@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.8 → 0.1.10
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/git +89 -8
- package/package.json +1 -1
- package/packages/agent-hooks/src/hooks/crg/background.ts +179 -0
- package/packages/agent-hooks/src/hooks/crg/handler.ts +2 -1
- package/packages/agent-hooks/src/hooks/crg/index.ts +36 -5
- package/packages/agent-hooks/src/rules/impl/git-paths.ts +5 -6
- package/packages/agent-hooks/src/rules/impl/tool-grep.ts +8 -8
- package/packages/azdo/src/azdo-repo-doctor.ts +79 -77
- package/packages/repo-controls/README.md +46 -8
- package/packages/repo-controls/assets/promote.ts +12 -7
- package/packages/repo-controls/assets/reconcile-guard.sh +113 -0
- package/packages/repo-controls/assets/version-bump.sh +96 -51
- package/packages/repo-controls/src/azure-devops-governance.ts +17 -16
- package/packages/repo-controls/src/constants.ts +8 -0
- package/packages/repo-controls/src/hook.ts +3 -2
- package/packages/repo-controls/src/integrity.ts +15 -1
package/bin/adl
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
[diffend] Oversized file quarantined before diffing.
|
|
2
2
|
name: package/bin/adl
|
|
3
|
-
size:
|
|
4
|
-
sha256:
|
|
3
|
+
size: 82925024 bytes
|
|
4
|
+
sha256: bf72c4f43099dc33afd161d5e8205d2fbfc5607e4b3aafac4ac0644224b75337
|
package/bin/git
CHANGED
|
@@ -621,8 +621,76 @@ case "$command_name" in
|
|
|
621
621
|
*) exec "$real_git" ${args[@]+"${args[@]}"} ;;
|
|
622
622
|
esac
|
|
623
623
|
|
|
624
|
+
# Whether option ARG, for network subcommand SUB, consumes the NEXT argv
|
|
625
|
+
# element as its value. Incident 2026-09-30 (Azure DevOps push 16505 recorded
|
|
626
|
+
# as the HUMAN "Tarun Rana" for an agent session's `git push -u origin
|
|
627
|
+
# feat/adl-install`): one shared value-option list treated `-u` as taking a
|
|
628
|
+
# value for EVERY subcommand. It only does for ls-remote/clone
|
|
629
|
+
# (`--upload-pack <exec>`); for push it is `--set-upstream` and for fetch/pull
|
|
630
|
+
# `--update-head-ok`, both flags. `origin` was swallowed as the "value",
|
|
631
|
+
# `feat/adl-install` became the "remote", `remote get-url` failed, and the
|
|
632
|
+
# push went to plain git and the human's credential helper. The table is
|
|
633
|
+
# therefore PER SUBCOMMAND (verified against git-push/fetch/pull/ls-remote/
|
|
634
|
+
# clone(1)). Options whose argument is optional (`--force-with-lease`,
|
|
635
|
+
# `--recurse-submodules` on fetch/pull/clone, `-S`, `-r`) only take it
|
|
636
|
+
# attached (`--opt=value`) and so never consume the next element.
|
|
637
|
+
network_option_takes_value() {
|
|
638
|
+
local sub="$1" arg="$2" short_values long_values chars i ch
|
|
639
|
+
local fetch_long=' depth deepen shallow-since shallow-exclude negotiation-tip negotiation-restrict negotiation-include filter refmap jobs server-option upload-pack exec submodule-prefix recurse-submodules-default bundle-uri '
|
|
640
|
+
case "$sub" in
|
|
641
|
+
push) short_values='o'; long_values=' push-option receive-pack exec repo recurse-submodules ' ;;
|
|
642
|
+
fetch) short_values='jo'; long_values="$fetch_long" ;;
|
|
643
|
+
pull) short_values='josX'; long_values="$fetch_long strategy strategy-option cleanup into-name " ;;
|
|
644
|
+
ls-remote) short_values='ou'; long_values=' upload-pack exec server-option sort ' ;;
|
|
645
|
+
clone) short_values='obucj'; long_values=' origin branch upload-pack template config separate-git-dir ref-format jobs bundle-uri depth shallow-since shallow-exclude reference reference-if-able server-option filter revision object-format ' ;;
|
|
646
|
+
*) return 1 ;;
|
|
647
|
+
esac
|
|
648
|
+
case "$arg" in
|
|
649
|
+
--|--*=*) return 1 ;;
|
|
650
|
+
--*) [[ "$long_values" == *" ${arg#--} "* ]] ;;
|
|
651
|
+
-?) [[ "$short_values" == *"${arg#-}"* ]] ;;
|
|
652
|
+
-*)
|
|
653
|
+
# Clustered short options (`-uf`, `-fo x`): the first value-taking
|
|
654
|
+
# letter ends the cluster; it consumes the next element only when it is
|
|
655
|
+
# the LAST letter (otherwise the rest of the cluster is its value).
|
|
656
|
+
chars="${arg#-}"
|
|
657
|
+
for ((i = 0; i < ${#chars}; i += 1)); do
|
|
658
|
+
ch="${chars:i:1}"
|
|
659
|
+
if [[ "$short_values" == *"$ch"* ]]; then
|
|
660
|
+
((i == ${#chars} - 1)) && return 0
|
|
661
|
+
return 1
|
|
662
|
+
fi
|
|
663
|
+
done
|
|
664
|
+
return 1
|
|
665
|
+
;;
|
|
666
|
+
*) return 1 ;;
|
|
667
|
+
esac
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
# True when OPERAND (an unresolved would-be remote name) is really a local
|
|
671
|
+
# path or a URL, which plain git handles without any named remote: an
|
|
672
|
+
# absolute/relative/home path, an existing path (relative to any leading
|
|
673
|
+
# `-C <dir>`), a `scheme://` URL, or scp-like `host:path`.
|
|
674
|
+
is_path_or_url_operand() {
|
|
675
|
+
local operand="$1" base='' i
|
|
676
|
+
case "$operand" in
|
|
677
|
+
/*|./*|../*|~*|.|..|*://*) return 0 ;;
|
|
678
|
+
esac
|
|
679
|
+
is_scp_like_url "$operand" && return 0
|
|
680
|
+
for ((i = 0; i < command_index; i += 1)); do
|
|
681
|
+
if [[ "${args[i]}" == -C && $((i + 1)) -lt command_index ]]; then
|
|
682
|
+
case "${args[i + 1]}" in
|
|
683
|
+
/*) base="${args[i + 1]}" ;;
|
|
684
|
+
*) base="${base:+$base/}${args[i + 1]}" ;;
|
|
685
|
+
esac
|
|
686
|
+
fi
|
|
687
|
+
done
|
|
688
|
+
[[ -e "${base:+$base/}$operand" ]]
|
|
689
|
+
}
|
|
690
|
+
|
|
624
691
|
remote_url=''
|
|
625
692
|
remote_name='origin'
|
|
693
|
+
remote_explicit=0
|
|
626
694
|
|
|
627
695
|
# A direct URL is authoritative for every network command. Scan it before
|
|
628
696
|
# resolving a remote name so clone, ls-remote, and explicit fetch/push/pull URLs never
|
|
@@ -635,24 +703,27 @@ for ((index = command_index + 1; index < ${#args[@]}; index += 1)); do
|
|
|
635
703
|
done
|
|
636
704
|
|
|
637
705
|
if [[ -z "$remote_url" ]]; then
|
|
638
|
-
# The first positional argument to fetch, pull, push, and ls-remote is the
|
|
639
|
-
# remote. Skip the
|
|
640
|
-
#
|
|
706
|
+
# The first positional argument to fetch, pull, push, and ls-remote is the
|
|
707
|
+
# selected remote. Skip options (and the value of those that take one FOR
|
|
708
|
+
# THIS SUBCOMMAND, see network_option_takes_value); other options are
|
|
709
|
+
# skipped rather than treated as a remote name.
|
|
641
710
|
for ((index = command_index + 1; index < ${#args[@]}; index += 1)); do
|
|
642
711
|
argument="${args[index]}"
|
|
643
712
|
case "$argument" in
|
|
644
713
|
--)
|
|
645
714
|
((index += 1))
|
|
646
|
-
((index < ${#args[@]}))
|
|
715
|
+
if ((index < ${#args[@]})); then
|
|
716
|
+
remote_name="${args[index]}"
|
|
717
|
+
remote_explicit=1
|
|
718
|
+
fi
|
|
647
719
|
break
|
|
648
720
|
;;
|
|
649
|
-
|
|
650
|
-
((index += 1))
|
|
651
|
-
;;
|
|
652
|
-
-?*|--*=*)
|
|
721
|
+
-?*)
|
|
722
|
+
network_option_takes_value "$command_name" "$argument" && ((index += 1))
|
|
653
723
|
;;
|
|
654
724
|
*)
|
|
655
725
|
remote_name="$argument"
|
|
726
|
+
remote_explicit=1
|
|
656
727
|
break
|
|
657
728
|
;;
|
|
658
729
|
esac
|
|
@@ -661,6 +732,16 @@ fi
|
|
|
661
732
|
|
|
662
733
|
if [[ -z "$remote_url" ]]; then
|
|
663
734
|
if ! remote_url="$("$real_git" "${args[@]:0:command_index}" remote get-url "$remote_name" 2>/dev/null)"; then
|
|
735
|
+
# FAIL CLOSED (incident 2026-09-30, above): an EXPLICITLY named remote
|
|
736
|
+
# that does not resolve, and is not a local path or a URL, must never be
|
|
737
|
+
# handed to plain git -- it would authenticate as the human via the
|
|
738
|
+
# keychain/credential helper if the name was really an Azure DevOps
|
|
739
|
+
# remote we mis-scanned. clone has no remote name (its operand is the
|
|
740
|
+
# source), and an unnamed default remote keeps today's pass-through.
|
|
741
|
+
if [[ "$command_name" != clone && "$remote_explicit" == 1 ]] && ! is_path_or_url_operand "$remote_name"; then
|
|
742
|
+
printf "git: cannot resolve remote '%s' for %s; refusing to pass an unresolved remote to plain git (it may authenticate as the human)\n" "$remote_name" "$command_name" >&2
|
|
743
|
+
exit 1
|
|
744
|
+
fi
|
|
664
745
|
exec "$real_git" ${args[@]+"${args[@]}"}
|
|
665
746
|
fi
|
|
666
747
|
fi
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amsterdamdatalabs/adl-agent-control-linux-x64",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
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": [
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-flight, detached `code-review-graph update --skip-flows`.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: on a large repo `update` takes ~13 s, but the
|
|
5
|
+
* `PostToolUse` hook that triggers it is killed by the host at 5 s
|
|
6
|
+
* (`assets/hooks.json`, `"timeout": 5`), so a synchronous run never
|
|
7
|
+
* finishes and the graph goes stale on every edit. The hook therefore only
|
|
8
|
+
* DECIDES here and returns at once; the work runs in a detached process
|
|
9
|
+
* group that outlives the hook.
|
|
10
|
+
*
|
|
11
|
+
* Protocol (all state under `<root>/.adl/`, already git-ignored):
|
|
12
|
+
* - `crg-update.lock` holds the pid of the process running the update.
|
|
13
|
+
* - `crg-update.pending` marker: "an edit landed while an update ran".
|
|
14
|
+
* - `logs/crg-update.log` the update's own stdout+stderr (append).
|
|
15
|
+
*
|
|
16
|
+
* `requestUpdate` (the hook side):
|
|
17
|
+
* 1. Acquire the lock atomically (`wx`), holding the hook's own pid as a
|
|
18
|
+
* placeholder so a concurrent hook sees a live owner, then spawn the
|
|
19
|
+
* runner and overwrite the lock with the runner's pid.
|
|
20
|
+
* 2. Lock already held by a live process (and younger than
|
|
21
|
+
* `MAX_LOCK_AGE_MS`, a guard against pid reuse after a crash/reboot):
|
|
22
|
+
* touch the pending marker and spawn nothing. A dead pid, or an
|
|
23
|
+
* over-age lock, is stale: it is removed and step 1 retried once.
|
|
24
|
+
* 3. After touching the marker, re-check the owner: if it exited in the
|
|
25
|
+
* window between the first check and the marker write, acquire and
|
|
26
|
+
* spawn instead, so the queued edit is never stranded.
|
|
27
|
+
*
|
|
28
|
+
* `RUNNER_SCRIPT` (the detached side) runs the update, and while the
|
|
29
|
+
* pending marker exists clears it and runs once more. On its way out it
|
|
30
|
+
* releases the lock, then re-checks the marker once and re-acquires the
|
|
31
|
+
* lock (noclobber) if a hook queued in the meantime, closing the last gap.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { closeSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
35
|
+
import { join } from 'node:path'
|
|
36
|
+
|
|
37
|
+
export const LOCK_REL = '.adl/crg-update.lock'
|
|
38
|
+
export const PENDING_REL = '.adl/crg-update.pending'
|
|
39
|
+
export const UPDATE_LOG_REL = '.adl/logs/crg-update.log'
|
|
40
|
+
|
|
41
|
+
/** A lock older than this is presumed left by a crash, whatever its pid says. */
|
|
42
|
+
export const MAX_LOCK_AGE_MS = 30 * 60 * 1000
|
|
43
|
+
|
|
44
|
+
export const RUNNER_SCRIPT = [
|
|
45
|
+
'lock="$1"; pending="$2"; shift 2',
|
|
46
|
+
'while :; do',
|
|
47
|
+
' rm -f "$pending"',
|
|
48
|
+
' "$@"',
|
|
49
|
+
' if [ -e "$pending" ]; then continue; fi',
|
|
50
|
+
' rm -f "$lock"',
|
|
51
|
+
' if [ -e "$pending" ] && (set -C; echo $$ > "$lock") 2>/dev/null; then continue; fi',
|
|
52
|
+
' break',
|
|
53
|
+
'done',
|
|
54
|
+
].join('\n')
|
|
55
|
+
|
|
56
|
+
export interface SpawnSpec {
|
|
57
|
+
readonly cmd: string
|
|
58
|
+
readonly args: readonly string[]
|
|
59
|
+
readonly cwd: string
|
|
60
|
+
readonly env: Readonly<Record<string, string | undefined>>
|
|
61
|
+
/** Absolute path the child's stdout+stderr are appended to. */
|
|
62
|
+
readonly logFile: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface BackgroundDeps {
|
|
66
|
+
/** Start a detached, unref'd child; return its pid. Must not wait for it. */
|
|
67
|
+
readonly spawn: (spec: SpawnSpec) => number
|
|
68
|
+
readonly isAlive: (pid: number) => boolean
|
|
69
|
+
readonly now: () => number
|
|
70
|
+
readonly selfPid: () => number
|
|
71
|
+
/** Resolve `bin` against `path` (the hook's own PATH); `null` when it is not there. */
|
|
72
|
+
readonly resolve: (bin: string, path: string | undefined) => string | null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type UpdateRequest = 'spawned' | 'queued'
|
|
76
|
+
|
|
77
|
+
const UPDATE_ARGS = ['update', '--skip-flows'] as const
|
|
78
|
+
|
|
79
|
+
function isPidAlive(pid: number): boolean {
|
|
80
|
+
try {
|
|
81
|
+
process.kill(pid, 0)
|
|
82
|
+
return true
|
|
83
|
+
} catch (error) {
|
|
84
|
+
// EPERM: the process exists but belongs to someone else.
|
|
85
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM'
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function spawnDetached(spec: SpawnSpec): number {
|
|
90
|
+
const fd = openSync(spec.logFile, 'a', 0o600)
|
|
91
|
+
try {
|
|
92
|
+
const proc = Bun.spawn([spec.cmd, ...spec.args], {
|
|
93
|
+
cwd: spec.cwd,
|
|
94
|
+
env: spec.env as Record<string, string>,
|
|
95
|
+
stdin: 'ignore',
|
|
96
|
+
stdout: fd,
|
|
97
|
+
stderr: fd,
|
|
98
|
+
// Own session/process group: survives the hook, unreachable by the host's timeout kill.
|
|
99
|
+
detached: true,
|
|
100
|
+
})
|
|
101
|
+
proc.unref()
|
|
102
|
+
return proc.pid
|
|
103
|
+
} finally {
|
|
104
|
+
closeSync(fd)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const defaultBackgroundDeps: BackgroundDeps = {
|
|
109
|
+
spawn: spawnDetached,
|
|
110
|
+
isAlive: isPidAlive,
|
|
111
|
+
now: () => Date.now(),
|
|
112
|
+
selfPid: () => process.pid,
|
|
113
|
+
resolve: (bin, path) => Bun.which(bin, { PATH: path ?? '' }),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function ownerIsLive(deps: BackgroundDeps, lockPath: string): boolean {
|
|
117
|
+
try {
|
|
118
|
+
const pid = Number.parseInt(readFileSync(lockPath, 'utf8').trim(), 10)
|
|
119
|
+
const ageMs = deps.now() - statSync(lockPath).mtimeMs
|
|
120
|
+
return Number.isInteger(pid) && pid > 0 && ageMs < MAX_LOCK_AGE_MS && deps.isAlive(pid)
|
|
121
|
+
} catch {
|
|
122
|
+
// Vanished between the caller's check and this read: nobody owns it.
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `wx` create: true if this call created the lock, false if it already exists. */
|
|
128
|
+
function tryAcquire(lockPath: string, pid: number): boolean {
|
|
129
|
+
try {
|
|
130
|
+
writeFileSync(lockPath, `${pid}\n`, { flag: 'wx', mode: 0o600 })
|
|
131
|
+
return true
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if ((error as NodeJS.ErrnoException).code === 'EEXIST') return false
|
|
134
|
+
throw error
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function startRunner(deps: BackgroundDeps, root: string, env: SpawnSpec['env'], lockPath: string, pendingPath: string): void {
|
|
139
|
+
const pid = deps.spawn({
|
|
140
|
+
cmd: 'sh',
|
|
141
|
+
args: ['-c', RUNNER_SCRIPT, 'sh', lockPath, pendingPath, 'code-review-graph', ...UPDATE_ARGS],
|
|
142
|
+
cwd: root,
|
|
143
|
+
env,
|
|
144
|
+
logFile: join(root, UPDATE_LOG_REL),
|
|
145
|
+
})
|
|
146
|
+
writeFileSync(lockPath, `${pid}\n`, { mode: 0o600 })
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Acquire + spawn. On a spawn failure the placeholder lock is released so the next edit can retry. */
|
|
150
|
+
function acquireAndStart(deps: BackgroundDeps, root: string, env: SpawnSpec['env'], lockPath: string, pendingPath: string): boolean {
|
|
151
|
+
if (!tryAcquire(lockPath, deps.selfPid())) return false
|
|
152
|
+
try {
|
|
153
|
+
startRunner(deps, root, env, lockPath, pendingPath)
|
|
154
|
+
} catch (error) {
|
|
155
|
+
rmSync(lockPath, { force: true })
|
|
156
|
+
throw error
|
|
157
|
+
}
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function requestUpdate(deps: BackgroundDeps, root: string, env: SpawnSpec['env']): UpdateRequest {
|
|
162
|
+
mkdirSync(join(root, '.adl', 'logs'), { recursive: true, mode: 0o700 })
|
|
163
|
+
const lockPath = join(root, LOCK_REL)
|
|
164
|
+
const pendingPath = join(root, PENDING_REL)
|
|
165
|
+
|
|
166
|
+
if (acquireAndStart(deps, root, env, lockPath, pendingPath)) return 'spawned'
|
|
167
|
+
if (!ownerIsLive(deps, lockPath)) {
|
|
168
|
+
rmSync(lockPath, { force: true })
|
|
169
|
+
if (acquireAndStart(deps, root, env, lockPath, pendingPath)) return 'spawned'
|
|
170
|
+
}
|
|
171
|
+
writeFileSync(pendingPath, '', { mode: 0o600 })
|
|
172
|
+
// The owner may have exited between the check and the marker write.
|
|
173
|
+
if (!ownerIsLive(deps, lockPath)) {
|
|
174
|
+
rmSync(lockPath, { force: true })
|
|
175
|
+
// The new runner clears the marker as its first step, so the queued edit is covered.
|
|
176
|
+
if (acquireAndStart(deps, root, env, lockPath, pendingPath)) return 'spawned'
|
|
177
|
+
}
|
|
178
|
+
return 'queued'
|
|
179
|
+
}
|
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { HookInvocation, HookResult } from '../contract.ts'
|
|
16
|
+
import { defaultBackgroundDeps } from './background.ts'
|
|
16
17
|
import { bunRunner, createCrgHandler } from './index.ts'
|
|
17
18
|
|
|
18
|
-
const crgHandler = createCrgHandler({ run: bunRunner })
|
|
19
|
+
const crgHandler = createCrgHandler({ run: bunRunner, background: defaultBackgroundDeps })
|
|
19
20
|
|
|
20
21
|
export async function handleCrg(inv: HookInvocation): Promise<HookResult> {
|
|
21
22
|
return crgHandler(inv)
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
|
|
48
48
|
import type { HookHandler, HookInvocation, HookResult } from '../contract.ts'
|
|
49
49
|
import { spawnWithTimeout } from '../../lib/spawn-with-timeout.ts'
|
|
50
|
+
import { defaultBackgroundDeps, requestUpdate, type BackgroundDeps } from './background.ts'
|
|
50
51
|
import { toDecideInput } from '../../pre-tool-use/handler.ts'
|
|
51
52
|
import { invocations } from '../../rules/shell/invocation.ts'
|
|
52
53
|
|
|
@@ -99,8 +100,38 @@ function missingBinaryNote(subcommand: string, result: RunResult): string {
|
|
|
99
100
|
return `crg ${subcommand}: ${BIN} ${reason}\n`
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
|
|
103
|
-
|
|
103
|
+
const UPDATE_LABEL = 'code-review-graph update --skip-flows'
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* `after-file-edit.sh`: `update --skip-flows`, stdout `{}`, always exit 0.
|
|
107
|
+
*
|
|
108
|
+
* With `background` (production wiring) the update is spawned DETACHED and
|
|
109
|
+
* single-flight (see `./background.ts`) and the hook returns at once: the
|
|
110
|
+
* update takes longer than the host's 5 s hook timeout on a large repo, so a
|
|
111
|
+
* synchronous run was killed every time and the graph never refreshed.
|
|
112
|
+
* Without it (unit tests of the plain runner path) the update runs inline.
|
|
113
|
+
*/
|
|
114
|
+
async function afterFileEdit(run: Runner, inv: HookInvocation, background: BackgroundDeps | undefined): Promise<HookResult> {
|
|
115
|
+
if (background !== undefined) {
|
|
116
|
+
// Fail loud up front: the detached runner's own "command not found" would land only in crg-update.log.
|
|
117
|
+
if (background.resolve(BIN, inv.env.PATH) === null) {
|
|
118
|
+
const note = missingBinaryNote('after-file-edit', { status: null, stdout: '', stderr: '' })
|
|
119
|
+
inv.record?.({ reason: `ERROR ${BIN} not on PATH`, message: note.trim() })
|
|
120
|
+
return { stdout: '{}\n', stderr: note, exitCode: 0 }
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const outcome = requestUpdate(background, inv.root, inv.env)
|
|
124
|
+
inv.record?.({
|
|
125
|
+
reason: 'PASS',
|
|
126
|
+
downstream: outcome === 'spawned' ? `spawned ${UPDATE_LABEL} (background)` : 'update already running; queued',
|
|
127
|
+
})
|
|
128
|
+
return { stdout: '{}\n', stderr: '', exitCode: 0 }
|
|
129
|
+
} catch (error) {
|
|
130
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
131
|
+
inv.record?.({ reason: `ERROR background ${UPDATE_LABEL}`, message })
|
|
132
|
+
return { stdout: '{}\n', stderr: `crg after-file-edit: could not start background update: ${message}\n`, exitCode: 0 }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
104
135
|
const result = await runCrg(run, inv, ['update', '--skip-flows'])
|
|
105
136
|
return { stdout: '{}\n', stderr: missingBinaryNote('after-file-edit', result), exitCode: 0 }
|
|
106
137
|
}
|
|
@@ -156,11 +187,11 @@ async function sessionStop(run: Runner, inv: HookInvocation): Promise<HookResult
|
|
|
156
187
|
return { stdout: `${JSON.stringify({ message, passed: true })}\n`, stderr: note, exitCode: 0 }
|
|
157
188
|
}
|
|
158
189
|
|
|
159
|
-
export function createCrgHandler(deps: { run: Runner }): HookHandler {
|
|
190
|
+
export function createCrgHandler(deps: { run: Runner; background?: BackgroundDeps }): HookHandler {
|
|
160
191
|
return async (inv: HookInvocation): Promise<HookResult> => {
|
|
161
192
|
switch (inv.subcommand) {
|
|
162
193
|
case 'after-file-edit':
|
|
163
|
-
return afterFileEdit(deps.run, inv)
|
|
194
|
+
return afterFileEdit(deps.run, inv, deps.background)
|
|
164
195
|
case 'pre-commit':
|
|
165
196
|
return preCommit(deps.run, inv)
|
|
166
197
|
case 'session-stop':
|
|
@@ -205,5 +236,5 @@ export const bunRunner: Runner = async (cmd, args, opts) => {
|
|
|
205
236
|
}
|
|
206
237
|
}
|
|
207
238
|
|
|
208
|
-
export const handleCrg: HookHandler = createCrgHandler({ run: bunRunner })
|
|
239
|
+
export const handleCrg: HookHandler = createCrgHandler({ run: bunRunner, background: defaultBackgroundDeps })
|
|
209
240
|
export default handleCrg
|
|
@@ -31,15 +31,14 @@ export const gitRm: RuleSpec = {
|
|
|
31
31
|
},
|
|
32
32
|
message(_finding: Finding, ctx: MessageContext) {
|
|
33
33
|
return {
|
|
34
|
-
user: 'Blocked git rm. It deletes tracked content and stages the deletion in one step; agents
|
|
34
|
+
user: 'Blocked git rm. It deletes tracked content and stages the deletion in one step; agents remove a tracked file with trash instead.',
|
|
35
35
|
agent:
|
|
36
36
|
'DENIED git rm. Every form is refused - with flags or without, --cached included. `git rm` deletes ' +
|
|
37
37
|
'tracked content AND stages the deletion in one step, so it is both the delete and the commit-prep ' +
|
|
38
|
-
'for it
|
|
39
|
-
'
|
|
40
|
-
'
|
|
41
|
-
|
|
42
|
-
`that already applies to committing a protected tree. ${gitAgentTail(ctx.snippet)}`,
|
|
38
|
+
'for it. There is no flag that makes it acceptable and no `git rm` that this hook allows. ' +
|
|
39
|
+
'If a tracked file genuinely has to go, run `trash <path>` (recoverable from ~/.Trash; fs.trash only ' +
|
|
40
|
+
'refuses the working directory, a system root, $HOME and .git), then `git add <path>` to stage the ' +
|
|
41
|
+
`deletion with the rest of the change. ${gitAgentTail(ctx.snippet)}`,
|
|
43
42
|
}
|
|
44
43
|
},
|
|
45
44
|
corpus: {
|
|
@@ -37,7 +37,7 @@ export const toolGrep: RuleSpec = {
|
|
|
37
37
|
category: 'tool',
|
|
38
38
|
severity: 'deny',
|
|
39
39
|
enabledByDefault: true,
|
|
40
|
-
summary: 'Deny the grep family; steer at
|
|
40
|
+
summary: 'Deny the grep family; steer at ast-grep / search / crg / rg / ctx_search.',
|
|
41
41
|
params: noParams,
|
|
42
42
|
match(inv: Invocation): Finding | null {
|
|
43
43
|
if (inv.kind !== 'grep') return null
|
|
@@ -45,15 +45,15 @@ export const toolGrep: RuleSpec = {
|
|
|
45
45
|
},
|
|
46
46
|
message(_finding: Finding, ctx: MessageContext) {
|
|
47
47
|
return {
|
|
48
|
-
user: 'Blocked grep.
|
|
48
|
+
user: 'Blocked grep. Pick the tool for the question: `ast-grep` for code shapes, `search` for docs by meaning, `crg` for callers and impact, `rg` for literal text, `ctx_search` for the indexed code search.',
|
|
49
49
|
agent:
|
|
50
|
-
'DENIED grep.
|
|
51
|
-
'
|
|
52
|
-
'
|
|
53
|
-
'
|
|
50
|
+
'DENIED grep. Match the tool to the question - in this order, all on PATH and already allowed:\n' +
|
|
51
|
+
' ast-grep code STRUCTURE: shapes rather than strings (`ast-grep -p "foo($A)" -l ts`)\n' +
|
|
52
|
+
' search docs and plans by MEANING: hybrid semantic + keyword (`search query "<text>"`)\n' +
|
|
53
|
+
' crg the code GRAPH: callers, callees, impact (`crg query callers_of <fn>`, `crg impact`)\n' +
|
|
54
|
+
' rg LITERAL text or regex, including stdin: `npm test 2>&1 | rg "pass|fail"`\n' +
|
|
54
55
|
' ctx_search the indexed code search (action=symbol for an exact symbol)\n' +
|
|
55
|
-
'
|
|
56
|
-
'so there is no capability being removed here, only a slower tool. ' +
|
|
56
|
+
'A pure output filter is no exception: `rg` reads stdin exactly as grep does. ' +
|
|
57
57
|
`Blocked command: ${ctx.snippet}`,
|
|
58
58
|
}
|
|
59
59
|
},
|