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