session-orchestrator 3.17.0 → 3.19.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 (187) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +105 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/docs/USER-GUIDE.md +8 -3
  21. package/docs/ci-setup.md +121 -7
  22. package/docs/codex-setup.md +1 -1
  23. package/docs/components.md +6 -6
  24. package/docs/cursor-setup.md +22 -9
  25. package/docs/events-schema.md +5 -1
  26. package/docs/instruction-delivery.md +444 -0
  27. package/docs/rule-authoring.md +58 -9
  28. package/docs/session-config-reference.md +244 -9
  29. package/docs/session-config-template.md +39 -3
  30. package/hooks/_lib/guard-source-loader.mjs +467 -0
  31. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  32. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  33. package/hooks/config-protection.mjs +0 -0
  34. package/hooks/enforce-commands.mjs +10 -2
  35. package/hooks/hooks-codex.json +1 -1
  36. package/hooks/hooks-cursor.json +11 -2
  37. package/hooks/hooks-pi.json +10 -0
  38. package/hooks/hooks.json +21 -1
  39. package/hooks/on-session-end.mjs +178 -18
  40. package/hooks/on-session-start.mjs +23 -0
  41. package/hooks/post-bash-write-verify.mjs +977 -0
  42. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  43. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  44. package/hooks/pre-bash-issue-budget.mjs +167 -0
  45. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  46. package/hooks/pre-bash-templates-first.mjs +96 -63
  47. package/hooks/subagent-telemetry.mjs +527 -37
  48. package/package.json +5 -2
  49. package/pi/prompts/contract-version-bump.md +12 -0
  50. package/rules/README.md +32 -0
  51. package/scripts/archive-closed-prds.mjs +12 -22
  52. package/scripts/autopilot-multi.mjs +103 -20
  53. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  54. package/scripts/check-doc-consistency.sh +17 -1
  55. package/scripts/eval-session.mjs +50 -9
  56. package/scripts/fleet-instruction-scan.mjs +141 -0
  57. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  58. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  59. package/scripts/lib/backlog-scan.mjs +39 -6
  60. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  61. package/scripts/lib/ci-status-banner.mjs +75 -12
  62. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  63. package/scripts/lib/command-blocker.mjs +1013 -58
  64. package/scripts/lib/config/config-protection.mjs +2 -1
  65. package/scripts/lib/config/drift-check.mjs +9 -1
  66. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  67. package/scripts/lib/config/issue-budget.mjs +123 -0
  68. package/scripts/lib/config/reconcile.mjs +21 -0
  69. package/scripts/lib/config/section-extractor.mjs +121 -1
  70. package/scripts/lib/config-schema.mjs +23 -3
  71. package/scripts/lib/config.mjs +17 -0
  72. package/scripts/lib/convergence-monitor.mjs +49 -3
  73. package/scripts/lib/description-surface.mjs +535 -0
  74. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  75. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  76. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  77. package/scripts/lib/eval/engine.mjs +47 -5
  78. package/scripts/lib/events.mjs +59 -7
  79. package/scripts/lib/gates/gate-full.mjs +15 -3
  80. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  81. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  82. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  83. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  84. package/scripts/lib/handover-gate.mjs +7 -3
  85. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  86. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  87. package/scripts/lib/io.mjs +345 -10
  88. package/scripts/lib/issue-budget.mjs +269 -0
  89. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  90. package/scripts/lib/label-scope.mjs +47 -0
  91. package/scripts/lib/learnings/schema.mjs +43 -3
  92. package/scripts/lib/lock-reaper.mjs +1 -2
  93. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  94. package/scripts/lib/peer-discovery.mjs +645 -0
  95. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  96. package/scripts/lib/product-repo-detect.mjs +9 -8
  97. package/scripts/lib/project-hygiene.mjs +432 -0
  98. package/scripts/lib/quality-gate.mjs +167 -0
  99. package/scripts/lib/recommendations-v0.mjs +1 -1
  100. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  101. package/scripts/lib/reconcile/emitter.mjs +23 -4
  102. package/scripts/lib/reconcile/engine.mjs +147 -39
  103. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  104. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  105. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  106. package/scripts/lib/resource-probe.mjs +19 -0
  107. package/scripts/lib/rule-loader.mjs +6 -0
  108. package/scripts/lib/scope-baseline.mjs +564 -0
  109. package/scripts/lib/scope-gate.mjs +399 -98
  110. package/scripts/lib/session-close-backfill.mjs +61 -6
  111. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  112. package/scripts/lib/session-id.mjs +221 -41
  113. package/scripts/lib/session-lock.mjs +304 -6
  114. package/scripts/lib/session-schema/constants.mjs +22 -3
  115. package/scripts/lib/session-schema/validator.mjs +16 -0
  116. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  117. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  118. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  119. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  120. package/scripts/lib/spiral-carryover.mjs +142 -30
  121. package/scripts/lib/state-md/mission-status.mjs +53 -3
  122. package/scripts/lib/subagents-schema.mjs +43 -9
  123. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  124. package/scripts/lib/tests-src-ratio.mjs +484 -0
  125. package/scripts/lib/validate/check-agents.mjs +56 -0
  126. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  127. package/scripts/lib/validate/check-rules.mjs +217 -35
  128. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  129. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  130. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  131. package/scripts/lib/vault-archive.mjs +17 -2
  132. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  133. package/scripts/lib/vault-mirror/process.mjs +30 -0
  134. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  135. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  136. package/scripts/lib/wave-resource-gate.mjs +115 -11
  137. package/scripts/lib/worktree/listing.mjs +44 -7
  138. package/scripts/mcp-server.sh +17 -3
  139. package/scripts/measure-context-overhead.sh +151 -0
  140. package/scripts/memory-propose.mjs +72 -9
  141. package/scripts/print-applicable-rules.mjs +51 -12
  142. package/scripts/release.mjs +534 -0
  143. package/scripts/run-quality-gate.mjs +123 -5
  144. package/scripts/validate-wave-scope.mjs +182 -17
  145. package/scripts/vault-integration-watcher.mjs +32 -10
  146. package/skills/_shared/config-reading.md +2 -2
  147. package/skills/bootstrap/fast-template.md +1 -1
  148. package/skills/claude-md-drift-check/checker.mjs +145 -28
  149. package/skills/contract-version-bump/SKILL.md +219 -0
  150. package/skills/discovery/SKILL.md +4 -4
  151. package/skills/discovery/issue-templates.md +11 -11
  152. package/skills/discovery/probes-audit.md +1 -1
  153. package/skills/discovery/probes-feature.md +1 -1
  154. package/skills/discovery/probes-session.md +26 -5
  155. package/skills/ecosystem-health/SKILL.md +1 -1
  156. package/skills/ecosystem-health/wizard.md +4 -4
  157. package/skills/evolve/SKILL.md +1 -0
  158. package/skills/gitlab-ops/SKILL.md +20 -12
  159. package/skills/gitlab-portfolio/SKILL.md +2 -2
  160. package/skills/hook-development/SKILL.md +1 -1
  161. package/skills/mode-selector/SKILL.md +1 -1
  162. package/skills/npm-publish/SKILL.md +17 -1
  163. package/skills/plan/SKILL.md +5 -5
  164. package/skills/plan/mode-feature.md +4 -4
  165. package/skills/plan/mode-new.md +10 -10
  166. package/skills/plan/mode-retro.md +1 -1
  167. package/skills/quality-gates/SKILL.md +1 -1
  168. package/skills/reconcile/SKILL.md +21 -4
  169. package/skills/session-end/SKILL.md +34 -13
  170. package/skills/session-end/discovery-scan.md +4 -2
  171. package/skills/session-end/drift-operations.md +4 -4
  172. package/skills/session-end/metrics-collection.md +13 -0
  173. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  174. package/skills/session-end/phase-3-6-tail.md +2 -1
  175. package/skills/session-end/plan-verification.md +5 -2
  176. package/skills/session-end/vault-operations.md +1 -1
  177. package/skills/session-end/verification-checklist.md +1 -1
  178. package/skills/session-plan/SKILL.md +6 -2
  179. package/skills/session-plan/wave-template.md +2 -0
  180. package/skills/session-start/SKILL.md +73 -7
  181. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  182. package/skills/test-runner/SKILL.md +2 -2
  183. package/skills/vault-sync/validator.mjs +108 -7
  184. package/skills/wave-executor/SKILL.md +5 -2
  185. package/skills/wave-executor/circuit-breaker.md +2 -0
  186. package/skills/wave-executor/wave-loop.md +163 -10
  187. package/templates/_shared/loop.md +4 -4
@@ -0,0 +1,362 @@
1
+ /**
2
+ * vcs-repo-spec.mjs — resolve a glab/gh `-R`/`--repo` host-pinning spec from
3
+ * the local git remotes (#839).
4
+ *
5
+ * Why this exists: a bare `glab`/`gh` spawn (no shell wrapper, no `-R`) falls
6
+ * back to the ambient `GITLAB_HOST`/`GH_HOST` env var to pick a host. On a
7
+ * multi-GitLab-instance host that can resolve to the WRONG instance and fail
8
+ * silently — the root cause of #839.
9
+ *
10
+ * Injecting `GITLAB_HOST` into the spawn env is NOT the fix — it was tested
11
+ * live and FAILS whenever `~/.ssh/config` maps the GitLab hostname to a
12
+ * `HostName` IP alias: glab then reports "none of the git remotes configured
13
+ * for this repository correspond to the GITLAB_HOST environment variable"
14
+ * because the remote it sees is the IP, not the hostname. The only mechanism
15
+ * verified to survive that mismatch is passing the raw remote URL (or a
16
+ * HOST/OWNER/REPO spec) via `-R`/`--repo`.
17
+ *
18
+ * Preference order:
19
+ * - vcs === 'github': remote `github` → `origin`
20
+ * - vcs === 'gitlab' (default): remote `gitlab` → `origin`
21
+ *
22
+ * Returns `undefined` when no matching remote resolves — callers MUST treat
23
+ * this as "could not auto-detect" and omit the `-R`/`--repo` flag entirely
24
+ * (never emit `-R undefined`).
25
+ *
26
+ * Spec format (#872 follow-up): `resolveRepoSpec` returns the RAW remote URL
27
+ * for `vcs: 'gitlab'`, but a NORMALIZED `HOST/OWNER/REPO` string for
28
+ * `vcs: 'github'` — `gh -R`/`--repo` documents only `[HOST/]OWNER/REPO` as
29
+ * its accepted spec shape, unlike `glab -R` which explicitly accepts a full
30
+ * URL. See `resolveRepoSpec`'s own docblock for the full rationale. This
31
+ * module also exports `resolveRepoHost` for the `glab api`/`gh api`
32
+ * call sites, which accept neither `-R` nor a URL — only `--hostname`.
33
+ *
34
+ * Credential safety (#907, CWE-214): every value returned by `resolveRepoSpec`
35
+ * / `resolveRepoHost` has any embedded userinfo credential
36
+ * (`https://user:token@host/...`, the GitLab-CI checkout pattern) stripped at
37
+ * the source, so a credential can never reach a `-R`/`--repo`/`--hostname`
38
+ * argv position (visible via `ps` / `/proc/<pid>/cmdline`). A scp-like SSH
39
+ * login (`git@host:path`) is NOT a credential and is preserved verbatim. The
40
+ * exported `redactUrlCredentials` is the log-line defense-in-depth counterpart
41
+ * for values that bypass the source strip (e.g. a `--repo` override). See
42
+ * `stripUrlCredentials` / `userinfoIsCredential`.
43
+ *
44
+ * Lifted out of `scripts/archive-closed-prds.mjs::defaultGlabRepo` (that
45
+ * script's docblock described this exact problem months before #839 was
46
+ * filed) into a shared `scripts/lib/` module so
47
+ * `scripts/lib/issue-close-strip-labels.mjs` and
48
+ * `scripts/lib/spiral-carryover.mjs` can reuse the same, single
49
+ * implementation instead of each re-deriving it.
50
+ */
51
+
52
+ import { execFileSync } from 'node:child_process';
53
+
54
+ /**
55
+ * argv-boundary guard (#872 follow-up, Q3-LOW centralization). A well-formed
56
+ * git remote URL or bare hostname never legitimately contains whitespace or a
57
+ * C0 control character — if a resolved value does, treat it as `undefined`
58
+ * ("could not auto-detect") rather than let it flow into a `-R`/`--repo`/
59
+ * `--hostname` argv position. This is the CENTRAL defense for both
60
+ * `resolveRepoSpec` and `resolveRepoHost`: the ~9 call sites across the repo
61
+ * that do a bare `if (spec) args.push('-R', spec)` do not re-check the value
62
+ * themselves, so they inherit this guard for free by going through either
63
+ * exported function here rather than reading a git remote URL directly.
64
+ */
65
+ // eslint-disable-next-line no-control-regex -- deliberate: the argv-boundary guard must catch ALL C0 control characters, not only the \s subset
66
+ const UNSAFE_ARGV_CHARS_RE = /[\s\x00-\x1f]/;
67
+
68
+ /**
69
+ * @param {unknown} value
70
+ * @returns {boolean} true when `value` is a string containing whitespace or a
71
+ * C0 control character — unsafe to forward as a single argv token.
72
+ */
73
+ function isUnsafeForArgv(value) {
74
+ return typeof value === 'string' && UNSAFE_ARGV_CHARS_RE.test(value);
75
+ }
76
+
77
+ /**
78
+ * Embedded-credential guard (#907, CWE-214). A `scheme://userinfo@host/...`
79
+ * remote URL carries its userinfo (`user:password`, or a bare `token`) in the
80
+ * clear. The GitLab-CI checkout pattern
81
+ * `https://gitlab-ci-token:<MASKED>@host/group/project.git` is the canonical
82
+ * source. If that raw URL reaches a `-R`/`--repo` argv position it is visible
83
+ * via `ps` / `/proc/<pid>/cmdline`, and if it reaches a `--verbose` log line
84
+ * it is written to CI job output — either way the credential leaks. The
85
+ * 2026-07-26 argv-boundary guard ({@link UNSAFE_ARGV_CHARS_RE}) does NOT catch
86
+ * this: an embedded credential contains no whitespace or control character.
87
+ *
88
+ * Matches ONLY the `scheme://` URL forms (`https://`, `http://`, `ssh://`,
89
+ * `git+ssh://`, …). The scp-like SSH form `git@host:path` has no `://` and is
90
+ * therefore never matched — a bare SSH login user is not a credential. The
91
+ * userinfo class `[^/\s]+` stops at the first `/`, so an `@` that appears in a
92
+ * PATH (e.g. `.../path@ref`) is never mistaken for userinfo. Because `@` is NOT
93
+ * excluded from the class, a greedy match binds `@` to the LAST `@` before the
94
+ * authority ends — the real userinfo/host separator per RFC 3986 (and how
95
+ * glab/gh parse it). This closes the residual-credential leak where a raw `@`
96
+ * inside the token/password (`user:gl@token@host`, or nested `a:b@c:d@host`)
97
+ * previously left a partial secret after a first-`@`-only match (#907 MED-1).
98
+ */
99
+ const URL_WITH_USERINFO_RE = /([a-z][a-z0-9+.-]*:\/\/)([^/\s]+)@/gi;
100
+
101
+ /**
102
+ * Decide whether a matched `scheme://userinfo@` is a CREDENTIAL (strip/redact)
103
+ * or a legitimate login username (leave untouched).
104
+ *
105
+ * A credential is either:
106
+ * - any userinfo carrying a password component (`user:pass`, a `:` present) —
107
+ * for ANY scheme, including `ssh://user:pass@host`; or
108
+ * - a bare userinfo on an `http`/`https` scheme (`https://token@host`) — HTTPS
109
+ * git auth passes tokens/PATs through the userinfo slot, so a bare userinfo
110
+ * there is a token, never a plain username.
111
+ *
112
+ * A bare userinfo on a non-HTTP scheme (`ssh://git@host`) is a login username,
113
+ * NOT a credential — SSH authenticates with keys, never a URL-embedded secret —
114
+ * so it is left untouched, consistent with the scp-like `git@host:path` case.
115
+ *
116
+ * @param {string} scheme e.g. `https://` (includes the trailing `://`)
117
+ * @param {string} userinfo the substring between `scheme` and `@`
118
+ * @returns {boolean}
119
+ */
120
+ function userinfoIsCredential(scheme, userinfo) {
121
+ if (userinfo.includes(':')) return true;
122
+ return /^https?:\/\/$/i.test(scheme);
123
+ }
124
+
125
+ /**
126
+ * Strip credential userinfo from a single remote URL, returning the URL with
127
+ * host/path/project-spec EXACTLY preserved. A credential-free URL (and the
128
+ * scp-like `git@host:path` SSH form) is returned BYTE-IDENTICAL — the strip is
129
+ * a no-op unless {@link userinfoIsCredential} classifies the userinfo as a
130
+ * secret. Never throws; a non-string returns unchanged.
131
+ *
132
+ * @param {string} url
133
+ * @returns {string}
134
+ */
135
+ export function stripUrlCredentials(url) {
136
+ if (typeof url !== 'string') return url;
137
+ return url.replace(URL_WITH_USERINFO_RE, (match, scheme, userinfo) =>
138
+ userinfoIsCredential(scheme, userinfo) ? scheme : match,
139
+ );
140
+ }
141
+
142
+ /**
143
+ * Defense-in-depth redactor for LOG output: replace any credential userinfo in
144
+ * an arbitrary text string (e.g. a verbose `glab … -R <spec>` line) with a
145
+ * `***` marker, leaving the surrounding text and the URL host/path intact. Used
146
+ * by `scripts/vault-integration-watcher.mjs`'s `verbose()` to cover the
147
+ * `--repo <spec>` override path, which bypasses the source-level strip in
148
+ * {@link resolveRawRemoteUrl}. Never throws; a non-string returns unchanged.
149
+ *
150
+ * @param {string} text
151
+ * @returns {string}
152
+ */
153
+ export function redactUrlCredentials(text) {
154
+ if (typeof text !== 'string') return text;
155
+ return text.replace(URL_WITH_USERINFO_RE, (match, scheme, userinfo) =>
156
+ userinfoIsCredential(scheme, userinfo) ? `${scheme}***@` : match,
157
+ );
158
+ }
159
+
160
+ /**
161
+ * Default git-remote runner: `git -C <repoRoot> remote get-url <name>`.
162
+ * Never throws — returns `{ ok:false, stdout:'', stderr }` on any failure
163
+ * (missing remote, not a git repo, git not on PATH, ...).
164
+ *
165
+ * @param {string[]} gitArgs
166
+ * @returns {{ ok: boolean, stdout: string, stderr: string }}
167
+ */
168
+ function defaultGitRun(gitArgs) {
169
+ try {
170
+ const stdout = execFileSync('git', gitArgs, {
171
+ encoding: 'utf8',
172
+ stdio: ['ignore', 'pipe', 'pipe'],
173
+ });
174
+ return { ok: true, stdout: String(stdout ?? ''), stderr: '' };
175
+ } catch (err) {
176
+ const stderr =
177
+ err && err.stderr ? String(err.stderr) : err && err.message ? String(err.message) : 'unknown error';
178
+ return { ok: false, stdout: '', stderr };
179
+ }
180
+ }
181
+
182
+ /** Remote-name preference order, per VCS. */
183
+ const REMOTE_PREFERENCE = {
184
+ gitlab: ['gitlab', 'origin'],
185
+ github: ['github', 'origin'],
186
+ };
187
+
188
+ /**
189
+ * The OTHER platform's well-known public host, per requested vcs — the one
190
+ * case a resolved remote URL can be PROVEN to belong to the wrong VCS family
191
+ * without any repo-specific host configuration (self-hosted GitLab/GitHub
192
+ * Enterprise instances can live at ANY domain, so this deliberately checks
193
+ * only the unambiguous public-host case, not a general host allow-list).
194
+ */
195
+ const WRONG_FAMILY_HOST = {
196
+ gitlab: 'github.com',
197
+ github: 'gitlab.com',
198
+ };
199
+
200
+ /**
201
+ * Extract the bare hostname from a git remote URL, handling both the HTTPS
202
+ * (`https://host/owner/repo.git`) and SSH (`git@host:owner/repo.git`) forms.
203
+ * Returns `null` for an unrecognized shape (never throws).
204
+ *
205
+ * @param {string} url
206
+ * @returns {string|null}
207
+ */
208
+ function extractHost(url) {
209
+ const httpsMatch = /^https?:\/\/([^/]+)/i.exec(url);
210
+ if (httpsMatch) return httpsMatch[1].toLowerCase();
211
+ const sshMatch = /^[^@\s]+@([^:\s]+):/i.exec(url);
212
+ if (sshMatch) return sshMatch[1].toLowerCase();
213
+ return null;
214
+ }
215
+
216
+ /**
217
+ * Normalize a github remote URL (HTTPS or SSH) into the `HOST/OWNER/REPO`
218
+ * shape `gh -R`/`--repo` documents as its accepted spec format. gh does NOT
219
+ * accept a raw remote URL the way `glab -R` does — only `[HOST/]OWNER/REPO`.
220
+ * Strips a trailing `.git` suffix and any trailing slash.
221
+ *
222
+ * Falls back to returning `url` unchanged when it does not match the
223
+ * expected `host/owner/repo` shape (never throws) — a raw URL is still
224
+ * strictly better than omitting `-R` entirely.
225
+ *
226
+ * @param {string} url
227
+ * @returns {string}
228
+ */
229
+ function normalizeGithubSpec(url) {
230
+ const httpsMatch = /^https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(url);
231
+ if (httpsMatch) {
232
+ const [, host, owner, repo] = httpsMatch;
233
+ return `${host.toLowerCase()}/${owner}/${repo}`;
234
+ }
235
+ const sshMatch = /^[^@\s]+@([^:\s]+):([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(url);
236
+ if (sshMatch) {
237
+ const [, host, owner, repo] = sshMatch;
238
+ return `${host.toLowerCase()}/${owner}/${repo}`;
239
+ }
240
+ return url;
241
+ }
242
+
243
+ /**
244
+ * Resolve the raw remote URL for the requested vcs — shared by
245
+ * `resolveRepoSpec` and `resolveRepoHost` so both apply the identical
246
+ * remote-preference-order + cross-family-guard resolution.
247
+ *
248
+ * Cross-family guard (#839 follow-up): a candidate URL whose host is the
249
+ * OTHER platform's well-known public host (`github.com` under vcs:'gitlab',
250
+ * `gitlab.com` under vcs:'github') is skipped rather than returned — passing
251
+ * it to `glab`/`gh -R` would be a guaranteed hard failure, strictly worse
252
+ * than the ambient-resolution fallback #839 replaced. This is a narrow,
253
+ * unambiguous check only; it does not attempt to validate self-hosted
254
+ * domains, which cannot be distinguished from a URL string alone.
255
+ *
256
+ * Credential guard (#907, CWE-214): the raw `git remote get-url` output can
257
+ * be `https://user:token@host/...` (GitLab-CI checkout pattern). The userinfo
258
+ * is stripped HERE, at the single source both `resolveRepoSpec` and
259
+ * `resolveRepoHost` flow through, BEFORE the cross-family host check and
260
+ * before the value can reach any `-R`/`--repo`/`--hostname` argv position —
261
+ * see {@link stripUrlCredentials}. A credential-free URL is unchanged
262
+ * (byte-identical), so #839/#872 behaviour is preserved.
263
+ *
264
+ * @param {{
265
+ * repoRoot?: string,
266
+ * vcs?: 'gitlab' | 'github',
267
+ * gitRun?: (args: string[]) => { ok: boolean, stdout: string, stderr: string }
268
+ * }} [opts]
269
+ * @returns {string|undefined}
270
+ */
271
+ function resolveRawRemoteUrl({ repoRoot, vcs = 'gitlab', gitRun = defaultGitRun } = {}) {
272
+ const vcsResolved = vcs === 'github' ? 'github' : 'gitlab';
273
+ const root = repoRoot ?? process.cwd();
274
+ const wrongFamilyHost = WRONG_FAMILY_HOST[vcsResolved];
275
+
276
+ for (const remote of REMOTE_PREFERENCE[vcsResolved]) {
277
+ const { ok, stdout } = gitRun(['-C', root, 'remote', 'get-url', remote]);
278
+ const url = ok ? stripUrlCredentials(stdout.trim()) : '';
279
+ if (!url) continue;
280
+ if (extractHost(url) === wrongFamilyHost) continue;
281
+ return url;
282
+ }
283
+ return undefined;
284
+ }
285
+
286
+ /**
287
+ * Resolve the glab/gh `-R`/`--repo` host-pinning spec.
288
+ *
289
+ * Format contract differs by vcs, because `glab -R` and `gh -R` accept
290
+ * different spec shapes:
291
+ * - `vcs: 'gitlab'` (default): the RAW remote URL, verbatim. `glab -R`
292
+ * explicitly accepts a full URL, and GitLab group namespaces can nest
293
+ * arbitrarily deep (`group/subgroup/project`), which makes a reliable
294
+ * `OWNER/REPO` derivation impossible from the URL alone — so the raw URL
295
+ * is the only unambiguous spec here.
296
+ * - `vcs: 'github'`: the NORMALIZED `HOST/OWNER/REPO` form (see
297
+ * `normalizeGithubSpec`). `gh -R`/`--repo` documents ONLY
298
+ * `[HOST/]OWNER/REPO` as accepted input — a raw URL is not guaranteed to
299
+ * parse the same way, and GitHub repos are always exactly two path
300
+ * segments (owner/repo), so the derivation is unambiguous.
301
+ *
302
+ * Returns `undefined` when no matching remote resolves — callers MUST treat
303
+ * this as "could not auto-detect" and omit the `-R`/`--repo` flag entirely
304
+ * (never emit `-R undefined`). Also returns `undefined` when the resolved
305
+ * spec contains whitespace or a control character — see the module-level
306
+ * argv-boundary guard ({@link isUnsafeForArgv}), which this function applies
307
+ * to the FINAL spec value (post `normalizeGithubSpec`, when applicable).
308
+ *
309
+ * @param {{
310
+ * repoRoot?: string,
311
+ * vcs?: 'gitlab' | 'github',
312
+ * gitRun?: (args: string[]) => { ok: boolean, stdout: string, stderr: string }
313
+ * }} [opts]
314
+ * @returns {string|undefined}
315
+ */
316
+ export function resolveRepoSpec({ repoRoot, vcs = 'gitlab', gitRun = defaultGitRun } = {}) {
317
+ const vcsResolved = vcs === 'github' ? 'github' : 'gitlab';
318
+ const url = resolveRawRemoteUrl({ repoRoot, vcs: vcsResolved, gitRun });
319
+ if (!url) return undefined;
320
+ const spec = vcsResolved === 'github' ? normalizeGithubSpec(url) : url;
321
+ return isUnsafeForArgv(spec) ? undefined : spec;
322
+ }
323
+
324
+ /**
325
+ * Resolve the bare hostname of the matching remote, for use with
326
+ * `glab api --hostname`/`gh api --hostname` — the `api` subcommand of both
327
+ * CLIs does NOT accept `-R`/`--repo` (it has no repo concept), only a
328
+ * `--hostname` flag to pin which instance the request targets. This is the
329
+ * host-pinning counterpart to `resolveRepoSpec` for those api-only call
330
+ * sites.
331
+ *
332
+ * Applies the identical remote-preference-order + cross-family-guard
333
+ * resolution as `resolveRepoSpec`, just returning the host instead of the
334
+ * full spec — same contract: `undefined` ⇒ caller omits the flag entirely.
335
+ * Also applies the same argv-boundary guard ({@link isUnsafeForArgv}) to the
336
+ * resolved host before returning it.
337
+ *
338
+ * @param {{
339
+ * repoRoot?: string,
340
+ * vcs?: 'gitlab' | 'github',
341
+ * gitRun?: (args: string[]) => { ok: boolean, stdout: string, stderr: string }
342
+ * }} [opts]
343
+ * @returns {string|undefined}
344
+ */
345
+ export function resolveRepoHost({ repoRoot, vcs, gitRun } = {}) {
346
+ const url = resolveRawRemoteUrl({ repoRoot, vcs, gitRun });
347
+ const host = url ? (extractHost(url) ?? undefined) : undefined;
348
+ return isUnsafeForArgv(host) ? undefined : host;
349
+ }
350
+
351
+ /**
352
+ * @deprecated Back-compat alias for `archive-closed-prds.mjs`'s original
353
+ * `defaultGlabRepo(repoRoot, gitRunFn)` positional signature (gitlab-only).
354
+ * New callers should use `resolveRepoSpec`.
355
+ *
356
+ * @param {string} repoRoot
357
+ * @param {(args: string[]) => { ok: boolean, stdout: string, stderr: string }} gitRunFn
358
+ * @returns {string|undefined}
359
+ */
360
+ export function defaultGlabRepo(repoRoot, gitRunFn) {
361
+ return resolveRepoSpec({ repoRoot, vcs: 'gitlab', gitRun: gitRunFn });
362
+ }
@@ -20,7 +20,7 @@ import { probe } from './resource-probe.mjs';
20
20
  * "proceed" decision.
21
21
  *
22
22
  * @param {object} opts - Same opts shape as evaluateWaveResourceGate
23
- * @returns {Promise<{ramFreeGb: number, ramAvailableGb: number|null, cpuLoadPct: number, concurrentSessions: number} | {probeFailed: true}>}
23
+ * @returns {Promise<{ramFreeGb: number, ramAvailableGb: number|null, cpuLoadPct: number, cpuLoad5mPct: number|null, concurrentSessions: number} | {probeFailed: true}>}
24
24
  */
25
25
  async function extractMeasurements(opts) {
26
26
  const { probeOverride } = opts;
@@ -31,6 +31,8 @@ async function extractMeasurements(opts) {
31
31
  // Tests may supply ramAvailableGb to exercise the macOS path; absent → null.
32
32
  ramAvailableGb: probeOverride.ramAvailableGb ?? null,
33
33
  cpuLoadPct: probeOverride.cpuLoadPct,
34
+ // 5m-average CPU pct (#943); absent → null (legacy overrides → 1m-only judging).
35
+ cpuLoad5mPct: probeOverride.cpuLoad5mPct ?? null,
34
36
  concurrentSessions: probeOverride.concurrentSessions,
35
37
  };
36
38
  }
@@ -47,6 +49,9 @@ async function extractMeasurements(opts) {
47
49
  // os.freemem() is already accurate. (#667)
48
50
  ramAvailableGb: snapshot.ram_available_gb ?? null,
49
51
  cpuLoadPct: snapshot.cpu_load_pct,
52
+ // 5m load-average as pct-of-cores (#943). null on Windows/zero-load, where
53
+ // the gate falls back to judging the 1m-derived cpu_load_pct alone.
54
+ cpuLoad5mPct: snapshot.cpu_load_5m_pct ?? null,
50
55
  // concurrent sessions: number of claude processes found by the probe.
51
56
  concurrentSessions: snapshot.claude_processes_count ?? 0,
52
57
  };
@@ -54,15 +59,89 @@ async function extractMeasurements(opts) {
54
59
 
55
60
  /**
56
61
  * Apply the gate decision rule sequence (rules 3-8) given measurements and
57
- * config. Returns the full gate result.
62
+ * config, then apply the HR-004 heavy-repo preflight ceiling on top. Returns
63
+ * the full gate result.
58
64
  *
59
- * @param {{ramFreeGb: number, cpuLoadPct: number, concurrentSessions: number}} measurements
65
+ * @param {{ramFreeGb: number, ramAvailableGb?: number|null, cpuLoadPct: number, cpuLoad5mPct?: number|null, concurrentSessions: number}} measurements
60
66
  * @param {object} opts - Same opts shape as evaluateWaveResourceGate
61
67
  * @returns {{decision: string, agents: number, reasons: string[], measurements: object}}
62
68
  */
63
69
  function applyDecisionRules(measurements, opts) {
70
+ const result = computeResourceDecision(measurements, opts);
71
+ return applyHeavyRepoCap(result, opts);
72
+ }
73
+
74
+ /**
75
+ * Resolve an `agents-per-wave` config value into a plain numeric cap, or
76
+ * `null` when no cap should apply.
77
+ *
78
+ * `_coerceInteger()` (scripts/lib/config/coercers.mjs) parses the documented
79
+ * HR-003 parenthetical-override syntax — `agents-per-wave: 4 (deep: 18)` —
80
+ * into an OBJECT `{ default: 4, deep: 18 }`, not a plain number. Feeding that
81
+ * object straight into a `typeof cap !== 'number'` guard makes the heavy-repo
82
+ * cap silently no-op for every repo using the override syntax, which defeats
83
+ * HR-004 exactly where it matters most (a heavy repo that also runs deep
84
+ * sessions).
85
+ *
86
+ * `evaluateWaveResourceGate()` has no session-mode input in scope — `waveRole`
87
+ * is a wave role (e.g. "Impl-Core"), not a session mode (e.g. "deep") — so the
88
+ * object shape resolves to `cap.default` here. That is the conservative
89
+ * choice: the documented HR-003 convention writes the override as
90
+ * `<default> (mode: <higher-ceiling>)`, i.e. `default` is the MORE
91
+ * restrictive of the pair. Falling back to it can only under-apply a looser
92
+ * mode-specific ceiling; it never lets a heavy repo exceed its base cap.
93
+ *
94
+ * @param {number|{default: number, [mode: string]: number}|*} cap
95
+ * @returns {number|null}
96
+ */
97
+ function resolveApwCap(cap) {
98
+ if (typeof cap === 'number') return Number.isFinite(cap) ? cap : null;
99
+ if (cap !== null && typeof cap === 'object' && !Array.isArray(cap)) {
100
+ const def = cap.default;
101
+ return typeof def === 'number' && Number.isFinite(def) ? def : null;
102
+ }
103
+ return null;
104
+ }
105
+
106
+ /**
107
+ * HR-003/HR-004 heavy-repo preflight ceiling (baseline #60). A STATIC cap
108
+ * independent of the live resource-probe verdict: when `config['heavy-repo']`
109
+ * is `true`, `agents` is clamped to at most `config['agents-per-wave']`
110
+ * (resolved via {@link resolveApwCap} to handle the object-override shape).
111
+ * More-restrictive-wins — this only ever LOWERS `agents`, never raises it
112
+ * above what the resource-driven rules already decided (e.g. a
113
+ * coordinator-direct 0 stays 0).
114
+ *
115
+ * @param {{decision: string, agents: number, reasons: string[], measurements: object}} result
116
+ * @param {object} opts - Same opts shape as evaluateWaveResourceGate
117
+ * @returns {{decision: string, agents: number, reasons: string[], measurements: object}}
118
+ */
119
+ function applyHeavyRepoCap(result, opts) {
120
+ const { config } = opts;
121
+ if (!config || config['heavy-repo'] !== true) return result;
122
+ const cap = resolveApwCap(config['agents-per-wave']);
123
+ if (cap === null) return result;
124
+ if (result.agents <= cap) return result; // already within the ceiling — never raise
125
+ return {
126
+ ...result,
127
+ decision: result.decision === 'coordinator-direct' ? 'coordinator-direct' : 'reduce',
128
+ agents: cap,
129
+ reasons: [...result.reasons, `heavy-repo: true caps agents-per-wave to ${cap} (HR-004)`],
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Rules 3-8: resource-driven decision sequence (RAM/CPU/concurrent-sessions).
135
+ * Extracted so `applyDecisionRules` can layer the HR-004 heavy-repo cap on
136
+ * top without duplicating this sequence.
137
+ *
138
+ * @param {{ramFreeGb: number, ramAvailableGb?: number|null, cpuLoadPct: number, cpuLoad5mPct?: number|null, concurrentSessions: number}} measurements
139
+ * @param {object} opts - Same opts shape as evaluateWaveResourceGate
140
+ * @returns {{decision: string, agents: number, reasons: string[], measurements: object}}
141
+ */
142
+ function computeResourceDecision(measurements, opts) {
64
143
  const { config, plannedAgents } = opts;
65
- const { ramFreeGb, ramAvailableGb, cpuLoadPct, concurrentSessions } = measurements;
144
+ const { ramFreeGb, ramAvailableGb, cpuLoadPct, cpuLoad5mPct, concurrentSessions } = measurements;
66
145
  const T = config['resource-thresholds'];
67
146
 
68
147
  // macOS fix (#667): os.freemem() reports only `Pages free`, which reads
@@ -111,24 +190,41 @@ function applyDecisionRules(measurements, opts) {
111
190
  };
112
191
  }
113
192
 
114
- // Rule 6: CPU overloaded → reduce.
115
- if (cpuLoadPct > T['cpu-load-max-pct']) {
193
+ // Rule 6: CPU overloaded → reduce. #943: this gate runs, by construction,
194
+ // right after the coordinator's own CPU-saturating quality-gate run — the 1m
195
+ // load average still carries that decaying tail (observed 2026-07-30:
196
+ // 96% → 91% → 78% → 75% within 36s), so a 1m-only reading systematically
197
+ // over-reports and halves waves without a real bottleneck. When the probe
198
+ // supplied a numeric 5m percentage, judge on min(1m, 5m): only-1m-high is a
199
+ // decaying transient (informational, no reduce), both-high is genuine
200
+ // sustained load. `cpuLoad5mPct` null (legacy overrides, Windows) → 1m-only.
201
+ const has5mCpu = typeof cpuLoad5mPct === 'number' && Number.isFinite(cpuLoad5mPct);
202
+ const effectiveCpuLoadPct = has5mCpu ? Math.min(cpuLoadPct, cpuLoad5mPct) : cpuLoadPct;
203
+ if (effectiveCpuLoadPct > T['cpu-load-max-pct']) {
204
+ const detail = has5mCpu ? ` (min of 1m ${cpuLoadPct}% / 5m ${cpuLoad5mPct}%)` : '';
116
205
  return {
117
206
  decision: 'reduce',
118
207
  agents: Math.max(1, Math.floor(plannedAgents / 2)),
119
208
  reasons: [
120
- `CPU load ${cpuLoadPct}% > max ${T['cpu-load-max-pct']}% — reducing agent count`,
209
+ `CPU load ${effectiveCpuLoadPct}%${detail} > max ${T['cpu-load-max-pct']}% — reducing agent count`,
121
210
  ],
122
211
  measurements,
123
212
  };
124
213
  }
214
+ const cpuTransientNote =
215
+ has5mCpu && cpuLoadPct > T['cpu-load-max-pct']
216
+ ? `info: CPU 1m load ${cpuLoadPct}% > max ${T['cpu-load-max-pct']}% but 5m load ${cpuLoad5mPct}% is below — decaying transient (typically the coordinator's own just-finished gate run), not reducing (#943)`
217
+ : null;
125
218
 
126
219
  // Rule 7: concurrent sessions above warn → proceed with warning.
127
220
  if (concurrentSessions > T['concurrent-sessions-warn']) {
128
221
  return {
129
222
  decision: 'proceed',
130
223
  agents: plannedAgents,
131
- reasons: [`warn: ${concurrentSessions} concurrent sessions`],
224
+ reasons: [
225
+ ...(cpuTransientNote ? [cpuTransientNote] : []),
226
+ `warn: ${concurrentSessions} concurrent sessions`,
227
+ ],
132
228
  measurements,
133
229
  };
134
230
  }
@@ -137,7 +233,10 @@ function applyDecisionRules(measurements, opts) {
137
233
  return {
138
234
  decision: 'proceed',
139
235
  agents: plannedAgents,
140
- reasons: ['all thresholds within bounds'],
236
+ reasons: [
237
+ ...(cpuTransientNote ? [cpuTransientNote] : []),
238
+ 'all thresholds within bounds',
239
+ ],
141
240
  measurements,
142
241
  };
143
242
  }
@@ -148,14 +247,19 @@ function applyDecisionRules(measurements, opts) {
148
247
  * @param {object} opts.config - Parsed Session Config (from parse-config.sh output)
149
248
  * @param {number} opts.plannedAgents - Number of agents the session-plan wants to dispatch
150
249
  * @param {string} opts.waveRole - e.g. "Impl-Core", "Quality"
151
- * @param {object} [opts.probeOverride] - {ramFreeGb, cpuLoadPct, concurrentSessions} for
152
- * testing; when omitted, calls resource-probe
250
+ * @param {object} [opts.probeOverride] - {ramFreeGb, cpuLoadPct, cpuLoad5mPct?, concurrentSessions}
251
+ * for testing; when omitted, calls resource-probe
153
252
  * @returns {Promise<{decision: "proceed"|"reduce"|"coordinator-direct", agents: number, reasons: string[], measurements: object}>}
154
253
  */
155
254
  export async function evaluateWaveResourceGate(opts) {
156
255
  const { config, plannedAgents } = opts;
157
256
 
158
257
  // Rule 1: resource-awareness disabled — skip all probing.
258
+ // `resource-awareness: false` is a FULL opt-out, INCLUDING the HR-004
259
+ // heavy-repo static cap below (applyHeavyRepoCap runs only inside
260
+ // applyDecisionRules, which this early return bypasses entirely). The
261
+ // static cap only applies on the resource-aware path — deliberate,
262
+ // reviewed 2026-07-23, baseline #60.
159
263
  if (config['resource-awareness'] === false) {
160
264
  return {
161
265
  decision: 'proceed',
@@ -2,7 +2,8 @@
2
2
  * worktree/listing.mjs — list and filter worktrees.
3
3
  *
4
4
  * Exports:
5
- * listWorktrees() — list all git worktrees
5
+ * listWorktrees() — list all git worktrees (bare array; swallows git failure)
6
+ * listWorktreesChecked() — same listing WITH a "git actually ran" signal (#919.3)
6
7
  * applyWorktreeExcludes(wtPath, patterns) — remove top-level dirs from worktree
7
8
  *
8
9
  * No imports from lifecycle.mjs — this module is intentionally a leaf of the
@@ -18,23 +19,39 @@ import path from 'node:path';
18
19
  // ---------------------------------------------------------------------------
19
20
 
20
21
  /**
21
- * List all git worktrees in the current repository.
22
+ * List all git worktrees WITH an explicit "git actually ran" signal (#919.3).
23
+ *
24
+ * The bare `listWorktrees()` below swallows a failing `git worktree list` into
25
+ * an empty array — indistinguishable from "git ran, repo has no extra
26
+ * worktrees". That collapse is the last fail-open gap in the
27
+ * `checkLiveForeignSession` full path (peer-discovery.mjs, #906/#908 residual):
28
+ * a total surface failure read as "nobody home". This variant keeps the listing
29
+ * contract but makes the failure DISTINGUISHABLE, so callers that need the
30
+ * distinction can make the fail-safe call themselves.
22
31
  *
23
32
  * @param {object} [opts]
24
33
  * @param {Function} [opts.$] Optional zx-compatible executor. Defaults to real zx.$.
25
34
  * Tests pass a mock here to avoid vi.mock('zx') under fork pool.
26
- * @returns {Promise<Array<{path: string, branch: string, head: string}>>}
27
- * Array of worktree descriptors; empty array if none or on parse error.
35
+ * @returns {Promise<{ok: boolean, worktrees: Array<{path: string, branch: string, head: string}>, error?: string}>}
36
+ * `ok: true` — `git worktree list` ran; `worktrees` is the (possibly empty)
37
+ * parsed listing. An empty list here is a MEASUREMENT.
38
+ * `ok: false` — the git invocation itself failed (spawn error, non-zero
39
+ * exit, not a repo, …); `worktrees` is `[]` and `error` carries
40
+ * the failure message. An empty list here is NOT a measurement.
28
41
  */
29
- export async function listWorktrees(opts = {}) {
42
+ export async function listWorktreesChecked(opts = {}) {
30
43
  const dollar = opts.$ ?? defaultDollar;
31
44
  const git = dollar({ cwd: process.cwd() });
32
45
  let output;
33
46
  try {
34
47
  const result = await git`git worktree list --porcelain`;
35
48
  output = result.stdout;
36
- } catch {
37
- return [];
49
+ } catch (err) {
50
+ return {
51
+ ok: false,
52
+ worktrees: [],
53
+ error: err instanceof Error ? err.message : String(err),
54
+ };
38
55
  }
39
56
 
40
57
  const worktrees = [];
@@ -67,6 +84,26 @@ export async function listWorktrees(opts = {}) {
67
84
  worktrees.push(current);
68
85
  }
69
86
 
87
+ return { ok: true, worktrees };
88
+ }
89
+
90
+ /**
91
+ * List all git worktrees in the current repository.
92
+ *
93
+ * Thin backward-compatible wrapper over `listWorktreesChecked()`: existing
94
+ * callers get the bare array they always got, INCLUDING the historical
95
+ * swallow-to-`[]` on git failure. Callers that must distinguish "git ran,
96
+ * empty" from "git failed" use `listWorktreesChecked()` instead (#919.3).
97
+ *
98
+ * @param {object} [opts]
99
+ * @param {Function} [opts.$] Optional zx-compatible executor. Defaults to real zx.$.
100
+ * Tests pass a mock here to avoid vi.mock('zx') under fork pool.
101
+ * @returns {Promise<Array<{path: string, branch: string, head: string}>>}
102
+ * Array of worktree descriptors; empty array if none OR on git failure
103
+ * (the two are indistinguishable here — by design, see wrapper note).
104
+ */
105
+ export async function listWorktrees(opts = {}) {
106
+ const { worktrees } = await listWorktreesChecked(opts);
70
107
  return worktrees;
71
108
  }
72
109
 
@@ -128,9 +128,23 @@ tool_session_config() {
128
128
  return
129
129
  fi
130
130
 
131
- # Extract everything from "## Session Config" to the next heading or EOF
132
- local config
133
- config=$(sed -n '/^## Session Config$/,/^## /{/^## Session Config$/d;/^## /d;p;}' "$instr_file" 2>/dev/null) || true
131
+ # Extract everything from "## Session Config" to the next heading or EOF.
132
+ #
133
+ # The address matches isSessionConfigHeading() in
134
+ # scripts/lib/config/section-extractor.mjs (the SSOT) exactly: the literal
135
+ # heading, plus an optional CR so a CRLF checkout is read the same way. Shell
136
+ # cannot import the JS predicate, so the alignment is by hand and this
137
+ # comment names the authority. Before #968 this was the only STRICTER site in
138
+ # the repo — a bare `$` anchors before the CR in a BRE, so on a CRLF checkout
139
+ # (this plugin ships to Windows-side Codex/Cursor users) the tool reported
140
+ # "no Session Config section" for a file the runtime parses fine.
141
+ #
142
+ # `\r` is not portable inside a BRE (GNU sed only), so the CR is injected as
143
+ # a literal byte via printf.
144
+ local cr config sc_re
145
+ cr=$(printf '\r')
146
+ sc_re="^## Session Config${cr}\{0,1\}\$"
147
+ config=$(sed -n "/$sc_re/,/^## /{/$sc_re/d;/^## /d;p;}" "$instr_file" 2>/dev/null) || true
134
148
 
135
149
  if [[ -z "$config" ]]; then
136
150
  respond "$id" "$(text_content "No '## Session Config' section found in $instr_file")"