@kontourai/flow-agents 3.6.0 → 3.8.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 +19 -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 +407 -51
- package/build/src/cli/assignment-provider.d.ts +4 -7
- package/build/src/cli/assignment-provider.js +80 -14
- 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 +825 -103
- package/build/src/cli/workflow.js +301 -43
- 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/build-universal-bundles.js +53 -3
- 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/getting-started.md +9 -1
- package/docs/public-workflow-cli.md +76 -7
- package/docs/skills-map.md +51 -27
- package/docs/spec/builder-flow-runtime.md +75 -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 +330 -6
- package/evals/integration/test_bundle_install.sh +318 -65
- package/evals/integration/test_bundle_lifecycle.sh +4 -6
- 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_install_merge.sh +18 -8
- 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 +334 -14
- 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_universal_bundles.sh +44 -1
- 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/packaging/README.md +1 -1
- package/scripts/README.md +1 -1
- package/scripts/hooks/lib/kit-catalog.js +1 -1
- package/scripts/hooks/stop-goal-fit.js +78 -9
- package/scripts/install-codex-home.sh +63 -23
- package/scripts/install-owned-files.js +18 -2
- package/src/builder-flow-run-adapter.ts +118 -32
- package/src/builder-flow-runtime.ts +426 -64
- package/src/cli/assignment-provider-lock.test.mjs +83 -0
- package/src/cli/assignment-provider.ts +75 -14
- package/src/cli/builder-flow-run-adapter.test.mjs +4 -2
- package/src/cli/builder-flow-runtime.test.mjs +436 -18
- 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 +746 -103
- package/src/cli/workflow.ts +288 -41
- 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/build-universal-bundles.ts +51 -3
- 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 } from "../lib/flow-resolver.js";
|
|
10
|
+
import { resolveActiveFlowStep, resolveAllFlowGateExpects, resolveFlowFilePath, resolveFlowStep, resolvePhaseMap, resolveRouteBackPolicy } 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
|
|
@@ -24,8 +25,21 @@ export const checkKinds = new Set(["build", "types", "lint", "test", "command",
|
|
|
24
25
|
export const checkStatuses = new Set(["pass", "fail", "not_verified", "skip"]);
|
|
25
26
|
export const verdicts = new Set(["pass", "partial", "fail", "not_verified"]);
|
|
26
27
|
export const WORKFLOW_WRITER_CONTRACT_VERSION = "1.0";
|
|
28
|
+
const PUBLIC_WORKFLOW_AUTHORITY = Symbol("public-workflow-authority");
|
|
27
29
|
function now() { return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); }
|
|
28
30
|
function read(file) { return fs.readFileSync(file, "utf8"); }
|
|
31
|
+
function readRegularFileNoFollow(file, label) {
|
|
32
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
33
|
+
try {
|
|
34
|
+
const stat = fs.fstatSync(fd);
|
|
35
|
+
if (!stat.isFile())
|
|
36
|
+
die(`${label} must be a regular file`);
|
|
37
|
+
return fs.readFileSync(fd);
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
fs.closeSync(fd);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
29
43
|
export function writeJson(file, payload) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`); }
|
|
30
44
|
// Single-line but readable "key": "value" form. Built by collapsing the
|
|
31
45
|
// structural whitespace from an indented stringify — corruption-proof, unlike a
|
|
@@ -47,8 +61,14 @@ function slugify(value, fallback) { return value.toLowerCase().replace(/[^a-z0-9
|
|
|
47
61
|
* Reuses slugify() for normalization. Validates that the id is a numeric GitHub issue number. */
|
|
48
62
|
function workItemSlug(ref) {
|
|
49
63
|
const hashIdx = ref.indexOf("#");
|
|
50
|
-
if (hashIdx < 0
|
|
51
|
-
|
|
64
|
+
if (hashIdx < 0) {
|
|
65
|
+
if (!/^[a-z][a-z0-9-]*:[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(ref) || ref.includes("..")) {
|
|
66
|
+
die("--work-item must be a provider-neutral provider:id ref or owner/repo#numeric-id");
|
|
67
|
+
}
|
|
68
|
+
return slugify(ref, "work-item");
|
|
69
|
+
}
|
|
70
|
+
if (hashIdx === ref.length - 1)
|
|
71
|
+
die("--work-item must be in owner/repo#numeric-id format");
|
|
52
72
|
const repoPath = ref.slice(0, hashIdx);
|
|
53
73
|
const id = ref.slice(hashIdx + 1);
|
|
54
74
|
if (!/^\d+$/.test(id))
|
|
@@ -62,9 +82,12 @@ function workItemSlug(ref) {
|
|
|
62
82
|
function assignmentSubjectMatchesWorkItem(slug, ref) {
|
|
63
83
|
if (ref === `local:${slug}`)
|
|
64
84
|
return true;
|
|
65
|
-
|
|
85
|
+
try {
|
|
86
|
+
return workItemSlug(ref) === slug;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
66
89
|
return false;
|
|
67
|
-
|
|
90
|
+
}
|
|
68
91
|
}
|
|
69
92
|
function sessionWorkItem(p, slug, dir) {
|
|
70
93
|
const providerRef = opt(p, "work-item");
|
|
@@ -587,10 +610,10 @@ export function reduceCaptureLogByCommand(commandLog) {
|
|
|
587
610
|
* @param timestamp ISO-8601 timestamp for createdAt / updatedAt / observedAt
|
|
588
611
|
* @param checks Normalized check objects (from record-evidence --check-json / --surface-trust-json)
|
|
589
612
|
* @param criteria Acceptance criteria objects (from acceptance.json .criteria array)
|
|
590
|
-
* @param critiques Critique objects
|
|
613
|
+
* @param critiques Critique objects reconstructed from trust.bundle claims
|
|
591
614
|
* @param commandLog Optional parsed command-log.jsonl entries (capture-authoritative fold)
|
|
592
615
|
*/
|
|
593
|
-
export async function buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, flowAgentsDir, actorKey) {
|
|
616
|
+
export async function buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, flowAgentsDir, actorKey, exactFlowContext) {
|
|
594
617
|
const surface = await tryLoadSurface();
|
|
595
618
|
if (!surface)
|
|
596
619
|
return null;
|
|
@@ -603,7 +626,10 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
|
|
|
603
626
|
// #291 Wave 2 Task 2.1 (§7)/Task 2.2: actorKey (when the caller already resolved one — see
|
|
604
627
|
// writeTrustBundle below) threads through to resolveActiveFlowStep's per-actor-first,
|
|
605
628
|
// legacy-fallback current.json read; omitted, this is IDENTICAL to pre-#291 behavior.
|
|
606
|
-
const
|
|
629
|
+
const exactRepoRoot = flowAgentsDir ? findRepoRootFromDir(path.dirname(flowAgentsDir)) : null;
|
|
630
|
+
const activeStep = exactFlowContext && exactRepoRoot
|
|
631
|
+
? resolveFlowStep(exactFlowContext.flowId, exactFlowContext.stepId, exactRepoRoot)
|
|
632
|
+
: flowAgentsDir ? resolveActiveFlowStep(flowAgentsDir, actorKey) : null;
|
|
607
633
|
// #270 CRITICAL/HIGH fix: resolve the session's active_flow_id independent of whether the
|
|
608
634
|
// CURRENTLY-active step resolves — a stamped gate claim names the STEP IT WAS ORIGINALLY
|
|
609
635
|
// RECORDED AT (frozen in metadata.gate_claim.step_id), which is routinely a DIFFERENT step than
|
|
@@ -612,6 +638,8 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
|
|
|
612
638
|
// the gate claim was recorded at — the validation must be against the FULL flow definition
|
|
613
639
|
// (every step's gate expects[], via resolveAllFlowGateExpects), not just the active one.
|
|
614
640
|
const sessionFlowId = (() => {
|
|
641
|
+
if (exactFlowContext)
|
|
642
|
+
return exactFlowContext.flowId;
|
|
615
643
|
if (!flowAgentsDir)
|
|
616
644
|
return null;
|
|
617
645
|
try {
|
|
@@ -852,7 +880,9 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
|
|
|
852
880
|
continue;
|
|
853
881
|
const subjectId = `${slug}/${check.id}`;
|
|
854
882
|
const fieldOrBehavior = String(check.summary ?? check.id);
|
|
855
|
-
const
|
|
883
|
+
const gateClaimIdentityVersion = check._gate_claim_identity_version === 2 ? 2 : 1;
|
|
884
|
+
const gateClaimRecordedAt = gateClaimIdentityVersion === 2 && typeof check._gate_claim_recorded_at === "string" ? check._gate_claim_recorded_at : null;
|
|
885
|
+
const claimId = generateClaimId(subjectId, "flow-agents.workflow", gateClaimRecordedAt ? `${fieldOrBehavior}::gate-visit::${gateClaimRecordedAt}` : fieldOrBehavior);
|
|
856
886
|
const evId = `ev:${claimId}`;
|
|
857
887
|
const legacyClaimType = `workflow.check.${check.kind ?? "external"}`;
|
|
858
888
|
const cmd = typeof check.command === "string" ? check.command.replace(/\s+/g, " ").trim() : "";
|
|
@@ -902,6 +932,11 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
|
|
|
902
932
|
const outputDigestMeta = typeof check._output_sha256 === "string" && check._output_sha256.length > 0
|
|
903
933
|
? { algorithm: "sha256", hex: check._output_sha256 }
|
|
904
934
|
: null;
|
|
935
|
+
const observedCommandsMeta = Array.isArray(check._observed_commands)
|
|
936
|
+
? check._observed_commands
|
|
937
|
+
.filter((entry) => typeof entry?.command === "string" && typeof entry?.exit_code === "number" && typeof entry?.output_sha256 === "string")
|
|
938
|
+
.map((entry) => ({ 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 } : {}) }))
|
|
939
|
+
: null;
|
|
905
940
|
// #270(a)/(c): a gate claim's declared claimType/subjectType, once resolved (either freshly
|
|
906
941
|
// via matchExpectsEntry below, or restored from a prior write's metadata.gate_claim stamp by
|
|
907
942
|
// checksFromBundle), is stamped here so it is frozen at record time — a later bundle rebuild
|
|
@@ -931,11 +966,15 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
|
|
|
931
966
|
origin: "check",
|
|
932
967
|
check_kind: String(check.kind ?? "external"),
|
|
933
968
|
...(activeStep && workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}),
|
|
969
|
+
...(typeof check._producer === "string" ? { expected_producer: check._producer } : {}),
|
|
970
|
+
...(typeof check._recorded_by === "string" ? { recorded_by: check._recorded_by } : {}),
|
|
971
|
+
...(Array.isArray(check._producer_self_produced_trust_slices) ? { self_produced_trust_slices: check._producer_self_produced_trust_slices } : {}),
|
|
934
972
|
...(waiver ? { waiver } : {}),
|
|
935
973
|
...(promotionMeta ? { promotion: promotionMeta } : {}),
|
|
936
974
|
...(artifactRefsMeta ? { artifact_refs: artifactRefsMeta } : {}),
|
|
937
975
|
...(standardRefsMeta ? { standard_refs: standardRefsMeta } : {}),
|
|
938
976
|
...(outputDigestMeta ? { output_digest: outputDigestMeta } : {}),
|
|
977
|
+
...(observedCommandsMeta && observedCommandsMeta.length > 0 ? { observed_commands: observedCommandsMeta } : {}),
|
|
939
978
|
};
|
|
940
979
|
const claimEvents = [];
|
|
941
980
|
if (evStatus) {
|
|
@@ -997,7 +1036,7 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
|
|
|
997
1036
|
// restored stamp) takes the currently-active step's id.
|
|
998
1037
|
const declaredStepId = gateClaimDeclaredStepId ?? (activeStep ? activeStep.stepId : null);
|
|
999
1038
|
const declaredMetadata = gateClaimExpectationId
|
|
1000
|
-
? { ...claimMetadata, gate_claim: { expectation_id: gateClaimExpectationId, claim_type: declared.claimType, subject_type: declared.subjectType, step_id: declaredStepId, ...(gateClaimRouteReason ? { route_reason: gateClaimRouteReason } : {}) } }
|
|
1039
|
+
? { ...claimMetadata, gate_claim: { expectation_id: gateClaimExpectationId, claim_type: declared.claimType, subject_type: declared.subjectType, step_id: declaredStepId, ...(gateClaimIdentityVersion === 2 ? { identity_version: 2 } : {}), ...(gateClaimRecordedAt ? { recorded_at: gateClaimRecordedAt } : {}), ...(gateClaimRouteReason ? { route_reason: gateClaimRouteReason } : {}) } }
|
|
1001
1040
|
: claimMetadata;
|
|
1002
1041
|
const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: effectiveStatus, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, ...(declaredMetadata ? { metadata: declaredMetadata } : {}) };
|
|
1003
1042
|
const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [evItem], events: claimEvents, policies: [declaredPolicy] });
|
|
@@ -1016,7 +1055,9 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
|
|
|
1016
1055
|
continue;
|
|
1017
1056
|
const subjectId = `${slug}/${criterion.id}`;
|
|
1018
1057
|
const fieldOrBehavior = String(criterion.description ?? criterion.id);
|
|
1019
|
-
const
|
|
1058
|
+
const criterionIdentityVersion = criterion.identity_version === 2 ? 2 : 1;
|
|
1059
|
+
const criterionVerifiedAt = criterionIdentityVersion === 2 && typeof criterion.verified_at === "string" ? criterion.verified_at : null;
|
|
1060
|
+
const claimId = generateClaimId(subjectId, "flow-agents.workflow", criterionVerifiedAt ? `${fieldOrBehavior}::verified::${criterionVerifiedAt}` : fieldOrBehavior);
|
|
1020
1061
|
const legacyClaimType = "workflow.acceptance.criterion";
|
|
1021
1062
|
const policy = ensurePolicy(legacyClaimType, "high", []);
|
|
1022
1063
|
const evStatus = criterionStatusToEventStatus(String(criterion.status ?? ""));
|
|
@@ -1031,13 +1072,13 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
|
|
|
1031
1072
|
if (declared) {
|
|
1032
1073
|
// Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
|
|
1033
1074
|
const declaredPolicy = ensurePolicy(declared.claimType, "high", []);
|
|
1034
|
-
const declaredClaimObj = { 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 } : {}) } };
|
|
1075
|
+
const declaredClaimObj = { 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 : [], ...(criterionIdentityVersion === 2 ? { identity_version: 2 } : {}), ...(criterionVerifiedAt ? { verified_at: criterionVerifiedAt } : {}) }, ...(workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}) } };
|
|
1035
1076
|
const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [], events: claimEvents, policies: [declaredPolicy] });
|
|
1036
1077
|
claims.push({ ...declaredClaimObj, status: declaredStatus });
|
|
1037
1078
|
}
|
|
1038
1079
|
else {
|
|
1039
1080
|
// No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
|
|
1040
|
-
const claimObj = { 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" } };
|
|
1081
|
+
const claimObj = { 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 : [] } } };
|
|
1041
1082
|
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [], events: claimEvents, policies: [policy] });
|
|
1042
1083
|
claims.push({ ...claimObj, status: derivedStatus });
|
|
1043
1084
|
}
|
|
@@ -1055,11 +1096,26 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
|
|
|
1055
1096
|
const supersededBy = typeof c.superseded_by === "string" && c.superseded_by.length > 0 ? c.superseded_by : null;
|
|
1056
1097
|
const critiqueReviewer = String(c.reviewer ?? "tool-code-reviewer");
|
|
1057
1098
|
const critiqueReviewedAt = String(c.reviewed_at ?? ts);
|
|
1058
|
-
const
|
|
1099
|
+
const critiqueIdentityVersion = c.identity_version === 2 ? 2 : 1;
|
|
1100
|
+
const critMeta = {
|
|
1101
|
+
origin: "critique",
|
|
1102
|
+
reviewer: critiqueReviewer,
|
|
1103
|
+
reviewed_at: critiqueReviewedAt,
|
|
1104
|
+
...(critiqueIdentityVersion === 2 ? { identity_version: 2 } : {}),
|
|
1105
|
+
findings: Array.isArray(c.findings) ? c.findings : [],
|
|
1106
|
+
lanes: Array.isArray(c.lanes) ? c.lanes : [],
|
|
1107
|
+
review_target: c.review_target && typeof c.review_target === "object" ? c.review_target : { artifacts: [] },
|
|
1108
|
+
// Keep the legacy field for consumers that still render a flat artifact list.
|
|
1109
|
+
artifact_refs: Array.isArray(c.artifact_refs) ? c.artifact_refs : [],
|
|
1110
|
+
...(activeStep && workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}),
|
|
1111
|
+
...(supersededBy ? { superseded_by: supersededBy } : {}),
|
|
1112
|
+
};
|
|
1059
1113
|
// A superseded historical write gets a distinct, stable claimId so it co-exists with the live
|
|
1060
1114
|
// claim of the same critique id (never overwrites or duplicates it). The salt is reproducible
|
|
1061
1115
|
// across rebuilds because superseded_by + reviewed_at are preserved in metadata.
|
|
1062
|
-
const claimIdSalt =
|
|
1116
|
+
const claimIdSalt = critiqueIdentityVersion === 2
|
|
1117
|
+
? `${fieldOrBehavior}::reviewed::${critiqueReviewedAt}${supersededBy ? `::superseded::${supersededBy}` : ""}`
|
|
1118
|
+
: (supersededBy ? `${fieldOrBehavior}::superseded::${supersededBy}::${critiqueReviewedAt}` : fieldOrBehavior);
|
|
1063
1119
|
const claimId = generateClaimId(subjectId, "flow-agents.workflow", claimIdSalt);
|
|
1064
1120
|
const legacyClaimType = "workflow.critique.review";
|
|
1065
1121
|
const policy = ensurePolicy(legacyClaimType, "medium", []);
|
|
@@ -1071,18 +1127,15 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
|
|
|
1071
1127
|
events.push(evt);
|
|
1072
1128
|
claimEvents.push(evt);
|
|
1073
1129
|
}
|
|
1074
|
-
//
|
|
1075
|
-
|
|
1076
|
-
const
|
|
1077
|
-
const subjectType = declared ? declared.subjectType : "workflow-critique";
|
|
1078
|
-
const claimPolicy = declared ? ensurePolicy(declared.claimType, "medium", []) : policy;
|
|
1079
|
-
const claimObj = { id: claimId, subjectType, subjectId, facet: "flow-agents.workflow", claimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: claimPolicy.id, metadata: critMeta };
|
|
1130
|
+
// Critique is intentionally report-only. It must never inherit a Flow gate expectation,
|
|
1131
|
+
// even while a run is positioned at a gate-bearing step.
|
|
1132
|
+
const claimObj = { 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 };
|
|
1080
1133
|
if (supersededBy) {
|
|
1081
1134
|
// History: status is "superseded" directly (no verification event); excluded from evaluation.
|
|
1082
1135
|
claims.push({ ...claimObj, status: "superseded" });
|
|
1083
1136
|
}
|
|
1084
1137
|
else {
|
|
1085
|
-
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [], events: claimEvents, policies: [
|
|
1138
|
+
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [], events: claimEvents, policies: [policy] });
|
|
1086
1139
|
claims.push({ ...claimObj, status: derivedStatus });
|
|
1087
1140
|
}
|
|
1088
1141
|
}
|
|
@@ -1108,7 +1161,7 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
|
|
|
1108
1161
|
* @param criteria Acceptance criteria objects (same as buildTrustBundle)
|
|
1109
1162
|
* @param critiques Critique objects (same as buildTrustBundle)
|
|
1110
1163
|
*/
|
|
1111
|
-
export async function writeTrustBundle(dir, slug, timestamp, checks, criteria, critiques, actorKey) {
|
|
1164
|
+
export async function writeTrustBundle(dir, slug, timestamp, checks, criteria, critiques, actorKey, exactFlowContext) {
|
|
1112
1165
|
try {
|
|
1113
1166
|
// Fold the deterministic capture log (PostToolUse evidence-capture) into the
|
|
1114
1167
|
// bundle so capture is authoritative over claimed status. Best-effort read.
|
|
@@ -1134,15 +1187,22 @@ export async function writeTrustBundle(dir, slug, timestamp, checks, criteria, c
|
|
|
1134
1187
|
const _flowAgentsDir = path.dirname(dir);
|
|
1135
1188
|
const _effectiveActorKey = actorKey ?? loadActorIdentityHelper().resolveActor(process.env).actor;
|
|
1136
1189
|
let _scopedFlowAgentsDir = undefined;
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1190
|
+
if (exactFlowContext) {
|
|
1191
|
+
// The context was read from this session's persisted flow_run. Navigation pointers may
|
|
1192
|
+
// lag or point at another run and must not override an explicit session mutation.
|
|
1193
|
+
_scopedFlowAgentsDir = _flowAgentsDir;
|
|
1194
|
+
}
|
|
1195
|
+
else {
|
|
1196
|
+
try {
|
|
1197
|
+
const _currentRaw = loadCurrentPointerHelper().readCurrentPointer(_flowAgentsDir, _effectiveActorKey).payload;
|
|
1198
|
+
const _artDir = _currentRaw && typeof _currentRaw["artifact_dir"] === "string" ? _currentRaw["artifact_dir"] : null;
|
|
1199
|
+
if (_artDir && path.resolve(_flowAgentsDir, _artDir) === path.resolve(dir)) {
|
|
1200
|
+
_scopedFlowAgentsDir = _flowAgentsDir;
|
|
1201
|
+
}
|
|
1142
1202
|
}
|
|
1203
|
+
catch { /* current.json absent or unreadable — no scoping */ }
|
|
1143
1204
|
}
|
|
1144
|
-
|
|
1145
|
-
const bundle = await buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, _scopedFlowAgentsDir, _effectiveActorKey);
|
|
1205
|
+
const bundle = await buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, _scopedFlowAgentsDir, _effectiveActorKey, exactFlowContext);
|
|
1146
1206
|
if (!bundle)
|
|
1147
1207
|
return { written: false, errors: [] }; // Surface unavailable — fail-open, skip write
|
|
1148
1208
|
const result = await validateTrustBundle(bundle);
|
|
@@ -1151,7 +1211,6 @@ export async function writeTrustBundle(dir, slug, timestamp, checks, criteria, c
|
|
|
1151
1211
|
return { written: false, errors: result.errors };
|
|
1152
1212
|
}
|
|
1153
1213
|
writeJson(path.join(dir, "trust.bundle"), bundle);
|
|
1154
|
-
await syncBuilderFlowSessionIfPresent(dir);
|
|
1155
1214
|
return { written: true, errors: [] };
|
|
1156
1215
|
}
|
|
1157
1216
|
catch (err) {
|
|
@@ -1761,6 +1820,9 @@ function initSidecars(dir, slug, sourceRequest, summary, nextAction, timestamp,
|
|
|
1761
1820
|
...sidecarBase(slug), status: initialLifecycle?.status ?? "planned", phase: initialLifecycle?.phase ?? "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
|
|
1762
1821
|
...(branch ? { branch } : {}),
|
|
1763
1822
|
...(retainedWorkItemRefs.length > 0 ? { work_item_refs: retainedWorkItemRefs } : {}),
|
|
1823
|
+
...(existingState.flow_run && typeof existingState.flow_run === "object" && !Array.isArray(existingState.flow_run)
|
|
1824
|
+
? { flow_run: existingState.flow_run }
|
|
1825
|
+
: {}),
|
|
1764
1826
|
artifact_paths: relArtifacts(dir),
|
|
1765
1827
|
next_action: nextActionPayload,
|
|
1766
1828
|
});
|
|
@@ -1837,14 +1899,40 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution, workItemR
|
|
|
1837
1899
|
const nowMs = opt(p, "now") ? Date.parse(opt(p, "now")) : Date.now();
|
|
1838
1900
|
const assignmentProviderKind = opt(p, "assignment-provider", "local-file");
|
|
1839
1901
|
let effective;
|
|
1902
|
+
let providerEvidenceBytes;
|
|
1840
1903
|
const effectiveStateJsonFlag = opt(p, "effective-state-json");
|
|
1841
1904
|
if (effectiveStateJsonFlag) {
|
|
1842
|
-
|
|
1905
|
+
providerEvidenceBytes = effectiveStateJsonFlag === "-"
|
|
1906
|
+
? fs.readFileSync(0)
|
|
1907
|
+
: readRegularFileNoFollow(path.resolve(effectiveStateJsonFlag), "ensure-session --effective-state-json");
|
|
1908
|
+
const parsed = JSON.parse(providerEvidenceBytes.toString("utf8"));
|
|
1843
1909
|
const candidate = parsed && typeof parsed === "object" ? parsed.effective : undefined;
|
|
1910
|
+
const assignment = parsed && typeof parsed === "object" ? parsed.assignment : undefined;
|
|
1911
|
+
const record = assignment && typeof assignment.record === "object" && assignment.record !== null ? assignment.record : undefined;
|
|
1844
1912
|
const validStates = new Set(["held", "reclaimable", "human-held", "free"]);
|
|
1845
1913
|
if (!candidate || typeof candidate.effective_state !== "string" || !validStates.has(candidate.effective_state)) {
|
|
1846
1914
|
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)}`);
|
|
1847
1915
|
}
|
|
1916
|
+
if (workItemRef && (parsed.role !== "AssignmentStatus"
|
|
1917
|
+
|| parsed.provider !== assignmentProviderKind
|
|
1918
|
+
|| assignment?.provider !== assignmentProviderKind
|
|
1919
|
+
|| assignment?.subject_id !== slug
|
|
1920
|
+
|| record?.role !== "AssignmentClaimRecord"
|
|
1921
|
+
|| record?.status !== "claimed"
|
|
1922
|
+
|| record?.subject_id !== slug
|
|
1923
|
+
|| record?.work_item_ref !== workItemRef
|
|
1924
|
+
|| record?.actor_key !== resolution.branchActorKey
|
|
1925
|
+
|| assignment?.assignee !== resolution.branchActorKey
|
|
1926
|
+
|| !record.actor || typeof record.actor !== "object"
|
|
1927
|
+
|| record.actor.runtime !== resolution.actorStruct.runtime
|
|
1928
|
+
|| record.actor.session_id !== resolution.actorStruct.session_id
|
|
1929
|
+
|| record.actor.host !== resolution.actorStruct.host
|
|
1930
|
+
|| (record.actor.human ?? null) !== (resolution.actorStruct.human ?? null)
|
|
1931
|
+
|| candidate.effective_state !== "held"
|
|
1932
|
+
|| candidate.reason !== "self_is_holder"
|
|
1933
|
+
|| candidate.holder?.actor !== resolution.branchActorKey)) {
|
|
1934
|
+
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");
|
|
1935
|
+
}
|
|
1848
1936
|
effective = candidate;
|
|
1849
1937
|
}
|
|
1850
1938
|
else if (assignmentProviderKind === "local-file") {
|
|
@@ -1882,9 +1970,7 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution, workItemR
|
|
|
1882
1970
|
return null;
|
|
1883
1971
|
}
|
|
1884
1972
|
const selectedWorkEvidence = () => {
|
|
1885
|
-
|
|
1886
|
-
// can authorize entry, but it cannot prove that this process acquired the Work Item.
|
|
1887
|
-
if (!workItemRef || effectiveStateJsonFlag || assignmentProviderKind !== "local-file")
|
|
1973
|
+
if (!workItemRef)
|
|
1888
1974
|
return null;
|
|
1889
1975
|
const assignment = readLocalAssignmentStatus(root, slug);
|
|
1890
1976
|
const record = assignment.record;
|
|
@@ -1898,6 +1984,8 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution, workItemR
|
|
|
1898
1984
|
return {
|
|
1899
1985
|
assignmentFile: assignmentFilePath(root, slug),
|
|
1900
1986
|
actorKey: resolution.branchActorKey,
|
|
1987
|
+
...(effectiveStateJsonFlag ? { providerEvidenceFile: path.resolve(effectiveStateJsonFlag) } : {}),
|
|
1988
|
+
...(providerEvidenceBytes ? { providerEvidenceBytes } : {}),
|
|
1901
1989
|
};
|
|
1902
1990
|
};
|
|
1903
1991
|
const resolveBranchForClaim = () => {
|
|
@@ -1911,8 +1999,22 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution, workItemR
|
|
|
1911
1999
|
// ActorStruct triple), so this redundant belt-and-suspenders check agrees with the
|
|
1912
2000
|
// effective_state computation instead of silently using a different identity.
|
|
1913
2001
|
const isSelf = effective.reason === "self_is_holder" || (!!effective.holder?.actor && effective.holder.actor === resolution.branchActorKey);
|
|
1914
|
-
if (isSelf)
|
|
2002
|
+
if (isSelf) {
|
|
2003
|
+
if (assignmentProviderKind !== "local-file") {
|
|
2004
|
+
const local = readLocalAssignmentStatus(root, slug).record;
|
|
2005
|
+
if (!local || local.status !== "claimed") {
|
|
2006
|
+
performLocalClaim(root, slug, resolution.actorStruct, {
|
|
2007
|
+
ttlSeconds: opt(p, "claim-ttl-seconds") ? Number(opt(p, "claim-ttl-seconds")) : loadLivenessPolicyHelper().resolveTtlSeconds(process.env),
|
|
2008
|
+
branch: resolveBranchForClaim(),
|
|
2009
|
+
artifactDir: path.relative(root, dir) || ".",
|
|
2010
|
+
reason: `provider-confirmed ${assignmentProviderKind} ownership mirror`,
|
|
2011
|
+
actorKey: resolution.branchActorKey,
|
|
2012
|
+
...(workItemRef ? { workItemRef } : {}),
|
|
2013
|
+
});
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
1915
2016
|
return selectedWorkEvidence();
|
|
2017
|
+
}
|
|
1916
2018
|
const holderActor = sanitize(effective.holder?.actor ?? "unknown");
|
|
1917
2019
|
const lastAtSuffix = effective.holder?.last_at ? ` (last_at ${sanitize(effective.holder.last_at)})` : "";
|
|
1918
2020
|
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.`);
|
|
@@ -1964,6 +2066,9 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution, workItemR
|
|
|
1964
2066
|
return selectedWorkEvidence();
|
|
1965
2067
|
}
|
|
1966
2068
|
case "free": {
|
|
2069
|
+
if (assignmentProviderKind !== "local-file") {
|
|
2070
|
+
die(`ensure-session refused: provider ${JSON.stringify(assignmentProviderKind)} has not confirmed this actor as the Work Item holder`);
|
|
2071
|
+
}
|
|
1967
2072
|
performLocalClaim(root, slug, resolution.actorStruct, {
|
|
1968
2073
|
ttlSeconds: opt(p, "claim-ttl-seconds") ? Number(opt(p, "claim-ttl-seconds")) : loadLivenessPolicyHelper().resolveTtlSeconds(process.env),
|
|
1969
2074
|
branch: resolveBranchForClaim(),
|
|
@@ -2015,11 +2120,11 @@ function resolveEnsureSessionEntry(p, dir) {
|
|
|
2015
2120
|
}
|
|
2016
2121
|
return { flowId, stepId: explicitStep || firstStep, firstStep };
|
|
2017
2122
|
}
|
|
2018
|
-
function assertCanonicalBuilderArtifactRoot(root) {
|
|
2123
|
+
function assertCanonicalBuilderArtifactRoot(root, flowId) {
|
|
2019
2124
|
const kontouraiRoot = path.dirname(root);
|
|
2020
2125
|
const projectRoot = path.dirname(kontouraiRoot);
|
|
2021
2126
|
if (path.basename(root) !== "flow-agents" || path.basename(kontouraiRoot) !== ".kontourai") {
|
|
2022
|
-
die(
|
|
2127
|
+
die(`ensure-session --flow-id ${flowId} requires --artifact-root <project>/.kontourai/flow-agents`);
|
|
2023
2128
|
}
|
|
2024
2129
|
const statIfPresent = (candidate) => {
|
|
2025
2130
|
try {
|
|
@@ -2033,7 +2138,7 @@ function assertCanonicalBuilderArtifactRoot(root) {
|
|
|
2033
2138
|
};
|
|
2034
2139
|
const projectStat = statIfPresent(projectRoot);
|
|
2035
2140
|
if (projectStat && (projectStat.isSymbolicLink() || !projectStat.isDirectory())) {
|
|
2036
|
-
die(`ensure-session --flow-id
|
|
2141
|
+
die(`ensure-session --flow-id ${flowId} requires a non-symlink project root: ${projectRoot}`);
|
|
2037
2142
|
}
|
|
2038
2143
|
const realProjectRoot = projectStat ? fs.realpathSync(projectRoot) : projectRoot;
|
|
2039
2144
|
for (const [candidate, expected, label] of [
|
|
@@ -2044,7 +2149,7 @@ function assertCanonicalBuilderArtifactRoot(root) {
|
|
|
2044
2149
|
if (!stat)
|
|
2045
2150
|
continue;
|
|
2046
2151
|
if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(candidate) !== expected) {
|
|
2047
|
-
die(`ensure-session --flow-id
|
|
2152
|
+
die(`ensure-session --flow-id ${flowId} requires a non-symlink ${label}: ${candidate}`);
|
|
2048
2153
|
}
|
|
2049
2154
|
}
|
|
2050
2155
|
}
|
|
@@ -2054,18 +2159,18 @@ function preflightEnsureSession(p) {
|
|
|
2054
2159
|
const dir = sessionDirFor(root, slug);
|
|
2055
2160
|
assertSafeSessionDirectory(root, dir);
|
|
2056
2161
|
const entry = resolveEnsureSessionEntry(p, dir);
|
|
2057
|
-
if (entry.flowId === "builder.build")
|
|
2058
|
-
assertCanonicalBuilderArtifactRoot(root);
|
|
2162
|
+
if (entry.flowId === "builder.build" || entry.flowId === "builder.shape")
|
|
2163
|
+
assertCanonicalBuilderArtifactRoot(root, entry.flowId);
|
|
2059
2164
|
sessionWorkItem(p, slug, dir);
|
|
2060
2165
|
}
|
|
2061
|
-
async function ensureSession(p) {
|
|
2166
|
+
async function ensureSession(p, allowCanonicalFlowMutation) {
|
|
2062
2167
|
const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
|
|
2063
2168
|
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)"));
|
|
2064
2169
|
const dir = sessionDirFor(root, slug);
|
|
2065
2170
|
assertSafeSessionDirectory(root, dir);
|
|
2066
2171
|
const entry = resolveEnsureSessionEntry(p, dir);
|
|
2067
|
-
if (entry.flowId === "builder.build")
|
|
2068
|
-
assertCanonicalBuilderArtifactRoot(root);
|
|
2172
|
+
if (entry.flowId === "builder.build" || entry.flowId === "builder.shape")
|
|
2173
|
+
assertCanonicalBuilderArtifactRoot(root, entry.flowId);
|
|
2069
2174
|
const workItem = sessionWorkItem(p, slug, dir);
|
|
2070
2175
|
// #291 Wave 2 Task 2.1 (§3, §4): resolve the actor ONCE, then run the ownership guard BEFORE
|
|
2071
2176
|
// any directory/file is created — a refusal must never leave a stray empty session dir. Reused
|
|
@@ -2075,8 +2180,20 @@ async function ensureSession(p) {
|
|
|
2075
2180
|
const selectionWorkItemRef = entry.flowId === "builder.build" && assignmentSubjectMatchesWorkItem(slug, workItem.ref)
|
|
2076
2181
|
? workItem.ref
|
|
2077
2182
|
: undefined;
|
|
2078
|
-
|
|
2183
|
+
let selectedWorkEvidence = enforceEnsureSessionOwnership(p, root, slug, dir, actorResolution, selectionWorkItemRef);
|
|
2079
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))
|
|
2189
|
+
die("ensure-session provider state evidence conflicts with the existing immutable snapshot");
|
|
2190
|
+
}
|
|
2191
|
+
else {
|
|
2192
|
+
fs.writeFileSync(staged, selectedWorkEvidence.providerEvidenceBytes, { flag: "wx", mode: 0o600 });
|
|
2193
|
+
}
|
|
2194
|
+
fs.chmodSync(staged, 0o400);
|
|
2195
|
+
selectedWorkEvidence = { ...selectedWorkEvidence, providerEvidenceFile: staged };
|
|
2196
|
+
}
|
|
2080
2197
|
const timestamp = opt(p, "timestamp", now());
|
|
2081
2198
|
if (workItem.localRecord && !fs.existsSync(path.join(dir, "work-item.json"))) {
|
|
2082
2199
|
writeJson(path.join(dir, "work-item.json"), workItem.localRecord);
|
|
@@ -2100,9 +2217,11 @@ async function ensureSession(p) {
|
|
|
2100
2217
|
const initialPhase = Object.entries(phaseMap ?? {}).find(([, step]) => step === entry.firstStep)?.[0];
|
|
2101
2218
|
const startCommand = pinnedFlowAgentsCommand(flowAgentsPackageVersion(), [
|
|
2102
2219
|
"workflow", "start",
|
|
2103
|
-
"--flow",
|
|
2104
|
-
"--work-item", workItem.ref,
|
|
2220
|
+
"--flow", entry.flowId,
|
|
2221
|
+
...(entry.flowId === "builder.shape" ? [] : ["--work-item", workItem.ref]),
|
|
2105
2222
|
...(workItem.ref === `local:${slug}` ? ["--task-slug", slug] : []),
|
|
2223
|
+
"--assignment-provider", opt(p, "assignment-provider", "local-file"),
|
|
2224
|
+
...(selectedWorkEvidence?.providerEvidenceFile ? ["--effective-state-json", selectedWorkEvidence.providerEvidenceFile] : []),
|
|
2106
2225
|
"--artifact-root", root,
|
|
2107
2226
|
"--title", opt(p, "title", slug),
|
|
2108
2227
|
"--summary", opt(p, "summary", "Workflow session is durable."),
|
|
@@ -2135,9 +2254,9 @@ async function ensureSession(p) {
|
|
|
2135
2254
|
? persistedCurrent.active_step_id
|
|
2136
2255
|
: entry.stepId;
|
|
2137
2256
|
writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", entry.flowId || undefined, resumedStep || undefined, actorResolution.unresolved ? undefined : actorResolution.branchActorKey);
|
|
2138
|
-
if (entry.flowId === "builder.build") {
|
|
2257
|
+
if (allowCanonicalFlowMutation && (entry.flowId === "builder.build" || entry.flowId === "builder.shape")) {
|
|
2139
2258
|
try {
|
|
2140
|
-
const started = await startBuilderFlowSession({ sessionDir: dir });
|
|
2259
|
+
const started = await startBuilderFlowSession({ sessionDir: dir, flowId: entry.flowId });
|
|
2141
2260
|
if (started.run.state.current_step === "pull-work"
|
|
2142
2261
|
&& selectedWorkEvidence
|
|
2143
2262
|
&& assignmentSubjectMatchesWorkItem(slug, workItem.ref)) {
|
|
@@ -2152,8 +2271,25 @@ async function ensureSession(p) {
|
|
|
2152
2271
|
}
|
|
2153
2272
|
const assignmentContent = fs.readFileSync(selectedWorkEvidence.assignmentFile);
|
|
2154
2273
|
const assignmentDigest = createHash("sha256").update(assignmentContent).digest("hex");
|
|
2274
|
+
const providerEvidenceBytes = selectedWorkEvidence.providerEvidenceFile
|
|
2275
|
+
? readRegularFileNoFollow(selectedWorkEvidence.providerEvidenceFile, "selected-work provider evidence")
|
|
2276
|
+
: null;
|
|
2277
|
+
if (providerEvidenceBytes && selectedWorkEvidence.providerEvidenceBytes && !providerEvidenceBytes.equals(selectedWorkEvidence.providerEvidenceBytes)) {
|
|
2278
|
+
die("ensure-session refused to emit selected-work evidence: provider evidence changed after ownership validation");
|
|
2279
|
+
}
|
|
2280
|
+
const providerEvidenceDigest = providerEvidenceBytes ? createHash("sha256").update(providerEvidenceBytes).digest("hex") : null;
|
|
2155
2281
|
const projectRoot = path.dirname(path.dirname(root));
|
|
2156
2282
|
const evidenceFile = path.relative(projectRoot, selectedWorkEvidence.assignmentFile);
|
|
2283
|
+
const pullWorkReport = path.join(dir, `${slug}--pull-work.md`);
|
|
2284
|
+
let reportIsValid = false;
|
|
2285
|
+
try {
|
|
2286
|
+
const reportStat = fs.lstatSync(pullWorkReport);
|
|
2287
|
+
reportIsValid = !reportStat.isSymbolicLink() && reportStat.isFile() && fs.readFileSync(pullWorkReport, "utf8").includes(workItem.ref);
|
|
2288
|
+
}
|
|
2289
|
+
catch { /* handled by the stable contract error below */ }
|
|
2290
|
+
if (!reportIsValid) {
|
|
2291
|
+
die(`ensure-session refused to emit selected-work evidence: expected concrete pull-work selection report ${pullWorkReport} naming ${JSON.stringify(workItem.ref)}`);
|
|
2292
|
+
}
|
|
2157
2293
|
await recordGateClaim(parseArgs([
|
|
2158
2294
|
"record-gate-claim",
|
|
2159
2295
|
dir,
|
|
@@ -2166,8 +2302,25 @@ async function ensureSession(p) {
|
|
|
2166
2302
|
file: evidenceFile,
|
|
2167
2303
|
summary: `Durable local assignment record for the selected Work Item and active actor; sha256:${assignmentDigest}.`,
|
|
2168
2304
|
}),
|
|
2305
|
+
...(selectedWorkEvidence.providerEvidenceFile ? [
|
|
2306
|
+
"--evidence-ref-json", JSON.stringify({
|
|
2307
|
+
kind: "artifact",
|
|
2308
|
+
file: path.relative(projectRoot, selectedWorkEvidence.providerEvidenceFile),
|
|
2309
|
+
summary: `Provider assignment state confirming ownership before the local runtime lease was mirrored; sha256:${providerEvidenceDigest}.`,
|
|
2310
|
+
}),
|
|
2311
|
+
] : []),
|
|
2312
|
+
"--evidence-ref-json", JSON.stringify({
|
|
2313
|
+
kind: "artifact",
|
|
2314
|
+
file: path.relative(projectRoot, pullWorkReport),
|
|
2315
|
+
summary: `Concrete pull-work selection report for ${workItem.ref}.`,
|
|
2316
|
+
}),
|
|
2169
2317
|
"--timestamp", timestamp,
|
|
2170
2318
|
]));
|
|
2319
|
+
if (selectedWorkEvidence.providerEvidenceFile && selectedWorkEvidence.providerEvidenceBytes
|
|
2320
|
+
&& !readRegularFileNoFollow(selectedWorkEvidence.providerEvidenceFile, "selected-work provider evidence").equals(selectedWorkEvidence.providerEvidenceBytes)) {
|
|
2321
|
+
die("ensure-session refused to synchronize selected-work evidence: provider evidence changed during claim recording");
|
|
2322
|
+
}
|
|
2323
|
+
await syncBuilderFlowSession({ sessionDir: dir });
|
|
2171
2324
|
});
|
|
2172
2325
|
}
|
|
2173
2326
|
}
|
|
@@ -2304,6 +2457,423 @@ export function normalizeEvidenceRefs(raw, label) {
|
|
|
2304
2457
|
return validateEvidenceRef({ ...ref }, label);
|
|
2305
2458
|
});
|
|
2306
2459
|
}
|
|
2460
|
+
function canonicalProjectRootForSession(dir) {
|
|
2461
|
+
const artifactRoot = path.dirname(dir);
|
|
2462
|
+
const kontouraiRoot = path.dirname(artifactRoot);
|
|
2463
|
+
if (path.basename(artifactRoot) !== "flow-agents" || path.basename(kontouraiRoot) !== ".kontourai")
|
|
2464
|
+
die("gate evidence requires a canonical .kontourai/flow-agents session");
|
|
2465
|
+
const projectRoot = path.dirname(kontouraiRoot);
|
|
2466
|
+
const stat = fs.lstatSync(projectRoot);
|
|
2467
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
2468
|
+
die("gate evidence project root must be a non-symlink directory");
|
|
2469
|
+
return projectRoot;
|
|
2470
|
+
}
|
|
2471
|
+
function pathIsWithinRoot(candidate, root) {
|
|
2472
|
+
const relative = path.relative(root, candidate);
|
|
2473
|
+
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
2474
|
+
}
|
|
2475
|
+
function validateLocalEvidenceFile(projectRoot, raw, label) {
|
|
2476
|
+
const candidate = path.resolve(projectRoot, raw);
|
|
2477
|
+
if (!pathIsWithinRoot(candidate, projectRoot))
|
|
2478
|
+
die(`${label} must remain inside the canonical project root`);
|
|
2479
|
+
let stat;
|
|
2480
|
+
try {
|
|
2481
|
+
stat = fs.lstatSync(candidate);
|
|
2482
|
+
}
|
|
2483
|
+
catch {
|
|
2484
|
+
die(`${label} does not exist`);
|
|
2485
|
+
}
|
|
2486
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
2487
|
+
die(`${label} must be a non-symlink regular file`);
|
|
2488
|
+
if (!pathIsWithinRoot(fs.realpathSync(candidate), fs.realpathSync(projectRoot)))
|
|
2489
|
+
die(`${label} escapes the canonical project root`);
|
|
2490
|
+
return path.relative(projectRoot, candidate);
|
|
2491
|
+
}
|
|
2492
|
+
function globMatches(pattern, relative) {
|
|
2493
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replaceAll("**", "::GLOBSTAR::").replaceAll("*", "[^/]*").replaceAll("::GLOBSTAR::", ".*");
|
|
2494
|
+
return new RegExp(`^${escaped}$`).test(relative);
|
|
2495
|
+
}
|
|
2496
|
+
function expectedGateProducer(flowId, stepId, expectationId) {
|
|
2497
|
+
const manifest = loadJson(path.join(flowAgentsPackageRoot(), "kits", "builder", "kit.json"));
|
|
2498
|
+
const actions = Array.isArray(manifest.flow_step_actions) ? manifest.flow_step_actions : [];
|
|
2499
|
+
const action = actions.find((candidate) => candidate.flow_id === flowId && candidate.step_id === stepId);
|
|
2500
|
+
if (!action)
|
|
2501
|
+
die(`record-gate-claim cannot derive a producer for unknown Flow step ${flowId}/${stepId}`);
|
|
2502
|
+
const skills = Array.isArray(action.skills) ? action.skills.filter((value) => typeof value === "string") : [];
|
|
2503
|
+
const roles = Array.isArray(manifest.skill_roles) ? manifest.skill_roles : [];
|
|
2504
|
+
const owners = roles.filter((role) => typeof role.skill_id === "string"
|
|
2505
|
+
&& Array.isArray(role.step_ids) && role.step_ids.includes(stepId)
|
|
2506
|
+
&& Array.isArray(role.expectation_ids) && role.expectation_ids.includes(expectationId)
|
|
2507
|
+
&& skills.includes(role.skill_id.replace(/^builder\./, "")));
|
|
2508
|
+
if (owners.length === 0) {
|
|
2509
|
+
const operations = Array.isArray(action.operations) ? action.operations.filter((value) => typeof value === "string") : [];
|
|
2510
|
+
const operationExpectations = Array.isArray(action.expectation_ids) ? action.expectation_ids : [];
|
|
2511
|
+
if (operations.length === 1 && operationExpectations.includes(expectationId)) {
|
|
2512
|
+
const artifacts = Array.isArray(action.artifacts) ? action.artifacts.filter((value) => typeof value === "string") : [];
|
|
2513
|
+
return {
|
|
2514
|
+
id: `operation:${operations[0]}`,
|
|
2515
|
+
artifactPatterns: artifacts.filter((value) => !value.includes("#")),
|
|
2516
|
+
selfProducedTrustSlices: artifacts.filter((value) => value.startsWith("trust.bundle#")).map((value) => value.slice("trust.bundle#".length)),
|
|
2517
|
+
};
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
if (owners.length !== 1)
|
|
2521
|
+
die(`record-gate-claim cannot derive exactly one producer for ${flowId}/${stepId}/${expectationId}`);
|
|
2522
|
+
const owner = owners[0];
|
|
2523
|
+
const artifacts = (Array.isArray(owner.artifacts) ? owner.artifacts : []).filter((value) => typeof value === "string" && value !== "ephemeral decision record");
|
|
2524
|
+
return {
|
|
2525
|
+
id: owner.skill_id,
|
|
2526
|
+
artifactPatterns: artifacts.filter((value) => !value.includes("#")),
|
|
2527
|
+
selfProducedTrustSlices: artifacts.filter((value) => value.startsWith("trust.bundle#")).map((value) => value.slice("trust.bundle#".length)),
|
|
2528
|
+
};
|
|
2529
|
+
}
|
|
2530
|
+
function validateReviewableGateEvidence(dir, slug, refs, producer, label) {
|
|
2531
|
+
if (refs.length === 0)
|
|
2532
|
+
die(`${label} requires at least one reviewable --evidence-ref-json`);
|
|
2533
|
+
const projectRoot = canonicalProjectRootForSession(dir);
|
|
2534
|
+
let declaredProducerArtifactFound = producer.artifactPatterns.length === 0;
|
|
2535
|
+
let localOrCommandEvidenceFound = false;
|
|
2536
|
+
for (const [index, ref] of refs.entries()) {
|
|
2537
|
+
if (ref.kind === "command" && hasNonEmptyString(ref.excerpt))
|
|
2538
|
+
localOrCommandEvidenceFound = true;
|
|
2539
|
+
if (typeof ref.file !== "string")
|
|
2540
|
+
continue;
|
|
2541
|
+
const relative = validateLocalEvidenceFile(projectRoot, ref.file, `${label} evidence ref ${index}`);
|
|
2542
|
+
ref.file = relative;
|
|
2543
|
+
localOrCommandEvidenceFound = true;
|
|
2544
|
+
if (ref.kind === "artifact" && producer.artifactPatterns.length > 0) {
|
|
2545
|
+
const patterns = producer.artifactPatterns.map((pattern) => {
|
|
2546
|
+
const expanded = pattern.replaceAll("<slug>", slug);
|
|
2547
|
+
return expanded.startsWith(".kontourai/") ? expanded : `.kontourai/flow-agents/${slug}/${expanded}`;
|
|
2548
|
+
});
|
|
2549
|
+
if (patterns.some((pattern) => globMatches(pattern, relative)))
|
|
2550
|
+
declaredProducerArtifactFound = true;
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
if (!declaredProducerArtifactFound)
|
|
2554
|
+
die(`${label} requires a declared durable artifact from producer ${producer.id}`);
|
|
2555
|
+
if (producer.selfProducedTrustSlices.length > 0 && !localOrCommandEvidenceFound) {
|
|
2556
|
+
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`);
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
function commandFromEvidenceRef(ref) {
|
|
2560
|
+
return typeof ref.excerpt === "string" ? ref.excerpt.trim() : (typeof ref.url === "string" ? ref.url.trim() : "");
|
|
2561
|
+
}
|
|
2562
|
+
function hasTestIntent(name) {
|
|
2563
|
+
return /(?:^|[-_.\/])(test|tests|check|checks|verify|verification|spec|specs|eval|evals)(?:$|[-_.\/])/i.test(name) || /(?:test|check|verify|spec|eval)/i.test(path.basename(name));
|
|
2564
|
+
}
|
|
2565
|
+
function resolvesExplicitTestTarget(projectRoot, token) {
|
|
2566
|
+
if (!hasTestIntent(token))
|
|
2567
|
+
return false;
|
|
2568
|
+
try {
|
|
2569
|
+
if (/[*?\[]/.test(token))
|
|
2570
|
+
return fs.globSync(token, { cwd: projectRoot }).length > 0;
|
|
2571
|
+
const target = path.resolve(projectRoot, token);
|
|
2572
|
+
return pathIsWithinRoot(target, projectRoot) && fs.lstatSync(target).isFile();
|
|
2573
|
+
}
|
|
2574
|
+
catch {
|
|
2575
|
+
return false;
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
function staticTestUnits(file, executable) {
|
|
2579
|
+
try {
|
|
2580
|
+
const content = fs.readFileSync(file, "utf8").slice(0, 256 * 1024);
|
|
2581
|
+
const hashComments = !["cargo", "go"].includes(executable);
|
|
2582
|
+
const active = content.split("\n")
|
|
2583
|
+
.map((line) => hashComments ? line.replace(/\s+#.*$/, "") : line.replace(/\s+\/\/.*$/, ""))
|
|
2584
|
+
.filter((line) => hashComments ? !line.trimStart().startsWith("#") : !line.trimStart().startsWith("//"))
|
|
2585
|
+
.join("\n");
|
|
2586
|
+
if (["bash", "sh", "zsh"].includes(executable)
|
|
2587
|
+
&& !/(?:^|\n)\s*set\s+(?:-[^\n\s]*e[^\n\s]*|-o\s+errexit)(?:\s|$)/.test(active))
|
|
2588
|
+
return 0;
|
|
2589
|
+
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) ?? [];
|
|
2590
|
+
return assertions.length;
|
|
2591
|
+
}
|
|
2592
|
+
catch {
|
|
2593
|
+
return 0;
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
/**
|
|
2597
|
+
* Produce evidence from the locally executed command and statically reviewable
|
|
2598
|
+
* test units. Runner stdout is deliberately excluded: any executable can print
|
|
2599
|
+
* a Vitest/Jest-looking success summary, but it cannot turn a non-test script
|
|
2600
|
+
* into a supported test workflow or supply this locally-created proof.
|
|
2601
|
+
*/
|
|
2602
|
+
export function testExecutionProof(command, projectRoot, seenScripts = new Set(), packageScriptBody = false) {
|
|
2603
|
+
const normalized = command.trim().replace(/\s+/g, " ");
|
|
2604
|
+
if (!normalized || /[`$()]/.test(normalized))
|
|
2605
|
+
return null;
|
|
2606
|
+
if (/[;&|]/.test(normalized)) {
|
|
2607
|
+
if (!packageScriptBody || normalized.includes("||") || /[;|]/.test(normalized))
|
|
2608
|
+
return null;
|
|
2609
|
+
const segments = normalized.split(/\s*&&\s*/).filter(Boolean);
|
|
2610
|
+
return segments.length > 1 ? segments.map((segment) => testExecutionProof(segment, projectRoot, new Set(seenScripts), false)).find(Boolean) ?? null : null;
|
|
2611
|
+
}
|
|
2612
|
+
if (/^(?:true|:|\/usr\/bin\/true)$/i.test(normalized))
|
|
2613
|
+
return null;
|
|
2614
|
+
if (/^(?:echo|printf)(?:\s|$)/i.test(normalized))
|
|
2615
|
+
return null;
|
|
2616
|
+
if (/(?:^|\s)(?:--version|-v|--help|-h)(?:\s|$)/.test(normalized))
|
|
2617
|
+
return null;
|
|
2618
|
+
const tokens = normalized.split(" ").filter(Boolean);
|
|
2619
|
+
while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0] ?? ""))
|
|
2620
|
+
tokens.shift();
|
|
2621
|
+
const executable = tokens[0] ?? "";
|
|
2622
|
+
const executableName = path.basename(executable);
|
|
2623
|
+
if (["npm", "pnpm", "yarn", "bun"].includes(executableName)) {
|
|
2624
|
+
// `bun test` is Bun's test runner; package scripts use `bun run <script>`.
|
|
2625
|
+
if (executableName === "bun" && tokens[1] === "test") {
|
|
2626
|
+
const target = tokens.slice(2).find((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token));
|
|
2627
|
+
const units = target ? staticTestUnits(path.resolve(projectRoot, target), "bun") : 0;
|
|
2628
|
+
return units > 0 ? { kind: "local-process-exit", runner: "bun test", static_test_units: units } : null;
|
|
2629
|
+
}
|
|
2630
|
+
const script = tokens[1] === "run" || tokens[1] === "run-script" ? tokens[2] : tokens[1];
|
|
2631
|
+
if (!script || !hasTestIntent(script) || seenScripts.has(script))
|
|
2632
|
+
return null;
|
|
2633
|
+
try {
|
|
2634
|
+
const pkg = loadJson(path.join(projectRoot, "package.json"));
|
|
2635
|
+
const scriptCommand = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts[script] : undefined;
|
|
2636
|
+
if (typeof scriptCommand !== "string")
|
|
2637
|
+
return null;
|
|
2638
|
+
seenScripts.add(script);
|
|
2639
|
+
return testExecutionProof(scriptCommand, projectRoot, seenScripts, true);
|
|
2640
|
+
}
|
|
2641
|
+
catch {
|
|
2642
|
+
return null;
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
if (["vitest", "jest", "mocha", "ava", "pytest", "rspec", "phpunit"].includes(executableName)
|
|
2646
|
+
&& !tokens.some((token) => /pass.?with.?no.?tests/i.test(token))) {
|
|
2647
|
+
if (executable !== executableName)
|
|
2648
|
+
return null;
|
|
2649
|
+
const targets = tokens.slice(1)
|
|
2650
|
+
.filter((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token))
|
|
2651
|
+
.flatMap((token) => /[*?\[]/.test(token) ? fs.globSync(token, { cwd: projectRoot }) : [token]);
|
|
2652
|
+
const units = targets.reduce((total, target) => total + staticTestUnits(path.resolve(projectRoot, target), executableName), 0);
|
|
2653
|
+
return units > 0 ? { kind: "local-process-exit", runner: executableName, static_test_units: units } : null;
|
|
2654
|
+
}
|
|
2655
|
+
if (executableName === "cargo" && tokens[1] === "test") {
|
|
2656
|
+
const files = [...fs.globSync("tests/**/*.rs", { cwd: projectRoot }), ...fs.globSync("src/**/*.rs", { cwd: projectRoot })];
|
|
2657
|
+
const units = [...new Set(files)].reduce((total, file) => total + staticTestUnits(path.resolve(projectRoot, file), "cargo"), 0);
|
|
2658
|
+
return units > 0 ? { kind: "local-process-exit", runner: "cargo test", static_test_units: units } : null;
|
|
2659
|
+
}
|
|
2660
|
+
if (executableName === "go" && tokens[1] === "test") {
|
|
2661
|
+
const files = fs.globSync("**/*_test.go", { cwd: projectRoot, exclude: ["vendor/**", ".git/**"] });
|
|
2662
|
+
const units = files.reduce((total, file) => total + staticTestUnits(path.resolve(projectRoot, file), "go"), 0);
|
|
2663
|
+
return units > 0 ? { kind: "local-process-exit", runner: "go test", static_test_units: units } : null;
|
|
2664
|
+
}
|
|
2665
|
+
if (executableName === "npx" && tokens[1] && ["vitest", "jest", "mocha", "ava", "pytest"].includes(path.basename(tokens[1]))) {
|
|
2666
|
+
const runner = path.basename(tokens[1]);
|
|
2667
|
+
const targets = tokens.slice(2)
|
|
2668
|
+
.filter((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token))
|
|
2669
|
+
.flatMap((token) => /[*?\[]/.test(token) ? fs.globSync(token, { cwd: projectRoot }) : [token]);
|
|
2670
|
+
const units = targets.reduce((total, target) => total + staticTestUnits(path.resolve(projectRoot, target), runner), 0);
|
|
2671
|
+
return units > 0 ? { kind: "local-process-exit", runner: `npx ${runner}`, static_test_units: units } : null;
|
|
2672
|
+
}
|
|
2673
|
+
if (executableName === "node" && tokens.includes("--test")) {
|
|
2674
|
+
const testFlag = tokens.indexOf("--test");
|
|
2675
|
+
const targets = tokens.slice(testFlag + 1)
|
|
2676
|
+
.filter((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token))
|
|
2677
|
+
.flatMap((token) => /[*?\[]/.test(token) ? fs.globSync(token, { cwd: projectRoot }) : [token]);
|
|
2678
|
+
const units = targets.reduce((total, target) => total + staticTestUnits(path.resolve(projectRoot, target), executableName), 0);
|
|
2679
|
+
return units > 0 ? { kind: "local-process-exit", runner: "node --test", static_test_units: units } : null;
|
|
2680
|
+
}
|
|
2681
|
+
const scriptPath = ["bash", "sh", "zsh", "tsx", "ts-node"].includes(executableName) ? tokens[1] : executable;
|
|
2682
|
+
if (!scriptPath || ["-c", "-lc", "-e"].includes(scriptPath) || !hasTestIntent(scriptPath))
|
|
2683
|
+
return null;
|
|
2684
|
+
try {
|
|
2685
|
+
const resolved = path.resolve(projectRoot, scriptPath);
|
|
2686
|
+
const stat = fs.lstatSync(resolved);
|
|
2687
|
+
if (stat.isSymbolicLink() || !stat.isFile() || !pathIsWithinRoot(fs.realpathSync(resolved), fs.realpathSync(projectRoot)))
|
|
2688
|
+
return null;
|
|
2689
|
+
const units = staticTestUnits(resolved, executableName);
|
|
2690
|
+
return units > 0 ? { kind: "local-process-exit", runner: executableName, static_test_units: units } : null;
|
|
2691
|
+
}
|
|
2692
|
+
catch {
|
|
2693
|
+
return null;
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
/** Validate test-evidence command shape without executing it. */
|
|
2697
|
+
export function isMeaningfulTestCommand(command, projectRoot, seenScripts = new Set(), packageScriptBody = false) {
|
|
2698
|
+
return testExecutionProof(command, projectRoot, seenScripts, packageScriptBody) !== null;
|
|
2699
|
+
}
|
|
2700
|
+
export function observedExecutedTestCount(output) {
|
|
2701
|
+
const counts = [];
|
|
2702
|
+
for (const pattern of [
|
|
2703
|
+
/^\s*(?:#|ℹ)\s*tests\s+(\d+)\s*$/gim,
|
|
2704
|
+
/^\s*1\.\.(\d+)(?:\s|$)/gim,
|
|
2705
|
+
/\b(\d+)\s+passed\b/gim,
|
|
2706
|
+
]) {
|
|
2707
|
+
for (const match of output.matchAll(pattern))
|
|
2708
|
+
counts.push(Number(match[1]));
|
|
2709
|
+
}
|
|
2710
|
+
const explicitPasses = output.match(/^\s*(?:---\s+PASS:|ok\s+\d+\s+-)/gm)?.length ?? 0;
|
|
2711
|
+
if (explicitPasses > 0)
|
|
2712
|
+
counts.push(explicitPasses);
|
|
2713
|
+
const goPackages = output.match(/^ok\s+\S+(?:\s|$)/gm)?.length ?? 0;
|
|
2714
|
+
if (goPackages > 0)
|
|
2715
|
+
counts.push(goPackages);
|
|
2716
|
+
return Math.max(0, ...counts.filter((count) => Number.isSafeInteger(count) && count > 0));
|
|
2717
|
+
}
|
|
2718
|
+
export function inferExecutedTestCount(command, projectRoot, output, seenScripts = new Set()) {
|
|
2719
|
+
const proof = testExecutionProof(command, projectRoot, seenScripts);
|
|
2720
|
+
if (!proof)
|
|
2721
|
+
return 0;
|
|
2722
|
+
const observed = observedExecutedTestCount(output);
|
|
2723
|
+
return observed > 0 ? Math.min(proof.static_test_units, observed) : 0;
|
|
2724
|
+
}
|
|
2725
|
+
async function normalizeObservedCommands(commands, projectRoot, requireTestIntent, expectedStatus) {
|
|
2726
|
+
if (commands.length === 0)
|
|
2727
|
+
die("record-gate-claim requires at least one --command for observed command evidence");
|
|
2728
|
+
if (new Set(commands).size !== commands.length)
|
|
2729
|
+
die("record-gate-claim --command values must be unique");
|
|
2730
|
+
for (const command of commands) {
|
|
2731
|
+
const { isRunnableCommandText } = loadRunnableCommandHelper();
|
|
2732
|
+
if (!isRunnableCommandText(command))
|
|
2733
|
+
die(`record-gate-claim --command "${command}" is not a runnable shell command — prose belongs in --summary, which is never executed.`);
|
|
2734
|
+
if (requireTestIntent && !isMeaningfulTestCommand(command, projectRoot))
|
|
2735
|
+
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");
|
|
2736
|
+
}
|
|
2737
|
+
// Passing test evidence is always executed exactly once by this canonical
|
|
2738
|
+
// writer. Caller-supplied observations remain available for non-test
|
|
2739
|
+
// attestations but can never stand in for locally observed test execution.
|
|
2740
|
+
const observed = await Promise.all(commands.map(async (command) => {
|
|
2741
|
+
const result = await runObservedCommand(command, projectRoot);
|
|
2742
|
+
const proof = requireTestIntent ? testExecutionProof(command, projectRoot) : null;
|
|
2743
|
+
return { command, exit_code: result.exit_code, output_sha256: result.output_sha256, ...(proof ? { test_count: inferExecutedTestCount(command, projectRoot, result.output), execution_proof: proof } : {}) };
|
|
2744
|
+
}));
|
|
2745
|
+
if (observed.length !== commands.length)
|
|
2746
|
+
die("record-gate-claim requires exactly one --observed-command-json for every --command");
|
|
2747
|
+
const byCommand = new Map();
|
|
2748
|
+
for (const entry of observed) {
|
|
2749
|
+
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))
|
|
2750
|
+
die("--observed-command-json must contain command, integer exit_code, and sha256 output_sha256");
|
|
2751
|
+
if (!commands.includes(entry.command))
|
|
2752
|
+
die("--observed-command-json command must exactly match one supplied --command");
|
|
2753
|
+
if (expectedStatus === "pass" && entry.exit_code !== 0)
|
|
2754
|
+
die(`record-gate-claim passing evidence command failed (exit ${entry.exit_code}): ${entry.command}`);
|
|
2755
|
+
if (expectedStatus === "fail" && entry.exit_code === 0)
|
|
2756
|
+
die(`record-gate-claim failing evidence command unexpectedly passed: ${entry.command}`);
|
|
2757
|
+
if (requireTestIntent && (!Number.isSafeInteger(entry.test_count) || Number(entry.test_count) <= 0 || !entry.execution_proof || entry.execution_proof.kind !== "local-process-exit"))
|
|
2758
|
+
die(`record-gate-claim passing tests-evidence command did not produce a local execution proof: ${entry.command}`);
|
|
2759
|
+
if (byCommand.has(entry.command))
|
|
2760
|
+
die("--observed-command-json command values must be unique");
|
|
2761
|
+
byCommand.set(entry.command, entry);
|
|
2762
|
+
}
|
|
2763
|
+
if (commands.some((command) => !byCommand.has(command)))
|
|
2764
|
+
die("every --command requires a corresponding --observed-command-json");
|
|
2765
|
+
return commands.map((command) => byCommand.get(command));
|
|
2766
|
+
}
|
|
2767
|
+
function requireObservedCommandRefs(refs, observedCommands, label, requireAll = false) {
|
|
2768
|
+
const commandRefs = refs.filter((ref) => ref.kind === "command");
|
|
2769
|
+
if (commandRefs.length === 0)
|
|
2770
|
+
die(`${label} requires a command evidence ref matching a successful observed command`);
|
|
2771
|
+
for (const ref of commandRefs) {
|
|
2772
|
+
if (!observedCommands.has(commandFromEvidenceRef(ref)))
|
|
2773
|
+
die(`${label} command evidence ref must exactly match a successful --observed-command-json command`);
|
|
2774
|
+
}
|
|
2775
|
+
if (requireAll) {
|
|
2776
|
+
const referenced = new Set(commandRefs.map(commandFromEvidenceRef));
|
|
2777
|
+
if ([...observedCommands].some((command) => !referenced.has(command)))
|
|
2778
|
+
die(`${label} requires a top-level command evidence ref for every successful observed command`);
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
function completePassingCriteria(existing, raw, observedCommands, verifiedAt) {
|
|
2782
|
+
if (raw.length === 0)
|
|
2783
|
+
die("record-gate-claim requires --criterion-json for a passing tests-evidence claim");
|
|
2784
|
+
const incoming = raw.map((value) => parseJson(value, "--criterion-json"));
|
|
2785
|
+
const expectedById = new Map();
|
|
2786
|
+
for (const criterion of existing) {
|
|
2787
|
+
const id = String(criterion.id ?? "");
|
|
2788
|
+
if (id.length > 0)
|
|
2789
|
+
expectedById.set(id, criterion);
|
|
2790
|
+
}
|
|
2791
|
+
const expectedIds = [...expectedById.keys()];
|
|
2792
|
+
const ids = incoming.map((criterion) => typeof criterion.id === "string" ? criterion.id : "");
|
|
2793
|
+
if (new Set(ids).size !== ids.length || ids.length !== expectedIds.length || ids.some((id) => !expectedIds.includes(id))) {
|
|
2794
|
+
die(`--criterion-json must cover every declared acceptance criterion exactly once (expected: ${expectedIds.join(", ") || "none"}; received: ${ids.join(", ") || "none"})`);
|
|
2795
|
+
}
|
|
2796
|
+
return incoming.map((criterion, index) => {
|
|
2797
|
+
if (Object.keys(criterion).some((key) => !["id", "status", "evidence_refs"].includes(key)))
|
|
2798
|
+
die(`criterion ${ids[index]} may update only id, status, and evidence_refs`);
|
|
2799
|
+
if (criterion.status !== "pass")
|
|
2800
|
+
die(`criterion ${ids[index]} must have status pass for a passing tests-evidence claim`);
|
|
2801
|
+
const refs = normalizeEvidenceRefs(criterion.evidence_refs, `criterion ${ids[index]} evidence_refs`);
|
|
2802
|
+
if (refs.length === 0)
|
|
2803
|
+
die(`criterion ${ids[index]} requires reviewable evidence_refs`);
|
|
2804
|
+
requireObservedCommandRefs(refs, observedCommands, `criterion ${ids[index]}`);
|
|
2805
|
+
return { ...expectedById.get(ids[index]), status: "pass", evidence_refs: refs, identity_version: 2, verified_at: verifiedAt };
|
|
2806
|
+
});
|
|
2807
|
+
}
|
|
2808
|
+
const critiqueStatuses = new Set(["pass", "fail", "not_verified"]);
|
|
2809
|
+
const safeCritiqueId = /^[a-z][a-z0-9_-]*$/;
|
|
2810
|
+
function normalizeCritiqueLanes(raw) {
|
|
2811
|
+
if (raw.length === 0)
|
|
2812
|
+
die("record-critique requires at least one --lane-json");
|
|
2813
|
+
const lanes = raw.map((value, index) => {
|
|
2814
|
+
const lane = parseJson(value, "--lane-json");
|
|
2815
|
+
const keys = Object.keys(lane);
|
|
2816
|
+
if (keys.some((key) => !["id", "status", "summary", "evidence_refs"].includes(key)))
|
|
2817
|
+
die(`--lane-json ${index} contains unsupported fields`);
|
|
2818
|
+
if (!safeCritiqueId.test(String(lane.id ?? "")))
|
|
2819
|
+
die(`--lane-json ${index} id must be a unique safe identifier`);
|
|
2820
|
+
if (!critiqueStatuses.has(String(lane.status ?? "")))
|
|
2821
|
+
die(`--lane-json ${index} status must be one of: pass, fail, not_verified`);
|
|
2822
|
+
if (!hasNonEmptyString(lane.summary))
|
|
2823
|
+
die(`--lane-json ${index} summary must be non-empty`);
|
|
2824
|
+
const evidenceRefs = normalizeEvidenceRefs(lane.evidence_refs, `--lane-json ${index} evidence_refs`);
|
|
2825
|
+
if (evidenceRefs.length === 0)
|
|
2826
|
+
die(`--lane-json ${index} requires structured reviewable evidence_refs`);
|
|
2827
|
+
return { id: lane.id, status: lane.status, summary: lane.summary, evidence_refs: evidenceRefs };
|
|
2828
|
+
});
|
|
2829
|
+
if (new Set(lanes.map((lane) => lane.id)).size !== lanes.length)
|
|
2830
|
+
die("--lane-json ids must be unique");
|
|
2831
|
+
return lanes;
|
|
2832
|
+
}
|
|
2833
|
+
function reviewTargetArtifacts(dir, rawPaths, label) {
|
|
2834
|
+
const projectRoot = canonicalProjectRootForSession(dir);
|
|
2835
|
+
const fallback = path.join(dir, `${taskSlugFor(dir)}--deliver.md`);
|
|
2836
|
+
const paths = rawPaths.length > 0 ? rawPaths : (fs.existsSync(fallback) ? [fallback] : []);
|
|
2837
|
+
const files = paths.map((raw, index) => validateLocalEvidenceFile(projectRoot, raw, `${label} artifact ${index}`));
|
|
2838
|
+
if (new Set(files).size !== files.length)
|
|
2839
|
+
die(`${label} artifact refs must be unique`);
|
|
2840
|
+
return files.map((file) => ({ file, sha256: createHash("sha256").update(fs.readFileSync(path.join(projectRoot, file))).digest("hex") }));
|
|
2841
|
+
}
|
|
2842
|
+
function reviewTargetArtifactsMatch(dir, reviewTarget) {
|
|
2843
|
+
try {
|
|
2844
|
+
if (!reviewTarget || typeof reviewTarget !== "object" || Array.isArray(reviewTarget))
|
|
2845
|
+
return false;
|
|
2846
|
+
const artifacts = reviewTarget.artifacts;
|
|
2847
|
+
if (!Array.isArray(artifacts) || artifacts.length === 0)
|
|
2848
|
+
return false;
|
|
2849
|
+
const projectRoot = canonicalProjectRootForSession(dir);
|
|
2850
|
+
const files = new Set();
|
|
2851
|
+
return artifacts.every((artifact) => {
|
|
2852
|
+
if (!artifact || typeof artifact !== "object")
|
|
2853
|
+
return false;
|
|
2854
|
+
const file = artifact.file;
|
|
2855
|
+
const sha256 = artifact.sha256;
|
|
2856
|
+
if (!hasNonEmptyString(file) || !/^[a-f0-9]{64}$/i.test(String(sha256)) || files.has(file))
|
|
2857
|
+
return false;
|
|
2858
|
+
files.add(file);
|
|
2859
|
+
const relative = validateLocalEvidenceFile(projectRoot, file, "critique review_target artifact");
|
|
2860
|
+
const digest = createHash("sha256").update(fs.readFileSync(path.join(projectRoot, relative))).digest("hex");
|
|
2861
|
+
return digest === sha256;
|
|
2862
|
+
});
|
|
2863
|
+
}
|
|
2864
|
+
catch {
|
|
2865
|
+
return false;
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
function critiqueIsCleanAndCurrent(dir, critique) {
|
|
2869
|
+
if (critique.verdict !== "pass")
|
|
2870
|
+
return false;
|
|
2871
|
+
if (!Array.isArray(critique.lanes) || critique.lanes.length === 0 || critique.lanes.some((lane) => lane.status !== "pass"))
|
|
2872
|
+
return false;
|
|
2873
|
+
if (Array.isArray(critique.findings) && critique.findings.some((finding) => finding.status === "open"))
|
|
2874
|
+
return false;
|
|
2875
|
+
return reviewTargetArtifactsMatch(dir, critique.review_target);
|
|
2876
|
+
}
|
|
2307
2877
|
// #270 HIGH fix (iteration 3): the `gate-claim-` check-id prefix is RESERVED for
|
|
2308
2878
|
// record-gate-claim's own internally-constructed ids (`id: \`gate-claim-${checkId}\`` — see
|
|
2309
2879
|
// recordGateClaim below). Every OTHER writer of check ids (record-evidence --check-json,
|
|
@@ -2637,6 +3207,20 @@ function checksFromBundle(dir) {
|
|
|
2637
3207
|
const od = md && typeof md === "object" ? md.output_digest : undefined;
|
|
2638
3208
|
return od && typeof od === "object" && od.algorithm === "sha256" && typeof od.hex === "string" && od.hex.length > 0 ? od.hex : undefined;
|
|
2639
3209
|
};
|
|
3210
|
+
const observedCommandsOf = (claim) => {
|
|
3211
|
+
const md = claim.metadata;
|
|
3212
|
+
const observed = md && typeof md === "object" ? md.observed_commands : undefined;
|
|
3213
|
+
return Array.isArray(observed) ? observed.filter((entry) => typeof entry?.command === "string" && typeof entry?.exit_code === "number" && typeof entry?.output_sha256 === "string") : undefined;
|
|
3214
|
+
};
|
|
3215
|
+
const applyProducerStamp = (check, claim) => {
|
|
3216
|
+
const md = claim.metadata;
|
|
3217
|
+
if (md && typeof md.expected_producer === "string")
|
|
3218
|
+
check._producer = md.expected_producer;
|
|
3219
|
+
if (md && typeof md.recorded_by === "string")
|
|
3220
|
+
check._recorded_by = md.recorded_by;
|
|
3221
|
+
if (md && Array.isArray(md.self_produced_trust_slices))
|
|
3222
|
+
check._producer_self_produced_trust_slices = md.self_produced_trust_slices;
|
|
3223
|
+
};
|
|
2640
3224
|
// #270(a)/(c): read side of the gate_claim stamp (write side: buildTrustBundle's
|
|
2641
3225
|
// claimMetadata.gate_claim, above). Restoring expectation_id/claim_type/subject_type/step_id
|
|
2642
3226
|
// is what lets a REBUILD (record-evidence/record-critique/record-learning, after the recorded
|
|
@@ -2658,6 +3242,10 @@ function checksFromBundle(dir) {
|
|
|
2658
3242
|
check._gate_claim_declared_subject = gc.subject_type;
|
|
2659
3243
|
if (typeof gc.step_id === "string")
|
|
2660
3244
|
check._gate_claim_declared_step_id = gc.step_id;
|
|
3245
|
+
if (typeof gc.recorded_at === "string")
|
|
3246
|
+
check._gate_claim_recorded_at = gc.recorded_at;
|
|
3247
|
+
if (gc.identity_version === 2)
|
|
3248
|
+
check._gate_claim_identity_version = 2;
|
|
2661
3249
|
if (typeof gc.route_reason === "string")
|
|
2662
3250
|
check._gate_claim_route_reason = gc.route_reason;
|
|
2663
3251
|
};
|
|
@@ -2730,6 +3318,10 @@ function checksFromBundle(dir) {
|
|
|
2730
3318
|
const outputSha256 = outputSha256Of(claim);
|
|
2731
3319
|
if (outputSha256)
|
|
2732
3320
|
check._output_sha256 = outputSha256;
|
|
3321
|
+
const observedCommands = observedCommandsOf(claim);
|
|
3322
|
+
if (observedCommands)
|
|
3323
|
+
check._observed_commands = observedCommands;
|
|
3324
|
+
applyProducerStamp(check, claim);
|
|
2733
3325
|
applyGateClaimStamp(check, claim);
|
|
2734
3326
|
applyGateClaimShapeUnstamped(check, claim);
|
|
2735
3327
|
checks.push(check);
|
|
@@ -2752,6 +3344,10 @@ function checksFromBundle(dir) {
|
|
|
2752
3344
|
const outputSha256 = outputSha256Of(claim);
|
|
2753
3345
|
if (outputSha256)
|
|
2754
3346
|
check._output_sha256 = outputSha256;
|
|
3347
|
+
const observedCommands = observedCommandsOf(claim);
|
|
3348
|
+
if (observedCommands)
|
|
3349
|
+
check._observed_commands = observedCommands;
|
|
3350
|
+
applyProducerStamp(check, claim);
|
|
2755
3351
|
applyGateClaimStamp(check, claim);
|
|
2756
3352
|
applyGateClaimShapeUnstamped(check, claim);
|
|
2757
3353
|
checks.push(check);
|
|
@@ -2786,9 +3382,18 @@ function existingCheckStampMap(checks) {
|
|
|
2786
3382
|
*/
|
|
2787
3383
|
function readBundleState(dir) {
|
|
2788
3384
|
const acceptance = loadJson(path.join(dir, "acceptance.json"));
|
|
3385
|
+
const bundledCriteria = criteriaFromBundle(dir);
|
|
3386
|
+
const acceptedCriteria = Array.isArray(acceptance.criteria) ? acceptance.criteria : [];
|
|
3387
|
+
const contractSignature = (criteria) => JSON.stringify(criteria.map((criterion) => ({
|
|
3388
|
+
id: criterion.id ?? null,
|
|
3389
|
+
description: criterion.description ?? null,
|
|
3390
|
+
})));
|
|
3391
|
+
const criteria = acceptedCriteria.length > 0 && contractSignature(acceptedCriteria) !== contractSignature(bundledCriteria)
|
|
3392
|
+
? acceptedCriteria
|
|
3393
|
+
: (bundledCriteria.length > 0 ? bundledCriteria : acceptedCriteria);
|
|
2789
3394
|
return {
|
|
2790
3395
|
checks: checksFromBundle(dir),
|
|
2791
|
-
criteria
|
|
3396
|
+
criteria,
|
|
2792
3397
|
critiques: critiquesFromBundle(dir),
|
|
2793
3398
|
};
|
|
2794
3399
|
}
|
|
@@ -2825,14 +3430,39 @@ function critiquesFromBundle(dir) {
|
|
|
2825
3430
|
id: String(c.subjectId || "").split("/").pop() || c.id,
|
|
2826
3431
|
verdict: c.value ?? "not_verified",
|
|
2827
3432
|
summary: c.fieldOrBehavior || "",
|
|
2828
|
-
findings: [],
|
|
3433
|
+
findings: Array.isArray(md.findings) ? md.findings : [],
|
|
3434
|
+
lanes: Array.isArray(md.lanes) ? md.lanes : [],
|
|
3435
|
+
review_target: md.review_target && typeof md.review_target === "object" && !Array.isArray(md.review_target) ? md.review_target : { artifacts: [] },
|
|
2829
3436
|
reviewer: typeof md.reviewer === "string" ? md.reviewer : "tool-code-reviewer",
|
|
2830
3437
|
reviewed_at: typeof md.reviewed_at === "string" ? md.reviewed_at : (c.updatedAt || c.createdAt || now()),
|
|
2831
|
-
|
|
3438
|
+
...(md.identity_version === 2 ? { identity_version: 2 } : {}),
|
|
3439
|
+
artifact_refs: Array.isArray(md.artifact_refs) ? md.artifact_refs : [],
|
|
2832
3440
|
...(typeof md.superseded_by === "string" && md.superseded_by.length > 0 ? { superseded_by: md.superseded_by } : {}),
|
|
2833
3441
|
};
|
|
2834
3442
|
});
|
|
2835
3443
|
}
|
|
3444
|
+
function criteriaFromBundle(dir) {
|
|
3445
|
+
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
3446
|
+
if (!Array.isArray(bundle.claims))
|
|
3447
|
+
return [];
|
|
3448
|
+
for (const claim of bundle.claims)
|
|
3449
|
+
requireStampedClaim(claim, dir);
|
|
3450
|
+
return bundle.claims
|
|
3451
|
+
.filter((claim) => claim && claimOrigin(claim) === "acceptance")
|
|
3452
|
+
.map((claim) => {
|
|
3453
|
+
const md = claim.metadata && typeof claim.metadata === "object" && !Array.isArray(claim.metadata) ? claim.metadata : {};
|
|
3454
|
+
const saved = md.criterion && typeof md.criterion === "object" && !Array.isArray(md.criterion) ? md.criterion : {};
|
|
3455
|
+
return {
|
|
3456
|
+
id: typeof saved.id === "string" ? saved.id : String(claim.subjectId || "").split("/").pop(),
|
|
3457
|
+
description: typeof saved.description === "string" ? saved.description : (claim.fieldOrBehavior || ""),
|
|
3458
|
+
status: typeof saved.status === "string" ? saved.status : (claim.value ?? "not_verified"),
|
|
3459
|
+
evidence_refs: Array.isArray(saved.evidence_refs) ? saved.evidence_refs : [],
|
|
3460
|
+
...(typeof saved.verified_at === "string" ? { verified_at: saved.verified_at } : {}),
|
|
3461
|
+
...(saved.identity_version === 2 ? { identity_version: 2 } : {}),
|
|
3462
|
+
};
|
|
3463
|
+
})
|
|
3464
|
+
.filter((criterion) => typeof criterion.id === "string" && criterion.id.length > 0);
|
|
3465
|
+
}
|
|
2836
3466
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
2837
3467
|
/**
|
|
2838
3468
|
* WS8 (ADR 0020): parse the accepted-gap waiver flags. Both --accepted-gap-reason and
|
|
@@ -2888,20 +3518,18 @@ async function recordEvidence(p) {
|
|
|
2888
3518
|
die("record-evidence requires at least one --check-json or --surface-trust-json");
|
|
2889
3519
|
validateAcceptanceEvidenceRefs(dir, p);
|
|
2890
3520
|
// Phase 4c: bundle is the sole verification artifact — stop writing evidence.json and acceptance.json update.
|
|
2891
|
-
const ts = opt(p, "timestamp",
|
|
3521
|
+
const ts = opt(p, "timestamp", new Date().toISOString());
|
|
2892
3522
|
// #298: readBundleState + merge-by-id instead of unconditionally replacing every prior check —
|
|
2893
3523
|
// record-evidence previously never called checksFromBundle(dir), so it dropped every check
|
|
2894
3524
|
// recorded by an earlier record-evidence/record-check/record-gate-claim call. A later check
|
|
2895
3525
|
// with the same id supersedes the earlier one (same-id resupply); a new id is additive.
|
|
2896
3526
|
// (_existingState was already read above, before normalizeCheck ran, so the reserved-prefix
|
|
2897
3527
|
// exists-check sees the SAME bundle snapshot the merge below uses — no second read needed.)
|
|
2898
|
-
const _criteriaStatus = verdict === "pass" ? "pass" : verdict === "fail" ? "fail" : "not_verified";
|
|
2899
|
-
const _criteriaForBundle = _existingState.criteria.map((c) => ({ ...c, status: _criteriaStatus }));
|
|
2900
3528
|
const _mergedChecks = mergeChecksById(_existingState.checks, checks);
|
|
2901
3529
|
// #268: preserve any existing critique claims (including superseded history) instead of dropping
|
|
2902
3530
|
// them — record-evidence previously hardcoded critiques:[] here, silently erasing finding history
|
|
2903
3531
|
// whenever it ran after a critique existed.
|
|
2904
|
-
assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks,
|
|
3532
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, _existingState.criteria, _existingState.critiques));
|
|
2905
3533
|
const stateStatus = verdict === "pass" ? "verified" : verdict === "fail" ? "failed" : "not_verified";
|
|
2906
3534
|
writeState(dir, slug, stateStatus, "verification", ts, "Evidence recorded.");
|
|
2907
3535
|
return 0;
|
|
@@ -3097,7 +3725,7 @@ function diagnostic(dir, code, summary) {
|
|
|
3097
3725
|
async function recordGateClaim(p) {
|
|
3098
3726
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
3099
3727
|
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
3100
|
-
const ts = opt(p, "timestamp",
|
|
3728
|
+
const ts = opt(p, "timestamp", new Date().toISOString());
|
|
3101
3729
|
const statusVal = opt(p, "status");
|
|
3102
3730
|
if (!["pass", "fail", "not_verified"].includes(statusVal))
|
|
3103
3731
|
die("--status must be one of: pass, fail, not_verified");
|
|
@@ -3108,14 +3736,23 @@ async function recordGateClaim(p) {
|
|
|
3108
3736
|
die("--route-reason is only valid with --status fail");
|
|
3109
3737
|
if (routeReason && !/^[a-z][a-z0-9_-]*$/.test(routeReason))
|
|
3110
3738
|
die("--route-reason must be a lowercase classifier identifier");
|
|
3111
|
-
//
|
|
3112
|
-
//
|
|
3113
|
-
//
|
|
3114
|
-
// Stop-short risk) lack of a dir-scoping guard: it now resolves ITS OWN actor's current.json
|
|
3115
|
-
// view, not a different actor's more-recently-written legacy pointer.
|
|
3739
|
+
// Prefer the exact session's canonical Flow projection. Actor/global current pointers are
|
|
3740
|
+
// ambient navigation state and may legitimately point at another run or lag this run.
|
|
3741
|
+
// Legacy sessions without flow_run retain the per-actor current-pointer fallback.
|
|
3116
3742
|
const flowAgentsDir = path.dirname(dir);
|
|
3117
3743
|
const gateClaimActorKey = resolveReadActorKey(p);
|
|
3118
|
-
const
|
|
3744
|
+
const sidecarState = loadJson(path.join(dir, "state.json"));
|
|
3745
|
+
const projectedRun = sidecarState.flow_run && typeof sidecarState.flow_run === "object" && !Array.isArray(sidecarState.flow_run)
|
|
3746
|
+
? sidecarState.flow_run
|
|
3747
|
+
: null;
|
|
3748
|
+
const exactFlowId = projectedRun && typeof projectedRun.definition_id === "string" ? projectedRun.definition_id : null;
|
|
3749
|
+
const exactStepId = projectedRun && typeof projectedRun.current_step === "string" ? projectedRun.current_step : null;
|
|
3750
|
+
const exactFlowContext = exactFlowId && exactStepId ? { flowId: exactFlowId, stepId: exactStepId } : undefined;
|
|
3751
|
+
const activeStep = exactFlowContext
|
|
3752
|
+
? resolveFlowStep(exactFlowContext.flowId, exactFlowContext.stepId, findRepoRootFromDir(path.dirname(flowAgentsDir)))
|
|
3753
|
+
: resolveActiveFlowStep(flowAgentsDir, gateClaimActorKey);
|
|
3754
|
+
if (exactFlowContext && !activeStep)
|
|
3755
|
+
die(`record-gate-claim cannot resolve exact session Flow step ${exactFlowContext.flowId}/${exactFlowContext.stepId}`);
|
|
3119
3756
|
if (!activeStep)
|
|
3120
3757
|
die("record-gate-claim requires an active flow step in current.json (set via ensure-session --flow-id or advance-state --flow-definition)");
|
|
3121
3758
|
if (routeReason && !activeStep.routeBackReasons.includes(routeReason)) {
|
|
@@ -3138,41 +3775,72 @@ async function recordGateClaim(p) {
|
|
|
3138
3775
|
die(`record-gate-claim: gate "${activeStep.gateId}" has ${expects.length} expects[] entries; --expectation <id> is required. Available: ${expects.map((e) => e.id).join(", ")}`);
|
|
3139
3776
|
}
|
|
3140
3777
|
const { claimType, subjectType } = targetExpectation.bundle_claim;
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3778
|
+
const gateCommands = opts(p, "command");
|
|
3779
|
+
const gateCommand = gateCommands[0] ?? "";
|
|
3780
|
+
const mustRunTests = targetExpectation.id === "tests-evidence" && statusVal === "pass";
|
|
3781
|
+
const observedCommandRaw = opts(p, "observed-command-json");
|
|
3782
|
+
if (mustRunTests && gateCommands.length === 0)
|
|
3783
|
+
die("record-gate-claim requires at least one --command for a passing tests-evidence claim");
|
|
3784
|
+
const projectRoot = gateCommands.length > 0 ? canonicalProjectRootForSession(dir) : null;
|
|
3785
|
+
const observedCommands = gateCommands.length > 0
|
|
3786
|
+
? await normalizeObservedCommands(gateCommands, projectRoot, mustRunTests, statusVal)
|
|
3787
|
+
: [];
|
|
3788
|
+
const observedCommandNames = new Set(observedCommands.map((entry) => entry.command));
|
|
3789
|
+
let outputSha256 = null;
|
|
3790
|
+
if (!mustRunTests && gateCommands.length > 1)
|
|
3791
|
+
die("record-gate-claim accepts repeatable --command only for passing tests-evidence claims");
|
|
3792
|
+
if (gateCommands.length === 0 && observedCommandRaw.length > 0)
|
|
3793
|
+
die("--observed-command-json requires --command");
|
|
3794
|
+
if (!mustRunTests && gateCommand && observedCommandRaw.length === 0) {
|
|
3795
|
+
const { isRunnableCommandText } = loadRunnableCommandHelper();
|
|
3796
|
+
if (!isRunnableCommandText(gateCommand))
|
|
3797
|
+
die(`record-gate-claim --command "${gateCommand}" is not a runnable shell command — prose belongs in --summary, which is never executed.`);
|
|
3798
|
+
}
|
|
3799
|
+
if (observedCommands.length > 0)
|
|
3800
|
+
outputSha256 = observedCommands[0].output_sha256;
|
|
3801
|
+
// Build a gate-targeted check that matchExpectsEntry maps to the declared claim tuple.
|
|
3802
|
+
// Command-backed checks retain their executable evidence classification.
|
|
3145
3803
|
const checkId = expectationId || targetExpectation.id;
|
|
3146
3804
|
// Build a minimal "external" check. Include _gate_claim_expectation_id so that
|
|
3147
3805
|
// matchExpectsEntry can do an exact lookup for multi-expects[] gates (ADR 0016 P-d Increment 2).
|
|
3148
3806
|
// normalizeCheck preserves extra underscore-prefixed fields without stripping them.
|
|
3149
3807
|
const check = {
|
|
3150
3808
|
id: `gate-claim-${checkId}`,
|
|
3151
|
-
kind: "external",
|
|
3809
|
+
kind: gateCommands.length > 0 ? "command" : "external",
|
|
3152
3810
|
status: statusVal,
|
|
3153
3811
|
summary,
|
|
3154
3812
|
_gate_claim_expectation_id: targetExpectation.id,
|
|
3813
|
+
_gate_claim_identity_version: 2,
|
|
3814
|
+
_gate_claim_recorded_at: ts,
|
|
3155
3815
|
...(routeReason ? { _gate_claim_route_reason: routeReason } : {}),
|
|
3156
3816
|
};
|
|
3157
3817
|
// Include structured evidence refs if provided
|
|
3158
3818
|
const evidenceRefs = opts(p, "evidence-ref-json").map((v) => validateEvidenceRef(parseJson(v, "--evidence-ref-json"), "--evidence-ref-json"));
|
|
3819
|
+
const producer = expectedGateProducer(exactFlowContext?.flowId ?? activeStep.flowId, activeStep.stepId, targetExpectation.id);
|
|
3820
|
+
if (statusVal === "pass")
|
|
3821
|
+
validateReviewableGateEvidence(dir, slug, evidenceRefs, producer, `gate claim ${targetExpectation.id}`);
|
|
3822
|
+
if (mustRunTests)
|
|
3823
|
+
requireObservedCommandRefs(evidenceRefs, observedCommandNames, "a passing tests-evidence claim", true);
|
|
3159
3824
|
if (evidenceRefs.length > 0) {
|
|
3160
3825
|
check.artifact_refs = evidenceRefs;
|
|
3161
3826
|
}
|
|
3827
|
+
check._producer = producer.id;
|
|
3828
|
+
check._recorded_by = gateClaimActorKey;
|
|
3829
|
+
if (producer.selfProducedTrustSlices.length > 0)
|
|
3830
|
+
check._producer_self_produced_trust_slices = producer.selfProducedTrustSlices;
|
|
3162
3831
|
// #270(b)/#412: --command gives a gate claim a real, runnable execution.label distinct from
|
|
3163
3832
|
// the human --summary prose, so stop-goal-fit.js's bundleClaimedPassCommandChecks section (B)
|
|
3164
3833
|
// finds a real command to re-run instead of falling back to fieldOrBehavior (the --summary
|
|
3165
3834
|
// prose itself), which it would otherwise execute as a shell command under
|
|
3166
3835
|
// FLOW_AGENTS_GOAL_FIT_RECHECK. Validated at record time with the same isRunnableCommandText
|
|
3167
3836
|
// heuristic the Stop-hook backstop uses (single-sourced — see loadRunnableCommandHelper).
|
|
3168
|
-
|
|
3169
|
-
if (gateCommand) {
|
|
3170
|
-
const { isRunnableCommandText } = loadRunnableCommandHelper();
|
|
3171
|
-
if (!isRunnableCommandText(gateCommand))
|
|
3172
|
-
die(`record-gate-claim --command "${gateCommand}" is not a runnable shell command — prose belongs in --summary, which is never executed.`);
|
|
3837
|
+
if (gateCommand)
|
|
3173
3838
|
check.command = gateCommand;
|
|
3174
|
-
|
|
3839
|
+
if (observedCommands.length > 0)
|
|
3840
|
+
check._observed_commands = observedCommands;
|
|
3175
3841
|
const checkNormalized = normalizeCheck(check, /* allowGateClaimPrefix */ true);
|
|
3842
|
+
if (outputSha256)
|
|
3843
|
+
checkNormalized._output_sha256 = outputSha256;
|
|
3176
3844
|
// WS8 (ADR 0020): honor the accepted-gap waiver flags for a gate claim too.
|
|
3177
3845
|
const gateWaiver = parseWaiver(p, ts);
|
|
3178
3846
|
if (gateWaiver)
|
|
@@ -3185,8 +3853,17 @@ async function recordGateClaim(p) {
|
|
|
3185
3853
|
// SAME expectation id supersedes the earlier check for that expectation (mergeChecksById); a
|
|
3186
3854
|
// gate claim against a different expectation is additive.
|
|
3187
3855
|
const _existingState = readBundleState(dir);
|
|
3856
|
+
const criteria = mustRunTests ? completePassingCriteria(_existingState.criteria, opts(p, "criterion-json"), observedCommandNames, ts) : _existingState.criteria;
|
|
3857
|
+
if (mustRunTests) {
|
|
3858
|
+
const liveCritiques = _existingState.critiques.filter((critique) => !critique.superseded_by);
|
|
3859
|
+
if (liveCritiques.length === 0 || liveCritiques.some((critique) => !critiqueIsCleanAndCurrent(dir, critique))) {
|
|
3860
|
+
die("a passing tests-evidence claim requires a current clean critique first");
|
|
3861
|
+
}
|
|
3862
|
+
for (const criterion of criteria)
|
|
3863
|
+
validateReviewableGateEvidence(dir, slug, criterion.evidence_refs, producer, `criterion ${criterion.id}`);
|
|
3864
|
+
}
|
|
3188
3865
|
const _mergedChecks = mergeChecksById(_existingState.checks, [checkNormalized]);
|
|
3189
|
-
assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks,
|
|
3866
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, criteria, _existingState.critiques, gateClaimActorKey, exactFlowContext));
|
|
3190
3867
|
return 0;
|
|
3191
3868
|
}
|
|
3192
3869
|
/**
|
|
@@ -3373,12 +4050,47 @@ export function normalizeFinding(raw) {
|
|
|
3373
4050
|
async function recordCritique(p) {
|
|
3374
4051
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
3375
4052
|
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
const
|
|
3380
|
-
|
|
3381
|
-
|
|
4053
|
+
const verdict = opt(p, "verdict");
|
|
4054
|
+
if (!critiqueStatuses.has(verdict))
|
|
4055
|
+
die("record-critique requires --verdict pass, fail, or not_verified");
|
|
4056
|
+
const summary = opt(p, "summary");
|
|
4057
|
+
if (!hasNonEmptyString(summary))
|
|
4058
|
+
die("record-critique requires --summary");
|
|
4059
|
+
const lanes = normalizeCritiqueLanes(opts(p, "lane-json"));
|
|
4060
|
+
const reviewArtifacts = reviewTargetArtifacts(dir, opts(p, "artifact-ref"), "record-critique review_target");
|
|
4061
|
+
if (verdict === "pass" && (lanes.some((lane) => lane.status !== "pass") || reviewArtifacts.length === 0)) {
|
|
4062
|
+
die("a passing critique requires every lane to pass and at least one local reviewed --artifact-ref");
|
|
4063
|
+
}
|
|
4064
|
+
const sidecarState = loadJson(path.join(dir, "state.json"));
|
|
4065
|
+
const projectedRun = sidecarState.flow_run && typeof sidecarState.flow_run === "object" && !Array.isArray(sidecarState.flow_run)
|
|
4066
|
+
? sidecarState.flow_run
|
|
4067
|
+
: null;
|
|
4068
|
+
const exactFlowContext = projectedRun
|
|
4069
|
+
&& typeof projectedRun.definition_id === "string"
|
|
4070
|
+
&& typeof projectedRun.current_step === "string"
|
|
4071
|
+
? { flowId: projectedRun.definition_id, stepId: projectedRun.current_step }
|
|
4072
|
+
: undefined;
|
|
4073
|
+
// trust.bundle is the sole critique source. critique.json is unsupported historical residue.
|
|
4074
|
+
const _critiqueState = readBundleState(dir);
|
|
4075
|
+
const bundleCritiques = _critiqueState.critiques;
|
|
4076
|
+
const critiqueId = opt(p, "id", "review");
|
|
4077
|
+
if (!safeCritiqueId.test(critiqueId))
|
|
4078
|
+
die("record-critique --id must be a safe identifier");
|
|
4079
|
+
const critique = {
|
|
4080
|
+
id: critiqueId,
|
|
4081
|
+
reviewer: opt(p, "reviewer", "tool-code-reviewer"),
|
|
4082
|
+
reviewed_at: opt(p, "timestamp", new Date().toISOString()),
|
|
4083
|
+
identity_version: 2,
|
|
4084
|
+
verdict,
|
|
4085
|
+
summary,
|
|
4086
|
+
lanes,
|
|
4087
|
+
review_target: {
|
|
4088
|
+
artifacts: reviewArtifacts,
|
|
4089
|
+
workspace_snapshot: captureReviewWorkspaceSnapshot(canonicalProjectRootForSession(dir), reviewArtifacts.map((artifact) => ({ file: String(artifact.file), sha256: String(artifact.sha256) }))),
|
|
4090
|
+
},
|
|
4091
|
+
artifact_refs: reviewArtifacts.map((artifact) => artifact.file),
|
|
4092
|
+
findings: opts(p, "finding-json").map((v) => normalizeFinding(parseJson(v, "--finding-json"))),
|
|
4093
|
+
};
|
|
3382
4094
|
if (critique.verdict === "pass" && critique.findings.some((f) => f.status === "open"))
|
|
3383
4095
|
die("required critique must pass");
|
|
3384
4096
|
// #267/#282: supersede-by-critique-id. The latest write for a critique id wins for
|
|
@@ -3405,8 +4117,7 @@ async function recordCritique(p) {
|
|
|
3405
4117
|
// checksFromBundle + acceptance.json read inline — this is the exact pattern readBundleState
|
|
3406
4118
|
// already exists to share; recordLearning uses it directly (see below) and recordCritique
|
|
3407
4119
|
// previously duplicated it by hand for no reason.
|
|
3408
|
-
|
|
3409
|
-
assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueState.checks, _critiqueState.criteria, critiques));
|
|
4120
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueState.checks, _critiqueState.criteria, critiques, undefined, exactFlowContext));
|
|
3410
4121
|
return 0;
|
|
3411
4122
|
}
|
|
3412
4123
|
function frontmatter(text, key) {
|
|
@@ -3432,7 +4143,22 @@ async function importCritique(p) {
|
|
|
3432
4143
|
const title = m.groups?.title ?? "finding";
|
|
3433
4144
|
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] });
|
|
3434
4145
|
}
|
|
3435
|
-
const
|
|
4146
|
+
const importedArtifact = path.relative(canonicalProjectRootForSession(dir), review);
|
|
4147
|
+
const parsed = {
|
|
4148
|
+
...p,
|
|
4149
|
+
positional: [dir],
|
|
4150
|
+
opts: {
|
|
4151
|
+
...p.opts,
|
|
4152
|
+
id: [slugify(path.basename(review).replace(/\.md$/, ""), "review")],
|
|
4153
|
+
reviewer: ["tool-code-reviewer"],
|
|
4154
|
+
verdict: [verdict],
|
|
4155
|
+
summary: [`Imported critique from ${path.basename(review)}`],
|
|
4156
|
+
"artifact-ref": [importedArtifact],
|
|
4157
|
+
"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." }] })],
|
|
4158
|
+
"finding-json": findings.map((f) => JSON.stringify(f)),
|
|
4159
|
+
},
|
|
4160
|
+
flags: p.flags,
|
|
4161
|
+
};
|
|
3436
4162
|
const result = await recordCritique(parsed);
|
|
3437
4163
|
if (verdict !== "pass")
|
|
3438
4164
|
die("required critique must pass");
|
|
@@ -4782,10 +5508,7 @@ function evidenceClean(dir) {
|
|
|
4782
5508
|
});
|
|
4783
5509
|
}
|
|
4784
5510
|
function critiqueClean(dir) {
|
|
4785
|
-
//
|
|
4786
|
-
// legacy (pre-bundle-era) sessions — unrelated to origin stamping. When a trust.bundle IS
|
|
4787
|
-
// present, every claim must be stamped (requireStampedClaim); no claimType-derivation fallback
|
|
4788
|
-
// for an unstamped claim (#268/#344).
|
|
5511
|
+
// trust.bundle is the sole critique artifact. Legacy critique.json must not influence gates.
|
|
4789
5512
|
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
4790
5513
|
if (Array.isArray(bundle.claims)) {
|
|
4791
5514
|
for (const c of bundle.claims)
|
|
@@ -4800,14 +5523,11 @@ function critiqueClean(dir) {
|
|
|
4800
5523
|
});
|
|
4801
5524
|
if (critiqueClaims.length === 0)
|
|
4802
5525
|
return false; // no critique written yet
|
|
4803
|
-
return
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
});
|
|
5526
|
+
return critiquesFromBundle(dir)
|
|
5527
|
+
.filter((critique) => !critique.superseded_by)
|
|
5528
|
+
.every((critique) => critiqueIsCleanAndCurrent(dir, critique));
|
|
4807
5529
|
}
|
|
4808
|
-
|
|
4809
|
-
const c = loadJson(path.join(dir, "critique.json"), {});
|
|
4810
|
-
return c.status === "pass" && Array.isArray(c.critiques) && c.critiques.every((x) => x.verdict !== "fail" && (!Array.isArray(x.findings) || x.findings.every((f) => f.status !== "open" && (f.file_refs === undefined || Array.isArray(f.file_refs)))));
|
|
5530
|
+
return false;
|
|
4811
5531
|
}
|
|
4812
5532
|
function assertExistingLearningValid(dir) {
|
|
4813
5533
|
const file = path.join(dir, "learning.json");
|
|
@@ -4865,8 +5585,7 @@ async function dogfoodPass(p) {
|
|
|
4865
5585
|
die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
|
|
4866
5586
|
if (!opt(p, "critique-id") && !critiqueClean(dir))
|
|
4867
5587
|
die("requires passing critique");
|
|
4868
|
-
|
|
4869
|
-
if (!critiqueClean(dir) && (fs.existsSync(path.join(dir, "trust.bundle")) || fs.existsSync(path.join(dir, "critique.json"))))
|
|
5588
|
+
if (!critiqueClean(dir) && fs.existsSync(path.join(dir, "trust.bundle")))
|
|
4870
5589
|
die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
|
|
4871
5590
|
}
|
|
4872
5591
|
}
|
|
@@ -5124,7 +5843,7 @@ async function gateReview(p) {
|
|
|
5124
5843
|
// Locate trust.bundle — required per SKILL.md contract
|
|
5125
5844
|
const bundlePath = path.join(dir, "trust.bundle");
|
|
5126
5845
|
if (!fs.existsSync(bundlePath)) {
|
|
5127
|
-
process.stderr.write(`[gate-review] trust.bundle absent at ${bundlePath} — NOT_VERIFIED.
|
|
5846
|
+
process.stderr.write(`[gate-review] trust.bundle absent at ${bundlePath} — NOT_VERIFIED. Record the required trust bundle before running gate review.\n`);
|
|
5128
5847
|
return 1;
|
|
5129
5848
|
}
|
|
5130
5849
|
// Load Surface (ESM, fail-open)
|
|
@@ -5859,7 +6578,10 @@ Available claim ids:
|
|
|
5859
6578
|
return 0;
|
|
5860
6579
|
}
|
|
5861
6580
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
5862
|
-
export
|
|
6581
|
+
export function mainFromPublicWorkflow(argv) {
|
|
6582
|
+
return main(argv, PUBLIC_WORKFLOW_AUTHORITY);
|
|
6583
|
+
}
|
|
6584
|
+
export async function main(argv = process.argv.slice(2), authority) {
|
|
5863
6585
|
const _rawArgv = argv;
|
|
5864
6586
|
// #380: `record-check <dir> -- <command...>` — argv after the FIRST literal `--` token is the
|
|
5865
6587
|
// command to execute verbatim (never option-parsed: a command like `npm test -- --watch`
|
|
@@ -5901,7 +6623,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
5901
6623
|
: 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]) : "";
|
|
5902
6624
|
return withLock(lockRoot, ["ensure-session", "record-agent-event", "dogfood-pass"].includes(p.command), p.command, () => {
|
|
5903
6625
|
switch (p.command) {
|
|
5904
|
-
case "ensure-session": return ensureSession(p);
|
|
6626
|
+
case "ensure-session": return ensureSession(p, authority === PUBLIC_WORKFLOW_AUTHORITY);
|
|
5905
6627
|
case "current": return current(p);
|
|
5906
6628
|
case "record-agent-event": return recordAgentEvent(p);
|
|
5907
6629
|
case "init-plan": return initPlan(p);
|