@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.
Files changed (33) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +2 -2
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +3 -2
  7. package/framework/MORPH.md +5 -1
  8. package/framework/commands/morph-proposal.md +4 -3
  9. package/framework/hooks/claude-code/core/enforce-phase-writes.js +21 -9
  10. package/framework/hooks/claude-code/core/gate-guard.js +68 -35
  11. package/framework/hooks/claude-code/core/protect-spec-files.js +11 -6
  12. package/framework/hooks/claude-code/core/state-sync.js +66 -26
  13. package/framework/hooks/claude-code/core/trace-autogen.js +9 -4
  14. package/framework/hooks/claude-code/core/worktree-context.js +12 -3
  15. package/framework/hooks/shared/feature-locator.js +164 -0
  16. package/framework/hooks/shared/payload-utils.js +71 -0
  17. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  18. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  19. package/framework/skills/core/morph-apply/SKILL.md +2 -2
  20. package/framework/skills/core/morph-brainstorming/SKILL.md +67 -32
  21. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  22. package/framework/skills/core/morph-hotfix/SKILL.md +9 -3
  23. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  24. package/framework/skills/core/morph-plan/SKILL.md +2 -2
  25. package/framework/skills/core/morph-proposal/SKILL.md +8 -9
  26. package/framework/skills/core/morph-review/SKILL.md +2 -2
  27. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  28. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  29. package/package.json +1 -1
  30. package/src/commands/project/claim.js +76 -7
  31. package/src/commands/state/advance.js +43 -0
  32. package/src/lib/validators/nextjs/next-component-validator.js +37 -1
  33. 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 case automation cannot cover: a session that died without
8
- * releasing, leaving a claim that keeps warning everyone until its TTL expires.
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
- * Read-only by default (`--list`); the only mutation is `--release`.
11
- * Always exits 0 except on a genuine misuse — the claim system is advisory and
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 { listClaims, releaseClaim, claimAge } from '../../../framework/hooks/shared/claim-registry.js';
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
- // Default and --list are the same read: the whole board, or one feature.
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
- if (!hasEventHandlers && !hasClientHooks) {
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
- /** Current hooks schema version — bump when hook definitions change */
18
- const HOOKS_VERSION = '3.4.0';
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';