@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 +3 -3
- package/dist/cli.js +48 -0
- package/dist/commands/end-of-session.js +67 -14
- package/dist/commands/memory.d.ts +9 -2
- package/dist/commands/memory.js +167 -7
- package/dist/commands/state-compile.d.ts +108 -0
- package/dist/commands/state-compile.js +793 -0
- package/dist/embedder/ollama.d.ts +7 -0
- package/dist/embedder/ollama.js +27 -1
- package/dist/path-resolution.d.ts +9 -0
- package/dist/path-resolution.js +16 -0
- package/package.json +5 -4
- package/scripts/hooks/pre-commit +71 -4
- package/templates/hooks/pre-commit +71 -4
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:
|
|
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:
|
|
29
|
+
ollama pull qwen3-embedding:0.6b # ~600 MB download
|
|
30
30
|
|
|
31
|
-
# Index the catalogue (first time
|
|
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
|
-
|
|
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
|
-
|
|
421
|
+
stateMdLastSessionPresent = rowText.length > 0;
|
|
374
422
|
}
|
|
375
423
|
}
|
|
376
|
-
|
|
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
|
|
410
|
-
capture_decisions: 'Step 2
|
|
411
|
-
capture_open_questions: 'Step 3
|
|
412
|
-
capture_risks: 'Step 4
|
|
413
|
-
update_work_units_index: 'Step 5
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
|
8
|
-
*
|
|
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 {
|
package/dist/commands/memory.js
CHANGED
|
@@ -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
|
|
8
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>;
|