@kontourai/flow-agents 1.3.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/CODEOWNERS +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/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/console-learning-projection.d.ts +1 -0
- package/build/src/cli/effective-backlog-settings.d.ts +1 -0
- package/build/src/cli/fixture-retirement-audit.d.ts +2 -0
- package/build/src/cli/init.d.ts +17 -0
- package/build/src/cli/init.js +242 -20
- package/build/src/cli/kit.d.ts +1 -0
- package/build/src/cli/promote-workflow-artifact.d.ts +1 -0
- package/build/src/cli/publish-change-helper.d.ts +1 -0
- package/build/src/cli/pull-work-provider.d.ts +1 -0
- package/build/src/cli/runtime-adapter.d.ts +1 -0
- package/build/src/cli/telemetry-doctor.d.ts +1 -0
- package/build/src/cli/usage-feedback.d.ts +1 -0
- package/build/src/cli/utterance-check.d.ts +1 -0
- package/build/src/cli/validate-hook-influence.d.ts +1 -0
- package/build/src/cli/validate-source-tree.d.ts +1 -0
- package/build/src/cli/validate-workflow-artifacts.d.ts +2 -0
- 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/veritas-governance.d.ts +1 -0
- package/build/src/cli/workflow-artifact-cleanup-audit.d.ts +1 -0
- package/build/src/cli/workflow-sidecar.d.ts +324 -0
- package/build/src/cli/workflow-sidecar.js +1973 -90
- package/build/src/cli.d.ts +2 -0
- package/build/src/cli.js +2 -3
- package/build/src/flow-kit/validate.d.ts +81 -0
- package/build/src/index.d.ts +5 -0
- package/build/src/index.js +36 -0
- package/build/src/lib/args.d.ts +8 -0
- package/build/src/lib/flow-resolver.d.ts +82 -0
- package/build/src/lib/flow-resolver.js +237 -0
- package/build/src/lib/fs.d.ts +7 -0
- package/build/src/lib/workflow-learning-projection.d.ts +132 -0
- package/build/src/runtime-adapters.d.ts +18 -0
- package/build/src/tools/build-universal-bundles.d.ts +2 -0
- package/build/src/tools/build-universal-bundles.js +34 -22
- package/build/src/tools/common.d.ts +9 -0
- package/build/src/tools/generate-context-map.d.ts +2 -0
- package/build/src/tools/generate-context-map.js +3 -16
- package/build/src/tools/validate-package.d.ts +2 -0
- package/build/src/tools/validate-source-tree.d.ts +2 -0
- 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/developer-architecture.md +14 -0
- 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 +78 -10
- 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 +54 -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_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_hook_category_behaviors.sh +14 -0
- package/evals/integration/test_install_merge.sh +1176 -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_library_exports.sh +85 -0
- package/evals/static/test_universal_bundles.sh +15 -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 +13 -4
- 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 +1471 -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/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 +2093 -84
- package/src/cli.ts +2 -3
- package/src/index.ts +53 -0
- package/src/lib/flow-resolver.ts +284 -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/tsconfig.json +1 -0
- 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,27 +2,86 @@
|
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
const
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
// ADR 0016 Abstraction A: shared FlowDefinition resolver (P-a)
|
|
9
|
+
import { resolveActiveFlowStep, resolveFlowFilePath, resolvePhaseMap } from "../lib/flow-resolver.js";
|
|
10
|
+
export const statuses = new Set(["new", "planning", "planned", "in_progress", "blocked", "verifying", "verified", "needs_decision", "not_verified", "failed", "delivered", "accepted", "archived"]);
|
|
11
|
+
export const phases = ["idea", "backlog", "pickup", "planning", "execution", "verification", "goal_fit", "evidence", "release", "learning", "done"];
|
|
12
|
+
export const checkKinds = new Set(["build", "types", "lint", "test", "security", "diff", "browser", "runtime", "policy", "external"]);
|
|
13
|
+
export const checkStatuses = new Set(["pass", "fail", "not_verified", "skip"]);
|
|
14
|
+
export const verdicts = new Set(["pass", "partial", "fail", "not_verified"]);
|
|
11
15
|
function now() { return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); }
|
|
12
16
|
function read(file) { return fs.readFileSync(file, "utf8"); }
|
|
13
|
-
function writeJson(file, payload) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`); }
|
|
17
|
+
export function writeJson(file, payload) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`); }
|
|
14
18
|
function printJson(payload) { console.log(JSON.stringify(payload).replace(/":/g, '": ').replace(/,"/g, ', "')); }
|
|
15
|
-
function loadJson(file, fallback = {}) { return fs.existsSync(file) ? JSON.parse(read(file)) : { ...fallback }; }
|
|
16
|
-
function appendJsonl(file, payload) {
|
|
19
|
+
export function loadJson(file, fallback = {}) { return fs.existsSync(file) ? JSON.parse(read(file)) : { ...fallback }; }
|
|
20
|
+
export function appendJsonl(file, payload) {
|
|
17
21
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
18
22
|
const line = JSON.stringify(payload, Object.keys(payload).sort()).replace(/":/g, '": ').replace(/,"/g, ', "');
|
|
19
23
|
fs.appendFileSync(file, `${line}\n`);
|
|
20
24
|
}
|
|
21
25
|
function die(message) { throw new Error(message); }
|
|
22
26
|
function slugify(value, fallback) { return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || fallback; }
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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;
|
|
26
85
|
try {
|
|
27
86
|
const _require = createRequire(import.meta.url);
|
|
28
87
|
const hachureDir = path.dirname(_require.resolve("hachure"));
|
|
@@ -34,18 +93,20 @@ function tryLoadHachureValidator() {
|
|
|
34
93
|
continue;
|
|
35
94
|
schemas[file] = JSON.parse(fs.readFileSync(path.join(schemasDir, file), "utf8"));
|
|
36
95
|
}
|
|
96
|
+
const inquiryRecordSchema = schemas["inquiry-record.schema.json"];
|
|
97
|
+
if (!inquiryRecordSchema) {
|
|
98
|
+
_hachureInquiryRecordValidator = null;
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
37
101
|
const ajv = new Ajv({ strict: false, allErrors: true });
|
|
38
102
|
for (const [filename, schema] of Object.entries(schemas)) {
|
|
39
|
-
if (filename === "
|
|
103
|
+
if (filename === "inquiry-record.schema.json")
|
|
40
104
|
continue;
|
|
41
105
|
ajv.addSchema(schema, filename);
|
|
42
106
|
}
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const validate = ajv.compile(trustBundleSchema);
|
|
47
|
-
return (bundle) => {
|
|
48
|
-
const valid = validate(bundle);
|
|
107
|
+
const validate = ajv.compile(inquiryRecordSchema);
|
|
108
|
+
_hachureInquiryRecordValidator = (record) => {
|
|
109
|
+
const valid = validate(record);
|
|
49
110
|
if (valid)
|
|
50
111
|
return { valid: true, errors: [] };
|
|
51
112
|
const errors = (validate.errors ?? []).map((err) => {
|
|
@@ -54,17 +115,412 @@ function tryLoadHachureValidator() {
|
|
|
54
115
|
});
|
|
55
116
|
return { valid: false, errors };
|
|
56
117
|
};
|
|
118
|
+
return _hachureInquiryRecordValidator;
|
|
57
119
|
}
|
|
58
120
|
catch {
|
|
121
|
+
_hachureInquiryRecordValidator = null;
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
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.
|
|
129
|
+
*/
|
|
130
|
+
export function validateInquiryRecord(record) {
|
|
131
|
+
const validate = getHachureInquiryRecordValidator();
|
|
132
|
+
if (!validate)
|
|
133
|
+
return { valid: true, errors: [], available: false };
|
|
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"
|
|
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
|
+
}
|
|
59
333
|
return null;
|
|
60
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
|
+
}
|
|
61
510
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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}`);
|
|
67
522
|
}
|
|
523
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
68
524
|
function safeRepoIdentifier(value) {
|
|
69
525
|
const trimmed = value.trim().replace(/\.git$/, "");
|
|
70
526
|
if (!trimmed || trimmed.length > 120)
|
|
@@ -119,7 +575,7 @@ function repoIdentifier() {
|
|
|
119
575
|
}
|
|
120
576
|
return safeRepoIdentifier(path.basename(process.cwd())) || "workspace";
|
|
121
577
|
}
|
|
122
|
-
function sidecarBase(slug) {
|
|
578
|
+
export function sidecarBase(slug) {
|
|
123
579
|
return { schema_version: "1.0", task_slug: slug, repo: repoIdentifier() };
|
|
124
580
|
}
|
|
125
581
|
function parseArgs(argv) {
|
|
@@ -197,7 +653,7 @@ async function withLock(dir, create, command, body) {
|
|
|
197
653
|
if (create)
|
|
198
654
|
fs.mkdirSync(dir, { recursive: true });
|
|
199
655
|
if (!fs.existsSync(dir))
|
|
200
|
-
return body();
|
|
656
|
+
return await body();
|
|
201
657
|
const lockDir = path.join(dir, ".workflow-sidecar.lockdir");
|
|
202
658
|
const staleMs = Number(process.env.FLOW_AGENTS_WORKFLOW_SIDECAR_STALE_LOCK_MS ?? 5 * 60 * 1000);
|
|
203
659
|
const deadline = Date.now() + 30000;
|
|
@@ -232,7 +688,7 @@ async function withLock(dir, create, command, body) {
|
|
|
232
688
|
const delay = process.env.FLOW_AGENTS_WORKFLOW_SIDECAR_LOCK_DELAY;
|
|
233
689
|
if (delay)
|
|
234
690
|
await new Promise((resolve) => setTimeout(resolve, Number(delay) * 1000));
|
|
235
|
-
return body();
|
|
691
|
+
return await body();
|
|
236
692
|
}
|
|
237
693
|
finally {
|
|
238
694
|
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
@@ -305,7 +761,63 @@ function validateAgentId(agent) {
|
|
|
305
761
|
die("--agent-id must be a conservative slug");
|
|
306
762
|
return agent;
|
|
307
763
|
}
|
|
308
|
-
|
|
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) {
|
|
309
821
|
writeJson(path.join(root, "current.json"), {
|
|
310
822
|
schema_version: "1.0",
|
|
311
823
|
active_slug: path.basename(dir),
|
|
@@ -314,6 +826,11 @@ function writeCurrent(root, dir, timestamp, owner, source) {
|
|
|
314
826
|
owner,
|
|
315
827
|
source,
|
|
316
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 } : {}),
|
|
317
834
|
});
|
|
318
835
|
}
|
|
319
836
|
function loadCurrent(root) {
|
|
@@ -358,7 +875,7 @@ function initSidecars(dir, slug, sourceRequest, summary, nextAction, timestamp,
|
|
|
358
875
|
}
|
|
359
876
|
function ensureSession(p) {
|
|
360
877
|
const root = path.resolve(opt(p, "artifact-root", ".flow-agents"));
|
|
361
|
-
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)"));
|
|
362
879
|
const dir = sessionDirFor(root, slug);
|
|
363
880
|
fs.mkdirSync(dir, { recursive: true });
|
|
364
881
|
const timestamp = opt(p, "timestamp", now());
|
|
@@ -370,7 +887,22 @@ function ensureSession(p) {
|
|
|
370
887
|
if (!fs.existsSync(path.join(dir, "state.json")) || !fs.existsSync(path.join(dir, "acceptance.json")) || !fs.existsSync(path.join(dir, "handoff.json"))) {
|
|
371
888
|
initSidecars(dir, slug, opt(p, "source-request"), opt(p, "summary"), opt(p, "next-action", "Continue."), timestamp, md);
|
|
372
889
|
}
|
|
373
|
-
|
|
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);
|
|
374
906
|
console.log(dir);
|
|
375
907
|
return 0;
|
|
376
908
|
}
|
|
@@ -406,6 +938,7 @@ function initPlan(p) {
|
|
|
406
938
|
const dir = artifactDirFrom(artifact);
|
|
407
939
|
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
408
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()));
|
|
409
942
|
return 0;
|
|
410
943
|
}
|
|
411
944
|
function parseJson(value, label) {
|
|
@@ -428,7 +961,7 @@ function hasNonEmptyString(value) {
|
|
|
428
961
|
function hasPositiveInteger(value) {
|
|
429
962
|
return Number.isInteger(value) && Number(value) >= 1;
|
|
430
963
|
}
|
|
431
|
-
function validateEvidenceRef(ref, label) {
|
|
964
|
+
export function validateEvidenceRef(ref, label) {
|
|
432
965
|
if (!["source", "command", "artifact", "provider", "external"].includes(ref.kind))
|
|
433
966
|
die(`${label} entry kind must be one of: source, command, artifact, provider, external`);
|
|
434
967
|
for (const key of Object.keys(ref))
|
|
@@ -458,7 +991,7 @@ function validateEvidenceRef(ref, label) {
|
|
|
458
991
|
die(`${label} ${ref.kind} refs require url`);
|
|
459
992
|
return ref;
|
|
460
993
|
}
|
|
461
|
-
function normalizeEvidenceRefs(raw, label) {
|
|
994
|
+
export function normalizeEvidenceRefs(raw, label) {
|
|
462
995
|
if (!Array.isArray(raw))
|
|
463
996
|
die(`${label} must be an array`);
|
|
464
997
|
return raw.map((ref) => {
|
|
@@ -469,7 +1002,7 @@ function normalizeEvidenceRefs(raw, label) {
|
|
|
469
1002
|
return validateEvidenceRef({ ...ref }, label);
|
|
470
1003
|
});
|
|
471
1004
|
}
|
|
472
|
-
function normalizeCheck(raw) {
|
|
1005
|
+
export function normalizeCheck(raw) {
|
|
473
1006
|
const check = { ...raw };
|
|
474
1007
|
if (!check.id || !check.kind || !check.status || !check.summary)
|
|
475
1008
|
die("check requires id, kind, status, and summary");
|
|
@@ -490,7 +1023,10 @@ function normalizeCheck(raw) {
|
|
|
490
1023
|
function normalizeSurfaceRefs(refs) {
|
|
491
1024
|
if (!Array.isArray(refs))
|
|
492
1025
|
die("surface_trust_refs must be an array");
|
|
493
|
-
|
|
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;
|
|
494
1030
|
return refs.map((ref) => {
|
|
495
1031
|
const keys = JSON.stringify(ref).match(/"([^"]+)":/g) ?? [];
|
|
496
1032
|
for (const key of keys.map((k) => k.slice(1, -2)))
|
|
@@ -500,19 +1036,21 @@ function normalizeSurfaceRefs(refs) {
|
|
|
500
1036
|
// trust.bundle is the canonical Hachure-aligned artifact kind; TrustReport/Trust Snapshot are legacy aliases
|
|
501
1037
|
if (!["trust.bundle", "TrustReport", "Trust Snapshot"].includes(out.artifact_kind))
|
|
502
1038
|
die("artifact_kind must be one of: trust.bundle, TrustReport, Trust Snapshot");
|
|
503
|
-
// When
|
|
504
|
-
|
|
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)) {
|
|
505
1042
|
try {
|
|
506
1043
|
const bundle = JSON.parse(fs.readFileSync(out.artifact_ref, "utf8"));
|
|
507
|
-
|
|
508
|
-
if (!result.valid) {
|
|
509
|
-
const errorSummary = result.errors.slice(0, 3).join("; ");
|
|
510
|
-
die(`trust.bundle artifact at ${out.artifact_ref} failed Hachure schema validation: ${errorSummary}`);
|
|
511
|
-
}
|
|
1044
|
+
surfaceValidateFn(bundle);
|
|
512
1045
|
}
|
|
513
1046
|
catch (err) {
|
|
514
|
-
if (err instanceof Error
|
|
515
|
-
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
|
+
}
|
|
516
1054
|
// File read or parse errors are not re-thrown: the artifact_ref validation path is advisory
|
|
517
1055
|
}
|
|
518
1056
|
}
|
|
@@ -555,17 +1093,6 @@ function surfaceCheckFromArtifact(file, index) {
|
|
|
555
1093
|
}
|
|
556
1094
|
return { id: `surface-trust-${index + 1}`, kind: "policy", status: ref.status, summary: ref.summary, surface_trust_refs: [ref] };
|
|
557
1095
|
}
|
|
558
|
-
function updateAcceptance(dir, verdict) {
|
|
559
|
-
const file = path.join(dir, "acceptance.json");
|
|
560
|
-
if (!fs.existsSync(file))
|
|
561
|
-
return;
|
|
562
|
-
const data = loadJson(file);
|
|
563
|
-
const status = verdict === "pass" ? "pass" : verdict === "fail" ? "fail" : "not_verified";
|
|
564
|
-
if (Array.isArray(data.criteria))
|
|
565
|
-
data.criteria = data.criteria.map((c) => ({ ...c, status }));
|
|
566
|
-
data.goal_fit = { ...(data.goal_fit ?? {}), status, summary: verdict === "pass" ? "Evidence passed." : "Evidence requires follow-up." };
|
|
567
|
-
writeJson(file, data);
|
|
568
|
-
}
|
|
569
1096
|
function validateAcceptanceEvidenceRefs(dir) {
|
|
570
1097
|
const file = path.join(dir, "acceptance.json");
|
|
571
1098
|
if (!fs.existsSync(file))
|
|
@@ -578,10 +1105,120 @@ function validateAcceptanceEvidenceRefs(dir) {
|
|
|
578
1105
|
normalizeEvidenceRefs(criterion.evidence_refs, `acceptance.criteria[${index}].evidence_refs`);
|
|
579
1106
|
});
|
|
580
1107
|
}
|
|
581
|
-
function writeState(dir, slug, status, phase, timestamp, summary, next = "continue") {
|
|
1108
|
+
export function writeState(dir, slug, status, phase, timestamp, summary, next = "continue") {
|
|
582
1109
|
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 } });
|
|
583
1110
|
}
|
|
584
|
-
|
|
1111
|
+
// ─── Phase 4c: bundle-only helpers ───────────────────────────────────────────
|
|
1112
|
+
// After 4c, evidence.json and critique.json are no longer written.
|
|
1113
|
+
// Extract checks and critiques from the existing trust.bundle for callers that
|
|
1114
|
+
// need to rebuild the bundle (e.g. record-critique, record-learning).
|
|
1115
|
+
// ADR 0016 Abstraction A (Step 0 Q3 carry-forward): build the set of declared
|
|
1116
|
+
// claimTypes from the active flow step for the session at `dir`. When no active
|
|
1117
|
+
// flow is present (workflow.* sessions), returns an empty set so every existing
|
|
1118
|
+
// predicate is unchanged. When a FlowDefinition-driven session (builder.build)
|
|
1119
|
+
// is active, the set contains the kit-typed claimTypes (e.g. "builder.verify.tests",
|
|
1120
|
+
// "builder.verify.policy-compliance") so round-trip helpers broaden their filters
|
|
1121
|
+
// to include declared claims alongside the legacy workflow.* ones.
|
|
1122
|
+
//
|
|
1123
|
+
// Safety guard: current.json in the .flow-agents dir records the CURRENTLY ACTIVE
|
|
1124
|
+
// session via artifact_dir. If current.json points to a different session than `dir`
|
|
1125
|
+
// (e.g. another session was the last to call advance-state --flow-definition), we
|
|
1126
|
+
// return an empty set so declared-type predicates are NOT applied to the wrong session.
|
|
1127
|
+
// This prevents a cross-session active_flow_id from broadening claim filters for
|
|
1128
|
+
// unrelated sessions (which would cause spurious evidence/critique check behavior).
|
|
1129
|
+
function declaredClaimTypesFor(dir) {
|
|
1130
|
+
const flowAgentsDir = path.dirname(dir);
|
|
1131
|
+
// Verify that current.json points to `dir` before reading active flow step.
|
|
1132
|
+
// If it points to a different session, return empty set (zero behavior change).
|
|
1133
|
+
const currentFile = path.join(flowAgentsDir, "current.json");
|
|
1134
|
+
try {
|
|
1135
|
+
const current = JSON.parse(fs.readFileSync(currentFile, "utf8"));
|
|
1136
|
+
const artDir = typeof current["artifact_dir"] === "string" ? current["artifact_dir"] : null;
|
|
1137
|
+
if (!artDir)
|
|
1138
|
+
return new Set();
|
|
1139
|
+
const resolvedCurrent = path.resolve(flowAgentsDir, artDir);
|
|
1140
|
+
if (path.resolve(dir) !== resolvedCurrent)
|
|
1141
|
+
return new Set();
|
|
1142
|
+
}
|
|
1143
|
+
catch {
|
|
1144
|
+
return new Set();
|
|
1145
|
+
}
|
|
1146
|
+
const activeStep = resolveActiveFlowStep(flowAgentsDir);
|
|
1147
|
+
if (!activeStep || activeStep.gateExpects.length === 0)
|
|
1148
|
+
return new Set();
|
|
1149
|
+
return new Set(activeStep.gateExpects.map((e) => e.bundle_claim.claimType));
|
|
1150
|
+
}
|
|
1151
|
+
function checksFromBundle(dir, declaredClaimTypes = new Set()) {
|
|
1152
|
+
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
1153
|
+
if (!Array.isArray(bundle.evidence))
|
|
1154
|
+
return [];
|
|
1155
|
+
const allClaims = Array.isArray(bundle.claims) ? bundle.claims : [];
|
|
1156
|
+
const claimById = new Map();
|
|
1157
|
+
for (const c of allClaims)
|
|
1158
|
+
if (c && c.id)
|
|
1159
|
+
claimById.set(c.id, c);
|
|
1160
|
+
const seen = new Set();
|
|
1161
|
+
const checks = [];
|
|
1162
|
+
for (const ev of bundle.evidence) {
|
|
1163
|
+
if (!ev || !ev.claimId)
|
|
1164
|
+
continue;
|
|
1165
|
+
const claim = claimById.get(ev.claimId);
|
|
1166
|
+
if (!claim)
|
|
1167
|
+
continue;
|
|
1168
|
+
const ct = String(claim.claimType || "");
|
|
1169
|
+
// ADR 0016 Step 0: broaden to include declared kit-typed claims alongside workflow.check.*
|
|
1170
|
+
if (!ct.startsWith("workflow.check.") && !declaredClaimTypes.has(ct))
|
|
1171
|
+
continue;
|
|
1172
|
+
if (seen.has(ev.claimId))
|
|
1173
|
+
continue;
|
|
1174
|
+
seen.add(ev.claimId);
|
|
1175
|
+
const kind = ct.startsWith("workflow.check.") ? (ct.replace("workflow.check.", "") || "external") : (ct.split(".").pop() || "external");
|
|
1176
|
+
const status = claim.value ?? "not_verified";
|
|
1177
|
+
const check = { id: String(claim.subjectId || "").split("/").pop() || ev.claimId, kind, status, summary: claim.fieldOrBehavior || "" };
|
|
1178
|
+
if (ev.execution && typeof ev.execution.label === "string")
|
|
1179
|
+
check.command = ev.execution.label;
|
|
1180
|
+
if (ev.evidenceType)
|
|
1181
|
+
check.evidenceType = ev.evidenceType;
|
|
1182
|
+
checks.push(check);
|
|
1183
|
+
}
|
|
1184
|
+
// Also include check claims that have no evidence item (surface_trust_refs style)
|
|
1185
|
+
for (const claim of allClaims) {
|
|
1186
|
+
if (!claim)
|
|
1187
|
+
continue;
|
|
1188
|
+
const ct = String(claim.claimType || "");
|
|
1189
|
+
// ADR 0016 Step 0: broaden to include declared kit-typed claims alongside workflow.check.*
|
|
1190
|
+
if (!ct.startsWith("workflow.check.") && !declaredClaimTypes.has(ct))
|
|
1191
|
+
continue;
|
|
1192
|
+
if (seen.has(claim.id))
|
|
1193
|
+
continue;
|
|
1194
|
+
seen.add(claim.id);
|
|
1195
|
+
const kind = ct.startsWith("workflow.check.") ? (ct.replace("workflow.check.", "") || "external") : (ct.split(".").pop() || "external");
|
|
1196
|
+
checks.push({ id: String(claim.subjectId || "").split("/").pop() || claim.id, kind, status: claim.value ?? "not_verified", summary: claim.fieldOrBehavior || "" });
|
|
1197
|
+
}
|
|
1198
|
+
return checks;
|
|
1199
|
+
}
|
|
1200
|
+
function critiquesFromBundle(dir, declaredClaimTypes = new Set()) {
|
|
1201
|
+
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
1202
|
+
if (!Array.isArray(bundle.claims))
|
|
1203
|
+
return [];
|
|
1204
|
+
// ADR 0016 Step 0: broaden to include declared kit-typed critique claims alongside workflow.critique.review.
|
|
1205
|
+
// P-d: exclude claims that have evidence items (evidence = check claims, not critique claims).
|
|
1206
|
+
// This prevents check-type declared claims (e.g. builder.verify.tests) from being read back
|
|
1207
|
+
// as critiques when declaredClaimTypes includes all gate expects[] types.
|
|
1208
|
+
const evidenceClaimIds = new Set(Array.isArray(bundle.evidence) ? bundle.evidence.map((e) => e?.claimId).filter((id) => typeof id === "string") : []);
|
|
1209
|
+
const critiqueClaims = bundle.claims.filter((c) => c && (c.claimType === "workflow.critique.review" || declaredClaimTypes.has(c.claimType)) && !evidenceClaimIds.has(c.id));
|
|
1210
|
+
return critiqueClaims.map((c) => ({
|
|
1211
|
+
id: String(c.subjectId || "").split("/").pop() || c.id,
|
|
1212
|
+
verdict: c.value ?? "not_verified",
|
|
1213
|
+
summary: c.fieldOrBehavior || "",
|
|
1214
|
+
findings: [],
|
|
1215
|
+
reviewer: "tool-code-reviewer",
|
|
1216
|
+
reviewed_at: c.updatedAt || c.createdAt || now(),
|
|
1217
|
+
artifact_refs: [],
|
|
1218
|
+
}));
|
|
1219
|
+
}
|
|
1220
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1221
|
+
async function recordEvidence(p) {
|
|
585
1222
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
586
1223
|
const verdict = opt(p, "verdict") || die("--verdict is required");
|
|
587
1224
|
if (!verdicts.has(verdict))
|
|
@@ -591,11 +1228,15 @@ function recordEvidence(p) {
|
|
|
591
1228
|
if (!checks.length && opts(p, "surface-trust-json").length === 0)
|
|
592
1229
|
die("record-evidence requires at least one --check-json or --surface-trust-json");
|
|
593
1230
|
validateAcceptanceEvidenceRefs(dir);
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
1231
|
+
// Phase 4c: bundle is the sole verification artifact — stop writing evidence.json and acceptance.json update.
|
|
1232
|
+
const ts = opt(p, "timestamp", now());
|
|
1233
|
+
const _existingAcceptance = loadJson(path.join(dir, "acceptance.json"));
|
|
1234
|
+
const _existingCriteria = Array.isArray(_existingAcceptance.criteria) ? _existingAcceptance.criteria : [];
|
|
1235
|
+
const _criteriaStatus = verdict === "pass" ? "pass" : verdict === "fail" ? "fail" : "not_verified";
|
|
1236
|
+
const _criteriaForBundle = _existingCriteria.map((c) => ({ ...c, status: _criteriaStatus }));
|
|
1237
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, ts, checks, _criteriaForBundle, []));
|
|
597
1238
|
const stateStatus = verdict === "pass" ? "verified" : verdict === "fail" ? "failed" : "not_verified";
|
|
598
|
-
writeState(dir, slug, stateStatus, "verification",
|
|
1239
|
+
writeState(dir, slug, stateStatus, "verification", ts, "Evidence recorded.");
|
|
599
1240
|
return 0;
|
|
600
1241
|
}
|
|
601
1242
|
function diagnostic(dir, code, summary) {
|
|
@@ -603,7 +1244,90 @@ function diagnostic(dir, code, summary) {
|
|
|
603
1244
|
appendJsonl(path.join(dir, "transition-diagnostics.jsonl"), payload);
|
|
604
1245
|
die(`${code}: ${summary}`);
|
|
605
1246
|
}
|
|
606
|
-
|
|
1247
|
+
/**
|
|
1248
|
+
* record-gate-claim — Generic gate-claim producer for skills (ADR 0016 P-d Increment 1).
|
|
1249
|
+
*
|
|
1250
|
+
* Allows a skill to record a claim that satisfies a SPECIFIC gate expectation at the
|
|
1251
|
+
* active step. The caller passes:
|
|
1252
|
+
* --status <pass|fail|not_verified> (required)
|
|
1253
|
+
* --summary <text> (required)
|
|
1254
|
+
* --expectation <id> (optional; auto-resolved when the gate has one entry)
|
|
1255
|
+
* --evidence-json <json> (optional; structured evidence refs)
|
|
1256
|
+
*
|
|
1257
|
+
* The producer emits a check of kind="external" targeting the gate expectation's declared
|
|
1258
|
+
* claimType + subjectType from the active step's expects[]. This populates the trust.bundle
|
|
1259
|
+
* with a correctly-typed claim derived by Surface, suitable for gate enforcement.
|
|
1260
|
+
*
|
|
1261
|
+
* When the gate has exactly ONE expects[] entry, --expectation is optional (auto-resolve).
|
|
1262
|
+
* When the gate has multiple entries, --expectation <id> is required.
|
|
1263
|
+
*
|
|
1264
|
+
* This is what Increment 2's 6 skills will call to satisfy the category (c) gates
|
|
1265
|
+
* (pull-work.selected, design-probe.*, pr-open.pull-request, learn.*) once producers are added.
|
|
1266
|
+
*
|
|
1267
|
+
* Error cases:
|
|
1268
|
+
* - No active flow/step in current.json → die with actionable message
|
|
1269
|
+
* - --expectation not found in expects[] → die
|
|
1270
|
+
* - Multiple expects[] entries and --expectation omitted → die
|
|
1271
|
+
* - Surface unavailable → assertBundleWritten fails loud (no silent data loss)
|
|
1272
|
+
*/
|
|
1273
|
+
async function recordGateClaim(p) {
|
|
1274
|
+
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
1275
|
+
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
1276
|
+
const ts = opt(p, "timestamp", now());
|
|
1277
|
+
const statusVal = opt(p, "status");
|
|
1278
|
+
if (!["pass", "fail", "not_verified"].includes(statusVal))
|
|
1279
|
+
die("--status must be one of: pass, fail, not_verified");
|
|
1280
|
+
const summary = opt(p, "summary") || die("--summary is required");
|
|
1281
|
+
const expectationId = opt(p, "expectation");
|
|
1282
|
+
// Resolve the active flow step from current.json
|
|
1283
|
+
const flowAgentsDir = path.dirname(dir);
|
|
1284
|
+
const activeStep = resolveActiveFlowStep(flowAgentsDir);
|
|
1285
|
+
if (!activeStep)
|
|
1286
|
+
die("record-gate-claim requires an active flow step in current.json (set via ensure-session --flow-id or advance-state --flow-definition)");
|
|
1287
|
+
const expects = activeStep.gateExpects;
|
|
1288
|
+
if (expects.length === 0)
|
|
1289
|
+
die(`record-gate-claim: active step "${activeStep.stepId}" gate "${activeStep.gateId}" has no expects[] entries`);
|
|
1290
|
+
// Resolve the target expects entry
|
|
1291
|
+
let targetExpectation;
|
|
1292
|
+
if (expectationId) {
|
|
1293
|
+
targetExpectation = expects.find((e) => e.id === expectationId);
|
|
1294
|
+
if (!targetExpectation)
|
|
1295
|
+
die(`record-gate-claim: --expectation "${expectationId}" not found in gate "${activeStep.gateId}" expects[]. Available: ${expects.map((e) => e.id).join(", ")}`);
|
|
1296
|
+
}
|
|
1297
|
+
else if (expects.length === 1) {
|
|
1298
|
+
targetExpectation = expects[0];
|
|
1299
|
+
}
|
|
1300
|
+
else {
|
|
1301
|
+
die(`record-gate-claim: gate "${activeStep.gateId}" has ${expects.length} expects[] entries; --expectation <id> is required. Available: ${expects.map((e) => e.id).join(", ")}`);
|
|
1302
|
+
}
|
|
1303
|
+
const { claimType, subjectType } = targetExpectation.bundle_claim;
|
|
1304
|
+
// Build a synthetic external check that will be matched by matchExpectsEntry to produce
|
|
1305
|
+
// a correctly-typed claim. We use kind="external" so it routes through the non-policy,
|
|
1306
|
+
// non-flow-step fallback path. The subjectType on the resulting claim comes from the
|
|
1307
|
+
// expects[] entry via matchExpectsEntry.
|
|
1308
|
+
const checkId = expectationId || targetExpectation.id;
|
|
1309
|
+
// Build a minimal "external" check. Include _gate_claim_expectation_id so that
|
|
1310
|
+
// matchExpectsEntry can do an exact lookup for multi-expects[] gates (ADR 0016 P-d Increment 2).
|
|
1311
|
+
// normalizeCheck preserves extra underscore-prefixed fields without stripping them.
|
|
1312
|
+
const check = {
|
|
1313
|
+
id: `gate-claim-${checkId}`,
|
|
1314
|
+
kind: "external",
|
|
1315
|
+
status: statusVal,
|
|
1316
|
+
summary,
|
|
1317
|
+
_gate_claim_expectation_id: targetExpectation.id,
|
|
1318
|
+
};
|
|
1319
|
+
// Include structured evidence refs if provided
|
|
1320
|
+
const evidenceRefs = opts(p, "evidence-ref-json").map((v) => validateEvidenceRef(parseJson(v, "--evidence-ref-json"), "--evidence-ref-json"));
|
|
1321
|
+
if (evidenceRefs.length > 0) {
|
|
1322
|
+
check.artifact_refs = evidenceRefs;
|
|
1323
|
+
}
|
|
1324
|
+
const checkNormalized = normalizeCheck(check);
|
|
1325
|
+
// Log the targeted gate expectation for transparency (goes to stderr only)
|
|
1326
|
+
process.stderr.write(`[record-gate-claim] targeting ${activeStep.stepId}/${activeStep.gateId}/${targetExpectation.id} → claimType=${claimType} subjectType=${subjectType}\n`);
|
|
1327
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, ts, [checkNormalized], [], []));
|
|
1328
|
+
return 0;
|
|
1329
|
+
}
|
|
1330
|
+
async function advanceState(p) {
|
|
607
1331
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
608
1332
|
const status = opt(p, "status");
|
|
609
1333
|
const phase = opt(p, "phase");
|
|
@@ -635,29 +1359,50 @@ function advanceState(p) {
|
|
|
635
1359
|
const timestamp = opt(p, "timestamp", now());
|
|
636
1360
|
writeState(dir, slug, status, phase, timestamp, opt(p, "summary"));
|
|
637
1361
|
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: [] });
|
|
1362
|
+
// ADR 0016 Abstraction A (P-d, Increment 1): when --flow-definition is provided,
|
|
1363
|
+
// resolve the phase→step mapping from the FlowDefinition and write active_step_id
|
|
1364
|
+
// into current.json. This is the single setter — no skill needs to call ensure-session
|
|
1365
|
+
// --step-id individually. The repoRoot is derived by walking up from dir to find kits/.
|
|
1366
|
+
if (flow) {
|
|
1367
|
+
const root = path.resolve(opt(p, "artifact-root", path.dirname(dir)));
|
|
1368
|
+
const repoRoot = findRepoRootFromDir(dir);
|
|
1369
|
+
const phaseMap = resolvePhaseMap(flow, repoRoot);
|
|
1370
|
+
const stepId = phaseMap?.[phase] ?? undefined;
|
|
1371
|
+
if (stepId) {
|
|
1372
|
+
writeCurrent(root, dir, timestamp, "workflow-sidecar", "advance-state", flow, stepId);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
livenessLifecycle(dir, slug, LIVENESS_TERMINAL.has(status) ? "release" : "heartbeat", timestamp);
|
|
1376
|
+
// Trust checkpoint: when advancing to a terminal delivered status, seal the checkpoint.
|
|
1377
|
+
if (status === "delivered") {
|
|
1378
|
+
await sealTrustCheckpoint(dir, slug, timestamp, status, "release").catch(() => { });
|
|
1379
|
+
// Publish delivery bundle: best-effort copy to delivery/ for CI trust-reconcile.
|
|
1380
|
+
await publishDelivery(dir, findRepoRootFromDir(dir)).catch(() => { });
|
|
1381
|
+
}
|
|
638
1382
|
return 0;
|
|
639
1383
|
}
|
|
640
|
-
function normalizeFinding(raw) {
|
|
1384
|
+
export function normalizeFinding(raw) {
|
|
641
1385
|
if (raw.file_refs !== undefined && !Array.isArray(raw.file_refs))
|
|
642
1386
|
die("file_refs must be an array");
|
|
643
1387
|
return raw;
|
|
644
1388
|
}
|
|
645
|
-
function
|
|
646
|
-
if (!required && critiques.length === 0)
|
|
647
|
-
return "not_required";
|
|
648
|
-
if (critiques.some((c) => c.verdict === "fail" || (Array.isArray(c.findings) && c.findings.some((f) => f.status === "open"))))
|
|
649
|
-
return "fail";
|
|
650
|
-
return "pass";
|
|
651
|
-
}
|
|
652
|
-
function recordCritique(p) {
|
|
1389
|
+
async function recordCritique(p) {
|
|
653
1390
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
654
1391
|
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
655
|
-
|
|
1392
|
+
// Phase 4c: accumulate existing critiques from trust.bundle (critique.json no longer written).
|
|
1393
|
+
// Fall back to critique.json for legacy sessions that still have it on disk.
|
|
1394
|
+
const existingCritiqueJson = loadJson(path.join(dir, "critique.json"), { critiques: [] });
|
|
1395
|
+
const legacyCritiques = Array.isArray(existingCritiqueJson.critiques) ? existingCritiqueJson.critiques : [];
|
|
1396
|
+
const _dctCritique = declaredClaimTypesFor(dir);
|
|
1397
|
+
const bundleCritiques = legacyCritiques.length === 0 ? critiquesFromBundle(dir, _dctCritique) : legacyCritiques;
|
|
656
1398
|
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"))) };
|
|
657
|
-
const critiques = [...
|
|
1399
|
+
const critiques = [...bundleCritiques, critique];
|
|
658
1400
|
if (critique.verdict === "pass" && critique.findings.some((f) => f.status === "open"))
|
|
659
1401
|
die("required critique must pass");
|
|
660
|
-
|
|
1402
|
+
// Phase 4c: build bundle from raw inputs; read checks from trust.bundle (evidence.json no longer written).
|
|
1403
|
+
const _critiqueEvChecks = checksFromBundle(dir, _dctCritique);
|
|
1404
|
+
const _critiqueAccCriteria = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
|
|
1405
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueEvChecks, _critiqueAccCriteria, critiques));
|
|
661
1406
|
return 0;
|
|
662
1407
|
}
|
|
663
1408
|
function frontmatter(text, key) {
|
|
@@ -668,7 +1413,7 @@ function frontmatter(text, key) {
|
|
|
668
1413
|
return "";
|
|
669
1414
|
return new RegExp(`^${key}:\\s*(.+)$`, "m").exec(text.slice(0, end))?.[1]?.trim() ?? "";
|
|
670
1415
|
}
|
|
671
|
-
function importCritique(p) {
|
|
1416
|
+
async function importCritique(p) {
|
|
672
1417
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
673
1418
|
const review = p.positional[1] || die("review artifact is required");
|
|
674
1419
|
const text = read(review);
|
|
@@ -684,12 +1429,12 @@ function importCritique(p) {
|
|
|
684
1429
|
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] });
|
|
685
1430
|
}
|
|
686
1431
|
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 };
|
|
687
|
-
const result = recordCritique(parsed);
|
|
1432
|
+
const result = await recordCritique(parsed);
|
|
688
1433
|
if (verdict !== "pass")
|
|
689
1434
|
die("required critique must pass");
|
|
690
1435
|
return result;
|
|
691
1436
|
}
|
|
692
|
-
function recordRelease(p) {
|
|
1437
|
+
async function recordRelease(p) {
|
|
693
1438
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
694
1439
|
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
695
1440
|
const decision = opt(p, "decision");
|
|
@@ -702,9 +1447,267 @@ function recordRelease(p) {
|
|
|
702
1447
|
const stateSummary = opt(p, "summary").trim() || `Release readiness recorded for ${decision}.`;
|
|
703
1448
|
writeJson(path.join(dir, "release.json"), payload);
|
|
704
1449
|
writeState(dir, slug, "delivered", "release", payload.updated_at, stateSummary);
|
|
1450
|
+
// Trust checkpoint: seal at the "delivered" moment (the natural terminal mark for record-release).
|
|
1451
|
+
await sealTrustCheckpoint(dir, slug, payload.updated_at, "delivered", "release").catch(() => { });
|
|
1452
|
+
// Publish delivery bundle: best-effort copy to delivery/ for CI trust-reconcile.
|
|
1453
|
+
await publishDelivery(dir, findRepoRootFromDir(dir)).catch(() => { });
|
|
1454
|
+
return 0;
|
|
1455
|
+
}
|
|
1456
|
+
// ─── Trust Checkpoint (Increment A) ──────────────────────────────────────────
|
|
1457
|
+
// Per-run frozen snapshot of verified trust state at completion. Written to
|
|
1458
|
+
// trust.checkpoint.json alongside the other workflow sidecars.
|
|
1459
|
+
// Surface owns the DerivationCheckpoint shape; flow-agents wraps it in an
|
|
1460
|
+
// ENVELOPE that adds per-run context surface does not carry.
|
|
1461
|
+
//
|
|
1462
|
+
// Envelope shape:
|
|
1463
|
+
// {
|
|
1464
|
+
// schema_version: "1.0",
|
|
1465
|
+
// slug: string,
|
|
1466
|
+
// work_item: string | null,
|
|
1467
|
+
// status: string,
|
|
1468
|
+
// phase: string,
|
|
1469
|
+
// sealed_at: ISO-8601,
|
|
1470
|
+
// commit_sha: string | null,
|
|
1471
|
+
// checkpoint: DerivationCheckpoint ← surface owns this
|
|
1472
|
+
// }
|
|
1473
|
+
//
|
|
1474
|
+
// Idempotent: re-running advance-state / record-release to the same terminal
|
|
1475
|
+
// status overwrites with the latest snapshot.
|
|
1476
|
+
// Fail-open: if no trust.bundle exists, or Surface is unavailable, the write
|
|
1477
|
+
// is skipped gracefully (no error surfaced to the caller).
|
|
1478
|
+
/** Derive the current git HEAD sha — null if unavailable (not in a repo, git absent). */
|
|
1479
|
+
function resolveCommitSha() {
|
|
1480
|
+
try {
|
|
1481
|
+
return execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
|
|
1482
|
+
}
|
|
1483
|
+
catch {
|
|
1484
|
+
return null;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* Build and write trust.checkpoint.json for a completed run.
|
|
1489
|
+
* Skips silently when:
|
|
1490
|
+
* - trust.bundle is absent (no evidence recorded yet)
|
|
1491
|
+
* - Surface is unavailable (checkpointFromReport not found)
|
|
1492
|
+
* The caller wraps this in .catch() so it never breaks the parent command.
|
|
1493
|
+
*
|
|
1494
|
+
* Increment B1 — checkpoint signing at the release boundary:
|
|
1495
|
+
* After the checkpoint is written, attempts Sigstore keyless signing (OIDC).
|
|
1496
|
+
* - CI/OIDC available: writes trust.checkpoint.sig.json (cosign-verifiable DSSE envelope)
|
|
1497
|
+
* and writes attestation:{status:"signed",...} to trust.checkpoint.attestation.json.
|
|
1498
|
+
* - Local (no OIDC): writes trust.checkpoint.intoto.json (unsigned in-toto statement)
|
|
1499
|
+
* and writes attestation:{status:"unsigned",...} to trust.checkpoint.attestation.json.
|
|
1500
|
+
* Signing is ALWAYS fail-open — a signing failure never breaks the seal.
|
|
1501
|
+
*/
|
|
1502
|
+
export async function sealTrustCheckpoint(dir, slug, sealedAt, status, phase) {
|
|
1503
|
+
const bundlePath = path.join(dir, "trust.bundle");
|
|
1504
|
+
if (!fs.existsSync(bundlePath))
|
|
1505
|
+
return; // no bundle — skip gracefully
|
|
1506
|
+
const surface = await tryLoadSurface();
|
|
1507
|
+
if (!surface || typeof surface.checkpointFromReport !== "function" || typeof surface.buildTrustReport !== "function")
|
|
1508
|
+
return; // Surface unavailable
|
|
1509
|
+
const bundle = JSON.parse(fs.readFileSync(bundlePath, "utf8"));
|
|
1510
|
+
const report = surface.buildTrustReport(bundle);
|
|
1511
|
+
const checkpoint = surface.checkpointFromReport(report);
|
|
1512
|
+
// Derive work_item from state.json if present (best-effort)
|
|
1513
|
+
let workItem = null;
|
|
1514
|
+
try {
|
|
1515
|
+
const stateRaw = loadJson(path.join(dir, "state.json"));
|
|
1516
|
+
if (typeof stateRaw.work_item === "string")
|
|
1517
|
+
workItem = stateRaw.work_item;
|
|
1518
|
+
}
|
|
1519
|
+
catch { /* ignored */ }
|
|
1520
|
+
const checkpointPath = path.join(dir, "trust.checkpoint.json");
|
|
1521
|
+
const envelope = {
|
|
1522
|
+
schema_version: "1.0",
|
|
1523
|
+
slug,
|
|
1524
|
+
work_item: workItem,
|
|
1525
|
+
status,
|
|
1526
|
+
phase,
|
|
1527
|
+
sealed_at: sealedAt,
|
|
1528
|
+
commit_sha: resolveCommitSha(),
|
|
1529
|
+
checkpoint,
|
|
1530
|
+
};
|
|
1531
|
+
writeJson(checkpointPath, envelope);
|
|
1532
|
+
// ─── Increment B1: sign the checkpoint at the release boundary ───────────────
|
|
1533
|
+
// Additive: if surface lacks in-toto/sigstore primitives, skip silently.
|
|
1534
|
+
// The .catch() at the call site already guards the parent command; this inner
|
|
1535
|
+
// catch is defense-in-depth so signing never propagates an error upward.
|
|
1536
|
+
await signCheckpointAttestation(dir, surface, bundle, checkpointPath).catch((err) => {
|
|
1537
|
+
process.stderr.write(`[checkpoint-signing] signing skipped due to error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* Increment B1 — Sign the trust checkpoint with in-toto/Sigstore.
|
|
1542
|
+
*
|
|
1543
|
+
* Called from sealTrustCheckpoint AFTER trust.checkpoint.json is written.
|
|
1544
|
+
* Computes the sha256 digest of the checkpoint file, builds an in-toto Statement
|
|
1545
|
+
* (predicate = trust bundle), and attempts Sigstore keyless signing.
|
|
1546
|
+
*
|
|
1547
|
+
* - Signed (CI/OIDC): writes trust.checkpoint.sig.json (DSSE envelope, cosign-verifiable).
|
|
1548
|
+
* - Unsigned (local): writes trust.checkpoint.intoto.json (unsigned statement).
|
|
1549
|
+
* - Always writes: trust.checkpoint.attestation.json with attestation:{status,path,...}.
|
|
1550
|
+
* trust.checkpoint.json is NOT modified after its digest is computed.
|
|
1551
|
+
*
|
|
1552
|
+
* NEVER throws — all errors are caught and surfaced as stderr warnings.
|
|
1553
|
+
* Skips silently when Surface's toInTotoStatement / signStatementWithSigstore are absent.
|
|
1554
|
+
*
|
|
1555
|
+
* @param dir Session artifact directory.
|
|
1556
|
+
* @param surface Loaded Surface module (may or may not have in-toto/sigstore exports).
|
|
1557
|
+
* @param bundle Parsed trust.bundle (becomes the in-toto predicate).
|
|
1558
|
+
* @param checkpointPath Absolute path to the already-written trust.checkpoint.json.
|
|
1559
|
+
*/
|
|
1560
|
+
async function signCheckpointAttestation(dir, surface, bundle, checkpointPath) {
|
|
1561
|
+
// Guard: both primitives must be present (consumed from Surface, never reimplemented).
|
|
1562
|
+
if (typeof surface.toInTotoStatement !== "function" || typeof surface.signStatementWithSigstore !== "function") {
|
|
1563
|
+
process.stderr.write("[checkpoint-signing] Surface in-toto/sigstore primitives unavailable — skipping attestation\n");
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
// Step A: compute sha256 digest of trust.checkpoint.json (the SUBJECT).
|
|
1567
|
+
// The checkpoint is self-evidencing — its digest is the external anchor.
|
|
1568
|
+
const checkpointBytes = fs.readFileSync(checkpointPath);
|
|
1569
|
+
const sha256hex = createHash("sha256").update(checkpointBytes).digest("hex");
|
|
1570
|
+
// Step B: build the in-toto Statement.
|
|
1571
|
+
// subject = the checkpoint file (what we are attesting TO)
|
|
1572
|
+
// predicate = the trust bundle (what the checkpoint CONTAINS)
|
|
1573
|
+
const subjects = [{ name: "trust.checkpoint.json", digest: { sha256: sha256hex } }];
|
|
1574
|
+
const statement = surface.toInTotoStatement(bundle, { subjects });
|
|
1575
|
+
// Step C: attempt Sigstore keyless signing (PRIMARY path).
|
|
1576
|
+
// signStatementWithSigstore returns null when no ambient OIDC credential is available
|
|
1577
|
+
// (local development, no ACTIONS_ID_TOKEN_REQUEST_URL). This is the expected local case.
|
|
1578
|
+
let signed = null;
|
|
1579
|
+
try {
|
|
1580
|
+
signed = await surface.signStatementWithSigstore(statement);
|
|
1581
|
+
}
|
|
1582
|
+
catch (err) {
|
|
1583
|
+
// signStatementWithSigstore may throw on unexpected failures (network error, config error);
|
|
1584
|
+
// treat as fail-open: fall through to the unsigned path.
|
|
1585
|
+
process.stderr.write(`[checkpoint-signing] signStatementWithSigstore threw: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1586
|
+
signed = null;
|
|
1587
|
+
}
|
|
1588
|
+
let attestation;
|
|
1589
|
+
if (signed) {
|
|
1590
|
+
// CI/OIDC path: write the cosign-verifiable DSSE envelope.
|
|
1591
|
+
const sigPath = path.join(dir, "trust.checkpoint.sig.json");
|
|
1592
|
+
writeJson(sigPath, signed.envelope);
|
|
1593
|
+
const keyid = signed.envelope.signatures[0]?.keyid ?? "";
|
|
1594
|
+
attestation = {
|
|
1595
|
+
status: "signed",
|
|
1596
|
+
path: "trust.checkpoint.sig.json",
|
|
1597
|
+
keyid,
|
|
1598
|
+
};
|
|
1599
|
+
process.stderr.write(`[checkpoint-signing] checkpoint signed with Sigstore — envelope written to ${sigPath}\n`);
|
|
1600
|
+
}
|
|
1601
|
+
else {
|
|
1602
|
+
// Local/unsigned path: write the unsigned in-toto statement for audit purposes.
|
|
1603
|
+
const unsignedPath = path.join(dir, "trust.checkpoint.intoto.json");
|
|
1604
|
+
writeJson(unsignedPath, statement);
|
|
1605
|
+
attestation = {
|
|
1606
|
+
status: "unsigned",
|
|
1607
|
+
path: "trust.checkpoint.intoto.json",
|
|
1608
|
+
reason: "no ambient signing identity",
|
|
1609
|
+
};
|
|
1610
|
+
process.stderr.write("[checkpoint-signing] no ambient OIDC identity — unsigned in-toto statement written (expected locally)\n");
|
|
1611
|
+
}
|
|
1612
|
+
// Step D: write the attestation record to a SEPARATE companion file.
|
|
1613
|
+
// trust.checkpoint.json is NOT modified — it must remain byte-identical to what was signed.
|
|
1614
|
+
// The companion file carries the pointer/status; the subject-digest binding in the
|
|
1615
|
+
// in-toto statement ties it back to the checkpoint without breaking the digest.
|
|
1616
|
+
const attestationPath = path.join(dir, "trust.checkpoint.attestation.json");
|
|
1617
|
+
writeJson(attestationPath, attestation);
|
|
1618
|
+
}
|
|
1619
|
+
/**
|
|
1620
|
+
* seal-checkpoint <dir> [--timestamp <iso>]
|
|
1621
|
+
*
|
|
1622
|
+
* Explicit seal of the trust checkpoint for the given artifact dir.
|
|
1623
|
+
* Equivalent to the seal that fires automatically at record-release / advance-state
|
|
1624
|
+
* to delivered. Useful for the deliver skill or a human to seal explicitly without
|
|
1625
|
+
* re-running advance-state.
|
|
1626
|
+
*
|
|
1627
|
+
* Usage: workflow-sidecar seal-checkpoint <artifactDir> [--timestamp <iso>]
|
|
1628
|
+
*/
|
|
1629
|
+
async function sealCheckpoint(p) {
|
|
1630
|
+
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
1631
|
+
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
1632
|
+
const timestamp = opt(p, "timestamp", now());
|
|
1633
|
+
const stateRaw = loadJson(path.join(dir, "state.json"));
|
|
1634
|
+
const status = typeof stateRaw.status === "string" ? stateRaw.status : "delivered";
|
|
1635
|
+
const phase = typeof stateRaw.phase === "string" ? stateRaw.phase : "release";
|
|
1636
|
+
const bundlePath = path.join(dir, "trust.bundle");
|
|
1637
|
+
if (!fs.existsSync(bundlePath)) {
|
|
1638
|
+
process.stderr.write(`[seal-checkpoint] no trust.bundle at ${bundlePath} — skipping (nothing to seal)
|
|
1639
|
+
`);
|
|
1640
|
+
return 0;
|
|
1641
|
+
}
|
|
1642
|
+
await sealTrustCheckpoint(dir, slug, timestamp, status, phase);
|
|
1643
|
+
const checkpointPath = path.join(dir, "trust.checkpoint.json");
|
|
1644
|
+
if (fs.existsSync(checkpointPath)) {
|
|
1645
|
+
console.log(checkpointPath);
|
|
1646
|
+
}
|
|
1647
|
+
else {
|
|
1648
|
+
process.stderr.write(`[seal-checkpoint] checkpoint was not written — @kontourai/surface may be unavailable
|
|
1649
|
+
`);
|
|
1650
|
+
}
|
|
1651
|
+
return 0;
|
|
1652
|
+
}
|
|
1653
|
+
// ─── Publish Delivery Bundle ──────────────────────────────────────────────────
|
|
1654
|
+
// Copies the session's trust.bundle (+ checkpoint companions) from the gitignored
|
|
1655
|
+
// session artifact dir (.flow-agents/<slug>/) to the committed delivery/ transport
|
|
1656
|
+
// path so the CI trust-reconcile job can reconcile it against fresh CI results.
|
|
1657
|
+
//
|
|
1658
|
+
// Fail-soft: if trust.bundle is absent (no evidence recorded yet), does nothing.
|
|
1659
|
+
// Idempotent: overwrites on re-delivery.
|
|
1660
|
+
// Called automatically from recordRelease and advanceState→delivered (best-effort).
|
|
1661
|
+
// Also exposed as the `publish-delivery <artifact-dir>` subcommand for explicit use.
|
|
1662
|
+
/**
|
|
1663
|
+
* Publish the session's trust artifacts to the committed delivery/ path.
|
|
1664
|
+
*
|
|
1665
|
+
* Copies trust.bundle, trust.checkpoint.json, and (if present)
|
|
1666
|
+
* trust.checkpoint.intoto.json / trust.checkpoint.sig.json from the
|
|
1667
|
+
* session artifact dir to <repoRoot>/delivery/.
|
|
1668
|
+
*
|
|
1669
|
+
* Fail-soft: if trust.bundle is absent, returns without throwing.
|
|
1670
|
+
* Idempotent: overwrites on re-delivery.
|
|
1671
|
+
*/
|
|
1672
|
+
export async function publishDelivery(dir, repoRoot) {
|
|
1673
|
+
const bundleSrc = path.join(dir, "trust.bundle");
|
|
1674
|
+
if (!fs.existsSync(bundleSrc))
|
|
1675
|
+
return; // no bundle — skip gracefully
|
|
1676
|
+
const deliveryDir = path.join(repoRoot, "delivery");
|
|
1677
|
+
fs.mkdirSync(deliveryDir, { recursive: true });
|
|
1678
|
+
// Required: trust.bundle (the CI anchor)
|
|
1679
|
+
fs.copyFileSync(bundleSrc, path.join(deliveryDir, "trust.bundle"));
|
|
1680
|
+
// Optional companions: checkpoint + signing artifacts
|
|
1681
|
+
const companions = [
|
|
1682
|
+
"trust.checkpoint.json",
|
|
1683
|
+
"trust.checkpoint.intoto.json",
|
|
1684
|
+
"trust.checkpoint.sig.json",
|
|
1685
|
+
];
|
|
1686
|
+
for (const filename of companions) {
|
|
1687
|
+
const src = path.join(dir, filename);
|
|
1688
|
+
if (fs.existsSync(src)) {
|
|
1689
|
+
fs.copyFileSync(src, path.join(deliveryDir, filename));
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
process.stderr.write(`[publish-delivery] published trust.bundle and companions to ${deliveryDir}\n`);
|
|
1693
|
+
}
|
|
1694
|
+
/**
|
|
1695
|
+
* publish-delivery <artifact-dir> [--repo-root <path>]
|
|
1696
|
+
*
|
|
1697
|
+
* Explicit publish of the session trust bundle to the committed delivery/ path.
|
|
1698
|
+
* Equivalent to the publish that fires automatically at record-release /
|
|
1699
|
+
* advance-state to delivered. Useful for the deliver skill or a human to
|
|
1700
|
+
* publish explicitly.
|
|
1701
|
+
*
|
|
1702
|
+
* Usage: workflow-sidecar publish-delivery <artifactDir> [--repo-root <path>]
|
|
1703
|
+
*/
|
|
1704
|
+
async function publishDeliveryCmd(p) {
|
|
1705
|
+
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
1706
|
+
const repoRoot = opt(p, "repo-root") || findRepoRootFromDir(dir);
|
|
1707
|
+
await publishDelivery(dir, repoRoot);
|
|
705
1708
|
return 0;
|
|
706
1709
|
}
|
|
707
|
-
function validateLearningCorrection(record) {
|
|
1710
|
+
export function validateLearningCorrection(record) {
|
|
708
1711
|
const correction = record.correction;
|
|
709
1712
|
if (correction === undefined)
|
|
710
1713
|
return;
|
|
@@ -747,7 +1750,7 @@ function validateLearningPrevention(prevention) {
|
|
|
747
1750
|
if (!["open", "completed", "accepted", "deferred", "rejected"].includes(value.status))
|
|
748
1751
|
die("correction.prevention.status must be one of: open, completed, accepted, deferred, rejected");
|
|
749
1752
|
}
|
|
750
|
-
function normalizeLearning(raw, timestamp) {
|
|
1753
|
+
export function normalizeLearning(raw, timestamp) {
|
|
751
1754
|
if (!Array.isArray(raw.source_refs))
|
|
752
1755
|
die("source_refs must be an array");
|
|
753
1756
|
if (!Array.isArray(raw.facts))
|
|
@@ -759,7 +1762,7 @@ function normalizeLearning(raw, timestamp) {
|
|
|
759
1762
|
validateLearningCorrection(raw);
|
|
760
1763
|
return { recorded_at: timestamp, ...raw };
|
|
761
1764
|
}
|
|
762
|
-
function recordLearning(p) {
|
|
1765
|
+
async function recordLearning(p) {
|
|
763
1766
|
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
764
1767
|
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
765
1768
|
const timestamp = opt(p, "timestamp", now());
|
|
@@ -771,9 +1774,35 @@ function recordLearning(p) {
|
|
|
771
1774
|
die("learning status learned requires every record to include correction.needed");
|
|
772
1775
|
writeJson(path.join(dir, "learning.json"), { ...sidecarBase(slug), status, updated_at: timestamp, records });
|
|
773
1776
|
writeState(dir, slug, "accepted", "learning", timestamp, opt(p, "summary"));
|
|
1777
|
+
// Phase 4c: build bundle from raw inputs; read checks/critiques from trust.bundle (bespoke sidecars no longer written).
|
|
1778
|
+
// ADR 0016 Step 0: pass declaredClaimTypes so declared builder.* claims survive the round-trip.
|
|
1779
|
+
const _dctLearning = declaredClaimTypesFor(dir);
|
|
1780
|
+
const _learningChecks = checksFromBundle(dir, _dctLearning);
|
|
1781
|
+
const _learningCriteria = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
|
|
1782
|
+
const _learningCritiques = critiquesFromBundle(dir, _dctLearning);
|
|
1783
|
+
assertBundleWritten(await writeTrustBundle(dir, slug, timestamp, _learningChecks, _learningCriteria, _learningCritiques));
|
|
774
1784
|
return 0;
|
|
775
1785
|
}
|
|
776
|
-
function evidenceClean(dir) {
|
|
1786
|
+
function evidenceClean(dir, declaredClaimTypes = new Set()) {
|
|
1787
|
+
// Phase 4c: read from trust.bundle (sole verification artifact); fall back to evidence.json for legacy sessions.
|
|
1788
|
+
// ADR 0016 Step 0: declaredClaimTypes broadens the filter to include kit-typed check claims
|
|
1789
|
+
// (e.g. builder.verify.tests) in addition to workflow.check.* for FlowDefinition-driven sessions.
|
|
1790
|
+
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
1791
|
+
if (Array.isArray(bundle.claims)) {
|
|
1792
|
+
const checkClaims = bundle.claims.filter((c) => {
|
|
1793
|
+
if (!c)
|
|
1794
|
+
return false;
|
|
1795
|
+
const ct = String(c.claimType || "");
|
|
1796
|
+
return ct.startsWith("workflow.check.") || declaredClaimTypes.has(ct);
|
|
1797
|
+
});
|
|
1798
|
+
if (checkClaims.length === 0)
|
|
1799
|
+
return false;
|
|
1800
|
+
return checkClaims.every((c) => {
|
|
1801
|
+
const v = String(c.value || "");
|
|
1802
|
+
return v === "pass" || v === "skip";
|
|
1803
|
+
});
|
|
1804
|
+
}
|
|
1805
|
+
// Legacy fallback: evidence.json
|
|
777
1806
|
const e = loadJson(path.join(dir, "evidence.json"), {});
|
|
778
1807
|
return e.verdict === "pass" && Array.isArray(e.checks) && e.checks.length > 0 && e.checks.every((c) => {
|
|
779
1808
|
if (!(c.status === "pass" || c.status === "skip"))
|
|
@@ -781,7 +1810,21 @@ function evidenceClean(dir) {
|
|
|
781
1810
|
return !Array.isArray(c.standard_refs) || c.standard_refs.every((r) => ["junit", "sarif", "coverage", "veritas"].includes(r.standard));
|
|
782
1811
|
});
|
|
783
1812
|
}
|
|
784
|
-
function critiqueClean(dir) {
|
|
1813
|
+
function critiqueClean(dir, declaredClaimTypes = new Set()) {
|
|
1814
|
+
// Phase 4c: read from trust.bundle (sole verification artifact); fall back to critique.json for legacy sessions.
|
|
1815
|
+
// ADR 0016 Step 0: declaredClaimTypes broadens the filter to include kit-typed critique claims
|
|
1816
|
+
// (e.g. builder.verify.policy-compliance) in addition to workflow.critique.review.
|
|
1817
|
+
const bundle = loadJson(path.join(dir, "trust.bundle"));
|
|
1818
|
+
if (Array.isArray(bundle.claims)) {
|
|
1819
|
+
const critiqueClaims = bundle.claims.filter((c) => c && (c.claimType === "workflow.critique.review" || declaredClaimTypes.has(c.claimType)));
|
|
1820
|
+
if (critiqueClaims.length === 0)
|
|
1821
|
+
return false; // no critique written yet
|
|
1822
|
+
return critiqueClaims.every((c) => {
|
|
1823
|
+
const v = String(c.value || "");
|
|
1824
|
+
return v !== "fail" && c.status !== "disputed" && c.status !== "rejected";
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
// Legacy fallback: critique.json
|
|
785
1828
|
const c = loadJson(path.join(dir, "critique.json"), {});
|
|
786
1829
|
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)))));
|
|
787
1830
|
}
|
|
@@ -804,7 +1847,7 @@ function assertExistingLearningValid(dir) {
|
|
|
804
1847
|
die("learning status learned requires every record to include correction.needed");
|
|
805
1848
|
}
|
|
806
1849
|
}
|
|
807
|
-
function dogfoodPass(p) {
|
|
1850
|
+
async function dogfoodPass(p) {
|
|
808
1851
|
const root = path.resolve(opt(p, "artifact-root", ".flow-agents"));
|
|
809
1852
|
const dir = path.resolve(opt(p, "artifact-dir") || currentDir(root) || "");
|
|
810
1853
|
requireArtifactDirUnderRoot(dir, root);
|
|
@@ -814,9 +1857,16 @@ function dogfoodPass(p) {
|
|
|
814
1857
|
const checks = opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json")));
|
|
815
1858
|
if (checks.some((c) => c.status !== "pass" && c.status !== "skip"))
|
|
816
1859
|
die("clean evidence requires all non-skipped checks to pass");
|
|
817
|
-
|
|
1860
|
+
// Phase 4c: evidence check reads from trust.bundle (sole verification artifact); legacy evidence.json fallback in evidenceClean.
|
|
1861
|
+
// ADR 0016 Step 0: pass declaredClaimTypes so builder.* check/critique claims count as clean evidence.
|
|
1862
|
+
const _dctDogfood = declaredClaimTypesFor(dir);
|
|
1863
|
+
const _hasBundleEvidence = fs.existsSync(path.join(dir, "trust.bundle")) && evidenceClean(dir, _dctDogfood);
|
|
1864
|
+
const _hasLegacyEvidence = fs.existsSync(path.join(dir, "evidence.json")) && evidenceClean(dir, _dctDogfood);
|
|
1865
|
+
if (!_hasBundleEvidence && !_hasLegacyEvidence && fs.existsSync(path.join(dir, "trust.bundle")))
|
|
818
1866
|
die("cannot mark clean without passing evidence");
|
|
819
|
-
if (!fs.existsSync(path.join(dir, "
|
|
1867
|
+
if (!_hasBundleEvidence && !_hasLegacyEvidence && !fs.existsSync(path.join(dir, "trust.bundle")) && fs.existsSync(path.join(dir, "evidence.json")))
|
|
1868
|
+
die("cannot mark clean without passing evidence");
|
|
1869
|
+
if (!_hasBundleEvidence && !_hasLegacyEvidence && !fs.existsSync(path.join(dir, "trust.bundle")) && !fs.existsSync(path.join(dir, "evidence.json")) && checks.length === 0)
|
|
820
1870
|
die("cannot mark clean without passing evidence");
|
|
821
1871
|
if (p.flags.has("require-critique") || opt(p, "release-decision")) {
|
|
822
1872
|
const newCritiqueVerdict = opt(p, "critique-verdict", "pass");
|
|
@@ -824,9 +1874,10 @@ function dogfoodPass(p) {
|
|
|
824
1874
|
normalizeFinding(parseJson(value, "--finding-json"));
|
|
825
1875
|
if (newCritiqueVerdict !== "pass")
|
|
826
1876
|
die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
|
|
827
|
-
if (!opt(p, "critique-id") && !critiqueClean(dir))
|
|
1877
|
+
if (!opt(p, "critique-id") && !critiqueClean(dir, _dctDogfood))
|
|
828
1878
|
die("requires passing critique");
|
|
829
|
-
if (
|
|
1879
|
+
// Phase 4c: if existing state has a dirty critique (in bundle or legacy critique.json), block even when adding a new critique-id.
|
|
1880
|
+
if (!critiqueClean(dir, _dctDogfood) && (fs.existsSync(path.join(dir, "trust.bundle")) || fs.existsSync(path.join(dir, "critique.json"))))
|
|
830
1881
|
die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
|
|
831
1882
|
}
|
|
832
1883
|
}
|
|
@@ -836,11 +1887,11 @@ function dogfoodPass(p) {
|
|
|
836
1887
|
if (opt(p, "learning-status") === "learned" && learningRecords.some((r) => r.correction === undefined))
|
|
837
1888
|
die("learned status requires every learning record to include correction.needed");
|
|
838
1889
|
if (opts(p, "check-json").length)
|
|
839
|
-
recordEvidence({ ...p, positional: [dir], opts: { ...p.opts, verdict: [verdict] }, flags: p.flags });
|
|
1890
|
+
await recordEvidence({ ...p, positional: [dir], opts: { ...p.opts, verdict: [verdict] }, flags: p.flags });
|
|
840
1891
|
if (p.flags.has("require-critique") && opt(p, "critique-id"))
|
|
841
|
-
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 });
|
|
1892
|
+
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 });
|
|
842
1893
|
if (learningRecords.length)
|
|
843
|
-
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 });
|
|
1894
|
+
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 });
|
|
844
1895
|
if (opt(p, "release-decision")) {
|
|
845
1896
|
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 });
|
|
846
1897
|
printJson({ release_decision: opt(p, "release-decision") });
|
|
@@ -853,14 +1904,821 @@ function dogfoodPass(p) {
|
|
|
853
1904
|
writeJson(path.join(dir, "handoff.json"), handoff);
|
|
854
1905
|
}
|
|
855
1906
|
writeState(dir, taskSlugFor(dir, opt(p, "task-slug")), stateStatus, "verification", opt(p, "timestamp", now()), opt(p, "summary"), verdict === "pass" ? "continue" : "blocked");
|
|
1907
|
+
// Phase 4c: bundle was already written by recordEvidence/recordCritique above (if called).
|
|
1908
|
+
// If neither ran (e.g. verdict=fail with no check-json), re-build from bundle (no bespoke sidecars).
|
|
856
1909
|
printJson({ state_status: stateStatus });
|
|
857
1910
|
return 0;
|
|
858
1911
|
}
|
|
1912
|
+
/**
|
|
1913
|
+
* Read the gate block signal from .flow-agents/.goal-fit-block-streak.json
|
|
1914
|
+
* (written by scripts/hooks/stop-goal-fit.js when block mode fires).
|
|
1915
|
+
* The file sits at <artifact-root>/.goal-fit-block-streak.json — one level
|
|
1916
|
+
* above the session artifact dir. Fail-open: returns { blocked: false } when
|
|
1917
|
+
* the file is absent or unreadable.
|
|
1918
|
+
*
|
|
1919
|
+
* @param artifactRoot The .flow-agents root dir (parent of session slug dir).
|
|
1920
|
+
*/
|
|
1921
|
+
export function readGateBlockSignal(artifactRoot) {
|
|
1922
|
+
const streakFile = path.join(artifactRoot, ".goal-fit-block-streak.json");
|
|
1923
|
+
try {
|
|
1924
|
+
if (!fs.existsSync(streakFile))
|
|
1925
|
+
return { blocked: false, hash: null, count: 0 };
|
|
1926
|
+
const raw = JSON.parse(fs.readFileSync(streakFile, "utf8"));
|
|
1927
|
+
const count = Number(raw?.count ?? 0);
|
|
1928
|
+
const hash = typeof raw?.hash === "string" ? raw.hash : null;
|
|
1929
|
+
return { blocked: count >= 1, hash, count };
|
|
1930
|
+
}
|
|
1931
|
+
catch {
|
|
1932
|
+
return { blocked: false, hash: null, count: 0 };
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
/**
|
|
1936
|
+
* Derive the gate-review calibration from a resolved InquiryRecord and the
|
|
1937
|
+
* block signal. Pure function — no I/O.
|
|
1938
|
+
*
|
|
1939
|
+
* Mapping (mirrors SKILL.md Bundle-Claim to Classification table):
|
|
1940
|
+
* outcome="matched", status="disputed"|"rejected", blocked=true → correct
|
|
1941
|
+
* outcome="matched", status="verified"|"assumed", blocked=true → false_block
|
|
1942
|
+
* outcome="matched", status="assumed", blocked=true → false_block
|
|
1943
|
+
* outcome="matched", status="stale"|"unknown", blocked=false → missed_block
|
|
1944
|
+
* outcome="matched", status="proposed", any → missed_block
|
|
1945
|
+
* outcome="unsupported" (absent claim), any → missed_block
|
|
1946
|
+
* outcome="derived", satisfied=true, any → correct/false_block by blocked flag
|
|
1947
|
+
* fallthrough → missed_block
|
|
1948
|
+
*/
|
|
1949
|
+
export function deriveGateCalibration(outcome, answerStatus, blocked) {
|
|
1950
|
+
if (outcome === "unsupported")
|
|
1951
|
+
return "missed_block";
|
|
1952
|
+
if (outcome === "matched" || outcome === "derived") {
|
|
1953
|
+
const s = answerStatus ?? "unknown";
|
|
1954
|
+
if (blocked) {
|
|
1955
|
+
if (s === "disputed" || s === "rejected")
|
|
1956
|
+
return "correct";
|
|
1957
|
+
if (s === "verified" || s === "assumed")
|
|
1958
|
+
return "false_block";
|
|
1959
|
+
// stale/unknown/proposed while blocked — gate fired without solid evidence
|
|
1960
|
+
return "false_block";
|
|
1961
|
+
}
|
|
1962
|
+
else {
|
|
1963
|
+
// Not blocked
|
|
1964
|
+
if (s === "stale" || s === "unknown" || s === "proposed")
|
|
1965
|
+
return "missed_block";
|
|
1966
|
+
// verified/assumed and no block — correct (no block warranted, none issued)
|
|
1967
|
+
return "correct";
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
return "missed_block";
|
|
1971
|
+
}
|
|
1972
|
+
/**
|
|
1973
|
+
* Compose the advisory proposed-fix string for a gate-review finding.
|
|
1974
|
+
* Pure function — no I/O.
|
|
1975
|
+
*/
|
|
1976
|
+
export function gateAdvisoryFix(calibration, claimId, answerStatus) {
|
|
1977
|
+
const s = answerStatus ?? "unknown";
|
|
1978
|
+
if (calibration === "correct") {
|
|
1979
|
+
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.`;
|
|
1980
|
+
}
|
|
1981
|
+
if (calibration === "false_block") {
|
|
1982
|
+
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.`;
|
|
1983
|
+
}
|
|
1984
|
+
// missed_block
|
|
1985
|
+
if (s === "stale") {
|
|
1986
|
+
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.`;
|
|
1987
|
+
}
|
|
1988
|
+
if (s === "absent") {
|
|
1989
|
+
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.`;
|
|
1990
|
+
}
|
|
1991
|
+
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.`;
|
|
1992
|
+
}
|
|
1993
|
+
/**
|
|
1994
|
+
* Build a schema-conformant InquiryRecord for the hachure inquiry-record.schema.json.
|
|
1995
|
+
* Strips Surface-internal fields (identityLinkIds, transitiveRuleIds) from
|
|
1996
|
+
* resolutionPath that are valid in the TS type but not in the JSON schema.
|
|
1997
|
+
* Sets answer.value to the gate-review value-add: { calibration, advisoryFix, gateFired, sessionSlug }.
|
|
1998
|
+
*/
|
|
1999
|
+
function toSchemaInquiryRecord(raw, calibration, advisoryFix, blocked, slug) {
|
|
2000
|
+
const resolutionPath = { claimIds: raw.resolutionPath.claimIds };
|
|
2001
|
+
if (raw.resolutionPath.ruleId !== undefined)
|
|
2002
|
+
resolutionPath["ruleId"] = raw.resolutionPath.ruleId;
|
|
2003
|
+
if (raw.resolutionPath.ruleVersion !== undefined)
|
|
2004
|
+
resolutionPath["ruleVersion"] = raw.resolutionPath.ruleVersion;
|
|
2005
|
+
const record = {
|
|
2006
|
+
id: raw.id,
|
|
2007
|
+
inquiry: raw.inquiry,
|
|
2008
|
+
outcome: raw.outcome,
|
|
2009
|
+
resolutionPath,
|
|
2010
|
+
inputSnapshot: raw.inputSnapshot,
|
|
2011
|
+
statusFunctionVersion: raw.statusFunctionVersion,
|
|
2012
|
+
resolvedAt: raw.resolvedAt,
|
|
2013
|
+
};
|
|
2014
|
+
// answer carries the canonical trust status AND gate-review's value-add advisory fix.
|
|
2015
|
+
// answer.status = derived TrustStatus from the resolved claim (or "unknown" when absent).
|
|
2016
|
+
// answer.value = { calibration, advisoryFix, gateFired, sessionSlug } — gate-review advisory.
|
|
2017
|
+
const answerStatus = raw.answer?.status ?? "unknown";
|
|
2018
|
+
record["answer"] = {
|
|
2019
|
+
status: answerStatus,
|
|
2020
|
+
value: {
|
|
2021
|
+
calibration,
|
|
2022
|
+
advisoryFix,
|
|
2023
|
+
gateFired: blocked,
|
|
2024
|
+
sessionSlug: slug,
|
|
2025
|
+
},
|
|
2026
|
+
};
|
|
2027
|
+
return record;
|
|
2028
|
+
}
|
|
2029
|
+
/**
|
|
2030
|
+
* Build an array of canonical InquiryRecords for all gate-fire and missed-block
|
|
2031
|
+
* candidates in the bundle, using Surface's resolveInquiry. Returns null when
|
|
2032
|
+
* Surface is unavailable (caller skips the output file — no fork fallback).
|
|
2033
|
+
*
|
|
2034
|
+
* @param bundle Parsed trust.bundle (BundleFile shape)
|
|
2035
|
+
* @param blockSignal Result of readGateBlockSignal()
|
|
2036
|
+
* @param slug Task slug (used in inquiry ids and session_slug)
|
|
2037
|
+
* @param expectedCriterionIds Optional list of expected criterion IDs to check
|
|
2038
|
+
* for absent claims (missed_block detection).
|
|
2039
|
+
* @param surface Loaded Surface module (must have resolveInquiry)
|
|
2040
|
+
* @param now Optional timestamp override for deterministic tests
|
|
2041
|
+
*/
|
|
2042
|
+
export function buildGateInquiryRecords(bundle, blockSignal, slug, expectedCriterionIds, surface, now) {
|
|
2043
|
+
const records = [];
|
|
2044
|
+
let idx = 0;
|
|
2045
|
+
const askedAt = (now ?? new Date()).toISOString();
|
|
2046
|
+
const bundleRecord = bundle;
|
|
2047
|
+
const claims = Array.isArray(bundle?.claims) ? bundle.claims : [];
|
|
2048
|
+
// Build a set of subjectIds already covered by bundle claims
|
|
2049
|
+
const claimSubjectIds = new Set(claims.map((c) => c.subjectId));
|
|
2050
|
+
// ── Step 1: resolve each bundle claim via resolveInquiry ──────────────────
|
|
2051
|
+
for (const claim of claims) {
|
|
2052
|
+
idx += 1;
|
|
2053
|
+
const inquiryId = `${slug}-gr-${idx}`;
|
|
2054
|
+
const inquiry = {
|
|
2055
|
+
id: inquiryId,
|
|
2056
|
+
question: `Was gate action on claim ${claim.id} (status: ${claim.status}) justified given the trust state?`,
|
|
2057
|
+
askedBy: "gate-review",
|
|
2058
|
+
askedAt,
|
|
2059
|
+
target: {
|
|
2060
|
+
subjectType: claim.subjectType,
|
|
2061
|
+
subjectId: claim.subjectId,
|
|
2062
|
+
fieldOrBehavior: claim.fieldOrBehavior,
|
|
2063
|
+
},
|
|
2064
|
+
metadata: { sessionSlug: slug, claimId: claim.id, blocked: blockSignal.blocked },
|
|
2065
|
+
};
|
|
2066
|
+
const rawRecord = surface.resolveInquiry(bundleRecord, inquiry, { now });
|
|
2067
|
+
const calibration = deriveGateCalibration(rawRecord.outcome, rawRecord.answer?.status, blockSignal.blocked);
|
|
2068
|
+
const advisoryFix = gateAdvisoryFix(calibration, claim.id, rawRecord.answer?.status ?? claim.status);
|
|
2069
|
+
records.push(toSchemaInquiryRecord(rawRecord, calibration, advisoryFix, blockSignal.blocked, slug));
|
|
2070
|
+
}
|
|
2071
|
+
// ── Step 2: resolve absent expected criteria (missed_block candidates) ────
|
|
2072
|
+
for (const criterionId of expectedCriterionIds) {
|
|
2073
|
+
const subjectId = `${slug}/${criterionId}`;
|
|
2074
|
+
// Skip if there's already a bundle claim for this criterion
|
|
2075
|
+
if (claimSubjectIds.has(subjectId) || claimSubjectIds.has(criterionId))
|
|
2076
|
+
continue;
|
|
2077
|
+
idx += 1;
|
|
2078
|
+
const inquiryId = `${slug}-gr-${idx}`;
|
|
2079
|
+
const inquiry = {
|
|
2080
|
+
id: inquiryId,
|
|
2081
|
+
question: `Was acceptance criterion "${criterionId}" claimed in the trust.bundle before gate evaluation?`,
|
|
2082
|
+
askedBy: "gate-review",
|
|
2083
|
+
askedAt,
|
|
2084
|
+
target: {
|
|
2085
|
+
subjectType: "workflow-check",
|
|
2086
|
+
subjectId,
|
|
2087
|
+
fieldOrBehavior: criterionId,
|
|
2088
|
+
},
|
|
2089
|
+
metadata: { sessionSlug: slug, criterionId, blocked: blockSignal.blocked, expectedCriterion: true },
|
|
2090
|
+
};
|
|
2091
|
+
const rawRecord = surface.resolveInquiry(bundleRecord, inquiry, { now });
|
|
2092
|
+
// outcome will be "unsupported" since no claim matches the absent criterion
|
|
2093
|
+
const calibration = deriveGateCalibration(rawRecord.outcome, rawRecord.answer?.status, blockSignal.blocked);
|
|
2094
|
+
const advisoryFix = gateAdvisoryFix(calibration, subjectId, "absent");
|
|
2095
|
+
records.push(toSchemaInquiryRecord(rawRecord, calibration, advisoryFix, blockSignal.blocked, slug));
|
|
2096
|
+
}
|
|
2097
|
+
// ── Step 3: if still empty (no claims, no expected criteria), emit one record
|
|
2098
|
+
if (records.length === 0) {
|
|
2099
|
+
idx += 1;
|
|
2100
|
+
const inquiryId = `${slug}-gr-${idx}`;
|
|
2101
|
+
const inquiry = {
|
|
2102
|
+
id: inquiryId,
|
|
2103
|
+
question: `Does the trust.bundle for session "${slug}" contain any claims for gate evaluation?`,
|
|
2104
|
+
askedBy: "gate-review",
|
|
2105
|
+
askedAt,
|
|
2106
|
+
// No target — natural-language-only inquiry → resolveInquiry returns "unsupported"
|
|
2107
|
+
metadata: { sessionSlug: slug, blocked: blockSignal.blocked, reason: "empty-bundle" },
|
|
2108
|
+
};
|
|
2109
|
+
const rawRecord = surface.resolveInquiry(bundleRecord, inquiry, { now });
|
|
2110
|
+
const advisoryFix = `Ensure \`workflow-sidecar record-evidence\` writes at least one claim to the trust.bundle for session \`${slug}\` before gate-review is invoked.`;
|
|
2111
|
+
records.push(toSchemaInquiryRecord(rawRecord, "missed_block", advisoryFix, blockSignal.blocked, slug));
|
|
2112
|
+
}
|
|
2113
|
+
return records;
|
|
2114
|
+
}
|
|
2115
|
+
/**
|
|
2116
|
+
* gate-review <artifact-dir>
|
|
2117
|
+
*
|
|
2118
|
+
* Reads the session's trust.bundle and the gate block signal, classifies each
|
|
2119
|
+
* gate fire or suspected miss using Surface's resolveInquiry, and emits
|
|
2120
|
+
* gate-review.inquiries.json as an array of canonical InquiryRecords.
|
|
2121
|
+
* ADVISORY ONLY — never modifies scripts/hooks/. Issue #119.
|
|
2122
|
+
*
|
|
2123
|
+
* The block signal is read from <artifact-root>/.goal-fit-block-streak.json,
|
|
2124
|
+
* written by scripts/hooks/stop-goal-fit.js when block mode fires. The file
|
|
2125
|
+
* lives one level above the session slug dir (the .flow-agents root).
|
|
2126
|
+
*
|
|
2127
|
+
* If @kontourai/surface is unavailable, logs a warning and returns 0
|
|
2128
|
+
* (fail-open — no bespoke fork fallback).
|
|
2129
|
+
*/
|
|
2130
|
+
async function gateReview(p) {
|
|
2131
|
+
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
2132
|
+
if (!fs.existsSync(dir))
|
|
2133
|
+
die(`artifact directory does not exist: ${dir}`);
|
|
2134
|
+
const slug = taskSlugFor(dir, opt(p, "task-slug"));
|
|
2135
|
+
// Locate trust.bundle — required per SKILL.md contract
|
|
2136
|
+
const bundlePath = path.join(dir, "trust.bundle");
|
|
2137
|
+
if (!fs.existsSync(bundlePath)) {
|
|
2138
|
+
process.stderr.write(`[gate-review] trust.bundle absent at ${bundlePath} — NOT_VERIFIED. Build ADR 0010 Phase 1 first.\n`);
|
|
2139
|
+
return 1;
|
|
2140
|
+
}
|
|
2141
|
+
// Load Surface (ESM, fail-open)
|
|
2142
|
+
const surface = await tryLoadSurface();
|
|
2143
|
+
if (!surface || typeof surface.resolveInquiry !== "function") {
|
|
2144
|
+
process.stderr.write(`[gate-review] @kontourai/surface unavailable or missing resolveInquiry — gate-review skipped (no fork fallback)\n`);
|
|
2145
|
+
return 0;
|
|
2146
|
+
}
|
|
2147
|
+
const bundle = JSON.parse(fs.readFileSync(bundlePath, "utf8"));
|
|
2148
|
+
// Read gate block signal from .flow-agents root (one level above session dir)
|
|
2149
|
+
const artifactRoot = path.dirname(dir);
|
|
2150
|
+
const blockSignal = readGateBlockSignal(artifactRoot);
|
|
2151
|
+
// Enumerate expected criterion IDs: primary = bundle claims (workflow.acceptance.criterion),
|
|
2152
|
+
// fallback = acceptance.json (back-compat for sessions without an up-to-date bundle).
|
|
2153
|
+
const criterionClaims = Array.isArray(bundle.claims)
|
|
2154
|
+
? bundle.claims.filter((c) => c.claimType === "workflow.acceptance.criterion")
|
|
2155
|
+
: [];
|
|
2156
|
+
let expectedCriterionIds;
|
|
2157
|
+
if (criterionClaims.length > 0) {
|
|
2158
|
+
// Extract the final segment of subjectId (e.g. "slug/AC1" → "AC1")
|
|
2159
|
+
expectedCriterionIds = criterionClaims
|
|
2160
|
+
.map((c) => String(c.subjectId ?? "").split("/").pop() ?? "")
|
|
2161
|
+
.filter(Boolean);
|
|
2162
|
+
}
|
|
2163
|
+
else {
|
|
2164
|
+
// Fallback: read acceptance.json (back-compat for sessions without criterion claims)
|
|
2165
|
+
const acceptancePath = path.join(dir, "acceptance.json");
|
|
2166
|
+
const acceptance = fs.existsSync(acceptancePath) ? loadJson(acceptancePath) : null;
|
|
2167
|
+
expectedCriterionIds = Array.isArray(acceptance?.criteria)
|
|
2168
|
+
? acceptance.criteria.map((c) => String(c.id ?? "")).filter(Boolean)
|
|
2169
|
+
: [];
|
|
2170
|
+
}
|
|
2171
|
+
const records = buildGateInquiryRecords(bundle, blockSignal, slug, expectedCriterionIds, surface);
|
|
2172
|
+
// Validate each record against the hachure inquiry-record.schema.json (fail-open)
|
|
2173
|
+
const validator = getHachureInquiryRecordValidator();
|
|
2174
|
+
let schemaValid = true;
|
|
2175
|
+
const validationErrors = [];
|
|
2176
|
+
for (const record of records) {
|
|
2177
|
+
if (validator) {
|
|
2178
|
+
const result = validator(record);
|
|
2179
|
+
if (!result.valid) {
|
|
2180
|
+
schemaValid = false;
|
|
2181
|
+
validationErrors.push(...result.errors.map((e) => `${record["id"] ?? "?"}: ${e}`));
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
if (!schemaValid) {
|
|
2186
|
+
process.stderr.write(`[gate-review] InquiryRecord schema validation errors:\n${validationErrors.join("\n")}\n`);
|
|
2187
|
+
}
|
|
2188
|
+
const outputPath = path.join(dir, "gate-review.inquiries.json");
|
|
2189
|
+
writeJson(outputPath, records);
|
|
2190
|
+
// Build summary counts by calibration
|
|
2191
|
+
const counts = {};
|
|
2192
|
+
for (const r of records) {
|
|
2193
|
+
const cal = r["answer"]?.["value"]?.["calibration"] ?? "unknown";
|
|
2194
|
+
counts[cal] = (counts[cal] ?? 0) + 1;
|
|
2195
|
+
}
|
|
2196
|
+
const summary = Object.entries(counts)
|
|
2197
|
+
.filter(([, n]) => n > 0)
|
|
2198
|
+
.map(([k, n]) => `${k}=${n}`)
|
|
2199
|
+
.join(", ");
|
|
2200
|
+
const schemaTag = validator ? (schemaValid ? " schema:valid" : " schema:INVALID") : " schema:unavailable";
|
|
2201
|
+
console.log(`gate-review: ${records.length} InquiryRecord(s) [${summary}]${schemaTag} → ${outputPath}`);
|
|
2202
|
+
return 0;
|
|
2203
|
+
}
|
|
2204
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2205
|
+
// ─── ADR 0010 Phase 3: project the local trust.bundle to the Surface Trust Panel ──
|
|
2206
|
+
// Surface owns derivation (buildTrustReport) AND rendering (the dependency-free
|
|
2207
|
+
// <surface-trust-panel> element). Flow Agents only assembles a standalone HTML
|
|
2208
|
+
// shell — no trust logic or rendering reimplemented (consume-never-fork).
|
|
2209
|
+
/** Locate Surface's self-contained, dependency-free panel element (ESM, no require). */
|
|
2210
|
+
function loadSurfacePanelJs() {
|
|
2211
|
+
let d = path.dirname(fileURLToPath(import.meta.url));
|
|
2212
|
+
for (let i = 0; i < 12; i += 1) {
|
|
2213
|
+
try {
|
|
2214
|
+
return fs.readFileSync(path.join(d, "node_modules/@kontourai/surface/dist/src/trust-panel/surface-trust-panel.js"), "utf8");
|
|
2215
|
+
}
|
|
2216
|
+
catch { /* walk up */ }
|
|
2217
|
+
const parent = path.dirname(d);
|
|
2218
|
+
if (parent === d)
|
|
2219
|
+
break;
|
|
2220
|
+
d = parent;
|
|
2221
|
+
}
|
|
2222
|
+
die("could not locate @kontourai/surface trust-panel element (dist/src/trust-panel/surface-trust-panel.js)");
|
|
2223
|
+
return "";
|
|
2224
|
+
}
|
|
2225
|
+
async function renderTrustPanel(p) {
|
|
2226
|
+
const root = path.resolve(opt(p, "artifact-root", ".flow-agents"));
|
|
2227
|
+
const dir = p.positional[0] ? artifactDirFrom(p.positional[0]) : currentDir(root);
|
|
2228
|
+
if (!dir)
|
|
2229
|
+
die("render-trust-panel requires a workflow dir or a recorded current session");
|
|
2230
|
+
let bundle = null;
|
|
2231
|
+
try {
|
|
2232
|
+
bundle = JSON.parse(fs.readFileSync(path.join(dir, "trust.bundle"), "utf8"));
|
|
2233
|
+
}
|
|
2234
|
+
catch {
|
|
2235
|
+
bundle = null;
|
|
2236
|
+
}
|
|
2237
|
+
if (!bundle)
|
|
2238
|
+
die(`no trust.bundle at ${path.join(dir, "trust.bundle")} — run record-evidence first`);
|
|
2239
|
+
const surface = (await import("@kontourai/surface"));
|
|
2240
|
+
if (typeof surface.buildTrustReport !== "function")
|
|
2241
|
+
die("@kontourai/surface buildTrustReport unavailable — cannot derive the trust report");
|
|
2242
|
+
const report = surface.buildTrustReport(bundle);
|
|
2243
|
+
// diffFreshness on resume: if a prior trust.checkpoint.json exists, surface the
|
|
2244
|
+
// fresh→stale transitions so the user sees what has gone stale since the last seal.
|
|
2245
|
+
const checkpointFile = path.join(dir, "trust.checkpoint.json");
|
|
2246
|
+
if (fs.existsSync(checkpointFile) && typeof surface.diffFreshness === "function") {
|
|
2247
|
+
try {
|
|
2248
|
+
const envelope = JSON.parse(fs.readFileSync(checkpointFile, "utf8"));
|
|
2249
|
+
const priorCheckpoint = envelope.checkpoint;
|
|
2250
|
+
if (priorCheckpoint && typeof priorCheckpoint === "object") {
|
|
2251
|
+
const transitions = surface.diffFreshness(priorCheckpoint, report);
|
|
2252
|
+
const staleTransitions = transitions.filter((t) => t["to"] === "stale");
|
|
2253
|
+
if (staleTransitions.length > 0) {
|
|
2254
|
+
const claimIds = staleTransitions.map((t) => String(t["claimId"] ?? "")).filter(Boolean);
|
|
2255
|
+
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`);
|
|
2256
|
+
}
|
|
2257
|
+
else {
|
|
2258
|
+
process.stderr.write(`[trust-checkpoint] 0 claims went stale since the last checkpoint (sealed ${String(envelope.sealed_at ?? "unknown")}).\n`);
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
catch {
|
|
2263
|
+
/* diffFreshness is advisory — never block the panel render */
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
const panelJs = loadSurfacePanelJs();
|
|
2267
|
+
const heading = `Flow Agents trust — ${String(path.basename(dir)).replace(/[<>"&]/g, "")}`;
|
|
2268
|
+
const reportJson = JSON.stringify(report).replace(/</g, "\\u003c");
|
|
2269
|
+
const html = `<!doctype html>
|
|
2270
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${heading}</title></head>
|
|
2271
|
+
<body style="margin:0;padding:1.5rem;background:#f4f1e6">
|
|
2272
|
+
<script type="module">
|
|
2273
|
+
${panelJs}
|
|
2274
|
+
</script>
|
|
2275
|
+
<surface-trust-panel heading="${heading}"></surface-trust-panel>
|
|
2276
|
+
<script id="trust-report" type="application/json">${reportJson}</script>
|
|
2277
|
+
<script type="module">document.querySelector("surface-trust-panel").report = JSON.parse(document.getElementById("trust-report").textContent);</script>
|
|
2278
|
+
</body></html>
|
|
2279
|
+
`;
|
|
2280
|
+
const out = opt(p, "out") || path.join(dir, "trust-panel.html");
|
|
2281
|
+
fs.writeFileSync(out, html);
|
|
2282
|
+
// Also emit the derived report as a first-class artifact — the universal input for
|
|
2283
|
+
// Surface's hosted Snapshot Viewer and a bare `<surface-trust-panel src=…>` (the HTML
|
|
2284
|
+
// above already embeds it). Suppress with --no-report.
|
|
2285
|
+
let reportOut = "";
|
|
2286
|
+
if (!p.flags.has("no-report")) {
|
|
2287
|
+
reportOut = opt(p, "report-out") || path.join(dir, "trust-report.json");
|
|
2288
|
+
fs.writeFileSync(reportOut, `${JSON.stringify(report, null, 2)}\n`);
|
|
2289
|
+
}
|
|
2290
|
+
console.log(out);
|
|
2291
|
+
if (reportOut)
|
|
2292
|
+
console.log(reportOut);
|
|
2293
|
+
return 0;
|
|
2294
|
+
}
|
|
2295
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2296
|
+
// ─── flow-agents#137 / ADR 0011: wire Surface's MCP to surface trust reports ──
|
|
2297
|
+
// Flow Agents produces the bundle; Surface's MCP projects it. `--mode print` is the
|
|
2298
|
+
// zero-write default (output the snippet). `enable`/`disable` edit a runtime JSON MCP
|
|
2299
|
+
// config (e.g. Claude Code `.mcp.json`) via a *conventional managed key* — idempotent,
|
|
2300
|
+
// reversible, and only ever our own entry (never auto-injected; opt-in only).
|
|
2301
|
+
const TRUST_MCP_SERVER = "flow-agents-surface-trust";
|
|
2302
|
+
function trustMcpRegistration() {
|
|
2303
|
+
// No static `--input` (a single file can't follow many per-task bundles or a moving
|
|
2304
|
+
// current); the skill passes the active task's bundle as a per-call `path` arg.
|
|
2305
|
+
return { command: "npx", args: ["-y", "@kontourai/surface", "mcp"] };
|
|
2306
|
+
}
|
|
2307
|
+
function trustMcp(p) {
|
|
2308
|
+
const mode = opt(p, "mode", "print");
|
|
2309
|
+
if (mode === "print") {
|
|
2310
|
+
console.log(JSON.stringify({ mcpServers: { [TRUST_MCP_SERVER]: trustMcpRegistration() } }, null, 2));
|
|
2311
|
+
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`);
|
|
2312
|
+
process.stderr.write(`# To view a task's trust inline, call surface_summary with path=<.flow-agents/<slug>/trust.bundle>.\n`);
|
|
2313
|
+
return 0;
|
|
2314
|
+
}
|
|
2315
|
+
if (mode !== "enable" && mode !== "disable")
|
|
2316
|
+
die("trust-mcp --mode must be print|enable|disable");
|
|
2317
|
+
const configPath = path.resolve(opt(p, "config", ".mcp.json"));
|
|
2318
|
+
let config = {};
|
|
2319
|
+
try {
|
|
2320
|
+
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
2321
|
+
}
|
|
2322
|
+
catch {
|
|
2323
|
+
config = {};
|
|
2324
|
+
}
|
|
2325
|
+
if (typeof config !== "object" || config === null || Array.isArray(config))
|
|
2326
|
+
die(`${configPath} is not a JSON object — refusing to edit`);
|
|
2327
|
+
if (!config.mcpServers || typeof config.mcpServers !== "object" || Array.isArray(config.mcpServers))
|
|
2328
|
+
config.mcpServers = {};
|
|
2329
|
+
if (mode === "enable") {
|
|
2330
|
+
config.mcpServers[TRUST_MCP_SERVER] = trustMcpRegistration();
|
|
2331
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
2332
|
+
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
2333
|
+
console.log(`enabled ${TRUST_MCP_SERVER} in ${configPath} (remove with: trust-mcp --mode disable)`);
|
|
2334
|
+
return 0;
|
|
2335
|
+
}
|
|
2336
|
+
// disable: remove only our own conventional entry; leave everything else untouched.
|
|
2337
|
+
if (Object.prototype.hasOwnProperty.call(config.mcpServers, TRUST_MCP_SERVER)) {
|
|
2338
|
+
delete config.mcpServers[TRUST_MCP_SERVER];
|
|
2339
|
+
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
2340
|
+
console.log(`disabled ${TRUST_MCP_SERVER} in ${configPath}`);
|
|
2341
|
+
}
|
|
2342
|
+
else {
|
|
2343
|
+
console.log(`${TRUST_MCP_SERVER} not present in ${configPath} — nothing to remove`);
|
|
2344
|
+
}
|
|
2345
|
+
return 0;
|
|
2346
|
+
}
|
|
2347
|
+
// ─── ADR 0012: agent coordination as liveness claims (policy-centered) ──────────
|
|
2348
|
+
// A work-claim is a regular Hachure claim governed by a *liveness policy* (ttl +
|
|
2349
|
+
// heartbeat → held/stale/released), keyed by the work-item subjectId, appended to a
|
|
2350
|
+
// shared stream all agents read. Status is RECOMPUTED via Surface's deriveTrustStatus
|
|
2351
|
+
// (no forked logic). Advisory, not a lock. The liveness policy is a general archetype
|
|
2352
|
+
// (not use-case-specific) and is a candidate to graduate upstream into Surface.
|
|
2353
|
+
const LIVENESS_POLICY = {
|
|
2354
|
+
id: "policy:liveness.hold",
|
|
2355
|
+
claimType: "liveness.hold",
|
|
2356
|
+
requiredEvidence: [],
|
|
2357
|
+
acceptanceCriteria: ["A heartbeat within ttlSeconds holds the claim; a lapse or release frees it."],
|
|
2358
|
+
reviewAuthority: "system",
|
|
2359
|
+
validityRule: { kind: "duration", durationDays: 1 },
|
|
2360
|
+
stalenessTriggers: [],
|
|
2361
|
+
conflictRules: [],
|
|
2362
|
+
impactLevel: "medium",
|
|
2363
|
+
};
|
|
2364
|
+
function livenessStreamFile(root) { return path.join(root, "liveness", "events.jsonl"); }
|
|
2365
|
+
function appendLivenessEvent(root, evt) {
|
|
2366
|
+
const file = livenessStreamFile(root);
|
|
2367
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
2368
|
+
fs.appendFileSync(file, `${JSON.stringify(evt)}\n`);
|
|
2369
|
+
}
|
|
2370
|
+
function readLivenessEvents(root) {
|
|
2371
|
+
// Delegate to the shared pure-CJS helper (scripts/hooks/lib/liveness-read.js).
|
|
2372
|
+
// Using createRequire so the ESM sidecar can load a CJS module without bundling it.
|
|
2373
|
+
try {
|
|
2374
|
+
const _req = createRequire(import.meta.url);
|
|
2375
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-read.js");
|
|
2376
|
+
const helper = _req(helperPath);
|
|
2377
|
+
return helper.readLivenessEvents(livenessStreamFile(root));
|
|
2378
|
+
}
|
|
2379
|
+
catch {
|
|
2380
|
+
// Fallback: read inline (keeps sidecar self-sufficient if helper is unavailable)
|
|
2381
|
+
let raw = "";
|
|
2382
|
+
try {
|
|
2383
|
+
raw = fs.readFileSync(livenessStreamFile(root), "utf8");
|
|
2384
|
+
}
|
|
2385
|
+
catch {
|
|
2386
|
+
return [];
|
|
2387
|
+
}
|
|
2388
|
+
return raw.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => { try {
|
|
2389
|
+
return JSON.parse(l);
|
|
2390
|
+
}
|
|
2391
|
+
catch {
|
|
2392
|
+
return null;
|
|
2393
|
+
} }).filter((x) => x !== null);
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
function livenessLabel(status) {
|
|
2397
|
+
if (status === "verified")
|
|
2398
|
+
return "held";
|
|
2399
|
+
if (status === "stale" || status === "revoked")
|
|
2400
|
+
return "free"; // reclaimable: lapsed or released
|
|
2401
|
+
if (status === "superseded")
|
|
2402
|
+
return "superseded";
|
|
2403
|
+
return status;
|
|
2404
|
+
}
|
|
2405
|
+
// ─── ADR 0012 lifecycle-driven liveness (opt-in via FLOW_AGENTS_LIVENESS) ──────
|
|
2406
|
+
// init-plan claims the work-item; advance-state heartbeats (or releases on terminal),
|
|
2407
|
+
// so the workflow lifecycle itself maintains the liveness claim — no manual liveness calls.
|
|
2408
|
+
// Additive + fail-open: a liveness-emit failure never affects the workflow command.
|
|
2409
|
+
const LIVENESS_TERMINAL = new Set(["delivered", "accepted", "archived"]);
|
|
2410
|
+
function resolveLivenessActor() { return (process.env.FLOW_AGENTS_ACTOR || "").trim() || "local"; }
|
|
2411
|
+
function livenessEnabled() { const v = String(process.env.FLOW_AGENTS_LIVENESS || "").trim().toLowerCase(); return v === "on" || v === "1" || v === "true"; }
|
|
2412
|
+
function livenessLifecycle(taskDir, slug, kind, timestamp) {
|
|
2413
|
+
if (!livenessEnabled())
|
|
2414
|
+
return;
|
|
2415
|
+
try {
|
|
2416
|
+
const root = path.dirname(taskDir); // .flow-agents/<slug> → .flow-agents (the shared liveness stream lives here)
|
|
2417
|
+
const evt = { type: kind, subjectId: slug, actor: resolveLivenessActor(), at: timestamp, source: "lifecycle" };
|
|
2418
|
+
if (kind === "claim")
|
|
2419
|
+
evt.ttlSeconds = 1800;
|
|
2420
|
+
appendLivenessEvent(root, evt);
|
|
2421
|
+
}
|
|
2422
|
+
catch { /* best-effort; liveness is advisory and must never break the workflow */ }
|
|
2423
|
+
}
|
|
2424
|
+
async function liveness(p) {
|
|
2425
|
+
const root = path.resolve(opt(p, "artifact-root", ".flow-agents"));
|
|
2426
|
+
const action = p.positional[0] || "";
|
|
2427
|
+
const subjectId = p.positional[1] || "";
|
|
2428
|
+
const actor = opt(p, "actor", process.env.FLOW_AGENTS_ACTOR || "unknown");
|
|
2429
|
+
const nowIso = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
2430
|
+
if (action === "claim" || action === "heartbeat" || action === "release") {
|
|
2431
|
+
if (!subjectId)
|
|
2432
|
+
die(`liveness ${action} requires a subjectId`);
|
|
2433
|
+
const evt = { type: action, subjectId, actor, at: opt(p, "at") || nowIso };
|
|
2434
|
+
if (action === "claim")
|
|
2435
|
+
evt.ttlSeconds = Number.parseInt(opt(p, "ttl", "1800"), 10) || 1800;
|
|
2436
|
+
appendLivenessEvent(root, evt);
|
|
2437
|
+
console.log(`liveness ${action}: ${subjectId} by ${actor}`);
|
|
2438
|
+
return 0;
|
|
2439
|
+
}
|
|
2440
|
+
if (action === "status") {
|
|
2441
|
+
const surface = (await import("@kontourai/surface"));
|
|
2442
|
+
if (typeof surface.deriveTrustStatus !== "function")
|
|
2443
|
+
die("@kontourai/surface deriveTrustStatus unavailable — requires surface >= 1.2");
|
|
2444
|
+
const subjectFilter = opt(p, "subject");
|
|
2445
|
+
const now = opt(p, "now") ? new Date(opt(p, "now")) : new Date();
|
|
2446
|
+
// Group events by subjectId::actor — one liveness claim per holder of a subject.
|
|
2447
|
+
const groups = new Map();
|
|
2448
|
+
for (const e of readLivenessEvents(root)) {
|
|
2449
|
+
if (!e.subjectId || !e.actor)
|
|
2450
|
+
continue;
|
|
2451
|
+
const key = `${e.subjectId}::${e.actor}`;
|
|
2452
|
+
let g = groups.get(key);
|
|
2453
|
+
if (!g) {
|
|
2454
|
+
g = { subjectId: String(e.subjectId), actor: String(e.actor), ttlSeconds: 1800, created: String(e.at), updated: String(e.at), events: [] };
|
|
2455
|
+
groups.set(key, g);
|
|
2456
|
+
}
|
|
2457
|
+
g.updated = String(e.at);
|
|
2458
|
+
if (e.type === "claim") {
|
|
2459
|
+
g.ttlSeconds = Number(e.ttlSeconds) || g.ttlSeconds;
|
|
2460
|
+
g.events.push({ id: `c:${key}:${e.at}`, claimId: key, status: "verified", actor: g.actor, method: "observation", evidenceIds: [], createdAt: e.at, verifiedAt: e.at });
|
|
2461
|
+
}
|
|
2462
|
+
else if (e.type === "heartbeat") {
|
|
2463
|
+
g.events.push({ id: `h:${key}:${e.at}`, claimId: key, status: "verified", actor: g.actor, method: "observation", evidenceIds: [], createdAt: e.at, verifiedAt: e.at });
|
|
2464
|
+
}
|
|
2465
|
+
else if (e.type === "release") {
|
|
2466
|
+
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 });
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
const rows = [];
|
|
2470
|
+
for (const g of groups.values()) {
|
|
2471
|
+
if (subjectFilter && g.subjectId !== subjectFilter)
|
|
2472
|
+
continue;
|
|
2473
|
+
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 };
|
|
2474
|
+
const status = surface.deriveTrustStatus({ claim, evidence: [], policy: LIVENESS_POLICY, events: g.events, now });
|
|
2475
|
+
rows.push({ subjectId: g.subjectId, actor: g.actor, status, label: livenessLabel(status) });
|
|
2476
|
+
}
|
|
2477
|
+
if (p.flags.has("json")) {
|
|
2478
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
2479
|
+
return 0;
|
|
2480
|
+
}
|
|
2481
|
+
for (const r of rows)
|
|
2482
|
+
console.log(`${r.subjectId}\t${r.actor}\t${r.label}`);
|
|
2483
|
+
return 0;
|
|
2484
|
+
}
|
|
2485
|
+
die("liveness action must be one of: claim | heartbeat | release | status");
|
|
2486
|
+
return 1;
|
|
2487
|
+
}
|
|
2488
|
+
/**
|
|
2489
|
+
* Build a structured explanation for a specific claim.
|
|
2490
|
+
* PURE: report + bundle + id in, structured explanation out.
|
|
2491
|
+
* No fs, no CLI, no .flow-agents paths. Promotable to Surface #171.
|
|
2492
|
+
*
|
|
2493
|
+
* @param report TrustReport from buildTrustReport(bundle) — required for derived status
|
|
2494
|
+
* @param bundle Raw parsed trust.bundle (BundleFile shape)
|
|
2495
|
+
* @param claimId The claim id to explain
|
|
2496
|
+
*/
|
|
2497
|
+
export function buildClaimExplanation(report, bundle, claimId) {
|
|
2498
|
+
const reportClaims = Array.isArray(report.claims) ? report.claims : [];
|
|
2499
|
+
const reportClaim = reportClaims.find((c) => c.id === claimId);
|
|
2500
|
+
if (!reportClaim) {
|
|
2501
|
+
return {
|
|
2502
|
+
found: false,
|
|
2503
|
+
status: "unknown",
|
|
2504
|
+
value: "",
|
|
2505
|
+
claimType: "",
|
|
2506
|
+
evidence: [],
|
|
2507
|
+
policy: null,
|
|
2508
|
+
why: { directInputs: [], leafClaims: [], diagnostics: [], transparencyGaps: [], changeRecords: [] },
|
|
2509
|
+
};
|
|
2510
|
+
}
|
|
2511
|
+
const bundleClaims = Array.isArray(bundle.claims) ? bundle.claims : [];
|
|
2512
|
+
const bundleClaim = bundleClaims.find((c) => c.id === claimId) ?? reportClaim;
|
|
2513
|
+
const bundlePolicies = Array.isArray(bundle.policies) ? bundle.policies : [];
|
|
2514
|
+
const bundleEvidence = Array.isArray(bundle.evidence) ? bundle.evidence : [];
|
|
2515
|
+
// Governing policy — follow verificationPolicyId into bundle.policies[]
|
|
2516
|
+
const verificationPolicyId = typeof bundleClaim.verificationPolicyId === "string" ? bundleClaim.verificationPolicyId : undefined;
|
|
2517
|
+
const rawPolicy = verificationPolicyId ? bundlePolicies.find((p) => p.id === verificationPolicyId) : undefined;
|
|
2518
|
+
const policy = rawPolicy
|
|
2519
|
+
? {
|
|
2520
|
+
id: String(rawPolicy.id ?? ""),
|
|
2521
|
+
requiredEvidence: Array.isArray(rawPolicy.requiredEvidence) ? rawPolicy.requiredEvidence : [],
|
|
2522
|
+
requiredMethods: Array.isArray(rawPolicy.requiredMethods) ? rawPolicy.requiredMethods : undefined,
|
|
2523
|
+
acceptanceCriteria: Array.isArray(rawPolicy.acceptanceCriteria) ? rawPolicy.acceptanceCriteria : [],
|
|
2524
|
+
reviewAuthority: String(rawPolicy.reviewAuthority ?? ""),
|
|
2525
|
+
}
|
|
2526
|
+
: null;
|
|
2527
|
+
// Evidence enhancement: pull evidence items for this claim, surface the execution block
|
|
2528
|
+
const claimEvidenceItems = bundleEvidence.filter((ev) => ev && ev.claimId === claimId);
|
|
2529
|
+
const evidence = claimEvidenceItems.map((ev) => {
|
|
2530
|
+
const exec = ev.execution && typeof ev.execution === "object" ? ev.execution : null;
|
|
2531
|
+
const execution = exec
|
|
2532
|
+
? {
|
|
2533
|
+
runner: String(exec.runner ?? exec.label ?? ""),
|
|
2534
|
+
label: String(exec.label ?? exec.runner ?? ""),
|
|
2535
|
+
isError: Boolean(exec.isError ?? (typeof exec.exitCode === "number" && exec.exitCode !== 0)),
|
|
2536
|
+
exitCode: typeof exec.exitCode === "number" ? exec.exitCode : null,
|
|
2537
|
+
}
|
|
2538
|
+
: null;
|
|
2539
|
+
return {
|
|
2540
|
+
evidenceType: String(ev.evidenceType ?? ev.type ?? "unknown"),
|
|
2541
|
+
label: String(ev.label ?? ev.excerptOrSummary ?? ev.sourceRef ?? ev.id ?? ""),
|
|
2542
|
+
execution,
|
|
2543
|
+
passing: execution ? !execution.isError : String(ev.status ?? "") !== "disputed",
|
|
2544
|
+
summary: String(ev.excerptOrSummary ?? ev.summary ?? ev.label ?? ""),
|
|
2545
|
+
};
|
|
2546
|
+
});
|
|
2547
|
+
// Drilldown: extract from report structure (report.transparencyGaps, report.changeRecords)
|
|
2548
|
+
const allGaps = Array.isArray(report.transparencyGaps) ? report.transparencyGaps : [];
|
|
2549
|
+
const allChanges = Array.isArray(report.changeRecords) ? report.changeRecords : [];
|
|
2550
|
+
const transparencyGaps = allGaps.filter((g) => g && g.claimId === claimId);
|
|
2551
|
+
const changeRecords = allChanges.filter((c) => c && c.claimId === claimId);
|
|
2552
|
+
return {
|
|
2553
|
+
found: true,
|
|
2554
|
+
status: String(reportClaim.status ?? "unknown"),
|
|
2555
|
+
value: String(bundleClaim.value ?? reportClaim.value ?? ""),
|
|
2556
|
+
claimType: String(bundleClaim.claimType ?? reportClaim.claimType ?? ""),
|
|
2557
|
+
evidence,
|
|
2558
|
+
policy,
|
|
2559
|
+
why: {
|
|
2560
|
+
directInputs: [], // populated by buildDerivationDrilldown if non-leaf
|
|
2561
|
+
leafClaims: [],
|
|
2562
|
+
diagnostics: [],
|
|
2563
|
+
transparencyGaps,
|
|
2564
|
+
changeRecords,
|
|
2565
|
+
},
|
|
2566
|
+
};
|
|
2567
|
+
}
|
|
2568
|
+
/**
|
|
2569
|
+
* claim <id> <dir>
|
|
2570
|
+
*
|
|
2571
|
+
* Look up a specific claim in the session's trust.bundle and print:
|
|
2572
|
+
* - Derived status and raw value
|
|
2573
|
+
* - Failing evidence items (with execution block: runner, exitCode, isError)
|
|
2574
|
+
* - Governing VerificationPolicy (how-to-verify)
|
|
2575
|
+
* - Derivation drilldown / transparency gaps (why it is in that state)
|
|
2576
|
+
*
|
|
2577
|
+
* --json Emit the structured ClaimExplanation object instead of text.
|
|
2578
|
+
*
|
|
2579
|
+
* Usage: workflow-sidecar claim <claimId> <artifactDir>
|
|
2580
|
+
*/
|
|
2581
|
+
async function claimLookup(p) {
|
|
2582
|
+
const claimId = p.positional[0] || die("claim id is required (first positional argument)");
|
|
2583
|
+
const rawDir = p.positional[1] || die("artifact directory is required (second positional argument)");
|
|
2584
|
+
const dir = path.resolve(rawDir);
|
|
2585
|
+
const bundlePath = path.join(dir, "trust.bundle");
|
|
2586
|
+
if (!fs.existsSync(bundlePath)) {
|
|
2587
|
+
process.stderr.write(`[claim] no trust.bundle at ${bundlePath} — run record-evidence first
|
|
2588
|
+
`);
|
|
2589
|
+
return 1;
|
|
2590
|
+
}
|
|
2591
|
+
const bundle = JSON.parse(fs.readFileSync(bundlePath, "utf8"));
|
|
2592
|
+
const bundleClaims = Array.isArray(bundle.claims) ? bundle.claims : [];
|
|
2593
|
+
const bundleClaim = bundleClaims.find((c) => c.id === claimId);
|
|
2594
|
+
if (!bundleClaim) {
|
|
2595
|
+
const available = bundleClaims.map((c) => c.id).join("\n ");
|
|
2596
|
+
process.stderr.write(`[claim] unknown claim id: ${claimId}
|
|
2597
|
+
Available claim ids:
|
|
2598
|
+
${available || "(none — bundle has no claims)"}
|
|
2599
|
+
`);
|
|
2600
|
+
return 1;
|
|
2601
|
+
}
|
|
2602
|
+
// Load Surface via tryLoadSurface() (ESM, cached, fail-open pattern)
|
|
2603
|
+
const surface = await tryLoadSurface();
|
|
2604
|
+
if (!surface || typeof surface.buildTrustReport !== "function" || typeof surface.buildDerivationDrilldown !== "function") {
|
|
2605
|
+
process.stderr.write(`[claim] @kontourai/surface unavailable or missing buildTrustReport/buildDerivationDrilldown
|
|
2606
|
+
`);
|
|
2607
|
+
return 0; // fail-open, consistent with gate-review pattern
|
|
2608
|
+
}
|
|
2609
|
+
// Build TrustReport (required — buildDerivationDrilldown needs TrustReport, not TrustBundle)
|
|
2610
|
+
const report = surface.buildTrustReport(bundle);
|
|
2611
|
+
// Build the structured explanation (pure, promotable to #171)
|
|
2612
|
+
const explanation = buildClaimExplanation(report, bundle, claimId);
|
|
2613
|
+
// Enrich the why.directInputs/leafClaims/diagnostics from the drilldown
|
|
2614
|
+
try {
|
|
2615
|
+
const drilldown = surface.buildDerivationDrilldown(report, claimId);
|
|
2616
|
+
if (drilldown) {
|
|
2617
|
+
explanation.why.directInputs = Array.isArray(drilldown.directInputs) ? drilldown.directInputs : [];
|
|
2618
|
+
explanation.why.leafClaims = Array.isArray(drilldown.leafClaims) ? drilldown.leafClaims : [];
|
|
2619
|
+
explanation.why.diagnostics = Array.isArray(drilldown.diagnostics) ? drilldown.diagnostics : [];
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
catch {
|
|
2623
|
+
// buildDerivationDrilldown threw (e.g. claim not in report) — proceed without drilldown
|
|
2624
|
+
}
|
|
2625
|
+
if (p.flags.has("json")) {
|
|
2626
|
+
console.log(JSON.stringify(explanation, null, 2));
|
|
2627
|
+
return 0;
|
|
2628
|
+
}
|
|
2629
|
+
// ── Human-readable output ───────────────────────────────────────────────────
|
|
2630
|
+
const lines = [];
|
|
2631
|
+
lines.push(`Claim: ${claimId}`);
|
|
2632
|
+
lines.push(`Status: ${explanation.status} Value: ${explanation.value}`);
|
|
2633
|
+
lines.push(`Type: ${explanation.claimType}`);
|
|
2634
|
+
lines.push("");
|
|
2635
|
+
// Evidence section — failing items are the concrete "why disputed"
|
|
2636
|
+
const failingEvidence = explanation.evidence.filter((ev) => !ev.passing);
|
|
2637
|
+
const allEvidence = explanation.evidence;
|
|
2638
|
+
if (allEvidence.length > 0) {
|
|
2639
|
+
lines.push("Evidence:");
|
|
2640
|
+
for (const ev of allEvidence) {
|
|
2641
|
+
const passMark = ev.passing ? "pass" : "FAIL";
|
|
2642
|
+
const execStr = ev.execution
|
|
2643
|
+
? ` [runner: ${ev.execution.runner}, exitCode: ${ev.execution.exitCode ?? "?"}, isError: ${ev.execution.isError}]`
|
|
2644
|
+
: "";
|
|
2645
|
+
lines.push(` [${passMark}] ${ev.evidenceType}: ${ev.label || ev.summary}${execStr}`);
|
|
2646
|
+
}
|
|
2647
|
+
if (failingEvidence.length > 0) {
|
|
2648
|
+
lines.push("");
|
|
2649
|
+
lines.push(`Failing evidence (disputed because):`);
|
|
2650
|
+
for (const ev of failingEvidence) {
|
|
2651
|
+
const execStr = ev.execution
|
|
2652
|
+
? ` ${ev.execution.runner} exited ${ev.execution.exitCode ?? "?"} (isError: ${ev.execution.isError})`
|
|
2653
|
+
: "";
|
|
2654
|
+
lines.push(` ${ev.evidenceType}: ${ev.label || ev.summary}${execStr}`);
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
else {
|
|
2659
|
+
lines.push("Evidence: (none recorded for this claim)");
|
|
2660
|
+
}
|
|
2661
|
+
lines.push("");
|
|
2662
|
+
// Policy section — how-to-verify
|
|
2663
|
+
if (explanation.policy) {
|
|
2664
|
+
const pol = explanation.policy;
|
|
2665
|
+
lines.push(`Governing Policy (${pol.id}):`);
|
|
2666
|
+
lines.push(` requiredEvidence: [${pol.requiredEvidence.join(", ")}]`);
|
|
2667
|
+
if (pol.requiredMethods && pol.requiredMethods.length > 0) {
|
|
2668
|
+
lines.push(` requiredMethods: [${pol.requiredMethods.join(", ")}]`);
|
|
2669
|
+
}
|
|
2670
|
+
lines.push(` acceptanceCriteria: [${pol.acceptanceCriteria.join(" | ")}]`);
|
|
2671
|
+
lines.push(` reviewAuthority: ${pol.reviewAuthority}`);
|
|
2672
|
+
}
|
|
2673
|
+
else {
|
|
2674
|
+
lines.push("Governing Policy: (none — claim has no verificationPolicyId or policy not found in bundle)");
|
|
2675
|
+
}
|
|
2676
|
+
lines.push("");
|
|
2677
|
+
// Why section — derivation drilldown + transparency gaps
|
|
2678
|
+
lines.push("Derivation Drilldown:");
|
|
2679
|
+
if (explanation.why.directInputs.length > 0) {
|
|
2680
|
+
lines.push(` Direct inputs: ${explanation.why.directInputs.length} claim(s)`);
|
|
2681
|
+
for (const inp of explanation.why.directInputs) {
|
|
2682
|
+
const inpStatus = typeof inp.claim === "object" && inp.claim ? String(inp.claim.status ?? "?") : "?";
|
|
2683
|
+
lines.push(` - ${inp.inputClaimId ?? "?"} (status: ${inpStatus})`);
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
else {
|
|
2687
|
+
lines.push(" Direct inputs: (none — leaf claim)");
|
|
2688
|
+
}
|
|
2689
|
+
if (explanation.why.leafClaims.length > 0) {
|
|
2690
|
+
lines.push(` Leaf claims: ${explanation.why.leafClaims.length} claim(s)`);
|
|
2691
|
+
}
|
|
2692
|
+
if (explanation.why.diagnostics.length > 0) {
|
|
2693
|
+
lines.push(` Diagnostics: ${explanation.why.diagnostics.length}`);
|
|
2694
|
+
for (const d of explanation.why.diagnostics) {
|
|
2695
|
+
lines.push(` - ${d.type ?? "?"}: ${d.message ?? ""}`);
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
if (explanation.why.transparencyGaps.length > 0) {
|
|
2699
|
+
lines.push(` Transparency gaps: ${explanation.why.transparencyGaps.length}`);
|
|
2700
|
+
for (const g of explanation.why.transparencyGaps) {
|
|
2701
|
+
lines.push(` - [${g.severity ?? "?"}] ${g.type ?? "?"}: ${g.message ?? ""}`);
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
else {
|
|
2705
|
+
lines.push(" Transparency gaps: (none)");
|
|
2706
|
+
}
|
|
2707
|
+
if (explanation.why.changeRecords.length > 0) {
|
|
2708
|
+
lines.push(` Change records: ${explanation.why.changeRecords.length}`);
|
|
2709
|
+
for (const cr of explanation.why.changeRecords) {
|
|
2710
|
+
lines.push(` - ${cr.action ?? "?"} at ${cr.at ?? cr.createdAt ?? "?"}`);
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
console.log(lines.join("\n"));
|
|
2714
|
+
return 0;
|
|
2715
|
+
}
|
|
2716
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
859
2717
|
async function main() {
|
|
860
2718
|
const p = parseArgs(process.argv.slice(2));
|
|
861
2719
|
if (!p.command)
|
|
862
2720
|
die("workflow-sidecar command is required");
|
|
863
|
-
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]) : "";
|
|
2721
|
+
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]) : "";
|
|
864
2722
|
return withLock(lockRoot, ["ensure-session", "record-agent-event", "dogfood-pass"].includes(p.command), p.command, () => {
|
|
865
2723
|
switch (p.command) {
|
|
866
2724
|
case "ensure-session": return ensureSession(p);
|
|
@@ -868,14 +2726,39 @@ async function main() {
|
|
|
868
2726
|
case "record-agent-event": return recordAgentEvent(p);
|
|
869
2727
|
case "init-plan": return initPlan(p);
|
|
870
2728
|
case "record-evidence": return recordEvidence(p);
|
|
2729
|
+
case "record-gate-claim": return recordGateClaim(p);
|
|
871
2730
|
case "advance-state": return advanceState(p);
|
|
872
2731
|
case "record-critique": return recordCritique(p);
|
|
873
2732
|
case "import-critique": return importCritique(p);
|
|
874
2733
|
case "record-release": return recordRelease(p);
|
|
875
2734
|
case "record-learning": return recordLearning(p);
|
|
876
2735
|
case "dogfood-pass": return dogfoodPass(p);
|
|
2736
|
+
case "gate-review": return gateReview(p);
|
|
2737
|
+
case "render-trust-panel": return renderTrustPanel(p);
|
|
2738
|
+
case "trust-mcp": return trustMcp(p);
|
|
2739
|
+
case "liveness": return liveness(p);
|
|
2740
|
+
case "claim": return claimLookup(p);
|
|
2741
|
+
case "seal-checkpoint": return sealCheckpoint(p);
|
|
2742
|
+
case "publish-delivery": return publishDeliveryCmd(p);
|
|
877
2743
|
default: die(`unknown command: ${p.command}`);
|
|
878
2744
|
}
|
|
879
2745
|
});
|
|
880
2746
|
}
|
|
881
|
-
|
|
2747
|
+
// Run the CLI only when executed directly, not when imported as a library.
|
|
2748
|
+
// Resolve real paths to handle symlinks (e.g. /tmp -> /private/tmp on macOS) so the
|
|
2749
|
+
// entry-point guard fires correctly when the module is loaded directly as a script.
|
|
2750
|
+
const _selfRealPath = (() => { try {
|
|
2751
|
+
return fs.realpathSync(fileURLToPath(import.meta.url));
|
|
2752
|
+
}
|
|
2753
|
+
catch {
|
|
2754
|
+
return fileURLToPath(import.meta.url);
|
|
2755
|
+
} })();
|
|
2756
|
+
const _argv1RealPath = (() => { try {
|
|
2757
|
+
return fs.realpathSync(process.argv[1]);
|
|
2758
|
+
}
|
|
2759
|
+
catch {
|
|
2760
|
+
return process.argv[1];
|
|
2761
|
+
} })();
|
|
2762
|
+
if (_selfRealPath === _argv1RealPath) {
|
|
2763
|
+
main().then((code) => process.exit(code)).catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); });
|
|
2764
|
+
}
|