@polymorphism-tech/morph-spec 8.11.0 → 8.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/morph-spec.js +2 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +3 -2
- package/framework/MORPH.md +5 -1
- package/framework/commands/morph-proposal.md +4 -3
- package/framework/hooks/claude-code/core/enforce-phase-writes.js +21 -9
- package/framework/hooks/claude-code/core/gate-guard.js +68 -35
- package/framework/hooks/claude-code/core/protect-spec-files.js +11 -6
- package/framework/hooks/claude-code/core/state-sync.js +66 -26
- package/framework/hooks/claude-code/core/trace-autogen.js +9 -4
- package/framework/hooks/claude-code/core/worktree-context.js +12 -3
- package/framework/hooks/shared/feature-locator.js +164 -0
- package/framework/hooks/shared/payload-utils.js +71 -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 +2 -2
- package/framework/skills/core/morph-brainstorming/SKILL.md +67 -32
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +9 -3
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +2 -2
- package/framework/skills/core/morph-proposal/SKILL.md +8 -9
- package/framework/skills/core/morph-review/SKILL.md +2 -2
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/project/claim.js +76 -7
- package/src/commands/state/advance.js +43 -0
- package/src/lib/validators/nextjs/next-component-validator.js +37 -1
- package/src/utils/hooks-installer.js +10 -2
|
@@ -4,19 +4,32 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Claims are acquired and heartbeaten AUTOMATICALLY (SessionStart hook +
|
|
6
6
|
* telemetry hook), so the operator never has to remember anything. This command
|
|
7
|
-
* exists for the
|
|
8
|
-
* releasing, leaving a claim that keeps
|
|
7
|
+
* exists for the two cases automation cannot cover:
|
|
8
|
+
* - a session that died without releasing, leaving a claim that keeps
|
|
9
|
+
* warning everyone until its TTL expires (`--release`);
|
|
10
|
+
* - an operator switching this SAME session/tree to a different feature
|
|
11
|
+
* mid-task. `morph-spec claim <feature>` (no flags) ACQUIRES it, moving
|
|
12
|
+
* the tree's previous claim in the same step — a manual release-then-
|
|
13
|
+
* reclaim was observed to just not happen mid-task, and the silent no-op
|
|
14
|
+
* this used to be (any unclaimed feature name printed "nothing here" and
|
|
15
|
+
* changed nothing) was worse: it looked like a query, not a skipped write.
|
|
9
16
|
*
|
|
10
|
-
*
|
|
11
|
-
* Always exits 0 except on a genuine misuse — the
|
|
12
|
-
* must never become a source of hard failures.
|
|
17
|
+
* `--list` forces the read-only view even when a feature is named. `--release`
|
|
18
|
+
* is the only other mutation. Always exits 0 except on a genuine misuse — the
|
|
19
|
+
* claim system is advisory and must never become a source of hard failures.
|
|
13
20
|
*/
|
|
14
21
|
|
|
15
22
|
import chalk from 'chalk';
|
|
16
23
|
import { execSync } from 'child_process';
|
|
17
24
|
import { resolve } from 'path';
|
|
18
25
|
import { isGitRepo, resolvePrimaryRoot } from './worktree.js';
|
|
19
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
acquireClaim,
|
|
28
|
+
foreignClaim,
|
|
29
|
+
listClaims,
|
|
30
|
+
releaseClaim,
|
|
31
|
+
claimAge,
|
|
32
|
+
} from '../../../framework/hooks/shared/claim-registry.js';
|
|
20
33
|
|
|
21
34
|
/** Resolve the repo's shared git common dir — where claims live. */
|
|
22
35
|
function gitCommonDirOf(cwd) {
|
|
@@ -25,6 +38,52 @@ function gitCommonDirOf(cwd) {
|
|
|
25
38
|
return common ? resolve(root, common) : null;
|
|
26
39
|
}
|
|
27
40
|
|
|
41
|
+
const norm = (p) => String(p ?? '').replace(/\\/g, '/');
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Acquire `feature`'s claim, moving it in the same step if this tree already
|
|
45
|
+
* holds a live claim on a different feature.
|
|
46
|
+
*
|
|
47
|
+
* "This tree's own claim" is identified by PATH, not session id: the CLI has
|
|
48
|
+
* no access to the Claude Code session id SessionStart stamps (that only
|
|
49
|
+
* reaches hooks, via stdin JSON) — path is the one honest signal a manual
|
|
50
|
+
* invocation shares with the automatic claim it may be replacing. Re-using
|
|
51
|
+
* that claim's session id when re-acquiring keeps the move from reading as a
|
|
52
|
+
* takeover of a stranger.
|
|
53
|
+
*/
|
|
54
|
+
function acquireOrMove(feature, { cwd, gitCommonDir, json }) {
|
|
55
|
+
const here = norm(cwd);
|
|
56
|
+
const previousHere = listClaims(gitCommonDir)
|
|
57
|
+
.find((c) => c.live && c.feature !== feature && norm(c.path) === here);
|
|
58
|
+
const takenFrom = foreignClaim(feature, { gitCommonDir, sessionId: previousHere?.sessionId });
|
|
59
|
+
|
|
60
|
+
if (previousHere) releaseClaim(previousHere.feature, { gitCommonDir });
|
|
61
|
+
const identity = previousHere?.sessionId || `cli-${process.pid}`;
|
|
62
|
+
acquireClaim(feature, { gitCommonDir, sessionId: identity, pid: process.pid, path: cwd });
|
|
63
|
+
|
|
64
|
+
if (json) {
|
|
65
|
+
console.log(JSON.stringify({
|
|
66
|
+
feature,
|
|
67
|
+
claimed: true,
|
|
68
|
+
movedFrom: previousHere?.feature ?? null,
|
|
69
|
+
tookOverFrom: takenFrom ? { pid: takenFrom.pid, path: takenFrom.path } : null,
|
|
70
|
+
}));
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log(previousHere
|
|
75
|
+
? chalk.green(`\n✅ Claim movido de "${previousHere.feature}" para "${feature}".\n`)
|
|
76
|
+
: chalk.green(`\n✅ Claim de "${feature}" adquirido.\n`));
|
|
77
|
+
if (takenFrom) {
|
|
78
|
+
console.log(chalk.yellow(
|
|
79
|
+
`⚠️ "${feature}" tinha claim VIVO de outra sessão (pid ${takenFrom.pid}` +
|
|
80
|
+
`${takenFrom.path ? ` · ${takenFrom.path}` : ''}, visto há ${claimAge(takenFrom)}) — assumido agora. ` +
|
|
81
|
+
'Confirme com a outra sessão antes de finish/archive/delete.\n'
|
|
82
|
+
));
|
|
83
|
+
}
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
86
|
+
|
|
28
87
|
/**
|
|
29
88
|
* @param {string|undefined} feature
|
|
30
89
|
* @param {{ release?: boolean, list?: boolean, json?: boolean, cwd?: string }} [options]
|
|
@@ -61,7 +120,17 @@ export function claimCommand(feature, options = {}) {
|
|
|
61
120
|
process.exit(0);
|
|
62
121
|
}
|
|
63
122
|
|
|
64
|
-
//
|
|
123
|
+
// `morph-spec claim <feature>` (no --release, no --list) is the manual
|
|
124
|
+
// acquire/move half of B1 — automation (SessionStart + heartbeat) covers the
|
|
125
|
+
// common case; this covers switching this session to a different feature
|
|
126
|
+
// mid-task without a release-then-reclaim dance. `--list` forces the
|
|
127
|
+
// read-only view below even when a feature is named.
|
|
128
|
+
if (feature && !options.list) {
|
|
129
|
+
acquireOrMove(feature, { cwd, gitCommonDir, json: options.json });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// No feature (or explicit --list): the read-only board, whole or filtered.
|
|
65
134
|
const all = listClaims(gitCommonDir);
|
|
66
135
|
const rows = feature ? all.filter((c) => c.feature === feature) : all;
|
|
67
136
|
|
|
@@ -67,6 +67,34 @@ function missingPhaseArtifacts(phase, featurePath) {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* When a required artifact is absent HERE, check whether it exists in the
|
|
72
|
+
* worktree that owns this feature. `feature.json` travels committed on
|
|
73
|
+
* `morph/{feature}`, so a feature developed in a worktree has its artifacts
|
|
74
|
+
* there and only a scaffold in the primary root — running `advance` from the
|
|
75
|
+
* root then demanded a file that already exists, one directory over.
|
|
76
|
+
*
|
|
77
|
+
* Message-only: this never reads state for a decision and never writes across
|
|
78
|
+
* trees. It replaces a misleading error with an accurate one.
|
|
79
|
+
*
|
|
80
|
+
* Fail-open — no git, no worktree, or any error yields null (today's message).
|
|
81
|
+
*
|
|
82
|
+
* @returns {Promise<{path: string, branch: string|null, present: string[]}|null>}
|
|
83
|
+
*/
|
|
84
|
+
async function artifactsLiveInWorktree(featureName, missing) {
|
|
85
|
+
try {
|
|
86
|
+
const { findWorktreeForFeature } = await import('../project/worktree.js');
|
|
87
|
+
const wt = findWorktreeForFeature(featureName, process.cwd());
|
|
88
|
+
if (!wt?.path) return null;
|
|
89
|
+
|
|
90
|
+
const base = join(wt.path, '.morph', 'features', featureName);
|
|
91
|
+
const present = missing.filter((rel) => existsSync(join(base, rel)));
|
|
92
|
+
return present.length ? { path: wt.path, branch: wt.branch, present } : null;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
70
98
|
/** Resolve who approved, honestly (mirrors approve.js — no fake human signatures). */
|
|
71
99
|
function resolveApprovedBy(options) {
|
|
72
100
|
if (options.approver) return options.approver;
|
|
@@ -104,6 +132,21 @@ export async function advanceCommand(featureName, options = {}) {
|
|
|
104
132
|
// the defect again.
|
|
105
133
|
const missingArtifacts = missingPhaseArtifacts(phase, featurePath);
|
|
106
134
|
if (missingArtifacts.length && !options.force) {
|
|
135
|
+
// Divergence, not absence: name the tree that actually holds the work
|
|
136
|
+
// instead of asking for a file that exists in it.
|
|
137
|
+
const elsewhere = await artifactsLiveInWorktree(featureName, missingArtifacts);
|
|
138
|
+
if (elsewhere) {
|
|
139
|
+
console.error(chalk.red(`\n❌ A fase "${phase}" de "${featureName}" não pode ser encerrada A PARTIR DESTA ÁRVORE.`));
|
|
140
|
+
console.error(chalk.gray(' Os artefatos não estão ausentes — eles vivem no worktree dono desta feature:'));
|
|
141
|
+
for (const rel of elsewhere.present) console.error(chalk.gray(` • ${rel}`));
|
|
142
|
+
console.error(chalk.gray(`\n Worktree: ${elsewhere.path}${elsewhere.branch ? ` (branch ${elsewhere.branch})` : ''}`));
|
|
143
|
+
console.error(chalk.gray(' Rode o comando LÁ — o gate é gravado no feature.json daquela árvore:'));
|
|
144
|
+
console.error(chalk.cyan(` cd "${elsewhere.path}" && morph-spec advance ${featureName}`));
|
|
145
|
+
console.error(chalk.yellow('\n NÃO use --force aqui: ele carimbaria o gate na cópia da raiz, duplicando'));
|
|
146
|
+
console.error(chalk.yellow(' uma aprovação que a outra árvore já tem e invertendo a ordem dos gates.\n'));
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
|
|
107
150
|
console.error(chalk.red(`\n❌ A fase "${phase}" não pode ser encerrada — artefato(s) obrigatório(s) ausente(s):`));
|
|
108
151
|
for (const rel of missingArtifacts) console.error(chalk.gray(` • ${rel}`));
|
|
109
152
|
console.error(chalk.gray('\n Produza o artefato e rode de novo, ou passe --force para avançar assim mesmo.\n'));
|
|
@@ -28,8 +28,34 @@ const EVENT_HANDLER_PATTERNS = [
|
|
|
28
28
|
/\bonFocus\b/,
|
|
29
29
|
/\bonBlur\b/,
|
|
30
30
|
/\bonKeyDown\b/,
|
|
31
|
+
// Qualquer outro handler de evento em JSX (onMouseEnter, onKeyUp, onScroll, onDrop…).
|
|
32
|
+
// Sem isto, a lista fechada acima transforma interatividade legítima em falso-positivo.
|
|
33
|
+
/\bon[A-Z]\w*\s*=\s*\{/,
|
|
31
34
|
];
|
|
32
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Chamada de QUALQUER hook, não só os sete nativos: `useRouter()`, `useQuery()`, `useForm<T>()`.
|
|
38
|
+
* O `[<(]` é o que faz `useState<Record<string, string>>(` contar — um hook com argumento de
|
|
39
|
+
* tipo não casa com `useState\s*\(` e passava despercebido.
|
|
40
|
+
*/
|
|
41
|
+
const HOOK_CALL_PATTERN = /\buse[A-Z]\w*\s*[<(]/;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* APIs de React que só existem no cliente. `forwardRef` e `createContext` lançam em Server
|
|
45
|
+
* Component, então um módulo que os usa PRECISA da diretiva, mesmo sem hook nem handler.
|
|
46
|
+
*/
|
|
47
|
+
const CLIENT_ONLY_APIS = [/\bforwardRef\b/, /\bcreateContext\b/];
|
|
48
|
+
|
|
49
|
+
/** Globais de browser: quem os toca não roda no servidor. */
|
|
50
|
+
const BROWSER_GLOBALS = /\b(window|document|localStorage|sessionStorage|navigator)\s*[.[]/;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Pacotes cujos componentes são client-only. O caso que mais gerava ruído é o wrapper de
|
|
54
|
+
* shadcn/Radix: ele só reexporta primitivas, então não tem hook nem handler no próprio
|
|
55
|
+
* arquivo — e era acusado para sempre, apesar de estar correto.
|
|
56
|
+
*/
|
|
57
|
+
const CLIENT_ONLY_IMPORTS = /from\s+['"](@radix-ui\/|sonner|@react-pdf\/|file-saver|framer-motion|motion\/|recharts|next\/navigation|react-hook-form|@tanstack\/react-query|embla-carousel|cmdk|vaul|react-day-picker|input-otp|@dnd-kit\/)/;
|
|
58
|
+
|
|
33
59
|
/** Next.js special files exempt from naming checks */
|
|
34
60
|
const NEXTJS_SPECIAL_FILES = ['page.tsx', 'layout.tsx', 'loading.tsx', 'error.tsx', 'not-found.tsx', 'route.ts', 'middleware.ts'];
|
|
35
61
|
|
|
@@ -75,7 +101,17 @@ export function validateNextComponent(content, filePath) {
|
|
|
75
101
|
if (hasUseClient) {
|
|
76
102
|
const hasEventHandlers = EVENT_HANDLER_PATTERNS.some(pattern => pattern.test(content));
|
|
77
103
|
const hasClientHooks = CLIENT_HOOKS.some(hook => new RegExp(`\\b${hook}\\b`).test(content));
|
|
78
|
-
|
|
104
|
+
// Só acusa quando NENHUM sinal de necessidade de cliente aparece. Um falso-positivo aqui
|
|
105
|
+
// pede a remoção de uma diretiva que o build precisa, então a checagem erra para o lado
|
|
106
|
+
// de ficar calada.
|
|
107
|
+
const needsClient =
|
|
108
|
+
hasEventHandlers ||
|
|
109
|
+
hasClientHooks ||
|
|
110
|
+
HOOK_CALL_PATTERN.test(content) ||
|
|
111
|
+
CLIENT_ONLY_APIS.some(pattern => pattern.test(content)) ||
|
|
112
|
+
BROWSER_GLOBALS.test(content) ||
|
|
113
|
+
CLIENT_ONLY_IMPORTS.test(content);
|
|
114
|
+
if (!needsClient) {
|
|
79
115
|
issues.push({
|
|
80
116
|
type: 'warning',
|
|
81
117
|
message: "'use client' directive present but no interactivity detected (no hooks, no event handlers)",
|
|
@@ -14,8 +14,16 @@ import { existsSync, chmodSync, readdirSync } from 'fs';
|
|
|
14
14
|
import { homedir } from 'os';
|
|
15
15
|
import { execSync } from 'child_process';
|
|
16
16
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Current hooks schema version — bump when hook definitions change.
|
|
19
|
+
*
|
|
20
|
+
* 3.5.0 keeps the same registry entries but changes what the installed hooks
|
|
21
|
+
* DO: state is now resolved from the target path's working tree instead of the
|
|
22
|
+
* session's. The hook files are re-copied unconditionally (`copyDirectory`
|
|
23
|
+
* defaults to `overwrite: true`), so the bump does not gate delivery — it makes
|
|
24
|
+
* `morph-spec update` report the change honestly instead of "already current".
|
|
25
|
+
*/
|
|
26
|
+
const HOOKS_VERSION = '3.5.0';
|
|
19
27
|
|
|
20
28
|
/** Marker for old dispatch.js (v1) */
|
|
21
29
|
const OLD_DISPATCH_COMMAND = 'node framework/hooks/agent-teams/dispatch.js';
|