@nusoft/nuos-build-catalogue 0.20.1 → 0.22.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;
@@ -51,7 +51,7 @@ const PROTOCOL_DESCRIPTIONS = {
51
51
  'end-of-session': 'Capture what happened, update state, write session log, commit',
52
52
  'wu-new': 'File a new work unit through a guided plain-English conversation',
53
53
  'persona-new': 'File a new persona by walking the seven dimensions conversationally',
54
- 'plan-orientation': 'Phase A of planning — project description, personas, the horizon map',
54
+ 'plan-orientation': 'Phase A of planning — project description, tech stack, personas, the horizon map',
55
55
  'build-wu': 'Orchestrate a swarm of agents to build one work unit end-to-end',
56
56
  };
57
57
  const TOOLS = {
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nusoft/nuos-build-catalogue",
3
- "version": "0.20.1",
3
+ "version": "0.22.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
@@ -19,6 +19,7 @@ Also read:
19
19
  - The contracts it touches (`docs/build/contracts/`)
20
20
  - The architecture files for any modules involved (`docs/build/architecture/`)
21
21
  - The relevant design-system pieces if the work unit ships a UI surface
22
+ - `methodfile.json`'s `techStack` section — if `techStack.defined` is `true`, extract the fields now; you'll inject them into every agent prompt in Step 4
22
23
  - Run `nuos-catalogue search "<work unit title or outcome>"` to find related prior work
23
24
 
24
25
  Before spawning any agents, search the cross-agent memory for relevant prior findings:
@@ -61,6 +62,20 @@ Skip steps when context allows — implementation-only WUs skip the architect; b
61
62
 
62
63
  Use Claude Code's **Task tool**. Each spawn names the agent (`subagent_type`), the model (from `methodfile.json`'s `swarm.models` block — usually leave as default), and the precise input.
63
64
 
65
+ **Technical context injection:** If `techStack.defined` is `true` in `methodfile.json`, every agent spawn prompt must open with a "Technical context" block:
66
+
67
+ ```
68
+ **Technical context (from methodfile.json):**
69
+ - Languages: [languages]
70
+ - Frontend: [frontend]
71
+ - Backend: [backend]
72
+ - Database: [database]
73
+ - Deployment: [deployment]
74
+ - External services: [externalServices]
75
+ ```
76
+
77
+ Omit `null` fields. If `techStack.defined` is `false` or the section is absent, note it in the swarm audit entry and suggest the operator define the stack (`/plan-orientation` or edit `methodfile.json` directly) before the next swarm run — agents generating code without a known stack default to generic patterns that often need rework.
78
+
64
79
  **Spawn in parallel where possible.** If two agents can work independently (e.g. tester writing tests while reviewer reads design), spawn them in the same message. Sequential when an agent's output is the next agent's input (architect → coder).
65
80
 
66
81
  For each spawn:
@@ -141,7 +156,7 @@ Every decision made by any agent during the swarm MUST land in the catalogue bef
141
156
 
142
157
  ## Cost guidance
143
158
 
144
- A typical full-feature swarm spawning architect (Opus, ~30 min) + coder (Sonnet, ~1 hr) + tester (Sonnet, ~30 min) + reviewer (Sonnet, ~15 min) costs substantially less than running the same work as a continuous Opus conversation. Don't sweat exact figures the 80/20 split is the lever. If a single work unit's swarm cost is becoming meaningful (>>£10), surface that to the operator before continuing; the WU is probably bigger than scoped.
159
+ A typical full-feature swarm spawning architect (Opus, ~30 min) + coder (Sonnet, ~1 hr) + tester (Sonnet, ~30 min) + reviewer (Sonnet, ~15 min) consumes substantially less of the operator's coding-tool plan budget than running the same work as a continuous Opus conversation. The 80/20 splitheavy reasoning for design and debugging only, lighter models for implementation and verification — is the lever. If a single work unit's swarm is consuming an unusual share of the day's plan budget, surface that to the operator before continuing; the WU is probably bigger than scoped.
145
160
 
146
161
  ---
147
162
 
@@ -158,16 +173,6 @@ If the reviewer returns REQUEST CHANGES, re-spawn the coder ONCE to address the
158
173
 
159
174
  Don't loop indefinitely. A third reviewer rejection is a signal — the work unit's design, contract, or acceptance criteria need clarification, not more code.
160
175
 
161
- ### Cost ceiling per work unit
162
-
163
- If the estimated cost (per the swarm audit) is exceeding **£10** for a single work unit:
164
-
165
- - STOP the swarm
166
- - Surface the cost trajectory to the operator
167
- - Recommend either splitting the work unit into smaller pieces, or accepting the higher cost with their explicit go-ahead
168
-
169
- This is a soft ceiling — the operator can authorise more. The point is to make cost visible before it accumulates invisibly.
170
-
171
176
  ### Time ceiling per agent
172
177
 
173
178
  If a single agent's run is taking substantially longer than its rough budget (architect >1 hr, coder >2 hrs, tester >1 hr, reviewer >30 min):
@@ -45,7 +45,43 @@ Listen. Don't interrupt. When they're done, summarise back in 2-3 sentences in y
45
45
 
46
46
  When the description is settled, **write it into `STATE.md`'s "What is currently in flight" section** — replacing the placeholder. Keep their voice; don't make it sound corporate. Show them the file path and confirm it's saved.
47
47
 
48
- ## Step 3 — One persona, then one or two more (15-20 min)
48
+ ## Step 3 — Tech stack (5 min)
49
+
50
+ Now ask what they're building it with:
51
+
52
+ > "Before we meet the people your project is for — quickly, what are you building it with? Language, framework, database, where it'll run. If you know already, brilliant. If you haven't decided, just say so and we'll note it as an open question."
53
+
54
+ Listen and capture what they give you. Common patterns:
55
+ - *"Next.js, PostgreSQL, deployed on Vercel"* → frontend + database + deployment all filled
56
+ - *"React Native with Firebase"* → frontend + database/backend filled
57
+ - *"Not sure yet"* → set `defined: false`, file a Q-NNN
58
+
59
+ **Write the result to `methodfile.json` now**, under the `techStack` section:
60
+
61
+ ```json
62
+ {
63
+ "techStack": {
64
+ "defined": true,
65
+ "languages": ["TypeScript"],
66
+ "frontend": "Next.js 15 (App Router)",
67
+ "backend": "Next.js API Routes / Server Actions",
68
+ "database": "PostgreSQL (Supabase)",
69
+ "deployment": "Vercel",
70
+ "externalServices": ["Stripe"],
71
+ "notes": null
72
+ }
73
+ }
74
+ ```
75
+
76
+ Fill in what you know; set unknown fields to `null`. If nothing is settled, set `defined: false`, leave all fields null, and file a Q-NNN open question: *"Tech stack not yet decided — revisit before Phase B."*
77
+
78
+ Show the operator the updated `methodfile.json` and confirm it saved. Tell them:
79
+
80
+ > *"This means every agent we spawn later will know what it's building against — language, framework, where it runs. Just a few fields, but it prevents a lot of wrong output later."*
81
+
82
+ **Drift discipline:** partial information is fine and still valuable. An operator who says *"definitely Next.js, not sure about the database yet"* should have `frontend: "Next.js"`, `database: null`, `defined: true`. Partial is better than undefined.
83
+
84
+ ## Step 4 — One persona, then one or two more (15-20 min)
49
85
 
50
86
  Tell the operator what's coming:
51
87
 
@@ -59,7 +95,7 @@ When P001 is filed, surface it and ask:
59
95
 
60
96
  If yes, run `/persona-new` again. Aim for **1-3 total** — more than 3 in Phase A usually means the project is overscoped; file the rest as open questions and revisit later.
61
97
 
62
- ## Step 4 — Map 1: The Horizon (8-10 min)
98
+ ## Step 5 — Map 1: The Horizon (8-10 min)
63
99
 
64
100
  When the personas are filed, transition:
65
101
 
@@ -75,7 +111,7 @@ Use the template at `docs/build/maps/01-template.md`. Walk through its sections
75
111
 
76
112
  Write the map to `docs/build/maps/01-the-horizon.md`. Show them the file path and confirm.
77
113
 
78
- ## Step 5 — Open questions (2 min)
114
+ ## Step 6 — Open questions (2 min)
79
115
 
80
116
  Pass over the conversation looking for anything the operator wasn't sure about. For each:
81
117
 
@@ -84,7 +120,7 @@ Pass over the conversation looking for anything the operator wasn't sure about.
84
120
 
85
121
  > "I noticed a few things you weren't sure about yet — [list]. I've filed them as open questions so we'll come back to them. Two of them affect Phase B (Architecture), so we'll definitely hit them next session."
86
122
 
87
- ## Step 6 — Close (2 min)
123
+ ## Step 7 — Close (2 min)
88
124
 
89
125
  Update STATE.md:
90
126
 
@@ -96,6 +132,7 @@ Then tell the operator what they now have:
96
132
 
97
133
  > "You've got your first catalogue substrate:
98
134
  >
135
+ > - **Tech stack** defined in `methodfile.json` — (or flagged as an open question if not yet settled)
99
136
  > - **[N] personas** in `docs/build/personas/` — these anchor every later decision
100
137
  > - **Map 1** at `docs/build/maps/01-the-horizon.md` — the whole-project picture
101
138
  > - **[N] open questions** in `docs/build/open-questions/` — these are what we'll resolve as planning continues
@@ -42,6 +42,8 @@ For infrastructure work, persona / trigger / walkthrough are marked `N/A — inf
42
42
 
43
43
  ## Step 4 — File the work unit
44
44
 
45
+ Before writing the file, check `methodfile.json`'s `techStack.defined`. If it's `false` or the field is absent, tell the operator: *"I notice the tech stack isn't defined yet — that normally happens during Phase A planning. Want to define it now, or shall I file an open question?"* Either way, continue filing the work unit. If `defined` is `true`, the acceptance criteria may reference the stack where relevant (e.g. *"renders correctly with Next.js App Router SSR"*).
46
+
45
47
  1. **Number it.** Scan `docs/build/work-units/` and `docs/build/work-units/done/` for the highest existing 3-digit prefix; new number is max + 1.
46
48
  2. **Slugify the title.** Lowercase; dashes for spaces; no special characters; cap at 60 chars.
47
49
  3. **Write the file** at `docs/build/work-units/NNN-slug.md`. Use `001-template-simple.md` for the simple shape, `001-template-full.md` for the full shape.
@@ -9,6 +9,16 @@
9
9
  "tagline": "{{PROJECT_TAGLINE}}",
10
10
  "domain": "{{PROJECT_DOMAIN}}"
11
11
  },
12
+ "techStack": {
13
+ "defined": false,
14
+ "languages": [],
15
+ "frontend": null,
16
+ "backend": null,
17
+ "database": null,
18
+ "deployment": null,
19
+ "externalServices": [],
20
+ "notes": null
21
+ },
12
22
  "catalogue": {
13
23
  "root": "docs/build/",
14
24
  "registers": {