@nusoft/nuos-build-catalogue 0.33.1 → 0.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,7 @@ The embedder is selected via `NUOS_CATALOGUE_EMBEDDER`:
14
14
 
15
15
  | Value | Provider | Default model | Dimensions | Notes |
16
16
  |---|---|---|---|---|
17
- | `ollama` (default) | Local Ollama | `qwen3-embedding:8b` | 4096 | **Sovereignty by default.** No network egress. Override the model with `NUOS_CATALOGUE_OLLAMA_MODEL=qwen3-embedding:4b` (2560 dims) or `qwen3-embedding:0.6b` (1024 dims) for smaller boxes. Needs `ollama serve` running and the model pulled (`ollama pull qwen3-embedding:8b`). |
17
+ | `ollama` (default) | Local Ollama | `qwen3-embedding:0.6b` | 1024 | **Sovereignty by default.** No network egress. The 0.6b default (~600 MB) runs on any modern laptop, including CPU-only. For better recall on a machine with headroom, raise fidelity with `NUOS_CATALOGUE_OLLAMA_MODEL=qwen3-embedding:4b` (2560 dims, ~2.5 GB) or `qwen3-embedding:8b` (4096 dims, ~4.7 GB). Needs `ollama serve` running and the model pulled (`ollama pull qwen3-embedding:0.6b`). |
18
18
  | `vertex` | Google Vertex | `text-embedding-005` | 768 | Cloud Google. Needs `GOOGLE_CLOUD_PROJECT` plus a Vertex access token (set `GOOGLE_VERTEX_ACCESS_TOKEN`, or have `gcloud` on PATH and run `gcloud auth application-default login`). |
19
19
  | `openai` | OpenAI | `text-embedding-3-small` | 1536 | Cloud OpenAI. Needs `OPENAI_API_KEY`. |
20
20
  | `stub` | Hash-based, no API | — | 384 | Tests + dev only. Results are noisy. |
@@ -26,9 +26,9 @@ Switching embedder (or model variant) requires a full reindex (`rm -rf .nuos-cat
26
26
  ```bash
27
27
  # Pre-flight (one time):
28
28
  ollama serve # in another shell
29
- ollama pull qwen3-embedding:8b # ~4.7 GB download
29
+ ollama pull qwen3-embedding:0.6b # ~600 MB download
30
30
 
31
- # Index the catalogue (first time takes ~20 min on 8b)
31
+ # Index the catalogue (first time re-embeds everything; later runs only re-embed changed files)
32
32
  npm run index
33
33
 
34
34
  # Search
package/dist/cli.js CHANGED
@@ -436,6 +436,15 @@ Usage:
436
436
  nuos-catalogue memory store --value="..." [--wu=wu-007] [--agent=architect] [--key="label"]
437
437
  nuos-catalogue memory search --query="..." [--limit=N] [--wu=wu-007] [--agent=architect]
438
438
 
439
+ nuos-catalogue state compile [--dry-run] [--state-md=<path>]
440
+ (WU 113b — recompile STATE.md generated regions from canonical store;
441
+ splices metadata / what-is-next / open-questions / decisions / risks /
442
+ health-check regions; preserves authored prose byte-for-byte)
443
+ nuos-catalogue state drift-check [--state-md=<path>]
444
+ (WU 113b Stage B — check whether STATE.md generated regions match
445
+ canonical state; exit 0 on clean / no-regions / can't-run;
446
+ exit 1 ONLY on confirmed generated-region drift; called by pre-commit hook)
447
+
439
448
  nuos-catalogue end-of-session
440
449
  (WU 112 — verify-and-gate: checks the nine end-of-session protocol steps
441
450
  against disk facts; prints a per-check report; exits non-zero on a blocked
@@ -657,6 +666,45 @@ async function main() {
657
666
  process.exit(result.exitCode);
658
667
  break;
659
668
  }
669
+ case 'state': {
670
+ // `state compile` — regenerate the generated regions of STATE.md (WU 113b / D132).
671
+ // `state drift-check` — check for generated-region drift (Stage B; called by pre-commit hook).
672
+ const sub = args.positional[0];
673
+ const buildRoot = resolveBuildRoot(args.flags['build-root']);
674
+ const workflowsPath = resolveWorkflowsPath(buildRoot, args.flags['workflows']);
675
+ if (sub === 'compile') {
676
+ const { cmdStateCompile } = await import('./commands/state-compile.js');
677
+ const store = await openWorkflowStore(workflowsPath);
678
+ const result = await cmdStateCompile(store, {
679
+ buildRoot,
680
+ stateMdPath: args.flags['state-md'] ? String(args.flags['state-md']) : undefined,
681
+ dryRun: Boolean(args.flags['dry-run']),
682
+ });
683
+ if (result.output)
684
+ console.log(result.output);
685
+ process.exit(result.exitCode);
686
+ }
687
+ else if (sub === 'drift-check') {
688
+ const { cmdStateDriftCheck } = await import('./commands/state-compile.js');
689
+ const store = await openWorkflowStore(workflowsPath);
690
+ const result = await cmdStateDriftCheck(store, {
691
+ buildRoot,
692
+ stateMdPath: args.flags['state-md'] ? String(args.flags['state-md']) : undefined,
693
+ });
694
+ // Drift-check output: clean/skipped messages go to stderr (informational); drifted goes to stderr too.
695
+ if (result.output)
696
+ process.stderr.write(result.output + '\n');
697
+ process.exit(result.exitCode);
698
+ }
699
+ else {
700
+ console.error(`unknown state subcommand: ${sub ?? '(none)'}`);
701
+ console.error('available:');
702
+ console.error(' state compile [--dry-run] [--state-md=<path>] [--build-root=<dir>] [--workflows=<file>]');
703
+ console.error(' state drift-check [--state-md=<path>] [--build-root=<dir>] [--workflows=<file>]');
704
+ process.exit(1);
705
+ }
706
+ break;
707
+ }
660
708
  case 'start-of-session': {
661
709
  // Reserved handle — body in a follow-up WU.
662
710
  console.error('start-of-session: not yet implemented (WU 112 reserves the handle; body in a follow-up WU).');
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import { stat, readdir, readFile } from 'node:fs/promises';
24
24
  import path from 'node:path';
25
+ import { cmdStateCompile } from './state-compile.js';
25
26
  const BUILD_MAINTAINER = {
26
27
  kind: 'staff',
27
28
  id: 'build-maintainer',
@@ -46,7 +47,7 @@ export async function cmdEndOfSession(store, runtime, args) {
46
47
  }
47
48
  // Gather disk facts — this is the only place filesystem access happens
48
49
  // (the workflow itself is pure).
49
- const catalogueFacts = await gatherFacts(args.buildRoot, activeWuHandle, sessionStartIso, today);
50
+ const catalogueFacts = await gatherFacts(args.buildRoot, activeWuHandle, sessionStartIso, today, store);
50
51
  // Check for an existing (incomplete) session.end:<date> record.
51
52
  const existingHandle = `session.end:${today}`;
52
53
  const existingRecord = store.get(existingHandle);
@@ -71,6 +72,7 @@ export async function cmdEndOfSession(store, runtime, args) {
71
72
  'capture_open_questions',
72
73
  'capture_risks',
73
74
  'update_work_units_index',
75
+ 'recompile_state_md',
74
76
  'update_state_md',
75
77
  'write_session_log',
76
78
  'confirm_no_loss',
@@ -127,7 +129,7 @@ export async function cmdEndOfSession(store, runtime, args) {
127
129
  // ---------------------------------------------------------------------------
128
130
  // Disk fact gathering — the only place fs access happens
129
131
  // ---------------------------------------------------------------------------
130
- async function gatherFacts(buildRoot, activeWuHandle, sessionStartIso, sessionDate) {
132
+ async function gatherFacts(buildRoot, activeWuHandle, sessionStartIso, sessionDate, store) {
131
133
  const sessionStartMs = new Date(sessionStartIso).getTime();
132
134
  // Step 1: WU notes
133
135
  const { wuNotesTouched, wuNotesHasTodayHeading } = await checkWuNotes(buildRoot, activeWuHandle, sessionStartMs, sessionDate);
@@ -137,6 +139,10 @@ async function gatherFacts(buildRoot, activeWuHandle, sessionStartIso, sessionDa
137
139
  const risksParity = await checkRisksParity(buildRoot);
138
140
  // Step 5: work-units index
139
141
  const doneMoveOk = await checkWorkUnitsIndex(buildRoot);
142
+ // Step 5.5 (D132): recompile the generated regions of STATE.md.
143
+ // This is the orchestrate-and-write step sanctioned by D132 for generated regions.
144
+ // It must not fail the session if STATE.md has no sentinel regions yet (pre-cutover).
145
+ const { stateMdRecompileResult, stateMdRecompileDetail } = await recompileStateMd(buildRoot, store);
140
146
  // Step 6: STATE.md
141
147
  const { stateMdTouched, stateMdLastUpdated, stateMdLastSessionResolves } = await checkStateMd(buildRoot, sessionStartMs, sessionDate);
142
148
  // Step 7: session log
@@ -148,6 +154,8 @@ async function gatherFacts(buildRoot, activeWuHandle, sessionStartIso, sessionDa
148
154
  questionsParity,
149
155
  risksParity,
150
156
  doneMoveOk,
157
+ stateMdRecompileResult,
158
+ stateMdRecompileDetail,
151
159
  stateMdTouched,
152
160
  stateMdLastUpdated,
153
161
  stateMdLastSessionResolves,
@@ -294,6 +302,44 @@ async function checkRisksParity(buildRoot) {
294
302
  // This check is present for forward-compat when risks get individual files.
295
303
  return { filesWithoutRow: [], rowsWithoutFile: [] };
296
304
  }
305
+ /**
306
+ * Recompile the generated regions of STATE.md (D132 / D130: orchestrate-and-write
307
+ * for the generated regions is sanctioned by D132; authored prose is never touched).
308
+ *
309
+ * Fail-open contract (same as `cmdStateDriftCheck`):
310
+ * - 'skipped' when STATE.md has no sentinel regions yet (pre-cutover) — ok
311
+ * - 'ok' when the recompile succeeded (or was already current)
312
+ * - 'error' when the compile command returned non-zero (adapter error, splice error)
313
+ *
314
+ * A 'skipped' result is treated as passing by the pack workflow so that
315
+ * end-of-session is not broken for catalogues that haven't completed Stage B cutover.
316
+ */
317
+ async function recompileStateMd(buildRoot, store) {
318
+ try {
319
+ const result = await cmdStateCompile(store, { buildRoot });
320
+ if (result.exitCode === 0) {
321
+ return { stateMdRecompileResult: 'ok', stateMdRecompileDetail: result.output?.trim() };
322
+ }
323
+ // Non-zero exit from cmdStateCompile — check if it's the missing-sentinel case (pre-cutover).
324
+ // The missing-sentinel output contains the specific wording from the command.
325
+ if (result.output?.includes('sentinel regions are absent')) {
326
+ return {
327
+ stateMdRecompileResult: 'skipped',
328
+ stateMdRecompileDetail: 'sentinel regions absent — pre-cutover',
329
+ };
330
+ }
331
+ return {
332
+ stateMdRecompileResult: 'error',
333
+ stateMdRecompileDetail: result.output?.trim(),
334
+ };
335
+ }
336
+ catch (err) {
337
+ return {
338
+ stateMdRecompileResult: 'error',
339
+ stateMdRecompileDetail: err instanceof Error ? err.message : String(err),
340
+ };
341
+ }
342
+ }
297
343
  async function checkWorkUnitsIndex(buildRoot) {
298
344
  const indexPath = path.join(buildRoot, 'work-units', '_index.md');
299
345
  const content = await fileContent(indexPath);
@@ -349,7 +395,9 @@ async function checkStateMd(buildRoot, sessionStartMs, sessionDate) {
349
395
  const stateMdTouched = mtime ? mtime.getTime() > sessionStartMs : false;
350
396
  const content = await fileContent(stateMdPath);
351
397
  let stateMdLastUpdated = '';
352
- let stateMdLastSessionResolves = false;
398
+ // Renamed from stateMdLastSessionResolves stateMdLastSessionPresent (WU 113b).
399
+ // The field checks presence of a non-empty "Last session" row, not link resolution.
400
+ let stateMdLastSessionPresent = false;
353
401
  if (content) {
354
402
  // Fix 1 (WU 112 fix-pass): accept all three "Last updated" shapes:
355
403
  // table-row: | Last updated | 2026-05-31 (**Session 115 — ...**) ... |
@@ -370,10 +418,14 @@ async function checkStateMd(buildRoot, sessionStartMs, sessionDate) {
370
418
  if (sessionLineMatch) {
371
419
  // The row is non-empty if it contains more than just the label itself.
372
420
  const rowText = sessionLineMatch[0].replace(/Last session/i, '').replace(/[|:\s]/g, '');
373
- stateMdLastSessionResolves = rowText.length > 0;
421
+ stateMdLastSessionPresent = rowText.length > 0;
374
422
  }
375
423
  }
376
- return { stateMdTouched, stateMdLastUpdated, stateMdLastSessionResolves };
424
+ // Return under the pack's EndOfSessionFacts field name (stateMdLastSessionResolves)
425
+ // — the internal variable was renamed to stateMdLastSessionPresent above to clarify
426
+ // the semantics (presence check, not link-resolution). The published interface is
427
+ // unchanged so the pack type is not broken.
428
+ return { stateMdTouched, stateMdLastUpdated, stateMdLastSessionResolves: stateMdLastSessionPresent };
377
429
  }
378
430
  async function checkSessionLog(buildRoot, sessionDate) {
379
431
  const sessionsDir = path.join(buildRoot, 'sessions');
@@ -406,15 +458,16 @@ function formatReport(payload, today, resumedFrom, dryRun) {
406
458
  lines.push('══════════════════════════════════════════════════════════════════════');
407
459
  lines.push('');
408
460
  const STEP_LABELS = {
409
- update_active_wu_notes: 'Step 1 — WU notes updated',
410
- capture_decisions: 'Step 2 — decisions captured',
411
- capture_open_questions: 'Step 3 — open questions captured',
412
- capture_risks: 'Step 4 — risks captured',
413
- update_work_units_index: 'Step 5 — work-units index updated',
414
- update_state_md: 'Step 6 — STATE.md updated',
415
- write_session_log: 'Step 7 session log written',
416
- confirm_no_loss: 'Step 8 confirm-no-loss gate',
417
- report: 'Step 9 report',
461
+ update_active_wu_notes: 'Step 1 — WU notes updated',
462
+ capture_decisions: 'Step 2 — decisions captured',
463
+ capture_open_questions: 'Step 3 — open questions captured',
464
+ capture_risks: 'Step 4 — risks captured',
465
+ update_work_units_index: 'Step 5 — work-units index updated',
466
+ recompile_state_md: 'Step 5b — STATE.md generated regions recompiled (D132)',
467
+ update_state_md: 'Step 6 STATE.md updated',
468
+ write_session_log: 'Step 7 session log written',
469
+ confirm_no_loss: 'Step 8 confirm-no-loss gate',
470
+ report: 'Step 9 — report',
418
471
  };
419
472
  for (const [stepId, state] of Object.entries(payload.steps)) {
420
473
  const label = STEP_LABELS[stepId] ?? stepId;
@@ -4,8 +4,9 @@
4
4
  *
5
5
  * Cross-agent memory: every agent in a swarm can write findings here and
6
6
  * any future agent (in this run or a later one) can retrieve them by
7
- * semantic query. Uses the same NuVector store as the catalogue index,
8
- * distinguished by kind: 'agent_memory'.
7
+ * semantic query. Uses its own NuVector store file (`memory.nv`), separate
8
+ * from the doc-search index (`index.nv`), so that the ~40s background
9
+ * reindex never locks out memory writes. See D131.
9
10
  *
10
11
  * CLI:
11
12
  * memory store --value="..." [--wu=wu-007] [--agent=architect] [--key="label"]
@@ -18,6 +19,9 @@ export interface MemoryStoreOptions {
18
19
  key?: string;
19
20
  cwd?: string;
20
21
  buildRoot?: string | boolean;
22
+ /** Override for the memory store path (defaults to `<index-dir>/memory.nv`). */
23
+ memory?: string | boolean;
24
+ /** @deprecated Kept for callers that pass `index` — resolved as `memory` for memory commands. */
21
25
  index?: string | boolean;
22
26
  }
23
27
  export interface MemorySearchOptions {
@@ -27,6 +31,9 @@ export interface MemorySearchOptions {
27
31
  agent?: string;
28
32
  cwd?: string;
29
33
  buildRoot?: string | boolean;
34
+ /** Override for the memory store path (defaults to `<index-dir>/memory.nv`). */
35
+ memory?: string | boolean;
36
+ /** @deprecated Kept for callers that pass `index` — resolved as `memory` for memory commands. */
30
37
  index?: string | boolean;
31
38
  }
32
39
  export interface MemoryHit {
@@ -4,21 +4,167 @@
4
4
  *
5
5
  * Cross-agent memory: every agent in a swarm can write findings here and
6
6
  * any future agent (in this run or a later one) can retrieve them by
7
- * semantic query. Uses the same NuVector store as the catalogue index,
8
- * distinguished by kind: 'agent_memory'.
7
+ * semantic query. Uses its own NuVector store file (`memory.nv`), separate
8
+ * from the doc-search index (`index.nv`), so that the ~40s background
9
+ * reindex never locks out memory writes. See D131.
9
10
  *
10
11
  * CLI:
11
12
  * memory store --value="..." [--wu=wu-007] [--agent=architect] [--key="label"]
12
13
  * memory search --query="..." [--limit=N] [--wu=wu-007] [--agent=architect]
13
14
  */
14
15
  import { randomUUID } from 'node:crypto';
15
- import { resolveBuildRoot, resolveIndexPath } from '../path-resolution.js';
16
+ import { existsSync, unlinkSync, writeFileSync } from 'node:fs';
17
+ import { resolveBuildRoot, resolveIndexPath, resolveMemoryPath } from '../path-resolution.js';
18
+ // resolveIndexPath is used only as the migration *source* (legacy index.nv),
19
+ // not as the live memory path (which is resolved via resolveMemoryPath).
16
20
  // NuVector's MemoryRecordKind union doesn't include a swarm-specific kind yet.
17
21
  // 'workflow_provenance' is the closest semantic match — agent memories are
18
22
  // provenance of the swarm workflow. NuFlow isn't wired (harness.runtime.nuflow
19
23
  // is null) so there's no collision today; records are further distinguished by
20
24
  // the presence of an `agent_role` metadata field (absent on NuFlow provenance).
21
25
  const MEMORY_KIND = 'workflow_provenance';
26
+ /**
27
+ * One-time idempotent migration: copy existing agent-memory records
28
+ * (kind `workflow_provenance` with an `agent_role` metadata field) from
29
+ * the legacy `index.nv` into the new `memory.nv`. Triggered lazily the
30
+ * first time a memory command opens the store (i.e. when `memory.nv` does
31
+ * not yet exist). Once `memory.nv` exists this function is a no-op.
32
+ *
33
+ * Decision on delete-vs-leave: we leave migrated records in `index.nv`.
34
+ * They are dead weight there — `memory search` reads only `memory.nv`,
35
+ * and the doc reindex upserts only doc-kind records — so leaving them
36
+ * causes no observable problem. Deletion via the store's `DeletionQuery`
37
+ * API would need the id list; the extra complexity buys nothing for a
38
+ * handful of records.
39
+ *
40
+ * Embeddings are copied verbatim via `fetch(ids)` — no re-embedding.
41
+ * If `index.nv` does not exist yet (fresh project), migration is skipped.
42
+ *
43
+ * Atomicity: uses a sentinel file (`memory.nv.migrating`) written before the
44
+ * migration opens `memory.nv` and deleted after a successful close. If the
45
+ * process dies mid-migration, the next run sees both files and retries.
46
+ *
47
+ * INVARIANT — never `unlinkSync(memoryPath)` then `openStore(memoryPath)` in
48
+ * the same process. NuVector's NAPI in-process inode registry tracks handles
49
+ * by inode; a same-process unlink+reopen materialises the store in-memory only
50
+ * (the file never appears on disk), silently losing all data on process exit.
51
+ * The only permitted `unlinkSync(memoryPath)` is the corrupt-open-failure guard
52
+ * at the bottom, which always re-throws immediately — the store is never
53
+ * reopened in the same process after that unlink.
54
+ *
55
+ * In the interrupted-migration path (memory.nv + sentinel both present) we
56
+ * therefore open the existing partial `memory.nv` directly. `upsertBatch` is
57
+ * idempotent by id, so re-writing the same records into a partial store just
58
+ * completes it, with no phantom-materialisation risk.
59
+ */
60
+ async function migrateMemoryRecordsIfNeeded(indexPath, memoryPath, dimensions) {
61
+ const sentinelPath = `${memoryPath}.migrating`;
62
+ // Complete gate: memory.nv exists with no sentinel → done (either a clean
63
+ // migration or a store created by a normal memory write). Early return.
64
+ if (existsSync(memoryPath) && !existsSync(sentinelPath))
65
+ return;
66
+ // Fresh project: no legacy index to migrate from. Clear any stray sentinel
67
+ // (shouldn't exist, but be tidy) and return; the caller's openStore will
68
+ // create memory.nv fresh on its own write.
69
+ if (!existsSync(indexPath)) {
70
+ if (existsSync(sentinelPath)) {
71
+ try {
72
+ unlinkSync(sentinelPath);
73
+ }
74
+ catch { /* ignore — best-effort */ }
75
+ }
76
+ return;
77
+ }
78
+ const { openStore, TENANT } = await import('../store/open.js');
79
+ // Write the sentinel before opening memory.nv. If the process dies after
80
+ // this point, the next run sees both files (or just the sentinel) and
81
+ // falls through to the (re)migration path below.
82
+ try {
83
+ writeFileSync(sentinelPath, '');
84
+ }
85
+ catch { /* non-fatal; best-effort */ }
86
+ try {
87
+ // Read from index.nv. Hold the store open for both retrieveContext and
88
+ // fetch — a single open avoids a close→reopen timing window.
89
+ const srcStore = await openStore({ storagePath: indexPath, dimensions });
90
+ let fullRecords;
91
+ try {
92
+ const zeroEmbedding = new Float32Array(dimensions);
93
+ const result = await srcStore.retrieveContext({
94
+ embedding: zeroEmbedding,
95
+ tenant: TENANT,
96
+ topK: 10_000,
97
+ filters: { kind: MEMORY_KIND },
98
+ scoreThreshold: 0,
99
+ });
100
+ const items = (result?.items ?? []);
101
+ // Filter to agent-memory records (presence of `agent_role` metadata).
102
+ const agentMemoryRefs = items
103
+ .filter((item) => {
104
+ const meta = item.metadata;
105
+ return meta !== undefined && 'agent_role' in meta;
106
+ })
107
+ .map((item) => item.ref);
108
+ fullRecords = agentMemoryRefs.length > 0
109
+ ? await srcStore.fetch(agentMemoryRefs)
110
+ : [];
111
+ }
112
+ finally {
113
+ await srcStore.close();
114
+ }
115
+ // Open memory.nv — create fresh (first run) or open the existing partial
116
+ // file (interrupted run). Do NOT unlink first: same-process unlink+reopen
117
+ // triggers the NAPI phantom-materialisation bug (see invariant above).
118
+ // upsertBatch is idempotent by id, so replaying into a partial store is safe.
119
+ let dstStore;
120
+ try {
121
+ dstStore = await openStore({ storagePath: memoryPath, dimensions });
122
+ }
123
+ catch (openErr) {
124
+ // openStore itself threw — the partial file is genuinely corrupt.
125
+ // Unlink it so a future process gets a clean create, leave the sentinel
126
+ // so that future run still enters the (re)migration path, then rethrow.
127
+ // NEVER reopen memoryPath in this process after this unlink.
128
+ if (existsSync(memoryPath)) {
129
+ try {
130
+ unlinkSync(memoryPath);
131
+ }
132
+ catch { /* ignore */ }
133
+ }
134
+ throw openErr;
135
+ }
136
+ try {
137
+ if (fullRecords.length > 0) {
138
+ await dstStore.upsertBatch(fullRecords);
139
+ }
140
+ // If there are no agent-memory records, the store is opened-and-closed
141
+ // empty. That materialises memory.nv on disk so existsSync is true and
142
+ // the gate is stable — memory search never falls through to re-read
143
+ // index.nv on subsequent calls.
144
+ }
145
+ finally {
146
+ await dstStore.close();
147
+ }
148
+ // Migration complete. Remove sentinel so the gate sees memory.nv alone.
149
+ try {
150
+ unlinkSync(sentinelPath);
151
+ }
152
+ catch { /* ignore — best-effort */ }
153
+ }
154
+ catch (err) {
155
+ // Any failure other than the corrupt-open case above (e.g. F2 lock on
156
+ // index.nv): clean up the sentinel so the next call retries from scratch.
157
+ // Do NOT unlink memoryPath here — if it was opened successfully before the
158
+ // failure, it's a valid partial store that the next run can complete via
159
+ // upsertBatch. Unlinking it would trigger the phantom-materialisation bug
160
+ // on re-entry in the same process.
161
+ try {
162
+ unlinkSync(sentinelPath);
163
+ }
164
+ catch { /* ignore */ }
165
+ throw err;
166
+ }
167
+ }
22
168
  export async function cmdMemoryStore(opts) {
23
169
  const { value, wu, agent, key } = opts;
24
170
  if (!value || value.trim().length === 0) {
@@ -28,9 +174,16 @@ export async function cmdMemoryStore(opts) {
28
174
  const { selectEmbedderFromEnv } = await import('../embedder/select.js');
29
175
  const { openStore, TENANT } = await import('../store/open.js');
30
176
  const buildRoot = resolveBuildRoot(opts.buildRoot, { cwd: opts.cwd ?? process.cwd() });
31
- const indexPath = resolveIndexPath(buildRoot, opts.index);
177
+ // Resolve the memory-specific path (memory.nv), falling back to the
178
+ // legacy `index` flag for callers that pass it, then the default.
179
+ const memoryFlag = opts.memory ?? opts.index;
180
+ const memoryPath = resolveMemoryPath(buildRoot, memoryFlag);
181
+ const indexPath = resolveIndexPath(buildRoot, undefined);
32
182
  const embedder = await selectEmbedderFromEnv();
33
- const store = await openStore({ storagePath: indexPath, dimensions: embedder.dimensions });
183
+ // Lazy one-time migration: move existing agent-memory records from
184
+ // index.nv into memory.nv on the first memory command run.
185
+ await migrateMemoryRecordsIfNeeded(indexPath, memoryPath, embedder.dimensions);
186
+ const store = await openStore({ storagePath: memoryPath, dimensions: embedder.dimensions });
34
187
  const [embedding] = await embedder.embed([value]);
35
188
  await store.upsert({
36
189
  id: randomUUID(),
@@ -59,9 +212,16 @@ export async function cmdMemorySearch(opts) {
59
212
  const { selectEmbedderFromEnv } = await import('../embedder/select.js');
60
213
  const { openStore, TENANT } = await import('../store/open.js');
61
214
  const buildRoot = resolveBuildRoot(opts.buildRoot, { cwd: opts.cwd ?? process.cwd() });
62
- const indexPath = resolveIndexPath(buildRoot, opts.index);
215
+ // Resolve the memory-specific path (memory.nv), falling back to the
216
+ // legacy `index` flag for callers that pass it, then the default.
217
+ const memoryFlag = opts.memory ?? opts.index;
218
+ const memoryPath = resolveMemoryPath(buildRoot, memoryFlag);
219
+ const indexPath = resolveIndexPath(buildRoot, undefined);
63
220
  const embedder = await selectEmbedderFromEnv();
64
- const store = await openStore({ storagePath: indexPath, dimensions: embedder.dimensions });
221
+ // Lazy one-time migration: move existing agent-memory records from
222
+ // index.nv into memory.nv on the first memory command run.
223
+ await migrateMemoryRecordsIfNeeded(indexPath, memoryPath, embedder.dimensions);
224
+ const store = await openStore({ storagePath: memoryPath, dimensions: embedder.dimensions });
65
225
  const [queryEmbedding] = await embedder.embed([query]);
66
226
  const result = await store.retrieveContext({
67
227
  embedding: queryEmbedding,
@@ -0,0 +1,108 @@
1
+ /**
2
+ * `nuos-catalogue state compile` — STATE.md hybrid-document recompile (WU 113b / D132).
3
+ *
4
+ * Reads canonical state from the **live markdown registers** (not the workflow
5
+ * store, which is stale under Mode 1) and splices the generated sections into
6
+ * the sentinel-delimited regions of STATE.md, leaving all authored prose
7
+ * byte-for-byte identical.
8
+ *
9
+ * **Source-of-truth for each generated region (D129 / Mode 1):**
10
+ * - Active WU: `.nuos-catalogue/active-wu` marker file (WU 136 pointer)
11
+ * + title/status resolved from `work-units/_index.md`
12
+ * - WUs in progress: 🟡 row count in `work-units/_index.md`
13
+ * - WUs completed: file count in `work-units/done/`
14
+ * - Blocked WUs: 🔴 rows in `work-units/_index.md`
15
+ * - Decisions: `decisions/_index.md` active section
16
+ * - Open questions: `open-questions/_index.md` active section
17
+ * - Risks: `risks/_index.md` active section
18
+ *
19
+ * The workflow store (`workflows.json`) is accepted as a parameter for API
20
+ * compatibility (the CLI always opens it), but is NOT consulted for any of
21
+ * the above — it is frozen at migration time and would produce stale counts.
22
+ *
23
+ * **No LLM in this path.** The adapter builds an `LLMCompilationOutput`
24
+ * directly from disk state. `renderArticleMarkdown` is called per section,
25
+ * then `spliceGeneratedRegions` writes only inside the sentinel pairs.
26
+ *
27
+ * **First-cutover boundary.** If a sentinel region is absent from the target
28
+ * STATE.md, this command reports the missing regions clearly and exits
29
+ * non-zero without guessing where to insert them. The one-time insertion of
30
+ * sentinels into the live file is a manual operator step (Stage B walkthrough).
31
+ *
32
+ * D132 / D129 boundary:
33
+ * - Generated regions: live markdown registers are source of truth; disk is
34
+ * rendered projection for these regions only.
35
+ * - Authored regions: disk remains the edit base (untouched by this command).
36
+ */
37
+ import type { LLMCompilationOutput, SentinelConfig } from '@nusoft/nuwiki';
38
+ import { checkArticleDrift } from '@nusoft/nuwiki';
39
+ import type { WorkflowStore } from '../migrate/store.js';
40
+ export declare const STATE_SENTINEL_CONFIG: SentinelConfig;
41
+ export declare const STATE_REGION_KEYS: {
42
+ readonly METADATA: "metadata";
43
+ readonly WHAT_IS_NEXT: "what_is_next";
44
+ readonly OPEN_QUESTIONS: "open_questions";
45
+ readonly RECENT_DECISIONS: "recent_decisions";
46
+ readonly RISKS: "risks";
47
+ readonly HEALTH_CHECK: "health_check";
48
+ };
49
+ export type StateRegionKey = (typeof STATE_REGION_KEYS)[keyof typeof STATE_REGION_KEYS];
50
+ export interface StateSourceAdapterInput {
51
+ store: WorkflowStore;
52
+ buildRoot: string;
53
+ now?: string;
54
+ }
55
+ export interface StateCompiledOutput {
56
+ /** The structured body — one section per generated region. */
57
+ compilationOutput: LLMCompilationOutput;
58
+ /** The generated region contents keyed by region key (ready for splice). */
59
+ regions: Record<StateRegionKey, string>;
60
+ }
61
+ /**
62
+ * Reads canonical state from the live markdown registers and the active-WU
63
+ * marker file, and produces the generated content for each STATE.md region.
64
+ *
65
+ * No LLM call is made. The adapter derives all content deterministically.
66
+ * The workflow store parameter is accepted for API compatibility but is not
67
+ * consulted — see module-level comment for the source-of-truth map.
68
+ */
69
+ export declare function buildStateCompilationOutput(input: StateSourceAdapterInput): Promise<StateCompiledOutput>;
70
+ export interface StateCompileResult {
71
+ output: string;
72
+ exitCode: number;
73
+ updatedRegions?: string[];
74
+ unchangedRegions?: string[];
75
+ }
76
+ export declare function cmdStateCompile(store: WorkflowStore, args: {
77
+ buildRoot: string;
78
+ stateMdPath?: string;
79
+ dryRun?: boolean;
80
+ now?: string;
81
+ }): Promise<StateCompileResult>;
82
+ /**
83
+ * Expose `checkArticleDrift` with STATE.md's sentinel config pre-applied.
84
+ * Used by the pre-commit hook (Stage B) and tests.
85
+ */
86
+ export declare function checkStateMdDrift(fileContent: string, expectedRegions: Record<string, string>): ReturnType<typeof checkArticleDrift>;
87
+ export interface StateDriftCheckResult {
88
+ output: string;
89
+ exitCode: number;
90
+ /** 'clean' | 'drifted' | 'skipped' — used by tests */
91
+ verdict: 'clean' | 'drifted' | 'skipped';
92
+ driftedRegions?: string[];
93
+ }
94
+ /**
95
+ * Check whether the generated regions of STATE.md match what the canonical
96
+ * state currently produces. Designed to be called by the pre-commit hook.
97
+ *
98
+ * Exit-code contract (fail-open):
99
+ * - exit 0 when generated regions are clean
100
+ * - exit 0 when STATE.md has no sentinel regions yet (pre-cutover)
101
+ * - exit 0 when the check cannot run (STATE.md unreadable, store missing)
102
+ * - exit 1 ONLY on confirmed generated-region drift
103
+ */
104
+ export declare function cmdStateDriftCheck(store: WorkflowStore, args: {
105
+ buildRoot: string;
106
+ stateMdPath?: string;
107
+ now?: string;
108
+ }): Promise<StateDriftCheckResult>;