session-orchestrator 3.20.0 → 3.22.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 (202) 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/000-session-orchestrator.mdc +3 -2
  5. package/.cursor/rules/030-wave-execution.mdc +10 -8
  6. package/.cursor/rules/040-discovery.mdc +6 -6
  7. package/.cursor/rules/050-plan.mdc +8 -8
  8. package/CHANGELOG.md +515 -0
  9. package/README.md +16 -11
  10. package/agents/analyst.md +1 -1
  11. package/agents/architect-reviewer.md +1 -1
  12. package/agents/code-implementer.md +4 -2
  13. package/agents/db-specialist.md +1 -1
  14. package/agents/dialectic-deriver.md +1 -1
  15. package/agents/docs-writer.md +1 -1
  16. package/agents/memory-proposal-collector.md +7 -5
  17. package/agents/qa-strategist.md +1 -1
  18. package/agents/security-reviewer.md +1 -1
  19. package/agents/session-reviewer.md +42 -1
  20. package/agents/skill-applied-judge.md +1 -1
  21. package/agents/test-writer.md +1 -1
  22. package/agents/ui-developer.md +1 -1
  23. package/agents/ux-evaluator.md +1 -1
  24. package/commands/eli5.md +33 -0
  25. package/commands/release.md +62 -0
  26. package/commands/test.md +2 -2
  27. package/docs/components.md +6 -5
  28. package/docs/migration-v3.md +9 -6
  29. package/docs/persona-panel.md +3 -1
  30. package/docs/scope-collision-guard.md +167 -0
  31. package/docs/session-config-reference.md +31 -8
  32. package/hooks/_lib/lock-bootstrap.mjs +19 -13
  33. package/hooks/enforce-scope.mjs +103 -3
  34. package/hooks/hooks-codex.json +1 -1
  35. package/hooks/hooks.json +21 -1
  36. package/hooks/on-session-end.mjs +76 -97
  37. package/hooks/on-session-start.mjs +195 -104
  38. package/hooks/on-stop.mjs +127 -12
  39. package/hooks/post-bash-write-verify.mjs +8 -32
  40. package/hooks/pre-auq-clarity.mjs +787 -0
  41. package/hooks/pre-bash-issue-budget.mjs +17 -18
  42. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  43. package/package.json +3 -1
  44. package/pi/prompts/eli5.md +12 -0
  45. package/pi/prompts/release.md +12 -0
  46. package/scripts/auq-audit.mjs +825 -0
  47. package/scripts/autopilot.mjs +10 -9
  48. package/scripts/emit-session.mjs +42 -0
  49. package/scripts/export-hw-learnings.mjs +61 -2
  50. package/scripts/lib/auq/clarity.mjs +1314 -0
  51. package/scripts/lib/auq/parse.mjs +1006 -0
  52. package/scripts/lib/auq/schema.mjs +1457 -0
  53. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  54. package/scripts/lib/backlog-scan.mjs +106 -15
  55. package/scripts/lib/build-live-signals.mjs +7 -3
  56. package/scripts/lib/ci-status-banner.mjs +267 -77
  57. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  58. package/scripts/lib/config/vault-integration.mjs +12 -1
  59. package/scripts/lib/dispatcher/rank.mjs +4 -7
  60. package/scripts/lib/gates/gate-full.mjs +3 -3
  61. package/scripts/lib/gates/gate-helpers.mjs +17 -6
  62. package/scripts/lib/git-config-drift.mjs +471 -0
  63. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  64. package/scripts/lib/io.mjs +432 -7
  65. package/scripts/lib/issue-budget.mjs +63 -9
  66. package/scripts/lib/learnings/select.mjs +157 -3
  67. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  68. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  69. package/scripts/lib/named-vault-resolver.mjs +105 -16
  70. package/scripts/lib/owner-interview.mjs +78 -32
  71. package/scripts/lib/peer-cards/schema.mjs +6 -2
  72. package/scripts/lib/peer-discovery.mjs +73 -22
  73. package/scripts/lib/project-hygiene.mjs +64 -4
  74. package/scripts/lib/reconcile/renderer.mjs +17 -4
  75. package/scripts/lib/reconcile/writer.mjs +69 -30
  76. package/scripts/lib/redact-spans.mjs +89 -0
  77. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  78. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  79. package/scripts/lib/resource-probe.mjs +18 -2
  80. package/scripts/lib/scope-baseline.mjs +77 -17
  81. package/scripts/lib/scope-gate.mjs +658 -0
  82. package/scripts/lib/secret-masker.mjs +262 -0
  83. package/scripts/lib/session-lock.mjs +34 -10
  84. package/scripts/lib/session-registry.mjs +9 -1
  85. package/scripts/lib/spiral-carryover.mjs +23 -2
  86. package/scripts/lib/state-md/mission-status.mjs +164 -58
  87. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  88. package/scripts/lib/validate/check-agents.mjs +77 -5
  89. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  90. package/scripts/lib/validate/check-commands.mjs +2 -20
  91. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
  93. package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
  94. package/scripts/lib/validate/check-rules.mjs +153 -9
  95. package/scripts/lib/validate/check-skills.mjs +191 -0
  96. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  97. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  98. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  99. package/scripts/lib/validate/check-unwired-features.mjs +219 -11
  100. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  101. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  102. package/scripts/lib/validate/tier-inference.mjs +46 -8
  103. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  104. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  105. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  106. package/scripts/lib/vault-mirror/process.mjs +264 -31
  107. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  108. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  109. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  110. package/scripts/lib/vcs-repo-spec.mjs +680 -30
  111. package/scripts/lib/wave-resource-gate.mjs +67 -73
  112. package/scripts/materialize-wave-scope.mjs +281 -0
  113. package/scripts/print-learnings-index.mjs +30 -3
  114. package/scripts/release.mjs +983 -107
  115. package/scripts/run-quality-gate.mjs +14 -0
  116. package/scripts/site-numbers.mjs +1049 -0
  117. package/scripts/validate-plugin.mjs +64 -0
  118. package/scripts/validate-wave-scope.mjs +286 -12
  119. package/scripts/vault-backfill.mjs +32 -5
  120. package/scripts/vault-mirror.mjs +26 -1
  121. package/skills/_shared/monitor-patterns.md +24 -4
  122. package/skills/_shared/parallel-aware-auq.md +30 -24
  123. package/skills/_shared/parallel-aware-preamble.md +31 -2
  124. package/skills/_shared/state-ownership.md +49 -6
  125. package/skills/bootstrap/SKILL.md +2 -1
  126. package/skills/brainstorm/SKILL.md +18 -18
  127. package/skills/brainstorm/soul.md +12 -0
  128. package/skills/claude-md-drift-check/SKILL.md +9 -1
  129. package/skills/debug/SKILL.md +4 -1
  130. package/skills/discovery/SKILL.md +28 -24
  131. package/skills/discovery/issue-templates.md +4 -4
  132. package/skills/discovery/probes-code.md +2 -2
  133. package/skills/discovery/probes-feature.md +6 -6
  134. package/skills/discovery/probes-infra.md +2 -2
  135. package/skills/discovery/probes-session.md +5 -5
  136. package/skills/dispatcher/SKILL.md +10 -1
  137. package/skills/eli5/SKILL.md +43 -0
  138. package/skills/evolve/SKILL.md +8 -9
  139. package/skills/frontmatter-guard/SKILL.md +9 -1
  140. package/skills/gitlab-ops/SKILL.md +73 -59
  141. package/skills/gitlab-portfolio/SKILL.md +10 -1
  142. package/skills/grill/SKILL.md +6 -6
  143. package/skills/grill/soul.md +16 -0
  144. package/skills/memory-cleanup/SKILL.md +20 -7
  145. package/skills/npm-publish/SKILL.md +23 -51
  146. package/skills/peekaboo-driver/SKILL.md +3 -3
  147. package/skills/persona-panel/SKILL.md +3 -1
  148. package/skills/plan/SKILL.md +18 -16
  149. package/skills/plan/mode-feature.md +1 -1
  150. package/skills/plan/mode-new.md +42 -12
  151. package/skills/plan/soul.md +12 -0
  152. package/skills/reconcile/SKILL.md +3 -3
  153. package/skills/repo-audit/SKILL.md +10 -1
  154. package/skills/session-end/SKILL.md +97 -22
  155. package/skills/session-end/metrics-collection.md +1 -1
  156. package/skills/session-end/phase-3-6-tail.md +37 -2
  157. package/skills/session-end/session-metrics-write.md +4 -10
  158. package/skills/session-plan/SKILL.md +2 -2
  159. package/skills/session-plan/wave-template.md +1 -1
  160. package/skills/session-start/SKILL.md +82 -36
  161. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  162. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  163. package/skills/session-start/soul.md +110 -0
  164. package/skills/spinout/SKILL.md +5 -1
  165. package/skills/sunset-review/SKILL.md +11 -1
  166. package/skills/test-runner/SKILL.md +2 -2
  167. package/skills/tmux-layout/SKILL.md +7 -2
  168. package/skills/using-orchestrator/SKILL.md +1 -1
  169. package/skills/vault-mirror/SKILL.md +10 -1
  170. package/skills/vault-sync/SKILL.md +10 -1
  171. package/skills/vault-sync/validator.mjs +55 -6
  172. package/skills/wave-executor/wave-loop.md +64 -12
  173. package/skills/write-executable-plan/SKILL.md +6 -6
  174. package/scripts/lib/mission-status-schema.mjs +0 -114
  175. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  176. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  177. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  178. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  179. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  180. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  181. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  182. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  183. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  184. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  185. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  186. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  187. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  188. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  189. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  190. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  191. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  192. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  193. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  194. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  195. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  196. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  197. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  198. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  199. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  200. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  201. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  202. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
@@ -0,0 +1,965 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-vcs-repo-flag.mjs — census of `gh`/`glab` invocations that omit `--repo`/`-R`.
4
+ *
5
+ * ## The defect class (issue #971)
6
+ *
7
+ * `gh` and `glab` resolve their target repository from the AMBIENT git remote of
8
+ * the process's cwd whenever `--repo`/`-R` is absent. That is invisible and
9
+ * correct in an operator's own checkout, and wrong everywhere the cwd is not the
10
+ * repo the command means: a sibling worktree, an `/autopilot` child process, a
11
+ * dispatched wave-agent, a consumer repo that copied a template, or any host
12
+ * whose `origin` points at a fork. The failure is silent — the command succeeds
13
+ * against the WRONG project. `scripts/lib/vcs-repo-spec.mjs` exists precisely to
14
+ * produce the spec such a call site should pass; this check finds the call sites
15
+ * that never learned to.
16
+ *
17
+ * ## Mode: WARN, never blocking
18
+ *
19
+ * Findings print as `WARN:` and the runner returns 0. `FAIL:` is reserved for
20
+ * the tool-error path (an unreadable tree). This is load-bearing, not
21
+ * stylistic: `scripts/validate-plugin.mjs` tallies `^[ ]{2}FAIL:` lines from
22
+ * EVERY sub-check into a module-wide counter and exits 1 if that counter is
23
+ * non-zero — the sub-check's own exit code is deliberately discarded for
24
+ * WARN-only checks. A single `FAIL:` line here would therefore red the whole
25
+ * validator (and with it three CI-reachable test files) on a census that was
26
+ * ~40 findings on the day it landed. Warn first, ratchet later once the Wave-3
27
+ * sweep has drained the backlog.
28
+ *
29
+ * ## Classification, and why the naive grep is >50% noise
30
+ *
31
+ * A broad `grep -n 'gh\|glab'` over this repo returns ~244 line matches; only a
32
+ * minority are invocations. Four filters do the separating, each measured:
33
+ *
34
+ * 1. **Markdown: a fence, or it is prose.** A CommonMark fence state machine
35
+ * (both ``` and ~~~, nesting-aware via marker length) gates every markdown
36
+ * match. Only ```bash / ```sh / ```shell / ```console fences are candidates.
37
+ * A match with no fence at all — a table cell, a bullet, running text — is
38
+ * prose. A non-shell fence (```js, ```yaml) is prose.
39
+ * 2. **Shell comments inside a shell fence are prose** (`^\s*#`).
40
+ * 3. **JS/MJS: comment lines are prose** (`^\s*(//|/*|*)`), and a match inside
41
+ * a string literal counts only when the command STARTS the literal (after
42
+ * optional whitespace) AND the literal carries no `${` interpolation. That
43
+ * second half is what separates
44
+ * `' glab ci status --pipeline-id LATEST'` (a real command handed to a
45
+ * shell) from `` `glab note add spawn error: ${err}` `` (a log message).
46
+ * 4. **The first token after the CLI must be a REAL top-level subcommand** of
47
+ * that CLI, measured from `gh --help` / `glab --help` (gh 2.86.0,
48
+ * glab 1.91.0, probed 2026-08-14). This alone kills the prose residue that
49
+ * survives filters 1–3 — `glab or gh`, `glab output could`, `glab exited`,
50
+ * `glab not found`, `glab is`, `glab then` were all live matches in the raw
51
+ * census.
52
+ *
53
+ * ## D-NA — where `--repo` does not exist, so its absence is not a defect
54
+ *
55
+ * Every entry below was probed against the installed binaries on 2026-08-14
56
+ * (gh 2.86.0, glab 1.91.0) by grepping the subcommand's own `--help` for
57
+ * `-R,? --repo` and, for the disputed cases, by executing the flag:
58
+ *
59
+ * - `gh api` / `glab api` — neither documents `--repo`; both document
60
+ * `--hostname`. `gh api -R x /user` → `unknown shorthand flag: 'R'`.
61
+ * `glab` accepts `-R` here only because it is an inherited root flag, and it
62
+ * changes nothing: the endpoint path IS the target. The instrument for these
63
+ * is `resolveRepoHost()` — see `scripts/lib/vcs-repo-spec.mjs` § hostname.
64
+ * - `gh repo <*>` — `gh repo view -R cli/cli` → `unknown shorthand flag: 'R'`.
65
+ * The `repo` group takes a POSITIONAL `[<repository>]`.
66
+ * - `gh auth`/`gh config`/`gh extension`/`gh alias`/`gh completion`/`gh status`
67
+ * and `glab auth`/`glab config`/`glab group`/`glab completion`/`glab alias`/
68
+ * `glab check-update`/`glab version`/`glab help` — host- or client-scoped,
69
+ * no repo concept (`gh auth status -R x` → `unknown shorthand flag: 'R'`).
70
+ *
71
+ * **`glab repo <*>` is deliberately NOT D-NA, and the asymmetry with `gh repo`
72
+ * is real.** `glab repo view -R gitlab-org/cli` is ACCEPTED (it produced a
73
+ * `404 Not Found` from the configured host, i.e. the flag resolved the target)
74
+ * because `-R/--repo` is a persistent root flag on `glab` — it does not appear
75
+ * in `glab repo view --help`'s own FLAGS block, so a help-text-only census
76
+ * would get this backwards.
77
+ *
78
+ * ## Conditional `--repo` — where the flag exists but REQUIRES a positional
79
+ *
80
+ * A third class sits between "applicable" and D-NA: subcommands whose positional
81
+ * is OPTIONAL (it defaults to the current branch's PR) but becomes MANDATORY the
82
+ * moment `-R` appears. Adding `-R` to such a call without also pinning a
83
+ * PR/branch does not harden it — it BREAKS it at runtime. Reporting these is
84
+ * therefore worse than useless: the "fix" is the regression.
85
+ *
86
+ * Measured 2026-08-14 against gh 2.86.0, one probe per subcommand, `cwd=/tmp`
87
+ * (no git remote, so nothing ambient can mask the result):
88
+ *
89
+ * gh pr checks -R cli/cli → argument required when using the `--repo` flag
90
+ * gh pr checks -R cli/cli trunk → no pull requests found for branch "trunk" (flag accepted)
91
+ * gh pr view -R cli/cli → argument required when using the --repo flag
92
+ * gh pr diff -R cli/cli → argument required when using the `--repo` flag
93
+ * gh pr ready -R cli/cli → argument required when using the --repo flag
94
+ * gh pr merge -R cli/cli → argument required when using the --repo flag
95
+ * gh pr comment -R cli/cli → argument required when using the --repo flag
96
+ *
97
+ * The rule is POSITIONAL-DEPENDENT, never subcommand-blanket: `gh pr checks 123`
98
+ * and `gh pr view 4711` both accept `-R` happily, so a bare one of those is
99
+ * still a genuine finding. Only the no-positional form is excluded.
100
+ *
101
+ * Three neighbouring shapes were probed and deliberately EXCLUDED from the table
102
+ * because their error is Cobra arity, not a `--repo` interaction — the positional
103
+ * is mandatory there with or without the flag, so no `-R` advice is being
104
+ * suppressed: `gh pr close`/`reopen` (`cannot close pull request: number, url,
105
+ * or branch required` / `accepts 1 arg(s), received 0`), every `gh issue <*>`
106
+ * (`gh issue view -R cli/cli` → `accepts 1 arg(s), received 0`), and
107
+ * `gh release delete`/`edit` (same arity message). `gh pr list`, `gh pr status`,
108
+ * `gh issue list` and `gh release view`/`list` all RAN against `cli/cli` with
109
+ * `-R` and no positional — those stay fully applicable.
110
+ *
111
+ * **The glab side of this table is empty by measurement, not by omission.**
112
+ * `glab mr view -R gitlab-org/cli` and `glab ci status -R gitlab-org/cli` (no
113
+ * positional, glab 1.91.0, 2026-08-14) were both ACCEPTED by the CLI and failed
114
+ * downstream at the configured host (exit 1, `ERROR` block), never at flag
115
+ * parsing. glab has no equivalent of gh's conditional-positional check.
116
+ *
117
+ * Note what a help-text-derived rule would have concluded here: `gh pr checks
118
+ * --help` lists `-R, --repo` under INHERITED FLAGS with no caveat whatsoever.
119
+ * Reading the help would have marked this call site as a plain finding. Only
120
+ * executing the binary shows the constraint — same lesson as the `glab repo`
121
+ * asymmetry above, in the opposite direction.
122
+ *
123
+ * ## Blind spot — a `-R` the CLI will REJECT
124
+ *
125
+ * Every entry in the gap list below is an OMISSION: a call site the census does
126
+ * not see. This one is not, so it is named apart from them.
127
+ * `repoFlagBlockedByMissingPositional()` is consulted only AFTER the
128
+ * `--repo`-present branch (see its own docstring for why), so the
129
+ * conditional-positional rule can only ever describe calls that LACK the flag —
130
+ * the harmless half of that table. The harmful half is invisible:
131
+ * `gh pr checks -R o/r --watch` carries `-R` with no positional, which gh 2.86.0
132
+ * rejects at parse time (`argument required when using the --repo flag`, exit 1,
133
+ * probed 2026-08-14). This census counts it as `withFlag` — as already hardened.
134
+ *
135
+ * Measured 2026-08-14 against this tree: **0 such call sites**. A probe copy of
136
+ * this file, patched to skip the repo flag's own value before scanning the
137
+ * remaining tokens for a positional, ran `inspectVcsRepoFlag(repoRoot)` and
138
+ * reported an empty list — so the blind spot is inert here today, and detecting
139
+ * it is ~8 lines.
140
+ *
141
+ * It is nonetheless NOT reported, for a contract reason rather than a detection
142
+ * one. A finding here means "add `--repo`"; that call's fix is the opposite
143
+ * ("name the PR, or drop the flag"), and the `kind` enum, the WARN message text
144
+ * and the PASS line's "N bare" all speak only the first language. Folding a
145
+ * loud, exit-1 failure into a census built for SILENT wrong-repo failures would
146
+ * hand the sweep a worklist whose entries prescribe opposite edits, and would
147
+ * buy nothing the command's own first run does not already say. That class wants
148
+ * its own check; it is routed as a follow-up rather than patched in here.
149
+ *
150
+ * ## Named coverage gaps (v1) — read these before quoting the count
151
+ *
152
+ * - **Variable argv arrays are not resolved.** `spawnSync('glab', glabArgs)`
153
+ * cannot be judged from the call site; only a LITERAL array is inspected.
154
+ * Measured 2026-08-14: 5 direct `execFile*`/`spawn*` sites naming `gh`/`glab`
155
+ * in `scripts/`+`hooks/`, 4 of them variable-args. The count is reported as
156
+ * `unresolvedArgv` in the summary so the hole is visible in the output, not
157
+ * only in this comment.
158
+ * - **Interpolated command templates are skipped** (filter 3). A genuine
159
+ * `` `glab issue view ${n}` `` is invisible. The alternative — treating every
160
+ * interpolated string as a command — readmits the four log-message templates
161
+ * this repo actually has, which is the worse trade at v1. The mirror hole:
162
+ * an INTERPOLATION-FREE log string that happens to open with a real
163
+ * subcommand (`log('glab issue list failed')`) would be reported as a call
164
+ * site. This repo has none today; filter 4 catches the common shapes
165
+ * (`glab note add …` is not a real subcommand).
166
+ * - **Backtick-command bullets in skill prose are classified as prose**, per
167
+ * filter 1. `skills/discovery/SKILL.md` and `skills/plan/SKILL.md` both carry
168
+ * list items whose inline-code command the coordinator copies verbatim —
169
+ * formally prose, operationally an instruction. Calling them prose is a
170
+ * DELIBERATE choice: the alternative (treating inline code as a command)
171
+ * matched every `` `glab` `` mention in every rule file. The Wave-3 sweep
172
+ * should read those two files by hand.
173
+ * - **A shell fence NESTED inside a wider (4+ backtick) fence is content, not a
174
+ * fence** — CommonMark-correct, but it means its commands are invisible.
175
+ * Measured 2026-08-14: `grep -rn '^\s*\x60\x60\x60\x60' skills docs agents
176
+ * commands .claude templates` → 0 matches, so the gap is inert today.
177
+ * - **`tests/` is excluded from the corpus.** A test that asserts on a command
178
+ * string is not a sweep target; including them added 42 raw matches of pure
179
+ * noise.
180
+ * - **Only `.md` and `.mjs`/`.js`/`.cjs` are scanned.** `.sh`, `.yml`, and
181
+ * `.gitlab-ci.yml` are outside v1 (raw census: 0 matches there today).
182
+ * - **A leading flag before the subcommand is not resolved.** `glab -R x issue
183
+ * list` yields no subcommand token and is counted as `skippedLeadingFlag`,
184
+ * never as a finding. Measured 0 occurrences in this repo.
185
+ * - Files are read with `readFileSync`, never a `grep` spawn: one NUL byte
186
+ * makes a text file invisible to grep-based audits (see
187
+ * `.claude/rules/anti-pattern-a-nul-byte-in-a-tracked-production-file-...md`),
188
+ * which would silently drop call sites and understate the census.
189
+ *
190
+ * Import-safety: importing this module exposes the inspector and runner only;
191
+ * the CLI path is guarded at the bottom of the file.
192
+ */
193
+
194
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
195
+ import path from 'node:path';
196
+ import { pathToFileURL } from 'node:url';
197
+
198
+ /** Directories whose content is scanned. Root-level `*.md` is added separately. */
199
+ const SCAN_DIRS = Object.freeze([
200
+ '.claude',
201
+ 'agents',
202
+ 'commands',
203
+ 'docs',
204
+ 'hooks',
205
+ 'scripts',
206
+ 'skills',
207
+ 'templates',
208
+ ]);
209
+
210
+ /** Directory names excluded at any depth. `tests` — see § Named coverage gaps. */
211
+ const EXCLUDED_DIRS = Object.freeze([
212
+ 'node_modules',
213
+ '.git',
214
+ 'tests',
215
+ 'test',
216
+ '__tests__',
217
+ 'coverage',
218
+ 'dist',
219
+ ]);
220
+
221
+ /** Markdown extensions (fence state machine applies). */
222
+ const DOC_EXTENSIONS = Object.freeze(['.md']);
223
+
224
+ /** Code extensions (comment + string-literal rules apply). */
225
+ const CODE_EXTENSIONS = Object.freeze(['.mjs', '.js', '.cjs']);
226
+
227
+ /** Fence languages whose body is shell. Anything else is prose. */
228
+ const SHELL_LANGS = Object.freeze(new Set(['bash', 'sh', 'shell', 'console', 'zsh']));
229
+
230
+ /**
231
+ * Real top-level subcommands, harvested from `gh --help` / `glab --help`
232
+ * (gh 2.86.0 / glab 1.91.0, 2026-08-14). Filter 4: a token that is not in this
233
+ * set is prose, not a subcommand.
234
+ */
235
+ const SUBCOMMANDS = Object.freeze({
236
+ gh: Object.freeze(
237
+ new Set([
238
+ 'actions', 'agent-task', 'alias', 'api', 'attestation', 'auth', 'browse', 'cache',
239
+ 'codespace', 'completion', 'config', 'copilot', 'environment', 'extension', 'gist',
240
+ 'gpg-key', 'issue', 'label', 'org', 'pr', 'project', 'release', 'repo', 'ruleset',
241
+ 'run', 'search', 'secret', 'ssh-key', 'status', 'variable', 'workflow',
242
+ ]),
243
+ ),
244
+ glab: Object.freeze(
245
+ new Set([
246
+ 'alias', 'api', 'attestation', 'auth', 'changelog', 'check-update', 'ci', 'cluster',
247
+ 'completion', 'config', 'deploy-key', 'duo', 'gpg-key', 'help', 'incident', 'issue',
248
+ 'iteration', 'job', 'label', 'mcp', 'milestone', 'mr', 'opentofu', 'pipeline',
249
+ 'release', 'repo', 'runner', 'runner-controller', 'schedule', 'securefile', 'snippet',
250
+ 'ssh-key', 'stack', 'token', 'user', 'variable', 'version', 'work-items',
251
+ ]),
252
+ ),
253
+ });
254
+
255
+ /**
256
+ * Subcommand groups where `--repo`/`-R` does not apply. See § D-NA in the header
257
+ * for the per-entry probe evidence. Keyed on the FIRST subcommand token.
258
+ */
259
+ const NOT_APPLICABLE = Object.freeze({
260
+ gh: Object.freeze(
261
+ new Set(['api', 'repo', 'auth', 'config', 'extension', 'alias', 'completion', 'status']),
262
+ ),
263
+ glab: Object.freeze(
264
+ new Set(['api', 'auth', 'config', 'group', 'completion', 'alias', 'check-update', 'version', 'help']),
265
+ ),
266
+ });
267
+
268
+ /**
269
+ * Subcommand groups whose SECOND positional argument IS the repository, so an
270
+ * explicit positional makes the invocation unambiguous without `--repo`.
271
+ * `glab repo view group/project` is as explicit as `glab repo view -R
272
+ * group/project`; `glab repo view --output json` is not. (`gh repo` is D-NA —
273
+ * it has no `-R` at all — so this table only ever needs the glab side.)
274
+ */
275
+ const POSITIONAL_REPO_GROUPS = Object.freeze({
276
+ gh: Object.freeze(new Set()),
277
+ glab: Object.freeze(new Set(['repo'])),
278
+ });
279
+
280
+ /**
281
+ * `<group> <subcommand>` pairs where `--repo`/`-R` is CONDITIONAL: legal with a
282
+ * positional, rejected without one. Keyed `cli → group → Set(subcommand)`. See
283
+ * § Conditional `--repo` in the header for the per-entry probe transcript; the
284
+ * glab side is empty by measurement.
285
+ */
286
+ const REPO_FLAG_NEEDS_POSITIONAL = Object.freeze({
287
+ gh: Object.freeze({
288
+ pr: Object.freeze(new Set(['checks', 'view', 'diff', 'ready', 'merge', 'comment'])),
289
+ }),
290
+ glab: Object.freeze({}),
291
+ });
292
+
293
+ /**
294
+ * Does this token stand in for a positional argument? A flag does not, and
295
+ * neither does a shell redirect — `gh pr checks 2>&1` names no PR, so reading
296
+ * `2>&1` as one would both hide a real finding here and manufacture a phantom
297
+ * `explicitPositional` for `glab repo view 2>&1`.
298
+ *
299
+ * @param {string | undefined} token
300
+ * @returns {boolean}
301
+ */
302
+ function looksPositional(token) {
303
+ return Boolean(token) && !token.startsWith('-') && !/^\d*[<>]/.test(token);
304
+ }
305
+
306
+ /**
307
+ * Is `-R` unavailable here because the conditional-positional rule bites — i.e.
308
+ * would adding the flag BREAK this call rather than harden it?
309
+ *
310
+ * Two deliberate conservatisms, both erring toward reporting rather than
311
+ * silence, because a wrongly-suppressed call site is invisible forever while a
312
+ * wrongly-reported one is one sweep read away:
313
+ *
314
+ * 1. Only consulted AFTER the `--repo`-present branch. A call that already
315
+ * carries the flag is never this function's business, and asking here would
316
+ * mean parsing which flags consume a value — `gh pr merge -R o/r 123` puts
317
+ * its positional at index 4, behind `-R`'s argument. What that ordering
318
+ * costs is named in § Blind spot at the top of this file: the `-R`-present,
319
+ * positional-absent call — the one the CLI actually rejects — is invisible.
320
+ * 2. ANY positional-looking token past the subcommand disarms the rule. A flag
321
+ * VALUE (`--json state` → `state`) can be mistaken for a positional; that
322
+ * mistake yields a finding, never a suppression.
323
+ *
324
+ * @param {'gh'|'glab'} cli
325
+ * @param {string} group first subcommand token (`pr`)
326
+ * @param {string | undefined} sub second token (`checks`)
327
+ * @param {string[]} rest every token past the subcommand pair
328
+ * @returns {boolean}
329
+ */
330
+ function repoFlagBlockedByMissingPositional(cli, group, sub, rest) {
331
+ const subs = REPO_FLAG_NEEDS_POSITIONAL[cli][group];
332
+ if (!subs || !sub || !subs.has(sub)) return false;
333
+ return !rest.some(looksPositional);
334
+ }
335
+
336
+ /**
337
+ * A `--repo`/`-R` occurrence. `--repo=x`, `-R x` and `-R"$SPEC"` all count;
338
+ * `--repository` does not (the `\b` blocks it) and neither does a `-R` glued to
339
+ * a bare word (`-Rfoo`), which no call site in this repo uses.
340
+ */
341
+ const REPO_FLAG_RE = /(?<![\w-])(?:--repo\b|--repo=|-R\b|-R(?=["'$]))/;
342
+
343
+ /**
344
+ * Command position: start of line, or after a shell separator / substitution
345
+ * opener, optionally preceded by `VAR=value` assignments.
346
+ */
347
+ const COMMAND_RE = /(?:^|[|;&(]|\$\()\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(gh|glab)(?=\s)/g;
348
+
349
+ /**
350
+ * Slice the text following a matched `gh`/`glab` token into this invocation's
351
+ * segment, and project out the part that is the OUTER command's own argv.
352
+ *
353
+ * Two jobs in one walk, because they share the concept a character-class stop
354
+ * regex cannot express — where a COMMAND SUBSTITUTION begins and ends:
355
+ *
356
+ * 1. **Where the invocation ends.** `|`, `;`, `||`, `&&` and `)` end a command
357
+ * only at substitution depth 0 (`&` alone never does — `2>&1`). A `)`-blind
358
+ * stop cuts `glab issue create --title "$(date)" -R g/p` before its flag and
359
+ * reports an already-correct call site.
360
+ * 2. **Whose `-R` is this.** Everything inside `$( … )` or backticks is a
361
+ * DIFFERENT command's argv. `glab issue list $(grep -R pattern src)` carries
362
+ * no repo flag at all — the `-R` is grep's (`cp`, `ls`, `rsync`, `chmod`
363
+ * spell it the same way). Crediting it to the outer call is the one failure
364
+ * direction this census must not have: unlike every gap named in the header,
365
+ * which merely omits a call site, this reports a BARE invocation as already
366
+ * hardened — and the `0 bare` that results is the number a sweep declares
367
+ * itself finished on. A `-R` that sits OUTSIDE the substitution is still the
368
+ * outer command's own, substitution-valued or not: `-R $(cat repo.txt)`
369
+ * counts.
370
+ *
371
+ * The outer projection replaces each substitution with a single space rather
372
+ * than deleting it, so that closing the gap in `-$(x)R` cannot weld a `-R` into
373
+ * existence.
374
+ *
375
+ * Two named ceilings, both landing on today's verdict so neither is a
376
+ * regression; revisit if a call site in this corpus ever takes either shape.
377
+ * `$((` arithmetic expansion reads as a substitution plus a plain paren, so a
378
+ * segment containing one is cut at its second `)` — exactly where the previous
379
+ * `)`-stop cut it. Quote state is not tracked: `'$(x)'` is a literal string to a
380
+ * real shell and a substitution to this walk, which reaches the same verdict
381
+ * (the `-R` inside is not this command's flag) by a more generous route.
382
+ *
383
+ * @param {string} rest text following the matched CLI token
384
+ * @returns {{segment: string, outer: string}} full segment, and its depth-0 projection
385
+ */
386
+ function sliceInvocation(rest) {
387
+ let segment = '';
388
+ let outer = '';
389
+ let depth = 0;
390
+ let inBacktick = false;
391
+
392
+ for (let index = 0; index < rest.length; index += 1) {
393
+ const char = rest[index];
394
+ const pair = rest.slice(index, index + 2);
395
+
396
+ if (depth === 0 && !inBacktick) {
397
+ if (pair === '||' || pair === '&&' || char === '|' || char === ';' || char === ')') break;
398
+ if (pair === '$(') {
399
+ depth = 1;
400
+ segment += pair;
401
+ outer += ' ';
402
+ index += 1;
403
+ continue;
404
+ }
405
+ if (char === '`') {
406
+ inBacktick = true;
407
+ segment += char;
408
+ outer += ' ';
409
+ continue;
410
+ }
411
+ segment += char;
412
+ outer += char;
413
+ continue;
414
+ }
415
+
416
+ segment += char;
417
+ if (inBacktick) {
418
+ if (char === '`') inBacktick = false;
419
+ continue;
420
+ }
421
+ if (pair === '$(') {
422
+ depth += 1;
423
+ segment += '(';
424
+ index += 1;
425
+ continue;
426
+ }
427
+ if (char === ')') depth -= 1;
428
+ }
429
+
430
+ return { segment, outer };
431
+ }
432
+
433
+ /** Snippet budget — see § stdout budget at the CLI guard. */
434
+ const SNIPPET_MAX = 200;
435
+
436
+ /**
437
+ * @typedef {{
438
+ * kind: 'missing-repo-flag-doc' | 'missing-repo-flag-code' | 'tool-error',
439
+ * file: string,
440
+ * line: number,
441
+ * cli: 'gh' | 'glab' | '-',
442
+ * command: string,
443
+ * message: string,
444
+ * }} Finding
445
+ */
446
+
447
+ /**
448
+ * @typedef {{
449
+ * applicable: number,
450
+ * notApplicable: number,
451
+ * positionalRequired: number,
452
+ * withFlag: number,
453
+ * explicitPositional: number,
454
+ * unknownSubcommand: number,
455
+ * skippedLeadingFlag: number,
456
+ * }} Tally
457
+ */
458
+
459
+ /**
460
+ * Recursively collect scannable files, skipping symlinks and excluded dirs.
461
+ *
462
+ * @param {string} directory absolute directory path
463
+ * @param {string[]} [acc]
464
+ * @returns {string[]} absolute file paths
465
+ */
466
+ function walk(directory, acc = []) {
467
+ if (!existsSync(directory)) return acc;
468
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
469
+ if (entry.isSymbolicLink()) continue;
470
+ if (EXCLUDED_DIRS.includes(entry.name)) continue;
471
+ const fullPath = path.join(directory, entry.name);
472
+ if (entry.isDirectory()) {
473
+ walk(fullPath, acc);
474
+ continue;
475
+ }
476
+ if (!entry.isFile()) continue;
477
+ const extension = path.extname(entry.name);
478
+ if (DOC_EXTENSIONS.includes(extension) || CODE_EXTENSIONS.includes(extension)) acc.push(fullPath);
479
+ }
480
+ return acc;
481
+ }
482
+
483
+ /** @param {string} text @returns {string} clamped, single-line snippet */
484
+ function clampSnippet(text) {
485
+ const flat = text.replace(/\s+/g, ' ').trim();
486
+ return flat.length > SNIPPET_MAX ? `${flat.slice(0, SNIPPET_MAX - 1)}…` : flat;
487
+ }
488
+
489
+ /**
490
+ * Extract every `gh`/`glab` invocation from one logical (continuation-joined)
491
+ * command line, classifying each against the subcommand + D-NA tables.
492
+ *
493
+ * @param {string} text the logical command line
494
+ * @param {Tally} tally mutated in place
495
+ * @returns {{cli: 'gh'|'glab', command: string, segment: string}[]} findings-worthy invocations
496
+ */
497
+ export function extractBareInvocations(text, tally) {
498
+ /** @type {{cli: 'gh'|'glab', command: string, segment: string}[]} */
499
+ const bare = [];
500
+ COMMAND_RE.lastIndex = 0;
501
+ /** @type {RegExpExecArray | null} */
502
+ let matched;
503
+ while ((matched = COMMAND_RE.exec(text)) !== null) {
504
+ const cli = /** @type {'gh'|'glab'} */ (matched[1]);
505
+ const { segment, outer } = sliceInvocation(text.slice(matched.index + matched[0].length));
506
+
507
+ const tokens = segment.trim().split(/\s+/).filter(Boolean);
508
+ if (tokens.length === 0) continue;
509
+ if (tokens[0].startsWith('-')) {
510
+ tally.skippedLeadingFlag += 1;
511
+ continue;
512
+ }
513
+ const head = tokens[0];
514
+ if (!SUBCOMMANDS[cli].has(head)) {
515
+ tally.unknownSubcommand += 1;
516
+ continue;
517
+ }
518
+ if (NOT_APPLICABLE[cli].has(head)) {
519
+ tally.notApplicable += 1;
520
+ continue;
521
+ }
522
+
523
+ tally.applicable += 1;
524
+ // `outer`, never `segment`: a `-R` inside a command substitution belongs to
525
+ // the inner command — see `sliceInvocation` job 2.
526
+ if (REPO_FLAG_RE.test(outer)) {
527
+ tally.withFlag += 1;
528
+ continue;
529
+ }
530
+ if (repoFlagBlockedByMissingPositional(cli, head, tokens[1], tokens.slice(2))) {
531
+ tally.positionalRequired += 1;
532
+ continue;
533
+ }
534
+ if (POSITIONAL_REPO_GROUPS[cli].has(head) && looksPositional(tokens[2])) {
535
+ tally.explicitPositional += 1;
536
+ continue;
537
+ }
538
+ const second = tokens[1] && !tokens[1].startsWith('-') ? ` ${tokens[1]}` : '';
539
+ bare.push({ cli, command: `${cli} ${head}${second}`, segment });
540
+ }
541
+ return bare;
542
+ }
543
+
544
+ /**
545
+ * Join `\`-continuation lines into logical lines, keeping the START line number.
546
+ *
547
+ * @param {{line: number, text: string}[]} entries
548
+ * @returns {{line: number, text: string}[]}
549
+ */
550
+ function joinContinuations(entries) {
551
+ /** @type {{line: number, text: string}[]} */
552
+ const joined = [];
553
+ for (let index = 0; index < entries.length; index += 1) {
554
+ const { line } = entries[index];
555
+ let { text } = entries[index];
556
+ while (/\\\s*$/.test(text) && index + 1 < entries.length && entries[index + 1].line === entries[index].line + 1) {
557
+ index += 1;
558
+ text = `${text.replace(/\\\s*$/, '')} ${entries[index].text.trim()}`;
559
+ }
560
+ joined.push({ line, text });
561
+ }
562
+ return joined;
563
+ }
564
+
565
+ /**
566
+ * Scan a markdown file: fence state machine → shell fences only → drop shell
567
+ * comments → join continuations → classify.
568
+ *
569
+ * @param {string} relative repo-relative path
570
+ * @param {string} body file content
571
+ * @param {Tally} tally mutated in place
572
+ * @returns {Finding[]}
573
+ */
574
+ export function scanMarkdown(relative, body, tally) {
575
+ /** @type {Finding[]} */
576
+ const findings = [];
577
+ /** @type {{line: number, text: string}[]} */
578
+ const shellLines = [];
579
+ /** @type {{marker: string, length: number, shell: boolean} | null} */
580
+ let fence = null;
581
+
582
+ const lines = body.split('\n');
583
+ for (let index = 0; index < lines.length; index += 1) {
584
+ const raw = lines[index];
585
+ const fenceMatch = raw.match(/^\s*(`{3,}|~{3,})\s*([A-Za-z0-9_+-]*)/);
586
+ if (fenceMatch) {
587
+ const marker = fenceMatch[1][0];
588
+ const length = fenceMatch[1].length;
589
+ const lang = fenceMatch[2].toLowerCase();
590
+ if (fence === null) {
591
+ fence = { marker, length, shell: SHELL_LANGS.has(lang) };
592
+ continue;
593
+ }
594
+ // A closing fence uses the same char, is at least as long, and has no info string.
595
+ if (marker === fence.marker && length >= fence.length && lang === '') {
596
+ fence = null;
597
+ continue;
598
+ }
599
+ // Otherwise it is fence content (a nested fence inside a wider one).
600
+ }
601
+ if (fence === null || !fence.shell) continue;
602
+ const stripped = raw.replace(/^\s*[$❯>]\s+/, '');
603
+ if (/^\s*#/.test(stripped)) continue;
604
+ shellLines.push({ line: index + 1, text: stripped });
605
+ }
606
+
607
+ for (const entry of joinContinuations(shellLines)) {
608
+ for (const hit of extractBareInvocations(entry.text, tally)) {
609
+ findings.push({
610
+ kind: 'missing-repo-flag-doc',
611
+ file: relative,
612
+ line: entry.line,
613
+ cli: hit.cli,
614
+ command: hit.command,
615
+ message:
616
+ `\`${hit.command}\` runs without --repo/-R — the target project comes from the ambient ` +
617
+ `cwd remote: ${clampSnippet(`${hit.cli}${hit.segment}`)}`,
618
+ });
619
+ }
620
+ }
621
+ return findings;
622
+ }
623
+
624
+ /**
625
+ * A `gh`/`glab` token that OPENS a string literal (quote, optional whitespace,
626
+ * then the CLI name). Filter 3: only such a literal is a shell command handed to
627
+ * something; a CLI name in the middle of a string is a message about a command.
628
+ */
629
+ const CODE_STRING_CMD_RE = /(['"`])\s*(?=(?:gh|glab)\s)/g;
630
+
631
+ /**
632
+ * Scan a literal `execFile*`/`spawn*` argv array. A non-literal args argument is
633
+ * counted as `unresolvedArgv` and never judged.
634
+ *
635
+ * @param {string} body file content
636
+ * @returns {{literals: {index: number, cli: 'gh'|'glab', args: string[]}[], unresolved: number}}
637
+ */
638
+ export function scanArgvCalls(body) {
639
+ const callRe = /\b(?:execFileSync|execFile|spawnSync|spawn)\s*\(\s*['"](gh|glab)['"]\s*,\s*/g;
640
+ /** @type {{index: number, cli: 'gh'|'glab', args: string[]}[]} */
641
+ const literals = [];
642
+ let unresolved = 0;
643
+ /** @type {RegExpExecArray | null} */
644
+ let matched;
645
+ while ((matched = callRe.exec(body)) !== null) {
646
+ const after = body.slice(matched.index + matched[0].length);
647
+ if (!after.startsWith('[')) {
648
+ unresolved += 1;
649
+ continue;
650
+ }
651
+ let depth = 0;
652
+ let end = -1;
653
+ for (let index = 0; index < after.length; index += 1) {
654
+ const char = after[index];
655
+ if (char === '[') depth += 1;
656
+ else if (char === ']') {
657
+ depth -= 1;
658
+ if (depth === 0) {
659
+ end = index;
660
+ break;
661
+ }
662
+ }
663
+ }
664
+ if (end === -1) {
665
+ unresolved += 1;
666
+ continue;
667
+ }
668
+ const inner = after.slice(1, end);
669
+ if (inner.includes('...') || /\$\{/.test(inner)) {
670
+ unresolved += 1;
671
+ continue;
672
+ }
673
+ const args = [...inner.matchAll(/['"]([^'"]*)['"]/g)].map((entry) => entry[1]);
674
+ literals.push({ index: matched.index, cli: /** @type {'gh'|'glab'} */ (matched[1]), args });
675
+ }
676
+ return { literals, unresolved };
677
+ }
678
+
679
+ /**
680
+ * Scan a JS/MJS file: comment lines are prose; a match counts only when it
681
+ * starts an interpolation-free string literal. Literal argv arrays are judged
682
+ * separately.
683
+ *
684
+ * @param {string} relative repo-relative path
685
+ * @param {string} body file content
686
+ * @param {Tally} tally mutated in place
687
+ * @returns {{findings: Finding[], unresolvedArgv: number}}
688
+ */
689
+ export function scanCode(relative, body, tally) {
690
+ /** @type {Finding[]} */
691
+ const findings = [];
692
+ const lines = body.split('\n');
693
+
694
+ for (let index = 0; index < lines.length; index += 1) {
695
+ const raw = lines[index];
696
+ if (/^\s*(\/\/|\/\*|\*)/.test(raw)) continue;
697
+ CODE_STRING_CMD_RE.lastIndex = 0;
698
+ /** @type {RegExpExecArray | null} */
699
+ let matched;
700
+ while ((matched = CODE_STRING_CMD_RE.exec(raw)) !== null) {
701
+ const quote = matched[1];
702
+ const contentStart = matched.index + matched[0].length;
703
+ const closeAt = raw.indexOf(quote, contentStart);
704
+ const literal = closeAt === -1 ? raw.slice(contentStart) : raw.slice(contentStart, closeAt);
705
+ if (literal.includes('${')) continue;
706
+ for (const hit of extractBareInvocations(literal.trim(), tally)) {
707
+ findings.push({
708
+ kind: 'missing-repo-flag-code',
709
+ file: relative,
710
+ line: index + 1,
711
+ cli: hit.cli,
712
+ command: hit.command,
713
+ message:
714
+ `\`${hit.command}\` in a shell-command string runs without --repo/-R — the target project ` +
715
+ `comes from the ambient cwd remote: ${clampSnippet(`${hit.cli}${hit.segment}`)}`,
716
+ });
717
+ }
718
+ }
719
+ }
720
+
721
+ const { literals, unresolved } = scanArgvCalls(body);
722
+ for (const call of literals) {
723
+ const head = call.args[0];
724
+ if (!head || head.startsWith('-')) {
725
+ tally.skippedLeadingFlag += 1;
726
+ continue;
727
+ }
728
+ if (!SUBCOMMANDS[call.cli].has(head)) {
729
+ tally.unknownSubcommand += 1;
730
+ continue;
731
+ }
732
+ if (NOT_APPLICABLE[call.cli].has(head)) {
733
+ tally.notApplicable += 1;
734
+ continue;
735
+ }
736
+ tally.applicable += 1;
737
+ if (call.args.some((arg) => arg === '--repo' || arg === '-R' || arg.startsWith('--repo='))) {
738
+ tally.withFlag += 1;
739
+ continue;
740
+ }
741
+ if (repoFlagBlockedByMissingPositional(call.cli, head, call.args[1], call.args.slice(2))) {
742
+ tally.positionalRequired += 1;
743
+ continue;
744
+ }
745
+ if (POSITIONAL_REPO_GROUPS[call.cli].has(head) && looksPositional(call.args[2])) {
746
+ tally.explicitPositional += 1;
747
+ continue;
748
+ }
749
+ const line = body.slice(0, call.index).split('\n').length;
750
+ const second = call.args[1] && !call.args[1].startsWith('-') ? ` ${call.args[1]}` : '';
751
+ findings.push({
752
+ kind: 'missing-repo-flag-code',
753
+ file: relative,
754
+ line,
755
+ cli: call.cli,
756
+ command: `${call.cli} ${head}${second}`,
757
+ message:
758
+ `\`${call.cli} ${head}${second}\` argv array carries no --repo/-R — the target project comes ` +
759
+ `from the ambient cwd remote: ${clampSnippet(call.args.join(' '))}`,
760
+ });
761
+ }
762
+
763
+ return { findings, unresolvedArgv: unresolved };
764
+ }
765
+
766
+ /**
767
+ * Run the full census.
768
+ *
769
+ * @param {string} pluginRoot absolute plugin root
770
+ * @returns {{
771
+ * ok: boolean,
772
+ * summary: {
773
+ * filesScanned: number, docFiles: number, codeFiles: number,
774
+ * applicable: number, notApplicable: number, positionalRequired: number, withFlag: number,
775
+ * explicitPositional: number, unknownSubcommand: number, skippedLeadingFlag: number,
776
+ * unresolvedArgv: number, findings: number,
777
+ * byKind: Record<string, number>,
778
+ * },
779
+ * findings: Finding[],
780
+ * toolError: boolean,
781
+ * }}
782
+ */
783
+ export function inspectVcsRepoFlag(pluginRoot) {
784
+ /** @type {Finding[]} */
785
+ const findings = [];
786
+ /** @type {Tally} */
787
+ const tally = {
788
+ applicable: 0,
789
+ notApplicable: 0,
790
+ positionalRequired: 0,
791
+ withFlag: 0,
792
+ explicitPositional: 0,
793
+ unknownSubcommand: 0,
794
+ skippedLeadingFlag: 0,
795
+ };
796
+ const result = {
797
+ ok: false,
798
+ summary: {
799
+ filesScanned: 0,
800
+ docFiles: 0,
801
+ codeFiles: 0,
802
+ ...tally,
803
+ unresolvedArgv: 0,
804
+ findings: 0,
805
+ /** @type {Record<string, number>} */
806
+ byKind: {},
807
+ },
808
+ findings,
809
+ toolError: false,
810
+ };
811
+
812
+ /** @type {string[]} */
813
+ let files;
814
+ try {
815
+ files = SCAN_DIRS.flatMap((dir) => walk(path.join(pluginRoot, dir)));
816
+ for (const entry of readdirSync(pluginRoot, { withFileTypes: true })) {
817
+ if (entry.isFile() && DOC_EXTENSIONS.includes(path.extname(entry.name))) {
818
+ files.push(path.join(pluginRoot, entry.name));
819
+ }
820
+ }
821
+ files.sort();
822
+ } catch (error) {
823
+ result.toolError = true;
824
+ findings.push({
825
+ kind: 'tool-error',
826
+ file: '-',
827
+ line: 0,
828
+ cli: '-',
829
+ command: '-',
830
+ message: `cannot enumerate the scan corpus: ${error instanceof Error ? error.message : String(error)}`,
831
+ });
832
+ return result;
833
+ }
834
+
835
+ let unresolvedArgv = 0;
836
+ let docFiles = 0;
837
+ let codeFiles = 0;
838
+
839
+ for (const absolute of files) {
840
+ const relative = path.relative(pluginRoot, absolute);
841
+ /** @type {string} */
842
+ let body;
843
+ try {
844
+ body = readFileSync(absolute, 'utf8');
845
+ } catch (error) {
846
+ result.toolError = true;
847
+ findings.push({
848
+ kind: 'tool-error',
849
+ file: relative,
850
+ line: 0,
851
+ cli: '-',
852
+ command: '-',
853
+ message: `cannot read: ${error instanceof Error ? error.message : String(error)}`,
854
+ });
855
+ return result;
856
+ }
857
+ if (!/\b(?:gh|glab)\b/.test(body)) {
858
+ if (DOC_EXTENSIONS.includes(path.extname(absolute))) docFiles += 1;
859
+ else codeFiles += 1;
860
+ continue;
861
+ }
862
+ if (DOC_EXTENSIONS.includes(path.extname(absolute))) {
863
+ docFiles += 1;
864
+ findings.push(...scanMarkdown(relative, body, tally));
865
+ } else {
866
+ codeFiles += 1;
867
+ const scanned = scanCode(relative, body, tally);
868
+ findings.push(...scanned.findings);
869
+ unresolvedArgv += scanned.unresolvedArgv;
870
+ }
871
+ }
872
+
873
+ findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
874
+
875
+ /** @type {Record<string, number>} */
876
+ const byKind = {};
877
+ for (const item of findings) byKind[item.kind] = (byKind[item.kind] ?? 0) + 1;
878
+
879
+ result.summary = {
880
+ filesScanned: files.length,
881
+ docFiles,
882
+ codeFiles,
883
+ ...tally,
884
+ unresolvedArgv,
885
+ findings: findings.length,
886
+ byKind,
887
+ };
888
+ result.ok = findings.length === 0;
889
+ return result;
890
+ }
891
+
892
+ /**
893
+ * Run the human-readable validator CLI.
894
+ *
895
+ * WARN-ONLY: findings print as WARN and still return 0. `FAIL:` is emitted only
896
+ * on the tool-error path — see § Mode in the header for why a single stray
897
+ * `FAIL:` line would red the whole `validate-plugin.mjs` gate.
898
+ *
899
+ * @param {string} pluginRoot absolute plugin root
900
+ * @returns {number} 0 = census completed (with or without findings), 2 = tool error
901
+ */
902
+ export function runCheckVcsRepoFlag(pluginRoot) {
903
+ console.log('--- Check: gh/glab invocations without --repo/-R (WARN-only) ---');
904
+ const inspection = inspectVcsRepoFlag(pluginRoot);
905
+
906
+ if (inspection.toolError) {
907
+ for (const item of inspection.findings) console.log(` FAIL: ${item.file} — ${item.message}`);
908
+ console.log('');
909
+ console.log(`Results: 0 passed, ${inspection.findings.length} failed`);
910
+ return 2;
911
+ }
912
+
913
+ for (const item of inspection.findings) {
914
+ console.log(` WARN: [${item.kind}] ${item.file}:${item.line} — ${item.message}`);
915
+ }
916
+
917
+ const s = inspection.summary;
918
+ console.log(
919
+ ` PASS: censused ${s.filesScanned} file(s) (${s.docFiles} doc + ${s.codeFiles} code) — ` +
920
+ `${s.applicable} repo-scoped invocation(s), ${s.withFlag} already pass --repo/-R, ` +
921
+ `${s.explicitPositional} name the repo positionally, ${s.findings} bare; ` +
922
+ `${s.notApplicable} where --repo does not apply, ${s.positionalRequired} where --repo needs a ` +
923
+ `positional the call omits, ${s.unknownSubcommand} prose match(es) dropped, ` +
924
+ `${s.unresolvedArgv} variable argv array(s) unjudged`,
925
+ );
926
+ console.log('');
927
+ console.log('Results: 1 passed, 0 failed');
928
+ return 0;
929
+ }
930
+
931
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] || '').href;
932
+ if (isMain) {
933
+ const argv = process.argv.slice(2);
934
+ const flags = new Set(argv.filter((a) => a.startsWith('--')));
935
+ const positional = argv.filter((a) => !a.startsWith('--'));
936
+ const usage =
937
+ 'Usage: check-vcs-repo-flag.mjs [<plugin-root>] [--json]\n' +
938
+ ' --json emit the inspection envelope as a single JSON object on stdout\n' +
939
+ 'Exit: 0 census completed (findings are WARN-only) · 1 usage error · 2 tool error';
940
+
941
+ if (flags.has('--help')) {
942
+ console.log(usage);
943
+ process.exitCode = 0;
944
+ } else {
945
+ const unknown = [...flags].filter((f) => f !== '--json' && f !== '--help');
946
+ if (unknown.length > 0) {
947
+ console.error(`Unknown flag(s): ${unknown.join(', ')}\n${usage}`);
948
+ process.exitCode = 1;
949
+ } else {
950
+ const pluginRoot = path.resolve(positional[0] ?? process.cwd());
951
+ if (flags.has('--json')) {
952
+ const inspection = inspectVcsRepoFlag(pluginRoot);
953
+ // Data on stdout, diagnostics on stderr (cli-design.md).
954
+ console.log(JSON.stringify(inspection, null, 2));
955
+ process.exitCode = inspection.toolError ? 2 : 0;
956
+ } else {
957
+ process.exitCode = runCheckVcsRepoFlag(pluginRoot);
958
+ }
959
+ }
960
+ }
961
+ // Deliberately NOT `process.exit()`: on a pipe, exiting discards stdout writes
962
+ // still queued in the async write buffer, and the `--json` envelope of this
963
+ // census can outgrow the ~64 KiB pipe capacity. Setting exitCode lets the
964
+ // writes drain first. Snippets are clamped to SNIPPET_MAX for the same reason.
965
+ }