@planu/cli 5.5.3 → 5.7.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 +58 -0
- package/dist/.planu-build.json +1 -1
- package/dist/cli/commands/telemetry.d.ts +3 -0
- package/dist/cli/commands/telemetry.js +118 -0
- package/dist/cli/router.js +3 -1
- package/dist/config/environment-schema.json +14 -0
- package/dist/engine/autopilot/bootstrap.js +1 -1
- package/dist/engine/cascade-hooks/core/append-releases.js +22 -12
- package/dist/engine/contradiction-detector.d.ts +2 -1
- package/dist/engine/contradiction-detector.js +215 -0
- package/dist/engine/detection-utils.d.ts +1 -0
- package/dist/engine/detection-utils.js +33 -0
- package/dist/engine/doc-generator/portal/index.d.ts +1 -1
- package/dist/engine/doc-generator/portal/index.js +1 -1
- package/dist/engine/doc-generator/portal/portal-regenerator.d.ts +8 -3
- package/dist/engine/doc-generator/portal/portal-regenerator.js +16 -7
- package/dist/engine/evidence-gates/evidence-autofill.d.ts +1 -1
- package/dist/engine/evidence-gates/evidence-autofill.js +1 -1
- package/dist/engine/framework-detector.js +8 -6
- package/dist/engine/handoff-artifacts/schemas.js +4 -0
- package/dist/engine/housekeeping/history-log.d.ts +1 -0
- package/dist/engine/housekeeping/history-log.js +58 -3
- package/dist/engine/housekeeping/index.d.ts +2 -1
- package/dist/engine/housekeeping/index.js +2 -1
- package/dist/engine/housekeeping/legacy-planu-demolisher.d.ts +3 -0
- package/dist/engine/housekeeping/legacy-planu-demolisher.js +164 -0
- package/dist/engine/housekeeping/runtime-residue-sweep.d.ts +9 -0
- package/dist/engine/housekeeping/runtime-residue-sweep.js +57 -0
- package/dist/engine/lifecycle-reconciliation.js +87 -40
- package/dist/engine/next-spec-resolver/orchestration-planner.js +1 -1
- package/dist/engine/next-spec-resolver/session-writer.js +1 -1
- package/dist/engine/project-graph/cache.js +23 -3
- package/dist/engine/readiness-checker.js +14 -2
- package/dist/engine/sdd-flow/checkpoints.js +29 -2
- package/dist/engine/session/checkpoint-writer.d.ts +1 -1
- package/dist/engine/session/checkpoint-writer.js +6 -5
- package/dist/engine/session-state/writer.js +4 -3
- package/dist/engine/spec-format/lean-spec-generator.js +1 -1
- package/dist/engine/spec-migrator/planu-canonical-policy.d.ts +8 -1
- package/dist/engine/spec-migrator/planu-canonical-policy.js +14 -13
- package/dist/engine/spec-migrator/strict-planu-cleanup.js +28 -3
- package/dist/engine/telemetry/error-reporter.d.ts +9 -9
- package/dist/engine/telemetry/error-reporter.js +15 -34
- package/dist/engine/telemetry/event-envelope.d.ts +11 -0
- package/dist/engine/telemetry/event-envelope.js +124 -0
- package/dist/engine/telemetry/telemetry-client.d.ts +8 -1
- package/dist/engine/telemetry/telemetry-client.js +38 -20
- package/dist/engine/telemetry/telemetry-store.d.ts +15 -2
- package/dist/engine/telemetry/telemetry-store.js +73 -2
- package/dist/engine/universal-rules/rules/planu-release-policy.js +1 -1
- package/dist/engine/validator/spec-compliance-runner.d.ts +2 -1
- package/dist/engine/validator/spec-compliance-runner.js +123 -1
- package/dist/hosts/claude-code/ux/mcp-resources.js +7 -23
- package/dist/index.js +26 -0
- package/dist/resources/specs.js +12 -33
- package/dist/storage/current-project.d.ts +3 -0
- package/dist/storage/current-project.js +21 -0
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/dist/storage/migrations/canonical-storage.js +5 -0
- package/dist/storage/retention.d.ts +14 -0
- package/dist/storage/retention.js +279 -0
- package/dist/storage/spec-index.d.ts +23 -0
- package/dist/storage/spec-index.js +123 -0
- package/dist/storage/spec-store.d.ts +8 -3
- package/dist/storage/spec-store.js +76 -6
- package/dist/storage/storage-catalog.js +3 -3
- package/dist/storage/storage-layout.d.ts +8 -0
- package/dist/storage/storage-layout.js +9 -0
- package/dist/storage/transition-log.js +2 -0
- package/dist/tools/challenge-spec.js +35 -19
- package/dist/tools/create-spec.js +10 -0
- package/dist/tools/execute-sdd-flow.js +11 -2
- package/dist/tools/export-spec.js +2 -1
- package/dist/tools/force-status-analytics.js +2 -1
- package/dist/tools/generate-docs-site.js +2 -1
- package/dist/tools/generate-proposal.js +6 -2
- package/dist/tools/init-project/claude-md-generator.js +19 -2
- package/dist/tools/init-project/conventions-writer.d.ts +5 -2
- package/dist/tools/init-project/conventions-writer.js +18 -13
- package/dist/tools/init-project/git-setup.js +9 -0
- package/dist/tools/init-project/handler.js +9 -1
- package/dist/tools/init-project/legacy-planu.d.ts +2 -0
- package/dist/tools/init-project/legacy-planu.js +18 -0
- package/dist/tools/init-project/legacy-root-migration.d.ts +15 -0
- package/dist/tools/init-project/legacy-root-migration.js +213 -0
- package/dist/tools/init-project/runtime-residue.d.ts +2 -0
- package/dist/tools/init-project/runtime-residue.js +11 -0
- package/dist/tools/init-project/schedule-housekeeping.d.ts +2 -0
- package/dist/tools/init-project/schedule-housekeeping.js +8 -0
- package/dist/tools/reconcile-spec.js +29 -2
- package/dist/tools/register-spec-tools/analysis-tools.d.ts +7 -0
- package/dist/tools/register-spec-tools/analysis-tools.js +13 -1
- package/dist/tools/safe-handler.js +6 -12
- package/dist/tools/session-checkpoint.js +1 -1
- package/dist/tools/update-status/index.js +7 -1
- package/dist/tools/update-status-actions.d.ts +7 -1
- package/dist/tools/update-status-actions.js +10 -2
- package/dist/types/handoff-artifacts.d.ts +1 -0
- package/dist/types/housekeeping.d.ts +38 -0
- package/dist/types/housekeeping.js +0 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/retention.d.ts +12 -0
- package/dist/types/retention.js +3 -0
- package/dist/types/scope.d.ts +23 -0
- package/dist/types/spec/core.d.ts +7 -0
- package/dist/types/spec/index-cache.d.ts +25 -0
- package/dist/types/spec/index-cache.js +3 -0
- package/dist/types/spec/index.d.ts +1 -0
- package/dist/types/spec/index.js +1 -0
- package/dist/types/spec/inputs.d.ts +9 -0
- package/dist/types/spec-format.d.ts +1 -0
- package/dist/types/telemetry.d.ts +39 -1
- package/dist/types/validation-evidence.d.ts +6 -0
- package/package.json +1 -1
- package/planu-plugin.json +1 -1
- package/scripts/lib/pending-release-file.mjs +20 -4
|
@@ -110,6 +110,41 @@ async function completeForwardReconciliation(args) {
|
|
|
110
110
|
await args.persistReceipt(args.receiptPath, committed);
|
|
111
111
|
return committed;
|
|
112
112
|
}
|
|
113
|
+
async function resolveReviewDigest(args) {
|
|
114
|
+
const { input, spec, reason } = args;
|
|
115
|
+
if (input.declaredDriftKind === 'architectural-premise') {
|
|
116
|
+
const reviewDigest = digest(reason.trim());
|
|
117
|
+
if (reviewDigest !== input.implementationReviewDigest) {
|
|
118
|
+
return failure('declared drift review digest does not match the declared reason', 'RECONCILIATION_STALE');
|
|
119
|
+
}
|
|
120
|
+
return { reviewDigest, driftSource: 'declared-architectural-premise' };
|
|
121
|
+
}
|
|
122
|
+
const reportPath = join(projectDataDir(args.projectId), 'handoffs', spec.id, 'validation-report.json');
|
|
123
|
+
let reportBytes;
|
|
124
|
+
try {
|
|
125
|
+
const status = await lstat(reportPath);
|
|
126
|
+
if (status.isSymbolicLink() || !status.isFile()) {
|
|
127
|
+
return failure('validation report is unsafe');
|
|
128
|
+
}
|
|
129
|
+
reportBytes = await readFile(reportPath);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return failure('validation report is missing');
|
|
133
|
+
}
|
|
134
|
+
const report = ValidationReportV1Schema.safeParse(JSON.parse(reportBytes.toString('utf8')));
|
|
135
|
+
if (!report.success ||
|
|
136
|
+
report.data.passed ||
|
|
137
|
+
report.data.reviewer.kind !== 'automation' ||
|
|
138
|
+
report.data.reviewer.agent !== AUTOMATED_VALIDATOR_AGENT ||
|
|
139
|
+
report.data.reviewer.verdict !== 'changes-requested') {
|
|
140
|
+
return failure('validation report is not an automated changes-requested finding');
|
|
141
|
+
}
|
|
142
|
+
const reviewDigest = digest(reportBytes);
|
|
143
|
+
if (reviewDigest !== input.implementationReviewDigest) {
|
|
144
|
+
return failure('implementation review digest does not match validation report', 'RECONCILIATION_STALE');
|
|
145
|
+
}
|
|
146
|
+
return { reviewDigest };
|
|
147
|
+
}
|
|
113
148
|
async function validateRequest(args) {
|
|
114
149
|
const { input, spec, context } = args;
|
|
115
150
|
if (context.surface !== 'local-mcp') {
|
|
@@ -136,42 +171,41 @@ async function validateRequest(args) {
|
|
|
136
171
|
if (!latest?.transitionId || latest.transitionId !== input.expectedImplementingTransitionId) {
|
|
137
172
|
return failure('expected implementing transition is stale', 'RECONCILIATION_STALE');
|
|
138
173
|
}
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
catch {
|
|
149
|
-
return failure('validation report is missing');
|
|
150
|
-
}
|
|
151
|
-
const report = ValidationReportV1Schema.safeParse(JSON.parse(reportBytes.toString('utf8')));
|
|
152
|
-
if (!report.success ||
|
|
153
|
-
report.data.passed ||
|
|
154
|
-
report.data.reviewer.kind !== 'automation' ||
|
|
155
|
-
report.data.reviewer.agent !== AUTOMATED_VALIDATOR_AGENT ||
|
|
156
|
-
report.data.reviewer.verdict !== 'changes-requested') {
|
|
157
|
-
return failure('validation report is not an automated changes-requested finding');
|
|
158
|
-
}
|
|
159
|
-
const reviewDigest = digest(reportBytes);
|
|
160
|
-
if (reviewDigest !== input.implementationReviewDigest) {
|
|
161
|
-
return failure('implementation review digest does not match validation report', 'RECONCILIATION_STALE');
|
|
174
|
+
const resolution = await resolveReviewDigest({
|
|
175
|
+
input,
|
|
176
|
+
spec,
|
|
177
|
+
projectId: args.projectId,
|
|
178
|
+
reason: input.reason,
|
|
179
|
+
});
|
|
180
|
+
if (!('reviewDigest' in resolution) || typeof resolution.reviewDigest !== 'string') {
|
|
181
|
+
return resolution;
|
|
162
182
|
}
|
|
183
|
+
const resolved = resolution;
|
|
163
184
|
return {
|
|
164
|
-
reviewDigest,
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
185
|
+
reviewDigest: resolved.reviewDigest,
|
|
186
|
+
driftSource: resolved.driftSource,
|
|
187
|
+
bindingDigest: computeBindingDigest({
|
|
188
|
+
projectId: args.projectId,
|
|
189
|
+
specId: spec.id,
|
|
190
|
+
requestId: input.reconciliationRequestId,
|
|
191
|
+
transitionId: latest.transitionId,
|
|
192
|
+
reviewDigest: resolved.reviewDigest,
|
|
193
|
+
reasonDigest: digest(input.reason.trim()),
|
|
194
|
+
driftSource: resolved.driftSource,
|
|
195
|
+
}),
|
|
173
196
|
};
|
|
174
197
|
}
|
|
198
|
+
function computeBindingDigest(args) {
|
|
199
|
+
return digest([
|
|
200
|
+
args.projectId,
|
|
201
|
+
args.specId,
|
|
202
|
+
args.requestId,
|
|
203
|
+
args.transitionId,
|
|
204
|
+
args.reviewDigest,
|
|
205
|
+
args.reasonDigest,
|
|
206
|
+
...(args.driftSource ? [args.driftSource] : []),
|
|
207
|
+
].join('\0'));
|
|
208
|
+
}
|
|
175
209
|
function validateDurableReceiptBinding(args) {
|
|
176
210
|
const { input, receipt } = args;
|
|
177
211
|
if (input.expectedImplementingTransitionId !== receipt.sourceImplementingTransitionId ||
|
|
@@ -180,14 +214,15 @@ function validateDurableReceiptBinding(args) {
|
|
|
180
214
|
digest(input.reason.trim()) !== receipt.reasonDigest) {
|
|
181
215
|
return failure('reconciliation retry binding conflicts with the durable receipt', 'RECONCILIATION_BINDING_CONFLICT');
|
|
182
216
|
}
|
|
183
|
-
const bindingDigest =
|
|
184
|
-
args.projectId,
|
|
185
|
-
args.specId,
|
|
186
|
-
receipt.requestId,
|
|
187
|
-
receipt.sourceImplementingTransitionId,
|
|
188
|
-
receipt.implementationReviewDigest,
|
|
189
|
-
receipt.reasonDigest,
|
|
190
|
-
|
|
217
|
+
const bindingDigest = computeBindingDigest({
|
|
218
|
+
projectId: args.projectId,
|
|
219
|
+
specId: args.specId,
|
|
220
|
+
requestId: receipt.requestId,
|
|
221
|
+
transitionId: receipt.sourceImplementingTransitionId,
|
|
222
|
+
reviewDigest: receipt.implementationReviewDigest,
|
|
223
|
+
reasonDigest: receipt.reasonDigest,
|
|
224
|
+
driftSource: receipt.driftSource,
|
|
225
|
+
});
|
|
191
226
|
if (bindingDigest !== receipt.bindingDigest) {
|
|
192
227
|
return failure('durable reconciliation binding is invalid', 'RECONCILIATION_BINDING_CONFLICT');
|
|
193
228
|
}
|
|
@@ -198,6 +233,11 @@ async function validateStoredBinding(args) {
|
|
|
198
233
|
if (durableBindingError) {
|
|
199
234
|
return durableBindingError;
|
|
200
235
|
}
|
|
236
|
+
if (args.receipt.driftSource === 'declared-architectural-premise') {
|
|
237
|
+
// No validation-report.json exists for a declared drift; the durable binding
|
|
238
|
+
// check above already re-derives and verifies the receipt's bindingDigest.
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
201
241
|
const reportPath = join(projectDataDir(args.projectId), 'handoffs', args.specId, 'validation-report.json');
|
|
202
242
|
try {
|
|
203
243
|
const reportBytes = await readFile(reportPath);
|
|
@@ -220,6 +260,11 @@ async function validateStoredBinding(args) {
|
|
|
220
260
|
export async function readCommittedReconciliations(projectId, specId) {
|
|
221
261
|
return readCommittedReconciliationReceipts(projectId, specId);
|
|
222
262
|
}
|
|
263
|
+
function extractDriftSource(validated) {
|
|
264
|
+
return 'driftSource' in validated && validated.driftSource === 'declared-architectural-premise'
|
|
265
|
+
? validated.driftSource
|
|
266
|
+
: undefined;
|
|
267
|
+
}
|
|
223
268
|
function receiptMatchesRequest(receipt, projectId, specId, requestId) {
|
|
224
269
|
return (receipt.projectId === projectId && receipt.specId === specId && receipt.requestId === requestId);
|
|
225
270
|
}
|
|
@@ -315,6 +360,7 @@ export async function reconcileImplementingSpec(args) {
|
|
|
315
360
|
typeof validated.reviewDigest !== 'string') {
|
|
316
361
|
return validated;
|
|
317
362
|
}
|
|
363
|
+
const driftSource = extractDriftSource(validated);
|
|
318
364
|
const expectedTransitionId = args.input.expectedImplementingTransitionId;
|
|
319
365
|
const reason = args.input.reason;
|
|
320
366
|
if (!expectedTransitionId || !reason) {
|
|
@@ -338,6 +384,7 @@ export async function reconcileImplementingSpec(args) {
|
|
|
338
384
|
stateHistory: [{ state: 'prepared', at: preparedAt }],
|
|
339
385
|
preparedAt,
|
|
340
386
|
phase: 'prepared',
|
|
387
|
+
...(driftSource ? { driftSource } : {}),
|
|
341
388
|
};
|
|
342
389
|
})();
|
|
343
390
|
if (!existing) {
|
|
@@ -196,7 +196,7 @@ function normalizeLegacyTier(value) {
|
|
|
196
196
|
}
|
|
197
197
|
async function readPlanVersion(projectPath) {
|
|
198
198
|
try {
|
|
199
|
-
const raw = await readFile(join(projectPath, 'planu', 'session.json'), 'utf-8');
|
|
199
|
+
const raw = await readFile(join(projectPath, 'planu', '.runtime', 'session.json'), 'utf-8');
|
|
200
200
|
const index = JSON.parse(raw);
|
|
201
201
|
return index.orchestrationPlan?.planVersion ?? 0;
|
|
202
202
|
}
|
|
@@ -68,7 +68,7 @@ export async function writeOrchestrationPlan(projectPath, plan) {
|
|
|
68
68
|
*/
|
|
69
69
|
export async function patchSessionJson(projectPath, patch) {
|
|
70
70
|
try {
|
|
71
|
-
const filePath = join(projectPath, 'planu', 'session.json');
|
|
71
|
+
const filePath = join(projectPath, 'planu', '.runtime', 'session.json');
|
|
72
72
|
let existingContent = null;
|
|
73
73
|
let current = { activeSpecs: [], updatedAt: '' };
|
|
74
74
|
try {
|
|
@@ -8,6 +8,8 @@ import { setTimeout as delay } from 'node:timers/promises';
|
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
// eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1661 SPEC-1662 SPEC-1663
|
|
10
10
|
import { projectDataDir, readJson, writeJson } from '../../storage/base-store.js';
|
|
11
|
+
// eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1661 SPEC-1662 SPEC-1663
|
|
12
|
+
import { enforceRetention } from '../../storage/retention.js';
|
|
11
13
|
import { LockBusyError, withSpecLock } from '../safety/cross-process-lock.js';
|
|
12
14
|
const POLICY_PATH = join(dirname(fileURLToPath(import.meta.url)), '../../config/project-knowledge-graph.json');
|
|
13
15
|
const CACHE_VERSION = 2;
|
|
@@ -200,7 +202,13 @@ export async function readProjectGraph(projectId, policy) {
|
|
|
200
202
|
return null;
|
|
201
203
|
}
|
|
202
204
|
export async function writeProjectGraph(projectId, policy, graph) {
|
|
203
|
-
|
|
205
|
+
const graphPath = projectGraphPath(projectId, policy);
|
|
206
|
+
await writeJson(graphPath, graph);
|
|
207
|
+
await enforceRetention({
|
|
208
|
+
kind: 'graph-cache',
|
|
209
|
+
path: projectGraphDir(projectId, policy),
|
|
210
|
+
protectedPaths: [graphPath],
|
|
211
|
+
});
|
|
204
212
|
}
|
|
205
213
|
export async function readProjectGraphSourceHashes(projectId, policy) {
|
|
206
214
|
const active = activeBuilds.get(projectGraphDir(projectId, policy));
|
|
@@ -217,6 +225,11 @@ export async function writeProjectGraphSourceHashes(projectId, policy, hashes) {
|
|
|
217
225
|
records: hashes,
|
|
218
226
|
fragments: current.cache?.fragments ?? {},
|
|
219
227
|
});
|
|
228
|
+
await enforceRetention({
|
|
229
|
+
kind: 'graph-cache',
|
|
230
|
+
path: projectGraphDir(projectId, policy),
|
|
231
|
+
protectedPaths: [projectGraphCachePath(projectId, policy)],
|
|
232
|
+
});
|
|
220
233
|
}
|
|
221
234
|
export async function writeProjectGraphArtifacts(args) {
|
|
222
235
|
if (args.graph.generation.length === 0 ||
|
|
@@ -225,8 +238,15 @@ export async function writeProjectGraphArtifacts(args) {
|
|
|
225
238
|
throw new Error('[Planu] Project graph/cache generation mismatch');
|
|
226
239
|
}
|
|
227
240
|
// Readers verify the shared generation, so neither write order can expose a mixed snapshot.
|
|
228
|
-
|
|
229
|
-
|
|
241
|
+
const cachePath = projectGraphCachePath(args.projectId, args.policy);
|
|
242
|
+
const graphPath = projectGraphPath(args.projectId, args.policy);
|
|
243
|
+
await writeJson(cachePath, args.cache);
|
|
244
|
+
await writeJson(graphPath, args.graph);
|
|
245
|
+
await enforceRetention({
|
|
246
|
+
kind: 'graph-cache',
|
|
247
|
+
path: projectGraphDir(args.projectId, args.policy),
|
|
248
|
+
protectedPaths: [cachePath, graphPath],
|
|
249
|
+
});
|
|
230
250
|
}
|
|
231
251
|
export async function withProjectGraphBuildLock(projectId, policy, task) {
|
|
232
252
|
const key = projectGraphDir(projectId, policy);
|
|
@@ -10,6 +10,7 @@ import { findScenariosWithoutTests, parseFrontmatterScenarios, } from './validat
|
|
|
10
10
|
import { normalizeExecutableEvidence } from './validator/executable-evidence.js';
|
|
11
11
|
import { evaluateImplementationContract } from './implementation-contract/index.js';
|
|
12
12
|
import { extractNormalizedAcceptanceCriteria } from './spec-format/acceptance-criteria.js';
|
|
13
|
+
import { detectCrossSpecPremiseContradictions } from './contradiction-detector.js';
|
|
13
14
|
// ── SPEC-784: Technical section quality constants ─────────────────────────────
|
|
14
15
|
const TECHNICAL_MIN_CHARS = 500;
|
|
15
16
|
// Detects "See technical.md", "See spec.md", or "See `<file>` technical.md" patterns
|
|
@@ -428,7 +429,7 @@ export async function checkSpecReadiness(spec, mode, projectHash) {
|
|
|
428
429
|
if (bddScenarioCount > 0) {
|
|
429
430
|
const scenariosWithoutTests = findScenariosWithoutTests(huRaw);
|
|
430
431
|
for (const title of scenariosWithoutTests) {
|
|
431
|
-
allBlockers.push(`scenarios_missing_tests: scenario "${title}" has no tests array — add tests entries (SPEC-732)`);
|
|
432
|
+
allBlockers.push(`scenarios_missing_tests: scenario "${title}" has no tests array — add tests entries as a block list (tests: with one "- path" line per test) or an inline array (tests: ["path"]) (SPEC-732)`);
|
|
432
433
|
}
|
|
433
434
|
}
|
|
434
435
|
// SPEC-1214: BDD-criteria executable-evidence gate — when a spec has no frontmatter
|
|
@@ -454,7 +455,18 @@ export async function checkSpecReadiness(spec, mode, projectHash) {
|
|
|
454
455
|
// concrete file paths, function names, or anticipated test breaks.
|
|
455
456
|
const specificityBlockers = checkSpecificityGate(spec, specificityEvidenceLines, fichaContent, anticipatedTestBreaksContent);
|
|
456
457
|
allBlockers.push(...specificityBlockers);
|
|
457
|
-
const
|
|
458
|
+
const scoreAfterSpecificityGate = specificityBlockers.length > 0 && spec.difficulty >= 3 ? Math.min(totalScore, 60) : totalScore;
|
|
459
|
+
// SPEC-1702: strict mode cross-checks premise claims about done/approved sibling
|
|
460
|
+
// specs against those siblings' own contracts and caps the score below 100.
|
|
461
|
+
const crossSpecPremiseFindings = mode === 'strict'
|
|
462
|
+
? await detectCrossSpecPremiseContradictions(spec.id, huRaw, projectPathFromSpecPath(spec.specPath))
|
|
463
|
+
: [];
|
|
464
|
+
for (const finding of crossSpecPremiseFindings) {
|
|
465
|
+
allBlockers.push(`cross-spec-premise: ${finding.targetSpecId} claims "${finding.targetSentence}" but ${finding.siblingSpecId} states "${finding.siblingSentence}" (shared file: ${finding.sharedFilePath})`);
|
|
466
|
+
}
|
|
467
|
+
const effectiveScore = crossSpecPremiseFindings.length > 0
|
|
468
|
+
? Math.min(scoreAfterSpecificityGate, 90)
|
|
469
|
+
: scoreAfterSpecificityGate;
|
|
458
470
|
const breakdown = {
|
|
459
471
|
hu: hu.points,
|
|
460
472
|
criteria: criteria.points,
|
|
@@ -2,12 +2,28 @@
|
|
|
2
2
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
// eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1699
|
|
6
|
+
import { projectRuntimeDir } from '../../storage/storage-layout.js';
|
|
7
|
+
const RUN_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
8
|
+
function assertValidRunId(runId) {
|
|
9
|
+
if (!RUN_ID_PATTERN.test(runId)) {
|
|
10
|
+
throw new Error(`Invalid runId: "${runId}". Run IDs must match ${RUN_ID_PATTERN.source} (no path separators).`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
5
13
|
function getStateDir(projectPath) {
|
|
14
|
+
return projectRuntimeDir(projectPath, 'state');
|
|
15
|
+
}
|
|
16
|
+
function getLegacyStateDir(projectPath) {
|
|
6
17
|
return join(projectPath, 'planu', 'state');
|
|
7
18
|
}
|
|
8
19
|
function getStatePath(projectPath, runId) {
|
|
20
|
+
assertValidRunId(runId);
|
|
9
21
|
return join(getStateDir(projectPath), `sdd-flow-${runId}.json`);
|
|
10
22
|
}
|
|
23
|
+
function getLegacyStatePath(projectPath, runId) {
|
|
24
|
+
assertValidRunId(runId);
|
|
25
|
+
return join(getLegacyStateDir(projectPath), `sdd-flow-${runId}.json`);
|
|
26
|
+
}
|
|
11
27
|
export async function saveCheckpoint(projectPath, state) {
|
|
12
28
|
const dir = getStateDir(projectPath);
|
|
13
29
|
if (!existsSync(dir)) {
|
|
@@ -18,11 +34,22 @@ export async function saveCheckpoint(projectPath, state) {
|
|
|
18
34
|
}
|
|
19
35
|
export async function loadCheckpoint(projectPath, runId) {
|
|
20
36
|
const path = getStatePath(projectPath, runId);
|
|
21
|
-
if (
|
|
37
|
+
if (existsSync(path)) {
|
|
38
|
+
try {
|
|
39
|
+
const raw = await readFile(path, 'utf-8');
|
|
40
|
+
return JSON.parse(raw);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// SPEC-1695: one-time legacy fallback for runs persisted before the relocation.
|
|
47
|
+
const legacyPath = getLegacyStatePath(projectPath, runId);
|
|
48
|
+
if (!existsSync(legacyPath)) {
|
|
22
49
|
return null;
|
|
23
50
|
}
|
|
24
51
|
try {
|
|
25
|
-
const raw = await readFile(
|
|
52
|
+
const raw = await readFile(legacyPath, 'utf-8');
|
|
26
53
|
return JSON.parse(raw);
|
|
27
54
|
}
|
|
28
55
|
catch {
|
|
@@ -4,7 +4,7 @@ export declare function buildCheckpointBlock(snapshot: AutoSessionSnapshot): str
|
|
|
4
4
|
/** Remove old checkpoint sections and prepend the new one to keep the file small. */
|
|
5
5
|
export declare function replaceLatestCheckpoint(existing: string, newBlock: string): string;
|
|
6
6
|
/**
|
|
7
|
-
* Write a compact checkpoint to `<projectPath>/planu/session-context.md`.
|
|
7
|
+
* Write a compact checkpoint to `<projectPath>/planu/.runtime/session-context.md`.
|
|
8
8
|
* Replaces any previous checkpoint section — idempotent.
|
|
9
9
|
* May throw — callers should catch or use maybeWriteCheckpoint.
|
|
10
10
|
*/
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// engine/session/checkpoint-writer.ts — Writes a compact session checkpoint to
|
|
2
|
-
// planu/session-context.md (SPEC-563
|
|
2
|
+
// planu/.runtime/session-context.md (SPEC-563, relocated under SPEC-1699).
|
|
3
|
+
// Fire-and-forget safe — never throws to caller.
|
|
3
4
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
6
|
import { sessionTracker } from './session-tracker.js';
|
|
@@ -56,13 +57,13 @@ export function replaceLatestCheckpoint(existing, newBlock) {
|
|
|
56
57
|
return `${newBlock}\n${trimmed}`.trimEnd() + '\n';
|
|
57
58
|
}
|
|
58
59
|
/**
|
|
59
|
-
* Write a compact checkpoint to `<projectPath>/planu/session-context.md`.
|
|
60
|
+
* Write a compact checkpoint to `<projectPath>/planu/.runtime/session-context.md`.
|
|
60
61
|
* Replaces any previous checkpoint section — idempotent.
|
|
61
62
|
* May throw — callers should catch or use maybeWriteCheckpoint.
|
|
62
63
|
*/
|
|
63
64
|
export async function writeCheckpoint(projectPath, snapshot) {
|
|
64
|
-
const
|
|
65
|
-
const filePath = join(
|
|
65
|
+
const runtimeDir = join(projectPath, 'planu', '.runtime');
|
|
66
|
+
const filePath = join(runtimeDir, 'session-context.md');
|
|
66
67
|
let existing = '';
|
|
67
68
|
try {
|
|
68
69
|
existing = await readFile(filePath, 'utf8');
|
|
@@ -72,7 +73,7 @@ export async function writeCheckpoint(projectPath, snapshot) {
|
|
|
72
73
|
}
|
|
73
74
|
const newBlock = buildCheckpointBlock(snapshot);
|
|
74
75
|
const updated = replaceLatestCheckpoint(existing, newBlock);
|
|
75
|
-
await mkdir(
|
|
76
|
+
await mkdir(runtimeDir, { recursive: true });
|
|
76
77
|
await writeFile(filePath, updated, 'utf8');
|
|
77
78
|
}
|
|
78
79
|
/**
|
|
@@ -4,16 +4,17 @@ import { readFile, mkdir } from 'node:fs/promises';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { execSync } from 'node:child_process';
|
|
6
6
|
import { stableJsonStringify, stableWriteGeneratedFile, stripRootUpdatedAt, } from '../generated-artifact-writer.js';
|
|
7
|
-
|
|
7
|
+
// eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1699
|
|
8
|
+
import { projectRuntimeDir } from '../../storage/storage-layout.js';
|
|
8
9
|
const ROOT_SESSION = 'session.json';
|
|
9
10
|
function stateDir(projectPath) {
|
|
10
|
-
return
|
|
11
|
+
return projectRuntimeDir(projectPath, 'state');
|
|
11
12
|
}
|
|
12
13
|
function specFile(projectPath, specId) {
|
|
13
14
|
return join(stateDir(projectPath), `session-${specId}.json`);
|
|
14
15
|
}
|
|
15
16
|
export function rootFile(projectPath) {
|
|
16
|
-
return join(projectPath, 'planu', ROOT_SESSION);
|
|
17
|
+
return join(projectPath, 'planu', '.runtime', ROOT_SESSION);
|
|
17
18
|
}
|
|
18
19
|
function getModifiedFiles(projectPath) {
|
|
19
20
|
try {
|
|
@@ -166,7 +166,7 @@ function extractCheckboxCriteria(description) {
|
|
|
166
166
|
function extractGivenWhenThenCriteria(description) {
|
|
167
167
|
const criteria = [];
|
|
168
168
|
// Single-line: GIVEN ... WHEN ... THEN ...
|
|
169
|
-
const singleLineRegex = /^[ \t]*(?:[-*>]\s*)?GIVEN\s+.+\s+WHEN\s+.+\s+THEN\s+(.+)$/gim;
|
|
169
|
+
const singleLineRegex = /^[ \t]*(?:[-*>]\s*|\d+[.)]\s*)?GIVEN\s+.+\s+WHEN\s+.+\s+THEN\s+(.+)$/gim;
|
|
170
170
|
let match = singleLineRegex.exec(description);
|
|
171
171
|
while (match) {
|
|
172
172
|
const thenText = match[1]?.trim() ?? '';
|
|
@@ -5,6 +5,13 @@ export declare function isCanonicalPlanuRootDir(name: string): boolean;
|
|
|
5
5
|
export declare function isCanonicalSpecFile(name: string): boolean;
|
|
6
6
|
export declare function isCanonicalSpecDir(name: string): boolean;
|
|
7
7
|
export declare function mustMergeBeforeDeleteSpecFile(name: string): boolean;
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Legacy-transition carve-out ONLY: strict-planu-cleanup.ts still walks a
|
|
10
|
+
* not-yet-migrated planu/releases/ directory and must not delete
|
|
11
|
+
* pending.json before the migration folds it into project.json's
|
|
12
|
+
* `releases` field. This is not part of the canonical contract — see
|
|
13
|
+
* canonicalContractText().
|
|
14
|
+
*/
|
|
15
|
+
export declare function isLegacyReleasePendingFile(relativeToPlanu: string): boolean;
|
|
9
16
|
export declare function canonicalContractText(): string;
|
|
10
17
|
//# sourceMappingURL=planu-canonical-policy.d.ts.map
|
|
@@ -14,18 +14,12 @@ const LEGACY_CONTENT_BEARING_PATTERNS = legacyArtifactRulesLoader
|
|
|
14
14
|
.filter((rule) => rule.classification === 'content-bearing')
|
|
15
15
|
.map((rule) => rule.pattern);
|
|
16
16
|
export const PLANU_CANONICAL_POLICY = {
|
|
17
|
-
canonicalRootFiles: [
|
|
18
|
-
|
|
19
|
-
'context.md',
|
|
20
|
-
'project.json',
|
|
21
|
-
'session-context.md',
|
|
22
|
-
'session.json',
|
|
23
|
-
'technology-selection.json',
|
|
24
|
-
],
|
|
25
|
-
canonicalRootDirs: ['releases', 'specs'],
|
|
17
|
+
canonicalRootFiles: ['project.json'],
|
|
18
|
+
canonicalRootDirs: ['specs'],
|
|
26
19
|
canonicalSpecFiles: ['spec.md'],
|
|
27
20
|
canonicalSpecDirs: [],
|
|
28
21
|
forbiddenHostAssetRootDirs: ['agents', 'skills', 'rules', 'hooks'],
|
|
22
|
+
runtimeHomeDir: 'planu/.runtime/',
|
|
29
23
|
generatedRuntimePatterns: [
|
|
30
24
|
'planu/index.html',
|
|
31
25
|
'planu/roadmap.html',
|
|
@@ -36,6 +30,7 @@ export const PLANU_CANONICAL_POLICY = {
|
|
|
36
30
|
'planu/handoffs/',
|
|
37
31
|
'planu/data/',
|
|
38
32
|
'planu/state/',
|
|
33
|
+
'planu/reports/',
|
|
39
34
|
'planu/.locks/',
|
|
40
35
|
'planu/specs/data/',
|
|
41
36
|
'planu/specs/*/technical-report.html',
|
|
@@ -68,7 +63,14 @@ export function isCanonicalSpecDir(name) {
|
|
|
68
63
|
export function mustMergeBeforeDeleteSpecFile(name) {
|
|
69
64
|
return LEGACY_MERGE_SET.has(name);
|
|
70
65
|
}
|
|
71
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Legacy-transition carve-out ONLY: strict-planu-cleanup.ts still walks a
|
|
68
|
+
* not-yet-migrated planu/releases/ directory and must not delete
|
|
69
|
+
* pending.json before the migration folds it into project.json's
|
|
70
|
+
* `releases` field. This is not part of the canonical contract — see
|
|
71
|
+
* canonicalContractText().
|
|
72
|
+
*/
|
|
73
|
+
export function isLegacyReleasePendingFile(relativeToPlanu) {
|
|
72
74
|
return relativeToPlanu === 'releases/pending.json';
|
|
73
75
|
}
|
|
74
76
|
export function canonicalContractText() {
|
|
@@ -76,15 +78,14 @@ export function canonicalContractText() {
|
|
|
76
78
|
'planu/',
|
|
77
79
|
' conventions.json',
|
|
78
80
|
' context.md',
|
|
79
|
-
' project.json',
|
|
81
|
+
' project.json (pending release records live in its `releases` field)',
|
|
80
82
|
' session-context.md',
|
|
81
83
|
' session.json',
|
|
82
84
|
' technology-selection.json',
|
|
83
|
-
' releases/',
|
|
84
|
-
' pending.json',
|
|
85
85
|
' specs/',
|
|
86
86
|
' SPEC-XXX-slug/',
|
|
87
87
|
' spec.md',
|
|
88
|
+
' .runtime/ (generated runtime home — session/index data, not committed)',
|
|
88
89
|
'',
|
|
89
90
|
'Evidence artifacts are Planu runtime data and are stored outside planu/specs/.',
|
|
90
91
|
'',
|
|
@@ -8,8 +8,21 @@ import { dirname, isAbsolute, join, relative } from 'node:path';
|
|
|
8
8
|
import { atomicWriteFile } from '../safety/atomic-write-file.js';
|
|
9
9
|
import { safeUnlink } from './git-aware-fs.js';
|
|
10
10
|
import { scanLegacyArtifacts } from './legacy-classifier.js';
|
|
11
|
-
import { PLANU_CANONICAL_POLICY, canonicalContractText, isCanonicalPlanuRootDir, isCanonicalPlanuRootFile,
|
|
11
|
+
import { PLANU_CANONICAL_POLICY, canonicalContractText, isCanonicalPlanuRootDir, isCanonicalPlanuRootFile, isLegacyReleasePendingFile, isCanonicalSpecDir, isCanonicalSpecFile, mustMergeBeforeDeleteSpecFile, } from './planu-canonical-policy.js';
|
|
12
12
|
const execFileAsync = promisify(execFile);
|
|
13
|
+
// SPEC-1699: legacy planu/ root files folded into project.json by init_project's
|
|
14
|
+
// migration. Destructive cleanup must never delete them outright — only the
|
|
15
|
+
// migration step, which reads their content first, may retire them.
|
|
16
|
+
const PENDING_ROOT_MIGRATION_FILES = new Set([
|
|
17
|
+
'conventions.json',
|
|
18
|
+
'context.md',
|
|
19
|
+
'session-context.md',
|
|
20
|
+
'session.json',
|
|
21
|
+
'technology-selection.json',
|
|
22
|
+
]);
|
|
23
|
+
function isPendingRootMigrationFile(entry) {
|
|
24
|
+
return PENDING_ROOT_MIGRATION_FILES.has(entry);
|
|
25
|
+
}
|
|
13
26
|
async function pathIsDirectory(path) {
|
|
14
27
|
try {
|
|
15
28
|
return (await stat(path)).isDirectory();
|
|
@@ -176,11 +189,23 @@ export async function runStrictPlanuCleanup(projectPath, authority = { mode: 're
|
|
|
176
189
|
for (const entry of entries) {
|
|
177
190
|
const full = join(planuDir, entry);
|
|
178
191
|
const isDir = await pathIsDirectory(full);
|
|
192
|
+
if (isDir && entry === 'releases') {
|
|
193
|
+
// SPEC-1699: 'releases' is no longer a canonical root dir, but its
|
|
194
|
+
// pending.json still needs the dedicated fold-into-project.json
|
|
195
|
+
// migration below before its directory can be removed.
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
179
198
|
if (isDir && !isCanonicalPlanuRootDir(entry)) {
|
|
180
199
|
await removePath(projectPath, full);
|
|
181
200
|
result.deleted.push(relative(projectPath, full));
|
|
182
201
|
continue;
|
|
183
202
|
}
|
|
203
|
+
if (!isDir && isPendingRootMigrationFile(entry)) {
|
|
204
|
+
// SPEC-1699: content-bearing legacy root file — init_project's migration
|
|
205
|
+
// folds it into project.json before this cleanup may delete it.
|
|
206
|
+
result.proposed.push(relative(projectPath, full));
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
184
209
|
if (!isDir && !isCanonicalPlanuRootFile(entry)) {
|
|
185
210
|
await removePath(projectPath, full);
|
|
186
211
|
result.deleted.push(relative(projectPath, full));
|
|
@@ -189,7 +214,7 @@ export async function runStrictPlanuCleanup(projectPath, authority = { mode: 're
|
|
|
189
214
|
const releasesDir = join(planuDir, 'releases');
|
|
190
215
|
for (const entry of await readDirectory(releasesDir)) {
|
|
191
216
|
const rel = `releases/${entry}`;
|
|
192
|
-
if (!
|
|
217
|
+
if (!isLegacyReleasePendingFile(rel)) {
|
|
193
218
|
const full = join(releasesDir, entry);
|
|
194
219
|
await removePath(projectPath, full);
|
|
195
220
|
result.deleted.push(relative(projectPath, full));
|
|
@@ -235,7 +260,7 @@ export async function validateStrictPlanuLayout(projectPath, options = {}) {
|
|
|
235
260
|
}
|
|
236
261
|
for (const entry of await readDirectory(join(planuDir, 'releases'))) {
|
|
237
262
|
const rel = `releases/${entry}`;
|
|
238
|
-
if (!
|
|
263
|
+
if (!isLegacyReleasePendingFile(rel)) {
|
|
239
264
|
offenders.push(`planu/${rel}`);
|
|
240
265
|
}
|
|
241
266
|
}
|
|
@@ -8,20 +8,20 @@ export declare function isErrorReportingEnabled(): boolean;
|
|
|
8
8
|
*/
|
|
9
9
|
export declare function sanitizeErrorMessage(message: string): string;
|
|
10
10
|
/**
|
|
11
|
-
* Classifies a telemetry event as
|
|
12
|
-
* Gate blocks (DoD, validate score, spec locked, invalid transition) →
|
|
13
|
-
* Unhandled exceptions →
|
|
11
|
+
* Classifies a telemetry event as blocked or error, mapping onto the envelope's `result` field.
|
|
12
|
+
* Gate blocks (DoD, validate score, spec locked, invalid transition) → blocked.
|
|
13
|
+
* Unhandled exceptions → error.
|
|
14
14
|
*/
|
|
15
|
-
export declare function classifyToolEvent(errorMessage: string, errorType?: string): '
|
|
15
|
+
export declare function classifyToolEvent(errorMessage: string, errorType?: string): 'error' | 'blocked';
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
17
|
+
* Emits an mcp_tool_failed envelope for an unhandled exception. Fire-and-forget — never throws.
|
|
18
|
+
* The envelope has no message field in Phase 1, so no error text is ever transmitted.
|
|
18
19
|
* Call this from safe-handler catch blocks after returning isError:true to the user.
|
|
19
20
|
*/
|
|
20
|
-
export declare function reportToolError(toolName: string,
|
|
21
|
+
export declare function reportToolError(toolName: string, _error: unknown): void;
|
|
21
22
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* Other expected errors (missing params, not-found, etc.) emit tool_error.
|
|
23
|
+
* Emits an mcp_tool_failed envelope for a business-logic validation failure, classifying
|
|
24
|
+
* `result` as blocked (gate/policy blocks) or error (other expected failures).
|
|
25
25
|
* Fire-and-forget — never throws.
|
|
26
26
|
*/
|
|
27
27
|
export declare function reportToolValidationError(toolName: string, message: string): void;
|