@event4u/agent-config 10.4.0 → 11.0.0

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 (83) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +69 -0
  3. package/dist/agent-src/commands/council/debate.md +13 -4
  4. package/dist/agent-src/commands/council/default.md +26 -11
  5. package/dist/agent-src/commands/council.md +1 -1
  6. package/dist/agent-src/commands/design-system/generate.md +23 -1
  7. package/dist/agent-src/commands/pr/create.md +51 -26
  8. package/dist/agent-src/commands/worktree/create.md +5 -3
  9. package/dist/agent-src/rules/settings-ask-protocol.md +14 -9
  10. package/dist/agent-src/skills/ai-council/SKILL.md +24 -6
  11. package/dist/agent-src/skills/corpus-grounding/SKILL.md +12 -1
  12. package/dist/agent-src/skills/corpus-grounding/scripts/decision_engine.ts +215 -7
  13. package/dist/agent-src/skills/corpus-grounding/scripts/ground.ts +39 -1
  14. package/dist/agent-src/skills/design-intelligence/ATTRIBUTION.md +19 -2
  15. package/dist/agent-src/skills/design-intelligence/data/manifest.json +36 -1
  16. package/dist/agent-src/skills/design-intelligence/data/motion.csv +17 -0
  17. package/dist/agent-src/skills/git-workflow/SKILL.md +9 -3
  18. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +9 -6
  19. package/dist/agent-src/skills/using-git-worktrees/SKILL.md +25 -21
  20. package/dist/agent-src/templates/agent-settings.md +0 -1
  21. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  22. package/dist/cli-delegate/{chunk-BV3WR67D.js → chunk-55WZN5VF.js} +3 -6
  23. package/dist/cli-delegate/{chunk-X3VOARZD.js → chunk-L565YFXH.js} +3 -3
  24. package/dist/cli-delegate/{chunk-JMQZ7MM5.js → chunk-MNWRDMR5.js} +1 -1
  25. package/dist/cli-delegate/{chunk-IR3IVRVC.js → chunk-NLP54U3J.js} +2 -1
  26. package/dist/cli-delegate/{chunk-FV34EGEX.js → chunk-NN4KW4HZ.js} +1 -1
  27. package/dist/cli-delegate/{chunk-6ENSXSBW.js → chunk-PPQO4NGR.js} +1 -1
  28. package/dist/cli-delegate/{chunk-6LOPHJLD.js → chunk-RYNADUFG.js} +1 -1
  29. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  30. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  31. package/dist/cli-delegate/cmd_conformance.js +2 -2
  32. package/dist/cli-delegate/cmd_doctor.js +2 -2
  33. package/dist/cli-delegate/cmd_explain.js +2 -2
  34. package/dist/cli-delegate/cmd_export.js +4 -4
  35. package/dist/cli-delegate/cmd_handoff.js +2 -2
  36. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  37. package/dist/cli-delegate/cmd_migrate.js +2 -2
  38. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  39. package/dist/cli-delegate/cmd_prune.js +4 -4
  40. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  41. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  42. package/dist/cli-delegate/cmd_session_recycle.js +1 -1
  43. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  44. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  45. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  46. package/dist/cli-delegate/cmd_sync.js +4 -4
  47. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  48. package/dist/cli-delegate/cmd_update.js +1 -1
  49. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  50. package/dist/cli-delegate/cmd_validate.js +4 -4
  51. package/dist/cli-delegate/cmd_versions.js +1 -1
  52. package/dist/discovery/deprecation-report.md +1 -1
  53. package/dist/discovery/discovery-manifest.json +14 -14
  54. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  55. package/dist/discovery/discovery-manifest.summary.md +1 -1
  56. package/dist/discovery/dormancy-report.md +1 -1
  57. package/dist/discovery/orphan-report.md +1 -1
  58. package/dist/discovery/packs.json +2 -2
  59. package/dist/discovery/trust-report.md +1 -1
  60. package/dist/discovery/workspaces.json +2 -2
  61. package/dist/hooks/dispatch.js +54 -12
  62. package/dist/install/install.mjs +5 -7
  63. package/dist/mcp/registry-manifest.json +1 -1
  64. package/dist/mcp/server.json +2 -2
  65. package/dist/mcp/server.mjs +2 -1
  66. package/dist/scripts/_lib/agent_settings.js +8 -0
  67. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  68. package/dist/server/schemas/settings.js +3 -4
  69. package/dist/server/schemas/settings.js.map +1 -1
  70. package/dist/ui/assets/{index-5fXOHoVh.js → index-B0aMJnuz.js} +2 -2
  71. package/dist/ui/assets/{index-5fXOHoVh.js.map → index-B0aMJnuz.js.map} +1 -1
  72. package/dist/ui/index.html +1 -1
  73. package/docs/contracts/settings-classes.md +12 -9
  74. package/package.json +1 -1
  75. package/src/config/agent-settings.template.yml +15 -18
  76. package/src/config/gate-coverage.yml +13 -3
  77. package/src/config/gate-violation-baselines.json +1 -1
  78. package/src/config/pack-size-budget.json +3 -2
  79. package/src/scripts/_lib/agent_settings.ts +8 -0
  80. package/src/scripts/check_branch_freshness.ts +238 -6
  81. package/src/scripts/external_sources_denylist.json +7 -2
  82. package/src/scripts/session_register_hook.ts +120 -10
  83. package/src/server/schemas/settings.ts +3 -6
@@ -27,7 +27,7 @@
27
27
  document.documentElement.setAttribute('data-theme', theme);
28
28
  })();
29
29
  </script>
30
- <script type="module" crossorigin src="/assets/index-5fXOHoVh.js"></script>
30
+ <script type="module" crossorigin src="/assets/index-B0aMJnuz.js"></script>
31
31
  <link rel="stylesheet" crossorigin href="/assets/index-jSfMW8_t.css">
32
32
  </head>
33
33
  <body>
@@ -39,9 +39,10 @@ surface without a consumer.
39
39
  **It governs writes, not reads.** Every class is readable by everything. A C
40
40
  class does not hide a value; it refuses to let a non-human change it.
41
41
 
42
- **It governs writes, not asks.** Four settings ship an `ask` value in their own
43
- enum (`tokens.rich_skills`, `subagents.adversarial_council`, `worktrees.mode`,
44
- `decision_engine.on_block`).
42
+ **It governs writes, not asks.** Three settings ship an `ask` value in their own
43
+ enum (`tokens.rich_skills`, `subagents.adversarial_council`,
44
+ `decision_engine.on_block`). It was four until ADR-229 deleted
45
+ `worktrees.mode`.
45
46
  Those are C-class here, and that is not a contradiction: the class says who may
46
47
  *persist* a new value; the `ask` value says what happens *at runtime* when the
47
48
  setting is already set to `ask`. A C-class key set to `ask` still produces a
@@ -189,12 +190,15 @@ dispatch runs, not WHETHER the layer exists, so they keep their own C rows.
189
190
  |---|---|
190
191
  | A — preference | 26 |
191
192
  | B — consent | 3 |
192
- | C — guarded | 106 |
193
- | **Total** | **135** |
193
+ | C — guarded | 105 |
194
+ | **Total** | **134** |
194
195
 
195
196
  The total was 140 until 2026-08-12, when five of the six keys no code path read were
196
197
  deleted, minus the one held open (§ The six unread keys, below): one A
197
- (`telegraph.speak_scope`) and four C.
198
+ (`telegraph.speak_scope`) and four C. It dropped to 134 on 2026-08-13 when
199
+ ADR-229 deleted `worktrees.mode` — a fifth C, and the first deletion in this
200
+ series of a key that WAS read: the doctrine there is that the decision was never
201
+ the agent's to make, not that nothing consulted it.
198
202
 
199
203
  The total is every leaf in the template, where *leaf* means anything that is not
200
204
  a **non-empty** map. An empty map (like the former `subagents.host_capabilities: {}`) is a real
@@ -244,9 +248,9 @@ the template, which is the drift this contract exists to prevent.
244
248
  |---|---|
245
249
  | derivable | 83 |
246
250
  | un-inferrable | 9 |
247
- | consent | 38 |
251
+ | consent | 37 |
248
252
  | policy | 5 |
249
- | **Total** | **135** |
253
+ | **Total** | **134** |
250
254
 
251
255
  First measured 2026-08-12 at 140 leaves (derivable 88 · consent 38 ·
252
256
  un-inferrable 9 · policy 5), from the table below rather than predicted — the
@@ -421,7 +425,6 @@ Rows follow template order, so a diff against the template reads straight down.
421
425
  | `subagents.model_ceiling` | C | `""` | session-wide subagent model cap (exported as `CLAUDE_CODE_SUBAGENT_MODEL` by suite-owned CLI spawn wrappers), which is spend and quality | consent |
422
426
  | `subagents.max_parallel` | C | `3` | parallelism cap, and therefore a spend rate | consent |
423
427
  | `subagents.adversarial_council` | C | `"off"` | governs a paid verification step | consent |
424
- | `worktrees.mode` | C | `ask` | standing permission for autonomous worktree creation | consent |
425
428
  | `ai_team.model` | C | `auto` | names an external model | un-inferrable |
426
429
  | `ai_team.allow_delegate` | C | `false` | grants an external tool write access to the repository | consent |
427
430
  | `ai_team.max_calls_per_day` | C | `50` | quota cap on a shared budget | consent |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
3
  "mcpName": "io.github.event4u-app/agent-config",
4
- "version": "10.4.0",
4
+ "version": "11.0.0",
5
5
  "description": "Governed skills, rules and commands for AI coding tools (Claude Code, Cursor, Windsurf, Copilot) — every claim machine-checked, including \"zero runtime daemon\".",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -815,24 +815,21 @@ subagents:
815
815
 
816
816
  # --- Git worktrees ---
817
817
  #
818
- # Controls how the `using-git-worktrees` skill and `subagent-orchestration`
819
- # mode 6 (`do-in-worktrees`) behave. Mechanical layer on top of the
820
- # existing `scope-control` permission gate — narrows, never widens.
821
- worktrees:
822
- # off | on | ask (default: ask)
823
- #
824
- # ask = status quo. Skill asks before creating any worktree; mode 6 is
825
- # selectable when the chain shape warrants it. Matches the
826
- # behaviour required by `scope-control` and `autonomous-execution`.
827
- # off = no autonomous worktree creation. Skill refuses; mode 6 falls
828
- # back to mode 3 (do-in-steps). Explicit user request in the
829
- # chat ("do this in a worktree", "use mode 6") overrides for
830
- # that single task — the setting suppresses *unprompted* usage,
831
- # not the tool itself.
832
- # on = standing permission. Skill skips the per-creation permission
833
- # ask; mode 6 may be picked without prompting. Iron-Law gates
834
- # (ignore-check, clean baseline) still apply.
835
- mode: ask
818
+ # There is no setting. Worktree creation is INSTRUCTION-ONLY (ADR-229):
819
+ # `using-git-worktrees` and `subagent-orchestration` mode 6 (`do-in-worktrees`)
820
+ # create a worktree ONLY when the user asks for one in the chat ("do this in a
821
+ # worktree", "use mode 6"). Unprompted, mode 6 is not selectable and the chain
822
+ # falls back to mode 3 (`do-in-steps`).
823
+ #
824
+ # `worktrees.mode` was DELETED. It offered `ask`/`on`/`off` over a decision that
825
+ # was never the agent's to make: `ask` spent a round trip on every spawn, `on`
826
+ # let the agent start parallel work on its own, and `off` was the behaviour
827
+ # everyone actually wanted — so the setting's only real value was hardcoded and
828
+ # the other two values removed. A leftover key from an older install is ignored
829
+ # with one deprecation line (see `REMOVED_KEYS` in `_lib/agent_settings.ts`).
830
+ #
831
+ # The Iron-Law gates on creation (ignore-safety check, clean baseline) are
832
+ # unchanged and still run on every explicitly-requested worktree.
836
833
 
837
834
  # --- AI Council (external second-opinion network) ---
838
835
  #
@@ -335,9 +335,19 @@ gates:
335
335
  Why 340 and not 360, the measured value: gate-authoring.md § registration
336
336
  requires a floor BELOW the live count, because a floor pinned at today's
337
337
  number reds on the very change that legitimately retires one unit. 340
338
- leaves ~20 units (~5.5%) of headroom while still catching the collapse this
339
- floor exists for — a re-broken extractor would drop the count by 164, not
340
- by 20.
338
+ leaves ~20 units (~5.5%) of headroom.
339
+ What this floor can and cannot catch, corrected after the R2 review caught
340
+ the first version arguing from a break that cannot fire it. The extraction
341
+ bug INFLATED the count — phantom gate names entering the CI set — so a
342
+ re-break raises the population to roughly 527 and a min_scanned floor never
343
+ triggers on a rise. The floor guards the opposite direction: an extractor
344
+ that stops finding taskfiles or workflows at all, a parse error zeroing one
345
+ side, a scan root that moves. Those are collapses of 100+ units and 340
346
+ catches every one of them. The realistic PARTIAL break — one taskfile
347
+ failing to parse, say ~14 units — passes 340 and would have failed 380,
348
+ which is the honest cost of the headroom and is accepted here rather than
349
+ hidden: a floor tight enough to catch that is also tight enough to red on
350
+ the next legitimate retirement.
341
351
 
342
352
  - id: check_site_links
343
353
  argv: []
@@ -4,7 +4,7 @@
4
4
  "ci-parity:local-only": {
5
5
  "count": 166,
6
6
  "landed": "2026-08-13",
7
- "note": "Gates that `task ci` runs and no workflow does — the direction this manifest's own header calls the one that let real defects merge (16 stale index rows reached the trunk behind a local-only check-index). Revealed, not created, by repairing the checker's extraction: check_ci_local_parity built its CI-side set by regexing RAW workflow text for `task <name>` and expanding each closure, comments included. Several workflow comments contain the literal string `task ci` while stating that no workflow invokes it, so `ci` was read as an invocation and its full 247-gate closure entered the 'runs in CI' set. The count was therefore 0 BY CONSTRUCTION, and the prose documenting the gap was what suppressed it. Measured at the repair: CI-side 273 → 106, undeclared_local_only 0 → 167 of 247 local gates (68%). Baselined rather than hard-failed because 167 findings nobody can clear in the change that reveals them is the gate-that-lands-as-N-instant-blockers failure this repository has refused before; shrink-only keeps the property that matters, namely that a NEW gate registered in `task ci` with no workflow reds immediately. Lowering happens by wiring gates into workflows or declaring them under `local_only:` with a reason. Note the punchline, because it bounds the risk of this entry: check_ci_local_parity ITSELF runs in no workflow, so it is its own finding and this ratchet has no remote reach either — clearing the backlog is what gives it teeth. DRAINED BY ONE, 2026-08-13: check_ci_local_parity itself was wired into consistency.yml, so it left the set it exists to report. That is the whole shape of a legitimate drain here — the gate stops being local-only because a workflow now runs it, not because the declaration list grew. The floor in gate-coverage.yml moved in the same change and for the same reason: wiring two gates raised the parsed population 357 -> 359.",
7
+ "note": "Gates that `task ci` runs and no workflow does — the direction this manifest's own header calls the one that let real defects merge (16 stale index rows reached the trunk behind a local-only check-index). Revealed, not created, by repairing the checker's extraction: check_ci_local_parity built its CI-side set by regexing RAW workflow text for `task <name>` and expanding each closure, comments included. Several workflow comments contain the literal string `task ci` while stating that no workflow invokes it, so `ci` was read as an invocation and its full 247-gate closure entered the 'runs in CI' set. The count was therefore 0 BY CONSTRUCTION, and the prose documenting the gap was what suppressed it. Measured at the repair: CI-side 273 → 106, undeclared_local_only 0 → 167 of 247 local gates (68%). Baselined rather than hard-failed because 167 findings nobody can clear in the change that reveals them is the gate-that-lands-as-N-instant-blockers failure this repository has refused before; shrink-only keeps the property that matters, namely that a NEW gate registered in `task ci` with no workflow reds immediately. Lowering happens by wiring gates into workflows or declaring them under `local_only:` with a reason. Note the punchline, because it bounds the risk of this entry: check_ci_local_parity ITSELF runs in no workflow, so it is its own finding and this ratchet has no remote reach either — clearing the backlog is what gives it teeth. DRAINED BY ONE, 2026-08-13: check_ci_local_parity itself was wired into consistency.yml, so it left the set it exists to report. That is the whole shape of a legitimate drain here — the gate stops being local-only because a workflow now runs it, not because the declaration list grew. The floor in gate-coverage.yml moved in the same change and for the same reason: wiring two gates raised the parsed population 357 -> 360 (109 CI + 251 local). The intermediate 359 was the reading after the CI-side wiring alone and before check_gate_coverage joined the local `ci` chain; it was committed here by mistake and is corrected rather than left as a second number for the same measurement.",
8
8
  "falsifier": "./scripts-run src/scripts/check_ci_local_parity"
9
9
  },
10
10
  "check_no_new_legacy_path:hardcoded-scan-roots": {
@@ -20,9 +20,10 @@
20
20
  "rationale": "Measured 2026-07-31: the largest non-excepted skill is corpus-grounding at 2.33%, so a 5% cap is green with better than 2x headroom and red for any skill that doubles. It is a falsifiable gate, not a ritual one.",
21
21
  "exceptions": {
22
22
  "design-intelligence": {
23
- "max_pct": 23.0,
23
+ "max_pct": 23.5,
24
24
  "measured_pct": 22.63,
25
- "reason": "771 KB of BM25 corpus (27 CSVs + 16 design-language prose specs + manifest.json) consumed at runtime by corpus-grounding's retriever, and referenced by 6 further skills. Moving it behind a lazy fetch would add a network dependency to a skill that works offline today — the same trade-off ADR-061 §8 already REJECTED for a 745 KB font index. The exception is visible debt, not an endorsement: raising max_pct requires a reason in the same commit."
25
+ "reason": "771 KB of BM25 corpus (27 CSVs + 16 design-language prose specs + manifest.json) consumed at runtime by corpus-grounding's retriever, and referenced by 6 further skills. Moving it behind a lazy fetch would add a network dependency to a skill that works offline today — the same trade-off ADR-061 §8 already REJECTED for a 745 KB font index. The exception is visible debt, not an endorsement: raising max_pct requires a reason in the same commit.",
26
+ "baseline_note_2026_08_13": "23.0 → 23.5 on 2026-08-13, and the reason belongs in this file per the sentence above. WHAT WAS ADOPTED: data/motion.csv, 10.5 KB, 16 rows. WHY IT IS NOT DISCRETIONARY: the roadmap's three design dials were all in its original scope, and two of the three (variance, density) were already portable — variance's keywords resolve against the local styles.csv (2/3/3 hits) and density is three pure range maps. The motion dial is not: upstream filters its corpus rows by `Intensity Tier == <dial tier>`, and that column exists only in motion.csv, so shipping the dial without the file means shipping a flag that silently does nothing. Two of three dials plus an inert third is a worse surface than three working ones. WHAT WAS DECLINED IN THE SAME PASS, so this is not a ratchet slipping: google-fonts.csv (743 KB — ADR-061 §8 rejects it by name and by size; the 2026-06-16 amendment authorises a slim top-N slice from a different mirror, not this file), typography.csv (our curated font-pairings-reference.csv is the ADR's working substitution), six desktop-UI stacks outside frontend-design's scope, and eleven content-drifted files nobody has reviewed. The base pin in ATTRIBUTION.md is deliberately NOT bumped for the same reason. HONEST LIMIT ON THE NUMBER: 22.63 is the committed measurement, not a fresh one — check_pack_size cannot be re-measured from a built worktree (it reads 9.096 against the 7.8 packed cap before any change, the trap the packed_size_mb note above documents), so the post-adoption share is arithmetic (~22.87 %) and the new cap carries ~0.6 pp over it rather than clearing by a hair. DECIDED BY: AI council, 2026-08-13, on a maintainer delegation — and honestly, by ONE member over two rounds, not by convergence: the second seat returned exit_1 (the codex CLI refuses a git worktree as an untrusted directory). Recorded as a single-member review in agents/evidence/analysis/design-system-blockers-council.md."
26
27
  }
27
28
  }
28
29
  }
@@ -922,6 +922,13 @@ export function load_agent_settings(
922
922
  * whether the repair is to build its missing reader or to delete a promise a
923
923
  * Hard Floor can never honour. See `settings-classes.md` § The six unread keys.
924
924
  *
925
+ * `worktrees.mode` (2026-08-13, ADR-229) is a third doctrine again: not
926
+ * always-on, not unread. It WAS read, and the reader honoured all three values —
927
+ * the deletion says the decision was never the agent's to make. `ask` bought a
928
+ * round trip per spawn, `on` let the agent start parallel work unprompted, and
929
+ * `off` described the wanted behaviour, so `off` became the hardcoded rule and
930
+ * the switch went. What decides instead is the user's own sentence in the chat.
931
+ *
925
932
  * Every reason string must name what decides INSTEAD, never just "removed".
926
933
  * Exported so `lint_settings_classes` can check the deletion side of the
927
934
  * surface: a reason that names no replacement, or an entry whose key is live in
@@ -942,6 +949,7 @@ export const REMOVED_KEYS: ReadonlyMap<string, string> = new Map([
942
949
  ['chat_history.on_overflow', 'the overflow mode comes from the rotate command --mode argv'],
943
950
  ['quality.wait_for_remote_ci', 'whether to poll follows from the push plus a detectable remote pipeline'],
944
951
  ['legal_review_prep.consented_at', 'the provenance sidecar settings:set writes and consentVerdict reads'],
952
+ ['worktrees.mode', 'the user asking for a worktree in the chat; creation is instruction-only and hardcoded'],
945
953
  ]);
946
954
 
947
955
  /** Keys already warned about in THIS process — the "once per run" dedupe. */
@@ -31,6 +31,26 @@
31
31
  * It never fetches, merges, or rebases for you. Choosing how to catch up is a
32
32
  * git-history decision (`git-history-discipline`), and a gate that rewrites
33
33
  * history to make itself pass is a worse failure than the one it reports.
34
+ *
35
+ * WHICH BASE — asked of the forge, not assumed.
36
+ *
37
+ * The first version hardcoded `main`. That is right for most branches here and
38
+ * wrong for every branch whose PR targets something else: a stacked PR, a
39
+ * release line, a consumer repo on `master`. On those the gate produced a false
40
+ * GREEN — "current with origin/main" while the branch sat behind the base it
41
+ * would actually merge into — which is worse than no gate, because it answers a
42
+ * question nobody asked in the voice of the one they did.
43
+ *
44
+ * So the base is resolved, in order: an explicit `--base`, then the base of the
45
+ * OPEN PR for this branch as the forge reports it, then the repo's default
46
+ * branch from `origin/HEAD`, then `main`. Every message names the base AND
47
+ * where it came from, because "I checked main because that is the PR's base"
48
+ * and "I checked main because I could not ask" must never print the same.
49
+ *
50
+ * When the forge cannot be asked (no `gh`, not authenticated, no GitHub remote)
51
+ * the run says so out loud even under `--quiet`: it has verified the default
52
+ * base and has NOT ruled out an open PR against a different one. That is a
53
+ * partial answer, and partial answers are reported as partial.
34
54
  */
35
55
  import { execFileSync } from "node:child_process";
36
56
  import fs from "node:fs";
@@ -43,9 +63,11 @@ import { reportScanned } from "./_lib/scan_scope.js";
43
63
 
44
64
  // ledger-exempt: single remote-ref probe — the entire scope is ONE ls-remote answer (0 or 1 refs) resolved to one aggregate ancestor verdict, and every empty path already publishes its reason via reportScanned allowEmpty; there is no per-target collection to account.
45
65
  const DEFAULT_BASE = "main";
46
- const SELF_TEST_MIN_CASES = 3;
47
- const SELF_TEST_MIN_REJECT = 1;
66
+ const SELF_TEST_MIN_CASES = 5;
67
+ const SELF_TEST_MIN_REJECT = 2;
48
68
  const SCRIPT_REL = "src/scripts/check_branch_freshness.ts";
69
+ /** One forge round trip on the push path — long enough for a cold `gh`, short enough not to stall a push. */
70
+ const FORGE_TIMEOUT_MS = 10_000;
49
71
 
50
72
  function git(args: string[]): string | null {
51
73
  try {
@@ -55,6 +77,134 @@ function git(args: string[]): string | null {
55
77
  }
56
78
  }
57
79
 
80
+ export type GhResult = { ok: true; stdout: string } | { ok: false; reason: string };
81
+ export type GhRunner = (args: string[]) => GhResult;
82
+
83
+ /**
84
+ * What the forge said about an open PR for this branch.
85
+ *
86
+ * Three-valued on purpose. `none` (the forge answered: no open PR) and
87
+ * `unavailable` (the forge could not be asked) lead to the same base and to
88
+ * DIFFERENT confidence, and collapsing them is exactly how a gate reports a
89
+ * green it did not earn.
90
+ */
91
+ export type ForgeAnswer =
92
+ | { kind: "pr"; base: string; number: number }
93
+ | { kind: "none" }
94
+ | { kind: "unavailable"; reason: string };
95
+
96
+ /** Where the base name came from — printed with every verdict. */
97
+ export type BaseSource = "flag" | "pull-request" | "origin-head" | "fallback";
98
+
99
+ export interface BaseResolution {
100
+ readonly base: string;
101
+ readonly source: BaseSource;
102
+ readonly pr?: number;
103
+ /** Present iff an open PR against a different base could not be ruled out. */
104
+ readonly unverified?: string;
105
+ }
106
+
107
+ function gh(args: string[]): GhResult {
108
+ try {
109
+ const out = execFileSync("gh", args, {
110
+ encoding: "utf8",
111
+ stdio: ["ignore", "pipe", "ignore"],
112
+ timeout: FORGE_TIMEOUT_MS,
113
+ });
114
+ return { ok: true, stdout: out.toString() };
115
+ } catch (err) {
116
+ const code = (err as NodeJS.ErrnoException).code;
117
+ if (code === "ENOENT") {
118
+ return { ok: false, reason: "the gh CLI is not installed" };
119
+ }
120
+ if (code === "ETIMEDOUT") {
121
+ return { ok: false, reason: `gh did not answer within ${FORGE_TIMEOUT_MS / 1000}s` };
122
+ }
123
+ return { ok: false, reason: "gh could not answer — not authenticated, or no GitHub remote" };
124
+ }
125
+ }
126
+
127
+ /**
128
+ * The base of the open PR whose head is `branch`.
129
+ *
130
+ * `pr list --head` rather than `pr view`: `pr view` will happily resolve a
131
+ * CLOSED PR for the same branch, and a merged PR's base is not the base the
132
+ * next push has to be current with.
133
+ */
134
+ export function askForgeForBase(branch: string, run: GhRunner = gh): ForgeAnswer {
135
+ const res = run([
136
+ "pr", "list", "--head", branch, "--state", "open",
137
+ "--json", "number,baseRefName", "--limit", "1",
138
+ ]);
139
+ if (!res.ok) {
140
+ return { kind: "unavailable", reason: res.reason };
141
+ }
142
+ let rows: unknown;
143
+ try {
144
+ rows = JSON.parse(res.stdout);
145
+ } catch {
146
+ return { kind: "unavailable", reason: "gh returned output that is not JSON" };
147
+ }
148
+ if (!Array.isArray(rows) || rows.length === 0) {
149
+ return { kind: "none" };
150
+ }
151
+ const row = rows[0] as { baseRefName?: unknown; number?: unknown };
152
+ const base = typeof row.baseRefName === "string" ? row.baseRefName.trim() : "";
153
+ if (base === "") {
154
+ return { kind: "unavailable", reason: "gh answered without a baseRefName" };
155
+ }
156
+ return { kind: "pr", base, number: typeof row.number === "number" ? row.number : 0 };
157
+ }
158
+
159
+ /** The repo's own default branch, so a consumer on `master` is not told about `main`. */
160
+ export function repoDefaultBase(): { base: string; source: "origin-head" | "fallback" } {
161
+ const ref = git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
162
+ if (ref !== null && ref.startsWith("origin/")) {
163
+ const name = ref.slice("origin/".length).trim();
164
+ if (name !== "") {
165
+ return { base: name, source: "origin-head" };
166
+ }
167
+ }
168
+ return { base: DEFAULT_BASE, source: "fallback" };
169
+ }
170
+
171
+ /** `--base` wins, then the open PR's base, then the repo default. */
172
+ export function resolveBase(
173
+ argv: readonly string[],
174
+ branch: string,
175
+ forge: (b: string) => ForgeAnswer = (b) => askForgeForBase(b),
176
+ ): BaseResolution {
177
+ const flag = argv.indexOf("--base");
178
+ if (flag >= 0) {
179
+ const explicit = argv[flag + 1];
180
+ if (explicit !== undefined && explicit !== "") {
181
+ return { base: explicit, source: "flag" };
182
+ }
183
+ }
184
+ const answer = forge(branch);
185
+ if (answer.kind === "pr") {
186
+ return { base: answer.base, source: "pull-request", pr: answer.number };
187
+ }
188
+ const fallback = repoDefaultBase();
189
+ return answer.kind === "unavailable"
190
+ ? { base: fallback.base, source: fallback.source, unverified: answer.reason }
191
+ : { base: fallback.base, source: fallback.source };
192
+ }
193
+
194
+ /** Human-readable provenance — never omitted, so no verdict is ambiguous about what it checked. */
195
+ export function describeBase(r: BaseResolution): string {
196
+ switch (r.source) {
197
+ case "flag":
198
+ return "base given on the command line";
199
+ case "pull-request":
200
+ return `base of open PR #${r.pr ?? 0}`;
201
+ case "origin-head":
202
+ return "repo default branch (origin/HEAD)";
203
+ case "fallback":
204
+ return `repo default (assumed ${DEFAULT_BASE} — origin/HEAD is not set)`;
205
+ }
206
+ }
207
+
58
208
  /** The SHA the server reports for `<base>` right now — never a tracking ref. */
59
209
  export function remoteHead(base: string, remote = "origin"): string | null {
60
210
  const out = git(["ls-remote", "--heads", remote, base]);
@@ -121,6 +271,47 @@ function buildFixture(root: string, opts: { advanceRemote: boolean }): string {
121
271
  return work;
122
272
  }
123
273
 
274
+ /**
275
+ * A repo where the branch's real base is NOT the default branch: `feat/x` is
276
+ * cut from `develop`, `develop` then advances on the remote, and `main` never
277
+ * moves.
278
+ *
279
+ * This is the fixture that demonstrates the defect the base resolution exists
280
+ * to remove. Checked against `main` the branch is perfectly current — a GREEN
281
+ * that is true and irrelevant. Checked against `develop`, the base it will
282
+ * actually merge into, it is behind. Both verdicts come from the same tree, so
283
+ * a regression that reinstates a hardcoded base flips exactly one of them.
284
+ */
285
+ function buildTwoBaseFixture(root: string): string {
286
+ const origin = path.join(root, "origin.git");
287
+ const work = path.join(root, "work");
288
+ fs.mkdirSync(origin, { recursive: true });
289
+ const g = (args: string[], at: string): void => {
290
+ execFileSync("git", args, { cwd: at, stdio: "ignore" });
291
+ };
292
+ const write = (at: string, name: string, body: string): void => {
293
+ fs.writeFileSync(path.join(at, name), body);
294
+ g(["add", "-A"], at);
295
+ g(["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", name], at);
296
+ };
297
+ g(["init", "--bare", "-b", DEFAULT_BASE], origin);
298
+ g(["init", "-b", DEFAULT_BASE, work], root);
299
+ g(["remote", "add", "origin", origin], work);
300
+ write(work, "a.txt", "one");
301
+ g(["push", "-u", "origin", DEFAULT_BASE], work);
302
+ g(["checkout", "-b", "develop"], work);
303
+ g(["push", "-u", "origin", "develop"], work);
304
+ g(["checkout", "-b", "feat/x"], work);
305
+ write(work, "b.txt", "two");
306
+ // A second clone advances `develop` only — `main` stays exactly where it was.
307
+ const other = path.join(root, "other");
308
+ g(["clone", origin, other], root);
309
+ g(["checkout", "develop"], other);
310
+ write(other, "c.txt", "three");
311
+ g(["push"], other);
312
+ return work;
313
+ }
314
+
124
315
  export function selfTest(repoRoot: string): number {
125
316
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "cbf-selftest-"));
126
317
  const run = (advanceRemote: boolean, onBase: boolean): number => {
@@ -134,12 +325,29 @@ export function selfTest(repoRoot: string): number {
134
325
  delete process.env["GITHUB_ACTIONS"];
135
326
  return runGateCli(repoRoot, SCRIPT_REL, ["--quiet"], work);
136
327
  };
328
+ const runTwoBase = (base: string): number => {
329
+ const dir = fs.mkdtempSync(path.join(root, "twobase-"));
330
+ const work = buildTwoBaseFixture(dir);
331
+ delete process.env["CI"];
332
+ delete process.env["GITHUB_ACTIONS"];
333
+ return runGateCli(repoRoot, SCRIPT_REL, ["--quiet", "--base", base], work);
334
+ };
137
335
  const cases: SelfTestCase[] = [
138
336
  {
139
337
  name: "a branch whose tracking ref is stale while the remote has moved is REFUSED",
140
338
  expect: "reject",
141
339
  run: () => run(true, false),
142
340
  },
341
+ {
342
+ name: "a branch behind its REAL base (develop) is refused when that base is checked",
343
+ expect: "reject",
344
+ run: () => runTwoBase("develop"),
345
+ },
346
+ {
347
+ name: "the same branch passes against main — the false green a hardcoded base produces",
348
+ expect: "accept",
349
+ run: () => runTwoBase(DEFAULT_BASE),
350
+ },
143
351
  {
144
352
  name: "a branch containing the remote base passes",
145
353
  expect: "accept",
@@ -186,8 +394,6 @@ export function main(argv: string[] = process.argv.slice(2)): number {
186
394
  return selfTest(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."));
187
395
  }
188
396
  const quiet = argv.includes("--quiet");
189
- const baseArg = argv.indexOf("--base");
190
- const base = baseArg >= 0 ? (argv[baseArg + 1] ?? DEFAULT_BASE) : DEFAULT_BASE;
191
397
 
192
398
  // CI merges or refuses on the server; a second opinion here would be noise.
193
399
  if (process.env["CI"] === "true" || process.env["GITHUB_ACTIONS"] === "true") {
@@ -200,11 +406,27 @@ export function main(argv: string[] = process.argv.slice(2)): number {
200
406
  scanReport(0, "detached HEAD — there is no branch whose freshness could be asked");
201
407
  return 0;
202
408
  }
409
+
410
+ // Resolved AFTER the branch is known: the PR's base is a fact about THIS branch.
411
+ const resolved = resolveBase(argv, branch);
412
+ const base = resolved.base;
413
+
203
414
  if (branch === base) {
204
415
  scanReport(0, `standing on ${base} itself — a branch cannot be behind itself`);
205
416
  return 0;
206
417
  }
207
418
 
419
+ // Loud even under --quiet: this run verified the default base and did NOT
420
+ // rule out an open PR against a different one. A partial answer is reported
421
+ // as partial, never folded into the plain green below.
422
+ if (resolved.unverified !== undefined) {
423
+ process.stdout.write(
424
+ `⚠️ check_branch_freshness: could not ask the forge for this branch's PR base ` +
425
+ `(${resolved.unverified}) — checking origin/${base}, the ${describeBase(resolved)}. ` +
426
+ "If an open PR targets a different base, THAT base is NOT verified here.\n",
427
+ );
428
+ }
429
+
208
430
  const sha = remoteHead(base);
209
431
  if (sha === null) {
210
432
  // Loud, not silent. An offline run has not verified the invariant, and a
@@ -221,19 +443,29 @@ export function main(argv: string[] = process.argv.slice(2)): number {
221
443
  scanReport(1);
222
444
  if (containsCommit(sha) === true) {
223
445
  if (!quiet) {
224
- process.stdout.write(`✅ branch is current with origin/${base} (${sha.slice(0, 9)})` + "\n");
446
+ process.stdout.write(
447
+ `✅ branch is current with origin/${base} (${sha.slice(0, 9)}) — ${describeBase(resolved)}\n`,
448
+ );
225
449
  }
226
450
  return 0;
227
451
  }
228
452
 
229
- console.error(`❌ branch is BEHIND origin/${base} — the remote is at ${sha.slice(0, 9)}.`);
453
+ console.error(
454
+ `❌ branch is BEHIND origin/${base} — the remote is at ${sha.slice(0, 9)}. ` +
455
+ `(${describeBase(resolved)})`,
456
+ );
230
457
  console.error("");
231
458
  console.error(" Pushing now opens a PR that may conflict, and worse: another branch");
232
459
  console.error(" may already have shipped what this one is implementing.");
233
460
  console.error("");
234
461
  console.error(` git fetch origin && git merge origin/${base}`);
462
+ console.error(" ./agent-config roadmap:progress # regenerate AFTER every merge, not only on conflict");
235
463
  console.error(" then re-run the gates before pushing.");
236
464
  console.error("");
465
+ console.error(" The dashboard line is not optional housekeeping: the roadmap dashboard is a");
466
+ console.error(" GENERATED file, so a merge that lands cleanly still leaves it describing");
467
+ console.error(" neither side's roadmap set. A clean auto-merge of a generated file is still wrong.");
468
+ console.error("");
237
469
  console.error(
238
470
  " This gate asks the REMOTE, not your tracking ref — a fetch from earlier in",
239
471
  );
@@ -95,7 +95,10 @@
95
95
  "agents/roadmaps/archive/road-to-subagent-value-realization.md",
96
96
  "agents/roadmap-assets/road-to-image-brand-typography.assets.md",
97
97
  "CREDITS.md",
98
- "agents/roadmaps/archive/road-to-ecosystem-harvest-index.md"
98
+ "agents/roadmaps/archive/road-to-ecosystem-harvest-index.md",
99
+ "provenance/borrows.jsonl",
100
+ "docs/THIRD-PARTY-NOTICES.md",
101
+ "agents/evidence/reviews/*.review-input/diff.patch"
99
102
  ],
100
103
  "skip_reason": {
101
104
  "vendored_cluster": "design-intelligence/corpus-grounding/design-tokens/react-shadcn-ui/tailwind-engineer + cost/*.mjs ship vendored Apache/MIT code; their upstream attribution is license-required and must NOT be stripped (ADR-061). CREDITS.md is the repo-level attribution file FOR that same cluster — it names the upstreams because the licenses require it, which is the one case source-confidentiality explicitly exempts. It was missing here, so the gate flagged the very attribution the rule mandates.",
@@ -103,7 +106,9 @@
103
106
  "generated_bundles": "content.json* bundle the vendored-cluster skill bodies above.",
104
107
  "self": "the linter + its denylist + the source-confidentiality rule necessarily contain the deny tokens as data.",
105
108
  "validate_safe_paths": "telegraph-derived (MIT) ported code; upstream attribution is license-required.",
106
- "archived_roadmaps_newly_tracked": "Archived roadmaps (and their assets) that predate the source-confidentiality sweep became tracked via the 2026-07 agents-dir gitignore hygiene change. They are frozen historical records; the claudekit mentions are ADR-061 license-attribution work and the remaining names are historical planning references. New roadmaps stay subject to the gate. road-to-ecosystem-harvest-index.md is the sharpest case: the flagged line is its own acceptance criterion, 'No harvest-family artifact names a real external source' — it quotes the names in order to assert their absence, so the gate fires on the sentence that records the cleanup. road-to-opt-retrieval-and-memory.md was REMOVED from this list on 2026-07-28 (road-to-native-code-intelligence Phase 6): its two clear-text mentions named a gitignored borrow-catalogue inbox file, which carries no historical value that a neutral descriptor loses — so the mentions were rewritten and the exception retired rather than kept. Retiring an exception is preferred over holding one whenever the named token is not license-required attribution and not load-bearing for the record."
109
+ "archived_roadmaps_newly_tracked": "Archived roadmaps (and their assets) that predate the source-confidentiality sweep became tracked via the 2026-07 agents-dir gitignore hygiene change. They are frozen historical records; the claudekit mentions are ADR-061 license-attribution work and the remaining names are historical planning references. New roadmaps stay subject to the gate. road-to-ecosystem-harvest-index.md is the sharpest case: the flagged line is its own acceptance criterion, 'No harvest-family artifact names a real external source' — it quotes the names in order to assert their absence, so the gate fires on the sentence that records the cleanup. road-to-opt-retrieval-and-memory.md was REMOVED from this list on 2026-07-28 (road-to-native-code-intelligence Phase 6): its two clear-text mentions named a gitignored borrow-catalogue inbox file, which carries no historical value that a neutral descriptor loses — so the mentions were rewritten and the exception retired rather than kept. Retiring an exception is preferred over holding one whenever the named token is not license-required attribution and not load-bearing for the record.",
110
+ "provenance_ledger": "provenance/borrows.jsonl and the docs/THIRD-PARTY-NOTICES.md it generates are the license-attribution surfaces for CODE borrows, and code-provenance REQUIRES the upstream name in them: the ledger's `source_url` is a mandatory field (provenance/README.md), and lint_provenance fails when the notices file drifts from it. That is the same license-required-attribution case source-confidentiality already exempts for the vendored cluster and CREDITS.md — the rule forbids derivation-attribution, not the notice a license obliges. The collision only surfaced on 2026-08-13 because the ledger had been empty until its first record; before that there was no name to flag. Scope is deliberately these two paths and not `provenance/*`: the harvest ledger next door governs KNOWLEDGE claims, where anonymisation is the rule and no license compels a name.",
111
+ "r2_review_diff_capture": "The R2 completion-review dispatcher writes a verbatim `diff.patch` of the branch under review into a tracked input package. It is a MECHANICAL CAPTURE, not authored text: any source name inside it was already written somewhere else in the same branch, where this gate does see it. A branch that touches the vendored cluster's ATTRIBUTION.md — carved out here precisely because its license requires the name — therefore reproduces that name in the capture and fails the gate for content the gate already allowed at its source. Scope is the capture ONLY: `findings.md` and `prompt.md` are authored prose and stay under the gate, so a reviewer who names a harvest source in their findings is still caught."
107
112
  },
108
113
  "_excluded_tokens": {
109
114
  "agent-os": "collides with our own coined 'Agent-OS primitive' term (universal-skills) — too FP-prone to auto-deny; scrub external 'agent-os' mentions by review.",