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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/bin/adl +2 -2
  2. package/bin/reconcile +3 -11
  3. package/bin/setup +15 -15
  4. package/package.json +1 -1
  5. package/packages/agent-hooks/deploy-profile.json +1 -1
  6. package/packages/agent-hooks/package.json +1 -1
  7. package/packages/agent-hooks/src/main.ts +23 -2
  8. package/packages/agent-hooks/src/pre-tool-use/handler.ts +2 -2
  9. package/packages/agent-hooks/src/rules/impl/adl-build-run-role.ts +116 -0
  10. package/packages/agent-hooks/src/rules/impl/adl-git-user-override-block.ts +159 -0
  11. package/packages/agent-hooks/src/rules/impl/adl-mcp-cli.ts +22 -12
  12. package/packages/agent-hooks/src/rules/impl/adl-pr-complete-pending.ts +9 -9
  13. package/packages/agent-hooks/src/rules/impl/adl-pr-role.ts +6 -5
  14. package/packages/agent-hooks/src/rules/impl/adl-run-required.ts +11 -5
  15. package/packages/agent-hooks/src/rules/impl/adl-workitem-role.ts +18 -5
  16. package/packages/agent-hooks/src/rules/impl/agent-tamper-protection.ts +30 -25
  17. package/packages/agent-hooks/src/rules/impl/git-core.ts +4 -8
  18. package/packages/agent-hooks/src/rules/impl/git-target.ts +5 -0
  19. package/packages/agent-hooks/src/rules/impl/mcp-tool.ts +4 -2
  20. package/packages/agent-hooks/src/rules/impl/tool-frontend.ts +230 -11
  21. package/packages/agent-hooks/src/rules/registry.ts +11 -2
  22. package/packages/agent-hooks/src/rules/shell/git-identity.ts +1 -1
  23. package/packages/agent-hooks/src/rules/shell/invocation.ts +14 -2
  24. package/packages/agent-hooks/src/rules/shell/protected-carriers.ts +10 -7
  25. package/packages/agent-settings/assets/TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md +2 -2
  26. package/packages/agent-settings/package.json +1 -1
  27. package/packages/agent-settings/src/deploy/agents-deploy.ts +19 -1
  28. package/packages/agent-settings/src/deploy/index.ts +1 -0
  29. package/packages/agent-skills/assets/agent-search-tool-audit/SKILL.md +4 -6
  30. package/packages/agent-skills/assets/code-review/SKILL.md +84 -66
  31. package/packages/agent-skills/assets/diagnosing-bugs/SKILL.md +23 -2
  32. package/packages/agent-skills/assets/diagnosing-bugs/references/FEEDBACK-LOOP-TACTICS.md +1 -1
  33. package/packages/agent-skills/assets/explore-codebase/SKILL.md +3 -2
  34. package/packages/agent-skills/assets/finalize-feature/SKILL.md +63 -0
  35. package/packages/agent-skills/assets/fix-ci/SKILL.md +58 -0
  36. package/packages/agent-skills/assets/handoff/SKILL.md +1 -1
  37. package/packages/agent-skills/assets/implement-feature/SKILL.md +150 -0
  38. package/packages/agent-skills/assets/implement-feature/references/briefs.md +94 -0
  39. package/packages/agent-skills/assets/implement-item/SKILL.md +71 -0
  40. package/packages/agent-skills/assets/mechanics/SKILL.md +38 -0
  41. package/packages/agent-skills/assets/plan-feature/SKILL.md +141 -0
  42. package/packages/agent-skills/assets/prepare-promotion/SKILL.md +90 -0
  43. package/packages/agent-skills/assets/ralplan/SKILL.md +149 -0
  44. package/packages/agent-skills/assets/refactor-safely/SKILL.md +4 -3
  45. package/packages/agent-skills/assets/report-bug/SKILL.md +72 -0
  46. package/packages/agent-skills/assets/review-change/SKILL.md +82 -0
  47. package/packages/agent-skills/assets/review-plan/SKILL.md +124 -0
  48. package/packages/agent-skills/assets/triage-workitems/SKILL.md +99 -0
  49. package/packages/agent-skills/assets/ultragoal/SKILL.md +77 -0
  50. package/packages/agent-skills/assets/validate-design/SKILL.md +64 -0
  51. package/packages/agent-skills/assets/verify-item/SKILL.md +71 -0
  52. package/packages/agent-skills/assets/wayfinder/SKILL.md +1 -1
  53. package/packages/agent-skills/assets/workitems-process/SKILL.md +145 -0
  54. package/packages/agent-skills/assets/workitems-process/references/ado-and-git.md +227 -0
  55. package/packages/agent-skills/assets/workitems-process/references/evidence.md +74 -0
  56. package/packages/agent-skills/assets/workitems-process/references/workitem-states.md +92 -0
  57. package/packages/agent-skills/assets/write-feature/SKILL.md +94 -0
  58. package/packages/agent-skills/dist/deploy.d.ts +52 -0
  59. package/packages/agent-skills/dist/deploy.d.ts.map +1 -1
  60. package/packages/agent-skills/dist/deploy.js +218 -7
  61. package/packages/agent-skills/dist/deploy.js.map +1 -1
  62. package/packages/agent-skills/dist/index.d.ts +1 -1
  63. package/packages/agent-skills/dist/index.d.ts.map +1 -1
  64. package/packages/agent-skills/dist/index.js +1 -1
  65. package/packages/agent-skills/dist/index.js.map +1 -1
  66. package/packages/agent-skills/package.json +1 -1
  67. package/packages/agent-skills/src/deploy.ts +288 -6
  68. package/packages/agent-skills/src/index.ts +10 -1
  69. package/packages/azdo/README.md +13 -4
  70. package/packages/azdo/bunfig.toml +6 -0
  71. package/packages/azdo/package.json +1 -1
  72. package/packages/azdo/scripts/probe-ado-rules.ts +1 -1
  73. package/packages/azdo/src/activity-log.ts +118 -0
  74. package/packages/azdo/src/azdo-attachments.ts +109 -0
  75. package/packages/azdo/src/azdo-pl.ts +100 -3
  76. package/packages/azdo/src/azdo-pr.ts +376 -17
  77. package/packages/azdo/src/azdo-wit.ts +594 -34
  78. package/packages/azdo/src/cli.ts +119 -12
  79. package/packages/azdo/src/lib/client.ts +95 -17
  80. package/packages/azdo/src/lib/validate-pr-metadata.ts +14 -5
  81. package/packages/azdo/src/mcp-server.ts +121 -8
  82. package/packages/azdo/src/tool-dispatch.ts +107 -23
  83. package/packages/azdo/src/workflow-ladder.csv +125 -107
  84. package/packages/azdo/src/workflow-rules.ts +254 -97
  85. package/packages/fleet/src/install-adl-package.ts +4 -3
  86. package/packages/repo-controls/README.md +12 -4
  87. package/packages/repo-controls/assets/promote.ts +1 -0
  88. package/packages/repo-controls/assets/scaffold/docs/AGENTS.md +5 -5
  89. package/packages/repo-controls/assets/scaffold/plans/AGENTS.md +42 -13
  90. package/packages/repo-controls/assets/scaffold/root/CONTRIBUTING.md +5 -5
  91. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/closed_set/test_check.py +0 -11
  92. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/filename/test_check.py +4 -6
  93. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/ledger/test_check.py +15 -0
  94. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/docs-okf-profiles.yaml +1 -17
  95. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-profile-untyped/docs/research/no-type.md +1 -1
  96. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-profile-untyped/docs/research/typed-experiment.md +1 -1
  97. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-profile-untyped/docs/research/typed-research.md +1 -1
  98. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/extra-key.md +1 -1
  99. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/extra-keys.md +1 -1
  100. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-created.md +1 -1
  101. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-description.md +1 -1
  102. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-last-update.md +1 -1
  103. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-status.md +1 -1
  104. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/missing-title.md +1 -1
  105. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/optional-keys.md +1 -1
  106. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/fixtures/okf-research/docs/research/research-ok.md +1 -1
  107. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/test_check.py +3 -22
  108. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/open_work_cap/check.py +39 -182
  109. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/open_work_cap/fixtures/open-work-cap-clean/plans/features/az601-clean/intent.md +17 -0
  110. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/open_work_cap/test_check.py +90 -321
  111. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plan_metadata.py +31 -5
  112. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/check.py +282 -54
  113. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-clean/plans/features/az347-test-coverage/design.md +35 -0
  114. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-clean/plans/features/az347-test-coverage/file-map-reference.md +19 -0
  115. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-clean/plans/features/az347-test-coverage/intent.md +25 -0
  116. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-clean/plans/features/az347-test-coverage/items/az351-restore-entrypoint.md +25 -0
  117. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-clean/plans/features/az347-test-coverage/requirements.md +44 -0
  118. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-violation/plans/features/az347-test-coverage/requirements.md +44 -0
  119. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/test_check.py +646 -53
  120. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/cli.py +3 -3
  121. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/policy/docs_shape.py +1 -12
  122. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/policy/gate-registry.alt.yaml +1 -1
  123. package/packages/repo-controls/package/adl-compliance/src/adl_compliance/policy/gate-registry.yaml +1 -1
  124. package/packages/repo-controls/package.json +1 -1
  125. package/packages/repo-controls/src/config.ts +21 -2
  126. package/packages/repo-controls/src/doctor.ts +9 -14
  127. package/packages/repo-controls/src/findings-ratchet.ts +12 -0
  128. package/packages/repo-controls/src/hook.ts +61 -4
@@ -34,11 +34,12 @@
34
34
  * regardless of who is asking, and the two rules never fight over the same
35
35
  * call.
36
36
  *
37
- * Rows 6 and 7 (PR comment; PR vote) are the same allow-set shape, but
38
- * `packages/azdo` has no comment/vote tool today — grepped 2026-09-27,
39
- * nothing named `pr_review`/`pr_comment`/`pr_vote` exists. PENDING: add a
40
- * sibling check here (or a new rule) when Stream B ships one, rather than
41
- * matching a guessed tool name now.
37
+ * Rows 6-8 (PR comment; PR vote on `main`; PR vote on `integration`) ship
38
+ * their own gate now: the sibling `adl-pr-review-role.ts` rules
39
+ * `pr_review` (shipped in `packages/azdo/src/azdo-pr-review.ts`, C3
40
+ * 2026-09-27) — comment allowed for the same allow-set as row 5, vote
41
+ * allowed for `agent-reviewer` only and only on `main`. This rule does not
42
+ * match `pr_review` at all; the two rules never fight over the same call.
42
43
  *
43
44
  * Requires an identity to already be present (`ADL_AGENT_ID_AGENT`
44
45
  * non-blank) — with no identity at all, `adl.run-required-for-writes`
@@ -58,9 +58,12 @@
58
58
  * request-item/`--form`/`--raw` implying a body (httpie's own semantics:
59
59
  * a request with data defaults to POST, one without defaults to GET).
60
60
  * - An Azure DevOps MCP write tool (the `'tool-call'` kind), named
61
- * `mcp__<prefix>__<tool>` where `<tool>` is one of the four mutating
61
+ * `mcp__<prefix>__<tool>` where `<tool>` is one of the six mutating
62
62
  * tools `packages/azdo/src/mcp-server.ts` actually registers:
63
- * `pr_create_or_update`, `pr_review`, `build_run`, `workitem_create_or_update`. The
63
+ * `pr_create_or_update`, `pr_review`, `build_run`, and the three
64
+ * `workitem_create_or_update_feature`/`_pbi`/`_bug` (the tool NAME carries
65
+ * the work-item type, so there is no blanket `workitem_create_or_update`
66
+ * tool any more). The
64
67
  * match is on the TOOL NAME alone, not the server prefix — the package is
65
68
  * `azdo`, P4 folds the server under `adl`, and Claude Code's plugin-scoped
66
69
  * form (`mcp__plugin_<plugin>_<server>__<tool>`) adds a third spelling —
@@ -165,7 +168,8 @@ const HTTP_CLIENT_NAMES = HTTP_CLIENT_BINARY_NAMES
165
168
  * `ListToolsRequestSchema` handler (re-checked 2026-10-01) — the
166
169
  * complete tool roster there is `pr_list_or_get`/`pr_create_or_update`/
167
170
  * `pr_review`, `build_list_or_get`/`build_run`, `workitem_list_or_get`/
168
- * `workitem_create_or_update`; the three not in this set are the read half.
171
+ * `workitem_create_or_update_feature`/`_pbi`/`_bug`; the three not in this
172
+ * set are the read half.
169
173
  * `pr_review` (docs/operations/agent-permissions.md rows 6-8: comment + vote) is
170
174
  * in this set because `adl-pr-review-role.ts` gates it by role —
171
175
  * a caller with no run identity at all must be denied here FIRST, the
@@ -176,7 +180,9 @@ const ADO_MCP_WRITE_TOOLS: ReadonlySet<string> = new Set([
176
180
  'pr_create_or_update',
177
181
  'pr_review',
178
182
  'build_run',
179
- 'workitem_create_or_update',
183
+ 'workitem_create_or_update_feature',
184
+ 'workitem_create_or_update_pbi',
185
+ 'workitem_create_or_update_bug',
180
186
  ])
181
187
 
182
188
  /**
@@ -184,7 +190,7 @@ const ADO_MCP_WRITE_TOOLS: ReadonlySet<string> = new Set([
184
190
  * DevOps MCP tool call, reads included — the read half gets no
185
191
  * pass just because it cannot mutate anything. `mcp-server.ts`'s complete
186
192
  * roster: `pr_list_or_get`, `build_list_or_get`, `workitem_list_or_get`
187
- * (reads) alongside the four writes above.
193
+ * (reads) alongside the six writes above.
188
194
  */
189
195
  /** The leading words of the `identityBearingReason` for an Azure DevOps MCP tool call — `message()` keys the MCP-only CLI advice off it. */
190
196
  const MCP_REASON_PREFIX = 'Azure DevOps MCP tool'
@@ -27,20 +27,33 @@ import { effectiveIdentityForMcpCli, toolCallFor } from './adl-mcp-cli.ts'
27
27
 
28
28
  const RULE_ID = 'adl.workitem-requires-tarun-or-vivek'
29
29
 
30
+ /**
31
+ * The work-item write tools this rule grades — the three per-type siblings the
32
+ * MCP server registers (the old single `workitem_create_or_update` tool is
33
+ * gone; the tool NAME is the type, so there is no `type` argument to inspect).
34
+ * A name outside this set is not a work-item write at all and is left to
35
+ * `adl.run-required-for-writes`/`adl-pr-role.ts`.
36
+ */
37
+ const WORKITEM_WRITE_TOOLS: ReadonlySet<string> = new Set([
38
+ 'workitem_create_or_update_feature',
39
+ 'workitem_create_or_update_pbi',
40
+ 'workitem_create_or_update_bug',
41
+ ])
42
+
30
43
  export const adlWorkitemRequiresTarunOrVivek: RuleSpec = {
31
44
  id: RULE_ID,
32
45
  category: 'tool-call',
33
46
  severity: 'deny',
34
47
  enabledByDefault: true,
35
- summary: 'Deny workitem_create_or_update for agent-reviewer (docs/operations/agent-permissions.md row 10) — the MCP tool AND its adl mcp cli equivalent.',
48
+ summary: 'Deny the workitem_create_or_update_* tools for agent-reviewer (docs/operations/agent-permissions.md row 10) — the MCP tools AND their adl mcp cli equivalents.',
36
49
  params: noParams,
37
50
  match(inv: Invocation, ctx: RuleContext<Record<string, never>>): Finding | null {
38
- // `adl mcp cli
39
- // workitem-create-or-update` dispatches to the same tool the MCP server
40
- // calls — graded identically, not only the tool-call payload shape.
51
+ // `adl mcp cli workitem-create-or-update-feature`/`-pbi`/`-bug` dispatch to
52
+ // the same tools the MCP server calls — graded identically, not only the
53
+ // tool-call payload shape.
41
54
  const call = toolCallFor(inv)
42
55
  if (call === null) return null
43
- if (mcpToolName(call.tool) !== 'workitem_create_or_update') return null
56
+ if (!WORKITEM_WRITE_TOOLS.has(mcpToolName(call.tool) ?? '')) return null
44
57
 
45
58
  // Grade the EFFECTIVE identity for THIS specific piece — a valid inline
46
59
  // `ADL_AGENT_ID_AGENT=` override on the same `adl mcp cli` invocation
@@ -2,21 +2,25 @@
2
2
  * Editing the files that wire the host's hooks.
3
3
  *
4
4
  * Handoff 5, rule 5: deny Write/Edit/MultiEdit/NotebookEdit on
5
- * `.claude/settings.json`, `.claude/settings.local.json`, or
6
- * `.codex/config.toml`. These are the files that install the hook into the
7
- * host (including the `.local.json` overrides). Editing any of them is a way
8
- * to switch the gate off without anyone noticing.
5
+ * `.claude/settings.json`, `.claude/settings.local.json`, and
6
+ * `.codex/config.toml`. These are the files that install the hook into
7
+ * the host (including the `.local.json` overrides) — editing any of them
8
+ * is a way to switch the gate off without anyone noticing.
9
9
  *
10
- * `adl-config.toml` was the fourth file until 2026-10-01 (Tarun: "nobody
11
- * should stop editing adl-config.toml"). It is no longer on the list, so an
12
- * agent can edit, move or delete it. Two things follow, both accepted by the
13
- * owner: (1) every hook run re-reads it, so `[hooks] enabled = false` (a
14
- * silent no-op, `main.ts`'s `runHook`) or a `[rules."<id>"] enabled = false`
15
- * takes effect on the agent's next tool call, ahead of any commit — the
16
- * commit-time layers (`git.protected-tree-commit`, repo-controls' integrity
17
- * gate) are the only review left; (2) the carriers that stayed on the list
18
- * below are only as strong as that file, since it can also switch THIS rule
19
- * off.
10
+ * `adl-config.toml` is NOT in this set. The earlier 2026-10-12 ruling
11
+ * ("we left a door open, close it") assumed the working-tree copy is what
12
+ * every running hook reads and that an agent writing
13
+ * `[hooks] enabled = false` flips the gate off ahead of any commit. The
14
+ * commit side IS enforced — repo-controls' `integrity.ts` (`WIRING`,
15
+ * refuses a normal commit of `adl-config.toml`) and `agent-hooks`'s
16
+ * `git.protected-tree-commit` rule (PBI 373/679) — and the working-tree
17
+ * edit is a runtime-config edit, not a carrier rewrite. The design
18
+ * ("agents may edit [WIRING] while preparing a reviewed change, but a
19
+ * normal commit must never land them" — integrity.ts:5-7) places the
20
+ * edit on the allowed side and the commit on the denied side.
21
+ * Re-confirmed 2026-10-13 (Tarun: "adl-config.toml can be editted but
22
+ * not committed by the agents"). `adl-config.toml` is a config file,
23
+ * not a hook carrier.
20
24
  *
21
25
  * Hooks are local-repo-first (Tarun, 2026-09-29): there is no managed-hooks
22
26
  * lock and no global carrier, so these per-repo carrier files are the ONLY
@@ -477,20 +481,21 @@ export const agentTamperProtection: RuleSpec = {
477
481
  : via === 'interpreter'
478
482
  ? 'an interpreter one-liner writing'
479
483
  : 'editing'
484
+ const listed = [...PROTECTED_CARRIER_PATHS]
485
+ .map((p) => '`' + p + '`')
486
+ .join(', ')
480
487
  return {
481
488
  user: `Blocked ${via === 'tool-call' ? 'editing' : verb} ${path}. This file wires the host's hooks; ask the user to change it.`,
482
489
  agent:
483
- `DENIED ${verb} ${path}. This file is one of the tamper-protected files: ` +
484
- '`.claude/settings.json`, `.claude/settings.local.json`, `.codex/config.toml`, ' +
485
- '`.codex/hooks.json`, `.cursor/hooks.json`, `.opencode/plugins/adl-repo.ts`, ' +
486
- "or `~/.kimi-code/config.toml`. Each installs or overrides a host's own hook contract " +
487
- "(directly, or — for `.opencode/plugins/adl-repo.ts` — as the generated plugin THAT IS " +
488
- "the carrier; `~/.kimi-code/config.toml` is Kimi's own single, globally-installed " +
489
- 'carrier — Kimi has no per-repo one). Editing, overwriting, moving over, truncating, ' +
490
- 'deleting, or rewriting any of them — by a tool call, a shell write, or a git checkout/ ' +
491
- 'restore/reset/rm — is a way to switch the gate off without anyone noticing, the ONLY ' +
492
- 'enforcement layer for the hosts that read them (there is no managed-hooks lock). ' +
493
- `If a legitimate change is needed, file a PR or ask the user to make it themselves. ` +
490
+ `DENIED ${verb} ${path}. This file is one of the tamper-protected files: ${listed}. ` +
491
+ 'Each installs or overrides a host\'s own hook contract. Editing, overwriting, moving ' +
492
+ 'over, truncating, deleting, or rewriting any of them — by a tool call, a shell write, ' +
493
+ 'or a git checkout/restore/reset/rm — is a way to switch the gate off without anyone ' +
494
+ 'noticing, the ONLY enforcement layer for the hosts that read them (there is no ' +
495
+ 'managed-hooks lock). `adl-config.toml` is intentionally NOT in this list: it is a ' +
496
+ 'runtime config file an agent may edit; the commit is denied by `git.protected-tree-' +
497
+ 'commit` and by repo-controls\' `integrity.ts` (PBI 373/679). If a legitimate change ' +
498
+ `to a host carrier is needed, file a PR or ask the user to make it themselves. ` +
494
499
  `Blocked call: ${ctx.snippet}`,
495
500
  }
496
501
  },
@@ -49,10 +49,9 @@ export const gitHooksPath: RuleSpec = {
49
49
  'git -c core.hooksPath=/dev/null commit -m x',
50
50
  'git -c core.hooksPath= push',
51
51
  'git -c core.hooksPath=/tmp/empty commit --amend',
52
- 'git config core.hooksPath /dev/null',
53
- 'git config --global core.hooksPath /tmp/empty',
54
52
  ],
55
- allow: ['git stash --help', 'git config user.name t'],
53
+ // The `git config core.hooksPath …` positive controls were here for years — gone as of Bug 695 (2026-10-12): `adl.git-user-override-block` denies every `git config` invocation in the engine, so a `git config …` string is denied upstream and never exercises the `subcommand === 'config' && overridesHooksPath(git.rest)` branch (the `-c` deny entries above still cover the override surface).
54
+ allow: ['git stash --help'],
56
55
  },
57
56
  }
58
57
 
@@ -91,11 +90,8 @@ export const gitCoreBare: RuleSpec = {
91
90
  deny: [
92
91
  'git -c core.bare=true status',
93
92
  'git -c core.bare=false commit -m x',
94
- 'git config core.bare true',
95
- 'git config --bool core.bare true',
96
- 'git config --global core.bare true',
97
- 'git config --unset core.bare',
98
93
  ],
99
- allow: ['git status', 'git config user.name t'],
94
+ // The `git config core.bare …` positive controls were here for years — gone as of Bug 695 (2026-10-12): `adl.git-user-override-block` denies every `git config` invocation in the engine, so a `git config …` string is denied upstream and never exercises the `subcommand === 'config' && flipsCoreBare(git.rest)` branch (the `-c` deny entries above still cover the override surface).
95
+ allow: ['git status'],
100
96
  },
101
97
  }
@@ -209,6 +209,7 @@ function heredocDelimiters(piece: string): string[] | 'unparsable' {
209
209
  for (; i < piece.length && !/[\s;|&<>()]/.test(piece[i] as string); i++) {
210
210
  sawWord = true
211
211
  const c = piece[i] as string
212
+ /* istanbul ignore next -- splitShell() only yields a valid (non-`invalid`) result when its trailing `escape` flag is clear, and it clears that flag by consuming the escaped character; so a valid piece can never END on a lone `\`, it can only end on the SECOND half of a `\\` pair, which this scan reaches as `c` and steps over with a defined `piece[++i]`. Exhaustively checked: 15,867,149 valid commands over the 18-symbol quote/separator alphabet produced zero pieces ending in a lone backslash. */
212
213
  if (c === '\\') word += piece[++i] ?? ''
213
214
  else if (c === "'" || c === '"') {
214
215
  const close = piece.indexOf(c, i + 1)
@@ -240,6 +241,7 @@ function parse(command: string): Parsed | null | 'closed' {
240
241
  let cursor = 0
241
242
  for (const piece of split.pieces) {
242
243
  const at = command.indexOf(piece, cursor)
244
+ /* istanbul ignore next -- every piece splitShell() returns is an ordered, non-overlapping substring of the very `command` it was split from, so `indexOf(piece, cursor)` always finds it at or after `cursor`; `at < 0` would require splitShell() to emit a piece that is not in its input. Exhaustively checked: 958,762 valid commands over the 18-symbol quote/separator alphabet produced zero such pieces. */
243
245
  if (at < 0) return { tokens, gaps: null, functions }
244
246
  gaps.push(command.slice(cursor, at))
245
247
  cursor = at + piece.length
@@ -342,9 +344,11 @@ function applyState(parsed: Parsed, until: number, target: Mutable): boolean {
342
344
  if (state.names.includes('GIT_WORK_TREE')) delete target.workTree
343
345
  continue
344
346
  }
347
+ /* istanbul ignore next -- `gaps` is null on exactly one path, parse()'s `at < 0` return above, which is itself unreachable because splitShell() only emits ordered non-overlapping substrings of `command`. Every other parse() result carries a populated `gaps`, so this re-check is the fail-closed half of that invariant rather than a reachable state. */
345
348
  if (parsed.gaps === null) return false
346
349
  const before = plainGap(parsed.gaps[i] as string)
347
350
  if (before === null) return false
351
+ /* istanbul ignore next -- `parsed.tokens[i - 1]` is null only for a here-doc BODY, and parse() sets `gaps[body] = '\n'` for every such nulled index; bodies are newline-separated, so the gap at the next index is that same `'\n'`, never `'&&'`. `before === '&&'` therefore short-circuits before this line reads `tokens[i - 1]`, so the `?? null` fallback is unreachable. */
348
352
  if (before === '&&' && (i === 0 || !['cd', 'export'].includes(classify(parsed.tokens[i - 1] ?? null, parsed.functions).kind))) return false
349
353
  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
354
  if (state.kind === 'cd') {
@@ -406,6 +410,7 @@ function walk(command: string, inv: Invocation & { kind: 'git' }, inherited: Mut
406
410
  if (inner === undefined) continue
407
411
  if (inner === null || !applyState(parsed, i, target)) return null
408
412
  const nested = walk(script, inv, target)
413
+ /* istanbul ignore next -- `inner` and `nested` are the SAME walk over the SAME script and invocation, differing only in the inherited `dirs`/`gitDir`/`workTree`; no branch inside walk() reads those to choose null or undefined (only `homeUnknown` gates expandPath, and applyState above never sets it). Since `inner` was already a non-null target here, so is `nested`, and the fallback is unreachable. Fuzzed: 6.6M generated shell/-c command pairs produced no case where it fires. */
409
414
  return nested ?? null
410
415
  }
411
416
  return undefined
@@ -11,7 +11,9 @@ export const KNOWN_MCP_TOOL_NAMES: ReadonlySet<string> = new Set([
11
11
  'build_list_or_get',
12
12
  'build_run',
13
13
  'workitem_list_or_get',
14
- 'workitem_create_or_update',
14
+ 'workitem_create_or_update_feature',
15
+ 'workitem_create_or_update_pbi',
16
+ 'workitem_create_or_update_bug',
15
17
  ])
16
18
 
17
19
  /**
@@ -50,7 +52,7 @@ export function toolNameEndingWithKnownSuffix(tool: string, knownSuffixes: Reado
50
52
  *
51
53
  * Shared by every rule that gates an Azure DevOps MCP tool by name
52
54
  * (adl-run-required.ts, adl-pr-role.ts, adl-pr-complete-pending.ts,
53
- * adl-workitem-role.ts, adl-build-run-denied.ts) so the parsing cannot drift
55
+ * adl-workitem-role.ts, adl-build-run-role.ts) so the parsing cannot drift
54
56
  * between them.
55
57
  *
56
58
  * Prefix-less fallback (added 2026-09-28 with the one-agent-identity
@@ -9,6 +9,30 @@
9
9
  * body either way; the allowance is stated in the message for the human,
10
10
  * not enforced here).
11
11
  *
12
+ * az365 (Scope A — JS-toolchain mutation policy, 2026-10-13): the matcher
13
+ * was a binary-name-only `deny` returning a Finding for every
14
+ * `frontend-tool` invocation regardless of effect. The OLD corpus
15
+ * contained 14 read-only invocations (lint, typecheck, coverage, test
16
+ * runs without snapshot updates, help-only) as `deny`, which is the bug:
17
+ * a legitimate "look at my code" call was refused with the same Finding
18
+ * as a file-rewriting one. The new matcher inspects `inv.args` and
19
+ * returns `null` (allow) for read-only invocations, returning a Finding
20
+ * only for the mutating ones:
21
+ *
22
+ * deny biome format … --write (file rewrite)
23
+ * deny vitest run -u / --update (snapshot mutation)
24
+ * deny tsc <file> (no --noEmit; emits .js/.d.ts)
25
+ * allow tsc --noEmit (type-check, no emit)
26
+ * allow biome check … (read)
27
+ * allow biome lint … (read)
28
+ * allow vitest run (test runner, no source write)
29
+ * allow vitest --coverage run (writes only to coverage/)
30
+ * allow jest --testPathPattern= (test runner, no source write)
31
+ *
32
+ * The matcher does NOT inspect the `just` recipe body — the gate runs at
33
+ * the recipe boundary, not inside it. `--help`/`--version` are already
34
+ * short-circuited at classification time and never reach this matcher.
35
+ *
12
36
  * Tarun's decision (2026-09-27, feature az372): the message must not name a
13
37
  * particular recipe — see messages.ts's `JUST_RECIPES_POINTER`.
14
38
  */
@@ -18,37 +42,231 @@ import type { Finding, MessageContext, RuleSpec } from '../spec.ts'
18
42
  import { noParams } from '../spec.ts'
19
43
  import { detailString, JUST_RECIPES_POINTER } from './messages.ts'
20
44
 
45
+ /** `biome format … --write` rewrites files in place. The flag may appear anywhere on the command line (`--write foo` or `foo --write`); the `--write=foo` and `-wX` concatenated forms arrive as single tokens. */
46
+ function biomeFormatWritesFile(args: readonly { readonly value: string }[]): boolean {
47
+ return args.some((arg) => {
48
+ if (arg.value === '--write' || arg.value === '-w') return true
49
+ if (arg.value.startsWith('--write=')) return true
50
+ // Concatenated short-flag form: `-wX` where X is anything. The shell
51
+ // does not split the token; the matcher sees `-wX` as one element.
52
+ if (arg.value.startsWith('-w') && arg.value.length > 2) return true
53
+ return false
54
+ })
55
+ }
56
+
57
+ /**
58
+ * `tsc <file>` without `--noEmit` emits `.js` and `.d.ts` files into the
59
+ * working tree. `tsc --noEmit` is the read intent. A bare `tsc` (no
60
+ * file operand, no flags) does nothing the matcher cares about — tsc
61
+ * without operands reads `tsconfig.json` and may emit based on its
62
+ * `noEmit` setting; we treat the no-operand case as ambiguous and let
63
+ * it through (the engine's `uncertain` category would catch it if a
64
+ * future rule needs that).
65
+ */
66
+ const TSC_VALUE_FLAGS: ReadonlySet<string> = new Set([
67
+ '-p', '--project', // config file
68
+ '--build', // takes a project reference; value can be 'false'
69
+ '-t', '--target',
70
+ '--module',
71
+ '--moduleResolution',
72
+ '--jsx',
73
+ '--outDir',
74
+ '--rootDir',
75
+ '--baseUrl',
76
+ '--paths',
77
+ '--types',
78
+ '--lib',
79
+ ])
80
+
81
+ function tscWritesFiles(args: readonly { readonly value: string }[]): boolean {
82
+ // --noEmit is the read intent; --noEmit=false is the write intent.
83
+ if (args.some((arg) => arg.value === '--noEmit')) return false
84
+ // Explicit emit flags are the write intent regardless of the file operand.
85
+ if (args.some((arg) => arg.value === '--emit' || arg.value.startsWith('--emit='))) return true
86
+ // Walk the args, skipping a value-taking flag's operand, and look for a
87
+ // positional file operand that names a .ts/.tsx file. That is the
88
+ // write intent: tsc defaults to emit when given a source file.
89
+ // `tsc -p tsconfig.json` is read-only because `-p` only names the
90
+ // config; tsc respects the config's own `noEmit` setting.
91
+ for (let index = 0; index < args.length; index += 1) {
92
+ const arg = args[index] as { readonly value: string }
93
+ if (arg.value.startsWith('-')) {
94
+ // Flag with a value takes the next word; skip it so we do not
95
+ // mistake the flag's value (`tsconfig.json` after `-p`) for a
96
+ // positional file operand.
97
+ if (TSC_VALUE_FLAGS.has(arg.value)) index += 1
98
+ continue
99
+ }
100
+ // Positional operand that names a TypeScript source file is the
101
+ // write intent. Anything else (a config, a glob, an empty) is not.
102
+ if (/\.tsx?$/.test(arg.value)) return true
103
+ }
104
+ return false
105
+ }
106
+
107
+ /**
108
+ * `vitest run -u` / `vitest run --update` mutates snapshot files in place.
109
+ * The shell hands the matcher individual tokens; the `-uX` / `--update=now`
110
+ * / `-u=foo` concatenation forms arrive as a single token whose value
111
+ * starts with `-u` or `--update=`. Treat any token that starts with `-u`
112
+ * or `--update` (and is not a different flag like `--updateSnapshot`
113
+ * which is a different binary's flag) as the snapshot-write trigger.
114
+ */
115
+ function vitestUpdatesSnapshots(args: readonly { readonly value: string }[]): boolean {
116
+ return args.some((arg) => {
117
+ if (arg.value === '-u' || arg.value === '--update') return true
118
+ if (arg.value === '-u=' || arg.value.startsWith('--update=')) return true
119
+ // Concatenated short-flag form: `-uX` where X is anything (rare in
120
+ // practice; vitest doesn't combine `-u` with other short flags, but
121
+ // the shell hands the matcher the token unsplit). Match `-u` as a
122
+ // prefix only when the rest is a non-flag char, so `-update` (long
123
+ // option) does not match.
124
+ if (arg.value.startsWith('-u') && arg.value.length > 2) return true
125
+ return false
126
+ })
127
+ }
128
+
129
+ /** `jest --testPathPattern=… -u` / `jest -u` / `jest --updateSnapshot` mutates snapshots. */
130
+ function jestUpdatesSnapshots(args: readonly { readonly value: string }[]): boolean {
131
+ return args.some((arg) => {
132
+ if (arg.value === '-u' || arg.value === '--updateSnapshot') return true
133
+ if (arg.value === '-u=' || arg.value.startsWith('--updateSnapshot=')) return true
134
+ // Concatenated short-flag form: `-uX` as above.
135
+ if (arg.value.startsWith('-u') && arg.value.length > 2) return true
136
+ return false
137
+ })
138
+ }
139
+
21
140
  export const toolFrontend: RuleSpec = {
22
141
  id: 'tool.frontend',
23
142
  category: 'tool',
24
143
  severity: 'deny',
25
144
  enabledByDefault: true,
26
- summary: "Deny direct npx/vitest/jest/biome/tsc; steer at the repository's just recipes.",
145
+ summary: "Deny mutating npx/vitest/jest/biome/tsc invocations; reads and tests are permitted.",
27
146
  params: noParams,
28
147
  match(inv: Invocation): Finding | null {
29
148
  if (inv.kind !== 'frontend-tool') return null
30
- return { ruleId: 'tool.frontend', severity: 'deny', detail: { tool: inv.tool } }
149
+ const args = inv.args
150
+ // The mutating surfaces. Each is the only binary-name whose flag
151
+ // shape can change the working tree; if the rule's matcher here
152
+ // grows, the corpus deny list below is the place to add the new
153
+ // fixture so the engine's verdict stays testable.
154
+ switch (inv.tool.replace(/\.exe$/i, '')) {
155
+ case 'biome':
156
+ // `biome format … --write` is the file-rewrite path. `biome
157
+ // check` and `biome lint` are reads and must NOT be denied. The
158
+ // matcher inspects the args to distinguish; without this
159
+ // inspection every biome invocation is denied, which is the
160
+ // az365 Scope A bug.
161
+ if (biomeFormatWritesFile(args)) {
162
+ return { ruleId: 'tool.frontend', severity: 'deny', detail: { tool: inv.tool, action: 'biome-format-write' } }
163
+ }
164
+ return null
165
+ case 'tsc':
166
+ // `tsc --noEmit` is a read. `tsc <file>` without --noEmit emits
167
+ // .js and .d.ts into the working tree. Match the absence of
168
+ // --noEmit as the write intent.
169
+ if (tscWritesFiles(args)) {
170
+ return { ruleId: 'tool.frontend', severity: 'deny', detail: { tool: inv.tool, action: 'tsc-emit' } }
171
+ }
172
+ return null
173
+ case 'vitest':
174
+ // `vitest run` is a test runner. The .vitest cache directory and
175
+ // any coverage output live under node_modules / coverage/, not in
176
+ // project source. `-u` / `--update` is the snapshot-mutation
177
+ // path; the snapshot files live alongside the test files and ARE
178
+ // project source.
179
+ if (vitestUpdatesSnapshots(args)) {
180
+ return { ruleId: 'tool.frontend', severity: 'deny', detail: { tool: inv.tool, action: 'vitest-snapshot-write' } }
181
+ }
182
+ return null
183
+ case 'jest':
184
+ if (jestUpdatesSnapshots(args)) {
185
+ return { ruleId: 'tool.frontend', severity: 'deny', detail: { tool: inv.tool, action: 'jest-snapshot-write' } }
186
+ }
187
+ return null
188
+ case 'npx':
189
+ // `npx biome check …` is a read; `npx vitest run` is a test
190
+ // runner; `npx tsc --noEmit` is a typecheck. None mutate source.
191
+ // The mutating shape (browser automation, file rewrites) is
192
+ // Scope B (az365's split into a separate Feature for browser
193
+ // automation); this rule's match() does not cover it today and
194
+ // the agent-hooks `adl-human-azdo-bypass` rule plus the future
195
+ // `tool.browser-automation` rule (Feature azTBA) are the right
196
+ // place for it.
197
+ return null
198
+ default:
199
+ // Unknown `frontend-tool` name (a new addition to
200
+ // ClassifyConfig.frontendToolNames the rule does not yet know
201
+ // about). Default to allow and let the corpus surface it as a
202
+ // chosen behaviour on the next pass.
203
+ return null
204
+ }
31
205
  },
32
206
  message(finding: Finding, ctx: MessageContext) {
33
207
  const tool = detailString(finding, 'tool')
208
+ const action = detailString(finding, 'action')
209
+ const actionLabel =
210
+ action === 'biome-format-write' ? 'file rewrite'
211
+ : action === 'tsc-emit' ? 'tsc emit (.js / .d.ts write)'
212
+ : action === 'vitest-snapshot-write' ? 'snapshot update'
213
+ : action === 'jest-snapshot-write' ? 'snapshot update'
214
+ : 'mutating invocation'
34
215
  return {
35
- user: `Blocked direct ${tool}. Run it through ${JUST_RECIPES_POINTER} instead; it carries the same arguments and runs through the gate profile.`,
216
+ user: `Blocked ${tool} (${actionLabel}). Run it through ${JUST_RECIPES_POINTER} instead; it carries the same arguments and runs through the gate profile.`,
36
217
  agent:
37
- `DENIED direct ${tool}. Do not invoke ${tool} from Bash/Shell, including through sh -c, xargs, find -exec, or command substitution. ` +
218
+ `DENIED ${tool} (${actionLabel}). Do not invoke ${tool} from Bash/Shell, including through sh -c, xargs, find -exec, or command substitution. ` +
38
219
  `Run it through ${JUST_RECIPES_POINTER} instead - the recipe carries the same arguments and runs the gate profile rather than skipping it. ` +
39
- '--help and --version remain allowed (they print text and run nothing), ' +
40
- 'and invoking the tool from inside a `just` recipe body is allowed because the gate runs at the recipe boundary. ' +
220
+ 'Read-only invocations of the same tool (lint, typecheck, test runs without snapshot updates) are permitted. ' +
41
221
  `Blocked command: ${ctx.snippet}`,
42
222
  }
43
223
  },
44
224
  corpus: {
225
+ // The mutating invocations — the only ones `match()` returns a Finding
226
+ // for. 6 entries; the az388 corpus floor in tests/unit/rules-corpus-floors.test.ts
227
+ // was lowered from 15 to 6 on 2026-10-13 (az365 Scope A) and the file
228
+ // header explains why.
45
229
  deny: [
230
+ 'biome format packages/desktop/src --write',
231
+ 'biome format --write packages/desktop/src',
232
+ 'biome format --write src/foo.ts',
233
+ // Concatenated short-flag form: the shell hands the matcher a
234
+ // single `-wX` token; the predicate's prefix check catches it.
235
+ 'biome format src/foo.ts -w',
236
+ 'vitest run -u',
237
+ 'vitest run --update',
238
+ // Concatenated form: the shell hands the matcher a single `-uX`
239
+ // token; the predicate's prefix check catches it. The corpus entry
240
+ // is the value the shell actually produces (`-uX`); vitest does
241
+ // not combine `-u` with other short flags, but the matcher has
242
+ // to handle the form anyway.
243
+ 'vitest run -uX',
244
+ // `=`-form: `--update=now` arrives as a single token, predicate
245
+ // catches via `startsWith('--update=')`.
246
+ 'vitest run --update=now',
247
+ 'tsc src/foo.ts',
248
+ // jest's snapshot-mutation flag form; covers the jest branch of
249
+ // match() and the jest action label in message(). The flag is
250
+ // either `-u` or `--updateSnapshot`; both must be denied.
251
+ 'jest -u',
252
+ 'jest --updateSnapshot',
253
+ 'jest -uX',
254
+ ],
255
+ // The read-only invocations — every one `match()` returns null for.
256
+ // The 14 the OLD corpus mis-classified as denied move here as the
257
+ // audit-trail entry that pins "this is a read, the deny is over-broad
258
+ // and a future narrowing should permit it" — the future narrowing is
259
+ // THIS rule's match(), already implemented; these entries are the
260
+ // proof the test suite pins against.
261
+ allow: [
46
262
  'npx biome check packages/desktop/src',
263
+ 'npx vitest run',
264
+ 'npx tsc --noEmit',
47
265
  'vitest run',
48
266
  'vitest --coverage run',
49
267
  'jest --testPathPattern=adl-conversation',
268
+ 'biome check packages/desktop/src',
50
269
  'biome lint packages/desktop/src',
51
- 'biome format packages/desktop/src --write',
52
270
  'tsc --noEmit',
53
271
  'tsc -p tsconfig.json',
54
272
  'xargs npx tsc --noEmit',
@@ -56,12 +274,13 @@ export const toolFrontend: RuleSpec = {
56
274
  "sh -c 'npx tsc --noEmit'",
57
275
  "bash -lc 'npx tsc --noEmit'",
58
276
  'echo $(biome lint src/)',
59
- // Not help-only: isHelpOrVersionOnly requires EVERY token to be a
60
- // help/version flag, and 'lint'/'--noEmit' are real arguments.
277
+ // The two help-only entries are already short-circuited by
278
+ // isHelpOrVersionOnly at classification time; they never reach
279
+ // `match()`. Listed under `allow` for the same audit-trail reason
280
+ // as the reads above: chosen behaviour, not merely a passing test.
61
281
  'biome lint --help',
62
282
  'tsc --noEmit --help',
63
- ],
64
- allow: [
283
+ // Pre-existing allow entries preserved.
65
284
  'npx --version',
66
285
  'vitest --version',
67
286
  'jest --version',
@@ -25,6 +25,7 @@ import type { AnyRuleSpec, RuleId } from './spec.ts'
25
25
  import { asAnyRuleSpec, corpusIsEmpty } from './spec.ts'
26
26
 
27
27
  import { gitHooksPath, gitCoreBare } from './impl/git-core.ts'
28
+ import { adlGitUserOverrideBlock } from './impl/adl-git-user-override-block.ts'
28
29
  import { gitStash, gitClean, gitRebase, gitFilterBranch, gitRestore, gitReset, gitUpdateIndex } from './impl/git-transitions.ts'
29
30
  import { gitNoVerify, gitAmend } from './impl/git-verify.ts'
30
31
  import { gitProtectedTreeCommit } from './impl/git-protected-tree.ts'
@@ -63,7 +64,7 @@ import { adlPrCreateUpdateRole } from './impl/adl-pr-role.ts'
63
64
  import { adlPrReviewRole } from './impl/adl-pr-review-role.ts'
64
65
  import { adlPrCompletePendingDecision } from './impl/adl-pr-complete-pending.ts'
65
66
  import { adlWorkitemRequiresTarunOrVivek } from './impl/adl-workitem-role.ts'
66
- import { adlBuildRunDenied } from './impl/adl-build-run-denied.ts'
67
+ import { adlBuildRunRole } from './impl/adl-build-run-role.ts'
67
68
  import { toolFind } from './impl/tool-find.ts'
68
69
  import { toolGrep } from './impl/tool-grep.ts'
69
70
  import { toolNativeSearch } from './impl/tool-native-search.ts'
@@ -85,6 +86,14 @@ import { agentTamperProtection } from './impl/agent-tamper-protection.ts'
85
86
  export const GIT_RULES: readonly AnyRuleSpec[] = [
86
87
  gitHooksPath,
87
88
  gitCoreBare,
89
+ // Right after the `core.*` override rules: a `user.*` override rides
90
+ // along with any subcommand via `-c user.*=`, exactly the way
91
+ // `core.hooksPath` / `core.bare` do — a `git -c user.name=human commit`
92
+ // / `push` should be reported as the user override, not whatever
93
+ // commit/push policy would otherwise report. The user's call (2026-10-12)
94
+ // is "no use of git -c user* at all … blanket ban for agents"; see
95
+ // CONTRIBUTING.md line 18 and INVARIANTS.md line 55.
96
+ adlGitUserOverrideBlock,
88
97
  gitStash,
89
98
  gitClean,
90
99
  gitRebase,
@@ -150,7 +159,7 @@ export const TOOL_RULES: readonly AnyRuleSpec[] = [
150
159
  adlPrReviewRole,
151
160
  adlPrCompletePendingDecision,
152
161
  adlWorkitemRequiresTarunOrVivek,
153
- adlBuildRunDenied,
162
+ adlBuildRunRole,
154
163
  ].map(
155
164
  asAnyRuleSpec,
156
165
  )
@@ -869,7 +869,7 @@ export interface GitInvocationPiece {
869
869
  /**
870
870
  * One `adl mcp cli <command> …` invocation found while scanning a command,
871
871
  * paired with the effective identity for THAT specific piece. `command` is the gated command name
872
- * (e.g. `workitem-create-or-update`), lower-cased; `rest` is the dequoted
872
+ * (e.g. `workitem-create-or-update-pbi`), lower-cased; `rest` is the dequoted
873
873
  * values after it, the same shape `impl/adl-mcp-cli.ts`'s `adlMcpCliCall`
874
874
  * reads off `Invocation.args.slice(3)`, so a role rule can correlate a
875
875
  * specific `Invocation` to its own piece here by comparing values, exactly
@@ -86,7 +86,19 @@ export type Invocation =
86
86
  | { readonly kind: 'azure-devops'; readonly args: readonly Token[] }
87
87
  | { readonly kind: 'find' }
88
88
  | { readonly kind: 'grep' }
89
- | { readonly kind: 'frontend-tool'; readonly tool: string }
89
+ /**
90
+ * `npx`/`vitest`/`jest`/`biome`/`tsc` and their Windows spellings (the
91
+ * `.exe` variants, see `ClassifyConfig.frontendToolNames`). The
92
+ * `args` field carries the tokenized post-name argument list so
93
+ * `tool.frontend.match()` (and any future `tool.browser-automation`
94
+ * rule) can inspect flags — `--write` on `biome format`, `--noEmit`
95
+ * on `tsc`, `-u`/`--update` on `vitest run`, etc. — instead of
96
+ * denying every binary-name invocation regardless of effect. Threaded
97
+ * through by `walkToolCommand` below; the args are not consumed by
98
+ * `isHelpOrVersionOnly` (which has already run before this producer),
99
+ * they are what remains of the post-name token stream.
100
+ */
101
+ | { readonly kind: 'frontend-tool'; readonly tool: string; readonly args: readonly Token[] }
90
102
  /**
91
103
  * `bun test` (any arguments, including a file/pattern operand) — the
92
104
  * `test` subcommand specifically, after skipping bun's own `-C <dir>` /
@@ -638,7 +650,7 @@ function walkToolCommand(name: string, args: readonly Token[], cfg: ClassifyConf
638
650
  return [{ kind: 'azure-devops', args }]
639
651
  }
640
652
  if (cfg.cargoNames.has(name)) return isHelpOrVersionOnly(args) ? [] : [{ kind: 'cargo' }]
641
- if (cfg.frontendToolNames.has(name)) return isHelpOrVersionOnly(args) ? [] : [{ kind: 'frontend-tool', tool: name }]
653
+ if (cfg.frontendToolNames.has(name)) return isHelpOrVersionOnly(args) ? [] : [{ kind: 'frontend-tool', tool: name, args }]
642
654
  if (cfg.bunNames.has(name)) return isBunTestSubcommand(args) ? [{ kind: 'bun-test' }] : null
643
655
  if (cfg.justNames.has(name)) return walkJustCommand(args)
644
656
  if (name === 'xargs' || name === 'xargs.exe') return xargsInvocation(args, walk)