@kontourai/flow-agents 3.3.0 → 3.4.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/add-to-project.yml +15 -0
- package/.github/workflows/ci.yml +161 -0
- package/CHANGELOG.md +41 -0
- package/CONTEXT.md +5 -1
- package/README.md +19 -8
- package/build/src/builder-flow-run-adapter.d.ts +80 -0
- package/build/src/builder-flow-run-adapter.js +241 -0
- package/build/src/builder-flow-runtime.d.ts +16 -0
- package/build/src/builder-flow-runtime.js +290 -0
- package/build/src/cli/builder-run.d.ts +1 -0
- package/build/src/cli/builder-run.js +27 -0
- package/build/src/cli/effective-backlog-settings.js +70 -2
- package/build/src/cli/init.d.ts +34 -0
- package/build/src/cli/init.js +341 -61
- package/build/src/cli/kit.js +55 -12
- package/build/src/cli/pull-work-provider.js +346 -5
- package/build/src/cli/skill-drift-check.d.ts +1 -0
- package/build/src/cli/skill-drift-check.js +165 -0
- package/build/src/cli/telemetry-doctor.d.ts +37 -0
- package/build/src/cli/telemetry-doctor.js +53 -6
- package/build/src/cli/validate-hook-influence.js +37 -7
- package/build/src/cli/workflow-sidecar.d.ts +93 -8
- package/build/src/cli/workflow-sidecar.js +1175 -158
- package/build/src/cli.js +5 -0
- package/build/src/flow-kit/validate.d.ts +54 -34
- package/build/src/flow-kit/validate.js +237 -26
- package/build/src/index.d.ts +2 -0
- package/build/src/index.js +1 -0
- package/build/src/lib/console-connect-options.d.ts +97 -0
- package/build/src/lib/console-connect-options.js +199 -0
- package/build/src/lib/console-telemetry-validate.d.ts +49 -0
- package/build/src/lib/console-telemetry-validate.js +91 -0
- package/build/src/lib/flow-resolver.d.ts +54 -1
- package/build/src/lib/flow-resolver.js +112 -5
- package/build/src/lib/fs.d.ts +17 -0
- package/build/src/lib/fs.js +172 -0
- package/build/src/lib/local-artifact-root.d.ts +44 -1
- package/build/src/lib/local-artifact-root.js +131 -3
- package/build/src/runtime-adapters.d.ts +39 -3
- package/build/src/runtime-adapters.js +77 -31
- package/build/src/tools/build-universal-bundles.js +40 -2
- package/build/src/tools/codex-agent-routing.d.ts +2 -0
- package/build/src/tools/codex-agent-routing.js +49 -0
- package/build/src/tools/generate-context-map.js +1 -0
- package/build/src/tools/validate-source-tree.js +27 -1
- package/context/scripts/hooks/lib/kit-catalog.js +235 -0
- package/context/scripts/hooks/lib/runnable-command.js +177 -0
- package/context/scripts/hooks/stop-goal-fit.js +278 -48
- package/context/scripts/hooks/workflow-steering.js +121 -21
- package/context/scripts/package.json +3 -0
- package/context/scripts/telemetry/install-console-config.sh +25 -4
- package/context/scripts/telemetry/lib/config.sh +102 -12
- package/context/scripts/telemetry/lib/pricing.sh +50 -0
- package/context/scripts/telemetry/lib/session.sh +3 -0
- package/context/scripts/telemetry/lib/transport.sh +87 -0
- package/context/scripts/telemetry/lib/usage.sh +205 -4
- package/context/scripts/telemetry/telemetry.conf +6 -0
- package/context/scripts/telemetry/telemetry.sh +48 -0
- package/context/settings/workspace-backlog-provider-settings.example.json +48 -0
- package/docs/agent-usage-feedback-loop.md +35 -0
- package/docs/architecture-engine-and-kits.md +110 -0
- package/docs/context-map.md +2 -0
- package/docs/decisions/embeddable-engine.md +152 -0
- package/docs/decisions/index.md +3 -1
- package/docs/decisions/trust-ledger-retention.md +88 -0
- package/docs/decisions/workflow-enforcement.md +31 -9
- package/docs/fixture-ownership.md +3 -0
- package/docs/implementing-trust-reconciliation.md +129 -0
- package/docs/index.md +19 -9
- package/docs/integrations/flow-agents-console.md +167 -0
- package/docs/kit-authoring-guide.md +52 -21
- package/docs/spec/builder-flow-runtime.md +80 -0
- package/docs/spec/runtime-hook-surface.md +45 -1
- package/docs/specs/economics-record-contract.md +270 -0
- package/docs/specs/harness-capability-matrix.md +74 -0
- package/docs/specs/learning-review-proposals-contract.md +340 -0
- package/docs/specs/routing-efficiency-review.md +59 -0
- package/docs/verifiable-trust.md +74 -25
- package/docs/workflow-usage-guide.md +10 -0
- package/evals/acceptance/prove-capture-teeth.sh +132 -0
- package/evals/ci/antigaming-suite.sh +1 -0
- package/evals/ci/run-baseline.sh +72 -4
- package/evals/fixtures/economics/acceptance.json +12 -0
- package/evals/fixtures/economics/agents/tool-worker-1/events.jsonl +2 -0
- package/evals/fixtures/economics/agents/tool-worker-2/events.jsonl +2 -0
- package/evals/fixtures/economics/agents/tool-worker-3/events.jsonl +2 -0
- package/evals/fixtures/economics/agents/tool-worker-4/events.jsonl +1 -0
- package/evals/fixtures/economics/agents/tool-worker-5/events.jsonl +2 -0
- package/evals/fixtures/economics/critique.json +22 -0
- package/evals/fixtures/economics/expected-record.json +71 -0
- package/evals/fixtures/economics/session-usage-event.json +1 -0
- package/evals/fixtures/economics/state.json +11 -0
- package/evals/fixtures/economics/transcript.jsonl +3 -0
- package/evals/fixtures/hook-influence/cases.json +7 -7
- package/evals/fixtures/learning-review-proposals/balanced/economics.jsonl +6 -0
- package/evals/fixtures/learning-review-proposals/effect-follow-up/economics.jsonl +5 -0
- package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-1/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-2/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-3/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-4/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-5/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/economics.jsonl +6 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/expected-aggregates.json +30 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/expected-aggregates.md +66 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-1/gate-review.inquiries.json +26 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-1/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-2/gate-review.inquiries.json +26 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-2/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-3/gate-review.inquiries.json +26 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-3/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-4/gate-review.inquiries.json +26 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-4/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-5/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-6/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/repeat-window/economics.jsonl +6 -0
- package/evals/fixtures/learning-review-proposals/under-threshold/economics.jsonl +3 -0
- package/evals/fixtures/telemetry/usage-transcript-sample.jsonl +4 -0
- package/evals/fixtures/trust-reconcile-exploits/mcp-degrade.json +42 -0
- package/evals/integration/test_builder_entry_enforcement.sh +241 -0
- package/evals/integration/test_builder_step_producers.sh +18 -10
- package/evals/integration/test_bundle_install.sh +172 -0
- package/evals/integration/test_console_tenant_isolation.sh +167 -0
- package/evals/integration/test_critique_supersession_roundtrip.sh +4 -1
- package/evals/integration/test_dual_emit_flow_step.sh +10 -4
- package/evals/integration/test_economics_record.sh +674 -0
- package/evals/integration/test_effective_backlog_settings.sh +1 -1
- package/evals/integration/test_evidence_capture_hook.sh +17 -2
- package/evals/integration/test_exemption_usage_review.sh +198 -0
- package/evals/integration/test_fixture_retirement_audit.sh +2 -2
- package/evals/integration/test_flow_kit_install_git.sh +83 -0
- package/evals/integration/test_flowdef_session_activation.sh +0 -1
- package/evals/integration/test_flowdef_session_history_preservation.sh +13 -3
- package/evals/integration/test_gate_lockdown.sh +7 -0
- package/evals/integration/test_gate_review_inquiry_records.sh +9 -1
- package/evals/integration/test_goal_fit_hook.sh +2031 -0
- package/evals/integration/test_hook_category_behaviors.sh +8 -1
- package/evals/integration/test_hook_influence_cases.sh +25 -1
- package/evals/integration/test_install_merge.sh +227 -2
- package/evals/integration/test_kit_conformance_levels.sh +6 -6
- package/evals/integration/test_learning_review_proposals.sh +329 -0
- package/evals/integration/test_liveness_conflict_injection.sh +26 -22
- package/evals/integration/test_liveness_console_relay.sh +166 -0
- package/evals/integration/test_liveness_heartbeat.sh +17 -17
- package/evals/integration/test_liveness_worktree_root.sh +575 -0
- package/evals/integration/test_phase_map_and_gate_claim.sh +6 -1
- package/evals/integration/test_publish_delivery.sh +331 -1
- package/evals/integration/test_pull_work_board.sh +200 -0
- package/evals/integration/test_pull_work_provider.sh +1 -1
- package/evals/integration/test_record_check.sh +378 -0
- package/evals/integration/test_routing_efficiency.sh +71 -0
- package/evals/integration/test_runtime_adapter_activation.sh +28 -0
- package/evals/integration/test_session_resume_roundtrip.sh +16 -19
- package/evals/integration/test_skill_drift_check.sh +870 -0
- package/evals/integration/test_telemetry.sh +445 -0
- package/evals/integration/test_telemetry_doctor.sh +66 -0
- package/evals/integration/test_telemetry_usage_pipeline.sh +228 -0
- package/evals/integration/test_trust_reconcile_negatives.sh +30 -13
- package/evals/integration/test_trust_reconcile_trailer_diagnostic.sh +247 -0
- package/evals/integration/test_usage_cost.sh +61 -0
- package/evals/integration/test_workflow_sidecar_writer.sh +1395 -0
- package/evals/integration/test_workflow_steering_hook.sh +157 -16
- package/evals/integration/test_workspace_settings.sh +176 -0
- package/evals/lib/env.sh +26 -0
- package/evals/lib/node.sh +8 -0
- package/evals/run.sh +29 -0
- package/evals/static/test_ci_integration_coverage.sh +115 -0
- package/evals/static/test_declared_scope_forms_documented.sh +114 -0
- package/evals/static/test_universal_bundles.sh +34 -0
- package/evals/static/test_validate_source_kit_asset_scope.sh +259 -0
- package/evals/static/test_workflow_skills.sh +1 -1
- package/kits/builder/flows/build.flow.json +9 -18
- package/kits/builder/flows/publish-learn.flow.json +5 -1
- package/kits/builder/kit.json +120 -0
- package/kits/builder/skills/deliver/SKILL.md +42 -0
- package/kits/builder/skills/evidence-gate/SKILL.md +12 -0
- package/kits/builder/skills/execute-plan/SKILL.md +9 -0
- package/kits/builder/skills/learning-review/SKILL.md +51 -0
- package/kits/builder/skills/plan-work/SKILL.md +17 -20
- package/kits/builder/skills/pull-work/SKILL.md +21 -0
- package/kits/builder/skills/release-readiness/SKILL.md +12 -0
- package/kits/knowledge/kit.json +9 -0
- package/kits/veritas-governance/docs/README.md +35 -7
- package/kits/veritas-governance/fixtures/exemption-review/mixed-fresh-stale.DECLARED.json +14 -0
- package/kits/veritas-governance/kit.json +14 -0
- package/kits/veritas-governance/skills/exemption-usage-review/SKILL.md +128 -0
- package/kits/veritas-governance/skills/exemption-usage-review/review-exemptions.mjs +231 -0
- package/package.json +2 -2
- package/packaging/manifest.json +29 -0
- package/schemas/backlog-provider-settings.schema.json +13 -0
- package/schemas/workflow-state.schema.json +44 -0
- package/scripts/README.md +4 -0
- package/scripts/check-content-boundary.cjs +8 -1
- package/scripts/ci/trust-reconcile.js +136 -0
- package/scripts/hooks/codex-hook-adapter.js +77 -2
- package/scripts/hooks/evidence-capture.js +38 -5
- package/scripts/hooks/lib/codex-exit-code.js +316 -0
- package/scripts/hooks/lib/kit-catalog.js +235 -0
- package/scripts/hooks/lib/liveness-write.js +28 -1
- package/scripts/hooks/lib/local-artifact-paths.js +97 -1
- package/scripts/hooks/lib/runnable-command.js +177 -0
- package/scripts/hooks/lib/skill-drift.js +350 -0
- package/scripts/hooks/stop-goal-fit.js +278 -48
- package/scripts/hooks/workflow-steering.js +121 -21
- package/scripts/install-codex-home.sh +97 -47
- package/scripts/install-merge.js +72 -14
- package/scripts/install-owned-files.js +178 -0
- package/scripts/liveness/relay.sh +84 -0
- package/scripts/telemetry/economics-record.schema.json +145 -0
- package/scripts/telemetry/economics-record.sh +331 -0
- package/scripts/telemetry/install-console-config.sh +25 -4
- package/scripts/telemetry/learning-review-decide.sh +124 -0
- package/scripts/telemetry/learning-review-proposals.schema.json +161 -0
- package/scripts/telemetry/learning-review-proposals.sh +484 -0
- package/scripts/telemetry/lib/config.sh +102 -12
- package/scripts/telemetry/lib/pricing.sh +14 -6
- package/scripts/telemetry/lib/session.sh +3 -0
- package/scripts/telemetry/lib/transport.sh +133 -15
- package/scripts/telemetry/lib/usage.sh +121 -28
- package/scripts/telemetry/routing-efficiency.sh +0 -0
- package/scripts/telemetry/telemetry.conf +6 -0
- package/scripts/telemetry/telemetry.sh +48 -0
- package/src/builder-flow-run-adapter.ts +357 -0
- package/src/builder-flow-runtime.ts +348 -0
- package/src/cli/builder-flow-run-adapter.test.mjs +495 -0
- package/src/cli/builder-flow-runtime.test.mjs +213 -0
- package/src/cli/builder-run.ts +28 -0
- package/src/cli/codex-agent-routing.test.mjs +44 -0
- package/src/cli/codex-exit-code.test.mjs +207 -0
- package/src/cli/console-connect-options.test.mjs +329 -0
- package/src/cli/console-telemetry-validate.test.mjs +157 -0
- package/src/cli/effective-backlog-settings.ts +68 -2
- package/src/cli/flow-resolver-composition.test.mjs +72 -0
- package/src/cli/init.test.mjs +161 -0
- package/src/cli/init.ts +407 -62
- package/src/cli/kit-metadata-security.test.mjs +443 -0
- package/src/cli/kit.ts +50 -12
- package/src/cli/pull-work-provider.ts +377 -3
- package/src/cli/sidecar-pure-helpers.test.mjs +64 -0
- package/src/cli/skill-drift-check.ts +196 -0
- package/src/cli/telemetry-doctor.test.mjs +53 -0
- package/src/cli/telemetry-doctor.ts +50 -7
- package/src/cli/validate-hook-influence.ts +37 -6
- package/src/cli/workflow-sidecar.ts +1150 -151
- package/src/cli.ts +5 -0
- package/src/flow-kit/validate.ts +277 -38
- package/src/index.ts +19 -0
- package/src/lib/console-connect-options.ts +261 -0
- package/src/lib/console-telemetry-validate.ts +88 -0
- package/src/lib/flow-resolver.ts +117 -4
- package/src/lib/fs.ts +160 -0
- package/src/lib/local-artifact-root.ts +129 -3
- package/src/runtime-adapters.ts +113 -33
- package/src/tools/build-universal-bundles.ts +36 -2
- package/src/tools/codex-agent-routing.ts +48 -0
- package/src/tools/generate-context-map.ts +1 -0
- package/src/tools/validate-source-tree.ts +26 -1
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
-
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
6
6
|
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, resolveFlowFilePath, resolvePhaseMap, resolveRouteBackPolicy, type ActiveFlowStep } from "../lib/flow-resolver.js";
|
|
10
|
+
import { resolveActiveFlowStep, resolveAllFlowGateExpects, resolveFlowFilePath, resolvePhaseMap, resolveRouteBackPolicy, type ActiveFlowStep } from "../lib/flow-resolver.js";
|
|
11
11
|
import { defaultArtifactRootForRead, flowAgentsArtifactRoot } from "../lib/local-artifact-root.js";
|
|
12
|
+
import { syncBuilderFlowSessionIfPresent } from "../builder-flow-runtime.js";
|
|
12
13
|
// #291 Wave 1 Task 1.1 exports: ensure-session's ownership guard reuses the EXACT same
|
|
13
14
|
// assignment ⋈ liveness join / claim / supersede logic #290 already ships for the
|
|
14
15
|
// `assignment-provider` CLI, rather than reimplementing a second, parallel join (static ESM
|
|
@@ -56,6 +57,40 @@ function workItemSlug(ref: string): string {
|
|
|
56
57
|
return slugify(`${owner}-${repo}-${id}`, "work-item");
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
type SessionWorkItem = {
|
|
61
|
+
ref: string;
|
|
62
|
+
localRecord?: AnyObj;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function sessionWorkItem(p: ReturnType<typeof parseArgs>, slug: string, dir: string): SessionWorkItem {
|
|
66
|
+
const providerRef = opt(p, "work-item");
|
|
67
|
+
if (providerRef) {
|
|
68
|
+
return { ref: providerRef };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const existingState = loadJson(path.join(dir, "state.json"));
|
|
72
|
+
if (Array.isArray(existingState.work_item_refs) && typeof existingState.work_item_refs[0] === "string") {
|
|
73
|
+
return { ref: existingState.work_item_refs[0] };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const title = opt(p, "title", slug).trim() || slug;
|
|
77
|
+
const body = opt(p, "summary").trim();
|
|
78
|
+
return {
|
|
79
|
+
ref: `local:${slug}`,
|
|
80
|
+
localRecord: {
|
|
81
|
+
id: slug,
|
|
82
|
+
title,
|
|
83
|
+
...(body ? { body } : {}),
|
|
84
|
+
status: "ready",
|
|
85
|
+
source_provider: {
|
|
86
|
+
kind: "local",
|
|
87
|
+
path: "work-item.json",
|
|
88
|
+
},
|
|
89
|
+
artifact_refs: ["state.json", "handoff.json"],
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
59
94
|
/** Pure, lock-free, side-effect-free CLI wrapper around workItemSlug() — the single source of
|
|
60
95
|
* truth for the deterministic subjectId/session-directory-name slug. Named resolveSlugCmd (not
|
|
61
96
|
* resolveSlug) to avoid colliding with any future export named resolveSlug. */
|
|
@@ -516,6 +551,62 @@ function critiqueToEventStatus(verdict: string, findings: AnyObj[]): string | nu
|
|
|
516
551
|
return null; // not_verified or unknown → no event → Surface returns "unknown"
|
|
517
552
|
}
|
|
518
553
|
|
|
554
|
+
/**
|
|
555
|
+
* Fold a raw command-log (command-log.jsonl entries) into a per-command classification,
|
|
556
|
+
* keyed by normalized command text (whitespace-collapsed, trimmed). Pure and side-effect-free
|
|
557
|
+
* so it is directly unit-testable (#470 iteration 2, finding #2 — HIGH: the prior inline reducer
|
|
558
|
+
* collapsed every non-"fail" `observedResult` — including "ambiguous" — to "pass", feeding a
|
|
559
|
+
* verified event + `passing:true` and reintroducing the #470 false-pass through the trust-bundle
|
|
560
|
+
* reconciliation path).
|
|
561
|
+
*
|
|
562
|
+
* Three-way classification, keyed on `observedResult` (never re-derived from `exitCode` alone,
|
|
563
|
+
* which would miscoerce the #362 grep/diff absence carve-out `ambiguous,exitCode:1` entry to
|
|
564
|
+
* `fail`):
|
|
565
|
+
* - "fail" when `observedResult==="fail"`, or (legacy, no observedResult) a nonzero
|
|
566
|
+
* integer `exitCode`.
|
|
567
|
+
* - "ambiguous" when `observedResult==="ambiguous"`, or (legacy) `exitCode` is `null` with no
|
|
568
|
+
* fail signal.
|
|
569
|
+
* - "pass" when `observedResult==="pass"`, or (legacy) `exitCode===0`.
|
|
570
|
+
*
|
|
571
|
+
* Precedence across repeated entries for the same command: fail > pass > ambiguous. A genuine
|
|
572
|
+
* exit-0 pass is positive evidence and confirms; ambiguous holds only when there is neither a
|
|
573
|
+
* fail nor a positive pass, since a "pass" always requires positive evidence.
|
|
574
|
+
*
|
|
575
|
+
* The caller (buildTrustBundle) maps "ambiguous" onto the existing canonical non-confirming
|
|
576
|
+
* status `not_verified` — consume-never-fork, mirroring record-check's identical mapping — so
|
|
577
|
+
* `checkStatusToEventStatus("not_verified")` returns null (no verification event emitted) and the
|
|
578
|
+
* evidence item is stamped `passing:false`. See Decision/finding #2 in the iteration-2 plan.
|
|
579
|
+
*/
|
|
580
|
+
export function reduceCaptureLogByCommand(commandLog: AnyObj[] | undefined): Map<string, { observedResult: "pass" | "fail" | "ambiguous"; exitCode: number | null }> {
|
|
581
|
+
const captureByCommand = new Map<string, { observedResult: "pass" | "fail" | "ambiguous"; exitCode: number | null }>();
|
|
582
|
+
for (const entry of Array.isArray(commandLog) ? commandLog : []) {
|
|
583
|
+
if (!entry || typeof entry.command !== "string") continue;
|
|
584
|
+
const key = entry.command.replace(/\s+/g, " ").trim();
|
|
585
|
+
if (!key) continue;
|
|
586
|
+
const exitCode = Number.isInteger(entry.exitCode) ? (entry.exitCode as number) : null;
|
|
587
|
+
let result: "pass" | "fail" | "ambiguous";
|
|
588
|
+
if (entry.observedResult === "fail" || (entry.observedResult === undefined && exitCode !== null && exitCode !== 0)) {
|
|
589
|
+
result = "fail";
|
|
590
|
+
} else if (entry.observedResult === "pass" || (entry.observedResult === undefined && exitCode === 0)) {
|
|
591
|
+
result = "pass";
|
|
592
|
+
} else {
|
|
593
|
+
// Covers observedResult==="ambiguous" AND the legacy no-observedResult, exitCode:null case.
|
|
594
|
+
result = "ambiguous";
|
|
595
|
+
}
|
|
596
|
+
const prev = captureByCommand.get(key);
|
|
597
|
+
let merged: "pass" | "fail" | "ambiguous" = result;
|
|
598
|
+
if (prev) {
|
|
599
|
+
// fail > pass > ambiguous precedence.
|
|
600
|
+
if (prev.observedResult === "fail" || result === "fail") merged = "fail";
|
|
601
|
+
else if (prev.observedResult === "pass" || result === "pass") merged = "pass";
|
|
602
|
+
else merged = "ambiguous";
|
|
603
|
+
}
|
|
604
|
+
const mergedExitCode = exitCode !== null ? exitCode : (prev ? prev.exitCode : null);
|
|
605
|
+
captureByCommand.set(key, { observedResult: merged, exitCode: mergedExitCode });
|
|
606
|
+
}
|
|
607
|
+
return captureByCommand;
|
|
608
|
+
}
|
|
609
|
+
|
|
519
610
|
/**
|
|
520
611
|
* Build a Hachure trust.bundle from raw check/criterion/critique inputs.
|
|
521
612
|
* trust.bundle is the PRIMARY artifact (ADR 0010 Phase 4a producer inversion).
|
|
@@ -544,6 +635,114 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
544
635
|
// legacy-fallback current.json read; omitted, this is IDENTICAL to pre-#291 behavior.
|
|
545
636
|
const activeStep: ActiveFlowStep | null = flowAgentsDir ? resolveActiveFlowStep(flowAgentsDir, actorKey) : null;
|
|
546
637
|
|
|
638
|
+
// #270 CRITICAL/HIGH fix: resolve the session's active_flow_id independent of whether the
|
|
639
|
+
// CURRENTLY-active step resolves — a stamped gate claim names the STEP IT WAS ORIGINALLY
|
|
640
|
+
// RECORDED AT (frozen in metadata.gate_claim.step_id), which is routinely a DIFFERENT step than
|
|
641
|
+
// whatever is active now. Validating the stamp against only the currently-active step's
|
|
642
|
+
// expects[] would reject every honest round-trip the moment the session advances past the step
|
|
643
|
+
// the gate claim was recorded at — the validation must be against the FULL flow definition
|
|
644
|
+
// (every step's gate expects[], via resolveAllFlowGateExpects), not just the active one.
|
|
645
|
+
const sessionFlowId: string | null = (() => {
|
|
646
|
+
if (!flowAgentsDir) return null;
|
|
647
|
+
try {
|
|
648
|
+
const pointer = loadCurrentPointerHelper().readCurrentPointer(flowAgentsDir, actorKey);
|
|
649
|
+
const payload = pointer.payload;
|
|
650
|
+
const fid = payload && typeof payload["active_flow_id"] === "string" ? payload["active_flow_id"] : null;
|
|
651
|
+
return fid && fid.length > 0 ? fid : null;
|
|
652
|
+
} catch {
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
})();
|
|
656
|
+
const workflowSubjectRef: string | null = (() => {
|
|
657
|
+
if (!flowAgentsDir) return null;
|
|
658
|
+
try {
|
|
659
|
+
const state = loadJson(path.join(flowAgentsDir, slug, "state.json"));
|
|
660
|
+
const refs = Array.isArray(state.work_item_refs) ? state.work_item_refs : [];
|
|
661
|
+
return refs.length === 1 && typeof refs[0] === "string" && refs[0].length > 0 ? refs[0] : null;
|
|
662
|
+
} catch {
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
})();
|
|
666
|
+
// repoRoot resolution mirrors resolveActiveFlowStep's own internal findRepoRoot(path.dirname(
|
|
667
|
+
// flowAgentsDir)) call exactly (flow-resolver.ts) — findRepoRootFromDir is this file's
|
|
668
|
+
// fallback-to-cwd equivalent of that unexported helper. NOTE (#270 MEDIUM fix, iteration 3):
|
|
669
|
+
// findRepoRootFromDir ALWAYS returns a truthy string (it falls back to process.cwd() — see
|
|
670
|
+
// findRepoRootFromDir's own doc comment above) whenever flowAgentsDir is set, so `flowRepoRoot`
|
|
671
|
+
// is only ever null when `!flowAgentsDir`. It can therefore never by itself signal a FlowDefinition
|
|
672
|
+
// load failure; that signal now comes from resolveAllFlowGateExpects's own null return (below).
|
|
673
|
+
const flowRepoRoot: string | null = flowAgentsDir ? findRepoRootFromDir(path.dirname(flowAgentsDir)) : null;
|
|
674
|
+
// Built lazily (only when at least one check actually carries a metadata.gate_claim stamp or
|
|
675
|
+
// gate-claim shape to validate) and cached across every check in this call's loop — the
|
|
676
|
+
// FlowDefinition file is read once per buildTrustBundle call, not once per claim.
|
|
677
|
+
//
|
|
678
|
+
// #270 MEDIUM fix (iteration 3): tri-state cache — `undefined` (not yet built), `null` (the
|
|
679
|
+
// FlowDefinition could not be loaded/parsed — see resolveAllFlowGateExpects's doc comment),
|
|
680
|
+
// or an array (loaded; possibly genuinely empty). This lets assertStampedGateClaimValid tell
|
|
681
|
+
// "cannot load the flow definition" apart from "loaded fine, tuple just doesn't match" — the
|
|
682
|
+
// previous code collapsed both into `[]`, which always failed the `.some()` match below and
|
|
683
|
+
// was misreported as "forged or corrupt" even when the real cause was an unloadable
|
|
684
|
+
// FlowDefinition (e.g. a bogus/renamed active_flow_id, or a `kits/` path that no longer
|
|
685
|
+
// resolves) — a `!flowRepoRoot` check can never catch this because flowRepoRoot is always
|
|
686
|
+
// truthy here (see the note above), so that check was dead code against this failure mode.
|
|
687
|
+
let _allGateExpectsCache: import("../lib/flow-resolver.js").FlowGateExpectsEntry[] | null | undefined = undefined;
|
|
688
|
+
const allGateExpectsForSession = (): import("../lib/flow-resolver.js").FlowGateExpectsEntry[] | null => {
|
|
689
|
+
if (_allGateExpectsCache !== undefined) return _allGateExpectsCache;
|
|
690
|
+
_allGateExpectsCache = (sessionFlowId && flowRepoRoot) ? resolveAllFlowGateExpects(sessionFlowId, flowRepoRoot) : null;
|
|
691
|
+
return _allGateExpectsCache;
|
|
692
|
+
};
|
|
693
|
+
/**
|
|
694
|
+
* Validate a RESTORED metadata.gate_claim stamp's (expectation_id, claim_type, subject_type,
|
|
695
|
+
* step_id) tuple against the session's FULL flow definition (every step's gate expects[], not
|
|
696
|
+
* just the currently-active step). This is the honest round-trip check: a claim recorded by
|
|
697
|
+
* record-gate-claim/buildTrustBundle can only ever carry a tuple that matches some real
|
|
698
|
+
* expects[] entry, because that is the only place the stamp is ever written (see the
|
|
699
|
+
* declaredMetadata.gate_claim assembly below). A tuple that does NOT match a real expectation is
|
|
700
|
+
* either FORGED (a hand-edited/attacker-supplied metadata.gate_claim) or CORRUPT — never a
|
|
701
|
+
* legitimate state this code can produce — so it dies loudly naming the claim id and the
|
|
702
|
+
* invalid stamp. It must NEVER fall through to matchExpectsEntry: that silent fall-through
|
|
703
|
+
* IS the re-typing bug this fix closes (a forged/corrupt stamp would otherwise get a fresh,
|
|
704
|
+
* heuristic-derived typing instead of being rejected).
|
|
705
|
+
*
|
|
706
|
+
* Accepted residual (#270, documented not fixed — iteration 3): this check validates
|
|
707
|
+
* STRUCTURAL conformance of the stamp's tuple against the flow definition's declared
|
|
708
|
+
* expects[] shape — it does not (and cannot, at this layer) verify WHO produced the stamp. A
|
|
709
|
+
* stamp that reuses a REAL (expectation_id, claim_type, subject_type, step_id) tuple copied
|
|
710
|
+
* from a legitimately-earned claim will PASS this validation, because it is, structurally,
|
|
711
|
+
* indistinguishable from an honest one. This is an accepted floor equal to the pre-existing
|
|
712
|
+
* trust boundary: any local process with filesystem access to this session already has an
|
|
713
|
+
* equivalent capability by invoking record-gate-claim directly. This validator closes the
|
|
714
|
+
* STRICTLY WORSE case — a forged tuple that does NOT correspond to any real expects[] entry,
|
|
715
|
+
* or a stamp silently re-typed via matchExpectsEntry's heuristic fallback — not general
|
|
716
|
+
* authorship/provenance binding. Provenance binding (e.g. cryptographically tying a stamp to
|
|
717
|
+
* the actor/process that recorded it) is future work; see issue #270's review thread.
|
|
718
|
+
*
|
|
719
|
+
* #270 MEDIUM fix (iteration 3): a DIFFERENT failure class — the FlowDefinition genuinely
|
|
720
|
+
* cannot be loaded/parsed (missing kits/ file, unreadable, invalid JSON, or a since-renamed/
|
|
721
|
+
* bogus active_flow_id) — must NEVER be reported as "forged or corrupt"; that message asserts
|
|
722
|
+
* the stamp itself is untrustworthy, which is not what happened here. This case dies with its
|
|
723
|
+
* own dedicated, distinctly-worded message instead.
|
|
724
|
+
*/
|
|
725
|
+
function assertStampedGateClaimValid(claimId: string, stamp: { expectationId: string; claimType: string; subjectType: string; stepId: string | null }): void {
|
|
726
|
+
if (!sessionFlowId) {
|
|
727
|
+
die(`buildTrustBundle: claim '${claimId}' carries a metadata.gate_claim stamp (expectation_id='${stamp.expectationId}') but no flow definition is resolvable for this session (no active_flow_id) — a stamped gate claim cannot be validated without the flow definition it was recorded against. This is either a legacy non-flow session or a missing --flow-id; re-record the gate claim inside a session with a resolvable --flow-id.`);
|
|
728
|
+
}
|
|
729
|
+
const allExpects = allGateExpectsForSession();
|
|
730
|
+
if (allExpects === null) {
|
|
731
|
+
die(`buildTrustBundle: claim '${claimId}' carries a metadata.gate_claim stamp (expectation_id='${stamp.expectationId}') naming flow '${sessionFlowId}', but FlowDefinition '${sessionFlowId}' cannot be loaded — cannot validate the gate-claim stamp. This is a load/parse failure (missing FlowDefinition file, unreadable, invalid JSON, or a renamed/bogus active_flow_id) — never treat this as a forged stamp. Restore or correct the '${sessionFlowId}' FlowDefinition (or the session's active_flow_id) before this claim can be validated.`);
|
|
732
|
+
}
|
|
733
|
+
const match = allExpects.some((entry) =>
|
|
734
|
+
entry.gateExpects.some((exp) =>
|
|
735
|
+
exp.id === stamp.expectationId
|
|
736
|
+
&& exp.bundle_claim.claimType === stamp.claimType
|
|
737
|
+
&& exp.bundle_claim.subjectType === stamp.subjectType
|
|
738
|
+
&& (stamp.stepId === null || stamp.stepId === entry.stepId),
|
|
739
|
+
),
|
|
740
|
+
);
|
|
741
|
+
if (!match) {
|
|
742
|
+
die(`buildTrustBundle: claim '${claimId}' carries a metadata.gate_claim stamp that does not match any expects[] entry declared in the '${sessionFlowId}' flow definition (expectation_id='${stamp.expectationId}', claim_type='${stamp.claimType}', subject_type='${stamp.subjectType}', step_id='${stamp.stepId ?? "<none>"}'). This stamp is forged or corrupt — it can never legitimately fail validation, since it is only ever written by this same code against a real expects[] entry. Refusing to trust it; never silently re-typing via matchExpectsEntry.`);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
547
746
|
const claims: AnyObj[] = [];
|
|
548
747
|
const evidenceItems: AnyObj[] = [];
|
|
549
748
|
const events: AnyObj[] = [];
|
|
@@ -576,17 +775,15 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
576
775
|
return p;
|
|
577
776
|
};
|
|
578
777
|
|
|
579
|
-
// Index the deterministic capture log by normalized command (
|
|
580
|
-
//
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
captureByCommand.set(key, { observedResult: failed || (prev && prev.observedResult === "fail") ? "fail" : "pass", exitCode: Number.isInteger(entry.exitCode) ? entry.exitCode : (prev ? prev.exitCode : null) });
|
|
589
|
-
}
|
|
778
|
+
// Index the deterministic capture log by normalized command (fail > pass > ambiguous
|
|
779
|
+
// precedence wins across repeated entries for the same command), so a claimed-pass check
|
|
780
|
+
// whose command actually FAILED (or never produced usable signal) becomes authoritative here.
|
|
781
|
+
// Extracted to reduceCaptureLogByCommand (below) for direct unit testing (#470 iteration 2,
|
|
782
|
+
// finding #2): the three-way classification is keyed on `observedResult`, NEVER re-derived
|
|
783
|
+
// from `exitCode` alone, so an `ambiguous,exitCode:1` entry (the #362 grep/diff absence
|
|
784
|
+
// carve-out) is never miscoerced to `fail` here, and a no-signal `ambiguous` (exitCode:null)
|
|
785
|
+
// is never coerced to `pass`.
|
|
786
|
+
const captureByCommand = reduceCaptureLogByCommand(commandLog);
|
|
590
787
|
|
|
591
788
|
// ─── P-b dual-emit helper ──────────────────────────────────────────────────
|
|
592
789
|
// Semantic matching table (ADR 0016 Abstraction A P-b):
|
|
@@ -699,18 +896,75 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
699
896
|
? { evidenceType: "attestation", method: "attestation", reconcilable: false }
|
|
700
897
|
: classifyEvidence(check.kind, cmd.length > 0);
|
|
701
898
|
const policy = ensurePolicy(legacyClaimType, "high", [evClass.evidenceType]);
|
|
702
|
-
|
|
899
|
+
// #470 iteration 2, finding #2: an "ambiguous" capture (no positive pass/fail signal —
|
|
900
|
+
// e.g. the codex no-signal default, or the #362 grep/diff absence carve-out) is
|
|
901
|
+
// non-confirming and must never be surfaced as a claim value of "pass". Map it onto the
|
|
902
|
+
// EXISTING canonical non-confirming status "not_verified" (consume-never-fork; the same
|
|
903
|
+
// mapping record-check already applies at its `ambiguous` branch above) so
|
|
904
|
+
// checkStatusToEventStatus("not_verified") returns null — no verification event, and the
|
|
905
|
+
// evidence item below is stamped passing:false. `isError` stays fail-only (ambiguous is
|
|
906
|
+
// not an error).
|
|
907
|
+
const effectiveStatus = captured ? (captured.observedResult === "ambiguous" ? "not_verified" : captured.observedResult) : String(check.status ?? "");
|
|
703
908
|
const evStatus = waiver ? "assumed" : checkStatusToEventStatus(effectiveStatus);
|
|
704
909
|
// Promotion claim marker (issue #312): a `promote` check carries a session-local
|
|
705
910
|
// _promotion object that must survive onto claim.metadata.promotion so the archive gate
|
|
706
911
|
// (workflow-artifact-cleanup-audit) and validators can detect the promotion claim without a
|
|
707
912
|
// new manifest entry. It rides alongside any waiver in a single merged metadata object.
|
|
708
913
|
const promotionMeta = (check._promotion && typeof check._promotion === "object") ? check._promotion as AnyObj : null;
|
|
914
|
+
// #298: artifact_refs/standard_refs are validated on input by normalizeCheck but were
|
|
915
|
+
// previously never persisted onto the claim — silently lost on the very first write, not
|
|
916
|
+
// just on round-trip. Stamp them onto claim.metadata (additive, mirrors waiver/promotion
|
|
917
|
+
// above) so checksFromBundle can restore them for every subsequent writer.
|
|
918
|
+
const artifactRefsMeta = Array.isArray(check.artifact_refs) && check.artifact_refs.length > 0 ? check.artifact_refs : null;
|
|
919
|
+
const standardRefsMeta = Array.isArray(check.standard_refs) && check.standard_refs.length > 0 ? check.standard_refs : null;
|
|
920
|
+
// #270/#380 MEDIUM fix: record-check's captured-output sha256 digest (see recordCheck's
|
|
921
|
+
// outputSha256 computation) — hash ONLY, never the raw output text, so this is secret-safe by
|
|
922
|
+
// construction even though trust.bundle is often committed/shared. Stamped onto
|
|
923
|
+
// claim.metadata (additive, mirrors artifact_refs/standard_refs above) so checksFromBundle can
|
|
924
|
+
// restore it for every subsequent writer, and so it survives any later rebuild.
|
|
925
|
+
// NOTE (#270 LOW fix, iteration 3): this digest is over the CLAMPED capture (recordCheck's
|
|
926
|
+
// clampOutput, first RECORD_CHECK_MAX_OUTPUT / 64 KiB of combined stdout+stderr), not the full
|
|
927
|
+
// raw command output — a command producing >64KiB of output has its digest computed only over
|
|
928
|
+
// the retained prefix, so two runs whose outputs diverge only past the 64KiB boundary hash
|
|
929
|
+
// identically.
|
|
930
|
+
const outputDigestMeta = typeof check._output_sha256 === "string" && check._output_sha256.length > 0
|
|
931
|
+
? { algorithm: "sha256", hex: check._output_sha256 }
|
|
932
|
+
: null;
|
|
933
|
+
// #270(a)/(c): a gate claim's declared claimType/subjectType, once resolved (either freshly
|
|
934
|
+
// via matchExpectsEntry below, or restored from a prior write's metadata.gate_claim stamp by
|
|
935
|
+
// checksFromBundle), is stamped here so it is frozen at record time — a later bundle rebuild
|
|
936
|
+
// (record-evidence/record-critique/record-learning) must never re-derive it from whatever
|
|
937
|
+
// step happens to be active THEN. See the matchExpectsEntry call site immediately below.
|
|
938
|
+
const gateClaimExpectationId = typeof check._gate_claim_expectation_id === "string" ? check._gate_claim_expectation_id : null;
|
|
939
|
+
const gateClaimDeclaredType = typeof check._gate_claim_declared_type === "string" ? check._gate_claim_declared_type : null;
|
|
940
|
+
const gateClaimDeclaredSubject = typeof check._gate_claim_declared_subject === "string" ? check._gate_claim_declared_subject : null;
|
|
941
|
+
const gateClaimDeclaredStepId = typeof check._gate_claim_declared_step_id === "string" ? check._gate_claim_declared_step_id : null;
|
|
942
|
+
const gateClaimRouteReason = typeof check._gate_claim_route_reason === "string" ? check._gate_claim_route_reason : null;
|
|
943
|
+
// #270 CRITICAL/HIGH fix: checksFromBundle stamps this when it read a claim that is
|
|
944
|
+
// gate-claim-SHAPED (origin:"check", check_kind:"external", kit-typed claimType) but carries
|
|
945
|
+
// NO metadata.gate_claim stamp — a claim this code could not have produced without also
|
|
946
|
+
// writing the stamp, so it predates cluster #270/#344. Die loudly with the same remedy
|
|
947
|
+
// pattern requireStampedClaim already uses elsewhere in this file, instead of silently
|
|
948
|
+
// falling through to matchExpectsEntry (which would re-derive a FRESH, possibly-wrong typing
|
|
949
|
+
// for what is actually a previously-recorded, now-untraceable gate claim — the re-typing bug).
|
|
950
|
+
const gateClaimShapeUnstampedClaimId = typeof check._gate_claim_shape_unstamped_claim_id === "string" ? check._gate_claim_shape_unstamped_claim_id : null;
|
|
951
|
+
if (gateClaimShapeUnstampedClaimId) {
|
|
952
|
+
die(`pre-cluster-270 gate claim '${gateClaimShapeUnstampedClaimId}' has no metadata.gate_claim stamp and cannot be re-typed authoritatively — re-record it (record-gate-claim) to regenerate.`);
|
|
953
|
+
}
|
|
709
954
|
// #268: stamp a stable origin discriminator so checksFromBundle / critiquesFromBundle can
|
|
710
955
|
// distinguish check vs critique vs acceptance claims across round-trips even under --flow-id,
|
|
711
956
|
// where all three collapse onto the same declared claimType (and a command-less critique claim
|
|
712
957
|
// would otherwise be re-absorbed as a test_output check → permanent [not-run] divergence).
|
|
713
|
-
const claimMetadata: AnyObj = {
|
|
958
|
+
const claimMetadata: AnyObj = {
|
|
959
|
+
origin: "check",
|
|
960
|
+
check_kind: String(check.kind ?? "external"),
|
|
961
|
+
...(activeStep && workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}),
|
|
962
|
+
...(waiver ? { waiver } : {}),
|
|
963
|
+
...(promotionMeta ? { promotion: promotionMeta } : {}),
|
|
964
|
+
...(artifactRefsMeta ? { artifact_refs: artifactRefsMeta } : {}),
|
|
965
|
+
...(standardRefsMeta ? { standard_refs: standardRefsMeta } : {}),
|
|
966
|
+
...(outputDigestMeta ? { output_digest: outputDigestMeta } : {}),
|
|
967
|
+
};
|
|
714
968
|
|
|
715
969
|
const claimEvents: AnyObj[] = [];
|
|
716
970
|
if (evStatus) {
|
|
@@ -734,11 +988,47 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
734
988
|
|
|
735
989
|
// P-d: declared-only when active flow/step present (shadow retired); no-flow path unchanged.
|
|
736
990
|
// When record-gate-claim sets _gate_claim_expectation_id, pass it for exact lookup (ADR 0016 P-d Increment 2).
|
|
737
|
-
|
|
991
|
+
// #270(c): a REBUILD of a previously-recorded gate claim (both the expectation id AND its
|
|
992
|
+
// originally-declared claimType/subjectType round-tripped via checksFromBundle's
|
|
993
|
+
// metadata.gate_claim restoration) uses the STAMPED typing directly instead of re-resolving
|
|
994
|
+
// matchExpectsEntry against whatever step is active NOW — that re-resolution is the exact
|
|
995
|
+
// defect (a claim recorded at step N silently re-typed as step N+1's claim on rebuild).
|
|
996
|
+
// matchExpectsEntry is still the resolver for the FIRST write (no prior stamp to trust).
|
|
997
|
+
//
|
|
998
|
+
// #270 CRITICAL/HIGH fix: a RESTORED stamp (gateClaimDeclaredType/gateClaimDeclaredSubject
|
|
999
|
+
// both present — meaning this is a REBUILD reading back a prior write's metadata.gate_claim,
|
|
1000
|
+
// not a fresh record-gate-claim call, which only ever sets gateClaimExpectationId) must be
|
|
1001
|
+
// VALIDATED against the session's full flow definition before being trusted — see
|
|
1002
|
+
// assertStampedGateClaimValid above. A stamp that fails validation dies loudly; it must NEVER
|
|
1003
|
+
// silently fall through to matchExpectsEntry (that fall-through was the exact #270 defect:
|
|
1004
|
+
// ANY invalid/forged/corrupt stamp got silently re-typed by the heuristic matcher instead of
|
|
1005
|
+
// being rejected). A FRESH write (only gateClaimExpectationId set, no declared type/subject
|
|
1006
|
+
// yet) has no stamp to validate — matchExpectsEntry is still the correct, and only, resolver
|
|
1007
|
+
// for that case, exactly as before.
|
|
1008
|
+
if (gateClaimExpectationId && gateClaimDeclaredType && gateClaimDeclaredSubject) {
|
|
1009
|
+
assertStampedGateClaimValid(claimId, {
|
|
1010
|
+
expectationId: gateClaimExpectationId,
|
|
1011
|
+
claimType: gateClaimDeclaredType,
|
|
1012
|
+
subjectType: gateClaimDeclaredSubject,
|
|
1013
|
+
stepId: gateClaimDeclaredStepId,
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
const declared = (gateClaimExpectationId && gateClaimDeclaredType && gateClaimDeclaredSubject)
|
|
1017
|
+
? { claimType: gateClaimDeclaredType, subjectType: gateClaimDeclaredSubject }
|
|
1018
|
+
: matchExpectsEntry("check", check.kind, gateClaimExpectationId ?? undefined);
|
|
738
1019
|
if (declared) {
|
|
739
1020
|
// Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
|
|
740
1021
|
const declaredPolicy = ensurePolicy(declared.claimType, "high", [evClass.evidenceType]);
|
|
741
|
-
|
|
1022
|
+
// Freeze the resolved typing (fresh or restored) into metadata.gate_claim so it survives
|
|
1023
|
+
// any subsequent rebuild regardless of the then-active step (#270a/c).
|
|
1024
|
+
// step_id prefers the ORIGINALLY-recorded step (restored from a prior stamp) on a
|
|
1025
|
+
// rebuild that has no active flow step of its own; only a genuinely first write (no
|
|
1026
|
+
// restored stamp) takes the currently-active step's id.
|
|
1027
|
+
const declaredStepId = gateClaimDeclaredStepId ?? (activeStep ? activeStep.stepId : null);
|
|
1028
|
+
const declaredMetadata: AnyObj = gateClaimExpectationId
|
|
1029
|
+
? { ...claimMetadata, gate_claim: { expectation_id: gateClaimExpectationId, claim_type: declared.claimType, subject_type: declared.subjectType, step_id: declaredStepId, ...(gateClaimRouteReason ? { route_reason: gateClaimRouteReason } : {}) } }
|
|
1030
|
+
: claimMetadata;
|
|
1031
|
+
const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: effectiveStatus, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, ...(declaredMetadata ? { metadata: declaredMetadata } : {}) };
|
|
742
1032
|
const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [evItem] as Record<string, unknown>[], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
|
|
743
1033
|
claims.push({ ...declaredClaimObj, status: declaredStatus });
|
|
744
1034
|
} else {
|
|
@@ -770,7 +1060,7 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
770
1060
|
if (declared) {
|
|
771
1061
|
// Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
|
|
772
1062
|
const declaredPolicy = ensurePolicy(declared.claimType, "high", []);
|
|
773
|
-
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" } };
|
|
1063
|
+
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 } : {}) } };
|
|
774
1064
|
const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
|
|
775
1065
|
claims.push({ ...declaredClaimObj, status: declaredStatus });
|
|
776
1066
|
} else {
|
|
@@ -793,7 +1083,7 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
793
1083
|
const supersededBy = typeof c.superseded_by === "string" && c.superseded_by.length > 0 ? c.superseded_by : null;
|
|
794
1084
|
const critiqueReviewer = String(c.reviewer ?? "tool-code-reviewer");
|
|
795
1085
|
const critiqueReviewedAt = String(c.reviewed_at ?? ts);
|
|
796
|
-
const critMeta: AnyObj = { origin: "critique", reviewer: critiqueReviewer, reviewed_at: critiqueReviewedAt, ...(supersededBy ? { superseded_by: supersededBy } : {}) };
|
|
1086
|
+
const critMeta: AnyObj = { origin: "critique", reviewer: critiqueReviewer, reviewed_at: critiqueReviewedAt, ...(activeStep && workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}), ...(supersededBy ? { superseded_by: supersededBy } : {}) };
|
|
797
1087
|
// A superseded historical write gets a distinct, stable claimId so it co-exists with the live
|
|
798
1088
|
// claim of the same critique id (never overwrites or duplicates it). The salt is reproducible
|
|
799
1089
|
// across rebuilds because superseded_by + reviewed_at are preserved in metadata.
|
|
@@ -883,6 +1173,7 @@ export async function writeTrustBundle(dir: string, slug: string, timestamp: str
|
|
|
883
1173
|
return { written: false, errors: result.errors };
|
|
884
1174
|
}
|
|
885
1175
|
writeJson(path.join(dir, "trust.bundle"), bundle);
|
|
1176
|
+
await syncBuilderFlowSessionIfPresent(dir);
|
|
886
1177
|
return { written: true, errors: [] };
|
|
887
1178
|
} catch (err) {
|
|
888
1179
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -1090,7 +1381,23 @@ function parseCriterion(line: string, index: number): AnyObj {
|
|
|
1090
1381
|
const evidence = m?.[1]?.trim().replace(/\.$/, "");
|
|
1091
1382
|
if (m) text = text.slice(0, m.index).trim().replace(/\.$/, "");
|
|
1092
1383
|
const item: AnyObj = { id: slugify(text, `criterion-${index + 1}`), description: text, status: "pending" };
|
|
1093
|
-
|
|
1384
|
+
// #412 (AC8 interaction, planning-contract.md line ~46/84): the "- Evidence: <...>" Markdown
|
|
1385
|
+
// field is contractually a "test, command, screenshot, dashboard, doc, CI, or manual check" —
|
|
1386
|
+
// NOT always a runnable command. Unconditionally synthesizing kind:"command" with the raw text
|
|
1387
|
+
// in `excerpt` for EVERY Evidence line (the pre-existing behavior here) was already
|
|
1388
|
+
// contract-incorrect for prose evidence; it is now also a hard failure at record time
|
|
1389
|
+
// (validateAcceptanceEvidenceRefs's new runnability rejection targets kind:"command"'s
|
|
1390
|
+
// excerpt/url), so fixing the classification here is required, not optional polish. Use the
|
|
1391
|
+
// SAME isRunnableCommandText heuristic the record-time rejection itself uses (single-sourced):
|
|
1392
|
+
// literally-runnable text goes in `excerpt` (reconcilable, execution.label-eligible); anything
|
|
1393
|
+
// else (prose, or ensure-session's own "pending" scaffold placeholder) goes in `summary`
|
|
1394
|
+
// instead (never validated for runnability, never executed) — still kind:"command" (a
|
|
1395
|
+
// structured ref is still produced either way; only WHICH field carries the text changes),
|
|
1396
|
+
// per the contract's own "prose belongs in ref.summary" rule.
|
|
1397
|
+
if (evidence) {
|
|
1398
|
+
const { isRunnableCommandText } = loadRunnableCommandHelper();
|
|
1399
|
+
item.evidence_refs = [evidenceRef("command", isRunnableCommandText(evidence) ? { excerpt: evidence } : { summary: evidence })];
|
|
1400
|
+
}
|
|
1094
1401
|
return item;
|
|
1095
1402
|
}
|
|
1096
1403
|
function artifactDirFrom(value: string): string { return path.extname(value) ? path.dirname(value) : value; }
|
|
@@ -1211,6 +1518,47 @@ function loadCurrentPointerHelper(): {
|
|
|
1211
1518
|
};
|
|
1212
1519
|
}
|
|
1213
1520
|
|
|
1521
|
+
/**
|
|
1522
|
+
* #412: delegate to the shared pure-CJS runnable-command-text heuristic
|
|
1523
|
+
* (scripts/hooks/lib/runnable-command.js), mirroring the createRequire idiom used by
|
|
1524
|
+
* loadCurrentPointerHelper()/loadActorIdentityHelper() above. Single-sources the heuristic
|
|
1525
|
+
* between stop-goal-fit.js's Stop-time backstop and this file's record-time rejection
|
|
1526
|
+
* (validateAcceptanceEvidenceRefs, recordGateClaim --command, recordCheck --command) — see
|
|
1527
|
+
* AC9.
|
|
1528
|
+
*
|
|
1529
|
+
* #362 (Wave 3, AC6/AC7): extended (not a new sibling loader — same module, same
|
|
1530
|
+
* createRequire call, single require() of runnable-command.js) to also return
|
|
1531
|
+
* `isAmbiguousAbsenceCommand`, landed in that module by Wave 1's Task 1.2. This keeps
|
|
1532
|
+
* `recordCheck`'s record-time ambiguous-status stamp and `validateAcceptanceEvidenceRefs`'s
|
|
1533
|
+
* record-time advisory single-sourced against the SAME heuristic `runBackstop`/
|
|
1534
|
+
* `readCommandLog`/`evidence-capture.js`'s `observeResult` already consume (Waves 1-2) —
|
|
1535
|
+
* never a second, divergent implementation of "what counts as ambiguous."
|
|
1536
|
+
*/
|
|
1537
|
+
function loadRunnableCommandHelper(): { isRunnableCommandText: (text: string) => boolean; isAmbiguousAbsenceCommand: (text: string) => boolean } {
|
|
1538
|
+
const _req = createRequire(import.meta.url);
|
|
1539
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/runnable-command.js");
|
|
1540
|
+
return _req(helperPath) as { isRunnableCommandText: (text: string) => boolean; isAmbiguousAbsenceCommand: (text: string) => boolean };
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/**
|
|
1544
|
+
* #362 (iteration-2 fix item 4/LOW): single-sourced human-facing remediation fragment shared by
|
|
1545
|
+
* this file's two ambiguous-absence-command emission sites (`validateAcceptanceEvidenceRefs`'s
|
|
1546
|
+
* record-time advisory and `recordCheck`'s ambiguous-status stderr note) — mirrors how
|
|
1547
|
+
* `isAmbiguousAbsenceCommand` itself is single-sourced above. Cross-ref: scripts/hooks/
|
|
1548
|
+
* stop-goal-fit.js keeps its OWN copy of the equivalent shared string (`AMBIGUOUS_REMEDIATION`)
|
|
1549
|
+
* for its three Stop-hook emission sites — the two files do not share a module for this string,
|
|
1550
|
+
* so each file single-sources independently.
|
|
1551
|
+
*/
|
|
1552
|
+
const AMBIGUOUS_REMEDIATION_ADVICE = "self-asserting command ('! grep ...' or 'grep -c ... | grep -qx 0')";
|
|
1553
|
+
|
|
1554
|
+
function validateRunnableCheckCommand(check: AnyObj, context: string): void {
|
|
1555
|
+
if (check.kind !== "command" || !hasNonEmptyString(check.command)) return;
|
|
1556
|
+
const { isRunnableCommandText } = loadRunnableCommandHelper();
|
|
1557
|
+
if (!isRunnableCommandText(check.command)) {
|
|
1558
|
+
die(`${context}: kind:"command" check command is not a runnable shell command: "${check.command}" — remediate by either (1) moving the prose to summary and omitting command/execution.label, or (2) reclassifying this check as kind:"external" (session-local attestation) instead of kind:"command".`);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1214
1562
|
/**
|
|
1215
1563
|
* #291 Wave 2 Task 2.1 (§5): writes the UNCHANGED legacy global `<root>/current.json` (the
|
|
1216
1564
|
* compat-shim's write-side half — every existing consumer without an actorKey keeps reading
|
|
@@ -1220,7 +1568,7 @@ function loadCurrentPointerHelper(): {
|
|
|
1220
1568
|
* byte-identical to this function's pre-#291 behavior — with a stderr note mirroring the existing
|
|
1221
1569
|
* unresolved-actor branch-naming diagnostic.
|
|
1222
1570
|
*/
|
|
1223
|
-
function writeCurrent(root: string, dir: string, timestamp: string, owner: string, source: string, flowId?: string, stepId?: string,
|
|
1571
|
+
function writeCurrent(root: string, dir: string, timestamp: string, owner: string, source: string, flowId?: string, stepId?: string, actorKey?: string): void {
|
|
1224
1572
|
// #289: mirror the active session's already-recorded branch (state.json.branch) into
|
|
1225
1573
|
// current.json so consumers of current.json (which has no schema of its own — not one of the
|
|
1226
1574
|
// 9 schemas under schemas/) see the routing branch without re-reading state.json separately.
|
|
@@ -1239,11 +1587,6 @@ function writeCurrent(root: string, dir: string, timestamp: string, owner: strin
|
|
|
1239
1587
|
// FlowDefinition omit them and fall through to the workflow.* claim type path.
|
|
1240
1588
|
...(flowId ? { active_flow_id: flowId } : {}),
|
|
1241
1589
|
...(stepId ? { active_step_id: stepId } : {}),
|
|
1242
|
-
// WS8 (AC12): sanctioned ad-hoc direct entry marker. Set when --step-id explicitly
|
|
1243
|
-
// targets a step other than the flow's resolved first step, so stop-goal-fit /
|
|
1244
|
-
// gate-review can distinguish an intentional direct entry (e.g. a planning-only
|
|
1245
|
-
// session that skips pull-work) from a stale/mis-stamped active_step_id.
|
|
1246
|
-
...(adHocReason ? { ad_hoc_entry: true, ad_hoc_reason: adHocReason } : {}),
|
|
1247
1590
|
};
|
|
1248
1591
|
writeJson(path.join(root, "current.json"), payload);
|
|
1249
1592
|
if (actorKey && !loadActorIdentityHelper().isUnresolvedActor(actorKey)) {
|
|
@@ -1313,7 +1656,17 @@ function updateCurrentAgent(root: string, dir: string, agentId: string, status:
|
|
|
1313
1656
|
}
|
|
1314
1657
|
}
|
|
1315
1658
|
|
|
1316
|
-
function initSidecars(
|
|
1659
|
+
function initSidecars(
|
|
1660
|
+
dir: string,
|
|
1661
|
+
slug: string,
|
|
1662
|
+
sourceRequest: string,
|
|
1663
|
+
summary: string,
|
|
1664
|
+
nextAction: string,
|
|
1665
|
+
timestamp: string,
|
|
1666
|
+
markdown?: string,
|
|
1667
|
+
workItemRefs: string[] = [],
|
|
1668
|
+
initialLifecycle?: { status: string; phase: string },
|
|
1669
|
+
): void {
|
|
1317
1670
|
const criteria = markdown ? definitionAcceptanceLines(markdown).map(parseCriterion) : [];
|
|
1318
1671
|
// #289/#309: `markdown` here is NOT always the session `<slug>--deliver.md` that
|
|
1319
1672
|
// ensureSession seeds the `branch:` line into — initPlan is called against the tool-planner's
|
|
@@ -1344,9 +1697,11 @@ function initSidecars(dir: string, slug: string, sourceRequest: string, summary:
|
|
|
1344
1697
|
// current call's timestamp silently rewrites a session's original creation time. Preserve the
|
|
1345
1698
|
// existing state.json's created_at when present; stamp only on true first-creation.
|
|
1346
1699
|
// updated_at still reflects "now" on every call — that field is intentionally mutable.
|
|
1700
|
+
const retainedWorkItemRefs = workItemRefs.length > 0 ? workItemRefs : (Array.isArray(existingState.work_item_refs) ? existingState.work_item_refs : []);
|
|
1347
1701
|
writeJson(path.join(dir, "state.json"), {
|
|
1348
|
-
...sidecarBase(slug), status: "planned", phase: "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
|
|
1702
|
+
...sidecarBase(slug), status: initialLifecycle?.status ?? "planned", phase: initialLifecycle?.phase ?? "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
|
|
1349
1703
|
...(branch ? { branch } : {}),
|
|
1704
|
+
...(retainedWorkItemRefs.length > 0 ? { work_item_refs: retainedWorkItemRefs } : {}),
|
|
1350
1705
|
artifact_paths: relArtifacts(dir),
|
|
1351
1706
|
next_action: { status: "continue", summary: nextAction || summary },
|
|
1352
1707
|
});
|
|
@@ -1567,10 +1922,41 @@ function enforceEnsureSessionOwnership(
|
|
|
1567
1922
|
* --claim-ttl-seconds <n> Overrides the liveness-policy TTL default for a new claim.
|
|
1568
1923
|
* --reason <text> Audit-trail reason recorded on the claim/supersede record.
|
|
1569
1924
|
*/
|
|
1925
|
+
function resolveEnsureSessionEntry(p: ReturnType<typeof parseArgs>, dir: string): { flowId: string; stepId: string; firstStep: string } {
|
|
1926
|
+
const flowId = opt(p, "flow-id");
|
|
1927
|
+
const explicitStep = opt(p, "step-id");
|
|
1928
|
+
if (!flowId) {
|
|
1929
|
+
if (explicitStep) die("ensure-session --step-id requires --flow-id");
|
|
1930
|
+
if (opt(p, "ad-hoc-reason")) die("ensure-session --ad-hoc-reason is no longer supported");
|
|
1931
|
+
return { flowId: "", stepId: "", firstStep: "" };
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
const firstStep = resolveFirstStep(flowId, findRepoRootFromDir(dir));
|
|
1935
|
+
if (!firstStep) die(`ensure-session could not resolve the first step for Flow Definition ${JSON.stringify(flowId)}`);
|
|
1936
|
+
if (opt(p, "ad-hoc-reason")) {
|
|
1937
|
+
die("ensure-session --ad-hoc-reason cannot authorize workflow entry; start at the Flow Definition's first step or resume persisted run state");
|
|
1938
|
+
}
|
|
1939
|
+
if (explicitStep && explicitStep !== firstStep) {
|
|
1940
|
+
die(`ensure-session refused workflow entry at ${JSON.stringify(explicitStep)}: new runs must start at first step ${JSON.stringify(firstStep)}; resume an existing run without --step-id`);
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
return { flowId, stepId: explicitStep || firstStep, firstStep };
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
function preflightEnsureSession(p: ReturnType<typeof parseArgs>): void {
|
|
1947
|
+
const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
|
|
1948
|
+
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)"));
|
|
1949
|
+
const dir = sessionDirFor(root, slug);
|
|
1950
|
+
resolveEnsureSessionEntry(p, dir);
|
|
1951
|
+
sessionWorkItem(p, slug, dir);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1570
1954
|
function ensureSession(p: ReturnType<typeof parseArgs>): number {
|
|
1571
1955
|
const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
|
|
1572
1956
|
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)"));
|
|
1573
1957
|
const dir = sessionDirFor(root, slug);
|
|
1958
|
+
const entry = resolveEnsureSessionEntry(p, dir);
|
|
1959
|
+
const workItem = sessionWorkItem(p, slug, dir);
|
|
1574
1960
|
// #291 Wave 2 Task 2.1 (§3, §4): resolve the actor ONCE, then run the ownership guard BEFORE
|
|
1575
1961
|
// any directory/file is created — a refusal must never leave a stray empty session dir. Reused
|
|
1576
1962
|
// below (writeCurrent's per-actor dual-write) so the branch-naming actor and the
|
|
@@ -1579,6 +1965,9 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
|
|
|
1579
1965
|
enforceEnsureSessionOwnership(p, root, slug, dir, actorResolution);
|
|
1580
1966
|
fs.mkdirSync(dir, { recursive: true });
|
|
1581
1967
|
const timestamp = opt(p, "timestamp", now());
|
|
1968
|
+
if (workItem.localRecord && !fs.existsSync(path.join(dir, "work-item.json"))) {
|
|
1969
|
+
writeJson(path.join(dir, "work-item.json"), workItem.localRecord);
|
|
1970
|
+
}
|
|
1582
1971
|
let md = fs.existsSync(path.join(dir, `${slug}--deliver.md`)) ? read(path.join(dir, `${slug}--deliver.md`)) : "";
|
|
1583
1972
|
if (!md) {
|
|
1584
1973
|
// #289: derive the routing branch ONLY on fresh session creation (this `if (!md)` guard).
|
|
@@ -1586,11 +1975,29 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
|
|
|
1586
1975
|
// of code is skipped on a resumed/taken-over session, which is what makes ADR 0021 §5
|
|
1587
1976
|
// takeover continuity true by construction (see Design Decision 3 in the plan).
|
|
1588
1977
|
const branch = resolveSessionBranch(p, slug);
|
|
1589
|
-
|
|
1978
|
+
const initialMarkdownStatus = entry.flowId ? "new" : "planning";
|
|
1979
|
+
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${opts(p, "criterion").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`;
|
|
1590
1980
|
fs.writeFileSync(path.join(dir, `${slug}--deliver.md`), md);
|
|
1591
1981
|
}
|
|
1592
1982
|
if (!fs.existsSync(path.join(dir, "state.json")) || !fs.existsSync(path.join(dir, "acceptance.json")) || !fs.existsSync(path.join(dir, "handoff.json"))) {
|
|
1593
|
-
|
|
1983
|
+
const phaseMap = entry.flowId ? resolvePhaseMap(entry.flowId, findRepoRootFromDir(dir)) : null;
|
|
1984
|
+
const initialPhase = Object.entries(phaseMap ?? {}).find(([, step]) => step === entry.firstStep)?.[0];
|
|
1985
|
+
const nextAction = entry.flowId
|
|
1986
|
+
? entry.flowId === "builder.build"
|
|
1987
|
+
? `Start the canonical Flow run with \`flow-agents builder-run start --session-dir .kontourai/flow-agents/${slug}\`; activate \`pull-work\` for work item ${JSON.stringify(workItem.ref)}, and satisfy the declared gate before advancing.`
|
|
1988
|
+
: `Continue at Flow step ${JSON.stringify(entry.stepId)} for work item ${JSON.stringify(workItem.ref)}; satisfy its declared gate before advancing.`
|
|
1989
|
+
: opt(p, "next-action", "Continue.");
|
|
1990
|
+
initSidecars(
|
|
1991
|
+
dir,
|
|
1992
|
+
slug,
|
|
1993
|
+
opt(p, "source-request"),
|
|
1994
|
+
opt(p, "summary"),
|
|
1995
|
+
nextAction,
|
|
1996
|
+
timestamp,
|
|
1997
|
+
md,
|
|
1998
|
+
[workItem.ref],
|
|
1999
|
+
initialPhase ? { status: "new", phase: initialPhase } : undefined,
|
|
2000
|
+
);
|
|
1594
2001
|
}
|
|
1595
2002
|
// ADR 0016 Abstraction A (P-a): optional --flow-id / --step-id flags persist FlowDefinition
|
|
1596
2003
|
// routing keys into current.json for the producer (P-b) and enforcer (P-c) to consume.
|
|
@@ -1599,27 +2006,14 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
|
|
|
1599
2006
|
// active_step_id to the FIRST step in the FlowDefinition's steps[] list. This ensures
|
|
1600
2007
|
// ensure-session --flow-id builder.build produces a FlowDefinition-driven session even
|
|
1601
2008
|
// before the first advance-state call.
|
|
1602
|
-
const
|
|
1603
|
-
const
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
} else if (flowId && explicitStep) {
|
|
1611
|
-
// WS8 (AC12): --step-id is the sanctioned ad-hoc direct-entry mechanism. When it names
|
|
1612
|
-
// a step other than the flow's resolved first step, record an explicit ad_hoc_entry
|
|
1613
|
-
// marker (with a reason) instead of silently letting the mis-stamp look like the
|
|
1614
|
-
// flow's normal first step. This is the root-cause fix for a planning-only session
|
|
1615
|
-
// whose active_step_id would otherwise default to builder.build's first step.
|
|
1616
|
-
const repoRoot = findRepoRootFromDir(dir);
|
|
1617
|
-
const firstStep = resolveFirstStep(flowId, repoRoot);
|
|
1618
|
-
if (firstStep && firstStep !== explicitStep) {
|
|
1619
|
-
adHocReason = opt(p, "ad-hoc-reason") || `direct entry at step "${explicitStep}" via --step-id (flow first step is "${firstStep}")`;
|
|
1620
|
-
}
|
|
1621
|
-
}
|
|
1622
|
-
writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", flowId || undefined, stepId || undefined, adHocReason, actorResolution.unresolved ? undefined : actorResolution.branchActorKey);
|
|
2009
|
+
const persistedCurrent = loadCurrent(root);
|
|
2010
|
+
const resumedStep = !opt(p, "step-id")
|
|
2011
|
+
&& persistedCurrent?.active_slug === slug
|
|
2012
|
+
&& persistedCurrent?.active_flow_id === entry.flowId
|
|
2013
|
+
&& typeof persistedCurrent?.active_step_id === "string"
|
|
2014
|
+
? persistedCurrent.active_step_id
|
|
2015
|
+
: entry.stepId;
|
|
2016
|
+
writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", entry.flowId || undefined, resumedStep || undefined, actorResolution.unresolved ? undefined : actorResolution.branchActorKey);
|
|
1623
2017
|
console.log(dir);
|
|
1624
2018
|
return 0;
|
|
1625
2019
|
}
|
|
@@ -1724,11 +2118,57 @@ export function normalizeEvidenceRefs(raw: unknown, label: string): AnyObj[] {
|
|
|
1724
2118
|
return validateEvidenceRef({ ...ref as AnyObj }, label);
|
|
1725
2119
|
});
|
|
1726
2120
|
}
|
|
1727
|
-
|
|
2121
|
+
// #270 HIGH fix (iteration 3): the `gate-claim-` check-id prefix is RESERVED for
|
|
2122
|
+
// record-gate-claim's own internally-constructed ids (`id: \`gate-claim-${checkId}\`` — see
|
|
2123
|
+
// recordGateClaim below). Every OTHER writer of check ids (record-evidence --check-json,
|
|
2124
|
+
// record-check, dogfood-pass --check-json) flows through this same normalizeCheck, and until
|
|
2125
|
+
// this fix any of them could ALSO hand-supply an id starting with "gate-claim-". That collision
|
|
2126
|
+
// silently satisfies the id-shape half of gateClaimShapeUnstampedId's four-signal detector (see
|
|
2127
|
+
// its comment above) without ever having gone through record-gate-claim, so a later rebuild
|
|
2128
|
+
// misclassifies the caller's check as an unstamped pre-cluster-270 gate claim and dies, losing
|
|
2129
|
+
// the write. Rejecting the prefix HERE, at record time, for every caller-supplied id makes the
|
|
2130
|
+
// id-shape signal sound again: only record-gate-claim's own path can ever produce that shape.
|
|
2131
|
+
// record-gate-claim opts in via allowGateClaimPrefix=true since it is the sole legitimate
|
|
2132
|
+
// producer of that id shape; every other caller uses the default (reject).
|
|
2133
|
+
//
|
|
2134
|
+
// #270 follow-up fix (publish-preflight, iteration 5): the rejection above must apply ONLY to
|
|
2135
|
+
// NEW mints, not to a correction of an id that already exists as a check claim id in the
|
|
2136
|
+
// session's CURRENT trust.bundle. A mis-recorded claim that already carries a `gate-claim-`
|
|
2137
|
+
// prefixed id (e.g. one that slipped through before this guard shipped, or was recorded via a
|
|
2138
|
+
// binary predating it) can otherwise NEVER be corrected: every attempt to re-record that exact
|
|
2139
|
+
// id — the only way to supersede/fix it — is itself rejected by this same guard, permanently
|
|
2140
|
+
// wedging that id.
|
|
2141
|
+
//
|
|
2142
|
+
// #270 CRITICAL fix (iteration 6, narrowing the above): "the id's identity already exists in
|
|
2143
|
+
// the bundle" is NOT by itself a safe supersession signal — an id that already exists MIGHT be
|
|
2144
|
+
// a REAL, properly-stamped claim produced by record-gate-claim itself (its own generated
|
|
2145
|
+
// `gate-claim-${checkId}` ids always already "exist" once minted). Exempting every existing id
|
|
2146
|
+
// unconditionally let record-evidence/record-check/dogfood-pass silently overwrite a live,
|
|
2147
|
+
// correctly-stamped gate claim — destroying its metadata.gate_claim stamp — and the caller
|
|
2148
|
+
// fully controls check_kind, so the replacement claim can trivially be shaped to evade
|
|
2149
|
+
// gateClaimShapeUnstampedId's detector (that detector requires check_kind==="external", which
|
|
2150
|
+
// only fires the alarm when the caller happens to pick that kind). The narrowed rule: an
|
|
2151
|
+
// existing id is supersedable via this path ONLY when the EXISTING claim for that id carries NO
|
|
2152
|
+
// metadata.gate_claim stamp — i.e. only the mis-recorded/wedged shape (the legitimate
|
|
2153
|
+
// correction target this exemption exists for). An existing id whose claim IS stamped is a live
|
|
2154
|
+
// gate claim; only record-gate-claim (allowGateClaimPrefix=true, its own unconditional opt-in)
|
|
2155
|
+
// may ever supersede it. Callers therefore pass `existingCheckStampById` (a Map from the
|
|
2156
|
+
// bundle's CURRENT check ids to whether that check's claim already carries a metadata.gate_claim
|
|
2157
|
+
// stamp, read via readBundleState/checksFromBundle's `_gate_claim_expectation_id` restoration
|
|
2158
|
+
// BEFORE normalizeCheck runs — see applyGateClaimStamp). This does not weaken new-mint
|
|
2159
|
+
// enforcement: a NOVEL gate-claim-* id (not already present at all) is still rejected exactly as
|
|
2160
|
+
// before, and superseding a STAMPED existing id is now rejected too.
|
|
2161
|
+
export function normalizeCheck(raw: AnyObj, allowGateClaimPrefix = false, existingCheckStampById?: ReadonlyMap<string, boolean>): AnyObj {
|
|
1728
2162
|
const check = { ...raw };
|
|
1729
2163
|
if (!check.id || !check.kind || !check.status || !check.summary) die("check requires id, kind, status, and summary");
|
|
1730
|
-
|
|
2164
|
+
if (!allowGateClaimPrefix && typeof check.id === "string" && check.id.startsWith("gate-claim-")) {
|
|
2165
|
+
const existingHasStamp = existingCheckStampById?.get(check.id);
|
|
2166
|
+
if (existingHasStamp === true) die(`check id "${check.id}" belongs to a live, properly-stamped gate claim — only record-gate-claim may supersede it. Superseding a stamped gate claim via record-evidence/record-check/dogfood-pass would silently destroy its metadata.gate_claim stamp. Re-record via record-gate-claim, or choose a different --id.`);
|
|
2167
|
+
if (existingHasStamp === undefined) die(`check id "${check.id}" starts with the reserved "gate-claim-" prefix — that namespace is reserved for record-gate-claim's own generated ids. Choose a different --id (or omit --id to let the check derive one from its command/summary). Supersession of an EXISTING, UNSTAMPED check claim with this same id (a correction) is permitted — this rejection applies to newly-minted ids not already present in the session's trust.bundle, and to ids that already belong to a stamped (live) gate claim.`);
|
|
2168
|
+
}
|
|
2169
|
+
if (!checkKinds.has(check.kind)) die("kind must be one of: build, types, lint, test, command, security, diff, browser, runtime, policy, external");
|
|
1731
2170
|
if (!checkStatuses.has(check.status)) die("status must be one of: pass, fail, not_verified, skip");
|
|
2171
|
+
validateRunnableCheckCommand(check, `check ${String(check.id)}`);
|
|
1732
2172
|
if (Array.isArray(check.standard_refs)) for (const ref of check.standard_refs) if (!["junit", "sarif", "coverage", "veritas"].includes(ref.standard)) die("standard must be one of");
|
|
1733
2173
|
if (check.artifact_refs) check.artifact_refs = normalizeEvidenceRefs(check.artifact_refs, "artifact_refs");
|
|
1734
2174
|
if (check.surface_trust_refs) check.surface_trust_refs = normalizeSurfaceRefs(check.surface_trust_refs);
|
|
@@ -1845,13 +2285,54 @@ function surfaceCheckFromArtifact(file: string, index: number): AnyObj {
|
|
|
1845
2285
|
return { id: `surface-trust-${index + 1}`, kind: "policy", status: ref.status, summary: ref.summary, surface_trust_refs: [ref] };
|
|
1846
2286
|
}
|
|
1847
2287
|
|
|
1848
|
-
|
|
2288
|
+
/**
|
|
2289
|
+
* #270 MEDIUM security fix: a `kind:"command"` evidence_refs entry whose command source is
|
|
2290
|
+
* genuinely non-runnable-by-design (e.g. a screenshot/dashboard reference the record-time
|
|
2291
|
+
* heuristic misclassifies) previously had NO escape hatch — this validator's die() was an
|
|
2292
|
+
* unconditional lockout. Mirrors --skip-ownership-guard's existing pattern in this file exactly:
|
|
2293
|
+
* a named, logged (never silent) bypass flag, not a silent fallback. `--skip-evidence-ref-
|
|
2294
|
+
* runnability-guard` is intentionally verbose/unambiguous so it is never reached for
|
|
2295
|
+
* accidentally — the die() message itself names the concrete remediations FIRST (move the prose
|
|
2296
|
+
* to ref.summary, or reclassify as kind:"external"/"artifact"), so the bypass is a last resort,
|
|
2297
|
+
* not the first thing an agent reaches for.
|
|
2298
|
+
*/
|
|
2299
|
+
function validateAcceptanceEvidenceRefs(dir: string, p?: ReturnType<typeof parseArgs>): void {
|
|
2300
|
+
if (p?.flags.has("skip-evidence-ref-runnability-guard")) {
|
|
2301
|
+
process.stderr.write("[record-evidence] evidence-ref runnability guard skipped via --skip-evidence-ref-runnability-guard\n");
|
|
2302
|
+
return;
|
|
2303
|
+
}
|
|
1849
2304
|
const file = path.join(dir, "acceptance.json");
|
|
1850
2305
|
if (!fs.existsSync(file)) return;
|
|
1851
2306
|
const data = loadJson(file);
|
|
1852
2307
|
if (!Array.isArray(data.criteria)) return;
|
|
2308
|
+
const { isRunnableCommandText, isAmbiguousAbsenceCommand } = loadRunnableCommandHelper();
|
|
1853
2309
|
data.criteria.forEach((criterion: AnyObj, index: number) => {
|
|
1854
|
-
if (criterion.evidence_refs
|
|
2310
|
+
if (criterion.evidence_refs === undefined) return;
|
|
2311
|
+
const refs = normalizeEvidenceRefs(criterion.evidence_refs, `acceptance.criteria[${index}].evidence_refs`);
|
|
2312
|
+
// #412 (AC8): a kind:"command" ref's command source (excerpt, falling back to url when that
|
|
2313
|
+
// is where the command string lives) must be a literally runnable shell command, not prose
|
|
2314
|
+
// describing a manual verification step — reject at RECORD time instead of letting it reach
|
|
2315
|
+
// the Stop-hook backstop (stop-goal-fit.js's bundleClaimedPassCommandChecks section B), which
|
|
2316
|
+
// would otherwise spawn `bash -lc "<a sentence>"` and misreport the resulting shell error as
|
|
2317
|
+
// a caught false-completion. Prose belongs in `summary` (never executed).
|
|
2318
|
+
refs.forEach((ref: AnyObj, refIndex: number) => {
|
|
2319
|
+
if (ref.kind !== "command") return;
|
|
2320
|
+
const commandSource = hasNonEmptyString(ref.excerpt) ? ref.excerpt : (hasNonEmptyString(ref.url) ? ref.url : "");
|
|
2321
|
+
if (!commandSource) return; // command refs may carry only `summary` — nothing to validate
|
|
2322
|
+
if (!isRunnableCommandText(commandSource)) {
|
|
2323
|
+
die(`acceptance.criteria[${index}].evidence_refs[${refIndex}]: kind:"command" ref's command text is not a runnable shell command: "${commandSource}" — remediate by either (1) moving the prose to ref.summary (never executed) and dropping/emptying ref.excerpt/ref.url, or (2) reclassifying this ref as kind:"external" (session-local attestation) or kind:"artifact" (a file/screenshot/dashboard reference) instead of kind:"command". As a last resort, --skip-evidence-ref-runnability-guard bypasses this check (logged, not silent).`);
|
|
2324
|
+
}
|
|
2325
|
+
// #362 (Wave 3, AC7): ADVISORY ONLY — never die(), never rejects/alters the ref. This is
|
|
2326
|
+
// deliberately the opposite posture from the runnability guard immediately above (which
|
|
2327
|
+
// stays fatal, unchanged). Guidance, not enforcement, per the plan's item 2: nudge a
|
|
2328
|
+
// newly-recorded bare (non-negated, non-count-asserted, non-chained) grep/diff evidence
|
|
2329
|
+
// ref toward a self-asserting form, without breaking back-compat with any already-recorded
|
|
2330
|
+
// evidence ref (#412) — an already-recorded ref reaching this validator on every subsequent
|
|
2331
|
+
// record-evidence call must keep passing, just with a repeated nudge, never a new failure.
|
|
2332
|
+
if (isAmbiguousAbsenceCommand(commandSource)) {
|
|
2333
|
+
process.stderr.write(`[record-evidence] advisory: acceptance.criteria[${index}].evidence_refs[${refIndex}] kind:"command" ref "${commandSource}" is a bare grep/diff invocation — its exit code is AMBIGUOUS (zero matches/no diff could mean PASS for an absence check or FAIL for a presence check). Consider re-recording it as a ${AMBIGUOUS_REMEDIATION_ADVICE} to remove the ambiguity. This is advisory only; the ref is NOT rejected.\n`);
|
|
2334
|
+
}
|
|
2335
|
+
});
|
|
1855
2336
|
});
|
|
1856
2337
|
}
|
|
1857
2338
|
export function writeState(dir: string, slug: string, status: string, phase: string, timestamp: string, summary: string, next = "continue"): void {
|
|
@@ -1917,6 +2398,83 @@ function checksFromBundle(dir: string): AnyObj[] {
|
|
|
1917
2398
|
const md = claim.metadata as AnyObj;
|
|
1918
2399
|
return md && typeof md === "object" && md.waiver && typeof md.waiver === "object" ? md.waiver as AnyObj : undefined;
|
|
1919
2400
|
};
|
|
2401
|
+
// #298: read side of the artifact_refs/standard_refs stamp (write side: buildTrustBundle's
|
|
2402
|
+
// claimMetadata.artifact_refs/.standard_refs, above). Previously these were validated on input
|
|
2403
|
+
// but never persisted at all — restoring them here is what makes them round-trip through a
|
|
2404
|
+
// second writer's rebuild instead of vanishing on the very first write.
|
|
2405
|
+
const refsOf = (claim: AnyObj): { artifact_refs?: AnyObj[]; standard_refs?: AnyObj[] } => {
|
|
2406
|
+
const md = claim.metadata as AnyObj;
|
|
2407
|
+
if (!md || typeof md !== "object") return {};
|
|
2408
|
+
const out: { artifact_refs?: AnyObj[]; standard_refs?: AnyObj[] } = {};
|
|
2409
|
+
if (Array.isArray(md.artifact_refs) && md.artifact_refs.length > 0) out.artifact_refs = md.artifact_refs;
|
|
2410
|
+
if (Array.isArray(md.standard_refs) && md.standard_refs.length > 0) out.standard_refs = md.standard_refs;
|
|
2411
|
+
return out;
|
|
2412
|
+
};
|
|
2413
|
+
// #270/#380: read side of the record-check output-digest stamp (write side: buildTrustBundle's
|
|
2414
|
+
// claimMetadata.output_digest, above). Restoring it here is what makes the digest survive a
|
|
2415
|
+
// subsequent writer's rebuild instead of vanishing on the very next record-evidence/
|
|
2416
|
+
// record-critique/record-learning call.
|
|
2417
|
+
const outputSha256Of = (claim: AnyObj): string | undefined => {
|
|
2418
|
+
const md = claim.metadata as AnyObj;
|
|
2419
|
+
const od = md && typeof md === "object" ? md.output_digest as AnyObj : undefined;
|
|
2420
|
+
return od && typeof od === "object" && od.algorithm === "sha256" && typeof od.hex === "string" && od.hex.length > 0 ? od.hex : undefined;
|
|
2421
|
+
};
|
|
2422
|
+
// #270(a)/(c): read side of the gate_claim stamp (write side: buildTrustBundle's
|
|
2423
|
+
// claimMetadata.gate_claim, above). Restoring expectation_id/claim_type/subject_type/step_id
|
|
2424
|
+
// is what lets a REBUILD (record-evidence/record-critique/record-learning, after the recorded
|
|
2425
|
+
// gate claim) recognize this as a previously-typed gate claim and reuse its frozen typing
|
|
2426
|
+
// instead of re-deriving via matchExpectsEntry against whatever step is active at rebuild time.
|
|
2427
|
+
const gateClaimOf = (claim: AnyObj): AnyObj | undefined => {
|
|
2428
|
+
const md = claim.metadata as AnyObj;
|
|
2429
|
+
return md && typeof md === "object" && md.gate_claim && typeof md.gate_claim === "object" ? md.gate_claim as AnyObj : undefined;
|
|
2430
|
+
};
|
|
2431
|
+
const applyGateClaimStamp = (check: AnyObj, claim: AnyObj): void => {
|
|
2432
|
+
const gc = gateClaimOf(claim);
|
|
2433
|
+
if (!gc) return;
|
|
2434
|
+
if (typeof gc.expectation_id === "string") check._gate_claim_expectation_id = gc.expectation_id;
|
|
2435
|
+
if (typeof gc.claim_type === "string") check._gate_claim_declared_type = gc.claim_type;
|
|
2436
|
+
if (typeof gc.subject_type === "string") check._gate_claim_declared_subject = gc.subject_type;
|
|
2437
|
+
if (typeof gc.step_id === "string") check._gate_claim_declared_step_id = gc.step_id;
|
|
2438
|
+
if (typeof gc.route_reason === "string") check._gate_claim_route_reason = gc.route_reason;
|
|
2439
|
+
};
|
|
2440
|
+
// #270 CRITICAL/HIGH fix: a claim that is gate-claim-SHAPED but carries NO metadata.gate_claim
|
|
2441
|
+
// stamp predates this cluster (#270/#344): buildTrustBundle could not have produced this shape
|
|
2442
|
+
// without ALSO writing the stamp, once the stamping code shipped, EXCEPT for one legitimate,
|
|
2443
|
+
// longstanding case that must NOT be flagged: a plain record-evidence check (kind:"external" or
|
|
2444
|
+
// otherwise) that simply happens to auto-match a declared kit-typed claim via matchExpectsEntry's
|
|
2445
|
+
// existing P-d fallback (ADR 0016) while a flow step is active — that path has ALWAYS existed,
|
|
2446
|
+
// never goes through record-gate-claim, and correctly has no gate_claim stamp (there is no
|
|
2447
|
+
// expectation id to freeze; matchExpectsEntry's heuristic result is expected to re-derive on
|
|
2448
|
+
// every rebuild for a plain check). The ONLY structurally reliable signal that a claim was
|
|
2449
|
+
// ACTUALLY produced by record-gate-claim (as opposed to a plain check that merely resembles one)
|
|
2450
|
+
// is record-gate-claim's own check-id-generation convention: `id: \`gate-claim-${checkId}\``
|
|
2451
|
+
// (see recordGateClaim) — no other producer in this file ever creates that id shape. Requiring
|
|
2452
|
+
// ALL FOUR signals (origin:"check", check_kind:"external", a kit-typed claimType — i.e. does NOT
|
|
2453
|
+
// start with "workflow." — AND a subjectId whose last path segment starts with "gate-claim-")
|
|
2454
|
+
// narrows detection to exactly the pre-cluster-270 defect class, without misclassifying an
|
|
2455
|
+
// ordinary declared check. Detected here (not in buildTrustBundle) because the claim's OWN
|
|
2456
|
+
// claimType/origin/check_kind/subjectId — the fields the detection needs — live on the bundle
|
|
2457
|
+
// claim, not on the reconstructed `check` object; buildTrustBundle only ever sees the `check`,
|
|
2458
|
+
// so the detection result is threaded through as a stamp on the check object itself, exactly
|
|
2459
|
+
// like every other gate_claim field above. buildTrustBundle's job is then only to die() loudly
|
|
2460
|
+
// on this stamp — never to re-derive or silently re-type it (that silent re-typing IS the
|
|
2461
|
+
// #268/#270 defect class).
|
|
2462
|
+
const gateClaimShapeUnstampedId = (claim: AnyObj): string | null => {
|
|
2463
|
+
if (claimOrigin(claim) !== "check") return null;
|
|
2464
|
+
const md = (claim.metadata && typeof claim.metadata === "object") ? claim.metadata as AnyObj : {};
|
|
2465
|
+
if (md.check_kind !== "external") return null;
|
|
2466
|
+
const claimType = typeof claim.claimType === "string" ? claim.claimType : "";
|
|
2467
|
+
if (!claimType || claimType.startsWith("workflow.")) return null;
|
|
2468
|
+
const subjectId = typeof claim.subjectId === "string" ? claim.subjectId : "";
|
|
2469
|
+
const lastSegment = subjectId.split("/").pop() ?? "";
|
|
2470
|
+
if (!lastSegment.startsWith("gate-claim-")) return null; // not record-gate-claim-shaped at all
|
|
2471
|
+
if (gateClaimOf(claim)) return null; // properly stamped — not this defect class
|
|
2472
|
+
return typeof claim.id === "string" ? claim.id : "<unknown>";
|
|
2473
|
+
};
|
|
2474
|
+
const applyGateClaimShapeUnstamped = (check: AnyObj, claim: AnyObj): void => {
|
|
2475
|
+
const unstampedId = gateClaimShapeUnstampedId(claim);
|
|
2476
|
+
if (unstampedId) check._gate_claim_shape_unstamped_claim_id = unstampedId;
|
|
2477
|
+
};
|
|
1920
2478
|
for (const ev of bundle.evidence) {
|
|
1921
2479
|
if (!ev || !ev.claimId) continue;
|
|
1922
2480
|
const claim = claimById.get(ev.claimId);
|
|
@@ -1931,6 +2489,11 @@ function checksFromBundle(dir: string): AnyObj[] {
|
|
|
1931
2489
|
if (ev.evidenceType) check.evidenceType = ev.evidenceType;
|
|
1932
2490
|
const waiver = waiverOf(claim);
|
|
1933
2491
|
if (waiver) check._waiver = waiver;
|
|
2492
|
+
Object.assign(check, refsOf(claim));
|
|
2493
|
+
const outputSha256 = outputSha256Of(claim);
|
|
2494
|
+
if (outputSha256) check._output_sha256 = outputSha256;
|
|
2495
|
+
applyGateClaimStamp(check, claim);
|
|
2496
|
+
applyGateClaimShapeUnstamped(check, claim);
|
|
1934
2497
|
checks.push(check);
|
|
1935
2498
|
}
|
|
1936
2499
|
// Also include check claims that have no evidence item (surface_trust_refs style).
|
|
@@ -1943,10 +2506,62 @@ function checksFromBundle(dir: string): AnyObj[] {
|
|
|
1943
2506
|
const check: AnyObj = { id: String(claim.subjectId || "").split("/").pop() || claim.id, kind, status: claim.value ?? "not_verified", summary: claim.fieldOrBehavior || "" };
|
|
1944
2507
|
const waiver = waiverOf(claim);
|
|
1945
2508
|
if (waiver) check._waiver = waiver;
|
|
2509
|
+
Object.assign(check, refsOf(claim));
|
|
2510
|
+
const outputSha256 = outputSha256Of(claim);
|
|
2511
|
+
if (outputSha256) check._output_sha256 = outputSha256;
|
|
2512
|
+
applyGateClaimStamp(check, claim);
|
|
2513
|
+
applyGateClaimShapeUnstamped(check, claim);
|
|
1946
2514
|
checks.push(check);
|
|
1947
2515
|
}
|
|
1948
2516
|
return checks;
|
|
1949
2517
|
}
|
|
2518
|
+
|
|
2519
|
+
/**
|
|
2520
|
+
* #270 CRITICAL fix (iteration 6): build the id -> hasStamp map normalizeCheck's narrowed
|
|
2521
|
+
* reserved-prefix exemption needs. A check's reconstructed `_gate_claim_expectation_id` (set by
|
|
2522
|
+
* applyGateClaimStamp above, from the claim's metadata.gate_claim) is present if and only if the
|
|
2523
|
+
* bundle claim backing this check id currently carries a live stamp. Every caller of
|
|
2524
|
+
* normalizeCheck (record-evidence, record-check, dogfood-pass) must derive this from the SAME
|
|
2525
|
+
* readBundleState/checksFromBundle snapshot used for the compose-safe merge, taken BEFORE
|
|
2526
|
+
* normalizeCheck runs, so the exemption sees the bundle state as it stands right now — not a
|
|
2527
|
+
* stale or partial view.
|
|
2528
|
+
*/
|
|
2529
|
+
function existingCheckStampMap(checks: AnyObj[]): Map<string, boolean> {
|
|
2530
|
+
const byId = new Map<string, boolean>();
|
|
2531
|
+
for (const c of checks) if (c && typeof c.id === "string") byId.set(c.id, typeof c._gate_claim_expectation_id === "string");
|
|
2532
|
+
return byId;
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
/**
|
|
2536
|
+
* #298/#270: the shared compose-safe read path every trust-bundle writer should use instead of
|
|
2537
|
+
* hand-assembling its own partial slice. Reconstructs ALL THREE families losslessly from the
|
|
2538
|
+
* existing bundle (checks via checksFromBundle, extended per above; criteria from
|
|
2539
|
+
* acceptance.json; critiques via critiquesFromBundle) so a writer that only intends to touch ONE
|
|
2540
|
+
* family never has to pass `[]` for the other two — that `[]`-for-untouched-slices pattern is
|
|
2541
|
+
* exactly what caused #270's 21-claims-to-1 wipe. Generalizes the pattern recordLearning already
|
|
2542
|
+
* used inline; behavior-neutral for recordLearning itself.
|
|
2543
|
+
*/
|
|
2544
|
+
function readBundleState(dir: string): { checks: AnyObj[]; criteria: AnyObj[]; critiques: AnyObj[] } {
|
|
2545
|
+
const acceptance = loadJson(path.join(dir, "acceptance.json"));
|
|
2546
|
+
return {
|
|
2547
|
+
checks: checksFromBundle(dir),
|
|
2548
|
+
criteria: Array.isArray(acceptance.criteria) ? acceptance.criteria : [],
|
|
2549
|
+
critiques: critiquesFromBundle(dir),
|
|
2550
|
+
};
|
|
2551
|
+
}
|
|
2552
|
+
/**
|
|
2553
|
+
* #298: compose-safe merge-by-id for the `checks` slice of readBundleState — a later check
|
|
2554
|
+
* with the same `id` supersedes/replaces the earlier one (same-id resupply is a legitimate
|
|
2555
|
+
* update, e.g. re-recording the same gate claim or the same named check after a fix); a check
|
|
2556
|
+
* with a new `id` is additive. Order is preserved (existing order, then newly-introduced ids
|
|
2557
|
+
* appended) so unrelated diagnostics (e.g. CI reconcile output) do not reorder churn.
|
|
2558
|
+
*/
|
|
2559
|
+
function mergeChecksById(existing: AnyObj[], incoming: AnyObj[]): AnyObj[] {
|
|
2560
|
+
const byId = new Map<string, AnyObj>();
|
|
2561
|
+
for (const c of existing) if (c && c.id) byId.set(c.id, c);
|
|
2562
|
+
for (const c of incoming) if (c && c.id) byId.set(c.id, c);
|
|
2563
|
+
return [...byId.values()];
|
|
2564
|
+
}
|
|
1950
2565
|
function critiquesFromBundle(dir: string): AnyObj[] {
|
|
1951
2566
|
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
1952
2567
|
if (!Array.isArray(bundle.claims)) return [];
|
|
@@ -1993,7 +2608,14 @@ async function recordEvidence(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
1993
2608
|
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
1994
2609
|
const _ts0 = opt(p, "timestamp", now());
|
|
1995
2610
|
const _waiver = parseWaiver(p, _ts0);
|
|
1996
|
-
|
|
2611
|
+
// #270 follow-up fix (iteration 5, narrowed iteration 6): read the bundle's CURRENT check ids
|
|
2612
|
+
// (and stamp status) before normalizeCheck runs so a correction of an UNSTAMPED wedged id is
|
|
2613
|
+
// exempted from the reserved gate-claim- prefix rejection below, while a brand-new mint of
|
|
2614
|
+
// that shape, OR supersession of a STAMPED (live) gate claim, is still rejected. Reused below
|
|
2615
|
+
// (not re-read) as _existingState for the compose-safe merge — one readBundleState call.
|
|
2616
|
+
const _existingState = readBundleState(dir);
|
|
2617
|
+
const _existingCheckStampById = existingCheckStampMap(_existingState.checks);
|
|
2618
|
+
const _checksRaw = [...opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json"), false, _existingCheckStampById)), ...opts(p, "surface-trust-json").map(surfaceCheckFromArtifact)];
|
|
1997
2619
|
// WS8 (AC4, iteration 2): a command-backed check reconciles against CI or fails — it can
|
|
1998
2620
|
// NEVER be waived. Reject --accepted-gap-reason/--waived-by on any check whose evidence
|
|
1999
2621
|
// classifies as test_output (build/types/lint/test/command, and security/browser/runtime
|
|
@@ -2011,23 +2633,181 @@ async function recordEvidence(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
2011
2633
|
}
|
|
2012
2634
|
const checks = _checksRaw.map((c) => _waiver ? { ...c, _waiver } : c);
|
|
2013
2635
|
if (!checks.length && opts(p, "surface-trust-json").length === 0) die("record-evidence requires at least one --check-json or --surface-trust-json");
|
|
2014
|
-
validateAcceptanceEvidenceRefs(dir);
|
|
2636
|
+
validateAcceptanceEvidenceRefs(dir, p);
|
|
2015
2637
|
// Phase 4c: bundle is the sole verification artifact — stop writing evidence.json and acceptance.json update.
|
|
2016
2638
|
const ts = opt(p, "timestamp", now());
|
|
2017
|
-
|
|
2018
|
-
|
|
2639
|
+
// #298: readBundleState + merge-by-id instead of unconditionally replacing every prior check —
|
|
2640
|
+
// record-evidence previously never called checksFromBundle(dir), so it dropped every check
|
|
2641
|
+
// recorded by an earlier record-evidence/record-check/record-gate-claim call. A later check
|
|
2642
|
+
// with the same id supersedes the earlier one (same-id resupply); a new id is additive.
|
|
2643
|
+
// (_existingState was already read above, before normalizeCheck ran, so the reserved-prefix
|
|
2644
|
+
// exists-check sees the SAME bundle snapshot the merge below uses — no second read needed.)
|
|
2019
2645
|
const _criteriaStatus = verdict === "pass" ? "pass" : verdict === "fail" ? "fail" : "not_verified";
|
|
2020
|
-
const _criteriaForBundle: AnyObj[] =
|
|
2646
|
+
const _criteriaForBundle: AnyObj[] = _existingState.criteria.map((c: AnyObj) => ({ ...c, status: _criteriaStatus }));
|
|
2647
|
+
const _mergedChecks = mergeChecksById(_existingState.checks, checks);
|
|
2021
2648
|
// #268: preserve any existing critique claims (including superseded history) instead of dropping
|
|
2022
2649
|
// them — record-evidence previously hardcoded critiques:[] here, silently erasing finding history
|
|
2023
2650
|
// whenever it ran after a critique existed.
|
|
2024
|
-
|
|
2025
|
-
assertBundleWritten(await writeTrustBundle(dir, slug, ts, checks, _criteriaForBundle, _existingCritiques));
|
|
2651
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, _criteriaForBundle, _existingState.critiques));
|
|
2026
2652
|
const stateStatus = verdict === "pass" ? "verified" : verdict === "fail" ? "failed" : "not_verified";
|
|
2027
2653
|
writeState(dir, slug, stateStatus, "verification", ts, "Evidence recorded.");
|
|
2028
2654
|
return 0;
|
|
2029
2655
|
}
|
|
2030
2656
|
|
|
2657
|
+
// #380: bounded stdout+stderr digest for a record-check execution, mirroring
|
|
2658
|
+
// scripts/hooks/evidence-capture.js's MAX_OUTPUT_SCAN bound (64 KiB) so the two capture code
|
|
2659
|
+
// paths (host-observed vs. self-executed) stay size-disciplined the same way, even though this
|
|
2660
|
+
// one runs the command itself rather than observing a host tool_response.
|
|
2661
|
+
const RECORD_CHECK_MAX_OUTPUT = 64 * 1024;
|
|
2662
|
+
function clampOutput(text: string): string {
|
|
2663
|
+
return text.length > RECORD_CHECK_MAX_OUTPUT ? text.slice(0, RECORD_CHECK_MAX_OUTPUT) : text;
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
/**
|
|
2667
|
+
* record-check — run a command and record a capture-backed check in one step (#380).
|
|
2668
|
+
*
|
|
2669
|
+
* Trust posture (#270 MEDIUM fix — was previously misdescribed): record-check EXECUTES a command
|
|
2670
|
+
* ON THE AGENT'S BEHALF, in-process, right here — it is not an observer. This is a materially
|
|
2671
|
+
* DIFFERENT trust posture than scripts/hooks/evidence-capture.js, which never executes anything
|
|
2672
|
+
* itself; it only OBSERVES a host tool_response that some other, already-executed tool call
|
|
2673
|
+
* produced (a PostToolUse hook). record-check's exitCode/observedResult classification mirrors
|
|
2674
|
+
* evidence-capture.js's observeResult() heuristic (a clean integer exit code is authoritative),
|
|
2675
|
+
* but the EXECUTION itself is this function's own doing, under this process's privileges — an
|
|
2676
|
+
* agent invoking record-check is asking this CLI to run a command for it, not merely to attest to
|
|
2677
|
+
* something that already ran. Callers and reviewers should weigh that accordingly: record-check
|
|
2678
|
+
* is a convenience that collapses "run a command" + "record its result" into one step, not a
|
|
2679
|
+
* passive-observation capture path.
|
|
2680
|
+
*
|
|
2681
|
+
* Syntax: `workflow-sidecar record-check <artifact-dir> -- <command...>` (argv after the FIRST
|
|
2682
|
+
* literal `--` token is the command to execute verbatim, split by main() before parseArgs — see
|
|
2683
|
+
* main()'s pre-split below) or `--command "<shell string>"` for parity with record-gate-claim
|
|
2684
|
+
* when the caller already has a single shell string rather than an argv array.
|
|
2685
|
+
*
|
|
2686
|
+
* Captures { exitCode, observedResult } the same way evidence-capture.js's observeResult() does:
|
|
2687
|
+
* a clean integer exit code drives observedResult (0 => pass, nonzero => fail). Records a
|
|
2688
|
+
* kind:"command" check with a real execution.label (the executed command), composed losslessly
|
|
2689
|
+
* through the same readBundleState + mergeChecksById + writeTrustBundle path every other writer
|
|
2690
|
+
* in this file uses — record-check is the compose-safe pattern PLUS an execution step in front
|
|
2691
|
+
* of it, not a parallel bundle-writing implementation.
|
|
2692
|
+
*
|
|
2693
|
+
* The captured stdout+stderr is hashed (sha256) and the digest — never the raw output — is
|
|
2694
|
+
* persisted onto the resulting claim's metadata.output_digest (see buildTrustBundle's
|
|
2695
|
+
* outputDigestMeta) so a later reviewer can confirm two independent runs produced byte-identical
|
|
2696
|
+
* output without the trust.bundle itself ever carrying raw command output (secret-safe by
|
|
2697
|
+
* construction).
|
|
2698
|
+
*
|
|
2699
|
+
* A non-zero exit records status "fail" (never silently swallowed) AND this command itself exits
|
|
2700
|
+
* non-zero — a check that failed to run correctly must be loud, not silently recorded as if
|
|
2701
|
+
* nothing happened.
|
|
2702
|
+
*/
|
|
2703
|
+
async function recordCheck(p: ReturnType<typeof parseArgs>, commandArgv: string[] | null): Promise<number> {
|
|
2704
|
+
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
2705
|
+
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
2706
|
+
const ts = opt(p, "timestamp", now());
|
|
2707
|
+
const commandString = opt(p, "command");
|
|
2708
|
+
if (!commandArgv?.length && !commandString) die('record-check requires a command: either `record-check <dir> -- <command...>` or `--command "<shell string>"`');
|
|
2709
|
+
if (commandArgv?.length && commandString) die("record-check: pass the command via EITHER `-- <command...>` OR --command, not both");
|
|
2710
|
+
|
|
2711
|
+
const repoRoot = findRepoRootFromDir(dir);
|
|
2712
|
+
let displayCommand: string;
|
|
2713
|
+
let exitCode: number | null;
|
|
2714
|
+
let stdout = "";
|
|
2715
|
+
let stderr = "";
|
|
2716
|
+
try {
|
|
2717
|
+
if (commandArgv?.length) {
|
|
2718
|
+
displayCommand = commandArgv.join(" ");
|
|
2719
|
+
const out = execFileSync(commandArgv[0]!, commandArgv.slice(1), { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 600000 });
|
|
2720
|
+
stdout = out;
|
|
2721
|
+
exitCode = 0;
|
|
2722
|
+
} else {
|
|
2723
|
+
// #412: a --command shell string is validated the same way record-gate-claim's --command
|
|
2724
|
+
// is (single-sourced isRunnableCommandText) — prose does not belong here either.
|
|
2725
|
+
const { isRunnableCommandText } = loadRunnableCommandHelper();
|
|
2726
|
+
if (!isRunnableCommandText(commandString)) die(`record-check --command "${commandString}" is not a runnable shell command.`);
|
|
2727
|
+
displayCommand = commandString;
|
|
2728
|
+
const out = execFileSync("bash", ["-lc", commandString], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 600000 });
|
|
2729
|
+
stdout = out;
|
|
2730
|
+
exitCode = 0;
|
|
2731
|
+
}
|
|
2732
|
+
} catch (err) {
|
|
2733
|
+
const e = err as { status?: number | null; stdout?: string; stderr?: string; message?: string };
|
|
2734
|
+
exitCode = typeof e.status === "number" ? e.status : null;
|
|
2735
|
+
stdout = typeof e.stdout === "string" ? e.stdout : "";
|
|
2736
|
+
stderr = typeof e.stderr === "string" ? e.stderr : (e.message ?? "");
|
|
2737
|
+
displayCommand = commandArgv?.length ? commandArgv.join(" ") : commandString;
|
|
2738
|
+
}
|
|
2739
|
+
// Mirror evidence-capture.js's observeResult(): a clean integer exit code is authoritative
|
|
2740
|
+
// (0 => pass, nonzero => fail); this path always HAS a clean integer exit code because it ran
|
|
2741
|
+
// the command itself (unlike the host-observation path, which sometimes only has failure
|
|
2742
|
+
// indicators). See scripts/hooks/evidence-capture.js's observeResult()/cleanExitCode().
|
|
2743
|
+
//
|
|
2744
|
+
// #362 (Wave 3, AC6): the SAME narrow, two-binary carve-out `runBackstop`/`readCommandLog`
|
|
2745
|
+
// (stop-goal-fit.js) and `observeResult` (evidence-capture.js) already apply — a bare
|
|
2746
|
+
// (non-negated, non-count-asserted, non-chained) `grep`/`diff` invocation that exits EXACTLY
|
|
2747
|
+
// 1 is exit-code-ambiguous (zero matches/no diff could be the author's intended PASS for an
|
|
2748
|
+
// absence check, or an unintended miss for a presence check) — is applied HERE at record time
|
|
2749
|
+
// too, since record-check is the most direct match to the issue's own AC12 field evidence: it
|
|
2750
|
+
// executes the command itself and stamps the resulting status. Exit codes >= 2 are untouched
|
|
2751
|
+
// (still "fail", a real tool error) — the carve-out is exit-code-1-only, never widened.
|
|
2752
|
+
//
|
|
2753
|
+
// There is no "ambiguous" value in the check-status enum (`checkStatuses`,
|
|
2754
|
+
// schemas/workflow-acceptance.schema.json's criteria[].status enum) and none is added here —
|
|
2755
|
+
// per the plan (and ADR 0008/0010 consume-never-fork), the ambiguous case maps onto the
|
|
2756
|
+
// EXISTING "not_verified" status rather than inventing a new enum value that every consumer of
|
|
2757
|
+
// that enum would need auditing for. The distinction from a hard "fail" is carried in the
|
|
2758
|
+
// stderr note and the check summary text, not a new status value.
|
|
2759
|
+
const { isAmbiguousAbsenceCommand } = loadRunnableCommandHelper();
|
|
2760
|
+
const ambiguous = exitCode === 1 && isAmbiguousAbsenceCommand(displayCommand ?? "");
|
|
2761
|
+
const status = exitCode === 0 ? "pass" : (ambiguous ? "not_verified" : "fail");
|
|
2762
|
+
// #270 MEDIUM fix: hash the captured (clamped) output — the digest itself is what gets
|
|
2763
|
+
// persisted onto claim.metadata (below, via buildTrustBundle), NEVER the raw output. This is
|
|
2764
|
+
// secret-safe by construction: even if the executed command's stdout/stderr contained a
|
|
2765
|
+
// credential or token, only its sha256 hex digest ever reaches the trust.bundle (which is
|
|
2766
|
+
// frequently committed/shared), not the text itself. The previous `_output_digest` field held
|
|
2767
|
+
// the raw clamped text under a misleading "digest" name and was never actually persisted
|
|
2768
|
+
// anywhere (dead code) — this replaces it with a REAL digest that IS persisted.
|
|
2769
|
+
const capturedOutput = clampOutput(`${stdout}${stderr ? `
|
|
2770
|
+
${stderr}` : ""}`.trim());
|
|
2771
|
+
const outputSha256 = capturedOutput ? createHash("sha256").update(capturedOutput, "utf8").digest("hex") : null;
|
|
2772
|
+
const checkIdRaw = opt(p, "id") || displayCommand;
|
|
2773
|
+
const checkId = checkIdRaw.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "record-check";
|
|
2774
|
+
const summary = opt(p, "summary", `record-check: ${displayCommand}${exitCode !== null ? ` (exit ${exitCode})` : ""}${ambiguous ? " — AMBIGUOUS (bare grep/diff exit 1): NOT_VERIFIED, not silently pass/fail" : ""}`);
|
|
2775
|
+
// #270 follow-up fix (iteration 5, narrowed iteration 6): read the bundle's CURRENT check ids
|
|
2776
|
+
// (and stamp status) BEFORE normalizeCheck runs so a correction of an UNSTAMPED wedged id is
|
|
2777
|
+
// exempted from the reserved gate-claim- prefix rejection, while a brand-new mint of that
|
|
2778
|
+
// shape, OR supersession of a STAMPED (live) gate claim, is still rejected. Reused below (not
|
|
2779
|
+
// re-read) for the compose-safe merge — one readBundleState call.
|
|
2780
|
+
const _existingState = readBundleState(dir);
|
|
2781
|
+
const _existingCheckStampById = existingCheckStampMap(_existingState.checks);
|
|
2782
|
+
// kind:"command" + a real `command` string already derives evidenceType "test_output" via
|
|
2783
|
+
// classifyEvidence in buildTrustBundle — no separate evidenceType input is consumed there.
|
|
2784
|
+
const check: AnyObj = normalizeCheck({
|
|
2785
|
+
id: checkId,
|
|
2786
|
+
kind: "command",
|
|
2787
|
+
status,
|
|
2788
|
+
summary,
|
|
2789
|
+
command: displayCommand,
|
|
2790
|
+
}, false, _existingCheckStampById);
|
|
2791
|
+
if (outputSha256) check._output_sha256 = outputSha256;
|
|
2792
|
+
|
|
2793
|
+
const _mergedChecks = mergeChecksById(_existingState.checks, [check]);
|
|
2794
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, _existingState.criteria, _existingState.critiques));
|
|
2795
|
+
if (ambiguous) {
|
|
2796
|
+
// #362 (AC6): never silent-pass, never hard-fail — a bare grep/diff exit 1 is recorded
|
|
2797
|
+
// "not_verified" (above) and this is surfaced loudly on stderr, but record-check itself
|
|
2798
|
+
// still exits 0: the command DID run (this is not a tool/execution error), and the operator
|
|
2799
|
+
// needs an actionable nudge, not a blocked pipeline. Exit codes >= 2 for grep/diff remain a
|
|
2800
|
+
// hard "fail" via the branch below, unchanged.
|
|
2801
|
+
process.stderr.write(`[record-check] command "${displayCommand}" exited 1 — for bare grep/diff this may mean zero matches/no differences (PASS for an absence check) or an unintended miss (FAIL for a presence check); recorded status: not_verified (ambiguous), not silently pass or fail. Re-record this check as a ${AMBIGUOUS_REMEDIATION_ADVICE} to remove the ambiguity.\n`);
|
|
2802
|
+
return 0;
|
|
2803
|
+
}
|
|
2804
|
+
if (status === "fail") {
|
|
2805
|
+
process.stderr.write(`[record-check] command failed (exit ${exitCode ?? "unknown"}): ${displayCommand}\n${stderr ? `${stderr}\n` : ""}`);
|
|
2806
|
+
return 1;
|
|
2807
|
+
}
|
|
2808
|
+
return 0;
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2031
2811
|
function diagnostic(dir: string, code: string, summary: string): never {
|
|
2032
2812
|
const payload = { timestamp: now(), code, summary };
|
|
2033
2813
|
appendJsonl(path.join(dir, "transition-diagnostics.jsonl"), payload);
|
|
@@ -2042,6 +2822,7 @@ function diagnostic(dir: string, code: string, summary: string): never {
|
|
|
2042
2822
|
* --status <pass|fail|not_verified> (required)
|
|
2043
2823
|
* --summary <text> (required)
|
|
2044
2824
|
* --expectation <id> (optional; auto-resolved when the gate has one entry)
|
|
2825
|
+
* --route-reason <classifier> (optional; fail only, interpreted by Flow)
|
|
2045
2826
|
* --evidence-json <json> (optional; structured evidence refs)
|
|
2046
2827
|
*
|
|
2047
2828
|
* The producer emits a check of kind="external" targeting the gate expectation's declared
|
|
@@ -2068,6 +2849,9 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
2068
2849
|
if (!["pass", "fail", "not_verified"].includes(statusVal)) die("--status must be one of: pass, fail, not_verified");
|
|
2069
2850
|
const summary = opt(p, "summary") || die("--summary is required");
|
|
2070
2851
|
const expectationId = opt(p, "expectation");
|
|
2852
|
+
const routeReason = opt(p, "route-reason");
|
|
2853
|
+
if (routeReason && statusVal !== "fail") die("--route-reason is only valid with --status fail");
|
|
2854
|
+
if (routeReason && !/^[a-z][a-z0-9_-]*$/.test(routeReason)) die("--route-reason must be a lowercase classifier identifier");
|
|
2071
2855
|
|
|
2072
2856
|
// Resolve the active flow step from current.json. #291 Wave 2 Task 2.1 (§7)/Task 2.2: resolve
|
|
2073
2857
|
// the CALLING actor's own current-pointer (per-actor-first, legacy-fallback) rather than an
|
|
@@ -2078,6 +2862,9 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
2078
2862
|
const gateClaimActorKey = resolveReadActorKey(p);
|
|
2079
2863
|
const activeStep = resolveActiveFlowStep(flowAgentsDir, gateClaimActorKey);
|
|
2080
2864
|
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)");
|
|
2865
|
+
if (routeReason && !activeStep.routeBackReasons.includes(routeReason)) {
|
|
2866
|
+
die(`--route-reason "${routeReason}" is not declared by gate "${activeStep.gateId}". Available: ${activeStep.routeBackReasons.join(", ") || "none"}`);
|
|
2867
|
+
}
|
|
2081
2868
|
|
|
2082
2869
|
const expects = activeStep.gateExpects;
|
|
2083
2870
|
if (expects.length === 0) die(`record-gate-claim: active step "${activeStep.stepId}" gate "${activeStep.gateId}" has no expects[] entries`);
|
|
@@ -2109,6 +2896,7 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
2109
2896
|
status: statusVal,
|
|
2110
2897
|
summary,
|
|
2111
2898
|
_gate_claim_expectation_id: targetExpectation.id,
|
|
2899
|
+
...(routeReason ? { _gate_claim_route_reason: routeReason } : {}),
|
|
2112
2900
|
};
|
|
2113
2901
|
|
|
2114
2902
|
// Include structured evidence refs if provided
|
|
@@ -2118,13 +2906,33 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
2118
2906
|
check.artifact_refs = evidenceRefs;
|
|
2119
2907
|
}
|
|
2120
2908
|
|
|
2121
|
-
|
|
2909
|
+
// #270(b)/#412: --command gives a gate claim a real, runnable execution.label distinct from
|
|
2910
|
+
// the human --summary prose, so stop-goal-fit.js's bundleClaimedPassCommandChecks section (B)
|
|
2911
|
+
// finds a real command to re-run instead of falling back to fieldOrBehavior (the --summary
|
|
2912
|
+
// prose itself), which it would otherwise execute as a shell command under
|
|
2913
|
+
// FLOW_AGENTS_GOAL_FIT_RECHECK. Validated at record time with the same isRunnableCommandText
|
|
2914
|
+
// heuristic the Stop-hook backstop uses (single-sourced — see loadRunnableCommandHelper).
|
|
2915
|
+
const gateCommand = opt(p, "command");
|
|
2916
|
+
if (gateCommand) {
|
|
2917
|
+
const { isRunnableCommandText } = loadRunnableCommandHelper();
|
|
2918
|
+
if (!isRunnableCommandText(gateCommand)) die(`record-gate-claim --command "${gateCommand}" is not a runnable shell command — prose belongs in --summary, which is never executed.`);
|
|
2919
|
+
check.command = gateCommand;
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
const checkNormalized = normalizeCheck(check, /* allowGateClaimPrefix */ true);
|
|
2122
2923
|
// WS8 (ADR 0020): honor the accepted-gap waiver flags for a gate claim too.
|
|
2123
2924
|
const gateWaiver = parseWaiver(p, ts);
|
|
2124
2925
|
if (gateWaiver) checkNormalized._waiver = gateWaiver;
|
|
2125
2926
|
// Log the targeted gate expectation for transparency (goes to stderr only)
|
|
2126
2927
|
process.stderr.write(`[record-gate-claim] targeting ${activeStep.stepId}/${activeStep.gateId}/${targetExpectation.id} → claimType=${claimType} subjectType=${subjectType}${gateWaiver ? " (WAIVED accepted_gap)" : ""}\n`);
|
|
2127
|
-
|
|
2928
|
+
// #270: accumulate into the FULL existing bundle state (checks/criteria/critiques) instead of
|
|
2929
|
+
// the prior [checkNormalized], [], [] call, which clobbered every other check/criterion/critique
|
|
2930
|
+
// in the bundle on every gate-claim write (the 21-claims-to-1 wipe). A gate claim against the
|
|
2931
|
+
// SAME expectation id supersedes the earlier check for that expectation (mergeChecksById); a
|
|
2932
|
+
// gate claim against a different expectation is additive.
|
|
2933
|
+
const _existingState = readBundleState(dir);
|
|
2934
|
+
const _mergedChecks = mergeChecksById(_existingState.checks, [checkNormalized]);
|
|
2935
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, _existingState.criteria, _existingState.critiques, gateClaimActorKey));
|
|
2128
2936
|
return 0;
|
|
2129
2937
|
}
|
|
2130
2938
|
|
|
@@ -2215,6 +3023,7 @@ async function promote(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
2215
3023
|
await publishDelivery(dir, publishRepoRoot).catch((err: unknown) => {
|
|
2216
3024
|
if (err instanceof InvalidBundleShapeError) throw err;
|
|
2217
3025
|
if (err instanceof NotFreshHolderError) throw err;
|
|
3026
|
+
if (err instanceof RepoHeadMismatchError) throw err;
|
|
2218
3027
|
process.stderr.write(`[promote] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
2219
3028
|
});
|
|
2220
3029
|
}
|
|
@@ -2270,7 +3079,7 @@ async function advanceState(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
2270
3079
|
// call site ALSO dual-writes the per-actor projection, not only ensure-session's call site —
|
|
2271
3080
|
// otherwise a session that only ever calls advance-state (never re-running ensure-session)
|
|
2272
3081
|
// would never get a per-actor current.json mirror for its own FlowDefinition routing keys.
|
|
2273
|
-
writeCurrent(root, dir, timestamp, "workflow-sidecar", "advance-state", flow, stepId,
|
|
3082
|
+
writeCurrent(root, dir, timestamp, "workflow-sidecar", "advance-state", flow, stepId, resolveReadActorKey(p));
|
|
2274
3083
|
}
|
|
2275
3084
|
}
|
|
2276
3085
|
livenessLifecycle(dir, slug, LIVENESS_TERMINAL.has(status) ? "release" : "heartbeat", timestamp);
|
|
@@ -2290,6 +3099,7 @@ async function advanceState(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
2290
3099
|
await publishDelivery(dir, publishRepoRoot).catch((err: unknown) => {
|
|
2291
3100
|
if (err instanceof InvalidBundleShapeError) throw err;
|
|
2292
3101
|
if (err instanceof NotFreshHolderError) throw err;
|
|
3102
|
+
if (err instanceof RepoHeadMismatchError) throw err;
|
|
2293
3103
|
process.stderr.write(`[advance-state] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
2294
3104
|
});
|
|
2295
3105
|
}
|
|
@@ -2329,10 +3139,13 @@ async function recordCritique(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
2329
3139
|
return e;
|
|
2330
3140
|
});
|
|
2331
3141
|
const critiques = [..._mergedCritiques, critique];
|
|
2332
|
-
// Phase 4c: build bundle from raw inputs; read checks
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
3142
|
+
// Phase 4c: build bundle from raw inputs; read checks/criteria via the shared compose-safe
|
|
3143
|
+
// readBundleState path (#270 LOW consolidation) instead of hand-rolling the identical
|
|
3144
|
+
// checksFromBundle + acceptance.json read inline — this is the exact pattern readBundleState
|
|
3145
|
+
// already exists to share; recordLearning uses it directly (see below) and recordCritique
|
|
3146
|
+
// previously duplicated it by hand for no reason.
|
|
3147
|
+
const _critiqueState = readBundleState(dir);
|
|
3148
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueState.checks, _critiqueState.criteria, critiques));
|
|
2336
3149
|
return 0;
|
|
2337
3150
|
}
|
|
2338
3151
|
function frontmatter(text: string, key: string): string {
|
|
@@ -2386,6 +3199,7 @@ async function recordRelease(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
2386
3199
|
await publishDelivery(dir, publishRepoRoot).catch((err: unknown) => {
|
|
2387
3200
|
if (err instanceof InvalidBundleShapeError) throw err;
|
|
2388
3201
|
if (err instanceof NotFreshHolderError) throw err;
|
|
3202
|
+
if (err instanceof RepoHeadMismatchError) throw err;
|
|
2389
3203
|
process.stderr.write(`[record-release] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
2390
3204
|
});
|
|
2391
3205
|
return 0;
|
|
@@ -2738,6 +3552,41 @@ export class NotFreshHolderError extends Error {
|
|
|
2738
3552
|
}
|
|
2739
3553
|
}
|
|
2740
3554
|
|
|
3555
|
+
/**
|
|
3556
|
+
* Distinct, identifiable error for a publish attempt whose resolved `repoRoot` does not
|
|
3557
|
+
* positively confirm the session's sealed `trust.checkpoint.json` `commit_sha` — a FOURTH,
|
|
3558
|
+
* distinct fail-closed tier (#413 Facet A), never conflated with `InvalidBundleShapeError`
|
|
3559
|
+
* (bundle shape) or `NotFreshHolderError` (actor hold). This is the "refuse loudly when the cwd
|
|
3560
|
+
* repo does not match the sealed checkpoint commit_sha" fallback the issue itself proposed:
|
|
3561
|
+
* `publishDelivery`'s `repoRoot` is resolved by walking up from the ARTIFACT directory
|
|
3562
|
+
* (findRepoRootFromDirStrict), which always physically lives under the primary checkout even
|
|
3563
|
+
* when invoked from an unrelated worktree/repo — so a positively-confirmed mismatch here is the
|
|
3564
|
+
* signal that this resolved repoRoot is NOT the repo the sealed checkpoint was produced against,
|
|
3565
|
+
* and publishing into it would silently write to the wrong tree.
|
|
3566
|
+
*
|
|
3567
|
+
* #413 iteration-2 Fix 4: thrown for the ONE positively-determinable `mismatchKind` (see
|
|
3568
|
+
* checkpointHeadAncestry's doc comment) — `"positive"` (repoRoot's HEAD is confirmed NOT an
|
|
3569
|
+
* ancestor-or-equal of commit_sha; `headSha` is a real sha). It is intentionally NEVER thrown for
|
|
3570
|
+
* `"undeterminable-shallow"` (e.g. a shallow clone missing the commit object) — that case only
|
|
3571
|
+
* warns and allows the publish (see publishDelivery's own gate). A non-git `repoRoot` classifies
|
|
3572
|
+
* as `mismatchKind === "none"` (no opinion — allow; see checkpointHeadAncestry's doc comment for
|
|
3573
|
+
* why the iteration-2 Fix 5 refusal here was reverted in iteration-3) and never throws this error
|
|
3574
|
+
* either.
|
|
3575
|
+
*/
|
|
3576
|
+
export class RepoHeadMismatchError extends Error {
|
|
3577
|
+
readonly code = "PUBLISH_DELIVERY_REPO_HEAD_MISMATCH" as const;
|
|
3578
|
+
readonly repoRoot: string;
|
|
3579
|
+
readonly commitSha: string;
|
|
3580
|
+
readonly headSha: string;
|
|
3581
|
+
constructor(repoRoot: string, commitSha: string, headSha: string) {
|
|
3582
|
+
super(`publish-delivery refused — resolved repoRoot '${repoRoot}' HEAD '${headSha}' is not an ancestor-or-equal of the sealed trust.checkpoint.json commit_sha '${commitSha}'. This usually means repoRoot was resolved to the wrong checkout (e.g. invoked from a worktree whose artifact dir physically lives under a different primary checkout), or repoRoot is not a valid git working tree at all. Pass --repo-root explicitly naming the checkout whose HEAD matches '${commitSha}', or re-seal the checkpoint (seal-checkpoint) against this repoRoot's current HEAD if this really is the intended target.`);
|
|
3583
|
+
this.name = "RepoHeadMismatchError";
|
|
3584
|
+
this.repoRoot = repoRoot;
|
|
3585
|
+
this.commitSha = commitSha;
|
|
3586
|
+
this.headSha = headSha;
|
|
3587
|
+
}
|
|
3588
|
+
}
|
|
3589
|
+
|
|
2741
3590
|
/**
|
|
2742
3591
|
* Human-actionable fix text per divergence type (Q2: unwaived-assumed's message always
|
|
2743
3592
|
* carries the "waiver voided by a mixed record-evidence call" root-cause hint — shape #5 is
|
|
@@ -2778,35 +3627,122 @@ function preflightFixHint(type: string): string {
|
|
|
2778
3627
|
* @param repoRoot repo root used for manifest resolution + the optional ancestor warning
|
|
2779
3628
|
* @param manifestOverride optional --manifest JSON string (CLI passthrough)
|
|
2780
3629
|
*/
|
|
2781
|
-
type TrustReconcileHelper = ReturnType<typeof loadTrustReconcileHelper>;
|
|
2782
3630
|
|
|
2783
3631
|
/**
|
|
2784
|
-
*
|
|
2785
|
-
*
|
|
2786
|
-
*
|
|
2787
|
-
*
|
|
3632
|
+
* #413 Facet A: best-effort single-direction ancestor check scoped to the specific needs of
|
|
3633
|
+
* checkpointHeadAncestry's hard-refuse gate — is `ancestorSha` positively determinable as an
|
|
3634
|
+
* ancestor of (or equal to) `descendantSha` in `repoRoot`, OR positively determinable as NOT an
|
|
3635
|
+
* ancestor, OR is the question simply UNDETERMINABLE here (shallow clone missing the object,
|
|
3636
|
+
* unknown/garbage sha, git itself unusable, etc.)? This is intentionally a SEPARATE, local
|
|
3637
|
+
* classification from `trust-reconcile.js`'s shared `isAncestorCommit()` (which the rest of the
|
|
3638
|
+
* repo, including checkpointStalenessWarning's own non-blocking warning below, correctly keeps
|
|
3639
|
+
* as a simple best-effort boolean that "fails toward false/stale" for every non-CI caller) —
|
|
3640
|
+
* that collapsed boolean is exactly right for a WARNING, but wrong for a HARD REFUSE gate: a
|
|
3641
|
+
* shallow clone's git-object-missing failure and a genuine cross-repo mismatch both surface as
|
|
3642
|
+
* `false` there, yet only the second is real harm (#413 iteration-2 Fix 4). `git merge-base
|
|
3643
|
+
* --is-ancestor` itself already distinguishes these at the exit-code level (0 = ancestor, 1 =
|
|
3644
|
+
* positively NOT an ancestor, anything else / a spawn failure = underivable), so this reuses
|
|
3645
|
+
* that primitive directly rather than duplicating trust-reconcile.js's collapsed wrapper.
|
|
2788
3646
|
*/
|
|
2789
|
-
function
|
|
2790
|
-
|
|
3647
|
+
function classifyAncestry(repoRoot: string, ancestorSha: string, descendantSha: string): "ancestor" | "not-ancestor" | "undeterminable" {
|
|
3648
|
+
if (!ancestorSha || !descendantSha) return "undeterminable";
|
|
3649
|
+
try {
|
|
3650
|
+
const res = spawnSync("git", ["merge-base", "--is-ancestor", ancestorSha, descendantSha], {
|
|
3651
|
+
cwd: repoRoot,
|
|
3652
|
+
stdio: "ignore",
|
|
3653
|
+
});
|
|
3654
|
+
if (!res || res.error) return "undeterminable";
|
|
3655
|
+
if (res.status === 0) return "ancestor";
|
|
3656
|
+
if (res.status === 1) return "not-ancestor";
|
|
3657
|
+
return "undeterminable"; // e.g. 128 (unknown commit, shallow clone missing the object, etc.)
|
|
3658
|
+
} catch {
|
|
3659
|
+
return "undeterminable";
|
|
3660
|
+
}
|
|
3661
|
+
}
|
|
3662
|
+
|
|
3663
|
+
/**
|
|
3664
|
+
* #413 Facet A: read `trust.checkpoint.json`'s `commit_sha` (if the checkpoint exists and
|
|
3665
|
+
* carries one) and resolve `git rev-parse HEAD` with an EXPLICIT `{ cwd: repoRoot }` (never the
|
|
3666
|
+
* bare no-cwd pattern `resolveCommitSha()` uses — see that function's own doc comment for why
|
|
3667
|
+
* that variant is intentionally cwd-inheriting for the seal-checkpoint path, and must not be
|
|
3668
|
+
* reused here). Returns `{ commitSha: null }` when there is no checkpoint or no commit_sha yet
|
|
3669
|
+
* (a session with nothing sealed has nothing to compare against — callers must treat this as
|
|
3670
|
+
* "no opinion", never as a mismatch). Returns `{ commitSha, headSha, isAncestor }` when both
|
|
3671
|
+
* shas are resolvable. This is the ONE shared comparison both `checkpointStalenessWarning`
|
|
3672
|
+
* (non-blocking) and `publishDelivery`'s hard refuse gate (fail-closed) call — never a second,
|
|
3673
|
+
* independently-drifting git shell-out.
|
|
3674
|
+
*
|
|
3675
|
+
* #413 iteration-2 Fix 4 (scoped to the hard-refuse tier ONLY — checkpointStalenessWarning's
|
|
3676
|
+
* non-blocking warning is intentionally unaffected, see its own doc comment): the returned
|
|
3677
|
+
* `isAncestor` stays a simple boolean (`true` unless POSITIVELY determined false) for backward
|
|
3678
|
+
* compatibility with that warning caller, but the `mismatchKind` field lets publishDelivery's
|
|
3679
|
+
* hard gate distinguish these cases precisely:
|
|
3680
|
+
* - "none": no checkpoint, no commit_sha, `repoRoot` is not a git working tree at all (or is a
|
|
3681
|
+
* freshly-initialized repo with no resolvable HEAD), or the commit_sha IS an
|
|
3682
|
+
* ancestor-or-equal of HEAD — never refuse. A non-git `repoRoot` is a SUPPORTED flow (e.g.
|
|
3683
|
+
* checkpoint-signing against a bare scratch dir — see evals/integration/test_checkpoint_signing.sh
|
|
3684
|
+
* TEST 2), not a mismatch signal: there is no HEAD to compare against, so this is "no
|
|
3685
|
+
* opinion", exactly like the no-checkpoint case above.
|
|
3686
|
+
* - "positive": repoRoot is a real git working tree, HEAD resolves, AND commit_sha is
|
|
3687
|
+
* POSITIVELY determined to NOT be an ancestor of HEAD (`git merge-base --is-ancestor` exits
|
|
3688
|
+
* 1, a real answer, not a failure) — the genuine wrong-tree harm case; hard refuse.
|
|
3689
|
+
* - "undeterminable-shallow": repoRoot is a real git working tree, HEAD resolves, but whether
|
|
3690
|
+
* commit_sha is an ancestor of HEAD could not be determined (e.g. a shallow clone missing
|
|
3691
|
+
* the commit_sha object) — must NOT hard-refuse a legitimate publish from a shallow clone;
|
|
3692
|
+
* warn loudly and allow (the shape + verify-hold gates still stand).
|
|
3693
|
+
*
|
|
3694
|
+
* #413 iteration-2 Fix 5 (reverted, iteration-3): a prior pass hard-refused here when `repoRoot`
|
|
3695
|
+
* was not a git working tree at all but a sealed commit_sha existed ("a sealed delivery implies
|
|
3696
|
+
* the target should be a real git repo"). That premise is false — publishing against a non-git
|
|
3697
|
+
* `repoRoot` is a supported scratch/checkpoint-signing flow, and the refusal regressed
|
|
3698
|
+
* `evals/integration/test_checkpoint_signing.sh` TEST 2. The accepted residual: a non-git
|
|
3699
|
+
* `repoRoot` with a sealed checkpoint is ALLOWED here; wrong-tree protection for a REAL git
|
|
3700
|
+
* repoRoot relies on this function's ancestry (HEAD-vs-commit_sha) check plus the shape-check and
|
|
3701
|
+
* verify-hold gates in publishDelivery, all of which correctly no-op (allow) when there is no git
|
|
3702
|
+
* HEAD to compare against.
|
|
3703
|
+
*/
|
|
3704
|
+
function checkpointHeadAncestry(dir: string, repoRoot: string): { commitSha: string | null; headSha: string | null; isAncestor: boolean; mismatchKind: "none" | "positive" | "undeterminable-shallow" } {
|
|
2791
3705
|
try {
|
|
2792
3706
|
const checkpointPath = path.join(dir, "trust.checkpoint.json");
|
|
2793
|
-
if (fs.existsSync(checkpointPath)) {
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
}
|
|
2803
|
-
if (headSha && !tr.isAncestorCommit(repoRoot, commitSha, headSha)) {
|
|
2804
|
-
warnings.push(`NON-BLOCKING: trust.checkpoint.json commit_sha '${commitSha}' is not an ancestor of local HEAD '${headSha}' — the checkpoint may be stale (e.g. after an amend/rebase). Consider re-sealing (seal-checkpoint) before publishing. This is a warning only — see #335 for the full checkpoint-staleness ownership check.`);
|
|
2805
|
-
}
|
|
2806
|
-
}
|
|
3707
|
+
if (!fs.existsSync(checkpointPath)) return { commitSha: null, headSha: null, isAncestor: true, mismatchKind: "none" };
|
|
3708
|
+
const checkpoint = loadJson(checkpointPath);
|
|
3709
|
+
const commitSha = checkpoint && typeof checkpoint === "object" ? (checkpoint as AnyObj).commit_sha : undefined;
|
|
3710
|
+
if (typeof commitSha !== "string" || !commitSha) return { commitSha: null, headSha: null, isAncestor: true, mismatchKind: "none" };
|
|
3711
|
+
let headSha = "";
|
|
3712
|
+
try {
|
|
3713
|
+
headSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
3714
|
+
} catch {
|
|
3715
|
+
headSha = "";
|
|
2807
3716
|
}
|
|
3717
|
+
// repoRoot is not a git working tree at all (non-git scratch dir) or is a freshly-initialized
|
|
3718
|
+
// repo with no resolvable HEAD yet — "no opinion", never a mismatch (see doc comment above).
|
|
3719
|
+
if (!headSha) return { commitSha, headSha: null, isAncestor: true, mismatchKind: "none" };
|
|
3720
|
+
const classification = classifyAncestry(repoRoot, commitSha, headSha);
|
|
3721
|
+
const isAncestor = classification !== "not-ancestor";
|
|
3722
|
+
const mismatchKind: "none" | "positive" | "undeterminable-shallow" =
|
|
3723
|
+
classification === "ancestor" ? "none" : classification === "not-ancestor" ? "positive" : "undeterminable-shallow";
|
|
3724
|
+
return { commitSha, headSha, isAncestor, mismatchKind };
|
|
2808
3725
|
} catch {
|
|
2809
|
-
// best-effort only — never
|
|
3726
|
+
// best-effort only — a resolution failure must never be misread as a mismatch.
|
|
3727
|
+
return { commitSha: null, headSha: null, isAncestor: true, mismatchKind: "none" };
|
|
3728
|
+
}
|
|
3729
|
+
}
|
|
3730
|
+
|
|
3731
|
+
/**
|
|
3732
|
+
* F4 (iteration-1): extracted from runReconcilePreflight so the main function stays under
|
|
3733
|
+
* the 50-line guideline. Q3 (optional, non-blocking): warn if the checkpoint's commit_sha is
|
|
3734
|
+
* not yet an ancestor of local HEAD — never affects ok/exit code, best-effort only. Delegates
|
|
3735
|
+
* to checkpointHeadAncestry (the shared comparison helper #413 Facet A's hard gate also uses)
|
|
3736
|
+
* so the warning path and the hard-refuse path can never independently drift. #413 iteration-2:
|
|
3737
|
+
* intentionally keeps using the plain `isAncestor` boolean (never `mismatchKind`) — a shallow
|
|
3738
|
+
* clone's undeterminable case still deserves this SAME staleness-flavored warning text, it just
|
|
3739
|
+
* must never become a hard refuse (see publishDelivery's own gate for that distinction).
|
|
3740
|
+
*/
|
|
3741
|
+
function checkpointStalenessWarning(dir: string, repoRoot: string): string[] {
|
|
3742
|
+
const warnings: string[] = [];
|
|
3743
|
+
const { commitSha, headSha, isAncestor } = checkpointHeadAncestry(dir, repoRoot);
|
|
3744
|
+
if (commitSha && headSha && !isAncestor) {
|
|
3745
|
+
warnings.push(`NON-BLOCKING: trust.checkpoint.json commit_sha '${commitSha}' is not an ancestor of local HEAD '${headSha}' — the checkpoint may be stale (e.g. after an amend/rebase). Consider re-sealing (seal-checkpoint) before publishing. This is a warning only — see #335 for the full checkpoint-staleness ownership check.`);
|
|
2810
3746
|
}
|
|
2811
3747
|
return warnings;
|
|
2812
3748
|
}
|
|
@@ -2857,7 +3793,7 @@ export function runReconcilePreflight(
|
|
|
2857
3793
|
issues.push(...sessionLocalIssues);
|
|
2858
3794
|
|
|
2859
3795
|
const report = issues.map((i) => `${i.message} — ${preflightFixHint(i.type)}`);
|
|
2860
|
-
const warnings = checkpointStalenessWarning(dir, repoRoot
|
|
3796
|
+
const warnings = checkpointStalenessWarning(dir, repoRoot);
|
|
2861
3797
|
|
|
2862
3798
|
return { ok: report.length === 0, issues: report, warnings };
|
|
2863
3799
|
}
|
|
@@ -3359,49 +4295,45 @@ async function takeoverPreflightCmd(p: ReturnType<typeof parseArgs>): Promise<nu
|
|
|
3359
4295
|
// Also exposed as the `publish-delivery <artifact-dir>` subcommand for explicit use.
|
|
3360
4296
|
|
|
3361
4297
|
/**
|
|
3362
|
-
* #
|
|
3363
|
-
*
|
|
4298
|
+
* #413 Facet B: prune ONLY keepSlug's OWN prior seal — NEVER another session's
|
|
4299
|
+
* `delivery/<other-slug>/` directory.
|
|
3364
4300
|
*
|
|
3365
|
-
*
|
|
3366
|
-
*
|
|
3367
|
-
*
|
|
3368
|
-
*
|
|
3369
|
-
*
|
|
3370
|
-
*
|
|
4301
|
+
* This function previously (#379) deleted ANY non-keepSlug directory under delivery/ that
|
|
4302
|
+
* "looked like a seal" (contained a trust.bundle or trust.checkpoint.json), on the theory that
|
|
4303
|
+
* an inherited per-session seal dir is harmless to leave and therefore harmless to delete too.
|
|
4304
|
+
* That theory was correct about harmlessness-if-left but did not follow that deletion was
|
|
4305
|
+
* therefore SAFE: issue #413's own corrected evidence reports this exact cross-slug loop
|
|
4306
|
+
* deleted a DIFFERENT, still-in-flight actor's active seal on a different branch
|
|
4307
|
+
* (`delivery/kontourai-flow-agents-349/`) — a real, observed destructive side effect with no
|
|
4308
|
+
* compensating correctness benefit. trust-reconcile.js's own PREFER-NEWEST doc comment
|
|
4309
|
+
* (resolveDeliveryCandidates, ~line 480-491) already establishes that leaving an
|
|
4310
|
+
* inherited/older seal in place is harmless — the reconciler always selects the newest owning
|
|
4311
|
+
* bundle regardless of what else is present — so there is no correctness reason to prune
|
|
4312
|
+
* ANOTHER session's seal at all, ever. The safest fix (and the one issue #413's own corrected
|
|
4313
|
+
* comment endorses — "never prune another session's active seal", no same-branch carve-out) is
|
|
4314
|
+
* to stop cross-slug pruning entirely, not merely narrow its blast radius.
|
|
3371
4315
|
*
|
|
3372
|
-
* The
|
|
3373
|
-
*
|
|
3374
|
-
*
|
|
3375
|
-
*
|
|
3376
|
-
*
|
|
3377
|
-
*
|
|
3378
|
-
*
|
|
4316
|
+
* The only pruning responsibility that legitimately remains here is keepSlug's OWN prior seal:
|
|
4317
|
+
* publishDelivery() is about to freshly (re)write `delivery/<keepSlug>/trust.bundle` and its
|
|
4318
|
+
* companions, but `fs.copyFileSync` only overwrites files that exist in the CURRENT session dir
|
|
4319
|
+
* — a companion that existed in a PRIOR publish of this exact slug (e.g.
|
|
4320
|
+
* trust.checkpoint.sig.json) but is absent from this publish would otherwise linger stale
|
|
4321
|
+
* alongside the freshly-written files. Removing keepSlug's own existing directory before it is
|
|
4322
|
+
* recreated (by the caller, immediately after this call) avoids that same-slug staleness
|
|
4323
|
+
* without touching any OTHER slug's directory.
|
|
3379
4324
|
*
|
|
3380
4325
|
* Best-effort: a prune failure is logged, never fatal to the delivery. Never touches
|
|
3381
|
-
* README.md, DECLARED, the flat seal files
|
|
4326
|
+
* README.md, DECLARED, the flat seal files (delivery/trust.bundle at the shared flat path,
|
|
4327
|
+
* deliberately untouched — see the historical #379 rationale on the flat-path migration
|
|
4328
|
+
* window), or any directory other than keepSlug's own.
|
|
3382
4329
|
*/
|
|
3383
4330
|
function pruneSupersededSeals(deliveryDir: string, keepSlug: string): void {
|
|
3384
|
-
|
|
4331
|
+
const ownDir = path.join(deliveryDir, keepSlug);
|
|
4332
|
+
if (!fs.existsSync(ownDir)) return; // nothing to prune yet — first publish for this slug.
|
|
3385
4333
|
try {
|
|
3386
|
-
|
|
3387
|
-
} catch {
|
|
3388
|
-
|
|
3389
|
-
}
|
|
3390
|
-
for (const entry of entries) {
|
|
3391
|
-
if (!entry.isDirectory() || entry.name === keepSlug) continue;
|
|
3392
|
-
const subdir = path.join(deliveryDir, entry.name);
|
|
3393
|
-
// Only prune dirs that actually look like a seal dir (contain a trust.bundle or
|
|
3394
|
-
// trust.checkpoint.json) — never an unrelated directory a human placed under delivery/.
|
|
3395
|
-
const looksLikeSeal =
|
|
3396
|
-
fs.existsSync(path.join(subdir, "trust.bundle")) ||
|
|
3397
|
-
fs.existsSync(path.join(subdir, "trust.checkpoint.json"));
|
|
3398
|
-
if (!looksLikeSeal) continue;
|
|
3399
|
-
try {
|
|
3400
|
-
fs.rmSync(subdir, { recursive: true, force: true });
|
|
3401
|
-
process.stderr.write(`[publish-delivery] #379: pruned superseded per-session seal delivery/${entry.name}/ (older-owning/stale; the reconciler selects the newest bundle regardless)\n`);
|
|
3402
|
-
} catch (err) {
|
|
3403
|
-
process.stderr.write(`[publish-delivery] #379: could not prune per-session seal delivery/${entry.name}/ (non-fatal): ${err instanceof Error ? err.message : String(err)}\n`);
|
|
3404
|
-
}
|
|
4334
|
+
fs.rmSync(ownDir, { recursive: true, force: true });
|
|
4335
|
+
} catch (err) {
|
|
4336
|
+
process.stderr.write(`[publish-delivery] #413: could not prune this session's own prior seal delivery/${keepSlug}/ before rewriting it (non-fatal, publish continues): ${err instanceof Error ? err.message : String(err)}\n`);
|
|
3405
4337
|
}
|
|
3406
4338
|
}
|
|
3407
4339
|
|
|
@@ -3441,17 +4373,44 @@ function pruneSupersededSeals(deliveryDir: string, keepSlug: string): void {
|
|
|
3441
4373
|
* which stay exactly as before (see each publishDelivery call site's catch handler for how
|
|
3442
4374
|
* the distinction is preserved end-to-end).
|
|
3443
4375
|
*
|
|
3444
|
-
* #293 (
|
|
4376
|
+
* #293 (THIRD, DISTINCT fail-closed gate): immediately after the shape check above and BEFORE
|
|
3445
4377
|
* writing anything into delivery/, runs `runVerifyHold()` — the assignment ⋈ liveness join
|
|
3446
4378
|
* asking "is the calling actor the fresh, non-superseded holder of this subject (or is it
|
|
3447
4379
|
* free/self-held)?" A not-fresh-holder result throws `NotFreshHolderError` (distinct from
|
|
3448
4380
|
* `InvalidBundleShapeError` — a different `code`, a different failure mode: actor hold vs
|
|
3449
|
-
* bundle shape).
|
|
3450
|
-
*
|
|
3451
|
-
*
|
|
3452
|
-
*
|
|
3453
|
-
*
|
|
3454
|
-
*
|
|
4381
|
+
* bundle shape).
|
|
4382
|
+
*
|
|
4383
|
+
* #413 Facet A (FOURTH, DISTINCT fail-closed gate): immediately after the verify-hold gate and
|
|
4384
|
+
* BEFORE writing anything into delivery/, compares the resolved `repoRoot`'s `git rev-parse
|
|
4385
|
+
* HEAD` against the session's sealed `trust.checkpoint.json` `commit_sha` (via the shared
|
|
4386
|
+
* checkpointHeadAncestry() helper — the SAME ancestor-check checkpointStalenessWarning already
|
|
4387
|
+
* used as a non-blocking warning). Throws `RepoHeadMismatchError` (distinct from both other
|
|
4388
|
+
* error tiers) instead of silently publishing into what is very likely the WRONG repo (e.g.
|
|
4389
|
+
* `repoRoot` resolved to the primary checkout via findRepoRootFromDirStrict's artifact-dir
|
|
4390
|
+
* walk, even though this session's checkpoint was sealed against a DIFFERENT worktree's HEAD).
|
|
4391
|
+
* A session with no checkpoint yet, or a checkpoint with no commit_sha, has nothing to compare
|
|
4392
|
+
* against and is unaffected (scoped strictly to the mismatch case, never a spurious refusal).
|
|
4393
|
+
*
|
|
4394
|
+
* #413 iteration-2 Fix 4 (see checkpointHeadAncestry's own doc comment for the full
|
|
4395
|
+
* `mismatchKind` classification this gate reads): only `mismatchKind === "positive"` (a
|
|
4396
|
+
* POSITIVELY-DETERMINABLE ancestry mismatch — the real wrong-tree harm case) hard refuses here.
|
|
4397
|
+
* `"undeterminable-shallow"` (e.g. a local shallow clone missing the commit_sha object) must
|
|
4398
|
+
* NEVER hard-refuse a legitimate publish — this branch only WARNS loudly and lets the shape +
|
|
4399
|
+
* verify-hold gates above continue to stand guard. A non-git `repoRoot` classifies as
|
|
4400
|
+
* `mismatchKind === "none"` (allow) — iteration-2's Fix 5 hard-refused this case too, but that
|
|
4401
|
+
* regressed the SUPPORTED non-git-repoRoot scratch/checkpoint-signing flow (see
|
|
4402
|
+
* evals/integration/test_checkpoint_signing.sh TEST 2) and was reverted in iteration-3. Wrong-tree
|
|
4403
|
+
* protection for that case relies on the shape-check and verify-hold gates above, which correctly
|
|
4404
|
+
* no-op (allow) when there is no git repo to inspect, same as this gate.
|
|
4405
|
+
*
|
|
4406
|
+
* There are now FOUR fail-closed/fail-soft TIERS in this function, and they must never be
|
|
4407
|
+
* conflated in prose or in a call site's catch handler: (1) fail-SOFT bundle absence /
|
|
4408
|
+
* repo-root resolution (silent no-op / visible warning, unchanged from before #356), (2)
|
|
4409
|
+
* fail-CLOSED bundle shape (#356, `InvalidBundleShapeError`), (3) fail-CLOSED verify-hold (#293,
|
|
4410
|
+
* `NotFreshHolderError`), (4) fail-CLOSED repo-HEAD-vs-checkpoint mismatch (#413 Facet A,
|
|
4411
|
+
* `RepoHeadMismatchError`) — see this tier's own gate below for the undeterminable-shallow
|
|
4412
|
+
* carve-out. Tiers run in this fixed order (shape, then verify-hold, then HEAD-mismatch); a
|
|
4413
|
+
* bundle that fails an earlier tier throws that tier's error specifically, never a later one's.
|
|
3455
4414
|
*/
|
|
3456
4415
|
export async function publishDelivery(dir: string, repoRoot: string | null): Promise<void> {
|
|
3457
4416
|
const bundleSrc = path.join(dir, "trust.bundle");
|
|
@@ -3462,10 +4421,8 @@ export async function publishDelivery(dir: string, repoRoot: string | null): Pro
|
|
|
3462
4421
|
return;
|
|
3463
4422
|
}
|
|
3464
4423
|
|
|
3465
|
-
// #356 AC6: fail-CLOSED on shape-invalidity —
|
|
3466
|
-
//
|
|
3467
|
-
// InvalidBundleShapeError (never a generic Error) so every call site can positively
|
|
3468
|
-
// distinguish this from the failure modes they already tolerate as best-effort.
|
|
4424
|
+
// #356 AC6: fail-CLOSED on shape-invalidity — TIER 2 (see this function's doc comment above
|
|
4425
|
+
// for the full tier ordering). Throws InvalidBundleShapeError (never a generic Error).
|
|
3469
4426
|
const preflight = runReconcilePreflight(dir, repoRoot);
|
|
3470
4427
|
if (!preflight.ok) {
|
|
3471
4428
|
process.stderr.write(`[publish-delivery] REFUSING to publish — trust.bundle failed the reconcile-preflight shape check (${preflight.issues.length} issue(s)):\n`);
|
|
@@ -3475,10 +4432,8 @@ export async function publishDelivery(dir: string, repoRoot: string | null): Pro
|
|
|
3475
4432
|
throw new InvalidBundleShapeError(preflight.issues);
|
|
3476
4433
|
}
|
|
3477
4434
|
|
|
3478
|
-
// #293:
|
|
3479
|
-
//
|
|
3480
|
-
// so every call site can positively distinguish "not the fresh holder" from "bundle shape
|
|
3481
|
-
// invalid".
|
|
4435
|
+
// #293: fail-CLOSED verify-hold — TIER 3 (see this function's doc comment above). Throws
|
|
4436
|
+
// NotFreshHolderError (never conflated with InvalidBundleShapeError).
|
|
3482
4437
|
const holdCheck = runVerifyHold(dir, repoRoot);
|
|
3483
4438
|
if (!holdCheck.ok) {
|
|
3484
4439
|
process.stderr.write(`[publish-delivery] REFUSING to publish — verify-hold gate: ${holdCheck.reason}\n`);
|
|
@@ -3486,6 +4441,21 @@ export async function publishDelivery(dir: string, repoRoot: string | null): Pro
|
|
|
3486
4441
|
throw new NotFreshHolderError(holdCheck);
|
|
3487
4442
|
}
|
|
3488
4443
|
|
|
4444
|
+
// #413 Facet A: FOURTH, DISTINCT fail-closed gate (see this function's doc comment above for
|
|
4445
|
+
// the full four-tier ordering, and checkpointHeadAncestry's doc comment for the
|
|
4446
|
+
// Fix 4 mismatchKind classification this reads).
|
|
4447
|
+
const ancestry = checkpointHeadAncestry(dir, repoRoot);
|
|
4448
|
+
if (ancestry.mismatchKind === "positive" && ancestry.commitSha && ancestry.headSha) {
|
|
4449
|
+
process.stderr.write(`[publish-delivery] REFUSING to publish — resolved repoRoot '${repoRoot}' HEAD '${ancestry.headSha}' is not an ancestor-or-equal of the sealed trust.checkpoint.json commit_sha '${ancestry.commitSha}'. This resolved repoRoot is very likely the WRONG checkout for this session (e.g. invoked from a worktree whose artifact dir physically lives under a different primary checkout). Pass --repo-root explicitly, or re-seal the checkpoint against this repoRoot if it really is the intended target.\n`);
|
|
4450
|
+
throw new RepoHeadMismatchError(repoRoot, ancestry.commitSha, ancestry.headSha);
|
|
4451
|
+
}
|
|
4452
|
+
if (ancestry.mismatchKind === "undeterminable-shallow" && ancestry.commitSha && ancestry.headSha) {
|
|
4453
|
+
// #413 iteration-2 Fix 4: NEVER hard-refuse here — a shallow clone (or any other case where
|
|
4454
|
+
// git cannot positively determine ancestry) must not spuriously block a legitimate publish.
|
|
4455
|
+
// The shape + verify-hold gates above already ran and passed; this is warn-and-allow only.
|
|
4456
|
+
process.stderr.write(`[publish-delivery] WARNING: could not determine whether trust.checkpoint.json commit_sha '${ancestry.commitSha}' is an ancestor of repoRoot '${repoRoot}' HEAD '${ancestry.headSha}' (e.g. a shallow clone missing the commit object) — allowing the publish since this is UNDETERMINABLE, not a positively-confirmed mismatch. The shape and verify-hold gates already passed.\n`);
|
|
4457
|
+
}
|
|
4458
|
+
|
|
3489
4459
|
const deliveryDir = path.join(repoRoot, "delivery");
|
|
3490
4460
|
// #379: slug is the session artifact dir's basename — the same human-meaningful id used
|
|
3491
4461
|
// throughout the session (.kontourai/flow-agents/<slug>/). The per-session dir NAME is only
|
|
@@ -3494,7 +4464,8 @@ export async function publishDelivery(dir: string, repoRoot: string | null): Pro
|
|
|
3494
4464
|
const sessionDeliveryDir = path.join(deliveryDir, slug);
|
|
3495
4465
|
|
|
3496
4466
|
fs.mkdirSync(deliveryDir, { recursive: true });
|
|
3497
|
-
//
|
|
4467
|
+
// #413 Facet B: prune only THIS session's own prior seal (never another slug's) before
|
|
4468
|
+
// freshly rewriting delivery/<slug>/ below.
|
|
3498
4469
|
pruneSupersededSeals(deliveryDir, slug);
|
|
3499
4470
|
fs.mkdirSync(sessionDeliveryDir, { recursive: true });
|
|
3500
4471
|
|
|
@@ -3586,12 +4557,13 @@ async function recordLearning(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
3586
4557
|
if (status === "learned" && records.some((r) => r.correction === undefined)) die("learning status learned requires every record to include correction.needed");
|
|
3587
4558
|
writeJson(path.join(dir, "learning.json"), { ...sidecarBase(slug), status, updated_at: timestamp, records });
|
|
3588
4559
|
writeState(dir, slug, "accepted", "learning", timestamp, opt(p, "summary"));
|
|
3589
|
-
// Phase 4c: build bundle from raw inputs; read checks/critiques
|
|
4560
|
+
// Phase 4c: build bundle from raw inputs; read checks/criteria/critiques via the shared
|
|
4561
|
+
// compose-safe readBundleState path (#270 LOW consolidation — this was the literal inline
|
|
4562
|
+
// pattern readBundleState was extracted FROM; using the shared helper directly keeps the two
|
|
4563
|
+
// from silently drifting apart in the future).
|
|
3590
4564
|
// #268/#344: declared builder.* claims survive the round-trip via their authoritative origin stamp.
|
|
3591
|
-
const
|
|
3592
|
-
|
|
3593
|
-
const _learningCritiques: AnyObj[] = critiquesFromBundle(dir);
|
|
3594
|
-
assertBundleWritten(await writeTrustBundle(dir, slug, timestamp, _learningChecks, _learningCriteria, _learningCritiques));
|
|
4565
|
+
const _learningState = readBundleState(dir);
|
|
4566
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, timestamp, _learningState.checks, _learningState.criteria, _learningState.critiques));
|
|
3595
4567
|
return 0;
|
|
3596
4568
|
}
|
|
3597
4569
|
function evidenceClean(dir: string): boolean {
|
|
@@ -3660,7 +4632,16 @@ async function dogfoodPass(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
3660
4632
|
assertExistingLearningValid(dir);
|
|
3661
4633
|
const verdict = opt(p, "verdict");
|
|
3662
4634
|
if (verdict === "pass") {
|
|
3663
|
-
|
|
4635
|
+
// #270 follow-up fix (iteration 5, narrowed iteration 6): dogfood-pass's own pre-validation
|
|
4636
|
+
// normalizeCheck call (before delegating the actual write to recordEvidence below) must apply
|
|
4637
|
+
// the same narrowed existing-unstamped-id exemption recordEvidence/recordCheck do, or a
|
|
4638
|
+
// legitimate correction of a mis-recorded, UNSTAMPED gate-claim-* id would die HERE, before
|
|
4639
|
+
// ever reaching recordEvidence's own (already-fixed) guard — while supersession of a STAMPED
|
|
4640
|
+
// (live) gate claim id must still die here too. readBundleState is safe to call even when
|
|
4641
|
+
// trust.bundle does not yet exist (loadJson falls back to {}), exactly as
|
|
4642
|
+
// recordEvidence/recordCheck rely on.
|
|
4643
|
+
const _dogfoodExistingCheckStampById = existingCheckStampMap(readBundleState(dir).checks);
|
|
4644
|
+
const checks = opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json"), false, _dogfoodExistingCheckStampById));
|
|
3664
4645
|
if (checks.some((c) => c.status !== "pass" && c.status !== "skip")) die("clean evidence requires all non-skipped checks to pass");
|
|
3665
4646
|
// Phase 4c: evidence check reads from trust.bundle (sole verification artifact); legacy evidence.json fallback in evidenceClean.
|
|
3666
4647
|
// #268/#344: builder.* check/critique claims count as clean evidence via their authoritative origin stamp.
|
|
@@ -4743,8 +5724,25 @@ Available claim ids:
|
|
|
4743
5724
|
|
|
4744
5725
|
|
|
4745
5726
|
async function main(): Promise<number> {
|
|
4746
|
-
const
|
|
5727
|
+
const _rawArgv = process.argv.slice(2);
|
|
5728
|
+
// #380: `record-check <dir> -- <command...>` — argv after the FIRST literal `--` token is the
|
|
5729
|
+
// command to execute verbatim (never option-parsed: a command like `npm test -- --watch`
|
|
5730
|
+
// legitimately contains its OWN `--`, so only the record-check dispatcher's own separator, the
|
|
5731
|
+
// first one, is consumed here). parseArgs() cannot represent this (it treats every `--foo`-
|
|
5732
|
+
// shaped token as an option key, including a bare `--`), so the split happens here, before
|
|
5733
|
+
// parseArgs ever sees the command's own argv.
|
|
5734
|
+
let _commandArgv: string[] | null = null;
|
|
5735
|
+
let _argvForParse = _rawArgv;
|
|
5736
|
+
if (_rawArgv[0] === "record-check") {
|
|
5737
|
+
const sepIdx = _rawArgv.indexOf("--");
|
|
5738
|
+
if (sepIdx >= 0) {
|
|
5739
|
+
_argvForParse = _rawArgv.slice(0, sepIdx);
|
|
5740
|
+
_commandArgv = _rawArgv.slice(sepIdx + 1);
|
|
5741
|
+
}
|
|
5742
|
+
}
|
|
5743
|
+
const p = parseArgs(_argvForParse);
|
|
4747
5744
|
if (!p.command) die("workflow-sidecar command is required");
|
|
5745
|
+
if (p.command === "ensure-session") preflightEnsureSession(p);
|
|
4748
5746
|
// F1 (#166 fix iteration 1): `liveness whoami` is a read-only, lock-free, write-free advisory
|
|
4749
5747
|
// surface (see the `action === "whoami"` branch inside `liveness()` above) — it must never
|
|
4750
5748
|
// acquire the workflow-sidecar lock, regardless of whether the artifact root already exists on
|
|
@@ -4771,6 +5769,7 @@ async function main(): Promise<number> {
|
|
|
4771
5769
|
case "init-plan": return initPlan(p);
|
|
4772
5770
|
case "record-evidence": return recordEvidence(p);
|
|
4773
5771
|
case "record-gate-claim": return recordGateClaim(p);
|
|
5772
|
+
case "record-check": return recordCheck(p, _commandArgv);
|
|
4774
5773
|
case "promote": return promote(p);
|
|
4775
5774
|
case "advance-state": return advanceState(p);
|
|
4776
5775
|
case "record-critique": return recordCritique(p);
|