@polymorphism-tech/morph-spec 8.37.0 → 8.38.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 +19 -6
- package/README.md +2 -2
- package/bin/morph-spec.js +16 -7
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +3 -3
- package/framework/MORPH.md +25 -22
- package/framework/agents.json +76 -27
- package/framework/docs/CLI.md +55 -18
- package/framework/evals/rubrics/code-quality.md +5 -0
- package/framework/evals/rubrics/condensed.md +6 -2
- package/framework/evals/rubrics/contracts.md +20 -0
- package/framework/evals/rubrics/test-coverage.md +17 -6
- package/framework/hooks/README.md +9 -3
- package/framework/hooks/claude-code/core/graph-refresh-kick.js +52 -0
- package/framework/hooks/claude-code/core/telemetry-log.js +172 -87
- package/framework/hooks/claude-code/core/validate-completion.js +16 -90
- package/framework/hooks/claude-code/core/worktree-context.js +10 -0
- package/framework/hooks/shared/command-tokens.js +96 -0
- package/framework/hooks/shared/git-exclude.js +44 -0
- package/framework/hooks/shared/git-paths.js +52 -0
- package/framework/hooks/shared/graph-freshness.js +380 -0
- package/framework/hooks/shared/graph-refresh.js +416 -0
- package/framework/hooks/shared/morph-cli.js +17 -5
- package/framework/hooks/shared/redact-secrets.js +55 -0
- package/framework/hooks/shared/telemetry-logger.js +25 -2
- package/framework/hooks/shared/transcript-usage.js +80 -0
- package/framework/rubrics/backend.json +2 -2
- package/framework/rubrics/default.json +2 -2
- package/framework/rubrics/frontend.json +2 -2
- package/framework/rules/csharp-standards.md +1 -0
- package/framework/rules/morph-workflow.md +1 -0
- package/framework/rules/nextjs-standards.md +1 -0
- package/framework/schemas/config.schema.json +41 -1
- package/framework/schemas/feature.schema.json +5 -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 +36 -11
- package/framework/skills/core/morph-brainstorming/SKILL.md +16 -7
- 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 +39 -10
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +72 -15
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +51 -13
- 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 +34 -9
- package/framework/standards/STANDARDS.json +187 -12
- package/framework/standards/ai-agents/rag-custom-pgvector.md +7 -3
- package/framework/standards/ai-agents/testing-ai.md +2 -1
- package/framework/standards/architecture/code-comments.md +64 -0
- package/framework/standards/backend/dotnet/testing.md +22 -0
- package/framework/standards/frontend/admin-app/data-table.md +562 -0
- package/framework/standards/frontend/admin-app/feedback.md +236 -0
- package/framework/standards/frontend/admin-app/forms.md +232 -0
- package/framework/standards/frontend/admin-app/page-structure.md +203 -0
- package/framework/standards/frontend/nextjs/components.md +37 -58
- package/framework/standards/frontend/nextjs/data-fetching.md +1 -0
- package/framework/standards/frontend/nextjs/forms.md +31 -30
- package/framework/standards/frontend/nextjs/state-management.md +2 -1
- package/framework/standards/frontend/nextjs/testing.md +18 -0
- package/framework/templates/context/design-system.md +3 -3
- package/framework/templates/docs/proposal.md +11 -0
- package/framework/templates/docs/spec.md +15 -0
- package/framework/templates/docs/ui-mockups.md +115 -98
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaPlan.cs +12 -6
- package/framework/templates/feature/decisions.md +124 -122
- package/package.json +1 -1
- package/src/commands/graph/affected.js +4 -3
- package/src/commands/graph/build.js +109 -18
- package/src/commands/graph/explain.js +8 -5
- package/src/commands/graph/status.js +14 -8
- package/src/commands/observability/cost.js +148 -18
- package/src/commands/project/doctor.js +82 -10
- package/src/commands/project/update.js +25 -0
- package/src/commands/project/worktree.js +73 -90
- package/src/commands/state/advance.js +18 -0
- package/src/commands/state/approve.js +15 -0
- package/src/commands/state/archive.js +62 -9
- package/src/commands/state/finish.js +62 -14
- package/src/commands/state/gate-check.js +8 -1
- package/src/commands/state/gate-decision.js +8 -4
- package/src/commands/state/report-check.js +90 -9
- package/src/commands/state/usecase.js +66 -0
- package/src/commands/tasks/dag.js +17 -2
- package/src/commands/validation/validate-feature.js +6 -1
- package/src/commands/validation/verify.js +20 -1
- package/src/dashboard/scanner/agents-scanner.js +5 -1
- package/src/dashboard/scanner/hooks-scanner.js +8 -1
- package/src/dashboard/scanner/standards-scanner.js +2 -1
- package/src/lib/ai/comment-stripper.js +101 -4
- package/src/lib/evaluator-report.js +25 -0
- package/src/lib/generators/handoff-generator.js +4 -18
- package/src/lib/git/default-branch.js +52 -0
- package/src/lib/git/tree-fingerprint.js +58 -0
- package/src/lib/git/tree-state.js +167 -0
- package/src/lib/graph/god-nodes.js +12 -3
- package/src/lib/graph/graph-health.js +48 -2
- package/src/lib/graph/graph-loader.js +99 -25
- package/src/lib/graph/graphifyignore.js +145 -28
- package/src/lib/graph/sanitize.js +117 -11
- package/src/lib/risk-detector.js +90 -0
- package/src/lib/specs/acervo-format.js +70 -0
- package/src/lib/specs/acervo-lifecycle.js +264 -0
- package/src/lib/specs/adr-index.js +169 -0
- package/src/lib/specs/revocations.js +156 -0
- package/src/lib/specs/screen-checklist.js +357 -0
- package/src/lib/specs/usecase-check.js +20 -16
- package/src/lib/specs/usecase-promoter.js +103 -38
- package/src/lib/specs/usecase-registry.js +7 -7
- package/src/lib/tasks/tasks-json-parser.js +2 -3
- package/src/lib/telemetry/metrics.js +386 -0
- package/src/lib/telemetry/telemetry-reader.js +161 -51
- package/src/lib/telemetry/verify-event.js +79 -0
- package/src/lib/validators/code-budget/code-budget-validator.js +172 -0
- package/src/lib/validators/code-budget/comment-scanner.js +75 -0
- package/src/lib/validators/code-budget/index.js +9 -0
- package/src/lib/validators/reuse/reuse-validator.js +4 -2
- package/src/lib/validators/reuse/symbol-scanner.js +27 -22
- package/src/lib/validators/validation-runner.js +21 -5
- package/src/lib/validators/verify-runner.js +11 -0
- package/src/lib/worktree/finish-backup.js +739 -0
- package/src/lib/worktree/ignored-triage.js +15 -8
- package/src/lib/worktree/worktree-audit.js +5 -0
- package/src/scripts/setup-infra.js +29 -18
- package/src/utils/agents-installer.js +281 -12
- package/src/utils/hooks-installer.js +75 -15
|
@@ -35,14 +35,24 @@
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
import { execSync, execFileSync } from 'child_process';
|
|
38
|
-
import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync,
|
|
38
|
+
import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, lstatSync, unlinkSync, rmdirSync, cpSync } from 'fs';
|
|
39
39
|
import { join, resolve, dirname, basename } from 'path';
|
|
40
40
|
import chalk from 'chalk';
|
|
41
41
|
import { detectDefaultBranch } from '../../lib/git/default-branch.js';
|
|
42
42
|
import { triageIgnoredPaths } from '../../lib/worktree/ignored-triage.js';
|
|
43
|
+
import {
|
|
44
|
+
finishBackupDir,
|
|
45
|
+
writeFeatureScopeBackup,
|
|
46
|
+
writeWorktreeIgnoredBackup,
|
|
47
|
+
enforceBackupRetention,
|
|
48
|
+
newBackupStamp,
|
|
49
|
+
} from '../../lib/worktree/finish-backup.js';
|
|
43
50
|
// The reparse scan lives in hooks/shared because the PreToolUse guardrail needs
|
|
44
51
|
// the SAME answer this file does, and hooks can never import from `src/`.
|
|
45
52
|
import { findReparsePoints } from '../../../framework/hooks/shared/reparse-scan.js';
|
|
53
|
+
import { ensureCommonExclude } from '../../../framework/hooks/shared/git-exclude.js';
|
|
54
|
+
import { seedGraphFromRoot } from '../../../framework/hooks/shared/graph-refresh.js';
|
|
55
|
+
import { dropLeftoverIgnore, withoutLeftoverIgnore } from '../../../framework/hooks/shared/graph-freshness.js';
|
|
46
56
|
|
|
47
57
|
// The container name lives in lib/worktree/dirs.js (single source, C1
|
|
48
58
|
// rationale documented there); re-exported here so existing importers and the
|
|
@@ -288,28 +298,9 @@ const EXCLUDE_MARKER = '# morph-spec: infra links inside worktrees (managed —
|
|
|
288
298
|
* @param {string[]} rels - worktree-relative paths to exclude
|
|
289
299
|
*/
|
|
290
300
|
function excludeWorktreePaths(mainCwd, rels) {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
// itself be a worktree (whose `.git` is a FILE), and the common dir is the
|
|
295
|
-
// only exclude every linked worktree actually reads.
|
|
296
|
-
const common = execSync('git rev-parse --git-common-dir', { cwd: mainCwd, stdio: 'pipe' }).toString().trim();
|
|
297
|
-
if (!common) return;
|
|
298
|
-
const infoDir = join(resolve(mainCwd, common), 'info');
|
|
299
|
-
const excludePath = join(infoDir, 'exclude');
|
|
300
|
-
const current = existsSync(excludePath) ? readFileSync(excludePath, 'utf8') : '';
|
|
301
|
-
const present = new Set(current.split(/\r?\n/).map((l) => l.trim()));
|
|
302
|
-
// Anchored (`/x`): these live at the top of a worktree, and an unanchored
|
|
303
|
-
// `node_modules` would also swallow nested ones the user may track.
|
|
304
|
-
const missing = rels.map((rel) => `/${rel.replace(/\\/g, '/')}`).filter((p) => !present.has(p));
|
|
305
|
-
if (!missing.length) return;
|
|
306
|
-
mkdirSync(infoDir, { recursive: true });
|
|
307
|
-
appendFileSync(excludePath, [
|
|
308
|
-
current && !current.endsWith('\n') ? '\n' : '',
|
|
309
|
-
present.has(EXCLUDE_MARKER) ? '' : `${EXCLUDE_MARKER}\n`,
|
|
310
|
-
`${missing.join('\n')}\n`,
|
|
311
|
-
].join(''));
|
|
312
|
-
} catch { /* fail-open */ }
|
|
301
|
+
// Anchored (`/x`): these live at the top of a worktree, and an unanchored
|
|
302
|
+
// `node_modules` would also swallow nested ones the user may track.
|
|
303
|
+
ensureCommonExclude(mainCwd, rels.map((rel) => `/${rel.replace(/\\/g, '/')}`), EXCLUDE_MARKER);
|
|
313
304
|
}
|
|
314
305
|
|
|
315
306
|
/**
|
|
@@ -412,6 +403,28 @@ export async function materializeState(worktreePath) {
|
|
|
412
403
|
}
|
|
413
404
|
}
|
|
414
405
|
|
|
406
|
+
/**
|
|
407
|
+
* Codebase graph for a new worktree. The tree is born from a root commit, so
|
|
408
|
+
* the root's graph is either this tree's HEAD graph already or one
|
|
409
|
+
* `morph-spec graph refresh` away — and the `reuse` validator reads the graph
|
|
410
|
+
* of the tree `verify` runs in, so a worktree without one skips reuse checking
|
|
411
|
+
* exactly where the new code is written. Nothing is launched in the
|
|
412
|
+
* background: the automatic refresh only ever touches the root (a detached
|
|
413
|
+
* process holding a worktree directory made `finish` fail on win32).
|
|
414
|
+
* Fail-open: no root graph, no copy.
|
|
415
|
+
*
|
|
416
|
+
* @param {string} worktreePath
|
|
417
|
+
* @param {string} root
|
|
418
|
+
* @returns {{seeded: boolean, reason?: string}}
|
|
419
|
+
*/
|
|
420
|
+
function seedWorktreeGraph(worktreePath, root) {
|
|
421
|
+
try {
|
|
422
|
+
return seedGraphFromRoot(worktreePath, root);
|
|
423
|
+
} catch {
|
|
424
|
+
return { seeded: false, reason: 'seed-failed' };
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
415
428
|
// ── Command handler ─────────────────────────────────────────────────────────
|
|
416
429
|
|
|
417
430
|
/**
|
|
@@ -521,6 +534,8 @@ export async function runWorktreeSetup(feature, options = {}) {
|
|
|
521
534
|
// committed feature.json files) so hooks work from the very first session.
|
|
522
535
|
const materialized = await ensureLocalState(worktreePath);
|
|
523
536
|
|
|
537
|
+
const graph = seedWorktreeGraph(worktreePath, cwd);
|
|
538
|
+
|
|
524
539
|
// Fleet handoff: the fresh instance that opens this worktree reads
|
|
525
540
|
// handoff.md before anything else (SessionStart hook points at it). The doc
|
|
526
541
|
// is derived and git-invisible (see handoff-generator). Fail-open — and a
|
|
@@ -560,6 +575,7 @@ export async function runWorktreeSetup(feature, options = {}) {
|
|
|
560
575
|
feature,
|
|
561
576
|
materialized: materialized.materialized,
|
|
562
577
|
...(envBlock ? { env: { base: envBlock.base, range: envBlock.range } } : {}),
|
|
578
|
+
...(graph.seeded ? { graph: 'seeded' } : {}),
|
|
563
579
|
nextStep: `Abra o Claude Code em ${worktreePath} para trabalhar nesta feature`,
|
|
564
580
|
};
|
|
565
581
|
} catch (err) {
|
|
@@ -654,6 +670,7 @@ export async function runWorktreeProvision(name, options = {}) {
|
|
|
654
670
|
setupInfraLinks(worktreePath, root);
|
|
655
671
|
const envBlock = await allocateWorktreeEnv(worktreePath, root, `task/${name}`);
|
|
656
672
|
const materialized = await ensureLocalState(worktreePath);
|
|
673
|
+
const graph = seedWorktreeGraph(worktreePath, root);
|
|
657
674
|
|
|
658
675
|
return {
|
|
659
676
|
ok: true,
|
|
@@ -666,6 +683,7 @@ export async function runWorktreeProvision(name, options = {}) {
|
|
|
666
683
|
name,
|
|
667
684
|
materialized: materialized.materialized,
|
|
668
685
|
...(envBlock ? { env: { base: envBlock.base, range: envBlock.range } } : {}),
|
|
686
|
+
...(graph.seeded ? { graph: 'seeded' } : {}),
|
|
669
687
|
};
|
|
670
688
|
} catch (err) {
|
|
671
689
|
return { ok: true, exitCode: 0, created: false, error: (err.message || 'unknown').slice(0, 200), name };
|
|
@@ -930,7 +948,7 @@ export function unlinkWorktreeInfraLinks(worktreePath, mainCwd) {
|
|
|
930
948
|
|
|
931
949
|
/**
|
|
932
950
|
* Gitignored paths a morph worktree owns and that are safe to remove — but ONLY
|
|
933
|
-
* once backed up (see
|
|
951
|
+
* once backed up (see writeWorktreeIgnoredBackup). `.morph/state.json` is provably
|
|
934
952
|
* derivable (loadState self-heals it from the committed feature.json files) and
|
|
935
953
|
* the rest is morph's own diagnostic output, which `setupInfraLinks` documents
|
|
936
954
|
* as worktree-local. They are small, so backing them up costs nothing and buys
|
|
@@ -1054,60 +1072,6 @@ export function classifyIgnoredLeftovers(worktreePath, rootPath, options = {}) {
|
|
|
1054
1072
|
return { shieldable, blocking: [], discarded: [...disposable, ...replicable], replicable };
|
|
1055
1073
|
}
|
|
1056
1074
|
|
|
1057
|
-
/**
|
|
1058
|
-
* Back up the worktree's morph-owned gitignored leftovers before the removal
|
|
1059
|
-
* deletes them. Same destination and rationale as backupFeatureScope.
|
|
1060
|
-
*
|
|
1061
|
-
* @param {string} worktreePath
|
|
1062
|
-
* @param {string} root
|
|
1063
|
-
* @param {string} feature
|
|
1064
|
-
* @param {string[]} rels - worktree-relative paths
|
|
1065
|
-
* @param {string} stamp
|
|
1066
|
-
* @returns {string|null} backup path, or null if ANY copy failed (caller must
|
|
1067
|
-
* then decline the removal — a partial backup authorizes nothing)
|
|
1068
|
-
*/
|
|
1069
|
-
function backupWorktreeIgnored(worktreePath, root, feature, rels, stamp) {
|
|
1070
|
-
const dest = join(root, '.git', 'morph-spec', 'finish-backup', `${feature}-${stamp}-worktree-ignored`);
|
|
1071
|
-
try {
|
|
1072
|
-
for (const rel of rels) {
|
|
1073
|
-
const src = join(worktreePath, rel);
|
|
1074
|
-
if (!existsSync(src)) continue; // a directory entry already covered by its children
|
|
1075
|
-
const target = join(dest, rel);
|
|
1076
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
1077
|
-
cpSync(src, target, { recursive: true });
|
|
1078
|
-
}
|
|
1079
|
-
return existsSync(dest) ? dest : null;
|
|
1080
|
-
} catch {
|
|
1081
|
-
return null;
|
|
1082
|
-
}
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
/**
|
|
1086
|
-
* Copy the feature's root-side scope to a backup OUTSIDE the working tree, so
|
|
1087
|
-
* the shed below can never be the reason something is lost.
|
|
1088
|
-
*
|
|
1089
|
-
* Lives under `.git/` on purpose: it is not a working-tree path, so it can never
|
|
1090
|
-
* dirty `git status` (which the very next validation reads) and is never merged,
|
|
1091
|
-
* committed, or cleaned by `git clean`.
|
|
1092
|
-
*
|
|
1093
|
-
* @param {string} root
|
|
1094
|
-
* @param {string} feature
|
|
1095
|
-
* @param {string} stamp - caller-supplied timestamp (keeps this pure/testable)
|
|
1096
|
-
* @returns {string|null} absolute backup path, or null if the copy failed
|
|
1097
|
-
*/
|
|
1098
|
-
function backupFeatureScope(root, feature, stamp) {
|
|
1099
|
-
const src = join(root, '.morph', 'features', feature);
|
|
1100
|
-
if (!existsSync(src)) return null;
|
|
1101
|
-
const dest = join(root, '.git', 'morph-spec', 'finish-backup', `${feature}-${stamp}`);
|
|
1102
|
-
try {
|
|
1103
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
1104
|
-
cpSync(src, dest, { recursive: true });
|
|
1105
|
-
return dest;
|
|
1106
|
-
} catch {
|
|
1107
|
-
return null; // caller MUST treat this as "do not shed"
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
1075
|
// ── finish primitives (used by `morph-spec finish`) ───────────────────────
|
|
1112
1076
|
//
|
|
1113
1077
|
// The old `worktree finish` command dissolved here: `morph-spec finish` (see
|
|
@@ -1119,17 +1083,19 @@ function backupFeatureScope(root, feature, stamp) {
|
|
|
1119
1083
|
/**
|
|
1120
1084
|
* Shed the feature's OWN transient root-side bookkeeping so the merge can own
|
|
1121
1085
|
* this feature's state while the whole-root clean-check keeps guarding UNRELATED
|
|
1122
|
-
* work. Backs up FIRST (see
|
|
1123
|
-
* safe WITHOUT having to be right about what is in scope: if the
|
|
1124
|
-
* the merge implies turns out wrong, the bytes are still on
|
|
1125
|
-
* only; the common flow committed the archive on the
|
|
1126
|
-
* untracked root-side scaffold to shed.
|
|
1086
|
+
* work. Backs up FIRST (see writeFeatureScopeBackup), then discards — the shed
|
|
1087
|
+
* is safe WITHOUT having to be right about what is in scope: if the
|
|
1088
|
+
* classification the merge implies turns out wrong, the bytes are still on
|
|
1089
|
+
* disk. Worktree-flow only; the common flow committed the archive on the
|
|
1090
|
+
* branch, so there is no untracked root-side scaffold to shed.
|
|
1127
1091
|
*
|
|
1128
1092
|
* @param {string} root
|
|
1129
1093
|
* @param {string} feature
|
|
1094
|
+
* @param {{ stamp?: string }} [opts] - the finish's one stamp, shared with the
|
|
1095
|
+
* teardown's backup so the two folders count as one backup
|
|
1130
1096
|
* @returns {{ shedBackup: string|null }} backup path if anything was shed
|
|
1131
1097
|
*/
|
|
1132
|
-
export function shedRootFeatureScope(root, feature) {
|
|
1098
|
+
export function shedRootFeatureScope(root, feature, { stamp } = {}) {
|
|
1133
1099
|
const featPathspec = `.morph/features/${feature}`;
|
|
1134
1100
|
let dirtyInScope = '';
|
|
1135
1101
|
try {
|
|
@@ -1138,7 +1104,7 @@ export function shedRootFeatureScope(root, feature) {
|
|
|
1138
1104
|
|
|
1139
1105
|
if (dirtyInScope.trim() === '') return { shedBackup: null };
|
|
1140
1106
|
|
|
1141
|
-
const shedBackup =
|
|
1107
|
+
const shedBackup = writeFeatureScopeBackup({ root, feature, stamp });
|
|
1142
1108
|
// No backup, no shed. Falling through leaves the root dirty, so the caller's
|
|
1143
1109
|
// clean-check refuses — the exact fail-closed behaviour from before any shed.
|
|
1144
1110
|
if (shedBackup) {
|
|
@@ -1363,14 +1329,17 @@ export function pickRemovalFailure(stderrs) {
|
|
|
1363
1329
|
* `deleteBranch` decouples `git branch -d` from removal: true for `--merge` (the
|
|
1364
1330
|
* branch is integrated), false for `--pr` (the open PR still needs the branch).
|
|
1365
1331
|
*
|
|
1332
|
+
* `stamp` names the backup; `finish` passes the one it gave the shed, so both
|
|
1333
|
+
* folders of that finish count as one backup under the retention.
|
|
1334
|
+
*
|
|
1366
1335
|
* @param {string} worktreePath
|
|
1367
|
-
* @param {{ root: string, feature: string, branch: string, options?: object, deleteBranch?: boolean }} ctx
|
|
1336
|
+
* @param {{ root: string, feature: string, branch: string, options?: object, deleteBranch?: boolean, stamp?: string }} ctx
|
|
1368
1337
|
* @returns {Promise<{ worktreeRemoved: boolean, ignoredBackup: string|null, discarded: string[],
|
|
1369
1338
|
* blockers: string[], ignoredBlocking: string[], shieldable: string[], declineReason: string|null,
|
|
1370
1339
|
* relinked: boolean, refused: boolean, infraLeftUnlinked: string[],
|
|
1371
1340
|
* removalFailure: {kind: string, detail: string}|null }>}
|
|
1372
1341
|
*/
|
|
1373
|
-
export async function teardownWorktree(worktreePath, { root, feature, branch, options = {}, deleteBranch = true, addressByTarget = false }) {
|
|
1342
|
+
export async function teardownWorktree(worktreePath, { root, feature, branch, options = {}, deleteBranch = true, addressByTarget = false, stamp }) {
|
|
1374
1343
|
// Drop the infra junctions FIRST, without following them — this is what makes
|
|
1375
1344
|
// a removal survivable (see unlinkWorktreeInfraLinks). Runs unconditionally.
|
|
1376
1345
|
//
|
|
@@ -1392,7 +1361,7 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
|
|
|
1392
1361
|
let ignoredBackup = null;
|
|
1393
1362
|
const clearedToRemove = blockers.length === 0 && ignored.blocking.length === 0;
|
|
1394
1363
|
if (clearedToRemove && ignored.shieldable.length) {
|
|
1395
|
-
ignoredBackup =
|
|
1364
|
+
ignoredBackup = writeWorktreeIgnoredBackup({ worktreePath, root, feature, rels: ignored.shieldable, stamp });
|
|
1396
1365
|
}
|
|
1397
1366
|
const canRemove = clearedToRemove && (!ignored.shieldable.length || ignoredBackup !== null);
|
|
1398
1367
|
|
|
@@ -1462,7 +1431,7 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
|
|
|
1462
1431
|
const removeCmd = (addressByTarget || !isValidFeatureName(feature))
|
|
1463
1432
|
? `morph-spec worktree remove --target "${worktreePath}"`
|
|
1464
1433
|
: `morph-spec worktree remove ${feature}`;
|
|
1465
|
-
const backupDir =
|
|
1434
|
+
const backupDir = finishBackupDir(root);
|
|
1466
1435
|
const sample = (list) => `${list.slice(0, 5).join(', ')}${list.length > 5 ? `, +${list.length - 5}` : ''}`;
|
|
1467
1436
|
const declineReason = worktreeRemoved
|
|
1468
1437
|
? null
|
|
@@ -1659,13 +1628,18 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
|
|
|
1659
1628
|
emit({ removed: false, error: 'status-failed', path: worktreePath, root,
|
|
1660
1629
|
hint: 'não foi possível provar que o worktree está limpo — nada foi removido' }, 1);
|
|
1661
1630
|
}
|
|
1662
|
-
const dirt = status.split(/\r?\n/).filter((l) => l.trim());
|
|
1631
|
+
const dirt = withoutLeftoverIgnore(worktreePath, status.split(/\r?\n/).filter((l) => l.trim()));
|
|
1663
1632
|
if (dirt.length) {
|
|
1664
1633
|
emit({ removed: false, error: 'worktree-dirty', path: worktreePath, root, dirt: dirt.slice(0, 20),
|
|
1665
1634
|
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);
|
|
1666
1635
|
}
|
|
1667
1636
|
}
|
|
1668
1637
|
|
|
1638
|
+
// A leftover temporary .graphifyignore (a `graph refresh` killed mid-run) is a
|
|
1639
|
+
// copy of the root's rules: not a file to back up. Deleted only now, after
|
|
1640
|
+
// every refusal, so a refused removal leaves the tree as it was.
|
|
1641
|
+
dropLeftoverIgnore(worktreePath);
|
|
1642
|
+
|
|
1669
1643
|
// The env-registry key this tree allocated under: `<f>` for morph/<f>,
|
|
1670
1644
|
// `task/<n>` for morph-task/<n>. Read BEFORE the teardown (the entry is gone
|
|
1671
1645
|
// from `git worktree list` afterwards). The old `'worktree'` placeholder
|
|
@@ -1683,9 +1657,13 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
|
|
|
1683
1657
|
e2e = tearDownFeatureStack(envKey || feature || basename(worktreePath), { root, worktreePath, volumes: false });
|
|
1684
1658
|
} catch { e2e = null; /* fail-open */ }
|
|
1685
1659
|
|
|
1660
|
+
// The stamp this removal's backup is written under — protected from the
|
|
1661
|
+
// retention that runs after it.
|
|
1662
|
+
const stamp = newBackupStamp();
|
|
1686
1663
|
const teardown = await teardownWorktree(worktreePath, {
|
|
1687
1664
|
root,
|
|
1688
1665
|
feature: feature || envKey || 'worktree',
|
|
1666
|
+
stamp,
|
|
1689
1667
|
branch,
|
|
1690
1668
|
options: { discardIgnored: options.discardIgnored === true },
|
|
1691
1669
|
// Removing a worktree is not closing a feature: the branch is the work, and
|
|
@@ -1724,6 +1702,11 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
|
|
|
1724
1702
|
if (teardown.infraLeftUnlinked.length) out.infraLeftUnlinked = teardown.infraLeftUnlinked;
|
|
1725
1703
|
out.hint = teardown.declineReason;
|
|
1726
1704
|
}
|
|
1705
|
+
// After the new backup, never before — and never the new backup itself.
|
|
1706
|
+
if (teardown.ignoredBackup) {
|
|
1707
|
+
const retention = enforceBackupRetention(root, { protectStamps: [stamp] });
|
|
1708
|
+
if (retention) out.backupRetention = retention;
|
|
1709
|
+
}
|
|
1727
1710
|
emit(out, teardown.worktreeRemoved ? 0 : 1);
|
|
1728
1711
|
}
|
|
1729
1712
|
|
|
@@ -6,6 +6,7 @@ import { getFeature, setApprovalGate, derivePhase } from '../../core/state/state
|
|
|
6
6
|
import { unmetPrereqs } from '../../core/state/gate-order.js';
|
|
7
7
|
import { OUTPUT_SCHEMA } from '../../core/paths/output-schema.js';
|
|
8
8
|
import { assertOwnerTree, ownerTreeRefusalLines } from '../../lib/worktree/feature-truth.js';
|
|
9
|
+
import { uiuxAutoApprovalBlock } from '../../lib/specs/screen-checklist.js';
|
|
9
10
|
|
|
10
11
|
const FRAMEWORK_DIR = fileURLToPath(new URL('../../../framework/', import.meta.url));
|
|
11
12
|
|
|
@@ -169,6 +170,19 @@ export async function advanceCommand(featureName, options = {}) {
|
|
|
169
170
|
const gate = PHASE_GATE[phase];
|
|
170
171
|
const approvedBy = resolveApprovedBy(options);
|
|
171
172
|
|
|
173
|
+
// The screen checklist is part of what the uiux phase owes. The predicate
|
|
174
|
+
// is approve.js's too: an "auto" signature never closes a gate the
|
|
175
|
+
// gate-check would pause, and a recorded decision or a human signature
|
|
176
|
+
// (--approver) is the answer to that pause; --force is not, as in approve.
|
|
177
|
+
const checklistBlock = phase === 'uiux' ? uiuxAutoApprovalBlock(process.cwd(), featureName, feature) : null;
|
|
178
|
+
if (checklistBlock && approvedBy === 'auto') {
|
|
179
|
+
console.error(chalk.red(`\n❌ A fase "uiux" não pode ser encerrada com assinatura automática — ${checklistBlock}.`));
|
|
180
|
+
console.error(chalk.gray('\n Complete o checklist (ver a skill morph-uiux) e rode de novo. Quem decidiu aprovar assim'));
|
|
181
|
+
console.error(chalk.gray(` mesmo grava a decisão (morph-spec gate-decision ${featureName} uiux --pause false --reason "...")`));
|
|
182
|
+
console.error(chalk.gray(' ou assina com --approver <nome>. O --force não dispensa o checklist.\n'));
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
|
|
172
186
|
// Gate monotonicity (mirrors approve.js): advance is a gate write-path too,
|
|
173
187
|
// so it must not stamp gate N while a predecessor is pending. In the
|
|
174
188
|
// sanctioned flow advance is always in-order (it approves G1 before the plan
|
|
@@ -242,6 +256,10 @@ export async function advanceCommand(featureName, options = {}) {
|
|
|
242
256
|
if (missingArtifacts.length) {
|
|
243
257
|
console.log(chalk.yellow(` ⚠️ --force: avançou SEM ${missingArtifacts.join(', ')}.`));
|
|
244
258
|
}
|
|
259
|
+
if (checklistBlock) {
|
|
260
|
+
const by = options.force ? '--force' : `aprovado por ${approvedBy}`;
|
|
261
|
+
console.log(chalk.yellow(` ⚠️ ${by}: avançou com o ${checklistBlock}.`));
|
|
262
|
+
}
|
|
245
263
|
// Report what actually happened: claiming "approved (auto)" when nothing
|
|
246
264
|
// was written is how the destroyed signature stayed invisible.
|
|
247
265
|
if (gate && alreadyApproved) {
|
|
@@ -7,6 +7,7 @@ import { getFeature, setApprovalGate, getApprovalGate, getApprovalHistory, deriv
|
|
|
7
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
|
+
import { uiuxAutoApprovalBlock } from '../../lib/specs/screen-checklist.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* The literal token `review-report.md` carries to declare which tree it
|
|
@@ -308,6 +309,20 @@ export async function approveCommand(featureName, gateName, options = {}) {
|
|
|
308
309
|
process.exit(1);
|
|
309
310
|
}
|
|
310
311
|
|
|
312
|
+
// The same backstop when no gate-check ran: the pause it WOULD record for
|
|
313
|
+
// the screen checklist, by the predicate advance.js reads too. `--force`
|
|
314
|
+
// does not bypass it, for the reason above.
|
|
315
|
+
if (gateName === 'uiux' && approvedBy === 'auto') {
|
|
316
|
+
const block = uiuxAutoApprovalBlock(process.cwd(), featureName, feature);
|
|
317
|
+
if (block) {
|
|
318
|
+
console.error(chalk.red(`\n❌ O Gate "uiux" não pode ser auto-aprovado — ${block}.`));
|
|
319
|
+
console.error(chalk.gray(' Complete o checklist (ver a skill morph-uiux) e rode de novo, aprove com'));
|
|
320
|
+
console.error(chalk.gray(' --approver <nome>, ou, se a pausa já foi decidida, grave a decisão:'));
|
|
321
|
+
console.error(chalk.gray(` morph-spec gate-decision ${featureName} uiux --pause false --reason "<motivo>"\n`));
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
311
326
|
let selfAssessed = false;
|
|
312
327
|
if (gateName === 'review') {
|
|
313
328
|
if (feature.workType === 'hotfix') {
|
|
@@ -3,6 +3,8 @@ import { existsSync, renameSync, mkdirSync, readFileSync, writeFileSync } from '
|
|
|
3
3
|
import { join, resolve, sep } from 'path';
|
|
4
4
|
import { execFileSync } from 'child_process';
|
|
5
5
|
import { promoteUseCases, SPECS_USECASES_PATHSPEC } from '../../lib/specs/usecase-promoter.js';
|
|
6
|
+
import { applySpecRevocations } from '../../lib/specs/acervo-lifecycle.js';
|
|
7
|
+
import { ADR_INDEX_PATHSPEC } from '../../lib/specs/adr-index.js';
|
|
6
8
|
import { isValidFeatureName } from '../project/worktree.js';
|
|
7
9
|
|
|
8
10
|
/** Refuse a move whose resolved target escapes `scopeDir`. */
|
|
@@ -64,7 +66,11 @@ function readReviewApproved(fjPath) {
|
|
|
64
66
|
* @param {{ cwd?: string, force?: boolean, git?: boolean }} [opts]
|
|
65
67
|
* @returns {{ ok: boolean, error?: string, archived?: boolean, alreadyArchived?: boolean,
|
|
66
68
|
* committed?: boolean, staged?: boolean, archivePath?: string, promotedUseCases?: string[],
|
|
67
|
-
* refusedUseCases?: Array<{id: string, incoming: string, existing: string, existingFeature: string, message: string}
|
|
69
|
+
* refusedUseCases?: Array<{id: string, incoming: string, existing: string, existingFeature: string, message: string}>,
|
|
70
|
+
* keptSuperseded?: Array<{id: string, file: string, supersededBy: string|null, scope?: string}>,
|
|
71
|
+
* supersededUseCases?: Array<{id: string, file: string, supersededBy: string, scope?: string}>,
|
|
72
|
+
* revocationProblems?: Array<{id: string|null, reason: string, message: string}>,
|
|
73
|
+
* adrIndex?: {added: string[], revoked: string[], narrowed: string[]}|null }}
|
|
68
74
|
*/
|
|
69
75
|
export function runArchive(feature, opts = {}) {
|
|
70
76
|
const cwd = opts.cwd || process.cwd();
|
|
@@ -158,10 +164,12 @@ export function runArchive(feature, opts = {}) {
|
|
|
158
164
|
// never to a refused close.
|
|
159
165
|
let promotedUseCases = [];
|
|
160
166
|
let refusedUseCases = [];
|
|
167
|
+
let keptSuperseded = [];
|
|
161
168
|
try {
|
|
162
|
-
const { promoted, refused } = promoteUseCases(cwd, archivePath);
|
|
169
|
+
const { promoted, refused, keptSuperseded: kept } = promoteUseCases(cwd, archivePath);
|
|
163
170
|
promotedUseCases = promoted;
|
|
164
171
|
refusedUseCases = refused;
|
|
172
|
+
keptSuperseded = kept;
|
|
165
173
|
if (promoted.length && useGit && staged) {
|
|
166
174
|
// This staging exists for the NEW files: promoted use cases are untracked,
|
|
167
175
|
// and the scoped `commit -- <pathspec>` below only knows paths git already
|
|
@@ -173,17 +181,39 @@ export function runArchive(feature, opts = {}) {
|
|
|
173
181
|
}
|
|
174
182
|
} catch { /* promotion must never block archiving */ }
|
|
175
183
|
|
|
184
|
+
// 3b) Apply what the feature's spec.md declared it revokes: acervo use cases
|
|
185
|
+
// turn `superseded`, and the ADR index gains/loses files. After the
|
|
186
|
+
// promotion, so a substitute promoted just now is already in place (and
|
|
187
|
+
// one it refused is known not to be); same commit, same fail-open contract.
|
|
188
|
+
let lifecycle = { superseded: [], adrIndex: null, problems: [] };
|
|
189
|
+
try {
|
|
190
|
+
lifecycle = applySpecRevocations(cwd, archivePath, { refused: refusedUseCases });
|
|
191
|
+
} catch { /* revocation must never block archiving */ }
|
|
192
|
+
const acervoChanged = promotedUseCases.length > 0 || lifecycle.superseded.length > 0;
|
|
193
|
+
const adrIndexChanged = lifecycle.adrIndex?.changed === true;
|
|
194
|
+
if (useGit && staged) {
|
|
195
|
+
if (lifecycle.superseded.length) {
|
|
196
|
+
try { execFileSync('git', ['add', '-A', '--', SPECS_USECASES_PATHSPEC], { cwd, stdio: 'pipe' }); } catch { /* non-fatal */ }
|
|
197
|
+
}
|
|
198
|
+
if (adrIndexChanged) {
|
|
199
|
+
try { execFileSync('git', ['add', '-A', '--', ADR_INDEX_PATHSPEC], { cwd, stdio: 'pipe' }); } catch { /* non-fatal */ }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
176
203
|
// 4) Commit the move — SCOPED to this feature's pathspec, NEVER `commit -a`
|
|
177
204
|
// (same anti-sweep discipline as `finish`'s worktree teardown). This is what
|
|
178
205
|
// lets the archived state travel with the branch on `finish` (archive-before-merge).
|
|
179
|
-
// The acervo
|
|
180
|
-
// pathspec that matches nothing, and that refusal would leave the
|
|
181
|
-
// and break the checkout/merge that follows.
|
|
206
|
+
// The acervo pathspecs join only when something under them changed: git
|
|
207
|
+
// refuses a pathspec that matches nothing, and that refusal would leave the
|
|
208
|
+
// tree dirty and break the checkout/merge that follows.
|
|
182
209
|
let committed = false;
|
|
183
210
|
if (useGit && staged) {
|
|
184
|
-
const pathspecs =
|
|
185
|
-
|
|
186
|
-
|
|
211
|
+
const pathspecs = [
|
|
212
|
+
featPathspec,
|
|
213
|
+
archivePathspec,
|
|
214
|
+
...(acervoChanged ? [SPECS_USECASES_PATHSPEC] : []),
|
|
215
|
+
...(adrIndexChanged ? [ADR_INDEX_PATHSPEC] : []),
|
|
216
|
+
];
|
|
187
217
|
try {
|
|
188
218
|
execFileSync('git', ['commit', '-m', `morph-spec archive: ${feature}`, '--', ...pathspecs], { cwd, stdio: 'pipe' });
|
|
189
219
|
committed = true;
|
|
@@ -206,7 +236,14 @@ export function runArchive(feature, opts = {}) {
|
|
|
206
236
|
} catch { /* index is derivable — non-fatal */ }
|
|
207
237
|
}
|
|
208
238
|
|
|
209
|
-
|
|
239
|
+
const adr = lifecycle.adrIndex;
|
|
240
|
+
return {
|
|
241
|
+
ok: true, archived: true, committed, staged, archivePath, promotedUseCases, refusedUseCases,
|
|
242
|
+
keptSuperseded,
|
|
243
|
+
supersededUseCases: lifecycle.superseded,
|
|
244
|
+
revocationProblems: lifecycle.problems,
|
|
245
|
+
adrIndex: adrIndexChanged ? { added: adr.added, revoked: adr.revoked, narrowed: adr.narrowed } : null,
|
|
246
|
+
};
|
|
210
247
|
}
|
|
211
248
|
|
|
212
249
|
/**
|
|
@@ -253,6 +290,22 @@ export async function archiveCommand(featureName, options = {}) {
|
|
|
253
290
|
console.log(chalk.yellow(` ${r.message}`));
|
|
254
291
|
}
|
|
255
292
|
}
|
|
293
|
+
for (const k of result.keptSuperseded ?? []) {
|
|
294
|
+
console.log(chalk.yellow(` ${k.id} continua superseded (superseded_by: ${k.supersededBy ?? '?'}) — a cópia promovida não trazia o status, e o acervo o manteve.`));
|
|
295
|
+
}
|
|
296
|
+
for (const s of result.supersededUseCases ?? []) {
|
|
297
|
+
console.log(chalk.gray(` ${s.id} → status: superseded (superseded_by: ${s.supersededBy}${s.scope ? `; ainda vale em: ${s.scope}` : ''}).`));
|
|
298
|
+
}
|
|
299
|
+
if (result.adrIndex) {
|
|
300
|
+
const { added, revoked, narrowed } = result.adrIndex;
|
|
301
|
+
console.log(chalk.gray(` ADR index (.morph/specs/adrs/): +${added.length} −${revoked.length} ~${narrowed.length}.`));
|
|
302
|
+
}
|
|
303
|
+
if (result.revocationProblems?.length) {
|
|
304
|
+
console.log(chalk.yellow(`\n⚠️ ${result.revocationProblems.length} revogação(ões) do spec.md NÃO aplicada(s):`));
|
|
305
|
+
for (const p of result.revocationProblems) {
|
|
306
|
+
console.log(chalk.yellow(` ${p.message}`));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
256
309
|
if (result.committed) console.log(chalk.gray(` Move committed on the current branch.\n`));
|
|
257
310
|
else if (result.staged) console.log(chalk.gray(` Move staged in git; commit it when ready.\n`));
|
|
258
311
|
else console.log('');
|