dotmd-cli 0.83.0 → 0.84.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- package/bin/dotmd.mjs +15 -0
- package/package.json +1 -1
- package/src/config.mjs +10 -0
- package/src/decision.mjs +220 -0
- package/src/new.mjs +49 -0
package/README.md
CHANGED
|
@@ -213,6 +213,9 @@ runlist new plan platform-work --coordination
|
|
|
213
213
|
runlist runlists
|
|
214
214
|
```
|
|
215
215
|
|
|
216
|
+
`runlist new hub platform-work` makes the same coordination hub; add
|
|
217
|
+
`--runlist a,b,c` or `--roadmap` for the other two shapes.
|
|
218
|
+
|
|
216
219
|
For progress across several runlists, create a roadmap:
|
|
217
220
|
|
|
218
221
|
```bash
|
|
@@ -225,6 +228,37 @@ Roadmaps roll up progress recursively and choose the first startable plan across
|
|
|
225
228
|
their child runlists. Runlists and roadmaps are held out of actionable plan
|
|
226
229
|
counts so dashboards do not double-count their children.
|
|
227
230
|
|
|
231
|
+
## Decisions
|
|
232
|
+
|
|
233
|
+
A decision is an entry in its plan, not a document of its own. Write the record
|
|
234
|
+
(the situation, what exists today, what each answer leaves in place) to a file
|
|
235
|
+
and add it:
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
runlist new decision auth-revamp --question "Which token store?" @record.md
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
It takes the next id (`D1`, `D2`, …) that no decision item in that plan uses,
|
|
242
|
+
lands at the end of the plan's top-level decisions section (created before
|
|
243
|
+
`## Version History` when there is none) with a `Disposition: OPEN.` line, and
|
|
244
|
+
refuses an empty record. `--disposition held` parks it instead.
|
|
245
|
+
|
|
246
|
+
A corpus that indexes its decisions in one register numbers them in one
|
|
247
|
+
sequence. Name the register, and the id follows the highest the register or the
|
|
248
|
+
plan uses, and the register gets the entry's row in the same locked write:
|
|
249
|
+
|
|
250
|
+
```js
|
|
251
|
+
export const decisions = {
|
|
252
|
+
section: 'Decisions',
|
|
253
|
+
prefix: 'D',
|
|
254
|
+
register: { file: 'docs/plans/register.md', statusLine: 'waiting on you:' },
|
|
255
|
+
};
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
The register block is the fenced block whose first line carries `statusLine`.
|
|
259
|
+
The row is the question plus `--answers` (what each answer leaves in place),
|
|
260
|
+
which is required when a register is configured.
|
|
261
|
+
|
|
228
262
|
## Safety Model
|
|
229
263
|
|
|
230
264
|
- Mutation commands support `--dry-run` / `-n`.
|
package/bin/dotmd.mjs
CHANGED
|
@@ -1001,6 +1001,21 @@ Examples:
|
|
|
1001
1001
|
EOF
|
|
1002
1002
|
runlist new prompt cleanup-tomorrow "look at remaining lint warnings"
|
|
1003
1003
|
|
|
1004
|
+
Hubs and decisions:
|
|
1005
|
+
runlist new hub <slug> # coordination hub (a plan)
|
|
1006
|
+
runlist new hub <slug> --runlist a,b,c # sprint hub plus child plans
|
|
1007
|
+
runlist new hub <slug> --roadmap # roadmap hub
|
|
1008
|
+
runlist new decision <plan> --question "<question>" [--answers "<…>"] @record.md
|
|
1009
|
+
Adds the next numbered entry to the plan's top-level
|
|
1010
|
+
decisions section with a \`Disposition: OPEN.\` line (or
|
|
1011
|
+
\`--disposition held\`) and the record, which is required;
|
|
1012
|
+
a plan with no such section gets \`## Decisions\`. With a
|
|
1013
|
+
register in config, the id is numbered across the corpus
|
|
1014
|
+
and the register gets its row, question plus --answers
|
|
1015
|
+
(required then), in the same locked write. Config:
|
|
1016
|
+
\`export const decisions = { section, prefix,
|
|
1017
|
+
register: { file, statusLine } }\`.
|
|
1018
|
+
|
|
1004
1019
|
Scaffolding runlists (plans only):
|
|
1005
1020
|
--runlist <a,b,c> Create a sprint runlist hub plus one child plan per slug.
|
|
1006
1021
|
The hub carries \`runlist: [<hub>-01-a.md, <hub>-02-b.md, …]\`
|
package/package.json
CHANGED
package/src/config.mjs
CHANGED
|
@@ -137,6 +137,8 @@ const DEFAULTS = {
|
|
|
137
137
|
templates: {},
|
|
138
138
|
|
|
139
139
|
glossary: null,
|
|
140
|
+
// `runlist new decision`: { section, prefix, register: { file, statusLine } }.
|
|
141
|
+
decisions: null,
|
|
140
142
|
|
|
141
143
|
// Opt-in JSONL command journal at .dotmd/journal.jsonl. Default off — agents
|
|
142
144
|
// and users who want usage observability flip this on (or set RUNLIST_JOURNAL=1).
|
|
@@ -417,6 +419,14 @@ function validateConfig(userConfig, config, validStatuses, indexPath) {
|
|
|
417
419
|
}
|
|
418
420
|
}
|
|
419
421
|
|
|
422
|
+
if (userConfig.decisions != null) {
|
|
423
|
+
const d = userConfig.decisions;
|
|
424
|
+
if (typeof d !== 'object' || Array.isArray(d)) warnings.push('Config: decisions must be an object.');
|
|
425
|
+
else if (d.register != null && (typeof d.register?.file !== 'string' || typeof d.register?.statusLine !== 'string')) {
|
|
426
|
+
warnings.push('Config: decisions.register needs a file and a statusLine.');
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
420
430
|
// Unknown top-level user config keys
|
|
421
431
|
for (const key of Object.keys(userConfig)) {
|
|
422
432
|
if (!VALID_CONFIG_KEYS.has(key)) {
|
package/src/decision.mjs
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { mutateFileSet } from './atomic-mutation.mjs';
|
|
4
|
+
import { authorizeManagedSource } from './managed-path.mjs';
|
|
5
|
+
import { walkSections } from './section.mjs';
|
|
6
|
+
import { die, nowIso, resolveDocPath, toRepoPath } from './util.mjs';
|
|
7
|
+
import { green, dim } from './color.mjs';
|
|
8
|
+
|
|
9
|
+
// `runlist new decision <plan> --question "…" @record.md`
|
|
10
|
+
//
|
|
11
|
+
// A decision is not a document of its own: it is an entry in the owning plan's
|
|
12
|
+
// decisions section, carrying an id, a disposition and a written record. This
|
|
13
|
+
// adds one, numbered after the highest id of its prefix the plan (and the
|
|
14
|
+
// register, when there is one) already uses, at the end of the plan's top-level
|
|
15
|
+
// decisions section, creating `## Decisions` when the plan has none. A
|
|
16
|
+
// decisions heading nested inside a workstream is that workstream's record and
|
|
17
|
+
// is left alone.
|
|
18
|
+
//
|
|
19
|
+
// Config (`runlist.config.mjs`), all optional:
|
|
20
|
+
// export const decisions = {
|
|
21
|
+
// section: 'Decisions', // the heading the entry lands under
|
|
22
|
+
// prefix: 'D', // what a new id is written under
|
|
23
|
+
// register: { file: 'docs/plans/register.md', statusLine: 'waiting on you:' },
|
|
24
|
+
// };
|
|
25
|
+
// With a register, ids are one sequence across the corpus: the next id is
|
|
26
|
+
// numbered after the highest the register or the plan uses, and the register
|
|
27
|
+
// gets the entry's index row in the same locked write as the plan. Without
|
|
28
|
+
// one, ids are plan-local.
|
|
29
|
+
|
|
30
|
+
const DISPOSITIONS = new Set(['open', 'held']);
|
|
31
|
+
const DECISION_HEADING = /\bdecisions?\b/i;
|
|
32
|
+
|
|
33
|
+
function splitDoc(raw) {
|
|
34
|
+
if (!raw.startsWith('---\n')) return null;
|
|
35
|
+
const end = raw.indexOf('\n---\n', 4);
|
|
36
|
+
if (end === -1) return null;
|
|
37
|
+
return { frontmatter: raw.slice(4, end), body: raw.slice(end + 5) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function stripFences(text) {
|
|
41
|
+
return text.replace(/^(`{3,}|~{3,})[^\n]*\n[\s\S]*?^\1[^\n]*$/gm, '');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const escapeRe = text => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
45
|
+
|
|
46
|
+
// Ids are read where a decision item starts (a heading, a list or bold lead,
|
|
47
|
+
// a table row or a register row), never from prose, where the same shape is
|
|
48
|
+
// as likely to be a job code or a citation of another plan's decision.
|
|
49
|
+
function highestId(text, prefix) {
|
|
50
|
+
const re = new RegExp(`^\\s*(?:#{1,6}\\s+|[-*]\\s+(?:\\[[ xX]\\]\\s+)?|\\|\\s*)?\\**${escapeRe(prefix)}(\\d{1,4})\\b`);
|
|
51
|
+
let max = 0;
|
|
52
|
+
for (const line of text.split('\n')) {
|
|
53
|
+
const m = line.match(re);
|
|
54
|
+
if (m) max = Math.max(max, Number(m[1]));
|
|
55
|
+
}
|
|
56
|
+
return max;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function nextDecisionId(body, prefix, register = null) {
|
|
60
|
+
let max = highestId(stripFences(body), prefix);
|
|
61
|
+
if (register) {
|
|
62
|
+
const rows = register.rows.map(row => row.match(new RegExp(`^${escapeRe(prefix)}(\\d{1,4})\\b`))?.[1]);
|
|
63
|
+
for (const n of rows) if (n) max = Math.max(max, Number(n));
|
|
64
|
+
}
|
|
65
|
+
return `${prefix}${max + 1}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The register block: a fence whose first line carries the configured status
|
|
69
|
+
// line. Returns its rows and where the closing fence sits, or null.
|
|
70
|
+
export function findRegister(text, statusLine) {
|
|
71
|
+
const lines = text.split('\n');
|
|
72
|
+
const wanted = statusLine.trim().toLowerCase();
|
|
73
|
+
for (let i = 0; i < lines.length; i++) {
|
|
74
|
+
const open = lines[i].match(/^(`{3,}|~{3,})/);
|
|
75
|
+
if (!open) continue;
|
|
76
|
+
let close = i + 1;
|
|
77
|
+
while (close < lines.length && !lines[close].startsWith(open[1])) close++;
|
|
78
|
+
if (close >= lines.length) return null;
|
|
79
|
+
const first = (lines[i + 1] ?? '').trim().toLowerCase();
|
|
80
|
+
if (first.includes(wanted)) {
|
|
81
|
+
return { closeLine: close, rows: lines.slice(i + 2, close).filter(line => line.trim()) };
|
|
82
|
+
}
|
|
83
|
+
i = close;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function insertRegisterRow(text, register, row) {
|
|
89
|
+
const lines = text.split('\n');
|
|
90
|
+
let at = register.closeLine;
|
|
91
|
+
while (at > 0 && lines[at - 1].trim() === '') at--;
|
|
92
|
+
lines.splice(at, 0, row);
|
|
93
|
+
return lines.join('\n');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Returns the new body and where the entry went. Pure, for tests.
|
|
97
|
+
export function insertDecision(body, { id, question, disposition, record, section: heading = 'Decisions' }) {
|
|
98
|
+
const lines = body.split('\n');
|
|
99
|
+
const sections = walkSections(body);
|
|
100
|
+
const named = s => s.heading.replace(/[^\w\s]+$/, '').trim().toLowerCase() === heading.toLowerCase();
|
|
101
|
+
const section = sections.find(s => s.level === 2 && named(s))
|
|
102
|
+
?? sections.find(s => s.level === 2 && DECISION_HEADING.test(s.heading));
|
|
103
|
+
const level = section ? Math.min(section.level + 1, 6) : 3;
|
|
104
|
+
const entry = [
|
|
105
|
+
`${'#'.repeat(level)} ${id} ${question.trim()}`,
|
|
106
|
+
'',
|
|
107
|
+
`Disposition: ${disposition.toUpperCase()}.`,
|
|
108
|
+
'',
|
|
109
|
+
record.trim(),
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
if (section) {
|
|
113
|
+
// After the section's last non-blank line, subsections included.
|
|
114
|
+
let at = section.lineEnd;
|
|
115
|
+
while (at > section.lineStart && lines[at - 1].trim() === '') at--;
|
|
116
|
+
const tail = at < lines.length && lines[at].trim() !== '' ? [''] : [];
|
|
117
|
+
lines.splice(at, 0, '', ...entry, ...tail);
|
|
118
|
+
return { body: lines.join('\n'), placement: `under \`${section.heading}\``, heading: section.heading };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const block = [`## ${heading}`, '', ...entry];
|
|
122
|
+
const before = sections.find(s => s.level === 2 && /^(version history|closeout)\b/i.test(s.heading));
|
|
123
|
+
if (before) {
|
|
124
|
+
lines.splice(before.lineStart - 1, 0, ...block, '');
|
|
125
|
+
return { body: lines.join('\n'), placement: `in a new \`## ${heading}\` before \`${before.heading}\``, heading };
|
|
126
|
+
}
|
|
127
|
+
const trimmed = body.replace(/\n+$/, '');
|
|
128
|
+
return { body: `${trimmed}\n\n${block.join('\n')}\n`, placement: `in a new \`## ${heading}\` at the end`, heading };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function runNewDecision({ planArg, question, disposition, record, answers = null }, config, { dryRun = false } = {}) {
|
|
132
|
+
const usage = 'Usage: runlist new decision <plan> --question "<the question>" @record.md';
|
|
133
|
+
if (!planArg) die(`${usage}\nThe plan is the file whose decisions section gets the entry.`);
|
|
134
|
+
if (!question || !question.trim()) die(`--question is required.\n${usage}`);
|
|
135
|
+
if (/\n/.test(question)) die('--question is one line; the record carries the rest.');
|
|
136
|
+
if (!record || !record.trim()) {
|
|
137
|
+
die('A decision needs its record: the situation, what exists today, and what each answer leaves in place.\n'
|
|
138
|
+
+ `Write it to a file and pass @path, pipe it in, or pass --body "...".\n${usage}`);
|
|
139
|
+
}
|
|
140
|
+
disposition = (disposition ?? 'open').toLowerCase();
|
|
141
|
+
if (!DISPOSITIONS.has(disposition)) {
|
|
142
|
+
die(`--disposition must be open or held; a ruled or closed decision is edited in place, not added.`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const resolved = resolveDocPath(planArg, config)
|
|
146
|
+
?? (planArg.endsWith('.md') ? null : resolveDocPath(`${planArg}.md`, config))
|
|
147
|
+
?? (planArg.includes('/') ? null : resolveDocPath(path.join('plans', `${planArg}.md`), config));
|
|
148
|
+
if (!resolved) die(`No such plan: ${planArg}`);
|
|
149
|
+
const planPath = authorizeManagedSource(resolved, config, { kind: 'Decision target' }).path;
|
|
150
|
+
const planRepoPath = toRepoPath(planPath, config.repoRoot);
|
|
151
|
+
|
|
152
|
+
const settings = config.raw?.decisions ?? {};
|
|
153
|
+
const prefix = settings.prefix ?? 'D';
|
|
154
|
+
const section = settings.section ?? 'Decisions';
|
|
155
|
+
let registerPath = null;
|
|
156
|
+
if (settings.register?.file) {
|
|
157
|
+
if (!settings.register.statusLine) die('decisions.register needs a statusLine: the first line of the register block.');
|
|
158
|
+
const registerResolved = resolveDocPath(settings.register.file, config);
|
|
159
|
+
if (!registerResolved) die(`The decisions register in config does not exist: ${settings.register.file}`);
|
|
160
|
+
registerPath = authorizeManagedSource(registerResolved, config, { kind: 'Decision register' }).path;
|
|
161
|
+
}
|
|
162
|
+
const sameFile = registerPath === planPath;
|
|
163
|
+
if (registerPath && (!answers || !answers.trim())) {
|
|
164
|
+
die('--answers is required with a register: the row says what each answer leaves in place and what it costs.\n'
|
|
165
|
+
+ `${usage} --answers "Yes: … No: …"`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const today = nowIso();
|
|
169
|
+
const planRaw = readFileSync(planPath, 'utf8');
|
|
170
|
+
const registerRaw = registerPath && !sameFile ? readFileSync(registerPath, 'utf8') : null;
|
|
171
|
+
|
|
172
|
+
const planDoc = splitDoc(planRaw.replace(/\r\n/g, '\n'));
|
|
173
|
+
if (!planDoc) die(`${planRepoPath} has no frontmatter block; runlist only adds decisions to managed documents.`);
|
|
174
|
+
const registerText = sameFile ? planDoc.body : registerRaw?.replace(/\r\n/g, '\n');
|
|
175
|
+
const register = registerPath ? findRegister(registerText, settings.register.statusLine) : null;
|
|
176
|
+
if (registerPath && !register) {
|
|
177
|
+
die(`${toRepoPath(registerPath, config.repoRoot)} has no block whose first line carries "${settings.register.statusLine}".`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const id = nextDecisionId(planDoc.body, prefix, register);
|
|
181
|
+
const inserted = insertDecision(planDoc.body, { id, question, disposition, record, section });
|
|
182
|
+
let planBody = inserted.body;
|
|
183
|
+
let row = null;
|
|
184
|
+
if (register && sameFile) {
|
|
185
|
+
row = registerRow({ id, disposition, today, question, answers, heading: inserted.heading, link: path.basename(planPath) });
|
|
186
|
+
planBody = insertRegisterRow(planBody, findRegister(planBody, settings.register.statusLine), row);
|
|
187
|
+
}
|
|
188
|
+
const bump = fm => (/^updated:/m.test(fm) ? fm.replace(/^updated:.*$/m, `updated: ${today}`) : fm);
|
|
189
|
+
const planOut = `---\n${bump(planDoc.frontmatter)}\n---\n${planBody}`;
|
|
190
|
+
|
|
191
|
+
const updates = [{ path: planPath, expectedContent: planRaw, content: planOut }];
|
|
192
|
+
if (register && !sameFile) {
|
|
193
|
+
const link = path.relative(path.dirname(registerPath), planPath).split(path.sep).join('/');
|
|
194
|
+
row = registerRow({ id, disposition, today, question, answers, heading: inserted.heading, link });
|
|
195
|
+
const registerOut = insertRegisterRow(registerText, register, row);
|
|
196
|
+
const registerDoc = splitDoc(registerOut);
|
|
197
|
+
updates.push({
|
|
198
|
+
path: registerPath,
|
|
199
|
+
expectedContent: registerRaw,
|
|
200
|
+
content: registerDoc ? `---\n${bump(registerDoc.frontmatter)}\n---\n${registerDoc.body}` : registerOut,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const registerRepoPath = registerPath ? toRepoPath(registerPath, config.repoRoot) : null;
|
|
205
|
+
if (dryRun) {
|
|
206
|
+
process.stdout.write(`[dry-run] Would add ${id} to ${planRepoPath} ${inserted.placement}\n`);
|
|
207
|
+
if (row) process.stdout.write(`[dry-run] Would add its row to the register in ${registerRepoPath}:\n ${row}\n`);
|
|
208
|
+
return { id, row };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
mutateFileSet({ updates }, { repoRoot: config.repoRoot });
|
|
212
|
+
process.stdout.write(`${green('Added')} ${id} to ${planRepoPath} ${dim(inserted.placement)}\n`);
|
|
213
|
+
if (row) process.stdout.write(`${green('Added')} its row to the register in ${registerRepoPath}\n`);
|
|
214
|
+
return { id, row };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function registerRow({ id, disposition, today, question, answers, heading, link }) {
|
|
218
|
+
const text = `${question.trim()} ${answers.trim()}`;
|
|
219
|
+
return `${id} ${disposition.toUpperCase()} ${today.slice(0, 10)}: ${text} Record: [${link} § ${heading} ${id}](${link}).`;
|
|
220
|
+
}
|
package/src/new.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import { existsSync, readFileSync, mkdirSync, fstatSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { runNewDecision } from './decision.mjs';
|
|
5
6
|
import { toRepoPath, die, warn, nowIso, emitFilesFooter } from './util.mjs';
|
|
6
7
|
import { green, dim, bold } from './color.mjs';
|
|
7
8
|
import { isInteractive, promptText } from './prompt.mjs';
|
|
@@ -652,9 +653,14 @@ next_step:
|
|
|
652
653
|
export async function runNew(argv, config, opts = {}) {
|
|
653
654
|
const { dryRun } = opts;
|
|
654
655
|
|
|
656
|
+
if (argv.find(a => !a.startsWith('-')) === 'decision') return runNewDecisionArgs(argv, config, opts);
|
|
657
|
+
|
|
655
658
|
const knownTypes = new Set(Object.keys(BUILTIN_TEMPLATES));
|
|
656
659
|
// Also include any custom templates from config
|
|
657
660
|
for (const k of Object.keys(config.raw?.templates ?? {})) knownTypes.add(k);
|
|
661
|
+
// `hub` is a plan with a hub body; `decision` is handled above.
|
|
662
|
+
knownTypes.add('hub');
|
|
663
|
+
knownTypes.add('decision');
|
|
658
664
|
|
|
659
665
|
const hasNameForBody = args => {
|
|
660
666
|
if (args.length >= 2 && knownTypes.has(args[0])) return true;
|
|
@@ -713,11 +719,27 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
713
719
|
name = positional[1];
|
|
714
720
|
if (positional.length > 2) bodyArg = positional.slice(2).join(' ');
|
|
715
721
|
} else {
|
|
722
|
+
// `new <name> <inline body>` is a doc with the type left out, but a single
|
|
723
|
+
// bare word in the body slot is almost always a mistyped type and a slug
|
|
724
|
+
// (`new decison foo`), which used to create `<type-name>.md` with the slug
|
|
725
|
+
// as its whole body.
|
|
726
|
+
const [first, second] = positional;
|
|
727
|
+
if (positional.length === 2 && !/[/\\]|\.md$/.test(first) && /^[A-Za-z][\w.-]*$/.test(second)) {
|
|
728
|
+
die(`Unknown type \`${first}\`. Types: ${[...knownTypes].join(', ')}.\n`
|
|
729
|
+
+ `For a doc named ${first} with "${second}" as its body, run: runlist new doc ${first} "${second}"`);
|
|
730
|
+
}
|
|
716
731
|
typeName = 'doc';
|
|
717
732
|
name = positional[0];
|
|
718
733
|
if (positional.length > 1) bodyArg = positional.slice(1).join(' ');
|
|
719
734
|
}
|
|
720
735
|
|
|
736
|
+
// A hub is a plan with a hub body. Coordination is the default shape, being
|
|
737
|
+
// the one a hub without children or tiers has.
|
|
738
|
+
if (typeName === 'hub') {
|
|
739
|
+
typeName = 'plan';
|
|
740
|
+
if (runlistArg === null && !roadmap) coordination = true;
|
|
741
|
+
}
|
|
742
|
+
|
|
721
743
|
if (!name) {
|
|
722
744
|
if (isInteractive()) {
|
|
723
745
|
name = await promptText(`${typeName} name: `);
|
|
@@ -1145,6 +1167,8 @@ export function newHelpForRepo(config) {
|
|
|
1145
1167
|
if (statuses.length) rows.push(` ${''.padEnd(width)} statuses: ${statuses.join(', ')}`);
|
|
1146
1168
|
}
|
|
1147
1169
|
const roots = (config.docsRoots ?? [config.docsRoot]).map(r => path.basename(r));
|
|
1170
|
+
rows.push(` ${'hub'.padEnd(width)} → a plan with a hub body (coordination unless --runlist or --roadmap)`);
|
|
1171
|
+
rows.push(` ${'decision'.padEnd(width)} → an entry in an existing plan's decisions section`);
|
|
1148
1172
|
return `This repo:
|
|
1149
1173
|
${rows.join('\n')}
|
|
1150
1174
|
roots (for --root): ${roots.join(', ')}`;
|
|
@@ -1206,4 +1230,29 @@ function listTemplates(config) {
|
|
|
1206
1230
|
if (desc) process.stdout.write(` ${dim(desc)}\n`);
|
|
1207
1231
|
process.stdout.write('\n');
|
|
1208
1232
|
}
|
|
1233
|
+
process.stdout.write(` hub\n ${dim('A plan with a hub body: coordination by default, --runlist or --roadmap for the others.')}\n\n`);
|
|
1234
|
+
process.stdout.write(` decision\n ${dim('An entry in an existing plan\'s decisions section: runlist new decision <plan> --question "…" @record.md')}\n\n`);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function runNewDecisionArgs(argv, config, opts) {
|
|
1238
|
+
const positional = [];
|
|
1239
|
+
let question = null;
|
|
1240
|
+
let disposition = null;
|
|
1241
|
+
let answers = null;
|
|
1242
|
+
let bodyFlag = null;
|
|
1243
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1244
|
+
const a = argv[i];
|
|
1245
|
+
if (a === '--question' && argv[i + 1] !== undefined) { question = argv[++i]; continue; }
|
|
1246
|
+
if (a === '--disposition' && argv[i + 1] !== undefined) { disposition = argv[++i]; continue; }
|
|
1247
|
+
if (a === '--answers' && argv[i + 1] !== undefined) { answers = argv[++i]; continue; }
|
|
1248
|
+
if ((a === '--body' || a === '--message') && argv[i + 1] !== undefined) { bodyFlag = argv[++i]; continue; }
|
|
1249
|
+
if (a === '--config' || a === '--root') { i++; continue; }
|
|
1250
|
+
if (!a.startsWith('-') || a === '-') positional.push(a);
|
|
1251
|
+
}
|
|
1252
|
+
const [, planArg, ...rest] = positional;
|
|
1253
|
+
let record = null;
|
|
1254
|
+
if (bodyFlag !== null) record = readBodyInput(bodyFlag);
|
|
1255
|
+
else if (rest.length) record = readBodyInput(rest.join(' '));
|
|
1256
|
+
else record = readPipedBodyInput();
|
|
1257
|
+
return runNewDecision({ planArg, question, disposition, record, answers }, config, { dryRun: opts.dryRun });
|
|
1209
1258
|
}
|