@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
|
@@ -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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
242
|
-
|
|
234
|
+
const input = payload || {};
|
|
235
|
+
const toolName = input.tool_name;
|
|
243
236
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
50
|
+
const absPath = isAbsolute(filePath) ? filePath : join(cwd, filePath);
|
|
54
51
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
} catch {
|
|
81
|
-
|
|
82
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
} catch {
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
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
|
-
|
|
110
|
-
|
|
183
|
+
event.feature = feature;
|
|
184
|
+
event.phase = phase;
|
|
111
185
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
} catch {
|
|
115
|
-
|
|
116
|
-
|
|
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 {
|
|
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
|
-
|
|
72
|
-
|
|
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:
|
|
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
|
-
|
|
298
|
+
await runStandalone(run);
|