dotmd-cli 0.68.0 → 0.70.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 (53) hide show
  1. package/README.md +144 -964
  2. package/bin/dotmd.mjs +241 -197
  3. package/dotmd.config.example.mjs +5 -8
  4. package/package.json +6 -10
  5. package/src/agent-context.mjs +132 -0
  6. package/src/atomic-mutation.mjs +1505 -0
  7. package/src/baton.mjs +109 -87
  8. package/src/bulk-tag.mjs +7 -7
  9. package/src/commands.mjs +326 -12
  10. package/src/completions.mjs +38 -98
  11. package/src/config.mjs +18 -3
  12. package/src/diff.mjs +7 -3
  13. package/src/doctor.mjs +12 -5
  14. package/src/export.mjs +154 -25
  15. package/src/fix-refs.mjs +2 -0
  16. package/src/frontmatter-fix.mjs +2 -0
  17. package/src/frontmatter.mjs +3 -2
  18. package/src/git.mjs +531 -14
  19. package/src/graph.mjs +53 -25
  20. package/src/guard.mjs +163 -60
  21. package/src/hud.mjs +65 -76
  22. package/src/index-file.mjs +28 -16
  23. package/src/index.mjs +17 -12
  24. package/src/init.mjs +1 -1
  25. package/src/journal.mjs +145 -12
  26. package/src/lifecycle.mjs +554 -282
  27. package/src/lint.mjs +57 -9
  28. package/src/managed-path.mjs +192 -0
  29. package/src/migrate-prompts.mjs +2 -0
  30. package/src/migrate-template.mjs +2 -0
  31. package/src/migrate.mjs +7 -1
  32. package/src/new.mjs +135 -54
  33. package/src/output-identity.mjs +106 -0
  34. package/src/pickup-card.mjs +24 -10
  35. package/src/pickup.mjs +457 -0
  36. package/src/prompts.mjs +138 -32
  37. package/src/query.mjs +22 -10
  38. package/src/reference-planner.mjs +292 -0
  39. package/src/rename.mjs +65 -73
  40. package/src/render.mjs +17 -8
  41. package/src/runlist.mjs +109 -71
  42. package/src/section.mjs +2 -1
  43. package/src/ship.mjs +39 -20
  44. package/src/stats.mjs +1 -1
  45. package/src/status-metadata.mjs +87 -0
  46. package/src/statuses.mjs +11 -26
  47. package/src/summary.mjs +14 -3
  48. package/src/update.mjs +38 -10
  49. package/src/use.mjs +4 -1
  50. package/src/util.mjs +1 -0
  51. package/src/validate.mjs +14 -6
  52. package/src/watch.mjs +6 -1
  53. package/src/notion.mjs +0 -528
package/src/commands.mjs CHANGED
@@ -1,13 +1,327 @@
1
- // Canonical list of CLI verbs the dispatcher in bin/dotmd.mjs handles.
2
- // Source of truth for both the unknown-command suggester and the self-check
3
- // that asserts every `dotmd <verb>` reference in generated slash-command
4
- // templates points at a real command.
5
- export const KNOWN_COMMANDS = [
6
- 'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'agent-context', 'hud',
7
- 'focus', 'query', 'grep', 'plans', 'prompts', 'stale', 'actionable', 'index', 'status', 'set', 'use', 'next', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor', 'runlist', 'runlists',
8
- 'unblocks', 'health', 'glossary', 'modules', 'module', 'surfaces',
9
- 'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
10
- 'watch', 'diff', 'new', 'init', 'completions', 'statuses', 'journal',
11
- 'guard', 'misuse', 'update',
12
- 'ship', 'self-check', 'baton',
1
+ // Canonical command grammar. Execution stays in bin/dotmd.mjs; this schema owns
2
+ // names, aliases, visibility, options, positional arity, help groups, and policy.
3
+ const none = Object.freeze({ mutation: 'none', pathPolicy: 'read-only' });
4
+ const mutates = (pathPolicy) => Object.freeze({ mutation: 'conditional', pathPolicy });
5
+
6
+ const flag = (...names) => Object.freeze({ names: Object.freeze(names), arity: 0 });
7
+ const value = (...names) => Object.freeze({ names: Object.freeze(names), arity: 1 });
8
+ const optionalValue = (...names) => Object.freeze({ names: Object.freeze(names), arity: 'optional' });
9
+ const positionals = (min = 0, max = min) => Object.freeze({ min, max });
10
+ const form = (syntax, { subcommands = [], args = positionals(), options = [], passthrough = false, dashPositionalsAfter = Infinity } = {}) => Object.freeze({
11
+ syntax,
12
+ subcommands: Object.freeze(subcommands),
13
+ positionals: args,
14
+ options: Object.freeze(options),
15
+ passthrough,
16
+ dashPositionalsAfter,
17
+ });
18
+
19
+ export const GLOBAL_OPTIONS = Object.freeze([
20
+ value('--config'), value('--root'), value('--type'),
21
+ flag('--dry-run', '-n'), flag('--verbose'), flag('--help', '-h'), flag('--version', '-v'),
22
+ ]);
23
+
24
+ const QUERY_OPTIONS = Object.freeze([
25
+ value('--type'), value('--status'), value('--keyword'), flag('--body'), value('--owner'),
26
+ value('--surface'), value('--module'), value('--domain'), value('--audience'),
27
+ value('--execution-mode'), value('--updated-since'), value('--limit'), value('--sort'),
28
+ value('--group'), flag('--all'), flag('--include-archived'), flag('--exclude-archived'),
29
+ flag('--stale'), flag('--has-next-step'), flag('--has-blockers'), flag('--checklist-open'),
30
+ flag('--json'), flag('--git'), flag('--summarize'), value('--summarize-limit'), value('--model'),
31
+ ]);
32
+ const LIFECYCLE_OPTIONS = Object.freeze([
33
+ value('--note'), flag('--no-index'), flag('--show-files'), flag('--force'),
34
+ ]);
35
+ const STATUS_PROPERTY_OPTIONS = Object.freeze([
36
+ value('--type'), value('--like'), flag('--yes', '-y'), flag('--ignore-lifecycle-override'), flag('--json'),
37
+ value('--context'), value('--staleDays'),
38
+ flag('--requiresModule', '--no-requiresModule'), flag('--terminal', '--no-terminal'),
39
+ flag('--archive', '--no-archive'), flag('--skipStale', '--no-skipStale'),
40
+ flag('--skipWarnings', '--no-skipWarnings'), flag('--quiet', '--no-quiet'),
41
+ ]);
42
+
43
+ const command = (name, policy, group, forms, extra = {}) => Object.freeze({
44
+ name,
45
+ policy,
46
+ group,
47
+ forms: Object.freeze(forms),
48
+ aliases: Object.freeze(extra.aliases ?? []),
49
+ visibility: extra.visibility ?? 'public',
50
+ });
51
+
52
+ const definitions = [
53
+ command('help', none, 'setup', [form('[topic]', { args: positionals(0, 1) })]),
54
+ command('completions', none, 'setup', [form('<bash|zsh>', { args: positionals(1, 1) })]),
55
+ command('init', mutates('repository setup paths and config files'), 'setup', [form('', { options: [flag('--force')] })]),
56
+ command('watch', mutates('proxy: child command policy applies'), 'setup', [form('[command...]', { args: positionals(0, Infinity), passthrough: true })]),
57
+
58
+ command('list', none, 'read', [form('', { options: [flag('--json'), flag('--verbose')] })]),
59
+ command('json', none, 'read', [form('')]),
60
+ command('coverage', none, 'read', [form('', { options: [flag('--json')] })]),
61
+ command('stats', none, 'read', [form('', { options: [flag('--json')] })]),
62
+ command('graph', none, 'read', [form('', { options: [flag('--dot'), flag('--json'), value('--status'), value('--module'), value('--surface')] })]),
63
+ command('deps', none, 'read', [form('[file]', { args: positionals(0, 1), options: [flag('--json'), value('--depth')] })]),
64
+ command('briefing', none, 'read', [form('', { options: [flag('--json')] })]),
65
+ command('context', none, 'read', [form('', { options: [flag('--json'), flag('--compact'), flag('--summarize'), value('--model')] })]),
66
+ command('agent-context', none, 'read', [form('', { options: [flag('--json')] })]),
67
+ command('hud', none, 'read', [form('', { options: [flag('--json'), flag('--subagent')] })]),
68
+ command('focus', none, 'read', [form('[status]', { args: positionals(0, 1), options: [flag('--json')] })]),
69
+ command('query', none, 'read', [form('[terms...]', { args: positionals(0, Infinity), options: QUERY_OPTIONS })]),
70
+ command('grep', none, 'read', [form('<term> [terms...]', { args: positionals(1, Infinity), options: QUERY_OPTIONS })]),
71
+ command('plans', none, 'read', [
72
+ form('[terms...]', { args: positionals(0, Infinity), options: QUERY_OPTIONS }),
73
+ form('status [terms...]', { subcommands: ['status'], args: positionals(0, Infinity), options: QUERY_OPTIONS }),
74
+ ]),
75
+ command('runlists', none, 'read', [form('', { options: [flag('--json'), value('--limit'), value('--sort')] })]),
76
+ command('roadmaps', none, 'read', [form('', { options: [flag('--json')] })]),
77
+ command('diff', none, 'read', [form('[file]', { args: positionals(0, 1), options: [flag('--stat'), value('--since'), flag('--summarize'), value('--model')] })]),
78
+ command('summary', none, 'read', [form('<file>', { args: positionals(1, 1), options: [value('--model'), value('--max-tokens'), flag('--json')] })]),
79
+ command('unblocks', none, 'read', [form('<file>', { args: positionals(1, 1), options: [flag('--json')] })]),
80
+ command('health', none, 'read', [form('', { options: [flag('--json')] })]),
81
+ command('glossary', none, 'read', [form('[term]', { args: positionals(0, 1), options: [flag('--list'), flag('--json')] })]),
82
+ command('modules', none, 'read', [form('', { options: [value('--sort'), value('--limit'), flag('--all'), flag('--json')] })]),
83
+ command('module', none, 'read', [form('<name>', { args: positionals(1, 1), options: [value('--sort'), flag('--json')] })]),
84
+ command('surfaces', none, 'read', [form('', { options: [flag('--json')] })]),
85
+ command('journal', none, 'read', [form('', { options: [value('--tail'), flag('--errors'), value('--session'), value('--since'), flag('--by-command'), flag('--json')] })]),
86
+ command('misuse', none, 'read', [form('', { options: [flag('--json'), value('--tail'), flag('--by-rule'), value('--repo')] })]),
87
+
88
+ command('roadmap', mutates('managed source when `next`; otherwise read-only'), 'workflow', [
89
+ form('[hub]', { args: positionals(0, 1), options: [flag('--json')] }),
90
+ form('next [hub]', { subcommands: ['next'], args: positionals(0, 1), options: [flag('--json'), flag('--full'), flag('--no-index')] }),
91
+ ]),
92
+ command('prompts', mutates('managed sources/destinations by subcommand'), 'workflow', [
93
+ form('', { options: [flag('--json'), value('--status'), flag('--include-archived'), value('--sort'), value('--limit'), flag('--all')] }),
94
+ form('list', { subcommands: ['list', 'status'], options: [flag('--json'), value('--status'), flag('--include-archived'), value('--sort'), value('--limit'), flag('--all')] }),
95
+ form('next', { subcommands: ['next'] }),
96
+ form('use [file]', { subcommands: ['use', 'resume'], args: positionals(0, 1), options: [flag('--no-index'), flag('--show-files'), flag('--force')] }),
97
+ form('show [file]', { subcommands: ['show', 'peek'], args: positionals(0, 1), options: [flag('--json')] }),
98
+ form('archive <file>', { subcommands: ['archive'], args: positionals(1, 1), options: [flag('--no-index'), flag('--show-files')] }),
99
+ form('new <slug> [body...]', { subcommands: ['new'], args: positionals(1, Infinity), options: [value('--body', '--message'), value('--title'), value('--status')] }),
100
+ form('hold <file>', { subcommands: ['hold', 'shelve'], args: positionals(1, 1) }),
101
+ form('unhold <file>', { subcommands: ['unhold', 'unshelve'], args: positionals(1, 1) }),
102
+ ], { aliases: ['prompt'] }),
103
+ command('use', mutates('managed source when starting/consuming; docs remain read-only'), 'workflow', [form('[file]', { args: positionals(0, 1), options: [flag('--json'), flag('--full'), flag('--no-index'), flag('--show-files'), flag('--force')] })]),
104
+ command('next', mutates('managed prompt source and same-root archive destination'), 'workflow', [form('', { options: [flag('--json'), flag('--no-index'), flag('--show-files'), flag('--force')] })]),
105
+ command('baton', mutates('managed plan/prompt sources and managed prompt destination'), 'workflow', [form('[plan|slug] <@draft|->', { args: positionals(0, 2), options: [value('--status'), value('--note'), value('--body', '--message'), flag('--force'), flag('--json')] })]),
106
+ command('runlist', mutates('managed hubs/children and managed scaffold destinations'), 'workflow', [
107
+ form('<hub>', { args: positionals(1, 1), options: [flag('--json')] }),
108
+ form('next <hub>', { subcommands: ['next'], args: positionals(1, 1), options: [flag('--json'), flag('--full'), flag('--no-index'), flag('--show-files')] }),
109
+ form('add <hub> <child...>', { subcommands: ['add'], args: positionals(2, Infinity), options: [flag('--json')] }),
110
+ form('remove <hub> <child...>', { subcommands: ['remove'], args: positionals(2, Infinity), options: [flag('--json'), flag('--clear-parent')] }),
111
+ form('reorder <hub> <child...>', { subcommands: ['reorder'], args: positionals(2, Infinity), options: [flag('--json'), value('--before'), value('--after')] }),
112
+ ]),
113
+
114
+ command('export', mutates('external output path intentionally unrestricted'), 'mutate', [form('[file]', { args: positionals(0, 1), options: [value('--format'), value('--output'), value('--status'), value('--module'), value('--root'), value('--type')] })]),
115
+ command('guard', mutates('global misuse log; no managed document writes'), 'mutate', [form('')]),
116
+ command('update', mutates('global CLI and plugin installation state'), 'mutate', [form('', { options: [flag('--check'), flag('--cli-only'), flag('--plugin-only')] })]),
117
+ command('status', mutates('managed source and same-root destination'), 'mutate', [form('<file> [status]', { args: positionals(1, 2), options: LIFECYCLE_OPTIONS })]),
118
+ command('set', mutates('managed source and same-root destination'), 'mutate', [form('<status> [file]', { args: positionals(1, 2), options: LIFECYCLE_OPTIONS })]),
119
+ command('ship', mutates('repository release/index paths and global release tooling'), 'mutate', [form('[patch|minor|major]', { args: positionals(0, 1) })]),
120
+ command('archive', mutates('managed source and same-root destination'), 'mutate', [form('<file>', { args: positionals(1, 1), options: [...LIFECYCLE_OPTIONS, flag('--closeout-template')] })]),
121
+ command('bulk', mutates('managed source sweep; archive destinations preserve roots'), 'mutate', [
122
+ form('archive <files...>', { subcommands: ['archive'], args: positionals(1, Infinity), options: [flag('--json'), flag('--no-index'), flag('--show-files')] }),
123
+ form('tag [files...]', { subcommands: ['tag'], args: positionals(0, Infinity), options: [value('--type'), value('--status'), flag('--json')] }),
124
+ ]),
125
+ command('bulk-tag', mutates('managed source sweep'), 'mutate', [form('[files...]', { args: positionals(0, Infinity), options: [value('--type'), value('--status'), flag('--json')] })]),
126
+ command('touch', mutates('managed source or managed source sweep'), 'mutate', [form('[file]', { args: positionals(0, 1), options: [flag('--git')] })]),
127
+ command('new', mutates('managed document destination; external body input unrestricted'), 'mutate', [form('[type] <name> [body...]', {
128
+ args: positionals(0, Infinity),
129
+ options: [value('--status'), value('--title'), value('--runlist'), flag('--coordination'), flag('--roadmap'), flag('--lite', '--minimal'), flag('--audit', '--findings'), value('--body', '--message'), value('--root'), flag('--show-files'), flag('--list-templates', '--list-types')],
130
+ dashPositionalsAfter: 1,
131
+ })]),
132
+ command('lint', mutates('managed source sweep with --fix; otherwise read-only'), 'mutate', [form('', { options: [flag('--fix')] })]),
133
+ command('rename', mutates('managed source, same-root destination, and rewrite sweep'), 'mutate', [form('<old> [new]', { args: positionals(1, 2), options: [flag('--show-files')] })]),
134
+ command('migrate', mutates('managed source sweep'), 'mutate', [form('<field> <old> <new> [files...]', { args: positionals(3, Infinity), options: [flag('--show-files')] })]),
135
+ command('fix-refs', mutates('managed source sweep'), 'mutate', [form('', { options: [flag('--show-files')] })]),
136
+ command('doctor', mutates('managed sweeps, repo index, and maintenance config paths by mode'), 'mutate', [form('[path]', { args: positionals(0, 1), options: [flag('--apply', '--yes'), flag('--statuses'), optionalValue('--migrate-template'), flag('--migrate-prompts'), flag('--frontmatter-fix'), flag('--project'), flag('--json'), flag('--include-archived')] })]),
137
+ command('statuses', mutates('project config path; document scan is read-only'), 'mutate', [
138
+ form('list', { subcommands: ['list'], options: [value('--type'), flag('--json')] }),
139
+ form('add <name>', { subcommands: ['add'], args: positionals(1, 1), options: STATUS_PROPERTY_OPTIONS }),
140
+ form('set <name>', { subcommands: ['set'], args: positionals(1, 1), options: STATUS_PROPERTY_OPTIONS }),
141
+ form('remove <name>', { subcommands: ['remove'], args: positionals(1, 1), options: STATUS_PROPERTY_OPTIONS }),
142
+ form('migrate <type>', { subcommands: ['migrate'], args: positionals(1, 1), options: [flag('--yes', '-y'), flag('--json'), flag('--ignore-lifecycle-override')] }),
143
+ form('', { options: [value('--type'), flag('--json')] }),
144
+ ]),
145
+ command('check', mutates('managed fix sweeps and repo-generated index; otherwise validation'), 'mutate', [form('[paths...]', { args: positionals(0, Infinity), options: [flag('--fix'), flag('--errors-only'), flag('--no-collapse'), flag('--json'), flag('--verbose')] })]),
146
+ command('index', mutates('repo-generated index destination; --print is read-only'), 'mutate', [form('', { options: [flag('--print')] })]),
147
+
148
+ command('self-check', none, 'internal', [form('', { options: [flag('--json')] })], { visibility: 'internal' }),
149
+ command('pickup', none, 'removed', [form('[args...]', { args: positionals(0, Infinity) })], { visibility: 'removed' }),
150
+ command('unpickup', none, 'removed', [form('[args...]', { args: positionals(0, Infinity) })], { visibility: 'removed' }),
151
+ command('release', none, 'removed', [form('[args...]', { args: positionals(0, Infinity) })], { visibility: 'removed' }),
152
+ command('finish', none, 'removed', [form('[args...]', { args: positionals(0, Infinity) })], { visibility: 'removed' }),
153
+ command('handoff', none, 'removed', [form('[args...]', { args: positionals(0, Infinity) })], { visibility: 'removed' }),
154
+
155
+ command('stale', none, 'preset', [form('[terms...]', { args: positionals(0, Infinity), options: QUERY_OPTIONS })]),
156
+ command('actionable', none, 'preset', [form('[terms...]', { args: positionals(0, Infinity), options: QUERY_OPTIONS })]),
13
157
  ];
158
+
159
+ function schemaErrors(schema) {
160
+ const errors = [];
161
+ const names = new Set();
162
+ const aliases = new Set();
163
+ for (const entry of schema) {
164
+ if (!entry.name || names.has(entry.name)) errors.push(`duplicate or empty command name: ${entry.name}`);
165
+ names.add(entry.name);
166
+ if (!entry.group) errors.push(`${entry.name}: missing help group`);
167
+ if (!entry.forms.length) errors.push(`${entry.name}: missing forms`);
168
+ for (const alias of entry.aliases) {
169
+ if (!alias || names.has(alias) || aliases.has(alias)) errors.push(`${entry.name}: duplicate alias ${alias}`);
170
+ aliases.add(alias);
171
+ }
172
+ const formKeys = new Set();
173
+ for (const commandForm of entry.forms) {
174
+ const key = commandForm.subcommands.join('|') || '<default>';
175
+ if (formKeys.has(key)) errors.push(`${entry.name}: duplicate form ${key}`);
176
+ formKeys.add(key);
177
+ const { min, max } = commandForm.positionals;
178
+ if (!Number.isInteger(min) || min < 0 || !(max === Infinity || Number.isInteger(max)) || max < min) {
179
+ errors.push(`${entry.name} ${key}: malformed positional arity`);
180
+ }
181
+ const optionNames = new Set();
182
+ for (const option of commandForm.options) {
183
+ if (![0, 1, 'optional'].includes(option.arity) || !option.names.length) errors.push(`${entry.name} ${key}: malformed option`);
184
+ for (const optionName of option.names) {
185
+ if (!optionName.startsWith('-') || optionNames.has(optionName)) errors.push(`${entry.name} ${key}: duplicate or malformed option ${optionName}`);
186
+ optionNames.add(optionName);
187
+ }
188
+ }
189
+ }
190
+ }
191
+ for (const alias of aliases) if (names.has(alias)) errors.push(`alias collides with command: ${alias}`);
192
+ return errors;
193
+ }
194
+
195
+ const errors = schemaErrors(definitions);
196
+ if (errors.length) throw new Error(`Invalid command schema:\n${errors.join('\n')}`);
197
+
198
+ export const COMMAND_SCHEMA = Object.freeze(Object.fromEntries(definitions.map(entry => [entry.name, entry])));
199
+ const ALIASES = Object.freeze(Object.fromEntries(definitions.flatMap(entry => entry.aliases.map(alias => [alias, entry.name]))));
200
+
201
+ export const COMMAND_POLICIES = Object.freeze(Object.fromEntries(definitions.map(entry => [entry.name, entry.policy])));
202
+ export const KNOWN_COMMANDS = Object.freeze(definitions.map(entry => entry.name));
203
+ export const PUBLIC_COMMANDS = Object.freeze(definitions.filter(entry => entry.visibility === 'public').map(entry => entry.name));
204
+ export const COMPLETION_COMMANDS = Object.freeze(definitions
205
+ .filter(entry => entry.visibility === 'public')
206
+ .flatMap(entry => [entry.name, ...entry.aliases]));
207
+ export const MUTATION_CAPABLE_COMMANDS = Object.freeze(new Set(
208
+ definitions.filter(entry => entry.policy.mutation !== 'none').map(entry => entry.name),
209
+ ));
210
+
211
+ export function canonicalCommand(name) {
212
+ return ALIASES[name] ?? name;
213
+ }
214
+
215
+ export function commandDefinition(name) {
216
+ return COMMAND_SCHEMA[canonicalCommand(name)] ?? null;
217
+ }
218
+
219
+ export function commandPolicy(name) {
220
+ return commandDefinition(name)?.policy ?? null;
221
+ }
222
+
223
+ export function commandOwnsOption(name, optionName) {
224
+ const definition = commandDefinition(name);
225
+ return Boolean(definition?.forms.some(commandForm => commandForm.options.some(option => option.names.includes(optionName))));
226
+ }
227
+
228
+ export function commandCompletionWords(name) {
229
+ const definition = commandDefinition(name);
230
+ if (!definition) return [];
231
+ const words = new Set();
232
+ for (const commandForm of definition.forms) {
233
+ for (const subcommand of commandForm.subcommands) words.add(subcommand);
234
+ for (const option of commandForm.options) for (const optionName of option.names) words.add(optionName);
235
+ }
236
+ return [...words];
237
+ }
238
+
239
+ export function commandUsage(name) {
240
+ const definition = commandDefinition(name);
241
+ if (!definition) return null;
242
+ return definition.forms.map(commandForm => `dotmd ${definition.name}${commandForm.syntax ? ` ${commandForm.syntax}` : ''}`).join('\n');
243
+ }
244
+
245
+ function optionMap(forms) {
246
+ const map = new Map();
247
+ for (const commandForm of forms) {
248
+ for (const option of commandForm.options) {
249
+ for (const optionName of option.names) map.set(optionName, option);
250
+ }
251
+ }
252
+ return map;
253
+ }
254
+
255
+ function scanArgs(name, argv, options, passthrough = false, dashPositionalsAfter = Infinity) {
256
+ const positional = [];
257
+ for (let i = 0; i < argv.length; i += 1) {
258
+ const arg = argv[i];
259
+ if (arg === '-') { positional.push(arg); continue; }
260
+ if (!arg.startsWith('-')) { positional.push(arg); continue; }
261
+ const option = options.get(arg);
262
+ if (!option) {
263
+ if (passthrough) continue;
264
+ if (positional.length >= dashPositionalsAfter) { positional.push(arg); continue; }
265
+ throw new Error(`Unknown flag for \`dotmd ${name}\`: ${arg}`);
266
+ }
267
+ if (option.arity === 1) {
268
+ const next = argv[i + 1];
269
+ if (next === undefined || options.has(next)) {
270
+ throw new Error(`Missing value for \`${arg}\` in \`dotmd ${name}\`.`);
271
+ }
272
+ i += 1;
273
+ } else if (option.arity === 'optional' && argv[i + 1] !== undefined && !argv[i + 1].startsWith('-')) {
274
+ i += 1;
275
+ }
276
+ }
277
+ return positional;
278
+ }
279
+
280
+ export function normalizeCommandArgs(name, argv) {
281
+ const canonical = canonicalCommand(name);
282
+ if (canonical !== 'roadmap') return [...argv];
283
+ const positionalIndexes = [];
284
+ const options = optionMap(commandDefinition(canonical).forms);
285
+ for (let i = 0; i < argv.length; i += 1) {
286
+ const option = options.get(argv[i]);
287
+ if (option) {
288
+ if (option.arity === 1) i += 1;
289
+ else if (option.arity === 'optional' && argv[i + 1] !== undefined && !argv[i + 1].startsWith('-')) i += 1;
290
+ continue;
291
+ }
292
+ if (!argv[i].startsWith('-')) positionalIndexes.push(i);
293
+ }
294
+ if (positionalIndexes.length === 2 && argv[positionalIndexes[1]] === 'next' && argv[positionalIndexes[0]] !== 'next') {
295
+ const normalized = [...argv];
296
+ const hub = normalized[positionalIndexes[0]];
297
+ normalized[positionalIndexes[0]] = 'next';
298
+ normalized[positionalIndexes[1]] = hub;
299
+ return normalized;
300
+ }
301
+ return [...argv];
302
+ }
303
+
304
+ export function validateCommandArgs(name, argv, { preset = false } = {}) {
305
+ const definition = preset ? COMMAND_SCHEMA.query : commandDefinition(name);
306
+ if (!definition) return argv;
307
+ const canonical = definition.name;
308
+ const normalized = normalizeCommandArgs(canonical, argv);
309
+ const allOptions = optionMap(definition.forms);
310
+ const allPassthrough = definition.forms.some(commandForm => commandForm.passthrough);
311
+ const dashPositionalsAfter = Math.min(...definition.forms.map(commandForm => commandForm.dashPositionalsAfter));
312
+ const preliminary = scanArgs(canonical, normalized, allOptions, allPassthrough, dashPositionalsAfter);
313
+ const selected = definition.forms.find(commandForm => commandForm.subcommands.includes(preliminary[0]))
314
+ ?? definition.forms.find(commandForm => commandForm.subcommands.length === 0);
315
+ if (!selected) throw new Error(`Unknown subcommand for \`dotmd ${canonical}\`: ${preliminary[0] ?? '(missing)'}`);
316
+ const selectedOptions = optionMap([selected]);
317
+ const positional = scanArgs(canonical, normalized, selectedOptions, selected.passthrough, selected.dashPositionalsAfter);
318
+ const args = selected.subcommands.includes(positional[0]) ? positional.slice(1) : positional;
319
+ if (args.length < selected.positionals.min || args.length > selected.positionals.max) {
320
+ throw new Error(`Usage: ${commandUsage(canonical)}`);
321
+ }
322
+ return normalized;
323
+ }
324
+
325
+ export function validateCommandSchema(schema = definitions) {
326
+ return schemaErrors(schema);
327
+ }
@@ -1,107 +1,41 @@
1
1
  import { die } from './util.mjs';
2
- import { KNOWN_COMMANDS } from './commands.mjs';
2
+ import { COMPLETION_COMMANDS, GLOBAL_OPTIONS, PUBLIC_COMMANDS, commandCompletionWords } from './commands.mjs';
3
3
 
4
- // Derive the completable command list from the dispatcher's canonical verb list
5
- // so completions never drift behind new commands. A tiny denylist drops verbs
6
- // that exist but shouldn't be tab-completed (internal self-test).
7
- const COMPLETION_DENYLIST = new Set(['self-check']);
8
- const COMMANDS = KNOWN_COMMANDS.filter(c => !COMPLETION_DENYLIST.has(c));
9
-
10
- const GLOBAL_FLAGS = ['--config', '--dry-run', '--verbose', '--root', '--type', '--help', '--version'];
11
-
12
- // Shared filter flags for the query-style commands (mirrors QUERY_FLAGS in
13
- // bin/dotmd.mjs). Kept as one array so query/grep/stale/actionable/plans stay
14
- // in lockstep.
15
- const QUERY_FLAGS = [
16
- '--type', '--status', '--keyword', '--body', '--owner', '--surface', '--module',
17
- '--domain', '--audience', '--execution-mode', '--updated-since', '--limit', '--sort',
18
- '--group', '--all', '--include-archived', '--exclude-archived', '--stale',
19
- '--has-next-step', '--has-blockers', '--checklist-open', '--json', '--git',
20
- '--summarize', '--summarize-limit', '--model',
21
- ];
22
-
23
- const COMMAND_FLAGS = {
24
- query: QUERY_FLAGS,
25
- grep: QUERY_FLAGS,
26
- stale: QUERY_FLAGS,
27
- actionable: QUERY_FLAGS,
28
- plans: ['status', ...QUERY_FLAGS],
29
- list: ['--verbose', '--json'],
30
- briefing: ['--json'],
31
- context: ['--json', '--compact', '--summarize', '--model'],
32
- 'agent-context': ['--json'],
33
- hud: ['--json', '--subagent'],
34
- index: ['--write'],
35
- coverage: ['--json'],
36
- stats: ['--json'],
37
- graph: ['--dot', '--json', '--status', '--module', '--surface'],
38
- deps: ['--json', '--depth'],
39
- unblocks: ['--json'],
40
- health: ['--json'],
41
- glossary: ['--list', '--json'],
42
- modules: ['--sort', '--json'],
43
- module: ['--json'],
44
- surfaces: ['--json'],
45
- runlists: ['--json', '--limit', '--sort'],
46
- runlist: ['next', '--json', '--full', '--no-index', '--show-files'],
47
- prompts: ['list', 'next', 'use', 'show', 'archive', 'new', 'hold', 'unhold',
48
- '--json', '--status', '--include-archived', '--sort', '--limit', '--all'],
49
- use: [],
50
- next: [],
51
- baton: ['--status', '--note', '--body', '--message', '--dry-run'],
52
- set: [],
53
- status: [],
54
- archive: ['--note', '--no-index', '--show-files', '--closeout-template'],
55
- bulk: ['archive', 'tag'],
56
- statuses: ['list', 'add', '--type', '--like', '--json'],
57
- update: ['--check', '--cli-only', '--plugin-only'],
58
- misuse: ['--json', '--tail', '--by-rule', '--repo'],
59
- journal: ['--tail', '--errors', '--session', '--since', '--by-command', '--json'],
60
- new: ['--status', '--title', '--template', '--list-templates', '--root', '--message', '--body'],
61
- notion: ['import', 'export', 'sync', '--force', '--dry-run'],
62
- export: ['--format', '--output', '--status', '--module', '--root', '--type'],
63
- focus: ['--json'],
64
- summary: ['--model', '--max-tokens', '--json'],
65
- diff: ['--stat', '--since', '--summarize', '--model'],
66
- touch: ['--git'],
67
- check: ['--fix', '--errors-only', '--no-collapse', '--json', '--verbose'],
68
- doctor: ['--apply', '--yes', '--dry-run', '--statuses', '--migrate-template',
69
- '--migrate-prompts', '--frontmatter-fix', '--project', '--json', '--include-archived'],
70
- lint: ['--fix'],
71
- ship: [],
72
- rename: [],
73
- migrate: [],
74
- 'fix-refs': [],
75
- };
4
+ const GLOBAL_FLAGS = GLOBAL_OPTIONS.flatMap(option => option.names);
5
+ const COMMAND_WORDS = Object.freeze(Object.fromEntries(
6
+ PUBLIC_COMMANDS.map(command => [command, commandCompletionWords(command)]),
7
+ ));
76
8
 
77
9
  function bashCompletion() {
78
10
  return `# dotmd bash completion
79
11
  # Add to ~/.bashrc: eval "$(dotmd completions bash)"
80
12
  _dotmd() {
81
- local cur prev cmd
13
+ local cur cmd expect_value
82
14
  cur="\${COMP_WORDS[COMP_CWORD]}"
83
- prev="\${COMP_WORDS[COMP_CWORD-1]}"
84
-
85
- # Find the subcommand
86
15
  cmd=""
16
+ expect_value=0
17
+
18
+ # Find the command while skipping pre-command global flags and their values.
87
19
  for ((i=1; i < COMP_CWORD; i++)); do
20
+ if (( expect_value )); then expect_value=0; continue; fi
88
21
  case "\${COMP_WORDS[i]}" in
22
+ --config|--root|--type) expect_value=1 ;;
23
+ --dry-run|-n|--verbose|--help|-h|--version|-v) ;;
89
24
  -*) ;;
90
25
  *) cmd="\${COMP_WORDS[i]}"; break ;;
91
26
  esac
92
27
  done
93
28
 
94
- # Complete commands if no subcommand yet
95
29
  if [[ -z "$cmd" ]]; then
96
- COMPREPLY=( $(compgen -W "${COMMANDS.join(' ')} ${GLOBAL_FLAGS.join(' ')}" -- "$cur") )
30
+ COMPREPLY=( $(compgen -W "${COMPLETION_COMMANDS.join(' ')} ${GLOBAL_FLAGS.join(' ')}" -- "$cur") )
97
31
  return
98
32
  fi
99
33
 
100
- # Per-command flag completion
101
34
  case "$cmd" in
102
- ${Object.entries(COMMAND_FLAGS).map(([cmd, flags]) =>
103
- ` ${cmd}) COMPREPLY=( $(compgen -W "${flags.join(' ')} ${GLOBAL_FLAGS.join(' ')}" -- "$cur") ) ;;`
35
+ ${Object.entries(COMMAND_WORDS).map(([command, words]) =>
36
+ ` ${command}) COMPREPLY=( $(compgen -W "${words.join(' ')} ${GLOBAL_FLAGS.join(' ')}" -- "$cur") ) ;;`
104
37
  ).join('\n')}
38
+ prompt) COMPREPLY=( $(compgen -W "${commandCompletionWords('prompts').join(' ')} ${GLOBAL_FLAGS.join(' ')}" -- "$cur") ) ;;
105
39
  *) COMPREPLY=( $(compgen -W "${GLOBAL_FLAGS.join(' ')}" -- "$cur") ) ;;
106
40
  esac
107
41
  }
@@ -114,23 +48,35 @@ function zshCompletion() {
114
48
  _dotmd() {
115
49
  local -a commands global_flags
116
50
  commands=(
117
- ${COMMANDS.map(c => ` '${c}'`).join('\n')}
51
+ ${COMPLETION_COMMANDS.map(command => ` '${command}'`).join('\n')}
118
52
  )
119
53
  global_flags=(
120
- ${GLOBAL_FLAGS.map(f => ` '${f}'`).join('\n')}
54
+ ${GLOBAL_FLAGS.map(option => ` '${option}'`).join('\n')}
121
55
  )
122
56
 
123
- if (( CURRENT == 2 )); then
57
+ local cmd=''
58
+ local expect_value=0
59
+ for ((i=2; i < CURRENT; i++)); do
60
+ if (( expect_value )); then expect_value=0; continue; fi
61
+ case "\${words[i]}" in
62
+ --config|--root|--type) expect_value=1 ;;
63
+ --dry-run|-n|--verbose|--help|-h|--version|-v) ;;
64
+ -*) ;;
65
+ *) cmd="\${words[i]}"; break ;;
66
+ esac
67
+ done
68
+
69
+ if [[ -z "$cmd" ]]; then
124
70
  _describe 'command' commands
125
71
  _describe 'flag' global_flags
126
72
  return
127
73
  fi
128
74
 
129
- local cmd=\${words[2]}
130
75
  case "$cmd" in
131
- ${Object.entries(COMMAND_FLAGS).map(([cmd, flags]) =>
132
- ` ${cmd}) _values 'flags' ${flags.map(f => `'${f}'`).join(' ')} ;;`
76
+ ${Object.entries(COMMAND_WORDS).map(([command, words]) =>
77
+ ` ${command}) _values 'arguments' ${words.map(word => `'${word}'`).join(' ')} ;;`
133
78
  ).join('\n')}
79
+ prompt) _values 'arguments' ${commandCompletionWords('prompts').map(word => `'${word}'`).join(' ')} ;;
134
80
  esac
135
81
 
136
82
  _describe 'flag' global_flags
@@ -140,14 +86,8 @@ compdef _dotmd dotmd`;
140
86
 
141
87
  export function runCompletions(argv) {
142
88
  const shell = argv[0];
143
- if (!shell) {
144
- die('Usage: dotmd completions <bash|zsh>');
145
- }
146
- if (shell === 'bash') {
147
- process.stdout.write(bashCompletion() + '\n');
148
- } else if (shell === 'zsh') {
149
- process.stdout.write(zshCompletion() + '\n');
150
- } else {
151
- die(`Unsupported shell: ${shell}\nSupported: bash, zsh`);
152
- }
89
+ if (!shell) die('Usage: dotmd completions <bash|zsh>');
90
+ if (shell === 'bash') process.stdout.write(bashCompletion() + '\n');
91
+ else if (shell === 'zsh') process.stdout.write(zshCompletion() + '\n');
92
+ else die(`Unsupported shell: ${shell}\nSupported: bash, zsh`);
153
93
  }
package/src/config.mjs CHANGED
@@ -55,6 +55,7 @@ const DEFAULTS = {
55
55
  },
56
56
 
57
57
  lifecycle: {
58
+ startableStatuses: ['active', 'planned'],
58
59
  archiveStatuses: ['archived'],
59
60
  skipStaleFor: ['archived', 'reference', 'partial', 'queued-after'],
60
61
  skipWarningsFor: ['archived', 'partial', 'queued-after'],
@@ -112,8 +113,6 @@ const DEFAULTS = {
112
113
 
113
114
  glossary: null,
114
115
 
115
- notion: null,
116
-
117
116
  // Opt-in JSONL command journal at .dotmd/journal.jsonl. Default off — agents
118
117
  // and users who want usage observability flip this on (or set DOTMD_JOURNAL=1).
119
118
  journal: false,
@@ -141,6 +140,7 @@ const VALID_CONTEXT_VALUES = new Set(['expanded', 'listed', 'counted']);
141
140
  */
142
141
  function normalizeRichStatuses(config, userConfig) {
143
142
  const derived = {
143
+ startableStatuses: [],
144
144
  archiveStatuses: [],
145
145
  skipStaleFor: [],
146
146
  skipWarningsFor: [],
@@ -199,6 +199,7 @@ function normalizeRichStatuses(config, userConfig) {
199
199
  }
200
200
 
201
201
  if (p.archive && !derived.archiveStatuses.includes(name)) derived.archiveStatuses.push(name);
202
+ if (p.startable && !derived.startableStatuses.includes(name)) derived.startableStatuses.push(name);
202
203
  if ((p.skipStale || quietImpliesSkipStale) && !derived.skipStaleFor.includes(name)) derived.skipStaleFor.push(name);
203
204
  if ((p.skipWarnings || quietImpliesSkipWarnings) && !derived.skipWarningsFor.includes(name)) derived.skipWarningsFor.push(name);
204
205
  if (p.terminal && !derived.terminalStatuses.includes(name)) derived.terminalStatuses.push(name);
@@ -241,6 +242,9 @@ function applyDerivedConfig(config, userConfig, derived) {
241
242
  }
242
243
 
243
244
  // lifecycle — each sub-key independently
245
+ if (!userConfig.lifecycle?.startableStatuses && derived.startableStatuses.length) {
246
+ config.lifecycle.startableStatuses = derived.startableStatuses;
247
+ }
244
248
  if (!userConfig.lifecycle?.archiveStatuses && derived.archiveStatuses.length) {
245
249
  config.lifecycle.archiveStatuses = derived.archiveStatuses;
246
250
  }
@@ -298,6 +302,15 @@ function validateConfig(userConfig, config, validStatuses, indexPath) {
298
302
  warnings.push('Config: archiveDir must be a string.');
299
303
  }
300
304
 
305
+ // Lifecycle status lists must reference configured statuses.
306
+ if (config.lifecycle?.startableStatuses) {
307
+ for (const s of config.lifecycle.startableStatuses) {
308
+ if (!validStatuses.has(s)) {
309
+ warnings.push(`Config: lifecycle.startableStatuses contains unknown status '${s}'.`);
310
+ }
311
+ }
312
+ }
313
+
301
314
  // lifecycle.archiveStatuses values must exist in validStatuses
302
315
  if (config.lifecycle?.archiveStatuses) {
303
316
  for (const s of config.lifecycle.archiveStatuses) {
@@ -486,6 +499,7 @@ export async function resolveConfig(cwd, explicitConfigPath) {
486
499
  // Lifecycle config
487
500
  const lifecycle = config.lifecycle;
488
501
  const archiveStatuses = new Set(lifecycle.archiveStatuses);
502
+ const startableStatuses = new Set(lifecycle.startableStatuses ?? ['active', 'planned']);
489
503
  const skipStaleFor = new Set(lifecycle.skipStaleFor);
490
504
  const skipWarningsFor = new Set(lifecycle.skipWarningsFor);
491
505
  const terminalStatuses = new Set(lifecycle.terminalStatuses);
@@ -526,7 +540,7 @@ export async function resolveConfig(cwd, explicitConfigPath) {
526
540
  rootValidStatuses,
527
541
  staleDaysByStatus,
528
542
 
529
- lifecycle: { archiveStatuses, skipStaleFor, skipWarningsFor, terminalStatuses, filedStatuses, archiveNestedTypes },
543
+ lifecycle: { archiveStatuses, startableStatuses, skipStaleFor, skipWarningsFor, terminalStatuses, filedStatuses, archiveNestedTypes },
530
544
 
531
545
  validSurfaces,
532
546
  validModules,
@@ -542,6 +556,7 @@ export async function resolveConfig(cwd, explicitConfigPath) {
542
556
  display: config.display,
543
557
  referenceFields: config.referenceFields,
544
558
  presets: config.presets,
559
+ configuredPresetNames: new Set(Object.keys(userConfig.presets ?? {})),
545
560
  journal: config.journal === true,
546
561
  guard: { deny: config.guard?.deny !== false },
547
562
  hooks,
package/src/diff.mjs CHANGED
@@ -75,16 +75,21 @@ function printFileDiff(relPath, since, diffOutput, opts) {
75
75
  process.stdout.write(bold(relPath) + dim(` (updated: ${since})`) + '\n');
76
76
 
77
77
  if (opts.summarize) {
78
+ const previewSkipped = Boolean(opts.config?._execution?.suppressSideEffects);
78
79
  let summary;
79
80
  try {
80
- summary = opts.config?.hooks?.summarizeDiff
81
+ summary = previewSkipped
82
+ ? null
83
+ : opts.config?.hooks?.summarizeDiff
81
84
  ? opts.config.hooks.summarizeDiff(diffOutput, relPath)
82
85
  : summarizeDiffText(diffOutput, relPath, opts.model);
83
86
  } catch (err) {
84
87
  warn(`Hook 'summarizeDiff' threw: ${err.message}`);
85
88
  summary = null;
86
89
  }
87
- if (summary) {
90
+ if (previewSkipped) {
91
+ process.stdout.write(dim(' [preview] Summary generation skipped; models and custom summarizeDiff hooks are not invoked.') + '\n');
92
+ } else if (summary) {
88
93
  process.stdout.write(dim(` Summary: ${summary}`) + '\n');
89
94
  } else {
90
95
  warn(' Summary unavailable (model call failed)');
@@ -94,4 +99,3 @@ function printFileDiff(relPath, since, diffOutput, opts) {
94
99
  process.stdout.write(diffOutput);
95
100
  process.stdout.write('\n');
96
101
  }
97
-