@planu/cli 4.10.11 → 4.11.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 +15 -0
- package/dist/config/project-knowledge-graph.json +42 -5
- package/dist/engine/core-bridge-project-graph.d.ts +13 -0
- package/dist/engine/core-bridge-project-graph.js +499 -0
- package/dist/engine/core-bridge.d.ts +7 -2
- package/dist/engine/core-bridge.js +55 -0
- package/dist/engine/frontmatter-parser.js +73 -23
- package/dist/engine/model-tier-resolver.d.ts +8 -7
- package/dist/engine/model-tier-resolver.js +70 -73
- package/dist/engine/next-spec-resolver/orchestration-planner.d.ts +5 -0
- package/dist/engine/next-spec-resolver/orchestration-planner.js +34 -5
- package/dist/engine/project-graph/builder.js +271 -36
- package/dist/engine/project-graph/cache.d.ts +22 -4
- package/dist/engine/project-graph/cache.js +412 -33
- package/dist/engine/project-graph/index.d.ts +1 -0
- package/dist/engine/project-graph/index.js +1 -0
- package/dist/engine/project-graph/native.d.ts +3 -0
- package/dist/engine/project-graph/native.js +36 -0
- package/dist/engine/project-graph/query.js +34 -2
- package/dist/engine/provider-adapters/adapters/claude.js +38 -14
- package/dist/engine/scan-project/index.js +88 -15
- package/dist/engine/spec-format/lean-spec-generator.d.ts +2 -2
- package/dist/engine/spec-format/lean-spec-generator.js +65 -50
- package/dist/engine/spec-format/metadata-value-policy.d.ts +161 -0
- package/dist/engine/spec-format/metadata-value-policy.js +87 -0
- package/dist/engine/spec-format/value-only-spec-serializer.d.ts +12 -0
- package/dist/engine/spec-format/value-only-spec-serializer.js +18 -0
- package/dist/engine/spec-generator/fallback-generator.js +4 -2
- package/dist/engine/spec-generator/opus-generator.js +5 -2
- package/dist/engine/spec-migrator/lean-migration.js +26 -13
- package/dist/storage/spec-store.js +6 -6
- package/dist/tools/create-spec.js +1027 -739
- package/dist/tools/render-spec-for-provider.js +4 -3
- package/dist/tools/reverse-engineer/handler.js +76 -43
- package/dist/tools/spec-split-handler.js +36 -75
- package/dist/types/conventions.d.ts +9 -0
- package/dist/types/core-bridge.d.ts +72 -0
- package/dist/types/next-spec.d.ts +2 -1
- package/dist/types/project-knowledge-graph.d.ts +58 -0
- package/dist/types/spec/core.d.ts +7 -4
- package/dist/types/spec-format.d.ts +2 -1
- package/dist/types/spec-generator.d.ts +8 -2
- package/package.json +11 -9
- package/planu-native.json +1 -1
- package/planu-plugin.json +1 -1
- package/dist/engine/spec-format/model-budget-deriver.d.ts +0 -5
- package/dist/engine/spec-format/model-budget-deriver.js +0 -7
|
@@ -4,9 +4,9 @@ import { ti } from '../i18n/index.js';
|
|
|
4
4
|
import { knowledgeStore, specStore } from '../storage/index.js';
|
|
5
5
|
import { readTechnologySelectionContract } from '../storage/technology-selection-store.js';
|
|
6
6
|
import { formatSuccess, addNextSteps, toolResult, interactiveResult } from './response-helpers.js';
|
|
7
|
-
import { writeFile, mkdir, rm, readFile, stat as fsStat } from 'node:fs/promises';
|
|
8
|
-
import { createHash } from 'node:crypto';
|
|
9
|
-
import { join as pathJoin } from 'node:path';
|
|
7
|
+
import { writeFile, mkdir, rm, readFile, stat as fsStat, rename, link as hardLink, } from 'node:fs/promises';
|
|
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
10
|
import { estimateSpec } from '../engine/estimator.js';
|
|
11
11
|
import { checkSpecReadiness } from '../engine/readiness-checker.js';
|
|
12
12
|
import { buildSpecContext, buildSplitResult } from './create-spec/spec-builder.js';
|
|
@@ -354,6 +354,257 @@ async function handleAgentTeamSynthesis(specId, projectPath, findings) {
|
|
|
354
354
|
function computeIdempotencyKey(title, projectPath) {
|
|
355
355
|
return createHash('sha256').update(`${title}::${projectPath}`).digest('hex');
|
|
356
356
|
}
|
|
357
|
+
const IDEMPOTENCY_CLAIM_STALE_MS = 60_000;
|
|
358
|
+
const IDEMPOTENCY_CLAIM_WAIT_MS = 25_000;
|
|
359
|
+
const IDEMPOTENCY_CLAIM_POLL_MS = 20;
|
|
360
|
+
const IDEMPOTENCY_MATCH_WINDOW_MS = 10 * 60 * 1000;
|
|
361
|
+
function getIdempotencyEvidencePath(projectPath, key) {
|
|
362
|
+
const analysisPath = getAsyncAnalysisPath(projectPath, 'SPEC-000');
|
|
363
|
+
const projectDataPath = pathDirname(pathDirname(analysisPath));
|
|
364
|
+
return pathJoin(projectDataPath, 'create-spec-idempotency', `${key}.json`);
|
|
365
|
+
}
|
|
366
|
+
function getIdempotencyClaimPath(projectPath, key) {
|
|
367
|
+
return getIdempotencyEvidencePath(projectPath, key).replace(/\.json$/, '.claim.json');
|
|
368
|
+
}
|
|
369
|
+
function parseIdempotencyEvidence(raw) {
|
|
370
|
+
try {
|
|
371
|
+
const value = JSON.parse(raw);
|
|
372
|
+
if (value === null || typeof value !== 'object') {
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
const record = value;
|
|
376
|
+
if ((record.version !== 1 && record.version !== 2) ||
|
|
377
|
+
(record.version === 2 && record.state !== 'committed') ||
|
|
378
|
+
(record.version === 2 && typeof record.ownerId !== 'string') ||
|
|
379
|
+
typeof record.key !== 'string' ||
|
|
380
|
+
typeof record.specId !== 'string' ||
|
|
381
|
+
!/^SPEC-\d+$/.test(record.specId) ||
|
|
382
|
+
typeof record.title !== 'string' ||
|
|
383
|
+
typeof record.status !== 'string' ||
|
|
384
|
+
typeof record.specPath !== 'string' ||
|
|
385
|
+
typeof record.createdAt !== 'string' ||
|
|
386
|
+
!Number.isFinite(Date.parse(record.createdAt)) ||
|
|
387
|
+
(record.version === 2 &&
|
|
388
|
+
(typeof record.committedAt !== 'string' ||
|
|
389
|
+
!Number.isFinite(Date.parse(record.committedAt))))) {
|
|
390
|
+
return undefined;
|
|
391
|
+
}
|
|
392
|
+
return record;
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return undefined;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
function parseIdempotencyClaim(raw) {
|
|
399
|
+
try {
|
|
400
|
+
const value = JSON.parse(raw);
|
|
401
|
+
if (value === null || typeof value !== 'object') {
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
const record = value;
|
|
405
|
+
if (record.version !== 2 ||
|
|
406
|
+
record.state !== 'intent' ||
|
|
407
|
+
typeof record.key !== 'string' ||
|
|
408
|
+
typeof record.title !== 'string' ||
|
|
409
|
+
typeof record.ownerId !== 'string' ||
|
|
410
|
+
record.ownerId.length === 0 ||
|
|
411
|
+
typeof record.claimedAt !== 'string' ||
|
|
412
|
+
!Number.isFinite(Date.parse(record.claimedAt))) {
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
return record;
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
return undefined;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
function isSafeSpecPath(projectPath, specPath) {
|
|
422
|
+
const relativePath = pathRelative(pathJoin(projectPath, 'planu', 'specs'), specPath);
|
|
423
|
+
return (relativePath.length > 0 &&
|
|
424
|
+
!pathIsAbsolute(relativePath) &&
|
|
425
|
+
relativePath !== '..' &&
|
|
426
|
+
!relativePath.startsWith(`..${pathSeparator}`) &&
|
|
427
|
+
relativePath.endsWith(`${pathSeparator}spec.md`));
|
|
428
|
+
}
|
|
429
|
+
async function findByIdempotencyEvidence(projectPath, key, cutoff, expectedOwnerId) {
|
|
430
|
+
const raw = await readFile(getIdempotencyEvidencePath(projectPath, key), 'utf-8').catch(() => '');
|
|
431
|
+
const evidence = parseIdempotencyEvidence(raw);
|
|
432
|
+
if (evidence === undefined) {
|
|
433
|
+
return undefined;
|
|
434
|
+
}
|
|
435
|
+
if (evidence.key !== key ||
|
|
436
|
+
(expectedOwnerId !== undefined &&
|
|
437
|
+
evidence.ownerId !== undefined &&
|
|
438
|
+
evidence.ownerId !== expectedOwnerId) ||
|
|
439
|
+
Date.parse(evidence.createdAt) <= cutoff ||
|
|
440
|
+
!isSafeSpecPath(projectPath, evidence.specPath)) {
|
|
441
|
+
return undefined;
|
|
442
|
+
}
|
|
443
|
+
const fileStat = await fsStat(evidence.specPath).catch(() => null);
|
|
444
|
+
if (fileStat === null) {
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
const content = await readFile(evidence.specPath, 'utf-8').catch(() => '');
|
|
448
|
+
const idMatch = FRONTMATTER_ID_RE.exec(content);
|
|
449
|
+
if (idMatch?.[1] !== evidence.specId) {
|
|
450
|
+
return undefined;
|
|
451
|
+
}
|
|
452
|
+
const status = FRONTMATTER_STATUS_RE.exec(content)?.[1] ?? evidence.status;
|
|
453
|
+
return {
|
|
454
|
+
id: evidence.specId,
|
|
455
|
+
title: evidence.title,
|
|
456
|
+
status: status,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
async function commitIdempotencyEvidence(projectPath, key, claim, spec, specPath) {
|
|
460
|
+
const evidencePath = getIdempotencyEvidencePath(projectPath, key);
|
|
461
|
+
const claimRaw = await readFile(getIdempotencyClaimPath(projectPath, key), 'utf-8').catch(() => '');
|
|
462
|
+
const activeClaim = parseIdempotencyClaim(claimRaw);
|
|
463
|
+
if (activeClaim?.ownerId !== claim.ownerId || activeClaim.key !== key) {
|
|
464
|
+
throw new Error('Idempotency claim ownership changed before commit');
|
|
465
|
+
}
|
|
466
|
+
const committedAt = new Date().toISOString();
|
|
467
|
+
const evidence = {
|
|
468
|
+
version: 2,
|
|
469
|
+
state: 'committed',
|
|
470
|
+
ownerId: claim.ownerId,
|
|
471
|
+
key,
|
|
472
|
+
specId: spec.id,
|
|
473
|
+
title: spec.title,
|
|
474
|
+
status: spec.status,
|
|
475
|
+
specPath,
|
|
476
|
+
createdAt: committedAt,
|
|
477
|
+
committedAt,
|
|
478
|
+
};
|
|
479
|
+
try {
|
|
480
|
+
await writeFile(evidencePath, JSON.stringify(evidence, null, 2), {
|
|
481
|
+
encoding: 'utf-8',
|
|
482
|
+
flag: 'wx',
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
if (error.code !== 'EEXIST') {
|
|
487
|
+
throw error;
|
|
488
|
+
}
|
|
489
|
+
const existingRaw = await readFile(evidencePath, 'utf-8').catch(() => '');
|
|
490
|
+
const existing = parseIdempotencyEvidence(existingRaw);
|
|
491
|
+
if (existing?.ownerId === claim.ownerId && existing.specId === spec.id) {
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
throw new Error('Idempotency evidence already committed by another request', {
|
|
495
|
+
cause: error,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
async function releaseIdempotencyClaim(projectPath, key, ownerId) {
|
|
500
|
+
const claimPath = getIdempotencyClaimPath(projectPath, key);
|
|
501
|
+
const raw = await readFile(claimPath, 'utf-8').catch(() => '');
|
|
502
|
+
if (parseIdempotencyClaim(raw)?.ownerId === ownerId) {
|
|
503
|
+
await quarantineAndDeleteIdempotencyClaim(claimPath, raw);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
async function quarantineAndDeleteIdempotencyClaim(claimPath, expectedRaw) {
|
|
507
|
+
const quarantinePath = `${claimPath}.${randomUUID()}.quarantine`;
|
|
508
|
+
try {
|
|
509
|
+
await rename(claimPath, quarantinePath);
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
if (error.code === 'ENOENT') {
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
throw error;
|
|
516
|
+
}
|
|
517
|
+
const quarantinedRaw = await readFile(quarantinePath, 'utf-8').catch(() => '');
|
|
518
|
+
if (quarantinedRaw === expectedRaw) {
|
|
519
|
+
await rm(quarantinePath, { force: true });
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
try {
|
|
523
|
+
await hardLink(quarantinePath, claimPath);
|
|
524
|
+
}
|
|
525
|
+
catch (error) {
|
|
526
|
+
if (error.code !== 'EEXIST') {
|
|
527
|
+
throw error;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
await rm(quarantinePath, { force: true });
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
async function waitForIdempotencyOwner(projectPath, key, claim) {
|
|
534
|
+
const deadline = Date.now() + IDEMPOTENCY_CLAIM_WAIT_MS;
|
|
535
|
+
const cutoff = Date.now() - IDEMPOTENCY_MATCH_WINDOW_MS;
|
|
536
|
+
while (Date.now() < deadline) {
|
|
537
|
+
const existing = await findByIdempotencyEvidence(projectPath, key, cutoff, claim.ownerId);
|
|
538
|
+
if (existing !== undefined) {
|
|
539
|
+
return { kind: 'existing', spec: existing };
|
|
540
|
+
}
|
|
541
|
+
const currentRaw = await readFile(getIdempotencyClaimPath(projectPath, key), 'utf-8').catch(() => '');
|
|
542
|
+
const current = parseIdempotencyClaim(currentRaw);
|
|
543
|
+
if (current?.ownerId !== claim.ownerId) {
|
|
544
|
+
return undefined;
|
|
545
|
+
}
|
|
546
|
+
if (Date.now() - Date.parse(current.claimedAt) > IDEMPOTENCY_CLAIM_STALE_MS) {
|
|
547
|
+
await releaseIdempotencyClaim(projectPath, key, current.ownerId);
|
|
548
|
+
return undefined;
|
|
549
|
+
}
|
|
550
|
+
await new Promise((resolve) => setTimeout(resolve, IDEMPOTENCY_CLAIM_POLL_MS));
|
|
551
|
+
}
|
|
552
|
+
return { kind: 'pending' };
|
|
553
|
+
}
|
|
554
|
+
async function acquireIdempotencyClaim(projectPath, key, title) {
|
|
555
|
+
const claimPath = getIdempotencyClaimPath(projectPath, key);
|
|
556
|
+
await mkdir(pathDirname(claimPath), { recursive: true });
|
|
557
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
558
|
+
const claim = {
|
|
559
|
+
version: 2,
|
|
560
|
+
state: 'intent',
|
|
561
|
+
key,
|
|
562
|
+
title,
|
|
563
|
+
ownerId: randomUUID(),
|
|
564
|
+
claimedAt: new Date().toISOString(),
|
|
565
|
+
};
|
|
566
|
+
try {
|
|
567
|
+
await writeFile(claimPath, JSON.stringify(claim, null, 2), {
|
|
568
|
+
encoding: 'utf-8',
|
|
569
|
+
flag: 'wx',
|
|
570
|
+
});
|
|
571
|
+
const racedCommit = await findByIdempotencyEvidence(projectPath, key, Date.now() - IDEMPOTENCY_MATCH_WINDOW_MS);
|
|
572
|
+
if (racedCommit !== undefined) {
|
|
573
|
+
await releaseIdempotencyClaim(projectPath, key, claim.ownerId);
|
|
574
|
+
return { kind: 'existing', spec: racedCommit };
|
|
575
|
+
}
|
|
576
|
+
const expiredEvidencePath = getIdempotencyEvidencePath(projectPath, key);
|
|
577
|
+
if ((await fsStat(expiredEvidencePath).catch(() => null)) !== null) {
|
|
578
|
+
await rm(expiredEvidencePath, { force: true });
|
|
579
|
+
}
|
|
580
|
+
return { kind: 'owner', claim };
|
|
581
|
+
}
|
|
582
|
+
catch (error) {
|
|
583
|
+
if (error.code !== 'EEXIST') {
|
|
584
|
+
throw error;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
const existingRaw = await readFile(claimPath, 'utf-8').catch(() => '');
|
|
588
|
+
const existingClaim = parseIdempotencyClaim(existingRaw);
|
|
589
|
+
if (existingClaim?.key !== key) {
|
|
590
|
+
const claimStat = await fsStat(claimPath).catch(() => null);
|
|
591
|
+
if (claimStat !== null && Date.now() - claimStat.mtimeMs > IDEMPOTENCY_CLAIM_STALE_MS) {
|
|
592
|
+
await quarantineAndDeleteIdempotencyClaim(claimPath, existingRaw);
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
return { kind: 'pending' };
|
|
596
|
+
}
|
|
597
|
+
if (Date.now() - Date.parse(existingClaim.claimedAt) > IDEMPOTENCY_CLAIM_STALE_MS) {
|
|
598
|
+
await releaseIdempotencyClaim(projectPath, key, existingClaim.ownerId);
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
const waited = await waitForIdempotencyOwner(projectPath, key, existingClaim);
|
|
602
|
+
if (waited !== undefined) {
|
|
603
|
+
return waited;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return { kind: 'pending' };
|
|
607
|
+
}
|
|
357
608
|
/**
|
|
358
609
|
* SPEC-781: Check if async analysis has completed for a given spec.
|
|
359
610
|
* Returns { pending: true } when external analysis state is absent.
|
|
@@ -367,12 +618,31 @@ async function checkAnalysisStatus(projectPath, specId) {
|
|
|
367
618
|
return { pending: true };
|
|
368
619
|
}
|
|
369
620
|
}
|
|
370
|
-
|
|
621
|
+
async function buildIdempotencyMatchResult(projectPath, existing) {
|
|
622
|
+
const analysisStatus = await checkAnalysisStatus(projectPath, existing.id);
|
|
623
|
+
return {
|
|
624
|
+
content: [
|
|
625
|
+
{
|
|
626
|
+
type: 'text',
|
|
627
|
+
text: `spec already exists: ${existing.id} — returning existing spec to avoid duplicate.\n\nTitle: ${existing.title}\nStatus: ${existing.status}\n\nNo new spec was created.`,
|
|
628
|
+
},
|
|
629
|
+
],
|
|
630
|
+
structuredContent: {
|
|
631
|
+
specId: existing.id,
|
|
632
|
+
title: existing.title,
|
|
633
|
+
status: existing.status,
|
|
634
|
+
message: `spec already exists: ${existing.id}`,
|
|
635
|
+
idempotencyMatch: true,
|
|
636
|
+
pendingAnalysis: analysisStatus.pending,
|
|
637
|
+
analysisComplete: !analysisStatus.pending,
|
|
638
|
+
},
|
|
639
|
+
};
|
|
640
|
+
}
|
|
371
641
|
const FRONTMATTER_ID_RE = /^id:\s*(SPEC-\d+)/m;
|
|
372
642
|
const FRONTMATTER_TITLE_RE = /^title:\s*"?([^"\n]+?)"?\s*$/m;
|
|
373
643
|
const FRONTMATTER_STATUS_RE = /^status:\s*(\S+)/m;
|
|
374
644
|
/** SPEC-770: Scan store and filesystem for a spec with matching idempotency key within windowMs. */
|
|
375
|
-
async function findByIdempotencyKey(projectPath, key, projectId, windowMs =
|
|
645
|
+
async function findByIdempotencyKey(projectPath, key, projectId, windowMs = IDEMPOTENCY_MATCH_WINDOW_MS) {
|
|
376
646
|
const cutoff = Date.now() - windowMs;
|
|
377
647
|
// Fast path: check store (always authoritative for successfully created specs)
|
|
378
648
|
try {
|
|
@@ -385,7 +655,12 @@ async function findByIdempotencyKey(projectPath, key, projectId, windowMs = 10 *
|
|
|
385
655
|
catch {
|
|
386
656
|
/* best-effort */
|
|
387
657
|
}
|
|
388
|
-
//
|
|
658
|
+
// Transactional fallback: the request key stays in external project data, not spec.md.
|
|
659
|
+
const evidenceMatch = await findByIdempotencyEvidence(projectPath, key, cutoff);
|
|
660
|
+
if (evidenceMatch !== undefined) {
|
|
661
|
+
return evidenceMatch;
|
|
662
|
+
}
|
|
663
|
+
// Compatibility fallback for specs created before external evidence was introduced.
|
|
389
664
|
try {
|
|
390
665
|
const { glob } = await import('glob');
|
|
391
666
|
const { join: joinPath } = await import('node:path');
|
|
@@ -396,18 +671,14 @@ async function findByIdempotencyKey(projectPath, key, projectId, windowMs = 10 *
|
|
|
396
671
|
continue;
|
|
397
672
|
}
|
|
398
673
|
const content = await readFile(specFile, 'utf-8').catch(() => '');
|
|
399
|
-
const keyMatch = FRONTMATTER_KEY_RE.exec(content);
|
|
400
|
-
if (keyMatch?.[1] !== key) {
|
|
401
|
-
continue;
|
|
402
|
-
}
|
|
403
674
|
const idMatch = FRONTMATTER_ID_RE.exec(content);
|
|
404
|
-
const titleMatch = FRONTMATTER_TITLE_RE.exec(content);
|
|
405
675
|
const statusMatch = FRONTMATTER_STATUS_RE.exec(content);
|
|
406
|
-
|
|
676
|
+
const explicitKeyMatch = /^idempotencyKey:\s*["']?([a-f0-9]{64})["']?\s*$/m.exec(content);
|
|
677
|
+
if (idMatch?.[1] !== undefined && explicitKeyMatch?.[1] === key) {
|
|
407
678
|
const specStatus = statusMatch?.[1] ?? 'draft';
|
|
408
679
|
return {
|
|
409
680
|
id: idMatch[1],
|
|
410
|
-
title:
|
|
681
|
+
title: idMatch[1],
|
|
411
682
|
status: specStatus,
|
|
412
683
|
};
|
|
413
684
|
}
|
|
@@ -529,768 +800,785 @@ export async function handleCreateSpec(inputParams, server) {
|
|
|
529
800
|
const idempotencyKey = computeIdempotencyKey(inputParams.title, resolvedPath);
|
|
530
801
|
const existingByKey = await findByIdempotencyKey(resolvedPath, idempotencyKey, hashProjectPath(resolvedPath));
|
|
531
802
|
if (existingByKey) {
|
|
532
|
-
|
|
533
|
-
|
|
803
|
+
return buildIdempotencyMatchResult(resolvedPath, existingByKey);
|
|
804
|
+
}
|
|
805
|
+
const claimResolution = await acquireIdempotencyClaim(resolvedPath, idempotencyKey, inputParams.title);
|
|
806
|
+
if (claimResolution.kind === 'existing') {
|
|
807
|
+
return buildIdempotencyMatchResult(resolvedPath, claimResolution.spec);
|
|
808
|
+
}
|
|
809
|
+
if (claimResolution.kind === 'pending') {
|
|
534
810
|
return {
|
|
535
811
|
content: [
|
|
536
812
|
{
|
|
537
813
|
type: 'text',
|
|
538
|
-
text:
|
|
814
|
+
text: 'An identical create_spec request is still in progress. Retry with the same title.',
|
|
539
815
|
},
|
|
540
816
|
],
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
title: existingByKey.title,
|
|
544
|
-
status: existingByKey.status,
|
|
545
|
-
message: `spec already exists: ${existingByKey.id}`,
|
|
546
|
-
idempotencyMatch: true,
|
|
547
|
-
pendingAnalysis: analysisStatus.pending,
|
|
548
|
-
analysisComplete: !analysisStatus.pending,
|
|
549
|
-
},
|
|
817
|
+
isError: true,
|
|
818
|
+
structuredContent: { idempotencyPending: true },
|
|
550
819
|
};
|
|
551
820
|
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
//
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
821
|
+
const idempotencyClaim = claimResolution.claim;
|
|
822
|
+
const claimLifecycle = {
|
|
823
|
+
committed: false,
|
|
824
|
+
retainForInFlightWork: false,
|
|
825
|
+
};
|
|
826
|
+
try {
|
|
827
|
+
// eslint-disable-next-line max-lines-per-function, complexity -- orchestrator: one sequential gate + response-build block per lifecycle step; splitting requires threading 15+ shared variables
|
|
828
|
+
return await trackCost(resolvedInputParams.projectPath ?? '', 'create_spec', async () => {
|
|
829
|
+
// Allow internal re-enrichment without mutating function parameter
|
|
830
|
+
let params = resolvedInputParams;
|
|
831
|
+
const { description, clarificationSessionId } = params;
|
|
832
|
+
try {
|
|
833
|
+
// SPEC-713: Phase 1 — Critical path: build + persist spec within 25s hard ceiling.
|
|
834
|
+
// Only the steps required to produce spec.md on disk are inside this budget.
|
|
835
|
+
// Post-creation enrichment (git, contradictions, quality scores, etc.) runs outside.
|
|
836
|
+
const criticalResult = await withTotalBudget('create_spec-critical', 25_000, async () => {
|
|
837
|
+
// Build spec context (ID, estimation, diagrams, scope filters)
|
|
838
|
+
const buildResult = await measureStep('buildSpecContext', () => buildSpecContext(params));
|
|
839
|
+
if (!buildResult.ok) {
|
|
840
|
+
return {
|
|
841
|
+
ok: false,
|
|
842
|
+
earlyReturn: {
|
|
843
|
+
content: [{ type: 'text', text: buildResult.errorMessage }],
|
|
844
|
+
isError: true,
|
|
845
|
+
},
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
const { spec, specDir, specPath, technicalPath, estimation, splitSuggestion, duplicate, projectId, agentTeamPlan, } = buildResult.context;
|
|
849
|
+
// Kept in external store/evidence for retries; the value-only serializer omits it.
|
|
850
|
+
spec.idempotencyKey = idempotencyKey;
|
|
851
|
+
// Load project knowledge and clarification session
|
|
852
|
+
const knowledge = await measureStep('loadKnowledge', () => knowledgeStore.getKnowledge(projectId));
|
|
853
|
+
const clarificationSession = clarificationSessionId
|
|
854
|
+
? await knowledgeStore.getClarification(projectId, clarificationSessionId)
|
|
855
|
+
: null;
|
|
856
|
+
// Validate against Constitution
|
|
857
|
+
const constitutionCheck = await measureStep('validateConstitution', () => validateConstitution(projectId, spec.title, spec.tags));
|
|
858
|
+
if (constitutionCheck.blocked && constitutionCheck.errorResult) {
|
|
859
|
+
return { ok: false, earlyReturn: constitutionCheck.errorResult };
|
|
860
|
+
}
|
|
861
|
+
// SPEC-461 Phase 3: Autopilot — analyze project to enrich spec (best-effort)
|
|
862
|
+
// SPEC-560: Wrapped with 5s timeout to prevent hangs on large projects (2000+ files)
|
|
863
|
+
// SPEC-713: measureStep for Phase 0 diagnostic; withBudget enforces 5s ceiling
|
|
864
|
+
const autopilotResult = await withBudget('autopilot-analyzer', 5_000, () => measureStep('autopilot-analyzer', () => analyzeProjectForSpec(params.projectPath ?? '', description, spec.title, knowledge)));
|
|
865
|
+
const autopilot = unwrapBudget(autopilotResult, getEmptyAutopilotResult());
|
|
866
|
+
// If very vague (<10 words), capture as idea instead of spec
|
|
867
|
+
if (autopilot.isIdea) {
|
|
868
|
+
return {
|
|
869
|
+
ok: false,
|
|
870
|
+
earlyReturn: {
|
|
871
|
+
content: [
|
|
872
|
+
{
|
|
873
|
+
type: 'text',
|
|
874
|
+
text: `💡 **Idea captured**: "${description}"\n\nThe description is too brief for a full spec. Use \`capture_idea\` to save it to the backlog, or provide more detail (>10 words) to create a spec.`,
|
|
875
|
+
},
|
|
876
|
+
],
|
|
877
|
+
},
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
// SPEC-463 / SPEC-471: Interactive clarification
|
|
881
|
+
const clarificationResult = handleClarification(server, description, knowledge, autopilot, params);
|
|
882
|
+
if (clarificationResult !== null) {
|
|
883
|
+
if ('earlyReturn' in clarificationResult) {
|
|
884
|
+
// SPEC-584: Persist token so the gate blocks retries without answers
|
|
885
|
+
await persistClarificationToken(clarificationResult.earlyReturn, projectId, 'create_spec');
|
|
886
|
+
return { ok: false, earlyReturn: clarificationResult.earlyReturn };
|
|
887
|
+
}
|
|
888
|
+
params = clarificationResult.params;
|
|
889
|
+
}
|
|
890
|
+
// Create spec directory and write lean files (SPEC-461)
|
|
891
|
+
await measureStep('mkdir-specDir', () => mkdir(specDir, { recursive: true }));
|
|
892
|
+
const filteredCriteria = filterGroundedCriteria(autopilot.suggestedCriteria);
|
|
893
|
+
const technologyContract = await readTechnologySelectionContract(params.projectPath ?? '');
|
|
894
|
+
const contractNote = technologyContract
|
|
895
|
+
? [
|
|
896
|
+
'',
|
|
897
|
+
'Technology Contract:',
|
|
898
|
+
`- Mode: ${technologyContract.mode}`,
|
|
899
|
+
technologyContract.language ? `- Language: ${technologyContract.language}` : '',
|
|
900
|
+
technologyContract.framework
|
|
901
|
+
? `- Framework: ${technologyContract.framework}`
|
|
902
|
+
: '- Framework: none or not selected',
|
|
903
|
+
technologyContract.platform ? `- Platform: ${technologyContract.platform}` : '',
|
|
904
|
+
technologyContract.projectType
|
|
905
|
+
? `- Project type: ${technologyContract.projectType}`
|
|
906
|
+
: '',
|
|
907
|
+
'- Agents must not choose a different stack unless the user approves a new contract.',
|
|
908
|
+
]
|
|
909
|
+
.filter(Boolean)
|
|
910
|
+
.join('\n')
|
|
911
|
+
: '';
|
|
912
|
+
const anthropicKey = await new ApiKeyResolver().resolveAnthropicKey(params.projectPath ?? '');
|
|
913
|
+
const specGenerator = anthropicKey !== undefined
|
|
914
|
+
? new OpusGenerator({ apiKey: anthropicKey })
|
|
915
|
+
: new FallbackGenerator();
|
|
916
|
+
const generatedSpec = await measureStep('generateSpecBody', () => specGenerator.generate({
|
|
917
|
+
title: spec.title,
|
|
918
|
+
description: `${description}${contractNote}`,
|
|
919
|
+
type: spec.type,
|
|
920
|
+
scope: spec.scope,
|
|
921
|
+
target: spec.target,
|
|
922
|
+
acFormat: params.acFormat,
|
|
923
|
+
projectContext: {
|
|
924
|
+
language: technologyContract?.language ?? knowledge?.language ?? undefined,
|
|
925
|
+
framework: technologyContract?.framework ?? knowledge?.framework ?? undefined,
|
|
926
|
+
architecture: knowledge?.architecture.primary ?? undefined,
|
|
570
927
|
},
|
|
571
|
-
};
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
928
|
+
}));
|
|
929
|
+
spec.generation = generatedSpec.generation;
|
|
930
|
+
spec.qualityWarnings =
|
|
931
|
+
generatedSpec.qualityWarnings.length > 0 ? generatedSpec.qualityWarnings : undefined;
|
|
932
|
+
const baseCriteria = extractCriteria(generatedSpec.specBody).map((criterion) => criterion.text);
|
|
933
|
+
const groundingCriteria = buildCriterionGroundingRecords({
|
|
934
|
+
criteria: [...baseCriteria, ...filteredCriteria],
|
|
935
|
+
userInput: description,
|
|
936
|
+
projectEvidence: [
|
|
937
|
+
...autopilot.detectedPatterns.map((pattern) => `detected-pattern:${pattern}`),
|
|
938
|
+
...autopilot.suggestedFiles.modify.map((file) => `file:${file.path}`),
|
|
939
|
+
...autopilot.suggestedFiles.create.map((file) => `file:${file.path}`),
|
|
940
|
+
...autopilot.suggestedFiles.test.map((file) => `file:${file.path}`),
|
|
941
|
+
],
|
|
942
|
+
generatedEvidence: [
|
|
943
|
+
generatedSpec.generation.modelId ?? generatedSpec.generation.method,
|
|
944
|
+
],
|
|
945
|
+
});
|
|
946
|
+
const contractCriteria = getContractCriteria(groundingCriteria);
|
|
947
|
+
const advisoryCriteria = getAdvisoryCriteria(groundingCriteria).map((record) => record.text);
|
|
948
|
+
const actionableMetrics = calculateActionableSpecMetrics({
|
|
949
|
+
criteria: [...baseCriteria, ...filteredCriteria],
|
|
950
|
+
groundingRecords: groundingCriteria,
|
|
951
|
+
}).filter(shouldExposeMetric);
|
|
952
|
+
const technicalFiles = await measureStep('resolveTechnicalFiles', () => resolveTechnicalFiles({
|
|
953
|
+
generatedSpecBody: generatedSpec.specBody,
|
|
954
|
+
generatedTechnicalSection: generatedSpec.technicalSection,
|
|
955
|
+
projectPath: params.projectPath ?? '',
|
|
956
|
+
autopilot,
|
|
957
|
+
fallbackReason: generatedSpec.fallbackReason,
|
|
958
|
+
}));
|
|
959
|
+
const groundedTechnical = await measureStep('groundTechnicalFiles', () => groundTechnicalFiles({
|
|
960
|
+
files: technicalFiles,
|
|
961
|
+
projectPath: params.projectPath ?? '',
|
|
962
|
+
userInput: description,
|
|
963
|
+
autopilot,
|
|
964
|
+
}));
|
|
965
|
+
const outOfScopeResolved = resolveOutOfScope(description, params.outOfScope);
|
|
966
|
+
const scenarioTestPaths = groundedTechnical.files.test.map((file) => file.path);
|
|
967
|
+
const leanSpec = generateLeanSpecContent({
|
|
968
|
+
spec,
|
|
969
|
+
description: generatedSpec.specBody,
|
|
970
|
+
estimation,
|
|
971
|
+
criteriaOverride: contractCriteria.map((record) => ({
|
|
972
|
+
text: record.text,
|
|
973
|
+
done: false,
|
|
974
|
+
})),
|
|
975
|
+
groundingCriteria,
|
|
976
|
+
groundingTechnicalReferences: groundedTechnical.records,
|
|
977
|
+
acFormat: params.acFormat,
|
|
978
|
+
scenarioTestPaths,
|
|
979
|
+
});
|
|
980
|
+
const leanTechnical = generateLeanTechnicalContent({
|
|
981
|
+
specId: spec.id,
|
|
982
|
+
filesToCreate: groundedTechnical.files.create,
|
|
983
|
+
filesToModify: groundedTechnical.files.modify,
|
|
984
|
+
filesToTest: groundedTechnical.files.test,
|
|
985
|
+
includeDecisionRequiredPlaceholder: spec.scope !== 'trivial',
|
|
986
|
+
});
|
|
987
|
+
// SPEC-709: write unified spec.md from origin — no separate technical.md.
|
|
988
|
+
// The legacy two-file output is preserved by appending the technical body
|
|
989
|
+
// as a `## Technical` section inside spec.md.
|
|
990
|
+
const unifiedWithoutContract = buildUnifiedSpecContent(leanSpec, leanTechnical);
|
|
991
|
+
const unifiedSpec = appendImplementationContractIfMissing(unifiedWithoutContract, {
|
|
992
|
+
description: generatedSpec.specBody,
|
|
993
|
+
criteria: contractCriteria.map((record) => ({ text: record.text, done: false })),
|
|
994
|
+
files: groundedTechnical.files,
|
|
995
|
+
outOfScope: outOfScopeResolved.items,
|
|
996
|
+
verificationCommands: [
|
|
997
|
+
...(scenarioTestPaths.length > 0
|
|
998
|
+
? [`pnpm vitest run ${scenarioTestPaths.join(' ')}`]
|
|
999
|
+
: []),
|
|
1000
|
+
'pnpm typecheck',
|
|
1001
|
+
'pnpm lint',
|
|
1002
|
+
'pnpm test',
|
|
1003
|
+
],
|
|
1004
|
+
});
|
|
1005
|
+
const genericOutputGate = checkGenericSpecOutput(unifiedSpec);
|
|
1006
|
+
if (!genericOutputGate.passed) {
|
|
1007
|
+
return {
|
|
1008
|
+
ok: false,
|
|
1009
|
+
earlyReturn: {
|
|
1010
|
+
content: [
|
|
1011
|
+
{
|
|
1012
|
+
type: 'text',
|
|
1013
|
+
text: 'Spec quality gate blocked generic output before persistence. ' +
|
|
1014
|
+
genericOutputGate.issues
|
|
1015
|
+
.slice(0, 3)
|
|
1016
|
+
.map((issue) => `${issue.phrase}: ${issue.reason}`)
|
|
1017
|
+
.join('; '),
|
|
1018
|
+
},
|
|
1019
|
+
],
|
|
1020
|
+
isError: true,
|
|
1021
|
+
structuredContent: {
|
|
1022
|
+
error: 'GENERIC_SPEC_OUTPUT_BLOCKED',
|
|
1023
|
+
sddConstitutionRuleId: 'sdd.no-generic-output',
|
|
1024
|
+
issues: genericOutputGate.issues,
|
|
1025
|
+
fixHint: 'Replace generic criteria or placeholder references with grounded, testable behavior.',
|
|
598
1026
|
},
|
|
1027
|
+
},
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
try {
|
|
1031
|
+
// SPEC-713: measure file write — this is the critical persistence step.
|
|
1032
|
+
await measureStep('writeFile-specPath', () => writeFile(specPath, unifiedSpec, 'utf-8'));
|
|
1033
|
+
await measureStep('commit-idempotency-evidence', () => commitIdempotencyEvidence(resolvedPath, idempotencyKey, idempotencyClaim, spec, specPath));
|
|
1034
|
+
claimLifecycle.committed = true;
|
|
1035
|
+
// SPEC-709: technical.md no longer written — content lives inside spec.md.
|
|
1036
|
+
// SPEC-461: No progress.md, no HTML reports.
|
|
1037
|
+
}
|
|
1038
|
+
catch (writeErr) {
|
|
1039
|
+
// Clean up partial files to avoid orphaned spec directories
|
|
1040
|
+
await rm(specDir, { recursive: true, force: true });
|
|
1041
|
+
throw writeErr;
|
|
1042
|
+
}
|
|
1043
|
+
// SPEC-612: Auto-suggest outOfScope when user did not provide any
|
|
1044
|
+
if (outOfScopeResolved.items.length > 0) {
|
|
1045
|
+
spec.outOfScope = outOfScopeResolved.items;
|
|
1046
|
+
}
|
|
1047
|
+
const outOfScopeSuggestionMsg = outOfScopeResolved.message;
|
|
1048
|
+
// Persist spec in storage
|
|
1049
|
+
// SPEC-713: measure specStore.createSpec — file lock + JSON rewrite
|
|
1050
|
+
await measureStep('specStore-createSpec', () => specStore.createSpec(projectId, spec));
|
|
1051
|
+
return {
|
|
1052
|
+
ok: true,
|
|
1053
|
+
data: {
|
|
1054
|
+
spec,
|
|
1055
|
+
specDir,
|
|
1056
|
+
specPath,
|
|
1057
|
+
technicalPath,
|
|
1058
|
+
estimation,
|
|
1059
|
+
splitSuggestion,
|
|
1060
|
+
duplicate,
|
|
1061
|
+
projectId,
|
|
1062
|
+
agentTeamPlan,
|
|
1063
|
+
knowledge,
|
|
1064
|
+
clarificationSession,
|
|
1065
|
+
constitutionCheck,
|
|
1066
|
+
autopilot,
|
|
1067
|
+
filteredCriteria,
|
|
1068
|
+
advisoryCriteria: [
|
|
1069
|
+
...advisoryCriteria,
|
|
1070
|
+
...groundedTechnical.advisoryFiles.map((file) => file.path),
|
|
599
1071
|
],
|
|
1072
|
+
actionableMetrics,
|
|
1073
|
+
outOfScopeSuggestionMsg,
|
|
600
1074
|
},
|
|
601
1075
|
};
|
|
1076
|
+
}); // end withTotalBudget critical path
|
|
1077
|
+
// SPEC-713: Handle total budget exceeded — return clear error before 60s MCP timeout
|
|
1078
|
+
if (criticalResult.timedOut) {
|
|
1079
|
+
claimLifecycle.retainForInFlightWork = criticalResult.warning.includes('exceeded');
|
|
1080
|
+
// SPEC-770: Wait briefly for any in-flight spec write to complete, then scan for duplicates
|
|
1081
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1082
|
+
const recentSpecs = await findRecentlyWrittenSpecs(resolvedPath, 2 * 60 * 1000);
|
|
1083
|
+
const possibleDuplicates = recentSpecs.length > 0 ? recentSpecs : undefined;
|
|
1084
|
+
const dupNote = possibleDuplicates
|
|
1085
|
+
? `\n\nA spec may have been created server-side: ${possibleDuplicates.map((s) => s.id).join(', ')}. Retry with the same title to return it instead of creating a duplicate.`
|
|
1086
|
+
: '\n\nThe spec may not have been persisted. Check `planu/specs/` and retry.';
|
|
1087
|
+
return {
|
|
1088
|
+
content: [
|
|
1089
|
+
{
|
|
1090
|
+
type: 'text',
|
|
1091
|
+
text: `❌ create_spec exceeded 25s internal budget.\n\n${criticalResult.warning}${dupNote}`,
|
|
1092
|
+
},
|
|
1093
|
+
],
|
|
1094
|
+
isError: true,
|
|
1095
|
+
...(possibleDuplicates ? { structuredContent: { possibleDuplicates } } : {}),
|
|
1096
|
+
};
|
|
602
1097
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
1098
|
+
if (!criticalResult.value.ok) {
|
|
1099
|
+
return criticalResult.value.earlyReturn;
|
|
1100
|
+
}
|
|
1101
|
+
// Destructure critical path results for use in post-creation enrichment
|
|
1102
|
+
const { spec, specDir: _specDir, specPath,
|
|
1103
|
+
// SPEC-1010 Bug A: no longer surfaced in the response payload (SSR back-migration).
|
|
1104
|
+
technicalPath: _technicalPath, estimation, splitSuggestion, duplicate, projectId, agentTeamPlan, knowledge, clarificationSession, constitutionCheck, autopilot, filteredCriteria, advisoryCriteria, actionableMetrics, outOfScopeSuggestionMsg, } = criticalResult.value.data;
|
|
1105
|
+
// -----------------------------------------------------------------------
|
|
1106
|
+
// Post-creation enrichment (outside 25s ceiling — best-effort, budgeted)
|
|
1107
|
+
// -----------------------------------------------------------------------
|
|
1108
|
+
// Auto-setup git branch (non-blocking)
|
|
1109
|
+
// SPEC-713: withBudget 3s — git subprocess can be slow on large repos
|
|
1110
|
+
const gitBudgetResult = await withBudget('setupGitBranch', 3_000, () => measureStep('setupGitBranch', () => setupGitBranch(projectId, spec.id)));
|
|
1111
|
+
const gitSetupResult = unwrapBudget(gitBudgetResult, undefined);
|
|
1112
|
+
/* v8 ignore start -- requires real git repo */
|
|
1113
|
+
if (gitSetupResult) {
|
|
1114
|
+
spec.gitBranch = gitSetupResult.branch;
|
|
612
1115
|
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
const
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
: new FallbackGenerator();
|
|
639
|
-
const generatedSpec = await measureStep('generateSpecBody', () => specGenerator.generate({
|
|
1116
|
+
/* v8 ignore stop */
|
|
1117
|
+
// Brief contradiction check (non-blocking)
|
|
1118
|
+
// SPEC-713: withBudget 2s — scans all specs for contradiction keywords
|
|
1119
|
+
const contradictionResult = await withBudget('checkContradictions', 2_000, () => measureStep('checkContradictions', () => checkContradictions(projectId, spec.id, description)));
|
|
1120
|
+
const contradictionHint = unwrapBudget(contradictionResult, undefined);
|
|
1121
|
+
// SPEC-555: Enrich estimation with calendar days (best-effort, non-blocking)
|
|
1122
|
+
// SPEC-713: withBudget 2s
|
|
1123
|
+
const velocityResult = await withBudget('velocityEnrichment', 2_000, () => measureStep('velocityEnrichment', () => enrichEstimationWithVelocity(params.projectPath ?? '', estimation.devHours)));
|
|
1124
|
+
const velocityEnrichment = unwrapBudget(velocityResult, {
|
|
1125
|
+
calendarDays: null,
|
|
1126
|
+
velocityNote: 'velocity data unavailable',
|
|
1127
|
+
});
|
|
1128
|
+
// SPEC-506: Apply persisted calibration multiplier (best-effort, non-blocking)
|
|
1129
|
+
// SPEC-713: withBudget 2s
|
|
1130
|
+
const calibrationResult = await withBudget('calibrationEnrichment', 2_000, () => measureStep('calibrationEnrichment', () => enrichEstimationWithCalibration(params.projectPath ?? '', estimation.devHours, spec.scope, spec.type)));
|
|
1131
|
+
const calibrationEnrichment = unwrapBudget(calibrationResult, {
|
|
1132
|
+
calibratedHours: estimation.devHours,
|
|
1133
|
+
applied: false,
|
|
1134
|
+
calibrationNote: null,
|
|
1135
|
+
});
|
|
1136
|
+
// Build result (SPEC-461: lean — no progress, HTML, diagrams, scope filters)
|
|
1137
|
+
const result = {
|
|
1138
|
+
// SPEC-781: async analysis running in background (external project data)
|
|
1139
|
+
pendingAnalysis: true,
|
|
1140
|
+
specId: spec.id,
|
|
640
1141
|
title: spec.title,
|
|
641
|
-
|
|
1142
|
+
slug: spec.slug,
|
|
642
1143
|
type: spec.type,
|
|
643
1144
|
scope: spec.scope,
|
|
1145
|
+
difficulty: spec.difficulty,
|
|
1146
|
+
risk: spec.risk,
|
|
1147
|
+
tags: spec.tags,
|
|
644
1148
|
target: spec.target,
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
1149
|
+
status: spec.status,
|
|
1150
|
+
gitBranch: spec.gitBranch,
|
|
1151
|
+
specPath,
|
|
1152
|
+
// SPEC-1010 Bug A — `technicalPath` no longer leaked in the response.
|
|
1153
|
+
// SSR back-migration (SPEC-752) folded technical.md into spec.md as
|
|
1154
|
+
// ## Technical; the file is never written and the field would point to
|
|
1155
|
+
// a non-existent path. Internal Spec record still carries it for
|
|
1156
|
+
// backwards-compat with stored data — that field is removed in PR-C.
|
|
1157
|
+
estimation: {
|
|
1158
|
+
devHours: calibrationEnrichment.calibratedHours,
|
|
1159
|
+
reviewHours: estimation.reviewHours,
|
|
1160
|
+
totalCostUsd: estimation.totalCostUsd,
|
|
1161
|
+
recommendedModel: estimation.recommendedModel,
|
|
1162
|
+
executionMode: estimation.tokenOptimization.mode,
|
|
1163
|
+
calendarDays: velocityEnrichment.calendarDays,
|
|
1164
|
+
velocityNote: velocityEnrichment.velocityNote,
|
|
650
1165
|
},
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
1166
|
+
duplicateWarning: duplicate
|
|
1167
|
+
? ti('spec.duplicateTitle', { title: spec.title, slug: spec.slug })
|
|
1168
|
+
: undefined,
|
|
1169
|
+
...(constitutionCheck.warnings.length > 0
|
|
1170
|
+
? { constitutionWarnings: constitutionCheck.warnings }
|
|
1171
|
+
: {}),
|
|
1172
|
+
...(clarificationSession
|
|
1173
|
+
? {
|
|
1174
|
+
linkedClarificationSession: clarificationSession.id,
|
|
1175
|
+
clarificationTopic: clarificationSession.topic,
|
|
1176
|
+
clarificationAnswersUsed: Object.keys(clarificationSession.answers).length,
|
|
1177
|
+
}
|
|
1178
|
+
: {
|
|
1179
|
+
clarificationNote: 'Spec created directly from description — Planu inferred requirements from project context.',
|
|
1180
|
+
}),
|
|
1181
|
+
message: ti('tools.create_spec.success', { id: spec.id, title: spec.title }),
|
|
1182
|
+
...(contradictionHint ? { contradictionHint } : {}),
|
|
1183
|
+
/* v8 ignore next -- requires real git repo */
|
|
1184
|
+
...(gitSetupResult ? { gitAutoSetup: gitSetupResult.data } : {}),
|
|
1185
|
+
};
|
|
1186
|
+
const advisorySignals = [];
|
|
1187
|
+
if (actionableMetrics.length > 0) {
|
|
1188
|
+
result.actionableMetrics = actionableMetrics;
|
|
1189
|
+
}
|
|
1190
|
+
if (advisoryCriteria.length > 0) {
|
|
1191
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
1192
|
+
key: 'ungrounded-contract-items',
|
|
1193
|
+
kind: 'quality',
|
|
1194
|
+
message: `${String(advisoryCriteria.length)} ungrounded contract item(s) kept advisory-only.`,
|
|
1195
|
+
source: 'validator',
|
|
1196
|
+
evidence: advisoryCriteria.slice(0, 10),
|
|
1197
|
+
confidence: 0.8,
|
|
1198
|
+
surface: 'structuredContent',
|
|
1199
|
+
value: advisoryCriteria,
|
|
1200
|
+
}));
|
|
1201
|
+
}
|
|
1202
|
+
const splitResult = buildSplitResult(splitSuggestion, knowledge?.experienceLevel);
|
|
1203
|
+
if (splitResult) {
|
|
1204
|
+
result.splitSuggestion = splitResult;
|
|
1205
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
1206
|
+
key: 'split-suggestion',
|
|
1207
|
+
kind: 'complexity',
|
|
1208
|
+
message: 'Spec may benefit from splitting.',
|
|
1209
|
+
source: 'heuristic',
|
|
1210
|
+
evidence: ['spec-splitter heuristic'],
|
|
1211
|
+
confidence: 0.5,
|
|
1212
|
+
surface: 'structuredContent',
|
|
1213
|
+
value: splitResult,
|
|
1214
|
+
deprecatedAlias: 'splitSuggestion',
|
|
1215
|
+
}));
|
|
1216
|
+
}
|
|
1217
|
+
// Dispatch hook event (fire-and-forget)
|
|
1218
|
+
fireSpecCreatedHook(projectId, spec, params.projectPath ?? '');
|
|
1219
|
+
// SPEC-781: Fire-and-forget async analysis (external project data)
|
|
1220
|
+
runAutopilotAsync(spec.id, params.projectPath ?? '', description);
|
|
1221
|
+
// SPEC-713: Track warnings from budgeted post-creation steps
|
|
1222
|
+
const budgetWarnings = [];
|
|
1223
|
+
// Auto-estimation (best-effort, fire-and-forget)
|
|
1224
|
+
// SPEC-713: withBudget 2s — estimation is sync, specStore.updateSpec is a single JSON write
|
|
1225
|
+
const autoEstimationResult = await withBudget('auto-estimation', 2_000, async () => {
|
|
1226
|
+
const autoEstimation = estimateSpec(spec);
|
|
1227
|
+
await measureStep('specStore-updateSpec', () => specStore.updateSpec(projectId, spec.id, { estimation: autoEstimation.estimation }));
|
|
1228
|
+
return autoEstimation;
|
|
666
1229
|
});
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
const leanSpec = generateLeanSpecContent({
|
|
689
|
-
spec,
|
|
690
|
-
description: generatedSpec.specBody,
|
|
691
|
-
estimation,
|
|
692
|
-
criteriaOverride: contractCriteria.map((record) => ({
|
|
693
|
-
text: record.text,
|
|
694
|
-
done: false,
|
|
1230
|
+
if (!autoEstimationResult.exceeded) {
|
|
1231
|
+
const ae = autoEstimationResult.value;
|
|
1232
|
+
result.autoEstimation = {
|
|
1233
|
+
confidence: ae.confidence,
|
|
1234
|
+
reasoning: ae.reasoning,
|
|
1235
|
+
similarSpecs: ae.similarSpecs,
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
// SPEC-461: No per-spec HTML reports — lean format
|
|
1239
|
+
// SPEC-713: Run post-creation checks in parallel to reduce total wall-clock time.
|
|
1240
|
+
// Each has an individual budget; results are merged into `result` after settlement.
|
|
1241
|
+
const [readinessResult, qualityResult, duplicatesResult, complexityResult, priorLinksResult, nextStepsResult,] = await Promise.all([
|
|
1242
|
+
// Auto-readiness check (best-effort) — SPEC-713: 3s budget
|
|
1243
|
+
withBudget('auto-readiness', 3_000, () => measureStep('auto-readiness', () => checkSpecReadiness(spec, 'lenient'))),
|
|
1244
|
+
// SPEC-492: Spec quality score (0-100) — SPEC-713: 2s budget
|
|
1245
|
+
withBudget('quality-score', 2_000, () => measureStep('quality-score', () => runQualityScore(spec).then((r) => r ?? null))),
|
|
1246
|
+
// SPEC-514: Semantic duplicate detection — SPEC-713: 2s budget
|
|
1247
|
+
withBudget('duplicate-detection', 2_000, () => measureStep('duplicate-detection', async () => {
|
|
1248
|
+
const allSpecs = await specStore.listSpecs(projectId);
|
|
1249
|
+
const recentSpecs = allSpecs.slice(-200).filter((s) => s.id !== spec.id);
|
|
1250
|
+
return findSimilarSpecs(spec.title, recentSpecs, { threshold: 0.3, topN: 3 });
|
|
695
1251
|
})),
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
1252
|
+
// SPEC-614 AC4: complexity-category hint — SPEC-713: 2s budget
|
|
1253
|
+
withBudget('complexity-advice', 2_000, () => measureStep('complexity-advice', () => runComplexityAdvice(projectId, spec.id, spec.tags, filteredCriteria.length))),
|
|
1254
|
+
// SPEC-615: prior-decision links — SPEC-713: 2s budget
|
|
1255
|
+
withBudget('prior-decisions', 2_000, () => measureStep('prior-decisions', () => runPriorDecisionsHint(projectId, description, spec.tags))),
|
|
1256
|
+
// Post-creation suggestions — SPEC-713: 3s budget
|
|
1257
|
+
withBudget('post-creation-suggestions', 3_000, () => measureStep('post-creation-suggestions', () => generatePostCreationSuggestions(params.projectPath ?? '', description, knowledge ?? undefined))),
|
|
1258
|
+
]);
|
|
1259
|
+
// Merge parallel results into `result`
|
|
1260
|
+
if (!readinessResult.exceeded) {
|
|
1261
|
+
const readiness = readinessResult.value;
|
|
1262
|
+
result.autoReadiness = { score: readiness.score, isReady: readiness.ready };
|
|
1263
|
+
}
|
|
1264
|
+
else {
|
|
1265
|
+
budgetWarnings.push(readinessResult.warning);
|
|
1266
|
+
}
|
|
1267
|
+
const qualityValue = unwrapBudget(qualityResult, null, budgetWarnings);
|
|
1268
|
+
if (qualityValue !== null) {
|
|
1269
|
+
result.qualityScore = qualityValue;
|
|
1270
|
+
}
|
|
1271
|
+
// SPEC-485: Simplicity autopilot — detect over-engineering signals (best-effort, sync)
|
|
1272
|
+
const simplicityResult = runSimplicityCheck([params.description, ...filteredCriteria].join('\n'), estimation.devHours);
|
|
1273
|
+
if (simplicityResult) {
|
|
1274
|
+
result.simplicityCheck = simplicityResult;
|
|
1275
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
1276
|
+
key: 'simplicity-check',
|
|
1277
|
+
kind: 'simplicity',
|
|
1278
|
+
message: `Simplicity recommendation: ${simplicityResult.recommendation}.`,
|
|
1279
|
+
source: 'heuristic',
|
|
1280
|
+
evidence: simplicityResult.signals.map((signal) => signal.type),
|
|
1281
|
+
confidence: 0.55,
|
|
1282
|
+
surface: 'structuredContent',
|
|
1283
|
+
value: simplicityResult,
|
|
1284
|
+
deprecatedAlias: 'simplicityCheck',
|
|
1285
|
+
}));
|
|
1286
|
+
}
|
|
1287
|
+
// SPEC-514: duplicate results
|
|
1288
|
+
const possibleDuplicates = unwrapBudget(duplicatesResult, [], budgetWarnings);
|
|
1289
|
+
if (possibleDuplicates.length > 0) {
|
|
1290
|
+
result.possibleDuplicates = possibleDuplicates;
|
|
1291
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
1292
|
+
key: 'possible-duplicates',
|
|
1293
|
+
kind: 'duplicate',
|
|
1294
|
+
message: `${String(possibleDuplicates.length)} possible duplicate spec(s) detected.`,
|
|
1295
|
+
source: 'heuristic',
|
|
1296
|
+
evidence: possibleDuplicates.map((dup) => dup.specId),
|
|
1297
|
+
confidence: 0.5,
|
|
1298
|
+
surface: 'structuredContent',
|
|
1299
|
+
value: possibleDuplicates,
|
|
1300
|
+
deprecatedAlias: 'possibleDuplicates',
|
|
1301
|
+
}));
|
|
1302
|
+
}
|
|
1303
|
+
// SPEC-222 Trigger 2: Auto-challenge hint for high-risk specs
|
|
1304
|
+
if (spec.risk === 'high' || spec.difficulty >= 4) {
|
|
1305
|
+
result.riskWarning = HIGH_RISK_WARNING;
|
|
1306
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
1307
|
+
key: 'risk-warning',
|
|
1308
|
+
kind: 'challenge',
|
|
1309
|
+
message: 'High-risk heuristic warning generated.',
|
|
1310
|
+
source: 'heuristic',
|
|
1311
|
+
evidence: [`risk:${spec.risk}`, `difficulty:${String(spec.difficulty)}`],
|
|
1312
|
+
confidence: 0.6,
|
|
1313
|
+
surface: 'structuredContent',
|
|
1314
|
+
value: HIGH_RISK_WARNING,
|
|
1315
|
+
deprecatedAlias: 'riskWarning',
|
|
1316
|
+
}));
|
|
1317
|
+
}
|
|
1318
|
+
// SPEC-614 AC4: complexity advice
|
|
1319
|
+
const cAdvice = unwrapBudget(complexityResult, null, budgetWarnings);
|
|
1320
|
+
if (cAdvice) {
|
|
1321
|
+
result.complexityAdvice = cAdvice;
|
|
1322
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
1323
|
+
key: 'complexity-advice',
|
|
1324
|
+
kind: 'complexity',
|
|
1325
|
+
message: cAdvice.reasoning,
|
|
1326
|
+
source: 'history',
|
|
1327
|
+
evidence: [`similarSpecs:${String(cAdvice.similarSpecsCount)}`],
|
|
1328
|
+
confidence: 0.6,
|
|
1329
|
+
surface: 'structuredContent',
|
|
1330
|
+
value: cAdvice,
|
|
1331
|
+
deprecatedAlias: 'complexityAdvice',
|
|
1332
|
+
}));
|
|
1333
|
+
}
|
|
1334
|
+
// SPEC-615: prior decisions
|
|
1335
|
+
const priorLinks = unwrapBudget(priorLinksResult, [], budgetWarnings);
|
|
1336
|
+
if (priorLinks.length > 0) {
|
|
1337
|
+
result.priorDecisions = priorLinks.map((l) => l.decisionId);
|
|
1338
|
+
spec.priorDecisions = result.priorDecisions;
|
|
1339
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
1340
|
+
key: 'prior-decisions',
|
|
1341
|
+
kind: 'prior-decision',
|
|
1342
|
+
message: `${String(priorLinks.length)} prior decision link(s) found.`,
|
|
1343
|
+
source: 'history',
|
|
1344
|
+
evidence: priorLinks.map((link) => link.decisionId),
|
|
1345
|
+
confidence: 0.65,
|
|
1346
|
+
surface: 'structuredContent',
|
|
1347
|
+
value: result.priorDecisions,
|
|
1348
|
+
deprecatedAlias: 'priorDecisions',
|
|
1349
|
+
}));
|
|
1350
|
+
}
|
|
1351
|
+
// Post-creation suggestions
|
|
1352
|
+
const nextSteps = unwrapBudget(nextStepsResult, [], budgetWarnings);
|
|
1353
|
+
if (nextSteps.length > 0) {
|
|
1354
|
+
result.nextSteps = nextSteps;
|
|
1355
|
+
}
|
|
1356
|
+
// SPEC-713: Surface budget warnings in result when steps exceeded their budget
|
|
1357
|
+
if (budgetWarnings.length > 0) {
|
|
1358
|
+
result.budgetWarnings = budgetWarnings;
|
|
1359
|
+
}
|
|
1360
|
+
// SPEC-1017: No auto-regeneration of project-tree dashboard HTML.
|
|
1361
|
+
if (params.projectPath) {
|
|
1362
|
+
notifyStoreChange(params.projectPath, 'specs');
|
|
1363
|
+
}
|
|
1364
|
+
// SPEC-644: Refresh model mapping TTL in background (fire-and-forget)
|
|
1365
|
+
void import('../engine/model-tier-resolver.js')
|
|
1366
|
+
.then(({ triggerModelMappingRefresh }) => {
|
|
1367
|
+
triggerModelMappingRefresh(knowledge?.projectPath ?? params.projectPath ?? '');
|
|
1368
|
+
})
|
|
1369
|
+
.catch(() => {
|
|
1370
|
+
/* best-effort */
|
|
707
1371
|
});
|
|
708
|
-
//
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
const
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
'pnpm typecheck',
|
|
722
|
-
'pnpm lint',
|
|
723
|
-
'pnpm test',
|
|
724
|
-
],
|
|
1372
|
+
// Auto post-creation pipeline: challenge + readiness (SPEC-445)
|
|
1373
|
+
// SPEC-713: Convert to fire-and-forget — pipeline has its own 10s internal timeout
|
|
1374
|
+
// but was blocking the MCP response. Now runs in background after response is built.
|
|
1375
|
+
const pipelineResult = {
|
|
1376
|
+
challengeSummary: null,
|
|
1377
|
+
readinessScore: null,
|
|
1378
|
+
readinessSuggestion: null,
|
|
1379
|
+
error: 'pipeline deferred (SPEC-713 fire-and-forget)',
|
|
1380
|
+
};
|
|
1381
|
+
// Start pipeline in background — result will be lost after response, but spec is
|
|
1382
|
+
// already persisted so this is a best-effort enrichment only.
|
|
1383
|
+
void runAutoPostCreatePipeline(spec.id, projectId, knowledge?.projectPath ?? params.projectPath ?? '').catch(() => {
|
|
1384
|
+
/* best-effort */
|
|
725
1385
|
});
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
issues: genericOutputGate.issues,
|
|
746
|
-
fixHint: 'Replace generic criteria or placeholder references with grounded, testable behavior.',
|
|
747
|
-
},
|
|
748
|
-
},
|
|
749
|
-
};
|
|
1386
|
+
// Build markdown response
|
|
1387
|
+
const lines = [
|
|
1388
|
+
`**SPEC-${spec.id}** — ${spec.title}`,
|
|
1389
|
+
'',
|
|
1390
|
+
`| Field | Value |`,
|
|
1391
|
+
`|-------|-------|`,
|
|
1392
|
+
`| Type | ${spec.type} |`,
|
|
1393
|
+
`| Scope | ${spec.scope} |`,
|
|
1394
|
+
`| Difficulty | ${String(spec.difficulty)}/5 |`,
|
|
1395
|
+
`| Risk | ${spec.risk} |`,
|
|
1396
|
+
`| Target | ${spec.target} |`,
|
|
1397
|
+
`| Status | ${spec.status} |`,
|
|
1398
|
+
`| Tags | ${spec.tags.join(', ')} |`,
|
|
1399
|
+
`| Dev hours | ${String(estimation.devHours)}h |`,
|
|
1400
|
+
`| Review hours | ${String(estimation.reviewHours)}h |`,
|
|
1401
|
+
`| Cost | $${String(estimation.totalCostUsd)} |`,
|
|
1402
|
+
];
|
|
1403
|
+
if (spec.gitBranch) {
|
|
1404
|
+
lines.push(`| Branch | \`${spec.gitBranch}\` |`);
|
|
750
1405
|
}
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
await measureStep('writeFile-specPath', () => writeFile(specPath, unifiedSpec, 'utf-8'));
|
|
754
|
-
// SPEC-709: technical.md no longer written — content lives inside spec.md.
|
|
755
|
-
// SPEC-461: No progress.md, no HTML reports.
|
|
1406
|
+
if (duplicate) {
|
|
1407
|
+
lines.push('', `⚠️ ${ti('spec.duplicateTitle', { title: spec.title, slug: spec.slug })}`);
|
|
756
1408
|
}
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
throw writeErr;
|
|
1409
|
+
if (constitutionCheck.warnings.length > 0) {
|
|
1410
|
+
lines.push('', '⚠️ **Constitution Warnings**');
|
|
1411
|
+
constitutionCheck.warnings.forEach((w) => lines.push(`- ${w.description}`));
|
|
761
1412
|
}
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
spec.outOfScope = outOfScopeResolved.items;
|
|
1413
|
+
if (contradictionHint) {
|
|
1414
|
+
lines.push('', `⚠️ ${contradictionHint}`);
|
|
765
1415
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
1416
|
+
if (possibleDuplicates.length > 0 ||
|
|
1417
|
+
result.riskWarning ||
|
|
1418
|
+
splitResult ||
|
|
1419
|
+
result.qualityScore ||
|
|
1420
|
+
simplicityResult) {
|
|
1421
|
+
lines.push('', 'Advisory signals were computed and are available in structuredContent.advisorySignals.');
|
|
1422
|
+
}
|
|
1423
|
+
const allNextSteps = (result.nextSteps ?? []).map((step) => (typeof step === 'string' ? step : formatPostCreationSuggestion(step)));
|
|
1424
|
+
const markdownText = allNextSteps.length > 0
|
|
1425
|
+
? addNextSteps(formatSuccess(ti('tools.create_spec.success', { id: spec.id, title: spec.title }), lines.join('\n')), allNextSteps)
|
|
1426
|
+
: formatSuccess(ti('tools.create_spec.success', { id: spec.id, title: spec.title }), lines.join('\n'));
|
|
1427
|
+
// SPEC-469: Build autopilot summary from analyzer results
|
|
1428
|
+
const collector = new AutopilotSummaryCollector();
|
|
1429
|
+
// SPEC-612: Record outOfScope auto-suggestion in autopilot summary
|
|
1430
|
+
if (outOfScopeSuggestionMsg !== null) {
|
|
1431
|
+
collector.pushOk('scope-boundaries', outOfScopeSuggestionMsg);
|
|
1432
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
1433
|
+
key: 'out-of-scope-suggestions',
|
|
1434
|
+
kind: 'out-of-scope',
|
|
1435
|
+
message: outOfScopeSuggestionMsg,
|
|
1436
|
+
source: 'heuristic',
|
|
1437
|
+
evidence: ['scope-boundaries suggester'],
|
|
1438
|
+
confidence: 0.45,
|
|
1439
|
+
surface: 'structuredContent',
|
|
1440
|
+
value: spec.outOfScope ?? [],
|
|
1441
|
+
}));
|
|
1442
|
+
}
|
|
1443
|
+
if (autopilot.detectedPatterns.length > 0) {
|
|
1444
|
+
collector.pushOk('pattern-detection', `Detected patterns: ${autopilot.detectedPatterns.join(', ')}`);
|
|
1445
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
1446
|
+
key: 'detected-patterns',
|
|
1447
|
+
kind: 'domain',
|
|
1448
|
+
message: `Detected patterns: ${autopilot.detectedPatterns.join(', ')}`,
|
|
1449
|
+
source: 'heuristic',
|
|
1450
|
+
evidence: autopilot.detectedPatterns,
|
|
1451
|
+
confidence: 0.5,
|
|
1452
|
+
surface: 'structuredContent',
|
|
1453
|
+
value: autopilot.detectedPatterns,
|
|
1454
|
+
}));
|
|
1455
|
+
}
|
|
1456
|
+
const totalSuggestedFiles = autopilot.suggestedFiles.create.length +
|
|
1457
|
+
autopilot.suggestedFiles.modify.length +
|
|
1458
|
+
autopilot.suggestedFiles.test.length;
|
|
1459
|
+
if (totalSuggestedFiles > 0) {
|
|
1460
|
+
collector.pushOk('file-analysis', `Suggested ${String(totalSuggestedFiles)} files (${String(autopilot.suggestedFiles.create.length)} create, ${String(autopilot.suggestedFiles.modify.length)} modify, ${String(autopilot.suggestedFiles.test.length)} test)`);
|
|
1461
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
1462
|
+
key: 'suggested-files',
|
|
1463
|
+
kind: 'file',
|
|
1464
|
+
message: `${String(totalSuggestedFiles)} possible related file(s) detected.`,
|
|
1465
|
+
source: 'heuristic',
|
|
1466
|
+
evidence: [
|
|
1467
|
+
...autopilot.suggestedFiles.create.map((f) => f.path),
|
|
1468
|
+
...autopilot.suggestedFiles.modify.map((f) => f.path),
|
|
1469
|
+
...autopilot.suggestedFiles.test.map((f) => f.path),
|
|
790
1470
|
],
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
if (criticalResult.timedOut) {
|
|
798
|
-
// SPEC-770: Wait briefly for any in-flight spec write to complete, then scan for duplicates
|
|
799
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
800
|
-
const recentSpecs = await findRecentlyWrittenSpecs(resolvedPath, 2 * 60 * 1000);
|
|
801
|
-
const possibleDuplicates = recentSpecs.length > 0 ? recentSpecs : undefined;
|
|
802
|
-
const dupNote = possibleDuplicates
|
|
803
|
-
? `\n\nA spec may have been created server-side: ${possibleDuplicates.map((s) => s.id).join(', ')}. Retry with the same title to return it instead of creating a duplicate.`
|
|
804
|
-
: '\n\nThe spec may not have been persisted. Check `planu/specs/` and retry.';
|
|
805
|
-
return {
|
|
806
|
-
content: [
|
|
807
|
-
{
|
|
808
|
-
type: 'text',
|
|
809
|
-
text: `❌ create_spec exceeded 25s internal budget.\n\n${criticalResult.warning}${dupNote}`,
|
|
1471
|
+
confidence: 0.45,
|
|
1472
|
+
surface: 'structuredContent',
|
|
1473
|
+
value: {
|
|
1474
|
+
create: autopilot.suggestedFiles.create.map((f) => f.path),
|
|
1475
|
+
modify: autopilot.suggestedFiles.modify.map((f) => f.path),
|
|
1476
|
+
test: autopilot.suggestedFiles.test.map((f) => f.path),
|
|
810
1477
|
},
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
gitBranch: spec.gitBranch,
|
|
869
|
-
specPath,
|
|
870
|
-
// SPEC-1010 Bug A — `technicalPath` no longer leaked in the response.
|
|
871
|
-
// SSR back-migration (SPEC-752) folded technical.md into spec.md as
|
|
872
|
-
// ## Technical; the file is never written and the field would point to
|
|
873
|
-
// a non-existent path. Internal Spec record still carries it for
|
|
874
|
-
// backwards-compat with stored data — that field is removed in PR-C.
|
|
875
|
-
estimation: {
|
|
876
|
-
devHours: calibrationEnrichment.calibratedHours,
|
|
877
|
-
reviewHours: estimation.reviewHours,
|
|
878
|
-
totalCostUsd: estimation.totalCostUsd,
|
|
879
|
-
recommendedModel: estimation.recommendedModel,
|
|
880
|
-
executionMode: estimation.tokenOptimization.mode,
|
|
881
|
-
calendarDays: velocityEnrichment.calendarDays,
|
|
882
|
-
velocityNote: velocityEnrichment.velocityNote,
|
|
883
|
-
},
|
|
884
|
-
duplicateWarning: duplicate
|
|
885
|
-
? ti('spec.duplicateTitle', { title: spec.title, slug: spec.slug })
|
|
886
|
-
: undefined,
|
|
887
|
-
...(constitutionCheck.warnings.length > 0
|
|
888
|
-
? { constitutionWarnings: constitutionCheck.warnings }
|
|
889
|
-
: {}),
|
|
890
|
-
...(clarificationSession
|
|
891
|
-
? {
|
|
892
|
-
linkedClarificationSession: clarificationSession.id,
|
|
893
|
-
clarificationTopic: clarificationSession.topic,
|
|
894
|
-
clarificationAnswersUsed: Object.keys(clarificationSession.answers).length,
|
|
1478
|
+
deprecatedAlias: 'autopilotSummary.suggestedFiles',
|
|
1479
|
+
}));
|
|
1480
|
+
}
|
|
1481
|
+
if (filteredCriteria.length > 0) {
|
|
1482
|
+
collector.pushOk('criteria-enrichment', `Added ${String(filteredCriteria.length)} acceptance criteria from project context`);
|
|
1483
|
+
}
|
|
1484
|
+
if (pipelineResult.challengeSummary) {
|
|
1485
|
+
collector.pushOk('challenge', `Challenge analysis complete: ${pipelineResult.challengeSummary}`);
|
|
1486
|
+
}
|
|
1487
|
+
if (pipelineResult.readinessScore !== null) {
|
|
1488
|
+
// SPEC-629: append Opus hint for difficulty >= 3 specs
|
|
1489
|
+
const opusHint = spec.difficulty >= 3
|
|
1490
|
+
? ` — difficulty ${String(spec.difficulty)}: use Opus to add exact file paths + function names`
|
|
1491
|
+
: '';
|
|
1492
|
+
collector.pushOk('readiness', `Readiness score: ${String(pipelineResult.readinessScore)}/100${opusHint}`);
|
|
1493
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
1494
|
+
key: 'readiness-score',
|
|
1495
|
+
kind: 'readiness',
|
|
1496
|
+
message: `Readiness score: ${String(pipelineResult.readinessScore)}/100.`,
|
|
1497
|
+
source: 'validator',
|
|
1498
|
+
evidence: ['auto post-creation pipeline'],
|
|
1499
|
+
confidence: 0.6,
|
|
1500
|
+
surface: 'structuredContent',
|
|
1501
|
+
value: pipelineResult.readinessScore,
|
|
1502
|
+
}));
|
|
1503
|
+
}
|
|
1504
|
+
else if (spec.difficulty >= 3) {
|
|
1505
|
+
// SPEC-629: always surface the Opus hint for high-difficulty specs
|
|
1506
|
+
collector.pushOk('recommend_model', `Difficulty ${String(spec.difficulty)} spec — use Opus to add exact file paths, function names and anticipated test breaks.`);
|
|
1507
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
1508
|
+
key: 'model-recommendation',
|
|
1509
|
+
kind: 'model',
|
|
1510
|
+
message: 'High-difficulty spec may benefit from a stronger model for review.',
|
|
1511
|
+
source: 'heuristic',
|
|
1512
|
+
evidence: [`difficulty:${String(spec.difficulty)}`],
|
|
1513
|
+
confidence: 0.4,
|
|
1514
|
+
surface: 'structuredContent',
|
|
1515
|
+
value: { recommendedTier: 'max' },
|
|
1516
|
+
}));
|
|
1517
|
+
}
|
|
1518
|
+
if (calibrationEnrichment.calibrationNote) {
|
|
1519
|
+
collector.pushOk('calibration', `📐 ${calibrationEnrichment.calibrationNote}`);
|
|
1520
|
+
}
|
|
1521
|
+
const humanSummary = buildCreateSpecSummary(spec.title, estimation.devHours);
|
|
1522
|
+
result.advisorySignals = advisorySignals;
|
|
1523
|
+
result.compat = buildAdvisoryCompat();
|
|
1524
|
+
const compactResult = compactObj(result);
|
|
1525
|
+
// SPEC-722: Issue a planner token for this spec creation.
|
|
1526
|
+
// Best-effort — token failure never blocks spec creation.
|
|
1527
|
+
let plannerToken;
|
|
1528
|
+
try {
|
|
1529
|
+
const resolvedProjectPath = resolvedInputParams.projectPath ?? '';
|
|
1530
|
+
if (resolvedProjectPath.length > 0) {
|
|
1531
|
+
const sessionId = resolvedInputParams.sessionId ?? 'unknown-session';
|
|
1532
|
+
const modelId = resolvedInputParams.modelId ?? 'unknown-model';
|
|
1533
|
+
const host = resolvedInputParams.host ?? 'unknown-host';
|
|
1534
|
+
plannerToken = await issuePlannerToken(resolvedProjectPath, spec.id, sessionId, modelId, host);
|
|
895
1535
|
}
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
}
|
|
908
|
-
if (advisoryCriteria.length > 0) {
|
|
909
|
-
advisorySignals.push(makeAdvisorySignal({
|
|
910
|
-
key: 'ungrounded-contract-items',
|
|
911
|
-
kind: 'quality',
|
|
912
|
-
message: `${String(advisoryCriteria.length)} ungrounded contract item(s) kept advisory-only.`,
|
|
913
|
-
source: 'validator',
|
|
914
|
-
evidence: advisoryCriteria.slice(0, 10),
|
|
915
|
-
confidence: 0.8,
|
|
916
|
-
surface: 'structuredContent',
|
|
917
|
-
value: advisoryCriteria,
|
|
918
|
-
}));
|
|
919
|
-
}
|
|
920
|
-
const splitResult = buildSplitResult(splitSuggestion, knowledge?.experienceLevel);
|
|
921
|
-
if (splitResult) {
|
|
922
|
-
result.splitSuggestion = splitResult;
|
|
923
|
-
advisorySignals.push(makeAdvisorySignal({
|
|
924
|
-
key: 'split-suggestion',
|
|
925
|
-
kind: 'complexity',
|
|
926
|
-
message: 'Spec may benefit from splitting.',
|
|
927
|
-
source: 'heuristic',
|
|
928
|
-
evidence: ['spec-splitter heuristic'],
|
|
929
|
-
confidence: 0.5,
|
|
930
|
-
surface: 'structuredContent',
|
|
931
|
-
value: splitResult,
|
|
932
|
-
deprecatedAlias: 'splitSuggestion',
|
|
933
|
-
}));
|
|
934
|
-
}
|
|
935
|
-
// Dispatch hook event (fire-and-forget)
|
|
936
|
-
fireSpecCreatedHook(projectId, spec, params.projectPath ?? '');
|
|
937
|
-
// SPEC-781: Fire-and-forget async analysis (external project data)
|
|
938
|
-
runAutopilotAsync(spec.id, params.projectPath ?? '', description);
|
|
939
|
-
// SPEC-713: Track warnings from budgeted post-creation steps
|
|
940
|
-
const budgetWarnings = [];
|
|
941
|
-
// Auto-estimation (best-effort, fire-and-forget)
|
|
942
|
-
// SPEC-713: withBudget 2s — estimation is sync, specStore.updateSpec is a single JSON write
|
|
943
|
-
const autoEstimationResult = await withBudget('auto-estimation', 2_000, async () => {
|
|
944
|
-
const autoEstimation = estimateSpec(spec);
|
|
945
|
-
await measureStep('specStore-updateSpec', () => specStore.updateSpec(projectId, spec.id, { estimation: autoEstimation.estimation }));
|
|
946
|
-
return autoEstimation;
|
|
947
|
-
});
|
|
948
|
-
if (!autoEstimationResult.exceeded) {
|
|
949
|
-
const ae = autoEstimationResult.value;
|
|
950
|
-
result.autoEstimation = {
|
|
951
|
-
confidence: ae.confidence,
|
|
952
|
-
reasoning: ae.reasoning,
|
|
953
|
-
similarSpecs: ae.similarSpecs,
|
|
954
|
-
};
|
|
955
|
-
}
|
|
956
|
-
// SPEC-461: No per-spec HTML reports — lean format
|
|
957
|
-
// SPEC-713: Run post-creation checks in parallel to reduce total wall-clock time.
|
|
958
|
-
// Each has an individual budget; results are merged into `result` after settlement.
|
|
959
|
-
const [readinessResult, qualityResult, duplicatesResult, complexityResult, priorLinksResult, nextStepsResult,] = await Promise.all([
|
|
960
|
-
// Auto-readiness check (best-effort) — SPEC-713: 3s budget
|
|
961
|
-
withBudget('auto-readiness', 3_000, () => measureStep('auto-readiness', () => checkSpecReadiness(spec, 'lenient'))),
|
|
962
|
-
// SPEC-492: Spec quality score (0-100) — SPEC-713: 2s budget
|
|
963
|
-
withBudget('quality-score', 2_000, () => measureStep('quality-score', () => runQualityScore(spec).then((r) => r ?? null))),
|
|
964
|
-
// SPEC-514: Semantic duplicate detection — SPEC-713: 2s budget
|
|
965
|
-
withBudget('duplicate-detection', 2_000, () => measureStep('duplicate-detection', async () => {
|
|
966
|
-
const allSpecs = await specStore.listSpecs(projectId);
|
|
967
|
-
const recentSpecs = allSpecs.slice(-200).filter((s) => s.id !== spec.id);
|
|
968
|
-
return findSimilarSpecs(spec.title, recentSpecs, { threshold: 0.3, topN: 3 });
|
|
969
|
-
})),
|
|
970
|
-
// SPEC-614 AC4: complexity-category hint — SPEC-713: 2s budget
|
|
971
|
-
withBudget('complexity-advice', 2_000, () => measureStep('complexity-advice', () => runComplexityAdvice(projectId, spec.id, spec.tags, filteredCriteria.length))),
|
|
972
|
-
// SPEC-615: prior-decision links — SPEC-713: 2s budget
|
|
973
|
-
withBudget('prior-decisions', 2_000, () => measureStep('prior-decisions', () => runPriorDecisionsHint(projectId, description, spec.tags))),
|
|
974
|
-
// Post-creation suggestions — SPEC-713: 3s budget
|
|
975
|
-
withBudget('post-creation-suggestions', 3_000, () => measureStep('post-creation-suggestions', () => generatePostCreationSuggestions(params.projectPath ?? '', description, knowledge ?? undefined))),
|
|
976
|
-
]);
|
|
977
|
-
// Merge parallel results into `result`
|
|
978
|
-
if (!readinessResult.exceeded) {
|
|
979
|
-
const readiness = readinessResult.value;
|
|
980
|
-
result.autoReadiness = { score: readiness.score, isReady: readiness.ready };
|
|
981
|
-
}
|
|
982
|
-
else {
|
|
983
|
-
budgetWarnings.push(readinessResult.warning);
|
|
984
|
-
}
|
|
985
|
-
const qualityValue = unwrapBudget(qualityResult, null, budgetWarnings);
|
|
986
|
-
if (qualityValue !== null) {
|
|
987
|
-
result.qualityScore = qualityValue;
|
|
988
|
-
}
|
|
989
|
-
// SPEC-485: Simplicity autopilot — detect over-engineering signals (best-effort, sync)
|
|
990
|
-
const simplicityResult = runSimplicityCheck([params.description, ...filteredCriteria].join('\n'), estimation.devHours);
|
|
991
|
-
if (simplicityResult) {
|
|
992
|
-
result.simplicityCheck = simplicityResult;
|
|
993
|
-
advisorySignals.push(makeAdvisorySignal({
|
|
994
|
-
key: 'simplicity-check',
|
|
995
|
-
kind: 'simplicity',
|
|
996
|
-
message: `Simplicity recommendation: ${simplicityResult.recommendation}.`,
|
|
997
|
-
source: 'heuristic',
|
|
998
|
-
evidence: simplicityResult.signals.map((signal) => signal.type),
|
|
999
|
-
confidence: 0.55,
|
|
1000
|
-
surface: 'structuredContent',
|
|
1001
|
-
value: simplicityResult,
|
|
1002
|
-
deprecatedAlias: 'simplicityCheck',
|
|
1003
|
-
}));
|
|
1004
|
-
}
|
|
1005
|
-
// SPEC-514: duplicate results
|
|
1006
|
-
const possibleDuplicates = unwrapBudget(duplicatesResult, [], budgetWarnings);
|
|
1007
|
-
if (possibleDuplicates.length > 0) {
|
|
1008
|
-
result.possibleDuplicates = possibleDuplicates;
|
|
1009
|
-
advisorySignals.push(makeAdvisorySignal({
|
|
1010
|
-
key: 'possible-duplicates',
|
|
1011
|
-
kind: 'duplicate',
|
|
1012
|
-
message: `${String(possibleDuplicates.length)} possible duplicate spec(s) detected.`,
|
|
1013
|
-
source: 'heuristic',
|
|
1014
|
-
evidence: possibleDuplicates.map((dup) => dup.specId),
|
|
1015
|
-
confidence: 0.5,
|
|
1016
|
-
surface: 'structuredContent',
|
|
1017
|
-
value: possibleDuplicates,
|
|
1018
|
-
deprecatedAlias: 'possibleDuplicates',
|
|
1019
|
-
}));
|
|
1020
|
-
}
|
|
1021
|
-
// SPEC-222 Trigger 2: Auto-challenge hint for high-risk specs
|
|
1022
|
-
if (spec.risk === 'high' || spec.difficulty >= 4) {
|
|
1023
|
-
result.riskWarning = HIGH_RISK_WARNING;
|
|
1024
|
-
advisorySignals.push(makeAdvisorySignal({
|
|
1025
|
-
key: 'risk-warning',
|
|
1026
|
-
kind: 'challenge',
|
|
1027
|
-
message: 'High-risk heuristic warning generated.',
|
|
1028
|
-
source: 'heuristic',
|
|
1029
|
-
evidence: [`risk:${spec.risk}`, `difficulty:${String(spec.difficulty)}`],
|
|
1030
|
-
confidence: 0.6,
|
|
1031
|
-
surface: 'structuredContent',
|
|
1032
|
-
value: HIGH_RISK_WARNING,
|
|
1033
|
-
deprecatedAlias: 'riskWarning',
|
|
1034
|
-
}));
|
|
1035
|
-
}
|
|
1036
|
-
// SPEC-614 AC4: complexity advice
|
|
1037
|
-
const cAdvice = unwrapBudget(complexityResult, null, budgetWarnings);
|
|
1038
|
-
if (cAdvice) {
|
|
1039
|
-
result.complexityAdvice = cAdvice;
|
|
1040
|
-
advisorySignals.push(makeAdvisorySignal({
|
|
1041
|
-
key: 'complexity-advice',
|
|
1042
|
-
kind: 'complexity',
|
|
1043
|
-
message: cAdvice.reasoning,
|
|
1044
|
-
source: 'history',
|
|
1045
|
-
evidence: [`similarSpecs:${String(cAdvice.similarSpecsCount)}`],
|
|
1046
|
-
confidence: 0.6,
|
|
1047
|
-
surface: 'structuredContent',
|
|
1048
|
-
value: cAdvice,
|
|
1049
|
-
deprecatedAlias: 'complexityAdvice',
|
|
1050
|
-
}));
|
|
1051
|
-
}
|
|
1052
|
-
// SPEC-615: prior decisions
|
|
1053
|
-
const priorLinks = unwrapBudget(priorLinksResult, [], budgetWarnings);
|
|
1054
|
-
if (priorLinks.length > 0) {
|
|
1055
|
-
result.priorDecisions = priorLinks.map((l) => l.decisionId);
|
|
1056
|
-
spec.priorDecisions = result.priorDecisions;
|
|
1057
|
-
advisorySignals.push(makeAdvisorySignal({
|
|
1058
|
-
key: 'prior-decisions',
|
|
1059
|
-
kind: 'prior-decision',
|
|
1060
|
-
message: `${String(priorLinks.length)} prior decision link(s) found.`,
|
|
1061
|
-
source: 'history',
|
|
1062
|
-
evidence: priorLinks.map((link) => link.decisionId),
|
|
1063
|
-
confidence: 0.65,
|
|
1064
|
-
surface: 'structuredContent',
|
|
1065
|
-
value: result.priorDecisions,
|
|
1066
|
-
deprecatedAlias: 'priorDecisions',
|
|
1067
|
-
}));
|
|
1068
|
-
}
|
|
1069
|
-
// Post-creation suggestions
|
|
1070
|
-
const nextSteps = unwrapBudget(nextStepsResult, [], budgetWarnings);
|
|
1071
|
-
if (nextSteps.length > 0) {
|
|
1072
|
-
result.nextSteps = nextSteps;
|
|
1073
|
-
}
|
|
1074
|
-
// SPEC-713: Surface budget warnings in result when steps exceeded their budget
|
|
1075
|
-
if (budgetWarnings.length > 0) {
|
|
1076
|
-
result.budgetWarnings = budgetWarnings;
|
|
1077
|
-
}
|
|
1078
|
-
// SPEC-1017: No auto-regeneration of project-tree dashboard HTML.
|
|
1079
|
-
if (params.projectPath) {
|
|
1080
|
-
notifyStoreChange(params.projectPath, 'specs');
|
|
1081
|
-
}
|
|
1082
|
-
// SPEC-644: Refresh model mapping TTL in background (fire-and-forget)
|
|
1083
|
-
void import('../engine/model-tier-resolver.js')
|
|
1084
|
-
.then(({ triggerModelMappingRefresh }) => {
|
|
1085
|
-
triggerModelMappingRefresh(knowledge?.projectPath ?? params.projectPath ?? '');
|
|
1086
|
-
})
|
|
1087
|
-
.catch(() => {
|
|
1088
|
-
/* best-effort */
|
|
1089
|
-
});
|
|
1090
|
-
// Auto post-creation pipeline: challenge + readiness (SPEC-445)
|
|
1091
|
-
// SPEC-713: Convert to fire-and-forget — pipeline has its own 10s internal timeout
|
|
1092
|
-
// but was blocking the MCP response. Now runs in background after response is built.
|
|
1093
|
-
const pipelineResult = {
|
|
1094
|
-
challengeSummary: null,
|
|
1095
|
-
readinessScore: null,
|
|
1096
|
-
readinessSuggestion: null,
|
|
1097
|
-
error: 'pipeline deferred (SPEC-713 fire-and-forget)',
|
|
1098
|
-
};
|
|
1099
|
-
// Start pipeline in background — result will be lost after response, but spec is
|
|
1100
|
-
// already persisted so this is a best-effort enrichment only.
|
|
1101
|
-
void runAutoPostCreatePipeline(spec.id, projectId, knowledge?.projectPath ?? params.projectPath ?? '').catch(() => {
|
|
1102
|
-
/* best-effort */
|
|
1103
|
-
});
|
|
1104
|
-
// Build markdown response
|
|
1105
|
-
const lines = [
|
|
1106
|
-
`**SPEC-${spec.id}** — ${spec.title}`,
|
|
1107
|
-
'',
|
|
1108
|
-
`| Field | Value |`,
|
|
1109
|
-
`|-------|-------|`,
|
|
1110
|
-
`| Type | ${spec.type} |`,
|
|
1111
|
-
`| Scope | ${spec.scope} |`,
|
|
1112
|
-
`| Difficulty | ${String(spec.difficulty)}/5 |`,
|
|
1113
|
-
`| Risk | ${spec.risk} |`,
|
|
1114
|
-
`| Target | ${spec.target} |`,
|
|
1115
|
-
`| Status | ${spec.status} |`,
|
|
1116
|
-
`| Tags | ${spec.tags.join(', ')} |`,
|
|
1117
|
-
`| Dev hours | ${String(estimation.devHours)}h |`,
|
|
1118
|
-
`| Review hours | ${String(estimation.reviewHours)}h |`,
|
|
1119
|
-
`| Cost | $${String(estimation.totalCostUsd)} |`,
|
|
1120
|
-
];
|
|
1121
|
-
if (spec.gitBranch) {
|
|
1122
|
-
lines.push(`| Branch | \`${spec.gitBranch}\` |`);
|
|
1123
|
-
}
|
|
1124
|
-
if (duplicate) {
|
|
1125
|
-
lines.push('', `⚠️ ${ti('spec.duplicateTitle', { title: spec.title, slug: spec.slug })}`);
|
|
1126
|
-
}
|
|
1127
|
-
if (constitutionCheck.warnings.length > 0) {
|
|
1128
|
-
lines.push('', '⚠️ **Constitution Warnings**');
|
|
1129
|
-
constitutionCheck.warnings.forEach((w) => lines.push(`- ${w.description}`));
|
|
1130
|
-
}
|
|
1131
|
-
if (contradictionHint) {
|
|
1132
|
-
lines.push('', `⚠️ ${contradictionHint}`);
|
|
1133
|
-
}
|
|
1134
|
-
if (possibleDuplicates.length > 0 ||
|
|
1135
|
-
result.riskWarning ||
|
|
1136
|
-
splitResult ||
|
|
1137
|
-
result.qualityScore ||
|
|
1138
|
-
simplicityResult) {
|
|
1139
|
-
lines.push('', 'Advisory signals were computed and are available in structuredContent.advisorySignals.');
|
|
1140
|
-
}
|
|
1141
|
-
const allNextSteps = (result.nextSteps ?? []).map((step) => (typeof step === 'string' ? step : formatPostCreationSuggestion(step)));
|
|
1142
|
-
const markdownText = allNextSteps.length > 0
|
|
1143
|
-
? addNextSteps(formatSuccess(ti('tools.create_spec.success', { id: spec.id, title: spec.title }), lines.join('\n')), allNextSteps)
|
|
1144
|
-
: formatSuccess(ti('tools.create_spec.success', { id: spec.id, title: spec.title }), lines.join('\n'));
|
|
1145
|
-
// SPEC-469: Build autopilot summary from analyzer results
|
|
1146
|
-
const collector = new AutopilotSummaryCollector();
|
|
1147
|
-
// SPEC-612: Record outOfScope auto-suggestion in autopilot summary
|
|
1148
|
-
if (outOfScopeSuggestionMsg !== null) {
|
|
1149
|
-
collector.pushOk('scope-boundaries', outOfScopeSuggestionMsg);
|
|
1150
|
-
advisorySignals.push(makeAdvisorySignal({
|
|
1151
|
-
key: 'out-of-scope-suggestions',
|
|
1152
|
-
kind: 'out-of-scope',
|
|
1153
|
-
message: outOfScopeSuggestionMsg,
|
|
1154
|
-
source: 'heuristic',
|
|
1155
|
-
evidence: ['scope-boundaries suggester'],
|
|
1156
|
-
confidence: 0.45,
|
|
1157
|
-
surface: 'structuredContent',
|
|
1158
|
-
value: spec.outOfScope ?? [],
|
|
1159
|
-
}));
|
|
1160
|
-
}
|
|
1161
|
-
if (autopilot.detectedPatterns.length > 0) {
|
|
1162
|
-
collector.pushOk('pattern-detection', `Detected patterns: ${autopilot.detectedPatterns.join(', ')}`);
|
|
1163
|
-
advisorySignals.push(makeAdvisorySignal({
|
|
1164
|
-
key: 'detected-patterns',
|
|
1165
|
-
kind: 'domain',
|
|
1166
|
-
message: `Detected patterns: ${autopilot.detectedPatterns.join(', ')}`,
|
|
1167
|
-
source: 'heuristic',
|
|
1168
|
-
evidence: autopilot.detectedPatterns,
|
|
1169
|
-
confidence: 0.5,
|
|
1170
|
-
surface: 'structuredContent',
|
|
1171
|
-
value: autopilot.detectedPatterns,
|
|
1172
|
-
}));
|
|
1173
|
-
}
|
|
1174
|
-
const totalSuggestedFiles = autopilot.suggestedFiles.create.length +
|
|
1175
|
-
autopilot.suggestedFiles.modify.length +
|
|
1176
|
-
autopilot.suggestedFiles.test.length;
|
|
1177
|
-
if (totalSuggestedFiles > 0) {
|
|
1178
|
-
collector.pushOk('file-analysis', `Suggested ${String(totalSuggestedFiles)} files (${String(autopilot.suggestedFiles.create.length)} create, ${String(autopilot.suggestedFiles.modify.length)} modify, ${String(autopilot.suggestedFiles.test.length)} test)`);
|
|
1179
|
-
advisorySignals.push(makeAdvisorySignal({
|
|
1180
|
-
key: 'suggested-files',
|
|
1181
|
-
kind: 'file',
|
|
1182
|
-
message: `${String(totalSuggestedFiles)} possible related file(s) detected.`,
|
|
1183
|
-
source: 'heuristic',
|
|
1184
|
-
evidence: [
|
|
1185
|
-
...autopilot.suggestedFiles.create.map((f) => f.path),
|
|
1186
|
-
...autopilot.suggestedFiles.modify.map((f) => f.path),
|
|
1187
|
-
...autopilot.suggestedFiles.test.map((f) => f.path),
|
|
1188
|
-
],
|
|
1189
|
-
confidence: 0.45,
|
|
1190
|
-
surface: 'structuredContent',
|
|
1191
|
-
value: {
|
|
1536
|
+
}
|
|
1537
|
+
catch {
|
|
1538
|
+
// best-effort — token issuance failure must not block spec creation
|
|
1539
|
+
}
|
|
1540
|
+
// SPEC-1011 Bug E / fallback hardening: surface local file analysis in the response payload.
|
|
1541
|
+
// Those paths are also written into ## Files only when used as technical evidence.
|
|
1542
|
+
const suggestedFilesPayload = autopilot.suggestedFiles.create.length +
|
|
1543
|
+
autopilot.suggestedFiles.modify.length +
|
|
1544
|
+
autopilot.suggestedFiles.test.length >
|
|
1545
|
+
0
|
|
1546
|
+
? {
|
|
1192
1547
|
create: autopilot.suggestedFiles.create.map((f) => f.path),
|
|
1193
1548
|
modify: autopilot.suggestedFiles.modify.map((f) => f.path),
|
|
1194
1549
|
test: autopilot.suggestedFiles.test.map((f) => f.path),
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
kind: 'readiness',
|
|
1214
|
-
message: `Readiness score: ${String(pipelineResult.readinessScore)}/100.`,
|
|
1215
|
-
source: 'validator',
|
|
1216
|
-
evidence: ['auto post-creation pipeline'],
|
|
1217
|
-
confidence: 0.6,
|
|
1218
|
-
surface: 'structuredContent',
|
|
1219
|
-
value: pipelineResult.readinessScore,
|
|
1220
|
-
}));
|
|
1221
|
-
}
|
|
1222
|
-
else if (spec.difficulty >= 3) {
|
|
1223
|
-
// SPEC-629: always surface the Opus hint for high-difficulty specs
|
|
1224
|
-
collector.pushOk('recommend_model', `Difficulty ${String(spec.difficulty)} spec — use Opus to add exact file paths, function names and anticipated test breaks.`);
|
|
1225
|
-
advisorySignals.push(makeAdvisorySignal({
|
|
1226
|
-
key: 'model-recommendation',
|
|
1227
|
-
kind: 'model',
|
|
1228
|
-
message: 'High-difficulty spec may benefit from a stronger model for review.',
|
|
1229
|
-
source: 'heuristic',
|
|
1230
|
-
evidence: [`difficulty:${String(spec.difficulty)}`],
|
|
1231
|
-
confidence: 0.4,
|
|
1232
|
-
surface: 'structuredContent',
|
|
1233
|
-
value: { recommendedTier: 'max' },
|
|
1234
|
-
}));
|
|
1235
|
-
}
|
|
1236
|
-
if (calibrationEnrichment.calibrationNote) {
|
|
1237
|
-
collector.pushOk('calibration', `📐 ${calibrationEnrichment.calibrationNote}`);
|
|
1238
|
-
}
|
|
1239
|
-
const humanSummary = buildCreateSpecSummary(spec.title, estimation.devHours);
|
|
1240
|
-
result.advisorySignals = advisorySignals;
|
|
1241
|
-
result.compat = buildAdvisoryCompat();
|
|
1242
|
-
const compactResult = compactObj(result);
|
|
1243
|
-
// SPEC-722: Issue a planner token for this spec creation.
|
|
1244
|
-
// Best-effort — token failure never blocks spec creation.
|
|
1245
|
-
let plannerToken;
|
|
1246
|
-
try {
|
|
1247
|
-
const resolvedProjectPath = resolvedInputParams.projectPath ?? '';
|
|
1248
|
-
if (resolvedProjectPath.length > 0) {
|
|
1249
|
-
const sessionId = resolvedInputParams.sessionId ?? 'unknown-session';
|
|
1250
|
-
const modelId = resolvedInputParams.modelId ?? 'unknown-model';
|
|
1251
|
-
const host = resolvedInputParams.host ?? 'unknown-host';
|
|
1252
|
-
plannerToken = await issuePlannerToken(resolvedProjectPath, spec.id, sessionId, modelId, host);
|
|
1253
|
-
}
|
|
1550
|
+
}
|
|
1551
|
+
: undefined;
|
|
1552
|
+
const baseResult = toolResult(markdownText, {
|
|
1553
|
+
...compactResult,
|
|
1554
|
+
advisorySignals,
|
|
1555
|
+
compat: buildAdvisoryCompat(),
|
|
1556
|
+
humanSummary,
|
|
1557
|
+
...(collector.hasEntries() ? { autopilotSummary: collector.getMessages() } : {}),
|
|
1558
|
+
...(suggestedFilesPayload !== undefined
|
|
1559
|
+
? { 'autopilotSummary.suggestedFiles': suggestedFilesPayload }
|
|
1560
|
+
: {}),
|
|
1561
|
+
...(agentTeamPlan && agentTeamPlan.roles.length > 0 ? { agentTeamPlan } : {}),
|
|
1562
|
+
...(plannerToken !== undefined ? { plannerToken } : {}),
|
|
1563
|
+
});
|
|
1564
|
+
return {
|
|
1565
|
+
...baseResult,
|
|
1566
|
+
content: [...baseResult.content, { type: 'text', text: humanSummary }],
|
|
1567
|
+
};
|
|
1254
1568
|
}
|
|
1255
|
-
catch {
|
|
1256
|
-
|
|
1569
|
+
catch (error) {
|
|
1570
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1571
|
+
return {
|
|
1572
|
+
content: [{ type: 'text', text: ti('errors.internalError', { message }) }],
|
|
1573
|
+
isError: true,
|
|
1574
|
+
};
|
|
1257
1575
|
}
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
0
|
|
1264
|
-
? {
|
|
1265
|
-
create: autopilot.suggestedFiles.create.map((f) => f.path),
|
|
1266
|
-
modify: autopilot.suggestedFiles.modify.map((f) => f.path),
|
|
1267
|
-
test: autopilot.suggestedFiles.test.map((f) => f.path),
|
|
1268
|
-
}
|
|
1269
|
-
: undefined;
|
|
1270
|
-
const baseResult = toolResult(markdownText, {
|
|
1271
|
-
...compactResult,
|
|
1272
|
-
advisorySignals,
|
|
1273
|
-
compat: buildAdvisoryCompat(),
|
|
1274
|
-
humanSummary,
|
|
1275
|
-
...(collector.hasEntries() ? { autopilotSummary: collector.getMessages() } : {}),
|
|
1276
|
-
...(suggestedFilesPayload !== undefined
|
|
1277
|
-
? { 'autopilotSummary.suggestedFiles': suggestedFilesPayload }
|
|
1278
|
-
: {}),
|
|
1279
|
-
...(agentTeamPlan && agentTeamPlan.roles.length > 0 ? { agentTeamPlan } : {}),
|
|
1280
|
-
...(plannerToken !== undefined ? { plannerToken } : {}),
|
|
1281
|
-
});
|
|
1282
|
-
return {
|
|
1283
|
-
...baseResult,
|
|
1284
|
-
content: [...baseResult.content, { type: 'text', text: humanSummary }],
|
|
1285
|
-
};
|
|
1286
|
-
}
|
|
1287
|
-
catch (error) {
|
|
1288
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1289
|
-
return {
|
|
1290
|
-
content: [{ type: 'text', text: ti('errors.internalError', { message }) }],
|
|
1291
|
-
isError: true,
|
|
1292
|
-
};
|
|
1576
|
+
}); // end trackCost
|
|
1577
|
+
}
|
|
1578
|
+
finally {
|
|
1579
|
+
if (!claimLifecycle.committed && !claimLifecycle.retainForInFlightWork) {
|
|
1580
|
+
await releaseIdempotencyClaim(resolvedPath, idempotencyKey, idempotencyClaim.ownerId);
|
|
1293
1581
|
}
|
|
1294
|
-
}
|
|
1582
|
+
}
|
|
1295
1583
|
}
|
|
1296
1584
|
//# sourceMappingURL=create-spec.js.map
|