@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/config/project-knowledge-graph.json +42 -5
  3. package/dist/engine/core-bridge-project-graph.d.ts +13 -0
  4. package/dist/engine/core-bridge-project-graph.js +499 -0
  5. package/dist/engine/core-bridge.d.ts +7 -2
  6. package/dist/engine/core-bridge.js +55 -0
  7. package/dist/engine/frontmatter-parser.js +73 -23
  8. package/dist/engine/model-tier-resolver.d.ts +8 -7
  9. package/dist/engine/model-tier-resolver.js +70 -73
  10. package/dist/engine/next-spec-resolver/orchestration-planner.d.ts +5 -0
  11. package/dist/engine/next-spec-resolver/orchestration-planner.js +34 -5
  12. package/dist/engine/project-graph/builder.js +271 -36
  13. package/dist/engine/project-graph/cache.d.ts +22 -4
  14. package/dist/engine/project-graph/cache.js +412 -33
  15. package/dist/engine/project-graph/index.d.ts +1 -0
  16. package/dist/engine/project-graph/index.js +1 -0
  17. package/dist/engine/project-graph/native.d.ts +3 -0
  18. package/dist/engine/project-graph/native.js +36 -0
  19. package/dist/engine/project-graph/query.js +34 -2
  20. package/dist/engine/provider-adapters/adapters/claude.js +38 -14
  21. package/dist/engine/scan-project/index.js +88 -15
  22. package/dist/engine/spec-format/lean-spec-generator.d.ts +2 -2
  23. package/dist/engine/spec-format/lean-spec-generator.js +65 -50
  24. package/dist/engine/spec-format/metadata-value-policy.d.ts +161 -0
  25. package/dist/engine/spec-format/metadata-value-policy.js +87 -0
  26. package/dist/engine/spec-format/value-only-spec-serializer.d.ts +12 -0
  27. package/dist/engine/spec-format/value-only-spec-serializer.js +18 -0
  28. package/dist/engine/spec-generator/fallback-generator.js +4 -2
  29. package/dist/engine/spec-generator/opus-generator.js +5 -2
  30. package/dist/engine/spec-migrator/lean-migration.js +26 -13
  31. package/dist/storage/spec-store.js +6 -6
  32. package/dist/tools/create-spec.js +1027 -739
  33. package/dist/tools/render-spec-for-provider.js +4 -3
  34. package/dist/tools/reverse-engineer/handler.js +76 -43
  35. package/dist/tools/spec-split-handler.js +36 -75
  36. package/dist/types/conventions.d.ts +9 -0
  37. package/dist/types/core-bridge.d.ts +72 -0
  38. package/dist/types/next-spec.d.ts +2 -1
  39. package/dist/types/project-knowledge-graph.d.ts +58 -0
  40. package/dist/types/spec/core.d.ts +7 -4
  41. package/dist/types/spec-format.d.ts +2 -1
  42. package/dist/types/spec-generator.d.ts +8 -2
  43. package/package.json +11 -9
  44. package/planu-native.json +1 -1
  45. package/planu-plugin.json +1 -1
  46. package/dist/engine/spec-format/model-budget-deriver.d.ts +0 -5
  47. 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
- const FRONTMATTER_KEY_RE = /^idempotencyKey:\s*(\S+)/m;
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 = 10 * 60 * 1000) {
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
- // Fallback: scan frontmatter in spec.md files (catches specs written before store update)
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
- if (idMatch?.[1] !== undefined) {
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: titleMatch?.[1] ?? '',
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
- // SPEC-781: Check if async analysis has completed in external project data.
533
- const analysisStatus = await checkAnalysisStatus(resolvedPath, existingByKey.id);
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: `spec already exists: ${existingByKey.id} returning existing spec to avoid duplicate.\n\nTitle: ${existingByKey.title}\nStatus: ${existingByKey.status}\n\nNo new spec was created.`,
814
+ text: 'An identical create_spec request is still in progress. Retry with the same title.',
539
815
  },
540
816
  ],
541
- structuredContent: {
542
- specId: existingByKey.id,
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
- // 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
553
- return trackCost(resolvedInputParams.projectPath ?? '', 'create_spec', async () => {
554
- // Allow internal re-enrichment without mutating function parameter
555
- let params = resolvedInputParams;
556
- const { description, clarificationSessionId } = params;
557
- try {
558
- // SPEC-713: Phase 1 Critical path: build + persist spec within 25s hard ceiling.
559
- // Only the steps required to produce spec.md on disk are inside this budget.
560
- // Post-creation enrichment (git, contradictions, quality scores, etc.) runs outside.
561
- const criticalResult = await withTotalBudget('create_spec-critical', 25_000, async () => {
562
- // Build spec context (ID, estimation, diagrams, scope filters)
563
- const buildResult = await measureStep('buildSpecContext', () => buildSpecContext(params));
564
- if (!buildResult.ok) {
565
- return {
566
- ok: false,
567
- earlyReturn: {
568
- content: [{ type: 'text', text: buildResult.errorMessage }],
569
- isError: true,
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
- const { spec, specDir, specPath, technicalPath, estimation, splitSuggestion, duplicate, projectId, agentTeamPlan, } = buildResult.context;
574
- // Load project knowledge and clarification session
575
- const knowledge = await measureStep('loadKnowledge', () => knowledgeStore.getKnowledge(projectId));
576
- const clarificationSession = clarificationSessionId
577
- ? await knowledgeStore.getClarification(projectId, clarificationSessionId)
578
- : null;
579
- // Validate against Constitution
580
- const constitutionCheck = await measureStep('validateConstitution', () => validateConstitution(projectId, spec.title, spec.tags));
581
- if (constitutionCheck.blocked && constitutionCheck.errorResult) {
582
- return { ok: false, earlyReturn: constitutionCheck.errorResult };
583
- }
584
- // SPEC-461 Phase 3: Autopilot — analyze project to enrich spec (best-effort)
585
- // SPEC-560: Wrapped with 5s timeout to prevent hangs on large projects (2000+ files)
586
- // SPEC-713: measureStep for Phase 0 diagnostic; withBudget enforces 5s ceiling
587
- const autopilotResult = await withBudget('autopilot-analyzer', 5_000, () => measureStep('autopilot-analyzer', () => analyzeProjectForSpec(params.projectPath ?? '', description, spec.title, knowledge)));
588
- const autopilot = unwrapBudget(autopilotResult, getEmptyAutopilotResult());
589
- // If very vague (<10 words), capture as idea instead of spec
590
- if (autopilot.isIdea) {
591
- return {
592
- ok: false,
593
- earlyReturn: {
594
- content: [
595
- {
596
- type: 'text',
597
- 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.`,
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
- // SPEC-463 / SPEC-471: Interactive clarification
604
- const clarificationResult = handleClarification(server, description, knowledge, autopilot, params);
605
- if (clarificationResult !== null) {
606
- if ('earlyReturn' in clarificationResult) {
607
- // SPEC-584: Persist token so the gate blocks retries without answers
608
- await persistClarificationToken(clarificationResult.earlyReturn, projectId, 'create_spec');
609
- return { ok: false, earlyReturn: clarificationResult.earlyReturn };
610
- }
611
- params = clarificationResult.params;
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
- // Create spec directory and write lean files (SPEC-461)
614
- await measureStep('mkdir-specDir', () => mkdir(specDir, { recursive: true }));
615
- const filteredCriteria = filterGroundedCriteria(autopilot.suggestedCriteria);
616
- const technologyContract = await readTechnologySelectionContract(params.projectPath ?? '');
617
- const contractNote = technologyContract
618
- ? [
619
- '',
620
- 'Technology Contract:',
621
- `- Mode: ${technologyContract.mode}`,
622
- technologyContract.language ? `- Language: ${technologyContract.language}` : '',
623
- technologyContract.framework
624
- ? `- Framework: ${technologyContract.framework}`
625
- : '- Framework: none or not selected',
626
- technologyContract.platform ? `- Platform: ${technologyContract.platform}` : '',
627
- technologyContract.projectType
628
- ? `- Project type: ${technologyContract.projectType}`
629
- : '',
630
- '- Agents must not choose a different stack unless the user approves a new contract.',
631
- ]
632
- .filter(Boolean)
633
- .join('\n')
634
- : '';
635
- const anthropicKey = await new ApiKeyResolver().resolveAnthropicKey(params.projectPath ?? '');
636
- const specGenerator = anthropicKey !== undefined
637
- ? new OpusGenerator({ apiKey: anthropicKey })
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
- description: `${description}${contractNote}`,
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
- acFormat: params.acFormat,
646
- projectContext: {
647
- language: technologyContract?.language ?? knowledge?.language ?? undefined,
648
- framework: technologyContract?.framework ?? knowledge?.framework ?? undefined,
649
- architecture: knowledge?.architecture.primary ?? undefined,
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
- spec.generatedWithModel = generatedSpec.generatedWithModel;
653
- spec.generatedAt = generatedSpec.generatedAt;
654
- spec.qualityWarnings = generatedSpec.qualityWarnings;
655
- const baseCriteria = extractCriteria(generatedSpec.specBody).map((criterion) => criterion.text);
656
- const groundingCriteria = buildCriterionGroundingRecords({
657
- criteria: [...baseCriteria, ...filteredCriteria],
658
- userInput: description,
659
- projectEvidence: [
660
- ...autopilot.detectedPatterns.map((pattern) => `detected-pattern:${pattern}`),
661
- ...autopilot.suggestedFiles.modify.map((file) => `file:${file.path}`),
662
- ...autopilot.suggestedFiles.create.map((file) => `file:${file.path}`),
663
- ...autopilot.suggestedFiles.test.map((file) => `file:${file.path}`),
664
- ],
665
- generatedEvidence: [generatedSpec.generatedWithModel],
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
- const contractCriteria = getContractCriteria(groundingCriteria);
668
- const advisoryCriteria = getAdvisoryCriteria(groundingCriteria).map((record) => record.text);
669
- const actionableMetrics = calculateActionableSpecMetrics({
670
- criteria: [...baseCriteria, ...filteredCriteria],
671
- groundingRecords: groundingCriteria,
672
- }).filter(shouldExposeMetric);
673
- const technicalFiles = await measureStep('resolveTechnicalFiles', () => resolveTechnicalFiles({
674
- generatedSpecBody: generatedSpec.specBody,
675
- generatedTechnicalSection: generatedSpec.technicalSection,
676
- projectPath: params.projectPath ?? '',
677
- autopilot,
678
- fallbackReason: generatedSpec.fallbackReason,
679
- }));
680
- const groundedTechnical = await measureStep('groundTechnicalFiles', () => groundTechnicalFiles({
681
- files: technicalFiles,
682
- projectPath: params.projectPath ?? '',
683
- userInput: description,
684
- autopilot,
685
- }));
686
- const outOfScopeResolved = resolveOutOfScope(description, params.outOfScope);
687
- const scenarioTestPaths = groundedTechnical.files.test.map((file) => file.path);
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
- groundingCriteria,
697
- groundingTechnicalReferences: groundedTechnical.records,
698
- acFormat: params.acFormat,
699
- scenarioTestPaths,
700
- });
701
- const leanTechnical = generateLeanTechnicalContent({
702
- specId: spec.id,
703
- filesToCreate: groundedTechnical.files.create,
704
- filesToModify: groundedTechnical.files.modify,
705
- filesToTest: groundedTechnical.files.test,
706
- includeDecisionRequiredPlaceholder: spec.scope !== 'trivial',
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
- // SPEC-709: write unified spec.md from origin — no separate technical.md.
709
- // The legacy two-file output is preserved by appending the technical body
710
- // as a `## Technical` section inside spec.md.
711
- const unifiedWithoutContract = buildUnifiedSpecContent(leanSpec, leanTechnical);
712
- const unifiedSpec = appendImplementationContractIfMissing(unifiedWithoutContract, {
713
- description: generatedSpec.specBody,
714
- criteria: contractCriteria.map((record) => ({ text: record.text, done: false })),
715
- files: groundedTechnical.files,
716
- outOfScope: outOfScopeResolved.items,
717
- verificationCommands: [
718
- ...(scenarioTestPaths.length > 0
719
- ? [`pnpm vitest run ${scenarioTestPaths.join(' ')}`]
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
- const genericOutputGate = checkGenericSpecOutput(unifiedSpec);
727
- if (!genericOutputGate.passed) {
728
- return {
729
- ok: false,
730
- earlyReturn: {
731
- content: [
732
- {
733
- type: 'text',
734
- text: 'Spec quality gate blocked generic output before persistence. ' +
735
- genericOutputGate.issues
736
- .slice(0, 3)
737
- .map((issue) => `${issue.phrase}: ${issue.reason}`)
738
- .join('; '),
739
- },
740
- ],
741
- isError: true,
742
- structuredContent: {
743
- error: 'GENERIC_SPEC_OUTPUT_BLOCKED',
744
- sddConstitutionRuleId: 'sdd.no-generic-output',
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
- try {
752
- // SPEC-713: measure file write this is the critical persistence step
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
- catch (writeErr) {
758
- // Clean up partial files to avoid orphaned spec directories
759
- await rm(specDir, { recursive: true, force: true });
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
- // SPEC-612: Auto-suggest outOfScope when user did not provide any
763
- if (outOfScopeResolved.items.length > 0) {
764
- spec.outOfScope = outOfScopeResolved.items;
1413
+ if (contradictionHint) {
1414
+ lines.push('', `⚠️ ${contradictionHint}`);
765
1415
  }
766
- const outOfScopeSuggestionMsg = outOfScopeResolved.message;
767
- // Persist spec in storage
768
- // SPEC-713: measure specStore.createSpec — file lock + JSON rewrite
769
- await measureStep('specStore-createSpec', () => specStore.createSpec(projectId, spec));
770
- return {
771
- ok: true,
772
- data: {
773
- spec,
774
- specDir,
775
- specPath,
776
- technicalPath,
777
- estimation,
778
- splitSuggestion,
779
- duplicate,
780
- projectId,
781
- agentTeamPlan,
782
- knowledge,
783
- clarificationSession,
784
- constitutionCheck,
785
- autopilot,
786
- filteredCriteria,
787
- advisoryCriteria: [
788
- ...advisoryCriteria,
789
- ...groundedTechnical.advisoryFiles.map((file) => file.path),
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
- actionableMetrics,
792
- outOfScopeSuggestionMsg,
793
- },
794
- };
795
- }); // end withTotalBudget critical path
796
- // SPEC-713: Handle total budget exceeded — return clear error before 60s MCP timeout
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
- isError: true,
813
- ...(possibleDuplicates ? { structuredContent: { possibleDuplicates } } : {}),
814
- };
815
- }
816
- if (!criticalResult.value.ok) {
817
- return criticalResult.value.earlyReturn;
818
- }
819
- // Destructure critical path results for use in post-creation enrichment
820
- const { spec, specDir: _specDir, specPath,
821
- // SPEC-1010 Bug A: no longer surfaced in the response payload (SSR back-migration).
822
- technicalPath: _technicalPath, estimation, splitSuggestion, duplicate, projectId, agentTeamPlan, knowledge, clarificationSession, constitutionCheck, autopilot, filteredCriteria, advisoryCriteria, actionableMetrics, outOfScopeSuggestionMsg, } = criticalResult.value.data;
823
- // -----------------------------------------------------------------------
824
- // Post-creation enrichment (outside 25s ceiling — best-effort, budgeted)
825
- // -----------------------------------------------------------------------
826
- // Auto-setup git branch (non-blocking)
827
- // SPEC-713: withBudget 3s — git subprocess can be slow on large repos
828
- const gitBudgetResult = await withBudget('setupGitBranch', 3_000, () => measureStep('setupGitBranch', () => setupGitBranch(projectId, spec.id)));
829
- const gitSetupResult = unwrapBudget(gitBudgetResult, undefined);
830
- /* v8 ignore start -- requires real git repo */
831
- if (gitSetupResult) {
832
- spec.gitBranch = gitSetupResult.branch;
833
- }
834
- /* v8 ignore stop */
835
- // Brief contradiction check (non-blocking)
836
- // SPEC-713: withBudget 2s — scans all specs for contradiction keywords
837
- const contradictionResult = await withBudget('checkContradictions', 2_000, () => measureStep('checkContradictions', () => checkContradictions(projectId, spec.id, description)));
838
- const contradictionHint = unwrapBudget(contradictionResult, undefined);
839
- // SPEC-555: Enrich estimation with calendar days (best-effort, non-blocking)
840
- // SPEC-713: withBudget 2s
841
- const velocityResult = await withBudget('velocityEnrichment', 2_000, () => measureStep('velocityEnrichment', () => enrichEstimationWithVelocity(params.projectPath ?? '', estimation.devHours)));
842
- const velocityEnrichment = unwrapBudget(velocityResult, {
843
- calendarDays: null,
844
- velocityNote: 'velocity data unavailable',
845
- });
846
- // SPEC-506: Apply persisted calibration multiplier (best-effort, non-blocking)
847
- // SPEC-713: withBudget 2s
848
- const calibrationResult = await withBudget('calibrationEnrichment', 2_000, () => measureStep('calibrationEnrichment', () => enrichEstimationWithCalibration(params.projectPath ?? '', estimation.devHours, spec.scope, spec.type)));
849
- const calibrationEnrichment = unwrapBudget(calibrationResult, {
850
- calibratedHours: estimation.devHours,
851
- applied: false,
852
- calibrationNote: null,
853
- });
854
- // Build result (SPEC-461: lean — no progress, HTML, diagrams, scope filters)
855
- const result = {
856
- // SPEC-781: async analysis running in background (external project data)
857
- pendingAnalysis: true,
858
- specId: spec.id,
859
- title: spec.title,
860
- slug: spec.slug,
861
- type: spec.type,
862
- scope: spec.scope,
863
- difficulty: spec.difficulty,
864
- risk: spec.risk,
865
- tags: spec.tags,
866
- target: spec.target,
867
- status: spec.status,
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
- clarificationNote: 'Spec created directly from description — Planu inferred requirements from project context.',
898
- }),
899
- message: ti('tools.create_spec.success', { id: spec.id, title: spec.title }),
900
- ...(contradictionHint ? { contradictionHint } : {}),
901
- /* v8 ignore next -- requires real git repo */
902
- ...(gitSetupResult ? { gitAutoSetup: gitSetupResult.data } : {}),
903
- };
904
- const advisorySignals = [];
905
- if (actionableMetrics.length > 0) {
906
- result.actionableMetrics = actionableMetrics;
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
- deprecatedAlias: 'autopilotSummary.suggestedFiles',
1197
- }));
1198
- }
1199
- if (filteredCriteria.length > 0) {
1200
- collector.pushOk('criteria-enrichment', `Added ${String(filteredCriteria.length)} acceptance criteria from project context`);
1201
- }
1202
- if (pipelineResult.challengeSummary) {
1203
- collector.pushOk('challenge', `Challenge analysis complete: ${pipelineResult.challengeSummary}`);
1204
- }
1205
- if (pipelineResult.readinessScore !== null) {
1206
- // SPEC-629: append Opus hint for difficulty >= 3 specs
1207
- const opusHint = spec.difficulty >= 3
1208
- ? ` — difficulty ${String(spec.difficulty)}: use Opus to add exact file paths + function names`
1209
- : '';
1210
- collector.pushOk('readiness', `Readiness score: ${String(pipelineResult.readinessScore)}/100${opusHint}`);
1211
- advisorySignals.push(makeAdvisorySignal({
1212
- key: 'readiness-score',
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
- // best-effort token issuance failure must not block spec creation
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
- // SPEC-1011 Bug E / fallback hardening: surface local file analysis in the response payload.
1259
- // Those paths are also written into ## Files only when used as technical evidence.
1260
- const suggestedFilesPayload = autopilot.suggestedFiles.create.length +
1261
- autopilot.suggestedFiles.modify.length +
1262
- autopilot.suggestedFiles.test.length >
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
- }); // end trackCost
1582
+ }
1295
1583
  }
1296
1584
  //# sourceMappingURL=create-spec.js.map