@planu/cli 5.0.0 → 5.1.1

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 (73) hide show
  1. package/CHANGELOG.md +49 -1
  2. package/dist/cli/commands/spec.js +10 -1
  3. package/dist/core/spec-validator.js +32 -18
  4. package/dist/engine/evidence-gates/artifact-reader.d.ts +2 -0
  5. package/dist/engine/evidence-gates/artifact-reader.js +59 -2
  6. package/dist/engine/evidence-gates/evidence-autofill.d.ts +10 -0
  7. package/dist/engine/evidence-gates/evidence-autofill.js +148 -0
  8. package/dist/engine/evidence-gates/evidence-skeletons.d.ts +19 -0
  9. package/dist/engine/evidence-gates/evidence-skeletons.js +69 -0
  10. package/dist/engine/execution/operation-journal.js +10 -4
  11. package/dist/engine/minimality/policy-loader.js +247 -6
  12. package/dist/engine/planu-core.darwin-arm64.node.manifest.json +7 -7
  13. package/dist/engine/planu-core.darwin-arm64.node.sbom.json +14 -14
  14. package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
  15. package/dist/engine/planu-core.darwin-x64.node.sbom.json +14 -14
  16. package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
  17. package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +14 -14
  18. package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
  19. package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +14 -14
  20. package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
  21. package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +14 -14
  22. package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
  23. package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +14 -14
  24. package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
  25. package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +14 -14
  26. package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
  27. package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +14 -14
  28. package/dist/engine/reverse-engineer/api-detector.js +2 -13
  29. package/dist/engine/reverse-engineer/complexity-analyzer.js +2 -13
  30. package/dist/engine/reverse-engineer/config-analyzer.js +2 -13
  31. package/dist/engine/reverse-engineer/dependency-graph.js +2 -13
  32. package/dist/engine/reverse-engineer/test-analyzer.js +2 -13
  33. package/dist/engine/reverse-engineer/walk-ignore.d.ts +3 -0
  34. package/dist/engine/reverse-engineer/walk-ignore.js +26 -0
  35. package/dist/engine/spec-format/acceptance-criteria.js +13 -12
  36. package/dist/engine/spec-format/text-fences.js +20 -2
  37. package/dist/engine/spec-state-syncer.js +1 -1
  38. package/dist/engine/timing/budget.js +5 -1
  39. package/dist/server/routes/specs.js +7 -5
  40. package/dist/tools/challenge-spec-helpers.d.ts +10 -1
  41. package/dist/tools/challenge-spec-helpers.js +63 -22
  42. package/dist/tools/challenge-spec.js +18 -3
  43. package/dist/tools/check-readiness.js +37 -13
  44. package/dist/tools/create-spec/spec-builder.d.ts +7 -0
  45. package/dist/tools/create-spec/spec-builder.js +19 -4
  46. package/dist/tools/create-spec.js +156 -86
  47. package/dist/tools/register-spec-tools/core-spec-tools.js +13 -12
  48. package/dist/tools/sync-spec-state-handler.js +49 -1
  49. package/dist/tools/update-status/batch.d.ts +6 -2
  50. package/dist/tools/update-status/batch.js +58 -1
  51. package/dist/tools/update-status/dod-gates.d.ts +16 -1
  52. package/dist/tools/update-status/dod-gates.js +191 -1
  53. package/dist/tools/update-status/done-receipt-verifier.d.ts +8 -0
  54. package/dist/tools/update-status/done-receipt-verifier.js +37 -2
  55. package/dist/tools/update-status/evidence-gate.d.ts +4 -0
  56. package/dist/tools/update-status/evidence-gate.js +67 -2
  57. package/dist/tools/update-status/file-sync.d.ts +2 -2
  58. package/dist/tools/update-status/index.d.ts +23 -1
  59. package/dist/tools/update-status/index.js +201 -24
  60. package/dist/tools/update-status/transition-guard.js +13 -1
  61. package/dist/tools/workspace-dashboard-handler.js +38 -0
  62. package/dist/types/evidence-autofill.d.ts +34 -0
  63. package/dist/types/evidence-autofill.js +2 -0
  64. package/dist/types/index.d.ts +1 -0
  65. package/dist/types/index.js +1 -0
  66. package/dist/types/spec/core.d.ts +6 -0
  67. package/dist/types/spec/inputs.d.ts +7 -2
  68. package/dist/types/spec-format.d.ts +1 -1
  69. package/dist/types/transition-log.d.ts +1 -1
  70. package/dist/types/validation.d.ts +8 -2
  71. package/package.json +11 -10
  72. package/planu-native.json +1 -1
  73. package/planu-plugin.json +1 -1
@@ -1,7 +1,5 @@
1
1
  // tools/check-readiness.ts — Completeness checkpoint tool (SPEC-039, SPEC-314, SPEC-716)
2
2
  import { specStore } from '../storage/index.js';
3
- import { checkSpecReadiness } from '../engine/readiness-checker.js';
4
- import { scoreSpecQuality } from '../engine/spec-quality-scorer.js';
5
3
  import { buildCheckReadinessSummary } from '../engine/human-summary.js';
6
4
  import { validateSpecFormat } from '../core/spec-validator.js';
7
5
  import { resolveProjectId } from './resolve-project-id.js';
@@ -9,6 +7,7 @@ import { resolveProjectId } from './resolve-project-id.js';
9
7
  const MAX_VISIBLE_BLOCKERS = 8;
10
8
  const MAX_VISIBLE_WARNINGS = 8;
11
9
  const MAX_VISIBLE_RECOMMENDATIONS = 5;
10
+ const CANONICAL_CRITERIA_MISSING = 'CANONICAL_CRITERIA_MISSING';
12
11
  function formatScore(score) {
13
12
  if (score >= 90) {
14
13
  return `${score}/100 (Excellent)`;
@@ -21,6 +20,23 @@ function formatScore(score) {
21
20
  }
22
21
  return `${score}/100 (Not Ready)`;
23
22
  }
23
+ function canonicalReadinessFailure(validationResult) {
24
+ const metrics = validationResult.metrics;
25
+ if (metrics === undefined || metrics.criteriaCount === 0) {
26
+ return CANONICAL_CRITERIA_MISSING;
27
+ }
28
+ return null;
29
+ }
30
+ function createFailClosedReport(specId, mode) {
31
+ return {
32
+ specId,
33
+ score: 0,
34
+ ready: false,
35
+ mode,
36
+ issues: { score: 0, breakdown: {}, blockers: [], warnings: [] },
37
+ recommendations: [],
38
+ };
39
+ }
24
40
  function formatReport(report) {
25
41
  const lines = [];
26
42
  lines.push(`# Readiness Report — ${report.specId}`);
@@ -109,20 +125,15 @@ export async function handleCheckReadiness(args) {
109
125
  isError: true,
110
126
  };
111
127
  }
112
- // SPEC-716: Ground yes/no decision in validateSpecFormat.
113
- // The existing checkSpecReadiness call and formatReport are preserved for
114
- // the rich human-readable output. validateSpecFormat drives the yes/no gate
115
- // and its errors/warnings are surfaced in structuredContent.validationResult.
116
- const [report, qualityReport, validationResult] = await Promise.all([
117
- checkSpecReadiness(spec, mode),
118
- scoreSpecQuality(spec),
119
- validateSpecFormat(spec, { readinessMode: mode, skipQuality: true }),
120
- ]);
128
+ // validateSpecFormat owns the readiness and quality evaluation so the response
129
+ // cannot combine separately computed evidence from different reads of spec.md.
130
+ const validationResult = await validateSpecFormat(spec, { readinessMode: mode });
121
131
  // SPEC-716: Ground ready flag in unified SpecValidationResult.
122
132
  // Only non-file-access errors from validateSpecFormat are surfaced in the
123
133
  // report — MISSING_FRONTMATTER due to file-not-found is best-effort since
124
134
  // the readiness checker already handles missing files gracefully.
125
135
  const vr = validationResult;
136
+ const report = vr.readinessReport ?? createFailClosedReport(spec.id, mode);
126
137
  const hardFormatErrors = vr.errors.filter((e) => e.code !== 'MISSING_FRONTMATTER' && !(e.code === 'READINESS_CHECK_FAILED'));
127
138
  const hardFormatMessages = hardFormatErrors.map((e) => e.message);
128
139
  const formatWarnings = vr.warnings
@@ -142,11 +153,24 @@ export async function handleCheckReadiness(args) {
142
153
  // Ground ready: only false if BOTH readiness report and format gate agree
143
154
  // (or format has hard non-file errors)
144
155
  report.ready = report.ready && hardFormatErrors.length === 0;
156
+ const canonicalFailure = canonicalReadinessFailure(vr);
157
+ if (canonicalFailure !== null) {
158
+ const target = mode === 'strict' ? report.issues.blockers : report.issues.warnings;
159
+ if (!target.includes(canonicalFailure)) {
160
+ target.push(canonicalFailure);
161
+ }
162
+ if (!report.recommendations.includes(`Resolve ${canonicalFailure} before implementation.`)) {
163
+ report.recommendations.push(`Resolve ${canonicalFailure} before implementation.`);
164
+ }
165
+ // Lenient mode is advisory, but it must not present a contradictory
166
+ // canonical validation result as an implementation-ready pass.
167
+ report.ready = false;
168
+ }
145
169
  // SPEC-612: recommend adding outOfScope items when none are declared
146
170
  if (spec.outOfScope === undefined || spec.outOfScope.length === 0) {
147
171
  report.recommendations.push('Add outOfScope items to the spec frontmatter to declare explicit scope boundaries (SPEC-612). This reduces scope creep during implementation.');
148
172
  }
149
- const qualityScore = qualityReport.score;
173
+ const qualityScore = vr.qualityReport?.score;
150
174
  const formatted = formatReport(report);
151
175
  const humanSummary = buildCheckReadinessSummary(report.score, report.issues.blockers.length);
152
176
  return {
@@ -161,7 +185,7 @@ export async function handleCheckReadiness(args) {
161
185
  mode: report.mode,
162
186
  issues: report.issues,
163
187
  recommendations: report.recommendations,
164
- qualityScore,
188
+ ...(qualityScore === undefined ? {} : { qualityScore }),
165
189
  humanSummary,
166
190
  validationResult: vr,
167
191
  },
@@ -13,4 +13,11 @@ export declare function buildSpecContext(params: CreateSpecInput): Promise<{
13
13
  export declare function buildSplitResult(splitSuggestion: ReturnType<typeof analyzeSplit>, experienceLevel: string | undefined): Record<string, unknown> | undefined;
14
14
  /** Infer meaningful tags from title and description when no tags were provided. */
15
15
  export declare function inferTagsFromContent(title: string, description: string, type?: string): string[];
16
+ /**
17
+ * SPEC-1351: Recompute the next spec id from a fresh store + filesystem scan.
18
+ * Called once (unlocked) during prepare to pick a candidate id, and again
19
+ * inside the exclusive create-spec write lock immediately before persisting
20
+ * so the id actually written can never collide under concurrency.
21
+ */
22
+ export declare function computeNextSpecId(projectPath: string, projectId: string): Promise<string>;
16
23
  //# sourceMappingURL=spec-builder.d.ts.map
@@ -23,10 +23,13 @@ export async function buildSpecContext(params) {
23
23
  const projectId = hashProjectPath(projectPath);
24
24
  const knowledge = await knowledgeStore.getKnowledge(projectId);
25
25
  const existingSpecs = await specStore.listSpecs(projectId);
26
- // Generate spec ID: combine store IDs + filesystem IDs for global max
27
- const fsSpecIds = await scanFilesystemSpecIds(projectPath);
28
- const allSpecs = [...existingSpecs, ...fsSpecIds.map((id) => ({ id }))];
29
- const specId = generateSpecId(allSpecs);
26
+ // Generate spec ID: combine store IDs + filesystem IDs for global max.
27
+ // SPEC-1351: this snapshot read is NOT locked — under concurrency two callers
28
+ // can compute the same "next" id here. create-spec.ts re-derives the id via
29
+ // computeNextSpecId() again inside its exclusive per-project write lock right
30
+ // before persisting, so a stale id computed here is only ever a starting
31
+ // candidate, never the value that actually gets written to disk.
32
+ const specId = await computeNextSpecId(projectPath, projectId);
30
33
  const slug = slugify(title);
31
34
  // Check for duplicate title
32
35
  const duplicate = existingSpecs.find((s) => slugify(s.title) === slug);
@@ -209,6 +212,18 @@ function buildTags(tags, feature, _title, _description, _type) {
209
212
  const result = feature ? [...new Set([...explicitTags, slugify(feature)])] : explicitTags;
210
213
  return [...new Set(result)];
211
214
  }
215
+ /**
216
+ * SPEC-1351: Recompute the next spec id from a fresh store + filesystem scan.
217
+ * Called once (unlocked) during prepare to pick a candidate id, and again
218
+ * inside the exclusive create-spec write lock immediately before persisting
219
+ * so the id actually written can never collide under concurrency.
220
+ */
221
+ export async function computeNextSpecId(projectPath, projectId) {
222
+ const existingSpecs = await specStore.listSpecs(projectId);
223
+ const fsSpecIds = await scanFilesystemSpecIds(projectPath);
224
+ const allSpecs = [...existingSpecs, ...fsSpecIds.map((id) => ({ id }))];
225
+ return generateSpecId(allSpecs);
226
+ }
212
227
  /* v8 ignore start -- filesystem scanning is best-effort, tested via integration */
213
228
  /** Scan filesystem for SPEC-XXX directories to find max ID globally. */
214
229
  async function scanFilesystemSpecIds(projectPath) {
@@ -6,8 +6,10 @@ import { readTechnologySelectionContract } from '../storage/technology-selection
6
6
  import { toolResult, interactiveResult } from './response-helpers.js';
7
7
  import { readFile, stat as fsStat } from 'node:fs/promises';
8
8
  import { createHash, randomUUID } from 'node:crypto';
9
- import { dirname as pathDirname, isAbsolute as pathIsAbsolute, join as pathJoin, relative as pathRelative, sep as pathSeparator, } from 'node:path';
10
- import { buildSpecContext, buildSplitResult } from './create-spec/spec-builder.js';
9
+ import { basename as pathBasename, dirname as pathDirname, isAbsolute as pathIsAbsolute, join as pathJoin, relative as pathRelative, sep as pathSeparator, } from 'node:path';
10
+ import { withFileLock } from '../storage/file-mutex.js';
11
+ import { generateBranchName } from './create-spec-helpers.js';
12
+ import { buildSpecContext, buildSplitResult, computeNextSpecId, } from './create-spec/spec-builder.js';
11
13
  import { validateConstitution } from './create-spec/constitution-validator.js';
12
14
  import { getAsyncAnalysisPath } from './create-spec/post-creation.js';
13
15
  import { extractCriteria, generateLeanSpecContent, } from '../engine/spec-format/lean-spec-generator.js';
@@ -284,6 +286,19 @@ function finishRecoveredOperation(journal, key, result) {
284
286
  }
285
287
  return entry?.result ?? result;
286
288
  }
289
+ /**
290
+ * SPEC-1348: release the journal claim of an attempt that failed before commit.
291
+ * Rolling back marks the key re-claimable; committed entries are left untouched
292
+ * because recovery resolution only acts on active (intent/prepared) states.
293
+ */
294
+ function rollbackUncommittedOperation(journal, key, reason) {
295
+ try {
296
+ journal.resolveRecovery('create_spec', key, { action: 'rollback', reason });
297
+ }
298
+ catch {
299
+ // Best-effort: the original failure remains the primary error.
300
+ }
301
+ }
287
302
  function buildCommittedCreateResult(input) {
288
303
  const advisorySignals = [];
289
304
  if (input.advisoryCriteria.length > 0) {
@@ -1253,7 +1268,15 @@ export async function handleCreateSpec(inputParams, server) {
1253
1268
  });
1254
1269
  // SPEC-770: Idempotency check — return existing spec if same title+projectPath within 10 minutes
1255
1270
  const operationKey = computeOperationKey(resolvedInputParams, resolvedPath, idempotencyKey);
1256
- const runtimeDatabase = new RuntimeDatabase({ path: resolveStorageLayout().runtimeDatabase });
1271
+ // SPEC-1351: create_spec holds its RuntimeDatabase connection for the whole
1272
+ // operation (idempotency journal + recovery payload). The default cap (4) is
1273
+ // exhausted by a handful of concurrent create_spec calls in one process,
1274
+ // which otherwise fail with RuntimeDatabaseResourceExhaustedError before any
1275
+ // persistence work starts. Raise the ceiling for this call site only.
1276
+ const runtimeDatabase = new RuntimeDatabase({
1277
+ path: resolveStorageLayout().runtimeDatabase,
1278
+ maxConnections: 16,
1279
+ });
1257
1280
  const operationJournal = new OperationJournal(runtimeDatabase, createHash('sha256').update(hashProjectPath(resolvedPath)).digest('hex').slice(0, 32));
1258
1281
  let journalEntry;
1259
1282
  try {
@@ -1378,95 +1401,138 @@ export async function handleCreateSpec(inputParams, server) {
1378
1401
  // Only the steps required to produce spec.md on disk are inside this budget.
1379
1402
  // Durable post-commit work is delegated to the spec.created outbox consumer.
1380
1403
  const criticalResult = await withTotalBudget('create_spec-critical', getRuntimePolicy().createSpec.criticalTimeoutMs, async (criticalSignal) => {
1381
- const { params, context, knowledge, clarificationSession, constitutionWarnings, advisoryCriteria, actionableMetrics, unifiedSpec, simplicityResult: committedSimplicity, } = prepared;
1382
- const { spec, specDir, specPath, splitSuggestion, duplicate, projectId } = context;
1383
- let committedPlannerToken;
1384
- try {
1385
- committedPlannerToken = await issuePlannerToken(resolvedPath, spec.id, params.sessionId ?? 'unknown-session', params.modelId ?? 'unknown-model', params.host ?? 'unknown-host');
1386
- }
1387
- catch {
1388
- // Best-effort evidence must not block durable spec creation.
1389
- }
1390
- const committedResult = buildCommittedCreateResult({
1391
- spec,
1392
- operationId: operationKey,
1393
- duplicate,
1394
- constitutionWarnings,
1395
- clarificationSession,
1396
- advisoryCriteria,
1397
- actionableMetrics,
1398
- splitSuggestion,
1399
- experienceLevel: knowledge?.experienceLevel,
1400
- contradictionHint: undefined,
1401
- simplicityResult: committedSimplicity,
1402
- plannerToken: committedPlannerToken,
1403
- });
1404
- let storeCreated = false;
1405
- try {
1406
- assertExecutionCanCommit(criticalSignal);
1407
- operationJournal.setRecoveryPayload('create_spec', operationKey, 1, {
1408
- operationVersion: 1,
1409
- projectPath: resolvedPath,
1410
- idempotencyKey,
1411
- ownerId: idempotencyClaim.ownerId,
1412
- projectId,
1413
- specId: spec.id,
1414
- specPath,
1415
- specContentDigest: createHash('sha256').update(unifiedSpec).digest('hex'),
1416
- result: committedResult,
1417
- });
1418
- operationJournal.prepared('create_spec', operationKey);
1419
- await measureStep('mkdir-specDir', () => executionMkdir(specDir, { recursive: true }));
1420
- // SPEC-713: measure file write — this is the critical persistence step.
1421
- assertExecutionCanCommit(criticalSignal);
1422
- await measureStep('writeFile-specPath', () => atomicWriteFile(specPath, unifiedSpec, { encoding: 'utf-8' }));
1423
- assertExecutionCanCommit(criticalSignal);
1424
- await measureStep('specStore-createSpec', () => specStore.createSpec(projectId, spec));
1425
- storeCreated = true;
1426
- assertExecutionCanCommit(criticalSignal);
1427
- await measureStep('commit-idempotency-evidence', () => commitIdempotencyEvidence(resolvedPath, idempotencyKey, idempotencyClaim, spec, specPath));
1428
- operationJournal.commitWithOutbox('create_spec', operationKey, committedResult, {
1429
- topic: 'spec.created',
1430
- payload: {
1431
- schemaVersion: 1,
1432
- specId: spec.id,
1433
- projectId,
1434
- projectPath: resolvedPath,
1435
- postCommitTasks: SPEC_CREATED_POST_COMMIT_TASKS,
1436
- },
1404
+ const { params, context, knowledge, clarificationSession, constitutionWarnings, advisoryCriteria, actionableMetrics, simplicityResult: committedSimplicity, } = prepared;
1405
+ const { splitSuggestion, duplicate, projectId } = context;
1406
+ let spec = context.spec;
1407
+ let specDir = context.specDir;
1408
+ let specPath = context.specPath;
1409
+ let unifiedSpec = prepared.unifiedSpec;
1410
+ // SPEC-1351: serialize the id re-derivation + persistence sequence
1411
+ // per project. The candidate id computed during (unlocked) prepare
1412
+ // can collide with another in-flight create_spec call; re-deriving
1413
+ // it here — inside the only place that ever writes spec.md for this
1414
+ // project — guarantees the id actually persisted is unique.
1415
+ return withFileLock(`create-spec:${projectId}`, async () => {
1416
+ let committedPlannerToken;
1417
+ try {
1418
+ const lockedSpecId = await computeNextSpecId(resolvedPath, projectId);
1419
+ if (lockedSpecId !== spec.id) {
1420
+ const newGitBranch = generateBranchName(lockedSpecId, spec.slug, spec.type);
1421
+ const newSpecDir = pathJoin(pathDirname(specDir), `${lockedSpecId}-${spec.slug}`);
1422
+ const newSpecPath = pathJoin(newSpecDir, pathBasename(specPath));
1423
+ unifiedSpec = unifiedSpec
1424
+ .replace(`id: ${spec.id}\n`, `id: ${lockedSpecId}\n`)
1425
+ .replace(`branch: ${spec.gitBranch}\n`, `branch: ${newGitBranch}\n`);
1426
+ spec = {
1427
+ ...spec,
1428
+ id: lockedSpecId,
1429
+ specPath: newSpecPath,
1430
+ technicalPath: newSpecPath,
1431
+ gitBranch: newGitBranch,
1432
+ };
1433
+ specDir = newSpecDir;
1434
+ specPath = newSpecPath;
1435
+ }
1436
+ committedPlannerToken = await issuePlannerToken(resolvedPath, spec.id, params.sessionId ?? 'unknown-session', params.modelId ?? 'unknown-model', params.host ?? 'unknown-host');
1437
+ }
1438
+ catch {
1439
+ // Best-effort evidence must not block durable spec creation.
1440
+ }
1441
+ const committedResult = buildCommittedCreateResult({
1442
+ spec,
1443
+ operationId: operationKey,
1444
+ duplicate,
1445
+ constitutionWarnings,
1446
+ clarificationSession,
1447
+ advisoryCriteria,
1448
+ actionableMetrics,
1449
+ splitSuggestion,
1450
+ experienceLevel: knowledge?.experienceLevel,
1451
+ contradictionHint: undefined,
1452
+ simplicityResult: committedSimplicity,
1453
+ plannerToken: committedPlannerToken,
1437
1454
  });
1438
- claimLifecycle.committed = true;
1439
- // SPEC-709/SPEC-461: unified spec.md; no progress or HTML artifacts.
1440
- }
1441
- catch (writeErr) {
1442
- const storeRolledBack = storeCreated
1443
- ? await specStore.deleteSpec(projectId, spec.id).catch(() => false)
1444
- : true;
1445
- await cleanupExecutionArtifact(getIdempotencyEvidencePath(resolvedPath, idempotencyKey), { force: true });
1446
- await cleanupExecutionArtifact(specDir, { recursive: true, force: true });
1455
+ let storeCreated = false;
1447
1456
  try {
1448
- operationJournal.resolveRecovery('create_spec', operationKey, {
1449
- action: storeRolledBack ? 'rollback' : 'quarantine',
1450
- reason: storeRolledBack
1451
- ? 'create_spec persistence failed and compensating cleanup completed'
1452
- : 'create_spec persistence failed and the spec store rollback was incomplete',
1457
+ assertExecutionCanCommit(criticalSignal);
1458
+ operationJournal.setRecoveryPayload('create_spec', operationKey, 1, {
1459
+ operationVersion: 1,
1460
+ projectPath: resolvedPath,
1461
+ idempotencyKey,
1462
+ ownerId: idempotencyClaim.ownerId,
1463
+ projectId,
1464
+ specId: spec.id,
1465
+ specPath,
1466
+ specContentDigest: createHash('sha256').update(unifiedSpec).digest('hex'),
1467
+ result: committedResult,
1453
1468
  });
1469
+ operationJournal.prepared('create_spec', operationKey);
1470
+ await measureStep('mkdir-specDir', () => executionMkdir(specDir, { recursive: true }));
1471
+ // SPEC-713: measure file write — this is the critical persistence step.
1472
+ assertExecutionCanCommit(criticalSignal);
1473
+ await measureStep('writeFile-specPath', () => atomicWriteFile(specPath, unifiedSpec, { encoding: 'utf-8' }));
1474
+ assertExecutionCanCommit(criticalSignal);
1475
+ // SPEC-1351 (AC2): verify the write actually landed on disk
1476
+ // before any commit path is allowed to report persisted:true.
1477
+ // atomicWriteFile resolving without throwing is not sufficient
1478
+ // evidence under resource-exhaustion / concurrent-load
1479
+ // conditions that previously let the journal report success
1480
+ // with zero bytes ever reaching disk.
1481
+ const writtenStat = await fsStat(specPath).catch(() => null);
1482
+ if (!writtenStat || writtenStat.size === 0) {
1483
+ throw new Error(`create_spec failed to persist ${specPath}: file is missing or empty after write. ` +
1484
+ 'No spec was registered — retry create_spec.');
1485
+ }
1486
+ await measureStep('specStore-createSpec', () => specStore.createSpec(projectId, spec));
1487
+ storeCreated = true;
1488
+ assertExecutionCanCommit(criticalSignal);
1489
+ await measureStep('commit-idempotency-evidence', () => commitIdempotencyEvidence(resolvedPath, idempotencyKey, idempotencyClaim, spec, specPath));
1490
+ operationJournal.commitWithOutbox('create_spec', operationKey, committedResult, {
1491
+ topic: 'spec.created',
1492
+ payload: {
1493
+ schemaVersion: 1,
1494
+ specId: spec.id,
1495
+ projectId,
1496
+ projectPath: resolvedPath,
1497
+ postCommitTasks: SPEC_CREATED_POST_COMMIT_TASKS,
1498
+ },
1499
+ });
1500
+ claimLifecycle.committed = true;
1501
+ // SPEC-709/SPEC-461: unified spec.md; no progress or HTML artifacts.
1454
1502
  }
1455
- catch {
1456
- // The original persistence failure remains the primary error.
1503
+ catch (writeErr) {
1504
+ const storeRolledBack = storeCreated
1505
+ ? await specStore.deleteSpec(projectId, spec.id).catch(() => false)
1506
+ : true;
1507
+ await cleanupExecutionArtifact(getIdempotencyEvidencePath(resolvedPath, idempotencyKey), { force: true });
1508
+ await cleanupExecutionArtifact(specDir, { recursive: true, force: true });
1509
+ try {
1510
+ operationJournal.resolveRecovery('create_spec', operationKey, {
1511
+ action: storeRolledBack ? 'rollback' : 'quarantine',
1512
+ reason: storeRolledBack
1513
+ ? 'create_spec persistence failed and compensating cleanup completed'
1514
+ : 'create_spec persistence failed and the spec store rollback was incomplete',
1515
+ });
1516
+ }
1517
+ catch {
1518
+ // The original persistence failure remains the primary error.
1519
+ }
1520
+ throw writeErr;
1457
1521
  }
1458
- throw writeErr;
1459
- }
1460
- return {
1461
- ok: true,
1462
- data: {
1463
- committedResult,
1464
- },
1465
- };
1522
+ return {
1523
+ ok: true,
1524
+ data: {
1525
+ committedResult,
1526
+ },
1527
+ };
1528
+ });
1466
1529
  }); // end withTotalBudget critical path
1467
1530
  // SPEC-713: Handle total budget exceeded — return clear error before 60s MCP timeout
1468
1531
  if (criticalResult.timedOut) {
1469
1532
  claimLifecycle.retainForInFlightWork = criticalResult.warning.includes('exceeded');
1533
+ if (!claimLifecycle.retainForInFlightWork) {
1534
+ rollbackUncommittedOperation(operationJournal, operationKey, 'create_spec timed out before commit with no in-flight work to preserve');
1535
+ }
1470
1536
  // SPEC-770: Wait briefly for any in-flight spec write to complete, then scan for duplicates
1471
1537
  await new Promise((resolve) => setTimeout(resolve, 500));
1472
1538
  const recentSpecs = await findRecentlyWrittenSpecs(resolvedPath, 2 * 60 * 1000, resolvedInputParams.title);
@@ -1486,6 +1552,7 @@ export async function handleCreateSpec(inputParams, server) {
1486
1552
  };
1487
1553
  }
1488
1554
  if (criticalResult.status === 'failed') {
1555
+ rollbackUncommittedOperation(operationJournal, operationKey, 'create_spec critical path failed before commit');
1489
1556
  return {
1490
1557
  content: [{ type: 'text', text: criticalResult.warning }],
1491
1558
  isError: true,
@@ -1498,6 +1565,7 @@ export async function handleCreateSpec(inputParams, server) {
1498
1565
  };
1499
1566
  }
1500
1567
  if (!criticalResult.value.ok) {
1568
+ rollbackUncommittedOperation(operationJournal, operationKey, 'create_spec returned early before commit');
1501
1569
  return criticalResult.value.earlyReturn;
1502
1570
  }
1503
1571
  if (!claimLifecycle.committed) {
@@ -1511,9 +1579,11 @@ export async function handleCreateSpec(inputParams, server) {
1511
1579
  content: [{ type: 'text', text: ti('errors.internalError', { message }) }],
1512
1580
  isError: true,
1513
1581
  };
1514
- return claimLifecycle.committed
1515
- ? finishRecoveredOperation(operationJournal, operationKey, errorResult)
1516
- : errorResult;
1582
+ if (claimLifecycle.committed) {
1583
+ return finishRecoveredOperation(operationJournal, operationKey, errorResult);
1584
+ }
1585
+ rollbackUncommittedOperation(operationJournal, operationKey, 'create_spec failed before commit');
1586
+ return errorResult;
1517
1587
  }
1518
1588
  }); // end trackCost
1519
1589
  }
@@ -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()
@@ -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