@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,3 +1,11 @@
|
|
|
1
|
+
/* eslint-disable max-lines -- done evidence gates remain co-located for one auditable fail-closed boundary */
|
|
2
|
+
// tools/update-status/dod-gates.ts — DoD gate checks and forceStatus bypass
|
|
3
|
+
// SPEC-729: Validate gate wrapped with withEscalation for bounded retries.
|
|
4
|
+
// SPEC-780: checkApprovedFormatGate honours forceApprove, mirroring SPEC-769 readiness gate.
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { constants } from 'node:fs';
|
|
7
|
+
import { lstat, open, realpath } from 'node:fs/promises';
|
|
8
|
+
import { isAbsolute, relative, resolve as resolvePath, sep } from 'node:path';
|
|
1
9
|
import { validateSpec } from '../../engine/validator.js';
|
|
2
10
|
import { generateDoD } from '../../engine/validator/dor-dod.js';
|
|
3
11
|
import { addLesson, getLessons } from '../../storage/lessons-store.js';
|
|
@@ -9,6 +17,8 @@ import { writeSpecReviewFeedback } from '../../engine/validator/spec-review-writ
|
|
|
9
17
|
import { formatKeyValue } from '../output-formatter.js';
|
|
10
18
|
import { reportClassifiedDegradation } from '../../errors/classified-degradation.js';
|
|
11
19
|
import { verifyCurrentDoneReceipt } from './done-receipt-verifier.js';
|
|
20
|
+
import { projectDataDir } from '../../storage/base-store.js';
|
|
21
|
+
import { ValidationReportV1Schema } from '../../engine/handoff-artifacts/schemas.js';
|
|
12
22
|
export { completedJobPublishesReceipt } from './done-receipt-verifier.js';
|
|
13
23
|
/**
|
|
14
24
|
* SPEC-721 / SPEC-222 Trigger 1: Run validate engine before marking done.
|
|
@@ -275,6 +285,176 @@ function validationReceiptGateError(specId, reason) {
|
|
|
275
285
|
},
|
|
276
286
|
};
|
|
277
287
|
}
|
|
288
|
+
function doneReviewDigestError(specId, code, message) {
|
|
289
|
+
const artifactPath = `external Planu project data: handoffs/${specId}/validation-report.json`;
|
|
290
|
+
const fixHint = 'Run validate, then recompute the sha256 digest from the exact report bytes.';
|
|
291
|
+
return {
|
|
292
|
+
content: [{ type: 'text', text: `${code}: ${message}\nFix: ${fixHint}` }],
|
|
293
|
+
isError: true,
|
|
294
|
+
structuredContent: {
|
|
295
|
+
error: code,
|
|
296
|
+
code: 422,
|
|
297
|
+
context: { specId, artifactPath },
|
|
298
|
+
fixHint,
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
const MAX_VALIDATION_REPORT_BYTES = 5 * 1024 * 1024;
|
|
303
|
+
function isContained(root, candidate) {
|
|
304
|
+
const pathFromRoot = relative(root, candidate);
|
|
305
|
+
return (pathFromRoot === '' ||
|
|
306
|
+
(!isAbsolute(pathFromRoot) && pathFromRoot !== '..' && !pathFromRoot.startsWith(`..${sep}`)));
|
|
307
|
+
}
|
|
308
|
+
function sameFileMetadata(left, right) {
|
|
309
|
+
return (left.dev === right.dev &&
|
|
310
|
+
left.ino === right.ino &&
|
|
311
|
+
left.size === right.size &&
|
|
312
|
+
left.mtimeNs === right.mtimeNs &&
|
|
313
|
+
left.ctimeNs === right.ctimeNs);
|
|
314
|
+
}
|
|
315
|
+
async function readStableDoneReviewReport(specId, projectId) {
|
|
316
|
+
const handoffsRoot = resolvePath(projectDataDir(projectId), 'handoffs');
|
|
317
|
+
const specRoot = resolvePath(handoffsRoot, specId);
|
|
318
|
+
const reportPath = resolvePath(specRoot, 'validation-report.json');
|
|
319
|
+
if (!isContained(handoffsRoot, specRoot) || !isContained(specRoot, reportPath)) {
|
|
320
|
+
return doneReviewDigestError(specId, 'DONE_REVIEW_REPORT_UNSAFE', 'The validation report path escapes its contained handoff directory.');
|
|
321
|
+
}
|
|
322
|
+
let canonicalHandoffs;
|
|
323
|
+
let canonicalSpecRoot;
|
|
324
|
+
let canonicalReport;
|
|
325
|
+
let pathStats;
|
|
326
|
+
try {
|
|
327
|
+
[canonicalHandoffs, canonicalSpecRoot, canonicalReport, pathStats] = await Promise.all([
|
|
328
|
+
realpath(handoffsRoot),
|
|
329
|
+
realpath(specRoot),
|
|
330
|
+
realpath(reportPath),
|
|
331
|
+
lstat(reportPath, { bigint: true }),
|
|
332
|
+
]);
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
return {
|
|
336
|
+
...doneReviewDigestError(specId, 'DONE_REVIEW_DIGEST_MISMATCH', 'No persisted validation report was found for this spec. Run validate first, then retry done.'),
|
|
337
|
+
isError: true,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
if (!isContained(canonicalHandoffs, canonicalSpecRoot) ||
|
|
341
|
+
!isContained(canonicalSpecRoot, canonicalReport) ||
|
|
342
|
+
pathStats.isSymbolicLink() ||
|
|
343
|
+
!pathStats.isFile() ||
|
|
344
|
+
pathStats.size > BigInt(MAX_VALIDATION_REPORT_BYTES)) {
|
|
345
|
+
return doneReviewDigestError(specId, 'DONE_REVIEW_REPORT_UNSAFE', 'The validation report is not a contained bounded regular file.');
|
|
346
|
+
}
|
|
347
|
+
if (typeof constants.O_NOFOLLOW !== 'number') {
|
|
348
|
+
return doneReviewDigestError(specId, 'DONE_REVIEW_REPORT_UNSAFE', 'The platform cannot safely open the validation report without following links.');
|
|
349
|
+
}
|
|
350
|
+
let handle;
|
|
351
|
+
try {
|
|
352
|
+
handle = await open(reportPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return {
|
|
356
|
+
...doneReviewDigestError(specId, 'DONE_REVIEW_REPORT_UNSAFE', 'The validation report could not be opened without following links.'),
|
|
357
|
+
isError: true,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
const openedStats = await handle.stat({ bigint: true });
|
|
362
|
+
if (!openedStats.isFile() || !sameFileMetadata(pathStats, openedStats)) {
|
|
363
|
+
return doneReviewDigestError(specId, 'DONE_REVIEW_REPORT_CHANGED', 'The validation report identity changed before it was opened.');
|
|
364
|
+
}
|
|
365
|
+
const reportBytes = Buffer.alloc(Number(openedStats.size));
|
|
366
|
+
let offset = 0;
|
|
367
|
+
while (offset < reportBytes.length) {
|
|
368
|
+
const { bytesRead } = await handle.read(reportBytes, offset, reportBytes.length - offset, offset);
|
|
369
|
+
if (bytesRead === 0) {
|
|
370
|
+
return doneReviewDigestError(specId, 'DONE_REVIEW_REPORT_CHANGED', 'The validation report changed while it was being read.');
|
|
371
|
+
}
|
|
372
|
+
offset += bytesRead;
|
|
373
|
+
}
|
|
374
|
+
const finalHandleStats = await handle.stat({ bigint: true });
|
|
375
|
+
if (!sameFileMetadata(openedStats, finalHandleStats)) {
|
|
376
|
+
return doneReviewDigestError(specId, 'DONE_REVIEW_REPORT_CHANGED', 'The opened validation report changed while it was being read.');
|
|
377
|
+
}
|
|
378
|
+
let finalPathStats;
|
|
379
|
+
let finalCanonicalReport;
|
|
380
|
+
try {
|
|
381
|
+
[finalPathStats, finalCanonicalReport] = await Promise.all([
|
|
382
|
+
lstat(reportPath, { bigint: true }),
|
|
383
|
+
realpath(reportPath),
|
|
384
|
+
]);
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return {
|
|
388
|
+
...doneReviewDigestError(specId, 'DONE_REVIEW_REPORT_CHANGED', 'The validation report path changed while it was being read.'),
|
|
389
|
+
isError: true,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
if (!isContained(canonicalSpecRoot, finalCanonicalReport)) {
|
|
393
|
+
return doneReviewDigestError(specId, 'DONE_REVIEW_REPORT_UNSAFE', 'The validation report path escaped containment while it was being read.');
|
|
394
|
+
}
|
|
395
|
+
if (finalPathStats.isSymbolicLink() ||
|
|
396
|
+
!finalPathStats.isFile() ||
|
|
397
|
+
!sameFileMetadata(openedStats, finalPathStats)) {
|
|
398
|
+
return doneReviewDigestError(specId, 'DONE_REVIEW_REPORT_CHANGED', 'The validation report path identity changed while it was being read.');
|
|
399
|
+
}
|
|
400
|
+
return reportBytes;
|
|
401
|
+
}
|
|
402
|
+
finally {
|
|
403
|
+
await handle.close();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* SPEC-1356 (T-A1): auto-derive the sha256 digest of the persisted
|
|
408
|
+
* `validation-report.json` so callers no longer have to compute it by hand.
|
|
409
|
+
* Reads and hashes the exact same bytes `checkDoneReviewDigest` would validate
|
|
410
|
+
* against, using the same hardened path/identity checks. Returns the digest, or
|
|
411
|
+
* the exact blocking `ToolResult` when the report cannot be safely read (e.g.
|
|
412
|
+
* validate has not run yet). Exported for direct unit testing and for the
|
|
413
|
+
* orchestration layer (`update-status/index.ts`) that no longer requires the
|
|
414
|
+
* caller to supply `implementationReviewDigest`.
|
|
415
|
+
*/
|
|
416
|
+
export async function deriveDoneReviewDigest(specId, projectId) {
|
|
417
|
+
const reportResult = await readStableDoneReviewReport(specId, projectId);
|
|
418
|
+
if (!Buffer.isBuffer(reportResult)) {
|
|
419
|
+
return { error: reportResult };
|
|
420
|
+
}
|
|
421
|
+
return { digest: `sha256:${createHash('sha256').update(reportResult).digest('hex')}` };
|
|
422
|
+
}
|
|
423
|
+
async function checkDoneReviewDigest(specId, projectId, implementationReviewDigest) {
|
|
424
|
+
if (!/^sha256:[a-f0-9]{64}$/u.test(implementationReviewDigest)) {
|
|
425
|
+
return doneReviewDigestError(specId, 'DONE_REVIEW_DIGEST_INVALID', 'The implementation review digest is malformed.');
|
|
426
|
+
}
|
|
427
|
+
const reportResult = await readStableDoneReviewReport(specId, projectId);
|
|
428
|
+
if (!Buffer.isBuffer(reportResult)) {
|
|
429
|
+
return reportResult;
|
|
430
|
+
}
|
|
431
|
+
const observed = `sha256:${createHash('sha256').update(reportResult).digest('hex')}`;
|
|
432
|
+
if (observed !== implementationReviewDigest) {
|
|
433
|
+
return doneReviewDigestError(specId, 'DONE_REVIEW_DIGEST_MISMATCH', 'The implementation review digest does not match the persisted validation report.');
|
|
434
|
+
}
|
|
435
|
+
let decoded;
|
|
436
|
+
try {
|
|
437
|
+
decoded = JSON.parse(reportResult.toString('utf8'));
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
return {
|
|
441
|
+
...doneReviewDigestError(specId, 'DONE_REVIEW_REPORT_UNSAFE', 'The validation report bytes are not valid JSON.'),
|
|
442
|
+
isError: true,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
const parsed = ValidationReportV1Schema.safeParse(decoded);
|
|
446
|
+
if (!parsed.success ||
|
|
447
|
+
parsed.data.specId !== specId ||
|
|
448
|
+
!parsed.data.passed ||
|
|
449
|
+
parsed.data.gates.some((gate) => !gate.passed) ||
|
|
450
|
+
parsed.data.minimalityReport?.blocked === true ||
|
|
451
|
+
parsed.data.reviewer.kind !== 'implementation-review-agent' ||
|
|
452
|
+
parsed.data.reviewer.agent !== 'planu-implementation-reviewer' ||
|
|
453
|
+
parsed.data.reviewer.verdict !== 'approved') {
|
|
454
|
+
return doneReviewDigestError(specId, 'DONE_REVIEW_REPORT_UNSAFE', 'The validation report schema or independent reviewer evidence is invalid.');
|
|
455
|
+
}
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
278
458
|
/**
|
|
279
459
|
* SPEC-725: Check the validation-report artifact gate before marking done.
|
|
280
460
|
* SPEC-1050: Fail closed. A done transition requires a fresh validation-report
|
|
@@ -493,8 +673,18 @@ function specReviewGateError(args) {
|
|
|
493
673
|
* When force=true, gates are not blocking but failing items are recorded in
|
|
494
674
|
* the project's force-bypass-log.json for traceability (guard: done ≠ implemented).
|
|
495
675
|
*/
|
|
496
|
-
export async function checkDoneGates(spec, specId, projectId, projectPath, _force, _forceReason = 'No force reason provided', verifyReceipt = verifyCurrentDoneReceipt) {
|
|
676
|
+
export async function checkDoneGates(spec, specId, projectId, projectPath, _force, _forceReason = 'No force reason provided', verifyReceipt = verifyCurrentDoneReceipt, implementationReviewDigest) {
|
|
497
677
|
try {
|
|
678
|
+
if (implementationReviewDigest !== undefined) {
|
|
679
|
+
const digestError = await checkDoneReviewDigest(specId, projectId, implementationReviewDigest);
|
|
680
|
+
if (digestError) {
|
|
681
|
+
return {
|
|
682
|
+
blocked: digestError,
|
|
683
|
+
forcedBypassWarning: null,
|
|
684
|
+
freshnessLease: null,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
}
|
|
498
688
|
const result = await verifyReceipt({ projectId, specId, projectPath, specPath: spec.specPath });
|
|
499
689
|
return result.valid
|
|
500
690
|
? {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type DurableJobRecord } from '../../storage/runtime-db.js';
|
|
2
2
|
import { type ValidationFreshnessLease } from '../../engine/validation/validation-freshness.js';
|
|
3
|
+
import type { ValidationReceiptBindings } from '../../types/validation-receipt.js';
|
|
3
4
|
export type DoneReceiptVerifier = (identity: {
|
|
4
5
|
projectId: string;
|
|
5
6
|
specId: string;
|
|
@@ -11,5 +12,12 @@ export type DoneReceiptVerifier = (identity: {
|
|
|
11
12
|
freshnessLease?: ValidationFreshnessLease;
|
|
12
13
|
}>;
|
|
13
14
|
export declare function completedJobPublishesReceipt(job: DurableJobRecord | undefined, receiptId: string): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Evidence artifacts (discovery/task-plan/traceability/contract-validation) may legitimately
|
|
17
|
+
* be regenerated after a receipt was issued (e.g. autopilot skeleton fill). If ONLY the evidence
|
|
18
|
+
* digests changed while every code-facing binding stayed byte-identical, the receipt is rebound
|
|
19
|
+
* to the freshly observed evidence digests instead of being rejected as stale.
|
|
20
|
+
*/
|
|
21
|
+
export declare function doneReceiptBindingsDifferOnlyInEvidence(observed: ValidationReceiptBindings | undefined, stored: ValidationReceiptBindings | undefined): boolean;
|
|
14
22
|
export declare function verifyCurrentDoneReceipt(identity: Parameters<DoneReceiptVerifier>[0]): ReturnType<DoneReceiptVerifier>;
|
|
15
23
|
//# sourceMappingURL=done-receipt-verifier.d.ts.map
|
|
@@ -70,6 +70,31 @@ export function completedJobPublishesReceipt(job, receiptId) {
|
|
|
70
70
|
return (published.receiptId === receiptId &&
|
|
71
71
|
published.receiptKey === `validation-receipt:${receiptId.slice('sha256:'.length)}`);
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Evidence artifacts (discovery/task-plan/traceability/contract-validation) may legitimately
|
|
75
|
+
* be regenerated after a receipt was issued (e.g. autopilot skeleton fill). If ONLY the evidence
|
|
76
|
+
* digests changed while every code-facing binding stayed byte-identical, the receipt is rebound
|
|
77
|
+
* to the freshly observed evidence digests instead of being rejected as stale.
|
|
78
|
+
*/
|
|
79
|
+
export function doneReceiptBindingsDifferOnlyInEvidence(observed, stored) {
|
|
80
|
+
if (!observed?.artifactDigests || !stored?.artifactDigests) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
const codeBindingsMatch = observed.projectId === stored.projectId &&
|
|
84
|
+
observed.specId === stored.specId &&
|
|
85
|
+
observed.sourceDigest === stored.sourceDigest &&
|
|
86
|
+
observed.specDigest === stored.specDigest &&
|
|
87
|
+
observed.policyDigest === stored.policyDigest &&
|
|
88
|
+
observed.toolchainDigest === stored.toolchainDigest &&
|
|
89
|
+
observed.artifactDigests.approvedSpec === stored.artifactDigests.approvedSpec;
|
|
90
|
+
if (!codeBindingsMatch) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
return (observed.artifactDigests.discovery !== stored.artifactDigests.discovery ||
|
|
94
|
+
observed.artifactDigests.taskPlan !== stored.artifactDigests.taskPlan ||
|
|
95
|
+
observed.artifactDigests.traceability !== stored.artifactDigests.traceability ||
|
|
96
|
+
observed.artifactDigests.contractValidation !== stored.artifactDigests.contractValidation);
|
|
97
|
+
}
|
|
73
98
|
export async function verifyCurrentDoneReceipt(identity) {
|
|
74
99
|
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
75
100
|
try {
|
|
@@ -127,8 +152,18 @@ export async function verifyCurrentDoneReceipt(identity) {
|
|
|
127
152
|
specPath,
|
|
128
153
|
});
|
|
129
154
|
const observedBindings = toValidationReceiptBindings(observed.bindings, observed.artifactDigests);
|
|
130
|
-
if (JSON.stringify(observedBindings) !== JSON.stringify(currentBindings)
|
|
131
|
-
!(
|
|
155
|
+
if (JSON.stringify(observedBindings) !== JSON.stringify(currentBindings)) {
|
|
156
|
+
if (!doneReceiptBindingsDifferOnlyInEvidence(observedBindings, currentBindings)) {
|
|
157
|
+
return { valid: false, reason: 'stale-bindings' };
|
|
158
|
+
}
|
|
159
|
+
await store.projectCurrentBindings(observedBindings);
|
|
160
|
+
console.warn('[planu:evidence-rebind]', {
|
|
161
|
+
projectId: identity.projectId,
|
|
162
|
+
specId: identity.specId,
|
|
163
|
+
rebindAt: new Date().toISOString(),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
if (!(await bindValidationFreshnessLease(ownedLease, observedBindings))) {
|
|
132
167
|
return { valid: false, reason: 'stale-bindings' };
|
|
133
168
|
}
|
|
134
169
|
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { ToolResult } from '../../types/index.js';
|
|
2
2
|
import type { Spec } from '../../types/spec/core.js';
|
|
3
3
|
import type { EvidenceGateTransition } from '../../types/evidence-gates.js';
|
|
4
|
+
/** SPEC-1356: repo-relative paths mentioned in the spec's Problem/Technical prose also count
|
|
5
|
+
* as approved canonical scope, even when they are not in the file-ownership checklist. Paths
|
|
6
|
+
* do not need to exist yet (a spec may name files it has not created). */
|
|
7
|
+
export declare function extractCanonicalScopePathsFromSections(body: string): string[];
|
|
4
8
|
/** SPEC-1054: BDD/SDD lifecycle evidence gate. */
|
|
5
9
|
export declare function checkLifecycleEvidenceTransitionGate(args: {
|
|
6
10
|
spec: Spec;
|
|
@@ -68,6 +68,18 @@ import { captureValidationFreshnessLease } from '../../engine/validation/validat
|
|
|
68
68
|
import { computeDurableValidationBindings, toValidationReceiptBindings, } from '../../engine/validation/durable-validation.js';
|
|
69
69
|
import { checkReconciliationFreshness } from '../../engine/evidence-gates/reconciliation-freshness.js';
|
|
70
70
|
import { extractCanonicalFileOwnership } from '../../engine/handoff-packager.js';
|
|
71
|
+
import { generateDiscoverySkeleton, generateTaskPlanSkeleton, writeEvidenceSkeleton, } from '../../engine/evidence-gates/evidence-skeletons.js';
|
|
72
|
+
import { extractSection } from '../../engine/spec-format/markdown-sections.js';
|
|
73
|
+
const CANONICAL_SCOPE_PATH = /(?:src|tests|scripts|website)\/[\w/.@-]+\.\w+/g;
|
|
74
|
+
/** SPEC-1356: repo-relative paths mentioned in the spec's Problem/Technical prose also count
|
|
75
|
+
* as approved canonical scope, even when they are not in the file-ownership checklist. Paths
|
|
76
|
+
* do not need to exist yet (a spec may name files it has not created). */
|
|
77
|
+
export function extractCanonicalScopePathsFromSections(body) {
|
|
78
|
+
const problem = extractSection(body, 'problem') ?? '';
|
|
79
|
+
const technical = extractSection(body, 'technical') ?? '';
|
|
80
|
+
const matches = `${problem}\n${technical}`.match(CANONICAL_SCOPE_PATH) ?? [];
|
|
81
|
+
return [...new Set(matches)];
|
|
82
|
+
}
|
|
71
83
|
/** SPEC-1054: BDD/SDD lifecycle evidence gate. */
|
|
72
84
|
export async function checkLifecycleEvidenceTransitionGate(args) {
|
|
73
85
|
const staleReconciliationEvidence = await checkReconciliationFreshness({
|
|
@@ -85,7 +97,7 @@ export async function checkLifecycleEvidenceTransitionGate(args) {
|
|
|
85
97
|
requiredContractKinds: [],
|
|
86
98
|
});
|
|
87
99
|
}
|
|
88
|
-
const [criteriaResult,
|
|
100
|
+
const [criteriaResult, initialArtifacts] = await Promise.all([
|
|
89
101
|
extractLifecycleGateCriteriaAndBody(args.spec, args.projectPath),
|
|
90
102
|
readEvidenceArtifacts({
|
|
91
103
|
spec: args.spec,
|
|
@@ -94,6 +106,16 @@ export async function checkLifecycleEvidenceTransitionGate(args) {
|
|
|
94
106
|
projectPath: args.projectPath,
|
|
95
107
|
}),
|
|
96
108
|
]);
|
|
109
|
+
const { criteria, body } = criteriaResult;
|
|
110
|
+
const artifacts = await autofillMissingEvidenceSkeletons({
|
|
111
|
+
spec: args.spec,
|
|
112
|
+
projectId: args.projectId,
|
|
113
|
+
specId: args.specId,
|
|
114
|
+
transition: args.transition,
|
|
115
|
+
criteria,
|
|
116
|
+
body,
|
|
117
|
+
artifacts: initialArtifacts,
|
|
118
|
+
});
|
|
97
119
|
const receiptResult = args.transition === 'done'
|
|
98
120
|
? await readVerifiedCurrentReceipt({
|
|
99
121
|
projectId: args.projectId,
|
|
@@ -110,7 +132,6 @@ export async function checkLifecycleEvidenceTransitionGate(args) {
|
|
|
110
132
|
requiredContractKinds: [],
|
|
111
133
|
});
|
|
112
134
|
}
|
|
113
|
-
const { criteria, body } = criteriaResult;
|
|
114
135
|
const result = checkLifecycleEvidenceGate({
|
|
115
136
|
transition: args.transition,
|
|
116
137
|
spec: args.spec,
|
|
@@ -129,12 +150,14 @@ export async function checkLifecycleEvidenceTransitionGate(args) {
|
|
|
129
150
|
? extractCanonicalFileOwnership(body)
|
|
130
151
|
: { present: false, toCreate: [], toModify: [], toTest: [], blockers: [] };
|
|
131
152
|
const groundedTechnicalPaths = body ? parseGroundedTechnicalPaths(body) : [];
|
|
153
|
+
const problemTechnicalScopePaths = body ? extractCanonicalScopePathsFromSections(body) : [];
|
|
132
154
|
const approvedScopePaths = [
|
|
133
155
|
...new Set([
|
|
134
156
|
...canonicalOwnership.toCreate,
|
|
135
157
|
...canonicalOwnership.toModify,
|
|
136
158
|
...canonicalOwnership.toTest,
|
|
137
159
|
...groundedTechnicalPaths,
|
|
160
|
+
...problemTechnicalScopePaths,
|
|
138
161
|
]),
|
|
139
162
|
];
|
|
140
163
|
if (canonicalOwnership.blockers.length > 0) {
|
|
@@ -159,6 +182,48 @@ export async function checkLifecycleEvidenceTransitionGate(args) {
|
|
|
159
182
|
requiredContractKinds: result.requiredContractKinds,
|
|
160
183
|
});
|
|
161
184
|
}
|
|
185
|
+
/** True when the artifact truly has no file yet, as opposed to failing schema validation. */
|
|
186
|
+
function isArtifactAbsent(artifacts, label) {
|
|
187
|
+
return !artifacts.invalidArtifacts.some((entry) => entry.startsWith(label));
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* SPEC-1356: self-heal missing (not malformed) discovery/task-plan handoff evidence by
|
|
191
|
+
* generating a schema-valid skeleton and persisting it, instead of blocking the transition
|
|
192
|
+
* on a manual authoring step. Malformed existing artifacts still fail the gate as before.
|
|
193
|
+
*/
|
|
194
|
+
async function autofillMissingEvidenceSkeletons(args) {
|
|
195
|
+
if (args.spec.scope === 'trivial') {
|
|
196
|
+
return args.artifacts;
|
|
197
|
+
}
|
|
198
|
+
let artifacts = args.artifacts;
|
|
199
|
+
if (args.transition === 'approved' &&
|
|
200
|
+
!artifacts.discovery &&
|
|
201
|
+
isArtifactAbsent(artifacts, 'Discovery evidence')) {
|
|
202
|
+
const skeleton = generateDiscoverySkeleton({ spec: args.spec, body: args.body });
|
|
203
|
+
await writeEvidenceSkeleton({
|
|
204
|
+
projectId: args.projectId,
|
|
205
|
+
specId: args.specId,
|
|
206
|
+
filename: 'discovery.json',
|
|
207
|
+
artifact: skeleton,
|
|
208
|
+
});
|
|
209
|
+
console.warn('[planu:evidence-skeleton]', { specId: args.specId, filename: 'discovery.json' });
|
|
210
|
+
artifacts = { ...artifacts, discovery: skeleton };
|
|
211
|
+
}
|
|
212
|
+
if (args.transition === 'implementing' &&
|
|
213
|
+
!artifacts.taskPlan &&
|
|
214
|
+
isArtifactAbsent(artifacts, 'Task plan evidence')) {
|
|
215
|
+
const skeleton = generateTaskPlanSkeleton({ criteria: args.criteria });
|
|
216
|
+
await writeEvidenceSkeleton({
|
|
217
|
+
projectId: args.projectId,
|
|
218
|
+
specId: args.specId,
|
|
219
|
+
filename: 'task-plan.json',
|
|
220
|
+
artifact: skeleton,
|
|
221
|
+
});
|
|
222
|
+
console.warn('[planu:evidence-skeleton]', { specId: args.specId, filename: 'task-plan.json' });
|
|
223
|
+
artifacts = { ...artifacts, taskPlan: skeleton };
|
|
224
|
+
}
|
|
225
|
+
return artifacts;
|
|
226
|
+
}
|
|
162
227
|
async function readVerifiedCurrentReceipt(identity) {
|
|
163
228
|
try {
|
|
164
229
|
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { SpecStatus,
|
|
1
|
+
import type { SpecStatus, Actuals } from '../../types/index.js';
|
|
2
2
|
import type { Spec } from '../../types/spec/core.js';
|
|
3
|
-
export declare function recordDoneMetrics(projectId: string, specId: string, spec: Spec, actuals:
|
|
3
|
+
export declare function recordDoneMetrics(projectId: string, specId: string, spec: Spec, actuals: Actuals, projectPath?: string): Promise<void>;
|
|
4
4
|
/**
|
|
5
5
|
* SPEC-698: A frontmatter sync warning surfaced when `update_status` could not
|
|
6
6
|
* keep the on-disk spec.md status in sync with the data store.
|
|
@@ -1,8 +1,30 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
-
import type { UpdateStatusInput, ToolResult } from '../../types/index.js';
|
|
2
|
+
import type { UpdateStatusInput, ToolResult, Actuals, ReconciliationInvocationContext } from '../../types/index.js';
|
|
3
|
+
type LifecycleAuthorityRoute = {
|
|
4
|
+
route: 'ordinary';
|
|
5
|
+
} | {
|
|
6
|
+
route: 'reconcile';
|
|
7
|
+
} | {
|
|
8
|
+
route: 'error';
|
|
9
|
+
result: ToolResult;
|
|
10
|
+
};
|
|
11
|
+
/** Select the lifecycle path before any transition, artifact, receipt, or log mutation. */
|
|
12
|
+
export declare function classifyLifecycleAuthority(input: UpdateStatusInput, context: ReconciliationInvocationContext, options?: {
|
|
13
|
+
requireDoneDigest?: boolean;
|
|
14
|
+
}): LifecycleAuthorityRoute;
|
|
15
|
+
/**
|
|
16
|
+
* SPEC-1356 (T-A2): a `done` transition no longer requires the caller to supply
|
|
17
|
+
* actuals. When the whole object is omitted, synthesize a minimal record instead
|
|
18
|
+
* of blocking the transition with `actuals_required`. Every caller-provided
|
|
19
|
+
* field — including a caller-provided partial object once the MCP input schema
|
|
20
|
+
* accepts one — is preserved verbatim; only genuinely unmeasured numeric fields
|
|
21
|
+
* default to 0, and `completedAt`/`notes` only default when absent.
|
|
22
|
+
*/
|
|
23
|
+
export declare function synthesizeActuals(actuals: Partial<Actuals> | undefined, transitionedAt: string): Actuals;
|
|
3
24
|
/** Bind deferred lifecycle work to one normalized absolute root without persisting that path. */
|
|
4
25
|
export declare function computeTransitionRootBinding(projectPath: string): string;
|
|
5
26
|
/** Recover durable post-commit work for every registered project after server startup. */
|
|
6
27
|
export declare function recoverPendingPostCommitTasksAtStartup(): Promise<void>;
|
|
7
28
|
export declare function handleUpdateStatus(params: UpdateStatusInput, server?: McpServer): Promise<ToolResult>;
|
|
29
|
+
export {};
|
|
8
30
|
//# sourceMappingURL=index.d.ts.map
|