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
package/src/health.mjs ADDED
@@ -0,0 +1,212 @@
1
+ import path from 'node:path';
2
+ import { buildIndex } from './index.mjs';
3
+ import { bold, dim, green, yellow, red } from './color.mjs';
4
+ import { buildCoordinationIndex, buildRoadmapIndex, isRoadmapHub, hubLabel } from './runlist.mjs';
5
+ import { isArchivedPath } from './util.mjs';
6
+
7
+ export function runHealth(argv, config) {
8
+ const json = argv.includes('--json');
9
+ const index = buildIndex(config);
10
+
11
+ // Only plans (type: plan or untyped docs in plans root)
12
+ const allPlans = index.docs.filter(d => d.type === 'plan' || (!d.type && d.root?.includes('plan')));
13
+ // Coordination hubs (prose-first runlists) are navigation maps, not execution
14
+ // units — they carry no checklist and skew active-plan aging — so lift the
15
+ // LIVE ones out of the pipeline + active set into a dedicated Runlists tally,
16
+ // mirroring `runlist plans` / `runlist runlists`. Archived hubs stay in `plans` so
17
+ // the archived/velocity counts are unchanged. No coordination hubs → `plans`
18
+ // equals the full set and every count below is identical to before.
19
+ const coordination = buildCoordinationIndex(index, config);
20
+ const closedStatuses = new Set([
21
+ ...(config.lifecycle?.archiveStatuses ?? []),
22
+ ...(config.lifecycle?.terminalStatuses ?? []),
23
+ ]);
24
+ const isLiveHub = (d) => coordination.has(d.path) && !closedStatuses.has(d.status) && !isArchivedPath(d.path, config);
25
+ const isLiveRoadmap = (d) => isRoadmapHub(d) && !closedStatuses.has(d.status) && !isArchivedPath(d.path, config);
26
+ const byAgeDesc = (a, b) => (b.daysSinceUpdate ?? -1) - (a.daysSinceUpdate ?? -1);
27
+ // Roadmaps (tier-3) get their own tally above Runlists — held out of the
28
+ // pipeline like coordination hubs, but pointed at via `runlist roadmaps`.
29
+ const roadmapHubs = allPlans.filter(isLiveRoadmap).sort(byAgeDesc);
30
+ const roadmapIndex = roadmapHubs.length > 0 ? buildRoadmapIndex(index, config, { coordination }) : new Map();
31
+ const runlistHubs = allPlans.filter(d => isLiveHub(d) && !isRoadmapHub(d))
32
+ // Most stale first — health is an aging lens, and it matches `runlist runlists`'
33
+ // default. Unknown-age hubs sort last so they never top the list.
34
+ .sort(byAgeDesc);
35
+ const plans = allPlans.filter(d => !isLiveHub(d));
36
+ const now = Date.now();
37
+
38
+ // Status distribution
39
+ const byStatus = {};
40
+ for (const doc of plans) {
41
+ const s = doc.status ?? 'unknown';
42
+ byStatus[s] = (byStatus[s] || 0) + 1;
43
+ }
44
+
45
+ // Active plan aging (days since created)
46
+ const activePlans = plans.filter(d => d.status === 'active');
47
+ const activeAges = activePlans.map(d => {
48
+ const created = d.created ? new Date(d.created).getTime() : null;
49
+ return created ? Math.floor((now - created) / 86400000) : null;
50
+ }).filter(a => a !== null);
51
+
52
+ // Recently archived (last 30 days by updated date)
53
+ const recentlyArchived = plans
54
+ .filter(d => d.status === 'archived' && d.updated)
55
+ .filter(d => {
56
+ const updated = new Date(d.updated).getTime();
57
+ return (now - updated) < 30 * 86400000;
58
+ });
59
+
60
+ // Paused plan aging
61
+ const pausedPlans = plans.filter(d => d.status === 'paused');
62
+ const pausedAges = pausedPlans.map(d => {
63
+ const updated = d.updated ? new Date(d.updated).getTime() : null;
64
+ return updated ? Math.floor((now - updated) / 86400000) : null;
65
+ }).filter(a => a !== null);
66
+
67
+ // Blocked plan count + aging
68
+ const blockedPlans = plans.filter(d => d.status === 'blocked');
69
+
70
+ // Checklist progress on active plans
71
+ const activeWithChecklists = activePlans.filter(d => d.checklist?.total > 0);
72
+ const avgCompletion = activeWithChecklists.length > 0
73
+ ? activeWithChecklists.reduce((sum, d) => sum + (d.checklist.completed / d.checklist.total), 0) / activeWithChecklists.length
74
+ : null;
75
+
76
+ // Plans with deferred items (search body — not available here, use checklist proxy)
77
+ const readyPlans = plans.filter(d => d.status === 'ready');
78
+ const plannedPlans = plans.filter(d => d.status === 'planned');
79
+
80
+ if (json) {
81
+ process.stdout.write(JSON.stringify({
82
+ totalPlans: plans.length,
83
+ byStatus,
84
+ active: {
85
+ count: activePlans.length,
86
+ ages: activeAges,
87
+ avgAge: activeAges.length > 0 ? Math.round(activeAges.reduce((a, b) => a + b, 0) / activeAges.length) : null,
88
+ maxAge: activeAges.length > 0 ? Math.max(...activeAges) : null,
89
+ avgChecklistCompletion: avgCompletion ? Math.round(avgCompletion * 100) : null,
90
+ },
91
+ paused: { count: pausedPlans.length, ages: pausedAges },
92
+ blocked: { count: blockedPlans.length },
93
+ ready: { count: readyPlans.length },
94
+ planned: { count: plannedPlans.length },
95
+ recentlyArchived: { count: recentlyArchived.length, last30d: recentlyArchived.map(d => path.basename(d.path, '.md')) },
96
+ runlists: { count: runlistHubs.length, hubs: runlistHubs.map(d => { const info = coordination.get(d.path); return { path: d.path, title: d.title, status: d.status, childCount: info?.childCount ?? 0, total: info?.total ?? 0, doneCount: info?.doneCount ?? 0, parkedCount: info?.parkedCount ?? 0, nextPickup: info?.nextPickup ?? null }; }) },
97
+ roadmaps: { count: roadmapHubs.length, hubs: roadmapHubs.map(d => { const info = roadmapIndex.get(d.path); return { path: d.path, title: d.title, status: d.status, childCount: info?.childCount ?? 0, grandTotal: info?.grandTotal ?? 0, grandDone: info?.grandDone ?? 0, grandParked: info?.grandParked ?? 0 }; }) },
98
+ }, null, 2) + '\n');
99
+ return;
100
+ }
101
+
102
+ process.stdout.write(bold('Plan Health') + '\n\n');
103
+
104
+ // Pipeline — ordered by the configured status vocab, then any present-but-
105
+ // unconfigured statuses (custom ones a repo defines, by count). Deriving from
106
+ // the live status set means in-session/partial/awaiting/etc. all show, and a
107
+ // dead status never leaves an empty row — unlike the old hand-kept list that
108
+ // drifted out of sync with the vocabulary.
109
+ process.stdout.write(bold('Pipeline:') + '\n');
110
+ const statusOrder = config.statusOrder ?? [];
111
+ const present = Object.keys(byStatus).filter(s => byStatus[s] > 0);
112
+ const ordered = [
113
+ ...statusOrder.filter(s => present.includes(s)),
114
+ ...present.filter(s => !statusOrder.includes(s)).sort((a, b) => byStatus[b] - byStatus[a]),
115
+ ];
116
+ const pad = Math.max(10, ...ordered.map(s => s.length));
117
+ for (const s of ordered) {
118
+ const count = byStatus[s];
119
+ const bar = '█'.repeat(Math.min(count, 40));
120
+ process.stdout.write(` ${s.padEnd(pad)} ${String(count).padStart(4)} ${dim(bar)}\n`);
121
+ }
122
+ process.stdout.write('\n');
123
+
124
+ // Roadmaps (tier-3) — pinned above Runlists with the recursive grand total.
125
+ if (roadmapHubs.length > 0) {
126
+ process.stdout.write(`${bold('Roadmaps:')} ${roadmapHubs.length} ${dim('· runlist roadmap')}\n`);
127
+ for (const doc of roadmapHubs.slice(0, 8)) {
128
+ const slug = hubLabel(doc).padEnd(28);
129
+ const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '?d';
130
+ const info = roadmapIndex.get(doc.path);
131
+ const rollStr = info ? ` ${dim(`${info.grandDone}/${info.grandTotal}`)}` : '';
132
+ const kidStr = info ? ` ${dim(`${info.childCount} runlists`)}` : '';
133
+ process.stdout.write(` ${slug} ${dim(age.padStart(4))}${rollStr}${kidStr}\n`);
134
+ }
135
+ if (roadmapHubs.length > 8) {
136
+ process.stdout.write(` ${dim(`...and ${roadmapHubs.length - 8} more`)}\n`);
137
+ }
138
+ process.stdout.write('\n');
139
+ }
140
+
141
+ // Runlists (coordination hubs) — held out of the leaf-plan pipeline above and
142
+ // surfaced as their own tally so they don't inflate the active count. Newest
143
+ // first, mirroring `runlist runlists`; capped with a "more" footer.
144
+ if (runlistHubs.length > 0) {
145
+ process.stdout.write(`${bold('Runlists:')} ${runlistHubs.length} ${dim('· runlist runlists')}\n`);
146
+ for (const doc of runlistHubs.slice(0, 8)) {
147
+ const slug = hubLabel(doc).padEnd(28);
148
+ const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '?d';
149
+ const info = coordination.get(doc.path);
150
+ const relStr = info?.total ? ` ${dim(`${info.doneCount}/${info.total}`)}` : '';
151
+ const nextStr = info?.nextPickup ? ` ${green('→')} ${info.nextPickup.label}` : '';
152
+ process.stdout.write(` ${slug} ${dim(age.padStart(4))}${relStr}${nextStr}\n`);
153
+ }
154
+ if (runlistHubs.length > 8) {
155
+ process.stdout.write(` ${dim(`...and ${runlistHubs.length - 8} more`)}\n`);
156
+ }
157
+ process.stdout.write('\n');
158
+ }
159
+
160
+ // Active plan health
161
+ if (activePlans.length > 0) {
162
+ process.stdout.write(bold('Active plans:') + '\n');
163
+ const avgAge = activeAges.length > 0 ? Math.round(activeAges.reduce((a, b) => a + b, 0) / activeAges.length) : 0;
164
+ const maxAge = activeAges.length > 0 ? Math.max(...activeAges) : 0;
165
+ process.stdout.write(` Count: ${activePlans.length} Avg age: ${avgAge}d Max age: ${maxAge}d\n`);
166
+ if (avgCompletion !== null) {
167
+ process.stdout.write(` Avg checklist: ${Math.round(avgCompletion * 100)}%\n`);
168
+ }
169
+ for (const doc of activePlans) {
170
+ const age = doc.created ? Math.floor((now - new Date(doc.created).getTime()) / 86400000) : '?';
171
+ const slug = path.basename(doc.path, '.md').padEnd(28);
172
+ const pct = doc.checklist?.total > 0 ? `${Math.round(doc.checklist.completed / doc.checklist.total * 100)}%` : '-';
173
+ const ageColor = age > 30 ? red(age + 'd') : age > 14 ? yellow(age + 'd') : dim(age + 'd');
174
+ process.stdout.write(` ${slug} ${ageColor} checklist: ${pct}\n`);
175
+ }
176
+ process.stdout.write('\n');
177
+ }
178
+
179
+ // Paused
180
+ if (pausedPlans.length > 0) {
181
+ process.stdout.write(bold('Paused:') + '\n');
182
+ for (const doc of pausedPlans) {
183
+ const slug = path.basename(doc.path, '.md').padEnd(28);
184
+ const age = doc.updated ? Math.floor((now - new Date(doc.updated).getTime()) / 86400000) + 'd paused' : '';
185
+ process.stdout.write(` ${slug} ${dim(age)}\n`);
186
+ }
187
+ process.stdout.write('\n');
188
+ }
189
+
190
+ // Velocity
191
+ process.stdout.write(bold('Velocity (last 30d):') + '\n');
192
+ process.stdout.write(` Archived: ${green(String(recentlyArchived.length))} plans\n`);
193
+ if (recentlyArchived.length > 0) {
194
+ for (const doc of recentlyArchived.slice(0, 8)) {
195
+ process.stdout.write(` ${dim(path.basename(doc.path, '.md'))}\n`);
196
+ }
197
+ if (recentlyArchived.length > 8) {
198
+ process.stdout.write(` ${dim(`...and ${recentlyArchived.length - 8} more`)}\n`);
199
+ }
200
+ }
201
+ process.stdout.write('\n');
202
+
203
+ // Blocked summary
204
+ if (blockedPlans.length > 0) {
205
+ process.stdout.write(`${bold('Blocked:')} ${blockedPlans.length} plans\n\n`);
206
+ }
207
+
208
+ // Ready to promote
209
+ if (readyPlans.length > 0) {
210
+ process.stdout.write(`${bold('Ready to promote:')} ${readyPlans.length} plans\n\n`);
211
+ }
212
+ }
package/src/hints.mjs ADDED
@@ -0,0 +1,129 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readJournalEntries, isJournalEnabled, journalFilePath } from './journal.mjs';
3
+ import { currentSessionId } from './util.mjs';
4
+ import { readEnv } from './naming.mjs';
5
+
6
+ // F17c: repeat-failure hints. When an agent runs the same broken invocation
7
+ // twice in the same session within HINT_WINDOW_MS, the second die() output is
8
+ // suffixed with a Tip: paragraph informed by the prior failure's recorded
9
+ // stderr. First failures stay terse — don't punish humans typing a command
10
+ // for the first time. The lookup is skipped cleanly when the journal is
11
+ // disabled or RUNLIST_NO_HINTS=1, so this costs nothing for non-opt-in users.
12
+
13
+ const HINT_WINDOW_MS = 10 * 60 * 1000;
14
+ const OVERLAP_THRESHOLD = 0.75;
15
+
16
+ const TEMPLATES = [
17
+ {
18
+ match: /Too many arguments|Usage:/i,
19
+ hint: ({ count, argv }) =>
20
+ `${count}× the same shape on \`${argv[0]} ${argv[1] ?? ''}\` in this session. Run \`runlist ${argv[0]} --help\` for the expected positional args.`,
21
+ },
22
+ {
23
+ match: /Already (consumed|archived)/i,
24
+ hint: ({ count, argv }) =>
25
+ `${count}× attempts to use a path that is already archived. Use \`runlist prompts list\` to see what is actually pending, or \`runlist next\` for the oldest live prompt.`,
26
+ },
27
+ {
28
+ match: /No pending prompts/i,
29
+ hint: ({ count }) =>
30
+ `${count}× \`runlist next\` with no pending prompts in the queue. Either queue one with \`runlist new prompt <slug> "..."\` or pass an explicit prompt file to \`runlist use\`.`,
31
+ },
32
+ {
33
+ match: /Unknown command/i,
34
+ hint: ({ count }) =>
35
+ `${count}× the same unknown command. Run \`runlist --help\` to list available commands; the dispatch already prints a did-you-mean for close misses.`,
36
+ },
37
+ {
38
+ match: /File not found|does not resolve/i,
39
+ hint: ({ count, argv }) =>
40
+ `${count}× pointing at a path that doesn't exist. Confirm the file with \`runlist query\` or \`runlist plans\` — paths resolve relative to repo root or doc roots, not the cwd.`,
41
+ },
42
+ {
43
+ match: /Unknown status|Unknown surface/i,
44
+ hint: ({ count }) =>
45
+ `${count}× rejected by the taxonomy validator. Run \`runlist statuses list\` or \`runlist surfaces\` to print the valid values for this project.`,
46
+ },
47
+ ];
48
+
49
+ // Global value-consuming flags must be skipped together with the token that
50
+ // follows them — otherwise `--config /tmp/foo` injects the tempdir path as
51
+ // "positional" and dilutes Jaccard overlap below threshold. Keep this list
52
+ // aligned with the global flag-strip list in bin/runlist.mjs (SCRUB_*).
53
+ const VALUE_FLAGS = new Set([
54
+ '--config', '--root', '--type', '--limit', '--sort', '--group',
55
+ '--status', '--owner', '--module', '--surface', '--domain',
56
+ '--audience', '--execution-mode', '--updated-since',
57
+ '--summarize-limit', '--model',
58
+ ]);
59
+
60
+ function nonFlagSet(argv) {
61
+ const out = new Set();
62
+ for (let i = 0; i < argv.length; i++) {
63
+ const a = argv[i];
64
+ if (typeof a !== 'string') continue;
65
+ if (a.startsWith('-')) {
66
+ if (VALUE_FLAGS.has(a)) i++;
67
+ continue;
68
+ }
69
+ out.add(a);
70
+ }
71
+ return out;
72
+ }
73
+
74
+ function jaccard(a, b) {
75
+ if (a.size === 0 && b.size === 0) return 1;
76
+ const aArr = [...a];
77
+ const inter = aArr.filter(x => b.has(x)).length;
78
+ const union = new Set([...a, ...b]).size;
79
+ return union === 0 ? 0 : inter / union;
80
+ }
81
+
82
+ // Returns a hint string (no `Tip:` prefix) or null. The caller is responsible
83
+ // for formatting. Failures inside this function are swallowed — a malformed
84
+ // journal must never break the error-reporting path.
85
+ export function findRepeatFailureHint(failingArgv, config) {
86
+ try {
87
+ if (readEnv('NO_HINTS') === '1') return null;
88
+ if (!config) return null;
89
+ if (!isJournalEnabled(config)) return null;
90
+ if (!existsSync(journalFilePath(config))) return null;
91
+ if (!Array.isArray(failingArgv) || failingArgv.length === 0) return null;
92
+
93
+ const sid = currentSessionId();
94
+ const now = Date.now();
95
+ const cutoff = now - HINT_WINDOW_MS;
96
+ const failingShape = nonFlagSet(failingArgv);
97
+
98
+ const entries = readJournalEntries(config);
99
+ const matches = [];
100
+ for (const entry of entries) {
101
+ if (!entry || entry.sid !== sid) continue;
102
+ if (!Array.isArray(entry.argv) || entry.argv.length === 0) continue;
103
+ if (entry.argv[0] !== failingArgv[0]) continue;
104
+ if ((entry.exit ?? 0) === 0) continue;
105
+ const ts = new Date(entry.ts).getTime();
106
+ if (Number.isNaN(ts) || ts < cutoff) continue;
107
+ const priorShape = nonFlagSet(entry.argv);
108
+ if (jaccard(failingShape, priorShape) < OVERLAP_THRESHOLD) continue;
109
+ matches.push({ entry, ts });
110
+ }
111
+
112
+ if (matches.length === 0) return null;
113
+ matches.sort((a, b) => b.ts - a.ts);
114
+ const last = matches[0].entry;
115
+ const count = matches.length + 1;
116
+ const prevErr = last.err ?? '';
117
+ const ageMin = Math.max(1, Math.round((now - matches[0].ts) / 60000));
118
+
119
+ for (const tmpl of TEMPLATES) {
120
+ if (tmpl.match.test(prevErr)) {
121
+ return tmpl.hint({ count, argv: failingArgv, prev: last, ageMin });
122
+ }
123
+ }
124
+
125
+ return `${count}× the same failing shape on \`${failingArgv[0]}\` in this session (last attempt ${ageMin}m ago). Check the args — \`runlist ${failingArgv[0]} --help\` shows what's expected.`;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }