@planu/cli 5.3.67 → 5.3.68

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,27 @@
1
+ ## [5.3.68] - 2026-08-28
2
+
3
+ ### Features
4
+ - feat(SPEC-1666): produce source-quality receipt pre-bump with fail-fast gate order
5
+
6
+ ### Bug Fixes
7
+ - fix(SPEC-1668): scope metric masking, byte-equality guard and uniform anchors per dual review
8
+ - fix(SPEC-1668): bind product-proof bump regeneration fields and harden fixture commit retry
9
+ - fix(SPEC-1666): align release-pipeline recovery suite with bump-neutral receipt contract
10
+ - fix(SPEC-1666): byte-preserving carrier normalization with derivation checks and mode-bound masking
11
+ - fix(SPEC-1315): classify degraded reads in portable spec-path migration
12
+ - fix(SPEC-1315): verify canonical root and trigger migration on lifecycle reads
13
+ - fix(SPEC-1315): keep spec paths portable across worktrees and release clones
14
+
15
+ ### Chores
16
+ - chore: absorb post-done session state
17
+ - chore(SPEC-1668): record done transition
18
+ - chore(SPEC-1668): record approval transition
19
+ - chore(SPEC-1666): record done transition
20
+ - chore(SPEC-1666): record implementing transition
21
+ - chore(SPEC-1315): record done transition and session state
22
+ - chore(specs): approve SPEC-1660..1664 with reviewer and discovery evidence
23
+
24
+
1
25
  ## [5.3.67] - 2026-08-28
2
26
 
3
27
  ### Bug Fixes
@@ -1 +1 @@
1
- {"schemaVersion":1,"commit":"fde3438aa03084c32064ee5f7ec4179e455e6062"}
1
+ {"schemaVersion":1,"commit":"39afacbb6522be60172b8571f6c5973000936a4a"}
@@ -11,10 +11,10 @@
11
11
  "^src/config/release-policy\\.json$"
12
12
  ],
13
13
  "sourceQualityPlan": [
14
- { "id": "validate", "executable": "pnpm", "args": ["validate"], "timeoutSeconds": 1800 },
15
- { "id": "strict", "executable": "pnpm", "args": ["check:strict"], "timeoutSeconds": 1800 },
16
14
  { "id": "reliability", "executable": "pnpm", "args": ["check:reliability"], "timeoutSeconds": 1800 },
17
15
  { "id": "dependency-freshness", "executable": "pnpm", "args": ["check:deps:fresh"], "timeoutSeconds": 1800 },
16
+ { "id": "validate", "executable": "pnpm", "args": ["validate"], "timeoutSeconds": 1800 },
17
+ { "id": "strict", "executable": "pnpm", "args": ["check:strict"], "timeoutSeconds": 1800 },
18
18
  { "id": "coverage", "executable": "pnpm", "args": ["test:coverage"], "timeoutSeconds": 1800 },
19
19
  { "id": "mutation", "executable": "pnpm", "args": ["audit:mutation"], "timeoutSeconds": 1800 }
20
20
  ]
@@ -1,4 +1,6 @@
1
1
  import type { RegisteredProject, GlobalProjectsRegistry } from '../types/index.js';
2
+ /** Best-effort, non-creating read of a checkout's logical project identity. */
3
+ export declare function readLogicalProjectId(projectPath: string): Promise<string | undefined>;
2
4
  /**
3
5
  * Read the full registry from disk.
4
6
  * Returns an empty registry when the file does not exist.
@@ -15,6 +17,21 @@ export declare function saveRegistry(registry: GlobalProjectsRegistry): Promise<
15
17
  * Returns the registered project entry.
16
18
  */
17
19
  export declare function addProject(projectPath: string): Promise<RegisteredProject>;
20
+ /** Stable, classified failure for a logical project id with no unambiguous canonical root. */
21
+ export type CanonicalRootResult = {
22
+ readonly ok: true;
23
+ readonly root: string;
24
+ } | {
25
+ readonly ok: false;
26
+ readonly reason: 'zero' | 'ambiguous';
27
+ readonly roots: readonly string[];
28
+ };
29
+ /**
30
+ * Resolve the single canonical root registered for a logical project identity.
31
+ * Fails closed — never guesses a first match — when zero or more than one
32
+ * distinct registered path claims the same `logicalProjectId`.
33
+ */
34
+ export declare function getCanonicalRoot(logicalProjectId: string): Promise<CanonicalRootResult>;
18
35
  /**
19
36
  * Remove a project from the registry by path.
20
37
  * Returns true if the project was found and removed, false otherwise.
@@ -4,6 +4,23 @@ import { readJson, writeJson, globalDataDir, hashProjectPath } from './base-stor
4
4
  import { withFileLock } from './file-mutex.js';
5
5
  import { isEphemeralProject } from '../engine/data-projects-gc/pattern-matcher.js';
6
6
  import { reportClassifiedDegradation } from '../errors/classified-degradation.js';
7
+ import { readFile } from 'node:fs/promises';
8
+ import { join } from 'node:path';
9
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
10
+ /** Best-effort, non-creating read of a checkout's logical project identity. */
11
+ export async function readLogicalProjectId(projectPath) {
12
+ try {
13
+ const raw = JSON.parse(await readFile(join(projectPath, 'planu', 'project.json'), 'utf8'));
14
+ const id = raw?.logicalProjectId;
15
+ return typeof id === 'string' && UUID_PATTERN.test(id) ? id : undefined;
16
+ }
17
+ catch (error) {
18
+ if (error.code !== 'ENOENT') {
19
+ reportClassifiedDegradation('LOGICAL_PROJECT_ID_READ_FAILED', error);
20
+ }
21
+ return undefined;
22
+ }
23
+ }
7
24
  // ---------------------------------------------------------------------------
8
25
  // File path
9
26
  // ---------------------------------------------------------------------------
@@ -46,11 +63,15 @@ export async function addProject(projectPath) {
46
63
  specCount: 0,
47
64
  };
48
65
  }
66
+ const logicalProjectId = await readLogicalProjectId(projectPath);
49
67
  return withFileLock(registryFile(), async () => {
50
68
  const registry = await getRegistry();
51
69
  const existing = registry.projects.find((p) => p.path === projectPath);
52
70
  if (existing !== undefined) {
53
71
  existing.hash = hash;
72
+ if (logicalProjectId !== undefined) {
73
+ existing.logicalProjectId = logicalProjectId;
74
+ }
54
75
  registry.updatedAt = new Date().toISOString();
55
76
  await saveRegistry(registry);
56
77
  return existing;
@@ -60,6 +81,7 @@ export async function addProject(projectPath) {
60
81
  hash,
61
82
  registeredAt: new Date().toISOString(),
62
83
  specCount: 0,
84
+ ...(logicalProjectId !== undefined && { logicalProjectId }),
63
85
  };
64
86
  registry.projects.push(entry);
65
87
  registry.updatedAt = new Date().toISOString();
@@ -67,6 +89,30 @@ export async function addProject(projectPath) {
67
89
  return entry;
68
90
  });
69
91
  }
92
+ /**
93
+ * Resolve the single canonical root registered for a logical project identity.
94
+ * Fails closed — never guesses a first match — when zero or more than one
95
+ * distinct registered path claims the same `logicalProjectId`.
96
+ */
97
+ export async function getCanonicalRoot(logicalProjectId) {
98
+ const projects = await getProjects();
99
+ const roots = [
100
+ ...new Set(projects
101
+ .filter((project) => project.logicalProjectId === logicalProjectId)
102
+ .map((project) => project.path)),
103
+ ];
104
+ if (roots.length === 0) {
105
+ return { ok: false, reason: 'zero', roots };
106
+ }
107
+ if (roots.length > 1) {
108
+ return { ok: false, reason: 'ambiguous', roots };
109
+ }
110
+ const [root] = roots;
111
+ if (root === undefined) {
112
+ return { ok: false, reason: 'zero', roots };
113
+ }
114
+ return { ok: true, root };
115
+ }
70
116
  /**
71
117
  * Remove a project from the registry by path.
72
118
  * Returns true if the project was found and removed, false otherwise.
@@ -27,6 +27,33 @@ export declare function hashWorkspaceKey(workspaceKey: string): string;
27
27
  export declare function deriveWorkspaceKey(projectPath: string): Promise<string>;
28
28
  /** Resolve or create stable project and machine-workspace UUIDs. */
29
29
  export declare function ensureProjectIdentity(projectPath: string, options: ProjectIdentityOptions): Promise<ResolvedProjectIdentity>;
30
+ export type PortablePathErrorCode = 'INVALID_TYPE' | 'EMPTY' | 'NUL_BYTE' | 'NOT_PORTABLE_SHAPE' | 'WRONG_SPEC' | 'ESCAPES_ROOT' | 'ROOT_NOT_FOUND' | 'ROOT_UNVERIFIED' | 'NOT_FOUND' | 'SYMLINK' | 'NOT_FILE';
31
+ /** Stable, classified failure for a spec path that cannot be resolved as a portable identity. */
32
+ export declare class PortablePathError extends Error {
33
+ readonly code: PortablePathErrorCode;
34
+ constructor(code: PortablePathErrorCode, message: string);
35
+ }
36
+ /**
37
+ * Extract the portable `planu/specs/<specId[-slug]>/<filename>` suffix from a stored
38
+ * path value, rejecting anything that is not that exact shape for the given spec.
39
+ * Works for both legacy absolute values and already-portable relative ones — the
40
+ * incoming prefix is discarded, so this doubles as the legacy-path normalizer.
41
+ */
42
+ export declare function toPortableSpecPath(specId: string, storedPath: unknown): string;
43
+ /**
44
+ * Resolve a stored `specPath`/`technicalPath` value against the verified canonical
45
+ * root, returning the realpath of the contained file. Fails closed — with a stable
46
+ * classified {@link PortablePathError} — for any absent, malformed, escaping,
47
+ * symlinked, or cross-spec value, before any content is read or written.
48
+ */
49
+ export declare function resolvePortableSpecPath(specId: string, storedPath: unknown, canonicalRoot: string): Promise<string>;
50
+ /**
51
+ * Lifecycle-consumer entry point: verify the candidate root against the
52
+ * global project registry before resolving through it, so an unregistered,
53
+ * ambiguous, or stale checkout never stands in for the real canonical root.
54
+ * Delegates to {@link resolvePortableSpecPath} once verified.
55
+ */
56
+ export declare function resolveVerifiedSpecPath(specId: string, storedPath: unknown, canonicalRoot: string): Promise<string>;
30
57
  /** Explicitly turn a copied project into an independent logical project. */
31
58
  export declare function forkProjectIdentity(projectPath: string, options: ProjectIdentityOptions): Promise<ResolvedProjectIdentity>;
32
59
  //# sourceMappingURL=project-identity.d.ts.map
@@ -1,7 +1,9 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises';
3
- import { basename, dirname, join, normalize, resolve, sep } from 'node:path';
2
+ import { lstat, mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises';
3
+ import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path';
4
4
  import { resolveStorageLayout } from './storage-layout.js';
5
+ import { getCanonicalRoot, readLogicalProjectId } from './global-projects-store.js';
6
+ import { isEphemeralProject } from '../engine/data-projects-gc/pattern-matcher.js';
5
7
  const IDENTITY_VERSION = 1;
6
8
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
7
9
  function isProjectIdentity(value) {
@@ -156,6 +158,133 @@ export async function ensureProjectIdentity(projectPath, options) {
156
158
  const workspace = await ensureWorkspaceDocument(options.layout ?? resolveStorageLayout(), options.workspaceKey, project.logicalProjectId);
157
159
  return { project, workspace, canonicalProjectPath };
158
160
  }
161
+ /** Stable, classified failure for a spec path that cannot be resolved as a portable identity. */
162
+ export class PortablePathError extends Error {
163
+ code;
164
+ constructor(code, message) {
165
+ super(message);
166
+ this.name = 'PortablePathError';
167
+ this.code = code;
168
+ }
169
+ }
170
+ /**
171
+ * Extract the portable `planu/specs/<specId[-slug]>/<filename>` suffix from a stored
172
+ * path value, rejecting anything that is not that exact shape for the given spec.
173
+ * Works for both legacy absolute values and already-portable relative ones — the
174
+ * incoming prefix is discarded, so this doubles as the legacy-path normalizer.
175
+ */
176
+ export function toPortableSpecPath(specId, storedPath) {
177
+ if (typeof storedPath !== 'string') {
178
+ throw new PortablePathError('INVALID_TYPE', '[Planu] specPath must be a string');
179
+ }
180
+ if (storedPath.includes('\0')) {
181
+ throw new PortablePathError('NUL_BYTE', '[Planu] specPath must not contain a NUL byte');
182
+ }
183
+ if (storedPath.trim().length === 0) {
184
+ throw new PortablePathError('EMPTY', '[Planu] specPath must not be empty or whitespace-only');
185
+ }
186
+ const segments = normalize(storedPath).split(sep).filter(Boolean);
187
+ const planuIndex = segments.lastIndexOf('planu');
188
+ const dir = segments[planuIndex + 2];
189
+ const filename = segments[planuIndex + 3];
190
+ const isPortableShape = planuIndex >= 0 &&
191
+ segments[planuIndex + 1] === 'specs' &&
192
+ dir !== undefined &&
193
+ filename !== undefined &&
194
+ planuIndex + 4 === segments.length;
195
+ if (!isPortableShape) {
196
+ throw new PortablePathError('NOT_PORTABLE_SHAPE', '[Planu] specPath is not a portable planu/specs/<dir>/<file> contract');
197
+ }
198
+ if (dir !== specId && !dir.startsWith(`${specId}-`)) {
199
+ throw new PortablePathError('WRONG_SPEC', `[Planu] specPath directory "${dir}" does not belong to ${specId}`);
200
+ }
201
+ return join('planu', 'specs', dir, filename);
202
+ }
203
+ function rejectsRootEscape(root, candidate) {
204
+ const rel = relative(root, candidate);
205
+ return rel.startsWith(`..${sep}`) || rel === '..' || isAbsolute(rel);
206
+ }
207
+ async function realpathClassified(path, notFoundCode) {
208
+ try {
209
+ return await realpath(path);
210
+ }
211
+ catch (error) {
212
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
213
+ throw new PortablePathError(notFoundCode, `[Planu] path not found: ${notFoundCode}`);
214
+ }
215
+ throw error;
216
+ }
217
+ }
218
+ /**
219
+ * Reject a candidate root that carries a tracked logical project identity but
220
+ * is not the single registry-confirmed canonical root for it — a checkout
221
+ * left over from a rename/fork, or an ambiguous/unregistered project never
222
+ * resolves specs against an arbitrary directory. A candidate with no tracked
223
+ * identity (`planu/project.json` absent) is untracked, not unverified, and
224
+ * passes through unchanged — as does an ephemeral checkout (test fixture,
225
+ * scratch clone), since the global registry deliberately excludes those
226
+ * (SPEC-581) and can never confirm a root for them.
227
+ */
228
+ async function rejectsUnverifiedRoot(root) {
229
+ if (isEphemeralProject(root)) {
230
+ return false;
231
+ }
232
+ const logicalProjectId = await readLogicalProjectId(root);
233
+ if (logicalProjectId === undefined) {
234
+ return false;
235
+ }
236
+ const canonical = await getCanonicalRoot(logicalProjectId);
237
+ return !canonical.ok || resolve(canonical.root) !== root;
238
+ }
239
+ /**
240
+ * Resolve a stored `specPath`/`technicalPath` value against the verified canonical
241
+ * root, returning the realpath of the contained file. Fails closed — with a stable
242
+ * classified {@link PortablePathError} — for any absent, malformed, escaping,
243
+ * symlinked, or cross-spec value, before any content is read or written.
244
+ */
245
+ export async function resolvePortableSpecPath(specId, storedPath, canonicalRoot) {
246
+ const suffix = toPortableSpecPath(specId, storedPath);
247
+ const root = resolve(canonicalRoot);
248
+ const candidate = resolve(root, suffix);
249
+ if (rejectsRootEscape(root, candidate)) {
250
+ throw new PortablePathError('ESCAPES_ROOT', '[Planu] specPath escapes the canonical root');
251
+ }
252
+ const rootReal = await realpathClassified(root, 'ROOT_NOT_FOUND');
253
+ let candidateReal;
254
+ let candidateStat;
255
+ try {
256
+ candidateReal = await realpathClassified(candidate, 'NOT_FOUND');
257
+ candidateStat = await lstat(candidate);
258
+ }
259
+ catch (error) {
260
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
261
+ throw new PortablePathError('NOT_FOUND', '[Planu] spec file not found under canonical root');
262
+ }
263
+ throw error;
264
+ }
265
+ if (candidateStat.isSymbolicLink()) {
266
+ throw new PortablePathError('SYMLINK', '[Planu] specPath must not resolve through a symlink');
267
+ }
268
+ if (!candidateStat.isFile()) {
269
+ throw new PortablePathError('NOT_FILE', '[Planu] specPath must resolve to a regular file');
270
+ }
271
+ if (rejectsRootEscape(rootReal, candidateReal)) {
272
+ throw new PortablePathError('ESCAPES_ROOT', '[Planu] specPath escapes the canonical root');
273
+ }
274
+ return candidateReal;
275
+ }
276
+ /**
277
+ * Lifecycle-consumer entry point: verify the candidate root against the
278
+ * global project registry before resolving through it, so an unregistered,
279
+ * ambiguous, or stale checkout never stands in for the real canonical root.
280
+ * Delegates to {@link resolvePortableSpecPath} once verified.
281
+ */
282
+ export async function resolveVerifiedSpecPath(specId, storedPath, canonicalRoot) {
283
+ if (await rejectsUnverifiedRoot(resolve(canonicalRoot))) {
284
+ throw new PortablePathError('ROOT_UNVERIFIED', '[Planu] canonical root is not the single registry-confirmed root for this project');
285
+ }
286
+ return resolvePortableSpecPath(specId, storedPath, canonicalRoot);
287
+ }
159
288
  /** Explicitly turn a copied project into an independent logical project. */
160
289
  export async function forkProjectIdentity(projectPath, options) {
161
290
  const canonicalProjectPath = await canonicalizeProjectPath(projectPath);
@@ -31,10 +31,31 @@ export declare function listSpecs(projectId: string): Promise<Spec[]>;
31
31
  export declare function withFreshSpecsLock<T>(projectId: string, fn: (specs: Spec[]) => Promise<T>): Promise<T>;
32
32
  /**
33
33
  * Get a single spec by ID. Returns `null` when not found.
34
- */
35
- export declare function getSpec(projectId: string, specId: string): Promise<Spec | null>;
34
+ *
35
+ * When `canonicalRoot` is supplied, a legacy absolute `specPath`/`technicalPath`
36
+ * for this spec is migrated to its portable project-relative identity first
37
+ * (best-effort — a failed migration attempt never blocks the read).
38
+ */
39
+ export declare function getSpec(projectId: string, specId: string, canonicalRoot?: string): Promise<Spec | null>;
40
+ /** Result of a {@link migrateLegacySpecPaths} run. */
41
+ export interface SpecPathMigrationResult {
42
+ readonly migrated: number;
43
+ readonly specIds: readonly string[];
44
+ }
45
+ /**
46
+ * Atomically migrate every eligible legacy absolute `specPath`/`technicalPath`
47
+ * in this project to its portable project-relative identity.
48
+ *
49
+ * Runs under the existing cross-process spec-store lock. A record migrates only
50
+ * when its normalized relative suffix identifies the same spec and the file
51
+ * exists under the verified `canonicalRoot` — never by guessing. Migration
52
+ * changes only the stored path identity; it never touches spec content.
53
+ * Idempotent: a spec already holding a portable path is a no-op. Emits at
54
+ * most one audit event per call, regardless of how many specs migrated.
55
+ */
56
+ export declare function migrateLegacySpecPaths(projectId: string, canonicalRoot: string, specIds?: readonly string[]): Promise<SpecPathMigrationResult>;
36
57
  /** Reload a spec from disk after the caller acquires its cross-process lock. */
37
- export declare function getSpecFresh(projectId: string, specId: string): Promise<Spec | null>;
58
+ export declare function getSpecFresh(projectId: string, specId: string, canonicalRoot?: string): Promise<Spec | null>;
38
59
  /** SPEC-601: Lookup a spec by its globally unique UUID. */
39
60
  export declare function getSpecByUuid(projectId: string, uuid: string): Promise<Spec | null>;
40
61
  /**
@@ -6,7 +6,11 @@ import { withFileLock } from './file-mutex.js';
6
6
  import { computeHealthScore } from '../engine/spec-health-scorer.js';
7
7
  import { loadSpecContent } from '../engine/validation-loop.js';
8
8
  import { createHash, randomUUID } from 'node:crypto';
9
+ import { isAbsolute } from 'node:path';
9
10
  import { acquireLock, releaseLock } from '../engine/safety/cross-process-lock.js';
11
+ import { resolvePortableSpecPath, toPortableSpecPath, PortablePathError, } from './project-identity.js';
12
+ import { appendEntry, getLastHash } from './audit-trail-store.js';
13
+ import { hashEntry } from '../engine/audit-trail/hasher.js';
10
14
  // ---------------------------------------------------------------------------
11
15
  // SPEC-720: Guard error — direct status write forbidden
12
16
  // ---------------------------------------------------------------------------
@@ -213,21 +217,136 @@ export async function withFreshSpecsLock(projectId, fn) {
213
217
  }
214
218
  /**
215
219
  * Get a single spec by ID. Returns `null` when not found.
220
+ *
221
+ * When `canonicalRoot` is supplied, a legacy absolute `specPath`/`technicalPath`
222
+ * for this spec is migrated to its portable project-relative identity first
223
+ * (best-effort — a failed migration attempt never blocks the read).
216
224
  */
217
- export async function getSpec(projectId, specId) {
225
+ export async function getSpec(projectId, specId, canonicalRoot) {
226
+ if (canonicalRoot !== undefined) {
227
+ await migrateLegacySpecPaths(projectId, canonicalRoot, [specId]).catch((error) => {
228
+ reportClassifiedDegradation('SPEC_PATH_MIGRATION_DEFERRED', error);
229
+ });
230
+ }
218
231
  const specs = await loadAll(projectId);
219
232
  return specs.find((s) => s.id === specId) ?? null;
220
233
  }
234
+ /**
235
+ * Record one hash-chained audit event for a legacy specPath migration batch.
236
+ * Best-effort: an audit failure never rolls back an already-persisted migration.
237
+ */
238
+ function recordSpecPathMigrationAudit(projectId, specIds) {
239
+ try {
240
+ const partial = {
241
+ id: randomUUID(),
242
+ timestamp: new Date().toISOString(),
243
+ toolName: 'spec-store.migrateLegacySpecPaths',
244
+ inputSummary: `projectId=${projectId} specIds=${specIds.join(',')}`.slice(0, 200),
245
+ outputType: 'success',
246
+ durationMs: 0,
247
+ prevHash: getLastHash(),
248
+ };
249
+ appendEntry({ ...partial, hash: hashEntry(partial) });
250
+ }
251
+ catch (error) {
252
+ reportClassifiedDegradation('SPEC_PATH_MIGRATION_AUDIT', error);
253
+ }
254
+ }
255
+ /**
256
+ * Atomically migrate every eligible legacy absolute `specPath`/`technicalPath`
257
+ * in this project to its portable project-relative identity.
258
+ *
259
+ * Runs under the existing cross-process spec-store lock. A record migrates only
260
+ * when its normalized relative suffix identifies the same spec and the file
261
+ * exists under the verified `canonicalRoot` — never by guessing. Migration
262
+ * changes only the stored path identity; it never touches spec content.
263
+ * Idempotent: a spec already holding a portable path is a no-op. Emits at
264
+ * most one audit event per call, regardless of how many specs migrated.
265
+ */
266
+ export async function migrateLegacySpecPaths(projectId, canonicalRoot, specIds) {
267
+ return withSpecMutationLock(projectId, async () => {
268
+ const specs = await loadAll(projectId);
269
+ const scope = specIds ? new Set(specIds) : null;
270
+ const migratedIds = [];
271
+ const next = await Promise.all(specs.map(async (spec) => {
272
+ if (scope && !scope.has(spec.id)) {
273
+ return spec;
274
+ }
275
+ if (typeof spec.specPath !== 'string' || !isAbsolute(spec.specPath)) {
276
+ return spec;
277
+ }
278
+ try {
279
+ await resolvePortableSpecPath(spec.id, spec.specPath, canonicalRoot);
280
+ }
281
+ catch (error) {
282
+ reportClassifiedDegradation('SPEC_PATH_MIGRATION_SKIPPED', error);
283
+ return spec;
284
+ }
285
+ const portableSpecPath = toPortableSpecPath(spec.id, spec.specPath);
286
+ let portableTechnicalPath = spec.technicalPath;
287
+ if (typeof spec.technicalPath === 'string' && isAbsolute(spec.technicalPath)) {
288
+ try {
289
+ portableTechnicalPath = toPortableSpecPath(spec.id, spec.technicalPath);
290
+ }
291
+ catch (error) {
292
+ reportClassifiedDegradation('TECHNICAL_PATH_MIGRATION_SKIPPED', error);
293
+ portableTechnicalPath = spec.technicalPath;
294
+ }
295
+ }
296
+ migratedIds.push(spec.id);
297
+ return { ...spec, specPath: portableSpecPath, technicalPath: portableTechnicalPath };
298
+ }));
299
+ if (migratedIds.length > 0) {
300
+ await saveAll(projectId, next);
301
+ recordSpecPathMigrationAudit(projectId, migratedIds);
302
+ }
303
+ return { migrated: migratedIds.length, specIds: migratedIds };
304
+ });
305
+ }
221
306
  /** Reload a spec from disk after the caller acquires its cross-process lock. */
222
- export async function getSpecFresh(projectId, specId) {
307
+ export async function getSpecFresh(projectId, specId, canonicalRoot) {
223
308
  specsCache.delete(projectId);
224
- return getSpec(projectId, specId);
309
+ return getSpec(projectId, specId, canonicalRoot);
225
310
  }
226
311
  /** SPEC-601: Lookup a spec by its globally unique UUID. */
227
312
  export async function getSpecByUuid(projectId, uuid) {
228
313
  const specs = await loadAll(projectId);
229
314
  return (specs.find((spec) => spec.uuid === uuid || spec.uuidAliases?.includes(uuid) === true) ?? null);
230
315
  }
316
+ /**
317
+ * Normalize an incoming absolute `specPath`/`technicalPath` — rooted in whatever
318
+ * checkout the caller invoked from — to its portable project-relative identity
319
+ * before it ever reaches disk. A value that is not the standard
320
+ * `planu/specs/<specId[-slug]>/<file>` shape is left untouched (best-effort):
321
+ * the shared resolver still fails closed for it at read time.
322
+ */
323
+ function portableizeSpecPaths(spec) {
324
+ let specPath = spec.specPath;
325
+ let technicalPath = spec.technicalPath;
326
+ if (typeof specPath === 'string' && isAbsolute(specPath)) {
327
+ try {
328
+ specPath = toPortableSpecPath(spec.id, specPath);
329
+ }
330
+ catch (error) {
331
+ if (!(error instanceof PortablePathError)) {
332
+ throw error;
333
+ }
334
+ }
335
+ }
336
+ if (typeof technicalPath === 'string' && isAbsolute(technicalPath)) {
337
+ try {
338
+ technicalPath = toPortableSpecPath(spec.id, technicalPath);
339
+ }
340
+ catch (error) {
341
+ if (!(error instanceof PortablePathError)) {
342
+ throw error;
343
+ }
344
+ }
345
+ }
346
+ return specPath === spec.specPath && technicalPath === spec.technicalPath
347
+ ? spec
348
+ : { ...spec, specPath, technicalPath };
349
+ }
231
350
  /**
232
351
  * Create a new spec. Throws if a spec with the same ID already exists.
233
352
  */
@@ -238,9 +357,11 @@ export async function createSpec(projectId, spec) {
238
357
  throw new Error(`Spec "${spec.id}" already exists in project "${projectId}"`);
239
358
  }
240
359
  const { criteria } = await loadSpecContent(spec);
360
+ const portableSpec = portableizeSpecPaths(spec);
241
361
  const specWithScore = {
242
- ...spec,
243
- healthScore: computeHealthScore(spec, criteria.length > 0 ? criteria : undefined).score,
362
+ ...portableSpec,
363
+ healthScore: computeHealthScore(portableSpec, criteria.length > 0 ? criteria : undefined)
364
+ .score,
244
365
  };
245
366
  const nextSpecs = [...specs, specWithScore];
246
367
  await saveAll(projectId, nextSpecs);
@@ -11,5 +11,5 @@ export interface ConcurrencyAnalysisResult extends ConcurrencyAnalysis {
11
11
  export declare function generateConcurrencyAnalysis(_spec: Spec, content: string, _knowledge: ProjectKnowledge): ConcurrencyAnalysisResult;
12
12
  export declare function buildScalabilityAssessment(spec: Spec, knowledge: ProjectKnowledge, scenarios: FailureScenario[], hasRolloutEvidence?: boolean): string;
13
13
  export declare function calculateOverallRisk(scenarios: FailureScenario[], concurrency: ConcurrencyAnalysis): RiskLevel;
14
- export declare function readSpecContent(spec: Spec): Promise<string>;
14
+ export declare function readSpecContent(spec: Spec, projectPath?: string): Promise<string>;
15
15
  //# sourceMappingURL=challenge-spec-helpers.d.ts.map
@@ -1,5 +1,7 @@
1
1
  // tools/challenge-spec-helpers.ts — Concurrency, risk, and spec-reader helpers for challenge-spec
2
2
  import { readFile } from 'node:fs/promises';
3
+ import { isAbsolute } from 'node:path';
4
+ import { resolveVerifiedSpecPath } from '../storage/project-identity.js';
3
5
  import { readSpecTechnicalSection } from '../engine/spec-format/read-technical-section.js';
4
6
  import { hasAffirmedMatch, stripMetaAnalysisText, stripNonContractText, } from '../engine/text-signal-boundaries.js';
5
7
  // Bare keyword matches (counter, status, draft, ...) are common in unrelated
@@ -131,11 +133,14 @@ export function calculateOverallRisk(scenarios, concurrency) {
131
133
  return 'low';
132
134
  }
133
135
  // --- Spec content reader ---
134
- export async function readSpecContent(spec) {
136
+ export async function readSpecContent(spec, projectPath) {
135
137
  let content = `${spec.title} ${spec.type} ${spec.scope} ${spec.target} ${spec.tags.join(' ')}`;
136
138
  try {
137
139
  if (spec.specPath) {
138
- content += ' ' + (await readFile(spec.specPath, 'utf-8'));
140
+ const resolvedPath = !isAbsolute(spec.specPath) && projectPath
141
+ ? await resolveVerifiedSpecPath(spec.id, spec.specPath, projectPath)
142
+ : spec.specPath;
143
+ content += ' ' + (await readFile(resolvedPath, 'utf-8'));
139
144
  }
140
145
  }
141
146
  catch {
@@ -2,11 +2,13 @@
2
2
  // Analyzes a spec from adversarial perspectives: failure scenarios,
3
3
  // concurrency issues, scale limits, security holes, and data consistency.
4
4
  import { readFile } from 'node:fs/promises';
5
+ import { isAbsolute } from 'node:path';
5
6
  import { createHash } from 'node:crypto';
6
7
  import { specStore, knowledgeStore } from '../storage/index.js';
7
8
  // SPEC-1011 Bug F: fallback resolver using disk fingerprints
8
9
  import { resolveProjectFromPath } from '../storage/project-resolver.js';
9
10
  import { updateSpec } from '../storage/spec-store.js';
11
+ import { resolveVerifiedSpecPath } from '../storage/project-identity.js';
10
12
  import { resolveProjectId, missingProjectIdError } from './resolve-project-id.js';
11
13
  import { elicitOrFallback, buildEnumSchema } from '../engine/elicitation/elicit-helper.js';
12
14
  import { t, ti } from '../i18n/index.js';
@@ -83,13 +85,16 @@ function extractOutOfScopeSection(content) {
83
85
  }
84
86
  return found ? captured.join('\n') : null;
85
87
  }
86
- async function resolveDocumentOutOfScope(spec) {
88
+ async function resolveDocumentOutOfScope(spec, projectPath) {
87
89
  if (!spec.specPath) {
88
90
  return [];
89
91
  }
90
92
  let fileContent;
91
93
  try {
92
- fileContent = await readFile(spec.specPath, 'utf-8');
94
+ const resolvedPath = !isAbsolute(spec.specPath) && projectPath
95
+ ? await resolveVerifiedSpecPath(spec.id, spec.specPath, projectPath)
96
+ : spec.specPath;
97
+ fileContent = await readFile(resolvedPath, 'utf-8');
93
98
  }
94
99
  catch {
95
100
  return [];
@@ -97,8 +102,8 @@ async function resolveDocumentOutOfScope(spec) {
97
102
  const sectionContent = extractOutOfScopeSection(fileContent);
98
103
  return sectionContent === null ? [] : extractListItems(sectionContent);
99
104
  }
100
- async function resolveOutOfScopeItems(spec) {
101
- const documentItems = await resolveDocumentOutOfScope(spec);
105
+ async function resolveOutOfScopeItems(spec, projectPath) {
106
+ const documentItems = await resolveDocumentOutOfScope(spec, projectPath);
102
107
  return documentItems.length > 0 ? documentItems : (spec.outOfScope ?? []);
103
108
  }
104
109
  /**
@@ -192,7 +197,7 @@ export async function handleChallengeSpec(args, server) {
192
197
  // 2b. Load Constitution for compliance context
193
198
  const constitution = await knowledgeStore.getConstitution(projectId);
194
199
  // 3. Read spec content
195
- const specContent = await readSpecContent(spec);
200
+ const specContent = await readSpecContent(spec, knowledge.projectPath);
196
201
  const capabilities = detectChallengeCapabilities(spec, specContent);
197
202
  // 4. Determine focus areas
198
203
  const focusAreas = focus && focus.length > 0 ? focus : ALL_FOCUS_AREAS;
@@ -224,7 +229,7 @@ export async function handleChallengeSpec(args, server) {
224
229
  }
225
230
  failureScenarios.push(...collectCapabilityScenarios({ spec, specContent, knowledge, focusAreas, capabilities }));
226
231
  // SPEC-612: Check for contradictions between outOfScope and acceptance criteria
227
- const resolvedOutOfScope = await resolveOutOfScopeItems(spec);
232
+ const resolvedOutOfScope = await resolveOutOfScopeItems(spec, knowledge.projectPath);
228
233
  if (resolvedOutOfScope.length > 0) {
229
234
  const criteriaRange = findMarkdownSectionRange(specContent, 'Acceptance Criteria');
230
235
  const criteriaTexts = criteriaRange === null
@@ -1,6 +1,8 @@
1
1
  // tools/check-readiness.ts — Completeness checkpoint tool (SPEC-039, SPEC-314, SPEC-716)
2
2
  import { readFile } from 'node:fs/promises';
3
+ import { isAbsolute } from 'node:path';
3
4
  import { specStore } from '../storage/index.js';
5
+ import { resolveVerifiedSpecPath } from '../storage/project-identity.js';
4
6
  import { buildCheckReadinessSummary } from '../engine/human-summary.js';
5
7
  import { validateSpecFormat } from '../core/spec-validator.js';
6
8
  import { parseFrontmatter } from '../engine/frontmatter-parser.js';
@@ -18,9 +20,12 @@ function asSpecScope(value) {
18
20
  * readiness gate honors on-disk edits instead of the specs.json snapshot that
19
21
  * `specStore` caches in memory.
20
22
  */
21
- async function readFreshDifficultyAndScope(specPath) {
23
+ async function readFreshDifficultyAndScope(specId, specPath, projectPath) {
22
24
  try {
23
- const raw = await readFile(specPath, 'utf-8');
25
+ const resolvedPath = projectPath
26
+ ? await resolveVerifiedSpecPath(specId, specPath, projectPath)
27
+ : specPath;
28
+ const raw = await readFile(resolvedPath, 'utf-8');
24
29
  const { metadata } = parseFrontmatter(raw);
25
30
  return {
26
31
  difficulty: asDifficulty(metadata.difficulty),
@@ -31,6 +36,17 @@ async function readFreshDifficultyAndScope(specPath) {
31
36
  return {};
32
37
  }
33
38
  }
39
+ async function resolveEffectiveSpecPath(specId, specPath, projectPath) {
40
+ if (!projectPath || isAbsolute(specPath)) {
41
+ return specPath;
42
+ }
43
+ try {
44
+ return await resolveVerifiedSpecPath(specId, specPath, projectPath);
45
+ }
46
+ catch {
47
+ return specPath;
48
+ }
49
+ }
34
50
  // ── Formatting helpers ───────────────────────────────────────────────────────
35
51
  const MAX_VISIBLE_BLOCKERS = 8;
36
52
  const MAX_VISIBLE_WARNINGS = 8;
@@ -148,7 +164,7 @@ export async function handleCheckReadiness(args) {
148
164
  };
149
165
  }
150
166
  const { specId, mode = 'strict' } = args;
151
- const spec = await specStore.getSpecFresh(projectId, specId);
167
+ const spec = await specStore.getSpecFresh(projectId, specId, args.projectPath);
152
168
  if (!spec) {
153
169
  return {
154
170
  content: [
@@ -160,9 +176,16 @@ export async function handleCheckReadiness(args) {
160
176
  isError: true,
161
177
  };
162
178
  }
163
- const fresh = spec.specPath ? await readFreshDifficultyAndScope(spec.specPath) : {};
179
+ const fresh = spec.specPath
180
+ ? await readFreshDifficultyAndScope(spec.id, spec.specPath, args.projectPath)
181
+ : {};
182
+ const resolvedSpecPath = spec.specPath
183
+ ? await resolveEffectiveSpecPath(spec.id, spec.specPath, args.projectPath)
184
+ : spec.specPath;
164
185
  const effectiveSpec = {
165
186
  ...spec,
187
+ specPath: resolvedSpecPath,
188
+ technicalPath: resolvedSpecPath,
166
189
  difficulty: fresh.difficulty ?? spec.difficulty,
167
190
  scope: fresh.scope ?? spec.scope,
168
191
  };
@@ -1,5 +1,5 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
2
+ import { join, isAbsolute } from 'node:path';
3
3
  import { specStore, knowledgeStore, patternStore } from '../storage/index.js';
4
4
  import { ti } from '../i18n/index.js';
5
5
  import { generatePlanMarkdown, EXECUTION_PLAN_MARKER } from '../engine/dor-dod.js';
@@ -9,10 +9,14 @@ import { generateMobileDistributionPhase, isMobileDistributionSpec, } from '../e
9
9
  import { isDesktopReleaseSpec, generateDesktopDistributionPhase, } from '../engine/execution-plan/desktop-distribution.js';
10
10
  import { determineCriticalPath, findParallelizable, validateExecutionPlanGraph, } from '../engine/execution-plan/plan-utils.js';
11
11
  import { projectDataDir } from '../storage/base-store.js';
12
+ import { resolveVerifiedSpecPath } from '../storage/project-identity.js';
12
13
  import { atomicWriteFile } from '../engine/safety/atomic-write-file.js';
13
- async function readSpecBody(spec) {
14
+ async function readSpecBody(spec, projectPath) {
14
15
  try {
15
- return await readFile(spec.specPath, 'utf-8');
16
+ const resolvedPath = !isAbsolute(spec.specPath) && projectPath
17
+ ? await resolveVerifiedSpecPath(spec.id, spec.specPath, projectPath)
18
+ : spec.specPath;
19
+ return await readFile(resolvedPath, 'utf-8');
16
20
  }
17
21
  catch {
18
22
  return null;
@@ -29,8 +33,8 @@ function invalidTaskPlanEntry(taskPlan) {
29
33
  const trimmedId = task.id.trim();
30
34
  return trimmedId.length > 0 ? trimmedId : '(missing id)';
31
35
  }
32
- async function loadGroundedContract(spec, projectId, specId) {
33
- const body = await readSpecBody(spec);
36
+ async function loadGroundedContract(spec, projectId, specId, projectPath) {
37
+ const body = await readSpecBody(spec, projectPath);
34
38
  const ownership = body
35
39
  ? extractCanonicalFileOwnership(body)
36
40
  : { present: false, toCreate: [], toModify: [], toTest: [], toTestDeclared: [], blockers: [] };
@@ -141,7 +145,7 @@ export async function handleGenerateExecutionPlan(args) {
141
145
  ],
142
146
  };
143
147
  }
144
- const contract = await loadGroundedContract(spec, projectId, specId);
148
+ const contract = await loadGroundedContract(spec, projectId, specId, knowledge.projectPath);
145
149
  if (!contract.ok) {
146
150
  return {
147
151
  content: [
@@ -51,7 +51,7 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
51
51
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
52
  });
53
53
  import { readFile } from 'node:fs/promises';
54
- import { isAbsolute, join } from 'node:path';
54
+ import { resolvePortableSpecPath } from '../../storage/project-identity.js';
55
55
  import { extractCriteria } from '../../engine/validator/extractors.js';
56
56
  import { readEvidenceArtifacts } from '../../engine/evidence-gates/artifact-reader.js';
57
57
  import { checkLifecycleEvidenceGate } from '../../engine/evidence-gates/lifecycle-gate.js';
@@ -290,9 +290,7 @@ async function currentBindingsAreFresh(identity, currentBindings) {
290
290
  return false;
291
291
  }
292
292
  try {
293
- const specPath = isAbsolute(identity.specPath)
294
- ? identity.specPath
295
- : join(identity.projectPath, identity.specPath);
293
+ const specPath = await resolvePortableSpecPath(identity.specId, identity.specPath, identity.projectPath);
296
294
  const observed = await computeDurableValidationBindings({
297
295
  canonicalProjectId: identity.projectId,
298
296
  specId: identity.specId,
@@ -311,8 +309,10 @@ async function currentBindingsAreFresh(identity, currentBindings) {
311
309
  }
312
310
  }
313
311
  async function extractLifecycleGateCriteriaAndBody(spec, projectPath) {
314
- const specPath = isAbsolute(spec.specPath) || !projectPath ? spec.specPath : join(projectPath, spec.specPath);
315
312
  try {
313
+ const specPath = projectPath
314
+ ? await resolvePortableSpecPath(spec.id, spec.specPath, projectPath)
315
+ : spec.specPath;
316
316
  const raw = await readFile(specPath, 'utf-8');
317
317
  const criteria = extractAcceptanceCriteriaTexts(raw);
318
318
  if (criteria.length > 0) {
@@ -3,12 +3,12 @@ import { metricsStore } from '../../storage/index.js';
3
3
  import { addLesson, getLessons } from '../../storage/lessons-store.js';
4
4
  import { dispatchFeedbackEvent } from '../learn.js';
5
5
  import { autoReconcileOnDone } from '../update-status-reconcile.js';
6
- import { lstat, readFile, realpath } from 'node:fs/promises';
6
+ import { readFile } from 'node:fs/promises';
7
7
  import { parseFrontmatter } from '../../engine/frontmatter-parser.js';
8
8
  import { computeFrontmatterSha } from '../../engine/frontmatter-sha/index.js';
9
9
  import { appendTransitionEvent } from '../../storage/transition-log.js';
10
10
  import { atomicWriteFile } from '../../engine/safety/atomic-write-file.js';
11
- import { isAbsolute, normalize, relative, resolve, sep } from 'node:path';
11
+ import { resolvePortableSpecPath, PortablePathError } from '../../storage/project-identity.js';
12
12
  import { redactFailureMessage, reportClassifiedDegradation, } from '../../errors/classified-degradation.js';
13
13
  export async function recordDoneMetrics(projectId, specId, spec, actuals, projectPath) {
14
14
  if (actuals.devHours + actuals.reviewHours === 0) {
@@ -97,46 +97,18 @@ async function resolveSpecPathForTransition(spec, projectPath) {
97
97
  if (!projectPath) {
98
98
  return spec.specPath;
99
99
  }
100
- const normalizedPath = normalize(spec.specPath);
101
- const segments = normalizedPath.split(sep).filter(Boolean);
102
- const planuIndex = segments.lastIndexOf('planu');
103
- const specDirectory = segments[planuIndex + 2];
104
- const isPortableContract = planuIndex >= 0 &&
105
- segments[planuIndex + 1] === 'specs' &&
106
- segments[planuIndex + 3] === 'spec.md' &&
107
- planuIndex + 4 === segments.length &&
108
- specDirectory !== undefined &&
109
- (specDirectory === spec.id || specDirectory.startsWith(`${spec.id}-`));
110
- if (!isPortableContract) {
111
- throw new Error('SPEC_PATH_RELOCALIZATION_FAILED: specPath is not a portable spec contract');
112
- }
113
- const selectedRoot = resolve(projectPath);
114
- const candidate = resolve(selectedRoot, 'planu', 'specs', specDirectory, 'spec.md');
115
- const relativeCandidate = relative(selectedRoot, candidate);
116
- if (relativeCandidate.startsWith(`..${sep}`) ||
117
- relativeCandidate === '..' ||
118
- isAbsolute(relativeCandidate)) {
119
- throw new Error('SPEC_PATH_RELOCALIZATION_FAILED: specPath escapes the selected root');
100
+ try {
101
+ return await resolvePortableSpecPath(spec.id, spec.specPath, projectPath);
120
102
  }
121
- const rootRealpath = await realpath(selectedRoot);
122
- const [candidateRealpath, candidateStat] = await Promise.all([
123
- realpath(candidate),
124
- lstat(candidate),
125
- ]).catch((err) => {
126
- if (err instanceof Error && err.code === 'ENOENT') {
127
- throw specArtifactAbsentError();
103
+ catch (error) {
104
+ if (error instanceof PortablePathError) {
105
+ if (error.code === 'NOT_FOUND') {
106
+ throw specArtifactAbsentError();
107
+ }
108
+ throw new Error(`SPEC_PATH_RELOCALIZATION_FAILED: ${error.message}`, { cause: error });
128
109
  }
129
- throw err;
130
- });
131
- const realRelative = relative(rootRealpath, candidateRealpath);
132
- if (candidateStat.isSymbolicLink() ||
133
- !candidateStat.isFile() ||
134
- realRelative.startsWith(`..${sep}`) ||
135
- realRelative === '..' ||
136
- isAbsolute(realRelative)) {
137
- throw new Error('SPEC_PATH_RELOCALIZATION_FAILED: specPath is not a contained regular file');
110
+ throw error;
138
111
  }
139
- return candidateRealpath;
140
112
  }
141
113
  function bodyWithoutFrontmatter(content) {
142
114
  return parseFrontmatter(content).body;
@@ -932,7 +932,7 @@ export async function handleUpdateStatus(params, server) {
932
932
  return ambiguityError;
933
933
  }
934
934
  // SPEC-769: Readiness gate — block 'approved' if spec has 0 criteria or score < 70
935
- const readinessGate = await checkReadinessGate(spec, approvalGateStatus, params.forceApprove);
935
+ const readinessGate = await checkReadinessGate(spec, approvalGateStatus, params.forceApprove, transitionProjectPath);
936
936
  if (readinessGate.blockResult) {
937
937
  return readinessGate.blockResult;
938
938
  }
@@ -73,7 +73,7 @@ export declare function checkSpecArtifactCommittedGate(spec: Spec, newStatus: Sp
73
73
  * - non-English prose → hard block
74
74
  * - score >= 70 → proceed, no warnings
75
75
  */
76
- export declare function checkReadinessGate(spec: Spec, newStatus: SpecStatus, forceApprove: boolean | undefined): Promise<ReadinessGateOutput>;
76
+ export declare function checkReadinessGate(spec: Spec, newStatus: SpecStatus, forceApprove: boolean | undefined, projectPath?: string): Promise<ReadinessGateOutput>;
77
77
  /**
78
78
  * SPEC-964: Challenge gate — block 'review' if challenge_spec was never run
79
79
  * or does not contain enough explicit resolution evidence.
@@ -1,6 +1,7 @@
1
1
  // tools/update-status/transition-guard.ts — Valid state transitions and DoR gate
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { isAbsolute, join } from 'node:path';
4
+ import { toPortableSpecPath, resolveVerifiedSpecPath, PortablePathError, } from '../../storage/project-identity.js';
4
5
  import { ti } from '../../i18n/index.js';
5
6
  import { validateDoR } from '../../engine/dor-dod.js';
6
7
  import { dispatchFeedbackEvent } from '../learn.js';
@@ -237,9 +238,30 @@ export async function checkSpecArtifactCommittedGate(spec, newStatus, projectPat
237
238
  if (newStatus !== 'implementing' || !projectPath || !spec.specPath) {
238
239
  return null;
239
240
  }
240
- const absoluteSpecPath = isAbsolute(spec.specPath)
241
- ? spec.specPath
242
- : join(projectPath, spec.specPath);
241
+ let absoluteSpecPath;
242
+ try {
243
+ absoluteSpecPath = join(projectPath, toPortableSpecPath(spec.id, spec.specPath));
244
+ }
245
+ catch (error) {
246
+ if (!(error instanceof PortablePathError)) {
247
+ throw error;
248
+ }
249
+ return {
250
+ content: [
251
+ {
252
+ type: 'text',
253
+ text: formatKeyValue({
254
+ error: 'SPEC_PATH_UNRESOLVABLE',
255
+ message: `spec.md for ${spec.id} could not be resolved under the canonical project root (${error.code}).`,
256
+ specId: spec.id,
257
+ fixHint: 'Run reconcile_spec to re-link this spec to its file, then retry update_status.',
258
+ }),
259
+ },
260
+ ],
261
+ isError: true,
262
+ structuredContent: { error: 'SPEC_PATH_UNRESOLVABLE', code: error.code, specId: spec.id },
263
+ };
264
+ }
243
265
  try {
244
266
  const { git } = await import('../git/git-helpers.js');
245
267
  const { stdout } = await git(projectPath, ['status', '--porcelain', '--', absoluteSpecPath]);
@@ -286,13 +308,18 @@ export async function checkSpecArtifactCommittedGate(spec, newStatus, projectPat
286
308
  * - non-English prose → hard block
287
309
  * - score >= 70 → proceed, no warnings
288
310
  */
289
- export async function checkReadinessGate(spec, newStatus, forceApprove) {
311
+ export async function checkReadinessGate(spec, newStatus, forceApprove, projectPath) {
290
312
  if ((newStatus !== 'review' && newStatus !== 'approved') || !spec.specPath) {
291
313
  return { blockResult: null, qualityWarnings: [] };
292
314
  }
293
315
  let body;
316
+ let resolvedPath;
294
317
  try {
295
- body = await readFile(spec.specPath, 'utf-8');
318
+ resolvedPath =
319
+ !isAbsolute(spec.specPath) && projectPath
320
+ ? await resolveVerifiedSpecPath(spec.id, spec.specPath, projectPath)
321
+ : spec.specPath;
322
+ body = await readFile(resolvedPath, 'utf-8');
296
323
  }
297
324
  catch (error) {
298
325
  /* reliability-optional: READINESS_SPEC_UNREADABLE — transition fails closed below */
@@ -301,10 +328,12 @@ export async function checkReadinessGate(spec, newStatus, forceApprove) {
301
328
  // situation — surface an actionable message instead of the raw fs error
302
329
  // ("ENOENT: no such file or directory, open '<path>'") that update_status
303
330
  // was previously appending "Transition blocked..." to verbatim.
304
- const isMissingFile = typeof error === 'object' &&
331
+ const isMissingFile = (typeof error === 'object' &&
305
332
  error !== null &&
306
333
  'code' in error &&
307
- error.code === 'ENOENT';
334
+ error.code === 'ENOENT') ||
335
+ (error instanceof PortablePathError &&
336
+ (error.code === 'NOT_FOUND' || error.code === 'ROOT_NOT_FOUND'));
308
337
  return readinessUnavailable('READINESS_SPEC_UNREADABLE', isMissingFile
309
338
  ? `spec.md is missing at ${spec.specPath}. Recreate the spec via create_spec or restore spec.md from git/backup before retrying this transition.`
310
339
  : error instanceof Error
@@ -397,9 +426,12 @@ export async function checkReadinessGate(spec, newStatus, forceApprove) {
397
426
  qualityWarnings: structuralInterpolationWarnings,
398
427
  };
399
428
  }
429
+ const specWithResolvedPath = resolvedPath === spec.specPath
430
+ ? spec
431
+ : { ...spec, specPath: resolvedPath, technicalPath: resolvedPath };
400
432
  let readiness;
401
433
  try {
402
- readiness = await checkSpecReadiness(spec, 'strict');
434
+ readiness = await checkSpecReadiness(specWithResolvedPath, 'strict');
403
435
  }
404
436
  catch (error) {
405
437
  /* reliability-optional: READINESS_EVALUATION_FAILED — transition fails closed below */
@@ -13,6 +13,8 @@ export interface RegisteredProject {
13
13
  specCount: number;
14
14
  /** ISO timestamp of the last spec scan, if any */
15
15
  lastScanAt?: string;
16
+ /** Logical project identity from planu/project.json, when the checkout has one */
17
+ logicalProjectId?: string;
16
18
  }
17
19
  /**
18
20
  * Global registry persisted at data/global/registered-projects.json.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "5.3.67",
3
+ "version": "5.3.68",
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",
@@ -56,7 +56,7 @@
56
56
  "check": "pnpm typecheck && pnpm lint:gate && pnpm format:check && pnpm check:destructive-ops && pnpm check:commercial-surfaces && pnpm check:tool-registration && pnpm check:public-privacy && pnpm check:website-proof && pnpm check:donation-assets && pnpm check:website-public-assets",
57
57
  "check:destructive-ops": "bash scripts/check-no-automatic-destructive-ops.sh",
58
58
  "check:strict": "pnpm check && pnpm generate:host-tool-registry -- --check && pnpm check:environment-schema && pnpm audit:hardcodes && pnpm audit:deadcode && pnpm audit:circular && pnpm audit:types && pnpm audit:security && pnpm audit:i18n",
59
- "check:preflight": "pnpm typecheck && pnpm lint:gate && pnpm format:check && node scripts/check-public-privacy.mjs && node scripts/check-preflight.mjs",
59
+ "check:preflight": "pnpm typecheck && pnpm lint:gate && pnpm format:check && node scripts/check-public-privacy.mjs && node scripts/check-preflight.mjs && bash scripts/release-preflight.sh --run-and-write && bash scripts/release-preflight.sh --verify",
60
60
  "check:reliability": "node scripts/check-reliability-policies.mjs",
61
61
  "check:environment-schema": "node scripts/check-environment-schema.mjs",
62
62
  "test:contracts": "vitest run tests/contracts tests/integration/package-consumer-hono.test.ts",
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.67",
5
+ "version": "5.3.68",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": ["npx", "@planu/cli@latest"],
8
8
  "packageName": "@planu/cli",