runlist 0.0.0 → 0.94.1
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/LICENSE +21 -0
- package/README.md +1114 -6
- package/assets/app/app.css +283 -0
- package/assets/app/app.mjs +381 -0
- package/assets/app/block-editor.mjs +182 -0
- package/assets/app/block-model.mjs +52 -0
- package/assets/app/document-create.mjs +86 -0
- package/assets/app/document-lifecycle.mjs +36 -0
- package/assets/app/document-stages.mjs +22 -0
- package/assets/app/document-views.mjs +33 -0
- package/assets/app/document-yardstick.mjs +258 -0
- package/assets/app/editor-navigation.mjs +51 -0
- package/assets/app/filing-navigation.mjs +138 -0
- package/assets/app/git-changes.mjs +80 -0
- package/assets/app/git-commit.mjs +46 -0
- package/assets/app/index.html +70 -0
- package/assets/app/library-navigation.mjs +93 -0
- package/assets/app/outline.mjs +18 -0
- package/assets/app/quick-navigation.mjs +52 -0
- package/assets/app/record-navigation.mjs +78 -0
- package/assets/app/recovery-center.mjs +53 -0
- package/assets/app/semantic-search.mjs +14 -0
- package/assets/app/settings.mjs +15 -0
- package/assets/app/shared.mjs +104 -0
- package/assets/app/stage-source.mjs +75 -0
- package/assets/app/template-editor.mjs +47 -0
- package/assets/app/theme.mjs +25 -0
- package/assets/app/transport.mjs +43 -0
- package/assets/contracts/source-editing-v1.md +232 -0
- package/assets/opencode/plugin.js +162 -0
- package/assets/schemas/runlist-record-v1.schema.json +912 -0
- package/bin/dotmd.mjs +5 -0
- package/bin/runlist.mjs +2714 -0
- package/package.json +53 -6
- package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
- package/plugins/runlist-codex/bin/runlist-hook +86 -0
- package/plugins/runlist-codex/hooks/hooks.json +16 -0
- package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
- package/runlist.config.example.mjs +370 -0
- package/scripts/postinstall.mjs +56 -0
- package/src/agent-context.mjs +201 -0
- package/src/ai.mjs +30 -0
- package/src/app-create.mjs +169 -0
- package/src/app-filing.mjs +139 -0
- package/src/app-git-commits.mjs +48 -0
- package/src/app-git.mjs +180 -0
- package/src/app-library.mjs +152 -0
- package/src/app-lifecycle-worker.mjs +12 -0
- package/src/app-lifecycle.mjs +79 -0
- package/src/app-records.mjs +134 -0
- package/src/app-search.mjs +80 -0
- package/src/app-semantic-search.mjs +105 -0
- package/src/app-service.mjs +208 -0
- package/src/app-yardstick.mjs +269 -0
- package/src/app.mjs +102 -0
- package/src/atomic-mutation.mjs +1736 -0
- package/src/baton.mjs +435 -0
- package/src/body-link.mjs +48 -0
- package/src/bulk-tag.mjs +174 -0
- package/src/check-collapse.mjs +75 -0
- package/src/claude-commands.mjs +75 -0
- package/src/cli-editor.mjs +8 -0
- package/src/code-refs.mjs +545 -0
- package/src/codex-integration.mjs +81 -0
- package/src/color.mjs +14 -0
- package/src/commands.mjs +374 -0
- package/src/completions.mjs +93 -0
- package/src/config-edit.mjs +606 -0
- package/src/config.mjs +705 -0
- package/src/decision-action.mjs +58 -0
- package/src/decision.mjs +220 -0
- package/src/decisions.mjs +787 -0
- package/src/deps.mjs +317 -0
- package/src/desktop-install.mjs +158 -0
- package/src/desktop-runtime.mjs +4 -0
- package/src/desktop.mjs +25 -0
- package/src/diff.mjs +101 -0
- package/src/doctor.mjs +591 -0
- package/src/durable-rename.mjs +55 -0
- package/src/errors-read.mjs +44 -0
- package/src/export.mjs +496 -0
- package/src/extractors.mjs +101 -0
- package/src/filing.mjs +255 -0
- package/src/fix-membership.mjs +134 -0
- package/src/fix-refs.mjs +174 -0
- package/src/flags.mjs +318 -0
- package/src/frontmatter-fix.mjs +195 -0
- package/src/frontmatter.mjs +252 -0
- package/src/git-commit-filters.mjs +32 -0
- package/src/git-commit-protocol.mjs +198 -0
- package/src/git-commit-signing.mjs +75 -0
- package/src/git-commit-store.mjs +26 -0
- package/src/git-commit-support.mjs +16 -0
- package/src/git-commit-worker.mjs +32 -0
- package/src/git.mjs +909 -0
- package/src/glossary-check.mjs +35 -0
- package/src/glossary.mjs +243 -0
- package/src/graph.mjs +298 -0
- package/src/guard.mjs +488 -0
- package/src/health.mjs +212 -0
- package/src/hints.mjs +129 -0
- package/src/host-integration.mjs +339 -0
- package/src/hub-membership.mjs +284 -0
- package/src/hub.mjs +292 -0
- package/src/hud.mjs +436 -0
- package/src/index-file.mjs +163 -0
- package/src/index.mjs +478 -0
- package/src/init.mjs +512 -0
- package/src/install.mjs +222 -0
- package/src/journal-read.mjs +116 -0
- package/src/journal.mjs +472 -0
- package/src/lifecycle.mjs +1444 -0
- package/src/lint.mjs +386 -0
- package/src/managed-path.mjs +192 -0
- package/src/markdown-code-spans.mjs +58 -0
- package/src/migrate-prompts.mjs +171 -0
- package/src/migrate-template.mjs +170 -0
- package/src/migrate.mjs +90 -0
- package/src/misuse-read.mjs +61 -0
- package/src/model-request.mjs +33 -0
- package/src/model.mjs +523 -0
- package/src/modules.mjs +355 -0
- package/src/naming.mjs +55 -0
- package/src/native-action.mjs +59 -0
- package/src/native-record.mjs +244 -0
- package/src/new.mjs +1321 -0
- package/src/output-identity.mjs +111 -0
- package/src/parent-plan.mjs +58 -0
- package/src/parse-cache.mjs +99 -0
- package/src/path-containment.mjs +15 -0
- package/src/pickup-card.mjs +293 -0
- package/src/pickup.mjs +671 -0
- package/src/prompt.mjs +25 -0
- package/src/prompts.mjs +585 -0
- package/src/query.mjs +977 -0
- package/src/record-action.mjs +11 -0
- package/src/record-create.mjs +82 -0
- package/src/reference-planner.mjs +388 -0
- package/src/reference-values.mjs +11 -0
- package/src/rename.mjs +107 -0
- package/src/render.mjs +678 -0
- package/src/roadmap.mjs +184 -0
- package/src/runlist.mjs +944 -0
- package/src/section.mjs +225 -0
- package/src/semantic-search-client.mjs +58 -0
- package/src/ship.mjs +166 -0
- package/src/show.mjs +101 -0
- package/src/skill-drift.mjs +98 -0
- package/src/source-editor.mjs +491 -0
- package/src/stages.mjs +73 -0
- package/src/state-migration.mjs +123 -0
- package/src/stats.mjs +200 -0
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +721 -0
- package/src/summary.mjs +74 -0
- package/src/surfaces.mjs +28 -0
- package/src/sync-status.mjs +316 -0
- package/src/template-store.mjs +62 -0
- package/src/update.mjs +338 -0
- package/src/use.mjs +58 -0
- package/src/util.mjs +337 -0
- package/src/validate.mjs +794 -0
- package/src/watch.mjs +56 -0
- package/src/xref.mjs +406 -0
- package/src/yardstick-command.mjs +178 -0
- package/src/yardstick.mjs +112 -0
package/src/validate.mjs
ADDED
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { asString, resolveRefPath, suggestCandidates } from './util.mjs';
|
|
3
|
+
import { getGitLastModified, getGitLastModifiedBatch, getGitLastSubstantiveModifiedBatch } from './git.mjs';
|
|
4
|
+
import { toRepoPath } from './util.mjs';
|
|
5
|
+
import { detectMarker, isPhaseHeading, phaseMarkerConflict, walkSections } from './section.mjs';
|
|
6
|
+
import { resolveBodyLinkTarget } from './body-link.mjs';
|
|
7
|
+
import { getStageDefinitions, readPlanStage } from './stages.mjs';
|
|
8
|
+
import { assessmentIssues } from './yardstick.mjs';
|
|
9
|
+
|
|
10
|
+
const NOW = new Date();
|
|
11
|
+
|
|
12
|
+
// Type-conventional dirs are the directories where `runlist new <type>` lands
|
|
13
|
+
// live (non-archive) docs of that type. Built-ins use `dir` ('plans'/'prompts')
|
|
14
|
+
// and `targetRoot`. In flat-array root configs (e.g. root: ['docs/plans',
|
|
15
|
+
// 'docs/prompts']), the root itself is a type-conventional dir; in default
|
|
16
|
+
// single-root configs (root: 'docs'), the type-conventional dirs are
|
|
17
|
+
// '<root>/plans' and '<root>/prompts'. This helper builds the union so the
|
|
18
|
+
// archive-drift check works for both layouts. Custom user templates with
|
|
19
|
+
// their own `dir` would extend this; we hard-code the built-in dir names.
|
|
20
|
+
const BUILTIN_TYPE_DIR_NAMES = ['plans', 'prompts'];
|
|
21
|
+
|
|
22
|
+
function liveTypeDirsForRoots(config) {
|
|
23
|
+
const set = new Set();
|
|
24
|
+
const roots = config.docsRoots || (config.docsRoot ? [config.docsRoot] : []);
|
|
25
|
+
// F15: filed-status bucket dirs are "live" too — a doc whose status is
|
|
26
|
+
// an archive status but whose parent dir is a filed bucket should still
|
|
27
|
+
// trigger the archive-drift warning (the file needs to move into the
|
|
28
|
+
// archive bucket).
|
|
29
|
+
const filedDirs = [...((config.lifecycle?.filedStatuses?.values?.()) ?? [])];
|
|
30
|
+
for (const root of roots) {
|
|
31
|
+
const rootRel = path.relative(config.repoRoot, root).split(path.sep).join('/');
|
|
32
|
+
// The root itself is a live dir (covers flat-array layouts where the
|
|
33
|
+
// root IS the type-container).
|
|
34
|
+
set.add(rootRel);
|
|
35
|
+
// Each builtin type-dir joined to the root (covers single-root layouts
|
|
36
|
+
// where 'docs' contains 'docs/plans' and 'docs/prompts' subdirs).
|
|
37
|
+
for (const dirName of BUILTIN_TYPE_DIR_NAMES) {
|
|
38
|
+
// Skip if root already ends in this name (no double-nesting like
|
|
39
|
+
// 'docs/prompts/prompts').
|
|
40
|
+
if (path.basename(rootRel) === dirName) continue;
|
|
41
|
+
set.add(rootRel ? `${rootRel}/${dirName}` : dirName);
|
|
42
|
+
}
|
|
43
|
+
// User template dirs from config (extend the set with whatever live
|
|
44
|
+
// dirs custom types declare).
|
|
45
|
+
for (const tmpl of Object.values(config.raw?.templates ?? {})) {
|
|
46
|
+
if (!tmpl || typeof tmpl !== 'object') continue;
|
|
47
|
+
if (tmpl.dir && path.basename(rootRel) !== tmpl.dir) {
|
|
48
|
+
set.add(rootRel ? `${rootRel}/${tmpl.dir}` : tmpl.dir);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// F15: filed bucket dirs joined to each root.
|
|
52
|
+
for (const dirName of filedDirs) {
|
|
53
|
+
if (path.basename(rootRel) === dirName) continue;
|
|
54
|
+
set.add(rootRel ? `${rootRel}/${dirName}` : dirName);
|
|
55
|
+
for (const typeDir of BUILTIN_TYPE_DIR_NAMES) {
|
|
56
|
+
if (path.basename(rootRel) === typeDir) continue;
|
|
57
|
+
const typeRoot = rootRel ? `${rootRel}/${typeDir}` : typeDir;
|
|
58
|
+
set.add(`${typeRoot}/${dirName}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return set;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isValidStatus(status, root, config, type) {
|
|
66
|
+
// When a doc declares a known type, that type's status set is authoritative.
|
|
67
|
+
// Falling through to the global union (across all types) would allow a
|
|
68
|
+
// `type: prompt` doc to carry `status: active` just because `active` is valid
|
|
69
|
+
// for plans — defeating the purpose of type-scoped vocabularies.
|
|
70
|
+
if (type) {
|
|
71
|
+
const typeSet = config.typeStatuses?.get(type);
|
|
72
|
+
if (typeSet) return typeSet.has(status);
|
|
73
|
+
}
|
|
74
|
+
const rootSet = config.rootValidStatuses?.get(root);
|
|
75
|
+
if (rootSet) return rootSet.has(status);
|
|
76
|
+
return config.validStatuses.has(status);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function validateDoc(doc, frontmatter, headingTitle, config) {
|
|
80
|
+
if (doc.type === 'plan') {
|
|
81
|
+
const archived = config.lifecycle.archiveStatuses.has(doc.status) || doc.path.split('/').includes(config.archiveDir);
|
|
82
|
+
for (const message of assessmentIssues(frontmatter, archived)) {
|
|
83
|
+
doc.warnings.push({ path: doc.path, level: 'warning', message });
|
|
84
|
+
}
|
|
85
|
+
const stage = readPlanStage(frontmatter.ships);
|
|
86
|
+
const milestones = (config.raw ?? config).taxonomy?.milestones;
|
|
87
|
+
if (stage.invalid) {
|
|
88
|
+
doc.errors.push({ path: doc.path, level: 'error', message: '`ships` must be a single stage string on one line without NUL characters; leave it blank or omit it for Unset.' });
|
|
89
|
+
} else if (stage.word && Array.isArray(milestones) && !getStageDefinitions(config).some(definition => definition.word === stage.word)) {
|
|
90
|
+
doc.errors.push({ path: doc.path, level: 'error', message: `Unknown stage \`${stage.word}\` in \`ships\`; expected a configured \`taxonomy.milestones\` value.` });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Validate type field
|
|
95
|
+
if (doc.type && config.validTypes && !config.validTypes.has(doc.type)) {
|
|
96
|
+
doc.warnings.push({ path: doc.path, level: 'warning', message: `Unknown type \`${doc.type}\`; expected one of: ${[...config.validTypes].join(', ')}.` });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!doc.status) {
|
|
100
|
+
doc.errors.push({ path: doc.path, level: 'error', message: 'Missing frontmatter `status`.' });
|
|
101
|
+
} else if (!isValidStatus(doc.status, doc.root, config, doc.type)) {
|
|
102
|
+
const typeSet = doc.type && config.typeStatuses?.get(doc.type);
|
|
103
|
+
const rootSet = config.rootValidStatuses?.get(doc.root);
|
|
104
|
+
// When the doc has a known type, scope the error hint to that type's vocab.
|
|
105
|
+
// Otherwise fall back to root-specific or global validStatuses.
|
|
106
|
+
const hint = typeSet
|
|
107
|
+
? `valid for type \`${doc.type}\`: ${[...typeSet].join(', ')}`
|
|
108
|
+
: `valid: ${[...(rootSet ?? config.validStatuses)].join(', ')}`;
|
|
109
|
+
doc.errors.push({ path: doc.path, level: 'error', message: `Unknown status \`${doc.status}\`; ${hint}.` });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const knownStatus = isValidStatus(doc.status, doc.root, config, doc.type);
|
|
113
|
+
|
|
114
|
+
if (knownStatus && !config.lifecycle.skipsWarnings(doc.status, doc.type) && !doc.updated) {
|
|
115
|
+
doc.errors.push({ path: doc.path, level: 'error', message: 'Missing frontmatter `updated` for non-archived doc.' });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (knownStatus && doc.auditLevel && doc.auditLevel !== 'none' && !doc.audited) {
|
|
119
|
+
doc.errors.push({ path: doc.path, level: 'error', message: '`audit_level` is set without `audited`.' });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (knownStatus && doc.auditLevel === 'none' && doc.audited) {
|
|
123
|
+
doc.errors.push({ path: doc.path, level: 'error', message: '`audit_level: none` cannot be combined with `audited`.' });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (Object.prototype.hasOwnProperty.call(frontmatter, 'blockers') && !Array.isArray(frontmatter.blockers)) {
|
|
127
|
+
doc.errors.push({ path: doc.path, level: 'error', message: '`blockers` must be a YAML list when present.' });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (Object.prototype.hasOwnProperty.call(frontmatter, 'blocked_by') && !Array.isArray(frontmatter.blocked_by)) {
|
|
131
|
+
doc.errors.push({ path: doc.path, level: 'error', message: '`blocked_by` must be a YAML list when present.' });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (Object.prototype.hasOwnProperty.call(frontmatter, 'surfaces') && !Array.isArray(frontmatter.surfaces)) {
|
|
135
|
+
doc.errors.push({ path: doc.path, level: 'error', message: '`surfaces` must be a YAML list when present.' });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (Object.prototype.hasOwnProperty.call(frontmatter, 'modules') && !Array.isArray(frontmatter.modules)) {
|
|
139
|
+
doc.errors.push({ path: doc.path, level: 'error', message: '`modules` must be a YAML list when present.' });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// modules-required gate: only fires when the project has actively declared
|
|
143
|
+
// a module taxonomy (`taxonomy.modules` is an array). Repos with no product
|
|
144
|
+
// modules (CLIs, dev tooling, single-domain apps) shouldn't be forced to
|
|
145
|
+
// sprinkle `modules: [none]` on every plan as a sentinel. Mirrors the
|
|
146
|
+
// long-standing `taxonomy.surfaces` opt-in semantics.
|
|
147
|
+
if (config.validModules && config.moduleRequiredStatuses.has(doc.status) && !doc.modules?.length) {
|
|
148
|
+
doc.errors.push({ path: doc.path, level: 'error', message: '`modules` is required for this status; declare a real module from `taxonomy.modules`, or `none` as the explicit no-module sentinel.' });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (config.validSurfaces && !config.lifecycle.skipsWarnings(doc.status, doc.type)) {
|
|
152
|
+
const knownSurfaces = [...config.validSurfaces];
|
|
153
|
+
for (const surface of doc.surfaces) {
|
|
154
|
+
if (!config.validSurfaces.has(surface)) {
|
|
155
|
+
const suggestions = suggestCandidates(surface, knownSurfaces, 3);
|
|
156
|
+
const hint = suggestions.length
|
|
157
|
+
? ` Did you mean: ${suggestions.map(s => `\`${s}\``).join(' | ')}?`
|
|
158
|
+
: '';
|
|
159
|
+
doc.warnings.push({
|
|
160
|
+
path: doc.path,
|
|
161
|
+
level: 'warning',
|
|
162
|
+
message: `Unknown surface \`${surface}\`; expected a known surface taxonomy value.${hint} Run \`runlist surfaces\` to list all valid values.`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// F18: Singular `module:` / `surface:` are deprecated in favor of plural arrays.
|
|
169
|
+
// The reader still merges singular into plural transparently (back-compat),
|
|
170
|
+
// but new usage should always go through the plural form. The migration
|
|
171
|
+
// target is inlined in the message so `runlist lint --fix` users see exactly
|
|
172
|
+
// what they'll end up with — and so non-fix readers can hand-migrate.
|
|
173
|
+
// Suppress for archived/terminal docs (same noise-control rule as F2).
|
|
174
|
+
if (!config.lifecycle.skipsWarnings(doc.status, doc.type)) {
|
|
175
|
+
for (const { singular, plural } of [{ singular: 'module', plural: 'modules' }, { singular: 'surface', plural: 'surfaces' }]) {
|
|
176
|
+
const singularValue = frontmatter[singular];
|
|
177
|
+
if (!Object.prototype.hasOwnProperty.call(frontmatter, singular)) continue;
|
|
178
|
+
const rawPluralValue = frontmatter[plural];
|
|
179
|
+
const pluralValue = Array.isArray(rawPluralValue)
|
|
180
|
+
? rawPluralValue
|
|
181
|
+
: rawPluralValue === undefined ? [] : [rawPluralValue];
|
|
182
|
+
const singularValues = Array.isArray(singularValue) ? singularValue : [singularValue];
|
|
183
|
+
const merged = [];
|
|
184
|
+
for (const v of [...singularValues, ...pluralValue]) {
|
|
185
|
+
if (typeof v === 'string' && v && !merged.includes(v)) merged.push(v);
|
|
186
|
+
}
|
|
187
|
+
const target = `${plural}: [${merged.map(v => JSON.stringify(v)).join(', ')}]`;
|
|
188
|
+
const autoFixableString = value => typeof value === 'string' && !/(^|\s)#/.test(value);
|
|
189
|
+
const autoFixable = singularValues.every(autoFixableString)
|
|
190
|
+
&& pluralValue.every(autoFixableString);
|
|
191
|
+
const guidance = autoFixable
|
|
192
|
+
? `use \`${target}\`. Run \`runlist lint --fix\` to migrate.`
|
|
193
|
+
: `use a \`${plural}:\` YAML list. Remove the deprecated \`${singular}:\` block manually and preserve all of its values.`;
|
|
194
|
+
doc.warnings.push({
|
|
195
|
+
path: doc.path,
|
|
196
|
+
level: 'warning',
|
|
197
|
+
message: `\`${singular}:\` (singular) is deprecated — ${guidance}`,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Prompts are intentionally body-only one-shot artifacts: the slug names the
|
|
203
|
+
// prompt, the body IS the payload. Forcing a `title` or blockquote `summary`
|
|
204
|
+
// is friction the prompt format was designed around.
|
|
205
|
+
const skipTitleSummary = doc.type === 'prompt';
|
|
206
|
+
|
|
207
|
+
if (!skipTitleSummary && !headingTitle && !asString(frontmatter.title)) {
|
|
208
|
+
doc.warnings.push({ path: doc.path, level: 'warning', message: 'Missing `title` and no H1 found for fallback.' });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!skipTitleSummary && !config.lifecycle.skipsWarnings(doc.status, doc.type) && !asString(frontmatter.summary) && !doc.summary) {
|
|
212
|
+
doc.warnings.push({ path: doc.path, level: 'warning', message: 'Missing `summary` and no blockquote fallback found.' });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Determine which statuses should have current_state and next_step (plans only, not docs/research)
|
|
216
|
+
const isPlanWork = knownStatus && doc.status && (!doc.type || doc.type === 'plan')
|
|
217
|
+
&& !config.lifecycle.terminalStatuses.has(doc.status) && !config.lifecycle.skipsWarnings(doc.status, doc.type);
|
|
218
|
+
|
|
219
|
+
if (isPlanWork && !asString(frontmatter.current_state)) {
|
|
220
|
+
doc.warnings.push({ path: doc.path, level: 'warning', message: 'Missing `current_state`; index output is using a fallback or placeholder.' });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (isPlanWork && doc.status !== 'blocked' && !asString(frontmatter.next_step)) {
|
|
224
|
+
doc.warnings.push({ path: doc.path, level: 'warning', message: 'Missing `next_step`; command output will omit a clear immediate action.' });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Archived plans must have a ## Closeout section
|
|
228
|
+
if (config.lifecycle.archiveStatuses.has(doc.status) && doc.type === 'plan' && !doc.hasCloseout) {
|
|
229
|
+
doc.warnings.push({ path: doc.path, level: 'warning', message: 'Archived plan missing `## Closeout` section.' });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Archive drift: a doc with an archive-flagged status (`status: archived` by
|
|
233
|
+
// default) whose parent dir is a "live" type-conventional location is
|
|
234
|
+
// misplaced — `runlist archive` would have moved it under `<that>/archiveDir/`.
|
|
235
|
+
// Without this check, default `runlist plans` / `runlist prompts` views silently
|
|
236
|
+
// drop the file (because they exclude archived paths), and the user gets no
|
|
237
|
+
// signal it exists but is invisible. Nested intentional content (e.g.,
|
|
238
|
+
// `docs/plans/audit/<file>.md`) is in a non-conventional subdir and exempt.
|
|
239
|
+
if (config.lifecycle.archiveStatuses.has(doc.status)) {
|
|
240
|
+
const parentDir = path.dirname(doc.path);
|
|
241
|
+
const liveDirs = liveTypeDirsForRoots(config);
|
|
242
|
+
if (liveDirs.has(parentDir)) {
|
|
243
|
+
const expected = `${parentDir}/${config.archiveDir}/${path.basename(doc.path)}`;
|
|
244
|
+
doc.errors.push({
|
|
245
|
+
path: doc.path,
|
|
246
|
+
level: 'error',
|
|
247
|
+
message: `\`status: ${doc.status}\` but file is a direct child of \`${parentDir}/\`, not \`${parentDir}/${config.archiveDir}/\`. Run \`runlist archive ${doc.path}\` to relocate to \`${expected}\`, or change the status.`,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Inverse archive drift (issue #13): a doc whose path is under
|
|
253
|
+
// `<dir>/<archiveDir>/` but whose status is NOT an archive status. Without
|
|
254
|
+
// this check, the file is invisible to default queries (the path is below
|
|
255
|
+
// an archive bucket so it's filtered out by `--exclude-archived`), yet its
|
|
256
|
+
// `status: pending` (or similar) still makes it surface in pending-prompt
|
|
257
|
+
// scans. The heal is to flip the frontmatter via `runlist <type-or-set>
|
|
258
|
+
// archive`, which now restores in place rather than failing.
|
|
259
|
+
if (doc.status && !config.lifecycle.archiveStatuses.has(doc.status)) {
|
|
260
|
+
const parentSegments = path.dirname(doc.path).split('/');
|
|
261
|
+
if (parentSegments.includes(config.archiveDir)) {
|
|
262
|
+
const heal = doc.type === 'prompt'
|
|
263
|
+
? `runlist prompts archive ${doc.path}`
|
|
264
|
+
: `runlist set archived ${doc.path}`;
|
|
265
|
+
doc.errors.push({
|
|
266
|
+
path: doc.path,
|
|
267
|
+
level: 'error',
|
|
268
|
+
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.`,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Historical documents still need navigable references. Their prose is a
|
|
274
|
+
// dated record, but links to moved or deleted files cannot be followed.
|
|
275
|
+
const docDir = path.dirname(path.join(config.repoRoot, doc.path));
|
|
276
|
+
const allRefFields = [...(config.referenceFields.bidirectional || []), ...(config.referenceFields.unidirectional || [])];
|
|
277
|
+
for (const field of allRefFields) {
|
|
278
|
+
for (const relPath of (doc.refFields[field] || [])) {
|
|
279
|
+
if (!resolveRefPath(relPath, docDir, config.repoRoot)) {
|
|
280
|
+
doc.errors.push({
|
|
281
|
+
path: doc.path,
|
|
282
|
+
level: 'error',
|
|
283
|
+
message: `${field} entry \`${relPath}\` does not resolve to an existing file.`,
|
|
284
|
+
meta: { kind: 'ref-resolution', field, relPath },
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
for (const link of (doc.bodyLinks || [])) {
|
|
291
|
+
const resolution = resolveBodyLinkTarget(link.href, docDir, config.repoRoot, config.externalBodyLinkRoots);
|
|
292
|
+
if (!resolution.ok) {
|
|
293
|
+
const shownHref = link.rawHref ?? link.href;
|
|
294
|
+
doc.errors.push({
|
|
295
|
+
path: doc.path,
|
|
296
|
+
level: 'error',
|
|
297
|
+
message: resolution.reason === 'outside-repo'
|
|
298
|
+
? `body link \`${shownHref}\` escapes the repository.`
|
|
299
|
+
: `body link \`${shownHref}\` does not resolve to an existing file or directory.`,
|
|
300
|
+
meta: {
|
|
301
|
+
kind: 'body-link-resolution',
|
|
302
|
+
field: 'body-link',
|
|
303
|
+
relPath: link.href,
|
|
304
|
+
rawHref: shownHref,
|
|
305
|
+
targetKind: link.targetKind ?? 'document',
|
|
306
|
+
angle: link.angle === true,
|
|
307
|
+
reason: resolution.reason,
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Guess which doc type a ref field expects so suggestions don't propose
|
|
315
|
+
// nonsense (a `related_plans` ref shouldn't suggest doc filenames). Heuristic:
|
|
316
|
+
// match common substrings in the field name. When ambiguous, return null so
|
|
317
|
+
// the caller suggests across all types.
|
|
318
|
+
function inferRefFieldType(field) {
|
|
319
|
+
if (!field) return null;
|
|
320
|
+
const f = field.toLowerCase();
|
|
321
|
+
if (f.includes('plan')) return 'plan';
|
|
322
|
+
if (f.includes('doc')) return 'doc';
|
|
323
|
+
if (f.includes('prompt')) return 'prompt';
|
|
324
|
+
if (f.includes('research')) return 'research';
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function candidatePathsForType(docs, type) {
|
|
329
|
+
// When the field implies a type, exclude docs that explicitly declare a
|
|
330
|
+
// different type. Untyped docs (no frontmatter `type:`) are kept — we
|
|
331
|
+
// don't know they aren't the target, so suggesting them is still useful.
|
|
332
|
+
const matches = type ? docs.filter(d => !d.type || d.type === type) : docs;
|
|
333
|
+
// Suggest by basename (the friendly handle agents/humans actually type) and
|
|
334
|
+
// by repo-relative path (covers cases where multiple files share a basename
|
|
335
|
+
// and the disambiguator is the directory).
|
|
336
|
+
const out = new Set();
|
|
337
|
+
for (const d of matches) {
|
|
338
|
+
out.add(path.basename(d.path));
|
|
339
|
+
out.add(d.path);
|
|
340
|
+
}
|
|
341
|
+
return [...out];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Post-pass enrichment: walk every doc's errors/warnings for unresolved-ref
|
|
345
|
+
// entries and append `Did you mean: a, b, c?` using the full index. Runs
|
|
346
|
+
// after parsing so it has the global doc list (validateDoc runs per-file and
|
|
347
|
+
// can't see siblings). Filters candidates by ref-field type when the field
|
|
348
|
+
// name implies one (e.g. `related_plans` → plans only).
|
|
349
|
+
export function enrichRefErrorSuggestions(docs, config) {
|
|
350
|
+
// One candidate list per inferred type, not per broken ref. The list is a
|
|
351
|
+
// filter plus a ~2-per-doc Set build over the whole index, and it does not
|
|
352
|
+
// depend on the entry — rebuilding it for each entry made this pass quadratic
|
|
353
|
+
// in (docs × broken refs), adding repeated work to every index build.
|
|
354
|
+
const candidateCache = new Map();
|
|
355
|
+
const candidatesFor = (type) => {
|
|
356
|
+
const key = type ?? '';
|
|
357
|
+
if (!candidateCache.has(key)) candidateCache.set(key, candidatePathsForType(docs, type));
|
|
358
|
+
return candidateCache.get(key);
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const enrich = (entry) => {
|
|
362
|
+
if (!entry?.meta || !['ref-resolution', 'body-link-resolution'].includes(entry.meta.kind)) return;
|
|
363
|
+
if (entry.meta.kind === 'body-link-resolution'
|
|
364
|
+
&& (entry.meta.targetKind !== 'document' || entry.meta.reason !== 'missing')) return;
|
|
365
|
+
if (entry._suggested) return;
|
|
366
|
+
const inferred = inferRefFieldType(entry.meta.field);
|
|
367
|
+
const suggestions = suggestCandidates(path.basename(entry.meta.relPath), candidatesFor(inferred));
|
|
368
|
+
entry._suggested = true;
|
|
369
|
+
if (suggestions.length === 0) return;
|
|
370
|
+
entry.message = `${entry.message} Did you mean: ${suggestions.join(', ')}?`;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
for (const doc of docs) {
|
|
374
|
+
for (const e of (doc.errors || [])) enrich(e);
|
|
375
|
+
for (const w of (doc.warnings || [])) enrich(w);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function checkBidirectionalReferences(docs, config) {
|
|
380
|
+
const warnings = [];
|
|
381
|
+
const biFields = config.referenceFields.bidirectional || [];
|
|
382
|
+
if (!biFields.length) return { warnings, errors: [] };
|
|
383
|
+
|
|
384
|
+
// refMap stores `Set<targetPath>` keyed by the source doc path — used to
|
|
385
|
+
// answer "does B reference A?" via .has(). oneWayMap stores the subset of A's
|
|
386
|
+
// outbound refs that opted out of expecting a back-ref (via the `>` prefix in
|
|
387
|
+
// frontmatter, parsed in src/index.mjs:parseDocFile). We split these so the
|
|
388
|
+
// membership check stays cheap (Set.has) while the per-ref directionality
|
|
389
|
+
// filter only consults oneWayMap when iterating outbound edges.
|
|
390
|
+
const refMap = new Map();
|
|
391
|
+
const oneWayMap = new Map();
|
|
392
|
+
for (const doc of docs) {
|
|
393
|
+
const docDir = path.dirname(path.join(config.repoRoot, doc.path));
|
|
394
|
+
const refs = new Set();
|
|
395
|
+
const oneWay = new Set();
|
|
396
|
+
for (const field of biFields) {
|
|
397
|
+
const entries = doc.refFields[field] || [];
|
|
398
|
+
const dirs = doc.refFieldDirections?.[field] || [];
|
|
399
|
+
for (let i = 0; i < entries.length; i++) {
|
|
400
|
+
const relPath = entries[i];
|
|
401
|
+
// Use the same doc-relative-then-repo-root fallback as validateDoc so
|
|
402
|
+
// both styles produce identical refMap keys; otherwise an entry like
|
|
403
|
+
// `docs/foo.md` (repo-root style) gets keyed as
|
|
404
|
+
// `<doc-parent>/docs/foo.md` and never matches the target's repo path.
|
|
405
|
+
const resolved = resolveRefPath(relPath, docDir, config.repoRoot)
|
|
406
|
+
?? path.resolve(docDir, relPath);
|
|
407
|
+
const targetPath = toRepoPath(resolved, config.repoRoot);
|
|
408
|
+
refs.add(targetPath);
|
|
409
|
+
if (dirs[i] === 'one-way') oneWay.add(targetPath);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
refMap.set(doc.path, refs);
|
|
413
|
+
oneWayMap.set(doc.path, oneWay);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
for (const [docPath, refs] of refMap) {
|
|
417
|
+
const oneWay = oneWayMap.get(docPath);
|
|
418
|
+
for (const targetPath of refs) {
|
|
419
|
+
if (oneWay.has(targetPath)) continue;
|
|
420
|
+
const targetRefs = refMap.get(targetPath);
|
|
421
|
+
if (targetRefs && !targetRefs.has(docPath)) {
|
|
422
|
+
warnings.push({ path: docPath, level: 'warning',
|
|
423
|
+
message: `references \`${targetPath}\` in ${biFields.join('/')}, but that doc does not reference back.` });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return { warnings, errors: [] };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Runlist back-pointer check: when a hub plan declares a `runlist:` of children,
|
|
432
|
+
// each child SHOULD have `parent_plan:` pointing back at the hub. The two
|
|
433
|
+
// fields encode complementary information (runlist = ordered execution intent;
|
|
434
|
+
// parent_plan = reverse-link the rest of runlist already uses for related-summary
|
|
435
|
+
// rendering), so divergence almost always means the agent forgot the back-link.
|
|
436
|
+
// Warning fires on the CHILD (that's the file that needs the edit). Skips
|
|
437
|
+
// terminal/archive statuses on either side — runlists referencing closed work
|
|
438
|
+
// are a normal history pattern. A `>`-prefixed (one-way) runlist entry opts
|
|
439
|
+
// that child out of the back-pointer requirement — the same per-ref escape
|
|
440
|
+
// hatch the bidirectional reciprocity check honors (A4) — so a hub can order a
|
|
441
|
+
// child it doesn't own without nagging the child to add `parent_plan:`.
|
|
442
|
+
export function checkRunlistBackPointers(docs, config) {
|
|
443
|
+
const warnings = [];
|
|
444
|
+
// Per-doc, not a precomputed name set: warning suppression is type-scoped, so
|
|
445
|
+
// the same status name can be quiet for one type and loud for another.
|
|
446
|
+
const skipDoc = (d) => config.lifecycle.terminalStatuses.has(d.status)
|
|
447
|
+
|| config.lifecycle.skipsWarnings(d.status, d.type);
|
|
448
|
+
const byPath = new Map(docs.map(d => [d.path, d]));
|
|
449
|
+
|
|
450
|
+
for (const hub of docs) {
|
|
451
|
+
if (skipDoc(hub)) continue;
|
|
452
|
+
const runlistRefs = hub.refFields?.runlist ?? [];
|
|
453
|
+
const runlistDirs = hub.refFieldDirections?.runlist ?? [];
|
|
454
|
+
if (runlistRefs.length === 0) continue;
|
|
455
|
+
const hubDir = path.dirname(path.join(config.repoRoot, hub.path));
|
|
456
|
+
|
|
457
|
+
for (let i = 0; i < runlistRefs.length; i++) {
|
|
458
|
+
const ref = runlistRefs[i];
|
|
459
|
+
if (runlistDirs[i] === 'one-way') continue; // `> child.md` → ordered but no back-pointer expected
|
|
460
|
+
const resolved = resolveRefPath(ref, hubDir, config.repoRoot);
|
|
461
|
+
if (!resolved) continue; // unresolved refs already get their own existence error
|
|
462
|
+
const childPath = toRepoPath(resolved, config.repoRoot);
|
|
463
|
+
const child = byPath.get(childPath);
|
|
464
|
+
if (!child) continue;
|
|
465
|
+
if (skipDoc(child)) continue;
|
|
466
|
+
const childParents = (child.refFields?.parent_plan ?? []).map(p => {
|
|
467
|
+
const abs = resolveRefPath(p, path.dirname(path.join(config.repoRoot, child.path)), config.repoRoot);
|
|
468
|
+
return abs ? toRepoPath(abs, config.repoRoot) : p;
|
|
469
|
+
});
|
|
470
|
+
if (childParents.includes(hub.path)) continue;
|
|
471
|
+
warnings.push({
|
|
472
|
+
path: child.path,
|
|
473
|
+
level: 'warning',
|
|
474
|
+
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.`,
|
|
475
|
+
meta: {
|
|
476
|
+
kind: childParents.length === 0 ? 'hub-membership-backref' : 'hub-membership-conflict',
|
|
477
|
+
hub: hub.path,
|
|
478
|
+
source: 'frontmatter-runlist',
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return warnings;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Coordination-hub hygiene: a plan whose slug is `*-runlist` / `runlist` reads
|
|
487
|
+
// as a coordination runlist, but `runlist plans` only *reliably* lifts it into the
|
|
488
|
+
// Runlists section (and out of the active count) when `execution_mode:
|
|
489
|
+
// coordination` is set. Slug detection is the fallback; the frontmatter field is
|
|
490
|
+
// the canonical signal. Nudge the few hubs that lean on the slug alone to make
|
|
491
|
+
// it explicit. Skips terminal/quiet statuses like every other warning-only check.
|
|
492
|
+
export function checkCoordinationHubExecutionMode(docs, config) {
|
|
493
|
+
const warnings = [];
|
|
494
|
+
// Per-doc, not a precomputed name set: warning suppression is type-scoped, so
|
|
495
|
+
// the same status name can be quiet for one type and loud for another.
|
|
496
|
+
const skipDoc = (d) => config.lifecycle.terminalStatuses.has(d.status)
|
|
497
|
+
|| config.lifecycle.skipsWarnings(d.status, d.type);
|
|
498
|
+
for (const doc of docs) {
|
|
499
|
+
if (doc.type && doc.type !== 'plan') continue;
|
|
500
|
+
if (skipDoc(doc)) continue;
|
|
501
|
+
// ANY explicit `execution_mode` ends it. The field is the canonical signal
|
|
502
|
+
// and the slug is only a fallback for hubs that predate it, so a plan that
|
|
503
|
+
// already answered — `implementation`, `roadmap`, anything — has said what it
|
|
504
|
+
// is. Nudging past that misfires on exactly one population: plans ABOUT
|
|
505
|
+
// runlists, whose slug contains the word while the document is ordinary work.
|
|
506
|
+
// (A real one: a plan to rename the runlist concept, declared
|
|
507
|
+
// `execution_mode: implementation`, nagged to call itself a coordination hub.)
|
|
508
|
+
if (doc.executionMode) continue;
|
|
509
|
+
const base = (doc.path.split('/').pop() || '').replace(/\.md$/, '');
|
|
510
|
+
if (base !== 'runlist' && !base.endsWith('-runlist')) continue;
|
|
511
|
+
warnings.push({
|
|
512
|
+
path: doc.path,
|
|
513
|
+
level: 'warning',
|
|
514
|
+
message: `reads as a coordination runlist (slug \`${base}\`) but is missing \`execution_mode: coordination\`. Add it so \`runlist plans\` / \`runlist runlists\` reliably treat it as a runlist, not an active plan.`,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
return warnings;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Roadmap-hub nudge: a coordination hub whose `related_plans:` children are
|
|
521
|
+
// *themselves* hubs (runlists / coordination hubs) is structurally a tier-3
|
|
522
|
+
// roadmap — runlist renders it flat (a hub among its own children, no recursive
|
|
523
|
+
// rollup) until `execution_mode: roadmap` is set. Nudge, never auto-promote: the
|
|
524
|
+
// explicit field beats structural magic for a primitive (Open Q in the
|
|
525
|
+
// roadmap-layer plan). Fires only when ≥2 children AND a majority are hubs, so a
|
|
526
|
+
// coordination hub that merely references one sibling runlist isn't mislabelled.
|
|
527
|
+
export function checkRoadmapHubExecutionMode(docs, config) {
|
|
528
|
+
const warnings = [];
|
|
529
|
+
// Per-doc, not a precomputed name set: warning suppression is type-scoped, so
|
|
530
|
+
// the same status name can be quiet for one type and loud for another.
|
|
531
|
+
const skipDoc = (d) => config.lifecycle.terminalStatuses.has(d.status)
|
|
532
|
+
|| config.lifecycle.skipsWarnings(d.status, d.type);
|
|
533
|
+
const byPath = new Map(docs.map(d => [d.path, d]));
|
|
534
|
+
const byBasename = new Map();
|
|
535
|
+
for (const d of docs) {
|
|
536
|
+
const base = d.path.split('/').pop();
|
|
537
|
+
if (!byBasename.has(base)) byBasename.set(base, d);
|
|
538
|
+
}
|
|
539
|
+
const looksLikeHub = (d) => {
|
|
540
|
+
if (!d) return false;
|
|
541
|
+
if (d.executionMode === 'coordination' || d.executionMode === 'roadmap') return true;
|
|
542
|
+
if (Array.isArray(d.refFields?.runlist) && d.refFields.runlist.length > 0) return true;
|
|
543
|
+
const b = (d.path.split('/').pop() || '').replace(/\.md$/, '');
|
|
544
|
+
return b === 'runlist' || b.endsWith('-runlist');
|
|
545
|
+
};
|
|
546
|
+
for (const doc of docs) {
|
|
547
|
+
if (doc.type && doc.type !== 'plan') continue;
|
|
548
|
+
if (skipDoc(doc)) continue;
|
|
549
|
+
if (doc.executionMode === 'roadmap') continue; // already a roadmap
|
|
550
|
+
if (doc.executionMode !== 'coordination') continue; // only nudge explicit coordination hubs
|
|
551
|
+
const refs = doc.refFields?.related_plans ?? [];
|
|
552
|
+
if (refs.length === 0) continue;
|
|
553
|
+
const dir = path.dirname(path.join(config.repoRoot, doc.path));
|
|
554
|
+
let resolved = 0, hubChildren = 0;
|
|
555
|
+
const seen = new Set();
|
|
556
|
+
for (const ref of refs) {
|
|
557
|
+
const abs = resolveRefPath(ref, dir, config.repoRoot);
|
|
558
|
+
let child = abs ? byPath.get(toRepoPath(abs, config.repoRoot)) ?? null : null;
|
|
559
|
+
if (!child) child = byBasename.get(ref.split('/').pop()) ?? null;
|
|
560
|
+
if (!child || child.path === doc.path || seen.has(child.path)) continue;
|
|
561
|
+
seen.add(child.path);
|
|
562
|
+
resolved++;
|
|
563
|
+
if (looksLikeHub(child)) hubChildren++;
|
|
564
|
+
}
|
|
565
|
+
if (hubChildren >= 2 && hubChildren > resolved / 2) {
|
|
566
|
+
warnings.push({
|
|
567
|
+
path: doc.path,
|
|
568
|
+
level: 'warning',
|
|
569
|
+
message: `is a coordination hub whose ${hubChildren} of ${resolved} \`related_plans:\` children are themselves runlists — that's structurally a tier-3 roadmap. Set \`execution_mode: roadmap\` so \`runlist roadmap\` rolls their progress up (recursive done/total) instead of rendering it flat among them.`,
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return warnings;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export function checkGitStaleness(docs, config, options = {}) {
|
|
577
|
+
const warnings = [];
|
|
578
|
+
const eligibleDocs = docs.filter(doc => !config.lifecycle.skipStaleFor.has(doc.status) && doc.updated);
|
|
579
|
+
const pathspecs = (config.docsRoots || [config.docsRoot])
|
|
580
|
+
.map(root => toRepoPath(root, config.repoRoot) || '.');
|
|
581
|
+
const gitMetadata = getGitLastModifiedBatch(
|
|
582
|
+
config.repoRoot,
|
|
583
|
+
eligibleDocs.map(doc => doc.path),
|
|
584
|
+
{ pathspecs, ...options },
|
|
585
|
+
);
|
|
586
|
+
const driftCandidates = eligibleDocs.filter(doc => {
|
|
587
|
+
const gitDate = gitMetadata.dates.get(doc.path) ?? null;
|
|
588
|
+
return Boolean(gitDate && gitDate.slice(0, 10) > doc.updated.slice(0, 10));
|
|
589
|
+
});
|
|
590
|
+
const candidatePaths = driftCandidates.map(doc => doc.path);
|
|
591
|
+
const candidateMetadata = {
|
|
592
|
+
dates: new Map(candidatePaths.filter(p => gitMetadata.dates.has(p)).map(p => [p, gitMetadata.dates.get(p)])),
|
|
593
|
+
commits: new Map(candidatePaths.filter(p => gitMetadata.commits?.has(p)).map(p => [p, gitMetadata.commits.get(p)])),
|
|
594
|
+
history: new Map(candidatePaths.filter(p => gitMetadata.history?.has(p)).map(p => [p, gitMetadata.history.get(p)])),
|
|
595
|
+
complete: gitMetadata.complete,
|
|
596
|
+
reason: gitMetadata.reason,
|
|
597
|
+
};
|
|
598
|
+
const substantiveMetadata = candidatePaths.length > 0
|
|
599
|
+
? getGitLastSubstantiveModifiedBatch(config.repoRoot, candidatePaths, candidateMetadata, options)
|
|
600
|
+
: candidateMetadata;
|
|
601
|
+
|
|
602
|
+
for (const doc of driftCandidates) {
|
|
603
|
+
const gitDate = substantiveMetadata.dates.get(doc.path) ?? null;
|
|
604
|
+
if (!gitDate) continue;
|
|
605
|
+
const gitDay = gitDate.slice(0, 10);
|
|
606
|
+
const fmDay = doc.updated.slice(0, 10);
|
|
607
|
+
|
|
608
|
+
if (gitDay > fmDay) {
|
|
609
|
+
warnings.push({
|
|
610
|
+
path: doc.path,
|
|
611
|
+
level: 'warning',
|
|
612
|
+
message: `frontmatter \`updated: ${doc.updated}\` is behind git history (last committed ${gitDate.slice(0, 10)}).`,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
if (!substantiveMetadata.complete) {
|
|
617
|
+
warnings.push({
|
|
618
|
+
path: toRepoPath(config.docsRoot, config.repoRoot) || '.',
|
|
619
|
+
level: 'warning',
|
|
620
|
+
message: `Git metadata is incomplete (${substantiveMetadata.reason}); staleness checks used known dates only.`,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
return warnings;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Plan-shape lint: soft warnings on convention drift. Plan-only.
|
|
627
|
+
// Body is the unparsed plan body (everything after the closing `---`).
|
|
628
|
+
export function validatePlanShape(doc, body, frontmatter, config) {
|
|
629
|
+
if (doc.type !== 'plan') return;
|
|
630
|
+
// Skip plans in terminal/archive statuses (closed work shouldn't generate noise)
|
|
631
|
+
if (config.lifecycle.terminalStatuses.has(doc.status) || config.lifecycle.archiveStatuses.has(doc.status)) return;
|
|
632
|
+
if (config.lifecycle.skipsWarnings(doc.status, doc.type)) return;
|
|
633
|
+
|
|
634
|
+
// 1. next_step length cap (800 chars). Was 300; raised in parallel with
|
|
635
|
+
// current_state for the same reason: agents need to encode "what to do next"
|
|
636
|
+
// with enough specificity (which file, which decision, which branch) that
|
|
637
|
+
// 300 chars often forced truncation into the body where the briefing
|
|
638
|
+
// doesn't read it.
|
|
639
|
+
const nextStep = typeof frontmatter.next_step === 'string' ? frontmatter.next_step : '';
|
|
640
|
+
if (nextStep.length > 800) {
|
|
641
|
+
doc.warnings.push({
|
|
642
|
+
path: doc.path,
|
|
643
|
+
level: 'warning',
|
|
644
|
+
message: `\`next_step\` is ${nextStep.length} chars (cap: 800). One mechanical fix: \`runlist doctor --frontmatter-fix\` (moves the overflow into the body) — do NOT hand-trim or re-run check in a loop. Going forward, write next_step as a 1-2 sentence pointer; detail goes in the body.`,
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// 2. current_state length cap (1500 chars). Was 500; raised because agents
|
|
649
|
+
// legitimately need ~150-250 words of resume-context (prior incidents, what
|
|
650
|
+
// shipped, what's verified, where to look) and the prior cap forced a
|
|
651
|
+
// truncate-and-move-to-body retry loop on non-trivial plans.
|
|
652
|
+
const currentState = typeof frontmatter.current_state === 'string' ? frontmatter.current_state : '';
|
|
653
|
+
if (currentState.length > 1500) {
|
|
654
|
+
doc.warnings.push({
|
|
655
|
+
path: doc.path,
|
|
656
|
+
level: 'warning',
|
|
657
|
+
message: `\`current_state\` is ${currentState.length} chars (cap: 1500). One mechanical fix: \`runlist doctor --frontmatter-fix\` (moves the overflow into the body) — do NOT hand-trim or re-run check in a loop. Going forward, write current_state as a 2-4 sentence summary; detail goes in the body.`,
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (!body) return;
|
|
662
|
+
|
|
663
|
+
// 4. Heading drift: case + name variants
|
|
664
|
+
const headingDrift = [
|
|
665
|
+
{ wrong: /^##\s+(Non-goals|Out of scope|Out of Scope|out of scope)\s*$/m, right: '## Non-Goals' },
|
|
666
|
+
];
|
|
667
|
+
for (const { wrong, right } of headingDrift) {
|
|
668
|
+
const m = body.match(wrong);
|
|
669
|
+
if (m) {
|
|
670
|
+
doc.warnings.push({
|
|
671
|
+
path: doc.path,
|
|
672
|
+
level: 'warning',
|
|
673
|
+
message: `Heading drift: \`${m[0].trim()}\` → suggest \`${right}\`.`,
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// 5 & 6 read phases through section.mjs rather than re-parsing them here.
|
|
679
|
+
// This check used to carry its own heading scan and its own copy of the
|
|
680
|
+
// marker vocabulary, which drifted from the real reader in both directions:
|
|
681
|
+
// it counted `### Phase 3 outcome` as a phase needing a marker, and it never
|
|
682
|
+
// learned the fixes section.mjs got. Two parsers for one concept disagreeing
|
|
683
|
+
// is the same defect class as `reference-planner` vs `resolveRefPath`.
|
|
684
|
+
const phases = walkSections(body).filter(isPhaseHeading);
|
|
685
|
+
|
|
686
|
+
// 5. Phase headings that carry no status marker at all.
|
|
687
|
+
if (phases.length > 0) {
|
|
688
|
+
const unmarked = phases.filter(p => detectMarker(p.heading) === null);
|
|
689
|
+
if (unmarked.length > 0) {
|
|
690
|
+
doc.warnings.push({
|
|
691
|
+
path: doc.path,
|
|
692
|
+
level: 'warning',
|
|
693
|
+
message: `${unmarked.length} of ${phases.length} phase heading(s) lack a status marker. Use one of ✅ shipped, ⏭ skipped, 🟡 in-progress, ⬜ todo, 🚧 blocked.`,
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// 6. A phase whose own checklist contradicts its marker. Reported, never
|
|
699
|
+
// fixed: runlist cannot know whether the marker or the boxes are the stale
|
|
700
|
+
// half, and guessing would either mark work done that is not, or reopen work
|
|
701
|
+
// that is. Both halves are the author's own writing — this only says they
|
|
702
|
+
// disagree.
|
|
703
|
+
for (const phase of phases) {
|
|
704
|
+
const conflict = phaseMarkerConflict(phase);
|
|
705
|
+
if (!conflict) continue;
|
|
706
|
+
doc.warnings.push({
|
|
707
|
+
path: doc.path,
|
|
708
|
+
level: 'warning',
|
|
709
|
+
message: `Phase marker contradicts its own checklist (line ${phase.lineStart}): \`${phase.heading.trim()}\` is marked ${conflict.declared}, but ${conflict.checked}/${conflict.total} boxes are checked — that reads as ${conflict.implied}. Update whichever half is stale.`,
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Doc-shape lint: soft warnings on convention drift. Doc-only.
|
|
715
|
+
// Mirrors validatePlanShape's structure.
|
|
716
|
+
export function validateDocShape(doc, body, frontmatter, config) {
|
|
717
|
+
if (doc.type !== 'doc') return;
|
|
718
|
+
if (config.lifecycle.terminalStatuses.has(doc.status) || config.lifecycle.archiveStatuses.has(doc.status)) return;
|
|
719
|
+
if (config.lifecycle.skipsWarnings(doc.status, doc.type)) return;
|
|
720
|
+
|
|
721
|
+
if (!body) return;
|
|
722
|
+
|
|
723
|
+
// Heading drift for docs.
|
|
724
|
+
const headingDrift = [
|
|
725
|
+
{ wrong: /^##\s+Related Documents\s*$/m, right: '## Related Documentation' },
|
|
726
|
+
];
|
|
727
|
+
for (const { wrong, right } of headingDrift) {
|
|
728
|
+
const m = body.match(wrong);
|
|
729
|
+
if (m) {
|
|
730
|
+
doc.warnings.push({
|
|
731
|
+
path: doc.path,
|
|
732
|
+
level: 'warning',
|
|
733
|
+
message: `Heading drift: \`${m[0].trim()}\` → suggest \`${right}\`.`,
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
export function computeDaysSinceUpdate(updated) {
|
|
740
|
+
if (!updated) return null;
|
|
741
|
+
const parsed = new Date(updated);
|
|
742
|
+
if (Number.isNaN(parsed.getTime())) return null;
|
|
743
|
+
|
|
744
|
+
const diffMs = NOW.getTime() - parsed.getTime();
|
|
745
|
+
if (diffMs < 0) return 0;
|
|
746
|
+
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
export function computeIsStale(status, updated, config, type = null) {
|
|
750
|
+
const typeStaleDays = type ? config.raw?.types?.[type]?.staleDays : null;
|
|
751
|
+
const hasTypeDays = Object.prototype.hasOwnProperty.call(typeStaleDays ?? {}, status);
|
|
752
|
+
const staleAfterDays = hasTypeDays ? typeStaleDays[status] : (config.staleDaysByStatus[status] ?? null);
|
|
753
|
+
if (staleAfterDays == null) return false;
|
|
754
|
+
|
|
755
|
+
const daysSinceUpdate = computeDaysSinceUpdate(updated);
|
|
756
|
+
if (daysSinceUpdate == null) return false;
|
|
757
|
+
return daysSinceUpdate > staleAfterDays;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
export function computeChecklistCompletionRate(checklist) {
|
|
761
|
+
if (!checklist.total) return null;
|
|
762
|
+
return Number((checklist.completed / checklist.total).toFixed(4));
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// A floor under the scan surface.
|
|
766
|
+
//
|
|
767
|
+
// Every other check in this file asks "is this doc wrong?" — none of them can ask
|
|
768
|
+
// "did we look at anything?". If the scan surface breaks (a root that stopped
|
|
769
|
+
// resolving, a config edit that narrowed the tree, a rename that moved docs/ out
|
|
770
|
+
// from under us), `runlist check` reports zero errors, and that output is
|
|
771
|
+
// byte-identical to a clean estate. A guard's whole failure mode is going quiet,
|
|
772
|
+
// and this is the one that would go quiet silently.
|
|
773
|
+
//
|
|
774
|
+
// Off unless `minDocs` is configured — a floor is a claim about YOUR corpus size
|
|
775
|
+
// and runlist cannot guess it. Deliberately an error, not a warning: a warning exits
|
|
776
|
+
// 0, which is the exact outcome this exists to prevent.
|
|
777
|
+
export function applyScanFloor(index, config, { scoped = false } = {}) {
|
|
778
|
+
const floor = config?.minDocs;
|
|
779
|
+
if (!floor) return index;
|
|
780
|
+
// A path-scoped check (`runlist check docs/plans/x.md`) is a deliberate subset, so
|
|
781
|
+
// the floor would fire on every single-file check. Not a corpus claim at all.
|
|
782
|
+
if (scoped) return index;
|
|
783
|
+
if (index.docs.length >= floor) return index;
|
|
784
|
+
|
|
785
|
+
index.errors.push({
|
|
786
|
+
path: null,
|
|
787
|
+
level: 'error',
|
|
788
|
+
message: `only ${index.docs.length} docs in the scan surface (expected at least ${floor}) — `
|
|
789
|
+
+ 'the file list broke, so a pass here would mean nothing. Check `root`/`excludeDirs` in '
|
|
790
|
+
+ 'your config, or lower `minDocs` if the corpus genuinely shrank.',
|
|
791
|
+
meta: { kind: 'scan-floor', found: index.docs.length, floor },
|
|
792
|
+
});
|
|
793
|
+
return index;
|
|
794
|
+
}
|