@planu/cli 5.3.66 → 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,39 @@
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
+
25
+ ## [5.3.67] - 2026-08-28
26
+
27
+ ### Bug Fixes
28
+ - fix(SPEC-1214): make readiness executable-evidence fallback reachable outside planu/specs
29
+ - fix(SPEC-1214): align readiness and validation on one executable-evidence contract
30
+ - fix(SPEC-1306): forward caller cwd as explicit projectPath in CLI status
31
+
32
+ ### Chores
33
+ - chore(SPEC-1315): transition to implementing
34
+ - chore: mark SPEC-1306/SPEC-1214 done and file SPEC-1664 dogfood bug
35
+
36
+
1
37
  ## [5.3.66] - 2026-08-28
2
38
 
3
39
  ### Bug Fixes
@@ -1 +1 @@
1
- {"schemaVersion":1,"commit":"e6b315bb677135204b8ade0a8d43149075ba3ba4"}
1
+ {"schemaVersion":1,"commit":"39afacbb6522be60172b8571f6c5973000936a4a"}
@@ -58,7 +58,7 @@ export function buildSingleStatusInput(values, specId, projectId, status) {
58
58
  return {
59
59
  specId,
60
60
  projectId,
61
- projectPath: values['project-path'] ? resolve(values['project-path']) : undefined,
61
+ projectPath: values['project-path'] ? resolve(values['project-path']) : process.cwd(),
62
62
  status: status,
63
63
  reviewNotes: values.notes,
64
64
  modelId: values['model-id'],
@@ -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,10 +1,13 @@
1
1
  // engine/readiness-checker.ts — Spec readiness evaluation for implementation gate (SPEC-039)
2
+ import { existsSync } from 'node:fs';
2
3
  import { readFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
3
5
  import { extractSectionBody, readSpecTechnicalSection, } from './spec-format/read-technical-section.js';
4
6
  import { stripFrontmatter } from './frontmatter-parser.js';
5
7
  import { loadReadinessConfig } from './readiness-config-loader.js';
6
8
  import { runEarsGate } from './ears-gate.js';
7
9
  import { findScenariosWithoutTests, parseFrontmatterScenarios, } from './validator/spec-compliance-runner.js';
10
+ import { normalizeExecutableEvidence } from './validator/executable-evidence.js';
8
11
  import { evaluateImplementationContract } from './implementation-contract/index.js';
9
12
  import { extractNormalizedAcceptanceCriteria } from './spec-format/acceptance-criteria.js';
10
13
  // ── SPEC-784: Technical section quality constants ─────────────────────────────
@@ -33,6 +36,13 @@ function scoreHuCompleteness(spec) {
33
36
  }
34
37
  return { points, blockers, warnings };
35
38
  }
39
+ function projectPathFromSpecPath(specPath) {
40
+ const marker = /[/\\]planu[/\\]specs[/\\]/;
41
+ if (!specPath || !marker.test(specPath)) {
42
+ return undefined;
43
+ }
44
+ return specPath.split(marker)[0];
45
+ }
36
46
  async function readHuRaw(spec) {
37
47
  if (!spec.specPath) {
38
48
  return '';
@@ -421,6 +431,16 @@ export async function checkSpecReadiness(spec, mode, projectHash) {
421
431
  allBlockers.push(`scenarios_missing_tests: scenario "${title}" has no tests array — add tests entries (SPEC-732)`);
422
432
  }
423
433
  }
434
+ // SPEC-1214: BDD-criteria executable-evidence gate — when a spec has no frontmatter
435
+ // scenarios, its criteria must carry a TEST: marker mapping to an executable test file,
436
+ // or validation will later score 0 with "No executable scenarios declared".
437
+ if (bddScenarioCount === 0 && spec.scope !== 'trivial') {
438
+ const readinessProjectPath = projectPathFromSpecPath(spec.specPath);
439
+ const executableEvidence = normalizeExecutableEvidence(huRaw, parseFrontmatterScenarios(huRaw), (path) => (readinessProjectPath ? existsSync(join(readinessProjectPath, path)) : true));
440
+ for (const title of executableEvidence.unmappedCriteria) {
441
+ allBlockers.push(`criteria_missing_executable_test: criterion "${title}" has no TEST: marker mapping to an executable test file (SPEC-1214)`);
442
+ }
443
+ }
424
444
  // SPEC-784: Technical section quality gates — blocker-severity issues
425
445
  if (detectTechnicalPlaceholder(huContent)) {
426
446
  allBlockers.push('TECHNICAL_PLACEHOLDER_DETECTED: ## Technical section contains a placeholder pointer ' +
@@ -0,0 +1,31 @@
1
+ export interface EvidenceTestLink {
2
+ path: string;
3
+ }
4
+ export interface EvidenceScenario {
5
+ title: string;
6
+ tests: EvidenceTestLink[];
7
+ }
8
+ export type ExecutableEvidenceSource = 'frontmatter' | 'criteria' | 'none';
9
+ export interface ExecutableEvidence {
10
+ source: ExecutableEvidenceSource;
11
+ scenarios: EvidenceScenario[];
12
+ unmappedCriteria: string[];
13
+ ignoredCriteriaTitles: string[];
14
+ }
15
+ /**
16
+ * Single evidence normalizer shared by spec-compliance-runner.ts and
17
+ * readiness-checker.ts (SPEC-1214). Frontmatter `scenarios` stay canonical when
18
+ * present; otherwise BDD criteria `TEST:` markers become the executable evidence,
19
+ * so a spec approved by readiness on `TEST:` markers cannot later validate as
20
+ * "no executable scenarios declared". `isValidTestPath` lets each caller reject a
21
+ * `TEST:` marker pointing at a file that does not exist on disk, instead of
22
+ * silently treating it as executable evidence; it defaults to accepting every
23
+ * path when a caller has no filesystem to check against.
24
+ */
25
+ export declare function normalizeExecutableEvidence(raw: string, frontmatterScenarios: {
26
+ title: string;
27
+ tests?: {
28
+ path: string;
29
+ }[];
30
+ }[], isValidTestPath?: (path: string) => boolean): ExecutableEvidence;
31
+ //# sourceMappingURL=executable-evidence.d.ts.map
@@ -0,0 +1,104 @@
1
+ import { extractSection } from '../spec-format/markdown-sections.js';
2
+ import { stripFrontmatter } from '../frontmatter-parser.js';
3
+ const CRITERION_HEADING = /^#{2,6}\s+((?:AC|AB)\s*-?\s*\d+)\b/i;
4
+ const GIVEN_BULLET = /^-\s*GIVEN\b/i;
5
+ const BDD_STEP = /^(?:[-*]\s+)?(GIVEN|WHEN|THEN|AND)\b/i;
6
+ const TEST_MARKER_LINE = /^\s*TEST:\s*(.+)$/im;
7
+ const TEST_FILE_TOKEN = /`?([\w./-]+\.(?:test|spec)\.[cm]?[jt]sx?)`?/g;
8
+ function splitByLineStarts(lines, starts) {
9
+ return starts.map((start, position) => {
10
+ const end = starts[position + 1] ?? lines.length;
11
+ return lines.slice(start, end).join('\n');
12
+ });
13
+ }
14
+ function splitCriterionBlocks(section) {
15
+ const lines = section.split('\n');
16
+ const headingStarts = lines
17
+ .map((line, index) => (CRITERION_HEADING.test(line.trim()) ? index : -1))
18
+ .filter((index) => index !== -1);
19
+ if (headingStarts.length > 0) {
20
+ return splitByLineStarts(lines, headingStarts);
21
+ }
22
+ const bulletStarts = lines
23
+ .map((line, index) => (GIVEN_BULLET.test(line.trim()) ? index : -1))
24
+ .filter((index) => index !== -1);
25
+ if (bulletStarts.length > 0) {
26
+ return splitByLineStarts(lines, bulletStarts);
27
+ }
28
+ return section.split(/\n\s*\n/).filter((block) => /\bGIVEN\b/i.test(block));
29
+ }
30
+ function blockTitle(block) {
31
+ const stepLines = block
32
+ .split('\n')
33
+ .map((line) => line.trim())
34
+ .filter((line) => BDD_STEP.test(line))
35
+ .map((line) => line.replace(/^[-*]\s+/, ''));
36
+ if (stepLines.length > 0) {
37
+ return stepLines.join(' ');
38
+ }
39
+ const heading = /^#{2,6}\s+(.+)$/.exec(block.split('\n')[0]?.trim() ?? '');
40
+ return (heading?.[1] ?? block.trim()).slice(0, 200);
41
+ }
42
+ function blockTestLinks(block, isValidTestPath) {
43
+ const marker = TEST_MARKER_LINE.exec(block);
44
+ if (!marker?.[1]) {
45
+ return [];
46
+ }
47
+ return [...marker[1].matchAll(TEST_FILE_TOKEN)]
48
+ .map((match) => match[1])
49
+ .filter((path) => Boolean(path))
50
+ .filter(isValidTestPath)
51
+ .map((path) => ({ path }));
52
+ }
53
+ function extractCriteriaScenarios(raw, isValidTestPath) {
54
+ const body = stripFrontmatter(raw);
55
+ const section = extractSection(body, 'acceptance criteria', 'criterios de aceptaci') ?? body;
56
+ return splitCriterionBlocks(section).map((block) => ({
57
+ title: blockTitle(block),
58
+ tests: blockTestLinks(block, isValidTestPath),
59
+ }));
60
+ }
61
+ /**
62
+ * Single evidence normalizer shared by spec-compliance-runner.ts and
63
+ * readiness-checker.ts (SPEC-1214). Frontmatter `scenarios` stay canonical when
64
+ * present; otherwise BDD criteria `TEST:` markers become the executable evidence,
65
+ * so a spec approved by readiness on `TEST:` markers cannot later validate as
66
+ * "no executable scenarios declared". `isValidTestPath` lets each caller reject a
67
+ * `TEST:` marker pointing at a file that does not exist on disk, instead of
68
+ * silently treating it as executable evidence; it defaults to accepting every
69
+ * path when a caller has no filesystem to check against.
70
+ */
71
+ export function normalizeExecutableEvidence(raw, frontmatterScenarios, isValidTestPath = () => true) {
72
+ const criteriaScenarios = extractCriteriaScenarios(raw, isValidTestPath);
73
+ if (frontmatterScenarios.length > 0) {
74
+ return {
75
+ source: 'frontmatter',
76
+ scenarios: frontmatterScenarios.map((scenario) => ({
77
+ title: scenario.title,
78
+ tests: (scenario.tests ?? []).map((test) => ({ path: test.path })),
79
+ })),
80
+ unmappedCriteria: [],
81
+ ignoredCriteriaTitles: criteriaScenarios
82
+ .filter((scenario) => scenario.tests.length > 0)
83
+ .map((scenario) => scenario.title),
84
+ };
85
+ }
86
+ const mapped = criteriaScenarios.filter((scenario) => scenario.tests.length > 0);
87
+ if (mapped.length > 0) {
88
+ return {
89
+ source: 'criteria',
90
+ scenarios: criteriaScenarios,
91
+ unmappedCriteria: criteriaScenarios
92
+ .filter((scenario) => scenario.tests.length === 0)
93
+ .map((scenario) => scenario.title),
94
+ ignoredCriteriaTitles: [],
95
+ };
96
+ }
97
+ return {
98
+ source: 'none',
99
+ scenarios: [],
100
+ unmappedCriteria: criteriaScenarios.map((scenario) => scenario.title),
101
+ ignoredCriteriaTitles: [],
102
+ };
103
+ }
104
+ //# sourceMappingURL=executable-evidence.js.map
@@ -27,6 +27,8 @@ export interface SpecComplianceResult {
27
27
  dimensionScore: number;
28
28
  perScenario: PerScenarioResult[];
29
29
  command: string;
30
+ evidenceSource?: 'frontmatter' | 'criteria' | 'none';
31
+ ignoredCriteriaTitles?: string[];
30
32
  }
31
33
  /** A sanitized terminal adapter failure that cannot be mistaken for a failed test verdict. */
32
34
  export declare class ComplianceCommandTerminalError extends Error {
@@ -8,6 +8,7 @@ import { technologyValue } from '../technology-registry.js';
8
8
  import { readMatchingValidationEvidence } from '../validation-evidence-ledger.js';
9
9
  import { readEvidenceArtifacts, traceabilityRowHasCurrentCommandEvidence, } from '../evidence-gates/artifact-reader.js';
10
10
  import { normalizeCriterionText } from '../criterion-identity.js';
11
+ import { normalizeExecutableEvidence } from './executable-evidence.js';
11
12
  /** A sanitized terminal adapter failure that cannot be mistaken for a failed test verdict. */
12
13
  export class ComplianceCommandTerminalError extends Error {
13
14
  code;
@@ -450,16 +451,31 @@ async function loadExactCommandRows(spec, projectPath, canonicalProjectId) {
450
451
  // eslint-disable-next-line max-lines-per-function -- coordinates validation, adapters, and isolated per-scenario evidence
451
452
  export async function runSpecCompliance(spec, projectPath, signal, canonicalProjectId) {
452
453
  const { readFile } = await import('node:fs/promises');
454
+ const { existsSync } = await import('node:fs');
453
455
  let raw;
454
456
  try {
455
457
  raw = await readFile(spec.specPath, 'utf-8');
456
458
  }
457
459
  catch {
458
- return { dimensionScore: 0, perScenario: [], command: '' };
460
+ return { dimensionScore: 0, perScenario: [], command: '', evidenceSource: 'none' };
459
461
  }
460
- const scenarios = parseFrontmatterScenarios(raw);
462
+ const frontmatterScenarios = parseFrontmatterScenarios(raw);
463
+ const executableEvidence = normalizeExecutableEvidence(raw, frontmatterScenarios, (path) => existsSync(join(projectPath, path)));
464
+ const scenarios = executableEvidence.source === 'frontmatter'
465
+ ? frontmatterScenarios
466
+ : executableEvidence.scenarios.map((scenario) => ({
467
+ title: scenario.title,
468
+ done: false,
469
+ tests: scenario.tests.map((test) => ({ path: test.path })).filter(isExecutableTestLink),
470
+ }));
461
471
  if (scenarios.length === 0) {
462
- return { dimensionScore: 0, perScenario: [], command: '' };
472
+ return {
473
+ dimensionScore: 0,
474
+ perScenario: [],
475
+ command: '',
476
+ evidenceSource: executableEvidence.source,
477
+ ignoredCriteriaTitles: executableEvidence.ignoredCriteriaTitles,
478
+ };
463
479
  }
464
480
  const allLinks = scenarios.flatMap((scenario) => scenario.tests ?? []);
465
481
  const executableLinks = allLinks.filter(isExecutableTestLink);
@@ -570,6 +586,8 @@ export async function runSpecCompliance(spec, projectPath, signal, canonicalProj
570
586
  perScenario,
571
587
  command: commands.join(' && ') ||
572
588
  `${technologyValue('technology-vitest-a9127f')} --reporter=json --run (no test files linked)`,
589
+ evidenceSource: executableEvidence.source,
590
+ ignoredCriteriaTitles: executableEvidence.ignoredCriteriaTitles,
573
591
  };
574
592
  }
575
593
  export function findScenariosWithoutTests(raw) {
@@ -130,12 +130,18 @@ function buildMinimalityGate(report) {
130
130
  : undefined,
131
131
  };
132
132
  }
133
+ function ignoredCriteriaNote(result) {
134
+ return result.ignoredCriteriaTitles?.length
135
+ ? ` Ignored BDD criteria TEST: mappings (frontmatter scenarios are canonical): ${result.ignoredCriteriaTitles.join(', ')}.`
136
+ : '';
137
+ }
133
138
  function buildSpecComplianceGate(result) {
139
+ const ignoredNote = ignoredCriteriaNote(result);
134
140
  if (result.perScenario.length === 0) {
135
141
  return {
136
142
  name: 'spec-compliance',
137
- passed: true,
138
- reason: 'No executable scenarios declared; spec-compliance runner skipped.',
143
+ passed: false,
144
+ reason: `No executable scenarios declared (evidence source: ${result.evidenceSource ?? 'none'}) — add BDD criteria TEST: markers or frontmatter scenarios before done.${ignoredNote}`,
139
145
  };
140
146
  }
141
147
  const passed = result.dimensionScore === 100;
@@ -143,8 +149,8 @@ function buildSpecComplianceGate(result) {
143
149
  name: 'spec-compliance',
144
150
  passed,
145
151
  reason: passed
146
- ? undefined
147
- : `Expected all executable scenarios to pass, got ${String(result.dimensionScore)}.`,
152
+ ? ignoredNote.trim() || undefined
153
+ : `Expected all executable scenarios to pass, got ${String(result.dimensionScore)}.${ignoredNote}`,
148
154
  };
149
155
  }
150
156
  function toValidationReportCompliance(result) {
@@ -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);