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/prompts.mjs
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
|
-
import { readFileSync,
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
3
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
5
|
-
import { asString, toRepoPath, die, resolveDocPath, isArchivedPath
|
|
4
|
+
import { asString, toRepoPath, die, resolveDocPath, isArchivedPath } from './util.mjs';
|
|
6
5
|
import { buildIndex, resolveDocArg } from './index.mjs';
|
|
7
6
|
import { runQuery } from './query.mjs';
|
|
8
|
-
import { runArchive, runStatus
|
|
9
|
-
import { appendJournalEntry } from './journal.mjs';
|
|
7
|
+
import { completePlanClaim, regenIndex, renderLifecycleMutation, runArchive, runStatus } from './lifecycle.mjs';
|
|
10
8
|
import { runNew } from './new.mjs';
|
|
11
9
|
import { green, dim } from './color.mjs';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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';
|
|
15
18
|
|
|
16
19
|
// `resume` is an alias for `use` — agents reach for "resume" when continuing a
|
|
17
20
|
// session; `use` reads as internal mechanics. Both names stay valid; the
|
|
@@ -130,10 +133,10 @@ function slugToPlanPath(s, config) {
|
|
|
130
133
|
// Resolve a markdown body link relative to the prompt's location so e.g.
|
|
131
134
|
// `../plans/foo.md` from docs/prompts/x.md → docs/plans/foo.md.
|
|
132
135
|
function resolveBodyLink(link, promptRepoPath) {
|
|
133
|
-
const cleaned = link.replace(/#.*$/, '');
|
|
136
|
+
const cleaned = link.replace(/#.*$/, '').replaceAll('\\', '/');
|
|
134
137
|
if (cleaned.startsWith('/')) return cleaned.replace(/^\/+/, '');
|
|
135
|
-
const promptDir = path.dirname(promptRepoPath);
|
|
136
|
-
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));
|
|
137
140
|
}
|
|
138
141
|
|
|
139
142
|
function renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived }) {
|
|
@@ -163,36 +166,27 @@ function renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived })
|
|
|
163
166
|
}
|
|
164
167
|
|
|
165
168
|
export function pendingPromptsOldestFirst(config) {
|
|
166
|
-
const index = buildIndex(config);
|
|
169
|
+
const index = buildIndex(config, { fast: true, invokeHooks: false });
|
|
170
|
+
const actionable = actionablePromptStatuses(config);
|
|
167
171
|
const prompts = index.docs.filter(d =>
|
|
168
172
|
d.type === 'prompt'
|
|
169
|
-
&& d.status
|
|
173
|
+
&& actionable.has(d.status)
|
|
170
174
|
&& !isArchivedPath(d.path, config),
|
|
171
175
|
);
|
|
172
176
|
|
|
173
177
|
return prompts
|
|
174
|
-
.
|
|
175
|
-
|
|
176
|
-
let mtime = 0;
|
|
177
|
-
try { mtime = abs ? statSync(abs).mtimeMs : 0; } catch { mtime = 0; }
|
|
178
|
-
return { doc: d, abs, created: d.created ?? '', mtime };
|
|
179
|
-
})
|
|
180
|
-
.sort((a, b) => {
|
|
181
|
-
if (a.created && b.created && a.created !== b.created) return a.created.localeCompare(b.created);
|
|
182
|
-
if (a.created && !b.created) return -1;
|
|
183
|
-
if (!a.created && b.created) return 1;
|
|
184
|
-
return a.mtime - b.mtime;
|
|
185
|
-
});
|
|
178
|
+
.sort(comparePromptDocs)
|
|
179
|
+
.map(d => ({ doc: d, abs: resolveDocPath(d.path, config), created: d.created ?? '' }));
|
|
186
180
|
}
|
|
187
181
|
|
|
188
|
-
function runPromptsNext(argv, config, opts = {}) {
|
|
182
|
+
async function runPromptsNext(argv, config, opts = {}) {
|
|
189
183
|
const queue = pendingPromptsOldestFirst(config);
|
|
190
184
|
if (queue.length === 0) {
|
|
191
185
|
die('No pending prompts.');
|
|
192
186
|
}
|
|
193
187
|
const head = queue[0];
|
|
194
188
|
if (!head.abs) die(`Could not resolve path: ${head.doc.path}`);
|
|
195
|
-
consumePrompt(head.abs, config, opts);
|
|
189
|
+
return consumePrompt(head.abs, config, opts);
|
|
196
190
|
}
|
|
197
191
|
|
|
198
192
|
// Resolve user input to a prompt path. Tries (in order): exact path,
|
|
@@ -209,7 +203,7 @@ export function resolvePromptInput(input, config, options = {}) {
|
|
|
209
203
|
if (withExt) return withExt;
|
|
210
204
|
}
|
|
211
205
|
|
|
212
|
-
const index = buildIndex(config);
|
|
206
|
+
const index = buildIndex(config, { fast: true, invokeHooks: false });
|
|
213
207
|
const prompts = index.docs.filter(d => d.type === 'prompt');
|
|
214
208
|
if (prompts.length === 0) {
|
|
215
209
|
if (dieOnMiss) die(`No prompts in the index.`);
|
|
@@ -236,17 +230,18 @@ export function resolvePromptInput(input, config, options = {}) {
|
|
|
236
230
|
return null;
|
|
237
231
|
}
|
|
238
232
|
|
|
239
|
-
function runPromptsUse(argv, config, opts = {}) {
|
|
233
|
+
async function runPromptsUse(argv, config, opts = {}) {
|
|
240
234
|
const input = argv.find(a => !a.startsWith('-'));
|
|
241
235
|
if (!input) die('Usage: dotmd prompts use <file-or-slug>');
|
|
242
236
|
const noIndex = argv.includes('--no-index') || opts.noIndex;
|
|
243
237
|
const showFiles = argv.includes('--show-files') || opts.showFiles;
|
|
244
238
|
const filePath = resolvePromptInput(input, config);
|
|
245
|
-
consumePrompt(filePath, config, { ...opts, noIndex, showFiles });
|
|
239
|
+
return consumePrompt(filePath, config, { ...opts, noIndex, showFiles });
|
|
246
240
|
}
|
|
247
241
|
|
|
248
|
-
export function consumePrompt(filePath, config, opts) {
|
|
242
|
+
export async function consumePrompt(filePath, config, opts) {
|
|
249
243
|
const { dryRun, noIndex, showFiles } = opts;
|
|
244
|
+
filePath = authorizeManagedSource(filePath, config, { kind: 'Prompt consumption source' }).path;
|
|
250
245
|
const raw = readFileSync(filePath, 'utf8');
|
|
251
246
|
const { frontmatter, body } = extractFrontmatter(raw);
|
|
252
247
|
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
@@ -261,6 +256,10 @@ export function consumePrompt(filePath, config, opts) {
|
|
|
261
256
|
die(`Already consumed: ${repoPath}`);
|
|
262
257
|
}
|
|
263
258
|
|
|
259
|
+
const planRef = asString(parsed.plan);
|
|
260
|
+
let linkedClaim = null;
|
|
261
|
+
if (planRef) linkedClaim = prepareLinkedPromptClaim(planRef, config);
|
|
262
|
+
|
|
264
263
|
if (dryRun) {
|
|
265
264
|
const prefix = dim('[dry-run]');
|
|
266
265
|
process.stderr.write(`${prefix} Would emit body and archive: ${repoPath} (${status ?? 'unknown'} → archived)\n`);
|
|
@@ -279,57 +278,117 @@ export function consumePrompt(filePath, config, opts) {
|
|
|
279
278
|
// hook crash, anything), the body must not have already gone to stdout —
|
|
280
279
|
// otherwise `claude "$(dotmd prompts next)"` consumes the prompt without it
|
|
281
280
|
// ever being archived, and the next session sees the same prompt as pending.
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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;
|
|
288
291
|
|
|
289
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
|
+
}
|
|
290
310
|
process.stderr.write(`${green('✓ Consumed')}: ${consumedPath}\n`);
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
+
};
|
|
297
335
|
}
|
|
298
336
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
catch {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
catch { return; }
|
|
315
|
-
const cur = asString(planFm.status);
|
|
316
|
-
// Only claim a startable plan. Already in-session → someone's on it (don't
|
|
317
|
-
// steal); archived/terminal → the link is stale. Either way, leave it be.
|
|
318
|
-
if (!cur || cur === 'in-session' || cur === 'archived') return;
|
|
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
|
+
}
|
|
319
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}`); }
|
|
320
361
|
const repoPath = toRepoPath(planPath, config.repoRoot);
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
+
};
|
|
333
392
|
}
|
|
334
393
|
|
|
335
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; }
|