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.
- package/README.md +144 -964
- package/bin/dotmd.mjs +241 -197
- 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 -87
- 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 +57 -9
- 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 +138 -32
- 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/prompts.mjs
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
import { readFileSync,
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
4
|
import { asString, toRepoPath, die, resolveDocPath, isArchivedPath } from './util.mjs';
|
|
5
|
-
import { buildIndex } from './index.mjs';
|
|
5
|
+
import { buildIndex, resolveDocArg } from './index.mjs';
|
|
6
6
|
import { runQuery } from './query.mjs';
|
|
7
|
-
import { runArchive, runStatus } from './lifecycle.mjs';
|
|
7
|
+
import { completePlanClaim, regenIndex, renderLifecycleMutation, runArchive, runStatus } from './lifecycle.mjs';
|
|
8
8
|
import { runNew } from './new.mjs';
|
|
9
9
|
import { green, dim } from './color.mjs';
|
|
10
|
+
import { authorizeManagedSource } from './managed-path.mjs';
|
|
11
|
+
import {
|
|
12
|
+
authoritativeSessionId,
|
|
13
|
+
classifyPlanPickup,
|
|
14
|
+
preparePlanClaim,
|
|
15
|
+
readPlanOwnership,
|
|
16
|
+
} from './pickup.mjs';
|
|
17
|
+
import { actionablePromptStatuses, comparePromptDocs } from './status-metadata.mjs';
|
|
10
18
|
|
|
11
19
|
// `resume` is an alias for `use` — agents reach for "resume" when continuing a
|
|
12
20
|
// session; `use` reads as internal mechanics. Both names stay valid; the
|
|
@@ -125,10 +133,10 @@ function slugToPlanPath(s, config) {
|
|
|
125
133
|
// Resolve a markdown body link relative to the prompt's location so e.g.
|
|
126
134
|
// `../plans/foo.md` from docs/prompts/x.md → docs/plans/foo.md.
|
|
127
135
|
function resolveBodyLink(link, promptRepoPath) {
|
|
128
|
-
const cleaned = link.replace(/#.*$/, '');
|
|
136
|
+
const cleaned = link.replace(/#.*$/, '').replaceAll('\\', '/');
|
|
129
137
|
if (cleaned.startsWith('/')) return cleaned.replace(/^\/+/, '');
|
|
130
|
-
const promptDir = path.dirname(promptRepoPath);
|
|
131
|
-
return path.normalize(path.join(promptDir, cleaned));
|
|
138
|
+
const promptDir = path.posix.dirname(promptRepoPath.replaceAll('\\', '/'));
|
|
139
|
+
return path.posix.normalize(path.posix.join(promptDir, cleaned));
|
|
132
140
|
}
|
|
133
141
|
|
|
134
142
|
function renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived }) {
|
|
@@ -158,36 +166,27 @@ function renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived })
|
|
|
158
166
|
}
|
|
159
167
|
|
|
160
168
|
export function pendingPromptsOldestFirst(config) {
|
|
161
|
-
const index = buildIndex(config);
|
|
169
|
+
const index = buildIndex(config, { fast: true, invokeHooks: false });
|
|
170
|
+
const actionable = actionablePromptStatuses(config);
|
|
162
171
|
const prompts = index.docs.filter(d =>
|
|
163
172
|
d.type === 'prompt'
|
|
164
|
-
&& d.status
|
|
173
|
+
&& actionable.has(d.status)
|
|
165
174
|
&& !isArchivedPath(d.path, config),
|
|
166
175
|
);
|
|
167
176
|
|
|
168
177
|
return prompts
|
|
169
|
-
.
|
|
170
|
-
|
|
171
|
-
let mtime = 0;
|
|
172
|
-
try { mtime = abs ? statSync(abs).mtimeMs : 0; } catch { mtime = 0; }
|
|
173
|
-
return { doc: d, abs, created: d.created ?? '', mtime };
|
|
174
|
-
})
|
|
175
|
-
.sort((a, b) => {
|
|
176
|
-
if (a.created && b.created && a.created !== b.created) return a.created.localeCompare(b.created);
|
|
177
|
-
if (a.created && !b.created) return -1;
|
|
178
|
-
if (!a.created && b.created) return 1;
|
|
179
|
-
return a.mtime - b.mtime;
|
|
180
|
-
});
|
|
178
|
+
.sort(comparePromptDocs)
|
|
179
|
+
.map(d => ({ doc: d, abs: resolveDocPath(d.path, config), created: d.created ?? '' }));
|
|
181
180
|
}
|
|
182
181
|
|
|
183
|
-
function runPromptsNext(argv, config, opts = {}) {
|
|
182
|
+
async function runPromptsNext(argv, config, opts = {}) {
|
|
184
183
|
const queue = pendingPromptsOldestFirst(config);
|
|
185
184
|
if (queue.length === 0) {
|
|
186
185
|
die('No pending prompts.');
|
|
187
186
|
}
|
|
188
187
|
const head = queue[0];
|
|
189
188
|
if (!head.abs) die(`Could not resolve path: ${head.doc.path}`);
|
|
190
|
-
consumePrompt(head.abs, config, opts);
|
|
189
|
+
return consumePrompt(head.abs, config, opts);
|
|
191
190
|
}
|
|
192
191
|
|
|
193
192
|
// Resolve user input to a prompt path. Tries (in order): exact path,
|
|
@@ -204,7 +203,7 @@ export function resolvePromptInput(input, config, options = {}) {
|
|
|
204
203
|
if (withExt) return withExt;
|
|
205
204
|
}
|
|
206
205
|
|
|
207
|
-
const index = buildIndex(config);
|
|
206
|
+
const index = buildIndex(config, { fast: true, invokeHooks: false });
|
|
208
207
|
const prompts = index.docs.filter(d => d.type === 'prompt');
|
|
209
208
|
if (prompts.length === 0) {
|
|
210
209
|
if (dieOnMiss) die(`No prompts in the index.`);
|
|
@@ -231,17 +230,18 @@ export function resolvePromptInput(input, config, options = {}) {
|
|
|
231
230
|
return null;
|
|
232
231
|
}
|
|
233
232
|
|
|
234
|
-
function runPromptsUse(argv, config, opts = {}) {
|
|
233
|
+
async function runPromptsUse(argv, config, opts = {}) {
|
|
235
234
|
const input = argv.find(a => !a.startsWith('-'));
|
|
236
235
|
if (!input) die('Usage: dotmd prompts use <file-or-slug>');
|
|
237
236
|
const noIndex = argv.includes('--no-index') || opts.noIndex;
|
|
238
237
|
const showFiles = argv.includes('--show-files') || opts.showFiles;
|
|
239
238
|
const filePath = resolvePromptInput(input, config);
|
|
240
|
-
consumePrompt(filePath, config, { ...opts, noIndex, showFiles });
|
|
239
|
+
return consumePrompt(filePath, config, { ...opts, noIndex, showFiles });
|
|
241
240
|
}
|
|
242
241
|
|
|
243
|
-
export function consumePrompt(filePath, config, opts) {
|
|
242
|
+
export async function consumePrompt(filePath, config, opts) {
|
|
244
243
|
const { dryRun, noIndex, showFiles } = opts;
|
|
244
|
+
filePath = authorizeManagedSource(filePath, config, { kind: 'Prompt consumption source' }).path;
|
|
245
245
|
const raw = readFileSync(filePath, 'utf8');
|
|
246
246
|
const { frontmatter, body } = extractFrontmatter(raw);
|
|
247
247
|
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
@@ -256,6 +256,10 @@ export function consumePrompt(filePath, config, opts) {
|
|
|
256
256
|
die(`Already consumed: ${repoPath}`);
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
const planRef = asString(parsed.plan);
|
|
260
|
+
let linkedClaim = null;
|
|
261
|
+
if (planRef) linkedClaim = prepareLinkedPromptClaim(planRef, config);
|
|
262
|
+
|
|
259
263
|
if (dryRun) {
|
|
260
264
|
const prefix = dim('[dry-run]');
|
|
261
265
|
process.stderr.write(`${prefix} Would emit body and archive: ${repoPath} (${status ?? 'unknown'} → archived)\n`);
|
|
@@ -274,15 +278,117 @@ export function consumePrompt(filePath, config, opts) {
|
|
|
274
278
|
// hook crash, anything), the body must not have already gone to stdout —
|
|
275
279
|
// otherwise `claude "$(dotmd prompts next)"` consumes the prompt without it
|
|
276
280
|
// ever being archived, and the next session sees the same prompt as pending.
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
281
|
+
const archiveResult = runArchive([filePath], config, {
|
|
282
|
+
noIndex,
|
|
283
|
+
showFiles,
|
|
284
|
+
out: process.stderr,
|
|
285
|
+
testHooks: opts.testHooks,
|
|
286
|
+
deferIndex: Boolean(linkedClaim),
|
|
287
|
+
additionalUpdates: linkedClaim?.prepared?.updates,
|
|
288
|
+
creations: linkedClaim?.prepared?.creations,
|
|
289
|
+
});
|
|
290
|
+
const consumedBody = archiveResult?.consumedBody ?? body;
|
|
283
291
|
|
|
284
292
|
const consumedPath = archiveResult?.newRepoPath ?? repoPath;
|
|
293
|
+
// Consume output is at-most-once: archive/claim commits before stdout. A
|
|
294
|
+
// downstream failure cannot roll the transaction back or make it consumable
|
|
295
|
+
// again; the archived path remains available through `prompts show`.
|
|
296
|
+
await writeConsumedBody(consumedBody, consumedPath, opts.writeBody, linkedClaim?.repoPath);
|
|
297
|
+
let completion = { indexRegenerated: false, ownershipChanged: false, hook: 'none', pending: false };
|
|
298
|
+
if (linkedClaim) {
|
|
299
|
+
try {
|
|
300
|
+
completion = completePlanClaim(linkedClaim.repoPath, config, opts);
|
|
301
|
+
if (!noIndex && config.indexPath && !completion.indexRegenerated) {
|
|
302
|
+
regenIndex(config, { throwOnError: true, testHooks: opts.testHooks });
|
|
303
|
+
completion.indexRegenerated = true;
|
|
304
|
+
}
|
|
305
|
+
} catch (err) {
|
|
306
|
+
throw new Error(`Prompt consumed and body delivered; linked claim completion remains pending for ${linkedClaim.repoPath}: ${err.message}`);
|
|
307
|
+
}
|
|
308
|
+
process.stderr.write(`${green('→ Claimed')}: ${linkedClaim.repoPath} (in-session)\n`);
|
|
309
|
+
}
|
|
285
310
|
process.stderr.write(`${green('✓ Consumed')}: ${consumedPath}\n`);
|
|
311
|
+
const ownershipRecordPath = linkedClaim?.prepared?.recordPath ?? (linkedClaim ? readPlanOwnership(linkedClaim.repoPath, config)?.recordPath : null);
|
|
312
|
+
const ownershipPath = ownershipRecordPath ? toRepoPath(ownershipRecordPath, config.repoRoot) : null;
|
|
313
|
+
const normalize = candidate => path.isAbsolute(candidate) ? toRepoPath(candidate, config.repoRoot) : candidate.split(path.sep).join('/');
|
|
314
|
+
const resultPaths = [
|
|
315
|
+
...(linkedClaim?.planChanged ? [linkedClaim.repoPath] : []),
|
|
316
|
+
...(archiveResult?.referencePaths ?? []),
|
|
317
|
+
].filter(Boolean).map(normalize);
|
|
318
|
+
const ownershipResultPaths = resultPaths.filter(candidate => candidate.startsWith('.dotmd/ownership/'));
|
|
319
|
+
const repositoryFiles = [...new Set(resultPaths.filter(candidate => !candidate.startsWith('.dotmd/ownership/')))];
|
|
320
|
+
const sessionFiles = [...new Set([
|
|
321
|
+
repoPath,
|
|
322
|
+
consumedPath,
|
|
323
|
+
(linkedClaim?.prepared || completion.ownershipChanged) ? ownershipPath : null,
|
|
324
|
+
...ownershipResultPaths,
|
|
325
|
+
].filter(Boolean).map(normalize))];
|
|
326
|
+
return {
|
|
327
|
+
operation: 'consume',
|
|
328
|
+
status: { from: status ?? null, to: 'archived', changed: true },
|
|
329
|
+
repositoryFiles,
|
|
330
|
+
sessionFiles,
|
|
331
|
+
generatedFiles: config.indexPath && !noIndex && (completion.indexRegenerated || archiveResult?.indexRegenerated) ? [toRepoPath(config.indexPath, config.repoRoot)] : [],
|
|
332
|
+
deferredGeneratedFiles: config.indexPath && noIndex ? [toRepoPath(config.indexPath, config.repoRoot)] : [],
|
|
333
|
+
claim: linkedClaim ? { plan: linkedClaim.repoPath, changed: linkedClaim.planChanged, pendingCompletion: completion.pending, hook: completion.hook } : null,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export async function writeConsumedBody(body, archivedPath, write = null, linkedPlan = null) {
|
|
338
|
+
const content = body.endsWith('\n') ? body : `${body}\n`;
|
|
339
|
+
try {
|
|
340
|
+
if (write) {
|
|
341
|
+
const accepted = await write(content);
|
|
342
|
+
if (accepted === false) throw Object.assign(new Error('writer reported unresolved backpressure'), { code: 'BACKPRESSURE' });
|
|
343
|
+
} else {
|
|
344
|
+
await new Promise((resolve, reject) => process.stdout.write(content, err => err ? reject(err) : resolve()));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
const completion = linkedPlan ? ` Then complete the linked claim with \`dotmd use ${linkedPlan}\`.` : '';
|
|
349
|
+
throw new Error(`Prompt was consumed but body output failed (${err.code ?? err.message}). It will not be emitted again; recover it with \`dotmd prompts show ${archivedPath}\`.${completion}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function prepareLinkedPromptClaim(planRef, config) {
|
|
354
|
+
let planPath = resolveDocPath(planRef, config) ?? resolveDocArg(planRef, config, { dieOnMiss: false });
|
|
355
|
+
if (!planPath || !existsSync(planPath)) die(`Linked plan is missing; prompt was not consumed: ${planRef}`);
|
|
356
|
+
planPath = authorizeManagedSource(planPath, config, { kind: 'Prompt linked plan source' }).path;
|
|
357
|
+
const raw = readFileSync(planPath, 'utf8');
|
|
358
|
+
let parsed;
|
|
359
|
+
try { parsed = parseSimpleFrontmatter(extractFrontmatter(raw).frontmatter); }
|
|
360
|
+
catch { die(`Linked plan is malformed; prompt was not consumed: ${planRef}`); }
|
|
361
|
+
const repoPath = toRepoPath(planPath, config.repoRoot);
|
|
362
|
+
const sessionId = authoritativeSessionId();
|
|
363
|
+
const ownership = readPlanOwnership(repoPath, config);
|
|
364
|
+
const oldStatus = asString(parsed.status);
|
|
365
|
+
const disposition = classifyPlanPickup({
|
|
366
|
+
type: asString(parsed.type),
|
|
367
|
+
status: oldStatus,
|
|
368
|
+
validStatuses: config.typeStatuses?.get('plan') ?? config.validStatuses,
|
|
369
|
+
startableStatuses: config.lifecycle.startableStatuses,
|
|
370
|
+
terminalStatuses: config.lifecycle.terminalStatuses,
|
|
371
|
+
archiveStatuses: config.lifecycle.archiveStatuses,
|
|
372
|
+
physicallyArchived: isArchivedPath(repoPath, config),
|
|
373
|
+
ownership,
|
|
374
|
+
sessionId,
|
|
375
|
+
malformed: false,
|
|
376
|
+
});
|
|
377
|
+
if (!disposition.pickupable) die(`Linked plan cannot be claimed (${disposition.kind}); prompt was not consumed: ${repoPath}`);
|
|
378
|
+
if (disposition.kind === 'resume') return { planPath, repoPath, prepared: null, planChanged: false, disposition: disposition.kind };
|
|
379
|
+
const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
380
|
+
const rendered = disposition.kind === 'start'
|
|
381
|
+
? renderLifecycleMutation(raw, { status: 'in-session', updated: now }, `Started (${oldStatus} → in-session).`, { createSection: true })
|
|
382
|
+
: null;
|
|
383
|
+
const prepared = preparePlanClaim({ filePath: planPath, sourceContent: raw, renderedContent: rendered,
|
|
384
|
+
ownership, sessionId, now, config });
|
|
385
|
+
return {
|
|
386
|
+
planPath,
|
|
387
|
+
repoPath,
|
|
388
|
+
prepared,
|
|
389
|
+
planChanged: prepared.updates.some(item => path.resolve(item.path) === path.resolve(planPath)),
|
|
390
|
+
disposition: disposition.kind,
|
|
391
|
+
};
|
|
286
392
|
}
|
|
287
393
|
|
|
288
394
|
// Read-only peek: print the body WITHOUT consuming. The sanctioned triage path
|
package/src/query.mjs
CHANGED
|
@@ -85,15 +85,21 @@ export function runQuery(index, argv, config, opts = {}) {
|
|
|
85
85
|
if (filters.body && !filters.keyword) {
|
|
86
86
|
die('`--body` extends a keyword search into document bodies — pass `--keyword <term>` (or use `dotmd grep <term>`).');
|
|
87
87
|
}
|
|
88
|
-
const docs = filterDocs(index.docs, filters, config);
|
|
88
|
+
const docs = filterDocs(index.docs, filters, config, opts.gitMetadataOptions);
|
|
89
|
+
const summaryPreviewSkipped = filters.summarize && config._execution?.suppressSideEffects;
|
|
89
90
|
|
|
90
91
|
if (filters.json) {
|
|
91
|
-
if (filters.summarize) {
|
|
92
|
+
if (filters.summarize && !summaryPreviewSkipped) {
|
|
92
93
|
for (let i = 0; i < docs.length && i < filters.summarizeLimit; i++) {
|
|
93
94
|
docs[i].aiSummary = getDocSummary(docs[i], config);
|
|
94
95
|
}
|
|
95
96
|
}
|
|
96
|
-
process.stdout.write(`${JSON.stringify({
|
|
97
|
+
process.stdout.write(`${JSON.stringify({
|
|
98
|
+
filters,
|
|
99
|
+
count: docs.length,
|
|
100
|
+
docs,
|
|
101
|
+
...(summaryPreviewSkipped ? { summaryPreview: { status: 'skipped-preview', reason: 'side-effect-free preview' } } : {}),
|
|
102
|
+
}, null, 2)}\n`);
|
|
97
103
|
return;
|
|
98
104
|
}
|
|
99
105
|
|
|
@@ -279,7 +285,7 @@ export function parseQueryArgs(argv) {
|
|
|
279
285
|
return filters;
|
|
280
286
|
}
|
|
281
287
|
|
|
282
|
-
export function filterDocs(docs, filters, config) {
|
|
288
|
+
export function filterDocs(docs, filters, config, gitMetadataOptions) {
|
|
283
289
|
let result = [...docs];
|
|
284
290
|
|
|
285
291
|
if (filters.types?.length) result = result.filter(d => filters.types.includes(d.type));
|
|
@@ -320,14 +326,18 @@ export function filterDocs(docs, filters, config) {
|
|
|
320
326
|
if (filters.updatedSince) result = result.filter(d => d.updated && d.updated >= filters.updatedSince);
|
|
321
327
|
|
|
322
328
|
if (filters.git) {
|
|
323
|
-
const
|
|
329
|
+
const gitMetadata = getGitLastModifiedBatch(config.repoRoot, result.map(doc => doc.path), gitMetadataOptions);
|
|
330
|
+
const gitDates = gitMetadata.dates;
|
|
324
331
|
for (const doc of result) {
|
|
325
332
|
const gitDate = gitDates.get(doc.path) ?? null;
|
|
326
333
|
if (gitDate) {
|
|
327
334
|
doc.daysSinceUpdate = computeDaysSinceUpdate(gitDate);
|
|
328
|
-
doc.isStale = computeIsStale(doc.status, gitDate, config);
|
|
335
|
+
doc.isStale = computeIsStale(doc.status, gitDate, config, doc.type);
|
|
329
336
|
}
|
|
330
337
|
}
|
|
338
|
+
if (!gitMetadata.complete) {
|
|
339
|
+
warn(`Git metadata is incomplete (${gitMetadata.reason}); query results use known dates only.`);
|
|
340
|
+
}
|
|
331
341
|
}
|
|
332
342
|
|
|
333
343
|
if (filters.stale) result = result.filter(d => d.isStale);
|
|
@@ -380,18 +390,16 @@ function scanBodyForKeyword(doc, needle, config) {
|
|
|
380
390
|
warn(`Could not read ${doc.path}: ${err.message}`);
|
|
381
391
|
return [];
|
|
382
392
|
}
|
|
383
|
-
const { body } = extractFrontmatter(raw);
|
|
393
|
+
const { body, bodyLineOffset } = extractFrontmatter(raw);
|
|
384
394
|
if (!body || !body.toLowerCase().includes(needle)) return [];
|
|
385
395
|
|
|
386
|
-
// body is a suffix of raw — the slice before it is the frontmatter block.
|
|
387
|
-
const bodyStartLine = raw.slice(0, raw.length - body.length).split('\n').length;
|
|
388
396
|
const lines = body.split('\n');
|
|
389
397
|
const matches = [];
|
|
390
398
|
for (let i = 0; i < lines.length && matches.length < MAX_BODY_MATCHES; i++) {
|
|
391
399
|
const text = lines[i].trim();
|
|
392
400
|
const at = text.toLowerCase().indexOf(needle);
|
|
393
401
|
if (at === -1) continue;
|
|
394
|
-
matches.push({ line:
|
|
402
|
+
matches.push({ line: bodyLineOffset + i + 1, text: excerptAround(text, at, needle.length) });
|
|
395
403
|
}
|
|
396
404
|
return matches;
|
|
397
405
|
}
|
|
@@ -411,6 +419,7 @@ function getDocSummary(doc, config) {
|
|
|
411
419
|
const { body } = extractFrontmatter(raw);
|
|
412
420
|
if (!body?.trim()) return null;
|
|
413
421
|
const meta = { title: doc.title, status: doc.status, path: doc.path };
|
|
422
|
+
if (config._execution?.suppressSideEffects) return null;
|
|
414
423
|
return config.hooks.summarizeDoc
|
|
415
424
|
? config.hooks.summarizeDoc(body, meta)
|
|
416
425
|
: summarizeDocBody(body, meta);
|
|
@@ -442,6 +451,9 @@ function renderQueryResults(docs, filters, config) {
|
|
|
442
451
|
if (filters.hasNextStep) process.stdout.write('- has-next-step: true\n');
|
|
443
452
|
if (filters.hasBlockers) process.stdout.write('- has-blockers: true\n');
|
|
444
453
|
if (filters.checklistOpen) process.stdout.write('- checklist-open: true\n');
|
|
454
|
+
if (filters.summarize && config._execution?.suppressSideEffects) {
|
|
455
|
+
process.stdout.write('- summaries: skipped in side-effect-free preview\n');
|
|
456
|
+
}
|
|
445
457
|
process.stdout.write('\n');
|
|
446
458
|
|
|
447
459
|
if (docs.length === 0) { process.stdout.write('No matching docs.\n'); return; }
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
|
|
5
|
+
function slash(value) { return value.split(path.sep).join('/'); }
|
|
6
|
+
|
|
7
|
+
function canonicalExisting(filePath) {
|
|
8
|
+
try { return realpathSync(filePath); }
|
|
9
|
+
catch {
|
|
10
|
+
const suffix = [];
|
|
11
|
+
let cursor = path.resolve(filePath);
|
|
12
|
+
while (true) {
|
|
13
|
+
try { return path.join(realpathSync(cursor), ...suffix); }
|
|
14
|
+
catch {
|
|
15
|
+
const parent = path.dirname(cursor);
|
|
16
|
+
if (parent === cursor) return path.resolve(filePath);
|
|
17
|
+
suffix.unshift(path.basename(cursor));
|
|
18
|
+
cursor = parent;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class AmbiguousReferenceError extends Error {
|
|
25
|
+
constructor(token, sourcePath, local, repository) {
|
|
26
|
+
super(`Ambiguous reference '${token}' in ${sourcePath}: document-relative resolves to ${local}, repository-relative resolves to ${repository}. Use an explicit unambiguous path.`);
|
|
27
|
+
this.name = 'AmbiguousReferenceError';
|
|
28
|
+
this.code = 'DOTMD_AMBIGUOUS_REFERENCE';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function configuredReferenceFields(config) {
|
|
33
|
+
return [...new Set([
|
|
34
|
+
...(config.referenceFields?.bidirectional ?? []),
|
|
35
|
+
...(config.referenceFields?.unidirectional ?? []),
|
|
36
|
+
])];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createReferenceIdentitySet(filePaths) {
|
|
40
|
+
const identities = new Set();
|
|
41
|
+
identities.paths = new Map();
|
|
42
|
+
for (const filePath of filePaths) {
|
|
43
|
+
const identity = canonicalExisting(filePath);
|
|
44
|
+
identities.add(identity);
|
|
45
|
+
identities.paths.set(identity, path.resolve(filePath));
|
|
46
|
+
}
|
|
47
|
+
return identities;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Both interpretations are evaluated. A local document wins only when the
|
|
51
|
+
// repo-relative spelling is absent or names the same identity; disagreement is
|
|
52
|
+
// rejected rather than guessed.
|
|
53
|
+
export function resolveReferenceIdentity(token, documentPath, repoRoot, identities) {
|
|
54
|
+
if (!token || /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(token)) return null;
|
|
55
|
+
const clean = token.replace(/[?#].*$/, '').replace(/\\([\s()[\]<>])/g, '$1');
|
|
56
|
+
const local = canonicalExisting(path.resolve(path.dirname(documentPath), clean));
|
|
57
|
+
const repository = canonicalExisting(path.resolve(repoRoot, clean.replace(/^\/+/, '')));
|
|
58
|
+
const localExists = identities.has(local);
|
|
59
|
+
const repositoryExists = identities.has(repository);
|
|
60
|
+
if (localExists && repositoryExists && local !== repository) {
|
|
61
|
+
throw new AmbiguousReferenceError(token, documentPath, local, repository);
|
|
62
|
+
}
|
|
63
|
+
return localExists ? local : (repositoryExists ? repository : null);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function rewriteToken(token, sourcePath, outputPath, repoRoot, identities, oldIdentity, newPath, rebaseAll, format = 'plain') {
|
|
67
|
+
const target = resolveReferenceIdentity(token, sourcePath, repoRoot, identities);
|
|
68
|
+
if (!target || (!rebaseAll && target !== oldIdentity)) return token;
|
|
69
|
+
const destination = target === oldIdentity ? path.resolve(newPath) : (identities.paths?.get(target) ?? target);
|
|
70
|
+
const relative = slash(path.relative(path.dirname(outputPath), destination)) || path.basename(destination);
|
|
71
|
+
return format === 'escaped' ? relative.replace(/([\s()[\]<>])/g, '\\$1') : relative;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function rewritePathTokens(value, args) {
|
|
75
|
+
let quote = null;
|
|
76
|
+
let commentAt = -1;
|
|
77
|
+
for (let index = 0; index < value.length; index++) {
|
|
78
|
+
const char = value[index];
|
|
79
|
+
if (quote) {
|
|
80
|
+
if (char === quote && value[index - 1] !== '\\') quote = null;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (char === '"' || char === "'") { quote = char; continue; }
|
|
84
|
+
if (char === '#' && index > 0 && /\s/.test(value[index - 1])) { commentAt = index - 1; break; }
|
|
85
|
+
}
|
|
86
|
+
const editable = commentAt === -1 ? value : value.slice(0, commentAt);
|
|
87
|
+
const comment = commentAt === -1 ? '' : value.slice(commentAt);
|
|
88
|
+
const quoted = editable.replace(/(["'])((?:\\.|(?!\1).)*)\1/g, (match, delimiter, token) => {
|
|
89
|
+
if (!/^.+\.md(?:[?#].*)?$/i.test(token)) return match;
|
|
90
|
+
const suffixAt = token.search(/[?#]/);
|
|
91
|
+
const bare = suffixAt === -1 ? token : token.slice(0, suffixAt);
|
|
92
|
+
const suffix = suffixAt === -1 ? '' : token.slice(suffixAt);
|
|
93
|
+
const rewritten = rewriteToken(bare, ...args);
|
|
94
|
+
return rewritten === bare ? match : `${delimiter}${rewritten}${suffix}${delimiter}`;
|
|
95
|
+
});
|
|
96
|
+
const rewritten = quoted.replace(/[^\s"'<>:[\],()#?]+\.md(?=$|[\s"'<>:[\],()#?])/g, token => rewriteToken(token, ...args));
|
|
97
|
+
return rewritten + comment;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function rewriteFrontmatter(frontmatter, fields, args) {
|
|
101
|
+
const allowed = new Set(fields);
|
|
102
|
+
const lines = frontmatter.split('\n');
|
|
103
|
+
let active = false;
|
|
104
|
+
return lines.map(line => {
|
|
105
|
+
const key = /^([A-Za-z_][\w-]*):(?:\s*(.*))?$/.exec(line);
|
|
106
|
+
if (key) {
|
|
107
|
+
active = allowed.has(key[1]);
|
|
108
|
+
if (!active) return line;
|
|
109
|
+
const colon = line.indexOf(':');
|
|
110
|
+
return line.slice(0, colon + 1) + rewritePathTokens(line.slice(colon + 1), args);
|
|
111
|
+
}
|
|
112
|
+
if (/^[^\s#][^:]*:/.test(line)) active = false;
|
|
113
|
+
if (!active || /^\s*#/.test(line)) return line;
|
|
114
|
+
return rewritePathTokens(line, args);
|
|
115
|
+
}).join('\n');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function destinationParts(raw) {
|
|
119
|
+
const angle = raw.startsWith('<') && raw.endsWith('>');
|
|
120
|
+
const inner = angle ? raw.slice(1, -1) : raw;
|
|
121
|
+
const suffixAt = inner.search(/[?#]/);
|
|
122
|
+
return {
|
|
123
|
+
angle,
|
|
124
|
+
path: suffixAt === -1 ? inner : inner.slice(0, suffixAt),
|
|
125
|
+
suffix: suffixAt === -1 ? '' : inner.slice(suffixAt),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function rewriteDestination(raw, args) {
|
|
130
|
+
const parsed = destinationParts(raw);
|
|
131
|
+
if (!/\.md$/i.test(parsed.path.replace(/\\./g, 'x'))) return raw;
|
|
132
|
+
const next = rewriteToken(parsed.path, ...args, parsed.angle ? 'plain' : 'escaped');
|
|
133
|
+
if (next === parsed.path) return raw;
|
|
134
|
+
const rendered = `${next}${parsed.suffix}`;
|
|
135
|
+
return parsed.angle ? `<${rendered}>` : rendered;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function rewriteMarkdownSegment(segment, args) {
|
|
139
|
+
// Inline links: angle destinations, escaped whitespace, optional titles.
|
|
140
|
+
let next = segment.replace(/(\[[^\]]*\]\(\s*)(<[^>\n]+>|(?:\\.|[^\s()])+)(\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?(\s*\))/g,
|
|
141
|
+
(match, prefix, destination, title = '', close, offset) => {
|
|
142
|
+
let escapes = 0;
|
|
143
|
+
for (let index = offset - 1; index >= 0 && segment[index] === '\\'; index--) escapes++;
|
|
144
|
+
if (escapes % 2 === 1) return match;
|
|
145
|
+
const rewritten = rewriteDestination(destination, args);
|
|
146
|
+
return rewritten === destination ? match : `${prefix}${rewritten}${title}${close}`;
|
|
147
|
+
});
|
|
148
|
+
// Reference definitions preserve labels, spacing, destinations, and titles.
|
|
149
|
+
next = next.replace(/^(\s{0,3}\[[^\]]+\]:\s*)(<[^>\n]+>|(?:\\.|[^\s])+)(.*)$/,
|
|
150
|
+
(match, prefix, destination, tail) => {
|
|
151
|
+
const rewritten = rewriteDestination(destination, args);
|
|
152
|
+
return rewritten === destination ? match : `${prefix}${rewritten}${tail}`;
|
|
153
|
+
});
|
|
154
|
+
return next;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function rewriteMarkdown(body, args) {
|
|
158
|
+
const lines = body.split('\n');
|
|
159
|
+
let fence = null;
|
|
160
|
+
let inlineRun = null;
|
|
161
|
+
let indentedCode = false;
|
|
162
|
+
let htmlBlock = null;
|
|
163
|
+
const containerContext = line => {
|
|
164
|
+
let rest = line;
|
|
165
|
+
let quotePrefix = '';
|
|
166
|
+
while (true) {
|
|
167
|
+
const quote = /^( {0,3}>[ \t]?)/.exec(rest);
|
|
168
|
+
if (!quote) break;
|
|
169
|
+
quotePrefix += quote[1];
|
|
170
|
+
rest = rest.slice(quote[1].length);
|
|
171
|
+
}
|
|
172
|
+
const list = /^( {0,3}(?:[-+*]|\d+[.)]))([ \t]+)/.exec(rest);
|
|
173
|
+
if (list) {
|
|
174
|
+
const padding = !list[2].includes('\t') && list[2].length <= 4 ? list[2] : list[2][0];
|
|
175
|
+
const listPrefix = `${list[1]}${padding}`;
|
|
176
|
+
rest = rest.slice(listPrefix.length);
|
|
177
|
+
return { content: rest, prefixes: [`${quotePrefix}${listPrefix}`, `${quotePrefix}${' '.repeat(listPrefix.length)}`] };
|
|
178
|
+
}
|
|
179
|
+
return { content: rest, prefixes: quotePrefix ? [quotePrefix] : [''] };
|
|
180
|
+
};
|
|
181
|
+
return lines.map(line => {
|
|
182
|
+
if (fence) {
|
|
183
|
+
const close = new RegExp(`^ {0,3}${fence.char === '`' ? '`' : '~'}{${fence.length},}[ \\t]*$`);
|
|
184
|
+
const compatible = fence.prefixes.find(prefix => line.startsWith(prefix));
|
|
185
|
+
if (compatible !== undefined && close.test(line.slice(compatible.length))) fence = null;
|
|
186
|
+
return line;
|
|
187
|
+
}
|
|
188
|
+
if (htmlBlock) {
|
|
189
|
+
if (htmlBlock === 'comment' && line.includes('-->')) htmlBlock = null;
|
|
190
|
+
else if (htmlBlock !== 'generic' && new RegExp(`</${htmlBlock}\\s*>`, 'i').test(line)) htmlBlock = null;
|
|
191
|
+
else if (htmlBlock === 'generic' && line.trim() === '') htmlBlock = null;
|
|
192
|
+
return line;
|
|
193
|
+
}
|
|
194
|
+
const trimmed = line.trimStart();
|
|
195
|
+
if (trimmed.startsWith('<!--')) {
|
|
196
|
+
if (!trimmed.includes('-->')) htmlBlock = 'comment';
|
|
197
|
+
return line;
|
|
198
|
+
}
|
|
199
|
+
const rawTag = /^<(pre|code|script|style)(?:\s|>|$)/i.exec(trimmed)?.[1]?.toLowerCase();
|
|
200
|
+
if (rawTag) {
|
|
201
|
+
if (!new RegExp(`</${rawTag}\\s*>`, 'i').test(trimmed)) htmlBlock = rawTag;
|
|
202
|
+
return line;
|
|
203
|
+
}
|
|
204
|
+
if (/^<\/?(?:address|article|aside|base|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)(?:\s|>|\/)/i.test(trimmed)) {
|
|
205
|
+
htmlBlock = 'generic';
|
|
206
|
+
return line;
|
|
207
|
+
}
|
|
208
|
+
if (trimmed.startsWith('<')) return line;
|
|
209
|
+
const container = containerContext(line);
|
|
210
|
+
if (indentedCode) {
|
|
211
|
+
if (container.content === '' || /^(?: {4}|\t)/.test(container.content)) return line;
|
|
212
|
+
indentedCode = false;
|
|
213
|
+
} else if (/^(?: {4}|\t)/.test(container.content)) {
|
|
214
|
+
indentedCode = true;
|
|
215
|
+
return line;
|
|
216
|
+
}
|
|
217
|
+
const opener = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(container.content);
|
|
218
|
+
if (opener) {
|
|
219
|
+
fence = { char: opener[1][0], length: opener[1].length, prefixes: container.prefixes };
|
|
220
|
+
return line;
|
|
221
|
+
}
|
|
222
|
+
let output = '';
|
|
223
|
+
let cursor = 0;
|
|
224
|
+
const runs = [...line.matchAll(/`+/g)];
|
|
225
|
+
let index = 0;
|
|
226
|
+
if (inlineRun !== null) {
|
|
227
|
+
const closingIndex = runs.findIndex(candidate => candidate[0].length === inlineRun);
|
|
228
|
+
if (closingIndex === -1) return line;
|
|
229
|
+
const closing = runs[closingIndex];
|
|
230
|
+
output += line.slice(0, closing.index + closing[0].length);
|
|
231
|
+
cursor = closing.index + closing[0].length;
|
|
232
|
+
index = closingIndex + 1;
|
|
233
|
+
inlineRun = null;
|
|
234
|
+
}
|
|
235
|
+
for (; index < runs.length; index++) {
|
|
236
|
+
const opening = runs[index];
|
|
237
|
+
const closingIndex = runs.findIndex((candidate, candidateIndex) => candidateIndex > index && candidate[0].length === opening[0].length);
|
|
238
|
+
if (closingIndex === -1) {
|
|
239
|
+
output += rewriteMarkdownSegment(line.slice(cursor, opening.index), args);
|
|
240
|
+
output += line.slice(opening.index);
|
|
241
|
+
inlineRun = opening[0].length;
|
|
242
|
+
cursor = line.length;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
const closing = runs[closingIndex];
|
|
246
|
+
output += rewriteMarkdownSegment(line.slice(cursor, opening.index), args);
|
|
247
|
+
output += line.slice(opening.index, closing.index + closing[0].length);
|
|
248
|
+
cursor = closing.index + closing[0].length;
|
|
249
|
+
index = closingIndex;
|
|
250
|
+
}
|
|
251
|
+
return output + rewriteMarkdownSegment(line.slice(cursor), args);
|
|
252
|
+
}).join('\n');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function rewriteDocumentReferences(content, {
|
|
256
|
+
sourcePath,
|
|
257
|
+
outputPath = sourcePath,
|
|
258
|
+
repoRoot,
|
|
259
|
+
identities,
|
|
260
|
+
oldPath,
|
|
261
|
+
newPath,
|
|
262
|
+
referenceFields = [],
|
|
263
|
+
rebaseAll = false,
|
|
264
|
+
}) {
|
|
265
|
+
const { frontmatter, body } = extractFrontmatter(content);
|
|
266
|
+
if (!frontmatter) return content;
|
|
267
|
+
const oldIdentity = canonicalExisting(oldPath);
|
|
268
|
+
const args = [sourcePath, outputPath, repoRoot, identities, oldIdentity, newPath, rebaseAll];
|
|
269
|
+
const nextFrontmatter = rewriteFrontmatter(frontmatter, referenceFields, args);
|
|
270
|
+
const nextBody = rewriteMarkdown(body, args);
|
|
271
|
+
return nextFrontmatter === frontmatter && nextBody === body ? content : `---\n${nextFrontmatter}\n---\n${nextBody}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function planReferenceMove({ documents, oldPath, newPath, repoRoot, referenceFields = [] }) {
|
|
275
|
+
const identities = createReferenceIdentitySet(documents.map(document => document.path));
|
|
276
|
+
const oldIdentity = canonicalExisting(oldPath);
|
|
277
|
+
identities.add(oldIdentity);
|
|
278
|
+
const source = documents.find(document => path.resolve(document.path) === path.resolve(oldPath));
|
|
279
|
+
if (!source) throw new Error(`Reference move plan is missing source content: ${oldPath}`);
|
|
280
|
+
const movedContent = rewriteDocumentReferences(source.content, {
|
|
281
|
+
sourcePath: oldPath, outputPath: newPath, repoRoot, identities, oldPath, newPath, referenceFields, rebaseAll: true,
|
|
282
|
+
});
|
|
283
|
+
const updates = [];
|
|
284
|
+
for (const document of documents) {
|
|
285
|
+
if (path.resolve(document.path) === path.resolve(oldPath)) continue;
|
|
286
|
+
const content = rewriteDocumentReferences(document.content, {
|
|
287
|
+
sourcePath: document.path, repoRoot, identities, oldPath, newPath, referenceFields,
|
|
288
|
+
});
|
|
289
|
+
if (content !== document.content) updates.push({ path: document.path, expectedContent: document.content, content });
|
|
290
|
+
}
|
|
291
|
+
return { movedContent, updates, identities };
|
|
292
|
+
}
|