@polymorphism-tech/morph-spec 8.19.0 → 8.21.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 (40) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +2 -1
  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 -11
  7. package/framework/MORPH.md +53 -55
  8. package/framework/agents.json +1 -1
  9. package/framework/docs/CLI.md +3 -3
  10. package/framework/hooks/claude-code/core/enforce-phase-writes.js +23 -2
  11. package/framework/hooks/claude-code/core/state-sync.js +5 -0
  12. package/framework/hooks/claude-code/core/telemetry-log.js +34 -3
  13. package/framework/hooks/claude-code/core/validate-completion.js +116 -4
  14. package/framework/hooks/claude-code/core/worktree-context.js +9 -4
  15. package/framework/hooks/shared/telemetry-logger.js +64 -1
  16. package/framework/phases.json +1 -1
  17. package/framework/schemas/tasks.schema.json +1 -1
  18. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  19. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  20. package/framework/skills/core/morph-apply/SKILL.md +6 -15
  21. package/framework/skills/core/morph-brainstorming/SKILL.md +3 -3
  22. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  23. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  24. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  25. package/framework/skills/core/morph-plan/SKILL.md +6 -4
  26. package/framework/skills/core/morph-proposal/SKILL.md +10 -1
  27. package/framework/skills/core/morph-review/SKILL.md +4 -3
  28. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  29. package/framework/skills/core/morph-standards/SKILL.md +29 -15
  30. package/framework/skills/core/morph-uiux/SKILL.md +135 -0
  31. package/package.json +1 -1
  32. package/src/commands/observability/cost.js +93 -5
  33. package/src/commands/project/update.js +2 -63
  34. package/src/commands/state/gate-check.js +10 -3
  35. package/src/core/templates/template-registry.js +2 -30
  36. package/src/lib/git/default-branch.js +44 -8
  37. package/src/lib/installers/templates-reconcile.js +81 -0
  38. package/src/lib/risk-detector.js +20 -1
  39. package/src/lib/telemetry/telemetry-reader.js +80 -4
  40. package/src/scripts/setup-infra.js +23 -5
@@ -1,16 +1,26 @@
1
1
  /**
2
- * `morph-spec cost [feature] [--by task|phase] [--json]`
2
+ * `morph-spec cost [feature] [--by task|phase] [--all-trees] [--json]`
3
3
  *
4
4
  * A cost-facing view over the same harness event stream `telemetry` reads
5
5
  * (.morph/logs/events.jsonl): how much wall-clock time was spent inside
6
6
  * measured tasks (TaskStart→TaskEnd), how many context bytes were
7
7
  * re-ingested (grouped by kind: rubric/standard/skill/agents/plan-artifact/
8
8
  * code), and how many sub-agent dispatches happened (with total prompt
9
- * chars).
9
+ * chars). When the stream has `SessionUsage` events (real, hook-observed
10
+ * token counts), those are summed and shown too — see the sessionUsage
11
+ * caveat below.
10
12
  *
11
13
  * IMPORTANT: bytes re-ingested is a DETERMINISTIC PROXY for cost, not a
12
14
  * token count. Hooks only see what tools read off disk — never the model's
13
15
  * actual token usage — so this measures re-ingestion pressure, not spend.
16
+ * `sessionUsage` (when present) is the exception: real tokens, observed
17
+ * directly, not a proxy — see aggregateCost's docstring for the contract.
18
+ *
19
+ * `--all-trees` (Furo 3) widens the source from "just the current tree" to
20
+ * every git worktree that has its own events.jsonl — a fleet running N
21
+ * features in N worktrees otherwise only ever sees whichever tree `cost` was
22
+ * run from. Reuses the same worktree enumeration `doctor --worktrees`/`fleet`
23
+ * use (see loadTelemetryAllTrees in telemetry-reader.js).
14
24
  *
15
25
  * Read-only, always exits 0 on a normal (even empty) run — fail-open, an
16
26
  * empty stream is an answer, not an error. Only `--by` with an unknown value
@@ -18,7 +28,7 @@
18
28
  */
19
29
 
20
30
  import chalk from 'chalk';
21
- import { loadTelemetry, aggregateCost } from '../../lib/telemetry/telemetry-reader.js';
31
+ import { loadTelemetry, loadTelemetryAllTrees, aggregateCost } from '../../lib/telemetry/telemetry-reader.js';
22
32
 
23
33
  const BYTES_CAVEAT =
24
34
  'Bytes re-ingeridos são um PROXY determinístico de custo (o que as ferramentas leram do disco) — NÃO são tokens reais. Hooks não enxergam o uso real de tokens do modelo.';
@@ -77,6 +87,19 @@ function printPhases(phases) {
77
87
  }
78
88
  }
79
89
 
90
+ /**
91
+ * Real (hook-observed) token counts, when the stream has `SessionUsage`
92
+ * events — the non-proxy sibling of the bytes-re-ingested numbers above.
93
+ * No-op when absent (aggregateCost returns `null`, never a zeroed object).
94
+ */
95
+ function printSessionUsage(sessionUsage) {
96
+ if (!sessionUsage) return;
97
+ console.log('\n' + chalk.white.bold('Tokens reais (sessões observadas)')
98
+ + chalk.gray(` · ${sessionUsage.sessions} SessionUsage`));
99
+ console.log(` input=${sessionUsage.inputTokens} output=${sessionUsage.outputTokens} `
100
+ + `cacheRead=${sessionUsage.cacheReadTokens} cacheCreation=${sessionUsage.cacheCreationTokens}`);
101
+ }
102
+
80
103
  function printSummary(summary) {
81
104
  console.log(`\n duração dentro de tasks medidas: ${fmtDuration(summary.taskDurationSeconds)}`);
82
105
  console.log(` dispatches: ${summary.dispatches.count} (${summary.dispatches.promptChars} chars de prompt)`);
@@ -88,11 +111,67 @@ function printSummary(summary) {
88
111
  console.log('\n' + chalk.white.bold('Mix de tools'));
89
112
  for (const t of summary.tools) console.log(` ${t.name.padEnd(16)} ${t.count}`);
90
113
  }
114
+
115
+ printSessionUsage(summary.sessionUsage);
116
+ }
117
+
118
+ /**
119
+ * `--all-trees` rendering: a short line per tree (so a fleet operator can
120
+ * spot which tree is expensive) followed by the COMBINED aggregate — full
121
+ * detail (task/phase breakdown honors `--by` here too, same as the
122
+ * single-tree path; a typeable flag combination is never a silent no-op).
123
+ *
124
+ * @param {string} [feature]
125
+ * @param {{ json?: boolean, by?: 'task'|'phase' }} options
126
+ * @param {string} cwd
127
+ */
128
+ function printAllTrees(feature, options, cwd) {
129
+ const treesData = loadTelemetryAllTrees(cwd);
130
+ const perTree = treesData.map(({ tree, path, events }) => ({
131
+ tree,
132
+ path,
133
+ ...aggregateCost(events, { feature }),
134
+ }));
135
+ const combined = aggregateCost(treesData.flatMap((t) => t.events), { feature });
136
+
137
+ if (options.json) {
138
+ console.log(JSON.stringify({ allTrees: true, trees: perTree, combined }));
139
+ return;
140
+ }
141
+
142
+ if (combined.total === 0) {
143
+ const scope = feature ? ` para a feature "${feature}"` : '';
144
+ console.log(chalk.gray(`Telemetria vazia${scope} em todas as árvores — nenhuma tem eventos capturados ainda.`));
145
+ return;
146
+ }
147
+
148
+ console.log(chalk.white.bold(
149
+ `Custo do harness (proxy determinística) — todas as árvores (${perTree.length} com eventos, ${combined.total} eventos no total)`
150
+ ));
151
+ console.log(chalk.gray(`⚠ ${BYTES_CAVEAT}`));
152
+
153
+ for (const t of perTree) {
154
+ console.log('\n' + chalk.cyan.bold(`── Árvore: ${t.tree}`) + chalk.gray(` — ${t.path}`));
155
+ console.log(` ${t.total} eventos · duração em tasks: ${fmtDuration(t.taskDurationSeconds)} · `
156
+ + `bytes: ${fmtBytes(t.bytesTotal)} · dispatches: ${t.dispatches.count}`);
157
+ }
158
+
159
+ console.log('\n' + chalk.white.bold('── Total agregado (todas as árvores)'));
160
+ if (combined.features.length) {
161
+ console.log(chalk.gray(`Features: ${combined.features.join(', ')}`));
162
+ }
163
+ if (options.by === 'task') {
164
+ printTasks(combined.tasks);
165
+ } else if (options.by === 'phase') {
166
+ printPhases(combined.phases);
167
+ } else {
168
+ printSummary(combined);
169
+ }
91
170
  }
92
171
 
93
172
  /**
94
173
  * @param {string} [feature] - narrow to a single feature
95
- * @param {{ json?: boolean, by?: 'task'|'phase', cwd?: string }} [options]
174
+ * @param {{ json?: boolean, by?: 'task'|'phase', allTrees?: boolean, cwd?: string }} [options]
96
175
  */
97
176
  export function costCommand(feature, options = {}) {
98
177
  if (options.by && !BY_MODES.includes(options.by)) {
@@ -102,7 +181,16 @@ export function costCommand(feature, options = {}) {
102
181
  return;
103
182
  }
104
183
 
105
- const events = loadTelemetry(options.cwd || process.cwd());
184
+ const cwd = options.cwd || process.cwd();
185
+
186
+ if (options.allTrees) {
187
+ // Same fail-open + Windows-flush discipline as the single-tree path
188
+ // below — no forced process.exit(), --json included.
189
+ printAllTrees(feature, options, cwd);
190
+ return;
191
+ }
192
+
193
+ const events = loadTelemetry(cwd);
106
194
  const summary = aggregateCost(events, { feature });
107
195
 
108
196
  if (options.json) {
@@ -24,8 +24,7 @@ import {
24
24
  checkProjectOutdated,
25
25
  saveProjectMorphVersion,
26
26
  getUpdateInstructions,
27
- detectInstallMethod,
28
- compareVersions
27
+ detectInstallMethod
29
28
  } from '../../utils/version-checker.js';
30
29
  import { installClaudeHooks, installGlobalStatusline } from '../../utils/claude-settings-manager.js';
31
30
  import { installSkills } from '../../utils/skills-installer.js';
@@ -33,6 +32,7 @@ import { installAgents, installDomainAgents } from '../../utils/agents-installer
33
32
  import { injectMorphImport } from '../../utils/claude-md-injector.js';
34
33
  import { getProjectTags } from '../../lib/stack-filter.js';
35
34
  import { updateClaudeMdAgentsBlock, installRulesForStack } from '../../scripts/setup-infra.js';
35
+ import { reconcileLegacyTemplatesCopy } from '../../lib/installers/templates-reconcile.js';
36
36
 
37
37
  /**
38
38
  * Backup user's config.json before cleaning
@@ -89,67 +89,6 @@ async function cleanFrameworkDirs(morphPath, targetPath) {
89
89
  }
90
90
  }
91
91
 
92
- /**
93
- * Last CLI version whose `update`/`setup-infra` still wrote
94
- * `.morph/framework/templates/` unconditionally, before Task 2 of the R3
95
- * perf plan removed that copy. Used as the decision boundary in
96
- * `reconcileLegacyTemplatesCopy` below — see its doc comment for why a
97
- * version check is used instead of diffing file content.
98
- */
99
- const LAST_VERSION_THAT_COPIED_TEMPLATES = '8.17.0';
100
-
101
- /**
102
- * Reconcile a pre-existing `.morph/framework/templates/` copy now that
103
- * `update` no longer writes it (Task 2, R3 perf plan — resolution falls
104
- * back to the npm package's own framework/templates/, see
105
- * template-registry.js / architecture-scanner.js's layered resolver).
106
- *
107
- * Decided from the project's PRIOR recorded CLI version (before THIS
108
- * update writes a new one), not by diffing content against the package.
109
- * A content diff can't tell "stale copy from an older CLI version" apart
110
- * from "deliberate user override" — and staleness is the dominant case for
111
- * anyone who actually runs `update`, since template content does change
112
- * between releases. The version boundary is unambiguous instead:
113
- *
114
- * - No local copy → nothing to do.
115
- * - Prior version is unrecorded, OR <= LAST_VERSION_THAT_COPIED_TEMPLATES
116
- * → the copy could ONLY have been put there by the OLD installer:
117
- * `.morph/framework/**` is Edit-denied to the agent (MORPH_DENY in
118
- * hooks-installer.js — "installer-managed, immutable at runtime"),
119
- * the directory is gitignored, and every `update` before this fix
120
- * unconditionally wiped + recopied it. There is no path by which a
121
- * project on an old version could have a genuine override sitting
122
- * here. Safe to delete unconditionally — it is dead weight, not data.
123
- * - Prior version > LAST_VERSION_THAT_COPIED_TEMPLATES → `update` and
124
- * `setup-infra` have never written this directory for this project.
125
- * Anything found here was placed deliberately (by the user or other
126
- * tooling) — this is the forward-looking override use case the
127
- * layered resolver exists for. Preserve untouched, unconditionally,
128
- * with no diffing.
129
- *
130
- * @param {string} templatesDest - .morph/framework/templates (may not exist)
131
- * @param {string|null} priorProjectVersion - project's recorded morph-spec
132
- * version BEFORE this update run (checkProjectOutdated().current)
133
- * @returns {'absent'|'removed-legacy'|'preserved-override'}
134
- */
135
- export function reconcileLegacyTemplatesCopy(templatesDest, priorProjectVersion) {
136
- if (!existsSync(templatesDest)) return 'absent';
137
-
138
- const isLegacy = !priorProjectVersion
139
- || compareVersions(priorProjectVersion, LAST_VERSION_THAT_COPIED_TEMPLATES) <= 0;
140
-
141
- if (!isLegacy) return 'preserved-override';
142
-
143
- try {
144
- fs.removeSync(templatesDest);
145
- return 'removed-legacy';
146
- } catch {
147
- // Couldn't remove — don't risk a half-broken state; leave it and let
148
- // the layered resolver's override-first check pick it up as before.
149
- return 'preserved-override';
150
- }
151
- }
152
-
153
92
  /**
154
93
  * Restore user's config.json after reinstallation
155
94
  * @param {string} morphPath - Path to .morph directory
@@ -87,9 +87,12 @@ function evaluatorCompositeScore(cwd, featureName) {
87
87
  * never had (it shipped fully tested with zero callers; see the R4 report).
88
88
  *
89
89
  * `verificationStatus` feeds a real pause rule (verify vermelho pausa —
90
- * política adicionada em risk-detector.js no fecho do R4); `verification`
91
- * completo, verificationStale, diffstat e sha seguem como contexto de
92
- * auditoria persistido, sem regra própria.
90
+ * política adicionada em risk-detector.js no fecho do R4, qualquer gate).
91
+ * `verificationStale` também alimenta uma regra própria, mas só no ship
92
+ * (`gate === 'review'`) — sha do stamp divergindo do HEAD atual. `gate` em si
93
+ * é o que permite risk-detector.js diferenciar as duas. `verification`
94
+ * completo, diffstat e sha seguem como contexto de auditoria persistido, sem
95
+ * regra própria.
93
96
  *
94
97
  * @param {string} featureName
95
98
  * @param {object} feature
@@ -104,6 +107,10 @@ function collectSignals(featureName, feature, cwd, gateName) {
104
107
  const verification = feature.verification || null;
105
108
 
106
109
  const signals = {
110
+ // Furo 2: disk-derived, never in OVERRIDABLE_SIGNAL_KEYS — a caller
111
+ // cannot spoof which gate risk-detector.js thinks it is deciding, same
112
+ // anti-spoof rationale as workType (hotfix's forced pause).
113
+ gate: gateName,
107
114
  workType: feature.workType || 'feature',
108
115
  taskCount: tasks.length,
109
116
  standardsViolations: [],
@@ -171,34 +171,6 @@ export function searchTemplates(keyword, projectPath = null) {
171
171
  });
172
172
  }
173
173
 
174
- /**
175
- * Get template absolute path with fallback logic.
176
- *
177
- * Resolution order:
178
- * 1. Project-local override (.morph/framework/templates/{path})
179
- * 2. Framework universal template (framework/templates/{path})
180
- *
181
- * @param {string} templateId - Template identifier
182
- * @param {string} projectPath - Project root path
183
- * @returns {string|null} Absolute template path or null if not found
184
- */
185
- export function resolveTemplatePathById(templateId, projectPath) {
186
- const template = getTemplateById(templateId, projectPath);
187
- if (!template) {
188
- return null;
189
- }
190
-
191
- // 1. Project-local override
192
- const projectLocalPath = join(projectPath, '.morph/framework/templates', template.path);
193
- if (existsSync(projectLocalPath)) {
194
- return projectLocalPath;
195
- }
196
-
197
- // 2. Framework universal template
198
- const frameworkPath = join(projectPath, 'framework/templates', template.path);
199
- return existsSync(frameworkPath) ? frameworkPath : null;
200
- }
201
-
202
174
  /** Absolute path of this repo's own framework/templates/ directory. */
203
175
  const FRAMEWORK_TEMPLATES_DIR = join(__dirname, '../../../framework/templates');
204
176
 
@@ -206,8 +178,8 @@ const FRAMEWORK_TEMPLATES_DIR = join(__dirname, '../../../framework/templates');
206
178
  * Resolve a template id to its absolute path inside THIS framework repo's
207
179
  * `framework/templates/` directory — for generators running against the
208
180
  * framework itself (scaffold-generator, context-generator), not against an
209
- * installed `.morph/`. Use resolveTemplatePathById() when a project root and
210
- * its project-local overrides are in play.
181
+ * installed `.morph/`. Use architecture-scanner.js's `resolveTemplatePath()`
182
+ * when a project root and its project-local overrides are in play.
211
183
  *
212
184
  * @param {string} templateId - Template identifier (REGISTRY.json `id`)
213
185
  * @returns {string} Absolute path to the template file
@@ -112,26 +112,62 @@ function parseShortstat(text) {
112
112
  };
113
113
  }
114
114
 
115
+ /**
116
+ * Count brand-new UNTRACKED files (never `git add`-ed) — the gap plain
117
+ * `git diff --shortstat` never sees, tracked-edits-only by design. Fail-open:
118
+ * any error (not a repo, git missing) reads as 0 untracked, never throws —
119
+ * this must not take down the whole `getDiffStat` result over a probe that
120
+ * is secondary to the diff itself.
121
+ *
122
+ * `--exclude-standard` matches gitignore the same way `git status` does, so
123
+ * scratch/build output never inflates the count.
124
+ *
125
+ * @param {string} cwd
126
+ * @returns {number}
127
+ */
128
+ function countUntrackedFiles(cwd) {
129
+ try {
130
+ const out = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], { cwd, stdio: 'pipe' })
131
+ .toString();
132
+ return out.split('\n').filter((l) => l.trim()).length;
133
+ } catch {
134
+ return 0;
135
+ }
136
+ }
137
+
115
138
  /**
116
139
  * `git diff --shortstat <baseRef>` against the CURRENT WORKING TREE (tracked
117
140
  * edits — staged, unstaged, and everything committed since baseRef —
118
141
  * deliberately not `baseRef...HEAD`, so a gate-check mid-implement still sees
119
- * uncommitted work). Same as plain `git diff`, this does NOT count brand-new
120
- * UNTRACKED files (never `git add`-ed) — a real, known gap if a gate check
121
- * runs before `git add`; not fixed here since covering it needs `--no-index`
122
- * or `git add -N` bookkeeping this shared helper doesn't own. Fail-open: null
123
- * when `baseRef` is falsy, cwd isn't a git repo, or the diff itself fails
124
- * (e.g. baseRef doesn't resolve).
142
+ * uncommitted work), PLUS a count of brand-new UNTRACKED files (never
143
+ * `git add`-ed) that plain `git diff` never shows regardless of commit vs
144
+ * working tree. `filesChanged` is the combined total (tracked-changed +
145
+ * untracked) so a caller reading just that one number gets the true scope of
146
+ * what changed; `untrackedFiles` is broken out separately so a caller that
147
+ * cares CAN tell diff-vs-new apart instead of the two being silently fused.
148
+ *
149
+ * Both git calls run with the same `cwd` — every current caller passes a
150
+ * feature/worktree root there, so `git diff`'s repo-wide default and
151
+ * `git ls-files`'s cwd-relative listing agree; a caller passing a
152
+ * sub-directory as `cwd` would see `untrackedFiles` scoped to that
153
+ * sub-directory while `insertions`/`deletions` stay repo-wide — not a
154
+ * concern today, but worth knowing if that assumption ever changes.
155
+ *
156
+ * Fail-open: null when `baseRef` is falsy, cwd isn't a git repo, or the diff
157
+ * itself fails (e.g. baseRef doesn't resolve). The untracked probe fails open
158
+ * on its own (see countUntrackedFiles) and never turns a good diff into null.
125
159
  *
126
160
  * @param {string|null} baseRef - typically `detectDefaultBranch(cwd)`
127
161
  * @param {string} [cwd]
128
- * @returns {{ filesChanged: number, insertions: number, deletions: number }|null}
162
+ * @returns {{ filesChanged: number, insertions: number, deletions: number, untrackedFiles: number }|null}
129
163
  */
130
164
  export function getDiffStat(baseRef, cwd = process.cwd()) {
131
165
  if (!baseRef) return null;
132
166
  try {
133
167
  const out = execFileSync('git', ['diff', '--shortstat', baseRef], { cwd, stdio: 'pipe' }).toString().trim();
134
- return parseShortstat(out);
168
+ const stat = parseShortstat(out);
169
+ const untrackedFiles = countUntrackedFiles(cwd);
170
+ return { ...stat, filesChanged: stat.filesChanged + untrackedFiles, untrackedFiles };
135
171
  } catch {
136
172
  return null;
137
173
  }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * templates-reconcile.js — reconcile a pre-existing `.morph/framework/templates/`
3
+ * copy against the project's prior recorded morph-spec version.
4
+ *
5
+ * Shared by BOTH install paths that can encounter this directory:
6
+ * - `morph-spec update` (src/commands/project/update.js) — always knows the
7
+ * project's prior version (`checkProjectOutdated().current`, read from
8
+ * `.morph/.morphversion` BEFORE this run overwrites it).
9
+ * - `morph-spec init`/`setup-infra` (src/scripts/setup-infra.js) — the SAME
10
+ * source (`getProjectMorphVersion()`, the function `checkProjectOutdated`
11
+ * itself calls), read at the same point in the run (before its own write
12
+ * of `.morph/.morphversion`), when it's available — e.g. a repair run
13
+ * over an already-initialized project. Falls back to a plain warning when
14
+ * it isn't (a project with no recorded version has nothing here for this
15
+ * function to reconcile against safely).
16
+ */
17
+
18
+ import { existsSync } from 'fs';
19
+ import fs from 'fs-extra';
20
+ import { compareVersions } from '../../utils/version-checker.js';
21
+
22
+ /**
23
+ * Last CLI version whose `update`/`setup-infra` still wrote
24
+ * `.morph/framework/templates/` unconditionally, before Task 2 of the R3
25
+ * perf plan removed that copy. Used as the decision boundary in
26
+ * `reconcileLegacyTemplatesCopy` below — see its doc comment for why a
27
+ * version check is used instead of diffing file content.
28
+ */
29
+ export const LAST_VERSION_THAT_COPIED_TEMPLATES = '8.17.0';
30
+
31
+ /**
32
+ * Reconcile a pre-existing `.morph/framework/templates/` copy now that
33
+ * neither install path unconditionally writes it anymore (Task 2, R3 perf
34
+ * plan — resolution falls back to the npm package's own framework/templates/,
35
+ * see template-registry.js / architecture-scanner.js's layered resolver).
36
+ *
37
+ * Decided from the project's PRIOR recorded CLI version (before THIS run
38
+ * writes a new one), not by diffing content against the package. A content
39
+ * diff can't tell "stale copy from an older CLI version" apart from
40
+ * "deliberate user override" — and staleness is the dominant case for
41
+ * anyone who actually runs `update`/`setup-infra` again, since template
42
+ * content does change between releases. The version boundary is unambiguous
43
+ * instead:
44
+ *
45
+ * - No local copy → nothing to do.
46
+ * - Prior version is unrecorded, OR <= LAST_VERSION_THAT_COPIED_TEMPLATES
47
+ * → the copy could ONLY have been put there by the OLD installer:
48
+ * `.morph/framework/**` is Edit-denied to the agent (MORPH_DENY in
49
+ * hooks-installer.js — "installer-managed, immutable at runtime"),
50
+ * the directory is gitignored, and every install/update before this fix
51
+ * unconditionally wiped + recopied it. There is no path by which a
52
+ * project on an old version could have a genuine override sitting
53
+ * here. Safe to delete unconditionally — it is dead weight, not data.
54
+ * - Prior version > LAST_VERSION_THAT_COPIED_TEMPLATES → this install path
55
+ * has never written this directory for this project. Anything found
56
+ * here was placed deliberately (by the user or other tooling) — this is
57
+ * the forward-looking override use case the layered resolver exists
58
+ * for. Preserve untouched, unconditionally, with no diffing.
59
+ *
60
+ * @param {string} templatesDest - .morph/framework/templates (may not exist)
61
+ * @param {string|null} priorProjectVersion - project's recorded morph-spec
62
+ * version BEFORE this run (e.g. `checkProjectOutdated().current`)
63
+ * @returns {'absent'|'removed-legacy'|'preserved-override'}
64
+ */
65
+ export function reconcileLegacyTemplatesCopy(templatesDest, priorProjectVersion) {
66
+ if (!existsSync(templatesDest)) return 'absent';
67
+
68
+ const isLegacy = !priorProjectVersion
69
+ || compareVersions(priorProjectVersion, LAST_VERSION_THAT_COPIED_TEMPLATES) <= 0;
70
+
71
+ if (!isLegacy) return 'preserved-override';
72
+
73
+ try {
74
+ fs.removeSync(templatesDest);
75
+ return 'removed-legacy';
76
+ } catch {
77
+ // Couldn't remove — don't risk a half-broken state; leave it and let
78
+ // the layered resolver's override-first check pick it up as before.
79
+ return 'preserved-override';
80
+ }
81
+ }
@@ -8,7 +8,9 @@
8
8
  * Signals checked (in order):
9
9
  * - designSystemDivergence: boolean — conflicting design sources detected
10
10
  * - standardsViolations: string[] — violations of mandate.md proibições
11
- * - verificationStatus: string — último `morph-spec verify` ('fail' pausa)
11
+ * - verificationStatus: string — último `morph-spec verify` ('fail' pausa, qualquer gate)
12
+ * - verificationStale + gate: bool/str — stamp com sha != HEAD pausa SÓ no
13
+ * ship (gate 'review') — código mudou depois do último verify
12
14
  * - evalScore: number — morph-eval score (pause if < 9)
13
15
  * - taskCount: number — total tasks (pause if > 20)
14
16
  */
@@ -64,6 +66,20 @@ export function shouldPauseGate(config, signals) {
64
66
  };
65
67
  }
66
68
 
69
+ // Furo 2: no gate de SHIP (review) apenas, um stamp de verification cujo
70
+ // sha diverge do HEAD atual significa que código mudou depois do último
71
+ // `morph-spec verify` — o resultado carimbado não cobre mais o que está
72
+ // prestes a ser aprovado. Pausa só aqui, nunca em plan/uiux/proposal: essa
73
+ // é uma regra de ship, não uma regra geral de gate. Stamp AUSENTE não entra
74
+ // aqui (verificationStale é fail-open para `false` em collectSignals
75
+ // quando não há stamp) — features legadas sem stamp continuam liberadas.
76
+ if (signals.gate === 'review' && signals.verificationStale === true) {
77
+ return {
78
+ pause: true,
79
+ reason: 'verification stamp desatualizado (sha difere do HEAD) — rode `morph-spec verify` de novo antes do ship',
80
+ };
81
+ }
82
+
67
83
  if (typeof signals.evalScore === 'number' && signals.evalScore < MIN_EVAL_SCORE) {
68
84
  return {
69
85
  pause: true,
@@ -93,6 +109,9 @@ export function getRiskSignals(signals) {
93
109
  out.push(`standards violations: ${signals.standardsViolations.join(', ')}`);
94
110
  }
95
111
  if (signals.verificationStatus === 'fail') out.push('verify vermelho');
112
+ if (signals.gate === 'review' && signals.verificationStale === true) {
113
+ out.push('verification stamp desatualizado (stale) no ship');
114
+ }
96
115
  if (typeof signals.evalScore === 'number' && signals.evalScore < MIN_EVAL_SCORE) {
97
116
  out.push(`eval score ${signals.evalScore}/10`);
98
117
  }
@@ -10,7 +10,8 @@
10
10
  */
11
11
 
12
12
  import { readFileSync, existsSync } from 'fs';
13
- import { join } from 'path';
13
+ import { join, basename } from 'path';
14
+ import { parseWorktreeList, resolvePrimaryRoot } from '../../commands/project/worktree.js';
14
15
 
15
16
  const EVENTS_FILE = join('.morph', 'logs', 'events.jsonl');
16
17
 
@@ -37,6 +38,45 @@ export function loadTelemetry(projectPath) {
37
38
  }
38
39
  }
39
40
 
41
+ /**
42
+ * Enumerate every git worktree that has its OWN `.morph/logs/events.jsonl` —
43
+ * the data source for `cost --all-trees` (Furo 3). Reuses the SAME worktree
44
+ * enumeration `doctor --worktrees`/`fleet` already use (`parseWorktreeList` +
45
+ * `resolvePrimaryRoot` from commands/project/worktree.js — see
46
+ * src/lib/worktree/fleet-scanner.js and worktree-audit.js for the sibling
47
+ * consumers) instead of re-walking `git worktree list` here.
48
+ *
49
+ * `git worktree list --porcelain` always lists the PRIMARY worktree first —
50
+ * that entry is labeled `'root'`; every other entry is labeled by its
51
+ * directory basename (typically the feature slug, e.g. `worktrees/{f}` →
52
+ * `f`). Only trees whose events.jsonl actually exists are returned — a
53
+ * worktree that never ran the harness contributes nothing, silently.
54
+ *
55
+ * Fail-open, like `loadTelemetry`: outside a git repo (or on any git error)
56
+ * `resolvePrimaryRoot`/`parseWorktreeList` degrade to a single root entry —
57
+ * never throws.
58
+ *
59
+ * @param {string} [cwd]
60
+ * @returns {Array<{ tree: string, path: string, events: Object[] }>}
61
+ */
62
+ export function loadTelemetryAllTrees(cwd = process.cwd()) {
63
+ const root = resolvePrimaryRoot(cwd);
64
+ const entries = parseWorktreeList(root);
65
+ const trees = entries.length ? entries : [{ path: root }];
66
+
67
+ const out = [];
68
+ for (const t of trees) {
69
+ const eventsPath = join(t.path, EVENTS_FILE);
70
+ if (!existsSync(eventsPath)) continue;
71
+ out.push({
72
+ tree: t.path === root ? 'root' : basename(t.path),
73
+ path: t.path,
74
+ events: loadTelemetry(t.path),
75
+ });
76
+ }
77
+ return out;
78
+ }
79
+
40
80
  function rank(map) {
41
81
  return [...map.entries()]
42
82
  .map(([name, count]) => ({ name, count }))
@@ -211,6 +251,26 @@ function finalizeTask(state) {
211
251
  * check) — today only Read events carry `bytes`, but this stays correct if
212
252
  * that widens rather than reporting a silent 0.
213
253
  *
254
+ * Dispatch prompt size (Furo 3 — contract with the hooks-side telemetry-log
255
+ * rewrite): a dispatch event's `prompt` field is now TRUNCATED to 2k chars on
256
+ * write, with the original length carried separately as `promptChars`. When
257
+ * `promptChars` (a number) is present it is preferred; a legacy stream that
258
+ * only ever had the untruncated `prompt` string falls back to
259
+ * `prompt.length`, unchanged from before — every existing byte count stays
260
+ * exact against old logs.
261
+ *
262
+ * SessionUsage events (`{ event: 'SessionUsage', inputTokens, outputTokens,
263
+ * cacheReadTokens, cacheCreationTokens }`) are a session-level rollup, not a
264
+ * per-tool event — they don't touch toolCounts/bytesByKind/dispatches, only
265
+ * sum into `sessionUsage` (real, hook-observed token counts — NOT the bytes
266
+ * proxy). They participate in the `--feature` filter exactly like every
267
+ * other event (via `scoped`, computed once at the top): a rollup with no
268
+ * `feature` field is silently excluded by a feature-scoped call, same as any
269
+ * other feature-less event would be — no special-casing. `sessionUsage` is
270
+ * `null` when the stream carries none (nothing to show), never a zeroed
271
+ * object, so a caller can tell "observed, all zero" apart from "never
272
+ * observed" the same way `evalScore` does in gate-check's signals.
273
+ *
214
274
  * @param {Object[]} events - raw events (from loadTelemetry)
215
275
  * @param {{ feature?: string }} [opts] - optional feature filter
216
276
  * @returns {{
@@ -220,11 +280,14 @@ function finalizeTask(state) {
220
280
  * bytesByKind: Object<string, number>,
221
281
  * bytesTotal: number,
222
282
  * dispatches: {count:number, promptChars:number},
283
+ * sessionUsage: {inputTokens:number, outputTokens:number, cacheReadTokens:number, cacheCreationTokens:number, sessions:number}|null,
223
284
  * tools: Array<{name:string,count:number}>,
224
285
  * tasks: Array<Object>,
225
286
  * phases: Array<{phase:string, bytesByKind:Object<string,number>, bytesTotal:number}>,
226
287
  * }}
227
288
  */
289
+ const SESSION_USAGE_KEYS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheCreationTokens'];
290
+
228
291
  export function aggregateCost(events, opts = {}) {
229
292
  const scoped = opts.feature
230
293
  ? (events || []).filter(e => e.feature === opts.feature)
@@ -237,6 +300,8 @@ export function aggregateCost(events, opts = {}) {
237
300
  const phaseBytes = new Map(); // phase -> Map(kind -> bytes)
238
301
  const activeByFeature = new Map(); // featureKey -> open task state
239
302
  const tasks = [];
303
+ const sessionUsageTotals = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 };
304
+ let sessionUsageCount = 0;
240
305
 
241
306
  for (const e of scoped) {
242
307
  if (!e || typeof e !== 'object') continue;
@@ -260,6 +325,14 @@ export function aggregateCost(events, opts = {}) {
260
325
  continue;
261
326
  }
262
327
 
328
+ if (e.event === 'SessionUsage') {
329
+ sessionUsageCount += 1;
330
+ for (const key of SESSION_USAGE_KEYS) {
331
+ if (typeof e[key] === 'number' && Number.isFinite(e[key])) sessionUsageTotals[key] += e[key];
332
+ }
333
+ continue;
334
+ }
335
+
263
336
  if (e.tool) toolCounts.set(e.tool, (toolCounts.get(e.tool) || 0) + 1);
264
337
  const active = activeByFeature.get(featureKey(e));
265
338
  if (active && e.tool) {
@@ -277,12 +350,14 @@ export function aggregateCost(events, opts = {}) {
277
350
  }
278
351
  }
279
352
 
280
- if ((e.tool === 'Agent' || e.tool === 'Task') && typeof e.prompt === 'string' && e.prompt) {
353
+ const hasPromptChars = typeof e.promptChars === 'number' && Number.isFinite(e.promptChars);
354
+ if ((e.tool === 'Agent' || e.tool === 'Task') && (hasPromptChars || (typeof e.prompt === 'string' && e.prompt))) {
355
+ const chars = hasPromptChars ? e.promptChars : e.prompt.length;
281
356
  dispatches.count += 1;
282
- dispatches.promptChars += e.prompt.length;
357
+ dispatches.promptChars += chars;
283
358
  if (active) {
284
359
  active.dispatches.count += 1;
285
- active.dispatches.promptChars += e.prompt.length;
360
+ active.dispatches.promptChars += chars;
286
361
  }
287
362
  }
288
363
  }
@@ -309,6 +384,7 @@ export function aggregateCost(events, opts = {}) {
309
384
  bytesByKind: mapToObj(bytesByKindGlobal),
310
385
  bytesTotal: sumValues(bytesByKindGlobal),
311
386
  dispatches,
387
+ sessionUsage: sessionUsageCount > 0 ? { ...sessionUsageTotals, sessions: sessionUsageCount } : null,
312
388
  tools: rank(toolCounts),
313
389
  tasks,
314
390
  phases,