@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
@@ -31,6 +31,13 @@ import { readFileSync, existsSync, mkdirSync } from 'node:fs';
31
31
  import { dirname, join, resolve, normalize, isAbsolute } from 'node:path';
32
32
 
33
33
  import { expandQuery } from '../compute/synonyms.js';
34
+ import { loadMigrations } from './migration-runner.js';
35
+ // v1.5.1 R4-H2 — auto-index rows must flow through indexEntry so the
36
+ // v1.5.0 memory-moat (M1 Obsidian indexing + M2 A-Mem auto-linking) fires
37
+ // for warm-tier rebuilds, not just the benchmark harness. obsidian-parser
38
+ // is imported directly so M1 runs synchronously inside the same txn batch.
39
+ import { indexObsidianRelations } from './obsidian-parser.js';
40
+ import { autoLink } from './auto-linker.js';
34
41
 
35
42
  const MAX_RESULTS = 50;
36
43
  const SNIPPET_HALF = 60;
@@ -50,30 +57,16 @@ try {
50
57
  }
51
58
 
52
59
  // Resolve migration modules synchronously at module load via top-level
53
- // await. Replayed inside searchMemory's sync path. Keep in lockstep with
54
- // ./migrations/.
55
- const MEMORY_MIGRATIONS = await loadMemoryMigrationsSync();
56
-
57
- async function loadMemoryMigrationsSync() {
58
- const v1 = await import('./migrations/001-fts5-init.js');
59
- const v2 = await import('./migrations/002-tier-semantic.js');
60
- const v3 = await import('./migrations/003-stale-candidate.js');
61
- const v4 = await import('./migrations/004-bitemporal.js');
62
- const v5 = await import('./migrations/005-vector-cache.js');
63
- const v6 = await import('./migrations/006-obsidian-graph.js');
64
- const v7 = await import('./migrations/007-skill-telemetry.js');
65
- const v8 = await import('./migrations/008-write-provenance.js');
66
- return [
67
- { version: v1.VERSION, description: v1.DESCRIPTION, up: v1.up },
68
- { version: v2.VERSION, description: v2.DESCRIPTION, up: v2.up },
69
- { version: v3.VERSION, description: v3.DESCRIPTION, up: v3.up },
70
- { version: v4.VERSION, description: v4.DESCRIPTION, up: v4.up },
71
- { version: v5.VERSION, description: v5.DESCRIPTION, up: v5.up },
72
- { version: v6.VERSION, description: v6.DESCRIPTION, up: v6.up },
73
- { version: v7.VERSION, description: v7.DESCRIPTION, up: v7.up },
74
- { version: v8.VERSION, description: v8.DESCRIPTION, up: v8.up },
75
- ].sort((a, b) => a.version - b.version);
76
- }
60
+ // await. Replayed inside searchMemory's sync path.
61
+ //
62
+ // v1.5.1 W3.B: discovery is delegated to memory/migration-runner.js
63
+ // (readdirSync over ./migrations/) so a single source of truth governs
64
+ // which migrations search.js knows about. Prior to this, search.js
65
+ // carried its OWN hardcoded list -- the v1.5.0 INT.7 hotfix patched
66
+ // the symptom (006/007/008 missing); this kills the dual-registry bug
67
+ // class outright. Drop migration 009 into ./migrations/, and search.js
68
+ // will pick it up automatically.
69
+ const MEMORY_MIGRATIONS = await loadMigrations();
77
70
 
78
71
  function highestMigrationVersion() {
79
72
  if (!MEMORY_MIGRATIONS.length) return 0;
@@ -220,12 +213,20 @@ function runMemoryMigrationsSync(db, currentVersion, targetVersion) {
220
213
 
221
214
  function autoIndex(db, files) {
222
215
  let n = 0;
216
+ // v1.5.1 R4-H2 — capture the rowid of every inserted entry so the
217
+ // memory-moat aux indexing (M1 Obsidian relations, M2 auto-link) can run
218
+ // over the warm-tier rebuild, not just the benchmark harness. The bulk
219
+ // INSERT stays in one transaction for FTS write performance; M1/M2 run
220
+ // AFTER commit so a parse/link failure can never abort the rebuild.
221
+ const inserted = [];
223
222
  const txfn = db.transaction((batch) => {
224
223
  const stmt = db.prepare(
225
224
  'INSERT INTO memory_entries (body, source, session_id, created_at) VALUES (?, ?, ?, ?)'
226
225
  );
227
226
  for (const item of batch) {
228
- stmt.run(item.body, item.source, null, item.created_at);
227
+ const info = stmt.run(item.body, item.source, null, item.created_at);
228
+ const id = info && info.lastInsertRowid != null ? Number(info.lastInsertRowid) : null;
229
+ inserted.push({ id, body: item.body });
229
230
  n++;
230
231
  }
231
232
  });
@@ -242,6 +243,26 @@ function autoIndex(db, files) {
242
243
  }
243
244
  if (batch.length === 0) return 0;
244
245
  try { txfn.immediate(batch); } catch { /* one bad batch should not abort the search */ }
246
+
247
+ // v1.5.1 R4-H2 — M1: Obsidian wikilink/tag/meta indexing into
248
+ // memory_links/_tags/_meta. Synchronous + idempotent (indexObsidianRelations
249
+ // clears prior rows for the id before re-inserting). Best-effort: a missing
250
+ // migration-006 schema or a parse failure must never break the search path.
251
+ // M2: A-Mem auto-linking — fire-and-forget, env-gated (IJFW_AUTOLINK_OFF),
252
+ // budget-capped (IJFW_AUTOLINK_BUDGET_USD); returns skipped cleanly when no
253
+ // API key, so a bulk rebuild without credentials does no LLM work.
254
+ for (const row of inserted) {
255
+ if (row.id == null) continue;
256
+ try {
257
+ indexObsidianRelations(db, String(row.id), row.body);
258
+ } catch { /* M1 best-effort -- never abort the search */ }
259
+ try {
260
+ const p = autoLink(db, { id: row.id, body: row.body });
261
+ if (p && typeof p.catch === 'function') p.catch(() => {});
262
+ // expose for tests that want deterministic completion
263
+ autoIndex.__lastAutoLinkPromise = p;
264
+ } catch { /* M2 dispatch best-effort */ }
265
+ }
245
266
  return n;
246
267
  }
247
268
 
@@ -325,6 +346,108 @@ function rowCount(db) {
325
346
  }
326
347
  }
327
348
 
349
+ // --- Structured provenance helpers -----------------------------------------
350
+
351
+ /**
352
+ * Convert a raw FTS row + fileBySource map to a structured provenance object.
353
+ * Used when opts.format === 'structured'.
354
+ *
355
+ * Fields that aren't computed in the existing pipeline are returned as
356
+ * null / 0 rather than introducing new compute work (Task 28 spec).
357
+ *
358
+ * @param {object} row - raw DB row from searchFts5
359
+ * @param {Map} fileBySource
360
+ * @param {string} rawQuery - original user query (for whyMatched extraction)
361
+ * @param {object} db - open DB handle (for backlink count query)
362
+ * @returns {object}
363
+ */
364
+ function ftsRowToStructured(row, fileBySource, rawQuery, db) {
365
+ const src = row.source || '';
366
+ const meta = fileBySource.get(src) || null;
367
+ const source = (meta && meta.path) || src;
368
+ const text = String(row.body || '');
369
+ const snip = text.slice(0, 200).replace(/\s+/g, ' ').trim();
370
+
371
+ // confidence: bm25 rank is negative (more negative = better). Convert to 0..1.
372
+ // rank returned from searchFts5 can be 0 or negative; we use the same
373
+ // score formula as ftsRowToResult (100 - rank) but normalise to 0..1 by
374
+ // clamping to [0, 100] and dividing.
375
+ const rawRank = Number(row.rank || 0);
376
+ const scoreRaw = 100 - rawRank; // same as ftsRowToResult
377
+ const confidence = Math.min(1, Math.max(0, scoreRaw / 100));
378
+
379
+ // ageDays: created_at is unix ms
380
+ const createdAt = Number(row.created_at || 0);
381
+ const ageDays = createdAt > 0
382
+ ? Math.max(0, (Date.now() - createdAt) / 86400000)
383
+ : 0;
384
+
385
+ // decayFactor: not yet computed in pipeline — return null per spec
386
+ const decayFactor = null;
387
+
388
+ // whyMatched: tokenise the raw query into distinct non-trivial terms
389
+ const whyMatched = rawQuery
390
+ .trim()
391
+ .split(/\s+/)
392
+ .map(t => t.replace(/['"*()]/g, '').toLowerCase())
393
+ .filter(t => t.length > 0);
394
+
395
+ // backlinkCount: count rows in memory_links where to_target matches source
396
+ let backlinkCount = 0;
397
+ if (db && row.id != null) {
398
+ try {
399
+ const idStr = String(row.id);
400
+ const r = db.prepare(
401
+ 'SELECT COUNT(*) AS n FROM memory_links WHERE to_target = ?'
402
+ ).get(idStr);
403
+ backlinkCount = r ? Number(r.n) : 0;
404
+ } catch { /* memory_links may not exist in older dbs */ }
405
+ }
406
+
407
+ return {
408
+ source,
409
+ anchor: null,
410
+ snippet: snip,
411
+ confidence,
412
+ ageDays,
413
+ decayFactor,
414
+ whyMatched,
415
+ backlinkCount,
416
+ };
417
+ }
418
+
419
+ /**
420
+ * Convert a hot-linear result to a structured provenance object.
421
+ * Linear results lack a DB row id so backlinkCount is always 0.
422
+ *
423
+ * @param {object} result - from searchLinear
424
+ * @param {string} rawQuery
425
+ * @returns {object}
426
+ */
427
+ function linearResultToStructured(result, rawQuery) {
428
+ const scoreRaw = Number(result.score || 0);
429
+ // Hot-linear score is titleMatches*3 + bodyMatches; normalise loosely to 0..1
430
+ // by capping at 50 matches (arbitrary but safe)
431
+ const confidence = Math.min(1, scoreRaw / 50);
432
+
433
+ const whyMatched = rawQuery
434
+ .trim()
435
+ .split(/\s+/)
436
+ .map(t => t.replace(/['"*()]/g, '').toLowerCase())
437
+ .filter(t => t.length > 0);
438
+
439
+ return {
440
+ source: result.path || result.relpath || '',
441
+ anchor: null,
442
+ snippet: result.snippet || '',
443
+ confidence,
444
+ ageDays: 0,
445
+ decayFactor: null,
446
+ whyMatched,
447
+ backlinkCount: 0,
448
+ };
449
+ }
450
+
328
451
  // --- Public API -------------------------------------------------------------
329
452
 
330
453
  /**
@@ -344,30 +467,41 @@ function rowCount(db) {
344
467
  * the hot-linear fallback is unfiltered (D1 does not yet write tier
345
468
  * metadata into the markdown surface).
346
469
  *
470
+ * format option (Task 28 — structured provenance):
471
+ * opts.format === 'structured' returns an Array of provenance objects:
472
+ * [{source, anchor, snippet, confidence, ageDays, decayFactor,
473
+ * whyMatched, backlinkCount}]
474
+ * Default (no format) returns Array<{path,relpath,title,snippet,score,
475
+ * tier_semantic}> — byte-identical to pre-Task-28 behaviour.
476
+ *
347
477
  * @param {string} q
348
478
  * @param {Array<{path,relpath,title,preview}>} files
349
479
  * @param {number} limit
350
480
  * @param {object|undefined} options
351
- * @returns {Array<{path,relpath,title,snippet,score,tier_semantic}>}
481
+ * @returns {Array<{path,relpath,title,snippet,score,tier_semantic}>|Array<provenance>}
352
482
  */
353
483
  export function searchMemory(q, files, limit = MAX_RESULTS, options) {
354
484
  if (!q || !q.trim() || !files || files.length === 0) return [];
355
485
 
356
- // Normalise options. Allow undefined / { tier_semantic, include_stale } /
357
- // a bare string (treated as the tier_semantic value) for ergonomic call
358
- // sites. include_stale defaults to false -- D4 GA-B2 retrieval guard.
486
+ // Normalise options. Allow undefined / { tier_semantic, include_stale,
487
+ // format } / a bare string (treated as the tier_semantic value) for
488
+ // ergonomic call sites. include_stale defaults to false -- D4 GA-B2
489
+ // retrieval guard. format === 'structured' enables Task-28 provenance.
359
490
  let tier_semantic;
360
491
  let include_stale = false;
492
+ let format;
361
493
  if (typeof options === 'string') {
362
494
  tier_semantic = options;
363
495
  } else if (options && typeof options === 'object') {
364
496
  tier_semantic = options.tier_semantic;
365
497
  include_stale = options.include_stale === true;
498
+ format = options.format;
366
499
  }
367
500
 
368
501
  const { expanded, synonym_matches, applied } = expandQuery(q);
369
502
 
370
503
  let warmHits = null;
504
+ let warmRawRows = null; // preserved for structured format (Task 28)
371
505
  let warmEmpty = false;
372
506
  let db = null;
373
507
 
@@ -403,6 +537,11 @@ export function searchMemory(q, files, limit = MAX_RESULTS, options) {
403
537
  if (f.relpath) fileBySource.set(f.relpath, f);
404
538
  if (f.path) fileBySource.set(f.path, f);
405
539
  }
540
+ if (format === 'structured') {
541
+ // Task 28: map raw rows to provenance objects while db is still open
542
+ // (backlinkCount query needs the handle)
543
+ warmRawRows = rows.map(r => ftsRowToStructured(r, fileBySource, q, db));
544
+ }
406
545
  warmHits = rows.map(r => ftsRowToResult(r, fileBySource));
407
546
  } else {
408
547
  warmEmpty = true;
@@ -411,31 +550,41 @@ export function searchMemory(q, files, limit = MAX_RESULTS, options) {
411
550
  }
412
551
  } catch {
413
552
  warmHits = null;
553
+ warmRawRows = null;
414
554
  } finally {
415
555
  if (db) { try { db.close(); } catch { /* ignore */ } }
416
556
  }
417
557
 
418
558
  let results;
419
559
  if (warmHits && warmHits.length > 0) {
420
- results = warmHits.slice(0, limit);
560
+ results = format === 'structured'
561
+ ? warmRawRows.slice(0, limit)
562
+ : warmHits.slice(0, limit);
421
563
  } else if (tier_semantic) {
422
564
  // Tier filter active and warm tier has no matches -- the hot-linear
423
565
  // tier doesn't carry tier metadata so it can't honour the filter.
424
566
  // Returning [] here keeps the contract honest ("only matching tier").
425
567
  results = [];
426
568
  } else {
427
- results = searchLinear(q, files, limit);
569
+ const linearResults = searchLinear(q, files, limit);
570
+ results = format === 'structured'
571
+ ? linearResults.map(r => linearResultToStructured(r, q))
572
+ : linearResults;
428
573
  }
429
574
 
430
- Object.defineProperty(results, 'synonym_matches', {
431
- value: applied ? synonym_matches : {},
432
- enumerable: false,
433
- });
434
- Object.defineProperty(results, 'tier', {
435
- value: warmHits && warmHits.length > 0
436
- ? 'warm-fts5'
437
- : (warmEmpty ? 'hot-linear-empty-fts5' : 'hot-linear'),
438
- enumerable: false,
439
- });
575
+ // Structured results are plain arrays — no non-enumerable decorations needed.
576
+ // Legacy path: attach non-enumerable metadata as before (byte-identical).
577
+ if (format !== 'structured') {
578
+ Object.defineProperty(results, 'synonym_matches', {
579
+ value: applied ? synonym_matches : {},
580
+ enumerable: false,
581
+ });
582
+ Object.defineProperty(results, 'tier', {
583
+ value: warmHits && warmHits.length > 0
584
+ ? 'warm-fts5'
585
+ : (warmEmpty ? 'hot-linear-empty-fts5' : 'hot-linear'),
586
+ enumerable: false,
587
+ });
588
+ }
440
589
  return results;
441
590
  }
@@ -333,7 +333,13 @@ export function storeFactBitemporal(db, fact, now) {
333
333
  const factId = insertFact(db, f, t);
334
334
  return { invalidated, factId };
335
335
  });
336
- const r = txn(fact, ts);
336
+ // F2.7: .immediate() issues BEGIN IMMEDIATE — see brain-handler conflict.resolve.
337
+ // Sister writers (conflict.resolve) hold IMMEDIATE locks; if we ran DEFERRED here
338
+ // we would hit SQLITE_BUSY when upgrading SHARED→RESERVED on the first write
339
+ // inside the txn body and the user's memory write would silently drop (or
340
+ // throw SQLITE_BUSY at the caller). Lock-mode alignment with sister writers
341
+ // is what makes the cross-connection contract honest.
342
+ const r = txn.immediate(fact, ts);
337
343
  return { invalidated: r.invalidated, factId: r.factId, deduped: false };
338
344
  }
339
345
 
@@ -513,6 +519,38 @@ export function applyDecayToFacts(rows, now, options = {}) {
513
519
  });
514
520
  }
515
521
 
522
+ /**
523
+ * getHistoryWindow -- bounded slice of facts about (subject[, predicate]) for
524
+ * the wiki compiler's "history" section. Returns at most `limit` rows ordered
525
+ * by valid_from DESC. When rollupOlder is true and rows.length === limit,
526
+ * also returns an `older` rollup of facts beyond the window so the wiki page
527
+ * can show "Older: 55 events between 2024-03-01 and 2025-06-30" without
528
+ * bloating the page.
529
+ *
530
+ * Trident F-B2 protection: prevents page-bloat for hot subjects with hundreds
531
+ * of facts.
532
+ */
533
+ export function getHistoryWindow(db, subject, predicate, { limit = 50, since = null, rollupOlder = true } = {}) {
534
+ const params = [subject];
535
+ let where = 'subject = ?';
536
+ if (predicate != null) { where += ' AND predicate = ?'; params.push(predicate); }
537
+ if (since) { where += ' AND valid_from >= ?'; params.push(since); }
538
+ const rows = db.prepare(
539
+ `SELECT id, predicate, object, valid_from, valid_to, memory_id, source, confidence
540
+ FROM facts WHERE ${where} ORDER BY valid_from DESC LIMIT ?`
541
+ ).all(...params, limit);
542
+ let older = null;
543
+ if (rollupOlder && rows.length === limit) {
544
+ const earliest = rows[rows.length - 1].valid_from;
545
+ const r = db.prepare(
546
+ `SELECT COUNT(*) AS count, MIN(valid_from) AS fromIso, MAX(valid_from) AS toIso
547
+ FROM facts WHERE ${where} AND valid_from < ?`
548
+ ).get(...params, earliest);
549
+ if (r.count > 0) older = r;
550
+ }
551
+ return { rows, older };
552
+ }
553
+
516
554
  export default {
517
555
  openTemporalDb,
518
556
  openTemporalDbSync,
@@ -524,6 +562,7 @@ export default {
524
562
  getHistory,
525
563
  getAllFactsWithWindows,
526
564
  applyDecayToFacts,
565
+ getHistoryWindow,
527
566
  DECAY_HALFLIFE_DAYS,
528
567
  DECAY_HALFLIFE_SESSION_DAYS,
529
568
  };
@@ -46,12 +46,17 @@
46
46
  * AGENTS.md hiccup — see `wave-state.js#checkpointWave`).
47
47
  */
48
48
 
49
- import { join } from 'node:path';
49
+ import { join, resolve as pathResolve, dirname as pathDirname } from 'node:path';
50
50
 
51
51
  import { withFsLock, lockPathFor } from '../fs-lock.js';
52
52
  import { readWaveState } from './wave-state.js';
53
53
  import { mergeFile, MergeBlockAwareError } from './merge-block-aware.js';
54
54
  import { query } from './state-sdk.js';
55
+ import {
56
+ selectDisciplineTemplate,
57
+ detectProjectTypeFromRepo,
58
+ } from './discipline-selector.js';
59
+ import { validateSafeRepoPath } from '../brain/path-guard.js';
55
60
 
56
61
  /**
57
62
  * Render the BLACKBOARD marker-block payload from a wave's STATE.md
@@ -93,8 +98,21 @@ export async function populateBlackboardBlock(waveId, projectRoot) {
93
98
  const state = await readWaveState(waveId, projectRoot);
94
99
  if (!state) return { ok: false, reason: 'no-state' };
95
100
 
101
+ // Defense-in-depth: refuse to operate at a filesystem root (e.g. '/' on
102
+ // POSIX, 'C:\\' on Windows). validateSafeRepoPath alone accepts these
103
+ // because '/AGENTS.md' is technically "inside" '/'; OS permissions would
104
+ // then catch the actual write, but the failure mode would be 'merge-error'
105
+ // not 'unsafe-path'. We want a clean structured rejection upstream of any
106
+ // I/O attempt. Test at integration/test-discipline-integration.js:189.
107
+ const resolvedRoot = pathResolve(projectRoot || '.');
108
+ if (resolvedRoot === pathDirname(resolvedRoot)) {
109
+ return { ok: false, reason: 'unsafe-path', error: 'projectRoot is a filesystem root' };
110
+ }
111
+
96
112
  const payload = renderBlackboardPayload(waveId, state);
97
113
  const agentsMdPath = join(projectRoot, 'AGENTS.md');
114
+ const guard = validateSafeRepoPath(projectRoot, agentsMdPath);
115
+ if (!guard.ok) return { ok: false, reason: 'unsafe-path', error: guard.error };
98
116
  const lockPath = lockPathFor(agentsMdPath);
99
117
 
100
118
  let mergeResult;
@@ -150,3 +168,98 @@ export async function populateBlackboardBlock(waveId, projectRoot) {
150
168
 
151
169
  return { ok: true };
152
170
  }
171
+
172
+ /**
173
+ * Populate the DISCIPLINE marker block in `<projectRoot>/AGENTS.md` with the
174
+ * project-appropriate discipline template body. Held under the §3 #8 AGENTS.md
175
+ * lock; in-process (no spawn). Best-effort SDK event emit after lock release.
176
+ *
177
+ * If `projectType` is not supplied, `detectProjectTypeFromRepo(projectRoot)`
178
+ * is called to infer it. For `unknown` / `mixed` types the DISCIPLINE block
179
+ * is written with an empty body (marker present, body empty) -- this is the
180
+ * correct state, not an error.
181
+ *
182
+ * Return shapes:
183
+ * `{ ok: true }` -- wrote AGENTS.md
184
+ * `{ ok: false, reason: 'merge-error', error }` -- merger threw
185
+ * `{ ok: false, reason: 'template-missing', error }` -- template file absent
186
+ *
187
+ * @param {string} projectRoot
188
+ * @param {string} [projectType] optional; inferred when absent
189
+ * @param {{waveId?: string}} [opts] optional; waveId defaults to 'system'
190
+ * @returns {Promise<{ok: boolean, reason?: string, error?: string}>}
191
+ */
192
+ export async function populateDisciplineBlock(projectRoot, projectType, opts = {}) {
193
+ const waveId = opts.waveId || 'system';
194
+ const type = projectType !== undefined && projectType !== null
195
+ ? String(projectType)
196
+ : detectProjectTypeFromRepo(projectRoot);
197
+
198
+ let content;
199
+ try {
200
+ content = selectDisciplineTemplate(type);
201
+ } catch (err) {
202
+ return {
203
+ ok: false,
204
+ reason: 'template-missing',
205
+ error: String(err.message || err),
206
+ };
207
+ }
208
+
209
+ // Defense-in-depth: same filesystem-root rejection as populateBlackboardBlock.
210
+ const resolvedRoot = pathResolve(projectRoot || '.');
211
+ if (resolvedRoot === pathDirname(resolvedRoot)) {
212
+ return { ok: false, reason: 'unsafe-path', error: 'projectRoot is a filesystem root' };
213
+ }
214
+
215
+ const agentsMdPath = join(projectRoot, 'AGENTS.md');
216
+ const guard = validateSafeRepoPath(projectRoot, agentsMdPath);
217
+ if (!guard.ok) return { ok: false, reason: 'unsafe-path', error: guard.error };
218
+ const lockPath = lockPathFor(agentsMdPath);
219
+
220
+ let mergeResult;
221
+ let mergeError = null;
222
+ try {
223
+ mergeResult = await withFsLock(lockPath, async () => mergeFile(
224
+ agentsMdPath,
225
+ [{ block: 'DISCIPLINE', content }],
226
+ ));
227
+ } catch (err) {
228
+ mergeError = err;
229
+ }
230
+
231
+ if (mergeError) {
232
+ const code = mergeError instanceof MergeBlockAwareError ? mergeError.code : null;
233
+ return {
234
+ ok: false,
235
+ reason: code === 'ERR_TEMPLATE_MISSING' ? 'template-missing' : 'merge-error',
236
+ error: String(mergeError.message || mergeError),
237
+ };
238
+ }
239
+
240
+ // SDK-routed observability event -- fire-and-forget AFTER lock release.
241
+ // Mirrors the agents-md.blackboard.set emit pattern above.
242
+ try {
243
+ await query('event.emit', {
244
+ subagentId: 'parent',
245
+ waveId,
246
+ eventType: 'agents-md.discipline.set',
247
+ data: {
248
+ path: mergeResult?.path ?? agentsMdPath,
249
+ bytes: mergeResult?.bytes ?? 0,
250
+ seeded: !!mergeResult?.seeded,
251
+ project_type: type,
252
+ },
253
+ dedupKey: `agents-md.discipline.set:${waveId}:${type}`,
254
+ }, { projectRoot, subagentId: 'parent' });
255
+ } catch {
256
+ // Observability is best-effort; never demote a successful AGENTS.md
257
+ // rewrite because the event tap had a hiccup.
258
+ }
259
+
260
+ // Forward noop flag from the short-circuit so callers can detect idempotent
261
+ // calls (5B-L2-05). When mergeResult.noop is true the file was unchanged.
262
+ const result = { ok: true };
263
+ if (mergeResult?.noop) result.noop = true;
264
+ return result;
265
+ }