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.
Files changed (53) hide show
  1. package/README.md +144 -964
  2. package/bin/dotmd.mjs +238 -195
  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/commands.mjs +326 -12
  10. package/src/completions.mjs +38 -98
  11. package/src/config.mjs +18 -3
  12. package/src/diff.mjs +7 -3
  13. package/src/doctor.mjs +12 -5
  14. package/src/export.mjs +154 -25
  15. package/src/fix-refs.mjs +2 -0
  16. package/src/frontmatter-fix.mjs +2 -0
  17. package/src/frontmatter.mjs +3 -2
  18. package/src/git.mjs +531 -14
  19. package/src/graph.mjs +53 -25
  20. package/src/guard.mjs +163 -60
  21. package/src/hud.mjs +65 -76
  22. package/src/index-file.mjs +28 -16
  23. package/src/index.mjs +17 -12
  24. package/src/init.mjs +1 -1
  25. package/src/journal.mjs +145 -12
  26. package/src/lifecycle.mjs +554 -282
  27. package/src/lint.mjs +3 -3
  28. package/src/managed-path.mjs +192 -0
  29. package/src/migrate-prompts.mjs +2 -0
  30. package/src/migrate-template.mjs +2 -0
  31. package/src/migrate.mjs +7 -1
  32. package/src/new.mjs +135 -54
  33. package/src/output-identity.mjs +106 -0
  34. package/src/pickup-card.mjs +24 -10
  35. package/src/pickup.mjs +457 -0
  36. package/src/prompts.mjs +134 -75
  37. package/src/query.mjs +22 -10
  38. package/src/reference-planner.mjs +292 -0
  39. package/src/rename.mjs +65 -73
  40. package/src/render.mjs +17 -8
  41. package/src/runlist.mjs +109 -71
  42. package/src/section.mjs +2 -1
  43. package/src/ship.mjs +39 -20
  44. package/src/stats.mjs +1 -1
  45. package/src/status-metadata.mjs +87 -0
  46. package/src/statuses.mjs +11 -26
  47. package/src/summary.mjs +14 -3
  48. package/src/update.mjs +38 -10
  49. package/src/use.mjs +4 -1
  50. package/src/util.mjs +1 -0
  51. package/src/validate.mjs +14 -6
  52. package/src/watch.mjs +6 -1
  53. package/src/notion.mjs +0 -528
package/src/prompts.mjs CHANGED
@@ -1,17 +1,20 @@
1
- import { readFileSync, statSync, existsSync } from 'node:fs';
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, currentSessionId } from './util.mjs';
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, updateFrontmatter } from './lifecycle.mjs';
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
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
- const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
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 === 'pending'
173
+ && actionable.has(d.status)
170
174
  && !isArchivedPath(d.path, config),
171
175
  );
172
176
 
173
177
  return prompts
174
- .map(d => {
175
- const abs = resolveDocPath(d.path, config);
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
- // Body is already in memory from extractFrontmatter, so the source file
283
- // can move out from under us safely.
284
- const archiveResult = runArchive([filePath], config, { noIndex, showFiles, out: process.stderr });
285
-
286
- process.stdout.write(body);
287
- if (!body.endsWith('\n')) process.stdout.write('\n');
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
- // Consume = claim: a baton-created resume prompt carries `plan: <path>`.
293
- // Adopt that plan for THIS session so the next `dotmd baton` (with no arg)
294
- // hands it off — closing the cross-session ownership loop that otherwise dies
295
- // at the prompt boundary.
296
- claimPromptPlan(asString(parsed.plan), config);
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
- // Flip the resume prompt's linked plan to in-session for this session and
300
- // record the ownership in the journal. The journal entry matters: baton's
301
- // `findOwnedPlan` reconstructs ownership from journaled `set in-session <plan>`
302
- // commands, and the outer `use <prompt>` argv can't tie a prompt ref to a plan
303
- // — so without this synthetic entry the claim would be invisible to baton (and,
304
- // with the misfire gate, would even make the next baton refuse).
305
- function claimPromptPlan(planRef, config) {
306
- if (!planRef) return;
307
- let planPath = null;
308
- try { planPath = resolveDocPath(planRef, config) ?? resolveDocArg(planRef, config, { dieOnMiss: false }); }
309
- catch { planPath = null; }
310
- if (!planPath || !existsSync(planPath)) return; // link went stale (plan renamed/removed) — the resume body already printed, so stay quiet
311
-
312
- let planFm;
313
- try { planFm = parseSimpleFrontmatter(extractFrontmatter(readFileSync(planPath, 'utf8')).frontmatter); }
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
- try { updateFrontmatter(planPath, { status: 'in-session' }); }
322
- catch { return; }
323
- try {
324
- // `v` MUST be the real CLI version: the journal rotates when a new entry's
325
- // version differs from the file's first entry, so a sentinel here would shove
326
- // our just-written claim into the backup file where findOwnedPlan can't see it.
327
- appendJournalEntry(config, {
328
- ts: new Date().toISOString(), sid: currentSessionId(), pid: process.pid,
329
- argv: ['set', 'in-session', repoPath], exit: 0, ms: 0, v: pkg.version,
330
- });
331
- } catch { /* journal is best-effort — the status flip already landed */ }
332
- process.stderr.write(`${green('→ Claimed')}: ${repoPath} (in-session)\n`);
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({ filters, count: docs.length, docs }, null, 2)}\n`);
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 gitDates = getGitLastModifiedBatch(config.repoRoot);
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: bodyStartLine + i, text: excerptAround(text, at, needle.length) });
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; }