@polymorphism-tech/morph-spec 8.15.1 → 8.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/morph-spec.js +19 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +10 -4
- package/framework/MORPH.md +4 -4
- package/framework/evals/rubrics/condensed.md +49 -0
- package/framework/hooks/README.md +3 -3
- package/framework/hooks/claude-code/core/chain.js +123 -0
- package/framework/hooks/claude-code/core/enforce-phase-writes.js +84 -77
- package/framework/hooks/claude-code/core/gate-guard.js +21 -25
- package/framework/hooks/claude-code/core/loop-detect.js +70 -64
- package/framework/hooks/claude-code/core/post-edit-typecheck.js +94 -45
- package/framework/hooks/claude-code/core/pre-bash-denylist.js +21 -15
- package/framework/hooks/claude-code/core/protect-spec-files.js +119 -103
- package/framework/hooks/claude-code/core/state-sync.js +32 -40
- package/framework/hooks/claude-code/core/tasks-json-guard.js +44 -44
- package/framework/hooks/claude-code/core/telemetry-log.js +151 -74
- package/framework/hooks/claude-code/core/trace-autogen.js +116 -6
- package/framework/hooks/claude-code/core/validate-completion.js +147 -107
- package/framework/hooks/claude-code/core/worktree-context.js +25 -22
- package/framework/hooks/claude-code/statusline.py +26 -4
- package/framework/hooks/shared/activity-logger.js +120 -69
- package/framework/hooks/shared/claim-registry.js +61 -1
- package/framework/hooks/shared/git-paths.js +94 -0
- package/framework/hooks/shared/hook-response.js +67 -0
- package/framework/hooks/shared/state-reader.js +95 -29
- package/framework/hooks/shared/state-writer.js +16 -0
- package/framework/rules/morph-task-tracking.md +4 -2
- package/framework/schemas/tasks.schema.json +10 -0
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +40 -28
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +16 -13
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +26 -25
- package/framework/skills/core/morph-plan/SKILL.md +14 -3
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +1 -1
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/package.json +2 -2
- package/src/commands/observability/cost.js +137 -0
- package/src/commands/project/standards.js +220 -0
- package/src/commands/state/approve.js +30 -9
- package/src/dashboard/scanner/hooks-scanner.js +11 -2
- package/src/lib/installers/memory-installer.js +1 -1
- package/src/lib/tasks/tasks-json-parser.js +26 -0
- package/src/lib/telemetry/telemetry-reader.js +213 -0
- package/src/utils/hooks-installer.js +84 -79
- 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.
|
|
5
|
-
*
|
|
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 {
|
|
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.
|
|
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
|
|
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
|
-
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
117
|
+
* @returns {{ sessionId: string, feature: string, phase: string, hooks: Array, events: Array }|null}
|
|
73
118
|
*/
|
|
74
119
|
export function readActivity(projectPath) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
*
|
|
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
|
|
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(
|
|
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
|
-
*
|
|
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
|
|
169
|
+
export function archiveSession(projectPath) {
|
|
115
170
|
try {
|
|
116
171
|
const activityPath = getActivityPath(projectPath);
|
|
117
|
-
|
|
172
|
+
if (!existsSync(activityPath)) return;
|
|
118
173
|
|
|
119
|
-
const
|
|
120
|
-
const
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
134
|
-
*
|
|
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
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
94
|
+
const state = (() => {
|
|
95
|
+
try {
|
|
96
|
+
const statePath = join(base, STATE_FILE);
|
|
40
97
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
102
|
+
|
|
103
|
+
const featuresDir = join(base, FEATURES_DIR);
|
|
104
|
+
if (existsSync(featuresDir)) {
|
|
105
|
+
let names = [];
|
|
54
106
|
try {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
133
|
+
stateCache.set(base, state);
|
|
134
|
+
return state;
|
|
69
135
|
}
|
|
70
136
|
|
|
71
137
|
/**
|