@polymorphism-tech/morph-spec 8.6.0 → 8.8.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/__pycache__/statusline.cpython-313.pyc +0 -0
- 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/claude-code/statusline.py +545 -521
- 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
- package/src/utils/hooks-installer.js +7 -1
|
@@ -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
|
+
}
|
|
@@ -10,14 +10,68 @@
|
|
|
10
10
|
* • orphan-folder — a feature folder with no feature.json
|
|
11
11
|
* • stale-index — a root-index entry with no feature folder (auto-heals on
|
|
12
12
|
* the next loadState, but surfaced so it's not a mystery)
|
|
13
|
+
* • branch-drift — the root's copy of a feature.json disagrees with the copy
|
|
14
|
+
* committed on `morph/{f}` (B5 — the root's view lags)
|
|
13
15
|
* • corrupt — a feature.json that won't parse
|
|
14
16
|
*/
|
|
15
17
|
|
|
18
|
+
import { execFileSync } from 'child_process';
|
|
16
19
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
17
20
|
import { join } from 'path';
|
|
18
21
|
import { gateOrderViolations } from './gate-order.js';
|
|
19
22
|
import { missingIdentity } from './feature-identity.js';
|
|
20
23
|
|
|
24
|
+
/** The operational truth of a feature.json: its status and which gates are approved. */
|
|
25
|
+
function operationalShape(fj) {
|
|
26
|
+
const gates = {};
|
|
27
|
+
for (const [g, v] of Object.entries(fj?.approvalGates || {})) gates[g] = v?.approved === true;
|
|
28
|
+
return JSON.stringify({ status: fj?.status ?? null, gates });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Does the root's working-tree copy of a feature.json disagree with the copy
|
|
33
|
+
* COMMITTED on that feature's own branch?
|
|
34
|
+
*
|
|
35
|
+
* `.morph/state.json` is gitignored and derived, and every worktree keeps its
|
|
36
|
+
* own — so "the index diverges between worktrees" is by design, not a defect to
|
|
37
|
+
* fix by making the index authoritative (that would undo the whole per-feature
|
|
38
|
+
* state model). The defect is that the divergence was INVISIBLE: since v8.4 the
|
|
39
|
+
* authoritative feature.json travels committed on `morph/{f}`, so a primary root
|
|
40
|
+
* parked on the default holds a copy that silently lags, and everything derived
|
|
41
|
+
* from it (status, gates, and the index rebuilt from it) lags with it.
|
|
42
|
+
*
|
|
43
|
+
* Comparison is on the OPERATIONAL fields only — status and approved-gate flags
|
|
44
|
+
* — not on bytes: reformatting or a timestamp bump is not a divergence anyone
|
|
45
|
+
* needs to hear about.
|
|
46
|
+
*
|
|
47
|
+
* Fail-open at every step: no repo, no branch, no blob, unparseable JSON → no
|
|
48
|
+
* issue. A diagnostic that invents problems is worse than one that misses some.
|
|
49
|
+
*
|
|
50
|
+
* @returns {string|null} the issue detail, or null when the copies agree
|
|
51
|
+
*/
|
|
52
|
+
function branchDrift(baseDir, feature, rootFj) {
|
|
53
|
+
const branch = `morph/${feature}`;
|
|
54
|
+
try {
|
|
55
|
+
const current = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: baseDir, stdio: 'pipe' })
|
|
56
|
+
.toString().trim();
|
|
57
|
+
// On the feature's own branch the working tree IS the branch — nothing to compare.
|
|
58
|
+
if (current === branch) return null;
|
|
59
|
+
|
|
60
|
+
execFileSync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], { cwd: baseDir, stdio: 'pipe' });
|
|
61
|
+
|
|
62
|
+
const raw = execFileSync('git', ['show', `${branch}:.morph/features/${feature}/feature.json`], {
|
|
63
|
+
cwd: baseDir, stdio: 'pipe',
|
|
64
|
+
}).toString();
|
|
65
|
+
const branchFj = JSON.parse(raw);
|
|
66
|
+
|
|
67
|
+
if (operationalShape(branchFj) === operationalShape(rootFj)) return null;
|
|
68
|
+
return `root copy disagrees with ${branch} (status/gates) — the branch is authoritative; `
|
|
69
|
+
+ `rode \`git show ${branch}:.morph/features/${feature}/feature.json\` para ver`;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
21
75
|
/**
|
|
22
76
|
* @param {string} [baseDir] - project root (defaults to cwd)
|
|
23
77
|
* @returns {Array<{ feature: string, type: string, detail: string }>} issues
|
|
@@ -49,6 +103,9 @@ export function auditFeatureLifecycle(baseDir = process.cwd()) {
|
|
|
49
103
|
continue;
|
|
50
104
|
}
|
|
51
105
|
|
|
106
|
+
const drift = branchDrift(baseDir, name, fj);
|
|
107
|
+
if (drift) issues.push({ feature: name, type: 'branch-drift', detail: drift });
|
|
108
|
+
|
|
52
109
|
const miss = missingIdentity(fj);
|
|
53
110
|
if (miss.length) {
|
|
54
111
|
issues.push({ feature: name, type: 'identity', detail: `missing ${miss.join(', ')}` });
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
|
+
import { WORKTREE_DIR_NAMES } from '../../commands/project/worktree.js';
|
|
3
4
|
|
|
4
5
|
export function scanState(projectDir) {
|
|
5
6
|
const statePath = join(projectDir, '.morph', 'state.json');
|
|
@@ -32,15 +33,22 @@ export function scanState(projectDir) {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
// Aggregate in-progress features that only exist inside a feature worktree
|
|
35
|
-
// (
|
|
36
|
+
// (worktrees/{wt}/.morph/features/*/feature.json). In the v8 multi-instance
|
|
36
37
|
// model a feature's authoritative feature.json is committed on its
|
|
37
38
|
// `morph/{feature}` branch and only lands in the root .morph/features/ after
|
|
38
39
|
// `morph-spec finish`. This is read-only visibility for the dashboard — root
|
|
39
40
|
// features (the merged, authoritative state) always take precedence, and
|
|
40
41
|
// worktree-sourced entries are tagged with `_worktree` so the UI can
|
|
41
42
|
// distinguish them.
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
//
|
|
44
|
+
// BOTH directory names are scanned: this scanner resolves worktrees by
|
|
45
|
+
// CONVENTION (unlike fleet/finish, which read `git worktree list`), so it is
|
|
46
|
+
// the one that would otherwise go blind to pre-C1 `.worktrees/` checkouts.
|
|
47
|
+
const worktreeDirs = WORKTREE_DIR_NAMES
|
|
48
|
+
.map((name) => join(projectDir, name))
|
|
49
|
+
.filter((dir) => existsSync(dir));
|
|
50
|
+
|
|
51
|
+
for (const worktreesDir of worktreeDirs) {
|
|
44
52
|
let worktreeNames = [];
|
|
45
53
|
try {
|
|
46
54
|
worktreeNames = readdirSync(worktreesDir, { withFileTypes: true })
|
|
@@ -19,7 +19,7 @@ import path from 'path';
|
|
|
19
19
|
import { existsSync, readFileSync } from 'fs';
|
|
20
20
|
import { renderTemplate } from '../../core/templates/template-renderer.js';
|
|
21
21
|
|
|
22
|
-
const SKIP_DIRS = new Set(['node_modules', 'bin', 'obj', '.git', '.morph', '.next', 'dist', 'out', '.worktrees']);
|
|
22
|
+
const SKIP_DIRS = new Set(['node_modules', 'bin', 'obj', '.git', '.morph', '.next', 'dist', 'out', 'worktrees', '.worktrees']);
|
|
23
23
|
const MAX_FILES_PER_SCANNER = 200;
|
|
24
24
|
|
|
25
25
|
/**
|
|
@@ -201,6 +201,14 @@ export function groupTasks(data) {
|
|
|
201
201
|
* Convert tasks.json to the legacy taskList shape used in state.json.
|
|
202
202
|
* Lets the rest of the codebase keep using feature.taskList while migration
|
|
203
203
|
* is in progress.
|
|
204
|
+
*
|
|
205
|
+
* BOUNDARY — this is the ONE place `done` becomes `completed`, and it is a
|
|
206
|
+
* one-way translation OUT of the tasks.json vocabulary. In tasks.json the
|
|
207
|
+
* terminal status is `done` (tasks.schema.json's enum has no `completed`);
|
|
208
|
+
* `completed` belongs to the legacy taskList and to Claude Code's native
|
|
209
|
+
* session tasks. Leaking `completed` back into a tasks.json write is rejected
|
|
210
|
+
* by `protect-spec-files` — see framework/rules/morph-task-tracking.md, which
|
|
211
|
+
* spells the split out for the author.
|
|
204
212
|
*/
|
|
205
213
|
export function toTaskList(data) {
|
|
206
214
|
return data.tasks.map(t => ({
|
|
@@ -17,18 +17,118 @@ import { execSync, execFileSync } from 'child_process';
|
|
|
17
17
|
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
18
18
|
import { join, resolve } from 'path';
|
|
19
19
|
import { isGitRepo, resolvePrimaryRoot, parseWorktreeList } from '../../commands/project/worktree.js';
|
|
20
|
+
import { hasOriginRemote } from '../git/default-branch.js';
|
|
20
21
|
import { registryPath, resolveFleetConfig } from './env-allocator.js';
|
|
21
22
|
import {
|
|
22
23
|
getFeature,
|
|
23
24
|
derivePhaseForFeature,
|
|
25
|
+
derivePhaseFrom,
|
|
24
26
|
getPendingGates,
|
|
27
|
+
pendingGatesFrom,
|
|
25
28
|
} from '../../../framework/hooks/shared/state-reader.js';
|
|
29
|
+
import { listClaims } from '../../../framework/hooks/shared/claim-registry.js';
|
|
30
|
+
|
|
31
|
+
function countFrom(raw) {
|
|
32
|
+
const data = JSON.parse(raw);
|
|
33
|
+
if (!Array.isArray(data.tasks)) return null;
|
|
34
|
+
return { done: data.tasks.filter((t) => t.status === 'done').length, total: data.tasks.length };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Which branch holds this feature's truth, when the working tree at `path` does
|
|
39
|
+
* NOT — or `null` when the working tree IS the feature's own checkout.
|
|
40
|
+
*
|
|
41
|
+
* The ONE place this condition lives. `tasks` (B3) and `phase`/`pendingGates`
|
|
42
|
+
* (B3b) are the same defect read through different files, so they must agree on
|
|
43
|
+
* when to look at the branch; two drifting copies of this test would resurrect
|
|
44
|
+
* the bug on one field while the other stayed fixed.
|
|
45
|
+
*
|
|
46
|
+
* A worktree entry is the feature's checkout by construction. A root entry is
|
|
47
|
+
* only the feature's checkout when the root happens to be ON `morph/{f}`.
|
|
48
|
+
*
|
|
49
|
+
* @returns {string|null} branch name to read from, or null for "read the disk"
|
|
50
|
+
*/
|
|
51
|
+
function branchToReadFrom(root, feature, location, branch) {
|
|
52
|
+
if (location === 'worktree' || !root || !feature) return null;
|
|
53
|
+
const featureBranch = branch || `morph/${feature}`;
|
|
54
|
+
try {
|
|
55
|
+
const current = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, stdio: 'pipe' })
|
|
56
|
+
.toString().trim();
|
|
57
|
+
return current === featureBranch ? null : featureBranch;
|
|
58
|
+
} catch {
|
|
59
|
+
return null; // no HEAD to compare against — trust the disk
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The feature's committed state on its own branch: the feature.json blob plus
|
|
65
|
+
* the set of files under its feature dir.
|
|
66
|
+
*
|
|
67
|
+
* `git ls-tree -z` is deliberate: without it git QUOTES paths containing
|
|
68
|
+
* non-ASCII bytes (`"a\303\247/…"`), which would silently fail the prefix strip
|
|
69
|
+
* and leave the artifact set empty — a predicate that is always false looks
|
|
70
|
+
* exactly like "this feature has no artifacts".
|
|
71
|
+
*
|
|
72
|
+
* Fail-open in layers: no blob → null (caller reads the disk); no tree → the
|
|
73
|
+
* gates alone still beat a stale working tree.
|
|
74
|
+
*
|
|
75
|
+
* @returns {{ fj: Object, files: Set<string> }|null}
|
|
76
|
+
*/
|
|
77
|
+
function branchView(root, feature, featureBranch) {
|
|
78
|
+
const prefix = `.morph/features/${feature}/`;
|
|
79
|
+
let fj;
|
|
80
|
+
try {
|
|
81
|
+
fj = JSON.parse(execFileSync('git', ['show', `${featureBranch}:${prefix}feature.json`], {
|
|
82
|
+
cwd: root, stdio: 'pipe',
|
|
83
|
+
}).toString());
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const files = new Set();
|
|
89
|
+
try {
|
|
90
|
+
const out = execFileSync('git', ['ls-tree', '-r', '--name-only', '-z', featureBranch, '--', prefix], {
|
|
91
|
+
cwd: root, stdio: 'pipe',
|
|
92
|
+
}).toString();
|
|
93
|
+
for (const raw of out.split('\0')) {
|
|
94
|
+
const p = raw.trim();
|
|
95
|
+
if (p.startsWith(prefix)) files.add(p.slice(prefix.length));
|
|
96
|
+
}
|
|
97
|
+
} catch { /* no tree — gates alone still beat the stale working tree */ }
|
|
98
|
+
|
|
99
|
+
return { fj, files };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Task progress for one feature.
|
|
104
|
+
*
|
|
105
|
+
* DIVERGENCE from the bug report, which blamed "a cached state.json/feature.json
|
|
106
|
+
* snapshot": this never read either — it always read `2-plan/tasks.json`
|
|
107
|
+
* directly. The real mechanism is which COPY of that file is on disk. Since
|
|
108
|
+
* v8.4 every feature is born on `morph/{f}` and its tasks.json is COMMITTED
|
|
109
|
+
* there; the primary root's working tree only ever holds the copy of the
|
|
110
|
+
* CHECKED-OUT branch. An operator sitting on the default therefore read the
|
|
111
|
+
* pre-work copy — the reported `2/2` for a feature whose branch already had
|
|
112
|
+
* T1..T3 all done.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} featureDir - the feature dir inside `path`
|
|
115
|
+
* @param {{ root: string, feature: string, featureBranch: string|null }} ctx
|
|
116
|
+
*/
|
|
117
|
+
function taskCounts(featureDir, { root, feature, featureBranch } = {}) {
|
|
118
|
+
if (featureBranch && root && feature) {
|
|
119
|
+
try {
|
|
120
|
+
const raw = execFileSync(
|
|
121
|
+
'git',
|
|
122
|
+
['show', `${featureBranch}:.morph/features/${feature}/2-plan/tasks.json`],
|
|
123
|
+
{ cwd: root, stdio: 'pipe' },
|
|
124
|
+
).toString();
|
|
125
|
+
const counted = countFrom(raw);
|
|
126
|
+
if (counted) return counted;
|
|
127
|
+
} catch { /* no such branch/blob — fall through to the working tree */ }
|
|
128
|
+
}
|
|
26
129
|
|
|
27
|
-
function taskCounts(featureDir) {
|
|
28
130
|
try {
|
|
29
|
-
|
|
30
|
-
if (!Array.isArray(data.tasks)) return null;
|
|
31
|
-
return { done: data.tasks.filter((t) => t.status === 'done').length, total: data.tasks.length };
|
|
131
|
+
return countFrom(readFileSync(join(featureDir, '2-plan', 'tasks.json'), 'utf8'));
|
|
32
132
|
} catch {
|
|
33
133
|
return null;
|
|
34
134
|
}
|
|
@@ -63,17 +163,108 @@ function dirtyCount(worktreePath) {
|
|
|
63
163
|
}
|
|
64
164
|
}
|
|
65
165
|
|
|
66
|
-
|
|
166
|
+
/**
|
|
167
|
+
* One `git ls-remote --heads origin` for the WHOLE scan → Map<branch, sha>.
|
|
168
|
+
*
|
|
169
|
+
* The fleet used to read only local state, so a feature whose branch (and PR)
|
|
170
|
+
* already lived on origin still showed up as "pending, nothing pushed" — that
|
|
171
|
+
* reading is what made a second session redo finished work. Resolved once per
|
|
172
|
+
* scan, never per entry.
|
|
173
|
+
*
|
|
174
|
+
* Returns `null` when there is no origin, or the probe fails/times out: the
|
|
175
|
+
* entries then carry `remote: null` ("unknown"), never a false `pushed: false`.
|
|
176
|
+
*
|
|
177
|
+
* @param {string} root
|
|
178
|
+
* @returns {Map<string,string>|null}
|
|
179
|
+
*/
|
|
180
|
+
function remoteHeads(root) {
|
|
181
|
+
if (!hasOriginRemote(root)) return null;
|
|
182
|
+
try {
|
|
183
|
+
const out = execFileSync('git', ['ls-remote', '--heads', 'origin'], {
|
|
184
|
+
cwd: root, stdio: 'pipe', timeout: 5000,
|
|
185
|
+
}).toString();
|
|
186
|
+
const map = new Map();
|
|
187
|
+
for (const line of out.split('\n')) {
|
|
188
|
+
const m = line.match(/^([0-9a-f]{7,64})\s+refs\/heads\/(.+)$/);
|
|
189
|
+
if (m) map.set(m[2].trim(), m[1]);
|
|
190
|
+
}
|
|
191
|
+
return map;
|
|
192
|
+
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* One `gh pr list` for the whole scan → Map<headRefName, {number, state}>.
|
|
199
|
+
*
|
|
200
|
+
* Gated on the origin URL actually pointing at github.com — `gh` against any
|
|
201
|
+
* other host is a guaranteed error, and paying its start-up cost to learn that
|
|
202
|
+
* on every `fleet` call is pure latency. Fail-open: no `gh`, not authenticated,
|
|
203
|
+
* or a timeout all yield `null`, which just leaves the PR half of the column
|
|
204
|
+
* empty while `pushed` still works.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} root
|
|
207
|
+
* @returns {Map<string,{number:number,state:string}>|null}
|
|
208
|
+
*/
|
|
209
|
+
function remotePrs(root) {
|
|
210
|
+
try {
|
|
211
|
+
const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: root, stdio: 'pipe' }).toString();
|
|
212
|
+
if (!/github\.com/i.test(url)) return null;
|
|
213
|
+
const out = execFileSync('gh', ['pr', 'list', '--state', 'all', '--limit', '100', '--json', 'number,state,headRefName'], {
|
|
214
|
+
cwd: root, stdio: 'pipe', timeout: 8000,
|
|
215
|
+
}).toString();
|
|
216
|
+
const map = new Map();
|
|
217
|
+
for (const pr of JSON.parse(out)) {
|
|
218
|
+
// First wins: `gh` lists newest-first, so an OPEN PR shadows an older
|
|
219
|
+
// closed one on the same branch.
|
|
220
|
+
if (pr?.headRefName && !map.has(pr.headRefName)) map.set(pr.headRefName, { number: pr.number, state: pr.state });
|
|
221
|
+
}
|
|
222
|
+
return map;
|
|
223
|
+
} catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The remote fact for one feature. Worktree entries know their real branch
|
|
230
|
+
* (which may carry a `--fresh` date suffix); root-resident features fall back
|
|
231
|
+
* to the canonical `morph/{feature}` every feature is born on.
|
|
232
|
+
*/
|
|
233
|
+
function buildRemote(feature, branch, heads, prs) {
|
|
234
|
+
if (!heads) return null;
|
|
235
|
+
const remoteBranch = branch || `morph/${feature}`;
|
|
236
|
+
const sha = heads.get(remoteBranch) || null;
|
|
237
|
+
return { branch: remoteBranch, pushed: sha !== null, sha, pr: prs?.get(remoteBranch) || null };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function buildEntry(feature, { location, path, branch, root, allocations, blockSize, heads, prs, claims }) {
|
|
67
241
|
let workType = null;
|
|
68
242
|
let status = 'unknown';
|
|
69
243
|
let phase = 'proposal';
|
|
70
244
|
let pendingGates = [];
|
|
245
|
+
|
|
246
|
+
// Resolved once and shared with taskCounts — see branchToReadFrom.
|
|
247
|
+
const featureBranch = branchToReadFrom(root, feature, location, branch);
|
|
248
|
+
|
|
71
249
|
try {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
250
|
+
// B3b: phase and gates suffered the same branch-staleness B3 fixed for
|
|
251
|
+
// tasks. They read `.morph/features/{f}/` through the working tree, so a
|
|
252
|
+
// root parked on the default reported the PRE-WORK phase for a feature
|
|
253
|
+
// whose branch was already at implement — and re-asked for a gate the
|
|
254
|
+
// feature branch had already approved.
|
|
255
|
+
const view = featureBranch ? branchView(root, feature, featureBranch) : null;
|
|
256
|
+
if (view) {
|
|
257
|
+
workType = view.fj?.workType || null;
|
|
258
|
+
status = view.fj?.status || 'unknown';
|
|
259
|
+
phase = derivePhaseFrom(view.fj, (rel) => view.files.has(rel));
|
|
260
|
+
pendingGates = pendingGatesFrom(view.fj);
|
|
261
|
+
} else {
|
|
262
|
+
const fj = getFeature(feature, path);
|
|
263
|
+
workType = fj?.workType || null;
|
|
264
|
+
status = fj?.status || 'unknown';
|
|
265
|
+
phase = derivePhaseForFeature(feature, path);
|
|
266
|
+
pendingGates = getPendingGates(feature, path);
|
|
267
|
+
}
|
|
77
268
|
} catch { /* field-level fail-open */ }
|
|
78
269
|
|
|
79
270
|
const featureDir = join(path, '.morph', 'features', feature);
|
|
@@ -87,13 +278,19 @@ function buildEntry(feature, { location, path, branch, root, allocations, blockS
|
|
|
87
278
|
location,
|
|
88
279
|
path,
|
|
89
280
|
branch,
|
|
90
|
-
tasks: taskCounts(featureDir),
|
|
281
|
+
tasks: taskCounts(featureDir, { root, feature, featureBranch }),
|
|
91
282
|
env: alloc && Number.isInteger(alloc.base)
|
|
92
283
|
? { base: alloc.base, range: [alloc.base, alloc.base + blockSize - 1] }
|
|
93
284
|
: null,
|
|
94
285
|
handoff: handoffInfo(featureDir),
|
|
95
286
|
lastCommit: location === 'worktree' && branch ? lastCommitRelative(root, branch) : null,
|
|
96
287
|
dirty: location === 'worktree' ? dirtyCount(path) : null,
|
|
288
|
+
remote: buildRemote(feature, branch, heads, prs),
|
|
289
|
+
// Advisory ownership (B1): which session is on this feature right now.
|
|
290
|
+
// `live` is what matters for the operator — a stale claim is reported as
|
|
291
|
+
// stale rather than hidden, so "nobody is here" and "somebody died here"
|
|
292
|
+
// stay distinguishable.
|
|
293
|
+
claim: claims?.[feature] || null,
|
|
97
294
|
};
|
|
98
295
|
}
|
|
99
296
|
|
|
@@ -110,15 +307,24 @@ export function scanFleet(cwd = process.cwd()) {
|
|
|
110
307
|
const root = resolvePrimaryRoot(cwd);
|
|
111
308
|
|
|
112
309
|
let allocations = {};
|
|
310
|
+
const claims = {};
|
|
113
311
|
try {
|
|
114
312
|
const common = execSync('git rev-parse --git-common-dir', { cwd: root, stdio: 'pipe' }).toString().trim();
|
|
115
313
|
if (common) {
|
|
116
|
-
const
|
|
117
|
-
|
|
314
|
+
const gitCommonDir = resolve(root, common);
|
|
315
|
+
try {
|
|
316
|
+
const reg = JSON.parse(readFileSync(registryPath(gitCommonDir), 'utf8'));
|
|
317
|
+
if (reg && typeof reg.allocations === 'object' && reg.allocations !== null) allocations = reg.allocations;
|
|
318
|
+
} catch { /* no env registry yet — env stays null per entry */ }
|
|
319
|
+
for (const c of listClaims(gitCommonDir)) claims[c.feature] = c;
|
|
118
320
|
}
|
|
119
|
-
} catch { /* no
|
|
321
|
+
} catch { /* no common dir — env and claim both stay null per entry */ }
|
|
120
322
|
const { blockSize } = resolveFleetConfig(root);
|
|
121
323
|
|
|
324
|
+
// Remote awareness, resolved ONCE for the whole scan (see remoteHeads/remotePrs).
|
|
325
|
+
const heads = remoteHeads(root);
|
|
326
|
+
const prs = heads ? remotePrs(root) : null;
|
|
327
|
+
|
|
122
328
|
const entries = [];
|
|
123
329
|
const inWorktree = new Set();
|
|
124
330
|
|
|
@@ -133,6 +339,9 @@ export function scanFleet(cwd = process.cwd()) {
|
|
|
133
339
|
root,
|
|
134
340
|
allocations,
|
|
135
341
|
blockSize,
|
|
342
|
+
heads,
|
|
343
|
+
prs,
|
|
344
|
+
claims,
|
|
136
345
|
}));
|
|
137
346
|
}
|
|
138
347
|
|
|
@@ -146,7 +355,7 @@ export function scanFleet(cwd = process.cwd()) {
|
|
|
146
355
|
.filter((name) => !inWorktree.has(name) && existsSync(join(featuresDir, name, 'feature.json')));
|
|
147
356
|
for (const name of names) {
|
|
148
357
|
entries.push(buildEntry(name, {
|
|
149
|
-
location: 'root', path: root, branch: null, root, allocations, blockSize,
|
|
358
|
+
location: 'root', path: root, branch: null, root, allocations, blockSize, heads, prs, claims,
|
|
150
359
|
}));
|
|
151
360
|
}
|
|
152
361
|
}
|