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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +10 -8
- package/CHANGELOG.md +414 -0
- package/README.md +16 -11
- package/agents/analyst.md +1 -1
- package/agents/architect-reviewer.md +1 -1
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +1 -1
- package/agents/dialectic-deriver.md +1 -1
- package/agents/docs-writer.md +1 -1
- package/agents/memory-proposal-collector.md +1 -1
- package/agents/qa-strategist.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +42 -1
- package/agents/skill-applied-judge.md +1 -1
- package/agents/test-writer.md +1 -1
- package/agents/ui-developer.md +1 -1
- package/agents/ux-evaluator.md +1 -1
- package/commands/release.md +60 -0
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -0
- package/hooks/enforce-scope.mjs +103 -3
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +11 -1
- package/hooks/on-session-end.mjs +52 -5
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +1 -1
- package/pi/prompts/release.md +12 -0
- package/scripts/autopilot.mjs +3 -1
- package/scripts/emit-session.mjs +42 -0
- package/scripts/export-hw-learnings.mjs +61 -2
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/backlog-scan.mjs +106 -15
- package/scripts/lib/build-live-signals.mjs +7 -3
- package/scripts/lib/ci-status-banner.mjs +207 -23
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +193 -7
- package/scripts/lib/learnings/select.mjs +157 -3
- package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
- package/scripts/lib/mirror-issues-banner.mjs +266 -0
- package/scripts/lib/named-vault-resolver.mjs +105 -16
- package/scripts/lib/peer-cards/schema.mjs +6 -2
- package/scripts/lib/reconcile/writer.mjs +69 -30
- package/scripts/lib/redact-spans.mjs +89 -0
- package/scripts/lib/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +658 -0
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/state-md/mission-status.mjs +21 -12
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-commands.mjs +2 -20
- package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
- package/scripts/lib/validate/check-skills.mjs +191 -0
- package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
- package/scripts/lib/validate/check-unwired-features.mjs +219 -11
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
- package/scripts/lib/validate/frontmatter-block.mjs +61 -0
- package/scripts/lib/validate/tier-inference.mjs +46 -8
- package/scripts/lib/vault-mirror/namespace.mjs +146 -1
- package/scripts/lib/vault-mirror/process.mjs +264 -31
- package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
- package/scripts/lib/vault-status/board-writer.mjs +300 -56
- package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
- package/scripts/lib/vcs-repo-spec.mjs +500 -19
- package/scripts/print-learnings-index.mjs +30 -3
- package/scripts/release.mjs +616 -61
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/validate-plugin.mjs +61 -0
- package/scripts/validate-wave-scope.mjs +281 -12
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/state-ownership.md +17 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- package/skills/discovery/issue-templates.md +4 -4
- package/skills/discovery/probes-code.md +2 -2
- package/skills/discovery/probes-feature.md +6 -6
- package/skills/discovery/probes-infra.md +2 -2
- package/skills/discovery/probes-session.md +5 -5
- package/skills/dispatcher/SKILL.md +10 -1
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +49 -39
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/memory-cleanup/SKILL.md +18 -5
- package/skills/npm-publish/SKILL.md +22 -50
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/mode-new.md +23 -5
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +44 -2
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/session-metrics-write.md +4 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +13 -1
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/tmux-layout/SKILL.md +7 -2
- package/skills/vault-mirror/SKILL.md +10 -1
- package/skills/vault-sync/SKILL.md +10 -1
- package/skills/vault-sync/validator.mjs +55 -6
- package/skills/wave-executor/wave-loop.md +42 -12
- 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 {
|
|
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
|
|
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
|
-
//
|
|
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 =
|
|
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
|
|
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
|
-
* @
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
if (
|
|
432
|
-
|
|
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
|
-
* -
|
|
10
|
-
*
|
|
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
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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(
|
|
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 `
|
|
219
|
+
return `rule fails the never-always-on invariant: ${problems.join('; ')}`;
|
|
181
220
|
}
|
|
182
221
|
|
|
183
222
|
// ---------------------------------------------------------------------------
|