dotmd-cli 0.69.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.
- package/README.md +144 -964
- package/bin/dotmd.mjs +238 -195
- package/dotmd.config.example.mjs +5 -8
- package/package.json +6 -10
- package/src/agent-context.mjs +132 -0
- package/src/atomic-mutation.mjs +1505 -0
- package/src/baton.mjs +109 -114
- package/src/bulk-tag.mjs +7 -7
- package/src/commands.mjs +326 -12
- package/src/completions.mjs +38 -98
- package/src/config.mjs +18 -3
- package/src/diff.mjs +7 -3
- package/src/doctor.mjs +12 -5
- package/src/export.mjs +154 -25
- package/src/fix-refs.mjs +2 -0
- package/src/frontmatter-fix.mjs +2 -0
- package/src/frontmatter.mjs +3 -2
- package/src/git.mjs +531 -14
- package/src/graph.mjs +53 -25
- package/src/guard.mjs +163 -60
- package/src/hud.mjs +65 -76
- package/src/index-file.mjs +28 -16
- package/src/index.mjs +17 -12
- package/src/init.mjs +1 -1
- package/src/journal.mjs +145 -12
- package/src/lifecycle.mjs +554 -282
- package/src/lint.mjs +3 -3
- package/src/managed-path.mjs +192 -0
- package/src/migrate-prompts.mjs +2 -0
- package/src/migrate-template.mjs +2 -0
- package/src/migrate.mjs +7 -1
- package/src/new.mjs +135 -54
- package/src/output-identity.mjs +106 -0
- package/src/pickup-card.mjs +24 -10
- package/src/pickup.mjs +457 -0
- package/src/prompts.mjs +134 -75
- package/src/query.mjs +22 -10
- package/src/reference-planner.mjs +292 -0
- package/src/rename.mjs +65 -73
- package/src/render.mjs +17 -8
- package/src/runlist.mjs +109 -71
- package/src/section.mjs +2 -1
- package/src/ship.mjs +39 -20
- package/src/stats.mjs +1 -1
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +11 -26
- package/src/summary.mjs +14 -3
- package/src/update.mjs +38 -10
- package/src/use.mjs +4 -1
- package/src/util.mjs +1 -0
- package/src/validate.mjs +14 -6
- package/src/watch.mjs +6 -1
- package/src/notion.mjs +0 -528
package/src/hud.mjs
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import { existsSync,
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import {
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
let
|
|
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
|
|
245
|
-
//
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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>` —
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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) {
|
package/src/index-file.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { readFileSync
|
|
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
|
|
90
|
-
|
|
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
|
-
|
|
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,15 @@ 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
|
|
20
|
-
// `
|
|
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
|
|
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;
|
|
27
29
|
const skipWarningOnlyChecks = fast || errorsOnly;
|
|
28
30
|
const docs = collectDocFiles(config).map(f => parseDocFile(f, config, { fast }));
|
|
29
31
|
if (!fast) {
|
|
@@ -42,7 +44,7 @@ export function buildIndex(config, opts = {}) {
|
|
|
42
44
|
errors.push(...doc.errors);
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
if (!fast && config.hooks.validate) {
|
|
47
|
+
if (!fast && invokeHooks && config.hooks.validate) {
|
|
46
48
|
const ctx = { config, allDocs: docs, repoRoot: config.repoRoot };
|
|
47
49
|
for (const doc of docs) {
|
|
48
50
|
try {
|
|
@@ -63,7 +65,7 @@ export function buildIndex(config, opts = {}) {
|
|
|
63
65
|
}
|
|
64
66
|
}
|
|
65
67
|
|
|
66
|
-
const transformedDocs = config.hooks.transformDoc
|
|
68
|
+
const transformedDocs = invokeHooks && config.hooks.transformDoc
|
|
67
69
|
? docs.map(d => {
|
|
68
70
|
try { return config.hooks.transformDoc(d) ?? d; }
|
|
69
71
|
catch (err) {
|
|
@@ -97,15 +99,19 @@ export function buildIndex(config, opts = {}) {
|
|
|
97
99
|
}
|
|
98
100
|
|
|
99
101
|
if (!fast && config.indexPath) {
|
|
100
|
-
// `autoHealIndex` is opt-in from the caller (currently `dotmd check`
|
|
101
|
-
//
|
|
102
|
+
// `autoHealIndex` is opt-in from the caller (currently `dotmd check`).
|
|
103
|
+
// When true, drift triggers an in-place rewrite and a
|
|
102
104
|
// warning instead of the old "Run `dotmd index`" error — closing the
|
|
103
105
|
// class of nags produced by mutation paths that skip `regenIndex`
|
|
104
106
|
// (`lint --fix`, direct file edits, etc). `transformedDocs` here is
|
|
105
107
|
// always the canonical full set; CLI-level `--root`/`--type` filtering
|
|
106
108
|
// runs after `buildIndex` returns, so a rewrite is safe. Off by default
|
|
107
109
|
// so dry-run / print modes never mutate disk as a side effect.
|
|
108
|
-
const indexCheck = checkIndex(transformedDocs, config, {
|
|
110
|
+
const indexCheck = checkIndex(transformedDocs, config, {
|
|
111
|
+
autoHeal: autoHealIndex,
|
|
112
|
+
rebuildDocs: autoHealIndex ? () => buildIndex(config, { fast: true }).docs : null,
|
|
113
|
+
testHooks: opts.testHooks,
|
|
114
|
+
});
|
|
109
115
|
warnings.push(...indexCheck.warnings);
|
|
110
116
|
errors.push(...indexCheck.errors);
|
|
111
117
|
}
|
|
@@ -135,7 +141,7 @@ export function buildIndex(config, opts = {}) {
|
|
|
135
141
|
if (hub) hub.warnings.push(w);
|
|
136
142
|
}
|
|
137
143
|
|
|
138
|
-
const gitWarnings = checkGitStaleness(transformedDocs, config);
|
|
144
|
+
const gitWarnings = checkGitStaleness(transformedDocs, config, opts.gitMetadataOptions);
|
|
139
145
|
warnings.push(...gitWarnings);
|
|
140
146
|
|
|
141
147
|
const claudeWarnings = checkClaudeCommands(config.repoRoot);
|
|
@@ -326,8 +332,7 @@ export function parseDocFile(filePath, config, opts = {}) {
|
|
|
326
332
|
}
|
|
327
333
|
|
|
328
334
|
// Tag doc with its root
|
|
329
|
-
const
|
|
330
|
-
const docRoot = roots.find(r => filePath.startsWith(r + '/')) ?? config.docsRoot;
|
|
335
|
+
const docRoot = findLexicalDocsRoot(filePath, config) ?? config.docsRoot;
|
|
331
336
|
const rootLabel = path.relative(config.repoRoot, docRoot).split(path.sep).join('/');
|
|
332
337
|
|
|
333
338
|
const docType = asString(parsedFrontmatter.type) ?? null;
|
|
@@ -365,7 +370,7 @@ export function parseDocFile(filePath, config, opts = {}) {
|
|
|
365
370
|
hasNextStep: Boolean(nextStep),
|
|
366
371
|
hasBlockers: blockers.length > 0,
|
|
367
372
|
daysSinceUpdate: computeDaysSinceUpdate(asString(parsedFrontmatter.updated) ?? null),
|
|
368
|
-
isStale: computeIsStale(asString(parsedFrontmatter.status), asString(parsedFrontmatter.updated) ?? null, config),
|
|
373
|
+
isStale: computeIsStale(asString(parsedFrontmatter.status), asString(parsedFrontmatter.updated) ?? null, config, docType),
|
|
369
374
|
warnings: [],
|
|
370
375
|
errors: [],
|
|
371
376
|
};
|
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
|
|
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)) {
|