@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.
- package/CHANGELOG.md +49 -1
- package/dist/cli/commands/spec.js +10 -1
- package/dist/core/spec-validator.js +32 -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/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-state-syncer.js +1 -1
- package/dist/engine/timing/budget.js +5 -1
- package/dist/server/routes/specs.js +7 -5
- 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 +156 -86
- package/dist/tools/register-spec-tools/core-spec-tools.js +13 -12
- 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/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/transition-log.d.ts +1 -1
- package/dist/types/validation.d.ts +8 -2
- package/package.json +11 -10
- package/planu-native.json +1 -1
- 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
|
-
//
|
|
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';
|
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
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
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
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
|
|
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
|
-
|
|
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
|