@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.33 → 0.1.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/adl +2 -2
- package/bin/reconcile +4 -3
- package/package.json +1 -1
- package/packages/agent-hooks/README.md +14 -0
- package/packages/agent-hooks/assets/opencode-plugin.template.ts +140 -104
- package/packages/agent-hooks/src/deploy/opencode-template.ts +5 -18
- package/packages/agent-hooks/src/deploy/render.ts +12 -3
- package/packages/agent-hooks/src/rules/impl/git-branch.ts +63 -0
- package/packages/agent-hooks/src/rules/impl/git-helpers.ts +91 -3
- package/packages/agent-hooks/src/rules/impl/git-history.ts +4 -13
- package/packages/agent-hooks/src/rules/impl/git-target.ts +423 -0
- package/packages/agent-hooks/src/rules/impl/tool-native-search.ts +8 -7
- package/packages/agent-hooks/src/rules/ports.ts +36 -0
- package/packages/agent-hooks/src/rules/registry.ts +3 -2
- package/packages/agent-hooks/src/rules/shell/protected-carriers.ts +1 -1
- package/packages/agent-settings/README.md +10 -4
- package/packages/agent-settings/assets/GLOBAL-AGENTS.md +5 -0
- package/packages/agent-settings/assets/TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md +40 -0
- package/packages/agent-settings/src/global-agents.ts +43 -8
- package/packages/agent-skills/assets/adl-mechanics/SKILL.md +38 -0
- package/packages/agent-skills/assets/agent-search-tool-audit/scripts/audit.sh +1 -1
- package/packages/agent-skills/assets/to-feature/SKILL.md +1 -1
- package/packages/agent-skills/assets/to-workitems/SKILL.md +1 -1
- package/packages/agent-skills/assets/to-workitems/references/AZURE-DEVOPS-HIERARCHY.md +3 -0
- package/packages/azdo/src/lib/agent-identity.ts +1 -1
- package/packages/repo-controls/README.md +12 -7
- package/packages/repo-controls/assets/scaffold/root/AGENTS.md +60 -11
- package/packages/repo-controls/assets/scaffold/root/CONTRIBUTING.md +28 -0
- package/packages/repo-controls/assets/scaffold/root/INVARIANTS.md +17 -0
- package/packages/repo-controls/src/cli.ts +23 -5
- package/packages/repo-controls/src/constants.ts +2 -15
- package/packages/repo-controls/src/install.ts +15 -6
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `
|
|
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
|
|
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 {
|
|
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
|
|
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> (
|
|
59
|
+
summary: 'Deny the native Grep/Glob/Search tools; steer at search <semantic query | keywords> (markdown), crg (code graph), rg, ast-grep.',
|
|
60
60
|
params: noParams,
|
|
61
61
|
match(inv: Invocation): Finding | null {
|
|
62
62
|
if (inv.kind !== 'tool-call') return null
|
|
63
63
|
if (!new Set(loadSurfacePolicy().deny.nativeTools).has(inv.tool)) return null
|
|
64
64
|
// Surface the tool input's path-ish field for the message so it can
|
|
65
|
-
// recommend `search` (the right tool for
|
|
65
|
+
// recommend `search` (the right tool for markdown) over
|
|
66
66
|
// `crg` (the right tool for code-graph queries). Different hosts name
|
|
67
67
|
// this field differently — Claude uses `path`, Codex uses `path`,
|
|
68
68
|
// Cursor uses `target_file` / `target_directory` for Write. The
|
|
@@ -77,10 +77,11 @@ export const toolNativeSearch: RuleSpec = {
|
|
|
77
77
|
},
|
|
78
78
|
message(finding: Finding, ctx: MessageContext) {
|
|
79
79
|
const tool = typeof finding.detail.tool === 'string' ? finding.detail.tool : 'this tool'
|
|
80
|
-
// Path-aware recommendation. Native Grep on
|
|
81
|
-
//
|
|
80
|
+
// Path-aware recommendation. Native Grep on a markdown file (any
|
|
81
|
+
// docs/plans/.scratchpad tree or *.md path) -> lead with `search`;
|
|
82
|
+
// code -> lead with `crg`.
|
|
82
83
|
const path = typeof finding.detail.path === 'string' ? finding.detail.path : ''
|
|
83
|
-
const isDoc = DOC_TREES.some((prefix) => path.startsWith(prefix))
|
|
84
|
+
const isDoc = DOC_TREES.some((prefix) => path.startsWith(prefix)) || path.endsWith('.md')
|
|
84
85
|
|
|
85
86
|
const leadTool = isDoc ? 'search' : 'crg'
|
|
86
87
|
const leadExamples = ALLOWED_SEARCH_TOOLS.find((row) => row.binary === leadTool)?.examples ?? []
|
|
@@ -93,8 +94,8 @@ export const toolNativeSearch: RuleSpec = {
|
|
|
93
94
|
|
|
94
95
|
return {
|
|
95
96
|
user: isDoc
|
|
96
|
-
? `Blocked the native ${tool} tool on \`${path}\` (
|
|
97
|
-
: `Blocked the native ${tool} tool. Lead with \`crg search "<text>"\` for code-graph queries, fall back to \`rg\`/\`ast-grep\` for literal text, \`search <semantic query | keywords>\`
|
|
97
|
+
? `Blocked the native ${tool} tool on \`${path}\` (markdown). The right answer is \`search <semantic query | keywords>\` — semantic + keyword, finds the doc that *talks* about X, in any markdown file. \`rg\`/\`crg\` are the wrong tools here.`
|
|
98
|
+
: `Blocked the native ${tool} tool. Lead with \`crg search "<text>"\` for code-graph queries, fall back to \`rg\`/\`ast-grep\` for literal text, \`search <semantic query | keywords>\` for any markdown file.`,
|
|
98
99
|
agent:
|
|
99
100
|
`DENIED the native ${tool} tool. It is routed through the lean-ctx hook but NOT converted — ` +
|
|
100
101
|
'the call still runs natively — so it is a way around the shell search ban rather than an ' +
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|