@polymorphism-tech/morph-spec 8.6.0 → 8.7.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 +4 -4
- package/bin/morph-spec.js +23 -1
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +2 -2
- package/docs/QUICKSTART.md +2 -2
- package/framework/CLAUDE.md +6 -3
- package/framework/MORPH.md +28 -3
- package/framework/hooks/claude-code/core/telemetry-log.js +15 -0
- package/framework/hooks/claude-code/core/worktree-context.js +41 -5
- package/framework/hooks/shared/claim-registry.js +175 -0
- package/framework/hooks/shared/state-reader.js +68 -25
- package/framework/rules/morph-task-tracking.md +14 -8
- package/framework/rules/morph-workflow.md +34 -1
- package/framework/schemas/feature.schema.json +2 -1
- 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 +7 -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/project/claim.js +91 -0
- package/src/commands/project/doctor.js +65 -0
- package/src/commands/project/fleet.js +37 -2
- package/src/commands/project/worktree.js +70 -7
- package/src/commands/state/advance.js +60 -1
- package/src/commands/state/approve.js +125 -2
- package/src/commands/state/create.js +3 -3
- package/src/commands/state/finish.js +88 -3
- package/src/commands/state/list.js +9 -1
- package/src/commands/state/report-check.js +202 -0
- package/src/core/state/lifecycle-audit.js +57 -0
- package/src/dashboard/scanner/state-scanner.js +11 -3
- package/src/lib/generators/architecture-scanner.js +1 -1
- package/src/lib/tasks/tasks-json-parser.js +8 -0
- package/src/lib/worktree/fleet-scanner.js +224 -15
- package/src/lib/worktree/worktree-audit.js +181 -0
- package/src/utils/file-copier.js +5 -1
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { existsSync, mkdirSync } from 'fs';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
4
5
|
import { getFeature, setApprovalGate, derivePhase } from '../../core/state/state-manager.js';
|
|
5
6
|
import { unmetPrereqs } from '../../core/state/gate-order.js';
|
|
7
|
+
import { OUTPUT_SCHEMA } from '../../core/paths/output-schema.js';
|
|
8
|
+
|
|
9
|
+
const FRAMEWORK_DIR = fileURLToPath(new URL('../../../framework/', import.meta.url));
|
|
6
10
|
|
|
7
11
|
/**
|
|
8
12
|
* `morph-spec advance <feature>` — approve the current phase's gate AND scaffold
|
|
@@ -28,6 +32,41 @@ const NEXT_PHASE_DIR = {
|
|
|
28
32
|
implement: '4-review',
|
|
29
33
|
};
|
|
30
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The canonical artifacts the phase being CLOSED owes, per
|
|
37
|
+
* `framework/phases.json → phases[phase].requiredOutputs`, resolved to real
|
|
38
|
+
* filenames through OUTPUT_SCHEMA.
|
|
39
|
+
*
|
|
40
|
+
* Data-driven on purpose: adding an artifact to a phase must not need a code
|
|
41
|
+
* change here, and the list must never drift from the phase definition.
|
|
42
|
+
* Fail-open — an unreadable phases.json/OUTPUT_SCHEMA yields an empty list, so
|
|
43
|
+
* a broken install degrades to today's behaviour instead of blocking every
|
|
44
|
+
* advance.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} phase
|
|
47
|
+
* @param {string} featurePath
|
|
48
|
+
* @returns {string[]} relative paths of the MISSING required artifacts
|
|
49
|
+
*/
|
|
50
|
+
function missingPhaseArtifacts(phase, featurePath) {
|
|
51
|
+
try {
|
|
52
|
+
const phasesPath = join(FRAMEWORK_DIR, 'phases.json');
|
|
53
|
+
const phases = JSON.parse(readFileSync(phasesPath, 'utf8')).phases || {};
|
|
54
|
+
const required = phases[phase]?.requiredOutputs || [];
|
|
55
|
+
const missing = [];
|
|
56
|
+
for (const outputType of required) {
|
|
57
|
+
// requiredOutputs already carries OUTPUT_SCHEMA's camelCase keys — an
|
|
58
|
+
// unknown key is skipped rather than guessed at.
|
|
59
|
+
const entry = OUTPUT_SCHEMA[outputType];
|
|
60
|
+
if (!entry?.filename || !entry?.phaseDir) continue;
|
|
61
|
+
const rel = `${entry.phaseDir}/${entry.filename}`;
|
|
62
|
+
if (!existsSync(join(featurePath, entry.phaseDir, entry.filename))) missing.push(rel);
|
|
63
|
+
}
|
|
64
|
+
return missing;
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
31
70
|
/** Resolve who approved, honestly (mirrors approve.js — no fake human signatures). */
|
|
32
71
|
function resolveApprovedBy(options) {
|
|
33
72
|
if (options.approver) return options.approver;
|
|
@@ -54,6 +93,23 @@ export async function advanceCommand(featureName, options = {}) {
|
|
|
54
93
|
return;
|
|
55
94
|
}
|
|
56
95
|
|
|
96
|
+
// Phase-exit artifact check (D3): a phase transition must not step over the
|
|
97
|
+
// artifact the phase it is CLOSING owes. Observed: a feature reached review
|
|
98
|
+
// with NO 3-implement/recap.md — the file was written later, by hand, so
|
|
99
|
+
// the Gate 3 review had nothing to review. `implement` has no gate, which
|
|
100
|
+
// is precisely why nothing stopped it.
|
|
101
|
+
//
|
|
102
|
+
// `--force` bypasses (same deliberate-recovery role it already plays for
|
|
103
|
+
// gate order) but still names what was skipped — a silent bypass would be
|
|
104
|
+
// the defect again.
|
|
105
|
+
const missingArtifacts = missingPhaseArtifacts(phase, featurePath);
|
|
106
|
+
if (missingArtifacts.length && !options.force) {
|
|
107
|
+
console.error(chalk.red(`\n❌ A fase "${phase}" não pode ser encerrada — artefato(s) obrigatório(s) ausente(s):`));
|
|
108
|
+
for (const rel of missingArtifacts) console.error(chalk.gray(` • ${rel}`));
|
|
109
|
+
console.error(chalk.gray('\n Produza o artefato e rode de novo, ou passe --force para avançar assim mesmo.\n'));
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
|
|
57
113
|
// 1) Approve the gate that completes the current phase.
|
|
58
114
|
const gate = PHASE_GATE[phase];
|
|
59
115
|
const approvedBy = resolveApprovedBy(options);
|
|
@@ -112,6 +168,9 @@ export async function advanceCommand(featureName, options = {}) {
|
|
|
112
168
|
} catch { /* fail-open */ }
|
|
113
169
|
|
|
114
170
|
console.log(chalk.green(`\n✅ Advanced "${featureName}" past the ${phase} phase.`));
|
|
171
|
+
if (missingArtifacts.length) {
|
|
172
|
+
console.log(chalk.yellow(` ⚠️ --force: avançou SEM ${missingArtifacts.join(', ')}.`));
|
|
173
|
+
}
|
|
115
174
|
if (gate) console.log(chalk.gray(` Gate "${gate}" approved (${approvedBy}).`));
|
|
116
175
|
if (nextDir) console.log(chalk.gray(` Scaffolded ${nextDir}/ — ready for the next phase's first artifact.\n`));
|
|
117
176
|
else console.log('');
|
|
@@ -1,10 +1,73 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import {
|
|
3
|
+
import { execFileSync } from 'child_process';
|
|
4
|
+
import { existsSync, readFileSync } from 'fs';
|
|
4
5
|
import { join } from 'path';
|
|
5
6
|
import { getFeature, setApprovalGate, getApprovalGate, getApprovalHistory, derivePhase } from '../../core/state/state-manager.js';
|
|
6
7
|
import { unmetPrereqs } from '../../core/state/gate-order.js';
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* The literal token `review-report.md` carries to declare which tree it
|
|
11
|
+
* reviewed. Kept as one exported constant so the CLI parser, the warning text
|
|
12
|
+
* and the `morph-review` skill can never drift apart (the A5 lesson).
|
|
13
|
+
*/
|
|
14
|
+
export const REVIEWED_SHA_TOKEN = '**Reviewed-SHA:**';
|
|
15
|
+
|
|
16
|
+
const REVIEWED_SHA_RE = /\*\*Reviewed-SHA:\*\*\s*([0-9a-f]{7,40})/i;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Is the feature's `review-report.md` still describing the current tree?
|
|
20
|
+
*
|
|
21
|
+
* @param {string} featureName
|
|
22
|
+
* @returns {{ status: 'ok'|'stale'|'unstamped'|'skip', reviewed?: string, head?: string, ahead?: number }}
|
|
23
|
+
* `skip` covers every case where the question cannot be answered honestly:
|
|
24
|
+
* no report, no git repo, an unresolvable SHA. Never a false refusal.
|
|
25
|
+
*/
|
|
26
|
+
function reviewStaleness(featureName) {
|
|
27
|
+
const cwd = process.cwd();
|
|
28
|
+
const reportPath = join(cwd, '.morph', 'features', featureName, '4-review', 'review-report.md');
|
|
29
|
+
if (!existsSync(reportPath)) return { status: 'skip' };
|
|
30
|
+
|
|
31
|
+
let head;
|
|
32
|
+
try {
|
|
33
|
+
head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd, stdio: 'pipe' }).toString().trim();
|
|
34
|
+
} catch {
|
|
35
|
+
return { status: 'skip' }; // not a git repo (or no commits) — nothing to compare
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let body = '';
|
|
39
|
+
try {
|
|
40
|
+
body = readFileSync(reportPath, 'utf8');
|
|
41
|
+
} catch {
|
|
42
|
+
return { status: 'skip' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const stamped = body.match(REVIEWED_SHA_RE)?.[1];
|
|
46
|
+
if (!stamped) return { status: 'unstamped', head };
|
|
47
|
+
|
|
48
|
+
let reviewed;
|
|
49
|
+
try {
|
|
50
|
+
reviewed = execFileSync('git', ['rev-parse', '--verify', `${stamped}^{commit}`], { cwd, stdio: 'pipe' })
|
|
51
|
+
.toString().trim();
|
|
52
|
+
} catch {
|
|
53
|
+
// A stamp git cannot resolve (rewritten history, a typo) is not evidence
|
|
54
|
+
// of staleness — say "unstamped" rather than block on a guess.
|
|
55
|
+
return { status: 'unstamped', head };
|
|
56
|
+
}
|
|
57
|
+
if (reviewed === head) return { status: 'ok', reviewed, head };
|
|
58
|
+
|
|
59
|
+
let ahead = 0;
|
|
60
|
+
try {
|
|
61
|
+
ahead = execFileSync('git', ['rev-list', '--count', `${reviewed}..${head}`], { cwd, stdio: 'pipe' })
|
|
62
|
+
.toString().trim();
|
|
63
|
+
ahead = Number(ahead) || 0;
|
|
64
|
+
} catch { ahead = 0; }
|
|
65
|
+
|
|
66
|
+
// HEAD not descending from the reviewed commit (a rebase, a different branch)
|
|
67
|
+
// is still "not the tree that was reviewed".
|
|
68
|
+
return { status: 'stale', reviewed, head, ahead };
|
|
69
|
+
}
|
|
70
|
+
|
|
8
71
|
/**
|
|
9
72
|
* Unapprove action function - Revoke a previously approved gate
|
|
10
73
|
*/
|
|
@@ -155,12 +218,72 @@ export async function approveCommand(featureName, gateName, options = {}) {
|
|
|
155
218
|
process.exit(1);
|
|
156
219
|
}
|
|
157
220
|
|
|
221
|
+
// Gate 3 evaluator backstop: the composite score of a feature comes from
|
|
222
|
+
// the INDEPENDENT evaluator (persona `evaluator`, fresh context) dispatched
|
|
223
|
+
// by morph-review — never from self-assessment by whoever implemented it.
|
|
224
|
+
// Nothing enforced that. When the dispatch was impossible (observed: a
|
|
225
|
+
// session instruction forbade sub-agents), the flow still produced a
|
|
226
|
+
// review-report.md carrying a composite score and a verdict, with a caveat
|
|
227
|
+
// in prose nobody is obliged to read. A gate that yields a grade without
|
|
228
|
+
// its evaluator is not a gate.
|
|
229
|
+
//
|
|
230
|
+
// The escape hatch is EXPLICIT and RECORDED (`--self-assessed`), never
|
|
231
|
+
// silent — the silence is the defect. `--force` deliberately does NOT
|
|
232
|
+
// bypass this: it is the out-of-order recovery flag, a different concern.
|
|
233
|
+
// Gate 3 staleness backstop (D1): a review reviews a TREE, and the tree
|
|
234
|
+
// moves. Observed: review-report.md written at b30ac6e, then T3 landed and
|
|
235
|
+
// inverted the default of a business rule; the report stayed in the repo
|
|
236
|
+
// claiming 976/976 tests (they were 982) and never mentioning T3. Nothing
|
|
237
|
+
// detected that the review artifact had fallen behind the code it reviews.
|
|
238
|
+
//
|
|
239
|
+
// The report stamps the SHA it reviewed; approval compares it with HEAD.
|
|
240
|
+
// A stamp that is BEHIND HEAD refuses. A MISSING stamp only warns — reports
|
|
241
|
+
// written before this existed must not brick a legacy feature — but the
|
|
242
|
+
// warning names the exact token to add, so it teaches instead of nagging.
|
|
243
|
+
if (gateName === 'review') {
|
|
244
|
+
const stale = reviewStaleness(featureName);
|
|
245
|
+
if (stale.status === 'stale') {
|
|
246
|
+
console.error(chalk.red('\n❌ O `4-review/review-report.md` está desatualizado em relação ao código.'));
|
|
247
|
+
console.error(chalk.gray(` Revisado em: ${stale.reviewed.slice(0, 8)}`));
|
|
248
|
+
console.error(chalk.gray(` HEAD atual: ${stale.head.slice(0, 8)}${stale.ahead ? ` (${stale.ahead} commit(s) depois da revisão)` : ''}`));
|
|
249
|
+
console.error(chalk.gray(' Um review revisa uma ÁRVORE, e a árvore andou. Re-rode a `morph-review`'));
|
|
250
|
+
console.error(chalk.gray(' (incluindo o avaliador independente) e atualize o `**Reviewed-SHA:**`.\n'));
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
253
|
+
if (stale.status === 'unstamped') {
|
|
254
|
+
console.log(chalk.yellow('\n⚠️ O `review-report.md` não carrega `**Reviewed-SHA:**` — impossível saber se ele revisa o código atual.'));
|
|
255
|
+
console.log(chalk.gray(` Adicione a linha \`**Reviewed-SHA:** ${stale.head}\` ao relatório para que o Gate 3 possa verificar.`));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let selfAssessed = false;
|
|
260
|
+
if (gateName === 'review') {
|
|
261
|
+
const evaluatorReport = join(process.cwd(), '.morph', 'features', featureName, '4-review', 'evaluator-report.md');
|
|
262
|
+
if (!existsSync(evaluatorReport)) {
|
|
263
|
+
if (!options.selfAssessed) {
|
|
264
|
+
console.error(chalk.red('\n❌ Gate 3 exige o avaliador independente — `4-review/evaluator-report.md` não existe.'));
|
|
265
|
+
console.error(chalk.gray(' O score composto da feature vem do avaliador (persona `evaluator`, contexto fresco)'));
|
|
266
|
+
console.error(chalk.gray(' despachado pela morph-review, nunca de auto-avaliação de quem implementou.'));
|
|
267
|
+
console.error(chalk.gray(' Se o dispatch for impossível nesta sessão, aprove com --self-assessed —'));
|
|
268
|
+
console.error(chalk.gray(' a degradação fica registrada no feature.json e aparece no `list`/`fleet`.\n'));
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
|
271
|
+
selfAssessed = true;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
158
275
|
await setApprovalGate(featureName, gateName, true, {
|
|
159
276
|
approvedBy,
|
|
160
277
|
approvedAt: new Date().toISOString(),
|
|
161
|
-
notes: options.notes
|
|
278
|
+
notes: options.notes,
|
|
279
|
+
...(selfAssessed ? { selfAssessed: true } : {}),
|
|
162
280
|
});
|
|
163
281
|
|
|
282
|
+
if (selfAssessed) {
|
|
283
|
+
console.log(chalk.yellow('\n⚠️ Gate 3 aprovado SEM avaliador independente (--self-assessed).'));
|
|
284
|
+
console.log(chalk.gray(' Registrado em feature.json → approvalGates.review.selfAssessed.'));
|
|
285
|
+
}
|
|
286
|
+
|
|
164
287
|
console.log(chalk.green(`\n✅ Gate "${gateName}" approved for feature: ${featureName}`));
|
|
165
288
|
console.log(chalk.gray(`Workflow can now proceed past ${gateName} phase\n`));
|
|
166
289
|
|
|
@@ -186,9 +186,9 @@ export async function createCommand(featureName, options = {}) {
|
|
|
186
186
|
if (worktreeResult?.created && worktreeResult.path) {
|
|
187
187
|
try {
|
|
188
188
|
// feature.json in the root is NOT committed yet (create just
|
|
189
|
-
// wrote it), so the branch `runWorktreeSetup`
|
|
190
|
-
//
|
|
191
|
-
// into the worktree and re-materialize its local state.json.
|
|
189
|
+
// wrote it), so the branch `runWorktreeSetup` forked off the
|
|
190
|
+
// DEFAULT branch cannot have it — copy the freshly-scaffolded
|
|
191
|
+
// folder into the worktree and re-materialize its local state.json.
|
|
192
192
|
cpSync(featureDir, join(worktreeResult.path, '.morph', 'features', featureName), { recursive: true });
|
|
193
193
|
// The `.worktree` registration is ROOT-OWNED metadata (runWorktreeSetup
|
|
194
194
|
// stamped it onto the root's feature.json, which cpSync just copied in).
|
|
@@ -12,8 +12,50 @@ import {
|
|
|
12
12
|
branchExists,
|
|
13
13
|
} from '../project/worktree.js';
|
|
14
14
|
import { detectDefaultBranch, hasOriginRemote } from '../../lib/git/default-branch.js';
|
|
15
|
+
import { releaseClaim } from '../../../framework/hooks/shared/claim-registry.js';
|
|
15
16
|
import { runArchive } from './archive.js';
|
|
16
17
|
|
|
18
|
+
/** workType → conventional-commit type. `hotfix` IS a fix; anything else is a chore. */
|
|
19
|
+
const PR_TYPE_BY_WORK_TYPE = { feature: 'feat', bug: 'fix', hotfix: 'fix', chore: 'chore' };
|
|
20
|
+
|
|
21
|
+
/** Git's soft limit for a subject line. */
|
|
22
|
+
const PR_TITLE_MAX = 72;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The PR title for `finish --pr`.
|
|
26
|
+
*
|
|
27
|
+
* Was always `morph: {slug}` — so every PR in a repo whose hand-made ones follow
|
|
28
|
+
* conventional commits (`fix(ui): coluna CRM nos dossiês…`) arrived shapeless
|
|
29
|
+
* and said nothing about what changed (D4).
|
|
30
|
+
*
|
|
31
|
+
* Now: `{type}({slug}): {subject}` — the type from the feature's `workType`, the
|
|
32
|
+
* slug kept as the scope (it is the traceability the old title at least had),
|
|
33
|
+
* and the subject from the feature's own identity: `name` first (a human
|
|
34
|
+
* deliberately titled it), then `description`, first line only. With neither,
|
|
35
|
+
* degrade to `{type}: {slug}` rather than emit an empty subject.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} feature
|
|
38
|
+
* @param {object|null} fj - the feature.json
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
export function buildPrTitle(feature, fj) {
|
|
42
|
+
const type = PR_TYPE_BY_WORK_TYPE[fj?.workType] || 'chore';
|
|
43
|
+
|
|
44
|
+
const named = typeof fj?.name === 'string' ? fj.name.trim() : '';
|
|
45
|
+
// The default `name` IS the slug (create stamps it), which as a subject would
|
|
46
|
+
// only repeat the scope — treat it as absent.
|
|
47
|
+
const described = typeof fj?.description === 'string' ? fj.description.trim() : '';
|
|
48
|
+
const raw = (named && named !== feature ? named : described).split('\n')[0].trim();
|
|
49
|
+
if (!raw) return `${type}: ${feature}`;
|
|
50
|
+
|
|
51
|
+
const prefix = `${type}(${feature}): `;
|
|
52
|
+
const room = PR_TITLE_MAX - prefix.length;
|
|
53
|
+
// A scope so long there is no room left for a subject: drop the scope rather
|
|
54
|
+
// than ship a title that is all prefix.
|
|
55
|
+
if (room < 12) return `${type}: ${raw}`.slice(0, PR_TITLE_MAX);
|
|
56
|
+
return prefix + (raw.length <= room ? raw : `${raw.slice(0, room - 1)}…`);
|
|
57
|
+
}
|
|
58
|
+
|
|
17
59
|
/**
|
|
18
60
|
* `morph-spec finish <feature> --pr|--merge` — the single, symmetric close of a
|
|
19
61
|
* feature's lifecycle, for BOTH the worktree flow and the common flow.
|
|
@@ -37,9 +79,22 @@ import { runArchive } from './archive.js';
|
|
|
37
79
|
export async function finishCommand(feature, options = {}) {
|
|
38
80
|
const cwd = options.cwd || process.cwd();
|
|
39
81
|
const root = resolvePrimaryRoot(cwd);
|
|
40
|
-
const emit = (obj) => console.log(JSON.stringify(obj));
|
|
41
82
|
const rootNorm = resolve(root).replace(/\\/g, '/');
|
|
42
83
|
|
|
84
|
+
// A closed feature must not keep warning other sessions that someone is
|
|
85
|
+
// working on it (B1). Released on EVERY success path — hooking it into emit
|
|
86
|
+
// is the single point that covers merge/PR/--keep alike. Fail-open: a claim
|
|
87
|
+
// that outlives its feature only expires on its own TTL.
|
|
88
|
+
const emit = (obj) => {
|
|
89
|
+
if (obj?.finished === true) {
|
|
90
|
+
try {
|
|
91
|
+
const common = execFileSync('git', ['rev-parse', '--git-common-dir'], { cwd: root, stdio: 'pipe' }).toString().trim();
|
|
92
|
+
if (common) releaseClaim(feature, { gitCommonDir: resolve(root, common) });
|
|
93
|
+
} catch { /* fail-open */ }
|
|
94
|
+
}
|
|
95
|
+
console.log(JSON.stringify(obj));
|
|
96
|
+
};
|
|
97
|
+
|
|
43
98
|
// Gate 0: valid kebab name — anti command-injection (the name flows into every
|
|
44
99
|
// git/gh call below) and the same gate the worktree path always applied.
|
|
45
100
|
if (!isValidFeatureName(feature)) {
|
|
@@ -85,6 +140,28 @@ export async function finishCommand(feature, options = {}) {
|
|
|
85
140
|
}
|
|
86
141
|
worktreePath = found.path;
|
|
87
142
|
featureCwd = worktreePath;
|
|
143
|
+
|
|
144
|
+
// Validation 0b: the session's cwd must not be INSIDE the worktree we are
|
|
145
|
+
// about to tear down (A4). Windows holds a lock on the process's working
|
|
146
|
+
// directory, so `git worktree remove` fails — and by then the archive is
|
|
147
|
+
// committed, the branch merged, and the infra junctions already dropped.
|
|
148
|
+
// The result is a worse state than never having tried: an orphan directory,
|
|
149
|
+
// a de-registered worktree, and a `relinkHint` telling the user to repair
|
|
150
|
+
// by hand. Refuse HERE, before the first mutation — the cost is one
|
|
151
|
+
// `cd`, and it is the exact failure observed in the ProspectPRO session.
|
|
152
|
+
const cwdNorm = resolve(cwd).replace(/\\/g, '/');
|
|
153
|
+
const wtNorm = resolve(worktreePath).replace(/\\/g, '/');
|
|
154
|
+
if (cwdNorm === wtNorm || cwdNorm.startsWith(`${wtNorm}/`)) {
|
|
155
|
+
emit({
|
|
156
|
+
finished: false,
|
|
157
|
+
error: 'cwd-inside-worktree',
|
|
158
|
+
feature,
|
|
159
|
+
path: worktreePath,
|
|
160
|
+
root,
|
|
161
|
+
hint: `saia do worktree antes do finish — o \`git worktree remove\` não consegue apagar o diretório de trabalho do próprio processo. Rode de novo a partir de "${root}"`,
|
|
162
|
+
});
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
88
165
|
} else {
|
|
89
166
|
// COMMON flow: the feature branch must exist and be the ROOT's current
|
|
90
167
|
// branch (so the archive commits on it and we can checkout the default to
|
|
@@ -120,7 +197,7 @@ export async function finishCommand(feature, options = {}) {
|
|
|
120
197
|
emit({ finished: false, error: 'review-not-approved', feature });
|
|
121
198
|
process.exit(1);
|
|
122
199
|
}
|
|
123
|
-
const prTitle =
|
|
200
|
+
const prTitle = buildPrTitle(feature, fj);
|
|
124
201
|
const prBody = (fj?.description && String(fj.description).trim())
|
|
125
202
|
? String(fj.description).trim()
|
|
126
203
|
: `Feature ${feature} — integrada via morph-spec finish --pr.`;
|
|
@@ -346,7 +423,15 @@ function teardownExtras(teardown, worktreePath, branch, deleteBranch) {
|
|
|
346
423
|
if (teardown.blockers.length) out.reparsePoints = teardown.blockers;
|
|
347
424
|
if (teardown.ignoredBlocking.length) out.ignoredFiles = teardown.ignoredBlocking;
|
|
348
425
|
out.cleanupHint = teardown.declineReason;
|
|
349
|
-
|
|
426
|
+
// The unlink runs before the removal by design (it is what keeps `git
|
|
427
|
+
// worktree remove` from following the junctions out into the primary root),
|
|
428
|
+
// so a declined removal always passes through a moment with the infra
|
|
429
|
+
// loose. The teardown now re-links on that path — report which of the two
|
|
430
|
+
// actually happened instead of always announcing the worse one (A4).
|
|
431
|
+
out.relinked = teardown.relinked === true;
|
|
432
|
+
out.relinkHint = teardown.relinked
|
|
433
|
+
? 'as junctions de infra foram soltas antes da tentativa e RESTAURADAS após a recusa — o worktree segue utilizável'
|
|
434
|
+
: `as junctions de infra foram soltas antes desta recusa e não puderam ser restauradas — para continuar no worktree, rode \`morph-spec worktree link --all --target "${worktreePath}"\``;
|
|
350
435
|
}
|
|
351
436
|
return out;
|
|
352
437
|
}
|
|
@@ -44,8 +44,14 @@ function buildRow(name, fj, fdir, location) {
|
|
|
44
44
|
const gates = {};
|
|
45
45
|
for (const g of G) gates[g] = fj.approvalGates?.[g]?.approved === true;
|
|
46
46
|
|
|
47
|
+
// Gate 3 approved WITHOUT the independent evaluator (`approve --self-assessed`).
|
|
48
|
+
// A recorded degradation is only worth recording if it is visible — this is
|
|
49
|
+
// the surface that makes it so (D2).
|
|
50
|
+
const selfAssessed = fj.approvalGates?.review?.selfAssessed === true;
|
|
51
|
+
|
|
47
52
|
const flags = [];
|
|
48
53
|
if (fj.__corrupt) flags.push('corrupt');
|
|
54
|
+
if (selfAssessed) flags.push('self-assessed-g3');
|
|
49
55
|
// Identity + gate-order anomalies come from the shared rules
|
|
50
56
|
// (core/state/feature-identity.js, core/state/gate-order.js) — same source
|
|
51
57
|
// doctor reports from.
|
|
@@ -60,6 +66,7 @@ function buildRow(name, fj, fdir, location) {
|
|
|
60
66
|
status: fj.status || 'unknown',
|
|
61
67
|
phase: location === 'archived' ? 'archived' : derivePhase(fdir),
|
|
62
68
|
gates,
|
|
69
|
+
selfAssessed,
|
|
63
70
|
flags,
|
|
64
71
|
location,
|
|
65
72
|
createdAt: fj.createdAt || null,
|
|
@@ -93,7 +100,8 @@ export function listCommand(options = {}) {
|
|
|
93
100
|
));
|
|
94
101
|
for (const r of rows) {
|
|
95
102
|
const flag = r.flags.length ? chalk.yellow('⚠') : ' ';
|
|
96
|
-
const desc = r.
|
|
103
|
+
const desc = (r.selfAssessed ? chalk.yellow('[G3 self-assessed] ') : '')
|
|
104
|
+
+ (r.description ? truncate(r.description, 44) : chalk.yellow('(sem descrição)'));
|
|
97
105
|
// padEnd counts the invisible color codes; pad the plain marks manually.
|
|
98
106
|
console.log(
|
|
99
107
|
col(r.feature, 22) + col(r.workType, 9) + col(r.status, 13) + col(r.phase, 11)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `morph-spec report-check <feature> [task] [--json]` — the deterministic half
|
|
3
|
+
* of the missing-report protocol (B4).
|
|
4
|
+
*
|
|
5
|
+
* MORPH.md already made the sub-agent's canonical report file the authoritative
|
|
6
|
+
* return value ("o ARQUIVO é o retorno, a última mensagem é cortesia"), but it
|
|
7
|
+
* never said what to do when the file is not there. The naive reading — "no
|
|
8
|
+
* report, so the agent failed" — is wrong and expensive: the agent may have
|
|
9
|
+
* done every bit of the work and lost the means to record it (observed: a
|
|
10
|
+
* worktree removed out from under a running sub-agent).
|
|
11
|
+
*
|
|
12
|
+
* So this command answers the question the orchestrator actually has, and
|
|
13
|
+
* answers it from evidence rather than from the agent's own word: **did the
|
|
14
|
+
* WORK land, independently of whether the REPORT did?**
|
|
15
|
+
*
|
|
16
|
+
* verdict action
|
|
17
|
+
* ───────────────────────────── ──────────────────
|
|
18
|
+
* ok proceed
|
|
19
|
+
* report-incomplete complete-degraded
|
|
20
|
+
* work-landed-report-missing accept-degraded
|
|
21
|
+
* no-work-no-report redispatch
|
|
22
|
+
* report-missing-evidence-unknown redispatch (conservative default)
|
|
23
|
+
*
|
|
24
|
+
* Exit 0 only for `ok` — anything needing an action exits non-zero so a caller
|
|
25
|
+
* can branch on it. `--json` always prints the machine-readable verdict.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import chalk from 'chalk';
|
|
29
|
+
import { existsSync, readFileSync } from 'fs';
|
|
30
|
+
import { join } from 'path';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The four mandatory items of the report contract, as detectable markers.
|
|
34
|
+
*
|
|
35
|
+
* HEURISTIC BY CONSTRUCTION, and deliberately loose: the contract asks for
|
|
36
|
+
* content, not for a fixed template, and reports written before this command
|
|
37
|
+
* existed must still audit cleanly. A false "complete" is the cheap error here
|
|
38
|
+
* (the orchestrator reads the report anyway); a false "incomplete" would send
|
|
39
|
+
* it re-dispatching finished work, which is the expensive one.
|
|
40
|
+
*/
|
|
41
|
+
const REPORT_MARKERS = [
|
|
42
|
+
// (2) files touched — a list line carrying a path or a backticked identifier
|
|
43
|
+
{ id: 'files', re: /^\s*[-*]\s+.*[`/\\].*$/m },
|
|
44
|
+
// (4) self-score 0-10
|
|
45
|
+
{ id: 'score', re: /score[^\n]*?\b(10|\d)(?:[.,]\d+)?\b/i },
|
|
46
|
+
// (3) problems found (or an explicit "none")
|
|
47
|
+
{ id: 'problems', re: /problema|issue|blocker|nenhum|impedimento/i },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/** (1) "what was done" — prose beyond the structural markers. */
|
|
51
|
+
const MIN_PROSE_CHARS = 80;
|
|
52
|
+
|
|
53
|
+
function reportPath(cwd, feature, taskId) {
|
|
54
|
+
return join(cwd, '.morph', 'features', feature, '3-implement', 'reports', `${taskId}.md`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function evaluatorPath(cwd, feature) {
|
|
58
|
+
return join(cwd, '.morph', 'features', feature, '4-review', 'evaluator-report.md');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Inspect one report file: does it exist, and which of the four mandatory
|
|
63
|
+
* items can be found in it.
|
|
64
|
+
*/
|
|
65
|
+
export function inspectReport(path) {
|
|
66
|
+
if (!existsSync(path)) return { exists: false, complete: false, missing: ['what', 'files', 'problems', 'score'] };
|
|
67
|
+
let body = '';
|
|
68
|
+
try {
|
|
69
|
+
body = readFileSync(path, 'utf8');
|
|
70
|
+
} catch {
|
|
71
|
+
return { exists: true, complete: false, missing: ['what', 'files', 'problems', 'score'], unreadable: true };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const missing = REPORT_MARKERS.filter((m) => !m.re.test(body)).map((m) => m.id);
|
|
75
|
+
// Prose = the body minus headings and list lines, so a bare file list does
|
|
76
|
+
// not pass as "what was done".
|
|
77
|
+
const prose = body
|
|
78
|
+
.split('\n')
|
|
79
|
+
.filter((l) => !/^\s*(#|[-*]\s)/.test(l))
|
|
80
|
+
.join(' ')
|
|
81
|
+
.replace(/\s+/g, ' ')
|
|
82
|
+
.trim();
|
|
83
|
+
if (prose.length < MIN_PROSE_CHARS) missing.unshift('what');
|
|
84
|
+
|
|
85
|
+
return { exists: true, complete: missing.length === 0, missing };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** How many of a task's declared outputs actually exist on disk. */
|
|
89
|
+
function outputEvidence(cwd, taskOutputs) {
|
|
90
|
+
const expected = Array.isArray(taskOutputs) ? taskOutputs : [];
|
|
91
|
+
const present = expected.filter((rel) => {
|
|
92
|
+
try {
|
|
93
|
+
return existsSync(join(cwd, rel));
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}).length;
|
|
98
|
+
return { outputsExpected: expected.length, outputsPresent: present };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Combine report state + evidence into a verdict and the action it implies. */
|
|
102
|
+
function decide(report, evidence) {
|
|
103
|
+
if (report.exists && report.complete) return { verdict: 'ok', action: 'proceed' };
|
|
104
|
+
if (report.exists) return { verdict: 'report-incomplete', action: 'complete-degraded' };
|
|
105
|
+
if (evidence.outputsExpected === 0) {
|
|
106
|
+
// No declared outputs means no evidence either way. Re-dispatching costs a
|
|
107
|
+
// sub-agent; silently accepting a phantom completion costs correctness.
|
|
108
|
+
return { verdict: 'report-missing-evidence-unknown', action: 'redispatch' };
|
|
109
|
+
}
|
|
110
|
+
return evidence.outputsPresent > 0
|
|
111
|
+
? { verdict: 'work-landed-report-missing', action: 'accept-degraded' }
|
|
112
|
+
: { verdict: 'no-work-no-report', action: 'redispatch' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function checkTask(cwd, feature, task) {
|
|
116
|
+
const report = inspectReport(reportPath(cwd, feature, task.id));
|
|
117
|
+
const evidence = outputEvidence(cwd, task.outputs);
|
|
118
|
+
return { task: task.id, report, evidence, ...decide(report, evidence) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const ACTION_HINT = {
|
|
122
|
+
proceed: 'relatório íntegro — siga o fluxo normal.',
|
|
123
|
+
'complete-degraded': 'complete os itens faltantes a partir da mensagem de retorno do sub-agent, carimbe o relatório como DEGRADADO e registre a falha de protocolo no recap.',
|
|
124
|
+
'accept-degraded': 'o TRABALHO entrou — não re-despache. Grave você mesmo o relatório canônico a partir da mensagem de retorno, carimbado como DEGRADADO, e registre a falha de protocolo no recap e nas `notes` da task.',
|
|
125
|
+
redispatch: 're-despache a task UMA vez com o mesmo prompt. Segunda ausência sem trabalho entregue = falha real: escale ao humano.',
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {string} feature
|
|
130
|
+
* @param {string|undefined} taskId
|
|
131
|
+
* @param {{ json?: boolean, cwd?: string }} [options]
|
|
132
|
+
*/
|
|
133
|
+
export function reportCheckCommand(feature, taskId, options = {}) {
|
|
134
|
+
const cwd = options.cwd || process.cwd();
|
|
135
|
+
const emit = (obj, code) => {
|
|
136
|
+
if (options.json) console.log(JSON.stringify(obj, null, 2));
|
|
137
|
+
else printHuman(obj);
|
|
138
|
+
process.exit(code);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const tasksPath = join(cwd, '.morph', 'features', feature, '2-plan', 'tasks.json');
|
|
142
|
+
if (!existsSync(tasksPath)) {
|
|
143
|
+
emit({ error: `feature "${feature}" não tem 2-plan/tasks.json — nada a auditar`, feature }, 1);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let tasks = [];
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(readFileSync(tasksPath, 'utf8'));
|
|
149
|
+
tasks = Array.isArray(parsed.tasks) ? parsed.tasks : [];
|
|
150
|
+
} catch {
|
|
151
|
+
emit({ error: `2-plan/tasks.json de "${feature}" está corrompido`, feature }, 1);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (taskId) {
|
|
155
|
+
const task = tasks.find((t) => t.id === taskId);
|
|
156
|
+
if (!task) emit({ error: `task "${taskId}" não existe em tasks.json`, feature }, 1);
|
|
157
|
+
const result = checkTask(cwd, feature, task);
|
|
158
|
+
emit({ feature, ...result, hint: ACTION_HINT[result.action] }, result.verdict === 'ok' ? 0 : 1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Whole-feature audit: every DONE task owes a report; a pending one does not.
|
|
162
|
+
const results = tasks.filter((t) => t.status === 'done').map((t) => checkTask(cwd, feature, t));
|
|
163
|
+
const evaluator = inspectReport(evaluatorPath(cwd, feature));
|
|
164
|
+
const clean = results.every((r) => r.verdict === 'ok');
|
|
165
|
+
emit({ feature, results, evaluator, clean }, clean ? 0 : 1);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function printHuman(obj) {
|
|
169
|
+
if (obj.error) {
|
|
170
|
+
console.error(chalk.red(`\n❌ ${obj.error}\n`));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const line = (r) => {
|
|
174
|
+
const icon = r.verdict === 'ok' ? chalk.green('✓') : chalk.yellow('⚠');
|
|
175
|
+
const ev = r.evidence.outputsExpected
|
|
176
|
+
? `outputs ${r.evidence.outputsPresent}/${r.evidence.outputsExpected}`
|
|
177
|
+
: 'outputs não declarados';
|
|
178
|
+
console.log(` ${icon} ${String(r.task).padEnd(6)} ${r.verdict.padEnd(32)} ${ev}`);
|
|
179
|
+
if (r.verdict !== 'ok') {
|
|
180
|
+
if (r.report.exists && r.report.missing.length) {
|
|
181
|
+
console.log(chalk.gray(` faltando no relatório: ${r.report.missing.join(', ')}`));
|
|
182
|
+
}
|
|
183
|
+
console.log(chalk.gray(` → ${ACTION_HINT[r.action]}`));
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
if (obj.task) {
|
|
188
|
+
console.log(chalk.bold(`\n📄 report-check ${obj.feature} ${obj.task}`));
|
|
189
|
+
line(obj);
|
|
190
|
+
console.log('');
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
console.log(chalk.bold(`\n📄 report-check ${obj.feature}`));
|
|
195
|
+
if (!obj.results.length) console.log(chalk.gray(' nenhuma task done — nada a auditar'));
|
|
196
|
+
for (const r of obj.results) line(r);
|
|
197
|
+
console.log(
|
|
198
|
+
` ${obj.evaluator.exists ? chalk.green('✓') : chalk.yellow('⚠')} ${'Gate 3'.padEnd(6)} `
|
|
199
|
+
+ `evaluator-report.md ${obj.evaluator.exists ? (obj.evaluator.complete ? 'íntegro' : 'incompleto') : 'ausente'}`,
|
|
200
|
+
);
|
|
201
|
+
console.log('');
|
|
202
|
+
}
|