session-orchestrator 3.20.0 → 3.21.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 (114) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +10 -8
  5. package/CHANGELOG.md +414 -0
  6. package/README.md +16 -11
  7. package/agents/analyst.md +1 -1
  8. package/agents/architect-reviewer.md +1 -1
  9. package/agents/code-implementer.md +4 -2
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/dialectic-deriver.md +1 -1
  12. package/agents/docs-writer.md +1 -1
  13. package/agents/memory-proposal-collector.md +1 -1
  14. package/agents/qa-strategist.md +1 -1
  15. package/agents/security-reviewer.md +1 -1
  16. package/agents/session-reviewer.md +42 -1
  17. package/agents/skill-applied-judge.md +1 -1
  18. package/agents/test-writer.md +1 -1
  19. package/agents/ui-developer.md +1 -1
  20. package/agents/ux-evaluator.md +1 -1
  21. package/commands/release.md +60 -0
  22. package/docs/migration-v3.md +9 -6
  23. package/docs/persona-panel.md +3 -1
  24. package/docs/scope-collision-guard.md +167 -0
  25. package/hooks/enforce-scope.mjs +103 -3
  26. package/hooks/hooks-codex.json +1 -1
  27. package/hooks/hooks.json +11 -1
  28. package/hooks/on-session-end.mjs +52 -5
  29. package/hooks/on-stop.mjs +127 -12
  30. package/hooks/post-bash-write-verify.mjs +8 -32
  31. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  32. package/package.json +1 -1
  33. package/pi/prompts/release.md +12 -0
  34. package/scripts/autopilot.mjs +3 -1
  35. package/scripts/emit-session.mjs +42 -0
  36. package/scripts/export-hw-learnings.mjs +61 -2
  37. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  38. package/scripts/lib/backlog-scan.mjs +106 -15
  39. package/scripts/lib/build-live-signals.mjs +7 -3
  40. package/scripts/lib/ci-status-banner.mjs +207 -23
  41. package/scripts/lib/git-config-drift.mjs +471 -0
  42. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  43. package/scripts/lib/io.mjs +193 -7
  44. package/scripts/lib/learnings/select.mjs +157 -3
  45. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  46. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  47. package/scripts/lib/named-vault-resolver.mjs +105 -16
  48. package/scripts/lib/peer-cards/schema.mjs +6 -2
  49. package/scripts/lib/reconcile/writer.mjs +69 -30
  50. package/scripts/lib/redact-spans.mjs +89 -0
  51. package/scripts/lib/scope-baseline.mjs +77 -17
  52. package/scripts/lib/scope-gate.mjs +658 -0
  53. package/scripts/lib/secret-masker.mjs +262 -0
  54. package/scripts/lib/session-lock.mjs +34 -10
  55. package/scripts/lib/session-registry.mjs +9 -1
  56. package/scripts/lib/state-md/mission-status.mjs +21 -12
  57. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  58. package/scripts/lib/validate/check-agents.mjs +77 -5
  59. package/scripts/lib/validate/check-commands.mjs +2 -20
  60. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  61. package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
  62. package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
  63. package/scripts/lib/validate/check-skills.mjs +191 -0
  64. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  65. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  66. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  67. package/scripts/lib/validate/check-unwired-features.mjs +219 -11
  68. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  69. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  70. package/scripts/lib/validate/tier-inference.mjs +46 -8
  71. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  72. package/scripts/lib/vault-mirror/process.mjs +264 -31
  73. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  74. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  75. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  76. package/scripts/lib/vcs-repo-spec.mjs +500 -19
  77. package/scripts/print-learnings-index.mjs +30 -3
  78. package/scripts/release.mjs +616 -61
  79. package/scripts/site-numbers.mjs +1049 -0
  80. package/scripts/validate-plugin.mjs +61 -0
  81. package/scripts/validate-wave-scope.mjs +281 -12
  82. package/scripts/vault-mirror.mjs +26 -1
  83. package/skills/_shared/monitor-patterns.md +24 -4
  84. package/skills/_shared/state-ownership.md +17 -0
  85. package/skills/claude-md-drift-check/SKILL.md +9 -1
  86. package/skills/debug/SKILL.md +4 -1
  87. package/skills/discovery/issue-templates.md +4 -4
  88. package/skills/discovery/probes-code.md +2 -2
  89. package/skills/discovery/probes-feature.md +6 -6
  90. package/skills/discovery/probes-infra.md +2 -2
  91. package/skills/discovery/probes-session.md +5 -5
  92. package/skills/dispatcher/SKILL.md +10 -1
  93. package/skills/frontmatter-guard/SKILL.md +9 -1
  94. package/skills/gitlab-ops/SKILL.md +49 -39
  95. package/skills/gitlab-portfolio/SKILL.md +10 -1
  96. package/skills/memory-cleanup/SKILL.md +18 -5
  97. package/skills/npm-publish/SKILL.md +22 -50
  98. package/skills/persona-panel/SKILL.md +3 -1
  99. package/skills/plan/mode-new.md +23 -5
  100. package/skills/repo-audit/SKILL.md +10 -1
  101. package/skills/session-end/SKILL.md +44 -2
  102. package/skills/session-end/metrics-collection.md +1 -1
  103. package/skills/session-end/session-metrics-write.md +4 -10
  104. package/skills/session-plan/SKILL.md +2 -2
  105. package/skills/session-plan/wave-template.md +1 -1
  106. package/skills/session-start/SKILL.md +13 -1
  107. package/skills/spinout/SKILL.md +5 -1
  108. package/skills/sunset-review/SKILL.md +11 -1
  109. package/skills/tmux-layout/SKILL.md +7 -2
  110. package/skills/vault-mirror/SKILL.md +10 -1
  111. package/skills/vault-sync/SKILL.md +10 -1
  112. package/skills/vault-sync/validator.mjs +55 -6
  113. package/skills/wave-executor/wave-loop.md +42 -12
  114. package/scripts/lib/mission-status-schema.mjs +0 -114
@@ -0,0 +1,471 @@
1
+ /**
2
+ * git-config-drift.mjs — the invisible half of a contaminated working copy.
3
+ *
4
+ * ## The incident this exists for (2026-08-19)
5
+ *
6
+ * A diagnostic command exported `GIT_DIR="$PWD/.git"` to run a pre-push hook
7
+ * "the way git runs it". The export outlived the command and applied to
8
+ * everything downstream, including the test suite. `GIT_DIR` outranks `-C`, so
9
+ * every `git -C <tmpdir> …` in every fixture helper retargeted at the REAL
10
+ * repository. Three consequences landed, in ascending cost:
11
+ *
12
+ * 1. HEAD went detached (a fixture ran `checkout --detach`).
13
+ * 2. Three fixture commits landed in the real `.git`.
14
+ * 3. **Three entries landed in `.git/config`** — a fixture remote
15
+ * (`git@gitlab.example.com:…`), a fixture identity (`user.email`
16
+ * `test@example.com`, `user.name` `Test`) and `commit.gpgsign=false`.
17
+ *
18
+ * Item 3 was by far the most expensive, for one reason: **`git status` cannot
19
+ * see it.** The first recovery pass checked HEAD, the index and all 1614
20
+ * tracked files and reported the tree clean; the config entries were found two
21
+ * hours later, by an agent measuring something unrelated. By then two commits
22
+ * had been pushed with the fixture author to both GitLab and the public GitHub
23
+ * mirror, where branch protection refuses the force-push that would fix them —
24
+ * the correction now lives in `.mailmap` permanently. The fixture remote would
25
+ * additionally have pointed every `glab -R` call of that session at a
26
+ * non-existent instance, because `REMOTE_PREFERENCE.gitlab` in
27
+ * `scripts/lib/vcs-repo-spec.mjs` prefers the platform-named remote over
28
+ * `origin`.
29
+ *
30
+ * This probe is the cheap symptom detector for that class: one `git config
31
+ * --local --list` per session, matched against an expectation list.
32
+ *
33
+ * ## Named ceiling (BV-004) — read this before trusting a `null`
34
+ *
35
+ * This is an EXPECTATION LIST, not a semantic analysis of git configuration.
36
+ * It catches a fixture-shaped remote and a fixture-shaped identity. It does
37
+ * **NOT** catch:
38
+ *
39
+ * - a plausibly-named remote pointing at a real foreign host
40
+ * (`git@gitlab.internal.acme/…` reads as legitimate here);
41
+ * - anything an operator set deliberately — a per-repo work identity and a
42
+ * fixture identity are indistinguishable from the config file alone, so a
43
+ * deliberate override is reported and must be read, not silenced;
44
+ * - contamination in files other than `.git/config` (fixture commits, a
45
+ * detached HEAD, stray refs) — `git status` and `git log` cover those, and
46
+ * duplicating them here would buy nothing;
47
+ * - a `.git/config` written AFTER this probe ran. It is a session-start
48
+ * snapshot, not a watcher.
49
+ *
50
+ * **Revisit trigger:** if an operator reports a standing false positive on a
51
+ * deliberate local override, add an explicit allow-list (a `[sessionOrchestrator]`
52
+ * local-config key naming the accepted keys) — do NOT widen or delete a rule,
53
+ * which would re-open the exact hole the probe was built to close.
54
+ *
55
+ * ## Fail-open is forbidden here
56
+ *
57
+ * A failed query is NOT a clean repo. That fold — `null` meaning both "asked
58
+ * and clean" and "could not ask" — is the defect class this whole session
59
+ * worked against; see `isQueryFailure` in `scripts/lib/vcs-repo-spec.mjs:485`
60
+ * for the canonical statement, and `mirror-issues-banner.mjs`'s `degraded`
61
+ * field for the same discipline in the banner contract. A query failure here
62
+ * returns a `warn` result carrying `degraded`, never `null`.
63
+ *
64
+ * Plain-JS — no Zod dependency. Never throws. Synchronous, so it can sit in a
65
+ * session-start phase alongside the other probes without an await.
66
+ *
67
+ * Mirrors the Phase 4 banner contract (`scripts/lib/loop-readiness-banner.mjs`,
68
+ * `scripts/lib/mirror-issues-banner.mjs`): a single `checkXxx()` entry point
69
+ * returning `null` or `{ severity, message, ... }`.
70
+ *
71
+ * Cross-references:
72
+ * - `scripts/lib/validate/check-test-git-config-target.mjs` — the static
73
+ * counterpart, which finds the test call sites that can cause this.
74
+ * - `scripts/lib/validate/check-banner-parity.mjs:59-63` — prior art for the
75
+ * filtered git environment reproduced below.
76
+ * - `skills/session-start/SKILL.md` Phase 4 — banner render site.
77
+ */
78
+
79
+ import { spawnSync } from 'node:child_process';
80
+
81
+ /** Default timeout in ms for each `git config` invocation. */
82
+ export const DEFAULT_TIMEOUT_MS = 5000;
83
+
84
+ /**
85
+ * Closed set of `degraded` reasons. A degraded result means the local config
86
+ * was NOT successfully read — never that it is clean.
87
+ *
88
+ * `config-unreadable` is a deliberate fifth member rather than a fold into
89
+ * `not-a-git-repo`: git exits 128 for BOTH a missing repository and a
90
+ * syntactically broken `.git/config`, and the second is a corruption signal in
91
+ * exactly this probe's neighbourhood. Labelling it "not a git repo" would send
92
+ * the operator looking in the wrong place.
93
+ *
94
+ * @type {readonly ['git-unavailable','not-a-git-repo','config-unreadable','timeout','git-error']}
95
+ */
96
+ export const DEGRADED_REASONS = Object.freeze([
97
+ 'git-unavailable',
98
+ 'not-a-git-repo',
99
+ 'config-unreadable',
100
+ 'timeout',
101
+ 'git-error',
102
+ ]);
103
+
104
+ /**
105
+ * Closed set of finding kinds, frozen so consumers can switch on it.
106
+ *
107
+ * - `ambient-git-env` — `GIT_DIR`/`GIT_WORK_TREE` present in the process
108
+ * environment. Not a config entry at all, but the PROXIMATE CAUSE of the
109
+ * incident above, and free to detect. Reported first because it makes every
110
+ * other git command in the session suspect.
111
+ * - `local-identity` — a local `user.email`/`user.name` overriding the
112
+ * global identity. The costliest entry of the incident.
113
+ * - `local-gpgsign` — a local `commit.gpgsign` differing from the global.
114
+ * - `fixture-remote` — a remote URL on a reserved/fixture host.
115
+ * - `hooks-path` — a local `core.hooksPath` not pointing at `.husky/_`.
116
+ *
117
+ * @type {readonly ['ambient-git-env','local-identity','local-gpgsign','fixture-remote','hooks-path']}
118
+ */
119
+ export const FINDING_KINDS = Object.freeze([
120
+ 'ambient-git-env',
121
+ 'local-identity',
122
+ 'local-gpgsign',
123
+ 'fixture-remote',
124
+ 'hooks-path',
125
+ ]);
126
+
127
+ /**
128
+ * Environment variables that redirect git's repository discovery. Their mere
129
+ * presence is a finding — `GIT_DIR` outranks `-C`, which is precisely why the
130
+ * incident's fixture helpers, all of which passed a correct `-C <tmpdir>`,
131
+ * still wrote into the real repository.
132
+ */
133
+ const REPO_REDIRECT_ENV_VARS = Object.freeze(['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR']);
134
+
135
+ /**
136
+ * The only environment variables forwarded to the `git` child. Everything else
137
+ * — `GIT_DIR` above all — is dropped, so this probe cannot be redirected at a
138
+ * foreign repository by the very contamination it is looking for. Same
139
+ * allowlist as `check-banner-parity.mjs:63`; deliberately duplicated rather
140
+ * than lifted into a shared module, because a 6-entry constant is cheaper to
141
+ * copy than a new module is to maintain (BV-001).
142
+ */
143
+ const GIT_ENV_ALLOWLIST = Object.freeze(['PATH', 'HOME', 'LANG', 'LC_ALL', 'TMPDIR', 'TZ']);
144
+
145
+ /**
146
+ * Host suffixes reserved for documentation and testing, plus loopback. These
147
+ * are MEASURED against this repo's own fixtures rather than guessed: on
148
+ * 2026-08-19 a census of `tests/` (`grep -rhoE '(git@|ssh://|https?://)[A-Za-z0-9.@_-]+'`)
149
+ * returned `gitlab.example.com` (189), `github.example.com` (25),
150
+ * `example.com` (19), `example.test` (11), `127.0.0.1` (7), `gitlab.example`
151
+ * (4), plus `test.local` from the fixture identities. `.invalid` is included
152
+ * from RFC 2606 for completeness though this repo has no instance of it.
153
+ *
154
+ * `.local` is the most false-positive-prone member (an operator MAY have a
155
+ * genuine mDNS/LAN git host) — it stays in because a WARN naming the remote
156
+ * costs one glance, and because `test.local` is a shape this repo's own
157
+ * fixtures produce.
158
+ */
159
+ const FIXTURE_HOST_SUFFIXES = Object.freeze([
160
+ '.example.com',
161
+ '.example.org',
162
+ '.example.net',
163
+ '.example',
164
+ '.test',
165
+ '.invalid',
166
+ '.local',
167
+ '.localhost',
168
+ ]);
169
+
170
+ /** Exact hosts treated as fixture/loopback targets. */
171
+ const FIXTURE_HOSTS = Object.freeze([
172
+ 'example.com',
173
+ 'example.org',
174
+ 'example.net',
175
+ 'example',
176
+ 'localhost',
177
+ '127.0.0.1',
178
+ '::1',
179
+ ]);
180
+
181
+ /** The only `core.hooksPath` this repo expects, matched as a path tail. */
182
+ const EXPECTED_HOOKS_PATH_TAIL = '.husky/_';
183
+
184
+ /**
185
+ * Build the filtered child environment. See {@link GIT_ENV_ALLOWLIST}.
186
+ *
187
+ * @param {Record<string, string|undefined>} sourceEnv
188
+ * @returns {Record<string, string>}
189
+ */
190
+ function filteredGitEnv(sourceEnv) {
191
+ /** @type {Record<string, string>} */
192
+ const out = {};
193
+ for (const key of GIT_ENV_ALLOWLIST) {
194
+ const value = sourceEnv?.[key];
195
+ if (typeof value === 'string') out[key] = value;
196
+ }
197
+ return out;
198
+ }
199
+
200
+ /**
201
+ * Run one `git config` query.
202
+ *
203
+ * @param {string[]} args argv after the leading `git`
204
+ * @param {{repoRoot: string, env: Record<string,string|undefined>, timeoutMs: number, spawn: Function}} ctx
205
+ * @returns {{ok: true, stdout: string} | {ok: false, reason: 'git-unavailable'|'not-a-git-repo'|'config-unreadable'|'timeout'|'git-error', status: number|null}}
206
+ */
207
+ function runGitConfig(args, ctx) {
208
+ const res = ctx.spawn('git', ['-C', ctx.repoRoot, ...args], {
209
+ encoding: 'utf8',
210
+ timeout: ctx.timeoutMs,
211
+ env: filteredGitEnv(ctx.env),
212
+ stdio: ['ignore', 'pipe', 'pipe'],
213
+ });
214
+
215
+ if (res?.error) {
216
+ const code = /** @type {any} */ (res.error).code;
217
+ if (code === 'ENOENT') return { ok: false, reason: 'git-unavailable', status: null };
218
+ if (code === 'ETIMEDOUT') return { ok: false, reason: 'timeout', status: null };
219
+ return { ok: false, reason: 'git-error', status: null };
220
+ }
221
+ if (res?.signal) return { ok: false, reason: 'timeout', status: null };
222
+
223
+ const status = typeof res?.status === 'number' ? res.status : null;
224
+ if (status === 0) return { ok: true, stdout: String(res?.stdout ?? '') };
225
+
226
+ // 128 covers BOTH "not a git repository" and "bad config line in
227
+ // .git/config". Discriminate on stderr so a corrupted config is not
228
+ // mislabelled as a missing repo — see DEGRADED_REASONS.
229
+ if (status === 128) {
230
+ const stderr = String(res?.stderr ?? '').toLowerCase();
231
+ if (stderr.includes('bad config') || stderr.includes('bad numeric config')) {
232
+ return { ok: false, reason: 'config-unreadable', status };
233
+ }
234
+ return { ok: false, reason: 'not-a-git-repo', status };
235
+ }
236
+ return { ok: false, reason: 'git-error', status };
237
+ }
238
+
239
+ /**
240
+ * Parse `git config --list -z` output: NUL-separated entries, each `key\nvalue`
241
+ * (a valueless boolean key has no `\n`).
242
+ *
243
+ * The `-z` form is deliberate: the plain `--list` form is `key=value` split on
244
+ * newlines, which mis-parses any multi-line config value — and a multi-line
245
+ * value in a remote URL is exactly the corrupted-config shape this probe is
246
+ * most likely to meet.
247
+ *
248
+ * @param {string} stdout
249
+ * @returns {Array<{key: string, value: string}>}
250
+ */
251
+ export function parseNulConfigList(stdout) {
252
+ /** @type {Array<{key: string, value: string}>} */
253
+ const entries = [];
254
+ for (const record of String(stdout ?? '').split('\0')) {
255
+ if (record === '') continue;
256
+ const nl = record.indexOf('\n');
257
+ if (nl === -1) entries.push({ key: record, value: '' });
258
+ else entries.push({ key: record.slice(0, nl), value: record.slice(nl + 1) });
259
+ }
260
+ return entries;
261
+ }
262
+
263
+ /**
264
+ * Extract the host from a git remote URL — both the `scheme://[user@]host/…`
265
+ * and the scp-like `[user@]host:path` forms.
266
+ *
267
+ * @param {string} url
268
+ * @returns {string|null} lowercased host, or null when no host is discernible
269
+ * (a local path remote, which is never a fixture-host finding)
270
+ */
271
+ export function remoteHost(url) {
272
+ const raw = String(url ?? '').trim();
273
+ if (raw === '') return null;
274
+
275
+ const schemeMatch = /^[a-z][a-z0-9+.-]*:\/\/([^/]*)/i.exec(raw);
276
+ if (schemeMatch) {
277
+ const authority = schemeMatch[1];
278
+ const afterUserinfo = authority.slice(authority.lastIndexOf('@') + 1);
279
+ return afterUserinfo.replace(/:\d+$/, '').toLowerCase() || null;
280
+ }
281
+
282
+ // scp-like: `[user@]host:path`. A Windows drive letter (`C:\…`) and an
283
+ // absolute/relative local path have no `@` and no dot-bearing host, so they
284
+ // fall through to null rather than being reported as a fixture host.
285
+ const scp = /^([^/\\:]+):(?!\/\/)/.exec(raw);
286
+ if (scp) {
287
+ const host = scp[1].slice(scp[1].lastIndexOf('@') + 1).toLowerCase();
288
+ return host.includes('.') || host === 'localhost' ? host : null;
289
+ }
290
+ return null;
291
+ }
292
+
293
+ /**
294
+ * @param {string|null} host
295
+ * @returns {boolean} true when `host` is a reserved documentation/testing or
296
+ * loopback host per {@link FIXTURE_HOST_SUFFIXES} / {@link FIXTURE_HOSTS}.
297
+ */
298
+ export function isFixtureHost(host) {
299
+ if (typeof host !== 'string' || host === '') return false;
300
+ if (FIXTURE_HOSTS.includes(host)) return true;
301
+ return FIXTURE_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix));
302
+ }
303
+
304
+ /**
305
+ * @param {string} value a `core.hooksPath` value
306
+ * @returns {boolean} true when it points at `.husky/_` (absolute or relative)
307
+ */
308
+ function hooksPathIsExpected(value) {
309
+ const normalized = String(value ?? '').replace(/\\/g, '/').replace(/\/+$/, '');
310
+ return normalized === EXPECTED_HOOKS_PATH_TAIL || normalized.endsWith(`/${EXPECTED_HOOKS_PATH_TAIL}`);
311
+ }
312
+
313
+ /**
314
+ * Build the degraded result. Distinct from `null` on purpose — see the header.
315
+ *
316
+ * @param {string} reason
317
+ * @returns {{severity:'warn', message:string, degraded:string, findings:[]}}
318
+ */
319
+ function degradedResult(reason) {
320
+ return {
321
+ severity: 'warn',
322
+ message:
323
+ `⚠ git-config-drift: .git/config konnte nicht gelesen werden (${reason}) ` +
324
+ `— Zustand unbekannt, nicht "sauber". \`git config --local --list\` von Hand prüfen.`,
325
+ degraded: reason,
326
+ findings: [],
327
+ };
328
+ }
329
+
330
+ /**
331
+ * Inspect `.git/config` (and the ambient git environment) for fixture-shaped
332
+ * contamination.
333
+ *
334
+ * Return contract — three states, not two:
335
+ * - `null` when the query SUCCEEDED and matched the expectation list.
336
+ * - `{ severity:'warn', message, findings: [...] }` when at least one
337
+ * expectation is violated.
338
+ * - `{ severity:'warn', message, degraded, findings: [] }` when the local
339
+ * config could not be read. `degraded` is present ONLY in this case, so its
340
+ * absence proves the config was actually read.
341
+ *
342
+ * Never throws.
343
+ *
344
+ * @param {{repoRoot?: string, env?: Record<string,string|undefined>, timeoutMs?: number}} [opts]
345
+ * @param {{spawn?: Function}} [deps] DI for tests only.
346
+ * @returns {null | {severity:'warn', message:string, findings: Array<{kind:string, key:string, value:string, detail:string}>, degraded?: string}}
347
+ */
348
+ export function checkGitConfigDrift(opts = {}, deps = {}) {
349
+ try {
350
+ const { repoRoot, env, timeoutMs = DEFAULT_TIMEOUT_MS } = opts ?? {};
351
+ if (!repoRoot || typeof repoRoot !== 'string') return null;
352
+
353
+ const activeEnv = env && typeof env === 'object' ? env : process.env;
354
+ const spawn = deps?.spawn ?? spawnSync;
355
+ const ctx = { repoRoot, env: activeEnv, timeoutMs, spawn };
356
+
357
+ /** @type {Array<{kind:string, key:string, value:string, detail:string}>} */
358
+ const findings = [];
359
+
360
+ // Finding 0 — the proximate cause. Checked BEFORE the query, because a set
361
+ // GIT_DIR makes every OTHER git command of this session suspect, including
362
+ // ones this probe never sees.
363
+ for (const name of REPO_REDIRECT_ENV_VARS) {
364
+ const value = activeEnv?.[name];
365
+ if (typeof value === 'string' && value !== '') {
366
+ findings.push({
367
+ kind: 'ambient-git-env',
368
+ key: name,
369
+ value,
370
+ detail:
371
+ `${name} ist gesetzt (${value}) — es schlägt \`-C\` und \`cwd\`, ` +
372
+ `jedes git-Kommando dieser Sitzung kann ein fremdes Repo treffen.`,
373
+ });
374
+ }
375
+ }
376
+
377
+ const local = runGitConfig(['config', '--local', '--list', '-z'], ctx);
378
+ if (!local.ok) return degradedResult(local.reason);
379
+
380
+ // Best-effort global read, for the identity/gpgsign comparison only. Its
381
+ // failure never degrades the result — the local query, which IS the
382
+ // measurement, succeeded.
383
+ const globalRes = runGitConfig(['config', '--global', '--list', '-z'], ctx);
384
+ /** @type {Map<string,string>} */
385
+ const globals = new Map();
386
+ if (globalRes.ok) {
387
+ for (const { key, value } of parseNulConfigList(globalRes.stdout)) {
388
+ globals.set(key.toLowerCase(), value);
389
+ }
390
+ }
391
+
392
+ for (const { key, value } of parseNulConfigList(local.stdout)) {
393
+ const lower = key.toLowerCase();
394
+
395
+ if (lower === 'user.email' || lower === 'user.name') {
396
+ const globalValue = globals.get(lower);
397
+ if (globalValue === value) continue; // local restates the global — no override
398
+ findings.push({
399
+ kind: 'local-identity',
400
+ key,
401
+ value,
402
+ detail:
403
+ `lokales ${key}=${value} überschreibt die Identität` +
404
+ (globalValue === undefined
405
+ ? ' (global nicht gesetzt)'
406
+ : ` (global: ${globalValue})`) +
407
+ ' — Commits dieses Repos tragen den lokalen Wert.',
408
+ });
409
+ continue;
410
+ }
411
+
412
+ if (lower === 'commit.gpgsign') {
413
+ const globalValue = globals.get(lower);
414
+ if (globalValue === value) continue;
415
+ findings.push({
416
+ kind: 'local-gpgsign',
417
+ key,
418
+ value,
419
+ detail:
420
+ `lokales ${key}=${value} weicht vom globalen Wert ab` +
421
+ (globalValue === undefined ? ' (global nicht gesetzt)' : ` (${globalValue})`) +
422
+ '.',
423
+ });
424
+ continue;
425
+ }
426
+
427
+ if (/^remote\..+\.url$/i.test(key)) {
428
+ const host = remoteHost(value);
429
+ if (isFixtureHost(host)) {
430
+ findings.push({
431
+ kind: 'fixture-remote',
432
+ key,
433
+ value,
434
+ detail:
435
+ `${key} zeigt auf den Fixture-/Loopback-Host ${host} — ` +
436
+ `ein plattformbenanntes Remote wird von REMOTE_PREFERENCE VOR origin gewählt.`,
437
+ });
438
+ }
439
+ continue;
440
+ }
441
+
442
+ if (lower === 'core.hookspath' && !hooksPathIsExpected(value)) {
443
+ findings.push({
444
+ kind: 'hooks-path',
445
+ key,
446
+ value,
447
+ detail: `core.hooksPath=${value} zeigt nicht auf ${EXPECTED_HOOKS_PATH_TAIL} — Husky-Hooks laufen nicht.`,
448
+ });
449
+ }
450
+ }
451
+
452
+ if (findings.length === 0) return null;
453
+
454
+ const plural = findings.length === 1 ? 'Eintrag' : 'Einträge';
455
+ return {
456
+ severity: 'warn',
457
+ message:
458
+ `⚠ git-config-drift: ${findings.length} unerwartete(r) ${plural} in der lokalen ` +
459
+ `git-Konfiguration — für \`git status\` unsichtbar: ` +
460
+ findings.map((f) => f.detail).join(' '),
461
+ findings,
462
+ };
463
+ } catch {
464
+ // Defensive catch-all: a session-start probe must never break the session.
465
+ // Distinct from the degraded path above — this is an internal defect, not a
466
+ // failed query, and there is nothing truthful left to report.
467
+ return null;
468
+ }
469
+ }
470
+
471
+ export default checkGitConfigDrift;
@@ -13,6 +13,7 @@ import { execFileSync } from 'node:child_process';
13
13
 
14
14
  import { safeRead, lineCount, pass, fail } from './helpers.mjs';
15
15
  import { resolveInstructionFile } from '../../common.mjs';
16
+ import { listRemotes } from '../../vcs-repo-spec.mjs';
16
17
 
17
18
  export function runCategory6(root) {
18
19
  const checks = [];
@@ -114,9 +115,31 @@ export function runCategory6(root) {
114
115
  // repo's "github" remote (GitLab-primary / GitHub-mirror setups). A repo
115
116
  // with no github remote is not required to mirror anything, so that state
116
117
  // is skip-as-pass (full points) rather than a failure. Every git call is
117
- // wrapped defensively — a missing remote, an unfetched tracking ref, a
118
- // non-git root, or any other git-edge-case degrades to skip-as-pass so the
119
- // audit never crashes or hard-fails on ambient repo state it can't inspect.
118
+ // wrapped defensively — an unfetched tracking ref or any other git-edge-case
119
+ // degrades to skip-as-pass so the audit never crashes or hard-fails on
120
+ // ambient repo state it can't inspect.
121
+ //
122
+ // TWO states are deliberately NOT skip-as-pass (#1039), and the rule that
123
+ // separates them from the rest is a single question: could the git call have
124
+ // returned null because the thing it asked about is legitimately ABSENT?
125
+ //
126
+ // 1. A FAILED remote query. Until #1039 the remote list came from a bare
127
+ // `git remote` whose every failure mode collapsed to `null`, so a root
128
+ // that is not a git repository — or a host without git on PATH — scored
129
+ // 2 of 2 with the message "no github mirror remote configured". The audit
130
+ // rewarded its own blindness. The remote list now comes from
131
+ // `listRemotes`, whose contract separates ABSENCE (`ok:true, remotes:[]`
132
+ // — a legitimate repo state) from QUERY FAILURE (`ok:false`, every reason
133
+ // satisfying `isQueryFailure`). Absence keeps full points.
134
+ // 2. An unmeasurable AHEAD-COUNT (see the fold at the `rev-list` call). The
135
+ // two `rev-parse --verify --quiet` folds above stay skip-as-pass because
136
+ // their null genuinely means "ref not fetched"; `rev-list` has no such
137
+ // state left once the ref is verified, so its null means git failed.
138
+ //
139
+ // Both are a `fail`, because a check that could not measure has not passed —
140
+ // and because the audit's own `checks_passed` counter
141
+ // (`scripts/harness-audit.mjs`) counts `status === 'pass'`, so recording an
142
+ // unmeasurable check as a pass inflates the number that summarises audit trust.
120
143
  //
121
144
  // Mirror-branch resolution tries two strategies, in order:
122
145
  // 1. `github/HEAD` symbolic ref — only populated by an explicit
@@ -142,14 +165,23 @@ export function runCategory6(root) {
142
165
  }
143
166
  };
144
167
 
145
- const remotesOutput = runGit(['remote']);
146
- const hasGithubRemote = remotesOutput !== null &&
147
- remotesOutput.split('\n').map((l) => l.trim()).includes('github');
168
+ const remotes = listRemotes({ repoRoot: root });
169
+ const hasGithubRemote = remotes.ok && remotes.remotes.some((r) => r.name === 'github');
148
170
 
149
- if (!hasGithubRemote) {
171
+ if (!remotes.ok) {
172
+ // `ok:false` from listRemotes ⟺ the query itself failed (its contract
173
+ // emits only isQueryFailure reasons: not-a-git-repo / git-unavailable /
174
+ // git-error). A repo with no remotes is `ok:true` with an empty list and
175
+ // takes the skip-as-pass branch below, exactly as before.
176
+ checks.push(fail({
177
+ checkId, maxPoints: 2, path: relPath,
178
+ evidence: { hasGithubRemote: null, remoteQuery: remotes.reason },
179
+ message: `git remote query failed (${remotes.reason}) — github mirror sync not measurable`,
180
+ }));
181
+ } else if (!hasGithubRemote) {
150
182
  checks.push(pass({
151
183
  checkId, points: 2, maxPoints: 2, path: relPath,
152
- evidence: { hasGithubRemote: false },
184
+ evidence: { hasGithubRemote: false, remoteCount: remotes.remotes.length },
153
185
  message: 'no github mirror remote configured — skipped',
154
186
  }));
155
187
  } else {
@@ -181,14 +213,35 @@ export function runCategory6(root) {
181
213
  message: 'github mirror ref not fetched locally — skipped',
182
214
  }));
183
215
  } else {
216
+ // The SECOND #1039 fold, and it is NOT symmetric with the one above —
217
+ // which is why the `rev-parse` folds stay skip-as-pass and this one does
218
+ // not. Up there, a null means the ref is simply absent: `git rev-parse
219
+ // --verify --quiet github/<branch>` exits 1 SILENTLY for an unfetched
220
+ // mirror ref, a legitimate repo state. Down here there is no legitimate
221
+ // absence left to express — `mirrorBranch` was already proven to exist by
222
+ // that very --verify, and `git rev-list --count <existing>..HEAD` exits 0
223
+ // with an integer or 128 with a fatal (measured 2026-08-19 in this repo:
224
+ // `HEAD~1..HEAD` → exit 0 "1"; `PLACEHOLDER-missing..HEAD` → exit 128).
225
+ // So a null here means git FAILED, not that the answer is "nothing", and
226
+ // booking it 2/2 was the same "the audit rewarded its own blindness"
227
+ // shape the remote-query branch above was fixed for.
228
+ //
229
+ // Both NaN shapes are folded into one `fail` on purpose. A successful
230
+ // `rev-list --count` always prints an integer, so non-numeric output is
231
+ // git not honouring its own contract — equally unmeasurable, and
232
+ // splitting it off would leave the identical fail-open standing behind a
233
+ // smaller trigger. `aheadCountQuery` records which of the two occurred.
184
234
  const revListOut = runGit(['rev-list', '--count', `github/${mirrorBranch}..HEAD`]);
185
235
  const aheadCount = revListOut !== null ? Number.parseInt(revListOut, 10) : NaN;
186
236
 
187
237
  if (Number.isNaN(aheadCount)) {
188
- checks.push(pass({
189
- checkId, points: 2, maxPoints: 2, path: relPath,
190
- evidence: { hasGithubRemote: true, mirrorBranch, resolvedVia, aheadCount: null },
191
- message: 'unable to determine ahead-count vs github mirror skipped',
238
+ const aheadCountQuery = revListOut === null ? 'git-error' : 'unparseable-output';
239
+ checks.push(fail({
240
+ checkId, maxPoints: 2, path: relPath,
241
+ evidence: { hasGithubRemote: true, mirrorBranch, resolvedVia, aheadCount: null, aheadCountQuery },
242
+ message:
243
+ `unable to determine ahead-count vs github/${mirrorBranch} (${aheadCountQuery}) — ` +
244
+ 'mirror drift not measurable',
192
245
  }));
193
246
  } else if (aheadCount === 0) {
194
247
  checks.push(pass({