@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.34 → 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/tool-native-search.ts +8 -7
- 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/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.
|
|
@@ -56,13 +56,13 @@ export const toolNativeSearch: RuleSpec = {
|
|
|
56
56
|
category: 'tool-call',
|
|
57
57
|
severity: 'deny',
|
|
58
58
|
enabledByDefault: true,
|
|
59
|
-
summary: 'Deny the native Grep/Glob/Search tools; steer at search <semantic query | keywords> (
|
|
59
|
+
summary: 'Deny the native Grep/Glob/Search tools; steer at search <semantic query | keywords> (markdown), crg (code graph), rg, ast-grep.',
|
|
60
60
|
params: noParams,
|
|
61
61
|
match(inv: Invocation): Finding | null {
|
|
62
62
|
if (inv.kind !== 'tool-call') return null
|
|
63
63
|
if (!new Set(loadSurfacePolicy().deny.nativeTools).has(inv.tool)) return null
|
|
64
64
|
// Surface the tool input's path-ish field for the message so it can
|
|
65
|
-
// recommend `search` (the right tool for
|
|
65
|
+
// recommend `search` (the right tool for markdown) over
|
|
66
66
|
// `crg` (the right tool for code-graph queries). Different hosts name
|
|
67
67
|
// this field differently — Claude uses `path`, Codex uses `path`,
|
|
68
68
|
// Cursor uses `target_file` / `target_directory` for Write. The
|
|
@@ -77,10 +77,11 @@ export const toolNativeSearch: RuleSpec = {
|
|
|
77
77
|
},
|
|
78
78
|
message(finding: Finding, ctx: MessageContext) {
|
|
79
79
|
const tool = typeof finding.detail.tool === 'string' ? finding.detail.tool : 'this tool'
|
|
80
|
-
// Path-aware recommendation. Native Grep on
|
|
81
|
-
//
|
|
80
|
+
// Path-aware recommendation. Native Grep on a markdown file (any
|
|
81
|
+
// docs/plans/.scratchpad tree or *.md path) -> lead with `search`;
|
|
82
|
+
// code -> lead with `crg`.
|
|
82
83
|
const path = typeof finding.detail.path === 'string' ? finding.detail.path : ''
|
|
83
|
-
const isDoc = DOC_TREES.some((prefix) => path.startsWith(prefix))
|
|
84
|
+
const isDoc = DOC_TREES.some((prefix) => path.startsWith(prefix)) || path.endsWith('.md')
|
|
84
85
|
|
|
85
86
|
const leadTool = isDoc ? 'search' : 'crg'
|
|
86
87
|
const leadExamples = ALLOWED_SEARCH_TOOLS.find((row) => row.binary === leadTool)?.examples ?? []
|
|
@@ -93,8 +94,8 @@ export const toolNativeSearch: RuleSpec = {
|
|
|
93
94
|
|
|
94
95
|
return {
|
|
95
96
|
user: isDoc
|
|
96
|
-
? `Blocked the native ${tool} tool on \`${path}\` (
|
|
97
|
-
: `Blocked the native ${tool} tool. Lead with \`crg search "<text>"\` for code-graph queries, fall back to \`rg\`/\`ast-grep\` for literal text, \`search <semantic query | keywords>\`
|
|
97
|
+
? `Blocked the native ${tool} tool on \`${path}\` (markdown). The right answer is \`search <semantic query | keywords>\` — semantic + keyword, finds the doc that *talks* about X, in any markdown file. \`rg\`/\`crg\` are the wrong tools here.`
|
|
98
|
+
: `Blocked the native ${tool} tool. Lead with \`crg search "<text>"\` for code-graph queries, fall back to \`rg\`/\`ast-grep\` for literal text, \`search <semantic query | keywords>\` for any markdown file.`,
|
|
98
99
|
agent:
|
|
99
100
|
`DENIED the native ${tool} tool. It is routed through the lean-ctx hook but NOT converted — ` +
|
|
100
101
|
'the call still runs natively — so it is a way around the shell search ban rather than an ' +
|
|
@@ -142,7 +142,7 @@ const PROTECTED_CARRIER_DIRS: ReadonlySet<string> = new Set([
|
|
|
142
142
|
*
|
|
143
143
|
* `path` ending in a literal
|
|
144
144
|
* `/` (BEFORE normalization strips it for the comparison itself) means "a
|
|
145
|
-
* file goes INTO this directory" (`cp GLOBAL-AGENTS.md .claude/`, `mv
|
|
145
|
+
* file goes INTO this directory" (`cp TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md .claude/`, `mv
|
|
146
146
|
* notes.md .cursor/`) — the directory itself is untouched, only gaining an
|
|
147
147
|
* unrelated file — never "this directory is the thing being moved/
|
|
148
148
|
* overwritten" (`mv .claude .claude-old`, `mv attacker-stuff .codex` — no
|
|
@@ -90,7 +90,7 @@ the built-in target allowlist.
|
|
|
90
90
|
Ownership rule: machine-global agent configuration belongs to enact-vps;
|
|
91
91
|
everything else belongs in the owning repository. The sweep therefore treats
|
|
92
92
|
anything not declared by enact-vps (`protected.json`, the Kimi managed block,
|
|
93
|
-
|
|
93
|
+
global-agents deployment targets) as foreign:
|
|
94
94
|
|
|
95
95
|
- JSON entries: Claude `settings.json` `hooks`, `mcpServers`, `enabledPlugins`,
|
|
96
96
|
`extraKnownMarketplaces`, `permissions.allow|deny|ask` items; `.claude.json`
|
|
@@ -239,13 +239,13 @@ purpose even though it is a real, documented key, so the whole container
|
|
|
239
239
|
stays a sweep candidate whenever non-empty — see the `reason` field on that
|
|
240
240
|
baseline entry for the specific rationale.
|
|
241
241
|
|
|
242
|
-
## Global agent instructions (`GLOBAL-AGENTS.md`)
|
|
242
|
+
## Global agent instructions (`TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md`)
|
|
243
243
|
|
|
244
|
-
[`GLOBAL-AGENTS.md`](./assets/GLOBAL-AGENTS.md) is the single source of truth for
|
|
244
|
+
[`TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md`](./assets/TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md) is the single source of truth for
|
|
245
245
|
every host's global instruction file: `~/.claude/CLAUDE.md`,
|
|
246
246
|
`~/.codex/AGENTS.md`, `~/.cursor/AGENTS.md`, `~/.kimi-code/AGENTS.md`, and
|
|
247
247
|
`~/.config/opencode/AGENTS.md`. Any change to those files must be made in
|
|
248
|
-
`GLOBAL-AGENTS.md`; a direct edit to a target is drift and is overwritten on
|
|
248
|
+
`TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md`; a direct edit to a target is drift and is overwritten on
|
|
249
249
|
the next deploy.
|
|
250
250
|
|
|
251
251
|
```bash
|
|
@@ -254,6 +254,12 @@ bin/global-agents --home "$HOME" --mode deploy # forced
|
|
|
254
254
|
bin/global-agents --home "$HOME" --mode uninstall
|
|
255
255
|
```
|
|
256
256
|
|
|
257
|
+
Deploy and status run only on Tarun's fleet (Tarun, 2026-10-10): the device
|
|
258
|
+
name is the first label of the hostname (or `--host NAME`), and it must be a
|
|
259
|
+
`mac.devices` or `linux.devices` key in `packages/fleet/manifest.json`.
|
|
260
|
+
Any other machine writes no global file and prints one `skipped:` line saying
|
|
261
|
+
why; existing files there are left alone, and uninstall is not gated.
|
|
262
|
+
|
|
257
263
|
Deploy is always forced; there is no `--force` flag and no leave-in-place
|
|
258
264
|
mode. A missing target is created, a target already equal to the source is
|
|
259
265
|
left untouched, and a drifted target (hand-edited, or stale from an older
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Tarun's global agent instructions
|
|
2
|
+
|
|
3
|
+
My personal preferences, for every agent on every machine I own, for coding and non-coding work alike.
|
|
4
|
+
Where a repository has its own AGENTS.md, that file and the files it links add the repository's rules. They never relax Truthfulness, Verification before claiming done or Scope discipline here; on any other conflict, ask.
|
|
5
|
+
|
|
6
|
+
## Communication
|
|
7
|
+
- Address me as **Tarun**.
|
|
8
|
+
- **Answer first.** No greeting, no "let me", no recap of what was just done, no closers.
|
|
9
|
+
- One idea per sentence. Short beats polite. Articles can go; not, never, no, only never do. Numbers, paths, commands exact.
|
|
10
|
+
- Bullet points over prose.
|
|
11
|
+
- **Open points:** one numbered list of open decisions per conversation. Show it only when it changes — item added or resolved. After a long gap or compaction, restate the full list. That is when it gets lost.
|
|
12
|
+
- **Questions:** one at a time, one or two sentences. Options inline: `A) … B) …`. A long multi-part question means split the work, not more prose.
|
|
13
|
+
- Full sentences for security warnings, irreversible actions, step-by-step orders, and when I'm confused. Then short resumes.
|
|
14
|
+
- No performance. If short phrasing isn't shorter, plain English wins. Never rewrite my prompts.
|
|
15
|
+
|
|
16
|
+
## Truthfulness (non-negotiable)
|
|
17
|
+
- Never present something as done, working or checked when it is not. A failure reported plainly is worth more than a success that is not real.
|
|
18
|
+
- **No silent fallback or degrade** — if the real path fails, say so loudly and stop.
|
|
19
|
+
- **No silent drops** — no skipped steps, ignored errors or quietly narrowed scope.
|
|
20
|
+
- **No fakes** — no placeholders, stand-ins or TODOs passed off as finished.
|
|
21
|
+
- Unverified claims never enter anything that lasts: documents, messages, notes, memory.
|
|
22
|
+
- Never read secret stores or credential files, never pass a token or password in a command or message, and never claim to be me or set anything that marks the session as human (drafting in my name when I ask is fine).
|
|
23
|
+
|
|
24
|
+
## Verification before claiming done
|
|
25
|
+
- **Evidence over assertions:** show what you ran or read, and make the check falsifiable ("did you search the source?" has an answer; "did you verify?" does not).
|
|
26
|
+
- **A search shows what was returned, not what exists.** Before saying something is absent, say how you looked, and run a control search the same way for something you know is there. Search wide first, then narrow. No hits for both means you searched the wrong place.
|
|
27
|
+
- **Ask the authoritative source, not the one in front of you:** the original over a copy, the live system over a description of it.
|
|
28
|
+
- **Re-check before reporting.** Cite the check you are reporting, not one you remember. Never claim "checked everything" without checking every case.
|
|
29
|
+
- A requirement you are about to not meet is a **stop**: tell me; never quietly decide to skip it.
|
|
30
|
+
|
|
31
|
+
## Scope discipline
|
|
32
|
+
- Work in your own working directory. Touch only what is in scope: the files, accounts, people and systems the task names.
|
|
33
|
+
- Anything outside scope is raised as an open point for me (or recorded where that work is tracked): never silently done, never silently dropped.
|
|
34
|
+
|
|
35
|
+
## Delegation & parallelism
|
|
36
|
+
- Where the surface supports helper agents, the main agent orchestrates and verifies and helpers do the hands-on work; small tasks may be done directly.
|
|
37
|
+
- Split independent work into **concurrent agents** launched together — never sequential when it can fan out.
|
|
38
|
+
- Match the model to the task: strongest for deep analysis and judgment, fastest for quick lookups.
|
|
39
|
+
- Author and reviewer are **distinct passes in separate contexts**. Never self-approve.
|
|
40
|
+
- Prefer speed through parallel runs and fast models; never skip verification for speed.
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Deploy the canonical `packages/agent-settings/assets/GLOBAL-AGENTS.md` out
|
|
2
|
+
* Deploy the canonical `packages/agent-settings/assets/TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md` out
|
|
3
3
|
* to every AI-agent host's own global instruction file, so each host reads
|
|
4
4
|
* the same content: `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`,
|
|
5
5
|
* `~/.cursor/AGENTS.md`, `~/.kimi-code/AGENTS.md`,
|
|
6
6
|
* `~/.config/opencode/AGENTS.md`.
|
|
7
7
|
*
|
|
8
|
-
* The canonical source is committed in this repo (`../assets/GLOBAL-AGENTS.md`
|
|
8
|
+
* The canonical source is committed in this repo (`../assets/TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md`
|
|
9
9
|
* relative to this file), not read from any other repo's checkout at
|
|
10
10
|
* runtime — two different repos fighting over the same global target file is
|
|
11
11
|
* exactly the collision this replaces.
|
|
12
12
|
*
|
|
13
|
-
* Ownership model: GLOBAL-AGENTS.md is the single source of truth for every
|
|
13
|
+
* Ownership model: TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md is the single source of truth for every
|
|
14
14
|
* target. Deploy is always forced — there is no opt-out flag:
|
|
15
15
|
* - missing target -> created from the source.
|
|
16
16
|
* - content === source -> untouched (true no-op).
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* stale, both are drift). These exact files are
|
|
19
19
|
* authoritative adl roots: replacement does
|
|
20
20
|
* not retain a displaced copy.
|
|
21
|
-
* Any change to a host's global instructions must go through GLOBAL-AGENTS.md.
|
|
21
|
+
* Any change to a host's global instructions must go through TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md.
|
|
22
22
|
* Uninstall removes those same owned files regardless of their current bytes.
|
|
23
23
|
*/
|
|
24
24
|
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
rmSync,
|
|
32
32
|
writeFileSync,
|
|
33
33
|
} from 'node:fs'
|
|
34
|
+
import { hostname as osHostname } from 'node:os'
|
|
34
35
|
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
35
36
|
import { fileURLToPath } from 'node:url'
|
|
36
37
|
|
|
@@ -47,7 +48,29 @@ export const AGENT_TARGETS: readonly AgentTarget[] = [
|
|
|
47
48
|
{ relativeDir: '.config/opencode', file: 'AGENTS.md' },
|
|
48
49
|
]
|
|
49
50
|
|
|
50
|
-
export const DEFAULT_SOURCE_PATH = fileURLToPath(new URL('../assets/GLOBAL-AGENTS.md', import.meta.url))
|
|
51
|
+
export const DEFAULT_SOURCE_PATH = fileURLToPath(new URL('../assets/TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md', import.meta.url))
|
|
52
|
+
|
|
53
|
+
export const DEFAULT_MANIFEST_PATH = fileURLToPath(new URL('../../fleet/manifest.json', import.meta.url))
|
|
54
|
+
|
|
55
|
+
export type FleetScope = { readonly inFleet: true; readonly device: string } | { readonly inFleet: false; readonly note: string }
|
|
56
|
+
|
|
57
|
+
export function deviceNameOf(hostname: string): string {
|
|
58
|
+
return hostname.split('.')[0] ?? hostname
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Fleet-only deploy rule and its rationale: README "Global agent instructions".
|
|
62
|
+
export function resolveFleetScope(hostname: string, manifestPath: string): FleetScope {
|
|
63
|
+
let manifest: { mac?: { devices?: object }; linux?: { devices?: object } }
|
|
64
|
+
try {
|
|
65
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
66
|
+
} catch (cause) {
|
|
67
|
+
throw new Error(`cannot read fleet manifest ${manifestPath}: ${messageOf(cause)}`)
|
|
68
|
+
}
|
|
69
|
+
const device = deviceNameOf(hostname)
|
|
70
|
+
const names = [...Object.keys(manifest.mac?.devices ?? {}), ...Object.keys(manifest.linux?.devices ?? {})]
|
|
71
|
+
if (names.includes(device)) return { inFleet: true, device }
|
|
72
|
+
return { inFleet: false, note: `skipped: '${device}' is not a device in packages/fleet/manifest.json, so no global file is written` }
|
|
73
|
+
}
|
|
51
74
|
|
|
52
75
|
export type TargetState = 'missing' | 'matches' | 'drifted'
|
|
53
76
|
|
|
@@ -181,23 +204,35 @@ function readSource(sourcePath: string): string {
|
|
|
181
204
|
}
|
|
182
205
|
|
|
183
206
|
function usage(): never {
|
|
184
|
-
throw new Error('usage: global-agents --home ABSOLUTE_PATH --mode deploy|status|uninstall')
|
|
207
|
+
throw new Error('usage: global-agents --home ABSOLUTE_PATH --mode deploy|status|uninstall [--host NAME] [--manifest PATH]')
|
|
185
208
|
}
|
|
186
209
|
|
|
187
|
-
export function main(argv: readonly string[]): void {
|
|
210
|
+
export function main(argv: readonly string[], hostname: string = osHostname()): void {
|
|
188
211
|
let home = ''
|
|
189
212
|
let mode = 'status'
|
|
213
|
+
let host = hostname
|
|
214
|
+
let manifest = DEFAULT_MANIFEST_PATH
|
|
190
215
|
|
|
191
216
|
for (let index = 0; index < argv.length; index += 1) {
|
|
192
217
|
const argument = argv[index]
|
|
193
218
|
if (argument === '--home') home = argv[++index] ?? ''
|
|
194
219
|
else if (argument === '--mode') mode = argv[++index] ?? ''
|
|
220
|
+
else if (argument === '--host') host = argv[++index] ?? ''
|
|
221
|
+
else if (argument === '--manifest') manifest = argv[++index] ?? ''
|
|
195
222
|
else usage()
|
|
196
223
|
}
|
|
197
|
-
if (home.length === 0 || !['deploy', 'status', 'uninstall'].includes(mode)) usage()
|
|
224
|
+
if (home.length === 0 || host.length === 0 || manifest.length === 0 || !['deploy', 'status', 'uninstall'].includes(mode)) usage()
|
|
198
225
|
|
|
199
226
|
const sourceContent = readSource(DEFAULT_SOURCE_PATH)
|
|
200
227
|
|
|
228
|
+
if (mode !== 'uninstall') {
|
|
229
|
+
const scope = resolveFleetScope(host, manifest)
|
|
230
|
+
if (!scope.inFleet) {
|
|
231
|
+
console.log(`global-agents: ${scope.note}`)
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
201
236
|
if (mode === 'status') {
|
|
202
237
|
const statuses = inspectGlobalAgents(home, sourceContent)
|
|
203
238
|
for (const status of statuses) console.log(`${status.state}: ${status.path}`)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: adl-mechanics
|
|
3
|
+
description: How agents reach Azure DevOps in an adl-controlled repository — agent identity, the git front controller, the adl MCP tools and their CLI fallback, repository-control hooks, and when `adl run` may start a sub-agent.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# adl mechanics
|
|
7
|
+
|
|
8
|
+
How adl behaves on every machine and in every repository it controls.
|
|
9
|
+
|
|
10
|
+
## Use When
|
|
11
|
+
|
|
12
|
+
- You are about to run a git network command, open or update a PR, read or write a work item, or start a build.
|
|
13
|
+
- A command is refused for identity, credentials or hooks, and you need to know why.
|
|
14
|
+
- The operator asks you to start a sub-agent session with `adl run`.
|
|
15
|
+
|
|
16
|
+
## Agent identity
|
|
17
|
+
|
|
18
|
+
- Every Azure DevOps access (git, PRs, work items, builds, reads included) runs as an agent identity, never as the human. The default is the machine's default agent, set by fleet reconcile; it can always be overridden.
|
|
19
|
+
- **Override:** prefix a command with `ADL_AGENT_ID_AGENT=<agent-id>`. An MCP tool call can't carry a prefix, so it uses the session environment or the machine default; the CLI fallback (`adl mcp cli <command>`) takes the prefix like any command.
|
|
20
|
+
- Never read vault files. Never pass a token.
|
|
21
|
+
- **Never set `ADL_HUMAN_AZDO`.** It is the owner's human-terminal marker for `git-ado`'s and `adl id whoami`'s fail-closed human pass-through (stdin is a TTY and `ADL_HUMAN_AZDO=1`, exactly). An agent carrying it would let a child process forge that pass-through.
|
|
22
|
+
|
|
23
|
+
## Git and Azure DevOps operations
|
|
24
|
+
|
|
25
|
+
- Use ordinary `git` for every Git operation. The managed `/usr/local/bin/git` front controller hands local and non-Azure DevOps work to the system Git, and routes HTTPS Azure DevOps network commands through `adl-agent-id exec --agent "$ADL_AGENT_ID_AGENT"`. `git-ado` handles the credential boundary; there is no second account or secret location.
|
|
26
|
+
- PRs, work items and builds go through the `adl` MCP server (`adl mcp serve`): `pr_list_or_get`, `pr_create_or_update`, `pr_review`, `build_list_or_get`, `build_run`, `workitem_list_or_get`, `workitem_create_or_update`. If the server is not working, fall back to `adl mcp cli <command>`.
|
|
27
|
+
|
|
28
|
+
## Repository controls
|
|
29
|
+
|
|
30
|
+
- Run via `adl repo controls`. Git hooks run pre-commit (local checks) and pre-push (Azure DevOps governance).
|
|
31
|
+
- Each repository is reconciled on its own, with its `.agents/`, `.claude/`, `.codex/`, `.cursor/`, `.kimi-code/` and `.opencode/` projections.
|
|
32
|
+
|
|
33
|
+
## Sub-agent sessions (`adl run`)
|
|
34
|
+
|
|
35
|
+
- Only on the operator's explicit request; an agent never starts one on its own. It is not an identity route and not a fallback for anything.
|
|
36
|
+
- **Headless:** `adl run <surface> --headless "<prompt>"`, surface one of `claude`, `codex`, `opencode`, `kimi`, `cursor`.
|
|
37
|
+
- **Identity:** add `--as <agent>`.
|
|
38
|
+
- **Model:** add `--model <model>`. adl passes each surface's own flag (claude and cursor `--model`; codex, opencode and kimi `-m`); a surface without one refuses it (exit 2). `--model` cannot be combined with `adl run -- <cmd>`.
|
|
@@ -92,7 +92,7 @@ run_ts = sys.argv[2]
|
|
|
92
92
|
today = sys.argv[3]
|
|
93
93
|
log_files = sys.argv[4:]
|
|
94
94
|
|
|
95
|
-
cutoff = datetime.datetime.fromisoformat(today + "T00:00:
|
|
95
|
+
cutoff = datetime.datetime.fromisoformat(today + "T00:00:00+00:00") - datetime.timedelta(days=days-1)
|
|
96
96
|
|
|
97
97
|
# Per (host, day), tally. Dedupe by (ts, command, rule) within each log.
|
|
98
98
|
per_host_day = collections.defaultdict(lambda: collections.Counter())
|
|
@@ -8,7 +8,7 @@ disable-model-invocation: true
|
|
|
8
8
|
|
|
9
9
|
Open a **Feature** on the board and give it a plan folder to live in.
|
|
10
10
|
|
|
11
|
-
**A plan is a Feature, and a Feature is a directory** — `plans/features/az<id>-<slug>/spec.md`, named for the work item it is. This skill creates that Feature and that directory; `to-workitems` later fills its `items/` with PBIs and Bugs parented under it. Where the repo carries a `plans/AGENTS.md`, it governs; otherwise follow
|
|
11
|
+
**A plan is a Feature, and a Feature is a directory** — `plans/features/az<id>-<slug>/spec.md`, named for the work item it is. This skill creates that Feature and that directory; `to-workitems` later fills its `items/` with PBIs and Bugs parented under it. Where the repo carries a `plans/AGENTS.md`, it governs; otherwise follow `## Plans and work items` in the repository's `CONTRIBUTING.md`.
|
|
12
12
|
|
|
13
13
|
Do NOT interview the user — synthesize what you already know from the conversation and the codebase.
|
|
14
14
|
|
|
@@ -8,7 +8,7 @@ disable-model-invocation: true
|
|
|
8
8
|
|
|
9
9
|
Put a plan's **backlog items** on the board.
|
|
10
10
|
|
|
11
|
-
**A plan is a Feature. An item file is a PBI or a Bug.** One file each. The plan and its `## Items` table are written before this skill runs; the item files are not, because each is named for an id only this skill allocates. Where the repo carries a `plans/AGENTS.md`, it governs the layout; otherwise follow
|
|
11
|
+
**A plan is a Feature. An item file is a PBI or a Bug.** One file each. The plan and its `## Items` table are written before this skill runs; the item files are not, because each is named for an id only this skill allocates. Where the repo carries a `plans/AGENTS.md`, it governs the layout; otherwise follow `## Plans and work items` in the repository's `CONTRIBUTING.md`. [references/AZURE-DEVOPS-HIERARCHY.md](references/AZURE-DEVOPS-HIERARCHY.md) carries the board rules both depend on.
|
|
12
12
|
|
|
13
13
|
## Use When
|
|
14
14
|
|
|
@@ -47,6 +47,9 @@ expressed by parenting; they need `System.LinkTypes.Dependency-Reverse` links,
|
|
|
47
47
|
which `workitem_create_or_update`'s `predecessors` argument creates — one link
|
|
48
48
|
per id, in the order given.
|
|
49
49
|
|
|
50
|
+
Supplying `parentId` on an **update** moves the item: the old parent link is
|
|
51
|
+
removed, never kept alongside the new one.
|
|
52
|
+
|
|
50
53
|
`predecessors` is the only argument that creates them. It has no alias at the
|
|
51
54
|
tool level: `blockedBy` is not read.
|
|
52
55
|
|
|
@@ -147,7 +147,7 @@ export interface AgentIdentityResolution {
|
|
|
147
147
|
* - `env.ADL_HUMAN_AZDO === '1'`: the EXACT string `"1"`, not `"true"`,
|
|
148
148
|
* not `"yes"`, not trimmed whitespace -- Tarun exports it from his own
|
|
149
149
|
* `.zshrc`. An agent must never set it (packages/agent-settings'
|
|
150
|
-
* GLOBAL-AGENTS.md, "Agent identity"; the hook's denial of an agent
|
|
150
|
+
* TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md, "Agent identity"; the hook's denial of an agent
|
|
151
151
|
* setting it is packages/agent-hooks work, not this module's).
|
|
152
152
|
*
|
|
153
153
|
* No fallback, no trimming, no case-insensitivity: a near-miss value is
|
|
@@ -359,7 +359,7 @@ guidance (see [Compliance seeding](#compliance-seeding) below). The decisions
|
|
|
359
359
|
and the gate list live in
|
|
360
360
|
[`docs/architecture/compliance-controls.md`](../../docs/architecture/compliance-controls.md#strict-activation-and-switching-a-gate-off).
|
|
361
361
|
`install --rewire` (what fleet reconcile runs on every enrolled repo) seeds nothing,
|
|
362
|
-
scaffolds nothing and does not grade: it only re-deploys the hook stubs, `.workflows`
|
|
362
|
+
scaffolds nothing (no root template either) and does not grade: it only re-deploys the hook stubs, `.workflows`
|
|
363
363
|
and the lock, and leaves adl-config.toml byte-identical. Without `uv`, the `adl install`
|
|
364
364
|
wrapper prints a one-line warning that grading is skipped, finishes deploying and exits 0;
|
|
365
365
|
pre-commit still fails loudly without `uv`. See
|
|
@@ -551,12 +551,17 @@ doctor report and never repairs or stages the managed surface: run
|
|
|
551
551
|
An existing `[controls.compliance]` with no `trees` table gets it appended; one
|
|
552
552
|
that already has a `trees` table (any keys) is left byte-identical.
|
|
553
553
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
`
|
|
557
|
-
`
|
|
558
|
-
|
|
559
|
-
`
|
|
554
|
+
### Root templates
|
|
555
|
+
|
|
556
|
+
The root templates `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md` and `INVARIANTS.md`
|
|
557
|
+
(`SCAFFOLD_ROOT_FILES` in `src/constants.ts`, sources in `assets/scaffold/root/`) are
|
|
558
|
+
written, when absent, only by a run that creates `adl-config.toml` (`adl install` on a
|
|
559
|
+
repository without one, or `adl migrate`; the internal `install --config-created` flag, set by `adl install`,
|
|
560
|
+
says so and is deliberately absent from help). `--rewire`, fleet reconcile and any later install never write them, even when a
|
|
561
|
+
file is missing. The only overwrite is `install --force-templates[=<file,...>]`: bare means
|
|
562
|
+
all four, a comma list picks files, an unknown name exits 2 listing the valid ones, and it
|
|
563
|
+
cannot be combined with `--rewire`. Each overwrite is an `update` action, also in `--json`; a forced template whose bytes already match is left unchanged and reported as no action.
|
|
564
|
+
The engine's root gate still requires `INVARIANTS.md`. See Scaffolding below
|
|
560
565
|
for which folders it creates.
|
|
561
566
|
|
|
562
567
|
#### Grading
|
|
@@ -1,22 +1,71 @@
|
|
|
1
1
|
# Agent entry point
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
repository. Edit it to describe what this repository is; the sections below
|
|
5
|
-
describe the layout the adl repo controls enforce.
|
|
3
|
+
First file a human or agent reads here. Keep it short.
|
|
6
4
|
|
|
7
|
-
##
|
|
5
|
+
## What this repository is
|
|
6
|
+
- <!-- Owner: one or two lines on what this repo is and who uses it. -->
|
|
7
|
+
|
|
8
|
+
## Layout (paths as declared in `[controls.compliance.trees]`)
|
|
8
9
|
|
|
9
10
|
| Path | What lives there |
|
|
10
11
|
| --- | --- |
|
|
11
12
|
| `docs/` | reference documentation, see `docs/AGENTS.md` |
|
|
12
13
|
| `plans/` | Features and the PBIs/Bugs under them, see `plans/AGENTS.md` |
|
|
13
14
|
| `workflows/` | CI pipelines and build/release scripts, see `workflows/AGENTS.md` |
|
|
14
|
-
| `.workflows/` | managed hook stubs and lock
|
|
15
|
-
|
|
16
|
-
## Rules
|
|
15
|
+
| `.workflows/` | managed hook stubs and lock; rebuilt by `adl repo controls install`, never hand-edit |
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
- `
|
|
17
|
+
## Where to look next
|
|
18
|
+
- `CONTRIBUTING.md`: plans and work items, commits and PRs, release flow, tasks ledger.
|
|
19
|
+
- `INVARIANTS.md`: what must never regress, how each is verified, protected paths.
|
|
20
|
+
- Graded on every commit by `adl repo controls compliance run`; enabled gates are `[controls.compliance].enabled` in `adl-config.toml`. A failing gate is fixed, not skipped.
|
|
22
21
|
- Work-item status lives on the board, never in a file.
|
|
22
|
+
|
|
23
|
+
## Keep this guidance alive
|
|
24
|
+
- **When:** you were corrected by the owner, a gate or test failed for a reason not written here, or you learned a rule the hard way.
|
|
25
|
+
- **Where (one place only):**
|
|
26
|
+
- convention or workflow → `CONTRIBUTING.md`
|
|
27
|
+
- must-never-regress rule → `INVARIANTS.md`, only with its verifying gate/test/command; no check, no invariant
|
|
28
|
+
- orientation (what lives where) → `AGENTS.md`
|
|
29
|
+
- folder-specific → that folder's `AGENTS.md`
|
|
30
|
+
- applies to every repository → tell the owner; never edit global files
|
|
31
|
+
- **How:** put the edit in the same commit/PR that taught you it; edit the existing bullet instead of adding a near-duplicate; one bullet, at most 2 lines.
|
|
32
|
+
- **Never record:** secrets, ids/URLs of one-off items, session or progress state, unverified claims, rules a gate already enforces and explains in its own message.
|
|
33
|
+
|
|
34
|
+
## Working defaults for code
|
|
35
|
+
Tarun's global instructions cover communication, truthfulness, verification, scope and delegation. These are the coding defaults. Edit them for this repository, except "Truthfulness and verification in code", which a repository may add to but never relax.
|
|
36
|
+
|
|
37
|
+
### Truthfulness and verification in code
|
|
38
|
+
- No `|| true`, no swallowed errors, no skipped gates.
|
|
39
|
+
- Negative claims need source-level proof: grep the implementation, not a tool listing. Search the whole package first, then narrow.
|
|
40
|
+
- Before reporting what the repository contains, ask the remote (`git show origin/<branch>:<path>`); before reporting a shipped tool's behaviour, ask the installed binary.
|
|
41
|
+
- Name the ref. Where a change is in flight, two branches answer differently and both are right about themselves; say which one you asked.
|
|
42
|
+
- Deployed is not running: a long-lived server or daemon keeps the code it started with. When a fix is installed and behaviour has not changed, restart the process first.
|
|
43
|
+
|
|
44
|
+
### Checkouts
|
|
45
|
+
- Commit rules: `CONTRIBUTING.md`.
|
|
46
|
+
- At most 3 checkouts per repository, including the main folder; git refuses the 4th and names the command to use. Reconcile old worktrees first (merge, then `git worktree remove <path>`).
|
|
47
|
+
- Prefer the one checkout; parallel subagents share it, each with its own write scope. Only the orchestrator commits.
|
|
48
|
+
|
|
49
|
+
### Tests
|
|
50
|
+
- For features, bugfixes and non-trivial refactors: tests first, confirmed failing for the right reason; then minimal code to pass; then a separate review pass.
|
|
51
|
+
- Every code change ships a new test. Exempt: pure config, docs, trivial one-liners.
|
|
52
|
+
- A test changes only when the intended behaviour changes. Don't loosen, skip or delete an assertion, or set an expectation to whatever the code returns, to hide a failure. Red is honest.
|
|
53
|
+
- When a change deliberately alters behaviour, rework the tests that assert the old behaviour in the same change; name each one and the decision that changed it.
|
|
54
|
+
|
|
55
|
+
### Fixing things
|
|
56
|
+
- Fix at the source — the primitive, token or shared layer — so it propagates everywhere, not a narrow downstream patch.
|
|
57
|
+
- Consult official docs before coding against an unfamiliar SDK, framework or API.
|
|
58
|
+
|
|
59
|
+
### Context tooling
|
|
60
|
+
- Match the tool to the question (when installed; otherwise `rg`):
|
|
61
|
+
- Orient in unfamiliar code → `crg architecture`, `crg communities`.
|
|
62
|
+
- Symbols and semantic code queries → `crg search "<text>"`.
|
|
63
|
+
- Callers, callees, blast radius → `crg query callers_of|callees_of|tests_for|importers_of <target>`, `crg impact`.
|
|
64
|
+
- Unreferenced code → `crg dead-code`.
|
|
65
|
+
- A code shape rather than a string → `ast-grep`.
|
|
66
|
+
- Prose in documentation folders (`docs/`, `plans/`) → `search <text>` (hybrid semantic + keyword).
|
|
67
|
+
- Literal text or regex → `rg`. Never `grep`; never `find | grep`, which filters file names and misses the file whose contents match.
|
|
68
|
+
- Files by name or tree → the host's native file-listing tools.
|
|
69
|
+
|
|
70
|
+
### Temporary files
|
|
71
|
+
- Scratch and throwaway work goes in `.scratchpad/` at the repository root, never `/tmp` or `/private/tmp`. Create it if missing; it is gitignored. Real deliverables go to their proper home.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
How to contribute to THIS repository. Orientation is in `AGENTS.md`; what must never regress, and the protected paths, are in `INVARIANTS.md`. Tree paths are those declared in `[controls.compliance.trees]`; `plans/` is the default.
|
|
4
|
+
|
|
5
|
+
## Plans and work items
|
|
6
|
+
- A plan is a Feature; a backlog item is a PBI or a Bug. One file each; the board is the only place their state lives.
|
|
7
|
+
- Layout: `plans/features/az<feature id>-<slug>/spec.md`, items under `items/az<id>-<slug>.md`.
|
|
8
|
+
- The id is the identity: allocate the item on the board first, then name the file. Until then it is a row in the plan's `## Items` table.
|
|
9
|
+
- Nothing moves between directories; the spec text is the item's fields.
|
|
10
|
+
- A gap found along the way goes in `plans/GAPS-AND-FINDINGS.md` and becomes a Bug under the same Feature.
|
|
11
|
+
|
|
12
|
+
## Commits and PRs
|
|
13
|
+
- Each commit is independently green and on one topic. Never `git stash`. Green wins over one topic, and say so.
|
|
14
|
+
- Subject: `<type>(<scope>): <lowercase subject>`; types `feat|fix|docs|refactor|perf|chore`; no `!`.
|
|
15
|
+
- Branch: `<type>/<kebab>`, same types, not configurable.
|
|
16
|
+
- `release:` is reserved for the promotion PR the pipeline raises; never write one by hand.
|
|
17
|
+
- A PR body needs non-empty `## Summary`, `## Why` and `## Changes`; the title follows the subject grammar.
|
|
18
|
+
|
|
19
|
+
## Release flow
|
|
20
|
+
- `<type>/<kebab>` branch → PR → `integration` (auto-completes when green) → pipeline opens `integration → main` → owner approves. No agent opens that PR.
|
|
21
|
+
- A repository that differs states it under "Release flow of this repository".
|
|
22
|
+
|
|
23
|
+
## Tasks ledger
|
|
24
|
+
- At the start and the end of a task, append one line to `docs/notes.md` (create it if missing, with YAML frontmatter and a `## Lines` section).
|
|
25
|
+
- Format `YYYY-MM-DD HH:MM <surface> <note>`, whole line ≤100 characters. Append-only, newest last, never rewrite earlier lines. One ledger per repository.
|
|
26
|
+
|
|
27
|
+
## Release flow of this repository
|
|
28
|
+
- <!-- Owner: state any difference from the flow above; delete this section if none. -->
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Invariants
|
|
2
|
+
|
|
3
|
+
Must-never-regress rules. One bullet each: `rule` — verified by `gate/test/command`. No check, no invariant. Add yours under "This repository".
|
|
4
|
+
|
|
5
|
+
## Estate-wide
|
|
6
|
+
- The compliance gates enabled in `adl-config.toml` pass before every commit — verified by `adl repo controls` pre-commit and CI. A gate is switched off only by the owner, by removing it from `[controls.compliance] enabled`.
|
|
7
|
+
- A compliance exemption list only shrinks: remove a file once it passes; never add one — verified by the gate that reads the register.
|
|
8
|
+
- No multi-line comments in code; the explanation lives in `docs/` — verified by `repository.no-multiline-comments`.
|
|
9
|
+
- The standard trees `docs`, `plans`, `workflows` and `compliance` are those declared in `[controls.compliance.trees]`, matching the layout in `AGENTS.md` — verified by the layout and docs gates.
|
|
10
|
+
|
|
11
|
+
## Protected paths
|
|
12
|
+
- Landed only by the owner; an agent leaves them uncommitted and asks — verified by the pre-commit integrity gate.
|
|
13
|
+
- `adl-config.toml`, `compliance/` (or the compliance tree declared in `[controls.compliance.trees]`) and `workflows/`, plus any pattern this repository adds under `[controls.integrity].watched`.
|
|
14
|
+
- No dot folder is protected as a whole: `.workflows/` and the agent surface folders hold the repository's own hooks and skills.
|
|
15
|
+
|
|
16
|
+
## This repository
|
|
17
|
+
- <!-- Owner: `rule` — verified by `gate/test/command`. -->
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { HOOK_NAMES, TOOL, VERSION } from './constants.ts'
|
|
1
|
+
import { HOOK_NAMES, SCAFFOLD_ROOT_FILES, TOOL, VERSION } from './constants.ts'
|
|
2
2
|
import { doctor } from './doctor.ts'
|
|
3
3
|
import { azdoPush } from './azdo-push.ts'
|
|
4
4
|
import { ensurePromotionPullRequest } from './promote.ts'
|
|
@@ -18,7 +18,8 @@ export interface Io {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
const USAGE = `usage:
|
|
21
|
-
${TOOL} install [--repo <path>] [--dry-run] [--json] [--no-grade] [--rewire]
|
|
21
|
+
${TOOL} install [--repo <path>] [--dry-run] [--json] [--no-grade] [--rewire] [--force-templates[=<file,...>]]
|
|
22
|
+
(--force-templates: overwrite the root templates; bare = all of ${SCAFFOLD_ROOT_FILES.join(', ')}, or a comma list)
|
|
22
23
|
${TOOL} grade [--repo <path>] (prints the compliance verdict as JSON; exit 0 whatever the verdict)
|
|
23
24
|
${TOOL} uninstall [--repo <path>] [--dry-run]
|
|
24
25
|
${TOOL} doctor [--repo <path>] [--json]
|
|
@@ -40,6 +41,8 @@ interface Parsed {
|
|
|
40
41
|
flags: Set<string>
|
|
41
42
|
positional: string[]
|
|
42
43
|
rest: string[]
|
|
44
|
+
/** The raw `--force-templates=<list>` value; undefined for the bare flag. */
|
|
45
|
+
forced?: string
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
function parse(command: string, argv: string[], allowed: string[]): Parsed {
|
|
@@ -50,7 +53,11 @@ function parse(command: string, argv: string[], allowed: string[]): Parsed {
|
|
|
50
53
|
parsed.rest = argv.slice(index + 1)
|
|
51
54
|
break
|
|
52
55
|
}
|
|
53
|
-
if (arg === '--
|
|
56
|
+
if (arg === '--force-templates' || arg.startsWith('--force-templates=')) {
|
|
57
|
+
if (!allowed.includes('--force-templates')) throw new UsageError(`unknown option for ${command}: ${arg}`)
|
|
58
|
+
parsed.flags.add('--force-templates')
|
|
59
|
+
if (arg.includes('=')) parsed.forced = arg.slice(arg.indexOf('=') + 1)
|
|
60
|
+
} else if (arg === '--repo') {
|
|
54
61
|
parsed.repo = argv[++index] ?? ''
|
|
55
62
|
} else if (arg.startsWith('--')) {
|
|
56
63
|
if (!allowed.includes(arg)) throw new UsageError(`unknown option for ${command}: ${arg}`)
|
|
@@ -66,6 +73,15 @@ function parse(command: string, argv: string[], allowed: string[]): Parsed {
|
|
|
66
73
|
return parsed
|
|
67
74
|
}
|
|
68
75
|
|
|
76
|
+
/** The root templates named by `--force-templates[=<list>]`; the bare flag means all, an unknown or empty name is refused. */
|
|
77
|
+
function forcedTemplates(value: string | undefined): string[] {
|
|
78
|
+
if (value === undefined) return [...SCAFFOLD_ROOT_FILES]
|
|
79
|
+
const names = value.split(',')
|
|
80
|
+
const unknown = names.filter((name) => !(SCAFFOLD_ROOT_FILES as readonly string[]).includes(name))
|
|
81
|
+
if (unknown.length > 0) throw new UsageError(`unknown root template for --force-templates: ${unknown.map((n) => `'${n}'`).join(', ')} (valid: ${SCAFFOLD_ROOT_FILES.join(', ')})`)
|
|
82
|
+
return names
|
|
83
|
+
}
|
|
84
|
+
|
|
69
85
|
function printActions(io: Io, actions: Action[], dryRun: boolean): void {
|
|
70
86
|
const tag = dryRun ? '[dry-run] ' : ''
|
|
71
87
|
for (const action of actions) io.out(`${tag}${action.kind} ${action.target} (${action.reason})`)
|
|
@@ -84,10 +100,12 @@ async function dispatch(argv: string[], io: Io): Promise<number> {
|
|
|
84
100
|
io.out(USAGE)
|
|
85
101
|
return 0
|
|
86
102
|
case 'install': {
|
|
87
|
-
const parsed = parse(command, args, ['--dry-run', '--json', '--no-grade', '--rewire'])
|
|
103
|
+
const parsed = parse(command, args, ['--dry-run', '--json', '--no-grade', '--rewire', '--config-created', '--force-templates'])
|
|
88
104
|
const rewire = parsed.flags.has('--rewire')
|
|
105
|
+
const forceTemplates = parsed.flags.has('--force-templates') ? forcedTemplates(parsed.forced) : []
|
|
106
|
+
if (rewire && forceTemplates.length > 0) throw new UsageError('--force-templates cannot be combined with --rewire (a re-wire never writes root templates)')
|
|
89
107
|
const grade = parsed.flags.has('--no-grade') || rewire ? {} : { grade: { env: io.env } }
|
|
90
|
-
const result = install(parsed.repo, { dryRun: parsed.flags.has('--dry-run'), rewire, ...grade })
|
|
108
|
+
const result = install(parsed.repo, { dryRun: parsed.flags.has('--dry-run'), rewire, configCreated: parsed.flags.has('--config-created'), forceTemplates, ...grade })
|
|
91
109
|
if (parsed.flags.has('--json')) {
|
|
92
110
|
io.out(JSON.stringify(result))
|
|
93
111
|
return 0
|
|
@@ -2,16 +2,6 @@ import { readFileSync } from 'node:fs'
|
|
|
2
2
|
import { join, resolve } from 'node:path'
|
|
3
3
|
|
|
4
4
|
export const TOOL = 'adl-repo-controls'
|
|
5
|
-
/**
|
|
6
|
-
* Never published (2026-09-27): kept only as this
|
|
7
|
-
* package's own internal identity (USAGE text, error messages, the lock
|
|
8
|
-
* file's `tool` field) — none of that is a claim that `adl-repo-controls`
|
|
9
|
-
* is installable. NPM_INSTALL/PACKAGE_NAME describe a registry package that
|
|
10
|
-
* does not exist; do not point a user-facing "how do I get this on PATH"
|
|
11
|
-
* message at them (see ADL_NPM_INSTALL below for the one that is real).
|
|
12
|
-
*/
|
|
13
|
-
const PACKAGE_NAME = '@amsterdamdatalabs/adl-repo-controls'
|
|
14
|
-
const NPM_INSTALL = `npm i -g ${PACKAGE_NAME}`
|
|
15
5
|
/**
|
|
16
6
|
* How a repository's committed git-hook stubs actually get `adl` onto
|
|
17
7
|
* PATH: this package is folded into the `adl` binary's `repo controls`
|
|
@@ -72,11 +62,8 @@ export const RECONCILE_GUARD_REL = '.workflows/reconcile-guard.sh'
|
|
|
72
62
|
/** The folder skeleton `install` bootstraps for the adl standard trees; see README, "Scaffolding". */
|
|
73
63
|
export const SCAFFOLD_ROOT = join(PACKAGE_ROOT, 'assets', 'scaffold')
|
|
74
64
|
export const SCAFFOLD_TREES = ['docs', 'plans', 'workflows'] as const
|
|
75
|
-
/** Repository-root
|
|
76
|
-
export const SCAFFOLD_ROOT_FILES = ['AGENTS.md', 'CLAUDE.md'] as const
|
|
77
|
-
/** The invariants that apply to ALL repositories; install deploys it as `INVARIANTS.md` only when the target has none. */
|
|
78
|
-
export const GLOBAL_INVARIANTS_SOURCE = join(PACKAGE_ROOT, 'assets', 'GLOBAL-INVARIANTS.md')
|
|
79
|
-
export const GLOBAL_INVARIANTS_TARGET = 'INVARIANTS.md'
|
|
65
|
+
/** Repository-root templates in `assets/scaffold/root/`: written only by a run that creates adl-config.toml, overwritten only by `--force-templates`. */
|
|
66
|
+
export const SCAFFOLD_ROOT_FILES = ['AGENTS.md', 'CLAUDE.md', 'CONTRIBUTING.md', 'INVARIANTS.md'] as const
|
|
80
67
|
|
|
81
68
|
export const MANAGED_ASSETS = [
|
|
82
69
|
{ rel: PROMOTE_REL, source: join(PACKAGE_ROOT, 'assets', 'promote.ts'), mode: 0o755, doctorId: 'promote' },
|
|
@@ -24,8 +24,6 @@ import {
|
|
|
24
24
|
ADL_NPM_INSTALL,
|
|
25
25
|
CONFIG_REL,
|
|
26
26
|
GITIGNORE_REL,
|
|
27
|
-
GLOBAL_INVARIANTS_SOURCE,
|
|
28
|
-
GLOBAL_INVARIANTS_TARGET,
|
|
29
27
|
HOOK_NAMES,
|
|
30
28
|
HOOKS_PATH,
|
|
31
29
|
LOCK_REL,
|
|
@@ -234,6 +232,10 @@ export interface InstallOptions {
|
|
|
234
232
|
grade?: { env: Env }
|
|
235
233
|
/** Re-wire only: no config seeding, no scaffolding, no grading (fleet reconcile; see README, "Re-wiring"). */
|
|
236
234
|
rewire?: boolean
|
|
235
|
+
/** This run created adl-config.toml, so the root templates are written when absent; see README, "Root templates". */
|
|
236
|
+
configCreated?: boolean
|
|
237
|
+
/** Root templates to overwrite (`--force-templates`); the only way an existing template is replaced. */
|
|
238
|
+
forceTemplates?: readonly string[]
|
|
237
239
|
}
|
|
238
240
|
|
|
239
241
|
export interface InstallResult {
|
|
@@ -266,6 +268,7 @@ function readCentralMarker(repo: string, dryRun: boolean): string {
|
|
|
266
268
|
export function install(repoArg: string, options: InstallOptions): InstallResult {
|
|
267
269
|
const repo = resolveRepo(repoArg)
|
|
268
270
|
const dryRun = options.dryRun === true
|
|
271
|
+
const configMissing = !existsSync(join(repo, CONFIG_REL))
|
|
269
272
|
const initialConfigText = readCentralMarker(repo, dryRun)
|
|
270
273
|
// Validate only repo-controls' declared tables before deleting its root.
|
|
271
274
|
// Other owners' tables remain deliberately opaque.
|
|
@@ -336,6 +339,7 @@ export function install(repoArg: string, options: InstallOptions): InstallResult
|
|
|
336
339
|
created: complianceResult.created,
|
|
337
340
|
sha256: sha256(extractTableSpan(finalConfigText, 'controls.compliance') as string),
|
|
338
341
|
}
|
|
342
|
+
scaffoldRootTemplates(ops, options.configCreated === true || (dryRun && configMissing), options.forceTemplates ?? [])
|
|
339
343
|
scaffoldStandardTrees(finalConfigText, ops)
|
|
340
344
|
}
|
|
341
345
|
|
|
@@ -507,10 +511,6 @@ function assertComplianceTree(value: unknown): void {
|
|
|
507
511
|
/** Bootstrap the standard trees at their declared paths, only where the folder is missing; see README, "Scaffolding". */
|
|
508
512
|
function scaffoldStandardTrees(configText: string, ops: Ops): void {
|
|
509
513
|
const missing = declaredScaffoldTrees(configText).filter(({ base }) => !ops.occupied(base))
|
|
510
|
-
for (const rel of SCAFFOLD_ROOT_FILES) {
|
|
511
|
-
ops.create(rel, readFileSync(join(SCAFFOLD_ROOT, 'root', rel)), 'scaffold the repository entry files (user-owned after install)')
|
|
512
|
-
}
|
|
513
|
-
ops.create(GLOBAL_INVARIANTS_TARGET, readFileSync(GLOBAL_INVARIANTS_SOURCE), 'deploy the global invariants (user-owned after install)')
|
|
514
514
|
for (const { name, base } of missing) {
|
|
515
515
|
for (const rel of scaffoldFiles(join(SCAFFOLD_ROOT, name), '')) {
|
|
516
516
|
ops.create(`${base}/${rel}`, readFileSync(join(SCAFFOLD_ROOT, name, rel)), `scaffold the ${name} tree (user-owned after install)`)
|
|
@@ -518,6 +518,15 @@ function scaffoldStandardTrees(configText: string, ops: Ops): void {
|
|
|
518
518
|
}
|
|
519
519
|
}
|
|
520
520
|
|
|
521
|
+
/** Root templates: created when absent on a run that created adl-config.toml, overwritten only for the forced names; see README, "Root templates". */
|
|
522
|
+
function scaffoldRootTemplates(ops: Ops, created: boolean, forced: readonly string[]): void {
|
|
523
|
+
for (const rel of SCAFFOLD_ROOT_FILES) {
|
|
524
|
+
const bytes = readFileSync(join(SCAFFOLD_ROOT, 'root', rel))
|
|
525
|
+
if (forced.includes(rel)) ops.write(rel, bytes, 'overwrite the root template (--force-templates)')
|
|
526
|
+
else if (created) ops.create(rel, bytes, 'scaffold the repository root template (user-owned after install)')
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
521
530
|
/** Every file under `dir`, relative to it, sorted, depth-first. */
|
|
522
531
|
function scaffoldFiles(dir: string, prefix: string): string[] {
|
|
523
532
|
return readdirSync(join(dir, prefix), { withFileTypes: true })
|