@polymorphism-tech/morph-spec 8.31.0 → 8.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CLAUDE.md +5 -0
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +19 -1
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +2 -1
  8. package/framework/MORPH.md +2 -2
  9. package/framework/docs/CLI.md +68 -63
  10. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  11. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  12. package/framework/skills/core/morph-apply/SKILL.md +3 -2
  13. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  14. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  15. package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
  16. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  17. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  18. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  19. package/framework/skills/core/morph-review/SKILL.md +2 -2
  20. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  21. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  22. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  23. package/package.json +1 -1
  24. package/src/commands/project/branch.js +170 -0
  25. package/src/commands/project/doctor.js +137 -9
  26. package/src/commands/project/fleet.js +10 -3
  27. package/src/commands/project/worktree.js +73 -50
  28. package/src/commands/state/finish.js +41 -26
  29. package/src/commands/state/list.js +79 -6
  30. package/src/core/state/lifecycle-audit.js +127 -0
  31. package/src/lib/git/branch-classifier.js +177 -0
  32. package/src/lib/git/gh.js +30 -0
  33. package/src/lib/git/pending-prs.js +89 -0
  34. package/src/lib/git/pr-index.js +70 -0
  35. package/src/lib/worktree/fleet-scanner.js +51 -21
  36. package/src/lib/worktree/ignored-triage.js +197 -0
  37. package/src/lib/worktree/worktree-audit.js +200 -35
@@ -0,0 +1,89 @@
1
+ /**
2
+ * pending-prs.js — as features que existem SÓ como PR aberto.
3
+ *
4
+ * `finish --pr` deixa o root primário sem vestígio da feature: o
5
+ * `shedRootFeatureScope` apaga `.morph/features/{f}` e o archive foi commitado
6
+ * na branch `morph/{f}`, chegando na default apenas no merge. Entre abrir e
7
+ * mergear, portanto, nenhuma superfície local sabe que aquela branch tem dono —
8
+ * foi assim que uma branch com PR #51 aberto quase foi varrida.
9
+ *
10
+ * Este módulo devolve o dono. Não há estado persistido a consultar, e é de
11
+ * propósito: o PR aberto no GitHub É o registro, a branch é sempre
12
+ * `morph/{feature}`, e o `pr-index.js` já é o leitor único desse fato. Lembrar
13
+ * o número numa segunda cópia só criaria como divergir dele.
14
+ *
15
+ * `null` (do índice ou daqui) significa DESCONHECIDO e jamais "não há
16
+ * pendência" — a mesma regra que torna seguros os caminhos destrutivos que
17
+ * leem o índice.
18
+ */
19
+
20
+ import { existsSync } from 'fs';
21
+ import { join } from 'path';
22
+ import { buildPrIndex } from './pr-index.js';
23
+
24
+ /** As branches que o framework cria por feature. */
25
+ export const MORPH_BRANCH_RE = /^morph\/(.+)$/;
26
+
27
+ const DAY_MS = 86400000;
28
+
29
+ /**
30
+ * O PR da branch canônica de uma feature.
31
+ *
32
+ * @param {Map<string,object>|null} index - de buildPrIndex
33
+ * @param {string} feature
34
+ * @returns {{number:number,state:string,mergedAt:string|null,url:string|null,createdAt:string|null}|null}
35
+ * null = desconhecido OU sem PR; quem precisa distinguir os dois olha o índice.
36
+ */
37
+ export function prForFeature(index, feature) {
38
+ if (!index) return null;
39
+ return index.get(`morph/${feature}`) || null;
40
+ }
41
+
42
+ /** A feature tem pasta (ativa ou arquivada) nesta árvore? */
43
+ function hasLocalFolder(root, feature) {
44
+ return existsSync(join(root, '.morph', 'features', feature))
45
+ || existsSync(join(root, '.morph', 'archive', feature));
46
+ }
47
+
48
+ /**
49
+ * As features aguardando merge: PR OPEN numa branch `morph/*` cujo slug não
50
+ * tem pasta nesta árvore.
51
+ *
52
+ * @param {string} root - a raiz a inspecionar (e de onde o `gh` roda)
53
+ * @param {{ index?: Map<string,object>|null, now?: Date }} [opts]
54
+ * `index` injeta um índice já construído (o `list`/`fleet` constroem um por
55
+ * processo e reaproveitam; os testes passam um sintético). Ausente = constrói.
56
+ * @returns {Array<{feature:string,branch:string,number:number,state:string,url:string|null,createdAt:string|null,ageDays:number|null}>|null}
57
+ * null = DESCONHECIDO. Nunca lista vazia por falta de informação.
58
+ */
59
+ export function pendingPrFeatures(root, opts = {}) {
60
+ const index = opts.index !== undefined ? opts.index : buildPrIndex(root);
61
+ if (!index) return null;
62
+
63
+ const now = opts.now instanceof Date ? opts.now : new Date();
64
+ const out = [];
65
+ for (const [branch, pr] of index) {
66
+ const m = branch.match(MORPH_BRANCH_RE);
67
+ if (!m) continue;
68
+ if (pr.state !== 'OPEN') continue;
69
+ const feature = m[1];
70
+ if (hasLocalFolder(root, feature)) continue;
71
+
72
+ let ageDays = null;
73
+ if (pr.createdAt) {
74
+ const t = Date.parse(pr.createdAt);
75
+ if (!Number.isNaN(t)) ageDays = Math.floor((now.getTime() - t) / DAY_MS);
76
+ }
77
+ out.push({
78
+ feature,
79
+ branch,
80
+ number: pr.number,
81
+ state: pr.state,
82
+ url: pr.url ?? null,
83
+ createdAt: pr.createdAt ?? null,
84
+ ageDays,
85
+ });
86
+ }
87
+ out.sort((a, b) => a.feature.localeCompare(b.feature));
88
+ return out;
89
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * pr-index.js — one `gh pr list` per process, shared by every caller that needs
3
+ * to answer "does a branch have a PR, and in what state".
4
+ *
5
+ * Extracted from `fleet-scanner.js` because three independent leaders now ask
6
+ * the SAME question and must get the SAME answer:
7
+ *
8
+ * • `morph-spec fleet` — the PR column
9
+ * • `morph-spec branch prune` — a branch with an OPEN PR is never deleted,
10
+ * and a MERGED one is deletable even when git
11
+ * cannot see the merge (squash-merge)
12
+ * • `morph-spec doctor --worktrees` — a worktree whose branch carries an open
13
+ * PR must NOT be offered up for removal
14
+ *
15
+ * Fail-open by construction: no `gh`, not authenticated, not GitHub, or a
16
+ * timeout all yield `null`. `null` means "unknown", NEVER "no PR" — every
17
+ * caller must treat the two differently, because the destructive paths
18
+ * (prune, remove) are only safe when the answer is a positive fact.
19
+ */
20
+
21
+ import { execFileSync } from 'child_process';
22
+ import { resolveGh } from './gh.js';
23
+
24
+ /**
25
+ * Build the branch → PR index for a repository.
26
+ *
27
+ * Gated on the origin URL actually pointing at github.com: `gh` against any
28
+ * other host is a guaranteed error, and paying its start-up cost to learn that
29
+ * on every call is pure latency.
30
+ *
31
+ * `--state all` on purpose: a MERGED PR is exactly the evidence a squash-merge
32
+ * erases from git history, and it is the whole reason `branch prune` exists.
33
+ * First wins — `gh` lists newest-first, so an OPEN PR shadows an older closed
34
+ * one on the same branch.
35
+ *
36
+ * @param {string} root - repository root to run `gh` from
37
+ * @param {{ limit?: number, timeout?: number }} [opts]
38
+ * @returns {Map<string,{number:number,state:string,mergedAt:string|null,url:string|null,createdAt:string|null}>|null}
39
+ * null = DESCONHECIDO (sem gh / não é GitHub / falhou), não "sem PRs"
40
+ */
41
+ export function buildPrIndex(root, opts = {}) {
42
+ const limit = String(opts.limit ?? 200);
43
+ const timeout = opts.timeout ?? 8000;
44
+ try {
45
+ const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: root, stdio: 'pipe' }).toString();
46
+ if (!/github\.com/i.test(url)) return null;
47
+ const { bin, prefix } = resolveGh();
48
+ const out = execFileSync(
49
+ bin,
50
+ [...prefix, 'pr', 'list', '--state', 'all', '--limit', limit,
51
+ '--json', 'number,state,headRefName,mergedAt,url,createdAt'],
52
+ { cwd: root, stdio: 'pipe', timeout },
53
+ ).toString();
54
+ const map = new Map();
55
+ for (const pr of JSON.parse(out)) {
56
+ if (pr?.headRefName && !map.has(pr.headRefName)) {
57
+ map.set(pr.headRefName, {
58
+ number: pr.number,
59
+ state: pr.state,
60
+ mergedAt: pr.mergedAt ?? null,
61
+ url: pr.url ?? null,
62
+ createdAt: pr.createdAt ?? null,
63
+ });
64
+ }
65
+ }
66
+ return map;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
@@ -28,6 +28,8 @@ import {
28
28
  pendingGatesFrom,
29
29
  } from '../../../framework/hooks/shared/state-reader.js';
30
30
  import { listClaims } from '../../../framework/hooks/shared/claim-registry.js';
31
+ import { buildPrIndex } from '../git/pr-index.js';
32
+ import { pendingPrFeatures } from '../git/pending-prs.js';
31
33
 
32
34
  function countFrom(raw) {
33
35
  const data = JSON.parse(raw);
@@ -133,32 +135,18 @@ function remoteHeads(root) {
133
135
  /**
134
136
  * One `gh pr list` for the whole scan → Map<headRefName, {number, state}>.
135
137
  *
136
- * Gated on the origin URL actually pointing at github.com — `gh` against any
137
- * other host is a guaranteed error, and paying its start-up cost to learn that
138
- * on every `fleet` call is pure latency. Fail-open: no `gh`, not authenticated,
139
- * or a timeout all yield `null`, which just leaves the PR half of the column
140
- * empty while `pushed` still works.
138
+ * Delegates to the shared `buildPrIndex` so `fleet`, `branch prune` and
139
+ * `doctor --worktrees` cannot answer "does this branch have a PR" differently
140
+ * — the destructive two read it to decide what NOT to delete.
141
+ *
142
+ * Fail-open: `null` (no `gh`, not authenticated, not GitHub, timeout) just
143
+ * leaves the PR half of the column empty while `pushed` still works.
141
144
  *
142
145
  * @param {string} root
143
146
  * @returns {Map<string,{number:number,state:string}>|null}
144
147
  */
145
148
  function remotePrs(root) {
146
- try {
147
- const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: root, stdio: 'pipe' }).toString();
148
- if (!/github\.com/i.test(url)) return null;
149
- const out = execFileSync('gh', ['pr', 'list', '--state', 'all', '--limit', '100', '--json', 'number,state,headRefName'], {
150
- cwd: root, stdio: 'pipe', timeout: 8000,
151
- }).toString();
152
- const map = new Map();
153
- for (const pr of JSON.parse(out)) {
154
- // First wins: `gh` lists newest-first, so an OPEN PR shadows an older
155
- // closed one on the same branch.
156
- if (pr?.headRefName && !map.has(pr.headRefName)) map.set(pr.headRefName, { number: pr.number, state: pr.state });
157
- }
158
- return map;
159
- } catch {
160
- return null;
161
- }
149
+ return buildPrIndex(root, { limit: 100 });
162
150
  }
163
151
 
164
152
  /**
@@ -297,5 +285,47 @@ export function scanFleet(cwd = process.cwd()) {
297
285
  }
298
286
  } catch { /* fail-open */ }
299
287
 
288
+ // Features that exist ONLY as an open PR: no registered worktree, no
289
+ // `.morph/features/*` folder in this tree. This is exactly what
290
+ // `finish --pr` leaves behind — and without this pass `fleet` would say
291
+ // "nothing in flight" about work that is under review on GitHub.
292
+ //
293
+ // `prs` was attempted above whenever `heads` is truthy (`remotePrs` runs —
294
+ // see `const prs = heads ? remotePrs(root) : null;` — regardless of
295
+ // whether that `gh` call itself succeeded). Gate on the SAME condition
296
+ // `remotePrs` used, not on `prs !== null`: `remotePrs`/`buildPrIndex` can
297
+ // fail (timeout, not authenticated, malformed JSON) and legitimately
298
+ // return `null` even after really invoking `gh`. Passing that `null`
299
+ // through lets `pendingPrFeatures` short-circuit on its own documented
300
+ // contract (explicit `null` index = known unknown, never a fallback build)
301
+ // instead of retrying `gh` a second time in the same scan. Only when
302
+ // `remotePrs` was never attempted at all (`heads` falsy) does
303
+ // `pendingPrFeatures` get no index, so it makes the first (and only) `gh`
304
+ // call itself.
305
+ try {
306
+ const seen = new Set(entries.map((e) => e.feature));
307
+ const pendOpts = heads ? { index: prs } : {};
308
+ for (const p of pendingPrFeatures(root, pendOpts) || []) {
309
+ if (seen.has(p.feature)) continue;
310
+ entries.push({
311
+ feature: p.feature,
312
+ workType: null,
313
+ phase: 'aguardando-merge',
314
+ status: 'in_progress',
315
+ pendingGates: [],
316
+ location: 'pr-pending',
317
+ path: null,
318
+ branch: p.branch,
319
+ tasks: null,
320
+ env: null,
321
+ handoff: { exists: false, generatedAt: null },
322
+ lastCommit: null,
323
+ dirty: null,
324
+ remote: { branch: p.branch, pushed: true, sha: null, pr: { number: p.number, state: p.state } },
325
+ claim: null,
326
+ });
327
+ }
328
+ } catch { /* fail-open — a pendency not listed must never take down the scan */ }
329
+
300
330
  return { root, entries };
301
331
  }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * ignored-triage.js — decide, per gitignored leftover, whether removing a
3
+ * worktree would DESTROY something or merely delete a copy.
4
+ *
5
+ * WHY THIS EXISTS: `git worktree remove` deletes gitignored files outright and
6
+ * the clean-check that gates it (`git status --porcelain`) omits them by
7
+ * design, so a user's `.env` dies silently while the command reports success.
8
+ * The first fix for that refused the removal whenever ANY unrecognised
9
+ * gitignored path was present — correct, but blind: in the observed session a
10
+ * worktree carried `.env` and `.env.e2e` that were BYTE-IDENTICAL to the
11
+ * primary root's, alongside a `keys/key-*.xml` that existed nowhere else. The
12
+ * refusal treated them alike, and the only escape it offered
13
+ * (`--discard-ignored`) destroyed the unique one together with the replicas.
14
+ *
15
+ * The triage below replaces "recognised vs not" with the question that actually
16
+ * decides whether bytes are lost:
17
+ *
18
+ * REPLICABLE — the primary root has the same relative path with identical
19
+ * content. Deleting the worktree's copy loses nothing; the
20
+ * original stays where it always was.
21
+ * DISPOSABLE — a well-known build artifact (`bin/`, `obj/`, `node_modules/`,
22
+ * `__pycache__/`, `test-results/`, `.auth/`, `*.tsbuildinfo`).
23
+ * Reproducible from source, which is a STRONGER guarantee than
24
+ * backed up.
25
+ * EXCLUSIVE — everything else: no counterpart in the root, or a counterpart
26
+ * whose content differs. These are the only bytes at stake, and
27
+ * the caller copies them to `finish-backup` before proceeding.
28
+ *
29
+ * Fail-CLOSED on every uncertainty: an unreadable file, a hash that cannot be
30
+ * computed, a directory too large to compare, a counterpart that is a link
31
+ * rather than a real file — all classify as EXCLUSIVE. Being wrong in that
32
+ * direction costs a backup copy; being wrong the other way costs the file.
33
+ */
34
+
35
+ import { createHash } from 'crypto';
36
+ import { existsSync, lstatSync, readdirSync, readFileSync } from 'fs';
37
+ import { join } from 'path';
38
+
39
+ /**
40
+ * Well-known, regenerable build artifacts. Deliberately narrow — do NOT add
41
+ * speculative patterns (`dist/`, `.next/`) that a project might legitimately
42
+ * hand-edit; anything unlisted still reaches the EXCLUSIVE bucket, where it is
43
+ * backed up rather than assumed disposable.
44
+ *
45
+ * `.auth/` (Playwright storage state) belongs here for a reason worth stating:
46
+ * it holds a captured session, so it LOOKS like precious local state — but it
47
+ * is regenerated by the auth setup project on the next run, and it expires on
48
+ * its own. Keeping it out of the backup avoids copying credentials around.
49
+ */
50
+ const DISPOSABLE_DIR_NAMES = new Set([
51
+ 'bin', 'obj', 'node_modules', '__pycache__', 'test-results', '.auth',
52
+ ]);
53
+ const DISPOSABLE_FILE_RE = /\.tsbuildinfo$/;
54
+
55
+ /**
56
+ * Comparing a directory recursively is only worth doing while it stays small:
57
+ * the point is to spare `.env`-sized replicas from a needless refusal, not to
58
+ * checksum a data set. Past either cap the answer becomes EXCLUSIVE (the safe
59
+ * direction), and the caller backs it up.
60
+ */
61
+ const REPLICABLE_MAX_FILES = 500;
62
+ const REPLICABLE_MAX_BYTES = 8 * 1024 * 1024;
63
+
64
+ /**
65
+ * @param {string} p - forward-slash relative path
66
+ * @returns {boolean}
67
+ */
68
+ export function isDisposableBuildArtifact(p) {
69
+ if (DISPOSABLE_FILE_RE.test(p)) return true;
70
+ return p.split('/').filter(Boolean).some((seg) => DISPOSABLE_DIR_NAMES.has(seg));
71
+ }
72
+
73
+ function sha256OfFile(path) {
74
+ try {
75
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Real file (not a link, not a directory)? A reparse point must never be read
83
+ * THROUGH — following one is the exact failure mode the whole teardown path
84
+ * exists to prevent — so a link on either side makes the pair incomparable.
85
+ */
86
+ function isRealFile(path) {
87
+ try {
88
+ return lstatSync(path).isFile();
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ function isRealDir(path) {
95
+ try {
96
+ return lstatSync(path).isDirectory();
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Every real file under `dir`, relative to it. Returns null when the walk hits
104
+ * a link, an unreadable entry, or either cap — "cannot enumerate" and "too big
105
+ * to compare" both mean "do not claim this is replicable".
106
+ */
107
+ function walkFiles(dir, budget) {
108
+ const out = [];
109
+ const stack = [''];
110
+ while (stack.length) {
111
+ const rel = stack.pop();
112
+ let entries;
113
+ try {
114
+ entries = readdirSync(join(dir, rel), { withFileTypes: true });
115
+ } catch {
116
+ return null;
117
+ }
118
+ for (const e of entries) {
119
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
120
+ const childAbs = join(dir, childRel);
121
+ if (e.isSymbolicLink()) return null; // never compare through a link
122
+ if (e.isDirectory()) { stack.push(childRel); continue; }
123
+ if (!e.isFile()) return null; // socket/fifo/unknown — incomparable
124
+ let size;
125
+ try { size = lstatSync(childAbs).size; } catch { return null; }
126
+ budget.files += 1;
127
+ budget.bytes += size;
128
+ if (budget.files > REPLICABLE_MAX_FILES || budget.bytes > REPLICABLE_MAX_BYTES) return null;
129
+ out.push(childRel);
130
+ }
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * Does `root/rel` hold exactly the same content as `worktree/rel`?
137
+ *
138
+ * Files compare by SHA-256; directories compare by having the SAME set of
139
+ * files, each with an identical hash — a root copy that is merely a SUPERSET
140
+ * is not a replica of the worktree's, because the worktree's own extra files
141
+ * would still be lost.
142
+ *
143
+ * @returns {boolean} false on ANY uncertainty
144
+ */
145
+ export function isReplicatedInRoot(worktreePath, rootPath, rel) {
146
+ const src = join(worktreePath, rel);
147
+ const dst = join(rootPath, rel);
148
+ if (!existsSync(src) || !existsSync(dst)) return false;
149
+
150
+ if (isRealFile(src)) {
151
+ if (!isRealFile(dst)) return false;
152
+ const a = sha256OfFile(src);
153
+ const b = sha256OfFile(dst);
154
+ return a !== null && a === b;
155
+ }
156
+
157
+ if (isRealDir(src)) {
158
+ if (!isRealDir(dst)) return false;
159
+ const srcFiles = walkFiles(src, { files: 0, bytes: 0 });
160
+ if (srcFiles === null) return false;
161
+ const dstFiles = walkFiles(dst, { files: 0, bytes: 0 });
162
+ if (dstFiles === null) return false;
163
+ if (srcFiles.length !== dstFiles.length) return false;
164
+ const dstSet = new Set(dstFiles);
165
+ for (const f of srcFiles) {
166
+ if (!dstSet.has(f)) return false;
167
+ const a = sha256OfFile(join(src, f));
168
+ const b = sha256OfFile(join(dst, f));
169
+ if (a === null || a !== b) return false;
170
+ }
171
+ return true;
172
+ }
173
+
174
+ return false; // a link, or something that is neither — incomparable
175
+ }
176
+
177
+ /**
178
+ * Split gitignored worktree paths into the three buckets above.
179
+ *
180
+ * ORDER IS DELIBERATE: disposable is checked FIRST, so a `bin/` that happens to
181
+ * be byte-identical to the root's is discarded rather than hashed, and a
182
+ * `node_modules` never enters the comparison budget at all.
183
+ *
184
+ * @param {{ worktreePath: string, rootPath: string, paths: string[] }} args
185
+ * @returns {{ replicable: string[], disposable: string[], exclusive: string[] }}
186
+ */
187
+ export function triageIgnoredPaths({ worktreePath, rootPath, paths }) {
188
+ const replicable = [];
189
+ const disposable = [];
190
+ const exclusive = [];
191
+ for (const rel of paths) {
192
+ if (isDisposableBuildArtifact(rel)) { disposable.push(rel); continue; }
193
+ if (isReplicatedInRoot(worktreePath, rootPath, rel)) { replicable.push(rel); continue; }
194
+ exclusive.push(rel);
195
+ }
196
+ return { replicable, disposable, exclusive };
197
+ }