@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 +39 -0
- package/dist/commands/init.js +6 -9
- package/dist/commands/install-claude-hooks.d.ts +31 -0
- package/dist/commands/install-claude-hooks.js +149 -0
- package/dist/commands/wu-active.d.ts +45 -0
- package/dist/commands/wu-active.js +83 -0
- package/dist/setup/auto-index.d.ts +14 -9
- package/dist/setup/auto-index.js +39 -17
- package/package.json +2 -2
- package/templates/claude-hooks/check-implementation-write.sh +167 -0
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;
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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.
|
package/dist/setup/auto-index.js
CHANGED
|
@@ -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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
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.
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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: '
|
|
119
|
+
kind: 'ran',
|
|
99
120
|
indexPath,
|
|
100
|
-
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
|
|
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.
|
|
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
|