@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
@@ -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';
@@ -15,7 +17,7 @@ import { generateLeanTechnicalContent, } from '../engine/spec-format/lean-techni
15
17
  import { extractFilesFromSpecBody } from '../engine/spec-format/technical-md-populator.js';
16
18
  import { buildCanonicalUnifiedSpecContent, validateUnifiedSpecCandidate, } from '../engine/spec-format/unified-spec-builder.js';
17
19
  import { buildImplementationContractSection } from '../engine/implementation-contract/index.js';
18
- import { validateEnglishOnlySpecText } from '../engine/spec-language/english-only.js';
20
+ import { resolveEnglishOnlySpecGate } from '../engine/spec-language/english-only.js';
19
21
  import { FallbackGenerator } from '../engine/spec-generator/index.js';
20
22
  import { analyzeProjectForSpec, getEmptyAutopilotResult, } from './create-spec/autopilot-analyzer.js';
21
23
  import { trackCost } from '../engine/cost-tracking/operation-tracker.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) {
@@ -865,28 +880,50 @@ function validateCreateSpecEnums(input) {
865
880
  }
866
881
  const DESCRIPTION_MAX_CHARS = 10_000;
867
882
  const DESCRIPTION_EXCEED_MSG = 'Description exceeds 10000 chars. Create with a summary (<3k) and use reconcile_spec to add the rest.';
868
- function checkEnglishOnlyInput(params) {
869
- const validation = validateEnglishOnlySpecText(`${params.title}\n\n${params.description}`);
870
- if (validation.ok) {
871
- return null;
872
- }
883
+ /** SPEC-1342: Terminal rejection — only reached after one translation clarification round failed. */
884
+ function buildEnglishGateRejectionResult(gate) {
873
885
  return {
874
886
  content: [
875
887
  {
876
888
  type: 'text',
877
- text: `English-only spec gate blocked create_spec.\n\n${validation.reason ?? 'Non-English prose detected.'}\n\n` +
889
+ text: `English-only spec gate blocked create_spec.\n\n${gate.reason ?? 'Non-English prose detected.'}\n\n` +
878
890
  'Rewrite the title and description in English, then call create_spec again. User-facing responses may be localized, but spec.md must be English.',
879
891
  },
880
892
  ],
881
893
  isError: true,
882
894
  structuredContent: {
883
895
  error: 'SPEC_LANGUAGE_GATE_BLOCKED',
884
- detectedLanguage: validation.detectedLanguage,
885
- signals: validation.signals,
896
+ detectedLanguage: gate.originalLanguage,
886
897
  fixHint: 'Rewrite title and description in English before creating the spec.',
887
898
  },
888
899
  };
889
900
  }
901
+ /**
902
+ * SPEC-1342: Apply an already-resolved English-only gate outcome. Returns:
903
+ * - `{ earlyReturn: null, englishTitle?, englishDescription? }` when the caller may proceed
904
+ * (input was already English, or was successfully translated).
905
+ * - `{ earlyReturn: ToolResult }` when the caller must return early (translation clarification
906
+ * question, or terminal rejection after one failed clarification round).
907
+ */
908
+ function applyEnglishOnlyGate(gate) {
909
+ switch (gate.action) {
910
+ case 'ok':
911
+ return { earlyReturn: null };
912
+ case 'translated':
913
+ return {
914
+ earlyReturn: null,
915
+ englishTitle: gate.englishTitle,
916
+ englishDescription: gate.englishDescription,
917
+ };
918
+ case 'reject':
919
+ return { earlyReturn: buildEnglishGateRejectionResult(gate) };
920
+ case 'ask':
921
+ return {
922
+ earlyReturn: interactiveResult(gate.questions ?? [], 'The spec title/description are not in English. Provide the English translation ' +
923
+ '(e.g. via AskUserQuestion), then retry create_spec with clarificationAnswers.', 'universal'),
924
+ };
925
+ }
926
+ }
890
927
  function quotePosixShellArgument(value) {
891
928
  return `'${value.replaceAll("'", `'"'"'`)}'`;
892
929
  }
@@ -1194,15 +1231,30 @@ export async function handleCreateSpec(inputParams, server) {
1194
1231
  inputParams.specId) {
1195
1232
  return handleAgentTeamSynthesis(inputParams.specId, resolvedPath, inputParams.agentTeamFindings);
1196
1233
  }
1197
- const languageGate = checkEnglishOnlyInput(resolvedInputParams);
1198
- if (languageGate) {
1199
- return languageGate;
1200
- }
1201
- // SPEC-584: Gate check — block if pending clarification token exists and no answers provided
1234
+ // SPEC-584: Gate check — block if pending clarification token exists and no answers provided.
1235
+ // Runs before the English-only gate so a second call with no translation answer hits the
1236
+ // existing well-formed "must call AskUserQuestion" mandate instead of re-asking forever.
1202
1237
  const gateError = await runClarificationGate(resolvedPath, 'create_spec', inputParams.clarificationAnswers);
1203
1238
  if (gateError) {
1204
1239
  return gateError;
1205
1240
  }
1241
+ // SPEC-1342: English-only gate — offers a translation clarification round instead of
1242
+ // hard-rejecting non-English title/description.
1243
+ const languageGateResult = resolveEnglishOnlySpecGate(resolvedInputParams.title, resolvedInputParams.description, resolvedInputParams.clarificationAnswers);
1244
+ const languageGate = applyEnglishOnlyGate(languageGateResult);
1245
+ if (languageGate.earlyReturn) {
1246
+ if (languageGateResult.action === 'ask') {
1247
+ await persistClarificationToken(languageGate.earlyReturn, hashProjectPath(resolvedPath), 'create_spec');
1248
+ }
1249
+ return languageGate.earlyReturn;
1250
+ }
1251
+ if (languageGate.englishTitle !== undefined) {
1252
+ resolvedInputParams.title = languageGate.englishTitle;
1253
+ }
1254
+ if (languageGate.englishDescription !== undefined) {
1255
+ resolvedInputParams.description = languageGate.englishDescription;
1256
+ }
1257
+ const originalLanguage = languageGateResult.originalLanguage;
1206
1258
  const idempotencyKey = computeIdempotencyKey(inputParams.title, resolvedPath, inputParams.idempotencyKey);
1207
1259
  const cutoff = Date.now() - IDEMPOTENCY_MATCH_WINDOW_MS;
1208
1260
  const storeOnlyCandidate = await findStoreByIdempotencyKey(hashProjectPath(resolvedPath), idempotencyKey, cutoff);
@@ -1253,7 +1305,15 @@ export async function handleCreateSpec(inputParams, server) {
1253
1305
  });
1254
1306
  // SPEC-770: Idempotency check — return existing spec if same title+projectPath within 10 minutes
1255
1307
  const operationKey = computeOperationKey(resolvedInputParams, resolvedPath, idempotencyKey);
1256
- const runtimeDatabase = new RuntimeDatabase({ path: resolveStorageLayout().runtimeDatabase });
1308
+ // SPEC-1351: create_spec holds its RuntimeDatabase connection for the whole
1309
+ // operation (idempotency journal + recovery payload). The default cap (4) is
1310
+ // exhausted by a handful of concurrent create_spec calls in one process,
1311
+ // which otherwise fail with RuntimeDatabaseResourceExhaustedError before any
1312
+ // persistence work starts. Raise the ceiling for this call site only.
1313
+ const runtimeDatabase = new RuntimeDatabase({
1314
+ path: resolveStorageLayout().runtimeDatabase,
1315
+ maxConnections: 16,
1316
+ });
1257
1317
  const operationJournal = new OperationJournal(runtimeDatabase, createHash('sha256').update(hashProjectPath(resolvedPath)).digest('hex').slice(0, 32));
1258
1318
  let journalEntry;
1259
1319
  try {
@@ -1378,95 +1438,138 @@ export async function handleCreateSpec(inputParams, server) {
1378
1438
  // Only the steps required to produce spec.md on disk are inside this budget.
1379
1439
  // Durable post-commit work is delegated to the spec.created outbox consumer.
1380
1440
  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
- },
1441
+ const { params, context, knowledge, clarificationSession, constitutionWarnings, advisoryCriteria, actionableMetrics, simplicityResult: committedSimplicity, } = prepared;
1442
+ const { splitSuggestion, duplicate, projectId } = context;
1443
+ let spec = context.spec;
1444
+ let specDir = context.specDir;
1445
+ let specPath = context.specPath;
1446
+ let unifiedSpec = prepared.unifiedSpec;
1447
+ // SPEC-1351: serialize the id re-derivation + persistence sequence
1448
+ // per project. The candidate id computed during (unlocked) prepare
1449
+ // can collide with another in-flight create_spec call; re-deriving
1450
+ // it here — inside the only place that ever writes spec.md for this
1451
+ // project — guarantees the id actually persisted is unique.
1452
+ return withFileLock(`create-spec:${projectId}`, async () => {
1453
+ let committedPlannerToken;
1454
+ try {
1455
+ const lockedSpecId = await computeNextSpecId(resolvedPath, projectId);
1456
+ if (lockedSpecId !== spec.id) {
1457
+ const newGitBranch = generateBranchName(lockedSpecId, spec.slug, spec.type);
1458
+ const newSpecDir = pathJoin(pathDirname(specDir), `${lockedSpecId}-${spec.slug}`);
1459
+ const newSpecPath = pathJoin(newSpecDir, pathBasename(specPath));
1460
+ unifiedSpec = unifiedSpec
1461
+ .replace(`id: ${spec.id}\n`, `id: ${lockedSpecId}\n`)
1462
+ .replace(`branch: ${spec.gitBranch}\n`, `branch: ${newGitBranch}\n`);
1463
+ spec = {
1464
+ ...spec,
1465
+ id: lockedSpecId,
1466
+ specPath: newSpecPath,
1467
+ technicalPath: newSpecPath,
1468
+ gitBranch: newGitBranch,
1469
+ };
1470
+ specDir = newSpecDir;
1471
+ specPath = newSpecPath;
1472
+ }
1473
+ committedPlannerToken = await issuePlannerToken(resolvedPath, spec.id, params.sessionId ?? 'unknown-session', params.modelId ?? 'unknown-model', params.host ?? 'unknown-host');
1474
+ }
1475
+ catch {
1476
+ // Best-effort evidence must not block durable spec creation.
1477
+ }
1478
+ const committedResult = buildCommittedCreateResult({
1479
+ spec,
1480
+ operationId: operationKey,
1481
+ duplicate,
1482
+ constitutionWarnings,
1483
+ clarificationSession,
1484
+ advisoryCriteria,
1485
+ actionableMetrics,
1486
+ splitSuggestion,
1487
+ experienceLevel: knowledge?.experienceLevel,
1488
+ contradictionHint: undefined,
1489
+ simplicityResult: committedSimplicity,
1490
+ plannerToken: committedPlannerToken,
1437
1491
  });
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 });
1492
+ let storeCreated = false;
1447
1493
  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',
1494
+ assertExecutionCanCommit(criticalSignal);
1495
+ operationJournal.setRecoveryPayload('create_spec', operationKey, 1, {
1496
+ operationVersion: 1,
1497
+ projectPath: resolvedPath,
1498
+ idempotencyKey,
1499
+ ownerId: idempotencyClaim.ownerId,
1500
+ projectId,
1501
+ specId: spec.id,
1502
+ specPath,
1503
+ specContentDigest: createHash('sha256').update(unifiedSpec).digest('hex'),
1504
+ result: committedResult,
1453
1505
  });
1506
+ operationJournal.prepared('create_spec', operationKey);
1507
+ await measureStep('mkdir-specDir', () => executionMkdir(specDir, { recursive: true }));
1508
+ // SPEC-713: measure file write — this is the critical persistence step.
1509
+ assertExecutionCanCommit(criticalSignal);
1510
+ await measureStep('writeFile-specPath', () => atomicWriteFile(specPath, unifiedSpec, { encoding: 'utf-8' }));
1511
+ assertExecutionCanCommit(criticalSignal);
1512
+ // SPEC-1351 (AC2): verify the write actually landed on disk
1513
+ // before any commit path is allowed to report persisted:true.
1514
+ // atomicWriteFile resolving without throwing is not sufficient
1515
+ // evidence under resource-exhaustion / concurrent-load
1516
+ // conditions that previously let the journal report success
1517
+ // with zero bytes ever reaching disk.
1518
+ const writtenStat = await fsStat(specPath).catch(() => null);
1519
+ if (!writtenStat || writtenStat.size === 0) {
1520
+ throw new Error(`create_spec failed to persist ${specPath}: file is missing or empty after write. ` +
1521
+ 'No spec was registered — retry create_spec.');
1522
+ }
1523
+ await measureStep('specStore-createSpec', () => specStore.createSpec(projectId, spec));
1524
+ storeCreated = true;
1525
+ assertExecutionCanCommit(criticalSignal);
1526
+ await measureStep('commit-idempotency-evidence', () => commitIdempotencyEvidence(resolvedPath, idempotencyKey, idempotencyClaim, spec, specPath));
1527
+ operationJournal.commitWithOutbox('create_spec', operationKey, committedResult, {
1528
+ topic: 'spec.created',
1529
+ payload: {
1530
+ schemaVersion: 1,
1531
+ specId: spec.id,
1532
+ projectId,
1533
+ projectPath: resolvedPath,
1534
+ postCommitTasks: SPEC_CREATED_POST_COMMIT_TASKS,
1535
+ },
1536
+ });
1537
+ claimLifecycle.committed = true;
1538
+ // SPEC-709/SPEC-461: unified spec.md; no progress or HTML artifacts.
1454
1539
  }
1455
- catch {
1456
- // The original persistence failure remains the primary error.
1540
+ catch (writeErr) {
1541
+ const storeRolledBack = storeCreated
1542
+ ? await specStore.deleteSpec(projectId, spec.id).catch(() => false)
1543
+ : true;
1544
+ await cleanupExecutionArtifact(getIdempotencyEvidencePath(resolvedPath, idempotencyKey), { force: true });
1545
+ await cleanupExecutionArtifact(specDir, { recursive: true, force: true });
1546
+ try {
1547
+ operationJournal.resolveRecovery('create_spec', operationKey, {
1548
+ action: storeRolledBack ? 'rollback' : 'quarantine',
1549
+ reason: storeRolledBack
1550
+ ? 'create_spec persistence failed and compensating cleanup completed'
1551
+ : 'create_spec persistence failed and the spec store rollback was incomplete',
1552
+ });
1553
+ }
1554
+ catch {
1555
+ // The original persistence failure remains the primary error.
1556
+ }
1557
+ throw writeErr;
1457
1558
  }
1458
- throw writeErr;
1459
- }
1460
- return {
1461
- ok: true,
1462
- data: {
1463
- committedResult,
1464
- },
1465
- };
1559
+ return {
1560
+ ok: true,
1561
+ data: {
1562
+ committedResult,
1563
+ },
1564
+ };
1565
+ });
1466
1566
  }); // end withTotalBudget critical path
1467
1567
  // SPEC-713: Handle total budget exceeded — return clear error before 60s MCP timeout
1468
1568
  if (criticalResult.timedOut) {
1469
1569
  claimLifecycle.retainForInFlightWork = criticalResult.warning.includes('exceeded');
1570
+ if (!claimLifecycle.retainForInFlightWork) {
1571
+ rollbackUncommittedOperation(operationJournal, operationKey, 'create_spec timed out before commit with no in-flight work to preserve');
1572
+ }
1470
1573
  // SPEC-770: Wait briefly for any in-flight spec write to complete, then scan for duplicates
1471
1574
  await new Promise((resolve) => setTimeout(resolve, 500));
1472
1575
  const recentSpecs = await findRecentlyWrittenSpecs(resolvedPath, 2 * 60 * 1000, resolvedInputParams.title);
@@ -1486,6 +1589,7 @@ export async function handleCreateSpec(inputParams, server) {
1486
1589
  };
1487
1590
  }
1488
1591
  if (criticalResult.status === 'failed') {
1592
+ rollbackUncommittedOperation(operationJournal, operationKey, 'create_spec critical path failed before commit');
1489
1593
  return {
1490
1594
  content: [{ type: 'text', text: criticalResult.warning }],
1491
1595
  isError: true,
@@ -1498,12 +1602,21 @@ export async function handleCreateSpec(inputParams, server) {
1498
1602
  };
1499
1603
  }
1500
1604
  if (!criticalResult.value.ok) {
1605
+ rollbackUncommittedOperation(operationJournal, operationKey, 'create_spec returned early before commit');
1501
1606
  return criticalResult.value.earlyReturn;
1502
1607
  }
1503
1608
  if (!claimLifecycle.committed) {
1504
1609
  throw new Error('create_spec critical path completed without a durable commit');
1505
1610
  }
1506
- return finishRecoveredOperation(operationJournal, operationKey, criticalResult.value.data.committedResult);
1611
+ const committedResult = criticalResult.value.data.committedResult;
1612
+ // SPEC-1342: surface the original (pre-translation) language on the created spec's response.
1613
+ if (originalLanguage && !committedResult.isError) {
1614
+ committedResult.structuredContent = {
1615
+ ...committedResult.structuredContent,
1616
+ originalLanguage,
1617
+ };
1618
+ }
1619
+ return finishRecoveredOperation(operationJournal, operationKey, committedResult);
1507
1620
  }
1508
1621
  catch (error) {
1509
1622
  const message = error instanceof Error ? error.message : String(error);
@@ -1511,9 +1624,11 @@ export async function handleCreateSpec(inputParams, server) {
1511
1624
  content: [{ type: 'text', text: ti('errors.internalError', { message }) }],
1512
1625
  isError: true,
1513
1626
  };
1514
- return claimLifecycle.committed
1515
- ? finishRecoveredOperation(operationJournal, operationKey, errorResult)
1516
- : errorResult;
1627
+ if (claimLifecycle.committed) {
1628
+ return finishRecoveredOperation(operationJournal, operationKey, errorResult);
1629
+ }
1630
+ rollbackUncommittedOperation(operationJournal, operationKey, 'create_spec failed before commit');
1631
+ return errorResult;
1517
1632
  }
1518
1633
  }); // end trackCost
1519
1634
  }