@kontourai/flow-agents 1.4.0 → 2.0.1
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 +29 -0
- package/.github/actions/trust-verify/action.yml +145 -0
- package/.github/workflows/ci.yml +11 -4
- package/.github/workflows/kit-gates-demo.yml +2 -2
- package/.github/workflows/publish-npm.yml +10 -2
- package/.github/workflows/release-please.yml +1 -1
- package/.github/workflows/runtime-compat.yml +1 -1
- package/.github/workflows/trust-reconcile.yml +113 -0
- package/AGENTS.md +13 -0
- package/CHANGELOG.md +103 -0
- package/CONTRIBUTING.md +4 -4
- package/README.md +1 -0
- package/agents/tool-planner.json +1 -1
- package/build/src/cli/init.js +242 -20
- package/build/src/cli/validate-workflow-artifacts.js +19 -2
- package/build/src/cli/verify.d.ts +1 -0
- package/build/src/cli/verify.js +90 -0
- package/build/src/cli/workflow-sidecar.d.ts +316 -8
- package/build/src/cli/workflow-sidecar.js +1996 -91
- package/build/src/cli.js +2 -3
- package/build/src/lib/flow-resolver.d.ts +111 -0
- package/build/src/lib/flow-resolver.js +308 -0
- package/build/src/tools/build-universal-bundles.js +34 -22
- package/build/src/tools/generate-context-map.js +3 -16
- package/build/src/tools/validate-source-tree.d.ts +1 -1
- package/build/src/tools/validate-source-tree.js +42 -162
- package/context/contracts/artifact-contract.md +10 -0
- package/context/contracts/delivery-contract.md +1 -0
- package/context/contracts/review-contract.md +1 -0
- package/context/contracts/verification-contract.md +2 -0
- package/context/gate-awareness.md +39 -0
- package/context/scripts/hooks/stop-goal-fit.js +632 -70
- package/docs/adr/0001-flow-agents-consumes-flow.md +1 -1
- package/docs/adr/0002-flow-kits-as-extension-unit.md +1 -1
- package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
- package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
- package/docs/adr/0007-skill-audit.md +1 -1
- package/docs/adr/0009-canonical-hook-core-kit-boundary.md +95 -0
- package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +139 -0
- package/docs/adr/0011-mcp-posture.md +100 -0
- package/docs/adr/0012-agent-coordination-as-liveness-claims.md +119 -0
- package/docs/adr/0013-context-lifecycle.md +151 -0
- package/docs/adr/0014-core-vs-domain-kit-boundary.md +143 -0
- package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +120 -0
- package/docs/adr/0016-three-hard-boundary-model.md +71 -0
- package/docs/adr/0017-anti-gaming-trust-security-model.md +155 -0
- package/docs/agent-system-guidebook.md +5 -12
- package/docs/context-map.md +4 -10
- package/docs/index.md +3 -2
- package/docs/integrations/framework-adapter.md +19 -6
- package/docs/integrations/index.md +2 -2
- package/docs/north-star.md +4 -4
- package/docs/operating-layers.md +3 -3
- package/docs/plans/adr-0010-phase2-gate-recompute.md +55 -0
- package/docs/repository-structure.md +2 -2
- package/docs/skills-map.md +1 -0
- package/docs/spec/runtime-hook-surface.md +62 -9
- package/docs/standards-register.md +3 -3
- package/docs/survey-utterance-check.md +1 -1
- package/docs/trust-anchor-adoption.md +197 -0
- package/docs/verifiable-trust.md +95 -0
- package/docs/veritas-integration.md +2 -2
- package/docs/workflow-usage-guide.md +69 -0
- package/evals/acceptance/DEMO-false-completion.md +144 -0
- package/evals/acceptance/demo-cast.sh +92 -0
- package/evals/acceptance/demo-false-completion.sh +72 -0
- package/evals/acceptance/demo-real-evidence.sh +104 -0
- package/evals/acceptance/demo.tape +29 -0
- package/evals/acceptance/prove-capture-teeth-declared.sh +335 -0
- package/evals/acceptance/prove-capture-teeth.sh +114 -0
- package/evals/acceptance/prove-teeth.sh +105 -0
- package/evals/ci/antigaming-suite.sh +55 -0
- package/evals/ci/run-baseline.sh +2 -0
- package/evals/fixtures/flow-kit-repository/invalid-missing-extension-asset/flows/review.flow.json +26 -0
- package/evals/fixtures/flow-kit-repository/invalid-missing-extension-asset/kit.json +20 -0
- package/evals/fixtures/flow-kit-repository/valid-unknown-extension/flows/review.flow.json +26 -0
- package/evals/fixtures/flow-kit-repository/valid-unknown-extension/kit.json +18 -0
- package/evals/integration/test_builder_step_producers.sh +379 -0
- package/evals/integration/test_bundle_install.sh +35 -71
- package/evals/integration/test_bundle_lifecycle.sh +39 -2
- package/evals/integration/test_captured_fail_reconciliation.sh +820 -0
- package/evals/integration/test_checkpoint_signing.sh +489 -0
- package/evals/integration/test_claim_lookup.sh +352 -0
- package/evals/integration/test_command_log_fork_classification.sh +134 -0
- package/evals/integration/test_command_log_integrity.sh +275 -0
- package/evals/integration/test_context_map.sh +0 -2
- package/evals/integration/test_dual_emit_flow_step.sh +278 -0
- package/evals/integration/test_enforcer_expects_driven.sh +281 -0
- package/evals/integration/test_evidence_capture_hook.sh +185 -0
- package/evals/integration/test_flow_kit_repository.sh +2 -0
- package/evals/integration/test_flowdef_session_activation.sh +273 -0
- package/evals/integration/test_flowdef_session_history_preservation.sh +250 -0
- package/evals/integration/test_gate_bypass_chain.sh +448 -0
- package/evals/integration/test_gate_lockdown.sh +1137 -0
- package/evals/integration/test_gate_review_inquiry_records.sh +399 -0
- package/evals/integration/test_goal_fit_escape_hatch.sh +73 -0
- package/evals/integration/test_goal_fit_hook.sh +69 -4
- package/evals/integration/test_goal_fit_rederive.sh +263 -0
- package/evals/integration/test_install_merge.sh +1176 -0
- package/evals/integration/test_kit_identity_trust.sh +393 -0
- package/evals/integration/test_mint_attestation.sh +373 -0
- package/evals/integration/test_phase_map_and_gate_claim.sh +365 -0
- package/evals/integration/test_publish_delivery.sh +269 -0
- package/evals/integration/test_reconcile_soundness.sh +528 -0
- package/evals/integration/test_resolvefirststep_security.sh +208 -0
- package/evals/integration/test_session_resume_roundtrip.sh +286 -0
- package/evals/integration/test_trust_checkpoint.sh +325 -0
- package/evals/integration/test_trust_reconcile.sh +293 -0
- package/evals/integration/test_verify_cli.sh +208 -0
- package/evals/integration/test_workflow_sidecar_writer.sh +549 -34
- package/evals/lib/node.sh +0 -6
- package/evals/run.sh +47 -0
- package/evals/static/test_workflow_skills.sh +6 -13
- package/install.sh +0 -7
- package/integrations/strands-ts/README.md +25 -15
- package/integrations/veritas/flow-agents.adapter.json +1 -2
- package/kits/builder/flows/build.flow.json +59 -12
- package/kits/builder/kit.json +85 -15
- package/kits/builder/skills/continue-work/SKILL.md +116 -0
- package/kits/builder/skills/deliver/SKILL.md +36 -6
- package/kits/builder/skills/design-probe/SKILL.md +28 -0
- package/kits/builder/skills/execute-plan/SKILL.md +9 -1
- package/kits/builder/skills/gate-review/SKILL.md +234 -0
- package/kits/builder/skills/learning-review/SKILL.md +30 -0
- package/kits/builder/skills/pickup-probe/SKILL.md +29 -0
- package/kits/builder/skills/plan-work/SKILL.md +13 -1
- package/kits/builder/skills/pull-work/SKILL.md +19 -0
- package/kits/knowledge/adapters/default-store/index.js +38 -0
- package/kits/knowledge/adapters/flow-runner/index.js +1620 -0
- package/kits/knowledge/adapters/obsidian-store/index.js +36 -6
- package/kits/knowledge/docs/store-contract.md +314 -0
- package/kits/knowledge/evals/audit-freshness/suite.test.js +368 -0
- package/kits/knowledge/evals/canonicalize-category/suite.test.js +383 -0
- package/kits/knowledge/evals/contract-suite/suite.test.js +111 -0
- package/kits/knowledge/evals/detect-contradictions/suite.test.js +324 -0
- package/kits/knowledge/evals/entities/suite.test.js +40 -0
- package/kits/knowledge/evals/glossary-sync/suite.test.js +416 -0
- package/kits/knowledge/evals/hygiene-review/suite.test.js +396 -0
- package/kits/knowledge/evals/retirement/suite.test.js +145 -0
- package/kits/knowledge/flows/audit-freshness.flow.json +44 -0
- package/kits/knowledge/flows/canonicalize-category.flow.json +44 -0
- package/kits/knowledge/flows/detect-contradictions.flow.json +44 -0
- package/kits/knowledge/flows/glossary-sync.flow.json +61 -0
- package/kits/knowledge/flows/hygiene-review.flow.json +43 -0
- package/kits/knowledge/kit.json +51 -1
- package/package.json +6 -6
- package/packaging/conformance/README.md +10 -2
- package/packaging/conformance/fixtures/evidence-capture--allow-records-command.json +29 -0
- package/packaging/conformance/fixtures/stop-goal-fit--block-bundle-disputed-claim.json +29 -0
- package/packaging/conformance/fixtures/stop-goal-fit--block-capture-contradicts-claimed-pass.json +30 -0
- package/packaging/conformance/fixtures/stop-goal-fit--block-mode.json +23 -0
- package/packaging/conformance/fixtures/stop-goal-fit--off-mode.json +24 -0
- package/packaging/conformance/fixtures/stop-goal-fit--warn-active-delivery.json +5 -2
- package/packaging/conformance/fixtures/stop-goal-fit--warn-no-bundle.json +23 -0
- package/packaging/conformance/fixtures/workflow-steering--reground-active-prompt.json +30 -0
- package/packaging/conformance/fixtures/workflow-steering--reground-session-start.json +30 -0
- package/packaging/conformance/run-conformance.js +1 -1
- package/scripts/README.md +2 -1
- package/scripts/build-universal-bundles.js +0 -1
- package/scripts/ci/mint-attestation.js +221 -0
- package/scripts/ci/trust-reconcile.js +545 -0
- package/scripts/hooks/config-protection.js +423 -1
- package/scripts/hooks/evidence-capture.js +348 -0
- package/scripts/hooks/lib/liveness-read.js +113 -0
- package/scripts/hooks/run-hook.js +6 -1
- package/scripts/hooks/stop-goal-fit.js +1524 -79
- package/scripts/hooks/workflow-steering.js +135 -5
- package/scripts/install-codex-home.sh +39 -0
- package/scripts/install-merge.js +330 -0
- package/scripts/repair-command-log.js +115 -0
- package/src/cli/init.ts +218 -20
- package/src/cli/validate-workflow-artifacts.ts +18 -2
- package/src/cli/verify.ts +100 -0
- package/src/cli/workflow-sidecar.ts +2127 -84
- package/src/cli.ts +2 -3
- package/src/lib/flow-resolver.ts +369 -0
- package/src/tools/build-universal-bundles.ts +34 -21
- package/src/tools/generate-context-map.ts +3 -17
- package/src/tools/validate-source-tree.ts +44 -104
- package/build/src/tools/filter-installed-packs.d.ts +0 -2
- package/build/src/tools/filter-installed-packs.js +0 -135
- package/packaging/packs.json +0 -49
- package/scripts/filter-installed-packs.js +0 -2
- package/src/tools/filter-installed-packs.ts +0 -132
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
5
6
|
import { createRequire } from "node:module";
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
+
// ADR 0016 Abstraction A: shared FlowDefinition resolver (P-a)
|
|
9
|
+
import { resolveActiveFlowStep, resolveFlowFilePath, resolvePhaseMap, resolveRouteBackPolicy } from "../lib/flow-resolver.js";
|
|
7
10
|
export const statuses = new Set(["new", "planning", "planned", "in_progress", "blocked", "verifying", "verified", "needs_decision", "not_verified", "failed", "delivered", "accepted", "archived"]);
|
|
8
11
|
export const phases = ["idea", "backlog", "pickup", "planning", "execution", "verification", "goal_fit", "evidence", "release", "learning", "done"];
|
|
9
12
|
export const checkKinds = new Set(["build", "types", "lint", "test", "security", "diff", "browser", "runtime", "policy", "external"]);
|
|
@@ -21,9 +24,64 @@ export function appendJsonl(file, payload) {
|
|
|
21
24
|
}
|
|
22
25
|
function die(message) { throw new Error(message); }
|
|
23
26
|
function slugify(value, fallback) { return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || fallback; }
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
/** Derives a deterministic, filesystem-safe slug from a canonical work-item ref like `kontourai/flow-agents#161`.
|
|
28
|
+
* Format: `<owner>-<repo>-<id>` e.g. `kontourai-flow-agents-161`.
|
|
29
|
+
* Reuses slugify() for normalization. Validates that the id is a numeric GitHub issue number. */
|
|
30
|
+
function workItemSlug(ref) {
|
|
31
|
+
const hashIdx = ref.indexOf("#");
|
|
32
|
+
if (hashIdx < 0 || hashIdx === ref.length - 1)
|
|
33
|
+
die("--work-item must be in owner/repo#id format");
|
|
34
|
+
const repoPath = ref.slice(0, hashIdx);
|
|
35
|
+
const id = ref.slice(hashIdx + 1);
|
|
36
|
+
if (!/^\d+$/.test(id))
|
|
37
|
+
die("--work-item id must be a numeric issue number");
|
|
38
|
+
const parts = repoPath.split("/");
|
|
39
|
+
if (parts.length !== 2 || !parts[0] || !parts[1])
|
|
40
|
+
die("--work-item repo must be owner/repo format");
|
|
41
|
+
const [owner, repo] = parts;
|
|
42
|
+
return slugify(`${owner}-${repo}-${id}`, "work-item");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Validate a Hachure trust.bundle using @kontourai/surface's canonical validator
|
|
46
|
+
* (surface is the authoritative owner of trust-bundle schema validation per ADR 0010 / ADR 0015).
|
|
47
|
+
* Returns `{ valid, errors, available }`. When @kontourai/surface is unavailable,
|
|
48
|
+
* `available` is false and `valid` is true (fail-open) so callers can choose to treat
|
|
49
|
+
* unvalidated bundles as acceptable or gate on `available`. Surface is REQUIRED for
|
|
50
|
+
* bundle writes per ADR 0010 Phase 4c — `assertBundleWritten` enforces this on the
|
|
51
|
+
* write path. Surface's validator is equivalent-or-stronger than the prior hachure
|
|
52
|
+
* JSON-Schema validator: it validates the same structural constraints plus cross-reference
|
|
53
|
+
* integrity (evidence/event → claim references) that the JSON schema did not enforce.
|
|
54
|
+
*/
|
|
55
|
+
export async function validateTrustBundle(bundle) {
|
|
56
|
+
// Use the already-loaded surface module when available (zero-cost re-entry after first load).
|
|
57
|
+
// When called standalone (fresh process, surface not yet loaded), attempt a one-shot import.
|
|
58
|
+
let surfaceValidate;
|
|
59
|
+
if (_surfaceModule !== undefined) {
|
|
60
|
+
// Module has been attempted: use cached result (null = unavailable).
|
|
61
|
+
surfaceValidate = _surfaceModule?.validateTrustBundle ?? undefined;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
// Not yet attempted — load now for standalone callers (e.g. library consumers, tests).
|
|
65
|
+
const m = await tryLoadSurface();
|
|
66
|
+
surfaceValidate = m?.validateTrustBundle ?? undefined;
|
|
67
|
+
}
|
|
68
|
+
if (!surfaceValidate)
|
|
69
|
+
return { valid: true, errors: [], available: false };
|
|
70
|
+
try {
|
|
71
|
+
surfaceValidate(bundle);
|
|
72
|
+
return { valid: true, errors: [], available: true };
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
76
|
+
return { valid: false, errors: [message], available: true };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Validate a single InquiryRecord against the hachure inquiry-record.schema.json.
|
|
80
|
+
// Uses a separate AJV instance compiled against that schema (not the trust-bundle schema).
|
|
81
|
+
let _hachureInquiryRecordValidator;
|
|
82
|
+
function getHachureInquiryRecordValidator() {
|
|
83
|
+
if (_hachureInquiryRecordValidator !== undefined)
|
|
84
|
+
return _hachureInquiryRecordValidator;
|
|
27
85
|
try {
|
|
28
86
|
const _require = createRequire(import.meta.url);
|
|
29
87
|
const hachureDir = path.dirname(_require.resolve("hachure"));
|
|
@@ -35,18 +93,20 @@ function tryLoadHachureValidator() {
|
|
|
35
93
|
continue;
|
|
36
94
|
schemas[file] = JSON.parse(fs.readFileSync(path.join(schemasDir, file), "utf8"));
|
|
37
95
|
}
|
|
96
|
+
const inquiryRecordSchema = schemas["inquiry-record.schema.json"];
|
|
97
|
+
if (!inquiryRecordSchema) {
|
|
98
|
+
_hachureInquiryRecordValidator = null;
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
38
101
|
const ajv = new Ajv({ strict: false, allErrors: true });
|
|
39
102
|
for (const [filename, schema] of Object.entries(schemas)) {
|
|
40
|
-
if (filename === "
|
|
103
|
+
if (filename === "inquiry-record.schema.json")
|
|
41
104
|
continue;
|
|
42
105
|
ajv.addSchema(schema, filename);
|
|
43
106
|
}
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const validate = ajv.compile(trustBundleSchema);
|
|
48
|
-
return (bundle) => {
|
|
49
|
-
const valid = validate(bundle);
|
|
107
|
+
const validate = ajv.compile(inquiryRecordSchema);
|
|
108
|
+
_hachureInquiryRecordValidator = (record) => {
|
|
109
|
+
const valid = validate(record);
|
|
50
110
|
if (valid)
|
|
51
111
|
return { valid: true, errors: [] };
|
|
52
112
|
const errors = (validate.errors ?? []).map((err) => {
|
|
@@ -55,31 +115,412 @@ function tryLoadHachureValidator() {
|
|
|
55
115
|
});
|
|
56
116
|
return { valid: false, errors };
|
|
57
117
|
};
|
|
118
|
+
return _hachureInquiryRecordValidator;
|
|
58
119
|
}
|
|
59
120
|
catch {
|
|
121
|
+
_hachureInquiryRecordValidator = null;
|
|
60
122
|
return null;
|
|
61
123
|
}
|
|
62
124
|
}
|
|
63
|
-
let _hachureValidator;
|
|
64
|
-
function getHachureValidator() {
|
|
65
|
-
if (_hachureValidator === undefined)
|
|
66
|
-
_hachureValidator = tryLoadHachureValidator();
|
|
67
|
-
return _hachureValidator;
|
|
68
|
-
}
|
|
69
125
|
/**
|
|
70
|
-
* Validate a
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* `{ valid: true, errors: [], available: false }` (fail-open) so callers can
|
|
74
|
-
* choose to treat unvalidated bundles as acceptable or gate on `available`.
|
|
75
|
-
* This is the same validator the sidecar writer uses for trust-backed evidence.
|
|
126
|
+
* Validate a record against the canonical hachure inquiry-record.schema.json
|
|
127
|
+
* (https://kontourai.io/schemas/surface/inquiry-record.schema.json).
|
|
128
|
+
* Returns `{ valid, errors, available }`. Fail-open when hachure is not installed.
|
|
76
129
|
*/
|
|
77
|
-
export function
|
|
78
|
-
const validate =
|
|
130
|
+
export function validateInquiryRecord(record) {
|
|
131
|
+
const validate = getHachureInquiryRecordValidator();
|
|
79
132
|
if (!validate)
|
|
80
133
|
return { valid: true, errors: [], available: false };
|
|
81
|
-
return { ...validate(
|
|
134
|
+
return { ...validate(record), available: true };
|
|
135
|
+
}
|
|
136
|
+
let _surfaceModule; // undefined = not tried yet; null = unavailable
|
|
137
|
+
async function tryLoadSurface() {
|
|
138
|
+
// Test/diagnostic seam: simulate a degraded environment where Surface is unavailable,
|
|
139
|
+
// to exercise the fail-loud (no silent data loss) path without disturbing node_modules.
|
|
140
|
+
if (process.env.FLOW_AGENTS_SURFACE_UNAVAILABLE === "1")
|
|
141
|
+
return null;
|
|
142
|
+
if (_surfaceModule !== undefined)
|
|
143
|
+
return _surfaceModule;
|
|
144
|
+
try {
|
|
145
|
+
const m = await import("@kontourai/surface");
|
|
146
|
+
_surfaceModule = m;
|
|
147
|
+
return _surfaceModule;
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
151
|
+
process.stderr.write(`[trust-bundle] @kontourai/surface unavailable — bundle write skipped: ${message}\n`);
|
|
152
|
+
_surfaceModule = null;
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** Map a workflow check status to the Surface VerificationEvent status. */
|
|
157
|
+
function checkStatusToEventStatus(status) {
|
|
158
|
+
if (status === "pass")
|
|
159
|
+
return "verified";
|
|
160
|
+
if (status === "fail")
|
|
161
|
+
return "disputed";
|
|
162
|
+
if (status === "skip")
|
|
163
|
+
return "assumed";
|
|
164
|
+
return null; // not_verified / unknown → no event → Surface returns "unknown"
|
|
165
|
+
}
|
|
166
|
+
/** Map an acceptance criterion status to the Surface VerificationEvent status. */
|
|
167
|
+
function criterionStatusToEventStatus(status) {
|
|
168
|
+
if (status === "pass")
|
|
169
|
+
return "verified";
|
|
170
|
+
if (status === "fail")
|
|
171
|
+
return "disputed";
|
|
172
|
+
if (status === "accepted_gap")
|
|
173
|
+
return "assumed";
|
|
174
|
+
return null; // pending / not_verified → no event → Surface returns "unknown"
|
|
175
|
+
}
|
|
176
|
+
/** Map a critique verdict to the Surface VerificationEvent status. */
|
|
177
|
+
function critiqueToEventStatus(verdict, findings) {
|
|
178
|
+
if (verdict === "fail")
|
|
179
|
+
return "disputed";
|
|
180
|
+
const hasOpenFinding = Array.isArray(findings) && findings.some((f) => f.status === "open");
|
|
181
|
+
if (verdict === "pass" && hasOpenFinding)
|
|
182
|
+
return "disputed";
|
|
183
|
+
if (verdict === "pass")
|
|
184
|
+
return "verified";
|
|
185
|
+
if (verdict === "comment")
|
|
186
|
+
return "assumed";
|
|
187
|
+
return null; // not_verified or unknown → no event → Surface returns "unknown"
|
|
82
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* Build a Hachure trust.bundle from raw check/criterion/critique inputs.
|
|
191
|
+
* trust.bundle is the PRIMARY artifact (ADR 0010 Phase 4a producer inversion).
|
|
192
|
+
* Callers pass raw inputs directly — not bespoke-sidecar-shaped objects.
|
|
193
|
+
* Derives claim statuses using @kontourai/surface's canonical versioned function.
|
|
194
|
+
* Returns null when Surface is unavailable (caller skips the bundle write).
|
|
195
|
+
* @param slug Task slug (used as subjectId prefix)
|
|
196
|
+
* @param timestamp ISO-8601 timestamp for createdAt / updatedAt / observedAt
|
|
197
|
+
* @param checks Normalized check objects (from record-evidence --check-json / --surface-trust-json)
|
|
198
|
+
* @param criteria Acceptance criteria objects (from acceptance.json .criteria array)
|
|
199
|
+
* @param critiques Critique objects (from critique.json .critiques array)
|
|
200
|
+
* @param commandLog Optional parsed command-log.jsonl entries (capture-authoritative fold)
|
|
201
|
+
*/
|
|
202
|
+
export async function buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, flowAgentsDir) {
|
|
203
|
+
const surface = await tryLoadSurface();
|
|
204
|
+
if (!surface)
|
|
205
|
+
return null;
|
|
206
|
+
const { deriveClaimStatus, generateClaimId, statusFunctionVersion } = surface;
|
|
207
|
+
// ADR 0016 Abstraction A (P-b): resolve active flow step for dual-emit.
|
|
208
|
+
// When flowAgentsDir is provided AND current.json carries active_flow_id/active_step_id,
|
|
209
|
+
// each produced claim gets a DECLARED primary claim (kit-typed) plus a legacy shadow
|
|
210
|
+
// (workflow.* type, claimId suffix "-legacy") for backward compatibility. When null,
|
|
211
|
+
// only the existing workflow.* claims are produced (zero behavior change).
|
|
212
|
+
const activeStep = flowAgentsDir ? resolveActiveFlowStep(flowAgentsDir) : null;
|
|
213
|
+
const claims = [];
|
|
214
|
+
const evidenceItems = [];
|
|
215
|
+
const events = [];
|
|
216
|
+
const ts = timestamp || new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
217
|
+
// One VerificationPolicy per distinct claimType, so status is policy-governed
|
|
218
|
+
// (not derived against an empty policy set). Maximal-fidelity per ADR 0010.
|
|
219
|
+
const policies = new Map();
|
|
220
|
+
const ensurePolicy = (claimType, impactLevel, requiredEvidence) => {
|
|
221
|
+
let p = policies.get(claimType);
|
|
222
|
+
if (!p) {
|
|
223
|
+
p = { id: `policy:${claimType}`, claimType, requiredEvidence, acceptanceCriteria: [`A verified verification event must support a ${claimType} claim.`], reviewAuthority: "system", validityRule: { kind: "manual" }, stalenessTriggers: [], conflictRules: [], impactLevel };
|
|
224
|
+
policies.set(claimType, p);
|
|
225
|
+
}
|
|
226
|
+
return p;
|
|
227
|
+
};
|
|
228
|
+
// Index the deterministic capture log by normalized command (a single FAIL wins),
|
|
229
|
+
// so a claimed-pass check whose command actually FAILED becomes authoritative here.
|
|
230
|
+
const captureByCommand = new Map();
|
|
231
|
+
for (const entry of Array.isArray(commandLog) ? commandLog : []) {
|
|
232
|
+
if (!entry || typeof entry.command !== "string")
|
|
233
|
+
continue;
|
|
234
|
+
const key = entry.command.replace(/\s+/g, " ").trim();
|
|
235
|
+
if (!key)
|
|
236
|
+
continue;
|
|
237
|
+
const failed = entry.observedResult === "fail" || (Number.isInteger(entry.exitCode) && entry.exitCode !== 0);
|
|
238
|
+
const prev = captureByCommand.get(key);
|
|
239
|
+
captureByCommand.set(key, { observedResult: failed || (prev && prev.observedResult === "fail") ? "fail" : "pass", exitCode: Number.isInteger(entry.exitCode) ? entry.exitCode : (prev ? prev.exitCode : null) });
|
|
240
|
+
}
|
|
241
|
+
// ─── P-b dual-emit helper ──────────────────────────────────────────────────
|
|
242
|
+
// Semantic matching table (ADR 0016 Abstraction A P-b):
|
|
243
|
+
// check (non-policy kind) → expects[] entry where claimType does NOT contain
|
|
244
|
+
// "acceptance" AND subjectType is NOT "decision". Preference: subjectType=
|
|
245
|
+
// "flow-step". Fallback: first non-decision, non-acceptance entry.
|
|
246
|
+
// check (kind=policy) → expects[] entry whose claimType contains
|
|
247
|
+
// "compliance" or "policy". Fallback: same as non-policy.
|
|
248
|
+
// acceptance criterion → expects[] entry whose subjectType is "flow-step"
|
|
249
|
+
// OR claimType contains "tests" OR "compliance". Fallback: first entry.
|
|
250
|
+
// critique → expects[] entry whose claimType contains "policy"
|
|
251
|
+
// OR "compliance" AND subjectType is "artifact". Fallback: last entry.
|
|
252
|
+
//
|
|
253
|
+
// The DECLARED claim is primary (kit-typed claimType + subjectType).
|
|
254
|
+
// The legacy claim uses the existing workflow.* claimType (suffix "-legacy") as
|
|
255
|
+
// a backward-compat shadow. Both cite the same evidence. Status is derived by
|
|
256
|
+
// Surface from that evidence (never hand-set).
|
|
257
|
+
//
|
|
258
|
+
// Per-gate producibility (ADR 0016 P-d):
|
|
259
|
+
// (a) Already handled via subjectType=flow-step preference:
|
|
260
|
+
// builder.verify.tests (verify-gate, subjectType=flow-step)
|
|
261
|
+
// builder.verify.policy-compliance (verify-gate, kind=policy match)
|
|
262
|
+
// (b) Producible via fallback (non-decision, non-acceptance, first match):
|
|
263
|
+
// builder.plan.implementation (plan-gate, subjectType=artifact)
|
|
264
|
+
// builder.execute.scope (execute-gate, subjectType=change)
|
|
265
|
+
// builder.merge-ready.readiness (merge-ready-gate, subjectType=change)
|
|
266
|
+
// builder.merge-ready-ci.readiness (merge-ready-ci-gate, subjectType=pull-request)
|
|
267
|
+
// (c) No natural producer — required:false in build.flow.json (ADR 0016 P-d plan):
|
|
268
|
+
// builder.pull-work.selected (pull-work-gate, subjectType=work-item)
|
|
269
|
+
// builder.design-probe.pickup-readiness (design-probe-gate, subjectType=work-item)
|
|
270
|
+
// builder.design-probe.decisions (design-probe-gate, subjectType=decision)
|
|
271
|
+
// builder.pr-open.pull-request (pr-open-gate, subjectType=pull-request)
|
|
272
|
+
// builder.learn.decisions (learn-gate, subjectType=decision)
|
|
273
|
+
// builder.learn.evidence (learn-gate, subjectType=release)
|
|
274
|
+
// For category (c): record-gate-claim subcommand allows skills to target a specific
|
|
275
|
+
// expects[] entry by --expectation <id>, bypassing this semantic match entirely.
|
|
276
|
+
function matchExpectsEntry(kind, checkKindVal, expectationId) {
|
|
277
|
+
if (!activeStep || activeStep.gateExpects.length === 0)
|
|
278
|
+
return null;
|
|
279
|
+
const expects = activeStep.gateExpects;
|
|
280
|
+
if (kind === "check") {
|
|
281
|
+
// ADR 0016 P-d Increment 2: when an explicit expectation id is given (from record-gate-claim
|
|
282
|
+
// --expectation), bypass heuristics and do exact lookup. This ensures multi-expects[] gates
|
|
283
|
+
// (learn-gate: decision + release; design-probe-gate: work-item + decision) produce the
|
|
284
|
+
// correct declared claimType rather than the heuristic-selected one.
|
|
285
|
+
if (expectationId) {
|
|
286
|
+
const exact = expects.find((e) => e.id === expectationId);
|
|
287
|
+
if (exact)
|
|
288
|
+
return { claimType: exact.bundle_claim.claimType, subjectType: exact.bundle_claim.subjectType };
|
|
289
|
+
}
|
|
290
|
+
const isPolicy = checkKindVal === "policy";
|
|
291
|
+
if (isPolicy) {
|
|
292
|
+
const match = expects.find((e) => {
|
|
293
|
+
const ct = e.bundle_claim.claimType.toLowerCase();
|
|
294
|
+
return ct.includes("compliance") || ct.includes("policy");
|
|
295
|
+
});
|
|
296
|
+
if (match)
|
|
297
|
+
return { claimType: match.bundle_claim.claimType, subjectType: match.bundle_claim.subjectType };
|
|
298
|
+
}
|
|
299
|
+
// Non-policy: prefer flow-step subjectType, exclude decision/acceptance entries
|
|
300
|
+
const preferred = expects.find((e) => {
|
|
301
|
+
const ct = e.bundle_claim.claimType.toLowerCase();
|
|
302
|
+
return e.bundle_claim.subjectType !== "decision" && !ct.includes("acceptance") && e.bundle_claim.subjectType === "flow-step";
|
|
303
|
+
});
|
|
304
|
+
if (preferred)
|
|
305
|
+
return { claimType: preferred.bundle_claim.claimType, subjectType: preferred.bundle_claim.subjectType };
|
|
306
|
+
const fallback = expects.find((e) => {
|
|
307
|
+
const ct = e.bundle_claim.claimType.toLowerCase();
|
|
308
|
+
return e.bundle_claim.subjectType !== "decision" && !ct.includes("acceptance");
|
|
309
|
+
});
|
|
310
|
+
if (fallback)
|
|
311
|
+
return { claimType: fallback.bundle_claim.claimType, subjectType: fallback.bundle_claim.subjectType };
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
if (kind === "acceptance") {
|
|
315
|
+
const match = expects.find((e) => {
|
|
316
|
+
const ct = e.bundle_claim.claimType.toLowerCase();
|
|
317
|
+
return e.bundle_claim.subjectType === "flow-step" || ct.includes("tests") || ct.includes("compliance");
|
|
318
|
+
});
|
|
319
|
+
if (match)
|
|
320
|
+
return { claimType: match.bundle_claim.claimType, subjectType: match.bundle_claim.subjectType };
|
|
321
|
+
return { claimType: expects[0].bundle_claim.claimType, subjectType: expects[0].bundle_claim.subjectType };
|
|
322
|
+
}
|
|
323
|
+
if (kind === "critique") {
|
|
324
|
+
const match = expects.find((e) => {
|
|
325
|
+
const ct = e.bundle_claim.claimType.toLowerCase();
|
|
326
|
+
return e.bundle_claim.subjectType === "artifact" && (ct.includes("policy") || ct.includes("compliance"));
|
|
327
|
+
});
|
|
328
|
+
if (match)
|
|
329
|
+
return { claimType: match.bundle_claim.claimType, subjectType: match.bundle_claim.subjectType };
|
|
330
|
+
const last = expects[expects.length - 1];
|
|
331
|
+
return { claimType: last.bundle_claim.claimType, subjectType: last.bundle_claim.subjectType };
|
|
332
|
+
}
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
336
|
+
// Evidence checks → claims + evidence items + events. Capture is authoritative.
|
|
337
|
+
for (const check of Array.isArray(checks) ? checks : []) {
|
|
338
|
+
if (!check.id)
|
|
339
|
+
continue;
|
|
340
|
+
const subjectId = `${slug}/${check.id}`;
|
|
341
|
+
const fieldOrBehavior = String(check.summary ?? check.id);
|
|
342
|
+
const claimId = generateClaimId(subjectId, "flow-agents.workflow", fieldOrBehavior);
|
|
343
|
+
const evId = `ev:${claimId}`;
|
|
344
|
+
const legacyClaimType = `workflow.check.${check.kind ?? "external"}`;
|
|
345
|
+
const policy = ensurePolicy(legacyClaimType, "high", ["test_output"]);
|
|
346
|
+
const cmd = typeof check.command === "string" ? check.command.replace(/\s+/g, " ").trim() : "";
|
|
347
|
+
const captured = cmd ? captureByCommand.get(cmd) : undefined;
|
|
348
|
+
const effectiveStatus = captured ? captured.observedResult : String(check.status ?? "");
|
|
349
|
+
const evStatus = checkStatusToEventStatus(effectiveStatus);
|
|
350
|
+
const claimEvents = [];
|
|
351
|
+
if (evStatus) {
|
|
352
|
+
const evt = { id: `evt:${claimId}`, claimId, status: evStatus, actor: "flow-agents/workflow-sidecar", method: "validation", evidenceIds: [evId], createdAt: ts, verifiedAt: ts };
|
|
353
|
+
events.push(evt);
|
|
354
|
+
claimEvents.push(evt);
|
|
355
|
+
}
|
|
356
|
+
const evItem = { id: evId, claimId, evidenceType: "test_output", method: "validation", sourceRef: `${slug}/evidence.json`, excerptOrSummary: fieldOrBehavior, observedAt: ts, collectedBy: "flow-agents/workflow-sidecar", passing: effectiveStatus === "pass" };
|
|
357
|
+
if (captured) {
|
|
358
|
+
evItem.sourceRef = `${slug}/command-log.jsonl`;
|
|
359
|
+
evItem.collectedBy = "flow-agents/evidence-capture";
|
|
360
|
+
evItem.execution = { runner: "bash", label: cmd, isError: captured.observedResult === "fail", ...(captured.exitCode != null ? { exitCode: captured.exitCode } : {}) };
|
|
361
|
+
}
|
|
362
|
+
evidenceItems.push(evItem);
|
|
363
|
+
// P-d: declared-only when active flow/step present (shadow retired); no-flow path unchanged.
|
|
364
|
+
// When record-gate-claim sets _gate_claim_expectation_id, pass it for exact lookup (ADR 0016 P-d Increment 2).
|
|
365
|
+
const declared = matchExpectsEntry("check", check.kind, typeof check._gate_claim_expectation_id === "string" ? check._gate_claim_expectation_id : undefined);
|
|
366
|
+
if (declared) {
|
|
367
|
+
// Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
|
|
368
|
+
const declaredPolicy = ensurePolicy(declared.claimType, "high", ["test_output"]);
|
|
369
|
+
const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId, surface: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: effectiveStatus, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id };
|
|
370
|
+
const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [evItem], events: claimEvents, policies: [declaredPolicy] });
|
|
371
|
+
claims.push({ ...declaredClaimObj, status: declaredStatus });
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
// No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
|
|
375
|
+
const claimObj = { id: claimId, subjectType: "workflow-check", subjectId, surface: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: effectiveStatus, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id };
|
|
376
|
+
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [evItem], events: claimEvents, policies: [policy] });
|
|
377
|
+
claims.push({ ...claimObj, status: derivedStatus });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// Acceptance criteria → claims + events
|
|
381
|
+
for (const criterion of Array.isArray(criteria) ? criteria : []) {
|
|
382
|
+
if (!criterion.id)
|
|
383
|
+
continue;
|
|
384
|
+
const subjectId = `${slug}/${criterion.id}`;
|
|
385
|
+
const fieldOrBehavior = String(criterion.description ?? criterion.id);
|
|
386
|
+
const claimId = generateClaimId(subjectId, "flow-agents.workflow", fieldOrBehavior);
|
|
387
|
+
const legacyClaimType = "workflow.acceptance.criterion";
|
|
388
|
+
const policy = ensurePolicy(legacyClaimType, "high", []);
|
|
389
|
+
const evStatus = criterionStatusToEventStatus(String(criterion.status ?? ""));
|
|
390
|
+
const claimEvents = [];
|
|
391
|
+
if (evStatus) {
|
|
392
|
+
const evt = { id: `evt:${claimId}`, claimId, status: evStatus, actor: "flow-agents/workflow-sidecar", method: "validation", evidenceIds: [], createdAt: ts, verifiedAt: ts };
|
|
393
|
+
events.push(evt);
|
|
394
|
+
claimEvents.push(evt);
|
|
395
|
+
}
|
|
396
|
+
// P-d: declared-only when active flow/step present (shadow retired); no-flow path unchanged.
|
|
397
|
+
const declared = matchExpectsEntry("acceptance");
|
|
398
|
+
if (declared) {
|
|
399
|
+
// Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
|
|
400
|
+
const declaredPolicy = ensurePolicy(declared.claimType, "high", []);
|
|
401
|
+
const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId, surface: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id };
|
|
402
|
+
const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [], events: claimEvents, policies: [declaredPolicy] });
|
|
403
|
+
claims.push({ ...declaredClaimObj, status: declaredStatus });
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
// No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
|
|
407
|
+
const claimObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId, surface: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id };
|
|
408
|
+
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [], events: claimEvents, policies: [policy] });
|
|
409
|
+
claims.push({ ...claimObj, status: derivedStatus });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// Critique entries → claims + events
|
|
413
|
+
for (const c of Array.isArray(critiques) ? critiques : []) {
|
|
414
|
+
if (!c.id)
|
|
415
|
+
continue;
|
|
416
|
+
const subjectId = `${slug}/${c.id}`;
|
|
417
|
+
const fieldOrBehavior = String(c.summary ?? c.verdict ?? c.id);
|
|
418
|
+
const claimId = generateClaimId(subjectId, "flow-agents.workflow", fieldOrBehavior);
|
|
419
|
+
const legacyClaimType = "workflow.critique.review";
|
|
420
|
+
const policy = ensurePolicy(legacyClaimType, "medium", []);
|
|
421
|
+
const evStatus = critiqueToEventStatus(String(c.verdict ?? ""), c.findings ?? []);
|
|
422
|
+
const claimEvents = [];
|
|
423
|
+
if (evStatus) {
|
|
424
|
+
const evt = { id: `evt:${claimId}`, claimId, status: evStatus, actor: "flow-agents/workflow-sidecar", method: "validation", evidenceIds: [], createdAt: ts, verifiedAt: ts };
|
|
425
|
+
events.push(evt);
|
|
426
|
+
claimEvents.push(evt);
|
|
427
|
+
}
|
|
428
|
+
// P-d: declared-only when active flow/step present (shadow retired); no-flow path unchanged.
|
|
429
|
+
const declared = matchExpectsEntry("critique");
|
|
430
|
+
if (declared) {
|
|
431
|
+
// Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
|
|
432
|
+
const declaredPolicy = ensurePolicy(declared.claimType, "medium", []);
|
|
433
|
+
const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId, surface: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: declaredPolicy.id };
|
|
434
|
+
const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [], events: claimEvents, policies: [declaredPolicy] });
|
|
435
|
+
claims.push({ ...declaredClaimObj, status: declaredStatus });
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
// No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
|
|
439
|
+
const claimObj = { id: claimId, subjectType: "workflow-critique", subjectId, surface: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: policy.id };
|
|
440
|
+
const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [], events: claimEvents, policies: [policy] });
|
|
441
|
+
claims.push({ ...claimObj, status: derivedStatus });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
schemaVersion: 3,
|
|
446
|
+
source: `flow-agents/workflow-sidecar;statusFunctionVersion=${statusFunctionVersion}`,
|
|
447
|
+
claims,
|
|
448
|
+
evidence: evidenceItems,
|
|
449
|
+
policies: [...policies.values()],
|
|
450
|
+
events,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Fail-open wrapper: builds (via Surface), validates, and writes a trust.bundle.
|
|
455
|
+
* Accepts raw check/criterion/critique inputs directly (ADR 0010 Phase 4a).
|
|
456
|
+
* trust.bundle is written as the PRIMARY artifact; bespoke sidecars are the
|
|
457
|
+
* caller's responsibility to emit as back-compat projections AFTER this call.
|
|
458
|
+
* ANY error is caught and logged to stderr — this function NEVER throws and
|
|
459
|
+
* NEVER affects the exit code of its caller.
|
|
460
|
+
* Returns { written: false } if Surface is unavailable (fail-open; does NOT
|
|
461
|
+
* fall back to hand-rolled status derivation).
|
|
462
|
+
* @param checks Normalized check objects (same as buildTrustBundle)
|
|
463
|
+
* @param criteria Acceptance criteria objects (same as buildTrustBundle)
|
|
464
|
+
* @param critiques Critique objects (same as buildTrustBundle)
|
|
465
|
+
*/
|
|
466
|
+
export async function writeTrustBundle(dir, slug, timestamp, checks, criteria, critiques) {
|
|
467
|
+
try {
|
|
468
|
+
// Fold the deterministic capture log (PostToolUse evidence-capture) into the
|
|
469
|
+
// bundle so capture is authoritative over claimed status. Best-effort read.
|
|
470
|
+
let commandLog = [];
|
|
471
|
+
try {
|
|
472
|
+
const raw = fs.readFileSync(path.join(dir, "command-log.jsonl"), "utf8");
|
|
473
|
+
commandLog = raw.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => { try {
|
|
474
|
+
return JSON.parse(l);
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
return null;
|
|
478
|
+
} }).filter((x) => x !== null);
|
|
479
|
+
}
|
|
480
|
+
catch { /* no capture log — fine */ }
|
|
481
|
+
// ADR 0016 Abstraction A (P-d): pass the .flow-agents dir ONLY when current.json
|
|
482
|
+
// points to this session (scoped active-flow guard). If current.json.artifact_dir
|
|
483
|
+
// resolves to a different session, pass null — no active-flow claim mapping for this bundle.
|
|
484
|
+
const _flowAgentsDir = path.dirname(dir);
|
|
485
|
+
let _scopedFlowAgentsDir = undefined;
|
|
486
|
+
try {
|
|
487
|
+
const _currentRaw = JSON.parse(fs.readFileSync(path.join(_flowAgentsDir, "current.json"), "utf8"));
|
|
488
|
+
const _artDir = typeof _currentRaw["artifact_dir"] === "string" ? _currentRaw["artifact_dir"] : null;
|
|
489
|
+
if (_artDir && path.resolve(_flowAgentsDir, _artDir) === path.resolve(dir)) {
|
|
490
|
+
_scopedFlowAgentsDir = _flowAgentsDir;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
catch { /* current.json absent or unreadable — no scoping */ }
|
|
494
|
+
const bundle = await buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, _scopedFlowAgentsDir);
|
|
495
|
+
if (!bundle)
|
|
496
|
+
return { written: false, errors: [] }; // Surface unavailable — fail-open, skip write
|
|
497
|
+
const result = await validateTrustBundle(bundle);
|
|
498
|
+
if (result.available && !result.valid) {
|
|
499
|
+
process.stderr.write(`[trust-bundle] schema validation failed: ${result.errors.join("; ")}\n`);
|
|
500
|
+
return { written: false, errors: result.errors };
|
|
501
|
+
}
|
|
502
|
+
writeJson(path.join(dir, "trust.bundle"), bundle);
|
|
503
|
+
return { written: true, errors: [] };
|
|
504
|
+
}
|
|
505
|
+
catch (err) {
|
|
506
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
507
|
+
process.stderr.write(`[trust-bundle] write failed: ${message}\n`);
|
|
508
|
+
return { written: false, errors: [message] };
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
// Phase 4c safety: the trust.bundle is the ONLY store (bespoke sidecars retired), so a
|
|
512
|
+
// fail-open write = SILENT DATA LOSS. Data-persisting writers must fail loudly when the
|
|
513
|
+
// bundle was not written (Surface unavailable, validation, or I/O) instead of exiting 0
|
|
514
|
+
// and dropping the record. (Was masked as a "flaky" concurrent-critique test.)
|
|
515
|
+
function assertBundleWritten(result) {
|
|
516
|
+
if (result.written)
|
|
517
|
+
return;
|
|
518
|
+
const reason = result.errors.length
|
|
519
|
+
? result.errors.join("; ")
|
|
520
|
+
: "@kontourai/surface is unavailable — it is REQUIRED to persist the trust.bundle (bundle-only workspace, ADR 0010 Phase 4c). Install it (>= 1.2) and retry.";
|
|
521
|
+
die(`trust.bundle was NOT written — the record was not persisted: ${reason}`);
|
|
522
|
+
}
|
|
523
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
83
524
|
function safeRepoIdentifier(value) {
|
|
84
525
|
const trimmed = value.trim().replace(/\.git$/, "");
|
|
85
526
|
if (!trimmed || trimmed.length > 120)
|
|
@@ -212,7 +653,7 @@ async function withLock(dir, create, command, body) {
|
|
|
212
653
|
if (create)
|
|
213
654
|
fs.mkdirSync(dir, { recursive: true });
|
|
214
655
|
if (!fs.existsSync(dir))
|
|
215
|
-
return body();
|
|
656
|
+
return await body();
|
|
216
657
|
const lockDir = path.join(dir, ".workflow-sidecar.lockdir");
|
|
217
658
|
const staleMs = Number(process.env.FLOW_AGENTS_WORKFLOW_SIDECAR_STALE_LOCK_MS ?? 5 * 60 * 1000);
|
|
218
659
|
const deadline = Date.now() + 30000;
|
|
@@ -247,7 +688,7 @@ async function withLock(dir, create, command, body) {
|
|
|
247
688
|
const delay = process.env.FLOW_AGENTS_WORKFLOW_SIDECAR_LOCK_DELAY;
|
|
248
689
|
if (delay)
|
|
249
690
|
await new Promise((resolve) => setTimeout(resolve, Number(delay) * 1000));
|
|
250
|
-
return body();
|
|
691
|
+
return await body();
|
|
251
692
|
}
|
|
252
693
|
finally {
|
|
253
694
|
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
@@ -320,7 +761,63 @@ function validateAgentId(agent) {
|
|
|
320
761
|
die("--agent-id must be a conservative slug");
|
|
321
762
|
return agent;
|
|
322
763
|
}
|
|
323
|
-
|
|
764
|
+
/**
|
|
765
|
+
* Find the repository root by walking upward from a starting directory to locate
|
|
766
|
+
* the nearest ancestor containing a kits/ subdirectory. Mirrors flow-resolver.ts
|
|
767
|
+
* findRepoRoot, but callable from workflow-sidecar.ts without re-importing the
|
|
768
|
+
* internal helper.
|
|
769
|
+
*
|
|
770
|
+
* ADR 0016 Abstraction A (P-d): used by advance-state and ensure-session to
|
|
771
|
+
* derive repoRoot for resolvePhaseMap calls.
|
|
772
|
+
*/
|
|
773
|
+
function findRepoRootFromDir(startDir) {
|
|
774
|
+
let dir = startDir;
|
|
775
|
+
for (let i = 0; i < 16; i++) {
|
|
776
|
+
if (fs.existsSync(path.join(dir, "kits")))
|
|
777
|
+
return dir;
|
|
778
|
+
const parent = path.dirname(dir);
|
|
779
|
+
if (parent === dir)
|
|
780
|
+
break;
|
|
781
|
+
dir = parent;
|
|
782
|
+
}
|
|
783
|
+
return process.cwd();
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Resolve the first step id from a FlowDefinition's steps[] list.
|
|
787
|
+
* Returns null when the flow cannot be loaded or has no steps.
|
|
788
|
+
* Used by ensure-session to default active_step_id when --flow-id is
|
|
789
|
+
* provided without --step-id (Q2 decision, P-d Increment 1).
|
|
790
|
+
*/
|
|
791
|
+
function resolveFirstStep(flowId, repoRoot) {
|
|
792
|
+
if (!flowId)
|
|
793
|
+
return null;
|
|
794
|
+
const dotIdx = flowId.indexOf(".");
|
|
795
|
+
if (dotIdx < 1)
|
|
796
|
+
return null;
|
|
797
|
+
const kitId = flowId.slice(0, dotIdx);
|
|
798
|
+
const flowName = flowId.slice(dotIdx + 1);
|
|
799
|
+
if (!kitId || !flowName)
|
|
800
|
+
return null;
|
|
801
|
+
// Use resolveFlowFilePath for SLUG_RE validation + path-containment check — the same
|
|
802
|
+
// defense used by resolveFlowStep and resolvePhaseMap (single implementation, DRY).
|
|
803
|
+
// Returns null for any traversal attempt (e.g. flowName="../../secret") so the
|
|
804
|
+
// caller gets a clean null return matching the existing null-contract.
|
|
805
|
+
const flowFilePath = resolveFlowFilePath(kitId, flowName, flowId, repoRoot);
|
|
806
|
+
if (!flowFilePath)
|
|
807
|
+
return null;
|
|
808
|
+
try {
|
|
809
|
+
const raw = fs.readFileSync(flowFilePath, "utf8");
|
|
810
|
+
const flowDef = JSON.parse(raw);
|
|
811
|
+
if (!flowDef || !Array.isArray(flowDef.steps) || flowDef.steps.length === 0)
|
|
812
|
+
return null;
|
|
813
|
+
const first = flowDef.steps[0];
|
|
814
|
+
return (first && typeof first.id === "string" && first.id !== "done") ? first.id : null;
|
|
815
|
+
}
|
|
816
|
+
catch {
|
|
817
|
+
return null;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
function writeCurrent(root, dir, timestamp, owner, source, flowId, stepId) {
|
|
324
821
|
writeJson(path.join(root, "current.json"), {
|
|
325
822
|
schema_version: "1.0",
|
|
326
823
|
active_slug: path.basename(dir),
|
|
@@ -329,6 +826,11 @@ function writeCurrent(root, dir, timestamp, owner, source) {
|
|
|
329
826
|
owner,
|
|
330
827
|
source,
|
|
331
828
|
active_agents: [],
|
|
829
|
+
// ADR 0016 Abstraction A (P-a): optional FlowDefinition routing keys for the producer
|
|
830
|
+
// and enforcer. Both fields are optional and backward-compatible — sessions without a
|
|
831
|
+
// FlowDefinition omit them and fall through to the workflow.* claim type path.
|
|
832
|
+
...(flowId ? { active_flow_id: flowId } : {}),
|
|
833
|
+
...(stepId ? { active_step_id: stepId } : {}),
|
|
332
834
|
});
|
|
333
835
|
}
|
|
334
836
|
function loadCurrent(root) {
|
|
@@ -373,7 +875,7 @@ function initSidecars(dir, slug, sourceRequest, summary, nextAction, timestamp,
|
|
|
373
875
|
}
|
|
374
876
|
function ensureSession(p) {
|
|
375
877
|
const root = path.resolve(opt(p, "artifact-root", ".flow-agents"));
|
|
376
|
-
const slug = opt(p, "task-slug") || die("--task-slug is required");
|
|
878
|
+
const slug = opt(p, "task-slug") || (opt(p, "work-item") ? workItemSlug(opt(p, "work-item")) : die("--task-slug is required (or pass --work-item to derive it)"));
|
|
377
879
|
const dir = sessionDirFor(root, slug);
|
|
378
880
|
fs.mkdirSync(dir, { recursive: true });
|
|
379
881
|
const timestamp = opt(p, "timestamp", now());
|
|
@@ -385,7 +887,22 @@ function ensureSession(p) {
|
|
|
385
887
|
if (!fs.existsSync(path.join(dir, "state.json")) || !fs.existsSync(path.join(dir, "acceptance.json")) || !fs.existsSync(path.join(dir, "handoff.json"))) {
|
|
386
888
|
initSidecars(dir, slug, opt(p, "source-request"), opt(p, "summary"), opt(p, "next-action", "Continue."), timestamp, md);
|
|
387
889
|
}
|
|
388
|
-
|
|
890
|
+
// ADR 0016 Abstraction A (P-a): optional --flow-id / --step-id flags persist FlowDefinition
|
|
891
|
+
// routing keys into current.json for the producer (P-b) and enforcer (P-c) to consume.
|
|
892
|
+
// When absent, behavior is unchanged — the workflow.* claim type path is used as before.
|
|
893
|
+
// P-d Increment 1 (Q2 decision): when --flow-id is given without --step-id, default
|
|
894
|
+
// active_step_id to the FIRST step in the FlowDefinition's steps[] list. This ensures
|
|
895
|
+
// ensure-session --flow-id builder.build produces a FlowDefinition-driven session even
|
|
896
|
+
// before the first advance-state call.
|
|
897
|
+
const flowId = opt(p, "flow-id");
|
|
898
|
+
let stepId = opt(p, "step-id");
|
|
899
|
+
if (flowId && !stepId) {
|
|
900
|
+
const repoRoot = findRepoRootFromDir(dir);
|
|
901
|
+
const firstStep = resolveFirstStep(flowId, repoRoot);
|
|
902
|
+
if (firstStep)
|
|
903
|
+
stepId = firstStep;
|
|
904
|
+
}
|
|
905
|
+
writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", flowId || undefined, stepId || undefined);
|
|
389
906
|
console.log(dir);
|
|
390
907
|
return 0;
|
|
391
908
|
}
|
|
@@ -421,6 +938,7 @@ function initPlan(p) {
|
|
|
421
938
|
const dir = artifactDirFrom(artifact);
|
|
422
939
|
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
423
940
|
initSidecars(dir, slug, opt(p, "source-request"), opt(p, "summary"), opt(p, "next-action"), opt(p, "timestamp", now()), read(artifact));
|
|
941
|
+
livenessLifecycle(dir, slug, "claim", opt(p, "timestamp", now()));
|
|
424
942
|
return 0;
|
|
425
943
|
}
|
|
426
944
|
function parseJson(value, label) {
|
|
@@ -505,7 +1023,10 @@ export function normalizeCheck(raw) {
|
|
|
505
1023
|
function normalizeSurfaceRefs(refs) {
|
|
506
1024
|
if (!Array.isArray(refs))
|
|
507
1025
|
die("surface_trust_refs must be an array");
|
|
508
|
-
|
|
1026
|
+
// Use the cached @kontourai/surface module for advisory inline validation of referenced
|
|
1027
|
+
// trust.bundle files. Fail-open when surface is not yet loaded (surface loads on first
|
|
1028
|
+
// bundle write via tryLoadSurface; normalizeSurfaceRefs may run before that).
|
|
1029
|
+
const surfaceValidateFn = _surfaceModule?.validateTrustBundle ?? null;
|
|
509
1030
|
return refs.map((ref) => {
|
|
510
1031
|
const keys = JSON.stringify(ref).match(/"([^"]+)":/g) ?? [];
|
|
511
1032
|
for (const key of keys.map((k) => k.slice(1, -2)))
|
|
@@ -515,19 +1036,21 @@ function normalizeSurfaceRefs(refs) {
|
|
|
515
1036
|
// trust.bundle is the canonical Hachure-aligned artifact kind; TrustReport/Trust Snapshot are legacy aliases
|
|
516
1037
|
if (!["trust.bundle", "TrustReport", "Trust Snapshot"].includes(out.artifact_kind))
|
|
517
1038
|
die("artifact_kind must be one of: trust.bundle, TrustReport, Trust Snapshot");
|
|
518
|
-
// When
|
|
519
|
-
|
|
1039
|
+
// When surface is loaded, validate the referenced trust artifact if it is a local file.
|
|
1040
|
+
// Advisory: surface's throw-based validator wraps into a fail-loud error on schema failure.
|
|
1041
|
+
if (surfaceValidateFn && out.artifact_ref && typeof out.artifact_ref === "string" && fs.existsSync(out.artifact_ref)) {
|
|
520
1042
|
try {
|
|
521
1043
|
const bundle = JSON.parse(fs.readFileSync(out.artifact_ref, "utf8"));
|
|
522
|
-
|
|
523
|
-
if (!result.valid) {
|
|
524
|
-
const errorSummary = result.errors.slice(0, 3).join("; ");
|
|
525
|
-
die(`trust.bundle artifact at ${out.artifact_ref} failed Hachure schema validation: ${errorSummary}`);
|
|
526
|
-
}
|
|
1044
|
+
surfaceValidateFn(bundle);
|
|
527
1045
|
}
|
|
528
1046
|
catch (err) {
|
|
529
|
-
if (err instanceof Error
|
|
530
|
-
throw
|
|
1047
|
+
if (err instanceof Error) {
|
|
1048
|
+
// Re-throw schema validation failures (surface throws on invalid); swallow read/parse errors.
|
|
1049
|
+
const msg = err.message;
|
|
1050
|
+
const isSchemaError = !msg.startsWith("ENOENT") && !msg.startsWith("SyntaxError") && !msg.toLowerCase().startsWith("unexpected");
|
|
1051
|
+
if (isSchemaError)
|
|
1052
|
+
die(`trust.bundle artifact at ${out.artifact_ref} failed schema validation: ${msg}`);
|
|
1053
|
+
}
|
|
531
1054
|
// File read or parse errors are not re-thrown: the artifact_ref validation path is advisory
|
|
532
1055
|
}
|
|
533
1056
|
}
|
|
@@ -548,15 +1071,63 @@ function deriveSurfaceStatus(ref) {
|
|
|
548
1071
|
return "fail";
|
|
549
1072
|
return "pass";
|
|
550
1073
|
}
|
|
1074
|
+
/**
|
|
1075
|
+
* Derive kit identity from a parsed trust.bundle by structurally reading the
|
|
1076
|
+
* DECLARED primary claim (kit-typed) rather than hardcoding "builder".
|
|
1077
|
+
*
|
|
1078
|
+
* Resolution order (no fallbacks to "builder"):
|
|
1079
|
+
* 1. First non-workflow.* claim in bundle.claims[] → claimType drives kitId + subject.
|
|
1080
|
+
* 2. No kit-typed claim: try current.json active_flow_id adjacent to the bundle file
|
|
1081
|
+
* (bundle lives at <session-dir>/trust.bundle → flowAgentsDir = grandparent).
|
|
1082
|
+
* 3. Genuinely unknown: mark as "unknown" — never hardcode a kit identity.
|
|
1083
|
+
*/
|
|
1084
|
+
export function kitIdentityFromBundle(raw, bundleFile) {
|
|
1085
|
+
// 1. Structurally read the bundle's declared kit-typed claim.
|
|
1086
|
+
const claims = Array.isArray(raw.claims) ? raw.claims : [];
|
|
1087
|
+
for (const claim of claims) {
|
|
1088
|
+
const ct = typeof claim?.claimType === "string" ? claim.claimType : "";
|
|
1089
|
+
if (ct && !ct.startsWith("workflow.")) {
|
|
1090
|
+
const kitId = ct.split(".")[0] ?? "unknown";
|
|
1091
|
+
if (kitId && kitId !== "unknown") {
|
|
1092
|
+
return { claimType: ct, kitId, subject: `${kitId}-kit`, gateId: ct };
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
// 2. No kit-typed claim in bundle — try to derive kit from current.json active_flow_id.
|
|
1097
|
+
// The bundle lives at <session-dir>/trust.bundle, so:
|
|
1098
|
+
// sessionDir = path.dirname(bundleFile)
|
|
1099
|
+
// flowAgentsDir = path.dirname(sessionDir)
|
|
1100
|
+
try {
|
|
1101
|
+
const sessionDir = path.dirname(bundleFile);
|
|
1102
|
+
const flowAgentsDir = path.dirname(sessionDir);
|
|
1103
|
+
const currentFile = path.join(flowAgentsDir, "current.json");
|
|
1104
|
+
const current = JSON.parse(fs.readFileSync(currentFile, "utf8"));
|
|
1105
|
+
const flowId = typeof current["active_flow_id"] === "string" ? current["active_flow_id"] : null;
|
|
1106
|
+
if (flowId && flowId.includes(".")) {
|
|
1107
|
+
const kitId = flowId.split(".")[0];
|
|
1108
|
+
if (kitId) {
|
|
1109
|
+
const derivedClaimType = `${kitId}.trust.bundle`;
|
|
1110
|
+
return { claimType: derivedClaimType, kitId, subject: `${kitId}-kit`, gateId: derivedClaimType };
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
catch {
|
|
1115
|
+
// Ignore — fall through to unknown
|
|
1116
|
+
}
|
|
1117
|
+
// 3. Genuinely unknown — never fallback to "builder".
|
|
1118
|
+
return { claimType: "unknown.trust.bundle", kitId: "unknown", subject: "unknown-kit", gateId: "unknown.trust.bundle" };
|
|
1119
|
+
}
|
|
551
1120
|
function surfaceCheckFromArtifact(file, index) {
|
|
552
1121
|
const raw = JSON.parse(read(file));
|
|
553
1122
|
const lower = JSON.stringify(raw).toLowerCase();
|
|
1123
|
+
// Structurally read kit identity from the bundle — never hardcode "builder".
|
|
1124
|
+
const { claimType: bundleClaimType, subject: bundleSubject, gateId: bundleGateId } = kitIdentityFromBundle(raw, file);
|
|
554
1125
|
let ref;
|
|
555
1126
|
if (lower.includes("provider") && lower.includes("absent")) {
|
|
556
|
-
ref = { artifact_kind: "trust.bundle", artifact_ref: file, gate_id: "provider.unavailable", claim_type:
|
|
1127
|
+
ref = { artifact_kind: "trust.bundle", artifact_ref: file, gate_id: "provider.unavailable", claim_type: bundleClaimType, claim_status: "unknown", subject: bundleSubject, freshness: { status: "unknown", summary: "No trust provider is configured" }, authority: { producer: "unknown", summary: "No trust provider is configured" }, integrity: { status: "unknown", summary: "Unknown" }, status: "not_verified", summary: "No trust provider is configured" };
|
|
557
1128
|
}
|
|
558
1129
|
else if (lower.includes("artifact") && lower.includes("absent")) {
|
|
559
|
-
ref = { artifact_kind: "trust.bundle", artifact_ref: file, gate_id: "artifact.unavailable", claim_type:
|
|
1130
|
+
ref = { artifact_kind: "trust.bundle", artifact_ref: file, gate_id: "artifact.unavailable", claim_type: bundleClaimType, claim_status: "unknown", subject: bundleSubject, freshness: { status: "unknown", summary: "Artifact not readable" }, authority: { producer: "unknown", summary: "Artifact not readable" }, integrity: { status: "unknown", summary: "Artifact not readable" }, status: "not_verified", summary: "artifact not readable" };
|
|
560
1131
|
}
|
|
561
1132
|
else {
|
|
562
1133
|
const claimStatus = lower.includes("rejected") ? "rejected" : "accepted";
|
|
@@ -564,23 +1135,12 @@ function surfaceCheckFromArtifact(file, index) {
|
|
|
564
1135
|
const producer = lower.includes("missing-authority") ? "unknown" : "surface-local";
|
|
565
1136
|
const integrity = lower.includes("mismatch") ? "mismatch" : "matched";
|
|
566
1137
|
// Use trust.bundle as the canonical Hachure-aligned artifact_kind for all trust-backed evidence refs
|
|
567
|
-
ref = { artifact_kind: "trust.bundle", artifact_ref: file, gate_id:
|
|
1138
|
+
ref = { artifact_kind: "trust.bundle", artifact_ref: file, gate_id: bundleGateId, claim_type: bundleClaimType, claim_status: claimStatus, subject: bundleSubject, freshness: { status: freshness, summary: freshness === "fresh" ? "fresh" : "not currently verifiable" }, authority: { producer, summary: producer === "unknown" ? "missing authority" : "Local Surface trust producer." }, integrity: { status: integrity, summary: integrity === "matched" ? "matched" : "integrity mismatch" } };
|
|
568
1139
|
ref.status = deriveSurfaceStatus(ref);
|
|
569
1140
|
ref.summary = ref.status === "pass" ? "accepted" : ref.status === "not_verified" ? "not currently verifiable" : (claimStatus === "rejected" ? "rejected" : producer === "unknown" ? "missing authority" : "integrity mismatch");
|
|
570
1141
|
}
|
|
571
1142
|
return { id: `surface-trust-${index + 1}`, kind: "policy", status: ref.status, summary: ref.summary, surface_trust_refs: [ref] };
|
|
572
1143
|
}
|
|
573
|
-
function updateAcceptance(dir, verdict) {
|
|
574
|
-
const file = path.join(dir, "acceptance.json");
|
|
575
|
-
if (!fs.existsSync(file))
|
|
576
|
-
return;
|
|
577
|
-
const data = loadJson(file);
|
|
578
|
-
const status = verdict === "pass" ? "pass" : verdict === "fail" ? "fail" : "not_verified";
|
|
579
|
-
if (Array.isArray(data.criteria))
|
|
580
|
-
data.criteria = data.criteria.map((c) => ({ ...c, status }));
|
|
581
|
-
data.goal_fit = { ...(data.goal_fit ?? {}), status, summary: verdict === "pass" ? "Evidence passed." : "Evidence requires follow-up." };
|
|
582
|
-
writeJson(file, data);
|
|
583
|
-
}
|
|
584
1144
|
function validateAcceptanceEvidenceRefs(dir) {
|
|
585
1145
|
const file = path.join(dir, "acceptance.json");
|
|
586
1146
|
if (!fs.existsSync(file))
|
|
@@ -596,7 +1156,117 @@ function validateAcceptanceEvidenceRefs(dir) {
|
|
|
596
1156
|
export function writeState(dir, slug, status, phase, timestamp, summary, next = "continue") {
|
|
597
1157
|
writeJson(path.join(dir, "state.json"), { ...loadJson(path.join(dir, "state.json")), ...sidecarBase(slug), status, phase, updated_at: timestamp, artifact_paths: relArtifacts(dir), next_action: { status: next, summary } });
|
|
598
1158
|
}
|
|
599
|
-
|
|
1159
|
+
// ─── Phase 4c: bundle-only helpers ───────────────────────────────────────────
|
|
1160
|
+
// After 4c, evidence.json and critique.json are no longer written.
|
|
1161
|
+
// Extract checks and critiques from the existing trust.bundle for callers that
|
|
1162
|
+
// need to rebuild the bundle (e.g. record-critique, record-learning).
|
|
1163
|
+
// ADR 0016 Abstraction A (Step 0 Q3 carry-forward): build the set of declared
|
|
1164
|
+
// claimTypes from the active flow step for the session at `dir`. When no active
|
|
1165
|
+
// flow is present (workflow.* sessions), returns an empty set so every existing
|
|
1166
|
+
// predicate is unchanged. When a FlowDefinition-driven session (builder.build)
|
|
1167
|
+
// is active, the set contains the kit-typed claimTypes (e.g. "builder.verify.tests",
|
|
1168
|
+
// "builder.verify.policy-compliance") so round-trip helpers broaden their filters
|
|
1169
|
+
// to include declared claims alongside the legacy workflow.* ones.
|
|
1170
|
+
//
|
|
1171
|
+
// Safety guard: current.json in the .flow-agents dir records the CURRENTLY ACTIVE
|
|
1172
|
+
// session via artifact_dir. If current.json points to a different session than `dir`
|
|
1173
|
+
// (e.g. another session was the last to call advance-state --flow-definition), we
|
|
1174
|
+
// return an empty set so declared-type predicates are NOT applied to the wrong session.
|
|
1175
|
+
// This prevents a cross-session active_flow_id from broadening claim filters for
|
|
1176
|
+
// unrelated sessions (which would cause spurious evidence/critique check behavior).
|
|
1177
|
+
function declaredClaimTypesFor(dir) {
|
|
1178
|
+
const flowAgentsDir = path.dirname(dir);
|
|
1179
|
+
// Verify that current.json points to `dir` before reading active flow step.
|
|
1180
|
+
// If it points to a different session, return empty set (zero behavior change).
|
|
1181
|
+
const currentFile = path.join(flowAgentsDir, "current.json");
|
|
1182
|
+
try {
|
|
1183
|
+
const current = JSON.parse(fs.readFileSync(currentFile, "utf8"));
|
|
1184
|
+
const artDir = typeof current["artifact_dir"] === "string" ? current["artifact_dir"] : null;
|
|
1185
|
+
if (!artDir)
|
|
1186
|
+
return new Set();
|
|
1187
|
+
const resolvedCurrent = path.resolve(flowAgentsDir, artDir);
|
|
1188
|
+
if (path.resolve(dir) !== resolvedCurrent)
|
|
1189
|
+
return new Set();
|
|
1190
|
+
}
|
|
1191
|
+
catch {
|
|
1192
|
+
return new Set();
|
|
1193
|
+
}
|
|
1194
|
+
const activeStep = resolveActiveFlowStep(flowAgentsDir);
|
|
1195
|
+
if (!activeStep || activeStep.gateExpects.length === 0)
|
|
1196
|
+
return new Set();
|
|
1197
|
+
return new Set(activeStep.gateExpects.map((e) => e.bundle_claim.claimType));
|
|
1198
|
+
}
|
|
1199
|
+
function checksFromBundle(dir, declaredClaimTypes = new Set()) {
|
|
1200
|
+
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
1201
|
+
if (!Array.isArray(bundle.evidence))
|
|
1202
|
+
return [];
|
|
1203
|
+
const allClaims = Array.isArray(bundle.claims) ? bundle.claims : [];
|
|
1204
|
+
const claimById = new Map();
|
|
1205
|
+
for (const c of allClaims)
|
|
1206
|
+
if (c && c.id)
|
|
1207
|
+
claimById.set(c.id, c);
|
|
1208
|
+
const seen = new Set();
|
|
1209
|
+
const checks = [];
|
|
1210
|
+
for (const ev of bundle.evidence) {
|
|
1211
|
+
if (!ev || !ev.claimId)
|
|
1212
|
+
continue;
|
|
1213
|
+
const claim = claimById.get(ev.claimId);
|
|
1214
|
+
if (!claim)
|
|
1215
|
+
continue;
|
|
1216
|
+
const ct = String(claim.claimType || "");
|
|
1217
|
+
// ADR 0016 Step 0: broaden to include declared kit-typed claims alongside workflow.check.*
|
|
1218
|
+
if (!ct.startsWith("workflow.check.") && !declaredClaimTypes.has(ct))
|
|
1219
|
+
continue;
|
|
1220
|
+
if (seen.has(ev.claimId))
|
|
1221
|
+
continue;
|
|
1222
|
+
seen.add(ev.claimId);
|
|
1223
|
+
const kind = ct.startsWith("workflow.check.") ? (ct.replace("workflow.check.", "") || "external") : (ct.split(".").pop() || "external");
|
|
1224
|
+
const status = claim.value ?? "not_verified";
|
|
1225
|
+
const check = { id: String(claim.subjectId || "").split("/").pop() || ev.claimId, kind, status, summary: claim.fieldOrBehavior || "" };
|
|
1226
|
+
if (ev.execution && typeof ev.execution.label === "string")
|
|
1227
|
+
check.command = ev.execution.label;
|
|
1228
|
+
if (ev.evidenceType)
|
|
1229
|
+
check.evidenceType = ev.evidenceType;
|
|
1230
|
+
checks.push(check);
|
|
1231
|
+
}
|
|
1232
|
+
// Also include check claims that have no evidence item (surface_trust_refs style)
|
|
1233
|
+
for (const claim of allClaims) {
|
|
1234
|
+
if (!claim)
|
|
1235
|
+
continue;
|
|
1236
|
+
const ct = String(claim.claimType || "");
|
|
1237
|
+
// ADR 0016 Step 0: broaden to include declared kit-typed claims alongside workflow.check.*
|
|
1238
|
+
if (!ct.startsWith("workflow.check.") && !declaredClaimTypes.has(ct))
|
|
1239
|
+
continue;
|
|
1240
|
+
if (seen.has(claim.id))
|
|
1241
|
+
continue;
|
|
1242
|
+
seen.add(claim.id);
|
|
1243
|
+
const kind = ct.startsWith("workflow.check.") ? (ct.replace("workflow.check.", "") || "external") : (ct.split(".").pop() || "external");
|
|
1244
|
+
checks.push({ id: String(claim.subjectId || "").split("/").pop() || claim.id, kind, status: claim.value ?? "not_verified", summary: claim.fieldOrBehavior || "" });
|
|
1245
|
+
}
|
|
1246
|
+
return checks;
|
|
1247
|
+
}
|
|
1248
|
+
function critiquesFromBundle(dir, declaredClaimTypes = new Set()) {
|
|
1249
|
+
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
1250
|
+
if (!Array.isArray(bundle.claims))
|
|
1251
|
+
return [];
|
|
1252
|
+
// ADR 0016 Step 0: broaden to include declared kit-typed critique claims alongside workflow.critique.review.
|
|
1253
|
+
// P-d: exclude claims that have evidence items (evidence = check claims, not critique claims).
|
|
1254
|
+
// This prevents check-type declared claims (e.g. builder.verify.tests) from being read back
|
|
1255
|
+
// as critiques when declaredClaimTypes includes all gate expects[] types.
|
|
1256
|
+
const evidenceClaimIds = new Set(Array.isArray(bundle.evidence) ? bundle.evidence.map((e) => e?.claimId).filter((id) => typeof id === "string") : []);
|
|
1257
|
+
const critiqueClaims = bundle.claims.filter((c) => c && (c.claimType === "workflow.critique.review" || declaredClaimTypes.has(c.claimType)) && !evidenceClaimIds.has(c.id));
|
|
1258
|
+
return critiqueClaims.map((c) => ({
|
|
1259
|
+
id: String(c.subjectId || "").split("/").pop() || c.id,
|
|
1260
|
+
verdict: c.value ?? "not_verified",
|
|
1261
|
+
summary: c.fieldOrBehavior || "",
|
|
1262
|
+
findings: [],
|
|
1263
|
+
reviewer: "tool-code-reviewer",
|
|
1264
|
+
reviewed_at: c.updatedAt || c.createdAt || now(),
|
|
1265
|
+
artifact_refs: [],
|
|
1266
|
+
}));
|
|
1267
|
+
}
|
|
1268
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1269
|
+
async function recordEvidence(p) {
|
|
600
1270
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
601
1271
|
const verdict = opt(p, "verdict") || die("--verdict is required");
|
|
602
1272
|
if (!verdicts.has(verdict))
|
|
@@ -606,11 +1276,15 @@ function recordEvidence(p) {
|
|
|
606
1276
|
if (!checks.length && opts(p, "surface-trust-json").length === 0)
|
|
607
1277
|
die("record-evidence requires at least one --check-json or --surface-trust-json");
|
|
608
1278
|
validateAcceptanceEvidenceRefs(dir);
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
1279
|
+
// Phase 4c: bundle is the sole verification artifact — stop writing evidence.json and acceptance.json update.
|
|
1280
|
+
const ts = opt(p, "timestamp", now());
|
|
1281
|
+
const _existingAcceptance = loadJson(path.join(dir, "acceptance.json"));
|
|
1282
|
+
const _existingCriteria = Array.isArray(_existingAcceptance.criteria) ? _existingAcceptance.criteria : [];
|
|
1283
|
+
const _criteriaStatus = verdict === "pass" ? "pass" : verdict === "fail" ? "fail" : "not_verified";
|
|
1284
|
+
const _criteriaForBundle = _existingCriteria.map((c) => ({ ...c, status: _criteriaStatus }));
|
|
1285
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, ts, checks, _criteriaForBundle, []));
|
|
612
1286
|
const stateStatus = verdict === "pass" ? "verified" : verdict === "fail" ? "failed" : "not_verified";
|
|
613
|
-
writeState(dir, slug, stateStatus, "verification",
|
|
1287
|
+
writeState(dir, slug, stateStatus, "verification", ts, "Evidence recorded.");
|
|
614
1288
|
return 0;
|
|
615
1289
|
}
|
|
616
1290
|
function diagnostic(dir, code, summary) {
|
|
@@ -618,7 +1292,90 @@ function diagnostic(dir, code, summary) {
|
|
|
618
1292
|
appendJsonl(path.join(dir, "transition-diagnostics.jsonl"), payload);
|
|
619
1293
|
die(`${code}: ${summary}`);
|
|
620
1294
|
}
|
|
621
|
-
|
|
1295
|
+
/**
|
|
1296
|
+
* record-gate-claim — Generic gate-claim producer for skills (ADR 0016 P-d Increment 1).
|
|
1297
|
+
*
|
|
1298
|
+
* Allows a skill to record a claim that satisfies a SPECIFIC gate expectation at the
|
|
1299
|
+
* active step. The caller passes:
|
|
1300
|
+
* --status <pass|fail|not_verified> (required)
|
|
1301
|
+
* --summary <text> (required)
|
|
1302
|
+
* --expectation <id> (optional; auto-resolved when the gate has one entry)
|
|
1303
|
+
* --evidence-json <json> (optional; structured evidence refs)
|
|
1304
|
+
*
|
|
1305
|
+
* The producer emits a check of kind="external" targeting the gate expectation's declared
|
|
1306
|
+
* claimType + subjectType from the active step's expects[]. This populates the trust.bundle
|
|
1307
|
+
* with a correctly-typed claim derived by Surface, suitable for gate enforcement.
|
|
1308
|
+
*
|
|
1309
|
+
* When the gate has exactly ONE expects[] entry, --expectation is optional (auto-resolve).
|
|
1310
|
+
* When the gate has multiple entries, --expectation <id> is required.
|
|
1311
|
+
*
|
|
1312
|
+
* This is what Increment 2's 6 skills will call to satisfy the category (c) gates
|
|
1313
|
+
* (pull-work.selected, design-probe.*, pr-open.pull-request, learn.*) once producers are added.
|
|
1314
|
+
*
|
|
1315
|
+
* Error cases:
|
|
1316
|
+
* - No active flow/step in current.json → die with actionable message
|
|
1317
|
+
* - --expectation not found in expects[] → die
|
|
1318
|
+
* - Multiple expects[] entries and --expectation omitted → die
|
|
1319
|
+
* - Surface unavailable → assertBundleWritten fails loud (no silent data loss)
|
|
1320
|
+
*/
|
|
1321
|
+
async function recordGateClaim(p) {
|
|
1322
|
+
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
1323
|
+
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
1324
|
+
const ts = opt(p, "timestamp", now());
|
|
1325
|
+
const statusVal = opt(p, "status");
|
|
1326
|
+
if (!["pass", "fail", "not_verified"].includes(statusVal))
|
|
1327
|
+
die("--status must be one of: pass, fail, not_verified");
|
|
1328
|
+
const summary = opt(p, "summary") || die("--summary is required");
|
|
1329
|
+
const expectationId = opt(p, "expectation");
|
|
1330
|
+
// Resolve the active flow step from current.json
|
|
1331
|
+
const flowAgentsDir = path.dirname(dir);
|
|
1332
|
+
const activeStep = resolveActiveFlowStep(flowAgentsDir);
|
|
1333
|
+
if (!activeStep)
|
|
1334
|
+
die("record-gate-claim requires an active flow step in current.json (set via ensure-session --flow-id or advance-state --flow-definition)");
|
|
1335
|
+
const expects = activeStep.gateExpects;
|
|
1336
|
+
if (expects.length === 0)
|
|
1337
|
+
die(`record-gate-claim: active step "${activeStep.stepId}" gate "${activeStep.gateId}" has no expects[] entries`);
|
|
1338
|
+
// Resolve the target expects entry
|
|
1339
|
+
let targetExpectation;
|
|
1340
|
+
if (expectationId) {
|
|
1341
|
+
targetExpectation = expects.find((e) => e.id === expectationId);
|
|
1342
|
+
if (!targetExpectation)
|
|
1343
|
+
die(`record-gate-claim: --expectation "${expectationId}" not found in gate "${activeStep.gateId}" expects[]. Available: ${expects.map((e) => e.id).join(", ")}`);
|
|
1344
|
+
}
|
|
1345
|
+
else if (expects.length === 1) {
|
|
1346
|
+
targetExpectation = expects[0];
|
|
1347
|
+
}
|
|
1348
|
+
else {
|
|
1349
|
+
die(`record-gate-claim: gate "${activeStep.gateId}" has ${expects.length} expects[] entries; --expectation <id> is required. Available: ${expects.map((e) => e.id).join(", ")}`);
|
|
1350
|
+
}
|
|
1351
|
+
const { claimType, subjectType } = targetExpectation.bundle_claim;
|
|
1352
|
+
// Build a synthetic external check that will be matched by matchExpectsEntry to produce
|
|
1353
|
+
// a correctly-typed claim. We use kind="external" so it routes through the non-policy,
|
|
1354
|
+
// non-flow-step fallback path. The subjectType on the resulting claim comes from the
|
|
1355
|
+
// expects[] entry via matchExpectsEntry.
|
|
1356
|
+
const checkId = expectationId || targetExpectation.id;
|
|
1357
|
+
// Build a minimal "external" check. Include _gate_claim_expectation_id so that
|
|
1358
|
+
// matchExpectsEntry can do an exact lookup for multi-expects[] gates (ADR 0016 P-d Increment 2).
|
|
1359
|
+
// normalizeCheck preserves extra underscore-prefixed fields without stripping them.
|
|
1360
|
+
const check = {
|
|
1361
|
+
id: `gate-claim-${checkId}`,
|
|
1362
|
+
kind: "external",
|
|
1363
|
+
status: statusVal,
|
|
1364
|
+
summary,
|
|
1365
|
+
_gate_claim_expectation_id: targetExpectation.id,
|
|
1366
|
+
};
|
|
1367
|
+
// Include structured evidence refs if provided
|
|
1368
|
+
const evidenceRefs = opts(p, "evidence-ref-json").map((v) => validateEvidenceRef(parseJson(v, "--evidence-ref-json"), "--evidence-ref-json"));
|
|
1369
|
+
if (evidenceRefs.length > 0) {
|
|
1370
|
+
check.artifact_refs = evidenceRefs;
|
|
1371
|
+
}
|
|
1372
|
+
const checkNormalized = normalizeCheck(check);
|
|
1373
|
+
// Log the targeted gate expectation for transparency (goes to stderr only)
|
|
1374
|
+
process.stderr.write(`[record-gate-claim] targeting ${activeStep.stepId}/${activeStep.gateId}/${targetExpectation.id} → claimType=${claimType} subjectType=${subjectType}\n`);
|
|
1375
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, ts, [checkNormalized], [], []));
|
|
1376
|
+
return 0;
|
|
1377
|
+
}
|
|
1378
|
+
async function advanceState(p) {
|
|
622
1379
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
623
1380
|
const status = opt(p, "status");
|
|
624
1381
|
const phase = opt(p, "phase");
|
|
@@ -633,16 +1390,22 @@ function advanceState(p) {
|
|
|
633
1390
|
if ((status === "archived" || status === "accepted") && prev.phase !== "learning")
|
|
634
1391
|
diagnostic(dir, "terminal_jump_rejected", "Terminal workflow states require release and learning gates.");
|
|
635
1392
|
const flow = opt(p, "flow-definition");
|
|
636
|
-
|
|
1393
|
+
// Route-back guard: FlowDefinition-driven (not hardcoded to builder.build).
|
|
1394
|
+
// Fires when the active flow's gate for prev.phase declares a route_back_policy
|
|
1395
|
+
// AND the target phase maps to a step listed in on_route_back values.
|
|
1396
|
+
// builder.build verify-gate already carries this declaration — behavior preserved.
|
|
1397
|
+
const repoRoot = flow ? findRepoRootFromDir(dir) : "";
|
|
1398
|
+
const routeBack = flow ? resolveRouteBackPolicy(flow, prev.phase, phase, repoRoot) : null;
|
|
1399
|
+
if (routeBack) {
|
|
637
1400
|
const reason = opt(p, "route-back-reason");
|
|
638
1401
|
if (!reason)
|
|
639
|
-
diagnostic(dir, "route_back_reason_required",
|
|
1402
|
+
diagnostic(dir, "route_back_reason_required", `Route-back from ${prev.phase} to ${phase} requires a --route-back-reason (e.g. implementation_defect).`);
|
|
640
1403
|
const file = path.join(dir, "transition-attempts.json");
|
|
641
1404
|
const attempts = loadJson(file);
|
|
642
|
-
const key =
|
|
1405
|
+
const key = `${prev.phase}->${phase}:${reason}`;
|
|
643
1406
|
const count = attempts[key]?.count ?? 0;
|
|
644
|
-
if (count >=
|
|
645
|
-
diagnostic(dir, "route_back_attempts_exceeded",
|
|
1407
|
+
if (count >= routeBack.maxAttempts)
|
|
1408
|
+
diagnostic(dir, "route_back_attempts_exceeded", `Route-back attempt limit (${routeBack.maxAttempts}) exceeded for ${prev.phase}→${phase}.`);
|
|
646
1409
|
attempts[key] = { count: count + 1, reason, updated_at: opt(p, "timestamp", now()) };
|
|
647
1410
|
writeJson(file, attempts);
|
|
648
1411
|
}
|
|
@@ -650,6 +1413,26 @@ function advanceState(p) {
|
|
|
650
1413
|
const timestamp = opt(p, "timestamp", now());
|
|
651
1414
|
writeState(dir, slug, status, phase, timestamp, opt(p, "summary"));
|
|
652
1415
|
writeJson(path.join(dir, "handoff.json"), { ...loadJson(path.join(dir, "handoff.json")), ...sidecarBase(slug), summary: opt(p, "summary"), current_state_ref: "state.json", next_steps: [opt(p, "next-action")].filter(Boolean), blockers: [], warnings: [] });
|
|
1416
|
+
// ADR 0016 Abstraction A (P-d, Increment 1): when --flow-definition is provided,
|
|
1417
|
+
// resolve the phase→step mapping from the FlowDefinition and write active_step_id
|
|
1418
|
+
// into current.json. This is the single setter — no skill needs to call ensure-session
|
|
1419
|
+
// --step-id individually. The repoRoot is derived by walking up from dir to find kits/.
|
|
1420
|
+
if (flow) {
|
|
1421
|
+
const root = path.resolve(opt(p, "artifact-root", path.dirname(dir)));
|
|
1422
|
+
// repoRoot already computed above when flow is present
|
|
1423
|
+
const phaseMap = resolvePhaseMap(flow, repoRoot);
|
|
1424
|
+
const stepId = phaseMap?.[phase] ?? undefined;
|
|
1425
|
+
if (stepId) {
|
|
1426
|
+
writeCurrent(root, dir, timestamp, "workflow-sidecar", "advance-state", flow, stepId);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
livenessLifecycle(dir, slug, LIVENESS_TERMINAL.has(status) ? "release" : "heartbeat", timestamp);
|
|
1430
|
+
// Trust checkpoint: when advancing to a terminal delivered status, seal the checkpoint.
|
|
1431
|
+
if (status === "delivered") {
|
|
1432
|
+
await sealTrustCheckpoint(dir, slug, timestamp, status, "release").catch(() => { });
|
|
1433
|
+
// Publish delivery bundle: best-effort copy to delivery/ for CI trust-reconcile.
|
|
1434
|
+
await publishDelivery(dir, findRepoRootFromDir(dir)).catch(() => { });
|
|
1435
|
+
}
|
|
653
1436
|
return 0;
|
|
654
1437
|
}
|
|
655
1438
|
export function normalizeFinding(raw) {
|
|
@@ -657,22 +1440,23 @@ export function normalizeFinding(raw) {
|
|
|
657
1440
|
die("file_refs must be an array");
|
|
658
1441
|
return raw;
|
|
659
1442
|
}
|
|
660
|
-
function
|
|
661
|
-
if (!required && critiques.length === 0)
|
|
662
|
-
return "not_required";
|
|
663
|
-
if (critiques.some((c) => c.verdict === "fail" || (Array.isArray(c.findings) && c.findings.some((f) => f.status === "open"))))
|
|
664
|
-
return "fail";
|
|
665
|
-
return "pass";
|
|
666
|
-
}
|
|
667
|
-
function recordCritique(p) {
|
|
1443
|
+
async function recordCritique(p) {
|
|
668
1444
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
669
1445
|
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
670
|
-
|
|
1446
|
+
// Phase 4c: accumulate existing critiques from trust.bundle (critique.json no longer written).
|
|
1447
|
+
// Fall back to critique.json for legacy sessions that still have it on disk.
|
|
1448
|
+
const existingCritiqueJson = loadJson(path.join(dir, "critique.json"), { critiques: [] });
|
|
1449
|
+
const legacyCritiques = Array.isArray(existingCritiqueJson.critiques) ? existingCritiqueJson.critiques : [];
|
|
1450
|
+
const _dctCritique = declaredClaimTypesFor(dir);
|
|
1451
|
+
const bundleCritiques = legacyCritiques.length === 0 ? critiquesFromBundle(dir, _dctCritique) : legacyCritiques;
|
|
671
1452
|
const critique = { id: opt(p, "id") || "review", reviewer: opt(p, "reviewer", "tool-code-reviewer"), reviewed_at: opt(p, "timestamp", now()), verdict: opt(p, "verdict", "pass"), summary: opt(p, "summary"), artifact_refs: opts(p, "artifact-ref"), findings: opts(p, "finding-json").map((v) => normalizeFinding(parseJson(v, "--finding-json"))) };
|
|
672
|
-
const critiques = [...
|
|
1453
|
+
const critiques = [...bundleCritiques, critique];
|
|
673
1454
|
if (critique.verdict === "pass" && critique.findings.some((f) => f.status === "open"))
|
|
674
1455
|
die("required critique must pass");
|
|
675
|
-
|
|
1456
|
+
// Phase 4c: build bundle from raw inputs; read checks from trust.bundle (evidence.json no longer written).
|
|
1457
|
+
const _critiqueEvChecks = checksFromBundle(dir, _dctCritique);
|
|
1458
|
+
const _critiqueAccCriteria = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
|
|
1459
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueEvChecks, _critiqueAccCriteria, critiques));
|
|
676
1460
|
return 0;
|
|
677
1461
|
}
|
|
678
1462
|
function frontmatter(text, key) {
|
|
@@ -683,7 +1467,7 @@ function frontmatter(text, key) {
|
|
|
683
1467
|
return "";
|
|
684
1468
|
return new RegExp(`^${key}:\\s*(.+)$`, "m").exec(text.slice(0, end))?.[1]?.trim() ?? "";
|
|
685
1469
|
}
|
|
686
|
-
function importCritique(p) {
|
|
1470
|
+
async function importCritique(p) {
|
|
687
1471
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
688
1472
|
const review = p.positional[1] || die("review artifact is required");
|
|
689
1473
|
const text = read(review);
|
|
@@ -699,12 +1483,12 @@ function importCritique(p) {
|
|
|
699
1483
|
findings.push({ id: slugify(title, `finding-${findings.length + 1}`), severity: (m.groups?.severity ?? "info").toLowerCase(), status: opt(p, "finding-status", verdict === "pass" ? "fixed" : "open"), description: title, file_refs: [m.groups?.target ?? review] });
|
|
700
1484
|
}
|
|
701
1485
|
const parsed = { ...p, positional: [dir], opts: { ...p.opts, id: [slugify(path.basename(review).replace(/\.md$/, ""), "review")], reviewer: ["tool-code-reviewer"], verdict: [verdict], summary: [`Imported critique from ${path.basename(review)}`], "finding-json": findings.map((f) => JSON.stringify(f)) }, flags: p.flags };
|
|
702
|
-
const result = recordCritique(parsed);
|
|
1486
|
+
const result = await recordCritique(parsed);
|
|
703
1487
|
if (verdict !== "pass")
|
|
704
1488
|
die("required critique must pass");
|
|
705
1489
|
return result;
|
|
706
1490
|
}
|
|
707
|
-
function recordRelease(p) {
|
|
1491
|
+
async function recordRelease(p) {
|
|
708
1492
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
709
1493
|
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
710
1494
|
const decision = opt(p, "decision");
|
|
@@ -717,6 +1501,264 @@ function recordRelease(p) {
|
|
|
717
1501
|
const stateSummary = opt(p, "summary").trim() || `Release readiness recorded for ${decision}.`;
|
|
718
1502
|
writeJson(path.join(dir, "release.json"), payload);
|
|
719
1503
|
writeState(dir, slug, "delivered", "release", payload.updated_at, stateSummary);
|
|
1504
|
+
// Trust checkpoint: seal at the "delivered" moment (the natural terminal mark for record-release).
|
|
1505
|
+
await sealTrustCheckpoint(dir, slug, payload.updated_at, "delivered", "release").catch(() => { });
|
|
1506
|
+
// Publish delivery bundle: best-effort copy to delivery/ for CI trust-reconcile.
|
|
1507
|
+
await publishDelivery(dir, findRepoRootFromDir(dir)).catch(() => { });
|
|
1508
|
+
return 0;
|
|
1509
|
+
}
|
|
1510
|
+
// ─── Trust Checkpoint (Increment A) ──────────────────────────────────────────
|
|
1511
|
+
// Per-run frozen snapshot of verified trust state at completion. Written to
|
|
1512
|
+
// trust.checkpoint.json alongside the other workflow sidecars.
|
|
1513
|
+
// Surface owns the DerivationCheckpoint shape; flow-agents wraps it in an
|
|
1514
|
+
// ENVELOPE that adds per-run context surface does not carry.
|
|
1515
|
+
//
|
|
1516
|
+
// Envelope shape:
|
|
1517
|
+
// {
|
|
1518
|
+
// schema_version: "1.0",
|
|
1519
|
+
// slug: string,
|
|
1520
|
+
// work_item: string | null,
|
|
1521
|
+
// status: string,
|
|
1522
|
+
// phase: string,
|
|
1523
|
+
// sealed_at: ISO-8601,
|
|
1524
|
+
// commit_sha: string | null,
|
|
1525
|
+
// checkpoint: DerivationCheckpoint ← surface owns this
|
|
1526
|
+
// }
|
|
1527
|
+
//
|
|
1528
|
+
// Idempotent: re-running advance-state / record-release to the same terminal
|
|
1529
|
+
// status overwrites with the latest snapshot.
|
|
1530
|
+
// Fail-open: if no trust.bundle exists, or Surface is unavailable, the write
|
|
1531
|
+
// is skipped gracefully (no error surfaced to the caller).
|
|
1532
|
+
/** Derive the current git HEAD sha — null if unavailable (not in a repo, git absent). */
|
|
1533
|
+
function resolveCommitSha() {
|
|
1534
|
+
try {
|
|
1535
|
+
return execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
|
|
1536
|
+
}
|
|
1537
|
+
catch {
|
|
1538
|
+
return null;
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
/**
|
|
1542
|
+
* Build and write trust.checkpoint.json for a completed run.
|
|
1543
|
+
* Skips silently when:
|
|
1544
|
+
* - trust.bundle is absent (no evidence recorded yet)
|
|
1545
|
+
* - Surface is unavailable (checkpointFromReport not found)
|
|
1546
|
+
* The caller wraps this in .catch() so it never breaks the parent command.
|
|
1547
|
+
*
|
|
1548
|
+
* Increment B1 — checkpoint signing at the release boundary:
|
|
1549
|
+
* After the checkpoint is written, attempts Sigstore keyless signing (OIDC).
|
|
1550
|
+
* - CI/OIDC available: writes trust.checkpoint.sig.json (cosign-verifiable DSSE envelope)
|
|
1551
|
+
* and writes attestation:{status:"signed",...} to trust.checkpoint.attestation.json.
|
|
1552
|
+
* - Local (no OIDC): writes trust.checkpoint.intoto.json (unsigned in-toto statement)
|
|
1553
|
+
* and writes attestation:{status:"unsigned",...} to trust.checkpoint.attestation.json.
|
|
1554
|
+
* Signing is ALWAYS fail-open — a signing failure never breaks the seal.
|
|
1555
|
+
*/
|
|
1556
|
+
export async function sealTrustCheckpoint(dir, slug, sealedAt, status, phase) {
|
|
1557
|
+
const bundlePath = path.join(dir, "trust.bundle");
|
|
1558
|
+
if (!fs.existsSync(bundlePath))
|
|
1559
|
+
return; // no bundle — skip gracefully
|
|
1560
|
+
const surface = await tryLoadSurface();
|
|
1561
|
+
if (!surface || typeof surface.checkpointFromReport !== "function" || typeof surface.buildTrustReport !== "function")
|
|
1562
|
+
return; // Surface unavailable
|
|
1563
|
+
const bundle = JSON.parse(fs.readFileSync(bundlePath, "utf8"));
|
|
1564
|
+
const report = surface.buildTrustReport(bundle);
|
|
1565
|
+
const checkpoint = surface.checkpointFromReport(report);
|
|
1566
|
+
// Derive work_item from state.json if present (best-effort)
|
|
1567
|
+
let workItem = null;
|
|
1568
|
+
try {
|
|
1569
|
+
const stateRaw = loadJson(path.join(dir, "state.json"));
|
|
1570
|
+
if (typeof stateRaw.work_item === "string")
|
|
1571
|
+
workItem = stateRaw.work_item;
|
|
1572
|
+
}
|
|
1573
|
+
catch { /* ignored */ }
|
|
1574
|
+
const checkpointPath = path.join(dir, "trust.checkpoint.json");
|
|
1575
|
+
const envelope = {
|
|
1576
|
+
schema_version: "1.0",
|
|
1577
|
+
slug,
|
|
1578
|
+
work_item: workItem,
|
|
1579
|
+
status,
|
|
1580
|
+
phase,
|
|
1581
|
+
sealed_at: sealedAt,
|
|
1582
|
+
commit_sha: resolveCommitSha(),
|
|
1583
|
+
checkpoint,
|
|
1584
|
+
};
|
|
1585
|
+
writeJson(checkpointPath, envelope);
|
|
1586
|
+
// ─── Increment B1: sign the checkpoint at the release boundary ───────────────
|
|
1587
|
+
// Additive: if surface lacks in-toto/sigstore primitives, skip silently.
|
|
1588
|
+
// The .catch() at the call site already guards the parent command; this inner
|
|
1589
|
+
// catch is defense-in-depth so signing never propagates an error upward.
|
|
1590
|
+
await signCheckpointAttestation(dir, surface, bundle, checkpointPath).catch((err) => {
|
|
1591
|
+
process.stderr.write(`[checkpoint-signing] signing skipped due to error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
/**
|
|
1595
|
+
* Increment B1 — Sign the trust checkpoint with in-toto/Sigstore.
|
|
1596
|
+
*
|
|
1597
|
+
* Called from sealTrustCheckpoint AFTER trust.checkpoint.json is written.
|
|
1598
|
+
* Computes the sha256 digest of the checkpoint file, builds an in-toto Statement
|
|
1599
|
+
* (predicate = trust bundle), and attempts Sigstore keyless signing.
|
|
1600
|
+
*
|
|
1601
|
+
* - Signed (CI/OIDC): writes trust.checkpoint.sig.json (DSSE envelope, cosign-verifiable).
|
|
1602
|
+
* - Unsigned (local): writes trust.checkpoint.intoto.json (unsigned statement).
|
|
1603
|
+
* - Always writes: trust.checkpoint.attestation.json with attestation:{status,path,...}.
|
|
1604
|
+
* trust.checkpoint.json is NOT modified after its digest is computed.
|
|
1605
|
+
*
|
|
1606
|
+
* NEVER throws — all errors are caught and surfaced as stderr warnings.
|
|
1607
|
+
* Skips silently when Surface's toInTotoStatement / signStatementWithSigstore are absent.
|
|
1608
|
+
*
|
|
1609
|
+
* @param dir Session artifact directory.
|
|
1610
|
+
* @param surface Loaded Surface module (may or may not have in-toto/sigstore exports).
|
|
1611
|
+
* @param bundle Parsed trust.bundle (becomes the in-toto predicate).
|
|
1612
|
+
* @param checkpointPath Absolute path to the already-written trust.checkpoint.json.
|
|
1613
|
+
*/
|
|
1614
|
+
async function signCheckpointAttestation(dir, surface, bundle, checkpointPath) {
|
|
1615
|
+
// Guard: both primitives must be present (consumed from Surface, never reimplemented).
|
|
1616
|
+
if (typeof surface.toInTotoStatement !== "function" || typeof surface.signStatementWithSigstore !== "function") {
|
|
1617
|
+
process.stderr.write("[checkpoint-signing] Surface in-toto/sigstore primitives unavailable — skipping attestation\n");
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
// Step A: compute sha256 digest of trust.checkpoint.json (the SUBJECT).
|
|
1621
|
+
// The checkpoint is self-evidencing — its digest is the external anchor.
|
|
1622
|
+
const checkpointBytes = fs.readFileSync(checkpointPath);
|
|
1623
|
+
const sha256hex = createHash("sha256").update(checkpointBytes).digest("hex");
|
|
1624
|
+
// Step B: build the in-toto Statement.
|
|
1625
|
+
// subject = the checkpoint file (what we are attesting TO)
|
|
1626
|
+
// predicate = the trust bundle (what the checkpoint CONTAINS)
|
|
1627
|
+
const subjects = [{ name: "trust.checkpoint.json", digest: { sha256: sha256hex } }];
|
|
1628
|
+
const statement = surface.toInTotoStatement(bundle, { subjects });
|
|
1629
|
+
// Step C: attempt Sigstore keyless signing (PRIMARY path).
|
|
1630
|
+
// signStatementWithSigstore returns null when no ambient OIDC credential is available
|
|
1631
|
+
// (local development, no ACTIONS_ID_TOKEN_REQUEST_URL). This is the expected local case.
|
|
1632
|
+
let signed = null;
|
|
1633
|
+
try {
|
|
1634
|
+
signed = await surface.signStatementWithSigstore(statement);
|
|
1635
|
+
}
|
|
1636
|
+
catch (err) {
|
|
1637
|
+
// signStatementWithSigstore may throw on unexpected failures (network error, config error);
|
|
1638
|
+
// treat as fail-open: fall through to the unsigned path.
|
|
1639
|
+
process.stderr.write(`[checkpoint-signing] signStatementWithSigstore threw: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1640
|
+
signed = null;
|
|
1641
|
+
}
|
|
1642
|
+
let attestation;
|
|
1643
|
+
if (signed) {
|
|
1644
|
+
// CI/OIDC path: write the cosign-verifiable DSSE envelope.
|
|
1645
|
+
const sigPath = path.join(dir, "trust.checkpoint.sig.json");
|
|
1646
|
+
writeJson(sigPath, signed.envelope);
|
|
1647
|
+
const keyid = signed.envelope.signatures[0]?.keyid ?? "";
|
|
1648
|
+
attestation = {
|
|
1649
|
+
status: "signed",
|
|
1650
|
+
path: "trust.checkpoint.sig.json",
|
|
1651
|
+
keyid,
|
|
1652
|
+
};
|
|
1653
|
+
process.stderr.write(`[checkpoint-signing] checkpoint signed with Sigstore — envelope written to ${sigPath}\n`);
|
|
1654
|
+
}
|
|
1655
|
+
else {
|
|
1656
|
+
// Local/unsigned path: write the unsigned in-toto statement for audit purposes.
|
|
1657
|
+
const unsignedPath = path.join(dir, "trust.checkpoint.intoto.json");
|
|
1658
|
+
writeJson(unsignedPath, statement);
|
|
1659
|
+
attestation = {
|
|
1660
|
+
status: "unsigned",
|
|
1661
|
+
path: "trust.checkpoint.intoto.json",
|
|
1662
|
+
reason: "no ambient signing identity",
|
|
1663
|
+
};
|
|
1664
|
+
process.stderr.write("[checkpoint-signing] no ambient OIDC identity — unsigned in-toto statement written (expected locally)\n");
|
|
1665
|
+
}
|
|
1666
|
+
// Step D: write the attestation record to a SEPARATE companion file.
|
|
1667
|
+
// trust.checkpoint.json is NOT modified — it must remain byte-identical to what was signed.
|
|
1668
|
+
// The companion file carries the pointer/status; the subject-digest binding in the
|
|
1669
|
+
// in-toto statement ties it back to the checkpoint without breaking the digest.
|
|
1670
|
+
const attestationPath = path.join(dir, "trust.checkpoint.attestation.json");
|
|
1671
|
+
writeJson(attestationPath, attestation);
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* seal-checkpoint <dir> [--timestamp <iso>]
|
|
1675
|
+
*
|
|
1676
|
+
* Explicit seal of the trust checkpoint for the given artifact dir.
|
|
1677
|
+
* Equivalent to the seal that fires automatically at record-release / advance-state
|
|
1678
|
+
* to delivered. Useful for the deliver skill or a human to seal explicitly without
|
|
1679
|
+
* re-running advance-state.
|
|
1680
|
+
*
|
|
1681
|
+
* Usage: workflow-sidecar seal-checkpoint <artifactDir> [--timestamp <iso>]
|
|
1682
|
+
*/
|
|
1683
|
+
async function sealCheckpoint(p) {
|
|
1684
|
+
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
1685
|
+
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
1686
|
+
const timestamp = opt(p, "timestamp", now());
|
|
1687
|
+
const stateRaw = loadJson(path.join(dir, "state.json"));
|
|
1688
|
+
const status = typeof stateRaw.status === "string" ? stateRaw.status : "delivered";
|
|
1689
|
+
const phase = typeof stateRaw.phase === "string" ? stateRaw.phase : "release";
|
|
1690
|
+
const bundlePath = path.join(dir, "trust.bundle");
|
|
1691
|
+
if (!fs.existsSync(bundlePath)) {
|
|
1692
|
+
process.stderr.write(`[seal-checkpoint] no trust.bundle at ${bundlePath} — skipping (nothing to seal)
|
|
1693
|
+
`);
|
|
1694
|
+
return 0;
|
|
1695
|
+
}
|
|
1696
|
+
await sealTrustCheckpoint(dir, slug, timestamp, status, phase);
|
|
1697
|
+
const checkpointPath = path.join(dir, "trust.checkpoint.json");
|
|
1698
|
+
if (fs.existsSync(checkpointPath)) {
|
|
1699
|
+
console.log(checkpointPath);
|
|
1700
|
+
}
|
|
1701
|
+
else {
|
|
1702
|
+
process.stderr.write(`[seal-checkpoint] checkpoint was not written — @kontourai/surface may be unavailable
|
|
1703
|
+
`);
|
|
1704
|
+
}
|
|
1705
|
+
return 0;
|
|
1706
|
+
}
|
|
1707
|
+
// ─── Publish Delivery Bundle ──────────────────────────────────────────────────
|
|
1708
|
+
// Copies the session's trust.bundle (+ checkpoint companions) from the gitignored
|
|
1709
|
+
// session artifact dir (.flow-agents/<slug>/) to the committed delivery/ transport
|
|
1710
|
+
// path so the CI trust-reconcile job can reconcile it against fresh CI results.
|
|
1711
|
+
//
|
|
1712
|
+
// Fail-soft: if trust.bundle is absent (no evidence recorded yet), does nothing.
|
|
1713
|
+
// Idempotent: overwrites on re-delivery.
|
|
1714
|
+
// Called automatically from recordRelease and advanceState→delivered (best-effort).
|
|
1715
|
+
// Also exposed as the `publish-delivery <artifact-dir>` subcommand for explicit use.
|
|
1716
|
+
/**
|
|
1717
|
+
* Publish the session's trust artifacts to the committed delivery/ path.
|
|
1718
|
+
*
|
|
1719
|
+
* Copies trust.bundle, trust.checkpoint.json, and (if present)
|
|
1720
|
+
* trust.checkpoint.intoto.json / trust.checkpoint.sig.json from the
|
|
1721
|
+
* session artifact dir to <repoRoot>/delivery/.
|
|
1722
|
+
*
|
|
1723
|
+
* Fail-soft: if trust.bundle is absent, returns without throwing.
|
|
1724
|
+
* Idempotent: overwrites on re-delivery.
|
|
1725
|
+
*/
|
|
1726
|
+
export async function publishDelivery(dir, repoRoot) {
|
|
1727
|
+
const bundleSrc = path.join(dir, "trust.bundle");
|
|
1728
|
+
if (!fs.existsSync(bundleSrc))
|
|
1729
|
+
return; // no bundle — skip gracefully
|
|
1730
|
+
const deliveryDir = path.join(repoRoot, "delivery");
|
|
1731
|
+
fs.mkdirSync(deliveryDir, { recursive: true });
|
|
1732
|
+
// Required: trust.bundle (the CI anchor)
|
|
1733
|
+
fs.copyFileSync(bundleSrc, path.join(deliveryDir, "trust.bundle"));
|
|
1734
|
+
// Optional companions: checkpoint + signing artifacts
|
|
1735
|
+
const companions = [
|
|
1736
|
+
"trust.checkpoint.json",
|
|
1737
|
+
"trust.checkpoint.intoto.json",
|
|
1738
|
+
"trust.checkpoint.sig.json",
|
|
1739
|
+
];
|
|
1740
|
+
for (const filename of companions) {
|
|
1741
|
+
const src = path.join(dir, filename);
|
|
1742
|
+
if (fs.existsSync(src)) {
|
|
1743
|
+
fs.copyFileSync(src, path.join(deliveryDir, filename));
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
process.stderr.write(`[publish-delivery] published trust.bundle and companions to ${deliveryDir}\n`);
|
|
1747
|
+
}
|
|
1748
|
+
/**
|
|
1749
|
+
* publish-delivery <artifact-dir> [--repo-root <path>]
|
|
1750
|
+
*
|
|
1751
|
+
* Explicit publish of the session trust bundle to the committed delivery/ path.
|
|
1752
|
+
* Equivalent to the publish that fires automatically at record-release /
|
|
1753
|
+
* advance-state to delivered. Useful for the deliver skill or a human to
|
|
1754
|
+
* publish explicitly.
|
|
1755
|
+
*
|
|
1756
|
+
* Usage: workflow-sidecar publish-delivery <artifactDir> [--repo-root <path>]
|
|
1757
|
+
*/
|
|
1758
|
+
async function publishDeliveryCmd(p) {
|
|
1759
|
+
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
1760
|
+
const repoRoot = opt(p, "repo-root") || findRepoRootFromDir(dir);
|
|
1761
|
+
await publishDelivery(dir, repoRoot);
|
|
720
1762
|
return 0;
|
|
721
1763
|
}
|
|
722
1764
|
export function validateLearningCorrection(record) {
|
|
@@ -774,7 +1816,7 @@ export function normalizeLearning(raw, timestamp) {
|
|
|
774
1816
|
validateLearningCorrection(raw);
|
|
775
1817
|
return { recorded_at: timestamp, ...raw };
|
|
776
1818
|
}
|
|
777
|
-
function recordLearning(p) {
|
|
1819
|
+
async function recordLearning(p) {
|
|
778
1820
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
779
1821
|
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
780
1822
|
const timestamp = opt(p, "timestamp", now());
|
|
@@ -786,9 +1828,35 @@ function recordLearning(p) {
|
|
|
786
1828
|
die("learning status learned requires every record to include correction.needed");
|
|
787
1829
|
writeJson(path.join(dir, "learning.json"), { ...sidecarBase(slug), status, updated_at: timestamp, records });
|
|
788
1830
|
writeState(dir, slug, "accepted", "learning", timestamp, opt(p, "summary"));
|
|
1831
|
+
// Phase 4c: build bundle from raw inputs; read checks/critiques from trust.bundle (bespoke sidecars no longer written).
|
|
1832
|
+
// ADR 0016 Step 0: pass declaredClaimTypes so declared builder.* claims survive the round-trip.
|
|
1833
|
+
const _dctLearning = declaredClaimTypesFor(dir);
|
|
1834
|
+
const _learningChecks = checksFromBundle(dir, _dctLearning);
|
|
1835
|
+
const _learningCriteria = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
|
|
1836
|
+
const _learningCritiques = critiquesFromBundle(dir, _dctLearning);
|
|
1837
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, timestamp, _learningChecks, _learningCriteria, _learningCritiques));
|
|
789
1838
|
return 0;
|
|
790
1839
|
}
|
|
791
|
-
function evidenceClean(dir) {
|
|
1840
|
+
function evidenceClean(dir, declaredClaimTypes = new Set()) {
|
|
1841
|
+
// Phase 4c: read from trust.bundle (sole verification artifact); fall back to evidence.json for legacy sessions.
|
|
1842
|
+
// ADR 0016 Step 0: declaredClaimTypes broadens the filter to include kit-typed check claims
|
|
1843
|
+
// (e.g. builder.verify.tests) in addition to workflow.check.* for FlowDefinition-driven sessions.
|
|
1844
|
+
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
1845
|
+
if (Array.isArray(bundle.claims)) {
|
|
1846
|
+
const checkClaims = bundle.claims.filter((c) => {
|
|
1847
|
+
if (!c)
|
|
1848
|
+
return false;
|
|
1849
|
+
const ct = String(c.claimType || "");
|
|
1850
|
+
return ct.startsWith("workflow.check.") || declaredClaimTypes.has(ct);
|
|
1851
|
+
});
|
|
1852
|
+
if (checkClaims.length === 0)
|
|
1853
|
+
return false;
|
|
1854
|
+
return checkClaims.every((c) => {
|
|
1855
|
+
const v = String(c.value || "");
|
|
1856
|
+
return v === "pass" || v === "skip";
|
|
1857
|
+
});
|
|
1858
|
+
}
|
|
1859
|
+
// Legacy fallback: evidence.json
|
|
792
1860
|
const e = loadJson(path.join(dir, "evidence.json"), {});
|
|
793
1861
|
return e.verdict === "pass" && Array.isArray(e.checks) && e.checks.length > 0 && e.checks.every((c) => {
|
|
794
1862
|
if (!(c.status === "pass" || c.status === "skip"))
|
|
@@ -796,7 +1864,21 @@ function evidenceClean(dir) {
|
|
|
796
1864
|
return !Array.isArray(c.standard_refs) || c.standard_refs.every((r) => ["junit", "sarif", "coverage", "veritas"].includes(r.standard));
|
|
797
1865
|
});
|
|
798
1866
|
}
|
|
799
|
-
function critiqueClean(dir) {
|
|
1867
|
+
function critiqueClean(dir, declaredClaimTypes = new Set()) {
|
|
1868
|
+
// Phase 4c: read from trust.bundle (sole verification artifact); fall back to critique.json for legacy sessions.
|
|
1869
|
+
// ADR 0016 Step 0: declaredClaimTypes broadens the filter to include kit-typed critique claims
|
|
1870
|
+
// (e.g. builder.verify.policy-compliance) in addition to workflow.critique.review.
|
|
1871
|
+
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
1872
|
+
if (Array.isArray(bundle.claims)) {
|
|
1873
|
+
const critiqueClaims = bundle.claims.filter((c) => c && (c.claimType === "workflow.critique.review" || declaredClaimTypes.has(c.claimType)));
|
|
1874
|
+
if (critiqueClaims.length === 0)
|
|
1875
|
+
return false; // no critique written yet
|
|
1876
|
+
return critiqueClaims.every((c) => {
|
|
1877
|
+
const v = String(c.value || "");
|
|
1878
|
+
return v !== "fail" && c.status !== "disputed" && c.status !== "rejected";
|
|
1879
|
+
});
|
|
1880
|
+
}
|
|
1881
|
+
// Legacy fallback: critique.json
|
|
800
1882
|
const c = loadJson(path.join(dir, "critique.json"), {});
|
|
801
1883
|
return c.status === "pass" && Array.isArray(c.critiques) && c.critiques.every((x) => x.verdict !== "fail" && (!Array.isArray(x.findings) || x.findings.every((f) => f.status !== "open" && (f.file_refs === undefined || Array.isArray(f.file_refs)))));
|
|
802
1884
|
}
|
|
@@ -819,7 +1901,7 @@ function assertExistingLearningValid(dir) {
|
|
|
819
1901
|
die("learning status learned requires every record to include correction.needed");
|
|
820
1902
|
}
|
|
821
1903
|
}
|
|
822
|
-
function dogfoodPass(p) {
|
|
1904
|
+
async function dogfoodPass(p) {
|
|
823
1905
|
const root = path.resolve(opt(p, "artifact-root", ".flow-agents"));
|
|
824
1906
|
const dir = path.resolve(opt(p, "artifact-dir") || currentDir(root) || "");
|
|
825
1907
|
requireArtifactDirUnderRoot(dir, root);
|
|
@@ -829,9 +1911,16 @@ function dogfoodPass(p) {
|
|
|
829
1911
|
const checks = opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json")));
|
|
830
1912
|
if (checks.some((c) => c.status !== "pass" && c.status !== "skip"))
|
|
831
1913
|
die("clean evidence requires all non-skipped checks to pass");
|
|
832
|
-
|
|
1914
|
+
// Phase 4c: evidence check reads from trust.bundle (sole verification artifact); legacy evidence.json fallback in evidenceClean.
|
|
1915
|
+
// ADR 0016 Step 0: pass declaredClaimTypes so builder.* check/critique claims count as clean evidence.
|
|
1916
|
+
const _dctDogfood = declaredClaimTypesFor(dir);
|
|
1917
|
+
const _hasBundleEvidence = fs.existsSync(path.join(dir, "trust.bundle")) && evidenceClean(dir, _dctDogfood);
|
|
1918
|
+
const _hasLegacyEvidence = fs.existsSync(path.join(dir, "evidence.json")) && evidenceClean(dir, _dctDogfood);
|
|
1919
|
+
if (!_hasBundleEvidence && !_hasLegacyEvidence && fs.existsSync(path.join(dir, "trust.bundle")))
|
|
1920
|
+
die("cannot mark clean without passing evidence");
|
|
1921
|
+
if (!_hasBundleEvidence && !_hasLegacyEvidence && !fs.existsSync(path.join(dir, "trust.bundle")) && fs.existsSync(path.join(dir, "evidence.json")))
|
|
833
1922
|
die("cannot mark clean without passing evidence");
|
|
834
|
-
if (!fs.existsSync(path.join(dir, "evidence.json")) && checks.length === 0)
|
|
1923
|
+
if (!_hasBundleEvidence && !_hasLegacyEvidence && !fs.existsSync(path.join(dir, "trust.bundle")) && !fs.existsSync(path.join(dir, "evidence.json")) && checks.length === 0)
|
|
835
1924
|
die("cannot mark clean without passing evidence");
|
|
836
1925
|
if (p.flags.has("require-critique") || opt(p, "release-decision")) {
|
|
837
1926
|
const newCritiqueVerdict = opt(p, "critique-verdict", "pass");
|
|
@@ -839,9 +1928,10 @@ function dogfoodPass(p) {
|
|
|
839
1928
|
normalizeFinding(parseJson(value, "--finding-json"));
|
|
840
1929
|
if (newCritiqueVerdict !== "pass")
|
|
841
1930
|
die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
|
|
842
|
-
if (!opt(p, "critique-id") && !critiqueClean(dir))
|
|
1931
|
+
if (!opt(p, "critique-id") && !critiqueClean(dir, _dctDogfood))
|
|
843
1932
|
die("requires passing critique");
|
|
844
|
-
if (
|
|
1933
|
+
// Phase 4c: if existing state has a dirty critique (in bundle or legacy critique.json), block even when adding a new critique-id.
|
|
1934
|
+
if (!critiqueClean(dir, _dctDogfood) && (fs.existsSync(path.join(dir, "trust.bundle")) || fs.existsSync(path.join(dir, "critique.json"))))
|
|
845
1935
|
die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
|
|
846
1936
|
}
|
|
847
1937
|
}
|
|
@@ -851,11 +1941,11 @@ function dogfoodPass(p) {
|
|
|
851
1941
|
if (opt(p, "learning-status") === "learned" && learningRecords.some((r) => r.correction === undefined))
|
|
852
1942
|
die("learned status requires every learning record to include correction.needed");
|
|
853
1943
|
if (opts(p, "check-json").length)
|
|
854
|
-
recordEvidence({ ...p, positional: [dir], opts: { ...p.opts, verdict: [verdict] }, flags: p.flags });
|
|
1944
|
+
await recordEvidence({ ...p, positional: [dir], opts: { ...p.opts, verdict: [verdict] }, flags: p.flags });
|
|
855
1945
|
if (p.flags.has("require-critique") && opt(p, "critique-id"))
|
|
856
|
-
recordCritique({ ...p, positional: [dir], opts: { ...p.opts, id: [opt(p, "critique-id")], verdict: [opt(p, "critique-verdict", "pass")], summary: [opt(p, "critique-summary", opt(p, "summary"))] }, flags: p.flags });
|
|
1946
|
+
await recordCritique({ ...p, positional: [dir], opts: { ...p.opts, id: [opt(p, "critique-id")], verdict: [opt(p, "critique-verdict", "pass")], summary: [opt(p, "critique-summary", opt(p, "summary"))] }, flags: p.flags });
|
|
857
1947
|
if (learningRecords.length)
|
|
858
|
-
recordLearning({ ...p, positional: [dir], opts: { ...p.opts, status: [opt(p, "learning-status", "learned")], "record-json": opts(p, "learning-record-json"), summary: [opt(p, "learning-summary", opt(p, "summary"))] }, flags: p.flags });
|
|
1948
|
+
await recordLearning({ ...p, positional: [dir], opts: { ...p.opts, status: [opt(p, "learning-status", "learned")], "record-json": opts(p, "learning-record-json"), summary: [opt(p, "learning-summary", opt(p, "summary"))] }, flags: p.flags });
|
|
859
1949
|
if (opt(p, "release-decision")) {
|
|
860
1950
|
recordRelease({ ...p, positional: [dir], opts: { ...p.opts, decision: [opt(p, "release-decision")], scope: [opt(p, "release-scope")], summary: [opt(p, "release-summary", opt(p, "summary"))], "gate-json": ['{"name":"merge","status":"pass","summary":"Dogfood release gate passed."}'], "evidence-ref": ["evidence.json"], "docs-json": [`{"status":"updated","summary":"Docs updated.","refs":["${opt(p, "release-doc-ref", "docs/workflow-usage-guide.md")}"]}`] }, flags: p.flags });
|
|
861
1951
|
printJson({ release_decision: opt(p, "release-decision") });
|
|
@@ -868,14 +1958,821 @@ function dogfoodPass(p) {
|
|
|
868
1958
|
writeJson(path.join(dir, "handoff.json"), handoff);
|
|
869
1959
|
}
|
|
870
1960
|
writeState(dir, taskSlugFor(dir, opt(p, "task-slug")), stateStatus, "verification", opt(p, "timestamp", now()), opt(p, "summary"), verdict === "pass" ? "continue" : "blocked");
|
|
1961
|
+
// Phase 4c: bundle was already written by recordEvidence/recordCritique above (if called).
|
|
1962
|
+
// If neither ran (e.g. verdict=fail with no check-json), re-build from bundle (no bespoke sidecars).
|
|
871
1963
|
printJson({ state_status: stateStatus });
|
|
872
1964
|
return 0;
|
|
873
1965
|
}
|
|
1966
|
+
/**
|
|
1967
|
+
* Read the gate block signal from .flow-agents/.goal-fit-block-streak.json
|
|
1968
|
+
* (written by scripts/hooks/stop-goal-fit.js when block mode fires).
|
|
1969
|
+
* The file sits at <artifact-root>/.goal-fit-block-streak.json — one level
|
|
1970
|
+
* above the session artifact dir. Fail-open: returns { blocked: false } when
|
|
1971
|
+
* the file is absent or unreadable.
|
|
1972
|
+
*
|
|
1973
|
+
* @param artifactRoot The .flow-agents root dir (parent of session slug dir).
|
|
1974
|
+
*/
|
|
1975
|
+
export function readGateBlockSignal(artifactRoot) {
|
|
1976
|
+
const streakFile = path.join(artifactRoot, ".goal-fit-block-streak.json");
|
|
1977
|
+
try {
|
|
1978
|
+
if (!fs.existsSync(streakFile))
|
|
1979
|
+
return { blocked: false, hash: null, count: 0 };
|
|
1980
|
+
const raw = JSON.parse(fs.readFileSync(streakFile, "utf8"));
|
|
1981
|
+
const count = Number(raw?.count ?? 0);
|
|
1982
|
+
const hash = typeof raw?.hash === "string" ? raw.hash : null;
|
|
1983
|
+
return { blocked: count >= 1, hash, count };
|
|
1984
|
+
}
|
|
1985
|
+
catch {
|
|
1986
|
+
return { blocked: false, hash: null, count: 0 };
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
/**
|
|
1990
|
+
* Derive the gate-review calibration from a resolved InquiryRecord and the
|
|
1991
|
+
* block signal. Pure function — no I/O.
|
|
1992
|
+
*
|
|
1993
|
+
* Mapping (mirrors SKILL.md Bundle-Claim to Classification table):
|
|
1994
|
+
* outcome="matched", status="disputed"|"rejected", blocked=true → correct
|
|
1995
|
+
* outcome="matched", status="verified"|"assumed", blocked=true → false_block
|
|
1996
|
+
* outcome="matched", status="assumed", blocked=true → false_block
|
|
1997
|
+
* outcome="matched", status="stale"|"unknown", blocked=false → missed_block
|
|
1998
|
+
* outcome="matched", status="proposed", any → missed_block
|
|
1999
|
+
* outcome="unsupported" (absent claim), any → missed_block
|
|
2000
|
+
* outcome="derived", satisfied=true, any → correct/false_block by blocked flag
|
|
2001
|
+
* fallthrough → missed_block
|
|
2002
|
+
*/
|
|
2003
|
+
export function deriveGateCalibration(outcome, answerStatus, blocked) {
|
|
2004
|
+
if (outcome === "unsupported")
|
|
2005
|
+
return "missed_block";
|
|
2006
|
+
if (outcome === "matched" || outcome === "derived") {
|
|
2007
|
+
const s = answerStatus ?? "unknown";
|
|
2008
|
+
if (blocked) {
|
|
2009
|
+
if (s === "disputed" || s === "rejected")
|
|
2010
|
+
return "correct";
|
|
2011
|
+
if (s === "verified" || s === "assumed")
|
|
2012
|
+
return "false_block";
|
|
2013
|
+
// stale/unknown/proposed while blocked — gate fired without solid evidence
|
|
2014
|
+
return "false_block";
|
|
2015
|
+
}
|
|
2016
|
+
else {
|
|
2017
|
+
// Not blocked
|
|
2018
|
+
if (s === "stale" || s === "unknown" || s === "proposed")
|
|
2019
|
+
return "missed_block";
|
|
2020
|
+
// verified/assumed and no block — correct (no block warranted, none issued)
|
|
2021
|
+
return "correct";
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
return "missed_block";
|
|
2025
|
+
}
|
|
2026
|
+
/**
|
|
2027
|
+
* Compose the advisory proposed-fix string for a gate-review finding.
|
|
2028
|
+
* Pure function — no I/O.
|
|
2029
|
+
*/
|
|
2030
|
+
export function gateAdvisoryFix(calibration, claimId, answerStatus) {
|
|
2031
|
+
const s = answerStatus ?? "unknown";
|
|
2032
|
+
if (calibration === "correct") {
|
|
2033
|
+
return `No gate change needed — block was warranted. Resolve the failure in claim \`${claimId}\` (status: \`${s}\`) and re-run gate-review to confirm the gate clears.`;
|
|
2034
|
+
}
|
|
2035
|
+
if (calibration === "false_block") {
|
|
2036
|
+
return `Investigate why the gate blocked when claim \`${claimId}\` has status \`${s}\`. Check whether stop-goal-fit evaluated a stale bundle snapshot or whether the block trigger was unrelated to bundle claims. If the block was spurious, add a freshness check to the gate evaluation loop.`;
|
|
2037
|
+
}
|
|
2038
|
+
// missed_block
|
|
2039
|
+
if (s === "stale") {
|
|
2040
|
+
return `Refresh the stale claim \`${claimId}\` by re-running the evidence capture step, then re-run gate-review to confirm the gate fires on updated data.`;
|
|
2041
|
+
}
|
|
2042
|
+
if (s === "absent") {
|
|
2043
|
+
return `Ensure \`workflow-sidecar record-evidence\` writes a bundle claim for \`${claimId}\` before \`stop-goal-fit\` evaluates. Currently no claim exists in the bundle — the gate has nothing to evaluate.`;
|
|
2044
|
+
}
|
|
2045
|
+
return `Ensure \`workflow-sidecar record-evidence\` writes a definitive event for claim \`${claimId}\` (currently \`${s}\`) before \`stop-goal-fit\` evaluates. The gate had no resolved evidence to act on.`;
|
|
2046
|
+
}
|
|
2047
|
+
/**
|
|
2048
|
+
* Build a schema-conformant InquiryRecord for the hachure inquiry-record.schema.json.
|
|
2049
|
+
* Strips Surface-internal fields (identityLinkIds, transitiveRuleIds) from
|
|
2050
|
+
* resolutionPath that are valid in the TS type but not in the JSON schema.
|
|
2051
|
+
* Sets answer.value to the gate-review value-add: { calibration, advisoryFix, gateFired, sessionSlug }.
|
|
2052
|
+
*/
|
|
2053
|
+
function toSchemaInquiryRecord(raw, calibration, advisoryFix, blocked, slug) {
|
|
2054
|
+
const resolutionPath = { claimIds: raw.resolutionPath.claimIds };
|
|
2055
|
+
if (raw.resolutionPath.ruleId !== undefined)
|
|
2056
|
+
resolutionPath["ruleId"] = raw.resolutionPath.ruleId;
|
|
2057
|
+
if (raw.resolutionPath.ruleVersion !== undefined)
|
|
2058
|
+
resolutionPath["ruleVersion"] = raw.resolutionPath.ruleVersion;
|
|
2059
|
+
const record = {
|
|
2060
|
+
id: raw.id,
|
|
2061
|
+
inquiry: raw.inquiry,
|
|
2062
|
+
outcome: raw.outcome,
|
|
2063
|
+
resolutionPath,
|
|
2064
|
+
inputSnapshot: raw.inputSnapshot,
|
|
2065
|
+
statusFunctionVersion: raw.statusFunctionVersion,
|
|
2066
|
+
resolvedAt: raw.resolvedAt,
|
|
2067
|
+
};
|
|
2068
|
+
// answer carries the canonical trust status AND gate-review's value-add advisory fix.
|
|
2069
|
+
// answer.status = derived TrustStatus from the resolved claim (or "unknown" when absent).
|
|
2070
|
+
// answer.value = { calibration, advisoryFix, gateFired, sessionSlug } — gate-review advisory.
|
|
2071
|
+
const answerStatus = raw.answer?.status ?? "unknown";
|
|
2072
|
+
record["answer"] = {
|
|
2073
|
+
status: answerStatus,
|
|
2074
|
+
value: {
|
|
2075
|
+
calibration,
|
|
2076
|
+
advisoryFix,
|
|
2077
|
+
gateFired: blocked,
|
|
2078
|
+
sessionSlug: slug,
|
|
2079
|
+
},
|
|
2080
|
+
};
|
|
2081
|
+
return record;
|
|
2082
|
+
}
|
|
2083
|
+
/**
|
|
2084
|
+
* Build an array of canonical InquiryRecords for all gate-fire and missed-block
|
|
2085
|
+
* candidates in the bundle, using Surface's resolveInquiry. Returns null when
|
|
2086
|
+
* Surface is unavailable (caller skips the output file — no fork fallback).
|
|
2087
|
+
*
|
|
2088
|
+
* @param bundle Parsed trust.bundle (BundleFile shape)
|
|
2089
|
+
* @param blockSignal Result of readGateBlockSignal()
|
|
2090
|
+
* @param slug Task slug (used in inquiry ids and session_slug)
|
|
2091
|
+
* @param expectedCriterionIds Optional list of expected criterion IDs to check
|
|
2092
|
+
* for absent claims (missed_block detection).
|
|
2093
|
+
* @param surface Loaded Surface module (must have resolveInquiry)
|
|
2094
|
+
* @param now Optional timestamp override for deterministic tests
|
|
2095
|
+
*/
|
|
2096
|
+
export function buildGateInquiryRecords(bundle, blockSignal, slug, expectedCriterionIds, surface, now) {
|
|
2097
|
+
const records = [];
|
|
2098
|
+
let idx = 0;
|
|
2099
|
+
const askedAt = (now ?? new Date()).toISOString();
|
|
2100
|
+
const bundleRecord = bundle;
|
|
2101
|
+
const claims = Array.isArray(bundle?.claims) ? bundle.claims : [];
|
|
2102
|
+
// Build a set of subjectIds already covered by bundle claims
|
|
2103
|
+
const claimSubjectIds = new Set(claims.map((c) => c.subjectId));
|
|
2104
|
+
// ── Step 1: resolve each bundle claim via resolveInquiry ──────────────────
|
|
2105
|
+
for (const claim of claims) {
|
|
2106
|
+
idx += 1;
|
|
2107
|
+
const inquiryId = `${slug}-gr-${idx}`;
|
|
2108
|
+
const inquiry = {
|
|
2109
|
+
id: inquiryId,
|
|
2110
|
+
question: `Was gate action on claim ${claim.id} (status: ${claim.status}) justified given the trust state?`,
|
|
2111
|
+
askedBy: "gate-review",
|
|
2112
|
+
askedAt,
|
|
2113
|
+
target: {
|
|
2114
|
+
subjectType: claim.subjectType,
|
|
2115
|
+
subjectId: claim.subjectId,
|
|
2116
|
+
fieldOrBehavior: claim.fieldOrBehavior,
|
|
2117
|
+
},
|
|
2118
|
+
metadata: { sessionSlug: slug, claimId: claim.id, blocked: blockSignal.blocked },
|
|
2119
|
+
};
|
|
2120
|
+
const rawRecord = surface.resolveInquiry(bundleRecord, inquiry, { now });
|
|
2121
|
+
const calibration = deriveGateCalibration(rawRecord.outcome, rawRecord.answer?.status, blockSignal.blocked);
|
|
2122
|
+
const advisoryFix = gateAdvisoryFix(calibration, claim.id, rawRecord.answer?.status ?? claim.status);
|
|
2123
|
+
records.push(toSchemaInquiryRecord(rawRecord, calibration, advisoryFix, blockSignal.blocked, slug));
|
|
2124
|
+
}
|
|
2125
|
+
// ── Step 2: resolve absent expected criteria (missed_block candidates) ────
|
|
2126
|
+
for (const criterionId of expectedCriterionIds) {
|
|
2127
|
+
const subjectId = `${slug}/${criterionId}`;
|
|
2128
|
+
// Skip if there's already a bundle claim for this criterion
|
|
2129
|
+
if (claimSubjectIds.has(subjectId) || claimSubjectIds.has(criterionId))
|
|
2130
|
+
continue;
|
|
2131
|
+
idx += 1;
|
|
2132
|
+
const inquiryId = `${slug}-gr-${idx}`;
|
|
2133
|
+
const inquiry = {
|
|
2134
|
+
id: inquiryId,
|
|
2135
|
+
question: `Was acceptance criterion "${criterionId}" claimed in the trust.bundle before gate evaluation?`,
|
|
2136
|
+
askedBy: "gate-review",
|
|
2137
|
+
askedAt,
|
|
2138
|
+
target: {
|
|
2139
|
+
subjectType: "workflow-check",
|
|
2140
|
+
subjectId,
|
|
2141
|
+
fieldOrBehavior: criterionId,
|
|
2142
|
+
},
|
|
2143
|
+
metadata: { sessionSlug: slug, criterionId, blocked: blockSignal.blocked, expectedCriterion: true },
|
|
2144
|
+
};
|
|
2145
|
+
const rawRecord = surface.resolveInquiry(bundleRecord, inquiry, { now });
|
|
2146
|
+
// outcome will be "unsupported" since no claim matches the absent criterion
|
|
2147
|
+
const calibration = deriveGateCalibration(rawRecord.outcome, rawRecord.answer?.status, blockSignal.blocked);
|
|
2148
|
+
const advisoryFix = gateAdvisoryFix(calibration, subjectId, "absent");
|
|
2149
|
+
records.push(toSchemaInquiryRecord(rawRecord, calibration, advisoryFix, blockSignal.blocked, slug));
|
|
2150
|
+
}
|
|
2151
|
+
// ── Step 3: if still empty (no claims, no expected criteria), emit one record
|
|
2152
|
+
if (records.length === 0) {
|
|
2153
|
+
idx += 1;
|
|
2154
|
+
const inquiryId = `${slug}-gr-${idx}`;
|
|
2155
|
+
const inquiry = {
|
|
2156
|
+
id: inquiryId,
|
|
2157
|
+
question: `Does the trust.bundle for session "${slug}" contain any claims for gate evaluation?`,
|
|
2158
|
+
askedBy: "gate-review",
|
|
2159
|
+
askedAt,
|
|
2160
|
+
// No target — natural-language-only inquiry → resolveInquiry returns "unsupported"
|
|
2161
|
+
metadata: { sessionSlug: slug, blocked: blockSignal.blocked, reason: "empty-bundle" },
|
|
2162
|
+
};
|
|
2163
|
+
const rawRecord = surface.resolveInquiry(bundleRecord, inquiry, { now });
|
|
2164
|
+
const advisoryFix = `Ensure \`workflow-sidecar record-evidence\` writes at least one claim to the trust.bundle for session \`${slug}\` before gate-review is invoked.`;
|
|
2165
|
+
records.push(toSchemaInquiryRecord(rawRecord, "missed_block", advisoryFix, blockSignal.blocked, slug));
|
|
2166
|
+
}
|
|
2167
|
+
return records;
|
|
2168
|
+
}
|
|
2169
|
+
/**
|
|
2170
|
+
* gate-review <artifact-dir>
|
|
2171
|
+
*
|
|
2172
|
+
* Reads the session's trust.bundle and the gate block signal, classifies each
|
|
2173
|
+
* gate fire or suspected miss using Surface's resolveInquiry, and emits
|
|
2174
|
+
* gate-review.inquiries.json as an array of canonical InquiryRecords.
|
|
2175
|
+
* ADVISORY ONLY — never modifies scripts/hooks/. Issue #119.
|
|
2176
|
+
*
|
|
2177
|
+
* The block signal is read from <artifact-root>/.goal-fit-block-streak.json,
|
|
2178
|
+
* written by scripts/hooks/stop-goal-fit.js when block mode fires. The file
|
|
2179
|
+
* lives one level above the session slug dir (the .flow-agents root).
|
|
2180
|
+
*
|
|
2181
|
+
* If @kontourai/surface is unavailable, logs a warning and returns 0
|
|
2182
|
+
* (fail-open — no bespoke fork fallback).
|
|
2183
|
+
*/
|
|
2184
|
+
async function gateReview(p) {
|
|
2185
|
+
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
2186
|
+
if (!fs.existsSync(dir))
|
|
2187
|
+
die(`artifact directory does not exist: ${dir}`);
|
|
2188
|
+
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
2189
|
+
// Locate trust.bundle — required per SKILL.md contract
|
|
2190
|
+
const bundlePath = path.join(dir, "trust.bundle");
|
|
2191
|
+
if (!fs.existsSync(bundlePath)) {
|
|
2192
|
+
process.stderr.write(`[gate-review] trust.bundle absent at ${bundlePath} — NOT_VERIFIED. Build ADR 0010 Phase 1 first.\n`);
|
|
2193
|
+
return 1;
|
|
2194
|
+
}
|
|
2195
|
+
// Load Surface (ESM, fail-open)
|
|
2196
|
+
const surface = await tryLoadSurface();
|
|
2197
|
+
if (!surface || typeof surface.resolveInquiry !== "function") {
|
|
2198
|
+
process.stderr.write(`[gate-review] @kontourai/surface unavailable or missing resolveInquiry — gate-review skipped (no fork fallback)\n`);
|
|
2199
|
+
return 0;
|
|
2200
|
+
}
|
|
2201
|
+
const bundle = JSON.parse(fs.readFileSync(bundlePath, "utf8"));
|
|
2202
|
+
// Read gate block signal from .flow-agents root (one level above session dir)
|
|
2203
|
+
const artifactRoot = path.dirname(dir);
|
|
2204
|
+
const blockSignal = readGateBlockSignal(artifactRoot);
|
|
2205
|
+
// Enumerate expected criterion IDs: primary = bundle claims (workflow.acceptance.criterion),
|
|
2206
|
+
// fallback = acceptance.json (back-compat for sessions without an up-to-date bundle).
|
|
2207
|
+
const criterionClaims = Array.isArray(bundle.claims)
|
|
2208
|
+
? bundle.claims.filter((c) => c.claimType === "workflow.acceptance.criterion")
|
|
2209
|
+
: [];
|
|
2210
|
+
let expectedCriterionIds;
|
|
2211
|
+
if (criterionClaims.length > 0) {
|
|
2212
|
+
// Extract the final segment of subjectId (e.g. "slug/AC1" → "AC1")
|
|
2213
|
+
expectedCriterionIds = criterionClaims
|
|
2214
|
+
.map((c) => String(c.subjectId ?? "").split("/").pop() ?? "")
|
|
2215
|
+
.filter(Boolean);
|
|
2216
|
+
}
|
|
2217
|
+
else {
|
|
2218
|
+
// Fallback: read acceptance.json (back-compat for sessions without criterion claims)
|
|
2219
|
+
const acceptancePath = path.join(dir, "acceptance.json");
|
|
2220
|
+
const acceptance = fs.existsSync(acceptancePath) ? loadJson(acceptancePath) : null;
|
|
2221
|
+
expectedCriterionIds = Array.isArray(acceptance?.criteria)
|
|
2222
|
+
? acceptance.criteria.map((c) => String(c.id ?? "")).filter(Boolean)
|
|
2223
|
+
: [];
|
|
2224
|
+
}
|
|
2225
|
+
const records = buildGateInquiryRecords(bundle, blockSignal, slug, expectedCriterionIds, surface);
|
|
2226
|
+
// Validate each record against the hachure inquiry-record.schema.json (fail-open)
|
|
2227
|
+
const validator = getHachureInquiryRecordValidator();
|
|
2228
|
+
let schemaValid = true;
|
|
2229
|
+
const validationErrors = [];
|
|
2230
|
+
for (const record of records) {
|
|
2231
|
+
if (validator) {
|
|
2232
|
+
const result = validator(record);
|
|
2233
|
+
if (!result.valid) {
|
|
2234
|
+
schemaValid = false;
|
|
2235
|
+
validationErrors.push(...result.errors.map((e) => `${record["id"] ?? "?"}: ${e}`));
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
if (!schemaValid) {
|
|
2240
|
+
process.stderr.write(`[gate-review] InquiryRecord schema validation errors:\n${validationErrors.join("\n")}\n`);
|
|
2241
|
+
}
|
|
2242
|
+
const outputPath = path.join(dir, "gate-review.inquiries.json");
|
|
2243
|
+
writeJson(outputPath, records);
|
|
2244
|
+
// Build summary counts by calibration
|
|
2245
|
+
const counts = {};
|
|
2246
|
+
for (const r of records) {
|
|
2247
|
+
const cal = r["answer"]?.["value"]?.["calibration"] ?? "unknown";
|
|
2248
|
+
counts[cal] = (counts[cal] ?? 0) + 1;
|
|
2249
|
+
}
|
|
2250
|
+
const summary = Object.entries(counts)
|
|
2251
|
+
.filter(([, n]) => n > 0)
|
|
2252
|
+
.map(([k, n]) => `${k}=${n}`)
|
|
2253
|
+
.join(", ");
|
|
2254
|
+
const schemaTag = validator ? (schemaValid ? " schema:valid" : " schema:INVALID") : " schema:unavailable";
|
|
2255
|
+
console.log(`gate-review: ${records.length} InquiryRecord(s) [${summary}]${schemaTag} → ${outputPath}`);
|
|
2256
|
+
return 0;
|
|
2257
|
+
}
|
|
2258
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2259
|
+
// ─── ADR 0010 Phase 3: project the local trust.bundle to the Surface Trust Panel ──
|
|
2260
|
+
// Surface owns derivation (buildTrustReport) AND rendering (the dependency-free
|
|
2261
|
+
// <surface-trust-panel> element). Flow Agents only assembles a standalone HTML
|
|
2262
|
+
// shell — no trust logic or rendering reimplemented (consume-never-fork).
|
|
2263
|
+
/** Locate Surface's self-contained, dependency-free panel element (ESM, no require). */
|
|
2264
|
+
function loadSurfacePanelJs() {
|
|
2265
|
+
let d = path.dirname(fileURLToPath(import.meta.url));
|
|
2266
|
+
for (let i = 0; i < 12; i += 1) {
|
|
2267
|
+
try {
|
|
2268
|
+
return fs.readFileSync(path.join(d, "node_modules/@kontourai/surface/dist/src/trust-panel/surface-trust-panel.js"), "utf8");
|
|
2269
|
+
}
|
|
2270
|
+
catch { /* walk up */ }
|
|
2271
|
+
const parent = path.dirname(d);
|
|
2272
|
+
if (parent === d)
|
|
2273
|
+
break;
|
|
2274
|
+
d = parent;
|
|
2275
|
+
}
|
|
2276
|
+
die("could not locate @kontourai/surface trust-panel element (dist/src/trust-panel/surface-trust-panel.js)");
|
|
2277
|
+
return "";
|
|
2278
|
+
}
|
|
2279
|
+
async function renderTrustPanel(p) {
|
|
2280
|
+
const root = path.resolve(opt(p, "artifact-root", ".flow-agents"));
|
|
2281
|
+
const dir = p.positional[0] ? artifactDirFrom(p.positional[0]) : currentDir(root);
|
|
2282
|
+
if (!dir)
|
|
2283
|
+
die("render-trust-panel requires a workflow dir or a recorded current session");
|
|
2284
|
+
let bundle = null;
|
|
2285
|
+
try {
|
|
2286
|
+
bundle = JSON.parse(fs.readFileSync(path.join(dir, "trust.bundle"), "utf8"));
|
|
2287
|
+
}
|
|
2288
|
+
catch {
|
|
2289
|
+
bundle = null;
|
|
2290
|
+
}
|
|
2291
|
+
if (!bundle)
|
|
2292
|
+
die(`no trust.bundle at ${path.join(dir, "trust.bundle")} — run record-evidence first`);
|
|
2293
|
+
const surface = (await import("@kontourai/surface"));
|
|
2294
|
+
if (typeof surface.buildTrustReport !== "function")
|
|
2295
|
+
die("@kontourai/surface buildTrustReport unavailable — cannot derive the trust report");
|
|
2296
|
+
const report = surface.buildTrustReport(bundle);
|
|
2297
|
+
// diffFreshness on resume: if a prior trust.checkpoint.json exists, surface the
|
|
2298
|
+
// fresh→stale transitions so the user sees what has gone stale since the last seal.
|
|
2299
|
+
const checkpointFile = path.join(dir, "trust.checkpoint.json");
|
|
2300
|
+
if (fs.existsSync(checkpointFile) && typeof surface.diffFreshness === "function") {
|
|
2301
|
+
try {
|
|
2302
|
+
const envelope = JSON.parse(fs.readFileSync(checkpointFile, "utf8"));
|
|
2303
|
+
const priorCheckpoint = envelope.checkpoint;
|
|
2304
|
+
if (priorCheckpoint && typeof priorCheckpoint === "object") {
|
|
2305
|
+
const transitions = surface.diffFreshness(priorCheckpoint, report);
|
|
2306
|
+
const staleTransitions = transitions.filter((t) => t["to"] === "stale");
|
|
2307
|
+
if (staleTransitions.length > 0) {
|
|
2308
|
+
const claimIds = staleTransitions.map((t) => String(t["claimId"] ?? "")).filter(Boolean);
|
|
2309
|
+
process.stderr.write(`[trust-checkpoint] ${staleTransitions.length} claim(s) went stale since the last checkpoint (sealed ${String(envelope.sealed_at ?? "unknown")}):\n${claimIds.map((id) => ` - ${id}`).join("\n")}\n`);
|
|
2310
|
+
}
|
|
2311
|
+
else {
|
|
2312
|
+
process.stderr.write(`[trust-checkpoint] 0 claims went stale since the last checkpoint (sealed ${String(envelope.sealed_at ?? "unknown")}).\n`);
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
catch {
|
|
2317
|
+
/* diffFreshness is advisory — never block the panel render */
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
const panelJs = loadSurfacePanelJs();
|
|
2321
|
+
const heading = `Flow Agents trust — ${String(path.basename(dir)).replace(/[<>"&]/g, "")}`;
|
|
2322
|
+
const reportJson = JSON.stringify(report).replace(/</g, "\\u003c");
|
|
2323
|
+
const html = `<!doctype html>
|
|
2324
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${heading}</title></head>
|
|
2325
|
+
<body style="margin:0;padding:1.5rem;background:#f4f1e6">
|
|
2326
|
+
<script type="module">
|
|
2327
|
+
${panelJs}
|
|
2328
|
+
</script>
|
|
2329
|
+
<surface-trust-panel heading="${heading}"></surface-trust-panel>
|
|
2330
|
+
<script id="trust-report" type="application/json">${reportJson}</script>
|
|
2331
|
+
<script type="module">document.querySelector("surface-trust-panel").report = JSON.parse(document.getElementById("trust-report").textContent);</script>
|
|
2332
|
+
</body></html>
|
|
2333
|
+
`;
|
|
2334
|
+
const out = opt(p, "out") || path.join(dir, "trust-panel.html");
|
|
2335
|
+
fs.writeFileSync(out, html);
|
|
2336
|
+
// Also emit the derived report as a first-class artifact — the universal input for
|
|
2337
|
+
// Surface's hosted Snapshot Viewer and a bare `<surface-trust-panel src=…>` (the HTML
|
|
2338
|
+
// above already embeds it). Suppress with --no-report.
|
|
2339
|
+
let reportOut = "";
|
|
2340
|
+
if (!p.flags.has("no-report")) {
|
|
2341
|
+
reportOut = opt(p, "report-out") || path.join(dir, "trust-report.json");
|
|
2342
|
+
fs.writeFileSync(reportOut, `${JSON.stringify(report, null, 2)}\n`);
|
|
2343
|
+
}
|
|
2344
|
+
console.log(out);
|
|
2345
|
+
if (reportOut)
|
|
2346
|
+
console.log(reportOut);
|
|
2347
|
+
return 0;
|
|
2348
|
+
}
|
|
2349
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2350
|
+
// ─── flow-agents#137 / ADR 0011: wire Surface's MCP to surface trust reports ──
|
|
2351
|
+
// Flow Agents produces the bundle; Surface's MCP projects it. `--mode print` is the
|
|
2352
|
+
// zero-write default (output the snippet). `enable`/`disable` edit a runtime JSON MCP
|
|
2353
|
+
// config (e.g. Claude Code `.mcp.json`) via a *conventional managed key* — idempotent,
|
|
2354
|
+
// reversible, and only ever our own entry (never auto-injected; opt-in only).
|
|
2355
|
+
const TRUST_MCP_SERVER = "flow-agents-surface-trust";
|
|
2356
|
+
function trustMcpRegistration() {
|
|
2357
|
+
// No static `--input` (a single file can't follow many per-task bundles or a moving
|
|
2358
|
+
// current); the skill passes the active task's bundle as a per-call `path` arg.
|
|
2359
|
+
return { command: "npx", args: ["-y", "@kontourai/surface", "mcp"] };
|
|
2360
|
+
}
|
|
2361
|
+
function trustMcp(p) {
|
|
2362
|
+
const mode = opt(p, "mode", "print");
|
|
2363
|
+
if (mode === "print") {
|
|
2364
|
+
console.log(JSON.stringify({ mcpServers: { [TRUST_MCP_SERVER]: trustMcpRegistration() } }, null, 2));
|
|
2365
|
+
process.stderr.write(`\n# Paste the above into your runtime MCP config (e.g. .mcp.json). Flow Agents does NOT write it for you unless you run: trust-mcp --mode enable\n`);
|
|
2366
|
+
process.stderr.write(`# To view a task's trust inline, call surface_summary with path=<.flow-agents/<slug>/trust.bundle>.\n`);
|
|
2367
|
+
return 0;
|
|
2368
|
+
}
|
|
2369
|
+
if (mode !== "enable" && mode !== "disable")
|
|
2370
|
+
die("trust-mcp --mode must be print|enable|disable");
|
|
2371
|
+
const configPath = path.resolve(opt(p, "config", ".mcp.json"));
|
|
2372
|
+
let config = {};
|
|
2373
|
+
try {
|
|
2374
|
+
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
2375
|
+
}
|
|
2376
|
+
catch {
|
|
2377
|
+
config = {};
|
|
2378
|
+
}
|
|
2379
|
+
if (typeof config !== "object" || config === null || Array.isArray(config))
|
|
2380
|
+
die(`${configPath} is not a JSON object — refusing to edit`);
|
|
2381
|
+
if (!config.mcpServers || typeof config.mcpServers !== "object" || Array.isArray(config.mcpServers))
|
|
2382
|
+
config.mcpServers = {};
|
|
2383
|
+
if (mode === "enable") {
|
|
2384
|
+
config.mcpServers[TRUST_MCP_SERVER] = trustMcpRegistration();
|
|
2385
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
2386
|
+
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
2387
|
+
console.log(`enabled ${TRUST_MCP_SERVER} in ${configPath} (remove with: trust-mcp --mode disable)`);
|
|
2388
|
+
return 0;
|
|
2389
|
+
}
|
|
2390
|
+
// disable: remove only our own conventional entry; leave everything else untouched.
|
|
2391
|
+
if (Object.prototype.hasOwnProperty.call(config.mcpServers, TRUST_MCP_SERVER)) {
|
|
2392
|
+
delete config.mcpServers[TRUST_MCP_SERVER];
|
|
2393
|
+
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
2394
|
+
console.log(`disabled ${TRUST_MCP_SERVER} in ${configPath}`);
|
|
2395
|
+
}
|
|
2396
|
+
else {
|
|
2397
|
+
console.log(`${TRUST_MCP_SERVER} not present in ${configPath} — nothing to remove`);
|
|
2398
|
+
}
|
|
2399
|
+
return 0;
|
|
2400
|
+
}
|
|
2401
|
+
// ─── ADR 0012: agent coordination as liveness claims (policy-centered) ──────────
|
|
2402
|
+
// A work-claim is a regular Hachure claim governed by a *liveness policy* (ttl +
|
|
2403
|
+
// heartbeat → held/stale/released), keyed by the work-item subjectId, appended to a
|
|
2404
|
+
// shared stream all agents read. Status is RECOMPUTED via Surface's deriveTrustStatus
|
|
2405
|
+
// (no forked logic). Advisory, not a lock. The liveness policy is a general archetype
|
|
2406
|
+
// (not use-case-specific) and is a candidate to graduate upstream into Surface.
|
|
2407
|
+
const LIVENESS_POLICY = {
|
|
2408
|
+
id: "policy:liveness.hold",
|
|
2409
|
+
claimType: "liveness.hold",
|
|
2410
|
+
requiredEvidence: [],
|
|
2411
|
+
acceptanceCriteria: ["A heartbeat within ttlSeconds holds the claim; a lapse or release frees it."],
|
|
2412
|
+
reviewAuthority: "system",
|
|
2413
|
+
validityRule: { kind: "duration", durationDays: 1 },
|
|
2414
|
+
stalenessTriggers: [],
|
|
2415
|
+
conflictRules: [],
|
|
2416
|
+
impactLevel: "medium",
|
|
2417
|
+
};
|
|
2418
|
+
function livenessStreamFile(root) { return path.join(root, "liveness", "events.jsonl"); }
|
|
2419
|
+
function appendLivenessEvent(root, evt) {
|
|
2420
|
+
const file = livenessStreamFile(root);
|
|
2421
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
2422
|
+
fs.appendFileSync(file, `${JSON.stringify(evt)}\n`);
|
|
2423
|
+
}
|
|
2424
|
+
function readLivenessEvents(root) {
|
|
2425
|
+
// Delegate to the shared pure-CJS helper (scripts/hooks/lib/liveness-read.js).
|
|
2426
|
+
// Using createRequire so the ESM sidecar can load a CJS module without bundling it.
|
|
2427
|
+
try {
|
|
2428
|
+
const _req = createRequire(import.meta.url);
|
|
2429
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-read.js");
|
|
2430
|
+
const helper = _req(helperPath);
|
|
2431
|
+
return helper.readLivenessEvents(livenessStreamFile(root));
|
|
2432
|
+
}
|
|
2433
|
+
catch {
|
|
2434
|
+
// Fallback: read inline (keeps sidecar self-sufficient if helper is unavailable)
|
|
2435
|
+
let raw = "";
|
|
2436
|
+
try {
|
|
2437
|
+
raw = fs.readFileSync(livenessStreamFile(root), "utf8");
|
|
2438
|
+
}
|
|
2439
|
+
catch {
|
|
2440
|
+
return [];
|
|
2441
|
+
}
|
|
2442
|
+
return raw.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => { try {
|
|
2443
|
+
return JSON.parse(l);
|
|
2444
|
+
}
|
|
2445
|
+
catch {
|
|
2446
|
+
return null;
|
|
2447
|
+
} }).filter((x) => x !== null);
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
function livenessLabel(status) {
|
|
2451
|
+
if (status === "verified")
|
|
2452
|
+
return "held";
|
|
2453
|
+
if (status === "stale" || status === "revoked")
|
|
2454
|
+
return "free"; // reclaimable: lapsed or released
|
|
2455
|
+
if (status === "superseded")
|
|
2456
|
+
return "superseded";
|
|
2457
|
+
return status;
|
|
2458
|
+
}
|
|
2459
|
+
// ─── ADR 0012 lifecycle-driven liveness (opt-in via FLOW_AGENTS_LIVENESS) ──────
|
|
2460
|
+
// init-plan claims the work-item; advance-state heartbeats (or releases on terminal),
|
|
2461
|
+
// so the workflow lifecycle itself maintains the liveness claim — no manual liveness calls.
|
|
2462
|
+
// Additive + fail-open: a liveness-emit failure never affects the workflow command.
|
|
2463
|
+
const LIVENESS_TERMINAL = new Set(["delivered", "accepted", "archived"]);
|
|
2464
|
+
function resolveLivenessActor() { return (process.env.FLOW_AGENTS_ACTOR || "").trim() || "local"; }
|
|
2465
|
+
function livenessEnabled() { const v = String(process.env.FLOW_AGENTS_LIVENESS || "").trim().toLowerCase(); return v === "on" || v === "1" || v === "true"; }
|
|
2466
|
+
function livenessLifecycle(taskDir, slug, kind, timestamp) {
|
|
2467
|
+
if (!livenessEnabled())
|
|
2468
|
+
return;
|
|
2469
|
+
try {
|
|
2470
|
+
const root = path.dirname(taskDir); // .flow-agents/<slug> → .flow-agents (the shared liveness stream lives here)
|
|
2471
|
+
const evt = { type: kind, subjectId: slug, actor: resolveLivenessActor(), at: timestamp, source: "lifecycle" };
|
|
2472
|
+
if (kind === "claim")
|
|
2473
|
+
evt.ttlSeconds = 1800;
|
|
2474
|
+
appendLivenessEvent(root, evt);
|
|
2475
|
+
}
|
|
2476
|
+
catch { /* best-effort; liveness is advisory and must never break the workflow */ }
|
|
2477
|
+
}
|
|
2478
|
+
async function liveness(p) {
|
|
2479
|
+
const root = path.resolve(opt(p, "artifact-root", ".flow-agents"));
|
|
2480
|
+
const action = p.positional[0] || "";
|
|
2481
|
+
const subjectId = p.positional[1] || "";
|
|
2482
|
+
const actor = opt(p, "actor", process.env.FLOW_AGENTS_ACTOR || "unknown");
|
|
2483
|
+
const nowIso = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
2484
|
+
if (action === "claim" || action === "heartbeat" || action === "release") {
|
|
2485
|
+
if (!subjectId)
|
|
2486
|
+
die(`liveness ${action} requires a subjectId`);
|
|
2487
|
+
const evt = { type: action, subjectId, actor, at: opt(p, "at") || nowIso };
|
|
2488
|
+
if (action === "claim")
|
|
2489
|
+
evt.ttlSeconds = Number.parseInt(opt(p, "ttl", "1800"), 10) || 1800;
|
|
2490
|
+
appendLivenessEvent(root, evt);
|
|
2491
|
+
console.log(`liveness ${action}: ${subjectId} by ${actor}`);
|
|
2492
|
+
return 0;
|
|
2493
|
+
}
|
|
2494
|
+
if (action === "status") {
|
|
2495
|
+
const surface = (await import("@kontourai/surface"));
|
|
2496
|
+
if (typeof surface.deriveTrustStatus !== "function")
|
|
2497
|
+
die("@kontourai/surface deriveTrustStatus unavailable — requires surface >= 1.2");
|
|
2498
|
+
const subjectFilter = opt(p, "subject");
|
|
2499
|
+
const now = opt(p, "now") ? new Date(opt(p, "now")) : new Date();
|
|
2500
|
+
// Group events by subjectId::actor — one liveness claim per holder of a subject.
|
|
2501
|
+
const groups = new Map();
|
|
2502
|
+
for (const e of readLivenessEvents(root)) {
|
|
2503
|
+
if (!e.subjectId || !e.actor)
|
|
2504
|
+
continue;
|
|
2505
|
+
const key = `${e.subjectId}::${e.actor}`;
|
|
2506
|
+
let g = groups.get(key);
|
|
2507
|
+
if (!g) {
|
|
2508
|
+
g = { subjectId: String(e.subjectId), actor: String(e.actor), ttlSeconds: 1800, created: String(e.at), updated: String(e.at), events: [] };
|
|
2509
|
+
groups.set(key, g);
|
|
2510
|
+
}
|
|
2511
|
+
g.updated = String(e.at);
|
|
2512
|
+
if (e.type === "claim") {
|
|
2513
|
+
g.ttlSeconds = Number(e.ttlSeconds) || g.ttlSeconds;
|
|
2514
|
+
g.events.push({ id: `c:${key}:${e.at}`, claimId: key, status: "verified", actor: g.actor, method: "observation", evidenceIds: [], createdAt: e.at, verifiedAt: e.at });
|
|
2515
|
+
}
|
|
2516
|
+
else if (e.type === "heartbeat") {
|
|
2517
|
+
g.events.push({ id: `h:${key}:${e.at}`, claimId: key, status: "verified", actor: g.actor, method: "observation", evidenceIds: [], createdAt: e.at, verifiedAt: e.at });
|
|
2518
|
+
}
|
|
2519
|
+
else if (e.type === "release") {
|
|
2520
|
+
g.events.push({ id: `r:${key}:${e.at}`, claimId: key, status: "revoked", type: "invalidation", actor: g.actor, method: "observation", evidenceIds: [], createdAt: e.at, verifiedAt: e.at });
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
const rows = [];
|
|
2524
|
+
for (const g of groups.values()) {
|
|
2525
|
+
if (subjectFilter && g.subjectId !== subjectFilter)
|
|
2526
|
+
continue;
|
|
2527
|
+
const claim = { id: `${g.subjectId}::${g.actor}`, subjectType: "work-item", subjectId: g.subjectId, surface: "flow.liveness", claimType: "liveness.hold", fieldOrBehavior: "held-by", value: g.actor, createdAt: g.created, updatedAt: g.updated, ttlSeconds: g.ttlSeconds, verificationPolicyId: LIVENESS_POLICY.id };
|
|
2528
|
+
const status = surface.deriveTrustStatus({ claim, evidence: [], policy: LIVENESS_POLICY, events: g.events, now });
|
|
2529
|
+
rows.push({ subjectId: g.subjectId, actor: g.actor, status, label: livenessLabel(status) });
|
|
2530
|
+
}
|
|
2531
|
+
if (p.flags.has("json")) {
|
|
2532
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
2533
|
+
return 0;
|
|
2534
|
+
}
|
|
2535
|
+
for (const r of rows)
|
|
2536
|
+
console.log(`${r.subjectId}\t${r.actor}\t${r.label}`);
|
|
2537
|
+
return 0;
|
|
2538
|
+
}
|
|
2539
|
+
die("liveness action must be one of: claim | heartbeat | release | status");
|
|
2540
|
+
return 1;
|
|
2541
|
+
}
|
|
2542
|
+
/**
|
|
2543
|
+
* Build a structured explanation for a specific claim.
|
|
2544
|
+
* PURE: report + bundle + id in, structured explanation out.
|
|
2545
|
+
* No fs, no CLI, no .flow-agents paths. Promotable to Surface #171.
|
|
2546
|
+
*
|
|
2547
|
+
* @param report TrustReport from buildTrustReport(bundle) — required for derived status
|
|
2548
|
+
* @param bundle Raw parsed trust.bundle (BundleFile shape)
|
|
2549
|
+
* @param claimId The claim id to explain
|
|
2550
|
+
*/
|
|
2551
|
+
export function buildClaimExplanation(report, bundle, claimId) {
|
|
2552
|
+
const reportClaims = Array.isArray(report.claims) ? report.claims : [];
|
|
2553
|
+
const reportClaim = reportClaims.find((c) => c.id === claimId);
|
|
2554
|
+
if (!reportClaim) {
|
|
2555
|
+
return {
|
|
2556
|
+
found: false,
|
|
2557
|
+
status: "unknown",
|
|
2558
|
+
value: "",
|
|
2559
|
+
claimType: "",
|
|
2560
|
+
evidence: [],
|
|
2561
|
+
policy: null,
|
|
2562
|
+
why: { directInputs: [], leafClaims: [], diagnostics: [], transparencyGaps: [], changeRecords: [] },
|
|
2563
|
+
};
|
|
2564
|
+
}
|
|
2565
|
+
const bundleClaims = Array.isArray(bundle.claims) ? bundle.claims : [];
|
|
2566
|
+
const bundleClaim = bundleClaims.find((c) => c.id === claimId) ?? reportClaim;
|
|
2567
|
+
const bundlePolicies = Array.isArray(bundle.policies) ? bundle.policies : [];
|
|
2568
|
+
const bundleEvidence = Array.isArray(bundle.evidence) ? bundle.evidence : [];
|
|
2569
|
+
// Governing policy — follow verificationPolicyId into bundle.policies[]
|
|
2570
|
+
const verificationPolicyId = typeof bundleClaim.verificationPolicyId === "string" ? bundleClaim.verificationPolicyId : undefined;
|
|
2571
|
+
const rawPolicy = verificationPolicyId ? bundlePolicies.find((p) => p.id === verificationPolicyId) : undefined;
|
|
2572
|
+
const policy = rawPolicy
|
|
2573
|
+
? {
|
|
2574
|
+
id: String(rawPolicy.id ?? ""),
|
|
2575
|
+
requiredEvidence: Array.isArray(rawPolicy.requiredEvidence) ? rawPolicy.requiredEvidence : [],
|
|
2576
|
+
requiredMethods: Array.isArray(rawPolicy.requiredMethods) ? rawPolicy.requiredMethods : undefined,
|
|
2577
|
+
acceptanceCriteria: Array.isArray(rawPolicy.acceptanceCriteria) ? rawPolicy.acceptanceCriteria : [],
|
|
2578
|
+
reviewAuthority: String(rawPolicy.reviewAuthority ?? ""),
|
|
2579
|
+
}
|
|
2580
|
+
: null;
|
|
2581
|
+
// Evidence enhancement: pull evidence items for this claim, surface the execution block
|
|
2582
|
+
const claimEvidenceItems = bundleEvidence.filter((ev) => ev && ev.claimId === claimId);
|
|
2583
|
+
const evidence = claimEvidenceItems.map((ev) => {
|
|
2584
|
+
const exec = ev.execution && typeof ev.execution === "object" ? ev.execution : null;
|
|
2585
|
+
const execution = exec
|
|
2586
|
+
? {
|
|
2587
|
+
runner: String(exec.runner ?? exec.label ?? ""),
|
|
2588
|
+
label: String(exec.label ?? exec.runner ?? ""),
|
|
2589
|
+
isError: Boolean(exec.isError ?? (typeof exec.exitCode === "number" && exec.exitCode !== 0)),
|
|
2590
|
+
exitCode: typeof exec.exitCode === "number" ? exec.exitCode : null,
|
|
2591
|
+
}
|
|
2592
|
+
: null;
|
|
2593
|
+
return {
|
|
2594
|
+
evidenceType: String(ev.evidenceType ?? ev.type ?? "unknown"),
|
|
2595
|
+
label: String(ev.label ?? ev.excerptOrSummary ?? ev.sourceRef ?? ev.id ?? ""),
|
|
2596
|
+
execution,
|
|
2597
|
+
passing: execution ? !execution.isError : String(ev.status ?? "") !== "disputed",
|
|
2598
|
+
summary: String(ev.excerptOrSummary ?? ev.summary ?? ev.label ?? ""),
|
|
2599
|
+
};
|
|
2600
|
+
});
|
|
2601
|
+
// Drilldown: extract from report structure (report.transparencyGaps, report.changeRecords)
|
|
2602
|
+
const allGaps = Array.isArray(report.transparencyGaps) ? report.transparencyGaps : [];
|
|
2603
|
+
const allChanges = Array.isArray(report.changeRecords) ? report.changeRecords : [];
|
|
2604
|
+
const transparencyGaps = allGaps.filter((g) => g && g.claimId === claimId);
|
|
2605
|
+
const changeRecords = allChanges.filter((c) => c && c.claimId === claimId);
|
|
2606
|
+
return {
|
|
2607
|
+
found: true,
|
|
2608
|
+
status: String(reportClaim.status ?? "unknown"),
|
|
2609
|
+
value: String(bundleClaim.value ?? reportClaim.value ?? ""),
|
|
2610
|
+
claimType: String(bundleClaim.claimType ?? reportClaim.claimType ?? ""),
|
|
2611
|
+
evidence,
|
|
2612
|
+
policy,
|
|
2613
|
+
why: {
|
|
2614
|
+
directInputs: [], // populated by buildDerivationDrilldown if non-leaf
|
|
2615
|
+
leafClaims: [],
|
|
2616
|
+
diagnostics: [],
|
|
2617
|
+
transparencyGaps,
|
|
2618
|
+
changeRecords,
|
|
2619
|
+
},
|
|
2620
|
+
};
|
|
2621
|
+
}
|
|
2622
|
+
/**
|
|
2623
|
+
* claim <id> <dir>
|
|
2624
|
+
*
|
|
2625
|
+
* Look up a specific claim in the session's trust.bundle and print:
|
|
2626
|
+
* - Derived status and raw value
|
|
2627
|
+
* - Failing evidence items (with execution block: runner, exitCode, isError)
|
|
2628
|
+
* - Governing VerificationPolicy (how-to-verify)
|
|
2629
|
+
* - Derivation drilldown / transparency gaps (why it is in that state)
|
|
2630
|
+
*
|
|
2631
|
+
* --json Emit the structured ClaimExplanation object instead of text.
|
|
2632
|
+
*
|
|
2633
|
+
* Usage: workflow-sidecar claim <claimId> <artifactDir>
|
|
2634
|
+
*/
|
|
2635
|
+
async function claimLookup(p) {
|
|
2636
|
+
const claimId = p.positional[0] || die("claim id is required (first positional argument)");
|
|
2637
|
+
const rawDir = p.positional[1] || die("artifact directory is required (second positional argument)");
|
|
2638
|
+
const dir = path.resolve(rawDir);
|
|
2639
|
+
const bundlePath = path.join(dir, "trust.bundle");
|
|
2640
|
+
if (!fs.existsSync(bundlePath)) {
|
|
2641
|
+
process.stderr.write(`[claim] no trust.bundle at ${bundlePath} — run record-evidence first
|
|
2642
|
+
`);
|
|
2643
|
+
return 1;
|
|
2644
|
+
}
|
|
2645
|
+
const bundle = JSON.parse(fs.readFileSync(bundlePath, "utf8"));
|
|
2646
|
+
const bundleClaims = Array.isArray(bundle.claims) ? bundle.claims : [];
|
|
2647
|
+
const bundleClaim = bundleClaims.find((c) => c.id === claimId);
|
|
2648
|
+
if (!bundleClaim) {
|
|
2649
|
+
const available = bundleClaims.map((c) => c.id).join("\n ");
|
|
2650
|
+
process.stderr.write(`[claim] unknown claim id: ${claimId}
|
|
2651
|
+
Available claim ids:
|
|
2652
|
+
${available || "(none — bundle has no claims)"}
|
|
2653
|
+
`);
|
|
2654
|
+
return 1;
|
|
2655
|
+
}
|
|
2656
|
+
// Load Surface via tryLoadSurface() (ESM, cached, fail-open pattern)
|
|
2657
|
+
const surface = await tryLoadSurface();
|
|
2658
|
+
if (!surface || typeof surface.buildTrustReport !== "function" || typeof surface.buildDerivationDrilldown !== "function") {
|
|
2659
|
+
process.stderr.write(`[claim] @kontourai/surface unavailable or missing buildTrustReport/buildDerivationDrilldown
|
|
2660
|
+
`);
|
|
2661
|
+
return 0; // fail-open, consistent with gate-review pattern
|
|
2662
|
+
}
|
|
2663
|
+
// Build TrustReport (required — buildDerivationDrilldown needs TrustReport, not TrustBundle)
|
|
2664
|
+
const report = surface.buildTrustReport(bundle);
|
|
2665
|
+
// Build the structured explanation (pure, promotable to #171)
|
|
2666
|
+
const explanation = buildClaimExplanation(report, bundle, claimId);
|
|
2667
|
+
// Enrich the why.directInputs/leafClaims/diagnostics from the drilldown
|
|
2668
|
+
try {
|
|
2669
|
+
const drilldown = surface.buildDerivationDrilldown(report, claimId);
|
|
2670
|
+
if (drilldown) {
|
|
2671
|
+
explanation.why.directInputs = Array.isArray(drilldown.directInputs) ? drilldown.directInputs : [];
|
|
2672
|
+
explanation.why.leafClaims = Array.isArray(drilldown.leafClaims) ? drilldown.leafClaims : [];
|
|
2673
|
+
explanation.why.diagnostics = Array.isArray(drilldown.diagnostics) ? drilldown.diagnostics : [];
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
catch {
|
|
2677
|
+
// buildDerivationDrilldown threw (e.g. claim not in report) — proceed without drilldown
|
|
2678
|
+
}
|
|
2679
|
+
if (p.flags.has("json")) {
|
|
2680
|
+
console.log(JSON.stringify(explanation, null, 2));
|
|
2681
|
+
return 0;
|
|
2682
|
+
}
|
|
2683
|
+
// ── Human-readable output ───────────────────────────────────────────────────
|
|
2684
|
+
const lines = [];
|
|
2685
|
+
lines.push(`Claim: ${claimId}`);
|
|
2686
|
+
lines.push(`Status: ${explanation.status} Value: ${explanation.value}`);
|
|
2687
|
+
lines.push(`Type: ${explanation.claimType}`);
|
|
2688
|
+
lines.push("");
|
|
2689
|
+
// Evidence section — failing items are the concrete "why disputed"
|
|
2690
|
+
const failingEvidence = explanation.evidence.filter((ev) => !ev.passing);
|
|
2691
|
+
const allEvidence = explanation.evidence;
|
|
2692
|
+
if (allEvidence.length > 0) {
|
|
2693
|
+
lines.push("Evidence:");
|
|
2694
|
+
for (const ev of allEvidence) {
|
|
2695
|
+
const passMark = ev.passing ? "pass" : "FAIL";
|
|
2696
|
+
const execStr = ev.execution
|
|
2697
|
+
? ` [runner: ${ev.execution.runner}, exitCode: ${ev.execution.exitCode ?? "?"}, isError: ${ev.execution.isError}]`
|
|
2698
|
+
: "";
|
|
2699
|
+
lines.push(` [${passMark}] ${ev.evidenceType}: ${ev.label || ev.summary}${execStr}`);
|
|
2700
|
+
}
|
|
2701
|
+
if (failingEvidence.length > 0) {
|
|
2702
|
+
lines.push("");
|
|
2703
|
+
lines.push(`Failing evidence (disputed because):`);
|
|
2704
|
+
for (const ev of failingEvidence) {
|
|
2705
|
+
const execStr = ev.execution
|
|
2706
|
+
? ` ${ev.execution.runner} exited ${ev.execution.exitCode ?? "?"} (isError: ${ev.execution.isError})`
|
|
2707
|
+
: "";
|
|
2708
|
+
lines.push(` ${ev.evidenceType}: ${ev.label || ev.summary}${execStr}`);
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
else {
|
|
2713
|
+
lines.push("Evidence: (none recorded for this claim)");
|
|
2714
|
+
}
|
|
2715
|
+
lines.push("");
|
|
2716
|
+
// Policy section — how-to-verify
|
|
2717
|
+
if (explanation.policy) {
|
|
2718
|
+
const pol = explanation.policy;
|
|
2719
|
+
lines.push(`Governing Policy (${pol.id}):`);
|
|
2720
|
+
lines.push(` requiredEvidence: [${pol.requiredEvidence.join(", ")}]`);
|
|
2721
|
+
if (pol.requiredMethods && pol.requiredMethods.length > 0) {
|
|
2722
|
+
lines.push(` requiredMethods: [${pol.requiredMethods.join(", ")}]`);
|
|
2723
|
+
}
|
|
2724
|
+
lines.push(` acceptanceCriteria: [${pol.acceptanceCriteria.join(" | ")}]`);
|
|
2725
|
+
lines.push(` reviewAuthority: ${pol.reviewAuthority}`);
|
|
2726
|
+
}
|
|
2727
|
+
else {
|
|
2728
|
+
lines.push("Governing Policy: (none — claim has no verificationPolicyId or policy not found in bundle)");
|
|
2729
|
+
}
|
|
2730
|
+
lines.push("");
|
|
2731
|
+
// Why section — derivation drilldown + transparency gaps
|
|
2732
|
+
lines.push("Derivation Drilldown:");
|
|
2733
|
+
if (explanation.why.directInputs.length > 0) {
|
|
2734
|
+
lines.push(` Direct inputs: ${explanation.why.directInputs.length} claim(s)`);
|
|
2735
|
+
for (const inp of explanation.why.directInputs) {
|
|
2736
|
+
const inpStatus = typeof inp.claim === "object" && inp.claim ? String(inp.claim.status ?? "?") : "?";
|
|
2737
|
+
lines.push(` - ${inp.inputClaimId ?? "?"} (status: ${inpStatus})`);
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
else {
|
|
2741
|
+
lines.push(" Direct inputs: (none — leaf claim)");
|
|
2742
|
+
}
|
|
2743
|
+
if (explanation.why.leafClaims.length > 0) {
|
|
2744
|
+
lines.push(` Leaf claims: ${explanation.why.leafClaims.length} claim(s)`);
|
|
2745
|
+
}
|
|
2746
|
+
if (explanation.why.diagnostics.length > 0) {
|
|
2747
|
+
lines.push(` Diagnostics: ${explanation.why.diagnostics.length}`);
|
|
2748
|
+
for (const d of explanation.why.diagnostics) {
|
|
2749
|
+
lines.push(` - ${d.type ?? "?"}: ${d.message ?? ""}`);
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
if (explanation.why.transparencyGaps.length > 0) {
|
|
2753
|
+
lines.push(` Transparency gaps: ${explanation.why.transparencyGaps.length}`);
|
|
2754
|
+
for (const g of explanation.why.transparencyGaps) {
|
|
2755
|
+
lines.push(` - [${g.severity ?? "?"}] ${g.type ?? "?"}: ${g.message ?? ""}`);
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
else {
|
|
2759
|
+
lines.push(" Transparency gaps: (none)");
|
|
2760
|
+
}
|
|
2761
|
+
if (explanation.why.changeRecords.length > 0) {
|
|
2762
|
+
lines.push(` Change records: ${explanation.why.changeRecords.length}`);
|
|
2763
|
+
for (const cr of explanation.why.changeRecords) {
|
|
2764
|
+
lines.push(` - ${cr.action ?? "?"} at ${cr.at ?? cr.createdAt ?? "?"}`);
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
console.log(lines.join("\n"));
|
|
2768
|
+
return 0;
|
|
2769
|
+
}
|
|
2770
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
874
2771
|
async function main() {
|
|
875
2772
|
const p = parseArgs(process.argv.slice(2));
|
|
876
2773
|
if (!p.command)
|
|
877
2774
|
die("workflow-sidecar command is required");
|
|
878
|
-
const lockRoot = ["ensure-session", "current", "dogfood-pass"].includes(p.command) ? path.resolve(opt(p, "artifact-root", ".flow-agents")) : p.command === "record-agent-event" ? explicitArtifactRoot(p) : p.positional[0] ? artifactDirFrom(p.positional[0]) : "";
|
|
2775
|
+
const lockRoot = ["ensure-session", "current", "dogfood-pass", "liveness"].includes(p.command) ? path.resolve(opt(p, "artifact-root", ".flow-agents")) : p.command === "record-agent-event" ? explicitArtifactRoot(p) : p.command === "claim" ? (p.positional[1] ? path.resolve(p.positional[1]) : "") : p.positional[0] ? artifactDirFrom(p.positional[0]) : "";
|
|
879
2776
|
return withLock(lockRoot, ["ensure-session", "record-agent-event", "dogfood-pass"].includes(p.command), p.command, () => {
|
|
880
2777
|
switch (p.command) {
|
|
881
2778
|
case "ensure-session": return ensureSession(p);
|
|
@@ -883,12 +2780,20 @@ async function main() {
|
|
|
883
2780
|
case "record-agent-event": return recordAgentEvent(p);
|
|
884
2781
|
case "init-plan": return initPlan(p);
|
|
885
2782
|
case "record-evidence": return recordEvidence(p);
|
|
2783
|
+
case "record-gate-claim": return recordGateClaim(p);
|
|
886
2784
|
case "advance-state": return advanceState(p);
|
|
887
2785
|
case "record-critique": return recordCritique(p);
|
|
888
2786
|
case "import-critique": return importCritique(p);
|
|
889
2787
|
case "record-release": return recordRelease(p);
|
|
890
2788
|
case "record-learning": return recordLearning(p);
|
|
891
2789
|
case "dogfood-pass": return dogfoodPass(p);
|
|
2790
|
+
case "gate-review": return gateReview(p);
|
|
2791
|
+
case "render-trust-panel": return renderTrustPanel(p);
|
|
2792
|
+
case "trust-mcp": return trustMcp(p);
|
|
2793
|
+
case "liveness": return liveness(p);
|
|
2794
|
+
case "claim": return claimLookup(p);
|
|
2795
|
+
case "seal-checkpoint": return sealCheckpoint(p);
|
|
2796
|
+
case "publish-delivery": return publishDeliveryCmd(p);
|
|
892
2797
|
default: die(`unknown command: ${p.command}`);
|
|
893
2798
|
}
|
|
894
2799
|
});
|