runlist 0.0.0 → 0.94.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1112 -6
  3. package/assets/app/app.css +283 -0
  4. package/assets/app/app.mjs +381 -0
  5. package/assets/app/block-editor.mjs +182 -0
  6. package/assets/app/block-model.mjs +52 -0
  7. package/assets/app/document-create.mjs +86 -0
  8. package/assets/app/document-lifecycle.mjs +36 -0
  9. package/assets/app/document-stages.mjs +22 -0
  10. package/assets/app/document-views.mjs +33 -0
  11. package/assets/app/document-yardstick.mjs +258 -0
  12. package/assets/app/editor-navigation.mjs +51 -0
  13. package/assets/app/filing-navigation.mjs +138 -0
  14. package/assets/app/git-changes.mjs +80 -0
  15. package/assets/app/git-commit.mjs +46 -0
  16. package/assets/app/index.html +70 -0
  17. package/assets/app/library-navigation.mjs +93 -0
  18. package/assets/app/outline.mjs +18 -0
  19. package/assets/app/quick-navigation.mjs +52 -0
  20. package/assets/app/record-navigation.mjs +78 -0
  21. package/assets/app/recovery-center.mjs +53 -0
  22. package/assets/app/semantic-search.mjs +14 -0
  23. package/assets/app/settings.mjs +15 -0
  24. package/assets/app/shared.mjs +104 -0
  25. package/assets/app/stage-source.mjs +75 -0
  26. package/assets/app/template-editor.mjs +47 -0
  27. package/assets/app/theme.mjs +25 -0
  28. package/assets/app/transport.mjs +43 -0
  29. package/assets/contracts/source-editing-v1.md +232 -0
  30. package/assets/opencode/plugin.js +162 -0
  31. package/assets/schemas/runlist-record-v1.schema.json +912 -0
  32. package/bin/dotmd.mjs +5 -0
  33. package/bin/runlist.mjs +2714 -0
  34. package/package.json +53 -6
  35. package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
  36. package/plugins/runlist-codex/bin/runlist-hook +86 -0
  37. package/plugins/runlist-codex/hooks/hooks.json +16 -0
  38. package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
  39. package/runlist.config.example.mjs +370 -0
  40. package/scripts/postinstall.mjs +56 -0
  41. package/src/agent-context.mjs +201 -0
  42. package/src/ai.mjs +30 -0
  43. package/src/app-create.mjs +169 -0
  44. package/src/app-filing.mjs +139 -0
  45. package/src/app-git-commits.mjs +48 -0
  46. package/src/app-git.mjs +180 -0
  47. package/src/app-library.mjs +152 -0
  48. package/src/app-lifecycle-worker.mjs +12 -0
  49. package/src/app-lifecycle.mjs +79 -0
  50. package/src/app-records.mjs +134 -0
  51. package/src/app-search.mjs +80 -0
  52. package/src/app-semantic-search.mjs +105 -0
  53. package/src/app-service.mjs +208 -0
  54. package/src/app-yardstick.mjs +269 -0
  55. package/src/app.mjs +102 -0
  56. package/src/atomic-mutation.mjs +1736 -0
  57. package/src/baton.mjs +435 -0
  58. package/src/body-link.mjs +48 -0
  59. package/src/bulk-tag.mjs +174 -0
  60. package/src/check-collapse.mjs +75 -0
  61. package/src/claude-commands.mjs +75 -0
  62. package/src/cli-editor.mjs +8 -0
  63. package/src/code-refs.mjs +545 -0
  64. package/src/codex-integration.mjs +81 -0
  65. package/src/color.mjs +14 -0
  66. package/src/commands.mjs +374 -0
  67. package/src/completions.mjs +93 -0
  68. package/src/config-edit.mjs +606 -0
  69. package/src/config.mjs +705 -0
  70. package/src/decision-action.mjs +58 -0
  71. package/src/decision.mjs +220 -0
  72. package/src/decisions.mjs +787 -0
  73. package/src/deps.mjs +317 -0
  74. package/src/desktop-install.mjs +158 -0
  75. package/src/desktop-runtime.mjs +4 -0
  76. package/src/desktop.mjs +25 -0
  77. package/src/diff.mjs +101 -0
  78. package/src/doctor.mjs +591 -0
  79. package/src/durable-rename.mjs +55 -0
  80. package/src/errors-read.mjs +44 -0
  81. package/src/export.mjs +496 -0
  82. package/src/extractors.mjs +101 -0
  83. package/src/filing.mjs +255 -0
  84. package/src/fix-membership.mjs +134 -0
  85. package/src/fix-refs.mjs +174 -0
  86. package/src/flags.mjs +318 -0
  87. package/src/frontmatter-fix.mjs +195 -0
  88. package/src/frontmatter.mjs +252 -0
  89. package/src/git-commit-filters.mjs +32 -0
  90. package/src/git-commit-protocol.mjs +198 -0
  91. package/src/git-commit-signing.mjs +75 -0
  92. package/src/git-commit-store.mjs +26 -0
  93. package/src/git-commit-support.mjs +16 -0
  94. package/src/git-commit-worker.mjs +32 -0
  95. package/src/git.mjs +909 -0
  96. package/src/glossary-check.mjs +35 -0
  97. package/src/glossary.mjs +243 -0
  98. package/src/graph.mjs +298 -0
  99. package/src/guard.mjs +488 -0
  100. package/src/health.mjs +212 -0
  101. package/src/hints.mjs +129 -0
  102. package/src/host-integration.mjs +339 -0
  103. package/src/hub-membership.mjs +284 -0
  104. package/src/hub.mjs +296 -0
  105. package/src/hud.mjs +436 -0
  106. package/src/index-file.mjs +163 -0
  107. package/src/index.mjs +478 -0
  108. package/src/init.mjs +512 -0
  109. package/src/install.mjs +222 -0
  110. package/src/journal-read.mjs +116 -0
  111. package/src/journal.mjs +472 -0
  112. package/src/lifecycle.mjs +1444 -0
  113. package/src/lint.mjs +386 -0
  114. package/src/managed-path.mjs +192 -0
  115. package/src/markdown-code-spans.mjs +58 -0
  116. package/src/migrate-prompts.mjs +171 -0
  117. package/src/migrate-template.mjs +170 -0
  118. package/src/migrate.mjs +90 -0
  119. package/src/misuse-read.mjs +61 -0
  120. package/src/model-request.mjs +33 -0
  121. package/src/model.mjs +523 -0
  122. package/src/modules.mjs +355 -0
  123. package/src/naming.mjs +55 -0
  124. package/src/native-action.mjs +59 -0
  125. package/src/native-record.mjs +244 -0
  126. package/src/new.mjs +1321 -0
  127. package/src/output-identity.mjs +111 -0
  128. package/src/parent-plan.mjs +58 -0
  129. package/src/parse-cache.mjs +99 -0
  130. package/src/path-containment.mjs +15 -0
  131. package/src/pickup-card.mjs +293 -0
  132. package/src/pickup.mjs +671 -0
  133. package/src/prompt.mjs +25 -0
  134. package/src/prompts.mjs +585 -0
  135. package/src/public-inspection.mjs +19 -0
  136. package/src/query.mjs +977 -0
  137. package/src/record-action.mjs +11 -0
  138. package/src/record-create.mjs +82 -0
  139. package/src/reference-planner.mjs +388 -0
  140. package/src/reference-values.mjs +11 -0
  141. package/src/rename.mjs +107 -0
  142. package/src/render.mjs +678 -0
  143. package/src/roadmap.mjs +184 -0
  144. package/src/runlist.mjs +944 -0
  145. package/src/section.mjs +225 -0
  146. package/src/semantic-search-client.mjs +58 -0
  147. package/src/ship.mjs +166 -0
  148. package/src/show.mjs +101 -0
  149. package/src/skill-drift.mjs +98 -0
  150. package/src/source-editor.mjs +491 -0
  151. package/src/stages.mjs +73 -0
  152. package/src/state-migration.mjs +123 -0
  153. package/src/stats.mjs +200 -0
  154. package/src/status-metadata.mjs +87 -0
  155. package/src/statuses.mjs +721 -0
  156. package/src/summary.mjs +74 -0
  157. package/src/surfaces.mjs +28 -0
  158. package/src/sync-status.mjs +316 -0
  159. package/src/template-store.mjs +62 -0
  160. package/src/update.mjs +338 -0
  161. package/src/use.mjs +58 -0
  162. package/src/util.mjs +337 -0
  163. package/src/validate.mjs +795 -0
  164. package/src/watch.mjs +56 -0
  165. package/src/xref.mjs +406 -0
  166. package/src/yardstick-command.mjs +178 -0
  167. package/src/yardstick.mjs +112 -0
package/src/watch.mjs ADDED
@@ -0,0 +1,56 @@
1
+ import { watch } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { dim } from './color.mjs';
6
+
7
+ export function watchCliPath(moduleUrl = import.meta.url) {
8
+ return path.resolve(path.dirname(fileURLToPath(moduleUrl)), '..', 'bin', 'runlist.mjs');
9
+ }
10
+
11
+ export function runWatch(argv, config) {
12
+ const subCommand = argv.length > 0 ? argv : ['list'];
13
+ const cliPath = watchCliPath();
14
+
15
+ let lastRun = 0;
16
+ const DEBOUNCE = 300;
17
+
18
+ function run() {
19
+ const now = Date.now();
20
+ if (now - lastRun < DEBOUNCE) return;
21
+ lastRun = now;
22
+
23
+ // Clear terminal
24
+ process.stdout.write('\x1b[2J\x1b[H');
25
+ process.stderr.write(dim(`[${new Date().toLocaleTimeString()}] runlist ${subCommand.join(' ')}`) + '\n\n');
26
+
27
+ spawnSync(process.execPath, [cliPath, ...subCommand], {
28
+ stdio: 'inherit',
29
+ cwd: process.cwd(),
30
+ });
31
+ }
32
+
33
+ // Run once immediately
34
+ run();
35
+
36
+ const roots = config.docsRoots || [config.docsRoot];
37
+ process.stderr.write(dim(`\nWatching ${roots.length} root(s) for changes... (Ctrl+C to stop)`) + '\n');
38
+
39
+ // Watch for changes across all roots
40
+ const watchers = roots.map(root =>
41
+ watch(root, { recursive: true }, (eventType, filename) => {
42
+ if (filename && filename.endsWith('.md')) {
43
+ run();
44
+ }
45
+ })
46
+ );
47
+
48
+ // Clean exit
49
+ process.on('SIGINT', () => {
50
+ for (const w of watchers) w.close();
51
+ process.exit(0);
52
+ });
53
+
54
+ // Keep alive
55
+ setInterval(() => {}, 1 << 30);
56
+ }
package/src/xref.mjs ADDED
@@ -0,0 +1,406 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { buildIndex, resolveDocArg } from './index.mjs';
5
+ import { resolveBodyLinkTarget } from './body-link.mjs';
6
+ import { showDoc } from './show.mjs';
7
+ import { decisionSettings, loadDecisionDocs, analyzeDecisions, linksBesideId, openWork, PENDING } from './decisions.mjs';
8
+ import { commitRename } from './durable-rename.mjs';
9
+ import { stateDir } from './naming.mjs';
10
+ import { openFlags, syncCheckFlags } from './flags.mjs';
11
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
12
+ import { die, resolveRefPath, toRepoPath } from './util.mjs';
13
+ import { bold, dim, yellow } from './color.mjs';
14
+
15
+ // `runlist xref <file>` — one document's cross-references in one read, so a
16
+ // session editing or reporting on a plan starts from what the rest of the
17
+ // corpus and the repository say about it, not from the plan alone:
18
+ //
19
+ // named by every document whose frontmatter or body points at it
20
+ // names what it points at, with status, and whether it is named back
21
+ // decisions its own records, any linked peer that disagrees, and the
22
+ // records elsewhere it cites beside a link to their document
23
+ // code the files it cites: live, renamed (to where) or removed, and
24
+ // the commits since the plan's last commit that touched them
25
+ // flags what is open on it
26
+ //
27
+ // `runlist xref --check` runs the two findings worth a flag across the corpus:
28
+ // open work (an unticked item, a blocker, next_step) naming a file git shows
29
+ // removed or renamed, and a decision open or held in one document and ruled
30
+ // or closed at a linked peer. Everything else here is context, not a defect:
31
+ // code changing under a live plan is the normal state of a live plan.
32
+
33
+ const CODE_CITATION = /(?<![\w/.@~-])((?:[\w.@[\]-]+\/)+[\w.@[\]-]+\.[A-Za-z][A-Za-z0-9]{0,5})(?::\d+(?:-\d+)?)?(?![\w/])/g;
34
+
35
+ /** Repo paths a text cites, `file:line` suffixes dropped. Documents, URLs
36
+ * and dot-relative paths are left to the reference checks. */
37
+ export function codeCitations(text) {
38
+ const out = new Set();
39
+ for (const m of text.matchAll(CODE_CITATION)) {
40
+ const p = m[1].replace(/\.$/, '');
41
+ if (/\.md$/i.test(p) || p.startsWith('.') || p.includes('//')) continue;
42
+ // A domain (`example.com/a.js`) is a URL with its scheme dropped.
43
+ if (/^[\w-]+\.(?:com|org|net|io|dev|app|ai|co)\//i.test(p)) continue;
44
+ out.add(p);
45
+ }
46
+ return out;
47
+ }
48
+
49
+ function git(args, repoRoot) {
50
+ const r = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
51
+ if (r.error || r.status !== 0) return null;
52
+ return r.stdout;
53
+ }
54
+
55
+ // Every suffix of every path at a segment boundary, so a plan's shorthand
56
+ // (`services/a.ts` for `pkg/src/services/a.ts`) resolves.
57
+ function suffixMap(paths) {
58
+ const map = new Map();
59
+ for (const p of paths) {
60
+ const parts = p.split('/');
61
+ for (let i = 0; i < parts.length; i++) {
62
+ const key = parts.slice(i).join('/');
63
+ if (!map.has(key)) map.set(key, []);
64
+ map.get(key).push(p);
65
+ }
66
+ }
67
+ return map;
68
+ }
69
+
70
+ // Removals and renames, newest first, as [from, to|null] in the order git
71
+ // reports them; `range` limits the walk (`old..HEAD`).
72
+ function movesIn(repoRoot, range = null) {
73
+ const args = ['-c', 'diff.renameLimit=0', 'log', '--diff-filter=DR', '--name-status', '-z', '--format='];
74
+ if (range) args.push(range);
75
+ const fields = (git(args, repoRoot) ?? '').split('\0').map(f => f.replace(/^\n+/, '')).filter(Boolean);
76
+ const out = [];
77
+ for (let i = 0; i < fields.length; i++) {
78
+ if (fields[i].startsWith('R')) { out.push([fields[i + 1], fields[i + 2]]); i += 2; }
79
+ else if (fields[i] === 'D') out.push([fields[++i], null]);
80
+ }
81
+ return out;
82
+ }
83
+
84
+ // The full history walk is seconds on a large repository, so its result is
85
+ // kept in the state directory against the commit it read up to, and a later
86
+ // run walks only the commits since. A cache that no longer descends to HEAD
87
+ // (history rewritten, another branch) is thrown away and rebuilt.
88
+ function historyMoves(repoRoot, { write = true } = {}) {
89
+ const head = git(['rev-parse', 'HEAD'], repoRoot)?.trim();
90
+ if (!head) return new Map();
91
+ const file = path.join(stateDir(repoRoot), 'xref-moves.json');
92
+ let cached = null;
93
+ try { cached = JSON.parse(readFileSync(file, 'utf8')); } catch { /* none yet */ }
94
+ const usable = cached?.head && Array.isArray(cached.moves)
95
+ && (cached.head === head || spawnSync('git', ['merge-base', '--is-ancestor', cached.head, head], { cwd: repoRoot }).status === 0);
96
+ const newer = usable ? (cached.head === head ? [] : movesIn(repoRoot, `${cached.head}..${head}`)) : movesIn(repoRoot);
97
+ const moves = usable ? [...newer, ...cached.moves] : newer;
98
+ if (write && (!usable || newer.length || cached.head !== head)) {
99
+ try {
100
+ mkdirSync(path.dirname(file), { recursive: true });
101
+ const tmp = `${file}.${process.pid}.tmp`;
102
+ writeFileSync(tmp, JSON.stringify({ head, moves }));
103
+ commitRename(tmp, file);
104
+ } catch { /* a cache that cannot be written is rebuilt next run */ }
105
+ }
106
+ const moved = new Map();
107
+ for (const [from, to] of moves) if (from && !moved.has(from)) moved.set(from, to);
108
+ return moved;
109
+ }
110
+
111
+ /**
112
+ * What the repository knows about paths: the tracked files, and every path
113
+ * git history removed or renamed (with its rename target, newest first).
114
+ * Built once per run; `resolveCitation` is pure over it.
115
+ */
116
+ export function repoPaths(repoRoot, options = {}) {
117
+ const tracked = (git(['ls-files', '-z'], repoRoot) ?? '').split('\0').filter(Boolean);
118
+ const moved = historyMoves(repoRoot, options);
119
+ return { tracked: suffixMap(tracked), moved, movedSuffix: suffixMap([...moved.keys()]) };
120
+ }
121
+
122
+ /** Where a cited path stands: live (with the file or files it names), renamed
123
+ * (to the file it is now, following a chain of renames), removed, or unknown
124
+ * (never tracked: a file the plan has not created yet, or shorthand). */
125
+ export function resolveCitation(cited, repo) {
126
+ const live = repo.tracked.get(cited);
127
+ if (live) return { state: 'live', paths: live };
128
+ const olds = repo.movedSuffix.get(cited);
129
+ if (!olds) return { state: 'unknown' };
130
+ for (const old of olds) {
131
+ let to = repo.moved.get(old);
132
+ const seen = new Set([old]);
133
+ while (to && !repo.tracked.has(to) && repo.moved.has(to) && !seen.has(to)) {
134
+ seen.add(to);
135
+ to = repo.moved.get(to);
136
+ }
137
+ if (to && repo.tracked.get(to)?.includes(to)) return { state: 'renamed', from: old, to };
138
+ }
139
+ return { state: 'removed', from: olds[0] };
140
+ }
141
+
142
+ function lastCommitDate(relPath, repoRoot) {
143
+ return git(['log', '-1', '--format=%cI', '--', relPath], repoRoot)?.trim() || null;
144
+ }
145
+
146
+ /** Commits after the document's last commit that touched a cited live file
147
+ * and left the document alone. */
148
+ function commitsSince(docPath, files, since, repoRoot) {
149
+ if (!since || !files.length) return [];
150
+ const out = git(['log', `--since=${since}`, '--format=\x1e%h %cs %s', '--name-only', '--', docPath, ...files], repoRoot) ?? '';
151
+ const wanted = new Set(files);
152
+ const commits = [];
153
+ for (const block of out.split('\x1e').filter(b => b.trim())) {
154
+ const [head, ...names] = block.split('\n').filter(Boolean);
155
+ if (names.includes(docPath)) continue;
156
+ const touched = names.filter(n => wanted.has(n));
157
+ if (!touched.length) continue;
158
+ const [hash, date, ...subject] = head.split(' ');
159
+ commits.push({ hash, date, subject: subject.join(' '), files: touched });
160
+ }
161
+ return commits;
162
+ }
163
+
164
+ function refTargets(doc, config) {
165
+ const dir = path.dirname(path.join(config.repoRoot, doc.path));
166
+ const out = [];
167
+ for (const [field, refs] of Object.entries(doc.refFields ?? {})) {
168
+ for (const ref of refs) {
169
+ const target = resolveRefPath(String(ref).replace(/#.*$/, ''), dir, config.repoRoot);
170
+ if (target) out.push({ field, path: toRepoPath(target, config.repoRoot) });
171
+ }
172
+ }
173
+ for (const link of doc.bodyLinks ?? []) {
174
+ if (link.targetKind !== 'document') continue;
175
+ const target = resolveBodyLinkTarget(link.href, dir, config.repoRoot);
176
+ if (target.ok) out.push({ field: 'body', path: toRepoPath(target.path, config.repoRoot) });
177
+ }
178
+ return out;
179
+ }
180
+
181
+ /** The lines of a document that are open work: unticked items, frontmatter
182
+ * blockers, and `next_step`. */
183
+ export function openWorkLines(text) {
184
+ const lines = openWork(text).map(e => ({ line: e.line, text: e.text }));
185
+ const fm = extractFrontmatter(text).frontmatter;
186
+ if (fm) {
187
+ const next = parseSimpleFrontmatter(fm, []).next_step;
188
+ if (next) {
189
+ const at = text.split('\n').findIndex(l => l.startsWith('next_step:'));
190
+ lines.push({ line: at + 1, text: String(next) });
191
+ }
192
+ }
193
+ return lines;
194
+ }
195
+
196
+ function decisionContext(config) {
197
+ const settings = decisionSettings(config.raw?.decisions ?? {});
198
+ const docs = loadDecisionDocs(config, settings);
199
+ return { docs, items: analyzeDecisions(docs, settings) };
200
+ }
201
+
202
+ const DONE = new Set(['ruled', 'closed']);
203
+
204
+ /** Decisions whose disposition disagrees with a linked peer's. */
205
+ export function decisionConflicts(items) {
206
+ const out = [];
207
+ for (const item of items) {
208
+ if (!PENDING.has(item.disposition)) continue;
209
+ for (const peer of item.peers) {
210
+ if (DONE.has(peer.disposition)) out.push({ item, peer });
211
+ }
212
+ }
213
+ return out;
214
+ }
215
+
216
+ /** Records in other documents that `text` cites: the id beside a link to
217
+ * the record's document on one line. */
218
+ function citedDecisions(text, own, items) {
219
+ const byFile = new Map();
220
+ for (const item of items) {
221
+ if (!item.id || item.doc === own) continue;
222
+ if (!byFile.has(item.file)) byFile.set(item.file, []);
223
+ byFile.get(item.file).push(item);
224
+ }
225
+ const found = new Map();
226
+ text.split('\n').forEach((line, i) => {
227
+ if (!line.includes('.md')) return;
228
+ for (const [file, list] of byFile) {
229
+ if (!line.includes(file)) continue;
230
+ for (const item of list) {
231
+ const key = `${item.doc}#${item.id}`;
232
+ if (!found.has(key) && line.includes(item.id) && linksBesideId(line, item.id).has(file)) found.set(key, { item, line: i + 1 });
233
+ }
234
+ }
235
+ });
236
+ return [...found.values()];
237
+ }
238
+
239
+ /** The cross-reference card for one document, by absolute path. */
240
+ export function xrefDoc(abs, config, ctx = {}) {
241
+ const rel = toRepoPath(abs, config.repoRoot);
242
+ const text = readFileSync(abs, 'utf8');
243
+ const index = ctx.index ?? buildIndex(config, { fast: true });
244
+ const byPath = new Map(index.docs.map(d => [d.path, d]));
245
+
246
+ const card = showDoc(abs, config);
247
+ const selfTargets = new Set(refTargets(byPath.get(rel) ?? { path: rel }, config).map(t => t.path));
248
+
249
+ const namedBy = [];
250
+ for (const doc of index.docs) {
251
+ if (doc.path === rel) continue;
252
+ const fields = [...new Set(refTargets(doc, config).filter(t => t.path === rel).map(t => t.field))];
253
+ if (fields.length) namedBy.push({ path: doc.path, status: doc.status ?? null, fields, namedBack: selfTargets.has(doc.path) });
254
+ }
255
+ namedBy.sort((a, b) => a.path.localeCompare(b.path));
256
+
257
+ const names = card.related.map(r => {
258
+ const target = r.path ? byPath.get(r.path) : null;
259
+ const back = target ? refTargets(target, config).some(t => t.path === rel) : false;
260
+ return { field: r.field, ref: r.ref, path: r.path, exists: r.exists, status: r.status, namesBack: back };
261
+ });
262
+
263
+ const { items } = ctx.decisions ?? decisionContext(config);
264
+ const own = items.filter(i => i.doc === rel && i.id).map(i => ({
265
+ id: i.id,
266
+ line: i.line,
267
+ disposition: i.effective ?? i.disposition,
268
+ disagrees: i.peers
269
+ .filter(p => p.disposition && p.disposition !== i.disposition && (PENDING.has(i.disposition) ? DONE.has(p.disposition) : PENDING.has(p.disposition)))
270
+ .map(p => ({ doc: p.doc, line: p.line, disposition: p.disposition })),
271
+ }));
272
+ const cited = citedDecisions(text, rel, items).map(({ item, line }) => ({
273
+ id: item.id, line, doc: item.doc, recordLine: item.line, disposition: item.effective ?? item.disposition,
274
+ }));
275
+
276
+ const repo = ctx.repo ?? repoPaths(config.repoRoot, { write: !config._execution?.suppressSideEffects });
277
+ const code = [...codeCitations(text)].sort().map(cited => ({ cited, ...resolveCitation(cited, repo) }));
278
+ const liveFiles = [...new Set(code.filter(c => c.state === 'live' && c.paths.length === 1).map(c => c.paths[0]))];
279
+ const since = lastCommitDate(rel, config.repoRoot);
280
+ const commits = commitsSince(rel, liveFiles, since, config.repoRoot);
281
+
282
+ return {
283
+ path: rel,
284
+ title: card.title,
285
+ status: card.status,
286
+ lastCommit: since,
287
+ namedBy,
288
+ names,
289
+ decisions: { own, cited },
290
+ code,
291
+ commitsSince: commits,
292
+ flags: openFlags(config).filter(f => f.file === rel).map(f => ({ id: f.id, severity: f.severity, line: f.line, text: f.text })),
293
+ };
294
+ }
295
+
296
+ // A citation with a removal word beside it (`a.ts is split and deleted`,
297
+ // `deleted a.ts`) records the removal; it is history, not a pointer.
298
+ const REMOVAL = /\b(?:deleted|removed|retired|split|renamed|moved|gone|dropped)\b/i;
299
+ export function narratesRemoval(line, cited) {
300
+ const at = line.indexOf(cited);
301
+ if (at === -1) return false;
302
+ return REMOVAL.test(line.slice(Math.max(0, at - 40), at + cited.length + 60));
303
+ }
304
+
305
+ /** The corpus-wide findings `--check` reports, as flag findings. */
306
+ export function xrefFindings(config, ctx = {}) {
307
+ const index = ctx.index ?? buildIndex(config, { fast: true });
308
+ const repo = ctx.repo ?? repoPaths(config.repoRoot, { write: !config._execution?.suppressSideEffects });
309
+ const archiveDir = config.archiveDir ?? 'archived';
310
+ const findings = [];
311
+ for (const doc of index.docs) {
312
+ if (doc.type !== 'plan' || doc.status === 'archived' || doc.path.split('/').includes(archiveDir)) continue;
313
+ const text = readFileSync(path.join(config.repoRoot, doc.path), 'utf8');
314
+ for (const work of openWorkLines(text)) {
315
+ for (const cited of codeCitations(work.text)) {
316
+ if (narratesRemoval(work.text, cited)) continue;
317
+ const r = resolveCitation(cited, repo);
318
+ if (r.state === 'renamed') findings.push({ file: doc.path, line: work.line, severity: 'warn', text: `Open work names ${cited}, which was renamed to ${r.to}.` });
319
+ else if (r.state === 'removed') findings.push({ file: doc.path, line: work.line, severity: 'warn', text: `Open work names ${cited}, which was removed.` });
320
+ }
321
+ }
322
+ }
323
+ const { items } = ctx.decisions ?? decisionContext(config);
324
+ for (const { item, peer } of decisionConflicts(items)) {
325
+ findings.push({ file: item.doc, line: item.line, severity: 'problem', text: `${item.id} is ${item.disposition} here and ${peer.disposition} at ${peer.doc}:${peer.line}.` });
326
+ }
327
+ return findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
328
+ }
329
+
330
+ function renderCard(c) {
331
+ const out = [];
332
+ out.push(`${bold(c.title ?? c.path)} (${c.status ?? 'no status'})`, c.path);
333
+ out.push(dim(c.lastCommit ? `last committed ${c.lastCommit.slice(0, 10)}` : 'not committed'));
334
+
335
+ out.push('', bold(`Named by (${c.namedBy.length})`));
336
+ for (const n of c.namedBy) out.push(` ${n.path} (${n.status ?? 'no status'}, ${n.fields.join(', ')})${n.namedBack ? '' : yellow(' not named back')}`);
337
+
338
+ out.push('', bold(`Names (${c.names.length})`));
339
+ for (const n of c.names) {
340
+ const where = n.exists ? `${n.path} (${n.status ?? 'no status'})` : yellow(`${n.ref} missing`);
341
+ out.push(` ${n.field}: ${where}${n.exists && !n.namesBack ? dim(' one-way') : ''}`);
342
+ }
343
+
344
+ const { own, cited } = c.decisions;
345
+ const tally = new Map();
346
+ for (const d of own) tally.set(d.disposition ?? 'no disposition', (tally.get(d.disposition ?? 'no disposition') ?? 0) + 1);
347
+ out.push('', bold(`Decisions here (${own.length}${tally.size ? `: ${[...tally].map(([k, n]) => `${n} ${k}`).join(', ')}` : ''})`));
348
+ for (const d of own) {
349
+ if (!d.disagrees.length && !PENDING.has(d.disposition) && d.disposition) continue;
350
+ out.push(` ${d.id} ${d.disposition ?? 'no disposition'} line ${d.line}`);
351
+ for (const p of d.disagrees) out.push(yellow(` ${p.disposition} at ${p.doc}:${p.line}`));
352
+ }
353
+ const byDoc = new Map();
354
+ for (const d of cited) {
355
+ if (!byDoc.has(d.doc)) byDoc.set(d.doc, []);
356
+ byDoc.get(d.doc).push(`${d.id} ${d.disposition ?? 'no disposition'}`);
357
+ }
358
+ out.push('', bold(`Decisions cited from elsewhere (${cited.length})`));
359
+ for (const [doc, list] of byDoc) out.push(` ${doc}: ${list.join(', ')}`);
360
+
361
+ const by = s => c.code.filter(x => x.state === s);
362
+ out.push('', bold(`Code (${c.code.length} cited: ${by('live').length} live, ${by('renamed').length} renamed, ${by('removed').length} removed, ${by('unknown').length} not in the repository)`));
363
+ for (const x of by('renamed')) out.push(yellow(` ${x.cited} renamed to ${x.to}`));
364
+ for (const x of by('removed')) out.push(yellow(` ${x.cited} removed`));
365
+ if (c.commitsSince.length) {
366
+ const counts = new Map();
367
+ for (const k of c.commitsSince) for (const f of k.files) counts.set(f, (counts.get(f) ?? 0) + 1);
368
+ const top = [...counts].sort((a, b) => b[1] - a[1]).slice(0, 8);
369
+ out.push(` ${c.commitsSince.length} commits since the last commit here touched ${counts.size} cited files:`);
370
+ for (const [f, n] of top) out.push(` ${f} ${n}`);
371
+ for (const k of c.commitsSince.slice(0, 5)) out.push(dim(` ${k.hash} ${k.date} ${k.subject.slice(0, 90)}`));
372
+ }
373
+
374
+ out.push('', bold(`Open flags (${c.flags.length})`));
375
+ for (const f of c.flags) out.push(` ${f.id} ${f.severity} ${f.text}`);
376
+ return `${out.join('\n')}\n`;
377
+ }
378
+
379
+ export function runXref(args, config) {
380
+ const json = args.includes('--json');
381
+ if (args.includes('--check')) {
382
+ const index = buildIndex(config, { fast: true });
383
+ if (index.scanCoverage?.complete === false) {
384
+ die(`Cross-reference scan incomplete: ${index.errors[0]?.message ?? index.warnings[0]?.message ?? 'a scan hook failed'}`);
385
+ }
386
+ const findings = xrefFindings(config, { index });
387
+ if (args.includes('--flag') && !config._execution?.suppressSideEffects) {
388
+ const { added, resolved } = syncCheckFlags(config, 'xref', findings, { complete: true });
389
+ process.stderr.write(`flags: ${added} added, ${resolved} resolved\n`);
390
+ }
391
+ if (json) process.stdout.write(`${JSON.stringify(findings, null, 2)}\n`);
392
+ else {
393
+ for (const f of findings) process.stdout.write(`${f.file}:${f.line} ${f.text}\n`);
394
+ process.stdout.write(`${findings.length} cross-reference findings.\n`);
395
+ }
396
+ if (findings.length) process.exitCode = 1;
397
+ return findings;
398
+ }
399
+ const targets = args.filter(a => !a.startsWith('-'));
400
+ if (!targets.length) die('Usage: runlist xref <file...> [--json] | runlist xref --check [--flag] [--json]');
401
+ const ctx = { index: buildIndex(config, { fast: true }), repo: repoPaths(config.repoRoot, { write: !config._execution?.suppressSideEffects }), decisions: decisionContext(config) };
402
+ const cards = targets.map(t => xrefDoc(resolveDocArg(t, config), config, ctx));
403
+ if (json) process.stdout.write(`${JSON.stringify(cards, null, 2)}\n`);
404
+ else process.stdout.write(cards.map(renderCard).join('\n'));
405
+ return cards;
406
+ }
@@ -0,0 +1,178 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
+ import { resolveDocArg } from './index.mjs';
5
+ import { authorizeManagedSource } from './managed-path.mjs';
6
+ import { mutateFileSet } from './atomic-mutation.mjs';
7
+ import { availableSessionId, assertPlanMutationAuthorized } from './pickup.mjs';
8
+ import { regenIndex, renderLifecycleMutation, runArchive } from './lifecycle.mjs';
9
+ import { isArchivedPath, nowIso, resolveRefPath, toRepoPath } from './util.mjs';
10
+ import { validateCommandArgs } from './commands.mjs';
11
+ import { compareYardstick, readYardstick, renderYardstick, YARDSTICK_FIELDS } from './yardstick.mjs';
12
+
13
+ function readPlan(input, config) {
14
+ const resolved = resolveDocArg(input, config, { dieOnMiss: false });
15
+ if (!resolved) throw new Error(`Plan not found: ${input}`);
16
+ const file = authorizeManagedSource(resolved, config, { kind: 'Yardstick plan' }).path;
17
+ const raw = readFileSync(file, 'utf8');
18
+ const warnings = [];
19
+ const { frontmatter } = extractFrontmatter(raw);
20
+ const fm = parseSimpleFrontmatter(frontmatter, warnings);
21
+ if (fm.type !== 'plan') throw new Error('Yardstick assessments apply only to plans.');
22
+ if (warnings.some(item => ['type', 'status', ...YARDSTICK_FIELDS].includes(item.key))) {
23
+ throw new Error('Duplicate lifecycle/yardstick fields must be resolved before an assessment.');
24
+ }
25
+ const repoPath = toRepoPath(file, config.repoRoot);
26
+ return { file, raw, fm, repoPath, archived: isArchivedPath(repoPath, config) && config.lifecycle.archiveStatuses.has(fm.status) };
27
+ }
28
+
29
+ function authorizePlan(plan, config) {
30
+ const ownership = assertPlanMutationAuthorized(plan.repoPath, config, { sessionId: availableSessionId() });
31
+ if (plan.fm.status === 'in-session' && ownership?.state !== 'owned') {
32
+ throw new Error('The in-session plan has no valid ownership record; recover its claim before assessing it.');
33
+ }
34
+ }
35
+
36
+ // Replace entire YAML field spans, including duplicates and multiline values,
37
+ // without removing comments or any unrelated frontmatter/body text.
38
+ export function renderAssessment(raw, fields) {
39
+ const { frontmatter, body, bodyLineOffset } = extractFrontmatter(raw);
40
+ if (!bodyLineOffset) throw new Error('Plan needs a valid frontmatter block.');
41
+ const lines = frontmatter.split('\n');
42
+ const kept = [];
43
+ let skip = false;
44
+ for (const line of lines) {
45
+ const key = line.match(/^([A-Za-z0-9_-]+):/);
46
+ if (key) skip = YARDSTICK_FIELDS.includes(key[1]);
47
+ if (!skip || /^\s*#/.test(line)) kept.push(line);
48
+ }
49
+ for (const [key, value] of Object.entries(fields)) kept.push(`${key}: ${JSON.stringify(value)}`);
50
+ return `---\n${kept.join('\n')}\n---\n${body}`;
51
+ }
52
+
53
+ function replacementChain(source, input, config) {
54
+ const target = readPlan(input, config);
55
+ const seen = new Set([realpathSync(source.file)]);
56
+ const guards = [];
57
+ let current = target;
58
+ while (current) {
59
+ const identity = realpathSync(current.file);
60
+ if (seen.has(identity)) throw new Error('A fold cannot reference itself or create a circular replacement chain.');
61
+ seen.add(identity);
62
+ guards.push({ path: current.file, expectedContent: current.raw });
63
+ if (current.fm.yardstick_disposition !== 'folded') break;
64
+ const ref = current.fm.yardstick_into;
65
+ if (typeof ref !== 'string' || !ref.trim()) throw new Error('The replacement chain has a missing plan link.');
66
+ const next = resolveRefPath(ref, path.dirname(current.file), config.repoRoot);
67
+ if (!next) throw new Error('The replacement chain has an unavailable plan.');
68
+ current = readPlan(next, config);
69
+ }
70
+ return { target, guards };
71
+ }
72
+
73
+ // Trusted adapters use this before loading a retained review's configuration.
74
+ export function assertYardstickReviewGuards(guards = []) {
75
+ for (const guard of guards) {
76
+ if (guard.absent ? existsSync(guard.path) : !existsSync(guard.path)
77
+ || readFileSync(guard.path, 'utf8') !== guard.expectedContent) {
78
+ throw new Error('Yardstick review material changed; reload before applying.');
79
+ }
80
+ }
81
+ }
82
+
83
+ export async function runYardstick(argv, config, opts = {}) {
84
+ argv = validateCommandArgs('yardstick', argv);
85
+ const out = opts.out ?? process.stdout;
86
+ const dryRun = Boolean(opts.dryRun);
87
+ const json = argv.includes('--json');
88
+ const noIndex = argv.includes('--no-index');
89
+ const values = {};
90
+ const positional = [];
91
+ for (let i = 0; i < argv.length; i++) {
92
+ if (argv[i] === '--reason' || argv[i] === '--into') values[argv[i]] = argv[++i];
93
+ else if (!argv[i].startsWith('--')) positional.push(argv[i]);
94
+ }
95
+ const [action, input, disposition] = positional;
96
+ // Trusted local adapters retain these snapshots between Review and Apply.
97
+ // They are never command-line arguments or renderer-controlled material.
98
+ assertYardstickReviewGuards(opts.guards);
99
+ const goal = readYardstick(config);
100
+ if (action === 'show') {
101
+ const plan = input ? readPlan(input, config) : null;
102
+ const comparison = plan ? compareYardstick(plan.fm, config, goal, plan.archived)
103
+ : { goal, delivers: null, assessment: null };
104
+ const result = { ...(plan ? { path: plan.repoPath } : {}), ...comparison };
105
+ out.write(json ? `${JSON.stringify(result, null, 2)}\n` : renderYardstick(result));
106
+ return result;
107
+ }
108
+
109
+ const plan = readPlan(input, config);
110
+ authorizePlan(plan, config);
111
+ if (goal.snapshot) {
112
+ const sourceNode = statSync(plan.file);
113
+ const goalNode = statSync(goal.snapshot.path);
114
+ if (sourceNode.dev === goalNode.dev && sourceNode.ino === goalNode.ino) {
115
+ throw new Error('Keep the product goal in a separate document; it cannot assess or archive itself.');
116
+ }
117
+ }
118
+ if (action === 'mark' && disposition !== 'serves') throw new Error('Use mark <plan> serves; close and fold are explicit archive actions.');
119
+ if (action !== 'clear' && goal.state !== 'ready') throw new Error(`Product goal is unavailable: ${goal.message ?? 'configure yardstick first'}`);
120
+ const reason = values['--reason']?.trim();
121
+ if (['close', 'fold'].includes(action) && !reason) throw new Error('Closing or folding requires --reason with the owner reason.');
122
+ if (values['--reason'] !== undefined && (!reason || reason.includes('\0'))) throw new Error('Reason must be nonempty text without NUL characters.');
123
+ const closing = action === 'close' || action === 'fold';
124
+ const fields = action === 'clear' ? {} : {
125
+ yardstick_disposition: action === 'mark' ? 'serves' : action === 'fold' ? 'folded' : 'closed',
126
+ yardstick_revision: goal.revision,
127
+ ...(reason ? { yardstick_reason: reason } : {}),
128
+ };
129
+ const guards = [{ path: plan.file, expectedContent: plan.raw }, ...(opts.guards ?? [])];
130
+ if (action !== 'clear') guards.push(goal.snapshot);
131
+ if (action === 'fold') {
132
+ if (!values['--into']) throw new Error('Folding requires --into with a replacement plan.');
133
+ const chain = replacementChain(plan, values['--into'], config);
134
+ fields.yardstick_into = `./${path.relative(path.dirname(plan.file), chain.target.file).split(path.sep).join('/')}`;
135
+ guards.push(...chain.guards);
136
+ }
137
+ const history = action === 'clear' ? 'Yardstick assessment cleared; execution status unchanged.'
138
+ : `Yardstick: ${fields.yardstick_disposition}${fields.yardstick_into ? ` into ${fields.yardstick_into}` : ''}${reason ? ` — ${reason.replace(/\r?\n/g, ' ')}` : '.'}`;
139
+ let result = { action, dryRun, path: plan.repoPath, assessment: fields, archived: plan.archived };
140
+ let archiveOutput = '';
141
+ if (closing && !plan.archived) {
142
+ const archived = runArchive([plan.file, ...(noIndex ? ['--no-index'] : [])], config, {
143
+ dryRun, out: { write(text) { archiveOutput += text; } }, note: history, guards, testHooks: opts.testHooks,
144
+ expectedDestination: opts.expectedDestination,
145
+ sourceTransform: raw => renderAssessment(raw, fields),
146
+ });
147
+ // Healing an already archived path previews an in-place change; the
148
+ // lifecycle dry-run intentionally has no move result in that case.
149
+ result = { ...result, archived: true, path: archived?.newRepoPath ?? plan.repoPath, previousPath: plan.repoPath, touched: archived?.touched ?? [] };
150
+ result.lifecycle = archiveOutput.trimEnd();
151
+ } else if (!dryRun) {
152
+ mutateFileSet({ guards, updates: [{ path: plan.file, expectedContent: plan.raw, render: raw => {
153
+ authorizePlan(plan, config);
154
+ return renderLifecycleMutation(renderAssessment(raw, fields), { updated: nowIso() }, history, { createSection: true });
155
+ } }] }, { repoRoot: config.repoRoot, testHooks: opts.testHooks });
156
+ if (!noIndex) regenIndex(config);
157
+ result.touched = [plan.repoPath];
158
+ }
159
+ if (!dryRun) {
160
+ const actual = readPlan(result.path, config);
161
+ result.comparison = compareYardstick(actual.fm, config, readYardstick(config), actual.archived);
162
+ } else if (opts.captureGuards) {
163
+ // GUI Review must show the same literal material these snapshots guard,
164
+ // including the proposed assessment, rather than an earlier panel read.
165
+ const proposed = Object.fromEntries(Object.entries(plan.fm).filter(([key]) => !YARDSTICK_FIELDS.includes(key)));
166
+ result.comparison = compareYardstick({ ...proposed, ...fields }, config, goal, closing || plan.archived);
167
+ }
168
+ if (json) out.write(`${JSON.stringify(result, null, 2)}\n`);
169
+ else {
170
+ if (archiveOutput) out.write(archiveOutput);
171
+ out.write(`${dryRun ? '[dry-run] Would record' : 'Recorded'} yardstick ${action}: ${result.path}${closing ? ' (archived)' : ''}\n`);
172
+ if (dryRun && reason) out.write(`Owner reason: ${reason}\n`);
173
+ if (dryRun && fields.yardstick_into) out.write(`Replacement (relative to the original plan): ${fields.yardstick_into}\n`);
174
+ if (result.comparison) out.write(renderYardstick(result.comparison, { compact: true }));
175
+ }
176
+ if (opts.captureGuards) Object.defineProperty(result, 'preparedGuards', { value: guards });
177
+ return result;
178
+ }