@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.
Files changed (51) 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 +4 -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/project/doctor.js +28 -1
  44. package/src/commands/state/approve.js +30 -9
  45. package/src/commands/state/archive.js +29 -3
  46. package/src/commands/state/delete.js +36 -2
  47. package/src/dashboard/scanner/hooks-scanner.js +11 -2
  48. package/src/lib/installers/memory-installer.js +1 -1
  49. package/src/lib/telemetry/telemetry-reader.js +213 -0
  50. package/src/utils/hooks-installer.js +84 -79
  51. package/framework/hooks/shared/session-snapshot.js +0 -105
@@ -11,10 +11,10 @@
11
11
  * - AskUserQuestion answered with option starting "aprovado" → advance gate
12
12
  */
13
13
 
14
- import { readFileSync } from 'fs';
15
14
  import { loadState, stateExists } from '../../shared/state-reader.js';
16
15
  import { persistFeature, setGateApproved, createDefaultFeature, readFeatureFile } from '../../shared/state-writer.js';
17
16
  import { resolveMorphRoot } from '../../shared/payload-utils.js';
17
+ import { contextResult, runStandalone } from '../../shared/hook-response.js';
18
18
  import {
19
19
  locateFeatureForQuestion,
20
20
  allWorkTrees,
@@ -32,19 +32,6 @@ const PHASE_FOLDERS = {
32
32
  '5-implement': 'implement', // legacy alias
33
33
  };
34
34
 
35
- function exit(output = {}) {
36
- process.stdout.write(JSON.stringify(output));
37
- process.exit(0);
38
- }
39
-
40
- function readStdin() {
41
- try {
42
- return JSON.parse(readFileSync(0, 'utf8') || '{}');
43
- } catch {
44
- return {};
45
- }
46
- }
47
-
48
35
  function projectDir() {
49
36
  return process.env.CLAUDE_PROJECT_DIR || process.cwd();
50
37
  }
@@ -229,36 +216,41 @@ function handleAskUserQuestion(input, sessionRoot) {
229
216
  return warnings.length ? warnings.join('\n') : null;
230
217
  }
231
218
 
232
- async function main() {
233
- // The SESSION's root. It is only a starting point: which `.morph/` actually
234
- // gets read and written is decided per event — by the target path for
235
- // Write/Edit, by the feature the question names for AskUserQuestion. The
236
- // `stateExists` gate therefore moved into each handler, against the tree that
237
- // handler resolved; gating on the session's tree up here would skip work that
238
- // belongs to a worktree (and, worse, silently pass it to the wrong one).
239
- const sessionRoot = projectDir();
219
+ /**
220
+ * @param {object} payload - parsed PostToolUse stdin payload (Write|Edit|AskUserQuestion)
221
+ * @returns {Promise<object|null>} a hookSpecificOutput warning on a gate
222
+ * order/attribution violation, or null (pass/silent).
223
+ */
224
+ export async function run(payload) {
225
+ try {
226
+ // The SESSION's root. It is only a starting point: which `.morph/` actually
227
+ // gets read and written is decided per event — by the target path for
228
+ // Write/Edit, by the feature the question names for AskUserQuestion. The
229
+ // `stateExists` gate therefore moved into each handler, against the tree that
230
+ // handler resolved; gating on the session's tree up here would skip work that
231
+ // belongs to a worktree (and, worse, silently pass it to the wrong one).
232
+ const sessionRoot = projectDir();
240
233
 
241
- const input = readStdin();
242
- const toolName = input.tool_name;
234
+ const input = payload || {};
235
+ const toolName = input.tool_name;
243
236
 
244
- let warning = null;
245
- try {
246
- if (toolName === 'Write' || toolName === 'Edit') {
247
- handleWriteEdit(input, sessionRoot);
248
- } else if (toolName === 'AskUserQuestion') {
249
- warning = handleAskUserQuestion(input, sessionRoot);
237
+ let warning = null;
238
+ try {
239
+ if (toolName === 'Write' || toolName === 'Edit') {
240
+ handleWriteEdit(input, sessionRoot);
241
+ } else if (toolName === 'AskUserQuestion') {
242
+ warning = handleAskUserQuestion(input, sessionRoot);
243
+ }
244
+ } catch {
245
+ // fail-open
250
246
  }
251
- } catch {
252
- // fail-open
253
- }
254
247
 
255
- // A non-blocking order-violation warning is surfaced as injected context
256
- // (hookSpecificOutput.additionalContext); everything else exits silently.
257
- if (warning) {
258
- exit({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: warning } });
259
- } else {
260
- exit();
248
+ // A non-blocking order-violation warning is surfaced as injected context
249
+ // (hookSpecificOutput.additionalContext); everything else stays silent.
250
+ return warning ? contextResult(warning, 'PostToolUse') : null;
251
+ } catch {
252
+ return null;
261
253
  }
262
254
  }
263
255
 
264
- main().catch(() => exit());
256
+ await runStandalone(run);
@@ -22,62 +22,62 @@
22
22
  import { readFileSync } from 'fs';
23
23
  import { isAbsolute, join } from 'path';
24
24
  import { stateExists } from '../../shared/state-reader.js';
25
- import { injectContext, pass } from '../../shared/hook-response.js';
25
+ import { contextResult, runStandalone } from '../../shared/hook-response.js';
26
26
  import { logHookActivity } from '../../shared/activity-logger.js';
27
27
  import { getFilePath } from '../../shared/payload-utils.js';
28
28
  import { isFeaturePath, getBasename } from '../../shared/phase-utils.js';
29
29
  import { VALID_TASK_STATUS, validateTasksDoc } from '../../shared/tasks-validator.js';
30
30
 
31
- function readStdin() {
32
- try {
33
- return JSON.parse(readFileSync(0, 'utf8') || '{}');
34
- } catch {
35
- return {};
36
- }
37
- }
38
-
39
31
  function projectDir() {
40
32
  return process.env.CLAUDE_PROJECT_DIR || process.cwd();
41
33
  }
42
34
 
43
- try {
44
- const cwd = projectDir();
45
- if (!stateExists(cwd)) pass();
35
+ /**
36
+ * @param {object} payload - parsed PostToolUse stdin payload (Write|Edit)
37
+ * @returns {Promise<object|null>} a hookSpecificOutput warning when tasks.json
38
+ * is unparseable or schema-invalid, or null (pass/silent).
39
+ */
40
+ export async function run(payload) {
41
+ try {
42
+ const cwd = projectDir();
43
+ if (!stateExists(cwd)) return null;
46
44
 
47
- const payload = readStdin();
48
- const filePath = getFilePath(payload);
49
- if (!filePath) pass();
50
- if (!isFeaturePath(filePath)) pass();
51
- if (getBasename(filePath) !== 'tasks.json') pass();
45
+ const filePath = getFilePath(payload);
46
+ if (!filePath) return null;
47
+ if (!isFeaturePath(filePath)) return null;
48
+ if (getBasename(filePath) !== 'tasks.json') return null;
52
49
 
53
- const absPath = isAbsolute(filePath) ? filePath : join(cwd, filePath);
50
+ const absPath = isAbsolute(filePath) ? filePath : join(cwd, filePath);
54
51
 
55
- let doc;
56
- try {
57
- doc = JSON.parse(readFileSync(absPath, 'utf-8'));
58
- } catch {
59
- logHookActivity('tasks-json-guard', 'PostToolUse', 'invalid', cwd);
60
- injectContext(
61
- '⚠️ [morph-spec] tasks.json INVÁLIDO — o arquivo gravado não parseia como JSON. ' +
62
- 'Corrija o arquivo antes de continuar.',
63
- 'PostToolUse'
64
- );
65
- }
52
+ let doc;
53
+ try {
54
+ doc = JSON.parse(readFileSync(absPath, 'utf-8'));
55
+ } catch {
56
+ logHookActivity('tasks-json-guard', 'PostToolUse', 'invalid', cwd);
57
+ return contextResult(
58
+ '⚠️ [morph-spec] tasks.json INVÁLIDO — o arquivo gravado não parseia como JSON. ' +
59
+ 'Corrija o arquivo antes de continuar.',
60
+ 'PostToolUse'
61
+ );
62
+ }
66
63
 
67
- const { valid, errors } = validateTasksDoc(doc);
68
- if (!valid) {
69
- logHookActivity('tasks-json-guard', 'PostToolUse', 'invalid', cwd);
70
- injectContext(
71
- '⚠️ [morph-spec] tasks.json com schema inválido após o write:\n' +
72
- errors.map(e => ` • ${e}`).join('\n') +
73
- `\nStatus válidos: ${VALID_TASK_STATUS.join(', ')}. Corrija antes de marcar a task como done.`,
74
- 'PostToolUse'
75
- );
76
- }
64
+ const { valid, errors } = validateTasksDoc(doc);
65
+ if (!valid) {
66
+ logHookActivity('tasks-json-guard', 'PostToolUse', 'invalid', cwd);
67
+ return contextResult(
68
+ '⚠️ [morph-spec] tasks.json com schema inválido após o write:\n' +
69
+ errors.map(e => ` • ${e}`).join('\n') +
70
+ `\nStatus válidos: ${VALID_TASK_STATUS.join(', ')}. Corrija antes de marcar a task como done.`,
71
+ 'PostToolUse'
72
+ );
73
+ }
77
74
 
78
- logHookActivity('tasks-json-guard', 'PostToolUse', 'ok', cwd);
79
- pass();
80
- } catch {
81
- // Fail-open
82
- process.exit(0);
75
+ logHookActivity('tasks-json-guard', 'PostToolUse', 'ok', cwd);
76
+ return null;
77
+ } catch {
78
+ // Fail-open
79
+ return null;
80
+ }
83
81
  }
82
+
83
+ await runStandalone(run);
@@ -18,100 +18,177 @@
18
18
  * shared trace context (the enrichment lesson from the observability standard:
19
19
  * propagate a correlation key, a TraceId does not survive the boundary).
20
20
  *
21
+ * SINK RESOLUTION: the event is appended to the `.morph/` tree that OWNS the
22
+ * tool's target file, not to `process.cwd()`/`CLAUDE_PROJECT_DIR` — same
23
+ * pattern as trace-autogen/state-sync (`resolveMorphRoot`). A session rooted
24
+ * in the primary repo can legitimately touch a feature living in a secondary
25
+ * git worktree; sinking on the session's cwd put those events in the WRONG
26
+ * tree's events.jsonl, the mirror of the trace-autogen worktree defect. Tools
27
+ * that carry no file_path (Bash, Skill, Agent…) have no target to resolve —
28
+ * the session cwd stays the reference for those, same as before.
29
+ *
21
30
  * SILENT: never blocks, never injects context — only appends. Fail-open on any
22
31
  * error (exit 0). Only active inside a morph project (stateExists gate).
23
32
  */
24
33
 
25
- import { readStdin } from '../../shared/stdin-reader.js';
34
+ import { statSync } from 'fs';
26
35
  import { stateExists, getActiveFeature, derivePhaseForFeature } from '../../shared/state-reader.js';
27
36
  import { appendTelemetryEvent } from '../../shared/telemetry-logger.js';
28
- import { pass } from '../../shared/hook-response.js';
37
+ import { runStandalone } from '../../shared/hook-response.js';
38
+ import { getFilePath, resolveMorphRoot, resolveTargetPath } from '../../shared/payload-utils.js';
29
39
 
30
40
  function normalize(p) {
31
41
  return (p || '').replace(/\\/g, '/');
32
42
  }
33
43
 
34
- try {
35
- if (!stateExists()) pass();
36
-
37
- const payload = await readStdin();
38
- if (!payload) pass();
44
+ /**
45
+ * Classify a Read target into the telemetry `kind` taxonomy — a contract with
46
+ * the observability side (src/lib/telemetry/telemetry-reader.js), so these
47
+ * values are pinned, not free-form. Order matters: check the specific
48
+ * categories before falling back to 'code'. Case-insensitive; `/` and `\`
49
+ * both normalize to `/` upstream via `normalize()`.
50
+ */
51
+ function classifyReadKind(target) {
52
+ const t = (target || '').toLowerCase();
39
53
 
40
- const cwd = process.cwd();
54
+ if (t.includes('evals/rubrics')) return 'rubric';
55
+ if (t.includes('standards/')) return 'standard';
56
+ if (t.endsWith('skill.md') || t.includes('/skills/')) return 'skill';
57
+ if (t.split('/').pop() === 'agents.json') return 'agents';
41
58
 
42
- // Correlation key: active feature + its phase (explicit, not ambient).
43
- // NOTE (perf): getActiveFeature → loadState hydrates every .morph/features/*/
44
- // feature.json, so enrichment is O(active-feature-count) per tool call. That
45
- // is fine for realistic projects (archive removes finished features, so the
46
- // dir holds only the few in-flight ones) but would grow on a project that
47
- // keeps many un-archived features. If that ever bites, resolve the active
48
- // feature from the thin state.json index alone. Kept accurate-over-cheap for
49
- // now because phase is not stored in feature.json — it needs derivation.
50
- let feature = null;
51
- let phase = null;
52
- try {
53
- const active = getActiveFeature(cwd);
54
- if (active) {
55
- feature = active.name;
56
- phase = active.feature?.phase || derivePhaseForFeature(active.name, cwd);
59
+ // Inside a feature folder AND one of the named plan artifacts (or under a
60
+ // usecases/ or reports/ subfolder) — everything else in a feature folder
61
+ // (source files a task produced, say) is 'code'.
62
+ const inFeature = t.match(/\.morph\/features\/[^/]+\/(.+)$/);
63
+ if (inFeature) {
64
+ const rel = inFeature[1];
65
+ const PLAN_ARTIFACT_BASENAMES = new Set([
66
+ 'proposal.md', 'spec.md', 'mandate.md', 'tasks.json',
67
+ 'decisions.md', 'recap.md', 'handoff.md',
68
+ ]);
69
+ const base = rel.split('/').pop();
70
+ if (PLAN_ARTIFACT_BASENAMES.has(base) || rel.includes('usecases/') || rel.includes('reports/')) {
71
+ return 'plan-artifact';
57
72
  }
58
- } catch {
59
- // enrichment is best-effort — a missing feature never blocks capture
60
73
  }
61
74
 
62
- // Claim heartbeat (B1): this hook already fires on EVERY tool call, so it is
63
- // the natural proof-of-life for the advisory feature claim — no extra hook,
64
- // no discipline asked of the operator. The registry throttles the actual
65
- // write (HEARTBEAT_THROTTLE_MS), so this is a small read on most calls.
66
- // Silent and fail-open, like everything else here.
67
- if (feature && payload.session_id) {
75
+ return 'code';
76
+ }
77
+
78
+ /**
79
+ * @param {object} payload - parsed PostToolUse (any tool) or UserPromptSubmit payload
80
+ * @returns {Promise<null>} always null — this hook is pure side-effect
81
+ * (appends to events.jsonl + claim heartbeat), never prints/injects context.
82
+ */
83
+ export async function run(payload) {
84
+ try {
85
+ if (!payload) return null;
86
+
87
+ // Resolve the sink tree from the tool's target file (when it has one)
88
+ // BEFORE the stateExists gate below — otherwise a root session writing
89
+ // into a worktree would be gated on the ROOT's state instead of the
90
+ // worktree's.
91
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
92
+ const targetFilePath = getFilePath(payload);
93
+ const cwd = targetFilePath ? resolveMorphRoot(targetFilePath, projectDir) : projectDir;
94
+
95
+ if (!stateExists(cwd)) return null;
96
+
97
+ // Correlation key: active feature + its phase (explicit, not ambient).
98
+ // NOTE (perf): getActiveFeature → loadState hydrates every .morph/features/*/
99
+ // feature.json, so enrichment is O(active-feature-count) per tool call. That
100
+ // is fine for realistic projects (archive removes finished features, so the
101
+ // dir holds only the few in-flight ones) but would grow on a project that
102
+ // keeps many un-archived features. If that ever bites, resolve the active
103
+ // feature from the thin state.json index alone. Kept accurate-over-cheap for
104
+ // now because phase is not stored in feature.json — it needs derivation.
105
+ let feature = null;
106
+ let phase = null;
68
107
  try {
69
- const { execSync } = await import('child_process');
70
- const { resolve } = await import('path');
71
- const { heartbeatClaim } = await import('../../shared/claim-registry.js');
72
- const common = execSync('git rev-parse --git-common-dir', { cwd, stdio: 'pipe' }).toString().trim();
73
- if (common) heartbeatClaim(feature, { gitCommonDir: resolve(cwd, common), sessionId: payload.session_id });
74
- } catch { /* fail-open — a missed heartbeat only shortens a claim's life */ }
75
- }
108
+ const active = getActiveFeature(cwd);
109
+ if (active) {
110
+ feature = active.name;
111
+ phase = active.feature?.phase || derivePhaseForFeature(active.name, cwd);
112
+ }
113
+ } catch {
114
+ // enrichment is best-effort — a missing feature never blocks capture
115
+ }
116
+
117
+ // Claim heartbeat (B1): this hook already fires on EVERY tool call, so it is
118
+ // the natural proof-of-life for the advisory feature claim — no extra hook,
119
+ // no discipline asked of the operator. Two throttles guard the cost:
120
+ // 1. shouldHeartbeat() — a LOCAL, git-independent pre-check (see its own
121
+ // doc in claim-registry.js) that answers "is a heartbeat even due"
122
+ // from a marker beside this project's own `.morph/logs/`. On ~99% of
123
+ // calls it says no and NOTHING git-related happens — not even the
124
+ // pure-FS walk below, let alone a subprocess.
125
+ // 2. Only when (1) says yes do we resolve the git common dir — via
126
+ // git-paths.js, pure filesystem, no `git` subprocess spawn (this used
127
+ // to be `execSync('git rev-parse --git-common-dir')`, ~34ms wasted on
128
+ // ~99% of calls because the write below was ALREADY throttled) — and
129
+ // call heartbeatClaim(), which has its own authoritative throttle
130
+ // keyed to claims.json's stored heartbeatAt.
131
+ // Silent and fail-open, like everything else here.
132
+ if (feature && payload.session_id) {
133
+ try {
134
+ const { shouldHeartbeat, heartbeatClaim } = await import('../../shared/claim-registry.js');
135
+ if (shouldHeartbeat(feature, { cwd, sessionId: payload.session_id })) {
136
+ const { resolveGitCommonDir } = await import('../../shared/git-paths.js');
137
+ const common = resolveGitCommonDir(cwd);
138
+ if (common) heartbeatClaim(feature, { gitCommonDir: common, sessionId: payload.session_id });
139
+ }
140
+ } catch { /* fail-open — a missed heartbeat only shortens a claim's life */ }
141
+ }
76
142
 
77
- let event;
78
-
79
- // UserPromptSubmit carries the user's prompt and no tool_name.
80
- if (payload.hook_event_name === 'UserPromptSubmit' || (!payload.tool_name && typeof payload.prompt === 'string')) {
81
- event = { event: 'UserPromptSubmit', tool: 'UserPrompt', prompt: payload.prompt || '' };
82
- } else {
83
- const tool = payload.tool_name;
84
- if (!tool) pass();
85
- const input = payload.tool_input || {};
86
- event = { event: 'PostToolUse', tool };
87
-
88
- if (tool === 'Skill') {
89
- event.skill = input.skill || '';
90
- } else if (tool === 'Agent' || tool === 'Task') {
91
- event.persona = input.subagent_type || '';
92
- if (input.description) event.desc = input.description;
93
- event.prompt = input.prompt || '';
94
- } else if (tool === 'Read') {
95
- event.target = normalize(input.file_path || input.path);
96
- if (/\/standards\//.test(event.target)) event.kind = 'standard';
97
- } else if (tool === 'Bash') {
98
- event.command = input.command || '';
99
- } else if (tool === 'Write' || tool === 'Edit') {
100
- event.target = normalize(input.file_path || input.path);
143
+ let event;
144
+
145
+ // UserPromptSubmit carries the user's prompt and no tool_name.
146
+ if (payload.hook_event_name === 'UserPromptSubmit' || (!payload.tool_name && typeof payload.prompt === 'string')) {
147
+ event = { event: 'UserPromptSubmit', tool: 'UserPrompt', prompt: payload.prompt || '' };
101
148
  } else {
102
- // Every other tool still counts toward "the whole path" — record a
103
- // compact target so Grep/Glob/WebFetch bursts remain distinguishable.
104
- const target = input.file_path || input.path || input.pattern || input.query || input.url || '';
105
- if (target) event.target = normalize(String(target));
149
+ const tool = payload.tool_name;
150
+ if (!tool) return null;
151
+ const input = payload.tool_input || {};
152
+ event = { event: 'PostToolUse', tool };
153
+
154
+ if (tool === 'Skill') {
155
+ event.skill = input.skill || '';
156
+ } else if (tool === 'Agent' || tool === 'Task') {
157
+ event.persona = input.subagent_type || '';
158
+ if (input.description) event.desc = input.description;
159
+ event.prompt = input.prompt || '';
160
+ } else if (tool === 'Read') {
161
+ event.target = targetFilePath || normalize(input.file_path || input.path);
162
+ event.kind = classifyReadKind(event.target);
163
+ // bytes is best-effort: statSync races a file that got deleted/moved
164
+ // between the Read and this hook firing, or lacks permission — omit
165
+ // the field rather than fail the whole (silent, fail-open) capture.
166
+ try {
167
+ event.bytes = statSync(resolveTargetPath(event.target, cwd)).size;
168
+ } catch {
169
+ // omit bytes
170
+ }
171
+ } else if (tool === 'Bash') {
172
+ event.command = input.command || '';
173
+ } else if (tool === 'Write' || tool === 'Edit') {
174
+ event.target = normalize(input.file_path || input.path);
175
+ } else {
176
+ // Every other tool still counts toward "the whole path" — record a
177
+ // compact target so Grep/Glob/WebFetch bursts remain distinguishable.
178
+ const target = input.file_path || input.path || input.pattern || input.query || input.url || '';
179
+ if (target) event.target = normalize(String(target));
180
+ }
106
181
  }
107
- }
108
182
 
109
- event.feature = feature;
110
- event.phase = phase;
183
+ event.feature = feature;
184
+ event.phase = phase;
111
185
 
112
- appendTelemetryEvent(event, cwd);
113
- pass();
114
- } catch {
115
- // Fail-open
116
- process.exit(0);
186
+ appendTelemetryEvent(event, cwd);
187
+ return null;
188
+ } catch {
189
+ // Fail-open
190
+ return null;
191
+ }
117
192
  }
193
+
194
+ await runStandalone(run);
@@ -24,13 +24,22 @@
24
24
  * (session_id/started_at preserved); a new dated file is created otherwise.
25
25
  * `completed_at` + phase_summary are stamped when no task is left
26
26
  * pending/in_progress. Fail-open: any error exits 0 silently.
27
+ *
28
+ * This is also the vantage point for TaskStart/TaskEnd telemetry: a
29
+ * pending→in_progress flip and a flip to done/skipped are the only two task
30
+ * transitions the harness can observe (tasks.json is the single place task
31
+ * status is written), so this hook diffs each task's previous status against
32
+ * `.morph/logs/task-times.json` and appends the matching event via the shared
33
+ * telemetry logger (see `updateTaskTimes` below). The same start/end pair
34
+ * feeds `duration_seconds` in the trace entry.
27
35
  */
28
36
 
29
37
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
30
38
  import { join } from 'path';
31
- import { readStdin } from '../../shared/stdin-reader.js';
39
+ import { runStandalone } from '../../shared/hook-response.js';
32
40
  import { stateExists } from '../../shared/state-reader.js';
33
41
  import { getFilePath, resolveMorphRoot } from '../../shared/payload-utils.js';
42
+ import { appendTelemetryEvent } from '../../shared/telemetry-logger.js';
34
43
 
35
44
  function projectDir() {
36
45
  return process.env.CLAUDE_PROJECT_DIR || process.cwd();
@@ -68,8 +77,100 @@ function sessionId(d) {
68
77
  /** tasks.json status → trace-schema status. Unsettled statuses are omitted. */
69
78
  const STATUS_MAP = { done: 'completed', skipped: 'skipped', blocked: 'failed' };
70
79
 
71
- async function main() {
72
- const payload = await readStdin();
80
+ const TASK_TIMES_PATH = join('.morph', 'logs', 'task-times.json');
81
+
82
+ /**
83
+ * Read `.morph/logs/task-times.json`: `{ [feature]: { [taskId]: { status,
84
+ * start?, end? } } }`. Nested by feature — not flat by task id, as a literal
85
+ * reading of "keyed by taskId" might suggest — because task ids (T1, T2…)
86
+ * are only unique WITHIN a feature; a flat map would let two sibling
87
+ * features in the same tree (see worktree-state-resolution.test.js for that
88
+ * shape) clobber each other's start/end timestamps.
89
+ * Fail-open: a missing/corrupt file reads as {}.
90
+ */
91
+ function readTaskTimes(root) {
92
+ const p = join(root, TASK_TIMES_PATH);
93
+ if (!existsSync(p)) return {};
94
+ try {
95
+ return JSON.parse(readFileSync(p, 'utf8'));
96
+ } catch {
97
+ return {};
98
+ }
99
+ }
100
+
101
+ function writeTaskTimes(root, allTimes) {
102
+ mkdirSync(join(root, '.morph', 'logs'), { recursive: true });
103
+ writeFileSync(join(root, TASK_TIMES_PATH), JSON.stringify(allTimes, null, 2), 'utf8');
104
+ }
105
+
106
+ /**
107
+ * Diff each task's PREVIOUS status (from `times`, this feature's slice of
108
+ * task-times.json) against its status in the tasks.json that was just
109
+ * written, and append the matching telemetry event via the shared logger —
110
+ * same shape/mechanism as every other event in events.jsonl, just a
111
+ * different `event` value.
112
+ *
113
+ * - pending → in_progress ⇒ TaskStart, stamp `start`
114
+ * - anything → done/skipped ⇒ TaskEnd (carries the new status), stamp `end`
115
+ * - first sight of a task id (no prior entry) only seeds the baseline,
116
+ * silently — there is no prior status to diff, and emitting an event here
117
+ * would fabricate a transition that never happened (e.g. a task whose
118
+ * first-ever tasks.json write already shows it `done`, from historical
119
+ * data predating this file).
120
+ *
121
+ * Mutates `times` in place and returns whether anything changed, so the
122
+ * caller only rewrites task-times.json when needed and the entries built
123
+ * right after this call see the freshly-stamped start/end for
124
+ * `duration_seconds`.
125
+ */
126
+ function updateTaskTimes(times, tasks, feature, root, nowIso) {
127
+ let changed = false;
128
+ for (const t of tasks) {
129
+ const prev = times[t.id];
130
+ if (!prev) {
131
+ times[t.id] = { status: t.status };
132
+ changed = true;
133
+ continue;
134
+ }
135
+ if (prev.status === t.status) continue;
136
+
137
+ if (prev.status === 'pending' && t.status === 'in_progress') {
138
+ appendTelemetryEvent({ event: 'TaskStart', feature, task: t.id, phase: 'implement' }, root);
139
+ times[t.id] = { ...prev, status: t.status, start: nowIso };
140
+ } else if (t.status === 'done' || t.status === 'skipped') {
141
+ appendTelemetryEvent({ event: 'TaskEnd', feature, task: t.id, status: t.status, phase: 'implement' }, root);
142
+ times[t.id] = { ...prev, status: t.status, end: nowIso };
143
+ } else {
144
+ times[t.id] = { ...prev, status: t.status };
145
+ }
146
+ changed = true;
147
+ }
148
+ return changed;
149
+ }
150
+
151
+ /** Seconds between recorded start/end for a task, or null when either is missing. */
152
+ function taskDuration(times, taskId) {
153
+ const rec = times[taskId];
154
+ if (!rec?.start || !rec?.end) return null;
155
+ const secs = (new Date(rec.end).getTime() - new Date(rec.start).getTime()) / 1000;
156
+ return Number.isFinite(secs) ? Math.round(secs * 10) / 10 : null;
157
+ }
158
+
159
+ /**
160
+ * @param {object} payload - parsed PostToolUse stdin payload (Write|Edit)
161
+ * @returns {Promise<null>} always null — this hook is pure side-effect (trace
162
+ * file + task-times.json), never prints/injects context.
163
+ */
164
+ export async function run(payload) {
165
+ try {
166
+ await generateTrace(payload);
167
+ } catch {
168
+ // Fail-open
169
+ }
170
+ return null;
171
+ }
172
+
173
+ async function generateTrace(payload) {
73
174
  if (!payload) return;
74
175
 
75
176
  const filePath = getFilePath(payload);
@@ -90,6 +191,16 @@ async function main() {
90
191
  const tasksDoc = JSON.parse(readFileSync(tasksPath, 'utf8'));
91
192
  const tasks = Array.isArray(tasksDoc.tasks) ? tasksDoc.tasks : [];
92
193
 
194
+ const now = new Date();
195
+
196
+ // TaskStart/TaskEnd telemetry + duration_seconds source: diff each task's
197
+ // previous status (persisted below) against the status just written.
198
+ const allTimes = readTaskTimes(cwd);
199
+ const times = allTimes[feature] ?? (allTimes[feature] = {});
200
+ if (updateTaskTimes(times, tasks, feature, cwd, now.toISOString())) {
201
+ writeTaskTimes(cwd, allTimes);
202
+ }
203
+
93
204
  let recap = {};
94
205
  const recapPath = join(featureDir, '3-implement', 'recap.md');
95
206
  if (existsSync(recapPath)) {
@@ -118,7 +229,6 @@ async function main() {
118
229
  try { previous = JSON.parse(readFileSync(join(tracesDir, existing), 'utf8')); } catch { previous = null; }
119
230
  }
120
231
 
121
- const now = new Date();
122
232
  const fileName = existing ?? `${now.toISOString().slice(0, 10)}${suffix}`;
123
233
 
124
234
  const entries = tasks
@@ -144,7 +254,7 @@ async function main() {
144
254
  test_coverage: dims.testCoverage ?? null,
145
255
  } : null,
146
256
  files_modified: Array.isArray(t.outputs) ? t.outputs.filter(o => typeof o === 'string') : [],
147
- duration_seconds: null,
257
+ duration_seconds: taskDuration(times, t.id),
148
258
  };
149
259
  });
150
260
 
@@ -185,4 +295,4 @@ async function main() {
185
295
  writeFileSync(join(tracesDir, fileName), JSON.stringify(trace, null, 2), 'utf8');
186
296
  }
187
297
 
188
- main().catch(() => { /* fail-open */ }).finally(() => process.exit(0));
298
+ await runStandalone(run);