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 +1 -1
- package/src/frontmatter-fix.mjs +1 -1
- package/src/lifecycle.mjs +29 -1
- package/src/prompts.mjs +6 -2
- package/src/query.mjs +5 -4
- package/src/util.mjs +10 -0
- package/src/validate.mjs +38 -4
package/package.json
CHANGED
package/src/frontmatter-fix.mjs
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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 =>
|
|
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
|
|
180
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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 >
|
|
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:
|
|
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
|
|