@polymorphism-tech/morph-spec 8.15.1 → 8.16.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.
Files changed (48) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +8 -0
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +2 -1
  7. package/framework/MORPH.md +3 -3
  8. package/framework/hooks/README.md +3 -3
  9. package/framework/hooks/claude-code/core/chain.js +123 -0
  10. package/framework/hooks/claude-code/core/enforce-phase-writes.js +84 -77
  11. package/framework/hooks/claude-code/core/gate-guard.js +21 -25
  12. package/framework/hooks/claude-code/core/loop-detect.js +70 -64
  13. package/framework/hooks/claude-code/core/post-edit-typecheck.js +94 -45
  14. package/framework/hooks/claude-code/core/pre-bash-denylist.js +21 -15
  15. package/framework/hooks/claude-code/core/protect-spec-files.js +110 -103
  16. package/framework/hooks/claude-code/core/state-sync.js +32 -40
  17. package/framework/hooks/claude-code/core/tasks-json-guard.js +44 -44
  18. package/framework/hooks/claude-code/core/telemetry-log.js +151 -74
  19. package/framework/hooks/claude-code/core/trace-autogen.js +116 -6
  20. package/framework/hooks/claude-code/core/validate-completion.js +147 -107
  21. package/framework/hooks/claude-code/core/worktree-context.js +25 -22
  22. package/framework/hooks/claude-code/statusline.py +26 -4
  23. package/framework/hooks/shared/activity-logger.js +120 -69
  24. package/framework/hooks/shared/claim-registry.js +61 -1
  25. package/framework/hooks/shared/git-paths.js +94 -0
  26. package/framework/hooks/shared/hook-response.js +67 -0
  27. package/framework/hooks/shared/state-reader.js +95 -29
  28. package/framework/hooks/shared/state-writer.js +16 -0
  29. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  30. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  31. package/framework/skills/core/morph-apply/SKILL.md +1 -1
  32. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  33. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  34. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  35. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  36. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  37. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  38. package/framework/skills/core/morph-review/SKILL.md +1 -1
  39. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  40. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  41. package/package.json +1 -1
  42. package/src/commands/observability/cost.js +137 -0
  43. package/src/commands/state/approve.js +30 -9
  44. package/src/dashboard/scanner/hooks-scanner.js +11 -2
  45. package/src/lib/installers/memory-installer.js +1 -1
  46. package/src/lib/telemetry/telemetry-reader.js +213 -0
  47. package/src/utils/hooks-installer.js +84 -79
  48. package/framework/hooks/shared/session-snapshot.js +0 -105
@@ -1,21 +1,41 @@
1
1
  /**
2
2
  * Activity Logger — Session-scoped hook event log
3
3
  *
4
- * Writes hook activity to .morph/logs/activity.json for live consumption
5
- * by `morph-spec monitor`.
4
+ * Writes hook activity to .morph/logs/activity.jsonl for live consumption
5
+ * (statusline, `morph-spec fleet`, etc).
6
6
  *
7
7
  * Design constraints:
8
8
  * - Synchronous only (hooks run in process, no async overhead)
9
- * - Fail-silent (never throws — hooks must not be blocked by logger)
9
+ * - Fail-silent (never throws — hooks must not be blocked by the logger)
10
10
  * - ≤5ms per call (no retries, no network)
11
+ * - APPEND-ONLY: one `appendFileSync` line per event, O(1) per call. The
12
+ * previous format did a read-modify-write of the WHOLE JSON doc per call
13
+ * (logHookActivity/logEvent both read, mutated, and rewrote the entire
14
+ * file) — O(n²) across a session once tool calls number in the hundreds,
15
+ * with 6 hooks each calling this 3-5x per Write. Same discipline as
16
+ * telemetry-logger.js's events.jsonl.
11
17
  * - Windows-safe paths via path.join
18
+ *
19
+ * Exported names/signatures are unchanged from the previous doc-based
20
+ * implementation on purpose — hooks outside this file's ownership
21
+ * (gate-guard.js, tasks-json-guard.js, enforce-phase-writes.js,
22
+ * loop-detect.js, protect-spec-files.js, validate-completion.js) call
23
+ * logHookActivity/logEvent and must keep working untouched. Only the
24
+ * on-disk format changed.
12
25
  */
13
26
 
14
- import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'fs';
27
+ import {
28
+ appendFileSync, readFileSync, mkdirSync, existsSync, statSync, renameSync, writeFileSync,
29
+ } from 'fs';
15
30
  import { join } from 'path';
16
31
 
17
32
  const LOGS_DIR = '.morph/logs';
18
- const ACTIVITY_FILE = join(LOGS_DIR, 'activity.json');
33
+ export const ACTIVITY_FILE = join(LOGS_DIR, 'activity.jsonl');
34
+
35
+ // SessionStart (worktree-context.js) rotates the log once it crosses this
36
+ // size — the archive/rotation call site resetActivity()/archiveSession()
37
+ // were written for but never got until now.
38
+ export const ROTATE_THRESHOLD_BYTES = 2 * 1024 * 1024; // 2 MB
19
39
 
20
40
  function getActivityPath(projectPath) {
21
41
  return join(projectPath || process.cwd(), ACTIVITY_FILE);
@@ -28,12 +48,39 @@ function ensureLogsDir(projectPath) {
28
48
  }
29
49
  }
30
50
 
31
- function readRaw(activityPath) {
51
+ function nowTs() {
52
+ return new Date().toTimeString().slice(0, 8); // HH:MM:SS — matches the previous format
53
+ }
54
+
55
+ /** Append one JSON line. Fail-silent, O(1) regardless of file size. */
56
+ function appendLine(projectPath, line) {
57
+ try {
58
+ ensureLogsDir(projectPath);
59
+ appendFileSync(getActivityPath(projectPath), JSON.stringify(line) + '\n', 'utf-8');
60
+ } catch {
61
+ // Fail-silent — never block hooks
62
+ }
63
+ }
64
+
65
+ /** Read every parseable line back. A corrupt/truncated line is skipped, not fatal. */
66
+ function readLines(projectPath) {
32
67
  try {
33
- if (!existsSync(activityPath)) return null;
34
- return JSON.parse(readFileSync(activityPath, 'utf-8'));
68
+ const activityPath = getActivityPath(projectPath);
69
+ if (!existsSync(activityPath)) return [];
70
+ const raw = readFileSync(activityPath, 'utf-8');
71
+ const lines = [];
72
+ for (const l of raw.split('\n')) {
73
+ const trimmed = l.trim();
74
+ if (!trimmed) continue;
75
+ try {
76
+ lines.push(JSON.parse(trimmed));
77
+ } catch {
78
+ // skip a single corrupt line — don't lose the whole stream
79
+ }
80
+ }
81
+ return lines;
35
82
  } catch {
36
- return null;
83
+ return [];
37
84
  }
38
85
  }
39
86
 
@@ -46,42 +93,50 @@ function readRaw(activityPath) {
46
93
  * @param {string} [projectPath] - Project root (defaults to cwd)
47
94
  */
48
95
  export function logHookActivity(name, event, result, projectPath) {
49
- try {
50
- const activityPath = getActivityPath(projectPath);
51
- ensureLogsDir(projectPath);
52
-
53
- const now = new Date();
54
- const ts = now.toTimeString().slice(0, 8); // HH:MM:SS
55
-
56
- const data = readRaw(activityPath) || { sessionId: '', feature: '', phase: '', hooks: [], skills: [] };
57
-
58
- data.hooks = data.hooks || [];
59
- data.hooks.push({ name, event, result, ts });
60
-
61
- writeFileSync(activityPath, JSON.stringify(data, null, 2), 'utf-8');
62
- } catch {
63
- // Fail-silent — never block hooks
64
- }
96
+ appendLine(projectPath, { kind: 'hook', name, event, result, ts: nowTs() });
65
97
  }
66
98
 
99
+ /**
100
+ * Log a structured event to the session activity log.
101
+ *
102
+ * @param {string} type - Event type (e.g., 'task_start', 'loop_detected')
103
+ * @param {Object} data - Event data payload
104
+ * @param {string} [projectPath] - Project root (defaults to cwd)
105
+ */
106
+ export function logEvent(type, data, projectPath) {
107
+ appendLine(projectPath, { kind: 'event', type, data, ts: nowTs() });
108
+ }
67
109
 
68
110
  /**
69
- * Read the current activity log.
111
+ * Read the current activity log, reconstructed into the aggregate shape
112
+ * older consumers expect: `{ sessionId, feature, phase, hooks, events }`.
113
+ * `sessionId`/`feature`/`phase` come from the synthetic 'session' header
114
+ * line `resetActivity()` writes, when present.
70
115
  *
71
116
  * @param {string} [projectPath]
72
- * @returns {{ sessionId: string, feature: string, phase: string, hooks: Array, skills: Array }|null}
117
+ * @returns {{ sessionId: string, feature: string, phase: string, hooks: Array, events: Array }|null}
73
118
  */
74
119
  export function readActivity(projectPath) {
75
- try {
76
- return readRaw(getActivityPath(projectPath));
77
- } catch {
78
- return null;
79
- }
120
+ const lines = readLines(projectPath);
121
+ if (lines.length === 0) return null;
122
+
123
+ const session = lines.find((l) => l.kind === 'session') || {};
124
+ const strip = ({ kind, ...rest }) => rest;
125
+
126
+ return {
127
+ sessionId: session.sessionId || '',
128
+ feature: session.feature || '',
129
+ phase: session.phase || '',
130
+ hooks: lines.filter((l) => l.kind === 'hook').map(strip),
131
+ events: lines.filter((l) => l.kind === 'event').map(strip),
132
+ };
80
133
  }
81
134
 
82
135
  /**
83
- * Reset the activity log for a new session.
84
- * Called from SessionStart hook to clear stale data.
136
+ * Reset the activity log for a new session — the one operation in this file
137
+ * that is NOT append-only (a session boundary, not a log entry). Truncates
138
+ * the file and writes a 'session' header line so `readActivity()` can
139
+ * recover sessionId/feature/phase.
85
140
  *
86
141
  * @param {string} sessionId - ISO timestamp of new session
87
142
  * @param {string} feature - Active feature name (empty string if none)
@@ -91,70 +146,66 @@ export function readActivity(projectPath) {
91
146
  export function resetActivity(sessionId, feature, phase, projectPath) {
92
147
  try {
93
148
  ensureLogsDir(projectPath);
94
- const data = {
149
+ const session = {
150
+ kind: 'session',
95
151
  sessionId: sessionId || new Date().toISOString(),
96
152
  feature: feature || '',
97
153
  phase: phase || '',
98
- hooks: [],
99
- skills: []
100
154
  };
101
- writeFileSync(getActivityPath(projectPath), JSON.stringify(data, null, 2), 'utf-8');
155
+ writeFileSync(getActivityPath(projectPath), JSON.stringify(session) + '\n', 'utf-8');
102
156
  } catch {
103
157
  // Fail-silent
104
158
  }
105
159
  }
106
160
 
107
161
  /**
108
- * Log a structured event to the session activity log.
162
+ * Archive the current activity log to sessions/ for historical logging.
163
+ * Only archives when there is at least one hook or event line — a bare (or
164
+ * absent) log is not worth preserving. `renameSync` keeps this O(1) even at
165
+ * the multi-MB sizes rotation is meant to catch (no read+rewrite of content).
109
166
  *
110
- * @param {string} type - Event type (e.g., 'task_start', 'loop_detected')
111
- * @param {Object} data - Event data payload
112
167
  * @param {string} [projectPath] - Project root (defaults to cwd)
113
168
  */
114
- export function logEvent(type, data, projectPath) {
169
+ export function archiveSession(projectPath) {
115
170
  try {
116
171
  const activityPath = getActivityPath(projectPath);
117
- ensureLogsDir(projectPath);
172
+ if (!existsSync(activityPath)) return;
118
173
 
119
- const now = new Date();
120
- const ts = now.toTimeString().slice(0, 8); // HH:MM:SS
174
+ const lines = readLines(projectPath);
175
+ const hasMeaningfulContent = lines.some((l) => l.kind === 'hook' || l.kind === 'event');
176
+ if (!hasMeaningfulContent) return;
121
177
 
122
- const activity = readRaw(activityPath) || { sessionId: '', feature: '', phase: '', hooks: [], events: [] };
123
- activity.events = activity.events || [];
124
- activity.events.push({ type, data, ts });
178
+ const session = lines.find((l) => l.kind === 'session') || {};
179
+ const sessionsDir = join(projectPath || process.cwd(), LOGS_DIR, 'sessions');
180
+ mkdirSync(sessionsDir, { recursive: true });
125
181
 
126
- writeFileSync(activityPath, JSON.stringify(activity, null, 2), 'utf-8');
182
+ const stamp = (session.sessionId || new Date().toISOString()).replace(/[:.]/g, '-');
183
+ const archivePath = join(sessionsDir, `${stamp}.jsonl`);
184
+ renameSync(activityPath, archivePath);
127
185
  } catch {
128
186
  // Fail-silent
129
187
  }
130
188
  }
131
189
 
132
190
  /**
133
- * Archive current activity.json to sessions/ directory for historical logging.
134
- * Called at the start of a new session.
191
+ * Rotate the activity log when it crosses `thresholdBytes` — the real call
192
+ * site for archiveSession() (previously written but never invoked anywhere).
193
+ * Called from worktree-context.js on SessionStart, the only reliable
194
+ * "between sessions" checkpoint the harness gets.
135
195
  *
136
196
  * @param {string} [projectPath] - Project root (defaults to cwd)
197
+ * @param {number} [thresholdBytes] - Rotate once the log is at least this large
198
+ * @returns {boolean} true if a rotation was performed
137
199
  */
138
- export function archiveSession(projectPath) {
200
+ export function rotateActivityLogIfLarge(projectPath, thresholdBytes = ROTATE_THRESHOLD_BYTES) {
139
201
  try {
140
202
  const activityPath = getActivityPath(projectPath);
141
- if (!existsSync(activityPath)) return;
142
-
143
- const data = readRaw(activityPath);
144
- if (!data) return;
145
-
146
- // Only archive if there are meaningful events
147
- if ((!data.hooks || data.hooks.length === 0) && (!data.events || data.events.length === 0)) return;
148
-
149
- const sessionsDir = join(projectPath || process.cwd(), LOGS_DIR, 'sessions');
150
- mkdirSync(sessionsDir, { recursive: true });
151
-
152
- const sessionId = (data.sessionId || new Date().toISOString()).replace(/[:.]/g, '-');
153
- const archivePath = join(sessionsDir, `${sessionId}.json`);
154
- writeFileSync(archivePath, JSON.stringify(data, null, 2), 'utf-8');
155
-
156
- unlinkSync(activityPath);
203
+ if (!existsSync(activityPath)) return false;
204
+ const { size } = statSync(activityPath);
205
+ if (size < thresholdBytes) return false;
206
+ archiveSession(projectPath);
207
+ return true;
157
208
  } catch {
158
- // Fail-silent
209
+ return false;
159
210
  }
160
211
  }
@@ -26,7 +26,7 @@
26
26
  */
27
27
 
28
28
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
29
- import { join } from 'path';
29
+ import { dirname, join } from 'path';
30
30
 
31
31
  /**
32
32
  * A claim with no heartbeat for this long is considered dead. The heartbeat
@@ -119,6 +119,66 @@ export function heartbeatClaim(feature, { gitCommonDir, sessionId }) {
119
119
  return true;
120
120
  }
121
121
 
122
+ /** Local, git-independent heartbeat pre-check marker — see `shouldHeartbeat`. */
123
+ function heartbeatMarkPath(cwd) {
124
+ return join(cwd, '.morph', 'logs', '.heartbeat-check.json');
125
+ }
126
+
127
+ /**
128
+ * Cheap, git-independent pre-check for whether a heartbeat is even worth
129
+ * ATTEMPTING. `heartbeatClaim` already throttles the WRITE to `claims.json`,
130
+ * but that registry lives under the git COMMON dir — resolving it (even via
131
+ * the pure-FS `git-paths.js`, never mind the old `execSync('git rev-parse
132
+ * --git-common-dir')`) is work `telemetry-log.js` pays on every tool call.
133
+ * This answers "is it due" from a tiny marker colocated with the CALLER's own
134
+ * `.morph/logs/` (already known, no git lookup of any kind) so that on ~99%
135
+ * of calls NOTHING git-related happens at all — not the subprocess, not even
136
+ * the FS walk.
137
+ *
138
+ * Side effect: when it returns `true` (heartbeat IS due), it immediately
139
+ * stamps the marker — mirroring `heartbeatClaim`'s own check-and-write
140
+ * pattern — so the NEXT call within the throttle window short-circuits here
141
+ * even if the caller's subsequent git resolution then fails (not actually a
142
+ * git repo, say): a failed attempt should not be retried on every single tool
143
+ * call either.
144
+ *
145
+ * This is a SEPARATE, LOCAL throttle from `claims.json`'s own authoritative
146
+ * `heartbeatAt` (keyed by git-common-dir, enforced inside `heartbeatClaim`) —
147
+ * the two can drift by up to one throttle window with no consequence: a
148
+ * missed local pre-check only costs a claim heartbeat that gets written on
149
+ * the very next call, and claims are advisory to begin with.
150
+ *
151
+ * Fail-open: any read/write error is treated as "due" — a broken marker must
152
+ * never permanently suppress the actual (authoritative) heartbeat. Not
153
+ * tmp+rename like `claims.json` — this file is low-stakes (worst case from a
154
+ * torn write is corrupt JSON, which just reads back as "due" next time) and
155
+ * shared by nobody outside this one project tree.
156
+ *
157
+ * @param {string} feature
158
+ * @param {{ cwd: string, sessionId: string, now?: number }} opts
159
+ * @returns {boolean}
160
+ */
161
+ export function shouldHeartbeat(feature, { cwd, sessionId, now = Date.now() }) {
162
+ const markPath = heartbeatMarkPath(cwd);
163
+ try {
164
+ const mark = JSON.parse(readFileSync(markPath, 'utf8'));
165
+ if (mark.feature === feature && mark.sessionId === sessionId) {
166
+ const at = Date.parse(mark.at ?? '');
167
+ if (Number.isFinite(at) && now - at < HEARTBEAT_THROTTLE_MS) return false;
168
+ }
169
+ } catch {
170
+ // missing/corrupt marker — treat as due
171
+ }
172
+
173
+ try {
174
+ mkdirSync(dirname(markPath), { recursive: true });
175
+ writeFileSync(markPath, JSON.stringify({ feature, sessionId, at: new Date(now).toISOString() }));
176
+ } catch {
177
+ // best-effort — a failed stamp only costs a repeated attempt next call
178
+ }
179
+ return true;
180
+ }
181
+
122
182
  /**
123
183
  * The LIVE claim on this feature held by a session that is not us — the thing
124
184
  * worth warning about. Returns null when the feature is unclaimed, claimed by
@@ -0,0 +1,94 @@
1
+ /**
2
+ * git-paths.js — pure-filesystem git common-dir resolution (no subprocess).
3
+ *
4
+ * `telemetry-log.js`'s claim heartbeat used to pay `execSync('git rev-parse
5
+ * --git-common-dir')` on every tool call to learn where `claims.json` lives —
6
+ * a ~34ms subprocess spawn, wasted on ~99% of calls because the heartbeat
7
+ * write itself is throttled (see `claim-registry.js`'s `shouldHeartbeat`).
8
+ * This module answers the SAME question — mirroring `git rev-parse
9
+ * --git-common-dir` — by reading the two on-disk shapes git itself uses,
10
+ * without spawning `git` at all:
11
+ *
12
+ * 1. PRIMARY checkout: `<root>/.git` is a DIRECTORY. That directory IS the
13
+ * common dir. (A directory `.git` is never a linked worktree's own —
14
+ * git always represents a worktree's `.git` as a FILE — so this case is
15
+ * unambiguous.)
16
+ * 2. LINKED WORKTREE (`git worktree add`): `<root>/.git` is a FILE
17
+ * containing `gitdir: <path>`, pointing at this worktree's PRIVATE
18
+ * gitdir (normally `<primary>/.git/worktrees/<name>`). Inside that
19
+ * private gitdir, a `commondir` file holds a path — relative to the
20
+ * private gitdir, or absolute — to the real common dir (normally
21
+ * `../..`, i.e. back up to `<primary>/.git`).
22
+ *
23
+ * Verified against a real `git worktree add` fixture: `commondir` held
24
+ * `../..`, `.git`'s `gitdir:` line held an absolute path, and the resolved
25
+ * result matched `git rev-parse --git-common-dir` exactly (see
26
+ * `test/hooks/git-paths.test.js`, which asserts that parity directly rather
27
+ * than against a hand-computed path).
28
+ *
29
+ * Fail-open: any missing file, unreadable path, or shape that doesn't match
30
+ * either case above resolves to `null`. Callers must treat `null` as "not
31
+ * resolvable right now" and skip whatever git-dependent step they were
32
+ * guarding — never throw.
33
+ */
34
+
35
+ import { readFileSync, statSync } from 'fs';
36
+ import { dirname, isAbsolute, join, resolve } from 'path';
37
+
38
+ /**
39
+ * Find the nearest `.git` entry (file or directory) at or above `startDir`,
40
+ * climbing exactly like git itself does to find the repo root.
41
+ *
42
+ * @param {string} startDir
43
+ * @returns {{ path: string, isDirectory: boolean }|null}
44
+ */
45
+ function findDotGit(startDir) {
46
+ let dir = resolve(startDir);
47
+ for (;;) {
48
+ const candidate = join(dir, '.git');
49
+ try {
50
+ const st = statSync(candidate);
51
+ return { path: candidate, isDirectory: st.isDirectory() };
52
+ } catch {
53
+ // not here — keep climbing
54
+ }
55
+ const parent = dirname(dir);
56
+ if (parent === dir) return null; // reached the filesystem root
57
+ dir = parent;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Resolve the git COMMON dir for `startDir` — pure filesystem, no subprocess.
63
+ * Always returns an absolute path (unlike `git rev-parse --git-common-dir`,
64
+ * which returns a path relative to the CALLER's cwd when run from the
65
+ * primary checkout — callers of this function never need their own
66
+ * `resolve(cwd, ...)` step afterward).
67
+ *
68
+ * @param {string} startDir
69
+ * @returns {string|null}
70
+ */
71
+ export function resolveGitCommonDir(startDir) {
72
+ try {
73
+ const found = findDotGit(startDir);
74
+ if (!found) return null;
75
+
76
+ if (found.isDirectory) {
77
+ return found.path;
78
+ }
79
+
80
+ // `.git` is a FILE: `gitdir: <path>` → this worktree's private gitdir.
81
+ const content = readFileSync(found.path, 'utf8');
82
+ const match = content.match(/^gitdir:\s*(.+?)\s*$/m);
83
+ if (!match) return null;
84
+ const privateGitDir = isAbsolute(match[1]) ? match[1] : resolve(dirname(found.path), match[1]);
85
+
86
+ // Inside the private gitdir, `commondir` points (relative to itself, or
87
+ // absolute) at the real common dir.
88
+ const commonRaw = readFileSync(join(privateGitDir, 'commondir'), 'utf8').trim();
89
+ if (!commonRaw) return null;
90
+ return isAbsolute(commonRaw) ? commonRaw : resolve(privateGitDir, commonRaw);
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
@@ -55,3 +55,70 @@ export function injectContext(text, eventName) {
55
55
  export function pass() {
56
56
  process.exit(0);
57
57
  }
58
+
59
+ // ── Pure (non-exiting) result builders ──────────────────────────────────────
60
+ //
61
+ // Every core hook used to run inline at module scope and call block()/pass()/
62
+ // injectContext() above to print+exit(0) the moment it knew its verdict. That
63
+ // stops being safe once a hook's logic moves into an exported `run(payload)`
64
+ // that can ALSO be called by framework/hooks/claude-code/core/chain.js — which
65
+ // imports several hooks' run() into ONE process and cannot have any of them
66
+ // call process.exit() mid-chain (it would kill the other links). run() must
67
+ // instead RETURN the verdict object; chain.js (or this file's own
68
+ // runStandalone(), below) is what actually prints it and exits.
69
+ //
70
+ // These builders produce the exact same JSON shapes as block()/approve()/
71
+ // injectContext() above — keep them in sync if that shape ever changes.
72
+
73
+ /** Pure equivalent of block(reason) — returns the verdict instead of printing+exiting. */
74
+ export function blockResult(reason) {
75
+ return { decision: 'block', reason };
76
+ }
77
+
78
+ /** Pure equivalent of approve(context) — null means "pass" (nothing to say). */
79
+ export function approveResult(context) {
80
+ return context ? { decision: 'approve', additionalContext: context } : null;
81
+ }
82
+
83
+ /** Pure equivalent of injectContext(text, eventName). */
84
+ export function contextResult(text, eventName) {
85
+ return { hookSpecificOutput: { hookEventName: eventName, additionalContext: text } };
86
+ }
87
+
88
+ /**
89
+ * Standalone tail every core hook calls as its last line:
90
+ * `await runStandalone(run);`
91
+ *
92
+ * Reads stdin once, calls the hook's `run(payload)`, prints the JSON verdict
93
+ * (if any) and exits 0 — reproducing exactly what block()/injectContext()/
94
+ * pass() used to do when a hook's logic lived inline at module scope.
95
+ *
96
+ * No-ops when MORPH_HOOK_CHAINED is set: that means core/chain.js already
97
+ * imported this module and will call run() itself with a payload it read
98
+ * once for the whole chain — this file must not ALSO read stdin or exit here
99
+ * (doing so would race chain.js's own stdin read and kill the chain process
100
+ * after the first link). chain.js sets the flag BEFORE its first dynamic
101
+ * import, so every hook it loads sees it already set at call time.
102
+ *
103
+ * Fail-open: any error (including a rejected run()) prints nothing and exits
104
+ * 0 — the same silent-pass behavior every hook's own outer try/catch used to
105
+ * produce on an unexpected error.
106
+ *
107
+ * @param {(payload: object) => Promise<object|null>} run
108
+ */
109
+ export async function runStandalone(run) {
110
+ if (process.env.MORPH_HOOK_CHAINED) return;
111
+
112
+ const { readStdin } = await import('./stdin-reader.js');
113
+ let result = null;
114
+ try {
115
+ const payload = await readStdin();
116
+ result = await run(payload);
117
+ } catch {
118
+ result = null; // fail-open
119
+ }
120
+ if (result) {
121
+ console.log(JSON.stringify(result));
122
+ }
123
+ process.exit(0);
124
+ }
@@ -13,6 +13,64 @@ import { join } from 'path';
13
13
  const STATE_FILE = '.morph/state.json';
14
14
  const FEATURES_DIR = '.morph/features';
15
15
 
16
+ /**
17
+ * Module-level memoization of `loadState()`, keyed by `projectPath`.
18
+ *
19
+ * `loadState()` does a `readdirSync` of `.morph/features` plus a
20
+ * `readFileSync` + `JSON.parse` of EVERY `feature.json` under it, on every
21
+ * call. A single hook invocation calls it 1-3x (directly, or via
22
+ * `getActiveFeature`/`derivePhaseForFeature`/`isGateApproved`/etc.) — re-doing
23
+ * that full hydration each time is pure waste when nothing on disk changed
24
+ * between those calls.
25
+ *
26
+ * SAFE because every hook process is short-lived and single-invocation: one
27
+ * `node state-reader.js`-importing process handles exactly one tool call (or
28
+ * one CLI command) and exits. There is no risk of the cache going stale
29
+ * ACROSS invocations, because each invocation starts with an empty Map.
30
+ * Within a SINGLE invocation, staleness would only matter if that same
31
+ * process both reads and then writes a feature's `feature.json` and
32
+ * expects a LATER read to see the write — verified (grep across every
33
+ * consumer, 2026-08-11) that the one hook that writes feature.json
34
+ * (`state-sync.js`) never re-reads through `loadState()` after writing in the
35
+ * same invocation; its `AskUserQuestion` handler reads via
36
+ * `readFeatureFile()` (a different, uncached module) as the PRIMARY path and
37
+ * only falls back to `loadState()` for a feature that has NEVER had a
38
+ * feature.json written — which, by construction, can't have just been
39
+ * written to in this same invocation either.
40
+ *
41
+ * A directory-mtime-based invalidation was considered instead (so a
42
+ * LONG-LIVED consumer could reuse this module safely too) but rejected on two
43
+ * grounds: (1) verified — grep for every importer of this file — that none is
44
+ * long-lived (they're all hooks, one process per tool call, or CLI commands,
45
+ * one process per invocation; the dashboard is a separate Vite/React app and
46
+ * the statusline is Python, neither imports this module); and (2) even if one
47
+ * existed, `.morph/features`'s own mtime only changes when feature
48
+ * DIRECTORIES are added/removed, not when an EXISTING feature.json's content
49
+ * is overwritten in place — so mtime-of-the-dir can't actually detect the
50
+ * mutation that matters here, and getting that right would mean stat'ing
51
+ * every feature.json individually, which re-adds the very cost this cache
52
+ * exists to remove. If a long-lived consumer is ever added, it should
53
+ * re-derive state per request rather than lean on this cache.
54
+ *
55
+ * CAVEAT for any future consumer: the object returned by `loadState()` is the
56
+ * SAME object on every cache hit, not a fresh copy — a caller that mutates it
57
+ * (as `state-sync.js`'s `handleWriteEdit` does, in-place, before persisting)
58
+ * is mutating the cached entry too. That's harmless today because nothing
59
+ * currently reads the cache again after such a mutation within one process,
60
+ * but treat the return value as read-only unless you've re-verified that.
61
+ *
62
+ * `clearStateCache()` is exported for tests that write/rewrite feature.json
63
+ * and then call a `loadState()`-backed function again WITHIN THE SAME
64
+ * process and need to see the fresh content — call it at the point of
65
+ * mutation.
66
+ */
67
+ const stateCache = new Map();
68
+
69
+ /** Drop all cached state — see `stateCache` above for when this is needed. */
70
+ export function clearStateCache() {
71
+ stateCache.clear();
72
+ }
73
+
16
74
  /**
17
75
  * Load state from the project root, hydrating each feature from its
18
76
  * authoritative per-feature `feature.json`.
@@ -24,48 +82,56 @@ const FEATURES_DIR = '.morph/features';
24
82
  * A single corrupt feature.json is skipped, not fatal.
25
83
  *
26
84
  * Fail-open: returns null on any unexpected error or when there is no state at
27
- * all (no index and no feature files).
85
+ * all (no index and no feature files). Memoized per `projectPath` for the
86
+ * lifetime of this process — see `stateCache` above.
28
87
  * @param {string} [projectPath] - Project root (defaults to cwd)
29
88
  * @returns {Object|null} State object or null
30
89
  */
31
90
  export function loadState(projectPath) {
32
- try {
33
- const base = projectPath || process.cwd();
34
- const statePath = join(base, STATE_FILE);
91
+ const base = projectPath || process.cwd();
92
+ if (stateCache.has(base)) return stateCache.get(base);
35
93
 
36
- let state = null;
37
- if (existsSync(statePath)) {
38
- state = JSON.parse(readFileSync(statePath, 'utf-8'));
39
- }
94
+ const state = (() => {
95
+ try {
96
+ const statePath = join(base, STATE_FILE);
40
97
 
41
- const featuresDir = join(base, FEATURES_DIR);
42
- if (existsSync(featuresDir)) {
43
- let names = [];
44
- try {
45
- names = readdirSync(featuresDir, { withFileTypes: true })
46
- .filter((e) => e.isDirectory())
47
- .map((e) => e.name);
48
- } catch {
49
- names = [];
98
+ let state = null;
99
+ if (existsSync(statePath)) {
100
+ state = JSON.parse(readFileSync(statePath, 'utf-8'));
50
101
  }
51
- for (const name of names) {
52
- const fjPath = join(featuresDir, name, 'feature.json');
53
- if (!existsSync(fjPath)) continue;
102
+
103
+ const featuresDir = join(base, FEATURES_DIR);
104
+ if (existsSync(featuresDir)) {
105
+ let names = [];
54
106
  try {
55
- const fj = JSON.parse(readFileSync(fjPath, 'utf-8'));
56
- state = state || { features: {} };
57
- state.features = state.features || {};
58
- state.features[name] = fj; // feature.json is authoritative
107
+ names = readdirSync(featuresDir, { withFileTypes: true })
108
+ .filter((e) => e.isDirectory())
109
+ .map((e) => e.name);
59
110
  } catch {
60
- // skip a single corrupt feature.json — don't blind every hook
111
+ names = [];
112
+ }
113
+ for (const name of names) {
114
+ const fjPath = join(featuresDir, name, 'feature.json');
115
+ if (!existsSync(fjPath)) continue;
116
+ try {
117
+ const fj = JSON.parse(readFileSync(fjPath, 'utf-8'));
118
+ state = state || { features: {} };
119
+ state.features = state.features || {};
120
+ state.features[name] = fj; // feature.json is authoritative
121
+ } catch {
122
+ // skip a single corrupt feature.json — don't blind every hook
123
+ }
61
124
  }
62
125
  }
126
+
127
+ return state;
128
+ } catch {
129
+ return null;
63
130
  }
131
+ })();
64
132
 
65
- return state;
66
- } catch {
67
- return null;
68
- }
133
+ stateCache.set(base, state);
134
+ return state;
69
135
  }
70
136
 
71
137
  /**