@nusoft/nuos-build-catalogue 0.20.0 → 0.21.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/dist/cli.js CHANGED
@@ -159,6 +159,27 @@ async function cmdRegisterDispatch(command, positional, flags) {
159
159
  process.exit(2);
160
160
  }
161
161
  const action = positional[0];
162
+ // WU 136 — `wu start` / `wu end` / `wu current` are file-only commands
163
+ // (manage the active-WU marker for the PreToolUse hook). They do NOT
164
+ // need the workflow store, so handle them BEFORE the store is opened —
165
+ // this also keeps them fast and avoids requiring a fully-migrated
166
+ // catalogue to declare an active WU.
167
+ if (command === 'wu' && (action === 'start' || action === 'end' || action === 'current')) {
168
+ const { cmdWuStart, cmdWuEnd, cmdWuCurrent } = await import('./commands/wu-active.js');
169
+ let result;
170
+ if (action === 'start') {
171
+ result = cmdWuStart(positional[1], { cwd: process.cwd() });
172
+ }
173
+ else if (action === 'end') {
174
+ result = cmdWuEnd({ cwd: process.cwd() });
175
+ }
176
+ else {
177
+ result = cmdWuCurrent({ cwd: process.cwd() });
178
+ }
179
+ console.log(result.output);
180
+ process.exit(result.exitCode);
181
+ return;
182
+ }
162
183
  const buildRoot = resolveBuildRoot(flags['build-root']);
163
184
  const workflowsPath = resolveWorkflowsPath(buildRoot, flags['workflows']);
164
185
  const store = await openWorkflowStore(workflowsPath);
@@ -368,6 +389,8 @@ Usage:
368
389
  (run the LLM-setup phase outside 'init': detect Ollama, offer to install where reliable, pull qwen3-embedding:0.6b with a progress bar. Idempotent — safe to re-run)
369
390
  nuos-catalogue install-protocols
370
391
  (refresh .claude/commands/<protocols> from this CLI's bundled canonical bodies)
392
+ nuos-catalogue install-hooks
393
+ (WU 136 — install the Claude PreToolUse hook that gates sibling-repo writes on a declared active WU; idempotent)
371
394
 
372
395
  nuos-catalogue index [--force] [--dry-run] [--catalogue=<dir>]
373
396
  nuos-catalogue search "<query>" [--kind=<file_kind>] [--status=<s>] [--limit=N] [--json]
@@ -381,6 +404,12 @@ Usage:
381
404
  nuos-catalogue wu advance <handle> --to=<status> [--reason="..."]
382
405
  nuos-catalogue wu tick <handle> --index=N --evidence="..."
383
406
  (--index is 1-based: --index=1 ticks the first AC)
407
+ nuos-catalogue wu start <handle>
408
+ (WU 136 — declare this WU as the active one for sibling-repo writes; required by the install-hooks gate)
409
+ nuos-catalogue wu end
410
+ (clear the active-WU marker)
411
+ nuos-catalogue wu current
412
+ (print the active WU handle, or "(none)")
384
413
  nuos-catalogue decision list [--status=<s>] [--limit=N] [--json]
385
414
  nuos-catalogue decision show <handle> [--json]
386
415
  nuos-catalogue decision create (interactive)
@@ -490,6 +519,16 @@ async function main() {
490
519
  }
491
520
  break;
492
521
  }
522
+ case 'install-hooks': {
523
+ // WU 136 — install the Claude PreToolUse hook that gates
524
+ // sibling-repo writes on a declared active WU.
525
+ const { cmdInstallClaudeHooks } = await import('./commands/install-claude-hooks.js');
526
+ const result = cmdInstallClaudeHooks({ cwd: process.cwd() });
527
+ if (result.output)
528
+ console.log(result.output);
529
+ process.exit(result.exitCode);
530
+ break;
531
+ }
493
532
  case 'migrate':
494
533
  await cmdMigrate(args.flags);
495
534
  break;
@@ -296,16 +296,13 @@ export async function cmdInstallProtocols(prompt, options = {}) {
296
296
  prompt.print('');
297
297
  prompt.print('Checking local semantic search (Ollama + qwen3-embedding:0.6b):');
298
298
  await reportLlmStatus((msg) => prompt.print(` ${msg}`));
299
- // Auto-build the search index when the LLM is ready but the index
300
- // isn't present yet (typical upgrade-path state: pre-0.19 install +
301
- // someone just added docs/build/ content). When the index already
302
- // exists this is a no-op; when the LLM isn't ready the helper skips
303
- // with a hint that's already printed by reportLlmStatus.
299
+ // Auto-build/refresh the search index when the LLM is ready. The
300
+ // indexer is incremental via per-file SHA hashes: a no-change project
301
+ // takes ~1s, a project with N changed files takes O(N) embed calls.
302
+ // When the LLM stack isn't ready the helper skips silently — the
303
+ // status was already reported above by reportLlmStatus.
304
304
  const { ensureIndexBuilt } = await import('../setup/auto-index.js');
305
- const indexResult = await ensureIndexBuilt({ cwd });
306
- if (indexResult.kind === 'just_built') {
307
- prompt.print(` ✓ Built search index (${indexResult.indexed} files, ${indexResult.chunks} chunks).`);
308
- }
305
+ await ensureIndexBuilt({ cwd });
309
306
  return { output: '', exitCode: 0 };
310
307
  }
311
308
  /**
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `install-hooks` — copy the package's Claude Code PreToolUse hook
3
+ * into the consumer's `.claude/hooks/`, wire it into the consumer's
4
+ * `.claude/settings.json`, and add the active-WU marker file to
5
+ * `.gitignore` so the per-session marker doesn't pollute commits.
6
+ *
7
+ * Idempotent. Safe to re-run after package upgrades — the hook script
8
+ * is overwritten, the settings entry is added only if missing, and the
9
+ * gitignore line is appended only if not present.
10
+ *
11
+ * @module commands/install-claude-hooks
12
+ */
13
+ export interface CommandResult {
14
+ output: string;
15
+ exitCode: number;
16
+ }
17
+ export interface InstallClaudeHooksOptions {
18
+ cwd?: string;
19
+ /** Override the path the templates are resolved from (testing only). */
20
+ templatesDir?: string;
21
+ }
22
+ /**
23
+ * Idempotently install the Claude PreToolUse hook into the project at
24
+ * `cwd`. Returns a CommandResult describing what was done.
25
+ */
26
+ export declare function cmdInstallClaudeHooks(opts?: InstallClaudeHooksOptions): CommandResult;
27
+ export declare function addPreToolUseHook(settings: Record<string, unknown>, matcher: string, command: string): {
28
+ value: Record<string, unknown>;
29
+ changed: boolean;
30
+ };
31
+ export declare function ensureGitignoreEntry(path: string, line: string): boolean;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * `install-hooks` — copy the package's Claude Code PreToolUse hook
3
+ * into the consumer's `.claude/hooks/`, wire it into the consumer's
4
+ * `.claude/settings.json`, and add the active-WU marker file to
5
+ * `.gitignore` so the per-session marker doesn't pollute commits.
6
+ *
7
+ * Idempotent. Safe to re-run after package upgrades — the hook script
8
+ * is overwritten, the settings entry is added only if missing, and the
9
+ * gitignore line is appended only if not present.
10
+ *
11
+ * @module commands/install-claude-hooks
12
+ */
13
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
14
+ import { dirname, join, resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ const HOOK_FILENAME = "check-implementation-write.sh";
17
+ const SETTINGS_MATCHER = "Write|Edit|MultiEdit|NotebookEdit";
18
+ const SETTINGS_COMMAND = `bash .claude/hooks/${HOOK_FILENAME}`;
19
+ /**
20
+ * Resolve the path to the bundled `templates/claude-hooks/` directory.
21
+ * When running from `dist/` (the published package) the templates dir
22
+ * sits at `../templates/claude-hooks/` relative to the compiled JS.
23
+ * When running from source (tsx during development) it's at
24
+ * `../../templates/claude-hooks/` relative to this file.
25
+ */
26
+ function resolveTemplatesDir() {
27
+ const here = dirname(fileURLToPath(import.meta.url));
28
+ // Look at sibling-of-parent first (compiled dist/commands/ → dist/../templates/),
29
+ // then grandparent-of-parent (src/commands/ → repo-root/templates/).
30
+ const candidates = [
31
+ resolve(here, "..", "..", "templates", "claude-hooks"),
32
+ resolve(here, "..", "templates", "claude-hooks"),
33
+ ];
34
+ for (const c of candidates) {
35
+ if (existsSync(c))
36
+ return c;
37
+ }
38
+ // Last resort: return the first candidate so the error message names
39
+ // a real-ish path.
40
+ return candidates[0];
41
+ }
42
+ /**
43
+ * Idempotently install the Claude PreToolUse hook into the project at
44
+ * `cwd`. Returns a CommandResult describing what was done.
45
+ */
46
+ export function cmdInstallClaudeHooks(opts = {}) {
47
+ const cwd = opts.cwd ?? process.cwd();
48
+ const templatesDir = opts.templatesDir ?? resolveTemplatesDir();
49
+ const srcHook = join(templatesDir, HOOK_FILENAME);
50
+ if (!existsSync(srcHook)) {
51
+ return {
52
+ output: `✖ nuos: hook template not found at ${srcHook}\n The package may be installed incorrectly. Try reinstalling.`,
53
+ exitCode: 1,
54
+ };
55
+ }
56
+ const lines = [];
57
+ // 1. Copy the hook script into .claude/hooks/.
58
+ const hooksDir = join(cwd, ".claude", "hooks");
59
+ if (!existsSync(hooksDir))
60
+ mkdirSync(hooksDir, { recursive: true });
61
+ const destHook = join(hooksDir, HOOK_FILENAME);
62
+ copyFileSync(srcHook, destHook);
63
+ // Mark executable. bash is invoked explicitly via the matcher's
64
+ // `command`, but the exec bit helps when the hook is invoked
65
+ // directly during debugging.
66
+ try {
67
+ chmodSync(destHook, 0o755);
68
+ }
69
+ catch {
70
+ // chmod is best-effort on filesystems that don't support it.
71
+ }
72
+ lines.push(` ✓ installed hook → .claude/hooks/${HOOK_FILENAME}`);
73
+ // 2. Merge into .claude/settings.json. We add a PreToolUse matcher
74
+ // entry only if no entry with the same command already exists.
75
+ const settingsPath = join(cwd, ".claude", "settings.json");
76
+ const settings = readJsonOrEmpty(settingsPath);
77
+ const updated = addPreToolUseHook(settings, SETTINGS_MATCHER, SETTINGS_COMMAND);
78
+ if (updated.changed) {
79
+ writeFileSync(settingsPath, JSON.stringify(updated.value, null, 2) + "\n", "utf8");
80
+ lines.push(" ✓ updated .claude/settings.json (PreToolUse entry added)");
81
+ }
82
+ else {
83
+ lines.push(" · .claude/settings.json already has the PreToolUse entry");
84
+ }
85
+ // 3. Add the active-WU marker to .gitignore.
86
+ const gitignorePath = join(cwd, ".gitignore");
87
+ const addedGitignore = ensureGitignoreEntry(gitignorePath, ".nuos-catalogue/active-wu");
88
+ if (addedGitignore) {
89
+ lines.push(" ✓ added .nuos-catalogue/active-wu to .gitignore");
90
+ }
91
+ else {
92
+ lines.push(" · .nuos-catalogue/active-wu already in .gitignore");
93
+ }
94
+ return {
95
+ output: [
96
+ "nuos: Claude Code hooks installed.",
97
+ ...lines,
98
+ "",
99
+ "Next: declare an active work unit before substantive sibling-repo work:",
100
+ " nuos-catalogue wu start <handle>",
101
+ ].join("\n"),
102
+ exitCode: 0,
103
+ };
104
+ }
105
+ // ── helpers ─────────────────────────────────────────────────────────────
106
+ function readJsonOrEmpty(path) {
107
+ if (!existsSync(path))
108
+ return {};
109
+ try {
110
+ return JSON.parse(readFileSync(path, "utf8"));
111
+ }
112
+ catch {
113
+ return {};
114
+ }
115
+ }
116
+ export function addPreToolUseHook(settings, matcher, command) {
117
+ // Defensive copy so callers can't accidentally see in-place mutation.
118
+ const next = { ...settings };
119
+ const hooksField = next.hooks ?? {};
120
+ const preToolUse = hooksField.PreToolUse ?? [];
121
+ // Already present? Check by command string (any matcher).
122
+ for (const entry of preToolUse) {
123
+ for (const h of entry.hooks ?? []) {
124
+ if (h.command === command) {
125
+ return { value: next, changed: false };
126
+ }
127
+ }
128
+ }
129
+ const newEntry = {
130
+ matcher,
131
+ hooks: [{ type: "command", command }],
132
+ };
133
+ const newPreToolUse = [...preToolUse, newEntry];
134
+ const newHooks = { ...hooksField, PreToolUse: newPreToolUse };
135
+ next.hooks = newHooks;
136
+ return { value: next, changed: true };
137
+ }
138
+ export function ensureGitignoreEntry(path, line) {
139
+ let body = "";
140
+ if (existsSync(path)) {
141
+ body = readFileSync(path, "utf8");
142
+ }
143
+ const lines = body.split("\n").map((l) => l.trim());
144
+ if (lines.includes(line))
145
+ return false;
146
+ const newBody = body.endsWith("\n") || body.length === 0 ? body + line + "\n" : body + "\n" + line + "\n";
147
+ writeFileSync(path, newBody, "utf8");
148
+ return true;
149
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `wu start` / `wu end` / `wu current` — manage the active-WU marker
3
+ * consumed by the Claude PreToolUse hook (WU 136).
4
+ *
5
+ * The marker is a single-line file at `.nuos-catalogue/active-wu`
6
+ * containing the handle of the WU currently being implemented. The hook
7
+ * reads it to decide whether a sibling-repo write is allowed.
8
+ *
9
+ * These commands are intentionally minimal — file read / write / unlink
10
+ * with friendly stdout. No workflow-store interaction, no validation of
11
+ * whether the handle resolves to a real WU. Validation could be added
12
+ * later if the unverified-handle pattern becomes a problem in practice;
13
+ * today's risk is low because the operator types the handle themselves.
14
+ *
15
+ * @module commands/wu-active
16
+ */
17
+ export interface CommandResult {
18
+ output: string;
19
+ exitCode: number;
20
+ }
21
+ export interface WuActiveOptions {
22
+ /** Project root. Defaults to process.cwd(). */
23
+ cwd?: string;
24
+ }
25
+ /** Compute the path to the active-WU marker for a given project root. */
26
+ export declare function activeWuMarkerPath(cwd: string): string;
27
+ /**
28
+ * `wu start <handle>` — write the handle into the marker. Creates the
29
+ * `.nuos-catalogue/` directory if it doesn't yet exist (idempotent).
30
+ *
31
+ * Overwrites any existing marker without ceremony — the operator's
32
+ * `start` is authoritative. If they want to know the current value
33
+ * before overwriting, they use `wu current`.
34
+ */
35
+ export declare function cmdWuStart(handle: string | undefined, opts?: WuActiveOptions): CommandResult;
36
+ /**
37
+ * `wu end` — remove the marker. Succeeds silently if no marker is
38
+ * present (idempotent: stopping a stopped state is fine).
39
+ */
40
+ export declare function cmdWuEnd(opts?: WuActiveOptions): CommandResult;
41
+ /**
42
+ * `wu current` — print the current active WU handle or `(none)`.
43
+ * Always exits 0; absence is not an error.
44
+ */
45
+ export declare function cmdWuCurrent(opts?: WuActiveOptions): CommandResult;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `wu start` / `wu end` / `wu current` — manage the active-WU marker
3
+ * consumed by the Claude PreToolUse hook (WU 136).
4
+ *
5
+ * The marker is a single-line file at `.nuos-catalogue/active-wu`
6
+ * containing the handle of the WU currently being implemented. The hook
7
+ * reads it to decide whether a sibling-repo write is allowed.
8
+ *
9
+ * These commands are intentionally minimal — file read / write / unlink
10
+ * with friendly stdout. No workflow-store interaction, no validation of
11
+ * whether the handle resolves to a real WU. Validation could be added
12
+ * later if the unverified-handle pattern becomes a problem in practice;
13
+ * today's risk is low because the operator types the handle themselves.
14
+ *
15
+ * @module commands/wu-active
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ /** Compute the path to the active-WU marker for a given project root. */
20
+ export function activeWuMarkerPath(cwd) {
21
+ return join(cwd, ".nuos-catalogue", "active-wu");
22
+ }
23
+ /**
24
+ * `wu start <handle>` — write the handle into the marker. Creates the
25
+ * `.nuos-catalogue/` directory if it doesn't yet exist (idempotent).
26
+ *
27
+ * Overwrites any existing marker without ceremony — the operator's
28
+ * `start` is authoritative. If they want to know the current value
29
+ * before overwriting, they use `wu current`.
30
+ */
31
+ export function cmdWuStart(handle, opts = {}) {
32
+ if (!handle || handle.trim().length === 0) {
33
+ return {
34
+ output: "Usage: nuos-catalogue wu start <handle>",
35
+ exitCode: 2,
36
+ };
37
+ }
38
+ const cwd = opts.cwd ?? process.cwd();
39
+ const marker = activeWuMarkerPath(cwd);
40
+ const dir = dirname(marker);
41
+ if (!existsSync(dir))
42
+ mkdirSync(dir, { recursive: true });
43
+ writeFileSync(marker, handle.trim() + "\n", "utf8");
44
+ return {
45
+ output: `nuos: active work unit set to "${handle.trim()}"`,
46
+ exitCode: 0,
47
+ };
48
+ }
49
+ /**
50
+ * `wu end` — remove the marker. Succeeds silently if no marker is
51
+ * present (idempotent: stopping a stopped state is fine).
52
+ */
53
+ export function cmdWuEnd(opts = {}) {
54
+ const cwd = opts.cwd ?? process.cwd();
55
+ const marker = activeWuMarkerPath(cwd);
56
+ if (existsSync(marker)) {
57
+ const prev = readFileSync(marker, "utf8").trim();
58
+ unlinkSync(marker);
59
+ return {
60
+ output: `nuos: cleared active work unit (was "${prev}")`,
61
+ exitCode: 0,
62
+ };
63
+ }
64
+ return {
65
+ output: "nuos: no active work unit to clear",
66
+ exitCode: 0,
67
+ };
68
+ }
69
+ /**
70
+ * `wu current` — print the current active WU handle or `(none)`.
71
+ * Always exits 0; absence is not an error.
72
+ */
73
+ export function cmdWuCurrent(opts = {}) {
74
+ const cwd = opts.cwd ?? process.cwd();
75
+ const marker = activeWuMarkerPath(cwd);
76
+ if (existsSync(marker)) {
77
+ const handle = readFileSync(marker, "utf8").trim();
78
+ if (handle.length > 0) {
79
+ return { output: handle, exitCode: 0 };
80
+ }
81
+ }
82
+ return { output: "(none)", exitCode: 0 };
83
+ }
@@ -15,13 +15,17 @@
15
15
  * @module setup/auto-index
16
16
  */
17
17
  /** Outcome of an auto-index attempt. */
18
- export type AutoIndexResult = {
19
- kind: 'already_built';
20
- indexPath: string;
21
- } | {
22
- kind: 'just_built';
18
+ export type AutoIndexResult =
19
+ /**
20
+ * The indexer ran. `indexed` includes both freshly-embedded files and
21
+ * re-embedded changed ones. `unchanged` is non-zero on subsequent
22
+ * runs — those files were SHA-matched and skipped without embedding.
23
+ */
24
+ {
25
+ kind: 'ran';
23
26
  indexPath: string;
24
27
  indexed: number;
28
+ unchanged: number;
25
29
  chunks: number;
26
30
  durationMs: number;
27
31
  } | {
@@ -43,10 +47,11 @@ export interface AutoIndexOptions {
43
47
  force?: boolean;
44
48
  }
45
49
  /**
46
- * Build the first search index when conditions allow. Idempotent: returns
47
- * `already_built` and prints nothing when the index file exists (unless
48
- * `force` is set). Returns `skipped_llm_not_ready` with a hint when the
49
- * Ollama probe fails the caller prints the hint and the user runs
50
+ * Run the indexer when conditions allow. Always runs (the indexer is
51
+ * incremental unchanged files are SHA-skipped without embedding work),
52
+ * so this both *creates* the index on first call and *refreshes* it on
53
+ * subsequent calls. Returns `skipped_llm_not_ready` with a hint when
54
+ * the Ollama probe fails — the caller prints the hint and the user runs
50
55
  * `setup-llm` to fix things.
51
56
  *
52
57
  * Never throws on user-facing failures.
@@ -19,10 +19,11 @@ import { resolveBuildRoot, resolveCatalogueRoot, resolveHashPath, resolveIndexPa
19
19
  import { DEFAULT_OLLAMA_HOST, detectModelPresent, detectOllamaApi } from './ollama-detect.js';
20
20
  import { DEFAULT_EMBEDDING_MODEL } from './run-llm-setup.js';
21
21
  /**
22
- * Build the first search index when conditions allow. Idempotent: returns
23
- * `already_built` and prints nothing when the index file exists (unless
24
- * `force` is set). Returns `skipped_llm_not_ready` with a hint when the
25
- * Ollama probe fails the caller prints the hint and the user runs
22
+ * Run the indexer when conditions allow. Always runs (the indexer is
23
+ * incremental unchanged files are SHA-skipped without embedding work),
24
+ * so this both *creates* the index on first call and *refreshes* it on
25
+ * subsequent calls. Returns `skipped_llm_not_ready` with a hint when
26
+ * the Ollama probe fails — the caller prints the hint and the user runs
26
27
  * `setup-llm` to fix things.
27
28
  *
28
29
  * Never throws on user-facing failures.
@@ -49,10 +50,13 @@ export async function ensureIndexBuilt(opts = {}) {
49
50
  catch {
50
51
  return { kind: 'skipped_no_catalogue' };
51
52
  }
52
- // Fast path: index file already exists and we're not forcing a rebuild.
53
- if (existsSync(indexPath) && !opts.force) {
54
- return { kind: 'already_built', indexPath };
55
- }
53
+ // We do not short-circuit on `existsSync(indexPath)` the indexer is
54
+ // already incremental via the per-file SHA hash store, so running it
55
+ // when the index is up-to-date is cheap (~1s on a 270-file catalogue
56
+ // with no changes). Short-circuiting here would leave newer files
57
+ // un-embedded until the user ran `nuos-catalogue index` manually,
58
+ // which is exactly the discoverability gap the auto-index is meant to
59
+ // close.
56
60
  // Probe the LLM stack — index requires Ollama + the model. If either
57
61
  // is missing, skip with a hint pointing at setup-llm.
58
62
  const apiHost = process.env.NUOS_CATALOGUE_OLLAMA_HOST ?? DEFAULT_OLLAMA_HOST;
@@ -73,10 +77,17 @@ export async function ensureIndexBuilt(opts = {}) {
73
77
  hint: 'Run `nuos-catalogue setup-llm` to pull the embedding model (~600 MB), then re-run `nuos-catalogue index`.',
74
78
  };
75
79
  }
76
- // LLM is ready. Build the index. We import lazily so the cold-start
77
- // path of `install-protocols` (where the index usually already exists)
78
- // doesn't pay the embedder-loading cost.
79
- out('Building search index for docs/build/ … (first run may take ~30 seconds)\n');
80
+ // LLM is ready. Run the indexer. The first run on a fresh project is
81
+ // ~30s of starter-kit content; subsequent runs are fast — the
82
+ // per-file SHA hashes mean unchanged files are skipped without
83
+ // embedding.
84
+ const isFirstRun = !existsSync(indexPath);
85
+ if (isFirstRun) {
86
+ out('Building search index for docs/build/ … (first run may take ~30 seconds)\n');
87
+ }
88
+ else {
89
+ out('Refreshing search index (incremental — only changed files are re-embedded)…\n');
90
+ }
80
91
  try {
81
92
  const { selectEmbedderFromEnv } = await import('../embedder/select.js');
82
93
  const { openStore } = await import('../store/open.js');
@@ -92,12 +103,23 @@ export async function ensureIndexBuilt(opts = {}) {
92
103
  force: Boolean(opts.force),
93
104
  dryRun: false,
94
105
  });
95
- out(`✓ Indexed ${report.indexed} file(s), ${report.chunks} chunks embedded in ` +
96
- `${(report.durationMs / 1000).toFixed(1)}s\n`);
106
+ const changed = report.indexed + report.updated;
107
+ const secs = (report.durationMs / 1000).toFixed(1);
108
+ if (isFirstRun) {
109
+ out(`✓ Indexed ${report.indexed} file(s), ${report.chunks} chunks embedded in ${secs}s\n`);
110
+ }
111
+ else if (changed === 0) {
112
+ out(`✓ Index up-to-date (${report.unchanged} files checked, none changed) in ${secs}s\n`);
113
+ }
114
+ else {
115
+ out(`✓ Re-indexed ${changed} changed file(s) (${report.unchanged} unchanged), ` +
116
+ `${report.chunks} chunks embedded in ${secs}s\n`);
117
+ }
97
118
  return {
98
- kind: 'just_built',
119
+ kind: 'ran',
99
120
  indexPath,
100
- indexed: report.indexed,
121
+ indexed: changed,
122
+ unchanged: report.unchanged,
101
123
  chunks: report.chunks,
102
124
  durationMs: report.durationMs,
103
125
  };
@@ -109,7 +131,7 @@ export async function ensureIndexBuilt(opts = {}) {
109
131
  }
110
132
  catch (err) {
111
133
  const message = err instanceof Error ? err.message : String(err);
112
- out(`\n✗ Index build failed: ${message}\n`);
134
+ out(`\n✗ Index refresh failed: ${message}\n`);
113
135
  out('Re-run `nuos-catalogue index` manually to retry.\n');
114
136
  return { kind: 'failed', error: message };
115
137
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nusoft/nuos-build-catalogue",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
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,7 +19,7 @@
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/swarm.test.ts tests/setup-progress-bar.test.ts tests/setup-ollama-pull.test.ts tests/setup-run-llm-setup.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/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",
23
23
  "typecheck": "tsc --noEmit",
24
24
  "index": "tsx src/cli.ts index",
25
25
  "search": "tsx src/cli.ts search"
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # NuOS Build Method — Claude Code PreToolUse hook (WU 136).
4
+ #
5
+ # Blocks Edit / Write / MultiEdit / NotebookEdit tool calls when:
6
+ # (a) the target file is OUTSIDE the catalogue project root, AND
7
+ # (b) no active work unit has been declared via `nuos-catalogue wu start <handle>`.
8
+ #
9
+ # Rationale
10
+ # ─────────
11
+ # The existing git pre-commit hook (templates/hooks/pre-commit) catches
12
+ # catalogue-side drift on commit. It does not see writes to sibling
13
+ # implementation repos (sensight/, nuvector/, …) — those are different
14
+ # git roots. So an agent can ship hours of substantive implementation
15
+ # work across many sibling-repo files before any catalogue trace gets
16
+ # recorded. This hook closes that gap at the earliest possible moment:
17
+ # the file-write itself.
18
+ #
19
+ # This is a soft-gate. The block is honest about what it is and how to
20
+ # release it (declare the active WU). The catalogue stays in charge of
21
+ # the project's discipline; the operator can override locally if a
22
+ # one-off write is genuinely needed (see "Manual override" below).
23
+ #
24
+ # Behaviour
25
+ # ─────────
26
+ # 1. Read the tool-call JSON on stdin (Claude Code PreToolUse contract).
27
+ # 2. Extract `tool_input.file_path` or `tool_input.notebook_path`.
28
+ # If neither is present (parse failure), exit 0 — never block on
29
+ # ambiguous input.
30
+ # 3. Determine the catalogue project root via $CLAUDE_PROJECT_DIR, falling
31
+ # back to `git rev-parse --show-toplevel` from cwd. If neither works,
32
+ # exit 0 (degrade-safe).
33
+ # 4. Classify the target path:
34
+ # — Inside the project root: ALLOW. Editing the catalogue itself
35
+ # (work units, decisions, indexes, hooks, scripts) is the
36
+ # catalogue trace — no WU declaration required.
37
+ # — Outside the project root: this is sibling-repo implementation
38
+ # work and requires a declared active WU.
39
+ # 5. For sibling-repo paths, check for an active-WU marker at
40
+ # `$PROJECT_ROOT/.nuos-catalogue/active-wu`.
41
+ # — Marker present (non-empty file): ALLOW. Log the touch with the
42
+ # declared WU handle so the audit trail names the work.
43
+ # — Marker absent or empty: BLOCK with exit 2 and a stderr message
44
+ # telling the operator what was blocked, what's missing, and the
45
+ # two commands to recover.
46
+ #
47
+ # Manual override
48
+ # ───────────────
49
+ # If a write to a sibling repo is genuinely catalogue-orthogonal (e.g.
50
+ # adjusting a personal dotfile, applying a hotfix unrelated to project
51
+ # work), the operator can either:
52
+ # (a) declare a temporary "ad-hoc" WU: `nuos-catalogue wu start adhoc`
53
+ # then `nuos-catalogue wu end` when done. The audit log records
54
+ # the touches under "adhoc" — visible at end-of-session.
55
+ # (b) set NUOS_SKIP_IMPLEMENTATION_GATE=1 in the environment for that
56
+ # single tool call. The block is bypassed and a STRONG warning is
57
+ # emitted to stderr. The bypass is logged.
58
+ #
59
+ # Bypass log lives at $PROJECT_ROOT/.nuos-enforcement.log alongside the
60
+ # catalogue-write hook's audit trail.
61
+ #
62
+ # Exit codes
63
+ # ──────────
64
+ # 0 — allow (or degrade-safe)
65
+ # 2 — block (Claude Code surfaces stderr to the model)
66
+
67
+ set -uo pipefail
68
+
69
+ # ── Inputs ──────────────────────────────────────────────────────────────
70
+
71
+ INPUT="$(cat 2>/dev/null || true)"
72
+
73
+ # Project root: prefer the Claude-provided env var, fall back to git.
74
+ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-}"
75
+ if [[ -z "$PROJECT_ROOT" ]]; then
76
+ PROJECT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
77
+ fi
78
+ if [[ -z "$PROJECT_ROOT" ]]; then exit 0; fi
79
+
80
+ LOG="$PROJECT_ROOT/.nuos-enforcement.log"
81
+
82
+ # Extract the target file path. Edit/Write use `file_path`; NotebookEdit
83
+ # uses `notebook_path`. MultiEdit also uses `file_path` (single root
84
+ # file for the batch).
85
+ FILE=$(printf '%s' "$INPUT" \
86
+ | grep -oE '"(file_path|notebook_path)"[[:space:]]*:[[:space:]]*"[^"]+"' \
87
+ | head -1 \
88
+ | sed -E 's/.*"([^"]+)"$/\1/')
89
+
90
+ # Parse failure: degrade safe (never block on ambiguous input).
91
+ if [[ -z "${FILE:-}" ]]; then exit 0; fi
92
+
93
+ # ── Classify the target path ────────────────────────────────────────────
94
+
95
+ # Normalise: if the path is relative, treat it as relative to cwd. The
96
+ # tool always passes absolute paths in practice, but we guard regardless.
97
+ case "$FILE" in
98
+ /*) ABSOLUTE_FILE="$FILE" ;;
99
+ *) ABSOLUTE_FILE="$(pwd)/$FILE" ;;
100
+ esac
101
+
102
+ # Trailing-slash-tolerant prefix match.
103
+ case "$ABSOLUTE_FILE/" in
104
+ "$PROJECT_ROOT"/*) IS_INTERNAL=1 ;;
105
+ *) IS_INTERNAL=0 ;;
106
+ esac
107
+
108
+ log_event() {
109
+ printf '%s | %s | %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$1" "$2" >> "$LOG" 2>/dev/null || true
110
+ }
111
+
112
+ # ── Internal write: always allowed ──────────────────────────────────────
113
+
114
+ if [[ "$IS_INTERNAL" == "1" ]]; then
115
+ exit 0
116
+ fi
117
+
118
+ # ── Sibling-repo write: requires an active WU declaration ──────────────
119
+
120
+ # Manual override (escape hatch). Logged loudly.
121
+ if [[ "${NUOS_SKIP_IMPLEMENTATION_GATE:-}" == "1" ]]; then
122
+ log_event "implementation-gate-bypassed" "$ABSOLUTE_FILE"
123
+ printf '⚠ nuos: NUOS_SKIP_IMPLEMENTATION_GATE=1 — sibling-repo write allowed without a WU declaration.\n' >&2
124
+ exit 0
125
+ fi
126
+
127
+ MARKER="$PROJECT_ROOT/.nuos-catalogue/active-wu"
128
+ ACTIVE_WU=""
129
+ if [[ -f "$MARKER" ]]; then
130
+ ACTIVE_WU="$(head -n 1 "$MARKER" 2>/dev/null | tr -d '[:space:]')"
131
+ fi
132
+
133
+ if [[ -n "$ACTIVE_WU" ]]; then
134
+ log_event "implementation-write-allowed[$ACTIVE_WU]" "$ABSOLUTE_FILE"
135
+ exit 0
136
+ fi
137
+
138
+ # Block. Stderr is surfaced to the model.
139
+ log_event "implementation-write-blocked" "$ABSOLUTE_FILE"
140
+ cat >&2 <<EOF
141
+ ✖ nuos: implementation write blocked (WU 136 gate).
142
+
143
+ Target file: $ABSOLUTE_FILE
144
+ Reason: This path is OUTSIDE the catalogue project root
145
+ ($PROJECT_ROOT)
146
+ and no active work unit has been declared.
147
+
148
+ Substantive implementation work in a sibling repo must trace to a
149
+ catalogued work unit. Choose one of:
150
+
151
+ 1. Declare an existing WU as active for this session:
152
+ nuos-catalogue wu start <handle> e.g. wu start 136
153
+
154
+ 2. File a new WU first, then declare it active:
155
+ nuos-catalogue wu create
156
+ nuos-catalogue wu start <new-handle>
157
+
158
+ 3. When done, clear the marker:
159
+ nuos-catalogue wu end
160
+
161
+ Genuinely catalogue-orthogonal write? Set
162
+ NUOS_SKIP_IMPLEMENTATION_GATE=1 to bypass for one call (logged to
163
+ .nuos-enforcement.log for the audit trail).
164
+
165
+ EOF
166
+
167
+ exit 2