Skip to content

Commit 242df5a

Browse files
committed
feat: add runcontract quality dimensions
1 parent 87f20d4 commit 242df5a

2 files changed

Lines changed: 110 additions & 26 deletions

File tree

src/domain/run-contract.ts

Lines changed: 74 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ export type RunContractCompletionStatus = "not-started" | "in-progress" | "block
55
export type RunContractCriteriaStatus = "satisfied" | "needs-evidence" | "unknown";
66
export type RunContractQualityStatus = "ready" | "attention" | "blocked" | "unknown";
77
export type ScoringHintLevel = "ok" | "attention" | "blocked" | "unknown";
8+
export type RunContractQualityDimensionId = "alignment" | "evidence" | "completeness" | "simplicity" | "risk";
9+
export type RunContractQualityDimensionStatus = "pass" | "warn" | "fail";
810

911
export interface ScoringHint {
1012
id: string;
@@ -13,6 +15,14 @@ export interface ScoringHint {
1315
evidenceBacked: boolean;
1416
}
1517

18+
export interface RunContractQualityDimension {
19+
id: RunContractQualityDimensionId;
20+
label: string;
21+
status: RunContractQualityDimensionStatus;
22+
reasons: string[];
23+
evidenceBacked: boolean;
24+
}
25+
1626
export interface EvidenceExpectation {
1727
id: string;
1828
summary: string;
@@ -68,6 +78,7 @@ export interface RunContractView {
6878
quality: {
6979
status: RunContractQualityStatus;
7080
summary: string;
81+
dimensions: RunContractQualityDimension[];
7182
hints: ScoringHint[];
7283
};
7384
}
@@ -140,28 +151,66 @@ function isExpectationSatisfied(state: WorkflowState, expectation: EvidenceExpec
140151
}
141152

142153
export function buildQualityHints(state: WorkflowState, results: CompletionCriteriaResult[]): RunContractView["quality"] {
143-
const hints: ScoringHint[] = [];
154+
const dimensions = buildQualityDimensions(state, results);
155+
const hints = dimensionsToHints(dimensions);
156+
const status = summarizeQualityStatus(hints);
157+
return { status, summary: summarizeQuality(status, dimensions), dimensions, hints };
158+
}
159+
160+
export function buildQualityDimensions(state: WorkflowState, results: CompletionCriteriaResult[]): RunContractQualityDimension[] {
144161
const missingRequired = results.filter((result) => result.required && result.status === "needs-evidence");
145-
if (state.status === "blocked" || state.status === "failed") {
146-
hints.push({ id: "lifecycle-blocked", level: "blocked", reason: state.blocker ?? `Workflow lifecycle is ${state.status}; supervisor review should resolve the blocker before treating quality as ready.`, evidenceBacked: Boolean(state.blocker || state.evidence.length) });
147-
} else if (missingRequired.length) {
148-
hints.push({ id: "evidence-readiness", level: "attention", reason: `Required evidence is missing (${missingRequired.flatMap((result) => result.missingExpectationIds).join(", ")}); evidence-backed completion remains the target, not superficial score passing.`, evidenceBacked: false });
149-
} else if (state.evidence.length) {
150-
hints.push({ id: "evidence-readiness", level: "ok", reason: "Required evidence expectations are satisfied by recorded validation evidence; keep completion authority with workflow validation and review.", evidenceBacked: true });
151-
} else {
152-
hints.push({ id: "evidence-readiness", level: "unknown", reason: "No validation evidence is available yet; quality is unknown until evidence is recorded.", evidenceBacked: false });
153-
}
154-
155-
if (state.pendingDecision) {
156-
hints.push({ id: "pending-decision", level: "blocked", reason: `Pending decision remains unresolved: ${state.pendingDecision.prompt}`, evidenceBacked: false });
157-
}
158-
159-
if (state.risks.length) {
160-
hints.push({ id: "unresolved-risk", level: "attention", reason: `Unresolved risks remain (${state.risks.length}); do not optimize this hint away without addressing the underlying risk evidence.`, evidenceBacked: state.evidence.length > 0 });
161-
}
162+
const missingExpectationIds = missingRequired.flatMap((result) => result.missingExpectationIds);
163+
const hasBlockingLifecycle = state.status === "blocked" || state.status === "failed";
164+
const hasTerminalFailure = state.status === "failed" || state.status === "cancelled";
165+
const goal = typeof state.task === "string" ? state.task.trim() : "";
166+
const simplicityRisk = state.risks.find((risk) => /abstraction|complex|complicated|scope|broad|clever|over[- ]?engineer/i.test(risk));
162167

163-
const status = summarizeQualityStatus(hints);
164-
return { status, summary: summarizeQuality(status, hints), hints };
168+
return [
169+
qualityDimension("alignment", "Alignment", hasTerminalFailure ? "fail" : goal ? "pass" : "warn", [
170+
hasTerminalFailure ? `Workflow lifecycle is ${state.status}; confirm the current work still aligns with the requested goal before continuing.` : goal ? "Workflow goal is present and remains tied to the generic RunContract source workflow." : "Workflow goal is missing or empty; advisory alignment cannot be confirmed.",
171+
], state.evidence.some((evidence) => Boolean(evidence.acceptanceCriteria?.length))),
172+
qualityDimension("evidence", "Evidence", missingRequired.length ? "warn" : state.evidence.length ? "pass" : "warn", [
173+
missingRequired.length ? `Required evidence is missing (${missingExpectationIds.join(", ")}); evidence quality is degraded without changing completion authority.` : state.evidence.length ? "Required evidence expectations are satisfied by recorded validation evidence." : "No validation evidence is available yet; record concrete evidence before treating quality as ready.",
174+
], !missingRequired.length && state.evidence.length > 0),
175+
qualityDimension("completeness", "Completeness", hasBlockingLifecycle ? "fail" : state.pendingDecision || missingRequired.length ? "warn" : "pass", [
176+
hasBlockingLifecycle ? state.blocker ?? `Workflow lifecycle is ${state.status}; resolve the blocker before considering the work complete.` : state.pendingDecision ? `Pending decision remains unresolved: ${state.pendingDecision.prompt}` : missingRequired.length ? "Completion criteria still need required evidence; validation remains authoritative." : "No pending decision or missing required completion evidence is visible.",
177+
], !missingRequired.length && state.evidence.length > 0),
178+
qualityDimension("simplicity", "Simplicity", simplicityRisk ? "warn" : "pass", [
179+
simplicityRisk ? `Risk calls out possible complexity or over-broad scope: ${simplicityRisk}` : "No explicit complexity, scope, or over-engineering risk is recorded.",
180+
], state.evidence.length > 0),
181+
qualityDimension("risk", "Risk", hasBlockingLifecycle ? "fail" : state.risks.length || state.pendingDecision ? "warn" : "pass", [
182+
hasBlockingLifecycle ? state.blocker ?? `Workflow lifecycle is ${state.status}; risk remains blocking.` : state.risks.length ? `Unresolved risks remain (${state.risks.length}); address the underlying risk evidence rather than optimizing the hint.` : state.pendingDecision ? "Pending decision keeps residual risk open until resolved." : "No unresolved risks or blockers are recorded.",
183+
], state.evidence.length > 0 || Boolean(state.blocker)),
184+
];
185+
}
186+
187+
function qualityDimension(id: RunContractQualityDimensionId, label: string, status: RunContractQualityDimensionStatus, reasons: string[], evidenceBacked: boolean): RunContractQualityDimension {
188+
return { id, label, status, reasons, evidenceBacked };
189+
}
190+
191+
function dimensionsToHints(dimensions: RunContractQualityDimension[]): ScoringHint[] {
192+
return dimensions
193+
.filter((dimension) => dimension.status !== "pass" || dimension.id === "evidence")
194+
.map((dimension) => ({
195+
id: hintIdForDimension(dimension),
196+
level: hintLevelForDimension(dimension),
197+
reason: `${dimension.label}: ${dimension.reasons.join(" ")}`,
198+
evidenceBacked: dimension.evidenceBacked,
199+
}));
200+
}
201+
202+
function hintIdForDimension(dimension: RunContractQualityDimension): string {
203+
if (dimension.id === "evidence") return "evidence-readiness";
204+
if (dimension.id === "risk") return dimension.status === "fail" ? "lifecycle-blocked" : "unresolved-risk";
205+
if (dimension.id === "completeness" && dimension.reasons.some((reason) => reason.startsWith("Pending decision"))) return "pending-decision";
206+
return `${dimension.id}-dimension`;
207+
}
208+
209+
function hintLevelForDimension(dimension: RunContractQualityDimension): ScoringHintLevel {
210+
if (dimension.status === "fail") return "blocked";
211+
if (hintIdForDimension(dimension) === "pending-decision") return "blocked";
212+
if (dimension.status === "warn") return "attention";
213+
return "ok";
165214
}
166215

167216
function summarizeQualityStatus(hints: ScoringHint[]): RunContractQualityStatus {
@@ -171,9 +220,11 @@ function summarizeQualityStatus(hints: ScoringHint[]): RunContractQualityStatus
171220
return "ready";
172221
}
173222

174-
function summarizeQuality(status: RunContractQualityStatus, hints: ScoringHint[]): string {
175-
if (status === "ready") return "Advisory quality hints are ready; evidence-backed workflow validation remains authoritative.";
176-
return `Advisory quality hints need ${status === "blocked" ? "blocker resolution" : status}; ${hints.length} hint(s) explain evidence-backed readiness without becoming completion authority.`;
223+
function summarizeQuality(status: RunContractQualityStatus, dimensions: RunContractQualityDimension[]): string {
224+
const failing = dimensions.filter((dimension) => dimension.status === "fail").length;
225+
const warning = dimensions.filter((dimension) => dimension.status === "warn").length;
226+
if (status === "ready") return "All five advisory quality dimensions pass; evidence-backed workflow validation remains authoritative.";
227+
return `Five advisory quality dimensions need attention (${failing} fail, ${warning} warn); signals explain quality without becoming completion authority.`;
177228
}
178229

179230
function projectPendingDecision(pendingDecision: NonNullable<WorkflowState["pendingDecision"]>): NonNullable<WorkflowState["pendingDecision"]> {

test/run-contract.test.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,13 @@ test("projects existing workflow state into a generic RunContract view", () => {
3535
assert.equal(view.expectations.find((expectation) => expectation.id === "evidence:any")?.required, true);
3636
assert.equal(view.completion.results.find((result) => result.id === "required-evidence")?.status, "needs-evidence");
3737
assert.equal(view.quality.status, "blocked");
38+
assert.deepEqual(view.quality.dimensions.map((dimension) => dimension.id), ["alignment", "evidence", "completeness", "simplicity", "risk"]);
39+
assert.equal(view.quality.dimensions.find((dimension) => dimension.id === "evidence")?.status, "warn");
40+
assert.equal(view.quality.dimensions.find((dimension) => dimension.id === "completeness")?.status, "warn");
41+
assert.equal(view.quality.dimensions.find((dimension) => dimension.id === "risk")?.status, "warn");
3842
assert.equal(view.quality.hints.find((hint) => hint.id === "evidence-readiness")?.level, "attention");
3943
assert.equal(view.quality.hints.find((hint) => hint.id === "pending-decision")?.level, "blocked");
40-
assert.match(view.quality.summary, /Advisory quality hints/);
44+
assert.match(view.quality.summary, /Five advisory quality dimensions/);
4145
});
4246

4347
test("projection stays derived from existing state and does not alias mutable arrays", () => {
@@ -94,7 +98,9 @@ test("projection exposes completion and advisory quality surfaces", () => {
9498
const completeView = projectRunContract({ ...completed, artifacts: [], evidence: [{ at: now, kind: "review" as const, summary: "accepted", verdict: "pass" as const }] }, getWorkflowDefinition(completed.workflowId));
9599
assert.equal(completeView.completion.status, "complete");
96100
assert.equal(completeView.quality.status, "ready");
97-
assert.match(completeView.quality.hints[0]?.reason ?? "", /evidence expectations are satisfied/);
101+
assert.equal(completeView.quality.dimensions.find((dimension) => dimension.id === "evidence")?.status, "pass");
102+
assert.equal(completeView.quality.hints.find((hint) => hint.id === "evidence-readiness")?.level, "ok");
103+
assert.match(completeView.quality.hints.find((hint) => hint.id === "evidence-readiness")?.reason ?? "", /evidence expectations are satisfied/);
98104
assert.equal(projectRunContract(blocked, getWorkflowDefinition(blocked.workflowId)).completion.summary, "waiting for approval");
99105
assert.equal(projectRunContract(blocked, getWorkflowDefinition(blocked.workflowId)).quality.status, "blocked");
100106
});
@@ -114,6 +120,33 @@ test("advisory quality hints surface stale evidence and risk without becoming co
114120

115121
assert.equal(view.completion.status, "complete");
116122
assert.equal(view.quality.status, "attention");
123+
assert.equal(view.quality.dimensions.find((dimension) => dimension.id === "risk")?.status, "warn");
124+
assert.equal(view.quality.dimensions.find((dimension) => dimension.id === "simplicity")?.status, "warn");
117125
assert.equal(view.quality.hints.find((hint) => hint.id === "unresolved-risk")?.level, "attention");
118-
assert.match(view.quality.hints.find((hint) => hint.id === "unresolved-risk")?.reason ?? "", /do not optimize this hint away/);
126+
assert.match(view.quality.hints.find((hint) => hint.id === "unresolved-risk")?.reason ?? "", /address the underlying risk evidence/);
127+
});
128+
129+
test("five advisory quality dimensions warn and fail without changing completion authority", () => {
130+
const missingEvidence = {
131+
...createWorkflowState({ workspace: "/workspace/local-notes", workflowId: "kapi-deep-interview", task: "Summarize interview notes", now }),
132+
pendingDecision: undefined,
133+
artifacts: [],
134+
};
135+
const missingView = projectRunContract(missingEvidence, getWorkflowDefinition(missingEvidence.workflowId));
136+
assert.equal(missingView.completion.status, "needs-evidence");
137+
assert.equal(missingView.quality.dimensions.find((dimension) => dimension.id === "evidence")?.status, "warn");
138+
assert.equal(missingView.quality.dimensions.find((dimension) => dimension.id === "completeness")?.status, "warn");
139+
140+
const blocked = {
141+
...missingEvidence,
142+
status: "blocked" as const,
143+
blocker: "local operator decision needed",
144+
risks: ["scope is too broad for one pass"],
145+
evidence: [{ at: now, kind: "manual" as const, summary: "operator noted blocker", verdict: "recorded" as const }],
146+
};
147+
const blockedView = projectRunContract(blocked, getWorkflowDefinition(blocked.workflowId));
148+
assert.equal(blockedView.completion.status, "blocked");
149+
assert.equal(blockedView.quality.dimensions.find((dimension) => dimension.id === "completeness")?.status, "fail");
150+
assert.equal(blockedView.quality.dimensions.find((dimension) => dimension.id === "risk")?.status, "fail");
151+
assert.equal(blockedView.quality.dimensions.find((dimension) => dimension.id === "simplicity")?.status, "warn");
119152
});

0 commit comments

Comments
 (0)