@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.
Files changed (130) hide show
  1. package/CLAUDE.md +19 -6
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +16 -7
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +3 -3
  8. package/framework/MORPH.md +25 -22
  9. package/framework/agents.json +76 -27
  10. package/framework/docs/CLI.md +55 -18
  11. package/framework/evals/rubrics/code-quality.md +5 -0
  12. package/framework/evals/rubrics/condensed.md +6 -2
  13. package/framework/evals/rubrics/contracts.md +20 -0
  14. package/framework/evals/rubrics/test-coverage.md +17 -6
  15. package/framework/hooks/README.md +9 -3
  16. package/framework/hooks/claude-code/core/graph-refresh-kick.js +52 -0
  17. package/framework/hooks/claude-code/core/telemetry-log.js +172 -87
  18. package/framework/hooks/claude-code/core/validate-completion.js +16 -90
  19. package/framework/hooks/claude-code/core/worktree-context.js +10 -0
  20. package/framework/hooks/shared/command-tokens.js +96 -0
  21. package/framework/hooks/shared/git-exclude.js +44 -0
  22. package/framework/hooks/shared/git-paths.js +52 -0
  23. package/framework/hooks/shared/graph-freshness.js +380 -0
  24. package/framework/hooks/shared/graph-refresh.js +416 -0
  25. package/framework/hooks/shared/morph-cli.js +17 -5
  26. package/framework/hooks/shared/redact-secrets.js +55 -0
  27. package/framework/hooks/shared/telemetry-logger.js +25 -2
  28. package/framework/hooks/shared/transcript-usage.js +80 -0
  29. package/framework/rubrics/backend.json +2 -2
  30. package/framework/rubrics/default.json +2 -2
  31. package/framework/rubrics/frontend.json +2 -2
  32. package/framework/rules/csharp-standards.md +1 -0
  33. package/framework/rules/morph-workflow.md +1 -0
  34. package/framework/rules/nextjs-standards.md +1 -0
  35. package/framework/schemas/config.schema.json +41 -1
  36. package/framework/schemas/feature.schema.json +5 -1
  37. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  38. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  39. package/framework/skills/core/morph-apply/SKILL.md +36 -11
  40. package/framework/skills/core/morph-brainstorming/SKILL.md +16 -7
  41. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  42. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  43. package/framework/skills/core/morph-implement/SKILL.md +39 -10
  44. package/framework/skills/core/morph-media/SKILL.md +1 -1
  45. package/framework/skills/core/morph-plan/SKILL.md +72 -15
  46. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  47. package/framework/skills/core/morph-review/SKILL.md +51 -13
  48. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  49. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  50. package/framework/skills/core/morph-uiux/SKILL.md +34 -9
  51. package/framework/standards/STANDARDS.json +187 -12
  52. package/framework/standards/ai-agents/rag-custom-pgvector.md +7 -3
  53. package/framework/standards/ai-agents/testing-ai.md +2 -1
  54. package/framework/standards/architecture/code-comments.md +64 -0
  55. package/framework/standards/backend/dotnet/testing.md +22 -0
  56. package/framework/standards/frontend/admin-app/data-table.md +562 -0
  57. package/framework/standards/frontend/admin-app/feedback.md +236 -0
  58. package/framework/standards/frontend/admin-app/forms.md +232 -0
  59. package/framework/standards/frontend/admin-app/page-structure.md +203 -0
  60. package/framework/standards/frontend/nextjs/components.md +37 -58
  61. package/framework/standards/frontend/nextjs/data-fetching.md +1 -0
  62. package/framework/standards/frontend/nextjs/forms.md +31 -30
  63. package/framework/standards/frontend/nextjs/state-management.md +2 -1
  64. package/framework/standards/frontend/nextjs/testing.md +18 -0
  65. package/framework/templates/context/design-system.md +3 -3
  66. package/framework/templates/docs/proposal.md +11 -0
  67. package/framework/templates/docs/spec.md +15 -0
  68. package/framework/templates/docs/ui-mockups.md +115 -98
  69. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaPlan.cs +12 -6
  70. package/framework/templates/feature/decisions.md +124 -122
  71. package/package.json +1 -1
  72. package/src/commands/graph/affected.js +4 -3
  73. package/src/commands/graph/build.js +109 -18
  74. package/src/commands/graph/explain.js +8 -5
  75. package/src/commands/graph/status.js +14 -8
  76. package/src/commands/observability/cost.js +148 -18
  77. package/src/commands/project/doctor.js +82 -10
  78. package/src/commands/project/update.js +25 -0
  79. package/src/commands/project/worktree.js +73 -90
  80. package/src/commands/state/advance.js +18 -0
  81. package/src/commands/state/approve.js +15 -0
  82. package/src/commands/state/archive.js +62 -9
  83. package/src/commands/state/finish.js +62 -14
  84. package/src/commands/state/gate-check.js +8 -1
  85. package/src/commands/state/gate-decision.js +8 -4
  86. package/src/commands/state/report-check.js +90 -9
  87. package/src/commands/state/usecase.js +66 -0
  88. package/src/commands/tasks/dag.js +17 -2
  89. package/src/commands/validation/validate-feature.js +6 -1
  90. package/src/commands/validation/verify.js +20 -1
  91. package/src/dashboard/scanner/agents-scanner.js +5 -1
  92. package/src/dashboard/scanner/hooks-scanner.js +8 -1
  93. package/src/dashboard/scanner/standards-scanner.js +2 -1
  94. package/src/lib/ai/comment-stripper.js +101 -4
  95. package/src/lib/evaluator-report.js +25 -0
  96. package/src/lib/generators/handoff-generator.js +4 -18
  97. package/src/lib/git/default-branch.js +52 -0
  98. package/src/lib/git/tree-fingerprint.js +58 -0
  99. package/src/lib/git/tree-state.js +167 -0
  100. package/src/lib/graph/god-nodes.js +12 -3
  101. package/src/lib/graph/graph-health.js +48 -2
  102. package/src/lib/graph/graph-loader.js +99 -25
  103. package/src/lib/graph/graphifyignore.js +145 -28
  104. package/src/lib/graph/sanitize.js +117 -11
  105. package/src/lib/risk-detector.js +90 -0
  106. package/src/lib/specs/acervo-format.js +70 -0
  107. package/src/lib/specs/acervo-lifecycle.js +264 -0
  108. package/src/lib/specs/adr-index.js +169 -0
  109. package/src/lib/specs/revocations.js +156 -0
  110. package/src/lib/specs/screen-checklist.js +357 -0
  111. package/src/lib/specs/usecase-check.js +20 -16
  112. package/src/lib/specs/usecase-promoter.js +103 -38
  113. package/src/lib/specs/usecase-registry.js +7 -7
  114. package/src/lib/tasks/tasks-json-parser.js +2 -3
  115. package/src/lib/telemetry/metrics.js +386 -0
  116. package/src/lib/telemetry/telemetry-reader.js +161 -51
  117. package/src/lib/telemetry/verify-event.js +79 -0
  118. package/src/lib/validators/code-budget/code-budget-validator.js +172 -0
  119. package/src/lib/validators/code-budget/comment-scanner.js +75 -0
  120. package/src/lib/validators/code-budget/index.js +9 -0
  121. package/src/lib/validators/reuse/reuse-validator.js +4 -2
  122. package/src/lib/validators/reuse/symbol-scanner.js +27 -22
  123. package/src/lib/validators/validation-runner.js +21 -5
  124. package/src/lib/validators/verify-runner.js +11 -0
  125. package/src/lib/worktree/finish-backup.js +739 -0
  126. package/src/lib/worktree/ignored-triage.js +15 -8
  127. package/src/lib/worktree/worktree-audit.js +5 -0
  128. package/src/scripts/setup-infra.js +29 -18
  129. package/src/utils/agents-installer.js +281 -12
  130. package/src/utils/hooks-installer.js +75 -15
@@ -0,0 +1,416 @@
1
+ /**
2
+ * graph-refresh.js — refresh em background do grafo da árvore PRINCIPAL.
3
+ *
4
+ * Quem dispara: o elo `graph-refresh-kick` (PostToolUse, depois de um comando
5
+ * Bash ou PowerShell com git/gh/morph-spec) e o SessionStart
6
+ * (`worktree-context`). Os dois chamam `kickGraphRefresh`, que só olha o disco
7
+ * e solta este mesmo arquivo como processo DESTACADO (`--run <raiz>`): quem
8
+ * dispara nunca espera o graphify, que custa ~80 s no MORPH_GHLBrain mesmo sem
9
+ * mudança nenhuma.
10
+ *
11
+ * Só a raiz. O processo destacado segura o diretório em que roda, e no win32
12
+ * um diretório em uso não se apaga: com o refresh de uma worktree correndo, o
13
+ * `finish` e o `worktree remove` falhavam com "Permission denied" e deixavam a
14
+ * worktree meio removida. Por isso o worker nasce com `cwd` no git common dir,
15
+ * nunca recebe o caminho de uma worktree, e o `graphify update` roda só na
16
+ * raiz — que nenhum `finish` remove. A worktree nasce com o grafo semeado da
17
+ * raiz (`seedGraphFromRoot`) e, dali em diante, é atualizada por
18
+ * `morph-spec graph refresh`, em primeiro plano.
19
+ *
20
+ * Por que não o hook git do próprio graphify (`graphify hook install`), medido
21
+ * no 0.9.50:
22
+ * - ele sai sem fazer nada numa worktree vinculada (compara `--git-dir` com
23
+ * `--git-common-dir`);
24
+ * - não instala `post-merge`: merge, fast-forward e `git pull` — o caminho
25
+ * pelo qual toda feature volta para a raiz — nunca rebuildam;
26
+ * - o rebuild dele só re-extrai `git diff HEAD~1 HEAD`; um commit depois de
27
+ * um fast-forward carimba `built_at_commit` = HEAD num grafo SEM os
28
+ * arquivos que o merge trouxe — "fresh" mentiroso.
29
+ * Aqui o critério é o da regra única (graph-freshness.js): o carimbo e o
30
+ * registro da última rodada contra o HEAD, e as regras do ignore por conteúdo.
31
+ *
32
+ * Um lock por REPOSITÓRIO (no git common dir) serializa o refresh em
33
+ * background com o `graph build|refresh` manual — cada `graphify update` usa
34
+ * todos os núcleos e os dois escreveriam o mesmo graph.json. O dono renova o
35
+ * carimbo do lock antes de cada rodada, e a checagem é refeita depois de
36
+ * soltá-lo, para não perder o commit que chegou enquanto ele era segurado.
37
+ *
38
+ * Fail-open em tudo: sem git, sem graphify ou sem grafo, nada acontece.
39
+ *
40
+ * @module graph-refresh
41
+ */
42
+
43
+ import { spawn, spawnSync } from 'child_process';
44
+ import {
45
+ appendFileSync, closeSync, copyFileSync, existsSync, mkdirSync, openSync,
46
+ readFileSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync,
47
+ } from 'fs';
48
+ import { dirname, join, resolve } from 'path';
49
+ import { fileURLToPath } from 'url';
50
+ import { readPrimaryHead, resolveGitCommonDir, workTreeTop } from './git-paths.js';
51
+ import { findFirstOnPath, quoteForCmd } from './morph-cli.js';
52
+ import { ensureCommonExclude } from './git-exclude.js';
53
+ import {
54
+ GRAPH_DIR, graphFile, graphFreshness, ignoreRulesHash, primaryTree, readRefreshRecord,
55
+ recordRefreshRun, refreshPending, treeKey, writeRefreshRecord,
56
+ } from './graph-freshness.js';
57
+
58
+ export { primaryTree };
59
+
60
+ /**
61
+ * O que uma worktree herda do grafo da raiz. Fora da lista, de propósito:
62
+ * `.graphify_root` guarda o caminho ABSOLUTO da raiz, e `cache/` é indexado
63
+ * pelo mtime da árvore de origem — medido: copiado, não poupou re-extração.
64
+ * O `.graphifyignore` também não vem: a worktree lê o do próprio checkout, e
65
+ * pôr `/.graphifyignore` no exclude do common dir escondia também o da RAIZ
66
+ * ainda não versionado (o `git add` dele passava a ser recusado).
67
+ */
68
+ const SEED_FILES = ['manifest.json', '.graphify_analysis.json', '.graphify_labels.json', 'graph.json'];
69
+
70
+ const EXCLUDE_MARKER = '# morph-spec: codebase graph inside worktrees (managed — see graph-refresh)';
71
+
72
+ const WORKER = fileURLToPath(import.meta.url);
73
+ const LOCK_MAX_AGE_MS = 30 * 60 * 1000;
74
+ const LOCK_WRITE_GRACE_MS = 5000;
75
+ const GRAPHIFY_TIMEOUT_MS = 20 * 60 * 1000;
76
+ const MAX_ROUNDS = 3;
77
+ const LOG_MAX_BYTES = 256 * 1024;
78
+
79
+ /**
80
+ * @param {string} dir
81
+ * @returns {boolean} `dir` está na árvore principal (o refresh automático a cobre)
82
+ */
83
+ export function isPrimaryTree(dir) {
84
+ const top = dir ? workTreeTop(dir) : null;
85
+ const primary = primaryTree(dir);
86
+ return Boolean(top && primary && treeKey(top) === treeKey(primary));
87
+ }
88
+
89
+ function stateDir(dir) {
90
+ const common = resolveGitCommonDir(dir);
91
+ return common ? join(common, 'morph-spec') : null;
92
+ }
93
+
94
+ /** @param {string} dir */
95
+ export function refreshLockPath(dir) {
96
+ const d = stateDir(dir);
97
+ return d ? join(d, 'graph-refresh.lock') : null;
98
+ }
99
+
100
+ function refreshLogPath(dir) {
101
+ const d = stateDir(dir);
102
+ return d ? join(d, 'graph-refresh.log') : null;
103
+ }
104
+
105
+ function mtimeOf(path) {
106
+ try {
107
+ return statSync(path).mtimeMs;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ function pidAlive(pid) {
114
+ try {
115
+ process.kill(pid, 0);
116
+ return true;
117
+ } catch (err) {
118
+ return err?.code === 'EPERM';
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Estado do lock do repositório — o que `graph status` mostra como "refresh em
124
+ * andamento". A idade conta da última renovação: um lock de processo morto,
125
+ * ou sem renovação há mais que o teto, é `live: false`, e o próximo refresh o
126
+ * recupera.
127
+ *
128
+ * @param {string} dir
129
+ * @param {{now?: number, isAlive?: (pid: number) => boolean}} [deps]
130
+ * @returns {{pid: number, since: string, renewed?: string, live: boolean}|null}
131
+ */
132
+ export function readRefreshLock(dir, { now = Date.now(), isAlive = pidAlive } = {}) {
133
+ const path = refreshLockPath(dir);
134
+ if (!path) return null;
135
+ try {
136
+ const lock = JSON.parse(readFileSync(path, 'utf8'));
137
+ const age = now - Date.parse(lock.renewed ?? lock.since);
138
+ return { ...lock, live: Boolean(isAlive(lock.pid)) && age >= 0 && age < LOCK_MAX_AGE_MS };
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * @param {string} dir
146
+ * @param {{isAlive?: (pid: number) => boolean}} [deps]
147
+ * @returns {string|null} o caminho do lock adquirido, ou null (ocupado ou sem git)
148
+ */
149
+ export function acquireRefreshLock(dir, deps = {}) {
150
+ const path = refreshLockPath(dir);
151
+ if (!path) return null;
152
+ try { mkdirSync(dirname(path), { recursive: true }); } catch { return null; }
153
+ for (let attempt = 0; attempt < 2; attempt++) {
154
+ try {
155
+ const fd = openSync(path, 'wx');
156
+ writeSync(fd, JSON.stringify({ pid: process.pid, since: new Date().toISOString() }));
157
+ closeSync(fd);
158
+ return path;
159
+ } catch (err) {
160
+ if (err?.code !== 'EEXIST') return null;
161
+ const current = readRefreshLock(dir, deps);
162
+ if (current?.live) return null;
163
+ // Ilegível e recém-criado é o dono ainda escrevendo entre o `wx` e o
164
+ // conteúdo — roubá-lo aqui poria dois graphify no mesmo graph.json.
165
+ const age = Date.now() - (mtimeOf(path) ?? 0);
166
+ if (!current && age < LOCK_WRITE_GRACE_MS) return null;
167
+ try { unlinkSync(path); } catch { return null; }
168
+ }
169
+ }
170
+ return null;
171
+ }
172
+
173
+ /**
174
+ * Renova o carimbo do lock, só se ainda for deste processo. Chamado antes de
175
+ * cada rodada do graphify: uma posse que atravessa várias rodadas passaria do
176
+ * teto de idade e seria roubada no meio de uma escrita.
177
+ *
178
+ * @param {string|null} path
179
+ */
180
+ export function renewRefreshLock(path) {
181
+ if (!path) return;
182
+ try {
183
+ const lock = JSON.parse(readFileSync(path, 'utf8'));
184
+ if (lock.pid !== process.pid) return;
185
+ writeFileSync(path, JSON.stringify({ ...lock, renewed: new Date().toISOString() }));
186
+ } catch { /* solto por outro — o release confere de novo */ }
187
+ }
188
+
189
+ /** Solta o lock só se ainda for deste processo. */
190
+ export function releaseRefreshLock(path) {
191
+ if (!path) return;
192
+ try {
193
+ if (JSON.parse(readFileSync(path, 'utf8')).pid === process.pid) unlinkSync(path);
194
+ } catch { /* já solto */ }
195
+ }
196
+
197
+ function appendRefreshLog(dir, entry) {
198
+ const path = refreshLogPath(dir);
199
+ if (!path) return;
200
+ try {
201
+ mkdirSync(dirname(path), { recursive: true });
202
+ if (existsSync(path) && statSync(path).size > LOG_MAX_BYTES) {
203
+ const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean);
204
+ writeFileSync(path, `${lines.slice(-200).join('\n')}\n`);
205
+ }
206
+ appendFileSync(path, `${JSON.stringify({ ts: new Date().toISOString(), ...entry })}\n`);
207
+ } catch { /* log é diagnóstico, nunca bloqueia */ }
208
+ }
209
+
210
+ /**
211
+ * `graphify` no PATH. No win32 o libuv só acha `.exe` sem shell; `.cmd`/`.bat`
212
+ * (o que um shim ou um dublê de teste instala) passa pelo shell.
213
+ *
214
+ * @param {NodeJS.ProcessEnv} [env]
215
+ * @returns {{file: string, shell: boolean}|null}
216
+ */
217
+ export function resolveGraphify(env = process.env) {
218
+ const names = process.platform === 'win32'
219
+ ? ['graphify.exe', 'graphify.cmd', 'graphify.bat']
220
+ : ['graphify'];
221
+ const file = findFirstOnPath(names, env);
222
+ return file ? { file, shell: /\.(cmd|bat)$/i.test(file) } : null;
223
+ }
224
+
225
+ /**
226
+ * `graphify <args>` síncrono, com `cwd` em `tree`. É o runner do refresh em
227
+ * background E do `graph build|refresh` manual: o mesmo binário resolvido e o
228
+ * mesmo `PYTHONHASHSEED=0` — é o que o hook do próprio graphify fixa para o
229
+ * clustering não trocar as comunidades de lugar a cada rodada.
230
+ *
231
+ * @param {string} tree
232
+ * @param {string[]} args
233
+ * @param {{env?: NodeJS.ProcessEnv, timeout?: number|null}} [opts] `timeout: null` = sem teto
234
+ * @returns {{status: number|null, output: string, unavailable?: boolean, error?: string}}
235
+ */
236
+ export function runGraphify(tree, args, { env = process.env, timeout = GRAPHIFY_TIMEOUT_MS } = {}) {
237
+ const bin = resolveGraphify(env);
238
+ if (!bin) return { status: null, unavailable: true, output: '' };
239
+ // Nunca com cwd na árvore: o graphify lê o alvo pelo argumento (o ignore e o
240
+ // git dele, medido no 0.9.50), e um graphify órfão com cwd numa worktree a
241
+ // prende no win32 até terminar — o finish e o worktree remove falhavam.
242
+ const opts = {
243
+ cwd: resolveGitCommonDir(tree) ?? tree,
244
+ env: { ...env, PYTHONHASHSEED: '0' },
245
+ encoding: 'utf8',
246
+ windowsHide: true,
247
+ maxBuffer: 64 * 1024 * 1024,
248
+ stdio: ['ignore', 'pipe', 'pipe'],
249
+ ...(timeout ? { timeout } : {}),
250
+ };
251
+ const r = bin.shell
252
+ ? spawnSync(quoteForCmd(bin.file), args.map(quoteForCmd), { ...opts, shell: true })
253
+ : spawnSync(bin.file, args, opts);
254
+ const output = `${r.stdout ?? ''}${r.stderr ?? ''}`;
255
+ return { status: r.status, output, ...(r.error ? { error: r.error.code } : {}) };
256
+ }
257
+
258
+ /**
259
+ * Semeia uma worktree com o grafo da raiz. A worktree nasce de um commit da
260
+ * raiz, então o grafo copiado ou já é o do HEAD dela ou fica a um
261
+ * `morph-spec graph refresh` de distância — nunca a uma extração do zero.
262
+ *
263
+ * Só `graphify-out/` (no exclude do common dir, senão a worktree fica suja
264
+ * para o `finish`). O registro da worktree copia o que se SABE do grafo da
265
+ * raiz: o commit em que ele está em dia e as regras com que foi extraído —
266
+ * nada inventado. Raiz sem registro das regras deixa a semente sem elas.
267
+ *
268
+ * @param {string} tree
269
+ * @param {string} root
270
+ * @returns {{seeded: boolean, reason?: string}}
271
+ */
272
+ export function seedGraphFromRoot(tree, root) {
273
+ if (!tree || !root || treeKey(tree) === treeKey(root)) return { seeded: false, reason: 'is-root' };
274
+ if (existsSync(graphFile(tree))) return { seeded: false, reason: 'present' };
275
+ if (!existsSync(graphFile(root))) return { seeded: false, reason: 'no-root-graph' };
276
+ try {
277
+ const rootNow = graphFreshness(root);
278
+ const dst = join(tree, GRAPH_DIR);
279
+ mkdirSync(dst, { recursive: true });
280
+ for (const name of SEED_FILES) {
281
+ const src = join(root, GRAPH_DIR, name);
282
+ if (!existsSync(src)) continue;
283
+ // Por rename, e o graph.json por último: a presença dele é o sinal de
284
+ // semeadura completa, e quem lê no meio nunca vê meio arquivo.
285
+ const tmp = join(dst, `${name}.seed.${process.pid}`);
286
+ copyFileSync(src, tmp);
287
+ renameSync(tmp, join(dst, name));
288
+ }
289
+ ensureCommonExclude(tree, [`/${GRAPH_DIR}`], EXCLUDE_MARKER);
290
+ const rules = rootNow.record && 'ignoreHash' in rootNow.record
291
+ ? { ignoreHash: rootNow.record.ignoreHash }
292
+ : {};
293
+ writeRefreshRecord(tree, {
294
+ head: rootNow.state === 'fresh' ? rootNow.head : rootNow.built,
295
+ exit: 0,
296
+ ...rules,
297
+ seeded: true,
298
+ });
299
+ return { seeded: true };
300
+ } catch (err) {
301
+ return { seeded: false, reason: err?.code || 'copy-failed' };
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Semeia a árvore de `dir` a partir da raiz, se ela for uma worktree sem
307
+ * grafo — o que o SessionStart faz por uma worktree criada antes da semeadura
308
+ * existir.
309
+ *
310
+ * @param {string} dir
311
+ */
312
+ export function seedFromPrimary(dir) {
313
+ const tree = dir ? workTreeTop(dir) : null;
314
+ const root = primaryTree(dir);
315
+ if (!tree || !root) return { seeded: false, reason: 'not-a-repo' };
316
+ return seedGraphFromRoot(tree, root);
317
+ }
318
+
319
+ /**
320
+ * O trabalho do processo destacado, em primeiro plano (é o que os testes
321
+ * chamam). Roda `graphify update` na raiz do repositório de `dir` enquanto o
322
+ * grafo dela precisar (`refreshPending`), até `MAX_ROUNDS` rodadas — um
323
+ * commit que chega no meio pede outra. Cada rodada vai para o registro
324
+ * (graph-freshness.js) e para o log.
325
+ *
326
+ * @param {string} dir - qualquer diretório do repositório; o alvo é sempre a raiz
327
+ * @param {{env?: NodeJS.ProcessEnv, runUpdate?: (tree: string) => {status: number|null, unavailable?: boolean, output?: string}}} [deps]
328
+ * @returns {{ran: boolean, locked?: boolean, reason?: string, results: object[]}}
329
+ */
330
+ export function runRefresh(dir, { env = process.env, runUpdate = (tree) => runGraphify(tree, ['update', tree], { env }) } = {}) {
331
+ const root = primaryTree(dir);
332
+ if (!root) return { ran: false, reason: 'no-primary-tree', results: [] };
333
+ const results = [];
334
+ let rounds = 0;
335
+
336
+ while (rounds < MAX_ROUNDS && refreshPending(graphFreshness(root))) {
337
+ const lock = acquireRefreshLock(root);
338
+ if (!lock) return { ran: results.length > 0, locked: true, results };
339
+ try {
340
+ while (rounds < MAX_ROUNDS) {
341
+ const before = graphFreshness(root);
342
+ if (!refreshPending(before)) break;
343
+ rounds += 1;
344
+ renewRefreshLock(lock);
345
+ const ignoreHash = ignoreRulesHash(root);
346
+ const started = Date.now();
347
+ const r = runUpdate(root);
348
+ const exit = r.status ?? null;
349
+ recordRefreshRun(root, { head: before.head, exit, ignoreHash, unavailable: Boolean(r.unavailable) });
350
+ const after = graphFreshness(root);
351
+ const entry = {
352
+ tree: root, from: before.built, head: before.head, to: after.built,
353
+ ok: exit === 0, fresh: after.state === 'fresh', exit, ms: Date.now() - started,
354
+ ...(r.unavailable ? { unavailable: true } : {}),
355
+ ...(exit !== 0 && r.output ? { output: r.output.slice(-400) } : {}),
356
+ };
357
+ appendRefreshLog(root, entry);
358
+ results.push(entry);
359
+ if (r.unavailable) return { ran: true, reason: 'graphify-unavailable', results };
360
+ }
361
+ } finally {
362
+ releaseRefreshLock(lock);
363
+ }
364
+ }
365
+ return { ran: results.length > 0, results };
366
+ }
367
+
368
+ /**
369
+ * Dispara o refresh da RAIZ em background e volta na hora — de qualquer
370
+ * árvore do repositório. Só olha o disco, sem subprocesso: sem grafo na raiz
371
+ * não há o que refrescar; grafo que não pede refresh no HEAD lido do disco não
372
+ * paga um processo por comando git; com o graphify registrado como ausente e
373
+ * ainda fora do PATH, não há quem rode. HEAD que o disco não responde (reftable,
374
+ * branch sem commit) fica para o worker, que confere de novo sob o lock.
375
+ *
376
+ * O worker nasce com `cwd` no git common dir e só recebe a raiz: nenhum
377
+ * processo destacado segura o diretório de uma worktree.
378
+ *
379
+ * `MORPH_GRAPH_AUTO_REFRESH=0` desliga (CI, máquina sem folga de CPU).
380
+ *
381
+ * @param {string} dir
382
+ * @param {{spawnFn?: typeof spawn, env?: NodeJS.ProcessEnv}} [deps]
383
+ * @returns {{kicked: boolean, reason?: string, pid?: number}}
384
+ */
385
+ export function kickGraphRefresh(dir, { spawnFn = spawn, env = process.env } = {}) {
386
+ if (env.MORPH_GRAPH_AUTO_REFRESH === '0') return { kicked: false, reason: 'disabled' };
387
+ const root = primaryTree(dir);
388
+ if (!root) return { kicked: false, reason: 'not-a-repo' };
389
+ if (!existsSync(graphFile(root))) return { kicked: false, reason: 'no-graph' };
390
+ const head = readPrimaryHead(root);
391
+ if (head && !refreshPending(graphFreshness(root, { head }))) return { kicked: false, reason: 'not-pending' };
392
+ if (readRefreshRecord(root)?.unavailable && !resolveGraphify(env)) {
393
+ return { kicked: false, reason: 'graphify-unavailable' };
394
+ }
395
+ try {
396
+ const child = spawnFn(process.execPath, [WORKER, '--run', root], {
397
+ cwd: resolveGitCommonDir(root), env, detached: true, stdio: 'ignore', windowsHide: true,
398
+ });
399
+ child.on?.('error', () => {});
400
+ child.unref?.();
401
+ return { kicked: true, pid: child.pid };
402
+ } catch {
403
+ return { kicked: false, reason: 'spawn-failed' };
404
+ }
405
+ }
406
+
407
+ const isMain = (() => {
408
+ const argv1 = process.argv[1];
409
+ if (!argv1) return false;
410
+ const norm = (p) => (process.platform === 'win32' ? resolve(p).toLowerCase() : resolve(p));
411
+ return norm(argv1) === norm(WORKER);
412
+ })();
413
+
414
+ if (isMain && process.argv[2] === '--run' && process.argv[3]) {
415
+ try { runRefresh(process.argv[3]); } catch { /* fail-open */ }
416
+ }
@@ -40,23 +40,35 @@ function splitCommand(command) {
40
40
  }
41
41
 
42
42
  /** Quote one argument for cmd.exe (the only shell path this module uses). */
43
- function quoteForCmd(arg) {
43
+ export function quoteForCmd(arg) {
44
44
  if (arg === '') return '""';
45
45
  if (!/[\s"&|<>^()]/.test(arg)) return arg;
46
46
  return `"${arg.replace(/"/g, '""')}"`;
47
47
  }
48
48
 
49
- /** First PATH entry holding `file`, or null. */
50
- function findOnPath(file, env) {
49
+ /**
50
+ * First PATH entry holding any of `files`, or null. The entries are walked in
51
+ * PATH order and every name is tried inside each one, the way cmd.exe applies
52
+ * PATHEXT: a `graphify.cmd` early in PATH wins over a `graphify.exe` later.
53
+ *
54
+ * @param {string[]} files
55
+ * @param {NodeJS.ProcessEnv} env
56
+ * @returns {string|null}
57
+ */
58
+ export function findFirstOnPath(files, env) {
51
59
  const raw = env.PATH || env.Path || env.path || '';
52
60
  for (const dir of raw.split(delimiter)) {
53
61
  if (!dir) continue;
54
- const candidate = join(dir, file);
55
- try { if (existsSync(candidate)) return candidate; } catch { /* unreadable entry */ }
62
+ for (const file of files) {
63
+ const candidate = join(dir, file);
64
+ try { if (existsSync(candidate)) return candidate; } catch { /* unreadable entry */ }
65
+ }
56
66
  }
57
67
  return null;
58
68
  }
59
69
 
70
+ const findOnPath = (file, env) => findFirstOnPath([file], env);
71
+
60
72
  /** Last stdout line that parses as a JSON object, or null. */
61
73
  export function lastJsonLine(stdout) {
62
74
  const lines = String(stdout || '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Redaction of obvious secrets in free text that telemetry persists (Bash
3
+ * commands, dispatch prompts). events.jsonl is copied into finish backups
4
+ * under `.git/`, so whatever lands there outlives the session.
5
+ *
6
+ * Pattern-based by design: it catches a secret written NEXT TO its name
7
+ * (`KEY=…`, `Authorization: …`, `user:pass@`) or in a well-known token shape.
8
+ * A bare value with no name beside it is invisible to any pattern — which is
9
+ * why tool OUTPUT (stdout/stderr, a failure's `error` body) is never persisted
10
+ * at all instead of being redacted.
11
+ */
12
+
13
+ export const REDACTED = '[REDACTED]';
14
+
15
+ const SECRET_KEY =
16
+ '[A-Za-z0-9_.-]*(?:secret|token|passw(?:or)?d|pwd|api[_-]?key|access[_-]?key|private[_-]?key|account[_-]?key|sharedaccesskey|credentials?|auth[_-]?key)[A-Za-z0-9_.-]*';
17
+
18
+ const VALUE = `("[^"]*"|'[^']*'|[^\\s'",;&|}]+)`;
19
+
20
+ const RULES = [
21
+ // A port (`host:8080/…`) is not a password; a password may contain `/`.
22
+ [/\b([a-z][a-z0-9+.-]*:\/\/[^\s:@/?#]+:)(?!\d+(?:[/?#\s]|$))[^\s@]+@/gi, `$1${REDACTED}@`],
23
+ [/\b(authorization\s*[:=]\s*)(?:(bearer|basic|token|digest)\s+)?[^\s'",;]+/gi,
24
+ (_, head, scheme) => `${head}${scheme ? `${scheme} ` : ''}${REDACTED}`],
25
+ // A token carries a digit; "Bearer authentication" is prose.
26
+ [/\b(bearer\s+)(?=[A-Za-z0-9._~+/=-]*\d)[A-Za-z0-9._~+/=-]{8,}/gi, `$1${REDACTED}`],
27
+ // `-u 1000:1000` is a uid:gid, not credentials.
28
+ [/(\s-u\s+|--user[=\s]+)(['"]?)(?!\d+:\d+\b)[^\s:'"]+:[^\s'"]+\2/g, `$1${REDACTED}`],
29
+ [new RegExp(`(["']?)(${SECRET_KEY})\\1(\\s*[:=]\\s*)${VALUE}`, 'gi'),
30
+ (_, quote, key, sep) => `${quote}${key}${quote}${sep}${REDACTED}`],
31
+ [new RegExp(`\\b([A-Z][A-Z0-9_]*_(?:KEY|PASS|PAT|SIG))(\\s*=\\s*)${VALUE}`, 'g'), `$1$2${REDACTED}`],
32
+ [new RegExp(`(--(?:password|passwd|pass|token|secret|api-?key|access-key|auth-token)(?:\\s+|=))${VALUE}`, 'gi'),
33
+ `$1${REDACTED}`],
34
+ [new RegExp(`(\\buser-secrets\\s+set\\s+(?:"[^"]*"|'[^']*'|\\S+)\\s+)${VALUE}`, 'gi'), `$1${REDACTED}`],
35
+ [/([?&](?:sig|access_token|token|api[_-]?key|key|password|secret|code)=)[^&\s'"#]+/gi, `$1${REDACTED}`],
36
+ [/\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})\b/g, REDACTED],
37
+ [/\bsk-(?:ant-)?[A-Za-z0-9_-]{16,}/g, REDACTED],
38
+ [/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{10,}/g, REDACTED],
39
+ [/\bnpm_[A-Za-z0-9]{36}\b/g, REDACTED],
40
+ [/\bxox[abposr]-[A-Za-z0-9-]{10,}/g, REDACTED],
41
+ [/\bAKIA[0-9A-Z]{16}\b/g, REDACTED],
42
+ [/\bAIza[0-9A-Za-z_-]{35}\b/g, REDACTED],
43
+ [/\beyJ[A-Za-z0-9_-]{5,}\.eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]+/g, REDACTED],
44
+ ];
45
+
46
+ /**
47
+ * @param {*} text
48
+ * @returns {*} the text with secrets replaced by `[REDACTED]`; non-strings unchanged
49
+ */
50
+ export function redactSecrets(text) {
51
+ if (typeof text !== 'string' || text === '') return text;
52
+ let out = text;
53
+ for (const [pattern, replacement] of RULES) out = out.replace(pattern, replacement);
54
+ return out;
55
+ }
@@ -20,6 +20,8 @@ import {
20
20
  appendFileSync, readFileSync, mkdirSync, existsSync, statSync, renameSync,
21
21
  } from 'fs';
22
22
  import { join, dirname } from 'path';
23
+ import { randomUUID } from 'crypto';
24
+ import { redactSecrets } from './redact-secrets.js';
23
25
 
24
26
  const LOGS_DIR = '.morph/logs';
25
27
  export const EVENTS_FILE = join(LOGS_DIR, 'events.jsonl');
@@ -32,13 +34,26 @@ export const EVENTS_FILE = join(LOGS_DIR, 'events.jsonl');
32
34
  // this it grows forever the way activity.jsonl used to before R1.2.
33
35
  export const ROTATE_THRESHOLD_BYTES = 5 * 1024 * 1024; // 5 MB
34
36
 
37
+ // Bumped when a field changes meaning. Readers key legacy handling off its
38
+ // absence (v1 events carry no schemaVersion and no eventId).
39
+ export const TELEMETRY_SCHEMA_VERSION = 2;
40
+
41
+ const FREE_TEXT_FIELDS = ['command', 'prompt', 'desc', 'target'];
42
+
43
+ /** A tool call that failed read nothing and changed nothing. */
44
+ export function isFailedToolCall(event) {
45
+ return event?.event === 'PostToolUseFailure';
46
+ }
47
+
35
48
  function getEventsPath(projectPath) {
36
49
  return join(projectPath || process.cwd(), EVENTS_FILE);
37
50
  }
38
51
 
39
52
  /**
40
53
  * Append a single telemetry event as one JSON line.
41
- * Stamps an ISO `ts` when the caller omits one.
54
+ * Stamps `ts` (unless given), `schemaVersion` and a unique `eventId` — the
55
+ * dedupe key when the same line is read back from a rotated archive or a
56
+ * finish backup — and redacts secrets in the free-text fields.
42
57
  *
43
58
  * @param {Object} event - Event payload (tool, skill/persona/target/prompt, feature, phase…)
44
59
  * @param {string} [projectPath] - Project root (defaults to cwd)
@@ -49,7 +64,15 @@ export function appendTelemetryEvent(event, projectPath) {
49
64
  const dir = dirname(eventsPath);
50
65
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
51
66
 
52
- const stamped = { ts: event?.ts || new Date().toISOString(), ...event };
67
+ const stamped = {
68
+ ts: event?.ts || new Date().toISOString(),
69
+ schemaVersion: TELEMETRY_SCHEMA_VERSION,
70
+ eventId: randomUUID(),
71
+ ...event,
72
+ };
73
+ for (const field of FREE_TEXT_FIELDS) {
74
+ if (typeof stamped[field] === 'string') stamped[field] = redactSecrets(stamped[field]);
75
+ }
53
76
  appendFileSync(eventsPath, JSON.stringify(stamped) + '\n', 'utf-8');
54
77
  } catch {
55
78
  // Fail-silent — never block hooks
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Real token usage read from a Claude Code transcript (JSONL), shared by the
3
+ * Stop hook (the main session's transcript) and SubagentStop (each
4
+ * sub-agent's own transcript, which the main one does not include).
5
+ */
6
+
7
+ import { readFileSync, existsSync, statSync } from 'fs';
8
+
9
+ // A 45 MB / ~16k-line transcript parses in ~350 ms; above this, skip rather
10
+ // than risk an unbounded read inside a hook.
11
+ const MAX_TRANSCRIPT_BYTES = 50 * 1024 * 1024;
12
+
13
+ export const USAGE_KEYS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheCreationTokens'];
14
+
15
+ /**
16
+ * Sum `message.usage` over the assistant turns of a transcript.
17
+ *
18
+ * One turn is written as several lines (one per content block), each repeating
19
+ * the same usage under the same `message.id`: summing lines double-counts, so
20
+ * the sum is deduplicated by message id.
21
+ *
22
+ * @param {string} [transcriptPath]
23
+ * @returns {{inputTokens:number,outputTokens:number,cacheReadTokens:number,cacheCreationTokens:number}|null}
24
+ * null when there is nothing to report (no/oversized/unreadable file, no usage)
25
+ */
26
+ export function readTranscriptUsage(transcriptPath) {
27
+ if (!transcriptPath || typeof transcriptPath !== 'string') return null;
28
+ try {
29
+ if (!existsSync(transcriptPath)) return null;
30
+ if (statSync(transcriptPath).size > MAX_TRANSCRIPT_BYTES) return null;
31
+
32
+ const totals = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 };
33
+ const seen = new Set();
34
+ let sawAny = false;
35
+
36
+ for (const line of readFileSync(transcriptPath, 'utf-8').split('\n')) {
37
+ const trimmed = line.trim();
38
+ if (!trimmed) continue;
39
+ let entry;
40
+ try { entry = JSON.parse(trimmed); } catch { continue; }
41
+ if (entry?.type !== 'assistant') continue;
42
+ const usage = entry?.message?.usage;
43
+ if (!usage || typeof usage !== 'object') continue;
44
+
45
+ const key = entry?.message?.id || entry?.uuid;
46
+ if (key) {
47
+ if (seen.has(key)) continue;
48
+ seen.add(key);
49
+ }
50
+
51
+ sawAny = true;
52
+ totals.inputTokens += Number(usage.input_tokens) || 0;
53
+ totals.outputTokens += Number(usage.output_tokens) || 0;
54
+ totals.cacheReadTokens += Number(usage.cache_read_input_tokens) || 0;
55
+ totals.cacheCreationTokens += Number(usage.cache_creation_input_tokens) || 0;
56
+ }
57
+
58
+ return sawAny ? totals : null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * The usage added since the previous sample of the same transcript.
66
+ *
67
+ * A counter going DOWN means the transcript restarted (a new file under the
68
+ * same key), so the whole current total is new usage — subtracting would
69
+ * produce negative spend.
70
+ *
71
+ * @param {object|null|undefined} previous - last recorded totals for this key
72
+ * @param {object} current - totals just read
73
+ * @returns {{delta: object, changed: boolean}}
74
+ */
75
+ export function usageDelta(previous, current) {
76
+ const reset = !previous || USAGE_KEYS.some((k) => (current[k] ?? 0) < (previous[k] ?? 0));
77
+ const delta = {};
78
+ for (const k of USAGE_KEYS) delta[k] = reset ? (current[k] ?? 0) : (current[k] ?? 0) - (previous[k] ?? 0);
79
+ return { delta, changed: USAGE_KEYS.some((k) => delta[k] !== 0) };
80
+ }
@@ -10,8 +10,8 @@
10
10
  },
11
11
  "code_quality": {
12
12
  "weight": 0.25,
13
- "validators": ["architecture", "reuse"],
14
- "description": "Architecture patterns, dependency direction, code structure, and reuse of what the codebase already has"
13
+ "validators": ["architecture", "reuse", "code-budget"],
14
+ "description": "Architecture patterns, dependency direction, code structure, reuse of what the codebase already has, and comment and test budget"
15
15
  },
16
16
  "design_ui": {
17
17
  "weight": 0.05,
@@ -10,8 +10,8 @@
10
10
  },
11
11
  "code_quality": {
12
12
  "weight": 0.25,
13
- "validators": ["architecture", "reuse"],
14
- "description": "Architecture patterns, code structure, and reuse of what the codebase already has"
13
+ "validators": ["architecture", "reuse", "code-budget"],
14
+ "description": "Architecture patterns, code structure, reuse of what the codebase already has, and comment and test budget"
15
15
  },
16
16
  "design_ui": {
17
17
  "weight": 0.15,
@@ -10,8 +10,8 @@
10
10
  },
11
11
  "code_quality": {
12
12
  "weight": 0.15,
13
- "validators": ["architecture", "reuse"],
14
- "description": "Architecture patterns, code structure, and reuse of what the codebase already has"
13
+ "validators": ["architecture", "reuse", "code-budget"],
14
+ "description": "Architecture patterns, code structure, reuse of what the codebase already has, and comment and test budget"
15
15
  },
16
16
  "design_ui": {
17
17
  "weight": 0.35,