dotmd-cli 0.45.1 → 0.45.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.45.1",
3
+ "version": "0.45.3",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -10,7 +10,7 @@ import { bold, green, dim } from './color.mjs';
10
10
  // the warning on the next few-word touch-up.
11
11
  const FIELDS = [
12
12
  { name: 'current_state', cap: 1500, target: 1200, heading: '## Current State' },
13
- { name: 'next_step', cap: 300, target: 200, heading: '## Next Step' },
13
+ { name: 'next_step', cap: 800, target: 600, heading: '## Next Step' },
14
14
  ];
15
15
 
16
16
  export function runFrontmatterFix(config, opts = {}) {
package/src/lifecycle.mjs CHANGED
@@ -552,12 +552,40 @@ export function runArchive(argv, config, opts = {}) {
552
552
 
553
553
  const archiveFileRoot = findFileRoot(filePath, config);
554
554
  const relFromRoot = path.relative(archiveFileRoot, filePath);
555
- if (relFromRoot.startsWith(config.archiveDir + '/') || relFromRoot.startsWith(config.archiveDir + path.sep)) { die(`Already archived: ${toRepoPath(filePath, config.repoRoot)}`); }
555
+ // Segment-membership covers both single-root (`<root>/archived/foo.md`) and
556
+ // multi-root (`<type-root>/archived/foo.md`) layouts. The older
557
+ // startsWith-only check missed nested cases where archived/ wasn't the first
558
+ // segment under the resolved root.
559
+ const inArchiveDir = relFromRoot.split(path.sep).includes(config.archiveDir);
556
560
 
557
561
  const raw = readFileSync(filePath, 'utf8');
558
562
  const { frontmatter, body } = extractFrontmatter(raw);
559
563
  const parsed = parseSimpleFrontmatter(frontmatter);
560
564
  const oldStatus = asString(parsed.status) ?? 'unknown';
565
+
566
+ // Heal stuck frontmatter (issue #13): file is under archiveDir/ but its
567
+ // status hasn't been flipped. Flip in place; don't try to move (it's already
568
+ // archived on disk) and don't refuse — refusal leaves the drift permanent.
569
+ if (inArchiveDir) {
570
+ if (oldStatus === 'archived') {
571
+ die(`Already archived: ${toRepoPath(filePath, config.repoRoot)}`);
572
+ }
573
+ const today = nowIso();
574
+ const repoPathHeal = toRepoPath(filePath, config.repoRoot);
575
+ if (dryRun) {
576
+ const prefix = dim('[dry-run]');
577
+ out.write(`${prefix} Would heal frontmatter in place: status: ${oldStatus} → archived, updated: ${today}\n`);
578
+ out.write(`${prefix} Would skip git mv (file already under \`${config.archiveDir}/\`)\n`);
579
+ return;
580
+ }
581
+ updateFrontmatter(filePath, { status: 'archived', updated: today });
582
+ appendVersionHistory(filePath, `Archived (frontmatter healed in place from \`${oldStatus}\`).`);
583
+ if (!noIndex) regenIndex(config);
584
+ out.write(`${green('✓ Healed')}: ${repoPathHeal} (${oldStatus} → archived; file already under \`${config.archiveDir}/\`)\n`);
585
+ if (showFiles) emitFilesFooter([repoPathHeal, config.indexPath], config);
586
+ return;
587
+ }
588
+
561
589
  const closeoutAction = closeoutTemplate ? planCloseoutInjection(body) : null;
562
590
 
563
591
  const today = nowIso();
package/src/prompts.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFileSync, statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
- import { asString, toRepoPath, die, resolveDocPath } from './util.mjs';
4
+ import { asString, toRepoPath, die, resolveDocPath, isArchivedPath } from './util.mjs';
5
5
  import { buildIndex } from './index.mjs';
6
6
  import { runQuery } from './query.mjs';
7
7
  import { runArchive, runStatus } from './lifecycle.mjs';
@@ -120,7 +120,11 @@ function renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived })
120
120
 
121
121
  export function pendingPromptsOldestFirst(config) {
122
122
  const index = buildIndex(config);
123
- const prompts = index.docs.filter(d => d.type === 'prompt' && d.status === 'pending');
123
+ const prompts = index.docs.filter(d =>
124
+ d.type === 'prompt'
125
+ && d.status === 'pending'
126
+ && !isArchivedPath(d.path, config),
127
+ );
124
128
 
125
129
  return prompts
126
130
  .map(d => {
package/src/query.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { capitalize, toSlug, truncate, warn, suggestCandidates } from './util.mjs';
3
+ import { capitalize, toSlug, truncate, warn, suggestCandidates, isArchivedPath } from './util.mjs';
4
4
  import { renderProgressBar, formatCurrentState } from './render.mjs';
5
5
  import { computeDaysSinceUpdate, computeIsStale } from './validate.mjs';
6
6
  import { getGitLastModifiedBatch } from './git.mjs';
@@ -176,14 +176,15 @@ export function filterDocs(docs, filters, config) {
176
176
 
177
177
  if (filters.types?.length) result = result.filter(d => filters.types.includes(d.type));
178
178
  if (filters.statuses?.length) result = result.filter(d => filters.statuses.includes(d.status));
179
- // --exclude-archived strips terminal/archive statuses unless the caller
180
- // explicitly opted back in with --include-archived.
179
+ // --exclude-archived strips terminal/archive statuses AND any file physically
180
+ // located under `archiveDir/` (issue #13: status can drift out of sync with
181
+ // the file's directory; the path is the source of truth for "is archived").
181
182
  if (filters.excludeArchived && !filters.includeArchived) {
182
183
  const archived = new Set([
183
184
  ...(config.lifecycle?.archiveStatuses ?? []),
184
185
  ...(config.lifecycle?.terminalStatuses ?? []),
185
186
  ]);
186
- result = result.filter(d => !archived.has(d.status));
187
+ result = result.filter(d => !archived.has(d.status) && !isArchivedPath(d.path, config));
187
188
  }
188
189
 
189
190
  if (filters.keyword) {
package/src/util.mjs CHANGED
@@ -55,6 +55,16 @@ export function toRepoPath(absolutePath, repoRoot) {
55
55
  return path.relative(repoRoot, absolutePath).split(path.sep).join('/');
56
56
  }
57
57
 
58
+ // True when any path segment equals `config.archiveDir`. Covers both
59
+ // `docs/plans/archived/foo.md` and `docs/prompts/archived/foo.md` regardless
60
+ // of whether the layout is single-root or flat-array. Read-side commands use
61
+ // this to skip files whose frontmatter `status:` has drifted out of sync with
62
+ // their archive location (issue #13).
63
+ export function isArchivedPath(repoPath, config) {
64
+ if (!repoPath || !config?.archiveDir) return false;
65
+ return repoPath.split('/').includes(config.archiveDir);
66
+ }
67
+
58
68
  // Emit a `files: a b c` line to stderr listing every doc / index path
59
69
  // the command touched (deduped, sorted, repo-relative). Lets agents do
60
70
  // `git add` with the exact set instead of guessing. Opt-in via
package/src/validate.mjs CHANGED
@@ -112,9 +112,18 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
112
112
  }
113
113
 
114
114
  if (config.validSurfaces && !config.lifecycle.skipWarningsFor.has(doc.status)) {
115
+ const knownSurfaces = [...config.validSurfaces];
115
116
  for (const surface of doc.surfaces) {
116
117
  if (!config.validSurfaces.has(surface)) {
117
- doc.warnings.push({ path: doc.path, level: 'warning', message: `Unknown surface \`${surface}\`; expected a known surface taxonomy value.` });
118
+ const suggestions = suggestCandidates(surface, knownSurfaces, 3);
119
+ const hint = suggestions.length
120
+ ? ` Did you mean: ${suggestions.map(s => `\`${s}\``).join(' | ')}?`
121
+ : '';
122
+ doc.warnings.push({
123
+ path: doc.path,
124
+ level: 'warning',
125
+ message: `Unknown surface \`${surface}\`; expected a known surface taxonomy value.${hint} Run \`dotmd surfaces\` to list all valid values.`,
126
+ });
118
127
  }
119
128
  }
120
129
  }
@@ -218,6 +227,27 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
218
227
  }
219
228
  }
220
229
 
230
+ // Inverse archive drift (issue #13): a doc whose path is under
231
+ // `<dir>/<archiveDir>/` but whose status is NOT an archive status. Without
232
+ // this check, the file is invisible to default queries (the path is below
233
+ // an archive bucket so it's filtered out by `--exclude-archived`), yet its
234
+ // `status: pending` (or similar) still makes it surface in pending-prompt
235
+ // scans. The heal is to flip the frontmatter via `dotmd <type-or-set>
236
+ // archive`, which now restores in place rather than failing.
237
+ if (doc.status && !config.lifecycle.archiveStatuses.has(doc.status)) {
238
+ const parentSegments = path.dirname(doc.path).split('/');
239
+ if (parentSegments.includes(config.archiveDir)) {
240
+ const heal = doc.type === 'prompt'
241
+ ? `dotmd prompts archive ${doc.path}`
242
+ : `dotmd set archived ${doc.path}`;
243
+ doc.errors.push({
244
+ path: doc.path,
245
+ level: 'error',
246
+ message: `File is under \`${config.archiveDir}/\` but \`status: ${doc.status}\` is not an archive status. Run \`${heal}\` to heal the frontmatter in place, or move the file out of the archive directory.`,
247
+ });
248
+ }
249
+ }
250
+
221
251
  // Validate reference fields resolve to existing files. Terminal statuses
222
252
  // (archived, deprecated, etc.) document historical state — their refs may
223
253
  // legitimately point at moved/deleted targets and shouldn't gate the
@@ -435,13 +465,17 @@ export function validatePlanShape(doc, body, frontmatter, config) {
435
465
  if (config.lifecycle.terminalStatuses.has(doc.status) || config.lifecycle.archiveStatuses.has(doc.status)) return;
436
466
  if (config.lifecycle.skipWarningsFor.has(doc.status)) return;
437
467
 
438
- // 1. next_step length cap (300 chars)
468
+ // 1. next_step length cap (800 chars). Was 300; raised in parallel with
469
+ // current_state for the same reason: agents need to encode "what to do next"
470
+ // with enough specificity (which file, which decision, which branch) that
471
+ // 300 chars often forced truncation into the body where the briefing
472
+ // doesn't read it.
439
473
  const nextStep = typeof frontmatter.next_step === 'string' ? frontmatter.next_step : '';
440
- if (nextStep.length > 300) {
474
+ if (nextStep.length > 800) {
441
475
  doc.warnings.push({
442
476
  path: doc.path,
443
477
  level: 'warning',
444
- message: `\`next_step\` is ${nextStep.length} chars (cap: 300). Long prose belongs in the body — keep next_step as a 1-2 line pointer.`,
478
+ message: `\`next_step\` is ${nextStep.length} chars (cap: 800). Long prose belongs in the body — keep next_step as a 1-2 sentence pointer.`,
445
479
  });
446
480
  }
447
481