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.
Files changed (166) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1114 -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 +292 -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/query.mjs +977 -0
  136. package/src/record-action.mjs +11 -0
  137. package/src/record-create.mjs +82 -0
  138. package/src/reference-planner.mjs +388 -0
  139. package/src/reference-values.mjs +11 -0
  140. package/src/rename.mjs +107 -0
  141. package/src/render.mjs +678 -0
  142. package/src/roadmap.mjs +184 -0
  143. package/src/runlist.mjs +944 -0
  144. package/src/section.mjs +225 -0
  145. package/src/semantic-search-client.mjs +58 -0
  146. package/src/ship.mjs +166 -0
  147. package/src/show.mjs +101 -0
  148. package/src/skill-drift.mjs +98 -0
  149. package/src/source-editor.mjs +491 -0
  150. package/src/stages.mjs +73 -0
  151. package/src/state-migration.mjs +123 -0
  152. package/src/stats.mjs +200 -0
  153. package/src/status-metadata.mjs +87 -0
  154. package/src/statuses.mjs +721 -0
  155. package/src/summary.mjs +74 -0
  156. package/src/surfaces.mjs +28 -0
  157. package/src/sync-status.mjs +316 -0
  158. package/src/template-store.mjs +62 -0
  159. package/src/update.mjs +338 -0
  160. package/src/use.mjs +58 -0
  161. package/src/util.mjs +337 -0
  162. package/src/validate.mjs +794 -0
  163. package/src/watch.mjs +56 -0
  164. package/src/xref.mjs +406 -0
  165. package/src/yardstick-command.mjs +178 -0
  166. package/src/yardstick.mjs +112 -0
@@ -0,0 +1,123 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ renameSync,
7
+ rmSync,
8
+ rmdirSync,
9
+ statSync,
10
+ writeFileSync,
11
+ } from 'node:fs';
12
+ import path from 'node:path';
13
+ import { processOwnerLiveness } from './atomic-mutation.mjs';
14
+ import { LEGACY_STATE_DIR, STATE_DIR } from './naming.mjs';
15
+
16
+ export { stateDir } from './naming.mjs';
17
+
18
+ const MIGRATION_MARKER = 'migrated-from-dotmd.json';
19
+ const DROP_ENTRIES = new Set(['handoffs']);
20
+ const TERMINAL_TRANSACTION_STATUSES = new Set(['committed', 'rolled-back', 'failed-manual']);
21
+
22
+ function heldLocks(lockRoot) {
23
+ if (!existsSync(lockRoot)) return [];
24
+ let entries;
25
+ try { entries = readdirSync(lockRoot); }
26
+ catch { return [{ lock: path.basename(lockRoot), status: 'unreadable-directory' }]; }
27
+ const held = [];
28
+ for (const entry of entries) {
29
+ const ownerPath = path.join(lockRoot, entry, 'owner.json');
30
+ if (!existsSync(ownerPath)) {
31
+ held.push({ lock: entry, status: 'owner-missing' });
32
+ continue;
33
+ }
34
+ let owner;
35
+ try { owner = JSON.parse(readFileSync(ownerPath, 'utf8')); }
36
+ catch {
37
+ held.push({ lock: entry, status: 'owner-unreadable' });
38
+ continue;
39
+ }
40
+ if (processOwnerLiveness(owner) !== 'dead') {
41
+ held.push({ lock: entry, status: 'held-or-unverifiable', pid: owner.pid, hostname: owner.hostname });
42
+ }
43
+ }
44
+ return held;
45
+ }
46
+
47
+ function unresolvedTransactions(transactionRoot) {
48
+ if (!existsSync(transactionRoot)) return [];
49
+ let entries;
50
+ try { entries = readdirSync(transactionRoot); }
51
+ catch { return [{ id: path.basename(transactionRoot), status: 'unreadable-directory' }]; }
52
+ const open = [];
53
+ for (const entry of entries) {
54
+ const manifestPath = path.join(transactionRoot, entry, 'manifest.json');
55
+ if (!existsSync(manifestPath)) {
56
+ open.push({ id: entry, status: 'manifest-missing' });
57
+ continue;
58
+ }
59
+ let manifest;
60
+ try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); }
61
+ catch { open.push({ id: entry, status: 'unreadable' }); continue; }
62
+ if (!TERMINAL_TRANSACTION_STATUSES.has(manifest.status)) {
63
+ open.push({ id: entry, status: manifest.status ?? 'unknown' });
64
+ }
65
+ }
66
+ return open;
67
+ }
68
+
69
+ function moveEntry(from, to) {
70
+ if (existsSync(to)) {
71
+ if (statSync(to).isDirectory() && statSync(from).isDirectory()) {
72
+ for (const child of readdirSync(from)) moveEntry(path.join(from, child), path.join(to, child));
73
+ rmSync(from, { recursive: true, force: true });
74
+ return;
75
+ }
76
+ // Current state wins an exact-name collision. The legacy copy is stale by
77
+ // definition and retaining both would make the next migration ambiguous.
78
+ rmSync(from, { recursive: true, force: true });
79
+ return;
80
+ }
81
+ renameSync(from, to);
82
+ }
83
+
84
+ export function migrateStateDirectory(repoRoot, options = {}) {
85
+ const root = path.resolve(repoRoot);
86
+ const from = path.join(root, LEGACY_STATE_DIR);
87
+ const to = path.join(root, STATE_DIR);
88
+ if (!existsSync(from)) return { status: 'noop', reason: 'nothing-to-migrate', from, to };
89
+
90
+ const held = heldLocks(path.join(from, 'locks'));
91
+ if (held.length) {
92
+ return {
93
+ status: 'refused', reason: 'locks-held', detail: held, from, to,
94
+ message: `Legacy state has ${held.length} lock${held.length === 1 ? '' : 's'} that ${held.length === 1 ? 'is' : 'are'} not provably abandoned. State migration refused; retry after active work finishes or inspect with \`runlist doctor --transactions\`.`,
95
+ };
96
+ }
97
+
98
+ const open = unresolvedTransactions(path.join(from, 'transactions'));
99
+ if (open.length) {
100
+ return {
101
+ status: 'refused', reason: 'transactions-unresolved', detail: open, from, to,
102
+ message: `${open.length} transaction${open.length === 1 ? ' is' : 's are'} not terminal (${open.map(item => `${item.id}: ${item.status}`).join(', ')}). State migration refused. Resolve with \`runlist doctor --transactions --apply\`, then retry.`,
103
+ };
104
+ }
105
+
106
+ if (options.dryRun) return { status: 'noop', reason: 'dry-run', from, to };
107
+
108
+ mkdirSync(to, { recursive: true });
109
+ for (const entry of readdirSync(from)) {
110
+ if (DROP_ENTRIES.has(entry)) {
111
+ rmSync(path.join(from, entry), { recursive: true, force: true });
112
+ continue;
113
+ }
114
+ moveEntry(path.join(from, entry), path.join(to, entry));
115
+ }
116
+ try { if (readdirSync(from).length === 0) rmdirSync(from); } catch {}
117
+
118
+ writeFileSync(
119
+ path.join(to, MIGRATION_MARKER),
120
+ JSON.stringify({ from: LEGACY_STATE_DIR, to: STATE_DIR, migratedAt: new Date().toISOString() }, null, 2) + '\n',
121
+ );
122
+ return { status: 'migrated', from, to };
123
+ }
package/src/stats.mjs ADDED
@@ -0,0 +1,200 @@
1
+ import { capitalize, toSlug, warn } from './util.mjs';
2
+ import { bold, dim } from './color.mjs';
3
+
4
+ function pct(n, total) {
5
+ if (!total) return 0;
6
+ return Math.round((n / total) * 100);
7
+ }
8
+
9
+ export function buildStats(index, config) {
10
+ const docs = index.docs;
11
+ const scope = config.statusOrder.filter(s => !config.lifecycle.terminalStatuses.has(s));
12
+ for (const typeSet of (config.typeStatuses?.values() ?? [])) {
13
+ for (const s of typeSet) {
14
+ if (!config.lifecycle.terminalStatuses.has(s) && !scope.includes(s)) scope.push(s);
15
+ }
16
+ }
17
+ const scoped = docs.filter(d => scope.includes(d.status));
18
+ const nonArchived = docs.filter(d => !config.lifecycle.skipsWarnings(d.status, d.type));
19
+
20
+ // Health
21
+ const staleCount = nonArchived.filter(d => d.isStale).length;
22
+ const brokenRefCount = index.errors.filter(e => e.message.includes('does not resolve to an existing file')).length;
23
+ const brokenLinkCount = index.warnings.filter(w => w.message.startsWith('body link')).length;
24
+
25
+ // Freshness
26
+ const withDays = nonArchived.filter(d => d.daysSinceUpdate != null);
27
+ const sorted = [...withDays].sort((a, b) => (b.daysSinceUpdate ?? 0) - (a.daysSinceUpdate ?? 0));
28
+ const oldest = sorted[0] ?? null;
29
+
30
+ // Checklists
31
+ const withChecklists = docs.filter(d => d.checklist?.total > 0);
32
+ const avgCompletion = withChecklists.length > 0
33
+ ? Math.round(withChecklists.reduce((sum, d) => sum + (d.checklistCompletionRate ?? 0), 0) / withChecklists.length * 100)
34
+ : 0;
35
+ const fullyComplete = withChecklists.filter(d => d.checklistCompletionRate === 1).length;
36
+ const withOpenItems = withChecklists.filter(d => d.checklist.open > 0).length;
37
+
38
+ // Audit
39
+ const auditCounts = { pass1: 0, pass2: 0, deep: 0 };
40
+ for (const d of scoped) {
41
+ if (auditCounts[d.auditLevel] !== undefined) auditCounts[d.auditLevel]++;
42
+ }
43
+ const auditedCount = auditCounts.pass1 + auditCounts.pass2 + auditCounts.deep;
44
+
45
+ return {
46
+ generatedAt: new Date().toISOString(),
47
+ totalDocs: docs.length,
48
+ countsByStatus: index.countsByStatus,
49
+ countsByType: index.countsByType ?? {},
50
+ health: {
51
+ staleCount,
52
+ stalePct: pct(staleCount, nonArchived.length),
53
+ nonArchivedCount: nonArchived.length,
54
+ errorCount: index.errors.length,
55
+ warningCount: index.warnings.length,
56
+ brokenRefCount,
57
+ brokenLinkCount,
58
+ },
59
+ freshness: {
60
+ today: docs.filter(d => d.daysSinceUpdate === 0).length,
61
+ thisWeek: docs.filter(d => d.daysSinceUpdate != null && d.daysSinceUpdate <= 7).length,
62
+ thisMonth: docs.filter(d => d.daysSinceUpdate != null && d.daysSinceUpdate <= 30).length,
63
+ oldest: oldest ? { path: oldest.path, slug: toSlug(oldest), daysSinceUpdate: oldest.daysSinceUpdate } : null,
64
+ },
65
+ completeness: {
66
+ scoped: scoped.length,
67
+ hasOwner: scoped.filter(d => d.owner).length,
68
+ hasSurface: scoped.filter(d => d.surfaces?.length).length,
69
+ hasModule: scoped.filter(d => d.modules?.length).length,
70
+ hasNextStep: scoped.filter(d => d.hasNextStep).length,
71
+ },
72
+ checklists: {
73
+ docsWithChecklists: withChecklists.length,
74
+ avgCompletion,
75
+ fullyComplete,
76
+ withOpenItems,
77
+ },
78
+ audit: {
79
+ audited: auditedCount,
80
+ ...auditCounts,
81
+ },
82
+ };
83
+ }
84
+
85
+ // ── Text renderer ──────────────────────────────────────────────────────
86
+
87
+ export function renderStats(stats, config) {
88
+ const defaultRenderer = (s) => _renderStats(s, config);
89
+ if (!config._execution?.suppressSideEffects && config.hooks.renderStats) {
90
+ try { return config.hooks.renderStats(stats, defaultRenderer); }
91
+ catch (err) { warn(`Hook 'renderStats' threw: ${err.message}`); }
92
+ }
93
+ return defaultRenderer(stats);
94
+ }
95
+
96
+ function _renderStats(stats, config) {
97
+ const lines = [];
98
+ lines.push(bold(`Stats`) + dim(` — ${stats.totalDocs} docs`));
99
+ lines.push('');
100
+
101
+ // Status. With F6, render one line per type when 2+ types have docs so
102
+ // `plan/partial` and `doc/partial` (semantically distinct under per-type
103
+ // taxonomies) don't collapse into one number. Single-type corpora keep the
104
+ // existing flat line — no needless `Plans:` header on a plans-only repo.
105
+ lines.push(bold('Status'));
106
+ const typesWithDocs = Object.entries(stats.countsByType ?? {})
107
+ .filter(([, statusMap]) => Object.values(statusMap).some(n => n > 0));
108
+ if (typesWithDocs.length > 1) {
109
+ // Type order: respect config.validTypes declaration order; unknown last.
110
+ const typeOrder = [...(config.validTypes ?? [])];
111
+ const sortedTypes = typesWithDocs.sort(([a], [b]) => {
112
+ const ai = typeOrder.indexOf(a);
113
+ const bi = typeOrder.indexOf(b);
114
+ if (ai === -1 && bi === -1) return a.localeCompare(b);
115
+ if (ai === -1) return 1;
116
+ if (bi === -1) return -1;
117
+ return ai - bi;
118
+ });
119
+ const labelFor = (t) => t === 'unknown' ? 'Untyped' : (t.charAt(0).toUpperCase() + t.slice(1) + 's');
120
+ for (const [type, statusMap] of sortedTypes) {
121
+ const statusesForType = [
122
+ ...config.statusOrder.filter(s => statusMap[s]),
123
+ ...Object.keys(statusMap).filter(s => !config.statusOrder.includes(s)).sort(),
124
+ ];
125
+ const parts = statusesForType
126
+ .filter(s => statusMap[s])
127
+ .map(s => `${s}: ${statusMap[s]}`);
128
+ lines.push(` ${dim(labelFor(type) + ':')} ${parts.join(' ')}`);
129
+ }
130
+ } else {
131
+ const allStatuses = [
132
+ ...config.statusOrder.filter(s => stats.countsByStatus[s]),
133
+ ...Object.keys(stats.countsByStatus).filter(s => !config.statusOrder.includes(s)).sort(),
134
+ ];
135
+ const statusParts = allStatuses
136
+ .filter(s => stats.countsByStatus[s])
137
+ .map(s => `${s}: ${stats.countsByStatus[s]}`);
138
+ lines.push(' ' + statusParts.join(' '));
139
+ }
140
+ lines.push('');
141
+
142
+ // Health
143
+ const h = stats.health;
144
+ lines.push(bold('Health'));
145
+ lines.push(` stale: ${h.staleCount}/${h.nonArchivedCount} (${h.stalePct}%)`);
146
+ lines.push(` errors: ${h.errorCount}`);
147
+ lines.push(` warnings: ${h.warningCount}`);
148
+ if (h.brokenRefCount) lines.push(` broken refs: ${h.brokenRefCount}`);
149
+ if (h.brokenLinkCount) lines.push(` broken links: ${h.brokenLinkCount}`);
150
+ lines.push('');
151
+
152
+ // Freshness
153
+ const f = stats.freshness;
154
+ lines.push(bold('Freshness'));
155
+ lines.push(` updated today: ${f.today} this week: ${f.thisWeek} this month: ${f.thisMonth}`);
156
+ if (f.oldest) {
157
+ lines.push(` oldest: ${f.oldest.slug} (${f.oldest.daysSinceUpdate}d)`);
158
+ }
159
+ lines.push('');
160
+
161
+ // Completeness
162
+ const c = stats.completeness;
163
+ if (c.scoped > 0) {
164
+ lines.push(bold('Completeness') + dim(' (active/ready/planned/blocked)'));
165
+ lines.push(` has owner: ${c.hasOwner}/${c.scoped} (${pct(c.hasOwner, c.scoped)}%)`);
166
+ lines.push(` has surface: ${c.hasSurface}/${c.scoped} (${pct(c.hasSurface, c.scoped)}%)`);
167
+ lines.push(` has module: ${c.hasModule}/${c.scoped} (${pct(c.hasModule, c.scoped)}%)`);
168
+ lines.push(` has next_step: ${c.hasNextStep}/${c.scoped} (${pct(c.hasNextStep, c.scoped)}%)`);
169
+ lines.push('');
170
+ }
171
+
172
+ // Checklists
173
+ const cl = stats.checklists;
174
+ if (cl.docsWithChecklists > 0) {
175
+ lines.push(bold('Checklists'));
176
+ lines.push(` docs with checklists: ${cl.docsWithChecklists}`);
177
+ lines.push(` avg completion: ${cl.avgCompletion}%`);
178
+ lines.push(` fully complete: ${cl.fullyComplete} open items: ${cl.withOpenItems}`);
179
+ lines.push('');
180
+ }
181
+
182
+ // Audit
183
+ const a = stats.audit;
184
+ if (c.scoped > 0) {
185
+ lines.push(bold('Audit'));
186
+ lines.push(` audited: ${a.audited}/${c.scoped} (${pct(a.audited, c.scoped)}%)`);
187
+ if (a.audited > 0) {
188
+ lines.push(` pass1: ${a.pass1} pass2: ${a.pass2} deep: ${a.deep}`);
189
+ }
190
+ lines.push('');
191
+ }
192
+
193
+ return `${lines.join('\n').trimEnd()}\n`;
194
+ }
195
+
196
+ // ── JSON renderer ──────────────────────────────────────────────────────
197
+
198
+ export function renderStatsJson(stats) {
199
+ return JSON.stringify(stats, null, 2) + '\n';
200
+ }
@@ -0,0 +1,87 @@
1
+ // Ordered, type-aware view of the effective status configuration. Config loading
2
+ // still resolves compatibility overrides; consumers use this module instead of
3
+ // reconstructing behavior from several global/type-specific fields.
4
+ const CACHE = new WeakMap();
5
+
6
+ export function resolveStatusMetadata(config) {
7
+ const cached = CACHE.get(config);
8
+ if (cached) return cached;
9
+ const byType = {};
10
+ const typeOrder = [...(config.validTypes ?? [])];
11
+
12
+ for (const type of typeOrder) {
13
+ const statuses = [...(config.typeStatuses?.get(type) ?? [])];
14
+ const typeDef = config.raw?.types?.[type] ?? {};
15
+ const context = config.typeContextConfig?.get(type) ?? typeDef.context ?? {};
16
+ const contextByStatus = new Map();
17
+ for (const bucket of ['expanded', 'listed', 'counted']) {
18
+ for (const status of context[bucket] ?? []) {
19
+ if (!contextByStatus.has(status)) contextByStatus.set(status, bucket);
20
+ }
21
+ }
22
+
23
+ byType[type] = statuses.map((name, rank) => {
24
+ const skipStale = config.lifecycle.skipStaleFor.has(name);
25
+ const skipWarnings = config.lifecycle.skipsWarnings(name, type);
26
+ const hasTypeStaleDays = Object.prototype.hasOwnProperty.call(typeDef.staleDays ?? {}, name);
27
+ return {
28
+ name,
29
+ rank,
30
+ context: contextByStatus.get(name) ?? 'counted',
31
+ staleDays: hasTypeStaleDays ? typeDef.staleDays[name] : (config.staleDaysByStatus?.[name] ?? null),
32
+ startable: config.lifecycle.startableStatuses.has(name),
33
+ terminal: config.lifecycle.isTerminal?.(name, type) ?? config.lifecycle.terminalStatuses.has(name),
34
+ archive: config.lifecycle.archiveStatuses.has(name),
35
+ filed: config.lifecycle.filedStatuses.get(name) ?? null,
36
+ skipStale,
37
+ skipWarnings,
38
+ quiet: skipStale && skipWarnings,
39
+ requiresModule: config.moduleRequiredStatuses.has(name),
40
+ };
41
+ });
42
+ }
43
+
44
+ const resolved = { typeOrder, byType };
45
+ CACHE.set(config, resolved);
46
+ return resolved;
47
+ }
48
+
49
+ export function statusMetadataFor(config, type, status) {
50
+ if (!type || !status) return null;
51
+ return resolveStatusMetadata(config).byType[type]?.find(item => item.name === status) ?? null;
52
+ }
53
+
54
+ export function statusesForContext(config, type, bucket) {
55
+ return (resolveStatusMetadata(config).byType[type] ?? [])
56
+ .filter(item => item.context === bucket)
57
+ .map(item => item.name);
58
+ }
59
+
60
+ export function statusHasContext(config, type, status, bucket) {
61
+ return statusMetadataFor(config, type, status)?.context === bucket;
62
+ }
63
+
64
+ export function actionablePromptStatuses(config) {
65
+ const expanded = statusesForContext(config, 'prompt', 'expanded');
66
+ return new Set(expanded.length > 0 ? expanded : ['pending']);
67
+ }
68
+
69
+ export function compareStrings(a, b) {
70
+ return a < b ? -1 : a > b ? 1 : 0;
71
+ }
72
+
73
+ // Oldest actionable prompt first. Missing dates sort last; path is the stable
74
+ // final tie-breaker shared by HUD, agent-context, and no-arg `runlist use`.
75
+ export function comparePromptDocs(a, b) {
76
+ const aCreated = a.created ?? '';
77
+ const bCreated = b.created ?? '';
78
+ if (aCreated && bCreated && aCreated !== bCreated) return compareStrings(aCreated, bCreated);
79
+ if (aCreated && !bCreated) return -1;
80
+ if (!aCreated && bCreated) return 1;
81
+ const aUpdated = a.updated ?? '';
82
+ const bUpdated = b.updated ?? '';
83
+ if (aUpdated && bUpdated && aUpdated !== bUpdated) return compareStrings(aUpdated, bUpdated);
84
+ if (aUpdated && !bUpdated) return -1;
85
+ if (!aUpdated && bUpdated) return 1;
86
+ return compareStrings(a.path ?? '', b.path ?? '');
87
+ }