dotmd-cli 0.69.0 → 0.70.1

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 (54) hide show
  1. package/README.md +144 -964
  2. package/bin/dotmd.mjs +251 -202
  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 -114
  8. package/src/bulk-tag.mjs +7 -7
  9. package/src/check-collapse.mjs +2 -2
  10. package/src/commands.mjs +326 -12
  11. package/src/completions.mjs +38 -98
  12. package/src/config.mjs +18 -3
  13. package/src/diff.mjs +7 -3
  14. package/src/doctor.mjs +25 -15
  15. package/src/export.mjs +154 -25
  16. package/src/fix-refs.mjs +2 -0
  17. package/src/frontmatter-fix.mjs +9 -7
  18. package/src/frontmatter.mjs +3 -2
  19. package/src/git.mjs +722 -14
  20. package/src/graph.mjs +53 -25
  21. package/src/guard.mjs +163 -60
  22. package/src/hud.mjs +65 -76
  23. package/src/index-file.mjs +28 -16
  24. package/src/index.mjs +21 -13
  25. package/src/init.mjs +1 -1
  26. package/src/journal.mjs +145 -12
  27. package/src/lifecycle.mjs +596 -294
  28. package/src/lint.mjs +117 -56
  29. package/src/managed-path.mjs +192 -0
  30. package/src/migrate-prompts.mjs +2 -0
  31. package/src/migrate-template.mjs +2 -0
  32. package/src/migrate.mjs +7 -1
  33. package/src/new.mjs +135 -54
  34. package/src/output-identity.mjs +106 -0
  35. package/src/pickup-card.mjs +24 -10
  36. package/src/pickup.mjs +457 -0
  37. package/src/prompts.mjs +134 -75
  38. package/src/query.mjs +22 -10
  39. package/src/reference-planner.mjs +292 -0
  40. package/src/rename.mjs +65 -73
  41. package/src/render.mjs +24 -11
  42. package/src/runlist.mjs +109 -71
  43. package/src/section.mjs +2 -1
  44. package/src/ship.mjs +39 -20
  45. package/src/stats.mjs +1 -1
  46. package/src/status-metadata.mjs +87 -0
  47. package/src/statuses.mjs +11 -26
  48. package/src/summary.mjs +14 -3
  49. package/src/update.mjs +38 -10
  50. package/src/use.mjs +4 -1
  51. package/src/util.mjs +1 -0
  52. package/src/validate.mjs +53 -17
  53. package/src/watch.mjs +6 -1
  54. package/src/notion.mjs +0 -528
package/src/baton.mjs CHANGED
@@ -1,13 +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, updateFrontmatter } from './lifecycle.mjs';
9
- import { resolvePromptInput } from './prompts.mjs';
6
+ import { preparePromptDocument, runNew, readBodyInput, readPipedBodyInput } from './new.mjs';
7
+ import { ensurePlanCompletionBeforeRelease, planHasPendingCompletion, runSet } from './lifecycle.mjs';
10
8
  import { green, dim } from './color.mjs';
9
+ import { authorizeManagedSource } from './managed-path.mjs';
10
+ import { assertPlanMutationAuthorized, authoritativeSessionId, listOwnedPlans, readPlanOwnership } from './pickup.mjs';
11
11
 
12
12
  // `dotmd baton` is the one-command handoff: save the resume prompt AND release
13
13
  // the plan in a single atomic-ish verb. It exists because the three-step skill
@@ -16,63 +16,14 @@ import { green, dim } from './color.mjs';
16
16
  // tangled in what to commit. Baton does exactly one plan, one prompt, one
17
17
  // status flip, and then *tells* the agent the exact commit command.
18
18
 
19
- // Does a journal argv doc reference point at this index doc? References come
20
- // from `use <x>` / `set in-session <x>` invocations, so they may be a repo
21
- // path, a bare basename, or a slug without .md.
22
- function matchesDocRef(doc, ref) {
23
- if (typeof ref !== 'string' || !ref) return false;
24
- const cleaned = ref.replace(/^\.\//, '');
25
- if (doc.path === cleaned) return true;
26
- const base = path.basename(doc.path, '.md');
27
- if (cleaned === base || cleaned === `${base}.md`) return true;
28
- return doc.path.endsWith(`/${cleaned}`) || doc.path.endsWith(`/${cleaned}.md`);
29
- }
30
-
31
- // Resolve which in-session plan belongs to THIS session. There is no checkout
32
- // or lock — in-session is just frontmatter — so ownership is reconstructed
33
- // from the per-repo journal: the last `use <plan>` / `set in-session <plan>`
34
- // this sid ran whose target is still in-session. Falls back to "the only
35
- // in-session plan" when the journal is *silent* for this session (disabled, or
36
- // another tool flipped the status). Returns { plan, via, inSession }; plan is
37
- // null when there's no defensible answer (caller decides how to ask).
38
19
  export function findOwnedPlan(config, index = null) {
39
20
  const idx = index ?? buildIndex(config);
40
21
  const inSession = idx.docs.filter(d => d.type === 'plan' && d.status === 'in-session');
41
- if (inSession.length === 0) return { plan: null, via: null, inSession };
42
-
43
- const sid = currentSessionId();
44
- let entries = [];
45
- try { entries = readJournalEntries(config); } catch { entries = []; }
46
- // Did THIS session issue any ownership command at all (`use` / `set in-session`
47
- // / `status … in-session`)? If it did but none matched an in-session plan, this
48
- // session's work lives elsewhere (e.g. it consumed a `use <prompt>`, or worked
49
- // a plan that's since been released) — so the lone in-session plan is
50
- // presumptively *another* session's, and auto-selecting it would flip a
51
- // stranger's status. Track that to gate the single-in-session fast path below.
52
- let sawOwnershipRef = false;
53
- for (let i = entries.length - 1; i >= 0; i--) {
54
- const e = entries[i];
55
- if (e?.sid !== sid || !Array.isArray(e.argv) || (e.exit ?? 0) !== 0) continue;
56
- const a = e.argv;
57
- let ref = null;
58
- if (a[0] === 'use') ref = a.slice(1).find(x => typeof x === 'string' && !x.startsWith('-'));
59
- else if (a[0] === 'set' && a[1] === 'in-session') ref = a.slice(2).find(x => typeof x === 'string' && !x.startsWith('-'));
60
- else if (a[0] === 'status' && a.includes('in-session')) ref = a.slice(1).find(x => typeof x === 'string' && !x.startsWith('-') && x !== 'in-session');
61
- if (!ref) continue;
62
- sawOwnershipRef = true;
63
- const doc = inSession.find(d => matchesDocRef(d, ref));
64
- if (doc) return { plan: doc, via: 'journal', inSession };
65
- }
66
-
67
- // Single-in-session fast path — ONLY when the journal is silent about this
68
- // session's ownership intent. If this sid *did* run an ownership command that
69
- // pointed somewhere other than the lone in-session plan, refuse and let the
70
- // caller demand an explicit slug/plan rather than hand off a plan this session
71
- // never touched (the cross-session baton misfire).
72
- if (inSession.length === 1 && !sawOwnershipRef) {
73
- return { plan: inSession[0], via: 'single-in-session', inSession };
74
- }
75
- 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 ?? [] };
76
27
  }
77
28
 
78
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:
@@ -89,17 +40,21 @@ function looksLikePath(arg) {
89
40
 
90
41
  export async function runBaton(argv, config, opts = {}) {
91
42
  const { dryRun } = opts;
43
+ const json = argv.includes('--json');
92
44
 
93
45
  let status = 'active';
94
46
  let statusFlag = false;
95
47
  let note = null;
96
48
  let bodyFlag = null;
49
+ let force = false;
97
50
  const positionals = [];
98
51
  for (let i = 0; i < argv.length; i++) {
99
52
  const a = argv[i];
100
53
  if (a === '--status' && argv[i + 1]) { status = argv[++i]; statusFlag = true; continue; }
101
54
  if (a === '--note' && argv[i + 1]) { note = argv[++i]; continue; }
102
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;
103
58
  if (!a.startsWith('-') || a === '-' || a.startsWith('@')) { positionals.push(a); continue; }
104
59
  die(`Unknown flag for \`dotmd baton\`: ${a}`);
105
60
  }
@@ -119,13 +74,7 @@ export async function runBaton(argv, config, opts = {}) {
119
74
  else if (bodyArg !== null) body = readBodyInput(bodyArg);
120
75
  else {
121
76
  // Auto-consume piped/redirected stdin, same probe as `dotmd new`.
122
- try {
123
- const stat = fstatSync(0);
124
- if (stat.isFIFO() || stat.isFile() || stat.isSocket()) {
125
- const piped = readFileSync(0, 'utf8');
126
- if (piped.length > 0) body = piped;
127
- }
128
- } catch { /* stdin not introspectable */ }
77
+ body = readPipedBodyInput();
129
78
  }
130
79
  if (!body || !body.trim()) die(BODY_USAGE);
131
80
 
@@ -157,19 +106,19 @@ export async function runBaton(argv, config, opts = {}) {
157
106
  const owned = findOwnedPlan(config);
158
107
  if (owned.plan) {
159
108
  planPath = path.resolve(config.repoRoot, owned.plan.path);
160
- if (owned.via === 'single-in-session') {
161
- process.stderr.write(dim(`Handing off the only in-session plan: ${owned.plan.path}\n`));
162
- }
163
- } else if (owned.inSession.length > 1) {
164
- 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')}`);
165
111
  } else {
166
- 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}`);
167
114
  }
168
115
  }
169
116
 
170
117
  let repoPath = null;
171
118
  let oldStatus = null;
119
+ let ownershipPath = null;
172
120
  if (planPath) {
121
+ planPath = authorizeManagedSource(planPath, config, { kind: 'Baton plan source' }).path;
173
122
  repoPath = toRepoPath(planPath, config.repoRoot);
174
123
  const raw = readFileSync(planPath, 'utf8');
175
124
  const { frontmatter: fmRaw } = extractFrontmatter(raw);
@@ -187,64 +136,108 @@ export async function runBaton(argv, config, opts = {}) {
187
136
  if (validStatuses && validStatuses.size > 0 && !validStatuses.has(status)) {
188
137
  die(`Invalid status \`${status}\` for type \`${docType ?? 'plan'}\`\nValid: ${[...validStatuses].join(', ')}`);
189
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`);
190
146
  } else {
191
147
  if (statusFlag) warn(`--status ignored — no plan involved in this handoff (saving the prompt only).`);
192
148
  if (note) warn(`--note ignored — no plan involved in this handoff (notes land in a plan's Version History).`);
193
149
  }
194
150
 
195
- // 1. Save the resume prompt. Collision-safe: resume-<slug>, then -2, -3, …
196
- // (a pending resume-<slug> from an earlier handoff must never block this one,
197
- // 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.
198
153
  const nameBase = planPath ? path.basename(planPath, '.md') : promptSlug;
199
154
  const slugBase = nameBase.startsWith('resume-') ? nameBase : `resume-${nameBase}`;
200
155
  let createdSlug = null;
201
- for (let n = 1; n <= 9 && !createdSlug; n++) {
202
- const slug = n === 1 ? slugBase : `${slugBase}-${n}`;
203
- try {
204
- await runNew(['prompt', slug, '--body', body], config, { dryRun });
205
- createdSlug = slug;
206
- } catch (err) {
207
- if (!/File already exists/.test(String(err?.message))) throw err;
208
- }
209
- }
210
- if (!createdSlug) die(`Could not find a free prompt slug for ${slugBase} (tried ${slugBase}-2 … ${slugBase}-9).`);
211
-
212
- // Link the prompt back to its plan so the next session's `dotmd use` re-claims
213
- // it (consume = claim). The resume-<slug> filename is a lossy link under -N
214
- // suffixing / plan renames; the explicit `plan:` field is the durable one.
215
- // Best-effort: a resolve/write hiccup must never fail an otherwise-good handoff.
216
- if (planPath && !dryRun) {
217
- try {
218
- const promptPath = resolvePromptInput(createdSlug, config, { dieOnMiss: false });
219
- if (promptPath) updateFrontmatter(promptPath, { plan: repoPath });
220
- } catch { /* stamping is best-effort */ }
221
- }
222
-
223
- // 2. Release the plan — exactly one status flip. Skipped entirely in slug
224
- // mode: with no plan involved there is nothing to release.
225
156
  let archiveResult = null;
226
157
  let statusChanged = false;
227
- if (planPath) {
228
- if (oldStatus === status) {
229
- process.stderr.write(dim(`Plan already ${status}: ${repoPath} (no status change)\n`));
230
- } 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;
231
180
  const setArgs = [status, planPath];
181
+ if (force) setArgs.push('--force');
232
182
  if (note) setArgs.push('--note', note);
233
- // viaBaton: this release IS the handoff — don't let runSet nudge "leave a
234
- // baton" on top of the baton we're in the middle of.
235
- archiveResult = await runSet(setArgs, config, { dryRun, viaBaton: true });
236
- 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
+ }
237
202
  }
238
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
+ };
239
232
 
240
- // 3. Tell the agent exactly what to commit — and what NOT to. The prompt is
241
- // session-local (often gitignored); only the plan's frontmatter change is
242
- // repo state.
243
233
  const prefix = dryRun ? dim('[dry-run] ') : '';
234
+ if (json) {
235
+ process.stdout.write(JSON.stringify(operationResult, null, 2) + '\n');
236
+ return operationResult;
237
+ }
244
238
  process.stderr.write(`\n${prefix}${green('✓ Baton passed')}: ${createdSlug} (the next session's hud surfaces it — nothing to paste into chat)\n`);
245
239
  if (statusChanged) {
246
- const newRepoPath = archiveResult?.newRepoPath ?? null;
247
- const pathspec = newRepoPath && newRepoPath !== repoPath ? `${repoPath} ${newRepoPath}` : repoPath;
240
+ const pathspec = operationResult.repositoryFiles.join(' ');
248
241
  let gitignored = false;
249
242
  try {
250
243
  const { isGitIgnored } = await import('./git.mjs');
@@ -253,8 +246,10 @@ export async function runBaton(argv, config, opts = {}) {
253
246
  if (gitignored) {
254
247
  process.stderr.write(dim(`${repoPath} is gitignored — no commit needed.\n`));
255
248
  } else {
256
- 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`);
257
250
  process.stderr.write(`${prefix} git commit -m "baton: ${path.basename(planPath, '.md')} ${oldStatus} → ${status}" -- ${pathspec}\n`);
258
251
  }
259
252
  }
253
+ if (operationResult.deferredGeneratedFiles.length) process.stderr.write(dim(`Generated index deferred: ${operationResult.deferredGeneratedFiles.join(', ')}\n`));
254
+ return operationResult;
260
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);
@@ -18,13 +18,13 @@ const CATEGORIES = [
18
18
  },
19
19
  {
20
20
  key: 'singular-module',
21
- match: /^`module:` \(singular\) is deprecated/,
21
+ match: /^`module:` \(singular\) is deprecated.*Run `dotmd lint --fix`/,
22
22
  label: 'docs use deprecated singular `module:`',
23
23
  fix: 'dotmd lint --fix',
24
24
  },
25
25
  {
26
26
  key: 'singular-surface',
27
- match: /^`surface:` \(singular\) is deprecated/,
27
+ match: /^`surface:` \(singular\) is deprecated.*Run `dotmd lint --fix`/,
28
28
  label: 'docs use deprecated singular `surface:`',
29
29
  fix: 'dotmd lint --fix',
30
30
  },