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,232 @@
1
+ # Source editing v1
2
+
3
+ `src/source-editor.mjs` is the shared local core for an existing-plan narrative
4
+ editing loop. It supports unambiguous legacy plans and valid native v1 plans,
5
+ flags, and decisions. It does not itself start a web server or change existing
6
+ domain CLI writers. Trusted adapters may opt into configured legacy document
7
+ types with `legacyTypes` and allow read-only unconfigured/untyped sources with
8
+ `allowUnconfiguredRead`. These are host options, never browser request fields.
9
+ Prompts stay excluded; frontmatter and history protections apply to every type.
10
+ The working document application in `src/app.mjs` supplies a
11
+ trusted local human adapter and consumes this core.
12
+
13
+ ## Actor and authority
14
+
15
+ The trusted local host constructs `createSourceEditor({config, authenticate,
16
+ authorize})`. Both callbacks are required and synchronous; asynchronous
17
+ authentication can happen at the host boundary before calling the core, with
18
+ the callback resolving a verified, revocable session from server-owned state.
19
+ The factory/config/callbacks/test hooks are never request data. This is an
20
+ in-process boundary, not protection against arbitrary code with filesystem
21
+ access.
22
+
23
+ `authenticate(context)` returns `{kind:'human'|'agent', id, label?, session_id?}`.
24
+ An agent requires its actual session ID. The core never reads the launching
25
+ process's identity from environment variables and ignores an actor supplied in
26
+ the request. A human browser session remains a human even when an agent started
27
+ the server. A browser adapter must authenticate its own session and
28
+ protect mutation requests against cross-site/unauthorized access.
29
+
30
+ `authorize({actor, action, path})` verifies authority against trusted runtime
31
+ policy. It returns `{allowed:true}` for an authorized human; an authorized agent
32
+ also requires a nonempty `grant_id`. The adapter must verify that the grant is
33
+ current, binds the actor/session and selected checkout/path, permits the exact
34
+ action, and has not expired or been revoked. A grant reference in Markdown is
35
+ evidence to look up, never authority by itself. The core rechecks identity and
36
+ policy after waiting for save locks, and policy again before source publication.
37
+ The receipt attributes the operation to the verified actor and initial grant.
38
+
39
+ Actions are `read`, `save`, `undo`, `read-draft`, `write-draft`, `discard-draft`,
40
+ `inspect-operation`, and `settle-operation`. Drafts and receipts are additionally
41
+ bound to their owner. Agents include their session in that binding. Inspecting,
42
+ settling, or undoing another actor's operation is not supported by this v1 API.
43
+
44
+ Future domain operations for rulings, delegation, resolution, lifecycle, task
45
+ allocation, relationships, and migration must use separate action permissions.
46
+ An agent ruling requires a verified delegation permitting that specific ruling,
47
+ not merely permission to edit narrative. Recommendations or accepted awareness
48
+ do not create execution authority. Those writers append shared provenance to
49
+ native metadata through domain operations; this API cannot edit those fields.
50
+
51
+ ## Source, revisions, and conflicts
52
+
53
+ `read(context,{path})` returns `{path,source,revision,type,editable,diagnostics}`.
54
+ `revision` is `sha256:<64 lowercase hex>` over the exact UTF-8 bytes. It is not a
55
+ Git commit, timestamp, frontmatter `updated`, or inferred current revision.
56
+ CRLF, Unicode, unknown metadata, tables, fences, links, comments, and attachments
57
+ are preserved because the core writes the supplied source without serialization.
58
+ Invalid/unsupported records remain readable and draftable, with `editable:false`.
59
+ The core accepts valid UTF-8 documents up to 8 MiB, without NUL bytes.
60
+
61
+ `save(context,{path,operationId,expectedRevision,source})` requires an exact base
62
+ revision and a caller-generated lowercase UUID v4. Frontmatter, lifecycle
63
+ `Version History` sections, and the sequence of stable native item anchors must
64
+ remain byte-identical/identity-identical. Narrative and existing task text or
65
+ checkboxes may change. New/removal/reordering of anchored items belongs to future
66
+ domain operations. Every native result is validated before publication. No
67
+ source serializer drops unknown fields or silently repairs invalid metadata.
68
+
69
+ A stale save throws `SourceEditError` with `code:'revision-conflict'` and
70
+ `details:{expectedRevision,currentRevision,currentSource}`. The application keeps
71
+ the draft, displays base/draft/current source and diff, and asks the user to
72
+ review a merge or reload. It submits a new operation ID against the explicitly
73
+ reviewed current revision. The core does not silently merge, retry a stale save
74
+ against a new revision, or choose a timestamp winner. Read/modify/save is a
75
+ compare-and-swap operation under the existing atomic-mutation path locks.
76
+
77
+ Configured Markdown roots authorize paths. Symlink sources, escapes, and
78
+ unmanaged types are rejected. A save also locks the canonical CLI ownership
79
+ record and checks its generation before publication. Corrupt ownership or a
80
+ claim held by another actor blocks saving. A human browser does not borrow the
81
+ launching agent's claim; the owner coordinates/releases it through the CLI.
82
+ Even a dead claim is not implicitly taken over by this API. An agent may save
83
+ its own claimed plan with verified edit authority. Saves never claim, release,
84
+ archive, run lifecycle hooks, repair references, stage Git, or commit Git.
85
+
86
+ Trusted checkout adapters may opt into `allowStageEdits:true`. This permits only
87
+ a plan's `ships:` field to change alongside narrative edits; all other envelope
88
+ bytes, lifecycle history and stable item identities remain protected. Stage
89
+ values are checked against the configured vocabulary, with an unset choice
90
+ allowed. Generic editor adapters retain the byte-identical frontmatter default.
91
+ The same save request, receipts, claims, revision checks and recovery path apply.
92
+ Compensating undo restores the retained exact before-image, including its prior
93
+ stage, without validating that old value as a new stage assignment.
94
+
95
+ ## Durable operations and repair
96
+
97
+ Before replacing source, the core durably writes a private `prepared` receipt
98
+ containing the operation ID, request kind, path, actor/grant, timestamp, exact
99
+ before/after source and revisions, and optional `undoOf`. The canonical file is
100
+ then atomically replaced with its existing mode and its directory synchronized.
101
+ Finally the receipt becomes `committed`. A successful result contains the
102
+ operation ID/state, resulting revision, actor/grant, and affected files.
103
+
104
+ The same operation ID with identical owner/path/kind/base/source (or undo target)
105
+ returns the retained outcome and does not write source again. Different payload
106
+ reuse is `operation-reused`. A replay also reports `currentRevision`, which may
107
+ have changed since the historical save; the host rereads before editing further.
108
+
109
+ After a crash, a prepared receipt is reconciled against current source:
110
+
111
+ | Current source | Result |
112
+ | --- | --- |
113
+ | Exact after-image | Mark committed/recovered; do not write source again. |
114
+ | Exact before-image | The original owner may retry the original request; a different operation is blocked as pending. |
115
+ | Neither image | Report `repair-required`; preserve all images and current source, write nothing to source. |
116
+
117
+ This is content-generation recovery. It cannot prove which external writer
118
+ published identical bytes, or reconstruct a missing/corrupt local receipt.
119
+ New saves inspect pending receipts for that document before obscuring their
120
+ publication evidence. Source/durability errors after preparation retain the
121
+ receipt rather than blindly rolling source back over a later external edit.
122
+
123
+ `inspectOperation(context,{operationId})` returns the retained images and current
124
+ revision to the authorized owner. After explicit inspection,
125
+ `settleOperation(context,{operationId,expectedRevision,disposition:'leave-current'})`
126
+ checks that revision and leaves source untouched. It records `committed` for
127
+ the after-image, `not-applied` for the before-image, or `settled-unknown` for a
128
+ third generation. Unknown is never relabeled success/failure. The settled
129
+ operation cannot be retried to write; further reviewed edits use a new ID.
130
+ Malformed state requires manual inspection and is never silently reset. These
131
+ receipts are separate from existing move/lifecycle transaction manifests and
132
+ are inspected through this API, not `doctor --transactions`.
133
+
134
+ The existing locking and filesystem authorization are cooperative local
135
+ controls. Uncooperative filesystem writes can still race the final checks and
136
+ rename; descriptor-relative I/O is not implemented. Do not describe this as a
137
+ filesystem security boundary or a cross-clone transaction. A lost receipt or
138
+ a checkout moved to a new local path needs explicit recovery review.
139
+
140
+ ## Draft recovery
141
+
142
+ `putDraft(context,{path,draftId,expectedDraftRevision,baseSource,baseRevision,source})`
143
+ stores a private draft, including the exact base snapshot for a later three-way
144
+ review. The base source must match its revision, but need not be the current
145
+ source; autosaving a stale draft never overwrites the document. Use `null` for a
146
+ new draft's expected revision. Later writes require the returned draft revision,
147
+ so two tabs cannot silently replace each other's recovery data.
148
+
149
+ `readDraft(context,{path,draftId})` returns the owner's draft and `stale` when the
150
+ canonical source differs from its base. It never applies the draft automatically.
151
+ `discardDraft(context,{path,draftId,expectedDraftRevision})` durably replaces it
152
+ with a body-free tombstone after explicit discard. A discarded ID can be used
153
+ again with expected revision `null`. Saves do not implicitly delete drafts;
154
+ the host clears them only after receiving/verifying the committed outcome.
155
+ Draft persistence survives host reload/disconnection after the core acknowledges
156
+ the draft. The plan app also keeps browser recovery snapshots before its disk
157
+ autosave acknowledges, and retains pending save/undo operation IDs across reload.
158
+ Missing, moved, or deleted canonical files require manual
159
+ inspection of local state in this first version; no create/rename recovery API
160
+ is provided yet.
161
+
162
+ State lives under the checkout's `.runlist/editor/` (or existing legacy state
163
+ directory), in owner-scoped draft files and an operation ledger. Files are
164
+ created with mode 0600 and directories with 0700; Windows uses its normal user
165
+ filesystem access controls. State may not traverse symlinks. In a Git checkout,
166
+ storage must be ignored and untracked before any body/before-image is persisted;
167
+ the core does not edit ignore rules. This storage is local, not shared record
168
+ history. No automatic expiry discards recovery data. Cleanup/retention controls
169
+ and a disk-wide draft inventory UI remain further work; the first app discovers
170
+ its browser recovery entries for each selected plan.
171
+
172
+ ## Compensating undo
173
+
174
+ `undo(context,{path,operationId,undoOf,expectedRevision})` creates a new receipt
175
+ and save operation referencing a retained committed operation. The requester
176
+ must own the original receipt and have current undo authority. The expected
177
+ revision must equal both the current file and the original result. The new
178
+ source reverses that operation's changes; its own exact before/after images and
179
+ actor are retained. If anything changed subsequently, undo reports a conflict
180
+ and requires explicit review. It does not merge over later edits or replay
181
+ claims, hooks, reference repairs, migrations, or Git operations. Undoing the
182
+ compensating operation is a redo with the same revision protections.
183
+
184
+ The first application exposes the reviewed diff, Save, preserved draft,
185
+ conflict review, and Undo through these operations. Git commit, sharing across
186
+ clones, lifecycle actions, and record migrations remain distinct operations.
187
+
188
+ ## Local document adapter
189
+
190
+ `runlist app [document] [--port <port>]` selects the configured checkout at startup
191
+ and binds only to `127.0.0.1`. A random access capability in the printed URL
192
+ fragment establishes a separate human browser session. The fragment is removed
193
+ after connection. Sessions use a port-scoped HttpOnly/SameSite=Strict cookie,
194
+ expire after twelve hours, and can be revoked with Disconnect. The adapter uses
195
+ the local OS user identity, not an agent session inherited from the environment.
196
+ This is a local capability, not a remote multi-user login service.
197
+
198
+ API mutations require the authenticated cookie, exact allowed local Origin,
199
+ and the session's random CSRF token. Host checks reject DNS-rebinding hosts;
200
+ cross-origin API requests are refused. Static content has a restrictive CSP
201
+ and no external scripts/styles. Markdown is escaped and unsafe link schemes
202
+ are refused. No browser request can select a new repository/config, inject an
203
+ actor/policy, release a claim, or start a lifecycle/domain operation. The adapter
204
+ homes configured hubs, plans and documents in a paged library. A hub is the
205
+ organizing document older guidance calls a runlist; its ordered child plans
206
+ live on that same document. Coordination, ordered and roadmap hubs are shapes
207
+ of this concept, not separate required container levels. Only
208
+ unambiguous configured narratives or valid native records are editable; untyped
209
+ and unsupported sources remain read only. Private prompts, excluded corpora and
210
+ generated indexes stay outside this adapter. Domain record views retain separate
211
+ action scopes.
212
+
213
+ The browser persists recovery data before Save/Undo requests, retries the same
214
+ payload/operation kind after an uncertain acknowledgement, and exposes retained
215
+ before/after/current evidence for explicit leave-current settlement. It does not
216
+ discard an interrupted operation merely because a request failed. Concurrent
217
+ source conflicts preserve the draft and require explicit merged-draft review
218
+ against the current revision. Saves/undo freeze the workspace while in flight
219
+ so a response cannot silently replace typing entered during the request.
220
+
221
+
222
+ The default document view edits supported Markdown blocks inline. It retains
223
+ raw source for untouched blocks, comments, and gaps; line-diff publication
224
+ preserves their original line endings. Enter and slash/plus menus create
225
+ blocks, task controls change existing checkboxes, and selected text supports
226
+ basic formatting. Code edits as plain text. Tables, nested lists, unclosed
227
+ fences, and unsupported block syntax have an explicit Markdown fallback.
228
+ Managed lifecycle history is read only in the block view. New/removal/reordering
229
+ of native anchors still belongs to domain operations; the core remains the
230
+ final validator. Draft undo/redo is distinct from compensating Undo save.
231
+ Inline changes autosave only the private draft; reviewed Save publishes source.
232
+ Pasted/dropped content is inserted as text, never authored HTML.
@@ -0,0 +1,162 @@
1
+ // runlist's OpenCode integration. Installed by `runlist install opencode`, which
2
+ // copies this file (with a version banner prepended) to the OpenCode plugin
3
+ // directory, where OpenCode auto-discovers it — it globs
4
+ // `{plugin,plugins}/*.{ts,js}` under `.opencode/` and the global config dir, so
5
+ // no `opencode.json` edit is needed.
6
+ //
7
+ // TWO CONSTRAINTS, both load-bearing:
8
+ //
9
+ // 1. EXACTLY ONE EXPORT. OpenCode treats every export of a plugin module as a
10
+ // plugin factory and throws `Plugin export is not a function` on anything
11
+ // that isn't one — so an exported helper wouldn't just be untidy, it would
12
+ // be *called* as a second plugin. Helpers stay module-local.
13
+ //
14
+ // 2. Only a deliberate guard denial may throw. OpenCode awaits hooks inside
15
+ // the chat request, so every other failure degrades to a no-op.
16
+ //
17
+ // Runs under Bun inside the OpenCode process. Node builtins only, no deps.
18
+
19
+ import { execFile } from 'node:child_process';
20
+
21
+ // The primer is a nicety; identity is a correctness invariant. They are split
22
+ // across two hooks deliberately: `shell.env` is stable API, while
23
+ // `experimental.chat.system.transform` may be renamed by OpenCode. If it is,
24
+ // priming stops and ownership keeps working — the failure lands on the half
25
+ // that can afford it.
26
+ const PRIMER_TTL_MS = 60_000;
27
+ const PRIMER_TIMEOUT_MS = 5_000;
28
+
29
+ function cliExecutables() {
30
+ return process.platform === 'win32' ? ['runlist.cmd', 'dotmd.cmd'] : ['runlist', 'dotmd'];
31
+ }
32
+
33
+ // Runs the CLI and resolves its trimmed stdout, or '' on any failure. `input`,
34
+ // when given, is written to the child's stdin.
35
+ function runCli(directory, args, input = null) {
36
+ return new Promise(resolve => {
37
+ let settled = false;
38
+ const done = value => { if (!settled) { settled = true; resolve(value); } };
39
+ const candidates = cliExecutables();
40
+ const attempt = index => {
41
+ if (index >= candidates.length) { done(''); return; }
42
+ try {
43
+ const child = execFile(candidates[index], args, {
44
+ cwd: directory,
45
+ timeout: PRIMER_TIMEOUT_MS,
46
+ windowsHide: true,
47
+ // Node refuses to execFile a `.cmd` without a shell (EINVAL since the
48
+ // 2024 batch-file fix), and npm installs the CLI on Windows as one.
49
+ // The arguments are fixed strings, never user input.
50
+ shell: process.platform === 'win32',
51
+ env: { ...process.env, NO_COLOR: '1' },
52
+ }, (error, stdout) => {
53
+ if (error?.code === 'ENOENT') attempt(index + 1);
54
+ else done(error ? '' : (stdout ?? '').trim());
55
+ });
56
+ child.stdin?.on('error', () => {});
57
+ if (input !== null) child.stdin?.end(input);
58
+ else child.stdin?.end();
59
+ } catch { attempt(index + 1); }
60
+ };
61
+ attempt(0);
62
+ });
63
+ }
64
+
65
+ // OpenCode runs no Claude Code hooks. Evaluate recognized prompt reads before
66
+ // the tool so opt-in strict mode can block them. Its V1 before hook cannot
67
+ // return model context, so ordinary warnings are appended to the result.
68
+ const PROMPT_FILE = /(^|[\\/])prompts[\\/]\S*\.md\b/;
69
+
70
+ function guardPayload(tool, args) {
71
+ if (tool === 'read' && typeof args?.filePath === 'string' && PROMPT_FILE.test(args.filePath)) {
72
+ return { tool_name: 'Read', tool_input: { file_path: args.filePath } };
73
+ }
74
+ if (tool === 'bash' && typeof args?.command === 'string' && PROMPT_FILE.test(args.command)) {
75
+ return { tool_name: 'Bash', tool_input: { command: args.command } };
76
+ }
77
+ return null;
78
+ }
79
+
80
+ export default async function runlistOpencodePlugin({ directory }) {
81
+ // Keyed by session so a subagent session primes independently, the way
82
+ // SubagentStart does under Claude Code.
83
+ const primers = new Map();
84
+ const guardDecisions = new Map();
85
+
86
+ async function guardFor(tool, args) {
87
+ const payload = guardPayload(tool, args);
88
+ if (!payload) return null;
89
+ const raw = await runCli(directory, ['guard'], JSON.stringify(payload));
90
+ return raw ? JSON.parse(raw)?.hookSpecificOutput ?? null : null;
91
+ }
92
+
93
+ async function primerFor(sessionId) {
94
+ const key = sessionId ?? '';
95
+ const cached = primers.get(key);
96
+ // Refreshed on a TTL rather than cached for the session's life: OpenCode
97
+ // rebuilds the system prompt every turn, so a once-only push would reach
98
+ // only the first request — and a never-refreshed one would keep announcing
99
+ // a pending prompt this session already consumed.
100
+ if (cached && Date.now() - cached.at < PRIMER_TTL_MS) return cached.text;
101
+ const text = await runCli(directory, ['hud']);
102
+ primers.set(key, { text, at: Date.now() });
103
+ return text;
104
+ }
105
+
106
+ return {
107
+ 'tool.execute.before': async (input, output) => {
108
+ let refusal = null;
109
+ try {
110
+ const decision = await guardFor(input?.tool, output?.args);
111
+ if (input?.callID && decision) guardDecisions.set(input.callID, decision);
112
+ if (decision?.permissionDecision === 'deny') {
113
+ if (input?.callID) guardDecisions.delete(input.callID);
114
+ refusal = decision.permissionDecisionReason;
115
+ }
116
+ } catch { /* a missing/broken guard must not fail the chat turn */ }
117
+ if (refusal) throw new Error(`[runlist] ${refusal}`);
118
+ },
119
+ // Ownership identity. OpenCode sets no session-id variable of its own, and
120
+ // `OPENCODE_PID` — what runlist falls back to without this plugin — names the
121
+ // OpenCode *process*, so every session in one TUI shares it and can release
122
+ // the others' plans. This is the only place the real session id is
123
+ // available to a tool shell.
124
+ 'shell.env': async (input, output) => {
125
+ try {
126
+ if (input?.sessionID) {
127
+ output.env.RUNLIST_SESSION_ID = `opencode:${input.sessionID}`;
128
+ // Legacy name, kept for an older CLI (before 0.77.0) still on PATH.
129
+ output.env.DOTMD_SESSION_ID = `opencode:${input.sessionID}`;
130
+ }
131
+ // The OpenCode server process hosts the session and outlives every tool
132
+ // shell, so it is the process whose liveness answers "is this claim's
133
+ // owner still there?" — `runlist doctor --claims` probes exactly this.
134
+ output.env.RUNLIST_SESSION_PID = String(process.pid);
135
+ output.env.DOTMD_SESSION_PID = String(process.pid);
136
+ } catch { /* never break a shell over this */ }
137
+ },
138
+
139
+ // The guard's warnings, appended after the call ran. A `deny` from the
140
+ // guard is ignored here: the call has already happened, so there is
141
+ // nothing left to refuse.
142
+ 'tool.execute.after': async (input, output) => {
143
+ try {
144
+ if (typeof output?.output !== 'string') return;
145
+ const decision = input?.callID ? guardDecisions.get(input.callID) : null;
146
+ if (input?.callID) guardDecisions.delete(input.callID);
147
+ const note = (decision ?? await guardFor(input?.tool, input?.args))?.additionalContext;
148
+ if (typeof note === 'string' && note) output.output += `\n\n${note}`;
149
+ } catch { /* a guard failure never touches the tool result */ }
150
+ },
151
+
152
+ // Session priming — the equivalent of the SessionStart hook that runs
153
+ // `runlist hud` under Claude Code. Silent outside a runlist repo (hud prints
154
+ // nothing and exits 0), so this is inert in unrelated projects.
155
+ 'experimental.chat.system.transform': async (input, output) => {
156
+ try {
157
+ const text = await primerFor(input?.sessionID);
158
+ if (text) output.system.push(text);
159
+ } catch { /* priming is best-effort */ }
160
+ },
161
+ };
162
+ }