@planu/cli 5.0.0 → 5.2.0

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.
Files changed (112) hide show
  1. package/CHANGELOG.md +76 -1
  2. package/dist/cli/commands/doctor.d.ts +22 -0
  3. package/dist/cli/commands/doctor.js +176 -2
  4. package/dist/cli/commands/spec.js +10 -1
  5. package/dist/core/spec-validator.js +32 -18
  6. package/dist/engine/autopilot/bootstrap.js +27 -0
  7. package/dist/engine/core-bridge.d.ts +28 -0
  8. package/dist/engine/core-bridge.js +67 -0
  9. package/dist/engine/drift-monitor.js +16 -18
  10. package/dist/engine/evidence-gates/artifact-reader.d.ts +2 -0
  11. package/dist/engine/evidence-gates/artifact-reader.js +59 -2
  12. package/dist/engine/evidence-gates/evidence-autofill.d.ts +10 -0
  13. package/dist/engine/evidence-gates/evidence-autofill.js +148 -0
  14. package/dist/engine/evidence-gates/evidence-skeletons.d.ts +19 -0
  15. package/dist/engine/evidence-gates/evidence-skeletons.js +69 -0
  16. package/dist/engine/execution/operation-journal.js +10 -4
  17. package/dist/engine/living-spec/hash-tracker.js +26 -28
  18. package/dist/engine/minimality/policy-loader.js +247 -6
  19. package/dist/engine/planu-core.darwin-arm64.node.manifest.json +7 -7
  20. package/dist/engine/planu-core.darwin-arm64.node.sbom.json +14 -14
  21. package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
  22. package/dist/engine/planu-core.darwin-x64.node.sbom.json +14 -14
  23. package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
  24. package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +14 -14
  25. package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
  26. package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +14 -14
  27. package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
  28. package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +14 -14
  29. package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
  30. package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +14 -14
  31. package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
  32. package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +14 -14
  33. package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
  34. package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +14 -14
  35. package/dist/engine/reverse-engineer/api-detector.js +2 -13
  36. package/dist/engine/reverse-engineer/complexity-analyzer.js +2 -13
  37. package/dist/engine/reverse-engineer/config-analyzer.js +2 -13
  38. package/dist/engine/reverse-engineer/dependency-graph.js +2 -13
  39. package/dist/engine/reverse-engineer/test-analyzer.js +2 -13
  40. package/dist/engine/reverse-engineer/walk-ignore.d.ts +3 -0
  41. package/dist/engine/reverse-engineer/walk-ignore.js +26 -0
  42. package/dist/engine/spec-format/acceptance-criteria.js +13 -12
  43. package/dist/engine/spec-format/text-fences.js +20 -2
  44. package/dist/engine/spec-language/english-only.d.ts +14 -0
  45. package/dist/engine/spec-language/english-only.js +58 -0
  46. package/dist/engine/spec-migrator/criteria-scanner.js +11 -13
  47. package/dist/engine/spec-migrator/drift-detector.js +10 -12
  48. package/dist/engine/spec-state-syncer.js +1 -1
  49. package/dist/engine/timing/budget.js +5 -1
  50. package/dist/engine/vector-store/tfidf.d.ts +13 -9
  51. package/dist/engine/vector-store/tfidf.js +26 -0
  52. package/dist/engine/worker-config-loader.d.ts +1 -1
  53. package/dist/engine/worker-config-loader.js +1 -11
  54. package/dist/engine/workers/schema.d.ts +0 -8
  55. package/dist/engine/workers/schema.js +0 -1
  56. package/dist/i18n/index.d.ts +18 -0
  57. package/dist/i18n/index.js +40 -1
  58. package/dist/server/routes/specs.js +7 -5
  59. package/dist/storage/global-store.d.ts +9 -0
  60. package/dist/storage/global-store.js +23 -0
  61. package/dist/storage/semantic-index-store.d.ts +23 -0
  62. package/dist/storage/semantic-index-store.js +105 -0
  63. package/dist/storage/status-store/self-healing.js +15 -17
  64. package/dist/tools/challenge-spec/scenarios-utils.js +5 -1
  65. package/dist/tools/challenge-spec-helpers.d.ts +10 -1
  66. package/dist/tools/challenge-spec-helpers.js +63 -22
  67. package/dist/tools/challenge-spec.js +18 -3
  68. package/dist/tools/check-readiness.js +37 -13
  69. package/dist/tools/create-spec/spec-builder.d.ts +7 -0
  70. package/dist/tools/create-spec/spec-builder.js +19 -4
  71. package/dist/tools/create-spec.js +216 -101
  72. package/dist/tools/init-project/handler.js +78 -25
  73. package/dist/tools/learn.js +10 -8
  74. package/dist/tools/register-spec-tools/core-spec-tools.js +13 -12
  75. package/dist/tools/registry/auth.js +1 -11
  76. package/dist/tools/semantic-search-handler.js +5 -6
  77. package/dist/tools/status-handler.js +19 -6
  78. package/dist/tools/sync-spec-state-handler.js +49 -1
  79. package/dist/tools/update-status/batch.d.ts +6 -2
  80. package/dist/tools/update-status/batch.js +58 -1
  81. package/dist/tools/update-status/dod-gates.d.ts +16 -1
  82. package/dist/tools/update-status/dod-gates.js +191 -1
  83. package/dist/tools/update-status/done-receipt-verifier.d.ts +8 -0
  84. package/dist/tools/update-status/done-receipt-verifier.js +37 -2
  85. package/dist/tools/update-status/evidence-gate.d.ts +4 -0
  86. package/dist/tools/update-status/evidence-gate.js +67 -2
  87. package/dist/tools/update-status/file-sync.d.ts +2 -2
  88. package/dist/tools/update-status/index.d.ts +23 -1
  89. package/dist/tools/update-status/index.js +201 -24
  90. package/dist/tools/update-status/transition-guard.js +13 -1
  91. package/dist/tools/validation-loop-handler.js +16 -15
  92. package/dist/tools/workspace-dashboard-handler.js +38 -0
  93. package/dist/types/evidence-autofill.d.ts +34 -0
  94. package/dist/types/evidence-autofill.js +2 -0
  95. package/dist/types/index.d.ts +1 -0
  96. package/dist/types/index.js +1 -0
  97. package/dist/types/spec/core.d.ts +6 -0
  98. package/dist/types/spec/inputs.d.ts +7 -2
  99. package/dist/types/spec-format.d.ts +1 -1
  100. package/dist/types/spec-language-translation.d.ts +18 -0
  101. package/dist/types/spec-language-translation.js +5 -0
  102. package/dist/types/spec-registry.d.ts +0 -2
  103. package/dist/types/status.d.ts +2 -0
  104. package/dist/types/transition-log.d.ts +1 -1
  105. package/dist/types/validation.d.ts +8 -2
  106. package/dist/types/vector-store.d.ts +18 -0
  107. package/dist/types/workers.d.ts +0 -3
  108. package/package.json +11 -10
  109. package/planu-native.json +8 -29
  110. package/planu-plugin.json +1 -1
  111. package/dist/engine/security/cve-refresher.d.ts +0 -12
  112. package/dist/engine/security/cve-refresher.js +0 -128
@@ -11,6 +11,7 @@ import { analyzeProject, checkProjectCompleteness } from '../../engine/analyzer.
11
11
  import { parseProjectQualityRules } from '../../engine/auditor-claude-md.js';
12
12
  import { detectMissingAreas, buildRequestPrompt } from '../../engine/context-merger.js';
13
13
  import { detectProjectPrivacyConfig, maybeGenerateEnvExample, buildBeginnerEnvGuide, } from './helpers.js';
14
+ import { buildDefaultPrivacyConfig } from '../../engine/pii-detector.js';
14
15
  import { checkBundledVersionGap } from '../../engine/version-detector/bundled-version-checker.js';
15
16
  import { checkAndFixBundledVersion } from '../../engine/mcp-config/mcp-config-writer.js';
16
17
  import { buildInitProjectResult } from './result-builder.js';
@@ -86,6 +87,25 @@ function detectMultipleFrameworks(stack) {
86
87
  }
87
88
  return null;
88
89
  }
90
+ /**
91
+ * SPEC-1343: Runs one independent, best-effort pipeline stage in isolation so it can be
92
+ * batched with `Promise.all` alongside other independent stages. A rejection never
93
+ * propagates to the caller — it resolves to `fallback` and is flagged via `failed`/`error`
94
+ * so init_project can still succeed and surface the failure in its result summary (AC2).
95
+ */
96
+ async function runOptionalStage(name, fallback, task) {
97
+ try {
98
+ return { name, value: await task(), failed: false };
99
+ }
100
+ catch (err) {
101
+ return {
102
+ name,
103
+ value: fallback,
104
+ failed: true,
105
+ error: err instanceof Error ? err.message : String(err),
106
+ };
107
+ }
108
+ }
89
109
  // eslint-disable-next-line max-lines-per-function
90
110
  export async function handleInitProject(params, server) {
91
111
  // eslint-disable-next-line max-lines-per-function, complexity
@@ -256,8 +276,14 @@ export async function handleInitProject(params, server) {
256
276
  const globalConfig = await globalStore.getGlobalConfig();
257
277
  const effectiveLocale = locale ?? globalConfig.defaultLocale;
258
278
  const effectiveExperience = experienceLevel ?? globalConfig.defaultExperienceLevel;
259
- // Run full project analysis
260
- const knowledge = await analyzeProject(projectPath, projectId, effectiveLocale, effectiveExperience);
279
+ // SPEC-1343: analyzeProject (full tree scan) and detectStackPatterns are both
280
+ // independent read-only scans of the same project tree — run them concurrently
281
+ // instead of back-to-back so wall time is ~1x the slower stage, not the sum (AC1).
282
+ const [knowledge, stackResult] = await Promise.all([
283
+ analyzeProject(projectPath, projectId, effectiveLocale, effectiveExperience),
284
+ detectStackPatterns(projectPath),
285
+ ]);
286
+ const { modernPatterns, dddConfig, agileDetection, orchestratorInfo, composeMultiplatform } = stackResult;
261
287
  if (approvedTechnologyContract !== null) {
262
288
  knowledge.language = approvedTechnologyContract.language ?? knowledge.language;
263
289
  knowledge.framework =
@@ -330,9 +356,6 @@ export async function handleInitProject(params, server) {
330
356
  if (hourlyRate !== undefined) {
331
357
  await globalStore.updateGlobalConfig({ hourlyRate });
332
358
  }
333
- // SPEC-033: Detect modern architecture patterns and stack info
334
- const stackResult = await detectStackPatterns(projectPath);
335
- const { modernPatterns, dddConfig, agileDetection, orchestratorInfo, composeMultiplatform } = stackResult;
336
359
  // SPEC-568: Smart tool group activation based on detected stack.
337
360
  // Runs before saveKnowledge so the snapshot can be persisted in one write.
338
361
  const groupProfileSnapshot = applyStackBasedGroupActivations(knowledge.stack);
@@ -352,16 +375,54 @@ export async function handleInitProject(params, server) {
352
375
  addProject(projectPath).catch(() => {
353
376
  /* best-effort — never block init_project */
354
377
  });
378
+ // SPEC-1343: buildProjectConfig -> runSpecMigrations must stay ordered (config must be
379
+ // written before migrations run against it, and runSpecMigrations is invoked exactly
380
+ // once per run, AC3). That ordered chain is independent of the read-only discovery
381
+ // batch below, so both run concurrently (AC1/AC4).
355
382
  // SPEC-131: Build legal profile and generate planu.json / What's New
356
383
  // SPEC-313: Pass workMode to persist in planu.json
357
- const configResult = await buildProjectConfig(projectPath, projectId, knowledge, workMode);
384
+ const configAndMigrations = (async () => {
385
+ const configResult = await buildProjectConfig(projectPath, projectId, knowledge, workMode);
386
+ // Run spec migrations (v0.30 -> v0.40, legacy paths, prefix normalization)
387
+ const migrations = await runSpecMigrations(projectPath, projectId, knowledge, {
388
+ repositoryMigration: 'initialization',
389
+ });
390
+ return { configResult, migrations };
391
+ })();
392
+ // SPEC-1343: Independent, best-effort discovery stages (skills fetch, privacy
393
+ // detection, health check, convention scan) — none of these feed each other, so they
394
+ // batch into a single Promise.all with per-stage error isolation. A failing optional
395
+ // stage falls back to a safe default instead of failing init_project (AC2).
396
+ const [recommendedSkillsOutcome, privacyConfigOutcome, healthReportOutcome, conventionScanOutcome,] = await Promise.all([
397
+ runOptionalStage('fetchRecommendedSkills', [], () => fetchRecommendedSkills(knowledge)),
398
+ runOptionalStage('detectProjectPrivacyConfig', buildDefaultPrivacyConfig('GDPR', []), () => detectProjectPrivacyConfig(projectPath)),
399
+ runOptionalStage('runHealthCheckWithBaseline', null, async () => {
400
+ const allSpecs = await listProjectSpecs(projectId);
401
+ const specSummaries = allSpecs.map((s) => ({
402
+ specId: s.id,
403
+ status: s.status,
404
+ gitBranch: s.gitBranch,
405
+ updatedAt: s.updatedAt,
406
+ }));
407
+ return runHealthCheckWithBaseline(projectPath, projectId, knowledge, specSummaries);
408
+ }),
409
+ runOptionalStage('runConventionScanSafe', 'failed', () => runConventionScanSafe(projectPath)),
410
+ ]);
411
+ const recommendedSkills = recommendedSkillsOutcome.value;
412
+ const privacyConfig = privacyConfigOutcome.value;
413
+ const healthReport = healthReportOutcome.value;
414
+ const conventionScanStatus = conventionScanOutcome.value;
415
+ const optionalStageFailures = [
416
+ recommendedSkillsOutcome,
417
+ privacyConfigOutcome,
418
+ healthReportOutcome,
419
+ conventionScanOutcome,
420
+ ]
421
+ .filter((outcome) => outcome.failed)
422
+ .map((outcome) => `${outcome.name}: ${outcome.error ?? 'unknown error'}`);
423
+ const { configResult, migrations } = await configAndMigrations;
358
424
  const { planuConfigPath, planuConfigGenerated, whatsNew } = configResult;
359
- // Run spec migrations (v0.30 -> v0.40, legacy paths, prefix normalization)
360
- const { discoveryResult, migrationResult, folderMigrationResult, criticalMigrationFailures, nonCriticalWarnings: nonCriticalMigrationWarnings, migrationReportPath, changedPaths: migrationChangedPaths = [], } = await runSpecMigrations(projectPath, projectId, knowledge, {
361
- repositoryMigration: 'initialization',
362
- });
363
- // Auto-fetch recommended skills based on detected stack
364
- const recommendedSkills = await fetchRecommendedSkills(knowledge);
425
+ const { discoveryResult, migrationResult, folderMigrationResult, criticalMigrationFailures, nonCriticalWarnings: nonCriticalMigrationWarnings, migrationReportPath, changedPaths: migrationChangedPaths = [], } = migrations;
365
426
  // Auto-install or queue skills (opt-out via planu.json or input param, SPEC-185)
366
427
  const autoInstallFromConfig = autoInstallSkills ?? (await readAutoInstallFlag(planuConfigPath));
367
428
  const { skillsAutoInstalled, skillsPendingInstall, skillsSkipped } = await orchestrateSkillInstalls(recommendedSkills, projectPath, autoInstallFromConfig);
@@ -393,17 +454,6 @@ export async function handleInitProject(params, server) {
393
454
  const beginnerEnvGuide = effectiveExperience === 'beginner' && envVars.length > 0
394
455
  ? buildBeginnerEnvGuide(envVars)
395
456
  : null;
396
- // SPEC-030: Detect legal framework and third-party data processors
397
- const privacyConfig = await detectProjectPrivacyConfig(projectPath);
398
- // SPEC-180: Project health check (best-effort)
399
- const allSpecs = await listProjectSpecs(projectId);
400
- const specSummaries = allSpecs.map((s) => ({
401
- specId: s.id,
402
- status: s.status,
403
- gitBranch: s.gitBranch,
404
- updatedAt: s.updatedAt,
405
- }));
406
- const healthReport = await runHealthCheckWithBaseline(projectPath, projectId, knowledge, specSummaries);
407
457
  // SPEC-491: Auto-cleanup planu/ — remove legacy HTMLs, migrate to lean spec format (fire-and-forget)
408
458
  import('../../engine/spec-migrator/planu-root-cleaner.js')
409
459
  .then(({ cleanPlanuRoot }) => cleanPlanuRoot(join(projectPath, 'planu')))
@@ -414,8 +464,6 @@ export async function handleInitProject(params, server) {
414
464
  void withAudit(projectPath, 'init_project', 'regeneratePages', () => regeneratePages(projectPath, ['knowledge', 'risks', 'decisions']), (pages) => ({ pages: pages.length })).catch(() => {
415
465
  /* best-effort — never fail init */
416
466
  });
417
- // SPEC-228: Deep scan — detect conventions and write planu/conventions.json (best-effort)
418
- const conventionScanStatus = await runConventionScanSafe(projectPath);
419
467
  // SPEC-645: Detect LLM client and cache in conventions.json (fire-and-forget)
420
468
  void import('../../engine/client-detection.js')
421
469
  .then(({ detectAndCacheClient }) => detectAndCacheClient(projectPath))
@@ -480,6 +528,11 @@ export async function handleInitProject(params, server) {
480
528
  }
481
529
  // SPEC-469: Build autopilot summary from setup steps performed
482
530
  const collector = new AutopilotSummaryCollector();
531
+ // SPEC-1343 AC2: surface optional discovery-stage failures in the result summary
532
+ // instead of failing init_project.
533
+ for (const failure of optionalStageFailures) {
534
+ collector.pushFail('discovery-stage-failed', failure);
535
+ }
483
536
  if (knowledge.stack.length > 0) {
484
537
  collector.pushOk('stack-detection', `Stack detected: ${knowledge.stack.join(', ')}`);
485
538
  }
@@ -3,8 +3,8 @@
3
3
  // to pattern-store for future use by estimation, auditing, and spec generation.
4
4
  // SPEC-011: adds dispatchFeedbackEvent for feedback loop dispatch.
5
5
  import { patternStore, knowledgeStore } from '../storage/index.js';
6
+ import { SemanticIndexStore } from '../storage/semantic-index-store.js';
6
7
  import { t, ti } from '../i18n/index.js';
7
- import { TFIDFEngine } from '../engine/vector-store/tfidf.js';
8
8
  import { cosineSimilarity } from '../engine/vector-store/similarity.js';
9
9
  import { compactJson } from './output-formatter.js';
10
10
  /** Default cosine similarity threshold for semantic deduplication. */
@@ -45,7 +45,7 @@ export async function handleLearn(args) {
45
45
  };
46
46
  }
47
47
  // 3. Semantic deduplication check (SPEC-075 AC-08)
48
- const dedupWarning = checkSemanticDuplicate(existingPatterns, pattern, type);
48
+ const dedupWarning = await checkSemanticDuplicate(projectId, existingPatterns, pattern, type);
49
49
  // 4. Create new pattern (store even if semantically similar — just warn)
50
50
  const patternId = `PAT-${type.substring(0, 3).toUpperCase()}-${Date.now().toString(36).toUpperCase()}`;
51
51
  const learnedPattern = {
@@ -93,18 +93,20 @@ export async function handleLearn(args) {
93
93
  /**
94
94
  * SPEC-075 AC-08: Check existing patterns for semantic similarity using TF-IDF.
95
95
  * Returns a DedupWarning if a pattern with cosine similarity > threshold exists.
96
+ * SPEC-1345: the same-type corpus is loaded from a persisted, incrementally
97
+ * updated TF-IDF index instead of being rebuilt from scratch on every call.
96
98
  */
97
- function checkSemanticDuplicate(existingPatterns, newPattern, patternType, threshold = DEFAULT_DEDUP_THRESHOLD) {
99
+ async function checkSemanticDuplicate(projectId, existingPatterns, newPattern, patternType, threshold = DEFAULT_DEDUP_THRESHOLD) {
98
100
  // Filter to same-type patterns only
99
101
  const sameType = existingPatterns.filter((p) => p.type === patternType);
100
102
  if (sameType.length === 0) {
101
103
  return undefined;
102
104
  }
103
- // Build TF-IDF engine from existing patterns + the new one
104
- const tfidf = new TFIDFEngine();
105
- for (const p of sameType) {
106
- tfidf.addDocument(p.pattern);
107
- }
105
+ const indexStore = new SemanticIndexStore(projectId, `pattern-${patternType}`);
106
+ const tfidf = await indexStore.loadOrBuild(sameType.map((p) => ({ id: p.id, content: p.pattern })));
107
+ // Embed the candidate in-memory only (it isn't stored yet, so it never
108
+ // becomes part of the persisted corpus) so the IDF reflects it, matching
109
+ // the previous non-persisted behavior.
108
110
  tfidf.addDocument(newPattern);
109
111
  // Embed the new pattern
110
112
  const newEmbedding = tfidf.embed(newPattern);
@@ -359,18 +359,18 @@ export function registerCoreSpecTools(server) {
359
359
  .describe('Set true when implementation drift requires reconcile_spec before status=done; true blocks done.'),
360
360
  actuals: z
361
361
  .object({
362
- devHours: z.number().min(0),
363
- reviewHours: z.number().min(0),
364
- tokensOpus: z.number().min(0),
365
- tokensSonnet: z.number().min(0),
366
- apiCostUsd: z.number().min(0),
367
- humanCostUsd: z.number().min(0),
368
- totalCostUsd: z.number().min(0),
369
- completedAt: z.string().max(500),
370
- notes: z.string().max(10_000),
362
+ devHours: z.number().min(0).optional(),
363
+ reviewHours: z.number().min(0).optional(),
364
+ tokensOpus: z.number().min(0).optional(),
365
+ tokensSonnet: z.number().min(0).optional(),
366
+ apiCostUsd: z.number().min(0).optional(),
367
+ humanCostUsd: z.number().min(0).optional(),
368
+ totalCostUsd: z.number().min(0).optional(),
369
+ completedAt: z.string().max(500).optional(),
370
+ notes: z.string().max(10_000).optional(),
371
371
  })
372
372
  .optional()
373
- .describe('Actual metrics (required when status = done)'),
373
+ .describe('Actual metrics for status = done. All fields optional (SPEC-1356): missing numeric fields default to 0, completedAt/notes are auto-filled when omitted.'),
374
374
  reviewNotes: z
375
375
  .string()
376
376
  .max(10_000)
@@ -407,7 +407,8 @@ export function registerCoreSpecTools(server) {
407
407
  implementationReviewDigest: z
408
408
  .string()
409
409
  .regex(/^sha256:[a-f0-9]{64}$/u)
410
- .optional(),
410
+ .optional()
411
+ .describe('SHA-256 of the exact validation-report.json bytes. Required implementation evidence for status=done; for status=review it is valid only inside the complete trusted local-MCP reconciliation tuple.'),
411
412
  forceApprove: z
412
413
  .boolean()
413
414
  .optional()
@@ -424,7 +425,7 @@ export function registerCoreSpecTools(server) {
424
425
  .max(4096)
425
426
  .optional()
426
427
  .describe('Absolute project root. Preferred when projectId is unknown.'),
427
- status: SpecStatusEnum.describe('New status for all specs'),
428
+ status: SpecStatusEnum.exclude(['done']).describe('New status for all specs. Batch supports draft, review, approved, implementing, and discarded; close done specs individually with full evidence.'),
428
429
  dryRun: z.boolean().optional().describe('Preview the batch without mutating any spec.'),
429
430
  reviewNotes: z
430
431
  .string()
@@ -22,8 +22,6 @@ export async function handleRegistryLogin(args) {
22
22
  const token = args.token;
23
23
  const user = args.user;
24
24
  const email = args.email;
25
- const rawPlan = args.plan;
26
- const plan = rawPlan ?? 'free';
27
25
  const expiresAt = args.expiresAt;
28
26
  if (!token || !user || !email) {
29
27
  return {
@@ -37,7 +35,6 @@ export async function handleRegistryLogin(args) {
37
35
  token: await storeSecretField('global/registry/pat', 'registry-pat', token),
38
36
  user,
39
37
  email,
40
- plan,
41
38
  expiresAt: expiresAt ?? new Date(Date.now() + TOKEN_EXPIRY_MS).toISOString(),
42
39
  };
43
40
  const credPath = credentialsPath();
@@ -45,12 +42,7 @@ export async function handleRegistryLogin(args) {
45
42
  await chmod(resolveStorageLayout().secrets, 0o700);
46
43
  await writeFile(credPath, JSON.stringify(credentials, null, 2), 'utf-8');
47
44
  await chmod(credPath, 0o600);
48
- return compactResult([
49
- `Done: Logged in successfully`,
50
- `**user**: ${user}`,
51
- `**email**: ${email}`,
52
- `**plan**: ${plan}`,
53
- ].join('\n'));
45
+ return compactResult([`Done: Logged in successfully`, `**user**: ${user}`, `**email**: ${email}`].join('\n'));
54
46
  }
55
47
  catch (error) {
56
48
  /* v8 ignore start -- unexpected runtime errors */
@@ -100,7 +92,6 @@ export async function handleRegistryWhoami(_args) {
100
92
  const info = {
101
93
  user: cred.user,
102
94
  email: cred.email,
103
- plan: cred.plan,
104
95
  expiresAt: cred.expiresAt,
105
96
  expired: isExpired,
106
97
  authenticated: !isExpired,
@@ -108,7 +99,6 @@ export async function handleRegistryWhoami(_args) {
108
99
  return compactResult([
109
100
  `**user**: ${info.user}`,
110
101
  `**email**: ${info.email}`,
111
- `**plan**: ${info.plan}`,
112
102
  `**expiresAt**: ${info.expiresAt}`,
113
103
  `**expired**: ${String(info.expired)}`,
114
104
  `**authenticated**: ${String(info.authenticated)}`,
@@ -1,7 +1,7 @@
1
1
  // tools/semantic-search-handler.ts — Handler for semantic_search tool.
2
2
  // SPEC-075 AC-07: Generates TF-IDF embedding of query, searches backend.
3
3
  import { getOrCreateBackend } from '../storage/vector-store/backend-factory.js';
4
- import { TFIDFEngine } from '../engine/vector-store/tfidf.js';
4
+ import { SemanticIndexStore } from '../storage/semantic-index-store.js';
5
5
  /**
6
6
  * Handle a semantic_search request.
7
7
  * Embeds the query with TF-IDF, then searches the vector backend.
@@ -33,11 +33,10 @@ export async function handleSemanticSearch(args) {
33
33
  ],
34
34
  };
35
35
  }
36
- // Build TF-IDF from existing corpus
37
- const tfidf = new TFIDFEngine();
38
- for (const doc of allDocs) {
39
- tfidf.addDocument(doc.content);
40
- }
36
+ // SPEC-1345: load the persisted TF-IDF index for this scope, incrementally
37
+ // reconciled against the current corpus (no full rebuild for unchanged docs).
38
+ const indexStore = new SemanticIndexStore(projectId, scope);
39
+ const tfidf = await indexStore.loadOrBuild(allDocs.map((doc) => ({ id: doc.id, content: doc.content })));
41
40
  // Embed the query
42
41
  const queryEmbedding = tfidf.embed(query);
43
42
  // Search
@@ -79,18 +79,28 @@ function parseSpecNumber(id) {
79
79
  const match = /SPEC-(\d+)/i.exec(id);
80
80
  return match?.[1] !== undefined ? parseInt(match[1], 10) : 0;
81
81
  }
82
+ function selectActiveSpec(specs) {
83
+ // Lifecycle-first ordering: implementing always outranks review, regardless of SPEC number.
84
+ // Within the same lifecycle status, the tie-break is descending SPEC number (most recent first).
85
+ for (const status of ['implementing', 'review']) {
86
+ const candidates = specs
87
+ .filter((s) => s.status === status)
88
+ .sort((a, b) => parseSpecNumber(b.id) - parseSpecNumber(a.id));
89
+ const candidate = candidates[0];
90
+ if (candidate !== undefined) {
91
+ return { id: candidate.id, title: candidate.title, status };
92
+ }
93
+ }
94
+ return null;
95
+ }
82
96
  async function getStatusSpecSnapshot(projectId) {
83
97
  try {
84
98
  const specs = await listSpecs(projectId);
85
- const inProgress = specs
86
- .filter((s) => s.status === 'implementing' || s.status === 'review')
87
- .sort((a, b) => parseSpecNumber(b.id) - parseSpecNumber(a.id));
99
+ const active = selectActiveSpec(specs.filter((s) => s.status === 'implementing' || s.status === 'review'));
88
100
  const approved = specs
89
101
  .filter((s) => s.status === 'approved')
90
102
  .sort((a, b) => parseSpecNumber(a.id) - parseSpecNumber(b.id));
91
- const activeItem = inProgress[0];
92
103
  const nextItem = approved[0];
93
- const active = activeItem !== undefined ? { id: activeItem.id, title: activeItem.title } : null;
94
104
  const next = nextItem !== undefined ? { id: nextItem.id, title: nextItem.title } : null;
95
105
  return { active, next, queueCount: approved.length };
96
106
  }
@@ -130,6 +140,9 @@ async function getSLABreaches(projectId) {
130
140
  // ---------------------------------------------------------------------------
131
141
  function buildSuggestion(snapshot) {
132
142
  if (snapshot.active !== null) {
143
+ if (snapshot.active.status === 'review') {
144
+ return `Resolve review for ${snapshot.active.id} — it is not yet implementing`;
145
+ }
133
146
  return `Continue ${snapshot.active.id} — it may be blocking others`;
134
147
  }
135
148
  if (snapshot.next !== null) {
@@ -145,7 +158,7 @@ function buildOutput(params) {
145
158
  const lines = ['Planu Status', '━━━━━━━━━━━━━━━'];
146
159
  if (snapshot.active !== null) {
147
160
  const title = snapshot.active.title.slice(0, 40);
148
- lines.push(`ACTIVE ${snapshot.active.id} ${title} (in-progress)`);
161
+ lines.push(`ACTIVE ${snapshot.active.id} ${title} (${snapshot.active.status})`);
149
162
  }
150
163
  else {
151
164
  lines.push(`ACTIVE (none)`);
@@ -12,7 +12,7 @@ import { parseFrontmatter } from '../engine/frontmatter-parser.js';
12
12
  import { verifyTerminalFrontmatter } from '../engine/frontmatter-sha/index.js';
13
13
  import { appendTransitionEvent } from '../storage/transition-log.js';
14
14
  import { cleanEphemeralArtifacts } from '../engine/housekeeping/index.js';
15
- import { readFile } from 'node:fs/promises';
15
+ import { readFile, stat } from 'node:fs/promises';
16
16
  // ---------------------------------------------------------------------------
17
17
  // Sync helpers (exported for startup use)
18
18
  // ---------------------------------------------------------------------------
@@ -33,9 +33,15 @@ export async function syncSpecState(projectPath, projectId) {
33
33
  divergences: [],
34
34
  errors: [`Failed to load specs for ${projectId}: ${msg}`],
35
35
  rejected: [],
36
+ ghosts: [],
36
37
  };
37
38
  }
39
+ // SPEC-1351 (AC3): quarantine store entries whose spec.md is missing on
40
+ // disk — ghost registrations left behind by a create_spec attempt that
41
+ // reported success but never persisted (or whose file was later deleted).
42
+ const ghosts = await detectGhostSpecs(projectId, dataEntries);
38
43
  const { updates, report } = await detectSpecStateDivergences(projectPath, dataEntries);
44
+ report.ghosts = ghosts;
39
45
  // SyncReport.rejected is always defined (SPEC-720)
40
46
  const TERMINAL_STATUSES = ['done', 'discarded'];
41
47
  for (const { specId, newStatus } of updates) {
@@ -136,6 +142,41 @@ export async function syncSpecState(projectPath, projectId) {
136
142
  }
137
143
  return report;
138
144
  }
145
+ /**
146
+ * SPEC-1351 (AC3): scan the data store for entries whose spec.md is missing on
147
+ * disk and quarantine each one — appends a `ghost_spec_quarantined` transition
148
+ * event (surfaced by workspace_alerts with a recovery action) and returns the
149
+ * list so callers can report it inline.
150
+ */
151
+ async function detectGhostSpecs(projectId, dataEntries) {
152
+ const ghosts = [];
153
+ await Promise.all(dataEntries.map(async (entry) => {
154
+ if (!entry.specPath) {
155
+ return;
156
+ }
157
+ try {
158
+ const fileStat = await stat(entry.specPath);
159
+ if (fileStat.size > 0) {
160
+ return;
161
+ }
162
+ }
163
+ catch {
164
+ // fall through to quarantine — missing or unreadable spec.md
165
+ }
166
+ const reason = `Store entry ${entry.id} references spec.md at ${entry.specPath}, which is missing or empty on disk. Recreate the spec via create_spec or restore spec.md from git/backup.`;
167
+ ghosts.push({ specId: entry.id, specPath: entry.specPath, reason });
168
+ void appendTransitionEvent({
169
+ projectId,
170
+ specId: entry.id,
171
+ eventType: 'ghost_spec_quarantined',
172
+ actor: 'sync_spec_state',
173
+ reason,
174
+ }).catch(() => {
175
+ /* best-effort */
176
+ });
177
+ }));
178
+ return ghosts;
179
+ }
139
180
  /**
140
181
  * Startup sync: iterates all registered projects and calls syncSpecState for each.
141
182
  * Non-blocking — errors are logged but not propagated.
@@ -183,6 +224,13 @@ function formatReport(report, label) {
183
224
  lines.push(` - ${r.specId}: ${r.reason}`);
184
225
  }
185
226
  }
227
+ // SPEC-1351: Ghost store entries quarantined (missing spec.md on disk)
228
+ if ((report.ghosts?.length ?? 0) > 0) {
229
+ lines.push(' Quarantined ghosts (spec.md missing — recreate or restore):');
230
+ for (const g of report.ghosts ?? []) {
231
+ lines.push(` - ${g.specId}: ${g.specPath}`);
232
+ }
233
+ }
186
234
  if (report.errors.length > 0) {
187
235
  lines.push(' Errors:');
188
236
  for (const e of report.errors) {
@@ -1,3 +1,7 @@
1
- import type { ToolResult, UpdateStatusBatchInput } from '../../types/index.js';
2
- export declare function handleUpdateStatusBatch(input: UpdateStatusBatchInput): Promise<ToolResult>;
1
+ import type { SpecStatus, ToolResult, UpdateStatusBatchInput } from '../../types/index.js';
2
+ type UncheckedUpdateStatusBatchInput = Omit<UpdateStatusBatchInput, 'status'> & {
3
+ status: SpecStatus;
4
+ };
5
+ export declare function handleUpdateStatusBatch(input: UncheckedUpdateStatusBatchInput): Promise<ToolResult>;
6
+ export {};
3
7
  //# sourceMappingURL=batch.d.ts.map
@@ -6,7 +6,60 @@ function firstText(result) {
6
6
  const first = result.content[0];
7
7
  return first?.type === 'text' ? first.text : JSON.stringify(result.structuredContent ?? {});
8
8
  }
9
+ /** SPEC-1349: extract the gate failure code + actionable next step from a per-spec transition result. */
10
+ function failureDetails(result) {
11
+ const structured = result.structuredContent;
12
+ const code = typeof structured?.error === 'string' ? structured.error : undefined;
13
+ const nextAction = typeof structured?.fixHint === 'string' ? structured.fixHint : undefined;
14
+ return { code, nextAction };
15
+ }
16
+ const MAX_NARRATIVE_FAILURE_ROWS = 5;
17
+ /** SPEC-1349: render up to MAX_NARRATIVE_FAILURE_ROWS per-spec reasons, capping token cost. */
18
+ function renderFailureNarrative(failed) {
19
+ if (failed.length === 0) {
20
+ return [];
21
+ }
22
+ const shown = failed.slice(0, MAX_NARRATIVE_FAILURE_ROWS);
23
+ const remaining = failed.length - shown.length;
24
+ const lines = ['Failed specs:', ...shown.map((row) => `- ${row.specId}: ${row.message}`)];
25
+ if (remaining > 0) {
26
+ const dominantCode = shown[0]?.code;
27
+ const sameCause = dominantCode !== undefined && failed.every((row) => row.code === dominantCode);
28
+ lines.push(sameCause
29
+ ? `...and ${String(remaining)} more with the same cause.`
30
+ : `...and ${String(remaining)} more (see structuredContent.failed for details).`);
31
+ }
32
+ return lines;
33
+ }
34
+ /** SPEC-1349: when every failure shares one gate cause, surface a single aggregated next step. */
35
+ function dominantFailureNextAction(failed) {
36
+ if (failed.length === 0) {
37
+ return undefined;
38
+ }
39
+ const firstCode = failed[0]?.code;
40
+ if (firstCode === undefined) {
41
+ return undefined;
42
+ }
43
+ const allSameCause = failed.every((row) => row.code === firstCode);
44
+ if (!allSameCause) {
45
+ return undefined;
46
+ }
47
+ return failed[0]?.nextAction;
48
+ }
9
49
  export async function handleUpdateStatusBatch(input) {
50
+ if (input.status === 'done') {
51
+ const fixHint = 'Close each spec individually with update_status(done) and its full implementation review evidence.';
52
+ return {
53
+ content: [
54
+ {
55
+ type: 'text',
56
+ text: `BATCH_DONE_UNSUPPORTED: Batch done transitions are not supported.\nFix: ${fixHint}`,
57
+ },
58
+ ],
59
+ isError: true,
60
+ structuredContent: { error: 'BATCH_DONE_UNSUPPORTED', code: 422, fixHint },
61
+ };
62
+ }
10
63
  const uniqueSpecIds = [...new Set(input.specIds)].sort();
11
64
  if (uniqueSpecIds.length === 0) {
12
65
  return {
@@ -57,7 +110,7 @@ export async function handleUpdateStatusBatch(input) {
57
110
  skipped.push({ specId, message });
58
111
  }
59
112
  else {
60
- failed.push({ specId, message });
113
+ failed.push({ specId, message, ...failureDetails(result) });
61
114
  }
62
115
  }
63
116
  else {
@@ -81,11 +134,14 @@ export async function handleUpdateStatusBatch(input) {
81
134
  /* reliability-cleanup: POST_BATCH_STRICT_CLEANUP — validation remains fail-closed */
82
135
  reportClassifiedDegradation('POST_BATCH_STRICT_CLEANUP', error);
83
136
  }
137
+ const aggregatedNextAction = dominantFailureNextAction(failed);
84
138
  const text = [
85
139
  `Batch status transition complete: ${input.status}`,
86
140
  `Updated: ${updated.length}`,
87
141
  `Skipped: ${skipped.length}`,
88
142
  `Failed: ${failed.length}`,
143
+ ...renderFailureNarrative(failed),
144
+ ...(aggregatedNextAction ? [`Next action: ${aggregatedNextAction}`] : []),
89
145
  ].join('\n');
90
146
  return {
91
147
  content: [{ type: 'text', text }],
@@ -95,6 +151,7 @@ export async function handleUpdateStatusBatch(input) {
95
151
  skipped,
96
152
  failed,
97
153
  sideEffectsFlushed: ['strict-planu-cleanup'],
154
+ ...(aggregatedNextAction ? { nextAction: aggregatedNextAction } : {}),
98
155
  },
99
156
  };
100
157
  }
@@ -83,6 +83,21 @@ export interface DoneGateResult {
83
83
  forcedBypassWarning: string | null;
84
84
  freshnessLease: ValidationFreshnessLease | null;
85
85
  }
86
+ /**
87
+ * SPEC-1356 (T-A1): auto-derive the sha256 digest of the persisted
88
+ * `validation-report.json` so callers no longer have to compute it by hand.
89
+ * Reads and hashes the exact same bytes `checkDoneReviewDigest` would validate
90
+ * against, using the same hardened path/identity checks. Returns the digest, or
91
+ * the exact blocking `ToolResult` when the report cannot be safely read (e.g.
92
+ * validate has not run yet). Exported for direct unit testing and for the
93
+ * orchestration layer (`update-status/index.ts`) that no longer requires the
94
+ * caller to supply `implementationReviewDigest`.
95
+ */
96
+ export declare function deriveDoneReviewDigest(specId: string, projectId: string): Promise<{
97
+ digest: string;
98
+ } | {
99
+ error: ToolResult;
100
+ }>;
86
101
  /**
87
102
  * SPEC-725: Check the validation-report artifact gate before marking done.
88
103
  * SPEC-1050: Fail closed. A done transition requires a fresh validation-report
@@ -99,7 +114,7 @@ export declare function checkSpecReviewGate(specId: string, projectId: string, _
99
114
  * When force=true, gates are not blocking but failing items are recorded in
100
115
  * the project's force-bypass-log.json for traceability (guard: done ≠ implemented).
101
116
  */
102
- export declare function checkDoneGates(spec: Spec, specId: string, projectId: string, projectPath: string | undefined, _force: boolean | undefined, _forceReason?: string, verifyReceipt?: DoneReceiptVerifier): Promise<DoneGateResult>;
117
+ export declare function checkDoneGates(spec: Spec, specId: string, projectId: string, projectPath: string | undefined, _force: boolean | undefined, _forceReason?: string, verifyReceipt?: DoneReceiptVerifier, implementationReviewDigest?: string): Promise<DoneGateResult>;
103
118
  export { checkQaGate } from './qa-gate.js';
104
119
  export { checkApprovedFormatGate, checkComplianceGate, type ComplianceGateResult, } from './approval-gates.js';
105
120
  /**