@kontourai/flow-agents 3.6.0 → 3.7.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.
- package/CHANGELOG.md +7 -0
- package/build/src/builder-flow-run-adapter.d.ts +22 -2
- package/build/src/builder-flow-run-adapter.js +93 -28
- package/build/src/builder-flow-runtime.d.ts +8 -3
- package/build/src/builder-flow-runtime.js +308 -47
- package/build/src/cli/assignment-provider.d.ts +4 -7
- package/build/src/cli/assignment-provider.js +67 -13
- package/build/src/cli/builder-run.js +14 -18
- package/build/src/cli/workflow-sidecar.d.ts +28 -4
- package/build/src/cli/workflow-sidecar.js +801 -97
- package/build/src/cli/workflow.js +290 -42
- package/build/src/flow-kit/validate.d.ts +17 -0
- package/build/src/flow-kit/validate.js +340 -2
- package/build/src/index.js +5 -5
- package/build/src/lib/observed-command.d.ts +7 -0
- package/build/src/lib/observed-command.js +100 -0
- package/build/src/tools/generate-context-map.js +5 -3
- package/context/scripts/hooks/lib/kit-catalog.js +1 -1
- package/context/scripts/hooks/stop-goal-fit.js +78 -9
- package/docs/context-map.md +22 -20
- package/docs/developer-architecture.md +1 -1
- package/docs/public-workflow-cli.md +76 -7
- package/docs/skills-map.md +51 -27
- package/docs/spec/builder-flow-runtime.md +41 -40
- package/docs/workflow-usage-guide.md +109 -42
- package/evals/fixtures/hook-influence/cases.json +2 -2
- package/evals/integration/test_builder_entry_enforcement.sh +52 -41
- package/evals/integration/test_builder_step_producers.sh +297 -6
- package/evals/integration/test_bundle_install.sh +212 -63
- package/evals/integration/test_critique_supersession_roundtrip.sh +21 -8
- package/evals/integration/test_current_json_per_actor.sh +12 -0
- package/evals/integration/test_dual_emit_flow_step.sh +62 -43
- package/evals/integration/test_flowdef_session_activation.sh +145 -25
- package/evals/integration/test_flowdef_session_history_preservation.sh +23 -21
- package/evals/integration/test_gate_lockdown.sh +3 -5
- package/evals/integration/test_goal_fit_hook.sh +60 -2
- package/evals/integration/test_liveness_verdict.sh +14 -17
- package/evals/integration/test_phase_map_and_gate_claim.sh +63 -38
- package/evals/integration/test_public_workflow_cli.sh +325 -11
- package/evals/integration/test_pull_work_liveness_preflight.sh +22 -66
- package/evals/integration/test_sidecar_field_preservation.sh +36 -11
- package/evals/integration/test_workflow_sidecar_writer.sh +277 -44
- package/evals/integration/test_workflow_steering_hook.sh +15 -38
- package/evals/run.sh +2 -0
- package/evals/static/test_builder_skill_coherence.sh +247 -0
- package/evals/static/test_library_exports.sh +5 -2
- package/evals/static/test_workflow_skills.sh +13 -325
- package/kits/builder/flows/build.flow.json +22 -0
- package/kits/builder/flows/shape.flow.json +9 -9
- package/kits/builder/kit.json +70 -16
- package/kits/builder/skills/builder-shape/SKILL.md +75 -75
- package/kits/builder/skills/continue-work/SKILL.md +45 -106
- package/kits/builder/skills/deliver/SKILL.md +96 -442
- package/kits/builder/skills/design-probe/SKILL.md +40 -139
- package/kits/builder/skills/evidence-gate/SKILL.md +59 -201
- package/kits/builder/skills/execute-plan/SKILL.md +54 -125
- package/kits/builder/skills/fix-bug/SKILL.md +42 -132
- package/kits/builder/skills/gate-review/SKILL.md +60 -211
- package/kits/builder/skills/idea-to-backlog/SKILL.md +63 -244
- package/kits/builder/skills/learning-review/SKILL.md +63 -170
- package/kits/builder/skills/pickup-probe/SKILL.md +54 -111
- package/kits/builder/skills/plan-work/SKILL.md +51 -185
- package/kits/builder/skills/pull-work/SKILL.md +136 -485
- package/kits/builder/skills/release-readiness/SKILL.md +66 -107
- package/kits/builder/skills/review-work/SKILL.md +89 -176
- package/kits/builder/skills/tdd-workflow/SKILL.md +53 -147
- package/kits/builder/skills/verify-work/SKILL.md +101 -113
- package/package.json +2 -2
- package/scripts/hooks/lib/kit-catalog.js +1 -1
- package/scripts/hooks/stop-goal-fit.js +78 -9
- package/src/builder-flow-run-adapter.ts +118 -32
- package/src/builder-flow-runtime.ts +331 -61
- package/src/cli/assignment-provider-lock.test.mjs +83 -0
- package/src/cli/assignment-provider.ts +62 -13
- package/src/cli/builder-flow-run-adapter.test.mjs +4 -2
- package/src/cli/builder-flow-runtime.test.mjs +194 -8
- package/src/cli/builder-run.ts +3 -9
- package/src/cli/kit-metadata-security.test.mjs +232 -6
- package/src/cli/public-api.test.mjs +15 -0
- package/src/cli/workflow-sidecar-execution-proof.test.mjs +90 -0
- package/src/cli/workflow-sidecar.ts +724 -97
- package/src/cli/workflow.ts +277 -40
- package/src/flow-kit/validate.ts +320 -2
- package/src/index.ts +6 -5
- package/src/lib/observed-command.ts +96 -0
- package/src/tools/generate-context-map.ts +5 -3
|
@@ -7,12 +7,13 @@ import { createHash } from "node:crypto";
|
|
|
7
7
|
import { createRequire } from "node:module";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
// ADR 0016 Abstraction A: shared FlowDefinition resolver (P-a)
|
|
10
|
-
import { resolveActiveFlowStep, resolveAllFlowGateExpects, resolveFlowFilePath, resolvePhaseMap, resolveRouteBackPolicy, type ActiveFlowStep } from "../lib/flow-resolver.js";
|
|
10
|
+
import { resolveActiveFlowStep, resolveAllFlowGateExpects, resolveFlowFilePath, resolveFlowStep, resolvePhaseMap, resolveRouteBackPolicy, type ActiveFlowStep } from "../lib/flow-resolver.js";
|
|
11
11
|
import { defaultArtifactRootForRead, flowAgentsArtifactRoot } from "../lib/local-artifact-root.js";
|
|
12
12
|
import { ensureSafeDirectory } from "../lib/fs.js";
|
|
13
|
-
import { flowAgentsPackageVersion } from "../lib/package-version.js";
|
|
13
|
+
import { flowAgentsPackageRoot, flowAgentsPackageVersion } from "../lib/package-version.js";
|
|
14
14
|
import { pinnedFlowAgentsCommand } from "../lib/pinned-cli-command.js";
|
|
15
|
-
import {
|
|
15
|
+
import { runObservedCommand } from "../lib/observed-command.js";
|
|
16
|
+
import { captureReviewWorkspaceSnapshot, startBuilderFlowSession, syncBuilderFlowSession } from "../builder-flow-runtime.js";
|
|
16
17
|
// #291 Wave 1 Task 1.1 exports: ensure-session's ownership guard reuses the EXACT same
|
|
17
18
|
// assignment ⋈ liveness join / claim / supersede logic #290 already ships for the
|
|
18
19
|
// `assignment-provider` CLI, rather than reimplementing a second, parallel join (static ESM
|
|
@@ -27,9 +28,20 @@ export const checkKinds = new Set(["build", "types", "lint", "test", "command",
|
|
|
27
28
|
export const checkStatuses = new Set(["pass", "fail", "not_verified", "skip"]);
|
|
28
29
|
export const verdicts = new Set(["pass", "partial", "fail", "not_verified"]);
|
|
29
30
|
export const WORKFLOW_WRITER_CONTRACT_VERSION = "1.0";
|
|
31
|
+
const PUBLIC_WORKFLOW_AUTHORITY = Symbol("public-workflow-authority");
|
|
30
32
|
|
|
31
33
|
function now(): string { return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); }
|
|
32
34
|
function read(file: string): string { return fs.readFileSync(file, "utf8"); }
|
|
35
|
+
function readRegularFileNoFollow(file: string, label: string): Buffer {
|
|
36
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
37
|
+
try {
|
|
38
|
+
const stat = fs.fstatSync(fd);
|
|
39
|
+
if (!stat.isFile()) die(`${label} must be a regular file`);
|
|
40
|
+
return fs.readFileSync(fd);
|
|
41
|
+
} finally {
|
|
42
|
+
fs.closeSync(fd);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
33
45
|
export function writeJson(file: string, payload: AnyObj): void { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`); }
|
|
34
46
|
// Single-line but readable "key": "value" form. Built by collapsing the
|
|
35
47
|
// structural whitespace from an indented stringify — corruption-proof, unlike a
|
|
@@ -51,7 +63,13 @@ function slugify(value: string, fallback: string): string { return value.toLower
|
|
|
51
63
|
* Reuses slugify() for normalization. Validates that the id is a numeric GitHub issue number. */
|
|
52
64
|
function workItemSlug(ref: string): string {
|
|
53
65
|
const hashIdx = ref.indexOf("#");
|
|
54
|
-
if (hashIdx < 0
|
|
66
|
+
if (hashIdx < 0) {
|
|
67
|
+
if (!/^[a-z][a-z0-9-]*:[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(ref) || ref.includes("..")) {
|
|
68
|
+
die("--work-item must be a provider-neutral provider:id ref or owner/repo#numeric-id");
|
|
69
|
+
}
|
|
70
|
+
return slugify(ref, "work-item");
|
|
71
|
+
}
|
|
72
|
+
if (hashIdx === ref.length - 1) die("--work-item must be in owner/repo#numeric-id format");
|
|
55
73
|
const repoPath = ref.slice(0, hashIdx);
|
|
56
74
|
const id = ref.slice(hashIdx + 1);
|
|
57
75
|
if (!/^\d+$/.test(id)) die("--work-item id must be a numeric issue number");
|
|
@@ -63,8 +81,7 @@ function workItemSlug(ref: string): string {
|
|
|
63
81
|
|
|
64
82
|
function assignmentSubjectMatchesWorkItem(slug: string, ref: string): boolean {
|
|
65
83
|
if (ref === `local:${slug}`) return true;
|
|
66
|
-
|
|
67
|
-
return workItemSlug(ref) === slug;
|
|
84
|
+
try { return workItemSlug(ref) === slug; } catch { return false; }
|
|
68
85
|
}
|
|
69
86
|
|
|
70
87
|
type SessionWorkItem = {
|
|
@@ -632,10 +649,10 @@ export function reduceCaptureLogByCommand(commandLog: AnyObj[] | undefined): Map
|
|
|
632
649
|
* @param timestamp ISO-8601 timestamp for createdAt / updatedAt / observedAt
|
|
633
650
|
* @param checks Normalized check objects (from record-evidence --check-json / --surface-trust-json)
|
|
634
651
|
* @param criteria Acceptance criteria objects (from acceptance.json .criteria array)
|
|
635
|
-
* @param critiques Critique objects
|
|
652
|
+
* @param critiques Critique objects reconstructed from trust.bundle claims
|
|
636
653
|
* @param commandLog Optional parsed command-log.jsonl entries (capture-authoritative fold)
|
|
637
654
|
*/
|
|
638
|
-
export async function buildTrustBundle(slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], commandLog?: AnyObj[], flowAgentsDir?: string, actorKey?: string): Promise<AnyObj | null> {
|
|
655
|
+
export async function buildTrustBundle(slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], commandLog?: AnyObj[], flowAgentsDir?: string, actorKey?: string, exactFlowContext?: { flowId: string; stepId: string }): Promise<AnyObj | null> {
|
|
639
656
|
const surface = await tryLoadSurface();
|
|
640
657
|
if (!surface) return null;
|
|
641
658
|
const { deriveClaimStatus, generateClaimId, statusFunctionVersion } = surface;
|
|
@@ -648,7 +665,10 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
648
665
|
// #291 Wave 2 Task 2.1 (§7)/Task 2.2: actorKey (when the caller already resolved one — see
|
|
649
666
|
// writeTrustBundle below) threads through to resolveActiveFlowStep's per-actor-first,
|
|
650
667
|
// legacy-fallback current.json read; omitted, this is IDENTICAL to pre-#291 behavior.
|
|
651
|
-
const
|
|
668
|
+
const exactRepoRoot = flowAgentsDir ? findRepoRootFromDir(path.dirname(flowAgentsDir)) : null;
|
|
669
|
+
const activeStep: ActiveFlowStep | null = exactFlowContext && exactRepoRoot
|
|
670
|
+
? resolveFlowStep(exactFlowContext.flowId, exactFlowContext.stepId, exactRepoRoot)
|
|
671
|
+
: flowAgentsDir ? resolveActiveFlowStep(flowAgentsDir, actorKey) : null;
|
|
652
672
|
|
|
653
673
|
// #270 CRITICAL/HIGH fix: resolve the session's active_flow_id independent of whether the
|
|
654
674
|
// CURRENTLY-active step resolves — a stamped gate claim names the STEP IT WAS ORIGINALLY
|
|
@@ -658,6 +678,7 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
658
678
|
// the gate claim was recorded at — the validation must be against the FULL flow definition
|
|
659
679
|
// (every step's gate expects[], via resolveAllFlowGateExpects), not just the active one.
|
|
660
680
|
const sessionFlowId: string | null = (() => {
|
|
681
|
+
if (exactFlowContext) return exactFlowContext.flowId;
|
|
661
682
|
if (!flowAgentsDir) return null;
|
|
662
683
|
try {
|
|
663
684
|
const pointer = loadCurrentPointerHelper().readCurrentPointer(flowAgentsDir, actorKey);
|
|
@@ -945,6 +966,11 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
945
966
|
const outputDigestMeta = typeof check._output_sha256 === "string" && check._output_sha256.length > 0
|
|
946
967
|
? { algorithm: "sha256", hex: check._output_sha256 }
|
|
947
968
|
: null;
|
|
969
|
+
const observedCommandsMeta = Array.isArray(check._observed_commands)
|
|
970
|
+
? check._observed_commands
|
|
971
|
+
.filter((entry: AnyObj) => typeof entry?.command === "string" && typeof entry?.exit_code === "number" && typeof entry?.output_sha256 === "string")
|
|
972
|
+
.map((entry: AnyObj) => ({ command: entry.command, exit_code: entry.exit_code, output_sha256: entry.output_sha256, ...(Number.isSafeInteger(entry.test_count) ? { test_count: entry.test_count } : {}), ...(entry.execution_proof && typeof entry.execution_proof === "object" ? { execution_proof: entry.execution_proof } : {}) }))
|
|
973
|
+
: null;
|
|
948
974
|
// #270(a)/(c): a gate claim's declared claimType/subjectType, once resolved (either freshly
|
|
949
975
|
// via matchExpectsEntry below, or restored from a prior write's metadata.gate_claim stamp by
|
|
950
976
|
// checksFromBundle), is stamped here so it is frozen at record time — a later bundle rebuild
|
|
@@ -974,11 +1000,15 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
974
1000
|
origin: "check",
|
|
975
1001
|
check_kind: String(check.kind ?? "external"),
|
|
976
1002
|
...(activeStep && workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}),
|
|
1003
|
+
...(typeof check._producer === "string" ? { expected_producer: check._producer } : {}),
|
|
1004
|
+
...(typeof check._recorded_by === "string" ? { recorded_by: check._recorded_by } : {}),
|
|
1005
|
+
...(Array.isArray(check._producer_self_produced_trust_slices) ? { self_produced_trust_slices: check._producer_self_produced_trust_slices } : {}),
|
|
977
1006
|
...(waiver ? { waiver } : {}),
|
|
978
1007
|
...(promotionMeta ? { promotion: promotionMeta } : {}),
|
|
979
1008
|
...(artifactRefsMeta ? { artifact_refs: artifactRefsMeta } : {}),
|
|
980
1009
|
...(standardRefsMeta ? { standard_refs: standardRefsMeta } : {}),
|
|
981
1010
|
...(outputDigestMeta ? { output_digest: outputDigestMeta } : {}),
|
|
1011
|
+
...(observedCommandsMeta && observedCommandsMeta.length > 0 ? { observed_commands: observedCommandsMeta } : {}),
|
|
982
1012
|
};
|
|
983
1013
|
|
|
984
1014
|
const claimEvents: AnyObj[] = [];
|
|
@@ -1075,12 +1105,12 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
1075
1105
|
if (declared) {
|
|
1076
1106
|
// Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
|
|
1077
1107
|
const declaredPolicy = ensurePolicy(declared.claimType, "high", []);
|
|
1078
|
-
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", ...(workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}) } };
|
|
1108
|
+
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", criterion: { id: criterion.id, description: criterion.description ?? criterion.id, status: criterion.status, evidence_refs: Array.isArray(criterion.evidence_refs) ? criterion.evidence_refs : [] }, ...(workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}) } };
|
|
1079
1109
|
const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
|
|
1080
1110
|
claims.push({ ...declaredClaimObj, status: declaredStatus });
|
|
1081
1111
|
} else {
|
|
1082
1112
|
// No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
|
|
1083
|
-
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" } };
|
|
1113
|
+
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", criterion: { id: criterion.id, description: criterion.description ?? criterion.id, status: criterion.status, evidence_refs: Array.isArray(criterion.evidence_refs) ? criterion.evidence_refs : [] } } };
|
|
1084
1114
|
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
|
|
1085
1115
|
claims.push({ ...claimObj, status: derivedStatus });
|
|
1086
1116
|
}
|
|
@@ -1098,7 +1128,18 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
1098
1128
|
const supersededBy = typeof c.superseded_by === "string" && c.superseded_by.length > 0 ? c.superseded_by : null;
|
|
1099
1129
|
const critiqueReviewer = String(c.reviewer ?? "tool-code-reviewer");
|
|
1100
1130
|
const critiqueReviewedAt = String(c.reviewed_at ?? ts);
|
|
1101
|
-
const critMeta: AnyObj = {
|
|
1131
|
+
const critMeta: AnyObj = {
|
|
1132
|
+
origin: "critique",
|
|
1133
|
+
reviewer: critiqueReviewer,
|
|
1134
|
+
reviewed_at: critiqueReviewedAt,
|
|
1135
|
+
findings: Array.isArray(c.findings) ? c.findings : [],
|
|
1136
|
+
lanes: Array.isArray(c.lanes) ? c.lanes : [],
|
|
1137
|
+
review_target: c.review_target && typeof c.review_target === "object" ? c.review_target : { artifacts: [] },
|
|
1138
|
+
// Keep the legacy field for consumers that still render a flat artifact list.
|
|
1139
|
+
artifact_refs: Array.isArray(c.artifact_refs) ? c.artifact_refs : [],
|
|
1140
|
+
...(activeStep && workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}),
|
|
1141
|
+
...(supersededBy ? { superseded_by: supersededBy } : {}),
|
|
1142
|
+
};
|
|
1102
1143
|
// A superseded historical write gets a distinct, stable claimId so it co-exists with the live
|
|
1103
1144
|
// claim of the same critique id (never overwrites or duplicates it). The salt is reproducible
|
|
1104
1145
|
// across rebuilds because superseded_by + reviewed_at are preserved in metadata.
|
|
@@ -1115,17 +1156,14 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
1115
1156
|
claimEvents.push(evt);
|
|
1116
1157
|
}
|
|
1117
1158
|
|
|
1118
|
-
//
|
|
1119
|
-
|
|
1120
|
-
const
|
|
1121
|
-
const subjectType = declared ? declared.subjectType : "workflow-critique";
|
|
1122
|
-
const claimPolicy = declared ? ensurePolicy(declared.claimType, "medium", []) : policy;
|
|
1123
|
-
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 };
|
|
1159
|
+
// Critique is intentionally report-only. It must never inherit a Flow gate expectation,
|
|
1160
|
+
// even while a run is positioned at a gate-bearing step.
|
|
1161
|
+
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, metadata: critMeta };
|
|
1124
1162
|
if (supersededBy) {
|
|
1125
1163
|
// History: status is "superseded" directly (no verification event); excluded from evaluation.
|
|
1126
1164
|
claims.push({ ...claimObj, status: "superseded" });
|
|
1127
1165
|
} else {
|
|
1128
|
-
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [
|
|
1166
|
+
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
|
|
1129
1167
|
claims.push({ ...claimObj, status: derivedStatus });
|
|
1130
1168
|
}
|
|
1131
1169
|
}
|
|
@@ -1153,7 +1191,7 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
1153
1191
|
* @param criteria Acceptance criteria objects (same as buildTrustBundle)
|
|
1154
1192
|
* @param critiques Critique objects (same as buildTrustBundle)
|
|
1155
1193
|
*/
|
|
1156
|
-
export async function writeTrustBundle(dir: string, slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], actorKey?: string): Promise<{ written: boolean; errors: string[] }> {
|
|
1194
|
+
export async function writeTrustBundle(dir: string, slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], actorKey?: string, exactFlowContext?: { flowId: string; stepId: string }): Promise<{ written: boolean; errors: string[] }> {
|
|
1157
1195
|
try {
|
|
1158
1196
|
// Fold the deterministic capture log (PostToolUse evidence-capture) into the
|
|
1159
1197
|
// bundle so capture is authoritative over claimed status. Best-effort read.
|
|
@@ -1173,14 +1211,20 @@ export async function writeTrustBundle(dir: string, slug: string, timestamp: str
|
|
|
1173
1211
|
const _flowAgentsDir = path.dirname(dir);
|
|
1174
1212
|
const _effectiveActorKey = actorKey ?? loadActorIdentityHelper().resolveActor(process.env).actor;
|
|
1175
1213
|
let _scopedFlowAgentsDir: string | undefined = undefined;
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1214
|
+
if (exactFlowContext) {
|
|
1215
|
+
// The context was read from this session's persisted flow_run. Navigation pointers may
|
|
1216
|
+
// lag or point at another run and must not override an explicit session mutation.
|
|
1217
|
+
_scopedFlowAgentsDir = _flowAgentsDir;
|
|
1218
|
+
} else {
|
|
1219
|
+
try {
|
|
1220
|
+
const _currentRaw = loadCurrentPointerHelper().readCurrentPointer(_flowAgentsDir, _effectiveActorKey).payload;
|
|
1221
|
+
const _artDir = _currentRaw && typeof _currentRaw["artifact_dir"] === "string" ? (_currentRaw["artifact_dir"] as string) : null;
|
|
1222
|
+
if (_artDir && path.resolve(_flowAgentsDir, _artDir) === path.resolve(dir)) {
|
|
1223
|
+
_scopedFlowAgentsDir = _flowAgentsDir;
|
|
1224
|
+
}
|
|
1225
|
+
} catch { /* current.json absent or unreadable — no scoping */ }
|
|
1226
|
+
}
|
|
1227
|
+
const bundle = await buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, _scopedFlowAgentsDir, _effectiveActorKey, exactFlowContext);
|
|
1184
1228
|
if (!bundle) return { written: false, errors: [] }; // Surface unavailable — fail-open, skip write
|
|
1185
1229
|
const result = await validateTrustBundle(bundle);
|
|
1186
1230
|
if (result.available && !result.valid) {
|
|
@@ -1188,7 +1232,6 @@ export async function writeTrustBundle(dir: string, slug: string, timestamp: str
|
|
|
1188
1232
|
return { written: false, errors: result.errors };
|
|
1189
1233
|
}
|
|
1190
1234
|
writeJson(path.join(dir, "trust.bundle"), bundle);
|
|
1191
|
-
await syncBuilderFlowSessionIfPresent(dir);
|
|
1192
1235
|
return { written: true, errors: [] };
|
|
1193
1236
|
} catch (err) {
|
|
1194
1237
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -1766,6 +1809,9 @@ function initSidecars(
|
|
|
1766
1809
|
...sidecarBase(slug), status: initialLifecycle?.status ?? "planned", phase: initialLifecycle?.phase ?? "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
|
|
1767
1810
|
...(branch ? { branch } : {}),
|
|
1768
1811
|
...(retainedWorkItemRefs.length > 0 ? { work_item_refs: retainedWorkItemRefs } : {}),
|
|
1812
|
+
...(existingState.flow_run && typeof existingState.flow_run === "object" && !Array.isArray(existingState.flow_run)
|
|
1813
|
+
? { flow_run: existingState.flow_run }
|
|
1814
|
+
: {}),
|
|
1769
1815
|
artifact_paths: relArtifacts(dir),
|
|
1770
1816
|
next_action: nextActionPayload,
|
|
1771
1817
|
});
|
|
@@ -1819,7 +1865,7 @@ function enforceEnsureSessionOwnership(
|
|
|
1819
1865
|
dir: string,
|
|
1820
1866
|
resolution: { actorStruct: ActorStruct; actorKey: string; branchActorKey: string; unresolved: boolean },
|
|
1821
1867
|
workItemRef?: string,
|
|
1822
|
-
): { assignmentFile: string; actorKey: string } | null {
|
|
1868
|
+
): { assignmentFile: string; actorKey: string; providerEvidenceFile?: string; providerEvidenceBytes?: Buffer } | null {
|
|
1823
1869
|
if (p.flags.has("skip-ownership-guard")) {
|
|
1824
1870
|
process.stderr.write("[ensure-session] ownership guard skipped via --skip-ownership-guard\n");
|
|
1825
1871
|
return null;
|
|
@@ -1854,15 +1900,41 @@ function enforceEnsureSessionOwnership(
|
|
|
1854
1900
|
|
|
1855
1901
|
type EffectiveResult = { effective_state: EffectiveState; reason: string; holder?: { actor?: string; assignee?: string | null; idle_days?: number | null; last_at?: string } };
|
|
1856
1902
|
let effective: EffectiveResult;
|
|
1903
|
+
let providerEvidenceBytes: Buffer | undefined;
|
|
1857
1904
|
|
|
1858
1905
|
const effectiveStateJsonFlag = opt(p, "effective-state-json");
|
|
1859
1906
|
if (effectiveStateJsonFlag) {
|
|
1860
|
-
|
|
1907
|
+
providerEvidenceBytes = effectiveStateJsonFlag === "-"
|
|
1908
|
+
? fs.readFileSync(0)
|
|
1909
|
+
: readRegularFileNoFollow(path.resolve(effectiveStateJsonFlag), "ensure-session --effective-state-json");
|
|
1910
|
+
const parsed = JSON.parse(providerEvidenceBytes.toString("utf8")) as AnyObj;
|
|
1861
1911
|
const candidate = parsed && typeof parsed === "object" ? (parsed.effective as AnyObj | undefined) : undefined;
|
|
1912
|
+
const assignment = parsed && typeof parsed === "object" ? (parsed.assignment as AnyObj | undefined) : undefined;
|
|
1913
|
+
const record = assignment && typeof assignment.record === "object" && assignment.record !== null ? assignment.record as AnyObj : undefined;
|
|
1862
1914
|
const validStates = new Set(["held", "reclaimable", "human-held", "free"]);
|
|
1863
1915
|
if (!candidate || typeof candidate.effective_state !== "string" || !validStates.has(candidate.effective_state)) {
|
|
1864
1916
|
die(`ensure-session --effective-state-json must contain an .effective object with a recognized effective_state (held|reclaimable|human-held|free); got ${JSON.stringify(candidate ? candidate.effective_state : candidate)}`);
|
|
1865
1917
|
}
|
|
1918
|
+
if (workItemRef && (parsed.role !== "AssignmentStatus"
|
|
1919
|
+
|| parsed.provider !== assignmentProviderKind
|
|
1920
|
+
|| assignment?.provider !== assignmentProviderKind
|
|
1921
|
+
|| assignment?.subject_id !== slug
|
|
1922
|
+
|| record?.role !== "AssignmentClaimRecord"
|
|
1923
|
+
|| record?.status !== "claimed"
|
|
1924
|
+
|| record?.subject_id !== slug
|
|
1925
|
+
|| record?.work_item_ref !== workItemRef
|
|
1926
|
+
|| record?.actor_key !== resolution.branchActorKey
|
|
1927
|
+
|| assignment?.assignee !== resolution.branchActorKey
|
|
1928
|
+
|| !record.actor || typeof record.actor !== "object"
|
|
1929
|
+
|| record.actor.runtime !== resolution.actorStruct.runtime
|
|
1930
|
+
|| record.actor.session_id !== resolution.actorStruct.session_id
|
|
1931
|
+
|| record.actor.host !== resolution.actorStruct.host
|
|
1932
|
+
|| (record.actor.human ?? null) !== (resolution.actorStruct.human ?? null)
|
|
1933
|
+
|| candidate.effective_state !== "held"
|
|
1934
|
+
|| candidate.reason !== "self_is_holder"
|
|
1935
|
+
|| candidate.holder?.actor !== resolution.branchActorKey)) {
|
|
1936
|
+
die("ensure-session --effective-state-json must be the configured provider's AssignmentStatus for this Work Item, with a claimed record and self_is_holder actor matching the current runtime");
|
|
1937
|
+
}
|
|
1866
1938
|
effective = candidate as EffectiveResult;
|
|
1867
1939
|
} else if (assignmentProviderKind === "local-file") {
|
|
1868
1940
|
// Conflict #3 (plan): subjectId for BOTH the assignment lookup and the liveness freshHolders
|
|
@@ -1898,10 +1970,8 @@ function enforceEnsureSessionOwnership(
|
|
|
1898
1970
|
return null;
|
|
1899
1971
|
}
|
|
1900
1972
|
|
|
1901
|
-
const selectedWorkEvidence = (): { assignmentFile: string; actorKey: string } | null => {
|
|
1902
|
-
|
|
1903
|
-
// can authorize entry, but it cannot prove that this process acquired the Work Item.
|
|
1904
|
-
if (!workItemRef || effectiveStateJsonFlag || assignmentProviderKind !== "local-file") return null;
|
|
1973
|
+
const selectedWorkEvidence = (): { assignmentFile: string; actorKey: string; providerEvidenceFile?: string; providerEvidenceBytes?: Buffer } | null => {
|
|
1974
|
+
if (!workItemRef) return null;
|
|
1905
1975
|
const assignment = readLocalAssignmentStatus(root, slug);
|
|
1906
1976
|
const record = assignment.record;
|
|
1907
1977
|
if (!record
|
|
@@ -1914,6 +1984,8 @@ function enforceEnsureSessionOwnership(
|
|
|
1914
1984
|
return {
|
|
1915
1985
|
assignmentFile: assignmentFilePath(root, slug),
|
|
1916
1986
|
actorKey: resolution.branchActorKey,
|
|
1987
|
+
...(effectiveStateJsonFlag ? { providerEvidenceFile: path.resolve(effectiveStateJsonFlag) } : {}),
|
|
1988
|
+
...(providerEvidenceBytes ? { providerEvidenceBytes } : {}),
|
|
1917
1989
|
};
|
|
1918
1990
|
};
|
|
1919
1991
|
|
|
@@ -1929,7 +2001,22 @@ function enforceEnsureSessionOwnership(
|
|
|
1929
2001
|
// ActorStruct triple), so this redundant belt-and-suspenders check agrees with the
|
|
1930
2002
|
// effective_state computation instead of silently using a different identity.
|
|
1931
2003
|
const isSelf = effective.reason === "self_is_holder" || (!!effective.holder?.actor && effective.holder.actor === resolution.branchActorKey);
|
|
1932
|
-
if (isSelf)
|
|
2004
|
+
if (isSelf) {
|
|
2005
|
+
if (assignmentProviderKind !== "local-file") {
|
|
2006
|
+
const local = readLocalAssignmentStatus(root, slug).record;
|
|
2007
|
+
if (!local || local.status !== "claimed") {
|
|
2008
|
+
performLocalClaim(root, slug, resolution.actorStruct, {
|
|
2009
|
+
ttlSeconds: opt(p, "claim-ttl-seconds") ? Number(opt(p, "claim-ttl-seconds")) : loadLivenessPolicyHelper().resolveTtlSeconds(process.env),
|
|
2010
|
+
branch: resolveBranchForClaim(),
|
|
2011
|
+
artifactDir: path.relative(root, dir) || ".",
|
|
2012
|
+
reason: `provider-confirmed ${assignmentProviderKind} ownership mirror`,
|
|
2013
|
+
actorKey: resolution.branchActorKey,
|
|
2014
|
+
...(workItemRef ? { workItemRef } : {}),
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
return selectedWorkEvidence();
|
|
2019
|
+
}
|
|
1933
2020
|
const holderActor = sanitize(effective.holder?.actor ?? "unknown");
|
|
1934
2021
|
const lastAtSuffix = effective.holder?.last_at ? ` (last_at ${sanitize(effective.holder.last_at)})` : "";
|
|
1935
2022
|
die(`ensure-session refused: subject ${sanitize(slug)} is currently held by a different, still-live actor (${holderActor}${lastAtSuffix}). Pick a different work item, or confirm the holder session is truly gone before considering a takeover.`);
|
|
@@ -1980,6 +2067,9 @@ function enforceEnsureSessionOwnership(
|
|
|
1980
2067
|
return selectedWorkEvidence();
|
|
1981
2068
|
}
|
|
1982
2069
|
case "free": {
|
|
2070
|
+
if (assignmentProviderKind !== "local-file") {
|
|
2071
|
+
die(`ensure-session refused: provider ${JSON.stringify(assignmentProviderKind)} has not confirmed this actor as the Work Item holder`);
|
|
2072
|
+
}
|
|
1983
2073
|
performLocalClaim(root, slug, resolution.actorStruct, {
|
|
1984
2074
|
ttlSeconds: opt(p, "claim-ttl-seconds") ? Number(opt(p, "claim-ttl-seconds")) : loadLivenessPolicyHelper().resolveTtlSeconds(process.env),
|
|
1985
2075
|
branch: resolveBranchForClaim(),
|
|
@@ -2032,11 +2122,11 @@ function resolveEnsureSessionEntry(p: ReturnType<typeof parseArgs>, dir: string)
|
|
|
2032
2122
|
return { flowId, stepId: explicitStep || firstStep, firstStep };
|
|
2033
2123
|
}
|
|
2034
2124
|
|
|
2035
|
-
function assertCanonicalBuilderArtifactRoot(root: string): void {
|
|
2125
|
+
function assertCanonicalBuilderArtifactRoot(root: string, flowId: "builder.build" | "builder.shape"): void {
|
|
2036
2126
|
const kontouraiRoot = path.dirname(root);
|
|
2037
2127
|
const projectRoot = path.dirname(kontouraiRoot);
|
|
2038
2128
|
if (path.basename(root) !== "flow-agents" || path.basename(kontouraiRoot) !== ".kontourai") {
|
|
2039
|
-
die(
|
|
2129
|
+
die(`ensure-session --flow-id ${flowId} requires --artifact-root <project>/.kontourai/flow-agents`);
|
|
2040
2130
|
}
|
|
2041
2131
|
|
|
2042
2132
|
const statIfPresent = (candidate: string): fs.Stats | null => {
|
|
@@ -2049,7 +2139,7 @@ function assertCanonicalBuilderArtifactRoot(root: string): void {
|
|
|
2049
2139
|
};
|
|
2050
2140
|
const projectStat = statIfPresent(projectRoot);
|
|
2051
2141
|
if (projectStat && (projectStat.isSymbolicLink() || !projectStat.isDirectory())) {
|
|
2052
|
-
die(`ensure-session --flow-id
|
|
2142
|
+
die(`ensure-session --flow-id ${flowId} requires a non-symlink project root: ${projectRoot}`);
|
|
2053
2143
|
}
|
|
2054
2144
|
const realProjectRoot = projectStat ? fs.realpathSync(projectRoot) : projectRoot;
|
|
2055
2145
|
for (const [candidate, expected, label] of [
|
|
@@ -2059,7 +2149,7 @@ function assertCanonicalBuilderArtifactRoot(root: string): void {
|
|
|
2059
2149
|
const stat = statIfPresent(candidate);
|
|
2060
2150
|
if (!stat) continue;
|
|
2061
2151
|
if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(candidate) !== expected) {
|
|
2062
|
-
die(`ensure-session --flow-id
|
|
2152
|
+
die(`ensure-session --flow-id ${flowId} requires a non-symlink ${label}: ${candidate}`);
|
|
2063
2153
|
}
|
|
2064
2154
|
}
|
|
2065
2155
|
}
|
|
@@ -2070,17 +2160,17 @@ function preflightEnsureSession(p: ReturnType<typeof parseArgs>): void {
|
|
|
2070
2160
|
const dir = sessionDirFor(root, slug);
|
|
2071
2161
|
assertSafeSessionDirectory(root, dir);
|
|
2072
2162
|
const entry = resolveEnsureSessionEntry(p, dir);
|
|
2073
|
-
if (entry.flowId === "builder.build") assertCanonicalBuilderArtifactRoot(root);
|
|
2163
|
+
if (entry.flowId === "builder.build" || entry.flowId === "builder.shape") assertCanonicalBuilderArtifactRoot(root, entry.flowId);
|
|
2074
2164
|
sessionWorkItem(p, slug, dir);
|
|
2075
2165
|
}
|
|
2076
2166
|
|
|
2077
|
-
async function ensureSession(p: ReturnType<typeof parseArgs
|
|
2167
|
+
async function ensureSession(p: ReturnType<typeof parseArgs>, allowCanonicalFlowMutation: boolean): Promise<number> {
|
|
2078
2168
|
const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
|
|
2079
2169
|
const slug = opt(p, "task-slug") || (opt(p, "work-item") ? workItemSlug(opt(p, "work-item")) : die("--task-slug is required (or pass --work-item to derive it)"));
|
|
2080
2170
|
const dir = sessionDirFor(root, slug);
|
|
2081
2171
|
assertSafeSessionDirectory(root, dir);
|
|
2082
2172
|
const entry = resolveEnsureSessionEntry(p, dir);
|
|
2083
|
-
if (entry.flowId === "builder.build") assertCanonicalBuilderArtifactRoot(root);
|
|
2173
|
+
if (entry.flowId === "builder.build" || entry.flowId === "builder.shape") assertCanonicalBuilderArtifactRoot(root, entry.flowId);
|
|
2084
2174
|
const workItem = sessionWorkItem(p, slug, dir);
|
|
2085
2175
|
// #291 Wave 2 Task 2.1 (§3, §4): resolve the actor ONCE, then run the ownership guard BEFORE
|
|
2086
2176
|
// any directory/file is created — a refusal must never leave a stray empty session dir. Reused
|
|
@@ -2090,8 +2180,18 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
2090
2180
|
const selectionWorkItemRef = entry.flowId === "builder.build" && assignmentSubjectMatchesWorkItem(slug, workItem.ref)
|
|
2091
2181
|
? workItem.ref
|
|
2092
2182
|
: undefined;
|
|
2093
|
-
|
|
2183
|
+
let selectedWorkEvidence = enforceEnsureSessionOwnership(p, root, slug, dir, actorResolution, selectionWorkItemRef);
|
|
2094
2184
|
ensureSafeDirectory(root, dir);
|
|
2185
|
+
if (selectedWorkEvidence?.providerEvidenceBytes) {
|
|
2186
|
+
const staged = path.join(dir, "assignment-provider-state.json");
|
|
2187
|
+
if (fs.existsSync(staged)) {
|
|
2188
|
+
if (!readRegularFileNoFollow(staged, "ensure-session provider state evidence destination").equals(selectedWorkEvidence.providerEvidenceBytes)) die("ensure-session provider state evidence conflicts with the existing immutable snapshot");
|
|
2189
|
+
} else {
|
|
2190
|
+
fs.writeFileSync(staged, selectedWorkEvidence.providerEvidenceBytes, { flag: "wx", mode: 0o600 });
|
|
2191
|
+
}
|
|
2192
|
+
fs.chmodSync(staged, 0o400);
|
|
2193
|
+
selectedWorkEvidence = { ...selectedWorkEvidence, providerEvidenceFile: staged };
|
|
2194
|
+
}
|
|
2095
2195
|
const timestamp = opt(p, "timestamp", now());
|
|
2096
2196
|
if (workItem.localRecord && !fs.existsSync(path.join(dir, "work-item.json"))) {
|
|
2097
2197
|
writeJson(path.join(dir, "work-item.json"), workItem.localRecord);
|
|
@@ -2114,9 +2214,11 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
2114
2214
|
const initialPhase = Object.entries(phaseMap ?? {}).find(([, step]) => step === entry.firstStep)?.[0];
|
|
2115
2215
|
const startCommand = pinnedFlowAgentsCommand(flowAgentsPackageVersion(), [
|
|
2116
2216
|
"workflow", "start",
|
|
2117
|
-
"--flow",
|
|
2118
|
-
"--work-item", workItem.ref,
|
|
2217
|
+
"--flow", entry.flowId,
|
|
2218
|
+
...(entry.flowId === "builder.shape" ? [] : ["--work-item", workItem.ref]),
|
|
2119
2219
|
...(workItem.ref === `local:${slug}` ? ["--task-slug", slug] : []),
|
|
2220
|
+
"--assignment-provider", opt(p, "assignment-provider", "local-file"),
|
|
2221
|
+
...(selectedWorkEvidence?.providerEvidenceFile ? ["--effective-state-json", selectedWorkEvidence.providerEvidenceFile] : []),
|
|
2120
2222
|
"--artifact-root", root,
|
|
2121
2223
|
"--title", opt(p, "title", slug),
|
|
2122
2224
|
"--summary", opt(p, "summary", "Workflow session is durable."),
|
|
@@ -2159,9 +2261,9 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
2159
2261
|
? persistedCurrent.active_step_id
|
|
2160
2262
|
: entry.stepId;
|
|
2161
2263
|
writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", entry.flowId || undefined, resumedStep || undefined, actorResolution.unresolved ? undefined : actorResolution.branchActorKey);
|
|
2162
|
-
if (entry.flowId === "builder.build") {
|
|
2264
|
+
if (allowCanonicalFlowMutation && (entry.flowId === "builder.build" || entry.flowId === "builder.shape")) {
|
|
2163
2265
|
try {
|
|
2164
|
-
const started = await startBuilderFlowSession({ sessionDir: dir });
|
|
2266
|
+
const started = await startBuilderFlowSession({ sessionDir: dir, flowId: entry.flowId });
|
|
2165
2267
|
if (started.run.state.current_step === "pull-work"
|
|
2166
2268
|
&& selectedWorkEvidence
|
|
2167
2269
|
&& assignmentSubjectMatchesWorkItem(slug, workItem.ref)) {
|
|
@@ -2176,8 +2278,24 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
2176
2278
|
}
|
|
2177
2279
|
const assignmentContent = fs.readFileSync(selectedWorkEvidence.assignmentFile);
|
|
2178
2280
|
const assignmentDigest = createHash("sha256").update(assignmentContent).digest("hex");
|
|
2281
|
+
const providerEvidenceBytes = selectedWorkEvidence.providerEvidenceFile
|
|
2282
|
+
? readRegularFileNoFollow(selectedWorkEvidence.providerEvidenceFile, "selected-work provider evidence")
|
|
2283
|
+
: null;
|
|
2284
|
+
if (providerEvidenceBytes && selectedWorkEvidence.providerEvidenceBytes && !providerEvidenceBytes.equals(selectedWorkEvidence.providerEvidenceBytes)) {
|
|
2285
|
+
die("ensure-session refused to emit selected-work evidence: provider evidence changed after ownership validation");
|
|
2286
|
+
}
|
|
2287
|
+
const providerEvidenceDigest = providerEvidenceBytes ? createHash("sha256").update(providerEvidenceBytes).digest("hex") : null;
|
|
2179
2288
|
const projectRoot = path.dirname(path.dirname(root));
|
|
2180
2289
|
const evidenceFile = path.relative(projectRoot, selectedWorkEvidence.assignmentFile);
|
|
2290
|
+
const pullWorkReport = path.join(dir, `${slug}--pull-work.md`);
|
|
2291
|
+
let reportIsValid = false;
|
|
2292
|
+
try {
|
|
2293
|
+
const reportStat = fs.lstatSync(pullWorkReport);
|
|
2294
|
+
reportIsValid = !reportStat.isSymbolicLink() && reportStat.isFile() && fs.readFileSync(pullWorkReport, "utf8").includes(workItem.ref);
|
|
2295
|
+
} catch { /* handled by the stable contract error below */ }
|
|
2296
|
+
if (!reportIsValid) {
|
|
2297
|
+
die(`ensure-session refused to emit selected-work evidence: expected concrete pull-work selection report ${pullWorkReport} naming ${JSON.stringify(workItem.ref)}`);
|
|
2298
|
+
}
|
|
2181
2299
|
await recordGateClaim(parseArgs([
|
|
2182
2300
|
"record-gate-claim",
|
|
2183
2301
|
dir,
|
|
@@ -2190,8 +2308,25 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
2190
2308
|
file: evidenceFile,
|
|
2191
2309
|
summary: `Durable local assignment record for the selected Work Item and active actor; sha256:${assignmentDigest}.`,
|
|
2192
2310
|
}),
|
|
2311
|
+
...(selectedWorkEvidence.providerEvidenceFile ? [
|
|
2312
|
+
"--evidence-ref-json", JSON.stringify({
|
|
2313
|
+
kind: "artifact",
|
|
2314
|
+
file: path.relative(projectRoot, selectedWorkEvidence.providerEvidenceFile),
|
|
2315
|
+
summary: `Provider assignment state confirming ownership before the local runtime lease was mirrored; sha256:${providerEvidenceDigest}.`,
|
|
2316
|
+
}),
|
|
2317
|
+
] : []),
|
|
2318
|
+
"--evidence-ref-json", JSON.stringify({
|
|
2319
|
+
kind: "artifact",
|
|
2320
|
+
file: path.relative(projectRoot, pullWorkReport),
|
|
2321
|
+
summary: `Concrete pull-work selection report for ${workItem.ref}.`,
|
|
2322
|
+
}),
|
|
2193
2323
|
"--timestamp", timestamp,
|
|
2194
2324
|
]));
|
|
2325
|
+
if (selectedWorkEvidence.providerEvidenceFile && selectedWorkEvidence.providerEvidenceBytes
|
|
2326
|
+
&& !readRegularFileNoFollow(selectedWorkEvidence.providerEvidenceFile, "selected-work provider evidence").equals(selectedWorkEvidence.providerEvidenceBytes)) {
|
|
2327
|
+
die("ensure-session refused to synchronize selected-work evidence: provider evidence changed during claim recording");
|
|
2328
|
+
}
|
|
2329
|
+
await syncBuilderFlowSession({ sessionDir: dir });
|
|
2195
2330
|
});
|
|
2196
2331
|
}
|
|
2197
2332
|
} catch (error) {
|
|
@@ -2303,6 +2438,373 @@ export function normalizeEvidenceRefs(raw: unknown, label: string): AnyObj[] {
|
|
|
2303
2438
|
return validateEvidenceRef({ ...ref as AnyObj }, label);
|
|
2304
2439
|
});
|
|
2305
2440
|
}
|
|
2441
|
+
|
|
2442
|
+
function canonicalProjectRootForSession(dir: string): string {
|
|
2443
|
+
const artifactRoot = path.dirname(dir);
|
|
2444
|
+
const kontouraiRoot = path.dirname(artifactRoot);
|
|
2445
|
+
if (path.basename(artifactRoot) !== "flow-agents" || path.basename(kontouraiRoot) !== ".kontourai") die("gate evidence requires a canonical .kontourai/flow-agents session");
|
|
2446
|
+
const projectRoot = path.dirname(kontouraiRoot);
|
|
2447
|
+
const stat = fs.lstatSync(projectRoot);
|
|
2448
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) die("gate evidence project root must be a non-symlink directory");
|
|
2449
|
+
return projectRoot;
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
function pathIsWithinRoot(candidate: string, root: string): boolean {
|
|
2453
|
+
const relative = path.relative(root, candidate);
|
|
2454
|
+
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
function validateLocalEvidenceFile(projectRoot: string, raw: string, label: string): string {
|
|
2458
|
+
const candidate = path.resolve(projectRoot, raw);
|
|
2459
|
+
if (!pathIsWithinRoot(candidate, projectRoot)) die(`${label} must remain inside the canonical project root`);
|
|
2460
|
+
let stat: fs.Stats;
|
|
2461
|
+
try { stat = fs.lstatSync(candidate); } catch { die(`${label} does not exist`); }
|
|
2462
|
+
if (stat.isSymbolicLink() || !stat.isFile()) die(`${label} must be a non-symlink regular file`);
|
|
2463
|
+
if (!pathIsWithinRoot(fs.realpathSync(candidate), fs.realpathSync(projectRoot))) die(`${label} escapes the canonical project root`);
|
|
2464
|
+
return path.relative(projectRoot, candidate);
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
function globMatches(pattern: string, relative: string): boolean {
|
|
2468
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replaceAll("**", "::GLOBSTAR::").replaceAll("*", "[^/]*").replaceAll("::GLOBSTAR::", ".*");
|
|
2469
|
+
return new RegExp(`^${escaped}$`).test(relative);
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
type GateProducer = { id: string; artifactPatterns: string[]; selfProducedTrustSlices: string[] };
|
|
2473
|
+
|
|
2474
|
+
function expectedGateProducer(flowId: string, stepId: string, expectationId: string): GateProducer {
|
|
2475
|
+
const manifest = loadJson(path.join(flowAgentsPackageRoot(), "kits", "builder", "kit.json"));
|
|
2476
|
+
const actions = Array.isArray(manifest.flow_step_actions) ? manifest.flow_step_actions as AnyObj[] : [];
|
|
2477
|
+
const action = actions.find((candidate) => candidate.flow_id === flowId && candidate.step_id === stepId);
|
|
2478
|
+
if (!action) die(`record-gate-claim cannot derive a producer for unknown Flow step ${flowId}/${stepId}`);
|
|
2479
|
+
const skills = Array.isArray(action.skills) ? action.skills.filter((value: unknown): value is string => typeof value === "string") : [];
|
|
2480
|
+
const roles = Array.isArray(manifest.skill_roles) ? manifest.skill_roles as AnyObj[] : [];
|
|
2481
|
+
const owners = roles.filter((role) => typeof role.skill_id === "string"
|
|
2482
|
+
&& Array.isArray(role.step_ids) && role.step_ids.includes(stepId)
|
|
2483
|
+
&& Array.isArray(role.expectation_ids) && role.expectation_ids.includes(expectationId)
|
|
2484
|
+
&& skills.includes(role.skill_id.replace(/^builder\./, "")));
|
|
2485
|
+
if (owners.length === 0) {
|
|
2486
|
+
const operations = Array.isArray(action.operations) ? action.operations.filter((value: unknown): value is string => typeof value === "string") : [];
|
|
2487
|
+
const operationExpectations = Array.isArray(action.expectation_ids) ? action.expectation_ids : [];
|
|
2488
|
+
if (operations.length === 1 && operationExpectations.includes(expectationId)) {
|
|
2489
|
+
const artifacts = Array.isArray(action.artifacts) ? action.artifacts.filter((value: unknown): value is string => typeof value === "string") : [];
|
|
2490
|
+
return {
|
|
2491
|
+
id: `operation:${operations[0]}`,
|
|
2492
|
+
artifactPatterns: artifacts.filter((value) => !value.includes("#")),
|
|
2493
|
+
selfProducedTrustSlices: artifacts.filter((value) => value.startsWith("trust.bundle#")).map((value) => value.slice("trust.bundle#".length)),
|
|
2494
|
+
};
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
if (owners.length !== 1) die(`record-gate-claim cannot derive exactly one producer for ${flowId}/${stepId}/${expectationId}`);
|
|
2498
|
+
const owner = owners[0]!;
|
|
2499
|
+
const artifacts = (Array.isArray(owner.artifacts) ? owner.artifacts : []).filter((value): value is string => typeof value === "string" && value !== "ephemeral decision record");
|
|
2500
|
+
return {
|
|
2501
|
+
id: owner.skill_id,
|
|
2502
|
+
artifactPatterns: artifacts.filter((value) => !value.includes("#")),
|
|
2503
|
+
selfProducedTrustSlices: artifacts.filter((value) => value.startsWith("trust.bundle#")).map((value) => value.slice("trust.bundle#".length)),
|
|
2504
|
+
};
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
function validateReviewableGateEvidence(dir: string, slug: string, refs: AnyObj[], producer: GateProducer, label: string): void {
|
|
2508
|
+
if (refs.length === 0) die(`${label} requires at least one reviewable --evidence-ref-json`);
|
|
2509
|
+
const projectRoot = canonicalProjectRootForSession(dir);
|
|
2510
|
+
let declaredProducerArtifactFound = producer.artifactPatterns.length === 0;
|
|
2511
|
+
let localOrCommandEvidenceFound = false;
|
|
2512
|
+
for (const [index, ref] of refs.entries()) {
|
|
2513
|
+
if (ref.kind === "command" && hasNonEmptyString(ref.excerpt)) localOrCommandEvidenceFound = true;
|
|
2514
|
+
if (typeof ref.file !== "string") continue;
|
|
2515
|
+
const relative = validateLocalEvidenceFile(projectRoot, ref.file, `${label} evidence ref ${index}`);
|
|
2516
|
+
ref.file = relative;
|
|
2517
|
+
localOrCommandEvidenceFound = true;
|
|
2518
|
+
if (ref.kind === "artifact" && producer.artifactPatterns.length > 0) {
|
|
2519
|
+
const patterns = producer.artifactPatterns.map((pattern) => {
|
|
2520
|
+
const expanded = pattern.replaceAll("<slug>", slug);
|
|
2521
|
+
return expanded.startsWith(".kontourai/") ? expanded : `.kontourai/flow-agents/${slug}/${expanded}`;
|
|
2522
|
+
});
|
|
2523
|
+
if (patterns.some((pattern) => globMatches(pattern, relative))) declaredProducerArtifactFound = true;
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
if (!declaredProducerArtifactFound) die(`${label} requires a declared durable artifact from producer ${producer.id}`);
|
|
2527
|
+
if (producer.selfProducedTrustSlices.length > 0 && !localOrCommandEvidenceFound) {
|
|
2528
|
+
die(`${label} is produced as trust.bundle fragment(s) ${producer.selfProducedTrustSlices.join(", ")} and requires local artifact or command evidence; external-only references cannot prove a passing claim`);
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
function commandFromEvidenceRef(ref: AnyObj): string {
|
|
2533
|
+
return typeof ref.excerpt === "string" ? ref.excerpt.trim() : (typeof ref.url === "string" ? ref.url.trim() : "");
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
function hasTestIntent(name: string): boolean {
|
|
2537
|
+
return /(?:^|[-_.\/])(test|tests|check|checks|verify|verification|spec|specs|eval|evals)(?:$|[-_.\/])/i.test(name) || /(?:test|check|verify|spec|eval)/i.test(path.basename(name));
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
function resolvesExplicitTestTarget(projectRoot: string, token: string): boolean {
|
|
2541
|
+
if (!hasTestIntent(token)) return false;
|
|
2542
|
+
try {
|
|
2543
|
+
if (/[*?\[]/.test(token)) return fs.globSync(token, { cwd: projectRoot }).length > 0;
|
|
2544
|
+
const target = path.resolve(projectRoot, token);
|
|
2545
|
+
return pathIsWithinRoot(target, projectRoot) && fs.lstatSync(target).isFile();
|
|
2546
|
+
} catch { return false; }
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
/** Validate test-evidence command shape without executing it. */
|
|
2550
|
+
type TestExecutionProof = {
|
|
2551
|
+
kind: "local-process-exit";
|
|
2552
|
+
runner: string;
|
|
2553
|
+
static_test_units: number;
|
|
2554
|
+
};
|
|
2555
|
+
|
|
2556
|
+
function staticTestUnits(file: string, executable: string): number {
|
|
2557
|
+
try {
|
|
2558
|
+
const content = fs.readFileSync(file, "utf8").slice(0, 256 * 1024);
|
|
2559
|
+
const hashComments = !["cargo", "go"].includes(executable);
|
|
2560
|
+
const active = content.split("\n")
|
|
2561
|
+
.map((line) => hashComments ? line.replace(/\s+#.*$/, "") : line.replace(/\s+\/\/.*$/, ""))
|
|
2562
|
+
.filter((line) => hashComments ? !line.trimStart().startsWith("#") : !line.trimStart().startsWith("//"))
|
|
2563
|
+
.join("\n");
|
|
2564
|
+
if (["bash", "sh", "zsh"].includes(executable)
|
|
2565
|
+
&& !/(?:^|\n)\s*set\s+(?:-[^\n\s]*e[^\n\s]*|-o\s+errexit)(?:\s|$)/.test(active)) return 0;
|
|
2566
|
+
const assertions = active.match(/(?:\b(?:test|it)\s*\(|\b(?:assert|expect)\s*\(|^\s*(?:async\s+)?def\s+test_|^\s*(?:public\s+)?function\s+test|^\s*it\s+["']|^\s*func\s+(?:Test|Fuzz|Example)\w*\s*\(|#\[(?:\w+::)?test\]|^(?:if\s+! ?)?(?:test\s|\[\s|\[\[\s|(?:grep|rg|diff|cmp)\s))/gm) ?? [];
|
|
2567
|
+
return assertions.length;
|
|
2568
|
+
} catch {
|
|
2569
|
+
return 0;
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
/**
|
|
2574
|
+
* Produce evidence from the locally executed command and statically reviewable
|
|
2575
|
+
* test units. Runner stdout is deliberately excluded: any executable can print
|
|
2576
|
+
* a Vitest/Jest-looking success summary, but it cannot turn a non-test script
|
|
2577
|
+
* into a supported test workflow or supply this locally-created proof.
|
|
2578
|
+
*/
|
|
2579
|
+
export function testExecutionProof(command: string, projectRoot: string, seenScripts = new Set<string>(), packageScriptBody = false): TestExecutionProof | null {
|
|
2580
|
+
const normalized = command.trim().replace(/\s+/g, " ");
|
|
2581
|
+
if (!normalized || /[`$()]/.test(normalized)) return null;
|
|
2582
|
+
if (/[;&|]/.test(normalized)) {
|
|
2583
|
+
if (!packageScriptBody || normalized.includes("||") || /[;|]/.test(normalized)) return null;
|
|
2584
|
+
const segments = normalized.split(/\s*&&\s*/).filter(Boolean);
|
|
2585
|
+
return segments.length > 1 ? segments.map((segment) => testExecutionProof(segment, projectRoot, new Set(seenScripts), false)).find(Boolean) ?? null : null;
|
|
2586
|
+
}
|
|
2587
|
+
if (/^(?:true|:|\/usr\/bin\/true)$/i.test(normalized)) return null;
|
|
2588
|
+
if (/^(?:echo|printf)(?:\s|$)/i.test(normalized)) return null;
|
|
2589
|
+
if (/(?:^|\s)(?:--version|-v|--help|-h)(?:\s|$)/.test(normalized)) return null;
|
|
2590
|
+
const tokens = normalized.split(" ").filter(Boolean);
|
|
2591
|
+
while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0] ?? "")) tokens.shift();
|
|
2592
|
+
const executable = tokens[0] ?? "";
|
|
2593
|
+
const executableName = path.basename(executable);
|
|
2594
|
+
if (["npm", "pnpm", "yarn", "bun"].includes(executableName)) {
|
|
2595
|
+
// `bun test` is Bun's test runner; package scripts use `bun run <script>`.
|
|
2596
|
+
if (executableName === "bun" && tokens[1] === "test") {
|
|
2597
|
+
const target = tokens.slice(2).find((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token));
|
|
2598
|
+
const units = target ? staticTestUnits(path.resolve(projectRoot, target), "bun") : 0;
|
|
2599
|
+
return units > 0 ? { kind: "local-process-exit", runner: "bun test", static_test_units: units } : null;
|
|
2600
|
+
}
|
|
2601
|
+
const script = tokens[1] === "run" || tokens[1] === "run-script" ? tokens[2] : tokens[1];
|
|
2602
|
+
if (!script || !hasTestIntent(script) || seenScripts.has(script)) return null;
|
|
2603
|
+
try {
|
|
2604
|
+
const pkg = loadJson(path.join(projectRoot, "package.json"));
|
|
2605
|
+
const scriptCommand = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts[script] : undefined;
|
|
2606
|
+
if (typeof scriptCommand !== "string") return null;
|
|
2607
|
+
seenScripts.add(script);
|
|
2608
|
+
return testExecutionProof(scriptCommand, projectRoot, seenScripts, true);
|
|
2609
|
+
} catch { return null; }
|
|
2610
|
+
}
|
|
2611
|
+
if (["vitest", "jest", "mocha", "ava", "pytest", "rspec", "phpunit"].includes(executableName)
|
|
2612
|
+
&& !tokens.some((token) => /pass.?with.?no.?tests/i.test(token))) {
|
|
2613
|
+
if (executable !== executableName) return null;
|
|
2614
|
+
const targets = tokens.slice(1)
|
|
2615
|
+
.filter((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token))
|
|
2616
|
+
.flatMap((token) => /[*?\[]/.test(token) ? fs.globSync(token, { cwd: projectRoot }) : [token]);
|
|
2617
|
+
const units = targets.reduce((total, target) => total + staticTestUnits(path.resolve(projectRoot, target), executableName), 0);
|
|
2618
|
+
return units > 0 ? { kind: "local-process-exit", runner: executableName, static_test_units: units } : null;
|
|
2619
|
+
}
|
|
2620
|
+
if (executableName === "cargo" && tokens[1] === "test") {
|
|
2621
|
+
const files = [...fs.globSync("tests/**/*.rs", { cwd: projectRoot }), ...fs.globSync("src/**/*.rs", { cwd: projectRoot })];
|
|
2622
|
+
const units = [...new Set(files)].reduce((total, file) => total + staticTestUnits(path.resolve(projectRoot, file), "cargo"), 0);
|
|
2623
|
+
return units > 0 ? { kind: "local-process-exit", runner: "cargo test", static_test_units: units } : null;
|
|
2624
|
+
}
|
|
2625
|
+
if (executableName === "go" && tokens[1] === "test") {
|
|
2626
|
+
const files = fs.globSync("**/*_test.go", { cwd: projectRoot, exclude: ["vendor/**", ".git/**"] });
|
|
2627
|
+
const units = files.reduce((total, file) => total + staticTestUnits(path.resolve(projectRoot, file), "go"), 0);
|
|
2628
|
+
return units > 0 ? { kind: "local-process-exit", runner: "go test", static_test_units: units } : null;
|
|
2629
|
+
}
|
|
2630
|
+
if (executableName === "npx" && tokens[1] && ["vitest", "jest", "mocha", "ava", "pytest"].includes(path.basename(tokens[1]))) {
|
|
2631
|
+
const runner = path.basename(tokens[1]);
|
|
2632
|
+
const targets = tokens.slice(2)
|
|
2633
|
+
.filter((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token))
|
|
2634
|
+
.flatMap((token) => /[*?\[]/.test(token) ? fs.globSync(token, { cwd: projectRoot }) : [token]);
|
|
2635
|
+
const units = targets.reduce((total, target) => total + staticTestUnits(path.resolve(projectRoot, target), runner), 0);
|
|
2636
|
+
return units > 0 ? { kind: "local-process-exit", runner: `npx ${runner}`, static_test_units: units } : null;
|
|
2637
|
+
}
|
|
2638
|
+
if (executableName === "node" && tokens.includes("--test")) {
|
|
2639
|
+
const testFlag = tokens.indexOf("--test");
|
|
2640
|
+
const targets = tokens.slice(testFlag + 1)
|
|
2641
|
+
.filter((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token))
|
|
2642
|
+
.flatMap((token) => /[*?\[]/.test(token) ? fs.globSync(token, { cwd: projectRoot }) : [token]);
|
|
2643
|
+
const units = targets.reduce((total, target) => total + staticTestUnits(path.resolve(projectRoot, target), executableName), 0);
|
|
2644
|
+
return units > 0 ? { kind: "local-process-exit", runner: "node --test", static_test_units: units } : null;
|
|
2645
|
+
}
|
|
2646
|
+
const scriptPath = ["bash", "sh", "zsh", "tsx", "ts-node"].includes(executableName) ? tokens[1] : executable;
|
|
2647
|
+
if (!scriptPath || ["-c", "-lc", "-e"].includes(scriptPath) || !hasTestIntent(scriptPath)) return null;
|
|
2648
|
+
try {
|
|
2649
|
+
const resolved = path.resolve(projectRoot, scriptPath);
|
|
2650
|
+
const stat = fs.lstatSync(resolved);
|
|
2651
|
+
if (stat.isSymbolicLink() || !stat.isFile() || !pathIsWithinRoot(fs.realpathSync(resolved), fs.realpathSync(projectRoot))) return null;
|
|
2652
|
+
const units = staticTestUnits(resolved, executableName);
|
|
2653
|
+
return units > 0 ? { kind: "local-process-exit", runner: executableName, static_test_units: units } : null;
|
|
2654
|
+
} catch { return null; }
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
/** Validate test-evidence command shape without executing it. */
|
|
2658
|
+
export function isMeaningfulTestCommand(command: string, projectRoot: string, seenScripts = new Set<string>(), packageScriptBody = false): boolean {
|
|
2659
|
+
return testExecutionProof(command, projectRoot, seenScripts, packageScriptBody) !== null;
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
export function observedExecutedTestCount(output: string): number {
|
|
2663
|
+
const counts: number[] = [];
|
|
2664
|
+
for (const pattern of [
|
|
2665
|
+
/^\s*(?:#|ℹ)\s*tests\s+(\d+)\s*$/gim,
|
|
2666
|
+
/^\s*1\.\.(\d+)(?:\s|$)/gim,
|
|
2667
|
+
/\b(\d+)\s+passed\b/gim,
|
|
2668
|
+
]) {
|
|
2669
|
+
for (const match of output.matchAll(pattern)) counts.push(Number(match[1]));
|
|
2670
|
+
}
|
|
2671
|
+
const explicitPasses = output.match(/^\s*(?:---\s+PASS:|ok\s+\d+\s+-)/gm)?.length ?? 0;
|
|
2672
|
+
if (explicitPasses > 0) counts.push(explicitPasses);
|
|
2673
|
+
const goPackages = output.match(/^ok\s+\S+(?:\s|$)/gm)?.length ?? 0;
|
|
2674
|
+
if (goPackages > 0) counts.push(goPackages);
|
|
2675
|
+
return Math.max(0, ...counts.filter((count) => Number.isSafeInteger(count) && count > 0));
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
export function inferExecutedTestCount(command: string, projectRoot: string, output: string, seenScripts = new Set<string>()): number {
|
|
2679
|
+
const proof = testExecutionProof(command, projectRoot, seenScripts);
|
|
2680
|
+
if (!proof) return 0;
|
|
2681
|
+
const observed = observedExecutedTestCount(output);
|
|
2682
|
+
return observed > 0 ? Math.min(proof.static_test_units, observed) : 0;
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
type ObservedCommand = { command: string; exit_code: number; output_sha256: string; test_count?: number; execution_proof?: TestExecutionProof };
|
|
2686
|
+
|
|
2687
|
+
async function normalizeObservedCommands(commands: string[], projectRoot: string, requireTestIntent: boolean, expectedStatus: string): Promise<ObservedCommand[]> {
|
|
2688
|
+
if (commands.length === 0) die("record-gate-claim requires at least one --command for observed command evidence");
|
|
2689
|
+
if (new Set(commands).size !== commands.length) die("record-gate-claim --command values must be unique");
|
|
2690
|
+
for (const command of commands) {
|
|
2691
|
+
const { isRunnableCommandText } = loadRunnableCommandHelper();
|
|
2692
|
+
if (!isRunnableCommandText(command)) die(`record-gate-claim --command "${command}" is not a runnable shell command — prose belongs in --summary, which is never executed.`);
|
|
2693
|
+
if (requireTestIntent && !isMeaningfulTestCommand(command, projectRoot)) die("record-gate-claim tests-evidence command must resolve through a non-vacuous package script or a known test/check/verify/eval runner or project-local test path; shell wrappers, no-ops, version/help commands, and arbitrary node -e commands are not evidence");
|
|
2694
|
+
}
|
|
2695
|
+
// Passing test evidence is always executed exactly once by this canonical
|
|
2696
|
+
// writer. Caller-supplied observations remain available for non-test
|
|
2697
|
+
// attestations but can never stand in for locally observed test execution.
|
|
2698
|
+
const observed = await Promise.all(commands.map(async (command) => {
|
|
2699
|
+
const result = await runObservedCommand(command, projectRoot);
|
|
2700
|
+
const proof = requireTestIntent ? testExecutionProof(command, projectRoot) : null;
|
|
2701
|
+
return { command, exit_code: result.exit_code, output_sha256: result.output_sha256, ...(proof ? { test_count: inferExecutedTestCount(command, projectRoot, result.output), execution_proof: proof } : {}) };
|
|
2702
|
+
}));
|
|
2703
|
+
if (observed.length !== commands.length) die("record-gate-claim requires exactly one --observed-command-json for every --command");
|
|
2704
|
+
const byCommand = new Map<string, ObservedCommand>();
|
|
2705
|
+
for (const entry of observed) {
|
|
2706
|
+
if (typeof entry.command !== "string" || typeof entry.exit_code !== "number" || !Number.isInteger(entry.exit_code) || typeof entry.output_sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(entry.output_sha256)) die("--observed-command-json must contain command, integer exit_code, and sha256 output_sha256");
|
|
2707
|
+
if (!commands.includes(entry.command)) die("--observed-command-json command must exactly match one supplied --command");
|
|
2708
|
+
if (expectedStatus === "pass" && entry.exit_code !== 0) die(`record-gate-claim passing evidence command failed (exit ${entry.exit_code}): ${entry.command}`);
|
|
2709
|
+
if (expectedStatus === "fail" && entry.exit_code === 0) die(`record-gate-claim failing evidence command unexpectedly passed: ${entry.command}`);
|
|
2710
|
+
if (requireTestIntent && (!Number.isSafeInteger(entry.test_count) || Number(entry.test_count) <= 0 || !entry.execution_proof || entry.execution_proof.kind !== "local-process-exit")) die(`record-gate-claim passing tests-evidence command did not produce a local execution proof: ${entry.command}`);
|
|
2711
|
+
if (byCommand.has(entry.command)) die("--observed-command-json command values must be unique");
|
|
2712
|
+
byCommand.set(entry.command, entry as ObservedCommand);
|
|
2713
|
+
}
|
|
2714
|
+
if (commands.some((command) => !byCommand.has(command))) die("every --command requires a corresponding --observed-command-json");
|
|
2715
|
+
return commands.map((command) => byCommand.get(command)!);
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
function requireObservedCommandRefs(refs: AnyObj[], observedCommands: ReadonlySet<string>, label: string, requireAll = false): void {
|
|
2719
|
+
const commandRefs = refs.filter((ref) => ref.kind === "command");
|
|
2720
|
+
if (commandRefs.length === 0) die(`${label} requires a command evidence ref matching a successful observed command`);
|
|
2721
|
+
for (const ref of commandRefs) {
|
|
2722
|
+
if (!observedCommands.has(commandFromEvidenceRef(ref))) die(`${label} command evidence ref must exactly match a successful --observed-command-json command`);
|
|
2723
|
+
}
|
|
2724
|
+
if (requireAll) {
|
|
2725
|
+
const referenced = new Set(commandRefs.map(commandFromEvidenceRef));
|
|
2726
|
+
if ([...observedCommands].some((command) => !referenced.has(command))) die(`${label} requires a top-level command evidence ref for every successful observed command`);
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
function completePassingCriteria(existing: AnyObj[], raw: string[], observedCommands: ReadonlySet<string>): AnyObj[] {
|
|
2731
|
+
if (raw.length === 0) die("record-gate-claim requires --criterion-json for a passing tests-evidence claim");
|
|
2732
|
+
const incoming = raw.map((value) => parseJson(value, "--criterion-json"));
|
|
2733
|
+
const expectedById = new Map<string, AnyObj>();
|
|
2734
|
+
for (const criterion of existing) {
|
|
2735
|
+
const id = String(criterion.id ?? "");
|
|
2736
|
+
if (id.length > 0) expectedById.set(id, criterion);
|
|
2737
|
+
}
|
|
2738
|
+
const expectedIds = [...expectedById.keys()];
|
|
2739
|
+
const ids = incoming.map((criterion) => typeof criterion.id === "string" ? criterion.id : "");
|
|
2740
|
+
if (new Set(ids).size !== ids.length || ids.length !== expectedIds.length || ids.some((id) => !expectedIds.includes(id))) {
|
|
2741
|
+
die(`--criterion-json must cover every declared acceptance criterion exactly once (expected: ${expectedIds.join(", ") || "none"}; received: ${ids.join(", ") || "none"})`);
|
|
2742
|
+
}
|
|
2743
|
+
return incoming.map((criterion, index) => {
|
|
2744
|
+
if (Object.keys(criterion).some((key) => !["id", "status", "evidence_refs"].includes(key))) die(`criterion ${ids[index]} may update only id, status, and evidence_refs`);
|
|
2745
|
+
if (criterion.status !== "pass") die(`criterion ${ids[index]} must have status pass for a passing tests-evidence claim`);
|
|
2746
|
+
const refs = normalizeEvidenceRefs(criterion.evidence_refs, `criterion ${ids[index]} evidence_refs`);
|
|
2747
|
+
if (refs.length === 0) die(`criterion ${ids[index]} requires reviewable evidence_refs`);
|
|
2748
|
+
requireObservedCommandRefs(refs, observedCommands, `criterion ${ids[index]}`);
|
|
2749
|
+
return { ...expectedById.get(ids[index])!, status: "pass", evidence_refs: refs };
|
|
2750
|
+
});
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
const critiqueStatuses = new Set(["pass", "fail", "not_verified"]);
|
|
2754
|
+
const safeCritiqueId = /^[a-z][a-z0-9_-]*$/;
|
|
2755
|
+
|
|
2756
|
+
function normalizeCritiqueLanes(raw: string[]): AnyObj[] {
|
|
2757
|
+
if (raw.length === 0) die("record-critique requires at least one --lane-json");
|
|
2758
|
+
const lanes = raw.map((value, index) => {
|
|
2759
|
+
const lane = parseJson(value, "--lane-json");
|
|
2760
|
+
const keys = Object.keys(lane);
|
|
2761
|
+
if (keys.some((key) => !["id", "status", "summary", "evidence_refs"].includes(key))) die(`--lane-json ${index} contains unsupported fields`);
|
|
2762
|
+
if (!safeCritiqueId.test(String(lane.id ?? ""))) die(`--lane-json ${index} id must be a unique safe identifier`);
|
|
2763
|
+
if (!critiqueStatuses.has(String(lane.status ?? ""))) die(`--lane-json ${index} status must be one of: pass, fail, not_verified`);
|
|
2764
|
+
if (!hasNonEmptyString(lane.summary)) die(`--lane-json ${index} summary must be non-empty`);
|
|
2765
|
+
const evidenceRefs = normalizeEvidenceRefs(lane.evidence_refs, `--lane-json ${index} evidence_refs`);
|
|
2766
|
+
if (evidenceRefs.length === 0) die(`--lane-json ${index} requires structured reviewable evidence_refs`);
|
|
2767
|
+
return { id: lane.id, status: lane.status, summary: lane.summary, evidence_refs: evidenceRefs };
|
|
2768
|
+
});
|
|
2769
|
+
if (new Set(lanes.map((lane) => lane.id)).size !== lanes.length) die("--lane-json ids must be unique");
|
|
2770
|
+
return lanes;
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
function reviewTargetArtifacts(dir: string, rawPaths: string[], label: string): AnyObj[] {
|
|
2774
|
+
const projectRoot = canonicalProjectRootForSession(dir);
|
|
2775
|
+
const fallback = path.join(dir, `${taskSlugFor(dir)}--deliver.md`);
|
|
2776
|
+
const paths = rawPaths.length > 0 ? rawPaths : (fs.existsSync(fallback) ? [fallback] : []);
|
|
2777
|
+
const files = paths.map((raw, index) => validateLocalEvidenceFile(projectRoot, raw, `${label} artifact ${index}`));
|
|
2778
|
+
if (new Set(files).size !== files.length) die(`${label} artifact refs must be unique`);
|
|
2779
|
+
return files.map((file) => ({ file, sha256: createHash("sha256").update(fs.readFileSync(path.join(projectRoot, file))).digest("hex") }));
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
function reviewTargetArtifactsMatch(dir: string, reviewTarget: unknown): boolean {
|
|
2783
|
+
try {
|
|
2784
|
+
if (!reviewTarget || typeof reviewTarget !== "object" || Array.isArray(reviewTarget)) return false;
|
|
2785
|
+
const artifacts = (reviewTarget as AnyObj).artifacts;
|
|
2786
|
+
if (!Array.isArray(artifacts) || artifacts.length === 0) return false;
|
|
2787
|
+
const projectRoot = canonicalProjectRootForSession(dir);
|
|
2788
|
+
const files = new Set<string>();
|
|
2789
|
+
return artifacts.every((artifact) => {
|
|
2790
|
+
if (!artifact || typeof artifact !== "object") return false;
|
|
2791
|
+
const file = (artifact as AnyObj).file;
|
|
2792
|
+
const sha256 = (artifact as AnyObj).sha256;
|
|
2793
|
+
if (!hasNonEmptyString(file) || !/^[a-f0-9]{64}$/i.test(String(sha256)) || files.has(file)) return false;
|
|
2794
|
+
files.add(file);
|
|
2795
|
+
const relative = validateLocalEvidenceFile(projectRoot, file, "critique review_target artifact");
|
|
2796
|
+
const digest = createHash("sha256").update(fs.readFileSync(path.join(projectRoot, relative))).digest("hex");
|
|
2797
|
+
return digest === sha256;
|
|
2798
|
+
});
|
|
2799
|
+
} catch { return false; }
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
function critiqueIsCleanAndCurrent(dir: string, critique: AnyObj): boolean {
|
|
2803
|
+
if (critique.verdict !== "pass") return false;
|
|
2804
|
+
if (!Array.isArray(critique.lanes) || critique.lanes.length === 0 || critique.lanes.some((lane: AnyObj) => lane.status !== "pass")) return false;
|
|
2805
|
+
if (Array.isArray(critique.findings) && critique.findings.some((finding: AnyObj) => finding.status === "open")) return false;
|
|
2806
|
+
return reviewTargetArtifactsMatch(dir, critique.review_target);
|
|
2807
|
+
}
|
|
2306
2808
|
// #270 HIGH fix (iteration 3): the `gate-claim-` check-id prefix is RESERVED for
|
|
2307
2809
|
// record-gate-claim's own internally-constructed ids (`id: \`gate-claim-${checkId}\`` — see
|
|
2308
2810
|
// recordGateClaim below). Every OTHER writer of check ids (record-evidence --check-json,
|
|
@@ -2604,6 +3106,17 @@ function checksFromBundle(dir: string): AnyObj[] {
|
|
|
2604
3106
|
const od = md && typeof md === "object" ? md.output_digest as AnyObj : undefined;
|
|
2605
3107
|
return od && typeof od === "object" && od.algorithm === "sha256" && typeof od.hex === "string" && od.hex.length > 0 ? od.hex : undefined;
|
|
2606
3108
|
};
|
|
3109
|
+
const observedCommandsOf = (claim: AnyObj): ObservedCommand[] | undefined => {
|
|
3110
|
+
const md = claim.metadata as AnyObj;
|
|
3111
|
+
const observed = md && typeof md === "object" ? md.observed_commands : undefined;
|
|
3112
|
+
return Array.isArray(observed) ? observed.filter((entry: AnyObj) => typeof entry?.command === "string" && typeof entry?.exit_code === "number" && typeof entry?.output_sha256 === "string") : undefined;
|
|
3113
|
+
};
|
|
3114
|
+
const applyProducerStamp = (check: AnyObj, claim: AnyObj): void => {
|
|
3115
|
+
const md = claim.metadata as AnyObj;
|
|
3116
|
+
if (md && typeof md.expected_producer === "string") check._producer = md.expected_producer;
|
|
3117
|
+
if (md && typeof md.recorded_by === "string") check._recorded_by = md.recorded_by;
|
|
3118
|
+
if (md && Array.isArray(md.self_produced_trust_slices)) check._producer_self_produced_trust_slices = md.self_produced_trust_slices;
|
|
3119
|
+
};
|
|
2607
3120
|
// #270(a)/(c): read side of the gate_claim stamp (write side: buildTrustBundle's
|
|
2608
3121
|
// claimMetadata.gate_claim, above). Restoring expectation_id/claim_type/subject_type/step_id
|
|
2609
3122
|
// is what lets a REBUILD (record-evidence/record-critique/record-learning, after the recorded
|
|
@@ -2677,6 +3190,9 @@ function checksFromBundle(dir: string): AnyObj[] {
|
|
|
2677
3190
|
Object.assign(check, refsOf(claim));
|
|
2678
3191
|
const outputSha256 = outputSha256Of(claim);
|
|
2679
3192
|
if (outputSha256) check._output_sha256 = outputSha256;
|
|
3193
|
+
const observedCommands = observedCommandsOf(claim);
|
|
3194
|
+
if (observedCommands) check._observed_commands = observedCommands;
|
|
3195
|
+
applyProducerStamp(check, claim);
|
|
2680
3196
|
applyGateClaimStamp(check, claim);
|
|
2681
3197
|
applyGateClaimShapeUnstamped(check, claim);
|
|
2682
3198
|
checks.push(check);
|
|
@@ -2694,6 +3210,9 @@ function checksFromBundle(dir: string): AnyObj[] {
|
|
|
2694
3210
|
Object.assign(check, refsOf(claim));
|
|
2695
3211
|
const outputSha256 = outputSha256Of(claim);
|
|
2696
3212
|
if (outputSha256) check._output_sha256 = outputSha256;
|
|
3213
|
+
const observedCommands = observedCommandsOf(claim);
|
|
3214
|
+
if (observedCommands) check._observed_commands = observedCommands;
|
|
3215
|
+
applyProducerStamp(check, claim);
|
|
2697
3216
|
applyGateClaimStamp(check, claim);
|
|
2698
3217
|
applyGateClaimShapeUnstamped(check, claim);
|
|
2699
3218
|
checks.push(check);
|
|
@@ -2728,9 +3247,18 @@ function existingCheckStampMap(checks: AnyObj[]): Map<string, boolean> {
|
|
|
2728
3247
|
*/
|
|
2729
3248
|
function readBundleState(dir: string): { checks: AnyObj[]; criteria: AnyObj[]; critiques: AnyObj[] } {
|
|
2730
3249
|
const acceptance = loadJson(path.join(dir, "acceptance.json"));
|
|
3250
|
+
const bundledCriteria = criteriaFromBundle(dir);
|
|
3251
|
+
const acceptedCriteria = Array.isArray(acceptance.criteria) ? acceptance.criteria as AnyObj[] : [];
|
|
3252
|
+
const contractSignature = (criteria: AnyObj[]): string => JSON.stringify(criteria.map((criterion) => ({
|
|
3253
|
+
id: criterion.id ?? null,
|
|
3254
|
+
description: criterion.description ?? null,
|
|
3255
|
+
})));
|
|
3256
|
+
const criteria = acceptedCriteria.length > 0 && contractSignature(acceptedCriteria) !== contractSignature(bundledCriteria)
|
|
3257
|
+
? acceptedCriteria
|
|
3258
|
+
: (bundledCriteria.length > 0 ? bundledCriteria : acceptedCriteria);
|
|
2731
3259
|
return {
|
|
2732
3260
|
checks: checksFromBundle(dir),
|
|
2733
|
-
criteria
|
|
3261
|
+
criteria,
|
|
2734
3262
|
critiques: critiquesFromBundle(dir),
|
|
2735
3263
|
};
|
|
2736
3264
|
}
|
|
@@ -2761,14 +3289,35 @@ function critiquesFromBundle(dir: string): AnyObj[] {
|
|
|
2761
3289
|
id: String(c.subjectId || "").split("/").pop() || c.id,
|
|
2762
3290
|
verdict: c.value ?? "not_verified",
|
|
2763
3291
|
summary: c.fieldOrBehavior || "",
|
|
2764
|
-
findings: [],
|
|
3292
|
+
findings: Array.isArray(md.findings) ? md.findings : [],
|
|
3293
|
+
lanes: Array.isArray(md.lanes) ? md.lanes : [],
|
|
3294
|
+
review_target: md.review_target && typeof md.review_target === "object" && !Array.isArray(md.review_target) ? md.review_target : { artifacts: [] },
|
|
2765
3295
|
reviewer: typeof md.reviewer === "string" ? md.reviewer : "tool-code-reviewer",
|
|
2766
3296
|
reviewed_at: typeof md.reviewed_at === "string" ? md.reviewed_at : (c.updatedAt || c.createdAt || now()),
|
|
2767
|
-
artifact_refs: [],
|
|
3297
|
+
artifact_refs: Array.isArray(md.artifact_refs) ? md.artifact_refs : [],
|
|
2768
3298
|
...(typeof md.superseded_by === "string" && md.superseded_by.length > 0 ? { superseded_by: md.superseded_by } : {}),
|
|
2769
3299
|
};
|
|
2770
3300
|
});
|
|
2771
3301
|
}
|
|
3302
|
+
|
|
3303
|
+
function criteriaFromBundle(dir: string): AnyObj[] {
|
|
3304
|
+
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
3305
|
+
if (!Array.isArray(bundle.claims)) return [];
|
|
3306
|
+
for (const claim of bundle.claims) requireStampedClaim(claim, dir);
|
|
3307
|
+
return bundle.claims
|
|
3308
|
+
.filter((claim: AnyObj) => claim && claimOrigin(claim) === "acceptance")
|
|
3309
|
+
.map((claim: AnyObj) => {
|
|
3310
|
+
const md = claim.metadata && typeof claim.metadata === "object" && !Array.isArray(claim.metadata) ? claim.metadata as AnyObj : {};
|
|
3311
|
+
const saved = md.criterion && typeof md.criterion === "object" && !Array.isArray(md.criterion) ? md.criterion as AnyObj : {};
|
|
3312
|
+
return {
|
|
3313
|
+
id: typeof saved.id === "string" ? saved.id : String(claim.subjectId || "").split("/").pop(),
|
|
3314
|
+
description: typeof saved.description === "string" ? saved.description : (claim.fieldOrBehavior || ""),
|
|
3315
|
+
status: typeof saved.status === "string" ? saved.status : (claim.value ?? "not_verified"),
|
|
3316
|
+
evidence_refs: Array.isArray(saved.evidence_refs) ? saved.evidence_refs : [],
|
|
3317
|
+
};
|
|
3318
|
+
})
|
|
3319
|
+
.filter((criterion: AnyObj) => typeof criterion.id === "string" && criterion.id.length > 0);
|
|
3320
|
+
}
|
|
2772
3321
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
2773
3322
|
/**
|
|
2774
3323
|
* WS8 (ADR 0020): parse the accepted-gap waiver flags. Both --accepted-gap-reason and
|
|
@@ -2827,13 +3376,11 @@ async function recordEvidence(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
2827
3376
|
// with the same id supersedes the earlier one (same-id resupply); a new id is additive.
|
|
2828
3377
|
// (_existingState was already read above, before normalizeCheck ran, so the reserved-prefix
|
|
2829
3378
|
// exists-check sees the SAME bundle snapshot the merge below uses — no second read needed.)
|
|
2830
|
-
const _criteriaStatus = verdict === "pass" ? "pass" : verdict === "fail" ? "fail" : "not_verified";
|
|
2831
|
-
const _criteriaForBundle: AnyObj[] = _existingState.criteria.map((c: AnyObj) => ({ ...c, status: _criteriaStatus }));
|
|
2832
3379
|
const _mergedChecks = mergeChecksById(_existingState.checks, checks);
|
|
2833
3380
|
// #268: preserve any existing critique claims (including superseded history) instead of dropping
|
|
2834
3381
|
// them — record-evidence previously hardcoded critiques:[] here, silently erasing finding history
|
|
2835
3382
|
// whenever it ran after a critique existed.
|
|
2836
|
-
assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks,
|
|
3383
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, _existingState.criteria, _existingState.critiques));
|
|
2837
3384
|
const stateStatus = verdict === "pass" ? "verified" : verdict === "fail" ? "failed" : "not_verified";
|
|
2838
3385
|
writeState(dir, slug, stateStatus, "verification", ts, "Evidence recorded.");
|
|
2839
3386
|
return 0;
|
|
@@ -3038,14 +3585,22 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
3038
3585
|
if (routeReason && statusVal !== "fail") die("--route-reason is only valid with --status fail");
|
|
3039
3586
|
if (routeReason && !/^[a-z][a-z0-9_-]*$/.test(routeReason)) die("--route-reason must be a lowercase classifier identifier");
|
|
3040
3587
|
|
|
3041
|
-
//
|
|
3042
|
-
//
|
|
3043
|
-
//
|
|
3044
|
-
// Stop-short risk) lack of a dir-scoping guard: it now resolves ITS OWN actor's current.json
|
|
3045
|
-
// view, not a different actor's more-recently-written legacy pointer.
|
|
3588
|
+
// Prefer the exact session's canonical Flow projection. Actor/global current pointers are
|
|
3589
|
+
// ambient navigation state and may legitimately point at another run or lag this run.
|
|
3590
|
+
// Legacy sessions without flow_run retain the per-actor current-pointer fallback.
|
|
3046
3591
|
const flowAgentsDir = path.dirname(dir);
|
|
3047
3592
|
const gateClaimActorKey = resolveReadActorKey(p);
|
|
3048
|
-
const
|
|
3593
|
+
const sidecarState = loadJson(path.join(dir, "state.json"));
|
|
3594
|
+
const projectedRun = sidecarState.flow_run && typeof sidecarState.flow_run === "object" && !Array.isArray(sidecarState.flow_run)
|
|
3595
|
+
? sidecarState.flow_run as AnyObj
|
|
3596
|
+
: null;
|
|
3597
|
+
const exactFlowId = projectedRun && typeof projectedRun.definition_id === "string" ? projectedRun.definition_id : null;
|
|
3598
|
+
const exactStepId = projectedRun && typeof projectedRun.current_step === "string" ? projectedRun.current_step : null;
|
|
3599
|
+
const exactFlowContext = exactFlowId && exactStepId ? { flowId: exactFlowId, stepId: exactStepId } : undefined;
|
|
3600
|
+
const activeStep = exactFlowContext
|
|
3601
|
+
? resolveFlowStep(exactFlowContext.flowId, exactFlowContext.stepId, findRepoRootFromDir(path.dirname(flowAgentsDir)))
|
|
3602
|
+
: resolveActiveFlowStep(flowAgentsDir, gateClaimActorKey);
|
|
3603
|
+
if (exactFlowContext && !activeStep) die(`record-gate-claim cannot resolve exact session Flow step ${exactFlowContext.flowId}/${exactFlowContext.stepId}`);
|
|
3049
3604
|
if (!activeStep) die("record-gate-claim requires an active flow step in current.json (set via ensure-session --flow-id or advance-state --flow-definition)");
|
|
3050
3605
|
if (routeReason && !activeStep.routeBackReasons.includes(routeReason)) {
|
|
3051
3606
|
die(`--route-reason "${routeReason}" is not declared by gate "${activeStep.gateId}". Available: ${activeStep.routeBackReasons.join(", ") || "none"}`);
|
|
@@ -3066,18 +3621,34 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
3066
3621
|
}
|
|
3067
3622
|
|
|
3068
3623
|
const { claimType, subjectType } = targetExpectation.bundle_claim;
|
|
3624
|
+
const gateCommands = opts(p, "command");
|
|
3625
|
+
const gateCommand = gateCommands[0] ?? "";
|
|
3626
|
+
const mustRunTests = targetExpectation.id === "tests-evidence" && statusVal === "pass";
|
|
3627
|
+
const observedCommandRaw = opts(p, "observed-command-json");
|
|
3628
|
+
if (mustRunTests && gateCommands.length === 0) die("record-gate-claim requires at least one --command for a passing tests-evidence claim");
|
|
3629
|
+
const projectRoot = gateCommands.length > 0 ? canonicalProjectRootForSession(dir) : null;
|
|
3630
|
+
const observedCommands = gateCommands.length > 0
|
|
3631
|
+
? await normalizeObservedCommands(gateCommands, projectRoot!, mustRunTests, statusVal)
|
|
3632
|
+
: [];
|
|
3633
|
+
const observedCommandNames = new Set(observedCommands.map((entry) => entry.command));
|
|
3634
|
+
let outputSha256: string | null = null;
|
|
3635
|
+
if (!mustRunTests && gateCommands.length > 1) die("record-gate-claim accepts repeatable --command only for passing tests-evidence claims");
|
|
3636
|
+
if (gateCommands.length === 0 && observedCommandRaw.length > 0) die("--observed-command-json requires --command");
|
|
3637
|
+
if (!mustRunTests && gateCommand && observedCommandRaw.length === 0) {
|
|
3638
|
+
const { isRunnableCommandText } = loadRunnableCommandHelper();
|
|
3639
|
+
if (!isRunnableCommandText(gateCommand)) die(`record-gate-claim --command "${gateCommand}" is not a runnable shell command — prose belongs in --summary, which is never executed.`);
|
|
3640
|
+
}
|
|
3641
|
+
if (observedCommands.length > 0) outputSha256 = observedCommands[0]!.output_sha256;
|
|
3069
3642
|
|
|
3070
|
-
// Build a
|
|
3071
|
-
//
|
|
3072
|
-
// non-flow-step fallback path. The subjectType on the resulting claim comes from the
|
|
3073
|
-
// expects[] entry via matchExpectsEntry.
|
|
3643
|
+
// Build a gate-targeted check that matchExpectsEntry maps to the declared claim tuple.
|
|
3644
|
+
// Command-backed checks retain their executable evidence classification.
|
|
3074
3645
|
const checkId = expectationId || targetExpectation.id;
|
|
3075
3646
|
// Build a minimal "external" check. Include _gate_claim_expectation_id so that
|
|
3076
3647
|
// matchExpectsEntry can do an exact lookup for multi-expects[] gates (ADR 0016 P-d Increment 2).
|
|
3077
3648
|
// normalizeCheck preserves extra underscore-prefixed fields without stripping them.
|
|
3078
3649
|
const check: AnyObj = {
|
|
3079
3650
|
id: `gate-claim-${checkId}`,
|
|
3080
|
-
kind: "external",
|
|
3651
|
+
kind: gateCommands.length > 0 ? "command" : "external",
|
|
3081
3652
|
status: statusVal,
|
|
3082
3653
|
summary,
|
|
3083
3654
|
_gate_claim_expectation_id: targetExpectation.id,
|
|
@@ -3086,10 +3657,16 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
3086
3657
|
|
|
3087
3658
|
// Include structured evidence refs if provided
|
|
3088
3659
|
const evidenceRefs: AnyObj[] = opts(p, "evidence-ref-json").map((v) => validateEvidenceRef(parseJson(v, "--evidence-ref-json"), "--evidence-ref-json"));
|
|
3660
|
+
const producer = expectedGateProducer(exactFlowContext?.flowId ?? activeStep.flowId, activeStep.stepId, targetExpectation.id);
|
|
3661
|
+
if (statusVal === "pass") validateReviewableGateEvidence(dir, slug, evidenceRefs, producer, `gate claim ${targetExpectation.id}`);
|
|
3662
|
+
if (mustRunTests) requireObservedCommandRefs(evidenceRefs, observedCommandNames, "a passing tests-evidence claim", true);
|
|
3089
3663
|
|
|
3090
3664
|
if (evidenceRefs.length > 0) {
|
|
3091
3665
|
check.artifact_refs = evidenceRefs;
|
|
3092
3666
|
}
|
|
3667
|
+
check._producer = producer.id;
|
|
3668
|
+
check._recorded_by = gateClaimActorKey;
|
|
3669
|
+
if (producer.selfProducedTrustSlices.length > 0) check._producer_self_produced_trust_slices = producer.selfProducedTrustSlices;
|
|
3093
3670
|
|
|
3094
3671
|
// #270(b)/#412: --command gives a gate claim a real, runnable execution.label distinct from
|
|
3095
3672
|
// the human --summary prose, so stop-goal-fit.js's bundleClaimedPassCommandChecks section (B)
|
|
@@ -3097,14 +3674,11 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
3097
3674
|
// prose itself), which it would otherwise execute as a shell command under
|
|
3098
3675
|
// FLOW_AGENTS_GOAL_FIT_RECHECK. Validated at record time with the same isRunnableCommandText
|
|
3099
3676
|
// heuristic the Stop-hook backstop uses (single-sourced — see loadRunnableCommandHelper).
|
|
3100
|
-
|
|
3101
|
-
if (
|
|
3102
|
-
const { isRunnableCommandText } = loadRunnableCommandHelper();
|
|
3103
|
-
if (!isRunnableCommandText(gateCommand)) die(`record-gate-claim --command "${gateCommand}" is not a runnable shell command — prose belongs in --summary, which is never executed.`);
|
|
3104
|
-
check.command = gateCommand;
|
|
3105
|
-
}
|
|
3677
|
+
if (gateCommand) check.command = gateCommand;
|
|
3678
|
+
if (observedCommands.length > 0) check._observed_commands = observedCommands;
|
|
3106
3679
|
|
|
3107
3680
|
const checkNormalized = normalizeCheck(check, /* allowGateClaimPrefix */ true);
|
|
3681
|
+
if (outputSha256) checkNormalized._output_sha256 = outputSha256;
|
|
3108
3682
|
// WS8 (ADR 0020): honor the accepted-gap waiver flags for a gate claim too.
|
|
3109
3683
|
const gateWaiver = parseWaiver(p, ts);
|
|
3110
3684
|
if (gateWaiver) checkNormalized._waiver = gateWaiver;
|
|
@@ -3116,8 +3690,16 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
3116
3690
|
// SAME expectation id supersedes the earlier check for that expectation (mergeChecksById); a
|
|
3117
3691
|
// gate claim against a different expectation is additive.
|
|
3118
3692
|
const _existingState = readBundleState(dir);
|
|
3693
|
+
const criteria = mustRunTests ? completePassingCriteria(_existingState.criteria, opts(p, "criterion-json"), observedCommandNames) : _existingState.criteria;
|
|
3694
|
+
if (mustRunTests) {
|
|
3695
|
+
const liveCritiques = _existingState.critiques.filter((critique) => !critique.superseded_by);
|
|
3696
|
+
if (liveCritiques.length === 0 || liveCritiques.some((critique) => !critiqueIsCleanAndCurrent(dir, critique))) {
|
|
3697
|
+
die("a passing tests-evidence claim requires a current clean critique first");
|
|
3698
|
+
}
|
|
3699
|
+
for (const criterion of criteria) validateReviewableGateEvidence(dir, slug, criterion.evidence_refs, producer, `criterion ${criterion.id}`);
|
|
3700
|
+
}
|
|
3119
3701
|
const _mergedChecks = mergeChecksById(_existingState.checks, [checkNormalized]);
|
|
3120
|
-
assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks,
|
|
3702
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, criteria, _existingState.critiques, gateClaimActorKey, exactFlowContext));
|
|
3121
3703
|
return 0;
|
|
3122
3704
|
}
|
|
3123
3705
|
|
|
@@ -3299,12 +3881,46 @@ export function normalizeFinding(raw: AnyObj): AnyObj {
|
|
|
3299
3881
|
async function recordCritique(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
3300
3882
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
3301
3883
|
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
const
|
|
3305
|
-
|
|
3306
|
-
const
|
|
3307
|
-
const
|
|
3884
|
+
const verdict = opt(p, "verdict");
|
|
3885
|
+
if (!critiqueStatuses.has(verdict)) die("record-critique requires --verdict pass, fail, or not_verified");
|
|
3886
|
+
const summary = opt(p, "summary");
|
|
3887
|
+
if (!hasNonEmptyString(summary)) die("record-critique requires --summary");
|
|
3888
|
+
const lanes = normalizeCritiqueLanes(opts(p, "lane-json"));
|
|
3889
|
+
const reviewArtifacts = reviewTargetArtifacts(dir, opts(p, "artifact-ref"), "record-critique review_target");
|
|
3890
|
+
if (verdict === "pass" && (lanes.some((lane) => lane.status !== "pass") || reviewArtifacts.length === 0)) {
|
|
3891
|
+
die("a passing critique requires every lane to pass and at least one local reviewed --artifact-ref");
|
|
3892
|
+
}
|
|
3893
|
+
const sidecarState = loadJson(path.join(dir, "state.json"));
|
|
3894
|
+
const projectedRun = sidecarState.flow_run && typeof sidecarState.flow_run === "object" && !Array.isArray(sidecarState.flow_run)
|
|
3895
|
+
? sidecarState.flow_run as AnyObj
|
|
3896
|
+
: null;
|
|
3897
|
+
const exactFlowContext = projectedRun
|
|
3898
|
+
&& typeof projectedRun.definition_id === "string"
|
|
3899
|
+
&& typeof projectedRun.current_step === "string"
|
|
3900
|
+
? { flowId: projectedRun.definition_id, stepId: projectedRun.current_step }
|
|
3901
|
+
: undefined;
|
|
3902
|
+
// trust.bundle is the sole critique source. critique.json is unsupported historical residue.
|
|
3903
|
+
const _critiqueState = readBundleState(dir);
|
|
3904
|
+
const bundleCritiques = _critiqueState.critiques;
|
|
3905
|
+
const critiqueId = opt(p, "id", "review");
|
|
3906
|
+
if (!safeCritiqueId.test(critiqueId)) die("record-critique --id must be a safe identifier");
|
|
3907
|
+
const critique = {
|
|
3908
|
+
id: critiqueId,
|
|
3909
|
+
reviewer: opt(p, "reviewer", "tool-code-reviewer"),
|
|
3910
|
+
reviewed_at: opt(p, "timestamp", now()),
|
|
3911
|
+
verdict,
|
|
3912
|
+
summary,
|
|
3913
|
+
lanes,
|
|
3914
|
+
review_target: {
|
|
3915
|
+
artifacts: reviewArtifacts,
|
|
3916
|
+
workspace_snapshot: captureReviewWorkspaceSnapshot(
|
|
3917
|
+
canonicalProjectRootForSession(dir),
|
|
3918
|
+
reviewArtifacts.map((artifact) => ({ file: String(artifact.file), sha256: String(artifact.sha256) })),
|
|
3919
|
+
),
|
|
3920
|
+
},
|
|
3921
|
+
artifact_refs: reviewArtifacts.map((artifact) => artifact.file),
|
|
3922
|
+
findings: opts(p, "finding-json").map((v) => normalizeFinding(parseJson(v, "--finding-json"))),
|
|
3923
|
+
};
|
|
3308
3924
|
if (critique.verdict === "pass" && critique.findings.some((f: AnyObj) => f.status === "open")) die("required critique must pass");
|
|
3309
3925
|
// #267/#282: supersede-by-critique-id. The latest write for a critique id wins for
|
|
3310
3926
|
// reconcile / status / validator purposes; each prior LIVE write for the same id is RETAINED as
|
|
@@ -3329,8 +3945,7 @@ async function recordCritique(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
3329
3945
|
// checksFromBundle + acceptance.json read inline — this is the exact pattern readBundleState
|
|
3330
3946
|
// already exists to share; recordLearning uses it directly (see below) and recordCritique
|
|
3331
3947
|
// previously duplicated it by hand for no reason.
|
|
3332
|
-
|
|
3333
|
-
assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueState.checks, _critiqueState.criteria, critiques));
|
|
3948
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueState.checks, _critiqueState.criteria, critiques, undefined, exactFlowContext));
|
|
3334
3949
|
return 0;
|
|
3335
3950
|
}
|
|
3336
3951
|
function frontmatter(text: string, key: string): string {
|
|
@@ -3353,7 +3968,22 @@ async function importCritique(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
3353
3968
|
const title = m.groups?.title ?? "finding";
|
|
3354
3969
|
findings.push({ id: slugify(title, `finding-${findings.length + 1}`), severity: (m.groups?.severity ?? "info").toLowerCase(), status: opt(p, "finding-status", verdict === "pass" ? "fixed" : "open"), description: title, file_refs: [m.groups?.target ?? review] });
|
|
3355
3970
|
}
|
|
3356
|
-
const
|
|
3971
|
+
const importedArtifact = path.relative(canonicalProjectRootForSession(dir), review);
|
|
3972
|
+
const parsed = {
|
|
3973
|
+
...p,
|
|
3974
|
+
positional: [dir],
|
|
3975
|
+
opts: {
|
|
3976
|
+
...p.opts,
|
|
3977
|
+
id: [slugify(path.basename(review).replace(/\.md$/, ""), "review")],
|
|
3978
|
+
reviewer: ["tool-code-reviewer"],
|
|
3979
|
+
verdict: [verdict],
|
|
3980
|
+
summary: [`Imported critique from ${path.basename(review)}`],
|
|
3981
|
+
"artifact-ref": [importedArtifact],
|
|
3982
|
+
"lane-json": [JSON.stringify({ id: "imported-review", status: verdict, summary: `Imported review artifact ${path.basename(review)}.`, evidence_refs: [{ kind: "artifact", file: importedArtifact, summary: "Imported review artifact." }] })],
|
|
3983
|
+
"finding-json": findings.map((f) => JSON.stringify(f)),
|
|
3984
|
+
},
|
|
3985
|
+
flags: p.flags,
|
|
3986
|
+
};
|
|
3357
3987
|
const result = await recordCritique(parsed);
|
|
3358
3988
|
if (verdict !== "pass") die("required critique must pass");
|
|
3359
3989
|
return result;
|
|
@@ -4774,10 +5404,7 @@ function evidenceClean(dir: string): boolean {
|
|
|
4774
5404
|
});
|
|
4775
5405
|
}
|
|
4776
5406
|
function critiqueClean(dir: string): boolean {
|
|
4777
|
-
//
|
|
4778
|
-
// legacy (pre-bundle-era) sessions — unrelated to origin stamping. When a trust.bundle IS
|
|
4779
|
-
// present, every claim must be stamped (requireStampedClaim); no claimType-derivation fallback
|
|
4780
|
-
// for an unstamped claim (#268/#344).
|
|
5407
|
+
// trust.bundle is the sole critique artifact. Legacy critique.json must not influence gates.
|
|
4781
5408
|
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
4782
5409
|
if (Array.isArray(bundle.claims)) {
|
|
4783
5410
|
for (const c of bundle.claims) requireStampedClaim(c, dir);
|
|
@@ -4788,14 +5415,11 @@ function critiqueClean(dir: string): boolean {
|
|
|
4788
5415
|
return claimOrigin(c) === "critique";
|
|
4789
5416
|
});
|
|
4790
5417
|
if (critiqueClaims.length === 0) return false; // no critique written yet
|
|
4791
|
-
return
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
});
|
|
5418
|
+
return critiquesFromBundle(dir)
|
|
5419
|
+
.filter((critique) => !critique.superseded_by)
|
|
5420
|
+
.every((critique) => critiqueIsCleanAndCurrent(dir, critique));
|
|
4795
5421
|
}
|
|
4796
|
-
|
|
4797
|
-
const c = loadJson(path.join(dir, "critique.json"), {});
|
|
4798
|
-
return c.status === "pass" && Array.isArray(c.critiques) && c.critiques.every((x: AnyObj) => x.verdict !== "fail" && (!Array.isArray(x.findings) || x.findings.every((f: AnyObj) => f.status !== "open" && (f.file_refs === undefined || Array.isArray(f.file_refs)))));
|
|
5422
|
+
return false;
|
|
4799
5423
|
}
|
|
4800
5424
|
function assertExistingLearningValid(dir: string): void {
|
|
4801
5425
|
const file = path.join(dir, "learning.json");
|
|
@@ -4840,8 +5464,7 @@ async function dogfoodPass(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
4840
5464
|
for (const value of opts(p, "finding-json")) normalizeFinding(parseJson(value, "--finding-json"));
|
|
4841
5465
|
if (newCritiqueVerdict !== "pass") die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
|
|
4842
5466
|
if (!opt(p, "critique-id") && !critiqueClean(dir)) die("requires passing critique");
|
|
4843
|
-
|
|
4844
|
-
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");
|
|
5467
|
+
if (!critiqueClean(dir) && fs.existsSync(path.join(dir, "trust.bundle"))) die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
|
|
4845
5468
|
}
|
|
4846
5469
|
}
|
|
4847
5470
|
const learningRecords = opts(p, "learning-record-json").map((v) => normalizeLearning(parseJson(v, "--learning-record-json"), opt(p, "timestamp", now())));
|
|
@@ -5161,7 +5784,7 @@ async function gateReview(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
5161
5784
|
// Locate trust.bundle — required per SKILL.md contract
|
|
5162
5785
|
const bundlePath = path.join(dir, "trust.bundle");
|
|
5163
5786
|
if (!fs.existsSync(bundlePath)) {
|
|
5164
|
-
process.stderr.write(`[gate-review] trust.bundle absent at ${bundlePath} — NOT_VERIFIED.
|
|
5787
|
+
process.stderr.write(`[gate-review] trust.bundle absent at ${bundlePath} — NOT_VERIFIED. Record the required trust bundle before running gate review.\n`);
|
|
5165
5788
|
return 1;
|
|
5166
5789
|
}
|
|
5167
5790
|
|
|
@@ -5908,7 +6531,11 @@ Available claim ids:
|
|
|
5908
6531
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
5909
6532
|
|
|
5910
6533
|
|
|
5911
|
-
export
|
|
6534
|
+
export function mainFromPublicWorkflow(argv: string[]): Promise<number> {
|
|
6535
|
+
return main(argv, PUBLIC_WORKFLOW_AUTHORITY);
|
|
6536
|
+
}
|
|
6537
|
+
|
|
6538
|
+
export async function main(argv: string[] = process.argv.slice(2), authority?: symbol): Promise<number> {
|
|
5912
6539
|
const _rawArgv = argv;
|
|
5913
6540
|
// #380: `record-check <dir> -- <command...>` — argv after the FIRST literal `--` token is the
|
|
5914
6541
|
// command to execute verbatim (never option-parsed: a command like `npm test -- --watch`
|
|
@@ -5948,7 +6575,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<numb
|
|
|
5948
6575
|
: p.command === "record-agent-event" ? explicitArtifactRoot(p) : p.command === "claim" ? (p.positional[1] ? path.resolve(p.positional[1]) : "") : p.command === "resolve-slug" ? "" : (isLivenessWhoami || isLivenessVerdict) ? "" : p.positional[0] ? artifactDirFrom(p.positional[0]) : "";
|
|
5949
6576
|
return withLock(lockRoot, ["ensure-session", "record-agent-event", "dogfood-pass"].includes(p.command), p.command, () => {
|
|
5950
6577
|
switch (p.command) {
|
|
5951
|
-
case "ensure-session": return ensureSession(p);
|
|
6578
|
+
case "ensure-session": return ensureSession(p, authority === PUBLIC_WORKFLOW_AUTHORITY);
|
|
5952
6579
|
case "current": return current(p);
|
|
5953
6580
|
case "record-agent-event": return recordAgentEvent(p);
|
|
5954
6581
|
case "init-plan": return initPlan(p);
|