@polymorphism-tech/morph-spec 7.2.0 → 7.4.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/CLAUDE.md +1 -1
- package/README.md +2 -2
- package/bin/morph-spec.js +4 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +2 -2
- package/framework/MORPH.md +29 -12
- package/framework/agents.json +132 -132
- package/framework/commands/morph-apply.md +1 -1
- package/framework/commands/morph-proposal.md +1 -1
- package/framework/evals/rubrics/README.md +9 -8
- package/framework/hooks/README.md +3 -1
- package/framework/hooks/claude-code/core/gate-guard.js +160 -0
- package/framework/hooks/claude-code/core/state-sync.js +15 -3
- package/framework/hooks/claude-code/core/validate-completion.js +241 -136
- package/framework/hooks/shared/state-reader.js +236 -221
- package/framework/phases.json +1 -1
- package/framework/skills/README.md +2 -2
- package/framework/skills/core/morph-apply/SKILL.md +63 -21
- package/framework/skills/core/morph-brainstorming/SKILL.md +5 -1
- package/framework/skills/core/morph-eval/SKILL.md +4 -5
- package/framework/skills/core/morph-implement/SKILL.md +47 -19
- package/framework/skills/core/morph-plan/SKILL.md +8 -2
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +13 -6
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/standards/ai-agents/setup.md +258 -244
- package/framework/templates/README.md +708 -709
- package/framework/templates/REGISTRY.json +1 -20
- package/framework/templates/context/CONTEXT-FEATURE.md +276 -276
- package/framework/templates/docs/proposal.md +171 -171
- package/framework/templates/feature/recap.md +145 -145
- package/package.json +1 -1
- package/src/commands/project/doctor.js +941 -946
- package/src/commands/project/init.js +517 -515
- package/src/commands/project/status.js +373 -376
- package/src/commands/state/approve.js +251 -213
- package/src/commands/validation/validate-feature.js +356 -287
- package/src/core/state/state-manager.js +910 -934
- package/src/dashboard/app/App.jsx +29 -31
- package/src/dashboard/app/components/Sidebar.jsx +44 -45
- package/src/dashboard/scanner/index.js +20 -21
- package/src/lib/context/context-builder.js +3 -3
- package/src/lib/generators/context-generator.js +2 -3
- package/src/lib/generators/mandate-generator.js +48 -92
- package/src/lib/generators/metadata-extractor.js +385 -386
- package/src/lib/generators/recap-generator.js +285 -285
- package/src/lib/risk-detector.js +2 -2
- package/src/lib/validators/content/content-validator.js +89 -286
- package/src/lib/validators/content/index.js +0 -1
- package/src/utils/hooks-installer.js +527 -516
- package/framework/templates/feature/tasks.md +0 -199
- package/framework/workflows/README.md +0 -1041
- package/framework/workflows/configs/fast-track.json +0 -46
- package/framework/workflows/configs/full.json +0 -64
- package/framework/workflows/configs/quick.json +0 -32
- package/framework/workflows/configs/standard.json +0 -57
- package/framework/workflows/docs/STORY-DRIVEN-DEVELOPMENT.md +0 -392
- package/framework/workflows/docs/design-impl.md +0 -37
- package/framework/workflows/docs/enforcement-pipeline.md +0 -668
- package/framework/workflows/docs/fast-track.md +0 -29
- package/framework/workflows/docs/full-morph.md +0 -69
- package/framework/workflows/docs/standard.md +0 -49
- package/framework/workflows/docs/ui-refresh.md +0 -39
- package/src/commands/state/index.js +0 -7
- package/src/core/workflows/workflow-detector.js +0 -586
- package/src/core/workflows/workflow-router.js +0 -204
- package/src/dashboard/app/pages/Workflows.jsx +0 -346
- package/src/dashboard/scanner/workflows-scanner.js +0 -18
- package/src/lib/phase-chain/phase-validator.js +0 -359
- package/src/lib/validators/content/cross-phase-validator.js +0 -281
- package/src/lib/validators/spec-validator.js +0 -312
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* gate-guard.js — PostToolUse hook (detect-and-warn, never block)
|
|
4
|
+
*
|
|
5
|
+
* Closes the gap from issue #63: the trust-mode contract lived only in skill
|
|
6
|
+
* pseudocode, with nothing verifying the LLM actually honored it. This hook
|
|
7
|
+
* compares the OBSERVED gate action against the decision the skill persisted
|
|
8
|
+
* to state.json (`features[feature].gateDecisions[gate]`) and injects a
|
|
9
|
+
* non-blocking warning when they disagree.
|
|
10
|
+
*
|
|
11
|
+
* It is a silent guardrail, NOT an orchestrator (per MORPH.md): it never
|
|
12
|
+
* blocks, never writes state, and fails open on any error or missing data.
|
|
13
|
+
*
|
|
14
|
+
* Two mismatch directions:
|
|
15
|
+
* - LLM PAUSED (AskUserQuestion on a gate) but the persisted decision said
|
|
16
|
+
* pause:false → over-pausing under trust:auto. Warn.
|
|
17
|
+
* - LLM AUTO-APPROVED (gate became approvedBy:"auto") but the persisted
|
|
18
|
+
* decision said pause:true → skipped a required human pause. Warn.
|
|
19
|
+
*
|
|
20
|
+
* Coherent action, no persisted decision, or any error → silence.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readFileSync } from 'fs';
|
|
24
|
+
import { loadState, stateExists } from '../../shared/state-reader.js';
|
|
25
|
+
import { injectContext, pass } from '../../shared/hook-response.js';
|
|
26
|
+
|
|
27
|
+
function readStdin() {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(readFileSync(0, 'utf8') || '{}');
|
|
30
|
+
} catch {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function projectDir() {
|
|
36
|
+
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function findActiveFeature(state) {
|
|
40
|
+
if (!state.features) return null;
|
|
41
|
+
const entries = Object.entries(state.features);
|
|
42
|
+
const active = entries.find(([, f]) => f.status === 'in_progress');
|
|
43
|
+
if (active) return active[0];
|
|
44
|
+
return entries[0]?.[0] ?? null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Map a gate question's text to a gate id. Mirrors state-sync.js. */
|
|
48
|
+
function gateFromQuestion(question) {
|
|
49
|
+
if (typeof question !== 'string') return null;
|
|
50
|
+
const lower = question.toLowerCase();
|
|
51
|
+
if (lower.includes('gate 1') || lower.includes('proposta')) return 'proposal';
|
|
52
|
+
if (lower.includes('gate 2') || lower.includes('plano') || lower.includes('plan')) return 'plan';
|
|
53
|
+
if (lower.includes('gate 3') || lower.includes('review')) return 'review';
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getDecision(state, feature, gate) {
|
|
58
|
+
const d = state.features?.[feature]?.gateDecisions?.[gate];
|
|
59
|
+
if (!d || typeof d.pause !== 'boolean') return null;
|
|
60
|
+
return d;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The LLM just asked a gate question. If the persisted decision said this
|
|
65
|
+
* gate should auto-pass (pause:false), the human was interrupted needlessly.
|
|
66
|
+
*/
|
|
67
|
+
function checkAskUserQuestion(input, state, feature) {
|
|
68
|
+
// Questions live in tool_input.questions[]; answers in tool_response.
|
|
69
|
+
const questions = input.tool_input?.questions;
|
|
70
|
+
const askedTexts = [];
|
|
71
|
+
if (Array.isArray(questions)) {
|
|
72
|
+
for (const q of questions) {
|
|
73
|
+
if (typeof q?.question === 'string') askedTexts.push(q.question);
|
|
74
|
+
if (typeof q?.header === 'string') askedTexts.push(q.header);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Fallback: some payloads only surface answered question keys.
|
|
78
|
+
const answers = input.tool_response?.answers ?? input.tool_response?.responses;
|
|
79
|
+
if (answers && typeof answers === 'object') {
|
|
80
|
+
askedTexts.push(...Object.keys(answers));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const text of askedTexts) {
|
|
84
|
+
const gate = gateFromQuestion(text);
|
|
85
|
+
if (!gate) continue;
|
|
86
|
+
const decision = getDecision(state, feature, gate);
|
|
87
|
+
if (!decision) continue; // no persisted decision → nothing to verify
|
|
88
|
+
if (decision.pause === false) {
|
|
89
|
+
return injectContext(
|
|
90
|
+
`⚠️ [morph-spec] GATE OVER-PAUSE — você apresentou o Gate "${gate}" via AskUserQuestion, ` +
|
|
91
|
+
`mas a decisão registrada em state.json (gateDecisions.${gate}) era pause:false ` +
|
|
92
|
+
`(trust:${decision.trust ?? 'auto'}, nenhum sinal de risco). ` +
|
|
93
|
+
`Sob trust:auto sem risco, este gate deveria auto-passar com um summary curto, sem interromper o humano. ` +
|
|
94
|
+
`Se a pausa foi intencional (você detectou um risco que o registro não capturou), atualize gateDecisions.${gate}.signals antes de prosseguir; ` +
|
|
95
|
+
`caso contrário, evite pausar gates auto-aprováveis.`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return pass();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The LLM just edited state.json. If a gate flipped to approvedBy:"auto" but
|
|
104
|
+
* the persisted decision said pause:true, a required human pause was skipped.
|
|
105
|
+
*/
|
|
106
|
+
function checkStateEdit(input, state, feature) {
|
|
107
|
+
const filePath = String(input.tool_input?.file_path ?? '').replace(/\\/g, '/');
|
|
108
|
+
if (!filePath.endsWith('.morph/state.json')) return pass();
|
|
109
|
+
|
|
110
|
+
const gates = state.features?.[feature]?.approvalGates ?? {};
|
|
111
|
+
for (const gate of ['proposal', 'plan', 'review']) {
|
|
112
|
+
const g = gates[gate];
|
|
113
|
+
const autoApproved = g?.approved === true && g?.approvedBy === 'auto';
|
|
114
|
+
if (!autoApproved) continue;
|
|
115
|
+
const decision = getDecision(state, feature, gate);
|
|
116
|
+
if (!decision) continue;
|
|
117
|
+
if (decision.pause === true) {
|
|
118
|
+
return injectContext(
|
|
119
|
+
`⚠️ [morph-spec] GATE SKIPPED PAUSE — o Gate "${gate}" foi marcado approvedBy:"auto" em state.json, ` +
|
|
120
|
+
`mas a decisão registrada (gateDecisions.${gate}) era pause:true ` +
|
|
121
|
+
`(sinal: ${decision.reason || 'risco detectado'}). ` +
|
|
122
|
+
`Um sinal de risco exigia pausa humana via AskUserQuestion — uma auto-aprovação aqui ignora o trust mode. ` +
|
|
123
|
+
`Reabra o gate com AskUserQuestion (label "aprovado, prosseguir") ou corrija o sinal de risco antes de avançar.`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return pass();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function main() {
|
|
131
|
+
const cwd = projectDir();
|
|
132
|
+
if (!stateExists(cwd)) return pass();
|
|
133
|
+
|
|
134
|
+
const input = readStdin();
|
|
135
|
+
const toolName = input.tool_name;
|
|
136
|
+
|
|
137
|
+
// Short-circuit before loading state: a Write/Edit only matters if it touched
|
|
138
|
+
// state.json. Skip the disk read for the dozens of unrelated edits per feature.
|
|
139
|
+
if (toolName === 'Edit' || toolName === 'Write') {
|
|
140
|
+
const fp = String(input.tool_input?.file_path ?? '').replace(/\\/g, '/');
|
|
141
|
+
if (!fp.endsWith('.morph/state.json')) return pass();
|
|
142
|
+
} else if (toolName !== 'AskUserQuestion') {
|
|
143
|
+
return pass();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const state = loadState(cwd);
|
|
148
|
+
if (!state) return pass();
|
|
149
|
+
const feature = findActiveFeature(state);
|
|
150
|
+
if (!feature) return pass();
|
|
151
|
+
|
|
152
|
+
if (toolName === 'AskUserQuestion') return checkAskUserQuestion(input, state, feature);
|
|
153
|
+
if (toolName === 'Edit' || toolName === 'Write') return checkStateEdit(input, state, feature);
|
|
154
|
+
} catch {
|
|
155
|
+
// fail-open
|
|
156
|
+
}
|
|
157
|
+
return pass();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
main();
|
|
@@ -173,9 +173,21 @@ function handleAskUserQuestion(input, cwd) {
|
|
|
173
173
|
if (!gate) continue;
|
|
174
174
|
|
|
175
175
|
state.features[featureName].approvalGates ??= {};
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
176
|
+
const existing = state.features[featureName].approvalGates[gate];
|
|
177
|
+
const alreadyApproved = existing === true || existing?.approved === true;
|
|
178
|
+
if (!alreadyApproved) {
|
|
179
|
+
// An "aprovado" answer to AskUserQuestion is a human approving in the
|
|
180
|
+
// loop — record it as such. Write the canonical gate shape
|
|
181
|
+
// ({ approved, timestamp, approvedBy }) used by setApprovalGate so the
|
|
182
|
+
// audit trail is consistent and a manual approval is distinguishable
|
|
183
|
+
// from a policy auto-approval (approvedBy: "auto").
|
|
184
|
+
const now = new Date().toISOString();
|
|
185
|
+
state.features[featureName].approvalGates[gate] = {
|
|
186
|
+
approved: true,
|
|
187
|
+
timestamp: now,
|
|
188
|
+
approvedBy: 'manual'
|
|
189
|
+
};
|
|
190
|
+
state.features[featureName].updatedAt = now;
|
|
179
191
|
changed = true;
|
|
180
192
|
}
|
|
181
193
|
}
|
|
@@ -1,136 +1,241 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Stop Hook: Phase Transition Alert + Build Check + Session Snapshot
|
|
5
|
-
*
|
|
6
|
-
* Event: Stop
|
|
7
|
-
*
|
|
8
|
-
* 1. Detects transitions into the `implement` phase and recommends /compact.
|
|
9
|
-
* 2. Runs stack-aware build check — warns only on failure (silent on pass).
|
|
10
|
-
* 3. Saves session snapshot for next-session orientation.
|
|
11
|
-
*
|
|
12
|
-
* Phase transition tracking: stores last-seen phase per feature in
|
|
13
|
-
* .morph/memory/phase-watch.json so the alert fires exactly once.
|
|
14
|
-
*
|
|
15
|
-
* Fail-open: exits 0 on any error.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
19
|
-
import { join } from 'path';
|
|
20
|
-
import { runBuildCheck } from '../../shared/build-check.js';
|
|
21
|
-
import { saveSessionSnapshot } from '../../shared/session-snapshot.js';
|
|
22
|
-
import {
|
|
23
|
-
stateExists, getActiveFeature, getMostRecentFeature, derivePhaseForFeature,
|
|
24
|
-
} from '../../shared/state-reader.js';
|
|
25
|
-
import { injectContext, pass } from '../../shared/hook-response.js';
|
|
26
|
-
import { logHookActivity, logEvent } from '../../shared/activity-logger.js';
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (!
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stop Hook: Phase Transition Alert + Build Check + Session Snapshot
|
|
5
|
+
*
|
|
6
|
+
* Event: Stop
|
|
7
|
+
*
|
|
8
|
+
* 1. Detects transitions into the `implement` phase and recommends /compact.
|
|
9
|
+
* 2. Runs stack-aware build check — warns only on failure (silent on pass).
|
|
10
|
+
* 3. Saves session snapshot for next-session orientation.
|
|
11
|
+
*
|
|
12
|
+
* Phase transition tracking: stores last-seen phase per feature in
|
|
13
|
+
* .morph/memory/phase-watch.json so the alert fires exactly once.
|
|
14
|
+
*
|
|
15
|
+
* Fail-open: exits 0 on any error.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
19
|
+
import { join } from 'path';
|
|
20
|
+
import { runBuildCheck } from '../../shared/build-check.js';
|
|
21
|
+
import { saveSessionSnapshot } from '../../shared/session-snapshot.js';
|
|
22
|
+
import {
|
|
23
|
+
stateExists, getActiveFeature, getMostRecentFeature, derivePhaseForFeature,
|
|
24
|
+
} from '../../shared/state-reader.js';
|
|
25
|
+
import { injectContext, pass } from '../../shared/hook-response.js';
|
|
26
|
+
import { logHookActivity, logEvent } from '../../shared/activity-logger.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Audit recap.md against tasks.json + standard-citation rule.
|
|
30
|
+
*
|
|
31
|
+
* Returns an array of human-readable warnings. Empty array = audit clean.
|
|
32
|
+
*
|
|
33
|
+
* Two checks:
|
|
34
|
+
* 1. Task IDs that appear as a "## T<n>" heading in recap.md but still
|
|
35
|
+
* carry status != 'done' in tasks.json — claim/state divergence.
|
|
36
|
+
* 2. Task headings without a "**Standard:**" line nearby (within 10 lines
|
|
37
|
+
* after the heading) — no standard citation = unauditable decision.
|
|
38
|
+
*/
|
|
39
|
+
function auditRecapAndTasks(featurePath) {
|
|
40
|
+
const warnings = [];
|
|
41
|
+
const recapPath = join(featurePath, '3-implement', 'recap.md');
|
|
42
|
+
const tasksJsonPath = join(featurePath, '2-plan', 'tasks.json');
|
|
43
|
+
if (!existsSync(recapPath) || !existsSync(tasksJsonPath)) return warnings;
|
|
44
|
+
|
|
45
|
+
let recap, tasksJson;
|
|
46
|
+
try {
|
|
47
|
+
recap = readFileSync(recapPath, 'utf-8');
|
|
48
|
+
tasksJson = JSON.parse(readFileSync(tasksJsonPath, 'utf-8'));
|
|
49
|
+
} catch {
|
|
50
|
+
return warnings;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const tasks = Array.isArray(tasksJson.tasks) ? tasksJson.tasks : [];
|
|
54
|
+
if (tasks.length === 0) return warnings;
|
|
55
|
+
|
|
56
|
+
// Index recap by task ID — collect line numbers where each task is mentioned
|
|
57
|
+
// as completed. Supports two formats agents commonly use:
|
|
58
|
+
// (a) "## T<n>" — heading style (per the SKILL.md template)
|
|
59
|
+
// (b) "| T<n> | ... done" — markdown table row with done in same row
|
|
60
|
+
const lines = recap.split('\n');
|
|
61
|
+
const recapMentions = new Map(); // id → starting line index
|
|
62
|
+
const headingRe = /^##\s+(T\d+)\b/;
|
|
63
|
+
const tableRowRe = /^\|\s*(T\d+)\s*\|.*\bdone\b/i;
|
|
64
|
+
lines.forEach((ln, i) => {
|
|
65
|
+
const heading = ln.match(headingRe);
|
|
66
|
+
if (heading && !recapMentions.has(heading[1])) {
|
|
67
|
+
recapMentions.set(heading[1], i);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const tableRow = ln.match(tableRowRe);
|
|
71
|
+
if (tableRow && !recapMentions.has(tableRow[1])) {
|
|
72
|
+
recapMentions.set(tableRow[1], i);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Check 1: status drift — task mentioned as done in recap but tasks.json
|
|
77
|
+
// still shows it as pending/in_progress.
|
|
78
|
+
for (const t of tasks) {
|
|
79
|
+
if (recapMentions.has(t.id) && t.status !== 'done') {
|
|
80
|
+
warnings.push(
|
|
81
|
+
`tasks.json drift: ${t.id} appears as done in recap.md but tasks.json shows status="${t.status}". Run writeTasksJson to sync.`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check 2: missing Standard citation per task mention. Skip pure
|
|
87
|
+
// table-row mentions (they're a summary, the heading-style block
|
|
88
|
+
// is where the standard belongs) — only flag when the agent did
|
|
89
|
+
// not produce a heading block at all for a task that is also marked
|
|
90
|
+
// done in tasks.json.
|
|
91
|
+
const standardRe = /\*\*Standard:\*\*|\bStandard\s*consulted\b|\bstandards?\s+used\b/i;
|
|
92
|
+
const headingMentions = new Map();
|
|
93
|
+
lines.forEach((ln, i) => {
|
|
94
|
+
const m = ln.match(headingRe);
|
|
95
|
+
if (m && !headingMentions.has(m[1])) headingMentions.set(m[1], i);
|
|
96
|
+
});
|
|
97
|
+
for (const t of tasks) {
|
|
98
|
+
if (t.status !== 'done') continue;
|
|
99
|
+
const lineIdx = headingMentions.get(t.id);
|
|
100
|
+
if (lineIdx === undefined) {
|
|
101
|
+
// Task is done but has no heading block at all — recap is summary-only.
|
|
102
|
+
warnings.push(
|
|
103
|
+
`recap.md task ${t.id} (status=done in tasks.json) has no detailed "## ${t.id}" block. Per morph-implement SKILL.md, each done task needs a heading block with a "**Standard:** ..." citation so the reviewer can audit the decision.`
|
|
104
|
+
);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const window = lines.slice(lineIdx, lineIdx + 12).join('\n');
|
|
108
|
+
if (!standardRe.test(window)) {
|
|
109
|
+
warnings.push(
|
|
110
|
+
`recap.md task ${t.id} is missing a "**Standard:** ..." citation within 12 lines of its heading. Cite which standard from STANDARDS.json justified the implementation (or "ad-hoc (see decisions.md ADR-N)" if intentional).`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return warnings;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Phases where compaction is strongly recommended before starting
|
|
119
|
+
const COMPACT_TRIGGER_PHASES = new Set(['implement']);
|
|
120
|
+
|
|
121
|
+
// Phases where compaction is suggested when arriving (heavy spec phases)
|
|
122
|
+
const HEAVY_SOURCE_PHASES = new Set(['plan', 'uiux']);
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
// Prevent infinite loop
|
|
126
|
+
if (process.env.MORPH_STOP_HOOK_ACTIVE === '1') pass();
|
|
127
|
+
|
|
128
|
+
if (!stateExists()) pass();
|
|
129
|
+
|
|
130
|
+
// getActiveFeature() only returns in_progress/draft features.
|
|
131
|
+
// Fall back to the most recently updated feature when all features are 'done'.
|
|
132
|
+
let active = getActiveFeature() || getMostRecentFeature();
|
|
133
|
+
if (!active) pass();
|
|
134
|
+
|
|
135
|
+
const { name, feature } = active;
|
|
136
|
+
const cwd = process.cwd();
|
|
137
|
+
const warnings = [];
|
|
138
|
+
|
|
139
|
+
// ── Phase transition detection ────────────────────────────────────────────
|
|
140
|
+
const currentPhase = feature.phase || derivePhaseForFeature(name, cwd);
|
|
141
|
+
|
|
142
|
+
const watchPath = join(cwd, '.morph', 'memory', 'phase-watch.json');
|
|
143
|
+
let phaseWatch = {};
|
|
144
|
+
try {
|
|
145
|
+
if (existsSync(watchPath)) phaseWatch = JSON.parse(readFileSync(watchPath, 'utf-8'));
|
|
146
|
+
} catch { /* corrupt file — start fresh */ }
|
|
147
|
+
|
|
148
|
+
const lastPhase = phaseWatch[name];
|
|
149
|
+
|
|
150
|
+
// Update stored phase (always, so the alert only fires once per transition)
|
|
151
|
+
try {
|
|
152
|
+
phaseWatch[name] = currentPhase;
|
|
153
|
+
writeFileSync(watchPath, JSON.stringify(phaseWatch, null, 2), 'utf-8');
|
|
154
|
+
} catch { /* non-blocking */ }
|
|
155
|
+
|
|
156
|
+
// Alert when entering implement from a heavy spec phase
|
|
157
|
+
const isPhaseTransition = lastPhase && lastPhase !== currentPhase;
|
|
158
|
+
const enteringImplement = COMPACT_TRIGGER_PHASES.has(currentPhase);
|
|
159
|
+
const comingFromHeavyPhase = HEAVY_SOURCE_PHASES.has(lastPhase);
|
|
160
|
+
|
|
161
|
+
if (isPhaseTransition && enteringImplement && comingFromHeavyPhase) {
|
|
162
|
+
warnings.push(
|
|
163
|
+
`⚡ Phase advanced: ${lastPhase} → implement`,
|
|
164
|
+
``,
|
|
165
|
+
`💡 Recommended before starting implementation:`,
|
|
166
|
+
` Run /compact to free up context — spec.md, mandate.md and tasks.json`,
|
|
167
|
+
` loaded during planning take significant space. A compact now gives`,
|
|
168
|
+
` Claude a clean slate focused on writing code.`,
|
|
169
|
+
``,
|
|
170
|
+
` After /compact, resume with: /morph-apply ${name}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Build/typecheck verification ──────────────────────────────────────────
|
|
175
|
+
// Stack-aware build check — advisory warning if build fails.
|
|
176
|
+
// Success is silent (no output on pass).
|
|
177
|
+
try {
|
|
178
|
+
const configPath = join(cwd, '.morph', 'config', 'config.json');
|
|
179
|
+
if (existsSync(configPath)) {
|
|
180
|
+
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
181
|
+
const stack = config?.project?.stack;
|
|
182
|
+
if (stack && currentPhase === 'implement') {
|
|
183
|
+
const buildResult = runBuildCheck(cwd, stack);
|
|
184
|
+
if (buildResult.status === 'fail') {
|
|
185
|
+
warnings.push('⚠ Build/typecheck failed:');
|
|
186
|
+
warnings.push(buildResult.output);
|
|
187
|
+
warnings.push('Fix build errors before ending session.');
|
|
188
|
+
}
|
|
189
|
+
// pass and skip → silent (success is silent)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
// Fail-open — build check must never block Stop
|
|
194
|
+
}
|
|
195
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
// ── Recap + tasks.json audit ──────────────────────────────────────────────
|
|
198
|
+
// Surface drift between recap.md and tasks.json, plus missing standard
|
|
199
|
+
// citations. Advisory warnings only — Stop hook never blocks.
|
|
200
|
+
try {
|
|
201
|
+
const featurePath = join(cwd, '.morph', 'features', name);
|
|
202
|
+
const recapWarnings = auditRecapAndTasks(featurePath);
|
|
203
|
+
if (recapWarnings.length > 0) {
|
|
204
|
+
warnings.push('📋 Recap audit:');
|
|
205
|
+
for (const w of recapWarnings) warnings.push(` • ${w}`);
|
|
206
|
+
}
|
|
207
|
+
} catch {
|
|
208
|
+
// Fail-open — audit must never block Stop
|
|
209
|
+
}
|
|
210
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
// ── Session snapshot ──────────────────────────────────────────────────────
|
|
213
|
+
// Save session state for next session's "getting up to speed" routine.
|
|
214
|
+
try {
|
|
215
|
+
if (active) {
|
|
216
|
+
const { name } = active;
|
|
217
|
+
saveSessionSnapshot({
|
|
218
|
+
feature: name,
|
|
219
|
+
phase: currentPhase,
|
|
220
|
+
buildStatus: 'skip',
|
|
221
|
+
lastEditedFiles: [],
|
|
222
|
+
}, cwd);
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
// Fail-open — snapshot save must never block Stop
|
|
226
|
+
}
|
|
227
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
if (warnings.length === 0) pass();
|
|
230
|
+
|
|
231
|
+
const message = [
|
|
232
|
+
'MORPH-SPEC:',
|
|
233
|
+
...warnings.map(w => ` ${w}`),
|
|
234
|
+
].join('\n');
|
|
235
|
+
|
|
236
|
+
logHookActivity('validate-completion', 'Stop', `warnings(${warnings.length})`);
|
|
237
|
+
logEvent('session_end', { feature: name, phase: currentPhase, warnings: warnings.length });
|
|
238
|
+
injectContext(message);
|
|
239
|
+
} catch {
|
|
240
|
+
process.exit(0);
|
|
241
|
+
}
|