@planu/cli 5.3.57 → 5.3.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,34 @@
1
+ ## [5.3.59] - 2026-08-26
2
+
3
+ ### Bug Fixes
4
+ - fix(SPEC-1631): report graph coverage as unavailable instead of zero gaps
5
+
6
+ ### Chores
7
+ - chore(planu): close SPEC-1631
8
+ - chore(planu): record SPEC-1631 implementing transition
9
+
10
+
11
+ ## [5.3.58] - 2026-08-26
12
+
13
+ ### Bug Fixes
14
+ - fix(SPEC-1317): assert the dynamic-import edge for the pending-release helper
15
+ - fix(SPEC-1319): close review findings on enricher integrity wiring
16
+ - fix(SPEC-1317): align doctor deep-check count with the installation-integrity check
17
+ - fix(SPEC-1319): block stale review-enricher runtimes from truncating canonical specs
18
+ - fix(SPEC-1317): diagnose and recover incomplete CLI installs without crashing planu status
19
+
20
+ ### Refactoring
21
+ - refactor(SPEC-1319): move enrichment integrity validator to a neutral module
22
+ - refactor(SPEC-1317): extract pending-ledger rewrite to satisfy the function-length gate
23
+
24
+ ### Chores
25
+ - chore(planu): close SPEC-1317 and SPEC-1319
26
+ - chore(SPEC-1319): integrate review-enricher integrity and stale-dist guard
27
+ - chore(SPEC-1317): integrate incomplete-install doctor and release-metadata degradation
28
+ - chore(planu): record SPEC-1317 implementing state
29
+ - chore(planu): record SPEC-1319 implementing and fix SPEC-1317 files ownership
30
+
31
+
1
32
  ## [5.3.57] - 2026-08-26
2
33
 
3
34
  ### Bug Fixes
@@ -1 +1 @@
1
- {"schemaVersion":1,"commit":"c368e9e9512d869bdabaf330a598fadc95eb2a9c"}
1
+ {"schemaVersion":1,"commit":"8ad23a1b29d2ffe8c44122aba263f9a6e9577d80"}
@@ -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':
@@ -1,11 +1,11 @@
1
1
  import type { CascadeContext, CoreActionResult, PendingReleaseEntry } from '../../../types/cascade-hooks.js';
2
- import { type ReleaseTagInfo } from '../../../../scripts/lib/pending-release-file.mjs';
2
+ import type { ReleaseTagInfo } from '../../../../scripts/lib/pending-release-file.mjs';
3
3
  export declare function normalizePendingReleaseEntries(value: unknown): (PendingReleaseEntry & {
4
4
  implementationCommit?: string;
5
5
  })[];
6
6
  export declare function reconcilePublishedPendingEntries(entries: readonly (PendingReleaseEntry & {
7
7
  implementationCommit?: string;
8
- })[], release: ReleaseTagInfo | null): (PendingReleaseEntry & {
8
+ })[], release: ReleaseTagInfo | null, isCommitReachable?: (repoRoot: string, commit: string, target: string) => boolean): (PendingReleaseEntry & {
9
9
  implementationCommit?: string;
10
10
  })[];
11
11
  export declare function appendReleasesPending(ctx: CascadeContext): Promise<CoreActionResult>;
@@ -4,12 +4,17 @@
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
10
  import { reportClassifiedDegradation } from '../../../errors/classified-degradation.js';
10
- import { isCommitReachable, resolveLatestReleaseTag, withPendingReleaseLock, writePendingReleaseLedger, } from '../../../../scripts/lib/pending-release-file.mjs';
11
11
  const CORE_ACTION_BUDGET_MS = 2000;
12
12
  const execFile = promisify(execFileCallback);
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);
17
+ }
13
18
  function isValidPendingReleaseEntry(value) {
14
19
  if (typeof value !== 'object' || value === null) {
15
20
  return false;
@@ -37,7 +42,7 @@ export function normalizePendingReleaseEntries(value) {
37
42
  }
38
43
  return [...deduped.values()];
39
44
  }
40
- export function reconcilePublishedPendingEntries(entries, release) {
45
+ export function reconcilePublishedPendingEntries(entries, release, isCommitReachable = requireIsCommitReachable) {
41
46
  if (!release) {
42
47
  return [...entries];
43
48
  }
@@ -72,6 +77,27 @@ async function captureHeadCommit(projectPath) {
72
77
  return undefined;
73
78
  }
74
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));
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);
100
+ }
75
101
  async function actuallyAppendReleasesPending(ctx, opts) {
76
102
  const { projectPath, projectId, specId } = ctx;
77
103
  if (!projectPath || ctx.newStatus !== 'done') {
@@ -80,6 +106,15 @@ async function actuallyAppendReleasesPending(ctx, opts) {
80
106
  if (opts.signal.aborted) {
81
107
  throw new Error('append-releases-pending aborted before start');
82
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;
83
118
  const releasesDir = join(projectPath, 'planu', 'releases');
84
119
  const pendingPath = join(releasesDir, 'pending.json');
85
120
  await new Promise((resolve, reject) => {
@@ -91,30 +126,15 @@ async function actuallyAppendReleasesPending(ctx, opts) {
91
126
  const spec = await specStore.getSpec(projectId, specId);
92
127
  const releaseInfo = await resolveLatestReleaseTag(projectPath);
93
128
  const implementationCommit = await captureHeadCommit(projectPath);
94
- await withPendingReleaseLock(pendingPath, { timeoutMs: 1_500, signal: opts.signal }, async () => {
95
- let pendingList = [];
96
- try {
97
- const raw = await readFile(pendingPath, 'utf-8');
98
- const parsed = JSON.parse(raw);
99
- pendingList = normalizePendingReleaseEntries(parsed);
100
- }
101
- catch (error) {
102
- if (error.code !== 'ENOENT') {
103
- reportClassifiedDegradation('PENDING_RELEASE_LEDGER_UNREADABLE', error);
104
- return;
105
- }
106
- }
107
- pendingList = reconcilePublishedPendingEntries(pendingList, releaseInfo);
108
- const completedAt = new Date().toISOString().substring(0, 10);
109
- pendingList = pendingList.filter((entry) => entry.specId !== specId);
110
- pendingList.push({
111
- specId,
112
- title: spec?.title ?? specId,
113
- completedAt,
114
- ...(implementationCommit ? { implementationCommit } : {}),
115
- });
116
- await writePendingReleaseLedger(pendingPath, pendingList);
117
- });
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
+ }));
118
138
  void (async () => {
119
139
  try {
120
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,2 @@
1
+ export declare function assertEnrichmentPreservesContract(before: string, after: string, hookId: string): void;
2
+ //# sourceMappingURL=integrity.d.ts.map
@@ -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
  }
@@ -19,6 +19,10 @@ export declare const ValidateCompletionOutputSchema: {
19
19
  failing: "failing";
20
20
  passing: "passing";
21
21
  }>;
22
+ graphAvailability: z.ZodEnum<{
23
+ available: "available";
24
+ unavailable: "unavailable";
25
+ }>;
22
26
  }, z.core.$strip>;
23
27
  counts: z.ZodObject<{
24
28
  matches: z.ZodNumber;
@@ -198,6 +202,10 @@ export declare const ValidateCompletionOutputSchema: {
198
202
  }, z.core.$strip>>;
199
203
  }, z.core.$strip>>;
200
204
  graphCoverage: z.ZodObject<{
205
+ availability: z.ZodEnum<{
206
+ available: "available";
207
+ unavailable: "unavailable";
208
+ }>;
201
209
  gapsCount: z.ZodNumber;
202
210
  gapsTop5: z.ZodArray<z.ZodString>;
203
211
  freshness: z.ZodOptional<z.ZodObject<{
@@ -40,6 +40,7 @@ export const ValidateCompletionOutputSchema = {
40
40
  failed: z.number(),
41
41
  total: z.number(),
42
42
  status: z.enum(['passing', 'failing']),
43
+ graphAvailability: z.enum(['available', 'unavailable']),
43
44
  }),
44
45
  counts: z.object({
45
46
  matches: z.number(),
@@ -162,6 +163,7 @@ export const ValidateCompletionOutputSchema = {
162
163
  })
163
164
  .optional(),
164
165
  graphCoverage: z.object({
166
+ availability: z.enum(['available', 'unavailable']),
165
167
  gapsCount: z.number(),
166
168
  gapsTop5: z.array(z.string()),
167
169
  freshness: z
@@ -1,9 +1,7 @@
1
1
  // tools/validate-graph-coverage.ts — Graph coverage helpers for validate.
2
+ import { reportClassifiedDegradation } from '../errors/classified-degradation.js';
2
3
  import { queryProjectGraphSlice } from '../engine/project-graph/index.js';
3
4
  function graphCoverageGaps(slice) {
4
- if (slice === null) {
5
- return [];
6
- }
7
5
  const criteria = slice.nodes.filter((node) => node.type === 'criterion');
8
6
  return criteria
9
7
  .filter((criterion) => {
@@ -14,15 +12,29 @@ function graphCoverageGaps(slice) {
14
12
  .slice(0, 10);
15
13
  }
16
14
  export async function buildGraphCoverageReport(args) {
17
- const graphSlice = await queryProjectGraphSlice(args).catch(() => null);
15
+ let graphSlice;
16
+ try {
17
+ graphSlice = await queryProjectGraphSlice(args);
18
+ }
19
+ catch (error) {
20
+ reportClassifiedDegradation('GRAPH_COVERAGE_UNAVAILABLE', error);
21
+ graphSlice = null;
22
+ }
23
+ if (graphSlice === null) {
24
+ return { availability: 'unavailable', gaps: [], compactNodes: 0, compactEdges: 0 };
25
+ }
18
26
  return {
19
- freshness: graphSlice?.freshness,
27
+ availability: 'available',
28
+ freshness: graphSlice.freshness,
20
29
  gaps: graphCoverageGaps(graphSlice),
21
- compactNodes: graphSlice?.nodes.length ?? 0,
22
- compactEdges: graphSlice?.edges.length ?? 0,
30
+ compactNodes: graphSlice.nodes.length,
31
+ compactEdges: graphSlice.edges.length,
23
32
  };
24
33
  }
25
34
  export function formatGraphCoverageText(report) {
35
+ if (report.availability === 'unavailable') {
36
+ return '\nGRAPH unavailable (project graph unreadable)';
37
+ }
26
38
  return report.gaps.length > 0
27
39
  ? `\nGRAPH ${String(report.gaps.length)} graph-backed coverage gap(s)`
28
40
  : '';
@@ -583,7 +583,7 @@ export async function executeValidate(args, server, onProgress) {
583
583
  });
584
584
  const graphText = formatGraphCoverageText(graphCoverage);
585
585
  // SPEC-512: Compact structuredContent — essential fields only at top level
586
- const compactSummary = buildCompactSummary(specId, effectiveResult.score === null ? null : effectiveScore, passedCount, failedCount + (qualityGateSummary.passed ? 0 : 1), totalCriteria);
586
+ const compactSummary = buildCompactSummary(specId, effectiveResult.score === null ? null : effectiveScore, passedCount, failedCount + (qualityGateSummary.passed ? 0 : 1), totalCriteria, graphCoverage.availability);
587
587
  const structuredBase = buildValidateStructuredContent({
588
588
  specId,
589
589
  title: spec.title,
@@ -718,7 +718,7 @@ function buildEffectiveValidationResult(result, specCompliance) {
718
718
  indeterminate: executableCoverage.indeterminate,
719
719
  };
720
720
  }
721
- function buildCompactSummary(specId, score, passedCount, failedCount, totalCriteria) {
721
+ function buildCompactSummary(specId, score, passedCount, failedCount, totalCriteria, graphAvailability) {
722
722
  return {
723
723
  specId,
724
724
  score,
@@ -726,6 +726,7 @@ function buildCompactSummary(specId, score, passedCount, failedCount, totalCrite
726
726
  failed: failedCount,
727
727
  total: totalCriteria,
728
728
  status: failedCount === 0 ? 'passing' : 'failing',
729
+ graphAvailability,
729
730
  };
730
731
  }
731
732
  function buildValidateStructuredContent(args) {
@@ -859,10 +860,10 @@ function buildValidateStructuredContent(args) {
859
860
  }
860
861
  : undefined,
861
862
  graphCoverage: {
863
+ availability: args.graphCoverage.availability,
862
864
  gapsCount: args.graphCoverage.gaps.length,
863
865
  gapsTop5: args.graphCoverage.gaps.slice(0, 5),
864
866
  freshness: args.graphCoverage.freshness,
865
- summary: args.graphCoverage.summary,
866
867
  },
867
868
  suggestionsTop5: args.suggestions.slice(0, 5),
868
869
  });
@@ -198,6 +198,7 @@ export interface ProjectGraphQueryInput {
198
198
  maxEdges?: number;
199
199
  }
200
200
  export interface GraphCoverageReport {
201
+ availability: 'available' | 'unavailable';
201
202
  freshness?: ProjectGraphFreshness;
202
203
  gaps: string[];
203
204
  compactNodes: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "5.3.57",
3
+ "version": "5.3.59",
4
4
  "description": "Planu — MCP Server for Spec Driven Development. Cross-platform (Linux/macOS/Windows, x64/arm64).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/planu-plugin.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dev.planu.cli",
3
3
  "displayName": "Planu — Spec Driven Development",
4
4
  "description": "Manage software specs, estimations, and autonomous SDD workflows. Language-agnostic MCP server for Claude Code.",
5
- "version": "5.3.57",
5
+ "version": "5.3.59",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": ["npx", "@planu/cli@latest"],
8
8
  "packageName": "@planu/cli",