@kontourai/flow-agents 2.3.0 → 2.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/CODEOWNERS +8 -0
- package/.github/workflows/ci.yml +32 -0
- package/AGENTS.md +4 -4
- package/CHANGELOG.md +39 -0
- package/CONTEXT.md +14 -0
- package/README.md +9 -3
- package/agents/dev.json +1 -1
- package/agents/tool-code-reviewer.json +1 -1
- package/agents/tool-planner.json +3 -3
- package/agents/tool-verifier.json +3 -3
- package/build/src/cli/console-learning-projection.js +3 -2
- package/build/src/cli/init.js +104 -21
- package/build/src/cli/kit.js +37 -6
- package/build/src/cli/pull-work-provider.js +1 -1
- package/build/src/cli/usage-feedback.js +3 -3
- package/build/src/cli/validate-hook-influence.js +1 -0
- package/build/src/cli/validate-workflow-artifacts.js +22 -6
- package/build/src/cli/workflow-artifact-cleanup-audit.js +36 -2
- package/build/src/cli/workflow-sidecar.d.ts +14 -7
- package/build/src/cli/workflow-sidecar.js +792 -69
- package/build/src/flow-kit/validate.d.ts +23 -0
- package/build/src/flow-kit/validate.js +67 -1
- package/build/src/index.d.ts +1 -1
- package/build/src/index.js +1 -1
- package/build/src/lib/flow-resolver.d.ts +6 -4
- package/build/src/lib/flow-resolver.js +125 -58
- package/build/src/lib/local-artifact-root.d.ts +14 -2
- package/build/src/lib/local-artifact-root.js +22 -5
- package/build/src/lib/workflow-learning-projection.js +2 -2
- package/build/src/runtime-adapters.d.ts +12 -0
- package/build/src/runtime-adapters.js +56 -16
- package/build/src/tools/build-universal-bundles.js +49 -16
- package/build/src/tools/generate-context-map.js +10 -8
- package/build/src/tools/validate-source-tree.js +23 -1
- package/console.telemetry.json +22 -0
- package/context/contracts/artifact-contract.md +38 -7
- package/context/contracts/builder-kit-workflow-state-contract.md +3 -2
- package/context/contracts/decision-registry-contract.md +263 -0
- package/context/contracts/delivery-contract.md +1 -1
- package/context/contracts/planning-contract.md +2 -1
- package/context/contracts/review-contract.md +5 -3
- package/context/contracts/verification-contract.md +9 -1
- package/context/contracts/work-item-contract.md +1 -1
- package/context/deferred/parallelization.md +1 -1
- package/context/gate-awareness.md +4 -4
- package/context/scripts/git-status.sh +2 -2
- package/context/scripts/hooks/config-protection.js +502 -2
- package/context/scripts/hooks/lib/config-protection-remedies.js +69 -0
- package/context/scripts/hooks/run-hook.js +14 -1
- package/context/scripts/hooks/stop-goal-fit.js +1140 -99
- package/context/scripts/hooks/workflow-steering.js +212 -6
- package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
- package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +35 -0
- package/docs/adr/0019-kit-dependency-ownership.md +62 -0
- package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +290 -0
- package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +221 -0
- package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +283 -0
- package/docs/agent-system-guidebook.md +3 -3
- package/docs/agent-usage-feedback-loop.md +6 -6
- package/docs/context-map.md +9 -7
- package/docs/decisions/decision-records.md +57 -0
- package/docs/decisions/index.md +14 -0
- package/docs/decisions/promotion-gate.md +52 -0
- package/docs/developer-architecture.md +1 -1
- package/docs/fixture-ownership.md +3 -0
- package/docs/flow-kit-repository-contract.md +5 -5
- package/docs/getting-started.md +12 -2
- package/docs/integrations/harness-install.md +2 -1
- package/docs/kit-authoring-guide.md +64 -5
- package/docs/knowledge-kit.md +5 -1
- package/docs/learnings/2026-07-improvement-program.md +203 -0
- package/docs/migrations.md +6 -5
- package/docs/north-star.md +2 -2
- package/docs/operating-layers.md +4 -3
- package/docs/repository-structure.md +5 -5
- package/docs/skills-map.md +8 -8
- package/docs/spec/runtime-hook-surface.md +19 -15
- package/docs/spikes/graph-provider-2026-07.md +155 -0
- package/docs/standards-register.md +7 -7
- package/docs/trust-anchor-adoption.md +105 -4
- package/docs/work-item-adapters.md +1 -1
- package/docs/workflow-artifact-lifecycle.md +72 -9
- package/docs/workflow-eval-strategy.md +3 -3
- package/docs/workflow-shared-contracts.md +1 -1
- package/docs/workflow-usage-guide.md +254 -21
- package/evals/acceptance/DEMO-false-completion.md +20 -11
- package/evals/acceptance/prove-capture-teeth-declared.sh +15 -15
- package/evals/acceptance/prove-capture-teeth.sh +11 -11
- package/evals/acceptance/test_kiro_harness.sh +1 -1
- package/evals/ci/antigaming-suite.sh +5 -0
- package/evals/ci/run-baseline.sh +65 -1
- package/evals/fixtures/flow-kit-repository/invalid-bad-dependency/flows/review.flow.json +26 -0
- package/evals/fixtures/flow-kit-repository/invalid-bad-dependency/kit.json +13 -0
- package/evals/fixtures/flow-kit-repository/valid-with-dependency/flows/review.flow.json +26 -0
- package/evals/fixtures/flow-kit-repository/valid-with-dependency/kit.json +13 -0
- package/evals/fixtures/hook-influence/cases.json +32 -0
- package/evals/fixtures/pull-work-provider/github-issues.json +6 -6
- package/evals/fixtures/trust-reconcile-exploits/fabricated-attestation.json +59 -0
- package/evals/fixtures/trust-reconcile-exploits/no-label-bypass.json +36 -0
- package/evals/fixtures/trust-reconcile-exploits/skip-assumed-bypass.json +59 -0
- package/evals/fixtures/trust-reconcile-exploits/status-misassertion.json +48 -0
- package/evals/fixtures/trust-reconcile-exploits/waived-command-check.json +44 -0
- package/evals/fixtures/trust-reconcile-mixed-bundle/mixed-bundle.json +159 -0
- package/evals/fixtures/trust-reconcile-ws3/ws3-bundle.json +920 -0
- package/evals/integration/test_actor_identity.sh +254 -0
- package/evals/integration/test_builder_step_producers.sh +33 -23
- package/evals/integration/test_bundle_install.sh +22 -16
- package/evals/integration/test_bundle_lifecycle.sh +11 -11
- package/evals/integration/test_captured_fail_reconciliation.sh +50 -50
- package/evals/integration/test_checkpoint_signing.sh +43 -0
- package/evals/integration/test_claim_lookup.sh +4 -4
- package/evals/integration/test_codex_hook_resolution.sh +114 -0
- package/evals/integration/test_command_log_concurrency.sh +8 -8
- package/evals/integration/test_command_log_fork_classification.sh +3 -3
- package/evals/integration/test_command_log_integrity.sh +16 -16
- package/evals/integration/test_console_learning_projection.sh +1 -1
- package/evals/integration/test_dual_emit_flow_step.sh +145 -0
- package/evals/integration/test_enforcer_expects_driven.sh +12 -12
- package/evals/integration/test_evidence_capture_hook.sh +14 -13
- package/evals/integration/test_fixture_retirement_audit.sh +2 -2
- package/evals/integration/test_flow_agents_statusline.sh +2 -2
- package/evals/integration/test_flow_kit_install_git.sh +52 -0
- package/evals/integration/test_flow_kit_repository.sh +2 -0
- package/evals/integration/test_flowdef_session_activation.sh +6 -6
- package/evals/integration/test_flowdef_union_floor_regression.sh +429 -0
- package/evals/integration/test_gate_bypass_chain.sh +52 -40
- package/evals/integration/test_gate_lockdown.sh +97 -73
- package/evals/integration/test_gate_review_inquiry_records.sh +3 -3
- package/evals/integration/test_goal_fit_escape_hatch.sh +10 -10
- package/evals/integration/test_goal_fit_ghost_session.sh +104 -0
- package/evals/integration/test_goal_fit_hook.sh +32 -32
- package/evals/integration/test_goal_fit_rederive.sh +9 -9
- package/evals/integration/test_hook_category_behaviors.sh +103 -0
- package/evals/integration/test_hook_influence_cases.sh +1 -0
- package/evals/integration/test_install_merge.sh +262 -1
- package/evals/integration/test_kit_identity_trust.sh +6 -6
- package/evals/integration/test_liveness_conflict_injection.sh +587 -0
- package/evals/integration/test_liveness_heartbeat.sh +677 -0
- package/evals/integration/test_liveness_verdict.sh +394 -0
- package/evals/integration/test_local_flow_kit_install.sh +15 -0
- package/evals/integration/test_phase_map_and_gate_claim.sh +63 -11
- package/evals/integration/test_promote_gate.sh +178 -0
- package/evals/integration/test_publish_delivery.sh +2 -2
- package/evals/integration/test_pull_work_liveness_preflight.sh +386 -0
- package/evals/integration/test_pull_work_provider.sh +2 -2
- package/evals/integration/test_reconcile_soundness.sh +4 -4
- package/evals/integration/test_resolvefirststep_security.sh +22 -0
- package/evals/integration/test_runtime_adapter_activation.sh +87 -18
- package/evals/integration/test_session_resume_roundtrip.sh +147 -0
- package/evals/integration/test_sidecar_field_preservation.sh +249 -0
- package/evals/integration/test_trust_reconcile.sh +2 -2
- package/evals/integration/test_trust_reconcile_manifest.sh +79 -0
- package/evals/integration/test_trust_reconcile_mixed_bundle.sh +96 -0
- package/evals/integration/test_trust_reconcile_negatives.sh +151 -0
- package/evals/integration/test_usage_feedback_global.sh +5 -5
- package/evals/integration/test_validate_artifacts_portability.sh +60 -0
- package/evals/integration/test_verify_cli.sh +2 -2
- package/evals/integration/test_veritas_governance_kit.sh +117 -0
- package/evals/integration/test_workflow_artifact_cleanup_audit.sh +26 -1
- package/evals/integration/test_workflow_artifacts.sh +33 -0
- package/evals/integration/test_workflow_sidecar_writer.sh +723 -74
- package/evals/integration/test_workflow_steering_hook.sh +175 -5
- package/evals/lib/codex-provider.sh +1 -1
- package/evals/lib/node.sh +19 -1
- package/evals/run.sh +22 -0
- package/evals/static/test_decisions.sh +150 -0
- package/evals/static/test_flowdef_codeowners_coverage.sh +56 -0
- package/evals/static/test_package.sh +19 -10
- package/evals/static/test_universal_bundles.sh +48 -3
- package/evals/static/test_workflow_skills.sh +41 -8
- package/kits/builder/flows/build.flow.json +3 -74
- package/kits/builder/flows/publish-learn.flow.json +90 -0
- package/kits/builder/kit.json +11 -0
- package/kits/builder/skills/builder-shape/SKILL.md +1 -1
- package/kits/builder/skills/deliver/SKILL.md +13 -11
- package/kits/builder/skills/design-probe/SKILL.md +4 -4
- package/kits/builder/skills/evidence-gate/SKILL.md +26 -1
- package/kits/builder/skills/execute-plan/SKILL.md +2 -2
- package/kits/builder/skills/fix-bug/SKILL.md +2 -0
- package/kits/builder/skills/gate-review/SKILL.md +6 -6
- package/kits/builder/skills/idea-to-backlog/SKILL.md +1 -1
- package/kits/builder/skills/learning-review/SKILL.md +7 -7
- package/kits/builder/skills/pickup-probe/SKILL.md +5 -4
- package/kits/builder/skills/plan-work/SKILL.md +27 -5
- package/kits/builder/skills/pull-work/SKILL.md +80 -5
- package/kits/builder/skills/release-readiness/SKILL.md +4 -4
- package/kits/builder/skills/review-work/SKILL.md +24 -1
- package/kits/builder/skills/tdd-workflow/SKILL.md +2 -0
- package/kits/builder/skills/verify-work/SKILL.md +8 -1
- package/kits/catalog.json +6 -0
- package/kits/veritas-governance/adapter/readiness-to-trust-bundle.mjs +178 -0
- package/kits/veritas-governance/docs/README.md +75 -0
- package/kits/veritas-governance/fixtures/readiness/not-ready.readiness-report.json +4645 -0
- package/kits/veritas-governance/fixtures/readiness/ready.readiness-report.json +4403 -0
- package/kits/veritas-governance/flows/readiness-check.flow.json +35 -0
- package/kits/veritas-governance/kit.json +16 -0
- package/package.json +8 -4
- package/packaging/README.md +2 -2
- package/packaging/conformance/fixtures/config-protection--allow-read-state-json.json +20 -0
- package/packaging/conformance/fixtures/config-protection--block-write-state-json.json +20 -0
- package/packaging/conformance/fixtures/stop-goal-fit--block-bundle-disputed-claim.json +1 -1
- package/packaging/conformance/fixtures/stop-goal-fit--block-capture-contradicts-claimed-pass.json +1 -1
- package/packaging/manifest.json +9 -10
- package/schemas/decision-record.schema.json +109 -0
- package/schemas/workflow-evidence.schema.json +1 -1
- package/schemas/workflow-state.schema.json +5 -0
- package/scripts/README.md +8 -3
- package/scripts/check-content-boundary.cjs +1 -1
- package/scripts/check-decisions.cjs +356 -0
- package/scripts/ci/derive-claim-status.mjs +74 -0
- package/scripts/ci/trust-reconcile.js +436 -79
- package/scripts/git-status.sh +2 -2
- package/scripts/hooks/claude-telemetry-hook.js +37 -3
- package/scripts/hooks/codex-telemetry-hook.js +36 -2
- package/scripts/hooks/config-protection.js +102 -30
- package/scripts/hooks/evidence-capture.js +3 -3
- package/scripts/hooks/lib/actor-identity.js +368 -0
- package/scripts/hooks/lib/config-protection-remedies.js +69 -0
- package/scripts/hooks/lib/liveness-heartbeat.js +361 -0
- package/scripts/hooks/lib/liveness-policy.js +127 -0
- package/scripts/hooks/lib/liveness-read.js +105 -12
- package/scripts/hooks/lib/liveness-write.js +46 -0
- package/scripts/hooks/lib/local-artifact-paths.js +12 -6
- package/scripts/hooks/opencode-telemetry-hook.js +27 -1
- package/scripts/hooks/pi-telemetry-hook.js +27 -1
- package/scripts/hooks/stop-goal-fit.js +157 -26
- package/scripts/hooks/workflow-steering.js +77 -3
- package/scripts/install-codex-home.sh +138 -38
- package/scripts/statusline/flow-agents-statusline.js +2 -2
- package/src/cli/console-learning-projection.ts +3 -2
- package/src/cli/init.ts +101 -21
- package/src/cli/kit.ts +37 -6
- package/src/cli/public-api.test.mjs +58 -6
- package/src/cli/pull-work-provider.ts +1 -1
- package/src/cli/trust-bundle-policy-order.test.mjs +87 -0
- package/src/cli/usage-feedback.ts +3 -3
- package/src/cli/validate-hook-influence.ts +1 -0
- package/src/cli/validate-workflow-artifacts.ts +22 -6
- package/src/cli/veritas-readiness-adapter.test.mjs +267 -0
- package/src/cli/workflow-artifact-cleanup-audit.ts +35 -2
- package/src/cli/workflow-sidecar.ts +795 -71
- package/src/flow-kit/validate.ts +74 -1
- package/src/index.ts +6 -2
- package/src/lib/flow-resolver.ts +123 -55
- package/src/lib/local-artifact-root.ts +24 -5
- package/src/lib/workflow-learning-projection.ts +2 -2
- package/src/runtime-adapters.ts +61 -15
- package/src/tools/build-universal-bundles.ts +49 -16
- package/src/tools/generate-context-map.ts +10 -8
- package/src/tools/validate-source-tree.ts +23 -1
|
@@ -13,7 +13,7 @@ type AnyObj = Record<string, any>;
|
|
|
13
13
|
|
|
14
14
|
export const statuses = new Set(["new", "planning", "planned", "in_progress", "blocked", "verifying", "verified", "needs_decision", "not_verified", "failed", "delivered", "accepted", "archived"]);
|
|
15
15
|
export const phases = ["idea", "backlog", "pickup", "planning", "execution", "verification", "goal_fit", "evidence", "release", "learning", "done"];
|
|
16
|
-
export const checkKinds = new Set(["build", "types", "lint", "test", "security", "diff", "browser", "runtime", "policy", "external"]);
|
|
16
|
+
export const checkKinds = new Set(["build", "types", "lint", "test", "command", "security", "diff", "browser", "runtime", "policy", "external"]);
|
|
17
17
|
export const checkStatuses = new Set(["pass", "fail", "not_verified", "skip"]);
|
|
18
18
|
export const verdicts = new Set(["pass", "partial", "fail", "not_verified"]);
|
|
19
19
|
|
|
@@ -50,6 +50,144 @@ function workItemSlug(ref: string): string {
|
|
|
50
50
|
return slugify(`${owner}-${repo}-${id}`, "work-item");
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** Pure, lock-free, side-effect-free CLI wrapper around workItemSlug() — the single source of
|
|
54
|
+
* truth for the deterministic subjectId/session-directory-name slug. Named resolveSlugCmd (not
|
|
55
|
+
* resolveSlug) to avoid colliding with any future export named resolveSlug. */
|
|
56
|
+
function resolveSlugCmd(p: ReturnType<typeof parseArgs>): number {
|
|
57
|
+
const ref = p.positional[0] || die("resolve-slug requires an owner/repo#id ref");
|
|
58
|
+
console.log(workItemSlug(ref));
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** First 6 hex chars of sha256(raw) — a short deterministic disambiguator (#289 F4). Two
|
|
63
|
+
* different raw inputs that both collapse to a segment's "unknown" fallback (e.g. two distinct
|
|
64
|
+
* all-garbage --task-slug values, or two distinct raw actor strings that both resolve
|
|
65
|
+
* "unresolved") would otherwise derive the identical fallback branch — this makes them diverge
|
|
66
|
+
* while staying fully deterministic for a given raw input (same raw -> same hash every call),
|
|
67
|
+
* which is required to preserve resolveSessionBranch's no-rederive/resume-continuity semantics
|
|
68
|
+
* (a resumed session's already-recorded branch is never recomputed regardless of this helper). */
|
|
69
|
+
function unknownDisambiguator(raw: string): string {
|
|
70
|
+
return createHash("sha256").update(raw).digest("hex").slice(0, 6);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Encode a value (actor string or --task-slug, neither guaranteed git-ref-safe) into a
|
|
74
|
+
* single git-check-ref-format(1)-safe branch path component (#289). Reuses
|
|
75
|
+
* actor-identity.js's sanitizeSegment (consume, don't fork) as the base, then closes the
|
|
76
|
+
* git-specific gaps sanitizeSegment's [A-Za-z0-9_.-] charset does not: the `:` delimiter
|
|
77
|
+
* serializeActor() uses (disallowed in a git ref), consecutive dots, a leading dot, a trailing
|
|
78
|
+
* dot (fix-plan iteration 1 F1 — git-check-ref-format forbids a component ending in `.`, and
|
|
79
|
+
* the prior pass only handled a run of 2+ trailing dots via the `..`-collapse step, missing the
|
|
80
|
+
* single-trailing-dot case e.g. a `--task-slug` of `my-fix.`), and a trailing `.lock`. The
|
|
81
|
+
* trailing-dot strip runs BEFORE the `.lock` rewrite so a `.lock` suffix hidden behind trailing
|
|
82
|
+
* dots (e.g. `foo.lock.`) is exposed and still rewritten rather than left dangling. Whenever the
|
|
83
|
+
* FINAL sanitized segment equals the fallback token `"unknown"` — whether because the charset
|
|
84
|
+
* filter collapsed the raw input to nothing, OR because the leading-/trailing-dot stripping
|
|
85
|
+
* above collapsed a near-miss input like `"unknown."` or `".unknown"` down to the literal
|
|
86
|
+
* `"unknown"`, OR because the raw input genuinely WAS the literal string `"unknown"` — a
|
|
87
|
+
* deterministic disambiguator (see unknownDisambiguator) is ALWAYS appended (fix-plan iteration 2
|
|
88
|
+
* F4': no literal-input carve-out). Distinct raw inputs can never collide on the bare literal
|
|
89
|
+
* `"unknown"` segment; the same raw input always re-derives the same disambiguated segment. See
|
|
90
|
+
* Design Decision 2 in the plan. */
|
|
91
|
+
function sanitizeBranchSegment(value: string, helper: { sanitizeSegment: (v: unknown) => string }): string {
|
|
92
|
+
const raw = String(value ?? "");
|
|
93
|
+
const colonReplaced = raw.replace(/:/g, "-");
|
|
94
|
+
let seg = helper.sanitizeSegment(colonReplaced);
|
|
95
|
+
seg = seg.replace(/\.{2,}/g, "-").replace(/^\.+/, "").replace(/\.+$/, "").replace(/\.lock$/i, "-lock");
|
|
96
|
+
if (!seg || seg === "unknown") return `unknown-${unknownDisambiguator(raw)}`;
|
|
97
|
+
return seg;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Validate an explicit `--branch` value strictly rather than trusting it verbatim (fix-plan
|
|
101
|
+
* iteration 1 F2, tightened by iteration 2 F2'). Explicit `--branch` bypasses
|
|
102
|
+
* `sanitizeBranchSegment` entirely by design (it is caller intent, may legitimately contain `/`
|
|
103
|
+
* to nest under `agent/...`, etc.), so unlike the derived path it must be rejected outright — not
|
|
104
|
+
* silently sanitized — when it cannot be a valid git ref. Whole-string checks (iteration 1):
|
|
105
|
+
* any control character/newline, a space, a leading or trailing `/`, a `//` sequence, a leading
|
|
106
|
+
* `.`, any `..` sequence, a trailing `.`, a trailing `.lock`, or any character outside
|
|
107
|
+
* `[A-Za-z0-9_./-]`. Per-component checks (iteration 2 F2'): the whole-string checks above only
|
|
108
|
+
* examine the START/END of the full value, so a charset-legal value can still smuggle an invalid
|
|
109
|
+
* `/`-delimited component past them — e.g. `"-lead"` (a leading `-` breaks the whole git ref,
|
|
110
|
+
* applied uniformly to every component here — stricter than git strictly requires, which is fine
|
|
111
|
+
* for a caller-facing override flag), `"a/.b"` (a non-first component starting with `.`),
|
|
112
|
+
* `"foo.lock/bar"` (a non-last component ending in `.lock`), or `"a/./b"` (a component that is
|
|
113
|
+
* exactly `.`). Belt-and-braces (F2'): once the lexical checks above all pass, `git
|
|
114
|
+
* check-ref-format --branch <value>` (the real git binary) is run as the final authority — it can
|
|
115
|
+
* only ever REJECT a value the lexical checks let through (never re-legalize one they rejected),
|
|
116
|
+
* so it closes any residual gap in this hand-rolled lexical pass. When git cannot be spawned at
|
|
117
|
+
* all (e.g. not installed — `ENOENT`) or does not complete (e.g. the 5s timeout fires), the git
|
|
118
|
+
* check is skipped silently and the lexical checks above remain the sole authority. Dies with
|
|
119
|
+
* remediation, or with git's own rejection message; never mutates any artifact before this check
|
|
120
|
+
* runs (resolveSessionBranch calls this before any file write). */
|
|
121
|
+
function validateExplicitBranch(value: string): void {
|
|
122
|
+
const remediation = `Pass a --branch value matching [A-Za-z0-9_./-], with no leading/trailing "/", no "//", no leading ".", no ".." sequence, and no trailing "." or ".lock" (got: ${JSON.stringify(value)}).`;
|
|
123
|
+
const fail = (reason: string): never => die(`ensure-session --branch value is not a valid git ref: ${reason}. ${remediation}`);
|
|
124
|
+
if (/[\x00-\x1F\x7F]/.test(value)) fail("contains a control character or newline");
|
|
125
|
+
if (/ /.test(value)) fail("contains a space");
|
|
126
|
+
if (value.startsWith("/") || value.endsWith("/")) fail('must not start or end with "/"');
|
|
127
|
+
if (value.includes("//")) fail('must not contain "//"');
|
|
128
|
+
if (value.startsWith(".")) fail('must not start with "."');
|
|
129
|
+
if (value.includes("..")) fail('must not contain a ".." sequence');
|
|
130
|
+
if (value.endsWith(".")) fail('must not end with "."');
|
|
131
|
+
if (/\.lock$/i.test(value)) fail('must not end with ".lock"');
|
|
132
|
+
if (/[^A-Za-z0-9_./-]/.test(value)) fail("contains a character outside [A-Za-z0-9_./-]");
|
|
133
|
+
|
|
134
|
+
// F2' per-component checks: split on "/" and validate each path component individually. The
|
|
135
|
+
// whole-string checks above cannot catch a hostile component that is not at the very start or
|
|
136
|
+
// end of the full value.
|
|
137
|
+
for (const component of value.split("/")) {
|
|
138
|
+
if (!component) fail('must not contain an empty path component ("//" or a leading/trailing "/")');
|
|
139
|
+
if (component === ".") fail('must not contain a path component that is exactly "."');
|
|
140
|
+
if (component.startsWith(".")) fail('must not contain a path component starting with "."');
|
|
141
|
+
if (component.startsWith("-")) fail('must not contain a path component starting with "-"');
|
|
142
|
+
if (component.endsWith(".")) fail('must not contain a path component ending with "."');
|
|
143
|
+
if (/\.lock$/i.test(component)) fail('must not contain a path component ending with ".lock"');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// F2' belt-and-braces: the real `git check-ref-format --branch` binary is the final authority
|
|
147
|
+
// when git is available. This can only REJECT a value that already passed every lexical check
|
|
148
|
+
// above — it never re-legalizes a value the lexical checks rejected (those `fail()` calls
|
|
149
|
+
// above already threw). argv-array form (no shell) with a 5s timeout so a hung or missing git
|
|
150
|
+
// binary cannot hang or crash session creation.
|
|
151
|
+
let result: { status: number | null; stderr: Buffer | string } | undefined;
|
|
152
|
+
try {
|
|
153
|
+
execFileSync("git", ["check-ref-format", "--branch", value], { stdio: ["ignore", "ignore", "pipe"], timeout: 5000 });
|
|
154
|
+
return; // exit 0 — git accepts the value; nothing further to check.
|
|
155
|
+
} catch (err) {
|
|
156
|
+
const spawnError = err as NodeJS.ErrnoException & { status?: number | null; stderr?: Buffer | string };
|
|
157
|
+
if (spawnError && spawnError.code === "ENOENT") return; // git not installed — lexical checks stand alone.
|
|
158
|
+
if (spawnError && typeof spawnError.status === "number") { result = { status: spawnError.status, stderr: spawnError.stderr ?? "" }; }
|
|
159
|
+
else return; // Any other spawn failure (e.g. timeout) — skip silently; lexical checks already passed.
|
|
160
|
+
}
|
|
161
|
+
if (result && result.status !== 0) {
|
|
162
|
+
const gitMessage = String(result.stderr ?? "").trim();
|
|
163
|
+
fail(gitMessage ? `git check-ref-format rejected the value: ${gitMessage}` : "git check-ref-format rejected the value");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Resolve the branch to seed a brand-new session with. Only called from ensureSession's
|
|
168
|
+
* `if (!md)` fresh-creation branch — an existing session's already-recorded branch is never
|
|
169
|
+
* recomputed (ADR 0021 §5 takeover continuity; see Design Decision 3). Precedence: explicit
|
|
170
|
+
* --branch (strictly validated, then honored verbatim — see F2) > derived agent/<actor>/<slug>.
|
|
171
|
+
* Never hard-fails session creation on actor-resolution ambiguity (Design Decision 4) — only a
|
|
172
|
+
* garbage explicit --actor, or a garbage explicit --branch, dies. */
|
|
173
|
+
function resolveSessionBranch(p: ReturnType<typeof parseArgs>, slug: string): string {
|
|
174
|
+
// Deliberately NOT trimmed before validation (unlike the pre-F2 baseline): a leading/trailing
|
|
175
|
+
// space must be REJECTED, not silently trimmed away — silent trimming would let a
|
|
176
|
+
// caller-supplied value differ from what gets recorded without any diagnostic (F2).
|
|
177
|
+
const explicitBranch = opt(p, "branch");
|
|
178
|
+
if (explicitBranch) { validateExplicitBranch(explicitBranch); return explicitBranch; }
|
|
179
|
+
const helper = loadActorIdentityHelper();
|
|
180
|
+
const explicitActorRaw = opt(p, "actor", "").trim();
|
|
181
|
+
if (explicitActorRaw && !/[A-Za-z0-9_.-]/.test(explicitActorRaw)) {
|
|
182
|
+
die("ensure-session --actor value strips to empty under the allowed actor charset ([A-Za-z0-9_.-]) — pass a value containing at least one letter, digit, underscore, period, or hyphen.");
|
|
183
|
+
}
|
|
184
|
+
const actor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : helper.resolveActor(process.env).actor;
|
|
185
|
+
const unresolved = helper.isUnresolvedActor(actor);
|
|
186
|
+
const safeActor = unresolved ? `unknown-actor-${unknownDisambiguator(actor)}` : sanitizeBranchSegment(actor, helper);
|
|
187
|
+
if (unresolved) process.stderr.write("[ensure-session] actor unresolved; branch uses \"unknown-actor\" segment (set --actor or FLOW_AGENTS_ACTOR for a stable branch name)\n");
|
|
188
|
+
return `agent/${safeActor}/${sanitizeBranchSegment(slug, helper)}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
53
191
|
/**
|
|
54
192
|
* Validate a Hachure trust.bundle using @kontourai/surface's canonical validator
|
|
55
193
|
* (surface is the authoritative owner of trust-bundle schema validation per ADR 0010 / ADR 0015).
|
|
@@ -170,7 +308,7 @@ type SurfaceModule = {
|
|
|
170
308
|
policies: Record<string, unknown>[];
|
|
171
309
|
now?: Date;
|
|
172
310
|
}) => { status: string; policyId: string | undefined };
|
|
173
|
-
generateClaimId: (subjectId: string,
|
|
311
|
+
generateClaimId: (subjectId: string, facet: string, fieldOrBehavior: string) => string;
|
|
174
312
|
statusFunctionVersion: string;
|
|
175
313
|
resolveInquiry: (
|
|
176
314
|
bundle: Record<string, unknown>,
|
|
@@ -241,6 +379,48 @@ function criterionStatusToEventStatus(status: string): string | null {
|
|
|
241
379
|
if (status === "accepted_gap") return "assumed";
|
|
242
380
|
return null; // pending / not_verified → no event → Surface returns "unknown"
|
|
243
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* WS8 (ADR 0020): Derive Surface evidence classification (evidenceType + method)
|
|
384
|
+
* from a workflow check's kind, replacing the previous hardcoded `test_output`.
|
|
385
|
+
* Only command-backed re-runnable checks are `test_output` (CI-reconcilable);
|
|
386
|
+
* everything else is inherently session-local (manual attestation, provider/
|
|
387
|
+
* document citation, crawl observation, policy rule, source excerpt). The
|
|
388
|
+
* `reconcilable` flag is informational — the CI reconciler classifies purely by
|
|
389
|
+
* the emitted `evidenceType`. Every value is a member of Surface's own
|
|
390
|
+
* evidence.schema.json `evidenceType`/`method` enums (consume-never-fork).
|
|
391
|
+
*/
|
|
392
|
+
function classifyEvidence(kind: string | undefined, hasCommand: boolean): { evidenceType: string; method: string; reconcilable: boolean } {
|
|
393
|
+
const k = String(kind ?? "external");
|
|
394
|
+
switch (k) {
|
|
395
|
+
case "build":
|
|
396
|
+
case "types":
|
|
397
|
+
case "lint":
|
|
398
|
+
case "test":
|
|
399
|
+
case "command":
|
|
400
|
+
return { evidenceType: "test_output", method: "validation", reconcilable: true };
|
|
401
|
+
case "security":
|
|
402
|
+
return hasCommand
|
|
403
|
+
? { evidenceType: "test_output", method: "validation", reconcilable: true }
|
|
404
|
+
: { evidenceType: "attestation", method: "corroboration", reconcilable: false };
|
|
405
|
+
case "diff":
|
|
406
|
+
return { evidenceType: "source_excerpt", method: "extraction", reconcilable: false };
|
|
407
|
+
case "browser":
|
|
408
|
+
return hasCommand
|
|
409
|
+
? { evidenceType: "test_output", method: "validation", reconcilable: true }
|
|
410
|
+
: { evidenceType: "crawl_observation", method: "observation", reconcilable: false };
|
|
411
|
+
case "runtime":
|
|
412
|
+
return hasCommand
|
|
413
|
+
? { evidenceType: "test_output", method: "validation", reconcilable: true }
|
|
414
|
+
: { evidenceType: "attestation", method: "attestation", reconcilable: false };
|
|
415
|
+
case "policy":
|
|
416
|
+
return { evidenceType: "policy_rule", method: "auditability", reconcilable: false };
|
|
417
|
+
case "external":
|
|
418
|
+
return { evidenceType: "attestation", method: "corroboration", reconcilable: false };
|
|
419
|
+
default:
|
|
420
|
+
return { evidenceType: "test_output", method: "validation", reconcilable: true };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
244
424
|
/** Map a critique verdict to the Surface VerificationEvent status. */
|
|
245
425
|
function critiqueToEventStatus(verdict: string, findings: AnyObj[]): string | null {
|
|
246
426
|
if (verdict === "fail") return "disputed";
|
|
@@ -281,14 +461,29 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
281
461
|
const events: AnyObj[] = [];
|
|
282
462
|
const ts = timestamp || new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
283
463
|
|
|
284
|
-
// One VerificationPolicy per distinct claimType, so status is
|
|
285
|
-
// (not derived against an empty policy set). Maximal-fidelity per ADR 0010.
|
|
464
|
+
// One VerificationPolicy per distinct (claimType, requiredEvidence) pair, so status is
|
|
465
|
+
// policy-governed (not derived against an empty policy set). Maximal-fidelity per ADR 0010.
|
|
466
|
+
//
|
|
467
|
+
// WS8 (AC1, iteration 2): the cache is keyed by claimType + the normalized requiredEvidence
|
|
468
|
+
// set, NOT by claimType alone. Two checks of the same legacy claimType that differ in
|
|
469
|
+
// command-presence (e.g. a command-backed browser check → requiredEvidence [test_output]
|
|
470
|
+
// vs a no-command browser check → [crawl_observation]) previously COLLIDED: the first-seen
|
|
471
|
+
// requiredEvidence won and corrupted the second claim's derived status (verified → proposed)
|
|
472
|
+
// in a record-order-dependent way. Keying by (claimType, requiredEvidence) makes policy
|
|
473
|
+
// construction order-independent — each distinct evidence signature gets its own policy, and
|
|
474
|
+
// each claim references its own via verificationPolicyId (Surface's resolvePolicyForClaim
|
|
475
|
+
// honors verificationPolicyId first, so same-claimType policies never cross-resolve).
|
|
476
|
+
// Merging is NOT used because Surface's requiredEvidence is all-of (`.every`), so a union
|
|
477
|
+
// would over-constrain both claims.
|
|
286
478
|
const policies = new Map<string, AnyObj>();
|
|
287
479
|
const ensurePolicy = (claimType: string, impactLevel: string, requiredEvidence: string[]): AnyObj => {
|
|
288
|
-
|
|
480
|
+
const reqSorted = [...new Set(requiredEvidence)].sort();
|
|
481
|
+
const key = `${claimType}::${reqSorted.join(",")}`;
|
|
482
|
+
let p = policies.get(key);
|
|
289
483
|
if (!p) {
|
|
290
|
-
|
|
291
|
-
|
|
484
|
+
const id = reqSorted.length ? `policy:${claimType}:${reqSorted.join("+")}` : `policy:${claimType}`;
|
|
485
|
+
p = { id, claimType, requiredEvidence: reqSorted, acceptanceCriteria: [`A verified verification event must support a ${claimType} claim.`], reviewAuthority: "system", validityRule: { kind: "manual" }, stalenessTriggers: [], conflictRules: [], impactLevel };
|
|
486
|
+
policies.set(key, p);
|
|
292
487
|
}
|
|
293
488
|
return p;
|
|
294
489
|
};
|
|
@@ -402,12 +597,28 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
402
597
|
const claimId = generateClaimId(subjectId, "flow-agents.workflow", fieldOrBehavior);
|
|
403
598
|
const evId = `ev:${claimId}`;
|
|
404
599
|
const legacyClaimType = `workflow.check.${check.kind ?? "external"}`;
|
|
405
|
-
const policy = ensurePolicy(legacyClaimType, "high", ["test_output"]);
|
|
406
600
|
|
|
407
601
|
const cmd = typeof check.command === "string" ? check.command.replace(/\s+/g, " ").trim() : "";
|
|
408
602
|
const captured = cmd ? captureByCommand.get(cmd) : undefined;
|
|
603
|
+
// WS8 (ADR 0020): classify evidence from check.kind instead of hardcoding test_output.
|
|
604
|
+
// A waived accepted-gap (check._waiver, set by record-evidence/record-gate-claim
|
|
605
|
+
// --accepted-gap-reason/--waived-by) is by definition session-local: it is an
|
|
606
|
+
// attested accepted gap, never a CI-re-runnable command, so it is classified
|
|
607
|
+
// attestation/attestation and its claim status is forced to `assumed` (reusing the
|
|
608
|
+
// existing accepted_gap -> assumed mapping — no new status value).
|
|
609
|
+
const waiver = (check._waiver && typeof check._waiver === "object") ? check._waiver as AnyObj : null;
|
|
610
|
+
const evClass = waiver
|
|
611
|
+
? { evidenceType: "attestation", method: "attestation", reconcilable: false }
|
|
612
|
+
: classifyEvidence(check.kind, cmd.length > 0);
|
|
613
|
+
const policy = ensurePolicy(legacyClaimType, "high", [evClass.evidenceType]);
|
|
409
614
|
const effectiveStatus = captured ? captured.observedResult : String(check.status ?? "");
|
|
410
|
-
const evStatus = checkStatusToEventStatus(effectiveStatus);
|
|
615
|
+
const evStatus = waiver ? "assumed" : checkStatusToEventStatus(effectiveStatus);
|
|
616
|
+
// Promotion claim marker (issue #312): a `promote` check carries a session-local
|
|
617
|
+
// _promotion object that must survive onto claim.metadata.promotion so the archive gate
|
|
618
|
+
// (workflow-artifact-cleanup-audit) and validators can detect the promotion claim without a
|
|
619
|
+
// new manifest entry. It rides alongside any waiver in a single merged metadata object.
|
|
620
|
+
const promotionMeta = (check._promotion && typeof check._promotion === "object") ? check._promotion as AnyObj : null;
|
|
621
|
+
const claimMetadata: AnyObj | null = (waiver || promotionMeta) ? { ...(waiver ? { waiver } : {}), ...(promotionMeta ? { promotion: promotionMeta } : {}) } : null;
|
|
411
622
|
|
|
412
623
|
const claimEvents: AnyObj[] = [];
|
|
413
624
|
if (evStatus) {
|
|
@@ -415,11 +626,17 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
415
626
|
events.push(evt);
|
|
416
627
|
claimEvents.push(evt);
|
|
417
628
|
}
|
|
418
|
-
const evItem: AnyObj = { id: evId, claimId, evidenceType:
|
|
629
|
+
const evItem: AnyObj = { id: evId, claimId, evidenceType: evClass.evidenceType, method: evClass.method, sourceRef: `${slug}/evidence.json`, excerptOrSummary: fieldOrBehavior, observedAt: ts, collectedBy: "flow-agents/workflow-sidecar", passing: effectiveStatus === "pass" };
|
|
419
630
|
if (captured) {
|
|
420
631
|
evItem.sourceRef = `${slug}/command-log.jsonl`;
|
|
421
632
|
evItem.collectedBy = "flow-agents/evidence-capture";
|
|
422
633
|
evItem.execution = { runner: "bash", label: cmd, isError: captured.observedResult === "fail", ...(captured.exitCode != null ? { exitCode: captured.exitCode } : {}) };
|
|
634
|
+
} else if (cmd && !waiver) {
|
|
635
|
+
// WS8 (ADR 0020): always stamp execution.label on command-backed checks so the CI
|
|
636
|
+
// reconciler has a stable key to match against the manifest, even when the local
|
|
637
|
+
// command-log capture did not happen to run this command. isError is derived from
|
|
638
|
+
// the check's own reported status (no captured exit code available in this path).
|
|
639
|
+
evItem.execution = { runner: "bash", label: cmd, isError: effectiveStatus !== "pass" };
|
|
423
640
|
}
|
|
424
641
|
evidenceItems.push(evItem);
|
|
425
642
|
|
|
@@ -428,13 +645,13 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
428
645
|
const declared = matchExpectsEntry("check", check.kind, typeof check._gate_claim_expectation_id === "string" ? check._gate_claim_expectation_id : undefined);
|
|
429
646
|
if (declared) {
|
|
430
647
|
// Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
|
|
431
|
-
const declaredPolicy = ensurePolicy(declared.claimType, "high", [
|
|
432
|
-
const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId,
|
|
648
|
+
const declaredPolicy = ensurePolicy(declared.claimType, "high", [evClass.evidenceType]);
|
|
649
|
+
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, ...(claimMetadata ? { metadata: claimMetadata } : {}) };
|
|
433
650
|
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>[] });
|
|
434
651
|
claims.push({ ...declaredClaimObj, status: declaredStatus });
|
|
435
652
|
} else {
|
|
436
653
|
// No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
|
|
437
|
-
const claimObj: AnyObj = { id: claimId, subjectType: "workflow-check", subjectId,
|
|
654
|
+
const claimObj: AnyObj = { id: claimId, subjectType: "workflow-check", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: effectiveStatus, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id, ...(claimMetadata ? { metadata: claimMetadata } : {}) };
|
|
438
655
|
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [evItem] as Record<string, unknown>[], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
|
|
439
656
|
claims.push({ ...claimObj, status: derivedStatus });
|
|
440
657
|
}
|
|
@@ -461,12 +678,12 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
461
678
|
if (declared) {
|
|
462
679
|
// Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
|
|
463
680
|
const declaredPolicy = ensurePolicy(declared.claimType, "high", []);
|
|
464
|
-
const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId,
|
|
681
|
+
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 };
|
|
465
682
|
const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
|
|
466
683
|
claims.push({ ...declaredClaimObj, status: declaredStatus });
|
|
467
684
|
} else {
|
|
468
685
|
// No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
|
|
469
|
-
const claimObj: AnyObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId,
|
|
686
|
+
const claimObj: AnyObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id };
|
|
470
687
|
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
|
|
471
688
|
claims.push({ ...claimObj, status: derivedStatus });
|
|
472
689
|
}
|
|
@@ -493,19 +710,19 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
493
710
|
if (declared) {
|
|
494
711
|
// Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
|
|
495
712
|
const declaredPolicy = ensurePolicy(declared.claimType, "medium", []);
|
|
496
|
-
const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId,
|
|
713
|
+
const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: declaredPolicy.id };
|
|
497
714
|
const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
|
|
498
715
|
claims.push({ ...declaredClaimObj, status: declaredStatus });
|
|
499
716
|
} else {
|
|
500
717
|
// No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
|
|
501
|
-
const claimObj: AnyObj = { id: claimId, subjectType: "workflow-critique", subjectId,
|
|
718
|
+
const claimObj: AnyObj = { id: claimId, subjectType: "workflow-critique", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: policy.id };
|
|
502
719
|
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
|
|
503
720
|
claims.push({ ...claimObj, status: derivedStatus });
|
|
504
721
|
}
|
|
505
722
|
}
|
|
506
723
|
|
|
507
724
|
return {
|
|
508
|
-
schemaVersion:
|
|
725
|
+
schemaVersion: 5,
|
|
509
726
|
source: `flow-agents/workflow-sidecar;statusFunctionVersion=${statusFunctionVersion}`,
|
|
510
727
|
claims,
|
|
511
728
|
evidence: evidenceItems,
|
|
@@ -536,7 +753,7 @@ export async function writeTrustBundle(dir: string, slug: string, timestamp: str
|
|
|
536
753
|
const raw = fs.readFileSync(path.join(dir, "command-log.jsonl"), "utf8");
|
|
537
754
|
commandLog = raw.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => { try { return JSON.parse(l) as AnyObj; } catch { return null; } }).filter((x): x is AnyObj => x !== null);
|
|
538
755
|
} catch { /* no capture log — fine */ }
|
|
539
|
-
// ADR 0016 Abstraction A (P-d): pass the
|
|
756
|
+
// ADR 0016 Abstraction A (P-d): pass the runtime artifact root ONLY when current.json
|
|
540
757
|
// points to this session (scoped active-flow guard). If current.json.artifact_dir
|
|
541
758
|
// resolves to a different session, pass null — no active-flow claim mapping for this bundle.
|
|
542
759
|
const _flowAgentsDir = path.dirname(dir);
|
|
@@ -743,6 +960,20 @@ function definitionAcceptanceLines(markdown: string): string[] {
|
|
|
743
960
|
}
|
|
744
961
|
return out;
|
|
745
962
|
}
|
|
963
|
+
|
|
964
|
+
/** Extract a top-level `name: value` markdown field's value (e.g. `branch: agent/x/y`), mirroring
|
|
965
|
+
* this file's own local-regex-parsing convention for section()/definitionAcceptanceLines() (a
|
|
966
|
+
* near-duplicate of validate-workflow-artifacts.ts's field()/section() helpers — this file
|
|
967
|
+
* already keeps its own local copy of that pattern rather than sharing a module). `name` is
|
|
968
|
+
* regex-escaped before interpolation (fix-plan iteration 1 F3), mirroring
|
|
969
|
+
* validate-workflow-artifacts.ts's own field() escaping verbatim — every current call site
|
|
970
|
+
* passes a fixed literal (e.g. "branch"), but escaping keeps a future non-literal name from
|
|
971
|
+
* silently changing match semantics via unescaped regex metacharacters. Returns "" when the
|
|
972
|
+
* field is absent. */
|
|
973
|
+
function markdownField(markdown: string, name: string): string {
|
|
974
|
+
const re = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\s*(.+)$`, "m");
|
|
975
|
+
return re.exec(markdown)?.[1]?.trim() ?? "";
|
|
976
|
+
}
|
|
746
977
|
function parseCriterion(line: string, index: number): AnyObj {
|
|
747
978
|
let text = line.replace(/^-\s+\[[ xX]\]\s*/, "").trim();
|
|
748
979
|
const m = /\s+-\s+Evidence:\s*(.+)$/i.exec(text);
|
|
@@ -777,14 +1008,20 @@ function validateAgentId(agent: string): string {
|
|
|
777
1008
|
|
|
778
1009
|
/**
|
|
779
1010
|
* Find the repository root by walking upward from a starting directory to locate
|
|
780
|
-
* the nearest ancestor containing a kits/ subdirectory
|
|
781
|
-
*
|
|
782
|
-
*
|
|
1011
|
+
* the nearest ancestor containing a kits/ subdirectory, without falling back to
|
|
1012
|
+
* process.cwd(). Returns null when no kits/ ancestor is found within the walk
|
|
1013
|
+
* bound (e.g. a scratch/temp session directory with no repo ancestor at all).
|
|
783
1014
|
*
|
|
784
|
-
*
|
|
785
|
-
*
|
|
1015
|
+
* WS5 iteration-2 part 2: extracted so publishDelivery's repo-root resolution can
|
|
1016
|
+
* be fail-closed (see findRepoRootFromDir below and publishDelivery). A scratch
|
|
1017
|
+
* test session dir (mktemp -d, no kits/ ancestor) must never resolve to whatever
|
|
1018
|
+
* repo happens to be the current process's cwd — that previously let a throwaway
|
|
1019
|
+
* eval-local trust.bundle silently clobber this real repo's delivery/trust.bundle
|
|
1020
|
+
* when the eval was run from a checkout of this repo (see
|
|
1021
|
+
* evals/integration/test_checkpoint_signing.sh TEST 2 and the WS5 session findings
|
|
1022
|
+
* at .kontourai/flow-agents/ws5-governance-kit-slice1).
|
|
786
1023
|
*/
|
|
787
|
-
function
|
|
1024
|
+
function findRepoRootFromDirStrict(startDir: string): string | null {
|
|
788
1025
|
let dir = startDir;
|
|
789
1026
|
for (let i = 0; i < 16; i++) {
|
|
790
1027
|
if (fs.existsSync(path.join(dir, "kits"))) return dir;
|
|
@@ -792,7 +1029,24 @@ function findRepoRootFromDir(startDir: string): string {
|
|
|
792
1029
|
if (parent === dir) break;
|
|
793
1030
|
dir = parent;
|
|
794
1031
|
}
|
|
795
|
-
return
|
|
1032
|
+
return null;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Find the repository root by walking upward from a starting directory to locate
|
|
1037
|
+
* the nearest ancestor containing a kits/ subdirectory. Mirrors flow-resolver.ts
|
|
1038
|
+
* findRepoRoot, but callable from workflow-sidecar.ts without re-importing the
|
|
1039
|
+
* internal helper. Falls back to process.cwd() when no kits/ ancestor is found —
|
|
1040
|
+
* appropriate for phase-map/first-step resolution (ADR 0016 Abstraction A, P-d),
|
|
1041
|
+
* where the caller is always invoked from within a real repo checkout.
|
|
1042
|
+
*
|
|
1043
|
+
* Do NOT use this cwd-falling-back variant for publishDelivery's repo-root
|
|
1044
|
+
* resolution — use findRepoRootFromDirStrict there instead, so a scratch/test
|
|
1045
|
+
* session dir with no repo ancestor fails closed (skips publish) rather than
|
|
1046
|
+
* silently trusting process.cwd(), which could be an unrelated real repo.
|
|
1047
|
+
*/
|
|
1048
|
+
function findRepoRootFromDir(startDir: string): string {
|
|
1049
|
+
return findRepoRootFromDirStrict(startDir) ?? process.cwd();
|
|
796
1050
|
}
|
|
797
1051
|
|
|
798
1052
|
/**
|
|
@@ -825,7 +1079,11 @@ function resolveFirstStep(flowId: string, repoRoot: string): string | null {
|
|
|
825
1079
|
}
|
|
826
1080
|
}
|
|
827
1081
|
|
|
828
|
-
function writeCurrent(root: string, dir: string, timestamp: string, owner: string, source: string, flowId?: string, stepId?: string): void {
|
|
1082
|
+
function writeCurrent(root: string, dir: string, timestamp: string, owner: string, source: string, flowId?: string, stepId?: string, adHocReason?: string): void {
|
|
1083
|
+
// #289: mirror the active session's already-recorded branch (state.json.branch) into
|
|
1084
|
+
// current.json so consumers of current.json (which has no schema of its own — not one of the
|
|
1085
|
+
// 9 schemas under schemas/) see the routing branch without re-reading state.json separately.
|
|
1086
|
+
const branch = loadJson(path.join(dir, "state.json")).branch;
|
|
829
1087
|
writeJson(path.join(root, "current.json"), {
|
|
830
1088
|
schema_version: "1.0",
|
|
831
1089
|
active_slug: path.basename(dir),
|
|
@@ -834,11 +1092,17 @@ function writeCurrent(root: string, dir: string, timestamp: string, owner: strin
|
|
|
834
1092
|
owner,
|
|
835
1093
|
source,
|
|
836
1094
|
active_agents: [],
|
|
1095
|
+
...(branch ? { branch } : {}),
|
|
837
1096
|
// ADR 0016 Abstraction A (P-a): optional FlowDefinition routing keys for the producer
|
|
838
1097
|
// and enforcer. Both fields are optional and backward-compatible — sessions without a
|
|
839
1098
|
// FlowDefinition omit them and fall through to the workflow.* claim type path.
|
|
840
1099
|
...(flowId ? { active_flow_id: flowId } : {}),
|
|
841
1100
|
...(stepId ? { active_step_id: stepId } : {}),
|
|
1101
|
+
// WS8 (AC12): sanctioned ad-hoc direct entry marker. Set when --step-id explicitly
|
|
1102
|
+
// targets a step other than the flow's resolved first step, so stop-goal-fit /
|
|
1103
|
+
// gate-review can distinguish an intentional direct entry (e.g. a planning-only
|
|
1104
|
+
// session that skips pull-work) from a stale/mis-stamped active_step_id.
|
|
1105
|
+
...(adHocReason ? { ad_hoc_entry: true, ad_hoc_reason: adHocReason } : {}),
|
|
842
1106
|
});
|
|
843
1107
|
}
|
|
844
1108
|
function loadCurrent(root: string): AnyObj | null {
|
|
@@ -850,7 +1114,13 @@ function currentDir(root: string): string | null {
|
|
|
850
1114
|
const c = loadCurrent(root);
|
|
851
1115
|
if (!c) return null;
|
|
852
1116
|
const dir = path.resolve(root, c.artifact_dir ?? c.active_slug ?? "");
|
|
853
|
-
|
|
1117
|
+
if (!fs.existsSync(dir)) return null;
|
|
1118
|
+
try {
|
|
1119
|
+
requireArtifactDirUnderRoot(dir, root);
|
|
1120
|
+
} catch {
|
|
1121
|
+
return null;
|
|
1122
|
+
}
|
|
1123
|
+
return dir;
|
|
854
1124
|
}
|
|
855
1125
|
function updateCurrentAgent(root: string, dir: string, agentId: string, status: string, timestamp: string): void {
|
|
856
1126
|
const cur = loadCurrent(root);
|
|
@@ -864,8 +1134,38 @@ function updateCurrentAgent(root: string, dir: string, agentId: string, status:
|
|
|
864
1134
|
|
|
865
1135
|
function initSidecars(dir: string, slug: string, sourceRequest: string, summary: string, nextAction: string, timestamp: string, markdown?: string): void {
|
|
866
1136
|
const criteria = markdown ? definitionAcceptanceLines(markdown).map(parseCriterion) : [];
|
|
1137
|
+
// #289/#309: `markdown` here is NOT always the session `<slug>--deliver.md` that
|
|
1138
|
+
// ensureSession seeds the `branch:` line into — initPlan is called against the tool-planner's
|
|
1139
|
+
// PLAN artifact (`<slug>--plan-work.md` etc.), a different file that typically carries no
|
|
1140
|
+
// `branch:` line at all. Since this function fully rewrites state.json (no merge with the
|
|
1141
|
+
// prior contents), naively re-deriving branch from `markdown` alone would clobber whatever
|
|
1142
|
+
// ensure-session already recorded there moments earlier (#309 regression). Resolve branch with
|
|
1143
|
+
// a three-tier fallback, preferring the most durable source first:
|
|
1144
|
+
// 1. EXISTING state.json.branch — once recorded, it is never re-derived or clobbered.
|
|
1145
|
+
// 2. The session's OWN canonical `<dir>/<slug>--deliver.md` on disk — the file ensureSession
|
|
1146
|
+
// always seeds the `branch:` line into, independent of whatever `markdown` was passed in.
|
|
1147
|
+
// Reading it directly (rather than trusting `markdown`) is what makes a repaired/backfilled
|
|
1148
|
+
// init-plan call (re-run against the same branch-less plan artifact, after a #309-era
|
|
1149
|
+
// session lost its state.json branch) recover the branch without a direct file edit.
|
|
1150
|
+
// 3. The `markdown` param itself (covers ensureSession's fresh-creation call, before
|
|
1151
|
+
// `<slug>--deliver.md` differs from `markdown`, and any direct/legacy caller that passes
|
|
1152
|
+
// the deliver markdown itself as the init-plan artifact).
|
|
1153
|
+
// This makes the branch survive every subsequent initSidecars call (init-plan, or a resumed
|
|
1154
|
+
// ensure-session) at this single choke point, without patching every other writer.
|
|
1155
|
+
const existingState = loadJson(path.join(dir, "state.json"));
|
|
1156
|
+
const existingBranch = existingState.branch;
|
|
1157
|
+
const deliverMdPath = path.join(dir, `${slug}--deliver.md`);
|
|
1158
|
+
const deliverBranch = fs.existsSync(deliverMdPath) ? markdownField(read(deliverMdPath), "branch") : "";
|
|
1159
|
+
const branch = existingBranch || deliverBranch || (markdown ? markdownField(markdown, "branch") : "");
|
|
1160
|
+
// #309 (scope addition): created_at is write-once, same class as the branch-drop bug above —
|
|
1161
|
+
// initSidecars fully rewrites state.json on every call (including a repair/backfill re-run of
|
|
1162
|
+
// init-plan against an already-existing session), so naively re-stamping created_at from the
|
|
1163
|
+
// current call's timestamp silently rewrites a session's original creation time. Preserve the
|
|
1164
|
+
// existing state.json's created_at when present; stamp only on true first-creation.
|
|
1165
|
+
// updated_at still reflects "now" on every call — that field is intentionally mutable.
|
|
867
1166
|
writeJson(path.join(dir, "state.json"), {
|
|
868
|
-
...sidecarBase(slug), status: "planned", phase: "planning", created_at: timestamp, updated_at: timestamp,
|
|
1167
|
+
...sidecarBase(slug), status: "planned", phase: "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
|
|
1168
|
+
...(branch ? { branch } : {}),
|
|
869
1169
|
artifact_paths: relArtifacts(dir),
|
|
870
1170
|
next_action: { status: "continue", summary: nextAction || summary },
|
|
871
1171
|
});
|
|
@@ -887,7 +1187,12 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
|
|
|
887
1187
|
const timestamp = opt(p, "timestamp", now());
|
|
888
1188
|
let md = fs.existsSync(path.join(dir, `${slug}--deliver.md`)) ? read(path.join(dir, `${slug}--deliver.md`)) : "";
|
|
889
1189
|
if (!md) {
|
|
890
|
-
|
|
1190
|
+
// #289: derive the routing branch ONLY on fresh session creation (this `if (!md)` guard).
|
|
1191
|
+
// An existing session's already-recorded `branch:` line is never touched — that whole branch
|
|
1192
|
+
// of code is skipped on a resumed/taken-over session, which is what makes ADR 0021 §5
|
|
1193
|
+
// takeover continuity true by construction (see Design Decision 3 in the plan).
|
|
1194
|
+
const branch = resolveSessionBranch(p, slug);
|
|
1195
|
+
md = `# ${opt(p, "title", slug)}\n\nbranch: ${branch}\nworktree: main\ncreated: ${timestamp}\nstatus: planning\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`;
|
|
891
1196
|
fs.writeFileSync(path.join(dir, `${slug}--deliver.md`), md);
|
|
892
1197
|
}
|
|
893
1198
|
if (!fs.existsSync(path.join(dir, "state.json")) || !fs.existsSync(path.join(dir, "acceptance.json")) || !fs.existsSync(path.join(dir, "handoff.json"))) {
|
|
@@ -901,13 +1206,26 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
|
|
|
901
1206
|
// ensure-session --flow-id builder.build produces a FlowDefinition-driven session even
|
|
902
1207
|
// before the first advance-state call.
|
|
903
1208
|
const flowId = opt(p, "flow-id");
|
|
904
|
-
|
|
1209
|
+
const explicitStep = opt(p, "step-id");
|
|
1210
|
+
let stepId = explicitStep;
|
|
1211
|
+
let adHocReason: string | undefined;
|
|
905
1212
|
if (flowId && !stepId) {
|
|
906
1213
|
const repoRoot = findRepoRootFromDir(dir);
|
|
907
1214
|
const firstStep = resolveFirstStep(flowId, repoRoot);
|
|
908
1215
|
if (firstStep) stepId = firstStep;
|
|
1216
|
+
} else if (flowId && explicitStep) {
|
|
1217
|
+
// WS8 (AC12): --step-id is the sanctioned ad-hoc direct-entry mechanism. When it names
|
|
1218
|
+
// a step other than the flow's resolved first step, record an explicit ad_hoc_entry
|
|
1219
|
+
// marker (with a reason) instead of silently letting the mis-stamp look like the
|
|
1220
|
+
// flow's normal first step. This is the root-cause fix for a planning-only session
|
|
1221
|
+
// whose active_step_id would otherwise default to builder.build's first step.
|
|
1222
|
+
const repoRoot = findRepoRootFromDir(dir);
|
|
1223
|
+
const firstStep = resolveFirstStep(flowId, repoRoot);
|
|
1224
|
+
if (firstStep && firstStep !== explicitStep) {
|
|
1225
|
+
adHocReason = opt(p, "ad-hoc-reason") || `direct entry at step "${explicitStep}" via --step-id (flow first step is "${firstStep}")`;
|
|
1226
|
+
}
|
|
909
1227
|
}
|
|
910
|
-
writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", flowId || undefined, stepId || undefined);
|
|
1228
|
+
writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", flowId || undefined, stepId || undefined, adHocReason);
|
|
911
1229
|
console.log(dir);
|
|
912
1230
|
return 0;
|
|
913
1231
|
}
|
|
@@ -928,7 +1246,7 @@ function recordAgentEvent(p: ReturnType<typeof parseArgs>): number {
|
|
|
928
1246
|
const dir = explicit ? path.resolve(explicit) : currentDir(root);
|
|
929
1247
|
if (!dir || !fs.existsSync(dir)) die("artifact directory does not exist");
|
|
930
1248
|
if (explicit && fs.lstatSync(dir).isSymbolicLink()) die(`artifact directory must not be a symlink: ${dir}`);
|
|
931
|
-
if (hasExplicitRoot) requireArtifactDirUnderRoot(dir, root);
|
|
1249
|
+
if (hasExplicitRoot || !explicit) requireArtifactDirUnderRoot(dir, root);
|
|
932
1250
|
const timestamp = opt(p, "timestamp", now());
|
|
933
1251
|
const agent = validateAgentId(opt(p, "agent-id"));
|
|
934
1252
|
const event = { timestamp, agent_id: agent, kind: opt(p, "kind", "note"), status: opt(p, "status", "info"), summary: opt(p, "summary"), ...(opt(p, "ref") ? { ref: opt(p, "ref") } : {}) };
|
|
@@ -986,7 +1304,7 @@ export function normalizeEvidenceRefs(raw: unknown, label: string): AnyObj[] {
|
|
|
986
1304
|
export function normalizeCheck(raw: AnyObj): AnyObj {
|
|
987
1305
|
const check = { ...raw };
|
|
988
1306
|
if (!check.id || !check.kind || !check.status || !check.summary) die("check requires id, kind, status, and summary");
|
|
989
|
-
|
|
1307
|
+
if (!checkKinds.has(check.kind)) die("kind must be one of: build, types, lint, test, command, security, diff, browser, runtime, policy, external");
|
|
990
1308
|
if (!checkStatuses.has(check.status)) die("status must be one of: pass, fail, not_verified, skip");
|
|
991
1309
|
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");
|
|
992
1310
|
if (check.artifact_refs) check.artifact_refs = normalizeEvidenceRefs(check.artifact_refs, "artifact_refs");
|
|
@@ -1129,7 +1447,7 @@ export function writeState(dir: string, slug: string, status: string, phase: str
|
|
|
1129
1447
|
// "builder.verify.policy-compliance") so round-trip helpers broaden their filters
|
|
1130
1448
|
// to include declared claims alongside the legacy workflow.* ones.
|
|
1131
1449
|
//
|
|
1132
|
-
// Safety guard: current.json in the
|
|
1450
|
+
// Safety guard: current.json in the runtime artifact root records the CURRENTLY ACTIVE
|
|
1133
1451
|
// session via artifact_dir. If current.json points to a different session than `dir`
|
|
1134
1452
|
// (e.g. another session was the last to call advance-state --flow-definition), we
|
|
1135
1453
|
// return an empty set so declared-type predicates are NOT applied to the wrong session.
|
|
@@ -1213,12 +1531,46 @@ function critiquesFromBundle(dir: string, declaredClaimTypes: Set<string> = new
|
|
|
1213
1531
|
}));
|
|
1214
1532
|
}
|
|
1215
1533
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1534
|
+
/**
|
|
1535
|
+
* WS8 (ADR 0020): parse the accepted-gap waiver flags. Both --accepted-gap-reason and
|
|
1536
|
+
* --waived-by are required together (an accepted gap with no justification or no
|
|
1537
|
+
* approver is refused — no silent/default waiver). Returns the waiver record to stamp
|
|
1538
|
+
* onto claim.metadata.waiver, or null when neither flag is present. Reuses the existing
|
|
1539
|
+
* accepted_gap -> assumed status mapping; adds no new canonical status value.
|
|
1540
|
+
*/
|
|
1541
|
+
function parseWaiver(p: ReturnType<typeof parseArgs>, ts: string): AnyObj | null {
|
|
1542
|
+
const reason = opt(p, "accepted-gap-reason");
|
|
1543
|
+
const waivedBy = opt(p, "waived-by");
|
|
1544
|
+
if (!reason && !waivedBy) return null;
|
|
1545
|
+
if (!reason) die("--accepted-gap-reason is required when --waived-by is set (an accepted gap must carry its justification)");
|
|
1546
|
+
if (!waivedBy) die("--waived-by is required when --accepted-gap-reason is set (an accepted gap must name its approver)");
|
|
1547
|
+
return { reason, approved_by: waivedBy, approved_at: ts };
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1216
1550
|
async function recordEvidence(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
1217
1551
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
1218
1552
|
const verdict = opt(p, "verdict") || die("--verdict is required");
|
|
1219
1553
|
if (!verdicts.has(verdict)) die("verdict must be one of: pass, partial, fail, not_verified");
|
|
1220
1554
|
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
1221
|
-
const
|
|
1555
|
+
const _ts0 = opt(p, "timestamp", now());
|
|
1556
|
+
const _waiver = parseWaiver(p, _ts0);
|
|
1557
|
+
const _checksRaw = [...opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json"))), ...opts(p, "surface-trust-json").map(surfaceCheckFromArtifact)];
|
|
1558
|
+
// WS8 (AC4, iteration 2): a command-backed check reconciles against CI or fails — it can
|
|
1559
|
+
// NEVER be waived. Reject --accepted-gap-reason/--waived-by on any check whose evidence
|
|
1560
|
+
// classifies as test_output (build/types/lint/test/command, and security/browser/runtime
|
|
1561
|
+
// WHEN a command is present). Only session-local checks (attestation/observation/citation/
|
|
1562
|
+
// diff/policy) are waivable. The CI reconciler enforces the same rule server-side (a waiver
|
|
1563
|
+
// on test_output evidence is a divergence), so this producer-side guard is defense-in-depth,
|
|
1564
|
+
// not the sole gate. (ADR 0020)
|
|
1565
|
+
if (_waiver) {
|
|
1566
|
+
for (const c of _checksRaw) {
|
|
1567
|
+
const hasCmd = typeof (c as AnyObj).command === "string" && String((c as AnyObj).command).trim().length > 0;
|
|
1568
|
+
if (classifyEvidence((c as AnyObj).kind as string | undefined, hasCmd).evidenceType === "test_output") {
|
|
1569
|
+
die(`--accepted-gap-reason/--waived-by cannot be applied to a command-backed check (kind='${String((c as AnyObj).kind)}'${hasCmd ? " with a command" : ""}): a command-backed check reconciles against CI or fails and cannot be waived. Waive only session-local checks (attestation/observation/citation). (ADR 0020)`);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
const checks = _checksRaw.map((c) => _waiver ? { ...c, _waiver } : c);
|
|
1222
1574
|
if (!checks.length && opts(p, "surface-trust-json").length === 0) die("record-evidence requires at least one --check-json or --surface-trust-json");
|
|
1223
1575
|
validateAcceptanceEvidenceRefs(dir);
|
|
1224
1576
|
// Phase 4c: bundle is the sole verification artifact — stop writing evidence.json and acceptance.json update.
|
|
@@ -1319,12 +1671,106 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
1319
1671
|
}
|
|
1320
1672
|
|
|
1321
1673
|
const checkNormalized = normalizeCheck(check);
|
|
1674
|
+
// WS8 (ADR 0020): honor the accepted-gap waiver flags for a gate claim too.
|
|
1675
|
+
const gateWaiver = parseWaiver(p, ts);
|
|
1676
|
+
if (gateWaiver) checkNormalized._waiver = gateWaiver;
|
|
1322
1677
|
// Log the targeted gate expectation for transparency (goes to stderr only)
|
|
1323
|
-
process.stderr.write(`[record-gate-claim] targeting ${activeStep.stepId}/${activeStep.gateId}/${targetExpectation.id} → claimType=${claimType} subjectType=${subjectType}\n`);
|
|
1678
|
+
process.stderr.write(`[record-gate-claim] targeting ${activeStep.stepId}/${activeStep.gateId}/${targetExpectation.id} → claimType=${claimType} subjectType=${subjectType}${gateWaiver ? " (WAIVED accepted_gap)" : ""}\n`);
|
|
1324
1679
|
assertBundleWritten(await writeTrustBundle(dir, slug, ts, [checkNormalized], [], []));
|
|
1325
1680
|
return 0;
|
|
1326
1681
|
}
|
|
1327
1682
|
|
|
1683
|
+
/**
|
|
1684
|
+
* promote — the promote-then-archive gate (issue #312). Durable-residue extraction is
|
|
1685
|
+
* the archival act: this records WHAT durable residue was promoted WHERE and writes a
|
|
1686
|
+
* PROMOTION CLAIM into the session trust.bundle.
|
|
1687
|
+
*
|
|
1688
|
+
* Claim shape (reconcile-safe by construction): the promotion check is kind="policy",
|
|
1689
|
+
* so classifyEvidence maps it to evidenceType "policy_rule" (session-local, method
|
|
1690
|
+
* "auditability"). It carries NO command / execution.label, so it can NEVER require a
|
|
1691
|
+
* reconcile-manifest entry and can NEVER become a [not-run] / unbacked-command
|
|
1692
|
+
* divergence at CI trust-reconcile. Its status derives to `verified` from that
|
|
1693
|
+
* session-local policy_rule evidence item, so the reconciler classifies it session-local
|
|
1694
|
+
* and accepts it as an ATTESTED claim (exit 0) — never as a test_output claim that must
|
|
1695
|
+
* match the manifest. The _promotion marker rides onto claim.metadata.promotion (see
|
|
1696
|
+
* buildTrustBundle) so the archive gate and validators can detect it without new manifest
|
|
1697
|
+
* entries (R1).
|
|
1698
|
+
*
|
|
1699
|
+
* Modes:
|
|
1700
|
+
* promote <dir> --evidence-path <p> [--evidence-path <p> ...]
|
|
1701
|
+
* Records the durable doc paths written (docs/decisions/<slug>.md, CONTEXT.md,
|
|
1702
|
+
* docs/learnings/*, …). Each path MUST exist on disk at record time — a missing
|
|
1703
|
+
* path fails loud (no silent empty promotion).
|
|
1704
|
+
* promote <dir> --none --reason "<why nothing durable>"
|
|
1705
|
+
* An explicit, auditable no-residue promotion (R3).
|
|
1706
|
+
*/
|
|
1707
|
+
async function promote(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
1708
|
+
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
1709
|
+
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
1710
|
+
const ts = opt(p, "timestamp", now());
|
|
1711
|
+
const none = p.flags.has("none");
|
|
1712
|
+
const reason = opt(p, "reason");
|
|
1713
|
+
const repoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDir(dir);
|
|
1714
|
+
const rawPaths = opts(p, "evidence-path");
|
|
1715
|
+
|
|
1716
|
+
if (none) {
|
|
1717
|
+
if (rawPaths.length) die("promote --none records a no-residue claim; do not also pass --evidence-path");
|
|
1718
|
+
if (!reason.trim()) die("promote --none requires --reason \"<why nothing durable was promoted>\"");
|
|
1719
|
+
} else {
|
|
1720
|
+
if (!rawPaths.length) die("promote requires at least one --evidence-path <durable-doc-path> (or --none --reason \"<why>\" for an explicit no-residue promotion)");
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// Every evidence ref MUST exist on disk at record time (fail loud otherwise). Store
|
|
1724
|
+
// repo-relative paths when the ref lives under the repo, so the claim is portable.
|
|
1725
|
+
const targets: string[] = [];
|
|
1726
|
+
for (const raw of rawPaths) {
|
|
1727
|
+
const abs = path.isAbsolute(raw) ? raw : path.resolve(repoRoot, raw);
|
|
1728
|
+
if (!fs.existsSync(abs)) die(`promote --evidence-path does not exist on disk: ${raw} (resolved: ${abs}). Promotion evidence refs must point at durable docs that were actually written.`);
|
|
1729
|
+
const rel = path.relative(repoRoot, abs);
|
|
1730
|
+
targets.push(rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel : raw);
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
const promotionMarker: AnyObj = {
|
|
1734
|
+
schema_version: "1.0",
|
|
1735
|
+
none,
|
|
1736
|
+
...(none ? { reason } : {}),
|
|
1737
|
+
targets,
|
|
1738
|
+
promoted_at: ts,
|
|
1739
|
+
};
|
|
1740
|
+
|
|
1741
|
+
const summary = opt(p, "summary") || (none
|
|
1742
|
+
? `Promotion (no durable residue): ${reason}`
|
|
1743
|
+
: `Promoted durable residue: ${targets.join(", ")}`);
|
|
1744
|
+
|
|
1745
|
+
// Session-local promotion check: kind="policy" -> policy_rule evidence, no command.
|
|
1746
|
+
const promotionCheck: AnyObj = { id: "promotion", kind: "policy", status: "pass", summary, _promotion: promotionMarker };
|
|
1747
|
+
|
|
1748
|
+
// Add the promotion claim WITHOUT dropping the session's existing verification
|
|
1749
|
+
// evidence/criteria/critiques (mirror record-critique's merge pattern). Drop any prior
|
|
1750
|
+
// "promotion" check so re-running promote is idempotent rather than duplicating.
|
|
1751
|
+
const _dct = declaredClaimTypesFor(dir);
|
|
1752
|
+
const existingChecks = checksFromBundle(dir, _dct).filter((c) => c.id !== "promotion");
|
|
1753
|
+
const _acc = loadJson(path.join(dir, "acceptance.json"));
|
|
1754
|
+
const criteria: AnyObj[] = Array.isArray(_acc.criteria) ? _acc.criteria : [];
|
|
1755
|
+
const critiques = critiquesFromBundle(dir, _dct);
|
|
1756
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, ts, [...existingChecks, promotionCheck], criteria, critiques));
|
|
1757
|
+
|
|
1758
|
+
// Auditable record of what was promoted where (companion to the trust.bundle claim).
|
|
1759
|
+
writeJson(path.join(dir, "promotion.json"), { ...sidecarBase(slug), ...promotionMarker, summary });
|
|
1760
|
+
|
|
1761
|
+
// Optionally republish so delivery/trust.bundle carries the promotion claim for CI.
|
|
1762
|
+
if (p.flags.has("publish")) {
|
|
1763
|
+
const publishRepoRoot = opt(p, "publish-repo-root") ? path.resolve(opt(p, "publish-repo-root")) : findRepoRootFromDirStrict(dir);
|
|
1764
|
+
await publishDelivery(dir, publishRepoRoot).catch((err: unknown) => {
|
|
1765
|
+
process.stderr.write(`[promote] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
process.stderr.write(`[promote] recorded ${none ? "no-residue" : targets.length + " durable ref(s)"} promotion claim for ${slug}\n`);
|
|
1770
|
+
printJson({ ok: true, slug, none, targets, promotion_claim: "trust.bundle" });
|
|
1771
|
+
return 0;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1328
1774
|
async function advanceState(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
1329
1775
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
1330
1776
|
const status = opt(p, "status");
|
|
@@ -1375,7 +1821,14 @@ async function advanceState(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
1375
1821
|
if (status === "delivered") {
|
|
1376
1822
|
await sealTrustCheckpoint(dir, slug, timestamp, status, "release").catch(() => { /* best-effort; checkpoint seal must not break advance-state */ });
|
|
1377
1823
|
// Publish delivery bundle: best-effort copy to delivery/ for CI trust-reconcile.
|
|
1378
|
-
|
|
1824
|
+
// Fail-closed repo-root resolution (findRepoRootFromDirStrict, no cwd fallback) — see
|
|
1825
|
+
// publishDelivery below. An explicit --repo-root (e.g. for a scratch/test artifact dir
|
|
1826
|
+
// with no kits/ ancestor of its own) always wins, matching publishDeliveryCmd. Failures
|
|
1827
|
+
// are visible (stderr warning), not silently swallowed.
|
|
1828
|
+
const publishRepoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
|
|
1829
|
+
await publishDelivery(dir, publishRepoRoot).catch((err: unknown) => {
|
|
1830
|
+
process.stderr.write(`[advance-state] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1831
|
+
});
|
|
1379
1832
|
}
|
|
1380
1833
|
return 0;
|
|
1381
1834
|
}
|
|
@@ -1442,7 +1895,14 @@ async function recordRelease(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
1442
1895
|
// Trust checkpoint: seal at the "delivered" moment (the natural terminal mark for record-release).
|
|
1443
1896
|
await sealTrustCheckpoint(dir, slug, payload.updated_at, "delivered", "release").catch(() => { /* best-effort; checkpoint seal must not break record-release */ });
|
|
1444
1897
|
// Publish delivery bundle: best-effort copy to delivery/ for CI trust-reconcile.
|
|
1445
|
-
|
|
1898
|
+
// Fail-closed repo-root resolution (findRepoRootFromDirStrict, no cwd fallback) — see
|
|
1899
|
+
// publishDelivery below. An explicit --repo-root (e.g. for a scratch/test artifact dir with
|
|
1900
|
+
// no kits/ ancestor of its own) always wins, matching publishDeliveryCmd. Failures are
|
|
1901
|
+
// visible (stderr warning), not silently swallowed.
|
|
1902
|
+
const publishRepoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
|
|
1903
|
+
await publishDelivery(dir, publishRepoRoot).catch((err: unknown) => {
|
|
1904
|
+
process.stderr.write(`[record-release] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1905
|
+
});
|
|
1446
1906
|
return 0;
|
|
1447
1907
|
}
|
|
1448
1908
|
|
|
@@ -1657,7 +2117,7 @@ async function sealCheckpoint(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
1657
2117
|
|
|
1658
2118
|
// ─── Publish Delivery Bundle ──────────────────────────────────────────────────
|
|
1659
2119
|
// Copies the session's trust.bundle (+ checkpoint companions) from the gitignored
|
|
1660
|
-
// session artifact dir (.flow-agents/<slug>/) to the committed delivery/ transport
|
|
2120
|
+
// session artifact dir (.kontourai/flow-agents/<slug>/) to the committed delivery/ transport
|
|
1661
2121
|
// path so the CI trust-reconcile job can reconcile it against fresh CI results.
|
|
1662
2122
|
//
|
|
1663
2123
|
// Fail-soft: if trust.bundle is absent (no evidence recorded yet), does nothing.
|
|
@@ -1672,13 +2132,25 @@ async function sealCheckpoint(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
1672
2132
|
* trust.checkpoint.intoto.json / trust.checkpoint.sig.json from the
|
|
1673
2133
|
* session artifact dir to <repoRoot>/delivery/.
|
|
1674
2134
|
*
|
|
1675
|
-
* Fail-soft: if trust.bundle is absent, returns without throwing.
|
|
2135
|
+
* Fail-soft on a missing bundle: if trust.bundle is absent, returns without throwing.
|
|
2136
|
+
* Fail-CLOSED on repo-root resolution: repoRoot must be a real, resolved kits/ ancestor
|
|
2137
|
+
* (see findRepoRootFromDirStrict) — null (no ancestor found) skips the publish with a
|
|
2138
|
+
* visible warning instead of writing to whatever process.cwd() happens to be. This
|
|
2139
|
+
* prevents a scratch/test session dir (no kits/ ancestor) from silently clobbering an
|
|
2140
|
+
* unrelated real repo's delivery/trust.bundle when invoked with that repo as cwd (see
|
|
2141
|
+
* evals/integration/test_checkpoint_signing.sh TEST 2 and the WS5 session findings at
|
|
2142
|
+
* .kontourai/flow-agents/ws5-governance-kit-slice1 for the root cause this fixes).
|
|
1676
2143
|
* Idempotent: overwrites on re-delivery.
|
|
1677
2144
|
*/
|
|
1678
|
-
export async function publishDelivery(dir: string, repoRoot: string): Promise<void> {
|
|
2145
|
+
export async function publishDelivery(dir: string, repoRoot: string | null): Promise<void> {
|
|
1679
2146
|
const bundleSrc = path.join(dir, "trust.bundle");
|
|
1680
2147
|
if (!fs.existsSync(bundleSrc)) return; // no bundle — skip gracefully
|
|
1681
2148
|
|
|
2149
|
+
if (!repoRoot) {
|
|
2150
|
+
process.stderr.write(`[publish-delivery] WARNING: no kits/ ancestor found from ${dir}; skipping publish. Refusing to fall back to process.cwd() to avoid clobbering an unrelated repo's delivery/trust.bundle. Pass --repo-root explicitly if this session dir is intentionally outside a repo checkout.\n`);
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
|
|
1682
2154
|
const deliveryDir = path.join(repoRoot, "delivery");
|
|
1683
2155
|
fs.mkdirSync(deliveryDir, { recursive: true });
|
|
1684
2156
|
|
|
@@ -1713,7 +2185,10 @@ export async function publishDelivery(dir: string, repoRoot: string): Promise<vo
|
|
|
1713
2185
|
*/
|
|
1714
2186
|
async function publishDeliveryCmd(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
1715
2187
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
1716
|
-
|
|
2188
|
+
// Fail-closed: an explicit --repo-root always wins; otherwise resolve strictly (no
|
|
2189
|
+
// process.cwd() fallback) so a scratch/test artifact dir cannot accidentally publish
|
|
2190
|
+
// into whichever repo happens to be the current working directory.
|
|
2191
|
+
const repoRoot = opt(p, "repo-root") || findRepoRootFromDirStrict(dir);
|
|
1717
2192
|
await publishDelivery(dir, repoRoot);
|
|
1718
2193
|
return 0;
|
|
1719
2194
|
}
|
|
@@ -1890,7 +2365,7 @@ export interface TrustClaim {
|
|
|
1890
2365
|
id: string;
|
|
1891
2366
|
subjectType: string;
|
|
1892
2367
|
subjectId: string;
|
|
1893
|
-
|
|
2368
|
+
facet?: string;
|
|
1894
2369
|
claimType: string;
|
|
1895
2370
|
fieldOrBehavior: string;
|
|
1896
2371
|
value: string;
|
|
@@ -1909,7 +2384,7 @@ export interface BundleFile {
|
|
|
1909
2384
|
policies: AnyObj[];
|
|
1910
2385
|
}
|
|
1911
2386
|
|
|
1912
|
-
/** The gate block signal read from .flow-agents/.goal-fit-block-streak.json */
|
|
2387
|
+
/** The gate block signal read from .kontourai/flow-agents/.goal-fit-block-streak.json */
|
|
1913
2388
|
export interface GateBlockSignal {
|
|
1914
2389
|
/** True when the streak file exists AND count >= 1 */
|
|
1915
2390
|
blocked: boolean;
|
|
@@ -1926,13 +2401,13 @@ export interface GateBlockSignal {
|
|
|
1926
2401
|
export type GateCalibration = "correct" | "false_block" | "missed_block";
|
|
1927
2402
|
|
|
1928
2403
|
/**
|
|
1929
|
-
* Read the gate block signal from .flow-agents/.goal-fit-block-streak.json
|
|
2404
|
+
* Read the gate block signal from .kontourai/flow-agents/.goal-fit-block-streak.json
|
|
1930
2405
|
* (written by scripts/hooks/stop-goal-fit.js when block mode fires).
|
|
1931
2406
|
* The file sits at <artifact-root>/.goal-fit-block-streak.json — one level
|
|
1932
2407
|
* above the session artifact dir. Fail-open: returns { blocked: false } when
|
|
1933
2408
|
* the file is absent or unreadable.
|
|
1934
2409
|
*
|
|
1935
|
-
* @param artifactRoot The
|
|
2410
|
+
* @param artifactRoot The runtime artifact root dir (parent of session slug dir).
|
|
1936
2411
|
*/
|
|
1937
2412
|
export function readGateBlockSignal(artifactRoot: string): GateBlockSignal {
|
|
1938
2413
|
const streakFile = path.join(artifactRoot, ".goal-fit-block-streak.json");
|
|
@@ -2159,7 +2634,7 @@ export function buildGateInquiryRecords(
|
|
|
2159
2634
|
*
|
|
2160
2635
|
* The block signal is read from <artifact-root>/.goal-fit-block-streak.json,
|
|
2161
2636
|
* written by scripts/hooks/stop-goal-fit.js when block mode fires. The file
|
|
2162
|
-
* lives one level above the session slug dir (the
|
|
2637
|
+
* lives one level above the session slug dir (the runtime artifact root).
|
|
2163
2638
|
*
|
|
2164
2639
|
* If @kontourai/surface is unavailable, logs a warning and returns 0
|
|
2165
2640
|
* (fail-open — no bespoke fork fallback).
|
|
@@ -2185,7 +2660,7 @@ async function gateReview(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
2185
2660
|
|
|
2186
2661
|
const bundle: BundleFile = JSON.parse(fs.readFileSync(bundlePath, "utf8"));
|
|
2187
2662
|
|
|
2188
|
-
// Read gate block signal from
|
|
2663
|
+
// Read gate block signal from the runtime artifact root (one level above session dir)
|
|
2189
2664
|
const artifactRoot = path.dirname(dir);
|
|
2190
2665
|
const blockSignal = readGateBlockSignal(artifactRoot);
|
|
2191
2666
|
|
|
@@ -2342,7 +2817,7 @@ function trustMcp(p: ReturnType<typeof parseArgs>): number {
|
|
|
2342
2817
|
if (mode === "print") {
|
|
2343
2818
|
console.log(JSON.stringify({ mcpServers: { [TRUST_MCP_SERVER]: trustMcpRegistration() } }, null, 2));
|
|
2344
2819
|
process.stderr.write(`\n# Paste the above into your runtime MCP config (e.g. .mcp.json). Flow Agents does NOT write it for you unless you run: trust-mcp --mode enable\n`);
|
|
2345
|
-
process.stderr.write(`# To view a task's trust inline, call surface_summary with path=<.flow-agents/<slug>/trust.bundle>.\n`);
|
|
2820
|
+
process.stderr.write(`# To view a task's trust inline, call surface_summary with path=<.kontourai/flow-agents/<slug>/trust.bundle>.\n`);
|
|
2346
2821
|
return 0;
|
|
2347
2822
|
}
|
|
2348
2823
|
if (mode !== "enable" && mode !== "disable") die("trust-mcp --mode must be print|enable|disable");
|
|
@@ -2386,12 +2861,25 @@ const LIVENESS_POLICY = {
|
|
|
2386
2861
|
impactLevel: "medium",
|
|
2387
2862
|
};
|
|
2388
2863
|
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2864
|
+
/**
|
|
2865
|
+
* Delegate to the shared pure-CJS writer (scripts/hooks/lib/liveness-write.js), mirroring the
|
|
2866
|
+
* createRequire pattern used by loadActorIdentityHelper() below. Deliberately NO inline duplicate
|
|
2867
|
+
* fallback — the whole point of #288 Wave 2 Task 2.1 is one writer shared by the CLI and the
|
|
2868
|
+
* hook wrappers' tool-activity heartbeat, not two copies of the append shape that can drift.
|
|
2869
|
+
*/
|
|
2870
|
+
function loadLivenessWriteHelper(): {
|
|
2871
|
+
livenessStreamFile: (root: string) => string;
|
|
2872
|
+
appendLivenessEvent: (root: string, evt: AnyObj) => void;
|
|
2873
|
+
} {
|
|
2874
|
+
const _req = createRequire(import.meta.url);
|
|
2875
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-write.js");
|
|
2876
|
+
return _req(helperPath) as {
|
|
2877
|
+
livenessStreamFile: (root: string) => string;
|
|
2878
|
+
appendLivenessEvent: (root: string, evt: AnyObj) => void;
|
|
2879
|
+
};
|
|
2394
2880
|
}
|
|
2881
|
+
function livenessStreamFile(root: string): string { return loadLivenessWriteHelper().livenessStreamFile(root); }
|
|
2882
|
+
function appendLivenessEvent(root: string, evt: AnyObj): void { loadLivenessWriteHelper().appendLivenessEvent(root, evt); }
|
|
2395
2883
|
function readLivenessEvents(root: string): AnyObj[] {
|
|
2396
2884
|
// Delegate to the shared pure-CJS helper (scripts/hooks/lib/liveness-read.js).
|
|
2397
2885
|
// Using createRequire so the ESM sidecar can load a CJS module without bundling it.
|
|
@@ -2414,36 +2902,168 @@ function livenessLabel(status: string): string {
|
|
|
2414
2902
|
return status;
|
|
2415
2903
|
}
|
|
2416
2904
|
|
|
2417
|
-
// ─── ADR 0012 lifecycle-driven liveness (opt-
|
|
2905
|
+
// ─── ADR 0012 lifecycle-driven liveness (default-on; opt-out via FLOW_AGENTS_LIVENESS) ──
|
|
2418
2906
|
// init-plan claims the work-item; advance-state heartbeats (or releases on terminal),
|
|
2419
2907
|
// so the workflow lifecycle itself maintains the liveness claim — no manual liveness calls.
|
|
2420
2908
|
// Additive + fail-open: a liveness-emit failure never affects the workflow command.
|
|
2421
2909
|
const LIVENESS_TERMINAL = new Set(["delivered", "accepted", "archived"]);
|
|
2422
|
-
|
|
2423
|
-
|
|
2910
|
+
/**
|
|
2911
|
+
* Delegate to the shared pure-CJS resolver (scripts/hooks/lib/actor-identity.js), mirroring the
|
|
2912
|
+
* createRequire pattern used by readLivenessEvents() above. Deliberately NO inline duplicate
|
|
2913
|
+
* fallback: if the module fails to load, that failure itself must surface as an unresolved actor
|
|
2914
|
+
* ("") — never silently degrade back to the retired "local" default (issue #287).
|
|
2915
|
+
*/
|
|
2916
|
+
function loadActorIdentityHelper(): {
|
|
2917
|
+
resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
|
|
2918
|
+
sanitizeSegment: (value: unknown) => string;
|
|
2919
|
+
isUnresolvedActor: (actor: string) => boolean;
|
|
2920
|
+
} {
|
|
2921
|
+
const _req = createRequire(import.meta.url);
|
|
2922
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/actor-identity.js");
|
|
2923
|
+
return _req(helperPath) as {
|
|
2924
|
+
resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
|
|
2925
|
+
sanitizeSegment: (value: unknown) => string;
|
|
2926
|
+
isUnresolvedActor: (actor: string) => boolean;
|
|
2927
|
+
};
|
|
2928
|
+
}
|
|
2929
|
+
function resolveLivenessActor(): string {
|
|
2930
|
+
return loadActorIdentityHelper().resolveActor(process.env).actor;
|
|
2931
|
+
}
|
|
2932
|
+
// isUnresolvedActor is no longer defined locally — it is single-sourced in
|
|
2933
|
+
// scripts/hooks/lib/actor-identity.js (loadActorIdentityHelper().isUnresolvedActor) so the
|
|
2934
|
+
// lifecycle auto-emit path, the direct CLI liveness path, and the tool-activity heartbeat path
|
|
2935
|
+
// all consume the same predicate rather than forking their own copy (#287 re-review MEDIUM; #288
|
|
2936
|
+
// Wave 1 Task 1.1 single-sources it there).
|
|
2937
|
+
/**
|
|
2938
|
+
* Delegate to the shared pure-CJS policy predicates (scripts/hooks/lib/liveness-policy.js) — the
|
|
2939
|
+
* one definition of "enabled" (default-on/opt-out) and the claim TTL default, consumed by both the
|
|
2940
|
+
* lifecycle auto-emit path and the manual `liveness claim --ttl` default so they can never disagree
|
|
2941
|
+
* (#288).
|
|
2942
|
+
*/
|
|
2943
|
+
function loadLivenessPolicyHelper(): {
|
|
2944
|
+
isLivenessEnabled: (env: NodeJS.ProcessEnv) => boolean;
|
|
2945
|
+
resolveTtlSeconds: (env: NodeJS.ProcessEnv) => number;
|
|
2946
|
+
} {
|
|
2947
|
+
const _req = createRequire(import.meta.url);
|
|
2948
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-policy.js");
|
|
2949
|
+
return _req(helperPath) as {
|
|
2950
|
+
isLivenessEnabled: (env: NodeJS.ProcessEnv) => boolean;
|
|
2951
|
+
resolveTtlSeconds: (env: NodeJS.ProcessEnv) => number;
|
|
2952
|
+
};
|
|
2953
|
+
}
|
|
2954
|
+
function livenessEnabled(): boolean { return loadLivenessPolicyHelper().isLivenessEnabled(process.env); }
|
|
2955
|
+
/**
|
|
2956
|
+
* F1 (#288 fix iteration 1, cr-HIGH fail-open violation): the `livenessEnabled()`
|
|
2957
|
+
* guard (and therefore its `loadLivenessPolicyHelper()` module load) must sit
|
|
2958
|
+
* INSIDE this function's own fail-open try/catch — previously it sat outside,
|
|
2959
|
+
* so a missing/broken scripts/hooks/lib/liveness-policy.js module made
|
|
2960
|
+
* `init-plan`/`advance-state` exit 1 instead of degrading gracefully
|
|
2961
|
+
* (repro-verified). Now: any failure here — including a failed helper load —
|
|
2962
|
+
* is caught, produces one stderr diagnostic, and the lifecycle auto-emit is
|
|
2963
|
+
* skipped; the workflow command's own exit code is never affected. This
|
|
2964
|
+
* mirrors the #287 fail-open convention already used elsewhere in this file.
|
|
2965
|
+
* The direct CLI write path (`async function liveness`, actions
|
|
2966
|
+
* claim|heartbeat|release) is deliberately NOT wrapped this way — it stays
|
|
2967
|
+
* fail-loud on a missing helper module, per the plan's explicit instruction
|
|
2968
|
+
* that only the convenience lifecycle wiring is flag-gated/fail-open.
|
|
2969
|
+
*/
|
|
2424
2970
|
function livenessLifecycle(taskDir: string, slug: string, kind: "claim" | "heartbeat" | "release", timestamp: string): void {
|
|
2425
|
-
if (!livenessEnabled()) return;
|
|
2426
2971
|
try {
|
|
2427
|
-
|
|
2428
|
-
const
|
|
2429
|
-
if (
|
|
2972
|
+
if (!livenessEnabled()) return;
|
|
2973
|
+
const actor = resolveLivenessActor();
|
|
2974
|
+
if (loadActorIdentityHelper().isUnresolvedActor(actor)) {
|
|
2975
|
+
process.stderr.write("[liveness] skipped auto-emit: actor unresolved (set FLOW_AGENTS_ACTOR or run inside a supported runtime)\n");
|
|
2976
|
+
return;
|
|
2977
|
+
}
|
|
2978
|
+
const root = path.dirname(taskDir); // .kontourai/flow-agents/<slug> → .kontourai/flow-agents (the shared liveness stream lives here)
|
|
2979
|
+
const evt: AnyObj = { type: kind, subjectId: slug, actor, at: timestamp, source: "lifecycle" };
|
|
2980
|
+
if (kind === "claim") evt.ttlSeconds = loadLivenessPolicyHelper().resolveTtlSeconds(process.env);
|
|
2430
2981
|
appendLivenessEvent(root, evt);
|
|
2431
|
-
} catch {
|
|
2982
|
+
} catch (err) {
|
|
2983
|
+
// best-effort; liveness is advisory and must never break the workflow — but the failure
|
|
2984
|
+
// itself must be visible (F1), not silently absorbed.
|
|
2985
|
+
try {
|
|
2986
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
2987
|
+
process.stderr.write(`[liveness] skipped auto-emit: ${detail}\n`);
|
|
2988
|
+
} catch { /* best-effort diagnostic only */ }
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
/**
|
|
2993
|
+
* F5 (#288 fix iteration 1, sec-LOW): strip control/escape characters before echoing a
|
|
2994
|
+
* subjectId/actor to the terminal. subjectId is a raw CLI positional argument on the write path
|
|
2995
|
+
* (never sanitized before this point — it is stored as-is in the event for data fidelity) and,
|
|
2996
|
+
* on the `status` read path, both subjectId and actor may originate from a hand-edited or
|
|
2997
|
+
* otherwise hostile liveness/events.jsonl file rather than this process's own writes. Strips the
|
|
2998
|
+
* C0 control range (0x00-0x1F), DEL (0x7F), and the C1 range (0x80-0x9F, which includes
|
|
2999
|
+
* ANSI-CSI-adjacent bytes) — display-only; the persisted event itself is never mutated.
|
|
3000
|
+
*/
|
|
3001
|
+
function stripControlCharsForDisplay(value: unknown): string {
|
|
3002
|
+
return String(value ?? "").replace(/[\u0000-\u001F\u007F-\u009F]/g, "");
|
|
2432
3003
|
}
|
|
2433
3004
|
|
|
2434
3005
|
async function liveness(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
2435
3006
|
const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
|
|
2436
3007
|
const action = p.positional[0] || "";
|
|
2437
3008
|
const subjectId = p.positional[1] || "";
|
|
2438
|
-
const actor = opt(p, "actor", process.env.FLOW_AGENTS_ACTOR || "unknown");
|
|
2439
3009
|
const nowIso = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
2440
3010
|
|
|
3011
|
+
if (action === "whoami") {
|
|
3012
|
+
// Read-only, lock-free, write-free advisory surface: reuses the identical resolution chain
|
|
3013
|
+
// as the write paths (loadActorIdentityHelper().resolveActor) but deliberately never dies on
|
|
3014
|
+
// an unresolved actor — the enforcement point stays at `liveness claim`, which already dies
|
|
3015
|
+
// loudly (see below). This lets a skill learn "who am I" during a read-only preflight without
|
|
3016
|
+
// emitting a bogus claim first.
|
|
3017
|
+
const helper = loadActorIdentityHelper();
|
|
3018
|
+
const explicitActorRaw = opt(p, "actor", "");
|
|
3019
|
+
const explicitActor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : "";
|
|
3020
|
+
const resolved = explicitActor
|
|
3021
|
+
? { actor: explicitActor, source: "explicit-override" }
|
|
3022
|
+
: helper.resolveActor(process.env);
|
|
3023
|
+
if (p.flags.has("json")) { console.log(JSON.stringify(resolved)); return 0; }
|
|
3024
|
+
console.log(`${stripControlCharsForDisplay(resolved.actor || "unresolved")}\t${stripControlCharsForDisplay(resolved.source)}`);
|
|
3025
|
+
return 0;
|
|
3026
|
+
}
|
|
3027
|
+
|
|
2441
3028
|
if (action === "claim" || action === "heartbeat" || action === "release") {
|
|
3029
|
+
// Actor resolution happens only for write actions (F5, #287 fix iteration 1) — "status" is a
|
|
3030
|
+
// read path and must not shell out via resolveLivenessActor()/resolveActor().
|
|
3031
|
+
const helper = loadActorIdentityHelper();
|
|
3032
|
+
const explicitActorRaw = opt(p, "actor", "");
|
|
3033
|
+
// F7 (#287 fix iteration 2): an explicit --actor value that strips to empty under the allowed
|
|
3034
|
+
// [A-Za-z0-9_.-] charset (e.g. "--actor ':::'") is a hard error on this write path, not a
|
|
3035
|
+
// silent fallback to sanitizeSegment's shared "unknown" sentinel — garbage on an explicit flag
|
|
3036
|
+
// is an authoring mistake the caller must fix, unlike the env-override seam (which falls
|
|
3037
|
+
// through to derivation instead, since there is no flag to correct).
|
|
3038
|
+
if (explicitActorRaw && !/[A-Za-z0-9_.-]/.test(explicitActorRaw)) {
|
|
3039
|
+
die(`liveness ${action} --actor value strips to empty under the allowed actor charset ([A-Za-z0-9_.-]) — pass a --actor value containing at least one letter, digit, underscore, period, or hyphen.`);
|
|
3040
|
+
}
|
|
3041
|
+
const explicitActor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : "";
|
|
3042
|
+
const actor = explicitActor || helper.resolveActor(process.env).actor;
|
|
3043
|
+
if (helper.isUnresolvedActor(actor)) {
|
|
3044
|
+
die(`liveness ${action} requires a resolvable actor — no explicit --actor flag, no FLOW_AGENTS_ACTOR override, and runtime/ancestry resolution failed. Fix: pass --actor <id>, or set FLOW_AGENTS_ACTOR=<id>, or run inside a supported runtime.`);
|
|
3045
|
+
}
|
|
2442
3046
|
if (!subjectId) die(`liveness ${action} requires a subjectId`);
|
|
3047
|
+
// F8(i) (#288 fix iteration 2, orphan-heartbeat invariant): heartbeat/release on the direct CLI
|
|
3048
|
+
// write path must never be the FIRST liveness event ever written for a (subjectId, actor) pair —
|
|
3049
|
+
// that is exactly how the reviewer-reproduced orphan-heartbeat bug arises (a bare heartbeat with
|
|
3050
|
+
// no claim behind it, later mistaken for claim evidence). A full stream read here is fine: this
|
|
3051
|
+
// is a rare, human/CLI-driven call, not the hot tool-activity path. `claim` itself is exempt (it
|
|
3052
|
+
// is the event that ESTABLISHES the pair in the first place).
|
|
3053
|
+
if (action === "heartbeat" || action === "release") {
|
|
3054
|
+
const priorEvents = readLivenessEvents(root);
|
|
3055
|
+
const hasPriorClaim = priorEvents.some((e) => e && e.type === "claim" && e.subjectId === subjectId && e.actor === actor);
|
|
3056
|
+
if (!hasPriorClaim) {
|
|
3057
|
+
die(`liveness ${action} requires a prior claim event for subjectId ${JSON.stringify(subjectId)} and actor ${JSON.stringify(actor)} — none was found in the liveness stream. Fix: run \`liveness claim ${subjectId} --actor ${actor}\` first, then retry ${action}.`);
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
2443
3060
|
const evt: AnyObj = { type: action, subjectId, actor, at: opt(p, "at") || nowIso };
|
|
2444
|
-
if (action === "claim")
|
|
3061
|
+
if (action === "claim") {
|
|
3062
|
+
const defaultTtl = loadLivenessPolicyHelper().resolveTtlSeconds(process.env);
|
|
3063
|
+
evt.ttlSeconds = Number.parseInt(opt(p, "ttl", String(defaultTtl)), 10) || defaultTtl;
|
|
3064
|
+
}
|
|
2445
3065
|
appendLivenessEvent(root, evt);
|
|
2446
|
-
console.log(`liveness ${action}: ${subjectId} by ${actor}`);
|
|
3066
|
+
console.log(`liveness ${action}: ${stripControlCharsForDisplay(subjectId)} by ${stripControlCharsForDisplay(actor)}`);
|
|
2447
3067
|
return 0;
|
|
2448
3068
|
}
|
|
2449
3069
|
|
|
@@ -2467,16 +3087,103 @@ async function liveness(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
2467
3087
|
const rows: AnyObj[] = [];
|
|
2468
3088
|
for (const g of groups.values()) {
|
|
2469
3089
|
if (subjectFilter && g.subjectId !== subjectFilter) continue;
|
|
2470
|
-
const claim: AnyObj = { id: `${g.subjectId}::${g.actor}`, subjectType: "work-item", subjectId: g.subjectId,
|
|
3090
|
+
const claim: AnyObj = { id: `${g.subjectId}::${g.actor}`, subjectType: "work-item", subjectId: g.subjectId, facet: "flow.liveness", claimType: "liveness.hold", fieldOrBehavior: "held-by", value: g.actor, createdAt: g.created, updatedAt: g.updated, ttlSeconds: g.ttlSeconds, verificationPolicyId: LIVENESS_POLICY.id };
|
|
2471
3091
|
const status = surface.deriveTrustStatus!({ claim, evidence: [], policy: LIVENESS_POLICY, events: g.events, now });
|
|
2472
3092
|
rows.push({ subjectId: g.subjectId, actor: g.actor, status, label: livenessLabel(status) });
|
|
2473
3093
|
}
|
|
2474
3094
|
if (p.flags.has("json")) { console.log(JSON.stringify(rows, null, 2)); return 0; }
|
|
2475
|
-
for (const r of rows) console.log(`${r.subjectId}\t${r.actor}\t${r.label}`);
|
|
3095
|
+
for (const r of rows) console.log(`${stripControlCharsForDisplay(r.subjectId)}\t${stripControlCharsForDisplay(r.actor)}\t${r.label}`);
|
|
3096
|
+
return 0;
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
if (action === "verdict") {
|
|
3100
|
+
// Read-only, lock-free CLI helper (#320 AC1) — mirrors `whoami`'s lock-bypass (see
|
|
3101
|
+
// isLivenessVerdict in main() below): computes {subjectId, winner, losers, reason, holders}
|
|
3102
|
+
// as a PURE function of the shared liveness stream. Among the subject's currently-fresh
|
|
3103
|
+
// claim holders (via the shared `freshHolders` helper — the exact canonical
|
|
3104
|
+
// freshness/grouping/release-handling logic `workflow-steering.js`'s ambient digest already
|
|
3105
|
+
// uses, not a re-derived rule), the holder whose most recent `claim` event has the earliest
|
|
3106
|
+
// `at` wins; an exact-timestamp tie breaks by ascending plain string comparison of actor id
|
|
3107
|
+
// (never locale-aware collation, for cross-machine determinism). Same stream state ⇒ same
|
|
3108
|
+
// verdict, regardless of which actor invokes it — no actor-specific input is read here.
|
|
3109
|
+
if (!subjectId) die("liveness verdict requires a subjectId");
|
|
3110
|
+
const events = readLivenessEvents(root);
|
|
3111
|
+
const nowMs = opt(p, "now") ? Date.parse(opt(p, "now")) : Date.now();
|
|
3112
|
+
const _req = createRequire(import.meta.url);
|
|
3113
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-read.js");
|
|
3114
|
+
const { freshHolders } = _req(helperPath) as { freshHolders: (events: AnyObj[], slug: string, selfActor: string, nowMs: number) => AnyObj[] };
|
|
3115
|
+
// Impossible-sentinel selfActor: real actors are sanitized to [A-Za-z0-9_.-]+ and can never be
|
|
3116
|
+
// empty (enforced on every write path above), so passing "" excludes nothing.
|
|
3117
|
+
const fresh = freshHolders(events, subjectId, "", nowMs);
|
|
3118
|
+
// freshHolders' `lastAt` tracks the group's latest event of ANY type (heartbeat-updated); the
|
|
3119
|
+
// tiebreak instead operates on each holder's most recent `claim`-type event `at` — the
|
|
3120
|
+
// "current claim timestamp", distinct from lastAt.
|
|
3121
|
+
const latestClaimAt = (actor: string): string => {
|
|
3122
|
+
let best = "";
|
|
3123
|
+
for (const e of events) {
|
|
3124
|
+
if (e && e.subjectId === subjectId && e.actor === actor && e.type === "claim" && typeof e.at === "string") {
|
|
3125
|
+
if (!best || e.at > best) best = e.at;
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
return best;
|
|
3129
|
+
};
|
|
3130
|
+
const holders: AnyObj[] = fresh
|
|
3131
|
+
.map((h) => ({ actor: String(h.actor), claimAt: latestClaimAt(String(h.actor)) || String(h.lastAt), lastAt: String(h.lastAt), ttlSeconds: Number(h.ttlSeconds) }))
|
|
3132
|
+
.sort((a, b) => (a.claimAt < b.claimAt ? -1 : a.claimAt > b.claimAt ? 1 : a.actor < b.actor ? -1 : a.actor > b.actor ? 1 : 0));
|
|
3133
|
+
let winner: AnyObj | null = null;
|
|
3134
|
+
let losers: AnyObj[] = [];
|
|
3135
|
+
let reason = "no-conflict";
|
|
3136
|
+
if (holders.length >= 2) {
|
|
3137
|
+
winner = holders[0];
|
|
3138
|
+
const tie = holders.filter((h) => h.claimAt === winner!.claimAt).length > 1;
|
|
3139
|
+
reason = tie ? "tie-actor-lexicographic" : "earlier-claim";
|
|
3140
|
+
losers = holders.filter((h) => h.actor !== winner!.actor);
|
|
3141
|
+
}
|
|
3142
|
+
if (p.flags.has("json")) {
|
|
3143
|
+
// #320 fix iteration 1 (sec-HIGH F3): `winner`/`losers`/`holders` carry raw actor/claimAt/
|
|
3144
|
+
// lastAt strings sourced straight from the multi-writer append-only liveness/events.jsonl
|
|
3145
|
+
// stream (see stripControlCharsForDisplay's header above — any process can append a
|
|
3146
|
+
// hostile line, bypassing the CLI write-side sanitizeSegment entirely). The pull-work
|
|
3147
|
+
// SKILL.md's Post-Claim Conflict Re-check instructs an LLM to read `winner.actor` /
|
|
3148
|
+
// `losers[].actor` straight out of THIS `--json` output into its own context — a second,
|
|
3149
|
+
// LLM-facing injection path alongside the mid-turn conflict hook (liveness-heartbeat.js's
|
|
3150
|
+
// computeConflict). Build a sanitized COPY for JSON emission only: control-char strip
|
|
3151
|
+
// (matching the text branch's existing stripControlCharsForDisplay treatment below) plus a
|
|
3152
|
+
// 64-char cap (matching sanitizeSegment's cap on the write side) on actor/claimAt/lastAt.
|
|
3153
|
+
// `reason`/`subjectId`/`ttlSeconds` are left as-is (enum/number, never free text). Never
|
|
3154
|
+
// mutates `result`/`holders`/`winner`/`losers` themselves — the text branch below still
|
|
3155
|
+
// needs the untouched originals for its own per-line rendering, and winner/loser identity
|
|
3156
|
+
// (already sanitizeSegment-clean for legitimate actors) is unaffected, so the loser-release
|
|
3157
|
+
// contract still works.
|
|
3158
|
+
const sanitizeVerdictHolderForJson = (h: AnyObj | null): AnyObj | null =>
|
|
3159
|
+
h
|
|
3160
|
+
? {
|
|
3161
|
+
...h,
|
|
3162
|
+
actor: stripControlCharsForDisplay(h.actor).slice(0, 64),
|
|
3163
|
+
claimAt: stripControlCharsForDisplay(h.claimAt).slice(0, 64),
|
|
3164
|
+
lastAt: stripControlCharsForDisplay(h.lastAt).slice(0, 64),
|
|
3165
|
+
}
|
|
3166
|
+
: h;
|
|
3167
|
+
const jsonResult = {
|
|
3168
|
+
subjectId,
|
|
3169
|
+
winner: sanitizeVerdictHolderForJson(winner),
|
|
3170
|
+
losers: losers.map((l) => sanitizeVerdictHolderForJson(l)),
|
|
3171
|
+
reason,
|
|
3172
|
+
holders: holders.map((h) => sanitizeVerdictHolderForJson(h)),
|
|
3173
|
+
};
|
|
3174
|
+
console.log(JSON.stringify(jsonResult));
|
|
3175
|
+
return 0;
|
|
3176
|
+
}
|
|
3177
|
+
for (const h of holders) {
|
|
3178
|
+
const tag = winner && h.actor === (winner as AnyObj).actor ? "WINNER" : (holders.length >= 2 ? "LOSER" : "HOLDER");
|
|
3179
|
+
console.log(`${stripControlCharsForDisplay(h.actor)}\tclaimAt=${stripControlCharsForDisplay(h.claimAt)}\t${tag}`);
|
|
3180
|
+
}
|
|
3181
|
+
if (winner) console.log(`WINNER: ${stripControlCharsForDisplay((winner as AnyObj).actor)}`);
|
|
3182
|
+
for (const l of losers) console.log(`LOSER: ${stripControlCharsForDisplay(l.actor)}`);
|
|
2476
3183
|
return 0;
|
|
2477
3184
|
}
|
|
2478
3185
|
|
|
2479
|
-
die("liveness action must be one of: claim | heartbeat | release | status");
|
|
3186
|
+
die("liveness action must be one of: claim | heartbeat | release | status | whoami | verdict");
|
|
2480
3187
|
return 1;
|
|
2481
3188
|
}
|
|
2482
3189
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -2652,9 +3359,24 @@ Available claim ids:
|
|
|
2652
3359
|
async function main(): Promise<number> {
|
|
2653
3360
|
const p = parseArgs(process.argv.slice(2));
|
|
2654
3361
|
if (!p.command) die("workflow-sidecar command is required");
|
|
2655
|
-
|
|
3362
|
+
// F1 (#166 fix iteration 1): `liveness whoami` is a read-only, lock-free, write-free advisory
|
|
3363
|
+
// surface (see the `action === "whoami"` branch inside `liveness()` above) — it must never
|
|
3364
|
+
// acquire the workflow-sidecar lock, regardless of whether the artifact root already exists on
|
|
3365
|
+
// disk. Without this action-level bypass, `liveness` was blanket-included in the lock-routing
|
|
3366
|
+
// branch below, so `whoami` against an EXISTING artifact root would still resolve a real
|
|
3367
|
+
// lockRoot and go through `withLock`'s mkdir/lockdir path — the opposite of "genuinely
|
|
3368
|
+
// lock-free". This bypass mirrors `resolve-slug`'s existing empty-lockRoot special case
|
|
3369
|
+
// immediately below and is scoped to the `whoami` action only: `liveness status` (a read path)
|
|
3370
|
+
// keeps its pre-existing lock behavior unchanged (out of scope for this fix — see fix-plan
|
|
3371
|
+
// iteration 1, F1), and `liveness claim` / `heartbeat` / `release` (write paths) are untouched.
|
|
3372
|
+
const isLivenessWhoami = p.command === "liveness" && p.positional[0] === "whoami";
|
|
3373
|
+
// #320 AC1: `liveness verdict` is read-only and lock-free, exactly like `whoami` above — it
|
|
3374
|
+
// must never acquire the workflow-sidecar lock. Same bypass shape, scoped to the `verdict`
|
|
3375
|
+
// action only; `liveness status` (also a read path) keeps its pre-existing lock behavior.
|
|
3376
|
+
const isLivenessVerdict = p.command === "liveness" && p.positional[0] === "verdict";
|
|
3377
|
+
const lockRoot = (["ensure-session", "current", "dogfood-pass", "liveness"].includes(p.command) && !isLivenessWhoami && !isLivenessVerdict)
|
|
2656
3378
|
? (opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : (p.command === "ensure-session" ? flowAgentsArtifactRoot() : defaultArtifactRootForRead()))
|
|
2657
|
-
: p.command === "record-agent-event" ? explicitArtifactRoot(p) : p.command === "claim" ? (p.positional[1] ? path.resolve(p.positional[1]) : "") : p.positional[0] ? artifactDirFrom(p.positional[0]) : "";
|
|
3379
|
+
: p.command === "record-agent-event" ? explicitArtifactRoot(p) : p.command === "claim" ? (p.positional[1] ? path.resolve(p.positional[1]) : "") : p.command === "resolve-slug" ? "" : (isLivenessWhoami || isLivenessVerdict) ? "" : p.positional[0] ? artifactDirFrom(p.positional[0]) : "";
|
|
2658
3380
|
return withLock(lockRoot, ["ensure-session", "record-agent-event", "dogfood-pass"].includes(p.command), p.command, () => {
|
|
2659
3381
|
switch (p.command) {
|
|
2660
3382
|
case "ensure-session": return ensureSession(p);
|
|
@@ -2663,6 +3385,7 @@ async function main(): Promise<number> {
|
|
|
2663
3385
|
case "init-plan": return initPlan(p);
|
|
2664
3386
|
case "record-evidence": return recordEvidence(p);
|
|
2665
3387
|
case "record-gate-claim": return recordGateClaim(p);
|
|
3388
|
+
case "promote": return promote(p);
|
|
2666
3389
|
case "advance-state": return advanceState(p);
|
|
2667
3390
|
case "record-critique": return recordCritique(p);
|
|
2668
3391
|
case "import-critique": return importCritique(p);
|
|
@@ -2674,6 +3397,7 @@ async function main(): Promise<number> {
|
|
|
2674
3397
|
case "trust-mcp": return trustMcp(p);
|
|
2675
3398
|
case "liveness": return liveness(p);
|
|
2676
3399
|
case "claim": return claimLookup(p);
|
|
3400
|
+
case "resolve-slug": return resolveSlugCmd(p);
|
|
2677
3401
|
case "seal-checkpoint": return sealCheckpoint(p);
|
|
2678
3402
|
case "publish-delivery": return publishDeliveryCmd(p);
|
|
2679
3403
|
default: die(`unknown command: ${p.command}`);
|