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/baton.mjs CHANGED
@@ -1,12 +1,13 @@
1
- import { readFileSync, fstatSync } from 'node:fs';
1
+ import { readFileSync, existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
- import { asString, toRepoPath, die, warn, currentSessionId } from './util.mjs';
4
+ import { asString, toRepoPath, die, warn } from './util.mjs';
5
5
  import { buildIndex, resolveDocArg } from './index.mjs';
6
- import { readJournalEntries } from './journal.mjs';
7
- import { runNew, readBodyInput } from './new.mjs';
8
- import { runSet } from './lifecycle.mjs';
6
+ import { preparePromptDocument, runNew, readBodyInput, readPipedBodyInput } from './new.mjs';
7
+ import { ensurePlanCompletionBeforeRelease, planHasPendingCompletion, runSet } from './lifecycle.mjs';
9
8
  import { green, dim } from './color.mjs';
9
+ import { authorizeManagedSource } from './managed-path.mjs';
10
+ import { assertPlanMutationAuthorized, authoritativeSessionId, listOwnedPlans, readPlanOwnership } from './pickup.mjs';
10
11
 
11
12
  // `dotmd baton` is the one-command handoff: save the resume prompt AND release
12
13
  // the plan in a single atomic-ish verb. It exists because the three-step skill
@@ -15,48 +16,14 @@ import { green, dim } from './color.mjs';
15
16
  // tangled in what to commit. Baton does exactly one plan, one prompt, one
16
17
  // status flip, and then *tells* the agent the exact commit command.
17
18
 
18
- // Does a journal argv doc reference point at this index doc? References come
19
- // from `use <x>` / `set in-session <x>` invocations, so they may be a repo
20
- // path, a bare basename, or a slug without .md.
21
- function matchesDocRef(doc, ref) {
22
- if (typeof ref !== 'string' || !ref) return false;
23
- const cleaned = ref.replace(/^\.\//, '');
24
- if (doc.path === cleaned) return true;
25
- const base = path.basename(doc.path, '.md');
26
- if (cleaned === base || cleaned === `${base}.md`) return true;
27
- return doc.path.endsWith(`/${cleaned}`) || doc.path.endsWith(`/${cleaned}.md`);
28
- }
29
-
30
- // Resolve which in-session plan belongs to THIS session. There is no checkout
31
- // or lock — in-session is just frontmatter — so ownership is reconstructed
32
- // from the per-repo journal: the last `use <plan>` / `set in-session <plan>`
33
- // this sid ran whose target is still in-session. Falls back to "the only
34
- // in-session plan" when the journal can't answer (disabled, or another tool
35
- // flipped the status). Returns { plan, via, inSession }; plan is null when
36
- // there's no defensible answer (caller decides how to ask).
37
19
  export function findOwnedPlan(config, index = null) {
38
20
  const idx = index ?? buildIndex(config);
39
21
  const inSession = idx.docs.filter(d => d.type === 'plan' && d.status === 'in-session');
40
- if (inSession.length === 0) return { plan: null, via: null, inSession };
41
-
42
- const sid = currentSessionId();
43
- let entries = [];
44
- try { entries = readJournalEntries(config); } catch { entries = []; }
45
- for (let i = entries.length - 1; i >= 0; i--) {
46
- const e = entries[i];
47
- if (e?.sid !== sid || !Array.isArray(e.argv) || (e.exit ?? 0) !== 0) continue;
48
- const a = e.argv;
49
- let ref = null;
50
- if (a[0] === 'use') ref = a.slice(1).find(x => typeof x === 'string' && !x.startsWith('-'));
51
- else if (a[0] === 'set' && a[1] === 'in-session') ref = a.slice(2).find(x => typeof x === 'string' && !x.startsWith('-'));
52
- else if (a[0] === 'status' && a.includes('in-session')) ref = a.slice(1).find(x => typeof x === 'string' && !x.startsWith('-') && x !== 'in-session');
53
- if (!ref) continue;
54
- const doc = inSession.find(d => matchesDocRef(d, ref));
55
- if (doc) return { plan: doc, via: 'journal', inSession };
56
- }
57
-
58
- if (inSession.length === 1) return { plan: inSession[0], via: 'single-in-session', inSession };
59
- return { plan: null, via: null, inSession };
22
+ let records = [];
23
+ try { records = listOwnedPlans(config, authoritativeSessionId()); } catch { return { plan: null, via: null, inSession }; }
24
+ const owned = records.map(record => inSession.find(doc => doc.path === record.plan)).filter(Boolean);
25
+ const clean = (records.diagnostics?.length ?? 0) === 0;
26
+ return { plan: clean && owned.length === 1 ? owned[0] : null, via: clean && owned.length === 1 ? 'ownership' : null, inSession, owned, diagnostics: records.diagnostics ?? [] };
60
27
  }
61
28
 
62
29
  const BODY_USAGE = `dotmd baton needs the resume draft as its body. Write 10–20 lines first — the next concrete decision plus any gotchas, NOT a recap of the plan — then:
@@ -73,17 +40,21 @@ function looksLikePath(arg) {
73
40
 
74
41
  export async function runBaton(argv, config, opts = {}) {
75
42
  const { dryRun } = opts;
43
+ const json = argv.includes('--json');
76
44
 
77
45
  let status = 'active';
78
46
  let statusFlag = false;
79
47
  let note = null;
80
48
  let bodyFlag = null;
49
+ let force = false;
81
50
  const positionals = [];
82
51
  for (let i = 0; i < argv.length; i++) {
83
52
  const a = argv[i];
84
53
  if (a === '--status' && argv[i + 1]) { status = argv[++i]; statusFlag = true; continue; }
85
54
  if (a === '--note' && argv[i + 1]) { note = argv[++i]; continue; }
86
55
  if ((a === '--body' || a === '--message') && argv[i + 1]) { bodyFlag = argv[++i]; continue; }
56
+ if (a === '--force') { force = true; continue; }
57
+ if (a === '--json') continue;
87
58
  if (!a.startsWith('-') || a === '-' || a.startsWith('@')) { positionals.push(a); continue; }
88
59
  die(`Unknown flag for \`dotmd baton\`: ${a}`);
89
60
  }
@@ -103,13 +74,7 @@ export async function runBaton(argv, config, opts = {}) {
103
74
  else if (bodyArg !== null) body = readBodyInput(bodyArg);
104
75
  else {
105
76
  // Auto-consume piped/redirected stdin, same probe as `dotmd new`.
106
- try {
107
- const stat = fstatSync(0);
108
- if (stat.isFIFO() || stat.isFile() || stat.isSocket()) {
109
- const piped = readFileSync(0, 'utf8');
110
- if (piped.length > 0) body = piped;
111
- }
112
- } catch { /* stdin not introspectable */ }
77
+ body = readPipedBodyInput();
113
78
  }
114
79
  if (!body || !body.trim()) die(BODY_USAGE);
115
80
 
@@ -141,19 +106,19 @@ export async function runBaton(argv, config, opts = {}) {
141
106
  const owned = findOwnedPlan(config);
142
107
  if (owned.plan) {
143
108
  planPath = path.resolve(config.repoRoot, owned.plan.path);
144
- if (owned.via === 'single-in-session') {
145
- process.stderr.write(dim(`Handing off the only in-session plan: ${owned.plan.path}\n`));
146
- }
147
- } else if (owned.inSession.length > 1) {
148
- die(`Multiple plans are in-session and the journal can't tell which is this session's — pass yours explicitly:\n${owned.inSession.map(d => ' dotmd baton ' + d.path + ' @/tmp/draft.md').join('\n')}\nNot about a plan? Name the handoff instead: dotmd baton <slug> @/tmp/draft.md`);
109
+ } else if (owned.owned?.length > 1) {
110
+ die(`Multiple plans are owned by this session; pass one explicitly:\n${owned.owned.map(d => ' dotmd baton ' + d.path + ' @/tmp/draft.md').join('\n')}`);
149
111
  } else {
150
- die(`No in-session plan, so baton needs a name for the resume prompt:\n dotmd baton <slug> @/tmp/draft.md # saves resume-<slug>, touches nothing else\nHanding off a specific plan? dotmd baton <plan-file> @/tmp/draft.md`);
112
+ const diagnostics = owned.diagnostics?.length ? `\nIgnored ownership records:\n${owned.diagnostics.map(d => ` ${d}`).join('\n')}` : '';
113
+ die(`No valid in-session plan is owned by this session, so baton needs a name for the resume prompt:\n dotmd baton <slug> @/tmp/draft.md # saves resume-<slug>, touches nothing else\nHanding off a specific plan? dotmd baton <plan-file> @/tmp/draft.md${diagnostics}`);
151
114
  }
152
115
  }
153
116
 
154
117
  let repoPath = null;
155
118
  let oldStatus = null;
119
+ let ownershipPath = null;
156
120
  if (planPath) {
121
+ planPath = authorizeManagedSource(planPath, config, { kind: 'Baton plan source' }).path;
157
122
  repoPath = toRepoPath(planPath, config.repoRoot);
158
123
  const raw = readFileSync(planPath, 'utf8');
159
124
  const { frontmatter: fmRaw } = extractFrontmatter(raw);
@@ -171,53 +136,108 @@ export async function runBaton(argv, config, opts = {}) {
171
136
  if (validStatuses && validStatuses.size > 0 && !validStatuses.has(status)) {
172
137
  die(`Invalid status \`${status}\` for type \`${docType ?? 'plan'}\`\nValid: ${[...validStatuses].join(', ')}`);
173
138
  }
139
+ if (status === 'in-session') {
140
+ die('`dotmd baton --status in-session` contradicts baton release semantics. Choose active/paused/awaiting/partial/blocked.');
141
+ }
142
+ assertPlanMutationAuthorized(repoPath, config, { sessionId: authoritativeSessionId(), force });
143
+ ownershipPath = readPlanOwnership(repoPath, config)?.recordPath ?? null;
144
+ if (!dryRun) ensurePlanCompletionBeforeRelease(repoPath, config, { testHooks: opts.testHooks });
145
+ else if (planHasPendingCompletion(repoPath, config)) process.stderr.write(`${dim('[dry-run]')} Pending claim completion would block this release.\n`);
174
146
  } else {
175
147
  if (statusFlag) warn(`--status ignored — no plan involved in this handoff (saving the prompt only).`);
176
148
  if (note) warn(`--note ignored — no plan involved in this handoff (notes land in a plan's Version History).`);
177
149
  }
178
150
 
179
- // 1. Save the resume prompt. Collision-safe: resume-<slug>, then -2, -3, …
180
- // (a pending resume-<slug> from an earlier handoff must never block this one,
181
- // and bodies are not mergeable).
151
+ // Plan mode publishes the already-stamped prompt, status/history update, and
152
+ // ownership release in one transaction. Slug mode has no plan transaction.
182
153
  const nameBase = planPath ? path.basename(planPath, '.md') : promptSlug;
183
154
  const slugBase = nameBase.startsWith('resume-') ? nameBase : `resume-${nameBase}`;
184
155
  let createdSlug = null;
185
- for (let n = 1; n <= 9 && !createdSlug; n++) {
186
- const slug = n === 1 ? slugBase : `${slugBase}-${n}`;
187
- try {
188
- await runNew(['prompt', slug, '--body', body], config, { dryRun });
189
- createdSlug = slug;
190
- } catch (err) {
191
- if (!/File already exists/.test(String(err?.message))) throw err;
192
- }
193
- }
194
- if (!createdSlug) die(`Could not find a free prompt slug for ${slugBase} (tried ${slugBase}-2 … ${slugBase}-9).`);
195
-
196
- // 2. Release the plan — exactly one status flip. Skipped entirely in slug
197
- // mode: with no plan involved there is nothing to release.
198
156
  let archiveResult = null;
199
157
  let statusChanged = false;
200
- if (planPath) {
201
- if (oldStatus === status) {
202
- process.stderr.write(dim(`Plan already ${status}: ${repoPath} (no status change)\n`));
203
- } else {
158
+ let promptRepoPath = null;
159
+ let newResult = null;
160
+ const muted = [];
161
+ const originalStdoutWrite = process.stdout.write;
162
+ if (json) process.stdout.write = chunk => { muted.push(String(chunk)); return true; };
163
+ try {
164
+ if (!planPath) {
165
+ for (let n = 1; n <= 9 && !createdSlug; n++) {
166
+ const slug = n === 1 ? slugBase : `${slugBase}-${n}`;
167
+ try {
168
+ const prepared = preparePromptDocument(slug, body, config, { dryRun });
169
+ newResult = await runNew(['prompt', slug, '--body', body], config, { dryRun, deferIndex: true });
170
+ createdSlug = slug;
171
+ promptRepoPath = prepared.repoPath;
172
+ }
173
+ catch (err) { if (!/File already exists/.test(String(err?.message))) throw err; }
174
+ }
175
+ } else {
176
+ for (let n = 1; n <= 9 && !createdSlug; n++) {
177
+ const candidate = n === 1 ? slugBase : `${slugBase}-${n}`;
178
+ const prepared = preparePromptDocument(candidate, body, config, { plan: repoPath, dryRun });
179
+ if (existsSync(prepared.filePath)) continue;
204
180
  const setArgs = [status, planPath];
181
+ if (force) setArgs.push('--force');
205
182
  if (note) setArgs.push('--note', note);
206
- // viaBaton: this release IS the handoff — don't let runSet nudge "leave a
207
- // baton" on top of the baton we're in the middle of.
208
- archiveResult = await runSet(setArgs, config, { dryRun, viaBaton: true });
209
- statusChanged = true;
183
+ try {
184
+ if (dryRun) process.stdout.write(`${dim('[dry-run]')} Would create: ${prepared.repoPath}\n`);
185
+ archiveResult = await runSet(setArgs, config, {
186
+ dryRun,
187
+ viaBaton: true,
188
+ testHooks: opts.testHooks,
189
+ creations: dryRun ? [] : [{ path: prepared.filePath, content: prepared.content }],
190
+ deferIndex: true,
191
+ });
192
+ createdSlug = prepared.slug;
193
+ promptRepoPath = prepared.repoPath;
194
+ statusChanged = oldStatus !== status;
195
+ if (!dryRun) {
196
+ try { config.hooks.onNew?.({ path: prepared.repoPath, status: 'pending', title: prepared.slug, type: 'prompt' }); }
197
+ catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
198
+ }
199
+ } catch (err) {
200
+ if (!/Destination already exists|File already exists/.test(String(err?.message))) throw err;
201
+ }
210
202
  }
211
203
  }
204
+ } finally {
205
+ if (json) process.stdout.write = originalStdoutWrite;
206
+ }
207
+ if (!createdSlug) die(`Could not find a free prompt slug for ${slugBase} (tried ${slugBase}-2 … ${slugBase}-9).`);
208
+
209
+ const normalizeRepoPath = candidate => {
210
+ if (!candidate) return null;
211
+ return path.isAbsolute(candidate) ? toRepoPath(candidate, config.repoRoot) : candidate;
212
+ };
213
+ const touched = (archiveResult?.touched ?? (planPath && statusChanged ? [repoPath] : [])).map(normalizeRepoPath);
214
+ const ownershipRepoPath = normalizeRepoPath(ownershipPath);
215
+ const repositoryFiles = [...new Set(touched.filter(candidate => candidate && candidate !== ownershipRepoPath && candidate !== promptRepoPath && candidate !== normalizeRepoPath(config.indexPath)))];
216
+ const sessionFiles = [...new Set([...(newResult?.sessionFiles ?? []), promptRepoPath, ownershipRepoPath].filter(Boolean))];
217
+ const deferredGeneratedFiles = [...new Set(newResult?.deferredGeneratedFiles ?? (config.indexPath ? [normalizeRepoPath(config.indexPath)] : []))];
218
+ const operationResult = {
219
+ operation: 'baton',
220
+ dryRun: Boolean(dryRun),
221
+ disposition: dryRun ? 'would-change' : 'applied',
222
+ wouldChange: Boolean(dryRun),
223
+ mode: planPath ? 'plan' : 'slug',
224
+ status: planPath ? { from: oldStatus, to: status, changed: statusChanged } : null,
225
+ repositoryFiles,
226
+ sessionFiles,
227
+ generatedFiles: [],
228
+ deferredGeneratedFiles,
229
+ prompt: promptRepoPath,
230
+ plan: archiveResult?.newRepoPath ?? repoPath,
231
+ };
212
232
 
213
- // 3. Tell the agent exactly what to commit — and what NOT to. The prompt is
214
- // session-local (often gitignored); only the plan's frontmatter change is
215
- // repo state.
216
233
  const prefix = dryRun ? dim('[dry-run] ') : '';
234
+ if (json) {
235
+ process.stdout.write(JSON.stringify(operationResult, null, 2) + '\n');
236
+ return operationResult;
237
+ }
217
238
  process.stderr.write(`\n${prefix}${green('✓ Baton passed')}: ${createdSlug} (the next session's hud surfaces it — nothing to paste into chat)\n`);
218
239
  if (statusChanged) {
219
- const newRepoPath = archiveResult?.newRepoPath ?? null;
220
- const pathspec = newRepoPath && newRepoPath !== repoPath ? `${repoPath} ${newRepoPath}` : repoPath;
240
+ const pathspec = operationResult.repositoryFiles.join(' ');
221
241
  let gitignored = false;
222
242
  try {
223
243
  const { isGitIgnored } = await import('./git.mjs');
@@ -226,8 +246,10 @@ export async function runBaton(argv, config, opts = {}) {
226
246
  if (gitignored) {
227
247
  process.stderr.write(dim(`${repoPath} is gitignored — no commit needed.\n`));
228
248
  } else {
229
- process.stderr.write(`${prefix}Commit the plan's status change (keep the prompt OUT of the pathspec — it's session-local):\n`);
249
+ process.stderr.write(`${prefix}Commit the repository files (session files stay OUT of the pathspec):\n`);
230
250
  process.stderr.write(`${prefix} git commit -m "baton: ${path.basename(planPath, '.md')} ${oldStatus} → ${status}" -- ${pathspec}\n`);
231
251
  }
232
252
  }
253
+ if (operationResult.deferredGeneratedFiles.length) process.stderr.write(dim(`Generated index deferred: ${operationResult.deferredGeneratedFiles.join(', ')}\n`));
254
+ return operationResult;
233
255
  }
package/src/bulk-tag.mjs CHANGED
@@ -5,6 +5,7 @@ import { collectDocFiles } from './index.mjs';
5
5
  import { toRepoPath, die, warn, resolveDocPath } from './util.mjs';
6
6
  import { writeFrontmatter } from './lifecycle.mjs';
7
7
  import { green, dim, yellow } from './color.mjs';
8
+ import { authorizeManagedSweep, findLexicalDocsRoot } from './managed-path.mjs';
8
9
 
9
10
  // Per-type default status for bulk-tagging pre-existing untagged markdown.
10
11
  // These intentionally lean conservative (draft / planned) rather than active —
@@ -25,6 +26,9 @@ const DEFAULT_STATUS_BY_TYPE = {
25
26
  // Centralizes the inline `rootLabel.includes('plan')` heuristic from lint.mjs
26
27
  // and extends it for prompts.
27
28
  export function inferTypeFromPath(filePath, docsRoot) {
29
+ const rootKind = path.basename(docsRoot);
30
+ if (rootKind === 'plans') return 'plan';
31
+ if (rootKind === 'prompts') return 'prompt';
28
32
  const rel = path.relative(docsRoot, filePath);
29
33
  const segments = rel.split(path.sep);
30
34
  if (segments.length >= 2) {
@@ -48,11 +52,6 @@ function parseArgs(argv) {
48
52
  return opts;
49
53
  }
50
54
 
51
- function findFileRoot(filePath, config) {
52
- const roots = config.docsRoots || [config.docsRoot];
53
- return roots.find(r => filePath.startsWith(r + '/')) ?? config.docsRoot;
54
- }
55
-
56
55
  export function runBulkTag(argv, config, opts = {}) {
57
56
  const { dryRun } = opts;
58
57
  const args = parseArgs(argv);
@@ -69,12 +68,13 @@ export function runBulkTag(argv, config, opts = {}) {
69
68
  } else {
70
69
  pool = allFiles;
71
70
  }
71
+ authorizeManagedSweep(pool, config, { kind: 'Bulk tag source' });
72
72
 
73
73
  // Skip already-archived files (mirrors bulk archive's policy at
74
74
  // lifecycle.mjs:569–573) — settled docs shouldn't be retroactively tagged.
75
75
  const archiveDir = config.archiveDir;
76
76
  const inArchive = (f) => {
77
- const root = findFileRoot(f, config);
77
+ const root = findLexicalDocsRoot(f, config) ?? config.docsRoot;
78
78
  const rel = path.relative(root, f);
79
79
  return rel.startsWith(archiveDir + '/') || rel.startsWith(archiveDir + path.sep);
80
80
  };
@@ -92,7 +92,7 @@ export function runBulkTag(argv, config, opts = {}) {
92
92
  // skipped silently (bulk-tag's job is to fill gaps, not nag).
93
93
  if (hasType && hasStatus) continue;
94
94
 
95
- const root = findFileRoot(filePath, config);
95
+ const root = findLexicalDocsRoot(filePath, config) ?? config.docsRoot;
96
96
  const inferredType = args.typeOverride ?? (hasType ? parsed.type : inferTypeFromPath(filePath, root));
97
97
  const defaultStatus = DEFAULT_STATUS_BY_TYPE[inferredType] ?? 'draft';
98
98
  const inferredStatus = args.statusOverride ?? (hasStatus ? parsed.status : defaultStatus);