session-orchestrator 3.19.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 (158) 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 +494 -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/commands/session.md +6 -2
  23. package/docs/USER-GUIDE.md +1 -1
  24. package/docs/instruction-delivery.md +350 -0
  25. package/docs/migration-v3.md +9 -6
  26. package/docs/persona-panel.md +3 -1
  27. package/docs/scope-collision-guard.md +167 -0
  28. package/docs/session-config-reference.md +1 -41
  29. package/docs/session-config-template.md +0 -23
  30. package/hooks/_lib/guard-source-loader.mjs +304 -91
  31. package/hooks/enforce-commands.mjs +216 -17
  32. package/hooks/enforce-scope.mjs +236 -12
  33. package/hooks/hooks-codex.json +1 -1
  34. package/hooks/hooks.json +11 -1
  35. package/hooks/on-session-end.mjs +52 -5
  36. package/hooks/on-session-start.mjs +7 -4
  37. package/hooks/on-stop.mjs +127 -12
  38. package/hooks/post-bash-write-verify.mjs +8 -32
  39. package/hooks/pre-bash-destructive-guard.mjs +146 -59
  40. package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
  41. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  42. package/package.json +2 -2
  43. package/pi/prompts/release.md +12 -0
  44. package/scripts/autopilot.mjs +3 -1
  45. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  46. package/scripts/emit-session.mjs +45 -40
  47. package/scripts/export-hw-learnings.mjs +61 -2
  48. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  49. package/scripts/lib/backlog-scan.mjs +106 -15
  50. package/scripts/lib/build-live-signals.mjs +7 -3
  51. package/scripts/lib/ci-status-banner.mjs +207 -23
  52. package/scripts/lib/command-blocker.mjs +322 -62
  53. package/scripts/lib/git-config-drift.mjs +471 -0
  54. package/scripts/lib/hardening.mjs +9 -9
  55. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  56. package/scripts/lib/io.mjs +193 -7
  57. package/scripts/lib/learnings/affinity.mjs +434 -0
  58. package/scripts/lib/learnings/candidates.mjs +736 -0
  59. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  60. package/scripts/lib/learnings/judgment.mjs +782 -0
  61. package/scripts/lib/learnings/kebab.mjs +128 -0
  62. package/scripts/lib/learnings/select.mjs +704 -0
  63. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  64. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  65. package/scripts/lib/named-vault-resolver.mjs +105 -16
  66. package/scripts/lib/peer-cards/schema.mjs +6 -2
  67. package/scripts/lib/reconcile/emitter.mjs +107 -22
  68. package/scripts/lib/reconcile/engine.mjs +9 -15
  69. package/scripts/lib/reconcile/renderer.mjs +141 -25
  70. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  71. package/scripts/lib/reconcile/writer.mjs +134 -1
  72. package/scripts/lib/redact-spans.mjs +89 -0
  73. package/scripts/lib/scope-baseline.mjs +77 -17
  74. package/scripts/lib/scope-gate.mjs +852 -72
  75. package/scripts/lib/secret-masker.mjs +262 -0
  76. package/scripts/lib/session-close-backfill.mjs +2 -2
  77. package/scripts/lib/session-lock.mjs +34 -10
  78. package/scripts/lib/session-record-repair.mjs +551 -0
  79. package/scripts/lib/session-registry.mjs +9 -1
  80. package/scripts/lib/session-schema/serializer.mjs +54 -0
  81. package/scripts/lib/session-schema.mjs +1 -0
  82. package/scripts/lib/session-token-rollup.mjs +68 -6
  83. package/scripts/lib/soul-resolve.mjs +12 -0
  84. package/scripts/lib/state-md/mission-status.mjs +21 -12
  85. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  86. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  87. package/scripts/lib/validate/check-agents.mjs +77 -5
  88. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  89. package/scripts/lib/validate/check-commands.mjs +2 -20
  90. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  91. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
  93. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  94. package/scripts/lib/validate/check-owner-leakage.mjs +188 -20
  95. package/scripts/lib/validate/check-rules.mjs +31 -5
  96. package/scripts/lib/validate/check-skills.mjs +191 -0
  97. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  98. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  99. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  100. package/scripts/lib/validate/check-unwired-features.mjs +757 -0
  101. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  102. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  103. package/scripts/lib/validate/tier-inference.mjs +46 -8
  104. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  105. package/scripts/lib/vault-mirror/process.mjs +264 -31
  106. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  107. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  108. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  109. package/scripts/lib/vcs-repo-spec.mjs +500 -19
  110. package/scripts/print-applicable-rules.mjs +170 -7
  111. package/scripts/print-learnings-index.mjs +501 -0
  112. package/scripts/release.mjs +616 -61
  113. package/scripts/repair-invalid-sessions.mjs +209 -0
  114. package/scripts/site-numbers.mjs +1049 -0
  115. package/scripts/sweep-expired-learnings.mjs +192 -32
  116. package/scripts/validate-plugin.mjs +82 -0
  117. package/scripts/validate-wave-scope.mjs +281 -12
  118. package/scripts/vault-mirror.mjs +26 -1
  119. package/skills/_shared/monitor-patterns.md +24 -4
  120. package/skills/_shared/state-ownership.md +17 -0
  121. package/skills/brainstorm/soul.md +47 -1
  122. package/skills/claude-md-drift-check/SKILL.md +9 -1
  123. package/skills/debug/SKILL.md +4 -1
  124. package/skills/discovery/issue-templates.md +4 -4
  125. package/skills/discovery/probes-code.md +2 -2
  126. package/skills/discovery/probes-feature.md +6 -6
  127. package/skills/discovery/probes-infra.md +2 -2
  128. package/skills/discovery/probes-session.md +5 -5
  129. package/skills/dispatcher/SKILL.md +10 -1
  130. package/skills/evolve/SKILL.md +116 -18
  131. package/skills/frontmatter-guard/SKILL.md +9 -1
  132. package/skills/gitlab-ops/SKILL.md +54 -39
  133. package/skills/gitlab-portfolio/SKILL.md +10 -1
  134. package/skills/grill/soul.md +44 -1
  135. package/skills/memory-cleanup/SKILL.md +18 -5
  136. package/skills/npm-publish/SKILL.md +22 -50
  137. package/skills/persona-panel/SKILL.md +3 -1
  138. package/skills/plan/mode-new.md +23 -5
  139. package/skills/plan/soul.md +46 -3
  140. package/skills/repo-audit/SKILL.md +10 -1
  141. package/skills/session-end/SKILL.md +45 -26
  142. package/skills/session-end/metrics-collection.md +1 -1
  143. package/skills/session-end/phase-3-6-tail.md +30 -1
  144. package/skills/session-end/plan-verification.md +1 -5
  145. package/skills/session-end/session-metrics-write.md +6 -10
  146. package/skills/session-plan/SKILL.md +2 -2
  147. package/skills/session-plan/wave-template.md +1 -1
  148. package/skills/session-start/SKILL.md +15 -1
  149. package/skills/session-start/soul.md +41 -1
  150. package/skills/spinout/SKILL.md +5 -1
  151. package/skills/sunset-review/SKILL.md +11 -1
  152. package/skills/tmux-layout/SKILL.md +7 -2
  153. package/skills/vault-mirror/SKILL.md +10 -1
  154. package/skills/vault-sync/SKILL.md +10 -1
  155. package/skills/vault-sync/validator.mjs +55 -6
  156. package/skills/wave-executor/SKILL.md +1 -5
  157. package/skills/wave-executor/wave-loop.md +77 -82
  158. package/scripts/lib/mission-status-schema.mjs +0 -114
@@ -1,20 +1,41 @@
1
1
  /**
2
- * memory-cleanup-stamp.mjs — Pure helper: stamp `memory_cleanup_at` on a
3
- * session record when `/memory-cleanup` ran this session.
2
+ * memory-cleanup-stamp.mjs — stamp `memory_cleanup_at` on a session record when
3
+ * `/memory-cleanup` ran this session, and DERIVE that fact from the event log
4
+ * instead of from coordinator recall.
4
5
  *
5
6
  * Issue #699 fix: a healthy no-op run of `/memory-cleanup` previously left
6
7
  * `memory_cleanup_at` unstamped, so `auto-dream.mjs` `readDreamSignals`
7
8
  * never advanced `lastCleanupAt` and `shouldDispatchAutoDream` kept firing a
8
- * false nudge. This helper stamps the field unconditionally whenever the
9
- * cleanup ran — including a healthy no-op where no memory files were mutated.
9
+ * false nudge. `stampMemoryCleanup()` stamps the field unconditionally whenever
10
+ * the cleanup ran — including a healthy no-op where no memory files were mutated.
11
+ *
12
+ * #699 follow-up (Disziplin statt Mechanik): the `ranCleanup` boolean itself used
13
+ * to come from the coordinator-LLM remembering a prose step at session-end. It
14
+ * measurably failed — a `/memory-cleanup` ran on 2026-08-14 and all three session
15
+ * records of that day read `memory_cleanup_at: null`, so the session-start banner
16
+ * reported "29 days ago" while the operator's own notes said 3. Since then
17
+ * `/memory-cleanup` emits `orchestrator.memory.cleanup_completed` and
18
+ * `deriveMemoryCleanupSignal()` reads that event back out of
19
+ * `.orchestrator/metrics/events.jsonl`, which is what `scripts/emit-session.mjs`
20
+ * calls at write time. Nothing depends on recall any more.
10
21
  *
11
22
  * Design constraints:
12
- * - Pure function — no I/O, no side-effects.
13
- * - No-throw — invalid inputs return the record unchanged (defensive).
14
- * - Testable seam — the Quality wave (Q2) unit-tests this function directly.
15
- * - No external deps — Node 20+ stdlib only (none needed here).
23
+ * - `stampMemoryCleanup()` stays pure — no I/O, no side-effects.
24
+ * - No-throw — invalid inputs / unreadable-or-corrupt event log return the
25
+ * "no cleanup" answer rather than blocking a session close.
26
+ * - Testable seam — both functions are unit-tested directly.
27
+ * - No external deps — Node stdlib only.
16
28
  */
17
29
 
30
+ import { readFileSync } from 'node:fs';
31
+
32
+ /**
33
+ * Event `/memory-cleanup` emits when a run completes (any mode, any outcome —
34
+ * including a healthy no-op). Emitted via `scripts/emit-event.mjs` → the
35
+ * canonical `emitEvent()` path; see `skills/memory-cleanup/SKILL.md` § Output.
36
+ */
37
+ export const MEMORY_CLEANUP_EVENT = 'orchestrator.memory.cleanup_completed';
38
+
18
39
  /**
19
40
  * Stamp `memory_cleanup_at` on a session record when `/memory-cleanup` ran.
20
41
  *
@@ -54,3 +75,106 @@ export function stampMemoryCleanup(record, { ranCleanup, completedAt } = {}) {
54
75
  // Return a shallow clone with the stamp applied — never mutate the input.
55
76
  return { ...record, memory_cleanup_at: completedAt };
56
77
  }
78
+
79
+ /**
80
+ * Derive "did `/memory-cleanup` run in THIS session?" from the event log.
81
+ *
82
+ * Reads `eventsFile` and looks for `orchestrator.memory.cleanup_completed`
83
+ * records that fall inside the session's own `[startedAt, completedAt]` window.
84
+ * This is the mechanical replacement for the coordinator-supplied boolean.
85
+ *
86
+ * Matching rule (two clauses, both mechanical):
87
+ * 1. the event's `timestamp` lies within `[startedAt, completedAt]` inclusive;
88
+ * 2. IF the event carries a non-empty `semantic_session_id`, it must equal
89
+ * `sessionId` (sessions.jsonl `session_id` is the SEMANTIC id — the UUID
90
+ * `session_id` some events carry lives in a different id space and is
91
+ * deliberately NOT used for matching).
92
+ *
93
+ * Named ceiling (deliberate simplification): an event with NO attribution is
94
+ * claimed on the window alone, so two parallel sessions in one repo with
95
+ * overlapping windows both derive `ranCleanup: true`. The consequence is a
96
+ * marginally generous cadence marker on the peer's record — never a MISSED
97
+ * cleanup, which is the failure this whole path exists to prevent. Revisit
98
+ * trigger: if per-session cleanup accounting is ever needed, attach
99
+ * `semantic_session_id` to the emitted event and clause 2 makes it exact.
100
+ * Scan cost is a single linear pass over `events.jsonl` (~23k lines,
101
+ * size-rotated at SessionStart by `events-rotation.mjs`) — revisit if rotation
102
+ * is ever removed.
103
+ *
104
+ * No-throw: a missing, unreadable, or partly-corrupt event log yields
105
+ * `{ ranCleanup: false, ... }`. A session close must never fail because
106
+ * telemetry is damaged.
107
+ *
108
+ * @param {object} opts
109
+ * @param {string} opts.eventsFile Absolute or CWD-relative path to events.jsonl.
110
+ * @param {string} [opts.sessionId] The record's `session_id` (semantic form).
111
+ * @param {string} opts.startedAt Session `started_at` (ISO-8601).
112
+ * @param {string} opts.completedAt Session `completed_at` (ISO-8601).
113
+ * @returns {{ranCleanup: boolean, at: string|null, matches: number}}
114
+ * `at` is the LATEST matching event timestamp (null when none matched);
115
+ * `matches` is how many events matched (a run can emit more than one).
116
+ */
117
+ export function deriveMemoryCleanupSignal({
118
+ eventsFile,
119
+ sessionId,
120
+ startedAt,
121
+ completedAt,
122
+ } = {}) {
123
+ const none = { ranCleanup: false, at: null, matches: 0 };
124
+
125
+ if (typeof eventsFile !== 'string' || eventsFile.length === 0) return none;
126
+
127
+ const startMs = Date.parse(startedAt);
128
+ const endMs = Date.parse(completedAt);
129
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) {
130
+ return none;
131
+ }
132
+
133
+ let raw;
134
+ try {
135
+ raw = readFileSync(eventsFile, 'utf8');
136
+ } catch {
137
+ // Missing / unreadable event log — no signal, never an error.
138
+ return none;
139
+ }
140
+
141
+ const wantSession = typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
142
+ let matches = 0;
143
+ let latestMs = -Infinity;
144
+ let latestTs = null;
145
+
146
+ for (const line of raw.split('\n')) {
147
+ // Cheap substring prefilter before the JSON.parse cost.
148
+ if (line.length === 0 || !line.includes(MEMORY_CLEANUP_EVENT)) continue;
149
+
150
+ let rec;
151
+ try {
152
+ rec = JSON.parse(line);
153
+ } catch {
154
+ continue; // corrupt line — skip, best-effort reader
155
+ }
156
+ if (rec === null || typeof rec !== 'object' || Array.isArray(rec)) continue;
157
+ if (rec.event !== MEMORY_CLEANUP_EVENT) continue;
158
+
159
+ const tsMs = Date.parse(rec.timestamp);
160
+ if (!Number.isFinite(tsMs) || tsMs < startMs || tsMs > endMs) continue;
161
+
162
+ const semantic = rec.semantic_session_id;
163
+ if (
164
+ wantSession !== null &&
165
+ typeof semantic === 'string' &&
166
+ semantic.length > 0 &&
167
+ semantic !== wantSession
168
+ ) {
169
+ continue; // attributed to a DIFFERENT session — not ours
170
+ }
171
+
172
+ matches += 1;
173
+ if (tsMs > latestMs) {
174
+ latestMs = tsMs;
175
+ latestTs = rec.timestamp;
176
+ }
177
+ }
178
+
179
+ return { ranCleanup: matches > 0, at: latestTs, matches };
180
+ }
@@ -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.