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