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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/bin/adl +2 -2
  2. package/bin/reconcile +4 -3
  3. package/package.json +1 -1
  4. package/packages/agent-hooks/README.md +14 -0
  5. package/packages/agent-hooks/assets/opencode-plugin.template.ts +140 -104
  6. package/packages/agent-hooks/src/deploy/opencode-template.ts +5 -18
  7. package/packages/agent-hooks/src/deploy/render.ts +12 -3
  8. package/packages/agent-hooks/src/rules/impl/git-branch.ts +63 -0
  9. package/packages/agent-hooks/src/rules/impl/git-helpers.ts +91 -3
  10. package/packages/agent-hooks/src/rules/impl/git-history.ts +4 -13
  11. package/packages/agent-hooks/src/rules/impl/git-target.ts +423 -0
  12. package/packages/agent-hooks/src/rules/impl/tool-native-search.ts +8 -7
  13. package/packages/agent-hooks/src/rules/ports.ts +36 -0
  14. package/packages/agent-hooks/src/rules/registry.ts +3 -2
  15. package/packages/agent-hooks/src/rules/shell/protected-carriers.ts +1 -1
  16. package/packages/agent-settings/README.md +10 -4
  17. package/packages/agent-settings/assets/GLOBAL-AGENTS.md +5 -0
  18. package/packages/agent-settings/assets/TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md +40 -0
  19. package/packages/agent-settings/src/global-agents.ts +43 -8
  20. package/packages/agent-skills/assets/adl-mechanics/SKILL.md +38 -0
  21. package/packages/agent-skills/assets/agent-search-tool-audit/scripts/audit.sh +1 -1
  22. package/packages/agent-skills/assets/to-feature/SKILL.md +1 -1
  23. package/packages/agent-skills/assets/to-workitems/SKILL.md +1 -1
  24. package/packages/agent-skills/assets/to-workitems/references/AZURE-DEVOPS-HIERARCHY.md +3 -0
  25. package/packages/azdo/src/lib/agent-identity.ts +1 -1
  26. package/packages/repo-controls/README.md +12 -7
  27. package/packages/repo-controls/assets/scaffold/root/AGENTS.md +60 -11
  28. package/packages/repo-controls/assets/scaffold/root/CONTRIBUTING.md +28 -0
  29. package/packages/repo-controls/assets/scaffold/root/INVARIANTS.md +17 -0
  30. package/packages/repo-controls/src/cli.ts +23 -5
  31. package/packages/repo-controls/src/constants.ts +2 -15
  32. package/packages/repo-controls/src/install.ts +15 -6
@@ -1,18 +1,18 @@
1
1
  /**
2
- * `branch -D`, `tag -d`, `switch -f`, `gc --prune[=<date>]`.
2
+ * `tag -d`, `switch -f`, `gc --prune[=<date>]`.
3
3
  * (`update-ref` in every form lives in git-plumbing.ts.)
4
4
  *
5
5
  * Ported from `blockedGitHistoryReason` in blocked-reason.mjs:78-86. None of
6
6
  * these bypass on a bare `--help`/`--version` the way the "core" rules do —
7
7
  * the original's `blockedGitPolicyReason` is called unconditionally, with no
8
- * `rmIsHelpOnly` gate — so neither does this. All four fall through
8
+ * `rmIsHelpOnly` gate — so neither does this. All three fall through
9
9
  * `classifyReason`'s default arm.
10
10
  */
11
11
 
12
12
  import type { Invocation } from '../shell/invocation.ts'
13
13
  import type { Finding, MessageContext, RuleSpec } from '../spec.ts'
14
14
  import { noParams } from '../spec.ts'
15
- import { branchForceDeletes, gcPrunesExplicitly, switchIsForced, tagDeletes } from './git-helpers.ts'
15
+ import { gcPrunesExplicitly, switchIsForced, tagDeletes } from './git-helpers.ts'
16
16
  import { defaultGitMessage, detailString } from './messages.ts'
17
17
 
18
18
  function git(inv: Invocation): (Invocation & { kind: 'git' }) | null {
@@ -40,15 +40,6 @@ function make(id: string, subcommand: string, reasonFor: () => string, hits: (g:
40
40
  }
41
41
  }
42
42
 
43
- export const gitBranchForceDelete = make(
44
- 'git.branch-force-delete',
45
- 'branch',
46
- () => 'git branch -D',
47
- (g) => branchForceDeletes(g.rest),
48
- ['git branch -D old-feature', 'git branch -aD', 'git branch --delete --force old-feature'],
49
- ['git branch -d merged-feature', 'git branch feature-x', 'git branch -a'],
50
- )
51
-
52
43
  export const gitTagDelete = make(
53
44
  'git.tag-delete',
54
45
  'tag',
@@ -64,7 +55,7 @@ export const gitSwitchForce = make(
64
55
  () => 'git switch -f',
65
56
  (g) => switchIsForced(g.rest),
66
57
  ['git switch -f main', 'git switch --force main'],
67
- ['git switch main', 'git switch -c new-branch'],
58
+ ['git switch main', 'git switch feat/existing'],
68
59
  )
69
60
 
70
61
  export const gitGcPrune = make(
@@ -0,0 +1,423 @@
1
+ // Best-effort static resolver of the repository a commit-like git command lands on. See docs/architecture/agent-git-branch-rules.md#resolver
2
+
3
+ import { statSync } from 'node:fs'
4
+ import { homedir } from 'node:os'
5
+ import type { BranchTarget } from '../ports.ts'
6
+ import type { Invocation } from '../shell/invocation.ts'
7
+ import { withoutRedirections } from '../shell/git-internals.ts'
8
+ import { splitShell } from '../shell/split.ts'
9
+ import { findMatchingBacktick, findMatchingParen } from '../shell/substitution.ts'
10
+ import { type Token, basename, tokenValue, tokenize } from '../shell/token.ts'
11
+
12
+ // Subcommands that add history to the current branch (pull, rebase, reset, amend are excluded: see docs/architecture/agent-git-branch-rules.md)
13
+ export const LANDING_SUBCOMMANDS: ReadonlySet<string> = new Set(['commit', 'merge', 'cherry-pick', 'revert', 'am'])
14
+
15
+ const ASSIGNMENT_RE = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/
16
+ const SHELLS: ReadonlySet<string> = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh'])
17
+ /** Commands that re-run another command: a shell `-c` script behind one is followed. */
18
+ const RE_EXECUTORS: ReadonlySet<string> = new Set(['xargs', 'parallel', 'env', 'nice', 'nohup', 'timeout', 'sudo', 'doas'])
19
+
20
+ // Bash builtins and reserved words: `compgen -b` + `compgen -k` on bash 5.3 (docs/architecture/agent-git-branch-rules.md#closed-builtin-sets-and-their-provenance)
21
+ const BASH_BUILTINS_AND_KEYWORDS: readonly string[] = (
22
+ '. : [ alias bg bind break builtin caller cd command compgen complete compopt continue declare dirs disown echo enable eval exec exit export false fc fg getopts hash help history jobs kill let local logout mapfile popd printf pushd pwd read readarray readonly return set shift shopt source suspend test times trap true type typeset ulimit umask unalias unset wait if then else elif fi case esac for select while until do done in function time { } ! [[ ]] coproc').split(' ')
23
+ // zsh builtins and reserved words: `zsh -c 'print ${(k)builtins} ${(k)reswords}'` on zsh 5.9 (same doc section)
24
+ const ZSH_BUILTINS_AND_RESERVED_WORDS: readonly string[] = (
25
+ 'unset rehash popd ulimit local jobs disable [ compfiles printf autoload noglob pushln zle readonly exit false times sched setopt getln builtin let bg which unhash pwd zparseopts logout disown type source eval comptags compdescribe compctl r zmodload zregexparse history return exec compadd emulate chdir ttyctl test comparguments pushd functions float zstyle print declare comptry alias shift - . bindkey typeset true hash compset cd compvalues getopts compgroups export enable limit echotc echo wait dirs unsetopt read : integer bye echoti compquote unfunction fc vared unalias kill compcall where fg zformat suspend unlimit break set continue command zcompile whence umask trap log private if else end do then { select coproc } ! case [[ repeat done for while time esac until fi nocorrect foreach elif function'
26
+ ).split(' ')
27
+ /** The closed set of shell builtins and reserved words (bash 5.3 + zsh 5.9): a head outside it is an external command or a function. */
28
+ const SHELL_BUILTINS_AND_KEYWORDS: ReadonlySet<string> = new Set([...BASH_BUILTINS_AND_KEYWORDS, ...ZSH_BUILTINS_AND_RESERVED_WORDS])
29
+ /** Builtins that only print or test: neutral (`printf -v` / `print -v` assign a variable and are not). */
30
+ const READ_ONLY_BUILTINS: ReadonlySet<string> = new Set(['echo', 'printf', 'print', 'which', 'whence', 'where', 'true', 'false', 'test', '[', '[[', 'pwd', 'type', 'hash', 'help', ':', 'times', 'dirs', 'jobs'])
31
+ /** Variables whose assignment (or export, or unset) changes what a later cd, `~` or shell startup does: unknown. */
32
+ const SENSITIVE_VARIABLES: ReadonlySet<string> = new Set(['BASH_ENV', 'ENV', 'HOME', 'CDPATH', 'ZDOTDIR', 'PATH'])
33
+ /** Variables that, as a prefix on a launched shell, change what that shell runs or means. */
34
+ const SHELL_STARTUP_VARIABLES: ReadonlySet<string> = new Set(['BASH_ENV', 'ENV', 'ZDOTDIR', 'HOME', 'CDPATH'])
35
+ /** git subcommands that move HEAD (or the worktree it is read from): after one, the branch a later landing command hits is unknown. */
36
+ const HEAD_MOVERS: ReadonlySet<string> = new Set(['switch', 'checkout', 'worktree', 'stash', 'bisect', 'rebase', 'reset'])
37
+ const GIT_VALUE_GLOBALS: ReadonlySet<string> = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--super-prefix', '--config-env'])
38
+
39
+ /** Does this piece run `git <HEAD_MOVER> ...` (behind any wrapper)? */
40
+ function movesHead(rest: readonly Token[]): boolean {
41
+ const gitAt = rest.findIndex((token) => ['git', 'git.exe'].includes(basename(token)))
42
+ if (gitAt < 0) return false
43
+ for (let i = gitAt + 1; i < rest.length; i++) {
44
+ const value = tokenValue(rest[i] as Token)
45
+ if (value.startsWith('-')) {
46
+ if (GIT_VALUE_GLOBALS.has(value)) i++
47
+ continue
48
+ }
49
+ const subcommand = value.toLowerCase()
50
+ if (!HEAD_MOVERS.has(subcommand)) return false
51
+ // Read-only forms of stash/worktree do not move HEAD: `stash list|show`, `worktree list`.
52
+ const action = rest.slice(i + 1).map((token) => tokenValue(token)).find((arg) => !arg.startsWith('-'))
53
+ if (subcommand === 'stash' && (action === 'list' || action === 'show')) return false
54
+ if (subcommand === 'worktree' && action === 'list') return false
55
+ return true
56
+ }
57
+ return false
58
+ }
59
+
60
+ function namesDirectory(word: string): boolean {
61
+ try {
62
+ return statSync(word).isDirectory()
63
+ } catch {
64
+ return false
65
+ }
66
+ }
67
+
68
+ interface Mutable {
69
+ dirs: string[]
70
+ gitDir?: string
71
+ workTree?: string
72
+ /** `HOME=` was assigned for this command: `~` no longer means the home directory we know. */
73
+ homeUnknown?: boolean
74
+ }
75
+
76
+ /** `~`, `~/x`, `$HOME`, `${HOME}` expand to the home directory; any other `$` or backtick is not static (`null`). */
77
+ function expandPath(value: string, target: Mutable): string | null {
78
+ const home = homedir()
79
+ const tilde = value === '~' || value.startsWith('~/')
80
+ if (tilde) return target.homeUnknown === true ? null : home + value.slice(1)
81
+ for (const prefix of ['${HOME}', '$HOME']) {
82
+ if (value === prefix || value.startsWith(`${prefix}/`)) return target.homeUnknown === true ? null : home + value.slice(prefix.length)
83
+ }
84
+ return /[$`]/.test(value) || value === '' ? null : value
85
+ }
86
+
87
+ /** Apply `NAME=value` env assignments we care about; false when one is not static. */
88
+ function applyEnv(target: Mutable, assignments: readonly string[]): boolean {
89
+ for (const assignment of assignments) {
90
+ const match = ASSIGNMENT_RE.exec(assignment)
91
+ if (!match) continue
92
+ const name = match[1] as string
93
+ if (name !== 'GIT_DIR' && name !== 'GIT_WORK_TREE') continue
94
+ const value = expandPath(match[2] as string, target)
95
+ if (value === null) return false
96
+ if (name === 'GIT_DIR') target.gitDir = value
97
+ else target.workTree = value
98
+ }
99
+ return true
100
+ }
101
+
102
+ function leadingAssignments(tokens: readonly Token[]): { assignments: string[]; rest: Token[] } {
103
+ let index = 0
104
+ const assignments: string[] = []
105
+ while (index < tokens.length && ASSIGNMENT_RE.test(tokenValue(tokens[index] as Token))) assignments.push(tokenValue(tokens[index++] as Token))
106
+ return { assignments, rest: tokens.slice(index) as Token[] }
107
+ }
108
+
109
+ /** `--git-dir`, `--work-tree`, `-C` among git's globals; false when one is not static. */
110
+ function applyGlobals(target: Mutable, globals: readonly Token[]): boolean {
111
+ const values = globals.map((token) => tokenValue(token))
112
+ for (let index = 0; index < values.length; index++) {
113
+ const value = values[index] as string
114
+ const take = (long: string): string | null => {
115
+ if (value === long) return values[++index] ?? ''
116
+ if (value.startsWith(`${long}=`)) return value.slice(long.length + 1)
117
+ return null
118
+ }
119
+ const gitDir = take('--git-dir')
120
+ if (gitDir !== null) {
121
+ const expanded = expandPath(gitDir, target)
122
+ if (expanded === null) return false
123
+ target.gitDir = expanded
124
+ continue
125
+ }
126
+ const workTree = take('--work-tree')
127
+ if (workTree !== null) {
128
+ const expanded = expandPath(workTree, target)
129
+ if (expanded === null) return false
130
+ target.workTree = expanded
131
+ continue
132
+ }
133
+ if (value === '-C' || (value.startsWith('-C') && value.length > 2)) {
134
+ const dir = expandPath(value === '-C' ? (values[++index] ?? '') : value.slice(2), target)
135
+ if (dir === null) return false
136
+ target.dirs.push(dir)
137
+ }
138
+ }
139
+ return true
140
+ }
141
+
142
+ /** Does this piece run our git invocation (possibly behind `env`/`command`/`builtin`/another wrapper)? */
143
+ function runsInvocation(piece: readonly Token[], inv: Invocation & { kind: 'git' }): boolean {
144
+ const tokens = withoutRedirections(piece) // the classifier's `rest` has none either (`git commit -F - <<EOF`)
145
+ const values = tokens.map((token) => tokenValue(token))
146
+ const wanted = inv.rest.map((token) => tokenValue(token))
147
+ const tail = wanted.length === 0 ? values : values.slice(-wanted.length)
148
+ const gitAt = tokens.findIndex((token) => ['git', 'git.exe'].includes(basename(token)))
149
+ return gitAt >= 0 && values.indexOf(inv.subcommand, gitAt) > gitAt && tail.length === wanted.length && tail.every((value, index) => value === wanted[index])
150
+ }
151
+
152
+ /** Assignments reaching git from the piece: leading `NAME=v`, and `env NAME=v ...`. `null` when `env` carries options we do not model. */
153
+ function pieceAssignments(tokens: readonly Token[]): string[] | null {
154
+ const { assignments, rest } = leadingAssignments(tokens)
155
+ let index = 0
156
+ while (index < rest.length && !['git', 'git.exe'].includes(basename(rest[index] as Token))) {
157
+ const name = basename(rest[index] as Token)
158
+ if (name === 'env') {
159
+ index++
160
+ while (index < rest.length && ASSIGNMENT_RE.test(tokenValue(rest[index] as Token))) assignments.push(tokenValue(rest[index++] as Token))
161
+ if (index < rest.length && tokenValue(rest[index] as Token).startsWith('-') && !['git', 'git.exe'].includes(basename(rest[index] as Token))) return null
162
+ } else if (name === 'command' || name === 'builtin') {
163
+ index++
164
+ while (index < rest.length && ['-p', '--'].includes(tokenValue(rest[index] as Token))) index++
165
+ } else {
166
+ index++ // another wrapper (nohup, time, ...): no environment of ours
167
+ }
168
+ }
169
+ return assignments
170
+ }
171
+
172
+ interface Parsed {
173
+ readonly tokens: readonly (readonly Token[] | null)[]
174
+ /** Text between consecutive pieces (operators, group characters, comments); `null` when the pieces could not be located in the source. */
175
+ readonly gaps: readonly string[] | null
176
+ /** Names of functions defined anywhere in the command (`f() {`, `function f`): a piece headed by one is unknown. */
177
+ readonly functions: ReadonlySet<string>
178
+ }
179
+
180
+ /** Heredoc delimiters opened by `<<` / `<<-` at the top level of one piece, or `'unparsable'`. Quotes, `\x` and `$(...)` are skipped. */
181
+ function heredocDelimiters(piece: string): string[] | 'unparsable' {
182
+ const delimiters: string[] = []
183
+ for (let i = 0; i < piece.length; i++) {
184
+ const char = piece[i] as string
185
+ if (char === '\\') i++
186
+ else if (char === "'") {
187
+ const close = piece.indexOf("'", i + 1)
188
+ if (close < 0) return 'unparsable'
189
+ i = close
190
+ } else if (char === '"') {
191
+ let j = i + 1
192
+ while (j < piece.length && piece[j] !== '"') j += piece[j] === '\\' ? 2 : 1
193
+ if (j >= piece.length) return 'unparsable'
194
+ i = j
195
+ } else if (char === '`') {
196
+ const close = findMatchingBacktick(piece, i)
197
+ if (close < 0) return 'unparsable'
198
+ i = close
199
+ } else if (char === '$' && piece[i + 1] === '(') {
200
+ const close = findMatchingParen(piece, i + 1)
201
+ if (close < 0) return 'unparsable'
202
+ i = close
203
+ } else if (char === '<' && piece[i + 1] === '<' && piece[i + 2] !== '<' && piece[i - 1] !== '<') {
204
+ i += 2
205
+ if (piece[i] === '-') i++
206
+ while (piece[i] === ' ' || piece[i] === '\t') i++
207
+ let word = ''
208
+ let sawWord = false
209
+ for (; i < piece.length && !/[\s;|&<>()]/.test(piece[i] as string); i++) {
210
+ sawWord = true
211
+ const c = piece[i] as string
212
+ if (c === '\\') word += piece[++i] ?? ''
213
+ else if (c === "'" || c === '"') {
214
+ const close = piece.indexOf(c, i + 1)
215
+ if (close < 0) return 'unparsable'
216
+ word += piece.slice(i + 1, close)
217
+ i = close
218
+ } else word += c
219
+ }
220
+ if (!sawWord) return 'unparsable'
221
+ delimiters.push(word)
222
+ i--
223
+ }
224
+ }
225
+ return delimiters
226
+ }
227
+
228
+ /** `null`: unparseable (parse.uncertain refuses it). `'closed'`: a heredoc we cannot account for -- fail closed. */
229
+ function parse(command: string): Parsed | null | 'closed' {
230
+ const split = splitShell(command)
231
+ if (split.invalid) return null
232
+ const tokens: (readonly Token[] | null)[] = split.pieces.map((piece) => {
233
+ const tokenized = tokenize(piece)
234
+ return tokenized.invalid ? null : tokenized.tokens
235
+ })
236
+ const functions = new Set<string>()
237
+ for (const match of command.matchAll(/([A-Za-z_][\w.:-]*)\s*\(\s*\)/g)) functions.add(match[1] as string)
238
+ for (const match of command.matchAll(/\bfunction\s+([A-Za-z_][\w.:-]*)/g)) functions.add(match[1] as string)
239
+ const gaps: string[] = []
240
+ let cursor = 0
241
+ for (const piece of split.pieces) {
242
+ const at = command.indexOf(piece, cursor)
243
+ if (at < 0) return { tokens, gaps: null, functions }
244
+ gaps.push(command.slice(cursor, at))
245
+ cursor = at + piece.length
246
+ }
247
+ // Heredoc body lines are not commands: drop them (docs/architecture/agent-git-branch-rules.md#resolver)
248
+ for (let i = 0; i < split.pieces.length; i++) {
249
+ const delimiters = heredocDelimiters(split.pieces[i] as string)
250
+ if (delimiters === 'unparsable') return 'closed'
251
+ if (delimiters.length === 0) continue
252
+ let body = i + 1
253
+ while (body < split.pieces.length && !(gaps[body] as string).includes('\n')) body++ // rest of the marker's own line
254
+ for (const delimiter of delimiters) {
255
+ let closed = false
256
+ for (; body < split.pieces.length && !closed; body++) {
257
+ closed = (split.pieces[body] as string).trim() === delimiter
258
+ tokens[body] = null
259
+ gaps[body] = '\n'
260
+ }
261
+ if (!closed) return 'closed'
262
+ }
263
+ i = body - 1
264
+ }
265
+ return { tokens, gaps, functions }
266
+ }
267
+
268
+ /** The gap as an operator when it is exactly `&&`, `;` or a newline; `null` for anything else (`||`, `|`, `&`, `(`, `{`, ...). */
269
+ function plainGap(gap: string): '&&' | ';' | null {
270
+ const normalized = gap.replace(/#[^\n]*/g, '').replace(/\s*\n\s*/g, ';').replace(/\s+/g, '').replace(/;+/g, ';')
271
+ return normalized === '&&' ? '&&' : normalized === ';' || normalized === '' ? ';' : null
272
+ }
273
+
274
+ type StatePiece =
275
+ | { kind: 'cd'; dir: string }
276
+ | { kind: 'export'; assignments: string[] }
277
+ | { kind: 'unset'; names: string[] }
278
+ | { kind: 'bad' }
279
+ | { kind: 'neutral' }
280
+
281
+ const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
282
+ const SET_CLUSTER_RE = /^[-+][eux]*$/
283
+ const SET_O_VALUES: readonly string[] = ['pipefail', 'errexit', 'nounset']
284
+
285
+ /** `set` with only -e/-u/-x/+e/+u/+x (clusters allowed, `-euo pipefail`) and `-o|+o pipefail|errexit|nounset`. */
286
+ function harmlessSet(args: readonly string[]): boolean {
287
+ if (args.length === 0) return false
288
+ for (let i = 0; i < args.length; i++) {
289
+ const arg = args[i] as string
290
+ const cluster = /^([-+][eux]*)o$/.exec(arg)
291
+ if (cluster !== null || arg === '-o' || arg === '+o') {
292
+ if (!SET_O_VALUES.includes(args[++i] ?? '')) return false
293
+ } else if (!SET_CLUSTER_RE.test(arg) || arg.length < 2) {
294
+ return false
295
+ }
296
+ }
297
+ return true
298
+ }
299
+
300
+ function classify(piece: readonly Token[] | null, functions: ReadonlySet<string>): StatePiece {
301
+ if (piece === null) return { kind: 'neutral' }
302
+ const { assignments, rest } = leadingAssignments(withoutRedirections(piece))
303
+ const names = assignments.map((assignment) => assignment.split('=')[0] as string)
304
+ if (names.some((name) => name.startsWith('GIT_'))) return rest.length === 0 ? { kind: 'bad' } : classifyHead(rest, assignments, functions)
305
+ if (rest.length === 0) return names.some((name) => SENSITIVE_VARIABLES.has(name)) ? { kind: 'bad' } : { kind: 'neutral' }
306
+ return classifyHead(rest, assignments, functions)
307
+ }
308
+
309
+ function classifyHead(rest: readonly Token[], assignments: readonly string[], functions: ReadonlySet<string>): StatePiece {
310
+ const head = tokenValue(rest[0] as Token)
311
+ const args = rest.slice(1).map((token) => tokenValue(token))
312
+ if (/[$`]/.test(head) || functions.has(head)) return { kind: 'bad' }
313
+ if (!SHELL_BUILTINS_AND_KEYWORDS.has(head)) {
314
+ if (movesHead(rest)) return { kind: 'bad' }
315
+ // zsh autocd: a path (or a bare word naming a directory) used as a command changes directory.
316
+ if (head.includes('/') && !['git', 'git.exe'].includes(basename(rest[0] as Token))) return { kind: 'bad' }
317
+ if (head.startsWith('~') || head === '..' || (!head.includes('/') && namesDirectory(head))) return { kind: 'bad' }
318
+ // An external command cannot change this shell, but a shell it launches runs its startup files first.
319
+ const startup = assignments.some((assignment) => SHELL_STARTUP_VARIABLES.has(assignment.split('=')[0] as string))
320
+ return startup && (SHELLS.has(basename(rest[0] as Token)) || RE_EXECUTORS.has(basename(rest[0] as Token))) ? { kind: 'bad' } : { kind: 'neutral' }
321
+ }
322
+ if (READ_ONLY_BUILTINS.has(head)) return (head === 'printf' || head === 'print') && args.includes('-v') ? { kind: 'bad' } : { kind: 'neutral' }
323
+ if (head === 'command' && (args[0] === '-v' || args[0] === '-V')) return { kind: 'neutral' }
324
+ if (head === 'set') return harmlessSet(args) ? { kind: 'neutral' } : { kind: 'bad' }
325
+ if (head === 'cd' || head === 'pushd') return args.length !== 1 || args[0] === '' || (args[0] as string).startsWith('-') ? { kind: 'bad' } : { kind: 'cd', dir: args[0] as string }
326
+ if (head === 'export') {
327
+ if (args.length > 0 && args.every((arg) => /^GIT_(DIR|WORK_TREE)=/.test(arg))) return { kind: 'export', assignments: args }
328
+ return args.every((arg) => NAME_RE.test(arg.split('=')[0] as string) && !arg.startsWith('GIT_') && !SENSITIVE_VARIABLES.has(arg.split('=')[0] as string)) ? { kind: 'neutral' } : { kind: 'bad' }
329
+ }
330
+ if (head === 'unset') return args.every((arg) => NAME_RE.test(arg) && !SENSITIVE_VARIABLES.has(arg)) ? { kind: 'unset', names: args } : { kind: 'bad' }
331
+ return { kind: 'bad' }
332
+ }
333
+
334
+ // Credit or reject the pieces before `until`; fail closed on anything unknown. // See docs/architecture/agent-git-branch-rules.md#resolver
335
+ function applyState(parsed: Parsed, until: number, target: Mutable): boolean {
336
+ for (let i = 0; i < until; i++) {
337
+ const state = classify(parsed.tokens[i] ?? null, parsed.functions)
338
+ if (state.kind === 'neutral') continue
339
+ if (state.kind === 'bad') return false
340
+ if (state.kind === 'unset') {
341
+ if (state.names.includes('GIT_DIR')) delete target.gitDir
342
+ if (state.names.includes('GIT_WORK_TREE')) delete target.workTree
343
+ continue
344
+ }
345
+ if (parsed.gaps === null) return false
346
+ const before = plainGap(parsed.gaps[i] as string)
347
+ if (before === null) return false
348
+ if (before === '&&' && (i === 0 || !['cd', 'export'].includes(classify(parsed.tokens[i - 1] ?? null, parsed.functions).kind))) return false
349
+ for (let j = i + 1; j <= Math.min(until, parsed.gaps.length - 1); j++) if (plainGap(parsed.gaps[j] as string) === null) return false
350
+ if (state.kind === 'cd') {
351
+ const dir = expandPath(state.dir, target)
352
+ if (dir === null) return false
353
+ target.dirs.push(dir)
354
+ } else if (!applyEnv(target, state.assignments)) {
355
+ return false
356
+ }
357
+ }
358
+ return true
359
+ }
360
+
361
+ // The `-c` script of a (re-executor-wrapped) shell piece, or 'unsafe' when a startup-file variable is set. // See docs/architecture/agent-git-branch-rules.md#resolver
362
+ function shellScript(tokens: readonly Token[]): string | null | 'unsafe' {
363
+ const { rest } = leadingAssignments(tokens)
364
+ if (rest[0] === undefined) return null
365
+ let shellAt = 0
366
+ if (!SHELLS.has(basename(rest[0]))) {
367
+ if (!RE_EXECUTORS.has(basename(rest[0]))) return null
368
+ shellAt = rest.findIndex((token, index) => index > 0 && SHELLS.has(basename(token)))
369
+ if (shellAt < 0) return null
370
+ }
371
+ for (let index = shellAt + 1; index < rest.length - 1; index++) {
372
+ if (/^-[a-zA-Z]*c$/.test(tokenValue(rest[index] as Token))) {
373
+ const assignments = pieceAssignments(tokens)
374
+ return assignments === null || assignments.some((assignment) => SHELL_STARTUP_VARIABLES.has(assignment.split('=')[0] as string)) ? 'unsafe' : tokenValue(rest[index + 1] as Token)
375
+ }
376
+ }
377
+ return null
378
+ }
379
+
380
+ /** `undefined`: the invocation is not in `command`. `null`: it is, but its target is not static. */
381
+ function walk(command: string, inv: Invocation & { kind: 'git' }, inherited: Mutable): BranchTarget | null | undefined {
382
+ const parsed = parse(command)
383
+ if (parsed === null) return undefined // unparseable: parse.uncertain's job to refuse
384
+ if (parsed === 'closed') return null
385
+ for (let i = 0; i < parsed.tokens.length; i++) {
386
+ const tokens = parsed.tokens[i]
387
+ if (tokens === null || tokens === undefined) continue
388
+ const target: Mutable = { ...inherited, dirs: [...inherited.dirs] }
389
+ if (runsInvocation(tokens, inv)) {
390
+ const assignments = pieceAssignments(tokens)
391
+ if (assignments === null || !applyState(parsed, i, target)) return null
392
+ if (assignments.some((assignment) => assignment.startsWith('HOME='))) target.homeUnknown = true
393
+ if (!applyEnv(target, assignments) || !applyGlobals(target, inv.globals)) return null
394
+ return target
395
+ }
396
+ const script = shellScript(tokens)
397
+ if (script === null) continue
398
+ if (script === 'unsafe') {
399
+ // The startup file may do anything, but only matters when our git is in there.
400
+ const unsafeInner = tokens.some((token) => tokenValue(token).includes(inv.subcommand))
401
+ if (unsafeInner) return null
402
+ continue
403
+ }
404
+ const probe: Mutable = { ...inherited, dirs: [...inherited.dirs] }
405
+ const inner = walk(script, inv, probe)
406
+ if (inner === undefined) continue
407
+ if (inner === null || !applyState(parsed, i, target)) return null
408
+ const nested = walk(script, inv, target)
409
+ return nested ?? null
410
+ }
411
+ return undefined
412
+ }
413
+
414
+ /** The statically resolved target of `inv` inside `command`, or `null` when it cannot be resolved. */
415
+ export function resolveGitTarget(inv: Invocation & { kind: 'git' }, command: string | undefined): BranchTarget | null {
416
+ const found = command === undefined ? undefined : walk(command, inv, { dirs: [] })
417
+ if (found !== undefined) return found
418
+ // Not found as a piece (xargs, find -exec, ...): its own globals, after any unconditional cd/export before it.
419
+ const target: Mutable = { dirs: [] }
420
+ const parsed = command === undefined ? null : parse(command)
421
+ if (parsed === 'closed' || (parsed !== null && !applyState(parsed, parsed.tokens.length, target))) return null
422
+ return applyGlobals(target, inv.globals) ? target : null
423
+ }
@@ -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> (docs/plans), crg (code graph), rg, ast-grep.',
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 docs/plans/.scratchpad) over
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 docs/plans/.scratchpad ->
81
- // lead with `search`; everywhere else -> lead with `crg`.
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}\` (a docs/plans/.scratchpad tree). The right answer is \`search <semantic query | keywords>\` — semantic + keyword, finds the doc that *talks* about X. \`rg\`/\`crg\` are the wrong tools here.`
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>\` only when the path is docs/plans/.scratchpad.`,
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 ' +
@@ -45,6 +45,14 @@ export interface AgentPort {
45
45
  * `git.protected-tree-commit` can fail closed on "could not read the index"
46
46
  * instead of silently treating an unreadable index as a clean one.
47
47
  */
48
+ /** Where a git invocation points, statically resolved from its command line (see `impl/git-target.ts`). */
49
+ export interface BranchTarget {
50
+ /** `cd <dir>` and `git -C <dir>` values, in the order git applies them. */
51
+ readonly dirs: readonly string[]
52
+ readonly gitDir?: string
53
+ readonly workTree?: string
54
+ }
55
+
48
56
  export interface RepoIndexPort {
49
57
  stagedPaths(): readonly string[] | null
50
58
  trackedModifiedPaths(): readonly string[] | null
@@ -64,6 +72,14 @@ export interface RepoIndexPort {
64
72
  * absent) the same as `null` (present, resolved to nothing).
65
73
  */
66
74
  configuredAlias?(verb: string): string | null
75
+ /**
76
+ * The checked-out branch of the repo a git invocation targets: the hook's
77
+ * root with `target.dirs` applied as `-C <dir>` values in order (relative
78
+ * ones composed), then `--git-dir` / `--work-tree` when given. `''` for a detached HEAD, `null` when it cannot be read (not a
79
+ * repository, git missing, timeout) so `git.commit-protected-branch` can
80
+ * fail closed instead of treating an unreadable HEAD as a safe one.
81
+ */
82
+ currentBranch(target: BranchTarget): string | null
67
83
  }
68
84
 
69
85
  export const liveEnv: EnvPort = {
@@ -159,6 +175,26 @@ export function liveRepoIndex(root: string): RepoIndexPort {
159
175
  }
160
176
  },
161
177
  resolveRemoteUrl: rewriteUrl,
178
+ currentBranch: (target) => {
179
+ try {
180
+ const out = execFileSync('git', [
181
+ '-C',
182
+ root,
183
+ ...target.dirs.flatMap((dir) => ['-C', dir]),
184
+ ...(target.gitDir === undefined ? [] : ['--git-dir', target.gitDir]),
185
+ ...(target.workTree === undefined ? [] : ['--work-tree', target.workTree]),
186
+ 'branch',
187
+ '--show-current',
188
+ ], {
189
+ encoding: 'utf8',
190
+ timeout: 10_000,
191
+ stdio: ['ignore', 'pipe', 'ignore'],
192
+ })
193
+ return out.replace(/\n+$/, '')
194
+ } catch {
195
+ return null
196
+ }
197
+ },
162
198
  configuredAlias: (verb) => {
163
199
  try {
164
200
  const out = execFileSync('git', ['-C', root, 'config', '--get', `alias.${verb}`], {
@@ -29,11 +29,11 @@ import { gitStash, gitClean, gitRebase, gitFilterBranch, gitRestore, gitReset, g
29
29
  import { gitNoVerify, gitAmend } from './impl/git-verify.ts'
30
30
  import { gitProtectedTreeCommit } from './impl/git-protected-tree.ts'
31
31
  import {
32
- gitBranchForceDelete,
33
32
  gitTagDelete,
34
33
  gitSwitchForce,
35
34
  gitGcPrune,
36
35
  } from './impl/git-history.ts'
36
+ import { gitBranchMutation, gitCommitProtectedBranch } from './impl/git-branch.ts'
37
37
  import {
38
38
  gitUpdateRef,
39
39
  gitSymbolicRefWrite,
@@ -95,7 +95,8 @@ export const GIT_RULES: readonly AnyRuleSpec[] = [
95
95
  gitNoVerify,
96
96
  gitAmend,
97
97
  gitProtectedTreeCommit,
98
- gitBranchForceDelete,
98
+ gitBranchMutation,
99
+ gitCommitProtectedBranch,
99
100
  gitTagDelete,
100
101
  gitSwitchForce,
101
102
  gitWorktree,
@@ -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
- GLOBAL-AGENTS deployment targets) as foreign:
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
@@ -6,6 +6,11 @@ repo's own `AGENTS.md`.
6
6
  ## Communication
7
7
  - Address me as **Tarun**.
8
8
  - Short, scannable **bullet points** — not long prose. Lead with the answer; keep reasoning tight.
9
+ - **Every reply has three parts, in this order:**
10
+ - a **TL;DR** of a few lines; information stays brief, and detail is given only when asked;
11
+ - an explicit **Recommendation**: what you would do and why, in one line;
12
+ - **one ask**: the single question or decision you need next.
13
+ - **Keep a numbered list of open points** and show it when it changes. I answer them one at a time, so never bundle several questions into one reply.
9
14
  - If a task is ambiguous, ask exactly one clarifying question.
10
15
 
11
16
  ## Truthfulness (non-negotiable)