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,665 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-test-git-config-target.mjs — census of state-MUTATING `git` invocations
4
+ * in `tests/**` that name no explicit target.
5
+ *
6
+ * ## The defect class (2026-08-19 incident)
7
+ *
8
+ * A `git` command that mutates repository state and passes neither `-C <path>`
9
+ * nor a `cwd:` option resolves its target from AMBIENT state: the process's
10
+ * working directory, or an inherited `GIT_DIR`/`GIT_WORK_TREE`. In a test that
11
+ * is invisible and usually harmless — vitest's cwd is the repo root, so the
12
+ * fixture "just works" — right up until the mutation lands in the developer's
13
+ * REAL repository.
14
+ *
15
+ * That happened. A coordinator exported `GIT_DIR="$PWD/.git"` for a hook
16
+ * diagnostic; the export survived into the test run, and because `GIT_DIR`
17
+ * outranks `-C`, fixture helpers retargeted at the real repo: a detached HEAD,
18
+ * three fixture commits, and three entries in `.git/config` (a fixture remote,
19
+ * a fixture `user.email`/`user.name`, `commit.gpgsign=false`). The config
20
+ * entries are invisible to `git status`; two commits reached GitLab AND the
21
+ * public GitHub mirror with the wrong author before anyone noticed.
22
+ *
23
+ * `scripts/lib/git-config-drift.mjs` is the runtime symptom probe for that
24
+ * incident. THIS check is the static half: it finds the call sites where a
25
+ * mutation's destination is decided by ambient state rather than by the call.
26
+ *
27
+ * ## What it does NOT catch — read before quoting a clean run
28
+ *
29
+ * **The incident's own two call sites pass this check.** Measured
30
+ * 2026-08-19: `tests/skills/claude-md-drift-check/checker.test.mjs:355` and
31
+ * `:399` run `spawnSync('git', ['remote','add','gitlab', <fixture url>], { cwd: vault })`
32
+ * — an explicit, correct `cwd`. They still wrote into the real repository,
33
+ * because an inherited `GIT_DIR` beats `cwd` and `-C` alike.
34
+ *
35
+ * So this check addresses a REAL but DIFFERENT subclass (a mutation with no
36
+ * declared destination at all) and is not, on its own, the root-cause gate for
37
+ * the incident. The root-cause gate for the `GIT_DIR` half is environmental —
38
+ * scrubbing `GIT_DIR`/`GIT_WORK_TREE` once, centrally, before any test runs —
39
+ * and lives outside this file. The `gitDirInheritable` counter in the summary
40
+ * measures that second population so the gap is visible in the OUTPUT, not
41
+ * only in this comment.
42
+ *
43
+ * Further named gaps:
44
+ * - **A non-literal argv array is never judged.** `execFileSync('git', args, …)`
45
+ * cannot be classified from the call site; counted as `unresolvedArgv`.
46
+ * - **A spread before the subcommand** (`['-C', dir, ...args]`) leaves the
47
+ * subcommand unknown; the target IS resolvable, so such sites are simply
48
+ * not findings.
49
+ * - **The options object is matched textually** inside the call expression: a
50
+ * `cwd:` anywhere in it counts as an explicit target. A `cwd` passed as an
51
+ * opaque variable (`execFileSync('git', [...], opts)`) is counted as
52
+ * `unresolvedOptions` and never reported.
53
+ * - **Only `.mjs`/`.js`/`.cjs` under `tests/` are scanned**, and files are
54
+ * read with `readFileSync`, never a `grep` spawn — one NUL byte makes a
55
+ * text file invisible to grep-based audits (see
56
+ * `.claude/rules/anti-pattern-a-nul-byte-in-a-tracked-production-file-....md`).
57
+ * - **Comment lines are skipped**, so a documented counter-example in a
58
+ * docblock is not a finding.
59
+ * - **A match inside a string literal is fixture TEXT, not a call site.**
60
+ * Without that filter this check reported 8 findings in its OWN test file,
61
+ * whose positive cases are the anti-pattern written as strings; 23 such
62
+ * matches exist across `tests/` (2026-08-19). See {@link insideStringLiteral}
63
+ * for the residual multi-line-template limit.
64
+ *
65
+ * ## Mode: WARN, never blocking
66
+ *
67
+ * Findings print as `WARN:` and the runner returns 0. `FAIL:` is reserved for
68
+ * the tool-error path. This is load-bearing, not stylistic:
69
+ * `scripts/validate-plugin.mjs` tallies `^[ ]{2}FAIL:` lines from EVERY
70
+ * sub-check into a module-wide counter and exits 1 when it is non-zero — the
71
+ * sub-check's own exit code is discarded for WARN-only checks. A single
72
+ * `FAIL:` line here would red the whole validator, and with it the spawner
73
+ * tests that run it. Same posture, and same reason, as
74
+ * `check-vcs-repo-flag.mjs` and `check-unwired-features.mjs`.
75
+ *
76
+ * Import-safety: importing this module exposes the inspector and runner only;
77
+ * the CLI path is guarded at the bottom of the file.
78
+ */
79
+
80
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
81
+ import path from 'node:path';
82
+ import { pathToFileURL } from 'node:url';
83
+
84
+ /** Only this directory is scanned — a mutation in `scripts/` is production intent. */
85
+ const SCAN_DIR = 'tests';
86
+
87
+ /** Extensions scanned. `.md` fixtures under tests/ are prose, not call sites. */
88
+ const CODE_EXTENSIONS = Object.freeze(['.mjs', '.js', '.cjs']);
89
+
90
+ /**
91
+ * Subcommands that MUTATE repository state, and the argument shapes that make
92
+ * an otherwise-mutating subcommand read-only.
93
+ *
94
+ * The set is the one named in the incident report — `config`, `remote`,
95
+ * `init`, `commit`, `checkout`, `reset`, `add`, `tag` — plus the four that
96
+ * mutate through the same door and appear in this repo's fixtures (`rm`, `mv`,
97
+ * `stash`, `branch`). It is deliberately NOT every mutating git subcommand:
98
+ * a wider set buys no additional true positive here (measured: the same
99
+ * finding list) and each extra entry is an extra false-positive surface.
100
+ *
101
+ * `readOnlyWhen` is consulted against the argv AFTER the subcommand.
102
+ *
103
+ * @type {ReadonlyMap<string, {readOnlyWhen?: RegExp[]}>}
104
+ */
105
+ const MUTATING_SUBCOMMANDS = new Map([
106
+ // `git config --get/--list/--get-all/--get-regexp` reads; anything else writes.
107
+ ['config', { readOnlyWhen: [/^--(get|get-all|get-regexp|get-urlmatch|list|l)$/, /^-l$/] }],
108
+ // `git remote` alone (or `-v`/`show`/`get-url`) reads; add/set-url/remove/rename write.
109
+ ['remote', { readOnlyWhen: [/^(-v|--verbose|show|get-url)$/] }],
110
+ ['init', {}],
111
+ ['commit', {}],
112
+ ['checkout', {}],
113
+ ['reset', {}],
114
+ ['add', {}],
115
+ // `git tag -l/--list` reads.
116
+ ['tag', { readOnlyWhen: [/^(-l|--list|--contains|--points-at)$/] }],
117
+ ['rm', {}],
118
+ ['mv', {}],
119
+ ['stash', {}],
120
+ // `git branch` alone / `-l` / `--list` / `-a` reads; -d/-D/-m/-M/<name> writes.
121
+ ['branch', { readOnlyWhen: [/^(-l|--list|-a|--all|-r|--remotes|--show-current|-v|--verbose)$/] }],
122
+ ]);
123
+
124
+ /**
125
+ * Subcommands whose own POSITIONAL argument names the repository, so the call
126
+ * is explicitly targeted without `-C` or `cwd:`.
127
+ *
128
+ * This is not a refinement invented at the desk — it is the entire first
129
+ * measurement. The v1 rule (no `-C`, no `cwd:`) reported **11 findings against
130
+ * `tests/` on 2026-08-19, and all 11 were `git init [-q] <dir>`**: a 100%
131
+ * false-positive rate on a shape that is not merely acceptable but the
132
+ * canonical way to create a fixture repo.
133
+ *
134
+ * `clone` is deliberately ABSENT: it creates a new repository rather than
135
+ * mutating an existing one, so it is not in {@link MUTATING_SUBCOMMANDS} to
136
+ * begin with.
137
+ *
138
+ * Maps the subcommand to the flags that CONSUME the following token, so
139
+ * `git init -b main` does not read `main` as the target directory.
140
+ *
141
+ * @type {ReadonlyMap<string, string[]>}
142
+ */
143
+ const POSITIONAL_TARGET_SUBCOMMANDS = new Map([
144
+ ['init', ['-b', '--initial-branch', '--template', '--separate-git-dir', '--object-format', '--ref-format', '--shared']],
145
+ ]);
146
+
147
+ /**
148
+ * `git config` scope flags that move the write OFF the repository entirely.
149
+ * `--global` writes `~/.gitconfig`, `--system` the machine config, `--file`
150
+ * a named file — none of them needs a repo target, so none is a finding.
151
+ */
152
+ const CONFIG_NON_REPO_SCOPES = Object.freeze(['--global', '--system', '--file', '--blob']);
153
+
154
+ /**
155
+ * `git` global options that consume the NEXT argv token. Needed so the
156
+ * subcommand scan does not mistake an option's VALUE for the subcommand.
157
+ */
158
+ const VALUE_TAKING_GLOBALS = Object.freeze(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path']);
159
+
160
+ /** `git` global options that declare an explicit repository target. */
161
+ const TARGET_DECLARING_GLOBALS = Object.freeze(['-C', '--git-dir', '--work-tree']);
162
+
163
+ /** Call shapes that hand an argv ARRAY to a `git` binary. */
164
+ const ARGV_CALL_RE = /\b(?:execFileSync|execFile|spawnSync|spawn)\s*\(\s*['"]git['"]\s*,\s*/g;
165
+
166
+ /** Call shapes that hand a SHELL STRING opening with `git` to a shell. */
167
+ const SHELL_CALL_RE = /\b(?:execSync|exec)\s*\(\s*(['"`])\s*git\s/g;
168
+
169
+ /** Matches a `cwd:` property in an options object. */
170
+ const CWD_OPTION_RE = /\bcwd\s*:/;
171
+
172
+ /** Matches a `GIT_DIR`/`GIT_WORK_TREE` mention (an explicit env target). */
173
+ const GIT_ENV_TARGET_RE = /\bGIT_(?:DIR|WORK_TREE|COMMON_DIR)\b/;
174
+
175
+ /**
176
+ * Matches an options-object argument that is an opaque identifier rather than
177
+ * a literal — `execFileSync('git', [...], opts)`. Such a call cannot be judged.
178
+ */
179
+ const OPAQUE_OPTIONS_RE = /^\s*,\s*[A-Za-z_$][\w$]*\s*\)/;
180
+
181
+ /**
182
+ * Recursively collect scannable files under `dir`. Returns `[]` for a missing
183
+ * directory (the caller reports that as a tool error via an empty corpus).
184
+ *
185
+ * @param {string} dir
186
+ * @returns {string[]}
187
+ */
188
+ export function walk(dir) {
189
+ /** @type {string[]} */
190
+ const out = [];
191
+ /** @type {import('node:fs').Dirent[]} */
192
+ let entries;
193
+ try {
194
+ entries = readdirSync(dir, { withFileTypes: true });
195
+ } catch {
196
+ return out;
197
+ }
198
+ for (const entry of entries) {
199
+ const full = path.join(dir, entry.name);
200
+ if (entry.isDirectory()) out.push(...walk(full));
201
+ else if (entry.isFile() && CODE_EXTENSIONS.includes(path.extname(entry.name))) out.push(full);
202
+ }
203
+ return out;
204
+ }
205
+
206
+ /**
207
+ * Slice the balanced-bracket region starting at `open` in `text`.
208
+ *
209
+ * @param {string} text
210
+ * @param {number} open index of the opening bracket
211
+ * @param {string} openChar
212
+ * @param {string} closeChar
213
+ * @returns {number} index of the matching close bracket, or -1
214
+ */
215
+ function matchBracket(text, open, openChar, closeChar) {
216
+ let depth = 0;
217
+ for (let i = open; i < text.length; i += 1) {
218
+ const ch = text[i];
219
+ if (ch === openChar) depth += 1;
220
+ else if (ch === closeChar) {
221
+ depth -= 1;
222
+ if (depth === 0) return i;
223
+ }
224
+ }
225
+ return -1;
226
+ }
227
+
228
+ /**
229
+ * @typedef {{t:'lit', v:string} | {t:'spread'} | {t:'expr'}} ArgvToken
230
+ * `lit` a string literal · `spread` a `...rest` element · `expr` any other
231
+ * expression (an identifier, a member access, an interpolated template).
232
+ *
233
+ * The `spread`/`expr` split is load-bearing, not cosmetic: `['init','-q',dir]`
234
+ * carries its target in an `expr` positional, while `['-C',dir,...args]`
235
+ * carries an unknowable tail in a `spread`. Folding both to "opaque" is what
236
+ * produced the first measurement's 11 false positives.
237
+ */
238
+
239
+ /**
240
+ * Tokenize a literal argv array body into ordered tokens.
241
+ *
242
+ * @param {string} inner text between `[` and `]`
243
+ * @returns {ArgvToken[]}
244
+ */
245
+ export function tokenizeArgv(inner) {
246
+ /** @type {ArgvToken[]} */
247
+ const tokens = [];
248
+ const re = /(['"])((?:\\.|(?!\1)[^\\])*)\1|`([^`$]*)`|(\.\.\.)[\w$.[\]]*|([A-Za-z_$][\w$.[\]]*|`[^`]*`)/g;
249
+ /** @type {RegExpExecArray|null} */
250
+ let m;
251
+ while ((m = re.exec(inner)) !== null) {
252
+ if (m[2] !== undefined) tokens.push({ t: 'lit', v: m[2] });
253
+ else if (m[3] !== undefined) tokens.push({ t: 'lit', v: m[3] });
254
+ else if (m[4] !== undefined) tokens.push({ t: 'spread' });
255
+ else tokens.push({ t: 'expr' });
256
+ }
257
+ return tokens;
258
+ }
259
+
260
+ /**
261
+ * Classify a tokenized `git` argv.
262
+ *
263
+ * @param {ArgvToken[]} tokens argv AFTER the `git` binary name
264
+ * @returns {{hasArgvTarget: boolean, subcommand: string|null, rest: ArgvToken[]}}
265
+ */
266
+ export function classifyArgv(tokens) {
267
+ let hasArgvTarget = false;
268
+ let index = 0;
269
+ while (index < tokens.length) {
270
+ const token = tokens[index];
271
+ // A non-literal in leading-flag position makes the subcommand unknowable;
272
+ // never guess past it.
273
+ if (token.t !== 'lit') return { hasArgvTarget, subcommand: null, rest: [] };
274
+ if (!token.v.startsWith('-')) break;
275
+
276
+ const eq = token.v.indexOf('=');
277
+ const bare = eq === -1 ? token.v : token.v.slice(0, eq);
278
+ if (TARGET_DECLARING_GLOBALS.includes(bare)) hasArgvTarget = true;
279
+ if (eq === -1 && VALUE_TAKING_GLOBALS.includes(bare)) index += 2;
280
+ else index += 1;
281
+ }
282
+ if (index >= tokens.length) return { hasArgvTarget, subcommand: null, rest: [] };
283
+ const head = tokens[index];
284
+ return {
285
+ hasArgvTarget,
286
+ subcommand: head.t === 'lit' ? head.v : null,
287
+ rest: tokens.slice(index + 1),
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Decide whether the subcommand's OWN arguments already name the destination:
293
+ * a `git init <dir>` positional, or a `git config --global|--system|--file`
294
+ * scope that does not touch the repository at all.
295
+ *
296
+ * @param {string} subcommand
297
+ * @param {ArgvToken[]} rest argv after the subcommand
298
+ * @returns {boolean}
299
+ */
300
+ export function hasSubcommandTarget(subcommand, rest) {
301
+ if (subcommand === 'config') {
302
+ return rest.some((token) => {
303
+ if (token.t !== 'lit') return false;
304
+ const eq = token.v.indexOf('=');
305
+ return CONFIG_NON_REPO_SCOPES.includes(eq === -1 ? token.v : token.v.slice(0, eq));
306
+ });
307
+ }
308
+ const valueFlags = POSITIONAL_TARGET_SUBCOMMANDS.get(subcommand);
309
+ if (!valueFlags) return false;
310
+ for (let index = 0; index < rest.length; index += 1) {
311
+ const token = rest[index];
312
+ // A spread hides an unknown number of elements — refuse to guess.
313
+ if (token.t === 'spread') return false;
314
+ // An `expr` in positional position is the target directory variable, the
315
+ // dominant fixture shape (`git init -q dir`).
316
+ if (token.t === 'expr') return true;
317
+ if (!token.v.startsWith('-')) return true;
318
+ if (token.v.indexOf('=') === -1 && valueFlags.includes(token.v)) index += 1;
319
+ }
320
+ return false;
321
+ }
322
+
323
+ /**
324
+ * @param {string|null} subcommand
325
+ * @param {ArgvToken[]} rest argv after the subcommand
326
+ * @returns {boolean} true when this invocation mutates repository state
327
+ */
328
+ export function isMutating(subcommand, rest) {
329
+ if (typeof subcommand !== 'string') return false;
330
+ const spec = MUTATING_SUBCOMMANDS.get(subcommand);
331
+ if (!spec) return false;
332
+ if (!spec.readOnlyWhen) return true;
333
+ // `git remote` / `git branch` / `git tag` with NO further argument is a
334
+ // listing form, which is read-only.
335
+ const args = rest.filter((token) => token.t === 'lit').map((token) => token.v);
336
+ if (subcommand !== 'config' && args.length === 0) return false;
337
+ return !args.some((arg) => spec.readOnlyWhen.some((re) => re.test(arg)));
338
+ }
339
+
340
+ /**
341
+ * Split a shell command string into tokens, dropping interpolation holes.
342
+ * `git add ${JSON.stringify(rel)}` → `['git','add']` — enough to resolve the
343
+ * subcommand, which is all this check needs.
344
+ *
345
+ * @param {string} command
346
+ * @returns {string[]}
347
+ */
348
+ export function tokenizeShellCommand(command) {
349
+ return String(command ?? '')
350
+ .replace(/\$\{[^}]*\}/g, ' ')
351
+ .split(/\s+/)
352
+ .filter((token) => token !== '')
353
+ .map((token) => token.replace(/^['"]|['"]$/g, ''));
354
+ }
355
+
356
+ /**
357
+ * Compute the 1-based line number of `index` in `body`.
358
+ *
359
+ * @param {string} body
360
+ * @param {number} index
361
+ * @returns {number}
362
+ */
363
+ function lineOf(body, index) {
364
+ let line = 1;
365
+ for (let i = 0; i < index && i < body.length; i += 1) if (body[i] === '\n') line += 1;
366
+ return line;
367
+ }
368
+
369
+ /**
370
+ * @param {string} body
371
+ * @param {number} index
372
+ * @returns {boolean} true when the line containing `index` is a comment line
373
+ */
374
+ function inCommentLine(body, index) {
375
+ const start = body.lastIndexOf('\n', index) + 1;
376
+ return /^\s*(\/\/|\/\*|\*)/.test(body.slice(start, index + 1));
377
+ }
378
+
379
+ /**
380
+ * True when `index` falls INSIDE a string literal on its own line.
381
+ *
382
+ * Necessary, not defensive: a test that documents this very anti-pattern
383
+ * writes the offending call as a STRING (`"execFileSync('git', ['config', …])"`),
384
+ * and without this scanner the check reports its own fixtures — 8 findings in
385
+ * its own test file, measured 2026-08-19. A check that is red on its own
386
+ * regression suite is the shape that gets switched off.
387
+ *
388
+ * A per-line scanner (rather than whole-file) because a multi-line template
389
+ * literal would otherwise poison every subsequent line's quote state, and a
390
+ * call is always matched at the token that OPENS it — which is on one line.
391
+ *
392
+ * Known limit: a template literal that spans lines and contains a `git` call is
393
+ * judged by the line it sits on, so an interpolated multi-line command string
394
+ * can be judged as code. Measured 0 occurrences in `tests/` on 2026-08-19.
395
+ *
396
+ * @param {string} body
397
+ * @param {number} index
398
+ * @returns {boolean}
399
+ */
400
+ export function insideStringLiteral(body, index) {
401
+ const start = body.lastIndexOf('\n', index) + 1;
402
+ /** @type {string|null} */
403
+ let quote = null;
404
+ for (let i = start; i < index; i += 1) {
405
+ const ch = body[i];
406
+ if (ch === '\\') {
407
+ i += 1;
408
+ continue;
409
+ }
410
+ if (quote === null) {
411
+ if (ch === "'" || ch === '"' || ch === '`') quote = ch;
412
+ } else if (ch === quote) {
413
+ quote = null;
414
+ }
415
+ }
416
+ return quote !== null;
417
+ }
418
+
419
+ /**
420
+ * Scan one file for `git` invocations.
421
+ *
422
+ * @param {string} relative repo-relative path
423
+ * @param {string} body file content
424
+ * @param {{applicable: number, targeted: number, readOnly: number, unresolvedArgv: number, unresolvedOptions: number, gitDirInheritable: number, insideStringLiteral: number}} tally mutated in place
425
+ * @returns {Array<{kind: string, file: string, line: number, form: string, command: string, message: string}>}
426
+ */
427
+ export function scanFile(relative, body, tally) {
428
+ /** @type {Array<{kind: string, file: string, line: number, form: string, command: string, message: string}>} */
429
+ const findings = [];
430
+
431
+ /** @type {Array<{index: number, form: 'argv'|'shell', tokens: Array<string|null>, tail: string}>} */
432
+ const calls = [];
433
+
434
+ ARGV_CALL_RE.lastIndex = 0;
435
+ /** @type {RegExpExecArray|null} */
436
+ let m;
437
+ while ((m = ARGV_CALL_RE.exec(body)) !== null) {
438
+ const afterIndex = m.index + m[0].length;
439
+ const after = body.slice(afterIndex);
440
+ if (!after.startsWith('[')) {
441
+ tally.unresolvedArgv += 1;
442
+ continue;
443
+ }
444
+ const close = matchBracket(after, 0, '[', ']');
445
+ if (close === -1) {
446
+ tally.unresolvedArgv += 1;
447
+ continue;
448
+ }
449
+ calls.push({
450
+ index: m.index,
451
+ form: 'argv',
452
+ tokens: tokenizeArgv(after.slice(1, close)),
453
+ tail: after.slice(close + 1),
454
+ });
455
+ }
456
+
457
+ SHELL_CALL_RE.lastIndex = 0;
458
+ while ((m = SHELL_CALL_RE.exec(body)) !== null) {
459
+ const quote = m[1];
460
+ const openIndex = body.indexOf(quote, m.index);
461
+ const closeIndex = body.indexOf(quote, openIndex + 1);
462
+ if (closeIndex === -1) continue;
463
+ const command = body.slice(openIndex + 1, closeIndex);
464
+ const tokens = tokenizeShellCommand(command);
465
+ calls.push({
466
+ index: m.index,
467
+ form: 'shell',
468
+ // Drop the leading `git`, then lift every word into the `ArgvToken`
469
+ // shape so both forms share `classifyArgv`. An interpolation hole was
470
+ // already erased by `tokenizeShellCommand`, so what survives is literal.
471
+ tokens: tokens.slice(1).map((v) => ({ t: /** @type {const} */ ('lit'), v })),
472
+ tail: body.slice(closeIndex + 1),
473
+ });
474
+ }
475
+
476
+ for (const call of calls) {
477
+ if (inCommentLine(body, call.index)) continue;
478
+ if (insideStringLiteral(body, call.index)) {
479
+ tally.insideStringLiteral += 1;
480
+ continue;
481
+ }
482
+
483
+ const { hasArgvTarget, subcommand, rest } = classifyArgv(call.tokens);
484
+ if (!isMutating(subcommand, rest)) {
485
+ if (subcommand !== null) tally.readOnly += 1;
486
+ continue;
487
+ }
488
+ tally.applicable += 1;
489
+
490
+ // The options object: everything up to the end of the call expression.
491
+ const callEnd = matchBracket(call.tail, call.tail.indexOf('('), '(', ')');
492
+ const optionsText = callEnd === -1 ? call.tail.slice(0, 400) : call.tail.slice(0, callEnd);
493
+ const opaqueOptions = OPAQUE_OPTIONS_RE.test(call.tail);
494
+ const hasCwd = CWD_OPTION_RE.test(optionsText);
495
+ const hasEnvTarget = GIT_ENV_TARGET_RE.test(optionsText);
496
+ const hasTarget =
497
+ hasArgvTarget || hasCwd || hasEnvTarget || hasSubcommandTarget(subcommand, rest);
498
+
499
+ if (hasTarget) {
500
+ tally.targeted += 1;
501
+ // The second, larger population: a correct target that an inherited
502
+ // GIT_DIR still outranks. Counted, never reported per-site — see header.
503
+ if (!hasEnvTarget) tally.gitDirInheritable += 1;
504
+ continue;
505
+ }
506
+ if (opaqueOptions) {
507
+ tally.unresolvedOptions += 1;
508
+ continue;
509
+ }
510
+
511
+ const printable = [subcommand, ...rest.map((t) => (t.t === 'lit' ? t.v : '<expr>'))]
512
+ .slice(0, 4)
513
+ .join(' ');
514
+ findings.push({
515
+ kind: 'no-target',
516
+ file: relative,
517
+ line: lineOf(body, call.index),
518
+ form: call.form,
519
+ command: `git ${printable}`,
520
+ message:
521
+ `\`git ${printable}\` mutiert Repo-Zustand ohne explizites Ziel ` +
522
+ `(weder \`-C <pfad>\` noch \`cwd:\`) — das Ziel entscheidet die ambiente cwd.`,
523
+ });
524
+ }
525
+
526
+ return findings;
527
+ }
528
+
529
+ /**
530
+ * Run the full census.
531
+ *
532
+ * @param {string} pluginRoot absolute plugin root
533
+ * @returns {{ok: boolean, summary: {filesScanned: number, applicable: number, targeted: number, readOnly: number, unresolvedArgv: number, unresolvedOptions: number, gitDirInheritable: number, insideStringLiteral: number, findings: number}, findings: Array<object>, toolError: boolean}}
534
+ */
535
+ export function inspectTestGitConfigTarget(pluginRoot) {
536
+ const tally = {
537
+ applicable: 0,
538
+ targeted: 0,
539
+ readOnly: 0,
540
+ unresolvedArgv: 0,
541
+ unresolvedOptions: 0,
542
+ gitDirInheritable: 0,
543
+ insideStringLiteral: 0,
544
+ };
545
+ /** @type {Array<object>} */
546
+ const findings = [];
547
+ const result = {
548
+ ok: false,
549
+ summary: { filesScanned: 0, ...tally, findings: 0 },
550
+ findings,
551
+ toolError: false,
552
+ };
553
+
554
+ const scanRoot = path.join(pluginRoot, SCAN_DIR);
555
+ try {
556
+ statSync(scanRoot);
557
+ } catch (error) {
558
+ result.toolError = true;
559
+ findings.push({
560
+ kind: 'tool-error',
561
+ file: SCAN_DIR,
562
+ line: 0,
563
+ message: `cannot stat the scan root: ${error instanceof Error ? error.message : String(error)}`,
564
+ });
565
+ return result;
566
+ }
567
+
568
+ const files = walk(scanRoot).sort();
569
+ for (const absolute of files) {
570
+ const relative = path.relative(pluginRoot, absolute);
571
+ /** @type {string} */
572
+ let body;
573
+ try {
574
+ body = readFileSync(absolute, 'utf8');
575
+ } catch (error) {
576
+ result.toolError = true;
577
+ findings.push({
578
+ kind: 'tool-error',
579
+ file: relative,
580
+ line: 0,
581
+ message: `cannot read: ${error instanceof Error ? error.message : String(error)}`,
582
+ });
583
+ return result;
584
+ }
585
+ if (!/['"`]\s*git[\s'"`]/.test(body)) continue;
586
+ findings.push(...scanFile(relative, body, tally));
587
+ }
588
+
589
+ findings.sort((a, b) => String(a.file).localeCompare(String(b.file)) || Number(a.line) - Number(b.line));
590
+ result.summary = { filesScanned: files.length, ...tally, findings: findings.length };
591
+ result.ok = findings.length === 0;
592
+ return result;
593
+ }
594
+
595
+ /**
596
+ * Run the human-readable validator CLI.
597
+ *
598
+ * WARN-ONLY: findings print as WARN and still return 0. `FAIL:` is emitted
599
+ * only on the tool-error path — see § Mode in the header.
600
+ *
601
+ * @param {string} pluginRoot absolute plugin root
602
+ * @returns {number} 0 = census completed, 2 = tool error
603
+ */
604
+ export function runCheckTestGitConfigTarget(pluginRoot) {
605
+ console.log('--- Check: state-mutating git calls in tests/ without an explicit target (WARN-only) ---');
606
+ const inspection = inspectTestGitConfigTarget(pluginRoot);
607
+
608
+ if (inspection.toolError) {
609
+ for (const item of inspection.findings) console.log(` FAIL: ${item.file} — ${item.message}`);
610
+ console.log('');
611
+ console.log(`Results: 0 passed, ${inspection.findings.length} failed`);
612
+ return 2;
613
+ }
614
+
615
+ for (const item of inspection.findings) {
616
+ console.log(` WARN: [${item.kind}] ${item.file}:${item.line} — ${item.message}`);
617
+ }
618
+
619
+ const s = inspection.summary;
620
+ console.log(
621
+ ` PASS: censused ${s.filesScanned} test file(s) — ${s.applicable} state-mutating git ` +
622
+ `invocation(s), ${s.targeted} name an explicit target, ${s.findings} do not; ` +
623
+ `${s.readOnly} read-only invocation(s) skipped, ${s.unresolvedArgv} variable argv ` +
624
+ `array(s) and ${s.unresolvedOptions} opaque options object(s) unjudged; ` +
625
+ `${s.gitDirInheritable} targeted call(s) would still be outranked by an inherited GIT_DIR; ` +
626
+ `${s.insideStringLiteral} match(es) inside a string literal treated as fixture text`,
627
+ );
628
+ console.log('');
629
+ console.log('Results: 1 passed, 0 failed');
630
+ return 0;
631
+ }
632
+
633
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] || '').href;
634
+ if (isMain) {
635
+ const argv = process.argv.slice(2);
636
+ const flags = new Set(argv.filter((a) => a.startsWith('--')));
637
+ const positional = argv.filter((a) => !a.startsWith('--'));
638
+ const usage =
639
+ 'Usage: check-test-git-config-target.mjs [<plugin-root>] [--json]\n' +
640
+ ' --json emit the inspection envelope as a single JSON object on stdout\n' +
641
+ 'Exit: 0 census completed (findings are WARN-only) · 1 usage error · 2 tool error';
642
+
643
+ if (flags.has('--help')) {
644
+ console.log(usage);
645
+ process.exitCode = 0;
646
+ } else {
647
+ const unknown = [...flags].filter((f) => f !== '--json' && f !== '--help');
648
+ if (unknown.length > 0) {
649
+ console.error(`Unknown flag(s): ${unknown.join(', ')}\n${usage}`);
650
+ process.exitCode = 1;
651
+ } else {
652
+ const pluginRoot = path.resolve(positional[0] ?? process.cwd());
653
+ if (flags.has('--json')) {
654
+ const inspection = inspectTestGitConfigTarget(pluginRoot);
655
+ console.log(JSON.stringify(inspection, null, 2));
656
+ process.exitCode = inspection.toolError ? 2 : 0;
657
+ } else {
658
+ process.exitCode = runCheckTestGitConfigTarget(pluginRoot);
659
+ }
660
+ }
661
+ }
662
+ // Deliberately NOT `process.exit()`: on a pipe, exiting discards stdout
663
+ // writes still queued in the async write buffer (see
664
+ // `.claude/rules/anti-pattern-console-log-process-exit-drops-stdout-....md`).
665
+ }
@@ -42,8 +42,28 @@ import { pathToFileURL } from 'node:url';
42
42
  // (ported from check-owner-leakage.mjs, wrapped as pure exported helpers)
43
43
  // ---------------------------------------------------------------------------
44
44
 
45
- /** Text-scan extension allow-list. */
46
- export const TEXT_EXTS = new Set(['.md', '.mjs', '.js', '.ts', '.json', '.yml', '.yaml', '.sh', '.txt']);
45
+ /**
46
+ * Text-scan extension allow-list.
47
+ *
48
+ * '.html' (#1080 Finding C). This set is a SECOND, independent copy of the same
49
+ * concept check-owner-leakage.mjs maintains, and the two had DRIFTED: that scanner
50
+ * gained '.html' while this one kept the older list. The consequence was specific,
51
+ * not cosmetic — this validator exists for text "consumed by an LLM" (see the file
52
+ * header), and site/ ships four .html pages carrying JSON-LD directly beside
53
+ * site/llms.txt and site/llms-full.txt, i.e. exactly the consumer circle it was built
54
+ * for. Measured before adding: an identical U+202E + U+200B payload produced 2
55
+ * dangerous-invisible findings in a .md file and NOTHING in a .html file.
56
+ *
57
+ * DELIBERATELY NOT added to STRICT_EXTS below. STRICT flags every non-curated emoji,
58
+ * and .html is a prose/markup medium. Today that carve-out costs no detection — the
59
+ * strict variant was measured and produced the SAME single finding as the lenient one,
60
+ * so no emoji false positive exists in the tracked pages right now — but page copy is
61
+ * precisely where a future non-curated emoji legitimately lands, and a CI landmine
62
+ * armed by ordinary copywriting is not worth the marginal gain. The actual attack
63
+ * surface (invisibles, bidi overrides, tag-block smuggling) is flagged in BOTH
64
+ * contexts, so nothing security-relevant rides on the strict/lenient choice here.
65
+ */
66
+ export const TEXT_EXTS = new Set(['.md', '.mjs', '.js', '.ts', '.json', '.yml', '.yaml', '.sh', '.txt', '.html']);
47
67
 
48
68
  /** STRICT-context extensions (emoji + homoglyphs flagged, not just invisibles). */
49
69
  const STRICT_EXTS = new Set(['.mjs', '.js', '.ts', '.sh', '.json', '.yml', '.yaml']);