@ijfw/memory-server 1.5.0 → 1.5.3

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 (71) hide show
  1. package/bin/ijfw-memorize +14 -7
  2. package/fixtures/team/book.json +6 -6
  3. package/fixtures/team/business.json +146 -20
  4. package/fixtures/team/content.json +6 -6
  5. package/fixtures/team/design.json +148 -20
  6. package/fixtures/team/mixed.json +206 -27
  7. package/fixtures/team/research.json +146 -20
  8. package/fixtures/team/software.json +148 -20
  9. package/package.json +8 -4
  10. package/src/brain/budget-guard.js +86 -0
  11. package/src/brain/citation-resolver.js +41 -0
  12. package/src/brain/context-injection.js +69 -0
  13. package/src/brain/discovery.js +83 -0
  14. package/src/brain/dream-pipeline.js +324 -0
  15. package/src/brain/dump-ingest.js +88 -0
  16. package/src/brain/entity-collapse.js +28 -0
  17. package/src/brain/export.js +112 -0
  18. package/src/brain/extractors/index.js +24 -0
  19. package/src/brain/extractors/markdown.js +27 -0
  20. package/src/brain/extractors/pdf.js +31 -0
  21. package/src/brain/extractors/transcript.js +38 -0
  22. package/src/brain/first-run-scan.js +61 -0
  23. package/src/brain/index.js +1 -0
  24. package/src/brain/layout-sentinel.js +29 -0
  25. package/src/brain/migrate-facts-internal-once.js +87 -0
  26. package/src/brain/path-guard.js +103 -0
  27. package/src/brain/paths.js +26 -0
  28. package/src/brain/promotion-suggester.js +41 -0
  29. package/src/brain/stub-detector.js +33 -0
  30. package/src/brain/tiered-llm.js +83 -0
  31. package/src/brain/wiki-compiler.js +144 -0
  32. package/src/brain/wiki-sentinels.js +45 -0
  33. package/src/brain/wiki-templates.js +94 -0
  34. package/src/cross-orchestrator-cli.js +336 -150
  35. package/src/cross-orchestrator.js +52 -3
  36. package/src/dashboard-server.js +1 -1
  37. package/src/dispatch/extension.js +1 -1
  38. package/src/dream/runner.mjs +21 -0
  39. package/src/extension-registry.js +2 -2
  40. package/src/handlers/brain-handler.js +319 -0
  41. package/src/hardware-signer.js +4 -2
  42. package/src/lib/ui-review-runner.js +48 -7
  43. package/src/memory/auto-linker.js +121 -2
  44. package/src/memory/benchmark.js +4 -3
  45. package/src/memory/layout-migrations/001-visible-layer.js +131 -0
  46. package/src/memory/layout-migrations/index.js +50 -0
  47. package/src/memory/migration-runner.js +37 -3
  48. package/src/memory/migrations/009-obsidian-backfill.js +50 -0
  49. package/src/memory/obsidian-parser.js +65 -2
  50. package/src/memory/reader.js +2 -1
  51. package/src/memory/search.js +190 -41
  52. package/src/memory/temporal.js +40 -1
  53. package/src/orchestrator/agents-md-blackboard.js +114 -1
  54. package/src/orchestrator/debug-trident-trigger.js +374 -0
  55. package/src/orchestrator/discipline-selector.js +276 -0
  56. package/src/orchestrator/merge-block-aware.js +15 -5
  57. package/src/orchestrator/post-done-runner.js +36 -8
  58. package/src/orchestrator/state-sdk.js +216 -10
  59. package/src/orchestrator/subagent-telemetry.js +19 -0
  60. package/src/orchestrator/wave-state.js +38 -0
  61. package/src/override-resolver.js +5 -3
  62. package/src/recovery/code-fixer.js +311 -6
  63. package/src/runtime-mediator.js +0 -1
  64. package/src/server.js +486 -132
  65. package/src/swarm-config.js +30 -22
  66. package/src/team/domain-templates/business.json +4 -1
  67. package/src/team/domain-templates/research.json +4 -1
  68. package/src/team/generator.js +162 -0
  69. package/src/update-apply.js +1 -1
  70. package/src/dashboard-charts.js +0 -239
  71. package/src/orchestrator/runtime-loop.js +0 -430
@@ -34,7 +34,7 @@
34
34
  *
35
35
  * RESERVED BLOCK NAMES
36
36
  * Matches the shell script verbatim:
37
- * MEMORY | ROUTING | AGENTS | BLACKBOARD | FRONTMATTER
37
+ * MEMORY | ROUTING | AGENTS | BLACKBOARD | FRONTMATTER | DISCIPLINE
38
38
  *
39
39
  * ESM, Node ≥18, zero new prod deps.
40
40
  */
@@ -60,7 +60,7 @@ import { writeAtomic } from '../lib/atomic-io.js';
60
60
  * name yields `ERR_BAD_BLOCK` (the same exit-2 the shell script returns).
61
61
  */
62
62
  export const RESERVED_BLOCKS = Object.freeze([
63
- 'MEMORY', 'ROUTING', 'AGENTS', 'BLACKBOARD', 'FRONTMATTER',
63
+ 'MEMORY', 'ROUTING', 'AGENTS', 'BLACKBOARD', 'FRONTMATTER', 'DISCIPLINE',
64
64
  ]);
65
65
 
66
66
  const RESERVED_BLOCK_SET = new Set(RESERVED_BLOCKS);
@@ -149,7 +149,7 @@ export function mergeBlocks(src, pairs) {
149
149
  if (typeof block !== 'string' || !RESERVED_BLOCK_SET.has(block)) {
150
150
  throw new MergeBlockAwareError(
151
151
  'ERR_BAD_BLOCK',
152
- `mergeBlocks: block name reserved set: ${RESERVED_BLOCKS.join(' ')} (got ${JSON.stringify(block)})`,
152
+ `mergeBlocks: unknown block ${JSON.stringify(block)} -- reserved set: ${RESERVED_BLOCKS.join(' ')}`,
153
153
  );
154
154
  }
155
155
  const content = (pair.content === undefined || pair.content === null)
@@ -310,6 +310,18 @@ export function mergeFile(targetAbsPath, pairs, opts = {}) {
310
310
  seeded = true;
311
311
  }
312
312
 
313
+ const src = readFileSync(abs, 'utf8');
314
+ const next = mergeBlocks(src, pairs);
315
+
316
+ // No-op short-circuit: if content is unchanged and this is not a seed,
317
+ // skip backup rotation and the atomic write entirely. Idempotent calls
318
+ // will no longer accumulate identical-content backups.
319
+ if (!seeded && next === src) {
320
+ return {
321
+ ok: true, path: abs, bytes: Buffer.byteLength(next), seeded: false, noop: true,
322
+ };
323
+ }
324
+
313
325
  // Backup + retention (best-effort, defaults on). `opts.backups === false`
314
326
  // suppresses (used by some tests to keep tmp clean).
315
327
  let backup;
@@ -318,8 +330,6 @@ export function mergeFile(targetAbsPath, pairs, opts = {}) {
318
330
  if (rot.taken && rot.path) backup = rot.path;
319
331
  }
320
332
 
321
- const src = readFileSync(abs, 'utf8');
322
- const next = mergeBlocks(src, pairs);
323
333
  const res = writeAtomic(abs, next, { mode: 0o644, ensureDir: true });
324
334
  return {
325
335
  ok: true, path: res.path, bytes: res.bytes, backup, seeded,
@@ -1,16 +1,27 @@
1
1
  /**
2
- * post-done-runner.js — v1.5.0-major S02: enforced post-DONE pipeline.
2
+ * post-done-runner.js — v1.5.0-major S02: post-DONE pipeline primitives.
3
3
  *
4
- * Runs after a subagent's DONE has been verified by runtime-loop.js. Wraps
5
- * reviewTask (v1.4.4 N3 two-stage review) and checkVerificationGate
6
- * (v1.4.4 N5) into a single callable the orchestrator-LLM invokes via MCP,
7
- * so the post-DONE contract isn't satisfied by markdown prose.
4
+ * WHAT IS LIVE: `runSelfCheck` is the only export on the production path. The
5
+ * live DONE-handler is the `subagent.post-done` state-SDK verb, which calls
6
+ * `runSelfCheck` directly (and fires debug-trident via debug-trident-trigger.js
7
+ * on a self-check failure). The verification gate itself is also enforced live
8
+ * — `state-sdk.js` calls `enforceVerificationGate` directly.
9
+ *
10
+ * WHAT IS NOT LIVE: `runPostDone` is a library/test surface — NOT the live
11
+ * DONE-handler. It is a convenience wrapper that bundles reviewTask (v1.4.4 N3
12
+ * two-stage review) + checkVerificationGate (v1.4.4 N5) for direct-import
13
+ * callers and the test path (`test-orchestrator-post-done-runner.js`). The
14
+ * production two-stage spec+quality review happens via agent dispatch
15
+ * (spec-reviewer + quality-reviewer agents), not through this wrapper. Its
16
+ * original S02 caller (`runtime-loop.js`) was never wired; that file is now
17
+ * removed. `runPostDone` is kept for its test surface and for any future
18
+ * caller that wants the two checks bundled — it does not carry production
19
+ * traffic today.
8
20
  *
9
21
  * v1.5.0 T13: the standalone `ijfw_subagent_post_done` MCP tool was retired and
10
22
  * absorbed into the single `ijfw_state` MCP tool as the `subagent.post-done`
11
23
  * verb (see STATE-SDK-CONTRACT §7). `runSelfCheck` is re-exported through
12
- * `state-sdk.js` for that verb; `runPostDone` is still exported here for the
13
- * direct-import test path (`test-orchestrator-post-done-runner.js`).
24
+ * `state-sdk.js` for that verb.
14
25
  *
15
26
  * Outcome shape (uniform regardless of branch taken):
16
27
  * {
@@ -46,6 +57,13 @@ import { existsSync } from 'node:fs';
46
57
  import { execFileSync } from 'node:child_process';
47
58
  import { reviewTask } from './review.js';
48
59
  import { checkVerificationGate, recordViolation } from './verification-gate.js';
60
+ // debug-trident (T29) is wired on the LIVE path only: `subagent.post-done` in
61
+ // state-sdk.js fires debug-trident fire-and-forget when its self-check gate
62
+ // fails, via `maybeFireDebugTrident` in debug-trident-trigger.js. That is the
63
+ // genuine production caller — codex+gemini are dispatched against the real
64
+ // gate-failure evidence whenever IJFW_DEBUG_TRIDENT is enabled. runPostDone
65
+ // deliberately does NOT invoke debug-trident (the earlier W2.C inline-
66
+ // annotation hook was dead — computed but never returned — and was removed).
49
67
 
50
68
  /**
51
69
  * Extract paths claimed in the report. Naive but effective: looks for
@@ -123,7 +141,17 @@ export function runSelfCheck(reportText, projectRoot) {
123
141
  }
124
142
 
125
143
  /**
126
- /**
144
+ * runPostDone — library/test surface. NOT the live DONE-handler.
145
+ *
146
+ * The live subagent-completion path is the `subagent.post-done` state-SDK verb
147
+ * (which runs `runSelfCheck` + fires debug-trident on failure), plus the
148
+ * verification gate enforced directly in `state-sdk.js`; the production
149
+ * two-stage spec+quality review runs via agent dispatch (spec-reviewer +
150
+ * quality-reviewer agents). This wrapper bundles reviewTask (N3) +
151
+ * checkVerificationGate (N5) + runSelfCheck (S09) for direct-import callers
152
+ * and `test-orchestrator-post-done-runner.js`. It carries no production
153
+ * traffic — keep it honest: do not describe it as the live handler.
154
+ *
127
155
  * @param {object} params
128
156
  * @param {string} params.taskId
129
157
  * @param {string} [params.taskSpec]
@@ -11,13 +11,16 @@
11
11
  * straight off that contract.
12
12
  *
13
13
  * ───────────────────────────────────────────────────────────────────────────
14
- * SCOPE BOUNDARY — T2 builds the verb core; three later tasks wrap it:
14
+ * SCOPE BOUNDARY — T2 built the verb core; three later tasks wrapped it.
15
+ * All three (T3/T4/T5) are now REALIZED — this section is kept as the design
16
+ * record, but the seams below are live, not stubs:
15
17
  *
16
- * T3 (lock hierarchy) — wraps `_withLocks()`. Today it is a pass-through;
17
- * T3 swaps in `withFsLock` acquisition in the §3
18
- * canonical acquire-order. Handlers already declare
19
- * the ordered lock-target list they touch, so T3
20
- * only has to make `_withLocks` honor it.
18
+ * T3 (lock hierarchy) — `_withLocks()` is NOT a pass-through. It acquires
19
+ * real filesystem locks via `withFsLock`, ordering
20
+ * each verb's declared lock-target list through
21
+ * `canonicalLockOrder` (the §3 canonical acquire
22
+ * order) to prevent deadlock, then runs `fn` while
23
+ * the locks are held and releases on completion.
21
24
  * T4 (intent/commit) — wraps `_journalBegin()` / `_journalCommit()`.
22
25
  * Today they are no-ops; T4 makes them write the
23
26
  * write-ahead `intent-journal.jsonl` records and
@@ -34,12 +37,13 @@
34
37
  */
35
38
 
36
39
  import {
37
- readFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync, readdirSync,
40
+ readFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync, readdirSync, realpathSync,
38
41
  } from 'node:fs';
39
- import { join, isAbsolute, dirname, basename } from 'node:path';
42
+ import { join, isAbsolute, isAbsolute as pathIsAbsolute, relative as pathRelative, dirname, basename } from 'node:path';
40
43
  import { homedir } from 'node:os';
41
44
  import { randomUUID, createHash } from 'node:crypto';
42
45
  import { gunzipSync } from 'node:zlib';
46
+ import { execFileSync } from 'node:child_process';
43
47
 
44
48
  import { writeAtomic, readSafe } from '../lib/atomic-io.js';
45
49
  import { rotateJsonlIfNeeded } from '../lib/jsonl-rotation.js';
@@ -58,6 +62,17 @@ import {
58
62
  appendUnderHeldLock as appendEventUnderHeldLock,
59
63
  resolveEventLogPath,
60
64
  } from './state-events.js';
65
+ // v1.5.1 cleanup C1: S08 incident-driven worktree safety guards. Previously
66
+ // orphan (only importer was the unwired runtime-loop.js). Wired here as
67
+ // preconditions of the LIVE `subagent.dispatch` verb — the genuinely-reachable
68
+ // worktree-isolated spawn path (ijfw_state MCP tool → query → subagent.dispatch).
69
+ // worktree-guards.js has no state-sdk dependency, so a static import is safe.
70
+ import {
71
+ captureSpawnToplevel,
72
+ assertPathWithinToplevel,
73
+ assertNoCwdDrift,
74
+ assertNotProtectedRef,
75
+ } from '../lib/worktree-guards.js';
61
76
 
62
77
  // ---------------------------------------------------------------------------
63
78
  // Gate-function indirection (T15 — Model 4 testability seam)
@@ -704,6 +719,88 @@ function requireStr(value, field) {
704
719
 
705
720
  function nowIso() { return new Date().toISOString(); }
706
721
 
722
+ /**
723
+ * v1.5.1 cleanup C1 — S08 worktree-guard preconditions for `subagent.dispatch`.
724
+ *
725
+ * Runs the three incident-driven guards (worktree-guards.js) against the
726
+ * project root a worktree-isolated subagent is about to be dispatched into:
727
+ * #3099 — assertPathWithinToplevel (abs-path / symlink escape)
728
+ * #3097 — assertNoCwdDrift (cwd drifted off the toplevel)
729
+ * #2924 — assertNotProtectedRef (HEAD on main/master/develop/…)
730
+ *
731
+ * Semantics — `subagent.dispatch` is a brief-COMPOSITION verb (it produces a
732
+ * deterministic dispatch brief; the real spawn happens platform-side). The
733
+ * guards are therefore PRECONDITIONS surfaced on the result, not a hard abort
734
+ * of brief composition:
735
+ * - Project root IS a git repo → all 3 guards run. A failure is reported on
736
+ * `guard.violations[]` and `guard.ok=false`. With IJFW_WORKTREE_GUARD_STRICT=1
737
+ * a violation throws (aborts the dispatch before `_withLocks`).
738
+ * - Project root is NOT a git repo (or guards can't run) → `guard.ok` stays
739
+ * true with `guard.skipped` set; brief composition proceeds. This keeps the
740
+ * verb usable from non-git temp dirs (tests, scratch projects).
741
+ *
742
+ * Only `'worktree'` isolation is guarded — `'shared'` dispatch spawns no
743
+ * separate worktree so containment/drift/protected-ref don't apply.
744
+ *
745
+ * @param {string} projectRoot absolute path the subagent dispatches into
746
+ * @param {'shared'|'worktree'} isolation
747
+ * @returns {{ok:boolean, skipped?:string, violations:string[], branch?:string}}
748
+ * @throws {Error} only when IJFW_WORKTREE_GUARD_STRICT=1 and a guard fails
749
+ */
750
+ function runWorktreeGuards(projectRoot, isolation) {
751
+ if (isolation !== 'worktree') {
752
+ return { ok: true, skipped: 'shared-isolation', violations: [] };
753
+ }
754
+ // Quiet git-repo pre-check — captureSpawnToplevel would otherwise let git's
755
+ // own "fatal: not a git repository" hit our stderr on non-git roots (common
756
+ // in tests / scratch projects). Suppress git stderr here, then run the real
757
+ // guards only when we know we're inside a work tree.
758
+ try {
759
+ const probe = execFileSync(
760
+ 'git', ['rev-parse', '--is-inside-work-tree'],
761
+ { cwd: projectRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
762
+ ).trim();
763
+ if (probe !== 'true') {
764
+ return { ok: true, skipped: 'not-a-git-repo', violations: [] };
765
+ }
766
+ } catch {
767
+ // Not a git tree — nothing to contain. Brief composition still proceeds.
768
+ return { ok: true, skipped: 'not-a-git-repo', violations: [] };
769
+ }
770
+ let toplevel;
771
+ try {
772
+ toplevel = captureSpawnToplevel(projectRoot);
773
+ } catch {
774
+ return { ok: true, skipped: 'not-a-git-repo', violations: [] };
775
+ }
776
+ const violations = [];
777
+ let branch;
778
+ try {
779
+ assertPathWithinToplevel(projectRoot, toplevel);
780
+ } catch (e) {
781
+ violations.push(`path-escape: ${e.message}`);
782
+ }
783
+ try {
784
+ assertNoCwdDrift(toplevel, projectRoot);
785
+ } catch (e) {
786
+ violations.push(`cwd-drift: ${e.message}`);
787
+ }
788
+ try {
789
+ branch = assertNotProtectedRef(projectRoot);
790
+ } catch (e) {
791
+ violations.push(`protected-ref: ${e.message}`);
792
+ }
793
+ const ok = violations.length === 0;
794
+ if (!ok && process.env.IJFW_WORKTREE_GUARD_STRICT === '1') {
795
+ throw new Error(
796
+ `state-sdk: subagent.dispatch S08 worktree guard failed — ${violations.join('; ')}`,
797
+ );
798
+ }
799
+ return ok
800
+ ? { ok: true, violations: [], branch }
801
+ : { ok: false, violations };
802
+ }
803
+
707
804
  // ---------------------------------------------------------------------------
708
805
  // VERB HANDLERS — one per contract §7 block. Signature: (payload, ctx, env).
709
806
  // `env` carries the per-invocation { verbId } so handlers can stamp records.
@@ -1000,6 +1097,13 @@ const handlers = {
1000
1097
  const isolation = payload?.isolation === 'shared' ? 'shared' : 'worktree';
1001
1098
  const role = typeof payload?.role === 'string' && payload.role.length > 0
1002
1099
  ? payload.role : null;
1100
+ // v1.5.1 cleanup C1 — S08 worktree-guard preconditions. Runs the three
1101
+ // incident-driven guards BEFORE the wave-state mutation when isolation is
1102
+ // 'worktree'. This is the production caller worktree-guards.js was missing
1103
+ // (its only prior importer was the unwired runtime-loop.js). With
1104
+ // IJFW_WORKTREE_GUARD_STRICT=1 a guard violation throws here, aborting the
1105
+ // dispatch before `_withLocks`; otherwise it is surfaced on the result.
1106
+ const worktreeGuard = runWorktreeGuards(root, isolation);
1003
1107
  // Caller-supplied env passthrough (object: name → value). Coerce values to
1004
1108
  // strings (env vars are always strings) and drop nullish entries.
1005
1109
  const callerEnv = (payload?.env && typeof payload.env === 'object'
@@ -1055,8 +1159,46 @@ const handlers = {
1055
1159
  const envLines = Object.keys(inheritedEnv).sort()
1056
1160
  .map((k) => ` ${k}=${inheritedEnv[k]}`);
1057
1161
  const eventLogPath = resolveEventLogPath(root, waveId, subagentId);
1058
- const eventLogRel = eventLogPath.startsWith(root + '/')
1059
- ? eventLogPath.slice(root.length + 1) : eventLogPath;
1162
+ // F5.6: the prior `startsWith(root + '/')` form (a) wired only on POSIX
1163
+ // because the separator is `/`, breaking on Windows where the separator
1164
+ // is `\`, and (b) silently leaked absolute paths into the dispatch brief
1165
+ // on Windows because the startsWith check always returned false. Use
1166
+ // path.relative + isAbsolute so the relative form is computed correctly
1167
+ // cross-platform, and fall back to the absolute path only when the
1168
+ // event log is genuinely outside the repo (rel '..' or different drive).
1169
+ // F-LENS2-13: pre-canonicalize via realpathSync so a Windows 8.3 short-
1170
+ // name (PROGRA~1) or a macOS /tmp -> /private/tmp symlink doesn't make
1171
+ // the path.relative containment check spuriously claim "outside repo".
1172
+ //
1173
+ // Canonicalization MUST be atomic across both sides: realpathSync(root)
1174
+ // can succeed (root exists) while realpathSync(eventLogPath) throws
1175
+ // ENOENT (event log file is about to be created). Independent try/catches
1176
+ // produce ASYMMETRIC results — on macOS /var/folders is a symlink to
1177
+ // /private/var/folders, so canonicalRoot becomes the long form while
1178
+ // canonicalEventLog stays short, path.relative returns a `..`-leading
1179
+ // string, and the containment check spuriously rejects — leaking the
1180
+ // absolute path into the dispatch brief. One try/catch keeps both sides
1181
+ // either canonical or both un-canonical.
1182
+ let canonicalRoot, canonicalEventLog;
1183
+ try {
1184
+ canonicalRoot = realpathSync(root);
1185
+ canonicalEventLog = realpathSync(eventLogPath);
1186
+ } catch {
1187
+ canonicalRoot = root;
1188
+ canonicalEventLog = eventLogPath;
1189
+ }
1190
+ const _rel = pathRelative(canonicalRoot, canonicalEventLog);
1191
+ // F-LENS2-14: when the event log is genuinely outside the repo, the
1192
+ // earlier behaviour leaked the FULL absolute path (often containing
1193
+ // $HOME / $USERPROFILE) into the dispatch brief — which the subagent
1194
+ // sees and may forward to an external LLM provider. Redact to a
1195
+ // pseudo-path so the subagent knows the log exists by basename without
1196
+ // leaking the absolute prefix. The orchestrator still has the real
1197
+ // eventLogPath in its return value for its own I/O.
1198
+ const _basename = (eventLogPath || '').split(/[\\/]/).pop() || 'events.jsonl';
1199
+ const eventLogRel = (_rel === '' || _rel.startsWith('..') || pathIsAbsolute(_rel))
1200
+ ? `<external>/${_basename}`
1201
+ : _rel;
1060
1202
  const dispatchBrief = [
1061
1203
  `# Subagent dispatch — ${subagentId} (wave ${waveId})`,
1062
1204
  role ? `Role: ${role}` : null,
@@ -1078,6 +1220,11 @@ const handlers = {
1078
1220
  isolation,
1079
1221
  inheritedEnv,
1080
1222
  eventLogPath,
1223
+ // v1.5.1 cleanup C1 — S08 guard outcome. `{ok:true}` when guards passed
1224
+ // or were skipped (non-git root / shared isolation); `{ok:false,
1225
+ // violations[]}` when a containment/drift/protected-ref hazard was
1226
+ // detected (non-strict mode — the orchestrator should not spawn).
1227
+ worktreeGuard,
1081
1228
  };
1082
1229
  }, env);
1083
1230
  },
@@ -1127,12 +1274,69 @@ const handlers = {
1127
1274
  process.stderr.write(`[state-sdk] WARN subagent.post-done gate execution-fail: ${e.message}\n`);
1128
1275
  return { ok: true, advisory: true, gate: 'post-done-self-check', reason: e.message };
1129
1276
  }
1277
+ // v1.5.1 cleanup C1 — T20 truncation classification. When the caller hands
1278
+ // us the subagent's `events` stream and/or intent `journal` on the payload,
1279
+ // run `detectTruncation` so a truncated post-DONE is classified on the LIVE
1280
+ // path (ijfw_state MCP tool → query → subagent.post-done). This is the
1281
+ // production caller recovery/truncation.js was missing — its only prior
1282
+ // importer was the unwired runtime-loop.js. Annotation-only: the classifier
1283
+ // never throws and never alters the self-check verdict.
1284
+ let truncation;
1285
+ if (Array.isArray(payload?.events) || Array.isArray(payload?.journal)) {
1286
+ try {
1287
+ const { detectTruncation } = await import('../recovery/truncation.js');
1288
+ const det = detectTruncation({
1289
+ events: payload.events,
1290
+ journal: payload.journal,
1291
+ expectedTerminalVerb: payload.expectedTerminalVerb,
1292
+ });
1293
+ truncation = {
1294
+ truncated: det.truncated,
1295
+ reason: det.reason,
1296
+ };
1297
+ } catch (e) {
1298
+ process.stderr.write(
1299
+ `[state-sdk] WARN subagent.post-done truncation classify failed: ${e.message}\n`,
1300
+ );
1301
+ }
1302
+ }
1130
1303
  if (selfCheck.verdict !== 'PASSED') {
1131
1304
  const reason = `self-check FAILED — ${selfCheck.files_missing.length} missing file(s), `
1132
1305
  + `${selfCheck.commits_missing.length} missing commit(s)`;
1306
+ // v1.5.1: LIVE wiring of debug-trident (T29) onto the production
1307
+ // gate-failure path. When the post-done self-check FAILS this is
1308
+ // exactly the stalled-investigation moment the Trident debug loop
1309
+ // exists for — dispatch codex+gemini to generate competing root-cause
1310
+ // hypotheses against the gate-failure evidence. FIRE-AND-FORGET:
1311
+ // `maybeFireDebugTrident` returns immediately, the campaign runs in a
1312
+ // detached promise — the verb's return value + timing are UNCHANGED so
1313
+ // STATE-SDK-CONTRACT §8 (subagent.post-done is a fast read verb) holds.
1314
+ // Env-gated (IJFW_DEBUG_TRIDENT) + silent no-op on missing deps; never
1315
+ // throws. Dynamic import avoids a static require cycle. Mirrors the
1316
+ // A-Mem auto-linker fire-and-forget pattern in memory/fts5.js.
1317
+ try {
1318
+ import('./debug-trident-trigger.js')
1319
+ .then(({ maybeFireDebugTrident }) => {
1320
+ maybeFireDebugTrident({
1321
+ projectRoot,
1322
+ subagentId: _subagentId,
1323
+ reason,
1324
+ reportText,
1325
+ selfCheck,
1326
+ });
1327
+ })
1328
+ .catch((e) => {
1329
+ try {
1330
+ process.stderr.write(
1331
+ `[state-sdk] WARN subagent.post-done debug-trident dispatch failed: ${e.message}\n`,
1332
+ );
1333
+ } catch { /* never throw */ }
1334
+ });
1335
+ } catch { /* fire-and-forget — never alters the verb verdict */ }
1133
1336
  if (!GATE_BYPASS) {
1134
1337
  return {
1135
1338
  ok: false, refused: true, gate: 'post-done-self-check', reason,
1339
+ ...(truncation ? { truncation } : {}),
1136
1340
  };
1137
1341
  }
1138
1342
  // Bypass masks a would-be refusal — emit a loud WARN + advisory
@@ -1149,6 +1353,7 @@ const handlers = {
1149
1353
  claimedCommits: selfCheck.commits_claimed,
1150
1354
  verified: false,
1151
1355
  },
1356
+ ...(truncation ? { truncation } : {}),
1152
1357
  };
1153
1358
  }
1154
1359
  return {
@@ -1158,6 +1363,7 @@ const handlers = {
1158
1363
  claimedCommits: selfCheck.commits_claimed,
1159
1364
  verified: selfCheck.verdict === 'PASSED',
1160
1365
  },
1366
+ ...(truncation ? { truncation } : {}),
1161
1367
  };
1162
1368
  },
1163
1369
 
@@ -27,6 +27,10 @@ import { query } from './state-sdk.js';
27
27
  import { pollEvents } from './state-events.js';
28
28
  // v1.5.0 N4.obs M1+M2: trace-id + hierarchical observation path.
29
29
  import { getTraceId, composePath } from '../observability/trace-id.js';
30
+ // v1.5.1 W2.F: checkpoint-contract evaluator (N4.obs M3). Validates a final
31
+ // status report carried by a checkpoint envelope against the v1.4.4 N2
32
+ // four-section contract before the envelope is written.
33
+ import { evaluateCheckpointContract } from '../observability/evaluator-checkpoint-contract.js';
30
34
 
31
35
  // ---------------------------------------------------------------------------
32
36
  // Frozen constants — Wave 11-A imports these directly
@@ -123,6 +127,21 @@ export async function recordCheckpoint(waveId, subId, checkpoint, projectRoot) {
123
127
  ...checkpoint,
124
128
  };
125
129
 
130
+ // v1.5.1 W2.F: contract-validate at emission time. When a checkpoint
131
+ // envelope carries the subagent's final status report (`checkpoint.report`),
132
+ // it MUST satisfy the v1.4.4 N2 four-section contract (Status/SHAs/Files/
133
+ // Tests). Schema drift now fails LOUD here at write — not silently at read.
134
+ // Progress-only checkpoints (no `report` field) skip this check unchanged.
135
+ if (typeof checkpoint.report === 'string') {
136
+ const evalResult = evaluateCheckpointContract(checkpoint.report);
137
+ if (!evalResult.valid) {
138
+ throw new Error(
139
+ `subagent-telemetry: checkpoint report for ${waveId}/${subId} `
140
+ + `violates checkpoint-contract — ${evalResult.reason}`,
141
+ );
142
+ }
143
+ }
144
+
126
145
  const serialised = JSON.stringify(envelope);
127
146
  if (serialised.length > MAX_CHECKPOINT_SIZE) {
128
147
  throw new Error(
@@ -59,6 +59,27 @@ function loadPopulateBlackboardBlock() {
59
59
  return _populateBlackboardBlockPromise;
60
60
  }
61
61
 
62
+ // Wave 5B wiring (post-cross-audit W1 fix): same lazy-Promise-singleton
63
+ // pattern as populateBlackboardBlock above. populateDisciplineBlock is
64
+ // idempotent (no-op short-circuit when content unchanged), so firing on
65
+ // every wave checkpoint is free and guarantees the DISCIPLINE marker block
66
+ // in AGENTS.md actually gets populated during a real workflow — closes the
67
+ // "ships as dead code" wiring gap the cross-audit caught.
68
+ let _populateDisciplineBlockPromise = null;
69
+ function loadPopulateDisciplineBlock() {
70
+ if (_populateDisciplineBlockPromise === null) {
71
+ _populateDisciplineBlockPromise = (async () => {
72
+ try {
73
+ const mod = await import('./agents-md-blackboard.js');
74
+ return mod.populateDisciplineBlock ?? null;
75
+ } catch {
76
+ return null;
77
+ }
78
+ })();
79
+ }
80
+ return _populateDisciplineBlockPromise;
81
+ }
82
+
62
83
  /**
63
84
  * Test-only helper: reset the populateBlackboardBlock promise singleton so a
64
85
  * test can simulate "first call after process start" semantics. Internal.
@@ -69,6 +90,14 @@ export function _resetPopulateBlackboardBlockSingleton() {
69
90
  _populateBlackboardBlockPromise = null;
70
91
  }
71
92
 
93
+ /**
94
+ * Test-only helper: reset the populateDisciplineBlock promise singleton.
95
+ * @internal
96
+ */
97
+ export function _resetPopulateDisciplineBlockSingleton() {
98
+ _populateDisciplineBlockPromise = null;
99
+ }
100
+
72
101
  // ---------------------------------------------------------------------------
73
102
  // Internal YAML helpers — flat subset only (string/number/boolean/string[])
74
103
  // ---------------------------------------------------------------------------
@@ -560,5 +589,14 @@ export async function checkpointWave(waveId, projectRoot) {
560
589
  try { await populateBlackboardBlock(waveId, projectRoot); } catch { /* advisory */ }
561
590
  }
562
591
 
592
+ // Wave 5B wiring (cross-audit W1 fix): populate the DISCIPLINE block too.
593
+ // Same advisory-failure semantics as the BLACKBOARD call above. Auto-detects
594
+ // project type from .ijfw/memory/brief.md frontmatter or repo signals — no
595
+ // explicit projectType passed, the detector handles it.
596
+ const populateDisciplineBlock = await loadPopulateDisciplineBlock();
597
+ if (populateDisciplineBlock) {
598
+ try { await populateDisciplineBlock(projectRoot, undefined, { waveId }); } catch { /* advisory */ }
599
+ }
600
+
563
601
  return next;
564
602
  }
@@ -65,9 +65,11 @@ import {
65
65
  * projectRoot. Used by deployResolvedSkill to know which platforms to write
66
66
  * the merged body into.
67
67
  *
68
- * TODO(W2b/t11): replace this with an exported helper from
69
- * installer/src/install-helpers.js once that module exposes a canonical
70
- * platform-list getter. Until then this on-disk probe is the contract.
68
+ * DEFERRED (platform-list consolidation): could be replaced with an exported
69
+ * helper from installer/src/install-helpers.js once that module exposes a
70
+ * canonical platform-list getter. Until then this on-disk probe is the
71
+ * contract — and as an on-disk probe it correctly reflects what is actually
72
+ * deployed, so the consolidation is a nice-to-have, not a defect.
71
73
  *
72
74
  * @param {string} projectRoot
73
75
  * @returns {string[]} absolute paths to existing platform skill dirs