@polymorphism-tech/morph-spec 8.15.0 → 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.
- package/README.md +2 -2
- package/bin/morph-spec.js +8 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +2 -1
- package/framework/MORPH.md +4 -3
- 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 +110 -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/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 +1 -1
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- 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 +1 -1
- package/src/commands/observability/cost.js +137 -0
- package/src/commands/project/doctor.js +28 -1
- package/src/commands/state/approve.js +30 -9
- package/src/commands/state/archive.js +29 -3
- package/src/commands/state/delete.js +36 -2
- package/src/dashboard/scanner/hooks-scanner.js +11 -2
- package/src/lib/installers/memory-installer.js +1 -1
- 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,28 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Stop Hook: Phase Transition Alert + Build Check
|
|
4
|
+
* Stop Hook: Phase Transition Alert + Build Check
|
|
5
5
|
*
|
|
6
6
|
* Event: Stop
|
|
7
7
|
*
|
|
8
8
|
* 1. Detects transitions into the `implement` phase and recommends /compact.
|
|
9
|
-
* 2. Runs stack-aware build check —
|
|
10
|
-
*
|
|
9
|
+
* 2. Runs stack-aware build check — dirty-flag gated (R1.2), warns only on
|
|
10
|
+
* failure (silent on pass/skip).
|
|
11
11
|
*
|
|
12
12
|
* Phase transition tracking: stores last-seen phase per feature in
|
|
13
13
|
* .morph/memory/phase-watch.json so the alert fires exactly once.
|
|
14
14
|
*
|
|
15
|
+
* Build-dirty gate: post-edit-typecheck.js touches
|
|
16
|
+
* .morph/memory/build-dirty.json on every source-file Write/Edit. Without
|
|
17
|
+
* that flag nothing changed since the last verified build, so the (often
|
|
18
|
+
* expensive) full build/typecheck is skipped entirely instead of running on
|
|
19
|
+
* every single Stop.
|
|
20
|
+
*
|
|
15
21
|
* Fail-open: exits 0 on any error.
|
|
16
22
|
*/
|
|
17
23
|
|
|
18
|
-
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
24
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'fs';
|
|
19
25
|
import { join } from 'path';
|
|
26
|
+
import { createHash } from 'crypto';
|
|
20
27
|
import { runBuildCheck } from '../../shared/build-check.js';
|
|
21
|
-
import { saveSessionSnapshot } from '../../shared/session-snapshot.js';
|
|
22
28
|
import {
|
|
23
29
|
stateExists, getActiveFeature, getMostRecentFeature, derivePhaseForFeature,
|
|
24
30
|
} from '../../shared/state-reader.js';
|
|
25
|
-
import {
|
|
31
|
+
import { contextResult, runStandalone } from '../../shared/hook-response.js';
|
|
26
32
|
import { logHookActivity, logEvent } from '../../shared/activity-logger.js';
|
|
27
33
|
|
|
28
34
|
/**
|
|
@@ -121,122 +127,156 @@ const COMPACT_TRIGGER_PHASES = new Set(['implement']);
|
|
|
121
127
|
// Phases where compaction is suggested when arriving (heavy spec phases)
|
|
122
128
|
const HEAVY_SOURCE_PHASES = new Set(['plan', 'uiux']);
|
|
123
129
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
130
|
+
/**
|
|
131
|
+
* @param {object} _payload - parsed Stop stdin payload (unused — this hook
|
|
132
|
+
* reads its own state from disk, not from the tool payload)
|
|
133
|
+
* @returns {Promise<object|null>} a hookSpecificOutput advisory (phase
|
|
134
|
+
* transition / build failure / recap drift), or null (pass/silent).
|
|
135
|
+
*/
|
|
136
|
+
export async function run(_payload) {
|
|
137
|
+
try {
|
|
138
|
+
// Prevent infinite loop
|
|
139
|
+
if (process.env.MORPH_STOP_HOOK_ACTIVE === '1') return null;
|
|
127
140
|
|
|
128
|
-
|
|
141
|
+
if (!stateExists()) return null;
|
|
129
142
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
143
|
+
// getActiveFeature() only returns in_progress/draft features.
|
|
144
|
+
// Fall back to the most recently updated feature when all features are 'done'.
|
|
145
|
+
let active = getActiveFeature() || getMostRecentFeature();
|
|
146
|
+
if (!active) return null;
|
|
134
147
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
148
|
+
const { name, feature } = active;
|
|
149
|
+
const cwd = process.cwd();
|
|
150
|
+
const warnings = [];
|
|
138
151
|
|
|
139
|
-
|
|
140
|
-
|
|
152
|
+
// .morph/memory/ hosts phase-watch.json, build-dirty.json (written by
|
|
153
|
+
// post-edit-typecheck.js) and warned-hashes.json — ensure it exists once,
|
|
154
|
+
// up front, so every write below can assume the dir is there.
|
|
155
|
+
const memDir = join(cwd, '.morph', 'memory');
|
|
156
|
+
try { mkdirSync(memDir, { recursive: true }); } catch { /* non-blocking */ }
|
|
141
157
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
try {
|
|
145
|
-
if (existsSync(watchPath)) phaseWatch = JSON.parse(readFileSync(watchPath, 'utf-8'));
|
|
146
|
-
} catch { /* corrupt file — start fresh */ }
|
|
158
|
+
// ── Phase transition detection ────────────────────────────────────────────
|
|
159
|
+
const currentPhase = feature.phase || derivePhaseForFeature(name, cwd);
|
|
147
160
|
|
|
148
|
-
|
|
161
|
+
const watchPath = join(memDir, 'phase-watch.json');
|
|
162
|
+
let phaseWatch = {};
|
|
163
|
+
try {
|
|
164
|
+
if (existsSync(watchPath)) phaseWatch = JSON.parse(readFileSync(watchPath, 'utf-8'));
|
|
165
|
+
} catch { /* corrupt file — start fresh */ }
|
|
149
166
|
|
|
150
|
-
|
|
151
|
-
try {
|
|
152
|
-
phaseWatch[name] = currentPhase;
|
|
153
|
-
writeFileSync(watchPath, JSON.stringify(phaseWatch, null, 2), 'utf-8');
|
|
154
|
-
} catch { /* non-blocking */ }
|
|
155
|
-
|
|
156
|
-
// Alert when entering implement from a heavy spec phase
|
|
157
|
-
const isPhaseTransition = lastPhase && lastPhase !== currentPhase;
|
|
158
|
-
const enteringImplement = COMPACT_TRIGGER_PHASES.has(currentPhase);
|
|
159
|
-
const comingFromHeavyPhase = HEAVY_SOURCE_PHASES.has(lastPhase);
|
|
160
|
-
|
|
161
|
-
if (isPhaseTransition && enteringImplement && comingFromHeavyPhase) {
|
|
162
|
-
warnings.push(
|
|
163
|
-
`⚡ Phase advanced: ${lastPhase} → implement`,
|
|
164
|
-
``,
|
|
165
|
-
`💡 Recommended before starting implementation:`,
|
|
166
|
-
` Run /compact to free up context — spec.md, mandate.md and tasks.json`,
|
|
167
|
-
` loaded during planning take significant space. A compact now gives`,
|
|
168
|
-
` Claude a clean slate focused on writing code.`,
|
|
169
|
-
``,
|
|
170
|
-
` After /compact, resume with: /morph-apply ${name}`,
|
|
171
|
-
);
|
|
172
|
-
}
|
|
167
|
+
const lastPhase = phaseWatch[name];
|
|
173
168
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
169
|
+
// Update stored phase (always, so the alert only fires once per transition)
|
|
170
|
+
try {
|
|
171
|
+
phaseWatch[name] = currentPhase;
|
|
172
|
+
writeFileSync(watchPath, JSON.stringify(phaseWatch, null, 2), 'utf-8');
|
|
173
|
+
} catch { /* non-blocking */ }
|
|
174
|
+
|
|
175
|
+
// Alert when entering implement from a heavy spec phase
|
|
176
|
+
const isPhaseTransition = lastPhase && lastPhase !== currentPhase;
|
|
177
|
+
const enteringImplement = COMPACT_TRIGGER_PHASES.has(currentPhase);
|
|
178
|
+
const comingFromHeavyPhase = HEAVY_SOURCE_PHASES.has(lastPhase);
|
|
179
|
+
|
|
180
|
+
if (isPhaseTransition && enteringImplement && comingFromHeavyPhase) {
|
|
181
|
+
warnings.push(
|
|
182
|
+
`⚡ Phase advanced: ${lastPhase} → implement`,
|
|
183
|
+
``,
|
|
184
|
+
`💡 Recommended before starting implementation:`,
|
|
185
|
+
` Run /compact to free up context — spec.md, mandate.md and tasks.json`,
|
|
186
|
+
` loaded during planning take significant space. A compact now gives`,
|
|
187
|
+
` Claude a clean slate focused on writing code.`,
|
|
188
|
+
``,
|
|
189
|
+
` After /compact, resume with: /morph-apply ${name}`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Build/typecheck verification (dirty-flag gated — R1.2) ────────────────
|
|
194
|
+
// Only run the (often expensive) full build/typecheck when something
|
|
195
|
+
// actually changed since the last time it was verified — post-edit-
|
|
196
|
+
// typecheck.js sets the flag on every source-file Write/Edit. No flag →
|
|
197
|
+
// nothing to verify → skip silently, no config/stack read even attempted.
|
|
198
|
+
const BUILD_CHECK_TIMEOUT_MS = 30000; // per-step timeout — build-check.js's own default, made explicit here
|
|
199
|
+
try {
|
|
200
|
+
const dirtyFlagPath = join(memDir, 'build-dirty.json');
|
|
201
|
+
if (existsSync(dirtyFlagPath)) {
|
|
202
|
+
const configPath = join(cwd, '.morph', 'config', 'config.json');
|
|
203
|
+
if (existsSync(configPath)) {
|
|
204
|
+
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
205
|
+
const stack = config?.project?.stack;
|
|
206
|
+
const { frontendPath, backendPath } = config?.project ?? {};
|
|
207
|
+
if (stack && currentPhase === 'implement') {
|
|
208
|
+
const buildResult = runBuildCheck(cwd, stack, BUILD_CHECK_TIMEOUT_MS, { frontendPath, backendPath });
|
|
209
|
+
if (buildResult.status === 'fail') {
|
|
210
|
+
warnings.push('⚠ Build/typecheck failed:');
|
|
211
|
+
warnings.push(buildResult.output);
|
|
212
|
+
warnings.push('Fix build errors before ending session.');
|
|
213
|
+
// keep the dirty flag — still dirty until a build actually passes
|
|
214
|
+
} else {
|
|
215
|
+
// 'pass' → verified clean. 'skip' → no build step was even
|
|
216
|
+
// resolved for this stack (e.g. fresh worktree, no tsconfig) —
|
|
217
|
+
// nothing to verify, so there is nothing left to stay dirty about.
|
|
218
|
+
// Either way, re-arm on the NEXT source edit, not every Stop.
|
|
219
|
+
try { unlinkSync(dirtyFlagPath); } catch { /* non-blocking */ }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
189
222
|
}
|
|
190
|
-
// pass and skip → silent (success is silent)
|
|
191
223
|
}
|
|
224
|
+
// No flag → skip the build entirely, silently.
|
|
225
|
+
} catch {
|
|
226
|
+
// Fail-open — build check must never block Stop
|
|
192
227
|
}
|
|
193
|
-
|
|
194
|
-
// Fail-open — build check must never block Stop
|
|
195
|
-
}
|
|
196
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
228
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
197
229
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
230
|
+
// ── Recap + tasks.json audit ──────────────────────────────────────────────
|
|
231
|
+
// Surface drift between recap.md and tasks.json, plus missing standard
|
|
232
|
+
// citations. Advisory warnings only — Stop hook never blocks.
|
|
233
|
+
//
|
|
234
|
+
// The same audit re-runs every Stop; without dedup it re-injects the same
|
|
235
|
+
// ~500-token block turn after turn even when nothing changed. Hash-gate it:
|
|
236
|
+
// only inject when the warning SET differs from the last one actually
|
|
237
|
+
// surfaced for this feature. Resolved (no more warnings) clears the stored
|
|
238
|
+
// hash, so a later recurrence — even an identical one — is reported fresh.
|
|
239
|
+
try {
|
|
240
|
+
const featurePath = join(cwd, '.morph', 'features', name);
|
|
241
|
+
const recapWarnings = auditRecapAndTasks(featurePath);
|
|
212
242
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
243
|
+
const warnedHashesPath = join(memDir, 'warned-hashes.json');
|
|
244
|
+
let warnedHashes = {};
|
|
245
|
+
try {
|
|
246
|
+
if (existsSync(warnedHashesPath)) warnedHashes = JSON.parse(readFileSync(warnedHashesPath, 'utf-8'));
|
|
247
|
+
} catch { /* corrupt file — start fresh */ }
|
|
248
|
+
|
|
249
|
+
if (recapWarnings.length > 0) {
|
|
250
|
+
const hash = createHash('sha1').update(recapWarnings.join('\n')).digest('hex');
|
|
251
|
+
if (warnedHashes[name]?.recap !== hash) {
|
|
252
|
+
warnings.push('📋 Recap audit:');
|
|
253
|
+
for (const w of recapWarnings) warnings.push(` • ${w}`);
|
|
254
|
+
warnedHashes[name] = { ...warnedHashes[name], recap: hash };
|
|
255
|
+
try { writeFileSync(warnedHashesPath, JSON.stringify(warnedHashes, null, 2), 'utf-8'); } catch { /* non-blocking */ }
|
|
256
|
+
}
|
|
257
|
+
// same hash as last time it fired → already surfaced, stay silent
|
|
258
|
+
} else if (warnedHashes[name]?.recap) {
|
|
259
|
+
delete warnedHashes[name].recap;
|
|
260
|
+
try { writeFileSync(warnedHashesPath, JSON.stringify(warnedHashes, null, 2), 'utf-8'); } catch { /* non-blocking */ }
|
|
261
|
+
}
|
|
262
|
+
} catch {
|
|
263
|
+
// Fail-open — audit must never block Stop
|
|
224
264
|
}
|
|
225
|
-
|
|
226
|
-
// Fail-open — snapshot save must never block Stop
|
|
227
|
-
}
|
|
228
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
265
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
229
266
|
|
|
230
|
-
|
|
267
|
+
if (warnings.length === 0) return null;
|
|
231
268
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
269
|
+
const message = [
|
|
270
|
+
'MORPH-SPEC:',
|
|
271
|
+
...warnings.map(w => ` ${w}`),
|
|
272
|
+
].join('\n');
|
|
236
273
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
} catch {
|
|
241
|
-
|
|
274
|
+
logHookActivity('validate-completion', 'Stop', `warnings(${warnings.length})`);
|
|
275
|
+
logEvent('session_end', { feature: name, phase: currentPhase, warnings: warnings.length });
|
|
276
|
+
return contextResult(message, 'Stop');
|
|
277
|
+
} catch {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
242
280
|
}
|
|
281
|
+
|
|
282
|
+
await runStandalone(run);
|
|
@@ -24,6 +24,11 @@
|
|
|
24
24
|
* 2. Primary root → active-feature summary (phase, task stats, pending
|
|
25
25
|
* gates), or silent if there's no feature in progress.
|
|
26
26
|
*
|
|
27
|
+
* Also the real call site for activity-log rotation (R1.2 of the performance
|
|
28
|
+
* plan): SessionStart is the only reliable "between sessions" checkpoint the
|
|
29
|
+
* harness gets, so this is where `.morph/logs/activity.jsonl` gets archived
|
|
30
|
+
* to `.morph/logs/sessions/` once it crosses the size threshold.
|
|
31
|
+
*
|
|
27
32
|
* Fail-open: any error, or nothing to report, → pass() silently. This hook
|
|
28
33
|
* never blocks a session from starting.
|
|
29
34
|
*/
|
|
@@ -38,16 +43,9 @@ import {
|
|
|
38
43
|
getPendingGates,
|
|
39
44
|
getTaskStats,
|
|
40
45
|
} from '../../shared/state-reader.js';
|
|
41
|
-
import {
|
|
46
|
+
import { contextResult, runStandalone } from '../../shared/hook-response.js';
|
|
42
47
|
import { acquireClaim, foreignClaim, claimAge } from '../../shared/claim-registry.js';
|
|
43
|
-
|
|
44
|
-
function readStdin() {
|
|
45
|
-
try {
|
|
46
|
-
return JSON.parse(readFileSync(0, 'utf8') || '{}');
|
|
47
|
-
} catch {
|
|
48
|
-
return {};
|
|
49
|
-
}
|
|
50
|
-
}
|
|
48
|
+
import { rotateActivityLogIfLarge } from '../../shared/activity-logger.js';
|
|
51
49
|
|
|
52
50
|
function projectDir() {
|
|
53
51
|
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
@@ -209,40 +207,45 @@ function claimBlock(dir, feature, sessionId) {
|
|
|
209
207
|
}
|
|
210
208
|
}
|
|
211
209
|
|
|
212
|
-
|
|
210
|
+
/**
|
|
211
|
+
* @param {object} payload - parsed SessionStart stdin payload
|
|
212
|
+
* @returns {Promise<object|null>} a hookSpecificOutput context (worktree
|
|
213
|
+
* identity or active-feature summary), or null (pass/silent).
|
|
214
|
+
*/
|
|
215
|
+
export async function run(payload) {
|
|
213
216
|
try {
|
|
214
217
|
const dir = projectDir();
|
|
215
218
|
// The SessionStart payload carries session_id — the identity the claim
|
|
216
219
|
// registry uses to tell "me" from "another session".
|
|
217
|
-
const payload = readStdin();
|
|
218
220
|
const sessionId = payload?.session_id || `pid-${process.pid}`;
|
|
219
221
|
|
|
220
222
|
if (!stateExists(dir)) {
|
|
221
|
-
|
|
222
|
-
return;
|
|
223
|
+
return null;
|
|
223
224
|
}
|
|
224
225
|
|
|
226
|
+
// Rotate the activity log before anything else touches it this session.
|
|
227
|
+
// Best-effort, never fatal to session start — rotateActivityLogIfLarge()
|
|
228
|
+
// is itself fail-open (returns false on any error).
|
|
229
|
+
try { rotateActivityLogIfLarge(dir); } catch { /* non-blocking */ }
|
|
230
|
+
|
|
225
231
|
if (isSecondaryWorktree(dir)) {
|
|
226
232
|
const feature = featureFromBranch(dir) || featureFromSingleDir(dir);
|
|
227
233
|
if (!feature) {
|
|
228
|
-
|
|
229
|
-
return;
|
|
234
|
+
return null;
|
|
230
235
|
}
|
|
231
|
-
|
|
232
|
-
return;
|
|
236
|
+
return contextResult(buildWorktreeContext(dir, feature) + claimBlock(dir, feature, sessionId), 'SessionStart');
|
|
233
237
|
}
|
|
234
238
|
|
|
235
239
|
// Primary root — active-feature summary (or silent if nothing's in progress)
|
|
236
240
|
const summary = buildActiveFeatureSummary(dir);
|
|
237
241
|
if (!summary) {
|
|
238
|
-
|
|
239
|
-
return;
|
|
242
|
+
return null;
|
|
240
243
|
}
|
|
241
244
|
const active = getActiveFeature(dir);
|
|
242
|
-
|
|
245
|
+
return contextResult(summary + (active?.name ? claimBlock(dir, active.name, sessionId) : ''), 'SessionStart');
|
|
243
246
|
} catch {
|
|
244
|
-
|
|
247
|
+
return null;
|
|
245
248
|
}
|
|
246
249
|
}
|
|
247
250
|
|
|
248
|
-
|
|
251
|
+
await runStandalone(run);
|
|
@@ -443,15 +443,37 @@ def get_active_features(cwd):
|
|
|
443
443
|
|
|
444
444
|
|
|
445
445
|
def get_last_hook(cwd):
|
|
446
|
-
"""Name of the most recent hook from .morph/logs/activity.
|
|
446
|
+
"""Name of the most recent hook from .morph/logs/activity.jsonl.
|
|
447
|
+
|
|
448
|
+
activity-logger.js (R1.2) writes append-only JSONL now, one event per
|
|
449
|
+
line (`{"kind": "hook", ...}` or `{"kind": "event", ...}`), instead of a
|
|
450
|
+
single JSON doc rewritten whole on every call. The log can grow up to the
|
|
451
|
+
rotation threshold (2 MB, see ROTATE_THRESHOLD_BYTES in activity-logger.js)
|
|
452
|
+
between SessionStarts, and this function runs on every prompt redraw, so
|
|
453
|
+
only the trailing slice is read — never the whole file.
|
|
447
454
|
|
|
448
455
|
The age is deliberately not shown: activity-logger.js only records
|
|
449
456
|
HH:MM:SS, so any age computed from it is wrong across midnight.
|
|
450
457
|
"""
|
|
451
458
|
try:
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
459
|
+
path = Path(cwd) / '.morph' / 'logs' / 'activity.jsonl'
|
|
460
|
+
size = path.stat().st_size
|
|
461
|
+
tail_bytes = 8192
|
|
462
|
+
with path.open('rb') as f:
|
|
463
|
+
if size > tail_bytes:
|
|
464
|
+
f.seek(-tail_bytes, 2)
|
|
465
|
+
tail = f.read().decode('utf-8', errors='ignore')
|
|
466
|
+
for line in reversed(tail.splitlines()):
|
|
467
|
+
line = line.strip()
|
|
468
|
+
if not line:
|
|
469
|
+
continue
|
|
470
|
+
try:
|
|
471
|
+
obj = json.loads(line)
|
|
472
|
+
except Exception:
|
|
473
|
+
continue
|
|
474
|
+
if obj.get('kind') == 'hook':
|
|
475
|
+
return obj.get('name') or None
|
|
476
|
+
return None
|
|
455
477
|
except Exception:
|
|
456
478
|
return None
|
|
457
479
|
|