@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.
@@ -32,6 +32,13 @@
32
32
  * idle-timeout (the keep_alive: "1m" we sent) cleans up within a
33
33
  * minute.
34
34
  *
35
+ * **Bounded footprint while loaded.** Beyond unloading promptly, each call
36
+ * also pins `options.num_ctx` (see EMBED_NUM_CTX) so the model loads with an
37
+ * embedding-sized context window instead of inheriting the daemon's
38
+ * chat-sized OLLAMA_CONTEXT_LENGTH. Without this the 639MB model loads at
39
+ * ~5.7GB resident; with it, ~1.1GB. This is what keeps a reindex from pushing
40
+ * a developer's machine into swap.
41
+ *
35
42
  * Sizing note — the new 0.6b default is ~600MB on disk and runs
36
43
  * comfortably on any modern laptop, including CPU-only. The 4b variant
37
44
  * (~2.5GB) and 8b variant (~4.7GB, benefits from ~16GB RAM + Metal)
@@ -32,6 +32,13 @@
32
32
  * idle-timeout (the keep_alive: "1m" we sent) cleans up within a
33
33
  * minute.
34
34
  *
35
+ * **Bounded footprint while loaded.** Beyond unloading promptly, each call
36
+ * also pins `options.num_ctx` (see EMBED_NUM_CTX) so the model loads with an
37
+ * embedding-sized context window instead of inheriting the daemon's
38
+ * chat-sized OLLAMA_CONTEXT_LENGTH. Without this the 639MB model loads at
39
+ * ~5.7GB resident; with it, ~1.1GB. This is what keeps a reindex from pushing
40
+ * a developer's machine into swap.
41
+ *
35
42
  * Sizing note — the new 0.6b default is ~600MB on disk and runs
36
43
  * comfortably on any modern laptop, including CPU-only. The 4b variant
37
44
  * (~2.5GB) and 8b variant (~4.7GB, benefits from ~16GB RAM + Metal)
@@ -47,6 +54,16 @@ const KNOWN_DIMENSIONS = {
47
54
  'qwen3-embedding:4b': 2560,
48
55
  'qwen3-embedding:0.6b': 1024,
49
56
  };
57
+ // Context window for embedding loads. The Ollama daemon's global
58
+ // OLLAMA_CONTEXT_LENGTH — set high for chat models (commonly 32K–64K) — is
59
+ // inherited by every model that doesn't override it. Inherited unchanged, it
60
+ // inflates the 639MB qwen3-embedding:0.6b model to ~5.7GB resident, which is
61
+ // enough to push a 16–18GB developer machine into swap during a reindex.
62
+ // Embedding inputs are capped at ~600 tokens (MAX_CHUNK_CHARS in
63
+ // indexer/chunk.ts), so a 2048-token window leaves ~3x headroom and never
64
+ // truncates a chunk. Measured 2026-06-01 (qwen3-embedding:0.6b, Apple Silicon):
65
+ // inherited 32K ctx → 5.7GB resident; num_ctx 2048 → 1.1GB resident.
66
+ const EMBED_NUM_CTX = 2048;
50
67
  export class OllamaEmbedder {
51
68
  dimensions;
52
69
  modelId;
@@ -68,7 +85,13 @@ export class OllamaEmbedder {
68
85
  const probe = await fetch(`${host}/api/embed`, {
69
86
  method: 'POST',
70
87
  headers: { 'content-type': 'application/json' },
71
- body: JSON.stringify({ model: modelId, input: 'probe' }),
88
+ body: JSON.stringify({
89
+ model: modelId,
90
+ input: 'probe',
91
+ // Pin the context window here too — the probe is what first loads the
92
+ // model, so without it the probe alone would pull in the full ~5.7GB.
93
+ options: { num_ctx: EMBED_NUM_CTX },
94
+ }),
72
95
  });
73
96
  if (!probe.ok) {
74
97
  const body = await probe.text().catch(() => '<unreadable>');
@@ -121,6 +144,9 @@ export class OllamaEmbedder {
121
144
  // Keep the model warm only for the duration of one operation.
122
145
  // dispose() at the end of the run sends keep_alive: 0 to unload.
123
146
  keep_alive: '1m',
147
+ // Cap the context window so the model loads at ~1.1GB rather than
148
+ // inheriting the daemon's chat-sized window and ballooning to ~5.7GB.
149
+ options: { num_ctx: EMBED_NUM_CTX },
124
150
  }),
125
151
  });
126
152
  if (!res.ok) {
@@ -52,6 +52,15 @@ export declare function resolveCatalogueRoot(flag: string | boolean | undefined,
52
52
  export declare function resolveIndexDir(buildRoot: string, ctx?: ResolutionContext): string;
53
53
  export declare function resolveWorkflowsPath(buildRoot: string, flag: string | boolean | undefined, ctx?: ResolutionContext): string;
54
54
  export declare function resolveIndexPath(buildRoot: string, flag: string | boolean | undefined, ctx?: ResolutionContext): string;
55
+ /**
56
+ * Resolve the cross-agent memory store path. Always co-located with the
57
+ * doc-index in the same `.nuos-catalogue/` directory, but in a separate
58
+ * file (`memory.nv`) so that the doc-index reindex (which holds an
59
+ * exclusive lock on `index.nv`) never contends with memory writes.
60
+ * Resolves `NUOS_CATALOGUE_MEMORY_PATH` env var when set; otherwise
61
+ * derives from `resolveIndexDir`. See D131.
62
+ */
63
+ export declare function resolveMemoryPath(buildRoot: string, flag: string | boolean | undefined, ctx?: ResolutionContext): string;
55
64
  export declare function resolveHashPath(buildRoot: string, flag: string | boolean | undefined, ctx?: ResolutionContext): string;
56
65
  /**
57
66
  * Soft warning surfaced after a `migrate` or `regenerate` run: if the
@@ -108,6 +108,22 @@ export function resolveIndexPath(buildRoot, flag, ctx) {
108
108
  return path.resolve(flag);
109
109
  return path.join(resolveIndexDir(buildRoot, ctx), 'index.nv');
110
110
  }
111
+ /**
112
+ * Resolve the cross-agent memory store path. Always co-located with the
113
+ * doc-index in the same `.nuos-catalogue/` directory, but in a separate
114
+ * file (`memory.nv`) so that the doc-index reindex (which holds an
115
+ * exclusive lock on `index.nv`) never contends with memory writes.
116
+ * Resolves `NUOS_CATALOGUE_MEMORY_PATH` env var when set; otherwise
117
+ * derives from `resolveIndexDir`. See D131.
118
+ */
119
+ export function resolveMemoryPath(buildRoot, flag, ctx) {
120
+ if (typeof flag === 'string' && flag.length > 0)
121
+ return path.resolve(flag);
122
+ const env = ctxEnv(ctx);
123
+ if (env.NUOS_CATALOGUE_MEMORY_PATH)
124
+ return path.resolve(env.NUOS_CATALOGUE_MEMORY_PATH);
125
+ return path.join(resolveIndexDir(buildRoot, ctx), 'memory.nv');
126
+ }
111
127
  export function resolveHashPath(buildRoot, flag, ctx) {
112
128
  if (typeof flag === 'string' && flag.length > 0)
113
129
  return path.resolve(flag);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nusoft/nuos-build-catalogue",
3
- "version": "0.33.1",
3
+ "version": "0.35.1",
4
4
  "description": "NuOS build-catalogue tooling: semantic search (WU 110) + migration runner that lifts markdown artefacts into JSON-backed workflow records (WU 111, Phase G).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,15 +19,16 @@
19
19
  "build": "rm -rf dist && tsc && chmod +x dist/cli.js",
20
20
  "prepublishOnly": "npm run build",
21
21
  "verify-storage": "tsx scripts/verify-persistence.ts",
22
- "test": "tsx --test tests/chunk.test.ts tests/metadata.test.ts tests/crawl.test.ts tests/migrate.test.ts tests/commands-read.test.ts tests/regenerate.test.ts tests/commands-write.test.ts tests/ac-parse.test.ts tests/create.test.ts tests/init.test.ts tests/wu-111-soak-findings.test.ts tests/plan.test.ts tests/mode.test.ts tests/render.test.ts tests/swarm.test.ts tests/setup-progress-bar.test.ts tests/setup-ollama-pull.test.ts tests/setup-run-llm-setup.test.ts tests/wu-active.test.ts tests/install-claude-hooks.test.ts tests/protocols-in-sync.test.ts tests/end-of-session.test.ts",
22
+ "test": "tsx --test tests/chunk.test.ts tests/metadata.test.ts tests/crawl.test.ts tests/migrate.test.ts tests/commands-read.test.ts tests/regenerate.test.ts tests/commands-write.test.ts tests/ac-parse.test.ts tests/create.test.ts tests/init.test.ts tests/wu-111-soak-findings.test.ts tests/plan.test.ts tests/mode.test.ts tests/render.test.ts tests/swarm.test.ts tests/setup-progress-bar.test.ts tests/setup-ollama-pull.test.ts tests/setup-run-llm-setup.test.ts tests/wu-active.test.ts tests/install-claude-hooks.test.ts tests/protocols-in-sync.test.ts tests/end-of-session.test.ts tests/hooks-in-sync.test.ts tests/memory-store-separation.test.ts tests/state-compile.test.ts tests/state-drift-check.test.ts tests/hook-isolation.test.ts",
23
23
  "typecheck": "tsc --noEmit",
24
24
  "index": "tsx src/cli.ts index",
25
25
  "search": "tsx src/cli.ts search"
26
26
  },
27
27
  "dependencies": {
28
- "@nusoft/nuvector": "^0.1.5",
29
28
  "@nusoft/nuflow": "^0.4.1",
30
- "@nusoft/nuflow-pack-nuos-build-catalogue": "^0.1.0"
29
+ "@nusoft/nuflow-pack-nuos-build-catalogue": "^0.3.0",
30
+ "@nusoft/nuvector": "^0.1.5",
31
+ "@nusoft/nuwiki": "^0.3.0"
31
32
  },
32
33
  "devDependencies": {
33
34
  "@nusoft/nuflow": "file:../nuflow",
@@ -134,14 +134,38 @@ fi
134
134
  # ---------- Rule 2: active-decision modification block (WU 111 ship) ---
135
135
 
136
136
  dim "[nuos:pre-commit] active-decision modification check"
137
- modified_decisions=$(git diff --cached --name-only --diff-filter=M \
137
+ #
138
+ # "Immutable once accepted" — this blocks edits only to a decision whose
139
+ # status *in HEAD* is already a locked state (`accepted` or `active`).
140
+ # Editing a still-`proposed` decision is allowed: promoting it to
141
+ # accepted/active is the sanctioned lifecycle step, not a violation, and
142
+ # proposed decisions are in-flight by design. New decision files are
143
+ # additions (excluded by --diff-filter=M), so a decision born `accepted`
144
+ # is never blocked on creation. The locked-status check uses the HEAD
145
+ # pre-image, so flipping an accepted decision back to `proposed` to sneak
146
+ # a substantive edit is still caught.
147
+ candidate_decisions=$(git diff --cached --name-only --diff-filter=M \
138
148
  | grep -E '^docs/build/decisions/D[0-9]+.*\.md$' \
139
149
  | grep -v '/superseded/' \
140
150
  || true)
141
151
 
142
- if [[ -n "$modified_decisions" ]]; then
152
+ locked_decisions=""
153
+ if [[ -n "$candidate_decisions" ]]; then
154
+ while IFS= read -r f; do
155
+ [[ -z "$f" ]] && continue
156
+ head_status=$(git show "HEAD:$f" 2>/dev/null \
157
+ | grep -m1 -E '^\*\*Status:\*\*' \
158
+ | sed -E 's/^\*\*Status:\*\*[[:space:]]*//' \
159
+ | awk '{print tolower($1)}')
160
+ case "$head_status" in
161
+ accepted|active) locked_decisions+="${f}"$'\n' ;;
162
+ esac
163
+ done <<< "$candidate_decisions"
164
+ fi
165
+
166
+ if [[ -n "$locked_decisions" ]]; then
143
167
  red "✖ active-decision modification — BLOCKED (WU 111 enforcement):"
144
- while IFS= read -r f; do echo " — $f"; done <<< "$modified_decisions"
168
+ while IFS= read -r f; do [[ -n "$f" ]] && echo " — $f"; done <<< "$locked_decisions"
145
169
  red " Decisions are immutable once accepted. The discipline is to write a"
146
170
  red " superseding D-NNN+1 and link forward. Use:"
147
171
  red " nuos-catalogue decision supersede <target> --by=<new-D> --reason=\"...\""
@@ -149,10 +173,53 @@ if [[ -n "$modified_decisions" ]]; then
149
173
  red " If this edit is a non-substantive typo fix or link cleanup that does"
150
174
  red " not change the decision's meaning, you may bypass this block with"
151
175
  red " --no-verify. CLAUDE.md prohibits --no-verify for substantive changes."
152
- log_event "active-decision-block" "$(echo "$modified_decisions" | tr '\n' ',')"
176
+ log_event "active-decision-block" "$(echo "$locked_decisions" | tr '\n' ',')"
153
177
  EXIT_CODE=1
154
178
  fi
155
179
 
180
+ # ---------- Rule 3: STATE.md generated-region drift block (WU 113b Stage B) ---
181
+
182
+ # Only run when docs/build/STATE.md is in the staged changes.
183
+ # Guard on nuos-catalogue being present and supporting `state drift-check`.
184
+ # Fail-open: if the binary is absent, old (doesn't know drift-check), or
185
+ # errors for any infra reason, skip this check silently — a missing binary
186
+ # must never block all commits.
187
+ #
188
+ # Old-binary detection: an old binary (< 0.35.0) exits non-zero with
189
+ # "unknown state subcommand: drift-check" on stderr. We distinguish this
190
+ # from a genuine drift finding by checking whether the output contains the
191
+ # drift-specific marker phrase. If the output does NOT contain "generated regions"
192
+ # (the phrase only the new drift-check command emits), we skip.
193
+ staged_state_md=$(git diff --cached --name-only | grep -F 'docs/build/STATE.md' || true)
194
+
195
+ if [[ -n "$staged_state_md" ]]; then
196
+ dim "[nuos:pre-commit] STATE.md generated-region drift check (WU 113b)"
197
+
198
+ if ! command -v nuos-catalogue > /dev/null 2>&1; then
199
+ dim "[nuos:pre-commit] nuos-catalogue not found — skipping STATE.md drift check"
200
+ else
201
+ # Run drift-check; capture output + exit code.
202
+ drift_output=$(nuos-catalogue state drift-check 2>&1) || drift_exit=$?
203
+ drift_exit=${drift_exit:-0}
204
+
205
+ if [[ $drift_exit -ne 0 ]]; then
206
+ # Non-zero exit — check whether this is a genuine drift finding or an
207
+ # infra/version problem (old binary, missing store, etc.).
208
+ if echo "$drift_output" | grep -qF 'generated regions'; then
209
+ # Confirmed generated-region drift — block the commit.
210
+ red "✖ STATE.md generated-region drift — BLOCKED (WU 113b enforcement):"
211
+ echo "$drift_output" | while IFS= read -r line; do echo " $line"; done
212
+ log_event "state-drift-block" "generated-region drift detected"
213
+ EXIT_CODE=1
214
+ else
215
+ # Not a drift finding (unknown subcommand from old binary, infra error, etc.)
216
+ # — skip silently (fail open).
217
+ dim "[nuos:pre-commit] STATE.md drift check returned non-zero (not a drift finding) — skipping"
218
+ fi
219
+ fi
220
+ fi
221
+ fi
222
+
156
223
  # ---------- Result ------------------------------------------------------
157
224
 
158
225
  if [[ $EXIT_CODE -eq 0 ]]; then
@@ -134,14 +134,38 @@ fi
134
134
  # ---------- Rule 2: active-decision modification block (WU 111 ship) ---
135
135
 
136
136
  dim "[nuos:pre-commit] active-decision modification check"
137
- modified_decisions=$(git diff --cached --name-only --diff-filter=M \
137
+ #
138
+ # "Immutable once accepted" — this blocks edits only to a decision whose
139
+ # status *in HEAD* is already a locked state (`accepted` or `active`).
140
+ # Editing a still-`proposed` decision is allowed: promoting it to
141
+ # accepted/active is the sanctioned lifecycle step, not a violation, and
142
+ # proposed decisions are in-flight by design. New decision files are
143
+ # additions (excluded by --diff-filter=M), so a decision born `accepted`
144
+ # is never blocked on creation. The locked-status check uses the HEAD
145
+ # pre-image, so flipping an accepted decision back to `proposed` to sneak
146
+ # a substantive edit is still caught.
147
+ candidate_decisions=$(git diff --cached --name-only --diff-filter=M \
138
148
  | grep -E '^docs/build/decisions/D[0-9]+.*\.md$' \
139
149
  | grep -v '/superseded/' \
140
150
  || true)
141
151
 
142
- if [[ -n "$modified_decisions" ]]; then
152
+ locked_decisions=""
153
+ if [[ -n "$candidate_decisions" ]]; then
154
+ while IFS= read -r f; do
155
+ [[ -z "$f" ]] && continue
156
+ head_status=$(git show "HEAD:$f" 2>/dev/null \
157
+ | grep -m1 -E '^\*\*Status:\*\*' \
158
+ | sed -E 's/^\*\*Status:\*\*[[:space:]]*//' \
159
+ | awk '{print tolower($1)}')
160
+ case "$head_status" in
161
+ accepted|active) locked_decisions+="${f}"$'\n' ;;
162
+ esac
163
+ done <<< "$candidate_decisions"
164
+ fi
165
+
166
+ if [[ -n "$locked_decisions" ]]; then
143
167
  red "✖ active-decision modification — BLOCKED (WU 111 enforcement):"
144
- while IFS= read -r f; do echo " — $f"; done <<< "$modified_decisions"
168
+ while IFS= read -r f; do [[ -n "$f" ]] && echo " — $f"; done <<< "$locked_decisions"
145
169
  red " Decisions are immutable once accepted. The discipline is to write a"
146
170
  red " superseding D-NNN+1 and link forward. Use:"
147
171
  red " nuos-catalogue decision supersede <target> --by=<new-D> --reason=\"...\""
@@ -149,10 +173,53 @@ if [[ -n "$modified_decisions" ]]; then
149
173
  red " If this edit is a non-substantive typo fix or link cleanup that does"
150
174
  red " not change the decision's meaning, you may bypass this block with"
151
175
  red " --no-verify. CLAUDE.md prohibits --no-verify for substantive changes."
152
- log_event "active-decision-block" "$(echo "$modified_decisions" | tr '\n' ',')"
176
+ log_event "active-decision-block" "$(echo "$locked_decisions" | tr '\n' ',')"
153
177
  EXIT_CODE=1
154
178
  fi
155
179
 
180
+ # ---------- Rule 3: STATE.md generated-region drift block (WU 113b Stage B) ---
181
+
182
+ # Only run when docs/build/STATE.md is in the staged changes.
183
+ # Guard on nuos-catalogue being present and supporting `state drift-check`.
184
+ # Fail-open: if the binary is absent, old (doesn't know drift-check), or
185
+ # errors for any infra reason, skip this check silently — a missing binary
186
+ # must never block all commits.
187
+ #
188
+ # Old-binary detection: an old binary (< 0.35.0) exits non-zero with
189
+ # "unknown state subcommand: drift-check" on stderr. We distinguish this
190
+ # from a genuine drift finding by checking whether the output contains the
191
+ # drift-specific marker phrase. If the output does NOT contain "generated regions"
192
+ # (the phrase only the new drift-check command emits), we skip.
193
+ staged_state_md=$(git diff --cached --name-only | grep -F 'docs/build/STATE.md' || true)
194
+
195
+ if [[ -n "$staged_state_md" ]]; then
196
+ dim "[nuos:pre-commit] STATE.md generated-region drift check (WU 113b)"
197
+
198
+ if ! command -v nuos-catalogue > /dev/null 2>&1; then
199
+ dim "[nuos:pre-commit] nuos-catalogue not found — skipping STATE.md drift check"
200
+ else
201
+ # Run drift-check; capture output + exit code.
202
+ drift_output=$(nuos-catalogue state drift-check 2>&1) || drift_exit=$?
203
+ drift_exit=${drift_exit:-0}
204
+
205
+ if [[ $drift_exit -ne 0 ]]; then
206
+ # Non-zero exit — check whether this is a genuine drift finding or an
207
+ # infra/version problem (old binary, missing store, etc.).
208
+ if echo "$drift_output" | grep -qF 'generated regions'; then
209
+ # Confirmed generated-region drift — block the commit.
210
+ red "✖ STATE.md generated-region drift — BLOCKED (WU 113b enforcement):"
211
+ echo "$drift_output" | while IFS= read -r line; do echo " $line"; done
212
+ log_event "state-drift-block" "generated-region drift detected"
213
+ EXIT_CODE=1
214
+ else
215
+ # Not a drift finding (unknown subcommand from old binary, infra error, etc.)
216
+ # — skip silently (fail open).
217
+ dim "[nuos:pre-commit] STATE.md drift check returned non-zero (not a drift finding) — skipping"
218
+ fi
219
+ fi
220
+ fi
221
+ fi
222
+
156
223
  # ---------- Result ------------------------------------------------------
157
224
 
158
225
  if [[ $EXIT_CODE -eq 0 ]]; then