@kontourai/flow-agents 2.3.0 → 3.0.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 +16 -0
- package/.github/workflows/ci.yml +44 -0
- package/.github/workflows/trust-reconcile.yml +62 -4
- package/AGENTS.md +4 -4
- package/CHANGELOG.md +62 -0
- package/CONTEXT.md +35 -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/assignment-provider.d.ts +1 -0
- package/build/src/cli/assignment-provider.js +748 -0
- package/build/src/cli/console-learning-projection.js +3 -2
- package/build/src/cli/effective-assignment-provider-settings.d.ts +1 -0
- package/build/src/cli/effective-assignment-provider-settings.js +125 -0
- 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 +27 -7
- 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 +938 -168
- package/build/src/cli.js +6 -0
- 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 +24 -1
- package/console.telemetry.json +22 -0
- package/context/contracts/artifact-contract.md +40 -7
- package/context/contracts/assignment-provider-contract.md +239 -0
- package/context/contracts/builder-kit-workflow-state-contract.md +5 -2
- package/context/contracts/decision-registry-contract.md +265 -0
- package/context/contracts/delivery-contract.md +3 -1
- package/context/contracts/execution-contract.md +25 -0
- package/context/contracts/governance-adapter-contract.md +2 -0
- package/context/contracts/knowledge-store-contract.md +197 -0
- package/context/contracts/planning-contract.md +4 -1
- package/context/contracts/review-contract.md +7 -3
- package/context/contracts/sandbox-policy.md +2 -0
- package/context/contracts/standing-directives.md +13 -0
- package/context/contracts/verification-contract.md +11 -1
- package/context/contracts/work-item-contract.md +3 -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/context/settings/assignment-provider-settings.json +33 -0
- 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 +463 -0
- package/docs/agent-system-guidebook.md +3 -3
- package/docs/agent-usage-feedback-loop.md +6 -6
- package/docs/context-map.md +10 -7
- package/docs/decisions/decision-records.md +57 -0
- package/docs/decisions/index.md +17 -0
- package/docs/decisions/knowledge-store-provider.md +51 -0
- package/docs/decisions/model-routing.md +63 -0
- package/docs/decisions/promotion-gate.md +52 -0
- package/docs/decisions/standing-directives.md +66 -0
- package/docs/developer-architecture.md +1 -1
- package/docs/fixture-ownership.md +4 -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 +3 -2
- package/docs/workflow-usage-guide.md +260 -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 +71 -1
- package/evals/fixtures/assignment-provider/actor-a.json +6 -0
- package/evals/fixtures/assignment-provider/actor-b.json +6 -0
- package/evals/fixtures/assignment-provider/github-issue-claimed.json +27 -0
- package/evals/fixtures/assignment-provider/github-issue-unassigned.json +7 -0
- package/evals/fixtures/assignment-provider/liveness-fresh.json +9 -0
- package/evals/fixtures/assignment-provider/liveness-stale.json +9 -0
- 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_assignment_provider_github.sh +318 -0
- package/evals/integration/test_assignment_provider_local_file.sh +222 -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_critique_supersession_roundtrip.sh +182 -0
- 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 +23 -6
- package/evals/integration/test_pull_work_assignment_join.sh +132 -0
- package/evals/integration/test_pull_work_liveness_preflight.sh +394 -0
- package/evals/integration/test_pull_work_provider.sh +2 -2
- package/evals/integration/test_reconcile_soundness.sh +37 -13
- 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 +11 -10
- 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 +759 -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 +802 -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 +32 -0
- package/evals/static/test_decisions.sh +150 -0
- package/evals/static/test_flowdef_codeowners_coverage.sh +62 -0
- package/evals/static/test_knowledge_providers.sh +23 -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 +32 -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 +148 -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/knowledge/kit.json +35 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/CONTEXT.md +12 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/old-sprocket-shape.md +13 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/sprocket-shape.md +14 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/widget-format.md +14 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/learnings/fixture-learning.md +7 -0
- package/kits/knowledge/providers/conformance/fixtures/work-item/issues.json +30 -0
- package/kits/knowledge/providers/conformance/suite.test.js +125 -0
- package/kits/knowledge/providers/git-repo/index.js +236 -0
- package/kits/knowledge/providers/health/health-pass.test.js +99 -0
- package/kits/knowledge/providers/health/index.js +153 -0
- package/kits/knowledge/providers/index.js +24 -0
- package/kits/knowledge/providers/lib/model.js +91 -0
- package/kits/knowledge/providers/lib/schema-validate.js +119 -0
- package/kits/knowledge/providers/markdown-vault/index.js +169 -0
- package/kits/knowledge/providers/work-item/index.js +204 -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 +11 -5
- 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/assignment-provider-settings.schema.json +125 -0
- package/schemas/decision-record.schema.json +109 -0
- package/schemas/knowledge/edge.schema.json +54 -0
- package/schemas/knowledge/health-report.schema.json +45 -0
- package/schemas/knowledge/node.schema.json +49 -0
- package/schemas/knowledge/proposal.schema.json +53 -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 +957 -103
- 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/assignment-provider.ts +845 -0
- package/src/cli/console-learning-projection.ts +3 -2
- package/src/cli/effective-assignment-provider-settings.ts +112 -0
- 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 +27 -7
- 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 +933 -168
- package/src/cli.ts +6 -0
- 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 +24 -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,64 @@ 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
|
+
// #268: stamp a stable origin discriminator so checksFromBundle / critiquesFromBundle can
|
|
585
|
+
// distinguish check vs critique vs acceptance claims across round-trips even under --flow-id,
|
|
586
|
+
// where all three collapse onto the same declared claimType (and a command-less critique claim
|
|
587
|
+
// would otherwise be re-absorbed as a test_output check → permanent [not-run] divergence).
|
|
588
|
+
const claimMetadata = { origin: "check", check_kind: String(check.kind ?? "external"), ...(waiver ? { waiver } : {}), ...(promotionMeta ? { promotion: promotionMeta } : {}) };
|
|
357
589
|
const claimEvents = [];
|
|
358
590
|
if (evStatus) {
|
|
359
591
|
const evt = { id: `evt:${claimId}`, claimId, status: evStatus, actor: "flow-agents/workflow-sidecar", method: "validation", evidenceIds: [evId], createdAt: ts, verifiedAt: ts };
|
|
360
592
|
events.push(evt);
|
|
361
593
|
claimEvents.push(evt);
|
|
362
594
|
}
|
|
363
|
-
const evItem = { id: evId, claimId, evidenceType:
|
|
595
|
+
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
596
|
if (captured) {
|
|
365
597
|
evItem.sourceRef = `${slug}/command-log.jsonl`;
|
|
366
598
|
evItem.collectedBy = "flow-agents/evidence-capture";
|
|
367
599
|
evItem.execution = { runner: "bash", label: cmd, isError: captured.observedResult === "fail", ...(captured.exitCode != null ? { exitCode: captured.exitCode } : {}) };
|
|
368
600
|
}
|
|
601
|
+
else if (cmd && !waiver) {
|
|
602
|
+
// WS8 (ADR 0020): always stamp execution.label on command-backed checks so the CI
|
|
603
|
+
// reconciler has a stable key to match against the manifest, even when the local
|
|
604
|
+
// command-log capture did not happen to run this command. isError is derived from
|
|
605
|
+
// the check's own reported status (no captured exit code available in this path).
|
|
606
|
+
evItem.execution = { runner: "bash", label: cmd, isError: effectiveStatus !== "pass" };
|
|
607
|
+
}
|
|
369
608
|
evidenceItems.push(evItem);
|
|
370
609
|
// P-d: declared-only when active flow/step present (shadow retired); no-flow path unchanged.
|
|
371
610
|
// When record-gate-claim sets _gate_claim_expectation_id, pass it for exact lookup (ADR 0016 P-d Increment 2).
|
|
372
611
|
const declared = matchExpectsEntry("check", check.kind, typeof check._gate_claim_expectation_id === "string" ? check._gate_claim_expectation_id : undefined);
|
|
373
612
|
if (declared) {
|
|
374
613
|
// 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,
|
|
614
|
+
const declaredPolicy = ensurePolicy(declared.claimType, "high", [evClass.evidenceType]);
|
|
615
|
+
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
616
|
const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [evItem], events: claimEvents, policies: [declaredPolicy] });
|
|
378
617
|
claims.push({ ...declaredClaimObj, status: declaredStatus });
|
|
379
618
|
}
|
|
380
619
|
else {
|
|
381
620
|
// No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
|
|
382
|
-
const claimObj = { id: claimId, subjectType: "workflow-check", subjectId,
|
|
621
|
+
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
622
|
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [evItem], events: claimEvents, policies: [policy] });
|
|
384
623
|
claims.push({ ...claimObj, status: derivedStatus });
|
|
385
624
|
}
|
|
@@ -405,13 +644,13 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
|
|
|
405
644
|
if (declared) {
|
|
406
645
|
// Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
|
|
407
646
|
const declaredPolicy = ensurePolicy(declared.claimType, "high", []);
|
|
408
|
-
const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId,
|
|
647
|
+
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, metadata: { origin: "acceptance" } };
|
|
409
648
|
const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [], events: claimEvents, policies: [declaredPolicy] });
|
|
410
649
|
claims.push({ ...declaredClaimObj, status: declaredStatus });
|
|
411
650
|
}
|
|
412
651
|
else {
|
|
413
652
|
// No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
|
|
414
|
-
const claimObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId,
|
|
653
|
+
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, metadata: { origin: "acceptance" } };
|
|
415
654
|
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [], events: claimEvents, policies: [policy] });
|
|
416
655
|
claims.push({ ...claimObj, status: derivedStatus });
|
|
417
656
|
}
|
|
@@ -422,10 +661,23 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
|
|
|
422
661
|
continue;
|
|
423
662
|
const subjectId = `${slug}/${c.id}`;
|
|
424
663
|
const fieldOrBehavior = String(c.summary ?? c.verdict ?? c.id);
|
|
425
|
-
|
|
664
|
+
// #267/#282: a critique carrying `superseded_by` is retained as HISTORY — a prior write for
|
|
665
|
+
// this critique id that a later, same-reviewer critique resolved. It is preserved structurally
|
|
666
|
+
// (status "superseded" + first-class metadata.superseded_by), but is excluded from reconcile
|
|
667
|
+
// evaluation and from the "critique pass cannot include fail members" validator rule.
|
|
668
|
+
const supersededBy = typeof c.superseded_by === "string" && c.superseded_by.length > 0 ? c.superseded_by : null;
|
|
669
|
+
const critiqueReviewer = String(c.reviewer ?? "tool-code-reviewer");
|
|
670
|
+
const critiqueReviewedAt = String(c.reviewed_at ?? ts);
|
|
671
|
+
const critMeta = { origin: "critique", reviewer: critiqueReviewer, reviewed_at: critiqueReviewedAt, ...(supersededBy ? { superseded_by: supersededBy } : {}) };
|
|
672
|
+
// A superseded historical write gets a distinct, stable claimId so it co-exists with the live
|
|
673
|
+
// claim of the same critique id (never overwrites or duplicates it). The salt is reproducible
|
|
674
|
+
// across rebuilds because superseded_by + reviewed_at are preserved in metadata.
|
|
675
|
+
const claimIdSalt = supersededBy ? `${fieldOrBehavior}::superseded::${supersededBy}::${critiqueReviewedAt}` : fieldOrBehavior;
|
|
676
|
+
const claimId = generateClaimId(subjectId, "flow-agents.workflow", claimIdSalt);
|
|
426
677
|
const legacyClaimType = "workflow.critique.review";
|
|
427
678
|
const policy = ensurePolicy(legacyClaimType, "medium", []);
|
|
428
|
-
|
|
679
|
+
// A superseded write emits NO verification event (its status is "superseded" directly).
|
|
680
|
+
const evStatus = supersededBy ? null : critiqueToEventStatus(String(c.verdict ?? ""), c.findings ?? []);
|
|
429
681
|
const claimEvents = [];
|
|
430
682
|
if (evStatus) {
|
|
431
683
|
const evt = { id: `evt:${claimId}`, claimId, status: evStatus, actor: "flow-agents/workflow-sidecar", method: "validation", evidenceIds: [], createdAt: ts, verifiedAt: ts };
|
|
@@ -434,22 +686,21 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
|
|
|
434
686
|
}
|
|
435
687
|
// P-d: declared-only when active flow/step present (shadow retired); no-flow path unchanged.
|
|
436
688
|
const declared = matchExpectsEntry("critique");
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
689
|
+
const claimType = declared ? declared.claimType : legacyClaimType;
|
|
690
|
+
const subjectType = declared ? declared.subjectType : "workflow-critique";
|
|
691
|
+
const claimPolicy = declared ? ensurePolicy(declared.claimType, "medium", []) : policy;
|
|
692
|
+
const claimObj = { id: claimId, subjectType, subjectId, facet: "flow-agents.workflow", claimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: claimPolicy.id, metadata: critMeta };
|
|
693
|
+
if (supersededBy) {
|
|
694
|
+
// History: status is "superseded" directly (no verification event); excluded from evaluation.
|
|
695
|
+
claims.push({ ...claimObj, status: "superseded" });
|
|
443
696
|
}
|
|
444
697
|
else {
|
|
445
|
-
|
|
446
|
-
const claimObj = { id: claimId, subjectType: "workflow-critique", subjectId, surface: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: policy.id };
|
|
447
|
-
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [], events: claimEvents, policies: [policy] });
|
|
698
|
+
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [], events: claimEvents, policies: [claimPolicy] });
|
|
448
699
|
claims.push({ ...claimObj, status: derivedStatus });
|
|
449
700
|
}
|
|
450
701
|
}
|
|
451
702
|
return {
|
|
452
|
-
schemaVersion:
|
|
703
|
+
schemaVersion: 5,
|
|
453
704
|
source: `flow-agents/workflow-sidecar;statusFunctionVersion=${statusFunctionVersion}`,
|
|
454
705
|
claims,
|
|
455
706
|
evidence: evidenceItems,
|
|
@@ -485,7 +736,7 @@ export async function writeTrustBundle(dir, slug, timestamp, checks, criteria, c
|
|
|
485
736
|
} }).filter((x) => x !== null);
|
|
486
737
|
}
|
|
487
738
|
catch { /* no capture log — fine */ }
|
|
488
|
-
// ADR 0016 Abstraction A (P-d): pass the
|
|
739
|
+
// ADR 0016 Abstraction A (P-d): pass the runtime artifact root ONLY when current.json
|
|
489
740
|
// points to this session (scoped active-flow guard). If current.json.artifact_dir
|
|
490
741
|
// resolves to a different session, pass null — no active-flow claim mapping for this bundle.
|
|
491
742
|
const _flowAgentsDir = path.dirname(dir);
|
|
@@ -726,6 +977,19 @@ function definitionAcceptanceLines(markdown) {
|
|
|
726
977
|
}
|
|
727
978
|
return out;
|
|
728
979
|
}
|
|
980
|
+
/** Extract a top-level `name: value` markdown field's value (e.g. `branch: agent/x/y`), mirroring
|
|
981
|
+
* this file's own local-regex-parsing convention for section()/definitionAcceptanceLines() (a
|
|
982
|
+
* near-duplicate of validate-workflow-artifacts.ts's field()/section() helpers — this file
|
|
983
|
+
* already keeps its own local copy of that pattern rather than sharing a module). `name` is
|
|
984
|
+
* regex-escaped before interpolation (fix-plan iteration 1 F3), mirroring
|
|
985
|
+
* validate-workflow-artifacts.ts's own field() escaping verbatim — every current call site
|
|
986
|
+
* passes a fixed literal (e.g. "branch"), but escaping keeps a future non-literal name from
|
|
987
|
+
* silently changing match semantics via unescaped regex metacharacters. Returns "" when the
|
|
988
|
+
* field is absent. */
|
|
989
|
+
function markdownField(markdown, name) {
|
|
990
|
+
const re = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\s*(.+)$`, "m");
|
|
991
|
+
return re.exec(markdown)?.[1]?.trim() ?? "";
|
|
992
|
+
}
|
|
729
993
|
function parseCriterion(line, index) {
|
|
730
994
|
let text = line.replace(/^-\s+\[[ xX]\]\s*/, "").trim();
|
|
731
995
|
const m = /\s+-\s+Evidence:\s*(.+)$/i.exec(text);
|
|
@@ -770,14 +1034,20 @@ function validateAgentId(agent) {
|
|
|
770
1034
|
}
|
|
771
1035
|
/**
|
|
772
1036
|
* Find the repository root by walking upward from a starting directory to locate
|
|
773
|
-
* the nearest ancestor containing a kits/ subdirectory
|
|
774
|
-
*
|
|
775
|
-
*
|
|
1037
|
+
* the nearest ancestor containing a kits/ subdirectory, without falling back to
|
|
1038
|
+
* process.cwd(). Returns null when no kits/ ancestor is found within the walk
|
|
1039
|
+
* bound (e.g. a scratch/temp session directory with no repo ancestor at all).
|
|
776
1040
|
*
|
|
777
|
-
*
|
|
778
|
-
*
|
|
1041
|
+
* WS5 iteration-2 part 2: extracted so publishDelivery's repo-root resolution can
|
|
1042
|
+
* be fail-closed (see findRepoRootFromDir below and publishDelivery). A scratch
|
|
1043
|
+
* test session dir (mktemp -d, no kits/ ancestor) must never resolve to whatever
|
|
1044
|
+
* repo happens to be the current process's cwd — that previously let a throwaway
|
|
1045
|
+
* eval-local trust.bundle silently clobber this real repo's delivery/trust.bundle
|
|
1046
|
+
* when the eval was run from a checkout of this repo (see
|
|
1047
|
+
* evals/integration/test_checkpoint_signing.sh TEST 2 and the WS5 session findings
|
|
1048
|
+
* at .kontourai/flow-agents/ws5-governance-kit-slice1).
|
|
779
1049
|
*/
|
|
780
|
-
function
|
|
1050
|
+
function findRepoRootFromDirStrict(startDir) {
|
|
781
1051
|
let dir = startDir;
|
|
782
1052
|
for (let i = 0; i < 16; i++) {
|
|
783
1053
|
if (fs.existsSync(path.join(dir, "kits")))
|
|
@@ -787,7 +1057,23 @@ function findRepoRootFromDir(startDir) {
|
|
|
787
1057
|
break;
|
|
788
1058
|
dir = parent;
|
|
789
1059
|
}
|
|
790
|
-
return
|
|
1060
|
+
return null;
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* Find the repository root by walking upward from a starting directory to locate
|
|
1064
|
+
* the nearest ancestor containing a kits/ subdirectory. Mirrors flow-resolver.ts
|
|
1065
|
+
* findRepoRoot, but callable from workflow-sidecar.ts without re-importing the
|
|
1066
|
+
* internal helper. Falls back to process.cwd() when no kits/ ancestor is found —
|
|
1067
|
+
* appropriate for phase-map/first-step resolution (ADR 0016 Abstraction A, P-d),
|
|
1068
|
+
* where the caller is always invoked from within a real repo checkout.
|
|
1069
|
+
*
|
|
1070
|
+
* Do NOT use this cwd-falling-back variant for publishDelivery's repo-root
|
|
1071
|
+
* resolution — use findRepoRootFromDirStrict there instead, so a scratch/test
|
|
1072
|
+
* session dir with no repo ancestor fails closed (skips publish) rather than
|
|
1073
|
+
* silently trusting process.cwd(), which could be an unrelated real repo.
|
|
1074
|
+
*/
|
|
1075
|
+
function findRepoRootFromDir(startDir) {
|
|
1076
|
+
return findRepoRootFromDirStrict(startDir) ?? process.cwd();
|
|
791
1077
|
}
|
|
792
1078
|
/**
|
|
793
1079
|
* Resolve the first step id from a FlowDefinition's steps[] list.
|
|
@@ -824,7 +1110,11 @@ function resolveFirstStep(flowId, repoRoot) {
|
|
|
824
1110
|
return null;
|
|
825
1111
|
}
|
|
826
1112
|
}
|
|
827
|
-
function writeCurrent(root, dir, timestamp, owner, source, flowId, stepId) {
|
|
1113
|
+
function writeCurrent(root, dir, timestamp, owner, source, flowId, stepId, adHocReason) {
|
|
1114
|
+
// #289: mirror the active session's already-recorded branch (state.json.branch) into
|
|
1115
|
+
// current.json so consumers of current.json (which has no schema of its own — not one of the
|
|
1116
|
+
// 9 schemas under schemas/) see the routing branch without re-reading state.json separately.
|
|
1117
|
+
const branch = loadJson(path.join(dir, "state.json")).branch;
|
|
828
1118
|
writeJson(path.join(root, "current.json"), {
|
|
829
1119
|
schema_version: "1.0",
|
|
830
1120
|
active_slug: path.basename(dir),
|
|
@@ -833,11 +1123,17 @@ function writeCurrent(root, dir, timestamp, owner, source, flowId, stepId) {
|
|
|
833
1123
|
owner,
|
|
834
1124
|
source,
|
|
835
1125
|
active_agents: [],
|
|
1126
|
+
...(branch ? { branch } : {}),
|
|
836
1127
|
// ADR 0016 Abstraction A (P-a): optional FlowDefinition routing keys for the producer
|
|
837
1128
|
// and enforcer. Both fields are optional and backward-compatible — sessions without a
|
|
838
1129
|
// FlowDefinition omit them and fall through to the workflow.* claim type path.
|
|
839
1130
|
...(flowId ? { active_flow_id: flowId } : {}),
|
|
840
1131
|
...(stepId ? { active_step_id: stepId } : {}),
|
|
1132
|
+
// WS8 (AC12): sanctioned ad-hoc direct entry marker. Set when --step-id explicitly
|
|
1133
|
+
// targets a step other than the flow's resolved first step, so stop-goal-fit /
|
|
1134
|
+
// gate-review can distinguish an intentional direct entry (e.g. a planning-only
|
|
1135
|
+
// session that skips pull-work) from a stale/mis-stamped active_step_id.
|
|
1136
|
+
...(adHocReason ? { ad_hoc_entry: true, ad_hoc_reason: adHocReason } : {}),
|
|
841
1137
|
});
|
|
842
1138
|
}
|
|
843
1139
|
function loadCurrent(root) {
|
|
@@ -851,7 +1147,15 @@ function currentDir(root) {
|
|
|
851
1147
|
if (!c)
|
|
852
1148
|
return null;
|
|
853
1149
|
const dir = path.resolve(root, c.artifact_dir ?? c.active_slug ?? "");
|
|
854
|
-
|
|
1150
|
+
if (!fs.existsSync(dir))
|
|
1151
|
+
return null;
|
|
1152
|
+
try {
|
|
1153
|
+
requireArtifactDirUnderRoot(dir, root);
|
|
1154
|
+
}
|
|
1155
|
+
catch {
|
|
1156
|
+
return null;
|
|
1157
|
+
}
|
|
1158
|
+
return dir;
|
|
855
1159
|
}
|
|
856
1160
|
function updateCurrentAgent(root, dir, agentId, status, timestamp) {
|
|
857
1161
|
const cur = loadCurrent(root);
|
|
@@ -866,8 +1170,38 @@ function updateCurrentAgent(root, dir, agentId, status, timestamp) {
|
|
|
866
1170
|
}
|
|
867
1171
|
function initSidecars(dir, slug, sourceRequest, summary, nextAction, timestamp, markdown) {
|
|
868
1172
|
const criteria = markdown ? definitionAcceptanceLines(markdown).map(parseCriterion) : [];
|
|
1173
|
+
// #289/#309: `markdown` here is NOT always the session `<slug>--deliver.md` that
|
|
1174
|
+
// ensureSession seeds the `branch:` line into — initPlan is called against the tool-planner's
|
|
1175
|
+
// PLAN artifact (`<slug>--plan-work.md` etc.), a different file that typically carries no
|
|
1176
|
+
// `branch:` line at all. Since this function fully rewrites state.json (no merge with the
|
|
1177
|
+
// prior contents), naively re-deriving branch from `markdown` alone would clobber whatever
|
|
1178
|
+
// ensure-session already recorded there moments earlier (#309 regression). Resolve branch with
|
|
1179
|
+
// a three-tier fallback, preferring the most durable source first:
|
|
1180
|
+
// 1. EXISTING state.json.branch — once recorded, it is never re-derived or clobbered.
|
|
1181
|
+
// 2. The session's OWN canonical `<dir>/<slug>--deliver.md` on disk — the file ensureSession
|
|
1182
|
+
// always seeds the `branch:` line into, independent of whatever `markdown` was passed in.
|
|
1183
|
+
// Reading it directly (rather than trusting `markdown`) is what makes a repaired/backfilled
|
|
1184
|
+
// init-plan call (re-run against the same branch-less plan artifact, after a #309-era
|
|
1185
|
+
// session lost its state.json branch) recover the branch without a direct file edit.
|
|
1186
|
+
// 3. The `markdown` param itself (covers ensureSession's fresh-creation call, before
|
|
1187
|
+
// `<slug>--deliver.md` differs from `markdown`, and any direct/legacy caller that passes
|
|
1188
|
+
// the deliver markdown itself as the init-plan artifact).
|
|
1189
|
+
// This makes the branch survive every subsequent initSidecars call (init-plan, or a resumed
|
|
1190
|
+
// ensure-session) at this single choke point, without patching every other writer.
|
|
1191
|
+
const existingState = loadJson(path.join(dir, "state.json"));
|
|
1192
|
+
const existingBranch = existingState.branch;
|
|
1193
|
+
const deliverMdPath = path.join(dir, `${slug}--deliver.md`);
|
|
1194
|
+
const deliverBranch = fs.existsSync(deliverMdPath) ? markdownField(read(deliverMdPath), "branch") : "";
|
|
1195
|
+
const branch = existingBranch || deliverBranch || (markdown ? markdownField(markdown, "branch") : "");
|
|
1196
|
+
// #309 (scope addition): created_at is write-once, same class as the branch-drop bug above —
|
|
1197
|
+
// initSidecars fully rewrites state.json on every call (including a repair/backfill re-run of
|
|
1198
|
+
// init-plan against an already-existing session), so naively re-stamping created_at from the
|
|
1199
|
+
// current call's timestamp silently rewrites a session's original creation time. Preserve the
|
|
1200
|
+
// existing state.json's created_at when present; stamp only on true first-creation.
|
|
1201
|
+
// updated_at still reflects "now" on every call — that field is intentionally mutable.
|
|
869
1202
|
writeJson(path.join(dir, "state.json"), {
|
|
870
|
-
...sidecarBase(slug), status: "planned", phase: "planning", created_at: timestamp, updated_at: timestamp,
|
|
1203
|
+
...sidecarBase(slug), status: "planned", phase: "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
|
|
1204
|
+
...(branch ? { branch } : {}),
|
|
871
1205
|
artifact_paths: relArtifacts(dir),
|
|
872
1206
|
next_action: { status: "continue", summary: nextAction || summary },
|
|
873
1207
|
});
|
|
@@ -888,7 +1222,12 @@ function ensureSession(p) {
|
|
|
888
1222
|
const timestamp = opt(p, "timestamp", now());
|
|
889
1223
|
let md = fs.existsSync(path.join(dir, `${slug}--deliver.md`)) ? read(path.join(dir, `${slug}--deliver.md`)) : "";
|
|
890
1224
|
if (!md) {
|
|
891
|
-
|
|
1225
|
+
// #289: derive the routing branch ONLY on fresh session creation (this `if (!md)` guard).
|
|
1226
|
+
// An existing session's already-recorded `branch:` line is never touched — that whole branch
|
|
1227
|
+
// of code is skipped on a resumed/taken-over session, which is what makes ADR 0021 §5
|
|
1228
|
+
// takeover continuity true by construction (see Design Decision 3 in the plan).
|
|
1229
|
+
const branch = resolveSessionBranch(p, slug);
|
|
1230
|
+
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
1231
|
fs.writeFileSync(path.join(dir, `${slug}--deliver.md`), md);
|
|
893
1232
|
}
|
|
894
1233
|
if (!fs.existsSync(path.join(dir, "state.json")) || !fs.existsSync(path.join(dir, "acceptance.json")) || !fs.existsSync(path.join(dir, "handoff.json"))) {
|
|
@@ -902,14 +1241,28 @@ function ensureSession(p) {
|
|
|
902
1241
|
// ensure-session --flow-id builder.build produces a FlowDefinition-driven session even
|
|
903
1242
|
// before the first advance-state call.
|
|
904
1243
|
const flowId = opt(p, "flow-id");
|
|
905
|
-
|
|
1244
|
+
const explicitStep = opt(p, "step-id");
|
|
1245
|
+
let stepId = explicitStep;
|
|
1246
|
+
let adHocReason;
|
|
906
1247
|
if (flowId && !stepId) {
|
|
907
1248
|
const repoRoot = findRepoRootFromDir(dir);
|
|
908
1249
|
const firstStep = resolveFirstStep(flowId, repoRoot);
|
|
909
1250
|
if (firstStep)
|
|
910
1251
|
stepId = firstStep;
|
|
911
1252
|
}
|
|
912
|
-
|
|
1253
|
+
else if (flowId && explicitStep) {
|
|
1254
|
+
// WS8 (AC12): --step-id is the sanctioned ad-hoc direct-entry mechanism. When it names
|
|
1255
|
+
// a step other than the flow's resolved first step, record an explicit ad_hoc_entry
|
|
1256
|
+
// marker (with a reason) instead of silently letting the mis-stamp look like the
|
|
1257
|
+
// flow's normal first step. This is the root-cause fix for a planning-only session
|
|
1258
|
+
// whose active_step_id would otherwise default to builder.build's first step.
|
|
1259
|
+
const repoRoot = findRepoRootFromDir(dir);
|
|
1260
|
+
const firstStep = resolveFirstStep(flowId, repoRoot);
|
|
1261
|
+
if (firstStep && firstStep !== explicitStep) {
|
|
1262
|
+
adHocReason = opt(p, "ad-hoc-reason") || `direct entry at step "${explicitStep}" via --step-id (flow first step is "${firstStep}")`;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", flowId || undefined, stepId || undefined, adHocReason);
|
|
913
1266
|
console.log(dir);
|
|
914
1267
|
return 0;
|
|
915
1268
|
}
|
|
@@ -931,7 +1284,7 @@ function recordAgentEvent(p) {
|
|
|
931
1284
|
die("artifact directory does not exist");
|
|
932
1285
|
if (explicit && fs.lstatSync(dir).isSymbolicLink())
|
|
933
1286
|
die(`artifact directory must not be a symlink: ${dir}`);
|
|
934
|
-
if (hasExplicitRoot)
|
|
1287
|
+
if (hasExplicitRoot || !explicit)
|
|
935
1288
|
requireArtifactDirUnderRoot(dir, root);
|
|
936
1289
|
const timestamp = opt(p, "timestamp", now());
|
|
937
1290
|
const agent = validateAgentId(opt(p, "agent-id"));
|
|
@@ -1014,7 +1367,7 @@ export function normalizeCheck(raw) {
|
|
|
1014
1367
|
if (!check.id || !check.kind || !check.status || !check.summary)
|
|
1015
1368
|
die("check requires id, kind, status, and summary");
|
|
1016
1369
|
if (!checkKinds.has(check.kind))
|
|
1017
|
-
die("kind must be one of: build, types, lint, test, security, diff, browser, runtime, policy, external");
|
|
1370
|
+
die("kind must be one of: build, types, lint, test, command, security, diff, browser, runtime, policy, external");
|
|
1018
1371
|
if (!checkStatuses.has(check.status))
|
|
1019
1372
|
die("status must be one of: pass, fail, not_verified, skip");
|
|
1020
1373
|
if (Array.isArray(check.standard_refs))
|
|
@@ -1167,67 +1520,70 @@ export function writeState(dir, slug, status, phase, timestamp, summary, next =
|
|
|
1167
1520
|
// After 4c, evidence.json and critique.json are no longer written.
|
|
1168
1521
|
// Extract checks and critiques from the existing trust.bundle for callers that
|
|
1169
1522
|
// need to rebuild the bundle (e.g. record-critique, record-learning).
|
|
1170
|
-
//
|
|
1171
|
-
//
|
|
1172
|
-
//
|
|
1173
|
-
//
|
|
1174
|
-
// is active, the set contains the kit-typed claimTypes (e.g. "builder.verify.tests",
|
|
1175
|
-
// "builder.verify.policy-compliance") so round-trip helpers broaden their filters
|
|
1176
|
-
// to include declared claims alongside the legacy workflow.* ones.
|
|
1523
|
+
// #268/#344: buildTrustBundle stamps a stable origin discriminator ("check" | "acceptance" |
|
|
1524
|
+
// "critique") plus check_kind (for origin "check") on EVERY claim it writes. These stamps are
|
|
1525
|
+
// AUTHORITATIVE and the ONLY way checksFromBundle/critiquesFromBundle (and evidenceClean/
|
|
1526
|
+
// critiqueClean below) classify a claim.
|
|
1177
1527
|
//
|
|
1178
|
-
//
|
|
1179
|
-
//
|
|
1180
|
-
//
|
|
1181
|
-
//
|
|
1182
|
-
//
|
|
1183
|
-
//
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1528
|
+
// Hard cutover (owner directive, no legacy fallbacks): there is deliberately no claimType-
|
|
1529
|
+
// derivation fallback for unstamped claims. A prior version of this file fell back to
|
|
1530
|
+
// classifying an unstamped claim by claimType heuristic — that fallback WAS the #268 defect
|
|
1531
|
+
// kept reachable (a no-evidence declared claim silently re-absorbed as a command-less
|
|
1532
|
+
// test_output check, and a critique claim silently re-absorbed as a check, corrupting the
|
|
1533
|
+
// round-trip catastrophically under --flow-id). An unstamped claim means the bundle predates
|
|
1534
|
+
// #344 and must be regenerated, not silently reclassified — see requireStampedClaim below.
|
|
1535
|
+
function claimOrigin(claim) {
|
|
1536
|
+
const md = claim && claim.metadata;
|
|
1537
|
+
return md && typeof md === "object" && typeof md.origin === "string" && md.origin.length > 0 ? String(md.origin) : null;
|
|
1538
|
+
}
|
|
1539
|
+
// Fails loud — never silent, never a heuristic reclassification — when a claim in `dir`'s
|
|
1540
|
+
// trust.bundle lacks its metadata.origin stamp, or (for an origin==="check" claim) its
|
|
1541
|
+
// metadata.check_kind stamp. Names the session dir and the remedy so the caller can regenerate
|
|
1542
|
+
// a fresh, fully-stamped bundle instead of reading a pre-supersession one.
|
|
1543
|
+
function requireStampedClaim(claim, dir) {
|
|
1544
|
+
if (!claim || typeof claim !== "object")
|
|
1545
|
+
die(`trust.bundle in ${dir} contains a malformed claim entry — cannot read.`);
|
|
1546
|
+
const remedy = `re-record evidence to regenerate: npm run workflow:sidecar -- record-evidence ${dir} --verdict <verdict> --check-json <...>`;
|
|
1547
|
+
const origin = claimOrigin(claim);
|
|
1548
|
+
if (!origin) {
|
|
1549
|
+
die(`pre-supersession trust.bundle in ${dir}: claim '${claim.id ?? "<unknown>"}' has no metadata.origin stamp (this bundle predates #344's origin/check_kind stamping and cannot be read authoritatively) — ${remedy}`);
|
|
1550
|
+
}
|
|
1551
|
+
if (origin === "check") {
|
|
1552
|
+
const md = (claim.metadata && typeof claim.metadata === "object") ? claim.metadata : {};
|
|
1553
|
+
if (typeof md.check_kind !== "string" || md.check_kind.length === 0) {
|
|
1554
|
+
die(`pre-supersession trust.bundle in ${dir}: check claim '${claim.id ?? "<unknown>"}' has metadata.origin but no metadata.check_kind stamp (this bundle predates #344's stamping and cannot be read authoritatively) — ${remedy}`);
|
|
1555
|
+
}
|
|
1200
1556
|
}
|
|
1201
|
-
|
|
1202
|
-
if (!activeStep || activeStep.gateExpects.length === 0)
|
|
1203
|
-
return new Set();
|
|
1204
|
-
return new Set(activeStep.gateExpects.map((e) => e.bundle_claim.claimType));
|
|
1557
|
+
return origin;
|
|
1205
1558
|
}
|
|
1206
|
-
function checksFromBundle(dir
|
|
1559
|
+
function checksFromBundle(dir) {
|
|
1207
1560
|
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
1561
|
+
const allClaims = Array.isArray(bundle.claims) ? bundle.claims : [];
|
|
1562
|
+
// Validate stamps on every claim up front — any unstamped claim anywhere in the bundle marks
|
|
1563
|
+
// it pre-supersession, regardless of whether it is check/acceptance/critique-typed.
|
|
1564
|
+
for (const claim of allClaims)
|
|
1565
|
+
requireStampedClaim(claim, dir);
|
|
1208
1566
|
if (!Array.isArray(bundle.evidence))
|
|
1209
1567
|
return [];
|
|
1210
|
-
const allClaims = Array.isArray(bundle.claims) ? bundle.claims : [];
|
|
1211
1568
|
const claimById = new Map();
|
|
1212
1569
|
for (const c of allClaims)
|
|
1213
1570
|
if (c && c.id)
|
|
1214
1571
|
claimById.set(c.id, c);
|
|
1215
1572
|
const seen = new Set();
|
|
1216
1573
|
const checks = [];
|
|
1574
|
+
const kindOf = (claim) => String(claim.metadata.check_kind);
|
|
1217
1575
|
for (const ev of bundle.evidence) {
|
|
1218
1576
|
if (!ev || !ev.claimId)
|
|
1219
1577
|
continue;
|
|
1220
1578
|
const claim = claimById.get(ev.claimId);
|
|
1221
1579
|
if (!claim)
|
|
1222
1580
|
continue;
|
|
1223
|
-
|
|
1224
|
-
// ADR 0016 Step 0: broaden to include declared kit-typed claims alongside workflow.check.*
|
|
1225
|
-
if (!ct.startsWith("workflow.check.") && !declaredClaimTypes.has(ct))
|
|
1581
|
+
if (claimOrigin(claim) !== "check")
|
|
1226
1582
|
continue;
|
|
1227
1583
|
if (seen.has(ev.claimId))
|
|
1228
1584
|
continue;
|
|
1229
1585
|
seen.add(ev.claimId);
|
|
1230
|
-
const kind =
|
|
1586
|
+
const kind = kindOf(claim);
|
|
1231
1587
|
const status = claim.value ?? "not_verified";
|
|
1232
1588
|
const check = { id: String(claim.subjectId || "").split("/").pop() || ev.claimId, kind, status, summary: claim.fieldOrBehavior || "" };
|
|
1233
1589
|
if (ev.execution && typeof ev.execution.label === "string")
|
|
@@ -1236,50 +1592,88 @@ function checksFromBundle(dir, declaredClaimTypes = new Set()) {
|
|
|
1236
1592
|
check.evidenceType = ev.evidenceType;
|
|
1237
1593
|
checks.push(check);
|
|
1238
1594
|
}
|
|
1239
|
-
// Also include check claims that have no evidence item (surface_trust_refs style)
|
|
1595
|
+
// Also include check claims that have no evidence item (surface_trust_refs style).
|
|
1240
1596
|
for (const claim of allClaims) {
|
|
1241
1597
|
if (!claim)
|
|
1242
1598
|
continue;
|
|
1243
|
-
|
|
1244
|
-
// ADR 0016 Step 0: broaden to include declared kit-typed claims alongside workflow.check.*
|
|
1245
|
-
if (!ct.startsWith("workflow.check.") && !declaredClaimTypes.has(ct))
|
|
1599
|
+
if (claimOrigin(claim) !== "check")
|
|
1246
1600
|
continue;
|
|
1247
1601
|
if (seen.has(claim.id))
|
|
1248
1602
|
continue;
|
|
1249
1603
|
seen.add(claim.id);
|
|
1250
|
-
const kind =
|
|
1604
|
+
const kind = kindOf(claim);
|
|
1251
1605
|
checks.push({ id: String(claim.subjectId || "").split("/").pop() || claim.id, kind, status: claim.value ?? "not_verified", summary: claim.fieldOrBehavior || "" });
|
|
1252
1606
|
}
|
|
1253
1607
|
return checks;
|
|
1254
1608
|
}
|
|
1255
|
-
function critiquesFromBundle(dir
|
|
1609
|
+
function critiquesFromBundle(dir) {
|
|
1256
1610
|
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
1257
1611
|
if (!Array.isArray(bundle.claims))
|
|
1258
1612
|
return [];
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
//
|
|
1262
|
-
//
|
|
1263
|
-
|
|
1264
|
-
const critiqueClaims = bundle.claims.filter((c) => c && (c
|
|
1265
|
-
return critiqueClaims.map((c) =>
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1613
|
+
for (const c of bundle.claims)
|
|
1614
|
+
requireStampedClaim(c, dir);
|
|
1615
|
+
// A claim is a CRITIQUE when its origin is "critique" (authoritative — see requireStampedClaim
|
|
1616
|
+
// above). reviewer / reviewed_at / superseded_by are read back from metadata so supersession
|
|
1617
|
+
// (#267/#282) round-trips losslessly.
|
|
1618
|
+
const critiqueClaims = bundle.claims.filter((c) => c && claimOrigin(c) === "critique");
|
|
1619
|
+
return critiqueClaims.map((c) => {
|
|
1620
|
+
const md = (c.metadata && typeof c.metadata === "object") ? c.metadata : {};
|
|
1621
|
+
return {
|
|
1622
|
+
id: String(c.subjectId || "").split("/").pop() || c.id,
|
|
1623
|
+
verdict: c.value ?? "not_verified",
|
|
1624
|
+
summary: c.fieldOrBehavior || "",
|
|
1625
|
+
findings: [],
|
|
1626
|
+
reviewer: typeof md.reviewer === "string" ? md.reviewer : "tool-code-reviewer",
|
|
1627
|
+
reviewed_at: typeof md.reviewed_at === "string" ? md.reviewed_at : (c.updatedAt || c.createdAt || now()),
|
|
1628
|
+
artifact_refs: [],
|
|
1629
|
+
...(typeof md.superseded_by === "string" && md.superseded_by.length > 0 ? { superseded_by: md.superseded_by } : {}),
|
|
1630
|
+
};
|
|
1631
|
+
});
|
|
1274
1632
|
}
|
|
1275
1633
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1634
|
+
/**
|
|
1635
|
+
* WS8 (ADR 0020): parse the accepted-gap waiver flags. Both --accepted-gap-reason and
|
|
1636
|
+
* --waived-by are required together (an accepted gap with no justification or no
|
|
1637
|
+
* approver is refused — no silent/default waiver). Returns the waiver record to stamp
|
|
1638
|
+
* onto claim.metadata.waiver, or null when neither flag is present. Reuses the existing
|
|
1639
|
+
* accepted_gap -> assumed status mapping; adds no new canonical status value.
|
|
1640
|
+
*/
|
|
1641
|
+
function parseWaiver(p, ts) {
|
|
1642
|
+
const reason = opt(p, "accepted-gap-reason");
|
|
1643
|
+
const waivedBy = opt(p, "waived-by");
|
|
1644
|
+
if (!reason && !waivedBy)
|
|
1645
|
+
return null;
|
|
1646
|
+
if (!reason)
|
|
1647
|
+
die("--accepted-gap-reason is required when --waived-by is set (an accepted gap must carry its justification)");
|
|
1648
|
+
if (!waivedBy)
|
|
1649
|
+
die("--waived-by is required when --accepted-gap-reason is set (an accepted gap must name its approver)");
|
|
1650
|
+
return { reason, approved_by: waivedBy, approved_at: ts };
|
|
1651
|
+
}
|
|
1276
1652
|
async function recordEvidence(p) {
|
|
1277
1653
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
1278
1654
|
const verdict = opt(p, "verdict") || die("--verdict is required");
|
|
1279
1655
|
if (!verdicts.has(verdict))
|
|
1280
1656
|
die("verdict must be one of: pass, partial, fail, not_verified");
|
|
1281
1657
|
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
1282
|
-
const
|
|
1658
|
+
const _ts0 = opt(p, "timestamp", now());
|
|
1659
|
+
const _waiver = parseWaiver(p, _ts0);
|
|
1660
|
+
const _checksRaw = [...opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json"))), ...opts(p, "surface-trust-json").map(surfaceCheckFromArtifact)];
|
|
1661
|
+
// WS8 (AC4, iteration 2): a command-backed check reconciles against CI or fails — it can
|
|
1662
|
+
// NEVER be waived. Reject --accepted-gap-reason/--waived-by on any check whose evidence
|
|
1663
|
+
// classifies as test_output (build/types/lint/test/command, and security/browser/runtime
|
|
1664
|
+
// WHEN a command is present). Only session-local checks (attestation/observation/citation/
|
|
1665
|
+
// diff/policy) are waivable. The CI reconciler enforces the same rule server-side (a waiver
|
|
1666
|
+
// on test_output evidence is a divergence), so this producer-side guard is defense-in-depth,
|
|
1667
|
+
// not the sole gate. (ADR 0020)
|
|
1668
|
+
if (_waiver) {
|
|
1669
|
+
for (const c of _checksRaw) {
|
|
1670
|
+
const hasCmd = typeof c.command === "string" && String(c.command).trim().length > 0;
|
|
1671
|
+
if (classifyEvidence(c.kind, hasCmd).evidenceType === "test_output") {
|
|
1672
|
+
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)`);
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
const checks = _checksRaw.map((c) => _waiver ? { ...c, _waiver } : c);
|
|
1283
1677
|
if (!checks.length && opts(p, "surface-trust-json").length === 0)
|
|
1284
1678
|
die("record-evidence requires at least one --check-json or --surface-trust-json");
|
|
1285
1679
|
validateAcceptanceEvidenceRefs(dir);
|
|
@@ -1289,7 +1683,11 @@ async function recordEvidence(p) {
|
|
|
1289
1683
|
const _existingCriteria = Array.isArray(_existingAcceptance.criteria) ? _existingAcceptance.criteria : [];
|
|
1290
1684
|
const _criteriaStatus = verdict === "pass" ? "pass" : verdict === "fail" ? "fail" : "not_verified";
|
|
1291
1685
|
const _criteriaForBundle = _existingCriteria.map((c) => ({ ...c, status: _criteriaStatus }));
|
|
1292
|
-
|
|
1686
|
+
// #268: preserve any existing critique claims (including superseded history) instead of dropping
|
|
1687
|
+
// them — record-evidence previously hardcoded critiques:[] here, silently erasing finding history
|
|
1688
|
+
// whenever it ran after a critique existed.
|
|
1689
|
+
const _existingCritiques = critiquesFromBundle(dir);
|
|
1690
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, ts, checks, _criteriaForBundle, _existingCritiques));
|
|
1293
1691
|
const stateStatus = verdict === "pass" ? "verified" : verdict === "fail" ? "failed" : "not_verified";
|
|
1294
1692
|
writeState(dir, slug, stateStatus, "verification", ts, "Evidence recorded.");
|
|
1295
1693
|
return 0;
|
|
@@ -1377,11 +1775,100 @@ async function recordGateClaim(p) {
|
|
|
1377
1775
|
check.artifact_refs = evidenceRefs;
|
|
1378
1776
|
}
|
|
1379
1777
|
const checkNormalized = normalizeCheck(check);
|
|
1778
|
+
// WS8 (ADR 0020): honor the accepted-gap waiver flags for a gate claim too.
|
|
1779
|
+
const gateWaiver = parseWaiver(p, ts);
|
|
1780
|
+
if (gateWaiver)
|
|
1781
|
+
checkNormalized._waiver = gateWaiver;
|
|
1380
1782
|
// 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`);
|
|
1783
|
+
process.stderr.write(`[record-gate-claim] targeting ${activeStep.stepId}/${activeStep.gateId}/${targetExpectation.id} → claimType=${claimType} subjectType=${subjectType}${gateWaiver ? " (WAIVED accepted_gap)" : ""}\n`);
|
|
1382
1784
|
assertBundleWritten(await writeTrustBundle(dir, slug, ts, [checkNormalized], [], []));
|
|
1383
1785
|
return 0;
|
|
1384
1786
|
}
|
|
1787
|
+
/**
|
|
1788
|
+
* promote — the promote-then-archive gate (issue #312). Durable-residue extraction is
|
|
1789
|
+
* the archival act: this records WHAT durable residue was promoted WHERE and writes a
|
|
1790
|
+
* PROMOTION CLAIM into the session trust.bundle.
|
|
1791
|
+
*
|
|
1792
|
+
* Claim shape (reconcile-safe by construction): the promotion check is kind="policy",
|
|
1793
|
+
* so classifyEvidence maps it to evidenceType "policy_rule" (session-local, method
|
|
1794
|
+
* "auditability"). It carries NO command / execution.label, so it can NEVER require a
|
|
1795
|
+
* reconcile-manifest entry and can NEVER become a [not-run] / unbacked-command
|
|
1796
|
+
* divergence at CI trust-reconcile. Its status derives to `verified` from that
|
|
1797
|
+
* session-local policy_rule evidence item, so the reconciler classifies it session-local
|
|
1798
|
+
* and accepts it as an ATTESTED claim (exit 0) — never as a test_output claim that must
|
|
1799
|
+
* match the manifest. The _promotion marker rides onto claim.metadata.promotion (see
|
|
1800
|
+
* buildTrustBundle) so the archive gate and validators can detect it without new manifest
|
|
1801
|
+
* entries (R1).
|
|
1802
|
+
*
|
|
1803
|
+
* Modes:
|
|
1804
|
+
* promote <dir> --evidence-path <p> [--evidence-path <p> ...]
|
|
1805
|
+
* Records the durable doc paths written (docs/decisions/<slug>.md, CONTEXT.md,
|
|
1806
|
+
* docs/learnings/*, …). Each path MUST exist on disk at record time — a missing
|
|
1807
|
+
* path fails loud (no silent empty promotion).
|
|
1808
|
+
* promote <dir> --none --reason "<why nothing durable>"
|
|
1809
|
+
* An explicit, auditable no-residue promotion (R3).
|
|
1810
|
+
*/
|
|
1811
|
+
async function promote(p) {
|
|
1812
|
+
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
1813
|
+
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
1814
|
+
const ts = opt(p, "timestamp", now());
|
|
1815
|
+
const none = p.flags.has("none");
|
|
1816
|
+
const reason = opt(p, "reason");
|
|
1817
|
+
const repoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDir(dir);
|
|
1818
|
+
const rawPaths = opts(p, "evidence-path");
|
|
1819
|
+
if (none) {
|
|
1820
|
+
if (rawPaths.length)
|
|
1821
|
+
die("promote --none records a no-residue claim; do not also pass --evidence-path");
|
|
1822
|
+
if (!reason.trim())
|
|
1823
|
+
die("promote --none requires --reason \"<why nothing durable was promoted>\"");
|
|
1824
|
+
}
|
|
1825
|
+
else {
|
|
1826
|
+
if (!rawPaths.length)
|
|
1827
|
+
die("promote requires at least one --evidence-path <durable-doc-path> (or --none --reason \"<why>\" for an explicit no-residue promotion)");
|
|
1828
|
+
}
|
|
1829
|
+
// Every evidence ref MUST exist on disk at record time (fail loud otherwise). Store
|
|
1830
|
+
// repo-relative paths when the ref lives under the repo, so the claim is portable.
|
|
1831
|
+
const targets = [];
|
|
1832
|
+
for (const raw of rawPaths) {
|
|
1833
|
+
const abs = path.isAbsolute(raw) ? raw : path.resolve(repoRoot, raw);
|
|
1834
|
+
if (!fs.existsSync(abs))
|
|
1835
|
+
die(`promote --evidence-path does not exist on disk: ${raw} (resolved: ${abs}). Promotion evidence refs must point at durable docs that were actually written.`);
|
|
1836
|
+
const rel = path.relative(repoRoot, abs);
|
|
1837
|
+
targets.push(rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel : raw);
|
|
1838
|
+
}
|
|
1839
|
+
const promotionMarker = {
|
|
1840
|
+
schema_version: "1.0",
|
|
1841
|
+
none,
|
|
1842
|
+
...(none ? { reason } : {}),
|
|
1843
|
+
targets,
|
|
1844
|
+
promoted_at: ts,
|
|
1845
|
+
};
|
|
1846
|
+
const summary = opt(p, "summary") || (none
|
|
1847
|
+
? `Promotion (no durable residue): ${reason}`
|
|
1848
|
+
: `Promoted durable residue: ${targets.join(", ")}`);
|
|
1849
|
+
// Session-local promotion check: kind="policy" -> policy_rule evidence, no command.
|
|
1850
|
+
const promotionCheck = { id: "promotion", kind: "policy", status: "pass", summary, _promotion: promotionMarker };
|
|
1851
|
+
// Add the promotion claim WITHOUT dropping the session's existing verification
|
|
1852
|
+
// evidence/criteria/critiques (mirror record-critique's merge pattern). Drop any prior
|
|
1853
|
+
// "promotion" check so re-running promote is idempotent rather than duplicating.
|
|
1854
|
+
const existingChecks = checksFromBundle(dir).filter((c) => c.id !== "promotion");
|
|
1855
|
+
const _acc = loadJson(path.join(dir, "acceptance.json"));
|
|
1856
|
+
const criteria = Array.isArray(_acc.criteria) ? _acc.criteria : [];
|
|
1857
|
+
const critiques = critiquesFromBundle(dir);
|
|
1858
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, ts, [...existingChecks, promotionCheck], criteria, critiques));
|
|
1859
|
+
// Auditable record of what was promoted where (companion to the trust.bundle claim).
|
|
1860
|
+
writeJson(path.join(dir, "promotion.json"), { ...sidecarBase(slug), ...promotionMarker, summary });
|
|
1861
|
+
// Optionally republish so delivery/trust.bundle carries the promotion claim for CI.
|
|
1862
|
+
if (p.flags.has("publish")) {
|
|
1863
|
+
const publishRepoRoot = opt(p, "publish-repo-root") ? path.resolve(opt(p, "publish-repo-root")) : findRepoRootFromDirStrict(dir);
|
|
1864
|
+
await publishDelivery(dir, publishRepoRoot).catch((err) => {
|
|
1865
|
+
process.stderr.write(`[promote] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1866
|
+
});
|
|
1867
|
+
}
|
|
1868
|
+
process.stderr.write(`[promote] recorded ${none ? "no-residue" : targets.length + " durable ref(s)"} promotion claim for ${slug}\n`);
|
|
1869
|
+
printJson({ ok: true, slug, none, targets, promotion_claim: "trust.bundle" });
|
|
1870
|
+
return 0;
|
|
1871
|
+
}
|
|
1385
1872
|
async function advanceState(p) {
|
|
1386
1873
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
1387
1874
|
const status = opt(p, "status");
|
|
@@ -1438,7 +1925,14 @@ async function advanceState(p) {
|
|
|
1438
1925
|
if (status === "delivered") {
|
|
1439
1926
|
await sealTrustCheckpoint(dir, slug, timestamp, status, "release").catch(() => { });
|
|
1440
1927
|
// Publish delivery bundle: best-effort copy to delivery/ for CI trust-reconcile.
|
|
1441
|
-
|
|
1928
|
+
// Fail-closed repo-root resolution (findRepoRootFromDirStrict, no cwd fallback) — see
|
|
1929
|
+
// publishDelivery below. An explicit --repo-root (e.g. for a scratch/test artifact dir
|
|
1930
|
+
// with no kits/ ancestor of its own) always wins, matching publishDeliveryCmd. Failures
|
|
1931
|
+
// are visible (stderr warning), not silently swallowed.
|
|
1932
|
+
const publishRepoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
|
|
1933
|
+
await publishDelivery(dir, publishRepoRoot).catch((err) => {
|
|
1934
|
+
process.stderr.write(`[advance-state] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1935
|
+
});
|
|
1442
1936
|
}
|
|
1443
1937
|
return 0;
|
|
1444
1938
|
}
|
|
@@ -1454,14 +1948,31 @@ async function recordCritique(p) {
|
|
|
1454
1948
|
// Fall back to critique.json for legacy sessions that still have it on disk.
|
|
1455
1949
|
const existingCritiqueJson = loadJson(path.join(dir, "critique.json"), { critiques: [] });
|
|
1456
1950
|
const legacyCritiques = Array.isArray(existingCritiqueJson.critiques) ? existingCritiqueJson.critiques : [];
|
|
1457
|
-
const
|
|
1458
|
-
const bundleCritiques = legacyCritiques.length === 0 ? critiquesFromBundle(dir, _dctCritique) : legacyCritiques;
|
|
1951
|
+
const bundleCritiques = legacyCritiques.length === 0 ? critiquesFromBundle(dir) : legacyCritiques;
|
|
1459
1952
|
const critique = { id: opt(p, "id") || "review", reviewer: opt(p, "reviewer", "tool-code-reviewer"), reviewed_at: opt(p, "timestamp", now()), verdict: opt(p, "verdict", "pass"), summary: opt(p, "summary"), artifact_refs: opts(p, "artifact-ref"), findings: opts(p, "finding-json").map((v) => normalizeFinding(parseJson(v, "--finding-json"))) };
|
|
1460
|
-
const critiques = [...bundleCritiques, critique];
|
|
1461
1953
|
if (critique.verdict === "pass" && critique.findings.some((f) => f.status === "open"))
|
|
1462
1954
|
die("required critique must pass");
|
|
1955
|
+
// #267/#282: supersede-by-critique-id. The latest write for a critique id wins for
|
|
1956
|
+
// reconcile / status / validator purposes; each prior LIVE write for the same id is RETAINED as
|
|
1957
|
+
// history (status "superseded", first-class metadata.superseded_by) but excluded from evaluation.
|
|
1958
|
+
// Supersession is REVIEWER-SCOPED (anti-gaming): a write may only supersede a prior live critique
|
|
1959
|
+
// of the same id written by the SAME reviewer — a different reviewer's disputed finding is never
|
|
1960
|
+
// buried, so it stays live and continues to block. DOCUMENTED GAP: reviewer identity is the
|
|
1961
|
+
// free-form --reviewer string (with a default); there is no cryptographic worker-vs-reviewer
|
|
1962
|
+
// distinction yet — that lands with the runtime actor-identity slice (#287/#290). Same-reviewer-
|
|
1963
|
+
// string scoping is the strongest honest enforcement available today and matches the granularity
|
|
1964
|
+
// the critique record already has.
|
|
1965
|
+
const _supersedeMarker = `${critique.id}@${critique.reviewed_at}`;
|
|
1966
|
+
const _mergedCritiques = bundleCritiques.map((e) => {
|
|
1967
|
+
const eSuperseded = typeof e.superseded_by === "string" && e.superseded_by.length > 0;
|
|
1968
|
+
const eReviewer = String(e.reviewer ?? "tool-code-reviewer");
|
|
1969
|
+
if (e.id === critique.id && !eSuperseded && eReviewer === critique.reviewer)
|
|
1970
|
+
return { ...e, superseded_by: _supersedeMarker };
|
|
1971
|
+
return e;
|
|
1972
|
+
});
|
|
1973
|
+
const critiques = [..._mergedCritiques, critique];
|
|
1463
1974
|
// Phase 4c: build bundle from raw inputs; read checks from trust.bundle (evidence.json no longer written).
|
|
1464
|
-
const _critiqueEvChecks = checksFromBundle(dir
|
|
1975
|
+
const _critiqueEvChecks = checksFromBundle(dir);
|
|
1465
1976
|
const _critiqueAccCriteria = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
|
|
1466
1977
|
assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueEvChecks, _critiqueAccCriteria, critiques));
|
|
1467
1978
|
return 0;
|
|
@@ -1511,7 +2022,14 @@ async function recordRelease(p) {
|
|
|
1511
2022
|
// Trust checkpoint: seal at the "delivered" moment (the natural terminal mark for record-release).
|
|
1512
2023
|
await sealTrustCheckpoint(dir, slug, payload.updated_at, "delivered", "release").catch(() => { });
|
|
1513
2024
|
// Publish delivery bundle: best-effort copy to delivery/ for CI trust-reconcile.
|
|
1514
|
-
|
|
2025
|
+
// Fail-closed repo-root resolution (findRepoRootFromDirStrict, no cwd fallback) — see
|
|
2026
|
+
// publishDelivery below. An explicit --repo-root (e.g. for a scratch/test artifact dir with
|
|
2027
|
+
// no kits/ ancestor of its own) always wins, matching publishDeliveryCmd. Failures are
|
|
2028
|
+
// visible (stderr warning), not silently swallowed.
|
|
2029
|
+
const publishRepoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
|
|
2030
|
+
await publishDelivery(dir, publishRepoRoot).catch((err) => {
|
|
2031
|
+
process.stderr.write(`[record-release] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
2032
|
+
});
|
|
1515
2033
|
return 0;
|
|
1516
2034
|
}
|
|
1517
2035
|
// ─── Trust Checkpoint (Increment A) ──────────────────────────────────────────
|
|
@@ -1713,7 +2231,7 @@ async function sealCheckpoint(p) {
|
|
|
1713
2231
|
}
|
|
1714
2232
|
// ─── Publish Delivery Bundle ──────────────────────────────────────────────────
|
|
1715
2233
|
// Copies the session's trust.bundle (+ checkpoint companions) from the gitignored
|
|
1716
|
-
// session artifact dir (.flow-agents/<slug>/) to the committed delivery/ transport
|
|
2234
|
+
// session artifact dir (.kontourai/flow-agents/<slug>/) to the committed delivery/ transport
|
|
1717
2235
|
// path so the CI trust-reconcile job can reconcile it against fresh CI results.
|
|
1718
2236
|
//
|
|
1719
2237
|
// Fail-soft: if trust.bundle is absent (no evidence recorded yet), does nothing.
|
|
@@ -1727,13 +2245,24 @@ async function sealCheckpoint(p) {
|
|
|
1727
2245
|
* trust.checkpoint.intoto.json / trust.checkpoint.sig.json from the
|
|
1728
2246
|
* session artifact dir to <repoRoot>/delivery/.
|
|
1729
2247
|
*
|
|
1730
|
-
* Fail-soft: if trust.bundle is absent, returns without throwing.
|
|
2248
|
+
* Fail-soft on a missing bundle: if trust.bundle is absent, returns without throwing.
|
|
2249
|
+
* Fail-CLOSED on repo-root resolution: repoRoot must be a real, resolved kits/ ancestor
|
|
2250
|
+
* (see findRepoRootFromDirStrict) — null (no ancestor found) skips the publish with a
|
|
2251
|
+
* visible warning instead of writing to whatever process.cwd() happens to be. This
|
|
2252
|
+
* prevents a scratch/test session dir (no kits/ ancestor) from silently clobbering an
|
|
2253
|
+
* unrelated real repo's delivery/trust.bundle when invoked with that repo as cwd (see
|
|
2254
|
+
* evals/integration/test_checkpoint_signing.sh TEST 2 and the WS5 session findings at
|
|
2255
|
+
* .kontourai/flow-agents/ws5-governance-kit-slice1 for the root cause this fixes).
|
|
1731
2256
|
* Idempotent: overwrites on re-delivery.
|
|
1732
2257
|
*/
|
|
1733
2258
|
export async function publishDelivery(dir, repoRoot) {
|
|
1734
2259
|
const bundleSrc = path.join(dir, "trust.bundle");
|
|
1735
2260
|
if (!fs.existsSync(bundleSrc))
|
|
1736
2261
|
return; // no bundle — skip gracefully
|
|
2262
|
+
if (!repoRoot) {
|
|
2263
|
+
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`);
|
|
2264
|
+
return;
|
|
2265
|
+
}
|
|
1737
2266
|
const deliveryDir = path.join(repoRoot, "delivery");
|
|
1738
2267
|
fs.mkdirSync(deliveryDir, { recursive: true });
|
|
1739
2268
|
// Required: trust.bundle (the CI anchor)
|
|
@@ -1764,7 +2293,10 @@ export async function publishDelivery(dir, repoRoot) {
|
|
|
1764
2293
|
*/
|
|
1765
2294
|
async function publishDeliveryCmd(p) {
|
|
1766
2295
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
1767
|
-
|
|
2296
|
+
// Fail-closed: an explicit --repo-root always wins; otherwise resolve strictly (no
|
|
2297
|
+
// process.cwd() fallback) so a scratch/test artifact dir cannot accidentally publish
|
|
2298
|
+
// into whichever repo happens to be the current working directory.
|
|
2299
|
+
const repoRoot = opt(p, "repo-root") || findRepoRootFromDirStrict(dir);
|
|
1768
2300
|
await publishDelivery(dir, repoRoot);
|
|
1769
2301
|
return 0;
|
|
1770
2302
|
}
|
|
@@ -1836,26 +2368,23 @@ async function recordLearning(p) {
|
|
|
1836
2368
|
writeJson(path.join(dir, "learning.json"), { ...sidecarBase(slug), status, updated_at: timestamp, records });
|
|
1837
2369
|
writeState(dir, slug, "accepted", "learning", timestamp, opt(p, "summary"));
|
|
1838
2370
|
// Phase 4c: build bundle from raw inputs; read checks/critiques from trust.bundle (bespoke sidecars no longer written).
|
|
1839
|
-
//
|
|
1840
|
-
const
|
|
1841
|
-
const _learningChecks = checksFromBundle(dir, _dctLearning);
|
|
2371
|
+
// #268/#344: declared builder.* claims survive the round-trip via their authoritative origin stamp.
|
|
2372
|
+
const _learningChecks = checksFromBundle(dir);
|
|
1842
2373
|
const _learningCriteria = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
|
|
1843
|
-
const _learningCritiques = critiquesFromBundle(dir
|
|
2374
|
+
const _learningCritiques = critiquesFromBundle(dir);
|
|
1844
2375
|
assertBundleWritten(await writeTrustBundle(dir, slug, timestamp, _learningChecks, _learningCriteria, _learningCritiques));
|
|
1845
2376
|
return 0;
|
|
1846
2377
|
}
|
|
1847
|
-
function evidenceClean(dir
|
|
1848
|
-
// Phase 4c: read from trust.bundle (sole verification artifact); fall back to evidence.json for
|
|
1849
|
-
//
|
|
1850
|
-
//
|
|
2378
|
+
function evidenceClean(dir) {
|
|
2379
|
+
// Phase 4c: read from trust.bundle (sole verification artifact); fall back to evidence.json for
|
|
2380
|
+
// legacy (pre-bundle-era) sessions that never wrote a trust.bundle at all — unrelated to origin
|
|
2381
|
+
// stamping. When a trust.bundle IS present, every claim must be stamped (requireStampedClaim);
|
|
2382
|
+
// there is no claimType-derivation fallback for an unstamped claim (#268/#344).
|
|
1851
2383
|
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
1852
2384
|
if (Array.isArray(bundle.claims)) {
|
|
1853
|
-
const
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
const ct = String(c.claimType || "");
|
|
1857
|
-
return ct.startsWith("workflow.check.") || declaredClaimTypes.has(ct);
|
|
1858
|
-
});
|
|
2385
|
+
for (const c of bundle.claims)
|
|
2386
|
+
requireStampedClaim(c, dir);
|
|
2387
|
+
const checkClaims = bundle.claims.filter((c) => c && claimOrigin(c) === "check");
|
|
1859
2388
|
if (checkClaims.length === 0)
|
|
1860
2389
|
return false;
|
|
1861
2390
|
return checkClaims.every((c) => {
|
|
@@ -1863,7 +2392,7 @@ function evidenceClean(dir, declaredClaimTypes = new Set()) {
|
|
|
1863
2392
|
return v === "pass" || v === "skip";
|
|
1864
2393
|
});
|
|
1865
2394
|
}
|
|
1866
|
-
// Legacy fallback: evidence.json
|
|
2395
|
+
// Legacy fallback: evidence.json (pre-bundle-era sessions with no trust.bundle at all)
|
|
1867
2396
|
const e = loadJson(path.join(dir, "evidence.json"), {});
|
|
1868
2397
|
return e.verdict === "pass" && Array.isArray(e.checks) && e.checks.length > 0 && e.checks.every((c) => {
|
|
1869
2398
|
if (!(c.status === "pass" || c.status === "skip"))
|
|
@@ -1871,13 +2400,23 @@ function evidenceClean(dir, declaredClaimTypes = new Set()) {
|
|
|
1871
2400
|
return !Array.isArray(c.standard_refs) || c.standard_refs.every((r) => ["junit", "sarif", "coverage", "veritas"].includes(r.standard));
|
|
1872
2401
|
});
|
|
1873
2402
|
}
|
|
1874
|
-
function critiqueClean(dir
|
|
1875
|
-
// Phase 4c: read from trust.bundle (sole verification artifact); fall back to critique.json for
|
|
1876
|
-
//
|
|
1877
|
-
// (
|
|
2403
|
+
function critiqueClean(dir) {
|
|
2404
|
+
// Phase 4c: read from trust.bundle (sole verification artifact); fall back to critique.json for
|
|
2405
|
+
// legacy (pre-bundle-era) sessions — unrelated to origin stamping. When a trust.bundle IS
|
|
2406
|
+
// present, every claim must be stamped (requireStampedClaim); no claimType-derivation fallback
|
|
2407
|
+
// for an unstamped claim (#268/#344).
|
|
1878
2408
|
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
1879
2409
|
if (Array.isArray(bundle.claims)) {
|
|
1880
|
-
const
|
|
2410
|
+
for (const c of bundle.claims)
|
|
2411
|
+
requireStampedClaim(c, dir);
|
|
2412
|
+
const critiqueClaims = bundle.claims.filter((c) => {
|
|
2413
|
+
if (!c)
|
|
2414
|
+
return false;
|
|
2415
|
+
// #267/#282: superseded history is not evaluated for cleanliness.
|
|
2416
|
+
if (c.metadata && typeof c.metadata === "object" && c.metadata.superseded_by)
|
|
2417
|
+
return false;
|
|
2418
|
+
return claimOrigin(c) === "critique";
|
|
2419
|
+
});
|
|
1881
2420
|
if (critiqueClaims.length === 0)
|
|
1882
2421
|
return false; // no critique written yet
|
|
1883
2422
|
return critiqueClaims.every((c) => {
|
|
@@ -1919,10 +2458,9 @@ async function dogfoodPass(p) {
|
|
|
1919
2458
|
if (checks.some((c) => c.status !== "pass" && c.status !== "skip"))
|
|
1920
2459
|
die("clean evidence requires all non-skipped checks to pass");
|
|
1921
2460
|
// Phase 4c: evidence check reads from trust.bundle (sole verification artifact); legacy evidence.json fallback in evidenceClean.
|
|
1922
|
-
//
|
|
1923
|
-
const
|
|
1924
|
-
const
|
|
1925
|
-
const _hasLegacyEvidence = fs.existsSync(path.join(dir, "evidence.json")) && evidenceClean(dir, _dctDogfood);
|
|
2461
|
+
// #268/#344: builder.* check/critique claims count as clean evidence via their authoritative origin stamp.
|
|
2462
|
+
const _hasBundleEvidence = fs.existsSync(path.join(dir, "trust.bundle")) && evidenceClean(dir);
|
|
2463
|
+
const _hasLegacyEvidence = fs.existsSync(path.join(dir, "evidence.json")) && evidenceClean(dir);
|
|
1926
2464
|
if (!_hasBundleEvidence && !_hasLegacyEvidence && fs.existsSync(path.join(dir, "trust.bundle")))
|
|
1927
2465
|
die("cannot mark clean without passing evidence");
|
|
1928
2466
|
if (!_hasBundleEvidence && !_hasLegacyEvidence && !fs.existsSync(path.join(dir, "trust.bundle")) && fs.existsSync(path.join(dir, "evidence.json")))
|
|
@@ -1935,10 +2473,10 @@ async function dogfoodPass(p) {
|
|
|
1935
2473
|
normalizeFinding(parseJson(value, "--finding-json"));
|
|
1936
2474
|
if (newCritiqueVerdict !== "pass")
|
|
1937
2475
|
die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
|
|
1938
|
-
if (!opt(p, "critique-id") && !critiqueClean(dir
|
|
2476
|
+
if (!opt(p, "critique-id") && !critiqueClean(dir))
|
|
1939
2477
|
die("requires passing critique");
|
|
1940
2478
|
// Phase 4c: if existing state has a dirty critique (in bundle or legacy critique.json), block even when adding a new critique-id.
|
|
1941
|
-
if (!critiqueClean(dir
|
|
2479
|
+
if (!critiqueClean(dir) && (fs.existsSync(path.join(dir, "trust.bundle")) || fs.existsSync(path.join(dir, "critique.json"))))
|
|
1942
2480
|
die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
|
|
1943
2481
|
}
|
|
1944
2482
|
}
|
|
@@ -1971,13 +2509,13 @@ async function dogfoodPass(p) {
|
|
|
1971
2509
|
return 0;
|
|
1972
2510
|
}
|
|
1973
2511
|
/**
|
|
1974
|
-
* Read the gate block signal from .flow-agents/.goal-fit-block-streak.json
|
|
2512
|
+
* Read the gate block signal from .kontourai/flow-agents/.goal-fit-block-streak.json
|
|
1975
2513
|
* (written by scripts/hooks/stop-goal-fit.js when block mode fires).
|
|
1976
2514
|
* The file sits at <artifact-root>/.goal-fit-block-streak.json — one level
|
|
1977
2515
|
* above the session artifact dir. Fail-open: returns { blocked: false } when
|
|
1978
2516
|
* the file is absent or unreadable.
|
|
1979
2517
|
*
|
|
1980
|
-
* @param artifactRoot The
|
|
2518
|
+
* @param artifactRoot The runtime artifact root dir (parent of session slug dir).
|
|
1981
2519
|
*/
|
|
1982
2520
|
export function readGateBlockSignal(artifactRoot) {
|
|
1983
2521
|
const streakFile = path.join(artifactRoot, ".goal-fit-block-streak.json");
|
|
@@ -2183,7 +2721,7 @@ export function buildGateInquiryRecords(bundle, blockSignal, slug, expectedCrite
|
|
|
2183
2721
|
*
|
|
2184
2722
|
* The block signal is read from <artifact-root>/.goal-fit-block-streak.json,
|
|
2185
2723
|
* written by scripts/hooks/stop-goal-fit.js when block mode fires. The file
|
|
2186
|
-
* lives one level above the session slug dir (the
|
|
2724
|
+
* lives one level above the session slug dir (the runtime artifact root).
|
|
2187
2725
|
*
|
|
2188
2726
|
* If @kontourai/surface is unavailable, logs a warning and returns 0
|
|
2189
2727
|
* (fail-open — no bespoke fork fallback).
|
|
@@ -2206,7 +2744,7 @@ async function gateReview(p) {
|
|
|
2206
2744
|
return 0;
|
|
2207
2745
|
}
|
|
2208
2746
|
const bundle = JSON.parse(fs.readFileSync(bundlePath, "utf8"));
|
|
2209
|
-
// Read gate block signal from
|
|
2747
|
+
// Read gate block signal from the runtime artifact root (one level above session dir)
|
|
2210
2748
|
const artifactRoot = path.dirname(dir);
|
|
2211
2749
|
const blockSignal = readGateBlockSignal(artifactRoot);
|
|
2212
2750
|
// Enumerate expected criterion IDs: primary = bundle claims (workflow.acceptance.criterion),
|
|
@@ -2370,7 +2908,7 @@ function trustMcp(p) {
|
|
|
2370
2908
|
if (mode === "print") {
|
|
2371
2909
|
console.log(JSON.stringify({ mcpServers: { [TRUST_MCP_SERVER]: trustMcpRegistration() } }, null, 2));
|
|
2372
2910
|
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`);
|
|
2911
|
+
process.stderr.write(`# To view a task's trust inline, call surface_summary with path=<.kontourai/flow-agents/<slug>/trust.bundle>.\n`);
|
|
2374
2912
|
return 0;
|
|
2375
2913
|
}
|
|
2376
2914
|
if (mode !== "enable" && mode !== "disable")
|
|
@@ -2422,12 +2960,19 @@ const LIVENESS_POLICY = {
|
|
|
2422
2960
|
conflictRules: [],
|
|
2423
2961
|
impactLevel: "medium",
|
|
2424
2962
|
};
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2963
|
+
/**
|
|
2964
|
+
* Delegate to the shared pure-CJS writer (scripts/hooks/lib/liveness-write.js), mirroring the
|
|
2965
|
+
* createRequire pattern used by loadActorIdentityHelper() below. Deliberately NO inline duplicate
|
|
2966
|
+
* fallback — the whole point of #288 Wave 2 Task 2.1 is one writer shared by the CLI and the
|
|
2967
|
+
* hook wrappers' tool-activity heartbeat, not two copies of the append shape that can drift.
|
|
2968
|
+
*/
|
|
2969
|
+
function loadLivenessWriteHelper() {
|
|
2970
|
+
const _req = createRequire(import.meta.url);
|
|
2971
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-write.js");
|
|
2972
|
+
return _req(helperPath);
|
|
2430
2973
|
}
|
|
2974
|
+
function livenessStreamFile(root) { return loadLivenessWriteHelper().livenessStreamFile(root); }
|
|
2975
|
+
function appendLivenessEvent(root, evt) { loadLivenessWriteHelper().appendLivenessEvent(root, evt); }
|
|
2431
2976
|
function readLivenessEvents(root) {
|
|
2432
2977
|
// Delegate to the shared pure-CJS helper (scripts/hooks/lib/liveness-read.js).
|
|
2433
2978
|
// Using createRequire so the ESM sidecar can load a CJS module without bundling it.
|
|
@@ -2463,39 +3008,158 @@ function livenessLabel(status) {
|
|
|
2463
3008
|
return "superseded";
|
|
2464
3009
|
return status;
|
|
2465
3010
|
}
|
|
2466
|
-
// ─── ADR 0012 lifecycle-driven liveness (opt-
|
|
3011
|
+
// ─── ADR 0012 lifecycle-driven liveness (default-on; opt-out via FLOW_AGENTS_LIVENESS) ──
|
|
2467
3012
|
// init-plan claims the work-item; advance-state heartbeats (or releases on terminal),
|
|
2468
3013
|
// so the workflow lifecycle itself maintains the liveness claim — no manual liveness calls.
|
|
2469
3014
|
// Additive + fail-open: a liveness-emit failure never affects the workflow command.
|
|
2470
3015
|
const LIVENESS_TERMINAL = new Set(["delivered", "accepted", "archived"]);
|
|
2471
|
-
|
|
2472
|
-
|
|
3016
|
+
/**
|
|
3017
|
+
* Delegate to the shared pure-CJS resolver (scripts/hooks/lib/actor-identity.js), mirroring the
|
|
3018
|
+
* createRequire pattern used by readLivenessEvents() above. Deliberately NO inline duplicate
|
|
3019
|
+
* fallback: if the module fails to load, that failure itself must surface as an unresolved actor
|
|
3020
|
+
* ("") — never silently degrade back to the retired "local" default (issue #287).
|
|
3021
|
+
*/
|
|
3022
|
+
function loadActorIdentityHelper() {
|
|
3023
|
+
const _req = createRequire(import.meta.url);
|
|
3024
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/actor-identity.js");
|
|
3025
|
+
return _req(helperPath);
|
|
3026
|
+
}
|
|
3027
|
+
function resolveLivenessActor() {
|
|
3028
|
+
return loadActorIdentityHelper().resolveActor(process.env).actor;
|
|
3029
|
+
}
|
|
3030
|
+
// isUnresolvedActor is no longer defined locally — it is single-sourced in
|
|
3031
|
+
// scripts/hooks/lib/actor-identity.js (loadActorIdentityHelper().isUnresolvedActor) so the
|
|
3032
|
+
// lifecycle auto-emit path, the direct CLI liveness path, and the tool-activity heartbeat path
|
|
3033
|
+
// all consume the same predicate rather than forking their own copy (#287 re-review MEDIUM; #288
|
|
3034
|
+
// Wave 1 Task 1.1 single-sources it there).
|
|
3035
|
+
/**
|
|
3036
|
+
* Delegate to the shared pure-CJS policy predicates (scripts/hooks/lib/liveness-policy.js) — the
|
|
3037
|
+
* one definition of "enabled" (default-on/opt-out) and the claim TTL default, consumed by both the
|
|
3038
|
+
* lifecycle auto-emit path and the manual `liveness claim --ttl` default so they can never disagree
|
|
3039
|
+
* (#288).
|
|
3040
|
+
*/
|
|
3041
|
+
function loadLivenessPolicyHelper() {
|
|
3042
|
+
const _req = createRequire(import.meta.url);
|
|
3043
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-policy.js");
|
|
3044
|
+
return _req(helperPath);
|
|
3045
|
+
}
|
|
3046
|
+
function livenessEnabled() { return loadLivenessPolicyHelper().isLivenessEnabled(process.env); }
|
|
3047
|
+
/**
|
|
3048
|
+
* F1 (#288 fix iteration 1, cr-HIGH fail-open violation): the `livenessEnabled()`
|
|
3049
|
+
* guard (and therefore its `loadLivenessPolicyHelper()` module load) must sit
|
|
3050
|
+
* INSIDE this function's own fail-open try/catch — previously it sat outside,
|
|
3051
|
+
* so a missing/broken scripts/hooks/lib/liveness-policy.js module made
|
|
3052
|
+
* `init-plan`/`advance-state` exit 1 instead of degrading gracefully
|
|
3053
|
+
* (repro-verified). Now: any failure here — including a failed helper load —
|
|
3054
|
+
* is caught, produces one stderr diagnostic, and the lifecycle auto-emit is
|
|
3055
|
+
* skipped; the workflow command's own exit code is never affected. This
|
|
3056
|
+
* mirrors the #287 fail-open convention already used elsewhere in this file.
|
|
3057
|
+
* The direct CLI write path (`async function liveness`, actions
|
|
3058
|
+
* claim|heartbeat|release) is deliberately NOT wrapped this way — it stays
|
|
3059
|
+
* fail-loud on a missing helper module, per the plan's explicit instruction
|
|
3060
|
+
* that only the convenience lifecycle wiring is flag-gated/fail-open.
|
|
3061
|
+
*/
|
|
2473
3062
|
function livenessLifecycle(taskDir, slug, kind, timestamp) {
|
|
2474
|
-
if (!livenessEnabled())
|
|
2475
|
-
return;
|
|
2476
3063
|
try {
|
|
2477
|
-
|
|
2478
|
-
|
|
3064
|
+
if (!livenessEnabled())
|
|
3065
|
+
return;
|
|
3066
|
+
const actor = resolveLivenessActor();
|
|
3067
|
+
if (loadActorIdentityHelper().isUnresolvedActor(actor)) {
|
|
3068
|
+
process.stderr.write("[liveness] skipped auto-emit: actor unresolved (set FLOW_AGENTS_ACTOR or run inside a supported runtime)\n");
|
|
3069
|
+
return;
|
|
3070
|
+
}
|
|
3071
|
+
const root = path.dirname(taskDir); // .kontourai/flow-agents/<slug> → .kontourai/flow-agents (the shared liveness stream lives here)
|
|
3072
|
+
const evt = { type: kind, subjectId: slug, actor, at: timestamp, source: "lifecycle" };
|
|
2479
3073
|
if (kind === "claim")
|
|
2480
|
-
evt.ttlSeconds =
|
|
3074
|
+
evt.ttlSeconds = loadLivenessPolicyHelper().resolveTtlSeconds(process.env);
|
|
2481
3075
|
appendLivenessEvent(root, evt);
|
|
2482
3076
|
}
|
|
2483
|
-
catch {
|
|
3077
|
+
catch (err) {
|
|
3078
|
+
// best-effort; liveness is advisory and must never break the workflow — but the failure
|
|
3079
|
+
// itself must be visible (F1), not silently absorbed.
|
|
3080
|
+
try {
|
|
3081
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
3082
|
+
process.stderr.write(`[liveness] skipped auto-emit: ${detail}\n`);
|
|
3083
|
+
}
|
|
3084
|
+
catch { /* best-effort diagnostic only */ }
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
/**
|
|
3088
|
+
* F5 (#288 fix iteration 1, sec-LOW): strip control/escape characters before echoing a
|
|
3089
|
+
* subjectId/actor to the terminal. subjectId is a raw CLI positional argument on the write path
|
|
3090
|
+
* (never sanitized before this point — it is stored as-is in the event for data fidelity) and,
|
|
3091
|
+
* on the `status` read path, both subjectId and actor may originate from a hand-edited or
|
|
3092
|
+
* otherwise hostile liveness/events.jsonl file rather than this process's own writes. Strips the
|
|
3093
|
+
* C0 control range (0x00-0x1F), DEL (0x7F), and the C1 range (0x80-0x9F, which includes
|
|
3094
|
+
* ANSI-CSI-adjacent bytes) — display-only; the persisted event itself is never mutated.
|
|
3095
|
+
*/
|
|
3096
|
+
function stripControlCharsForDisplay(value) {
|
|
3097
|
+
return String(value ?? "").replace(/[\u0000-\u001F\u007F-\u009F]/g, "");
|
|
2484
3098
|
}
|
|
2485
3099
|
async function liveness(p) {
|
|
2486
3100
|
const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
|
|
2487
3101
|
const action = p.positional[0] || "";
|
|
2488
3102
|
const subjectId = p.positional[1] || "";
|
|
2489
|
-
const actor = opt(p, "actor", process.env.FLOW_AGENTS_ACTOR || "unknown");
|
|
2490
3103
|
const nowIso = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
3104
|
+
if (action === "whoami") {
|
|
3105
|
+
// Read-only, lock-free, write-free advisory surface: reuses the identical resolution chain
|
|
3106
|
+
// as the write paths (loadActorIdentityHelper().resolveActor) but deliberately never dies on
|
|
3107
|
+
// an unresolved actor — the enforcement point stays at `liveness claim`, which already dies
|
|
3108
|
+
// loudly (see below). This lets a skill learn "who am I" during a read-only preflight without
|
|
3109
|
+
// emitting a bogus claim first.
|
|
3110
|
+
const helper = loadActorIdentityHelper();
|
|
3111
|
+
const explicitActorRaw = opt(p, "actor", "");
|
|
3112
|
+
const explicitActor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : "";
|
|
3113
|
+
const resolved = explicitActor
|
|
3114
|
+
? { actor: explicitActor, source: "explicit-override" }
|
|
3115
|
+
: helper.resolveActor(process.env);
|
|
3116
|
+
if (p.flags.has("json")) {
|
|
3117
|
+
console.log(JSON.stringify(resolved));
|
|
3118
|
+
return 0;
|
|
3119
|
+
}
|
|
3120
|
+
console.log(`${stripControlCharsForDisplay(resolved.actor || "unresolved")}\t${stripControlCharsForDisplay(resolved.source)}`);
|
|
3121
|
+
return 0;
|
|
3122
|
+
}
|
|
2491
3123
|
if (action === "claim" || action === "heartbeat" || action === "release") {
|
|
3124
|
+
// Actor resolution happens only for write actions (F5, #287 fix iteration 1) — "status" is a
|
|
3125
|
+
// read path and must not shell out via resolveLivenessActor()/resolveActor().
|
|
3126
|
+
const helper = loadActorIdentityHelper();
|
|
3127
|
+
const explicitActorRaw = opt(p, "actor", "");
|
|
3128
|
+
// F7 (#287 fix iteration 2): an explicit --actor value that strips to empty under the allowed
|
|
3129
|
+
// [A-Za-z0-9_.-] charset (e.g. "--actor ':::'") is a hard error on this write path, not a
|
|
3130
|
+
// silent fallback to sanitizeSegment's shared "unknown" sentinel — garbage on an explicit flag
|
|
3131
|
+
// is an authoring mistake the caller must fix, unlike the env-override seam (which falls
|
|
3132
|
+
// through to derivation instead, since there is no flag to correct).
|
|
3133
|
+
if (explicitActorRaw && !/[A-Za-z0-9_.-]/.test(explicitActorRaw)) {
|
|
3134
|
+
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.`);
|
|
3135
|
+
}
|
|
3136
|
+
const explicitActor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : "";
|
|
3137
|
+
const actor = explicitActor || helper.resolveActor(process.env).actor;
|
|
3138
|
+
if (helper.isUnresolvedActor(actor)) {
|
|
3139
|
+
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.`);
|
|
3140
|
+
}
|
|
2492
3141
|
if (!subjectId)
|
|
2493
3142
|
die(`liveness ${action} requires a subjectId`);
|
|
3143
|
+
// F8(i) (#288 fix iteration 2, orphan-heartbeat invariant): heartbeat/release on the direct CLI
|
|
3144
|
+
// write path must never be the FIRST liveness event ever written for a (subjectId, actor) pair —
|
|
3145
|
+
// that is exactly how the reviewer-reproduced orphan-heartbeat bug arises (a bare heartbeat with
|
|
3146
|
+
// no claim behind it, later mistaken for claim evidence). A full stream read here is fine: this
|
|
3147
|
+
// is a rare, human/CLI-driven call, not the hot tool-activity path. `claim` itself is exempt (it
|
|
3148
|
+
// is the event that ESTABLISHES the pair in the first place).
|
|
3149
|
+
if (action === "heartbeat" || action === "release") {
|
|
3150
|
+
const priorEvents = readLivenessEvents(root);
|
|
3151
|
+
const hasPriorClaim = priorEvents.some((e) => e && e.type === "claim" && e.subjectId === subjectId && e.actor === actor);
|
|
3152
|
+
if (!hasPriorClaim) {
|
|
3153
|
+
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}.`);
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
2494
3156
|
const evt = { type: action, subjectId, actor, at: opt(p, "at") || nowIso };
|
|
2495
|
-
if (action === "claim")
|
|
2496
|
-
|
|
3157
|
+
if (action === "claim") {
|
|
3158
|
+
const defaultTtl = loadLivenessPolicyHelper().resolveTtlSeconds(process.env);
|
|
3159
|
+
evt.ttlSeconds = Number.parseInt(opt(p, "ttl", String(defaultTtl)), 10) || defaultTtl;
|
|
3160
|
+
}
|
|
2497
3161
|
appendLivenessEvent(root, evt);
|
|
2498
|
-
console.log(`liveness ${action}: ${subjectId} by ${actor}`);
|
|
3162
|
+
console.log(`liveness ${action}: ${stripControlCharsForDisplay(subjectId)} by ${stripControlCharsForDisplay(actor)}`);
|
|
2499
3163
|
return 0;
|
|
2500
3164
|
}
|
|
2501
3165
|
if (action === "status") {
|
|
@@ -2531,7 +3195,7 @@ async function liveness(p) {
|
|
|
2531
3195
|
for (const g of groups.values()) {
|
|
2532
3196
|
if (subjectFilter && g.subjectId !== subjectFilter)
|
|
2533
3197
|
continue;
|
|
2534
|
-
const claim = { id: `${g.subjectId}::${g.actor}`, subjectType: "work-item", subjectId: g.subjectId,
|
|
3198
|
+
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
3199
|
const status = surface.deriveTrustStatus({ claim, evidence: [], policy: LIVENESS_POLICY, events: g.events, now });
|
|
2536
3200
|
rows.push({ subjectId: g.subjectId, actor: g.actor, status, label: livenessLabel(status) });
|
|
2537
3201
|
}
|
|
@@ -2540,10 +3204,99 @@ async function liveness(p) {
|
|
|
2540
3204
|
return 0;
|
|
2541
3205
|
}
|
|
2542
3206
|
for (const r of rows)
|
|
2543
|
-
console.log(`${r.subjectId}\t${r.actor}\t${r.label}`);
|
|
3207
|
+
console.log(`${stripControlCharsForDisplay(r.subjectId)}\t${stripControlCharsForDisplay(r.actor)}\t${r.label}`);
|
|
3208
|
+
return 0;
|
|
3209
|
+
}
|
|
3210
|
+
if (action === "verdict") {
|
|
3211
|
+
// Read-only, lock-free CLI helper (#320 AC1) — mirrors `whoami`'s lock-bypass (see
|
|
3212
|
+
// isLivenessVerdict in main() below): computes {subjectId, winner, losers, reason, holders}
|
|
3213
|
+
// as a PURE function of the shared liveness stream. Among the subject's currently-fresh
|
|
3214
|
+
// claim holders (via the shared `freshHolders` helper — the exact canonical
|
|
3215
|
+
// freshness/grouping/release-handling logic `workflow-steering.js`'s ambient digest already
|
|
3216
|
+
// uses, not a re-derived rule), the holder whose most recent `claim` event has the earliest
|
|
3217
|
+
// `at` wins; an exact-timestamp tie breaks by ascending plain string comparison of actor id
|
|
3218
|
+
// (never locale-aware collation, for cross-machine determinism). Same stream state ⇒ same
|
|
3219
|
+
// verdict, regardless of which actor invokes it — no actor-specific input is read here.
|
|
3220
|
+
if (!subjectId)
|
|
3221
|
+
die("liveness verdict requires a subjectId");
|
|
3222
|
+
const events = readLivenessEvents(root);
|
|
3223
|
+
const nowMs = opt(p, "now") ? Date.parse(opt(p, "now")) : Date.now();
|
|
3224
|
+
const _req = createRequire(import.meta.url);
|
|
3225
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-read.js");
|
|
3226
|
+
const { freshHolders } = _req(helperPath);
|
|
3227
|
+
// Impossible-sentinel selfActor: real actors are sanitized to [A-Za-z0-9_.-]+ and can never be
|
|
3228
|
+
// empty (enforced on every write path above), so passing "" excludes nothing.
|
|
3229
|
+
const fresh = freshHolders(events, subjectId, "", nowMs);
|
|
3230
|
+
// freshHolders' `lastAt` tracks the group's latest event of ANY type (heartbeat-updated); the
|
|
3231
|
+
// tiebreak instead operates on each holder's most recent `claim`-type event `at` — the
|
|
3232
|
+
// "current claim timestamp", distinct from lastAt.
|
|
3233
|
+
const latestClaimAt = (actor) => {
|
|
3234
|
+
let best = "";
|
|
3235
|
+
for (const e of events) {
|
|
3236
|
+
if (e && e.subjectId === subjectId && e.actor === actor && e.type === "claim" && typeof e.at === "string") {
|
|
3237
|
+
if (!best || e.at > best)
|
|
3238
|
+
best = e.at;
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
return best;
|
|
3242
|
+
};
|
|
3243
|
+
const holders = fresh
|
|
3244
|
+
.map((h) => ({ actor: String(h.actor), claimAt: latestClaimAt(String(h.actor)) || String(h.lastAt), lastAt: String(h.lastAt), ttlSeconds: Number(h.ttlSeconds) }))
|
|
3245
|
+
.sort((a, b) => (a.claimAt < b.claimAt ? -1 : a.claimAt > b.claimAt ? 1 : a.actor < b.actor ? -1 : a.actor > b.actor ? 1 : 0));
|
|
3246
|
+
let winner = null;
|
|
3247
|
+
let losers = [];
|
|
3248
|
+
let reason = "no-conflict";
|
|
3249
|
+
if (holders.length >= 2) {
|
|
3250
|
+
winner = holders[0];
|
|
3251
|
+
const tie = holders.filter((h) => h.claimAt === winner.claimAt).length > 1;
|
|
3252
|
+
reason = tie ? "tie-actor-lexicographic" : "earlier-claim";
|
|
3253
|
+
losers = holders.filter((h) => h.actor !== winner.actor);
|
|
3254
|
+
}
|
|
3255
|
+
if (p.flags.has("json")) {
|
|
3256
|
+
// #320 fix iteration 1 (sec-HIGH F3): `winner`/`losers`/`holders` carry raw actor/claimAt/
|
|
3257
|
+
// lastAt strings sourced straight from the multi-writer append-only liveness/events.jsonl
|
|
3258
|
+
// stream (see stripControlCharsForDisplay's header above — any process can append a
|
|
3259
|
+
// hostile line, bypassing the CLI write-side sanitizeSegment entirely). The pull-work
|
|
3260
|
+
// SKILL.md's Post-Claim Conflict Re-check instructs an LLM to read `winner.actor` /
|
|
3261
|
+
// `losers[].actor` straight out of THIS `--json` output into its own context — a second,
|
|
3262
|
+
// LLM-facing injection path alongside the mid-turn conflict hook (liveness-heartbeat.js's
|
|
3263
|
+
// computeConflict). Build a sanitized COPY for JSON emission only: control-char strip
|
|
3264
|
+
// (matching the text branch's existing stripControlCharsForDisplay treatment below) plus a
|
|
3265
|
+
// 64-char cap (matching sanitizeSegment's cap on the write side) on actor/claimAt/lastAt.
|
|
3266
|
+
// `reason`/`subjectId`/`ttlSeconds` are left as-is (enum/number, never free text). Never
|
|
3267
|
+
// mutates `result`/`holders`/`winner`/`losers` themselves — the text branch below still
|
|
3268
|
+
// needs the untouched originals for its own per-line rendering, and winner/loser identity
|
|
3269
|
+
// (already sanitizeSegment-clean for legitimate actors) is unaffected, so the loser-release
|
|
3270
|
+
// contract still works.
|
|
3271
|
+
const sanitizeVerdictHolderForJson = (h) => h
|
|
3272
|
+
? {
|
|
3273
|
+
...h,
|
|
3274
|
+
actor: stripControlCharsForDisplay(h.actor).slice(0, 64),
|
|
3275
|
+
claimAt: stripControlCharsForDisplay(h.claimAt).slice(0, 64),
|
|
3276
|
+
lastAt: stripControlCharsForDisplay(h.lastAt).slice(0, 64),
|
|
3277
|
+
}
|
|
3278
|
+
: h;
|
|
3279
|
+
const jsonResult = {
|
|
3280
|
+
subjectId,
|
|
3281
|
+
winner: sanitizeVerdictHolderForJson(winner),
|
|
3282
|
+
losers: losers.map((l) => sanitizeVerdictHolderForJson(l)),
|
|
3283
|
+
reason,
|
|
3284
|
+
holders: holders.map((h) => sanitizeVerdictHolderForJson(h)),
|
|
3285
|
+
};
|
|
3286
|
+
console.log(JSON.stringify(jsonResult));
|
|
3287
|
+
return 0;
|
|
3288
|
+
}
|
|
3289
|
+
for (const h of holders) {
|
|
3290
|
+
const tag = winner && h.actor === winner.actor ? "WINNER" : (holders.length >= 2 ? "LOSER" : "HOLDER");
|
|
3291
|
+
console.log(`${stripControlCharsForDisplay(h.actor)}\tclaimAt=${stripControlCharsForDisplay(h.claimAt)}\t${tag}`);
|
|
3292
|
+
}
|
|
3293
|
+
if (winner)
|
|
3294
|
+
console.log(`WINNER: ${stripControlCharsForDisplay(winner.actor)}`);
|
|
3295
|
+
for (const l of losers)
|
|
3296
|
+
console.log(`LOSER: ${stripControlCharsForDisplay(l.actor)}`);
|
|
2544
3297
|
return 0;
|
|
2545
3298
|
}
|
|
2546
|
-
die("liveness action must be one of: claim | heartbeat | release | status");
|
|
3299
|
+
die("liveness action must be one of: claim | heartbeat | release | status | whoami | verdict");
|
|
2547
3300
|
return 1;
|
|
2548
3301
|
}
|
|
2549
3302
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -2707,9 +3460,24 @@ async function main() {
|
|
|
2707
3460
|
const p = parseArgs(process.argv.slice(2));
|
|
2708
3461
|
if (!p.command)
|
|
2709
3462
|
die("workflow-sidecar command is required");
|
|
2710
|
-
|
|
3463
|
+
// F1 (#166 fix iteration 1): `liveness whoami` is a read-only, lock-free, write-free advisory
|
|
3464
|
+
// surface (see the `action === "whoami"` branch inside `liveness()` above) — it must never
|
|
3465
|
+
// acquire the workflow-sidecar lock, regardless of whether the artifact root already exists on
|
|
3466
|
+
// disk. Without this action-level bypass, `liveness` was blanket-included in the lock-routing
|
|
3467
|
+
// branch below, so `whoami` against an EXISTING artifact root would still resolve a real
|
|
3468
|
+
// lockRoot and go through `withLock`'s mkdir/lockdir path — the opposite of "genuinely
|
|
3469
|
+
// lock-free". This bypass mirrors `resolve-slug`'s existing empty-lockRoot special case
|
|
3470
|
+
// immediately below and is scoped to the `whoami` action only: `liveness status` (a read path)
|
|
3471
|
+
// keeps its pre-existing lock behavior unchanged (out of scope for this fix — see fix-plan
|
|
3472
|
+
// iteration 1, F1), and `liveness claim` / `heartbeat` / `release` (write paths) are untouched.
|
|
3473
|
+
const isLivenessWhoami = p.command === "liveness" && p.positional[0] === "whoami";
|
|
3474
|
+
// #320 AC1: `liveness verdict` is read-only and lock-free, exactly like `whoami` above — it
|
|
3475
|
+
// must never acquire the workflow-sidecar lock. Same bypass shape, scoped to the `verdict`
|
|
3476
|
+
// action only; `liveness status` (also a read path) keeps its pre-existing lock behavior.
|
|
3477
|
+
const isLivenessVerdict = p.command === "liveness" && p.positional[0] === "verdict";
|
|
3478
|
+
const lockRoot = (["ensure-session", "current", "dogfood-pass", "liveness"].includes(p.command) && !isLivenessWhoami && !isLivenessVerdict)
|
|
2711
3479
|
? (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]) : "";
|
|
3480
|
+
: 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
3481
|
return withLock(lockRoot, ["ensure-session", "record-agent-event", "dogfood-pass"].includes(p.command), p.command, () => {
|
|
2714
3482
|
switch (p.command) {
|
|
2715
3483
|
case "ensure-session": return ensureSession(p);
|
|
@@ -2718,6 +3486,7 @@ async function main() {
|
|
|
2718
3486
|
case "init-plan": return initPlan(p);
|
|
2719
3487
|
case "record-evidence": return recordEvidence(p);
|
|
2720
3488
|
case "record-gate-claim": return recordGateClaim(p);
|
|
3489
|
+
case "promote": return promote(p);
|
|
2721
3490
|
case "advance-state": return advanceState(p);
|
|
2722
3491
|
case "record-critique": return recordCritique(p);
|
|
2723
3492
|
case "import-critique": return importCritique(p);
|
|
@@ -2729,6 +3498,7 @@ async function main() {
|
|
|
2729
3498
|
case "trust-mcp": return trustMcp(p);
|
|
2730
3499
|
case "liveness": return liveness(p);
|
|
2731
3500
|
case "claim": return claimLookup(p);
|
|
3501
|
+
case "resolve-slug": return resolveSlugCmd(p);
|
|
2732
3502
|
case "seal-checkpoint": return sealCheckpoint(p);
|
|
2733
3503
|
case "publish-delivery": return publishDeliveryCmd(p);
|
|
2734
3504
|
default: die(`unknown command: ${p.command}`);
|