@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.9 → 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 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: 74d2d0af04a268afff30746e0d68d9d49d8e92c347e913330393c90362c99aff
3
+ size: 82925024 bytes
4
+ sha256: bf72c4f43099dc33afd161d5e8205d2fbfc5607e4b3aafac4ac0644224b75337
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amsterdamdatalabs/adl-agent-control-linux-x64",
3
- "version": "0.1.9",
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
- /** `after-file-edit.sh`: silent `update --skip-flows`, stdout `{}`, always exit 0. */
103
- async function afterFileEdit(run: Runner, inv: HookInvocation): Promise<HookResult> {
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 leave the file in place and hand the removal to you.',
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, and it was the last deletion verb this hook left open after rm and trash were closed. ' +
39
- 'There is no flag that makes it acceptable and no `git rm` that this hook allows. ' +
40
- 'If a tracked file genuinely has to go: leave it exactly where it is, finish the rest of the change, ' +
41
- 'and tell the user which paths should be removed and why - removal is theirs to run, the same rule ' +
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 rg / ast-grep / search / ctx_search.',
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. Use `rg` (same job, faster, repo-aware), `ast-grep` for structural search, or `ctx_search` for the indexed search.',
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. Use one of these instead - all are on PATH and already allowed:\n' +
51
- ' rg drop-in for grep, including stdin: `npm test 2>&1 | rg "pass|fail"`\n' +
52
- ' ast-grep structural/syntax search, for shapes rather than strings\n' +
53
- ' search hybrid semantic + keyword over indexed docs (`search query "<text>"`)\n' +
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
- 'This applies to a pure output filter too: `rg` reads stdin exactly as grep does, ' +
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
  },
@@ -85,13 +85,24 @@
85
85
  * as its own finding. checkBuildServiceCanRelease() answers one narrow
86
86
  * question directly, per the real failure above: can the identity that
87
87
  * ACTUALLY RUNS THE PIPELINE (repo scope) push a `***NO_CI***` bump commit
88
- * to `integration` (GenericContribute), bypass `integration`'s own policies
89
- * while doing so (PolicyExempt), and create/update the `integration -> main`
90
- * PR and set auto-complete (PullRequestContribute) — and it FAILS loudly,
88
+ * to the PR's source (feature) branch (GenericContribute), and create/update
89
+ * the `integration -> main` PR and set auto-complete
90
+ * (PullRequestContribute) — and it FAILS loudly,
91
91
  * naming the permission, the identity's display name and the fix path, on
92
92
  * any of: the permission not effectively allowed, the identity not
93
93
  * resolvable, or the ACL itself not readable by the calling agent.
94
94
  *
95
+ * New release flow (Tarun, 2026-09-30): the CI pipeline runs on PRs into
96
+ * `integration` and its stage 1 pushes the version bump to the PR's SOURCE
97
+ * (feature) branch; a promote pipeline on `integration` pushes only opens the
98
+ * `integration -> main` PR; a CD pipeline runs on pushes to `main`. Nothing
99
+ * pushes directly to `integration` any more, so (a) the build service no
100
+ * longer needs PolicyExempt on `integration` — and, by least privilege,
101
+ * having it there is drift; (b) the release-identity check no longer needs
102
+ * PolicyExempt; (c) the promotion PR gets NO build validation (Tarun's
103
+ * approval is the only gate; CD runs after the merge), so `main` no longer
104
+ * requires a build-validation policy and an enabled one is drift.
105
+ *
95
106
  * Policy type GUIDs (verified against the azure-devops-cli-extension source,
96
107
  * packages/repo-controls already hardcodes the first three the same way):
97
108
  * minimum reviewers fa4e907d-c16b-4a4c-9dfa-4906e5d171dd
@@ -178,7 +189,11 @@ export const REPO_STANDARD = {
178
189
  forbiddenReviewerPolicies: [POLICY_TYPE.requiredReviewers] as const,
179
190
  mergeStrategy: { allowSquash: false, allowRebase: false, allowRebaseMerge: false, allowNoFastForward: true },
180
191
  commentRequirementsRequired: true,
181
- buildValidationRequiredWhenPipelineExists: true,
192
+ // Tarun, 2026-09-30, new release flow: the integration -> main promotion
193
+ // PR must NOT run a build (Tarun's approval is the only gate; the CD
194
+ // pipeline runs after the merge, on pushes to main). An ENABLED build
195
+ // policy on main is drift (`main.build-validation-present`).
196
+ buildValidationForbidden: true,
182
197
  },
183
198
  // One table for every effective-permission expectation this checker reads
184
199
  // via the Git Repositories security namespace ACL, so the standard never
@@ -216,9 +231,13 @@ export const REPO_STANDARD = {
216
231
  * — Tarun, 2026-09-27, cross-checked against
217
232
  * /Users/tarunrana/Documents/ADL-ISMS-Policies/.scratchpad/ci_build_service_grant.py,
218
233
  * which grants exactly these bits on adl-agent-control). CI needs to push
219
- * and complete PRs on the repo, and needs PolicyExempt only on
220
- * `integration` (so a version-bump commit made by CI itself is not
221
- * blocked by its own policies) — never on `main`.
234
+ * and complete PRs on the repo (Contribute is also what the version-bump
235
+ * push to the PR's SOURCE feature branch needs — Tarun, 2026-09-30, new
236
+ * release flow). It needs PolicyExempt NOWHERE: nothing pushes directly to
237
+ * `integration` any more (the bump lands on the feature branch), so
238
+ * PolicyExempt on `integration` is no longer required and, by least
239
+ * privilege, is drift (`permissions.build-service.has-policyExempt-integration`);
240
+ * never on `main` either (existing rule).
222
241
  *
223
242
  * NOT forcePush (Tarun, 2026-09-30): the bump is a plain fast-forward
224
243
  * `git push origin HEAD:integration`; agent-identities' build service has
@@ -229,33 +248,28 @@ export const REPO_STANDARD = {
229
248
  repo: { allow: ["genericContribute", "pullRequestContribute"] } as {
230
249
  allow: readonly PermissionBitName[];
231
250
  },
232
- integrationRef: { allow: ["policyExempt"] } as { allow: readonly PermissionBitName[] },
251
+ integrationRef: { deny: ["policyExempt"] } as { deny: readonly PermissionBitName[] },
233
252
  mainRef: { deny: ["policyExempt"] } as { deny: readonly PermissionBitName[] },
234
253
  },
235
254
  /**
236
255
  * checkBuildServiceCanRelease() (file header, "checkBuildServiceCanRelease()"):
237
- * the exact three permissions the release flow needs from whichever
238
- * build service identity actually runs the pipeline — the real failure
239
- * this check exists for was a 403 on `pullRequestContribute`.
256
+ * the permissions the release flow needs from whichever build service
257
+ * identity actually runs the pipeline — the real failure this check
258
+ * exists for was a 403 on `pullRequestContribute`.
240
259
  *
241
- * `policyExempt` ("Bypass policies when pushing") is checked at the
242
- * `integration` REF token, never the repo-scope token — verified live
243
- * 2026-09-30 against adl-agent-control: the Infrastructure-AzDo Build
244
- * Service demonstrably bypassed `integration`'s own policies to push
245
- * `chore: bump version 0.1.6 to 0.1.7 ***NO_CI***` (c966e6a) straight to
246
- * that protected branch, yet its repo-scope effective allow bitmask
247
- * (16430) does not include PolicyExempt (128) at all — only its
248
- * `refs/heads/integration`-scope effective allow (16558) does. Checking
249
- * `policyExempt` at repo scope produced a false
250
- * `permissions.release.<slug>.missing-policyExempt` finding that
251
- * contradicted this observed reality. `genericContribute` and
252
- * `pullRequestContribute` stay checked at repo scope — both bits ARE
253
- * present there for real, and PR operations are a repo-level, not
254
- * branch-level, permission.
260
+ * `policyExempt` ("Bypass policies when pushing") is NOT required any
261
+ * more (Tarun, 2026-09-30, new release flow): the version bump now lands
262
+ * on the PR's SOURCE (feature) branch, not on `integration`, so nothing
263
+ * needs to bypass `integration`'s policies. (History: it used to be
264
+ * checked at the `integration` REF token, never repo scope — verified
265
+ * live 2026-09-30 against adl-agent-control, where the build service
266
+ * pushed `chore: bump version 0.1.6 to 0.1.7 ***NO_CI***` (c966e6a)
267
+ * straight to `integration`.) `genericContribute` (the bump push to the
268
+ * feature branch) and `pullRequestContribute` stay checked at repo scope
269
+ * — PR operations are a repo-level, not branch-level, permission.
255
270
  */
256
271
  buildServiceRelease: {
257
272
  repoScoped: ["genericContribute", "pullRequestContribute"] as const,
258
- integrationRefScoped: ["policyExempt"] as const,
259
273
  },
260
274
  },
261
275
  } as const;
@@ -518,11 +532,7 @@ function checkMainMergeStrategy(policies: readonly PolicyConfiguration[], drifts
518
532
  });
519
533
  }
520
534
 
521
- function checkMainCommentsAndBuild(
522
- policies: readonly PolicyConfiguration[],
523
- hasPipeline: boolean,
524
- drifts: RepoDoctorDrift[],
525
- ): void {
535
+ function checkMainCommentsAndBuild(policies: readonly PolicyConfiguration[], drifts: RepoDoctorDrift[]): void {
526
536
  const comments = enabledPolicyOfType(policies, POLICY_TYPE.commentRequirements);
527
537
  if (!comments) {
528
538
  drifts.push({
@@ -531,21 +541,27 @@ function checkMainCommentsAndBuild(
531
541
  });
532
542
  }
533
543
 
534
- if (!hasPipeline) return;
544
+ // Tarun, 2026-09-30, new release flow: the promotion PR must not run a
545
+ // build — main no longer requires build validation, and an enabled one is
546
+ // drift (regardless of whether a pipeline exists).
535
547
  const build = enabledPolicyOfType(policies, POLICY_TYPE.build);
536
- if (!build) drifts.push({ id: "main.build-validation-missing", message: "missing build validation" });
548
+ if (build) {
549
+ drifts.push({
550
+ id: "main.build-validation-present",
551
+ message: `main has an enabled build-validation policy (#${build.id}); the promotion PR must not run a build (CD runs after merge) — Tarun's approval is the only gate`,
552
+ });
553
+ }
537
554
  }
538
555
 
539
556
  function checkMainPolicies(
540
557
  policies: readonly PolicyConfiguration[],
541
- hasPipeline: boolean,
542
558
  strictMainBranchRules: boolean,
543
559
  drifts: RepoDoctorDrift[],
544
560
  ): void {
545
561
  checkMainRequiredReviewer(policies, drifts);
546
562
  checkMainMinimumReviewers(policies, strictMainBranchRules, drifts);
547
563
  checkMainMergeStrategy(policies, drifts);
548
- checkMainCommentsAndBuild(policies, hasPipeline, drifts);
564
+ checkMainCommentsAndBuild(policies, drifts);
549
565
  }
550
566
 
551
567
  /**
@@ -823,10 +839,11 @@ async function resolveBuildServiceDescriptor(namespaceId: string, projectId: str
823
839
  }
824
840
 
825
841
  /**
826
- * CI needs to push and complete PRs on the repo itself, and needs to bypass
827
- * policy only on `integration` (so its own version-bump commit is not
828
- * blocked by the very policies it satisfies) — never on `main`, where a
829
- * bypass would defeat the human-reviewer gate entirely.
842
+ * CI needs to push (the version bump to the PR's source branch) and complete
843
+ * PRs on the repo itself. It needs to bypass policy NOWHERE (Tarun,
844
+ * 2026-09-30, new release flow: nothing pushes directly to `integration`
845
+ * any more, so PolicyExempt there is drift; on `main` a bypass would defeat
846
+ * the human-reviewer gate entirely).
830
847
  */
831
848
  async function checkBuildServicePermissions(
832
849
  namespaceId: string,
@@ -885,11 +902,14 @@ async function checkBuildServicePermissions(
885
902
  drifts,
886
903
  );
887
904
  if (integrationEffective) {
888
- for (const bitName of REPO_STANDARD.permissions.buildService.integrationRef.allow) {
889
- if ((effectiveAllowBits(integrationEffective) & GIT_REPO_PERMISSION_BIT[bitName]) === 0) {
905
+ // Tarun, 2026-09-30, new release flow: nothing pushes directly to
906
+ // integration any more, so PolicyExempt there is no longer required and
907
+ // is flagged when effectively allowed (least privilege).
908
+ for (const bitName of REPO_STANDARD.permissions.buildService.integrationRef.deny) {
909
+ if ((effectiveAllowBits(integrationEffective) & GIT_REPO_PERMISSION_BIT[bitName]) !== 0) {
890
910
  drifts.push({
891
- id: `permissions.build-service.missing-${bitName}-integration`,
892
- message: `the project build service does not effectively have ${bitName} on refs/heads/integration`,
911
+ id: `permissions.build-service.has-${bitName}-integration`,
912
+ message: `the project build service effectively has ${bitName} on refs/heads/integration, which the standard forbids (nothing pushes directly to integration; the version bump lands on the PR's source branch)`,
893
913
  });
894
914
  }
895
915
  }
@@ -941,16 +961,6 @@ function releaseFixPath(repository: string, identityDisplayName: string, bitName
941
961
  return `Project settings → Repositories → \`${repository}\` → Security → \`${identityDisplayName}\` → \`${GIT_REPO_PERMISSION_UI_LABEL[bitName]}\` = Allow`;
942
962
  }
943
963
 
944
- /** Same as releaseFixPath() but for a bit that is granted per-BRANCH (verified live 2026-09-30: PolicyExempt on this org's build service lives on refs/heads/integration's own security page, never the repo-root one). */
945
- function releaseFixPathForRef(
946
- repository: string,
947
- ref: string,
948
- identityDisplayName: string,
949
- bitName: PermissionBitName,
950
- ): string {
951
- return `Repos → \`${repository}\` → Branches → \`${ref}\` → ⋯ → Branch security → \`${identityDisplayName}\` → \`${GIT_REPO_PERMISSION_UI_LABEL[bitName]}\` = Allow`;
952
- }
953
-
954
964
  /**
955
965
  * Which build service identity actually runs the pipeline, per Build —
956
966
  * General Settings — Get's `enforceJobAuthScope` (file header) — "Limit job
@@ -973,12 +983,10 @@ async function fetchEnforceJobAuthScope(c: AdoClient): Promise<boolean | undefin
973
983
  /**
974
984
  * One identity's answer to "can it release": resolve its descriptor by exact
975
985
  * display name (Identities — Read Identities, file header), then check
976
- * `buildServiceRelease.repoScoped` on the repository token AND
977
- * `buildServiceRelease.integrationRefScoped` on the `integration` ref token
978
- * — NOT the same token for all three (see REPO_STANDARD.permissions.
979
- * buildServiceRelease's own doc comment: PolicyExempt is a per-BRANCH grant
980
- * in this org, verified live against real bypass-on-push evidence, and
981
- * checking it at repo scope produced a false "missing" finding). Every
986
+ * `buildServiceRelease.repoScoped` on the repository token. PolicyExempt is
987
+ * no longer part of what a release needs (Tarun, 2026-09-30, new release
988
+ * flow: the bump lands on the PR's source branch, not on integration — see
989
+ * REPO_STANDARD.permissions.buildServiceRelease's own doc comment). Every
982
990
  * failure mode is its own loud, distinct drift — an unresolvable identity,
983
991
  * an ACL the calling agent cannot read, and each missing permission named
984
992
  * with its fix path — never a silent pass.
@@ -986,7 +994,6 @@ async function fetchEnforceJobAuthScope(c: AdoClient): Promise<boolean | undefin
986
994
  async function checkOneBuildServiceCanRelease(
987
995
  namespaceId: string,
988
996
  repoToken: string,
989
- integrationRefToken: string,
990
997
  repository: string,
991
998
  displayName: string,
992
999
  drifts: RepoDoctorDrift[],
@@ -1013,8 +1020,7 @@ async function checkOneBuildServiceCanRelease(
1013
1020
  }
1014
1021
 
1015
1022
  // LOW, independent review 2026-09-30: name WHICH token failed — "the ACL"
1016
- // alone left a human unable to tell a repo-scope 403 from a
1017
- // refs/heads/integration-scope one without re-running this by hand.
1023
+ // alone left a human unable to tell which token's read 403'd.
1018
1024
  async function effectiveAt(token: string, tokenLabel: string): Promise<{ allow: number; deny: number } | undefined> {
1019
1025
  let effective: EffectivePermissionsResult;
1020
1026
  try {
@@ -1039,16 +1045,6 @@ async function checkOneBuildServiceCanRelease(
1039
1045
  message: `'${displayName}' does not effectively have ${GIT_REPO_PERMISSION_UI_LABEL[bitName]} on '${repository}' — the release pipeline will 403 the way agent-identities build 4897 did. Fix: ${releaseFixPath(repository, displayName, bitName)}`,
1040
1046
  });
1041
1047
  }
1042
-
1043
- const integrationEffective = await effectiveAt(integrationRefToken, "refs/heads/integration");
1044
- if (!integrationEffective) return;
1045
- for (const bitName of REPO_STANDARD.permissions.buildServiceRelease.integrationRefScoped) {
1046
- if ((effectiveAllowBits(integrationEffective) & GIT_REPO_PERMISSION_BIT[bitName]) !== 0) continue;
1047
- drifts.push({
1048
- id: `permissions.release.${slug}.missing-${bitName}`,
1049
- message: `'${displayName}' does not effectively have ${GIT_REPO_PERMISSION_UI_LABEL[bitName]} on 'refs/heads/integration' of '${repository}' — its own version-bump commit would be blocked by integration's own policies. Fix: ${releaseFixPathForRef(repository, "integration", displayName, bitName)}`,
1050
- });
1051
- }
1052
1048
  }
1053
1049
 
1054
1050
  /**
@@ -1079,9 +1075,8 @@ async function checkBuildServiceCanRelease(
1079
1075
  : [projectScoped, collectionScoped];
1080
1076
 
1081
1077
  const repoToken = `repoV2/${projectId}/${repositoryId}`;
1082
- const integrationRefToken = `${repoToken}/refs/heads/${refNameHexUtf16Le("integration")}`;
1083
1078
  for (const displayName of displayNames) {
1084
- await checkOneBuildServiceCanRelease(namespaceId, repoToken, integrationRefToken, repository, displayName, drifts);
1079
+ await checkOneBuildServiceCanRelease(namespaceId, repoToken, repository, displayName, drifts);
1085
1080
  }
1086
1081
  }
1087
1082
 
@@ -1140,7 +1135,7 @@ export async function repoDoctorCheck(options: RepoDoctorOptions = {}): Promise<
1140
1135
  const integrationPolicies = await fetchBranchPolicies(c, repositoryId, "integration");
1141
1136
  const mainPolicies = await fetchBranchPolicies(c, repositoryId, "main");
1142
1137
  checkIntegrationPolicies(integrationPolicies, hasPipeline, drifts);
1143
- checkMainPolicies(mainPolicies, hasPipeline, strictMainBranchRules, drifts);
1138
+ checkMainPolicies(mainPolicies, strictMainBranchRules, drifts);
1144
1139
 
1145
1140
  await checkAgentPermissions(projectId, repositoryId, strictMainBranchRules, drifts);
1146
1141
  await checkBuildServicePermissions(
@@ -59,6 +59,11 @@ have needed):
59
59
  evidence blocks the push, never a warning, never a silent pass. A push
60
60
  already requires network access to the remote, so a network + token
61
61
  requirement here is not a new cost the way it would be at commit time.
62
+ The only guard required on the target branch is the minimum-reviewer
63
+ policy (one approval, `resetOnSourcePush=true`). A build policy is NOT
64
+ required (Tarun, 2026-09-30, three-pipeline release flow): the promotion PR
65
+ runs no build and Tarun's approval is the only gate, so an absent build policy
66
+ is not a failure (a present one is recorded as `ok`, never flagged here).
62
67
  - **A bare `adl-repo-controls doctor` run** (not through either hook) keeps
63
68
  reporting everything, exactly as before — this split only changes which
64
69
  hook exercises which checks, never what `doctor` itself is capable of
@@ -214,6 +219,13 @@ deactivates its hook path. Canonical logs, their root ignore, quarantined log
214
219
  evidence, and runtime caches remain. `adl-config.toml` itself is never deleted — only the
215
220
  `[controls]` span this package appended is ever spliced back out.
216
221
 
222
+ **promote** creates or reuses the active PR from `[controls.release].source`
223
+ to `target` and arms auto-complete (the installed `.workflows/promote.ts`;
224
+ see the assets list below). It requires the target's minimum-reviewer policy
225
+ and does not require a build policy: the promotion PR runs no build (Tarun,
226
+ 2026-09-30). It is what the Promote pipeline (`promote.yml`, triggered by a
227
+ merge into `integration`) runs.
228
+
217
229
  **doctor** is read-only and exits 1 on any failure: bash >= 4, bun, binary on
218
230
  PATH, the bundled compliance package present at its packaged path, repository
219
231
  root, `[controls]` config validity, (warning) `coverage/coverage-summary.json`
@@ -225,7 +237,7 @@ lock, each stub (present, unedited, executable), `core.hooksPath`, `.gitignore`
225
237
  lines, and (warning) lock version skew. When `[controls.release]` is declared,
226
238
  it also derives the configured `origin` remote, loads the validated canonical
227
239
  Agent Vault file before reading Azure DevOps, and records separate fail-closed receipts for the HTTPS handle,
228
- required owner reviewer, minimum-review reset, and build policy.
240
+ required owner reviewer, minimum-review reset, and (only when one is present) build policy.
229
241
 
230
242
  **run** executes a bundled enact-m5 tool against the repository with the config
231
243
  exported: `azdo-push` (Azure DevOps push with push lock + behind-origin refusal),
@@ -289,7 +301,25 @@ you own. Nothing is parsed and re-serialized; text is spliced into the table's s
289
301
  | `release.source` | no | `""` | `"integration"` | source branch for the managed promotion PR; no branch pair is guessed |
290
302
  | `release.target` | no | `""` | `"main"` | target branch for the managed promotion PR |
291
303
  | `release.merge_strategy` | no | `"squash"` | `"no-fast-forward"` | one of `squash`, `rebase`, `rebase-merge`, or `no-fast-forward` — sent to Azure DevOps as the matching wire value (`squash`, `rebase`, `rebaseMerge`, `noFastForward`) |
292
- | `release.pre_bump` | no | `[]` | — | ordered, repository-relative script paths `version-bump.sh` runs from the repo root before the bump commit |
304
+ | `release.pre_bump` | no | `[]` | — | ordered, repository-relative script paths `version-bump.sh` runs from the repo root before the bump commit (the CI pipeline's Bump stage, on the PR's source branch) |
305
+
306
+ ### Release scripts — Bump and Guard (Tarun, 2026-09-30)
307
+
308
+ One clean hard cutover to three pipelines (CI on PRs into `integration`,
309
+ Promote on `integration`, CD on `main`; `docs/architecture/cli-design.md` §6a).
310
+ Two managed scripts run in the CI pipeline, and neither ever pushes to
311
+ `integration`:
312
+
313
+ - **`.workflows/version-bump.sh --file package.json --pr-source <source> --base
314
+ integration --push`** (Bump stage) sets the PR's SOURCE branch to
315
+ `integration`'s version + 1 patch, running `release.pre_bump` scripts in the
316
+ same commit, and pushes it (a plain push as the build service, never an
317
+ agent identity, never force). It refuses a source that does not contain
318
+ `integration`'s tip. The old `--integration` mode is gone: it is an unknown
319
+ argument.
320
+ - **`.workflows/reconcile-guard.sh`** (Guard stage) fails, printing the
321
+ reconcile command (`git pull origin integration && git push`), unless the
322
+ source contains `integration`'s tip and is at `integration` + 1.
293
323
 
294
324
  ### `release.pre_bump` — multi-language version bumps
295
325
 
@@ -365,12 +395,14 @@ sed -i.bak -E "s/^const Version = \".*\"/const Version = \"${ADL_NEXT_VERSION}\"
365
395
  rm -f "${file}.bak"
366
396
  ```
367
397
 
368
- `install` copies two package-owned standalone CI assets byte-for-byte:
398
+ `install` copies four package-owned standalone CI assets byte-for-byte:
369
399
 
370
400
  - `.workflows/promote.ts`
371
401
  - `.workflows/scripts/enable-integration-auto-complete.mjs`
402
+ - `.workflows/version-bump.sh`
403
+ - `.workflows/reconcile-guard.sh`
372
404
 
373
- Both installed assets are real executable files, never symlinks. Their SHA-256
405
+ All installed assets are real executable files, never symlinks. Their SHA-256
374
406
  hashes live in `.workflows/repo-controls.lock.json`, and `doctor` fails if either
375
407
  file, mode, or lock entry drifts from the canonical source under `assets/`. A
376
408
  repository opts into promotion by declaring `[controls.release]` and wiring its
@@ -380,10 +412,11 @@ without deleting the source branch or transitioning work items — it never
380
412
  names a reviewer on the PR (Tarun, 2026-09-27: naming one, typically the
381
413
  person who pushed to `integration`, is a self-approval risk and contradicts
382
414
  "agent-reviewer is optional, never required"). Before it reads or mutates PRs,
383
- it proves the exact target branch has enabled blocking repository-wide
384
- policies for at least one approval with `resetOnSourcePush = true` (the
385
- author's own vote does not count towards it) and a build definition; a
386
- missing or weaker policy fails promotion. Pre-commit calls the same read-only
415
+ it proves the exact target branch has an enabled blocking repository-wide
416
+ policy for at least one approval with `resetOnSourcePush = true` (the
417
+ author's own vote does not count towards it); a missing or weaker policy fails
418
+ promotion. No build policy is required (Tarun, 2026-09-30: the promotion PR
419
+ runs no build; the approval is the only gate). Pre-commit calls the same read-only
387
420
  doctor report and never repairs or stages the managed surface: run
388
421
  `adl repo controls install --repo .` explicitly to repair it.
389
422
 
@@ -399,6 +432,11 @@ and `X.Y.Z` subjects exempt; no staged `.scratchpad` paths or new references;
399
432
 
400
433
  ### Managed dot paths and protected controls
401
434
 
435
+ `.workflows/` is the adl-owned managed root: every file `install` writes there,
436
+ including the scripts `.workflows/promote.ts`, `.workflows/version-bump.sh` and
437
+ `.workflows/reconcile-guard.sh`, is hashed in `.workflows/repo-controls.lock.json`
438
+ and repaired by the next `install`; do not hand-edit it.
439
+
402
440
  The pre-commit gate has two deliberately different outcomes:
403
441
 
404
442
  - A changed or untracked **top-level** dot path is **commit-required**.
@@ -30,6 +30,11 @@
30
30
  * forget the other, and the derived form satisfies this package's own
31
31
  * commit-msg grammar for any pair.
32
32
  *
33
+ * The target branch's gate is Tarun's approval alone (Tarun, 2026-09-30): the
34
+ * promotion PR gets NO build validation and CD runs after the merge into the
35
+ * target. The pre-flight proves the minimum-reviewer policy (>=1 approver,
36
+ * resetOnSourcePush=true) and does not require a build policy.
37
+ *
33
38
  * There is no default pair. Defaulting to integration -> main would put one
34
39
  * repository's branch names back into the package by the back door.
35
40
  */
@@ -192,6 +197,12 @@ export interface PromotionPolicyReceipts {
192
197
  * only counts when it is enabled, blocking, and scoped exactly to the target
193
198
  * branch; a project-wide or path-filtered lookalike cannot guard a promotion.
194
199
  *
200
+ * The target's gate is the minimum-reviewer policy ONLY (Tarun, 2026-09-30):
201
+ * the integration -> main promotion PR gets no build validation, Tarun's
202
+ * approval is the only gate, and CD runs after the merge into main. Promotion
203
+ * therefore never requires a build policy. `build` is still reported so a
204
+ * caller can show one that happens to be present, but nothing here demands it.
205
+ *
195
206
  * No named-reviewer policy is checked here, deliberately (Tarun,
196
207
  * 2026-09-27): the two-branch standard's `main` gate is "exactly one human
197
208
  * reviewer, author's vote excluded" via the minimum-reviewer policy below —
@@ -245,17 +256,11 @@ async function verifyPolicyGate(
245
256
  )
246
257
  const policies = response.value ?? []
247
258
  const receipts = promotionPolicyReceipts(policies, { repositoryId, targetRef })
248
- const { minimumReviewer, build } = receipts
249
- if (!minimumReviewer) {
259
+ if (!receipts.minimumReviewer) {
250
260
  throw new Error(
251
261
  `promotion requires an enabled blocking minimum-reviewer policy for repository ${repositoryId} ${targetRef} with minimumApproverCount>=1 and resetOnSourcePush=true`,
252
262
  )
253
263
  }
254
- if (!build) {
255
- throw new Error(
256
- `promotion requires an enabled blocking build policy for repository ${repositoryId} ${targetRef} with buildDefinitionId>0`,
257
- )
258
- }
259
264
  }
260
265
 
261
266
  export async function ensurePromotionPullRequest({
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Stage 2 of the CI pipeline for pull requests into `integration` (stage 1
5
+ # bumps the PR branch, stage 3 runs the tests). It forces the PR author,
6
+ # usually an agent, to reconcile with integration before anything merges:
7
+ #
8
+ # Check A the PR branch contains integration's tip
9
+ # Check B the PR branch's manifest version is integration's + 1 patch
10
+ #
11
+ # Read-only: this script only fetches and reads. It never pushes, and the only
12
+ # refs it writes are the two remote-tracking refs it fetches.
13
+ #
14
+ # Run from the package directory (like version-bump.sh); the manifest is
15
+ # resolved repo-relative through `git rev-parse --show-prefix`.
16
+
17
+ # CI never carries an agent identity. Never set one here, and never inherit
18
+ # one from the caller.
19
+ unset ADL_AGENT_ID_AGENT
20
+
21
+ die() {
22
+ echo "reconcile-guard: $*" >&2
23
+ exit 1
24
+ }
25
+
26
+ version_file=''
27
+ pr_source=''
28
+ base=''
29
+
30
+ while [[ $# -gt 0 ]]; do
31
+ case "$1" in
32
+ --file)
33
+ [[ $# -ge 2 ]] || die '--file requires a path'
34
+ version_file="$2"
35
+ shift 2
36
+ ;;
37
+ --pr-source)
38
+ [[ $# -ge 2 ]] || die '--pr-source requires a branch'
39
+ pr_source="$2"
40
+ shift 2
41
+ ;;
42
+ --base)
43
+ [[ $# -ge 2 ]] || die '--base requires a branch'
44
+ base="$2"
45
+ shift 2
46
+ ;;
47
+ *)
48
+ die "unknown argument: $1"
49
+ ;;
50
+ esac
51
+ done
52
+
53
+ [[ -n "$version_file" ]] || die '--file is required'
54
+ [[ "$version_file" == 'package.json' ]] || die 'only --file package.json is supported'
55
+ [[ -n "$pr_source" ]] || die '--pr-source is required'
56
+ [[ -n "$base" ]] || die '--base is required'
57
+
58
+ # Azure Pipelines hands over Build.SourceBranch-style refs (refs/heads/x).
59
+ branch="${pr_source#refs/heads/}"
60
+ base="${base#refs/heads/}"
61
+ [[ -n "$branch" ]] || die "--pr-source '$pr_source' names no branch"
62
+
63
+ case "$branch" in
64
+ main | integration | "$base")
65
+ die "refusing to guard '$branch': it is not a pull-request source branch"
66
+ ;;
67
+ esac
68
+
69
+ prefix="$(git rev-parse --show-prefix)" || die 'not inside a git work tree'
70
+ manifest_path="${prefix}${version_file}"
71
+
72
+ base_ref="refs/remotes/origin/$base"
73
+ branch_ref="refs/remotes/origin/$branch"
74
+
75
+ git fetch --quiet origin "+refs/heads/$base:$base_ref" || die "cannot fetch origin $base"
76
+ git fetch --quiet origin "+refs/heads/$branch:$branch_ref" || die "cannot fetch origin $branch"
77
+
78
+ base_short="$(git rev-parse --short "$base_ref")" || die "cannot resolve $base_ref"
79
+
80
+ if ! git merge-base --is-ancestor "$base_ref" "$branch_ref"; then
81
+ missing="$(git rev-list --count "$branch_ref..$base_ref")"
82
+ echo "reconcile-guard: FAIL — $branch does not contain integration's tip ($base_short). Reconcile: git pull origin integration && git push" >&2
83
+ echo "reconcile-guard: $missing integration commit(s) missing from $branch" >&2
84
+ exit 1
85
+ fi
86
+
87
+ read_version() {
88
+ local ref="$1" text
89
+ text="$(git show "$ref:$manifest_path")" || die "cannot read $manifest_path at $ref"
90
+ printf '%s' "$text" | node -e '
91
+ const version = JSON.parse(require("node:fs").readFileSync(0, "utf8")).version
92
+ if (typeof version !== "string") process.exit(1)
93
+ process.stdout.write(version)
94
+ ' || die "cannot read a string version from $manifest_path at $ref"
95
+ }
96
+
97
+ parse_version() {
98
+ [[ "$1" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || die "cannot parse '$1' as a release version x.y.z"
99
+ }
100
+
101
+ base_version="$(read_version "$base_ref")"
102
+ branch_version="$(read_version "$branch_ref")"
103
+
104
+ parse_version "$base_version"
105
+ target="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))"
106
+ parse_version "$branch_version"
107
+
108
+ if [[ "$branch_version" != "$target" ]]; then
109
+ echo "reconcile-guard: FAIL — $branch version $branch_version is not integration $base_version + 1 patch ($target). Stage 1 bumps it; re-run the PR build after reconciling." >&2
110
+ exit 1
111
+ fi
112
+
113
+ echo "reconcile-guard: PASS — $branch contains integration $base_short and is at $target"
@@ -1,13 +1,22 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
- # The integration build owns package version allocation, exactly as every
5
- # other repo on this standard does it (enact-extensions, enact-combined):
6
- # after a PR merges, bump the patch version on `integration`, commit it with
7
- # ***NO_CI*** and push it straight back to `integration`. The build service
8
- # has "bypass policies when pushing"; ***NO_CI*** stops the push from
9
- # triggering another pipeline run. No npm query, no second PR, no second
10
- # test run (Tarun, 2026-09-29).
4
+ # STAGE 1 of the PR pipeline (PR -> integration): the PR build owns package
5
+ # version allocation. Every PR into `integration` must carry exactly
6
+ # `<integration version> + patch 1`; this script makes it so, on the PR's
7
+ # SOURCE branch, before anything else validates it:
8
+ #
9
+ # version-bump.sh --file package.json --pr-source <feature branch> \
10
+ # --base integration --push
11
+ #
12
+ # The Azure Pipelines PR build checks out the detached refs/pull/N/merge
13
+ # commit with persisted credentials, so `git push` authenticates as the
14
+ # project build service. The bump commit is created on the source branch's own
15
+ # tip (never on the merge commit), carries no ***NO_CI*** (feature branches
16
+ # have no CI trigger, so the push re-queues the PR build, which then
17
+ # validates the bumped commit), and is pushed with a plain push — never
18
+ # forced. When the source is already at the target version nothing is
19
+ # committed and the run continues. No npm query, no second PR.
11
20
 
12
21
  # CI never carries an agent identity: the push below authenticates as the
13
22
  # project build service through the checkout's persisted System.AccessToken.
@@ -20,8 +29,8 @@ die() {
20
29
  }
21
30
 
22
31
  version_file=''
23
- integration_branch=''
24
- integration_mode=false
32
+ pr_source=''
33
+ base_branch=''
25
34
  push_bump=false
26
35
 
27
36
  while [[ $# -gt 0 ]]; do
@@ -31,14 +40,15 @@ while [[ $# -gt 0 ]]; do
31
40
  version_file="$2"
32
41
  shift 2
33
42
  ;;
34
- --branch)
35
- [[ $# -ge 2 ]] || die '--branch requires a name'
36
- integration_branch="$2"
43
+ --pr-source)
44
+ [[ $# -ge 2 ]] || die '--pr-source requires a branch ref'
45
+ pr_source="$2"
37
46
  shift 2
38
47
  ;;
39
- --integration)
40
- integration_mode=true
41
- shift
48
+ --base)
49
+ [[ $# -ge 2 ]] || die '--base requires a branch name'
50
+ base_branch="$2"
51
+ shift 2
42
52
  ;;
43
53
  --push)
44
54
  push_bump=true
@@ -51,46 +61,73 @@ while [[ $# -gt 0 ]]; do
51
61
  done
52
62
 
53
63
  [[ "$version_file" == 'package.json' ]] || die 'only --file package.json is supported'
54
- [[ -f "$version_file" ]] || die "manifest not found: $version_file"
55
- [[ -n "$integration_branch" ]] || die '--branch is required'
56
- [[ "$integration_mode" == true ]] || die '--integration is required'
64
+ [[ -n "$pr_source" ]] || die '--pr-source is required'
65
+ [[ -n "$base_branch" ]] || die '--base is required'
57
66
  [[ "$push_bump" == true ]] || die '--push is required'
58
67
 
59
- current_branch="$(git branch --show-current)"
60
- if [[ -z "$current_branch" ]]; then
61
- # Azure Pipelines' `checkout: self` leaves the working tree on a detached
62
- # HEAD (build 4822, integration, 2026-09-27: `git branch --show-current`
63
- # came back empty even though the checkout was the right commit on
64
- # `integration`). Fall back to Build.SourceBranch (env BUILD_SOURCEBRANCH,
65
- # e.g. `refs/heads/integration`) rather than requiring the pipeline to
66
- # `git checkout -B` a real branch first.
67
- case "${BUILD_SOURCEBRANCH:-}" in
68
- refs/heads/*)
69
- current_branch="${BUILD_SOURCEBRANCH#refs/heads/}"
70
- ;;
71
- '')
72
- die 'cannot determine the current branch: HEAD is detached (git branch --show-current is empty) and BUILD_SOURCEBRANCH is not set'
73
- ;;
74
- *)
75
- die "BUILD_SOURCEBRANCH '$BUILD_SOURCEBRANCH' is not a branch ref (expected 'refs/heads/<name>')"
76
- ;;
77
- esac
68
+ # Accept `refs/heads/x` (Build.SourceBranch / System.PullRequest.SourceBranch)
69
+ # or a bare `x`; anything else under refs/ is not a branch.
70
+ source_branch="${pr_source#refs/heads/}"
71
+ case "$source_branch" in
72
+ '') die '--pr-source names an empty branch' ;;
73
+ refs/*) die "--pr-source '$pr_source' is not a branch ref (expected 'refs/heads/<name>' or '<name>')" ;;
74
+ esac
75
+ base_branch="${base_branch#refs/heads/}"
76
+ if [[ "$source_branch" == "$base_branch" || "$source_branch" == 'main' || "$source_branch" == 'integration' ]]; then
77
+ die "refusing to bump '$source_branch': the PR source must be a feature branch, not the base ('$base_branch'), main or integration"
78
78
  fi
79
- [[ "$current_branch" == "$integration_branch" ]] || \
80
- die "integration mode expects branch '$integration_branch', currently on '$current_branch'"
81
-
82
- current_version="$(node -e 'const version = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).version; if (typeof version !== "string") process.exit(1); process.stdout.write(version)' "$version_file")" || \
83
- die "cannot read a string version from $version_file"
84
79
 
85
- if [[ ! "$current_version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
86
- die "cannot parse '$current_version' as a release version x.y.z"
80
+ git diff --quiet || die 'working tree is not clean before version bump'
81
+ git diff --cached --quiet || die 'index is not clean before version bump'
82
+ repo_root="$(git rev-parse --show-toplevel)" || die 'cannot resolve the repository root'
83
+ # The script runs from the package directory (e.g. apps/cli); the manifest's
84
+ # repository-relative path is how it is read from the base branch.
85
+ repo_prefix="$(git rev-parse --show-prefix)" || die 'cannot resolve the working directory inside the repository'
86
+ repo_relative_file="${repo_prefix}${version_file}"
87
+
88
+ # Read both tips fresh from origin. The PR build's checkout is the detached
89
+ # merge commit; bumping that would push a commit that is not the branch's own
90
+ # history, so the working tree is moved onto a REAL branch at the source tip.
91
+ git fetch origin \
92
+ "refs/heads/$base_branch:refs/remotes/origin/$base_branch" \
93
+ "refs/heads/$source_branch:refs/remotes/origin/$source_branch" || \
94
+ die "cannot fetch '$base_branch' and '$source_branch' from origin"
95
+
96
+ # The source must already contain the base's tip: the version it is bumped to
97
+ # is derived from that tip, so a source behind it would be validated against a
98
+ # merge the bump never saw.
99
+ if ! git merge-base --is-ancestor "refs/remotes/origin/$base_branch" "refs/remotes/origin/$source_branch"; then
100
+ missing_count="$(git rev-list --count "refs/remotes/origin/$source_branch..refs/remotes/origin/$base_branch")"
101
+ base_short="$(git rev-parse --short "refs/remotes/origin/$base_branch")"
102
+ echo "version-bump: FAIL — $source_branch does not contain $base_branch's tip ($base_short); reconcile first: git pull origin $base_branch && git push" >&2
103
+ echo "version-bump: $missing_count commit(s) on $base_branch are missing from $source_branch" >&2
104
+ exit 1
87
105
  fi
88
106
 
107
+ git checkout -q -B "$source_branch" "refs/remotes/origin/$source_branch" || \
108
+ die "cannot check out '$source_branch' at its origin tip"
109
+
110
+ [[ -f "$version_file" ]] || die "manifest not found: $version_file"
111
+
112
+ read_version() {
113
+ node -e 'const version = JSON.parse(require("node:fs").readFileSync(0, "utf8")).version; if (typeof version !== "string") process.exit(1); process.stdout.write(version)'
114
+ }
115
+
116
+ base_version="$(git show "refs/remotes/origin/$base_branch:$repo_relative_file" | read_version)" || \
117
+ die "cannot read a string version from $repo_relative_file on origin/$base_branch"
118
+ if [[ ! "$base_version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
119
+ die "cannot parse '$base_version' (origin/$base_branch) as a release version x.y.z"
120
+ fi
89
121
  next_version="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))"
90
122
 
91
- git diff --quiet || die 'working tree is not clean before version bump'
92
- git diff --cached --quiet || die 'index is not clean before version bump'
93
- repo_root="$(git rev-parse --show-toplevel)" || die 'cannot resolve the repository root'
123
+ current_version="$(read_version < "$version_file")" || die "cannot read a string version from $version_file"
124
+
125
+ if [[ "$current_version" == "$next_version" ]]; then
126
+ echo "version-bump: $source_branch already at $next_version (integration $base_version); nothing to bump"
127
+ echo '##vso[task.setvariable variable=bumped;isOutput=true]false'
128
+ exit 0
129
+ fi
130
+
94
131
  node -e '
95
132
  const fs = require("node:fs")
96
133
  const [file, version] = process.argv.slice(1)
@@ -105,7 +142,7 @@ node -e '
105
142
  # package.json can reach. No file, or no `pre_bump` key: no scripts, and
106
143
  # everything below this comment is a no-op, so the resulting commit is
107
144
  # byte-identical to the behaviour before this feature existed (see
108
- # packages/repo-controls/tests/version-bump-pre-bump.test.ts).
145
+ # packages/repo-controls/tests/version-bump-pr-source-pre-bump.test.ts).
109
146
  config_path="$repo_root/adl-config.toml"
110
147
  pre_bump_list=''
111
148
  if [[ -f "$config_path" ]]; then
@@ -189,6 +226,9 @@ fi
189
226
 
190
227
  git -C "$repo_root" add -A
191
228
  git diff --cached --quiet && die "version bump produced no staged change (expected at least $version_file)"
192
- git commit -m "chore: bump version $current_version to $next_version ***NO_CI***"
193
- git push origin "HEAD:$integration_branch"
194
- echo "Bumped $version_file from $current_version to $next_version on $integration_branch."
229
+ # No ***NO_CI***: a feature branch has no CI trigger, so this push is what
230
+ # re-queues the PR build against the bumped commit.
231
+ git commit -m "chore: bump version $current_version to $next_version"
232
+ git push origin "HEAD:refs/heads/$source_branch"
233
+ echo "version-bump: bumped $source_branch from $current_version to $next_version (integration $base_version); pushed — this run ends, the re-queued PR build validates the bumped commit"
234
+ echo '##vso[task.setvariable variable=bumped;isOutput=true]true'
@@ -168,9 +168,12 @@ export interface AzureDevOpsGovernanceOptions {
168
168
  }
169
169
 
170
170
  /**
171
- * One read-only receipt per promotion guard. Unreadable evidence is a failure,
172
- * never a warning: a commit cannot claim the remote guard is armed when that
173
- * fact could not be established.
171
+ * One read-only receipt per promotion guard. The only required guard on the
172
+ * target branch is the minimum-reviewer policy (Tarun, 2026-09-30): a build
173
+ * policy is NOT required, because the integration -> main promotion PR gets no
174
+ * build validation and CD runs after the merge. Unreadable evidence is a
175
+ * failure, never a warning: a commit cannot claim the remote guard is armed
176
+ * when that fact could not be established.
174
177
  */
175
178
  export function azureDevOpsGovernanceChecks(options: AzureDevOpsGovernanceOptions): AzureDevOpsGovernanceCheck[] {
176
179
  const checks: AzureDevOpsGovernanceCheck[] = []
@@ -231,18 +234,16 @@ export function azureDevOpsGovernanceChecks(options: AzureDevOpsGovernanceOption
231
234
  message: `missing blocking minimum-reviewer policy requiring one approval and resetOnSourcePush=true for ${prefix}`,
232
235
  },
233
236
  )
234
- checks.push(
235
- receipts.build
236
- ? {
237
- id: 'azure-devops.build-policy',
238
- status: 'ok',
239
- message: `blocking build policy #${id(receipts.build)} uses definition ${receipts.build.settings?.buildDefinitionId} for ${prefix}`,
240
- }
241
- : {
242
- id: 'azure-devops.build-policy',
243
- status: 'fail',
244
- message: `missing blocking build policy with a build definition for ${prefix}`,
245
- },
246
- )
237
+ // No build-policy requirement on `main` (Tarun, 2026-09-30): the promotion PR
238
+ // gets no build validation, so an absent build policy is not a failure. A
239
+ // present one is only recorded, never flagged here (the azdo repo doctor
240
+ // owns that).
241
+ if (receipts.build) {
242
+ checks.push({
243
+ id: 'azure-devops.build-policy',
244
+ status: 'ok',
245
+ message: `blocking build policy #${id(receipts.build)} uses definition ${receipts.build.settings?.buildDefinitionId} for ${prefix}`,
246
+ })
247
+ }
247
248
  return checks
248
249
  }
@@ -63,6 +63,8 @@ export const PROMOTE_REL = '.workflows/promote.ts'
63
63
  export const AUTO_COMPLETE_REL = '.workflows/scripts/enable-integration-auto-complete.mjs'
64
64
  /** The standalone immutable package-version allocator for integration CI. */
65
65
  export const VERSION_BUMP_REL = '.workflows/version-bump.sh'
66
+ /** The standalone CI stage-2 guard: a PR branch must contain integration's tip and be at its version + 1 patch. */
67
+ export const RECONCILE_GUARD_REL = '.workflows/reconcile-guard.sh'
66
68
 
67
69
  export const MANAGED_ASSETS = [
68
70
  { rel: PROMOTE_REL, source: join(PACKAGE_ROOT, 'assets', 'promote.ts'), mode: 0o755, doctorId: 'promote' },
@@ -78,6 +80,12 @@ export const MANAGED_ASSETS = [
78
80
  mode: 0o755,
79
81
  doctorId: 'version-bump',
80
82
  },
83
+ {
84
+ rel: RECONCILE_GUARD_REL,
85
+ source: join(PACKAGE_ROOT, 'assets', 'reconcile-guard.sh'),
86
+ mode: 0o755,
87
+ doctorId: 'reconcile-guard',
88
+ },
81
89
  ] as const
82
90
  /** Shared with adl hook ([hooks]/[rules]/[repo]) and adl-extensions ([skills]); this package owns only [controls]. */
83
91
  export const CONFIG_REL = 'adl-config.toml'
@@ -430,8 +430,9 @@ function dirtyPaths(porcelain: string): string[] {
430
430
  const hasOrigin = staged === 'R' || staged === 'C' || worktree === 'R' || worktree === 'C'
431
431
  const origin = hasOrigin ? records[++index] : undefined
432
432
  // An untracked path has no index entry, so it is necessarily omitted from
433
- // this commit. Keep it: centrally managed dot paths must be staged rather
434
- // than silently left behind.
433
+ // this commit. Keep it: other top-level dot paths must be staged rather than
434
+ // silently left behind, and a protected root (.workflows, .agents, .claude,
435
+ // .codex, .cursor, .kimi-code, .opencode) is refused outright (integrity.ts).
435
436
  if (staged === '?' && worktree === '?') {
436
437
  paths.push(record.slice(3))
437
438
  continue
@@ -9,7 +9,21 @@
9
9
 
10
10
  export const WIRING: readonly string[] = ['adl-config.toml']
11
11
 
12
- const FIXED_ROOTS: readonly string[] = ['compliance', 'workflows']
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
+ ]
13
27
 
14
28
  /** A declared pattern, as a regex. `*` stays in one directory; `**` crosses them. */
15
29
  function patternToRegExp(pattern: string): RegExp {