@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 +24 -0
- package/dist/.planu-build.json +1 -1
- package/dist/config/release-policy.json +2 -2
- package/dist/storage/global-projects-store.d.ts +17 -0
- package/dist/storage/global-projects-store.js +46 -0
- package/dist/storage/project-identity.d.ts +27 -0
- package/dist/storage/project-identity.js +131 -2
- package/dist/storage/spec-store.d.ts +24 -3
- package/dist/storage/spec-store.js +126 -5
- package/dist/tools/challenge-spec-helpers.d.ts +1 -1
- package/dist/tools/challenge-spec-helpers.js +7 -2
- package/dist/tools/challenge-spec.js +11 -6
- package/dist/tools/check-readiness.js +27 -4
- package/dist/tools/generate-execution-plan.js +10 -6
- package/dist/tools/update-status/evidence-gate.js +5 -5
- package/dist/tools/update-status/file-sync.js +11 -39
- package/dist/tools/update-status/index.js +1 -1
- package/dist/tools/update-status/transition-guard.d.ts +1 -1
- package/dist/tools/update-status/transition-guard.js +40 -8
- package/dist/types/cross-repo-search.d.ts +2 -0
- package/package.json +2 -2
- package/planu-plugin.json +1 -1
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
|
package/dist/.planu-build.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"schemaVersion":1,"commit":"
|
|
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
|
-
|
|
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
|
-
...
|
|
243
|
-
healthScore: computeHealthScore(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
5
|
+
"version": "5.3.68",
|
|
6
6
|
"icon": "assets/plugin/icon.svg",
|
|
7
7
|
"command": ["npx", "@planu/cli@latest"],
|
|
8
8
|
"packageName": "@planu/cli",
|