@nusoft/nuos-build-catalogue 0.19.0 → 0.20.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 +10 -0
- package/dist/commands/init.js +66 -0
- package/dist/setup/auto-index.d.ts +54 -0
- package/dist/setup/auto-index.js +116 -0
- package/package.json +1 -1
- package/scripts/hooks/post-commit +96 -0
- package/scripts/hooks/pre-commit +162 -0
- package/scripts/install-hooks.sh +44 -0
package/dist/cli.js
CHANGED
|
@@ -460,7 +460,17 @@ async function main() {
|
|
|
460
460
|
// when the user switched machines and needs to pull the model
|
|
461
461
|
// freshly. Same orchestrator that `init` calls internally.
|
|
462
462
|
const { runLlmSetup } = await import('./setup/run-llm-setup.js');
|
|
463
|
+
const { ensureIndexBuilt } = await import('./setup/auto-index.js');
|
|
463
464
|
const result = await runLlmSetup({ nonInteractive: false });
|
|
465
|
+
// After the LLM stack is ready, auto-build the search index when
|
|
466
|
+
// it isn't already present. Same helper init and install-protocols
|
|
467
|
+
// use — keeps the three commands aligned on "after this finishes
|
|
468
|
+
// the project is search-ready".
|
|
469
|
+
if (result.kind === 'already_ready' ||
|
|
470
|
+
result.kind === 'pulled_only' ||
|
|
471
|
+
result.kind === 'installed_and_pulled') {
|
|
472
|
+
await ensureIndexBuilt({});
|
|
473
|
+
}
|
|
464
474
|
// Most failure paths emit guidance in-band; we exit non-zero only
|
|
465
475
|
// when a pull actually failed (so CI scripting can branch on it).
|
|
466
476
|
const exitCode = result.kind === 'pull_failed' || result.kind === 'install_failed' ? 1 : 0;
|
package/dist/commands/init.js
CHANGED
|
@@ -211,6 +211,7 @@ export async function cmdInit(prompt, options = {}) {
|
|
|
211
211
|
// `nuos-catalogue setup-llm` later.
|
|
212
212
|
if (!options.noLlm) {
|
|
213
213
|
const { runLlmSetup } = await import('../setup/run-llm-setup.js');
|
|
214
|
+
const { ensureIndexBuilt } = await import('../setup/auto-index.js');
|
|
214
215
|
await runLlmSetup({
|
|
215
216
|
// The setup module writes its own progress directly to stderr; we
|
|
216
217
|
// don't route through `prompt.print` because the in-place progress
|
|
@@ -224,6 +225,17 @@ export async function cmdInit(prompt, options = {}) {
|
|
|
224
225
|
// so this is safe in unattended runs too.
|
|
225
226
|
nonInteractive: false,
|
|
226
227
|
});
|
|
228
|
+
// After LLM setup succeeds, auto-build the first search index. On a
|
|
229
|
+
// fresh project this is ~30s of starter-kit boilerplate; trivial,
|
|
230
|
+
// and finishing here means `search` works out of the box. When the
|
|
231
|
+
// LLM stack isn't ready, `ensureIndexBuilt` skips with a hint
|
|
232
|
+
// pointing back to setup-llm.
|
|
233
|
+
const indexResult = await ensureIndexBuilt({ cwd });
|
|
234
|
+
if (indexResult.kind === 'skipped_llm_not_ready') {
|
|
235
|
+
prompt.print('');
|
|
236
|
+
prompt.print(` · Skipping first-index build: ${indexResult.reason}.`);
|
|
237
|
+
prompt.print(` · ${indexResult.hint}`);
|
|
238
|
+
}
|
|
227
239
|
}
|
|
228
240
|
else {
|
|
229
241
|
prompt.print('');
|
|
@@ -274,8 +286,62 @@ export async function cmdInstallProtocols(prompt, options = {}) {
|
|
|
274
286
|
prompt.print('');
|
|
275
287
|
prompt.print(`Refreshing swarm agent definitions (.claude/agents/):`);
|
|
276
288
|
await installAgents(cwd, (msg) => prompt.print(msg));
|
|
289
|
+
// Quick non-interactive probe of the local-inference stack (WU 135).
|
|
290
|
+
// `install-protocols` is the natural upgrade path for existing
|
|
291
|
+
// projects, so we surface the LLM status here too — but as a status
|
|
292
|
+
// report rather than the full install/pull flow (which is what
|
|
293
|
+
// `setup-llm` is for). This keeps install-protocols fast and
|
|
294
|
+
// script-safe while making the LLM state visible without the user
|
|
295
|
+
// needing to know about a separate command.
|
|
296
|
+
prompt.print('');
|
|
297
|
+
prompt.print('Checking local semantic search (Ollama + qwen3-embedding:0.6b):');
|
|
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.
|
|
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
|
+
}
|
|
277
309
|
return { output: '', exitCode: 0 };
|
|
278
310
|
}
|
|
311
|
+
/**
|
|
312
|
+
* Quick probe + status print for the LLM stack. Non-interactive: never
|
|
313
|
+
* prompts, never installs, never pulls. The full install/pull flow
|
|
314
|
+
* lives in `setup-llm`; this is the "what's the current state?" report.
|
|
315
|
+
*
|
|
316
|
+
* Times out after ~1.5s when Ollama isn't running so the command stays
|
|
317
|
+
* snappy on machines that haven't set up local inference yet.
|
|
318
|
+
*/
|
|
319
|
+
async function reportLlmStatus(log) {
|
|
320
|
+
const { narrowPlatform } = await import('../setup/types.js');
|
|
321
|
+
const { detectOllamaApi, detectModelPresent } = await import('../setup/ollama-detect.js');
|
|
322
|
+
const { DEFAULT_EMBEDDING_MODEL } = await import('../setup/run-llm-setup.js');
|
|
323
|
+
const platform = narrowPlatform(process.platform);
|
|
324
|
+
const apiHost = process.env.NUOS_CATALOGUE_OLLAMA_HOST ?? 'http://localhost:11434';
|
|
325
|
+
const modelId = process.env.NUOS_CATALOGUE_OLLAMA_MODEL ?? DEFAULT_EMBEDDING_MODEL;
|
|
326
|
+
const api = await detectOllamaApi(apiHost);
|
|
327
|
+
if (!api.reachable) {
|
|
328
|
+
log(`✗ Ollama is not running at ${apiHost}`);
|
|
329
|
+
log(' Run `nuos-catalogue setup-llm` for guided install + pull.');
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
log(`✓ Ollama is running at ${apiHost}`);
|
|
333
|
+
const model = await detectModelPresent(apiHost, modelId);
|
|
334
|
+
if (!model.present) {
|
|
335
|
+
log(`✗ ${modelId} is not pulled`);
|
|
336
|
+
log(' Run `nuos-catalogue setup-llm` to download it (~600 MB).');
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
log(`✓ ${modelId} is pulled (~600 MB)`);
|
|
340
|
+
log(`Semantic search is ready. Try \`nuos-catalogue search "your query"\` after the first index.`);
|
|
341
|
+
// Suppress the unused-variable warning while keeping platform available
|
|
342
|
+
// for future per-OS hints (e.g. "Ollama runs in the menu bar on macOS").
|
|
343
|
+
void platform;
|
|
344
|
+
}
|
|
279
345
|
// ---------------------------------------------------------------------------
|
|
280
346
|
// installHooks — copy bundled hook sources into the consumer + activate them
|
|
281
347
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helper that runs the first search index build automatically
|
|
3
|
+
* from `init`, `install-protocols`, and `setup-llm`.
|
|
4
|
+
*
|
|
5
|
+
* Gated on the LLM stack being ready (Ollama + the configured embedding
|
|
6
|
+
* model). If the LLM isn't ready, this helper returns a `skipped_llm`
|
|
7
|
+
* result with a hint string the caller prints. The hint references
|
|
8
|
+
* `setup-llm` so the user has a clear path forward.
|
|
9
|
+
*
|
|
10
|
+
* Indexing on a fresh project takes ~30s — small enough that auto-
|
|
11
|
+
* running on first install is friendlier than asking. Subsequent calls
|
|
12
|
+
* are incremental via the per-file SHA hashes, so re-running on an
|
|
13
|
+
* existing index is cheap.
|
|
14
|
+
*
|
|
15
|
+
* @module setup/auto-index
|
|
16
|
+
*/
|
|
17
|
+
/** Outcome of an auto-index attempt. */
|
|
18
|
+
export type AutoIndexResult = {
|
|
19
|
+
kind: 'already_built';
|
|
20
|
+
indexPath: string;
|
|
21
|
+
} | {
|
|
22
|
+
kind: 'just_built';
|
|
23
|
+
indexPath: string;
|
|
24
|
+
indexed: number;
|
|
25
|
+
chunks: number;
|
|
26
|
+
durationMs: number;
|
|
27
|
+
} | {
|
|
28
|
+
kind: 'skipped_llm_not_ready';
|
|
29
|
+
reason: string;
|
|
30
|
+
hint: string;
|
|
31
|
+
} | {
|
|
32
|
+
kind: 'skipped_no_catalogue';
|
|
33
|
+
} | {
|
|
34
|
+
kind: 'failed';
|
|
35
|
+
error: string;
|
|
36
|
+
};
|
|
37
|
+
export interface AutoIndexOptions {
|
|
38
|
+
/** Project root for path resolution. Defaults to `process.cwd()`. */
|
|
39
|
+
cwd?: string;
|
|
40
|
+
/** Output sink — defaults to process.stderr. */
|
|
41
|
+
out?: (text: string) => void;
|
|
42
|
+
/** Force a full reindex even if the index file already exists. */
|
|
43
|
+
force?: boolean;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
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
|
+
* `setup-llm` to fix things.
|
|
51
|
+
*
|
|
52
|
+
* Never throws on user-facing failures.
|
|
53
|
+
*/
|
|
54
|
+
export declare function ensureIndexBuilt(opts?: AutoIndexOptions): Promise<AutoIndexResult>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helper that runs the first search index build automatically
|
|
3
|
+
* from `init`, `install-protocols`, and `setup-llm`.
|
|
4
|
+
*
|
|
5
|
+
* Gated on the LLM stack being ready (Ollama + the configured embedding
|
|
6
|
+
* model). If the LLM isn't ready, this helper returns a `skipped_llm`
|
|
7
|
+
* result with a hint string the caller prints. The hint references
|
|
8
|
+
* `setup-llm` so the user has a clear path forward.
|
|
9
|
+
*
|
|
10
|
+
* Indexing on a fresh project takes ~30s — small enough that auto-
|
|
11
|
+
* running on first install is friendlier than asking. Subsequent calls
|
|
12
|
+
* are incremental via the per-file SHA hashes, so re-running on an
|
|
13
|
+
* existing index is cheap.
|
|
14
|
+
*
|
|
15
|
+
* @module setup/auto-index
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
18
|
+
import { resolveBuildRoot, resolveCatalogueRoot, resolveHashPath, resolveIndexPath, } from '../path-resolution.js';
|
|
19
|
+
import { DEFAULT_OLLAMA_HOST, detectModelPresent, detectOllamaApi } from './ollama-detect.js';
|
|
20
|
+
import { DEFAULT_EMBEDDING_MODEL } from './run-llm-setup.js';
|
|
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
|
|
26
|
+
* `setup-llm` to fix things.
|
|
27
|
+
*
|
|
28
|
+
* Never throws on user-facing failures.
|
|
29
|
+
*/
|
|
30
|
+
export async function ensureIndexBuilt(opts = {}) {
|
|
31
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
32
|
+
const out = opts.out ?? ((text) => process.stderr.write(text));
|
|
33
|
+
// Resolve where the index file lives without forcing the LLM stack to
|
|
34
|
+
// load — path resolution is cheap and offline. When the project has
|
|
35
|
+
// no `docs/build/` yet (e.g. install-protocols invoked in a non-
|
|
36
|
+
// scaffolded directory), resolveBuildRoot throws — we treat that as a
|
|
37
|
+
// silent no-op, since there is nothing meaningful to index.
|
|
38
|
+
const ctx = { cwd, env: process.env };
|
|
39
|
+
let buildRoot;
|
|
40
|
+
let catalogueRoot;
|
|
41
|
+
let indexPath;
|
|
42
|
+
let hashPath;
|
|
43
|
+
try {
|
|
44
|
+
buildRoot = resolveBuildRoot(undefined, ctx);
|
|
45
|
+
catalogueRoot = resolveCatalogueRoot(undefined, ctx);
|
|
46
|
+
indexPath = resolveIndexPath(buildRoot, undefined, ctx);
|
|
47
|
+
hashPath = resolveHashPath(buildRoot, undefined, ctx);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return { kind: 'skipped_no_catalogue' };
|
|
51
|
+
}
|
|
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
|
+
}
|
|
56
|
+
// Probe the LLM stack — index requires Ollama + the model. If either
|
|
57
|
+
// is missing, skip with a hint pointing at setup-llm.
|
|
58
|
+
const apiHost = process.env.NUOS_CATALOGUE_OLLAMA_HOST ?? DEFAULT_OLLAMA_HOST;
|
|
59
|
+
const modelId = process.env.NUOS_CATALOGUE_OLLAMA_MODEL ?? DEFAULT_EMBEDDING_MODEL;
|
|
60
|
+
const api = await detectOllamaApi(apiHost);
|
|
61
|
+
if (!api.reachable) {
|
|
62
|
+
return {
|
|
63
|
+
kind: 'skipped_llm_not_ready',
|
|
64
|
+
reason: `Ollama is not running at ${apiHost}`,
|
|
65
|
+
hint: 'Run `nuos-catalogue setup-llm` to set up local semantic search, then re-run `nuos-catalogue index`.',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const model = await detectModelPresent(apiHost, modelId);
|
|
69
|
+
if (!model.present) {
|
|
70
|
+
return {
|
|
71
|
+
kind: 'skipped_llm_not_ready',
|
|
72
|
+
reason: `${modelId} is not pulled`,
|
|
73
|
+
hint: 'Run `nuos-catalogue setup-llm` to pull the embedding model (~600 MB), then re-run `nuos-catalogue index`.',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
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
|
+
try {
|
|
81
|
+
const { selectEmbedderFromEnv } = await import('../embedder/select.js');
|
|
82
|
+
const { openStore } = await import('../store/open.js');
|
|
83
|
+
const { runIndex } = await import('../indexer/upsert.js');
|
|
84
|
+
const embedder = await selectEmbedderFromEnv();
|
|
85
|
+
const store = await openStore({ storagePath: indexPath, dimensions: embedder.dimensions });
|
|
86
|
+
try {
|
|
87
|
+
const report = await runIndex({
|
|
88
|
+
catalogueRoot,
|
|
89
|
+
hashFilePath: hashPath,
|
|
90
|
+
store,
|
|
91
|
+
embedder,
|
|
92
|
+
force: Boolean(opts.force),
|
|
93
|
+
dryRun: false,
|
|
94
|
+
});
|
|
95
|
+
out(`✓ Indexed ${report.indexed} file(s), ${report.chunks} chunks embedded in ` +
|
|
96
|
+
`${(report.durationMs / 1000).toFixed(1)}s\n`);
|
|
97
|
+
return {
|
|
98
|
+
kind: 'just_built',
|
|
99
|
+
indexPath,
|
|
100
|
+
indexed: report.indexed,
|
|
101
|
+
chunks: report.chunks,
|
|
102
|
+
durationMs: report.durationMs,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
// Unload-after-use commitment — embedder releases the model.
|
|
107
|
+
await embedder.dispose();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
112
|
+
out(`\n✗ Index build failed: ${message}\n`);
|
|
113
|
+
out('Re-run `nuos-catalogue index` manually to retry.\n');
|
|
114
|
+
return { kind: 'failed', error: message };
|
|
115
|
+
}
|
|
116
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nusoft/nuos-build-catalogue",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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": {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# NuOS catalogue post-commit hook — auto-refresh the semantic-search index
|
|
4
|
+
# after every commit that touched docs/build/**.
|
|
5
|
+
#
|
|
6
|
+
# Why this hook:
|
|
7
|
+
# `nuos-catalogue search` only finds what's in the NuVector index. The
|
|
8
|
+
# index is hash-based and incremental, but it doesn't update itself —
|
|
9
|
+
# you have to run `nuos-catalogue index` after meaningful changes for
|
|
10
|
+
# new content to be searchable. Running it manually is too easy to
|
|
11
|
+
# forget; the discipline argument that gave us the pre-commit hook
|
|
12
|
+
# (index-drift, accepted-decision immutability) applies equally here.
|
|
13
|
+
#
|
|
14
|
+
# Behaviour:
|
|
15
|
+
# - Skip if the just-landed commit did NOT touch docs/build/** (most
|
|
16
|
+
# code commits don't need a reindex)
|
|
17
|
+
# - Skip if `nuos-catalogue` isn't resolvable (the CLI may not be
|
|
18
|
+
# installed yet on a fresh clone; this hook should never block)
|
|
19
|
+
# - Otherwise: run the index in the BACKGROUND so the user's terminal
|
|
20
|
+
# isn't held while we embed. All output goes to .nuos-enforcement.log.
|
|
21
|
+
#
|
|
22
|
+
# The hook respects two env vars:
|
|
23
|
+
# NUOS_CATALOGUE_INDEX_DIR default: <repo>/.nuos-catalogue
|
|
24
|
+
# NUOS_CATALOGUE_OLLAMA_MODEL passed through to the CLI
|
|
25
|
+
#
|
|
26
|
+
# This hook never blocks. If indexing fails (Ollama not running, model
|
|
27
|
+
# not pulled, dimension mismatch with existing index), the error goes
|
|
28
|
+
# to the log and the user can investigate. The commit itself already
|
|
29
|
+
# landed by the time post-commit fires.
|
|
30
|
+
|
|
31
|
+
set -uo pipefail
|
|
32
|
+
|
|
33
|
+
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
34
|
+
LOG="$REPO_ROOT/.nuos-enforcement.log"
|
|
35
|
+
|
|
36
|
+
dim() { printf '\033[2m%s\033[0m\n' "$*"; }
|
|
37
|
+
yellow() { printf '\033[33m%s\033[0m\n' "$*"; }
|
|
38
|
+
|
|
39
|
+
# ---- Skip-paths --------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
# Skip if this project doesn't have a catalogue at all.
|
|
42
|
+
if [[ ! -d "$REPO_ROOT/docs/build" ]]; then
|
|
43
|
+
exit 0
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
# Skip if the just-landed commit didn't touch docs/build/**.
|
|
47
|
+
if ! git diff-tree --no-commit-id --name-only -r HEAD 2>/dev/null | grep -q '^docs/build/'; then
|
|
48
|
+
exit 0
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
# Resolve the CLI: prefer globally-installed `nuos-catalogue`; fall back
|
|
52
|
+
# to `npx --yes` which fetches from npm on demand.
|
|
53
|
+
INDEX_CMD=""
|
|
54
|
+
if command -v nuos-catalogue >/dev/null 2>&1; then
|
|
55
|
+
INDEX_CMD="nuos-catalogue"
|
|
56
|
+
elif command -v npx >/dev/null 2>&1; then
|
|
57
|
+
INDEX_CMD="npx --yes @nusoft/nuos-build-catalogue"
|
|
58
|
+
else
|
|
59
|
+
yellow "[nuos:post-commit] neither nuos-catalogue nor npx found; skipping index refresh"
|
|
60
|
+
printf '%s | post-commit-skip | no CLI available\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" >> "$LOG"
|
|
61
|
+
exit 0
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
# ---- Background index refresh ------------------------------------------
|
|
65
|
+
|
|
66
|
+
# Default the index dir to project-local if the user hasn't set one.
|
|
67
|
+
: "${NUOS_CATALOGUE_INDEX_DIR:=$REPO_ROOT/.nuos-catalogue}"
|
|
68
|
+
export NUOS_CATALOGUE_INDEX_DIR
|
|
69
|
+
|
|
70
|
+
# Detach from the terminal so the post-commit returns immediately. All
|
|
71
|
+
# output (stdout + stderr) goes to the enforcement log so the user can
|
|
72
|
+
# tail it if they want to see progress.
|
|
73
|
+
(
|
|
74
|
+
start=$(date +%s)
|
|
75
|
+
printf '%s | post-commit-index | start (model=%s, dir=%s)\n' \
|
|
76
|
+
"$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
|
|
77
|
+
"${NUOS_CATALOGUE_OLLAMA_MODEL:-default}" \
|
|
78
|
+
"$NUOS_CATALOGUE_INDEX_DIR" \
|
|
79
|
+
>> "$LOG"
|
|
80
|
+
|
|
81
|
+
if cd "$REPO_ROOT" && $INDEX_CMD index >> "$LOG" 2>&1; then
|
|
82
|
+
end=$(date +%s)
|
|
83
|
+
printf '%s | post-commit-index | done in %ss\n' \
|
|
84
|
+
"$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
|
|
85
|
+
$((end - start)) \
|
|
86
|
+
>> "$LOG"
|
|
87
|
+
else
|
|
88
|
+
printf '%s | post-commit-index | FAILED (see lines above in %s)\n' \
|
|
89
|
+
"$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
|
|
90
|
+
"$(basename "$LOG")" \
|
|
91
|
+
>> "$LOG"
|
|
92
|
+
fi
|
|
93
|
+
) </dev/null >/dev/null 2>&1 &
|
|
94
|
+
disown 2>/dev/null || true
|
|
95
|
+
|
|
96
|
+
dim "[nuos:post-commit] index refresh started in background — see .nuos-enforcement.log"
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# NuOS catalogue pre-commit hook (WU 111 enforcement phase).
|
|
4
|
+
#
|
|
5
|
+
# Catches drift before commit. After WU 111's Phase J ship, the
|
|
6
|
+
# accepted-decision rule has flipped from warning → block (per the
|
|
7
|
+
# pack's narrower-than-originally-planned enforcement scope, recorded
|
|
8
|
+
# in WU 111's "Forward-compatibility commitments" section). Other
|
|
9
|
+
# rules described in WU 128's original aggressive list are NOT shipping
|
|
10
|
+
# — distinguishing tool-written from human-written content was deemed
|
|
11
|
+
# overengineering for a planning-artefact catalogue.
|
|
12
|
+
#
|
|
13
|
+
# Active rules:
|
|
14
|
+
# 1. index-drift detection — every WU/decision/open-question/risk file
|
|
15
|
+
# must have a matching row in its _index.md (and vice versa)
|
|
16
|
+
# 2. active-decision modification block — modifying a committed
|
|
17
|
+
# `accepted` decision file is BLOCKED (not just warned). The
|
|
18
|
+
# discipline is to write a superseding D-NNN+1 and link forward.
|
|
19
|
+
# To deliberately fix a typo or link in an accepted decision,
|
|
20
|
+
# use `git commit --no-verify` (CLAUDE.md prohibits this for
|
|
21
|
+
# substantive changes; reserve it for typo-only fixes).
|
|
22
|
+
#
|
|
23
|
+
# Sentinel-protected sections (e.g. STATE.md's `nuos:sentinel`) remain
|
|
24
|
+
# protected via the existing `.claude/hooks/check-catalogue-write.sh`
|
|
25
|
+
# Claude Code hook. That rule continues unchanged at WU 111 ship.
|
|
26
|
+
#
|
|
27
|
+
# Bypass: this hook respects --no-verify like any other. The CLAUDE.md
|
|
28
|
+
# policy explicitly prohibits --no-verify use for substantive changes;
|
|
29
|
+
# the technical block fires at the CI server-side check (a future WU).
|
|
30
|
+
|
|
31
|
+
set -uo pipefail
|
|
32
|
+
|
|
33
|
+
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
34
|
+
ENFORCEMENT_LOG="$REPO_ROOT/.nuos-enforcement.log"
|
|
35
|
+
EXIT_CODE=0
|
|
36
|
+
|
|
37
|
+
red() { printf '\033[31m%s\033[0m\n' "$*"; }
|
|
38
|
+
yellow() { printf '\033[33m%s\033[0m\n' "$*"; }
|
|
39
|
+
green() { printf '\033[32m%s\033[0m\n' "$*"; }
|
|
40
|
+
dim() { printf '\033[2m%s\033[0m\n' "$*"; }
|
|
41
|
+
|
|
42
|
+
log_event() {
|
|
43
|
+
printf '%s | %s | %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$1" "$2" >> "$ENFORCEMENT_LOG"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# ---------- Generic index-drift checker ---------------------------------
|
|
47
|
+
#
|
|
48
|
+
# Args:
|
|
49
|
+
# $1 — kind label (for messages)
|
|
50
|
+
# $2 — directory (relative to repo root)
|
|
51
|
+
# $3 — _index.md path (relative to repo root)
|
|
52
|
+
# $4 — filename regex (matches IDs the directory contains)
|
|
53
|
+
# $5 — index-row ID regex (a Perl-compatible regex that anchors on the
|
|
54
|
+
# leftmost column of an index table row)
|
|
55
|
+
#
|
|
56
|
+
# The two regexes must extract the SAME set of IDs (e.g. "001", "030g",
|
|
57
|
+
# "D042") so set comparison works.
|
|
58
|
+
|
|
59
|
+
check_index_drift() {
|
|
60
|
+
local kind="$1" dir="$2" index="$3" file_regex="$4" row_regex="$5"
|
|
61
|
+
|
|
62
|
+
if [[ ! -d "$REPO_ROOT/$dir" ]]; then return 0; fi
|
|
63
|
+
if [[ ! -f "$REPO_ROOT/$index" ]]; then return 0; fi
|
|
64
|
+
|
|
65
|
+
# IDs extracted from filenames in the directory (top-level + one subdir
|
|
66
|
+
# like done/, resolved/, superseded/)
|
|
67
|
+
local ids_in_tree
|
|
68
|
+
ids_in_tree=$(cd "$REPO_ROOT/$dir" && {
|
|
69
|
+
find . -maxdepth 2 -type f -name "*.md" \
|
|
70
|
+
-not -name "_index.md" \
|
|
71
|
+
-not -name "*-template.md" 2>/dev/null \
|
|
72
|
+
| sed -nE "$file_regex" \
|
|
73
|
+
| sort -u
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
# IDs extracted from leftmost column of table rows in the index
|
|
77
|
+
local ids_in_index
|
|
78
|
+
ids_in_index=$(sed -nE "$row_regex" "$REPO_ROOT/$index" | sort -u)
|
|
79
|
+
|
|
80
|
+
local missing_from_index
|
|
81
|
+
missing_from_index=$(comm -23 <(printf '%s\n' "$ids_in_tree") <(printf '%s\n' "$ids_in_index"))
|
|
82
|
+
|
|
83
|
+
if [[ -n "$missing_from_index" ]]; then
|
|
84
|
+
red "✖ index-drift ($kind): on disk but missing from $index:"
|
|
85
|
+
while IFS= read -r id; do echo " — $id"; done <<< "$missing_from_index"
|
|
86
|
+
log_event "index-drift" "$kind missing from index: $(echo "$missing_from_index" | tr '\n' ',')"
|
|
87
|
+
EXIT_CODE=1
|
|
88
|
+
fi
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
dim "[nuos:pre-commit] index-drift check"
|
|
92
|
+
|
|
93
|
+
# Note: BSD sed (macOS default) is fussy about `|` as both delimiter and
|
|
94
|
+
# regex content. Using `#` as the s/// delimiter sidesteps the collision.
|
|
95
|
+
|
|
96
|
+
# Work units: filenames are NNN-slug.md or NNNa-slug.md (with optional letter).
|
|
97
|
+
# Index rows are `| NNN |` or `| NNNa |` in the leftmost column.
|
|
98
|
+
check_index_drift \
|
|
99
|
+
"work-units" \
|
|
100
|
+
"docs/build/work-units" \
|
|
101
|
+
"docs/build/work-units/_index.md" \
|
|
102
|
+
's#^\./(done/)?([0-9]{3}[a-z]?)-[^/]*\.md$#\2#p' \
|
|
103
|
+
's#^\| ([0-9]{3}[a-z]?) \|.*$#\1#p'
|
|
104
|
+
|
|
105
|
+
# Decisions: filenames are DNNN-slug.md.
|
|
106
|
+
# Index rows: `| DNNN |` or `| [DNNN](...) |`.
|
|
107
|
+
check_index_drift \
|
|
108
|
+
"decisions" \
|
|
109
|
+
"docs/build/decisions" \
|
|
110
|
+
"docs/build/decisions/_index.md" \
|
|
111
|
+
's#^\./(done/|superseded/)?(D[0-9]{3})-[^/]*\.md$#\2#p' \
|
|
112
|
+
's#^\| \[?(D[0-9]{3}).*$#\1#p'
|
|
113
|
+
|
|
114
|
+
# Open questions: filenames are QNNN-slug.md.
|
|
115
|
+
check_index_drift \
|
|
116
|
+
"open-questions" \
|
|
117
|
+
"docs/build/open-questions" \
|
|
118
|
+
"docs/build/open-questions/_index.md" \
|
|
119
|
+
's#^\./(resolved/)?(Q[0-9]{3})-[^/]*\.md$#\2#p' \
|
|
120
|
+
's#^\| \[?(Q[0-9]{3}).*$#\1#p'
|
|
121
|
+
|
|
122
|
+
# Risks: per current convention, individual risk files are inline in
|
|
123
|
+
# risks/_index.md (no per-risk .md files yet). Skip the check entirely
|
|
124
|
+
# until that pattern changes.
|
|
125
|
+
if compgen -G "$REPO_ROOT/docs/build/risks/R[0-9][0-9][0-9]-*.md" > /dev/null; then
|
|
126
|
+
check_index_drift \
|
|
127
|
+
"risks" \
|
|
128
|
+
"docs/build/risks" \
|
|
129
|
+
"docs/build/risks/_index.md" \
|
|
130
|
+
's#^\./(R[0-9]{3})-[^/]*\.md$#\1#p' \
|
|
131
|
+
's#^\| \[?(R[0-9]{3}).*$#\1#p'
|
|
132
|
+
fi
|
|
133
|
+
|
|
134
|
+
# ---------- Rule 2: active-decision modification block (WU 111 ship) ---
|
|
135
|
+
|
|
136
|
+
dim "[nuos:pre-commit] active-decision modification check"
|
|
137
|
+
modified_decisions=$(git diff --cached --name-only --diff-filter=M \
|
|
138
|
+
| grep -E '^docs/build/decisions/D[0-9]+.*\.md$' \
|
|
139
|
+
| grep -v '/superseded/' \
|
|
140
|
+
|| true)
|
|
141
|
+
|
|
142
|
+
if [[ -n "$modified_decisions" ]]; then
|
|
143
|
+
red "✖ active-decision modification — BLOCKED (WU 111 enforcement):"
|
|
144
|
+
while IFS= read -r f; do echo " — $f"; done <<< "$modified_decisions"
|
|
145
|
+
red " Decisions are immutable once accepted. The discipline is to write a"
|
|
146
|
+
red " superseding D-NNN+1 and link forward. Use:"
|
|
147
|
+
red " nuos-catalogue decision supersede <target> --by=<new-D> --reason=\"...\""
|
|
148
|
+
red ""
|
|
149
|
+
red " If this edit is a non-substantive typo fix or link cleanup that does"
|
|
150
|
+
red " not change the decision's meaning, you may bypass this block with"
|
|
151
|
+
red " --no-verify. CLAUDE.md prohibits --no-verify for substantive changes."
|
|
152
|
+
log_event "active-decision-block" "$(echo "$modified_decisions" | tr '\n' ',')"
|
|
153
|
+
EXIT_CODE=1
|
|
154
|
+
fi
|
|
155
|
+
|
|
156
|
+
# ---------- Result ------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
if [[ $EXIT_CODE -eq 0 ]]; then
|
|
159
|
+
green "[nuos:pre-commit] all rules pass (WU 111 enforcement)"
|
|
160
|
+
log_event "pre-commit-pass" "$(git diff --cached --name-only | wc -l | tr -d ' ') files"
|
|
161
|
+
fi
|
|
162
|
+
exit $EXIT_CODE
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Install the catalogue's git hooks into .git/hooks/.
|
|
4
|
+
#
|
|
5
|
+
# Why this script (not husky):
|
|
6
|
+
# The nuos repo is a markdown catalogue, not an npm package. Adding a
|
|
7
|
+
# package.json + node_modules just to install hooks would be infrastructure
|
|
8
|
+
# tax for what is otherwise a doc repo. A small bash installer copies the
|
|
9
|
+
# hooks from the version-controlled scripts/hooks/ into .git/hooks/.
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# bash scripts/install-hooks.sh
|
|
13
|
+
#
|
|
14
|
+
# Re-run any time scripts/hooks/ changes; the installer is idempotent.
|
|
15
|
+
|
|
16
|
+
set -euo pipefail
|
|
17
|
+
|
|
18
|
+
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
19
|
+
SOURCE="$REPO_ROOT/scripts/hooks"
|
|
20
|
+
TARGET="$REPO_ROOT/.git/hooks"
|
|
21
|
+
|
|
22
|
+
if [[ ! -d "$SOURCE" ]]; then
|
|
23
|
+
echo "✖ scripts/hooks/ not found; nothing to install" >&2
|
|
24
|
+
exit 1
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
mkdir -p "$TARGET"
|
|
28
|
+
|
|
29
|
+
installed=0
|
|
30
|
+
for hook in "$SOURCE"/*; do
|
|
31
|
+
name="$(basename "$hook")"
|
|
32
|
+
cp "$hook" "$TARGET/$name"
|
|
33
|
+
chmod +x "$TARGET/$name"
|
|
34
|
+
installed=$((installed + 1))
|
|
35
|
+
done
|
|
36
|
+
|
|
37
|
+
echo "✓ installed $installed hook(s) into .git/hooks/"
|
|
38
|
+
echo
|
|
39
|
+
echo "Active rules (WU 111 enforcement):"
|
|
40
|
+
echo " • index-drift detection (work-units, decisions, open-questions, risks)"
|
|
41
|
+
echo " • active-decision modification BLOCK (was warning under WU 128 light-touch)"
|
|
42
|
+
echo
|
|
43
|
+
echo "To verify the install: \`git hook list\` (git ≥2.36) or \`ls .git/hooks/\`"
|
|
44
|
+
echo "To uninstall: \`rm .git/hooks/pre-commit\`"
|