@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.33 → 0.1.35

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 (32) hide show
  1. package/bin/adl +2 -2
  2. package/bin/reconcile +4 -3
  3. package/package.json +1 -1
  4. package/packages/agent-hooks/README.md +14 -0
  5. package/packages/agent-hooks/assets/opencode-plugin.template.ts +140 -104
  6. package/packages/agent-hooks/src/deploy/opencode-template.ts +5 -18
  7. package/packages/agent-hooks/src/deploy/render.ts +12 -3
  8. package/packages/agent-hooks/src/rules/impl/git-branch.ts +63 -0
  9. package/packages/agent-hooks/src/rules/impl/git-helpers.ts +91 -3
  10. package/packages/agent-hooks/src/rules/impl/git-history.ts +4 -13
  11. package/packages/agent-hooks/src/rules/impl/git-target.ts +423 -0
  12. package/packages/agent-hooks/src/rules/impl/tool-native-search.ts +8 -7
  13. package/packages/agent-hooks/src/rules/ports.ts +36 -0
  14. package/packages/agent-hooks/src/rules/registry.ts +3 -2
  15. package/packages/agent-hooks/src/rules/shell/protected-carriers.ts +1 -1
  16. package/packages/agent-settings/README.md +10 -4
  17. package/packages/agent-settings/assets/GLOBAL-AGENTS.md +5 -0
  18. package/packages/agent-settings/assets/TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md +40 -0
  19. package/packages/agent-settings/src/global-agents.ts +43 -8
  20. package/packages/agent-skills/assets/adl-mechanics/SKILL.md +38 -0
  21. package/packages/agent-skills/assets/agent-search-tool-audit/scripts/audit.sh +1 -1
  22. package/packages/agent-skills/assets/to-feature/SKILL.md +1 -1
  23. package/packages/agent-skills/assets/to-workitems/SKILL.md +1 -1
  24. package/packages/agent-skills/assets/to-workitems/references/AZURE-DEVOPS-HIERARCHY.md +3 -0
  25. package/packages/azdo/src/lib/agent-identity.ts +1 -1
  26. package/packages/repo-controls/README.md +12 -7
  27. package/packages/repo-controls/assets/scaffold/root/AGENTS.md +60 -11
  28. package/packages/repo-controls/assets/scaffold/root/CONTRIBUTING.md +28 -0
  29. package/packages/repo-controls/assets/scaffold/root/INVARIANTS.md +17 -0
  30. package/packages/repo-controls/src/cli.ts +23 -5
  31. package/packages/repo-controls/src/constants.ts +2 -15
  32. package/packages/repo-controls/src/install.ts +15 -6
package/bin/adl CHANGED
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/bin/adl
3
- size: 82920928 bytes
4
- sha256: 3e480a5aff47ee61aec30d26dad9d06dc8f3c4b33ca4b5ed1b8ef9b4a810bb99
3
+ size: 82949600 bytes
4
+ sha256: 42e867b263b1cb13e200275f93036ccdaca15e3902ed11b60efbdea3730d96a3
package/bin/reconcile CHANGED
@@ -186,6 +186,7 @@ collect_sweep_backups() {
186
186
  script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
187
187
  repo_root="$(cd "$script_dir/.." && pwd)"
188
188
  manifest="${manifest:-$repo_root/packages/fleet/manifest.json}"
189
+ manifest="$(cd "$(dirname "$manifest")" && pwd)/$(basename "$manifest")"
189
190
  # shellcheck source=lib/bun-compile-build.sh
190
191
  source "$script_dir/lib/bun-compile-build.sh"
191
192
  # shellcheck source=lib/adl-package.sh
@@ -3055,13 +3056,13 @@ fi
3055
3056
  if [[ "$global_agents_enabled" == true ]]; then
3056
3057
  if [[ "$mode" != apply ]]; then
3057
3058
  echo "global-tools: global agents mode=status home=$administrator_home"
3058
- "$BASH" "$script_dir/global-agents" --home "$administrator_home" --mode status
3059
+ "$BASH" "$script_dir/global-agents" --home "$administrator_home" --mode status --host "$device" --manifest "$manifest"
3059
3060
  else
3060
3061
  echo "global-tools: global agents mode=deploy home=$administrator_home (as $administrator)"
3061
3062
  # The exact target files are authoritative global artifacts. Drift is
3062
- # replaced from GLOBAL-AGENTS.md with no backup or leave-in-place branch.
3063
+ # replaced from TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md with no backup or leave-in-place branch.
3063
3064
  (cd /tmp && sudo -u "$administrator" -H env PATH="${brew_bin%/*}:/usr/bin:/bin" \
3064
- "$BASH" "$script_dir/global-agents" --home "$administrator_home" --mode deploy)
3065
+ "$BASH" "$script_dir/global-agents" --home "$administrator_home" --mode deploy --host "$device" --manifest "$manifest")
3065
3066
  fi
3066
3067
  fi
3067
3068
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amsterdamdatalabs/adl-agent-control-linux-x64",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "description": "adl CLI binary for linux/x64 (agent identity + Azure DevOps front door — see docs/architecture/cli-design.md)",
5
5
  "license": "UNLICENSED",
6
6
  "os": [
@@ -474,6 +474,17 @@ rendered by `src/deploy/opencode-template.ts` from `assets/opencode-plugin.templ
474
474
  `assets/hooks.json` and the OpenCode surface in `deploy-profile.json`. Do not edit
475
475
  the deployed file by hand; re-run `adl install`.
476
476
 
477
+ - The deployed file uses OpenCode v2's plugin shape (`export default { id, setup }`,
478
+ with `setup(ctx)` imperatively registering hooks via `ctx.session.hook` /
479
+ `ctx.tool.hook` / `ctx.event.subscribe` — confirmed against
480
+ `github.com/anomalyco/opencode/blob/dev/packages/core/src/plugin/module.ts`
481
+ and the promise adapter at
482
+ `github.com/anomalyco/opencode/blob/dev/packages/plugin/src/promise/adapter.ts`).
483
+ v2 dropped v1's `chat.message` + `experimental.chat.system.transform` events; the
484
+ template maps the equivalent flow to `ctx.session.hook("prompt", ...)` (run
485
+ `user-prompt-submit` per submission, stash the additionalContext) and
486
+ `ctx.session.hook("context", ...)` (push stashed context into `event.system`
487
+ immediately before provider dispatch).
477
488
  - The deployed file has no multi-line comments (Tarun 2026-10-08, Bug 602), so it
478
489
  passes compliance control REP-007. Its header is a single line pointing here.
479
490
  - Fallback projection: an event with no documented OpenCode equivalent runs on a
@@ -481,5 +492,8 @@ the deployed file by hand; re-run `adl install`.
481
492
  `packages/agent-hooks/deploy-profile.json` (today `SessionEnd -> SessionIdle`).
482
493
  The generated file lists the map as one line, `// Event fallbacks: A -> B, C -> D`,
483
494
  and emits no line at all when the map is empty.
495
+ - `UserPromptSubmit` is intentionally not routed to any opencode slot — the v2
496
+ public plugin model has no replacement for v1's `chat.message` event; the rule
497
+ still runs for claude/codex/cursor/kimi through their own carriers.
484
498
  - Source of truth for hook commands is `assets/hooks.json`; the generated arrays
485
499
  carry `command`, `matcher`, `timeoutMs` and `fallbackFor` per entry.
@@ -2,6 +2,8 @@
2
2
 
3
3
  __ADL_FALLBACK_ENTRIES__
4
4
 
5
+ // OpenCode v2 plugin: `export default { id, setup }`; setup(ctx) imperatively registers hooks via ctx.session.hook / ctx.tool.hook / ctx.event.subscribe.
6
+
5
7
  interface HookCommandSpec {
6
8
  command: string;
7
9
  matcher?: string;
@@ -27,10 +29,6 @@ const HOOKS_TOOL_EXECUTE_AFTER: HookCommandSpec[] = [
27
29
  __ADL_ARRAY_HOOKS_TOOL_EXECUTE_AFTER__
28
30
  ];
29
31
 
30
- const HOOKS_CHAT_MESSAGE: HookCommandSpec[] = [
31
- __ADL_ARRAY_HOOKS_CHAT_MESSAGE__
32
- ];
33
-
34
32
  const HOOKS_EVENT_SESSION_CREATED: HookCommandSpec[] = [
35
33
  __ADL_ARRAY_HOOKS_EVENT_SESSION_CREATED__
36
34
  ];
@@ -47,30 +45,41 @@ async function runHookCommand(
47
45
  payload: Record<string, unknown>,
48
46
  cwd: string,
49
47
  ): Promise<{ exitCode: number | null; stdout: string; stderr: string }> {
50
- try {
51
- const proc = Bun.spawn(["/bin/sh", "-c", spec.command], {
52
- cwd,
53
- stdin: "pipe",
54
- stdout: "pipe",
55
- stderr: "pipe",
56
- ...(spec.timeoutMs ? { timeout: spec.timeoutMs, killSignal: "SIGTERM" } : {}),
57
- });
58
- proc.stdin.write(JSON.stringify(payload));
59
- proc.stdin.end();
60
- const [stdout, stderr] = await Promise.all([
61
- new Response(proc.stdout).text(),
62
- new Response(proc.stderr).text(),
63
- ]);
64
- await proc.exited;
65
- if (proc.exitCode !== 0 && proc.exitCode !== 2) {
66
- console.error("[" + PLUGIN_NAME + "] hook command exited " + proc.exitCode + ": " + spec.command);
48
+ const { spawn } = await import("node:child_process");
49
+ return new Promise((resolve) => {
50
+ try {
51
+ const proc = spawn("/bin/sh", ["-c", spec.command], {
52
+ cwd,
53
+ stdio: ["pipe", "pipe", "pipe"],
54
+ });
55
+ let stdout = "";
56
+ let stderr = "";
57
+ proc.stdout!.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8"); });
58
+ proc.stderr!.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8"); });
59
+ const timer = spec.timeoutMs
60
+ ? setTimeout(() => { try { proc.kill("SIGTERM"); } catch {} }, spec.timeoutMs)
61
+ : null;
62
+ proc.on("error", (err) => {
63
+ if (timer) clearTimeout(timer);
64
+ const message = err instanceof Error ? err.message : String(err);
65
+ console.error("[" + PLUGIN_NAME + "] hook command crashed: " + spec.command + " -- " + message);
66
+ resolve({ exitCode: null, stdout: "", stderr: message });
67
+ });
68
+ proc.on("exit", (code) => {
69
+ if (timer) clearTimeout(timer);
70
+ if (code !== 0 && code !== 2) {
71
+ console.error("[" + PLUGIN_NAME + "] hook command exited " + code + ": " + spec.command);
72
+ }
73
+ resolve({ exitCode: code, stdout, stderr });
74
+ });
75
+ proc.stdin!.write(JSON.stringify(payload));
76
+ proc.stdin!.end();
77
+ } catch (err) {
78
+ const message = err instanceof Error ? err.message : String(err);
79
+ console.error("[" + PLUGIN_NAME + "] hook command crashed: " + spec.command + " -- " + message);
80
+ resolve({ exitCode: null, stdout: "", stderr: message });
67
81
  }
68
- return { exitCode: proc.exitCode, stdout, stderr };
69
- } catch (err) {
70
- const message = err instanceof Error ? err.message : String(err);
71
- console.error("[" + PLUGIN_NAME + "] hook command crashed: " + spec.command + " -- " + message);
72
- return { exitCode: null, stdout: "", stderr: message };
73
- }
82
+ });
74
83
  }
75
84
 
76
85
  function denyReason(result: { exitCode: number | null; stdout: string; stderr: string }): string | undefined {
@@ -113,9 +122,7 @@ function applyUpdatedInput(
113
122
  }
114
123
  }
115
124
 
116
- const adlPromptContext = new Map<string, string>();
117
-
118
- function extractHookContext(result: { exitCode: number | null; stdout: string; stderr: string }): string | undefined {
125
+ function extractHookContext(result: { stdout: string }): string | undefined {
119
126
  const trimmed = result.stdout.trim();
120
127
  if (!trimmed) return undefined;
121
128
  try {
@@ -130,91 +137,120 @@ function extractHookContext(result: { exitCode: number | null; stdout: string; s
130
137
  return trimmed;
131
138
  }
132
139
 
133
- export const __ADL_PLUGIN_IDENTIFIER__ = async ({ directory }: { directory: string }) => {
134
- return {
135
- "chat.message": async (input: any, output: any) => {
136
- const parts = output && Array.isArray(output.parts) ? output.parts : [];
137
- const promptText = parts.filter((p: any) => p && p.type === "text").map((p: any) => p.text).join(" ");
138
- let context: string | undefined;
139
- for (const spec of HOOKS_CHAT_MESSAGE) {
140
+ export default {
141
+ id: "__ADL_PLUGIN_NAME__",
142
+ setup: async (ctx: any) => {
143
+ const cwd = ctx.location?.directory ?? process.cwd();
144
+ const adlPromptContext = new Map<string, string[]>();
145
+
146
+ // Per-prompt: run user-prompt-submit, stash additionalContext keyed by sessionID.
147
+ await ctx.session.hook("prompt", async (event: any) => {
148
+ const sessionID: string | undefined = event?.sessionID;
149
+ if (!sessionID) return;
150
+ const promptText: string = event?.prompt?.text ?? "";
151
+ const out: string[] = [];
140
152
  const payload = {
141
153
  hook_event_name: "UserPromptSubmit",
142
154
  prompt: promptText,
143
- cwd: directory,
144
- session_id: input && input.sessionID,
155
+ cwd,
156
+ session_id: sessionID,
145
157
  };
146
- const result = await runHookCommand(spec, payload, directory);
158
+ const result = await runHookCommand(
159
+ { command: "adl hook user-prompt-submit --host=opencode", timeoutMs: 15000 },
160
+ payload,
161
+ cwd,
162
+ );
147
163
  const extracted = extractHookContext(result);
148
- if (extracted) context = context ? context + " " + extracted : extracted;
149
- }
150
- const sessionID = input && input.sessionID;
151
- if (sessionID) {
152
- if (context) adlPromptContext.set(sessionID, context);
164
+ if (extracted) out.push(extracted);
165
+ if (out.length) adlPromptContext.set(sessionID, out);
153
166
  else adlPromptContext.delete(sessionID);
154
- }
155
- },
156
- "experimental.chat.system.transform": async (input: any, output: any) => {
157
- const sessionID = input && input.sessionID;
158
- if (!sessionID) return;
159
- const context = adlPromptContext.get(sessionID);
160
- if (context && output && Array.isArray(output.system)) {
161
- output.system.push(context);
162
- }
163
- },
164
- "tool.execute.before": async (input: ToolHookInput, output: ToolHookOutput) => {
165
- for (const spec of HOOKS_TOOL_EXECUTE_BEFORE) {
166
- if (spec.matcher && !new RegExp("^(?:" + spec.matcher + ")$", "i").test(input.tool)) continue;
167
- const payload = {
168
- hook_event_name: "PreToolUse",
169
- tool_name: input.tool,
170
- tool_input: output.args,
171
- cwd: directory,
172
- session_id: input.sessionID,
173
- };
174
- const result = await runHookCommand(spec, payload, directory);
175
- const reason = denyReason(result);
176
- if (reason) throw new Error(reason);
177
- applyUpdatedInput(result, output);
178
- }
179
- },
180
- "tool.execute.after": async (input: ToolHookInput, output: ToolHookOutput) => {
181
- for (const spec of HOOKS_TOOL_EXECUTE_AFTER) {
182
- if (spec.matcher && !new RegExp("^(?:" + spec.matcher + ")$", "i").test(input.tool)) continue;
183
- const payload = {
184
- hook_event_name: "PostToolUse",
185
- tool_name: input.tool,
186
- tool_input: output.args,
187
- cwd: directory,
188
- session_id: input.sessionID,
189
- };
190
- const result = await runHookCommand(spec, payload, directory);
191
- // PostToolUse cannot block: the tool call already completed.
192
- }
193
- },
194
- event: async ({ event }: { event: any }) => {
195
- if (event && event.type === "session.created") {
196
- for (const spec of HOOKS_EVENT_SESSION_CREATED) {
167
+ });
168
+
169
+ // Before provider dispatch: push stashed context into the system prompt array.
170
+ await ctx.session.hook("context", (event: any) => {
171
+ const sessionID: string | undefined = event?.sessionID;
172
+ if (!sessionID) return;
173
+ const out = adlPromptContext.get(sessionID);
174
+ if (!out || out.length === 0) return;
175
+ if (!Array.isArray(event.system)) return;
176
+ for (const text of out) {
177
+ event.system.push({ type: "text", text });
178
+ }
179
+ });
180
+
181
+ // Pre-tool-use: deny / rewrite. Always runs pre-tool-use; crg:pre-commit only on bash.
182
+ await ctx.tool.hook("execute.before", async (event: any) => {
183
+ const input = event as ToolHookInput;
184
+ const output = { args: (event?.input ?? {}) as Record<string, unknown> };
185
+ for (const spec of HOOKS_TOOL_EXECUTE_BEFORE) {
186
+ if (spec.matcher && !new RegExp("^(?:" + spec.matcher + ")$", "i").test(input.tool)) continue;
197
187
  const payload = {
198
- hook_event_name: "SessionStart",
199
- cwd: directory,
200
- session_id: event.properties && (event.properties.sessionID || (event.properties.info && event.properties.info.id)),
188
+ hook_event_name: "PreToolUse",
189
+ tool_name: input.tool,
190
+ tool_input: output.args,
191
+ cwd,
192
+ session_id: input.sessionID,
201
193
  };
202
- await runHookCommand(spec, payload, directory);
194
+ const result = await runHookCommand(spec, payload, cwd);
195
+ const reason = denyReason(result);
196
+ if (reason) throw new Error(reason);
197
+ applyUpdatedInput(result, output);
203
198
  }
204
- }
205
- if (event && event.type === "session.idle") {
206
- for (const spec of HOOKS_EVENT_SESSION_IDLE) {
199
+ if (output.args !== event?.input && event && "input" in event) {
200
+ event.input = output.args;
201
+ }
202
+ });
203
+
204
+ // Post-tool-use: observational, can't block. Runs after-file-edit on write|edit.
205
+ await ctx.tool.hook("execute.after", async (event: any) => {
206
+ const input = event as ToolHookInput;
207
+ for (const spec of HOOKS_TOOL_EXECUTE_AFTER) {
208
+ if (spec.matcher && !new RegExp("^(?:" + spec.matcher + ")$", "i").test(input.tool)) continue;
207
209
  const payload = {
208
- hook_event_name: "SessionIdle",
209
- cwd: directory,
210
- session_id: event.properties && (event.properties.sessionID || (event.properties.info && event.properties.info.id)),
211
- ...(spec.fallbackFor ? { fallback_for: spec.fallbackFor } : {}),
210
+ hook_event_name: "PostToolUse",
211
+ tool_name: input.tool,
212
+ tool_input: (event?.input ?? {}),
213
+ cwd,
214
+ session_id: input.sessionID,
212
215
  };
213
- await runHookCommand(spec, payload, directory);
216
+ await runHookCommand(spec, payload, cwd);
214
217
  }
218
+ });
219
+
220
+ // session.created / session.idle: subscribe to the event stream and dispatch.
221
+ if (ctx.event && typeof ctx.event.subscribe === "function") {
222
+ (async () => {
223
+ try {
224
+ for await (const event of ctx.event.subscribe()) {
225
+ const type = event?.type;
226
+ if (type === "session.created") {
227
+ const sessionID: string | undefined = event?.properties?.sessionID ?? event?.properties?.info?.id;
228
+ for (const spec of HOOKS_EVENT_SESSION_CREATED) {
229
+ const payload = {
230
+ hook_event_name: "SessionStart",
231
+ cwd,
232
+ session_id: sessionID,
233
+ };
234
+ await runHookCommand(spec, payload, cwd);
235
+ }
236
+ } else if (type === "session.idle") {
237
+ const sessionID: string | undefined = event?.properties?.sessionID ?? event?.properties?.info?.id;
238
+ for (const spec of HOOKS_EVENT_SESSION_IDLE) {
239
+ const payload = {
240
+ hook_event_name: "SessionIdle",
241
+ cwd,
242
+ session_id: sessionID,
243
+ ...(spec.fallbackFor ? { fallback_for: spec.fallbackFor } : {}),
244
+ };
245
+ await runHookCommand(spec, payload, cwd);
246
+ }
247
+ }
248
+ }
249
+ } catch (err) {
250
+ const message = err instanceof Error ? err.message : String(err);
251
+ console.error("[" + PLUGIN_NAME + "] event subscriber crashed: " + message);
252
+ }
253
+ })();
215
254
  }
216
255
  },
217
- };
218
- };
219
-
220
- export default __ADL_PLUGIN_IDENTIFIER__;
256
+ };
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * Fills assets/opencode-plugin.template.ts with the rendered hook arrays
3
- * (render.ts's renderOpencodeSlots), the profile's pluginName, and the
4
- * identifier derived from it — reproducing this repo's own committed
5
- * `.opencode/plugins/adl-repo.ts` byte-for-byte for the current
6
- * deploy-profile.json + assets/hooks.json, except the header (a one-line
7
- * comment plus an optional one-line fallback comment; no multi-line comments in
8
- * deployed code, Bug 602). See README "Generated OpenCode plugin".
3
+ * (render.ts's renderOpencodeSlots) and the profile's pluginName — reproducing
4
+ * this repo's own committed `.opencode/plugins/adl-repo.ts` byte-for-byte for
5
+ * the current deploy-profile.json + assets/hooks.json, except the header
6
+ * (a one-line comment plus an optional one-line fallback comment; no multi-line
7
+ * comments in deployed code, Bug 602). See README "Generated OpenCode plugin".
9
8
  */
10
9
  import { readFileSync } from 'node:fs'
11
10
  import { join } from 'node:path'
@@ -13,14 +12,6 @@ import type { HookSurface } from './profile.ts'
13
12
  import { OPENCODE_SLOTS, type RenderedOpencodeEntry, renderOpencodeSlots } from './render.ts'
14
13
  import type { CanonicalHooksDoc } from './source.ts'
15
14
 
16
- /** kebab-case -> camelCase, e.g. "adl-repo" -> "adlRepo". Ported from the deleted generator's toIdentifier (git show 5b34d10:packages/agent-skills/src/create/opencode.ts). */
17
- function toCamelCase(pluginName: string): string {
18
- return pluginName
19
- .split('-')
20
- .map((part, index) => (index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)))
21
- .join('')
22
- }
23
-
24
15
  function serializeEntry(entry: RenderedOpencodeEntry): string {
25
16
  const fields: string[] = [`command: ${JSON.stringify(entry.command)}`]
26
17
  if (entry.matcher !== undefined) fields.push(`matcher: ${JSON.stringify(entry.matcher)}`)
@@ -68,9 +59,5 @@ export function renderOpencodePlugin(options: RenderOpencodePluginOptions): stri
68
59
  if (!out.includes('__ADL_PLUGIN_NAME__')) throw new Error(`opencode plugin template is missing marker __ADL_PLUGIN_NAME__: ${templatePath}`)
69
60
  out = out.split('__ADL_PLUGIN_NAME__').join(surface.pluginName)
70
61
 
71
- const identifier = `${toCamelCase(surface.pluginName)}Plugin`
72
- if (!out.includes('__ADL_PLUGIN_IDENTIFIER__')) throw new Error(`opencode plugin template is missing marker __ADL_PLUGIN_IDENTIFIER__: ${templatePath}`)
73
- out = out.split('__ADL_PLUGIN_IDENTIFIER__').join(identifier)
74
-
75
62
  return out
76
63
  }
@@ -119,24 +119,33 @@ export interface RenderedOpencodeEntry {
119
119
  const OPENCODE_SLOT_FOR_EVENT: Readonly<Record<string, string>> = {
120
120
  PreToolUse: 'HOOKS_TOOL_EXECUTE_BEFORE',
121
121
  PostToolUse: 'HOOKS_TOOL_EXECUTE_AFTER',
122
- UserPromptSubmit: 'HOOKS_CHAT_MESSAGE',
123
122
  SessionStart: 'HOOKS_EVENT_SESSION_CREATED',
124
123
  SessionEnd: 'HOOKS_EVENT_SESSION_IDLE',
125
124
  }
126
125
 
126
+ /**
127
+ * Canonical events intentionally NOT routed to any opencode plugin slot.
128
+ * v2's public plugin model dropped the v1 `chat.message` +
129
+ * `experimental.chat.system.transform` pair that carried UserPromptSubmit,
130
+ * so the rule still runs for claude/codex/cursor/kimi through their own
131
+ * carriers but is invisible to the opencode surface. Add a new entry here
132
+ * only with the same justification.
133
+ */
134
+ const OPENCODE_UNSUPPORTED_CANONICAL_EVENTS: ReadonlySet<string> = new Set(['UserPromptSubmit'])
135
+
127
136
  export const OPENCODE_SLOTS: readonly string[] = [
128
137
  'HOOKS_TOOL_EXECUTE_BEFORE',
129
138
  'HOOKS_TOOL_EXECUTE_AFTER',
130
- 'HOOKS_CHAT_MESSAGE',
131
139
  'HOOKS_EVENT_SESSION_CREATED',
132
140
  'HOOKS_EVENT_SESSION_IDLE',
133
141
  ]
134
142
 
135
- /** slot name -> rendered entries, in source order. Every event in `doc` must have a known slot (see OPENCODE_SLOT_FOR_EVENT). */
143
+ /** slot name -> rendered entries, in source order. Every event in `doc` must have a known slot or be in OPENCODE_UNSUPPORTED_CANONICAL_EVENTS. */
136
144
  export function renderOpencodeSlots(doc: CanonicalHooksDoc, surface: HookSurface): ReadonlyMap<string, readonly RenderedOpencodeEntry[]> {
137
145
  const out = new Map<string, RenderedOpencodeEntry[]>()
138
146
  for (const slot of OPENCODE_SLOTS) out.set(slot, [])
139
147
  for (const [event, groups] of doc) {
148
+ if (OPENCODE_UNSUPPORTED_CANONICAL_EVENTS.has(event)) continue
140
149
  const slot = OPENCODE_SLOT_FOR_EVENT[event]
141
150
  if (slot === undefined) throw new Error(`opencode-plugin has no template slot for canonical event "${event}"`)
142
151
  // `fallbackFor` names the CANONICAL event being substituted (e.g.
@@ -0,0 +1,63 @@
1
+ // PBI 661 (Tarun, 2026-10-09): agents never change branches; both denies say "ask the user for git branch operations". See docs/architecture/agent-git-branch-rules.md
2
+
3
+ import type { Invocation } from '../shell/invocation.ts'
4
+ import type { Finding, MessageContext, RuleContext, RuleSpec } from '../spec.ts'
5
+ import { noParams } from '../spec.ts'
6
+ import { branchMutates, isHelpOnlyInvocation, isSequencerRecoveryOnly, switchCheckoutCreatesBranch } from './git-helpers.ts'
7
+ import { LANDING_SUBCOMMANDS, resolveGitTarget } from './git-target.ts'
8
+ import { gitAgentTail } from './messages.ts'
9
+
10
+ const ASK_USER = 'ask the user for git branch operations'
11
+
12
+ const UNRESOLVED =
13
+ 'The commit target (which repository and branch this would land on) could not be determined statically from this command line. ' +
14
+ 'Run `git commit` as its own command, or with `git -C <path>`, so the target is explicit. '
15
+
16
+ function askUserMessage(ctx: MessageContext, unresolved = false) {
17
+ return { user: ASK_USER, agent: `DENIED ${ASK_USER}. ${unresolved ? UNRESOLVED : ''}${gitAgentTail(ctx.snippet)}` }
18
+ }
19
+
20
+ export const gitBranchMutation: RuleSpec = {
21
+ id: 'git.branch-mutation',
22
+ category: 'git',
23
+ severity: 'deny',
24
+ enabledByDefault: true,
25
+ summary: 'Deny every mutating git branch form and branch creation via git switch -c / git checkout -b.',
26
+ params: noParams,
27
+ match(inv: Invocation): Finding | null {
28
+ if (inv.kind !== 'git') return null
29
+ const hit =
30
+ inv.subcommand === 'branch' ? branchMutates(inv.rest) : inv.subcommand === 'switch' || inv.subcommand === 'checkout' ? switchCheckoutCreatesBranch(inv.subcommand, inv.rest) : false
31
+ return hit ? { ruleId: 'git.branch-mutation', severity: 'deny', detail: {} } : null
32
+ },
33
+ message: (_finding: Finding, ctx: MessageContext) => askUserMessage(ctx),
34
+ corpus: {
35
+ deny: ['git branch foo', 'git branch -D old-feature', 'git branch -aD', 'git branch --delete --force old-feature', 'git branch -m old new', 'git switch -c new-branch', 'git checkout -b new-branch'],
36
+ allow: ['git branch', 'git branch -a', 'git branch -vv', 'git branch --show-current', "git branch --list 'feat/*'", 'git switch main', 'git checkout feature-x'],
37
+ },
38
+ }
39
+
40
+ export const gitCommitProtectedBranch: RuleSpec = {
41
+ id: 'git.commit-protected-branch',
42
+ category: 'git',
43
+ severity: 'deny',
44
+ enabledByDefault: true,
45
+ summary: 'Deny git commit (and merge, cherry-pick, revert, am) while the target repo is on a protected branch, or when the target or its branch cannot be resolved.',
46
+ params: noParams,
47
+ match(inv: Invocation, ctx: RuleContext<Record<string, never>>): Finding | null {
48
+ if (inv.kind !== 'git' || !LANDING_SUBCOMMANDS.has(inv.subcommand)) return null
49
+ if (isHelpOnlyInvocation(inv.rest)) return null // prints text, commits nothing
50
+ if (inv.subcommand !== 'commit' && isSequencerRecoveryOnly(inv.rest)) return null // --abort / --quit only end an operation
51
+ const target = resolveGitTarget(inv, ctx.command)
52
+ // null target or branch = unresolved: fail closed (docs/architecture/agent-git-branch-rules.md)
53
+ const branch = target === null ? null : ctx.index.currentBranch(target)
54
+ if (branch !== null && !ctx.agentConfig.protectedBranches.includes(branch)) return null
55
+ return { ruleId: 'git.commit-protected-branch', severity: 'deny', detail: { unresolved: branch === null ? 'yes' : 'no' } }
56
+ },
57
+ message: (finding: Finding, ctx: MessageContext) => askUserMessage(ctx, finding.detail['unresolved'] === 'yes'),
58
+ // Fixture branch is never protected, so deny entries are the fail-closed ones (docs/architecture/agent-git-branch-rules.md)
59
+ corpus: {
60
+ deny: ['cd "$REPO" && git commit -m x', 'git -C "$REPO" commit -m x', 'GIT_DIR="$D" git merge origin/integration'],
61
+ allow: ["git commit -m 'feat(x): y'", 'git status'],
62
+ },
63
+ }
@@ -61,9 +61,97 @@ export function commitAmends(rest: readonly Token[]): boolean {
61
61
  return hasFlag(rest, '--amend')
62
62
  }
63
63
 
64
- export function branchForceDeletes(rest: readonly Token[]): boolean {
65
- if (hasFlagOrCluster(rest, [], ['D'])) return true
66
- return hasFlagOrCluster(rest, ['--delete'], ['d']) && hasFlagOrCluster(rest, ['--force'], ['f'])
64
+ /**
65
+ * Every long option `git branch -h` lists on git 2.56 (positive forms), plus the
66
+ * legacy `--set-upstream`. Used ONLY to resolve unique prefixes the way git's
67
+ * parse-options does; whether an option is allowed is `BRANCH_READ_ONLY_OPTIONS`.
68
+ */
69
+ const BRANCH_POSITIVE_OPTIONS: readonly string[] = [
70
+ 'verbose', 'quiet', 'track', 'set-upstream-to', 'set-upstream', 'unset-upstream', 'color', 'remotes', 'contains', 'no-contains', 'abbrev', 'all',
71
+ 'delete', 'move', 'omit-empty', 'copy', 'list', 'show-current', 'create-reflog', 'edit-description', 'delete-merged', 'dry-run', 'force', 'merged',
72
+ 'no-merged', 'forked', 'column', 'sort', 'points-at', 'ignore-case', 'recurse-submodules', 'format', 'help',
73
+ ]
74
+ const BRANCH_ALL_OPTIONS: readonly string[] = [...new Set([...BRANCH_POSITIVE_OPTIONS, ...BRANCH_POSITIVE_OPTIONS.filter((name) => !name.startsWith('no-') && name !== 'help' && name !== 'remotes' && name !== 'all').map((name) => `no-${name}`)])]
75
+ /** PBI 661 allowlist: the only `git branch` options that read and never write. Anything else (known or not) is a deny. */
76
+ const BRANCH_READ_ONLY_OPTIONS: ReadonlySet<string> = new Set([
77
+ 'verbose', 'quiet', 'color', 'remotes', 'all', 'list', 'show-current', 'omit-empty', 'ignore-case', 'dry-run', 'abbrev', 'column', 'help',
78
+ 'contains', 'no-contains', 'merged', 'no-merged', 'forked', 'sort', 'points-at', 'format',
79
+ 'no-verbose', 'no-quiet', 'no-color', 'no-abbrev', 'no-column', 'no-sort', 'no-points-at', 'no-format', 'no-omit-empty', 'no-ignore-case', 'no-dry-run',
80
+ ])
81
+ /** Read-only options whose separate next token is their value. */
82
+ const BRANCH_VALUE_OPTIONS: ReadonlySet<string> = new Set(['contains', 'no-contains', 'merged', 'no-merged', 'forked', 'sort', 'points-at', 'format'])
83
+ /** Read-only options that put `git branch` in list mode, where positionals are patterns, not new branch names. */
84
+ const BRANCH_LIST_OPTIONS: ReadonlySet<string> = new Set(['list', 'show-current', 'contains', 'no-contains', 'merged', 'no-merged', 'points-at', 'forked'])
85
+ /** `-v -q -r -a -l -i`, alone or clustered; every other short letter writes or is unknown. */
86
+ const BRANCH_READ_ONLY_LETTERS: readonly string[] = ['v', 'q', 'r', 'a', 'l', 'i']
87
+
88
+ /** git's parse-options rule: an exact name wins, else the unique name that starts with `name`; `null` when unknown or ambiguous. */
89
+ function resolveLongOption(name: string, table: readonly string[]): string | null {
90
+ if (table.includes(name)) return name
91
+ const candidates = table.filter((candidate) => candidate.startsWith(name))
92
+ return candidates.length === 1 ? (candidates[0] as string) : null
93
+ }
94
+
95
+ /**
96
+ * PBI 661: does this `git branch` argv change branches, or might it? Fail
97
+ * closed: only an argv made entirely of known read-only options (long options
98
+ * resolved by unique prefix as git does) is false. A bare positional with no
99
+ * list-mode option is a branch name to CREATE, so it is true.
100
+ */
101
+ export function branchMutates(rest: readonly Token[]): boolean {
102
+ if (isHelpOnlyInvocation(rest)) return false
103
+ let listMode = false
104
+ let positionals = 0
105
+ for (let index = 0; index < rest.length; index++) {
106
+ const value = tokenValue(rest[index] as Token)
107
+ if (value === '--') {
108
+ positionals += rest.length - index - 1
109
+ break
110
+ }
111
+ if (value.startsWith('--')) {
112
+ const name = resolveLongOption(value.slice(2).split('=')[0] as string, BRANCH_ALL_OPTIONS)
113
+ if (name === null || !BRANCH_READ_ONLY_OPTIONS.has(name)) return true
114
+ if (BRANCH_LIST_OPTIONS.has(name)) listMode = true
115
+ if (BRANCH_VALUE_OPTIONS.has(name) && !value.includes('=')) index++
116
+ } else if (value.startsWith('-') && value.length > 1) {
117
+ const letters = [...value.slice(1)]
118
+ if (letters.some((letter) => !BRANCH_READ_ONLY_LETTERS.includes(letter))) return true
119
+ if (letters.includes('l')) listMode = true
120
+ } else {
121
+ positionals++
122
+ }
123
+ }
124
+ return positionals > 0 && !listMode
125
+ }
126
+
127
+ /** Long options that create a branch, per subcommand; `NON_CREATING` are the long options whose exact name must win over a longer creating one. */
128
+ const CREATING_LONG_OPTIONS: Readonly<Record<string, readonly string[]>> = {
129
+ switch: ['create', 'force-create', 'orphan', 'track'],
130
+ checkout: ['orphan', 'track'],
131
+ }
132
+ const NON_CREATING_LONG_OPTIONS: readonly string[] = ['force', 'detach', 'discard-changes', 'merge', 'conflict', 'quiet', 'progress', 'no-progress', 'guess', 'no-guess', 'no-track', 'ignore-other-worktrees', 'recurse-submodules', 'no-recurse-submodules', 'overlay', 'no-overlay', 'ours', 'theirs', 'patch']
133
+
134
+ /**
135
+ * PBI 661: `git switch -c/-C/-t/--create/--force-create/--orphan/--track` and
136
+ * `git checkout -b/-B/-t/--orphan/--track` create a branch, including any
137
+ * unique (or ambiguous-with-a-creating) long-option prefix (`--cr`, `--force-c`,
138
+ * `--orp`, `--tr`). Plain `git switch|checkout <existing>` is not matched.
139
+ */
140
+ export function switchCheckoutCreatesBranch(subcommand: string, rest: readonly Token[]): boolean {
141
+ const longs = CREATING_LONG_OPTIONS[subcommand] ?? []
142
+ const letters = subcommand === 'switch' ? ['c', 'C', 't'] : ['b', 'B', 't']
143
+ for (const token of rest) {
144
+ const value = tokenValue(token)
145
+ if (value === '--') return false
146
+ if (value.startsWith('--')) {
147
+ const name = value.slice(2).split('=')[0] as string
148
+ if (longs.includes(name)) return true
149
+ if (name.length > 0 && !NON_CREATING_LONG_OPTIONS.includes(name) && longs.some((long) => long.startsWith(name))) return true
150
+ } else if (value.startsWith('-') && value.length > 1 && [...value.slice(1)].some((letter) => letters.includes(letter))) {
151
+ return true
152
+ }
153
+ }
154
+ return false
67
155
  }
68
156
 
69
157
  export function tagDeletes(rest: readonly Token[]): boolean {