@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.
- package/bin/adl +2 -2
- package/bin/reconcile +4 -3
- package/package.json +1 -1
- package/packages/agent-hooks/README.md +14 -0
- package/packages/agent-hooks/assets/opencode-plugin.template.ts +140 -104
- package/packages/agent-hooks/src/deploy/opencode-template.ts +5 -18
- package/packages/agent-hooks/src/deploy/render.ts +12 -3
- package/packages/agent-hooks/src/rules/impl/git-branch.ts +63 -0
- package/packages/agent-hooks/src/rules/impl/git-helpers.ts +91 -3
- package/packages/agent-hooks/src/rules/impl/git-history.ts +4 -13
- package/packages/agent-hooks/src/rules/impl/git-target.ts +423 -0
- package/packages/agent-hooks/src/rules/impl/tool-native-search.ts +8 -7
- package/packages/agent-hooks/src/rules/ports.ts +36 -0
- package/packages/agent-hooks/src/rules/registry.ts +3 -2
- package/packages/agent-hooks/src/rules/shell/protected-carriers.ts +1 -1
- package/packages/agent-settings/README.md +10 -4
- package/packages/agent-settings/assets/GLOBAL-AGENTS.md +5 -0
- package/packages/agent-settings/assets/TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md +40 -0
- package/packages/agent-settings/src/global-agents.ts +43 -8
- package/packages/agent-skills/assets/adl-mechanics/SKILL.md +38 -0
- package/packages/agent-skills/assets/agent-search-tool-audit/scripts/audit.sh +1 -1
- package/packages/agent-skills/assets/to-feature/SKILL.md +1 -1
- package/packages/agent-skills/assets/to-workitems/SKILL.md +1 -1
- package/packages/agent-skills/assets/to-workitems/references/AZURE-DEVOPS-HIERARCHY.md +3 -0
- package/packages/azdo/src/lib/agent-identity.ts +1 -1
- package/packages/repo-controls/README.md +12 -7
- package/packages/repo-controls/assets/scaffold/root/AGENTS.md +60 -11
- package/packages/repo-controls/assets/scaffold/root/CONTRIBUTING.md +28 -0
- package/packages/repo-controls/assets/scaffold/root/INVARIANTS.md +17 -0
- package/packages/repo-controls/src/cli.ts +23 -5
- package/packages/repo-controls/src/constants.ts +2 -15
- 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:
|
|
4
|
-
sha256:
|
|
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.
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
144
|
-
session_id:
|
|
155
|
+
cwd,
|
|
156
|
+
session_id: sessionID,
|
|
145
157
|
};
|
|
146
|
-
const result = await runHookCommand(
|
|
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)
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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: "
|
|
199
|
-
|
|
200
|
-
|
|
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,
|
|
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
|
-
|
|
206
|
-
|
|
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: "
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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,
|
|
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)
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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 {
|