dotmd-cli 0.39.5 → 0.39.7
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/bin/dotmd.mjs +36 -0
- package/package.json +1 -1
- package/src/config.mjs +1 -1
- package/src/index.mjs +21 -1
- package/src/init.mjs +1 -1
- package/src/runlist.mjs +169 -0
- package/src/stats.mjs +39 -9
- package/src/validate.mjs +44 -0
package/bin/dotmd.mjs
CHANGED
|
@@ -49,6 +49,7 @@ Validate & Fix:
|
|
|
49
49
|
Lifecycle:
|
|
50
50
|
pickup <file> [--takeover] Pick up a plan (set in-session + print body)
|
|
51
51
|
release [<file>] [--to <s>] Release in-session lease (alias: unpickup)
|
|
52
|
+
runlist <hub> [next] Show or walk an ordered group of plans (see \`dotmd help runlist\`)
|
|
52
53
|
finish <file> [done|active] Finish a plan (set done or active)
|
|
53
54
|
status <file> <status> Transition document status
|
|
54
55
|
archive <file> Archive (status + move + update refs)
|
|
@@ -897,6 +898,40 @@ directory, updates references, and regenerates the index.
|
|
|
897
898
|
|
|
898
899
|
Use --dry-run (-n) to preview changes without writing anything.`,
|
|
899
900
|
|
|
901
|
+
runlist: `dotmd runlist <hub> [next] — work with an ordered group of plans
|
|
902
|
+
|
|
903
|
+
A "runlist" is just a plan with a \`runlist:\` array of child plan paths in its
|
|
904
|
+
frontmatter — there is no separate doc type. The hub plan can have any status;
|
|
905
|
+
the order of the children comes from the array.
|
|
906
|
+
|
|
907
|
+
Usage:
|
|
908
|
+
dotmd runlist <hub> Show children + their statuses, in order.
|
|
909
|
+
The first non-archived child is marked \`→\`.
|
|
910
|
+
dotmd runlist next <hub> Pick up the first non-archived child.
|
|
911
|
+
Stops if it's not in a pickup-able status
|
|
912
|
+
(active / planned / in-session) so you resolve
|
|
913
|
+
the blocker before continuing the runlist.
|
|
914
|
+
|
|
915
|
+
Flags (only meaningful with \`next\`, forwarded to pickup):
|
|
916
|
+
--takeover Override a held lease.
|
|
917
|
+
--full Print full plan body instead of the pickup card.
|
|
918
|
+
--no-index Skip index regeneration.
|
|
919
|
+
--show-files Emit \`files: …\` footer.
|
|
920
|
+
|
|
921
|
+
Common shape:
|
|
922
|
+
---
|
|
923
|
+
type: plan
|
|
924
|
+
status: active
|
|
925
|
+
title: Auth Revamp
|
|
926
|
+
runlist:
|
|
927
|
+
- auth-revamp-01-extract.md
|
|
928
|
+
- auth-revamp-02-rewrite.md
|
|
929
|
+
- auth-revamp-03-cleanup.md
|
|
930
|
+
---
|
|
931
|
+
|
|
932
|
+
Child plans should set \`parent_plan:\` back at the hub — \`dotmd check\` warns
|
|
933
|
+
when they don't.`,
|
|
934
|
+
|
|
900
935
|
'bulk-tag': `dotmd bulk-tag [files...] — fill in type/status frontmatter on pre-existing markdown
|
|
901
936
|
|
|
902
937
|
Scans the docs tree for files that are missing either \`type:\` or \`status:\`
|
|
@@ -1061,6 +1096,7 @@ async function main() {
|
|
|
1061
1096
|
if (command === 'journal') { const { runJournal } = await import('../src/journal-read.mjs'); runJournal(restArgs, config); return; }
|
|
1062
1097
|
if (command === 'pickup') { const { runPickup } = await import('../src/lifecycle.mjs'); await runPickup(restArgs, config, { dryRun }); return; }
|
|
1063
1098
|
if (command === 'unpickup' || command === 'release') { const { runUnpickup } = await import('../src/lifecycle.mjs'); await runUnpickup(restArgs, config, { dryRun }); return; }
|
|
1099
|
+
if (command === 'runlist') { const { runRunlist } = await import('../src/runlist.mjs'); await runRunlist(restArgs, config, { dryRun }); return; }
|
|
1064
1100
|
if (command === 'handoff') { die('`dotmd handoff` was removed in 0.31.0. Use `dotmd prompts new <name>` to create a saved prompt instead. The .dotmd/handoffs/ sidecar mechanism no longer exists; see CHANGELOG.'); }
|
|
1065
1101
|
if (command === 'finish') { const { runFinish } = await import('../src/lifecycle.mjs'); await runFinish(restArgs, config, { dryRun }); return; }
|
|
1066
1102
|
if (command === 'status') { const { runStatus } = await import('../src/lifecycle.mjs'); await runStatus(restArgs, config, { dryRun }); return; }
|
package/package.json
CHANGED
package/src/config.mjs
CHANGED
|
@@ -89,7 +89,7 @@ const DEFAULTS = {
|
|
|
89
89
|
// resolver work without config. Override by setting `referenceFields`
|
|
90
90
|
// in your config — your value fully replaces (no per-field merge).
|
|
91
91
|
bidirectional: ['related_plans', 'related_docs'],
|
|
92
|
-
unidirectional: ['parent_plan'],
|
|
92
|
+
unidirectional: ['parent_plan', 'runlist'],
|
|
93
93
|
},
|
|
94
94
|
|
|
95
95
|
templates: {},
|
package/src/index.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
4
|
import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts, extractBodyLinks } from './extractors.mjs';
|
|
5
5
|
import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn } from './util.mjs';
|
|
6
|
-
import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
|
|
6
|
+
import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, checkRunlistBackPointers, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
|
|
7
7
|
import { checkIndex } from './index-file.mjs';
|
|
8
8
|
import { checkClaudeCommands } from './claude-commands.mjs';
|
|
9
9
|
|
|
@@ -74,6 +74,18 @@ export function buildIndex(config, opts = {}) {
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
// Per-type counts (F6): same input docs, keyed by `type` first so callers
|
|
78
|
+
// can distinguish `plan/partial` (work shipped + tail deferred) from
|
|
79
|
+
// `doc/partial` (incomplete reference material). Untyped docs (pre-0.30
|
|
80
|
+
// corpora) land under `unknown` rather than getting dropped silently.
|
|
81
|
+
const countsByType = {};
|
|
82
|
+
for (const doc of transformedDocs) {
|
|
83
|
+
if (!doc.status) continue;
|
|
84
|
+
const type = doc.type || 'unknown';
|
|
85
|
+
if (!countsByType[type]) countsByType[type] = {};
|
|
86
|
+
countsByType[type][doc.status] = (countsByType[type][doc.status] ?? 0) + 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
77
89
|
if (!fast) {
|
|
78
90
|
if (config.indexPath) {
|
|
79
91
|
const indexCheck = checkIndex(transformedDocs, config);
|
|
@@ -84,6 +96,13 @@ export function buildIndex(config, opts = {}) {
|
|
|
84
96
|
const refCheck = checkBidirectionalReferences(transformedDocs, config);
|
|
85
97
|
warnings.push(...refCheck.warnings);
|
|
86
98
|
|
|
99
|
+
const runlistWarnings = checkRunlistBackPointers(transformedDocs, config);
|
|
100
|
+
warnings.push(...runlistWarnings);
|
|
101
|
+
for (const w of runlistWarnings) {
|
|
102
|
+
const child = transformedDocs.find(d => d.path === w.path);
|
|
103
|
+
if (child) child.warnings.push(w);
|
|
104
|
+
}
|
|
105
|
+
|
|
87
106
|
const gitWarnings = checkGitStaleness(transformedDocs, config);
|
|
88
107
|
warnings.push(...gitWarnings);
|
|
89
108
|
|
|
@@ -95,6 +114,7 @@ export function buildIndex(config, opts = {}) {
|
|
|
95
114
|
generatedAt: new Date().toISOString(),
|
|
96
115
|
docs: transformedDocs,
|
|
97
116
|
countsByStatus,
|
|
117
|
+
countsByType,
|
|
98
118
|
warnings,
|
|
99
119
|
errors,
|
|
100
120
|
};
|
package/src/init.mjs
CHANGED
|
@@ -64,7 +64,7 @@ export const index = {
|
|
|
64
64
|
// Add field names here (and to your templates) to track more relationships.
|
|
65
65
|
export const referenceFields = {
|
|
66
66
|
bidirectional: ['related_plans', 'related_docs'],
|
|
67
|
-
unidirectional: ['parent_plan'],
|
|
67
|
+
unidirectional: ['parent_plan', 'runlist'],
|
|
68
68
|
};
|
|
69
69
|
`;
|
|
70
70
|
|
package/src/runlist.mjs
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import {
|
|
5
|
+
asString,
|
|
6
|
+
die,
|
|
7
|
+
normalizeStringList,
|
|
8
|
+
resolveDocPath,
|
|
9
|
+
resolveRefPath,
|
|
10
|
+
toRepoPath,
|
|
11
|
+
toSlug,
|
|
12
|
+
} from './util.mjs';
|
|
13
|
+
import { bold, cyan, dim, green, red, yellow } from './color.mjs';
|
|
14
|
+
|
|
15
|
+
const PICKUPABLE_STATUSES = new Set(['active', 'planned', 'in-session']);
|
|
16
|
+
|
|
17
|
+
// Read a hub plan's `runlist:` and resolve each entry to a repo-relative path
|
|
18
|
+
// plus its current status. Missing files are reported with `missing: true`;
|
|
19
|
+
// callers decide how to render them. Pure: no IO beyond file reads.
|
|
20
|
+
function readRunlistChildren(hubAbsPath, config) {
|
|
21
|
+
const raw = readFileSync(hubAbsPath, 'utf8');
|
|
22
|
+
const { frontmatter: fmRaw } = extractFrontmatter(raw);
|
|
23
|
+
const fm = parseSimpleFrontmatter(fmRaw);
|
|
24
|
+
const refs = normalizeStringList(fm.runlist);
|
|
25
|
+
|
|
26
|
+
const hubDir = path.dirname(hubAbsPath);
|
|
27
|
+
const out = [];
|
|
28
|
+
for (const ref of refs) {
|
|
29
|
+
const abs = resolveRefPath(ref, hubDir, config.repoRoot);
|
|
30
|
+
if (!abs) {
|
|
31
|
+
out.push({ ref, path: null, status: null, title: null, missing: true });
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const repoPath = toRepoPath(abs, config.repoRoot);
|
|
35
|
+
try {
|
|
36
|
+
const childRaw = readFileSync(abs, 'utf8');
|
|
37
|
+
const { frontmatter: childFmRaw } = extractFrontmatter(childRaw);
|
|
38
|
+
const childFm = parseSimpleFrontmatter(childFmRaw);
|
|
39
|
+
out.push({
|
|
40
|
+
ref,
|
|
41
|
+
path: repoPath,
|
|
42
|
+
status: asString(childFm.status) ?? null,
|
|
43
|
+
title: asString(childFm.title) ?? path.basename(abs, '.md'),
|
|
44
|
+
parentPlan: childFm.parent_plan ?? null,
|
|
45
|
+
missing: false,
|
|
46
|
+
});
|
|
47
|
+
} catch {
|
|
48
|
+
out.push({ ref, path: repoPath, status: null, title: null, missing: true });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const STATUS_TAG_COLORS = {
|
|
55
|
+
'in-session': (s) => bold(red(s)),
|
|
56
|
+
'active': green,
|
|
57
|
+
'planned': (s) => s,
|
|
58
|
+
'blocked': yellow,
|
|
59
|
+
'partial': (s) => dim(green(s)),
|
|
60
|
+
'paused': (s) => yellow(s),
|
|
61
|
+
'awaiting': yellow,
|
|
62
|
+
'queued-after': (s) => dim(cyan(s)),
|
|
63
|
+
'archived': dim,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function colorStatus(status) {
|
|
67
|
+
const fn = STATUS_TAG_COLORS[status] ?? ((s) => s);
|
|
68
|
+
return fn(status ?? 'unknown');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function renderRunlist(hubRepoPath, children, opts = {}) {
|
|
72
|
+
const lines = [];
|
|
73
|
+
lines.push(bold(`runlist: ${hubRepoPath}`));
|
|
74
|
+
if (children.length === 0) {
|
|
75
|
+
lines.push(dim(' (empty — add child plan paths to the hub plan\'s `runlist:` field)'));
|
|
76
|
+
return lines.join('\n') + '\n';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const archiveStatuses = opts.archiveStatuses ?? new Set(['archived']);
|
|
80
|
+
let nextPicked = false;
|
|
81
|
+
for (let i = 0; i < children.length; i++) {
|
|
82
|
+
const c = children[i];
|
|
83
|
+
const idx = String(i + 1).padStart(2);
|
|
84
|
+
if (c.missing) {
|
|
85
|
+
lines.push(` ${idx}. ${red('missing')} ${c.ref}`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const isNext = !nextPicked && !archiveStatuses.has(c.status);
|
|
89
|
+
if (isNext) nextPicked = true;
|
|
90
|
+
const marker = isNext ? green('→') : ' ';
|
|
91
|
+
const statusTag = `[${colorStatus(c.status)}]`;
|
|
92
|
+
lines.push(` ${marker} ${idx}. ${statusTag} ${c.path}`);
|
|
93
|
+
}
|
|
94
|
+
if (!nextPicked) {
|
|
95
|
+
lines.push('');
|
|
96
|
+
lines.push(dim(' All children archived. Hub is ready for archive.'));
|
|
97
|
+
}
|
|
98
|
+
return lines.join('\n') + '\n';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function runRunlist(argv, config, opts = {}) {
|
|
102
|
+
const json = argv.includes('--json');
|
|
103
|
+
const positional = argv.filter(a => !a.startsWith('-'));
|
|
104
|
+
|
|
105
|
+
// Subcommand dispatch: `runlist <hub>` (show) vs `runlist next <hub>` (pickup)
|
|
106
|
+
const sub = positional[0] === 'next' ? 'next' : 'show';
|
|
107
|
+
const hubInput = sub === 'next' ? positional[1] : positional[0];
|
|
108
|
+
|
|
109
|
+
if (!hubInput) {
|
|
110
|
+
die(sub === 'next'
|
|
111
|
+
? 'Usage: dotmd runlist next <hub-plan>'
|
|
112
|
+
: 'Usage: dotmd runlist <hub-plan>');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const hubAbs = resolveDocPath(hubInput, config);
|
|
116
|
+
if (!hubAbs) die(`Hub plan not found: ${hubInput}`);
|
|
117
|
+
const hubRepoPath = toRepoPath(hubAbs, config.repoRoot);
|
|
118
|
+
|
|
119
|
+
const children = readRunlistChildren(hubAbs, config);
|
|
120
|
+
const archiveStatuses = config.lifecycle?.archiveStatuses ?? new Set(['archived']);
|
|
121
|
+
|
|
122
|
+
if (sub === 'show') {
|
|
123
|
+
if (json) {
|
|
124
|
+
process.stdout.write(JSON.stringify({
|
|
125
|
+
hub: hubRepoPath,
|
|
126
|
+
children,
|
|
127
|
+
}, null, 2) + '\n');
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
process.stdout.write(renderRunlist(hubRepoPath, children, { archiveStatuses }));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// sub === 'next' — find first non-archived non-missing child and pick it up.
|
|
135
|
+
const target = children.find(c => !c.missing && !archiveStatuses.has(c.status));
|
|
136
|
+
if (!target) {
|
|
137
|
+
if (children.length === 0) die(`Hub ${hubRepoPath} has empty \`runlist:\` — nothing to pick up.`);
|
|
138
|
+
const allArchived = children.every(c => !c.missing && archiveStatuses.has(c.status));
|
|
139
|
+
if (allArchived) {
|
|
140
|
+
die(`All children in runlist ${hubRepoPath} are archived. Hub is ready for \`dotmd archive ${hubRepoPath}\`.`);
|
|
141
|
+
}
|
|
142
|
+
const missing = children.filter(c => c.missing).map(c => c.ref);
|
|
143
|
+
die(`No pickup-able child in runlist ${hubRepoPath}. Unresolved refs: ${missing.join(', ')}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Pre-check status: pickup will die on non-pickup-able statuses, but with
|
|
147
|
+
// a generic message. Surface the runlist context first so the agent knows
|
|
148
|
+
// which list is blocked and on which item.
|
|
149
|
+
if (!PICKUPABLE_STATUSES.has(target.status)) {
|
|
150
|
+
die(
|
|
151
|
+
`Next child in runlist ${hubRepoPath} is ${target.path} (status: ${target.status}).\n` +
|
|
152
|
+
`Resolve the blocker before continuing the runlist.\n` +
|
|
153
|
+
` dotmd status ${target.path} active # if ready to resume\n` +
|
|
154
|
+
` dotmd pickup ${target.path} # to inspect`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Delegate to runPickup — same lease semantics, same VH append, same card
|
|
159
|
+
// render. Dynamic import to avoid circular module-load cost when the
|
|
160
|
+
// runlist command isn't used.
|
|
161
|
+
const { runPickup } = await import('./lifecycle.mjs');
|
|
162
|
+
const pickupArgs = [target.path];
|
|
163
|
+
if (argv.includes('--takeover')) pickupArgs.push('--takeover');
|
|
164
|
+
if (argv.includes('--full')) pickupArgs.push('--full');
|
|
165
|
+
if (argv.includes('--no-index')) pickupArgs.push('--no-index');
|
|
166
|
+
if (argv.includes('--show-files')) pickupArgs.push('--show-files');
|
|
167
|
+
if (json) pickupArgs.push('--json');
|
|
168
|
+
await runPickup(pickupArgs, config, opts);
|
|
169
|
+
}
|
package/src/stats.mjs
CHANGED
|
@@ -46,6 +46,7 @@ export function buildStats(index, config) {
|
|
|
46
46
|
generatedAt: new Date().toISOString(),
|
|
47
47
|
totalDocs: docs.length,
|
|
48
48
|
countsByStatus: index.countsByStatus,
|
|
49
|
+
countsByType: index.countsByType ?? {},
|
|
49
50
|
health: {
|
|
50
51
|
staleCount,
|
|
51
52
|
stalePct: pct(staleCount, nonArchived.length),
|
|
@@ -97,16 +98,45 @@ function _renderStats(stats, config) {
|
|
|
97
98
|
lines.push(bold(`Stats`) + dim(` — ${stats.totalDocs} docs`));
|
|
98
99
|
lines.push('');
|
|
99
100
|
|
|
100
|
-
// Status
|
|
101
|
+
// Status. With F6, render one line per type when 2+ types have docs so
|
|
102
|
+
// `plan/partial` and `doc/partial` (semantically distinct under per-type
|
|
103
|
+
// taxonomies) don't collapse into one number. Single-type corpora keep the
|
|
104
|
+
// existing flat line — no needless `Plans:` header on a plans-only repo.
|
|
101
105
|
lines.push(bold('Status'));
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
106
|
+
const typesWithDocs = Object.entries(stats.countsByType ?? {})
|
|
107
|
+
.filter(([, statusMap]) => Object.values(statusMap).some(n => n > 0));
|
|
108
|
+
if (typesWithDocs.length > 1) {
|
|
109
|
+
// Type order: respect config.validTypes declaration order; unknown last.
|
|
110
|
+
const typeOrder = [...(config.validTypes ?? [])];
|
|
111
|
+
const sortedTypes = typesWithDocs.sort(([a], [b]) => {
|
|
112
|
+
const ai = typeOrder.indexOf(a);
|
|
113
|
+
const bi = typeOrder.indexOf(b);
|
|
114
|
+
if (ai === -1 && bi === -1) return a.localeCompare(b);
|
|
115
|
+
if (ai === -1) return 1;
|
|
116
|
+
if (bi === -1) return -1;
|
|
117
|
+
return ai - bi;
|
|
118
|
+
});
|
|
119
|
+
const labelFor = (t) => t === 'unknown' ? 'Untyped' : (t.charAt(0).toUpperCase() + t.slice(1) + 's');
|
|
120
|
+
for (const [type, statusMap] of sortedTypes) {
|
|
121
|
+
const statusesForType = [
|
|
122
|
+
...config.statusOrder.filter(s => statusMap[s]),
|
|
123
|
+
...Object.keys(statusMap).filter(s => !config.statusOrder.includes(s)).sort(),
|
|
124
|
+
];
|
|
125
|
+
const parts = statusesForType
|
|
126
|
+
.filter(s => statusMap[s])
|
|
127
|
+
.map(s => `${s}: ${statusMap[s]}`);
|
|
128
|
+
lines.push(` ${dim(labelFor(type) + ':')} ${parts.join(' ')}`);
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
const allStatuses = [
|
|
132
|
+
...config.statusOrder.filter(s => stats.countsByStatus[s]),
|
|
133
|
+
...Object.keys(stats.countsByStatus).filter(s => !config.statusOrder.includes(s)).sort(),
|
|
134
|
+
];
|
|
135
|
+
const statusParts = allStatuses
|
|
136
|
+
.filter(s => stats.countsByStatus[s])
|
|
137
|
+
.map(s => `${s}: ${stats.countsByStatus[s]}`);
|
|
138
|
+
lines.push(' ' + statusParts.join(' '));
|
|
139
|
+
}
|
|
110
140
|
lines.push('');
|
|
111
141
|
|
|
112
142
|
// Health
|
package/src/validate.mjs
CHANGED
|
@@ -358,6 +358,50 @@ export function checkBidirectionalReferences(docs, config) {
|
|
|
358
358
|
return { warnings, errors: [] };
|
|
359
359
|
}
|
|
360
360
|
|
|
361
|
+
// Runlist back-pointer check: when a hub plan declares a `runlist:` of children,
|
|
362
|
+
// each child SHOULD have `parent_plan:` pointing back at the hub. The two
|
|
363
|
+
// fields encode complementary information (runlist = ordered execution intent;
|
|
364
|
+
// parent_plan = reverse-link the rest of dotmd already uses for related-summary
|
|
365
|
+
// rendering), so divergence almost always means the agent forgot the back-link.
|
|
366
|
+
// Warning fires on the CHILD (that's the file that needs the edit). Skips
|
|
367
|
+
// terminal/archive statuses on either side — runlists referencing closed work
|
|
368
|
+
// are a normal history pattern.
|
|
369
|
+
export function checkRunlistBackPointers(docs, config) {
|
|
370
|
+
const warnings = [];
|
|
371
|
+
const skipStatuses = new Set([
|
|
372
|
+
...(config.lifecycle.terminalStatuses ?? []),
|
|
373
|
+
...(config.lifecycle.skipWarningsFor ?? []),
|
|
374
|
+
]);
|
|
375
|
+
const byPath = new Map(docs.map(d => [d.path, d]));
|
|
376
|
+
|
|
377
|
+
for (const hub of docs) {
|
|
378
|
+
if (skipStatuses.has(hub.status)) continue;
|
|
379
|
+
const runlistRefs = hub.refFields?.runlist ?? [];
|
|
380
|
+
if (runlistRefs.length === 0) continue;
|
|
381
|
+
const hubDir = path.dirname(path.join(config.repoRoot, hub.path));
|
|
382
|
+
|
|
383
|
+
for (const ref of runlistRefs) {
|
|
384
|
+
const resolved = resolveRefPath(ref, hubDir, config.repoRoot);
|
|
385
|
+
if (!resolved) continue; // unresolved refs already get their own existence error
|
|
386
|
+
const childPath = toRepoPath(resolved, config.repoRoot);
|
|
387
|
+
const child = byPath.get(childPath);
|
|
388
|
+
if (!child) continue;
|
|
389
|
+
if (skipStatuses.has(child.status)) continue;
|
|
390
|
+
const childParents = (child.refFields?.parent_plan ?? []).map(p => {
|
|
391
|
+
const abs = resolveRefPath(p, path.dirname(path.join(config.repoRoot, child.path)), config.repoRoot);
|
|
392
|
+
return abs ? toRepoPath(abs, config.repoRoot) : p;
|
|
393
|
+
});
|
|
394
|
+
if (childParents.includes(hub.path)) continue;
|
|
395
|
+
warnings.push({
|
|
396
|
+
path: child.path,
|
|
397
|
+
level: 'warning',
|
|
398
|
+
message: `appears in runlist of \`${hub.path}\` but \`parent_plan:\` does not point back at it. Add \`parent_plan: ${hub.path}\` so reverse-link tooling (pickup-card Related:, graph) stays consistent.`,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return warnings;
|
|
403
|
+
}
|
|
404
|
+
|
|
361
405
|
export function checkGitStaleness(docs, config) {
|
|
362
406
|
const warnings = [];
|
|
363
407
|
const gitDates = getGitLastModifiedBatch(config.repoRoot);
|