@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
|
@@ -13,7 +13,7 @@ type AnyObj = Record<string, any>;
|
|
|
13
13
|
|
|
14
14
|
export const statuses = new Set(["new", "planning", "planned", "in_progress", "blocked", "verifying", "verified", "needs_decision", "not_verified", "failed", "delivered", "accepted", "archived"]);
|
|
15
15
|
export const phases = ["idea", "backlog", "pickup", "planning", "execution", "verification", "goal_fit", "evidence", "release", "learning", "done"];
|
|
16
|
-
export const checkKinds = new Set(["build", "types", "lint", "test", "security", "diff", "browser", "runtime", "policy", "external"]);
|
|
16
|
+
export const checkKinds = new Set(["build", "types", "lint", "test", "command", "security", "diff", "browser", "runtime", "policy", "external"]);
|
|
17
17
|
export const checkStatuses = new Set(["pass", "fail", "not_verified", "skip"]);
|
|
18
18
|
export const verdicts = new Set(["pass", "partial", "fail", "not_verified"]);
|
|
19
19
|
|
|
@@ -50,6 +50,144 @@ function workItemSlug(ref: string): string {
|
|
|
50
50
|
return slugify(`${owner}-${repo}-${id}`, "work-item");
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** Pure, lock-free, side-effect-free CLI wrapper around workItemSlug() — the single source of
|
|
54
|
+
* truth for the deterministic subjectId/session-directory-name slug. Named resolveSlugCmd (not
|
|
55
|
+
* resolveSlug) to avoid colliding with any future export named resolveSlug. */
|
|
56
|
+
function resolveSlugCmd(p: ReturnType<typeof parseArgs>): number {
|
|
57
|
+
const ref = p.positional[0] || die("resolve-slug requires an owner/repo#id ref");
|
|
58
|
+
console.log(workItemSlug(ref));
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** First 6 hex chars of sha256(raw) — a short deterministic disambiguator (#289 F4). Two
|
|
63
|
+
* different raw inputs that both collapse to a segment's "unknown" fallback (e.g. two distinct
|
|
64
|
+
* all-garbage --task-slug values, or two distinct raw actor strings that both resolve
|
|
65
|
+
* "unresolved") would otherwise derive the identical fallback branch — this makes them diverge
|
|
66
|
+
* while staying fully deterministic for a given raw input (same raw -> same hash every call),
|
|
67
|
+
* which is required to preserve resolveSessionBranch's no-rederive/resume-continuity semantics
|
|
68
|
+
* (a resumed session's already-recorded branch is never recomputed regardless of this helper). */
|
|
69
|
+
function unknownDisambiguator(raw: string): string {
|
|
70
|
+
return createHash("sha256").update(raw).digest("hex").slice(0, 6);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Encode a value (actor string or --task-slug, neither guaranteed git-ref-safe) into a
|
|
74
|
+
* single git-check-ref-format(1)-safe branch path component (#289). Reuses
|
|
75
|
+
* actor-identity.js's sanitizeSegment (consume, don't fork) as the base, then closes the
|
|
76
|
+
* git-specific gaps sanitizeSegment's [A-Za-z0-9_.-] charset does not: the `:` delimiter
|
|
77
|
+
* serializeActor() uses (disallowed in a git ref), consecutive dots, a leading dot, a trailing
|
|
78
|
+
* dot (fix-plan iteration 1 F1 — git-check-ref-format forbids a component ending in `.`, and
|
|
79
|
+
* the prior pass only handled a run of 2+ trailing dots via the `..`-collapse step, missing the
|
|
80
|
+
* single-trailing-dot case e.g. a `--task-slug` of `my-fix.`), and a trailing `.lock`. The
|
|
81
|
+
* trailing-dot strip runs BEFORE the `.lock` rewrite so a `.lock` suffix hidden behind trailing
|
|
82
|
+
* dots (e.g. `foo.lock.`) is exposed and still rewritten rather than left dangling. Whenever the
|
|
83
|
+
* FINAL sanitized segment equals the fallback token `"unknown"` — whether because the charset
|
|
84
|
+
* filter collapsed the raw input to nothing, OR because the leading-/trailing-dot stripping
|
|
85
|
+
* above collapsed a near-miss input like `"unknown."` or `".unknown"` down to the literal
|
|
86
|
+
* `"unknown"`, OR because the raw input genuinely WAS the literal string `"unknown"` — a
|
|
87
|
+
* deterministic disambiguator (see unknownDisambiguator) is ALWAYS appended (fix-plan iteration 2
|
|
88
|
+
* F4': no literal-input carve-out). Distinct raw inputs can never collide on the bare literal
|
|
89
|
+
* `"unknown"` segment; the same raw input always re-derives the same disambiguated segment. See
|
|
90
|
+
* Design Decision 2 in the plan. */
|
|
91
|
+
function sanitizeBranchSegment(value: string, helper: { sanitizeSegment: (v: unknown) => string }): string {
|
|
92
|
+
const raw = String(value ?? "");
|
|
93
|
+
const colonReplaced = raw.replace(/:/g, "-");
|
|
94
|
+
let seg = helper.sanitizeSegment(colonReplaced);
|
|
95
|
+
seg = seg.replace(/\.{2,}/g, "-").replace(/^\.+/, "").replace(/\.+$/, "").replace(/\.lock$/i, "-lock");
|
|
96
|
+
if (!seg || seg === "unknown") return `unknown-${unknownDisambiguator(raw)}`;
|
|
97
|
+
return seg;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Validate an explicit `--branch` value strictly rather than trusting it verbatim (fix-plan
|
|
101
|
+
* iteration 1 F2, tightened by iteration 2 F2'). Explicit `--branch` bypasses
|
|
102
|
+
* `sanitizeBranchSegment` entirely by design (it is caller intent, may legitimately contain `/`
|
|
103
|
+
* to nest under `agent/...`, etc.), so unlike the derived path it must be rejected outright — not
|
|
104
|
+
* silently sanitized — when it cannot be a valid git ref. Whole-string checks (iteration 1):
|
|
105
|
+
* any control character/newline, a space, a leading or trailing `/`, a `//` sequence, a leading
|
|
106
|
+
* `.`, any `..` sequence, a trailing `.`, a trailing `.lock`, or any character outside
|
|
107
|
+
* `[A-Za-z0-9_./-]`. Per-component checks (iteration 2 F2'): the whole-string checks above only
|
|
108
|
+
* examine the START/END of the full value, so a charset-legal value can still smuggle an invalid
|
|
109
|
+
* `/`-delimited component past them — e.g. `"-lead"` (a leading `-` breaks the whole git ref,
|
|
110
|
+
* applied uniformly to every component here — stricter than git strictly requires, which is fine
|
|
111
|
+
* for a caller-facing override flag), `"a/.b"` (a non-first component starting with `.`),
|
|
112
|
+
* `"foo.lock/bar"` (a non-last component ending in `.lock`), or `"a/./b"` (a component that is
|
|
113
|
+
* exactly `.`). Belt-and-braces (F2'): once the lexical checks above all pass, `git
|
|
114
|
+
* check-ref-format --branch <value>` (the real git binary) is run as the final authority — it can
|
|
115
|
+
* only ever REJECT a value the lexical checks let through (never re-legalize one they rejected),
|
|
116
|
+
* so it closes any residual gap in this hand-rolled lexical pass. When git cannot be spawned at
|
|
117
|
+
* all (e.g. not installed — `ENOENT`) or does not complete (e.g. the 5s timeout fires), the git
|
|
118
|
+
* check is skipped silently and the lexical checks above remain the sole authority. Dies with
|
|
119
|
+
* remediation, or with git's own rejection message; never mutates any artifact before this check
|
|
120
|
+
* runs (resolveSessionBranch calls this before any file write). */
|
|
121
|
+
function validateExplicitBranch(value: string): void {
|
|
122
|
+
const remediation = `Pass a --branch value matching [A-Za-z0-9_./-], with no leading/trailing "/", no "//", no leading ".", no ".." sequence, and no trailing "." or ".lock" (got: ${JSON.stringify(value)}).`;
|
|
123
|
+
const fail = (reason: string): never => die(`ensure-session --branch value is not a valid git ref: ${reason}. ${remediation}`);
|
|
124
|
+
if (/[\x00-\x1F\x7F]/.test(value)) fail("contains a control character or newline");
|
|
125
|
+
if (/ /.test(value)) fail("contains a space");
|
|
126
|
+
if (value.startsWith("/") || value.endsWith("/")) fail('must not start or end with "/"');
|
|
127
|
+
if (value.includes("//")) fail('must not contain "//"');
|
|
128
|
+
if (value.startsWith(".")) fail('must not start with "."');
|
|
129
|
+
if (value.includes("..")) fail('must not contain a ".." sequence');
|
|
130
|
+
if (value.endsWith(".")) fail('must not end with "."');
|
|
131
|
+
if (/\.lock$/i.test(value)) fail('must not end with ".lock"');
|
|
132
|
+
if (/[^A-Za-z0-9_./-]/.test(value)) fail("contains a character outside [A-Za-z0-9_./-]");
|
|
133
|
+
|
|
134
|
+
// F2' per-component checks: split on "/" and validate each path component individually. The
|
|
135
|
+
// whole-string checks above cannot catch a hostile component that is not at the very start or
|
|
136
|
+
// end of the full value.
|
|
137
|
+
for (const component of value.split("/")) {
|
|
138
|
+
if (!component) fail('must not contain an empty path component ("//" or a leading/trailing "/")');
|
|
139
|
+
if (component === ".") fail('must not contain a path component that is exactly "."');
|
|
140
|
+
if (component.startsWith(".")) fail('must not contain a path component starting with "."');
|
|
141
|
+
if (component.startsWith("-")) fail('must not contain a path component starting with "-"');
|
|
142
|
+
if (component.endsWith(".")) fail('must not contain a path component ending with "."');
|
|
143
|
+
if (/\.lock$/i.test(component)) fail('must not contain a path component ending with ".lock"');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// F2' belt-and-braces: the real `git check-ref-format --branch` binary is the final authority
|
|
147
|
+
// when git is available. This can only REJECT a value that already passed every lexical check
|
|
148
|
+
// above — it never re-legalizes a value the lexical checks rejected (those `fail()` calls
|
|
149
|
+
// above already threw). argv-array form (no shell) with a 5s timeout so a hung or missing git
|
|
150
|
+
// binary cannot hang or crash session creation.
|
|
151
|
+
let result: { status: number | null; stderr: Buffer | string } | undefined;
|
|
152
|
+
try {
|
|
153
|
+
execFileSync("git", ["check-ref-format", "--branch", value], { stdio: ["ignore", "ignore", "pipe"], timeout: 5000 });
|
|
154
|
+
return; // exit 0 — git accepts the value; nothing further to check.
|
|
155
|
+
} catch (err) {
|
|
156
|
+
const spawnError = err as NodeJS.ErrnoException & { status?: number | null; stderr?: Buffer | string };
|
|
157
|
+
if (spawnError && spawnError.code === "ENOENT") return; // git not installed — lexical checks stand alone.
|
|
158
|
+
if (spawnError && typeof spawnError.status === "number") { result = { status: spawnError.status, stderr: spawnError.stderr ?? "" }; }
|
|
159
|
+
else return; // Any other spawn failure (e.g. timeout) — skip silently; lexical checks already passed.
|
|
160
|
+
}
|
|
161
|
+
if (result && result.status !== 0) {
|
|
162
|
+
const gitMessage = String(result.stderr ?? "").trim();
|
|
163
|
+
fail(gitMessage ? `git check-ref-format rejected the value: ${gitMessage}` : "git check-ref-format rejected the value");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Resolve the branch to seed a brand-new session with. Only called from ensureSession's
|
|
168
|
+
* `if (!md)` fresh-creation branch — an existing session's already-recorded branch is never
|
|
169
|
+
* recomputed (ADR 0021 §5 takeover continuity; see Design Decision 3). Precedence: explicit
|
|
170
|
+
* --branch (strictly validated, then honored verbatim — see F2) > derived agent/<actor>/<slug>.
|
|
171
|
+
* Never hard-fails session creation on actor-resolution ambiguity (Design Decision 4) — only a
|
|
172
|
+
* garbage explicit --actor, or a garbage explicit --branch, dies. */
|
|
173
|
+
function resolveSessionBranch(p: ReturnType<typeof parseArgs>, slug: string): string {
|
|
174
|
+
// Deliberately NOT trimmed before validation (unlike the pre-F2 baseline): a leading/trailing
|
|
175
|
+
// space must be REJECTED, not silently trimmed away — silent trimming would let a
|
|
176
|
+
// caller-supplied value differ from what gets recorded without any diagnostic (F2).
|
|
177
|
+
const explicitBranch = opt(p, "branch");
|
|
178
|
+
if (explicitBranch) { validateExplicitBranch(explicitBranch); return explicitBranch; }
|
|
179
|
+
const helper = loadActorIdentityHelper();
|
|
180
|
+
const explicitActorRaw = opt(p, "actor", "").trim();
|
|
181
|
+
if (explicitActorRaw && !/[A-Za-z0-9_.-]/.test(explicitActorRaw)) {
|
|
182
|
+
die("ensure-session --actor value strips to empty under the allowed actor charset ([A-Za-z0-9_.-]) — pass a value containing at least one letter, digit, underscore, period, or hyphen.");
|
|
183
|
+
}
|
|
184
|
+
const actor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : helper.resolveActor(process.env).actor;
|
|
185
|
+
const unresolved = helper.isUnresolvedActor(actor);
|
|
186
|
+
const safeActor = unresolved ? `unknown-actor-${unknownDisambiguator(actor)}` : sanitizeBranchSegment(actor, helper);
|
|
187
|
+
if (unresolved) process.stderr.write("[ensure-session] actor unresolved; branch uses \"unknown-actor\" segment (set --actor or FLOW_AGENTS_ACTOR for a stable branch name)\n");
|
|
188
|
+
return `agent/${safeActor}/${sanitizeBranchSegment(slug, helper)}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
53
191
|
/**
|
|
54
192
|
* Validate a Hachure trust.bundle using @kontourai/surface's canonical validator
|
|
55
193
|
* (surface is the authoritative owner of trust-bundle schema validation per ADR 0010 / ADR 0015).
|
|
@@ -170,7 +308,7 @@ type SurfaceModule = {
|
|
|
170
308
|
policies: Record<string, unknown>[];
|
|
171
309
|
now?: Date;
|
|
172
310
|
}) => { status: string; policyId: string | undefined };
|
|
173
|
-
generateClaimId: (subjectId: string,
|
|
311
|
+
generateClaimId: (subjectId: string, facet: string, fieldOrBehavior: string) => string;
|
|
174
312
|
statusFunctionVersion: string;
|
|
175
313
|
resolveInquiry: (
|
|
176
314
|
bundle: Record<string, unknown>,
|
|
@@ -241,6 +379,48 @@ function criterionStatusToEventStatus(status: string): string | null {
|
|
|
241
379
|
if (status === "accepted_gap") return "assumed";
|
|
242
380
|
return null; // pending / not_verified → no event → Surface returns "unknown"
|
|
243
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* WS8 (ADR 0020): Derive Surface evidence classification (evidenceType + method)
|
|
384
|
+
* from a workflow check's kind, replacing the previous hardcoded `test_output`.
|
|
385
|
+
* Only command-backed re-runnable checks are `test_output` (CI-reconcilable);
|
|
386
|
+
* everything else is inherently session-local (manual attestation, provider/
|
|
387
|
+
* document citation, crawl observation, policy rule, source excerpt). The
|
|
388
|
+
* `reconcilable` flag is informational — the CI reconciler classifies purely by
|
|
389
|
+
* the emitted `evidenceType`. Every value is a member of Surface's own
|
|
390
|
+
* evidence.schema.json `evidenceType`/`method` enums (consume-never-fork).
|
|
391
|
+
*/
|
|
392
|
+
function classifyEvidence(kind: string | undefined, hasCommand: boolean): { evidenceType: string; method: string; reconcilable: boolean } {
|
|
393
|
+
const k = String(kind ?? "external");
|
|
394
|
+
switch (k) {
|
|
395
|
+
case "build":
|
|
396
|
+
case "types":
|
|
397
|
+
case "lint":
|
|
398
|
+
case "test":
|
|
399
|
+
case "command":
|
|
400
|
+
return { evidenceType: "test_output", method: "validation", reconcilable: true };
|
|
401
|
+
case "security":
|
|
402
|
+
return hasCommand
|
|
403
|
+
? { evidenceType: "test_output", method: "validation", reconcilable: true }
|
|
404
|
+
: { evidenceType: "attestation", method: "corroboration", reconcilable: false };
|
|
405
|
+
case "diff":
|
|
406
|
+
return { evidenceType: "source_excerpt", method: "extraction", reconcilable: false };
|
|
407
|
+
case "browser":
|
|
408
|
+
return hasCommand
|
|
409
|
+
? { evidenceType: "test_output", method: "validation", reconcilable: true }
|
|
410
|
+
: { evidenceType: "crawl_observation", method: "observation", reconcilable: false };
|
|
411
|
+
case "runtime":
|
|
412
|
+
return hasCommand
|
|
413
|
+
? { evidenceType: "test_output", method: "validation", reconcilable: true }
|
|
414
|
+
: { evidenceType: "attestation", method: "attestation", reconcilable: false };
|
|
415
|
+
case "policy":
|
|
416
|
+
return { evidenceType: "policy_rule", method: "auditability", reconcilable: false };
|
|
417
|
+
case "external":
|
|
418
|
+
return { evidenceType: "attestation", method: "corroboration", reconcilable: false };
|
|
419
|
+
default:
|
|
420
|
+
return { evidenceType: "test_output", method: "validation", reconcilable: true };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
244
424
|
/** Map a critique verdict to the Surface VerificationEvent status. */
|
|
245
425
|
function critiqueToEventStatus(verdict: string, findings: AnyObj[]): string | null {
|
|
246
426
|
if (verdict === "fail") return "disputed";
|
|
@@ -281,14 +461,29 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
281
461
|
const events: AnyObj[] = [];
|
|
282
462
|
const ts = timestamp || new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
283
463
|
|
|
284
|
-
// One VerificationPolicy per distinct claimType, so status is
|
|
285
|
-
// (not derived against an empty policy set). Maximal-fidelity per ADR 0010.
|
|
464
|
+
// One VerificationPolicy per distinct (claimType, requiredEvidence) pair, so status is
|
|
465
|
+
// policy-governed (not derived against an empty policy set). Maximal-fidelity per ADR 0010.
|
|
466
|
+
//
|
|
467
|
+
// WS8 (AC1, iteration 2): the cache is keyed by claimType + the normalized requiredEvidence
|
|
468
|
+
// set, NOT by claimType alone. Two checks of the same legacy claimType that differ in
|
|
469
|
+
// command-presence (e.g. a command-backed browser check → requiredEvidence [test_output]
|
|
470
|
+
// vs a no-command browser check → [crawl_observation]) previously COLLIDED: the first-seen
|
|
471
|
+
// requiredEvidence won and corrupted the second claim's derived status (verified → proposed)
|
|
472
|
+
// in a record-order-dependent way. Keying by (claimType, requiredEvidence) makes policy
|
|
473
|
+
// construction order-independent — each distinct evidence signature gets its own policy, and
|
|
474
|
+
// each claim references its own via verificationPolicyId (Surface's resolvePolicyForClaim
|
|
475
|
+
// honors verificationPolicyId first, so same-claimType policies never cross-resolve).
|
|
476
|
+
// Merging is NOT used because Surface's requiredEvidence is all-of (`.every`), so a union
|
|
477
|
+
// would over-constrain both claims.
|
|
286
478
|
const policies = new Map<string, AnyObj>();
|
|
287
479
|
const ensurePolicy = (claimType: string, impactLevel: string, requiredEvidence: string[]): AnyObj => {
|
|
288
|
-
|
|
480
|
+
const reqSorted = [...new Set(requiredEvidence)].sort();
|
|
481
|
+
const key = `${claimType}::${reqSorted.join(",")}`;
|
|
482
|
+
let p = policies.get(key);
|
|
289
483
|
if (!p) {
|
|
290
|
-
|
|
291
|
-
|
|
484
|
+
const id = reqSorted.length ? `policy:${claimType}:${reqSorted.join("+")}` : `policy:${claimType}`;
|
|
485
|
+
p = { id, claimType, requiredEvidence: reqSorted, acceptanceCriteria: [`A verified verification event must support a ${claimType} claim.`], reviewAuthority: "system", validityRule: { kind: "manual" }, stalenessTriggers: [], conflictRules: [], impactLevel };
|
|
486
|
+
policies.set(key, p);
|
|
292
487
|
}
|
|
293
488
|
return p;
|
|
294
489
|
};
|
|
@@ -402,12 +597,32 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
402
597
|
const claimId = generateClaimId(subjectId, "flow-agents.workflow", fieldOrBehavior);
|
|
403
598
|
const evId = `ev:${claimId}`;
|
|
404
599
|
const legacyClaimType = `workflow.check.${check.kind ?? "external"}`;
|
|
405
|
-
const policy = ensurePolicy(legacyClaimType, "high", ["test_output"]);
|
|
406
600
|
|
|
407
601
|
const cmd = typeof check.command === "string" ? check.command.replace(/\s+/g, " ").trim() : "";
|
|
408
602
|
const captured = cmd ? captureByCommand.get(cmd) : undefined;
|
|
603
|
+
// WS8 (ADR 0020): classify evidence from check.kind instead of hardcoding test_output.
|
|
604
|
+
// A waived accepted-gap (check._waiver, set by record-evidence/record-gate-claim
|
|
605
|
+
// --accepted-gap-reason/--waived-by) is by definition session-local: it is an
|
|
606
|
+
// attested accepted gap, never a CI-re-runnable command, so it is classified
|
|
607
|
+
// attestation/attestation and its claim status is forced to `assumed` (reusing the
|
|
608
|
+
// existing accepted_gap -> assumed mapping — no new status value).
|
|
609
|
+
const waiver = (check._waiver && typeof check._waiver === "object") ? check._waiver as AnyObj : null;
|
|
610
|
+
const evClass = waiver
|
|
611
|
+
? { evidenceType: "attestation", method: "attestation", reconcilable: false }
|
|
612
|
+
: classifyEvidence(check.kind, cmd.length > 0);
|
|
613
|
+
const policy = ensurePolicy(legacyClaimType, "high", [evClass.evidenceType]);
|
|
409
614
|
const effectiveStatus = captured ? captured.observedResult : String(check.status ?? "");
|
|
410
|
-
const evStatus = checkStatusToEventStatus(effectiveStatus);
|
|
615
|
+
const evStatus = waiver ? "assumed" : checkStatusToEventStatus(effectiveStatus);
|
|
616
|
+
// Promotion claim marker (issue #312): a `promote` check carries a session-local
|
|
617
|
+
// _promotion object that must survive onto claim.metadata.promotion so the archive gate
|
|
618
|
+
// (workflow-artifact-cleanup-audit) and validators can detect the promotion claim without a
|
|
619
|
+
// new manifest entry. It rides alongside any waiver in a single merged metadata object.
|
|
620
|
+
const promotionMeta = (check._promotion && typeof check._promotion === "object") ? check._promotion as AnyObj : null;
|
|
621
|
+
// #268: stamp a stable origin discriminator so checksFromBundle / critiquesFromBundle can
|
|
622
|
+
// distinguish check vs critique vs acceptance claims across round-trips even under --flow-id,
|
|
623
|
+
// where all three collapse onto the same declared claimType (and a command-less critique claim
|
|
624
|
+
// would otherwise be re-absorbed as a test_output check → permanent [not-run] divergence).
|
|
625
|
+
const claimMetadata: AnyObj = { origin: "check", check_kind: String(check.kind ?? "external"), ...(waiver ? { waiver } : {}), ...(promotionMeta ? { promotion: promotionMeta } : {}) };
|
|
411
626
|
|
|
412
627
|
const claimEvents: AnyObj[] = [];
|
|
413
628
|
if (evStatus) {
|
|
@@ -415,11 +630,17 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
415
630
|
events.push(evt);
|
|
416
631
|
claimEvents.push(evt);
|
|
417
632
|
}
|
|
418
|
-
const evItem: AnyObj = { id: evId, claimId, evidenceType:
|
|
633
|
+
const evItem: AnyObj = { id: evId, claimId, evidenceType: evClass.evidenceType, method: evClass.method, sourceRef: `${slug}/evidence.json`, excerptOrSummary: fieldOrBehavior, observedAt: ts, collectedBy: "flow-agents/workflow-sidecar", passing: effectiveStatus === "pass" };
|
|
419
634
|
if (captured) {
|
|
420
635
|
evItem.sourceRef = `${slug}/command-log.jsonl`;
|
|
421
636
|
evItem.collectedBy = "flow-agents/evidence-capture";
|
|
422
637
|
evItem.execution = { runner: "bash", label: cmd, isError: captured.observedResult === "fail", ...(captured.exitCode != null ? { exitCode: captured.exitCode } : {}) };
|
|
638
|
+
} else if (cmd && !waiver) {
|
|
639
|
+
// WS8 (ADR 0020): always stamp execution.label on command-backed checks so the CI
|
|
640
|
+
// reconciler has a stable key to match against the manifest, even when the local
|
|
641
|
+
// command-log capture did not happen to run this command. isError is derived from
|
|
642
|
+
// the check's own reported status (no captured exit code available in this path).
|
|
643
|
+
evItem.execution = { runner: "bash", label: cmd, isError: effectiveStatus !== "pass" };
|
|
423
644
|
}
|
|
424
645
|
evidenceItems.push(evItem);
|
|
425
646
|
|
|
@@ -428,13 +649,13 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
428
649
|
const declared = matchExpectsEntry("check", check.kind, typeof check._gate_claim_expectation_id === "string" ? check._gate_claim_expectation_id : undefined);
|
|
429
650
|
if (declared) {
|
|
430
651
|
// Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
|
|
431
|
-
const declaredPolicy = ensurePolicy(declared.claimType, "high", [
|
|
432
|
-
const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId,
|
|
652
|
+
const declaredPolicy = ensurePolicy(declared.claimType, "high", [evClass.evidenceType]);
|
|
653
|
+
const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: effectiveStatus, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, ...(claimMetadata ? { metadata: claimMetadata } : {}) };
|
|
433
654
|
const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [evItem] as Record<string, unknown>[], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
|
|
434
655
|
claims.push({ ...declaredClaimObj, status: declaredStatus });
|
|
435
656
|
} else {
|
|
436
657
|
// No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
|
|
437
|
-
const claimObj: AnyObj = { id: claimId, subjectType: "workflow-check", subjectId,
|
|
658
|
+
const claimObj: AnyObj = { id: claimId, subjectType: "workflow-check", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: effectiveStatus, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id, ...(claimMetadata ? { metadata: claimMetadata } : {}) };
|
|
438
659
|
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [evItem] as Record<string, unknown>[], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
|
|
439
660
|
claims.push({ ...claimObj, status: derivedStatus });
|
|
440
661
|
}
|
|
@@ -461,12 +682,12 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
461
682
|
if (declared) {
|
|
462
683
|
// Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
|
|
463
684
|
const declaredPolicy = ensurePolicy(declared.claimType, "high", []);
|
|
464
|
-
const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId,
|
|
685
|
+
const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, metadata: { origin: "acceptance" } };
|
|
465
686
|
const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
|
|
466
687
|
claims.push({ ...declaredClaimObj, status: declaredStatus });
|
|
467
688
|
} else {
|
|
468
689
|
// No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
|
|
469
|
-
const claimObj: AnyObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId,
|
|
690
|
+
const claimObj: AnyObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id, metadata: { origin: "acceptance" } };
|
|
470
691
|
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
|
|
471
692
|
claims.push({ ...claimObj, status: derivedStatus });
|
|
472
693
|
}
|
|
@@ -477,10 +698,23 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
477
698
|
if (!c.id) continue;
|
|
478
699
|
const subjectId = `${slug}/${c.id}`;
|
|
479
700
|
const fieldOrBehavior = String(c.summary ?? c.verdict ?? c.id);
|
|
480
|
-
|
|
701
|
+
// #267/#282: a critique carrying `superseded_by` is retained as HISTORY — a prior write for
|
|
702
|
+
// this critique id that a later, same-reviewer critique resolved. It is preserved structurally
|
|
703
|
+
// (status "superseded" + first-class metadata.superseded_by), but is excluded from reconcile
|
|
704
|
+
// evaluation and from the "critique pass cannot include fail members" validator rule.
|
|
705
|
+
const supersededBy = typeof c.superseded_by === "string" && c.superseded_by.length > 0 ? c.superseded_by : null;
|
|
706
|
+
const critiqueReviewer = String(c.reviewer ?? "tool-code-reviewer");
|
|
707
|
+
const critiqueReviewedAt = String(c.reviewed_at ?? ts);
|
|
708
|
+
const critMeta: AnyObj = { origin: "critique", reviewer: critiqueReviewer, reviewed_at: critiqueReviewedAt, ...(supersededBy ? { superseded_by: supersededBy } : {}) };
|
|
709
|
+
// A superseded historical write gets a distinct, stable claimId so it co-exists with the live
|
|
710
|
+
// claim of the same critique id (never overwrites or duplicates it). The salt is reproducible
|
|
711
|
+
// across rebuilds because superseded_by + reviewed_at are preserved in metadata.
|
|
712
|
+
const claimIdSalt = supersededBy ? `${fieldOrBehavior}::superseded::${supersededBy}::${critiqueReviewedAt}` : fieldOrBehavior;
|
|
713
|
+
const claimId = generateClaimId(subjectId, "flow-agents.workflow", claimIdSalt);
|
|
481
714
|
const legacyClaimType = "workflow.critique.review";
|
|
482
715
|
const policy = ensurePolicy(legacyClaimType, "medium", []);
|
|
483
|
-
|
|
716
|
+
// A superseded write emits NO verification event (its status is "superseded" directly).
|
|
717
|
+
const evStatus = supersededBy ? null : critiqueToEventStatus(String(c.verdict ?? ""), c.findings ?? []);
|
|
484
718
|
const claimEvents: AnyObj[] = [];
|
|
485
719
|
if (evStatus) {
|
|
486
720
|
const evt: AnyObj = { id: `evt:${claimId}`, claimId, status: evStatus, actor: "flow-agents/workflow-sidecar", method: "validation", evidenceIds: [], createdAt: ts, verifiedAt: ts };
|
|
@@ -490,22 +724,21 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
|
|
|
490
724
|
|
|
491
725
|
// P-d: declared-only when active flow/step present (shadow retired); no-flow path unchanged.
|
|
492
726
|
const declared = matchExpectsEntry("critique");
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
727
|
+
const claimType = declared ? declared.claimType : legacyClaimType;
|
|
728
|
+
const subjectType = declared ? declared.subjectType : "workflow-critique";
|
|
729
|
+
const claimPolicy = declared ? ensurePolicy(declared.claimType, "medium", []) : policy;
|
|
730
|
+
const claimObj: AnyObj = { id: claimId, subjectType, subjectId, facet: "flow-agents.workflow", claimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: claimPolicy.id, metadata: critMeta };
|
|
731
|
+
if (supersededBy) {
|
|
732
|
+
// History: status is "superseded" directly (no verification event); excluded from evaluation.
|
|
733
|
+
claims.push({ ...claimObj, status: "superseded" });
|
|
499
734
|
} else {
|
|
500
|
-
|
|
501
|
-
const claimObj: AnyObj = { 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 };
|
|
502
|
-
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
|
|
735
|
+
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [claimPolicy] as Record<string, unknown>[] });
|
|
503
736
|
claims.push({ ...claimObj, status: derivedStatus });
|
|
504
737
|
}
|
|
505
738
|
}
|
|
506
739
|
|
|
507
740
|
return {
|
|
508
|
-
schemaVersion:
|
|
741
|
+
schemaVersion: 5,
|
|
509
742
|
source: `flow-agents/workflow-sidecar;statusFunctionVersion=${statusFunctionVersion}`,
|
|
510
743
|
claims,
|
|
511
744
|
evidence: evidenceItems,
|
|
@@ -536,7 +769,7 @@ export async function writeTrustBundle(dir: string, slug: string, timestamp: str
|
|
|
536
769
|
const raw = fs.readFileSync(path.join(dir, "command-log.jsonl"), "utf8");
|
|
537
770
|
commandLog = raw.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => { try { return JSON.parse(l) as AnyObj; } catch { return null; } }).filter((x): x is AnyObj => x !== null);
|
|
538
771
|
} catch { /* no capture log — fine */ }
|
|
539
|
-
// ADR 0016 Abstraction A (P-d): pass the
|
|
772
|
+
// ADR 0016 Abstraction A (P-d): pass the runtime artifact root ONLY when current.json
|
|
540
773
|
// points to this session (scoped active-flow guard). If current.json.artifact_dir
|
|
541
774
|
// resolves to a different session, pass null — no active-flow claim mapping for this bundle.
|
|
542
775
|
const _flowAgentsDir = path.dirname(dir);
|
|
@@ -743,6 +976,20 @@ function definitionAcceptanceLines(markdown: string): string[] {
|
|
|
743
976
|
}
|
|
744
977
|
return out;
|
|
745
978
|
}
|
|
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: string, name: string): string {
|
|
990
|
+
const re = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\s*(.+)$`, "m");
|
|
991
|
+
return re.exec(markdown)?.[1]?.trim() ?? "";
|
|
992
|
+
}
|
|
746
993
|
function parseCriterion(line: string, index: number): AnyObj {
|
|
747
994
|
let text = line.replace(/^-\s+\[[ xX]\]\s*/, "").trim();
|
|
748
995
|
const m = /\s+-\s+Evidence:\s*(.+)$/i.exec(text);
|
|
@@ -777,14 +1024,20 @@ function validateAgentId(agent: string): string {
|
|
|
777
1024
|
|
|
778
1025
|
/**
|
|
779
1026
|
* Find the repository root by walking upward from a starting directory to locate
|
|
780
|
-
* the nearest ancestor containing a kits/ subdirectory
|
|
781
|
-
*
|
|
782
|
-
*
|
|
1027
|
+
* the nearest ancestor containing a kits/ subdirectory, without falling back to
|
|
1028
|
+
* process.cwd(). Returns null when no kits/ ancestor is found within the walk
|
|
1029
|
+
* bound (e.g. a scratch/temp session directory with no repo ancestor at all).
|
|
783
1030
|
*
|
|
784
|
-
*
|
|
785
|
-
*
|
|
1031
|
+
* WS5 iteration-2 part 2: extracted so publishDelivery's repo-root resolution can
|
|
1032
|
+
* be fail-closed (see findRepoRootFromDir below and publishDelivery). A scratch
|
|
1033
|
+
* test session dir (mktemp -d, no kits/ ancestor) must never resolve to whatever
|
|
1034
|
+
* repo happens to be the current process's cwd — that previously let a throwaway
|
|
1035
|
+
* eval-local trust.bundle silently clobber this real repo's delivery/trust.bundle
|
|
1036
|
+
* when the eval was run from a checkout of this repo (see
|
|
1037
|
+
* evals/integration/test_checkpoint_signing.sh TEST 2 and the WS5 session findings
|
|
1038
|
+
* at .kontourai/flow-agents/ws5-governance-kit-slice1).
|
|
786
1039
|
*/
|
|
787
|
-
function
|
|
1040
|
+
function findRepoRootFromDirStrict(startDir: string): string | null {
|
|
788
1041
|
let dir = startDir;
|
|
789
1042
|
for (let i = 0; i < 16; i++) {
|
|
790
1043
|
if (fs.existsSync(path.join(dir, "kits"))) return dir;
|
|
@@ -792,7 +1045,24 @@ function findRepoRootFromDir(startDir: string): string {
|
|
|
792
1045
|
if (parent === dir) break;
|
|
793
1046
|
dir = parent;
|
|
794
1047
|
}
|
|
795
|
-
return
|
|
1048
|
+
return null;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* Find the repository root by walking upward from a starting directory to locate
|
|
1053
|
+
* the nearest ancestor containing a kits/ subdirectory. Mirrors flow-resolver.ts
|
|
1054
|
+
* findRepoRoot, but callable from workflow-sidecar.ts without re-importing the
|
|
1055
|
+
* internal helper. Falls back to process.cwd() when no kits/ ancestor is found —
|
|
1056
|
+
* appropriate for phase-map/first-step resolution (ADR 0016 Abstraction A, P-d),
|
|
1057
|
+
* where the caller is always invoked from within a real repo checkout.
|
|
1058
|
+
*
|
|
1059
|
+
* Do NOT use this cwd-falling-back variant for publishDelivery's repo-root
|
|
1060
|
+
* resolution — use findRepoRootFromDirStrict there instead, so a scratch/test
|
|
1061
|
+
* session dir with no repo ancestor fails closed (skips publish) rather than
|
|
1062
|
+
* silently trusting process.cwd(), which could be an unrelated real repo.
|
|
1063
|
+
*/
|
|
1064
|
+
function findRepoRootFromDir(startDir: string): string {
|
|
1065
|
+
return findRepoRootFromDirStrict(startDir) ?? process.cwd();
|
|
796
1066
|
}
|
|
797
1067
|
|
|
798
1068
|
/**
|
|
@@ -825,7 +1095,11 @@ function resolveFirstStep(flowId: string, repoRoot: string): string | null {
|
|
|
825
1095
|
}
|
|
826
1096
|
}
|
|
827
1097
|
|
|
828
|
-
function writeCurrent(root: string, dir: string, timestamp: string, owner: string, source: string, flowId?: string, stepId?: string): void {
|
|
1098
|
+
function writeCurrent(root: string, dir: string, timestamp: string, owner: string, source: string, flowId?: string, stepId?: string, adHocReason?: string): void {
|
|
1099
|
+
// #289: mirror the active session's already-recorded branch (state.json.branch) into
|
|
1100
|
+
// current.json so consumers of current.json (which has no schema of its own — not one of the
|
|
1101
|
+
// 9 schemas under schemas/) see the routing branch without re-reading state.json separately.
|
|
1102
|
+
const branch = loadJson(path.join(dir, "state.json")).branch;
|
|
829
1103
|
writeJson(path.join(root, "current.json"), {
|
|
830
1104
|
schema_version: "1.0",
|
|
831
1105
|
active_slug: path.basename(dir),
|
|
@@ -834,11 +1108,17 @@ function writeCurrent(root: string, dir: string, timestamp: string, owner: strin
|
|
|
834
1108
|
owner,
|
|
835
1109
|
source,
|
|
836
1110
|
active_agents: [],
|
|
1111
|
+
...(branch ? { branch } : {}),
|
|
837
1112
|
// ADR 0016 Abstraction A (P-a): optional FlowDefinition routing keys for the producer
|
|
838
1113
|
// and enforcer. Both fields are optional and backward-compatible — sessions without a
|
|
839
1114
|
// FlowDefinition omit them and fall through to the workflow.* claim type path.
|
|
840
1115
|
...(flowId ? { active_flow_id: flowId } : {}),
|
|
841
1116
|
...(stepId ? { active_step_id: stepId } : {}),
|
|
1117
|
+
// WS8 (AC12): sanctioned ad-hoc direct entry marker. Set when --step-id explicitly
|
|
1118
|
+
// targets a step other than the flow's resolved first step, so stop-goal-fit /
|
|
1119
|
+
// gate-review can distinguish an intentional direct entry (e.g. a planning-only
|
|
1120
|
+
// session that skips pull-work) from a stale/mis-stamped active_step_id.
|
|
1121
|
+
...(adHocReason ? { ad_hoc_entry: true, ad_hoc_reason: adHocReason } : {}),
|
|
842
1122
|
});
|
|
843
1123
|
}
|
|
844
1124
|
function loadCurrent(root: string): AnyObj | null {
|
|
@@ -850,7 +1130,13 @@ function currentDir(root: string): string | null {
|
|
|
850
1130
|
const c = loadCurrent(root);
|
|
851
1131
|
if (!c) return null;
|
|
852
1132
|
const dir = path.resolve(root, c.artifact_dir ?? c.active_slug ?? "");
|
|
853
|
-
|
|
1133
|
+
if (!fs.existsSync(dir)) return null;
|
|
1134
|
+
try {
|
|
1135
|
+
requireArtifactDirUnderRoot(dir, root);
|
|
1136
|
+
} catch {
|
|
1137
|
+
return null;
|
|
1138
|
+
}
|
|
1139
|
+
return dir;
|
|
854
1140
|
}
|
|
855
1141
|
function updateCurrentAgent(root: string, dir: string, agentId: string, status: string, timestamp: string): void {
|
|
856
1142
|
const cur = loadCurrent(root);
|
|
@@ -864,8 +1150,38 @@ function updateCurrentAgent(root: string, dir: string, agentId: string, status:
|
|
|
864
1150
|
|
|
865
1151
|
function initSidecars(dir: string, slug: string, sourceRequest: string, summary: string, nextAction: string, timestamp: string, markdown?: string): void {
|
|
866
1152
|
const criteria = markdown ? definitionAcceptanceLines(markdown).map(parseCriterion) : [];
|
|
1153
|
+
// #289/#309: `markdown` here is NOT always the session `<slug>--deliver.md` that
|
|
1154
|
+
// ensureSession seeds the `branch:` line into — initPlan is called against the tool-planner's
|
|
1155
|
+
// PLAN artifact (`<slug>--plan-work.md` etc.), a different file that typically carries no
|
|
1156
|
+
// `branch:` line at all. Since this function fully rewrites state.json (no merge with the
|
|
1157
|
+
// prior contents), naively re-deriving branch from `markdown` alone would clobber whatever
|
|
1158
|
+
// ensure-session already recorded there moments earlier (#309 regression). Resolve branch with
|
|
1159
|
+
// a three-tier fallback, preferring the most durable source first:
|
|
1160
|
+
// 1. EXISTING state.json.branch — once recorded, it is never re-derived or clobbered.
|
|
1161
|
+
// 2. The session's OWN canonical `<dir>/<slug>--deliver.md` on disk — the file ensureSession
|
|
1162
|
+
// always seeds the `branch:` line into, independent of whatever `markdown` was passed in.
|
|
1163
|
+
// Reading it directly (rather than trusting `markdown`) is what makes a repaired/backfilled
|
|
1164
|
+
// init-plan call (re-run against the same branch-less plan artifact, after a #309-era
|
|
1165
|
+
// session lost its state.json branch) recover the branch without a direct file edit.
|
|
1166
|
+
// 3. The `markdown` param itself (covers ensureSession's fresh-creation call, before
|
|
1167
|
+
// `<slug>--deliver.md` differs from `markdown`, and any direct/legacy caller that passes
|
|
1168
|
+
// the deliver markdown itself as the init-plan artifact).
|
|
1169
|
+
// This makes the branch survive every subsequent initSidecars call (init-plan, or a resumed
|
|
1170
|
+
// ensure-session) at this single choke point, without patching every other writer.
|
|
1171
|
+
const existingState = loadJson(path.join(dir, "state.json"));
|
|
1172
|
+
const existingBranch = existingState.branch;
|
|
1173
|
+
const deliverMdPath = path.join(dir, `${slug}--deliver.md`);
|
|
1174
|
+
const deliverBranch = fs.existsSync(deliverMdPath) ? markdownField(read(deliverMdPath), "branch") : "";
|
|
1175
|
+
const branch = existingBranch || deliverBranch || (markdown ? markdownField(markdown, "branch") : "");
|
|
1176
|
+
// #309 (scope addition): created_at is write-once, same class as the branch-drop bug above —
|
|
1177
|
+
// initSidecars fully rewrites state.json on every call (including a repair/backfill re-run of
|
|
1178
|
+
// init-plan against an already-existing session), so naively re-stamping created_at from the
|
|
1179
|
+
// current call's timestamp silently rewrites a session's original creation time. Preserve the
|
|
1180
|
+
// existing state.json's created_at when present; stamp only on true first-creation.
|
|
1181
|
+
// updated_at still reflects "now" on every call — that field is intentionally mutable.
|
|
867
1182
|
writeJson(path.join(dir, "state.json"), {
|
|
868
|
-
...sidecarBase(slug), status: "planned", phase: "planning", created_at: timestamp, updated_at: timestamp,
|
|
1183
|
+
...sidecarBase(slug), status: "planned", phase: "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
|
|
1184
|
+
...(branch ? { branch } : {}),
|
|
869
1185
|
artifact_paths: relArtifacts(dir),
|
|
870
1186
|
next_action: { status: "continue", summary: nextAction || summary },
|
|
871
1187
|
});
|
|
@@ -887,7 +1203,12 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
|
|
|
887
1203
|
const timestamp = opt(p, "timestamp", now());
|
|
888
1204
|
let md = fs.existsSync(path.join(dir, `${slug}--deliver.md`)) ? read(path.join(dir, `${slug}--deliver.md`)) : "";
|
|
889
1205
|
if (!md) {
|
|
890
|
-
|
|
1206
|
+
// #289: derive the routing branch ONLY on fresh session creation (this `if (!md)` guard).
|
|
1207
|
+
// An existing session's already-recorded `branch:` line is never touched — that whole branch
|
|
1208
|
+
// of code is skipped on a resumed/taken-over session, which is what makes ADR 0021 §5
|
|
1209
|
+
// takeover continuity true by construction (see Design Decision 3 in the plan).
|
|
1210
|
+
const branch = resolveSessionBranch(p, slug);
|
|
1211
|
+
md = `# ${opt(p, "title", slug)}\n\nbranch: ${branch}\nworktree: main\ncreated: ${timestamp}\nstatus: planning\ntype: deliver\niteration: 1\n\n## Plan\n\n${opt(p, "summary", "")}\n\n## Definition Of Done\n\n- **User outcome:** ${opt(p, "summary", "Workflow session is durable.")}\n- **Scope:** Workflow session artifacts and sidecars.\n- **Acceptance criteria:**\n${opts(p, "criterion").map((c) => ` - [ ] ${c} - Evidence: pending.`).join("\n")}\n- **Usefulness checks:**\n - [ ] User-facing workflow is documented or discoverable\n- **Stop-short risks:** Workflow artifacts could drift.\n- **Durable docs target:** not needed\n- **Sandbox mode:** local-edit\n\n## Execution Progress\n\n- [ ] Session initialized.\n\n## Verification Report\n\nBuild: [NOT_VERIFIED] Verification has not run yet.\n\n### Acceptance Criteria\n- [NOT_VERIFIED] Verification has not run yet - Evidence: pending workflow execution and checks.\n\n### Verdict: NOT_VERIFIED\n\n## Goal Fit Gate\n\n- [ ] Original user goal restated\n\n## Final Acceptance\n\n- [ ] CI/relevant checks passed or local equivalent recorded\n`;
|
|
891
1212
|
fs.writeFileSync(path.join(dir, `${slug}--deliver.md`), md);
|
|
892
1213
|
}
|
|
893
1214
|
if (!fs.existsSync(path.join(dir, "state.json")) || !fs.existsSync(path.join(dir, "acceptance.json")) || !fs.existsSync(path.join(dir, "handoff.json"))) {
|
|
@@ -901,13 +1222,26 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
|
|
|
901
1222
|
// ensure-session --flow-id builder.build produces a FlowDefinition-driven session even
|
|
902
1223
|
// before the first advance-state call.
|
|
903
1224
|
const flowId = opt(p, "flow-id");
|
|
904
|
-
|
|
1225
|
+
const explicitStep = opt(p, "step-id");
|
|
1226
|
+
let stepId = explicitStep;
|
|
1227
|
+
let adHocReason: string | undefined;
|
|
905
1228
|
if (flowId && !stepId) {
|
|
906
1229
|
const repoRoot = findRepoRootFromDir(dir);
|
|
907
1230
|
const firstStep = resolveFirstStep(flowId, repoRoot);
|
|
908
1231
|
if (firstStep) stepId = firstStep;
|
|
1232
|
+
} else if (flowId && explicitStep) {
|
|
1233
|
+
// WS8 (AC12): --step-id is the sanctioned ad-hoc direct-entry mechanism. When it names
|
|
1234
|
+
// a step other than the flow's resolved first step, record an explicit ad_hoc_entry
|
|
1235
|
+
// marker (with a reason) instead of silently letting the mis-stamp look like the
|
|
1236
|
+
// flow's normal first step. This is the root-cause fix for a planning-only session
|
|
1237
|
+
// whose active_step_id would otherwise default to builder.build's first step.
|
|
1238
|
+
const repoRoot = findRepoRootFromDir(dir);
|
|
1239
|
+
const firstStep = resolveFirstStep(flowId, repoRoot);
|
|
1240
|
+
if (firstStep && firstStep !== explicitStep) {
|
|
1241
|
+
adHocReason = opt(p, "ad-hoc-reason") || `direct entry at step "${explicitStep}" via --step-id (flow first step is "${firstStep}")`;
|
|
1242
|
+
}
|
|
909
1243
|
}
|
|
910
|
-
writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", flowId || undefined, stepId || undefined);
|
|
1244
|
+
writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", flowId || undefined, stepId || undefined, adHocReason);
|
|
911
1245
|
console.log(dir);
|
|
912
1246
|
return 0;
|
|
913
1247
|
}
|
|
@@ -928,7 +1262,7 @@ function recordAgentEvent(p: ReturnType<typeof parseArgs>): number {
|
|
|
928
1262
|
const dir = explicit ? path.resolve(explicit) : currentDir(root);
|
|
929
1263
|
if (!dir || !fs.existsSync(dir)) die("artifact directory does not exist");
|
|
930
1264
|
if (explicit && fs.lstatSync(dir).isSymbolicLink()) die(`artifact directory must not be a symlink: ${dir}`);
|
|
931
|
-
if (hasExplicitRoot) requireArtifactDirUnderRoot(dir, root);
|
|
1265
|
+
if (hasExplicitRoot || !explicit) requireArtifactDirUnderRoot(dir, root);
|
|
932
1266
|
const timestamp = opt(p, "timestamp", now());
|
|
933
1267
|
const agent = validateAgentId(opt(p, "agent-id"));
|
|
934
1268
|
const event = { timestamp, agent_id: agent, kind: opt(p, "kind", "note"), status: opt(p, "status", "info"), summary: opt(p, "summary"), ...(opt(p, "ref") ? { ref: opt(p, "ref") } : {}) };
|
|
@@ -986,7 +1320,7 @@ export function normalizeEvidenceRefs(raw: unknown, label: string): AnyObj[] {
|
|
|
986
1320
|
export function normalizeCheck(raw: AnyObj): AnyObj {
|
|
987
1321
|
const check = { ...raw };
|
|
988
1322
|
if (!check.id || !check.kind || !check.status || !check.summary) die("check requires id, kind, status, and summary");
|
|
989
|
-
|
|
1323
|
+
if (!checkKinds.has(check.kind)) die("kind must be one of: build, types, lint, test, command, security, diff, browser, runtime, policy, external");
|
|
990
1324
|
if (!checkStatuses.has(check.status)) die("status must be one of: pass, fail, not_verified, skip");
|
|
991
1325
|
if (Array.isArray(check.standard_refs)) for (const ref of check.standard_refs) if (!["junit", "sarif", "coverage", "veritas"].includes(ref.standard)) die("standard must be one of");
|
|
992
1326
|
if (check.artifact_refs) check.artifact_refs = normalizeEvidenceRefs(check.artifact_refs, "artifact_refs");
|
|
@@ -1121,104 +1455,141 @@ export function writeState(dir: string, slug: string, status: string, phase: str
|
|
|
1121
1455
|
// Extract checks and critiques from the existing trust.bundle for callers that
|
|
1122
1456
|
// need to rebuild the bundle (e.g. record-critique, record-learning).
|
|
1123
1457
|
|
|
1124
|
-
//
|
|
1125
|
-
//
|
|
1126
|
-
//
|
|
1127
|
-
//
|
|
1128
|
-
// is active, the set contains the kit-typed claimTypes (e.g. "builder.verify.tests",
|
|
1129
|
-
// "builder.verify.policy-compliance") so round-trip helpers broaden their filters
|
|
1130
|
-
// to include declared claims alongside the legacy workflow.* ones.
|
|
1458
|
+
// #268/#344: buildTrustBundle stamps a stable origin discriminator ("check" | "acceptance" |
|
|
1459
|
+
// "critique") plus check_kind (for origin "check") on EVERY claim it writes. These stamps are
|
|
1460
|
+
// AUTHORITATIVE and the ONLY way checksFromBundle/critiquesFromBundle (and evidenceClean/
|
|
1461
|
+
// critiqueClean below) classify a claim.
|
|
1131
1462
|
//
|
|
1132
|
-
//
|
|
1133
|
-
//
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
1136
|
-
//
|
|
1137
|
-
//
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1463
|
+
// Hard cutover (owner directive, no legacy fallbacks): there is deliberately no claimType-
|
|
1464
|
+
// derivation fallback for unstamped claims. A prior version of this file fell back to
|
|
1465
|
+
// classifying an unstamped claim by claimType heuristic — that fallback WAS the #268 defect
|
|
1466
|
+
// kept reachable (a no-evidence declared claim silently re-absorbed as a command-less
|
|
1467
|
+
// test_output check, and a critique claim silently re-absorbed as a check, corrupting the
|
|
1468
|
+
// round-trip catastrophically under --flow-id). An unstamped claim means the bundle predates
|
|
1469
|
+
// #344 and must be regenerated, not silently reclassified — see requireStampedClaim below.
|
|
1470
|
+
function claimOrigin(claim: AnyObj): string | null {
|
|
1471
|
+
const md = claim && (claim as AnyObj).metadata;
|
|
1472
|
+
return md && typeof md === "object" && typeof (md as AnyObj).origin === "string" && (md as AnyObj).origin.length > 0 ? String((md as AnyObj).origin) : null;
|
|
1473
|
+
}
|
|
1474
|
+
// Fails loud — never silent, never a heuristic reclassification — when a claim in `dir`'s
|
|
1475
|
+
// trust.bundle lacks its metadata.origin stamp, or (for an origin==="check" claim) its
|
|
1476
|
+
// metadata.check_kind stamp. Names the session dir and the remedy so the caller can regenerate
|
|
1477
|
+
// a fresh, fully-stamped bundle instead of reading a pre-supersession one.
|
|
1478
|
+
function requireStampedClaim(claim: AnyObj, dir: string): string {
|
|
1479
|
+
if (!claim || typeof claim !== "object") die(`trust.bundle in ${dir} contains a malformed claim entry — cannot read.`);
|
|
1480
|
+
const remedy = `re-record evidence to regenerate: npm run workflow:sidecar -- record-evidence ${dir} --verdict <verdict> --check-json <...>`;
|
|
1481
|
+
const origin = claimOrigin(claim);
|
|
1482
|
+
if (!origin) {
|
|
1483
|
+
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}`);
|
|
1484
|
+
}
|
|
1485
|
+
if (origin === "check") {
|
|
1486
|
+
const md = (claim.metadata && typeof claim.metadata === "object") ? claim.metadata as AnyObj : {};
|
|
1487
|
+
if (typeof md.check_kind !== "string" || md.check_kind.length === 0) {
|
|
1488
|
+
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}`);
|
|
1489
|
+
}
|
|
1151
1490
|
}
|
|
1152
|
-
|
|
1153
|
-
if (!activeStep || activeStep.gateExpects.length === 0) return new Set<string>();
|
|
1154
|
-
return new Set<string>(activeStep.gateExpects.map((e) => e.bundle_claim.claimType));
|
|
1491
|
+
return origin;
|
|
1155
1492
|
}
|
|
1156
|
-
|
|
1157
|
-
function checksFromBundle(dir: string, declaredClaimTypes: Set<string> = new Set()): AnyObj[] {
|
|
1493
|
+
function checksFromBundle(dir: string): AnyObj[] {
|
|
1158
1494
|
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
1159
|
-
if (!Array.isArray(bundle.evidence)) return [];
|
|
1160
1495
|
const allClaims: AnyObj[] = Array.isArray(bundle.claims) ? bundle.claims : [];
|
|
1496
|
+
// Validate stamps on every claim up front — any unstamped claim anywhere in the bundle marks
|
|
1497
|
+
// it pre-supersession, regardless of whether it is check/acceptance/critique-typed.
|
|
1498
|
+
for (const claim of allClaims) requireStampedClaim(claim, dir);
|
|
1499
|
+
if (!Array.isArray(bundle.evidence)) return [];
|
|
1161
1500
|
const claimById = new Map<string, AnyObj>();
|
|
1162
1501
|
for (const c of allClaims) if (c && c.id) claimById.set(c.id, c);
|
|
1163
1502
|
const seen = new Set<string>();
|
|
1164
1503
|
const checks: AnyObj[] = [];
|
|
1504
|
+
const kindOf = (claim: AnyObj): string => String((claim.metadata as AnyObj).check_kind);
|
|
1165
1505
|
for (const ev of bundle.evidence) {
|
|
1166
1506
|
if (!ev || !ev.claimId) continue;
|
|
1167
1507
|
const claim = claimById.get(ev.claimId);
|
|
1168
1508
|
if (!claim) continue;
|
|
1169
|
-
|
|
1170
|
-
// ADR 0016 Step 0: broaden to include declared kit-typed claims alongside workflow.check.*
|
|
1171
|
-
if (!ct.startsWith("workflow.check.") && !declaredClaimTypes.has(ct)) continue;
|
|
1509
|
+
if (claimOrigin(claim) !== "check") continue;
|
|
1172
1510
|
if (seen.has(ev.claimId)) continue;
|
|
1173
1511
|
seen.add(ev.claimId);
|
|
1174
|
-
const kind =
|
|
1512
|
+
const kind = kindOf(claim);
|
|
1175
1513
|
const status = claim.value ?? "not_verified";
|
|
1176
1514
|
const check: AnyObj = { id: String(claim.subjectId || "").split("/").pop() || ev.claimId, kind, status, summary: claim.fieldOrBehavior || "" };
|
|
1177
1515
|
if (ev.execution && typeof ev.execution.label === "string") check.command = ev.execution.label;
|
|
1178
1516
|
if (ev.evidenceType) check.evidenceType = ev.evidenceType;
|
|
1179
1517
|
checks.push(check);
|
|
1180
1518
|
}
|
|
1181
|
-
// Also include check claims that have no evidence item (surface_trust_refs style)
|
|
1519
|
+
// Also include check claims that have no evidence item (surface_trust_refs style).
|
|
1182
1520
|
for (const claim of allClaims) {
|
|
1183
1521
|
if (!claim) continue;
|
|
1184
|
-
|
|
1185
|
-
// ADR 0016 Step 0: broaden to include declared kit-typed claims alongside workflow.check.*
|
|
1186
|
-
if (!ct.startsWith("workflow.check.") && !declaredClaimTypes.has(ct)) continue;
|
|
1522
|
+
if (claimOrigin(claim) !== "check") continue;
|
|
1187
1523
|
if (seen.has(claim.id)) continue;
|
|
1188
1524
|
seen.add(claim.id);
|
|
1189
|
-
const kind =
|
|
1525
|
+
const kind = kindOf(claim);
|
|
1190
1526
|
checks.push({ id: String(claim.subjectId || "").split("/").pop() || claim.id, kind, status: claim.value ?? "not_verified", summary: claim.fieldOrBehavior || "" });
|
|
1191
1527
|
}
|
|
1192
1528
|
return checks;
|
|
1193
1529
|
}
|
|
1194
|
-
function critiquesFromBundle(dir: string
|
|
1530
|
+
function critiquesFromBundle(dir: string): AnyObj[] {
|
|
1195
1531
|
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
1196
1532
|
if (!Array.isArray(bundle.claims)) return [];
|
|
1197
|
-
|
|
1198
|
-
//
|
|
1199
|
-
//
|
|
1200
|
-
//
|
|
1201
|
-
const
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1533
|
+
for (const c of bundle.claims) requireStampedClaim(c, dir);
|
|
1534
|
+
// A claim is a CRITIQUE when its origin is "critique" (authoritative — see requireStampedClaim
|
|
1535
|
+
// above). reviewer / reviewed_at / superseded_by are read back from metadata so supersession
|
|
1536
|
+
// (#267/#282) round-trips losslessly.
|
|
1537
|
+
const critiqueClaims = bundle.claims.filter((c: AnyObj) => c && claimOrigin(c) === "critique");
|
|
1538
|
+
return critiqueClaims.map((c: AnyObj) => {
|
|
1539
|
+
const md = (c.metadata && typeof c.metadata === "object") ? c.metadata as AnyObj : {};
|
|
1540
|
+
return {
|
|
1541
|
+
id: String(c.subjectId || "").split("/").pop() || c.id,
|
|
1542
|
+
verdict: c.value ?? "not_verified",
|
|
1543
|
+
summary: c.fieldOrBehavior || "",
|
|
1544
|
+
findings: [],
|
|
1545
|
+
reviewer: typeof md.reviewer === "string" ? md.reviewer : "tool-code-reviewer",
|
|
1546
|
+
reviewed_at: typeof md.reviewed_at === "string" ? md.reviewed_at : (c.updatedAt || c.createdAt || now()),
|
|
1547
|
+
artifact_refs: [],
|
|
1548
|
+
...(typeof md.superseded_by === "string" && md.superseded_by.length > 0 ? { superseded_by: md.superseded_by } : {}),
|
|
1549
|
+
};
|
|
1550
|
+
});
|
|
1214
1551
|
}
|
|
1215
1552
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1553
|
+
/**
|
|
1554
|
+
* WS8 (ADR 0020): parse the accepted-gap waiver flags. Both --accepted-gap-reason and
|
|
1555
|
+
* --waived-by are required together (an accepted gap with no justification or no
|
|
1556
|
+
* approver is refused — no silent/default waiver). Returns the waiver record to stamp
|
|
1557
|
+
* onto claim.metadata.waiver, or null when neither flag is present. Reuses the existing
|
|
1558
|
+
* accepted_gap -> assumed status mapping; adds no new canonical status value.
|
|
1559
|
+
*/
|
|
1560
|
+
function parseWaiver(p: ReturnType<typeof parseArgs>, ts: string): AnyObj | null {
|
|
1561
|
+
const reason = opt(p, "accepted-gap-reason");
|
|
1562
|
+
const waivedBy = opt(p, "waived-by");
|
|
1563
|
+
if (!reason && !waivedBy) return null;
|
|
1564
|
+
if (!reason) die("--accepted-gap-reason is required when --waived-by is set (an accepted gap must carry its justification)");
|
|
1565
|
+
if (!waivedBy) die("--waived-by is required when --accepted-gap-reason is set (an accepted gap must name its approver)");
|
|
1566
|
+
return { reason, approved_by: waivedBy, approved_at: ts };
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1216
1569
|
async function recordEvidence(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
1217
1570
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
1218
1571
|
const verdict = opt(p, "verdict") || die("--verdict is required");
|
|
1219
1572
|
if (!verdicts.has(verdict)) die("verdict must be one of: pass, partial, fail, not_verified");
|
|
1220
1573
|
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
1221
|
-
const
|
|
1574
|
+
const _ts0 = opt(p, "timestamp", now());
|
|
1575
|
+
const _waiver = parseWaiver(p, _ts0);
|
|
1576
|
+
const _checksRaw = [...opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json"))), ...opts(p, "surface-trust-json").map(surfaceCheckFromArtifact)];
|
|
1577
|
+
// WS8 (AC4, iteration 2): a command-backed check reconciles against CI or fails — it can
|
|
1578
|
+
// NEVER be waived. Reject --accepted-gap-reason/--waived-by on any check whose evidence
|
|
1579
|
+
// classifies as test_output (build/types/lint/test/command, and security/browser/runtime
|
|
1580
|
+
// WHEN a command is present). Only session-local checks (attestation/observation/citation/
|
|
1581
|
+
// diff/policy) are waivable. The CI reconciler enforces the same rule server-side (a waiver
|
|
1582
|
+
// on test_output evidence is a divergence), so this producer-side guard is defense-in-depth,
|
|
1583
|
+
// not the sole gate. (ADR 0020)
|
|
1584
|
+
if (_waiver) {
|
|
1585
|
+
for (const c of _checksRaw) {
|
|
1586
|
+
const hasCmd = typeof (c as AnyObj).command === "string" && String((c as AnyObj).command).trim().length > 0;
|
|
1587
|
+
if (classifyEvidence((c as AnyObj).kind as string | undefined, hasCmd).evidenceType === "test_output") {
|
|
1588
|
+
die(`--accepted-gap-reason/--waived-by cannot be applied to a command-backed check (kind='${String((c as AnyObj).kind)}'${hasCmd ? " with a command" : ""}): a command-backed check reconciles against CI or fails and cannot be waived. Waive only session-local checks (attestation/observation/citation). (ADR 0020)`);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
const checks = _checksRaw.map((c) => _waiver ? { ...c, _waiver } : c);
|
|
1222
1593
|
if (!checks.length && opts(p, "surface-trust-json").length === 0) die("record-evidence requires at least one --check-json or --surface-trust-json");
|
|
1223
1594
|
validateAcceptanceEvidenceRefs(dir);
|
|
1224
1595
|
// Phase 4c: bundle is the sole verification artifact — stop writing evidence.json and acceptance.json update.
|
|
@@ -1227,7 +1598,11 @@ async function recordEvidence(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
1227
1598
|
const _existingCriteria: AnyObj[] = Array.isArray(_existingAcceptance.criteria) ? _existingAcceptance.criteria : [];
|
|
1228
1599
|
const _criteriaStatus = verdict === "pass" ? "pass" : verdict === "fail" ? "fail" : "not_verified";
|
|
1229
1600
|
const _criteriaForBundle: AnyObj[] = _existingCriteria.map((c: AnyObj) => ({ ...c, status: _criteriaStatus }));
|
|
1230
|
-
|
|
1601
|
+
// #268: preserve any existing critique claims (including superseded history) instead of dropping
|
|
1602
|
+
// them — record-evidence previously hardcoded critiques:[] here, silently erasing finding history
|
|
1603
|
+
// whenever it ran after a critique existed.
|
|
1604
|
+
const _existingCritiques: AnyObj[] = critiquesFromBundle(dir);
|
|
1605
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, ts, checks, _criteriaForBundle, _existingCritiques));
|
|
1231
1606
|
const stateStatus = verdict === "pass" ? "verified" : verdict === "fail" ? "failed" : "not_verified";
|
|
1232
1607
|
writeState(dir, slug, stateStatus, "verification", ts, "Evidence recorded.");
|
|
1233
1608
|
return 0;
|
|
@@ -1319,12 +1694,105 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
1319
1694
|
}
|
|
1320
1695
|
|
|
1321
1696
|
const checkNormalized = normalizeCheck(check);
|
|
1697
|
+
// WS8 (ADR 0020): honor the accepted-gap waiver flags for a gate claim too.
|
|
1698
|
+
const gateWaiver = parseWaiver(p, ts);
|
|
1699
|
+
if (gateWaiver) checkNormalized._waiver = gateWaiver;
|
|
1322
1700
|
// Log the targeted gate expectation for transparency (goes to stderr only)
|
|
1323
|
-
process.stderr.write(`[record-gate-claim] targeting ${activeStep.stepId}/${activeStep.gateId}/${targetExpectation.id} → claimType=${claimType} subjectType=${subjectType}\n`);
|
|
1701
|
+
process.stderr.write(`[record-gate-claim] targeting ${activeStep.stepId}/${activeStep.gateId}/${targetExpectation.id} → claimType=${claimType} subjectType=${subjectType}${gateWaiver ? " (WAIVED accepted_gap)" : ""}\n`);
|
|
1324
1702
|
assertBundleWritten(await writeTrustBundle(dir, slug, ts, [checkNormalized], [], []));
|
|
1325
1703
|
return 0;
|
|
1326
1704
|
}
|
|
1327
1705
|
|
|
1706
|
+
/**
|
|
1707
|
+
* promote — the promote-then-archive gate (issue #312). Durable-residue extraction is
|
|
1708
|
+
* the archival act: this records WHAT durable residue was promoted WHERE and writes a
|
|
1709
|
+
* PROMOTION CLAIM into the session trust.bundle.
|
|
1710
|
+
*
|
|
1711
|
+
* Claim shape (reconcile-safe by construction): the promotion check is kind="policy",
|
|
1712
|
+
* so classifyEvidence maps it to evidenceType "policy_rule" (session-local, method
|
|
1713
|
+
* "auditability"). It carries NO command / execution.label, so it can NEVER require a
|
|
1714
|
+
* reconcile-manifest entry and can NEVER become a [not-run] / unbacked-command
|
|
1715
|
+
* divergence at CI trust-reconcile. Its status derives to `verified` from that
|
|
1716
|
+
* session-local policy_rule evidence item, so the reconciler classifies it session-local
|
|
1717
|
+
* and accepts it as an ATTESTED claim (exit 0) — never as a test_output claim that must
|
|
1718
|
+
* match the manifest. The _promotion marker rides onto claim.metadata.promotion (see
|
|
1719
|
+
* buildTrustBundle) so the archive gate and validators can detect it without new manifest
|
|
1720
|
+
* entries (R1).
|
|
1721
|
+
*
|
|
1722
|
+
* Modes:
|
|
1723
|
+
* promote <dir> --evidence-path <p> [--evidence-path <p> ...]
|
|
1724
|
+
* Records the durable doc paths written (docs/decisions/<slug>.md, CONTEXT.md,
|
|
1725
|
+
* docs/learnings/*, …). Each path MUST exist on disk at record time — a missing
|
|
1726
|
+
* path fails loud (no silent empty promotion).
|
|
1727
|
+
* promote <dir> --none --reason "<why nothing durable>"
|
|
1728
|
+
* An explicit, auditable no-residue promotion (R3).
|
|
1729
|
+
*/
|
|
1730
|
+
async function promote(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
1731
|
+
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
1732
|
+
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
1733
|
+
const ts = opt(p, "timestamp", now());
|
|
1734
|
+
const none = p.flags.has("none");
|
|
1735
|
+
const reason = opt(p, "reason");
|
|
1736
|
+
const repoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDir(dir);
|
|
1737
|
+
const rawPaths = opts(p, "evidence-path");
|
|
1738
|
+
|
|
1739
|
+
if (none) {
|
|
1740
|
+
if (rawPaths.length) die("promote --none records a no-residue claim; do not also pass --evidence-path");
|
|
1741
|
+
if (!reason.trim()) die("promote --none requires --reason \"<why nothing durable was promoted>\"");
|
|
1742
|
+
} else {
|
|
1743
|
+
if (!rawPaths.length) die("promote requires at least one --evidence-path <durable-doc-path> (or --none --reason \"<why>\" for an explicit no-residue promotion)");
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
// Every evidence ref MUST exist on disk at record time (fail loud otherwise). Store
|
|
1747
|
+
// repo-relative paths when the ref lives under the repo, so the claim is portable.
|
|
1748
|
+
const targets: string[] = [];
|
|
1749
|
+
for (const raw of rawPaths) {
|
|
1750
|
+
const abs = path.isAbsolute(raw) ? raw : path.resolve(repoRoot, raw);
|
|
1751
|
+
if (!fs.existsSync(abs)) die(`promote --evidence-path does not exist on disk: ${raw} (resolved: ${abs}). Promotion evidence refs must point at durable docs that were actually written.`);
|
|
1752
|
+
const rel = path.relative(repoRoot, abs);
|
|
1753
|
+
targets.push(rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel : raw);
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
const promotionMarker: AnyObj = {
|
|
1757
|
+
schema_version: "1.0",
|
|
1758
|
+
none,
|
|
1759
|
+
...(none ? { reason } : {}),
|
|
1760
|
+
targets,
|
|
1761
|
+
promoted_at: ts,
|
|
1762
|
+
};
|
|
1763
|
+
|
|
1764
|
+
const summary = opt(p, "summary") || (none
|
|
1765
|
+
? `Promotion (no durable residue): ${reason}`
|
|
1766
|
+
: `Promoted durable residue: ${targets.join(", ")}`);
|
|
1767
|
+
|
|
1768
|
+
// Session-local promotion check: kind="policy" -> policy_rule evidence, no command.
|
|
1769
|
+
const promotionCheck: AnyObj = { id: "promotion", kind: "policy", status: "pass", summary, _promotion: promotionMarker };
|
|
1770
|
+
|
|
1771
|
+
// Add the promotion claim WITHOUT dropping the session's existing verification
|
|
1772
|
+
// evidence/criteria/critiques (mirror record-critique's merge pattern). Drop any prior
|
|
1773
|
+
// "promotion" check so re-running promote is idempotent rather than duplicating.
|
|
1774
|
+
const existingChecks = checksFromBundle(dir).filter((c) => c.id !== "promotion");
|
|
1775
|
+
const _acc = loadJson(path.join(dir, "acceptance.json"));
|
|
1776
|
+
const criteria: AnyObj[] = Array.isArray(_acc.criteria) ? _acc.criteria : [];
|
|
1777
|
+
const critiques = critiquesFromBundle(dir);
|
|
1778
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, ts, [...existingChecks, promotionCheck], criteria, critiques));
|
|
1779
|
+
|
|
1780
|
+
// Auditable record of what was promoted where (companion to the trust.bundle claim).
|
|
1781
|
+
writeJson(path.join(dir, "promotion.json"), { ...sidecarBase(slug), ...promotionMarker, summary });
|
|
1782
|
+
|
|
1783
|
+
// Optionally republish so delivery/trust.bundle carries the promotion claim for CI.
|
|
1784
|
+
if (p.flags.has("publish")) {
|
|
1785
|
+
const publishRepoRoot = opt(p, "publish-repo-root") ? path.resolve(opt(p, "publish-repo-root")) : findRepoRootFromDirStrict(dir);
|
|
1786
|
+
await publishDelivery(dir, publishRepoRoot).catch((err: unknown) => {
|
|
1787
|
+
process.stderr.write(`[promote] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
process.stderr.write(`[promote] recorded ${none ? "no-residue" : targets.length + " durable ref(s)"} promotion claim for ${slug}\n`);
|
|
1792
|
+
printJson({ ok: true, slug, none, targets, promotion_claim: "trust.bundle" });
|
|
1793
|
+
return 0;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1328
1796
|
async function advanceState(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
1329
1797
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
1330
1798
|
const status = opt(p, "status");
|
|
@@ -1375,7 +1843,14 @@ async function advanceState(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
1375
1843
|
if (status === "delivered") {
|
|
1376
1844
|
await sealTrustCheckpoint(dir, slug, timestamp, status, "release").catch(() => { /* best-effort; checkpoint seal must not break advance-state */ });
|
|
1377
1845
|
// Publish delivery bundle: best-effort copy to delivery/ for CI trust-reconcile.
|
|
1378
|
-
|
|
1846
|
+
// Fail-closed repo-root resolution (findRepoRootFromDirStrict, no cwd fallback) — see
|
|
1847
|
+
// publishDelivery below. An explicit --repo-root (e.g. for a scratch/test artifact dir
|
|
1848
|
+
// with no kits/ ancestor of its own) always wins, matching publishDeliveryCmd. Failures
|
|
1849
|
+
// are visible (stderr warning), not silently swallowed.
|
|
1850
|
+
const publishRepoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
|
|
1851
|
+
await publishDelivery(dir, publishRepoRoot).catch((err: unknown) => {
|
|
1852
|
+
process.stderr.write(`[advance-state] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1853
|
+
});
|
|
1379
1854
|
}
|
|
1380
1855
|
return 0;
|
|
1381
1856
|
}
|
|
@@ -1392,13 +1867,29 @@ async function recordCritique(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
1392
1867
|
// Fall back to critique.json for legacy sessions that still have it on disk.
|
|
1393
1868
|
const existingCritiqueJson = loadJson(path.join(dir, "critique.json"), { critiques: [] });
|
|
1394
1869
|
const legacyCritiques: AnyObj[] = Array.isArray(existingCritiqueJson.critiques) ? existingCritiqueJson.critiques : [];
|
|
1395
|
-
const
|
|
1396
|
-
const bundleCritiques = legacyCritiques.length === 0 ? critiquesFromBundle(dir, _dctCritique) : legacyCritiques;
|
|
1870
|
+
const bundleCritiques = legacyCritiques.length === 0 ? critiquesFromBundle(dir) : legacyCritiques;
|
|
1397
1871
|
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"))) };
|
|
1398
|
-
const critiques = [...bundleCritiques, critique];
|
|
1399
1872
|
if (critique.verdict === "pass" && critique.findings.some((f: AnyObj) => f.status === "open")) die("required critique must pass");
|
|
1873
|
+
// #267/#282: supersede-by-critique-id. The latest write for a critique id wins for
|
|
1874
|
+
// reconcile / status / validator purposes; each prior LIVE write for the same id is RETAINED as
|
|
1875
|
+
// history (status "superseded", first-class metadata.superseded_by) but excluded from evaluation.
|
|
1876
|
+
// Supersession is REVIEWER-SCOPED (anti-gaming): a write may only supersede a prior live critique
|
|
1877
|
+
// of the same id written by the SAME reviewer — a different reviewer's disputed finding is never
|
|
1878
|
+
// buried, so it stays live and continues to block. DOCUMENTED GAP: reviewer identity is the
|
|
1879
|
+
// free-form --reviewer string (with a default); there is no cryptographic worker-vs-reviewer
|
|
1880
|
+
// distinction yet — that lands with the runtime actor-identity slice (#287/#290). Same-reviewer-
|
|
1881
|
+
// string scoping is the strongest honest enforcement available today and matches the granularity
|
|
1882
|
+
// the critique record already has.
|
|
1883
|
+
const _supersedeMarker = `${critique.id}@${critique.reviewed_at}`;
|
|
1884
|
+
const _mergedCritiques = bundleCritiques.map((e: AnyObj) => {
|
|
1885
|
+
const eSuperseded = typeof e.superseded_by === "string" && e.superseded_by.length > 0;
|
|
1886
|
+
const eReviewer = String(e.reviewer ?? "tool-code-reviewer");
|
|
1887
|
+
if (e.id === critique.id && !eSuperseded && eReviewer === critique.reviewer) return { ...e, superseded_by: _supersedeMarker };
|
|
1888
|
+
return e;
|
|
1889
|
+
});
|
|
1890
|
+
const critiques = [..._mergedCritiques, critique];
|
|
1400
1891
|
// Phase 4c: build bundle from raw inputs; read checks from trust.bundle (evidence.json no longer written).
|
|
1401
|
-
const _critiqueEvChecks: AnyObj[] = checksFromBundle(dir
|
|
1892
|
+
const _critiqueEvChecks: AnyObj[] = checksFromBundle(dir);
|
|
1402
1893
|
const _critiqueAccCriteria: AnyObj[] = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
|
|
1403
1894
|
assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueEvChecks, _critiqueAccCriteria, critiques));
|
|
1404
1895
|
return 0;
|
|
@@ -1442,7 +1933,14 @@ async function recordRelease(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
1442
1933
|
// Trust checkpoint: seal at the "delivered" moment (the natural terminal mark for record-release).
|
|
1443
1934
|
await sealTrustCheckpoint(dir, slug, payload.updated_at, "delivered", "release").catch(() => { /* best-effort; checkpoint seal must not break record-release */ });
|
|
1444
1935
|
// Publish delivery bundle: best-effort copy to delivery/ for CI trust-reconcile.
|
|
1445
|
-
|
|
1936
|
+
// Fail-closed repo-root resolution (findRepoRootFromDirStrict, no cwd fallback) — see
|
|
1937
|
+
// publishDelivery below. An explicit --repo-root (e.g. for a scratch/test artifact dir with
|
|
1938
|
+
// no kits/ ancestor of its own) always wins, matching publishDeliveryCmd. Failures are
|
|
1939
|
+
// visible (stderr warning), not silently swallowed.
|
|
1940
|
+
const publishRepoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
|
|
1941
|
+
await publishDelivery(dir, publishRepoRoot).catch((err: unknown) => {
|
|
1942
|
+
process.stderr.write(`[record-release] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1943
|
+
});
|
|
1446
1944
|
return 0;
|
|
1447
1945
|
}
|
|
1448
1946
|
|
|
@@ -1657,7 +2155,7 @@ async function sealCheckpoint(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
1657
2155
|
|
|
1658
2156
|
// ─── Publish Delivery Bundle ──────────────────────────────────────────────────
|
|
1659
2157
|
// Copies the session's trust.bundle (+ checkpoint companions) from the gitignored
|
|
1660
|
-
// session artifact dir (.flow-agents/<slug>/) to the committed delivery/ transport
|
|
2158
|
+
// session artifact dir (.kontourai/flow-agents/<slug>/) to the committed delivery/ transport
|
|
1661
2159
|
// path so the CI trust-reconcile job can reconcile it against fresh CI results.
|
|
1662
2160
|
//
|
|
1663
2161
|
// Fail-soft: if trust.bundle is absent (no evidence recorded yet), does nothing.
|
|
@@ -1672,13 +2170,25 @@ async function sealCheckpoint(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
1672
2170
|
* trust.checkpoint.intoto.json / trust.checkpoint.sig.json from the
|
|
1673
2171
|
* session artifact dir to <repoRoot>/delivery/.
|
|
1674
2172
|
*
|
|
1675
|
-
* Fail-soft: if trust.bundle is absent, returns without throwing.
|
|
2173
|
+
* Fail-soft on a missing bundle: if trust.bundle is absent, returns without throwing.
|
|
2174
|
+
* Fail-CLOSED on repo-root resolution: repoRoot must be a real, resolved kits/ ancestor
|
|
2175
|
+
* (see findRepoRootFromDirStrict) — null (no ancestor found) skips the publish with a
|
|
2176
|
+
* visible warning instead of writing to whatever process.cwd() happens to be. This
|
|
2177
|
+
* prevents a scratch/test session dir (no kits/ ancestor) from silently clobbering an
|
|
2178
|
+
* unrelated real repo's delivery/trust.bundle when invoked with that repo as cwd (see
|
|
2179
|
+
* evals/integration/test_checkpoint_signing.sh TEST 2 and the WS5 session findings at
|
|
2180
|
+
* .kontourai/flow-agents/ws5-governance-kit-slice1 for the root cause this fixes).
|
|
1676
2181
|
* Idempotent: overwrites on re-delivery.
|
|
1677
2182
|
*/
|
|
1678
|
-
export async function publishDelivery(dir: string, repoRoot: string): Promise<void> {
|
|
2183
|
+
export async function publishDelivery(dir: string, repoRoot: string | null): Promise<void> {
|
|
1679
2184
|
const bundleSrc = path.join(dir, "trust.bundle");
|
|
1680
2185
|
if (!fs.existsSync(bundleSrc)) return; // no bundle — skip gracefully
|
|
1681
2186
|
|
|
2187
|
+
if (!repoRoot) {
|
|
2188
|
+
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`);
|
|
2189
|
+
return;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
1682
2192
|
const deliveryDir = path.join(repoRoot, "delivery");
|
|
1683
2193
|
fs.mkdirSync(deliveryDir, { recursive: true });
|
|
1684
2194
|
|
|
@@ -1713,7 +2223,10 @@ export async function publishDelivery(dir: string, repoRoot: string): Promise<vo
|
|
|
1713
2223
|
*/
|
|
1714
2224
|
async function publishDeliveryCmd(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
1715
2225
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
1716
|
-
|
|
2226
|
+
// Fail-closed: an explicit --repo-root always wins; otherwise resolve strictly (no
|
|
2227
|
+
// process.cwd() fallback) so a scratch/test artifact dir cannot accidentally publish
|
|
2228
|
+
// into whichever repo happens to be the current working directory.
|
|
2229
|
+
const repoRoot = opt(p, "repo-root") || findRepoRootFromDirStrict(dir);
|
|
1717
2230
|
await publishDelivery(dir, repoRoot);
|
|
1718
2231
|
return 0;
|
|
1719
2232
|
}
|
|
@@ -1767,45 +2280,49 @@ async function recordLearning(p: ReturnType<typeof parseArgs>): Promise<number>
|
|
|
1767
2280
|
writeJson(path.join(dir, "learning.json"), { ...sidecarBase(slug), status, updated_at: timestamp, records });
|
|
1768
2281
|
writeState(dir, slug, "accepted", "learning", timestamp, opt(p, "summary"));
|
|
1769
2282
|
// Phase 4c: build bundle from raw inputs; read checks/critiques from trust.bundle (bespoke sidecars no longer written).
|
|
1770
|
-
//
|
|
1771
|
-
const
|
|
1772
|
-
const _learningChecks: AnyObj[] = checksFromBundle(dir, _dctLearning);
|
|
2283
|
+
// #268/#344: declared builder.* claims survive the round-trip via their authoritative origin stamp.
|
|
2284
|
+
const _learningChecks: AnyObj[] = checksFromBundle(dir);
|
|
1773
2285
|
const _learningCriteria: AnyObj[] = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
|
|
1774
|
-
const _learningCritiques: AnyObj[] = critiquesFromBundle(dir
|
|
2286
|
+
const _learningCritiques: AnyObj[] = critiquesFromBundle(dir);
|
|
1775
2287
|
assertBundleWritten(await writeTrustBundle(dir, slug, timestamp, _learningChecks, _learningCriteria, _learningCritiques));
|
|
1776
2288
|
return 0;
|
|
1777
2289
|
}
|
|
1778
|
-
function evidenceClean(dir: string
|
|
1779
|
-
// Phase 4c: read from trust.bundle (sole verification artifact); fall back to evidence.json for
|
|
1780
|
-
//
|
|
1781
|
-
//
|
|
2290
|
+
function evidenceClean(dir: string): boolean {
|
|
2291
|
+
// Phase 4c: read from trust.bundle (sole verification artifact); fall back to evidence.json for
|
|
2292
|
+
// legacy (pre-bundle-era) sessions that never wrote a trust.bundle at all — unrelated to origin
|
|
2293
|
+
// stamping. When a trust.bundle IS present, every claim must be stamped (requireStampedClaim);
|
|
2294
|
+
// there is no claimType-derivation fallback for an unstamped claim (#268/#344).
|
|
1782
2295
|
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
1783
2296
|
if (Array.isArray(bundle.claims)) {
|
|
1784
|
-
const
|
|
1785
|
-
|
|
1786
|
-
const ct = String(c.claimType || "");
|
|
1787
|
-
return ct.startsWith("workflow.check.") || declaredClaimTypes.has(ct);
|
|
1788
|
-
});
|
|
2297
|
+
for (const c of bundle.claims) requireStampedClaim(c, dir);
|
|
2298
|
+
const checkClaims = (bundle.claims as AnyObj[]).filter((c: AnyObj) => c && claimOrigin(c) === "check");
|
|
1789
2299
|
if (checkClaims.length === 0) return false;
|
|
1790
2300
|
return checkClaims.every((c: AnyObj) => {
|
|
1791
2301
|
const v = String(c.value || "");
|
|
1792
2302
|
return v === "pass" || v === "skip";
|
|
1793
2303
|
});
|
|
1794
2304
|
}
|
|
1795
|
-
// Legacy fallback: evidence.json
|
|
2305
|
+
// Legacy fallback: evidence.json (pre-bundle-era sessions with no trust.bundle at all)
|
|
1796
2306
|
const e = loadJson(path.join(dir, "evidence.json"), {});
|
|
1797
2307
|
return e.verdict === "pass" && Array.isArray(e.checks) && e.checks.length > 0 && e.checks.every((c: AnyObj) => {
|
|
1798
2308
|
if (!(c.status === "pass" || c.status === "skip")) return false;
|
|
1799
2309
|
return !Array.isArray(c.standard_refs) || c.standard_refs.every((r: AnyObj) => ["junit", "sarif", "coverage", "veritas"].includes(r.standard));
|
|
1800
2310
|
});
|
|
1801
2311
|
}
|
|
1802
|
-
function critiqueClean(dir: string
|
|
1803
|
-
// Phase 4c: read from trust.bundle (sole verification artifact); fall back to critique.json for
|
|
1804
|
-
//
|
|
1805
|
-
// (
|
|
2312
|
+
function critiqueClean(dir: string): boolean {
|
|
2313
|
+
// Phase 4c: read from trust.bundle (sole verification artifact); fall back to critique.json for
|
|
2314
|
+
// legacy (pre-bundle-era) sessions — unrelated to origin stamping. When a trust.bundle IS
|
|
2315
|
+
// present, every claim must be stamped (requireStampedClaim); no claimType-derivation fallback
|
|
2316
|
+
// for an unstamped claim (#268/#344).
|
|
1806
2317
|
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
1807
2318
|
if (Array.isArray(bundle.claims)) {
|
|
1808
|
-
const
|
|
2319
|
+
for (const c of bundle.claims) requireStampedClaim(c, dir);
|
|
2320
|
+
const critiqueClaims = (bundle.claims as AnyObj[]).filter((c: AnyObj) => {
|
|
2321
|
+
if (!c) return false;
|
|
2322
|
+
// #267/#282: superseded history is not evaluated for cleanliness.
|
|
2323
|
+
if (c.metadata && typeof c.metadata === "object" && (c.metadata as AnyObj).superseded_by) return false;
|
|
2324
|
+
return claimOrigin(c) === "critique";
|
|
2325
|
+
});
|
|
1809
2326
|
if (critiqueClaims.length === 0) return false; // no critique written yet
|
|
1810
2327
|
return critiqueClaims.every((c: AnyObj) => {
|
|
1811
2328
|
const v = String(c.value || "");
|
|
@@ -1839,10 +2356,9 @@ async function dogfoodPass(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
1839
2356
|
const checks = opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json")));
|
|
1840
2357
|
if (checks.some((c) => c.status !== "pass" && c.status !== "skip")) die("clean evidence requires all non-skipped checks to pass");
|
|
1841
2358
|
// Phase 4c: evidence check reads from trust.bundle (sole verification artifact); legacy evidence.json fallback in evidenceClean.
|
|
1842
|
-
//
|
|
1843
|
-
const
|
|
1844
|
-
const
|
|
1845
|
-
const _hasLegacyEvidence = fs.existsSync(path.join(dir, "evidence.json")) && evidenceClean(dir, _dctDogfood);
|
|
2359
|
+
// #268/#344: builder.* check/critique claims count as clean evidence via their authoritative origin stamp.
|
|
2360
|
+
const _hasBundleEvidence = fs.existsSync(path.join(dir, "trust.bundle")) && evidenceClean(dir);
|
|
2361
|
+
const _hasLegacyEvidence = fs.existsSync(path.join(dir, "evidence.json")) && evidenceClean(dir);
|
|
1846
2362
|
if (!_hasBundleEvidence && !_hasLegacyEvidence && fs.existsSync(path.join(dir, "trust.bundle"))) die("cannot mark clean without passing evidence");
|
|
1847
2363
|
if (!_hasBundleEvidence && !_hasLegacyEvidence && !fs.existsSync(path.join(dir, "trust.bundle")) && fs.existsSync(path.join(dir, "evidence.json"))) die("cannot mark clean without passing evidence");
|
|
1848
2364
|
if (!_hasBundleEvidence && !_hasLegacyEvidence && !fs.existsSync(path.join(dir, "trust.bundle")) && !fs.existsSync(path.join(dir, "evidence.json")) && checks.length === 0) die("cannot mark clean without passing evidence");
|
|
@@ -1850,9 +2366,9 @@ async function dogfoodPass(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
1850
2366
|
const newCritiqueVerdict = opt(p, "critique-verdict", "pass");
|
|
1851
2367
|
for (const value of opts(p, "finding-json")) normalizeFinding(parseJson(value, "--finding-json"));
|
|
1852
2368
|
if (newCritiqueVerdict !== "pass") die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
|
|
1853
|
-
if (!opt(p, "critique-id") && !critiqueClean(dir
|
|
2369
|
+
if (!opt(p, "critique-id") && !critiqueClean(dir)) die("requires passing critique");
|
|
1854
2370
|
// Phase 4c: if existing state has a dirty critique (in bundle or legacy critique.json), block even when adding a new critique-id.
|
|
1855
|
-
if (!critiqueClean(dir
|
|
2371
|
+
if (!critiqueClean(dir) && (fs.existsSync(path.join(dir, "trust.bundle")) || fs.existsSync(path.join(dir, "critique.json")))) die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
|
|
1856
2372
|
}
|
|
1857
2373
|
}
|
|
1858
2374
|
const learningRecords = opts(p, "learning-record-json").map((v) => normalizeLearning(parseJson(v, "--learning-record-json"), opt(p, "timestamp", now())));
|
|
@@ -1890,7 +2406,7 @@ export interface TrustClaim {
|
|
|
1890
2406
|
id: string;
|
|
1891
2407
|
subjectType: string;
|
|
1892
2408
|
subjectId: string;
|
|
1893
|
-
|
|
2409
|
+
facet?: string;
|
|
1894
2410
|
claimType: string;
|
|
1895
2411
|
fieldOrBehavior: string;
|
|
1896
2412
|
value: string;
|
|
@@ -1909,7 +2425,7 @@ export interface BundleFile {
|
|
|
1909
2425
|
policies: AnyObj[];
|
|
1910
2426
|
}
|
|
1911
2427
|
|
|
1912
|
-
/** The gate block signal read from .flow-agents/.goal-fit-block-streak.json */
|
|
2428
|
+
/** The gate block signal read from .kontourai/flow-agents/.goal-fit-block-streak.json */
|
|
1913
2429
|
export interface GateBlockSignal {
|
|
1914
2430
|
/** True when the streak file exists AND count >= 1 */
|
|
1915
2431
|
blocked: boolean;
|
|
@@ -1926,13 +2442,13 @@ export interface GateBlockSignal {
|
|
|
1926
2442
|
export type GateCalibration = "correct" | "false_block" | "missed_block";
|
|
1927
2443
|
|
|
1928
2444
|
/**
|
|
1929
|
-
* Read the gate block signal from .flow-agents/.goal-fit-block-streak.json
|
|
2445
|
+
* Read the gate block signal from .kontourai/flow-agents/.goal-fit-block-streak.json
|
|
1930
2446
|
* (written by scripts/hooks/stop-goal-fit.js when block mode fires).
|
|
1931
2447
|
* The file sits at <artifact-root>/.goal-fit-block-streak.json — one level
|
|
1932
2448
|
* above the session artifact dir. Fail-open: returns { blocked: false } when
|
|
1933
2449
|
* the file is absent or unreadable.
|
|
1934
2450
|
*
|
|
1935
|
-
* @param artifactRoot The
|
|
2451
|
+
* @param artifactRoot The runtime artifact root dir (parent of session slug dir).
|
|
1936
2452
|
*/
|
|
1937
2453
|
export function readGateBlockSignal(artifactRoot: string): GateBlockSignal {
|
|
1938
2454
|
const streakFile = path.join(artifactRoot, ".goal-fit-block-streak.json");
|
|
@@ -2159,7 +2675,7 @@ export function buildGateInquiryRecords(
|
|
|
2159
2675
|
*
|
|
2160
2676
|
* The block signal is read from <artifact-root>/.goal-fit-block-streak.json,
|
|
2161
2677
|
* written by scripts/hooks/stop-goal-fit.js when block mode fires. The file
|
|
2162
|
-
* lives one level above the session slug dir (the
|
|
2678
|
+
* lives one level above the session slug dir (the runtime artifact root).
|
|
2163
2679
|
*
|
|
2164
2680
|
* If @kontourai/surface is unavailable, logs a warning and returns 0
|
|
2165
2681
|
* (fail-open — no bespoke fork fallback).
|
|
@@ -2185,7 +2701,7 @@ async function gateReview(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
2185
2701
|
|
|
2186
2702
|
const bundle: BundleFile = JSON.parse(fs.readFileSync(bundlePath, "utf8"));
|
|
2187
2703
|
|
|
2188
|
-
// Read gate block signal from
|
|
2704
|
+
// Read gate block signal from the runtime artifact root (one level above session dir)
|
|
2189
2705
|
const artifactRoot = path.dirname(dir);
|
|
2190
2706
|
const blockSignal = readGateBlockSignal(artifactRoot);
|
|
2191
2707
|
|
|
@@ -2342,7 +2858,7 @@ function trustMcp(p: ReturnType<typeof parseArgs>): number {
|
|
|
2342
2858
|
if (mode === "print") {
|
|
2343
2859
|
console.log(JSON.stringify({ mcpServers: { [TRUST_MCP_SERVER]: trustMcpRegistration() } }, null, 2));
|
|
2344
2860
|
process.stderr.write(`\n# Paste the above into your runtime MCP config (e.g. .mcp.json). Flow Agents does NOT write it for you unless you run: trust-mcp --mode enable\n`);
|
|
2345
|
-
process.stderr.write(`# To view a task's trust inline, call surface_summary with path=<.flow-agents/<slug>/trust.bundle>.\n`);
|
|
2861
|
+
process.stderr.write(`# To view a task's trust inline, call surface_summary with path=<.kontourai/flow-agents/<slug>/trust.bundle>.\n`);
|
|
2346
2862
|
return 0;
|
|
2347
2863
|
}
|
|
2348
2864
|
if (mode !== "enable" && mode !== "disable") die("trust-mcp --mode must be print|enable|disable");
|
|
@@ -2386,12 +2902,25 @@ const LIVENESS_POLICY = {
|
|
|
2386
2902
|
impactLevel: "medium",
|
|
2387
2903
|
};
|
|
2388
2904
|
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2905
|
+
/**
|
|
2906
|
+
* Delegate to the shared pure-CJS writer (scripts/hooks/lib/liveness-write.js), mirroring the
|
|
2907
|
+
* createRequire pattern used by loadActorIdentityHelper() below. Deliberately NO inline duplicate
|
|
2908
|
+
* fallback — the whole point of #288 Wave 2 Task 2.1 is one writer shared by the CLI and the
|
|
2909
|
+
* hook wrappers' tool-activity heartbeat, not two copies of the append shape that can drift.
|
|
2910
|
+
*/
|
|
2911
|
+
function loadLivenessWriteHelper(): {
|
|
2912
|
+
livenessStreamFile: (root: string) => string;
|
|
2913
|
+
appendLivenessEvent: (root: string, evt: AnyObj) => void;
|
|
2914
|
+
} {
|
|
2915
|
+
const _req = createRequire(import.meta.url);
|
|
2916
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-write.js");
|
|
2917
|
+
return _req(helperPath) as {
|
|
2918
|
+
livenessStreamFile: (root: string) => string;
|
|
2919
|
+
appendLivenessEvent: (root: string, evt: AnyObj) => void;
|
|
2920
|
+
};
|
|
2394
2921
|
}
|
|
2922
|
+
function livenessStreamFile(root: string): string { return loadLivenessWriteHelper().livenessStreamFile(root); }
|
|
2923
|
+
function appendLivenessEvent(root: string, evt: AnyObj): void { loadLivenessWriteHelper().appendLivenessEvent(root, evt); }
|
|
2395
2924
|
function readLivenessEvents(root: string): AnyObj[] {
|
|
2396
2925
|
// Delegate to the shared pure-CJS helper (scripts/hooks/lib/liveness-read.js).
|
|
2397
2926
|
// Using createRequire so the ESM sidecar can load a CJS module without bundling it.
|
|
@@ -2414,36 +2943,168 @@ function livenessLabel(status: string): string {
|
|
|
2414
2943
|
return status;
|
|
2415
2944
|
}
|
|
2416
2945
|
|
|
2417
|
-
// ─── ADR 0012 lifecycle-driven liveness (opt-
|
|
2946
|
+
// ─── ADR 0012 lifecycle-driven liveness (default-on; opt-out via FLOW_AGENTS_LIVENESS) ──
|
|
2418
2947
|
// init-plan claims the work-item; advance-state heartbeats (or releases on terminal),
|
|
2419
2948
|
// so the workflow lifecycle itself maintains the liveness claim — no manual liveness calls.
|
|
2420
2949
|
// Additive + fail-open: a liveness-emit failure never affects the workflow command.
|
|
2421
2950
|
const LIVENESS_TERMINAL = new Set(["delivered", "accepted", "archived"]);
|
|
2422
|
-
|
|
2423
|
-
|
|
2951
|
+
/**
|
|
2952
|
+
* Delegate to the shared pure-CJS resolver (scripts/hooks/lib/actor-identity.js), mirroring the
|
|
2953
|
+
* createRequire pattern used by readLivenessEvents() above. Deliberately NO inline duplicate
|
|
2954
|
+
* fallback: if the module fails to load, that failure itself must surface as an unresolved actor
|
|
2955
|
+
* ("") — never silently degrade back to the retired "local" default (issue #287).
|
|
2956
|
+
*/
|
|
2957
|
+
function loadActorIdentityHelper(): {
|
|
2958
|
+
resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
|
|
2959
|
+
sanitizeSegment: (value: unknown) => string;
|
|
2960
|
+
isUnresolvedActor: (actor: string) => boolean;
|
|
2961
|
+
} {
|
|
2962
|
+
const _req = createRequire(import.meta.url);
|
|
2963
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/actor-identity.js");
|
|
2964
|
+
return _req(helperPath) as {
|
|
2965
|
+
resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
|
|
2966
|
+
sanitizeSegment: (value: unknown) => string;
|
|
2967
|
+
isUnresolvedActor: (actor: string) => boolean;
|
|
2968
|
+
};
|
|
2969
|
+
}
|
|
2970
|
+
function resolveLivenessActor(): string {
|
|
2971
|
+
return loadActorIdentityHelper().resolveActor(process.env).actor;
|
|
2972
|
+
}
|
|
2973
|
+
// isUnresolvedActor is no longer defined locally — it is single-sourced in
|
|
2974
|
+
// scripts/hooks/lib/actor-identity.js (loadActorIdentityHelper().isUnresolvedActor) so the
|
|
2975
|
+
// lifecycle auto-emit path, the direct CLI liveness path, and the tool-activity heartbeat path
|
|
2976
|
+
// all consume the same predicate rather than forking their own copy (#287 re-review MEDIUM; #288
|
|
2977
|
+
// Wave 1 Task 1.1 single-sources it there).
|
|
2978
|
+
/**
|
|
2979
|
+
* Delegate to the shared pure-CJS policy predicates (scripts/hooks/lib/liveness-policy.js) — the
|
|
2980
|
+
* one definition of "enabled" (default-on/opt-out) and the claim TTL default, consumed by both the
|
|
2981
|
+
* lifecycle auto-emit path and the manual `liveness claim --ttl` default so they can never disagree
|
|
2982
|
+
* (#288).
|
|
2983
|
+
*/
|
|
2984
|
+
function loadLivenessPolicyHelper(): {
|
|
2985
|
+
isLivenessEnabled: (env: NodeJS.ProcessEnv) => boolean;
|
|
2986
|
+
resolveTtlSeconds: (env: NodeJS.ProcessEnv) => number;
|
|
2987
|
+
} {
|
|
2988
|
+
const _req = createRequire(import.meta.url);
|
|
2989
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-policy.js");
|
|
2990
|
+
return _req(helperPath) as {
|
|
2991
|
+
isLivenessEnabled: (env: NodeJS.ProcessEnv) => boolean;
|
|
2992
|
+
resolveTtlSeconds: (env: NodeJS.ProcessEnv) => number;
|
|
2993
|
+
};
|
|
2994
|
+
}
|
|
2995
|
+
function livenessEnabled(): boolean { return loadLivenessPolicyHelper().isLivenessEnabled(process.env); }
|
|
2996
|
+
/**
|
|
2997
|
+
* F1 (#288 fix iteration 1, cr-HIGH fail-open violation): the `livenessEnabled()`
|
|
2998
|
+
* guard (and therefore its `loadLivenessPolicyHelper()` module load) must sit
|
|
2999
|
+
* INSIDE this function's own fail-open try/catch — previously it sat outside,
|
|
3000
|
+
* so a missing/broken scripts/hooks/lib/liveness-policy.js module made
|
|
3001
|
+
* `init-plan`/`advance-state` exit 1 instead of degrading gracefully
|
|
3002
|
+
* (repro-verified). Now: any failure here — including a failed helper load —
|
|
3003
|
+
* is caught, produces one stderr diagnostic, and the lifecycle auto-emit is
|
|
3004
|
+
* skipped; the workflow command's own exit code is never affected. This
|
|
3005
|
+
* mirrors the #287 fail-open convention already used elsewhere in this file.
|
|
3006
|
+
* The direct CLI write path (`async function liveness`, actions
|
|
3007
|
+
* claim|heartbeat|release) is deliberately NOT wrapped this way — it stays
|
|
3008
|
+
* fail-loud on a missing helper module, per the plan's explicit instruction
|
|
3009
|
+
* that only the convenience lifecycle wiring is flag-gated/fail-open.
|
|
3010
|
+
*/
|
|
2424
3011
|
function livenessLifecycle(taskDir: string, slug: string, kind: "claim" | "heartbeat" | "release", timestamp: string): void {
|
|
2425
|
-
if (!livenessEnabled()) return;
|
|
2426
3012
|
try {
|
|
2427
|
-
|
|
2428
|
-
const
|
|
2429
|
-
if (
|
|
3013
|
+
if (!livenessEnabled()) return;
|
|
3014
|
+
const actor = resolveLivenessActor();
|
|
3015
|
+
if (loadActorIdentityHelper().isUnresolvedActor(actor)) {
|
|
3016
|
+
process.stderr.write("[liveness] skipped auto-emit: actor unresolved (set FLOW_AGENTS_ACTOR or run inside a supported runtime)\n");
|
|
3017
|
+
return;
|
|
3018
|
+
}
|
|
3019
|
+
const root = path.dirname(taskDir); // .kontourai/flow-agents/<slug> → .kontourai/flow-agents (the shared liveness stream lives here)
|
|
3020
|
+
const evt: AnyObj = { type: kind, subjectId: slug, actor, at: timestamp, source: "lifecycle" };
|
|
3021
|
+
if (kind === "claim") evt.ttlSeconds = loadLivenessPolicyHelper().resolveTtlSeconds(process.env);
|
|
2430
3022
|
appendLivenessEvent(root, evt);
|
|
2431
|
-
} catch {
|
|
3023
|
+
} catch (err) {
|
|
3024
|
+
// best-effort; liveness is advisory and must never break the workflow — but the failure
|
|
3025
|
+
// itself must be visible (F1), not silently absorbed.
|
|
3026
|
+
try {
|
|
3027
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
3028
|
+
process.stderr.write(`[liveness] skipped auto-emit: ${detail}\n`);
|
|
3029
|
+
} catch { /* best-effort diagnostic only */ }
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
/**
|
|
3034
|
+
* F5 (#288 fix iteration 1, sec-LOW): strip control/escape characters before echoing a
|
|
3035
|
+
* subjectId/actor to the terminal. subjectId is a raw CLI positional argument on the write path
|
|
3036
|
+
* (never sanitized before this point — it is stored as-is in the event for data fidelity) and,
|
|
3037
|
+
* on the `status` read path, both subjectId and actor may originate from a hand-edited or
|
|
3038
|
+
* otherwise hostile liveness/events.jsonl file rather than this process's own writes. Strips the
|
|
3039
|
+
* C0 control range (0x00-0x1F), DEL (0x7F), and the C1 range (0x80-0x9F, which includes
|
|
3040
|
+
* ANSI-CSI-adjacent bytes) — display-only; the persisted event itself is never mutated.
|
|
3041
|
+
*/
|
|
3042
|
+
function stripControlCharsForDisplay(value: unknown): string {
|
|
3043
|
+
return String(value ?? "").replace(/[\u0000-\u001F\u007F-\u009F]/g, "");
|
|
2432
3044
|
}
|
|
2433
3045
|
|
|
2434
3046
|
async function liveness(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
2435
3047
|
const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
|
|
2436
3048
|
const action = p.positional[0] || "";
|
|
2437
3049
|
const subjectId = p.positional[1] || "";
|
|
2438
|
-
const actor = opt(p, "actor", process.env.FLOW_AGENTS_ACTOR || "unknown");
|
|
2439
3050
|
const nowIso = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
2440
3051
|
|
|
3052
|
+
if (action === "whoami") {
|
|
3053
|
+
// Read-only, lock-free, write-free advisory surface: reuses the identical resolution chain
|
|
3054
|
+
// as the write paths (loadActorIdentityHelper().resolveActor) but deliberately never dies on
|
|
3055
|
+
// an unresolved actor — the enforcement point stays at `liveness claim`, which already dies
|
|
3056
|
+
// loudly (see below). This lets a skill learn "who am I" during a read-only preflight without
|
|
3057
|
+
// emitting a bogus claim first.
|
|
3058
|
+
const helper = loadActorIdentityHelper();
|
|
3059
|
+
const explicitActorRaw = opt(p, "actor", "");
|
|
3060
|
+
const explicitActor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : "";
|
|
3061
|
+
const resolved = explicitActor
|
|
3062
|
+
? { actor: explicitActor, source: "explicit-override" }
|
|
3063
|
+
: helper.resolveActor(process.env);
|
|
3064
|
+
if (p.flags.has("json")) { console.log(JSON.stringify(resolved)); return 0; }
|
|
3065
|
+
console.log(`${stripControlCharsForDisplay(resolved.actor || "unresolved")}\t${stripControlCharsForDisplay(resolved.source)}`);
|
|
3066
|
+
return 0;
|
|
3067
|
+
}
|
|
3068
|
+
|
|
2441
3069
|
if (action === "claim" || action === "heartbeat" || action === "release") {
|
|
3070
|
+
// Actor resolution happens only for write actions (F5, #287 fix iteration 1) — "status" is a
|
|
3071
|
+
// read path and must not shell out via resolveLivenessActor()/resolveActor().
|
|
3072
|
+
const helper = loadActorIdentityHelper();
|
|
3073
|
+
const explicitActorRaw = opt(p, "actor", "");
|
|
3074
|
+
// F7 (#287 fix iteration 2): an explicit --actor value that strips to empty under the allowed
|
|
3075
|
+
// [A-Za-z0-9_.-] charset (e.g. "--actor ':::'") is a hard error on this write path, not a
|
|
3076
|
+
// silent fallback to sanitizeSegment's shared "unknown" sentinel — garbage on an explicit flag
|
|
3077
|
+
// is an authoring mistake the caller must fix, unlike the env-override seam (which falls
|
|
3078
|
+
// through to derivation instead, since there is no flag to correct).
|
|
3079
|
+
if (explicitActorRaw && !/[A-Za-z0-9_.-]/.test(explicitActorRaw)) {
|
|
3080
|
+
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.`);
|
|
3081
|
+
}
|
|
3082
|
+
const explicitActor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : "";
|
|
3083
|
+
const actor = explicitActor || helper.resolveActor(process.env).actor;
|
|
3084
|
+
if (helper.isUnresolvedActor(actor)) {
|
|
3085
|
+
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.`);
|
|
3086
|
+
}
|
|
2442
3087
|
if (!subjectId) die(`liveness ${action} requires a subjectId`);
|
|
3088
|
+
// F8(i) (#288 fix iteration 2, orphan-heartbeat invariant): heartbeat/release on the direct CLI
|
|
3089
|
+
// write path must never be the FIRST liveness event ever written for a (subjectId, actor) pair —
|
|
3090
|
+
// that is exactly how the reviewer-reproduced orphan-heartbeat bug arises (a bare heartbeat with
|
|
3091
|
+
// no claim behind it, later mistaken for claim evidence). A full stream read here is fine: this
|
|
3092
|
+
// is a rare, human/CLI-driven call, not the hot tool-activity path. `claim` itself is exempt (it
|
|
3093
|
+
// is the event that ESTABLISHES the pair in the first place).
|
|
3094
|
+
if (action === "heartbeat" || action === "release") {
|
|
3095
|
+
const priorEvents = readLivenessEvents(root);
|
|
3096
|
+
const hasPriorClaim = priorEvents.some((e) => e && e.type === "claim" && e.subjectId === subjectId && e.actor === actor);
|
|
3097
|
+
if (!hasPriorClaim) {
|
|
3098
|
+
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}.`);
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
2443
3101
|
const evt: AnyObj = { type: action, subjectId, actor, at: opt(p, "at") || nowIso };
|
|
2444
|
-
if (action === "claim")
|
|
3102
|
+
if (action === "claim") {
|
|
3103
|
+
const defaultTtl = loadLivenessPolicyHelper().resolveTtlSeconds(process.env);
|
|
3104
|
+
evt.ttlSeconds = Number.parseInt(opt(p, "ttl", String(defaultTtl)), 10) || defaultTtl;
|
|
3105
|
+
}
|
|
2445
3106
|
appendLivenessEvent(root, evt);
|
|
2446
|
-
console.log(`liveness ${action}: ${subjectId} by ${actor}`);
|
|
3107
|
+
console.log(`liveness ${action}: ${stripControlCharsForDisplay(subjectId)} by ${stripControlCharsForDisplay(actor)}`);
|
|
2447
3108
|
return 0;
|
|
2448
3109
|
}
|
|
2449
3110
|
|
|
@@ -2467,16 +3128,103 @@ async function liveness(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
2467
3128
|
const rows: AnyObj[] = [];
|
|
2468
3129
|
for (const g of groups.values()) {
|
|
2469
3130
|
if (subjectFilter && g.subjectId !== subjectFilter) continue;
|
|
2470
|
-
const claim: AnyObj = { id: `${g.subjectId}::${g.actor}`, subjectType: "work-item", subjectId: g.subjectId,
|
|
3131
|
+
const claim: AnyObj = { id: `${g.subjectId}::${g.actor}`, subjectType: "work-item", subjectId: g.subjectId, facet: "flow.liveness", claimType: "liveness.hold", fieldOrBehavior: "held-by", value: g.actor, createdAt: g.created, updatedAt: g.updated, ttlSeconds: g.ttlSeconds, verificationPolicyId: LIVENESS_POLICY.id };
|
|
2471
3132
|
const status = surface.deriveTrustStatus!({ claim, evidence: [], policy: LIVENESS_POLICY, events: g.events, now });
|
|
2472
3133
|
rows.push({ subjectId: g.subjectId, actor: g.actor, status, label: livenessLabel(status) });
|
|
2473
3134
|
}
|
|
2474
3135
|
if (p.flags.has("json")) { console.log(JSON.stringify(rows, null, 2)); return 0; }
|
|
2475
|
-
for (const r of rows) console.log(`${r.subjectId}\t${r.actor}\t${r.label}`);
|
|
3136
|
+
for (const r of rows) console.log(`${stripControlCharsForDisplay(r.subjectId)}\t${stripControlCharsForDisplay(r.actor)}\t${r.label}`);
|
|
3137
|
+
return 0;
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
if (action === "verdict") {
|
|
3141
|
+
// Read-only, lock-free CLI helper (#320 AC1) — mirrors `whoami`'s lock-bypass (see
|
|
3142
|
+
// isLivenessVerdict in main() below): computes {subjectId, winner, losers, reason, holders}
|
|
3143
|
+
// as a PURE function of the shared liveness stream. Among the subject's currently-fresh
|
|
3144
|
+
// claim holders (via the shared `freshHolders` helper — the exact canonical
|
|
3145
|
+
// freshness/grouping/release-handling logic `workflow-steering.js`'s ambient digest already
|
|
3146
|
+
// uses, not a re-derived rule), the holder whose most recent `claim` event has the earliest
|
|
3147
|
+
// `at` wins; an exact-timestamp tie breaks by ascending plain string comparison of actor id
|
|
3148
|
+
// (never locale-aware collation, for cross-machine determinism). Same stream state ⇒ same
|
|
3149
|
+
// verdict, regardless of which actor invokes it — no actor-specific input is read here.
|
|
3150
|
+
if (!subjectId) die("liveness verdict requires a subjectId");
|
|
3151
|
+
const events = readLivenessEvents(root);
|
|
3152
|
+
const nowMs = opt(p, "now") ? Date.parse(opt(p, "now")) : Date.now();
|
|
3153
|
+
const _req = createRequire(import.meta.url);
|
|
3154
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-read.js");
|
|
3155
|
+
const { freshHolders } = _req(helperPath) as { freshHolders: (events: AnyObj[], slug: string, selfActor: string, nowMs: number) => AnyObj[] };
|
|
3156
|
+
// Impossible-sentinel selfActor: real actors are sanitized to [A-Za-z0-9_.-]+ and can never be
|
|
3157
|
+
// empty (enforced on every write path above), so passing "" excludes nothing.
|
|
3158
|
+
const fresh = freshHolders(events, subjectId, "", nowMs);
|
|
3159
|
+
// freshHolders' `lastAt` tracks the group's latest event of ANY type (heartbeat-updated); the
|
|
3160
|
+
// tiebreak instead operates on each holder's most recent `claim`-type event `at` — the
|
|
3161
|
+
// "current claim timestamp", distinct from lastAt.
|
|
3162
|
+
const latestClaimAt = (actor: string): string => {
|
|
3163
|
+
let best = "";
|
|
3164
|
+
for (const e of events) {
|
|
3165
|
+
if (e && e.subjectId === subjectId && e.actor === actor && e.type === "claim" && typeof e.at === "string") {
|
|
3166
|
+
if (!best || e.at > best) best = e.at;
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
3169
|
+
return best;
|
|
3170
|
+
};
|
|
3171
|
+
const holders: AnyObj[] = fresh
|
|
3172
|
+
.map((h) => ({ actor: String(h.actor), claimAt: latestClaimAt(String(h.actor)) || String(h.lastAt), lastAt: String(h.lastAt), ttlSeconds: Number(h.ttlSeconds) }))
|
|
3173
|
+
.sort((a, b) => (a.claimAt < b.claimAt ? -1 : a.claimAt > b.claimAt ? 1 : a.actor < b.actor ? -1 : a.actor > b.actor ? 1 : 0));
|
|
3174
|
+
let winner: AnyObj | null = null;
|
|
3175
|
+
let losers: AnyObj[] = [];
|
|
3176
|
+
let reason = "no-conflict";
|
|
3177
|
+
if (holders.length >= 2) {
|
|
3178
|
+
winner = holders[0];
|
|
3179
|
+
const tie = holders.filter((h) => h.claimAt === winner!.claimAt).length > 1;
|
|
3180
|
+
reason = tie ? "tie-actor-lexicographic" : "earlier-claim";
|
|
3181
|
+
losers = holders.filter((h) => h.actor !== winner!.actor);
|
|
3182
|
+
}
|
|
3183
|
+
if (p.flags.has("json")) {
|
|
3184
|
+
// #320 fix iteration 1 (sec-HIGH F3): `winner`/`losers`/`holders` carry raw actor/claimAt/
|
|
3185
|
+
// lastAt strings sourced straight from the multi-writer append-only liveness/events.jsonl
|
|
3186
|
+
// stream (see stripControlCharsForDisplay's header above — any process can append a
|
|
3187
|
+
// hostile line, bypassing the CLI write-side sanitizeSegment entirely). The pull-work
|
|
3188
|
+
// SKILL.md's Post-Claim Conflict Re-check instructs an LLM to read `winner.actor` /
|
|
3189
|
+
// `losers[].actor` straight out of THIS `--json` output into its own context — a second,
|
|
3190
|
+
// LLM-facing injection path alongside the mid-turn conflict hook (liveness-heartbeat.js's
|
|
3191
|
+
// computeConflict). Build a sanitized COPY for JSON emission only: control-char strip
|
|
3192
|
+
// (matching the text branch's existing stripControlCharsForDisplay treatment below) plus a
|
|
3193
|
+
// 64-char cap (matching sanitizeSegment's cap on the write side) on actor/claimAt/lastAt.
|
|
3194
|
+
// `reason`/`subjectId`/`ttlSeconds` are left as-is (enum/number, never free text). Never
|
|
3195
|
+
// mutates `result`/`holders`/`winner`/`losers` themselves — the text branch below still
|
|
3196
|
+
// needs the untouched originals for its own per-line rendering, and winner/loser identity
|
|
3197
|
+
// (already sanitizeSegment-clean for legitimate actors) is unaffected, so the loser-release
|
|
3198
|
+
// contract still works.
|
|
3199
|
+
const sanitizeVerdictHolderForJson = (h: AnyObj | null): AnyObj | null =>
|
|
3200
|
+
h
|
|
3201
|
+
? {
|
|
3202
|
+
...h,
|
|
3203
|
+
actor: stripControlCharsForDisplay(h.actor).slice(0, 64),
|
|
3204
|
+
claimAt: stripControlCharsForDisplay(h.claimAt).slice(0, 64),
|
|
3205
|
+
lastAt: stripControlCharsForDisplay(h.lastAt).slice(0, 64),
|
|
3206
|
+
}
|
|
3207
|
+
: h;
|
|
3208
|
+
const jsonResult = {
|
|
3209
|
+
subjectId,
|
|
3210
|
+
winner: sanitizeVerdictHolderForJson(winner),
|
|
3211
|
+
losers: losers.map((l) => sanitizeVerdictHolderForJson(l)),
|
|
3212
|
+
reason,
|
|
3213
|
+
holders: holders.map((h) => sanitizeVerdictHolderForJson(h)),
|
|
3214
|
+
};
|
|
3215
|
+
console.log(JSON.stringify(jsonResult));
|
|
3216
|
+
return 0;
|
|
3217
|
+
}
|
|
3218
|
+
for (const h of holders) {
|
|
3219
|
+
const tag = winner && h.actor === (winner as AnyObj).actor ? "WINNER" : (holders.length >= 2 ? "LOSER" : "HOLDER");
|
|
3220
|
+
console.log(`${stripControlCharsForDisplay(h.actor)}\tclaimAt=${stripControlCharsForDisplay(h.claimAt)}\t${tag}`);
|
|
3221
|
+
}
|
|
3222
|
+
if (winner) console.log(`WINNER: ${stripControlCharsForDisplay((winner as AnyObj).actor)}`);
|
|
3223
|
+
for (const l of losers) console.log(`LOSER: ${stripControlCharsForDisplay(l.actor)}`);
|
|
2476
3224
|
return 0;
|
|
2477
3225
|
}
|
|
2478
3226
|
|
|
2479
|
-
die("liveness action must be one of: claim | heartbeat | release | status");
|
|
3227
|
+
die("liveness action must be one of: claim | heartbeat | release | status | whoami | verdict");
|
|
2480
3228
|
return 1;
|
|
2481
3229
|
}
|
|
2482
3230
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -2652,9 +3400,24 @@ Available claim ids:
|
|
|
2652
3400
|
async function main(): Promise<number> {
|
|
2653
3401
|
const p = parseArgs(process.argv.slice(2));
|
|
2654
3402
|
if (!p.command) die("workflow-sidecar command is required");
|
|
2655
|
-
|
|
3403
|
+
// F1 (#166 fix iteration 1): `liveness whoami` is a read-only, lock-free, write-free advisory
|
|
3404
|
+
// surface (see the `action === "whoami"` branch inside `liveness()` above) — it must never
|
|
3405
|
+
// acquire the workflow-sidecar lock, regardless of whether the artifact root already exists on
|
|
3406
|
+
// disk. Without this action-level bypass, `liveness` was blanket-included in the lock-routing
|
|
3407
|
+
// branch below, so `whoami` against an EXISTING artifact root would still resolve a real
|
|
3408
|
+
// lockRoot and go through `withLock`'s mkdir/lockdir path — the opposite of "genuinely
|
|
3409
|
+
// lock-free". This bypass mirrors `resolve-slug`'s existing empty-lockRoot special case
|
|
3410
|
+
// immediately below and is scoped to the `whoami` action only: `liveness status` (a read path)
|
|
3411
|
+
// keeps its pre-existing lock behavior unchanged (out of scope for this fix — see fix-plan
|
|
3412
|
+
// iteration 1, F1), and `liveness claim` / `heartbeat` / `release` (write paths) are untouched.
|
|
3413
|
+
const isLivenessWhoami = p.command === "liveness" && p.positional[0] === "whoami";
|
|
3414
|
+
// #320 AC1: `liveness verdict` is read-only and lock-free, exactly like `whoami` above — it
|
|
3415
|
+
// must never acquire the workflow-sidecar lock. Same bypass shape, scoped to the `verdict`
|
|
3416
|
+
// action only; `liveness status` (also a read path) keeps its pre-existing lock behavior.
|
|
3417
|
+
const isLivenessVerdict = p.command === "liveness" && p.positional[0] === "verdict";
|
|
3418
|
+
const lockRoot = (["ensure-session", "current", "dogfood-pass", "liveness"].includes(p.command) && !isLivenessWhoami && !isLivenessVerdict)
|
|
2656
3419
|
? (opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : (p.command === "ensure-session" ? flowAgentsArtifactRoot() : defaultArtifactRootForRead()))
|
|
2657
|
-
: p.command === "record-agent-event" ? explicitArtifactRoot(p) : p.command === "claim" ? (p.positional[1] ? path.resolve(p.positional[1]) : "") : p.positional[0] ? artifactDirFrom(p.positional[0]) : "";
|
|
3420
|
+
: p.command === "record-agent-event" ? explicitArtifactRoot(p) : p.command === "claim" ? (p.positional[1] ? path.resolve(p.positional[1]) : "") : p.command === "resolve-slug" ? "" : (isLivenessWhoami || isLivenessVerdict) ? "" : p.positional[0] ? artifactDirFrom(p.positional[0]) : "";
|
|
2658
3421
|
return withLock(lockRoot, ["ensure-session", "record-agent-event", "dogfood-pass"].includes(p.command), p.command, () => {
|
|
2659
3422
|
switch (p.command) {
|
|
2660
3423
|
case "ensure-session": return ensureSession(p);
|
|
@@ -2663,6 +3426,7 @@ async function main(): Promise<number> {
|
|
|
2663
3426
|
case "init-plan": return initPlan(p);
|
|
2664
3427
|
case "record-evidence": return recordEvidence(p);
|
|
2665
3428
|
case "record-gate-claim": return recordGateClaim(p);
|
|
3429
|
+
case "promote": return promote(p);
|
|
2666
3430
|
case "advance-state": return advanceState(p);
|
|
2667
3431
|
case "record-critique": return recordCritique(p);
|
|
2668
3432
|
case "import-critique": return importCritique(p);
|
|
@@ -2674,6 +3438,7 @@ async function main(): Promise<number> {
|
|
|
2674
3438
|
case "trust-mcp": return trustMcp(p);
|
|
2675
3439
|
case "liveness": return liveness(p);
|
|
2676
3440
|
case "claim": return claimLookup(p);
|
|
3441
|
+
case "resolve-slug": return resolveSlugCmd(p);
|
|
2677
3442
|
case "seal-checkpoint": return sealCheckpoint(p);
|
|
2678
3443
|
case "publish-delivery": return publishDeliveryCmd(p);
|
|
2679
3444
|
default: die(`unknown command: ${p.command}`);
|