@kontourai/flow-agents 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.github/workflows/ci.yml +12 -0
  2. package/CHANGELOG.md +13 -0
  3. package/build/src/cli/assignment-provider.js +10 -1
  4. package/build/src/cli/workflow-artifact-cleanup-audit.js +418 -11
  5. package/build/src/cli/workflow-sidecar.d.ts +224 -0
  6. package/build/src/cli/workflow-sidecar.js +775 -4
  7. package/build/src/tools/validate-source-tree.js +3 -2
  8. package/context/contracts/artifact-contract.md +16 -2
  9. package/context/scripts/hooks/workflow-steering.js +73 -1
  10. package/docs/coordination-guide.md +370 -0
  11. package/docs/decisions/agent-coordination.md +26 -9
  12. package/docs/decisions/index.md +2 -2
  13. package/docs/decisions/trust-reconcile.md +42 -9
  14. package/docs/fixture-ownership.md +3 -2
  15. package/docs/index.md +4 -0
  16. package/docs/integrations/flow-agents-console.md +108 -0
  17. package/docs/integrations/index.md +4 -0
  18. package/docs/workflow-artifact-lifecycle.md +38 -1
  19. package/evals/ci/antigaming-suite.sh +1 -0
  20. package/evals/ci/run-baseline.sh +6 -0
  21. package/evals/fixtures/reconcile-preflight/disputed-critique-unsuperseded.json +48 -0
  22. package/evals/fixtures/reconcile-preflight/standalone-disputed-session-local.json +59 -0
  23. package/evals/integration/test_checkpoint_signing.sh +10 -2
  24. package/evals/integration/test_ci_actor_identity.sh +221 -0
  25. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  26. package/evals/integration/test_publish_delivery.sh +59 -2
  27. package/evals/integration/test_reconcile_preflight.sh +304 -0
  28. package/evals/integration/test_takeover_protocol.sh +340 -0
  29. package/evals/integration/test_trust_reconcile_negatives.sh +91 -0
  30. package/evals/integration/test_verify_hold.sh +910 -0
  31. package/evals/integration/test_veritas_governance_kit.sh +257 -0
  32. package/evals/integration/test_workflow_artifact_cleanup_audit.sh +575 -3
  33. package/evals/run.sh +8 -0
  34. package/kits/builder/skills/continue-work/SKILL.md +2 -0
  35. package/kits/builder/skills/deliver/SKILL.md +73 -0
  36. package/kits/builder/skills/pull-work/SKILL.md +12 -2
  37. package/kits/veritas-governance/docs/README.md +81 -3
  38. package/kits/veritas-governance/fixtures/exemption/approved.trust-bundle.json +74 -0
  39. package/kits/veritas-governance/fixtures/exemption/not-approved.trust-bundle.json +74 -0
  40. package/kits/veritas-governance/flows/exemption-issuance.flow.json +35 -0
  41. package/kits/veritas-governance/kit.json +5 -0
  42. package/package.json +1 -1
  43. package/scripts/ci/trust-reconcile.js +78 -253
  44. package/scripts/hooks/lib/actor-identity.js +82 -0
  45. package/scripts/hooks/workflow-steering.js +73 -1
  46. package/scripts/lib/reconcile-shape.js +381 -0
  47. package/src/cli/assignment-provider.ts +12 -1
  48. package/src/cli/workflow-artifact-cleanup-audit.ts +483 -10
  49. package/src/cli/workflow-sidecar.ts +866 -4
  50. package/src/tools/validate-source-tree.ts +3 -2
@@ -124,6 +124,18 @@ const path = require('path');
124
124
  // copy here had drifted (it was missing the trailing `/bin/true` check), which is
125
125
  // exactly why this is now imported rather than duplicated.
126
126
  const { hasLaunderingOperator } = require('../lib/command-log-chain.js');
127
+ // #356: the bundle-shape classification (classifyBundleClaims) and the pure,
128
+ // shape-level divergence-construction helpers used by Step 2 below are shared with a
129
+ // local pre-push preflight (reconcile-preflight) via this module, so the preflight can
130
+ // never drift from what this CI reconciler actually enforces. See that module's own
131
+ // header comment for the extraction rationale.
132
+ const {
133
+ classifyBundleClaims,
134
+ waiverOnCommandIssues,
135
+ noEvidenceCommandIssues,
136
+ reconcilableManifestIssues,
137
+ sessionLocalShapeIssues,
138
+ } = require('../lib/reconcile-shape.js');
127
139
 
128
140
  // ---------------------------------------------------------------------------
129
141
  // Helpers
@@ -199,138 +211,6 @@ function runCommand(cmd, repoRoot) {
199
211
  };
200
212
  }
201
213
 
202
- /**
203
- * Classify a trust.bundle's claims into: reconcilable command claims (test_output +
204
- * execution.label), session-local claims (attestation/observation/citation), never-captured
205
- * or unbacked command claims (not-run divergence), and command-backed claims carrying a
206
- * waiver (waiver-on-command divergence). Returns
207
- * { reconcilable, sessionLocal, noEvidenceCommand, waiverOnCommand }.
208
- *
209
- * Source of truth: evidence[].execution.label is the command string recorded at capture time.
210
- * evidence[].passing (normalized) means the agent claimed this passed. `claim.status` is NOT
211
- * trusted here — the caller re-derives it CI-side (see derive-claim-status.mjs / finding-3).
212
- *
213
- * WS8 iteration-2 hardening:
214
- * - finding 1: ANY pass-asserting claim whose evidence is `evidenceType: test_output`
215
- * (Surface's default when unset) but which did NOT reconcile — i.e. it has no
216
- * manifest-matchable execution.label — is a divergence, NOT session-local. A test_output
217
- * claim either reconciles against the manifest or is a divergence; it is never accepted on
218
- * self-reported status. (Previously only the literal claimType `workflow.check.command`
219
- * was guarded, so a fabricated kind:"test" claim with no command slipped through.)
220
- * - finding 4: a command-backed (test_output) claim carrying a waiver is a divergence — a
221
- * command-backed check reconciles against CI or fails; it cannot be waived.
222
- */
223
- function classifyBundleClaims(bundle) {
224
- const evidence = Array.isArray(bundle.evidence) ? bundle.evidence : [];
225
- const claims = Array.isArray(bundle.claims) ? bundle.claims : [];
226
-
227
- const claimById = new Map();
228
- for (const c of claims) if (c && c.id) claimById.set(c.id, c);
229
-
230
- // Evidence indexing. A missing evidenceType defaults to test_output for backward
231
- // compatibility with pre-classification bundles (same default classifyEvidence uses).
232
- const claimHasLabeledTestOutput = new Set(); // test_output evidence WITH an execution.label
233
- const claimHasTestOutputEvidence = new Set(); // ANY test_output evidence (label or not)
234
- // WS8 iteration-4 (converged finding): the session-local (non-test_output) evidenceType per
235
- // claim, so the reconciler can name it on the loud ATTESTED marker below — a fabricated
236
- // human_attestation/attestation/external claim with no --command is otherwise
237
- // indistinguishable, in the reconciler's own output, from a genuinely re-runnable check.
238
- const claimEvidenceType = new Map();
239
- for (const ev of evidence) {
240
- if (!ev || !ev.claimId) continue;
241
- const evType = ev.evidenceType || 'test_output';
242
- if (evType !== 'test_output') {
243
- if (!claimEvidenceType.has(ev.claimId)) claimEvidenceType.set(ev.claimId, evType);
244
- continue;
245
- }
246
- claimHasTestOutputEvidence.add(ev.claimId);
247
- if (ev.execution && ev.execution.label) claimHasLabeledTestOutput.add(ev.claimId);
248
- }
249
-
250
- // finding 4: a command-backed (test_output-evidence) claim that also carries a waiver.
251
- const waiverOnCommand = [];
252
- for (const c of claims) {
253
- if (!c || !c.id) continue;
254
- const waiver = (c.metadata && typeof c.metadata === 'object') ? c.metadata.waiver : undefined;
255
- if (waiver && typeof waiver === 'object' && claimHasTestOutputEvidence.has(c.id)) {
256
- waiverOnCommand.push({ claimId: c.id, claimType: String(c.claimType || ''), subject: c.subjectId || c.fieldOrBehavior || c.id });
257
- }
258
- }
259
-
260
- // (A) Reconcilable claimed-passes: evidence items that are test_output (CI-reconcilable),
261
- // carry an execution.label, and assert pass. Session-local evidenceTypes
262
- // (crawl_observation, human_attestation, attestation, policy_rule, source_excerpt,
263
- // document_citation, calculation_trace) are NOT reconciled per-command — they are handled
264
- // by the session-local/waiver path below.
265
- const reconcilable = [];
266
- const reconcilableClaimIds = new Set();
267
- const seen = new Set();
268
- for (const ev of evidence) {
269
- if (!ev || !ev.execution || !ev.execution.label) continue;
270
- if (!isPassingValue(ev.passing)) continue;
271
- const evType = ev.evidenceType || 'test_output';
272
- if (evType !== 'test_output') continue; // session-local — not CI-reconcilable
273
- const cmd = normalizeCmd(ev.execution.label);
274
- if (!cmd) continue;
275
- reconcilableClaimIds.add(ev.claimId);
276
- if (seen.has(cmd)) continue;
277
- seen.add(cmd);
278
- const claim = claimById.get(ev.claimId);
279
- reconcilable.push({ cmd, claimId: ev.claimId, evId: ev.id, claimType: claim ? String(claim.claimType || '') : '' });
280
- }
281
-
282
- // (B) Session-local claims, never-captured command claims, and unreconciled test_output.
283
- const sessionLocal = [];
284
- const noEvidenceCommand = [];
285
- const seenClaims = new Set();
286
- for (const c of claims) {
287
- if (!c || !c.id || typeof c.claimType !== 'string') continue;
288
- // #267/#282: a superseded critique write is HISTORY — excluded from reconcile evaluation so a
289
- // resolved session converges (a fail critique that a later same-reviewer pass superseded no
290
- // longer blocks). Scoped to NON-test_output claims so a command-backed claim can never launder
291
- // a real failure by carrying superseded_by — a test_output claim always reconciles or diverges.
292
- if (c.metadata && typeof c.metadata === 'object' && c.metadata.superseded_by && !claimHasTestOutputEvidence.has(c.id)) continue;
293
- if (reconcilableClaimIds.has(c.id)) continue; // handled by (A)
294
- if (seenClaims.has(c.id)) continue;
295
- const status = String(c.status || '');
296
- const assertsPass = isPassingValue(c.value) || status === 'verified' || status === 'assumed';
297
- const isFailing = status === 'disputed' || status === 'rejected';
298
- if (!assertsPass && !isFailing) continue; // pending/unknown non-asserting — ignore (as before)
299
- seenClaims.add(c.id);
300
-
301
- // finding 1: a pass-asserting claim backed by test_output evidence that did NOT reconcile
302
- // (it has test_output evidence but no manifest-matchable execution.label — otherwise it
303
- // would be in bucket A) is a not-run divergence. A test_output claim reconciles against the
304
- // manifest or it is a divergence — it is NEVER accepted as session-local on self-report.
305
- if (assertsPass && claimHasTestOutputEvidence.has(c.id)) {
306
- const rawCmd = normalizeCmd(c.fieldOrBehavior || c.value || '');
307
- noEvidenceCommand.push({ cmd: rawCmd || `[claim:${c.id}]`, claimId: c.id, claimType: c.claimType, reason: 'test_output-unreconciled' });
308
- continue;
309
- }
310
-
311
- // A workflow.check.command claim with no captured (labeled) evidence is a never-captured
312
- // claimed pass — not-run divergence (anti-gaming teeth preserved).
313
- if (assertsPass && c.claimType === 'workflow.check.command' && !claimHasLabeledTestOutput.has(c.id)) {
314
- const rawCmd = normalizeCmd(c.fieldOrBehavior || c.value || '');
315
- noEvidenceCommand.push({ cmd: rawCmd || `[claim:${c.id}:${c.claimType}]`, claimId: c.id, claimType: c.claimType, reason: 'no-evidence-command' });
316
- continue;
317
- }
318
-
319
- const waiver = (c.metadata && typeof c.metadata === 'object') ? c.metadata.waiver : undefined;
320
- sessionLocal.push({
321
- claimId: c.id,
322
- claimType: c.claimType,
323
- assertedStatus: status,
324
- value: c.value,
325
- waiver: (waiver && typeof waiver === 'object') ? waiver : null,
326
- subject: c.subjectId || c.fieldOrBehavior || c.id,
327
- evidenceType: claimEvidenceType.get(c.id) || 'unknown',
328
- });
329
- }
330
-
331
- return { reconcilable, sessionLocal, noEvidenceCommand, waiverOnCommand };
332
- }
333
-
334
214
  /**
335
215
  * WS8 (finding 3): re-derive every claim's status CI-side from the bundle's OWN
336
216
  * evidence/events/policies, using @kontourai/surface's canonical deriveClaimStatus (the same
@@ -1224,50 +1104,24 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null, mani
1224
1104
  }
1225
1105
 
1226
1106
  // finding 4 (server-side): a command-backed (test_output-evidence) claim carrying a
1227
- // waiver is a divergence — a command-backed check reconciles against CI or fails; it
1228
- // cannot be waived away.
1229
- for (const { claimId, claimType, subject } of waiverOnCommand) {
1230
- issues.push({
1231
- type: 'waiver-on-command-check',
1232
- message: `trust divergence: claim '${claimId}' (${subject}, claimType: ${claimType}) carries a waiver but is backed by test_output evidence — a command-backed check reconciles against CI or fails and cannot be waived`,
1233
- });
1234
- }
1107
+ // waiver is a divergence — shape-only, delegated to scripts/lib/reconcile-shape.js
1108
+ // (used byte-identically here and by the local reconcile-preflight).
1109
+ issues.push(...waiverOnCommandIssues(waiverOnCommand));
1235
1110
 
1236
1111
  // not-run divergences: never-captured command claims (no evidence) AND test_output
1237
- // claims that did not reconcile (no manifest-matchable execution.label).
1238
- for (const { cmd, claimId, claimType, reason } of noEvidenceCommand) {
1239
- const message = reason === 'test_output-unreconciled'
1240
- ? `trust divergence: claim '${claimId}' (claimType: ${claimType}) asserts pass with test_output evidence but has no manifest-matched execution.label — a test_output claim must reconcile against the manifest or it is a divergence (never accepted as session-local)`
1241
- : `trust divergence: claim '${claimId}' (claimType: ${claimType}) asserts pass but has no supporting evidence item — command never captured`;
1242
- issues.push({ type: 'not-run', cmd, message });
1243
- }
1244
-
1245
- // Reconcilable test_output claims: laundering → manifest match → CI fresh result.
1246
- for (const { cmd } of reconcilable) {
1112
+ // claims that did not reconcile (no manifest-matchable execution.label). Shape-only,
1113
+ // delegated to scripts/lib/reconcile-shape.js.
1114
+ issues.push(...noEvidenceCommandIssues(noEvidenceCommand));
1115
+
1116
+ // Reconcilable test_output claims: laundering manifest match (shape-only, delegated
1117
+ // to scripts/lib/reconcile-shape.js) CI fresh result (CI-only, stays here — requires
1118
+ // a live command execution the local preflight must not perform).
1119
+ const { issues: manifestIssues, unresolved: reconcilableUnresolved } =
1120
+ reconcilableManifestIssues(reconcilable, manifestByCmd);
1121
+ issues.push(...manifestIssues);
1122
+ for (const { cmd, manifestEntry: entry } of reconcilableUnresolved) {
1247
1123
  const normalCmd = normalizeCmd(cmd);
1248
1124
 
1249
- // (a) Laundering operator check — must come first (most specific signal).
1250
- if (hasLaunderingOperator(cmd)) {
1251
- issues.push({
1252
- type: 'laundering',
1253
- cmd,
1254
- message: `trust divergence: agent claimed '${cmd}' passed; command contains exit-code-laundering operator (|| ... / ; true / ; exit 0 / etc.)`,
1255
- });
1256
- continue;
1257
- }
1258
-
1259
- // A test_output claim MUST name a manifest (required-lane) command. An agent
1260
- // cannot self-label an arbitrary command test_output to dodge the manifest.
1261
- const entry = manifestByCmd.get(normalCmd);
1262
- if (!entry) {
1263
- issues.push({
1264
- type: 'not-run',
1265
- cmd,
1266
- message: `trust divergence: agent claimed '${cmd}' passed; command is not in the reconcile manifest — a test_output claim must name a manifest/required-lane command (CI cannot self-declare an arbitrary command)`,
1267
- });
1268
- continue;
1269
- }
1270
-
1271
1125
  // Prefer the fresh-verify Step 1 result; otherwise re-run this (manifest, and
1272
1126
  // therefore required-lane) command fresh now. Only manifest commands are ever
1273
1127
  // run on demand, so the set is bounded by the registry.
@@ -1303,90 +1157,26 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null, mani
1303
1157
  process.stdout.write(`[trust-reconcile] RECONCILED: '${cmd}' (manifest id: ${entry.id}) — claimed pass, CI fresh run = PASS\n`);
1304
1158
  }
1305
1159
 
1306
- // Session-local claims: not CI-reconcilable, but NOT a pass bypass. Each must either
1307
- // (a) carry a loud, justified waiver, or (b) resolve a real CI-RE-DERIVED `verified`
1308
- // status. WS8 iteration-2 hardening:
1309
- // - finding 3: the status used here is RE-DERIVED CI-side, never the self-reported
1310
- // claim.status. A mismatch is a `status-misassertion` divergence. If re-derivation
1311
- // is unavailable we fail closed (`status-underivable`) we never fall back to
1312
- // trusting the bundle's own status.
1313
- // - finding 2: `assumed` alone is NO LONGER a silent pass. `assumed` is acceptable
1314
- // ONLY with a waiver (printed as a loud WAIVED line). An unwaived `assumed` claim is
1315
- // an `unwaived-assumed` divergence (restores pre-WS8 semantics where `assumed` alone
1316
- // never satisfied assertsPass).
1317
- //
1318
- // WS8 iteration-4 (converged iteration-3 finding, both gates): a session-local claim
1319
- // that re-derives `verified` with NO waiver is, by definition, backed only by
1320
- // human_attestation/attestation/external/etc. evidence with no --command i.e.
1321
- // `deriveClaimStatus` only proves the bundle is internally self-consistent, never that
1322
- // the underlying attestation is true. A fully fabricated bundle (fake claim + fake
1323
- // evidence + fake event, all hand-authored to agree) is INDISTINGUISHABLE from a real
1324
- // one at this layer. This was previously printed as a quiet `SESSION-LOCAL OK` line
1325
- // indistinguishable from a genuinely reconciled check. It is now always printed as a
1326
- // loud, distinct `ATTESTED (not independently verifiable at L0)` marker — see ADR 0020
1327
- // Residuals. This does NOT change the exit code (attestations are not blocked at L0;
1328
- // blocking every honest human-attestation use is not the fix) — it is a visibility-only
1329
- // change so a reviewer/downstream tool can grep for and count exactly how many claims in
1330
- // a passing bundle rest on bundle-internal consistency alone rather than independent
1331
- // (CI fresh-run or cryptographically-signed) verification.
1332
- let attestedCount = 0;
1333
- for (const { claimId, claimType, assertedStatus, waiver, subject, evidenceType } of sessionLocal) {
1334
- // finding 3: re-derive; never trust the asserted status.
1335
- let status;
1336
- if (!derivedStatus) {
1337
- issues.push({
1338
- type: 'status-underivable',
1339
- message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) asserts status '${assertedStatus || 'unknown'}' but CI-side re-derivation is unavailable — refusing to trust a self-reported status (fail-closed)`,
1340
- });
1341
- continue;
1342
- }
1343
- const derived = derivedStatus.get(claimId);
1344
- if (derived === undefined || derived === null) {
1345
- issues.push({
1346
- type: 'status-underivable',
1347
- message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) could not be re-derived CI-side from the bundle's own evidence/events/policies — refusing to trust its self-reported status '${assertedStatus || 'unknown'}' (fail-closed)`,
1348
- });
1349
- continue;
1350
- }
1351
- if (derived !== assertedStatus) {
1352
- issues.push({
1353
- type: 'status-misassertion',
1354
- message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) asserts status '${assertedStatus || 'unknown'}' but CI re-derivation from the bundle's own evidence/events/policies yields '${derived}' — the reconciler does not trust self-reported claim.status`,
1355
- });
1356
- continue;
1357
- }
1358
- status = derived;
1359
-
1360
- if (status === 'disputed' || status === 'rejected') {
1361
- issues.push({
1362
- type: 'session-local-failed',
1363
- message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) has re-derived status '${status}' — a failing/rejected claim blocks (session-local classification is not a pass bypass)`,
1364
- });
1365
- continue;
1366
- }
1367
- // finding 2: a waiver is the ONLY way an `assumed` (or otherwise non-`verified`)
1368
- // session-local claim passes, and it is printed loudly. `verified` still passes on its
1369
- // own re-derived status.
1370
- if (waiver && waiver.reason && waiver.approved_by) {
1160
+ // Session-local claims: not CI-reconcilable, but NOT a pass bypass. Shape-only,
1161
+ // delegated to scripts/lib/reconcile-shape.js (sessionLocalShapeIssues) see that
1162
+ // module for the full WS8 finding-2/finding-3/iteration-4 rationale (waiver-only pass
1163
+ // for non-`verified` status, CI-re-derived status never self-reported, loud ATTESTED
1164
+ // marker for bundle-internal-only consistency). iteration-1 F1: CI MUST pass
1165
+ // `onUnderivable: 'fail'` explicitlythis is CI's trust anchor and must never silently
1166
+ // degrade to trusting a self-reported status when re-derivation is unavailable (that
1167
+ // reduced-coverage mode is opt-in only, for the LOCAL preflight see reconcile-shape.js).
1168
+ // iteration-1 F3: the WAIVED/ATTESTED log lines below are driven by the shared function's
1169
+ // own `logEvents` classification (not a parallel re-derivation) so CI's stdout narrative
1170
+ // can never drift from what sessionLocalShapeIssues actually decided.
1171
+ const { issues: sessionLocalIssues, attestedCount, logEvents } =
1172
+ sessionLocalShapeIssues(sessionLocal, derivedStatus, { onUnderivable: 'fail' });
1173
+ issues.push(...sessionLocalIssues);
1174
+ for (const { kind, claimId, claimType, subject, evidenceType, status, waiver } of logEvents) {
1175
+ if (kind === 'waived') {
1371
1176
  process.stdout.write(`[trust-reconcile] WAIVED: ${subject} (${claimType}) status=${status} — ${waiver.reason} (approved by ${waiver.approved_by})\n`);
1372
- continue;
1373
- }
1374
- if (status === 'verified') {
1375
- attestedCount++;
1177
+ } else if (kind === 'attested') {
1376
1178
  process.stdout.write(`[trust-reconcile] ATTESTED (not independently verifiable at L0): '${claimId}' (${claimType}) evidenceType=${evidenceType} — accepted on bundle-internal consistency only; see ADR 0020 Residuals\n`);
1377
- continue;
1378
1179
  }
1379
- if (status === 'assumed') {
1380
- issues.push({
1381
- type: 'unwaived-assumed',
1382
- message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) has re-derived status 'assumed' but carries no waiver — 'assumed' alone is not a pass; it requires a documented waiver (--accepted-gap-reason/--waived-by) to be accepted`,
1383
- });
1384
- continue;
1385
- }
1386
- issues.push({
1387
- type: 'unwaived-session-local',
1388
- message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) asserts pass with re-derived status '${status || 'unknown'}' but has no waiver and no CI-re-derived verified status`,
1389
- });
1390
1180
  }
1391
1181
 
1392
1182
  // WS8 iteration-4: always emit the summary count, even when zero, so a passing bundle
@@ -1496,6 +1286,41 @@ function main() {
1496
1286
  // The direct-CLI behavior is preserved: when run as a script, main() is called below.
1497
1287
  module.exports.runTrustReconcile = runTrustReconcile;
1498
1288
 
1289
+ // #356: manifest-resolution helpers exported for reuse by the local reconcile-preflight
1290
+ // (Wave 2) so it resolves the manifest via the EXACT SAME priority chain this CI reconciler
1291
+ // uses (CLI --manifest > TRUST_RECONCILE_MANIFEST > package.json > run-baseline.sh
1292
+ // --manifest-json > legacy fresh-verify-commands fallback) — never a second implementation.
1293
+ // These stay defined here (not moved to scripts/lib/reconcile-shape.js) because they are
1294
+ // CI-adjacent resolution logic (they may spawn `evals/ci/run-baseline.sh --manifest-json`,
1295
+ // a cheap static-registry emit, not a fresh test run) rather than pure bundle-shape
1296
+ // classification.
1297
+ module.exports.resolveManifest = resolveManifest;
1298
+ module.exports.runBaselineManifest = runBaselineManifest;
1299
+ module.exports.normalizeManifestEntries = normalizeManifestEntries;
1300
+ module.exports.slugifyLabel = slugifyLabel;
1301
+ module.exports.normalizeCmd = normalizeCmd;
1302
+ module.exports.isAncestorCommit = isAncestorCommit;
1303
+ // #356: resolveManifest's legacy fallback tier (tier 5, "legacy:fresh-verify-commands")
1304
+ // folds the CANONICAL verify commands into the manifest when no dedicated manifest source
1305
+ // resolves. Exporting resolveCanonicalCommands too so the local preflight's manifest
1306
+ // resolution has genuine parity with CI on that fallback tier as well — otherwise a repo (or
1307
+ // a fixture/test repo) with no run-baseline.sh/package.json manifest source would silently
1308
+ // resolve an EMPTY legacy-fallback manifest locally while CI's own resolution (which always
1309
+ // threads its own resolved canonicalCommands into this same fallback) resolves a non-empty
1310
+ // one, a real parity gap, not merely a fixture quirk.
1311
+ module.exports.resolveCanonicalCommands = resolveCanonicalCommands;
1312
+
1313
+ // #356: re-export the shared bundle-shape classification/issue-construction functions
1314
+ // (primary home: scripts/lib/reconcile-shape.js) so a caller that already requires
1315
+ // trust-reconcile.js (e.g. an existing test harness) can reach them without also needing
1316
+ // to know the shared module's path. This is a re-export, not a second implementation —
1317
+ // the single definition lives in scripts/lib/reconcile-shape.js.
1318
+ module.exports.classifyBundleClaims = classifyBundleClaims;
1319
+ module.exports.waiverOnCommandIssues = waiverOnCommandIssues;
1320
+ module.exports.noEvidenceCommandIssues = noEvidenceCommandIssues;
1321
+ module.exports.reconcilableManifestIssues = reconcilableManifestIssues;
1322
+ module.exports.sessionLocalShapeIssues = sessionLocalShapeIssues;
1323
+
1499
1324
  if (require.main === module) {
1500
1325
  main();
1501
1326
  }
@@ -229,6 +229,75 @@ function sanitizeSegment(value) {
229
229
  return cleaned || 'unknown';
230
230
  }
231
231
 
232
+ /**
233
+ * #398: detect a CI-triggered session and derive a STABLE actor identity from the CI provider's
234
+ * published identifiers. The right granularity is the JOB/RUN (a CI job ≈ one agent session), NOT
235
+ * the runner machine (hostname can be shared/reused across jobs). Detection is order-independent
236
+ * (each provider gates on its own canonical marker env var). Returns `{ runtime, session_id }` on a
237
+ * recognized provider WITH a non-blank stable id, else `null` (caller falls through to
238
+ * process-ancestry, where #293's advisory verify-hold net still protects).
239
+ *
240
+ * Deliberately conservative: a generic/unrecognized `CI=true` returns null rather than fabricating
241
+ * a "stable" id from something that might shift between subprocesses — a wrong stable classification
242
+ * would let the #293 hard gate ENFORCE against a shifting identity, which is worse than advisory.
243
+ * All segments are sanitized by serializeActor at the call site (allowed charset, length-capped),
244
+ * so a hostile env var value cannot inject.
245
+ *
246
+ * ACCEPTED GRANULARITY GAP (matrix / parallelism): the id is job/run-granular. GitLab (`CI_JOB_ID`),
247
+ * Azure (`SYSTEM_JOBID`), and Buildkite (`BUILDKITE_JOB_ID`) expose a per-job-INSTANCE id that is
248
+ * already unique across matrix/parallel legs. CircleCI parallelism is disambiguated below via
249
+ * `CIRCLE_NODE_INDEX`. But GitHub Actions and Jenkins declarative-`matrix{}` cells share
250
+ * `GITHUB_JOB` / `BUILD_TAG` across all legs of one run+attempt — matrix values live only in the
251
+ * `${{ matrix }}` / axis context, not in any env var this can read — so two concurrent legs collapse
252
+ * to the SAME CI actor. This degrades SAFELY: worst case is idempotent self-recognition (one leg
253
+ * treats another's claim as its own); it never false-blocks and never injects. The coordination-
254
+ * relevant CI jobs (trust-reconcile / publish) run as single, non-matrix jobs today. Filed as a
255
+ * fast-follow to add a GitHub-matrix disambiguator if a coordination path ever runs under a matrix.
256
+ *
257
+ * @param {NodeJS.ProcessEnv} [env]
258
+ * @returns {{runtime: string, session_id: string} | null}
259
+ */
260
+ function detectCiActor(env = process.env) {
261
+ env = env || {};
262
+ const s = (v) => String(v == null ? '' : v).trim();
263
+ const compose = (...parts) => parts.map(s).filter(Boolean).join('-');
264
+
265
+ // GitHub Actions — run id + attempt + job is unique and stable across the whole job.
266
+ if (s(env.GITHUB_ACTIONS) === 'true') {
267
+ const id = compose(env.GITHUB_RUN_ID, env.GITHUB_RUN_ATTEMPT, env.GITHUB_JOB);
268
+ return id ? { runtime: 'github-actions', session_id: id } : null;
269
+ }
270
+ // GitLab CI — CI_JOB_ID is stable per job.
271
+ if (s(env.GITLAB_CI) === 'true') {
272
+ const id = s(env.CI_JOB_ID);
273
+ return id ? { runtime: 'gitlab-ci', session_id: id } : null;
274
+ }
275
+ // CircleCI — workflow id + job name + node index (CIRCLE_NODE_INDEX disambiguates the containers
276
+ // of a `parallelism: N` job; "0" for a single-container job, so it is always present and stable).
277
+ if (s(env.CIRCLECI) === 'true') {
278
+ const id = compose(env.CIRCLE_WORKFLOW_ID, env.CIRCLE_JOB, env.CIRCLE_NODE_INDEX) || s(env.CIRCLE_BUILD_NUM);
279
+ return id ? { runtime: 'circleci', session_id: id } : null;
280
+ }
281
+ // Jenkins — BUILD_TAG is the stable per-build identifier; fall back to job + build id.
282
+ if (s(env.JENKINS_URL)) {
283
+ const id = s(env.BUILD_TAG) || compose(env.JOB_NAME, env.BUILD_ID);
284
+ return id ? { runtime: 'jenkins', session_id: id } : null;
285
+ }
286
+ // Azure Pipelines — build id + system job id (SYSTEM_JOBID is a per-job-instance GUID, unique
287
+ // across matrix/strategy legs).
288
+ if (s(env.TF_BUILD) === 'true') {
289
+ const id = compose(env.BUILD_BUILDID, env.SYSTEM_JOBID);
290
+ return id ? { runtime: 'azure-pipelines', session_id: id } : null;
291
+ }
292
+ // Buildkite — job id is a stable UUID per job.
293
+ if (s(env.BUILDKITE) === 'true') {
294
+ const id = s(env.BUILDKITE_JOB_ID);
295
+ return id ? { runtime: 'buildkite', session_id: id } : null;
296
+ }
297
+ // Generic/unrecognized CI: do NOT fabricate stability — fall through to process-ancestry.
298
+ return null;
299
+ }
300
+
232
301
  /**
233
302
  * Serialize a runtime-agnostic actor struct into a single string safe for
234
303
  * the existing `${subjectId}::${actor}` grouping key: each field is passed
@@ -334,6 +403,18 @@ function resolveActor(env = process.env) {
334
403
  return { actor, source: `runtime-session-id:${runtime}` };
335
404
  }
336
405
 
406
+ // #398: CI-runtime tier — sits ABOVE process-ancestry (stable across every subprocess in a CI
407
+ // job) and BELOW an explicit override / native runtime session id (those are more specific and
408
+ // already returned above). A CI-triggered agent session otherwise falls to process-ancestry,
409
+ // whose seed differs across subprocesses within one job (a subject claimed in `ensure-session`
410
+ // isn't recognized as self at `publish`) — the exact instability #293 had to degrade the
411
+ // verify-hold gate to advisory for. A stable CI identity lets that gate ENFORCE instead.
412
+ const ci = detectCiActor(env);
413
+ if (ci && ci.session_id) {
414
+ const actor = serializeActor({ runtime: ci.runtime, session_id: ci.session_id, host: os.hostname() });
415
+ return { actor, source: `ci-runtime:${ci.runtime}` };
416
+ }
417
+
337
418
  const seed = ancestorActorSeed();
338
419
  if (seed) {
339
420
  const actor = serializeActor({ runtime, session_id: `anc-${seed}`, host: os.hostname() });
@@ -360,6 +441,7 @@ function isUnresolvedActor(actor) {
360
441
  module.exports = {
361
442
  detectRuntime,
362
443
  runtimeSessionId,
444
+ detectCiActor,
363
445
  ancestorActorSeed,
364
446
  sanitizeSegment,
365
447
  serializeActor,
@@ -159,8 +159,25 @@ function latestWorkflowState(root) {
159
159
  return states[0] || null;
160
160
  }
161
161
 
162
+ // #293 AC7 (injection discipline): strips C0 (0x00-0x1F), DEL (0x7F), and C1 (0x80-0x9F,
163
+ // which includes ANSI-CSI-adjacent bytes) BEFORE whitespace-collapse/truncation — mirroring
164
+ // src/cli/workflow-sidecar.ts's stripControlCharsForDisplay / assignment-provider.ts's
165
+ // sanitizeDisplayField exactly (same charset, same rationale: holder/actor/assignee strings
166
+ // sourced from the shared multi-writer liveness stream or an attacker-postable GitHub comment
167
+ // are untrusted display input). Previously this function only collapsed whitespace, so a raw
168
+ // ESC/BEL byte embedded in a hostile actor string passed through unstripped into every steering
169
+ // notice that calls safeStateText (resumeSteering's LIVENESS WARNING block, supersessionSteering's
170
+ // SUPERSEDED notice) — fixed here, in the one shared helper, rather than in each call site.
162
171
  function safeStateText(value, maxLength = 240) {
163
- const text = String(value || '').replace(/\s+/g, ' ').trim();
172
+ // Whitespace-collapse FIRST (so an embedded newline/tab becomes a joining space, preserving
173
+ // the existing multiline-summary neutralization behavior), THEN strip control/ANSI bytes
174
+ // (#293 AC7) — reversing this order would strip \n before the collapse ever sees it, losing
175
+ // the join-space between sentences. Only C0/DEL/C1 bytes THAT SURVIVE the collapse (ESC, BEL,
176
+ // and other non-whitespace control bytes — real \s already became a plain space above) are
177
+ // stripped, mirroring src/cli/workflow-sidecar.ts's stripControlCharsForDisplay /
178
+ // assignment-provider.ts's sanitizeDisplayField charset exactly.
179
+ const collapsed = String(value || '').replace(/\s+/g, ' ').trim();
180
+ const text = collapsed.replace(/[\u0000-\u001F\u007F-\u009F]/g, '');
164
181
  if (text.length <= maxLength) return text;
165
182
  return `${text.slice(0, maxLength - 3)}...`;
166
183
  }
@@ -411,6 +428,51 @@ function resumeSteering(root, current) {
411
428
  }
412
429
  }
413
430
 
431
+ /**
432
+ * Compose the every-turn SUPERSEDED steering notice (issue #293).
433
+ *
434
+ * Unlike resumeSteering()'s RESUME block (SessionStart only), this notice must
435
+ * surface on EVERY turn (UserPromptSubmit as well as SessionStart) once another
436
+ * actor holds a fresh liveness claim on the caller's own subject — the
437
+ * "don't publish, you may be stale" signal the verify-hold gate backstops at
438
+ * publish time. Reuses the SAME liveness-only join resumeSteering()'s liveness
439
+ * advisory block already performs (freshHolders(events, slug, selfActor, now)),
440
+ * not a second computation.
441
+ *
442
+ * freshHolders() already excludes selfActor internally (see
443
+ * scripts/hooks/lib/liveness-read.js), so any holder returned here is by
444
+ * definition another actor — no additional self-filter is required.
445
+ *
446
+ * Fully fail-open: any error anywhere in this function returns '' rather than
447
+ * throwing, matching every other steering helper in this file.
448
+ *
449
+ * @param {string} root Repository root
450
+ * @param {{ file: string, payload: object }} current Latest active state entry (this actor's own)
451
+ * @returns {string}
452
+ */
453
+ function supersessionSteering(root, current) {
454
+ try {
455
+ const state = current.payload;
456
+ const workflowDir = path.dirname(current.file);
457
+ const slug = state.task_slug || path.basename(workflowDir);
458
+
459
+ const resolved = resolveActor(process.env);
460
+ const selfActor = resolved.actor || 'local';
461
+
462
+ const events = flowAgentsArtifactRootsForRead(root)
463
+ .flatMap(artifactRoot => readLivenessEvents(path.join(artifactRoot, 'liveness', 'events.jsonl')));
464
+ if (events.length === 0) return '';
465
+
466
+ const holders = freshHolders(events, slug, selfActor, Date.now());
467
+ if (holders.length === 0) return '';
468
+
469
+ const holder = holders[0];
470
+ return `[SUPERSEDED: another agent appears live on this work: actor ${safeStateText(holder.actor)}, last seen ${holder.lastAt}. Your claim on this subject may be stale — do not publish until you re-verify hold (see verify-hold gate) or hand off.]`;
471
+ } catch {
472
+ return '';
473
+ }
474
+ }
475
+
414
476
  function run(rawInput) {
415
477
  try {
416
478
  const input = JSON.parse(rawInput);
@@ -451,10 +513,19 @@ function run(rawInput) {
451
513
  }
452
514
  }
453
515
 
516
+ // Every-turn supersession notice (#293): fires on UserPromptSubmit (every turn),
517
+ // not just SessionStart, so a takeover mid-session keeps surfacing the warning.
518
+ if (event === 'UserPromptSubmit' && current) {
519
+ const supersessionHint = supersessionSteering(root, current);
520
+ if (supersessionHint) hints.push(supersessionHint);
521
+ }
522
+
454
523
  // SessionStart only: append the RESUME block for richer situational awareness
455
524
  if (event === 'SessionStart' && current) {
456
525
  const resumeBlock = resumeSteering(root, current);
457
526
  if (resumeBlock) hints.push(resumeBlock);
527
+ const supersessionHint = supersessionSteering(root, current);
528
+ if (supersessionHint) hints.push(supersessionHint);
458
529
  }
459
530
 
460
531
  if (shouldAppendWorkflowContext) {
@@ -492,6 +563,7 @@ module.exports = {
492
563
  safeStateText,
493
564
  stateNeedsAmbientSteering,
494
565
  resumeSteering,
566
+ supersessionSteering,
495
567
  promptText,
496
568
  looksLikeBuilderWork,
497
569
  builderWorkflowSteering,