@polymorphism-tech/morph-spec 8.37.1 → 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 (128) 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 +54 -17
  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/testing-ai.md +2 -1
  53. package/framework/standards/architecture/code-comments.md +64 -0
  54. package/framework/standards/backend/dotnet/testing.md +22 -0
  55. package/framework/standards/frontend/admin-app/data-table.md +562 -0
  56. package/framework/standards/frontend/admin-app/feedback.md +236 -0
  57. package/framework/standards/frontend/admin-app/forms.md +232 -0
  58. package/framework/standards/frontend/admin-app/page-structure.md +203 -0
  59. package/framework/standards/frontend/nextjs/components.md +37 -58
  60. package/framework/standards/frontend/nextjs/data-fetching.md +1 -0
  61. package/framework/standards/frontend/nextjs/forms.md +31 -30
  62. package/framework/standards/frontend/nextjs/state-management.md +2 -1
  63. package/framework/standards/frontend/nextjs/testing.md +18 -0
  64. package/framework/templates/context/design-system.md +3 -3
  65. package/framework/templates/docs/proposal.md +11 -0
  66. package/framework/templates/docs/spec.md +15 -0
  67. package/framework/templates/docs/ui-mockups.md +115 -98
  68. package/framework/templates/feature/decisions.md +124 -122
  69. package/package.json +1 -1
  70. package/src/commands/graph/affected.js +4 -3
  71. package/src/commands/graph/build.js +109 -18
  72. package/src/commands/graph/explain.js +8 -5
  73. package/src/commands/graph/status.js +14 -8
  74. package/src/commands/observability/cost.js +148 -18
  75. package/src/commands/project/doctor.js +82 -10
  76. package/src/commands/project/update.js +25 -0
  77. package/src/commands/project/worktree.js +73 -90
  78. package/src/commands/state/advance.js +18 -0
  79. package/src/commands/state/approve.js +15 -0
  80. package/src/commands/state/archive.js +62 -9
  81. package/src/commands/state/finish.js +62 -14
  82. package/src/commands/state/gate-check.js +8 -1
  83. package/src/commands/state/gate-decision.js +8 -4
  84. package/src/commands/state/report-check.js +90 -9
  85. package/src/commands/state/usecase.js +66 -0
  86. package/src/commands/tasks/dag.js +17 -2
  87. package/src/commands/validation/validate-feature.js +6 -1
  88. package/src/commands/validation/verify.js +20 -1
  89. package/src/dashboard/scanner/agents-scanner.js +5 -1
  90. package/src/dashboard/scanner/hooks-scanner.js +8 -1
  91. package/src/dashboard/scanner/standards-scanner.js +2 -1
  92. package/src/lib/ai/comment-stripper.js +101 -4
  93. package/src/lib/evaluator-report.js +25 -0
  94. package/src/lib/generators/handoff-generator.js +4 -18
  95. package/src/lib/git/default-branch.js +52 -0
  96. package/src/lib/git/tree-fingerprint.js +58 -0
  97. package/src/lib/git/tree-state.js +167 -0
  98. package/src/lib/graph/god-nodes.js +12 -3
  99. package/src/lib/graph/graph-health.js +48 -2
  100. package/src/lib/graph/graph-loader.js +99 -25
  101. package/src/lib/graph/graphifyignore.js +145 -28
  102. package/src/lib/graph/sanitize.js +117 -11
  103. package/src/lib/risk-detector.js +90 -0
  104. package/src/lib/specs/acervo-format.js +70 -0
  105. package/src/lib/specs/acervo-lifecycle.js +264 -0
  106. package/src/lib/specs/adr-index.js +169 -0
  107. package/src/lib/specs/revocations.js +156 -0
  108. package/src/lib/specs/screen-checklist.js +357 -0
  109. package/src/lib/specs/usecase-check.js +20 -16
  110. package/src/lib/specs/usecase-promoter.js +103 -38
  111. package/src/lib/specs/usecase-registry.js +7 -7
  112. package/src/lib/tasks/tasks-json-parser.js +2 -3
  113. package/src/lib/telemetry/metrics.js +386 -0
  114. package/src/lib/telemetry/telemetry-reader.js +161 -51
  115. package/src/lib/telemetry/verify-event.js +79 -0
  116. package/src/lib/validators/code-budget/code-budget-validator.js +172 -0
  117. package/src/lib/validators/code-budget/comment-scanner.js +75 -0
  118. package/src/lib/validators/code-budget/index.js +9 -0
  119. package/src/lib/validators/reuse/reuse-validator.js +4 -2
  120. package/src/lib/validators/reuse/symbol-scanner.js +27 -22
  121. package/src/lib/validators/validation-runner.js +21 -5
  122. package/src/lib/validators/verify-runner.js +11 -0
  123. package/src/lib/worktree/finish-backup.js +739 -0
  124. package/src/lib/worktree/ignored-triage.js +15 -8
  125. package/src/lib/worktree/worktree-audit.js +5 -0
  126. package/src/scripts/setup-infra.js +29 -18
  127. package/src/utils/agents-installer.js +281 -12
  128. package/src/utils/hooks-installer.js +75 -15
@@ -21,7 +21,7 @@
21
21
  * Fail-open: exits 0 on any error.
22
22
  */
23
23
 
24
- import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, statSync } from 'fs';
24
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'fs';
25
25
  import { join } from 'path';
26
26
  import { createHash } from 'crypto';
27
27
  import { runBuildCheck } from '../../shared/build-check.js';
@@ -31,6 +31,7 @@ import {
31
31
  import { contextResult, runStandalone } from '../../shared/hook-response.js';
32
32
  import { logHookActivity, logEvent } from '../../shared/activity-logger.js';
33
33
  import { appendTelemetryEvent } from '../../shared/telemetry-logger.js';
34
+ import { readTranscriptUsage, usageDelta } from '../../shared/transcript-usage.js';
34
35
 
35
36
  /**
36
37
  * Audit recap.md against tasks.json + standard-citation rule.
@@ -122,80 +123,6 @@ function auditRecapAndTasks(featurePath) {
122
123
  return warnings;
123
124
  }
124
125
 
125
- // Safety valve for a pathological outlier transcript — NOT needed for the
126
- // normal case: a real 45 MB / ~16k-line transcript (inspected directly while
127
- // building this) parses+sums in ~350ms locally, well inside a Stop hook's
128
- // budget. Above this, skip rather than risk an unbounded read.
129
- const MAX_TRANSCRIPT_BYTES = 50 * 1024 * 1024; // 50 MB
130
-
131
- /**
132
- * Sum real token usage from a Claude Code session transcript (JSONL, the
133
- * `transcript_path` the Stop payload carries).
134
- *
135
- * Each assistant TURN is written to the transcript as MULTIPLE JSONL lines —
136
- * one per content block (thinking/text/tool_use) — every one of them
137
- * repeating the exact SAME turn-level `message.usage` object under the same
138
- * `message.id`. Verified against a real transcript: 5982 assistant lines but
139
- * only 3315 unique message ids, with byte-identical usage across duplicates
140
- * of the same id — summing every line naively double/triple-counts (measured
141
- * ~2x on output_tokens on that file). This is why a per-line sum is wrong and
142
- * a dedup-by-id sum is required; there is no running/cumulative total field
143
- * to lean on instead — each turn's `usage` is that turn's own cost.
144
- *
145
- * Tolerant to shape: any missing usage field reads as 0. A corrupt/truncated
146
- * line is skipped, not fatal — same discipline as telemetry-logger's reader.
147
- *
148
- * @param {string} [transcriptPath]
149
- * @returns {{inputTokens:number,outputTokens:number,cacheReadTokens:number,cacheCreationTokens:number}|null}
150
- * null when there is nothing to report (no path, missing/oversized/
151
- * unreadable file, or zero assistant messages carrying usage).
152
- */
153
- function computeSessionUsage(transcriptPath) {
154
- if (!transcriptPath || typeof transcriptPath !== 'string') return null;
155
- try {
156
- if (!existsSync(transcriptPath)) return null;
157
- if (statSync(transcriptPath).size > MAX_TRANSCRIPT_BYTES) return null;
158
-
159
- const raw = readFileSync(transcriptPath, 'utf-8');
160
- let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheCreationTokens = 0;
161
- const seenMessageIds = new Set();
162
- let sawAny = false;
163
-
164
- for (const line of raw.split('\n')) {
165
- const trimmed = line.trim();
166
- if (!trimmed) continue;
167
- let entry;
168
- try {
169
- entry = JSON.parse(trimmed);
170
- } catch {
171
- continue; // one corrupt/truncated line must not lose the whole transcript
172
- }
173
- if (entry?.type !== 'assistant') continue;
174
- const usage = entry?.message?.usage;
175
- if (!usage || typeof usage !== 'object') continue;
176
-
177
- // Dedup by message.id (fallback to the line's own uuid when a message
178
- // carries none) — first occurrence wins, duplicates carry identical
179
- // values so which one wins never matters.
180
- const dedupKey = entry?.message?.id || entry?.uuid;
181
- if (dedupKey) {
182
- if (seenMessageIds.has(dedupKey)) continue;
183
- seenMessageIds.add(dedupKey);
184
- }
185
-
186
- sawAny = true;
187
- inputTokens += Number(usage.input_tokens) || 0;
188
- outputTokens += Number(usage.output_tokens) || 0;
189
- cacheReadTokens += Number(usage.cache_read_input_tokens) || 0;
190
- cacheCreationTokens += Number(usage.cache_creation_input_tokens) || 0;
191
- }
192
-
193
- return sawAny ? { inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens } : null;
194
- } catch {
195
- return null; // fail-open
196
- }
197
- }
198
-
199
126
  // Phases where compaction is strongly recommended before starting
200
127
  const COMPACT_TRIGGER_PHASES = new Set(['implement']);
201
128
 
@@ -343,13 +270,11 @@ export async function run(payload) {
343
270
  // ────────────────────────────────────────────────────────────────────────
344
271
 
345
272
  // ── Session usage (real tokens, from the transcript) ──────────────────────
346
- // Silent side effect — never contributes to `warnings`/additionalContext,
347
- // same as telemetry-log.js's capture. Throttled: only re-emitted when the
348
- // computed totals actually changed since the last SessionUsage recorded
349
- // for this session_id, so an unchanged transcript across consecutive
350
- // Stops (no new assistant turn) does not re-append an identical event.
273
+ // Silent side effect. The transcript total is cumulative, so each event
274
+ // also carries the `delta` since the previous sample of the same session:
275
+ // summing deltas per sessionId is the only sum that does not double-count.
351
276
  try {
352
- const usage = computeSessionUsage(payload?.transcript_path);
277
+ const usage = readTranscriptUsage(payload?.transcript_path);
353
278
  if (usage) {
354
279
  const usagePath = join(memDir, 'session-usage.json');
355
280
  let stored = {};
@@ -358,15 +283,16 @@ export async function run(payload) {
358
283
  } catch { /* corrupt file — start fresh */ }
359
284
 
360
285
  const sessionKey = payload?.session_id || 'default';
361
- const last = stored[sessionKey];
362
- const unchanged = last
363
- && last.inputTokens === usage.inputTokens
364
- && last.outputTokens === usage.outputTokens
365
- && last.cacheReadTokens === usage.cacheReadTokens
366
- && last.cacheCreationTokens === usage.cacheCreationTokens;
367
-
368
- if (!unchanged) {
369
- appendTelemetryEvent({ event: 'SessionUsage', ...usage }, cwd);
286
+ const { delta, changed } = usageDelta(stored[sessionKey], usage);
287
+ if (changed) {
288
+ appendTelemetryEvent({
289
+ event: 'SessionUsage',
290
+ sessionId: payload?.session_id ?? null,
291
+ feature: name,
292
+ phase: currentPhase,
293
+ ...usage,
294
+ delta,
295
+ }, cwd);
370
296
  stored[sessionKey] = usage;
371
297
  try { writeFileSync(usagePath, JSON.stringify(stored, null, 2), 'utf-8'); } catch { /* non-blocking */ }
372
298
  }
@@ -50,6 +50,7 @@ import { contextResult, runStandalone } from '../../shared/hook-response.js';
50
50
  import { acquireClaim, foreignClaim, claimAge } from '../../shared/claim-registry.js';
51
51
  import { rotateActivityLogIfLarge } from '../../shared/activity-logger.js';
52
52
  import { rotateEventsLogIfLarge } from '../../shared/telemetry-logger.js';
53
+ import { kickGraphRefresh, seedFromPrimary } from '../../shared/graph-refresh.js';
53
54
 
54
55
  function projectDir() {
55
56
  return process.env.CLAUDE_PROJECT_DIR || process.cwd();
@@ -293,6 +294,15 @@ export async function run(payload) {
293
294
  // helpers are themselves fail-open (return false on any error).
294
295
  try { rotateActivityLogIfLarge(dir); } catch { /* non-blocking */ }
295
296
  try { rotateEventsLogIfLarge(dir); } catch { /* non-blocking */ }
297
+ // Between sessions is where commits made outside the agent (a terminal, an
298
+ // IDE, a `git pull`) are caught: the detached refresh brings the ROOT's
299
+ // graph to HEAD — never a worktree's (a detached process holding a
300
+ // worktree directory made `finish` fail on win32). A worktree that has no
301
+ // graph yet (created before seeding existed) gets the root's copy here;
302
+ // from then on it is refreshed by hand (`morph-spec graph refresh`).
303
+ // Returns immediately — nothing here waits on graphify.
304
+ try { seedFromPrimary(dir); } catch { /* non-blocking — a no-op on the root */ }
305
+ try { kickGraphRefresh(dir); } catch { /* non-blocking */ }
296
306
 
297
307
  if (isSecondaryWorktree(dir)) {
298
308
  const task = taskFromBranch(dir);
@@ -21,3 +21,99 @@ export function tokenize(command) {
21
21
  while ((m = re.exec(String(command || ''))) !== null) tokens.push(m[1] ?? m[2] ?? m[3]);
22
22
  return tokens;
23
23
  }
24
+
25
+ /** Index of the first pipe (`|`, not `||` nor `>|`) outside quotes, or -1. */
26
+ function firstUnquotedPipe(command) {
27
+ const s = String(command || '');
28
+ let quote = null;
29
+ for (let i = 0; i < s.length; i++) {
30
+ const ch = s[i];
31
+ if (quote) {
32
+ if (ch === quote) quote = null;
33
+ else if (ch === '\\' && quote === '"') i++;
34
+ continue;
35
+ }
36
+ if (ch === '\\') { i++; continue; }
37
+ if (ch === '"' || ch === "'") { quote = ch; continue; }
38
+ if (ch !== '|') continue;
39
+ if (s[i + 1] === '|') { i++; continue; }
40
+ if (s[i - 1] === '>') continue;
41
+ return i;
42
+ }
43
+ return -1;
44
+ }
45
+
46
+ /**
47
+ * Does the command pipe one process into another outside quotes? Without
48
+ * `pipefail` the shell reports the LAST command's status, so the exit code of
49
+ * `dotnet test … | grep …` is grep's, not the test run's.
50
+ * @param {string} command
51
+ * @returns {boolean}
52
+ */
53
+ export function hasUnquotedPipe(command) {
54
+ return firstUnquotedPipe(command) >= 0;
55
+ }
56
+
57
+ /**
58
+ * The work a command does, without the filter that shapes its output:
59
+ * `dotnet test 2>&1 | tail -25` and `dotnet test | tail -20` run the same
60
+ * tests. Whitespace collapsed, stderr redirections dropped.
61
+ * @param {string} command
62
+ * @returns {string}
63
+ */
64
+ export function producerOf(command) {
65
+ const s = String(command || '');
66
+ const pipe = firstUnquotedPipe(s);
67
+ return (pipe >= 0 ? s.slice(0, pipe) : s)
68
+ .replace(/\s2>(&1|\/dev\/null|nul)\b/gi, ' ')
69
+ .replace(/\s+/g, ' ')
70
+ .trim();
71
+ }
72
+
73
+ /**
74
+ * The command split at `;`, newline and background `&` outside quotes, plus
75
+ * whether a background `&` was seen. `&&`, `||` and `>&`/`&>` redirections
76
+ * stay inside their segment.
77
+ */
78
+ function topLevelSegments(command) {
79
+ const s = String(command || '');
80
+ const segments = [];
81
+ let start = 0;
82
+ let background = false;
83
+ let quote = null;
84
+ for (let i = 0; i < s.length; i++) {
85
+ const ch = s[i];
86
+ if (quote) {
87
+ if (ch === quote) quote = null;
88
+ else if (ch === '\\' && quote === '"') i++;
89
+ continue;
90
+ }
91
+ if (ch === '\\') { i++; continue; }
92
+ if (ch === '"' || ch === "'") { quote = ch; continue; }
93
+ const isAmp = ch === '&' && s[i + 1] !== '&' && s[i - 1] !== '&' && s[i - 1] !== '>' && s[i + 1] !== '>';
94
+ if (ch !== ';' && ch !== '\n' && !isAmp) continue;
95
+ if (isAmp) background = true;
96
+ segments.push(s.slice(start, i));
97
+ start = i + 1;
98
+ }
99
+ segments.push(s.slice(start));
100
+ return { segments: segments.map((x) => x.trim()).filter(Boolean), background };
101
+ }
102
+
103
+ const REPORTER = /^(?:echo|printf|true|:|exit\s+0)\b/;
104
+
105
+ /**
106
+ * Is the exit code of this command the exit code of the work it ran? The
107
+ * shell reports the last segment of a `;` sequence: `dotnet test > log;
108
+ * echo "exit=$?"` always exits 0, and so does `dotnet test || true`. A pipe
109
+ * without `pipefail` reports the filter, and a background `&` reports 0.
110
+ * @param {string} command
111
+ * @returns {boolean}
112
+ */
113
+ export function exitCodeMasked(command) {
114
+ const { segments, background } = topLevelSegments(command);
115
+ if (background) return true;
116
+ if (hasUnquotedPipe(command) && !/\bpipefail\b/.test(String(command || ''))) return true;
117
+ const last = segments[segments.length - 1] || '';
118
+ return (segments.length > 1 && REPORTER.test(last)) || /\|\|/.test(last.replace(/"[^"]*"|'[^']*'/g, ''));
119
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * git-exclude.js — o escritor ÚNICO dos blocos que o morph-spec mantém no
3
+ * `info/exclude` do git.
4
+ *
5
+ * O arquivo lido é o do COMMON dir (`git rev-parse --git-common-dir`), nunca o
6
+ * `.git/worktrees/<id>/info/exclude`: o git não lê o deste para uma worktree
7
+ * vinculada, e o do common dir vale para toda árvore do repositório — inclusive
8
+ * a raiz primária. Cada chamador passa o seu marcador, e o bloco só ganha as
9
+ * linhas que ainda faltam.
10
+ *
11
+ * Idempotente e fail-open: um exclude que não pôde ser escrito custa um
12
+ * arquivo aparecendo como `??`, nunca um erro.
13
+ */
14
+
15
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'fs';
16
+ import { join } from 'path';
17
+ import { resolveGitCommonDir } from './git-paths.js';
18
+
19
+ /**
20
+ * @param {string} cwd - qualquer diretório dentro do repositório
21
+ * @param {string[]} patterns - linhas prontas (já ancoradas, ex. `/graphify-out`)
22
+ * @param {string} marker - comentário que identifica o bloco do chamador
23
+ */
24
+ export function ensureCommonExclude(cwd, patterns, marker) {
25
+ if (!patterns.length) return;
26
+ try {
27
+ // Pelo disco, sem subprocesso: um `git` gerado aqui abria uma janela de
28
+ // console no win32 quando o chamador era um processo destacado.
29
+ const common = resolveGitCommonDir(cwd);
30
+ if (!common) return;
31
+ const infoDir = join(common, 'info');
32
+ const excludePath = join(infoDir, 'exclude');
33
+ const current = existsSync(excludePath) ? readFileSync(excludePath, 'utf8') : '';
34
+ const present = new Set(current.split(/\r?\n/).map((l) => l.trim()));
35
+ const missing = patterns.filter((p) => !present.has(p));
36
+ if (!missing.length) return;
37
+ mkdirSync(infoDir, { recursive: true });
38
+ appendFileSync(excludePath, [
39
+ current && !current.endsWith('\n') ? '\n' : '',
40
+ present.has(marker) ? '' : `${marker}\n`,
41
+ `${missing.join('\n')}\n`,
42
+ ].join(''));
43
+ } catch { /* fail-open */ }
44
+ }
@@ -83,6 +83,23 @@ export function isSecondaryWorktree(startDir) {
83
83
  }
84
84
  }
85
85
 
86
+ /**
87
+ * Top of the working tree that contains `startDir` — the directory holding the
88
+ * nearest `.git` (file or directory), which is what `git rev-parse
89
+ * --show-toplevel` answers, without the subprocess. `null` outside any repo.
90
+ *
91
+ * @param {string} startDir
92
+ * @returns {string|null}
93
+ */
94
+ export function workTreeTop(startDir) {
95
+ try {
96
+ const found = findDotGit(startDir);
97
+ return found ? dirname(found.path) : null;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
86
103
  /**
87
104
  * Resolve the git COMMON dir for `startDir` — pure filesystem, no subprocess.
88
105
  * Always returns an absolute path (unlike `git rev-parse --git-common-dir`,
@@ -117,3 +134,38 @@ export function resolveGitCommonDir(startDir) {
117
134
  return null;
118
135
  }
119
136
  }
137
+
138
+ const SHA = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
139
+
140
+ /**
141
+ * The commit HEAD points at in the PRIMARY checkout `root`, read from disk —
142
+ * `git rev-parse HEAD` without the subprocess: a detached SHA, or the branch's
143
+ * loose ref, then `packed-refs`. `null` for every shape it does not read (a
144
+ * linked worktree, reftable, a branch with no commit yet): the caller asks git.
145
+ *
146
+ * @param {string} root
147
+ * @returns {string|null}
148
+ */
149
+ export function readPrimaryHead(root) {
150
+ try {
151
+ const gitDir = join(root, '.git');
152
+ if (!statSync(gitDir).isDirectory()) return null;
153
+ const head = readFileSync(join(gitDir, 'HEAD'), 'utf8').trim();
154
+ if (SHA.test(head)) return head;
155
+ const ref = /^ref:\s*(refs\/\S+)$/.exec(head)?.[1];
156
+ if (!ref) return null;
157
+ try {
158
+ const loose = readFileSync(join(gitDir, ref), 'utf8').trim();
159
+ if (SHA.test(loose)) return loose;
160
+ } catch {
161
+ // not loose — packed
162
+ }
163
+ for (const line of readFileSync(join(gitDir, 'packed-refs'), 'utf8').split(/\r?\n/)) {
164
+ const [sha, name] = line.split(' ');
165
+ if (name === ref && SHA.test(sha)) return sha;
166
+ }
167
+ return null;
168
+ } catch {
169
+ return null;
170
+ }
171
+ }