@nusoft/nuos-build-catalogue 0.33.3 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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;
@@ -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>;