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,266 @@
1
+ /**
2
+ * mirror-issues-banner.mjs — the mirror blind spot (#1022 follow-up)
3
+ *
4
+ * `skills/gitlab-ops/SKILL.md` § VCS Auto-Detection picks EXACTLY ONE platform
5
+ * via if/else. In a repo whose `origin` is GitLab and whose `github` remote is
6
+ * a public mirror, no code path ever reads issues from the mirror — so issues
7
+ * filed by external reporters against the mirror are structurally invisible to
8
+ * every session. This probe is the counter-measure: it asks the OTHER side.
9
+ *
10
+ * The VCS family is therefore HARD-PINNED to `'github'`, never auto-detected.
11
+ * Auto-detection is the defect this module exists to compensate for; deriving
12
+ * the family here would reproduce it.
13
+ *
14
+ * No new Session Config key by design. `resolveRepoSpec({ vcs:'github' })`
15
+ * derives the `gh -R` spec straight from `git remote` (its `REMOTE_PREFERENCE`
16
+ * tries the remote literally named `github` first, and its `WRONG_FAMILY_HOST`
17
+ * guard discards a cross-family match). That makes the probe SELF-DISABLING: a
18
+ * repo without a GitHub mirror resolves to `undefined` → `null` → no spawn, no
19
+ * network call. A config key would be a second SSOT drifting against
20
+ * `git remote`.
21
+ *
22
+ * Plain-JS — no Zod dependency. Never throws.
23
+ *
24
+ * Mirrors the Phase 4 banner contract documented in
25
+ * `scripts/lib/loop-readiness-banner.mjs:23-26`: a single `checkXxx()` entry
26
+ * point returning `null` or `{ severity, message, ... }`.
27
+ *
28
+ * Cross-references:
29
+ * - `scripts/lib/ci-status-banner.mjs` — the sibling project-facing probe
30
+ * (whose `null`-collapsing this module deliberately does NOT copy, see below).
31
+ * - `scripts/lib/reconcile-nudge-banner.mjs:183-186` — the same
32
+ * absence-preserving discipline applied to a count.
33
+ * - `skills/session-start/SKILL.md` Phase 4 — banner render site.
34
+ */
35
+
36
+ import { execFile as _execFile } from 'node:child_process';
37
+ import { promisify } from 'node:util';
38
+ import { resolveRepoSpec as _resolveRepoSpec } from './vcs-repo-spec.mjs';
39
+
40
+ const execFileAsync = promisify(_execFile);
41
+
42
+ /** Default timeout in ms for the `gh` invocation. Mirrors `ci-status-banner.mjs:20`. */
43
+ export const DEFAULT_TIMEOUT_MS = 8000;
44
+
45
+ /** Default `--limit` passed to `gh issue list`. */
46
+ export const DEFAULT_LIMIT = 20;
47
+
48
+ /**
49
+ * The VCS family this probe queries. Deliberately a module constant, not a
50
+ * parameter: querying the auto-detected platform would make this probe a no-op
51
+ * in exactly the repos it exists for.
52
+ */
53
+ const MIRROR_VCS = 'github';
54
+
55
+ /** How many issue numbers the banner message names before summarising. */
56
+ const MESSAGE_ISSUE_CAP = 5;
57
+
58
+ /**
59
+ * Closed set of `degraded` reasons. A degraded result means "the mirror was
60
+ * NOT successfully read" — never "the mirror is clean".
61
+ *
62
+ * `query-failed` is the residual bucket for a `gh` that ran and exited
63
+ * non-zero for a reason that is neither missing-CLI, timeout, nor auth
64
+ * (network down, repo renamed, rate limit). It is a deliberate fifth member
65
+ * beyond the four originally specified: folding those into `parse-error`
66
+ * would mislabel a network failure as malformed output, reintroducing the
67
+ * dishonest-state class this module was built to remove.
68
+ *
69
+ * @type {readonly ['cli-missing','timeout','parse-error','auth-error','query-failed']}
70
+ */
71
+ export const DEGRADED_REASONS = Object.freeze([
72
+ 'cli-missing',
73
+ 'timeout',
74
+ 'parse-error',
75
+ 'auth-error',
76
+ 'query-failed',
77
+ ]);
78
+
79
+ /**
80
+ * Run `cmd` with a timeout race. Mirrors `ci-status-banner.mjs:30-38`.
81
+ *
82
+ * The timer is cleared and `unref`ed so a fast success does not hold the event
83
+ * loop open for the full budget (`.claude/rules/testing.md` § Async & Timeout
84
+ * Patterns). KNOWN LIMIT, inherited from the shared pattern and NOT fixed
85
+ * here: losing the race abandons the `gh` child process rather than killing
86
+ * it — a hung `gh` is left orphaned.
87
+ *
88
+ * @param {string} cmd
89
+ * @param {string[]} args
90
+ * @param {{ cwd?: string, timeoutMs?: number, execFile?: Function }} [opts]
91
+ * @returns {Promise<{ stdout?: string, stderr?: string }>}
92
+ */
93
+ async function execWithTimeout(cmd, args, opts = {}) {
94
+ const { cwd, timeoutMs = DEFAULT_TIMEOUT_MS, execFile = execFileAsync } = opts;
95
+ /** @type {NodeJS.Timeout | undefined} */
96
+ let timer;
97
+ try {
98
+ return await Promise.race([
99
+ execFile(cmd, args, { cwd, env: process.env }),
100
+ new Promise((_, reject) => {
101
+ timer = setTimeout(() => reject(new Error('timeout')), timeoutMs);
102
+ if (typeof timer.unref === 'function') timer.unref();
103
+ }),
104
+ ]);
105
+ } finally {
106
+ if (timer) clearTimeout(timer);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Map a failed `gh` invocation onto a {@link DEGRADED_REASONS} member.
112
+ *
113
+ * @param {unknown} err
114
+ * @returns {'cli-missing'|'timeout'|'auth-error'|'query-failed'}
115
+ */
116
+ function classifyFailure(err) {
117
+ const code = err && typeof err === 'object' ? /** @type {any} */ (err).code : undefined;
118
+ if (code === 'ENOENT') return 'cli-missing';
119
+
120
+ const message = err instanceof Error ? err.message : String(err ?? '');
121
+ if (message === 'timeout') return 'timeout';
122
+
123
+ const stderr = err && typeof err === 'object' ? String(/** @type {any} */ (err).stderr ?? '') : '';
124
+ const haystack = `${message}\n${stderr}`.toLowerCase();
125
+ if (
126
+ haystack.includes('gh auth login') ||
127
+ haystack.includes('not logged in') ||
128
+ haystack.includes('authentication') ||
129
+ haystack.includes('http 401') ||
130
+ haystack.includes('http 403')
131
+ ) {
132
+ return 'auth-error';
133
+ }
134
+ return 'query-failed';
135
+ }
136
+
137
+ /**
138
+ * Build the degraded result. Distinct from `null` on purpose: `null` in the
139
+ * banner contract reads as "all clear", which a failed query has NOT
140
+ * established.
141
+ *
142
+ * @param {string} repoSpec
143
+ * @param {string} reason
144
+ * @returns {{ severity: 'warn', message: string, repoSpec: string, degraded: string }}
145
+ */
146
+ function degradedResult(repoSpec, reason) {
147
+ return {
148
+ severity: 'warn',
149
+ message:
150
+ `⚠ mirror-issues: Mirror ${repoSpec} konnte nicht abgefragt werden (${reason}) ` +
151
+ `— Zustand unbekannt, nicht "sauber".`,
152
+ repoSpec,
153
+ degraded: reason,
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Render the found-issues banner message, naming issue numbers up to
159
+ * {@link MESSAGE_ISSUE_CAP} and summarising any remainder.
160
+ *
161
+ * @param {string} repoSpec
162
+ * @param {Array<{ number: number, title: string }>} issues
163
+ * @returns {string}
164
+ */
165
+ function formatMessage(repoSpec, issues) {
166
+ const named = issues.slice(0, MESSAGE_ISSUE_CAP).map((i) => `#${i.number}`).join(', ');
167
+ const rest = issues.length - Math.min(issues.length, MESSAGE_ISSUE_CAP);
168
+ const tail = rest > 0 ? ` (+${rest} weitere)` : '';
169
+ const plural = issues.length === 1 ? 'offenes Issue' : 'offene Issues';
170
+ return (
171
+ `⚠ mirror-issues: ${issues.length} ${plural} im Mirror ${repoSpec}: ${named}${tail} ` +
172
+ `— kein anderer Codepfad dieser Session liest sie.`
173
+ );
174
+ }
175
+
176
+ /**
177
+ * Check the GitHub mirror for open issues and produce a session-start banner.
178
+ *
179
+ * Return contract — three states, not two:
180
+ * - `null` when the mirror remote does not resolve (repo genuinely has no
181
+ * mirror; NO subprocess is spawned) or when the query SUCCEEDED and found
182
+ * zero open issues (measured and clean).
183
+ * - `{ severity:'warn', message, count, repoSpec, issues }` when N > 0.
184
+ * - `{ severity:'warn', message, repoSpec, degraded }` when the query did not
185
+ * succeed. `degraded` is present ONLY in this case, so its absence proves
186
+ * the mirror was actually read — the distinction `ci-status-banner.mjs`
187
+ * collapses (CLI-missing, bad JSON and no-remote all return `null` there,
188
+ * which is why the mirror gap went unseen for months).
189
+ *
190
+ * Never throws.
191
+ *
192
+ * @param {{ repoRoot?: string, timeoutMs?: number, limit?: number }} [opts]
193
+ * @param {{ execFile?: Function, resolveRepoSpec?: Function }} [deps] DI for tests only.
194
+ * @returns {Promise<null | { severity: 'warn', message: string, repoSpec: string, count?: number, issues?: Array<{number:number,title:string}>, degraded?: string }>}
195
+ */
196
+ export async function checkMirrorIssues(opts = {}, deps = {}) {
197
+ try {
198
+ const { repoRoot, timeoutMs = DEFAULT_TIMEOUT_MS, limit = DEFAULT_LIMIT } = opts ?? {};
199
+ if (!repoRoot || typeof repoRoot !== 'string') return null;
200
+
201
+ const { execFile = execFileAsync, resolveRepoSpec = _resolveRepoSpec } = deps ?? {};
202
+
203
+ // Step 1 — resolve the mirror spec from `git remote`. `undefined` here is
204
+ // the self-disabling path: no mirror, no spawn, no network cost.
205
+ let repoSpec;
206
+ try {
207
+ repoSpec = resolveRepoSpec({ repoRoot, vcs: MIRROR_VCS });
208
+ } catch {
209
+ return null;
210
+ }
211
+ if (!repoSpec || typeof repoSpec !== 'string') return null;
212
+
213
+ // Step 2 — query. Args array, never a shell string.
214
+ let stdout;
215
+ try {
216
+ const result = await execWithTimeout(
217
+ 'gh',
218
+ [
219
+ 'issue',
220
+ 'list',
221
+ '-R',
222
+ repoSpec,
223
+ '--state',
224
+ 'open',
225
+ '--limit',
226
+ String(limit),
227
+ '--json',
228
+ 'number,title',
229
+ ],
230
+ { cwd: repoRoot, timeoutMs, execFile },
231
+ );
232
+ stdout = String(result?.stdout ?? '');
233
+ } catch (err) {
234
+ return degradedResult(repoSpec, classifyFailure(err));
235
+ }
236
+
237
+ // Step 3 — parse. A CLI that exited 0 with unusable output is degraded,
238
+ // NOT clean.
239
+ let issues;
240
+ try {
241
+ const parsed = JSON.parse(stdout);
242
+ if (!Array.isArray(parsed)) return degradedResult(repoSpec, 'parse-error');
243
+ issues = parsed
244
+ .filter((entry) => entry && typeof entry === 'object' && Number.isFinite(Number(entry.number)))
245
+ .map((entry) => ({ number: Number(entry.number), title: String(entry.title ?? '') }));
246
+ } catch {
247
+ return degradedResult(repoSpec, 'parse-error');
248
+ }
249
+
250
+ // Step 4 — measured and clean.
251
+ if (issues.length === 0) return null;
252
+
253
+ return {
254
+ severity: 'warn',
255
+ message: formatMessage(repoSpec, issues),
256
+ count: issues.length,
257
+ repoSpec,
258
+ issues,
259
+ };
260
+ } catch {
261
+ // Defensive catch-all: a banner must never break session-start.
262
+ return null;
263
+ }
264
+ }
265
+
266
+ export default checkMirrorIssues;
@@ -16,6 +16,29 @@
16
16
  * IO is fully injectable (existsSync, realpathSync, env, gitRemote) so every
17
17
  * branch is unit-testable without touching disk or git.
18
18
  *
19
+ * ── Remote resolution (#1039) ────────────────────────────────────────────────
20
+ *
21
+ * The default `gitRemote` reads the repo's remote through the shared core in
22
+ * `vcs-repo-spec.mjs` ({@link resolvePreferredRemote}), VCS-LESS. Two properties
23
+ * of that call are load-bearing here and must not be "tidied":
24
+ *
25
+ * 1. **`vcs` is deliberately omitted.** This module needs a URL, not a
26
+ * platform family, and the vcs-less preference order is `origin` first.
27
+ * That order is what keeps the derived slug STABLE: in a repo carrying both
28
+ * `origin` (→ `<group>/<repo>`) and `gitlab`/`github` mirrors under other
29
+ * namespaces, a `gitlab`-first order would re-namespace — i.e. silently
30
+ * RENAME — every vault note already written under the origin namespace.
31
+ * 2. **Absence and query-failure are kept apart.** The former
32
+ * `git remote get-url origin` implementation returned `''` for *both* "no
33
+ * remote configured" and "git blew up / this is not a repo", and a falsy
34
+ * URL skips the walk-up entirely. The vault then resolved to the
35
+ * single-vault fallback with no trace of why — one `source:'fallback'`
36
+ * label covering a benign repo state and a broken measurement. The
37
+ * fallback result now carries an optional `remoteError` (a
38
+ * `REMOTE_RESOLUTION_REASONS` value) so the two are separable BY VALUE, and
39
+ * only a query failure ({@link isQueryFailure}) WARNs — an absence is
40
+ * normal and stays silent.
41
+ *
19
42
  * ── Exports ──────────────────────────────────────────────────────────────────
20
43
  *
21
44
  * parseNamedVaults(ownerConfig)
@@ -28,7 +51,7 @@
28
51
 
29
52
  import { join, dirname } from 'node:path';
30
53
  import { existsSync as nodeExistsSync, realpathSync as nodeRealpathSync } from 'node:fs';
31
- import { spawnSync } from 'node:child_process';
54
+ import { resolvePreferredRemote, isQueryFailure } from './vcs-repo-spec.mjs';
32
55
 
33
56
  // ---------------------------------------------------------------------------
34
57
  // Internal helpers
@@ -289,12 +312,31 @@ export function findRepoRoot(cwd = process.cwd(), { existsSync = nodeExistsSync,
289
312
  * `gitRemote(repoRoot)` is called ONLY in the walk-up path — injecting a
290
313
  * stub that throws proves the explicit path never calls it.
291
314
  *
315
+ * `gitRemote` keeps its `(repoRoot) => string` contract verbatim; the optional
316
+ * second argument is an OUT-parameter the default implementation uses to report
317
+ * WHY it returned `''`. A one-arg stub ignores it and classifies as
318
+ * `'no-remotes'` on `''` / `'git-error'` on a throw — so every pre-existing
319
+ * injection still works unchanged.
320
+ *
321
+ * `gitRun` is injected into the DEFAULT `gitRemote` only, and is ignored when
322
+ * `gitRemote` is supplied (the caller has replaced the thing that would use it).
323
+ * It exists so the default remote-resolution path — preference order, reason
324
+ * propagation — is testable at all; a `gitRemote` stub answers that question
325
+ * before the code under test runs and can only ever confirm itself.
326
+ *
327
+ * `remoteError` is present ONLY on a `source:'fallback'` result that was reached
328
+ * because the remote query produced nothing; it is absent when the fallback was
329
+ * reached for any other reason (no vaults configured, no repo root, the repo IS
330
+ * a vault, no org-prefix match). Additive — pre-#1039 readers see the identical
331
+ * four fields.
332
+ *
292
333
  * @param {{
293
334
  * vaultName?: string|null,
294
335
  * cwd?: string,
295
336
  * ownerConfig?: object,
296
337
  * env?: Record<string, string|undefined>,
297
- * gitRemote?: (repoRoot: string) => string,
338
+ * gitRemote?: (repoRoot: string, out?: {reason?: string}) => string,
339
+ * gitRun?: (args: string[]) => {ok: boolean, stdout: string, stderr: string, status?: number, code?: string},
298
340
  * existsSync?: Function,
299
341
  * realpathSync?: Function,
300
342
  * }} [opts]
@@ -302,7 +344,8 @@ export function findRepoRoot(cwd = process.cwd(), { existsSync = nodeExistsSync,
302
344
  * root: string|null,
303
345
  * suffix: string,
304
346
  * name: string|null,
305
- * source: 'explicit'|'walkup'|'fallback'
347
+ * source: 'explicit'|'walkup'|'fallback',
348
+ * remoteError?: string
306
349
  * }}
307
350
  */
308
351
  export function resolveNamedVault({
@@ -310,11 +353,14 @@ export function resolveNamedVault({
310
353
  cwd = process.cwd(),
311
354
  ownerConfig,
312
355
  env = process.env,
313
- gitRemote = _defaultGitRemote,
356
+ gitRemote,
357
+ gitRun,
314
358
  existsSync = nodeExistsSync,
315
359
  realpathSync = nodeRealpathSync,
316
360
  } = {}) {
317
361
  const vaults = parseNamedVaults(ownerConfig);
362
+ const readRemote =
363
+ typeof gitRemote === 'function' ? gitRemote : (root, out) => _defaultGitRemote(root, out, gitRun);
318
364
 
319
365
  // ── Path 1: explicit vault-name ──────────────────────────────────────────
320
366
  const trimmedName = typeof vaultName === 'string' ? vaultName.trim() : '';
@@ -330,18 +376,39 @@ export function resolveNamedVault({
330
376
  }
331
377
 
332
378
  // ── Path 2: walk-up org-prefix match ────────────────────────────────────
379
+ /** @type {string|undefined} — set ONLY when the remote query is why we fall through */
380
+ let remoteError;
381
+
333
382
  if (vaults.length > 0) {
334
383
  const repoRoot = findRepoRoot(cwd, { existsSync, realpathSync });
335
384
  if (repoRoot !== null) {
336
- // Get the git remote for origin
385
+ // Resolve the repo's preferred remote (vcs-less origin-first; see the
386
+ // module docblock for why that order must not move).
387
+ /** @type {{reason?: string, stderr?: string}} */
388
+ const remoteOut = {};
337
389
  let remoteUrl;
338
390
  try {
339
- remoteUrl = gitRemote(repoRoot);
340
- } catch {
391
+ remoteUrl = readRemote(repoRoot, remoteOut);
392
+ } catch (err) {
393
+ // A throwing gitRemote could not answer the question — that is a query
394
+ // failure, never "this repo has no remote".
341
395
  remoteUrl = '';
396
+ remoteOut.reason = 'git-error';
397
+ remoteOut.stderr = err instanceof Error ? err.message : String(err);
342
398
  }
343
399
 
344
- if (remoteUrl) {
400
+ if (!remoteUrl) {
401
+ // Falsy URL: keep the REASON instead of collapsing it into a bare
402
+ // `source:'fallback'`. A one-arg stub that reported nothing means the
403
+ // benign "no remote configured" case.
404
+ remoteError = remoteOut.reason ?? 'no-remotes';
405
+ if (isQueryFailure(remoteError)) {
406
+ const detail = remoteOut.stderr ? `: ${String(remoteOut.stderr).trim()}` : '';
407
+ process.stderr.write(
408
+ `WARN named-vault-resolver: could not read the git remote of "${repoRoot}" (${remoteError})${detail}; falling back to the single-vault default\n`,
409
+ );
410
+ }
411
+ } else {
345
412
  // Derive org/repo from the remote URL (strip suffix/.git/scheme)
346
413
  const repoSlug = _deriveSlugFromRemote(remoteUrl);
347
414
 
@@ -366,11 +433,14 @@ export function resolveNamedVault({
366
433
  }
367
434
 
368
435
  // ── Path 3: single-vault fallback ────────────────────────────────────────
436
+ // `remoteError` is spread in only when set, so a fallback reached for any
437
+ // other reason keeps the exact pre-#1039 four-field shape.
369
438
  return {
370
439
  root: null,
371
440
  suffix: _resolveEnvSuffix(env) ?? DEFAULT_SUFFIX,
372
441
  name: null,
373
442
  source: 'fallback',
443
+ ...(remoteError === undefined ? {} : { remoteError }),
374
444
  };
375
445
  }
376
446
 
@@ -420,14 +490,33 @@ function _deriveSlugFromRemote(url) {
420
490
  }
421
491
 
422
492
  /**
423
- * Default gitRemote implementation: runs `git -C <repoRoot> remote get-url origin`.
493
+ * Default gitRemote implementation (#1039).
494
+ *
495
+ * Delegates to the shared {@link resolvePreferredRemote} core VCS-LESS, which
496
+ * makes this resolver work in the repos the old hard-coded
497
+ * `git remote get-url origin` was blind in — a repo whose remotes are named
498
+ * `gitlab`/`github`, a fork whose sole remote is `upstream`. The vcs-less
499
+ * preference order still tries `origin` FIRST, so the slug derived for a repo
500
+ * that has an `origin` is byte-identical to the pre-#1039 value.
501
+ *
502
+ * Return type stays `string` (the `gitRemote` DI contract depends on it). The
503
+ * failure reason travels through the optional `out` OUT-parameter instead:
504
+ * folding it into the return value would have meant changing that contract for
505
+ * every injected stub.
506
+ *
424
507
  * @param {string} repoRoot
425
- * @returns {string}
508
+ * @param {{reason?: string, stderr?: string}} [out] — populated with the
509
+ * {@link REMOTE_RESOLUTION_REASONS} reason when the resolution failed
510
+ * @param {Function} [gitRun] — injectable git runner; `undefined` uses the real one
511
+ * @returns {string} the remote URL, or `''` when none resolved
426
512
  */
427
- function _defaultGitRemote(repoRoot) {
428
- const res = spawnSync('git', ['-C', repoRoot, 'remote', 'get-url', 'origin'], {
429
- encoding: 'utf8',
430
- });
431
- if (res.status !== 0) return '';
432
- return res.stdout.trim();
513
+ function _defaultGitRemote(repoRoot, out, gitRun) {
514
+ // `vcs` deliberately omitted see the module docblock (origin-first order).
515
+ const resolved = resolvePreferredRemote({ repoRoot, gitRun });
516
+ if (resolved.ok) return resolved.url;
517
+ if (isPlainObject(out)) {
518
+ out.reason = resolved.reason;
519
+ if (resolved.stderr) out.stderr = resolved.stderr;
520
+ }
521
+ return '';
433
522
  }
@@ -6,8 +6,12 @@
6
6
  * YAML frontmatter on read and before write.
7
7
  *
8
8
  * Design notes:
9
- * - Follows the `mission-status-schema.mjs` convention (pure regex/checks)
10
- * rather than `skills/vault-sync/validator.mjs` (zod). The repo root has
9
+ * - Pure regex/checks rather than `skills/vault-sync/validator.mjs` (zod).
10
+ * (This line used to cite `scripts/lib/mission-status-schema.mjs` as the
11
+ * exemplar for that convention; that module was deleted on 2026-08-15 for
12
+ * having zero production callers while three prose sites promised it ran.
13
+ * The convention below stands on its own reason, stated next.)
14
+ * The repo root has
11
15
  * no `zod` dependency — only `skills/vault-sync/` does, scoped to that
12
16
  * skill's own `node_modules`. Keeping this validator dependency-free lets
13
17
  * callers from anywhere under `scripts/lib/` use it without extra setup.
@@ -123,12 +123,37 @@ function writeTextAtomic(destPath, content) {
123
123
  * `globs=null, meta={}, parseError=true` → always-on again, and with empty
124
124
  * meta it passes every gate by design.
125
125
  *
126
- * Scope note: the checks below fire only for a document that DECLARES
127
- * `auto-generated: true` (plus the parse check, which applies to every
128
- * document). The module's contract stays general a caller may still write a
129
- * document with no frontmatter but every document the reconcile pipeline
130
- * actually produces carries `auto-generated: true` on its first frontmatter
131
- * line, so the machine-authored path is fully covered.
126
+ * Scope (#1018 L2) three tiers, deliberately not one:
127
+ *
128
+ * 1. PARSE every document. Unparseable means unsafe, not unknown.
129
+ * 2. `alwaysApply: true` — every document, regardless of any marker. A rule
130
+ * file that DECLARES itself always-on is precisely the outcome this gate
131
+ * exists to prevent, and declaring it must not be the way around the gate.
132
+ * Before #1018 this check sat behind the `auto-generated: true` branch, so
133
+ * a document with `alwaysApply: true` and no `auto-generated` key was
134
+ * written to disk unexamined — measured, not inferred: a `writeApprovedRules`
135
+ * probe returned `written: 1` for exactly that input.
136
+ * 3. The never-always-on invariant set (activation axis, non-empty globs,
137
+ * `learning-key`, `expires-at`) — every document carrying ANY machine-
138
+ * provenance marker (`auto-generated: true`, `learning-key`, or
139
+ * `expires-at`). Keying on the marker SET rather than on `auto-generated`
140
+ * alone means a document that loses its `auto-generated` line to truncation
141
+ * but keeps its provenance keys is still held to the invariant.
142
+ *
143
+ * Why tier 3 is marker-scoped and not universal — measured against the live
144
+ * corpus, not assumed: applying the invariant set to EVERY document would
145
+ * refuse 16 of the 29 files currently in `.claude/rules/`. Those 16 are the
146
+ * hand-authored always-on rules (`development.md`, `security.md`,
147
+ * `parallel-sessions.md`, …), for which always-on is the intended, correct
148
+ * shape — they carry no frontmatter at all. A universal gate would therefore
149
+ * refuse the legitimate majority of the corpus to catch a machine-path defect.
150
+ * Marker-scoped refuses 0 of 29. The module's general contract survives: a
151
+ * caller may still write a document with no frontmatter.
152
+ *
153
+ * CEILING (BV-004): the marker set is a fixed three-key list, not a schema
154
+ * lookup. Revisit if the renderer gains a fourth provenance key, or if a second
155
+ * non-reconcile caller of `writeApprovedRules` appears — today there is exactly
156
+ * one production caller and it passes renderer output.
132
157
  *
133
158
  * Mirrors `scripts/lib/validate/check-rules.mjs`, which enforces the same
134
159
  * invariants as a CI gate. Two enforcement points, one invariant: CI catches
@@ -147,37 +172,51 @@ function frontmatterRefusalReason(content) {
147
172
  }
148
173
 
149
174
  const { globs, meta } = parsed;
150
-
151
- // Not a machine-authored auto-generated rule → the never-always-on invariant
152
- // does not bind (see the scope note above).
153
- if (meta['auto-generated'] !== true) return null;
175
+ const hasKey = (key) => Object.prototype.hasOwnProperty.call(meta, key);
154
176
 
155
177
  const problems = [];
156
178
 
157
- const hasEmptyGlobs = Array.isArray(globs) && globs.length === 0;
158
- const hasGlobs = Array.isArray(globs) && globs.length > 0;
159
- const hasHostClass = Object.prototype.hasOwnProperty.call(meta, 'host-class');
160
-
161
- if (hasEmptyGlobs) {
162
- // NOT the "no axis" case and NOT always-on — the opposite: rule-loader.mjs
163
- // excludes on `globs.length === 0` unconditionally, AFTER gating, so the
164
- // rule never loads in ANY context even alongside a host-class: key.
165
- problems.push('empty globs array (globs: []) — the rule would match nothing and never load in ANY context');
166
- } else if (!hasGlobs && !hasHostClass) {
167
- problems.push('no activation axis (globs absent AND host-class absent) — the rule would load always-on');
168
- }
169
- if (!Object.prototype.hasOwnProperty.call(meta, 'learning-key')) {
170
- problems.push('missing required frontmatter key: learning-key');
171
- }
172
- if (!Object.prototype.hasOwnProperty.call(meta, 'expires-at')) {
173
- problems.push('missing required frontmatter key: expires-at');
174
- }
179
+ // ── Tier 2: binds on EVERY document (see the scope note above) ────────────
180
+ // Not gated behind any marker: a document that declares itself always-on is
181
+ // the exact outcome this gate prevents, so the declaration cannot be the
182
+ // escape hatch. The renderer only ever emits `alwaysApply: false`.
175
183
  if (meta.alwaysApply === true) {
176
- problems.push('alwaysApply: true on an auto-generated rule — the renderer only ever emits false');
184
+ problems.push(
185
+ 'alwaysApply: true — a rule written through this writer must never declare itself always-on (the renderer only ever emits false)',
186
+ );
187
+ }
188
+
189
+ // ── Tier 3: binds on any machine-provenance-bearing document ──────────────
190
+ // Marker-scoped rather than universal so the hand-authored always-on corpus
191
+ // (16 of 29 live rule files) keeps writing; marker-scoped rather than
192
+ // `auto-generated`-only so a document that loses that line to truncation but
193
+ // keeps its provenance keys is still held to the invariant.
194
+ const isProvenanceBearing =
195
+ meta['auto-generated'] === true || hasKey('learning-key') || hasKey('expires-at');
196
+
197
+ if (isProvenanceBearing) {
198
+ const hasEmptyGlobs = Array.isArray(globs) && globs.length === 0;
199
+ const hasGlobs = Array.isArray(globs) && globs.length > 0;
200
+ const hasHostClass = hasKey('host-class');
201
+
202
+ if (hasEmptyGlobs) {
203
+ // NOT the "no axis" case and NOT always-on — the opposite: rule-loader.mjs
204
+ // excludes on `globs.length === 0` unconditionally, AFTER gating, so the
205
+ // rule never loads in ANY context even alongside a host-class: key.
206
+ problems.push('empty globs array (globs: []) — the rule would match nothing and never load in ANY context');
207
+ } else if (!hasGlobs && !hasHostClass) {
208
+ problems.push('no activation axis (globs absent AND host-class absent) — the rule would load always-on');
209
+ }
210
+ if (!hasKey('learning-key')) {
211
+ problems.push('missing required frontmatter key: learning-key');
212
+ }
213
+ if (!hasKey('expires-at')) {
214
+ problems.push('missing required frontmatter key: expires-at');
215
+ }
177
216
  }
178
217
 
179
218
  if (problems.length === 0) return null;
180
- return `auto-generated rule fails the never-always-on invariant: ${problems.join('; ')}`;
219
+ return `rule fails the never-always-on invariant: ${problems.join('; ')}`;
181
220
  }
182
221
 
183
222
  // ---------------------------------------------------------------------------