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/hud.mjs CHANGED
@@ -1,14 +1,15 @@
1
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
5
- import { asString, toRepoPath, currentSessionId } from './util.mjs';
4
+ import { currentSessionId, isArchivedPath } from './util.mjs';
6
5
  import { dim, yellow } from './color.mjs';
7
6
  import { buildIndex } from './index.mjs';
8
- import { refreshStaleSlashCommands } from './claude-commands.mjs';
9
7
  import { readJournalEntries, journalFilePath, readMisuseEntries } from './journal.mjs';
10
8
  import { compareVersions } from './update.mjs';
11
9
  import { findOwnedPlan } from './baton.mjs';
10
+ import { actionablePromptStatuses, comparePromptDocs, resolveStatusMetadata } from './status-metadata.mjs';
11
+
12
+ export { actionablePromptStatuses } from './status-metadata.mjs';
12
13
 
13
14
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
15
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
@@ -45,51 +46,14 @@ export function detectVersionDrift(env = process.env) {
45
46
  // for stripped-down configs). This means a user who customizes
46
47
  // types.prompt.statuses to add e.g. `urgent: { context: 'expanded' }` gets that
47
48
  // status surfaced too, without needing a code change.
48
- export function actionablePromptStatuses(config) {
49
- const promptCtx = config.typeContextConfig?.get('prompt');
50
- const expanded = promptCtx?.expanded;
51
- if (Array.isArray(expanded) && expanded.length > 0) return new Set(expanded);
52
- return new Set(['pending']);
53
- }
54
-
55
49
  // Returns repo paths, oldest-created first — the same order no-arg `dotmd use`
56
50
  // consumes them, so prompts[0] is always "the one you'd pick up next".
57
- function findActionablePrompts(config) {
58
- const roots = config.docsRoots || (config.docsRoot ? [config.docsRoot] : []);
59
- const archiveDir = config.archiveDir || 'archived';
51
+ function findActionablePrompts(config, index) {
60
52
  const actionable = actionablePromptStatuses(config);
61
- const found = [];
62
- const seen = new Set();
63
-
64
- for (const root of roots) {
65
- // A root may either contain a prompts/ subdir (the common case, e.g. root=docs)
66
- // or be the prompts/ dir itself (e.g. root=docs/prompts — see #6).
67
- const dir = path.basename(root) === 'prompts' ? root : path.join(root, 'prompts');
68
- if (seen.has(dir)) continue;
69
- seen.add(dir);
70
- if (!existsSync(dir)) continue;
71
- let entries;
72
- try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
73
- for (const entry of entries) {
74
- if (entry.isDirectory()) continue;
75
- if (!entry.name.endsWith('.md')) continue;
76
- const filePath = path.join(dir, entry.name);
77
- // Skip any nested archived/ collisions just in case
78
- if (filePath.includes(`/${archiveDir}/`)) continue;
79
- let raw;
80
- try { raw = readFileSync(filePath, 'utf8'); } catch { continue; }
81
- const { frontmatter } = extractFrontmatter(raw);
82
- if (!frontmatter) continue;
83
- const fm = parseSimpleFrontmatter(frontmatter);
84
- if (asString(fm.type) !== 'prompt') continue;
85
- if (!actionable.has(asString(fm.status))) continue;
86
- found.push({ path: toRepoPath(filePath, config.repoRoot), created: asString(fm.created) ?? '' });
87
- }
88
- }
89
-
90
- return found
91
- .sort((a, b) => a.created.localeCompare(b.created) || a.path.localeCompare(b.path))
92
- .map(p => p.path);
53
+ return index.docs
54
+ .filter(doc => doc.type === 'prompt' && actionable.has(doc.status) && !isArchivedPath(doc.path, config))
55
+ .sort(comparePromptDocs)
56
+ .map(doc => doc.path);
93
57
  }
94
58
 
95
59
  // F17b: hud reads journal. Three additive sections, gated on
@@ -230,27 +194,30 @@ export function buildMisuseRecap(config, now = Date.now()) {
230
194
  }
231
195
 
232
196
  export function buildHud(config) {
233
- const prompts = findActionablePrompts(config);
197
+ let prompts = [];
198
+ const skippedValidationHooks = ['validate', 'transformDoc', 'formatSnapshot']
199
+ .filter(name => typeof config.hooks?.[name] === 'function');
234
200
 
235
201
  // Validation error count — hud's "silent when clean" contract should treat
236
202
  // `check` errors as not-clean. Without this, a SessionStart hook firing hud
237
203
  // can leave the agent with no visible signal that a check is failing.
238
204
  // `errorsOnly: true` skips warning-only cross-doc passes (git staleness,
239
- // bidirectional refs, claude-commands) that hud never reads — ~6× faster on
240
- // SessionStart for platform-scale corpora. Per-file validation + checkIndex
241
- // still run, so the error count matches `dotmd check`'s.
242
- let errors = 0;
205
+ // bidirectional refs, claude-commands) that hud never reads. Built-in
206
+ // per-file validation + checkIndex still run; user hooks are deliberately
207
+ // suppressed because SessionStart is passive.
208
+ let builtInErrors = 0;
243
209
  // `owned` answers "which plan is THIS session's?" for programmatic callers
244
- // (the baton flow reads it) — derived from the journal, falling back to the
245
- // only in-session plan. Null when there's no defensible answer.
210
+ // (the baton flow reads it) — derived only from a valid durable ownership
211
+ // record. Null when ownership is absent, stale, corrupt, or ambiguous.
246
212
  let owned = null;
247
213
  try {
248
- // `autoHealIndex: true` mirrors `dotmd check` — drift from non-regen
249
- // mutation paths (`lint --fix`, direct file edits, etc.) heals silently
250
- // at SessionStart so the agent doesn't open every session with a
251
- // spurious "Run `dotmd index`" error in the hud error count.
252
- const index = buildIndex(config, { errorsOnly: true, autoHealIndex: true });
253
- errors = index.errors.length;
214
+ const index = buildIndex(config, {
215
+ errorsOnly: true,
216
+ autoHealIndex: false,
217
+ invokeHooks: false,
218
+ });
219
+ prompts = findActionablePrompts(config, index);
220
+ builtInErrors = index.errors.length;
254
221
  const o = findOwnedPlan(config, index);
255
222
  if (o.plan) owned = { path: o.plan.path, title: o.plan.title ?? null, via: o.via };
256
223
  } catch { /* swallow — bad config shouldn't break the SessionStart hook */ }
@@ -258,7 +225,20 @@ export function buildHud(config) {
258
225
  const { previousSelf, fleet, recentRejections } = buildJournalSections(config);
259
226
  const misuseRecap = buildMisuseRecap(config);
260
227
 
261
- return { owned, prompts, errors, previousSelf, fleet, recentRejections, misuseRecap };
228
+ const validationComplete = skippedValidationHooks.length === 0;
229
+ return {
230
+ owned,
231
+ prompts,
232
+ errors: validationComplete ? builtInErrors : null,
233
+ ...(validationComplete ? {} : {
234
+ builtInErrors,
235
+ validationPreview: { status: 'built-in-only', skippedHooks: skippedValidationHooks },
236
+ }),
237
+ previousSelf,
238
+ fleet,
239
+ recentRejections,
240
+ misuseRecap,
241
+ };
262
242
  }
263
243
 
264
244
  // Subagent primer: a spawned subagent (Explore, Plan, general-purpose) starts
@@ -270,10 +250,29 @@ export function buildHud(config) {
270
250
  const SUBAGENT_PRIMER = [
271
251
  'dotmd manages this repo\'s plans/docs/prompts (markdown + YAML frontmatter).',
272
252
  'Verbs: plans|briefing | query <filters> | use [<file>] | set <status> <file> | new <type> <slug> | archive <file>.',
273
- 'Do NOT: cat/read a docs/prompts/*.md (use `dotmd use <file>` — it prints + archives atomically);',
253
+ 'Do NOT: cat/read a docs/prompts/*.md (use `dotmd use <file>` — archive/claim commits before at-most-once output);',
274
254
  'git add/commit a prompt (they are session-local, often gitignored); hand-edit a `status:` field (use `dotmd set`).',
275
255
  ].join('\n');
276
256
 
257
+ export function buildPlanStatusPrimer(config, { maxChars = 220 } = {}) {
258
+ const statuses = (resolveStatusMetadata(config).byType.plan ?? []).map(item => item.name);
259
+ const fallback = 'run `dotmd statuses list --type plan`';
260
+ if (statuses.length === 0) return `Plan statuses unavailable; ${fallback}.`;
261
+ const prefix = 'Plan statuses: ';
262
+ const full = `${prefix}${statuses.join(', ')}`;
263
+ if (full.length <= maxChars) return full;
264
+
265
+ const shown = [];
266
+ for (const status of statuses) {
267
+ const omitted = statuses.length - shown.length - 1;
268
+ const candidate = `${prefix}${[...shown, status].join(', ')}, ... (+${omitted}; ${fallback})`;
269
+ if (candidate.length > maxChars) break;
270
+ shown.push(status);
271
+ }
272
+ const omitted = statuses.length - shown.length;
273
+ return `${prefix}${shown.join(', ')}${shown.length ? ', ' : ''}... (+${omitted}; ${fallback})`;
274
+ }
275
+
277
276
  // The plugin's SessionStart/SubagentStart hooks fire in EVERY repo (it's enabled
278
277
  // globally), but the primer only helps where dotmd is actually used. Gate on a
279
278
  // discovered config: `dotmd init` writes dotmd.config.mjs, so "has a config" is
@@ -296,6 +295,7 @@ export function runHud(argv, config) {
296
295
  if (argv.includes('--subagent')) {
297
296
  if (!dotmdRepo) return; // silent in repos that don't use dotmd
298
297
  process.stdout.write(dim(SUBAGENT_PRIMER) + '\n');
298
+ process.stdout.write(dim(buildPlanStatusPrimer(config)) + '\n');
299
299
  if (drift) process.stdout.write(yellow(drift) + '\n');
300
300
  return;
301
301
  }
@@ -306,17 +306,6 @@ export function runHud(argv, config) {
306
306
 
307
307
  const hud = buildHud(config);
308
308
 
309
- // Clean up retired generated slash-command files (the plugin skill replaces
310
- // them). Banner-gated, so hand-authored commands survive. Wrapped: teardown
311
- // must never kill the SessionStart hook (would block every session). Runs for
312
- // its side effect only — nothing is announced in stdout (see the primer-only
313
- // contract below). Skipped in --json mode to keep the structured shape stable
314
- // for programmatic callers.
315
- if (!json) {
316
- try { refreshStaleSlashCommands(config); }
317
- catch { /* swallow — see comment above */ }
318
- }
319
-
320
309
  if (json) {
321
310
  process.stdout.write(JSON.stringify({ ...hud, drift: drift ?? null }, null, 2) + '\n');
322
311
  return;
@@ -332,14 +321,14 @@ export function runHud(argv, config) {
332
321
  // next session ever picked up):
333
322
  // - pending prompts: the previous session queued work for THIS one;
334
323
  // consuming it is the very next action.
335
- // - an in-session plan attributed to this sid via the journal: this
324
+ // - an in-session plan attributed to this sid via durable ownership: this
336
325
  // session (pre-compaction) owns it and should continue or hand it off.
337
- // The single-in-session fallback is deliberately NOT printed — at
338
- // SessionStart that plan likely belongs to another live session.
326
+ // Global in-session counts never provide a fallback.
339
327
  // The misuse recap stays for the same reason: a repeat-offense rule means
340
328
  // the primer alone isn't landing, so name the habit to break.
341
329
  process.stdout.write(dim('dotmd: plans|briefing set <status> [<file>] new <type> <slug> use [<file>] archive <file> baton [<slug>] <@draft|-> (save a resume prompt; releases the in-session plan if any) (use [no-arg] → oldest pending prompt)') + '\n');
342
- if (hud.owned && hud.owned.via === 'journal') {
330
+ process.stdout.write(dim(buildPlanStatusPrimer(config)) + '\n');
331
+ if (hud.owned && hud.owned.via === 'ownership') {
343
332
  process.stdout.write(yellow(`[dotmd] in-session (yours): ${hud.owned.path} — continue it; hand off with \`dotmd baton @/tmp/draft.md\` before stopping.`) + '\n');
344
333
  }
345
334
  if (hud.prompts.length > 0) {
@@ -1,10 +1,11 @@
1
- import { readFileSync, writeFileSync } from 'node:fs';
1
+ import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { capitalize, escapeTable } from './util.mjs';
4
4
  import { formatSnapshot } from './render.mjs';
5
+ import { authorizeRepoGeneratedPath } from './managed-path.mjs';
6
+ import { mutateFile } from './atomic-mutation.mjs';
5
7
 
6
- export function renderIndexFile(index, config) {
7
- const current = readFileSync(config.indexPath, 'utf8');
8
+ export function renderIndexFile(index, config, current = readFileSync(config.indexPath, 'utf8')) {
8
9
  const start = current.indexOf(config.indexStartMarker);
9
10
  const end = current.indexOf(config.indexEndMarker);
10
11
 
@@ -86,8 +87,12 @@ function renderIndexSnapshot(doc, config, snapshotMode) {
86
87
  return capitalize(doc.status ?? 'unknown');
87
88
  }
88
89
 
89
- export function writeIndex(content, config) {
90
- writeFileSync(config.indexPath, content, 'utf8');
90
+ export function writeRenderedIndex(indexOrFactory, config, options = {}) {
91
+ authorizeRepoGeneratedPath(config.indexPath, config, { kind: 'Generated index destination' });
92
+ mutateFile(config.indexPath, { repoRoot: config.repoRoot, ...options }, current => {
93
+ const index = typeof indexOrFactory === 'function' ? indexOrFactory() : indexOrFactory;
94
+ return renderIndexFile(index, config, current);
95
+ });
91
96
  }
92
97
 
93
98
  // `autoHeal: true` rewrites the index in place when drift is detected and
@@ -102,12 +107,28 @@ export function writeIndex(content, config) {
102
107
  // filtered/synthetic docs list omit it to keep the old error semantics —
103
108
  // auto-overwriting from a partial doc list would clobber valid content.
104
109
  export function checkIndex(docs, config, opts = {}) {
105
- const { autoHeal = false } = opts;
110
+ const { autoHeal = false, rebuildDocs = null, testHooks = null } = opts;
106
111
  const warnings = [];
107
112
  const errors = [];
108
113
 
109
114
  if (!config.indexPath) return { warnings, errors };
110
115
 
116
+ if (autoHeal) {
117
+ try {
118
+ authorizeRepoGeneratedPath(config.indexPath, config, { kind: 'Generated index destination' });
119
+ const result = mutateFile(config.indexPath, { repoRoot: config.repoRoot, testHooks }, currentContent => {
120
+ const currentDocs = rebuildDocs ? rebuildDocs() : docs;
121
+ return renderIndexFile({ docs: currentDocs }, config, currentContent);
122
+ });
123
+ if (result.changed) {
124
+ warnings.push({ path: config.indexPath, level: 'warning', message: 'Auto-regenerated stale index block.' });
125
+ }
126
+ } catch (err) {
127
+ errors.push({ path: config.indexPath, level: 'error', message: `Could not auto-regenerate stale index block: ${err.message}` });
128
+ }
129
+ return { warnings, errors };
130
+ }
131
+
111
132
  const current = readFileSync(config.indexPath, 'utf8');
112
133
  const start = current.indexOf(config.indexStartMarker);
113
134
  const end = current.indexOf(config.indexEndMarker);
@@ -120,16 +141,7 @@ export function checkIndex(docs, config, opts = {}) {
120
141
  const index = { docs };
121
142
  const expected = renderIndexFile(index, config);
122
143
  if (expected !== current) {
123
- if (autoHeal) {
124
- try {
125
- writeFileSync(config.indexPath, expected, 'utf8');
126
- warnings.push({ path: config.indexPath, level: 'warning', message: 'Auto-regenerated stale index block.' });
127
- } catch (err) {
128
- errors.push({ path: config.indexPath, level: 'error', message: `Could not auto-regenerate stale index block: ${err.message}` });
129
- }
130
- } else {
131
- errors.push({ path: config.indexPath, level: 'error', message: 'Generated index block is stale. Run `dotmd index`.' });
132
- }
144
+ errors.push({ path: config.indexPath, level: 'error', message: 'Generated index block is stale. Run `dotmd index`.' });
133
145
  }
134
146
 
135
147
  return { warnings, errors };
package/src/index.mjs CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
4
  import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts, extractBodyLinks } from './extractors.mjs';
5
5
  import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn, die, resolveDocPath, suggestCandidates } from './util.mjs';
6
+ import { findLexicalDocsRoot } from './managed-path.mjs';
6
7
  import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, checkRunlistBackPointers, checkCoordinationHubExecutionMode, checkRoadmapHubExecutionMode, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
7
8
  import { checkIndex } from './index-file.mjs';
8
9
  import { checkClaudeCommands } from './claude-commands.mjs';
@@ -16,14 +17,16 @@ import { checkSkillDrift } from './skill-drift.mjs';
16
17
  // `dotmd check`). Saves the full-repo `git log` scan in `checkGitStaleness`
17
18
  // plus the bidirectional ref walk + claude-commands check.
18
19
  //
19
- // `errorsOnly: true` runs every error-producing pass (per-file `validateDoc`,
20
- // `checkIndex`, the `validate` hook) but skips the warning-only cross-doc
20
+ // `errorsOnly: true` runs every built-in error-producing pass (per-file
21
+ // `validateDoc`, `checkIndex`) but skips the warning-only cross-doc
21
22
  // passes (bidirectional refs, runlist back-pointers, git staleness, claude
22
23
  // commands). Use it from `dotmd hud` — the SessionStart hook only renders the
23
24
  // error COUNT, so the warning-only passes are pure overhead there. Preserves
24
- // the invariant that hud's "✗ N validation errors" line matches `dotmd check`.
25
+ // the built-in invariant that HUD's error count matches `dotmd check`.
25
26
  export function buildIndex(config, opts = {}) {
26
27
  const { fast = false, errorsOnly = false, autoHealIndex = false } = opts;
28
+ const invokeHooks = opts.invokeHooks ?? !config._execution?.suppressSideEffects;
29
+ const gitStaleness = opts.gitStaleness ?? config._execution?.gitStaleness ?? true;
27
30
  const skipWarningOnlyChecks = fast || errorsOnly;
28
31
  const docs = collectDocFiles(config).map(f => parseDocFile(f, config, { fast }));
29
32
  if (!fast) {
@@ -42,7 +45,7 @@ export function buildIndex(config, opts = {}) {
42
45
  errors.push(...doc.errors);
43
46
  }
44
47
 
45
- if (!fast && config.hooks.validate) {
48
+ if (!fast && invokeHooks && config.hooks.validate) {
46
49
  const ctx = { config, allDocs: docs, repoRoot: config.repoRoot };
47
50
  for (const doc of docs) {
48
51
  try {
@@ -63,7 +66,7 @@ export function buildIndex(config, opts = {}) {
63
66
  }
64
67
  }
65
68
 
66
- const transformedDocs = config.hooks.transformDoc
69
+ const transformedDocs = invokeHooks && config.hooks.transformDoc
67
70
  ? docs.map(d => {
68
71
  try { return config.hooks.transformDoc(d) ?? d; }
69
72
  catch (err) {
@@ -97,15 +100,19 @@ export function buildIndex(config, opts = {}) {
97
100
  }
98
101
 
99
102
  if (!fast && config.indexPath) {
100
- // `autoHealIndex` is opt-in from the caller (currently `dotmd check` and
101
- // `dotmd hud`). When true, drift triggers an in-place rewrite and a
103
+ // `autoHealIndex` is opt-in from the caller (currently `dotmd check`).
104
+ // When true, drift triggers an in-place rewrite and a
102
105
  // warning instead of the old "Run `dotmd index`" error — closing the
103
106
  // class of nags produced by mutation paths that skip `regenIndex`
104
107
  // (`lint --fix`, direct file edits, etc). `transformedDocs` here is
105
108
  // always the canonical full set; CLI-level `--root`/`--type` filtering
106
109
  // runs after `buildIndex` returns, so a rewrite is safe. Off by default
107
110
  // so dry-run / print modes never mutate disk as a side effect.
108
- const indexCheck = checkIndex(transformedDocs, config, { autoHeal: autoHealIndex });
111
+ const indexCheck = checkIndex(transformedDocs, config, {
112
+ autoHeal: autoHealIndex,
113
+ rebuildDocs: autoHealIndex ? () => buildIndex(config, { fast: true }).docs : null,
114
+ testHooks: opts.testHooks,
115
+ });
109
116
  warnings.push(...indexCheck.warnings);
110
117
  errors.push(...indexCheck.errors);
111
118
  }
@@ -135,8 +142,10 @@ export function buildIndex(config, opts = {}) {
135
142
  if (hub) hub.warnings.push(w);
136
143
  }
137
144
 
138
- const gitWarnings = checkGitStaleness(transformedDocs, config);
139
- warnings.push(...gitWarnings);
145
+ if (gitStaleness) {
146
+ const gitWarnings = checkGitStaleness(transformedDocs, config, opts.gitMetadataOptions);
147
+ warnings.push(...gitWarnings);
148
+ }
140
149
 
141
150
  const claudeWarnings = checkClaudeCommands(config.repoRoot);
142
151
  warnings.push(...claudeWarnings);
@@ -326,8 +335,7 @@ export function parseDocFile(filePath, config, opts = {}) {
326
335
  }
327
336
 
328
337
  // Tag doc with its root
329
- const roots = config.docsRoots || [config.docsRoot];
330
- const docRoot = roots.find(r => filePath.startsWith(r + '/')) ?? config.docsRoot;
338
+ const docRoot = findLexicalDocsRoot(filePath, config) ?? config.docsRoot;
331
339
  const rootLabel = path.relative(config.repoRoot, docRoot).split(path.sep).join('/');
332
340
 
333
341
  const docType = asString(parsedFrontmatter.type) ?? null;
@@ -365,7 +373,7 @@ export function parseDocFile(filePath, config, opts = {}) {
365
373
  hasNextStep: Boolean(nextStep),
366
374
  hasBlockers: blockers.length > 0,
367
375
  daysSinceUpdate: computeDaysSinceUpdate(asString(parsedFrontmatter.updated) ?? null),
368
- isStale: computeIsStale(asString(parsedFrontmatter.status), asString(parsedFrontmatter.updated) ?? null, config),
376
+ isStale: computeIsStale(asString(parsedFrontmatter.status), asString(parsedFrontmatter.updated) ?? null, config, docType),
369
377
  warnings: [],
370
378
  errors: [],
371
379
  };
package/src/init.mjs CHANGED
@@ -313,7 +313,7 @@ export async function runInit(cwd, config, opts = {}) {
313
313
  process.stdout.write(` export const root = [${subs.map(s => `'${s}'`).join(', ')}];\n`);
314
314
  }
315
315
 
316
- // .gitignore: ensure .dotmd/ is ignored (session leases live there)
316
+ // .gitignore: ensure .dotmd/ is ignored (session ownership records live there)
317
317
  const gitignorePath = path.join(cwd, '.gitignore');
318
318
  const ignoreLine = '.dotmd/';
319
319
  if (existsSync(gitignorePath)) {