@planu/cli 5.3.56 → 5.3.58
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 +46 -0
- package/dist/.planu-build.json +1 -0
- package/dist/cli/commands/doctor.d.ts +3 -1
- package/dist/cli/commands/doctor.js +59 -3
- package/dist/cli/index.js +6 -0
- package/dist/engine/build-freshness.d.ts +6 -0
- package/dist/engine/build-freshness.js +170 -0
- package/dist/engine/cascade-hooks/core/append-releases.d.ts +9 -2
- package/dist/engine/cascade-hooks/core/append-releases.js +84 -51
- package/dist/engine/cascade-hooks/hooks/review-enricher.hook.js +13 -2
- package/dist/engine/cascade-hooks/integrity.d.ts +2 -0
- package/dist/engine/cascade-hooks/integrity.js +40 -0
- package/dist/engine/technical-enricher/index.js +2 -0
- package/dist/index.js +6 -0
- package/dist/types/build-freshness.d.ts +10 -0
- package/dist/types/build-freshness.js +2 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/package.json +1 -1
- package/planu-plugin.json +1 -1
- package/scripts/lib/pending-release-file.mjs +39 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,49 @@
|
|
|
1
|
+
## [5.3.58] - 2026-08-26
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
- fix(SPEC-1317): assert the dynamic-import edge for the pending-release helper
|
|
5
|
+
- fix(SPEC-1319): close review findings on enricher integrity wiring
|
|
6
|
+
- fix(SPEC-1317): align doctor deep-check count with the installation-integrity check
|
|
7
|
+
- fix(SPEC-1319): block stale review-enricher runtimes from truncating canonical specs
|
|
8
|
+
- fix(SPEC-1317): diagnose and recover incomplete CLI installs without crashing planu status
|
|
9
|
+
|
|
10
|
+
### Refactoring
|
|
11
|
+
- refactor(SPEC-1319): move enrichment integrity validator to a neutral module
|
|
12
|
+
- refactor(SPEC-1317): extract pending-ledger rewrite to satisfy the function-length gate
|
|
13
|
+
|
|
14
|
+
### Chores
|
|
15
|
+
- chore(planu): close SPEC-1317 and SPEC-1319
|
|
16
|
+
- chore(SPEC-1319): integrate review-enricher integrity and stale-dist guard
|
|
17
|
+
- chore(SPEC-1317): integrate incomplete-install doctor and release-metadata degradation
|
|
18
|
+
- chore(planu): record SPEC-1317 implementing state
|
|
19
|
+
- chore(planu): record SPEC-1319 implementing and fix SPEC-1317 files ownership
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
## [5.3.57] - 2026-08-26
|
|
23
|
+
|
|
24
|
+
### Bug Fixes
|
|
25
|
+
- fix(SPEC-1316): stop non-array ledgers and silent reachability failures from losing pending releases
|
|
26
|
+
- fix(build-freshness): stop bricking a legit checkout when git is unavailable
|
|
27
|
+
- fix(build): reject stale local dist before dispatch (SPEC-1318)
|
|
28
|
+
- fix(build-freshness): allow diff-clean commits past the build stamp
|
|
29
|
+
- fix(release): classify pending releases by git reachability (SPEC-1316)
|
|
30
|
+
- fix(build): support reftable HEAD and tolerate a dirty-build-then-commit push
|
|
31
|
+
- fix(release): close three fail-open gaps in pending-release reconciliation
|
|
32
|
+
- fix(build): treat uncompiled TypeScript execution as not-applicable
|
|
33
|
+
- fix(release): classify pending releases by git tag reachability, not date
|
|
34
|
+
- fix(build): reject stale local dist output before CLI/MCP dispatch
|
|
35
|
+
|
|
36
|
+
### Chores
|
|
37
|
+
- chore(planu): close SPEC-1316 and SPEC-1318
|
|
38
|
+
- chore(planu): add executable scenarios to SPEC-1316/1318 and file SPEC-1631
|
|
39
|
+
- chore(planu): record SPEC-1316/1318 implementing transitions
|
|
40
|
+
- chore(planu): add missing Create subsection to SPEC-1316 files ownership
|
|
41
|
+
- chore(planu): approve 5 more specs after adding implementation contracts and test-break evidence
|
|
42
|
+
- chore(planu): approve 6 reviewed specs, discard 2 stale after independent review
|
|
43
|
+
- chore(planu): discard 9 speculative feature specs and 5 already-fixed gate specs
|
|
44
|
+
- chore(planu): session checkpoint after v5.3.56
|
|
45
|
+
|
|
46
|
+
|
|
1
47
|
## [5.3.56] - 2026-08-25
|
|
2
48
|
|
|
3
49
|
### Bug Fixes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"schemaVersion":1,"commit":"27eba0edeec0ce821f730eebbbbd999f5a1725b6"}
|
|
@@ -25,8 +25,10 @@ declare function checkDataDirWritable(): DeepCheckResult;
|
|
|
25
25
|
declare function checkRuntimeDatabase(): DeepCheckResult;
|
|
26
26
|
/** Check (4): the active locale resolves to a real translated message. */
|
|
27
27
|
declare function checkLocaleResolution(): DeepCheckResult;
|
|
28
|
+
/** Check (5): every package-owned runtime helper compiled CLI code imports is present. */
|
|
29
|
+
declare function checkInstallationIntegrity(): DeepCheckResult;
|
|
28
30
|
declare function runDeepChecks(): DeepCheckResult[];
|
|
29
|
-
export { checkNodeVersion, checkDataDirWritable, checkRuntimeDatabase, checkLocaleResolution, runDeepChecks, };
|
|
31
|
+
export { checkNodeVersion, checkDataDirWritable, checkRuntimeDatabase, checkLocaleResolution, checkInstallationIntegrity, runDeepChecks, };
|
|
30
32
|
export { checkTool };
|
|
31
33
|
export declare const doctorCommand: CliCommand;
|
|
32
34
|
//# sourceMappingURL=doctor.d.ts.map
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// cli/commands/doctor.ts — planu doctor: verify MCP installations health (SPEC-236)
|
|
2
2
|
// Deep diagnostics (Node version, storage writability, runtime DB,
|
|
3
3
|
// locale resolution) added by SPEC-1346 / SPEC-1344.
|
|
4
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
|
-
import { dirname, join } from 'node:path';
|
|
6
|
+
import { dirname, isAbsolute, join, relative } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { bold, cyan, green, yellow, red, dim } from '../colors.js';
|
|
9
9
|
import { readJsonFile, hasPlanuEntry } from './install.js';
|
|
@@ -97,6 +97,8 @@ function checkTool(check) {
|
|
|
97
97
|
// ---------------------------------------------------------------------------
|
|
98
98
|
/** Root of the package, resolved relative to this module so it works from src/ and dist/. */
|
|
99
99
|
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../../../package.json');
|
|
100
|
+
/** Package-relative runtime helpers compiled CLI code imports outside dist/. */
|
|
101
|
+
const REQUIRED_RUNTIME_HELPERS = ['scripts/lib/pending-release-file.mjs'];
|
|
100
102
|
function parseVersionTuple(version) {
|
|
101
103
|
const match = /(\d+)\.(\d+)\.(\d+)/.exec(version);
|
|
102
104
|
return [Number(match?.[1] ?? 0), Number(match?.[2] ?? 0), Number(match?.[3] ?? 0)];
|
|
@@ -196,12 +198,66 @@ function checkLocaleResolution() {
|
|
|
196
198
|
}
|
|
197
199
|
return { name: 'Locale resolution', status: 'ok', detail: `Resolved locale: ${locale}` };
|
|
198
200
|
}
|
|
201
|
+
function readInstalledVersion() {
|
|
202
|
+
try {
|
|
203
|
+
const pkg = JSON.parse(readFileSync(PACKAGE_ROOT, 'utf-8'));
|
|
204
|
+
return pkg.version ?? 'unknown';
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return 'unknown';
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function readInstalledPackageName() {
|
|
211
|
+
try {
|
|
212
|
+
const pkg = JSON.parse(readFileSync(PACKAGE_ROOT, 'utf-8'));
|
|
213
|
+
return pkg.name ?? '@planu/cli';
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return '@planu/cli';
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function isContainedInPackageRoot(packageRoot, candidate) {
|
|
220
|
+
const rel = relative(packageRoot, candidate);
|
|
221
|
+
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
222
|
+
}
|
|
223
|
+
function isRegularFileWithinPackage(packageRoot, relativePath) {
|
|
224
|
+
const candidate = join(packageRoot, relativePath);
|
|
225
|
+
if (!isContainedInPackageRoot(packageRoot, candidate)) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
return statSync(candidate).isFile();
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** Check (5): every package-owned runtime helper compiled CLI code imports is present. */
|
|
236
|
+
function checkInstallationIntegrity() {
|
|
237
|
+
const packageRoot = dirname(PACKAGE_ROOT);
|
|
238
|
+
const missing = REQUIRED_RUNTIME_HELPERS.filter((helper) => !isRegularFileWithinPackage(packageRoot, helper));
|
|
239
|
+
if (missing.length === 0) {
|
|
240
|
+
return {
|
|
241
|
+
name: 'Installation integrity',
|
|
242
|
+
status: 'ok',
|
|
243
|
+
detail: 'All package-owned runtime helpers are present.',
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const version = readInstalledVersion();
|
|
247
|
+
const packageName = readInstalledPackageName();
|
|
248
|
+
return {
|
|
249
|
+
name: 'Installation integrity',
|
|
250
|
+
status: 'fail',
|
|
251
|
+
detail: `INCOMPLETE_INSTALLATION: ${packageName}@${version} is missing ${missing.join(', ')}. Reinstall with: npm install -g ${packageName}@${version}`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
199
254
|
function runDeepChecks() {
|
|
200
255
|
return [
|
|
201
256
|
checkNodeVersion(),
|
|
202
257
|
checkDataDirWritable(),
|
|
203
258
|
checkRuntimeDatabase(),
|
|
204
259
|
checkLocaleResolution(),
|
|
260
|
+
checkInstallationIntegrity(),
|
|
205
261
|
];
|
|
206
262
|
}
|
|
207
263
|
function deepCheckIcon(status) {
|
|
@@ -226,7 +282,7 @@ function printDeepChecks(results) {
|
|
|
226
282
|
}
|
|
227
283
|
}
|
|
228
284
|
// Exported for tests
|
|
229
|
-
export { checkNodeVersion, checkDataDirWritable, checkRuntimeDatabase, checkLocaleResolution, runDeepChecks, };
|
|
285
|
+
export { checkNodeVersion, checkDataDirWritable, checkRuntimeDatabase, checkLocaleResolution, checkInstallationIntegrity, runDeepChecks, };
|
|
230
286
|
function statusIcon(status) {
|
|
231
287
|
switch (status) {
|
|
232
288
|
case 'ok':
|
package/dist/cli/index.js
CHANGED
|
@@ -6,7 +6,13 @@
|
|
|
6
6
|
// we act as the MCP server. Otherwise we run the CLI router.
|
|
7
7
|
import { route } from './router.js';
|
|
8
8
|
import { installNetworkPolicy } from '../engine/network-policy.js';
|
|
9
|
+
import { assertLocalBuildFreshness } from '../engine/build-freshness.js';
|
|
9
10
|
installNetworkPolicy();
|
|
11
|
+
const freshness = assertLocalBuildFreshness();
|
|
12
|
+
if (freshness.status === 'stale') {
|
|
13
|
+
process.stderr.write(`Stale local build output detected (${freshness.reason}). Run \`pnpm build:ts\` and retry.\n`);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
10
16
|
const args = process.argv.slice(2);
|
|
11
17
|
// stdin.isTTY is undefined/false when piped (MCP host); true in a terminal.
|
|
12
18
|
if (args.length === 0 && !process.stdin.isTTY) {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { BuildFreshnessResult, BuildStamp } from '../types/build-freshness.js';
|
|
2
|
+
export declare const BUILD_STAMP_FILENAME = ".planu-build.json";
|
|
3
|
+
export declare const BUILD_STAMP_SCHEMA_VERSION = 1;
|
|
4
|
+
export declare function writeBuildStamp(distRoot: string, stamp: Omit<BuildStamp, 'schemaVersion'>): void;
|
|
5
|
+
export declare function assertLocalBuildFreshness(distRoot?: string): BuildFreshnessResult;
|
|
6
|
+
//# sourceMappingURL=build-freshness.d.ts.map
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
export const BUILD_STAMP_FILENAME = '.planu-build.json';
|
|
6
|
+
export const BUILD_STAMP_SCHEMA_VERSION = 1;
|
|
7
|
+
const GIT_FALLBACK_TIMEOUT_MS = 2000;
|
|
8
|
+
function isBuildStamp(value) {
|
|
9
|
+
if (typeof value !== 'object' || value === null) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
const candidate = value;
|
|
13
|
+
return (candidate.schemaVersion === BUILD_STAMP_SCHEMA_VERSION &&
|
|
14
|
+
typeof candidate.commit === 'string' &&
|
|
15
|
+
candidate.commit.length > 0);
|
|
16
|
+
}
|
|
17
|
+
export function writeBuildStamp(distRoot, stamp) {
|
|
18
|
+
const finalPath = join(distRoot, BUILD_STAMP_FILENAME);
|
|
19
|
+
const tempPath = `${finalPath}.${process.pid}.tmp`;
|
|
20
|
+
const record = { schemaVersion: BUILD_STAMP_SCHEMA_VERSION, ...stamp };
|
|
21
|
+
try {
|
|
22
|
+
writeFileSync(tempPath, `${JSON.stringify(record)}\n`, 'utf8');
|
|
23
|
+
renameSync(tempPath, finalPath);
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
rmSync(tempPath, { force: true });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function readBuildStamp(distRoot) {
|
|
30
|
+
const stampPath = join(distRoot, BUILD_STAMP_FILENAME);
|
|
31
|
+
if (!existsSync(stampPath)) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(readFileSync(stampPath, 'utf8'));
|
|
36
|
+
return isBuildStamp(parsed) ? parsed : null;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function hasGitMarker(repoRoot) {
|
|
43
|
+
return existsSync(join(repoRoot, '.git'));
|
|
44
|
+
}
|
|
45
|
+
const GIT_SHA_RE = /^[0-9a-f]{40}$|^[0-9a-f]{64}$/iu;
|
|
46
|
+
const GIT_SYMBOLIC_REF_RE = /^ref:\s*(\S+)$/u;
|
|
47
|
+
function readTrimmed(path) {
|
|
48
|
+
try {
|
|
49
|
+
return readFileSync(path, 'utf8').trim();
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function resolveWorktreeGitDir(repoRoot) {
|
|
56
|
+
const gitPath = join(repoRoot, '.git');
|
|
57
|
+
let stat;
|
|
58
|
+
try {
|
|
59
|
+
stat = statSync(gitPath);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if (stat.isDirectory()) {
|
|
65
|
+
return gitPath;
|
|
66
|
+
}
|
|
67
|
+
const content = readTrimmed(gitPath);
|
|
68
|
+
const match = content === null ? null : /^gitdir:\s*(.+)$/u.exec(content);
|
|
69
|
+
return match?.[1] ? resolve(repoRoot, match[1]) : null;
|
|
70
|
+
}
|
|
71
|
+
function resolveCommonGitDir(worktreeGitDir) {
|
|
72
|
+
const commonDirRelative = readTrimmed(join(worktreeGitDir, 'commondir'));
|
|
73
|
+
return commonDirRelative === null ? worktreeGitDir : resolve(worktreeGitDir, commonDirRelative);
|
|
74
|
+
}
|
|
75
|
+
function resolveRefSha(commonGitDir, ref) {
|
|
76
|
+
const looseRef = readTrimmed(join(commonGitDir, ref));
|
|
77
|
+
if (looseRef !== null && GIT_SHA_RE.test(looseRef)) {
|
|
78
|
+
return looseRef;
|
|
79
|
+
}
|
|
80
|
+
const packedRefs = readTrimmed(join(commonGitDir, 'packed-refs'));
|
|
81
|
+
if (packedRefs === null) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
for (const line of packedRefs.split('\n')) {
|
|
85
|
+
const [sha, packedRef] = line.split(' ');
|
|
86
|
+
if (packedRef === ref && sha !== undefined && GIT_SHA_RE.test(sha)) {
|
|
87
|
+
return sha;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
function resolveHeadFromFilesystem(repoRoot) {
|
|
93
|
+
const worktreeGitDir = resolveWorktreeGitDir(repoRoot);
|
|
94
|
+
if (worktreeGitDir === null) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const headContent = readTrimmed(join(worktreeGitDir, 'HEAD'));
|
|
98
|
+
if (headContent === null) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
if (GIT_SHA_RE.test(headContent)) {
|
|
102
|
+
return headContent;
|
|
103
|
+
}
|
|
104
|
+
const symbolicMatch = GIT_SYMBOLIC_REF_RE.exec(headContent);
|
|
105
|
+
if (!symbolicMatch) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const ref = symbolicMatch[1];
|
|
109
|
+
return ref === undefined ? null : resolveRefSha(resolveCommonGitDir(worktreeGitDir), ref);
|
|
110
|
+
}
|
|
111
|
+
function runGitRevParse(repoRoot, revision) {
|
|
112
|
+
try {
|
|
113
|
+
const output = execFileSync('git', ['rev-parse', revision], {
|
|
114
|
+
cwd: repoRoot,
|
|
115
|
+
encoding: 'utf8',
|
|
116
|
+
timeout: GIT_FALLBACK_TIMEOUT_MS,
|
|
117
|
+
killSignal: 'SIGKILL',
|
|
118
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
119
|
+
}).trim();
|
|
120
|
+
return GIT_SHA_RE.test(output) ? output : null;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function resolveHead(repoRoot) {
|
|
127
|
+
return resolveHeadFromFilesystem(repoRoot) ?? runGitRevParse(repoRoot, 'HEAD');
|
|
128
|
+
}
|
|
129
|
+
const BUILD_INPUT_ROOT = 'src';
|
|
130
|
+
function sourceUnchanged(repoRoot, diffArgs) {
|
|
131
|
+
const result = spawnSync('git', ['diff', '--quiet', ...diffArgs, '--', BUILD_INPUT_ROOT], {
|
|
132
|
+
cwd: repoRoot,
|
|
133
|
+
timeout: GIT_FALLBACK_TIMEOUT_MS,
|
|
134
|
+
killSignal: 'SIGKILL',
|
|
135
|
+
stdio: 'ignore',
|
|
136
|
+
});
|
|
137
|
+
return result.error === undefined && result.signal === null && result.status === 0;
|
|
138
|
+
}
|
|
139
|
+
function sourceMatchesRevision(repoRoot, stampCommit, head) {
|
|
140
|
+
return sourceUnchanged(repoRoot, [stampCommit, head]) || sourceUnchanged(repoRoot, [stampCommit]);
|
|
141
|
+
}
|
|
142
|
+
function resolveDistRoot() {
|
|
143
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
144
|
+
}
|
|
145
|
+
function isRunningFromCompiledModule() {
|
|
146
|
+
return fileURLToPath(import.meta.url).endsWith('.js');
|
|
147
|
+
}
|
|
148
|
+
export function assertLocalBuildFreshness(distRoot) {
|
|
149
|
+
if (distRoot === undefined && !isRunningFromCompiledModule()) {
|
|
150
|
+
return { status: 'not-applicable' };
|
|
151
|
+
}
|
|
152
|
+
const resolvedDistRoot = distRoot ?? resolveDistRoot();
|
|
153
|
+
const repoRoot = resolve(resolvedDistRoot, '..');
|
|
154
|
+
if (!hasGitMarker(repoRoot)) {
|
|
155
|
+
return { status: 'not-applicable' };
|
|
156
|
+
}
|
|
157
|
+
const stamp = readBuildStamp(resolvedDistRoot);
|
|
158
|
+
if (!stamp) {
|
|
159
|
+
return { status: 'stale', reason: 'missing or malformed build provenance' };
|
|
160
|
+
}
|
|
161
|
+
const head = resolveHead(repoRoot);
|
|
162
|
+
if (!head) {
|
|
163
|
+
return { status: 'not-applicable' };
|
|
164
|
+
}
|
|
165
|
+
if (stamp.commit === head || sourceMatchesRevision(repoRoot, stamp.commit, head)) {
|
|
166
|
+
return { status: 'fresh' };
|
|
167
|
+
}
|
|
168
|
+
return { status: 'stale', reason: 'build provenance does not match the current revision' };
|
|
169
|
+
}
|
|
170
|
+
//# sourceMappingURL=build-freshness.js.map
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { CascadeContext, CoreActionResult, PendingReleaseEntry } from '../../../types/cascade-hooks.js';
|
|
2
|
-
|
|
3
|
-
export declare function
|
|
2
|
+
import type { ReleaseTagInfo } from '../../../../scripts/lib/pending-release-file.mjs';
|
|
3
|
+
export declare function normalizePendingReleaseEntries(value: unknown): (PendingReleaseEntry & {
|
|
4
|
+
implementationCommit?: string;
|
|
5
|
+
})[];
|
|
6
|
+
export declare function reconcilePublishedPendingEntries(entries: readonly (PendingReleaseEntry & {
|
|
7
|
+
implementationCommit?: string;
|
|
8
|
+
})[], release: ReleaseTagInfo | null, isCommitReachable?: (repoRoot: string, commit: string, target: string) => boolean): (PendingReleaseEntry & {
|
|
9
|
+
implementationCommit?: string;
|
|
10
|
+
})[];
|
|
4
11
|
export declare function appendReleasesPending(ctx: CascadeContext): Promise<CoreActionResult>;
|
|
5
12
|
//# sourceMappingURL=append-releases.d.ts.map
|
|
@@ -4,68 +4,99 @@
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { readFile, mkdir } from 'node:fs/promises';
|
|
6
6
|
import { execFile as execFileCallback } from 'node:child_process';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
7
8
|
import { promisify } from 'node:util';
|
|
8
9
|
import { specStore } from '../../../storage/index.js';
|
|
9
|
-
import {
|
|
10
|
+
import { reportClassifiedDegradation } from '../../../errors/classified-degradation.js';
|
|
10
11
|
const CORE_ACTION_BUDGET_MS = 2000;
|
|
11
|
-
const PENDING_ENTRY_MAX_AGE_DAYS = 30;
|
|
12
12
|
const execFile = promisify(execFileCallback);
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
typeof value.title === 'string' &&
|
|
18
|
-
typeof value.completedAt === 'string');
|
|
13
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
14
|
+
function requireIsCommitReachable(repoRoot, commit, target) {
|
|
15
|
+
const helpers = requireFromHere('../../../../scripts/lib/pending-release-file.mjs');
|
|
16
|
+
return helpers.isCommitReachable(repoRoot, commit, target);
|
|
19
17
|
}
|
|
20
|
-
function
|
|
21
|
-
|
|
22
|
-
if (Number.isNaN(completedAt.getTime())) {
|
|
18
|
+
function isValidPendingReleaseEntry(value) {
|
|
19
|
+
if (typeof value !== 'object' || value === null) {
|
|
23
20
|
return false;
|
|
24
21
|
}
|
|
25
|
-
const
|
|
26
|
-
return
|
|
22
|
+
const candidate = value;
|
|
23
|
+
return (typeof candidate.specId === 'string' &&
|
|
24
|
+
typeof candidate.title === 'string' &&
|
|
25
|
+
typeof candidate.completedAt === 'string' &&
|
|
26
|
+
(candidate.implementationCommit === undefined ||
|
|
27
|
+
typeof candidate.implementationCommit === 'string'));
|
|
28
|
+
}
|
|
29
|
+
function hasParsableCompletionDate(entry) {
|
|
30
|
+
return !Number.isNaN(new Date(entry.completedAt).getTime());
|
|
27
31
|
}
|
|
28
|
-
export function normalizePendingReleaseEntries(value
|
|
32
|
+
export function normalizePendingReleaseEntries(value) {
|
|
29
33
|
if (!Array.isArray(value)) {
|
|
30
|
-
|
|
34
|
+
throw new Error('Pending release ledger is not an array');
|
|
31
35
|
}
|
|
32
36
|
const deduped = new Map();
|
|
33
37
|
for (const entry of value) {
|
|
34
|
-
if (!isValidPendingReleaseEntry(entry) || !
|
|
38
|
+
if (!isValidPendingReleaseEntry(entry) || !hasParsableCompletionDate(entry)) {
|
|
35
39
|
continue;
|
|
36
40
|
}
|
|
37
41
|
deduped.set(entry.specId, entry);
|
|
38
42
|
}
|
|
39
43
|
return [...deduped.values()];
|
|
40
44
|
}
|
|
41
|
-
export function reconcilePublishedPendingEntries(entries,
|
|
42
|
-
if (!
|
|
45
|
+
export function reconcilePublishedPendingEntries(entries, release, isCommitReachable = requireIsCommitReachable) {
|
|
46
|
+
if (!release) {
|
|
43
47
|
return [...entries];
|
|
44
48
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
let reachabilityCheckFailed = false;
|
|
50
|
+
return entries.filter((entry) => {
|
|
51
|
+
if (!entry.implementationCommit) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
return !isCommitReachable(release.repoRoot, entry.implementationCommit, release.commit);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (!reachabilityCheckFailed) {
|
|
59
|
+
reachabilityCheckFailed = true;
|
|
60
|
+
reportClassifiedDegradation('PENDING_RELEASE_REACHABILITY_CHECK_FAILED', error);
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
49
65
|
}
|
|
50
|
-
async function
|
|
66
|
+
async function captureHeadCommit(projectPath) {
|
|
51
67
|
try {
|
|
52
|
-
const
|
|
53
|
-
if (typeof manifest.version !== 'string' ||
|
|
54
|
-
!/^\d+\.\d+\.\d+(?:[-+].*)?$/.test(manifest.version)) {
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
57
|
-
const tag = `v${manifest.version}`;
|
|
58
|
-
const { stdout } = await execFile('git', ['show', '-s', '--format=%cI', tag], {
|
|
68
|
+
const { stdout } = await execFile('git', ['rev-parse', 'HEAD'], {
|
|
59
69
|
cwd: projectPath,
|
|
60
70
|
timeout: 1_000,
|
|
61
71
|
maxBuffer: 16 * 1024,
|
|
62
72
|
});
|
|
63
|
-
const
|
|
64
|
-
return
|
|
73
|
+
const commit = stdout.trim();
|
|
74
|
+
return commit || undefined;
|
|
65
75
|
}
|
|
66
76
|
catch {
|
|
67
|
-
return
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function rewritePendingLedger(input) {
|
|
81
|
+
let pendingList = [];
|
|
82
|
+
try {
|
|
83
|
+
const raw = await readFile(input.pendingPath, 'utf-8');
|
|
84
|
+
pendingList = normalizePendingReleaseEntries(JSON.parse(raw));
|
|
68
85
|
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (error.code !== 'ENOENT') {
|
|
88
|
+
reportClassifiedDegradation('PENDING_RELEASE_LEDGER_UNREADABLE', error);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
pendingList = reconcilePublishedPendingEntries(pendingList, input.releaseInfo, input.isCommitReachable).filter((entry) => entry.specId !== input.specId);
|
|
93
|
+
pendingList.push({
|
|
94
|
+
specId: input.specId,
|
|
95
|
+
title: input.title,
|
|
96
|
+
completedAt: new Date().toISOString().substring(0, 10),
|
|
97
|
+
...(input.implementationCommit ? { implementationCommit: input.implementationCommit } : {}),
|
|
98
|
+
});
|
|
99
|
+
await input.writePendingReleaseLedger(input.pendingPath, pendingList);
|
|
69
100
|
}
|
|
70
101
|
async function actuallyAppendReleasesPending(ctx, opts) {
|
|
71
102
|
const { projectPath, projectId, specId } = ctx;
|
|
@@ -75,6 +106,15 @@ async function actuallyAppendReleasesPending(ctx, opts) {
|
|
|
75
106
|
if (opts.signal.aborted) {
|
|
76
107
|
throw new Error('append-releases-pending aborted before start');
|
|
77
108
|
}
|
|
109
|
+
let helpers;
|
|
110
|
+
try {
|
|
111
|
+
helpers = await import('../../../../scripts/lib/pending-release-file.mjs');
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
reportClassifiedDegradation('RELEASE_METADATA_UNAVAILABLE', error);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const { isCommitReachable, resolveLatestReleaseTag, withPendingReleaseLock, writePendingReleaseLedger, } = helpers;
|
|
78
118
|
const releasesDir = join(projectPath, 'planu', 'releases');
|
|
79
119
|
const pendingPath = join(releasesDir, 'pending.json');
|
|
80
120
|
await new Promise((resolve, reject) => {
|
|
@@ -84,24 +124,17 @@ async function actuallyAppendReleasesPending(ctx, opts) {
|
|
|
84
124
|
(async () => {
|
|
85
125
|
await mkdir(releasesDir, { recursive: true });
|
|
86
126
|
const spec = await specStore.getSpec(projectId, specId);
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
pendingList = reconcilePublishedPendingEntries(pendingList, latestPublishedAt);
|
|
99
|
-
const completedAt = new Date().toISOString().substring(0, 10);
|
|
100
|
-
pendingList = pendingList.filter((entry) => entry.specId !== specId);
|
|
101
|
-
pendingList.push({ specId, title: spec?.title ?? specId, completedAt });
|
|
102
|
-
await writePendingReleaseLedger(pendingPath, pendingList);
|
|
103
|
-
});
|
|
104
|
-
// Auto-commit planu/ changes so pending.json is never left unstaged
|
|
127
|
+
const releaseInfo = await resolveLatestReleaseTag(projectPath);
|
|
128
|
+
const implementationCommit = await captureHeadCommit(projectPath);
|
|
129
|
+
await withPendingReleaseLock(pendingPath, { timeoutMs: 1_500, signal: opts.signal }, () => rewritePendingLedger({
|
|
130
|
+
pendingPath,
|
|
131
|
+
specId,
|
|
132
|
+
title: spec?.title ?? specId,
|
|
133
|
+
implementationCommit,
|
|
134
|
+
releaseInfo,
|
|
135
|
+
isCommitReachable,
|
|
136
|
+
writePendingReleaseLedger,
|
|
137
|
+
}));
|
|
105
138
|
void (async () => {
|
|
106
139
|
try {
|
|
107
140
|
const { planuAutoCommit } = await import('../../git/planu-autocommit.js');
|
|
@@ -1,18 +1,29 @@
|
|
|
1
1
|
import { reportClassifiedDegradation } from '../../../errors/classified-degradation.js';
|
|
2
|
+
import { assertLocalBuildFreshness } from '../../build-freshness.js';
|
|
3
|
+
function isEnrichmentIntegrityFailure(error) {
|
|
4
|
+
return error instanceof Error && error.message.startsWith('SPEC_ENRICHMENT_INTEGRITY_FAILED');
|
|
5
|
+
}
|
|
2
6
|
async function handler(ctx) {
|
|
3
7
|
const { projectPath, specId, spec } = ctx;
|
|
4
8
|
if (!projectPath) {
|
|
5
9
|
return;
|
|
6
10
|
}
|
|
7
|
-
// Skip trivial-scope specs
|
|
8
11
|
if (spec.scope === 'trivial') {
|
|
9
12
|
return;
|
|
10
13
|
}
|
|
11
14
|
try {
|
|
15
|
+
if (assertLocalBuildFreshness().status === 'stale') {
|
|
16
|
+
reportClassifiedDegradation('STALE_LOCAL_DIST', new Error('Local build output is stale relative to the active checkout. Run `pnpm build:ts` before retrying.'));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
12
19
|
const { runTechnicalEnricher } = await import('../../../engine/technical-enricher/index.js');
|
|
13
20
|
await runTechnicalEnricher({ specId, projectPath, specScope: spec.scope });
|
|
14
21
|
}
|
|
15
|
-
catch {
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (isEnrichmentIntegrityFailure(error)) {
|
|
24
|
+
reportClassifiedDegradation('SPEC_ENRICHMENT_INTEGRITY_FAILED', new Error('Review enrichment output failed the canonical spec integrity contract'));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
16
27
|
reportClassifiedDegradation('REVIEW_ENRICHER_FAILED', new Error('Technical review enrichment failed'));
|
|
17
28
|
}
|
|
18
29
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const HISTORICAL_TRUNCATION_MARKER = '[truncated — spec too large, enrich remaining files manually]';
|
|
2
|
+
const REQUIRED_WORD_MARKERS = ['GIVEN', 'WHEN', 'THEN', 'AND'];
|
|
3
|
+
const REQUIRED_LITERAL_MARKERS = ['FILES:', 'FUNCTIONS:', 'TEST:'];
|
|
4
|
+
const HEADING_LINE_PATTERN = /^#{1,6}[ \t]+.+$/gm;
|
|
5
|
+
function countLiteralOccurrences(haystack, needle) {
|
|
6
|
+
let count = 0;
|
|
7
|
+
let index = haystack.indexOf(needle);
|
|
8
|
+
while (index !== -1) {
|
|
9
|
+
count += 1;
|
|
10
|
+
index = haystack.indexOf(needle, index + needle.length);
|
|
11
|
+
}
|
|
12
|
+
return count;
|
|
13
|
+
}
|
|
14
|
+
function countWordOccurrences(haystack, word) {
|
|
15
|
+
return (haystack.match(new RegExp(`\\b${word}\\b`, 'g')) ?? []).length;
|
|
16
|
+
}
|
|
17
|
+
function integrityFailure(hookId, reason) {
|
|
18
|
+
return new Error(`SPEC_ENRICHMENT_INTEGRITY_FAILED: ${hookId} ${reason}`);
|
|
19
|
+
}
|
|
20
|
+
export function assertEnrichmentPreservesContract(before, after, hookId) {
|
|
21
|
+
if (after.includes(HISTORICAL_TRUNCATION_MARKER)) {
|
|
22
|
+
throw integrityFailure(hookId, 'produced the historical truncation marker');
|
|
23
|
+
}
|
|
24
|
+
const afterHeadings = new Set(after.match(HEADING_LINE_PATTERN) ?? []);
|
|
25
|
+
const beforeHeadings = before.match(HEADING_LINE_PATTERN) ?? [];
|
|
26
|
+
if (beforeHeadings.some((heading) => !afterHeadings.has(heading))) {
|
|
27
|
+
throw integrityFailure(hookId, 'removed a pre-existing heading');
|
|
28
|
+
}
|
|
29
|
+
for (const marker of REQUIRED_WORD_MARKERS) {
|
|
30
|
+
if (countWordOccurrences(after, marker) < countWordOccurrences(before, marker)) {
|
|
31
|
+
throw integrityFailure(hookId, `reduced "${marker}" occurrences`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const marker of REQUIRED_LITERAL_MARKERS) {
|
|
35
|
+
if (countLiteralOccurrences(after, marker) < countLiteralOccurrences(before, marker)) {
|
|
36
|
+
throw integrityFailure(hookId, `reduced "${marker}" occurrences`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=integrity.js.map
|
|
@@ -9,6 +9,7 @@ import { hasGeneratedEnrichment, renderEnriched } from './render-enriched.js';
|
|
|
9
9
|
import { findMarkdownSectionRange } from '../spec-format/markdown-sections.js';
|
|
10
10
|
import { resolveContainedProjectFile } from '../safety/contained-project-file.js';
|
|
11
11
|
import { atomicWriteFile } from '../safety/atomic-write-file.js';
|
|
12
|
+
import { assertEnrichmentPreservesContract } from '../cascade-hooks/integrity.js';
|
|
12
13
|
export async function enrichTechnicalContent(input) {
|
|
13
14
|
const { technicalContent, specScope, projectPath } = input;
|
|
14
15
|
if (hasGeneratedEnrichment(technicalContent)) {
|
|
@@ -63,6 +64,7 @@ async function runEnricherFromSpecSection(opts) {
|
|
|
63
64
|
});
|
|
64
65
|
if (result.enriched && result.enrichedContent) {
|
|
65
66
|
const updated = `${specRaw.slice(0, section.contentStart)}${result.enrichedContent}${specRaw.slice(section.end)}`;
|
|
67
|
+
assertEnrichmentPreservesContract(specRaw, updated, 'review-enricher');
|
|
66
68
|
await atomicWriteFile(physicalSpecPath, updated);
|
|
67
69
|
}
|
|
68
70
|
}
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
// Creates the MCP server, registers tools & resources, and starts stdio transport.
|
|
4
4
|
import { installNetworkPolicy } from './engine/network-policy.js';
|
|
5
5
|
installNetworkPolicy();
|
|
6
|
+
const { assertLocalBuildFreshness } = await import('./engine/build-freshness.js');
|
|
7
|
+
const freshness = assertLocalBuildFreshness();
|
|
8
|
+
if (freshness.status === 'stale') {
|
|
9
|
+
process.stderr.write(`Stale local build output detected (${freshness.reason}). Run \`pnpm build:ts\` and retry.\n`);
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
6
12
|
// Remove obsolete entitlement state before CLI dispatch or MCP registration can read requests.
|
|
7
13
|
const { removeCommercialState } = await import('./engine/migrations/remove-commercial-state.js');
|
|
8
14
|
await removeCommercialState();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type BuildFreshnessStatus = 'fresh' | 'stale' | 'not-applicable';
|
|
2
|
+
export interface BuildFreshnessResult {
|
|
3
|
+
readonly status: BuildFreshnessStatus;
|
|
4
|
+
readonly reason?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface BuildStamp {
|
|
7
|
+
readonly schemaVersion: number;
|
|
8
|
+
readonly commit: string;
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=build-freshness.d.ts.map
|
package/dist/types/index.d.ts
CHANGED
|
@@ -169,6 +169,7 @@ export * from './spec-lock.js';
|
|
|
169
169
|
export * from './spec-lock-v2.js';
|
|
170
170
|
export * from './diagram.js';
|
|
171
171
|
export * from './browser-validator.js';
|
|
172
|
+
export * from './build-freshness.js';
|
|
172
173
|
export * from './google-workspace.js';
|
|
173
174
|
export * from './skill-eval.js';
|
|
174
175
|
export * from './spec-marketplace.js';
|
package/dist/types/index.js
CHANGED
|
@@ -166,6 +166,7 @@ export * from './spec-lock.js';
|
|
|
166
166
|
export * from './spec-lock-v2.js';
|
|
167
167
|
export * from './diagram.js';
|
|
168
168
|
export * from './browser-validator.js';
|
|
169
|
+
export * from './build-freshness.js';
|
|
169
170
|
export * from './google-workspace.js';
|
|
170
171
|
export * from './skill-eval.js';
|
|
171
172
|
export * from './spec-marketplace.js';
|
package/package.json
CHANGED
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.58",
|
|
6
6
|
"icon": "assets/plugin/icon.svg",
|
|
7
7
|
"command": ["npx", "@planu/cli@latest"],
|
|
8
8
|
"packageName": "@planu/cli",
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { execFile as execFileCallback, spawnSync } from 'node:child_process';
|
|
1
2
|
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
2
5
|
|
|
6
|
+
const execFile = promisify(execFileCallback);
|
|
3
7
|
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
4
8
|
const POLL_INTERVAL_MS = 10;
|
|
5
9
|
const ABANDONED_LOCK_MS = 30_000;
|
|
@@ -77,6 +81,41 @@ export async function withPendingReleaseLock(pendingPath, options, task) {
|
|
|
77
81
|
}
|
|
78
82
|
}
|
|
79
83
|
|
|
84
|
+
const VERSION_TAG_PATTERN = /^\d+\.\d+\.\d+(?:[-+].*)?$/;
|
|
85
|
+
|
|
86
|
+
export async function resolveLatestReleaseTag(repoRoot) {
|
|
87
|
+
try {
|
|
88
|
+
const manifest = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8'));
|
|
89
|
+
if (typeof manifest.version !== 'string' || !VERSION_TAG_PATTERN.test(manifest.version)) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const tag = `v${manifest.version}`;
|
|
93
|
+
const { stdout } = await execFile('git', ['rev-parse', '--verify', '-q', `${tag}^{commit}`], {
|
|
94
|
+
cwd: repoRoot,
|
|
95
|
+
timeout: 1_000,
|
|
96
|
+
maxBuffer: 16 * 1024,
|
|
97
|
+
});
|
|
98
|
+
const commit = stdout.trim();
|
|
99
|
+
return commit ? { tag, commit, repoRoot } : null;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function isCommitReachable(repoRoot, commit, ontoCommit) {
|
|
106
|
+
const result = spawnSync('git', ['merge-base', '--is-ancestor', commit, ontoCommit], {
|
|
107
|
+
cwd: repoRoot,
|
|
108
|
+
stdio: 'ignore',
|
|
109
|
+
timeout: 1_000,
|
|
110
|
+
});
|
|
111
|
+
if (result.error) {
|
|
112
|
+
throw result.error;
|
|
113
|
+
}
|
|
114
|
+
if (result.status === 0) return true;
|
|
115
|
+
if (result.status === 1) return false;
|
|
116
|
+
throw new Error(`git merge-base --is-ancestor exited with status ${String(result.status)}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
80
119
|
export async function writePendingReleaseLedger(pendingPath, entries) {
|
|
81
120
|
const temporaryPath = `${pendingPath}.${process.pid}.${Date.now()}.tmp`;
|
|
82
121
|
try {
|