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/guard.mjs ADDED
@@ -0,0 +1,488 @@
1
+ import { readFileSync, realpathSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { inspectGitCommandPaths } from './git.mjs';
5
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
6
+ import { recordGuardEvent } from './journal.mjs';
7
+ import { readEnv } from './naming.mjs';
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+ const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
11
+
12
+ // `runlist guard` is the PreToolUse hook handler. Claude Code pipes the tool-call
13
+ // payload on stdin; we evaluate it against a small set of "wrong-move" rules and
14
+ // reply with a PreToolUse hook-output JSON object. Every catch is also recorded
15
+ // to the cross-repo misuse log so the operator can audit *every* incorrect usage
16
+ // — these mistakes never invoke runlist directly, so the guard is the only place
17
+ // they become visible.
18
+ //
19
+ // Two decision levels:
20
+ // 'deny' — block the call and feed the reason back to the model. Reserved for
21
+ // moves that are guaranteed-wrong: committing a gitignored prompt (it
22
+ // would fail anyway) and hand-editing a `status:` field (`runlist set`
23
+ // is a complete substitute; config `guard: { deny: false }` drops the
24
+ // status rules back to warn).
25
+ // 'warn' — let the call proceed but inject teaching context so the agent learns
26
+ // the runlist-native command. Used for soft mistakes (cat/Read of a
27
+ // prompt) where a human might legitimately do it; we nudge rather
28
+ // than block.
29
+
30
+ const SHELL_READERS = new Set(['cat', 'less', 'more', 'head', 'tail', 'bat', 'view', 'open']);
31
+
32
+ // Normalize path separators so the guard's `/`-based matching also fires on
33
+ // Windows backslash paths (`docs\prompts\foo.md`). Without this the PreToolUse
34
+ // status-edit/commit-prompt guards silently no-op on Windows — protection
35
+ // absent exactly where it's needed. On POSIX paths this is a no-op.
36
+ function toSlash(p) {
37
+ return typeof p === 'string' ? p.replace(/\\/g, '/') : p;
38
+ }
39
+
40
+ // A path that ends in .md and sits under a `prompts/` directory is a
41
+ // session-local saved prompt regardless of which doc root it belongs to —
42
+ // robust across repos without needing the resolved config. Archived prompts
43
+ // (`…/prompts/archived/…`, the default nested archive for the prompt type) are
44
+ // committable history, NOT session-local, so they're explicitly excluded — the
45
+ // guard must not block committing or reading them.
46
+ function isPromptPath(p, config) {
47
+ const s = toSlash(p);
48
+ if (typeof s !== 'string' || !s.endsWith('.md')) return false;
49
+ if (!/(^|\/)prompts\//.test(s)) return false;
50
+ const archiveDir = toSlash(config?.archiveDir || 'archived').replace(/^\/+|\/+$/g, '');
51
+ if (archiveDir && s.split('/').includes(archiveDir)) return false;
52
+ return true;
53
+ }
54
+
55
+ // Loose "is this a runlist-managed doc" test: a .md file under one of the
56
+ // configured doc roots (default `docs/`). Used for the status-edit guard.
57
+ function isManagedDoc(p, config) {
58
+ const s = toSlash(p);
59
+ if (typeof s !== 'string' || !s.endsWith('.md')) return false;
60
+ const roots = config?.docsRoots || (config?.docsRoot ? [config.docsRoot] : ['docs']);
61
+ return roots.some(r => {
62
+ const rNorm = toSlash(r).replace(/\/+$/, '');
63
+ const base = rNorm.split('/').pop();
64
+ return s.includes(`/${base}/`) || s.startsWith(`${base}/`) || s.includes(rNorm);
65
+ });
66
+ }
67
+
68
+ // Minimal shell lexer: preserve quoted/escaped spaces and drop quote syntax so
69
+ // command/path decisions operate on argument boundaries rather than whitespace.
70
+ function shellTokens(command) {
71
+ if (typeof command !== 'string') return [];
72
+ const tokens = [];
73
+ let token = '';
74
+ let quote = null;
75
+ let escaped = false;
76
+ for (let i = 0; i < command.length; i++) {
77
+ const char = command[i];
78
+ if (escaped) { token += char; escaped = false; continue; }
79
+ if (char === '\\' && quote !== "'") {
80
+ const next = command[i + 1];
81
+ if (next && /[\s'"\\|&;]/.test(next)) escaped = true;
82
+ else token += char;
83
+ continue;
84
+ }
85
+ if (quote) {
86
+ if (char === quote) quote = null;
87
+ else token += char;
88
+ continue;
89
+ }
90
+ if (char === '"' || char === "'") { quote = char; continue; }
91
+ if (/\s/.test(char)) {
92
+ if (token) { tokens.push(token); token = ''; }
93
+ continue;
94
+ }
95
+ token += char;
96
+ }
97
+ if (escaped) token += '\\';
98
+ if (token) tokens.push(token);
99
+ return tokens;
100
+ }
101
+
102
+ // Drop heredoc bodies, keeping the command line that opens them. Heredoc
103
+ // bodies are document content — resume-prompt drafts routinely mention
104
+ // `docs/prompts/…` paths and even describe the guard's own rules, and none of
105
+ // that is the *command* doing anything.
106
+ function stripHeredocBodies(command) {
107
+ if (typeof command !== 'string' || !command.includes('<<')) return command;
108
+ const lines = command.split('\n');
109
+ const out = [];
110
+ let marker = null;
111
+ for (const line of lines) {
112
+ if (marker !== null) {
113
+ if (line.trim() === marker) marker = null;
114
+ continue;
115
+ }
116
+ out.push(line);
117
+ const m = line.match(/<<-?\s*(['"]?)(\w+)\1/);
118
+ if (m) marker = m[2];
119
+ }
120
+ return out.join('\n');
121
+ }
122
+
123
+ // Split a compound command into independently-evaluated segments. Each side of
124
+ // a pipe / && / || / ; / newline runs its own program, so a rule should only
125
+ // fire on the segment whose program actually touches the prompt — `runlist check
126
+ // docs/prompts/x.md; git commit -- docs/plans/y.md` commits no prompt.
127
+ function shellSegments(command) {
128
+ const input = stripHeredocBodies(command);
129
+ const segments = [];
130
+ let segment = '';
131
+ let quote = null;
132
+ let escaped = false;
133
+ for (let i = 0; i < input.length; i++) {
134
+ const char = input[i];
135
+ if (escaped) { segment += char; escaped = false; continue; }
136
+ if (char === '\\' && quote !== "'") { segment += char; escaped = true; continue; }
137
+ if (quote) {
138
+ segment += char;
139
+ if (char === quote) quote = null;
140
+ continue;
141
+ }
142
+ if (char === '"' || char === "'") { quote = char; segment += char; continue; }
143
+ const pair = input.slice(i, i + 2);
144
+ if (char === ';' || char === '\n' || char === '|' || pair === '&&') {
145
+ if (segment.trim()) segments.push(segment.trim());
146
+ segment = '';
147
+ if (pair === '&&' || pair === '||') i += 1;
148
+ continue;
149
+ }
150
+ segment += char;
151
+ }
152
+ if (segment.trim()) segments.push(segment.trim());
153
+ return segments;
154
+ }
155
+
156
+ function executableIndex(tokens) {
157
+ let i = 0;
158
+ const assignment = /^[A-Za-z_][A-Za-z0-9_]*=/;
159
+ let unwrapping = true;
160
+ while (unwrapping) {
161
+ while (assignment.test(tokens[i] ?? '')) i += 1;
162
+ if (tokens[i] === 'env') {
163
+ i += 1;
164
+ while (i < tokens.length) {
165
+ if (tokens[i] === '-u' || tokens[i] === '--unset') { i += 2; continue; }
166
+ if (tokens[i].startsWith('-') || assignment.test(tokens[i])) { i += 1; continue; }
167
+ break;
168
+ }
169
+ continue;
170
+ }
171
+ if (tokens[i] === 'command') {
172
+ i += 1;
173
+ while (tokens[i]?.startsWith('-')) i += 1;
174
+ continue;
175
+ }
176
+ if (tokens[i] === 'sudo') {
177
+ i += 1;
178
+ while (tokens[i]?.startsWith('-')) {
179
+ if (['-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt', '-C', '--chdir'].includes(tokens[i])) i += 2;
180
+ else i += 1;
181
+ }
182
+ continue;
183
+ }
184
+ unwrapping = false;
185
+ }
186
+ return i;
187
+ }
188
+
189
+ function parseGitInvocation(tokens, baseCwd) {
190
+ let i = executableIndex(tokens);
191
+ if (path.basename(tokens[i] ?? '') !== 'git') return null;
192
+ i += 1;
193
+ let cwd = baseCwd;
194
+ const valueOptions = new Set(['-c', '--namespace', '--super-prefix', '--config-env']);
195
+ while (i < tokens.length && tokens[i].startsWith('-')) {
196
+ const option = tokens[i];
197
+ if (option === '-C' && tokens[i + 1]) {
198
+ cwd = path.resolve(cwd, tokens[i + 1]);
199
+ i += 2;
200
+ } else if (option.startsWith('-C') && option.length > 2) {
201
+ cwd = path.resolve(cwd, option.slice(2));
202
+ i += 1;
203
+ } else if (option === '--git-dir' || option === '--work-tree'
204
+ || option.startsWith('--git-dir=') || option.startsWith('--work-tree=')) {
205
+ return null; // non-standard repository context: fail open rather than inspect the wrong tree
206
+ } else if (valueOptions.has(option)) i += 2;
207
+ else i += 1;
208
+ }
209
+ const subcommand = tokens[i];
210
+ if (!/^(add|commit|stage)$/.test(subcommand ?? '')) return null;
211
+ return { subcommand, args: tokens.slice(i + 1), cwd };
212
+ }
213
+
214
+ // Decision level for the status-edit rules. Hand-editing `status:` has no
215
+ // legitimate variant — `runlist set` is a complete substitute — so it denies by
216
+ // default. `guard: { deny: false }` in config drops it back to warn-only.
217
+ function editStatusDecision(config) {
218
+ return config?.guard?.deny === false ? 'warn' : 'deny';
219
+ }
220
+
221
+ function editStatusResult(target, config, detail) {
222
+ return {
223
+ decision: editStatusDecision(config),
224
+ rule: 'edit-status',
225
+ detail,
226
+ reason:
227
+ // Names the canonical executable: a session that follows this message
228
+ // should land on the current name, not the legacy alias.
229
+ `Looks like a hand-edit of the \`status:\` field in ${target}. Use \`runlist set <status> ${target}\` instead — ` +
230
+ `it validates the status against this doc's type, runs lifecycle hooks, fixes refs, and keeps the index in sync. Direct edits skip all of that. ` +
231
+ `Creating a doc? \`runlist new <type> <name> --status <status>\` writes the status in the same step.`,
232
+ };
233
+ }
234
+
235
+ // In-place stream editors (`sed -i`, `perl -pi`, `awk -i inplace`) are the
236
+ // shell-side bypass of the Edit-tool status guard. Only the command text
237
+ // before any heredoc marker is scanned — heredoc bodies are document content
238
+ // (often prose *describing* these rules), not commands.
239
+ const STREAM_EDITOR_INPLACE = [
240
+ /\bsed\b[^|;&<>]*\s-i/,
241
+ /\bperl\b[^|;&<>]*\s-[a-zA-Z]*i/,
242
+ /\bg?awk\b[^|;&<>]*\binplace\b/,
243
+ ];
244
+
245
+ function promptReadDecision(filePath, config, deps = {}) {
246
+ if (config?.guard?.promptReads !== 'deny') return 'warn';
247
+ if (!isManagedDoc(filePath, config)) return 'warn';
248
+ const readFile = deps.readFile ?? ((p) => readFileSync(p, 'utf8'));
249
+ try {
250
+ const absolute = path.resolve(deps.gitCwd ?? process.cwd(), filePath);
251
+ const real = p => { try { return realpathSync(p); } catch { return p; } };
252
+ const roots = config.docsRoots || (config.docsRoot ? [config.docsRoot] : ['docs']);
253
+ const insideRoot = roots.some(root => {
254
+ const base = real(path.resolve(config.repoRoot ?? process.cwd(), root));
255
+ const relative = path.relative(base, real(absolute));
256
+ return relative && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
257
+ });
258
+ if (!insideRoot) return 'warn';
259
+ const fm = parseSimpleFrontmatter(extractFrontmatter(readFile(absolute)).frontmatter);
260
+ return fm.type === 'prompt' && fm.status === 'pending' ? 'deny' : 'warn';
261
+ } catch { return 'warn'; }
262
+ }
263
+
264
+ function evalBash(command, config, inspectGitPaths, baseCwd, deps = {}) {
265
+ const segments = shellSegments(command);
266
+ let cwd = baseCwd;
267
+
268
+ for (const seg of segments) {
269
+ const segTokens = shellTokens(seg);
270
+ if (!segTokens.length) continue;
271
+ const commandIndex = executableIndex(segTokens);
272
+ const cmd0 = path.basename(segTokens[commandIndex] ?? '');
273
+ const promptTokens = segTokens.filter(token => isPromptPath(token, config));
274
+ if (cmd0 === 'cd' && segTokens[commandIndex + 1]) {
275
+ cwd = path.resolve(cwd, segTokens[commandIndex + 1]);
276
+ continue;
277
+ }
278
+
279
+ // Rule A — deny only when Git's current state says the command would
280
+ // actually include a live prompt. This covers broad forms (`add .`, `-A`,
281
+ // pathless commit, commit -a) without blocking ignored or clean prompts.
282
+ const git = parseGitInvocation(segTokens, cwd);
283
+ if (git) {
284
+ const includedPaths = inspectGitPaths(git.subcommand, git.args, git.cwd);
285
+ const targets = [...new Set(includedPaths.filter(candidate => isPromptPath(candidate, config)))];
286
+ if (targets.length > 0) {
287
+ return {
288
+ decision: 'deny',
289
+ rule: 'commit-prompt',
290
+ detail: `git ${git.subcommand} ${targets.join(' ')}`,
291
+ reason:
292
+ `Saved prompts (${targets.join(', ')}) are session-local runlist artifacts, not source to commit. ` +
293
+ `Don't git add/commit them — commit your other changes without the prompt in the pathspec. ` +
294
+ `The next session consumes a prompt with \`runlist use <file>\` (or \`runlist use\` for the oldest pending), which prints the body and archives it atomically.`,
295
+ };
296
+ }
297
+ }
298
+
299
+ // Rule B — reading a prompt through the shell instead of consuming it.
300
+ if (SHELL_READERS.has(cmd0) && promptTokens.length) {
301
+ return {
302
+ decision: promptReadDecision(promptTokens[0], config, { ...deps, gitCwd: cwd }),
303
+ rule: 'cat-prompt',
304
+ detail: `${cmd0} ${promptTokens.join(' ')}`,
305
+ reason:
306
+ `${promptTokens.join(', ')} is a saved runlist prompt. To start work from it, run \`runlist use ${promptTokens[0]}\` — ` +
307
+ `it commits archive/claim before at-most-once body output (prevents double-consumption). ` +
308
+ `Just peeking or triaging (not consuming)? \`runlist prompts show ${promptTokens[0]}\` reads it without archiving. Don't \`${cmd0}\` it directly.`,
309
+ };
310
+ }
311
+
312
+ // Rule C — only an actual in-place stream-editor invocation can be a
313
+ // status edit. Quoted prose printed by echo/printf is not executable code.
314
+ if (/^(?:sed|perl|g?awk)$/.test(cmd0) && /status/.test(seg)
315
+ && STREAM_EDITOR_INPLACE.some(re => re.test(seg))) {
316
+ const managed = segTokens.filter(token => isManagedDoc(token, config));
317
+ if (managed.length > 0) return editStatusResult(managed[0], config, `status-edit ${managed[0]}`);
318
+ }
319
+ }
320
+
321
+ return null;
322
+ }
323
+
324
+ function evalRead(filePath, config, deps = {}) {
325
+ if (!isPromptPath(filePath, config)) return null;
326
+ return {
327
+ decision: promptReadDecision(filePath, config, deps),
328
+ rule: 'read-prompt',
329
+ detail: filePath,
330
+ reason:
331
+ `${filePath} is a saved runlist prompt. To start work from it, run \`runlist use ${filePath}\` — it commits archive/claim before at-most-once body output so it can't be double-consumed. ` +
332
+ `Just peeking or triaging (not consuming)? \`runlist prompts show ${filePath}\` reads it without archiving. ` +
333
+ `Surveying the whole queue? \`runlist prompts show --all\` peeks every pending prompt in one call — don't Read them file by file.`,
334
+ };
335
+ }
336
+
337
+ // Codex and OpenCode expose patch edits as one string. Only +/- hunk lines
338
+ // count: context mentioning status is an anchor, not an edit. New files are
339
+ // creation, and status on a deleted file is not a hand-edited transition.
340
+ function evalPatch(command, config) {
341
+ if (typeof command !== 'string') return null;
342
+ let target = null;
343
+ let removed = [];
344
+ let added = [];
345
+ const changed = () => target && statusLines(removed.join('\n')).join('\n') !== statusLines(added.join('\n')).join('\n');
346
+ for (const line of command.split('\n')) {
347
+ const update = line.match(/^\*\*\* Update File: (.+)$/);
348
+ if (update || /^\*\*\* (?:Add|Delete) File: /.test(line) || line === '*** End Patch') {
349
+ if (changed()) return editStatusResult(target, config, target);
350
+ target = update && isManagedDoc(update[1], config) ? update[1] : null;
351
+ removed = [];
352
+ added = [];
353
+ continue;
354
+ }
355
+ if (!target || line.startsWith('***')) continue;
356
+ if (line.startsWith('+')) added.push(line.slice(1));
357
+ else if (line.startsWith('-')) removed.push(line.slice(1));
358
+ }
359
+ return changed() ? editStatusResult(target, config, target) : null;
360
+ }
361
+
362
+ // Every `status:` line in a snippet, normalized for comparison.
363
+ function statusLines(s) {
364
+ if (typeof s !== 'string') return [];
365
+ return (s.match(/^[ \t]*status[ \t]*:[^\n]*/gm) ?? []).map(l => l.trim());
366
+ }
367
+
368
+ // Only fire when the edit actually CHANGES a `status:` line. An edit whose
369
+ // old/new strings both carry the same `status:` line is using it as anchor
370
+ // context (e.g. adding a `summary:` field above it) — warning on those taught
371
+ // sessions to ignore the rule (the health-repo repeat offenses were exactly
372
+ // this false positive).
373
+ function evalEdit(input, config, deps = {}) {
374
+ const filePath = input?.file_path;
375
+ if (!isManagedDoc(filePath, config)) return null;
376
+
377
+ const pairs = [];
378
+ const newStr = input?.new_string ?? input?.new_str;
379
+ if (typeof newStr === 'string') pairs.push([input?.old_string ?? input?.old_str ?? '', newStr]);
380
+ for (const e of Array.isArray(input?.edits) ? input.edits : []) {
381
+ if (typeof e?.new_string === 'string') pairs.push([e.old_string ?? '', e.new_string]);
382
+ }
383
+ if (typeof input?.content === 'string') {
384
+ // Write replaces the whole file — diff against what's on disk. An
385
+ // unreadable/missing target is doc creation, not a status edit.
386
+ const readFile = deps.readFile ?? ((p) => readFileSync(p, 'utf8'));
387
+ let existing;
388
+ try { existing = readFile(filePath); } catch { existing = null; }
389
+ if (typeof existing === 'string') pairs.push([existing, input.content]);
390
+ }
391
+
392
+ const changed = pairs.some(([oldS, newS]) => statusLines(oldS).join('\n') !== statusLines(newS).join('\n'));
393
+ if (!changed) return null;
394
+ return editStatusResult(filePath, config, filePath);
395
+ }
396
+
397
+ // Pure evaluation — `deps.isIgnored(path) -> bool` is injected so tests don't
398
+ // need a real git tree. Returns null (no opinion) or a result object.
399
+ export function evaluateGuard(payload, config, deps = {}) {
400
+ if (readEnv('GUARD') === '0') return null;
401
+ if (!config?.configFound) return null;
402
+ const tool = payload?.tool_name;
403
+ const input = payload?.tool_input || {};
404
+ const inspectGitPaths = deps.inspectGitPaths
405
+ || ((subcommand, args, cwd) => inspectGitCommandPaths(subcommand, args, cwd ?? deps.gitCwd ?? process.cwd()));
406
+
407
+ if (tool === 'Bash') return evalBash(input.command || input.cmd || '', config, inspectGitPaths, input.workdir ?? deps.gitCwd ?? process.cwd(), deps);
408
+ if (tool === 'Read') return evalRead(input.file_path || '', config, deps);
409
+ if (tool === 'apply_patch') return evalPatch(input.command || input.patchText || '', config);
410
+ if (/^mcp__.*__(?:read_file|read_text_file|read_media_file|read_multiple_files)$/.test(tool ?? '')) {
411
+ const paths = Array.isArray(input.paths) ? input.paths : [input.path ?? input.file_path];
412
+ for (const p of paths) {
413
+ const result = evalRead(p, config, deps);
414
+ if (result) return result;
415
+ }
416
+ }
417
+ if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') return evalEdit(input, config, deps);
418
+ return null;
419
+ }
420
+
421
+ export function readHookStdin() {
422
+ return new Promise((resolve) => {
423
+ let data = '';
424
+ try {
425
+ process.stdin.setEncoding('utf8');
426
+ process.stdin.on('data', (c) => { data += c; });
427
+ process.stdin.on('end', () => resolve(data));
428
+ process.stdin.on('error', () => resolve(data));
429
+ // Don't hang the tool dispatch if stdin never closes. unref() so the
430
+ // timer can't hold the event loop open — without it every guard
431
+ // invocation lingered the full 2s AFTER answering, which added ~2s of
432
+ // dead latency to every guarded tool call in every session.
433
+ setTimeout(() => resolve(data), 2000).unref();
434
+ } catch {
435
+ resolve(data);
436
+ }
437
+ });
438
+ }
439
+
440
+ function emit(result) {
441
+ if (!result) {
442
+ // No opinion — stay silent, let the tool run.
443
+ process.stdout.write('{}\n');
444
+ return;
445
+ }
446
+ const hookSpecificOutput = { hookEventName: 'PreToolUse' };
447
+ if (result.decision === 'deny') {
448
+ hookSpecificOutput.permissionDecision = 'deny';
449
+ hookSpecificOutput.permissionDecisionReason = result.reason;
450
+ } else {
451
+ // warn — allow the call but teach the agent the runlist-native path.
452
+ hookSpecificOutput.additionalContext = `[runlist] ${result.reason}`;
453
+ }
454
+ process.stdout.write(JSON.stringify({ hookSpecificOutput }) + '\n');
455
+ }
456
+
457
+ export async function runGuard(argv, config, opts = {}) {
458
+ let payload = {};
459
+ try {
460
+ const raw = await readHookStdin();
461
+ if (raw && raw.trim()) payload = JSON.parse(raw);
462
+ } catch {
463
+ payload = {};
464
+ }
465
+
466
+ let result = null;
467
+ try {
468
+ result = evaluateGuard(payload, config);
469
+ } catch {
470
+ result = null;
471
+ }
472
+
473
+ if (result && !opts.dryRun) {
474
+ recordGuardEvent({
475
+ repo: config?.repoRoot,
476
+ tool: payload?.tool_name,
477
+ rule: result.rule,
478
+ decision: result.decision,
479
+ detail: result.detail,
480
+ version: pkg.version,
481
+ });
482
+ }
483
+
484
+ emit(result);
485
+ // A guard must never fail the tool dispatch; always exit 0 and let the JSON
486
+ // carry the decision.
487
+ process.exitCode = 0;
488
+ }