@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.32 → 0.1.34

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 CHANGED
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/bin/adl
3
- size: 82920928 bytes
4
- sha256: f63a82963b12de92399625846d611dc9a469ceec692522e95fbae7ff3f702acb
3
+ size: 82945504 bytes
4
+ sha256: b6594d01b2ebea8949f80faa8da029598f8be7513fb1e3c7b63ddc01df4c8f02
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amsterdamdatalabs/adl-agent-control-linux-x64",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
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": [
@@ -39,14 +39,14 @@
39
39
  * `classifyGitPushDestination`) — denied for EVERY identity, matching the
40
40
  * matrix's ❌/❌ for this row. An unrecognized branch name (neither a
41
41
  * protected name nor a recognized feature prefix) fails closed into this
42
- * same bucket: the matrix only grants an exception for the four listed
42
+ * same bucket: the matrix only grants an exception for the listed
43
43
  * prefixes, not for "anything else". `develop` is retired
44
44
  * (docs/specs/two-branch-standard-2026-09-27.md §1b.2 — the standard is
45
45
  * two branches, not three) and is deliberately NOT a named protected
46
46
  * branch any more; a push to it still lands in this same bucket, but as
47
47
  * an ordinary unrecognized name, not a special case.
48
48
  * 3. Row 2 — the destination is a recognized feature branch
49
- * (`feat/ fix/ docs/ chore/`): allowed for `agent-tarun`/`agent-vivek`
49
+ * (`<commit type>/`, every type but `release` — packages/azdo/src/lib/commit-types.ts): allowed for `agent-tarun`/`agent-vivek`
50
50
  * (Azure DevOps Contributors, and this is the ordinary way they ship
51
51
  * work); denied for `agent-reviewer` (Azure DevOps Reader — cannot push
52
52
  * at all, see docs/enact/handoff-enact-vps-global-tools-2026-09-25.md's
@@ -56,6 +56,7 @@
56
56
  import type { Invocation } from '../shell/invocation.ts'
57
57
  import type { Token } from '../shell/token.ts'
58
58
  import { tokenValue } from '../shell/token.ts'
59
+ import { BRANCH_PREFIXES } from '../../../../azdo/src/lib/commit-types.ts'
59
60
  import type { Finding, MessageContext, RuleContext, RuleSpec } from '../spec.ts'
60
61
  import { noParams } from '../spec.ts'
61
62
  import { AGENT_REVIEWER, resolveFromEnvOrFile } from './adl-identity.ts'
@@ -134,7 +135,7 @@ function pushBypassesPolicy(rest: readonly Token[]): boolean {
134
135
  }
135
136
 
136
137
  const PROTECTED_BRANCHES: ReadonlySet<string> = new Set(['integration', 'main'])
137
- const FEATURE_PREFIXES: readonly string[] = ['feat/', 'fix/', 'docs/', 'chore/']
138
+ const FEATURE_PREFIXES: readonly string[] = BRANCH_PREFIXES.map((prefix) => `${prefix}/`)
138
139
 
139
140
  function normalizeBranchRef(ref: string): string {
140
141
  return ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : ref
@@ -229,7 +230,7 @@ export const adlGitPushBranchPolicy: RuleSpec = {
229
230
  const user = 'protected branch: push a feature branch and open a PR'
230
231
  return {
231
232
  user,
232
- agent: `DENIED ${user}. \`integration\`/\`main\` (and any branch this hook cannot resolve from argv alone, including a retired name like \`develop\`) never take a direct push — push a \`feat/ fix/ docs/ chore/\` branch and open a PR. Blocked: ${ctx.snippet}`,
233
+ agent: `DENIED ${user}. \`integration\`/\`main\` (and any branch this hook cannot resolve from argv alone, including a retired name like \`develop\`) never take a direct push — push a \`${FEATURE_PREFIXES.join(' ')}\` branch and open a PR. Blocked: ${ctx.snippet}`,
233
234
  }
234
235
  }
235
236
 
@@ -0,0 +1,63 @@
1
+ // PBI 661 (Tarun, 2026-10-09): agents never change branches; both denies say "ask the user for git branch operations". See docs/architecture/agent-git-branch-rules.md
2
+
3
+ import type { Invocation } from '../shell/invocation.ts'
4
+ import type { Finding, MessageContext, RuleContext, RuleSpec } from '../spec.ts'
5
+ import { noParams } from '../spec.ts'
6
+ import { branchMutates, isHelpOnlyInvocation, isSequencerRecoveryOnly, switchCheckoutCreatesBranch } from './git-helpers.ts'
7
+ import { LANDING_SUBCOMMANDS, resolveGitTarget } from './git-target.ts'
8
+ import { gitAgentTail } from './messages.ts'
9
+
10
+ const ASK_USER = 'ask the user for git branch operations'
11
+
12
+ const UNRESOLVED =
13
+ 'The commit target (which repository and branch this would land on) could not be determined statically from this command line. ' +
14
+ 'Run `git commit` as its own command, or with `git -C <path>`, so the target is explicit. '
15
+
16
+ function askUserMessage(ctx: MessageContext, unresolved = false) {
17
+ return { user: ASK_USER, agent: `DENIED ${ASK_USER}. ${unresolved ? UNRESOLVED : ''}${gitAgentTail(ctx.snippet)}` }
18
+ }
19
+
20
+ export const gitBranchMutation: RuleSpec = {
21
+ id: 'git.branch-mutation',
22
+ category: 'git',
23
+ severity: 'deny',
24
+ enabledByDefault: true,
25
+ summary: 'Deny every mutating git branch form and branch creation via git switch -c / git checkout -b.',
26
+ params: noParams,
27
+ match(inv: Invocation): Finding | null {
28
+ if (inv.kind !== 'git') return null
29
+ const hit =
30
+ inv.subcommand === 'branch' ? branchMutates(inv.rest) : inv.subcommand === 'switch' || inv.subcommand === 'checkout' ? switchCheckoutCreatesBranch(inv.subcommand, inv.rest) : false
31
+ return hit ? { ruleId: 'git.branch-mutation', severity: 'deny', detail: {} } : null
32
+ },
33
+ message: (_finding: Finding, ctx: MessageContext) => askUserMessage(ctx),
34
+ corpus: {
35
+ deny: ['git branch foo', 'git branch -D old-feature', 'git branch -aD', 'git branch --delete --force old-feature', 'git branch -m old new', 'git switch -c new-branch', 'git checkout -b new-branch'],
36
+ allow: ['git branch', 'git branch -a', 'git branch -vv', 'git branch --show-current', "git branch --list 'feat/*'", 'git switch main', 'git checkout feature-x'],
37
+ },
38
+ }
39
+
40
+ export const gitCommitProtectedBranch: RuleSpec = {
41
+ id: 'git.commit-protected-branch',
42
+ category: 'git',
43
+ severity: 'deny',
44
+ enabledByDefault: true,
45
+ summary: 'Deny git commit (and merge, cherry-pick, revert, am) while the target repo is on a protected branch, or when the target or its branch cannot be resolved.',
46
+ params: noParams,
47
+ match(inv: Invocation, ctx: RuleContext<Record<string, never>>): Finding | null {
48
+ if (inv.kind !== 'git' || !LANDING_SUBCOMMANDS.has(inv.subcommand)) return null
49
+ if (isHelpOnlyInvocation(inv.rest)) return null // prints text, commits nothing
50
+ if (inv.subcommand !== 'commit' && isSequencerRecoveryOnly(inv.rest)) return null // --abort / --quit only end an operation
51
+ const target = resolveGitTarget(inv, ctx.command)
52
+ // null target or branch = unresolved: fail closed (docs/architecture/agent-git-branch-rules.md)
53
+ const branch = target === null ? null : ctx.index.currentBranch(target)
54
+ if (branch !== null && !ctx.agentConfig.protectedBranches.includes(branch)) return null
55
+ return { ruleId: 'git.commit-protected-branch', severity: 'deny', detail: { unresolved: branch === null ? 'yes' : 'no' } }
56
+ },
57
+ message: (finding: Finding, ctx: MessageContext) => askUserMessage(ctx, finding.detail['unresolved'] === 'yes'),
58
+ // Fixture branch is never protected, so deny entries are the fail-closed ones (docs/architecture/agent-git-branch-rules.md)
59
+ corpus: {
60
+ deny: ['cd "$REPO" && git commit -m x', 'git -C "$REPO" commit -m x', 'GIT_DIR="$D" git merge origin/integration'],
61
+ allow: ["git commit -m 'feat(x): y'", 'git status'],
62
+ },
63
+ }
@@ -61,9 +61,97 @@ export function commitAmends(rest: readonly Token[]): boolean {
61
61
  return hasFlag(rest, '--amend')
62
62
  }
63
63
 
64
- export function branchForceDeletes(rest: readonly Token[]): boolean {
65
- if (hasFlagOrCluster(rest, [], ['D'])) return true
66
- return hasFlagOrCluster(rest, ['--delete'], ['d']) && hasFlagOrCluster(rest, ['--force'], ['f'])
64
+ /**
65
+ * Every long option `git branch -h` lists on git 2.56 (positive forms), plus the
66
+ * legacy `--set-upstream`. Used ONLY to resolve unique prefixes the way git's
67
+ * parse-options does; whether an option is allowed is `BRANCH_READ_ONLY_OPTIONS`.
68
+ */
69
+ const BRANCH_POSITIVE_OPTIONS: readonly string[] = [
70
+ 'verbose', 'quiet', 'track', 'set-upstream-to', 'set-upstream', 'unset-upstream', 'color', 'remotes', 'contains', 'no-contains', 'abbrev', 'all',
71
+ 'delete', 'move', 'omit-empty', 'copy', 'list', 'show-current', 'create-reflog', 'edit-description', 'delete-merged', 'dry-run', 'force', 'merged',
72
+ 'no-merged', 'forked', 'column', 'sort', 'points-at', 'ignore-case', 'recurse-submodules', 'format', 'help',
73
+ ]
74
+ const BRANCH_ALL_OPTIONS: readonly string[] = [...new Set([...BRANCH_POSITIVE_OPTIONS, ...BRANCH_POSITIVE_OPTIONS.filter((name) => !name.startsWith('no-') && name !== 'help' && name !== 'remotes' && name !== 'all').map((name) => `no-${name}`)])]
75
+ /** PBI 661 allowlist: the only `git branch` options that read and never write. Anything else (known or not) is a deny. */
76
+ const BRANCH_READ_ONLY_OPTIONS: ReadonlySet<string> = new Set([
77
+ 'verbose', 'quiet', 'color', 'remotes', 'all', 'list', 'show-current', 'omit-empty', 'ignore-case', 'dry-run', 'abbrev', 'column', 'help',
78
+ 'contains', 'no-contains', 'merged', 'no-merged', 'forked', 'sort', 'points-at', 'format',
79
+ 'no-verbose', 'no-quiet', 'no-color', 'no-abbrev', 'no-column', 'no-sort', 'no-points-at', 'no-format', 'no-omit-empty', 'no-ignore-case', 'no-dry-run',
80
+ ])
81
+ /** Read-only options whose separate next token is their value. */
82
+ const BRANCH_VALUE_OPTIONS: ReadonlySet<string> = new Set(['contains', 'no-contains', 'merged', 'no-merged', 'forked', 'sort', 'points-at', 'format'])
83
+ /** Read-only options that put `git branch` in list mode, where positionals are patterns, not new branch names. */
84
+ const BRANCH_LIST_OPTIONS: ReadonlySet<string> = new Set(['list', 'show-current', 'contains', 'no-contains', 'merged', 'no-merged', 'points-at', 'forked'])
85
+ /** `-v -q -r -a -l -i`, alone or clustered; every other short letter writes or is unknown. */
86
+ const BRANCH_READ_ONLY_LETTERS: readonly string[] = ['v', 'q', 'r', 'a', 'l', 'i']
87
+
88
+ /** git's parse-options rule: an exact name wins, else the unique name that starts with `name`; `null` when unknown or ambiguous. */
89
+ function resolveLongOption(name: string, table: readonly string[]): string | null {
90
+ if (table.includes(name)) return name
91
+ const candidates = table.filter((candidate) => candidate.startsWith(name))
92
+ return candidates.length === 1 ? (candidates[0] as string) : null
93
+ }
94
+
95
+ /**
96
+ * PBI 661: does this `git branch` argv change branches, or might it? Fail
97
+ * closed: only an argv made entirely of known read-only options (long options
98
+ * resolved by unique prefix as git does) is false. A bare positional with no
99
+ * list-mode option is a branch name to CREATE, so it is true.
100
+ */
101
+ export function branchMutates(rest: readonly Token[]): boolean {
102
+ if (isHelpOnlyInvocation(rest)) return false
103
+ let listMode = false
104
+ let positionals = 0
105
+ for (let index = 0; index < rest.length; index++) {
106
+ const value = tokenValue(rest[index] as Token)
107
+ if (value === '--') {
108
+ positionals += rest.length - index - 1
109
+ break
110
+ }
111
+ if (value.startsWith('--')) {
112
+ const name = resolveLongOption(value.slice(2).split('=')[0] as string, BRANCH_ALL_OPTIONS)
113
+ if (name === null || !BRANCH_READ_ONLY_OPTIONS.has(name)) return true
114
+ if (BRANCH_LIST_OPTIONS.has(name)) listMode = true
115
+ if (BRANCH_VALUE_OPTIONS.has(name) && !value.includes('=')) index++
116
+ } else if (value.startsWith('-') && value.length > 1) {
117
+ const letters = [...value.slice(1)]
118
+ if (letters.some((letter) => !BRANCH_READ_ONLY_LETTERS.includes(letter))) return true
119
+ if (letters.includes('l')) listMode = true
120
+ } else {
121
+ positionals++
122
+ }
123
+ }
124
+ return positionals > 0 && !listMode
125
+ }
126
+
127
+ /** Long options that create a branch, per subcommand; `NON_CREATING` are the long options whose exact name must win over a longer creating one. */
128
+ const CREATING_LONG_OPTIONS: Readonly<Record<string, readonly string[]>> = {
129
+ switch: ['create', 'force-create', 'orphan', 'track'],
130
+ checkout: ['orphan', 'track'],
131
+ }
132
+ const NON_CREATING_LONG_OPTIONS: readonly string[] = ['force', 'detach', 'discard-changes', 'merge', 'conflict', 'quiet', 'progress', 'no-progress', 'guess', 'no-guess', 'no-track', 'ignore-other-worktrees', 'recurse-submodules', 'no-recurse-submodules', 'overlay', 'no-overlay', 'ours', 'theirs', 'patch']
133
+
134
+ /**
135
+ * PBI 661: `git switch -c/-C/-t/--create/--force-create/--orphan/--track` and
136
+ * `git checkout -b/-B/-t/--orphan/--track` create a branch, including any
137
+ * unique (or ambiguous-with-a-creating) long-option prefix (`--cr`, `--force-c`,
138
+ * `--orp`, `--tr`). Plain `git switch|checkout <existing>` is not matched.
139
+ */
140
+ export function switchCheckoutCreatesBranch(subcommand: string, rest: readonly Token[]): boolean {
141
+ const longs = CREATING_LONG_OPTIONS[subcommand] ?? []
142
+ const letters = subcommand === 'switch' ? ['c', 'C', 't'] : ['b', 'B', 't']
143
+ for (const token of rest) {
144
+ const value = tokenValue(token)
145
+ if (value === '--') return false
146
+ if (value.startsWith('--')) {
147
+ const name = value.slice(2).split('=')[0] as string
148
+ if (longs.includes(name)) return true
149
+ if (name.length > 0 && !NON_CREATING_LONG_OPTIONS.includes(name) && longs.some((long) => long.startsWith(name))) return true
150
+ } else if (value.startsWith('-') && value.length > 1 && [...value.slice(1)].some((letter) => letters.includes(letter))) {
151
+ return true
152
+ }
153
+ }
154
+ return false
67
155
  }
68
156
 
69
157
  export function tagDeletes(rest: readonly Token[]): boolean {
@@ -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
+ }
@@ -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,
@@ -47,6 +47,12 @@ export const PROTECTED_CARRIER_PATHS: ReadonlySet<string> = new Set([
47
47
  // separate home-relative resolution needed — the suffix is the same
48
48
  // regardless of what resolves the leading `~`/`$HOME`/absolute prefix.
49
49
  '.kimi-code/config.toml',
50
+ // OpenCode's permission policy (surface-policy.ts); OpenCode reads both spellings (global-surface-sweep.ts).
51
+ '.config/opencode/opencode.jsonc',
52
+ '.config/opencode/opencode.json',
53
+ // Claude managed settings, macOS and Linux; suffix entries so /private/etc and ../ spellings match too.
54
+ 'Library/Application Support/ClaudeCode/managed-settings.json',
55
+ 'etc/claude-code/managed-settings.json',
50
56
  ])
51
57
 
52
58
  /**
@@ -105,10 +111,8 @@ export function protectedCarrierMatch(path: string): string | null {
105
111
  /**
106
112
  * The per-host carrier DIRECTORIES — deleting, moving away, or overwriting
107
113
  * the WHOLE directory removes (or replaces) the carrier file inside it too,
108
- * exactly as much as touching that file directly. `.claude`/`.codex`/`.cursor`/`.opencode` — Kimi's own
109
- * `~/.kimi-code/` is deliberately NOT included: `mv ~/.kimi-code /tmp` is a
110
- * genuinely different, much bigger blast radius (moving the user's whole
111
- * Kimi config directory, not a repo-scoped host-config directory).
114
+ * exactly as much as touching that file directly. All five surfaces, global and
115
+ * in the repo (Tarun, 2026-10-09): `.claude`/`.codex`/`.cursor`/`.opencode`, `.kimi-code`, `.config/opencode`.
112
116
  *
113
117
  * Covers `mv`/`cp -r` (destination)/`rsync` (destination) via
114
118
  * `protected-carrier-writes.ts`'s existing operand extraction — a
@@ -119,7 +123,17 @@ export function protectedCarrierMatch(path: string): string | null {
119
123
  * existing operand handlers, and covering them needs new, tool-specific
120
124
  * parsing; a known gap, recorded here rather than silently dropped.
121
125
  */
122
- const PROTECTED_CARRIER_DIRS: ReadonlySet<string> = new Set(['.claude', '.codex', '.cursor', '.opencode'])
126
+ // The managed-settings folders are protected too: moving one away takes its managed-settings.json with it.
127
+ const PROTECTED_CARRIER_DIRS: ReadonlySet<string> = new Set([
128
+ '.claude',
129
+ '.codex',
130
+ '.cursor',
131
+ '.opencode',
132
+ '.kimi-code',
133
+ '.config/opencode',
134
+ 'Library/Application Support/ClaudeCode',
135
+ 'etc/claude-code',
136
+ ])
123
137
 
124
138
  /**
125
139
  * The protected carrier directory `path` names — exact match or a trailing
@@ -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)
@@ -64,7 +69,7 @@ repo's own `AGENTS.md`.
64
69
  - **Repository controls run via `adl repo controls`.** Git hooks in controlled repositories run pre-commit (local checks) and pre-push (Azure DevOps governance). Each repository is reconciled on its own with its `.agents/`, `.claude/`, `.codex/`, `.cursor/`, `.kimi-code/`, `.opencode/` projections.
65
70
  - **The release flow (`integration` auto-complete; the `branch → PR → integration → main` promotion; the integration PR merging itself once green) applies to repositories whose root contains an `adl-config.toml`.** Other repositories follow the release flow declared in their own `AGENTS.md`. The human approval gate, where it exists, is the promotion from `integration` to `main`; everything before that is auto-complete once CI is green.
66
71
  - Keep each commit **independently green** and on one cohesive topic. Never use `git stash`. When "one topic" and "independently green" conflict, green wins — and say so.
67
- - Subjects are `<type>(<scope>): <lowercase subject>`. Types: `feat|fix|docs|refactor|perf|chore`. No `!` marker. `release:` is reserved for the promotion PR the pipeline raises — never write one by hand. **In repositories whose root contains an `adl-config.toml`, the pipeline opens `integration → main` once CI is green on `integration` — no agent opens it; the subject follows the commit grammar above.**
72
+ - Subjects are `<type>(<scope>): <lowercase subject>`. Types: `feat|fix|docs|refactor|perf|chore`. Branch prefixes are exactly these types (`<type>/<kebab>`), not configurable per repository. No `!` marker. `release:` is reserved for the promotion PR the pipeline raises — never write one by hand. **In repositories whose root contains an `adl-config.toml`, the pipeline opens `integration → main` once CI is green on `integration` — no agent opens it; the subject follows the commit grammar above.**
68
73
  - A PR body needs non-empty `## Summary`, `## Why` and `## Changes`. The title follows the commit grammar above.
69
74
 
70
75
  ## Tests
@@ -0,0 +1,8 @@
1
+ // The one list of commit types (Bug 651, Tarun 2026-10-09); every other list derives from it.
2
+ export const COMMIT_TYPES = ['feat', 'fix', 'docs', 'refactor', 'perf', 'chore', 'release'] as const
3
+
4
+ // `release` is reserved for the promotion PR the pipeline raises; agents and humans never author it.
5
+ const RESERVED_COMMIT_TYPE = 'release'
6
+
7
+ // Branch prefixes are exactly the commit types minus `release`; no repository overrides them.
8
+ export const BRANCH_PREFIXES: readonly string[] = COMMIT_TYPES.filter((type) => type !== RESERVED_COMMIT_TYPE)
@@ -1,4 +1,7 @@
1
- const TITLE_PATTERN = /^(feat|fix|docs|refactor|perf|chore)(\([a-z0-9][a-z0-9-]*\))?: .+$/
1
+ import { BRANCH_PREFIXES } from './commit-types.ts'
2
+
3
+ // A PR title never carries the pipeline-reserved `release`, so its types are the branch prefixes.
4
+ const TITLE_PATTERN = new RegExp(`^(${BRANCH_PREFIXES.join('|')})(\\([a-z0-9][a-z0-9-]*\\))?: .+$`)
2
5
  const REQUIRED_SECTIONS = ['Summary', 'Why', 'Changes']
3
6
 
4
7
  export function validatePullRequestMetadata(title: string, description: string): string[] {
@@ -51,7 +51,7 @@ written) and the `core.hooksPath` setting are what `install --repo` wires in
51
51
  **target** repos.
52
52
 
53
53
  **Rule values are defaults, not universals.** The bundled defaults are enact-m5's:
54
- branch prefixes `feat|fix|docs|chore`, protected `main` and `integration`,
54
+ branch prefixes fixed to the commit types minus `release` (`packages/azdo/src/lib/commit-types.ts`), protected `main` and `integration`,
55
55
  lowercase kebab-case descriptions, lowercase commit subject with an 80-character
56
56
  limit and a fixed type list, plus the coverage ratchet and the behind-origin push
57
57
  check. Per-repo values are config-driven, in `adl-config.toml`'s `[controls]`
@@ -384,7 +384,7 @@ gate is switched off by an explicit `false`. See "No silent skips" below.
384
384
  | Key | Seeded by install | When absent | enact-m5 | Effect |
385
385
  |---|---|---|---|---|
386
386
  | `branches.protected` | yes | *required* | `["main", "integration"]` | pre-push refuses while on these branches |
387
- | `branches.prefixes` | yes | *required* | `["feat", "fix", "docs", "chore"]` | pre-push / azdo-push branch pattern `<prefix>/<kebab>` |
387
+ | `branches.prefixes` | removed | a config error naming the key | n/a | not configurable: branch prefixes are the commit types minus `release` (`packages/azdo/src/lib/commit-types.ts`); pre-push / azdo-push enforce `<prefix>/<kebab>`; delete the key |
388
388
  | `pre_push.remote` | yes | *required* | `"origin"` | remote azdo-push pushes to and reads status from |
389
389
  | `pre_commit.staged_coverage` | yes (`true`) | config error | `true` | enact-m5-only staged coverage measurement (fails when `[controls.coverage].produce` is not declared) |
390
390
  | `pre_commit.doc_freshness` | yes (`true`) | config error | opt in | a staged `docs/` or `plans/` `.md` carries a `last_update` stamped within 5h (nothing to grade when no docs are staged) |
@@ -633,7 +633,7 @@ the repository, and any of their files with consecutive comment lines fail REP-0
633
633
  result.
634
634
 
635
635
  Fixed conventions (enact-m5, not configurable): commit subject
636
- `^(feat|fix|docs|refactor|perf|chore|release)(\([a-z0-9][a-z0-9-]*\))?: .+`, at most 80
636
+ `^(<commit type>)(\([a-z0-9][a-z0-9-]*\))?: .+` (types `feat|fix|docs|refactor|perf|chore|release`, defined once in `packages/azdo/src/lib/commit-types.ts` and handed to the hook as `ADL_REPO_CONTROLS_COMMIT_TYPES`; `release` is the pipeline's only), at most 80
637
637
  characters, lowercase description without trailing period; `Merge `, `Revert "`
638
638
  and `X.Y.Z` subjects exempt; no staged `.scratchpad` paths or new references;
639
639
  `git diff --cached --check`; git environment scrubbed before push checks.
@@ -650,16 +650,16 @@ The pre-commit gate has two deliberately different outcomes:
650
650
  - A changed or untracked **top-level** dot path is **commit-required**.
651
651
  When it is omitted from the index, the hook names it and tells the author to
652
652
  stage it. Once staged, it can be committed normally. This covers top-level dot
653
- paths that are not protected, such as `.mcp.json` and future ones. Nested paths such as
653
+ paths that are not protected, such as `.mcp.json`, `.workflows/` and the agent surface roots
654
+ (`.agents/`, `.claude/`, `.codex/`, `.cursor/`, `.kimi-code/`, `.opencode/`): those hold a
655
+ repository's own hooks and skills, so they are not protected as a whole (Bug 644). Nested paths such as
654
656
  `tools/.claude/**` are ordinary repository content. The designated ephemeral
655
657
  scratch storage is never committable.
656
658
  - A **protected control** requires human approval even when staged: every path
657
- under `compliance/`, `workflows/`, `.workflows/` or an agent surface root
658
- (`.agents/`, `.claude/`, `.codex/`, `.cursor/`, `.kimi-code/`, `.opencode/`),
659
- `adl-config.toml`, the folder declared as `[controls.compliance.trees].compliance`
659
+ under `compliance/` or `workflows/`, `adl-config.toml`, the folder declared as `[controls.compliance.trees].compliance`
660
660
  (in addition to the fixed `compliance/` root, so a relocated register folder stays
661
661
  protected: `integrityWatched` in `src/hook.ts`), and the repository's explicit
662
- `[controls.integrity].watched` patterns (`FIXED_ROOTS` and `WIRING` in `src/integrity.ts`). The full definition and the
662
+ `[controls.integrity].watched` patterns (`PROTECTED_ROOTS` and `WIRING` in `src/integrity.ts`). A repository protects any dot path it chooses through `watched`. The full definition and the
663
663
  owner override: [`#protected-paths-and-owner-override`](../../docs/architecture/compliance-controls.md#protected-paths-and-owner-override). The same controls cannot be
664
664
  left dirty while committing other work.
665
665
 
@@ -2,7 +2,7 @@
2
2
  # Enforce the conventional commit subject contract.
3
3
  set -euo pipefail
4
4
 
5
- ALLOWED_TYPES='feat|fix|docs|refactor|perf|chore|release'
5
+ ALLOWED_TYPES="${ADL_REPO_CONTROLS_COMMIT_TYPES:?}"
6
6
  ALLOWED_TYPES_DISPLAY="${ALLOWED_TYPES//|/, }"
7
7
  FORMAT_RE="^(${ALLOWED_TYPES})"'(\([a-z0-9][a-z0-9-]*\))?: .+'
8
8
  TYPE_RE='^([A-Za-z0-9]+)(\(|:)'
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
+ import { BRANCH_PREFIXES, COMMIT_TYPES } from '../../azdo/src/lib/commit-types.ts'
3
4
  import { CONFIG_REL, INSTALL_HINT } from './constants.ts'
4
5
  import { MERGE_STRATEGIES, type MergeStrategy } from './promote.ts'
5
6
 
@@ -76,7 +77,7 @@ export interface FindingsConfig {
76
77
  }
77
78
 
78
79
  export interface RepoControlsConfig {
79
- branches: { protected: string[]; prefixes: string[] }
80
+ branches: { protected: string[] }
80
81
  preCommit: { staticChecks: StaticCheck[]; stagedCoverage: boolean; docFreshness: boolean; compliance: boolean }
81
82
  commitMsg: { staticChecks: StaticCheck[] }
82
83
  release: { optedOut: boolean; source: string; target: string; mergeStrategy: MergeStrategy; preBump: string[] }
@@ -111,8 +112,8 @@ export const SEEDED_ATTRIBUTES: SeededAttribute[] = [
111
112
  {
112
113
  key: 'branches',
113
114
  text: `
114
- # pre-push refuses to push while one of these branches is checked out; prefixes: <prefix>/<kebab>.
115
- branches = { protected = ["main", "integration"], prefixes = ["feat", "fix", "docs", "chore"] }
115
+ # pre-push refuses to push while one of these branches is checked out; branch prefixes are fixed to the commit types.
116
+ branches = { protected = ["main", "integration"] }
116
117
  `,
117
118
  },
118
119
  {
@@ -340,7 +341,6 @@ export function extractControlsSpan(text: string): string | null {
340
341
  }
341
342
 
342
343
  const BRANCH_RE = /^[a-z0-9][a-z0-9/-]*$/
343
- const PREFIX_RE = /^[a-z0-9][a-z0-9-]*$/
344
344
  const LABEL_RE = /^[a-z0-9][a-z0-9-]*$/
345
345
  const REMOTE_RE = /^\S+$/
346
346
 
@@ -487,6 +487,7 @@ export function parseConfig(text: string): RepoControlsConfig {
487
487
  problems.unknownKeys(controls, ['branches', 'pre_commit', 'pre_push', 'commit_msg', 'post_commit', 'compliance', 'coverage', 'findings', 'integrity', 'release'], '')
488
488
 
489
489
  const branches = problems.table(controls, 'branches', ['protected', 'prefixes'])
490
+ if (branches.prefixes !== undefined) problems.add('branches.prefixes is no longer configurable — delete it; branch prefixes are fixed to the commit types')
490
491
  // `branches` and `pre_push.remote` are REQUIRED: install always seeds them, and
491
492
  // `pre-push` reads their env vars with `${VAR:?}`, which aborts on an empty value —
492
493
  // so "no branch policy" cannot be spelled as an empty list.
@@ -539,7 +540,6 @@ export function parseConfig(text: string): RepoControlsConfig {
539
540
  const config: RepoControlsConfig = {
540
541
  branches: {
541
542
  protected: problems.names(branches, 'protected', 'branches.protected', BRANCH_RE),
542
- prefixes: problems.names(branches, 'prefixes', 'branches.prefixes', PREFIX_RE),
543
543
  },
544
544
  preCommit: {
545
545
  // The repository declares WHAT runs at commit and WHICH hook it runs in.
@@ -747,7 +747,8 @@ export function configEnv(repo: string, config: RepoControlsConfig): Record<stri
747
747
  const env: Record<string, string> = {
748
748
  ADL_REPO_CONTROLS_ROOT: repo,
749
749
  ADL_REPO_CONTROLS_PROTECTED_BRANCHES: config.branches.protected.join('|'),
750
- ADL_REPO_CONTROLS_BRANCH_PREFIXES: config.branches.prefixes.join('|'),
750
+ ADL_REPO_CONTROLS_BRANCH_PREFIXES: BRANCH_PREFIXES.join('|'),
751
+ ADL_REPO_CONTROLS_COMMIT_TYPES: COMMIT_TYPES.join('|'),
751
752
  ADL_REPO_CONTROLS_COVERAGE_RATCHET: config.prePush.coverageRatchet ? '1' : '0',
752
753
  ADL_REPO_CONTROLS_FINDINGS_RATCHET: config.prePush.findingsRatchet ? '1' : '0',
753
754
  ADL_REPO_CONTROLS_EXPORT_LINKS: config.prePush.exportLinks.join('\n'),
@@ -9,21 +9,8 @@
9
9
 
10
10
  const WIRING: readonly string[] = ['adl-config.toml']
11
11
 
12
- // Tarun, 2026-09-30: `.workflows/` and every agent surface root are protected
13
- // like `workflows/` — a dirty path under any of them blocks a normal commit and
14
- // only Tarun lands it. They were "centrally managed dotfiles" an agent could
15
- // commit alongside a related change; that let an agent land gate-defining files.
16
- const FIXED_ROOTS: readonly string[] = [
17
- 'compliance',
18
- 'workflows',
19
- '.workflows',
20
- '.agents',
21
- '.claude',
22
- '.codex',
23
- '.cursor',
24
- '.kimi-code',
25
- '.opencode',
26
- ]
12
+ // Only these folders are protected as a whole; dot folders hold a repository's own hooks and skills (Bug 644).
13
+ const PROTECTED_ROOTS: readonly string[] = ['compliance', 'workflows']
27
14
 
28
15
  /** A declared pattern, as a regex. `*` stays in one directory; `**` crosses them. */
29
16
  function patternToRegExp(pattern: string): RegExp {
@@ -59,18 +46,18 @@ function protectedMatchers(watched: readonly string[]): ((path: string) => boole
59
46
  return [...wiring, ...declared]
60
47
  }
61
48
 
62
- const isUnderFixedRoot = (path: string): boolean => FIXED_ROOTS.some((root) => path === root || path.startsWith(`${root}/`))
49
+ const isUnderProtectedRoot = (path: string): boolean => PROTECTED_ROOTS.some((root) => path === root || path.startsWith(`${root}/`))
63
50
 
64
51
  /** Centrally managed repository files that must travel with the next commit. */
65
52
  const isTopLevelDotPath = (path: string): boolean => path.startsWith('.')
66
53
 
67
54
  /**
68
55
  * The single source of truth used for final-index and dirty-worktree checks.
69
- * Fixed package policy protects root compliance/workflows and the enrollment
56
+ * Package policy protects root compliance/workflows and the enrollment
70
57
  * file; each repository adds its own gate files through `watched`.
71
58
  */
72
59
  export function isProtectedPath(path: string, watched: readonly string[]): boolean {
73
- return isUnderFixedRoot(path) || protectedMatchers(watched).some((match) => match(path))
60
+ return isUnderProtectedRoot(path) || protectedMatchers(watched).some((match) => match(path))
74
61
  }
75
62
 
76
63
  /**