opencode-swarm 7.135.1 → 7.135.3

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 (99) hide show
  1. package/.opencode/skills/critic-gate/SKILL.md +10 -0
  2. package/.opencode/skills/swarm-pr-feedback/SKILL.md +24 -21
  3. package/.opencode/skills/swarm-pr-review/SKILL.md +55 -42
  4. package/dist/agents/explorer.d.ts +1 -1
  5. package/dist/agents/index.d.ts +12 -0
  6. package/dist/background/candidate-contract.d.ts +113 -0
  7. package/dist/background/candidate-parser.d.ts +8 -29
  8. package/dist/background/pr-review-trigger-contract.d.ts +197 -0
  9. package/dist/background/workspace-snapshot.d.ts +26 -0
  10. package/dist/cli/{config-doctor-6398t3a6.js → config-doctor-4nznqeca.js} +3 -2
  11. package/dist/cli/{core-9876t6cs.js → core-4z1s2ak1.js} +3 -2
  12. package/dist/cli/{curation-policy-f7qz4tez.js → curation-policy-81m14519.js} +5 -5
  13. package/dist/cli/{curator-drift-9q6f85bm.js → curator-drift-jpap6hv4.js} +2 -2
  14. package/dist/cli/{curator-fjhyezyx.js → curator-e0z5gcww.js} +28 -27
  15. package/dist/cli/curator-llm-factory-fem13y9n.js +57 -0
  16. package/dist/cli/{dispatch-gxbeb2ps.js → dispatch-b1fge4xk.js} +2 -2
  17. package/dist/cli/{evidence-summary-service-0cczdmjv.js → evidence-summary-service-w006jnpg.js} +6 -6
  18. package/dist/cli/{explorer-jp0nen03.js → explorer-15gqzxh8.js} +1 -1
  19. package/dist/cli/{gate-evidence-8zrwdj69.js → gate-evidence-aenyz6vt.js} +4 -4
  20. package/dist/cli/guardrail-explain-wgaj23hd.js +58 -0
  21. package/dist/cli/{guardrail-log-bd45kw0x.js → guardrail-log-04p1z1vt.js} +5 -4
  22. package/dist/cli/{hive-promoter-f2yh22m1.js → hive-promoter-k345z42k.js} +28 -27
  23. package/dist/cli/{index-wdkkf3xg.js → index-168phrs7.js} +12 -10
  24. package/dist/cli/{index-xhpmagdm.js → index-30pg2rs3.js} +2 -2
  25. package/dist/cli/{index-bjrkg4tc.js → index-36v55ryg.js} +2 -2
  26. package/dist/cli/index-3jcyn8g6.js +1219 -0
  27. package/dist/cli/{index-7an2qn0s.js → index-45t7w06b.js} +2 -2
  28. package/dist/cli/{index-2ghkk9ve.js → index-4qzeef9h.js} +1 -1
  29. package/dist/cli/{index-4pdrmemy.js → index-5436jxnw.js} +2 -2
  30. package/dist/cli/{index-5vp9gka3.js → index-a01r818w.js} +5 -3
  31. package/dist/cli/{index-nbnqf4fj.js → index-bcmrg4t4.js} +1 -1
  32. package/dist/cli/{index-fwkqq9mq.js → index-c9ddxv4k.js} +57 -8
  33. package/dist/cli/{index-ens0gt38.js → index-cggqh2dz.js} +7 -7
  34. package/dist/cli/{index-vxv732ex.js → index-dg1njnms.js} +7 -4
  35. package/dist/cli/{index-aky90eqz.js → index-dwargd10.js} +11596 -10043
  36. package/dist/cli/{index-yhba8swx.js → index-dzyjb33e.js} +1 -1
  37. package/dist/cli/{index-xg9repnx.js → index-ey29aap6.js} +1 -1
  38. package/dist/cli/{index-5wx8wdxg.js → index-f52jt0am.js} +5 -5
  39. package/dist/cli/{index-2nqsr1dx.js → index-gxxxmajn.js} +4 -4
  40. package/dist/cli/{index-jtshf1pd.js → index-kb7qy7an.js} +4 -2
  41. package/dist/cli/{index-b7p0mp2q.js → index-mrtms113.js} +4 -4
  42. package/dist/cli/{index-dzaarxhg.js → index-p5kr35wh.js} +8 -1210
  43. package/dist/cli/{index-j89zmcpx.js → index-p9ngg568.js} +6 -6
  44. package/dist/cli/{index-g1drmgxe.js → index-rv7s2k9y.js} +3 -3
  45. package/dist/cli/{index-z085b4ka.js → index-tcn457d5.js} +1 -1
  46. package/dist/cli/{index-fpkb3mw1.js → index-vg3yx648.js} +1 -1
  47. package/dist/cli/{index-13xxjfhn.js → index-vm4xw9z3.js} +1 -1
  48. package/dist/cli/{index-jxchddjk.js → index-wygxzvtf.js} +6 -4
  49. package/dist/cli/{index-b4z7s917.js → index-x20cgr2q.js} +1 -1
  50. package/dist/cli/{index-qe3v54nb.js → index-y111zefa.js} +64 -23
  51. package/dist/cli/{index-w80r3axq.js → index-y47crdg9.js} +34 -32
  52. package/dist/cli/{index-3nasn371.js → index-y6a7gjtj.js} +126 -42
  53. package/dist/cli/{index-247b1c1m.js → index-yf30m841.js} +3 -3
  54. package/dist/cli/index.js +31 -29
  55. package/dist/cli/{knowledge-escalator-4tp3yy7e.js → knowledge-escalator-42ecv7m3.js} +6 -6
  56. package/dist/cli/{knowledge-events-7854gb3p.js → knowledge-events-zhna7v3g.js} +4 -4
  57. package/dist/cli/{knowledge-link-etrfntkd.js → knowledge-link-mm1w967j.js} +3 -3
  58. package/dist/cli/{knowledge-store-cxnr6v01.js → knowledge-store-56jcfc5k.js} +4 -4
  59. package/dist/cli/{knowledge-validator-3n5t55qy.js → knowledge-validator-8zk0pnb6.js} +7 -7
  60. package/dist/cli/{pending-delegations-4z3tkkct.js → pending-delegations-qajsxct0.js} +4 -4
  61. package/dist/cli/{pr-subscriptions-29j794kd.js → pr-subscriptions-qhr41epq.js} +4 -4
  62. package/dist/cli/{scan-cursor-0cexjh3b.js → scan-cursor-h3ndxd41.js} +5 -5
  63. package/dist/cli/{schema-rs5a7ywx.js → schema-r6wmd7zp.js} +2 -1
  64. package/dist/cli/{scope-persistence-1vsnkmm4.js → scope-persistence-h2fpgxww.js} +3 -3
  65. package/dist/cli/{skill-generator-n0h9n0zd.js → skill-generator-12cftsx4.js} +8 -8
  66. package/dist/cli/{workspace-snapshot-9h5za0s6.js → workspace-snapshot-jmyamqnv.js} +6 -1
  67. package/dist/cli/{worktree-collision-ownership-dtwr0xj4.js → worktree-collision-ownership-13btcj9g.js} +5 -5
  68. package/dist/commands/approve-plan-critic.d.ts +21 -0
  69. package/dist/commands/registry.d.ts +9 -1
  70. package/dist/config/cache-paths.d.ts +48 -0
  71. package/dist/config/constants.d.ts +18 -0
  72. package/dist/config/host-path.d.ts +95 -0
  73. package/dist/config/lane-context.d.ts +117 -0
  74. package/dist/config/lane-permissions.d.ts +277 -0
  75. package/dist/config/schema.d.ts +10 -0
  76. package/dist/config/swarm-branch.d.ts +130 -0
  77. package/dist/hooks/delegation-gate.d.ts +42 -1
  78. package/dist/hooks/pr-workflow-gate.d.ts +30 -4
  79. package/dist/hooks/repo-graph-builder.d.ts +18 -0
  80. package/dist/hooks/skill-propagation-gate.d.ts +10 -0
  81. package/dist/index.js +459 -443
  82. package/dist/memory/schema.d.ts +4 -4
  83. package/dist/tools/approve-plan-critic.d.ts +5 -0
  84. package/dist/tools/convene-council.d.ts +3 -3
  85. package/dist/tools/dispatch-lanes.d.ts +65 -7
  86. package/dist/tools/index.d.ts +1 -0
  87. package/dist/tools/manifest.d.ts +1 -0
  88. package/dist/tools/repo-graph/builder.d.ts +20 -2
  89. package/dist/tools/repo-graph/incremental.d.ts +18 -1
  90. package/dist/tools/repo-graph/ontology.d.ts +8 -0
  91. package/dist/tools/repo-graph/types.d.ts +62 -1
  92. package/dist/tools/repo-graph.d.ts +1 -1
  93. package/dist/tools/submit-phase-council-verdicts.d.ts +3 -3
  94. package/dist/tools/tool-metadata.d.ts +4 -0
  95. package/dist/tools/write-final-council-evidence.d.ts +3 -3
  96. package/dist/tools/write-pr-review-trigger-eval.d.ts +34 -63
  97. package/package.json +1 -1
  98. package/dist/cli/curator-llm-factory-wr3zmy2c.js +0 -56
  99. package/dist/cli/guardrail-explain-g1s89b3e.js +0 -57
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Swarm worktree-lane context detection.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * OpenCode partitions **all** permission state by *directory*. Every service
7
+ * that matters — `Permission.state`, `Agent.state`, `Plugin.state`,
8
+ * `ToolRegistry.state` — is built through the same directory-keyed
9
+ * `InstanceState` cache. When opencode-swarm creates a worktree-lane session
10
+ * bound to a new directory (`session.create({ query: { directory: lanePath } })`),
11
+ * that lane gets a brand-new permission universe: an empty `approved` list, so
12
+ * every prior "Allow always" is forgotten, and a private pending map. Because a
13
+ * lane instance has no TUI attached, an `external_directory` prompt raised there
14
+ * can never be answered and the lane hangs forever (the host's `Permission.ask`
15
+ * awaits its deferred with no timeout).
16
+ *
17
+ * The fix is to pre-resolve permissions for lane instances via the plugin
18
+ * `config` hook. That requires answering one question cheaply and reliably:
19
+ * **is this directory a swarm worktree lane, and if so, what project is it a
20
+ * worktree of?**
21
+ *
22
+ * ## Why there is no `git` subprocess here
23
+ *
24
+ * The obvious implementation is
25
+ * `git -C <lane> rev-parse --path-format=absolute --git-common-dir`. This module
26
+ * deliberately does not do that. `resolveLaneContext` is called from the plugin
27
+ * `config` hook, which the host runs inside `Plugin.state` initialisation —
28
+ * squarely on the plugin-init path that AGENTS.md invariant 1 governs, and that
29
+ * invariant names Git commands explicitly as forbidden there.
30
+ *
31
+ * Instead this module reads the two files git itself would consult:
32
+ *
33
+ * - `<lane>/.git` — in a linked worktree this is a *file*, not a directory,
34
+ * containing `gitdir: <main>/.git/worktrees/<name>`.
35
+ * - `<main>/.git/worktrees/<name>/commondir` — a relative pointer back to the
36
+ * shared `.git` directory (normally `../..`).
37
+ *
38
+ * That is the same information the subprocess would return, obtained with at
39
+ * most two small synchronous reads, no child process, no timeout to get wrong,
40
+ * and nothing to kill in a `finally`. It is strictly safer on the init path than
41
+ * a spawn would be, and it removes an entire class of invariant-3 exposure.
42
+ *
43
+ * @module config/lane-context
44
+ */
45
+ import { addDeferredWarning } from '../services/warning-buffer';
46
+ /**
47
+ * A resolved swarm worktree lane.
48
+ */
49
+ export interface LaneContext {
50
+ /** Absolute, resolved path of the lane worktree itself. */
51
+ lanePath: string;
52
+ /**
53
+ * Absolute, resolved path of the project this lane is a git worktree of —
54
+ * i.e. the main working tree that owns the shared `.git` directory.
55
+ */
56
+ parentProjectPath: string;
57
+ }
58
+ /**
59
+ * Test-only dependency-injection seam (AGENTS.md invariant 7 — prefer
60
+ * `_internals` over `mock.module`, which leaks across files in Bun's shared
61
+ * test-runner process). Tests replace these to simulate unreadable `.git`
62
+ * files, malformed pointers, and permission errors. Restore in `afterEach`.
63
+ */
64
+ export declare const _internals: {
65
+ readFileSync: (p: string, enc: BufferEncoding) => string;
66
+ statSync: (p: string) => {
67
+ isFile(): boolean;
68
+ };
69
+ addDeferredWarning: typeof addDeferredWarning;
70
+ /**
71
+ * Clears the detection cache. Test-only; production has no reason to call
72
+ * it because a directory's lane-ness cannot change while the process holds
73
+ * an OpenCode instance for that directory.
74
+ */
75
+ clearCache: () => void;
76
+ };
77
+ /**
78
+ * Decides whether `directory` is a swarm worktree lane and, if so, resolves the
79
+ * lane path and the parent project it is a worktree of.
80
+ *
81
+ * Detection requires two independent conditions, the second of which may be
82
+ * satisfied either way:
83
+ *
84
+ * 1. REQUIRED — the directory (or a bounded number of its ancestors) is a real
85
+ * LINKED git worktree whose main working tree resolves. A main working tree
86
+ * has a `.git` directory rather than a file and is never a lane.
87
+ * 2. REQUIRED — the worktree is swarm-OWNED, established by EITHER
88
+ * (a) its branch matching the full grammar in `./swarm-branch.ts`
89
+ * (`swarm/<purpose>/<sessionId>/<id>` or `swarm-lane/<sessionId>/<id>`,
90
+ * with `<sessionId>` of the form `ses_…`) — the authoritative,
91
+ * path-independent signal; OR
92
+ * (b) the path sitting under a `.swarm-worktrees` base — the fallback for a
93
+ * worktree whose HEAD is detached or unreadable, so its branch cannot
94
+ * be consulted.
95
+ *
96
+ * (2a) is a full-grammar match rather than a `swarm/` prefix test on purpose: a
97
+ * user-authored `swarm/my-own-experiment` worktree must NOT be captured. A
98
+ * false positive is worse than a false negative here — see the module note in
99
+ * `./swarm-branch.ts`.
100
+ *
101
+ * A detached-HEAD swarm lane created OUTSIDE `.swarm-worktrees` (a
102
+ * `worktree_dir` override, or the Windows path-budget fallback) satisfies
103
+ * neither branch of (2) and is a false NEGATIVE: no permission changes, i.e.
104
+ * today's behaviour. That is the safe direction and is left as-is.
105
+ *
106
+ * NEVER throws. Any error — nonexistent directory, unreadable `.git`, malformed
107
+ * pointer, permission error, non-string input — yields `null` ("not a lane"),
108
+ * which preserves today's behaviour for ordinary sessions. That direction is
109
+ * the safe one: a false negative means "no permission changes at all", while a
110
+ * false positive would apply a deny-by-default ruleset to a normal project.
111
+ *
112
+ * @param directory - Directory to classify (typically the plugin's own
113
+ * `ctx.directory`, which under this host IS the instance
114
+ * directory).
115
+ * @returns The resolved lane context, or `null` when `directory` is not a lane.
116
+ */
117
+ export declare function resolveLaneContext(directory: string): LaneContext | null;
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Scoped `external_directory` permission rules for swarm worktree-lane
3
+ * instances.
4
+ *
5
+ * ## The problem this solves
6
+ *
7
+ * A lane session is created against a new directory, so OpenCode builds it a
8
+ * fresh directory-keyed permission universe with an empty `approved` list. The
9
+ * host's default agent ruleset ends in `external_directory: { "*": "ask" }`, and
10
+ * a lane instance has no TUI attached to answer an ask — `Permission.ask` parks
11
+ * on a deferred with no timeout and the lane hangs forever.
12
+ *
13
+ * ## The mechanism
14
+ *
15
+ * The plugin `config` hook runs once per OpenCode instance (`Plugin.state` is
16
+ * built through the same directory-keyed `InstanceState` cache as
17
+ * `Permission.state`), so in a lane instance it runs with `ctx.directory` set to
18
+ * the lane path. Rules merged into the top-level `permission` block there are
19
+ * folded by the host into **every** agent's ruleset:
20
+ *
21
+ * ```js
22
+ * // opencode host, Agent.state
23
+ * l = a.fromConfig(p.permission ?? {}) // top-level config permission
24
+ * r = { build: { permission: a.merge(c, ..., l) }, plan: {...}, general: {...},
25
+ * explore: {...}, compaction: {...}, title: {...}, summary: {...} }
26
+ * for (const [n, s] of Object.entries(p.agent ?? {})) {
27
+ * let e = r[n];
28
+ * if (!e) e = r[n] = { ..., permission: a.merge(c, l), ... }; // swarm agents
29
+ * e.permission = a.merge(e.permission, a.fromConfig(s.permission ?? {}));
30
+ * }
31
+ * ```
32
+ *
33
+ * Top-level is therefore strictly broader than per-agent injection: it reaches
34
+ * the host-native agents (`build`, `plan`, `general`, `explore`, ...) that a
35
+ * lane can also run, and it lands *before* any per-agent block so explicit
36
+ * per-agent user config still wins. Injecting the same rules per-agent as well
37
+ * would duplicate every rule in every ruleset and would make our injected rules
38
+ * outrank the user's own top-level entries — the wrong precedence.
39
+ *
40
+ * ## DISCLOSED ASSUMPTION: hook-before-agents ordering
41
+ *
42
+ * This design requires the `config` hook to run before `Agent.state` reads the
43
+ * config. What is PROVEN: both sides touch the *same* object — `Plugin.state`
44
+ * does `U = yield* Config.get()` and mutates `U` in place, `Agent.state` does
45
+ * `p = yield* Config.get()`, and `Config.state` is InstanceState-cached, so
46
+ * `get()` returns one shared instance. There is no copy; only ordering is in
47
+ * question.
48
+ *
49
+ * What is NOT proven: any static ordering guarantee. Two bounded searches of
50
+ * the host binary came back negative — the Agent layer's deps
51
+ * (`deps:[J.node,O.node,P.node,_.node,$.node,Ve]`, offset 100817167) do not
52
+ * include the Plugin node (the three symbols the Agent chunk imports from the
53
+ * Plugin chunk are `$G`, `b8`, `r1`, none of which is the Plugin service), and
54
+ * `Plugin.init` (offset 102128681) has no caller anywhere in the bundle. The
55
+ * hook therefore fires lazily, when `Plugin.state` is first populated by a
56
+ * `Plugin.trigger` / `Plugin.list`.
57
+ *
58
+ * The evidence that it holds is empirical and strong: opencode-swarm's agents
59
+ * are registered ONLY by this config hook, and they demonstrably appear in the
60
+ * TUI, which is impossible unless the hook ran before `Agent.state` was built.
61
+ * Treat it as a verified-in-practice property of this host build rather than a
62
+ * contract. If a future OpenCode release reorders layer construction, the
63
+ * symptom is loud and immediate — no swarm agents at all — not a silent
64
+ * permission regression.
65
+ *
66
+ * ## Rule ordering is load-bearing
67
+ *
68
+ * The host evaluates with `findLast` over the flattened rule list:
69
+ *
70
+ * ```js
71
+ * function c(j, J, ...K) {
72
+ * return K.flat().findLast((z) => g.match(j, z.permission) && g.match(J, z.pattern))
73
+ * ?? { action: "ask", permission: j, pattern: "*" };
74
+ * }
75
+ * ```
76
+ *
77
+ * and `fromConfig` preserves `Object.entries` order. So **later wins**, and the
78
+ * catch-all `"*": "deny"` must be emitted FIRST, before the specific allows.
79
+ * Emitting it last would deny everything. `tests/unit/config/lane-permissions.test.ts`
80
+ * pins this against a faithful re-implementation of the host's own
81
+ * `fromConfig`/`merge`/`evaluate`, so the ordering contract is verified rather
82
+ * than assumed.
83
+ *
84
+ * Two further host behaviours matter and are deliberately relied upon:
85
+ *
86
+ * - `Wildcard.match` compiles the rule pattern with `*` -> `.*` under the `s`
87
+ * (dotAll) flag, so `<dir>/*` covers the entire subtree, and it normalises
88
+ * `\` to `/` on both sides before matching, so native Windows paths are fine.
89
+ * - After merging config, the host appends
90
+ * `external_directory: { <Global.Path.data>/tool-output/*: "allow" }` to every
91
+ * agent unless a rule already matches that exact pattern with `action: "deny"`.
92
+ * Our catch-all uses pattern `"*"`, which does not satisfy that exact-string
93
+ * check, so the append still happens and still lands last. Tool output stays
94
+ * readable. The emitted ruleset is intentionally not the final ruleset.
95
+ *
96
+ * @module config/lane-permissions
97
+ */
98
+ import { addDeferredWarning } from '../services/warning-buffer';
99
+ import type { LaneContext } from './lane-context';
100
+ import { resolveLaneContext } from './lane-context';
101
+ /**
102
+ * Lane permission policy. Mirrors `worktree.lane_permissions` in
103
+ * `src/config/schema.ts`.
104
+ */
105
+ export type LanePermissionMode = 'scoped_allow' | 'deny' | 'off';
106
+ /** Permission actions OpenCode understands for a rule. */
107
+ type PermissionAction = 'allow' | 'ask' | 'deny';
108
+ /** Result of assembling a lane's `external_directory` rule map. */
109
+ interface LaneRuleBuild {
110
+ /** The rule map, in emission (== evaluation) order. */
111
+ rules: Record<string, PermissionAction>;
112
+ /**
113
+ * User-configured patterns whose `"ask"` was coerced to `"deny"` because a
114
+ * lane has no TUI that could answer an ask. Empty in the common case.
115
+ */
116
+ coercedAskPatterns: string[];
117
+ }
118
+ /** An allowlisted directory plus the justification for granting it. */
119
+ interface LaneAllowlistEntry {
120
+ /** Absolute directory path. */
121
+ dir: string;
122
+ /**
123
+ * The emitted `external_directory` rule pattern for {@link dir}.
124
+ *
125
+ * Computed ONCE here and reused by both consumers — the rule map and the
126
+ * `.swarm/events.jsonl` record. Deriving it twice meant a second round of
127
+ * `realpathSync.native` per entry on the config-hook path and, more
128
+ * importantly, left room for the two to disagree: if they ever did, the
129
+ * event log would misreport the rule that was actually emitted, which is
130
+ * precisely the observability this subsystem exists to provide.
131
+ */
132
+ pattern: string;
133
+ /** Why this directory is justified — recorded in `.swarm/events.jsonl`. */
134
+ reason: string;
135
+ }
136
+ /**
137
+ * Converts an absolute directory into the `external_directory` rule pattern
138
+ * that covers it and everything beneath it.
139
+ *
140
+ * The literal part of the directory is escaped for the host's matcher. The host
141
+ * compiles a rule pattern as:
142
+ *
143
+ * ```js
144
+ * o.replace(/[.+^${}()|[\]\\]/g,"\\$&").replace(/\*​/g,".*").replace(/\?/g,".")
145
+ * // then: new RegExp("^"+compiled+"$","si")
146
+ * ```
147
+ *
148
+ * Note what is NOT escaped: `*` and `?`. A directory legitimately named `a*`
149
+ * (legal on POSIX) would compile to `a.*` and silently grant every sibling
150
+ * starting with `a`; `?` becomes a single-character wildcard the same way. The
151
+ * matcher offers no escape syntax for either, so such a directory cannot be
152
+ * expressed exactly. {@link isExpressibleDirectory} therefore drops it from the
153
+ * allowlist rather than emitting an over-broad grant: dropping fails safe (the
154
+ * path is denied, and its absence is visible in the event log), emitting would
155
+ * fail open.
156
+ *
157
+ * The matcher also uses the `i` flag, so patterns are case-insensitive even on
158
+ * case-sensitive filesystems. `/home/u/Work/*` will therefore also match
159
+ * `/home/u/work/...`. That widening is inherent to the host and cannot be
160
+ * avoided from a rule pattern; it is recorded here so it is a known, reviewed
161
+ * property rather than a surprise.
162
+ */
163
+ declare function laneDirectoryPattern(dir: string): string;
164
+ /**
165
+ * Builds the justified `external_directory` allowlist for a lane.
166
+ *
167
+ * Every entry is a directory the lane provably needs in order to do the work it
168
+ * was created for. Nothing is added "just in case" — an over-broad allowlist
169
+ * here is the security cost of this fix, so each entry carries its reason and
170
+ * is asserted in tests.
171
+ *
172
+ * @param lane - Resolved lane context.
173
+ * @returns Deduplicated absolute directories with justifications.
174
+ */
175
+ declare function buildLaneAllowlist(lane: LaneContext, configuredSkillPaths?: readonly string[], configuredSkillUrls?: readonly string[]): LaneAllowlistEntry[];
176
+ /**
177
+ * Builds the `external_directory` rule map to write into the top-level
178
+ * `permission` block of a lane instance's config.
179
+ *
180
+ * Emission order (which IS the evaluation order, later-wins):
181
+ *
182
+ * 1. `"*": "deny"` — our catch-all, so nothing can be left pending in a lane
183
+ * that has no TUI to answer it. This is the only mechanism that delivers
184
+ * the approved policy's "answered, not hung" guarantee now that the host's
185
+ * `permission.ask` plugin hook is confirmed dead (never triggered by
186
+ * opencode 1.18.10).
187
+ * 2. Our justified allowlist — beats the catch-all because it comes later.
188
+ * 3. Whatever the user already configured — beats everything, because explicit
189
+ * user configuration must always outrank a plugin default. A user entry
190
+ * keyed `"*"` replaces our catch-all's value in place (same key), so
191
+ * `external_directory: "allow"` in `opencode.json` still means allow-all.
192
+ *
193
+ * ## Why `"ask"` is coerced to `"deny"` INSIDE A LANE
194
+ *
195
+ * A user's `allow` and `deny` are honoured verbatim. `ask` is not, and that is
196
+ * deliberate: a lane instance has no TUI attached, so `ask` cannot be answered
197
+ * by anyone. It is not a third policy choice there — it is a guaranteed
198
+ * indefinite hang, which is the exact defect this module exists to remove.
199
+ * Honouring it literally would let a top-level `external_directory: "ask"` (or
200
+ * any `"ask"` pattern) silently reinstate the bug while this code reported
201
+ * success. Coercion resolves to the fail-safe direction (`deny`, never
202
+ * `allow`), the affected patterns are named in the advisory, and they are
203
+ * recorded under `coercedAskPatterns` in the `.swarm/events.jsonl` entry so the
204
+ * decision is auditable rather than silent. Users who genuinely want the
205
+ * prompting behaviour back have `worktree.lane_permissions: "off"`.
206
+ *
207
+ * @param mode - Configured lane permission policy.
208
+ * @param lane - Resolved lane context.
209
+ * @param existing - Current `external_directory` value from the config being
210
+ * mutated, if any.
211
+ * @returns The rule map plus any patterns whose `ask` was coerced, or `null`
212
+ * when the policy is `off` (the caller must then touch nothing).
213
+ */
214
+ declare function buildLaneExternalDirectoryRules(mode: LanePermissionMode, lane: LaneContext, existing?: unknown, precomputedAllowlist?: readonly LaneAllowlistEntry[]): LaneRuleBuild | null;
215
+ /**
216
+ * Renders the operator-facing remedy text for a lane whose permissions are
217
+ * being scoped.
218
+ *
219
+ * The permission system itself carries an action, not a message — a `deny` rule
220
+ * cannot explain itself. So the explanation is delivered out-of-band (a deferred
221
+ * warning plus a `.swarm/events.jsonl` record) and names the exact
222
+ * `opencode.json` edit that widens the allowlist.
223
+ */
224
+ declare function renderLanePermissionAdvisory(mode: Exclude<LanePermissionMode, 'off'>, lane: LaneContext, allowlist: readonly LaneAllowlistEntry[], coercedAskPatterns?: readonly string[]): string;
225
+ /**
226
+ * Tier-0 test seam (see the writing-tests skill): these are pure functions with
227
+ * no external dependencies, so they are tested directly rather than mocked.
228
+ * They are intentionally NOT part of the module's public API — only
229
+ * {@link applyLanePermissions} is called from production.
230
+ */
231
+ export declare const _test_exports: {
232
+ buildLaneAllowlist: typeof buildLaneAllowlist;
233
+ buildLaneExternalDirectoryRules: typeof buildLaneExternalDirectoryRules;
234
+ laneDirectoryPattern: typeof laneDirectoryPattern;
235
+ renderLanePermissionAdvisory: typeof renderLanePermissionAdvisory;
236
+ };
237
+ /**
238
+ * Test-only dependency-injection seam (AGENTS.md invariant 7). Tests replace
239
+ * these to assert the event record and advisory without touching the real
240
+ * filesystem or the process-wide warning buffer. Restore in `afterEach`.
241
+ */
242
+ export declare const _internals: {
243
+ resolveLaneContext: typeof resolveLaneContext;
244
+ addDeferredWarning: typeof addDeferredWarning;
245
+ appendFileSync: (p: string, data: string, enc: BufferEncoding) => void;
246
+ mkdirSync: (p: string, opts: {
247
+ recursive: boolean;
248
+ }) => string | undefined;
249
+ };
250
+ /** Outcome of {@link applyLanePermissions}, returned for tests and logging. */
251
+ export interface LanePermissionApplication {
252
+ /** `true` when the directory was recognised as a swarm worktree lane. */
253
+ lane: boolean;
254
+ /** Effective policy. `undefined` when `lane` is `false`. */
255
+ mode?: LanePermissionMode;
256
+ /** Rules written into `permission.external_directory`, when any. */
257
+ rules?: Record<string, PermissionAction>;
258
+ }
259
+ /**
260
+ * Applies scoped lane permissions to an OpenCode config object, in place.
261
+ *
262
+ * Called from the plugin `config` hook. When `directory` is not a swarm
263
+ * worktree lane this returns immediately and mutates NOTHING — ordinary
264
+ * sessions must be byte-for-byte unaffected, which
265
+ * `tests/unit/config/lane-permissions-config.test.ts` asserts directly.
266
+ *
267
+ * @param opencodeConfig - The live merged config object the host handed to the
268
+ * `config` hook. Mutated in place, as the hook contract
269
+ * requires.
270
+ * @param directory - The plugin's own `ctx.directory`. Under this host that
271
+ * IS the instance directory, so in a lane instance it is
272
+ * the lane path.
273
+ * @param mode - Configured `worktree.lane_permissions` policy.
274
+ * @returns What was decided, for logging and tests.
275
+ */
276
+ export declare function applyLanePermissions(opencodeConfig: Record<string, unknown>, directory: string, mode: LanePermissionMode): LanePermissionApplication;
277
+ export {};
@@ -1197,6 +1197,11 @@ export declare const WorktreeIsolationConfigSchema: z.ZodObject<{
1197
1197
  skip: "skip";
1198
1198
  copy: "copy";
1199
1199
  }>>;
1200
+ lane_permissions: z.ZodDefault<z.ZodEnum<{
1201
+ scoped_allow: "scoped_allow";
1202
+ deny: "deny";
1203
+ off: "off";
1204
+ }>>;
1200
1205
  serialization_release_after_dispatches: z.ZodDefault<z.ZodNumber>;
1201
1206
  serialization_release_after_ms: z.ZodDefault<z.ZodNumber>;
1202
1207
  runtime_isolation: z.ZodOptional<z.ZodObject<{
@@ -2449,6 +2454,11 @@ export declare const PluginConfigSchema: z.ZodObject<{
2449
2454
  skip: "skip";
2450
2455
  copy: "copy";
2451
2456
  }>>;
2457
+ lane_permissions: z.ZodDefault<z.ZodEnum<{
2458
+ scoped_allow: "scoped_allow";
2459
+ deny: "deny";
2460
+ off: "off";
2461
+ }>>;
2452
2462
  serialization_release_after_dispatches: z.ZodDefault<z.ZodNumber>;
2453
2463
  serialization_release_after_ms: z.ZodDefault<z.ZodNumber>;
2454
2464
  runtime_isolation: z.ZodOptional<z.ZodObject<{
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The swarm worktree BRANCH GRAMMAR — single source of truth for both building
3
+ * and recognising the branch names that identify a swarm-owned worktree.
4
+ *
5
+ * ## Why a grammar and not a prefix
6
+ *
7
+ * Lane detection (`src/config/lane-context.ts`) uses the branch name as its
8
+ * ownership signal because, unlike the worktree's path, it is invariant across
9
+ * all three provisioning layouts (default `.swarm-worktrees`, a configured
10
+ * `worktree.worktree_dir`, and the Windows path-budget fallback that relocates
11
+ * a lane to `<os.tmpdir()>/swwt/...`).
12
+ *
13
+ * Matching a bare `swarm/` PREFIX is not safe. A user running
14
+ * `git worktree add -b swarm/my-own-experiment ../scratch` would be classified
15
+ * as a lane, and their ordinary interactive session would then have
16
+ * `external_directory: { "*": "deny", ... }` injected. That is strictly worse
17
+ * than the hang this subsystem exists to fix, because the host's
18
+ * `Permission.ask` short-circuits on a deny before it ever creates a deferred:
19
+ *
20
+ * ```js
21
+ * // opencode 1.18.10, Permission.ask
22
+ * if (W.action === "deny") return yield* new U.DeniedError({ ... });
23
+ * ```
24
+ *
25
+ * — so no prompt is raised and "Allow always" can never be reached. The user
26
+ * has no in-session recovery.
27
+ *
28
+ * The grammar below therefore matches the COMPLETE shape
29
+ * `makeWorktreeBranchName` emits, including a session segment constrained to
30
+ * OpenCode's `ses_`-prefixed identifier form. `tests/unit/config/swarm-branch.test.ts`
31
+ * holds a round-trip property test over the producer and a negative corpus of
32
+ * plausible human-authored branch names.
33
+ *
34
+ * @module config/swarm-branch
35
+ */
36
+ /**
37
+ * True when `sessionId` is a session identifier the lane grammar can encode.
38
+ *
39
+ * Tool arguments are LLM-supplied (`sessionID: z.string()`) and the host's own
40
+ * `SessionID` brand is only `isStartsWith("ses")`, so a value like `ses-run-1`
41
+ * passes the host but yields a branch the recogniser cannot match — a lane that
42
+ * silently skips permission scoping and hangs. Callers that accept a session id
43
+ * from outside should check this before provisioning.
44
+ */
45
+ export declare function isSwarmSessionId(sessionId: unknown): sessionId is string;
46
+ /**
47
+ * Builds the branch name for a swarm worktree.
48
+ *
49
+ * The ONLY producer of these names. `makeWorktreeBranchName` in
50
+ * `src/worktree/core.ts` delegates here so the producer and
51
+ * {@link matchSwarmLaneBranch} cannot drift.
52
+ *
53
+ * @param sessionId - Parent session identifier.
54
+ * @param id - Execution-unit identifier (task or lane id).
55
+ * @param purpose - Worktree purpose (e.g. `lane`).
56
+ * @param legacyLane - Use Lean Turbo's `swarm-lane/<sessionId>/<id>` style.
57
+ */
58
+ export declare function buildSwarmBranchName(sessionId: string, id: string, purpose: string, legacyLane: boolean): string;
59
+ /**
60
+ * Recognises the DEFAULT lane PATH shape,
61
+ * `<base>/.swarm-worktrees/<sessionId>/<id>`.
62
+ *
63
+ * This is the fallback ownership signal for a worktree whose HEAD is detached
64
+ * or unreadable, so its branch cannot be consulted. It must be as tight as the
65
+ * branch grammar: matching "any path containing a `.swarm-worktrees` segment"
66
+ * captures a user's own `git worktree add -b my-feature ../.swarm-worktrees/manual-user-wt`,
67
+ * which then has an unrecoverable deny-by-default injected into an ordinary
68
+ * interactive session.
69
+ *
70
+ * `provisionWorktree` builds this path as
71
+ * `path.resolve(resolveWorktreeBaseDir(directory), sessionId, id)`, i.e. exactly
72
+ * two segments after the base, so the shape check requires exactly that — with
73
+ * the session segment held to the SAME `ses_…` constraint the branch grammar
74
+ * uses. The directory name comes from the same `SWARM_WORKTREE_DIR_NAME`
75
+ * constant `resolveWorktreeBaseDir` builds with, so the three definitions
76
+ * cannot drift.
77
+ *
78
+ * KNOWN RESIDUAL (accepted, not a defect to chase): a worktree the USER created
79
+ * at exactly `<base>/.swarm-worktrees/ses_<alnum>/<id>` on a non-swarm branch
80
+ * still classifies as a lane. Reaching it requires deliberately creating a
81
+ * directory literally named `ses_<alnum>` under a `.swarm-worktrees` base, and
82
+ * the same leniency is what keeps a REAL lane detected after someone checks out
83
+ * a different branch inside it. Narrowing further would trade a far more likely
84
+ * false negative (a real lane silently unscoped, i.e. the original hang) for a
85
+ * far less likely false positive.
86
+ *
87
+ * Note this recognises only the DEFAULT layout. A `worktree_dir` override or
88
+ * the Windows path-budget fallback produces a different path, and a lane in
89
+ * those layouts with a detached HEAD is a false NEGATIVE — no permission
90
+ * changes, i.e. today's behaviour. That is the safe direction.
91
+ *
92
+ * @param lanePath - An already-resolved absolute path.
93
+ */
94
+ export declare function matchSwarmLanePath(lanePath: string): {
95
+ sessionId: string;
96
+ id: string;
97
+ } | undefined;
98
+ /** A branch name recognised as a swarm worktree lane. */
99
+ export interface SwarmLaneBranch {
100
+ /** Worktree purpose. `'lane'` for the legacy style, which encodes no purpose. */
101
+ purpose: string;
102
+ sessionId: string;
103
+ id: string;
104
+ style: 'purpose' | 'legacy-lane';
105
+ }
106
+ /**
107
+ * Recognises a branch name produced by {@link buildSwarmBranchName}.
108
+ *
109
+ * Matches the complete grammar and nothing wider:
110
+ * - `swarm/<purpose>/<sessionId>/<id>` — exactly 4 segments
111
+ * - `swarm-lane/<sessionId>/<id>` — exactly 3 segments
112
+ *
113
+ * with `<sessionId>` matching `ses_[A-Za-z0-9]+` and `<purpose>` / `<id>` each
114
+ * a single non-empty, non-dot segment. Anything else — including a bare
115
+ * `swarm/my-own-experiment`, or a real lane name with extra trailing segments —
116
+ * returns `undefined`, which classifies the worktree as NOT swarm-owned and
117
+ * leaves it completely untouched.
118
+ *
119
+ * @returns The parsed branch, or `undefined` when the name is not a swarm lane.
120
+ */
121
+ export declare function matchSwarmLaneBranch(branch: string): SwarmLaneBranch | undefined;
122
+ /**
123
+ * Tier-0 test seam (writing-tests skill). The prefix list is an implementation
124
+ * detail of the grammar — production code goes through
125
+ * {@link buildSwarmBranchName} / {@link matchSwarmLaneBranch} — but the branch
126
+ * tests assert that both documented styles are actually exercised.
127
+ */
128
+ export declare const _test_exports: {
129
+ SWARM_WORKTREE_BRANCH_PREFIXES: readonly ["swarm/", "swarm-lane/"];
130
+ };
@@ -265,11 +265,50 @@ interface MessageWithParts {
265
265
  info: MessageInfo;
266
266
  parts: MessagePart[];
267
267
  }
268
+ declare function extractPlanCriticVerdict(output: unknown): 'APPROVED' | 'NEEDS_REVISION' | 'REJECTED' | null;
268
269
  /**
269
270
  * Returns whether the plan in the given directory has a valid plan-critic
270
271
  * approval. Does not throw — returns `false` for any failure (fail-closed).
271
272
  */
272
273
  export declare function isPlanCriticApproved(directory: string): Promise<boolean>;
274
+ /**
275
+ * Escape hatch for the ratchet-tighter `critic_pre_plan` gate (issue #2012).
276
+ *
277
+ * When the critic returns APPROVED but the mechanical snapshot recorder
278
+ * ({@link recordPlanCriticApprovalSnapshotIfApplicable}) fails to persist it
279
+ * (verdict-format mismatch, dispatch-signal miss, or a plan.json read race),
280
+ * the gate permanently blocks ALL coder delegations because `critic_pre_plan`
281
+ * defaults to `true` and cannot be disabled (ratchet-tighter). This records a
282
+ * manual `plan_critic_gate` approval snapshot so the gate unblocks, with a
283
+ * distinct `method: 'manual_override'` audit marker so a human or downstream
284
+ * review can distinguish a manual approval from a mechanical critic approval.
285
+ *
286
+ * This mirrors the established escape-hatch pattern (PR_REVIEW gate #1898:
287
+ * `abortPrWorkflow` + `/swarm abort-pr-workflow` + `abort_pr_workflow` tool).
288
+ *
289
+ * Fail-closed preconditions:
290
+ * - The session must be an active **architect** session. The escape hatch is an
291
+ * escalation; non-architect callers are rejected so a coder/reviewer cannot
292
+ * self-unblock.
293
+ * - A plan.json must exist; you cannot approve a non-existent plan.
294
+ *
295
+ * @param directory - Project root containing `.swarm/`
296
+ * @param sessionID - The caller's session id (must be an architect session)
297
+ * @param options.reason - Optional human/agent-supplied reason (audited)
298
+ * @param options.userConfirmed - `true` only when invoked via the restricted
299
+ * `/swarm approve-plan-critic` command (human-run); `false` when invoked via
300
+ * the `approve_plan_critic` tool (agent-initiated). Recorded in the audit so a
301
+ * self-approve is visible.
302
+ */
303
+ export declare function forceRecordPlanCriticApproval(directory: string, sessionID: string, options?: {
304
+ reason?: string;
305
+ userConfirmed?: boolean;
306
+ }): Promise<{
307
+ planId: string;
308
+ recordedAt: string;
309
+ reason?: string;
310
+ userConfirmed: boolean;
311
+ }>;
273
312
  export declare function resolveDelegatedPlanTaskId(args: Record<string, unknown>, knownPlanTaskIds?: ReadonlySet<string>): string | null;
274
313
  /**
275
314
  * Builds a cause-specific diagnostic for `prepareCoderScope`'s SCOPE_NOT_DECLARED
@@ -332,7 +371,9 @@ export declare const _internals: {
332
371
  bindBackgroundCoderReservationForDispatch: typeof import("../background/pending-delegations.js").bindBackgroundCoderReservation;
333
372
  releaseBackgroundCoderReservationForDispatch: typeof import("../background/pending-delegations.js").releaseBackgroundCoderReservation;
334
373
  resetStandardWorktreeIsolationState: typeof resetStandardWorktreeIsolationState;
335
- PLAN_CRITIC_TASK_SIGNALS: readonly ["critic-gate", "plan critic", "review plan", "review the plan", "plan.md", "approve the plan", "plan approval"];
374
+ PLAN_CRITIC_TASK_SIGNALS: readonly ["critic-gate", "plan critic", "review plan", "review the plan", "plan.md", "approve the plan", "plan approval", "pre-implementation review", "evaluate this plan", "evaluate the plan", "assess this plan", "assess the plan", "plan soundness", "before implementation", "review the plan below"];
375
+ extractPlanCriticVerdict: typeof extractPlanCriticVerdict;
376
+ forceRecordPlanCriticApproval: typeof forceRecordPlanCriticApproval;
336
377
  provisionWorktree: typeof _wtiInternals.provisionWorktree;
337
378
  removeWorktree: typeof _wtiInternals.removeWorktree;
338
379
  attemptMergeBackFromDirty: typeof _wtiInternals.attemptMergeBackFromDirty;