@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, type ActiveFlowStep } from "../lib/flow-resolver.js";
|
|
10
|
+
import { resolveActiveFlowStep, resolveAllFlowGateExpects, resolveFlowFilePath, resolveFlowStep, resolvePhaseMap, resolveRouteBackPolicy, type ActiveFlowStep } from "../lib/flow-resolver.js";
|
|
11
11
|
import { defaultArtifactRootForRead, flowAgentsArtifactRoot } from "../lib/local-artifact-root.js";
|
|
12
12
|
import { ensureSafeDirectory } from "../lib/fs.js";
|
|
13
|
-
import { flowAgentsPackageVersion } from "../lib/package-version.js";
|
|
13
|
+
import { flowAgentsPackageRoot, flowAgentsPackageVersion } from "../lib/package-version.js";
|
|
14
14
|
import { pinnedFlowAgentsCommand } from "../lib/pinned-cli-command.js";
|
|
15
|
-
import {
|
|
15
|
+
import { runObservedCommand } from "../lib/observed-command.js";
|
|
16
|
+
import { captureReviewWorkspaceSnapshot, startBuilderFlowSession, syncBuilderFlowSession } from "../builder-flow-runtime.js";
|
|
16
17
|
// #291 Wave 1 Task 1.1 exports: ensure-session's ownership guard reuses the EXACT same
|
|
17
18
|
// assignment ⋈ liveness join / claim / supersede logic #290 already ships for the
|
|
18
19
|
// `assignment-provider` CLI, rather than reimplementing a second, parallel join (static ESM
|
|
@@ -27,9 +28,20 @@ export const checkKinds = new Set(["build", "types", "lint", "test", "command",
|
|
|
27
28
|
export const checkStatuses = new Set(["pass", "fail", "not_verified", "skip"]);
|
|
28
29
|
export const verdicts = new Set(["pass", "partial", "fail", "not_verified"]);
|
|
29
30
|
export const WORKFLOW_WRITER_CONTRACT_VERSION = "1.0";
|
|
31
|
+
const PUBLIC_WORKFLOW_AUTHORITY = Symbol("public-workflow-authority");
|
|
30
32
|
|
|
31
33
|
function now(): string { return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); }
|
|
32
34
|
function read(file: string): string { return fs.readFileSync(file, "utf8"); }
|
|
35
|
+
function readRegularFileNoFollow(file: string, label: string): Buffer {
|
|
36
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
37
|
+
try {
|
|
38
|
+
const stat = fs.fstatSync(fd);
|
|
39
|
+
if (!stat.isFile()) die(`${label} must be a regular file`);
|
|
40
|
+
return fs.readFileSync(fd);
|
|
41
|
+
} finally {
|
|
42
|
+
fs.closeSync(fd);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
33
45
|
export function writeJson(file: string, payload: AnyObj): void { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`); }
|
|
34
46
|
// Single-line but readable "key": "value" form. Built by collapsing the
|
|
35
47
|
// structural whitespace from an indented stringify — corruption-proof, unlike a
|
|
@@ -51,7 +63,13 @@ function slugify(value: string, fallback: string): string { return value.toLower
|
|
|
51
63
|
* Reuses slugify() for normalization. Validates that the id is a numeric GitHub issue number. */
|
|
52
64
|
function workItemSlug(ref: string): string {
|
|
53
65
|
const hashIdx = ref.indexOf("#");
|
|
54
|
-
if (hashIdx < 0
|
|
66
|
+
if (hashIdx < 0) {
|
|
67
|
+
if (!/^[a-z][a-z0-9-]*:[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(ref) || ref.includes("..")) {
|
|
68
|
+
die("--work-item must be a provider-neutral provider:id ref or owner/repo#numeric-id");
|
|
69
|
+
}
|
|
70
|
+
return slugify(ref, "work-item");
|
|
71
|
+
}
|
|
72
|
+
if (hashIdx === ref.length - 1) die("--work-item must be in owner/repo#numeric-id format");
|
|
55
73
|
const repoPath = ref.slice(0, hashIdx);
|
|
56
74
|
const id = ref.slice(hashIdx + 1);
|
|
57
75
|
if (!/^\d+$/.test(id)) die("--work-item id must be a numeric issue number");
|
|
@@ -63,8 +81,7 @@ function workItemSlug(ref: string): string {
|
|
|
63
81
|
|
|
64
82
|
function assignmentSubjectMatchesWorkItem(slug: string, ref: string): boolean {
|
|
65
83
|
if (ref === `local:${slug}`) return true;
|
|
66
|
-
|
|
67
|
-
return workItemSlug(ref) === slug;
|
|
84
|
+
try { return workItemSlug(ref) === slug; } catch { return false; }
|
|
68
85
|
}
|
|
69
86
|
|
|
70
87
|
type SessionWorkItem = {
|
|
@@ -632,10 +649,10 @@ export function reduceCaptureLogByCommand(commandLog: AnyObj[] | undefined): Map
|
|
|
632
649
|
* @param timestamp ISO-8601 timestamp for createdAt / updatedAt / observedAt
|
|
633
650
|
* @param checks Normalized check objects (from record-evidence --check-json / --surface-trust-json)
|
|
634
651
|
* @param criteria Acceptance criteria objects (from acceptance.json .criteria array)
|
|
635
|
-
* @param critiques Critique objects
|
|
652
|
+
* @param critiques Critique objects reconstructed from trust.bundle claims
|
|
636
653
|
* @param commandLog Optional parsed command-log.jsonl entries (capture-authoritative fold)
|
|
637
654
|
*/
|
|
638
|
-
export async function buildTrustBundle(slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], commandLog?: AnyObj[], flowAgentsDir?: string, actorKey?: string): Promise<AnyObj | null> {
|
|
655
|
+
export async function buildTrustBundle(slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], commandLog?: AnyObj[], flowAgentsDir?: string, actorKey?: string, exactFlowContext?: { flowId: string; stepId: string }): Promise<AnyObj | null> {
|
|
639
656
|
const surface = await tryLoadSurface();
|
|
640
657
|
if (!surface) return null;
|
|
641
658
|
const { deriveClaimStatus, generateClaimId, statusFunctionVersion } = surface;
|
|
@@ -648,7 +665,10 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
648
665
|
// #291 Wave 2 Task 2.1 (§7)/Task 2.2: actorKey (when the caller already resolved one — see
|
|
649
666
|
// writeTrustBundle below) threads through to resolveActiveFlowStep's per-actor-first,
|
|
650
667
|
// legacy-fallback current.json read; omitted, this is IDENTICAL to pre-#291 behavior.
|
|
651
|
-
const
|
|
668
|
+
const exactRepoRoot = flowAgentsDir ? findRepoRootFromDir(path.dirname(flowAgentsDir)) : null;
|
|
669
|
+
const activeStep: ActiveFlowStep | null = exactFlowContext && exactRepoRoot
|
|
670
|
+
? resolveFlowStep(exactFlowContext.flowId, exactFlowContext.stepId, exactRepoRoot)
|
|
671
|
+
: flowAgentsDir ? resolveActiveFlowStep(flowAgentsDir, actorKey) : null;
|
|
652
672
|
|
|
653
673
|
// #270 CRITICAL/HIGH fix: resolve the session's active_flow_id independent of whether the
|
|
654
674
|
// CURRENTLY-active step resolves — a stamped gate claim names the STEP IT WAS ORIGINALLY
|
|
@@ -658,6 +678,7 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
658
678
|
// the gate claim was recorded at — the validation must be against the FULL flow definition
|
|
659
679
|
// (every step's gate expects[], via resolveAllFlowGateExpects), not just the active one.
|
|
660
680
|
const sessionFlowId: string | null = (() => {
|
|
681
|
+
if (exactFlowContext) return exactFlowContext.flowId;
|
|
661
682
|
if (!flowAgentsDir) return null;
|
|
662
683
|
try {
|
|
663
684
|
const pointer = loadCurrentPointerHelper().readCurrentPointer(flowAgentsDir, actorKey);
|
|
@@ -894,7 +915,9 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
894
915
|
if (!check.id) continue;
|
|
895
916
|
const subjectId = `${slug}/${check.id}`;
|
|
896
917
|
const fieldOrBehavior = String(check.summary ?? check.id);
|
|
897
|
-
const
|
|
918
|
+
const gateClaimIdentityVersion = check._gate_claim_identity_version === 2 ? 2 : 1;
|
|
919
|
+
const gateClaimRecordedAt = gateClaimIdentityVersion === 2 && typeof check._gate_claim_recorded_at === "string" ? check._gate_claim_recorded_at : null;
|
|
920
|
+
const claimId = generateClaimId(subjectId, "flow-agents.workflow", gateClaimRecordedAt ? `${fieldOrBehavior}::gate-visit::${gateClaimRecordedAt}` : fieldOrBehavior);
|
|
898
921
|
const evId = `ev:${claimId}`;
|
|
899
922
|
const legacyClaimType = `workflow.check.${check.kind ?? "external"}`;
|
|
900
923
|
|
|
@@ -945,6 +968,11 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
945
968
|
const outputDigestMeta = typeof check._output_sha256 === "string" && check._output_sha256.length > 0
|
|
946
969
|
? { algorithm: "sha256", hex: check._output_sha256 }
|
|
947
970
|
: null;
|
|
971
|
+
const observedCommandsMeta = Array.isArray(check._observed_commands)
|
|
972
|
+
? check._observed_commands
|
|
973
|
+
.filter((entry: AnyObj) => typeof entry?.command === "string" && typeof entry?.exit_code === "number" && typeof entry?.output_sha256 === "string")
|
|
974
|
+
.map((entry: AnyObj) => ({ command: entry.command, exit_code: entry.exit_code, output_sha256: entry.output_sha256, ...(Number.isSafeInteger(entry.test_count) ? { test_count: entry.test_count } : {}), ...(entry.execution_proof && typeof entry.execution_proof === "object" ? { execution_proof: entry.execution_proof } : {}) }))
|
|
975
|
+
: null;
|
|
948
976
|
// #270(a)/(c): a gate claim's declared claimType/subjectType, once resolved (either freshly
|
|
949
977
|
// via matchExpectsEntry below, or restored from a prior write's metadata.gate_claim stamp by
|
|
950
978
|
// checksFromBundle), is stamped here so it is frozen at record time — a later bundle rebuild
|
|
@@ -974,11 +1002,15 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
974
1002
|
origin: "check",
|
|
975
1003
|
check_kind: String(check.kind ?? "external"),
|
|
976
1004
|
...(activeStep && workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}),
|
|
1005
|
+
...(typeof check._producer === "string" ? { expected_producer: check._producer } : {}),
|
|
1006
|
+
...(typeof check._recorded_by === "string" ? { recorded_by: check._recorded_by } : {}),
|
|
1007
|
+
...(Array.isArray(check._producer_self_produced_trust_slices) ? { self_produced_trust_slices: check._producer_self_produced_trust_slices } : {}),
|
|
977
1008
|
...(waiver ? { waiver } : {}),
|
|
978
1009
|
...(promotionMeta ? { promotion: promotionMeta } : {}),
|
|
979
1010
|
...(artifactRefsMeta ? { artifact_refs: artifactRefsMeta } : {}),
|
|
980
1011
|
...(standardRefsMeta ? { standard_refs: standardRefsMeta } : {}),
|
|
981
1012
|
...(outputDigestMeta ? { output_digest: outputDigestMeta } : {}),
|
|
1013
|
+
...(observedCommandsMeta && observedCommandsMeta.length > 0 ? { observed_commands: observedCommandsMeta } : {}),
|
|
982
1014
|
};
|
|
983
1015
|
|
|
984
1016
|
const claimEvents: AnyObj[] = [];
|
|
@@ -1041,7 +1073,7 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
1041
1073
|
// restored stamp) takes the currently-active step's id.
|
|
1042
1074
|
const declaredStepId = gateClaimDeclaredStepId ?? (activeStep ? activeStep.stepId : null);
|
|
1043
1075
|
const declaredMetadata: AnyObj = gateClaimExpectationId
|
|
1044
|
-
? { ...claimMetadata, gate_claim: { expectation_id: gateClaimExpectationId, claim_type: declared.claimType, subject_type: declared.subjectType, step_id: declaredStepId, ...(gateClaimRouteReason ? { route_reason: gateClaimRouteReason } : {}) } }
|
|
1076
|
+
? { ...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 } : {}) } }
|
|
1045
1077
|
: claimMetadata;
|
|
1046
1078
|
const declaredClaimObj: AnyObj = { 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 } : {}) };
|
|
1047
1079
|
const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [evItem] as Record<string, unknown>[], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
|
|
@@ -1059,7 +1091,9 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
1059
1091
|
if (!criterion.id) continue;
|
|
1060
1092
|
const subjectId = `${slug}/${criterion.id}`;
|
|
1061
1093
|
const fieldOrBehavior = String(criterion.description ?? criterion.id);
|
|
1062
|
-
const
|
|
1094
|
+
const criterionIdentityVersion = criterion.identity_version === 2 ? 2 : 1;
|
|
1095
|
+
const criterionVerifiedAt = criterionIdentityVersion === 2 && typeof criterion.verified_at === "string" ? criterion.verified_at : null;
|
|
1096
|
+
const claimId = generateClaimId(subjectId, "flow-agents.workflow", criterionVerifiedAt ? `${fieldOrBehavior}::verified::${criterionVerifiedAt}` : fieldOrBehavior);
|
|
1063
1097
|
const legacyClaimType = "workflow.acceptance.criterion";
|
|
1064
1098
|
const policy = ensurePolicy(legacyClaimType, "high", []);
|
|
1065
1099
|
const evStatus = criterionStatusToEventStatus(String(criterion.status ?? ""));
|
|
@@ -1075,12 +1109,12 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
1075
1109
|
if (declared) {
|
|
1076
1110
|
// Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
|
|
1077
1111
|
const declaredPolicy = ensurePolicy(declared.claimType, "high", []);
|
|
1078
|
-
const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, metadata: { origin: "acceptance", ...(workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}) } };
|
|
1112
|
+
const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, metadata: { origin: "acceptance", criterion: { id: criterion.id, description: criterion.description ?? criterion.id, status: criterion.status, evidence_refs: Array.isArray(criterion.evidence_refs) ? criterion.evidence_refs : [], ...(criterionIdentityVersion === 2 ? { identity_version: 2 } : {}), ...(criterionVerifiedAt ? { verified_at: criterionVerifiedAt } : {}) }, ...(workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}) } };
|
|
1079
1113
|
const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
|
|
1080
1114
|
claims.push({ ...declaredClaimObj, status: declaredStatus });
|
|
1081
1115
|
} else {
|
|
1082
1116
|
// No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
|
|
1083
|
-
const claimObj: AnyObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id, metadata: { origin: "acceptance" } };
|
|
1117
|
+
const claimObj: AnyObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id, metadata: { origin: "acceptance", criterion: { id: criterion.id, description: criterion.description ?? criterion.id, status: criterion.status, evidence_refs: Array.isArray(criterion.evidence_refs) ? criterion.evidence_refs : [] } } };
|
|
1084
1118
|
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
|
|
1085
1119
|
claims.push({ ...claimObj, status: derivedStatus });
|
|
1086
1120
|
}
|
|
@@ -1098,11 +1132,26 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
1098
1132
|
const supersededBy = typeof c.superseded_by === "string" && c.superseded_by.length > 0 ? c.superseded_by : null;
|
|
1099
1133
|
const critiqueReviewer = String(c.reviewer ?? "tool-code-reviewer");
|
|
1100
1134
|
const critiqueReviewedAt = String(c.reviewed_at ?? ts);
|
|
1101
|
-
const
|
|
1135
|
+
const critiqueIdentityVersion = c.identity_version === 2 ? 2 : 1;
|
|
1136
|
+
const critMeta: AnyObj = {
|
|
1137
|
+
origin: "critique",
|
|
1138
|
+
reviewer: critiqueReviewer,
|
|
1139
|
+
reviewed_at: critiqueReviewedAt,
|
|
1140
|
+
...(critiqueIdentityVersion === 2 ? { identity_version: 2 } : {}),
|
|
1141
|
+
findings: Array.isArray(c.findings) ? c.findings : [],
|
|
1142
|
+
lanes: Array.isArray(c.lanes) ? c.lanes : [],
|
|
1143
|
+
review_target: c.review_target && typeof c.review_target === "object" ? c.review_target : { artifacts: [] },
|
|
1144
|
+
// Keep the legacy field for consumers that still render a flat artifact list.
|
|
1145
|
+
artifact_refs: Array.isArray(c.artifact_refs) ? c.artifact_refs : [],
|
|
1146
|
+
...(activeStep && workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}),
|
|
1147
|
+
...(supersededBy ? { superseded_by: supersededBy } : {}),
|
|
1148
|
+
};
|
|
1102
1149
|
// A superseded historical write gets a distinct, stable claimId so it co-exists with the live
|
|
1103
1150
|
// claim of the same critique id (never overwrites or duplicates it). The salt is reproducible
|
|
1104
1151
|
// across rebuilds because superseded_by + reviewed_at are preserved in metadata.
|
|
1105
|
-
const claimIdSalt =
|
|
1152
|
+
const claimIdSalt = critiqueIdentityVersion === 2
|
|
1153
|
+
? `${fieldOrBehavior}::reviewed::${critiqueReviewedAt}${supersededBy ? `::superseded::${supersededBy}` : ""}`
|
|
1154
|
+
: (supersededBy ? `${fieldOrBehavior}::superseded::${supersededBy}::${critiqueReviewedAt}` : fieldOrBehavior);
|
|
1106
1155
|
const claimId = generateClaimId(subjectId, "flow-agents.workflow", claimIdSalt);
|
|
1107
1156
|
const legacyClaimType = "workflow.critique.review";
|
|
1108
1157
|
const policy = ensurePolicy(legacyClaimType, "medium", []);
|
|
@@ -1115,17 +1164,14 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
1115
1164
|
claimEvents.push(evt);
|
|
1116
1165
|
}
|
|
1117
1166
|
|
|
1118
|
-
//
|
|
1119
|
-
|
|
1120
|
-
const
|
|
1121
|
-
const subjectType = declared ? declared.subjectType : "workflow-critique";
|
|
1122
|
-
const claimPolicy = declared ? ensurePolicy(declared.claimType, "medium", []) : policy;
|
|
1123
|
-
const claimObj: AnyObj = { id: claimId, subjectType, subjectId, facet: "flow-agents.workflow", claimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: claimPolicy.id, metadata: critMeta };
|
|
1167
|
+
// Critique is intentionally report-only. It must never inherit a Flow gate expectation,
|
|
1168
|
+
// even while a run is positioned at a gate-bearing step.
|
|
1169
|
+
const claimObj: AnyObj = { id: claimId, subjectType: "workflow-critique", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: policy.id, metadata: critMeta };
|
|
1124
1170
|
if (supersededBy) {
|
|
1125
1171
|
// History: status is "superseded" directly (no verification event); excluded from evaluation.
|
|
1126
1172
|
claims.push({ ...claimObj, status: "superseded" });
|
|
1127
1173
|
} else {
|
|
1128
|
-
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [
|
|
1174
|
+
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
|
|
1129
1175
|
claims.push({ ...claimObj, status: derivedStatus });
|
|
1130
1176
|
}
|
|
1131
1177
|
}
|
|
@@ -1153,7 +1199,7 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
1153
1199
|
* @param criteria Acceptance criteria objects (same as buildTrustBundle)
|
|
1154
1200
|
* @param critiques Critique objects (same as buildTrustBundle)
|
|
1155
1201
|
*/
|
|
1156
|
-
export async function writeTrustBundle(dir: string, slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], actorKey?: string): Promise<{ written: boolean; errors: string[] }> {
|
|
1202
|
+
export async function writeTrustBundle(dir: string, slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], actorKey?: string, exactFlowContext?: { flowId: string; stepId: string }): Promise<{ written: boolean; errors: string[] }> {
|
|
1157
1203
|
try {
|
|
1158
1204
|
// Fold the deterministic capture log (PostToolUse evidence-capture) into the
|
|
1159
1205
|
// bundle so capture is authoritative over claimed status. Best-effort read.
|
|
@@ -1173,14 +1219,20 @@ export async function writeTrustBundle(dir: string, slug: string, timestamp: str
|
|
|
1173
1219
|
const _flowAgentsDir = path.dirname(dir);
|
|
1174
1220
|
const _effectiveActorKey = actorKey ?? loadActorIdentityHelper().resolveActor(process.env).actor;
|
|
1175
1221
|
let _scopedFlowAgentsDir: string | undefined = undefined;
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1222
|
+
if (exactFlowContext) {
|
|
1223
|
+
// The context was read from this session's persisted flow_run. Navigation pointers may
|
|
1224
|
+
// lag or point at another run and must not override an explicit session mutation.
|
|
1225
|
+
_scopedFlowAgentsDir = _flowAgentsDir;
|
|
1226
|
+
} else {
|
|
1227
|
+
try {
|
|
1228
|
+
const _currentRaw = loadCurrentPointerHelper().readCurrentPointer(_flowAgentsDir, _effectiveActorKey).payload;
|
|
1229
|
+
const _artDir = _currentRaw && typeof _currentRaw["artifact_dir"] === "string" ? (_currentRaw["artifact_dir"] as string) : null;
|
|
1230
|
+
if (_artDir && path.resolve(_flowAgentsDir, _artDir) === path.resolve(dir)) {
|
|
1231
|
+
_scopedFlowAgentsDir = _flowAgentsDir;
|
|
1232
|
+
}
|
|
1233
|
+
} catch { /* current.json absent or unreadable — no scoping */ }
|
|
1234
|
+
}
|
|
1235
|
+
const bundle = await buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, _scopedFlowAgentsDir, _effectiveActorKey, exactFlowContext);
|
|
1184
1236
|
if (!bundle) return { written: false, errors: [] }; // Surface unavailable — fail-open, skip write
|
|
1185
1237
|
const result = await validateTrustBundle(bundle);
|
|
1186
1238
|
if (result.available && !result.valid) {
|
|
@@ -1188,7 +1240,6 @@ export async function writeTrustBundle(dir: string, slug: string, timestamp: str
|
|
|
1188
1240
|
return { written: false, errors: result.errors };
|
|
1189
1241
|
}
|
|
1190
1242
|
writeJson(path.join(dir, "trust.bundle"), bundle);
|
|
1191
|
-
await syncBuilderFlowSessionIfPresent(dir);
|
|
1192
1243
|
return { written: true, errors: [] };
|
|
1193
1244
|
} catch (err) {
|
|
1194
1245
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -1766,6 +1817,9 @@ function initSidecars(
|
|
|
1766
1817
|
...sidecarBase(slug), status: initialLifecycle?.status ?? "planned", phase: initialLifecycle?.phase ?? "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
|
|
1767
1818
|
...(branch ? { branch } : {}),
|
|
1768
1819
|
...(retainedWorkItemRefs.length > 0 ? { work_item_refs: retainedWorkItemRefs } : {}),
|
|
1820
|
+
...(existingState.flow_run && typeof existingState.flow_run === "object" && !Array.isArray(existingState.flow_run)
|
|
1821
|
+
? { flow_run: existingState.flow_run }
|
|
1822
|
+
: {}),
|
|
1769
1823
|
artifact_paths: relArtifacts(dir),
|
|
1770
1824
|
next_action: nextActionPayload,
|
|
1771
1825
|
});
|
|
@@ -1819,7 +1873,7 @@ function enforceEnsureSessionOwnership(
|
|
|
1819
1873
|
dir: string,
|
|
1820
1874
|
resolution: { actorStruct: ActorStruct; actorKey: string; branchActorKey: string; unresolved: boolean },
|
|
1821
1875
|
workItemRef?: string,
|
|
1822
|
-
): { assignmentFile: string; actorKey: string } | null {
|
|
1876
|
+
): { assignmentFile: string; actorKey: string; providerEvidenceFile?: string; providerEvidenceBytes?: Buffer } | null {
|
|
1823
1877
|
if (p.flags.has("skip-ownership-guard")) {
|
|
1824
1878
|
process.stderr.write("[ensure-session] ownership guard skipped via --skip-ownership-guard\n");
|
|
1825
1879
|
return null;
|
|
@@ -1854,15 +1908,41 @@ function enforceEnsureSessionOwnership(
|
|
|
1854
1908
|
|
|
1855
1909
|
type EffectiveResult = { effective_state: EffectiveState; reason: string; holder?: { actor?: string; assignee?: string | null; idle_days?: number | null; last_at?: string } };
|
|
1856
1910
|
let effective: EffectiveResult;
|
|
1911
|
+
let providerEvidenceBytes: Buffer | undefined;
|
|
1857
1912
|
|
|
1858
1913
|
const effectiveStateJsonFlag = opt(p, "effective-state-json");
|
|
1859
1914
|
if (effectiveStateJsonFlag) {
|
|
1860
|
-
|
|
1915
|
+
providerEvidenceBytes = effectiveStateJsonFlag === "-"
|
|
1916
|
+
? fs.readFileSync(0)
|
|
1917
|
+
: readRegularFileNoFollow(path.resolve(effectiveStateJsonFlag), "ensure-session --effective-state-json");
|
|
1918
|
+
const parsed = JSON.parse(providerEvidenceBytes.toString("utf8")) as AnyObj;
|
|
1861
1919
|
const candidate = parsed && typeof parsed === "object" ? (parsed.effective as AnyObj | undefined) : undefined;
|
|
1920
|
+
const assignment = parsed && typeof parsed === "object" ? (parsed.assignment as AnyObj | undefined) : undefined;
|
|
1921
|
+
const record = assignment && typeof assignment.record === "object" && assignment.record !== null ? assignment.record as AnyObj : undefined;
|
|
1862
1922
|
const validStates = new Set(["held", "reclaimable", "human-held", "free"]);
|
|
1863
1923
|
if (!candidate || typeof candidate.effective_state !== "string" || !validStates.has(candidate.effective_state)) {
|
|
1864
1924
|
die(`ensure-session --effective-state-json must contain an .effective object with a recognized effective_state (held|reclaimable|human-held|free); got ${JSON.stringify(candidate ? candidate.effective_state : candidate)}`);
|
|
1865
1925
|
}
|
|
1926
|
+
if (workItemRef && (parsed.role !== "AssignmentStatus"
|
|
1927
|
+
|| parsed.provider !== assignmentProviderKind
|
|
1928
|
+
|| assignment?.provider !== assignmentProviderKind
|
|
1929
|
+
|| assignment?.subject_id !== slug
|
|
1930
|
+
|| record?.role !== "AssignmentClaimRecord"
|
|
1931
|
+
|| record?.status !== "claimed"
|
|
1932
|
+
|| record?.subject_id !== slug
|
|
1933
|
+
|| record?.work_item_ref !== workItemRef
|
|
1934
|
+
|| record?.actor_key !== resolution.branchActorKey
|
|
1935
|
+
|| assignment?.assignee !== resolution.branchActorKey
|
|
1936
|
+
|| !record.actor || typeof record.actor !== "object"
|
|
1937
|
+
|| record.actor.runtime !== resolution.actorStruct.runtime
|
|
1938
|
+
|| record.actor.session_id !== resolution.actorStruct.session_id
|
|
1939
|
+
|| record.actor.host !== resolution.actorStruct.host
|
|
1940
|
+
|| (record.actor.human ?? null) !== (resolution.actorStruct.human ?? null)
|
|
1941
|
+
|| candidate.effective_state !== "held"
|
|
1942
|
+
|| candidate.reason !== "self_is_holder"
|
|
1943
|
+
|| candidate.holder?.actor !== resolution.branchActorKey)) {
|
|
1944
|
+
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");
|
|
1945
|
+
}
|
|
1866
1946
|
effective = candidate as EffectiveResult;
|
|
1867
1947
|
} else if (assignmentProviderKind === "local-file") {
|
|
1868
1948
|
// Conflict #3 (plan): subjectId for BOTH the assignment lookup and the liveness freshHolders
|
|
@@ -1898,10 +1978,8 @@ function enforceEnsureSessionOwnership(
|
|
|
1898
1978
|
return null;
|
|
1899
1979
|
}
|
|
1900
1980
|
|
|
1901
|
-
const selectedWorkEvidence = (): { assignmentFile: string; actorKey: string } | null => {
|
|
1902
|
-
|
|
1903
|
-
// can authorize entry, but it cannot prove that this process acquired the Work Item.
|
|
1904
|
-
if (!workItemRef || effectiveStateJsonFlag || assignmentProviderKind !== "local-file") return null;
|
|
1981
|
+
const selectedWorkEvidence = (): { assignmentFile: string; actorKey: string; providerEvidenceFile?: string; providerEvidenceBytes?: Buffer } | null => {
|
|
1982
|
+
if (!workItemRef) return null;
|
|
1905
1983
|
const assignment = readLocalAssignmentStatus(root, slug);
|
|
1906
1984
|
const record = assignment.record;
|
|
1907
1985
|
if (!record
|
|
@@ -1914,6 +1992,8 @@ function enforceEnsureSessionOwnership(
|
|
|
1914
1992
|
return {
|
|
1915
1993
|
assignmentFile: assignmentFilePath(root, slug),
|
|
1916
1994
|
actorKey: resolution.branchActorKey,
|
|
1995
|
+
...(effectiveStateJsonFlag ? { providerEvidenceFile: path.resolve(effectiveStateJsonFlag) } : {}),
|
|
1996
|
+
...(providerEvidenceBytes ? { providerEvidenceBytes } : {}),
|
|
1917
1997
|
};
|
|
1918
1998
|
};
|
|
1919
1999
|
|
|
@@ -1929,7 +2009,22 @@ function enforceEnsureSessionOwnership(
|
|
|
1929
2009
|
// ActorStruct triple), so this redundant belt-and-suspenders check agrees with the
|
|
1930
2010
|
// effective_state computation instead of silently using a different identity.
|
|
1931
2011
|
const isSelf = effective.reason === "self_is_holder" || (!!effective.holder?.actor && effective.holder.actor === resolution.branchActorKey);
|
|
1932
|
-
if (isSelf)
|
|
2012
|
+
if (isSelf) {
|
|
2013
|
+
if (assignmentProviderKind !== "local-file") {
|
|
2014
|
+
const local = readLocalAssignmentStatus(root, slug).record;
|
|
2015
|
+
if (!local || local.status !== "claimed") {
|
|
2016
|
+
performLocalClaim(root, slug, resolution.actorStruct, {
|
|
2017
|
+
ttlSeconds: opt(p, "claim-ttl-seconds") ? Number(opt(p, "claim-ttl-seconds")) : loadLivenessPolicyHelper().resolveTtlSeconds(process.env),
|
|
2018
|
+
branch: resolveBranchForClaim(),
|
|
2019
|
+
artifactDir: path.relative(root, dir) || ".",
|
|
2020
|
+
reason: `provider-confirmed ${assignmentProviderKind} ownership mirror`,
|
|
2021
|
+
actorKey: resolution.branchActorKey,
|
|
2022
|
+
...(workItemRef ? { workItemRef } : {}),
|
|
2023
|
+
});
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
return selectedWorkEvidence();
|
|
2027
|
+
}
|
|
1933
2028
|
const holderActor = sanitize(effective.holder?.actor ?? "unknown");
|
|
1934
2029
|
const lastAtSuffix = effective.holder?.last_at ? ` (last_at ${sanitize(effective.holder.last_at)})` : "";
|
|
1935
2030
|
die(`ensure-session refused: subject ${sanitize(slug)} is currently held by a different, still-live actor (${holderActor}${lastAtSuffix}). Pick a different work item, or confirm the holder session is truly gone before considering a takeover.`);
|
|
@@ -1980,6 +2075,9 @@ function enforceEnsureSessionOwnership(
|
|
|
1980
2075
|
return selectedWorkEvidence();
|
|
1981
2076
|
}
|
|
1982
2077
|
case "free": {
|
|
2078
|
+
if (assignmentProviderKind !== "local-file") {
|
|
2079
|
+
die(`ensure-session refused: provider ${JSON.stringify(assignmentProviderKind)} has not confirmed this actor as the Work Item holder`);
|
|
2080
|
+
}
|
|
1983
2081
|
performLocalClaim(root, slug, resolution.actorStruct, {
|
|
1984
2082
|
ttlSeconds: opt(p, "claim-ttl-seconds") ? Number(opt(p, "claim-ttl-seconds")) : loadLivenessPolicyHelper().resolveTtlSeconds(process.env),
|
|
1985
2083
|
branch: resolveBranchForClaim(),
|
|
@@ -2032,11 +2130,11 @@ function resolveEnsureSessionEntry(p: ReturnType<typeof parseArgs>, dir: string)
|
|
|
2032
2130
|
return { flowId, stepId: explicitStep || firstStep, firstStep };
|
|
2033
2131
|
}
|
|
2034
2132
|
|
|
2035
|
-
function assertCanonicalBuilderArtifactRoot(root: string): void {
|
|
2133
|
+
function assertCanonicalBuilderArtifactRoot(root: string, flowId: "builder.build" | "builder.shape"): void {
|
|
2036
2134
|
const kontouraiRoot = path.dirname(root);
|
|
2037
2135
|
const projectRoot = path.dirname(kontouraiRoot);
|
|
2038
2136
|
if (path.basename(root) !== "flow-agents" || path.basename(kontouraiRoot) !== ".kontourai") {
|
|
2039
|
-
die(
|
|
2137
|
+
die(`ensure-session --flow-id ${flowId} requires --artifact-root <project>/.kontourai/flow-agents`);
|
|
2040
2138
|
}
|
|
2041
2139
|
|
|
2042
2140
|
const statIfPresent = (candidate: string): fs.Stats | null => {
|
|
@@ -2049,7 +2147,7 @@ function assertCanonicalBuilderArtifactRoot(root: string): void {
|
|
|
2049
2147
|
};
|
|
2050
2148
|
const projectStat = statIfPresent(projectRoot);
|
|
2051
2149
|
if (projectStat && (projectStat.isSymbolicLink() || !projectStat.isDirectory())) {
|
|
2052
|
-
die(`ensure-session --flow-id
|
|
2150
|
+
die(`ensure-session --flow-id ${flowId} requires a non-symlink project root: ${projectRoot}`);
|
|
2053
2151
|
}
|
|
2054
2152
|
const realProjectRoot = projectStat ? fs.realpathSync(projectRoot) : projectRoot;
|
|
2055
2153
|
for (const [candidate, expected, label] of [
|
|
@@ -2059,7 +2157,7 @@ function assertCanonicalBuilderArtifactRoot(root: string): void {
|
|
|
2059
2157
|
const stat = statIfPresent(candidate);
|
|
2060
2158
|
if (!stat) continue;
|
|
2061
2159
|
if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(candidate) !== expected) {
|
|
2062
|
-
die(`ensure-session --flow-id
|
|
2160
|
+
die(`ensure-session --flow-id ${flowId} requires a non-symlink ${label}: ${candidate}`);
|
|
2063
2161
|
}
|
|
2064
2162
|
}
|
|
2065
2163
|
}
|
|
@@ -2070,17 +2168,17 @@ function preflightEnsureSession(p: ReturnType<typeof parseArgs>): void {
|
|
|
2070
2168
|
const dir = sessionDirFor(root, slug);
|
|
2071
2169
|
assertSafeSessionDirectory(root, dir);
|
|
2072
2170
|
const entry = resolveEnsureSessionEntry(p, dir);
|
|
2073
|
-
if (entry.flowId === "builder.build") assertCanonicalBuilderArtifactRoot(root);
|
|
2171
|
+
if (entry.flowId === "builder.build" || entry.flowId === "builder.shape") assertCanonicalBuilderArtifactRoot(root, entry.flowId);
|
|
2074
2172
|
sessionWorkItem(p, slug, dir);
|
|
2075
2173
|
}
|
|
2076
2174
|
|
|
2077
|
-
async function ensureSession(p: ReturnType<typeof parseArgs
|
|
2175
|
+
async function ensureSession(p: ReturnType<typeof parseArgs>, allowCanonicalFlowMutation: boolean): Promise<number> {
|
|
2078
2176
|
const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
|
|
2079
2177
|
const slug = opt(p, "task-slug") || (opt(p, "work-item") ? workItemSlug(opt(p, "work-item")) : die("--task-slug is required (or pass --work-item to derive it)"));
|
|
2080
2178
|
const dir = sessionDirFor(root, slug);
|
|
2081
2179
|
assertSafeSessionDirectory(root, dir);
|
|
2082
2180
|
const entry = resolveEnsureSessionEntry(p, dir);
|
|
2083
|
-
if (entry.flowId === "builder.build") assertCanonicalBuilderArtifactRoot(root);
|
|
2181
|
+
if (entry.flowId === "builder.build" || entry.flowId === "builder.shape") assertCanonicalBuilderArtifactRoot(root, entry.flowId);
|
|
2084
2182
|
const workItem = sessionWorkItem(p, slug, dir);
|
|
2085
2183
|
// #291 Wave 2 Task 2.1 (§3, §4): resolve the actor ONCE, then run the ownership guard BEFORE
|
|
2086
2184
|
// any directory/file is created — a refusal must never leave a stray empty session dir. Reused
|
|
@@ -2090,8 +2188,18 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
2090
2188
|
const selectionWorkItemRef = entry.flowId === "builder.build" && assignmentSubjectMatchesWorkItem(slug, workItem.ref)
|
|
2091
2189
|
? workItem.ref
|
|
2092
2190
|
: undefined;
|
|
2093
|
-
|
|
2191
|
+
let selectedWorkEvidence = enforceEnsureSessionOwnership(p, root, slug, dir, actorResolution, selectionWorkItemRef);
|
|
2094
2192
|
ensureSafeDirectory(root, dir);
|
|
2193
|
+
if (selectedWorkEvidence?.providerEvidenceBytes) {
|
|
2194
|
+
const staged = path.join(dir, "assignment-provider-state.json");
|
|
2195
|
+
if (fs.existsSync(staged)) {
|
|
2196
|
+
if (!readRegularFileNoFollow(staged, "ensure-session provider state evidence destination").equals(selectedWorkEvidence.providerEvidenceBytes)) die("ensure-session provider state evidence conflicts with the existing immutable snapshot");
|
|
2197
|
+
} else {
|
|
2198
|
+
fs.writeFileSync(staged, selectedWorkEvidence.providerEvidenceBytes, { flag: "wx", mode: 0o600 });
|
|
2199
|
+
}
|
|
2200
|
+
fs.chmodSync(staged, 0o400);
|
|
2201
|
+
selectedWorkEvidence = { ...selectedWorkEvidence, providerEvidenceFile: staged };
|
|
2202
|
+
}
|
|
2095
2203
|
const timestamp = opt(p, "timestamp", now());
|
|
2096
2204
|
if (workItem.localRecord && !fs.existsSync(path.join(dir, "work-item.json"))) {
|
|
2097
2205
|
writeJson(path.join(dir, "work-item.json"), workItem.localRecord);
|
|
@@ -2114,9 +2222,11 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
2114
2222
|
const initialPhase = Object.entries(phaseMap ?? {}).find(([, step]) => step === entry.firstStep)?.[0];
|
|
2115
2223
|
const startCommand = pinnedFlowAgentsCommand(flowAgentsPackageVersion(), [
|
|
2116
2224
|
"workflow", "start",
|
|
2117
|
-
"--flow",
|
|
2118
|
-
"--work-item", workItem.ref,
|
|
2225
|
+
"--flow", entry.flowId,
|
|
2226
|
+
...(entry.flowId === "builder.shape" ? [] : ["--work-item", workItem.ref]),
|
|
2119
2227
|
...(workItem.ref === `local:${slug}` ? ["--task-slug", slug] : []),
|
|
2228
|
+
"--assignment-provider", opt(p, "assignment-provider", "local-file"),
|
|
2229
|
+
...(selectedWorkEvidence?.providerEvidenceFile ? ["--effective-state-json", selectedWorkEvidence.providerEvidenceFile] : []),
|
|
2120
2230
|
"--artifact-root", root,
|
|
2121
2231
|
"--title", opt(p, "title", slug),
|
|
2122
2232
|
"--summary", opt(p, "summary", "Workflow session is durable."),
|
|
@@ -2159,9 +2269,9 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
2159
2269
|
? persistedCurrent.active_step_id
|
|
2160
2270
|
: entry.stepId;
|
|
2161
2271
|
writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", entry.flowId || undefined, resumedStep || undefined, actorResolution.unresolved ? undefined : actorResolution.branchActorKey);
|
|
2162
|
-
if (entry.flowId === "builder.build") {
|
|
2272
|
+
if (allowCanonicalFlowMutation && (entry.flowId === "builder.build" || entry.flowId === "builder.shape")) {
|
|
2163
2273
|
try {
|
|
2164
|
-
const started = await startBuilderFlowSession({ sessionDir: dir });
|
|
2274
|
+
const started = await startBuilderFlowSession({ sessionDir: dir, flowId: entry.flowId });
|
|
2165
2275
|
if (started.run.state.current_step === "pull-work"
|
|
2166
2276
|
&& selectedWorkEvidence
|
|
2167
2277
|
&& assignmentSubjectMatchesWorkItem(slug, workItem.ref)) {
|
|
@@ -2176,8 +2286,24 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
2176
2286
|
}
|
|
2177
2287
|
const assignmentContent = fs.readFileSync(selectedWorkEvidence.assignmentFile);
|
|
2178
2288
|
const assignmentDigest = createHash("sha256").update(assignmentContent).digest("hex");
|
|
2289
|
+
const providerEvidenceBytes = selectedWorkEvidence.providerEvidenceFile
|
|
2290
|
+
? readRegularFileNoFollow(selectedWorkEvidence.providerEvidenceFile, "selected-work provider evidence")
|
|
2291
|
+
: null;
|
|
2292
|
+
if (providerEvidenceBytes && selectedWorkEvidence.providerEvidenceBytes && !providerEvidenceBytes.equals(selectedWorkEvidence.providerEvidenceBytes)) {
|
|
2293
|
+
die("ensure-session refused to emit selected-work evidence: provider evidence changed after ownership validation");
|
|
2294
|
+
}
|
|
2295
|
+
const providerEvidenceDigest = providerEvidenceBytes ? createHash("sha256").update(providerEvidenceBytes).digest("hex") : null;
|
|
2179
2296
|
const projectRoot = path.dirname(path.dirname(root));
|
|
2180
2297
|
const evidenceFile = path.relative(projectRoot, selectedWorkEvidence.assignmentFile);
|
|
2298
|
+
const pullWorkReport = path.join(dir, `${slug}--pull-work.md`);
|
|
2299
|
+
let reportIsValid = false;
|
|
2300
|
+
try {
|
|
2301
|
+
const reportStat = fs.lstatSync(pullWorkReport);
|
|
2302
|
+
reportIsValid = !reportStat.isSymbolicLink() && reportStat.isFile() && fs.readFileSync(pullWorkReport, "utf8").includes(workItem.ref);
|
|
2303
|
+
} catch { /* handled by the stable contract error below */ }
|
|
2304
|
+
if (!reportIsValid) {
|
|
2305
|
+
die(`ensure-session refused to emit selected-work evidence: expected concrete pull-work selection report ${pullWorkReport} naming ${JSON.stringify(workItem.ref)}`);
|
|
2306
|
+
}
|
|
2181
2307
|
await recordGateClaim(parseArgs([
|
|
2182
2308
|
"record-gate-claim",
|
|
2183
2309
|
dir,
|
|
@@ -2190,8 +2316,25 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
2190
2316
|
file: evidenceFile,
|
|
2191
2317
|
summary: `Durable local assignment record for the selected Work Item and active actor; sha256:${assignmentDigest}.`,
|
|
2192
2318
|
}),
|
|
2319
|
+
...(selectedWorkEvidence.providerEvidenceFile ? [
|
|
2320
|
+
"--evidence-ref-json", JSON.stringify({
|
|
2321
|
+
kind: "artifact",
|
|
2322
|
+
file: path.relative(projectRoot, selectedWorkEvidence.providerEvidenceFile),
|
|
2323
|
+
summary: `Provider assignment state confirming ownership before the local runtime lease was mirrored; sha256:${providerEvidenceDigest}.`,
|
|
2324
|
+
}),
|
|
2325
|
+
] : []),
|
|
2326
|
+
"--evidence-ref-json", JSON.stringify({
|
|
2327
|
+
kind: "artifact",
|
|
2328
|
+
file: path.relative(projectRoot, pullWorkReport),
|
|
2329
|
+
summary: `Concrete pull-work selection report for ${workItem.ref}.`,
|
|
2330
|
+
}),
|
|
2193
2331
|
"--timestamp", timestamp,
|
|
2194
2332
|
]));
|
|
2333
|
+
if (selectedWorkEvidence.providerEvidenceFile && selectedWorkEvidence.providerEvidenceBytes
|
|
2334
|
+
&& !readRegularFileNoFollow(selectedWorkEvidence.providerEvidenceFile, "selected-work provider evidence").equals(selectedWorkEvidence.providerEvidenceBytes)) {
|
|
2335
|
+
die("ensure-session refused to synchronize selected-work evidence: provider evidence changed during claim recording");
|
|
2336
|
+
}
|
|
2337
|
+
await syncBuilderFlowSession({ sessionDir: dir });
|
|
2195
2338
|
});
|
|
2196
2339
|
}
|
|
2197
2340
|
} catch (error) {
|
|
@@ -2303,6 +2446,373 @@ export function normalizeEvidenceRefs(raw: unknown, label: string): AnyObj[] {
|
|
|
2303
2446
|
return validateEvidenceRef({ ...ref as AnyObj }, label);
|
|
2304
2447
|
});
|
|
2305
2448
|
}
|
|
2449
|
+
|
|
2450
|
+
function canonicalProjectRootForSession(dir: string): string {
|
|
2451
|
+
const artifactRoot = path.dirname(dir);
|
|
2452
|
+
const kontouraiRoot = path.dirname(artifactRoot);
|
|
2453
|
+
if (path.basename(artifactRoot) !== "flow-agents" || path.basename(kontouraiRoot) !== ".kontourai") die("gate evidence requires a canonical .kontourai/flow-agents session");
|
|
2454
|
+
const projectRoot = path.dirname(kontouraiRoot);
|
|
2455
|
+
const stat = fs.lstatSync(projectRoot);
|
|
2456
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) die("gate evidence project root must be a non-symlink directory");
|
|
2457
|
+
return projectRoot;
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
function pathIsWithinRoot(candidate: string, root: string): boolean {
|
|
2461
|
+
const relative = path.relative(root, candidate);
|
|
2462
|
+
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
function validateLocalEvidenceFile(projectRoot: string, raw: string, label: string): string {
|
|
2466
|
+
const candidate = path.resolve(projectRoot, raw);
|
|
2467
|
+
if (!pathIsWithinRoot(candidate, projectRoot)) die(`${label} must remain inside the canonical project root`);
|
|
2468
|
+
let stat: fs.Stats;
|
|
2469
|
+
try { stat = fs.lstatSync(candidate); } catch { die(`${label} does not exist`); }
|
|
2470
|
+
if (stat.isSymbolicLink() || !stat.isFile()) die(`${label} must be a non-symlink regular file`);
|
|
2471
|
+
if (!pathIsWithinRoot(fs.realpathSync(candidate), fs.realpathSync(projectRoot))) die(`${label} escapes the canonical project root`);
|
|
2472
|
+
return path.relative(projectRoot, candidate);
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
function globMatches(pattern: string, relative: string): boolean {
|
|
2476
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replaceAll("**", "::GLOBSTAR::").replaceAll("*", "[^/]*").replaceAll("::GLOBSTAR::", ".*");
|
|
2477
|
+
return new RegExp(`^${escaped}$`).test(relative);
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
type GateProducer = { id: string; artifactPatterns: string[]; selfProducedTrustSlices: string[] };
|
|
2481
|
+
|
|
2482
|
+
function expectedGateProducer(flowId: string, stepId: string, expectationId: string): GateProducer {
|
|
2483
|
+
const manifest = loadJson(path.join(flowAgentsPackageRoot(), "kits", "builder", "kit.json"));
|
|
2484
|
+
const actions = Array.isArray(manifest.flow_step_actions) ? manifest.flow_step_actions as AnyObj[] : [];
|
|
2485
|
+
const action = actions.find((candidate) => candidate.flow_id === flowId && candidate.step_id === stepId);
|
|
2486
|
+
if (!action) die(`record-gate-claim cannot derive a producer for unknown Flow step ${flowId}/${stepId}`);
|
|
2487
|
+
const skills = Array.isArray(action.skills) ? action.skills.filter((value: unknown): value is string => typeof value === "string") : [];
|
|
2488
|
+
const roles = Array.isArray(manifest.skill_roles) ? manifest.skill_roles as AnyObj[] : [];
|
|
2489
|
+
const owners = roles.filter((role) => typeof role.skill_id === "string"
|
|
2490
|
+
&& Array.isArray(role.step_ids) && role.step_ids.includes(stepId)
|
|
2491
|
+
&& Array.isArray(role.expectation_ids) && role.expectation_ids.includes(expectationId)
|
|
2492
|
+
&& skills.includes(role.skill_id.replace(/^builder\./, "")));
|
|
2493
|
+
if (owners.length === 0) {
|
|
2494
|
+
const operations = Array.isArray(action.operations) ? action.operations.filter((value: unknown): value is string => typeof value === "string") : [];
|
|
2495
|
+
const operationExpectations = Array.isArray(action.expectation_ids) ? action.expectation_ids : [];
|
|
2496
|
+
if (operations.length === 1 && operationExpectations.includes(expectationId)) {
|
|
2497
|
+
const artifacts = Array.isArray(action.artifacts) ? action.artifacts.filter((value: unknown): value is string => typeof value === "string") : [];
|
|
2498
|
+
return {
|
|
2499
|
+
id: `operation:${operations[0]}`,
|
|
2500
|
+
artifactPatterns: artifacts.filter((value) => !value.includes("#")),
|
|
2501
|
+
selfProducedTrustSlices: artifacts.filter((value) => value.startsWith("trust.bundle#")).map((value) => value.slice("trust.bundle#".length)),
|
|
2502
|
+
};
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
if (owners.length !== 1) die(`record-gate-claim cannot derive exactly one producer for ${flowId}/${stepId}/${expectationId}`);
|
|
2506
|
+
const owner = owners[0]!;
|
|
2507
|
+
const artifacts = (Array.isArray(owner.artifacts) ? owner.artifacts : []).filter((value): value is string => typeof value === "string" && value !== "ephemeral decision record");
|
|
2508
|
+
return {
|
|
2509
|
+
id: owner.skill_id,
|
|
2510
|
+
artifactPatterns: artifacts.filter((value) => !value.includes("#")),
|
|
2511
|
+
selfProducedTrustSlices: artifacts.filter((value) => value.startsWith("trust.bundle#")).map((value) => value.slice("trust.bundle#".length)),
|
|
2512
|
+
};
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
function validateReviewableGateEvidence(dir: string, slug: string, refs: AnyObj[], producer: GateProducer, label: string): void {
|
|
2516
|
+
if (refs.length === 0) die(`${label} requires at least one reviewable --evidence-ref-json`);
|
|
2517
|
+
const projectRoot = canonicalProjectRootForSession(dir);
|
|
2518
|
+
let declaredProducerArtifactFound = producer.artifactPatterns.length === 0;
|
|
2519
|
+
let localOrCommandEvidenceFound = false;
|
|
2520
|
+
for (const [index, ref] of refs.entries()) {
|
|
2521
|
+
if (ref.kind === "command" && hasNonEmptyString(ref.excerpt)) localOrCommandEvidenceFound = true;
|
|
2522
|
+
if (typeof ref.file !== "string") continue;
|
|
2523
|
+
const relative = validateLocalEvidenceFile(projectRoot, ref.file, `${label} evidence ref ${index}`);
|
|
2524
|
+
ref.file = relative;
|
|
2525
|
+
localOrCommandEvidenceFound = true;
|
|
2526
|
+
if (ref.kind === "artifact" && producer.artifactPatterns.length > 0) {
|
|
2527
|
+
const patterns = producer.artifactPatterns.map((pattern) => {
|
|
2528
|
+
const expanded = pattern.replaceAll("<slug>", slug);
|
|
2529
|
+
return expanded.startsWith(".kontourai/") ? expanded : `.kontourai/flow-agents/${slug}/${expanded}`;
|
|
2530
|
+
});
|
|
2531
|
+
if (patterns.some((pattern) => globMatches(pattern, relative))) declaredProducerArtifactFound = true;
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
if (!declaredProducerArtifactFound) die(`${label} requires a declared durable artifact from producer ${producer.id}`);
|
|
2535
|
+
if (producer.selfProducedTrustSlices.length > 0 && !localOrCommandEvidenceFound) {
|
|
2536
|
+
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`);
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
function commandFromEvidenceRef(ref: AnyObj): string {
|
|
2541
|
+
return typeof ref.excerpt === "string" ? ref.excerpt.trim() : (typeof ref.url === "string" ? ref.url.trim() : "");
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
function hasTestIntent(name: string): boolean {
|
|
2545
|
+
return /(?:^|[-_.\/])(test|tests|check|checks|verify|verification|spec|specs|eval|evals)(?:$|[-_.\/])/i.test(name) || /(?:test|check|verify|spec|eval)/i.test(path.basename(name));
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
function resolvesExplicitTestTarget(projectRoot: string, token: string): boolean {
|
|
2549
|
+
if (!hasTestIntent(token)) return false;
|
|
2550
|
+
try {
|
|
2551
|
+
if (/[*?\[]/.test(token)) return fs.globSync(token, { cwd: projectRoot }).length > 0;
|
|
2552
|
+
const target = path.resolve(projectRoot, token);
|
|
2553
|
+
return pathIsWithinRoot(target, projectRoot) && fs.lstatSync(target).isFile();
|
|
2554
|
+
} catch { return false; }
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
/** Validate test-evidence command shape without executing it. */
|
|
2558
|
+
type TestExecutionProof = {
|
|
2559
|
+
kind: "local-process-exit";
|
|
2560
|
+
runner: string;
|
|
2561
|
+
static_test_units: number;
|
|
2562
|
+
};
|
|
2563
|
+
|
|
2564
|
+
function staticTestUnits(file: string, executable: string): number {
|
|
2565
|
+
try {
|
|
2566
|
+
const content = fs.readFileSync(file, "utf8").slice(0, 256 * 1024);
|
|
2567
|
+
const hashComments = !["cargo", "go"].includes(executable);
|
|
2568
|
+
const active = content.split("\n")
|
|
2569
|
+
.map((line) => hashComments ? line.replace(/\s+#.*$/, "") : line.replace(/\s+\/\/.*$/, ""))
|
|
2570
|
+
.filter((line) => hashComments ? !line.trimStart().startsWith("#") : !line.trimStart().startsWith("//"))
|
|
2571
|
+
.join("\n");
|
|
2572
|
+
if (["bash", "sh", "zsh"].includes(executable)
|
|
2573
|
+
&& !/(?:^|\n)\s*set\s+(?:-[^\n\s]*e[^\n\s]*|-o\s+errexit)(?:\s|$)/.test(active)) return 0;
|
|
2574
|
+
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) ?? [];
|
|
2575
|
+
return assertions.length;
|
|
2576
|
+
} catch {
|
|
2577
|
+
return 0;
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
/**
|
|
2582
|
+
* Produce evidence from the locally executed command and statically reviewable
|
|
2583
|
+
* test units. Runner stdout is deliberately excluded: any executable can print
|
|
2584
|
+
* a Vitest/Jest-looking success summary, but it cannot turn a non-test script
|
|
2585
|
+
* into a supported test workflow or supply this locally-created proof.
|
|
2586
|
+
*/
|
|
2587
|
+
export function testExecutionProof(command: string, projectRoot: string, seenScripts = new Set<string>(), packageScriptBody = false): TestExecutionProof | null {
|
|
2588
|
+
const normalized = command.trim().replace(/\s+/g, " ");
|
|
2589
|
+
if (!normalized || /[`$()]/.test(normalized)) return null;
|
|
2590
|
+
if (/[;&|]/.test(normalized)) {
|
|
2591
|
+
if (!packageScriptBody || normalized.includes("||") || /[;|]/.test(normalized)) return null;
|
|
2592
|
+
const segments = normalized.split(/\s*&&\s*/).filter(Boolean);
|
|
2593
|
+
return segments.length > 1 ? segments.map((segment) => testExecutionProof(segment, projectRoot, new Set(seenScripts), false)).find(Boolean) ?? null : null;
|
|
2594
|
+
}
|
|
2595
|
+
if (/^(?:true|:|\/usr\/bin\/true)$/i.test(normalized)) return null;
|
|
2596
|
+
if (/^(?:echo|printf)(?:\s|$)/i.test(normalized)) return null;
|
|
2597
|
+
if (/(?:^|\s)(?:--version|-v|--help|-h)(?:\s|$)/.test(normalized)) return null;
|
|
2598
|
+
const tokens = normalized.split(" ").filter(Boolean);
|
|
2599
|
+
while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0] ?? "")) tokens.shift();
|
|
2600
|
+
const executable = tokens[0] ?? "";
|
|
2601
|
+
const executableName = path.basename(executable);
|
|
2602
|
+
if (["npm", "pnpm", "yarn", "bun"].includes(executableName)) {
|
|
2603
|
+
// `bun test` is Bun's test runner; package scripts use `bun run <script>`.
|
|
2604
|
+
if (executableName === "bun" && tokens[1] === "test") {
|
|
2605
|
+
const target = tokens.slice(2).find((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token));
|
|
2606
|
+
const units = target ? staticTestUnits(path.resolve(projectRoot, target), "bun") : 0;
|
|
2607
|
+
return units > 0 ? { kind: "local-process-exit", runner: "bun test", static_test_units: units } : null;
|
|
2608
|
+
}
|
|
2609
|
+
const script = tokens[1] === "run" || tokens[1] === "run-script" ? tokens[2] : tokens[1];
|
|
2610
|
+
if (!script || !hasTestIntent(script) || seenScripts.has(script)) return null;
|
|
2611
|
+
try {
|
|
2612
|
+
const pkg = loadJson(path.join(projectRoot, "package.json"));
|
|
2613
|
+
const scriptCommand = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts[script] : undefined;
|
|
2614
|
+
if (typeof scriptCommand !== "string") return null;
|
|
2615
|
+
seenScripts.add(script);
|
|
2616
|
+
return testExecutionProof(scriptCommand, projectRoot, seenScripts, true);
|
|
2617
|
+
} catch { return null; }
|
|
2618
|
+
}
|
|
2619
|
+
if (["vitest", "jest", "mocha", "ava", "pytest", "rspec", "phpunit"].includes(executableName)
|
|
2620
|
+
&& !tokens.some((token) => /pass.?with.?no.?tests/i.test(token))) {
|
|
2621
|
+
if (executable !== executableName) return null;
|
|
2622
|
+
const targets = tokens.slice(1)
|
|
2623
|
+
.filter((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token))
|
|
2624
|
+
.flatMap((token) => /[*?\[]/.test(token) ? fs.globSync(token, { cwd: projectRoot }) : [token]);
|
|
2625
|
+
const units = targets.reduce((total, target) => total + staticTestUnits(path.resolve(projectRoot, target), executableName), 0);
|
|
2626
|
+
return units > 0 ? { kind: "local-process-exit", runner: executableName, static_test_units: units } : null;
|
|
2627
|
+
}
|
|
2628
|
+
if (executableName === "cargo" && tokens[1] === "test") {
|
|
2629
|
+
const files = [...fs.globSync("tests/**/*.rs", { cwd: projectRoot }), ...fs.globSync("src/**/*.rs", { cwd: projectRoot })];
|
|
2630
|
+
const units = [...new Set(files)].reduce((total, file) => total + staticTestUnits(path.resolve(projectRoot, file), "cargo"), 0);
|
|
2631
|
+
return units > 0 ? { kind: "local-process-exit", runner: "cargo test", static_test_units: units } : null;
|
|
2632
|
+
}
|
|
2633
|
+
if (executableName === "go" && tokens[1] === "test") {
|
|
2634
|
+
const files = fs.globSync("**/*_test.go", { cwd: projectRoot, exclude: ["vendor/**", ".git/**"] });
|
|
2635
|
+
const units = files.reduce((total, file) => total + staticTestUnits(path.resolve(projectRoot, file), "go"), 0);
|
|
2636
|
+
return units > 0 ? { kind: "local-process-exit", runner: "go test", static_test_units: units } : null;
|
|
2637
|
+
}
|
|
2638
|
+
if (executableName === "npx" && tokens[1] && ["vitest", "jest", "mocha", "ava", "pytest"].includes(path.basename(tokens[1]))) {
|
|
2639
|
+
const runner = path.basename(tokens[1]);
|
|
2640
|
+
const targets = tokens.slice(2)
|
|
2641
|
+
.filter((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token))
|
|
2642
|
+
.flatMap((token) => /[*?\[]/.test(token) ? fs.globSync(token, { cwd: projectRoot }) : [token]);
|
|
2643
|
+
const units = targets.reduce((total, target) => total + staticTestUnits(path.resolve(projectRoot, target), runner), 0);
|
|
2644
|
+
return units > 0 ? { kind: "local-process-exit", runner: `npx ${runner}`, static_test_units: units } : null;
|
|
2645
|
+
}
|
|
2646
|
+
if (executableName === "node" && tokens.includes("--test")) {
|
|
2647
|
+
const testFlag = tokens.indexOf("--test");
|
|
2648
|
+
const targets = tokens.slice(testFlag + 1)
|
|
2649
|
+
.filter((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token))
|
|
2650
|
+
.flatMap((token) => /[*?\[]/.test(token) ? fs.globSync(token, { cwd: projectRoot }) : [token]);
|
|
2651
|
+
const units = targets.reduce((total, target) => total + staticTestUnits(path.resolve(projectRoot, target), executableName), 0);
|
|
2652
|
+
return units > 0 ? { kind: "local-process-exit", runner: "node --test", static_test_units: units } : null;
|
|
2653
|
+
}
|
|
2654
|
+
const scriptPath = ["bash", "sh", "zsh", "tsx", "ts-node"].includes(executableName) ? tokens[1] : executable;
|
|
2655
|
+
if (!scriptPath || ["-c", "-lc", "-e"].includes(scriptPath) || !hasTestIntent(scriptPath)) return null;
|
|
2656
|
+
try {
|
|
2657
|
+
const resolved = path.resolve(projectRoot, scriptPath);
|
|
2658
|
+
const stat = fs.lstatSync(resolved);
|
|
2659
|
+
if (stat.isSymbolicLink() || !stat.isFile() || !pathIsWithinRoot(fs.realpathSync(resolved), fs.realpathSync(projectRoot))) return null;
|
|
2660
|
+
const units = staticTestUnits(resolved, executableName);
|
|
2661
|
+
return units > 0 ? { kind: "local-process-exit", runner: executableName, static_test_units: units } : null;
|
|
2662
|
+
} catch { return null; }
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
/** Validate test-evidence command shape without executing it. */
|
|
2666
|
+
export function isMeaningfulTestCommand(command: string, projectRoot: string, seenScripts = new Set<string>(), packageScriptBody = false): boolean {
|
|
2667
|
+
return testExecutionProof(command, projectRoot, seenScripts, packageScriptBody) !== null;
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
export function observedExecutedTestCount(output: string): number {
|
|
2671
|
+
const counts: number[] = [];
|
|
2672
|
+
for (const pattern of [
|
|
2673
|
+
/^\s*(?:#|ℹ)\s*tests\s+(\d+)\s*$/gim,
|
|
2674
|
+
/^\s*1\.\.(\d+)(?:\s|$)/gim,
|
|
2675
|
+
/\b(\d+)\s+passed\b/gim,
|
|
2676
|
+
]) {
|
|
2677
|
+
for (const match of output.matchAll(pattern)) counts.push(Number(match[1]));
|
|
2678
|
+
}
|
|
2679
|
+
const explicitPasses = output.match(/^\s*(?:---\s+PASS:|ok\s+\d+\s+-)/gm)?.length ?? 0;
|
|
2680
|
+
if (explicitPasses > 0) counts.push(explicitPasses);
|
|
2681
|
+
const goPackages = output.match(/^ok\s+\S+(?:\s|$)/gm)?.length ?? 0;
|
|
2682
|
+
if (goPackages > 0) counts.push(goPackages);
|
|
2683
|
+
return Math.max(0, ...counts.filter((count) => Number.isSafeInteger(count) && count > 0));
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
export function inferExecutedTestCount(command: string, projectRoot: string, output: string, seenScripts = new Set<string>()): number {
|
|
2687
|
+
const proof = testExecutionProof(command, projectRoot, seenScripts);
|
|
2688
|
+
if (!proof) return 0;
|
|
2689
|
+
const observed = observedExecutedTestCount(output);
|
|
2690
|
+
return observed > 0 ? Math.min(proof.static_test_units, observed) : 0;
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
type ObservedCommand = { command: string; exit_code: number; output_sha256: string; test_count?: number; execution_proof?: TestExecutionProof };
|
|
2694
|
+
|
|
2695
|
+
async function normalizeObservedCommands(commands: string[], projectRoot: string, requireTestIntent: boolean, expectedStatus: string): Promise<ObservedCommand[]> {
|
|
2696
|
+
if (commands.length === 0) die("record-gate-claim requires at least one --command for observed command evidence");
|
|
2697
|
+
if (new Set(commands).size !== commands.length) die("record-gate-claim --command values must be unique");
|
|
2698
|
+
for (const command of commands) {
|
|
2699
|
+
const { isRunnableCommandText } = loadRunnableCommandHelper();
|
|
2700
|
+
if (!isRunnableCommandText(command)) die(`record-gate-claim --command "${command}" is not a runnable shell command — prose belongs in --summary, which is never executed.`);
|
|
2701
|
+
if (requireTestIntent && !isMeaningfulTestCommand(command, projectRoot)) die("record-gate-claim tests-evidence command must resolve through a non-vacuous package script or a known test/check/verify/eval runner or project-local test path; shell wrappers, no-ops, version/help commands, and arbitrary node -e commands are not evidence");
|
|
2702
|
+
}
|
|
2703
|
+
// Passing test evidence is always executed exactly once by this canonical
|
|
2704
|
+
// writer. Caller-supplied observations remain available for non-test
|
|
2705
|
+
// attestations but can never stand in for locally observed test execution.
|
|
2706
|
+
const observed = await Promise.all(commands.map(async (command) => {
|
|
2707
|
+
const result = await runObservedCommand(command, projectRoot);
|
|
2708
|
+
const proof = requireTestIntent ? testExecutionProof(command, projectRoot) : null;
|
|
2709
|
+
return { command, exit_code: result.exit_code, output_sha256: result.output_sha256, ...(proof ? { test_count: inferExecutedTestCount(command, projectRoot, result.output), execution_proof: proof } : {}) };
|
|
2710
|
+
}));
|
|
2711
|
+
if (observed.length !== commands.length) die("record-gate-claim requires exactly one --observed-command-json for every --command");
|
|
2712
|
+
const byCommand = new Map<string, ObservedCommand>();
|
|
2713
|
+
for (const entry of observed) {
|
|
2714
|
+
if (typeof entry.command !== "string" || typeof entry.exit_code !== "number" || !Number.isInteger(entry.exit_code) || typeof entry.output_sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(entry.output_sha256)) die("--observed-command-json must contain command, integer exit_code, and sha256 output_sha256");
|
|
2715
|
+
if (!commands.includes(entry.command)) die("--observed-command-json command must exactly match one supplied --command");
|
|
2716
|
+
if (expectedStatus === "pass" && entry.exit_code !== 0) die(`record-gate-claim passing evidence command failed (exit ${entry.exit_code}): ${entry.command}`);
|
|
2717
|
+
if (expectedStatus === "fail" && entry.exit_code === 0) die(`record-gate-claim failing evidence command unexpectedly passed: ${entry.command}`);
|
|
2718
|
+
if (requireTestIntent && (!Number.isSafeInteger(entry.test_count) || Number(entry.test_count) <= 0 || !entry.execution_proof || entry.execution_proof.kind !== "local-process-exit")) die(`record-gate-claim passing tests-evidence command did not produce a local execution proof: ${entry.command}`);
|
|
2719
|
+
if (byCommand.has(entry.command)) die("--observed-command-json command values must be unique");
|
|
2720
|
+
byCommand.set(entry.command, entry as ObservedCommand);
|
|
2721
|
+
}
|
|
2722
|
+
if (commands.some((command) => !byCommand.has(command))) die("every --command requires a corresponding --observed-command-json");
|
|
2723
|
+
return commands.map((command) => byCommand.get(command)!);
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
function requireObservedCommandRefs(refs: AnyObj[], observedCommands: ReadonlySet<string>, label: string, requireAll = false): void {
|
|
2727
|
+
const commandRefs = refs.filter((ref) => ref.kind === "command");
|
|
2728
|
+
if (commandRefs.length === 0) die(`${label} requires a command evidence ref matching a successful observed command`);
|
|
2729
|
+
for (const ref of commandRefs) {
|
|
2730
|
+
if (!observedCommands.has(commandFromEvidenceRef(ref))) die(`${label} command evidence ref must exactly match a successful --observed-command-json command`);
|
|
2731
|
+
}
|
|
2732
|
+
if (requireAll) {
|
|
2733
|
+
const referenced = new Set(commandRefs.map(commandFromEvidenceRef));
|
|
2734
|
+
if ([...observedCommands].some((command) => !referenced.has(command))) die(`${label} requires a top-level command evidence ref for every successful observed command`);
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
function completePassingCriteria(existing: AnyObj[], raw: string[], observedCommands: ReadonlySet<string>, verifiedAt: string): AnyObj[] {
|
|
2739
|
+
if (raw.length === 0) die("record-gate-claim requires --criterion-json for a passing tests-evidence claim");
|
|
2740
|
+
const incoming = raw.map((value) => parseJson(value, "--criterion-json"));
|
|
2741
|
+
const expectedById = new Map<string, AnyObj>();
|
|
2742
|
+
for (const criterion of existing) {
|
|
2743
|
+
const id = String(criterion.id ?? "");
|
|
2744
|
+
if (id.length > 0) expectedById.set(id, criterion);
|
|
2745
|
+
}
|
|
2746
|
+
const expectedIds = [...expectedById.keys()];
|
|
2747
|
+
const ids = incoming.map((criterion) => typeof criterion.id === "string" ? criterion.id : "");
|
|
2748
|
+
if (new Set(ids).size !== ids.length || ids.length !== expectedIds.length || ids.some((id) => !expectedIds.includes(id))) {
|
|
2749
|
+
die(`--criterion-json must cover every declared acceptance criterion exactly once (expected: ${expectedIds.join(", ") || "none"}; received: ${ids.join(", ") || "none"})`);
|
|
2750
|
+
}
|
|
2751
|
+
return incoming.map((criterion, index) => {
|
|
2752
|
+
if (Object.keys(criterion).some((key) => !["id", "status", "evidence_refs"].includes(key))) die(`criterion ${ids[index]} may update only id, status, and evidence_refs`);
|
|
2753
|
+
if (criterion.status !== "pass") die(`criterion ${ids[index]} must have status pass for a passing tests-evidence claim`);
|
|
2754
|
+
const refs = normalizeEvidenceRefs(criterion.evidence_refs, `criterion ${ids[index]} evidence_refs`);
|
|
2755
|
+
if (refs.length === 0) die(`criterion ${ids[index]} requires reviewable evidence_refs`);
|
|
2756
|
+
requireObservedCommandRefs(refs, observedCommands, `criterion ${ids[index]}`);
|
|
2757
|
+
return { ...expectedById.get(ids[index])!, status: "pass", evidence_refs: refs, identity_version: 2, verified_at: verifiedAt };
|
|
2758
|
+
});
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
const critiqueStatuses = new Set(["pass", "fail", "not_verified"]);
|
|
2762
|
+
const safeCritiqueId = /^[a-z][a-z0-9_-]*$/;
|
|
2763
|
+
|
|
2764
|
+
function normalizeCritiqueLanes(raw: string[]): AnyObj[] {
|
|
2765
|
+
if (raw.length === 0) die("record-critique requires at least one --lane-json");
|
|
2766
|
+
const lanes = raw.map((value, index) => {
|
|
2767
|
+
const lane = parseJson(value, "--lane-json");
|
|
2768
|
+
const keys = Object.keys(lane);
|
|
2769
|
+
if (keys.some((key) => !["id", "status", "summary", "evidence_refs"].includes(key))) die(`--lane-json ${index} contains unsupported fields`);
|
|
2770
|
+
if (!safeCritiqueId.test(String(lane.id ?? ""))) die(`--lane-json ${index} id must be a unique safe identifier`);
|
|
2771
|
+
if (!critiqueStatuses.has(String(lane.status ?? ""))) die(`--lane-json ${index} status must be one of: pass, fail, not_verified`);
|
|
2772
|
+
if (!hasNonEmptyString(lane.summary)) die(`--lane-json ${index} summary must be non-empty`);
|
|
2773
|
+
const evidenceRefs = normalizeEvidenceRefs(lane.evidence_refs, `--lane-json ${index} evidence_refs`);
|
|
2774
|
+
if (evidenceRefs.length === 0) die(`--lane-json ${index} requires structured reviewable evidence_refs`);
|
|
2775
|
+
return { id: lane.id, status: lane.status, summary: lane.summary, evidence_refs: evidenceRefs };
|
|
2776
|
+
});
|
|
2777
|
+
if (new Set(lanes.map((lane) => lane.id)).size !== lanes.length) die("--lane-json ids must be unique");
|
|
2778
|
+
return lanes;
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
function reviewTargetArtifacts(dir: string, rawPaths: string[], label: string): AnyObj[] {
|
|
2782
|
+
const projectRoot = canonicalProjectRootForSession(dir);
|
|
2783
|
+
const fallback = path.join(dir, `${taskSlugFor(dir)}--deliver.md`);
|
|
2784
|
+
const paths = rawPaths.length > 0 ? rawPaths : (fs.existsSync(fallback) ? [fallback] : []);
|
|
2785
|
+
const files = paths.map((raw, index) => validateLocalEvidenceFile(projectRoot, raw, `${label} artifact ${index}`));
|
|
2786
|
+
if (new Set(files).size !== files.length) die(`${label} artifact refs must be unique`);
|
|
2787
|
+
return files.map((file) => ({ file, sha256: createHash("sha256").update(fs.readFileSync(path.join(projectRoot, file))).digest("hex") }));
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
function reviewTargetArtifactsMatch(dir: string, reviewTarget: unknown): boolean {
|
|
2791
|
+
try {
|
|
2792
|
+
if (!reviewTarget || typeof reviewTarget !== "object" || Array.isArray(reviewTarget)) return false;
|
|
2793
|
+
const artifacts = (reviewTarget as AnyObj).artifacts;
|
|
2794
|
+
if (!Array.isArray(artifacts) || artifacts.length === 0) return false;
|
|
2795
|
+
const projectRoot = canonicalProjectRootForSession(dir);
|
|
2796
|
+
const files = new Set<string>();
|
|
2797
|
+
return artifacts.every((artifact) => {
|
|
2798
|
+
if (!artifact || typeof artifact !== "object") return false;
|
|
2799
|
+
const file = (artifact as AnyObj).file;
|
|
2800
|
+
const sha256 = (artifact as AnyObj).sha256;
|
|
2801
|
+
if (!hasNonEmptyString(file) || !/^[a-f0-9]{64}$/i.test(String(sha256)) || files.has(file)) return false;
|
|
2802
|
+
files.add(file);
|
|
2803
|
+
const relative = validateLocalEvidenceFile(projectRoot, file, "critique review_target artifact");
|
|
2804
|
+
const digest = createHash("sha256").update(fs.readFileSync(path.join(projectRoot, relative))).digest("hex");
|
|
2805
|
+
return digest === sha256;
|
|
2806
|
+
});
|
|
2807
|
+
} catch { return false; }
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
function critiqueIsCleanAndCurrent(dir: string, critique: AnyObj): boolean {
|
|
2811
|
+
if (critique.verdict !== "pass") return false;
|
|
2812
|
+
if (!Array.isArray(critique.lanes) || critique.lanes.length === 0 || critique.lanes.some((lane: AnyObj) => lane.status !== "pass")) return false;
|
|
2813
|
+
if (Array.isArray(critique.findings) && critique.findings.some((finding: AnyObj) => finding.status === "open")) return false;
|
|
2814
|
+
return reviewTargetArtifactsMatch(dir, critique.review_target);
|
|
2815
|
+
}
|
|
2306
2816
|
// #270 HIGH fix (iteration 3): the `gate-claim-` check-id prefix is RESERVED for
|
|
2307
2817
|
// record-gate-claim's own internally-constructed ids (`id: \`gate-claim-${checkId}\`` — see
|
|
2308
2818
|
// recordGateClaim below). Every OTHER writer of check ids (record-evidence --check-json,
|
|
@@ -2604,6 +3114,17 @@ function checksFromBundle(dir: string): AnyObj[] {
|
|
|
2604
3114
|
const od = md && typeof md === "object" ? md.output_digest as AnyObj : undefined;
|
|
2605
3115
|
return od && typeof od === "object" && od.algorithm === "sha256" && typeof od.hex === "string" && od.hex.length > 0 ? od.hex : undefined;
|
|
2606
3116
|
};
|
|
3117
|
+
const observedCommandsOf = (claim: AnyObj): ObservedCommand[] | undefined => {
|
|
3118
|
+
const md = claim.metadata as AnyObj;
|
|
3119
|
+
const observed = md && typeof md === "object" ? md.observed_commands : undefined;
|
|
3120
|
+
return Array.isArray(observed) ? observed.filter((entry: AnyObj) => typeof entry?.command === "string" && typeof entry?.exit_code === "number" && typeof entry?.output_sha256 === "string") : undefined;
|
|
3121
|
+
};
|
|
3122
|
+
const applyProducerStamp = (check: AnyObj, claim: AnyObj): void => {
|
|
3123
|
+
const md = claim.metadata as AnyObj;
|
|
3124
|
+
if (md && typeof md.expected_producer === "string") check._producer = md.expected_producer;
|
|
3125
|
+
if (md && typeof md.recorded_by === "string") check._recorded_by = md.recorded_by;
|
|
3126
|
+
if (md && Array.isArray(md.self_produced_trust_slices)) check._producer_self_produced_trust_slices = md.self_produced_trust_slices;
|
|
3127
|
+
};
|
|
2607
3128
|
// #270(a)/(c): read side of the gate_claim stamp (write side: buildTrustBundle's
|
|
2608
3129
|
// claimMetadata.gate_claim, above). Restoring expectation_id/claim_type/subject_type/step_id
|
|
2609
3130
|
// is what lets a REBUILD (record-evidence/record-critique/record-learning, after the recorded
|
|
@@ -2620,6 +3141,8 @@ function checksFromBundle(dir: string): AnyObj[] {
|
|
|
2620
3141
|
if (typeof gc.claim_type === "string") check._gate_claim_declared_type = gc.claim_type;
|
|
2621
3142
|
if (typeof gc.subject_type === "string") check._gate_claim_declared_subject = gc.subject_type;
|
|
2622
3143
|
if (typeof gc.step_id === "string") check._gate_claim_declared_step_id = gc.step_id;
|
|
3144
|
+
if (typeof gc.recorded_at === "string") check._gate_claim_recorded_at = gc.recorded_at;
|
|
3145
|
+
if (gc.identity_version === 2) check._gate_claim_identity_version = 2;
|
|
2623
3146
|
if (typeof gc.route_reason === "string") check._gate_claim_route_reason = gc.route_reason;
|
|
2624
3147
|
};
|
|
2625
3148
|
// #270 CRITICAL/HIGH fix: a claim that is gate-claim-SHAPED but carries NO metadata.gate_claim
|
|
@@ -2677,6 +3200,9 @@ function checksFromBundle(dir: string): AnyObj[] {
|
|
|
2677
3200
|
Object.assign(check, refsOf(claim));
|
|
2678
3201
|
const outputSha256 = outputSha256Of(claim);
|
|
2679
3202
|
if (outputSha256) check._output_sha256 = outputSha256;
|
|
3203
|
+
const observedCommands = observedCommandsOf(claim);
|
|
3204
|
+
if (observedCommands) check._observed_commands = observedCommands;
|
|
3205
|
+
applyProducerStamp(check, claim);
|
|
2680
3206
|
applyGateClaimStamp(check, claim);
|
|
2681
3207
|
applyGateClaimShapeUnstamped(check, claim);
|
|
2682
3208
|
checks.push(check);
|
|
@@ -2694,6 +3220,9 @@ function checksFromBundle(dir: string): AnyObj[] {
|
|
|
2694
3220
|
Object.assign(check, refsOf(claim));
|
|
2695
3221
|
const outputSha256 = outputSha256Of(claim);
|
|
2696
3222
|
if (outputSha256) check._output_sha256 = outputSha256;
|
|
3223
|
+
const observedCommands = observedCommandsOf(claim);
|
|
3224
|
+
if (observedCommands) check._observed_commands = observedCommands;
|
|
3225
|
+
applyProducerStamp(check, claim);
|
|
2697
3226
|
applyGateClaimStamp(check, claim);
|
|
2698
3227
|
applyGateClaimShapeUnstamped(check, claim);
|
|
2699
3228
|
checks.push(check);
|
|
@@ -2728,9 +3257,18 @@ function existingCheckStampMap(checks: AnyObj[]): Map<string, boolean> {
|
|
|
2728
3257
|
*/
|
|
2729
3258
|
function readBundleState(dir: string): { checks: AnyObj[]; criteria: AnyObj[]; critiques: AnyObj[] } {
|
|
2730
3259
|
const acceptance = loadJson(path.join(dir, "acceptance.json"));
|
|
3260
|
+
const bundledCriteria = criteriaFromBundle(dir);
|
|
3261
|
+
const acceptedCriteria = Array.isArray(acceptance.criteria) ? acceptance.criteria as AnyObj[] : [];
|
|
3262
|
+
const contractSignature = (criteria: AnyObj[]): string => JSON.stringify(criteria.map((criterion) => ({
|
|
3263
|
+
id: criterion.id ?? null,
|
|
3264
|
+
description: criterion.description ?? null,
|
|
3265
|
+
})));
|
|
3266
|
+
const criteria = acceptedCriteria.length > 0 && contractSignature(acceptedCriteria) !== contractSignature(bundledCriteria)
|
|
3267
|
+
? acceptedCriteria
|
|
3268
|
+
: (bundledCriteria.length > 0 ? bundledCriteria : acceptedCriteria);
|
|
2731
3269
|
return {
|
|
2732
3270
|
checks: checksFromBundle(dir),
|
|
2733
|
-
criteria
|
|
3271
|
+
criteria,
|
|
2734
3272
|
critiques: critiquesFromBundle(dir),
|
|
2735
3273
|
};
|
|
2736
3274
|
}
|
|
@@ -2761,14 +3299,38 @@ function critiquesFromBundle(dir: string): AnyObj[] {
|
|
|
2761
3299
|
id: String(c.subjectId || "").split("/").pop() || c.id,
|
|
2762
3300
|
verdict: c.value ?? "not_verified",
|
|
2763
3301
|
summary: c.fieldOrBehavior || "",
|
|
2764
|
-
findings: [],
|
|
3302
|
+
findings: Array.isArray(md.findings) ? md.findings : [],
|
|
3303
|
+
lanes: Array.isArray(md.lanes) ? md.lanes : [],
|
|
3304
|
+
review_target: md.review_target && typeof md.review_target === "object" && !Array.isArray(md.review_target) ? md.review_target : { artifacts: [] },
|
|
2765
3305
|
reviewer: typeof md.reviewer === "string" ? md.reviewer : "tool-code-reviewer",
|
|
2766
3306
|
reviewed_at: typeof md.reviewed_at === "string" ? md.reviewed_at : (c.updatedAt || c.createdAt || now()),
|
|
2767
|
-
|
|
3307
|
+
...(md.identity_version === 2 ? { identity_version: 2 } : {}),
|
|
3308
|
+
artifact_refs: Array.isArray(md.artifact_refs) ? md.artifact_refs : [],
|
|
2768
3309
|
...(typeof md.superseded_by === "string" && md.superseded_by.length > 0 ? { superseded_by: md.superseded_by } : {}),
|
|
2769
3310
|
};
|
|
2770
3311
|
});
|
|
2771
3312
|
}
|
|
3313
|
+
|
|
3314
|
+
function criteriaFromBundle(dir: string): AnyObj[] {
|
|
3315
|
+
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
3316
|
+
if (!Array.isArray(bundle.claims)) return [];
|
|
3317
|
+
for (const claim of bundle.claims) requireStampedClaim(claim, dir);
|
|
3318
|
+
return bundle.claims
|
|
3319
|
+
.filter((claim: AnyObj) => claim && claimOrigin(claim) === "acceptance")
|
|
3320
|
+
.map((claim: AnyObj) => {
|
|
3321
|
+
const md = claim.metadata && typeof claim.metadata === "object" && !Array.isArray(claim.metadata) ? claim.metadata as AnyObj : {};
|
|
3322
|
+
const saved = md.criterion && typeof md.criterion === "object" && !Array.isArray(md.criterion) ? md.criterion as AnyObj : {};
|
|
3323
|
+
return {
|
|
3324
|
+
id: typeof saved.id === "string" ? saved.id : String(claim.subjectId || "").split("/").pop(),
|
|
3325
|
+
description: typeof saved.description === "string" ? saved.description : (claim.fieldOrBehavior || ""),
|
|
3326
|
+
status: typeof saved.status === "string" ? saved.status : (claim.value ?? "not_verified"),
|
|
3327
|
+
evidence_refs: Array.isArray(saved.evidence_refs) ? saved.evidence_refs : [],
|
|
3328
|
+
...(typeof saved.verified_at === "string" ? { verified_at: saved.verified_at } : {}),
|
|
3329
|
+
...(saved.identity_version === 2 ? { identity_version: 2 } : {}),
|
|
3330
|
+
};
|
|
3331
|
+
})
|
|
3332
|
+
.filter((criterion: AnyObj) => typeof criterion.id === "string" && criterion.id.length > 0);
|
|
3333
|
+
}
|
|
2772
3334
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
2773
3335
|
/**
|
|
2774
3336
|
* WS8 (ADR 0020): parse the accepted-gap waiver flags. Both --accepted-gap-reason and
|
|
@@ -2820,20 +3382,18 @@ async function recordEvidence(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
2820
3382
|
if (!checks.length && opts(p, "surface-trust-json").length === 0) die("record-evidence requires at least one --check-json or --surface-trust-json");
|
|
2821
3383
|
validateAcceptanceEvidenceRefs(dir, p);
|
|
2822
3384
|
// Phase 4c: bundle is the sole verification artifact — stop writing evidence.json and acceptance.json update.
|
|
2823
|
-
const ts = opt(p, "timestamp",
|
|
3385
|
+
const ts = opt(p, "timestamp", new Date().toISOString());
|
|
2824
3386
|
// #298: readBundleState + merge-by-id instead of unconditionally replacing every prior check —
|
|
2825
3387
|
// record-evidence previously never called checksFromBundle(dir), so it dropped every check
|
|
2826
3388
|
// recorded by an earlier record-evidence/record-check/record-gate-claim call. A later check
|
|
2827
3389
|
// with the same id supersedes the earlier one (same-id resupply); a new id is additive.
|
|
2828
3390
|
// (_existingState was already read above, before normalizeCheck ran, so the reserved-prefix
|
|
2829
3391
|
// exists-check sees the SAME bundle snapshot the merge below uses — no second read needed.)
|
|
2830
|
-
const _criteriaStatus = verdict === "pass" ? "pass" : verdict === "fail" ? "fail" : "not_verified";
|
|
2831
|
-
const _criteriaForBundle: AnyObj[] = _existingState.criteria.map((c: AnyObj) => ({ ...c, status: _criteriaStatus }));
|
|
2832
3392
|
const _mergedChecks = mergeChecksById(_existingState.checks, checks);
|
|
2833
3393
|
// #268: preserve any existing critique claims (including superseded history) instead of dropping
|
|
2834
3394
|
// them — record-evidence previously hardcoded critiques:[] here, silently erasing finding history
|
|
2835
3395
|
// whenever it ran after a critique existed.
|
|
2836
|
-
assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks,
|
|
3396
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, _existingState.criteria, _existingState.critiques));
|
|
2837
3397
|
const stateStatus = verdict === "pass" ? "verified" : verdict === "fail" ? "failed" : "not_verified";
|
|
2838
3398
|
writeState(dir, slug, stateStatus, "verification", ts, "Evidence recorded.");
|
|
2839
3399
|
return 0;
|
|
@@ -3029,7 +3589,7 @@ function diagnostic(dir: string, code: string, summary: string): never {
|
|
|
3029
3589
|
async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
3030
3590
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
3031
3591
|
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
3032
|
-
const ts = opt(p, "timestamp",
|
|
3592
|
+
const ts = opt(p, "timestamp", new Date().toISOString());
|
|
3033
3593
|
const statusVal = opt(p, "status");
|
|
3034
3594
|
if (!["pass", "fail", "not_verified"].includes(statusVal)) die("--status must be one of: pass, fail, not_verified");
|
|
3035
3595
|
const summary = opt(p, "summary") || die("--summary is required");
|
|
@@ -3038,14 +3598,22 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
3038
3598
|
if (routeReason && statusVal !== "fail") die("--route-reason is only valid with --status fail");
|
|
3039
3599
|
if (routeReason && !/^[a-z][a-z0-9_-]*$/.test(routeReason)) die("--route-reason must be a lowercase classifier identifier");
|
|
3040
3600
|
|
|
3041
|
-
//
|
|
3042
|
-
//
|
|
3043
|
-
//
|
|
3044
|
-
// Stop-short risk) lack of a dir-scoping guard: it now resolves ITS OWN actor's current.json
|
|
3045
|
-
// view, not a different actor's more-recently-written legacy pointer.
|
|
3601
|
+
// Prefer the exact session's canonical Flow projection. Actor/global current pointers are
|
|
3602
|
+
// ambient navigation state and may legitimately point at another run or lag this run.
|
|
3603
|
+
// Legacy sessions without flow_run retain the per-actor current-pointer fallback.
|
|
3046
3604
|
const flowAgentsDir = path.dirname(dir);
|
|
3047
3605
|
const gateClaimActorKey = resolveReadActorKey(p);
|
|
3048
|
-
const
|
|
3606
|
+
const sidecarState = loadJson(path.join(dir, "state.json"));
|
|
3607
|
+
const projectedRun = sidecarState.flow_run && typeof sidecarState.flow_run === "object" && !Array.isArray(sidecarState.flow_run)
|
|
3608
|
+
? sidecarState.flow_run as AnyObj
|
|
3609
|
+
: null;
|
|
3610
|
+
const exactFlowId = projectedRun && typeof projectedRun.definition_id === "string" ? projectedRun.definition_id : null;
|
|
3611
|
+
const exactStepId = projectedRun && typeof projectedRun.current_step === "string" ? projectedRun.current_step : null;
|
|
3612
|
+
const exactFlowContext = exactFlowId && exactStepId ? { flowId: exactFlowId, stepId: exactStepId } : undefined;
|
|
3613
|
+
const activeStep = exactFlowContext
|
|
3614
|
+
? resolveFlowStep(exactFlowContext.flowId, exactFlowContext.stepId, findRepoRootFromDir(path.dirname(flowAgentsDir)))
|
|
3615
|
+
: resolveActiveFlowStep(flowAgentsDir, gateClaimActorKey);
|
|
3616
|
+
if (exactFlowContext && !activeStep) die(`record-gate-claim cannot resolve exact session Flow step ${exactFlowContext.flowId}/${exactFlowContext.stepId}`);
|
|
3049
3617
|
if (!activeStep) die("record-gate-claim requires an active flow step in current.json (set via ensure-session --flow-id or advance-state --flow-definition)");
|
|
3050
3618
|
if (routeReason && !activeStep.routeBackReasons.includes(routeReason)) {
|
|
3051
3619
|
die(`--route-reason "${routeReason}" is not declared by gate "${activeStep.gateId}". Available: ${activeStep.routeBackReasons.join(", ") || "none"}`);
|
|
@@ -3066,30 +3634,54 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
3066
3634
|
}
|
|
3067
3635
|
|
|
3068
3636
|
const { claimType, subjectType } = targetExpectation.bundle_claim;
|
|
3637
|
+
const gateCommands = opts(p, "command");
|
|
3638
|
+
const gateCommand = gateCommands[0] ?? "";
|
|
3639
|
+
const mustRunTests = targetExpectation.id === "tests-evidence" && statusVal === "pass";
|
|
3640
|
+
const observedCommandRaw = opts(p, "observed-command-json");
|
|
3641
|
+
if (mustRunTests && gateCommands.length === 0) die("record-gate-claim requires at least one --command for a passing tests-evidence claim");
|
|
3642
|
+
const projectRoot = gateCommands.length > 0 ? canonicalProjectRootForSession(dir) : null;
|
|
3643
|
+
const observedCommands = gateCommands.length > 0
|
|
3644
|
+
? await normalizeObservedCommands(gateCommands, projectRoot!, mustRunTests, statusVal)
|
|
3645
|
+
: [];
|
|
3646
|
+
const observedCommandNames = new Set(observedCommands.map((entry) => entry.command));
|
|
3647
|
+
let outputSha256: string | null = null;
|
|
3648
|
+
if (!mustRunTests && gateCommands.length > 1) die("record-gate-claim accepts repeatable --command only for passing tests-evidence claims");
|
|
3649
|
+
if (gateCommands.length === 0 && observedCommandRaw.length > 0) die("--observed-command-json requires --command");
|
|
3650
|
+
if (!mustRunTests && gateCommand && observedCommandRaw.length === 0) {
|
|
3651
|
+
const { isRunnableCommandText } = loadRunnableCommandHelper();
|
|
3652
|
+
if (!isRunnableCommandText(gateCommand)) die(`record-gate-claim --command "${gateCommand}" is not a runnable shell command — prose belongs in --summary, which is never executed.`);
|
|
3653
|
+
}
|
|
3654
|
+
if (observedCommands.length > 0) outputSha256 = observedCommands[0]!.output_sha256;
|
|
3069
3655
|
|
|
3070
|
-
// Build a
|
|
3071
|
-
//
|
|
3072
|
-
// non-flow-step fallback path. The subjectType on the resulting claim comes from the
|
|
3073
|
-
// expects[] entry via matchExpectsEntry.
|
|
3656
|
+
// Build a gate-targeted check that matchExpectsEntry maps to the declared claim tuple.
|
|
3657
|
+
// Command-backed checks retain their executable evidence classification.
|
|
3074
3658
|
const checkId = expectationId || targetExpectation.id;
|
|
3075
3659
|
// Build a minimal "external" check. Include _gate_claim_expectation_id so that
|
|
3076
3660
|
// matchExpectsEntry can do an exact lookup for multi-expects[] gates (ADR 0016 P-d Increment 2).
|
|
3077
3661
|
// normalizeCheck preserves extra underscore-prefixed fields without stripping them.
|
|
3078
3662
|
const check: AnyObj = {
|
|
3079
3663
|
id: `gate-claim-${checkId}`,
|
|
3080
|
-
kind: "external",
|
|
3664
|
+
kind: gateCommands.length > 0 ? "command" : "external",
|
|
3081
3665
|
status: statusVal,
|
|
3082
3666
|
summary,
|
|
3083
3667
|
_gate_claim_expectation_id: targetExpectation.id,
|
|
3668
|
+
_gate_claim_identity_version: 2,
|
|
3669
|
+
_gate_claim_recorded_at: ts,
|
|
3084
3670
|
...(routeReason ? { _gate_claim_route_reason: routeReason } : {}),
|
|
3085
3671
|
};
|
|
3086
3672
|
|
|
3087
3673
|
// Include structured evidence refs if provided
|
|
3088
3674
|
const evidenceRefs: AnyObj[] = opts(p, "evidence-ref-json").map((v) => validateEvidenceRef(parseJson(v, "--evidence-ref-json"), "--evidence-ref-json"));
|
|
3675
|
+
const producer = expectedGateProducer(exactFlowContext?.flowId ?? activeStep.flowId, activeStep.stepId, targetExpectation.id);
|
|
3676
|
+
if (statusVal === "pass") validateReviewableGateEvidence(dir, slug, evidenceRefs, producer, `gate claim ${targetExpectation.id}`);
|
|
3677
|
+
if (mustRunTests) requireObservedCommandRefs(evidenceRefs, observedCommandNames, "a passing tests-evidence claim", true);
|
|
3089
3678
|
|
|
3090
3679
|
if (evidenceRefs.length > 0) {
|
|
3091
3680
|
check.artifact_refs = evidenceRefs;
|
|
3092
3681
|
}
|
|
3682
|
+
check._producer = producer.id;
|
|
3683
|
+
check._recorded_by = gateClaimActorKey;
|
|
3684
|
+
if (producer.selfProducedTrustSlices.length > 0) check._producer_self_produced_trust_slices = producer.selfProducedTrustSlices;
|
|
3093
3685
|
|
|
3094
3686
|
// #270(b)/#412: --command gives a gate claim a real, runnable execution.label distinct from
|
|
3095
3687
|
// the human --summary prose, so stop-goal-fit.js's bundleClaimedPassCommandChecks section (B)
|
|
@@ -3097,14 +3689,11 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
3097
3689
|
// prose itself), which it would otherwise execute as a shell command under
|
|
3098
3690
|
// FLOW_AGENTS_GOAL_FIT_RECHECK. Validated at record time with the same isRunnableCommandText
|
|
3099
3691
|
// heuristic the Stop-hook backstop uses (single-sourced — see loadRunnableCommandHelper).
|
|
3100
|
-
|
|
3101
|
-
if (
|
|
3102
|
-
const { isRunnableCommandText } = loadRunnableCommandHelper();
|
|
3103
|
-
if (!isRunnableCommandText(gateCommand)) die(`record-gate-claim --command "${gateCommand}" is not a runnable shell command — prose belongs in --summary, which is never executed.`);
|
|
3104
|
-
check.command = gateCommand;
|
|
3105
|
-
}
|
|
3692
|
+
if (gateCommand) check.command = gateCommand;
|
|
3693
|
+
if (observedCommands.length > 0) check._observed_commands = observedCommands;
|
|
3106
3694
|
|
|
3107
3695
|
const checkNormalized = normalizeCheck(check, /* allowGateClaimPrefix */ true);
|
|
3696
|
+
if (outputSha256) checkNormalized._output_sha256 = outputSha256;
|
|
3108
3697
|
// WS8 (ADR 0020): honor the accepted-gap waiver flags for a gate claim too.
|
|
3109
3698
|
const gateWaiver = parseWaiver(p, ts);
|
|
3110
3699
|
if (gateWaiver) checkNormalized._waiver = gateWaiver;
|
|
@@ -3116,8 +3705,16 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
3116
3705
|
// SAME expectation id supersedes the earlier check for that expectation (mergeChecksById); a
|
|
3117
3706
|
// gate claim against a different expectation is additive.
|
|
3118
3707
|
const _existingState = readBundleState(dir);
|
|
3708
|
+
const criteria = mustRunTests ? completePassingCriteria(_existingState.criteria, opts(p, "criterion-json"), observedCommandNames, ts) : _existingState.criteria;
|
|
3709
|
+
if (mustRunTests) {
|
|
3710
|
+
const liveCritiques = _existingState.critiques.filter((critique) => !critique.superseded_by);
|
|
3711
|
+
if (liveCritiques.length === 0 || liveCritiques.some((critique) => !critiqueIsCleanAndCurrent(dir, critique))) {
|
|
3712
|
+
die("a passing tests-evidence claim requires a current clean critique first");
|
|
3713
|
+
}
|
|
3714
|
+
for (const criterion of criteria) validateReviewableGateEvidence(dir, slug, criterion.evidence_refs, producer, `criterion ${criterion.id}`);
|
|
3715
|
+
}
|
|
3119
3716
|
const _mergedChecks = mergeChecksById(_existingState.checks, [checkNormalized]);
|
|
3120
|
-
assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks,
|
|
3717
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, criteria, _existingState.critiques, gateClaimActorKey, exactFlowContext));
|
|
3121
3718
|
return 0;
|
|
3122
3719
|
}
|
|
3123
3720
|
|
|
@@ -3299,12 +3896,47 @@ export function normalizeFinding(raw: AnyObj): AnyObj {
|
|
|
3299
3896
|
async function recordCritique(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
3300
3897
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
3301
3898
|
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
const
|
|
3305
|
-
|
|
3306
|
-
const
|
|
3307
|
-
const
|
|
3899
|
+
const verdict = opt(p, "verdict");
|
|
3900
|
+
if (!critiqueStatuses.has(verdict)) die("record-critique requires --verdict pass, fail, or not_verified");
|
|
3901
|
+
const summary = opt(p, "summary");
|
|
3902
|
+
if (!hasNonEmptyString(summary)) die("record-critique requires --summary");
|
|
3903
|
+
const lanes = normalizeCritiqueLanes(opts(p, "lane-json"));
|
|
3904
|
+
const reviewArtifacts = reviewTargetArtifacts(dir, opts(p, "artifact-ref"), "record-critique review_target");
|
|
3905
|
+
if (verdict === "pass" && (lanes.some((lane) => lane.status !== "pass") || reviewArtifacts.length === 0)) {
|
|
3906
|
+
die("a passing critique requires every lane to pass and at least one local reviewed --artifact-ref");
|
|
3907
|
+
}
|
|
3908
|
+
const sidecarState = loadJson(path.join(dir, "state.json"));
|
|
3909
|
+
const projectedRun = sidecarState.flow_run && typeof sidecarState.flow_run === "object" && !Array.isArray(sidecarState.flow_run)
|
|
3910
|
+
? sidecarState.flow_run as AnyObj
|
|
3911
|
+
: null;
|
|
3912
|
+
const exactFlowContext = projectedRun
|
|
3913
|
+
&& typeof projectedRun.definition_id === "string"
|
|
3914
|
+
&& typeof projectedRun.current_step === "string"
|
|
3915
|
+
? { flowId: projectedRun.definition_id, stepId: projectedRun.current_step }
|
|
3916
|
+
: undefined;
|
|
3917
|
+
// trust.bundle is the sole critique source. critique.json is unsupported historical residue.
|
|
3918
|
+
const _critiqueState = readBundleState(dir);
|
|
3919
|
+
const bundleCritiques = _critiqueState.critiques;
|
|
3920
|
+
const critiqueId = opt(p, "id", "review");
|
|
3921
|
+
if (!safeCritiqueId.test(critiqueId)) die("record-critique --id must be a safe identifier");
|
|
3922
|
+
const critique = {
|
|
3923
|
+
id: critiqueId,
|
|
3924
|
+
reviewer: opt(p, "reviewer", "tool-code-reviewer"),
|
|
3925
|
+
reviewed_at: opt(p, "timestamp", new Date().toISOString()),
|
|
3926
|
+
identity_version: 2,
|
|
3927
|
+
verdict,
|
|
3928
|
+
summary,
|
|
3929
|
+
lanes,
|
|
3930
|
+
review_target: {
|
|
3931
|
+
artifacts: reviewArtifacts,
|
|
3932
|
+
workspace_snapshot: captureReviewWorkspaceSnapshot(
|
|
3933
|
+
canonicalProjectRootForSession(dir),
|
|
3934
|
+
reviewArtifacts.map((artifact) => ({ file: String(artifact.file), sha256: String(artifact.sha256) })),
|
|
3935
|
+
),
|
|
3936
|
+
},
|
|
3937
|
+
artifact_refs: reviewArtifacts.map((artifact) => artifact.file),
|
|
3938
|
+
findings: opts(p, "finding-json").map((v) => normalizeFinding(parseJson(v, "--finding-json"))),
|
|
3939
|
+
};
|
|
3308
3940
|
if (critique.verdict === "pass" && critique.findings.some((f: AnyObj) => f.status === "open")) die("required critique must pass");
|
|
3309
3941
|
// #267/#282: supersede-by-critique-id. The latest write for a critique id wins for
|
|
3310
3942
|
// reconcile / status / validator purposes; each prior LIVE write for the same id is RETAINED as
|
|
@@ -3329,8 +3961,7 @@ async function recordCritique(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
3329
3961
|
// checksFromBundle + acceptance.json read inline — this is the exact pattern readBundleState
|
|
3330
3962
|
// already exists to share; recordLearning uses it directly (see below) and recordCritique
|
|
3331
3963
|
// previously duplicated it by hand for no reason.
|
|
3332
|
-
|
|
3333
|
-
assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueState.checks, _critiqueState.criteria, critiques));
|
|
3964
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueState.checks, _critiqueState.criteria, critiques, undefined, exactFlowContext));
|
|
3334
3965
|
return 0;
|
|
3335
3966
|
}
|
|
3336
3967
|
function frontmatter(text: string, key: string): string {
|
|
@@ -3353,7 +3984,22 @@ async function importCritique(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
3353
3984
|
const title = m.groups?.title ?? "finding";
|
|
3354
3985
|
findings.push({ id: slugify(title, `finding-${findings.length + 1}`), severity: (m.groups?.severity ?? "info").toLowerCase(), status: opt(p, "finding-status", verdict === "pass" ? "fixed" : "open"), description: title, file_refs: [m.groups?.target ?? review] });
|
|
3355
3986
|
}
|
|
3356
|
-
const
|
|
3987
|
+
const importedArtifact = path.relative(canonicalProjectRootForSession(dir), review);
|
|
3988
|
+
const parsed = {
|
|
3989
|
+
...p,
|
|
3990
|
+
positional: [dir],
|
|
3991
|
+
opts: {
|
|
3992
|
+
...p.opts,
|
|
3993
|
+
id: [slugify(path.basename(review).replace(/\.md$/, ""), "review")],
|
|
3994
|
+
reviewer: ["tool-code-reviewer"],
|
|
3995
|
+
verdict: [verdict],
|
|
3996
|
+
summary: [`Imported critique from ${path.basename(review)}`],
|
|
3997
|
+
"artifact-ref": [importedArtifact],
|
|
3998
|
+
"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." }] })],
|
|
3999
|
+
"finding-json": findings.map((f) => JSON.stringify(f)),
|
|
4000
|
+
},
|
|
4001
|
+
flags: p.flags,
|
|
4002
|
+
};
|
|
3357
4003
|
const result = await recordCritique(parsed);
|
|
3358
4004
|
if (verdict !== "pass") die("required critique must pass");
|
|
3359
4005
|
return result;
|
|
@@ -4774,10 +5420,7 @@ function evidenceClean(dir: string): boolean {
|
|
|
4774
5420
|
});
|
|
4775
5421
|
}
|
|
4776
5422
|
function critiqueClean(dir: string): boolean {
|
|
4777
|
-
//
|
|
4778
|
-
// legacy (pre-bundle-era) sessions — unrelated to origin stamping. When a trust.bundle IS
|
|
4779
|
-
// present, every claim must be stamped (requireStampedClaim); no claimType-derivation fallback
|
|
4780
|
-
// for an unstamped claim (#268/#344).
|
|
5423
|
+
// trust.bundle is the sole critique artifact. Legacy critique.json must not influence gates.
|
|
4781
5424
|
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
4782
5425
|
if (Array.isArray(bundle.claims)) {
|
|
4783
5426
|
for (const c of bundle.claims) requireStampedClaim(c, dir);
|
|
@@ -4788,14 +5431,11 @@ function critiqueClean(dir: string): boolean {
|
|
|
4788
5431
|
return claimOrigin(c) === "critique";
|
|
4789
5432
|
});
|
|
4790
5433
|
if (critiqueClaims.length === 0) return false; // no critique written yet
|
|
4791
|
-
return
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
});
|
|
5434
|
+
return critiquesFromBundle(dir)
|
|
5435
|
+
.filter((critique) => !critique.superseded_by)
|
|
5436
|
+
.every((critique) => critiqueIsCleanAndCurrent(dir, critique));
|
|
4795
5437
|
}
|
|
4796
|
-
|
|
4797
|
-
const c = loadJson(path.join(dir, "critique.json"), {});
|
|
4798
|
-
return c.status === "pass" && Array.isArray(c.critiques) && c.critiques.every((x: AnyObj) => x.verdict !== "fail" && (!Array.isArray(x.findings) || x.findings.every((f: AnyObj) => f.status !== "open" && (f.file_refs === undefined || Array.isArray(f.file_refs)))));
|
|
5438
|
+
return false;
|
|
4799
5439
|
}
|
|
4800
5440
|
function assertExistingLearningValid(dir: string): void {
|
|
4801
5441
|
const file = path.join(dir, "learning.json");
|
|
@@ -4840,8 +5480,7 @@ async function dogfoodPass(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
4840
5480
|
for (const value of opts(p, "finding-json")) normalizeFinding(parseJson(value, "--finding-json"));
|
|
4841
5481
|
if (newCritiqueVerdict !== "pass") die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
|
|
4842
5482
|
if (!opt(p, "critique-id") && !critiqueClean(dir)) die("requires passing critique");
|
|
4843
|
-
|
|
4844
|
-
if (!critiqueClean(dir) && (fs.existsSync(path.join(dir, "trust.bundle")) || fs.existsSync(path.join(dir, "critique.json")))) die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
|
|
5483
|
+
if (!critiqueClean(dir) && fs.existsSync(path.join(dir, "trust.bundle"))) die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
|
|
4845
5484
|
}
|
|
4846
5485
|
}
|
|
4847
5486
|
const learningRecords = opts(p, "learning-record-json").map((v) => normalizeLearning(parseJson(v, "--learning-record-json"), opt(p, "timestamp", now())));
|
|
@@ -5161,7 +5800,7 @@ async function gateReview(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
5161
5800
|
// Locate trust.bundle — required per SKILL.md contract
|
|
5162
5801
|
const bundlePath = path.join(dir, "trust.bundle");
|
|
5163
5802
|
if (!fs.existsSync(bundlePath)) {
|
|
5164
|
-
process.stderr.write(`[gate-review] trust.bundle absent at ${bundlePath} — NOT_VERIFIED.
|
|
5803
|
+
process.stderr.write(`[gate-review] trust.bundle absent at ${bundlePath} — NOT_VERIFIED. Record the required trust bundle before running gate review.\n`);
|
|
5165
5804
|
return 1;
|
|
5166
5805
|
}
|
|
5167
5806
|
|
|
@@ -5908,7 +6547,11 @@ Available claim ids:
|
|
|
5908
6547
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
5909
6548
|
|
|
5910
6549
|
|
|
5911
|
-
export
|
|
6550
|
+
export function mainFromPublicWorkflow(argv: string[]): Promise<number> {
|
|
6551
|
+
return main(argv, PUBLIC_WORKFLOW_AUTHORITY);
|
|
6552
|
+
}
|
|
6553
|
+
|
|
6554
|
+
export async function main(argv: string[] = process.argv.slice(2), authority?: symbol): Promise<number> {
|
|
5912
6555
|
const _rawArgv = argv;
|
|
5913
6556
|
// #380: `record-check <dir> -- <command...>` — argv after the FIRST literal `--` token is the
|
|
5914
6557
|
// command to execute verbatim (never option-parsed: a command like `npm test -- --watch`
|
|
@@ -5948,7 +6591,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<numb
|
|
|
5948
6591
|
: p.command === "record-agent-event" ? explicitArtifactRoot(p) : p.command === "claim" ? (p.positional[1] ? path.resolve(p.positional[1]) : "") : p.command === "resolve-slug" ? "" : (isLivenessWhoami || isLivenessVerdict) ? "" : p.positional[0] ? artifactDirFrom(p.positional[0]) : "";
|
|
5949
6592
|
return withLock(lockRoot, ["ensure-session", "record-agent-event", "dogfood-pass"].includes(p.command), p.command, () => {
|
|
5950
6593
|
switch (p.command) {
|
|
5951
|
-
case "ensure-session": return ensureSession(p);
|
|
6594
|
+
case "ensure-session": return ensureSession(p, authority === PUBLIC_WORKFLOW_AUTHORITY);
|
|
5952
6595
|
case "current": return current(p);
|
|
5953
6596
|
case "record-agent-event": return recordAgentEvent(p);
|
|
5954
6597
|
case "init-plan": return initPlan(p);
|