@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.
- package/CHANGELOG.md +76 -1
- package/dist/cli/commands/doctor.d.ts +22 -0
- package/dist/cli/commands/doctor.js +176 -2
- package/dist/cli/commands/spec.js +10 -1
- package/dist/core/spec-validator.js +32 -18
- package/dist/engine/autopilot/bootstrap.js +27 -0
- package/dist/engine/core-bridge.d.ts +28 -0
- package/dist/engine/core-bridge.js +67 -0
- package/dist/engine/drift-monitor.js +16 -18
- package/dist/engine/evidence-gates/artifact-reader.d.ts +2 -0
- package/dist/engine/evidence-gates/artifact-reader.js +59 -2
- package/dist/engine/evidence-gates/evidence-autofill.d.ts +10 -0
- package/dist/engine/evidence-gates/evidence-autofill.js +148 -0
- package/dist/engine/evidence-gates/evidence-skeletons.d.ts +19 -0
- package/dist/engine/evidence-gates/evidence-skeletons.js +69 -0
- package/dist/engine/execution/operation-journal.js +10 -4
- package/dist/engine/living-spec/hash-tracker.js +26 -28
- package/dist/engine/minimality/policy-loader.js +247 -6
- package/dist/engine/planu-core.darwin-arm64.node.manifest.json +7 -7
- package/dist/engine/planu-core.darwin-arm64.node.sbom.json +14 -14
- package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
- package/dist/engine/planu-core.darwin-x64.node.sbom.json +14 -14
- package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +14 -14
- package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +14 -14
- package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +14 -14
- package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +14 -14
- package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
- package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +14 -14
- package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
- package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +14 -14
- package/dist/engine/reverse-engineer/api-detector.js +2 -13
- package/dist/engine/reverse-engineer/complexity-analyzer.js +2 -13
- package/dist/engine/reverse-engineer/config-analyzer.js +2 -13
- package/dist/engine/reverse-engineer/dependency-graph.js +2 -13
- package/dist/engine/reverse-engineer/test-analyzer.js +2 -13
- package/dist/engine/reverse-engineer/walk-ignore.d.ts +3 -0
- package/dist/engine/reverse-engineer/walk-ignore.js +26 -0
- package/dist/engine/spec-format/acceptance-criteria.js +13 -12
- package/dist/engine/spec-format/text-fences.js +20 -2
- package/dist/engine/spec-language/english-only.d.ts +14 -0
- package/dist/engine/spec-language/english-only.js +58 -0
- package/dist/engine/spec-migrator/criteria-scanner.js +11 -13
- package/dist/engine/spec-migrator/drift-detector.js +10 -12
- package/dist/engine/spec-state-syncer.js +1 -1
- package/dist/engine/timing/budget.js +5 -1
- package/dist/engine/vector-store/tfidf.d.ts +13 -9
- package/dist/engine/vector-store/tfidf.js +26 -0
- package/dist/engine/worker-config-loader.d.ts +1 -1
- package/dist/engine/worker-config-loader.js +1 -11
- package/dist/engine/workers/schema.d.ts +0 -8
- package/dist/engine/workers/schema.js +0 -1
- package/dist/i18n/index.d.ts +18 -0
- package/dist/i18n/index.js +40 -1
- package/dist/server/routes/specs.js +7 -5
- package/dist/storage/global-store.d.ts +9 -0
- package/dist/storage/global-store.js +23 -0
- package/dist/storage/semantic-index-store.d.ts +23 -0
- package/dist/storage/semantic-index-store.js +105 -0
- package/dist/storage/status-store/self-healing.js +15 -17
- package/dist/tools/challenge-spec/scenarios-utils.js +5 -1
- package/dist/tools/challenge-spec-helpers.d.ts +10 -1
- package/dist/tools/challenge-spec-helpers.js +63 -22
- package/dist/tools/challenge-spec.js +18 -3
- package/dist/tools/check-readiness.js +37 -13
- package/dist/tools/create-spec/spec-builder.d.ts +7 -0
- package/dist/tools/create-spec/spec-builder.js +19 -4
- package/dist/tools/create-spec.js +216 -101
- package/dist/tools/init-project/handler.js +78 -25
- package/dist/tools/learn.js +10 -8
- package/dist/tools/register-spec-tools/core-spec-tools.js +13 -12
- package/dist/tools/registry/auth.js +1 -11
- package/dist/tools/semantic-search-handler.js +5 -6
- package/dist/tools/status-handler.js +19 -6
- package/dist/tools/sync-spec-state-handler.js +49 -1
- package/dist/tools/update-status/batch.d.ts +6 -2
- package/dist/tools/update-status/batch.js +58 -1
- package/dist/tools/update-status/dod-gates.d.ts +16 -1
- package/dist/tools/update-status/dod-gates.js +191 -1
- package/dist/tools/update-status/done-receipt-verifier.d.ts +8 -0
- package/dist/tools/update-status/done-receipt-verifier.js +37 -2
- package/dist/tools/update-status/evidence-gate.d.ts +4 -0
- package/dist/tools/update-status/evidence-gate.js +67 -2
- package/dist/tools/update-status/file-sync.d.ts +2 -2
- package/dist/tools/update-status/index.d.ts +23 -1
- package/dist/tools/update-status/index.js +201 -24
- package/dist/tools/update-status/transition-guard.js +13 -1
- package/dist/tools/validation-loop-handler.js +16 -15
- package/dist/tools/workspace-dashboard-handler.js +38 -0
- package/dist/types/evidence-autofill.d.ts +34 -0
- package/dist/types/evidence-autofill.js +2 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/spec/core.d.ts +6 -0
- package/dist/types/spec/inputs.d.ts +7 -2
- package/dist/types/spec-format.d.ts +1 -1
- package/dist/types/spec-language-translation.d.ts +18 -0
- package/dist/types/spec-language-translation.js +5 -0
- package/dist/types/spec-registry.d.ts +0 -2
- package/dist/types/status.d.ts +2 -0
- package/dist/types/transition-log.d.ts +1 -1
- package/dist/types/validation.d.ts +8 -2
- package/dist/types/vector-store.d.ts +18 -0
- package/dist/types/workers.d.ts +0 -3
- package/package.json +11 -10
- package/planu-native.json +8 -29
- package/planu-plugin.json +1 -1
- package/dist/engine/security/cve-refresher.d.ts +0 -12
- 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
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
869
|
-
|
|
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${
|
|
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:
|
|
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
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
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
|
-
|
|
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,
|
|
1382
|
-
const {
|
|
1383
|
-
let
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
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
|
}
|