@kontourai/flow-agents 2.2.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 +46 -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
|
@@ -10,7 +10,7 @@ import { resolveActiveFlowStep, resolveFlowFilePath, resolvePhaseMap, resolveRou
|
|
|
10
10
|
import { defaultArtifactRootForRead, flowAgentsArtifactRoot } from "../lib/local-artifact-root.js";
|
|
11
11
|
export const statuses = new Set(["new", "planning", "planned", "in_progress", "blocked", "verifying", "verified", "needs_decision", "not_verified", "failed", "delivered", "accepted", "archived"]);
|
|
12
12
|
export const phases = ["idea", "backlog", "pickup", "planning", "execution", "verification", "goal_fit", "evidence", "release", "learning", "done"];
|
|
13
|
-
export const checkKinds = new Set(["build", "types", "lint", "test", "security", "diff", "browser", "runtime", "policy", "external"]);
|
|
13
|
+
export const checkKinds = new Set(["build", "types", "lint", "test", "command", "security", "diff", "browser", "runtime", "policy", "external"]);
|
|
14
14
|
export const checkStatuses = new Set(["pass", "fail", "not_verified", "skip"]);
|
|
15
15
|
export const verdicts = new Set(["pass", "partial", "fail", "not_verified"]);
|
|
16
16
|
function now() { return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); }
|
|
@@ -48,6 +48,162 @@ function workItemSlug(ref) {
|
|
|
48
48
|
const [owner, repo] = parts;
|
|
49
49
|
return slugify(`${owner}-${repo}-${id}`, "work-item");
|
|
50
50
|
}
|
|
51
|
+
/** Pure, lock-free, side-effect-free CLI wrapper around workItemSlug() — the single source of
|
|
52
|
+
* truth for the deterministic subjectId/session-directory-name slug. Named resolveSlugCmd (not
|
|
53
|
+
* resolveSlug) to avoid colliding with any future export named resolveSlug. */
|
|
54
|
+
function resolveSlugCmd(p) {
|
|
55
|
+
const ref = p.positional[0] || die("resolve-slug requires an owner/repo#id ref");
|
|
56
|
+
console.log(workItemSlug(ref));
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
/** First 6 hex chars of sha256(raw) — a short deterministic disambiguator (#289 F4). Two
|
|
60
|
+
* different raw inputs that both collapse to a segment's "unknown" fallback (e.g. two distinct
|
|
61
|
+
* all-garbage --task-slug values, or two distinct raw actor strings that both resolve
|
|
62
|
+
* "unresolved") would otherwise derive the identical fallback branch — this makes them diverge
|
|
63
|
+
* while staying fully deterministic for a given raw input (same raw -> same hash every call),
|
|
64
|
+
* which is required to preserve resolveSessionBranch's no-rederive/resume-continuity semantics
|
|
65
|
+
* (a resumed session's already-recorded branch is never recomputed regardless of this helper). */
|
|
66
|
+
function unknownDisambiguator(raw) {
|
|
67
|
+
return createHash("sha256").update(raw).digest("hex").slice(0, 6);
|
|
68
|
+
}
|
|
69
|
+
/** Encode a value (actor string or --task-slug, neither guaranteed git-ref-safe) into a
|
|
70
|
+
* single git-check-ref-format(1)-safe branch path component (#289). Reuses
|
|
71
|
+
* actor-identity.js's sanitizeSegment (consume, don't fork) as the base, then closes the
|
|
72
|
+
* git-specific gaps sanitizeSegment's [A-Za-z0-9_.-] charset does not: the `:` delimiter
|
|
73
|
+
* serializeActor() uses (disallowed in a git ref), consecutive dots, a leading dot, a trailing
|
|
74
|
+
* dot (fix-plan iteration 1 F1 — git-check-ref-format forbids a component ending in `.`, and
|
|
75
|
+
* the prior pass only handled a run of 2+ trailing dots via the `..`-collapse step, missing the
|
|
76
|
+
* single-trailing-dot case e.g. a `--task-slug` of `my-fix.`), and a trailing `.lock`. The
|
|
77
|
+
* trailing-dot strip runs BEFORE the `.lock` rewrite so a `.lock` suffix hidden behind trailing
|
|
78
|
+
* dots (e.g. `foo.lock.`) is exposed and still rewritten rather than left dangling. Whenever the
|
|
79
|
+
* FINAL sanitized segment equals the fallback token `"unknown"` — whether because the charset
|
|
80
|
+
* filter collapsed the raw input to nothing, OR because the leading-/trailing-dot stripping
|
|
81
|
+
* above collapsed a near-miss input like `"unknown."` or `".unknown"` down to the literal
|
|
82
|
+
* `"unknown"`, OR because the raw input genuinely WAS the literal string `"unknown"` — a
|
|
83
|
+
* deterministic disambiguator (see unknownDisambiguator) is ALWAYS appended (fix-plan iteration 2
|
|
84
|
+
* F4': no literal-input carve-out). Distinct raw inputs can never collide on the bare literal
|
|
85
|
+
* `"unknown"` segment; the same raw input always re-derives the same disambiguated segment. See
|
|
86
|
+
* Design Decision 2 in the plan. */
|
|
87
|
+
function sanitizeBranchSegment(value, helper) {
|
|
88
|
+
const raw = String(value ?? "");
|
|
89
|
+
const colonReplaced = raw.replace(/:/g, "-");
|
|
90
|
+
let seg = helper.sanitizeSegment(colonReplaced);
|
|
91
|
+
seg = seg.replace(/\.{2,}/g, "-").replace(/^\.+/, "").replace(/\.+$/, "").replace(/\.lock$/i, "-lock");
|
|
92
|
+
if (!seg || seg === "unknown")
|
|
93
|
+
return `unknown-${unknownDisambiguator(raw)}`;
|
|
94
|
+
return seg;
|
|
95
|
+
}
|
|
96
|
+
/** Validate an explicit `--branch` value strictly rather than trusting it verbatim (fix-plan
|
|
97
|
+
* iteration 1 F2, tightened by iteration 2 F2'). Explicit `--branch` bypasses
|
|
98
|
+
* `sanitizeBranchSegment` entirely by design (it is caller intent, may legitimately contain `/`
|
|
99
|
+
* to nest under `agent/...`, etc.), so unlike the derived path it must be rejected outright — not
|
|
100
|
+
* silently sanitized — when it cannot be a valid git ref. Whole-string checks (iteration 1):
|
|
101
|
+
* any control character/newline, a space, a leading or trailing `/`, a `//` sequence, a leading
|
|
102
|
+
* `.`, any `..` sequence, a trailing `.`, a trailing `.lock`, or any character outside
|
|
103
|
+
* `[A-Za-z0-9_./-]`. Per-component checks (iteration 2 F2'): the whole-string checks above only
|
|
104
|
+
* examine the START/END of the full value, so a charset-legal value can still smuggle an invalid
|
|
105
|
+
* `/`-delimited component past them — e.g. `"-lead"` (a leading `-` breaks the whole git ref,
|
|
106
|
+
* applied uniformly to every component here — stricter than git strictly requires, which is fine
|
|
107
|
+
* for a caller-facing override flag), `"a/.b"` (a non-first component starting with `.`),
|
|
108
|
+
* `"foo.lock/bar"` (a non-last component ending in `.lock`), or `"a/./b"` (a component that is
|
|
109
|
+
* exactly `.`). Belt-and-braces (F2'): once the lexical checks above all pass, `git
|
|
110
|
+
* check-ref-format --branch <value>` (the real git binary) is run as the final authority — it can
|
|
111
|
+
* only ever REJECT a value the lexical checks let through (never re-legalize one they rejected),
|
|
112
|
+
* so it closes any residual gap in this hand-rolled lexical pass. When git cannot be spawned at
|
|
113
|
+
* all (e.g. not installed — `ENOENT`) or does not complete (e.g. the 5s timeout fires), the git
|
|
114
|
+
* check is skipped silently and the lexical checks above remain the sole authority. Dies with
|
|
115
|
+
* remediation, or with git's own rejection message; never mutates any artifact before this check
|
|
116
|
+
* runs (resolveSessionBranch calls this before any file write). */
|
|
117
|
+
function validateExplicitBranch(value) {
|
|
118
|
+
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)}).`;
|
|
119
|
+
const fail = (reason) => die(`ensure-session --branch value is not a valid git ref: ${reason}. ${remediation}`);
|
|
120
|
+
if (/[\x00-\x1F\x7F]/.test(value))
|
|
121
|
+
fail("contains a control character or newline");
|
|
122
|
+
if (/ /.test(value))
|
|
123
|
+
fail("contains a space");
|
|
124
|
+
if (value.startsWith("/") || value.endsWith("/"))
|
|
125
|
+
fail('must not start or end with "/"');
|
|
126
|
+
if (value.includes("//"))
|
|
127
|
+
fail('must not contain "//"');
|
|
128
|
+
if (value.startsWith("."))
|
|
129
|
+
fail('must not start with "."');
|
|
130
|
+
if (value.includes(".."))
|
|
131
|
+
fail('must not contain a ".." sequence');
|
|
132
|
+
if (value.endsWith("."))
|
|
133
|
+
fail('must not end with "."');
|
|
134
|
+
if (/\.lock$/i.test(value))
|
|
135
|
+
fail('must not end with ".lock"');
|
|
136
|
+
if (/[^A-Za-z0-9_./-]/.test(value))
|
|
137
|
+
fail("contains a character outside [A-Za-z0-9_./-]");
|
|
138
|
+
// F2' per-component checks: split on "/" and validate each path component individually. The
|
|
139
|
+
// whole-string checks above cannot catch a hostile component that is not at the very start or
|
|
140
|
+
// end of the full value.
|
|
141
|
+
for (const component of value.split("/")) {
|
|
142
|
+
if (!component)
|
|
143
|
+
fail('must not contain an empty path component ("//" or a leading/trailing "/")');
|
|
144
|
+
if (component === ".")
|
|
145
|
+
fail('must not contain a path component that is exactly "."');
|
|
146
|
+
if (component.startsWith("."))
|
|
147
|
+
fail('must not contain a path component starting with "."');
|
|
148
|
+
if (component.startsWith("-"))
|
|
149
|
+
fail('must not contain a path component starting with "-"');
|
|
150
|
+
if (component.endsWith("."))
|
|
151
|
+
fail('must not contain a path component ending with "."');
|
|
152
|
+
if (/\.lock$/i.test(component))
|
|
153
|
+
fail('must not contain a path component ending with ".lock"');
|
|
154
|
+
}
|
|
155
|
+
// F2' belt-and-braces: the real `git check-ref-format --branch` binary is the final authority
|
|
156
|
+
// when git is available. This can only REJECT a value that already passed every lexical check
|
|
157
|
+
// above — it never re-legalizes a value the lexical checks rejected (those `fail()` calls
|
|
158
|
+
// above already threw). argv-array form (no shell) with a 5s timeout so a hung or missing git
|
|
159
|
+
// binary cannot hang or crash session creation.
|
|
160
|
+
let result;
|
|
161
|
+
try {
|
|
162
|
+
execFileSync("git", ["check-ref-format", "--branch", value], { stdio: ["ignore", "ignore", "pipe"], timeout: 5000 });
|
|
163
|
+
return; // exit 0 — git accepts the value; nothing further to check.
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
const spawnError = err;
|
|
167
|
+
if (spawnError && spawnError.code === "ENOENT")
|
|
168
|
+
return; // git not installed — lexical checks stand alone.
|
|
169
|
+
if (spawnError && typeof spawnError.status === "number") {
|
|
170
|
+
result = { status: spawnError.status, stderr: spawnError.stderr ?? "" };
|
|
171
|
+
}
|
|
172
|
+
else
|
|
173
|
+
return; // Any other spawn failure (e.g. timeout) — skip silently; lexical checks already passed.
|
|
174
|
+
}
|
|
175
|
+
if (result && result.status !== 0) {
|
|
176
|
+
const gitMessage = String(result.stderr ?? "").trim();
|
|
177
|
+
fail(gitMessage ? `git check-ref-format rejected the value: ${gitMessage}` : "git check-ref-format rejected the value");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** Resolve the branch to seed a brand-new session with. Only called from ensureSession's
|
|
181
|
+
* `if (!md)` fresh-creation branch — an existing session's already-recorded branch is never
|
|
182
|
+
* recomputed (ADR 0021 §5 takeover continuity; see Design Decision 3). Precedence: explicit
|
|
183
|
+
* --branch (strictly validated, then honored verbatim — see F2) > derived agent/<actor>/<slug>.
|
|
184
|
+
* Never hard-fails session creation on actor-resolution ambiguity (Design Decision 4) — only a
|
|
185
|
+
* garbage explicit --actor, or a garbage explicit --branch, dies. */
|
|
186
|
+
function resolveSessionBranch(p, slug) {
|
|
187
|
+
// Deliberately NOT trimmed before validation (unlike the pre-F2 baseline): a leading/trailing
|
|
188
|
+
// space must be REJECTED, not silently trimmed away — silent trimming would let a
|
|
189
|
+
// caller-supplied value differ from what gets recorded without any diagnostic (F2).
|
|
190
|
+
const explicitBranch = opt(p, "branch");
|
|
191
|
+
if (explicitBranch) {
|
|
192
|
+
validateExplicitBranch(explicitBranch);
|
|
193
|
+
return explicitBranch;
|
|
194
|
+
}
|
|
195
|
+
const helper = loadActorIdentityHelper();
|
|
196
|
+
const explicitActorRaw = opt(p, "actor", "").trim();
|
|
197
|
+
if (explicitActorRaw && !/[A-Za-z0-9_.-]/.test(explicitActorRaw)) {
|
|
198
|
+
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.");
|
|
199
|
+
}
|
|
200
|
+
const actor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : helper.resolveActor(process.env).actor;
|
|
201
|
+
const unresolved = helper.isUnresolvedActor(actor);
|
|
202
|
+
const safeActor = unresolved ? `unknown-actor-${unknownDisambiguator(actor)}` : sanitizeBranchSegment(actor, helper);
|
|
203
|
+
if (unresolved)
|
|
204
|
+
process.stderr.write("[ensure-session] actor unresolved; branch uses \"unknown-actor\" segment (set --actor or FLOW_AGENTS_ACTOR for a stable branch name)\n");
|
|
205
|
+
return `agent/${safeActor}/${sanitizeBranchSegment(slug, helper)}`;
|
|
206
|
+
}
|
|
51
207
|
/**
|
|
52
208
|
* Validate a Hachure trust.bundle using @kontourai/surface's canonical validator
|
|
53
209
|
* (surface is the authoritative owner of trust-bundle schema validation per ADR 0010 / ADR 0015).
|
|
@@ -180,6 +336,47 @@ function criterionStatusToEventStatus(status) {
|
|
|
180
336
|
return "assumed";
|
|
181
337
|
return null; // pending / not_verified → no event → Surface returns "unknown"
|
|
182
338
|
}
|
|
339
|
+
/**
|
|
340
|
+
* WS8 (ADR 0020): Derive Surface evidence classification (evidenceType + method)
|
|
341
|
+
* from a workflow check's kind, replacing the previous hardcoded `test_output`.
|
|
342
|
+
* Only command-backed re-runnable checks are `test_output` (CI-reconcilable);
|
|
343
|
+
* everything else is inherently session-local (manual attestation, provider/
|
|
344
|
+
* document citation, crawl observation, policy rule, source excerpt). The
|
|
345
|
+
* `reconcilable` flag is informational — the CI reconciler classifies purely by
|
|
346
|
+
* the emitted `evidenceType`. Every value is a member of Surface's own
|
|
347
|
+
* evidence.schema.json `evidenceType`/`method` enums (consume-never-fork).
|
|
348
|
+
*/
|
|
349
|
+
function classifyEvidence(kind, hasCommand) {
|
|
350
|
+
const k = String(kind ?? "external");
|
|
351
|
+
switch (k) {
|
|
352
|
+
case "build":
|
|
353
|
+
case "types":
|
|
354
|
+
case "lint":
|
|
355
|
+
case "test":
|
|
356
|
+
case "command":
|
|
357
|
+
return { evidenceType: "test_output", method: "validation", reconcilable: true };
|
|
358
|
+
case "security":
|
|
359
|
+
return hasCommand
|
|
360
|
+
? { evidenceType: "test_output", method: "validation", reconcilable: true }
|
|
361
|
+
: { evidenceType: "attestation", method: "corroboration", reconcilable: false };
|
|
362
|
+
case "diff":
|
|
363
|
+
return { evidenceType: "source_excerpt", method: "extraction", reconcilable: false };
|
|
364
|
+
case "browser":
|
|
365
|
+
return hasCommand
|
|
366
|
+
? { evidenceType: "test_output", method: "validation", reconcilable: true }
|
|
367
|
+
: { evidenceType: "crawl_observation", method: "observation", reconcilable: false };
|
|
368
|
+
case "runtime":
|
|
369
|
+
return hasCommand
|
|
370
|
+
? { evidenceType: "test_output", method: "validation", reconcilable: true }
|
|
371
|
+
: { evidenceType: "attestation", method: "attestation", reconcilable: false };
|
|
372
|
+
case "policy":
|
|
373
|
+
return { evidenceType: "policy_rule", method: "auditability", reconcilable: false };
|
|
374
|
+
case "external":
|
|
375
|
+
return { evidenceType: "attestation", method: "corroboration", reconcilable: false };
|
|
376
|
+
default:
|
|
377
|
+
return { evidenceType: "test_output", method: "validation", reconcilable: true };
|
|
378
|
+
}
|
|
379
|
+
}
|
|
183
380
|
/** Map a critique verdict to the Surface VerificationEvent status. */
|
|
184
381
|
function critiqueToEventStatus(verdict, findings) {
|
|
185
382
|
if (verdict === "fail")
|
|
@@ -221,14 +418,29 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
|
|
|
221
418
|
const evidenceItems = [];
|
|
222
419
|
const events = [];
|
|
223
420
|
const ts = timestamp || new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
224
|
-
// One VerificationPolicy per distinct claimType, so status is
|
|
225
|
-
// (not derived against an empty policy set). Maximal-fidelity per ADR 0010.
|
|
421
|
+
// One VerificationPolicy per distinct (claimType, requiredEvidence) pair, so status is
|
|
422
|
+
// policy-governed (not derived against an empty policy set). Maximal-fidelity per ADR 0010.
|
|
423
|
+
//
|
|
424
|
+
// WS8 (AC1, iteration 2): the cache is keyed by claimType + the normalized requiredEvidence
|
|
425
|
+
// set, NOT by claimType alone. Two checks of the same legacy claimType that differ in
|
|
426
|
+
// command-presence (e.g. a command-backed browser check → requiredEvidence [test_output]
|
|
427
|
+
// vs a no-command browser check → [crawl_observation]) previously COLLIDED: the first-seen
|
|
428
|
+
// requiredEvidence won and corrupted the second claim's derived status (verified → proposed)
|
|
429
|
+
// in a record-order-dependent way. Keying by (claimType, requiredEvidence) makes policy
|
|
430
|
+
// construction order-independent — each distinct evidence signature gets its own policy, and
|
|
431
|
+
// each claim references its own via verificationPolicyId (Surface's resolvePolicyForClaim
|
|
432
|
+
// honors verificationPolicyId first, so same-claimType policies never cross-resolve).
|
|
433
|
+
// Merging is NOT used because Surface's requiredEvidence is all-of (`.every`), so a union
|
|
434
|
+
// would over-constrain both claims.
|
|
226
435
|
const policies = new Map();
|
|
227
436
|
const ensurePolicy = (claimType, impactLevel, requiredEvidence) => {
|
|
228
|
-
|
|
437
|
+
const reqSorted = [...new Set(requiredEvidence)].sort();
|
|
438
|
+
const key = `${claimType}::${reqSorted.join(",")}`;
|
|
439
|
+
let p = policies.get(key);
|
|
229
440
|
if (!p) {
|
|
230
|
-
|
|
231
|
-
|
|
441
|
+
const id = reqSorted.length ? `policy:${claimType}:${reqSorted.join("+")}` : `policy:${claimType}`;
|
|
442
|
+
p = { id, claimType, requiredEvidence: reqSorted, acceptanceCriteria: [`A verified verification event must support a ${claimType} claim.`], reviewAuthority: "system", validityRule: { kind: "manual" }, stalenessTriggers: [], conflictRules: [], impactLevel };
|
|
443
|
+
policies.set(key, p);
|
|
232
444
|
}
|
|
233
445
|
return p;
|
|
234
446
|
};
|
|
@@ -349,37 +561,60 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
|
|
|
349
561
|
const claimId = generateClaimId(subjectId, "flow-agents.workflow", fieldOrBehavior);
|
|
350
562
|
const evId = `ev:${claimId}`;
|
|
351
563
|
const legacyClaimType = `workflow.check.${check.kind ?? "external"}`;
|
|
352
|
-
const policy = ensurePolicy(legacyClaimType, "high", ["test_output"]);
|
|
353
564
|
const cmd = typeof check.command === "string" ? check.command.replace(/\s+/g, " ").trim() : "";
|
|
354
565
|
const captured = cmd ? captureByCommand.get(cmd) : undefined;
|
|
566
|
+
// WS8 (ADR 0020): classify evidence from check.kind instead of hardcoding test_output.
|
|
567
|
+
// A waived accepted-gap (check._waiver, set by record-evidence/record-gate-claim
|
|
568
|
+
// --accepted-gap-reason/--waived-by) is by definition session-local: it is an
|
|
569
|
+
// attested accepted gap, never a CI-re-runnable command, so it is classified
|
|
570
|
+
// attestation/attestation and its claim status is forced to `assumed` (reusing the
|
|
571
|
+
// existing accepted_gap -> assumed mapping — no new status value).
|
|
572
|
+
const waiver = (check._waiver && typeof check._waiver === "object") ? check._waiver : null;
|
|
573
|
+
const evClass = waiver
|
|
574
|
+
? { evidenceType: "attestation", method: "attestation", reconcilable: false }
|
|
575
|
+
: classifyEvidence(check.kind, cmd.length > 0);
|
|
576
|
+
const policy = ensurePolicy(legacyClaimType, "high", [evClass.evidenceType]);
|
|
355
577
|
const effectiveStatus = captured ? captured.observedResult : String(check.status ?? "");
|
|
356
|
-
const evStatus = checkStatusToEventStatus(effectiveStatus);
|
|
578
|
+
const evStatus = waiver ? "assumed" : checkStatusToEventStatus(effectiveStatus);
|
|
579
|
+
// Promotion claim marker (issue #312): a `promote` check carries a session-local
|
|
580
|
+
// _promotion object that must survive onto claim.metadata.promotion so the archive gate
|
|
581
|
+
// (workflow-artifact-cleanup-audit) and validators can detect the promotion claim without a
|
|
582
|
+
// new manifest entry. It rides alongside any waiver in a single merged metadata object.
|
|
583
|
+
const promotionMeta = (check._promotion && typeof check._promotion === "object") ? check._promotion : null;
|
|
584
|
+
const claimMetadata = (waiver || promotionMeta) ? { ...(waiver ? { waiver } : {}), ...(promotionMeta ? { promotion: promotionMeta } : {}) } : null;
|
|
357
585
|
const claimEvents = [];
|
|
358
586
|
if (evStatus) {
|
|
359
587
|
const evt = { id: `evt:${claimId}`, claimId, status: evStatus, actor: "flow-agents/workflow-sidecar", method: "validation", evidenceIds: [evId], createdAt: ts, verifiedAt: ts };
|
|
360
588
|
events.push(evt);
|
|
361
589
|
claimEvents.push(evt);
|
|
362
590
|
}
|
|
363
|
-
const evItem = { id: evId, claimId, evidenceType:
|
|
591
|
+
const evItem = { 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" };
|
|
364
592
|
if (captured) {
|
|
365
593
|
evItem.sourceRef = `${slug}/command-log.jsonl`;
|
|
366
594
|
evItem.collectedBy = "flow-agents/evidence-capture";
|
|
367
595
|
evItem.execution = { runner: "bash", label: cmd, isError: captured.observedResult === "fail", ...(captured.exitCode != null ? { exitCode: captured.exitCode } : {}) };
|
|
368
596
|
}
|
|
597
|
+
else if (cmd && !waiver) {
|
|
598
|
+
// WS8 (ADR 0020): always stamp execution.label on command-backed checks so the CI
|
|
599
|
+
// reconciler has a stable key to match against the manifest, even when the local
|
|
600
|
+
// command-log capture did not happen to run this command. isError is derived from
|
|
601
|
+
// the check's own reported status (no captured exit code available in this path).
|
|
602
|
+
evItem.execution = { runner: "bash", label: cmd, isError: effectiveStatus !== "pass" };
|
|
603
|
+
}
|
|
369
604
|
evidenceItems.push(evItem);
|
|
370
605
|
// P-d: declared-only when active flow/step present (shadow retired); no-flow path unchanged.
|
|
371
606
|
// When record-gate-claim sets _gate_claim_expectation_id, pass it for exact lookup (ADR 0016 P-d Increment 2).
|
|
372
607
|
const declared = matchExpectsEntry("check", check.kind, typeof check._gate_claim_expectation_id === "string" ? check._gate_claim_expectation_id : undefined);
|
|
373
608
|
if (declared) {
|
|
374
609
|
// Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
|
|
375
|
-
const declaredPolicy = ensurePolicy(declared.claimType, "high", [
|
|
376
|
-
const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId,
|
|
610
|
+
const declaredPolicy = ensurePolicy(declared.claimType, "high", [evClass.evidenceType]);
|
|
611
|
+
const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: effectiveStatus, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, ...(claimMetadata ? { metadata: claimMetadata } : {}) };
|
|
377
612
|
const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [evItem], events: claimEvents, policies: [declaredPolicy] });
|
|
378
613
|
claims.push({ ...declaredClaimObj, status: declaredStatus });
|
|
379
614
|
}
|
|
380
615
|
else {
|
|
381
616
|
// No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
|
|
382
|
-
const claimObj = { id: claimId, subjectType: "workflow-check", subjectId,
|
|
617
|
+
const claimObj = { 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 } : {}) };
|
|
383
618
|
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [evItem], events: claimEvents, policies: [policy] });
|
|
384
619
|
claims.push({ ...claimObj, status: derivedStatus });
|
|
385
620
|
}
|
|
@@ -405,13 +640,13 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
|
|
|
405
640
|
if (declared) {
|
|
406
641
|
// Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
|
|
407
642
|
const declaredPolicy = ensurePolicy(declared.claimType, "high", []);
|
|
408
|
-
const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId,
|
|
643
|
+
const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id };
|
|
409
644
|
const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [], events: claimEvents, policies: [declaredPolicy] });
|
|
410
645
|
claims.push({ ...declaredClaimObj, status: declaredStatus });
|
|
411
646
|
}
|
|
412
647
|
else {
|
|
413
648
|
// No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
|
|
414
|
-
const claimObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId,
|
|
649
|
+
const claimObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id };
|
|
415
650
|
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [], events: claimEvents, policies: [policy] });
|
|
416
651
|
claims.push({ ...claimObj, status: derivedStatus });
|
|
417
652
|
}
|
|
@@ -437,19 +672,19 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
|
|
|
437
672
|
if (declared) {
|
|
438
673
|
// Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
|
|
439
674
|
const declaredPolicy = ensurePolicy(declared.claimType, "medium", []);
|
|
440
|
-
const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId,
|
|
675
|
+
const declaredClaimObj = { 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 };
|
|
441
676
|
const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [], events: claimEvents, policies: [declaredPolicy] });
|
|
442
677
|
claims.push({ ...declaredClaimObj, status: declaredStatus });
|
|
443
678
|
}
|
|
444
679
|
else {
|
|
445
680
|
// No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
|
|
446
|
-
const claimObj = { id: claimId, subjectType: "workflow-critique", subjectId,
|
|
681
|
+
const claimObj = { id: claimId, subjectType: "workflow-critique", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: policy.id };
|
|
447
682
|
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [], events: claimEvents, policies: [policy] });
|
|
448
683
|
claims.push({ ...claimObj, status: derivedStatus });
|
|
449
684
|
}
|
|
450
685
|
}
|
|
451
686
|
return {
|
|
452
|
-
schemaVersion:
|
|
687
|
+
schemaVersion: 5,
|
|
453
688
|
source: `flow-agents/workflow-sidecar;statusFunctionVersion=${statusFunctionVersion}`,
|
|
454
689
|
claims,
|
|
455
690
|
evidence: evidenceItems,
|
|
@@ -485,7 +720,7 @@ export async function writeTrustBundle(dir, slug, timestamp, checks, criteria, c
|
|
|
485
720
|
} }).filter((x) => x !== null);
|
|
486
721
|
}
|
|
487
722
|
catch { /* no capture log — fine */ }
|
|
488
|
-
// ADR 0016 Abstraction A (P-d): pass the
|
|
723
|
+
// ADR 0016 Abstraction A (P-d): pass the runtime artifact root ONLY when current.json
|
|
489
724
|
// points to this session (scoped active-flow guard). If current.json.artifact_dir
|
|
490
725
|
// resolves to a different session, pass null — no active-flow claim mapping for this bundle.
|
|
491
726
|
const _flowAgentsDir = path.dirname(dir);
|
|
@@ -726,6 +961,19 @@ function definitionAcceptanceLines(markdown) {
|
|
|
726
961
|
}
|
|
727
962
|
return out;
|
|
728
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, name) {
|
|
974
|
+
const re = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\s*(.+)$`, "m");
|
|
975
|
+
return re.exec(markdown)?.[1]?.trim() ?? "";
|
|
976
|
+
}
|
|
729
977
|
function parseCriterion(line, index) {
|
|
730
978
|
let text = line.replace(/^-\s+\[[ xX]\]\s*/, "").trim();
|
|
731
979
|
const m = /\s+-\s+Evidence:\s*(.+)$/i.exec(text);
|
|
@@ -770,14 +1018,20 @@ function validateAgentId(agent) {
|
|
|
770
1018
|
}
|
|
771
1019
|
/**
|
|
772
1020
|
* Find the repository root by walking upward from a starting directory to locate
|
|
773
|
-
* the nearest ancestor containing a kits/ subdirectory
|
|
774
|
-
*
|
|
775
|
-
*
|
|
1021
|
+
* the nearest ancestor containing a kits/ subdirectory, without falling back to
|
|
1022
|
+
* process.cwd(). Returns null when no kits/ ancestor is found within the walk
|
|
1023
|
+
* bound (e.g. a scratch/temp session directory with no repo ancestor at all).
|
|
776
1024
|
*
|
|
777
|
-
*
|
|
778
|
-
*
|
|
1025
|
+
* WS5 iteration-2 part 2: extracted so publishDelivery's repo-root resolution can
|
|
1026
|
+
* be fail-closed (see findRepoRootFromDir below and publishDelivery). A scratch
|
|
1027
|
+
* test session dir (mktemp -d, no kits/ ancestor) must never resolve to whatever
|
|
1028
|
+
* repo happens to be the current process's cwd — that previously let a throwaway
|
|
1029
|
+
* eval-local trust.bundle silently clobber this real repo's delivery/trust.bundle
|
|
1030
|
+
* when the eval was run from a checkout of this repo (see
|
|
1031
|
+
* evals/integration/test_checkpoint_signing.sh TEST 2 and the WS5 session findings
|
|
1032
|
+
* at .kontourai/flow-agents/ws5-governance-kit-slice1).
|
|
779
1033
|
*/
|
|
780
|
-
function
|
|
1034
|
+
function findRepoRootFromDirStrict(startDir) {
|
|
781
1035
|
let dir = startDir;
|
|
782
1036
|
for (let i = 0; i < 16; i++) {
|
|
783
1037
|
if (fs.existsSync(path.join(dir, "kits")))
|
|
@@ -787,7 +1041,23 @@ function findRepoRootFromDir(startDir) {
|
|
|
787
1041
|
break;
|
|
788
1042
|
dir = parent;
|
|
789
1043
|
}
|
|
790
|
-
return
|
|
1044
|
+
return null;
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Find the repository root by walking upward from a starting directory to locate
|
|
1048
|
+
* the nearest ancestor containing a kits/ subdirectory. Mirrors flow-resolver.ts
|
|
1049
|
+
* findRepoRoot, but callable from workflow-sidecar.ts without re-importing the
|
|
1050
|
+
* internal helper. Falls back to process.cwd() when no kits/ ancestor is found —
|
|
1051
|
+
* appropriate for phase-map/first-step resolution (ADR 0016 Abstraction A, P-d),
|
|
1052
|
+
* where the caller is always invoked from within a real repo checkout.
|
|
1053
|
+
*
|
|
1054
|
+
* Do NOT use this cwd-falling-back variant for publishDelivery's repo-root
|
|
1055
|
+
* resolution — use findRepoRootFromDirStrict there instead, so a scratch/test
|
|
1056
|
+
* session dir with no repo ancestor fails closed (skips publish) rather than
|
|
1057
|
+
* silently trusting process.cwd(), which could be an unrelated real repo.
|
|
1058
|
+
*/
|
|
1059
|
+
function findRepoRootFromDir(startDir) {
|
|
1060
|
+
return findRepoRootFromDirStrict(startDir) ?? process.cwd();
|
|
791
1061
|
}
|
|
792
1062
|
/**
|
|
793
1063
|
* Resolve the first step id from a FlowDefinition's steps[] list.
|
|
@@ -824,7 +1094,11 @@ function resolveFirstStep(flowId, repoRoot) {
|
|
|
824
1094
|
return null;
|
|
825
1095
|
}
|
|
826
1096
|
}
|
|
827
|
-
function writeCurrent(root, dir, timestamp, owner, source, flowId, stepId) {
|
|
1097
|
+
function writeCurrent(root, dir, timestamp, owner, source, flowId, stepId, adHocReason) {
|
|
1098
|
+
// #289: mirror the active session's already-recorded branch (state.json.branch) into
|
|
1099
|
+
// current.json so consumers of current.json (which has no schema of its own — not one of the
|
|
1100
|
+
// 9 schemas under schemas/) see the routing branch without re-reading state.json separately.
|
|
1101
|
+
const branch = loadJson(path.join(dir, "state.json")).branch;
|
|
828
1102
|
writeJson(path.join(root, "current.json"), {
|
|
829
1103
|
schema_version: "1.0",
|
|
830
1104
|
active_slug: path.basename(dir),
|
|
@@ -833,11 +1107,17 @@ function writeCurrent(root, dir, timestamp, owner, source, flowId, stepId) {
|
|
|
833
1107
|
owner,
|
|
834
1108
|
source,
|
|
835
1109
|
active_agents: [],
|
|
1110
|
+
...(branch ? { branch } : {}),
|
|
836
1111
|
// ADR 0016 Abstraction A (P-a): optional FlowDefinition routing keys for the producer
|
|
837
1112
|
// and enforcer. Both fields are optional and backward-compatible — sessions without a
|
|
838
1113
|
// FlowDefinition omit them and fall through to the workflow.* claim type path.
|
|
839
1114
|
...(flowId ? { active_flow_id: flowId } : {}),
|
|
840
1115
|
...(stepId ? { active_step_id: stepId } : {}),
|
|
1116
|
+
// WS8 (AC12): sanctioned ad-hoc direct entry marker. Set when --step-id explicitly
|
|
1117
|
+
// targets a step other than the flow's resolved first step, so stop-goal-fit /
|
|
1118
|
+
// gate-review can distinguish an intentional direct entry (e.g. a planning-only
|
|
1119
|
+
// session that skips pull-work) from a stale/mis-stamped active_step_id.
|
|
1120
|
+
...(adHocReason ? { ad_hoc_entry: true, ad_hoc_reason: adHocReason } : {}),
|
|
841
1121
|
});
|
|
842
1122
|
}
|
|
843
1123
|
function loadCurrent(root) {
|
|
@@ -851,7 +1131,15 @@ function currentDir(root) {
|
|
|
851
1131
|
if (!c)
|
|
852
1132
|
return null;
|
|
853
1133
|
const dir = path.resolve(root, c.artifact_dir ?? c.active_slug ?? "");
|
|
854
|
-
|
|
1134
|
+
if (!fs.existsSync(dir))
|
|
1135
|
+
return null;
|
|
1136
|
+
try {
|
|
1137
|
+
requireArtifactDirUnderRoot(dir, root);
|
|
1138
|
+
}
|
|
1139
|
+
catch {
|
|
1140
|
+
return null;
|
|
1141
|
+
}
|
|
1142
|
+
return dir;
|
|
855
1143
|
}
|
|
856
1144
|
function updateCurrentAgent(root, dir, agentId, status, timestamp) {
|
|
857
1145
|
const cur = loadCurrent(root);
|
|
@@ -866,8 +1154,38 @@ function updateCurrentAgent(root, dir, agentId, status, timestamp) {
|
|
|
866
1154
|
}
|
|
867
1155
|
function initSidecars(dir, slug, sourceRequest, summary, nextAction, timestamp, markdown) {
|
|
868
1156
|
const criteria = markdown ? definitionAcceptanceLines(markdown).map(parseCriterion) : [];
|
|
1157
|
+
// #289/#309: `markdown` here is NOT always the session `<slug>--deliver.md` that
|
|
1158
|
+
// ensureSession seeds the `branch:` line into — initPlan is called against the tool-planner's
|
|
1159
|
+
// PLAN artifact (`<slug>--plan-work.md` etc.), a different file that typically carries no
|
|
1160
|
+
// `branch:` line at all. Since this function fully rewrites state.json (no merge with the
|
|
1161
|
+
// prior contents), naively re-deriving branch from `markdown` alone would clobber whatever
|
|
1162
|
+
// ensure-session already recorded there moments earlier (#309 regression). Resolve branch with
|
|
1163
|
+
// a three-tier fallback, preferring the most durable source first:
|
|
1164
|
+
// 1. EXISTING state.json.branch — once recorded, it is never re-derived or clobbered.
|
|
1165
|
+
// 2. The session's OWN canonical `<dir>/<slug>--deliver.md` on disk — the file ensureSession
|
|
1166
|
+
// always seeds the `branch:` line into, independent of whatever `markdown` was passed in.
|
|
1167
|
+
// Reading it directly (rather than trusting `markdown`) is what makes a repaired/backfilled
|
|
1168
|
+
// init-plan call (re-run against the same branch-less plan artifact, after a #309-era
|
|
1169
|
+
// session lost its state.json branch) recover the branch without a direct file edit.
|
|
1170
|
+
// 3. The `markdown` param itself (covers ensureSession's fresh-creation call, before
|
|
1171
|
+
// `<slug>--deliver.md` differs from `markdown`, and any direct/legacy caller that passes
|
|
1172
|
+
// the deliver markdown itself as the init-plan artifact).
|
|
1173
|
+
// This makes the branch survive every subsequent initSidecars call (init-plan, or a resumed
|
|
1174
|
+
// ensure-session) at this single choke point, without patching every other writer.
|
|
1175
|
+
const existingState = loadJson(path.join(dir, "state.json"));
|
|
1176
|
+
const existingBranch = existingState.branch;
|
|
1177
|
+
const deliverMdPath = path.join(dir, `${slug}--deliver.md`);
|
|
1178
|
+
const deliverBranch = fs.existsSync(deliverMdPath) ? markdownField(read(deliverMdPath), "branch") : "";
|
|
1179
|
+
const branch = existingBranch || deliverBranch || (markdown ? markdownField(markdown, "branch") : "");
|
|
1180
|
+
// #309 (scope addition): created_at is write-once, same class as the branch-drop bug above —
|
|
1181
|
+
// initSidecars fully rewrites state.json on every call (including a repair/backfill re-run of
|
|
1182
|
+
// init-plan against an already-existing session), so naively re-stamping created_at from the
|
|
1183
|
+
// current call's timestamp silently rewrites a session's original creation time. Preserve the
|
|
1184
|
+
// existing state.json's created_at when present; stamp only on true first-creation.
|
|
1185
|
+
// updated_at still reflects "now" on every call — that field is intentionally mutable.
|
|
869
1186
|
writeJson(path.join(dir, "state.json"), {
|
|
870
|
-
...sidecarBase(slug), status: "planned", phase: "planning", created_at: timestamp, updated_at: timestamp,
|
|
1187
|
+
...sidecarBase(slug), status: "planned", phase: "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
|
|
1188
|
+
...(branch ? { branch } : {}),
|
|
871
1189
|
artifact_paths: relArtifacts(dir),
|
|
872
1190
|
next_action: { status: "continue", summary: nextAction || summary },
|
|
873
1191
|
});
|
|
@@ -888,7 +1206,12 @@ function ensureSession(p) {
|
|
|
888
1206
|
const timestamp = opt(p, "timestamp", now());
|
|
889
1207
|
let md = fs.existsSync(path.join(dir, `${slug}--deliver.md`)) ? read(path.join(dir, `${slug}--deliver.md`)) : "";
|
|
890
1208
|
if (!md) {
|
|
891
|
-
|
|
1209
|
+
// #289: derive the routing branch ONLY on fresh session creation (this `if (!md)` guard).
|
|
1210
|
+
// An existing session's already-recorded `branch:` line is never touched — that whole branch
|
|
1211
|
+
// of code is skipped on a resumed/taken-over session, which is what makes ADR 0021 §5
|
|
1212
|
+
// takeover continuity true by construction (see Design Decision 3 in the plan).
|
|
1213
|
+
const branch = resolveSessionBranch(p, slug);
|
|
1214
|
+
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`;
|
|
892
1215
|
fs.writeFileSync(path.join(dir, `${slug}--deliver.md`), md);
|
|
893
1216
|
}
|
|
894
1217
|
if (!fs.existsSync(path.join(dir, "state.json")) || !fs.existsSync(path.join(dir, "acceptance.json")) || !fs.existsSync(path.join(dir, "handoff.json"))) {
|
|
@@ -902,14 +1225,28 @@ function ensureSession(p) {
|
|
|
902
1225
|
// ensure-session --flow-id builder.build produces a FlowDefinition-driven session even
|
|
903
1226
|
// before the first advance-state call.
|
|
904
1227
|
const flowId = opt(p, "flow-id");
|
|
905
|
-
|
|
1228
|
+
const explicitStep = opt(p, "step-id");
|
|
1229
|
+
let stepId = explicitStep;
|
|
1230
|
+
let adHocReason;
|
|
906
1231
|
if (flowId && !stepId) {
|
|
907
1232
|
const repoRoot = findRepoRootFromDir(dir);
|
|
908
1233
|
const firstStep = resolveFirstStep(flowId, repoRoot);
|
|
909
1234
|
if (firstStep)
|
|
910
1235
|
stepId = firstStep;
|
|
911
1236
|
}
|
|
912
|
-
|
|
1237
|
+
else if (flowId && explicitStep) {
|
|
1238
|
+
// WS8 (AC12): --step-id is the sanctioned ad-hoc direct-entry mechanism. When it names
|
|
1239
|
+
// a step other than the flow's resolved first step, record an explicit ad_hoc_entry
|
|
1240
|
+
// marker (with a reason) instead of silently letting the mis-stamp look like the
|
|
1241
|
+
// flow's normal first step. This is the root-cause fix for a planning-only session
|
|
1242
|
+
// whose active_step_id would otherwise default to builder.build's first step.
|
|
1243
|
+
const repoRoot = findRepoRootFromDir(dir);
|
|
1244
|
+
const firstStep = resolveFirstStep(flowId, repoRoot);
|
|
1245
|
+
if (firstStep && firstStep !== explicitStep) {
|
|
1246
|
+
adHocReason = opt(p, "ad-hoc-reason") || `direct entry at step "${explicitStep}" via --step-id (flow first step is "${firstStep}")`;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", flowId || undefined, stepId || undefined, adHocReason);
|
|
913
1250
|
console.log(dir);
|
|
914
1251
|
return 0;
|
|
915
1252
|
}
|
|
@@ -931,7 +1268,7 @@ function recordAgentEvent(p) {
|
|
|
931
1268
|
die("artifact directory does not exist");
|
|
932
1269
|
if (explicit && fs.lstatSync(dir).isSymbolicLink())
|
|
933
1270
|
die(`artifact directory must not be a symlink: ${dir}`);
|
|
934
|
-
if (hasExplicitRoot)
|
|
1271
|
+
if (hasExplicitRoot || !explicit)
|
|
935
1272
|
requireArtifactDirUnderRoot(dir, root);
|
|
936
1273
|
const timestamp = opt(p, "timestamp", now());
|
|
937
1274
|
const agent = validateAgentId(opt(p, "agent-id"));
|
|
@@ -1014,7 +1351,7 @@ export function normalizeCheck(raw) {
|
|
|
1014
1351
|
if (!check.id || !check.kind || !check.status || !check.summary)
|
|
1015
1352
|
die("check requires id, kind, status, and summary");
|
|
1016
1353
|
if (!checkKinds.has(check.kind))
|
|
1017
|
-
die("kind must be one of: build, types, lint, test, security, diff, browser, runtime, policy, external");
|
|
1354
|
+
die("kind must be one of: build, types, lint, test, command, security, diff, browser, runtime, policy, external");
|
|
1018
1355
|
if (!checkStatuses.has(check.status))
|
|
1019
1356
|
die("status must be one of: pass, fail, not_verified, skip");
|
|
1020
1357
|
if (Array.isArray(check.standard_refs))
|
|
@@ -1175,7 +1512,7 @@ export function writeState(dir, slug, status, phase, timestamp, summary, next =
|
|
|
1175
1512
|
// "builder.verify.policy-compliance") so round-trip helpers broaden their filters
|
|
1176
1513
|
// to include declared claims alongside the legacy workflow.* ones.
|
|
1177
1514
|
//
|
|
1178
|
-
// Safety guard: current.json in the
|
|
1515
|
+
// Safety guard: current.json in the runtime artifact root records the CURRENTLY ACTIVE
|
|
1179
1516
|
// session via artifact_dir. If current.json points to a different session than `dir`
|
|
1180
1517
|
// (e.g. another session was the last to call advance-state --flow-definition), we
|
|
1181
1518
|
// return an empty set so declared-type predicates are NOT applied to the wrong session.
|
|
@@ -1273,13 +1610,49 @@ function critiquesFromBundle(dir, declaredClaimTypes = new Set()) {
|
|
|
1273
1610
|
}));
|
|
1274
1611
|
}
|
|
1275
1612
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1613
|
+
/**
|
|
1614
|
+
* WS8 (ADR 0020): parse the accepted-gap waiver flags. Both --accepted-gap-reason and
|
|
1615
|
+
* --waived-by are required together (an accepted gap with no justification or no
|
|
1616
|
+
* approver is refused — no silent/default waiver). Returns the waiver record to stamp
|
|
1617
|
+
* onto claim.metadata.waiver, or null when neither flag is present. Reuses the existing
|
|
1618
|
+
* accepted_gap -> assumed status mapping; adds no new canonical status value.
|
|
1619
|
+
*/
|
|
1620
|
+
function parseWaiver(p, ts) {
|
|
1621
|
+
const reason = opt(p, "accepted-gap-reason");
|
|
1622
|
+
const waivedBy = opt(p, "waived-by");
|
|
1623
|
+
if (!reason && !waivedBy)
|
|
1624
|
+
return null;
|
|
1625
|
+
if (!reason)
|
|
1626
|
+
die("--accepted-gap-reason is required when --waived-by is set (an accepted gap must carry its justification)");
|
|
1627
|
+
if (!waivedBy)
|
|
1628
|
+
die("--waived-by is required when --accepted-gap-reason is set (an accepted gap must name its approver)");
|
|
1629
|
+
return { reason, approved_by: waivedBy, approved_at: ts };
|
|
1630
|
+
}
|
|
1276
1631
|
async function recordEvidence(p) {
|
|
1277
1632
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
1278
1633
|
const verdict = opt(p, "verdict") || die("--verdict is required");
|
|
1279
1634
|
if (!verdicts.has(verdict))
|
|
1280
1635
|
die("verdict must be one of: pass, partial, fail, not_verified");
|
|
1281
1636
|
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
1282
|
-
const
|
|
1637
|
+
const _ts0 = opt(p, "timestamp", now());
|
|
1638
|
+
const _waiver = parseWaiver(p, _ts0);
|
|
1639
|
+
const _checksRaw = [...opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json"))), ...opts(p, "surface-trust-json").map(surfaceCheckFromArtifact)];
|
|
1640
|
+
// WS8 (AC4, iteration 2): a command-backed check reconciles against CI or fails — it can
|
|
1641
|
+
// NEVER be waived. Reject --accepted-gap-reason/--waived-by on any check whose evidence
|
|
1642
|
+
// classifies as test_output (build/types/lint/test/command, and security/browser/runtime
|
|
1643
|
+
// WHEN a command is present). Only session-local checks (attestation/observation/citation/
|
|
1644
|
+
// diff/policy) are waivable. The CI reconciler enforces the same rule server-side (a waiver
|
|
1645
|
+
// on test_output evidence is a divergence), so this producer-side guard is defense-in-depth,
|
|
1646
|
+
// not the sole gate. (ADR 0020)
|
|
1647
|
+
if (_waiver) {
|
|
1648
|
+
for (const c of _checksRaw) {
|
|
1649
|
+
const hasCmd = typeof c.command === "string" && String(c.command).trim().length > 0;
|
|
1650
|
+
if (classifyEvidence(c.kind, hasCmd).evidenceType === "test_output") {
|
|
1651
|
+
die(`--accepted-gap-reason/--waived-by cannot be applied to a command-backed check (kind='${String(c.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)`);
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
const checks = _checksRaw.map((c) => _waiver ? { ...c, _waiver } : c);
|
|
1283
1656
|
if (!checks.length && opts(p, "surface-trust-json").length === 0)
|
|
1284
1657
|
die("record-evidence requires at least one --check-json or --surface-trust-json");
|
|
1285
1658
|
validateAcceptanceEvidenceRefs(dir);
|
|
@@ -1377,11 +1750,101 @@ async function recordGateClaim(p) {
|
|
|
1377
1750
|
check.artifact_refs = evidenceRefs;
|
|
1378
1751
|
}
|
|
1379
1752
|
const checkNormalized = normalizeCheck(check);
|
|
1753
|
+
// WS8 (ADR 0020): honor the accepted-gap waiver flags for a gate claim too.
|
|
1754
|
+
const gateWaiver = parseWaiver(p, ts);
|
|
1755
|
+
if (gateWaiver)
|
|
1756
|
+
checkNormalized._waiver = gateWaiver;
|
|
1380
1757
|
// Log the targeted gate expectation for transparency (goes to stderr only)
|
|
1381
|
-
process.stderr.write(`[record-gate-claim] targeting ${activeStep.stepId}/${activeStep.gateId}/${targetExpectation.id} → claimType=${claimType} subjectType=${subjectType}\n`);
|
|
1758
|
+
process.stderr.write(`[record-gate-claim] targeting ${activeStep.stepId}/${activeStep.gateId}/${targetExpectation.id} → claimType=${claimType} subjectType=${subjectType}${gateWaiver ? " (WAIVED accepted_gap)" : ""}\n`);
|
|
1382
1759
|
assertBundleWritten(await writeTrustBundle(dir, slug, ts, [checkNormalized], [], []));
|
|
1383
1760
|
return 0;
|
|
1384
1761
|
}
|
|
1762
|
+
/**
|
|
1763
|
+
* promote — the promote-then-archive gate (issue #312). Durable-residue extraction is
|
|
1764
|
+
* the archival act: this records WHAT durable residue was promoted WHERE and writes a
|
|
1765
|
+
* PROMOTION CLAIM into the session trust.bundle.
|
|
1766
|
+
*
|
|
1767
|
+
* Claim shape (reconcile-safe by construction): the promotion check is kind="policy",
|
|
1768
|
+
* so classifyEvidence maps it to evidenceType "policy_rule" (session-local, method
|
|
1769
|
+
* "auditability"). It carries NO command / execution.label, so it can NEVER require a
|
|
1770
|
+
* reconcile-manifest entry and can NEVER become a [not-run] / unbacked-command
|
|
1771
|
+
* divergence at CI trust-reconcile. Its status derives to `verified` from that
|
|
1772
|
+
* session-local policy_rule evidence item, so the reconciler classifies it session-local
|
|
1773
|
+
* and accepts it as an ATTESTED claim (exit 0) — never as a test_output claim that must
|
|
1774
|
+
* match the manifest. The _promotion marker rides onto claim.metadata.promotion (see
|
|
1775
|
+
* buildTrustBundle) so the archive gate and validators can detect it without new manifest
|
|
1776
|
+
* entries (R1).
|
|
1777
|
+
*
|
|
1778
|
+
* Modes:
|
|
1779
|
+
* promote <dir> --evidence-path <p> [--evidence-path <p> ...]
|
|
1780
|
+
* Records the durable doc paths written (docs/decisions/<slug>.md, CONTEXT.md,
|
|
1781
|
+
* docs/learnings/*, …). Each path MUST exist on disk at record time — a missing
|
|
1782
|
+
* path fails loud (no silent empty promotion).
|
|
1783
|
+
* promote <dir> --none --reason "<why nothing durable>"
|
|
1784
|
+
* An explicit, auditable no-residue promotion (R3).
|
|
1785
|
+
*/
|
|
1786
|
+
async function promote(p) {
|
|
1787
|
+
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
1788
|
+
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
1789
|
+
const ts = opt(p, "timestamp", now());
|
|
1790
|
+
const none = p.flags.has("none");
|
|
1791
|
+
const reason = opt(p, "reason");
|
|
1792
|
+
const repoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDir(dir);
|
|
1793
|
+
const rawPaths = opts(p, "evidence-path");
|
|
1794
|
+
if (none) {
|
|
1795
|
+
if (rawPaths.length)
|
|
1796
|
+
die("promote --none records a no-residue claim; do not also pass --evidence-path");
|
|
1797
|
+
if (!reason.trim())
|
|
1798
|
+
die("promote --none requires --reason \"<why nothing durable was promoted>\"");
|
|
1799
|
+
}
|
|
1800
|
+
else {
|
|
1801
|
+
if (!rawPaths.length)
|
|
1802
|
+
die("promote requires at least one --evidence-path <durable-doc-path> (or --none --reason \"<why>\" for an explicit no-residue promotion)");
|
|
1803
|
+
}
|
|
1804
|
+
// Every evidence ref MUST exist on disk at record time (fail loud otherwise). Store
|
|
1805
|
+
// repo-relative paths when the ref lives under the repo, so the claim is portable.
|
|
1806
|
+
const targets = [];
|
|
1807
|
+
for (const raw of rawPaths) {
|
|
1808
|
+
const abs = path.isAbsolute(raw) ? raw : path.resolve(repoRoot, raw);
|
|
1809
|
+
if (!fs.existsSync(abs))
|
|
1810
|
+
die(`promote --evidence-path does not exist on disk: ${raw} (resolved: ${abs}). Promotion evidence refs must point at durable docs that were actually written.`);
|
|
1811
|
+
const rel = path.relative(repoRoot, abs);
|
|
1812
|
+
targets.push(rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel : raw);
|
|
1813
|
+
}
|
|
1814
|
+
const promotionMarker = {
|
|
1815
|
+
schema_version: "1.0",
|
|
1816
|
+
none,
|
|
1817
|
+
...(none ? { reason } : {}),
|
|
1818
|
+
targets,
|
|
1819
|
+
promoted_at: ts,
|
|
1820
|
+
};
|
|
1821
|
+
const summary = opt(p, "summary") || (none
|
|
1822
|
+
? `Promotion (no durable residue): ${reason}`
|
|
1823
|
+
: `Promoted durable residue: ${targets.join(", ")}`);
|
|
1824
|
+
// Session-local promotion check: kind="policy" -> policy_rule evidence, no command.
|
|
1825
|
+
const promotionCheck = { id: "promotion", kind: "policy", status: "pass", summary, _promotion: promotionMarker };
|
|
1826
|
+
// Add the promotion claim WITHOUT dropping the session's existing verification
|
|
1827
|
+
// evidence/criteria/critiques (mirror record-critique's merge pattern). Drop any prior
|
|
1828
|
+
// "promotion" check so re-running promote is idempotent rather than duplicating.
|
|
1829
|
+
const _dct = declaredClaimTypesFor(dir);
|
|
1830
|
+
const existingChecks = checksFromBundle(dir, _dct).filter((c) => c.id !== "promotion");
|
|
1831
|
+
const _acc = loadJson(path.join(dir, "acceptance.json"));
|
|
1832
|
+
const criteria = Array.isArray(_acc.criteria) ? _acc.criteria : [];
|
|
1833
|
+
const critiques = critiquesFromBundle(dir, _dct);
|
|
1834
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, ts, [...existingChecks, promotionCheck], criteria, critiques));
|
|
1835
|
+
// Auditable record of what was promoted where (companion to the trust.bundle claim).
|
|
1836
|
+
writeJson(path.join(dir, "promotion.json"), { ...sidecarBase(slug), ...promotionMarker, summary });
|
|
1837
|
+
// Optionally republish so delivery/trust.bundle carries the promotion claim for CI.
|
|
1838
|
+
if (p.flags.has("publish")) {
|
|
1839
|
+
const publishRepoRoot = opt(p, "publish-repo-root") ? path.resolve(opt(p, "publish-repo-root")) : findRepoRootFromDirStrict(dir);
|
|
1840
|
+
await publishDelivery(dir, publishRepoRoot).catch((err) => {
|
|
1841
|
+
process.stderr.write(`[promote] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
process.stderr.write(`[promote] recorded ${none ? "no-residue" : targets.length + " durable ref(s)"} promotion claim for ${slug}\n`);
|
|
1845
|
+
printJson({ ok: true, slug, none, targets, promotion_claim: "trust.bundle" });
|
|
1846
|
+
return 0;
|
|
1847
|
+
}
|
|
1385
1848
|
async function advanceState(p) {
|
|
1386
1849
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
1387
1850
|
const status = opt(p, "status");
|
|
@@ -1438,7 +1901,14 @@ async function advanceState(p) {
|
|
|
1438
1901
|
if (status === "delivered") {
|
|
1439
1902
|
await sealTrustCheckpoint(dir, slug, timestamp, status, "release").catch(() => { });
|
|
1440
1903
|
// Publish delivery bundle: best-effort copy to delivery/ for CI trust-reconcile.
|
|
1441
|
-
|
|
1904
|
+
// Fail-closed repo-root resolution (findRepoRootFromDirStrict, no cwd fallback) — see
|
|
1905
|
+
// publishDelivery below. An explicit --repo-root (e.g. for a scratch/test artifact dir
|
|
1906
|
+
// with no kits/ ancestor of its own) always wins, matching publishDeliveryCmd. Failures
|
|
1907
|
+
// are visible (stderr warning), not silently swallowed.
|
|
1908
|
+
const publishRepoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
|
|
1909
|
+
await publishDelivery(dir, publishRepoRoot).catch((err) => {
|
|
1910
|
+
process.stderr.write(`[advance-state] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1911
|
+
});
|
|
1442
1912
|
}
|
|
1443
1913
|
return 0;
|
|
1444
1914
|
}
|
|
@@ -1511,7 +1981,14 @@ async function recordRelease(p) {
|
|
|
1511
1981
|
// Trust checkpoint: seal at the "delivered" moment (the natural terminal mark for record-release).
|
|
1512
1982
|
await sealTrustCheckpoint(dir, slug, payload.updated_at, "delivered", "release").catch(() => { });
|
|
1513
1983
|
// Publish delivery bundle: best-effort copy to delivery/ for CI trust-reconcile.
|
|
1514
|
-
|
|
1984
|
+
// Fail-closed repo-root resolution (findRepoRootFromDirStrict, no cwd fallback) — see
|
|
1985
|
+
// publishDelivery below. An explicit --repo-root (e.g. for a scratch/test artifact dir with
|
|
1986
|
+
// no kits/ ancestor of its own) always wins, matching publishDeliveryCmd. Failures are
|
|
1987
|
+
// visible (stderr warning), not silently swallowed.
|
|
1988
|
+
const publishRepoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
|
|
1989
|
+
await publishDelivery(dir, publishRepoRoot).catch((err) => {
|
|
1990
|
+
process.stderr.write(`[record-release] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1991
|
+
});
|
|
1515
1992
|
return 0;
|
|
1516
1993
|
}
|
|
1517
1994
|
// ─── Trust Checkpoint (Increment A) ──────────────────────────────────────────
|
|
@@ -1713,7 +2190,7 @@ async function sealCheckpoint(p) {
|
|
|
1713
2190
|
}
|
|
1714
2191
|
// ─── Publish Delivery Bundle ──────────────────────────────────────────────────
|
|
1715
2192
|
// Copies the session's trust.bundle (+ checkpoint companions) from the gitignored
|
|
1716
|
-
// session artifact dir (.flow-agents/<slug>/) to the committed delivery/ transport
|
|
2193
|
+
// session artifact dir (.kontourai/flow-agents/<slug>/) to the committed delivery/ transport
|
|
1717
2194
|
// path so the CI trust-reconcile job can reconcile it against fresh CI results.
|
|
1718
2195
|
//
|
|
1719
2196
|
// Fail-soft: if trust.bundle is absent (no evidence recorded yet), does nothing.
|
|
@@ -1727,13 +2204,24 @@ async function sealCheckpoint(p) {
|
|
|
1727
2204
|
* trust.checkpoint.intoto.json / trust.checkpoint.sig.json from the
|
|
1728
2205
|
* session artifact dir to <repoRoot>/delivery/.
|
|
1729
2206
|
*
|
|
1730
|
-
* Fail-soft: if trust.bundle is absent, returns without throwing.
|
|
2207
|
+
* Fail-soft on a missing bundle: if trust.bundle is absent, returns without throwing.
|
|
2208
|
+
* Fail-CLOSED on repo-root resolution: repoRoot must be a real, resolved kits/ ancestor
|
|
2209
|
+
* (see findRepoRootFromDirStrict) — null (no ancestor found) skips the publish with a
|
|
2210
|
+
* visible warning instead of writing to whatever process.cwd() happens to be. This
|
|
2211
|
+
* prevents a scratch/test session dir (no kits/ ancestor) from silently clobbering an
|
|
2212
|
+
* unrelated real repo's delivery/trust.bundle when invoked with that repo as cwd (see
|
|
2213
|
+
* evals/integration/test_checkpoint_signing.sh TEST 2 and the WS5 session findings at
|
|
2214
|
+
* .kontourai/flow-agents/ws5-governance-kit-slice1 for the root cause this fixes).
|
|
1731
2215
|
* Idempotent: overwrites on re-delivery.
|
|
1732
2216
|
*/
|
|
1733
2217
|
export async function publishDelivery(dir, repoRoot) {
|
|
1734
2218
|
const bundleSrc = path.join(dir, "trust.bundle");
|
|
1735
2219
|
if (!fs.existsSync(bundleSrc))
|
|
1736
2220
|
return; // no bundle — skip gracefully
|
|
2221
|
+
if (!repoRoot) {
|
|
2222
|
+
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`);
|
|
2223
|
+
return;
|
|
2224
|
+
}
|
|
1737
2225
|
const deliveryDir = path.join(repoRoot, "delivery");
|
|
1738
2226
|
fs.mkdirSync(deliveryDir, { recursive: true });
|
|
1739
2227
|
// Required: trust.bundle (the CI anchor)
|
|
@@ -1764,7 +2252,10 @@ export async function publishDelivery(dir, repoRoot) {
|
|
|
1764
2252
|
*/
|
|
1765
2253
|
async function publishDeliveryCmd(p) {
|
|
1766
2254
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
1767
|
-
|
|
2255
|
+
// Fail-closed: an explicit --repo-root always wins; otherwise resolve strictly (no
|
|
2256
|
+
// process.cwd() fallback) so a scratch/test artifact dir cannot accidentally publish
|
|
2257
|
+
// into whichever repo happens to be the current working directory.
|
|
2258
|
+
const repoRoot = opt(p, "repo-root") || findRepoRootFromDirStrict(dir);
|
|
1768
2259
|
await publishDelivery(dir, repoRoot);
|
|
1769
2260
|
return 0;
|
|
1770
2261
|
}
|
|
@@ -1971,13 +2462,13 @@ async function dogfoodPass(p) {
|
|
|
1971
2462
|
return 0;
|
|
1972
2463
|
}
|
|
1973
2464
|
/**
|
|
1974
|
-
* Read the gate block signal from .flow-agents/.goal-fit-block-streak.json
|
|
2465
|
+
* Read the gate block signal from .kontourai/flow-agents/.goal-fit-block-streak.json
|
|
1975
2466
|
* (written by scripts/hooks/stop-goal-fit.js when block mode fires).
|
|
1976
2467
|
* The file sits at <artifact-root>/.goal-fit-block-streak.json — one level
|
|
1977
2468
|
* above the session artifact dir. Fail-open: returns { blocked: false } when
|
|
1978
2469
|
* the file is absent or unreadable.
|
|
1979
2470
|
*
|
|
1980
|
-
* @param artifactRoot The
|
|
2471
|
+
* @param artifactRoot The runtime artifact root dir (parent of session slug dir).
|
|
1981
2472
|
*/
|
|
1982
2473
|
export function readGateBlockSignal(artifactRoot) {
|
|
1983
2474
|
const streakFile = path.join(artifactRoot, ".goal-fit-block-streak.json");
|
|
@@ -2183,7 +2674,7 @@ export function buildGateInquiryRecords(bundle, blockSignal, slug, expectedCrite
|
|
|
2183
2674
|
*
|
|
2184
2675
|
* The block signal is read from <artifact-root>/.goal-fit-block-streak.json,
|
|
2185
2676
|
* written by scripts/hooks/stop-goal-fit.js when block mode fires. The file
|
|
2186
|
-
* lives one level above the session slug dir (the
|
|
2677
|
+
* lives one level above the session slug dir (the runtime artifact root).
|
|
2187
2678
|
*
|
|
2188
2679
|
* If @kontourai/surface is unavailable, logs a warning and returns 0
|
|
2189
2680
|
* (fail-open — no bespoke fork fallback).
|
|
@@ -2206,7 +2697,7 @@ async function gateReview(p) {
|
|
|
2206
2697
|
return 0;
|
|
2207
2698
|
}
|
|
2208
2699
|
const bundle = JSON.parse(fs.readFileSync(bundlePath, "utf8"));
|
|
2209
|
-
// Read gate block signal from
|
|
2700
|
+
// Read gate block signal from the runtime artifact root (one level above session dir)
|
|
2210
2701
|
const artifactRoot = path.dirname(dir);
|
|
2211
2702
|
const blockSignal = readGateBlockSignal(artifactRoot);
|
|
2212
2703
|
// Enumerate expected criterion IDs: primary = bundle claims (workflow.acceptance.criterion),
|
|
@@ -2370,7 +2861,7 @@ function trustMcp(p) {
|
|
|
2370
2861
|
if (mode === "print") {
|
|
2371
2862
|
console.log(JSON.stringify({ mcpServers: { [TRUST_MCP_SERVER]: trustMcpRegistration() } }, null, 2));
|
|
2372
2863
|
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`);
|
|
2373
|
-
process.stderr.write(`# To view a task's trust inline, call surface_summary with path=<.flow-agents/<slug>/trust.bundle>.\n`);
|
|
2864
|
+
process.stderr.write(`# To view a task's trust inline, call surface_summary with path=<.kontourai/flow-agents/<slug>/trust.bundle>.\n`);
|
|
2374
2865
|
return 0;
|
|
2375
2866
|
}
|
|
2376
2867
|
if (mode !== "enable" && mode !== "disable")
|
|
@@ -2422,12 +2913,19 @@ const LIVENESS_POLICY = {
|
|
|
2422
2913
|
conflictRules: [],
|
|
2423
2914
|
impactLevel: "medium",
|
|
2424
2915
|
};
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2916
|
+
/**
|
|
2917
|
+
* Delegate to the shared pure-CJS writer (scripts/hooks/lib/liveness-write.js), mirroring the
|
|
2918
|
+
* createRequire pattern used by loadActorIdentityHelper() below. Deliberately NO inline duplicate
|
|
2919
|
+
* fallback — the whole point of #288 Wave 2 Task 2.1 is one writer shared by the CLI and the
|
|
2920
|
+
* hook wrappers' tool-activity heartbeat, not two copies of the append shape that can drift.
|
|
2921
|
+
*/
|
|
2922
|
+
function loadLivenessWriteHelper() {
|
|
2923
|
+
const _req = createRequire(import.meta.url);
|
|
2924
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-write.js");
|
|
2925
|
+
return _req(helperPath);
|
|
2430
2926
|
}
|
|
2927
|
+
function livenessStreamFile(root) { return loadLivenessWriteHelper().livenessStreamFile(root); }
|
|
2928
|
+
function appendLivenessEvent(root, evt) { loadLivenessWriteHelper().appendLivenessEvent(root, evt); }
|
|
2431
2929
|
function readLivenessEvents(root) {
|
|
2432
2930
|
// Delegate to the shared pure-CJS helper (scripts/hooks/lib/liveness-read.js).
|
|
2433
2931
|
// Using createRequire so the ESM sidecar can load a CJS module without bundling it.
|
|
@@ -2463,39 +2961,158 @@ function livenessLabel(status) {
|
|
|
2463
2961
|
return "superseded";
|
|
2464
2962
|
return status;
|
|
2465
2963
|
}
|
|
2466
|
-
// ─── ADR 0012 lifecycle-driven liveness (opt-
|
|
2964
|
+
// ─── ADR 0012 lifecycle-driven liveness (default-on; opt-out via FLOW_AGENTS_LIVENESS) ──
|
|
2467
2965
|
// init-plan claims the work-item; advance-state heartbeats (or releases on terminal),
|
|
2468
2966
|
// so the workflow lifecycle itself maintains the liveness claim — no manual liveness calls.
|
|
2469
2967
|
// Additive + fail-open: a liveness-emit failure never affects the workflow command.
|
|
2470
2968
|
const LIVENESS_TERMINAL = new Set(["delivered", "accepted", "archived"]);
|
|
2471
|
-
|
|
2472
|
-
|
|
2969
|
+
/**
|
|
2970
|
+
* Delegate to the shared pure-CJS resolver (scripts/hooks/lib/actor-identity.js), mirroring the
|
|
2971
|
+
* createRequire pattern used by readLivenessEvents() above. Deliberately NO inline duplicate
|
|
2972
|
+
* fallback: if the module fails to load, that failure itself must surface as an unresolved actor
|
|
2973
|
+
* ("") — never silently degrade back to the retired "local" default (issue #287).
|
|
2974
|
+
*/
|
|
2975
|
+
function loadActorIdentityHelper() {
|
|
2976
|
+
const _req = createRequire(import.meta.url);
|
|
2977
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/actor-identity.js");
|
|
2978
|
+
return _req(helperPath);
|
|
2979
|
+
}
|
|
2980
|
+
function resolveLivenessActor() {
|
|
2981
|
+
return loadActorIdentityHelper().resolveActor(process.env).actor;
|
|
2982
|
+
}
|
|
2983
|
+
// isUnresolvedActor is no longer defined locally — it is single-sourced in
|
|
2984
|
+
// scripts/hooks/lib/actor-identity.js (loadActorIdentityHelper().isUnresolvedActor) so the
|
|
2985
|
+
// lifecycle auto-emit path, the direct CLI liveness path, and the tool-activity heartbeat path
|
|
2986
|
+
// all consume the same predicate rather than forking their own copy (#287 re-review MEDIUM; #288
|
|
2987
|
+
// Wave 1 Task 1.1 single-sources it there).
|
|
2988
|
+
/**
|
|
2989
|
+
* Delegate to the shared pure-CJS policy predicates (scripts/hooks/lib/liveness-policy.js) — the
|
|
2990
|
+
* one definition of "enabled" (default-on/opt-out) and the claim TTL default, consumed by both the
|
|
2991
|
+
* lifecycle auto-emit path and the manual `liveness claim --ttl` default so they can never disagree
|
|
2992
|
+
* (#288).
|
|
2993
|
+
*/
|
|
2994
|
+
function loadLivenessPolicyHelper() {
|
|
2995
|
+
const _req = createRequire(import.meta.url);
|
|
2996
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-policy.js");
|
|
2997
|
+
return _req(helperPath);
|
|
2998
|
+
}
|
|
2999
|
+
function livenessEnabled() { return loadLivenessPolicyHelper().isLivenessEnabled(process.env); }
|
|
3000
|
+
/**
|
|
3001
|
+
* F1 (#288 fix iteration 1, cr-HIGH fail-open violation): the `livenessEnabled()`
|
|
3002
|
+
* guard (and therefore its `loadLivenessPolicyHelper()` module load) must sit
|
|
3003
|
+
* INSIDE this function's own fail-open try/catch — previously it sat outside,
|
|
3004
|
+
* so a missing/broken scripts/hooks/lib/liveness-policy.js module made
|
|
3005
|
+
* `init-plan`/`advance-state` exit 1 instead of degrading gracefully
|
|
3006
|
+
* (repro-verified). Now: any failure here — including a failed helper load —
|
|
3007
|
+
* is caught, produces one stderr diagnostic, and the lifecycle auto-emit is
|
|
3008
|
+
* skipped; the workflow command's own exit code is never affected. This
|
|
3009
|
+
* mirrors the #287 fail-open convention already used elsewhere in this file.
|
|
3010
|
+
* The direct CLI write path (`async function liveness`, actions
|
|
3011
|
+
* claim|heartbeat|release) is deliberately NOT wrapped this way — it stays
|
|
3012
|
+
* fail-loud on a missing helper module, per the plan's explicit instruction
|
|
3013
|
+
* that only the convenience lifecycle wiring is flag-gated/fail-open.
|
|
3014
|
+
*/
|
|
2473
3015
|
function livenessLifecycle(taskDir, slug, kind, timestamp) {
|
|
2474
|
-
if (!livenessEnabled())
|
|
2475
|
-
return;
|
|
2476
3016
|
try {
|
|
2477
|
-
|
|
2478
|
-
|
|
3017
|
+
if (!livenessEnabled())
|
|
3018
|
+
return;
|
|
3019
|
+
const actor = resolveLivenessActor();
|
|
3020
|
+
if (loadActorIdentityHelper().isUnresolvedActor(actor)) {
|
|
3021
|
+
process.stderr.write("[liveness] skipped auto-emit: actor unresolved (set FLOW_AGENTS_ACTOR or run inside a supported runtime)\n");
|
|
3022
|
+
return;
|
|
3023
|
+
}
|
|
3024
|
+
const root = path.dirname(taskDir); // .kontourai/flow-agents/<slug> → .kontourai/flow-agents (the shared liveness stream lives here)
|
|
3025
|
+
const evt = { type: kind, subjectId: slug, actor, at: timestamp, source: "lifecycle" };
|
|
2479
3026
|
if (kind === "claim")
|
|
2480
|
-
evt.ttlSeconds =
|
|
3027
|
+
evt.ttlSeconds = loadLivenessPolicyHelper().resolveTtlSeconds(process.env);
|
|
2481
3028
|
appendLivenessEvent(root, evt);
|
|
2482
3029
|
}
|
|
2483
|
-
catch {
|
|
3030
|
+
catch (err) {
|
|
3031
|
+
// best-effort; liveness is advisory and must never break the workflow — but the failure
|
|
3032
|
+
// itself must be visible (F1), not silently absorbed.
|
|
3033
|
+
try {
|
|
3034
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
3035
|
+
process.stderr.write(`[liveness] skipped auto-emit: ${detail}\n`);
|
|
3036
|
+
}
|
|
3037
|
+
catch { /* best-effort diagnostic only */ }
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
/**
|
|
3041
|
+
* F5 (#288 fix iteration 1, sec-LOW): strip control/escape characters before echoing a
|
|
3042
|
+
* subjectId/actor to the terminal. subjectId is a raw CLI positional argument on the write path
|
|
3043
|
+
* (never sanitized before this point — it is stored as-is in the event for data fidelity) and,
|
|
3044
|
+
* on the `status` read path, both subjectId and actor may originate from a hand-edited or
|
|
3045
|
+
* otherwise hostile liveness/events.jsonl file rather than this process's own writes. Strips the
|
|
3046
|
+
* C0 control range (0x00-0x1F), DEL (0x7F), and the C1 range (0x80-0x9F, which includes
|
|
3047
|
+
* ANSI-CSI-adjacent bytes) — display-only; the persisted event itself is never mutated.
|
|
3048
|
+
*/
|
|
3049
|
+
function stripControlCharsForDisplay(value) {
|
|
3050
|
+
return String(value ?? "").replace(/[\u0000-\u001F\u007F-\u009F]/g, "");
|
|
2484
3051
|
}
|
|
2485
3052
|
async function liveness(p) {
|
|
2486
3053
|
const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
|
|
2487
3054
|
const action = p.positional[0] || "";
|
|
2488
3055
|
const subjectId = p.positional[1] || "";
|
|
2489
|
-
const actor = opt(p, "actor", process.env.FLOW_AGENTS_ACTOR || "unknown");
|
|
2490
3056
|
const nowIso = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
3057
|
+
if (action === "whoami") {
|
|
3058
|
+
// Read-only, lock-free, write-free advisory surface: reuses the identical resolution chain
|
|
3059
|
+
// as the write paths (loadActorIdentityHelper().resolveActor) but deliberately never dies on
|
|
3060
|
+
// an unresolved actor — the enforcement point stays at `liveness claim`, which already dies
|
|
3061
|
+
// loudly (see below). This lets a skill learn "who am I" during a read-only preflight without
|
|
3062
|
+
// emitting a bogus claim first.
|
|
3063
|
+
const helper = loadActorIdentityHelper();
|
|
3064
|
+
const explicitActorRaw = opt(p, "actor", "");
|
|
3065
|
+
const explicitActor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : "";
|
|
3066
|
+
const resolved = explicitActor
|
|
3067
|
+
? { actor: explicitActor, source: "explicit-override" }
|
|
3068
|
+
: helper.resolveActor(process.env);
|
|
3069
|
+
if (p.flags.has("json")) {
|
|
3070
|
+
console.log(JSON.stringify(resolved));
|
|
3071
|
+
return 0;
|
|
3072
|
+
}
|
|
3073
|
+
console.log(`${stripControlCharsForDisplay(resolved.actor || "unresolved")}\t${stripControlCharsForDisplay(resolved.source)}`);
|
|
3074
|
+
return 0;
|
|
3075
|
+
}
|
|
2491
3076
|
if (action === "claim" || action === "heartbeat" || action === "release") {
|
|
3077
|
+
// Actor resolution happens only for write actions (F5, #287 fix iteration 1) — "status" is a
|
|
3078
|
+
// read path and must not shell out via resolveLivenessActor()/resolveActor().
|
|
3079
|
+
const helper = loadActorIdentityHelper();
|
|
3080
|
+
const explicitActorRaw = opt(p, "actor", "");
|
|
3081
|
+
// F7 (#287 fix iteration 2): an explicit --actor value that strips to empty under the allowed
|
|
3082
|
+
// [A-Za-z0-9_.-] charset (e.g. "--actor ':::'") is a hard error on this write path, not a
|
|
3083
|
+
// silent fallback to sanitizeSegment's shared "unknown" sentinel — garbage on an explicit flag
|
|
3084
|
+
// is an authoring mistake the caller must fix, unlike the env-override seam (which falls
|
|
3085
|
+
// through to derivation instead, since there is no flag to correct).
|
|
3086
|
+
if (explicitActorRaw && !/[A-Za-z0-9_.-]/.test(explicitActorRaw)) {
|
|
3087
|
+
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.`);
|
|
3088
|
+
}
|
|
3089
|
+
const explicitActor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : "";
|
|
3090
|
+
const actor = explicitActor || helper.resolveActor(process.env).actor;
|
|
3091
|
+
if (helper.isUnresolvedActor(actor)) {
|
|
3092
|
+
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.`);
|
|
3093
|
+
}
|
|
2492
3094
|
if (!subjectId)
|
|
2493
3095
|
die(`liveness ${action} requires a subjectId`);
|
|
3096
|
+
// F8(i) (#288 fix iteration 2, orphan-heartbeat invariant): heartbeat/release on the direct CLI
|
|
3097
|
+
// write path must never be the FIRST liveness event ever written for a (subjectId, actor) pair —
|
|
3098
|
+
// that is exactly how the reviewer-reproduced orphan-heartbeat bug arises (a bare heartbeat with
|
|
3099
|
+
// no claim behind it, later mistaken for claim evidence). A full stream read here is fine: this
|
|
3100
|
+
// is a rare, human/CLI-driven call, not the hot tool-activity path. `claim` itself is exempt (it
|
|
3101
|
+
// is the event that ESTABLISHES the pair in the first place).
|
|
3102
|
+
if (action === "heartbeat" || action === "release") {
|
|
3103
|
+
const priorEvents = readLivenessEvents(root);
|
|
3104
|
+
const hasPriorClaim = priorEvents.some((e) => e && e.type === "claim" && e.subjectId === subjectId && e.actor === actor);
|
|
3105
|
+
if (!hasPriorClaim) {
|
|
3106
|
+
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}.`);
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
2494
3109
|
const evt = { type: action, subjectId, actor, at: opt(p, "at") || nowIso };
|
|
2495
|
-
if (action === "claim")
|
|
2496
|
-
|
|
3110
|
+
if (action === "claim") {
|
|
3111
|
+
const defaultTtl = loadLivenessPolicyHelper().resolveTtlSeconds(process.env);
|
|
3112
|
+
evt.ttlSeconds = Number.parseInt(opt(p, "ttl", String(defaultTtl)), 10) || defaultTtl;
|
|
3113
|
+
}
|
|
2497
3114
|
appendLivenessEvent(root, evt);
|
|
2498
|
-
console.log(`liveness ${action}: ${subjectId} by ${actor}`);
|
|
3115
|
+
console.log(`liveness ${action}: ${stripControlCharsForDisplay(subjectId)} by ${stripControlCharsForDisplay(actor)}`);
|
|
2499
3116
|
return 0;
|
|
2500
3117
|
}
|
|
2501
3118
|
if (action === "status") {
|
|
@@ -2531,7 +3148,7 @@ async function liveness(p) {
|
|
|
2531
3148
|
for (const g of groups.values()) {
|
|
2532
3149
|
if (subjectFilter && g.subjectId !== subjectFilter)
|
|
2533
3150
|
continue;
|
|
2534
|
-
const claim = { id: `${g.subjectId}::${g.actor}`, subjectType: "work-item", subjectId: g.subjectId,
|
|
3151
|
+
const claim = { 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 };
|
|
2535
3152
|
const status = surface.deriveTrustStatus({ claim, evidence: [], policy: LIVENESS_POLICY, events: g.events, now });
|
|
2536
3153
|
rows.push({ subjectId: g.subjectId, actor: g.actor, status, label: livenessLabel(status) });
|
|
2537
3154
|
}
|
|
@@ -2540,10 +3157,99 @@ async function liveness(p) {
|
|
|
2540
3157
|
return 0;
|
|
2541
3158
|
}
|
|
2542
3159
|
for (const r of rows)
|
|
2543
|
-
console.log(`${r.subjectId}\t${r.actor}\t${r.label}`);
|
|
3160
|
+
console.log(`${stripControlCharsForDisplay(r.subjectId)}\t${stripControlCharsForDisplay(r.actor)}\t${r.label}`);
|
|
3161
|
+
return 0;
|
|
3162
|
+
}
|
|
3163
|
+
if (action === "verdict") {
|
|
3164
|
+
// Read-only, lock-free CLI helper (#320 AC1) — mirrors `whoami`'s lock-bypass (see
|
|
3165
|
+
// isLivenessVerdict in main() below): computes {subjectId, winner, losers, reason, holders}
|
|
3166
|
+
// as a PURE function of the shared liveness stream. Among the subject's currently-fresh
|
|
3167
|
+
// claim holders (via the shared `freshHolders` helper — the exact canonical
|
|
3168
|
+
// freshness/grouping/release-handling logic `workflow-steering.js`'s ambient digest already
|
|
3169
|
+
// uses, not a re-derived rule), the holder whose most recent `claim` event has the earliest
|
|
3170
|
+
// `at` wins; an exact-timestamp tie breaks by ascending plain string comparison of actor id
|
|
3171
|
+
// (never locale-aware collation, for cross-machine determinism). Same stream state ⇒ same
|
|
3172
|
+
// verdict, regardless of which actor invokes it — no actor-specific input is read here.
|
|
3173
|
+
if (!subjectId)
|
|
3174
|
+
die("liveness verdict requires a subjectId");
|
|
3175
|
+
const events = readLivenessEvents(root);
|
|
3176
|
+
const nowMs = opt(p, "now") ? Date.parse(opt(p, "now")) : Date.now();
|
|
3177
|
+
const _req = createRequire(import.meta.url);
|
|
3178
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-read.js");
|
|
3179
|
+
const { freshHolders } = _req(helperPath);
|
|
3180
|
+
// Impossible-sentinel selfActor: real actors are sanitized to [A-Za-z0-9_.-]+ and can never be
|
|
3181
|
+
// empty (enforced on every write path above), so passing "" excludes nothing.
|
|
3182
|
+
const fresh = freshHolders(events, subjectId, "", nowMs);
|
|
3183
|
+
// freshHolders' `lastAt` tracks the group's latest event of ANY type (heartbeat-updated); the
|
|
3184
|
+
// tiebreak instead operates on each holder's most recent `claim`-type event `at` — the
|
|
3185
|
+
// "current claim timestamp", distinct from lastAt.
|
|
3186
|
+
const latestClaimAt = (actor) => {
|
|
3187
|
+
let best = "";
|
|
3188
|
+
for (const e of events) {
|
|
3189
|
+
if (e && e.subjectId === subjectId && e.actor === actor && e.type === "claim" && typeof e.at === "string") {
|
|
3190
|
+
if (!best || e.at > best)
|
|
3191
|
+
best = e.at;
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
return best;
|
|
3195
|
+
};
|
|
3196
|
+
const holders = fresh
|
|
3197
|
+
.map((h) => ({ actor: String(h.actor), claimAt: latestClaimAt(String(h.actor)) || String(h.lastAt), lastAt: String(h.lastAt), ttlSeconds: Number(h.ttlSeconds) }))
|
|
3198
|
+
.sort((a, b) => (a.claimAt < b.claimAt ? -1 : a.claimAt > b.claimAt ? 1 : a.actor < b.actor ? -1 : a.actor > b.actor ? 1 : 0));
|
|
3199
|
+
let winner = null;
|
|
3200
|
+
let losers = [];
|
|
3201
|
+
let reason = "no-conflict";
|
|
3202
|
+
if (holders.length >= 2) {
|
|
3203
|
+
winner = holders[0];
|
|
3204
|
+
const tie = holders.filter((h) => h.claimAt === winner.claimAt).length > 1;
|
|
3205
|
+
reason = tie ? "tie-actor-lexicographic" : "earlier-claim";
|
|
3206
|
+
losers = holders.filter((h) => h.actor !== winner.actor);
|
|
3207
|
+
}
|
|
3208
|
+
if (p.flags.has("json")) {
|
|
3209
|
+
// #320 fix iteration 1 (sec-HIGH F3): `winner`/`losers`/`holders` carry raw actor/claimAt/
|
|
3210
|
+
// lastAt strings sourced straight from the multi-writer append-only liveness/events.jsonl
|
|
3211
|
+
// stream (see stripControlCharsForDisplay's header above — any process can append a
|
|
3212
|
+
// hostile line, bypassing the CLI write-side sanitizeSegment entirely). The pull-work
|
|
3213
|
+
// SKILL.md's Post-Claim Conflict Re-check instructs an LLM to read `winner.actor` /
|
|
3214
|
+
// `losers[].actor` straight out of THIS `--json` output into its own context — a second,
|
|
3215
|
+
// LLM-facing injection path alongside the mid-turn conflict hook (liveness-heartbeat.js's
|
|
3216
|
+
// computeConflict). Build a sanitized COPY for JSON emission only: control-char strip
|
|
3217
|
+
// (matching the text branch's existing stripControlCharsForDisplay treatment below) plus a
|
|
3218
|
+
// 64-char cap (matching sanitizeSegment's cap on the write side) on actor/claimAt/lastAt.
|
|
3219
|
+
// `reason`/`subjectId`/`ttlSeconds` are left as-is (enum/number, never free text). Never
|
|
3220
|
+
// mutates `result`/`holders`/`winner`/`losers` themselves — the text branch below still
|
|
3221
|
+
// needs the untouched originals for its own per-line rendering, and winner/loser identity
|
|
3222
|
+
// (already sanitizeSegment-clean for legitimate actors) is unaffected, so the loser-release
|
|
3223
|
+
// contract still works.
|
|
3224
|
+
const sanitizeVerdictHolderForJson = (h) => h
|
|
3225
|
+
? {
|
|
3226
|
+
...h,
|
|
3227
|
+
actor: stripControlCharsForDisplay(h.actor).slice(0, 64),
|
|
3228
|
+
claimAt: stripControlCharsForDisplay(h.claimAt).slice(0, 64),
|
|
3229
|
+
lastAt: stripControlCharsForDisplay(h.lastAt).slice(0, 64),
|
|
3230
|
+
}
|
|
3231
|
+
: h;
|
|
3232
|
+
const jsonResult = {
|
|
3233
|
+
subjectId,
|
|
3234
|
+
winner: sanitizeVerdictHolderForJson(winner),
|
|
3235
|
+
losers: losers.map((l) => sanitizeVerdictHolderForJson(l)),
|
|
3236
|
+
reason,
|
|
3237
|
+
holders: holders.map((h) => sanitizeVerdictHolderForJson(h)),
|
|
3238
|
+
};
|
|
3239
|
+
console.log(JSON.stringify(jsonResult));
|
|
3240
|
+
return 0;
|
|
3241
|
+
}
|
|
3242
|
+
for (const h of holders) {
|
|
3243
|
+
const tag = winner && h.actor === winner.actor ? "WINNER" : (holders.length >= 2 ? "LOSER" : "HOLDER");
|
|
3244
|
+
console.log(`${stripControlCharsForDisplay(h.actor)}\tclaimAt=${stripControlCharsForDisplay(h.claimAt)}\t${tag}`);
|
|
3245
|
+
}
|
|
3246
|
+
if (winner)
|
|
3247
|
+
console.log(`WINNER: ${stripControlCharsForDisplay(winner.actor)}`);
|
|
3248
|
+
for (const l of losers)
|
|
3249
|
+
console.log(`LOSER: ${stripControlCharsForDisplay(l.actor)}`);
|
|
2544
3250
|
return 0;
|
|
2545
3251
|
}
|
|
2546
|
-
die("liveness action must be one of: claim | heartbeat | release | status");
|
|
3252
|
+
die("liveness action must be one of: claim | heartbeat | release | status | whoami | verdict");
|
|
2547
3253
|
return 1;
|
|
2548
3254
|
}
|
|
2549
3255
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -2707,9 +3413,24 @@ async function main() {
|
|
|
2707
3413
|
const p = parseArgs(process.argv.slice(2));
|
|
2708
3414
|
if (!p.command)
|
|
2709
3415
|
die("workflow-sidecar command is required");
|
|
2710
|
-
|
|
3416
|
+
// F1 (#166 fix iteration 1): `liveness whoami` is a read-only, lock-free, write-free advisory
|
|
3417
|
+
// surface (see the `action === "whoami"` branch inside `liveness()` above) — it must never
|
|
3418
|
+
// acquire the workflow-sidecar lock, regardless of whether the artifact root already exists on
|
|
3419
|
+
// disk. Without this action-level bypass, `liveness` was blanket-included in the lock-routing
|
|
3420
|
+
// branch below, so `whoami` against an EXISTING artifact root would still resolve a real
|
|
3421
|
+
// lockRoot and go through `withLock`'s mkdir/lockdir path — the opposite of "genuinely
|
|
3422
|
+
// lock-free". This bypass mirrors `resolve-slug`'s existing empty-lockRoot special case
|
|
3423
|
+
// immediately below and is scoped to the `whoami` action only: `liveness status` (a read path)
|
|
3424
|
+
// keeps its pre-existing lock behavior unchanged (out of scope for this fix — see fix-plan
|
|
3425
|
+
// iteration 1, F1), and `liveness claim` / `heartbeat` / `release` (write paths) are untouched.
|
|
3426
|
+
const isLivenessWhoami = p.command === "liveness" && p.positional[0] === "whoami";
|
|
3427
|
+
// #320 AC1: `liveness verdict` is read-only and lock-free, exactly like `whoami` above — it
|
|
3428
|
+
// must never acquire the workflow-sidecar lock. Same bypass shape, scoped to the `verdict`
|
|
3429
|
+
// action only; `liveness status` (also a read path) keeps its pre-existing lock behavior.
|
|
3430
|
+
const isLivenessVerdict = p.command === "liveness" && p.positional[0] === "verdict";
|
|
3431
|
+
const lockRoot = (["ensure-session", "current", "dogfood-pass", "liveness"].includes(p.command) && !isLivenessWhoami && !isLivenessVerdict)
|
|
2711
3432
|
? (opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : (p.command === "ensure-session" ? flowAgentsArtifactRoot() : defaultArtifactRootForRead()))
|
|
2712
|
-
: 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]) : "";
|
|
3433
|
+
: 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]) : "";
|
|
2713
3434
|
return withLock(lockRoot, ["ensure-session", "record-agent-event", "dogfood-pass"].includes(p.command), p.command, () => {
|
|
2714
3435
|
switch (p.command) {
|
|
2715
3436
|
case "ensure-session": return ensureSession(p);
|
|
@@ -2718,6 +3439,7 @@ async function main() {
|
|
|
2718
3439
|
case "init-plan": return initPlan(p);
|
|
2719
3440
|
case "record-evidence": return recordEvidence(p);
|
|
2720
3441
|
case "record-gate-claim": return recordGateClaim(p);
|
|
3442
|
+
case "promote": return promote(p);
|
|
2721
3443
|
case "advance-state": return advanceState(p);
|
|
2722
3444
|
case "record-critique": return recordCritique(p);
|
|
2723
3445
|
case "import-critique": return importCritique(p);
|
|
@@ -2729,6 +3451,7 @@ async function main() {
|
|
|
2729
3451
|
case "trust-mcp": return trustMcp(p);
|
|
2730
3452
|
case "liveness": return liveness(p);
|
|
2731
3453
|
case "claim": return claimLookup(p);
|
|
3454
|
+
case "resolve-slug": return resolveSlugCmd(p);
|
|
2732
3455
|
case "seal-checkpoint": return sealCheckpoint(p);
|
|
2733
3456
|
case "publish-delivery": return publishDeliveryCmd(p);
|
|
2734
3457
|
default: die(`unknown command: ${p.command}`);
|