create-quiver 0.14.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +183 -518
  3. package/README_FOR_AI.md +32 -26
  4. package/ROADMAP.md +5 -0
  5. package/assets/quiver-wordmark.svg +22 -0
  6. package/docs/AI_CONTEXT.md.template +2 -0
  7. package/docs/AI_ONBOARDING_PROMPT.md.template +9 -1
  8. package/docs/CLI_UX_GUIDE.md +125 -0
  9. package/docs/COMMANDS.md.template +16 -3
  10. package/docs/GITFLOW_PR_GUIDE.md +70 -0
  11. package/docs/getting-started/linux.md +84 -0
  12. package/docs/getting-started/macos.md +85 -0
  13. package/docs/getting-started/windows-git-bash-wsl.md +78 -0
  14. package/docs/getting-started/windows-powershell.md +96 -0
  15. package/docs/reference/commands.md +94 -0
  16. package/docs/workflows/existing-project.md +131 -0
  17. package/docs/workflows/full-ai-spec-to-pr.md +311 -0
  18. package/docs/workflows/legacy-quiver-project.md +102 -0
  19. package/docs/workflows/new-project.md +76 -0
  20. package/package.json +5 -1
  21. package/specs/quiver-v29-planner-prepare-context-cli-ux/EVIDENCE_REPORT.md +163 -0
  22. package/specs/quiver-v29-planner-prepare-context-cli-ux/EXECUTION_PLAN.md +72 -0
  23. package/specs/quiver-v29-planner-prepare-context-cli-ux/SPEC.md +173 -0
  24. package/specs/quiver-v29-planner-prepare-context-cli-ux/STATUS.md +34 -0
  25. package/specs/quiver-v29-planner-prepare-context-cli-ux/pr.md +95 -0
  26. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-00-cli-ux-spec-foundation/CLOSURE_BRIEF.md +32 -0
  27. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-00-cli-ux-spec-foundation/EXECUTION_BRIEF.md +45 -0
  28. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-00-cli-ux-spec-foundation/slice.json +58 -0
  29. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-01-cli-ux-primitives-theme/CLOSURE_BRIEF.md +33 -0
  30. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-01-cli-ux-primitives-theme/EXECUTION_BRIEF.md +49 -0
  31. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-01-cli-ux-primitives-theme/slice.json +75 -0
  32. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-02-planner-context-proposal-contract/CLOSURE_BRIEF.md +32 -0
  33. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-02-planner-context-proposal-contract/EXECUTION_BRIEF.md +47 -0
  34. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-02-planner-context-proposal-contract/slice.json +71 -0
  35. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-03-prepare-context-planner-review-flow/CLOSURE_BRIEF.md +33 -0
  36. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-03-prepare-context-planner-review-flow/EXECUTION_BRIEF.md +52 -0
  37. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-03-prepare-context-planner-review-flow/slice.json +82 -0
  38. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-04-ux-flag-matrix-compatibility/CLOSURE_BRIEF.md +34 -0
  39. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-04-ux-flag-matrix-compatibility/EXECUTION_BRIEF.md +46 -0
  40. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-04-ux-flag-matrix-compatibility/slice.json +73 -0
  41. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-05-progressive-command-adoption/CLOSURE_BRIEF.md +34 -0
  42. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-05-progressive-command-adoption/EXECUTION_BRIEF.md +46 -0
  43. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-05-progressive-command-adoption/slice.json +83 -0
  44. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-06-docs-tests-smoke-readiness/CLOSURE_BRIEF.md +31 -0
  45. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-06-docs-tests-smoke-readiness/EXECUTION_BRIEF.md +50 -0
  46. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-06-docs-tests-smoke-readiness/slice.json +95 -0
  47. package/src/create-quiver/commands/ai.js +458 -3
  48. package/src/create-quiver/commands/spec.js +64 -2
  49. package/src/create-quiver/index.js +49 -1
  50. package/src/create-quiver/lib/ai/context-proposal.js +389 -0
  51. package/src/create-quiver/lib/ai/context-proposal.schema.js +31 -0
  52. package/src/create-quiver/lib/cli/editor.js +118 -0
  53. package/src/create-quiver/lib/cli/theme.js +100 -0
  54. package/src/create-quiver/lib/cli/ux-flags.js +151 -0
  55. package/src/create-quiver/lib/cli/ux.js +130 -0
@@ -1,4 +1,5 @@
1
1
  const fs = require('node:fs');
2
+ const os = require('node:os');
2
3
  const path = require('node:path');
3
4
 
4
5
  const { redactSecrets } = require('../lib/evidence');
@@ -10,6 +11,9 @@ const {
10
11
  writeRawProviderArtifact,
11
12
  } = require('../lib/ai/artifacts');
12
13
  const { buildContextPackMetadata, normalizeRole } = require('../lib/ai/context-packs');
14
+ const { parseContextProposalOutput } = require('../lib/ai/context-proposal');
15
+ const { openEditor } = require('../lib/cli/editor');
16
+ const { createUx } = require('../lib/cli/ux');
13
17
  const { runExecuteSlice, runPromptSlice } = require('../lib/ai/executor');
14
18
  const { runExecutePlan } = require('../lib/ai/execution-plan');
15
19
  const { buildPrCreatePlan, formatPreflightReport, formatPrCreateReport, preflightGitHubPr, runGhPrCreate } = require('../lib/ai/github');
@@ -313,6 +317,255 @@ function formatContextPreparationReport({ dryRun, plan, writePlan, writtenDocs,
313
317
  return `${lines.join('\n')}\n`;
314
318
  }
315
319
 
320
+ function truncatePromptSection(text, maxChars = 1200) {
321
+ const value = String(text || '').trimEnd();
322
+ if (value.length <= maxChars) {
323
+ return value;
324
+ }
325
+ return `${value.slice(0, maxChars).trimEnd()}\n[... truncated ${value.length - maxChars} chars ...]`;
326
+ }
327
+
328
+ function buildPrepareContextPlannerPrompt({ pack, draftPack }) {
329
+ const plan = draftPack.plan;
330
+ const allowedPaths = draftPack.docs.map((doc) => doc.path);
331
+ const sections = [
332
+ pack.prompt,
333
+ 'Task: planner-assisted Quiver context preparation.',
334
+ 'Goal: improve the docs-only onboarding context for future AI work while preserving WDD + SDD safety.',
335
+ 'Rules:',
336
+ '- Return only valid JSON. Do not include Markdown outside the JSON object.',
337
+ '- Do not modify product code, UI code, tests, migrations, dependencies, lockfiles, build files, runtime config, generated files, or paths outside the repo.',
338
+ '- Only propose writes to the allowed docs paths listed below.',
339
+ '- If information is ambiguous, document assumptions and risks instead of inventing facts.',
340
+ '- Keep human-authored content safe; Quiver will merge proposals through managed context blocks.',
341
+ '',
342
+ 'Allowed docs-only output paths:',
343
+ ...allowedPaths.map((item) => `- ${item}`),
344
+ '',
345
+ 'Project context:',
346
+ `- Project: ${plan.projectName}`,
347
+ `- Project slug: ${plan.projectSlug}`,
348
+ `- Package manager: ${plan.facts.packageManager}`,
349
+ `- Stack summary: ${plan.facts.stackSummary}`,
350
+ '',
351
+ 'Files considered by deterministic prepare-context:',
352
+ ...plan.filesConsidered.map((item) => `- ${item.path}: ${item.present ? 'present' : 'absent'}${item.reason ? ` (${item.reason})` : ''}`),
353
+ '',
354
+ 'Known assumptions:',
355
+ ...formatPathList(plan.assumptions),
356
+ '',
357
+ 'Known risks:',
358
+ ...formatPathList(plan.risks),
359
+ '',
360
+ 'Known contradictions:',
361
+ ...formatPathList(plan.contradictions),
362
+ '',
363
+ 'Deterministic candidate docs:',
364
+ ...draftPack.docs.flatMap((doc) => [
365
+ `### ${doc.path}`,
366
+ truncatePromptSection(doc.content),
367
+ ]),
368
+ '',
369
+ 'Required JSON output shape:',
370
+ JSON.stringify({
371
+ schema_version: 1,
372
+ kind: 'quiver-context-proposal',
373
+ summary: 'short summary',
374
+ assumptions: ['assumption to confirm'],
375
+ risks: ['risk to track'],
376
+ docs: [
377
+ {
378
+ path: 'docs/AI_CONTEXT.md',
379
+ action: 'update',
380
+ reason: 'why this doc should change',
381
+ content: '# AI Context\\n\\nFull proposed content or managed section content.\\n',
382
+ assumptions: [],
383
+ risks: [],
384
+ },
385
+ ],
386
+ omitted_paths: ['paths intentionally omitted'],
387
+ next_steps: ['safe next step'],
388
+ }, null, 2),
389
+ ];
390
+
391
+ return {
392
+ allowedPaths,
393
+ plan,
394
+ prompt: sections.join('\n'),
395
+ promptSource: 'quiver prepare-context planner proposal contract',
396
+ };
397
+ }
398
+
399
+ function formatPrepareContextPlannerDryRunReport({ provider, role, context, invocation, promptInfo, review, interactive }) {
400
+ const plan = promptInfo.plan;
401
+ const lines = [
402
+ 'AI prepare-context planner dry-run',
403
+ 'Mode: dry-run',
404
+ 'Planner: enabled',
405
+ `Provider: ${provider}`,
406
+ `Role: ${role}`,
407
+ `Context pack: ${context}`,
408
+ `Command: ${invocation.command} ${invocation.args.join(' ')}`.trim(),
409
+ `Prompt bytes: ${invocation.promptLength}`,
410
+ `Prompt source: ${promptInfo.promptSource}`,
411
+ `Review requested: ${review ? 'yes' : 'no'}`,
412
+ `Interactive requested: ${interactive ? 'yes' : 'no'}`,
413
+ 'Provider execution: skipped',
414
+ 'Writes: none',
415
+ 'Product code: untouched',
416
+ `Candidate docs: ${promptInfo.allowedPaths.join(', ')}`,
417
+ 'Files considered:',
418
+ ...plan.filesConsidered.map((item) => `- ${item.path}: ${item.present ? 'present' : 'absent'}`),
419
+ 'Allowed docs-only paths:',
420
+ ...promptInfo.allowedPaths.map((item) => `- ${item}`),
421
+ 'Next safe commands:',
422
+ '- npx create-quiver ai prepare-context --with-planner --print-prompt',
423
+ '- npx create-quiver ai prepare-context --with-planner --dry-run --review',
424
+ '- npx create-quiver ai prepare-context --with-planner',
425
+ ];
426
+
427
+ return `${lines.join('\n')}\n`;
428
+ }
429
+
430
+ function serializeProposalForReview(proposal) {
431
+ return {
432
+ schema_version: proposal.schemaVersion,
433
+ kind: proposal.kind,
434
+ summary: proposal.summary,
435
+ assumptions: proposal.assumptions,
436
+ risks: proposal.risks,
437
+ docs: proposal.docs.map((doc) => ({
438
+ path: doc.path,
439
+ action: doc.action,
440
+ reason: doc.reason,
441
+ content: doc.content,
442
+ assumptions: doc.assumptions,
443
+ risks: doc.risks,
444
+ })),
445
+ omitted_paths: proposal.omittedPaths,
446
+ next_steps: proposal.nextSteps,
447
+ };
448
+ }
449
+
450
+ function createProposalReviewFile(proposal, options = {}) {
451
+ const reviewDir = options.reviewDir || fs.mkdtempSync(path.join(os.tmpdir(), 'quiver-context-review-'));
452
+ const reviewPath = path.join(reviewDir, 'context-proposal.json');
453
+ fs.mkdirSync(reviewDir, { recursive: true });
454
+ fs.writeFileSync(reviewPath, `${JSON.stringify(serializeProposalForReview(proposal), null, 2)}\n`);
455
+ return reviewPath;
456
+ }
457
+
458
+ function makeReviewError(message, reviewPath, cause) {
459
+ const error = new Error(formatError(`${message}\nReview artifact: ${reviewPath}\nNext safe step: edit the artifact into valid proposal JSON or rerun with --with-planner --dry-run.`));
460
+ error.code = cause?.code || 'AI_CONTEXT_REVIEW_FAILED';
461
+ error.cause = cause;
462
+ error.reviewPath = reviewPath;
463
+ return error;
464
+ }
465
+
466
+ function createReviewTextFile(contents, options = {}) {
467
+ const reviewDir = options.reviewDir || fs.mkdtempSync(path.join(os.tmpdir(), 'quiver-review-'));
468
+ const filename = options.reviewFileName || 'review.md';
469
+ const reviewPath = path.join(reviewDir, filename);
470
+ fs.mkdirSync(reviewDir, { recursive: true });
471
+ fs.writeFileSync(reviewPath, String(contents || ''));
472
+ return reviewPath;
473
+ }
474
+
475
+ async function reviewTextWithEditor(repoRoot, contents, options = {}) {
476
+ const reviewPath = createReviewTextFile(contents, options);
477
+ const hasEditorRunner = typeof options.openEditorFn === 'function';
478
+ const canOpenEditor = hasEditorRunner || options.stdinIsTTY === true || (options.stdinIsTTY !== false && Boolean(process.stdin.isTTY));
479
+
480
+ if (!canOpenEditor) {
481
+ throw makeReviewError(`${options.reviewLabel || 'review'} requires an interactive terminal or an injected editor runner.`, reviewPath);
482
+ }
483
+
484
+ const editorResult = hasEditorRunner
485
+ ? options.openEditorFn(reviewPath, { cwd: repoRoot, env: options.env || process.env })
486
+ : openEditor(reviewPath, { cwd: repoRoot, env: options.env || process.env });
487
+
488
+ if (!editorResult || editorResult.ok !== true) {
489
+ throw makeReviewError(editorResult?.reason || `${options.reviewLabel || 'review'} was canceled.`, reviewPath);
490
+ }
491
+
492
+ return {
493
+ reviewPath,
494
+ text: fs.readFileSync(reviewPath, 'utf8'),
495
+ };
496
+ }
497
+
498
+ async function confirmInteractiveAction(message, options = {}) {
499
+ if (options.interactive !== true) {
500
+ return;
501
+ }
502
+
503
+ const ux = options.ux || createUx({
504
+ interactive: true,
505
+ promptConfirm: options.promptConfirm,
506
+ stdinIsTTY: options.stdinIsTTY,
507
+ stdoutIsTTY: options.stdoutIsTTY,
508
+ stderrIsTTY: options.stderrIsTTY,
509
+ write: options.write,
510
+ });
511
+ const confirmed = await ux.promptConfirm(message, {
512
+ initialValue: false,
513
+ });
514
+
515
+ if (!confirmed) {
516
+ const error = new Error(formatError('interactive approval declined. No files were written.'));
517
+ error.code = 'AI_INTERACTIVE_APPROVAL_DECLINED';
518
+ throw error;
519
+ }
520
+ }
521
+
522
+ async function reviewPlannerContextProposal(repoRoot, proposal, options = {}) {
523
+ const reviewPath = createProposalReviewFile(proposal, options);
524
+ const hasEditorRunner = typeof options.openEditorFn === 'function';
525
+ const canOpenEditor = hasEditorRunner || options.stdinIsTTY === true || (options.stdinIsTTY !== false && Boolean(process.stdin.isTTY));
526
+
527
+ if (!canOpenEditor) {
528
+ throw makeReviewError('ai prepare-context review requires an interactive terminal or an injected editor runner.', reviewPath);
529
+ }
530
+
531
+ const editorResult = hasEditorRunner
532
+ ? options.openEditorFn(reviewPath, { cwd: repoRoot, env: options.env || process.env })
533
+ : openEditor(reviewPath, { cwd: repoRoot, env: options.env || process.env });
534
+
535
+ if (!editorResult || editorResult.ok !== true) {
536
+ throw makeReviewError(editorResult?.reason || 'ai prepare-context review was canceled before applying docs.', reviewPath);
537
+ }
538
+
539
+ try {
540
+ return {
541
+ proposal: parseContextProposalOutput(fs.readFileSync(reviewPath, 'utf8')),
542
+ reviewPath,
543
+ };
544
+ } catch (error) {
545
+ throw makeReviewError('edited planner proposal is invalid after review.', reviewPath, error);
546
+ }
547
+ }
548
+
549
+ async function confirmPlannerContextWrites(writePlan, options = {}) {
550
+ const changed = writePlan.filter((item) => item.action !== 'skip').length;
551
+ await confirmInteractiveAction(`Apply ${changed} docs-only context update${changed === 1 ? '' : 's'}?`, options);
552
+ }
553
+
554
+ function buildPlannerContextWritePlan(repoRoot, proposal) {
555
+ const reasonByPath = new Map(proposal.docs.map((doc) => [doc.path, doc.reason]));
556
+ const draftDocs = proposal.docs
557
+ .filter((doc) => doc.action !== 'skip')
558
+ .map((doc) => ({
559
+ path: doc.path,
560
+ content: doc.content,
561
+ }));
562
+
563
+ return buildContextWritePlan(repoRoot, draftDocs).map((item) => ({
564
+ ...item,
565
+ reason: item.action === 'skip' ? item.reason : reasonByPath.get(item.path) || item.reason,
566
+ }));
567
+ }
568
+
316
569
  function writeProviderOutput(result) {
317
570
  if (result.stdout) {
318
571
  process.stdout.write(redactSecrets(result.stdout));
@@ -937,6 +1190,10 @@ async function runOnboard(repoRoot, options = {}) {
937
1190
  }
938
1191
 
939
1192
  async function runPrepareContext(repoRoot, options = {}) {
1193
+ if (options.withPlanner === true) {
1194
+ return runPrepareContextWithPlanner(repoRoot, options);
1195
+ }
1196
+
940
1197
  const draftPack = buildContextPreparationDrafts(repoRoot);
941
1198
  const writePlan = buildContextWritePlan(repoRoot, draftPack.docs);
942
1199
  const report = {
@@ -998,6 +1255,152 @@ async function runPrepareContext(repoRoot, options = {}) {
998
1255
  };
999
1256
  }
1000
1257
 
1258
+ async function runPrepareContextWithPlanner(repoRoot, options = {}) {
1259
+ const role = normalizeRole(options.role || DEFAULT_PLAN_ROLE);
1260
+ const provider = resolveProviderForProfile(repoRoot, role, options.provider, options.providerExplicit, DEFAULT_PLAN_PROVIDER);
1261
+ const context = options.context || DEFAULT_PLAN_CONTEXT;
1262
+ const timeoutMs = normalizeTimeout(options.timeout);
1263
+ const draftPack = buildContextPreparationDrafts(repoRoot);
1264
+ const pack = buildContextPackMetadata({
1265
+ role,
1266
+ packName: context,
1267
+ repoRoot,
1268
+ });
1269
+ const promptInfo = buildPrepareContextPlannerPrompt({ pack, draftPack });
1270
+ const prompt = promptInfo.prompt;
1271
+ assertProviderPromptWithinLimit(prompt, options.promptLimitOptions || {});
1272
+ let invocation;
1273
+
1274
+ try {
1275
+ invocation = buildProviderInvocation(provider, {
1276
+ prompt,
1277
+ cwd: repoRoot,
1278
+ timeoutMs,
1279
+ });
1280
+ } catch (error) {
1281
+ throw annotateProviderError(error, 'prepare-context');
1282
+ }
1283
+
1284
+ if (options.dryRun) {
1285
+ const report = {
1286
+ task: 'prepare-context',
1287
+ mode: 'planner',
1288
+ dryRun: true,
1289
+ provider,
1290
+ role,
1291
+ contextPack: context,
1292
+ invocation,
1293
+ candidateDocs: promptInfo.allowedPaths,
1294
+ plan: draftPack.plan,
1295
+ };
1296
+ process.stdout.write(formatPrepareContextPlannerDryRunReport({
1297
+ provider,
1298
+ role,
1299
+ context,
1300
+ invocation,
1301
+ promptInfo,
1302
+ review: options.review === true,
1303
+ interactive: options.interactive === true,
1304
+ }));
1305
+ return report;
1306
+ }
1307
+
1308
+ if (options.printPrompt) {
1309
+ const report = {
1310
+ task: 'prepare-context',
1311
+ provider,
1312
+ role,
1313
+ contextPack: context,
1314
+ invocation,
1315
+ prompt,
1316
+ promptSource: promptInfo.promptSource,
1317
+ };
1318
+ process.stdout.write(formatPromptOnlyReport(report));
1319
+ return report;
1320
+ }
1321
+
1322
+ let result;
1323
+ try {
1324
+ result = await (options.runProviderFn || runProvider)(provider, {
1325
+ prompt,
1326
+ cwd: repoRoot,
1327
+ timeoutMs,
1328
+ dryRun: false,
1329
+ probe: options.probe,
1330
+ spawn: options.spawn,
1331
+ tempRoot: options.tempRoot,
1332
+ tempFileName: options.tempFileName,
1333
+ tempFilePrefix: options.tempFilePrefix,
1334
+ });
1335
+ } catch (error) {
1336
+ throw annotateProviderError(error, 'prepare-context');
1337
+ }
1338
+
1339
+ if (!result.ok) {
1340
+ writeProviderOutput(result);
1341
+ throw annotateProviderError(result.error || new Error('provider run failed'), 'prepare-context');
1342
+ }
1343
+
1344
+ const clean = extractCleanProviderOutput(result, { prompt, projectRoot: repoRoot });
1345
+ let proposal = parseContextProposalOutput(clean.cleanOutput);
1346
+ let reviewPath = '';
1347
+
1348
+ if (options.review === true) {
1349
+ const reviewed = await reviewPlannerContextProposal(repoRoot, proposal, options);
1350
+ proposal = reviewed.proposal;
1351
+ reviewPath = reviewed.reviewPath;
1352
+ }
1353
+
1354
+ const writePlan = buildPlannerContextWritePlan(repoRoot, proposal);
1355
+ await confirmPlannerContextWrites(writePlan, options);
1356
+
1357
+ const lifecycleRun = ensureAiRun(repoRoot, {
1358
+ command: 'ai prepare-context --with-planner',
1359
+ input: options.input || '',
1360
+ runId: options.runId,
1361
+ phase: 'created',
1362
+ });
1363
+ const snapshot = createContextSnapshots(repoRoot, lifecycleRun, writePlan, options.now || new Date());
1364
+ const plannedDocs = writePlan.filter((item) => item.action !== 'skip').map((item) => item.path);
1365
+
1366
+ process.stdout.write(formatContextPreparationReport({
1367
+ dryRun: false,
1368
+ plan: draftPack.plan,
1369
+ writePlan,
1370
+ writtenDocs: plannedDocs,
1371
+ snapshot,
1372
+ }));
1373
+
1374
+ const writtenDocs = writeDraftDocs(writePlan);
1375
+ updateAiRunPhase(repoRoot, lifecycleRun.run_id, 'onboarding-ready', {
1376
+ artifact: snapshot.manifestPath,
1377
+ command: 'ai prepare-context --with-planner',
1378
+ });
1379
+ process.stdout.write(formatContextPreparationReport({
1380
+ dryRun: false,
1381
+ plan: draftPack.plan,
1382
+ writePlan,
1383
+ writtenDocs,
1384
+ snapshot,
1385
+ completed: true,
1386
+ }));
1387
+
1388
+ return {
1389
+ task: 'prepare-context',
1390
+ mode: 'planner',
1391
+ dryRun: false,
1392
+ provider,
1393
+ role,
1394
+ contextPack: context,
1395
+ invocation,
1396
+ proposal,
1397
+ reviewPath,
1398
+ runId: lifecycleRun.run_id,
1399
+ snapshot,
1400
+ writtenDocs,
1401
+ };
1402
+ }
1403
+
1001
1404
  async function runPlan(repoRoot, options = {}) {
1002
1405
  const phase = normalizePlannerPhase(options.phase || DEFAULT_PLAN_PHASE);
1003
1406
  const role = normalizeRole(options.role || DEFAULT_PLAN_ROLE);
@@ -1118,6 +1521,15 @@ async function runPlan(repoRoot, options = {}) {
1118
1521
  invocation,
1119
1522
  };
1120
1523
  process.stdout.write(formatDryRunReport(report));
1524
+ if (options.withPlanner === true) {
1525
+ process.stdout.write('Planner mode: already active for ai plan; --with-planner is accepted for UX consistency.\n');
1526
+ }
1527
+ if (options.review === true) {
1528
+ process.stdout.write('Review requested: provider output will be opened for review before saving the draft in live mode.\n');
1529
+ }
1530
+ if (options.interactive === true) {
1531
+ process.stdout.write('Interactive requested: live mode will ask before saving the draft.\n');
1532
+ }
1121
1533
  return report;
1122
1534
  }
1123
1535
 
@@ -1157,13 +1569,27 @@ async function runPlan(repoRoot, options = {}) {
1157
1569
  throw annotateProviderError(result.error || new Error('provider run failed'), 'plan', phase);
1158
1570
  }
1159
1571
 
1572
+ const clean = extractCleanProviderOutput(result, { prompt, projectRoot: repoRoot });
1573
+ let cleanOutput = clean.cleanOutput;
1574
+ let reviewPath = '';
1575
+
1576
+ if (options.review === true) {
1577
+ const reviewed = await reviewTextWithEditor(repoRoot, cleanOutput, {
1578
+ ...options,
1579
+ reviewFileName: `ai-plan-${phase}-draft.md`,
1580
+ reviewLabel: `ai plan --phase ${phase} review`,
1581
+ });
1582
+ cleanOutput = reviewed.text;
1583
+ reviewPath = reviewed.reviewPath;
1584
+ }
1585
+
1586
+ await confirmInteractiveAction(`Save ${phase} planner draft?`, options);
1160
1587
  const lifecycleRun = ensureAiRun(repoRoot, {
1161
1588
  command: `ai plan --phase ${phase}`,
1162
1589
  input: inputPath,
1163
1590
  runId: options.runId,
1164
1591
  });
1165
- const clean = extractCleanProviderOutput(result, { prompt, projectRoot: repoRoot });
1166
- writeCleanProviderOutput(clean);
1592
+ writeCleanProviderOutput({ cleanOutput });
1167
1593
  const rawArtifact = writeRawProviderArtifact(repoRoot, lifecycleRun.run_id, `ai-plan-${phase}`, result, {
1168
1594
  metadata: {
1169
1595
  phase,
@@ -1174,10 +1600,11 @@ async function runPlan(repoRoot, options = {}) {
1174
1600
  input_compaction: inputCompaction,
1175
1601
  },
1176
1602
  });
1177
- const draft = savePlannerDraft(repoRoot, phase, inputPath, clean.cleanOutput, {
1603
+ const draft = savePlannerDraft(repoRoot, phase, inputPath, cleanOutput, {
1178
1604
  rawArtifactPath: rawArtifact.path,
1179
1605
  outputSource: clean.source,
1180
1606
  inputCompaction,
1607
+ reviewPath,
1181
1608
  });
1182
1609
  updateAiRunPhase(repoRoot, lifecycleRun.run_id, phase === 'acceptance' ? 'acceptance-draft' : 'technical-plan-draft', {
1183
1610
  artifact: path.relative(repoRoot, draft.filePath).split(path.sep).join('/'),
@@ -1192,6 +1619,7 @@ async function runPlan(repoRoot, options = {}) {
1192
1619
  phase,
1193
1620
  invocation,
1194
1621
  result,
1622
+ reviewPath,
1195
1623
  };
1196
1624
  }
1197
1625
 
@@ -1928,6 +2356,31 @@ async function runPr(repoRoot, options = {}) {
1928
2356
  throw annotateGitHubError(error, 'pr');
1929
2357
  }
1930
2358
 
2359
+ if (options.review === true) {
2360
+ const hasEditorRunner = typeof options.openEditorFn === 'function';
2361
+ const canOpenEditor = hasEditorRunner || options.stdinIsTTY === true || (options.stdinIsTTY !== false && Boolean(process.stdin.isTTY));
2362
+ if (!canOpenEditor) {
2363
+ throw annotateGitHubError(makeReviewError('ai pr --review requires an interactive terminal or an injected editor runner.', plan.prBodyPath), 'pr');
2364
+ }
2365
+ const editorResult = hasEditorRunner
2366
+ ? options.openEditorFn(plan.prBodyPath, { cwd: repoRoot, env: options.env || process.env })
2367
+ : openEditor(plan.prBodyPath, { cwd: repoRoot, env: options.env || process.env });
2368
+ if (!editorResult || editorResult.ok !== true) {
2369
+ throw annotateGitHubError(makeReviewError(editorResult?.reason || 'ai pr review was canceled.', plan.prBodyPath), 'pr');
2370
+ }
2371
+ try {
2372
+ plan = buildPrCreatePlan(repoRoot, preflight, {
2373
+ baseBranch: options.baseBranch,
2374
+ ghCommand: options.ghCommand,
2375
+ input: options.input,
2376
+ prBodyPath: options.prBodyPath,
2377
+ title: options.title,
2378
+ });
2379
+ } catch (error) {
2380
+ throw annotateGitHubError(error, 'pr');
2381
+ }
2382
+ }
2383
+
1931
2384
  if (dryRun || !create) {
1932
2385
  process.stdout.write(formatPrCreateReport({ preflight, plan }, { dryRun, create }));
1933
2386
  return {
@@ -1939,6 +2392,8 @@ async function runPr(repoRoot, options = {}) {
1939
2392
  };
1940
2393
  }
1941
2394
 
2395
+ await confirmInteractiveAction(`Create GitHub PR '${plan.title}'?`, options);
2396
+
1942
2397
  let result;
1943
2398
  try {
1944
2399
  result = runGhPrCreate(plan, {
@@ -2,6 +2,8 @@ const fs = require('node:fs');
2
2
  const path = require('node:path');
3
3
 
4
4
  const { checkHandoff } = require('../lib/handoff');
5
+ const { openEditor } = require('../lib/cli/editor');
6
+ const { createUx } = require('../lib/cli/ux');
5
7
  const { parseJsonWithComments } = require('../lib/json');
6
8
  const { assertPathInsideRoot, validateProjectRelativePaths } = require('../lib/paths');
7
9
  const { resolveReviewedTechnicalPlanInput } = require('../lib/ai/plan-review');
@@ -362,6 +364,10 @@ function formatSpecCreateDryRun(preview) {
362
364
  lines.push(`- ${file}`);
363
365
  }
364
366
 
367
+ if (preview.reviewRequested) {
368
+ lines.push('Review requested: yes (dry-run preview only; no editor opened and no files written).');
369
+ }
370
+
365
371
  lines.push(...formatNextCommands(preview.relativeSpecDir));
366
372
  lines.push('No files will be written in dry-run mode.');
367
373
  return `${lines.join('\n')}\n`;
@@ -384,11 +390,63 @@ function formatSpecCreateResult(result, repoRoot) {
384
390
  return `${lines.join('\n')}\n`;
385
391
  }
386
392
 
387
- function runCreateSpec(repoRoot, options = {}) {
393
+ function createSpecReviewFile(preview, options = {}) {
394
+ const os = require('node:os');
395
+ const reviewDir = options.reviewDir || fs.mkdtempSync(path.join(os.tmpdir(), 'quiver-spec-create-review-'));
396
+ const reviewPath = path.join(reviewDir, 'spec-create-preview.md');
397
+ fs.mkdirSync(reviewDir, { recursive: true });
398
+ fs.writeFileSync(reviewPath, formatSpecCreateDryRun({ ...preview, reviewRequested: false }));
399
+ return reviewPath;
400
+ }
401
+
402
+ async function confirmSpecCreate(message, options = {}) {
403
+ if (options.interactive !== true) {
404
+ return;
405
+ }
406
+
407
+ const ux = options.ux || createUx({
408
+ interactive: true,
409
+ promptConfirm: options.promptConfirm,
410
+ stdinIsTTY: options.stdinIsTTY,
411
+ stdoutIsTTY: options.stdoutIsTTY,
412
+ stderrIsTTY: options.stderrIsTTY,
413
+ write: options.write,
414
+ });
415
+ const confirmed = await ux.promptConfirm(message, { initialValue: false });
416
+ if (!confirmed) {
417
+ throw new Error(formatError('spec create interactive approval declined. No files were written.'));
418
+ }
419
+ }
420
+
421
+ async function reviewSpecCreatePreview(repoRoot, preview, options = {}) {
422
+ if (options.review !== true) {
423
+ return '';
424
+ }
425
+
426
+ const reviewPath = createSpecReviewFile(preview, options);
427
+ const hasEditorRunner = typeof options.openEditorFn === 'function';
428
+ const canOpenEditor = hasEditorRunner || options.stdinIsTTY === true || (options.stdinIsTTY !== false && Boolean(process.stdin.isTTY));
429
+
430
+ if (!canOpenEditor) {
431
+ throw new Error(formatError(`spec create --review requires an interactive terminal or an injected editor runner.\nReview artifact: ${reviewPath}`));
432
+ }
433
+
434
+ const editorResult = hasEditorRunner
435
+ ? options.openEditorFn(reviewPath, { cwd: repoRoot, env: options.env || process.env })
436
+ : openEditor(reviewPath, { cwd: repoRoot, env: options.env || process.env });
437
+
438
+ if (!editorResult || editorResult.ok !== true) {
439
+ throw new Error(formatError(`${editorResult?.reason || 'spec create review was canceled.'}\nReview artifact: ${reviewPath}`));
440
+ }
441
+
442
+ return reviewPath;
443
+ }
444
+
445
+ async function runCreateSpec(repoRoot, options = {}) {
388
446
  const preview = buildSpecCreatePreview(repoRoot, options);
389
447
 
390
448
  if (options.dryRun) {
391
- process.stdout.write(formatSpecCreateDryRun(preview));
449
+ process.stdout.write(formatSpecCreateDryRun({ ...preview, reviewRequested: options.review === true }));
392
450
  return {
393
451
  task: 'spec-create',
394
452
  dryRun: true,
@@ -398,6 +456,9 @@ function runCreateSpec(repoRoot, options = {}) {
398
456
  };
399
457
  }
400
458
 
459
+ const reviewPath = await reviewSpecCreatePreview(repoRoot, preview, options);
460
+ await confirmSpecCreate(`Create spec '${preview.manifest.slug}'?`, options);
461
+
401
462
  const result = generateSpecArtifacts(repoRoot, {
402
463
  input: preview.inputPath,
403
464
  specSlug: options.specSlug,
@@ -411,6 +472,7 @@ function runCreateSpec(repoRoot, options = {}) {
411
472
  specDir: toRelativePosix(repoRoot, result.specDir),
412
473
  files: result.files.map((filePath) => toRelativePosix(repoRoot, filePath)),
413
474
  manifest: result.manifest,
475
+ reviewPath,
414
476
  };
415
477
  }
416
478