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,225 @@
1
+ // Pure markdown section walker. Regex-walks H1-H6 headings respecting fenced
2
+ // code blocks (``` and ~~~). Returns flat list of sections with body content
3
+ // and body-relative line numbers (1-indexed). Callers with stripped frontmatter
4
+ // add extractFrontmatter().bodyLineOffset before exposing file coordinates.
5
+
6
+ export function walkSections(body) {
7
+ const lines = body.split('\n');
8
+ const fenceRe = /^(`{3,}|~{3,})/;
9
+ const headingRe = /^(#{1,6})\s+(.+?)\s*$/;
10
+ const sections = [];
11
+ let fenceChar = null;
12
+ let h2Ancestor = null;
13
+
14
+ for (let i = 0; i < lines.length; i++) {
15
+ const line = lines[i];
16
+ const fence = line.match(fenceRe);
17
+ if (fence) {
18
+ const tok = fence[1][0]; // ` or ~
19
+ if (fenceChar === null) fenceChar = tok;
20
+ else if (fenceChar === tok) fenceChar = null;
21
+ continue;
22
+ }
23
+ if (fenceChar !== null) continue;
24
+ const h = line.match(headingRe);
25
+ if (!h) continue;
26
+ const level = h[1].length;
27
+ const heading = h[2];
28
+ if (level === 1) h2Ancestor = null;
29
+ sections.push({
30
+ level,
31
+ heading,
32
+ h2Ancestor: level > 2 ? h2Ancestor : null,
33
+ lineStart: i + 1, // 1-indexed
34
+ lineEnd: lines.length, // patched below
35
+ bodyLineStart: i + 2,
36
+ });
37
+ if (level === 2) h2Ancestor = heading;
38
+ }
39
+
40
+ for (let i = 0; i < sections.length; i++) {
41
+ const next = sections.find((s, j) => j > i && s.level <= sections[i].level);
42
+ sections[i].lineEnd = next ? next.lineStart - 1 : lines.length;
43
+ }
44
+
45
+ for (const s of sections) {
46
+ s.body = lines.slice(s.bodyLineStart - 1, s.lineEnd).join('\n').trim();
47
+ }
48
+
49
+ return sections;
50
+ }
51
+
52
+ // Find a section by heading text, case-insensitive, trims trailing markers.
53
+ // Returns the matching section or null.
54
+ export function findSection(sections, name) {
55
+ const norm = (s) => s.toLowerCase().replace(/[^\w\s]+$/, '').trim();
56
+ const target = norm(name);
57
+ return sections.find(s => norm(s.heading) === target) ?? null;
58
+ }
59
+
60
+ // Status marker detection for phase headings. Returns one of:
61
+ // 'shipped' | 'skipped' | 'in-progress' | 'blocked' | 'todo' | null
62
+ // Glyphs are checked BEFORE prose, not interleaved with it. A glyph is a
63
+ // deliberate mark; a prose word is often about something else in the sentence:
64
+ //
65
+ // ⬜ Phase 4: Retry budget rework (scoping COMPLETE) ← the SCOPING completed
66
+ // Phase 2 — schema migrated ⬜ todo (column rename half DONE) ← half a RENAME is done
67
+ // Phase 3 (original scope) — ⏭ superseded by the shipped Phase 3 ← a DIFFERENT phase shipped
68
+ //
69
+ // Interleaved, first-match-wins order called all three shipped, so
70
+ // findActivePhase skipped a phase the author had explicitly marked unstarted.
71
+ // Within each tier the order is still priority order, which decides a heading
72
+ // carrying more than one mark.
73
+ const GLYPH_PATTERNS = [
74
+ { kind: 'shipped', re: /(✅|☑|✔|✓)/ },
75
+ { kind: 'skipped', re: /(⏭)/ },
76
+ { kind: 'in-progress', re: /(🟡|🔄)/ },
77
+ { kind: 'blocked', re: /(🚧|🔴)/ },
78
+ { kind: 'todo', re: /(⬜|⬛|◻|☐)/ },
79
+ ];
80
+
81
+ const PROSE_PATTERNS = [
82
+ // A qualifier inverts the word it modifies, and these run BEFORE the plain
83
+ // `shipped` pattern so the qualifier wins. Otherwise `mostly done` and
84
+ // `(partially complete)` both read as shipped, and findActivePhase skips a
85
+ // phase whose own checklist still has open boxes.
86
+ { kind: 'todo', re: /\b(?:not|never)[\s-]+(?:complete(?:d)?|done|shipped|started)\b/i },
87
+ { kind: 'in-progress', re: /\b(?:partially|partly|mostly|nearly|almost|half)[\s-]*(?:complete(?:d)?|done|shipped)\b/i },
88
+ { kind: 'shipped', re: /(\bshipped\b|\bdone\b|\bcomplete\b)/i },
89
+ { kind: 'skipped', re: /(\bskip(?:ped)?\b)/i },
90
+ { kind: 'in-progress', re: /(\bin[-_ ]?(?:progress|flight)\b|\bwip\b)/i },
91
+ { kind: 'blocked', re: /(\bblocked\b)/i },
92
+ { kind: 'todo', re: /(\btodo\b|\bnot[-_ ]?started\b)/i },
93
+ ];
94
+
95
+ export function detectMarker(heading) {
96
+ for (const { kind, re } of GLYPH_PATTERNS) {
97
+ if (re.test(heading)) return kind;
98
+ }
99
+ for (const { kind, re } of PROSE_PATTERNS) {
100
+ if (re.test(heading)) return kind;
101
+ }
102
+ return null;
103
+ }
104
+
105
+ // Leading decoration a phase heading may carry before the word "Phase":
106
+ // emphasis punctuation and any run of status markers. `detectMarker` already
107
+ // reads a marker wherever it sits, but this test was anchored at `^phase`, so
108
+ // a plan that writes `### ⬜ Phase 2 — …` had NO phases at all — not a
109
+ // miscount, an empty phase set, which drops the pickup card to its
110
+ // "no ## Phases section" fallback.
111
+ // Several of these glyphs are commonly typed in emoji-presentation form
112
+ // (the base codepoint plus a variation selector): the skip mark, the ballot
113
+ // box, the check mark. `detectMarker` is a substring test so it never
114
+ // noticed. This is a character CLASS, and the selector sits between the glyph
115
+ // and the space, is not \s, and ends the run — so a heading led by the
116
+ // emoji-presentation form was not a phase heading at all, while the bare
117
+ // codepoint was. Reported by the owner, 2026-08-16.
118
+ const PHASE_DECORATION = String.raw`[\s>*_~\`#-]*(?:[✅🚧⬜🟡⏭☑✔✓◻☐⬛🔴🔄][︎️]?\s*)*`;
119
+ const PHASE_LEAD = new RegExp(`^${PHASE_DECORATION}phase\\b`, 'i');
120
+ const FILES_MANIFEST_ANCESTOR = new RegExp(`^${PHASE_DECORATION}files\\b`, 'i');
121
+
122
+ // "Phase 3 outcome" is commentary ABOUT a phase, not a phase. Counting it
123
+ // inflates the phase set, and because `findActivePhase` ranks blocked above
124
+ // todo, a heading like "Phase 3 smoke findings — BLOCKER" gets picked as the
125
+ // plan's active phase over a real unstarted one.
126
+ //
127
+ // The noun list is deliberately tight. Wrongly excluding a real phase hides
128
+ // work; wrongly including commentary only miscounts — so this errs toward
129
+ // counting, and a word is added here only once the corpus shows it standing
130
+ // for a retrospective rather than a phase.
131
+ const PHASE_COMMENTARY = new RegExp(
132
+ `^${PHASE_DECORATION}phase\\s+\\S+\\s+(outcome|progress|notes?|findings?|smoke|retro|review|recap|summary)\\b`,
133
+ 'i',
134
+ );
135
+
136
+ // Compound commentary shapes covered by regression fixtures. Keep
137
+ // these anchored immediately after the phase identifier: the individual nouns
138
+ // all collide with genuine work. In particular, do not add bare `status`,
139
+ // `audit`, `design`, `shape`, `plan`, `pre-plan`, or `gap-check`; the corpus has
140
+ // executable phases with each of those names. The dated gap-check form below is
141
+ // a status report, while `Phase N — gap-check fixes` remains a real phase.
142
+ const PHASE_COMPOUND_COMMENTARY = new RegExp(
143
+ `^${PHASE_DECORATION}phase\\s+\\S+\\s+(?:` +
144
+ `execution\\s+status|` +
145
+ `status\\s+snapshot|` +
146
+ `gap[- ]check\\s+\\d{4}-\\d{2}-\\d{2}|` +
147
+ `audit\\s+results?|` +
148
+ `inventory\\s+findings?|` +
149
+ `deviations\\s*(?:\\+|&|and)\\s*(?:open\\s+questions|notes?)|` +
150
+ `design\\s+[-—:]\\s*researched|` +
151
+ `shape\\s+[-—:]\\s*worked\\s+out|` +
152
+ `sub[- ]phases` +
153
+ `)\\b`,
154
+ 'i',
155
+ );
156
+
157
+ export function isPhaseHeading(section) {
158
+ if (section.level !== 3) return false;
159
+ if (FILES_MANIFEST_ANCESTOR.test(section.h2Ancestor ?? '')) return false;
160
+ return PHASE_LEAD.test(section.heading)
161
+ && !PHASE_COMMENTARY.test(section.heading)
162
+ && !PHASE_COMPOUND_COMMENTARY.test(section.heading);
163
+ }
164
+
165
+ // A phase's OWN checklist — the boxes directly under its heading, stopping at
166
+ // the next heading of any level so a sub-section's checklist is never counted
167
+ // as the parent phase's evidence.
168
+ export function phaseTally(section) {
169
+ let checked = 0, unchecked = 0;
170
+ for (const line of String(section?.body ?? '').split('\n')) {
171
+ if (/^#{1,6}\s/.test(line)) break;
172
+ if (/^\s*[-*]\s*\[x\]/i.test(line)) checked++;
173
+ else if (/^\s*[-*]\s*\[ \]/.test(line)) unchecked++;
174
+ }
175
+ return { checked, unchecked, total: checked + unchecked };
176
+ }
177
+
178
+ /**
179
+ * A phase whose declared marker its own checklist contradicts.
180
+ *
181
+ * Only two disagreements are reported, because only two are unambiguous:
182
+ *
183
+ * shipped + an open box — the plan's own checklist says otherwise
184
+ * todo + every box checked — the work is done, the marker says unstarted
185
+ *
186
+ * `blocked` and `skipped` are judgements a tally cannot refute (blocked at 0/7
187
+ * is perfectly coherent), and `in-progress` with everything checked usually
188
+ * means work the checklist does not enumerate. These ambiguous cases are
189
+ * excluded because checklist counts alone cannot establish a conflict.
190
+ *
191
+ * Returns null when there is no conflict, no marker, or no checklist.
192
+ */
193
+ export function phaseMarkerConflict(section) {
194
+ const declared = detectMarker(section?.heading ?? '');
195
+ if (declared !== 'shipped' && declared !== 'todo') return null;
196
+ const tally = phaseTally(section);
197
+ if (tally.total === 0) return null;
198
+ if (declared === 'shipped' && tally.unchecked > 0) return { declared, implied: 'in progress', ...tally };
199
+ if (declared === 'todo' && tally.unchecked === 0) return { declared, implied: 'shipped', ...tally };
200
+ return null;
201
+ }
202
+
203
+ // Summarize a phase set: { 'shipped': 2, 'in-progress': 1, 'todo': 2 }
204
+ export function summarizePhases(sections) {
205
+ const phases = sections.filter(isPhaseHeading);
206
+ const counts = {};
207
+ for (const p of phases) {
208
+ const k = detectMarker(p.heading) ?? 'todo';
209
+ counts[k] = (counts[k] ?? 0) + 1;
210
+ }
211
+ return { total: phases.length, counts, phases };
212
+ }
213
+
214
+ // Active phase = first phase whose marker is NOT shipped/skipped.
215
+ // Priority within active: in-progress > blocked > todo.
216
+ export function findActivePhase(sections) {
217
+ const phases = sections.filter(isPhaseHeading);
218
+ const active = phases.filter(p => {
219
+ const m = detectMarker(p.heading);
220
+ return m !== 'shipped' && m !== 'skipped';
221
+ });
222
+ if (active.length === 0) return null;
223
+ const rank = (m) => ({ 'in-progress': 0, 'blocked': 1, 'todo': 2, [null]: 3 })[m] ?? 3;
224
+ return active.sort((a, b) => rank(detectMarker(a.heading)) - rank(detectMarker(b.heading)))[0];
225
+ }
@@ -0,0 +1,58 @@
1
+ import {spawn} from 'node:child_process';
2
+ import {StringDecoder} from 'node:string_decoder';
3
+ import path from 'node:path';
4
+
5
+ export class SemanticSearchError extends Error {
6
+ constructor(code){super(code);this.code=code;}
7
+ }
8
+ export function semanticEnvironment(nodePath,base=process.env){
9
+ const env={};for(const key of ['HOME','USERPROFILE','APPDATA','LOCALAPPDATA','SystemRoot','WINDIR','TEMP','TMP','TMPDIR','LANG','LC_ALL','XDG_CONFIG_HOME','XDG_DATA_HOME','XDG_CACHE_HOME'])if(base[key])env[key]=base[key];
10
+ env.PATH=path.dirname(nodePath);env.GMAX_NO_AUTOSTART='1';env.GMAX_NO_STALE_HINT='1';return env;
11
+ }
12
+ // Small stdio MCP client: no SDK, gmax library, index or model dependency in Runlist.
13
+ export async function withSemanticClient(installation,checkout,signal,work,{spawnChild=spawn}={}){
14
+ signal.throwIfAborted();
15
+ const child=spawnChild(installation.nodePath,[installation.entryPath,'mcp','--existing-index-only'],{cwd:checkout,env:semanticEnvironment(installation.nodePath),stdio:['pipe','pipe','pipe'],windowsHide:true,shell:false});
16
+ const pending=new Map(),decoder=new StringDecoder('utf8');let nextId=1,carry='',frameBytes=0,ended=false,outputBytes=0;
17
+ const fail=code=>{if(ended)return;ended=true;for(const entry of pending.values())entry.reject(new SemanticSearchError(code));pending.clear();child.kill();};
18
+ child.on('error',()=>fail('tool_unavailable'));child.on('exit',()=>fail('tool_unavailable'));
19
+ child.stdin.on('error',()=>fail('tool_unavailable'));
20
+ // Drain diagnostics without retaining paths, queries, snippets or stderr in UI/logs.
21
+ child.stderr.on('data',()=>{});
22
+ child.stdout.on('data',chunk=>{
23
+ outputBytes+=chunk.length;frameBytes+=chunk.length;
24
+ if(outputBytes>4*1024*1024||frameBytes>1024*1024){fail('response_too_large');return;}
25
+ carry+=decoder.write(chunk);
26
+ for(;;){const nl=carry.indexOf('\n');if(nl<0)break;const line=carry.slice(0,nl);carry=carry.slice(nl+1);frameBytes=Buffer.byteLength(carry);let message;
27
+ try{message=JSON.parse(line);}catch{fail('invalid_response');return;}
28
+ if(!message||message.jsonrpc!=='2.0'){fail('invalid_response');return;}
29
+ if(message.id!==undefined){const entry=pending.get(message.id);if(!entry){fail('invalid_response');return;}pending.delete(message.id);if(message.error)entry.reject(new SemanticSearchError('unsupported_tool'));else entry.resolve(message.result);}
30
+ else if(message.method&&!message.method.startsWith('notifications/')){fail('invalid_response');return;}
31
+ }
32
+ });
33
+ function call(method,params){if(ended||signal.aborted)return Promise.reject(new SemanticSearchError('cancelled'));const id=nextId++,wire=JSON.stringify({jsonrpc:'2.0',id,method,params})+'\n';if(Buffer.byteLength(wire)>1024*1024)return Promise.reject(new SemanticSearchError('response_too_large'));return new Promise((resolve,reject)=>{pending.set(id,{resolve,reject});child.stdin.write(wire);});}
34
+ const abort=()=>{for(const id of pending.keys())if(child.stdin.writable)child.stdin.write(JSON.stringify({jsonrpc:'2.0',method:'notifications/cancelled',params:{requestId:id,reason:'Cancelled'}})+'\n');fail('cancelled');};
35
+ signal.addEventListener('abort',abort,{once:true});
36
+ if(signal.aborted)abort();
37
+ try{
38
+ const hello=await call('initialize',{protocolVersion:'2025-06-18',capabilities:{},clientInfo:{name:'Runlist',version:'1'}});
39
+ if(hello?.protocolVersion!=='2025-06-18'||hello.serverInfo?.name!=='gmax-document-search')throw new SemanticSearchError('unsupported_tool');
40
+ child.stdin.write(JSON.stringify({jsonrpc:'2.0',method:'notifications/initialized'})+'\n');
41
+ const tool=async(name,args)=>{const reply=await call('tools/call',{name,arguments:args});const data=reply?.structuredContent;
42
+ if(reply?.isError||!data||data.contractVersion!==1||data.capabilities?.existingIndexOnly!==1||data.capabilities.queryLogging!==false||data.capabilities.watch!==false||data.capabilities.runtimeStartup!==false)throw new SemanticSearchError('unsupported_tool');
43
+ if(data.state==='ready'&&(!Number.isSafeInteger(data.generation)||data.generation<1))throw new SemanticSearchError('embedding_mismatch');
44
+ return data;
45
+ };
46
+ return await work({tool});
47
+ }finally{
48
+ signal.removeEventListener('abort',abort);ended=true;for(const entry of pending.values())entry.reject(new SemanticSearchError('cancelled'));pending.clear();
49
+ child.stdin.end();child.kill();
50
+ // Reap only this owned bridge. The shared daemon/embedding runtime is untouched.
51
+ if(child.exitCode===null&&child.signalCode===null)await new Promise(resolve=>{
52
+ const kill=setTimeout(()=>child.kill('SIGKILL'),500),deadline=setTimeout(done,1000);
53
+ function done(){clearTimeout(kill);clearTimeout(deadline);child.removeListener('exit',done);resolve();}
54
+ child.once('exit',done);
55
+ if(child.exitCode!==null||child.signalCode!==null)done();
56
+ });
57
+ }
58
+ }
package/src/ship.mjs ADDED
@@ -0,0 +1,166 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import { die, warn, toRepoPath } from './util.mjs';
5
+ import { assertGitIndex } from './git.mjs';
6
+ import { green, dim, yellow } from './color.mjs';
7
+
8
+ // Files runlist ship will auto-stage when they're dirty. Anything outside this
9
+ // allowlist stays in the working tree — user has to `git add` it explicitly,
10
+ // so secrets / .env / sibling-session WIP never get bundled into a release.
11
+ const ALLOWLIST_PATTERNS = [
12
+ /^src\//,
13
+ /^test\//,
14
+ /^bin\//,
15
+ /^docs\//,
16
+ // Plugin artifacts ship in lockstep with the CLI (the plugin-based workflow
17
+ // is canonical), so a dirty SKILL.md / command / hook / manifest is a release
18
+ // change. `.claude/commands/` stays for repos that still hand-author slash
19
+ // commands — harmless, and dropping it would un-stage their edits.
20
+ /^plugins\//,
21
+ /^\.claude-plugin\//,
22
+ /^\.claude\/commands\//,
23
+ /^runlist\.config\.example\.mjs$/,
24
+ /^runlist\.config\.mjs$/,
25
+ /^dotmd\.config\.example\.mjs$/,
26
+ /^dotmd\.config\.mjs$/,
27
+ /^package(?:-lock)?\.json$/,
28
+ /^README\.md$/,
29
+ /^CLAUDE\.md$/,
30
+ /^CHANGELOG\.md$/,
31
+ /^\.gitignore$/,
32
+ ];
33
+
34
+ export function bumpVersion(current, bump) {
35
+ const parts = current.split('.').map(Number);
36
+ if (parts.length !== 3 || parts.some(Number.isNaN)) {
37
+ die(`Cannot parse current version: ${current}`);
38
+ }
39
+ const [maj, min, pat] = parts;
40
+ if (bump === 'major') return `${maj + 1}.0.0`;
41
+ if (bump === 'minor') return `${maj}.${min + 1}.0`;
42
+ if (bump === 'patch') return `${maj}.${min}.${pat + 1}`;
43
+ die(`Invalid bump: ${bump}. Use patch | minor | major.`);
44
+ }
45
+
46
+ export function isAllowed(repoPath) {
47
+ return ALLOWLIST_PATTERNS.some(re => re.test(repoPath));
48
+ }
49
+
50
+ export function listDirtyFiles(repoRoot) {
51
+ const result = spawnSync('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all'], {
52
+ cwd: repoRoot,
53
+ encoding: 'utf8',
54
+ });
55
+ if (result.status !== 0) die(`git status failed: ${result.stderr}`);
56
+ const fields = result.stdout.split('\0');
57
+ const files = [];
58
+ for (let i = 0; i < fields.length; i++) {
59
+ const record = fields[i];
60
+ if (!record) continue;
61
+ const status = record.slice(0, 2);
62
+ const destination = record.slice(3);
63
+ files.push({ status, path: destination });
64
+ if (status.includes('R')) {
65
+ const source = fields[++i];
66
+ if (source) files.push({ status, path: source });
67
+ } else if (status.includes('C')) {
68
+ i++; // Porcelain -z includes the unchanged copy source as a second field.
69
+ }
70
+ }
71
+ return files;
72
+ }
73
+
74
+ export async function runShip(argv, config, opts = {}) {
75
+ const { dryRun } = opts;
76
+ const positional = argv.filter(a => !a.startsWith('-'));
77
+ const bump = positional[0] ?? 'patch';
78
+ if (!['patch', 'minor', 'major'].includes(bump)) {
79
+ die(`Invalid bump: ${bump}. Usage: runlist ship [patch|minor|major]`);
80
+ }
81
+
82
+ const pkgPath = path.join(config.repoRoot, 'package.json');
83
+ if (!existsSync(pkgPath)) die(`No package.json at ${toRepoPath(pkgPath, config.repoRoot)}`);
84
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
85
+ const current = pkg.version;
86
+ const target = bumpVersion(current, bump);
87
+
88
+ process.stdout.write(`${green('→')} Shipping ${current} → ${target} (${bump})\n`);
89
+
90
+ try {
91
+ assertGitIndex(config.repoRoot);
92
+ } catch (err) {
93
+ die(`Refusing to ship with inherited staged files. ${err.message}`);
94
+ }
95
+
96
+ // Per-repo slash-command scaffolding is retired (the runlist plugin's SKILL.md
97
+ // is canonical now), so there is nothing to regenerate at ship time. Any
98
+ // stale generated files are swept by `runlist hud` / `runlist doctor`.
99
+
100
+ // Identify dirty tracked files. Anything matching the allowlist gets
101
+ // staged; everything else is left dirty so the user can handle it.
102
+ const dirty = listDirtyFiles(config.repoRoot);
103
+ const untracked = dirty.filter(d => d.status === '??');
104
+ const tracked = dirty.filter(d => d.status !== '??');
105
+
106
+ const toStage = tracked.filter(d => isAllowed(d.path)).map(d => d.path);
107
+ const skipped = tracked.filter(d => !isAllowed(d.path)).map(d => d.path);
108
+
109
+ // Untracked files matching the allowlist (e.g. a fresh new plan) are also
110
+ // safe to add — that's the common case of "scaffolded a plan, now shipping."
111
+ const newAllowed = untracked.filter(d => isAllowed(d.path)).map(d => d.path);
112
+ const newSkipped = untracked.filter(d => !isAllowed(d.path)).map(d => d.path);
113
+
114
+ const allToStage = [...toStage, ...newAllowed];
115
+ const allSkipped = [...skipped, ...newSkipped];
116
+
117
+ if (allSkipped.length > 0) {
118
+ process.stderr.write(`${dim(`Not staging (outside allowlist): ${allSkipped.join(', ')}`)}\n`);
119
+ }
120
+
121
+ if (dryRun) {
122
+ process.stdout.write(`${dim('[dry-run]')} Would stage ${allToStage.length} file(s):\n`);
123
+ for (const p of allToStage) process.stdout.write(` ${p}\n`);
124
+ process.stdout.write(`${dim('[dry-run]')} Would commit and run \`npm version ${bump}\`\n`);
125
+ return;
126
+ }
127
+
128
+ if (allSkipped.length > 0) {
129
+ die('Refusing to create a release preparation commit while skipped files are dirty. Commit, stash, or remove them first.');
130
+ }
131
+
132
+ if (allToStage.length > 0) {
133
+ const add = spawnSync('git', ['add', '--', ...allToStage], { cwd: config.repoRoot, encoding: 'utf8' });
134
+ if (add.status !== 0) die(`git add failed: ${add.stderr}`);
135
+
136
+ try {
137
+ assertGitIndex(config.repoRoot, allToStage);
138
+ } catch (err) {
139
+ die(`Refusing to commit an unexpected Git index. ${err.message}`);
140
+ }
141
+
142
+ const subject = `chore: release ${target}`;
143
+ const body = `Auto-staged by \`runlist ship\`:\n${allToStage.map(p => `- ${p}`).join('\n')}`;
144
+ const commitMsg = `${subject}\n\n${body}`;
145
+ const commit = spawnSync('git', ['commit', '-m', commitMsg], { cwd: config.repoRoot, encoding: 'utf8' });
146
+ if (commit.status !== 0) die(`git commit failed: ${commit.stderr || commit.stdout}`);
147
+ process.stdout.write(`${green('→')} Committed: ${subject}\n`);
148
+ } else {
149
+ process.stdout.write(`${dim('→ Nothing to commit before bump.')}\n`);
150
+ }
151
+
152
+ // 3. npm version <bump> — handles package.json bump, tag, push, GH release,
153
+ // npm publish, and local reinstall via the existing pre/postversion
154
+ // scripts. We stream its output so the user sees CI progress live.
155
+ process.stdout.write(`${green('→')} Running \`npm version ${bump}\`…\n`);
156
+ const npmResult = spawnSync('npm', ['version', bump], {
157
+ cwd: config.repoRoot,
158
+ stdio: 'inherit',
159
+ });
160
+ if (npmResult.status !== 0) {
161
+ warn('`npm version` failed. If the target tag exists, run `npm run release:resume`; otherwise follow the rollback/rerun guidance from the version lifecycle.');
162
+ process.exit(npmResult.status ?? 1);
163
+ }
164
+
165
+ process.stdout.write(`${green('✓')} Shipped ${target}\n`);
166
+ }
package/src/show.mjs ADDED
@@ -0,0 +1,101 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
+ import { parseDocFile, resolveDocArg } from './index.mjs';
5
+ import { resolveBodyLinkTarget } from './body-link.mjs';
6
+ import { die, normalizeStringList, resolveRefPath, toRepoPath } from './util.mjs';
7
+
8
+ // `runlist show <file...>` — one document's card as data: its title, status,
9
+ // next step, blockers and checklist, the plans and docs its frontmatter names,
10
+ // and the documents its body links to. Read-only; nothing is claimed.
11
+
12
+ // Frontmatter lists that name other documents. The configured reference fields
13
+ // are read too; `related_docs` is read whether or not a repo configures it.
14
+ const RELATED = ['related_plans', 'related_docs', 'supports_plans', 'parent_plan', 'runlist'];
15
+
16
+ function relatedFields(config) {
17
+ const configured = [...(config.referenceFields?.bidirectional ?? []), ...(config.referenceFields?.unidirectional ?? [])];
18
+ return [...new Set([...RELATED, ...configured])];
19
+ }
20
+
21
+ function brief(abs, config) {
22
+ try {
23
+ const d = parseDocFile(abs, config, { fast: true });
24
+ return { title: d.title, status: d.status, type: d.type };
25
+ } catch {
26
+ return { title: null, status: null, type: null };
27
+ }
28
+ }
29
+
30
+ /** The card for one document, by absolute path. */
31
+ export function showDoc(abs, config) {
32
+ const doc = parseDocFile(abs, config, { fast: true });
33
+ const raw = readFileSync(abs, 'utf8');
34
+ const fm = parseSimpleFrontmatter(extractFrontmatter(raw).frontmatter ?? '', []);
35
+ const dir = path.dirname(abs);
36
+ const seen = new Set([doc.path]);
37
+
38
+ const related = [];
39
+ for (const field of relatedFields(config)) {
40
+ for (const entry of normalizeStringList(fm[field])) {
41
+ const ref = String(entry).replace(/^>\s*/, '').replace(/#.*$/, '').trim();
42
+ if (!ref) continue;
43
+ const target = resolveRefPath(ref, dir, config.repoRoot);
44
+ const rel = target ? toRepoPath(target, config.repoRoot) : null;
45
+ if (rel && seen.has(`${field}:${rel}`)) continue;
46
+ if (rel) seen.add(`${field}:${rel}`);
47
+ related.push({ field, ref, path: rel, exists: Boolean(target), ...(target ? brief(target, config) : { title: null, status: null, type: null }) });
48
+ }
49
+ }
50
+
51
+ const named = new Set(related.map(r => r.path).filter(Boolean));
52
+ const links = [];
53
+ for (const link of doc.bodyLinks ?? []) {
54
+ if (link.targetKind !== 'document') continue;
55
+ const target = resolveBodyLinkTarget(link.href, dir, config.repoRoot);
56
+ if (!target.ok) continue;
57
+ const rel = toRepoPath(target.path, config.repoRoot);
58
+ if (seen.has(rel) || named.has(rel)) continue;
59
+ seen.add(rel);
60
+ links.push({ path: rel, ...brief(target.path, config) });
61
+ }
62
+
63
+ return {
64
+ path: doc.path,
65
+ type: doc.type,
66
+ title: doc.title,
67
+ status: doc.status,
68
+ summary: doc.summary,
69
+ currentState: doc.currentState === 'No current_state set' ? null : doc.currentState,
70
+ nextStep: doc.nextStep,
71
+ blockers: doc.blockers,
72
+ checklist: doc.checklist,
73
+ updated: doc.updated,
74
+ related,
75
+ links,
76
+ };
77
+ }
78
+
79
+ export function runShow(args, config) {
80
+ const json = args.includes('--json');
81
+ const targets = args.filter(a => !a.startsWith('-'));
82
+ if (!targets.length) die('Usage: runlist show <file...> [--json]');
83
+ const cards = [];
84
+ for (const t of targets) {
85
+ const abs = resolveDocArg(t, config, { dieOnMiss: !json });
86
+ cards.push(abs ? showDoc(abs, config) : { path: t, error: 'not found' });
87
+ }
88
+ if (json) {
89
+ process.stdout.write(`${JSON.stringify(cards, null, 2)}\n`);
90
+ return;
91
+ }
92
+ cards.forEach((c, n) => {
93
+ if (n) process.stdout.write('\n');
94
+ process.stdout.write(`${c.title} (${c.status ?? 'no status'})\n${c.path}\n`);
95
+ if (c.nextStep) process.stdout.write(`Next: ${c.nextStep}\n`);
96
+ for (const b of c.blockers) process.stdout.write(`Blocker: ${b}\n`);
97
+ if (c.checklist?.total) process.stdout.write(`Checklist: ${c.checklist.completed} of ${c.checklist.total} done\n`);
98
+ for (const r of c.related) process.stdout.write(`${r.field}: ${r.path ?? `${r.ref} (missing)`}${r.status ? ` (${r.status})` : ''}\n`);
99
+ if (c.links.length) process.stdout.write(`Linked from the body: ${c.links.map(l => l.path).join(', ')}\n`);
100
+ });
101
+ }
@@ -0,0 +1,98 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // runlist is a drift- and staleness-catcher that didn't, until now, catch drift
5
+ // in its OWN plugin surface. Two canonical workflow-doc surfaces are kept in
6
+ // lockstep by hand: the repo's CLAUDE.md (its own instructions) and the plugin
7
+ // SKILL.md (what every OTHER repo's session learns the workflow from). CLAUDE.md
8
+ // literally says "keep it in sync with the guidance below" — this guard makes
9
+ // that lockstep mechanical instead of manual.
10
+ //
11
+ // The comparison unit is a marked block (not a fuzzy phrase heuristic):
12
+ // deterministic, robust, and zero false positives. Each surface wraps the
13
+ // irreducible workflow contract between the markers below; the guard extracts
14
+ // both and compares them whitespace-tolerantly. Editing the contract in one
15
+ // surface and forgetting the other is exactly what this catches.
16
+
17
+ export const CANONICAL_MARKERS = {
18
+ start: '<!-- runlist:canonical-workflow:start -->',
19
+ end: '<!-- runlist:canonical-workflow:end -->',
20
+ };
21
+
22
+ // The spelling used through dotmd-cli 0.78.0. Still read, so a copy of either
23
+ // surface that predates the rename keeps participating in the lockstep instead
24
+ // of silently dropping out of it. A block must open and close with the same
25
+ // spelling; the current spelling is tried first.
26
+ export const LEGACY_CANONICAL_MARKERS = {
27
+ start: '<!-- dotmd:canonical-workflow:start -->',
28
+ end: '<!-- dotmd:canonical-workflow:end -->',
29
+ };
30
+ const MARKER_SETS = [CANONICAL_MARKERS, LEGACY_CANONICAL_MARKERS];
31
+
32
+ const CLAUDE_MD = 'CLAUDE.md';
33
+ const SKILL_MD = path.join('plugins', 'runlist', 'skills', 'runlist', 'SKILL.md');
34
+
35
+ // Pull the inner text between the canonical markers. Returns null when the
36
+ // markers are absent or malformed — callers treat null as "this surface doesn't
37
+ // participate in the lockstep block", which is what keeps a user repo that has
38
+ // its own CLAUDE.md (but never adopted the block) from ever tripping the guard.
39
+ export function extractCanonicalBlock(text) {
40
+ if (typeof text !== 'string') return null;
41
+ for (const markers of MARKER_SETS) {
42
+ const startIdx = text.indexOf(markers.start);
43
+ if (startIdx === -1) continue;
44
+ const afterStart = startIdx + markers.start.length;
45
+ const endIdx = text.indexOf(markers.end, afterStart);
46
+ if (endIdx === -1 || endIdx < afterStart) continue;
47
+ return text.slice(afterStart, endIdx);
48
+ }
49
+ return null;
50
+ }
51
+
52
+ // Whitespace-tolerant so a CRLF vs LF or a stray trailing space between the two
53
+ // files isn't reported as drift — only meaningful content divergence is.
54
+ function normalizeBlock(block) {
55
+ return block
56
+ .replace(/\r\n?/g, '\n')
57
+ .split('\n')
58
+ .map(line => line.replace(/\s+$/, ''))
59
+ .join('\n')
60
+ .trim();
61
+ }
62
+
63
+ function readIfPresent(filePath) {
64
+ try {
65
+ if (!existsSync(filePath)) return null;
66
+ return readFileSync(filePath, 'utf8');
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ // Guard: the canonical workflow block must stay identical across CLAUDE.md and
73
+ // the plugin SKILL.md. Returns [] unless BOTH files exist AND BOTH carry the
74
+ // block — so it only fires in a repo that has adopted the lockstep convention
75
+ // (i.e. the runlist repo itself), never in a user repo that merely has its own
76
+ // CLAUDE.md. That strict gate is the price of zero false positives; the
77
+ // trade-off is that deleting the markers from one surface silently disables the
78
+ // guard rather than warning (a deliberate, visible act, unlike a content edit).
79
+ export function checkSkillDrift(config) {
80
+ const repoRoot = config?.repoRoot;
81
+ if (!repoRoot) return [];
82
+
83
+ const claudeText = readIfPresent(path.join(repoRoot, CLAUDE_MD));
84
+ const skillText = readIfPresent(path.join(repoRoot, SKILL_MD));
85
+ if (claudeText === null || skillText === null) return [];
86
+
87
+ const claudeBlock = extractCanonicalBlock(claudeText);
88
+ const skillBlock = extractCanonicalBlock(skillText);
89
+ if (claudeBlock === null || skillBlock === null) return [];
90
+
91
+ if (normalizeBlock(claudeBlock) === normalizeBlock(skillBlock)) return [];
92
+
93
+ return [{
94
+ path: SKILL_MD,
95
+ level: 'warning',
96
+ message: `canonical workflow block drifted from \`${CLAUDE_MD}\`. The block between \`${CANONICAL_MARKERS.start}\` and \`${CANONICAL_MARKERS.end}\` must stay identical in both files — reconcile them so the plugin skill and the repo instructions teach the same workflow.`,
97
+ }];
98
+ }