@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.
Files changed (53) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +19 -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 +10 -4
  7. package/framework/MORPH.md +4 -4
  8. package/framework/evals/rubrics/condensed.md +49 -0
  9. package/framework/hooks/README.md +3 -3
  10. package/framework/hooks/claude-code/core/chain.js +123 -0
  11. package/framework/hooks/claude-code/core/enforce-phase-writes.js +84 -77
  12. package/framework/hooks/claude-code/core/gate-guard.js +21 -25
  13. package/framework/hooks/claude-code/core/loop-detect.js +70 -64
  14. package/framework/hooks/claude-code/core/post-edit-typecheck.js +94 -45
  15. package/framework/hooks/claude-code/core/pre-bash-denylist.js +21 -15
  16. package/framework/hooks/claude-code/core/protect-spec-files.js +119 -103
  17. package/framework/hooks/claude-code/core/state-sync.js +32 -40
  18. package/framework/hooks/claude-code/core/tasks-json-guard.js +44 -44
  19. package/framework/hooks/claude-code/core/telemetry-log.js +151 -74
  20. package/framework/hooks/claude-code/core/trace-autogen.js +116 -6
  21. package/framework/hooks/claude-code/core/validate-completion.js +147 -107
  22. package/framework/hooks/claude-code/core/worktree-context.js +25 -22
  23. package/framework/hooks/claude-code/statusline.py +26 -4
  24. package/framework/hooks/shared/activity-logger.js +120 -69
  25. package/framework/hooks/shared/claim-registry.js +61 -1
  26. package/framework/hooks/shared/git-paths.js +94 -0
  27. package/framework/hooks/shared/hook-response.js +67 -0
  28. package/framework/hooks/shared/state-reader.js +95 -29
  29. package/framework/hooks/shared/state-writer.js +16 -0
  30. package/framework/rules/morph-task-tracking.md +4 -2
  31. package/framework/schemas/tasks.schema.json +10 -0
  32. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  33. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  34. package/framework/skills/core/morph-apply/SKILL.md +40 -28
  35. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  36. package/framework/skills/core/morph-eval/SKILL.md +16 -13
  37. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  38. package/framework/skills/core/morph-implement/SKILL.md +26 -25
  39. package/framework/skills/core/morph-plan/SKILL.md +14 -3
  40. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  41. package/framework/skills/core/morph-review/SKILL.md +1 -1
  42. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  43. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  44. package/package.json +2 -2
  45. package/src/commands/observability/cost.js +137 -0
  46. package/src/commands/project/standards.js +220 -0
  47. package/src/commands/state/approve.js +30 -9
  48. package/src/dashboard/scanner/hooks-scanner.js +11 -2
  49. package/src/lib/installers/memory-installer.js +1 -1
  50. package/src/lib/tasks/tasks-json-parser.js +26 -0
  51. package/src/lib/telemetry/telemetry-reader.js +213 -0
  52. package/src/utils/hooks-installer.js +84 -79
  53. package/framework/hooks/shared/session-snapshot.js +0 -105
@@ -24,7 +24,6 @@
24
24
  */
25
25
 
26
26
  import { readFileSync } from 'fs';
27
- import { readStdin } from '../../shared/stdin-reader.js';
28
27
  import { stateExists, isGateApproved } from '../../shared/state-reader.js';
29
28
  import {
30
29
  isFeaturePath,
@@ -33,7 +32,7 @@ import {
33
32
  PROTECTED_SPEC_FILES,
34
33
  MUTABLE_SPEC_FIELDS
35
34
  } from '../../shared/phase-utils.js';
36
- import { block, pass } from '../../shared/hook-response.js';
35
+ import { blockResult, runStandalone } from '../../shared/hook-response.js';
37
36
  import { logHookActivity } from '../../shared/activity-logger.js';
38
37
  import { getFilePath, getToolName, resolveMorphRoot, resolveTargetPath } from '../../shared/payload-utils.js';
39
38
  import { validateTasksDoc, outputsDivergenceViolations } from '../../shared/tasks-validator.js';
@@ -42,6 +41,15 @@ import { validateTasksDoc, outputsDivergenceViolations } from '../../shared/task
42
41
  * Compare two tasks.json structures and return the list of plan-contract
43
42
  * violations — changes that touch anything other than the mutable fields.
44
43
  *
44
+ * Frozen-by-default, not an enumerated frozen list: the loop below walks the
45
+ * UNION of old/new task keys and flags any key not in `mutableFields`. A new
46
+ * plan-contract field (e.g. `agent`/`standards`, stamped by morph-plan — R2
47
+ * token-cut) needs NO entry added here to become protected; it is frozen the
48
+ * moment it exists on a task, simply by never being added to
49
+ * MUTABLE_SPEC_FIELDS (output-schema.js → `npm run generate` →
50
+ * framework/hooks/shared/phase-utils.js). Only fields that must stay
51
+ * EDITABLE post-Gate-2 (today: status, outputs, notes) belong in that list.
52
+ *
45
53
  * @param {object} oldDoc - parsed previous file content
46
54
  * @param {object} newDoc - parsed proposed file content
47
55
  * @param {string[]} mutableFields - field names allowed to change
@@ -98,124 +106,132 @@ function diffPlanContract(oldDoc, newDoc, mutableFields) {
98
106
  return violations;
99
107
  }
100
108
 
101
- try {
102
- // Amend mode: bypass spec protection for legitimate in-implementation corrections
103
- if (process.env.MORPH_AMEND_PHASE) {
104
- console.error(`[morph-spec] AMEND MODE: bypassing spec protection for phase '${process.env.MORPH_AMEND_PHASE}'`);
105
- pass();
106
- }
107
-
108
- const payload = await readStdin();
109
- if (!payload) pass();
109
+ /**
110
+ * @param {object} payload - parsed PreToolUse stdin payload
111
+ * @returns {Promise<object|null>} `{decision:'block',reason}` or null (pass).
112
+ * Never prints, never exits — see runStandalone()/chain.js at the bottom.
113
+ */
114
+ export async function run(payload) {
115
+ try {
116
+ // Amend mode: bypass spec protection for legitimate in-implementation corrections
117
+ if (process.env.MORPH_AMEND_PHASE) {
118
+ console.error(`[morph-spec] AMEND MODE: bypassing spec protection for phase '${process.env.MORPH_AMEND_PHASE}'`);
119
+ return null;
120
+ }
110
121
 
111
- const filePath = getFilePath(payload);
112
- if (!filePath) pass();
122
+ if (!payload) return null;
113
123
 
114
- // Only check files inside .morph/features/
115
- if (!isFeaturePath(filePath)) pass();
124
+ const filePath = getFilePath(payload);
125
+ if (!filePath) return null;
116
126
 
117
- // The gate that locks this file is recorded in the feature.json of the tree
118
- // that OWNS it. Asking the session's tree instead made this guard go DARK in
119
- // a secondary worktree: the root had no approved gate, so a post-Gate-2
120
- // spec.md rewrite sailed through. Same defect as enforce-phase-writes,
121
- // opposite direction — there it over-blocked, here it under-protected.
122
- const root = resolveMorphRoot(filePath, process.cwd());
123
- if (!stateExists(root)) pass();
127
+ // Only check files inside .morph/features/
128
+ if (!isFeaturePath(filePath)) return null;
124
129
 
125
- const featureName = extractFeatureName(filePath);
126
- if (!featureName) pass();
130
+ // The gate that locks this file is recorded in the feature.json of the tree
131
+ // that OWNS it. Asking the session's tree instead made this guard go DARK in
132
+ // a secondary worktree: the root had no approved gate, so a post-Gate-2
133
+ // spec.md rewrite sailed through. Same defect as enforce-phase-writes,
134
+ // opposite direction — there it over-blocked, here it under-protected.
135
+ const root = resolveMorphRoot(filePath, process.cwd());
136
+ if (!stateExists(root)) return null;
127
137
 
128
- const filename = getBasename(filePath);
129
- const requiredGate = PROTECTED_SPEC_FILES[filename];
138
+ const featureName = extractFeatureName(filePath);
139
+ if (!featureName) return null;
130
140
 
131
- // Not a protected file
132
- if (!requiredGate) pass();
141
+ const filename = getBasename(filePath);
142
+ const requiredGate = PROTECTED_SPEC_FILES[filename];
133
143
 
134
- // Gate not yet approved — allow editing
135
- if (!isGateApproved(featureName, requiredGate, root)) {
136
- logHookActivity('protect-spec-files', 'PreToolUse', 'ok');
137
- pass();
138
- }
144
+ // Not a protected file
145
+ if (!requiredGate) return null;
139
146
 
140
- // ── Gate approved — file is locked ──────────────────────────────────────
141
- const mutableFields = MUTABLE_SPEC_FIELDS[filename];
142
-
143
- // Files with mutable progress fields (e.g. tasks.json): allow a full Write
144
- // that touches only those fields. Anything else stays blocked.
145
- if (mutableFields) {
146
- const toolName = getToolName(payload);
147
-
148
- // Build the proposed document from either a full Write or a targeted Edit,
149
- // then semantic-diff it against the current file on disk. Any failure to
150
- // read / parse / apply → fail-open (pass), never a false block.
151
- const absPath = resolveTargetPath(filePath, root);
152
- let oldDoc, newDoc;
153
- try {
154
- const original = readFileSync(absPath, 'utf-8');
155
- oldDoc = JSON.parse(original);
156
-
157
- if (toolName === 'Edit') {
158
- // Simulate the edit: apply old_string → new_string to the current file.
159
- const oldStr = payload?.tool_input?.old_string;
160
- const newStr = payload?.tool_input?.new_string;
161
- if (typeof oldStr !== 'string' || typeof newStr !== 'string') throw new Error('no edit strings');
162
- const edited = payload?.tool_input?.replace_all
163
- ? original.split(oldStr).join(newStr)
164
- : original.replace(oldStr, newStr);
165
- newDoc = JSON.parse(edited);
166
- } else {
167
- const newContent = payload?.tool_input?.content;
168
- if (typeof newContent !== 'string') throw new Error('no content');
169
- newDoc = JSON.parse(newContent);
170
- }
171
- } catch {
147
+ // Gate not yet approved — allow editing
148
+ if (!isGateApproved(featureName, requiredGate, root)) {
172
149
  logHookActivity('protect-spec-files', 'PreToolUse', 'ok');
173
- pass();
150
+ return null;
174
151
  }
175
152
 
176
- const violations = diffPlanContract(oldDoc, newDoc, mutableFields);
153
+ // ── Gate approved — file is locked ──────────────────────────────────────
154
+ const mutableFields = MUTABLE_SPEC_FIELDS[filename];
155
+
156
+ // Files with mutable progress fields (e.g. tasks.json): allow a full Write
157
+ // that touches only those fields. Anything else stays blocked.
158
+ if (mutableFields) {
159
+ const toolName = getToolName(payload);
160
+
161
+ // Build the proposed document from either a full Write or a targeted Edit,
162
+ // then semantic-diff it against the current file on disk. Any failure to
163
+ // read / parse / apply → fail-open (pass), never a false block.
164
+ const absPath = resolveTargetPath(filePath, root);
165
+ let oldDoc, newDoc;
166
+ try {
167
+ const original = readFileSync(absPath, 'utf-8');
168
+ oldDoc = JSON.parse(original);
169
+
170
+ if (toolName === 'Edit') {
171
+ // Simulate the edit: apply old_string → new_string to the current file.
172
+ const oldStr = payload?.tool_input?.old_string;
173
+ const newStr = payload?.tool_input?.new_string;
174
+ if (typeof oldStr !== 'string' || typeof newStr !== 'string') throw new Error('no edit strings');
175
+ const edited = payload?.tool_input?.replace_all
176
+ ? original.split(oldStr).join(newStr)
177
+ : original.replace(oldStr, newStr);
178
+ newDoc = JSON.parse(edited);
179
+ } else {
180
+ const newContent = payload?.tool_input?.content;
181
+ if (typeof newContent !== 'string') throw new Error('no content');
182
+ newDoc = JSON.parse(newContent);
183
+ }
184
+ } catch {
185
+ logHookActivity('protect-spec-files', 'PreToolUse', 'ok');
186
+ return null;
187
+ }
177
188
 
178
- // Schema check on the proposed doc: even mutable-field writes must keep a
179
- // valid status enum (a leaf agent writing status:"completed" was the
180
- // production failure this closes). Only NEWLY introduced schema errors
181
- // block — a pre-existing violation must not deadlock progress writes.
182
- const preExisting = new Set(validateTasksDoc(oldDoc).errors);
183
- violations.push(...validateTasksDoc(newDoc).errors.filter(e => !preExisting.has(e)));
189
+ const violations = diffPlanContract(oldDoc, newDoc, mutableFields);
184
190
 
185
- // Planned-vs-real outputs diff: changing `outputs` is allowed, but the
186
- // divergence must be explained in `notes` in the same edit.
187
- violations.push(...outputsDivergenceViolations(oldDoc, newDoc));
191
+ // Schema check on the proposed doc: even mutable-field writes must keep a
192
+ // valid status enum (a leaf agent writing status:"completed" was the
193
+ // production failure this closes). Only NEWLY introduced schema errors
194
+ // block — a pre-existing violation must not deadlock progress writes.
195
+ const preExisting = new Set(validateTasksDoc(oldDoc).errors);
196
+ violations.push(...validateTasksDoc(newDoc).errors.filter(e => !preExisting.has(e)));
188
197
 
189
- if (violations.length === 0) {
190
- // Only mutable progress fields changed — a legitimate implement-phase
191
- // update via Write OR Edit.
192
- logHookActivity('protect-spec-files', 'PreToolUse', 'ok');
193
- pass();
198
+ // Planned-vs-real outputs diff: changing `outputs` is allowed, but the
199
+ // divergence must be explained in `notes` in the same edit.
200
+ violations.push(...outputsDivergenceViolations(oldDoc, newDoc));
201
+
202
+ if (violations.length === 0) {
203
+ // Only mutable progress fields changed — a legitimate implement-phase
204
+ // update via Write OR Edit.
205
+ logHookActivity('protect-spec-files', 'PreToolUse', 'ok');
206
+ return null;
207
+ }
208
+
209
+ logHookActivity('protect-spec-files', 'PreToolUse', 'blocked');
210
+ return blockResult(
211
+ `MORPH-SPEC: '${filename}' write rejected — the '${requiredGate}' gate has been approved.\n` +
212
+ `This ${toolName} violates the plan contract or the tasks schema:\n` +
213
+ violations.map(v => ` • ${v}`).join('\n') + '\n\n' +
214
+ `During the implement phase only these fields may change: ${mutableFields.join(', ')}\n` +
215
+ `(status must stay in the schema enum, and outputs that diverge from the plan\n` +
216
+ `require an explanatory 'notes' in the same edit).\n` +
217
+ `To change the plan itself, ask the user to revoke the '${requiredGate}' gate,\n` +
218
+ `then re-present it for approval.`
219
+ );
194
220
  }
195
221
 
222
+ // Fully-locked file (spec.md, mandate.md, design files): blanket block.
196
223
  logHookActivity('protect-spec-files', 'PreToolUse', 'blocked');
197
- block(
198
- `MORPH-SPEC: '${filename}' write rejected — the '${requiredGate}' gate has been approved.\n` +
199
- `This ${toolName} violates the plan contract or the tasks schema:\n` +
200
- violations.map(v => ` • ${v}`).join('\n') + '\n\n' +
201
- `During the implement phase only these fields may change: ${mutableFields.join(', ')}\n` +
202
- `(status must stay in the schema enum, and outputs that diverge from the plan\n` +
203
- `require an explanatory 'notes' in the same edit).\n` +
204
- `To change the plan itself, ask the user to revoke the '${requiredGate}' gate,\n` +
205
- `then re-present it for approval.`
224
+ return blockResult(
225
+ `MORPH-SPEC: '${filename}' is locked — the '${requiredGate}' gate has been approved.\n` +
226
+ `Editing approved specs breaks the spec contract.\n\n` +
227
+ `If changes are truly needed, ask the user to revoke the gate approval\n` +
228
+ `in .morph/state.json (set approvals.${requiredGate} to false), make\n` +
229
+ `your edits, then re-present the gate for approval.`
206
230
  );
231
+ } catch {
232
+ // Fail-open
233
+ return null;
207
234
  }
208
-
209
- // Fully-locked file (spec.md, mandate.md, design files): blanket block.
210
- logHookActivity('protect-spec-files', 'PreToolUse', 'blocked');
211
- block(
212
- `MORPH-SPEC: '${filename}' is locked — the '${requiredGate}' gate has been approved.\n` +
213
- `Editing approved specs breaks the spec contract.\n\n` +
214
- `If changes are truly needed, ask the user to revoke the gate approval\n` +
215
- `in .morph/state.json (set approvals.${requiredGate} to false), make\n` +
216
- `your edits, then re-present the gate for approval.`
217
- );
218
- } catch {
219
- // Fail-open
220
- process.exit(0);
221
235
  }
236
+
237
+ await runStandalone(run);
@@ -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);