@planu/cli 5.3.55 → 5.3.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,45 @@
1
+ ## [5.3.57] - 2026-08-26
2
+
3
+ ### Bug Fixes
4
+ - fix(SPEC-1316): stop non-array ledgers and silent reachability failures from losing pending releases
5
+ - fix(build-freshness): stop bricking a legit checkout when git is unavailable
6
+ - fix(build): reject stale local dist before dispatch (SPEC-1318)
7
+ - fix(build-freshness): allow diff-clean commits past the build stamp
8
+ - fix(release): classify pending releases by git reachability (SPEC-1316)
9
+ - fix(build): support reftable HEAD and tolerate a dirty-build-then-commit push
10
+ - fix(release): close three fail-open gaps in pending-release reconciliation
11
+ - fix(build): treat uncompiled TypeScript execution as not-applicable
12
+ - fix(release): classify pending releases by git tag reachability, not date
13
+ - fix(build): reject stale local dist output before CLI/MCP dispatch
14
+
15
+ ### Chores
16
+ - chore(planu): close SPEC-1316 and SPEC-1318
17
+ - chore(planu): add executable scenarios to SPEC-1316/1318 and file SPEC-1631
18
+ - chore(planu): record SPEC-1316/1318 implementing transitions
19
+ - chore(planu): add missing Create subsection to SPEC-1316 files ownership
20
+ - chore(planu): approve 5 more specs after adding implementation contracts and test-break evidence
21
+ - chore(planu): approve 6 reviewed specs, discard 2 stale after independent review
22
+ - chore(planu): discard 9 speculative feature specs and 5 already-fixed gate specs
23
+ - chore(planu): session checkpoint after v5.3.56
24
+
25
+
26
+ ## [5.3.56] - 2026-08-25
27
+
28
+ ### Bug Fixes
29
+ - fix(tests): anchor the revert-proof fixture to a pushed pre-guard tag
30
+ - fix(release): name the failing command and its real termination cause
31
+ - fix(release): name the blocking effect and fail closed on a broken tag lookup
32
+ - fix(release): re-anchor a superseded recovery ledger instead of dead-ending
33
+ - fix(worktree): reclaim content-equivalent worktrees and read the canonical branch
34
+
35
+ ### Chores
36
+ - chore(planu): close SPEC-1626
37
+ - chore(planu): close SPEC-1630
38
+ - chore(planu): close SPEC-1625
39
+ - chore(planu): close SPEC-1624, file SPEC-1629 transition-log rotation
40
+ - chore(planu): file SPEC-1625 and SPEC-1626 release-pipeline dogfood bugs
41
+
42
+
1
43
  ## [5.3.55] - 2026-08-25
2
44
 
3
45
  ### Bug Fixes
@@ -0,0 +1 @@
1
+ {"schemaVersion":1,"commit":"c368e9e9512d869bdabaf330a598fadc95eb2a9c"}
package/dist/cli/index.js CHANGED
@@ -6,7 +6,13 @@
6
6
  // we act as the MCP server. Otherwise we run the CLI router.
7
7
  import { route } from './router.js';
8
8
  import { installNetworkPolicy } from '../engine/network-policy.js';
9
+ import { assertLocalBuildFreshness } from '../engine/build-freshness.js';
9
10
  installNetworkPolicy();
11
+ const freshness = assertLocalBuildFreshness();
12
+ if (freshness.status === 'stale') {
13
+ process.stderr.write(`Stale local build output detected (${freshness.reason}). Run \`pnpm build:ts\` and retry.\n`);
14
+ process.exit(1);
15
+ }
10
16
  const args = process.argv.slice(2);
11
17
  // stdin.isTTY is undefined/false when piped (MCP host); true in a terminal.
12
18
  if (args.length === 0 && !process.stdin.isTTY) {
@@ -0,0 +1,6 @@
1
+ import type { BuildFreshnessResult, BuildStamp } from '../types/build-freshness.js';
2
+ export declare const BUILD_STAMP_FILENAME = ".planu-build.json";
3
+ export declare const BUILD_STAMP_SCHEMA_VERSION = 1;
4
+ export declare function writeBuildStamp(distRoot: string, stamp: Omit<BuildStamp, 'schemaVersion'>): void;
5
+ export declare function assertLocalBuildFreshness(distRoot?: string): BuildFreshnessResult;
6
+ //# sourceMappingURL=build-freshness.d.ts.map
@@ -0,0 +1,170 @@
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ export const BUILD_STAMP_FILENAME = '.planu-build.json';
6
+ export const BUILD_STAMP_SCHEMA_VERSION = 1;
7
+ const GIT_FALLBACK_TIMEOUT_MS = 2000;
8
+ function isBuildStamp(value) {
9
+ if (typeof value !== 'object' || value === null) {
10
+ return false;
11
+ }
12
+ const candidate = value;
13
+ return (candidate.schemaVersion === BUILD_STAMP_SCHEMA_VERSION &&
14
+ typeof candidate.commit === 'string' &&
15
+ candidate.commit.length > 0);
16
+ }
17
+ export function writeBuildStamp(distRoot, stamp) {
18
+ const finalPath = join(distRoot, BUILD_STAMP_FILENAME);
19
+ const tempPath = `${finalPath}.${process.pid}.tmp`;
20
+ const record = { schemaVersion: BUILD_STAMP_SCHEMA_VERSION, ...stamp };
21
+ try {
22
+ writeFileSync(tempPath, `${JSON.stringify(record)}\n`, 'utf8');
23
+ renameSync(tempPath, finalPath);
24
+ }
25
+ finally {
26
+ rmSync(tempPath, { force: true });
27
+ }
28
+ }
29
+ function readBuildStamp(distRoot) {
30
+ const stampPath = join(distRoot, BUILD_STAMP_FILENAME);
31
+ if (!existsSync(stampPath)) {
32
+ return null;
33
+ }
34
+ try {
35
+ const parsed = JSON.parse(readFileSync(stampPath, 'utf8'));
36
+ return isBuildStamp(parsed) ? parsed : null;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ function hasGitMarker(repoRoot) {
43
+ return existsSync(join(repoRoot, '.git'));
44
+ }
45
+ const GIT_SHA_RE = /^[0-9a-f]{40}$|^[0-9a-f]{64}$/iu;
46
+ const GIT_SYMBOLIC_REF_RE = /^ref:\s*(\S+)$/u;
47
+ function readTrimmed(path) {
48
+ try {
49
+ return readFileSync(path, 'utf8').trim();
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ function resolveWorktreeGitDir(repoRoot) {
56
+ const gitPath = join(repoRoot, '.git');
57
+ let stat;
58
+ try {
59
+ stat = statSync(gitPath);
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ if (stat.isDirectory()) {
65
+ return gitPath;
66
+ }
67
+ const content = readTrimmed(gitPath);
68
+ const match = content === null ? null : /^gitdir:\s*(.+)$/u.exec(content);
69
+ return match?.[1] ? resolve(repoRoot, match[1]) : null;
70
+ }
71
+ function resolveCommonGitDir(worktreeGitDir) {
72
+ const commonDirRelative = readTrimmed(join(worktreeGitDir, 'commondir'));
73
+ return commonDirRelative === null ? worktreeGitDir : resolve(worktreeGitDir, commonDirRelative);
74
+ }
75
+ function resolveRefSha(commonGitDir, ref) {
76
+ const looseRef = readTrimmed(join(commonGitDir, ref));
77
+ if (looseRef !== null && GIT_SHA_RE.test(looseRef)) {
78
+ return looseRef;
79
+ }
80
+ const packedRefs = readTrimmed(join(commonGitDir, 'packed-refs'));
81
+ if (packedRefs === null) {
82
+ return null;
83
+ }
84
+ for (const line of packedRefs.split('\n')) {
85
+ const [sha, packedRef] = line.split(' ');
86
+ if (packedRef === ref && sha !== undefined && GIT_SHA_RE.test(sha)) {
87
+ return sha;
88
+ }
89
+ }
90
+ return null;
91
+ }
92
+ function resolveHeadFromFilesystem(repoRoot) {
93
+ const worktreeGitDir = resolveWorktreeGitDir(repoRoot);
94
+ if (worktreeGitDir === null) {
95
+ return null;
96
+ }
97
+ const headContent = readTrimmed(join(worktreeGitDir, 'HEAD'));
98
+ if (headContent === null) {
99
+ return null;
100
+ }
101
+ if (GIT_SHA_RE.test(headContent)) {
102
+ return headContent;
103
+ }
104
+ const symbolicMatch = GIT_SYMBOLIC_REF_RE.exec(headContent);
105
+ if (!symbolicMatch) {
106
+ return null;
107
+ }
108
+ const ref = symbolicMatch[1];
109
+ return ref === undefined ? null : resolveRefSha(resolveCommonGitDir(worktreeGitDir), ref);
110
+ }
111
+ function runGitRevParse(repoRoot, revision) {
112
+ try {
113
+ const output = execFileSync('git', ['rev-parse', revision], {
114
+ cwd: repoRoot,
115
+ encoding: 'utf8',
116
+ timeout: GIT_FALLBACK_TIMEOUT_MS,
117
+ killSignal: 'SIGKILL',
118
+ stdio: ['ignore', 'pipe', 'ignore'],
119
+ }).trim();
120
+ return GIT_SHA_RE.test(output) ? output : null;
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
126
+ function resolveHead(repoRoot) {
127
+ return resolveHeadFromFilesystem(repoRoot) ?? runGitRevParse(repoRoot, 'HEAD');
128
+ }
129
+ const BUILD_INPUT_ROOT = 'src';
130
+ function sourceUnchanged(repoRoot, diffArgs) {
131
+ const result = spawnSync('git', ['diff', '--quiet', ...diffArgs, '--', BUILD_INPUT_ROOT], {
132
+ cwd: repoRoot,
133
+ timeout: GIT_FALLBACK_TIMEOUT_MS,
134
+ killSignal: 'SIGKILL',
135
+ stdio: 'ignore',
136
+ });
137
+ return result.error === undefined && result.signal === null && result.status === 0;
138
+ }
139
+ function sourceMatchesRevision(repoRoot, stampCommit, head) {
140
+ return sourceUnchanged(repoRoot, [stampCommit, head]) || sourceUnchanged(repoRoot, [stampCommit]);
141
+ }
142
+ function resolveDistRoot() {
143
+ return resolve(dirname(fileURLToPath(import.meta.url)), '..');
144
+ }
145
+ function isRunningFromCompiledModule() {
146
+ return fileURLToPath(import.meta.url).endsWith('.js');
147
+ }
148
+ export function assertLocalBuildFreshness(distRoot) {
149
+ if (distRoot === undefined && !isRunningFromCompiledModule()) {
150
+ return { status: 'not-applicable' };
151
+ }
152
+ const resolvedDistRoot = distRoot ?? resolveDistRoot();
153
+ const repoRoot = resolve(resolvedDistRoot, '..');
154
+ if (!hasGitMarker(repoRoot)) {
155
+ return { status: 'not-applicable' };
156
+ }
157
+ const stamp = readBuildStamp(resolvedDistRoot);
158
+ if (!stamp) {
159
+ return { status: 'stale', reason: 'missing or malformed build provenance' };
160
+ }
161
+ const head = resolveHead(repoRoot);
162
+ if (!head) {
163
+ return { status: 'not-applicable' };
164
+ }
165
+ if (stamp.commit === head || sourceMatchesRevision(repoRoot, stamp.commit, head)) {
166
+ return { status: 'fresh' };
167
+ }
168
+ return { status: 'stale', reason: 'build provenance does not match the current revision' };
169
+ }
170
+ //# sourceMappingURL=build-freshness.js.map
@@ -1,5 +1,12 @@
1
1
  import type { CascadeContext, CoreActionResult, PendingReleaseEntry } from '../../../types/cascade-hooks.js';
2
- export declare function normalizePendingReleaseEntries(value: unknown, now?: Date): PendingReleaseEntry[];
3
- export declare function reconcilePublishedPendingEntries(entries: readonly PendingReleaseEntry[], latestPublishedAt: string | null): PendingReleaseEntry[];
2
+ import { type ReleaseTagInfo } from '../../../../scripts/lib/pending-release-file.mjs';
3
+ export declare function normalizePendingReleaseEntries(value: unknown): (PendingReleaseEntry & {
4
+ implementationCommit?: string;
5
+ })[];
6
+ export declare function reconcilePublishedPendingEntries(entries: readonly (PendingReleaseEntry & {
7
+ implementationCommit?: string;
8
+ })[], release: ReleaseTagInfo | null): (PendingReleaseEntry & {
9
+ implementationCommit?: string;
10
+ })[];
4
11
  export declare function appendReleasesPending(ctx: CascadeContext): Promise<CoreActionResult>;
5
12
  //# sourceMappingURL=append-releases.d.ts.map
@@ -6,65 +6,70 @@ import { readFile, mkdir } from 'node:fs/promises';
6
6
  import { execFile as execFileCallback } from 'node:child_process';
7
7
  import { promisify } from 'node:util';
8
8
  import { specStore } from '../../../storage/index.js';
9
- import { withPendingReleaseLock, writePendingReleaseLedger, } from '../../../../scripts/lib/pending-release-file.mjs';
9
+ import { reportClassifiedDegradation } from '../../../errors/classified-degradation.js';
10
+ import { isCommitReachable, resolveLatestReleaseTag, withPendingReleaseLock, writePendingReleaseLedger, } from '../../../../scripts/lib/pending-release-file.mjs';
10
11
  const CORE_ACTION_BUDGET_MS = 2000;
11
- const PENDING_ENTRY_MAX_AGE_DAYS = 30;
12
12
  const execFile = promisify(execFileCallback);
13
13
  function isValidPendingReleaseEntry(value) {
14
- return (typeof value === 'object' &&
15
- value !== null &&
16
- typeof value.specId === 'string' &&
17
- typeof value.title === 'string' &&
18
- typeof value.completedAt === 'string');
19
- }
20
- function isRecentPendingEntry(entry, now = new Date()) {
21
- const completedAt = new Date(entry.completedAt);
22
- if (Number.isNaN(completedAt.getTime())) {
14
+ if (typeof value !== 'object' || value === null) {
23
15
  return false;
24
16
  }
25
- const ageMs = now.getTime() - completedAt.getTime();
26
- return ageMs <= PENDING_ENTRY_MAX_AGE_DAYS * 24 * 60 * 60 * 1000;
17
+ const candidate = value;
18
+ return (typeof candidate.specId === 'string' &&
19
+ typeof candidate.title === 'string' &&
20
+ typeof candidate.completedAt === 'string' &&
21
+ (candidate.implementationCommit === undefined ||
22
+ typeof candidate.implementationCommit === 'string'));
23
+ }
24
+ function hasParsableCompletionDate(entry) {
25
+ return !Number.isNaN(new Date(entry.completedAt).getTime());
27
26
  }
28
- export function normalizePendingReleaseEntries(value, now = new Date()) {
27
+ export function normalizePendingReleaseEntries(value) {
29
28
  if (!Array.isArray(value)) {
30
- return [];
29
+ throw new Error('Pending release ledger is not an array');
31
30
  }
32
31
  const deduped = new Map();
33
32
  for (const entry of value) {
34
- if (!isValidPendingReleaseEntry(entry) || !isRecentPendingEntry(entry, now)) {
33
+ if (!isValidPendingReleaseEntry(entry) || !hasParsableCompletionDate(entry)) {
35
34
  continue;
36
35
  }
37
36
  deduped.set(entry.specId, entry);
38
37
  }
39
38
  return [...deduped.values()];
40
39
  }
41
- export function reconcilePublishedPendingEntries(entries, latestPublishedAt) {
42
- if (!latestPublishedAt || !/^\d{4}-\d{2}-\d{2}/.test(latestPublishedAt)) {
40
+ export function reconcilePublishedPendingEntries(entries, release) {
41
+ if (!release) {
43
42
  return [...entries];
44
43
  }
45
- const releaseDay = latestPublishedAt.slice(0, 10);
46
- // Date-only completion metadata cannot prove ordering within the release day.
47
- // Preserve same-day entries fail-closed; a later release can reconcile them safely.
48
- return entries.filter((entry) => entry.completedAt.slice(0, 10) >= releaseDay);
44
+ let reachabilityCheckFailed = false;
45
+ return entries.filter((entry) => {
46
+ if (!entry.implementationCommit) {
47
+ return true;
48
+ }
49
+ try {
50
+ return !isCommitReachable(release.repoRoot, entry.implementationCommit, release.commit);
51
+ }
52
+ catch (error) {
53
+ if (!reachabilityCheckFailed) {
54
+ reachabilityCheckFailed = true;
55
+ reportClassifiedDegradation('PENDING_RELEASE_REACHABILITY_CHECK_FAILED', error);
56
+ }
57
+ return true;
58
+ }
59
+ });
49
60
  }
50
- async function readLatestPublishedReleaseDate(projectPath) {
61
+ async function captureHeadCommit(projectPath) {
51
62
  try {
52
- const manifest = JSON.parse(await readFile(join(projectPath, 'package.json'), 'utf-8'));
53
- if (typeof manifest.version !== 'string' ||
54
- !/^\d+\.\d+\.\d+(?:[-+].*)?$/.test(manifest.version)) {
55
- return null;
56
- }
57
- const tag = `v${manifest.version}`;
58
- const { stdout } = await execFile('git', ['show', '-s', '--format=%cI', tag], {
63
+ const { stdout } = await execFile('git', ['rev-parse', 'HEAD'], {
59
64
  cwd: projectPath,
60
65
  timeout: 1_000,
61
66
  maxBuffer: 16 * 1024,
62
67
  });
63
- const publishedAt = stdout.trim();
64
- return Number.isNaN(Date.parse(publishedAt)) ? null : publishedAt;
68
+ const commit = stdout.trim();
69
+ return commit || undefined;
65
70
  }
66
71
  catch {
67
- return null;
72
+ return undefined;
68
73
  }
69
74
  }
70
75
  async function actuallyAppendReleasesPending(ctx, opts) {
@@ -84,7 +89,8 @@ async function actuallyAppendReleasesPending(ctx, opts) {
84
89
  (async () => {
85
90
  await mkdir(releasesDir, { recursive: true });
86
91
  const spec = await specStore.getSpec(projectId, specId);
87
- const latestPublishedAt = await readLatestPublishedReleaseDate(projectPath);
92
+ const releaseInfo = await resolveLatestReleaseTag(projectPath);
93
+ const implementationCommit = await captureHeadCommit(projectPath);
88
94
  await withPendingReleaseLock(pendingPath, { timeoutMs: 1_500, signal: opts.signal }, async () => {
89
95
  let pendingList = [];
90
96
  try {
@@ -92,16 +98,23 @@ async function actuallyAppendReleasesPending(ctx, opts) {
92
98
  const parsed = JSON.parse(raw);
93
99
  pendingList = normalizePendingReleaseEntries(parsed);
94
100
  }
95
- catch {
96
- /* file doesn't exist yet — start fresh */
101
+ catch (error) {
102
+ if (error.code !== 'ENOENT') {
103
+ reportClassifiedDegradation('PENDING_RELEASE_LEDGER_UNREADABLE', error);
104
+ return;
105
+ }
97
106
  }
98
- pendingList = reconcilePublishedPendingEntries(pendingList, latestPublishedAt);
107
+ pendingList = reconcilePublishedPendingEntries(pendingList, releaseInfo);
99
108
  const completedAt = new Date().toISOString().substring(0, 10);
100
109
  pendingList = pendingList.filter((entry) => entry.specId !== specId);
101
- pendingList.push({ specId, title: spec?.title ?? specId, completedAt });
110
+ pendingList.push({
111
+ specId,
112
+ title: spec?.title ?? specId,
113
+ completedAt,
114
+ ...(implementationCommit ? { implementationCommit } : {}),
115
+ });
102
116
  await writePendingReleaseLedger(pendingPath, pendingList);
103
117
  });
104
- // Auto-commit planu/ changes so pending.json is never left unstaged
105
118
  void (async () => {
106
119
  try {
107
120
  const { planuAutoCommit } = await import('../../git/planu-autocommit.js');
package/dist/index.js CHANGED
@@ -3,6 +3,12 @@
3
3
  // Creates the MCP server, registers tools & resources, and starts stdio transport.
4
4
  import { installNetworkPolicy } from './engine/network-policy.js';
5
5
  installNetworkPolicy();
6
+ const { assertLocalBuildFreshness } = await import('./engine/build-freshness.js');
7
+ const freshness = assertLocalBuildFreshness();
8
+ if (freshness.status === 'stale') {
9
+ process.stderr.write(`Stale local build output detected (${freshness.reason}). Run \`pnpm build:ts\` and retry.\n`);
10
+ process.exit(1);
11
+ }
6
12
  // Remove obsolete entitlement state before CLI dispatch or MCP registration can read requests.
7
13
  const { removeCommercialState } = await import('./engine/migrations/remove-commercial-state.js');
8
14
  await removeCommercialState();
@@ -0,0 +1,10 @@
1
+ export type BuildFreshnessStatus = 'fresh' | 'stale' | 'not-applicable';
2
+ export interface BuildFreshnessResult {
3
+ readonly status: BuildFreshnessStatus;
4
+ readonly reason?: string;
5
+ }
6
+ export interface BuildStamp {
7
+ readonly schemaVersion: number;
8
+ readonly commit: string;
9
+ }
10
+ //# sourceMappingURL=build-freshness.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=build-freshness.js.map
@@ -169,6 +169,7 @@ export * from './spec-lock.js';
169
169
  export * from './spec-lock-v2.js';
170
170
  export * from './diagram.js';
171
171
  export * from './browser-validator.js';
172
+ export * from './build-freshness.js';
172
173
  export * from './google-workspace.js';
173
174
  export * from './skill-eval.js';
174
175
  export * from './spec-marketplace.js';
@@ -166,6 +166,7 @@ export * from './spec-lock.js';
166
166
  export * from './spec-lock-v2.js';
167
167
  export * from './diagram.js';
168
168
  export * from './browser-validator.js';
169
+ export * from './build-freshness.js';
169
170
  export * from './google-workspace.js';
170
171
  export * from './skill-eval.js';
171
172
  export * from './spec-marketplace.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "5.3.55",
3
+ "version": "5.3.57",
4
4
  "description": "Planu — MCP Server for Spec Driven Development. Cross-platform (Linux/macOS/Windows, x64/arm64).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/planu-plugin.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dev.planu.cli",
3
3
  "displayName": "Planu — Spec Driven Development",
4
4
  "description": "Manage software specs, estimations, and autonomous SDD workflows. Language-agnostic MCP server for Claude Code.",
5
- "version": "5.3.55",
5
+ "version": "5.3.57",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": ["npx", "@planu/cli@latest"],
8
8
  "packageName": "@planu/cli",
@@ -1,5 +1,9 @@
1
+ import { execFile as execFileCallback, spawnSync } from 'node:child_process';
1
2
  import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { promisify } from 'node:util';
2
5
 
6
+ const execFile = promisify(execFileCallback);
3
7
  const DEFAULT_TIMEOUT_MS = 5_000;
4
8
  const POLL_INTERVAL_MS = 10;
5
9
  const ABANDONED_LOCK_MS = 30_000;
@@ -77,6 +81,41 @@ export async function withPendingReleaseLock(pendingPath, options, task) {
77
81
  }
78
82
  }
79
83
 
84
+ const VERSION_TAG_PATTERN = /^\d+\.\d+\.\d+(?:[-+].*)?$/;
85
+
86
+ export async function resolveLatestReleaseTag(repoRoot) {
87
+ try {
88
+ const manifest = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8'));
89
+ if (typeof manifest.version !== 'string' || !VERSION_TAG_PATTERN.test(manifest.version)) {
90
+ return null;
91
+ }
92
+ const tag = `v${manifest.version}`;
93
+ const { stdout } = await execFile('git', ['rev-parse', '--verify', '-q', `${tag}^{commit}`], {
94
+ cwd: repoRoot,
95
+ timeout: 1_000,
96
+ maxBuffer: 16 * 1024,
97
+ });
98
+ const commit = stdout.trim();
99
+ return commit ? { tag, commit, repoRoot } : null;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ export function isCommitReachable(repoRoot, commit, ontoCommit) {
106
+ const result = spawnSync('git', ['merge-base', '--is-ancestor', commit, ontoCommit], {
107
+ cwd: repoRoot,
108
+ stdio: 'ignore',
109
+ timeout: 1_000,
110
+ });
111
+ if (result.error) {
112
+ throw result.error;
113
+ }
114
+ if (result.status === 0) return true;
115
+ if (result.status === 1) return false;
116
+ throw new Error(`git merge-base --is-ancestor exited with status ${String(result.status)}`);
117
+ }
118
+
80
119
  export async function writePendingReleaseLedger(pendingPath, entries) {
81
120
  const temporaryPath = `${pendingPath}.${process.pid}.${Date.now()}.tmp`;
82
121
  try {