@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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Stop Hook: warn when an active workflow is about to stop short of its goal.
|
|
4
4
|
*
|
|
5
|
-
* The hook reads .flow-agents artifacts, looks for the most recent active
|
|
5
|
+
* The hook reads .kontourai/flow-agents artifacts, looks for the most recent active
|
|
6
6
|
* delivery/session file, and reports missing Definition Of Done, Goal Fit, or
|
|
7
7
|
* Final Acceptance state.
|
|
8
8
|
*
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* Code at L2) set block so the installed product enforces while the engine
|
|
16
16
|
* default and conformance contract stay warn.
|
|
17
17
|
*
|
|
18
|
-
* Scope: the gate evaluates the session's current task (.flow-agents/current.json)
|
|
18
|
+
* Scope: the gate evaluates the session's current task (.kontourai/flow-agents/current.json)
|
|
19
19
|
* when set, so an unrelated active workflow elsewhere in the repo does not gate
|
|
20
20
|
* this stop. It also never hard-blocks a pre-execution (not-yet-started) task on
|
|
21
21
|
* mere incompleteness — only genuine false-completion signals (a claimed pass the
|
|
@@ -27,6 +27,21 @@
|
|
|
27
27
|
const fs = require('fs');
|
|
28
28
|
const path = require('path');
|
|
29
29
|
const { spawnSync } = require('child_process');
|
|
30
|
+
const crypto = require('crypto');
|
|
31
|
+
|
|
32
|
+
// Hash-chain primitives + the exit-code-laundering heuristic come from ONE shared
|
|
33
|
+
// module, so this verifier can never drift from the writer (evidence-capture.js).
|
|
34
|
+
// CHAIN_GENESIS is re-aliased to CHAIN_GENESIS_VERIFY to preserve the long-standing
|
|
35
|
+
// export name consumed by repair-command-log.js and the fork-classification eval.
|
|
36
|
+
const {
|
|
37
|
+
CHAIN_GENESIS: CHAIN_GENESIS_VERIFY,
|
|
38
|
+
canonicalJsonForChain,
|
|
39
|
+
hasLaunderingOperator,
|
|
40
|
+
} = require('../lib/command-log-chain.js');
|
|
41
|
+
const {
|
|
42
|
+
flowAgentsArtifactRoot,
|
|
43
|
+
flowAgentsArtifactRootsForRead,
|
|
44
|
+
} = require('./lib/local-artifact-paths');
|
|
30
45
|
|
|
31
46
|
const MAX_STDIN = 1024 * 1024;
|
|
32
47
|
const ACTIVE_STATUSES = new Set([
|
|
@@ -44,8 +59,9 @@ const ACTIVE_STATUSES = new Set([
|
|
|
44
59
|
]);
|
|
45
60
|
// WORKFLOW_SESSION_TYPES: used for artifact identification only, not for verdict production.
|
|
46
61
|
const WORKFLOW_SESSION_TYPES = new Set(['deliver', 'delivery', 'fix-bug', 'execute-plan', 'verify-work']);
|
|
47
|
-
|
|
48
|
-
const
|
|
62
|
+
// Phase 4c: bundle-only. Required set = {state.json, handoff.json, trust.bundle}. Drop evidence.json/acceptance.json/critique.json.
|
|
63
|
+
const SIDECAR_NAMES = new Set(['state.json', 'handoff.json', 'trust.bundle']);
|
|
64
|
+
const OPTIONAL_SIDECAR_NAMES = new Set();
|
|
49
65
|
|
|
50
66
|
// A workflow that has not started execution is EXPECTED to be incomplete, so the
|
|
51
67
|
// Stop gate must not hard-block on its missing DOD / Goal Fit / not-done state.
|
|
@@ -136,7 +152,23 @@ function sidecarValidation(root, artifactDir) {
|
|
|
136
152
|
if (requireSidecars || requireCritique) {
|
|
137
153
|
const present = new Set(sidecarFiles.map(file => path.basename(file)));
|
|
138
154
|
const requiredNames = new Set(requireSidecars ? SIDECAR_NAMES : []);
|
|
139
|
-
|
|
155
|
+
// Phase 4c: critique.json is no longer written; trust.bundle carries critique claims.
|
|
156
|
+
// FLOW_AGENTS_REQUIRE_CRITIQUE is satisfied by:
|
|
157
|
+
// - critique.json (legacy, may not be in SIDECAR_NAMES but may still be on disk), OR
|
|
158
|
+
// - trust.bundle that contains at least one workflow.critique.review claim.
|
|
159
|
+
if (requireCritique) {
|
|
160
|
+
// Check disk directly (critique.json is no longer in SIDECAR_NAMES so may not be in present)
|
|
161
|
+
const hasCritiqueJson = fs.existsSync(path.join(artifactDir, 'critique.json'));
|
|
162
|
+
const bundleFile = path.join(artifactDir, 'trust.bundle');
|
|
163
|
+
let hasBundleCritique = false;
|
|
164
|
+
if (fs.existsSync(bundleFile)) {
|
|
165
|
+
try {
|
|
166
|
+
const b = JSON.parse(fs.readFileSync(bundleFile, 'utf8'));
|
|
167
|
+
hasBundleCritique = Array.isArray(b.claims) && b.claims.some(c => c && c.claimType === 'workflow.critique.review');
|
|
168
|
+
} catch { /* fall through — no bundle critique */ }
|
|
169
|
+
}
|
|
170
|
+
if (!hasCritiqueJson && !hasBundleCritique) requiredNames.add('critique.json');
|
|
171
|
+
}
|
|
140
172
|
const missing = [...requiredNames].filter(name => !present.has(name)).sort();
|
|
141
173
|
if (missing.length > 0) {
|
|
142
174
|
return missing.map(name => `${relative(root, path.join(artifactDir, name))} sidecar validation: required sidecar is missing`);
|
|
@@ -264,6 +296,49 @@ async function tryLoadSurface() {
|
|
|
264
296
|
}
|
|
265
297
|
}
|
|
266
298
|
|
|
299
|
+
// ─── ADR 0016 Abstraction A P-c: flow-resolver integration ────────────────────
|
|
300
|
+
// Load the compiled flow-resolver (build/src/lib/flow-resolver.js) via CJS
|
|
301
|
+
// require behind the same hasBuild guard used for the validator. Fail-open:
|
|
302
|
+
// returns null when build/ is absent, require throws, or current.json has no
|
|
303
|
+
// active_flow_id / active_step_id. The caller (bundleEnforcement, sidecarGuidance)
|
|
304
|
+
// treats null as "no active FlowDefinition" and falls back to the workflow.* path.
|
|
305
|
+
function loadActiveFlowStep(flowAgentsDir) {
|
|
306
|
+
const packageRoot = path.resolve(__dirname, '..', '..');
|
|
307
|
+
const builtResolver = path.join(packageRoot, 'build', 'src', 'lib', 'flow-resolver.js');
|
|
308
|
+
if (!fs.existsSync(builtResolver)) return null; // hasBuild guard: no build/ yet
|
|
309
|
+
try {
|
|
310
|
+
const resolver = require(builtResolver);
|
|
311
|
+
if (typeof resolver.resolveActiveFlowStep !== 'function') return null;
|
|
312
|
+
return resolver.resolveActiveFlowStep(flowAgentsDir);
|
|
313
|
+
} catch {
|
|
314
|
+
return null; // require failed or resolver threw — fail-open
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Message-assembly only: derive the user-facing gate identity prefix from the
|
|
320
|
+
* resolved active FlowDefinition step (loadActiveFlowStep). Produces
|
|
321
|
+
* "[<flowId>/<gateId>]" (e.g. "[builder.build/verify-gate]") when a
|
|
322
|
+
* FlowDefinition gate is active, else the generic "[stop-gate]" fallback.
|
|
323
|
+
* Purely cosmetic — never affects claim selection, HARD_BLOCK/FULL_BLOCK
|
|
324
|
+
* classification, block counting, or exit codes.
|
|
325
|
+
*/
|
|
326
|
+
function gateLabel(activeFlowStep) {
|
|
327
|
+
return activeFlowStep && activeFlowStep.flowId && activeFlowStep.gateId
|
|
328
|
+
? `[${activeFlowStep.flowId}/${activeFlowStep.gateId}]`
|
|
329
|
+
: '[stop-gate]';
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Derive the declared claimType Set from an active FlowDefinition's gate expects[].
|
|
334
|
+
* Returns null when no FlowDefinition is active (callers fall back to workflow.* only).
|
|
335
|
+
*/
|
|
336
|
+
function declaredClaimTypesFor(activeFlowStep) {
|
|
337
|
+
return activeFlowStep && Array.isArray(activeFlowStep.gateExpects)
|
|
338
|
+
? new Set(activeFlowStep.gateExpects.map(e => e && e.bundle_claim && e.bundle_claim.claimType).filter(Boolean))
|
|
339
|
+
: null;
|
|
340
|
+
}
|
|
341
|
+
|
|
267
342
|
function safeOneLine(value, maxLength = 220) {
|
|
268
343
|
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
|
269
344
|
if (text.length <= maxLength) return text;
|
|
@@ -274,11 +349,211 @@ function normalizedStatus(value) {
|
|
|
274
349
|
return safeOneLine(value, 80).toLowerCase();
|
|
275
350
|
}
|
|
276
351
|
|
|
277
|
-
|
|
352
|
+
// ─── ADR 0010 Phase 4b: bundle-first helpers for consumer migration ────────────
|
|
353
|
+
// These helpers extract evidence/critique/acceptance data from the trust.bundle
|
|
354
|
+
// when it is present, falling back to the bespoke sidecar for bundle-less sessions.
|
|
355
|
+
// The sidecar content is IDENTICAL to the bundle projection (Phase 4a guarantee),
|
|
356
|
+
// so consumer reads produce identical verdicts.
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Extract the effective "verdict" from trust.bundle workflow.check.* claims,
|
|
360
|
+
* or from declared claimTypes when a FlowDefinition is active (P-c extension).
|
|
361
|
+
* Priority of non-pass statuses: fail > not_verified > partial > pass.
|
|
362
|
+
* Returns null when the bundle has no matching claims.
|
|
363
|
+
*
|
|
364
|
+
* @param {Array} claims - trust.bundle claims array
|
|
365
|
+
* @param {Set<string>|null} [declaredClaimTypes] - optional set of declared claimTypes from gateExpects[]
|
|
366
|
+
*/
|
|
367
|
+
function bundleEvidenceVerdict(claims, declaredClaimTypes) {
|
|
368
|
+
const checkClaims = claims.filter(c => {
|
|
369
|
+
if (!c || typeof c.claimType !== 'string') return false;
|
|
370
|
+
if (c.claimType.startsWith('workflow.check.')) return true;
|
|
371
|
+
return declaredClaimTypes != null && declaredClaimTypes.has(c.claimType);
|
|
372
|
+
});
|
|
373
|
+
if (checkClaims.length === 0) return null;
|
|
374
|
+
let worst = 'pass';
|
|
375
|
+
const PRIORITY = { fail: 4, failed: 4, not_verified: 3, 'not-verified': 3, partial: 2, pass: 1, skip: 0 };
|
|
376
|
+
for (const c of checkClaims) {
|
|
377
|
+
const v = normalizedStatus(c.value || 'pass');
|
|
378
|
+
if ((PRIORITY[v] || 0) > (PRIORITY[worst] || 0)) worst = v;
|
|
379
|
+
}
|
|
380
|
+
return worst;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Extract the check ID from a claim's subjectId (format: "${slug}/${checkId}").
|
|
385
|
+
* Returns the part after the first slash, or the full subjectId if no slash.
|
|
386
|
+
*/
|
|
387
|
+
function claimCheckId(subjectId) {
|
|
388
|
+
const s = String(subjectId || '');
|
|
389
|
+
const slash = s.indexOf('/');
|
|
390
|
+
return slash >= 0 ? s.slice(slash + 1) : s;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Build the list of blocking check-claims from trust.bundle (equivalent to
|
|
395
|
+
* evidence.json.checks filtered to non-pass status).
|
|
396
|
+
* Returns objects shaped like { id, status, summary } (summary from fieldOrBehavior).
|
|
397
|
+
*
|
|
398
|
+
* @param {Array} claims - trust.bundle claims array
|
|
399
|
+
* @param {Set<string>|null} [declaredClaimTypes] - optional set of declared claimTypes from gateExpects[]
|
|
400
|
+
*/
|
|
401
|
+
function bundleBlockingChecks(claims, declaredClaimTypes) {
|
|
402
|
+
return claims.filter(c => {
|
|
403
|
+
if (!c || typeof c.claimType !== 'string') return false;
|
|
404
|
+
const typeMatch = c.claimType.startsWith('workflow.check.')
|
|
405
|
+
|| (declaredClaimTypes != null && declaredClaimTypes.has(c.claimType));
|
|
406
|
+
if (!typeMatch) return false;
|
|
407
|
+
const v = normalizedStatus(c.value || '');
|
|
408
|
+
return v === 'fail' || v === 'failed' || v === 'not_verified' || v === 'not-verified';
|
|
409
|
+
}).map(c => ({
|
|
410
|
+
id: claimCheckId(c.subjectId),
|
|
411
|
+
status: c.value || 'unknown',
|
|
412
|
+
summary: c.fieldOrBehavior || '',
|
|
413
|
+
}));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Determine critique status from trust.bundle workflow.critique.review claims,
|
|
418
|
+
* or from declared claimTypes when a FlowDefinition is active (P-c extension).
|
|
419
|
+
* Returns the "worst" value among critique claims, or null when none present.
|
|
420
|
+
*
|
|
421
|
+
* @param {Array} claims - trust.bundle claims array
|
|
422
|
+
* @param {Set<string>|null} [declaredClaimTypes] - optional set of declared claimTypes from gateExpects[]
|
|
423
|
+
*/
|
|
424
|
+
function bundleCritiqueStatus(claims, declaredClaimTypes) {
|
|
425
|
+
const critiqueClaims = claims.filter(c => {
|
|
426
|
+
if (!c || typeof c.claimType !== 'string') return false;
|
|
427
|
+
if (c.claimType === 'workflow.critique.review') return true;
|
|
428
|
+
return declaredClaimTypes != null && declaredClaimTypes.has(c.claimType);
|
|
429
|
+
});
|
|
430
|
+
if (critiqueClaims.length === 0) return null;
|
|
431
|
+
// A disputed or failed critique is blocking
|
|
432
|
+
for (const c of critiqueClaims) {
|
|
433
|
+
const v = normalizedStatus(c.value || '');
|
|
434
|
+
if (v === 'fail' || v === 'failed' || c.status === 'disputed' || c.status === 'rejected') return c.value || 'fail';
|
|
435
|
+
}
|
|
436
|
+
return 'pass';
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Build the list of claimed-pass command checks from the trust.bundle's evidence[]
|
|
441
|
+
* (items with execution.label) and from workflow.check.command claims whose effective
|
|
442
|
+
* value is "pass" (never-captured claimed pass). Falls back to an empty list when
|
|
443
|
+
* the bundle has no evidence items.
|
|
444
|
+
*
|
|
445
|
+
* Returns objects shaped like { id, kind, status, command } — same shape as
|
|
446
|
+
* evidence.json.checks — so captureCrossReference's body logic is unchanged.
|
|
447
|
+
*
|
|
448
|
+
* @param {object} bundle - trust.bundle object
|
|
449
|
+
* @param {Set<string>|null} [declaredClaimTypes] - optional set of declared claimTypes from gateExpects[]
|
|
450
|
+
*/
|
|
451
|
+
function bundleClaimedPassCommandChecks(bundle, declaredClaimTypes) {
|
|
452
|
+
const allEvidence = Array.isArray(bundle.evidence) ? bundle.evidence : [];
|
|
453
|
+
const allClaims = Array.isArray(bundle.claims) ? bundle.claims : [];
|
|
454
|
+
|
|
455
|
+
// Build a map from claimId -> claim for fast lookup
|
|
456
|
+
const claimById = new Map();
|
|
457
|
+
for (const c of allClaims) {
|
|
458
|
+
if (c && c.id) claimById.set(c.id, c);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const checks = [];
|
|
462
|
+
const seen = new Set();
|
|
463
|
+
|
|
464
|
+
// (A) Evidence items with execution.label (command captures).
|
|
465
|
+
// These represent commands that actually ran — include them regardless of
|
|
466
|
+
// effective status so we can cross-reference against the live log.
|
|
467
|
+
for (const ev of allEvidence) {
|
|
468
|
+
if (!ev || !ev.execution || !ev.execution.label) continue;
|
|
469
|
+
const cmd = String(ev.execution.label || '').replace(/\s+/g, ' ').trim();
|
|
470
|
+
if (!cmd) continue;
|
|
471
|
+
const claim = claimById.get(ev.claimId);
|
|
472
|
+
if (!claim) continue;
|
|
473
|
+
const claimTypeStr = String(claim.claimType || '');
|
|
474
|
+
if (!claimTypeStr.startsWith('workflow.check.') && !(declaredClaimTypes != null && declaredClaimTypes.has(claimTypeStr))) continue;
|
|
475
|
+
// Deduplicate by command
|
|
476
|
+
if (seen.has(cmd)) continue;
|
|
477
|
+
seen.add(cmd);
|
|
478
|
+
const id = claimCheckId(claim.subjectId);
|
|
479
|
+
// Use 'pass' as the nominal claimed status; cross-reference catches contradictions.
|
|
480
|
+
checks.push({ id, kind: 'command', status: 'pass', command: cmd });
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// (B) Workflow.check.command claims with effective value "pass" but no capture
|
|
484
|
+
// (no evidence item with execution) — these are originally-claimed-pass checks
|
|
485
|
+
// that were never captured.
|
|
486
|
+
for (const c of allClaims) {
|
|
487
|
+
if (!c || typeof c.claimType !== 'string') continue;
|
|
488
|
+
const isCommandType = c.claimType === 'workflow.check.command'
|
|
489
|
+
|| (declaredClaimTypes != null && declaredClaimTypes.has(c.claimType));
|
|
490
|
+
if (!isCommandType) continue;
|
|
491
|
+
if (normalizedStatus(c.value || '') !== 'pass') continue;
|
|
492
|
+
// Check if this claim already has a capture evidence item (covered in (A))
|
|
493
|
+
const hasCapture = allEvidence.some(ev => ev && ev.claimId === c.id && ev.execution && ev.execution.label);
|
|
494
|
+
if (hasCapture) continue;
|
|
495
|
+
// No capture — use fieldOrBehavior as command identifier for backstop resolution.
|
|
496
|
+
const evItem = allEvidence.find(ev => ev && ev.claimId === c.id);
|
|
497
|
+
const cmd = evItem
|
|
498
|
+
? normalizeCommand(evItem.excerptOrSummary || '')
|
|
499
|
+
: normalizeCommand(c.fieldOrBehavior || '');
|
|
500
|
+
const id = claimCheckId(c.subjectId);
|
|
501
|
+
if (!cmd) {
|
|
502
|
+
checks.push({ id, kind: 'command', status: 'pass', command: '' });
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (seen.has(cmd)) continue;
|
|
506
|
+
seen.add(cmd);
|
|
507
|
+
checks.push({ id, kind: 'command', status: 'pass', command: cmd });
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return checks;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Extract pending acceptance criteria from trust.bundle workflow.acceptance.criterion claims,
|
|
515
|
+
* or from declared claimTypes when a FlowDefinition is active (P-c extension).
|
|
516
|
+
* Returns the count of claims whose value is pending/not_started/empty/unknown.
|
|
517
|
+
* Returns null when the bundle has no matching claims.
|
|
518
|
+
*
|
|
519
|
+
* @param {Array} claims - trust.bundle claims array
|
|
520
|
+
* @param {Set<string>|null} [declaredClaimTypes] - optional set of declared claimTypes from gateExpects[]
|
|
521
|
+
*/
|
|
522
|
+
function bundlePendingCriteriaCount(claims, declaredClaimTypes) {
|
|
523
|
+
const criteriaClaims = claims.filter(c => {
|
|
524
|
+
if (!c || typeof c.claimType !== 'string') return false;
|
|
525
|
+
if (c.claimType === 'workflow.acceptance.criterion') return true;
|
|
526
|
+
return declaredClaimTypes != null && declaredClaimTypes.has(c.claimType);
|
|
527
|
+
});
|
|
528
|
+
if (criteriaClaims.length === 0) return null;
|
|
529
|
+
const pending = criteriaClaims.filter(c => {
|
|
530
|
+
const v = normalizedStatus(c.value || '');
|
|
531
|
+
return v === 'pending' || v === 'not_started' || v === '' || v === 'unknown';
|
|
532
|
+
});
|
|
533
|
+
return pending.length;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* ADR 0010 Phase 4b: sidecarGuidance — bundle-first evidence/critique reads.
|
|
540
|
+
* state.json reads are UNCHANGED (state.json stays as primary source).
|
|
541
|
+
* evidence.json verdict/checks: read from trust.bundle when present, fall back
|
|
542
|
+
* to evidence.json for bundle-less sessions (no regression).
|
|
543
|
+
* not_verified_gaps: always from evidence.json (no bundle equivalent).
|
|
544
|
+
* critique status: read from trust.bundle when present, fall back to critique.json.
|
|
545
|
+
* Finding details: still from critique.json when present (both bundle and sidecar paths).
|
|
546
|
+
*
|
|
547
|
+
* ADR 0016 P-c: when activeFlowStep is non-null, pass its declared claimTypes to
|
|
548
|
+
* bundle helpers so declared-type claims (e.g. builder.verify.tests) produce the
|
|
549
|
+
* same sidecar guidance signals as workflow.* claims.
|
|
550
|
+
*/
|
|
551
|
+
function sidecarGuidance(root, artifactDir, activeFlowStep) {
|
|
552
|
+
// Build the declared claimType set from the FlowDefinition gate expects[] (P-c).
|
|
553
|
+
// Null when no FlowDefinition is active (fallback: helpers use workflow.* prefix only).
|
|
554
|
+
const declaredClaimTypes = declaredClaimTypesFor(activeFlowStep);
|
|
278
555
|
const warnings = [];
|
|
279
556
|
const state = readJsonFile(path.join(artifactDir, 'state.json'));
|
|
280
|
-
const evidence = readJsonFile(path.join(artifactDir, 'evidence.json'));
|
|
281
|
-
const critique = readJsonFile(path.join(artifactDir, 'critique.json'));
|
|
282
557
|
const base = relative(root, artifactDir);
|
|
283
558
|
|
|
284
559
|
if (state) {
|
|
@@ -301,38 +576,88 @@ function sidecarGuidance(root, artifactDir) {
|
|
|
301
576
|
warnings.push(`${base} next action: ${safeOneLine(next.summary)}${next.target_phase ? ` (target phase: ${safeOneLine(next.target_phase, 80)})` : ''}`);
|
|
302
577
|
}
|
|
303
578
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
579
|
+
// ── Evidence verdict + checks: bundle-first, fallback to evidence.json ────
|
|
580
|
+
const bundle = readJsonFile(path.join(artifactDir, 'trust.bundle'));
|
|
581
|
+
const bundleClaims = bundle && Array.isArray(bundle.claims) ? bundle.claims : null;
|
|
582
|
+
|
|
583
|
+
if (bundleClaims) {
|
|
584
|
+
// Phase 4b: read verdict and per-check signals from trust.bundle claims.
|
|
585
|
+
// P-c: pass declaredClaimTypes so declared-type claims are included alongside workflow.*.
|
|
586
|
+
const verdict = bundleEvidenceVerdict(bundleClaims, declaredClaimTypes);
|
|
587
|
+
if (verdict && verdict !== 'pass' && verdict !== 'skip') {
|
|
588
|
+
warnings.push(`${base} evidence verdict:${safeOneLine(verdict, 40)}; do not deliver without accepted gap or new evidence.`);
|
|
310
589
|
}
|
|
311
|
-
|
|
312
|
-
if (evidence && Array.isArray(evidence.checks)) {
|
|
313
|
-
const blockingChecks = evidence.checks.filter(check => {
|
|
314
|
-
const status = normalizedStatus(check && check.status);
|
|
315
|
-
return status === 'fail' || status === 'failed' || status === 'not_verified' || status === 'not-verified';
|
|
316
|
-
});
|
|
590
|
+
const blockingChecks = bundleBlockingChecks(bundleClaims, declaredClaimTypes);
|
|
317
591
|
for (const check of blockingChecks.slice(0, 4)) {
|
|
318
592
|
const status = safeOneLine(check.status || 'unknown', 40);
|
|
319
593
|
warnings.push(`${base} evidence check ${safeOneLine(check.id || 'unknown', 80)} status:${status}: ${safeOneLine(check.summary)}`);
|
|
320
594
|
}
|
|
595
|
+
} else {
|
|
596
|
+
// Fallback: no bundle — read from evidence.json (existing behavior, no regression).
|
|
597
|
+
const evidence = readJsonFile(path.join(artifactDir, 'evidence.json'));
|
|
598
|
+
if (evidence && normalizedStatus(evidence.verdict) && normalizedStatus(evidence.verdict) !== 'pass') {
|
|
599
|
+
warnings.push(`${base} evidence verdict:${safeOneLine(evidence.verdict, 40)}; do not deliver without accepted gap or new evidence.`);
|
|
600
|
+
}
|
|
601
|
+
if (evidence && Array.isArray(evidence.checks)) {
|
|
602
|
+
const blockingChecks = evidence.checks.filter(check => {
|
|
603
|
+
const status = normalizedStatus(check && check.status);
|
|
604
|
+
return status === 'fail' || status === 'failed' || status === 'not_verified' || status === 'not-verified';
|
|
605
|
+
});
|
|
606
|
+
for (const check of blockingChecks.slice(0, 4)) {
|
|
607
|
+
const status = safeOneLine(check.status || 'unknown', 40);
|
|
608
|
+
warnings.push(`${base} evidence check ${safeOneLine(check.id || 'unknown', 80)} status:${status}: ${safeOneLine(check.summary)}`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// not_verified_gaps: always from evidence.json (no bundle equivalent).
|
|
614
|
+
const evidence = readJsonFile(path.join(artifactDir, 'evidence.json'));
|
|
615
|
+
if (evidence && Array.isArray(evidence.not_verified_gaps) && evidence.not_verified_gaps.length > 0) {
|
|
616
|
+
for (const gap of evidence.not_verified_gaps.slice(0, 3)) {
|
|
617
|
+
warnings.push(`${base} evidence NOT_VERIFIED gap: ${safeOneLine(gap)}`);
|
|
618
|
+
}
|
|
321
619
|
}
|
|
322
620
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
621
|
+
// ── Critique: bundle-first status, critique.json for finding details ──────
|
|
622
|
+
const critique = readJsonFile(path.join(artifactDir, 'critique.json'));
|
|
623
|
+
|
|
624
|
+
if (bundleClaims) {
|
|
625
|
+
// Phase 4b: read critique status from trust.bundle claims.
|
|
626
|
+
// P-c: pass declaredClaimTypes so declared-type critique claims are included.
|
|
627
|
+
const critiqueStatusVal = bundleCritiqueStatus(bundleClaims, declaredClaimTypes);
|
|
628
|
+
const critiqueIsBlocking = critiqueStatusVal !== null && normalizedStatus(critiqueStatusVal) !== 'pass';
|
|
629
|
+
if (critiqueIsBlocking) {
|
|
630
|
+
warnings.push(`${base} critique status:${safeOneLine(critiqueStatusVal || 'unknown', 40)}; required critique must pass or findings be accepted.`);
|
|
631
|
+
// Finding details: still from critique.json when present (both paths use the same details source).
|
|
632
|
+
const critiques = critique && Array.isArray(critique.critiques) ? critique.critiques : [];
|
|
633
|
+
let openCount = 0;
|
|
634
|
+
for (const review of critiques) {
|
|
635
|
+
const findings = Array.isArray(review && review.findings) ? review.findings : [];
|
|
636
|
+
for (const finding of findings) {
|
|
637
|
+
if (!finding || normalizedStatus(finding.status) !== 'open') continue;
|
|
638
|
+
warnings.push(`${base} critique open ${safeOneLine(finding.severity || 'unknown', 40)}: ${safeOneLine(finding.description)}`);
|
|
639
|
+
openCount += 1;
|
|
640
|
+
if (openCount >= 3) break;
|
|
641
|
+
}
|
|
642
|
+
if (openCount >= 3) break;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
} else {
|
|
646
|
+
// Fallback: no bundle — read from critique.json (existing behavior, no regression).
|
|
647
|
+
if (critique && critique.required === true && normalizedStatus(critique.status) !== 'pass') {
|
|
648
|
+
warnings.push(`${base} critique status:${safeOneLine(critique.status || 'unknown', 40)}; required critique must pass or findings be accepted.`);
|
|
649
|
+
const critiques = Array.isArray(critique.critiques) ? critique.critiques : [];
|
|
650
|
+
let openCount = 0;
|
|
651
|
+
for (const review of critiques) {
|
|
652
|
+
const findings = Array.isArray(review && review.findings) ? review.findings : [];
|
|
653
|
+
for (const finding of findings) {
|
|
654
|
+
if (!finding || normalizedStatus(finding.status) !== 'open') continue;
|
|
655
|
+
warnings.push(`${base} critique open ${safeOneLine(finding.severity || 'unknown', 40)}: ${safeOneLine(finding.description)}`);
|
|
656
|
+
openCount += 1;
|
|
657
|
+
if (openCount >= 3) break;
|
|
658
|
+
}
|
|
333
659
|
if (openCount >= 3) break;
|
|
334
660
|
}
|
|
335
|
-
if (openCount >= 3) break;
|
|
336
661
|
}
|
|
337
662
|
}
|
|
338
663
|
|
|
@@ -342,12 +667,16 @@ function sidecarGuidance(root, artifactDir) {
|
|
|
342
667
|
// -----------------------------------------------------------------------
|
|
343
668
|
// Capture-first evidence determinism (Part B)
|
|
344
669
|
//
|
|
345
|
-
//
|
|
346
|
-
// hook (evidence-capture.js)
|
|
347
|
-
// command-log.jsonl at the source. Here at the
|
|
348
|
-
//
|
|
349
|
-
// back to re-running a TRUSTED command when the log has no
|
|
350
|
-
// claimed-pass command (i.e. it was never actually run).
|
|
670
|
+
// The trust.bundle (emitted by workflow-sidecar via @kontourai/surface) carries
|
|
671
|
+
// capture-authoritative evidence items. The capture hook (evidence-capture.js)
|
|
672
|
+
// writes REAL command results to command-log.jsonl at the source. Here at the
|
|
673
|
+
// Stop gate we cross-reference claimed-pass command checks against that captured
|
|
674
|
+
// truth, and only fall back to re-running a TRUSTED command when the log has no
|
|
675
|
+
// execution for a claimed-pass command (i.e. it was never actually run).
|
|
676
|
+
//
|
|
677
|
+
// ADR 0010 Phase 4b: source the claimed-pass command checks from the bundle's
|
|
678
|
+
// evidence[] (execution/command items) instead of evidence.json checks.
|
|
679
|
+
// command-log.jsonl path UNCHANGED — it stays the capture truth source.
|
|
351
680
|
// -----------------------------------------------------------------------
|
|
352
681
|
|
|
353
682
|
function normalizeCommand(value) {
|
|
@@ -383,6 +712,195 @@ function readCommandLog(artifactDir) {
|
|
|
383
712
|
return byCommand;
|
|
384
713
|
}
|
|
385
714
|
|
|
715
|
+
/**
|
|
716
|
+
* Read command-log.jsonl into a map of normalized-command -> LATEST capture outcome.
|
|
717
|
+
* The LAST entry for each command wins (unlike readCommandLog which makes FAIL sticky).
|
|
718
|
+
* Used for both capturedFailReconciliation and captureCrossReference (Fix C): we want to
|
|
719
|
+
* know the LAST result, so a genuine re-run-to-pass clears the earlier FAIL. Only an actual
|
|
720
|
+
* re-run (new PASS entry in the log) clears it — a new claim cannot change the log.
|
|
721
|
+
*/
|
|
722
|
+
function readLatestCommandLog(artifactDir) {
|
|
723
|
+
const file = path.join(artifactDir, 'command-log.jsonl');
|
|
724
|
+
let raw = '';
|
|
725
|
+
try { raw = fs.readFileSync(file, 'utf8'); } catch { return new Map(); }
|
|
726
|
+
const byCommand = new Map();
|
|
727
|
+
for (const line of raw.split('\n')) {
|
|
728
|
+
const trimmed = line.trim();
|
|
729
|
+
if (!trimmed) continue;
|
|
730
|
+
let entry;
|
|
731
|
+
try { entry = JSON.parse(trimmed); } catch { continue; }
|
|
732
|
+
if (!entry || typeof entry.command !== 'string') continue;
|
|
733
|
+
const key = normalizeCommand(entry.command);
|
|
734
|
+
if (!key) continue;
|
|
735
|
+
const failed = entry.observedResult === 'fail' || (Number.isInteger(entry.exitCode) && entry.exitCode !== 0);
|
|
736
|
+
// LAST entry wins — genuine re-run-to-pass overwrites the earlier FAIL.
|
|
737
|
+
byCommand.set(key, {
|
|
738
|
+
ran: true,
|
|
739
|
+
failed,
|
|
740
|
+
exitCode: Number.isInteger(entry.exitCode) ? entry.exitCode : null,
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
return byCommand;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// ─── Claim-status helpers for capturedFailReconciliation ─────────────────────
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Returns true when a claim's stored status+value asserts the command PASSED.
|
|
750
|
+
* Used to detect namespace-agnostic false-completions.
|
|
751
|
+
*/
|
|
752
|
+
function claimAssertsPass(status, value) {
|
|
753
|
+
const s = String(status || '').toLowerCase();
|
|
754
|
+
const v = String(value || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
755
|
+
// Fix E: added 'approved' status alias and 'true'/'ok' value aliases
|
|
756
|
+
return (s === 'verified' || s === 'assumed' || s === 'accepted' || s === 'trusted' || s === 'approved')
|
|
757
|
+
&& (v === 'pass' || v === 'passed' || v === 'verified' || v === 'true' || v === 'ok');
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Returns true when a claim's stored status+value ACKNOWLEDGES a failure
|
|
762
|
+
* (the agent owned the failure rather than claiming pass).
|
|
763
|
+
*/
|
|
764
|
+
function claimAcknowledgesFailure(status, value) {
|
|
765
|
+
const s = String(status || '').toLowerCase();
|
|
766
|
+
const v = String(value || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
767
|
+
return s === 'disputed' || s === 'rejected' || s === 'failing' || s === 'failed'
|
|
768
|
+
|| s === 'not_verified' || s === 'not-verified'
|
|
769
|
+
|| v === 'fail' || v === 'failed' || v === 'not_verified' || v === 'failing';
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// hasLaunderingOperator (the exit-code-mask heuristic) is imported from
|
|
773
|
+
// ../lib/command-log-chain.js so this verifier and scripts/ci/trust-reconcile.js
|
|
774
|
+
// share one normative definition. Applied in captureCrossReference's satisfied
|
|
775
|
+
// path and capturedFailReconciliation.
|
|
776
|
+
|
|
777
|
+
// ─── Hash-chain integrity verification (Increment B2, tamper-EVIDENCE) ────────
|
|
778
|
+
//
|
|
779
|
+
// command-log.jsonl is the independent capture truth source. Each chained entry
|
|
780
|
+
// carries a `_chain` block: {seq, prevHash, hash} where
|
|
781
|
+
// hash = sha256(prevHash + canonicalJson(record))
|
|
782
|
+
// and canonicalJson is the record WITHOUT `_chain`, keys sorted alphabetically.
|
|
783
|
+
//
|
|
784
|
+
// HONEST FRAMING: this is tamper-EVIDENCE, not tamper-PROOF. An agent that
|
|
785
|
+
// rewrites all hashes can still forge a valid chain. The real tamper-proof
|
|
786
|
+
// boundary is the signed checkpoint (B1). This raises the local bar and catches
|
|
787
|
+
// casual/accidental tampering and corruption.
|
|
788
|
+
//
|
|
789
|
+
// verifyCommandLogChain returns:
|
|
790
|
+
// "ok" — all entries have _chain and every hash/linkage is valid.
|
|
791
|
+
// "legacy" — no _chain fields at all (pre-B2 log); treat as today, no
|
|
792
|
+
// integrity claim. Backward-compat: existing fixtures stay green.
|
|
793
|
+
// "broken" — a chained entry has a bad hash or bad linkage; the capture
|
|
794
|
+
// truth source appears altered/removed/reordered.
|
|
795
|
+
//
|
|
796
|
+
// The genesis prevHash is a fixed arbitrary sentinel — NOT the SHA256 of any
|
|
797
|
+
// specific input string. The comment in evidence-capture.js previously (and
|
|
798
|
+
// incorrectly) claimed it was sha256("flow-agents:command-log:genesis"); it is not.
|
|
799
|
+
// Both the genesis (CHAIN_GENESIS_VERIFY, imported above) and the canonical-JSON
|
|
800
|
+
// helper (canonicalJsonForChain) come from ../lib/command-log-chain.js, the single
|
|
801
|
+
// source the writer in evidence-capture.js imports too — so they cannot diverge.
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Verify the hash chain of command-log.jsonl.
|
|
805
|
+
* Returns { status, brokenAt, forkAt } where:
|
|
806
|
+
* status = "ok" | "legacy" | "broken" | "forked"
|
|
807
|
+
* brokenAt = index (0-based) of the first broken entry, or null
|
|
808
|
+
* forkAt = index (0-based) of the first concurrent-fork sibling, or null
|
|
809
|
+
*
|
|
810
|
+
* "forked" is a BENIGN concurrent-append race, not tampering: two PostToolUse
|
|
811
|
+
* captures appended off the same parent tip (e.g. parallel agents sharing one
|
|
812
|
+
* log) before the writer lock (flow-agents#232) serialized them. It is
|
|
813
|
+
* distinguished from "broken" because:
|
|
814
|
+
* - every entry's hash is still self-consistent (no content was edited), and
|
|
815
|
+
* - every entry's parent is reachable (nothing was reordered or removed);
|
|
816
|
+
* - the only anomaly is a parent claimed by >1 capture-sourced sibling.
|
|
817
|
+
* Tamper — a content edit (self-hash mismatch), a reorder, or a deletion
|
|
818
|
+
* (unreachable parent) — still returns "broken". A fork cannot be used to
|
|
819
|
+
* launder a content edit: editing a record breaks its self-hash, which is
|
|
820
|
+
* checked before fork classification.
|
|
821
|
+
*/
|
|
822
|
+
function verifyCommandLogChain(artifactDir) {
|
|
823
|
+
const file = path.join(artifactDir, 'command-log.jsonl');
|
|
824
|
+
let raw = '';
|
|
825
|
+
try { raw = fs.readFileSync(file, 'utf8'); } catch { return { status: 'legacy', brokenAt: null, forkAt: null }; }
|
|
826
|
+
|
|
827
|
+
const lines = raw.split('\n').filter(l => l.trim());
|
|
828
|
+
if (lines.length === 0) return { status: 'legacy', brokenAt: null, forkAt: null };
|
|
829
|
+
|
|
830
|
+
// Parse all entries, tolerating unparseable lines (they count as legacy/unchained).
|
|
831
|
+
const entries = [];
|
|
832
|
+
for (const line of lines) {
|
|
833
|
+
try {
|
|
834
|
+
const entry = JSON.parse(line);
|
|
835
|
+
if (entry && typeof entry === 'object') entries.push(entry);
|
|
836
|
+
} catch { /* skip malformed lines */ }
|
|
837
|
+
}
|
|
838
|
+
if (entries.length === 0) return { status: 'legacy', brokenAt: null, forkAt: null };
|
|
839
|
+
|
|
840
|
+
// Classify: are there any chained entries?
|
|
841
|
+
const hasAnyChain = entries.some(e => e._chain && typeof e._chain.hash === 'string');
|
|
842
|
+
if (!hasAnyChain) return { status: 'legacy', brokenAt: null, forkAt: null };
|
|
843
|
+
|
|
844
|
+
// Walk in file order. A chained entry is ACCEPTED when both:
|
|
845
|
+
// (a) self-consistent: hash === sha256(prevHash + canonicalJson(record)),
|
|
846
|
+
// so a content edit (e.g. flipping exitCode) without rehashing fails; and
|
|
847
|
+
// (b) reachable: prevHash is genesis or the hash of any prior accepted entry.
|
|
848
|
+
// We track the SET of reachable hashes (not just the latest tip) so that
|
|
849
|
+
// concurrent-fork siblings — which share a still-reachable parent — are
|
|
850
|
+
// tolerated, while a reorder/deletion (parent not reachable) is caught.
|
|
851
|
+
const reachable = new Set([CHAIN_GENESIS_VERIFY]);
|
|
852
|
+
const parentSources = new Map(); // prevHash -> [source, ...] (fork detection)
|
|
853
|
+
let prevWasChained = false;
|
|
854
|
+
let forked = false;
|
|
855
|
+
let firstForkAt = null;
|
|
856
|
+
|
|
857
|
+
for (let i = 0; i < entries.length; i++) {
|
|
858
|
+
const entry = entries[i];
|
|
859
|
+
const chain = entry._chain;
|
|
860
|
+
if (!chain || typeof chain.hash !== 'string') {
|
|
861
|
+
// Legacy entry without _chain. If we have already seen a chained entry,
|
|
862
|
+
// a gap in the chain (a legacy entry in the middle) counts as broken
|
|
863
|
+
// (it could indicate a removed chained entry was replaced by a legacy one).
|
|
864
|
+
if (prevWasChained) return { status: 'broken', brokenAt: i, forkAt: null };
|
|
865
|
+
// Before any chained entry: tolerate (legacy prefix).
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// (a) Self-consistency. A content edit without rehashing fails here.
|
|
870
|
+
if (typeof chain.prevHash !== 'string') return { status: 'broken', brokenAt: i, forkAt: null };
|
|
871
|
+
const selfHash = crypto.createHash('sha256')
|
|
872
|
+
.update(chain.prevHash + canonicalJsonForChain(entry), 'utf8')
|
|
873
|
+
.digest('hex');
|
|
874
|
+
if (chain.hash !== selfHash) return { status: 'broken', brokenAt: i, forkAt: null };
|
|
875
|
+
|
|
876
|
+
// (b) Reachability. An unreachable parent means a reorder or a removed
|
|
877
|
+
// predecessor — structural tamper, not a benign concurrent append.
|
|
878
|
+
if (!reachable.has(chain.prevHash)) return { status: 'broken', brokenAt: i, forkAt: null };
|
|
879
|
+
|
|
880
|
+
// Fork detection: a parent claimed by more than one entry is a fork. It is
|
|
881
|
+
// benign only when EVERY sibling on that parent is a PostToolUse capture
|
|
882
|
+
// (two captures racing on the same tip). Any non-capture sibling on a
|
|
883
|
+
// shared parent is treated as tamper (conservative).
|
|
884
|
+
const sources = parentSources.get(chain.prevHash) || [];
|
|
885
|
+
sources.push(entry.source);
|
|
886
|
+
parentSources.set(chain.prevHash, sources);
|
|
887
|
+
if (sources.length > 1) {
|
|
888
|
+
if (!sources.every(s => s === 'postToolUse-capture')) {
|
|
889
|
+
return { status: 'broken', brokenAt: i, forkAt: null };
|
|
890
|
+
}
|
|
891
|
+
if (firstForkAt === null) firstForkAt = i;
|
|
892
|
+
forked = true;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
reachable.add(chain.hash);
|
|
896
|
+
prevWasChained = true;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (forked) return { status: 'forked', brokenAt: null, forkAt: firstForkAt };
|
|
900
|
+
return { status: 'ok', brokenAt: null, forkAt: null };
|
|
901
|
+
}
|
|
902
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
903
|
+
|
|
386
904
|
/**
|
|
387
905
|
* Resolve a TRUSTED command to re-run for a claimed-pass check whose command was
|
|
388
906
|
* never captured. Priority (most trusted first):
|
|
@@ -394,10 +912,39 @@ function readCommandLog(artifactDir) {
|
|
|
394
912
|
* FLOW_AGENTS_GOAL_FIT_RECHECK=true (the RCE-risky opt-in path).
|
|
395
913
|
* Returns { argv, cwd, source } or null when nothing trusted resolves.
|
|
396
914
|
*/
|
|
915
|
+
// WS8 (AC10b): a kind:"command" evidence ref's excerpt/command must be a literally runnable
|
|
916
|
+
// shell command, not a prose description of a manual verification step. This heuristic
|
|
917
|
+
// rejects prose so the goal-fit backstop never spawns `bash -lc "<a sentence>"` and
|
|
918
|
+
// misreports the resulting shell error as a caught false-completion. Pairs with the rule
|
|
919
|
+
// stated in context/contracts/planning-contract.md (AC11).
|
|
920
|
+
function isRunnableCommandText(text) {
|
|
921
|
+
const s = typeof text === 'string' ? text.trim() : '';
|
|
922
|
+
if (!s) return false;
|
|
923
|
+
// Sentence-like prose (a . ! or ? followed by more words) is not a single command.
|
|
924
|
+
if (/[.!?]\s+\S/.test(s)) return false;
|
|
925
|
+
const first = s.split(/\s+/)[0] || '';
|
|
926
|
+
// WS8 (AC10b, iteration 2): the first-token allowlist is broadened to the common
|
|
927
|
+
// real-world verify binaries a project's runnable evidence names (git, tsc, eslint,
|
|
928
|
+
// python/python3, docker, curl, jq, diff, grep, test) so honest command evidence is not
|
|
929
|
+
// misclassified as prose. Still fail-closed for genuinely non-command prose: a bare
|
|
930
|
+
// sentence whose first token is none of these (and carries no path prefix or shell
|
|
931
|
+
// metacharacter) is rejected rather than executed.
|
|
932
|
+
const knownBinary = /^(npm|npx|pnpm|yarn|node|bash|sh|zsh|make|just|task|cargo|go|pytest|tox|deno|bun|ruby|rake|mvn|gradle|dotnet|swift|flutter|dart|git|tsc|eslint|python|python3|docker|curl|jq|diff|grep|test)$/.test(first)
|
|
933
|
+
|| first.startsWith('./') || first.startsWith('/') || first.includes('/');
|
|
934
|
+
const hasShellMeta = /[|&;<>()$`]/.test(s);
|
|
935
|
+
return knownBinary || hasShellMeta;
|
|
936
|
+
}
|
|
937
|
+
|
|
397
938
|
function resolveTrustedCommand(root, artifactDir, check, acceptance) {
|
|
398
939
|
// (a) acceptance criterion command for the matching criterion.
|
|
399
940
|
const fromAcceptance = acceptanceCommandFor(check, acceptance);
|
|
400
|
-
if (fromAcceptance)
|
|
941
|
+
if (fromAcceptance) {
|
|
942
|
+
// WS8 (AC10b): never spawn a prose "excerpt" as bash. A kind:"command" ref whose text
|
|
943
|
+
// is not a runnable shell command is malformed-evidence — reported distinctly, not
|
|
944
|
+
// executed, and not conflated with a caught false-completion.
|
|
945
|
+
if (!isRunnableCommandText(fromAcceptance)) return { malformed: fromAcceptance };
|
|
946
|
+
return { argv: ['bash', '-lc', fromAcceptance], cwd: root, source: 'acceptance' };
|
|
947
|
+
}
|
|
401
948
|
|
|
402
949
|
// (b) declared manifest target. Map the check command/id to a declared script.
|
|
403
950
|
const declared = declaredManifestTarget(root, check);
|
|
@@ -509,42 +1056,147 @@ function runBackstop(trusted) {
|
|
|
509
1056
|
}
|
|
510
1057
|
|
|
511
1058
|
/**
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
1059
|
+
* ADR 0010 Phase 4b: captureCrossReference — bundle-first command check sourcing.
|
|
1060
|
+
* Sources the claimed-pass command checks from trust.bundle evidence[] (execution/
|
|
1061
|
+
* command items) when the bundle is present, falling back to evidence.json checks
|
|
1062
|
+
* for bundle-less sessions. command-log.jsonl UNCHANGED — it stays the capture
|
|
1063
|
+
* truth source. The teeth (claimed-pass + captured-fail → block) are byte-identical.
|
|
515
1064
|
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
1065
|
+
* ADR 0016 P-c (fix): accept activeFlowStep so declared-type sessions (e.g.
|
|
1066
|
+
* builder.verify.tests) are visible to the cross-reference, closing the hole
|
|
1067
|
+
* where captureCrossReference was the only capture consumer not threaded with
|
|
1068
|
+
* the FlowDefinition. Mirrors the pattern in bundleEnforcement / sidecarGuidance.
|
|
519
1069
|
*/
|
|
520
|
-
function captureCrossReference(root, artifactDir) {
|
|
521
|
-
|
|
522
|
-
|
|
1070
|
+
function captureCrossReference(root, artifactDir, activeFlowStep) {
|
|
1071
|
+
// Build the declared claimType set from the FlowDefinition gate expects[] (P-c).
|
|
1072
|
+
// Null when no FlowDefinition is active (fallback: bundleClaimedPassCommandChecks
|
|
1073
|
+
// uses workflow.check.* prefix only — no regression for non-FlowDefinition sessions).
|
|
1074
|
+
const declaredClaimTypes = declaredClaimTypesFor(activeFlowStep);
|
|
1075
|
+
const bundle = readJsonFile(path.join(artifactDir, 'trust.bundle'));
|
|
523
1076
|
const acceptance = readJsonFile(path.join(artifactDir, 'acceptance.json'));
|
|
524
|
-
const log =
|
|
1077
|
+
const log = readLatestCommandLog(artifactDir); // Fix C: latest-wins; genuine fix-then-rerun-to-pass clears the block
|
|
525
1078
|
const base = relative(root, artifactDir);
|
|
526
1079
|
const backstopMode = resolveBackstopMode();
|
|
527
1080
|
const warnings = [];
|
|
528
1081
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
1082
|
+
// AC3 fail-closed: detect a missing command log in a post-execution session.
|
|
1083
|
+
// When state.json confirms the session is past the planning phase (commands should
|
|
1084
|
+
// have run), but command-log.jsonl is absent, emit a FULL_BLOCK warning.
|
|
1085
|
+
// A genuine pre-execution session (no commands expected yet) is NOT blocked —
|
|
1086
|
+
// distinguished by requiring BOTH a non-pre-execution phase AND a non-pre-execution
|
|
1087
|
+
// status (avoids blocking a freshly planned task that has state.json but no log yet).
|
|
1088
|
+
//
|
|
1089
|
+
// BAR-RAISER: an agent could forge state.json with a pre-execution phase to avoid
|
|
1090
|
+
// this. The real anchor is external (signed checkpoints + human review).
|
|
1091
|
+
{
|
|
1092
|
+
const logFileMissing = !fs.existsSync(path.join(artifactDir, 'command-log.jsonl'));
|
|
1093
|
+
if (logFileMissing) {
|
|
1094
|
+
const captureState = readJsonFile(path.join(artifactDir, 'state.json'));
|
|
1095
|
+
if (captureState) {
|
|
1096
|
+
const capturePhase = normalizedStatus(captureState.phase || '');
|
|
1097
|
+
const captureStatus = normalizedStatus(captureState.status || '');
|
|
1098
|
+
// Post-execution: phase is set AND not a pre-execution phase (idea/backlog/pickup/planning).
|
|
1099
|
+
// Also require status is not a pre-execution status (new/planning/planned/backlog).
|
|
1100
|
+
const postExecPhase = capturePhase && !PRE_EXECUTION_PHASES.has(capturePhase);
|
|
1101
|
+
const preExecStatus = !captureStatus || captureStatus === 'new' || PRE_EXECUTION_STATUSES.has(captureStatus);
|
|
1102
|
+
if (postExecPhase && !preExecStatus) {
|
|
1103
|
+
// Fix #216 over-block: only emit the missing-log warning when a command was
|
|
1104
|
+
// actually EXPECTED to be captured — i.e., the trust.bundle evidence has at
|
|
1105
|
+
// least one item with execution.label (concrete proof a command was meant to
|
|
1106
|
+
// be captured). A no-command session (doc review, policy task advanced to
|
|
1107
|
+
// verification without running shell commands) must NOT be blocked here.
|
|
1108
|
+
// Note: `bundle` is already read at the top of captureCrossReference.
|
|
1109
|
+
const captureEvidence = bundle && Array.isArray(bundle.evidence) ? bundle.evidence : [];
|
|
1110
|
+
const hasExpectedCapture = captureEvidence.some(ev => ev && ev.execution && ev.execution.label);
|
|
1111
|
+
if (hasExpectedCapture) {
|
|
1112
|
+
warnings.push(
|
|
1113
|
+
`${base} expected capture log is missing — possible deletion of the capture truth source; ` +
|
|
1114
|
+
`phase:${capturePhase} status:${captureStatus} indicates commands should have run. ` +
|
|
1115
|
+
'Cannot verify command execution deterministically. ' +
|
|
1116
|
+
'Restore from a checkpoint or investigate.'
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// ── Hash-chain integrity check ──────────────────────────────────────────────
|
|
1125
|
+
// Verify command-log.jsonl before trusting its pass/fail signals. If the chain
|
|
1126
|
+
// is broken (altered, removed, or reordered entries), the capture truth source
|
|
1127
|
+
// is compromised: we must NOT trust its pass signals for claimed-pass checks.
|
|
1128
|
+
//
|
|
1129
|
+
// ok → proceed normally (chain is valid, log is trustworthy).
|
|
1130
|
+
// legacy → proceed normally (pre-B2 log, no chain to verify, existing behavior).
|
|
1131
|
+
// forked → benign concurrent-append race (not tampering): emit a loud but
|
|
1132
|
+
// NON-blocking advisory and keep trusting the records. The capture
|
|
1133
|
+
// contradiction teeth still run (the records are genuine, just not
|
|
1134
|
+
// linearly ordered); the operator can re-linearize with the repair
|
|
1135
|
+
// tool. This is what stops honest parallel work from being trapped.
|
|
1136
|
+
// broken → emit a loud warning and treat ALL claimed-pass commands relying on
|
|
1137
|
+
// this log as NOT_VERIFIED/blocking — do not let them sail through.
|
|
1138
|
+
let chainBroken = false;
|
|
1139
|
+
{
|
|
1140
|
+
const chainResult = verifyCommandLogChain(artifactDir);
|
|
1141
|
+
if (chainResult.status === 'broken') {
|
|
1142
|
+
chainBroken = true;
|
|
1143
|
+
const brokenIdx = chainResult.brokenAt !== null ? ` (entry ${chainResult.brokenAt})` : '';
|
|
1144
|
+
warnings.push(
|
|
1145
|
+
`${base} command-log integrity check FAILED — capture truth source appears tampered${brokenIdx}: ` +
|
|
1146
|
+
'claimed-pass checks relying on it are NOT trusted. ' +
|
|
1147
|
+
'This is tamper-EVIDENCE (hash-chain broken); alteration, removal, or reordering detected. ' +
|
|
1148
|
+
'NOT_VERIFIED: cannot confirm or deny claimed passes.'
|
|
1149
|
+
);
|
|
1150
|
+
} else if (chainResult.status === 'forked') {
|
|
1151
|
+
// NOT a hard block: this string must not match HARD_BLOCK/FULL_BLOCK. A
|
|
1152
|
+
// concurrent fork is benign — no content was edited and nothing was
|
|
1153
|
+
// removed — so honest parallel work proceeds. We surface it loudly and
|
|
1154
|
+
// point at the deterministic repair.
|
|
1155
|
+
const forkIdx = chainResult.forkAt !== null ? ` (entry ${chainResult.forkAt})` : '';
|
|
1156
|
+
warnings.push(
|
|
1157
|
+
`${base} command-log shows a concurrent-capture fork${forkIdx} — two PostToolUse captures appended off the same parent ` +
|
|
1158
|
+
'(parallel writers before the writer lock). This is NOT tampering: every record is self-consistent and reachable. ' +
|
|
1159
|
+
'Records remain trusted; re-linearize with: node scripts/repair-command-log.js <artifact-dir>'
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// Build the list of claimed-pass command checks — bundle-first, evidence.json fallback.
|
|
1165
|
+
let claimedPass;
|
|
1166
|
+
if (bundle && Array.isArray(bundle.claims)) {
|
|
1167
|
+
// Phase 4b: source from trust.bundle evidence[] (execution/command items).
|
|
1168
|
+
claimedPass = bundleClaimedPassCommandChecks(bundle, declaredClaimTypes);
|
|
1169
|
+
} else {
|
|
1170
|
+
// Fallback: no bundle — read from evidence.json (existing behavior, no regression).
|
|
1171
|
+
const evidence = readJsonFile(path.join(artifactDir, 'evidence.json'));
|
|
1172
|
+
if (!evidence || !Array.isArray(evidence.checks)) return warnings;
|
|
1173
|
+
claimedPass = evidence.checks.filter(check => {
|
|
1174
|
+
if (!check || typeof check !== 'object') return false;
|
|
1175
|
+
const kind = normalizedStatus(check.kind);
|
|
1176
|
+
const status = normalizedStatus(check.status);
|
|
1177
|
+
return kind === 'command' && (status === 'pass' || status === 'passed') && normalizeCommand(check.command);
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
535
1180
|
|
|
536
1181
|
for (const check of claimedPass.slice(0, 8)) {
|
|
537
1182
|
const cmd = normalizeCommand(check.command);
|
|
1183
|
+
if (!cmd) continue;
|
|
538
1184
|
const id = safeOneLine(check.id || cmd, 80);
|
|
539
1185
|
const logged = log.get(cmd);
|
|
540
1186
|
|
|
541
|
-
if (logged && logged.ran) {
|
|
542
|
-
// (1) Cross-reference the capture log first.
|
|
1187
|
+
if (!chainBroken && logged && logged.ran) {
|
|
1188
|
+
// (1) Cross-reference the capture log first (only when chain is intact).
|
|
1189
|
+
// A broken chain means we cannot trust the log's pass signals — skip this
|
|
1190
|
+
// shortcut and fall through to the backstop/NOT_VERIFIED path below.
|
|
543
1191
|
if (logged.failed) {
|
|
544
1192
|
const exit = Number.isInteger(logged.exitCode) ? ` (exitCode:${logged.exitCode})` : '';
|
|
545
1193
|
warnings.push(`${base} evidence check ${id}: capture log CONTRADICTS claimed pass — command "${safeOneLine(cmd, 120)}" was recorded as FAIL${exit}. This is a caught false-completion.`);
|
|
1194
|
+
} else if (hasLaunderingOperator(cmd)) {
|
|
1195
|
+
// Fix D: exit-code laundering. The captured exit-0 is not trustworthy — the command
|
|
1196
|
+
// baked in '|| true' / '|| :' / '; true' / '; exit 0' / '| true' to mask the real result.
|
|
1197
|
+
warnings.push(`${base} evidence check ${id}: claimed pass relies on an exit-code-laundered command "${safeOneLine(cmd, 120)}" — the exit code is not a trustworthy signal (laundering operators mask the real exit code).`);
|
|
546
1198
|
}
|
|
547
|
-
// log shows it ran and passed → satisfied deterministically
|
|
1199
|
+
// else: log shows it ran and passed with no laundering → satisfied deterministically.
|
|
548
1200
|
continue;
|
|
549
1201
|
}
|
|
550
1202
|
|
|
@@ -554,6 +1206,12 @@ function captureCrossReference(root, artifactDir) {
|
|
|
554
1206
|
continue;
|
|
555
1207
|
}
|
|
556
1208
|
const trusted = resolveTrustedCommand(root, artifactDir, check, acceptance);
|
|
1209
|
+
if (trusted && trusted.malformed) {
|
|
1210
|
+
// WS8 (AC10b): the matching acceptance criterion named a kind:"command" evidence ref
|
|
1211
|
+
// whose text is prose, not a runnable command. Do NOT execute it; classify it.
|
|
1212
|
+
warnings.push(`${base} evidence check ${id}: malformed-evidence — acceptance criterion names a kind:"command" evidence ref whose text is not a runnable shell command ("${safeOneLine(trusted.malformed, 120)}"); it was NOT executed. Use a literal runnable command, or record the check as not_verified/accepted_gap (see context/contracts/planning-contract.md).`);
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
557
1215
|
if (!trusted) {
|
|
558
1216
|
warnings.push(`${base} evidence check ${id}: claimed pass but NOT_VERIFIED — command "${safeOneLine(cmd, 120)}" was never captured and no trusted command (acceptance criterion / declared manifest target) resolves to re-run it. Set FLOW_AGENTS_GOAL_FIT_RECHECK=true to opt into re-running the model's free-form command.`);
|
|
559
1217
|
continue;
|
|
@@ -574,6 +1232,153 @@ function captureCrossReference(root, artifactDir) {
|
|
|
574
1232
|
return warnings;
|
|
575
1233
|
}
|
|
576
1234
|
|
|
1235
|
+
/**
|
|
1236
|
+
* Namespace-agnostic captured-FAIL reconciliation (AC1 — closes the allowlist bypass).
|
|
1237
|
+
*
|
|
1238
|
+
* The existing captureCrossReference only checks claims that pass the namespace
|
|
1239
|
+
* allowlist (workflow.* prefix or declared gateExpects[]). A kit-typed claim
|
|
1240
|
+
* (e.g. builder.verify.tests) whose command-log entry says FAIL can slip through
|
|
1241
|
+
* when no active FlowDefinition declares that claimType.
|
|
1242
|
+
*
|
|
1243
|
+
* This function is namespace-agnostic: it builds the LATEST-capture-per-command map
|
|
1244
|
+
* and for each command whose last capture is FAIL it checks:
|
|
1245
|
+
* (A) Any claim (ANY namespace) asserting pass for that command → false-completion HARD_BLOCK
|
|
1246
|
+
* Fix A: runs on EVERY stop (status-independent). A claim contradicting the capture is
|
|
1247
|
+
* a false-completion regardless of whether state.json shows the task as 'done'.
|
|
1248
|
+
* Fix D: also checks commands with laundering operators whose latest capture is PASS (exit 0);
|
|
1249
|
+
* a claimed-pass for a laundered command is NOT a trustworthy signal.
|
|
1250
|
+
* Fix B: Case B (unaccounted at completion — no-claim-at-all branch) REMOVED.
|
|
1251
|
+
* It over-blocked incidental failures (grep no-match, git diff --exit-code, etc.).
|
|
1252
|
+
* Case A covers the real threat (claimed pass contradicts captured fail).
|
|
1253
|
+
* Fix E: verifyCommandLogChain called; on broken chain reconciliation is skipped (log
|
|
1254
|
+
* integrity is already signalled by captureCrossReference).
|
|
1255
|
+
*
|
|
1256
|
+
* No-over-block guarantees:
|
|
1257
|
+
* - Fail-then-re-run-to-pass: latest is PASS → not in latestFails → no warning.
|
|
1258
|
+
* - Acknowledged failure: claim has failing/disputed status → ackClaims → no warning.
|
|
1259
|
+
* - No-command session: no log → latestLog empty → no warning.
|
|
1260
|
+
* - Incidental fail (grep/diff/find) with no pass-claim → no warning (Case B removed).
|
|
1261
|
+
*/
|
|
1262
|
+
function capturedFailReconciliation(root, artifactDir, taskStatus) {
|
|
1263
|
+
// Fix A: removed the `completing` guard. Run on EVERY stop — status-independent.
|
|
1264
|
+
// A claim contradicting the capture is a false-completion whether or not the agent
|
|
1265
|
+
// has set state.json.status to a terminal value. (taskStatus param kept for compat.)
|
|
1266
|
+
|
|
1267
|
+
const latestLog = readLatestCommandLog(artifactDir);
|
|
1268
|
+
if (latestLog.size === 0) return []; // No captures — nothing to reconcile
|
|
1269
|
+
|
|
1270
|
+
// Fix E: verify chain integrity; skip reconciliation when broken (log untrusted).
|
|
1271
|
+
// The main integrity warning is already emitted by captureCrossReference.
|
|
1272
|
+
const chainResult = verifyCommandLogChain(artifactDir);
|
|
1273
|
+
if (chainResult.status === 'broken') return []; // Can't trust pass/fail signals
|
|
1274
|
+
|
|
1275
|
+
// Collect commands whose LATEST capture is FAIL (Case A).
|
|
1276
|
+
const latestFails = new Map(); // cmd -> {failed:true, exitCode}
|
|
1277
|
+
for (const [cmd, info] of latestLog) {
|
|
1278
|
+
if (info.failed) latestFails.set(cmd, info);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// Fix D: Collect commands whose latest capture is PASS (exit 0) but whose command
|
|
1282
|
+
// string contains an exit-code-neutralizing operator (laundering). The captured
|
|
1283
|
+
// exit-0 is not a trustworthy signal for these — real test failures are hidden.
|
|
1284
|
+
const launderedPass = new Map(); // cmd -> {failed:false, exitCode:0}
|
|
1285
|
+
for (const [cmd, info] of latestLog) {
|
|
1286
|
+
if (!info.failed && hasLaunderingOperator(cmd)) launderedPass.set(cmd, info);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
if (latestFails.size === 0 && launderedPass.size === 0) return []; // Nothing to flag
|
|
1290
|
+
|
|
1291
|
+
const base = relative(root, artifactDir);
|
|
1292
|
+
const warnings = [];
|
|
1293
|
+
|
|
1294
|
+
// Load the trust.bundle for claim/evidence analysis.
|
|
1295
|
+
const bundle = readJsonFile(path.join(artifactDir, 'trust.bundle'));
|
|
1296
|
+
const allClaims = bundle && Array.isArray(bundle.claims) ? bundle.claims : [];
|
|
1297
|
+
const allEvidence = bundle && Array.isArray(bundle.evidence) ? bundle.evidence : [];
|
|
1298
|
+
|
|
1299
|
+
// Build: claimId → claim (for fast evidence→claim lookup)
|
|
1300
|
+
const claimById = new Map();
|
|
1301
|
+
for (const c of allClaims) {
|
|
1302
|
+
if (c && c.id) claimById.set(c.id, c);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// commandsToCheck: FAIL-latest commands + laundered-pass commands
|
|
1306
|
+
const commandsToCheck = new Set([...latestFails.keys(), ...launderedPass.keys()]);
|
|
1307
|
+
|
|
1308
|
+
// For each relevant command, track what claims say about it.
|
|
1309
|
+
// cmdAcc: cmd → {passClaims: [...], ackClaims: []}
|
|
1310
|
+
const cmdAcc = new Map();
|
|
1311
|
+
const initAcc = (cmd) => {
|
|
1312
|
+
if (!cmdAcc.has(cmd)) cmdAcc.set(cmd, { passClaims: [], ackClaims: [] });
|
|
1313
|
+
return cmdAcc.get(cmd);
|
|
1314
|
+
};
|
|
1315
|
+
|
|
1316
|
+
// Path A: evidence items with execution.label link a claim to a specific command.
|
|
1317
|
+
for (const ev of allEvidence) {
|
|
1318
|
+
if (!ev || !ev.execution || !ev.execution.label) continue;
|
|
1319
|
+
const cmd = normalizeCommand(ev.execution.label);
|
|
1320
|
+
if (!cmd || !commandsToCheck.has(cmd)) continue;
|
|
1321
|
+
const claim = claimById.get(ev.claimId);
|
|
1322
|
+
if (!claim) continue;
|
|
1323
|
+
const acc = initAcc(cmd);
|
|
1324
|
+
const s = String(claim.status || '').toLowerCase();
|
|
1325
|
+
const v = normalizedStatus(claim.value || '');
|
|
1326
|
+
if (claimAssertsPass(s, v)) acc.passClaims.push(claim);
|
|
1327
|
+
if (claimAcknowledgesFailure(s, v)) acc.ackClaims.push(claim);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// Path B: claim.fieldOrBehavior resolves directly to the command (field-based resolution).
|
|
1331
|
+
for (const c of allClaims) {
|
|
1332
|
+
if (!c) continue;
|
|
1333
|
+
const cmd = normalizeCommand(c.fieldOrBehavior || '');
|
|
1334
|
+
if (!cmd || !commandsToCheck.has(cmd)) continue;
|
|
1335
|
+
const acc = initAcc(cmd);
|
|
1336
|
+
const s = String(c.status || '').toLowerCase();
|
|
1337
|
+
const v = normalizedStatus(c.value || '');
|
|
1338
|
+
if (claimAssertsPass(s, v)) acc.passClaims.push(c);
|
|
1339
|
+
if (claimAcknowledgesFailure(s, v)) acc.ackClaims.push(c);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// Case A: Evaluate each FAIL-latest command for pass-claims (status-independent).
|
|
1343
|
+
for (const [cmd, failInfo] of latestFails) {
|
|
1344
|
+
const exit = Number.isInteger(failInfo.exitCode) ? failInfo.exitCode : null;
|
|
1345
|
+
const exitStr = exit !== null ? ` (exit ${exit})` : '';
|
|
1346
|
+
const acc = cmdAcc.get(cmd);
|
|
1347
|
+
|
|
1348
|
+
if (acc && acc.passClaims.length > 0) {
|
|
1349
|
+
// Any-namespace claim asserts pass for a command whose latest capture is FAIL.
|
|
1350
|
+
// This is the namespace-agnostic false-completion signal.
|
|
1351
|
+
const claim = acc.passClaims[0];
|
|
1352
|
+
warnings.push(
|
|
1353
|
+
`${base} captured command '${safeOneLine(cmd, 120)}' last ran FAIL${exitStr} ` +
|
|
1354
|
+
`but claim ${safeOneLine(claim.subjectId || claim.id, 80)} (${safeOneLine(claim.claimType, 48)}) ` +
|
|
1355
|
+
`asserts pass — namespace-agnostic caught false-completion.`
|
|
1356
|
+
);
|
|
1357
|
+
}
|
|
1358
|
+
// Fix B: Case B (unaccounted at completion — no-claim-at-all) REMOVED.
|
|
1359
|
+
// It over-blocked incidental failures with no pass-claim. Case A covers the real threat.
|
|
1360
|
+
// Acknowledged failure (ackClaims.length > 0) → OK, no warning.
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// Fix D: Evaluate laundered-pass commands for pass-claims.
|
|
1364
|
+
for (const [cmd] of launderedPass) {
|
|
1365
|
+
const acc = cmdAcc.get(cmd);
|
|
1366
|
+
if (acc && acc.passClaims.length > 0) {
|
|
1367
|
+
const claim = acc.passClaims[0];
|
|
1368
|
+
warnings.push(
|
|
1369
|
+
`${base} captured command '${safeOneLine(cmd, 120)}' claimed pass relies on an ` +
|
|
1370
|
+
`exit-code-laundered command — claim ${safeOneLine(claim.subjectId || claim.id, 80)} ` +
|
|
1371
|
+
`(${safeOneLine(claim.claimType, 48)}) asserts pass but the exit code is not a ` +
|
|
1372
|
+
`trustworthy signal (laundering operators mask the real exit code).`
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
return warnings;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// ─── ADR 0010 Phase 2: enforce on the canonical Hachure trust.bundle ──────────
|
|
1381
|
+
|
|
577
1382
|
// ─── ADR 0010 Phase 2: enforce on the canonical Hachure trust.bundle ──────────
|
|
578
1383
|
// The trust.bundle (emitted by workflow-sidecar via @kontourai/surface) carries
|
|
579
1384
|
// each claim's Surface-derived status — including capture-authoritative results
|
|
@@ -589,7 +1394,13 @@ function captureCrossReference(root, artifactDir) {
|
|
|
589
1394
|
// stored status (e.g. stored "verified" but evidence re-derives to "disputed"),
|
|
590
1395
|
// that mismatch is a strong tamper signal — block with an explicit warning.
|
|
591
1396
|
// Fail-open: if Surface is unavailable, fall back to the stored-status check.
|
|
592
|
-
|
|
1397
|
+
//
|
|
1398
|
+
// ADR 0016 P-c: when activeFlowStep is non-null, claim-selection uses the gate's
|
|
1399
|
+
// declared claimType set (gateExpects[].bundle_claim.claimType). When null, the
|
|
1400
|
+
// existing workflow.* prefix filter runs unchanged (fallback). The re-derivation
|
|
1401
|
+
// loop, tamper-detection, high/critical filter, and block/exit-2 logic are
|
|
1402
|
+
// STRUCTURALLY UNCHANGED — only WHICH claims are selected changes.
|
|
1403
|
+
async function bundleEnforcement(artifactDir, activeFlowStep) {
|
|
593
1404
|
const bundle = readJsonFile(path.join(artifactDir, 'trust.bundle'));
|
|
594
1405
|
if (!bundle || !Array.isArray(bundle.claims)) return [];
|
|
595
1406
|
|
|
@@ -600,11 +1411,72 @@ async function bundleEnforcement(artifactDir) {
|
|
|
600
1411
|
const allEvents = Array.isArray(bundle.events) ? bundle.events : [];
|
|
601
1412
|
const allPolicies = Array.isArray(bundle.policies) ? bundle.policies : [];
|
|
602
1413
|
|
|
1414
|
+
// P-c: claim-selection predicate.
|
|
1415
|
+
// When activeFlowStep is non-null, select claims whose claimType is in the
|
|
1416
|
+
// gate's declared set. When null, fall back to the existing workflow.* prefix
|
|
1417
|
+
// filter so no-FlowDefinition sessions are unaffected.
|
|
1418
|
+
const declaredClaimTypes = declaredClaimTypesFor(activeFlowStep);
|
|
1419
|
+
|
|
1420
|
+
// SECURITY (Layer 2 — gate-bypass-chain fix): use UNION form instead of if/else.
|
|
1421
|
+
// With the old if/else, an empty declaredClaimTypes (Set{}) from a fake flow with
|
|
1422
|
+
// expects:[] caused isSelectedClaim to return false for EVERY claim — all
|
|
1423
|
+
// bundleEnforcement checks were silently bypassed. The union form ensures workflow.*
|
|
1424
|
+
// claims are ALWAYS enforced regardless of whether a FlowDefinition is active or what
|
|
1425
|
+
// its expects[] contains. Declared claimTypes are added on top of the baseline.
|
|
1426
|
+
//
|
|
1427
|
+
// AC3 two-part dependency (regression guard — see test_captured_fail_reconciliation.sh):
|
|
1428
|
+
// Part 1 (this union form): ensures bundleEnforcement always enforces workflow.* claims
|
|
1429
|
+
// regardless of declaredClaimTypes being null or empty Set.
|
|
1430
|
+
// Part 2 (empty-expects guard below): emits gate-misconfiguration HARD_BLOCK when
|
|
1431
|
+
// declaredClaimTypes is a non-null empty Set (active flow with expects:[]).
|
|
1432
|
+
// Both parts are required: Part 1 alone lets the empty-expects bypass slip through
|
|
1433
|
+
// without a loud signal; Part 2 alone without the union would silently pass 0 claims.
|
|
1434
|
+
const isSelectedClaim = (claim) => {
|
|
1435
|
+
const ct = String(claim.claimType || '');
|
|
1436
|
+
// Union: workflow.* is always selected (baseline); declared types extend it.
|
|
1437
|
+
return ct.startsWith('workflow.') || (declaredClaimTypes != null && declaredClaimTypes.has(ct));
|
|
1438
|
+
};
|
|
1439
|
+
|
|
1440
|
+
// Misconfiguration guard: an active FlowDefinition with zero expects[] is suspicious.
|
|
1441
|
+
// Empty expects can indicate a tampered flow definition (the gate-bypass exploit).
|
|
1442
|
+
// Emit a hard warning so operators see it regardless of session phase.
|
|
1443
|
+
if (declaredClaimTypes !== null && declaredClaimTypes.size === 0) {
|
|
1444
|
+
warnings.push(`gate misconfiguration: active FlowDefinition has empty expects[] (possible tampered flow definition or zero-expects bypass attempt); verify the flow definition is authentic before delivery.`);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// AC3 fail-closed (high-assurance path): when @kontourai/surface is unavailable, we
|
|
1448
|
+
// cannot re-derive claim status from evidence/events/policies. For high/critical-impact
|
|
1449
|
+
// selected claims this means we silently trust the stored status field — exactly the
|
|
1450
|
+
// vector re-derive-at-gate (ADR 0010 Phase 2b) was designed to close.
|
|
1451
|
+
// Fail-closed: emit a FULL_BLOCK warning so operators see that re-derivation is absent.
|
|
1452
|
+
// Low-impact-only bundles stay non-blocking (noise reduction for common dev sessions).
|
|
1453
|
+
//
|
|
1454
|
+
// BAR-RAISER, NOT AIRTIGHT: an agent that deletes/corrupts @kontourai/surface to force
|
|
1455
|
+
// surface=null would trigger this path deliberately. The real anchor is external:
|
|
1456
|
+
// signed checkpoints + human review. This raises the cost of the silent-trust vector.
|
|
1457
|
+
if (!surface) {
|
|
1458
|
+
const highImpactCount = bundle.claims.reduce((n, claim) => {
|
|
1459
|
+
if (!claim || typeof claim !== 'object') return n;
|
|
1460
|
+
const impact = String(claim.impactLevel || '').toLowerCase();
|
|
1461
|
+
if (impact !== 'high' && impact !== 'critical') return n;
|
|
1462
|
+
return isSelectedClaim(claim) ? n + 1 : n;
|
|
1463
|
+
}, 0);
|
|
1464
|
+
if (highImpactCount > 0) {
|
|
1465
|
+
warnings.push(
|
|
1466
|
+
`surface unavailable — ${highImpactCount} high/critical-impact claim(s) could not be re-derived at gate; ` +
|
|
1467
|
+
'stored claim status is trusted without independent re-derivation (fail-closed: high-assurance path). ' +
|
|
1468
|
+
'Ensure @kontourai/surface is installed and importable, or escalate for operator review.'
|
|
1469
|
+
);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
|
|
603
1473
|
for (const claim of bundle.claims) {
|
|
604
1474
|
if (!claim || typeof claim !== 'object') continue;
|
|
605
1475
|
const impact = String(claim.impactLevel || '').toLowerCase();
|
|
606
1476
|
const storedStatus = String(claim.status || '').toLowerCase();
|
|
607
1477
|
if (impact !== 'high' && impact !== 'critical') continue;
|
|
1478
|
+
// P-c: claim-selection — only process claims matching the active predicate.
|
|
1479
|
+
if (!isSelectedClaim(claim)) continue;
|
|
608
1480
|
|
|
609
1481
|
// Step 1: Re-derive status via Surface when available.
|
|
610
1482
|
// This closes the gaming vector: editing the stored status field cannot bypass
|
|
@@ -645,18 +1517,18 @@ async function bundleEnforcement(artifactDir) {
|
|
|
645
1517
|
&& (recomputedStatus === 'disputed' || recomputedStatus === 'rejected');
|
|
646
1518
|
|
|
647
1519
|
if (isTampered) {
|
|
648
|
-
warnings.push(`trust.bundle claim tampered: ${safeOneLine(claim.subjectId || claim.id, 80)} (${safeOneLine(claim.claimType, 48)}) — stored status "${storedStatus}" does not match recompute "${recomputedStatus}" (possible tampered bundle); caught false-completion
|
|
1520
|
+
warnings.push(`trust.bundle claim tampered: ${safeOneLine(claim.subjectId || claim.id, 80)} (${safeOneLine(claim.claimType, 48)}) — stored status "${storedStatus}" does not match recompute "${recomputedStatus}" (possible tampered bundle); caught false-completion. Run: npm run workflow:sidecar -- claim ${safeOneLine(claim.subjectId || claim.id, 80)} ${artifactDir}`);
|
|
649
1521
|
} else {
|
|
650
|
-
warnings.push(`trust.bundle claim disputed: ${safeOneLine(claim.subjectId || claim.id, 80)} (${safeOneLine(claim.claimType, 48)}) — Surface recompute shows not verified; caught false-completion
|
|
1522
|
+
warnings.push(`trust.bundle claim disputed: ${safeOneLine(claim.subjectId || claim.id, 80)} (${safeOneLine(claim.claimType, 48)}) — Surface recompute shows not verified; caught false-completion. Run: npm run workflow:sidecar -- claim ${safeOneLine(claim.subjectId || claim.id, 80)} ${artifactDir}`);
|
|
651
1523
|
}
|
|
652
1524
|
}
|
|
653
1525
|
return warnings;
|
|
654
1526
|
}
|
|
655
1527
|
|
|
656
1528
|
/**
|
|
657
|
-
* Scope to the session's current task when .flow-agents/current.json points at
|
|
1529
|
+
* Scope to the session's current task when .kontourai/flow-agents/current.json points at
|
|
658
1530
|
* one (mirroring evidence-capture.js). Returns the slug dir, or null to fall back
|
|
659
|
-
* to scanning all of .flow-agents (newest-mtime).
|
|
1531
|
+
* to scanning all of .kontourai/flow-agents (newest-mtime).
|
|
660
1532
|
*/
|
|
661
1533
|
function preferredArtifactDir(flowAgentsDir) {
|
|
662
1534
|
const current = readJsonFile(path.join(flowAgentsDir, 'current.json'));
|
|
@@ -668,6 +1540,24 @@ function preferredArtifactDir(flowAgentsDir) {
|
|
|
668
1540
|
return dir.startsWith(flowAgentsDir + path.sep) && fs.existsSync(dir) ? dir : null;
|
|
669
1541
|
}
|
|
670
1542
|
|
|
1543
|
+
/** WS8 (AC10a): a session dir is eligible as "active" only with real sidecar presence. */
|
|
1544
|
+
function hasSidecarPresence(artifactDir) {
|
|
1545
|
+
return fs.existsSync(path.join(artifactDir, 'state.json')) || fs.existsSync(path.join(artifactDir, 'trust.bundle'));
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
// WS8 (AC10a): when current.json names a slug whose session directory does NOT exist,
|
|
1549
|
+
// return that slug so analyze() can log the staleness rather than silently falling back to
|
|
1550
|
+
// a global mtime scan that could resurface an abandoned/never-real session as active.
|
|
1551
|
+
function staleCurrentSlug(flowAgentsDir) {
|
|
1552
|
+
const current = readJsonFile(path.join(flowAgentsDir, 'current.json'));
|
|
1553
|
+
if (!current) return null;
|
|
1554
|
+
const slug = current.artifact_dir || current.active_slug;
|
|
1555
|
+
if (typeof slug !== 'string' || !slug.trim()) return null;
|
|
1556
|
+
const safe = slug.replace(/\.\.+/g, '').replace(/^[/\\]+/, '');
|
|
1557
|
+
const dir = path.join(flowAgentsDir, safe);
|
|
1558
|
+
return (dir.startsWith(flowAgentsDir + path.sep) && !fs.existsSync(dir)) ? slug : null;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
671
1561
|
/**
|
|
672
1562
|
* A task is pre-execution (work not yet started) when its state.json status/phase
|
|
673
1563
|
* is still in the idea→planning band, or (no state.json) its markdown status is.
|
|
@@ -689,10 +1579,15 @@ function isPreExecution(artifactDir, markdownStatus) {
|
|
|
689
1579
|
// If a trust.bundle exists, bundleEnforcement handles it. If state.json exists,
|
|
690
1580
|
// sidecarGuidance handles it. The gap: a session with only a markdown artifact.
|
|
691
1581
|
//
|
|
692
|
-
// Adjustment A (
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
|
|
1582
|
+
// ADR 0010 Phase 4b: Adjustment A (Final Acceptance hygiene):
|
|
1583
|
+
// When the task is delivered but acceptance criteria are still pending, emit the
|
|
1584
|
+
// Final Acceptance reminder. Read from trust.bundle claims when present; fall back
|
|
1585
|
+
// to acceptance.json for bundle-less sessions.
|
|
1586
|
+
//
|
|
1587
|
+
// ADR 0016 P-c: pass activeFlowStep so bundlePendingCriteriaCount includes declared types.
|
|
1588
|
+
function missingBundleOrStateSignal(artifactDir, activeFlowStep) {
|
|
1589
|
+
// Build the declared claimType set from the FlowDefinition gate expects[] (P-c).
|
|
1590
|
+
const declaredClaimTypes = declaredClaimTypesFor(activeFlowStep);
|
|
696
1591
|
const warnings = [];
|
|
697
1592
|
const hasBundle = fs.existsSync(path.join(artifactDir, 'trust.bundle'));
|
|
698
1593
|
const state = readJsonFile(path.join(artifactDir, 'state.json'));
|
|
@@ -705,39 +1600,79 @@ function missingBundleOrStateSignal(artifactDir) {
|
|
|
705
1600
|
return warnings;
|
|
706
1601
|
}
|
|
707
1602
|
|
|
708
|
-
// Adjustment A:
|
|
709
|
-
// When the task is delivered but acceptance
|
|
710
|
-
//
|
|
711
|
-
const
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
1603
|
+
// Adjustment A: Final Acceptance hygiene.
|
|
1604
|
+
// When the task is delivered but acceptance criteria are still pending, emit the
|
|
1605
|
+
// Final Acceptance reminder. Bundle-first; fall back to acceptance.json.
|
|
1606
|
+
const bundle = readJsonFile(path.join(artifactDir, 'trust.bundle'));
|
|
1607
|
+
const bundleClaims = bundle && Array.isArray(bundle.claims) ? bundle.claims : null;
|
|
1608
|
+
|
|
1609
|
+
if (bundleClaims) {
|
|
1610
|
+
// Phase 4b: read pending criteria count from trust.bundle claims.
|
|
1611
|
+
// P-c: pass declaredClaimTypes so declared-type acceptance claims are included.
|
|
1612
|
+
const pendingCount = bundlePendingCriteriaCount(bundleClaims, declaredClaimTypes);
|
|
1613
|
+
if (pendingCount !== null && pendingCount > 0) {
|
|
718
1614
|
const base = path.basename(artifactDir);
|
|
719
|
-
warnings.push(`${base} Final Acceptance: ${
|
|
1615
|
+
warnings.push(`${base} Final Acceptance: ${pendingCount} acceptance criterion/criteria still pending; complete CI/merge/docs before final delivery.`);
|
|
1616
|
+
}
|
|
1617
|
+
} else {
|
|
1618
|
+
// Fallback: no bundle — read from acceptance.json (existing behavior, no regression).
|
|
1619
|
+
const acceptance = readJsonFile(path.join(artifactDir, 'acceptance.json'));
|
|
1620
|
+
if (acceptance && Array.isArray(acceptance.criteria)) {
|
|
1621
|
+
const pendingCriteria = acceptance.criteria.filter(c => {
|
|
1622
|
+
const s = normalizedStatus(c && c.status);
|
|
1623
|
+
return s === 'pending' || s === 'not_started' || s === '' || s === 'unknown';
|
|
1624
|
+
});
|
|
1625
|
+
if (pendingCriteria.length > 0) {
|
|
1626
|
+
const base = path.basename(artifactDir);
|
|
1627
|
+
warnings.push(`${base} Final Acceptance: ${pendingCriteria.length} acceptance criterion/criteria still pending; complete CI/merge/docs before final delivery.`);
|
|
1628
|
+
}
|
|
720
1629
|
}
|
|
721
1630
|
}
|
|
722
1631
|
|
|
723
1632
|
return warnings;
|
|
724
1633
|
}
|
|
725
1634
|
|
|
1635
|
+
// ─── Gate severity classification regexes (module scope — used by analyze() and run()) ─
|
|
1636
|
+
//
|
|
1637
|
+
// HARD_BLOCK: always blocks, even for pre-execution and terminal tasks.
|
|
1638
|
+
// Fires on genuine false-completion signals (a claimed pass the capture log or
|
|
1639
|
+
// evidence.json contradicts), integrity failures, and gate misconfiguration.
|
|
1640
|
+
//
|
|
1641
|
+
// FULL_BLOCK: fires for execution-onward tasks (post-planning, non-terminal).
|
|
1642
|
+
// Includes all HARD_BLOCK patterns plus completeness/hygiene and not-done state.
|
|
1643
|
+
//
|
|
1644
|
+
// Both are used in analyze() for blocking decisions AND in run() for the AC2
|
|
1645
|
+
// MAX_BLOCKS hard-block guard (preventing auto-release of hard blocks).
|
|
1646
|
+
const HARD_BLOCK = /contradicts evidence\.json|caught false-completion|evidence verdict:|evidence check .+ status:|critique status|critique open|required sidecar is missing|command-log integrity check FAILED|gate misconfiguration:|exit-code-laundered/;
|
|
1647
|
+
// FULL_BLOCK adds: workflow-state hygiene, surface-unavailable fail-closed, missing log.
|
|
1648
|
+
const FULL_BLOCK = /status:|Definition Of Done|Goal Fit|sidecar validation:|contradicts evidence\.json|workflow state|evidence verdict|evidence check|NOT_VERIFIED gap|critique status|critique open|next action|caught false-completion|NOT_VERIFIED —|command-log integrity check FAILED|gate misconfiguration:|surface unavailable —|expected capture log is missing|exit-code-laundered|malformed-evidence/;
|
|
1649
|
+
|
|
726
1650
|
async function analyze(root, now = Date.now()) {
|
|
727
|
-
const
|
|
1651
|
+
const flowAgentsDirs = flowAgentsArtifactRootsForRead(root);
|
|
728
1652
|
// Scope to the session's current task when current.json names one, so an
|
|
729
1653
|
// unrelated active workflow elsewhere in the repo does not gate this stop.
|
|
730
|
-
const scoped = preferredArtifactDir(
|
|
731
|
-
|
|
732
|
-
|
|
1654
|
+
const scoped = flowAgentsDirs.map(preferredArtifactDir).find(Boolean);
|
|
1655
|
+
// WS8 (AC10a): if current.json points at a nonexistent slug, LOG the staleness and, in
|
|
1656
|
+
// the global fallback, require sidecar presence so a stale pointer cannot resurface an
|
|
1657
|
+
// abandoned/never-real markdown-only directory as "the active session".
|
|
1658
|
+
const staleSlug = scoped ? null : flowAgentsDirs.map(staleCurrentSlug).find(Boolean);
|
|
1659
|
+
if (staleSlug) {
|
|
1660
|
+
process.stderr.write(`[Hook] Goal Fit: current.json names slug "${safeOneLine(staleSlug, 80)}" but no such session directory exists — ignoring the stale pointer instead of resurfacing an abandoned session via a global mtime scan.\n`);
|
|
1661
|
+
}
|
|
1662
|
+
const searchDirs = scoped ? [scoped] : flowAgentsDirs;
|
|
1663
|
+
let artifacts = searchDirs
|
|
733
1664
|
.flatMap(dir => walkMarkdown(dir))
|
|
734
1665
|
.map(readArtifact)
|
|
735
1666
|
.filter(isWorkflowArtifact)
|
|
736
1667
|
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
1668
|
+
if (staleSlug) {
|
|
1669
|
+
artifacts = artifacts.filter(a => a && hasSidecarPresence(path.dirname(a.file)));
|
|
1670
|
+
}
|
|
737
1671
|
|
|
738
1672
|
if (artifacts.length === 0) return { warnings: [], blocking: false };
|
|
739
1673
|
|
|
740
1674
|
const latest = artifacts[0];
|
|
1675
|
+
const latestArtifactDir = path.dirname(latest.file);
|
|
741
1676
|
const warnings = [];
|
|
742
1677
|
const relPath = relative(root, latest.file);
|
|
743
1678
|
const status = latest.status || 'unknown';
|
|
@@ -751,31 +1686,64 @@ async function analyze(root, now = Date.now()) {
|
|
|
751
1686
|
// Verdict is now bundle-driven via bundleEnforcement + sidecarGuidance.
|
|
752
1687
|
// Sessions with neither trust.bundle nor state.json are caught by missingBundleOrStateSignal.
|
|
753
1688
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
warnings.push(...
|
|
1689
|
+
// ADR 0016 P-c: load the active FlowDefinition gate (fail-open: null when absent).
|
|
1690
|
+
// Null → existing workflow.* fallback path unchanged. Non-null → expects[]-driven claim selection.
|
|
1691
|
+
const activeFlowStep = loadActiveFlowStep(path.dirname(latestArtifactDir));
|
|
1692
|
+
|
|
1693
|
+
warnings.push(...sidecarValidation(root, latestArtifactDir));
|
|
1694
|
+
warnings.push(...sidecarGuidance(root, latestArtifactDir, activeFlowStep));
|
|
1695
|
+
const captureWarnings = captureCrossReference(root, latestArtifactDir, activeFlowStep);
|
|
1696
|
+
warnings.push(...captureWarnings);
|
|
1697
|
+
// Dedup: bundleEnforcement and captureCrossReference can both fire "caught false-completion"
|
|
1698
|
+
// for the same disputed claim. Suppress the bundleEnforcement warning ONLY when
|
|
1699
|
+
// captureCrossReference already produced a hard-block warning ("caught false-completion")
|
|
1700
|
+
// for the same check. NOT_VERIFIED / backstop-skip capture warnings must NOT suppress
|
|
1701
|
+
// the bundle tamper/disputed signal — that mismatch is a re-derive block independent of
|
|
1702
|
+
// whether the command was ever captured (anti-gaming guarantee, ADR 0010 Phase 2b).
|
|
1703
|
+
const captureHardBlockIds = new Set();
|
|
1704
|
+
for (const w of captureWarnings) {
|
|
1705
|
+
if (!/caught false-completion/.test(w)) continue; // only hard blocks suppress bundle warning
|
|
1706
|
+
const m = /evidence check ([^\s:]+):/.exec(w);
|
|
1707
|
+
if (m) captureHardBlockIds.add(m[1]);
|
|
1708
|
+
}
|
|
1709
|
+
const bundleWarnings = (await bundleEnforcement(latestArtifactDir, activeFlowStep)).filter(w => {
|
|
1710
|
+
if (!captureHardBlockIds.size) return true;
|
|
1711
|
+
// bundleEnforcement warns: "trust.bundle claim disputed: <subjectId> ..."
|
|
1712
|
+
const m = /trust\.bundle claim (?:disputed|tampered): ([^\s(]+)/.exec(w);
|
|
1713
|
+
if (!m) return true;
|
|
1714
|
+
const subjectId = m[1];
|
|
1715
|
+
// subjectId = "slug/checkId" — extract the checkId (last segment)
|
|
1716
|
+
const checkId = subjectId.includes('/') ? subjectId.slice(subjectId.indexOf('/') + 1) : subjectId;
|
|
1717
|
+
// If captureCrossReference already hard-blocked this check, suppress the bundle warning.
|
|
1718
|
+
return !captureHardBlockIds.has(checkId);
|
|
1719
|
+
});
|
|
1720
|
+
warnings.push(...bundleWarnings);
|
|
1721
|
+
warnings.push(...missingBundleOrStateSignal(latestArtifactDir, activeFlowStep));
|
|
759
1722
|
|
|
760
1723
|
// A pre-execution task (not started) OR a terminal task (which is itself a
|
|
761
1724
|
// completion *claim*) must not block on mere incompleteness — but a FALSE claim
|
|
762
1725
|
// (capture/evidence contradiction) still blocks at any phase. This is the whole
|
|
763
1726
|
// point of the capture cross-reference: catch a task that falsely claims done.
|
|
764
|
-
const gateState = readJsonFile(path.join(
|
|
1727
|
+
const gateState = readJsonFile(path.join(latestArtifactDir, 'state.json'));
|
|
765
1728
|
const taskStatus = gateState ? normalizedStatus(gateState.status) : normalizedStatus(status);
|
|
766
|
-
const preExecution = isPreExecution(
|
|
1729
|
+
const preExecution = isPreExecution(latestArtifactDir, status);
|
|
767
1730
|
const terminal = TERMINAL_STATUSES.has(taskStatus);
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
//
|
|
771
|
-
|
|
1731
|
+
|
|
1732
|
+
// Namespace-agnostic captured-FAIL reconciliation (AC1 — closes the allowlist bypass).
|
|
1733
|
+
// Fix A: status-independent — runs on EVERY stop. A claim contradicting the capture
|
|
1734
|
+
// is a false-completion whether or not the agent says the task is 'done'.
|
|
1735
|
+
warnings.push(...capturedFailReconciliation(root, latestArtifactDir, taskStatus));
|
|
1736
|
+
|
|
1737
|
+
// Use module-scope HARD_BLOCK / FULL_BLOCK (defined above analyze()).
|
|
1738
|
+
// pre-execution/terminal tasks: only HARD_BLOCK signals cause a block.
|
|
1739
|
+
// execution-onward tasks: FULL_BLOCK signals cause a block.
|
|
772
1740
|
const blockRe = (preExecution || terminal) ? HARD_BLOCK : FULL_BLOCK;
|
|
773
1741
|
const blocking = warnings.some(w => {
|
|
774
1742
|
// Capture cross-reference warn-mode notes never block (operator opted out).
|
|
775
1743
|
if (/\[backstop in warn mode — not blocking\]/.test(w)) return false;
|
|
776
1744
|
return blockRe.test(w);
|
|
777
1745
|
});
|
|
778
|
-
return { warnings, blocking, preExecution };
|
|
1746
|
+
return { warnings, blocking, preExecution, gatePrefix: gateLabel(activeFlowStep) };
|
|
779
1747
|
}
|
|
780
1748
|
|
|
781
1749
|
/**
|
|
@@ -802,7 +1770,8 @@ function resolveMaxBlocks() {
|
|
|
802
1770
|
}
|
|
803
1771
|
|
|
804
1772
|
function blockStreakFile(root) {
|
|
805
|
-
|
|
1773
|
+
const artifactRoot = flowAgentsArtifactRoot(root);
|
|
1774
|
+
return path.join(artifactRoot, '.goal-fit-block-streak.json');
|
|
806
1775
|
}
|
|
807
1776
|
|
|
808
1777
|
function reasonsHash(warnings) {
|
|
@@ -827,6 +1796,52 @@ function bumpBlockStreak(root, hash) {
|
|
|
827
1796
|
return count;
|
|
828
1797
|
}
|
|
829
1798
|
|
|
1799
|
+
/**
|
|
1800
|
+
* Message-assembly only: per-gap remediation guidance (linter-style " → " line)
|
|
1801
|
+
* appended under a matching warning. Fix-first / accept-second ordering per
|
|
1802
|
+
* class; the "accept" path always names a reason requirement and is never the
|
|
1803
|
+
* only or primary option. Does not affect which warnings are emitted, HARD_BLOCK/
|
|
1804
|
+
* FULL_BLOCK classification, block counting, or exit codes — text only.
|
|
1805
|
+
* Returns null when a warning does not match one of the four known classes.
|
|
1806
|
+
*/
|
|
1807
|
+
function remediationFor(warning) {
|
|
1808
|
+
const w = String(warning || '');
|
|
1809
|
+
|
|
1810
|
+
// (1) claimed pass but the trusted re-run failed — reuse the exact command
|
|
1811
|
+
// already named in the warning text; never invent a different one.
|
|
1812
|
+
// NOTE: anchor on the quoted command substring + fixed tail, NOT on
|
|
1813
|
+
// `trusted backstop (<source>)` paren-matching. trusted.source can itself
|
|
1814
|
+
// contain parens (e.g. "model-command (FLOW_AGENTS_GOAL_FIT_RECHECK)"), and a
|
|
1815
|
+
// non-nesting `[^)]*` silently fails to match that nested-paren source,
|
|
1816
|
+
// dropping the guidance line for the RECHECK backstop path.
|
|
1817
|
+
const rerun = /re-run of "([\s\S]+?)" FAILED with exit \d+, contradicting the claimed pass/.exec(w);
|
|
1818
|
+
if (rerun) {
|
|
1819
|
+
return ` → run: ${rerun[1]} — fix the failures, then re-record the check`;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
// (2) claimed pass never captured (no trusted command resolved, backstop
|
|
1823
|
+
// disabled via FLOW_AGENTS_GOAL_FIT_BACKSTOP=skip, or the backstop could not launch).
|
|
1824
|
+
// Same nested-paren fix as (1): match on the invariant "could not run" tail
|
|
1825
|
+
// rather than paren-counting the `(<source>)` segment.
|
|
1826
|
+
if (/claimed pass but NOT_VERIFIED — command ".+?" was never captured/.test(w)
|
|
1827
|
+
|| /claimed pass but NOT_VERIFIED — trusted backstop .*could not run/.test(w)) {
|
|
1828
|
+
return " → run the command in this session (evidence capture is active), then re-record the check with the exact command string via: npm run workflow:sidecar -- record-evidence <artifact-dir> --verdict <v> --check-json '...'";
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
// (3) critique verdict fail / open findings.
|
|
1832
|
+
if (/critique status:/.test(w) || /critique open /.test(w)) {
|
|
1833
|
+
return ' → resolve the findings and record a passing critique (record-critique), or mark each finding accepted with a reason';
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
// (4) evidence verdict fail / missing acceptance evidence.
|
|
1837
|
+
if (/evidence verdict:/.test(w) || /evidence check .+ status:/.test(w)
|
|
1838
|
+
|| /evidence NOT_VERIFIED gap:/.test(w) || /Final Acceptance:/.test(w)) {
|
|
1839
|
+
return ' → fix the failing check, or record it as an accepted gap with justification in the session artifact';
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
return null;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
830
1845
|
async function run(rawInput) {
|
|
831
1846
|
const input = parseJson(rawInput);
|
|
832
1847
|
const root = findRepoRoot(input.cwd || process.cwd());
|
|
@@ -838,9 +1853,15 @@ async function run(rawInput) {
|
|
|
838
1853
|
return rawInput;
|
|
839
1854
|
}
|
|
840
1855
|
|
|
1856
|
+
const gatePrefix = result.gatePrefix || '[stop-gate]';
|
|
841
1857
|
const message = [
|
|
842
|
-
|
|
843
|
-
...result.warnings.
|
|
1858
|
+
`${gatePrefix} Goal Fit warning:`,
|
|
1859
|
+
...result.warnings.flatMap(w => {
|
|
1860
|
+
const lines = [` - ${w}`];
|
|
1861
|
+
const guidance = remediationFor(w);
|
|
1862
|
+
if (guidance) lines.push(guidance);
|
|
1863
|
+
return lines;
|
|
1864
|
+
}),
|
|
844
1865
|
].join('\n');
|
|
845
1866
|
|
|
846
1867
|
if (mode !== 'block' || !result.blocking) {
|
|
@@ -851,16 +1872,36 @@ async function run(rawInput) {
|
|
|
851
1872
|
const maxBlocks = resolveMaxBlocks();
|
|
852
1873
|
const count = bumpBlockStreak(root, reasonsHash(result.warnings));
|
|
853
1874
|
if (count >= maxBlocks) {
|
|
1875
|
+
// AC2: never auto-release a HARD block (caught false-completion, capture contradiction,
|
|
1876
|
+
// tamper signal, gate misconfiguration, integrity failure). An agent burning through
|
|
1877
|
+
// N identical hard blocks to escape via the streak counter must not work.
|
|
1878
|
+
//
|
|
1879
|
+
// BAR-RAISER, NOT AIRTIGHT: an agent with shell access can still reset the streak
|
|
1880
|
+
// with runtime-constructed paths or by modifying the warning
|
|
1881
|
+
// text so the hash changes. The real anchor is external (signed checkpoints + human
|
|
1882
|
+
// review). This raises the cost of the burn-through-the-counter escape vector.
|
|
1883
|
+
const isHardBlock = result.warnings.some(w => {
|
|
1884
|
+
if (/\[backstop in warn mode — not blocking\]/.test(w)) return false;
|
|
1885
|
+
return HARD_BLOCK.test(w);
|
|
1886
|
+
});
|
|
1887
|
+
if (isHardBlock) {
|
|
1888
|
+
// Do NOT clear the streak — keep accumulating so the same hard block stays visible.
|
|
1889
|
+
return {
|
|
1890
|
+
stdout: rawInput,
|
|
1891
|
+
stderr: `${message}\n${gatePrefix} max-blocks reached but the block is a caught false-completion / integrity failure — not auto-releasing; requires a real fix or operator override.`,
|
|
1892
|
+
exitCode: 2,
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
854
1895
|
clearBlockStreak(root);
|
|
855
1896
|
return {
|
|
856
1897
|
stdout: rawInput,
|
|
857
|
-
stderr: `${message}\n
|
|
1898
|
+
stderr: `${message}\n${gatePrefix} released — the same gap(s) blocked ${count}x without progress; needs your decision (listed above).`,
|
|
858
1899
|
exitCode: 0,
|
|
859
1900
|
};
|
|
860
1901
|
}
|
|
861
1902
|
return {
|
|
862
1903
|
stdout: rawInput,
|
|
863
|
-
stderr: `${message}\n
|
|
1904
|
+
stderr: `${message}\n${gatePrefix} Stop blocked — ${result.warnings.length} evidence gap(s) (block ${count}; after ${maxBlocks} identical blocks I stop blocking and hand this to you)`,
|
|
864
1905
|
exitCode: 2,
|
|
865
1906
|
};
|
|
866
1907
|
}
|
|
@@ -896,4 +1937,4 @@ if (require.main === module) {
|
|
|
896
1937
|
});
|
|
897
1938
|
}
|
|
898
1939
|
|
|
899
|
-
module.exports = { analyze, run, resolveGoalFitMode, uncheckedInSection, findRepoRoot, sidecarGuidance, safeOneLine, captureCrossReference, bundleEnforcement, readCommandLog, resolveTrustedCommand, declaredManifestTarget };
|
|
1940
|
+
module.exports = { analyze, run, resolveGoalFitMode, uncheckedInSection, findRepoRoot, sidecarGuidance, safeOneLine, captureCrossReference, bundleEnforcement, loadActiveFlowStep, readCommandLog, resolveTrustedCommand, declaredManifestTarget, verifyCommandLogChain, CHAIN_GENESIS_VERIFY, hasLaunderingOperator };
|