@polymorphism-tech/morph-spec 8.42.0 → 8.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +15 -5
- package/README.md +2 -2
- package/bin/morph-spec.js +48 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +5 -4
- package/framework/MORPH.md +10 -9
- package/framework/agents.json +1 -1
- package/framework/docs/CLI.md +22 -10
- package/framework/evals/proof-rubrics/mutation-proof.md +43 -13
- package/framework/hooks/claude-code/core/heavy-gate.js +72 -0
- package/framework/hooks/claude-code/core/heavy-release.js +34 -0
- package/framework/hooks/claude-code/core/worktree-context.js +21 -20
- package/framework/hooks/claude-code/core/worktree-create.js +4 -4
- package/framework/hooks/claude-code/core/worktree-env-inject.js +1 -4
- package/framework/hooks/shared/agent-reap.js +89 -0
- package/framework/hooks/shared/e2e-check.js +3 -1
- package/framework/hooks/shared/graph-refresh.js +1 -9
- package/framework/hooks/shared/heavy-command.js +128 -0
- package/framework/hooks/shared/heavy-config.js +65 -0
- package/framework/hooks/shared/heavy-holders.js +85 -0
- package/framework/hooks/shared/heavy-slots.js +207 -0
- package/framework/hooks/shared/process-alive.js +8 -0
- package/framework/hooks/shared/worktree-root.js +93 -0
- package/framework/rules/ai-agents-standards.md +1 -0
- package/framework/schemas/config.schema.json +80 -0
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +8 -3
- 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 +9 -4
- package/framework/skills/core/morph-machine/SKILL.md +46 -0
- package/framework/skills/core/morph-media/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 +45 -16
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/standards/PROVENANCE.json +10 -10
- package/framework/standards/STANDARDS.json +45 -1
- package/framework/standards/ai-agents/tool-adapters.md +226 -0
- package/framework/standards/frontend/design-system/ai-video-generation.md +2 -0
- package/framework/standards/frontend/scroll-driven/frame-scrub.md +11 -1
- package/framework/standards/infrastructure/docker/local-compose-isolation.md +11 -1
- package/package.json +1 -1
- package/src/commands/e2e/e2e.js +27 -8
- package/src/commands/mcp/mcp-setup.js +10 -9
- package/src/commands/project/doctor-procs.js +162 -0
- package/src/commands/project/doctor.js +42 -4
- package/src/commands/project/heavy.js +61 -0
- package/src/commands/project/init.js +1 -1
- package/src/commands/project/update.js +4 -0
- package/src/commands/project/worktree-reap.js +34 -0
- package/src/commands/project/worktree.js +112 -43
- package/src/commands/state/approve.js +2 -1
- package/src/commands/state/finish.js +4 -0
- package/src/commands/state/gate-check.js +2 -1
- package/src/commands/validation/mutate.js +200 -0
- package/src/commands/validation/verify.js +39 -22
- package/src/dashboard/scanner/features-scanner.js +4 -4
- package/src/dashboard/scanner/project-api.js +4 -4
- package/src/lib/detectors/claude-config-detector.js +30 -1
- package/src/lib/e2e/compose-env.js +5 -2
- package/src/lib/git/evidence-staleness.js +39 -0
- package/src/lib/heavy/cleanup.js +70 -0
- package/src/lib/heavy/gate.js +96 -0
- package/src/lib/installers/mcp-installer.js +15 -2
- package/src/lib/mutation/adapters.js +90 -0
- package/src/lib/mutation/commands.js +60 -0
- package/src/lib/mutation/manifest.js +138 -0
- package/src/lib/mutation/plan.js +36 -0
- package/src/lib/mutation/report.js +66 -0
- package/src/lib/mutation/runner.js +351 -0
- package/src/lib/mutation/schemata.js +60 -0
- package/src/lib/mutation/snapshot.js +69 -0
- package/src/lib/mutation/subset.js +75 -0
- package/src/lib/mutation/trx.js +51 -0
- package/src/lib/procs/classify.js +91 -0
- package/src/lib/procs/list.js +124 -0
- package/src/lib/procs/mcp-config.js +103 -0
- package/src/lib/procs/prune.js +25 -0
- package/src/lib/procs/wslconfig.js +70 -0
- package/src/lib/projects/registry.js +6 -2
- package/src/lib/risk-detector.js +3 -3
- package/src/lib/tasks/eval-node.js +4 -34
- package/src/lib/tasks/linux-runner.js +30 -3
- package/src/lib/tasks/test-runner.js +1 -1
- package/src/lib/validators/contracts/contract-compliance-validator.js +4 -42
- package/src/lib/validators/verify-runner.js +26 -8
- package/src/lib/worktree/agent-reaper.js +247 -0
- package/src/lib/worktree/dirs.js +3 -32
- package/src/lib/worktree/env-allocator.js +35 -5
- package/src/lib/worktree/vscode-settings.js +113 -0
- package/src/lib/worktree/worktree-audit.js +49 -5
- package/src/lib/worktree/worktree-include.js +46 -0
- package/src/scripts/setup-infra.js +4 -0
- package/src/utils/hooks-installer.js +11 -4
|
@@ -58,6 +58,21 @@ import { dropLeftoverIgnore, withoutLeftoverIgnore } from '../../../framework/ho
|
|
|
58
58
|
// rationale documented there); re-exported here so existing importers and the
|
|
59
59
|
// dependency-matrix row keep pointing at this file.
|
|
60
60
|
import { WORKTREES_DIR, LEGACY_WORKTREES_DIR, WORKTREE_DIR_NAMES } from '../../lib/worktree/dirs.js';
|
|
61
|
+
import { applyEditorSettings } from '../../lib/worktree/vscode-settings.js';
|
|
62
|
+
import { copyWorktreeIncludes } from '../../lib/worktree/worktree-include.js';
|
|
63
|
+
import { containerDirs, externalContainer, newWorktreesDir } from '../../../framework/hooks/shared/worktree-root.js';
|
|
64
|
+
|
|
65
|
+
/** Fail-open: the editor file is a convenience, never a reason a worktree is not created. */
|
|
66
|
+
function rootAndEditorReport(root) {
|
|
67
|
+
const { warning } = externalContainer(root);
|
|
68
|
+
const worktreeRoot = warning ? { worktreeRootWarning: warning } : {};
|
|
69
|
+
try {
|
|
70
|
+
const report = applyEditorSettings(root);
|
|
71
|
+
return report.status === 'off' ? worktreeRoot : { ...worktreeRoot, editorSettings: report };
|
|
72
|
+
} catch {
|
|
73
|
+
return worktreeRoot;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
61
76
|
export { WORKTREES_DIR, LEGACY_WORKTREES_DIR, WORKTREE_DIR_NAMES };
|
|
62
77
|
|
|
63
78
|
// ── Pure helpers (exported for tests) ──────────────────────────────────────
|
|
@@ -146,7 +161,8 @@ export function worktreeExists(worktreePath, cwd = process.cwd()) {
|
|
|
146
161
|
export function buildWorktreePath(feature, cwd = process.cwd(), options = {}) {
|
|
147
162
|
const suffix = options.fresh ? `-${getDateSuffix()}` : '';
|
|
148
163
|
const normalizedCwd = cwd.replace(/\\/g, '/');
|
|
149
|
-
|
|
164
|
+
const container = externalContainer(normalizedCwd).dir ?? `${normalizedCwd}/${WORKTREES_DIR}`;
|
|
165
|
+
return `${container}/${feature}${suffix}`;
|
|
150
166
|
}
|
|
151
167
|
|
|
152
168
|
/**
|
|
@@ -514,7 +530,7 @@ export async function runWorktreeSetup(feature, options = {}) {
|
|
|
514
530
|
}
|
|
515
531
|
|
|
516
532
|
// Create worktree directory parent if needed
|
|
517
|
-
const parentDir =
|
|
533
|
+
const parentDir = newWorktreesDir(cwd);
|
|
518
534
|
if (!existsSync(parentDir)) {
|
|
519
535
|
mkdirSync(parentDir, { recursive: true });
|
|
520
536
|
}
|
|
@@ -524,6 +540,7 @@ export async function runWorktreeSetup(feature, options = {}) {
|
|
|
524
540
|
|
|
525
541
|
// Set up infrastructure junctions (node_modules, .claude, .morph/framework).
|
|
526
542
|
setupInfraLinks(worktreePath, cwd);
|
|
543
|
+
const included = copyWorktreeIncludes(worktreePath, cwd);
|
|
527
544
|
|
|
528
545
|
// Fleet env: allocate this feature's deterministic port block and write the
|
|
529
546
|
// worktree-local manifest (.morph/worktree.env). Fail-open — a worktree
|
|
@@ -573,9 +590,11 @@ export async function runWorktreeSetup(feature, options = {}) {
|
|
|
573
590
|
branch,
|
|
574
591
|
branchedFrom,
|
|
575
592
|
feature,
|
|
593
|
+
...(included.length ? { worktreeInclude: included } : {}),
|
|
576
594
|
materialized: materialized.materialized,
|
|
577
595
|
...(envBlock ? { env: { base: envBlock.base, range: envBlock.range } } : {}),
|
|
578
596
|
...(graph.seeded ? { graph: 'seeded' } : {}),
|
|
597
|
+
...rootAndEditorReport(resolvePrimaryRoot(cwd)),
|
|
579
598
|
nextStep: `Abra o Claude Code em ${worktreePath} para trabalhar nesta feature`,
|
|
580
599
|
};
|
|
581
600
|
} catch (err) {
|
|
@@ -699,13 +718,14 @@ export async function runWorktreeProvision(name, options = {}) {
|
|
|
699
718
|
}
|
|
700
719
|
}
|
|
701
720
|
|
|
702
|
-
const parentDir =
|
|
721
|
+
const parentDir = newWorktreesDir(root);
|
|
703
722
|
if (!existsSync(parentDir)) mkdirSync(parentDir, { recursive: true });
|
|
704
723
|
|
|
705
724
|
execFileSync('git', ['worktree', 'add', worktreePath, branch], { cwd: root, stdio: 'pipe' });
|
|
706
725
|
|
|
707
726
|
setupInfraLinks(worktreePath, root);
|
|
708
727
|
const envFiles = copyRootEnvFiles(worktreePath, root);
|
|
728
|
+
const included = copyWorktreeIncludes(worktreePath, root);
|
|
709
729
|
// A tree born from `morph/<f>` works FOR that feature: MORPH_FEATURE says
|
|
710
730
|
// so, while the port block stays the task's own (`task/<name>`).
|
|
711
731
|
const forFeature = String(options.base || '').match(/^morph\/(.+)$/)?.[1] ?? null;
|
|
@@ -724,9 +744,11 @@ export async function runWorktreeProvision(name, options = {}) {
|
|
|
724
744
|
name,
|
|
725
745
|
...(forFeature ? { feature: forFeature } : {}),
|
|
726
746
|
...(envFiles.length ? { envFiles } : {}),
|
|
747
|
+
...(included.length ? { worktreeInclude: included } : {}),
|
|
727
748
|
materialized: materialized.materialized,
|
|
728
749
|
...(envBlock ? { env: { base: envBlock.base, range: envBlock.range } } : {}),
|
|
729
750
|
...(graph.seeded ? { graph: 'seeded' } : {}),
|
|
751
|
+
...rootAndEditorReport(root),
|
|
730
752
|
};
|
|
731
753
|
} catch (err) {
|
|
732
754
|
return { ok: true, exitCode: 0, created: false, error: (err.message || 'unknown').slice(0, 200), name };
|
|
@@ -860,19 +882,21 @@ export async function allocateWorktreeEnv(worktreePath, root, envKey, { morphFea
|
|
|
860
882
|
} catch { morphFeature = null; }
|
|
861
883
|
}
|
|
862
884
|
try {
|
|
863
|
-
const { allocateEnvBlock, resolveFleetConfig, buildEnvFileContent } =
|
|
885
|
+
const { allocateEnvBlock, resolveFleetConfig, buildEnvFileContent, resolvePortAliases } =
|
|
864
886
|
await import('../../lib/worktree/env-allocator.js');
|
|
865
887
|
const common = execSync('git rev-parse --git-common-dir', { cwd: root, stdio: 'pipe' }).toString().trim();
|
|
866
888
|
if (!common) return null;
|
|
867
889
|
const gitCommonDir = resolve(root, common);
|
|
868
890
|
const block = allocateEnvBlock(envKey, { gitCommonDir, ...resolveFleetConfig(root) });
|
|
869
891
|
let stack = '';
|
|
892
|
+
let aliases = {};
|
|
870
893
|
try {
|
|
871
894
|
const cfg = JSON.parse(readFileSync(join(root, '.morph', 'config', 'config.json'), 'utf8').replace(/^/, ''));
|
|
872
895
|
stack = cfg?.project?.stack || '';
|
|
896
|
+
aliases = resolvePortAliases(cfg).aliases;
|
|
873
897
|
} catch { /* fail-open — stackless env still carries PORT */ }
|
|
874
898
|
mkdirSync(join(worktreePath, '.morph'), { recursive: true });
|
|
875
|
-
writeFileSync(envPath, buildEnvFileContent(envKey, block, stack, { morphFeature }), 'utf8');
|
|
899
|
+
writeFileSync(envPath, buildEnvFileContent(envKey, block, stack, { morphFeature, aliases }), 'utf8');
|
|
876
900
|
excludeWorktreePaths(root, [join('.morph', 'worktree.env')]);
|
|
877
901
|
return block;
|
|
878
902
|
} catch {
|
|
@@ -886,7 +910,7 @@ export async function allocateWorktreeEnv(worktreePath, root, envKey, { morphFea
|
|
|
886
910
|
* Parse `git worktree list --porcelain` into structured entries.
|
|
887
911
|
* Fail-open: returns `[]` on any git error (e.g. not a git repo).
|
|
888
912
|
* @param {string} cwd
|
|
889
|
-
* @returns {Array<{ path: string, branch: string|null, head: string|null }>}
|
|
913
|
+
* @returns {Array<{ path: string, branch: string|null, head: string|null, locked: boolean }>}
|
|
890
914
|
*/
|
|
891
915
|
export function parseWorktreeList(cwd) {
|
|
892
916
|
let output;
|
|
@@ -901,7 +925,9 @@ export function parseWorktreeList(cwd) {
|
|
|
901
925
|
for (const line of output.split('\n')) {
|
|
902
926
|
if (line.startsWith('worktree ')) {
|
|
903
927
|
if (current) entries.push(current);
|
|
904
|
-
current = { path: line.slice('worktree '.length).trim().replace(/\\/g, '/'), branch: null, head: null };
|
|
928
|
+
current = { path: line.slice('worktree '.length).trim().replace(/\\/g, '/'), branch: null, head: null, locked: false };
|
|
929
|
+
} else if (/^locked(\s|$)/.test(line) && current) {
|
|
930
|
+
current.locked = true;
|
|
905
931
|
} else if (line.startsWith('branch ') && current) {
|
|
906
932
|
current.branch = line.slice('branch '.length).trim();
|
|
907
933
|
} else if (line.startsWith('HEAD ') && current) {
|
|
@@ -937,8 +963,8 @@ export function findWorktreeForFeature(feature, root) {
|
|
|
937
963
|
|
|
938
964
|
let match = entries.find((e) => e.branch === refName);
|
|
939
965
|
if (!match) {
|
|
940
|
-
const
|
|
941
|
-
match = entries.find((e) => e.path
|
|
966
|
+
const guesses = containerDirs(root).map((dir) => `${dir}/${feature}`);
|
|
967
|
+
match = entries.find((e) => guesses.includes(e.path));
|
|
942
968
|
}
|
|
943
969
|
if (!match) return null;
|
|
944
970
|
|
|
@@ -1214,10 +1240,17 @@ export function primaryRootUnexplainedDirt(root, feature, opts = {}) {
|
|
|
1214
1240
|
}
|
|
1215
1241
|
|
|
1216
1242
|
/**
|
|
1217
|
-
* Clear the root-side `.worktree` registration for a finished feature
|
|
1218
|
-
*
|
|
1219
|
-
*
|
|
1220
|
-
*
|
|
1243
|
+
* Clear the root-side `.worktree` registration for a finished feature. Runs
|
|
1244
|
+
* whenever the worktree is REMOVED — including `--pr`, where no merge landed —
|
|
1245
|
+
* so a stale registration never trips the anti-dupe guard or the next finish's
|
|
1246
|
+
* root-clean.
|
|
1247
|
+
*
|
|
1248
|
+
* A registration that was committed on the default branch is cleared by a commit
|
|
1249
|
+
* scoped to the feature's own pathspec. An untracked scaffold (what
|
|
1250
|
+
* `create --worktree` leaves at the root) is never committed: the branch owns
|
|
1251
|
+
* the feature, and committing the skeleton would put it on the default next to
|
|
1252
|
+
* the archived copy. When the feature is already archived the skeleton is shed,
|
|
1253
|
+
* backup first, by the same primitive `finish` uses.
|
|
1221
1254
|
*
|
|
1222
1255
|
* Written DIRECTLY (never saveState, which re-persists every sibling's
|
|
1223
1256
|
* feature.json and can dirty the root outside this command's pathspec). The thin
|
|
@@ -1226,9 +1259,12 @@ export function primaryRootUnexplainedDirt(root, feature, opts = {}) {
|
|
|
1226
1259
|
*
|
|
1227
1260
|
* @param {string} root
|
|
1228
1261
|
* @param {string} feature
|
|
1262
|
+
* @param {{ stamp?: string }} [opts] - the caller's backup stamp, for the shed
|
|
1263
|
+
* @returns {Promise<{ shedBackup: string|null }>}
|
|
1229
1264
|
*/
|
|
1230
|
-
export async function clearRootWorktreeRegistration(root, feature) {
|
|
1265
|
+
export async function clearRootWorktreeRegistration(root, feature, { stamp } = {}) {
|
|
1231
1266
|
const featPathspec = `.morph/features/${feature}`;
|
|
1267
|
+
const gitOpts = { cwd: root, stdio: 'pipe' };
|
|
1232
1268
|
try {
|
|
1233
1269
|
const rootFjPath = join(root, '.morph', 'features', feature, 'feature.json');
|
|
1234
1270
|
if (existsSync(rootFjPath)) {
|
|
@@ -1241,16 +1277,19 @@ export async function clearRootWorktreeRegistration(root, feature) {
|
|
|
1241
1277
|
}
|
|
1242
1278
|
} catch { /* fail-open */ }
|
|
1243
1279
|
|
|
1244
|
-
// Clearing that field can itself dirty the root (a committed registration
|
|
1245
|
-
// survives the shed's checkout and the merge). Committing is in contract —
|
|
1246
|
-
// finish already writes commits — and scoped to this feature's path only.
|
|
1247
1280
|
try {
|
|
1248
|
-
const
|
|
1249
|
-
if (
|
|
1250
|
-
|
|
1251
|
-
|
|
1281
|
+
const tracked = execFileSync('git', ['ls-files', '--', featPathspec], gitOpts).toString().trim() !== '';
|
|
1282
|
+
if (!tracked) {
|
|
1283
|
+
if (!existsSync(join(root, '.morph', 'archive', feature))) return { shedBackup: null };
|
|
1284
|
+
return shedRootFeatureScope(root, feature, { stamp });
|
|
1285
|
+
}
|
|
1286
|
+
let unchanged = true;
|
|
1287
|
+
try { execFileSync('git', ['diff', '--quiet', 'HEAD', '--', featPathspec], gitOpts); } catch { unchanged = false; }
|
|
1288
|
+
if (!unchanged) {
|
|
1289
|
+
execFileSync('git', ['commit', '-m', `morph-spec finish: clear ${feature} worktree registration`, '--', featPathspec], gitOpts);
|
|
1252
1290
|
}
|
|
1253
1291
|
} catch { /* fail-open — a dirty root is a nuisance, not a reason to fail */ }
|
|
1292
|
+
return { shedBackup: null };
|
|
1254
1293
|
}
|
|
1255
1294
|
|
|
1256
1295
|
/**
|
|
@@ -1388,14 +1427,20 @@ export function pickRemovalFailure(stderrs) {
|
|
|
1388
1427
|
* `stamp` names the backup; `finish` passes the one it gave the shed, so both
|
|
1389
1428
|
* folders of that finish count as one backup under the retention.
|
|
1390
1429
|
*
|
|
1430
|
+
* `relinkOnFailure` (the agent reaper) also restores the junctions when git
|
|
1431
|
+
* FAILED, not only when we refused: the reaper removes trees on a schedule, and
|
|
1432
|
+
* a lock that leaves a possibly-live agent without `.claude` kills its hooks.
|
|
1433
|
+
* Off by default — a person retrying a failed removal by hand is what the
|
|
1434
|
+
* unlinked state protects. A tree git already de-registered has nothing to link.
|
|
1435
|
+
*
|
|
1391
1436
|
* @param {string} worktreePath
|
|
1392
1437
|
* @param {{ root: string, feature: string, branch: string, options?: object, deleteBranch?: boolean, stamp?: string }} ctx
|
|
1393
1438
|
* @returns {Promise<{ worktreeRemoved: boolean, ignoredBackup: string|null, discarded: string[],
|
|
1394
1439
|
* blockers: string[], ignoredBlocking: string[], shieldable: string[], declineReason: string|null,
|
|
1395
1440
|
* relinked: boolean, refused: boolean, infraLeftUnlinked: string[],
|
|
1396
|
-
* removalFailure: {kind: string, detail: string}|null }>}
|
|
1441
|
+
* removalFailure: {kind: string, detail: string}|null, rootShedBackup: string|null }>}
|
|
1397
1442
|
*/
|
|
1398
|
-
export async function teardownWorktree(worktreePath, { root, feature, branch, options = {}, deleteBranch = true, addressByTarget = false, stamp }) {
|
|
1443
|
+
export async function teardownWorktree(worktreePath, { root, feature, branch, options = {}, deleteBranch = true, addressByTarget = false, relinkOnFailure = false, stamp }) {
|
|
1399
1444
|
// Drop the infra junctions FIRST, without following them — this is what makes
|
|
1400
1445
|
// a removal survivable (see unlinkWorktreeInfraLinks). Runs unconditionally.
|
|
1401
1446
|
//
|
|
@@ -1423,6 +1468,7 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
|
|
|
1423
1468
|
|
|
1424
1469
|
let worktreeRemoved = false;
|
|
1425
1470
|
let removalFailure = null;
|
|
1471
|
+
let rootShedBackup = null;
|
|
1426
1472
|
if (canRemove) {
|
|
1427
1473
|
const attempt = await attemptWorktreeRemoval(worktreePath, root);
|
|
1428
1474
|
worktreeRemoved = attempt.removed;
|
|
@@ -1443,7 +1489,7 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
|
|
|
1443
1489
|
const common = execSync('git rev-parse --git-common-dir', { cwd: root, stdio: 'pipe' }).toString().trim();
|
|
1444
1490
|
if (common) releaseEnvBlock(feature, { gitCommonDir: resolve(root, common) });
|
|
1445
1491
|
} catch { /* fail-open */ }
|
|
1446
|
-
await clearRootWorktreeRegistration(root, feature);
|
|
1492
|
+
({ shedBackup: rootShedBackup } = await clearRootWorktreeRegistration(root, feature, { stamp }));
|
|
1447
1493
|
}
|
|
1448
1494
|
|
|
1449
1495
|
// Rollback (A4) — ONLY on a REFUSAL, never on a FAILURE. The two look alike
|
|
@@ -1463,7 +1509,8 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
|
|
|
1463
1509
|
// and the caller falls back to telling the user to run `worktree link --all`.
|
|
1464
1510
|
const refused = !canRemove;
|
|
1465
1511
|
let relinked = false;
|
|
1466
|
-
|
|
1512
|
+
const relinkAfterFailure = relinkOnFailure && !refused && !worktreeRemoved && !removalFailure?.alreadyDeregistered;
|
|
1513
|
+
if ((refused || relinkAfterFailure) && unlinked.length && existsSync(worktreePath)) {
|
|
1467
1514
|
try {
|
|
1468
1515
|
setupInfraLinks(worktreePath, root);
|
|
1469
1516
|
relinked = true;
|
|
@@ -1471,7 +1518,7 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
|
|
|
1471
1518
|
}
|
|
1472
1519
|
// What the caller must tell the user about on the FAILURE path: the worktree
|
|
1473
1520
|
// survived without its shared infra, on purpose.
|
|
1474
|
-
const infraLeftUnlinked = (!worktreeRemoved && !refused) ? unlinked : [];
|
|
1521
|
+
const infraLeftUnlinked = (!worktreeRemoved && !refused && !relinked) ? unlinked : [];
|
|
1475
1522
|
|
|
1476
1523
|
// Every branch names `morph-spec worktree remove` and NEVER `git worktree
|
|
1477
1524
|
// remove`. The raw command follows the junctions this function manages, and a
|
|
@@ -1508,8 +1555,14 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
|
|
|
1508
1555
|
+ (removalFailure.kind === 'in-use' ? ' — EM USO por outro processo (instância do Claude Code, VS Code, dev server ou terminal com cwd nele)' : '')
|
|
1509
1556
|
+ `${removalFailure.detail ? ` (${removalFailure.detail})` : ''}. \`${removeCmd}\` não o alcança mais — o git não o conhece. Feche o que o usa, confira que está vazio e sem junction, e apague só o diretório (sem recursão)`
|
|
1510
1557
|
: removalFailure?.kind === 'in-use'
|
|
1511
|
-
? `worktree NÃO removido: o diretório está EM USO por outro processo (instância do Claude Code, VS Code, dev server ou terminal com cwd em "${worktreePath}").
|
|
1512
|
-
|
|
1558
|
+
? `worktree NÃO removido: o diretório está EM USO por outro processo (instância do Claude Code, VS Code, dev server ou terminal com cwd em "${worktreePath}"). `
|
|
1559
|
+
+ (relinked
|
|
1560
|
+
? `A infra compartilhada foi RESTAURADA — o worktree segue utilizável. Feche o que está usando o diretório e rode \`${removeCmd}\``
|
|
1561
|
+
: `A infra compartilhada ficou DESMONTADA de propósito — feche o que está usando o diretório e rode \`${removeCmd}\`. Se preferir continuar trabalhando nele, restaure a infra com \`morph-spec worktree link --all\``)
|
|
1562
|
+
: `worktree NÃO removido: a remoção falhou${removalFailure?.detail ? ` (${removalFailure.detail})` : ''}. `
|
|
1563
|
+
+ (relinked
|
|
1564
|
+
? `A infra compartilhada foi RESTAURADA — o worktree segue utilizável. Rode \`${removeCmd}\` para tentar de novo`
|
|
1565
|
+
: `A infra compartilhada ficou DESMONTADA de propósito — rode \`${removeCmd}\` para tentar de novo, ou \`morph-spec worktree link --all\` para voltar a trabalhar nele`);
|
|
1513
1566
|
|
|
1514
1567
|
return {
|
|
1515
1568
|
worktreeRemoved,
|
|
@@ -1531,6 +1584,7 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
|
|
|
1531
1584
|
refused,
|
|
1532
1585
|
infraLeftUnlinked,
|
|
1533
1586
|
removalFailure,
|
|
1587
|
+
rootShedBackup,
|
|
1534
1588
|
};
|
|
1535
1589
|
}
|
|
1536
1590
|
|
|
@@ -1580,8 +1634,9 @@ export function sessionAnchoredInWorktree(worktreePath, env = process.env) {
|
|
|
1580
1634
|
// ── remove ───────────────────────────────────────────────────────────────
|
|
1581
1635
|
|
|
1582
1636
|
/**
|
|
1583
|
-
*
|
|
1584
|
-
* `morph-spec worktree remove --target <path
|
|
1637
|
+
* Non-exiting core of `morph-spec worktree remove <feature>` /
|
|
1638
|
+
* `morph-spec worktree remove --target <path>` — returns `{ out, code }` instead
|
|
1639
|
+
* of printing and exiting, so the agent reaper can call it in a loop.
|
|
1585
1640
|
*
|
|
1586
1641
|
* WHY THIS EXISTS: `teardownWorktree` — the only removal that drops the infra
|
|
1587
1642
|
* junctions before letting git near the directory — used to be reachable ONLY
|
|
@@ -1600,13 +1655,14 @@ export function sessionAnchoredInWorktree(worktreePath, env = process.env) {
|
|
|
1600
1655
|
* closing a feature is still `morph-spec finish`.
|
|
1601
1656
|
*
|
|
1602
1657
|
* @param {string|undefined} featureArg
|
|
1603
|
-
* @param {{ target?: string, discardIgnored?: boolean, deleteBranch?: boolean, ifClean?: boolean, cwd?: string }} [options]
|
|
1658
|
+
* @param {{ target?: string, discardIgnored?: boolean, deleteBranch?: boolean, ifClean?: boolean, relinkOnFailure?: boolean, cwd?: string }} [options]
|
|
1659
|
+
* @returns {Promise<{ out: object, code: 0|1 }>}
|
|
1604
1660
|
*/
|
|
1605
|
-
export async function
|
|
1661
|
+
export async function runWorktreeRemove(featureArg, options = {}) {
|
|
1606
1662
|
const cwdArg = options.cwd || process.cwd();
|
|
1607
|
-
const
|
|
1663
|
+
const outcome = (out, code) => ({ out, code });
|
|
1608
1664
|
|
|
1609
|
-
if (!isGitRepo(cwdArg))
|
|
1665
|
+
if (!isGitRepo(cwdArg)) return outcome({ removed: false, error: 'not-a-git-repo' }, 1);
|
|
1610
1666
|
const root = resolvePrimaryRoot(cwdArg);
|
|
1611
1667
|
|
|
1612
1668
|
let worktreePath = null;
|
|
@@ -1623,11 +1679,11 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
|
|
|
1623
1679
|
// is the same path-traversal class already fixed in delete/archive.
|
|
1624
1680
|
const entry = parseWorktreeList(root).find((e) => e.path === worktreePath);
|
|
1625
1681
|
if (!entry) {
|
|
1626
|
-
|
|
1682
|
+
return outcome({ removed: false, error: 'not-a-registered-worktree', target: worktreePath, root,
|
|
1627
1683
|
hint: 'o path precisa ser um worktree registrado neste repo — confira `morph-spec worktree list`' }, 1);
|
|
1628
1684
|
}
|
|
1629
1685
|
if (worktreePath === root) {
|
|
1630
|
-
|
|
1686
|
+
return outcome({ removed: false, error: 'refuses-primary-root', path: worktreePath,
|
|
1631
1687
|
hint: 'a raiz primária nunca é removível por este comando' }, 1);
|
|
1632
1688
|
}
|
|
1633
1689
|
branch = entry.branch ? entry.branch.replace(/^refs\/heads\//, '') : null;
|
|
@@ -1640,12 +1696,12 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
|
|
|
1640
1696
|
}
|
|
1641
1697
|
} else {
|
|
1642
1698
|
if (!isValidFeatureName(feature)) {
|
|
1643
|
-
|
|
1699
|
+
return outcome({ removed: false, error: 'invalid-feature-name', feature: feature ?? null,
|
|
1644
1700
|
hint: 'informe <feature> (kebab-case) ou use --target <path>' }, 1);
|
|
1645
1701
|
}
|
|
1646
1702
|
const found = findWorktreeForFeature(feature, root);
|
|
1647
1703
|
if (!found || found.path.replace(/\\/g, '/') === root) {
|
|
1648
|
-
|
|
1704
|
+
return outcome({ removed: false, error: 'no-worktree-for-feature', feature, root,
|
|
1649
1705
|
hint: `nenhum worktree separado para "${feature}" — confira \`morph-spec worktree list\`` }, 1);
|
|
1650
1706
|
}
|
|
1651
1707
|
worktreePath = found.path;
|
|
@@ -1656,7 +1712,7 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
|
|
|
1656
1712
|
// is MORE likely to hit it: the natural moment to run it is right after
|
|
1657
1713
|
// working inside the worktree.
|
|
1658
1714
|
if (cwdIsInsideWorktree(cwdArg, worktreePath)) {
|
|
1659
|
-
|
|
1715
|
+
return outcome({ removed: false, error: 'cwd-inside-worktree', path: worktreePath, root,
|
|
1660
1716
|
hint: `saia do worktree antes de removê-lo — o processo trava o próprio diretório de trabalho. Rode de novo a partir de "${root}"` }, 1);
|
|
1661
1717
|
}
|
|
1662
1718
|
|
|
@@ -1666,7 +1722,7 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
|
|
|
1666
1722
|
// refusal IS the deferral.
|
|
1667
1723
|
if (sessionAnchoredInWorktree(worktreePath)) {
|
|
1668
1724
|
const again = featureAddressable && feature ? `morph-spec worktree remove ${feature}` : `morph-spec worktree remove --target "${worktreePath}"`;
|
|
1669
|
-
|
|
1725
|
+
return outcome({ removed: false, error: 'session-anchored-in-worktree', path: worktreePath, root,
|
|
1670
1726
|
hint: `a sessão do Claude Code que rodou este comando está ancorada neste worktree — os hooks dela resolvem \`.morph/framework\` por ele, e desmontá-lo quebraria todo tool call seguinte (e o diretório seguiria travado pela sessão). Feche esta sessão e rode \`${again}\` de uma sessão aberta em "${root}"` }, 1);
|
|
1671
1727
|
}
|
|
1672
1728
|
|
|
@@ -1681,12 +1737,12 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
|
|
|
1681
1737
|
try {
|
|
1682
1738
|
status = execFileSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd: worktreePath, stdio: 'pipe' }).toString();
|
|
1683
1739
|
} catch {
|
|
1684
|
-
|
|
1740
|
+
return outcome({ removed: false, error: 'status-failed', path: worktreePath, root,
|
|
1685
1741
|
hint: 'não foi possível provar que o worktree está limpo — nada foi removido' }, 1);
|
|
1686
1742
|
}
|
|
1687
1743
|
const dirt = withoutLeftoverIgnore(worktreePath, status.split(/\r?\n/).filter((l) => l.trim()));
|
|
1688
1744
|
if (dirt.length) {
|
|
1689
|
-
|
|
1745
|
+
return outcome({ removed: false, error: 'worktree-dirty', path: worktreePath, root, dirt: dirt.slice(0, 20),
|
|
1690
1746
|
hint: `o worktree tem trabalho não commitado — commit ou descarte antes, ou rode \`morph-spec worktree remove --target "${worktreePath}"\` sem --if-clean para remover com backup` }, 1);
|
|
1691
1747
|
}
|
|
1692
1748
|
}
|
|
@@ -1725,6 +1781,7 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
|
|
|
1725
1781
|
// Removing a worktree is not closing a feature: the branch is the work, and
|
|
1726
1782
|
// deleting it here would discard unintegrated commits. Opt-in only.
|
|
1727
1783
|
deleteBranch: options.deleteBranch === true,
|
|
1784
|
+
relinkOnFailure: options.relinkOnFailure === true,
|
|
1728
1785
|
// Re-run advice must address the worktree the way it was addressable here:
|
|
1729
1786
|
// a `--target` worktree has no feature name that would resolve.
|
|
1730
1787
|
addressByTarget: !featureAddressable,
|
|
@@ -1749,6 +1806,7 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
|
|
|
1749
1806
|
if (teardown.replicable.length) out.replicatedInRoot = teardown.replicable;
|
|
1750
1807
|
if (teardown.ignoredBackup) out.ignoredBackup = teardown.ignoredBackup;
|
|
1751
1808
|
if (teardown.shieldable.length) out.backedUp = teardown.shieldable;
|
|
1809
|
+
if (teardown.rootShedBackup) out.shedBackup = teardown.rootShedBackup;
|
|
1752
1810
|
} else {
|
|
1753
1811
|
out.refused = teardown.refused;
|
|
1754
1812
|
if (teardown.blockers.length) out.reparsePoints = teardown.blockers;
|
|
@@ -1763,7 +1821,18 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
|
|
|
1763
1821
|
const retention = enforceBackupRetention(root, { protectStamps: [stamp] });
|
|
1764
1822
|
if (retention) out.backupRetention = retention;
|
|
1765
1823
|
}
|
|
1766
|
-
|
|
1824
|
+
return outcome(out, teardown.worktreeRemoved ? 0 : 1);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
/**
|
|
1828
|
+
* `morph-spec worktree remove` — prints the JSON verdict and exits with its code.
|
|
1829
|
+
* @param {string|undefined} featureArg
|
|
1830
|
+
* @param {object} [options] - see `runWorktreeRemove`
|
|
1831
|
+
*/
|
|
1832
|
+
export async function worktreeRemoveCommand(featureArg, options = {}) {
|
|
1833
|
+
const { out, code } = await runWorktreeRemove(featureArg, options);
|
|
1834
|
+
console.log(JSON.stringify(out));
|
|
1835
|
+
process.exit(code);
|
|
1767
1836
|
}
|
|
1768
1837
|
|
|
1769
1838
|
// ── list ─────────────────────────────────────────────────────────────────
|
|
@@ -8,6 +8,7 @@ import { unmetPrereqs } from '../../core/state/gate-order.js';
|
|
|
8
8
|
import { evaluatorReportState, COMPOSITE_SCORE_TOKEN } from '../../lib/evaluator-report.js';
|
|
9
9
|
import { assertOwnerTree, ownerTreeRefusalLines } from '../../lib/worktree/feature-truth.js';
|
|
10
10
|
import { uiuxAutoApprovalBlock } from '../../lib/specs/screen-checklist.js';
|
|
11
|
+
import { treeMovedSince } from '../../lib/git/evidence-staleness.js';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* The literal token `review-report.md` carries to declare which tree it
|
|
@@ -57,7 +58,7 @@ function reviewStaleness(featureName) {
|
|
|
57
58
|
// of staleness — say "unstamped" rather than block on a guess.
|
|
58
59
|
return { status: 'unstamped', head };
|
|
59
60
|
}
|
|
60
|
-
if (reviewed
|
|
61
|
+
if (!treeMovedSince(cwd, reviewed, head)) return { status: 'ok', reviewed, head };
|
|
61
62
|
|
|
62
63
|
let ahead = 0;
|
|
63
64
|
try {
|
|
@@ -65,6 +65,8 @@ export function buildPrTitle(feature, fj) {
|
|
|
65
65
|
return prefix + (raw.length <= room ? raw : `${raw.slice(0, room - 1)}…`);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
const MEMORY_NOTE = 'o fechamento desta feature já está registrado no repositório (commit de archive, `decisions.md`, use cases promovidos, `git log`) — memória de sessão só para o que NENHUM deles guarda: decisão do usuário, pendência a observar em produção, custo medido.';
|
|
69
|
+
|
|
68
70
|
/**
|
|
69
71
|
* `morph-spec finish <feature> --pr|--merge` — the single, symmetric close of a
|
|
70
72
|
* feature's lifecycle, for BOTH the worktree flow and the common flow.
|
|
@@ -132,6 +134,8 @@ export async function finishCommand(feature, options = {}) {
|
|
|
132
134
|
const emit = (obj) => {
|
|
133
135
|
if (resumed) obj = { ...obj, resumed: true };
|
|
134
136
|
if (obj?.finished === true) {
|
|
137
|
+
// The one path every finished:true takes; the --pr payload is built by hand, outside mergeOutput.
|
|
138
|
+
obj = { ...obj, memoryNote: MEMORY_NOTE };
|
|
135
139
|
try {
|
|
136
140
|
const common = execFileSync('git', ['rev-parse', '--git-common-dir'], { cwd: root, stdio: 'pipe' }).toString().trim();
|
|
137
141
|
if (common) releaseClaim(feature, { gitCommonDir: resolve(root, common) });
|
|
@@ -8,6 +8,7 @@ import { collectScreenChecklist } from '../../lib/specs/screen-checklist.js';
|
|
|
8
8
|
import { persistGateDecision, VALID_GATES, readTrustFromConfig } from './gate-decision.js';
|
|
9
9
|
import { minimatch } from 'minimatch';
|
|
10
10
|
import { detectDefaultBranch, getHeadSha, getDiffStat, changedPathsAgainst } from '../../lib/git/default-branch.js';
|
|
11
|
+
import { treeMovedSince } from '../../lib/git/evidence-staleness.js';
|
|
11
12
|
import { readMorphConfig } from '../../lib/tasks/test-runner.js';
|
|
12
13
|
import { oversizedTasks } from '../../lib/tasks/tasks-json-parser.js';
|
|
13
14
|
import { assertOwnerTree, ownerTreeRefusalLines } from '../../lib/worktree/feature-truth.js';
|
|
@@ -283,7 +284,7 @@ function collectSignals(featureName, feature, cwd, gateName) {
|
|
|
283
284
|
// stamp com sha null (checkout sem git) ou stamp ausente lê como NOT stale.
|
|
284
285
|
signals.verification = verification;
|
|
285
286
|
if (verification?.status) signals.verificationStatus = verification.status;
|
|
286
|
-
signals.verificationStale =
|
|
287
|
+
signals.verificationStale = treeMovedSince(cwd, verification?.sha, sha);
|
|
287
288
|
|
|
288
289
|
// O nó e2e, separado do rollup de propósito: um `skip` NÃO reprova
|
|
289
290
|
// `verification.status`, então sem estas duas chaves o gate lê 'pass' tanto
|