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
@@ -0,0 +1,355 @@
1
+ // Modules dashboard (F16). Two view-only commands:
2
+ // `runlist modules` — one row per discovered module, dynamic status columns
3
+ // `runlist module <name>` — plans for one module, grouped by status, stale flagged inline
4
+ //
5
+ // Aggregation lives in `aggregateModules` (pure data). Both renderers consume
6
+ // the same shape, so JSON output and the table share one source of truth.
7
+ //
8
+ // Constraint cheatsheet (see docs/archived/modules-dashboard.md):
9
+ // M1 dynamic columns — discover statuses from rendered rows, drop empty cols
10
+ // M2 overflow fallback — drop-empty first, then stacked render if still too wide
11
+ // M3 double-counting — `modules: [a, b]` increments both rows (intended)
12
+ // M4 (none) bucket — surface when ≥1 plan has empty modules
13
+ // M6 archived/skipStale — terminal statuses excluded; skipStale don't contribute to `stale`
14
+ import { toSlug, truncate, die, suggestCandidates } from './util.mjs';
15
+ import { bold, dim, yellow, red, green, blue, magenta, cyan, brightYellow } from './color.mjs';
16
+
17
+ const STATUS_COLORS = {
18
+ 'in-session': (s) => bold(red(s)),
19
+ 'active': green,
20
+ 'planned': blue,
21
+ 'blocked': yellow,
22
+ 'partial': (s) => dim(green(s)),
23
+ 'paused': magenta,
24
+ 'awaiting': brightYellow,
25
+ 'queued-after': (s) => dim(cyan(s)),
26
+ 'archived': dim,
27
+ 'pending': bold,
28
+ 'claimed': dim,
29
+ };
30
+
31
+ function colorStatus(status, text) {
32
+ const fn = STATUS_COLORS[status] ?? ((s) => s);
33
+ return fn(text);
34
+ }
35
+
36
+ // Single pass over docs → Map<moduleName, aggregate>. Terminal-status docs are
37
+ // excluded entirely (M6) — they pollute the active-work picture. skipStale
38
+ // statuses are counted toward totals but never contribute to `stale` so they
39
+ // don't lie about staleness when a custom config (e.g. `backlog`) opts out.
40
+ export function aggregateModules(docs, config) {
41
+ const terminal = config.lifecycle.terminalStatuses;
42
+ const skipStale = config.lifecycle.skipStaleFor;
43
+ const liveDocs = docs.filter(d => !terminal.has(d.status));
44
+ const uniqueTotal = liveDocs.length;
45
+
46
+ const modules = new Map();
47
+ function bucket(name) {
48
+ if (!modules.has(name)) {
49
+ modules.set(name, {
50
+ name,
51
+ plans: [],
52
+ byStatus: {},
53
+ stale: 0,
54
+ ageSum: 0,
55
+ ageCount: 0,
56
+ oldest: null,
57
+ nextStepCount: 0,
58
+ });
59
+ }
60
+ return modules.get(name);
61
+ }
62
+
63
+ for (const doc of liveDocs) {
64
+ const names = doc.modules?.length ? doc.modules : ['(none)'];
65
+ for (const name of names) {
66
+ const m = bucket(name);
67
+ m.plans.push(doc);
68
+ m.byStatus[doc.status] = (m.byStatus[doc.status] ?? 0) + 1;
69
+ if (doc.isStale && !skipStale.has(doc.status)) m.stale += 1;
70
+ if (typeof doc.daysSinceUpdate === 'number') {
71
+ m.ageSum += doc.daysSinceUpdate;
72
+ m.ageCount += 1;
73
+ if (!m.oldest || doc.daysSinceUpdate > m.oldest.ageDays) {
74
+ m.oldest = { slug: toSlug(doc), ageDays: doc.daysSinceUpdate };
75
+ }
76
+ }
77
+ if (doc.hasNextStep) m.nextStepCount += 1;
78
+ }
79
+ }
80
+
81
+ const rows = [...modules.values()].map(m => ({
82
+ name: m.name,
83
+ total: m.plans.length,
84
+ byStatus: m.byStatus,
85
+ stale: m.stale,
86
+ avgAgeDays: m.ageCount > 0 ? m.ageSum / m.ageCount : 0,
87
+ oldest: m.oldest,
88
+ nextStepPct: m.plans.length > 0 ? m.nextStepCount / m.plans.length : 0,
89
+ _plans: m.plans,
90
+ }));
91
+
92
+ return { rows, totalUnique: uniqueTotal };
93
+ }
94
+
95
+ // Cleanup-rank formula (R3): high stale share + high average age + lower total
96
+ // floats modules that have rotted; low total in the denominator keeps a single
97
+ // ancient plan from outweighing a 30-plan module with two stale ones. Document
98
+ // in `--help`; iterate after running on real corpora.
99
+ function cleanupScore(row) {
100
+ return (row.stale * row.avgAgeDays) / Math.max(row.total, 1);
101
+ }
102
+
103
+ function buildSorter(sort) {
104
+ if (sort === 'stale') return (a, b) => b.stale - a.stale || b.total - a.total;
105
+ if (sort === 'age') return (a, b) => b.avgAgeDays - a.avgAgeDays || b.total - a.total;
106
+ if (sort === 'nextstep') return (a, b) => b.nextStepPct - a.nextStepPct || b.total - a.total;
107
+ if (sort === 'cleanup') return (a, b) => cleanupScore(b) - cleanupScore(a) || b.stale - a.stale;
108
+ return (a, b) => b.total - a.total || a.name.localeCompare(b.name);
109
+ }
110
+
111
+ function parseFlags(argv) {
112
+ const flags = { sort: 'total', json: false, limit: 20, all: false };
113
+ for (let i = 0; i < argv.length; i++) {
114
+ const arg = argv[i];
115
+ if (arg === '--sort' && argv[i + 1]) { flags.sort = argv[++i]; continue; }
116
+ if (arg === '--json') { flags.json = true; continue; }
117
+ if (arg === '--limit' && argv[i + 1]) { flags.limit = parseInt(argv[++i], 10); continue; }
118
+ if (arg === '--all') { flags.all = true; continue; }
119
+ }
120
+ const validSorts = new Set(['total', 'stale', 'age', 'nextstep', 'cleanup']);
121
+ if (!validSorts.has(flags.sort)) {
122
+ die(`Invalid --sort value: ${flags.sort}. Valid: ${[...validSorts].join(', ')}.`);
123
+ }
124
+ return flags;
125
+ }
126
+
127
+ // Discover which status columns to render. Only statuses with ≥1 non-zero cell
128
+ // across the rendered rows make the cut (M1). Ordered by config.statusOrder
129
+ // first, then alphabetically for any custom statuses the index encountered
130
+ // that aren't in statusOrder (so a custom `research` column appears).
131
+ function discoverStatusColumns(rows, config) {
132
+ const seen = new Set();
133
+ for (const row of rows) {
134
+ for (const [status, count] of Object.entries(row.byStatus)) {
135
+ if (count > 0) seen.add(status);
136
+ }
137
+ }
138
+ const ordered = config.statusOrder.filter(s => seen.has(s));
139
+ const extras = [...seen].filter(s => !config.statusOrder.includes(s)).sort();
140
+ return [...ordered, ...extras];
141
+ }
142
+
143
+ function fmtAge(days) {
144
+ if (!Number.isFinite(days)) return '-';
145
+ if (days < 1) return '<1d';
146
+ if (days < 10) return `${days.toFixed(1)}d`;
147
+ return `${Math.round(days)}d`;
148
+ }
149
+
150
+ function fmtPct(frac) {
151
+ if (!Number.isFinite(frac)) return '-';
152
+ return `${Math.round(frac * 100)}%`;
153
+ }
154
+
155
+ function fmtOldest(oldest) {
156
+ if (!oldest) return '-';
157
+ const slug = truncate(oldest.slug, 14);
158
+ return `${slug} (${fmtAge(oldest.ageDays)})`;
159
+ }
160
+
161
+ // Heuristic column-width budget. Returns true if the dynamic-columns table
162
+ // will fit within the terminal; caller switches to the stacked render if not.
163
+ function tableFits(rows, statuses, termWidth) {
164
+ const nameCol = Math.max(8, ...rows.map(r => r.name.length));
165
+ const statusCols = statuses.length * 7; // each status header padded
166
+ const tailCols = 6 /*stale*/ + 8 /*age*/ + 22 /*oldest*/ + 6 /*next%*/ + 8 /*gutters*/;
167
+ return nameCol + statusCols + tailCols <= termWidth;
168
+ }
169
+
170
+ function pad(s, width) {
171
+ if (s.length >= width) return s;
172
+ return s + ' '.repeat(width - s.length);
173
+ }
174
+
175
+ function padLeft(s, width) {
176
+ if (s.length >= width) return s;
177
+ return ' '.repeat(width - s.length) + s;
178
+ }
179
+
180
+ function renderTable(rows, statuses, config) {
181
+ const nameCol = Math.max(8, ...rows.map(r => r.name.length));
182
+ const lines = [];
183
+ const headerCells = [
184
+ pad('module', nameCol),
185
+ ...statuses.map(s => padLeft(s.slice(0, 5), 5)),
186
+ padLeft('stale', 5),
187
+ padLeft('avgAge', 7),
188
+ pad('oldest', 22),
189
+ padLeft('next%', 5),
190
+ ];
191
+ lines.push(dim(headerCells.join(' ')));
192
+ for (const row of rows) {
193
+ const cells = [
194
+ pad(row.name, nameCol),
195
+ ...statuses.map(s => {
196
+ const n = row.byStatus[s] ?? 0;
197
+ const text = padLeft(String(n), 5);
198
+ return n > 0 ? colorStatus(s, text) : dim(text);
199
+ }),
200
+ padLeft(String(row.stale), 5),
201
+ padLeft(fmtAge(row.avgAgeDays), 7),
202
+ pad(fmtOldest(row.oldest), 22),
203
+ padLeft(fmtPct(row.nextStepPct), 5),
204
+ ];
205
+ lines.push(cells.join(' '));
206
+ }
207
+ return lines.join('\n') + '\n';
208
+ }
209
+
210
+ // Stacked fallback (M2 step 2): one block per module, status counts indented.
211
+ // Preserves every value — never collapses to "Other".
212
+ function renderStacked(rows, statuses) {
213
+ const lines = [];
214
+ for (const row of rows) {
215
+ lines.push(bold(row.name) + dim(` · ${row.total} total · ${row.stale} stale · avg ${fmtAge(row.avgAgeDays)} · next-step ${fmtPct(row.nextStepPct)}`));
216
+ const cells = statuses
217
+ .filter(s => (row.byStatus[s] ?? 0) > 0)
218
+ .map(s => `${colorStatus(s, s)}: ${row.byStatus[s]}`);
219
+ if (cells.length) lines.push(' ' + cells.join(' '));
220
+ if (row.oldest) lines.push(dim(` oldest: ${row.oldest.slug} (${fmtAge(row.oldest.ageDays)})`));
221
+ lines.push('');
222
+ }
223
+ return lines.join('\n');
224
+ }
225
+
226
+ export function runModulesDashboard(index, argv, config) {
227
+ const flags = parseFlags(argv);
228
+ const { rows, totalUnique } = aggregateModules(index.docs, config);
229
+ rows.sort(buildSorter(flags.sort));
230
+ const totalModules = rows.length;
231
+ const displayRows = flags.all ? rows : rows.slice(0, flags.limit);
232
+
233
+ if (flags.json) {
234
+ const out = {
235
+ type: 'plan',
236
+ sort: flags.sort,
237
+ _totalUnique: totalUnique,
238
+ modules: displayRows.map(r => ({
239
+ name: r.name,
240
+ total: r.total,
241
+ byStatus: r.byStatus,
242
+ stale: r.stale,
243
+ avgAgeDays: Math.round(r.avgAgeDays * 10) / 10,
244
+ oldest: r.oldest,
245
+ nextStepPct: Math.round(r.nextStepPct * 100) / 100,
246
+ })),
247
+ };
248
+ process.stdout.write(JSON.stringify(out, null, 2) + '\n');
249
+ return;
250
+ }
251
+
252
+ if (rows.length === 0) {
253
+ process.stdout.write('No modules found.\n');
254
+ return;
255
+ }
256
+
257
+ const statuses = discoverStatusColumns(displayRows, config);
258
+ const termWidth = process.stdout.columns || 120;
259
+
260
+ const header = `${totalModules} modules · ${totalUnique} plans · sort: ${flags.sort}`;
261
+ process.stdout.write(dim(header) + '\n\n');
262
+
263
+ if (tableFits(displayRows, statuses, termWidth)) {
264
+ process.stdout.write(renderTable(displayRows, statuses, config));
265
+ } else {
266
+ process.stdout.write(renderStacked(displayRows, statuses));
267
+ }
268
+
269
+ if (!flags.all && rows.length > flags.limit) {
270
+ const hidden = rows.length - flags.limit;
271
+ process.stdout.write('\n' + dim(` ${hidden} more module${hidden === 1 ? '' : 's'} · runlist modules --all\n`));
272
+ }
273
+ }
274
+
275
+ export function runModuleDetail(index, argv, config) {
276
+ const flags = { sort: 'status', json: false };
277
+ const positional = [];
278
+ for (let i = 0; i < argv.length; i++) {
279
+ const arg = argv[i];
280
+ if (arg === '--sort' && argv[i + 1]) { flags.sort = argv[++i]; continue; }
281
+ if (arg === '--json') { flags.json = true; continue; }
282
+ if (arg.startsWith('-')) continue;
283
+ positional.push(arg);
284
+ }
285
+ const name = positional[0];
286
+ if (!name) die('Usage: runlist module <name>');
287
+
288
+ const { rows } = aggregateModules(index.docs, config);
289
+ const row = rows.find(r => r.name === name);
290
+ if (!row) {
291
+ const candidates = rows.map(r => r.name);
292
+ const suggestions = suggestCandidates(name, candidates);
293
+ const hint = suggestions.length
294
+ ? `Did you mean: ${suggestions.join(', ')}?`
295
+ : `Available: ${candidates.slice(0, 5).join(', ')}${candidates.length > 5 ? ', …' : ''} (run \`runlist modules\` for the full list).`;
296
+ die(`Module '${name}' not found. ${hint}`);
297
+ }
298
+
299
+ const plans = row._plans.slice();
300
+ if (flags.sort === 'updated' || flags.sort === 'age') {
301
+ plans.sort((a, b) => (b.daysSinceUpdate ?? 0) - (a.daysSinceUpdate ?? 0));
302
+ } else {
303
+ plans.sort((a, b) => {
304
+ const ai = config.statusOrder.indexOf(a.status);
305
+ const bi = config.statusOrder.indexOf(b.status);
306
+ const aIdx = ai === -1 ? Number.MAX_SAFE_INTEGER : ai;
307
+ const bIdx = bi === -1 ? Number.MAX_SAFE_INTEGER : bi;
308
+ if (aIdx !== bIdx) return aIdx - bIdx;
309
+ return (b.daysSinceUpdate ?? 0) - (a.daysSinceUpdate ?? 0);
310
+ });
311
+ }
312
+
313
+ if (flags.json) {
314
+ const out = {
315
+ name: row.name,
316
+ total: row.total,
317
+ plans: plans.map(p => ({
318
+ path: p.path,
319
+ slug: toSlug(p),
320
+ status: p.status,
321
+ daysSinceUpdate: p.daysSinceUpdate,
322
+ isStale: p.isStale,
323
+ hasNextStep: p.hasNextStep,
324
+ summary: p.summary,
325
+ })),
326
+ };
327
+ process.stdout.write(JSON.stringify(out, null, 2) + '\n');
328
+ return;
329
+ }
330
+
331
+ process.stdout.write(dim(`${row.total} plans · ${row.stale} stale · module: ${row.name}`) + '\n');
332
+
333
+ const skipStale = config.lifecycle.skipStaleFor;
334
+ const groups = new Map();
335
+ for (const p of plans) {
336
+ if (!groups.has(p.status)) groups.set(p.status, []);
337
+ groups.get(p.status).push(p);
338
+ }
339
+ const orderedStatuses = [
340
+ ...config.statusOrder.filter(s => groups.has(s)),
341
+ ...[...groups.keys()].filter(s => !config.statusOrder.includes(s)),
342
+ ];
343
+ for (const status of orderedStatuses) {
344
+ const group = groups.get(status);
345
+ process.stdout.write(`\n${bold(colorStatus(status, status))} (${group.length})\n`);
346
+ for (const p of group) {
347
+ const slug = toSlug(p);
348
+ const age = fmtAge(p.daysSinceUpdate);
349
+ const staleTag = p.isStale && !skipStale.has(p.status) ? dim(' [stale]') : '';
350
+ const next = p.nextStep ? ` — ${truncate(p.nextStep, 60)}` : '';
351
+ process.stdout.write(` ${slug} ${dim(age)}${staleTag}${next}\n`);
352
+ }
353
+ }
354
+ process.stdout.write('\n');
355
+ }
package/src/naming.mjs ADDED
@@ -0,0 +1,55 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // Canonical product-owned names and the spellings accepted during migration.
5
+ // Writers use the current names. Readers accept both so a new build can safely
6
+ // recover work left by an older build. Migration is intentionally one-way.
7
+ export const PRODUCT_NAME = 'runlist';
8
+ export const LEGACY_PRODUCT_NAME = 'dotmd';
9
+
10
+ export const STATE_DIR = '.runlist';
11
+ export const LEGACY_STATE_DIR = '.dotmd';
12
+
13
+ export const ARTIFACT_PREFIX = '.runlist-';
14
+ export const LEGACY_ARTIFACT_PREFIX = '.dotmd-';
15
+
16
+ export const CONFIG_FILENAMES = [
17
+ 'runlist.config.mjs',
18
+ '.runlist.config.mjs',
19
+ 'runlist.config.js',
20
+ 'dotmd.config.mjs',
21
+ '.dotmd.config.mjs',
22
+ 'dotmd.config.js',
23
+ ];
24
+
25
+ export const ENV_PREFIX = 'RUNLIST_';
26
+ export const LEGACY_ENV_PREFIX = 'DOTMD_';
27
+
28
+ // Prefer current state, but let an explicitly refused migration keep operating
29
+ // against the untouched legacy directory.
30
+ export function stateDir(repoRoot) {
31
+ const root = path.resolve(repoRoot);
32
+ const current = path.join(root, STATE_DIR);
33
+ if (existsSync(current)) return current;
34
+ const legacy = path.join(root, LEGACY_STATE_DIR);
35
+ if (existsSync(legacy)) return legacy;
36
+ return current;
37
+ }
38
+
39
+ export function isOwnedArtifact(basename, kind = '') {
40
+ const suffix = kind ? `${kind}-` : '';
41
+ return basename.startsWith(`${ARTIFACT_PREFIX}${suffix}`)
42
+ || basename.startsWith(`${LEGACY_ARTIFACT_PREFIX}${suffix}`);
43
+ }
44
+
45
+ export function isSidecarArtifact(basename, kind = '') {
46
+ const suffix = kind ? `${kind}-` : '';
47
+ return basename.includes(`${ARTIFACT_PREFIX}${suffix}`)
48
+ || basename.includes(`${LEGACY_ARTIFACT_PREFIX}${suffix}`);
49
+ }
50
+
51
+ export function readEnv(name, env = process.env) {
52
+ const current = env[`${ENV_PREFIX}${name}`];
53
+ if (current !== undefined) return current;
54
+ return env[`${LEGACY_ENV_PREFIX}${name}`];
55
+ }
@@ -0,0 +1,59 @@
1
+ import {parseNativeRecord} from './native-record.mjs';
2
+ import {SourceEditError} from './source-editor.mjs';
3
+
4
+ const fail=(code,message)=>{throw new SourceEditError(code,message);};
5
+ export function prepareNativeAction(source,request,actor) {
6
+ const parsed=parseNativeRecord(source);
7
+ if(!parsed.ok||!['flag','decision'].includes(parsed.record?.type))fail('invalid-record','A valid native flag or decision is required. Repair its source before recording an action.');
8
+ if(!actor?.id||!['human','agent'].includes(actor.kind))fail('unauthenticated','An authenticated human or agent is required.');
9
+ const {action,note,operationId,at,optionId}=request;
10
+ if(!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(operationId??''))fail('invalid-id','A UUID v4 operation is required.');
11
+ if(typeof note!=='string'||!note.trim()||note.length>4000)fail('native-invalid-action','Give a reason, up to 4,000 characters.');
12
+ if(typeof at!=='string'||!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/.test(at)||!Number.isFinite(Date.parse(at)))fail('native-invalid-action','A valid recording timestamp is required.');
13
+ const record=structuredClone(parsed.record),data=record.record_data,by=structuredClone(actor),reason=note.trim(),oldStatus=record.status;
14
+ if(data.history.some(e=>e.id===`event:${operationId}`))fail('operation-reused','This operation is already in the record. Retry its retained request.');
15
+ const event={id:`event:${operationId}`,kind:action,by,at,reason,from:oldStatus};
16
+ if(record.type==='flag') {
17
+ if(!['accept','reject','resolve','reopen'].includes(action))fail('native-invalid-action','Choose accept, reject, resolve or reopen.');
18
+ if(action==='reopen' ? oldStatus==='open' : oldStatus!=='open')fail('native-state-conflict','The flag changed state. Reload it before reviewing an action.');
19
+ if(action==='accept'||action==='reject'){
20
+ event.previous_triage=structuredClone(data.triage);
21
+ data.triage={...data.triage,disposition:action==='accept'?'accepted':'rejected',by,at,reason};record.status=action==='reject'?'rejected':'open';
22
+ }else if(action==='resolve'){
23
+ const resolution={id:`resolution:${operationId}`,by,at,method:'manual',reason,evidence_ids:[]};
24
+ data.resolutions.push(resolution);data.active_resolution_id=resolution.id;record.status='resolved';event.resolution_id=resolution.id;
25
+ }else{
26
+ event.previous_triage=structuredClone(data.triage);event.previous_resolution_id=data.active_resolution_id??null;
27
+ const {by:priorBy,at:priorAt,reason:priorReason,...extensions}=data.triage;
28
+ data.triage={...extensions,disposition:'unreviewed'};delete data.active_resolution_id;record.status='open';
29
+ }
30
+ }else{
31
+ if(!['held','ruled','closed','open'].includes(action))fail('native-invalid-action','Choose open, held, ruled or closed.');
32
+ const superseded=new Set(data.rulings.map(r=>r.supersedes_ruling_id).filter(Boolean)),heads=data.rulings.filter(r=>!superseded.has(r.id));
33
+ if(heads.length>1)fail('native-ruling-conflict','Competing rulings need an explicit reconciliation in the source.');
34
+ const previous=heads[0];
35
+ if(action==='ruled'||action==='closed'||previous?.kind==='select'){
36
+ if(actor.kind!=='human')fail('native-authority-required','A human must record or withdraw a ruling. Agents can recommend an option separately.');
37
+ if(action==='ruled'&&!data.options.some(o=>o.id===optionId))fail('native-invalid-option','Select an existing option.');
38
+ const ruling={id:`ruling:${operationId}`,kind:action==='ruled'?'select':'withdraw',by,at,reason,authority:{basis:'human'},evidence_ids:[],...(action==='ruled'?{option_id:optionId}:{}),...(previous?{supersedes_ruling_id:previous.id}:{})};
39
+ data.rulings.push(ruling);event.ruling_id=ruling.id;
40
+ if(['ruled','closed'].includes(action))data.active_ruling_id=ruling.id;
41
+ }
42
+ if(!['ruled','closed'].includes(action))delete data.active_ruling_id;
43
+ if(action==='held')data.hold={...data.hold,by,at,reason};else delete data.hold;
44
+ record.status=action;
45
+ }
46
+ event.to=record.status;data.history.push(event);record.updated=at;
47
+ // Replace only managed top-level lines and the literal JSON payload. Preserve
48
+ // every other metadata byte, the body and its original line endings.
49
+ const lines=source.match(/[^\r\n]*(?:\r\n|\n|\r|$)/g).filter(Boolean),end=lines.findIndex((l,i)=>i>0&&/^---\s*$/.test(l.trim())),start=lines.findIndex((l,i)=>i>0&&i<end&&/^record_data: [|]/.test(l));
50
+ if(start<0||end<0)fail('invalid-record','The record_data literal block is required.');
51
+ let finish=start+1;while(finish<end&&(/^[ \t]/.test(lines[finish])||!lines[finish].trim()))finish++;
52
+ const eol=lines[start].match(/\r\n|\n|\r$/)?.[0]??'\n';
53
+ const payload=JSON.stringify(data,null,2).split('\n').map(line=>` ${line}${eol}`);
54
+ lines.splice(start+1,finish-start-1,...payload);
55
+ for(const [field,value] of [['status',record.status],['updated',at]]){const i=lines.findIndex(l=>l.startsWith(`${field}:`));if(i<1)fail('invalid-record',`Missing ${field}.`);const ending=lines[i].match(/\r\n|\n|\r$/)?.[0]??'';lines[i]=`${field}: ${value}${ending}`;}
56
+ const next=lines.join(''),after=parseNativeRecord(next);
57
+ if(!after.ok)throw new SourceEditError('invalid-record','The action would violate the native record contract.',{diagnostics:after.diagnostics});
58
+ return next;
59
+ }