@kontourai/flow-agents 2.4.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/.github/CODEOWNERS +8 -0
  2. package/.github/workflows/ci.yml +12 -0
  3. package/.github/workflows/trust-reconcile.yml +62 -4
  4. package/CHANGELOG.md +23 -0
  5. package/CONTEXT.md +21 -0
  6. package/build/src/cli/assignment-provider.d.ts +1 -0
  7. package/build/src/cli/assignment-provider.js +748 -0
  8. package/build/src/cli/effective-assignment-provider-settings.d.ts +1 -0
  9. package/build/src/cli/effective-assignment-provider-settings.js +125 -0
  10. package/build/src/cli/validate-workflow-artifacts.js +5 -1
  11. package/build/src/cli/workflow-sidecar.js +157 -110
  12. package/build/src/cli.js +6 -0
  13. package/build/src/tools/validate-source-tree.js +1 -0
  14. package/context/contracts/artifact-contract.md +2 -0
  15. package/context/contracts/assignment-provider-contract.md +239 -0
  16. package/context/contracts/builder-kit-workflow-state-contract.md +2 -0
  17. package/context/contracts/decision-registry-contract.md +2 -0
  18. package/context/contracts/delivery-contract.md +2 -0
  19. package/context/contracts/execution-contract.md +25 -0
  20. package/context/contracts/governance-adapter-contract.md +2 -0
  21. package/context/contracts/knowledge-store-contract.md +197 -0
  22. package/context/contracts/planning-contract.md +2 -0
  23. package/context/contracts/review-contract.md +2 -0
  24. package/context/contracts/sandbox-policy.md +2 -0
  25. package/context/contracts/standing-directives.md +13 -0
  26. package/context/contracts/verification-contract.md +2 -0
  27. package/context/contracts/work-item-contract.md +2 -0
  28. package/context/settings/assignment-provider-settings.json +33 -0
  29. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +180 -0
  30. package/docs/context-map.md +1 -0
  31. package/docs/decisions/index.md +3 -0
  32. package/docs/decisions/knowledge-store-provider.md +51 -0
  33. package/docs/decisions/model-routing.md +63 -0
  34. package/docs/decisions/standing-directives.md +66 -0
  35. package/docs/fixture-ownership.md +1 -0
  36. package/docs/workflow-shared-contracts.md +2 -1
  37. package/docs/workflow-usage-guide.md +6 -0
  38. package/evals/ci/run-baseline.sh +6 -0
  39. package/evals/fixtures/assignment-provider/actor-a.json +6 -0
  40. package/evals/fixtures/assignment-provider/actor-b.json +6 -0
  41. package/evals/fixtures/assignment-provider/github-issue-claimed.json +27 -0
  42. package/evals/fixtures/assignment-provider/github-issue-unassigned.json +7 -0
  43. package/evals/fixtures/assignment-provider/liveness-fresh.json +9 -0
  44. package/evals/fixtures/assignment-provider/liveness-stale.json +9 -0
  45. package/evals/integration/test_assignment_provider_github.sh +318 -0
  46. package/evals/integration/test_assignment_provider_local_file.sh +222 -0
  47. package/evals/integration/test_critique_supersession_roundtrip.sh +182 -0
  48. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  49. package/evals/integration/test_publish_delivery.sh +21 -4
  50. package/evals/integration/test_pull_work_assignment_join.sh +132 -0
  51. package/evals/integration/test_pull_work_liveness_preflight.sh +14 -6
  52. package/evals/integration/test_reconcile_soundness.sh +33 -9
  53. package/evals/integration/test_trust_reconcile.sh +9 -8
  54. package/evals/integration/test_trust_reconcile_negatives.sh +608 -0
  55. package/evals/integration/test_workflow_sidecar_writer.sh +79 -0
  56. package/evals/run.sh +10 -0
  57. package/evals/static/test_flowdef_codeowners_coverage.sh +6 -0
  58. package/evals/static/test_knowledge_providers.sh +23 -0
  59. package/kits/builder/skills/deliver/SKILL.md +19 -0
  60. package/kits/builder/skills/pull-work/SKILL.md +78 -10
  61. package/kits/knowledge/kit.json +35 -0
  62. package/kits/knowledge/providers/conformance/fixtures/git-repo/CONTEXT.md +12 -0
  63. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/old-sprocket-shape.md +13 -0
  64. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/sprocket-shape.md +14 -0
  65. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/widget-format.md +14 -0
  66. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/learnings/fixture-learning.md +7 -0
  67. package/kits/knowledge/providers/conformance/fixtures/work-item/issues.json +30 -0
  68. package/kits/knowledge/providers/conformance/suite.test.js +125 -0
  69. package/kits/knowledge/providers/git-repo/index.js +236 -0
  70. package/kits/knowledge/providers/health/health-pass.test.js +99 -0
  71. package/kits/knowledge/providers/health/index.js +153 -0
  72. package/kits/knowledge/providers/index.js +24 -0
  73. package/kits/knowledge/providers/lib/model.js +91 -0
  74. package/kits/knowledge/providers/lib/schema-validate.js +119 -0
  75. package/kits/knowledge/providers/markdown-vault/index.js +169 -0
  76. package/kits/knowledge/providers/work-item/index.js +204 -0
  77. package/package.json +4 -2
  78. package/schemas/assignment-provider-settings.schema.json +125 -0
  79. package/schemas/knowledge/edge.schema.json +54 -0
  80. package/schemas/knowledge/health-report.schema.json +45 -0
  81. package/schemas/knowledge/node.schema.json +49 -0
  82. package/schemas/knowledge/proposal.schema.json +53 -0
  83. package/scripts/ci/trust-reconcile.js +521 -24
  84. package/src/cli/assignment-provider.ts +845 -0
  85. package/src/cli/effective-assignment-provider-settings.ts +112 -0
  86. package/src/cli/validate-workflow-artifacts.ts +5 -1
  87. package/src/cli/workflow-sidecar.ts +147 -106
  88. package/src/cli.ts +6 -0
  89. package/src/tools/validate-source-tree.ts +1 -0
@@ -0,0 +1,112 @@
1
+ import * as child_process from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { parseArgs, flagString, flagBool } from "../lib/args.js";
7
+ import { readJson } from "../lib/fs.js";
8
+
9
+ // Mirrors src/cli/effective-backlog-settings.ts's loadSettings/currentRepo/merge/findProject/
10
+ // effective structure and its ask_user/configured result envelope exactly, pointed at the
11
+ // AssignmentProvider settings schema/example instead of the backlog equivalents (#290 Wave 2
12
+ // Task B). Deliberately NOT extracted into a shared helper with effective-backlog-settings.ts —
13
+ // see the #290 plan artifact's Unresolved Questions #3 for the recorded rationale (duplicating
14
+ // this file's small merge/repo-detection logic is lower-risk than refactoring a file outside
15
+ // this issue's declared scope).
16
+
17
+ const PROJECT_SETTINGS_RELATIVE_PATH = path.join("context", "settings", "assignment-provider-settings.json");
18
+
19
+ function loadSettings(file: string): Record<string, unknown> | null {
20
+ if (!fs.existsSync(file)) return null;
21
+ const data = readJson(file) as Record<string, unknown>;
22
+ if (data.schema_version !== "1.0") throw new Error(`${file}: unsupported schema_version ${String(data.schema_version)}`);
23
+ return data;
24
+ }
25
+
26
+ function repoFromText(text: string): { owner: string; name: string } | null {
27
+ const match = text.trim().match(/github\.com[:/]([^/]+)\/([^/.]+)(?:\.git)?$/);
28
+ return match ? { owner: match[1], name: match[2] } : null;
29
+ }
30
+
31
+ function currentRepo(repoPath: string): { owner: string; name: string } | null {
32
+ try {
33
+ const out = child_process.execFileSync("git", ["-C", repoPath, "remote", "get-url", "origin"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
34
+ const repo = repoFromText(out);
35
+ if (repo) return repo;
36
+ } catch {}
37
+ const packagePath = path.join(repoPath, "package.json");
38
+ if (fs.existsSync(packagePath)) {
39
+ const data = readJson(packagePath) as Record<string, unknown>;
40
+ const repository = data.repository;
41
+ const url = typeof repository === "object" && repository !== null ? (repository as Record<string, unknown>).url : repository;
42
+ if (typeof url === "string") return repoFromText(url);
43
+ }
44
+ return null;
45
+ }
46
+
47
+ function merge(base: unknown, override: unknown): Record<string, unknown> | null {
48
+ if (!base && !override) return null;
49
+ if (!base) return structuredClone(override) as Record<string, unknown>;
50
+ if (!override) return structuredClone(base) as Record<string, unknown>;
51
+ const out = structuredClone(base) as Record<string, unknown>;
52
+ for (const [key, value] of Object.entries(override as Record<string, unknown>)) {
53
+ out[key] = typeof value === "object" && value !== null && typeof out[key] === "object" && out[key] !== null && !Array.isArray(value)
54
+ ? merge(out[key], value)
55
+ : structuredClone(value);
56
+ }
57
+ return out;
58
+ }
59
+
60
+ function findProject(settings: Record<string, unknown> | null, repo: { owner: string; name: string }): Record<string, unknown> | null {
61
+ const projects = settings?.projects;
62
+ if (!Array.isArray(projects)) return null;
63
+ return (projects.find((project) => {
64
+ const projectRepo = (((project as Record<string, unknown>).project as Record<string, unknown> | undefined)?.repo ?? {}) as Record<string, unknown>;
65
+ return projectRepo.owner === repo.owner && projectRepo.name === repo.name;
66
+ }) as Record<string, unknown> | undefined) ?? null;
67
+ }
68
+
69
+ function defaultProjectSettingsPath(): string {
70
+ let cursor = path.dirname(fileURLToPath(import.meta.url));
71
+ while (true) {
72
+ const candidate = path.join(cursor, PROJECT_SETTINGS_RELATIVE_PATH);
73
+ if (fs.existsSync(candidate)) return candidate;
74
+ const parent = path.dirname(cursor);
75
+ if (parent === cursor) return path.resolve(PROJECT_SETTINGS_RELATIVE_PATH);
76
+ cursor = parent;
77
+ }
78
+ }
79
+
80
+ function effective(repoPath: string, projectSettings: string, globalSettings: string): [Record<string, unknown>, number] {
81
+ const repo = currentRepo(repoPath);
82
+ const projectDoc = loadSettings(projectSettings);
83
+ const globalDoc = loadSettings(globalSettings);
84
+ if (!repo) return [{ status: "ask_user", reason: "could_not_identify_current_repo", message: "Ask the user which assignment AssignmentProvider to use for this workspace.", resolution: { project_settings_path: projectSettings, global_settings_path: globalSettings } }, 2];
85
+ const effectiveSettings = merge(merge(merge(globalDoc?.defaults, findProject(globalDoc, repo)), projectDoc?.defaults), findProject(projectDoc, repo));
86
+ if (!effectiveSettings) return [{ status: "ask_user", reason: "no_assignment_provider_settings", message: "Ask the user which assignment AssignmentProvider to use before claiming work.", current_repo: repo, resolution: { project_settings_path: projectSettings, global_settings_path: globalSettings, checked: ["project", "global"] } }, 2];
87
+ return [{ status: "configured", current_repo: repo, source: findProject(projectDoc, repo) || projectDoc?.defaults ? "project" : "global", precedence: ["project.projects match", "project.defaults", "global.projects match", "global.defaults"], settings: effectiveSettings }, 0];
88
+ }
89
+
90
+ export function main(argv = process.argv.slice(2)): number {
91
+ const args = parseArgs(argv);
92
+ try {
93
+ const [result, code] = effective(
94
+ path.resolve(flagString(args.flags, "repo-path", ".") ?? "."),
95
+ path.resolve(flagString(args.flags, "project-settings", defaultProjectSettingsPath()) ?? ""),
96
+ path.resolve(flagString(args.flags, "global-settings", path.join(os.homedir(), ".config", "flow-agents", "assignment-provider-settings.json")) ?? ""),
97
+ );
98
+ if (flagBool(args.flags, "json")) console.log(JSON.stringify(result, null, 2));
99
+ else console.log(`status: ${String(result.status)}`);
100
+ return code;
101
+ } catch (error) {
102
+ console.error(`error: ${(error as Error).message}`);
103
+ return 1;
104
+ }
105
+ }
106
+
107
+ // Use process.exitCode (not process.exit) to allow stdout to be flushed before exit.
108
+ // Resolve real paths to handle symlinks (e.g. /tmp -> /private/tmp on macOS) so the
109
+ // entry-point guard fires correctly when the module is loaded directly as a script.
110
+ const _selfRealPath = (() => { try { return fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return fileURLToPath(import.meta.url); } })();
111
+ const _argv1RealPath = (() => { try { return fs.realpathSync(process.argv[1]); } catch { return process.argv[1]; } })();
112
+ if (_selfRealPath === _argv1RealPath) { process.exitCode = main(); }
@@ -395,7 +395,11 @@ function validateSidecarGroup(inputs: string[], markdown: string[], requireSidec
395
395
  if (bundleValue) {
396
396
  const claims = Array.isArray(bundleValue.claims) ? bundleValue.claims : [];
397
397
  const critiqueClaims = claims.filter((c: any) => c && c.claimType === "workflow.critique.review");
398
- if (critiqueClaims.some((c: any) => c.value === "fail" || c.status === "disputed")) issues.push({ path: trustBundlePath, message: "required critique must pass" });
398
+ // #282: a historical fail/disputed critique that has been explicitly superseded by a later
399
+ // resolving write (metadata.superseded_by) is retained structurally as history and does NOT
400
+ // block a top-level pass — only a LIVE (non-superseded) fail/disputed critique blocks.
401
+ const isSuperseded = (c: any) => c && c.metadata && typeof c.metadata === "object" && c.metadata.superseded_by;
402
+ if (critiqueClaims.some((c: any) => (c.value === "fail" || c.status === "disputed") && !isSuperseded(c))) issues.push({ path: trustBundlePath, message: "required critique must pass" });
399
403
  }
400
404
  }
401
405
  const acceptance = path.join(dir, "acceptance.json");
@@ -618,7 +618,11 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
618
618
  // (workflow-artifact-cleanup-audit) and validators can detect the promotion claim without a
619
619
  // new manifest entry. It rides alongside any waiver in a single merged metadata object.
620
620
  const promotionMeta = (check._promotion && typeof check._promotion === "object") ? check._promotion as AnyObj : null;
621
- const claimMetadata: AnyObj | null = (waiver || promotionMeta) ? { ...(waiver ? { waiver } : {}), ...(promotionMeta ? { promotion: promotionMeta } : {}) } : null;
621
+ // #268: stamp a stable origin discriminator so checksFromBundle / critiquesFromBundle can
622
+ // distinguish check vs critique vs acceptance claims across round-trips even under --flow-id,
623
+ // where all three collapse onto the same declared claimType (and a command-less critique claim
624
+ // would otherwise be re-absorbed as a test_output check → permanent [not-run] divergence).
625
+ const claimMetadata: AnyObj = { origin: "check", check_kind: String(check.kind ?? "external"), ...(waiver ? { waiver } : {}), ...(promotionMeta ? { promotion: promotionMeta } : {}) };
622
626
 
623
627
  const claimEvents: AnyObj[] = [];
624
628
  if (evStatus) {
@@ -678,12 +682,12 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
678
682
  if (declared) {
679
683
  // Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
680
684
  const declaredPolicy = ensurePolicy(declared.claimType, "high", []);
681
- const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id };
685
+ const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, metadata: { origin: "acceptance" } };
682
686
  const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
683
687
  claims.push({ ...declaredClaimObj, status: declaredStatus });
684
688
  } else {
685
689
  // No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
686
- const claimObj: AnyObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id };
690
+ const claimObj: AnyObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id, metadata: { origin: "acceptance" } };
687
691
  const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
688
692
  claims.push({ ...claimObj, status: derivedStatus });
689
693
  }
@@ -694,10 +698,23 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
694
698
  if (!c.id) continue;
695
699
  const subjectId = `${slug}/${c.id}`;
696
700
  const fieldOrBehavior = String(c.summary ?? c.verdict ?? c.id);
697
- const claimId = generateClaimId(subjectId, "flow-agents.workflow", fieldOrBehavior);
701
+ // #267/#282: a critique carrying `superseded_by` is retained as HISTORY — a prior write for
702
+ // this critique id that a later, same-reviewer critique resolved. It is preserved structurally
703
+ // (status "superseded" + first-class metadata.superseded_by), but is excluded from reconcile
704
+ // evaluation and from the "critique pass cannot include fail members" validator rule.
705
+ const supersededBy = typeof c.superseded_by === "string" && c.superseded_by.length > 0 ? c.superseded_by : null;
706
+ const critiqueReviewer = String(c.reviewer ?? "tool-code-reviewer");
707
+ const critiqueReviewedAt = String(c.reviewed_at ?? ts);
708
+ const critMeta: AnyObj = { origin: "critique", reviewer: critiqueReviewer, reviewed_at: critiqueReviewedAt, ...(supersededBy ? { superseded_by: supersededBy } : {}) };
709
+ // A superseded historical write gets a distinct, stable claimId so it co-exists with the live
710
+ // claim of the same critique id (never overwrites or duplicates it). The salt is reproducible
711
+ // across rebuilds because superseded_by + reviewed_at are preserved in metadata.
712
+ const claimIdSalt = supersededBy ? `${fieldOrBehavior}::superseded::${supersededBy}::${critiqueReviewedAt}` : fieldOrBehavior;
713
+ const claimId = generateClaimId(subjectId, "flow-agents.workflow", claimIdSalt);
698
714
  const legacyClaimType = "workflow.critique.review";
699
715
  const policy = ensurePolicy(legacyClaimType, "medium", []);
700
- const evStatus = critiqueToEventStatus(String(c.verdict ?? ""), c.findings ?? []);
716
+ // A superseded write emits NO verification event (its status is "superseded" directly).
717
+ const evStatus = supersededBy ? null : critiqueToEventStatus(String(c.verdict ?? ""), c.findings ?? []);
701
718
  const claimEvents: AnyObj[] = [];
702
719
  if (evStatus) {
703
720
  const evt: AnyObj = { id: `evt:${claimId}`, claimId, status: evStatus, actor: "flow-agents/workflow-sidecar", method: "validation", evidenceIds: [], createdAt: ts, verifiedAt: ts };
@@ -707,16 +724,15 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
707
724
 
708
725
  // P-d: declared-only when active flow/step present (shadow retired); no-flow path unchanged.
709
726
  const declared = matchExpectsEntry("critique");
710
- if (declared) {
711
- // Declared kit-typed claim only no legacy shadow (ADR 0016 P-d).
712
- const declaredPolicy = ensurePolicy(declared.claimType, "medium", []);
713
- const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: declaredPolicy.id };
714
- const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
715
- claims.push({ ...declaredClaimObj, status: declaredStatus });
727
+ const claimType = declared ? declared.claimType : legacyClaimType;
728
+ const subjectType = declared ? declared.subjectType : "workflow-critique";
729
+ const claimPolicy = declared ? ensurePolicy(declared.claimType, "medium", []) : policy;
730
+ const claimObj: AnyObj = { id: claimId, subjectType, subjectId, facet: "flow-agents.workflow", claimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: claimPolicy.id, metadata: critMeta };
731
+ if (supersededBy) {
732
+ // History: status is "superseded" directly (no verification event); excluded from evaluation.
733
+ claims.push({ ...claimObj, status: "superseded" });
716
734
  } else {
717
- // No active flow step only the workflow.* primary claim (legitimate no-flow fallback path).
718
- const claimObj: AnyObj = { id: claimId, subjectType: "workflow-critique", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: policy.id };
719
- const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
735
+ const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [claimPolicy] as Record<string, unknown>[] });
720
736
  claims.push({ ...claimObj, status: derivedStatus });
721
737
  }
722
738
  }
@@ -1439,96 +1455,99 @@ export function writeState(dir: string, slug: string, status: string, phase: str
1439
1455
  // Extract checks and critiques from the existing trust.bundle for callers that
1440
1456
  // need to rebuild the bundle (e.g. record-critique, record-learning).
1441
1457
 
1442
- // ADR 0016 Abstraction A (Step 0 Q3 carry-forward): build the set of declared
1443
- // claimTypes from the active flow step for the session at `dir`. When no active
1444
- // flow is present (workflow.* sessions), returns an empty set so every existing
1445
- // predicate is unchanged. When a FlowDefinition-driven session (builder.build)
1446
- // is active, the set contains the kit-typed claimTypes (e.g. "builder.verify.tests",
1447
- // "builder.verify.policy-compliance") so round-trip helpers broaden their filters
1448
- // to include declared claims alongside the legacy workflow.* ones.
1458
+ // #268/#344: buildTrustBundle stamps a stable origin discriminator ("check" | "acceptance" |
1459
+ // "critique") plus check_kind (for origin "check") on EVERY claim it writes. These stamps are
1460
+ // AUTHORITATIVE and the ONLY way checksFromBundle/critiquesFromBundle (and evidenceClean/
1461
+ // critiqueClean below) classify a claim.
1449
1462
  //
1450
- // Safety guard: current.json in the runtime artifact root records the CURRENTLY ACTIVE
1451
- // session via artifact_dir. If current.json points to a different session than `dir`
1452
- // (e.g. another session was the last to call advance-state --flow-definition), we
1453
- // return an empty set so declared-type predicates are NOT applied to the wrong session.
1454
- // This prevents a cross-session active_flow_id from broadening claim filters for
1455
- // unrelated sessions (which would cause spurious evidence/critique check behavior).
1456
- function declaredClaimTypesFor(dir: string): Set<string> {
1457
- const flowAgentsDir = path.dirname(dir);
1458
- // Verify that current.json points to `dir` before reading active flow step.
1459
- // If it points to a different session, return empty set (zero behavior change).
1460
- const currentFile = path.join(flowAgentsDir, "current.json");
1461
- try {
1462
- const current = JSON.parse(fs.readFileSync(currentFile, "utf8")) as Record<string, unknown>;
1463
- const artDir = typeof current["artifact_dir"] === "string" ? current["artifact_dir"] : null;
1464
- if (!artDir) return new Set<string>();
1465
- const resolvedCurrent = path.resolve(flowAgentsDir, artDir);
1466
- if (path.resolve(dir) !== resolvedCurrent) return new Set<string>();
1467
- } catch {
1468
- return new Set<string>();
1463
+ // Hard cutover (owner directive, no legacy fallbacks): there is deliberately no claimType-
1464
+ // derivation fallback for unstamped claims. A prior version of this file fell back to
1465
+ // classifying an unstamped claim by claimType heuristic that fallback WAS the #268 defect
1466
+ // kept reachable (a no-evidence declared claim silently re-absorbed as a command-less
1467
+ // test_output check, and a critique claim silently re-absorbed as a check, corrupting the
1468
+ // round-trip catastrophically under --flow-id). An unstamped claim means the bundle predates
1469
+ // #344 and must be regenerated, not silently reclassified — see requireStampedClaim below.
1470
+ function claimOrigin(claim: AnyObj): string | null {
1471
+ const md = claim && (claim as AnyObj).metadata;
1472
+ return md && typeof md === "object" && typeof (md as AnyObj).origin === "string" && (md as AnyObj).origin.length > 0 ? String((md as AnyObj).origin) : null;
1473
+ }
1474
+ // Fails loud — never silent, never a heuristic reclassification — when a claim in `dir`'s
1475
+ // trust.bundle lacks its metadata.origin stamp, or (for an origin==="check" claim) its
1476
+ // metadata.check_kind stamp. Names the session dir and the remedy so the caller can regenerate
1477
+ // a fresh, fully-stamped bundle instead of reading a pre-supersession one.
1478
+ function requireStampedClaim(claim: AnyObj, dir: string): string {
1479
+ if (!claim || typeof claim !== "object") die(`trust.bundle in ${dir} contains a malformed claim entry — cannot read.`);
1480
+ const remedy = `re-record evidence to regenerate: npm run workflow:sidecar -- record-evidence ${dir} --verdict <verdict> --check-json <...>`;
1481
+ const origin = claimOrigin(claim);
1482
+ if (!origin) {
1483
+ die(`pre-supersession trust.bundle in ${dir}: claim '${claim.id ?? "<unknown>"}' has no metadata.origin stamp (this bundle predates #344's origin/check_kind stamping and cannot be read authoritatively) — ${remedy}`);
1484
+ }
1485
+ if (origin === "check") {
1486
+ const md = (claim.metadata && typeof claim.metadata === "object") ? claim.metadata as AnyObj : {};
1487
+ if (typeof md.check_kind !== "string" || md.check_kind.length === 0) {
1488
+ die(`pre-supersession trust.bundle in ${dir}: check claim '${claim.id ?? "<unknown>"}' has metadata.origin but no metadata.check_kind stamp (this bundle predates #344's stamping and cannot be read authoritatively) — ${remedy}`);
1489
+ }
1469
1490
  }
1470
- const activeStep = resolveActiveFlowStep(flowAgentsDir);
1471
- if (!activeStep || activeStep.gateExpects.length === 0) return new Set<string>();
1472
- return new Set<string>(activeStep.gateExpects.map((e) => e.bundle_claim.claimType));
1491
+ return origin;
1473
1492
  }
1474
-
1475
- function checksFromBundle(dir: string, declaredClaimTypes: Set<string> = new Set()): AnyObj[] {
1493
+ function checksFromBundle(dir: string): AnyObj[] {
1476
1494
  const bundle = loadJson(path.join(dir, "trust.bundle"));
1477
- if (!Array.isArray(bundle.evidence)) return [];
1478
1495
  const allClaims: AnyObj[] = Array.isArray(bundle.claims) ? bundle.claims : [];
1496
+ // Validate stamps on every claim up front — any unstamped claim anywhere in the bundle marks
1497
+ // it pre-supersession, regardless of whether it is check/acceptance/critique-typed.
1498
+ for (const claim of allClaims) requireStampedClaim(claim, dir);
1499
+ if (!Array.isArray(bundle.evidence)) return [];
1479
1500
  const claimById = new Map<string, AnyObj>();
1480
1501
  for (const c of allClaims) if (c && c.id) claimById.set(c.id, c);
1481
1502
  const seen = new Set<string>();
1482
1503
  const checks: AnyObj[] = [];
1504
+ const kindOf = (claim: AnyObj): string => String((claim.metadata as AnyObj).check_kind);
1483
1505
  for (const ev of bundle.evidence) {
1484
1506
  if (!ev || !ev.claimId) continue;
1485
1507
  const claim = claimById.get(ev.claimId);
1486
1508
  if (!claim) continue;
1487
- const ct = String(claim.claimType || "");
1488
- // ADR 0016 Step 0: broaden to include declared kit-typed claims alongside workflow.check.*
1489
- if (!ct.startsWith("workflow.check.") && !declaredClaimTypes.has(ct)) continue;
1509
+ if (claimOrigin(claim) !== "check") continue;
1490
1510
  if (seen.has(ev.claimId)) continue;
1491
1511
  seen.add(ev.claimId);
1492
- const kind = ct.startsWith("workflow.check.") ? (ct.replace("workflow.check.", "") || "external") : (ct.split(".").pop() || "external");
1512
+ const kind = kindOf(claim);
1493
1513
  const status = claim.value ?? "not_verified";
1494
1514
  const check: AnyObj = { id: String(claim.subjectId || "").split("/").pop() || ev.claimId, kind, status, summary: claim.fieldOrBehavior || "" };
1495
1515
  if (ev.execution && typeof ev.execution.label === "string") check.command = ev.execution.label;
1496
1516
  if (ev.evidenceType) check.evidenceType = ev.evidenceType;
1497
1517
  checks.push(check);
1498
1518
  }
1499
- // Also include check claims that have no evidence item (surface_trust_refs style)
1519
+ // Also include check claims that have no evidence item (surface_trust_refs style).
1500
1520
  for (const claim of allClaims) {
1501
1521
  if (!claim) continue;
1502
- const ct = String(claim.claimType || "");
1503
- // ADR 0016 Step 0: broaden to include declared kit-typed claims alongside workflow.check.*
1504
- if (!ct.startsWith("workflow.check.") && !declaredClaimTypes.has(ct)) continue;
1522
+ if (claimOrigin(claim) !== "check") continue;
1505
1523
  if (seen.has(claim.id)) continue;
1506
1524
  seen.add(claim.id);
1507
- const kind = ct.startsWith("workflow.check.") ? (ct.replace("workflow.check.", "") || "external") : (ct.split(".").pop() || "external");
1525
+ const kind = kindOf(claim);
1508
1526
  checks.push({ id: String(claim.subjectId || "").split("/").pop() || claim.id, kind, status: claim.value ?? "not_verified", summary: claim.fieldOrBehavior || "" });
1509
1527
  }
1510
1528
  return checks;
1511
1529
  }
1512
- function critiquesFromBundle(dir: string, declaredClaimTypes: Set<string> = new Set()): AnyObj[] {
1530
+ function critiquesFromBundle(dir: string): AnyObj[] {
1513
1531
  const bundle = loadJson(path.join(dir, "trust.bundle"));
1514
1532
  if (!Array.isArray(bundle.claims)) return [];
1515
- // ADR 0016 Step 0: broaden to include declared kit-typed critique claims alongside workflow.critique.review.
1516
- // P-d: exclude claims that have evidence items (evidence = check claims, not critique claims).
1517
- // This prevents check-type declared claims (e.g. builder.verify.tests) from being read back
1518
- // as critiques when declaredClaimTypes includes all gate expects[] types.
1519
- const evidenceClaimIds = new Set<string>(
1520
- Array.isArray(bundle.evidence) ? bundle.evidence.map((e: AnyObj) => e?.claimId).filter((id: unknown): id is string => typeof id === "string") : []
1521
- );
1522
- const critiqueClaims = bundle.claims.filter((c: AnyObj) => c && (c.claimType === "workflow.critique.review" || declaredClaimTypes.has(c.claimType)) && !evidenceClaimIds.has(c.id));
1523
- return critiqueClaims.map((c: AnyObj) => ({
1524
- id: String(c.subjectId || "").split("/").pop() || c.id,
1525
- verdict: c.value ?? "not_verified",
1526
- summary: c.fieldOrBehavior || "",
1527
- findings: [],
1528
- reviewer: "tool-code-reviewer",
1529
- reviewed_at: c.updatedAt || c.createdAt || now(),
1530
- artifact_refs: [],
1531
- }));
1533
+ for (const c of bundle.claims) requireStampedClaim(c, dir);
1534
+ // A claim is a CRITIQUE when its origin is "critique" (authoritative see requireStampedClaim
1535
+ // above). reviewer / reviewed_at / superseded_by are read back from metadata so supersession
1536
+ // (#267/#282) round-trips losslessly.
1537
+ const critiqueClaims = bundle.claims.filter((c: AnyObj) => c && claimOrigin(c) === "critique");
1538
+ return critiqueClaims.map((c: AnyObj) => {
1539
+ const md = (c.metadata && typeof c.metadata === "object") ? c.metadata as AnyObj : {};
1540
+ return {
1541
+ id: String(c.subjectId || "").split("/").pop() || c.id,
1542
+ verdict: c.value ?? "not_verified",
1543
+ summary: c.fieldOrBehavior || "",
1544
+ findings: [],
1545
+ reviewer: typeof md.reviewer === "string" ? md.reviewer : "tool-code-reviewer",
1546
+ reviewed_at: typeof md.reviewed_at === "string" ? md.reviewed_at : (c.updatedAt || c.createdAt || now()),
1547
+ artifact_refs: [],
1548
+ ...(typeof md.superseded_by === "string" && md.superseded_by.length > 0 ? { superseded_by: md.superseded_by } : {}),
1549
+ };
1550
+ });
1532
1551
  }
1533
1552
  // ─────────────────────────────────────────────────────────────────────────────
1534
1553
  /**
@@ -1579,7 +1598,11 @@ async function recordEvidence(p: ReturnType<typeof parseArgs>): Promise<number>
1579
1598
  const _existingCriteria: AnyObj[] = Array.isArray(_existingAcceptance.criteria) ? _existingAcceptance.criteria : [];
1580
1599
  const _criteriaStatus = verdict === "pass" ? "pass" : verdict === "fail" ? "fail" : "not_verified";
1581
1600
  const _criteriaForBundle: AnyObj[] = _existingCriteria.map((c: AnyObj) => ({ ...c, status: _criteriaStatus }));
1582
- assertBundleWritten(await writeTrustBundle(dir, slug, ts, checks, _criteriaForBundle, []));
1601
+ // #268: preserve any existing critique claims (including superseded history) instead of dropping
1602
+ // them — record-evidence previously hardcoded critiques:[] here, silently erasing finding history
1603
+ // whenever it ran after a critique existed.
1604
+ const _existingCritiques: AnyObj[] = critiquesFromBundle(dir);
1605
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, checks, _criteriaForBundle, _existingCritiques));
1583
1606
  const stateStatus = verdict === "pass" ? "verified" : verdict === "fail" ? "failed" : "not_verified";
1584
1607
  writeState(dir, slug, stateStatus, "verification", ts, "Evidence recorded.");
1585
1608
  return 0;
@@ -1748,11 +1771,10 @@ async function promote(p: ReturnType<typeof parseArgs>): Promise<number> {
1748
1771
  // Add the promotion claim WITHOUT dropping the session's existing verification
1749
1772
  // evidence/criteria/critiques (mirror record-critique's merge pattern). Drop any prior
1750
1773
  // "promotion" check so re-running promote is idempotent rather than duplicating.
1751
- const _dct = declaredClaimTypesFor(dir);
1752
- const existingChecks = checksFromBundle(dir, _dct).filter((c) => c.id !== "promotion");
1774
+ const existingChecks = checksFromBundle(dir).filter((c) => c.id !== "promotion");
1753
1775
  const _acc = loadJson(path.join(dir, "acceptance.json"));
1754
1776
  const criteria: AnyObj[] = Array.isArray(_acc.criteria) ? _acc.criteria : [];
1755
- const critiques = critiquesFromBundle(dir, _dct);
1777
+ const critiques = critiquesFromBundle(dir);
1756
1778
  assertBundleWritten(await writeTrustBundle(dir, slug, ts, [...existingChecks, promotionCheck], criteria, critiques));
1757
1779
 
1758
1780
  // Auditable record of what was promoted where (companion to the trust.bundle claim).
@@ -1845,13 +1867,29 @@ async function recordCritique(p: ReturnType<typeof parseArgs>): Promise<number>
1845
1867
  // Fall back to critique.json for legacy sessions that still have it on disk.
1846
1868
  const existingCritiqueJson = loadJson(path.join(dir, "critique.json"), { critiques: [] });
1847
1869
  const legacyCritiques: AnyObj[] = Array.isArray(existingCritiqueJson.critiques) ? existingCritiqueJson.critiques : [];
1848
- const _dctCritique = declaredClaimTypesFor(dir);
1849
- const bundleCritiques = legacyCritiques.length === 0 ? critiquesFromBundle(dir, _dctCritique) : legacyCritiques;
1870
+ const bundleCritiques = legacyCritiques.length === 0 ? critiquesFromBundle(dir) : legacyCritiques;
1850
1871
  const critique = { id: opt(p, "id") || "review", reviewer: opt(p, "reviewer", "tool-code-reviewer"), reviewed_at: opt(p, "timestamp", now()), verdict: opt(p, "verdict", "pass"), summary: opt(p, "summary"), artifact_refs: opts(p, "artifact-ref"), findings: opts(p, "finding-json").map((v) => normalizeFinding(parseJson(v, "--finding-json"))) };
1851
- const critiques = [...bundleCritiques, critique];
1852
1872
  if (critique.verdict === "pass" && critique.findings.some((f: AnyObj) => f.status === "open")) die("required critique must pass");
1873
+ // #267/#282: supersede-by-critique-id. The latest write for a critique id wins for
1874
+ // reconcile / status / validator purposes; each prior LIVE write for the same id is RETAINED as
1875
+ // history (status "superseded", first-class metadata.superseded_by) but excluded from evaluation.
1876
+ // Supersession is REVIEWER-SCOPED (anti-gaming): a write may only supersede a prior live critique
1877
+ // of the same id written by the SAME reviewer — a different reviewer's disputed finding is never
1878
+ // buried, so it stays live and continues to block. DOCUMENTED GAP: reviewer identity is the
1879
+ // free-form --reviewer string (with a default); there is no cryptographic worker-vs-reviewer
1880
+ // distinction yet — that lands with the runtime actor-identity slice (#287/#290). Same-reviewer-
1881
+ // string scoping is the strongest honest enforcement available today and matches the granularity
1882
+ // the critique record already has.
1883
+ const _supersedeMarker = `${critique.id}@${critique.reviewed_at}`;
1884
+ const _mergedCritiques = bundleCritiques.map((e: AnyObj) => {
1885
+ const eSuperseded = typeof e.superseded_by === "string" && e.superseded_by.length > 0;
1886
+ const eReviewer = String(e.reviewer ?? "tool-code-reviewer");
1887
+ if (e.id === critique.id && !eSuperseded && eReviewer === critique.reviewer) return { ...e, superseded_by: _supersedeMarker };
1888
+ return e;
1889
+ });
1890
+ const critiques = [..._mergedCritiques, critique];
1853
1891
  // Phase 4c: build bundle from raw inputs; read checks from trust.bundle (evidence.json no longer written).
1854
- const _critiqueEvChecks: AnyObj[] = checksFromBundle(dir, _dctCritique);
1892
+ const _critiqueEvChecks: AnyObj[] = checksFromBundle(dir);
1855
1893
  const _critiqueAccCriteria: AnyObj[] = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
1856
1894
  assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueEvChecks, _critiqueAccCriteria, critiques));
1857
1895
  return 0;
@@ -2242,45 +2280,49 @@ async function recordLearning(p: ReturnType<typeof parseArgs>): Promise<number>
2242
2280
  writeJson(path.join(dir, "learning.json"), { ...sidecarBase(slug), status, updated_at: timestamp, records });
2243
2281
  writeState(dir, slug, "accepted", "learning", timestamp, opt(p, "summary"));
2244
2282
  // Phase 4c: build bundle from raw inputs; read checks/critiques from trust.bundle (bespoke sidecars no longer written).
2245
- // ADR 0016 Step 0: pass declaredClaimTypes so declared builder.* claims survive the round-trip.
2246
- const _dctLearning = declaredClaimTypesFor(dir);
2247
- const _learningChecks: AnyObj[] = checksFromBundle(dir, _dctLearning);
2283
+ // #268/#344: declared builder.* claims survive the round-trip via their authoritative origin stamp.
2284
+ const _learningChecks: AnyObj[] = checksFromBundle(dir);
2248
2285
  const _learningCriteria: AnyObj[] = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
2249
- const _learningCritiques: AnyObj[] = critiquesFromBundle(dir, _dctLearning);
2286
+ const _learningCritiques: AnyObj[] = critiquesFromBundle(dir);
2250
2287
  assertBundleWritten(await writeTrustBundle(dir, slug, timestamp, _learningChecks, _learningCriteria, _learningCritiques));
2251
2288
  return 0;
2252
2289
  }
2253
- function evidenceClean(dir: string, declaredClaimTypes: Set<string> = new Set()): boolean {
2254
- // Phase 4c: read from trust.bundle (sole verification artifact); fall back to evidence.json for legacy sessions.
2255
- // ADR 0016 Step 0: declaredClaimTypes broadens the filter to include kit-typed check claims
2256
- // (e.g. builder.verify.tests) in addition to workflow.check.* for FlowDefinition-driven sessions.
2290
+ function evidenceClean(dir: string): boolean {
2291
+ // Phase 4c: read from trust.bundle (sole verification artifact); fall back to evidence.json for
2292
+ // legacy (pre-bundle-era) sessions that never wrote a trust.bundle at all unrelated to origin
2293
+ // stamping. When a trust.bundle IS present, every claim must be stamped (requireStampedClaim);
2294
+ // there is no claimType-derivation fallback for an unstamped claim (#268/#344).
2257
2295
  const bundle = loadJson(path.join(dir, "trust.bundle"));
2258
2296
  if (Array.isArray(bundle.claims)) {
2259
- const checkClaims = (bundle.claims as AnyObj[]).filter((c: AnyObj) => {
2260
- if (!c) return false;
2261
- const ct = String(c.claimType || "");
2262
- return ct.startsWith("workflow.check.") || declaredClaimTypes.has(ct);
2263
- });
2297
+ for (const c of bundle.claims) requireStampedClaim(c, dir);
2298
+ const checkClaims = (bundle.claims as AnyObj[]).filter((c: AnyObj) => c && claimOrigin(c) === "check");
2264
2299
  if (checkClaims.length === 0) return false;
2265
2300
  return checkClaims.every((c: AnyObj) => {
2266
2301
  const v = String(c.value || "");
2267
2302
  return v === "pass" || v === "skip";
2268
2303
  });
2269
2304
  }
2270
- // Legacy fallback: evidence.json
2305
+ // Legacy fallback: evidence.json (pre-bundle-era sessions with no trust.bundle at all)
2271
2306
  const e = loadJson(path.join(dir, "evidence.json"), {});
2272
2307
  return e.verdict === "pass" && Array.isArray(e.checks) && e.checks.length > 0 && e.checks.every((c: AnyObj) => {
2273
2308
  if (!(c.status === "pass" || c.status === "skip")) return false;
2274
2309
  return !Array.isArray(c.standard_refs) || c.standard_refs.every((r: AnyObj) => ["junit", "sarif", "coverage", "veritas"].includes(r.standard));
2275
2310
  });
2276
2311
  }
2277
- function critiqueClean(dir: string, declaredClaimTypes: Set<string> = new Set()): boolean {
2278
- // Phase 4c: read from trust.bundle (sole verification artifact); fall back to critique.json for legacy sessions.
2279
- // ADR 0016 Step 0: declaredClaimTypes broadens the filter to include kit-typed critique claims
2280
- // (e.g. builder.verify.policy-compliance) in addition to workflow.critique.review.
2312
+ function critiqueClean(dir: string): boolean {
2313
+ // Phase 4c: read from trust.bundle (sole verification artifact); fall back to critique.json for
2314
+ // legacy (pre-bundle-era) sessions unrelated to origin stamping. When a trust.bundle IS
2315
+ // present, every claim must be stamped (requireStampedClaim); no claimType-derivation fallback
2316
+ // for an unstamped claim (#268/#344).
2281
2317
  const bundle = loadJson(path.join(dir, "trust.bundle"));
2282
2318
  if (Array.isArray(bundle.claims)) {
2283
- const critiqueClaims = (bundle.claims as AnyObj[]).filter((c: AnyObj) => c && (c.claimType === "workflow.critique.review" || declaredClaimTypes.has(c.claimType)));
2319
+ for (const c of bundle.claims) requireStampedClaim(c, dir);
2320
+ const critiqueClaims = (bundle.claims as AnyObj[]).filter((c: AnyObj) => {
2321
+ if (!c) return false;
2322
+ // #267/#282: superseded history is not evaluated for cleanliness.
2323
+ if (c.metadata && typeof c.metadata === "object" && (c.metadata as AnyObj).superseded_by) return false;
2324
+ return claimOrigin(c) === "critique";
2325
+ });
2284
2326
  if (critiqueClaims.length === 0) return false; // no critique written yet
2285
2327
  return critiqueClaims.every((c: AnyObj) => {
2286
2328
  const v = String(c.value || "");
@@ -2314,10 +2356,9 @@ async function dogfoodPass(p: ReturnType<typeof parseArgs>): Promise<number> {
2314
2356
  const checks = opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json")));
2315
2357
  if (checks.some((c) => c.status !== "pass" && c.status !== "skip")) die("clean evidence requires all non-skipped checks to pass");
2316
2358
  // Phase 4c: evidence check reads from trust.bundle (sole verification artifact); legacy evidence.json fallback in evidenceClean.
2317
- // ADR 0016 Step 0: pass declaredClaimTypes so builder.* check/critique claims count as clean evidence.
2318
- const _dctDogfood = declaredClaimTypesFor(dir);
2319
- const _hasBundleEvidence = fs.existsSync(path.join(dir, "trust.bundle")) && evidenceClean(dir, _dctDogfood);
2320
- const _hasLegacyEvidence = fs.existsSync(path.join(dir, "evidence.json")) && evidenceClean(dir, _dctDogfood);
2359
+ // #268/#344: builder.* check/critique claims count as clean evidence via their authoritative origin stamp.
2360
+ const _hasBundleEvidence = fs.existsSync(path.join(dir, "trust.bundle")) && evidenceClean(dir);
2361
+ const _hasLegacyEvidence = fs.existsSync(path.join(dir, "evidence.json")) && evidenceClean(dir);
2321
2362
  if (!_hasBundleEvidence && !_hasLegacyEvidence && fs.existsSync(path.join(dir, "trust.bundle"))) die("cannot mark clean without passing evidence");
2322
2363
  if (!_hasBundleEvidence && !_hasLegacyEvidence && !fs.existsSync(path.join(dir, "trust.bundle")) && fs.existsSync(path.join(dir, "evidence.json"))) die("cannot mark clean without passing evidence");
2323
2364
  if (!_hasBundleEvidence && !_hasLegacyEvidence && !fs.existsSync(path.join(dir, "trust.bundle")) && !fs.existsSync(path.join(dir, "evidence.json")) && checks.length === 0) die("cannot mark clean without passing evidence");
@@ -2325,9 +2366,9 @@ async function dogfoodPass(p: ReturnType<typeof parseArgs>): Promise<number> {
2325
2366
  const newCritiqueVerdict = opt(p, "critique-verdict", "pass");
2326
2367
  for (const value of opts(p, "finding-json")) normalizeFinding(parseJson(value, "--finding-json"));
2327
2368
  if (newCritiqueVerdict !== "pass") die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
2328
- if (!opt(p, "critique-id") && !critiqueClean(dir, _dctDogfood)) die("requires passing critique");
2369
+ if (!opt(p, "critique-id") && !critiqueClean(dir)) die("requires passing critique");
2329
2370
  // Phase 4c: if existing state has a dirty critique (in bundle or legacy critique.json), block even when adding a new critique-id.
2330
- if (!critiqueClean(dir, _dctDogfood) && (fs.existsSync(path.join(dir, "trust.bundle")) || fs.existsSync(path.join(dir, "critique.json")))) die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
2371
+ if (!critiqueClean(dir) && (fs.existsSync(path.join(dir, "trust.bundle")) || fs.existsSync(path.join(dir, "critique.json")))) die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
2331
2372
  }
2332
2373
  }
2333
2374
  const learningRecords = opts(p, "learning-record-json").map((v) => normalizeLearning(parseJson(v, "--learning-record-json"), opt(p, "timestamp", now())));
package/src/cli.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { basename } from "node:path";
3
3
  import { main as effectiveBacklogSettings } from "./cli/effective-backlog-settings.js";
4
+ import { main as effectiveAssignmentProviderSettings } from "./cli/effective-assignment-provider-settings.js";
5
+ import { main as assignmentProvider } from "./cli/assignment-provider.js";
4
6
  import { main as consoleLearningProjection } from "./cli/console-learning-projection.js";
5
7
  import { main as kit } from "./cli/kit.js";
6
8
  import { main as fixtureRetirementAudit } from "./cli/fixture-retirement-audit.js";
@@ -25,6 +27,8 @@ const availableCommands = new Map<string, (argv: string[]) => number | Promise<n
25
27
  ["build-bundles", () => buildBundles()],
26
28
  ["console-learning-projection", consoleLearningProjection],
27
29
  ["context-map", contextMap],
30
+ ["assignment-provider", assignmentProvider],
31
+ ["effective-assignment-provider-settings", effectiveAssignmentProviderSettings],
28
32
  ["effective-backlog-settings", effectiveBacklogSettings],
29
33
  ["fixture-retirement-audit", fixtureRetirementAudit],
30
34
  ["kit", kit],
@@ -48,6 +52,8 @@ const aliases = new Map<string, string>([
48
52
  ["flow-agents-build-bundles", "build-bundles"],
49
53
  ["flow-agents-console-learning-projection", "console-learning-projection"],
50
54
  ["flow-agents-context-map", "context-map"],
55
+ ["flow-agents-assignment-provider", "assignment-provider"],
56
+ ["flow-agents-effective-assignment-provider-settings", "effective-assignment-provider-settings"],
51
57
  ["flow-agents-effective-backlog-settings", "effective-backlog-settings"],
52
58
  ["flow-agents-fixture-retirement-audit", "fixture-retirement-audit"],
53
59
  ["flow-agents-kit", "kit"],
@@ -93,6 +93,7 @@ const hookFilePolicies = new Map<string, { category: string; requiredNeedles: st
93
93
  ["scripts/hooks/lib/resolve-formatter.js", { category: "shared hook library", requiredNeedles: ["resolveFormatter"] }],
94
94
  ]);
95
95
  const fixtureOwnerPolicies = new Map<string, { owners: string[]; classification: string }>([
96
+ ["evals/fixtures/assignment-provider", { owners: ["evals/integration/test_assignment_provider_local_file.sh", "evals/integration/test_assignment_provider_github.sh", "evals/integration/test_pull_work_assignment_join.sh"], classification: "AssignmentProvider local-file and GitHub render/status fixtures (#290)" }],
96
97
  ["evals/fixtures/backlog-provider-settings", { owners: ["evals/integration/test_effective_backlog_settings.sh"], classification: "settings precedence fixtures" }],
97
98
  ["evals/fixtures/builder-kit-workflow-state", { owners: ["evals/static/test_workflow_skills.sh"], classification: "Builder Kit workflow-state fixtures" }],
98
99
  ["evals/fixtures/console-learning-projection", { owners: ["evals/integration/test_console_learning_projection.sh"], classification: "console learning projection fixtures" }],