@open-agent-toolkit/cli 0.3.18 → 0.3.20

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 (132) hide show
  1. package/README.md +2 -1
  2. package/assets/agents/oat-phase-implementer.md +104 -3
  3. package/assets/agents/oat-reviewer.md +20 -5
  4. package/assets/bundle-metadata.json +1 -1
  5. package/assets/docs/reference/cli-reference.md +15 -7
  6. package/assets/docs/reference/oat-directory-structure.md +2 -0
  7. package/assets/docs/reference/project-artifacts.md +72 -1
  8. package/assets/docs/reference/project-state-machine.md +55 -6
  9. package/assets/docs/skills/brainstorm.md +3 -1
  10. package/assets/docs/skills/index.md +13 -9
  11. package/assets/docs/workflows/advanced/autonomy.md +12 -2
  12. package/assets/docs/workflows/advanced/dispatch-ceiling.md +2 -2
  13. package/assets/docs/workflows/advanced/workflow-gates.md +25 -7
  14. package/assets/docs/workflows/approvals-and-automation.md +25 -9
  15. package/assets/docs/workflows/choose-workflow.md +24 -8
  16. package/assets/docs/workflows/ideas/index.md +1 -1
  17. package/assets/docs/workflows/ideas/lifecycle.md +3 -2
  18. package/assets/docs/workflows/projects/closeout/closeout-skills.md +83 -5
  19. package/assets/docs/workflows/projects/closeout/pr-flow.md +9 -1
  20. package/assets/docs/workflows/projects/execution/execution-skills.md +27 -2
  21. package/assets/docs/workflows/projects/execution/implementation-execution.md +15 -0
  22. package/assets/docs/workflows/projects/execution/picking-up-projects.md +20 -1
  23. package/assets/docs/workflows/projects/lifecycle.md +172 -9
  24. package/assets/docs/workflows/projects/planning/hill-checkpoints.md +18 -1
  25. package/assets/docs/workflows/projects/planning/planning-skills.md +13 -10
  26. package/assets/docs/workflows/projects/planning/starting-projects.md +138 -20
  27. package/assets/docs/workflows/projects/reviews/index.md +36 -2
  28. package/assets/docs/workflows/projects/reviews/review-flavors.md +7 -3
  29. package/assets/public-package-versions.json +4 -4
  30. package/assets/skills/oat-brainstorm/SKILL.md +58 -9
  31. package/assets/skills/oat-brainstorm/references/destinations.md +12 -4
  32. package/assets/skills/oat-docs/SKILL.md +13 -4
  33. package/assets/skills/oat-pjm-review-backlog/SKILL.md +2 -2
  34. package/assets/skills/oat-project-autonomous/SKILL.md +20 -8
  35. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +156 -122
  36. package/assets/skills/oat-project-brief/SKILL.md +592 -0
  37. package/assets/skills/oat-project-brief/references/brief-contract.md +743 -0
  38. package/assets/skills/oat-project-brief/references/docs/autonomy-contract.md +412 -0
  39. package/assets/skills/oat-project-brief/references/docs/gate-approval-record.md +93 -0
  40. package/assets/skills/oat-project-capture/SKILL.md +2 -2
  41. package/assets/skills/oat-project-complete/SKILL.md +16 -2
  42. package/assets/skills/oat-project-discover/SKILL.md +4 -2
  43. package/assets/skills/oat-project-dispatch-subagents/SKILL.md +9 -1
  44. package/assets/skills/oat-project-document/SKILL.md +17 -1
  45. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +156 -122
  46. package/assets/skills/oat-project-goal/SKILL.md +697 -0
  47. package/assets/skills/oat-project-goal/references/docs/autonomy-contract.md +412 -0
  48. package/assets/skills/oat-project-goal/references/docs/gate-approval-record.md +93 -0
  49. package/assets/skills/oat-project-implement/SKILL.md +1 -1
  50. package/assets/skills/oat-project-implement/references/completion-and-closeout.md +107 -0
  51. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +156 -122
  52. package/assets/skills/oat-project-implement/references/docs/gate-approval-record.md +13 -0
  53. package/assets/skills/oat-project-implement/references/phase-execution.md +173 -1
  54. package/assets/skills/oat-project-implement/references/plan-and-resume.md +124 -3
  55. package/assets/skills/oat-project-import-plan/SKILL.md +5 -1
  56. package/assets/skills/oat-project-lite/SKILL.md +4 -1
  57. package/assets/skills/oat-project-lite/references/docs/autonomy-contract.md +156 -122
  58. package/assets/skills/oat-project-new/SKILL.md +5 -1
  59. package/assets/skills/oat-project-next/SKILL.md +71 -14
  60. package/assets/skills/oat-project-next/references/docs/gate-approval-record.md +13 -0
  61. package/assets/skills/oat-project-plan/SKILL.md +24 -3
  62. package/assets/skills/oat-project-plan-writing/SKILL.md +8 -4
  63. package/assets/skills/oat-project-pr-final/SKILL.md +65 -3
  64. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +156 -122
  65. package/assets/skills/oat-project-pr-progress/SKILL.md +125 -4
  66. package/assets/skills/oat-project-progress/SKILL.md +63 -8
  67. package/assets/skills/oat-project-progress/references/docs/gate-approval-record.md +13 -0
  68. package/assets/skills/oat-project-promote-spec-driven/SKILL.md +2 -2
  69. package/assets/skills/oat-project-quick-start/SKILL.md +65 -1
  70. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +156 -122
  71. package/assets/skills/oat-project-quick-start/references/docs/gate-approval-record.md +13 -0
  72. package/assets/skills/oat-project-reconcile/SKILL.md +94 -3
  73. package/assets/skills/oat-project-review-provide/SKILL.md +397 -12
  74. package/assets/skills/oat-project-review-receive/SKILL.md +155 -1
  75. package/assets/skills/oat-project-revise/SKILL.md +51 -4
  76. package/assets/skills/oat-project-summary/SKILL.md +150 -2
  77. package/assets/skills/oat-wave-execute/SKILL.md +3 -2
  78. package/assets/templates/implementation-brief.md +165 -0
  79. package/assets/templates/plan-brief.md +140 -0
  80. package/assets/templates/plan.md +1 -1
  81. package/assets/templates/state.md +8 -2
  82. package/dist/commands/project/closeout-check/closeout-invariant.d.ts +119 -3
  83. package/dist/commands/project/closeout-check/closeout-invariant.d.ts.map +1 -1
  84. package/dist/commands/project/closeout-check/closeout-invariant.js +454 -2
  85. package/dist/commands/project/closeout-check/index.d.ts +21 -3
  86. package/dist/commands/project/closeout-check/index.d.ts.map +1 -1
  87. package/dist/commands/project/closeout-check/index.js +190 -4
  88. package/dist/commands/project/complete-state/index.d.ts +2 -1
  89. package/dist/commands/project/complete-state/index.d.ts.map +1 -1
  90. package/dist/commands/project/complete-state/index.js +7 -1
  91. package/dist/commands/project/delivery/check.d.ts +66 -0
  92. package/dist/commands/project/delivery/check.d.ts.map +1 -0
  93. package/dist/commands/project/delivery/check.js +232 -0
  94. package/dist/commands/project/delivery/evidence.d.ts +171 -0
  95. package/dist/commands/project/delivery/evidence.d.ts.map +1 -0
  96. package/dist/commands/project/delivery/evidence.js +902 -0
  97. package/dist/commands/project/delivery/index.d.ts +7 -0
  98. package/dist/commands/project/delivery/index.d.ts.map +1 -0
  99. package/dist/commands/project/delivery/index.js +13 -0
  100. package/dist/commands/project/delivery/refresh.d.ts +110 -0
  101. package/dist/commands/project/delivery/refresh.d.ts.map +1 -0
  102. package/dist/commands/project/delivery/refresh.js +441 -0
  103. package/dist/commands/project/index.d.ts.map +1 -1
  104. package/dist/commands/project/index.js +2 -0
  105. package/dist/commands/project/log/append.d.ts +6 -0
  106. package/dist/commands/project/log/append.d.ts.map +1 -1
  107. package/dist/commands/project/log/append.js +6 -1
  108. package/dist/commands/project/new/scaffold.d.ts +1 -1
  109. package/dist/commands/project/new/scaffold.d.ts.map +1 -1
  110. package/dist/commands/project/new/scaffold.js +81 -7
  111. package/dist/commands/project/promote/promote.d.ts +46 -1
  112. package/dist/commands/project/promote/promote.d.ts.map +1 -1
  113. package/dist/commands/project/promote/promote.js +646 -5
  114. package/dist/commands/project/validate-plan/frontmatter.d.ts +12 -0
  115. package/dist/commands/project/validate-plan/frontmatter.d.ts.map +1 -0
  116. package/dist/commands/project/validate-plan/frontmatter.js +27 -0
  117. package/dist/commands/project/validate-plan/index.d.ts.map +1 -1
  118. package/dist/commands/project/validate-plan/index.js +62 -18
  119. package/dist/commands/project/validate-plan/profiles.d.ts +69 -0
  120. package/dist/commands/project/validate-plan/profiles.d.ts.map +1 -0
  121. package/dist/commands/project/validate-plan/profiles.js +420 -0
  122. package/dist/commands/project/validate-plan/validate-plan.d.ts +3 -19
  123. package/dist/commands/project/validate-plan/validate-plan.d.ts.map +1 -1
  124. package/dist/commands/project/validate-plan/validate-plan.js +5 -82
  125. package/dist/commands/shared/frontmatter.d.ts +1 -1
  126. package/dist/commands/shared/frontmatter.d.ts.map +1 -1
  127. package/dist/commands/shared/frontmatter.js +2 -0
  128. package/dist/commands/state/generate.d.ts.map +1 -1
  129. package/dist/commands/state/generate.js +82 -2
  130. package/dist/commands/tools/shared/pack-manifest.d.ts.map +1 -1
  131. package/dist/commands/tools/shared/pack-manifest.js +4 -0
  132. package/package.json +2 -2
@@ -5,6 +5,7 @@ import { buildCommandContext, } from '../../../app/command-context.js';
5
5
  import { applyTemplateReplacements } from '../../project/new/scaffold.js';
6
6
  import { defaultGitRunner } from '../../project/sync/git.js';
7
7
  import { buildSyncTarget, pushSynced as defaultPushSynced, } from '../../project/sync/ref-sync.js';
8
+ import { parsePhaseHeadingLine } from '../../project/validate-plan/profiles.js';
8
9
  import { commitExactPaths, } from '../../shared/exact-path-commit.js';
9
10
  import { getFrontmatterBlock } from '../../shared/frontmatter.js';
10
11
  import { replaceFrontmatter } from '../../shared/frontmatter-write.js';
@@ -15,6 +16,7 @@ import { resolveTemplate as defaultResolveTemplate } from '../../shared/template
15
16
  import { resolveAssetsRoot } from '../../../fs/assets.js';
16
17
  import { fileExists as defaultFileExists } from '../../../fs/io.js';
17
18
  import { resolveProjectRoot } from '../../../fs/paths.js';
19
+ import { parsePhaseMarkers, parseTaskProgress, recordLineMask, } from '@open-agent-toolkit/control-plane';
18
20
  import { Command } from 'commander';
19
21
  import YAML from 'yaml';
20
22
  const PROMOTED_FILES = [
@@ -181,7 +183,7 @@ function renderQuickState(stateContent, statePath, nowUtc) {
181
183
  function repoRelativePath(repoRoot, path) {
182
184
  return relative(repoRoot, path).split(sep).join('/');
183
185
  }
184
- async function persistPromotion(repoRoot, projectsRoot, projectRoot, projectName, scope, identity, dependencies) {
186
+ async function persistPromotion(repoRoot, projectsRoot, projectRoot, projectName, scope, identity, dependencies, files = PROMOTED_FILES) {
185
187
  const message = `chore(oat): promote ${projectName} to quick`;
186
188
  if (scope === 'synced') {
187
189
  const target = buildSyncTarget(repoRoot, projectsRoot, projectName);
@@ -194,7 +196,7 @@ async function persistPromotion(repoRoot, projectsRoot, projectRoot, projectName
194
196
  if (scope === 'local') {
195
197
  return;
196
198
  }
197
- const pathspecs = PROMOTED_FILES.map((file) => repoRelativePath(repoRoot, join(projectRoot, file)));
199
+ const pathspecs = files.map((file) => repoRelativePath(repoRoot, join(projectRoot, file)));
198
200
  await dependencies.gitRunner.run(['rev-parse', '--is-inside-work-tree'], {
199
201
  cwd: repoRoot,
200
202
  });
@@ -243,6 +245,25 @@ async function promoteProject(projectPath, options, context, dependencies) {
243
245
  catch {
244
246
  return { status: 'refused', reason: 'project-unreadable', files: [] };
245
247
  }
248
+ const mode = stateFrontmatter['oat_workflow_mode'];
249
+ if (mode === 'brief' || mode === 'goal') {
250
+ if (mode === 'goal') {
251
+ const unsettled = goalPromotionRefusal(stateFrontmatter);
252
+ if (unsettled !== null)
253
+ return unsettled;
254
+ }
255
+ return promoteBriefProject({
256
+ repoRoot,
257
+ projectRoot,
258
+ projectName,
259
+ statePath,
260
+ planPath,
261
+ stateContent,
262
+ planContent,
263
+ sourceMode: mode,
264
+ stateFrontmatter,
265
+ }, context, dependencies);
266
+ }
246
267
  if (stateFrontmatter['oat_workflow_mode'] !== 'lite') {
247
268
  return { status: 'refused', reason: 'not-lite', files: [] };
248
269
  }
@@ -332,11 +353,631 @@ async function promoteProject(projectPath, options, context, dependencies) {
332
353
  files: [...PROMOTED_FILES],
333
354
  };
334
355
  }
356
+ // ---------------------------------------------------------------------------
357
+ // goal → quick: the settled delivery boundary (B3)
358
+ // ---------------------------------------------------------------------------
359
+ /** Registry statuses at which a delivery no longer needs goal machinery. */
360
+ const SETTLED_DELIVERY_STATUSES = new Set(['at_endpoint', 'superseded']);
361
+ /** The goal state carried verbatim into the promoted state and the archive. */
362
+ const GOAL_HISTORY_KEYS = [
363
+ 'oat_deliveries',
364
+ 'oat_milestone_gates',
365
+ 'oat_goal_outcome_audit',
366
+ ];
367
+ /**
368
+ * A goal promotes only at a settled delivery boundary: the active milestone
369
+ * is `delivered`, every registry entry is `at_endpoint` or `superseded` (none
370
+ * `registering` or `open`; an empty registry is not settled), and no recovery
371
+ * is pending. Extracted phases are
372
+ * refused by the shared migration. Returns the refusal, or null when settled.
373
+ */
374
+ export function goalPromotionRefusal(stateFrontmatter) {
375
+ if (stateFrontmatter['oat_milestone_status'] !== 'delivered') {
376
+ return { status: 'refused', reason: 'goal-milestone-active', files: [] };
377
+ }
378
+ const recovery = stateFrontmatter['oat_goal_recovery'];
379
+ if (recovery !== null && recovery !== undefined) {
380
+ return { status: 'refused', reason: 'goal-recovery-pending', files: [] };
381
+ }
382
+ const registry = stateFrontmatter['oat_deliveries'];
383
+ const entries = Array.isArray(registry) ? registry : [];
384
+ const unsettled = entries
385
+ .filter((entry) => typeof entry !== 'object' ||
386
+ entry === null ||
387
+ !SETTLED_DELIVERY_STATUSES.has(String(entry['status'])))
388
+ .map((entry) => typeof entry === 'object' && entry !== null
389
+ ? String(entry['delivery_id'] ?? '?')
390
+ : '?');
391
+ // `delivered` means registered, open and review-ready (B4): a delivered
392
+ // milestone with no registry entry is contradictory, never vacuously settled.
393
+ if (!Array.isArray(registry) ||
394
+ entries.length === 0 ||
395
+ unsettled.length > 0) {
396
+ return {
397
+ status: 'refused',
398
+ reason: 'goal-delivery-unsettled',
399
+ files: [],
400
+ deliveries: unsettled,
401
+ };
402
+ }
403
+ return null;
404
+ }
405
+ /**
406
+ * The goal archive: the original brief followed by its delivery history, so
407
+ * the registry, milestone gates and outcome audit survive under the archive
408
+ * as well as in the promoted `state.md`.
409
+ */
410
+ function renderGoalArchive(planContent, stateFrontmatter) {
411
+ const history = {};
412
+ for (const key of GOAL_HISTORY_KEYS) {
413
+ history[key] = stateFrontmatter[key] ?? null;
414
+ }
415
+ return `${planContent.trimEnd()}
416
+
417
+ ## Delivery History (archived at promotion)
418
+
419
+ The goal's delivery registry, milestone gate carriers and outcome audit as
420
+ recorded in \`state.md\` when the project was promoted to quick.
421
+
422
+ \`\`\`yaml
423
+ ${YAML.stringify(history).trimEnd()}
424
+ \`\`\`
425
+ `;
426
+ }
427
+ // ---------------------------------------------------------------------------
428
+ // brief → quick
429
+ // ---------------------------------------------------------------------------
430
+ const BRIEF_ARCHIVE_FILE = 'references/brief-archive.md';
431
+ const SYNTHESIZED_TASK_TITLE = 'Accepted via phase evidence';
432
+ /** Brief sections rendered into `discovery.md` and dropped from the quick plan. */
433
+ const BRIEF_ONLY_SECTIONS = [
434
+ 'goal',
435
+ 'outcomes',
436
+ 'approach',
437
+ 'constraints and out of scope',
438
+ 'constraints',
439
+ 'out of scope',
440
+ 'decisions',
441
+ 'known unknowns',
442
+ 'delivery endpoint and authority',
443
+ 'delivery authority',
444
+ 'milestones',
445
+ 'phases',
446
+ ];
447
+ const TASK_RECORD_HEADING = /^### Task ((p-?rev\d+|p\d+)-t(\d+)): (.+)$/;
448
+ const RECORD_STATUS_LINE = /^\*\*Status:\*\*\s+(\S+)$/;
449
+ const RECORD_COMMIT_LINE = /^\*\*Commit:\*\*\s+(\S+)$/;
450
+ const RECORD_MARKER_LINE = /^\*\*(Status|Commit|Executor):\*\*/;
451
+ const SECTION_HEADING_LINE = /^#{1,2}[ \t]/;
452
+ const HEADING_LINE = /^#/;
453
+ function stripFrontmatter(content) {
454
+ const match = /^---\n[\s\S]*?\n---[^\n]*(?:\n|$)/.exec(content);
455
+ return match ? content.slice(match[0].length) : content;
456
+ }
457
+ function normalizeOrdinal(digits, width) {
458
+ return digits.replace(/^0+(?=\d)/, '').padStart(width, '0');
459
+ }
460
+ function taskPhaseId(prefix) {
461
+ const revision = /^p-?rev(\d+)$/.exec(prefix);
462
+ if (revision?.[1])
463
+ return `p-rev${normalizeOrdinal(revision[1], 1)}`;
464
+ return `p${normalizeOrdinal(prefix.slice(1), 2)}`;
465
+ }
466
+ function taskPrefixForPhase(phaseId) {
467
+ return phaseId.startsWith('p-rev') ? `prev${phaseId.slice(5)}` : phaseId;
468
+ }
469
+ /** Split markdown into `## ` sections, ignoring headings inside code. */
470
+ function splitSections(body) {
471
+ const lines = body.split('\n');
472
+ const mask = recordLineMask(lines);
473
+ const sections = [{ heading: null, lines: [] }];
474
+ for (const [index, line] of lines.entries()) {
475
+ if (mask[index] && /^##[ \t]/.test(line)) {
476
+ sections.push({ heading: line, lines: [line] });
477
+ }
478
+ else {
479
+ sections[sections.length - 1].lines.push(line);
480
+ }
481
+ }
482
+ return sections;
483
+ }
484
+ function sectionTitle(heading) {
485
+ return heading.replace(/^##[ \t]+/, '').trim();
486
+ }
487
+ function sectionBody(section) {
488
+ return section.lines.slice(1).join('\n').trim();
489
+ }
490
+ function findBriefSection(sections, matches) {
491
+ const section = sections.find((candidate) => candidate.heading != null &&
492
+ matches(sectionTitle(candidate.heading).toLowerCase()));
493
+ const body = section ? sectionBody(section) : '';
494
+ return body.length > 0 ? body : null;
495
+ }
496
+ /**
497
+ * Execution records per journal phase section: `### Task` headings whose
498
+ * normalized phase id matches the enclosing `## Phase` section. Also returns
499
+ * each phase section's end line (exclusive) for appending records.
500
+ */
501
+ function readJournalRecords(lines) {
502
+ const mask = recordLineMask(lines);
503
+ const records = new Map();
504
+ const sectionEnds = new Map();
505
+ let phaseId = null;
506
+ let current = null;
507
+ const closeSection = (end) => {
508
+ if (phaseId != null)
509
+ sectionEnds.set(phaseId, end);
510
+ };
511
+ for (const [index, line] of lines.entries()) {
512
+ if (!mask[index]) {
513
+ current?.body.push(line);
514
+ continue;
515
+ }
516
+ if (SECTION_HEADING_LINE.test(line)) {
517
+ closeSection(index);
518
+ current = null;
519
+ phaseId = parsePhaseHeadingLine(line)?.phaseId ?? null;
520
+ continue;
521
+ }
522
+ const task = TASK_RECORD_HEADING.exec(line);
523
+ if (task && phaseId != null) {
524
+ current = null;
525
+ if (taskPhaseId(task[2]) === phaseId) {
526
+ current = {
527
+ taskId: task[1],
528
+ ordinal: Number.parseInt(task[3], 10),
529
+ title: task[4],
530
+ status: null,
531
+ commit: null,
532
+ body: [],
533
+ };
534
+ const list = records.get(phaseId) ?? [];
535
+ list.push(current);
536
+ records.set(phaseId, list);
537
+ }
538
+ continue;
539
+ }
540
+ if (HEADING_LINE.test(line)) {
541
+ current = null;
542
+ continue;
543
+ }
544
+ if (current == null)
545
+ continue;
546
+ const trimmed = line.trim();
547
+ const status = RECORD_STATUS_LINE.exec(trimmed)?.[1];
548
+ if (status !== undefined) {
549
+ current.status ??= status;
550
+ continue;
551
+ }
552
+ const commit = RECORD_COMMIT_LINE.exec(trimmed)?.[1];
553
+ if (commit !== undefined) {
554
+ current.commit ??= commit;
555
+ continue;
556
+ }
557
+ if (!RECORD_MARKER_LINE.test(trimmed))
558
+ current.body.push(line);
559
+ }
560
+ closeSection(lines.length);
561
+ return { records, sectionEnds };
562
+ }
563
+ /** Plan phase block lines owned by the phase heading (up to the first `#`). */
564
+ function ownedPhaseLines(section) {
565
+ const owned = [];
566
+ const mask = recordLineMask(section.lines);
567
+ for (const [index, line] of section.lines.slice(1).entries()) {
568
+ if (mask[index + 1] && HEADING_LINE.test(line))
569
+ break;
570
+ owned.push(line);
571
+ }
572
+ while (owned.length > 0 && owned[owned.length - 1].trim() === '') {
573
+ owned.pop();
574
+ }
575
+ return owned;
576
+ }
577
+ function trimBlank(lines) {
578
+ let start = 0;
579
+ let end = lines.length;
580
+ while (start < end && lines[start].trim() === '')
581
+ start += 1;
582
+ while (end > start && lines[end - 1].trim() === '')
583
+ end -= 1;
584
+ return lines.slice(start, end);
585
+ }
586
+ /**
587
+ * The brief's `## Reviews` section with every row left byte-identical (Scope,
588
+ * Type and Artifact are review-event identity) and one provenance line under
589
+ * the heading marking the rows recorded before promotion.
590
+ */
591
+ function reviewsWithProvenance(section, today, sourceMode) {
592
+ return [
593
+ section.lines[0],
594
+ '',
595
+ `Rows dated on or before ${today} were recorded pre-promotion under workflow mode ${sourceMode}.`,
596
+ '',
597
+ ...trimBlank(section.lines.slice(1)),
598
+ ];
599
+ }
600
+ function renderPlanTask(record, sourceMode) {
601
+ const commit = record.commit && record.commit !== '-' ? record.commit : null;
602
+ return [
603
+ `### Task ${record.taskId}: ${record.title}`,
604
+ '',
605
+ `> Accepted before promotion (${sourceMode}). Execution record: \`implementation.md\`${commit ? ` (commit \`${commit}\`)` : ''}.`,
606
+ ...(trimBlank(record.body).length > 0
607
+ ? ['', ...trimBlank(record.body)]
608
+ : []),
609
+ '',
610
+ ];
611
+ }
612
+ /**
613
+ * Pure brief → quick migration. Phase states follow the phase-unit progress
614
+ * contract: accepted (journal `completed`, not provisional), active (any other
615
+ * started status), pending, or provisional. An extracted phase refuses.
616
+ */
617
+ export function migrateBriefToQuick(input) {
618
+ const { planContent, implementationContent, projectName, today } = input;
619
+ const sourceMode = input.sourceMode ?? 'brief';
620
+ const markers = parsePhaseMarkers(implementationContent);
621
+ if ([...markers.values()].some((marker) => marker.status === 'extracted')) {
622
+ return { ok: false, reason: 'extracted-phases' };
623
+ }
624
+ const progress = parseTaskProgress(planContent, implementationContent, {
625
+ phaseUnits: true,
626
+ });
627
+ if (progress.phases.length === 0) {
628
+ return { ok: false, reason: 'invalid-brief-plan' };
629
+ }
630
+ const phaseStates = {};
631
+ for (const phase of progress.phases) {
632
+ const status = markers.get(phase.phaseId)?.status ?? 'pending';
633
+ phaseStates[phase.phaseId] = phase.provisional
634
+ ? 'provisional'
635
+ : phase.completed === 1
636
+ ? 'accepted'
637
+ : status === 'pending'
638
+ ? 'pending'
639
+ : 'active';
640
+ }
641
+ const pendingPhases = progress.phases
642
+ .map((phase) => phase.phaseId)
643
+ .filter((phaseId, index, all) => phaseStates[phaseId] !== 'accepted' && all.indexOf(phaseId) === index);
644
+ const journalLines = implementationContent.split('\n');
645
+ const { records, sectionEnds } = readJournalRecords(journalLines);
646
+ const insertions = [];
647
+ // An accepted phase without a completed record is synthesized against its
648
+ // phase head; without a head there is no evidence anchor, so refuse rather
649
+ // than fabricate a completed record.
650
+ const headless = progress.phases
651
+ .map((phase) => phase.phaseId)
652
+ .filter((phaseId, index, all) => all.indexOf(phaseId) === index &&
653
+ phaseStates[phaseId] === 'accepted' &&
654
+ !(records.get(phaseId) ?? []).some((record) => record.status === 'completed') &&
655
+ markers.get(phaseId)?.head == null);
656
+ if (headless.length > 0) {
657
+ return { ok: false, reason: 'missing-phase-head', phases: headless };
658
+ }
659
+ const frontmatter = getFrontmatterBlock(planContent)
660
+ ? readObjectFrontmatter(planContent, 'plan.md')
661
+ : {};
662
+ const sections = splitSections(stripFrontmatter(planContent));
663
+ const goal = findBriefSection(sections, (title) => title === 'goal');
664
+ const output = [
665
+ `# Implementation Plan: ${projectName}`,
666
+ '',
667
+ `> Promoted from a ${sourceMode === 'goal' ? 'goal brief' : 'brief'} on ${today}. The original brief is archived at \`${BRIEF_ARCHIVE_FILE}\`; its outcomes, decisions, known unknowns and delivery authority are carried into \`discovery.md\`.`,
668
+ '',
669
+ `**Goal:** ${goal ? goal.split('\n\n')[0].replace(/\n/g, ' ') : '{Author from discovery}'}`,
670
+ '',
671
+ '**Commit Convention:** `{type}({scope}): {description}` - e.g., `feat(p01-t01): add user auth endpoint`',
672
+ '',
673
+ ];
674
+ let referencesSeen = false;
675
+ const seenPhases = new Set();
676
+ for (const section of sections) {
677
+ if (section.heading == null)
678
+ continue;
679
+ const title = sectionTitle(section.heading);
680
+ const phaseHeading = parsePhaseHeadingLine(section.heading);
681
+ if (phaseHeading) {
682
+ const { phaseId, name } = phaseHeading;
683
+ if (seenPhases.has(phaseId))
684
+ continue;
685
+ seenPhases.add(phaseId);
686
+ const state = phaseStates[phaseId] ?? 'pending';
687
+ const phaseRecords = records.get(phaseId) ?? [];
688
+ const lines = [`## Phase ${phaseId}: ${name}`, ''];
689
+ const owned = trimBlank(ownedPhaseLines(section));
690
+ if (owned.length > 0)
691
+ lines.push(...owned, '');
692
+ if (state === 'accepted' || state === 'active') {
693
+ const completed = phaseRecords.filter((r) => r.status === 'completed');
694
+ const unfinished = state === 'active'
695
+ ? phaseRecords.filter((r) => r.status === 'pending' || r.status === 'in_progress')
696
+ : [];
697
+ const historical = phaseRecords.filter((r) => r.status !== 'completed' && !unfinished.includes(r));
698
+ if (historical.length > 0) {
699
+ lines.push(`**Historical records:** ${historical
700
+ .map((r) => `${r.taskId} (${r.status ?? 'unknown'})`)
701
+ .join(', ')}`, '');
702
+ }
703
+ if (unfinished.length > 0) {
704
+ lines.push(`**Unfinished records:** ${unfinished
705
+ .map((r) => `${r.taskId} (${r.status})`)
706
+ .join(', ')}`, '');
707
+ }
708
+ for (const record of completed) {
709
+ lines.push(...renderPlanTask(record, sourceMode));
710
+ }
711
+ if (state === 'accepted' && completed.length === 0) {
712
+ const next = Math.max(0, ...phaseRecords.map((r) => r.ordinal)) + 1;
713
+ const taskId = `${taskPrefixForPhase(phaseId)}-t${String(next).padStart(2, '0')}`;
714
+ const head = markers.get(phaseId).head;
715
+ lines.push(`### Task ${taskId}: ${SYNTHESIZED_TASK_TITLE}`, '', `> Synthesized at promotion: the phase was accepted on its exit evidence (head \`${head}\`) without a completed execution record.`, '');
716
+ const end = sectionEnds.get(phaseId) ?? journalLines.length;
717
+ insertions.push({
718
+ at: end,
719
+ lines: [
720
+ `### Task ${taskId}: ${SYNTHESIZED_TASK_TITLE}`,
721
+ '',
722
+ '**Status:** completed',
723
+ `**Commit:** ${head}`,
724
+ '**Executor:** reconciled',
725
+ '',
726
+ `Synthesized by \`oat project promote --to quick\` on ${today}: the phase was accepted on its exit evidence without a completed execution record.`,
727
+ '',
728
+ ],
729
+ });
730
+ }
731
+ }
732
+ output.push(...lines);
733
+ continue;
734
+ }
735
+ if (BRIEF_ONLY_SECTIONS.includes(title.toLowerCase()))
736
+ continue;
737
+ if (/^delivery\b/i.test(title))
738
+ continue;
739
+ if (title.toLowerCase() === 'reviews') {
740
+ output.push(...reviewsWithProvenance(section, today, sourceMode), '');
741
+ continue;
742
+ }
743
+ if (title.toLowerCase() === 'references') {
744
+ referencesSeen = true;
745
+ output.push(...trimBlank(section.lines), `- Brief archive: \`${BRIEF_ARCHIVE_FILE}\``, '- Discovery: `discovery.md`', '');
746
+ continue;
747
+ }
748
+ output.push(...trimBlank(section.lines), '');
749
+ }
750
+ if (!referencesSeen) {
751
+ output.push('## References', '', `- Brief archive: \`${BRIEF_ARCHIVE_FILE}\``, '- Discovery: `discovery.md`', '');
752
+ }
753
+ frontmatter['oat_status'] = 'in_progress';
754
+ frontmatter['oat_ready_for'] = null;
755
+ frontmatter['oat_last_updated'] = today;
756
+ frontmatter['oat_template'] = true;
757
+ frontmatter['oat_template_name'] = 'plan';
758
+ frontmatter['oat_plan_source'] = 'quick';
759
+ frontmatter['oat_promoted_from'] = sourceMode;
760
+ frontmatter['oat_promotion_pending_phases'] = pendingPhases;
761
+ const plan = `---\n${YAML.stringify(frontmatter)}---\n\n${output
762
+ .join('\n')
763
+ .replace(/\n{3,}/g, '\n\n')
764
+ .trimEnd()}\n`;
765
+ const nextJournal = [...journalLines];
766
+ for (const insertion of [...insertions].sort((a, b) => b.at - a.at)) {
767
+ const before = nextJournal.slice(0, insertion.at);
768
+ while (before.length > 0 && before[before.length - 1].trim() === '') {
769
+ before.pop();
770
+ }
771
+ const after = nextJournal.slice(insertion.at);
772
+ nextJournal.splice(0, nextJournal.length, ...before, '', ...insertion.lines, ...after);
773
+ }
774
+ const implementation = nextJournal.join('\n');
775
+ return {
776
+ ok: true,
777
+ migration: {
778
+ plan,
779
+ implementation,
780
+ implementationChanged: insertions.length > 0,
781
+ pendingPhases,
782
+ phaseStates,
783
+ },
784
+ };
785
+ }
786
+ function upsertDiscoverySection(content, heading, body) {
787
+ const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
788
+ if (new RegExp(`^## ${escapedHeading}[ \\t]*$`, 'm').test(content)) {
789
+ return replaceSection(content, heading, body);
790
+ }
791
+ const section = `## ${heading}\n\n${body.trim()}\n\n`;
792
+ const nextSteps = /^## Next Steps[ \t]*$/m.exec(content);
793
+ return nextSteps
794
+ ? `${content.slice(0, nextSteps.index)}${section}${content.slice(nextSteps.index)}`
795
+ : `${content.trimEnd()}\n\n${section}`;
796
+ }
797
+ function renderBriefDiscovery(template, projectName, today, nowUtc, briefPlan) {
798
+ let rendered = applyTemplateReplacements(template, projectName, today, nowUtc, 'quick');
799
+ const frontmatter = readObjectFrontmatter(rendered, 'rendered discovery.md');
800
+ frontmatter['oat_ready_for'] = 'oat-project-quick-start';
801
+ rendered = replaceFrontmatter(rendered, YAML.stringify(frontmatter).trimEnd());
802
+ const sections = splitSections(stripFrontmatter(briefPlan));
803
+ const carried = [
804
+ ['Initial Request', findBriefSection(sections, (t) => t === 'goal')],
805
+ ['Success Criteria', findBriefSection(sections, (t) => t === 'outcomes')],
806
+ ['Key Decisions', findBriefSection(sections, (t) => t === 'decisions')],
807
+ [
808
+ 'Constraints',
809
+ findBriefSection(sections, (t) => t.startsWith('constraints')),
810
+ ],
811
+ [
812
+ 'Open Questions',
813
+ findBriefSection(sections, (t) => t === 'known unknowns'),
814
+ ],
815
+ [
816
+ 'Delivery Endpoint and Authority (from brief)',
817
+ findBriefSection(sections, (t) => t.startsWith('delivery')),
818
+ ],
819
+ ];
820
+ for (const [heading, body] of carried) {
821
+ if (body)
822
+ rendered = upsertDiscoverySection(rendered, heading, body);
823
+ }
824
+ const approach = findBriefSection(sections, (t) => t === 'approach');
825
+ if (approach)
826
+ rendered = renderChosenDirection(rendered, approach);
827
+ return rendered;
828
+ }
829
+ /**
830
+ * Carry the brief's `## Approach` into discovery's `## Solution Space` as the
831
+ * `### Chosen Direction` body. Headings inside the approach (for example
832
+ * `### Design`) are demoted one level so they stay under Chosen Direction;
833
+ * lines inside fenced or indented code are left byte-for-byte.
834
+ */
835
+ function renderChosenDirection(content, approach) {
836
+ const approachLines = approach.split('\n');
837
+ const mask = recordLineMask(approachLines);
838
+ const body = approachLines
839
+ .map((line, index) => mask[index] ? line.replace(/^(#{3,5})(?=[ \t])/, '#$1') : line)
840
+ .join('\n')
841
+ .trim();
842
+ const chosen = /^### Chosen Direction[ \t]*$/m.exec(content);
843
+ if (chosen) {
844
+ const bodyStart = chosen.index + chosen[0].length;
845
+ const next = /^#{1,3}[ \t]/m.exec(content.slice(bodyStart));
846
+ const bodyEnd = next ? bodyStart + next.index : content.length;
847
+ return `${content.slice(0, bodyStart)}\n\n${body}\n\n${content.slice(bodyEnd).replace(/^\s*/, '')}`;
848
+ }
849
+ const solutionSpace = /^## Solution Space[ \t]*$/m.exec(content);
850
+ if (solutionSpace) {
851
+ const bodyStart = solutionSpace.index + solutionSpace[0].length;
852
+ const next = /^##[ \t]/m.exec(content.slice(bodyStart));
853
+ const at = next ? bodyStart + next.index : content.length;
854
+ return `${content.slice(0, at).trimEnd()}\n\n### Chosen Direction\n\n${body}\n\n${content.slice(at)}`;
855
+ }
856
+ return upsertDiscoverySection(content, 'Solution Space', `### Chosen Direction\n\n${body}`);
857
+ }
858
+ function renderPromotedBriefState(stateContent, statePath, nowUtc) {
859
+ const frontmatter = readObjectFrontmatter(stateContent, statePath);
860
+ frontmatter['oat_workflow_mode'] = 'quick';
861
+ frontmatter['oat_phase'] = 'plan';
862
+ frontmatter['oat_phase_status'] = 'in_progress';
863
+ frontmatter['oat_ready_for'] = 'oat-project-quick-start';
864
+ frontmatter['oat_project_state_updated'] = nowUtc;
865
+ return replaceFrontmatter(stateContent, YAML.stringify(frontmatter).trimEnd());
866
+ }
867
+ async function promoteBriefProject(input, context, dependencies) {
868
+ const { repoRoot, projectRoot, projectName, statePath, planPath } = input;
869
+ const archivePath = join(projectRoot, BRIEF_ARCHIVE_FILE);
870
+ const implementationPath = join(projectRoot, 'implementation.md');
871
+ if (await dependencies.fileExists(archivePath)) {
872
+ return { status: 'refused', reason: 'brief-archive-exists', files: [] };
873
+ }
874
+ let implementationContent = '';
875
+ if (await dependencies.fileExists(implementationPath)) {
876
+ try {
877
+ implementationContent = await dependencies.readFile(implementationPath, 'utf8');
878
+ }
879
+ catch {
880
+ return { status: 'refused', reason: 'project-unreadable', files: [] };
881
+ }
882
+ }
883
+ const now = dependencies.now();
884
+ const nowUtc = now.toISOString();
885
+ const today = nowUtc.slice(0, 10);
886
+ let migrated;
887
+ try {
888
+ migrated = migrateBriefToQuick({
889
+ planContent: input.planContent,
890
+ implementationContent,
891
+ projectName,
892
+ today,
893
+ sourceMode: input.sourceMode,
894
+ });
895
+ }
896
+ catch {
897
+ return { status: 'refused', reason: 'invalid-brief-plan', files: [] };
898
+ }
899
+ if (!migrated.ok) {
900
+ return {
901
+ status: 'refused',
902
+ reason: migrated.reason,
903
+ files: [],
904
+ ...(migrated.reason === 'missing-phase-head'
905
+ ? { phases: migrated.phases }
906
+ : {}),
907
+ };
908
+ }
909
+ const { migration } = migrated;
910
+ const projectsRoot = await dependencies.resolveProjectsRoot(repoRoot, dependencies.processEnv);
911
+ const scope = resolveProjectScope(projectRoot, projectsRoot, repoRoot);
912
+ if (!scope) {
913
+ return { status: 'refused', reason: 'scope-unresolved', files: [] };
914
+ }
915
+ let discoveryContent;
916
+ let quickStateContent;
917
+ try {
918
+ const discoveryTemplate = (await dependencies.resolveTemplate({
919
+ name: 'discovery.md',
920
+ templatesRoot: join(repoRoot, '.oat', 'templates'),
921
+ home: context.home,
922
+ assetsRoot: () => resolveAssetsRoot(),
923
+ })).content;
924
+ discoveryContent = renderBriefDiscovery(discoveryTemplate, projectName, today, nowUtc, input.planContent);
925
+ quickStateContent = renderPromotedBriefState(input.stateContent, statePath, nowUtc);
926
+ }
927
+ catch {
928
+ return { status: 'refused', reason: 'template-unreadable', files: [] };
929
+ }
930
+ const files = [
931
+ 'discovery.md',
932
+ BRIEF_ARCHIVE_FILE,
933
+ ...(migration.implementationChanged ? ['implementation.md'] : []),
934
+ 'plan.md',
935
+ 'state.md',
936
+ ];
937
+ const identity = `promote:${projectRoot}:${input.sourceMode}-to-quick:${randomUUID()}`;
938
+ const writtenFiles = [];
939
+ try {
940
+ await dependencies.mkdir(join(projectRoot, 'references'), {
941
+ recursive: true,
942
+ });
943
+ await dependencies.writeFile(join(projectRoot, 'discovery.md'), discoveryContent, 'utf8');
944
+ writtenFiles.push('discovery.md');
945
+ await dependencies.writeFile(archivePath, input.sourceMode === 'goal'
946
+ ? renderGoalArchive(input.planContent, input.stateFrontmatter)
947
+ : input.planContent, 'utf8');
948
+ writtenFiles.push(BRIEF_ARCHIVE_FILE);
949
+ if (migration.implementationChanged) {
950
+ await dependencies.writeFile(implementationPath, migration.implementation, 'utf8');
951
+ writtenFiles.push('implementation.md');
952
+ }
953
+ await dependencies.writeFile(planPath, migration.plan, 'utf8');
954
+ writtenFiles.push('plan.md');
955
+ await dependencies.writeFile(statePath, quickStateContent, 'utf8');
956
+ writtenFiles.push('state.md');
957
+ }
958
+ catch {
959
+ return { status: 'refused', reason: 'write-failed', files: writtenFiles };
960
+ }
961
+ try {
962
+ await persistPromotion(repoRoot, projectsRoot, projectRoot, projectName, scope, identity, dependencies, files);
963
+ }
964
+ catch (error) {
965
+ return {
966
+ status: 'refused',
967
+ reason: 'persistence-failed',
968
+ files: writtenFiles,
969
+ ...(error instanceof PromotionPersistenceError
970
+ ? { recovery: error.recovery }
971
+ : {}),
972
+ };
973
+ }
974
+ return { status: 'promoted', reason: 'promoted', files };
975
+ }
335
976
  export function createProjectPromoteCommand(overrides = {}) {
336
977
  const dependencies = { ...DEFAULT_DEPENDENCIES, ...overrides };
337
978
  return new Command('promote')
338
- .description('Promote an authored lite project to the quick workflow')
339
- .argument('<project-path>', 'Lite project path to promote')
979
+ .description('Promote an authored lite, brief or settled goal project to the quick workflow')
980
+ .argument('<project-path>', 'Lite, brief or goal project path to promote')
340
981
  .requiredOption('--to <mode>', 'Target workflow mode')
341
982
  .action(async (projectPath, options, command) => {
342
983
  const context = dependencies.buildCommandContext(readGlobalOptions(command));
@@ -348,7 +989,7 @@ export function createProjectPromoteCommand(overrides = {}) {
348
989
  context.logger.info(`Promoted ${projectPath} to quick.`);
349
990
  }
350
991
  else {
351
- context.logger.error(`Promotion refused: ${result.reason}.${result.recovery ? ` Exact-path persistence ${result.recovery.outcome}; attempts ${result.recovery.attempts}; lock ${result.recovery.lockClass ?? 'none'}; identity ${result.recovery.identity}; receipt ${result.recovery.receipt ?? 'unavailable'}. ${result.recovery.error ?? ''} Recover existing Quick artifacts with: ${result.recovery.command}` : ''}`);
992
+ context.logger.error(`Promotion refused: ${result.reason}${result.phases ? ` (${result.phases.join(', ')})` : ''}${result.deliveries ? ` (${result.deliveries.join(', ')})` : ''}.${result.recovery ? ` Exact-path persistence ${result.recovery.outcome}; attempts ${result.recovery.attempts}; lock ${result.recovery.lockClass ?? 'none'}; identity ${result.recovery.identity}; receipt ${result.recovery.receipt ?? 'unavailable'}. ${result.recovery.error ?? ''} Recover existing Quick artifacts with: ${result.recovery.command}` : ''}`);
352
993
  }
353
994
  process.exitCode = result.status === 'promoted' ? 0 : 1;
354
995
  });