@polymorphism-tech/morph-spec 8.15.1 → 8.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/morph-spec.js +19 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +10 -4
- package/framework/MORPH.md +4 -4
- package/framework/evals/rubrics/condensed.md +49 -0
- package/framework/hooks/README.md +3 -3
- package/framework/hooks/claude-code/core/chain.js +123 -0
- package/framework/hooks/claude-code/core/enforce-phase-writes.js +84 -77
- package/framework/hooks/claude-code/core/gate-guard.js +21 -25
- package/framework/hooks/claude-code/core/loop-detect.js +70 -64
- package/framework/hooks/claude-code/core/post-edit-typecheck.js +94 -45
- package/framework/hooks/claude-code/core/pre-bash-denylist.js +21 -15
- package/framework/hooks/claude-code/core/protect-spec-files.js +119 -103
- package/framework/hooks/claude-code/core/state-sync.js +32 -40
- package/framework/hooks/claude-code/core/tasks-json-guard.js +44 -44
- package/framework/hooks/claude-code/core/telemetry-log.js +151 -74
- package/framework/hooks/claude-code/core/trace-autogen.js +116 -6
- package/framework/hooks/claude-code/core/validate-completion.js +147 -107
- package/framework/hooks/claude-code/core/worktree-context.js +25 -22
- package/framework/hooks/claude-code/statusline.py +26 -4
- package/framework/hooks/shared/activity-logger.js +120 -69
- package/framework/hooks/shared/claim-registry.js +61 -1
- package/framework/hooks/shared/git-paths.js +94 -0
- package/framework/hooks/shared/hook-response.js +67 -0
- package/framework/hooks/shared/state-reader.js +95 -29
- package/framework/hooks/shared/state-writer.js +16 -0
- package/framework/rules/morph-task-tracking.md +4 -2
- package/framework/schemas/tasks.schema.json +10 -0
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +40 -28
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +16 -13
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +26 -25
- package/framework/skills/core/morph-plan/SKILL.md +14 -3
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +1 -1
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/package.json +2 -2
- package/src/commands/observability/cost.js +137 -0
- package/src/commands/project/standards.js +220 -0
- package/src/commands/state/approve.js +30 -9
- package/src/dashboard/scanner/hooks-scanner.js +11 -2
- package/src/lib/installers/memory-installer.js +1 -1
- package/src/lib/tasks/tasks-json-parser.js +26 -0
- package/src/lib/telemetry/telemetry-reader.js +213 -0
- package/src/utils/hooks-installer.js +84 -79
- package/framework/hooks/shared/session-snapshot.js +0 -105
|
@@ -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 {
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
if (!filePath) pass();
|
|
122
|
+
if (!payload) return null;
|
|
113
123
|
|
|
114
|
-
|
|
115
|
-
|
|
124
|
+
const filePath = getFilePath(payload);
|
|
125
|
+
if (!filePath) return null;
|
|
116
126
|
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
|
|
138
|
+
const featureName = extractFeatureName(filePath);
|
|
139
|
+
if (!featureName) return null;
|
|
130
140
|
|
|
131
|
-
|
|
132
|
-
|
|
141
|
+
const filename = getBasename(filePath);
|
|
142
|
+
const requiredGate = PROTECTED_SPEC_FILES[filename];
|
|
133
143
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
logHookActivity('protect-spec-files', 'PreToolUse', 'ok');
|
|
137
|
-
pass();
|
|
138
|
-
}
|
|
144
|
+
// Not a protected file
|
|
145
|
+
if (!requiredGate) return null;
|
|
139
146
|
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
150
|
+
return null;
|
|
174
151
|
}
|
|
175
152
|
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
198
|
-
`MORPH-SPEC: '${filename}'
|
|
199
|
-
`
|
|
200
|
-
|
|
201
|
-
`
|
|
202
|
-
`
|
|
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
|
-
|
|
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);
|