create-quiver 0.7.0 → 0.9.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 (89) hide show
  1. package/.claude/settings.local.json +52 -0
  2. package/.github/workflows/ci.yml +2 -2
  3. package/BACKLOG.md +139 -0
  4. package/CHANGELOG.md +20 -1
  5. package/README.md +31 -1
  6. package/README_FOR_AI.md +25 -13
  7. package/ROADMAP.md +28 -6
  8. package/docs/AI_ONBOARDING_PROMPT.md.template +4 -0
  9. package/docs/COMMANDS.md.template +25 -0
  10. package/docs/INDEX.md.template +2 -0
  11. package/docs/SUPPORT_MATRIX.md.template +9 -0
  12. package/docs/WORKFLOW.md.template +4 -0
  13. package/docs/examples/graph.md.template +62 -0
  14. package/docs/examples/next.md.template +27 -0
  15. package/docs/examples/plan.md.template +28 -0
  16. package/package.json +5 -2
  17. package/package.template.json +8 -3
  18. package/scripts/check-slice-readiness.sh +6 -4
  19. package/scripts/init-docs.sh +57 -9
  20. package/specs/[project-name]/HANDOFF.md.template +37 -0
  21. package/specs/[project-name]/slices/slice-template/slice.json +5 -0
  22. package/specs/quiver-v08-agent-onboarding-analysis/slices/slice-01-project-scan-command/slice.json +1 -1
  23. package/specs/quiver-v08-agent-onboarding-analysis/slices/slice-02-ai-onboarding-prompt/slice.json +1 -1
  24. package/specs/quiver-v08-agent-onboarding-analysis/slices/slice-03-doctor-readme-adoption-flow/slice.json +1 -1
  25. package/specs/quiver-v12-cross-platform-native-runtime/slices/slice-01-cross-platform-support-contract/slice.json +1 -1
  26. package/specs/quiver-v12-cross-platform-native-runtime/slices/slice-02-node-init-docs-runtime/slice.json +1 -1
  27. package/specs/quiver-v12-cross-platform-native-runtime/slices/slice-03-node-migrate-analyze-doctor-flow/slice.json +1 -1
  28. package/specs/quiver-v12-cross-platform-native-runtime/slices/slice-04-node-slice-lifecycle-commands/slice.json +1 -1
  29. package/specs/quiver-v12-cross-platform-native-runtime/slices/slice-05-generated-project-scripts-and-migration/slice.json +1 -1
  30. package/specs/quiver-v13-token-efficient-ai-context/EVIDENCE_REPORT.md +1 -1
  31. package/specs/quiver-v13-token-efficient-ai-context/SPEC.md +1 -1
  32. package/specs/quiver-v15-init-required-before-migrate/EVIDENCE_REPORT.md +26 -0
  33. package/specs/quiver-v15-init-required-before-migrate/SPEC.md +66 -0
  34. package/specs/quiver-v15-init-required-before-migrate/STATUS.md +26 -0
  35. package/specs/quiver-v15-init-required-before-migrate/slices/slice-01-migrate-initialization-precondition/slice.json +65 -0
  36. package/specs/quiver-v15-init-required-before-migrate/slices/slice-02-doctor-not-initialized-guidance/slice.json +61 -0
  37. package/specs/quiver-v15-init-required-before-migrate/slices/slice-03-docs-smokes-init-before-migrate/slice.json +64 -0
  38. package/specs/quiver-v16-handoff-contract/EVIDENCE_REPORT.md +26 -0
  39. package/specs/quiver-v16-handoff-contract/SPEC.md +68 -0
  40. package/specs/quiver-v16-handoff-contract/STATUS.md +26 -0
  41. package/specs/quiver-v16-handoff-contract/slices/slice-01-handoff-template-and-contract/slice.json +66 -0
  42. package/specs/quiver-v16-handoff-contract/slices/slice-02-check-handoff-command/slice.json +70 -0
  43. package/specs/quiver-v16-handoff-contract/slices/slice-03-handoff-scaffold-optional/slice.json +67 -0
  44. package/specs/quiver-v17-orchestration-foundation/EVIDENCE_REPORT.md +32 -0
  45. package/specs/quiver-v17-orchestration-foundation/SPEC.md +79 -0
  46. package/specs/quiver-v17-orchestration-foundation/STATUS.md +31 -0
  47. package/specs/quiver-v17-orchestration-foundation/slices/slice-01-ci-matrix-verified/slice.json +68 -0
  48. package/specs/quiver-v17-orchestration-foundation/slices/slice-02-slice-graph-library/slice.json +65 -0
  49. package/specs/quiver-v17-orchestration-foundation/slices/slice-03-depends-on-validation/slice.json +72 -0
  50. package/specs/quiver-v18-slice-orchestration/EVIDENCE_REPORT.md +38 -0
  51. package/specs/quiver-v18-slice-orchestration/SPEC.md +91 -0
  52. package/specs/quiver-v18-slice-orchestration/STATUS.md +33 -0
  53. package/specs/quiver-v18-slice-orchestration/slices/slice-01-plan-command/slice.json +79 -0
  54. package/specs/quiver-v18-slice-orchestration/slices/slice-02-graph-mvp-tree/slice.json +75 -0
  55. package/specs/quiver-v18-slice-orchestration/slices/slice-03-graph-extended-formats/slice.json +70 -0
  56. package/specs/quiver-v18-slice-orchestration/slices/slice-04-next-command/slice.json +73 -0
  57. package/specs/quiver-v18-stabilization/EVIDENCE_REPORT.md +26 -0
  58. package/specs/quiver-v18-stabilization/SPEC.md +62 -0
  59. package/specs/quiver-v18-stabilization/STATUS.md +30 -0
  60. package/specs/quiver-v18-stabilization/slices/slice-01-fix-legacy-dependency-resolution/CLOSURE_BRIEF.md +29 -0
  61. package/specs/quiver-v18-stabilization/slices/slice-01-fix-legacy-dependency-resolution/EXECUTION_BRIEF.md +134 -0
  62. package/specs/quiver-v18-stabilization/slices/slice-01-fix-legacy-dependency-resolution/slice.json +56 -0
  63. package/specs/quiver-v18-stabilization/slices/slice-02-roadmap-and-branch-cleanup/CLOSURE_BRIEF.md +29 -0
  64. package/specs/quiver-v18-stabilization/slices/slice-02-roadmap-and-branch-cleanup/EXECUTION_BRIEF.md +118 -0
  65. package/specs/quiver-v18-stabilization/slices/slice-02-roadmap-and-branch-cleanup/slice.json +57 -0
  66. package/specs/quiver-v18-stabilization/slices/slice-03-publish-drafts-branch/CLOSURE_BRIEF.md +23 -0
  67. package/specs/quiver-v18-stabilization/slices/slice-03-publish-drafts-branch/EXECUTION_BRIEF.md +73 -0
  68. package/specs/quiver-v18-stabilization/slices/slice-03-publish-drafts-branch/slice.json +49 -0
  69. package/specs/quiver-v19-self-install-dev-dep/EVIDENCE_REPORT.md +19 -0
  70. package/specs/quiver-v19-self-install-dev-dep/SPEC.md +51 -0
  71. package/specs/quiver-v19-self-install-dev-dep/STATUS.md +20 -0
  72. package/specs/quiver-v19-self-install-dev-dep/slices/slice-01-auto-install-dev-dep/CLOSURE_BRIEF.md +29 -0
  73. package/specs/quiver-v19-self-install-dev-dep/slices/slice-01-auto-install-dev-dep/EXECUTION_BRIEF.md +287 -0
  74. package/specs/quiver-v19-self-install-dev-dep/slices/slice-01-auto-install-dev-dep/slice.json +56 -0
  75. package/src/create-quiver/commands/graph.js +97 -0
  76. package/src/create-quiver/commands/next.js +134 -0
  77. package/src/create-quiver/commands/plan.js +205 -0
  78. package/src/create-quiver/index.js +203 -3
  79. package/src/create-quiver/lib/handoff.js +104 -0
  80. package/src/create-quiver/lib/init-docs.js +108 -13
  81. package/src/create-quiver/lib/json.js +14 -0
  82. package/src/create-quiver/lib/lifecycle.js +3 -2
  83. package/src/create-quiver/lib/readiness.js +55 -1
  84. package/src/create-quiver/lib/renderers/dot.js +129 -0
  85. package/src/create-quiver/lib/renderers/mermaid.js +119 -0
  86. package/src/create-quiver/lib/renderers/tree.js +116 -0
  87. package/src/create-quiver/lib/slice-graph.js +453 -0
  88. package/src/create-quiver/lib/slice.js +2 -1
  89. package/src/create-quiver/lib/state.js +50 -0
@@ -0,0 +1,97 @@
1
+ const { buildGraph, computeLevels, detectFileConflicts, readAllSlices } = require('../lib/slice-graph');
2
+ const { renderDotGraph } = require('../lib/renderers/dot');
3
+ const { renderMermaidGraph } = require('../lib/renderers/mermaid');
4
+ const { renderTreeGraph, isUnicodeEnabled } = require('../lib/renderers/tree');
5
+
6
+ const EXCLUDED_STATUSES = new Set(['completed', 'skipped', 'cancelled']);
7
+
8
+ function toGraphNode(node) {
9
+ return {
10
+ ref: node.ref,
11
+ spec_slug: node.specSlug,
12
+ slice_id: node.sliceId,
13
+ title: node.title || node.sliceId,
14
+ hours: Number.isFinite(Number(node.json?.estimated_hours)) ? Number(node.json.estimated_hours) : 0,
15
+ status: node.status || 'draft',
16
+ files: Array.isArray(node.files) ? node.files : [],
17
+ depends_on: Array.isArray(node.depends_on) ? node.depends_on : [],
18
+ };
19
+ }
20
+
21
+ function buildConflictPayload(levelIndex, groups) {
22
+ return groups.map((group) => ({
23
+ level: levelIndex,
24
+ files: group.files,
25
+ slices: group.slices,
26
+ }));
27
+ }
28
+
29
+ function collectGraph(repoRoot, options = {}) {
30
+ const graph = buildGraph(readAllSlices(repoRoot));
31
+ computeLevels(graph);
32
+ const pendingNodes = graph.nodes.filter((node) => !EXCLUDED_STATUSES.has(String(node.status || '').toLowerCase()));
33
+ const pendingNodeRefs = new Set(pendingNodes.map((node) => node.ref));
34
+ const pendingEdges = graph.edges.filter((edge) => pendingNodeRefs.has(edge.from) && pendingNodeRefs.has(edge.to));
35
+ const levels = pendingNodes.length > 0 ? computeLevels({ nodes: pendingNodes, edges: pendingEdges, cycles: [] }) : [];
36
+ const levelEntries = levels.map((level, levelIndex) => {
37
+ const conflictGroups = detectFileConflicts(level);
38
+
39
+ return {
40
+ index: levelIndex,
41
+ slices: level.map(toGraphNode),
42
+ conflicts: buildConflictPayload(levelIndex, conflictGroups),
43
+ };
44
+ });
45
+
46
+ const filteredLevels = typeof options.level === 'number'
47
+ ? levelEntries.filter((entry) => entry.index === options.level)
48
+ : levelEntries;
49
+
50
+ return {
51
+ levels: filteredLevels.map((entry) => entry.slices),
52
+ conflicts: filteredLevels.flatMap((entry) => entry.conflicts),
53
+ };
54
+ }
55
+
56
+ function formatHumanGraph(report, options = {}) {
57
+ const format = options.format || 'tree';
58
+ const sharedOptions = {
59
+ showConflicts: options.showConflicts === true,
60
+ unicode: options.unicode === true || isUnicodeEnabled(options),
61
+ };
62
+
63
+ if (format === 'tree') {
64
+ return renderTreeGraph(report, sharedOptions);
65
+ }
66
+
67
+ if (format === 'mermaid') {
68
+ return renderMermaidGraph(report, sharedOptions);
69
+ }
70
+
71
+ if (format === 'dot') {
72
+ return renderDotGraph(report, sharedOptions);
73
+ }
74
+
75
+ throw new Error(`create-quiver: unsupported graph format: ${format}`);
76
+ }
77
+
78
+ function runGraph(repoRoot, options = {}) {
79
+ if (options.format && !['tree', 'mermaid', 'dot'].includes(options.format)) {
80
+ throw new Error(`create-quiver: unsupported graph format: ${options.format}`);
81
+ }
82
+
83
+ const report = collectGraph(repoRoot, options);
84
+ if (options.json) {
85
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
86
+ return report;
87
+ }
88
+
89
+ process.stdout.write(formatHumanGraph(report, options));
90
+ return report;
91
+ }
92
+
93
+ module.exports = {
94
+ collectGraph,
95
+ formatHumanGraph,
96
+ runGraph,
97
+ };
@@ -0,0 +1,134 @@
1
+ const path = require('path');
2
+ const readline = require('readline');
3
+ const { collectPlan } = require('./plan');
4
+ const { startSlice } = require('../lib/lifecycle');
5
+
6
+ function toStartSliceCommand(slicePath) {
7
+ return `npx create-quiver start-slice "${slicePath}"`;
8
+ }
9
+
10
+ function collectNext(repoRoot, options = {}) {
11
+ const report = collectPlan(repoRoot, {
12
+ onlyReady: true,
13
+ specSlug: options.specSlug,
14
+ });
15
+
16
+ const allReady = report.plan.filter((item) => String(item.status || '').toLowerCase() !== 'blocked');
17
+ const next = allReady.length > 0 ? allReady[0] : null;
18
+
19
+ return {
20
+ all_ready: allReady,
21
+ next,
22
+ };
23
+ }
24
+
25
+ function formatHumanNext(report, options = {}) {
26
+ const lines = [options.allReady ? 'Ready slices' : 'Next ready slice'];
27
+
28
+ if (!report.next) {
29
+ lines.push('No ready slices found.');
30
+ return `${lines.join('\n')}\n`;
31
+ }
32
+
33
+ const items = options.allReady ? report.all_ready : [report.next];
34
+
35
+ for (const [index, item] of items.entries()) {
36
+ const command = toStartSliceCommand(item.slice_path);
37
+ if (options.allReady) {
38
+ lines.push('');
39
+ lines.push(`[${index + 1}] ${item.ref}`);
40
+ lines.push(`Title: ${item.title}`);
41
+ lines.push(`Status: ${item.status}`);
42
+ lines.push(`Start: ${command}`);
43
+ } else {
44
+ lines.push(`Slice: ${item.ref}`);
45
+ lines.push(`Title: ${item.title}`);
46
+ lines.push(`Status: ${item.status}`);
47
+ lines.push(`Start: ${command}`);
48
+ if (report.all_ready.length > 1) {
49
+ lines.push(`Also ready: ${report.all_ready.slice(1).map((candidate) => candidate.ref).join(', ')}`);
50
+ }
51
+ }
52
+ }
53
+
54
+ return `${lines.join('\n')}\n`;
55
+ }
56
+
57
+ function promptConfirm(message, io = {}) {
58
+ const input = io.input || process.stdin;
59
+ const output = io.output || process.stdout;
60
+
61
+ return new Promise((resolve, reject) => {
62
+ const rl = readline.createInterface({
63
+ input,
64
+ output,
65
+ });
66
+
67
+ rl.question(`${message} [y/N] `, (answer) => {
68
+ rl.close();
69
+ resolve(/^y(es)?$/i.test(String(answer || '').trim()));
70
+ });
71
+
72
+ rl.on('SIGINT', () => {
73
+ rl.close();
74
+ reject(new Error('create-quiver: confirmation aborted.'));
75
+ });
76
+ });
77
+ }
78
+
79
+ async function runNext(repoRoot, options = {}) {
80
+ const report = collectNext(repoRoot, options);
81
+ const isTTY = options.isTTY || {
82
+ stdin: Boolean(process.stdin.isTTY),
83
+ stdout: Boolean(process.stdout.isTTY),
84
+ };
85
+
86
+ if (options.autoStart) {
87
+ if (!isTTY.stdin || !isTTY.stdout) {
88
+ throw new Error('create-quiver: --auto-start requires an interactive TTY.');
89
+ }
90
+
91
+ if (!report.next) {
92
+ process.stdout.write('No ready slices found.\n');
93
+ return report;
94
+ }
95
+
96
+ const confirmed = await (options.promptConfirm || promptConfirm)(`Start slice ${report.next.ref}?`);
97
+ if (confirmed) {
98
+ const slicePath = path.resolve(repoRoot, report.next.slice_path);
99
+ await Promise.resolve((options.startSliceFn || startSlice)(slicePath, { allowDraft: true }));
100
+ process.stdout.write(`Started: ${report.next.ref}\n`);
101
+ } else {
102
+ process.stdout.write('Aborted.\n');
103
+ }
104
+ return report;
105
+ }
106
+
107
+ if (options.json) {
108
+ const payload = {
109
+ all_ready: report.all_ready.map((item) => ({
110
+ ...item,
111
+ start_slice_command: toStartSliceCommand(item.slice_path),
112
+ })),
113
+ next: report.next
114
+ ? {
115
+ ...report.next,
116
+ start_slice_command: toStartSliceCommand(report.next.slice_path),
117
+ }
118
+ : null,
119
+ };
120
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
121
+ return payload;
122
+ }
123
+
124
+ process.stdout.write(formatHumanNext(report, options));
125
+ return report;
126
+ }
127
+
128
+ module.exports = {
129
+ collectNext,
130
+ formatHumanNext,
131
+ promptConfirm,
132
+ runNext,
133
+ toStartSliceCommand,
134
+ };
@@ -0,0 +1,205 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { relativePosixPath } = require('../lib/paths');
4
+ const { buildGraph, readAllSlices, topoSort } = require('../lib/slice-graph');
5
+
6
+ const EXCLUDED_STATUSES = new Set(['completed', 'skipped', 'cancelled']);
7
+
8
+ function toHourCount(value) {
9
+ const parsed = Number(value);
10
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
11
+ }
12
+
13
+ function compareRefs(left, right) {
14
+ return String(left || '').localeCompare(String(right || ''));
15
+ }
16
+
17
+ function sliceToPlanItem(repoRoot, slice, index, readySet) {
18
+ return {
19
+ index,
20
+ ref: slice.ref,
21
+ spec_family: slice.specFamily || '',
22
+ spec_slug: slice.specSlug,
23
+ slice_id: slice.sliceId,
24
+ slice_path: relativePosixPath(repoRoot, slice.slicePath),
25
+ ticket: slice.ticket || '',
26
+ title: slice.title || slice.sliceId,
27
+ status: slice.status || 'draft',
28
+ hours: toHourCount(slice.json?.estimated_hours),
29
+ files: Array.isArray(slice.files) ? slice.files : [],
30
+ depends_on: Array.isArray(slice.depends_on) ? slice.depends_on : [],
31
+ ready: readySet.has(slice.ref),
32
+ };
33
+ }
34
+
35
+ function buildSubgraph(graph, refs) {
36
+ const refSet = new Set(refs);
37
+ return {
38
+ nodes: graph.nodes.filter((node) => refSet.has(node.ref)),
39
+ edges: graph.edges.filter((edge) => refSet.has(edge.from) && refSet.has(edge.to)),
40
+ cycles: graph.cycles.filter((cycle) => cycle.every((ref) => refSet.has(ref))),
41
+ };
42
+ }
43
+
44
+ function buildReadySet(graph, pendingRefs) {
45
+ const pendingSet = new Set(pendingRefs);
46
+ const incomingCounts = new Map(pendingRefs.map((ref) => [ref, 0]));
47
+
48
+ for (const edge of graph.edges) {
49
+ if (!pendingSet.has(edge.from) || !pendingSet.has(edge.to)) {
50
+ continue;
51
+ }
52
+ incomingCounts.set(edge.to, (incomingCounts.get(edge.to) || 0) + 1);
53
+ }
54
+
55
+ return new Set(Array.from(incomingCounts.entries()).filter(([, count]) => count === 0).map(([ref]) => ref));
56
+ }
57
+
58
+ function buildCriticalPath(graph, refs) {
59
+ if (refs.length === 0) {
60
+ return [];
61
+ }
62
+
63
+ const subgraph = buildSubgraph(graph, refs);
64
+ const ordered = topoSort(subgraph);
65
+ const incoming = new Map(ordered.map((node) => [node.ref, []]));
66
+
67
+ for (const edge of subgraph.edges) {
68
+ if (!incoming.has(edge.to)) {
69
+ continue;
70
+ }
71
+ incoming.get(edge.to).push(edge.from);
72
+ }
73
+
74
+ const best = new Map();
75
+
76
+ for (const node of ordered) {
77
+ const hours = toHourCount(node.json?.estimated_hours);
78
+ const predecessors = incoming.get(node.ref) || [];
79
+ let candidate = {
80
+ hours,
81
+ path: [node.ref],
82
+ };
83
+
84
+ for (const pred of predecessors) {
85
+ const prev = best.get(pred);
86
+ if (!prev) {
87
+ continue;
88
+ }
89
+ const nextCandidate = {
90
+ hours: prev.hours + hours,
91
+ path: [...prev.path, node.ref],
92
+ };
93
+ const pathLabel = nextCandidate.path.join('>');
94
+ const bestLabel = candidate.path.join('>');
95
+ if (nextCandidate.hours > candidate.hours || (nextCandidate.hours === candidate.hours && compareRefs(pathLabel, bestLabel) < 0)) {
96
+ candidate = nextCandidate;
97
+ }
98
+ }
99
+
100
+ best.set(node.ref, candidate);
101
+ }
102
+
103
+ let winner = null;
104
+ for (const node of ordered) {
105
+ const candidate = best.get(node.ref);
106
+ if (!candidate) {
107
+ continue;
108
+ }
109
+ if (!winner || candidate.hours > winner.hours || (candidate.hours === winner.hours && compareRefs(candidate.path.join('>'), winner.path.join('>')) < 0)) {
110
+ winner = candidate;
111
+ }
112
+ }
113
+
114
+ return winner ? winner.path : [];
115
+ }
116
+
117
+ function collectPlan(repoRoot, options = {}) {
118
+ const allSlices = readAllSlices(repoRoot);
119
+ const graph = buildGraph(allSlices);
120
+ const topo = topoSort(graph);
121
+ const excluded = EXCLUDED_STATUSES;
122
+ const specSlug = options.specSlug ? String(options.specSlug).trim() : '';
123
+
124
+ const pendingRefs = new Set(
125
+ graph.nodes
126
+ .filter((node) => !excluded.has(String(node.status || '').toLowerCase()))
127
+ .map((node) => node.ref),
128
+ );
129
+
130
+ const readyRefs = buildReadySet(graph, Array.from(pendingRefs));
131
+
132
+ const selectedNodes = topo.filter((node) => {
133
+ if (!pendingRefs.has(node.ref)) {
134
+ return false;
135
+ }
136
+ if (specSlug && node.specSlug !== specSlug) {
137
+ return false;
138
+ }
139
+ return true;
140
+ });
141
+
142
+ const readyNodes = selectedNodes.filter((node) => readyRefs.has(node.ref));
143
+ const targetNodes = options.onlyReady ? readyNodes : selectedNodes;
144
+ const criticalPath = buildCriticalPath(graph, targetNodes.map((node) => node.ref));
145
+ const totalHours = targetNodes.reduce((sum, node) => sum + toHourCount(node.json?.estimated_hours), 0);
146
+ const plan = targetNodes.map((node, index) => sliceToPlanItem(repoRoot, node, index + 1, readyRefs));
147
+
148
+ return {
149
+ critical_path: criticalPath,
150
+ plan,
151
+ total_hours: totalHours,
152
+ };
153
+ }
154
+
155
+ function formatHumanPlan(report, options = {}) {
156
+ const unicode = Boolean(options.unicode) || /UTF-8/i.test(process.env.LANG || '');
157
+ const title = options.onlyReady ? 'Ready slices' : 'Quiver plan';
158
+ const pathSeparator = unicode ? ' → ' : ' -> ';
159
+ const lines = [title, `Total hours: ${report.total_hours}`, `Critical path: ${report.critical_path.length > 0 ? report.critical_path.join(pathSeparator) : '-'}`, ''];
160
+
161
+ if (report.plan.length === 0) {
162
+ lines.push('No pending slices found.');
163
+ return `${lines.join('\n')}\n`;
164
+ }
165
+
166
+ const rows = report.plan.map((item) => [
167
+ `[${item.index}]`,
168
+ item.ticket || '-',
169
+ item.ref,
170
+ item.title,
171
+ `${item.hours}`,
172
+ item.status,
173
+ ]);
174
+
175
+ const widths = rows[0].map((_, colIndex) => Math.max(
176
+ rows.reduce((max, row) => Math.max(max, row[colIndex].length), 0),
177
+ ['[N]', 'TICKET', 'SPEC/SLICE', 'TITLE', 'HOURS', 'STATUS'][colIndex].length,
178
+ ));
179
+
180
+ lines.push(['[N]', 'TICKET', 'SPEC/SLICE', 'TITLE', 'HOURS', 'STATUS'].map((label, index) => label.padEnd(widths[index])).join(' '));
181
+ lines.push(widths.map((width) => '-'.repeat(width)).join(' '));
182
+
183
+ for (const row of rows) {
184
+ lines.push(row.map((value, index) => value.padEnd(widths[index])).join(' '));
185
+ }
186
+
187
+ return `${lines.join('\n')}\n`;
188
+ }
189
+
190
+ function runPlan(repoRoot, options = {}) {
191
+ const report = collectPlan(repoRoot, options);
192
+ if (options.json) {
193
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
194
+ return report;
195
+ }
196
+
197
+ process.stdout.write(formatHumanPlan(report, options));
198
+ return report;
199
+ }
200
+
201
+ module.exports = {
202
+ collectPlan,
203
+ formatHumanPlan,
204
+ runPlan,
205
+ };