@nusoft/nuos-build-catalogue 0.30.3 → 0.32.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.
@@ -1,11 +1,15 @@
1
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.
2
+ * `install-hooks` — copy every Claude Code PreToolUse hook shipped in
3
+ * the package into the consumer's `.claude/hooks/`, wire them into the
4
+ * consumer's `.claude/settings.json` under a single shared matcher,
5
+ * and add the active-WU marker file to `.gitignore`.
6
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
7
+ * Hooks discovered automatically by scanning `templates/claude-hooks/`
8
+ * for `*.sh` files adding a new hook to that directory is enough to
9
+ * have it installed on the next consumer upgrade.
10
+ *
11
+ * Idempotent. Safe to re-run after package upgrades — hook scripts
12
+ * are overwritten, settings entries are added only if missing, and the
9
13
  * gitignore line is appended only if not present.
10
14
  *
11
15
  * @module commands/install-claude-hooks
@@ -20,10 +24,21 @@ export interface InstallClaudeHooksOptions {
20
24
  templatesDir?: string;
21
25
  }
22
26
  /**
23
- * Idempotently install the Claude PreToolUse hook into the project at
27
+ * Idempotently install every Claude PreToolUse hook into the project at
24
28
  * `cwd`. Returns a CommandResult describing what was done.
25
29
  */
26
30
  export declare function cmdInstallClaudeHooks(opts?: InstallClaudeHooksOptions): CommandResult;
31
+ /**
32
+ * Add a `{type:"command", command}` hook entry under the PreToolUse
33
+ * matcher. If a matcher entry with the same `matcher` string already
34
+ * exists, the command is appended to its `hooks` array (deduped by
35
+ * command string). Otherwise a new matcher entry is created. Settings
36
+ * that already contain the command anywhere under PreToolUse are
37
+ * returned unchanged.
38
+ *
39
+ * Pure function: takes settings in, returns a new settings object plus
40
+ * a `changed` flag indicating whether anything was actually added.
41
+ */
27
42
  export declare function addPreToolUseHook(settings: Record<string, unknown>, matcher: string, command: string): {
28
43
  value: Record<string, unknown>;
29
44
  changed: boolean;
@@ -1,21 +1,27 @@
1
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.
2
+ * `install-hooks` — copy every Claude Code PreToolUse hook shipped in
3
+ * the package into the consumer's `.claude/hooks/`, wire them into the
4
+ * consumer's `.claude/settings.json` under a single shared matcher,
5
+ * and add the active-WU marker file to `.gitignore`.
6
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
7
+ * Hooks discovered automatically by scanning `templates/claude-hooks/`
8
+ * for `*.sh` files adding a new hook to that directory is enough to
9
+ * have it installed on the next consumer upgrade.
10
+ *
11
+ * Idempotent. Safe to re-run after package upgrades — hook scripts
12
+ * are overwritten, settings entries are added only if missing, and the
9
13
  * gitignore line is appended only if not present.
10
14
  *
11
15
  * @module commands/install-claude-hooks
12
16
  */
13
- import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
17
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from "node:fs";
14
18
  import { dirname, join, resolve } from "node:path";
15
19
  import { fileURLToPath } from "node:url";
16
- const HOOK_FILENAME = "check-implementation-write.sh";
17
20
  const SETTINGS_MATCHER = "Write|Edit|MultiEdit|NotebookEdit";
18
- const SETTINGS_COMMAND = `bash .claude/hooks/${HOOK_FILENAME}`;
21
+ // The active-WU marker is read only by check-implementation-write.sh.
22
+ // We add it to .gitignore whenever that hook is being installed.
23
+ const IMPLEMENTATION_HOOK = "check-implementation-write.sh";
24
+ const ACTIVE_WU_MARKER = ".nuos-catalogue/active-wu";
19
25
  /**
20
26
  * Resolve the path to the bundled `templates/claude-hooks/` directory.
21
27
  * When running from `dist/` (the published package) the templates dir
@@ -25,8 +31,6 @@ const SETTINGS_COMMAND = `bash .claude/hooks/${HOOK_FILENAME}`;
25
31
  */
26
32
  function resolveTemplatesDir() {
27
33
  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
34
  const candidates = [
31
35
  resolve(here, "..", "..", "templates", "claude-hooks"),
32
36
  resolve(here, "..", "templates", "claude-hooks"),
@@ -35,61 +39,77 @@ function resolveTemplatesDir() {
35
39
  if (existsSync(c))
36
40
  return c;
37
41
  }
38
- // Last resort: return the first candidate so the error message names
39
- // a real-ish path.
40
42
  return candidates[0];
41
43
  }
42
44
  /**
43
- * Idempotently install the Claude PreToolUse hook into the project at
45
+ * Idempotently install every Claude PreToolUse hook into the project at
44
46
  * `cwd`. Returns a CommandResult describing what was done.
45
47
  */
46
48
  export function cmdInstallClaudeHooks(opts = {}) {
47
49
  const cwd = opts.cwd ?? process.cwd();
48
50
  const templatesDir = opts.templatesDir ?? resolveTemplatesDir();
49
- const srcHook = join(templatesDir, HOOK_FILENAME);
50
- if (!existsSync(srcHook)) {
51
+ if (!existsSync(templatesDir)) {
52
+ return {
53
+ output: `✖ nuos: hook templates directory not found at ${templatesDir}\n The package may be installed incorrectly. Try reinstalling.`,
54
+ exitCode: 1,
55
+ };
56
+ }
57
+ const hookFiles = readdirSync(templatesDir)
58
+ .filter((f) => f.endsWith(".sh"))
59
+ .sort();
60
+ if (hookFiles.length === 0) {
51
61
  return {
52
- output: `✖ nuos: hook template not found at ${srcHook}\n The package may be installed incorrectly. Try reinstalling.`,
62
+ output: `✖ nuos: no hook scripts found in ${templatesDir}`,
53
63
  exitCode: 1,
54
64
  };
55
65
  }
56
66
  const lines = [];
57
- // 1. Copy the hook script into .claude/hooks/.
58
67
  const hooksDir = join(cwd, ".claude", "hooks");
59
68
  if (!existsSync(hooksDir))
60
69
  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.
70
+ // 1. Copy every hook script into .claude/hooks/.
71
+ for (const filename of hookFiles) {
72
+ const src = join(templatesDir, filename);
73
+ const dest = join(hooksDir, filename);
74
+ copyFileSync(src, dest);
75
+ try {
76
+ chmodSync(dest, 0o755);
77
+ }
78
+ catch {
79
+ // chmod is best-effort on filesystems that don't support it.
80
+ }
81
+ lines.push(` ✓ installed hook → .claude/hooks/${filename}`);
71
82
  }
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.
83
+ // 2. Merge into .claude/settings.json. All hooks share one PreToolUse
84
+ // matcher; each contributes one command entry.
75
85
  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");
86
+ let settings = readJsonOrEmpty(settingsPath);
87
+ let settingsChanged = false;
88
+ for (const filename of hookFiles) {
89
+ const command = `bash .claude/hooks/${filename}`;
90
+ const updated = addPreToolUseHook(settings, SETTINGS_MATCHER, command);
91
+ settings = updated.value;
92
+ if (updated.changed)
93
+ settingsChanged = true;
84
94
  }
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");
95
+ if (settingsChanged) {
96
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
97
+ lines.push(" ✓ updated .claude/settings.json (PreToolUse entries added)");
90
98
  }
91
99
  else {
92
- lines.push(" · .nuos-catalogue/active-wu already in .gitignore");
100
+ lines.push(" · .claude/settings.json already has every PreToolUse entry");
101
+ }
102
+ // 3. Add the active-WU marker to .gitignore — only relevant when the
103
+ // implementation-write hook is part of the bundle.
104
+ if (hookFiles.includes(IMPLEMENTATION_HOOK)) {
105
+ const gitignorePath = join(cwd, ".gitignore");
106
+ const added = ensureGitignoreEntry(gitignorePath, ACTIVE_WU_MARKER);
107
+ if (added) {
108
+ lines.push(` ✓ added ${ACTIVE_WU_MARKER} to .gitignore`);
109
+ }
110
+ else {
111
+ lines.push(` · ${ACTIVE_WU_MARKER} already in .gitignore`);
112
+ }
93
113
  }
94
114
  return {
95
115
  output: [
@@ -113,12 +133,22 @@ function readJsonOrEmpty(path) {
113
133
  return {};
114
134
  }
115
135
  }
136
+ /**
137
+ * Add a `{type:"command", command}` hook entry under the PreToolUse
138
+ * matcher. If a matcher entry with the same `matcher` string already
139
+ * exists, the command is appended to its `hooks` array (deduped by
140
+ * command string). Otherwise a new matcher entry is created. Settings
141
+ * that already contain the command anywhere under PreToolUse are
142
+ * returned unchanged.
143
+ *
144
+ * Pure function: takes settings in, returns a new settings object plus
145
+ * a `changed` flag indicating whether anything was actually added.
146
+ */
116
147
  export function addPreToolUseHook(settings, matcher, command) {
117
- // Defensive copy so callers can't accidentally see in-place mutation.
118
148
  const next = { ...settings };
119
149
  const hooksField = next.hooks ?? {};
120
150
  const preToolUse = hooksField.PreToolUse ?? [];
121
- // Already present? Check by command string (any matcher).
151
+ // Already present anywhere? Dedupe by command string.
122
152
  for (const entry of preToolUse) {
123
153
  for (const h of entry.hooks ?? []) {
124
154
  if (h.command === command) {
@@ -126,11 +156,29 @@ export function addPreToolUseHook(settings, matcher, command) {
126
156
  }
127
157
  }
128
158
  }
129
- const newEntry = {
130
- matcher,
131
- hooks: [{ type: "command", command }],
132
- };
133
- const newPreToolUse = [...preToolUse, newEntry];
159
+ // Look for an existing matcher with the same matcher string and
160
+ // append to it — keeps all our hooks under a single matcher entry,
161
+ // matching the shape the catalogue uses in its own settings.json.
162
+ const matchingIndex = preToolUse.findIndex((e) => e.matcher === matcher);
163
+ let newPreToolUse;
164
+ if (matchingIndex >= 0) {
165
+ const existing = preToolUse[matchingIndex];
166
+ const merged = {
167
+ matcher: existing.matcher,
168
+ hooks: [...(existing.hooks ?? []), { type: "command", command }],
169
+ };
170
+ newPreToolUse = [
171
+ ...preToolUse.slice(0, matchingIndex),
172
+ merged,
173
+ ...preToolUse.slice(matchingIndex + 1),
174
+ ];
175
+ }
176
+ else {
177
+ newPreToolUse = [
178
+ ...preToolUse,
179
+ { matcher, hooks: [{ type: "command", command }] },
180
+ ];
181
+ }
134
182
  const newHooks = { ...hooksField, PreToolUse: newPreToolUse };
135
183
  next.hooks = newHooks;
136
184
  return { value: next, changed: true };
@@ -143,7 +191,9 @@ export function ensureGitignoreEntry(path, line) {
143
191
  const lines = body.split("\n").map((l) => l.trim());
144
192
  if (lines.includes(line))
145
193
  return false;
146
- const newBody = body.endsWith("\n") || body.length === 0 ? body + line + "\n" : body + "\n" + line + "\n";
194
+ const newBody = body.endsWith("\n") || body.length === 0
195
+ ? body + line + "\n"
196
+ : body + "\n" + line + "\n";
147
197
  writeFileSync(path, newBody, "utf8");
148
198
  return true;
149
199
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nusoft/nuos-build-catalogue",
3
- "version": "0.30.3",
3
+ "version": "0.32.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": {
@@ -59,6 +59,48 @@ When you're done, write a brief to the coder agent in the work unit's notes —
59
59
 
60
60
  If you find yourself writing *"likely"*, *"presumably"*, *"should work"* in your decision, that's a missing verification step. Replace it with a concrete check, or file the uncertainty as an open question. Hedge words leave room for plausible-looking work that doesn't match reality.
61
61
 
62
+ ## Module discipline — deep, not shallow
63
+
64
+ **Every design you produce sits inside the project's module structure. The structure is built from deep modules — small interface, large hidden complexity — and never from shallow ones.** This is the single most load-bearing architectural commitment in the project. Read `docs/philosophy/deep-modules.md` before your first design pass on any new project, and re-read it whenever you find yourself reaching for a new module.
65
+
66
+ ### Before you design
67
+
68
+ Read the WU's `Module:` field. Read the architecture file for that module. Your design extends that module's hidden complexity — its `Interface surface` should grow as little as possible, its `Hidden complexity` should grow to absorb the new work.
69
+
70
+ ### When the WU proposes a new module
71
+
72
+ If the `/wu-new` intake gate routed a new-module proposal to you, you produce the architecture file *before* the WU is filed. Use `docs/build/architecture/module-template.md`. Every field is required — especially:
73
+
74
+ - **Interface surface** — list every public entry point. Few. Named. Stable.
75
+ - **Hidden complexity** — list every piece of state, branching, integration, edge case the module absorbs. Many. Specific.
76
+ - **Depth justification** — two or three sentences answering: *why is the hidden body genuinely larger than the interface?* If you can't answer this, the module is shallow — fold the work into an existing module instead.
77
+ - **Paths claimed** — every source path this module owns. The `check-module-discipline.sh` hook reads this to block writes to unclaimed paths.
78
+
79
+ ### Shallow-module patterns you must not propose
80
+
81
+ The build-wu deep-module gate will reject any of these. Catch them yourself first:
82
+
83
+ - **The pass-through wrapper** — public methods each call exactly one method in another module. Adds interface cost without encapsulation.
84
+ - **The util / helper / shared / common / lib / misc grab-bag** — these names are banned. The work lives inside the module whose hidden complexity needs it.
85
+ - **The thin adapter** — renames or thinly re-exports another module. Rename at the source instead.
86
+ - **The micro-module** — three files, four functions, no significant hidden body. Merge into the deepest reasonable existing module.
87
+ - **The premature split** — splitting one coherent responsibility into two modules to "separate concerns" when those concerns are not separable in the runtime. One deep module beats two shallow ones.
88
+ - **The interface-equals-body module** — `Interface surface` items roughly equal `Hidden complexity` items. The depth ratio is ~1; the module has no depth.
89
+
90
+ ### When in doubt, fold into an existing module
91
+
92
+ Default to **extend an existing deep module**. Only propose a new module when:
93
+
94
+ 1. No existing module's `Hidden complexity` can plausibly absorb the responsibility, AND
95
+ 2. The new module's hidden body is *much larger* than its interface, AND
96
+ 3. You can write a depth justification you would defend in review.
97
+
98
+ The cost of an under-split module is rework (cheap; you can split later). The cost of an over-split system is permanent shallow sprawl (expensive; nobody ever un-splits). When the call is close, fold in.
99
+
100
+ ### Make module-depth one of your design-it-twice axes
101
+
102
+ When you produce two structurally different designs (Pattern N), make **module-depth shape** one of the structural axes whenever the work has any module-boundary implications. Example: *Design A folds the new behaviour into the existing `consolidation` module (interface grows by 1 method, hidden body absorbs the new state machine). Design B creates a new `consolidation-replay` module (interface = 4 methods, hidden body = the replay engine + scheduling).* Then pick on depth, cohesion, and the cost-of-being-wrong asymmetry above — not on "which feels neater."
103
+
62
104
  ## No shortcuts. No workarounds. No provisional designs.
63
105
 
64
106
  **This is absolute.** The architect's job is to produce the correct, fully-designed solution — not the fastest one, not the simplest one, not the one that fits inside this sprint. Speed of delivery is never a valid input to an architectural decision.
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # NuOS Build Method — Deep-Module Discipline Hook (PreToolUse)
4
+ #
5
+ # Blocks Write / Edit / MultiEdit / NotebookEdit when:
6
+ # (a) the target file is a source file under a recognised source tree,
7
+ # AND
8
+ # (b) the catalogue has an architecture register at
9
+ # docs/build/architecture/ with at least one module filed, AND
10
+ # (c) the target path is NOT claimed by any module's `## Paths claimed`
11
+ # block.
12
+ #
13
+ # Rationale
14
+ # ─────────
15
+ # The catalogue's value over a long build depends on the project staying
16
+ # built from DEEP modules — small interface, large hidden body. The most
17
+ # reliable failure mode is an agent quietly creating a shallow module
18
+ # mid-implementation: a new `src/foo/` directory, a `utils.ts`, a thin
19
+ # wrapper "to keep things tidy." Once written, these are permanent —
20
+ # every later WU builds against them and un-splitting them is too costly
21
+ # to ever happen.
22
+ #
23
+ # The /wu-new intake gate and the /build-wu architectural quality gate
24
+ # are the conversational defences. This hook is the mechanical one. It
25
+ # reads the architecture register to learn which source paths are
26
+ # claimed by which module, and blocks any write to an unclaimed source
27
+ # path. The agent's only recovery path is to (a) extend the relevant
28
+ # module's `Paths claimed`, or (b) propose a new module via the
29
+ # architect (which itself must justify depth before it can claim paths).
30
+ #
31
+ # Doctrine: docs/philosophy/deep-modules.md
32
+ #
33
+ # Degrade-safe behaviour
34
+ # ──────────────────────
35
+ # The hook exits 0 (allow) without enforcement when:
36
+ # • CLAUDE_PROJECT_DIR is unset and no git root is detectable
37
+ # • the architecture register does not exist
38
+ # • the architecture register exists but contains no module files
39
+ # (only _index.md / module-template.md — project is pre-architecture)
40
+ # • the target file is not a source file (configs, docs, scripts, tests)
41
+ # • the target file lives inside the catalogue itself
42
+ # • the JSON tool input cannot be parsed
43
+ # • jq and python3 are both unavailable
44
+ #
45
+ # "Better to wave a violation through than to block a legitimate write
46
+ # the operator did not anticipate" — the conversational gates catch
47
+ # most things; this hook is the safety net, not the only defence.
48
+ #
49
+ # Override
50
+ # ────────
51
+ # Set NUOS_SKIP_MODULE_DISCIPLINE=1 in the environment for one tool call
52
+ # to bypass. Logged to .nuos-enforcement.log for the audit trail.
53
+ #
54
+ # Exit codes
55
+ # ──────────
56
+ # 0 — allow (or degrade-safe)
57
+ # 2 — block (Claude Code surfaces stderr to the model)
58
+
59
+ set -uo pipefail
60
+
61
+ INPUT="$(cat 2>/dev/null || true)"
62
+
63
+ # ── Project root ──────────────────────────────────────────────────────────────
64
+ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-}"
65
+ if [[ -z "$PROJECT_ROOT" ]]; then
66
+ PROJECT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
67
+ fi
68
+ if [[ -z "$PROJECT_ROOT" ]]; then exit 0; fi
69
+
70
+ LOG="$PROJECT_ROOT/.nuos-enforcement.log"
71
+
72
+ log_event() {
73
+ printf '%s | %s | %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$1" "$2" >> "$LOG" 2>/dev/null || true
74
+ }
75
+
76
+ # ── Override ──────────────────────────────────────────────────────────────────
77
+ if [[ "${NUOS_SKIP_MODULE_DISCIPLINE:-}" == "1" ]]; then
78
+ log_event "module-discipline-bypassed" "${PWD}"
79
+ printf '⚠ nuos: NUOS_SKIP_MODULE_DISCIPLINE=1 — module-discipline check skipped.\n' >&2
80
+ exit 0
81
+ fi
82
+
83
+ # ── Extract file path ─────────────────────────────────────────────────────────
84
+ FILE=$(printf '%s' "$INPUT" \
85
+ | grep -oE '"(file_path|notebook_path)"[[:space:]]*:[[:space:]]*"[^"]+"' \
86
+ | head -1 \
87
+ | sed -E 's/"(file_path|notebook_path)"[[:space:]]*:[[:space:]]*"//' \
88
+ | tr -d '"')
89
+
90
+ if [[ -z "${FILE:-}" ]]; then exit 0; fi
91
+
92
+ # Normalise to absolute
93
+ case "$FILE" in
94
+ /*) ABSOLUTE_FILE="$FILE" ;;
95
+ *) ABSOLUTE_FILE="$(pwd)/$FILE" ;;
96
+ esac
97
+
98
+ # ── Skip the catalogue itself ─────────────────────────────────────────────────
99
+ # Writes inside docs/build/, docs/philosophy/, docs/guides/, docs/contracts/,
100
+ # .claude/, .agents/, .opencode/, templates/, scripts/ etc. are catalogue
101
+ # work — never module-implementation work.
102
+ case "$ABSOLUTE_FILE" in
103
+ */docs/*|*/.claude/*|*/.agents/*|*/.opencode/*|*/.nuos-catalogue/*|*/CLAUDE.md|*/README.md|*/CHANGELOG.md)
104
+ exit 0 ;;
105
+ esac
106
+
107
+ # Skip common non-source paths
108
+ case "$ABSOLUTE_FILE" in
109
+ */node_modules/*|*/dist/*|*/.next/*|*/build/*|*/.nuxt/*|*/coverage/*|*/.git/*|*/.turbo/*|*/.vercel/*)
110
+ exit 0 ;;
111
+ */tests/*|*/test/*|*/__tests__/*|*/e2e/*|*/spec/*|*/__mocks__/*|*/fixtures/*)
112
+ exit 0 ;;
113
+ */scripts/*|*/bin/*|*/migrations/*|*/seed/*|*/seeds/*)
114
+ exit 0 ;;
115
+ */public/*|*/static/*|*/assets/*)
116
+ exit 0 ;;
117
+ esac
118
+
119
+ # Skip non-source file types
120
+ case "$ABSOLUTE_FILE" in
121
+ *.test.ts|*.test.tsx|*.test.js|*.test.jsx|*.test.mjs|*.test.cjs)
122
+ exit 0 ;;
123
+ *.spec.ts|*.spec.tsx|*.spec.js|*.spec.jsx|*.spec.mjs|*.spec.cjs)
124
+ exit 0 ;;
125
+ *.d.ts|*.config.ts|*.config.js|*.config.mjs|*.config.cjs)
126
+ exit 0 ;;
127
+ *.json|*.yaml|*.yml|*.toml|*.ini|*.env|*.md|*.txt|*.lock|*.sum|*.mod)
128
+ exit 0 ;;
129
+ *.png|*.jpg|*.jpeg|*.gif|*.svg|*.ico|*.webp|*.avif|*.woff|*.woff2|*.ttf|*.otf|*.eot)
130
+ exit 0 ;;
131
+ *.sh|*.bash|*.zsh|*.fish|*.ps1|*.bat|*.cmd)
132
+ exit 0 ;;
133
+ *.lock|*Dockerfile*|*.dockerignore|*.gitignore|*.gitattributes|*.npmignore)
134
+ exit 0 ;;
135
+ esac
136
+
137
+ # Only enforce on these source extensions
138
+ EXTENSION="${ABSOLUTE_FILE##*.}"
139
+ case "$EXTENSION" in
140
+ ts|tsx|js|jsx|mjs|cjs|py|rb|go|rs|java|kt|swift|cs|c|cpp|cc|h|hpp|php|ex|exs|erl|hs|ml|scala|clj|elm) : ;;
141
+ vue|svelte|astro) : ;;
142
+ *) exit 0 ;;
143
+ esac
144
+
145
+ # ── Find the architecture register ────────────────────────────────────────────
146
+ ARCH_DIR=""
147
+ if [[ -d "$PROJECT_ROOT/docs/build/architecture" ]]; then
148
+ ARCH_DIR="$PROJECT_ROOT/docs/build/architecture"
149
+ else
150
+ # Split-repo pattern: look for a sibling catalogue
151
+ PARENT="$(dirname "$PROJECT_ROOT")"
152
+ for sibling in "$PARENT"/*/docs/build/architecture; do
153
+ if [[ -d "$sibling" ]]; then
154
+ ARCH_DIR="$sibling"
155
+ break
156
+ fi
157
+ done
158
+ fi
159
+
160
+ # No architecture register → project is pre-architecture; degrade-safe.
161
+ if [[ -z "$ARCH_DIR" ]]; then exit 0; fi
162
+
163
+ # Collect module files (everything except _index.md and module-template.md).
164
+ shopt -s nullglob
165
+ MODULE_FILES=()
166
+ for f in "$ARCH_DIR"/*.md; do
167
+ base="$(basename "$f")"
168
+ case "$base" in
169
+ _index.md|module-template.md) continue ;;
170
+ esac
171
+ MODULE_FILES+=("$f")
172
+ done
173
+ shopt -u nullglob
174
+
175
+ # No modules filed yet → degrade-safe.
176
+ if [[ ${#MODULE_FILES[@]} -eq 0 ]]; then exit 0; fi
177
+
178
+ # ── Compute the target's path relative to its repo root ───────────────────────
179
+ # In-repo case: relative to PROJECT_ROOT.
180
+ # Sibling-repo case: relative to the sibling's git toplevel.
181
+ TARGET_REPO_ROOT=""
182
+ case "$ABSOLUTE_FILE/" in
183
+ "$PROJECT_ROOT"/*)
184
+ TARGET_REPO_ROOT="$PROJECT_ROOT" ;;
185
+ *)
186
+ # Resolve the sibling repo root by asking git from the file's directory.
187
+ TARGET_DIR="$(dirname "$ABSOLUTE_FILE")"
188
+ if [[ -d "$TARGET_DIR" ]]; then
189
+ TARGET_REPO_ROOT="$(git -C "$TARGET_DIR" rev-parse --show-toplevel 2>/dev/null || true)"
190
+ fi
191
+ ;;
192
+ esac
193
+
194
+ # Couldn't resolve a repo root for the target → degrade-safe.
195
+ if [[ -z "$TARGET_REPO_ROOT" ]]; then exit 0; fi
196
+
197
+ # Strip the repo root prefix → relative path.
198
+ RELATIVE_TARGET="${ABSOLUTE_FILE#$TARGET_REPO_ROOT/}"
199
+
200
+ # If stripping didn't change anything, we couldn't compute a relative path.
201
+ if [[ "$RELATIVE_TARGET" == "$ABSOLUTE_FILE" ]]; then exit 0; fi
202
+
203
+ # Repo-root-level files (no directory) are never source modules.
204
+ case "$RELATIVE_TARGET" in
205
+ */*) : ;;
206
+ *) exit 0 ;;
207
+ esac
208
+
209
+ # ── Extract all claimed paths from every module file ──────────────────────────
210
+ # A module file's "Paths claimed" section looks like:
211
+ #
212
+ # ## Paths claimed
213
+ #
214
+ # > *Required. List the source-tree paths...*
215
+ #
216
+ # - `src/auth/`
217
+ # - `apps/web/src/auth/**`
218
+ #
219
+ # We collect every bullet-list entry under that heading, stripping
220
+ # backticks, quotes, and trailing glob/slash suffixes to derive a prefix
221
+ # we can match against.
222
+
223
+ extract_claims() {
224
+ local file="$1"
225
+ awk '
226
+ BEGIN { in_section = 0 }
227
+ /^## Paths claimed/ { in_section = 1; next }
228
+ /^## / && in_section { in_section = 0 }
229
+ in_section {
230
+ # bullet line: leading - or * followed by content
231
+ if (match($0, /^[[:space:]]*[-*][[:space:]]+/)) {
232
+ line = substr($0, RSTART + RLENGTH)
233
+ # strip blockquote markers / italics that may leak from template hints
234
+ gsub(/^[[:space:]]*\*[[:space:]]*/, "", line)
235
+ # take only the first backticked token if present, else first whitespace-free token
236
+ if (match(line, /`[^`]+`/)) {
237
+ token = substr(line, RSTART + 1, RLENGTH - 2)
238
+ } else {
239
+ # first whitespace-delimited token
240
+ n = split(line, parts, /[[:space:]]+/)
241
+ token = parts[1]
242
+ }
243
+ # ignore template placeholders like [module-slug]
244
+ if (token ~ /^\[/) next
245
+ if (token == "") next
246
+ print token
247
+ }
248
+ }
249
+ ' "$file"
250
+ }
251
+
252
+ CLAIMS=()
253
+ CLAIM_OWNERS=() # parallel array: which module file each claim came from
254
+ for mf in "${MODULE_FILES[@]}"; do
255
+ while IFS= read -r claim; do
256
+ [[ -z "$claim" ]] && continue
257
+ CLAIMS+=("$claim")
258
+ CLAIM_OWNERS+=("$(basename "$mf" .md)")
259
+ done < <(extract_claims "$mf")
260
+ done
261
+
262
+ # No claims declared anywhere → degrade-safe (modules exist but none have
263
+ # populated Paths claimed yet — treat as still bootstrapping).
264
+ if [[ ${#CLAIMS[@]} -eq 0 ]]; then exit 0; fi
265
+
266
+ # ── Match target against claims ──────────────────────────────────────────────
267
+ # Normalise a claim into a prefix: strip leading `./`, trailing `/**`,
268
+ # `/*`, or `/`. Match if RELATIVE_TARGET starts with the prefix followed
269
+ # by `/` or equals the prefix exactly.
270
+
271
+ normalise_claim() {
272
+ local c="$1"
273
+ c="${c#./}"
274
+ c="${c%/}"
275
+ c="${c%/\*\*}"
276
+ c="${c%/\*}"
277
+ printf '%s' "$c"
278
+ }
279
+
280
+ MATCHED=0
281
+ for claim in "${CLAIMS[@]}"; do
282
+ prefix="$(normalise_claim "$claim")"
283
+ [[ -z "$prefix" ]] && continue
284
+ if [[ "$RELATIVE_TARGET" == "$prefix" || "$RELATIVE_TARGET" == "$prefix"/* ]]; then
285
+ MATCHED=1
286
+ break
287
+ fi
288
+ done
289
+
290
+ if [[ "$MATCHED" == "1" ]]; then
291
+ exit 0
292
+ fi
293
+
294
+ # ── Block ─────────────────────────────────────────────────────────────────────
295
+ log_event "module-discipline-blocked" "$ABSOLUTE_FILE"
296
+
297
+ # Build the "available modules" summary for the error message.
298
+ MODULE_SUMMARY=""
299
+ for mf in "${MODULE_FILES[@]}"; do
300
+ slug="$(basename "$mf" .md)"
301
+ # Pull the first non-blank line under "## What this module does".
302
+ desc=$(awk '
303
+ BEGIN { in_section = 0; printed = 0 }
304
+ /^## What this module does/ { in_section = 1; next }
305
+ /^## / && in_section { exit }
306
+ in_section && printed == 0 && NF > 0 && $0 !~ /^>/ {
307
+ print
308
+ printed = 1
309
+ exit
310
+ }
311
+ ' "$mf" 2>/dev/null | head -c 140)
312
+ MODULE_SUMMARY+="
313
+ • $slug — ${desc:-(no summary)}"
314
+ done
315
+
316
+ cat >&2 <<EOF
317
+ ✖ nuos: deep-module discipline block — unclaimed source path.
318
+
319
+ Target file: $RELATIVE_TARGET
320
+ (resolved to: $ABSOLUTE_FILE)
321
+
322
+ This path is not claimed by any module in
323
+ $ARCH_DIR
324
+
325
+ The doctrine: every source file lives inside a deep module that
326
+ explicitly claims it under '## Paths claimed' in its architecture
327
+ file. New unclaimed source paths are the failure mode that creates
328
+ shallow modules (util grab-bags, pass-through wrappers, premature
329
+ splits). See docs/philosophy/deep-modules.md.
330
+
331
+ Modules currently filed:$MODULE_SUMMARY
332
+
333
+ ── Required action ──────────────────────────────────────────────────────────
334
+
335
+ Pick one:
336
+
337
+ 1. The work belongs in an EXISTING module above. Open that
338
+ module's architecture file and add the path under
339
+ '## Paths claimed', then retry the write.
340
+
341
+ 2. The work is genuinely a NEW deep module. STOP this write.
342
+ Run the architect to propose the module (interface surface,
343
+ hidden complexity, depth justification, paths claimed),
344
+ file docs/build/architecture/<slug>.md from
345
+ module-template.md, THEN retry.
346
+
347
+ Never: invent a name like 'utils', 'helpers', 'common', 'shared',
348
+ 'lib', or 'misc' to bypass this gate — those are shallow-module
349
+ patterns and will be rejected by the architectural quality gate.
350
+
351
+ Override (logged): NUOS_SKIP_MODULE_DISCIPLINE=1 for one call.
352
+
353
+ EOF
354
+
355
+ exit 2
@@ -37,6 +37,14 @@ nuos-catalogue memory search --query="<the module or contract name being worked
37
37
 
38
38
  Surface any high-score memories (> 0.8) to the relevant agents as additional context in their spawn prompt. Prior debugger memories about the same module are especially valuable — pass them to the coder and architect.
39
39
 
40
+ ## Step 1.5 — Load the owning module (mandatory)
41
+
42
+ The WU must have a `Module:` field set (added by the `/wu-new` deep-module intake gate). Read it.
43
+
44
+ - **If the field is set**, read `docs/build/architecture/<module-slug>.md` in full — especially `Interface surface`, `Hidden complexity`, and `Paths claimed`. Every agent spawned for this WU must receive the architecture file as required reading in their spawn prompt. The coder must not touch any source path that is not listed in the module's `Paths claimed` block (the `check-module-discipline.sh` PreToolUse hook will block them otherwise).
45
+
46
+ - **If the field is missing** (legacy WUs filed before the intake gate, or a hand-filed WU that skipped the gate), STOP. Tell the operator: *"This WU has no module assigned. The deep-module discipline requires every WU to declare which module it lives in. Want me to walk through the intake gate now — pick from existing modules or have the architect propose a new one — before the swarm proceeds?"* Do not classify or spawn anything until `Module:` is set and the architecture file is read.
47
+
40
48
  ## Step 2 — Classify the work
41
49
 
42
50
  Decide what shape this work is. Most work units fall into one of these patterns:
@@ -182,6 +190,20 @@ If `methodfile.json` has `e2e.enabled: true`, run the Playwright test suite from
182
190
 
183
191
  If `methodfile.json` has no `e2e` section or `e2e.enabled: false`, skip this gate but note in the audit entry: *"Playwright gate skipped — e2e not configured in methodfile.json"*. For UI-surfacing work units (any WU that adds or changes a page, component, or user interaction), prompt the developer: *"This WU ships a UI change but no Playwright spec exists. Want me to file a follow-up WU to add e2e coverage for this surface?"*
184
192
 
193
+ ## Step 5.7 — Code-quality lite gate (only if the coder touched source)
194
+
195
+ Runs after the test gates pass, before promotion. Pure self-check by the coordinator on the coder's staged diff. Skip entirely if the WU was design-only or only touched docs/registers.
196
+
197
+ Against `git diff --name-only <base>...HEAD` filtered to source files (same filter as Step 5.5 Gate B), scan for these three lite-gate items only:
198
+
199
+ 1. **1k-line cross** — did this WU push any file from under 1000 lines to over 1000 lines? If yes, surface to the operator: *"WU N pushed [file] from X → Y lines. Decompose before promotion, or accept the sprawl?"* Don't auto-decompose; this is an operator call.
200
+ 2. **Spaghetti** — does the diff add ad-hoc conditionals, one-off booleans, or special-case branches bolted into unrelated flows? If yes, send back to the coder with: *"This adds a special-case branch into [flow]. Move it behind its own abstraction before promotion."*
201
+ 3. **Canonical-helper duplication** — does the diff introduce a bespoke helper that duplicates an existing utility the codebase already has? If yes, send back to the coder with the path to the canonical helper.
202
+
203
+ These are the three highest-yield checks from the full thermo-nuclear code-quality rubric. The full rubric runs at end-of-session against the staged commit diff (see [end-of-session.md](end-of-session.md) Step 10) and escalates to `/thermo-nuclear-code-quality-review` for the harsh pass. The lite gate here is the cheap-early-warning so structural mistakes are caught before they layer with downstream agent output.
204
+
205
+ Record `✓ code-quality lite gate passed` (or the trigger if it fired) in the swarm audit entry under `## Gate triggers`.
206
+
185
207
  ## Step 6 — Record the swarm run
186
208
 
187
209
  Write an audit entry at `docs/build/swarm/YYYY-MM-DD-wu-<handle>.md`. Use the template at `docs/build/swarm/_template.md`. Capture:
@@ -272,6 +294,29 @@ Before routing the architect's brief to the coder, read it for shortcut indicato
272
294
 
273
295
  Do not feel time or cost pressure. A proper design that takes longer is always preferred over a shortcut that ships sooner. Routing a shortcut brief to the coder does not save time — it produces code the reviewer will block, and the loop costs more than getting the design right once.
274
296
 
297
+ ### Deep-module gate (after architect, before coder — mandatory)
298
+
299
+ Runs alongside the architectural quality gate above. Reads the architect's brief specifically for module-depth violations. Doctrine: [docs/philosophy/deep-modules.md](../../starter-kit/docs/philosophy/deep-modules.md). This is also a **hard stop**.
300
+
301
+ **Before checking, confirm the WU has a `Module:` field set.** If the WU was filed without one (legacy WUs from before the intake gate, or a hand-filed WU that skipped `/wu-new`), STOP and route to the operator: *"This WU has no module assigned. Run the deep-module intake gate before the swarm can spawn — either pick an existing module from `docs/build/architecture/`, or have the architect propose a new one."* Do not let the swarm proceed without `Module:` set.
302
+
303
+ **Shallow-module red flags in the architect's brief — any one is a rejection:**
304
+
305
+ - **A new module is being proposed without its architecture file already filed.** The architect must produce `docs/build/architecture/<slug>.md` (using `module-template.md`) before the coder spawns. The file must have every field populated — including `Interface surface`, `Hidden complexity`, `Depth justification`, and `Paths claimed`. A brief that says "we'll file the architecture entry after coding" is a rejection.
306
+ - **A new module whose `Interface surface` is roughly as wide as its `Hidden complexity`.** Count the items; if interface ≥ hidden, the module is shallow. Reject and tell the architect: *"This module's interface is not narrower than its body — it has no depth. Either fold this work into an existing module whose hidden complexity it actually serves, or expand the hidden body to justify the boundary."*
307
+ - **A new module named `utils`, `helpers`, `common`, `shared`, `lib`, `misc`, or any variant.** These names signal grab-bags by construction. Reject. Tell the architect: *"This project has no utils module by design. The work this names must live inside the module whose hidden complexity needs it. Which module is that?"*
308
+ - **A new module that is a pass-through wrapper** — its public methods each call exactly one method in another module. Reject; the wrapping module adds interface cost without adding encapsulation.
309
+ - **A new module that re-exports or thinly adapts another module.** Reject; rename or adapt at the existing module instead.
310
+ - **A design that splits one coherent responsibility across two new modules** to "separate concerns" when those concerns are not actually separable in the runtime. Reject; one deep module is better than two shallow ones.
311
+ - **The brief touches source paths not claimed by any module's `## Paths claimed` section.** Reject; either update the owning module's claimed paths in the brief, or run the new-module flow first.
312
+ - **The architect's brief proposes a new module when an existing module's `Hidden complexity` plausibly covers the responsibility.** Reject and ask the architect to either (a) demonstrate the responsibility does *not* fit, with specifics, or (b) fold the work into the existing module.
313
+
314
+ **When you find a deep-module red flag**, send the brief back with this instruction (adapt to the specific finding):
315
+
316
+ > "The brief proposes [quote the specific shallow pattern]. This project is built from deep modules — small interface, large hidden body. A shallow module ships permanent overhead. Either fold the work into [name the existing deep module that could absorb it], or produce the full module proposal (interface surface, hidden complexity, depth justification, paths claimed) that proves this is genuinely a deep module. Read `docs/philosophy/deep-modules.md` before retrying."
317
+
318
+ When the gate passes — both architectural quality and deep-module — record `✓ deep-module gate passed` in the swarm audit entry under `## Gate triggers`. When it fails, record the trigger and the retry.
319
+
275
320
  - **Time ceiling per agent.** If a run exceeds its rough budget (architect >1h, coder >2h, tester >1h, reviewer >30m), don't kill the agent (loses in-flight work) — surface the duration and ask whether to continue, redirect, or escalate (e.g. coder stuck → debugger).
276
321
  - **Architectural drift.** If the coder or tester surfaces a design choice not in the architect's brief, STOP, route to the architect for a decision before re-spawning. Coders making design calls inline is the failure mode the swarm exists to prevent.
277
322
  - **Midpoint coherence check** (full-feature swarms). After coder finishes, before tester spawns: are file paths and contracts the architect named present in the coder's output? If misaligned, escalate before spending tester tokens.
@@ -84,7 +84,21 @@ Before committing, scan:
84
84
  - Cross-references resolve (no dead links)
85
85
  - Dates are right
86
86
 
87
- ### 10. Commit
87
+ ### 10. Code-quality gate (only if staged diff touches source)
88
+
89
+ If `git diff --cached --name-only` shows source files (`*.ts`, `*.tsx`, `*.js`, `*.jsx`, `*.sh`, `*.py` — NOT markdown, decisions, sessions, or registers), run this 7-point pass against the staged diff before committing. If only docs/registers are staged, skip the gate entirely.
90
+
91
+ 1. **Code-judo** — is there a reframing that *deletes* whole branches/helpers/layers rather than rearranging them? Push for the simpler model, not the cleaner version of the same model.
92
+ 2. **1k-line rule** — did any file cross 1000 lines? If yes, decompose first; don't ship the sprawl.
93
+ 3. **Spaghetti growth** — new ad-hoc conditionals, one-off booleans, or special-case branches bolted into unrelated flows? Push the logic behind its own abstraction.
94
+ 4. **Boundaries** — feature-specific logic leaking into shared paths; bespoke helpers duplicating a canonical one; logic landing in the wrong layer/package.
95
+ 5. **Types** — unnecessary `any` / `unknown` / optionality / casts masking the real contract? Make boundaries explicit.
96
+ 6. **Atomicity** — serial async where parallel is simpler and clearer; partial-update flows that can leave state half-applied.
97
+ 7. **Wrappers** — thin abstractions, identity passthroughs, or pass-through helpers adding indirection without buying clarity?
98
+
99
+ If a finding is non-trivial, fix it inline or file a follow-up WU before committing. **Escalate to the full rubric** by invoking `/thermo-nuclear-code-quality-review` if the lite pass smells anything structural — the full skill is the "this should not ship as-is" reviewer; the 7-point pass above is the cheap pre-flight.
100
+
101
+ ### 11. Commit
88
102
 
89
103
  Single commit. Message format:
90
104
 
@@ -27,6 +27,30 @@ Also ask:
27
27
 
28
28
  - **Which persona is this for?** Show them the list from `docs/build/personas/`. If none yet, file a persona first via `/persona-new`.
29
29
 
30
+ ## Step 2.5 — Deep-module intake gate (mandatory, non-negotiable)
31
+
32
+ > **This gate is the most important step in `/wu-new`. It is the single mechanism that prevents shallow-module sprawl as a long-running build progresses. Do not skip it. Do not abbreviate it. See [docs/philosophy/deep-modules.md](../../starter-kit/docs/philosophy/deep-modules.md) for the doctrine.**
33
+
34
+ Before filing the work unit, the operator must declare which module owns it. List every entry in `docs/build/architecture/` (read the `_index.md` table) and ask:
35
+
36
+ > *"Which existing module does this work unit live inside? Here's what we've got: [list each module + one-line `What this module does` summary]. Or — does this need a new module?"*
37
+
38
+ **Three possible answers:**
39
+
40
+ 1. **"It belongs to existing module X."** Read `docs/build/architecture/X.md`. Check that the WU's responsibility actually fits inside X's `Hidden complexity` — not just that file paths overlap. If yes, set `Module: X` in the WU. If the WU adds new source paths, also add them to X's `## Paths claimed` section in the same conversation.
41
+
42
+ 2. **"It needs a new module Y."** STOP. Do not file the WU yet. Tell the operator: *"A new module needs an architect pass before this WU can be filed. Want me to walk through proposing module Y now — its interface surface, hidden complexity, depth justification — and file the architecture entry first? Then we file the WU against the new module."* On confirmation, run the architect through the new-module flow: produce `docs/build/architecture/Y.md` from `module-template.md`, populate every field including `Paths claimed`, then return here and file the WU with `Module: Y`.
43
+
44
+ 3. **"I'm not sure — it could go in X or be its own thing Y."** This is the most dangerous case. Default to **fits inside X** and tell the operator why: *"Splitting too early creates a shallow module that's permanent; folding into X is reversible later. Let's put it in X. If three more WUs land there and a coherent sub-responsibility emerges, the architect can split it then."* Only override this default if the architect has explicitly justified the split in the conversation.
45
+
46
+ **Forbidden answers:**
47
+
48
+ - *"It's just a small utility / helper / shared bit."* — Util grab-bags are shallow modules by definition. Tell the operator: *"There's no `utils` module in this project by design — small bits live inside the module whose hidden complexity needs them. Which module's hidden complexity does this work serve?"*
49
+ - *"Let's figure it out during the build."* — The whole point of this gate is to decide upfront. Push back: *"The build agents need to know which module they're working inside before they start. Let's pick now — if it's wrong we can correct it before the coder spawns."*
50
+ - *"Skip this for now, we'll come back to it."* — There is no skipping. The WU does not get a number, does not get filed, until `Module:` is set.
51
+
52
+ **Set `Module:` in the WU file.** Both `001-template-simple.md` and `001-template-full.md` carry a `Module:` field in the header. The value is the architecture file slug (e.g., `auth`, `consolidation`, `morning-briefing`).
53
+
30
54
  ## Step 3 — Walk the full shape (only when --full or for infrastructure work)
31
55
 
32
56
  The full shape has the four fields above plus:
@@ -13,12 +13,18 @@ The **architecture** register describes the major pieces of {{PROJECT_NAME}} and
13
13
  For each major piece of your project:
14
14
 
15
15
  - **What it does** — a paragraph in plain language
16
+ - **Interface surface** — every public entry point, kept deliberately small
17
+ - **Hidden complexity** — what the module encapsulates that callers don't need to think about
18
+ - **Depth justification** — why this module's hidden body is genuinely large relative to its interface
19
+ - **Paths claimed** — source-tree paths this module owns (read by the `check-module-discipline.sh` hook)
16
20
  - **Who's responsible for it** — which persona or role uses it most directly
17
21
  - **What it depends on** — other modules, external services, hardware
18
22
  - **What depends on it** — what would break if this module went away
19
23
  - **Open questions about it** — anything unresolved about its shape
20
24
  - **Links to relevant contracts** — what this module produces and consumes
21
25
 
26
+ Every module in this register is a **deep module** by commitment. Shallow modules — pass-through wrappers, util grab-bags, micro-modules, premature splits — are rejected at the intake gate. See [deep-modules.md](../../philosophy/deep-modules.md) for the doctrine; the rule is enforced by the `/wu-new` intake gate, the `/build-wu` architectural quality gate, and the `check-module-discipline.sh` PreToolUse hook.
27
+
22
28
  Architecture files are *what's true about each piece*; they're not implementation specs. Implementation lives in code; this register lives in the catalogue.
23
29
 
24
30
  ## When the architecture register gets populated
@@ -12,6 +12,33 @@
12
12
 
13
13
  > Example: "The Overnight Consolidation module processes every interaction with a student during a school day and produces, by morning, a per-student plan ranked by need."
14
14
 
15
+ ## Interface surface
16
+
17
+ > *Small by design. List every public entry point another module can call: function names, exported types, HTTP routes, CLI commands, message types. If this list runs longer than a screen, the interface is wide — the module is at risk of being shallow. See [deep-modules.md](../../philosophy/deep-modules.md).*
18
+
19
+ - [public function / route / type]
20
+ - [...]
21
+
22
+ ## Hidden complexity
23
+
24
+ > *Large by design. List what this module encapsulates that callers do not have to think about: state, branching, external integrations, retry logic, validation, edge cases, persistence, ordering, concurrency. The depth ratio (hidden complexity ÷ interface surface) is what makes a module deep.*
25
+
26
+ - [thing hidden from callers]
27
+ - [...]
28
+
29
+ ## Depth justification
30
+
31
+ > *Required for every module. Answer in two or three sentences: why is the hidden complexity above genuinely larger than the interface surface above? If you cannot answer this, the module is shallow — fold its work into an existing module instead of filing a new one.*
32
+
33
+ [The depth argument.]
34
+
35
+ ## Paths claimed
36
+
37
+ > *Required. List the source-tree paths this module owns. The PreToolUse hook (`check-module-discipline.sh`) reads this section and blocks writes to source files not claimed by any module. Use directory prefixes (`src/auth/`) or glob-style patterns (`src/auth/**`). One per line, as a bullet.*
38
+
39
+ - `src/[module-slug]/`
40
+ - [...]
41
+
15
42
  ## Who uses it directly
16
43
 
17
44
  [List the personas who interact with this module via UI/UX surfaces. Each as `[P001](../personas/P001-name.md)` with a one-line note on how they use it.]
@@ -3,6 +3,7 @@
3
3
  | Field | Value |
4
4
  | --- | --- |
5
5
  | Status | 🟡 in flight |
6
+ | Module | [architecture slug — required; set at the `/wu-new` deep-module intake gate] |
6
7
  | Depends on | none |
7
8
  | Blocks | [WUs that cannot start until this lands] |
8
9
  | Implements | [the decision or pattern this realises] |
@@ -4,6 +4,7 @@
4
4
 
5
5
  **Status:** 🔵 proposed / 🟡 in flight / 🟣 awaiting review / ✅ shipped / 🔴 blocked
6
6
  **For:** [persona handle and name — e.g. [P001 — name](../personas/P001-name.md)]
7
+ **Module:** [architecture slug — e.g. [auth](../architecture/auth.md). Required. Set at the `/wu-new` deep-module intake gate.]
7
8
  **Last updated:** {{TODAY}}
8
9
 
9
10
  ## What's done when this ships
@@ -12,7 +12,7 @@ That is when a philosophy doc becomes worth writing. One short narrative-form do
12
12
 
13
13
  | Doc | Topic |
14
14
  | --- | --- |
15
- | _none yet philosophy emerges as decisions accumulate_ | |
15
+ | [deep-modules.md](deep-modules.md) | Every feature lives inside an existing deep module or constitutes a new one with stated interface, hidden complexity, and depth justification. Non-negotiable. |
16
16
 
17
17
  ## Pattern
18
18
 
@@ -0,0 +1,82 @@
1
+ # Deep modules
2
+
3
+ > The architectural commitment that this project is built from **deep modules** — small interface, large hidden complexity — and never from shallow ones. This is the most load-bearing decision in the project's shape, and the rule cannot be broken as the build progresses.
4
+
5
+ ## What a deep module is
6
+
7
+ A deep module is a chunk of the system with:
8
+
9
+ - **A small interface** — few public functions, few public types, few entry points. What other modules need to know to use it is small.
10
+ - **A large hidden body** — significant complexity, state, branching, integration with external systems, edge-case handling, all *behind* the interface.
11
+
12
+ The asymmetry between interface and body is the **depth ratio**. A deep module hides a lot behind a little. A shallow module hides little behind a little — its interface is roughly as wide as its implementation, so the module is almost pure overhead.
13
+
14
+ Ousterhout's *A Philosophy of Software Design* names this directly: **the best modules are deep**. They reduce the total cognitive load of the system because every caller pays a small interface cost in exchange for a large hidden cost they no longer have to think about.
15
+
16
+ ## What a shallow module looks like
17
+
18
+ These are the failure modes this project rejects:
19
+
20
+ - **The pass-through** — a module whose public methods each call exactly one method in another module, adding nothing. Wraps without hiding.
21
+ - **The util / helper grab-bag** — `utils.ts`, `helpers/`, `common/`. A directory of unrelated functions sharing only the property that nobody knew where else to put them. No hidden complexity, no coherent responsibility.
22
+ - **The leaky abstraction** — the interface exposes the implementation's data structures, internal types, or assumptions. Callers must understand the implementation to use the interface correctly. The hidden body is not actually hidden.
23
+ - **The thin wrapper** — a module that exists to rename, re-export, or thinly adapt another module. Adds a layer of indirection without adding encapsulation.
24
+ - **The micro-module** — three files, four functions, one responsibility that should have lived inside a larger module. The boundary itself becomes the overhead.
25
+ - **The premature split** — taking a feature whose complexity could have been absorbed by an existing deep module and giving it its own module *just because it's new*. Splits cohesion without earning depth.
26
+
27
+ When a shallow module appears, the system pays the interface cost (boundaries, contracts, imports, indirection) without earning the encapsulation benefit. Shallow modules accumulate; the system becomes a sprawl of thin layers that hide nothing and force every change to thread through many files.
28
+
29
+ ## The rule, in one sentence
30
+
31
+ > **Every new feature added during the build either lives inside an existing deep module, or constitutes a new deep module with a stated interface, stated hidden complexity, and a stated depth justification. There is no third option.**
32
+
33
+ This rule is non-negotiable. It applies to every work unit. It applies whether the work is greenfield or a follow-on. It applies whether the operator is in `lite`, `standard`, or `power` mode. It is enforced by the protocols (intake gate in `/wu-new`, architectural quality gate in `/build-wu`, audit by the reviewer) and by a Claude PreToolUse hook (`check-module-discipline.sh`) that blocks writes to source paths not claimed by any module.
34
+
35
+ ## How to apply the rule when filing a work unit
36
+
37
+ When `/wu-new` runs, the operator is asked which module the work belongs to. Three answers are possible:
38
+
39
+ 1. **"It belongs to module X (existing)."** Check that the WU's responsibility actually fits inside X's hidden complexity — not just that the file paths happen to overlap. If yes, the WU's `Module:` field is set to `X` and the work proceeds. The architecture file for X is updated if the WU adds new paths or surfaces.
40
+
41
+ 2. **"It needs a new module Y."** The architect runs first, before the WU is filed. They produce a contract for Y: its small interface, its large hidden complexity, the depth justification. The new module is filed in `docs/build/architecture/Y.md`, the relevant contract(s) are filed in `docs/build/contracts/`, and only then is the WU filed with `Module: Y`.
42
+
43
+ 3. **"I'm not sure — it could go in X or it could be Y."** This is the most common case and the most dangerous. Default to **fits inside X** until the architect explicitly justifies the split. The cost of an under-split module is rework; the cost of an over-split system is permanent shallow sprawl that compounds. The first error is cheap to fix, the second is not.
44
+
45
+ ## How to apply the rule when designing
46
+
47
+ The architect's brief, for any work unit, must answer three questions before the coder spawns:
48
+
49
+ 1. **Which module owns this?** Named, with a link to its architecture file.
50
+ 2. **What does the interface look like after this change?** The new public methods/exports/routes — explicit, small, named.
51
+ 3. **What complexity is hidden behind that interface?** The state, the branching, the external integrations, the edge cases the caller does not have to think about.
52
+
53
+ If the third answer is small relative to the second, the design is shallow. The architect must either fold the work into an existing deep module (so the new complexity joins existing hidden complexity) or expand the hidden body (more is genuinely encapsulated here) — never ship a shallow new module.
54
+
55
+ ## How to apply the rule during implementation
56
+
57
+ The coder may not create a new top-level source directory unless a corresponding architecture file claims it under `## Paths claimed`. The PreToolUse hook enforces this. If the coder finds themselves wanting to create `src/foo/` mid-implementation, that is a signal — pause, route back to the architect, get the module filed (or get `foo/` claimed by an existing module) before the file is written.
58
+
59
+ This is friction by design. Most shallow modules are born from a coder's mid-flight decision to "just split this out." The hook makes that decision visible and routes it through the architect.
60
+
61
+ ## How to apply the rule during review
62
+
63
+ The reviewer reads every change against three checks:
64
+
65
+ - **Did this work create a new module?** If yes, is the architecture file present, is the interface small, is the hidden complexity large, is the depth justification recorded?
66
+ - **Did this work extend an existing module?** If yes, does the extension actually live inside the module's hidden complexity — or is it leaking into the interface in a way that makes the module shallower than it was?
67
+ - **Did this work add code to an unclaimed path?** If yes, the hook should already have caught it; if it didn't, that's a hook gap to file.
68
+
69
+ A change that creates or worsens a shallow module is a `REQUEST CHANGES`, not an `APPROVE`. Speed of delivery is never an acceptable reason to ship a shallow module — see the no-shortcuts policy that already governs the architect's brief.
70
+
71
+ ## Why this matters more than most architectural rules
72
+
73
+ Most architectural commitments can be re-evaluated when the situation changes. Module depth cannot. A shallow module ships interface contracts, file paths, imports, and tests that callers build against — un-splitting it later means rewriting all of them. By the time the cost of the wrong split is felt, the cost of fixing it is large enough that it never gets fixed. The shallow split becomes permanent.
74
+
75
+ So the discipline is **enforce at intake**, not at cleanup. The intake gate is the cheap moment. Every other moment is more expensive.
76
+
77
+ ## Related
78
+
79
+ - The no-shortcuts policy (architect.md, build-wu.md) — shallow modules are a class of shortcut
80
+ - The design-it-twice rule (architect.md) — one of the two designs must explore folding into an existing module
81
+ - The module template (`../build/architecture/module-template.md`) — the contract every new module must fill in
82
+ - The module-discipline hook (`.claude/hooks/check-module-discipline.sh`) — the mechanical gate