session-orchestrator 5.3.0 → 5.4.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/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.agents/skills/navigator/SKILL.md +22 -0
- package/.agents/skills/navigator/agents/openai.yaml +5 -0
- package/.agents/skills/test-audit/SKILL.md +21 -0
- package/.agents/skills/test-audit/agents/openai.yaml +5 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.codex-plugin/skills/navigator/SKILL.md +22 -0
- package/.codex-plugin/skills/navigator/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
- package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
- package/.cursor/commands/navigator.md +15 -0
- package/.cursor/commands/test-audit.md +14 -0
- package/.cursor/rules/060-evolve.mdc +27 -12
- package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.cursor/skills/navigator/SKILL.md +13 -0
- package/.cursor/skills/test-audit/SKILL.md +12 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +1 -1
- package/AGENTS.md +9 -31
- package/CHANGELOG.md +70 -0
- package/README.md +14 -10
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +10 -1
- package/docs/ci-setup.md +57 -4
- package/docs/components.md +12 -12
- package/docs/events-schema.md +11 -8
- package/docs/persona-panel.md +5 -4
- package/docs/rule-authoring.md +27 -12
- package/docs/scope-collision-guard.md +26 -4
- package/docs/session-config-reference.md +55 -6
- package/docs/session-config-template.md +11 -2
- package/hooks/_lib/hook-import-set.json +27 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks-pi.json +5 -0
- package/hooks/hooks.json +6 -1
- package/hooks/on-session-start.mjs +53 -0
- package/hooks/on-stop.mjs +3 -1
- package/hooks/post-tool-batch-wave-signal.mjs +3 -1
- package/hooks/pre-bash-memory-propose-audit.mjs +52 -3
- package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
- package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
- package/hooks/pre-task-scope-disjoint.mjs +559 -72
- package/hooks/subagent-telemetry.mjs +96 -19
- package/package.json +3 -3
- package/pi/prompts/navigator.md +12 -0
- package/pi/prompts/test-audit.md +12 -0
- package/rules/README.md +6 -1
- package/rules/_index.md +3 -3
- package/rules/always-on/bash-harness-pitfalls.md +7 -1
- package/rules/opt-in-domain/prompt-caching.md +5 -1
- package/rules/opt-in-stack/backend-data.md +9 -1
- package/rules/opt-in-stack/backend.md +7 -1
- package/rules/opt-in-stack/frontend.md +6 -1
- package/rules/opt-in-stack/security-web.md +7 -1
- package/rules/opt-in-stack/swift.md +5 -1
- package/scripts/apply-session-learnings.mjs +492 -0
- package/scripts/archive-closed-prds.mjs +2 -1
- package/scripts/autopilot.mjs +49 -53
- package/scripts/backfill-abandoned-sessions.mjs +1 -1
- package/scripts/baseline-archetypes.mjs +1 -1
- package/scripts/check-package-manager.mjs +62 -5
- package/scripts/cursor-install.mjs +1 -4
- package/scripts/emit-session.mjs +296 -2
- package/scripts/export-hw-learnings.mjs +22 -3
- package/scripts/fleet-checkin.mjs +147 -0
- package/scripts/lib/agent-frontmatter.mjs +3 -3
- package/scripts/lib/auto-dream.mjs +7 -6
- package/scripts/lib/autopilot/flags.mjs +6 -1
- package/scripts/lib/autopilot/loop.mjs +10 -6
- package/scripts/lib/baseline-archetypes.mjs +41 -22
- package/scripts/lib/config/host-paths.mjs +79 -0
- package/scripts/lib/config/persona-gate-wave.mjs +3 -3
- package/scripts/lib/config/reaper.mjs +7 -6
- package/scripts/lib/config-schema.mjs +13 -0
- package/scripts/lib/config.mjs +71 -10
- package/scripts/lib/events.mjs +21 -2
- package/scripts/lib/fleet-protocol.mjs +270 -0
- package/scripts/lib/gates/gate-baseline.mjs +13 -3
- package/scripts/lib/gates/gate-full.mjs +7 -2
- package/scripts/lib/gates/gate-helpers.mjs +93 -2
- package/scripts/lib/gates/gate-incremental.mjs +23 -22
- package/scripts/lib/gates/gate-per-file.mjs +16 -7
- package/scripts/lib/instruction-budget-guard.mjs +69 -11
- package/scripts/lib/learnings/expiry-sweep.mjs +34 -3
- package/scripts/lib/learnings/io.mjs +154 -26
- package/scripts/lib/learnings/schema.mjs +163 -15
- package/scripts/lib/learnings/sizing-subject.mjs +2 -2
- package/scripts/lib/memory-proposals/schema.mjs +11 -5
- package/scripts/lib/named-baseline-resolver.mjs +53 -28
- package/scripts/lib/named-vault-resolver.mjs +104 -5
- package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
- package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +291 -0
- package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +92 -0
- package/scripts/lib/orphan-reaper/scan.mjs +506 -0
- package/scripts/lib/orphan-reaper.mjs +43 -1315
- package/scripts/lib/owner-yaml.mjs +104 -6
- package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
- package/scripts/lib/process-group.mjs +62 -11
- package/scripts/lib/project-hygiene.mjs +21 -1
- package/scripts/lib/quality-gate.mjs +45 -5
- package/scripts/lib/resource-probe.mjs +1 -1
- package/scripts/lib/rule-loader.mjs +100 -62
- package/scripts/lib/rules-sync.mjs +27 -12
- package/scripts/lib/scope-baseline.mjs +3 -4
- package/scripts/lib/session-record-repair.mjs +183 -26
- package/scripts/lib/session-registry.mjs +27 -2
- package/scripts/lib/session-schema/constants.mjs +30 -4
- package/scripts/lib/session-schema/validator.mjs +28 -1
- package/scripts/lib/session-token-rollup.mjs +245 -52
- package/scripts/lib/sessions-canonical.mjs +100 -9
- package/scripts/lib/sh-quote.mjs +29 -0
- package/scripts/lib/subagents-schema.mjs +30 -3
- package/scripts/lib/telemetry/pricing.mjs +118 -71
- package/scripts/lib/tests-src-ratio.mjs +140 -23
- package/scripts/lib/tmux-layout/layouts.mjs +1 -11
- package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
- package/scripts/lib/ux-grill/reconcile.mjs +1 -1
- package/scripts/lib/validate/check-agents.mjs +1 -1
- package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +87 -14
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/validate-vendored-rules.mjs +206 -81
- package/scripts/lib/vault-mirror/telemetry.mjs +8 -1
- package/scripts/lib/vault-status/board-writer.mjs +48 -11
- package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
- package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
- package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
- package/scripts/lib/wave-resource-gate.mjs +34 -8
- package/scripts/memory-propose.mjs +18 -5
- package/scripts/release-session-lock.mjs +2 -1
- package/scripts/release.mjs +10 -3
- package/scripts/repair-invalid-sessions.mjs +37 -4
- package/scripts/run-migrate-v2-cross-repo.mjs +158 -30
- package/scripts/run-quality-gate.mjs +323 -302
- package/scripts/site-numbers.mjs +163 -5
- package/scripts/sweep-expired-learnings.mjs +56 -5
- package/scripts/sweep-expired-rules.mjs +2 -1
- package/scripts/validate-plugin.mjs +7 -0
- package/scripts/vault-integration-watcher.mjs +2 -1
- package/scripts/vault-mirror.mjs +72 -46
- package/scripts/wave-scope-binding.mjs +2 -1
- package/skills/_shared/config-reading.md +3 -3
- package/skills/_shared/fleet-protocol.md +131 -0
- package/skills/_shared/state-ownership.md +5 -0
- package/skills/autopilot/SKILL.md +25 -11
- package/skills/bootstrap/SKILL.md +1 -1
- package/skills/bootstrap/_shared-template.md +1 -1
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/bootstrap/private-contract.md +2 -1
- package/skills/bootstrap/public-fallback.md +6 -3
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
- package/skills/claude-md-drift-check/SKILL.md +10 -10
- package/skills/claude-md-drift-check/checker.mjs +26 -18
- package/skills/evolve/SKILL.md +2 -2
- package/skills/evolve/references/evolve-analyze-mode.md +10 -6
- package/skills/memory-cleanup/SKILL.md +1 -1
- package/skills/navigator/SKILL.md +193 -0
- package/skills/navigator/references/account-check.sh +87 -0
- package/skills/navigator/references/fleet-decisions.mjs +231 -0
- package/skills/navigator/references/slot-check.sh +114 -0
- package/skills/persona-panel/SKILL.md +2 -2
- package/skills/persona-panel/persona-format.md +2 -2
- package/skills/persona-panel/presets/designer-lens.md +1 -1
- package/skills/persona-panel/presets/engineer-lens.md +1 -1
- package/skills/persona-panel/presets/pm-lens.md +1 -1
- package/skills/plan/mode-retro.md +19 -4
- package/skills/quality-gates/SKILL.md +6 -4
- package/skills/remote-offload/SKILL.md +4 -2
- package/skills/session-end/learning-patterns.md +22 -12
- package/skills/session-end/metrics-collection.md +5 -6
- package/skills/session-end/references/phase-3-documentation-updates.md +2 -2
- package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
- package/skills/session-end/references/session-summary-template.md +1 -1
- package/skills/session-end/session-metrics-write.md +22 -60
- package/skills/session-plan/SKILL.md +0 -3
- package/skills/session-start/SKILL.md +6 -0
- package/skills/session-start/references/operations-contract.md +4 -0
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -2
- package/skills/test-audit/SKILL.md +152 -0
- package/skills/test-audit/references/campaign.md +111 -0
- package/skills/test-audit/references/mutate.sh +50 -0
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +3 -1
- package/skills/wave-executor/references/wave-loop-review.md +3 -5
- package/templates/personas/README.md +5 -5
- package/templates/personas/accounting-compliance.v1.md +1 -1
- package/templates/personas/accounting-tax-advisor.v1.md +1 -1
- package/templates/personas/buyer-p1-cto.v1.md +1 -1
- package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
- package/templates/personas/buyer-p3-build.v1.md +1 -1
- package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
- package/templates/personas/buyer-p5-solo.v1.md +1 -1
- package/templates/personas/buyer-p6-ld.v1.md +1 -1
- package/templates/personas/klima-ai-expert.v1.md +1 -1
- package/templates/personas/klima-physicist.v1.md +1 -1
|
@@ -6,17 +6,21 @@
|
|
|
6
6
|
* Catches vendoring bugs that are invisible at the source-of-truth layer but
|
|
7
7
|
* become live footguns once synced into a target repo:
|
|
8
8
|
*
|
|
9
|
-
* - `
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* - `frontmatter-not-at-top` — a `---` block preceded by anything (the
|
|
10
|
+
* provenance comment, a blank line). Claude Code reads frontmatter only
|
|
11
|
+
* from line 1 and reads only its `paths:` key, so such a rule silently
|
|
12
|
+
* loads ALWAYS-ON in every consumer repo (#1449).
|
|
13
|
+
* - `globs-paths-mismatch` — a library rule's frontmatter must carry
|
|
14
|
+
* `globs:` AND `paths:` with the identical list (order-insensitive), or
|
|
15
|
+
* neither. `paths:` is what Claude Code reads; `globs:` stays because
|
|
16
|
+
* `rule-loader.mjs` prefers it (#742/#795). One key alone, or two
|
|
17
|
+
* different lists, scopes the rule differently per loader.
|
|
15
18
|
* - Missing provenance header — `rules-sync.mjs` detects "plugin-owned vs.
|
|
16
|
-
* local override"
|
|
17
|
-
* `PLUGIN_HEADER_PREFIX
|
|
18
|
-
*
|
|
19
|
-
*
|
|
19
|
+
* local override" via `isPluginOwnedContent()`: the header line starting
|
|
20
|
+
* with `PLUGIN_HEADER_PREFIX` on line 1 (a rule without frontmatter) or as
|
|
21
|
+
* the first non-blank line after a line-1 frontmatter block. A source file
|
|
22
|
+
* missing it gets mis-detected as a local override on the NEXT re-sync (it
|
|
23
|
+
* looks hand-authored), so the plugin can never update it again.
|
|
20
24
|
* - Unfilled placeholder tokens (`{{PROJECT_NAME}}`-style, `## TODO:
|
|
21
25
|
* Customize` headings, `<!-- TODO:` comments) leaking from an
|
|
22
26
|
* un-filled-in skeleton into a synced rule.
|
|
@@ -30,9 +34,10 @@
|
|
|
30
34
|
* Stdlib-only ESM, with the same picomatch-with-fallback resolution pattern
|
|
31
35
|
* as `rule-loader.mjs` (duplicated here rather than imported because those
|
|
32
36
|
* helpers are module-private there — the *approach* is reused, not
|
|
33
|
-
* reinvented).
|
|
34
|
-
*
|
|
35
|
-
* `
|
|
37
|
+
* reinvented). The globs-derived probes use the already-exported
|
|
38
|
+
* `parseGlobsFrontmatter` from `rule-loader.mjs` directly; only
|
|
39
|
+
* `globs-paths-mismatch` reads the two keys itself (`readScopeKeys`), because
|
|
40
|
+
* the loader merges them and cannot say which key carried which list.
|
|
36
41
|
*
|
|
37
42
|
* ## Scope: the `rules/` fleet library, never a repo's own `.claude/rules/`
|
|
38
43
|
*
|
|
@@ -43,15 +48,14 @@
|
|
|
43
48
|
* written target is never read back through the gate. The CLI's `--dir` is
|
|
44
49
|
* operator-supplied and means that same library.
|
|
45
50
|
*
|
|
46
|
-
* This
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* probe for `.claude/rules/`.
|
|
51
|
+
* This matters for `globs-paths-mismatch` and the provenance probe: they encode
|
|
52
|
+
* the LIBRARY convention (both scope keys, a provenance header). A repo's own
|
|
53
|
+
* CONSOLIDATED rules under `.claude/rules/` are `paths:`-only and header-free
|
|
54
|
+
* by design, because Claude Code's native loader reads ONLY `paths:`
|
|
55
|
+
* (`validate/check-rules.mjs` #1108; `docs/rule-authoring.md` § Consolidated
|
|
56
|
+
* rules point 3). Measured 2026-09-16 in this repo: 10 path-scoped rule files
|
|
57
|
+
* there, 9 of them `paths:`-only. Pointing `--dir` at such a tree reports
|
|
58
|
+
* findings about a convention those files were never meant to follow.
|
|
55
59
|
*
|
|
56
60
|
* @module validate-vendored-rules
|
|
57
61
|
*/
|
|
@@ -61,7 +65,7 @@ import { join, resolve, relative, basename } from 'node:path';
|
|
|
61
65
|
import { fileURLToPath } from 'node:url';
|
|
62
66
|
import { execFileSync } from 'node:child_process';
|
|
63
67
|
import { createRequire } from 'node:module';
|
|
64
|
-
import { parseGlobsFrontmatter } from './rule-loader.mjs';
|
|
68
|
+
import { parseGlobsFrontmatter, FRONTMATTER_NOT_AT_TOP } from './rule-loader.mjs';
|
|
65
69
|
import { isMainModule } from './is-main-module.mjs';
|
|
66
70
|
|
|
67
71
|
// Mirrors rules-sync.mjs's exported PLUGIN_HEADER_PREFIX (rules-sync.mjs
|
|
@@ -137,21 +141,26 @@ function matchGlob(filePath, globPattern) {
|
|
|
137
141
|
// Frontmatter helpers
|
|
138
142
|
// ---------------------------------------------------------------------------
|
|
139
143
|
|
|
144
|
+
// A line that may precede a MISPLACED frontmatter opener: blank, or one
|
|
145
|
+
// complete single-line HTML comment (the provenance header's shape).
|
|
140
146
|
const HEADER_LINE_RE = /^[ \t]*(?:<!--.*-->)?[ \t]*$/;
|
|
141
147
|
|
|
142
148
|
/**
|
|
143
|
-
*
|
|
149
|
+
* Locates a YAML frontmatter block delimited by `---` lines.
|
|
144
150
|
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
151
|
+
* Deliberately tolerant of a leading run of blank lines and/or single-line
|
|
152
|
+
* HTML comments before the opener — NOT because such a block is valid (Claude
|
|
153
|
+
* Code reads frontmatter only when `---` is line 1, and `rule-loader.mjs`
|
|
154
|
+
* no longer tolerates the prefix either), but so `frontmatter-not-at-top` can
|
|
155
|
+
* SEE the misplaced block and report it. Callers that judge what a loader
|
|
156
|
+
* reads must check `startLine === 0` first.
|
|
148
157
|
*
|
|
149
|
-
* @param {string} content
|
|
150
|
-
* @returns {{
|
|
151
|
-
* 0-based file-line
|
|
158
|
+
* @param {string[]} lines - file content split on `\r?\n`
|
|
159
|
+
* @returns {{ bodyLines: string[], startLine: number, endLine: number } | null}
|
|
160
|
+
* startLine / endLine are the 0-based file-line indices of the opening and
|
|
161
|
+
* closing `---`.
|
|
152
162
|
*/
|
|
153
|
-
function
|
|
154
|
-
const lines = content.split(/\r?\n/);
|
|
163
|
+
function locateFrontmatter(lines) {
|
|
155
164
|
let startLine = 0;
|
|
156
165
|
while (startLine < lines.length && HEADER_LINE_RE.test(lines[startLine])) {
|
|
157
166
|
startLine++;
|
|
@@ -159,22 +168,93 @@ function extractFrontmatter(content) {
|
|
|
159
168
|
if (lines[startLine] !== '---') return null;
|
|
160
169
|
for (let i = startLine + 1; i < lines.length; i++) {
|
|
161
170
|
if (lines[i] === '---') {
|
|
162
|
-
return {
|
|
171
|
+
return { bodyLines: lines.slice(startLine + 1, i), startLine, endLine: i };
|
|
163
172
|
}
|
|
164
173
|
}
|
|
165
174
|
return null;
|
|
166
175
|
}
|
|
167
176
|
|
|
168
177
|
/**
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
178
|
+
* True when `content` carries the plugin provenance header in one of the two
|
|
179
|
+
* positions the vendoring convention allows (#1449):
|
|
180
|
+
*
|
|
181
|
+
* (a) line 1 — a rule WITHOUT frontmatter, and also every vendored copy
|
|
182
|
+
* written before #1449 (header on line 1, frontmatter after it);
|
|
183
|
+
* (b) the first non-blank line after the closing `---` of a frontmatter
|
|
184
|
+
* block that opens on line 1 — the current format for a rule WITH
|
|
185
|
+
* frontmatter, because Claude Code reads `paths:` only from a line-1
|
|
186
|
+
* block.
|
|
187
|
+
*
|
|
188
|
+
* This is the ownership predicate `rules-sync.mjs` uses to tell a
|
|
189
|
+
* plugin-owned vendored copy (rewrite it) from a local rule (preserve it), and
|
|
190
|
+
* the `provenance-header` probe's acceptance test. Accepting (a) with a
|
|
191
|
+
* frontmatter after it keeps old-format consumer copies plugin-owned, so the
|
|
192
|
+
* next sync REWRITES them into the new format instead of preserving them
|
|
193
|
+
* forever. Lives here rather than in rules-sync.mjs because rules-sync.mjs
|
|
194
|
+
* imports from this module; it re-exports this function.
|
|
195
|
+
*
|
|
196
|
+
* @param {string} content - raw rule file content
|
|
197
|
+
* @returns {boolean}
|
|
175
198
|
*/
|
|
176
|
-
function
|
|
177
|
-
|
|
199
|
+
export function isPluginOwnedContent(content) {
|
|
200
|
+
const lines = content.split(/\r?\n/);
|
|
201
|
+
if ((lines[0] ?? '').startsWith(PLUGIN_HEADER_PREFIX)) return true;
|
|
202
|
+
const fm = locateFrontmatter(lines);
|
|
203
|
+
if (!fm || fm.startLine !== 0) return false;
|
|
204
|
+
const next = lines.slice(fm.endLine + 1).find((l) => l.trim() !== '');
|
|
205
|
+
return next !== undefined && next.startsWith(PLUGIN_HEADER_PREFIX);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Strips one pair of surrounding quotes, as rule-loader.mjs does for list items.
|
|
210
|
+
* @param {string} s
|
|
211
|
+
* @returns {string}
|
|
212
|
+
*/
|
|
213
|
+
function unquoteItem(s) {
|
|
214
|
+
return s.trim().replace(/^["']|["']$/g, '');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Reads the `globs:` and `paths:` sequences of a frontmatter body EACH ON ITS
|
|
219
|
+
* OWN. `parseGlobsFrontmatter()` cannot answer this: it merges the two keys by
|
|
220
|
+
* design (`globs:` wins, `paths:` is discarded), so it cannot say which key
|
|
221
|
+
* carried which list. Accepts the three value shapes rule-loader.mjs accepts —
|
|
222
|
+
* indented block list, flow list `[a, b]`, single inline value.
|
|
223
|
+
*
|
|
224
|
+
* @param {string[]} bodyLines - frontmatter lines between the `---` markers
|
|
225
|
+
* @returns {{ globs?: { values: string[], index: number }, paths?: { values: string[], index: number } }}
|
|
226
|
+
* `index` is the 0-based body line of the key.
|
|
227
|
+
*/
|
|
228
|
+
function readScopeKeys(bodyLines) {
|
|
229
|
+
/** @type {{ globs?: { values: string[], index: number }, paths?: { values: string[], index: number } }} */
|
|
230
|
+
const found = {};
|
|
231
|
+
let active = null;
|
|
232
|
+
bodyLines.forEach((raw, index) => {
|
|
233
|
+
const line = raw.replace(/\s+$/, '');
|
|
234
|
+
if (line === '' || /^\s*#/.test(line)) return;
|
|
235
|
+
if (active) {
|
|
236
|
+
const item = /^\s+-\s+(.*)$/.exec(line);
|
|
237
|
+
if (item) {
|
|
238
|
+
found[active].values.push(unquoteItem(item[1]));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
active = null;
|
|
242
|
+
}
|
|
243
|
+
const key = /^(globs|paths):\s*(.*)$/.exec(line);
|
|
244
|
+
if (!key) return;
|
|
245
|
+
const [, name, value] = key;
|
|
246
|
+
let values = [];
|
|
247
|
+
if (value === '') {
|
|
248
|
+
active = name;
|
|
249
|
+
} else if (value.startsWith('[') && value.endsWith(']')) {
|
|
250
|
+
const inner = value.slice(1, -1).trim();
|
|
251
|
+
values = inner === '' ? [] : inner.split(',').map(unquoteItem);
|
|
252
|
+
} else {
|
|
253
|
+
values = [unquoteItem(value)];
|
|
254
|
+
}
|
|
255
|
+
found[name] = { values, index };
|
|
256
|
+
});
|
|
257
|
+
return found;
|
|
178
258
|
}
|
|
179
259
|
|
|
180
260
|
/**
|
|
@@ -463,7 +543,7 @@ const FOREIGN_TOKEN_RE = /[A-Z][a-z]+[A-Z]/;
|
|
|
463
543
|
|
|
464
544
|
/**
|
|
465
545
|
* @typedef {object} RuleViolation
|
|
466
|
-
* @property {string} rule - check id (paths-
|
|
546
|
+
* @property {string} rule - check id (frontmatter-not-at-top | globs-paths-mismatch | provenance-header | placeholder | zero-match-globs | foreign-glob)
|
|
467
547
|
* @property {'error'|'warn'} severity
|
|
468
548
|
* @property {string} message
|
|
469
549
|
* @property {number} [line] - 1-based line number, when known
|
|
@@ -486,52 +566,94 @@ export function validateRuleContent({ content, relPath, targetRoot = null, requi
|
|
|
486
566
|
/** @type {RuleViolation[]} */
|
|
487
567
|
const violations = [];
|
|
488
568
|
|
|
489
|
-
|
|
490
|
-
const fm =
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
569
|
+
const lines = content.split(/\r?\n/);
|
|
570
|
+
const fm = locateFrontmatter(lines);
|
|
571
|
+
|
|
572
|
+
// ---- frontmatter-not-at-top (error) ----
|
|
573
|
+
// Claude Code reads frontmatter only when `---` is line 1. Anything before
|
|
574
|
+
// it (the provenance comment, a blank line) makes it see NO frontmatter, so
|
|
575
|
+
// a path-scoped rule loads ALWAYS-ON in every consumer repo, with no error.
|
|
576
|
+
// Two detectors, one verdict (BV-003): `locateFrontmatter` sees a block
|
|
577
|
+
// behind blank lines / single-line comments (the provenance shape);
|
|
578
|
+
// `parseGlobsFrontmatter` (the loader — the same code the wave injector
|
|
579
|
+
// runs) additionally sees one behind ANY text, e.g. a heading. Either
|
|
580
|
+
// signal is the defect; the finding is reported once.
|
|
581
|
+
const loaderDefect = parseGlobsFrontmatter(content).defect === FRONTMATTER_NOT_AT_TOP;
|
|
582
|
+
if ((fm && fm.startLine !== 0) || (!(fm && fm.startLine === 0) && loaderDefect)) {
|
|
583
|
+
const openerLine = fm ? fm.startLine + 1 : lines.findIndex((l) => l === '---') + 1;
|
|
584
|
+
violations.push({
|
|
585
|
+
rule: 'frontmatter-not-at-top',
|
|
586
|
+
severity: 'error',
|
|
587
|
+
message:
|
|
588
|
+
`${relPath}: the frontmatter block opens on line ${openerLine}, not line 1. ` +
|
|
589
|
+
`Claude Code reads frontmatter only when '---' is the very first line, so it sees none ` +
|
|
590
|
+
`here and loads this rule always-on, ignoring its 'paths:' scope. Move the block to the ` +
|
|
591
|
+
`top; the provenance header goes on the first line after the closing '---'.`,
|
|
592
|
+
line: 1,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// ---- globs-paths-mismatch (error) ----
|
|
597
|
+
// Judged only on a line-1 block: a misplaced block is read by no loader,
|
|
598
|
+
// and frontmatter-not-at-top already reports it.
|
|
599
|
+
if (fm && fm.startLine === 0) {
|
|
600
|
+
const { globs, paths } = readScopeKeys(fm.bodyLines);
|
|
601
|
+
// body index i sits on 1-based file line i + 2 when the opener is line 1.
|
|
602
|
+
const keyLine = (k) => k.index + 2;
|
|
603
|
+
let detail = null;
|
|
604
|
+
let line;
|
|
605
|
+
if (globs && !paths) {
|
|
606
|
+
detail =
|
|
607
|
+
`declares 'globs:' but no 'paths:'. Claude Code reads only 'paths:', so it loads this ` +
|
|
608
|
+
`rule always-on. Add 'paths:' carrying the identical list.`;
|
|
609
|
+
line = keyLine(globs);
|
|
610
|
+
} else if (paths && !globs) {
|
|
611
|
+
detail =
|
|
612
|
+
`declares 'paths:' but no 'globs:'. rule-loader.mjs prefers 'globs:' (#742/#795); a ` +
|
|
613
|
+
`rule vendored out through the plugin's rules/ library carries both keys. Add 'globs:' ` +
|
|
614
|
+
`carrying the identical list. This does NOT apply to a repo's own consolidated ` +
|
|
615
|
+
`.claude/rules/ files — those are paths:-only by design and this gate never scans them.`;
|
|
616
|
+
line = keyLine(paths);
|
|
617
|
+
} else if (globs && paths) {
|
|
618
|
+
const g = new Set(globs.values);
|
|
619
|
+
const p = new Set(paths.values);
|
|
620
|
+
const onlyGlobs = [...g].filter((x) => !p.has(x));
|
|
621
|
+
const onlyPaths = [...p].filter((x) => !g.has(x));
|
|
622
|
+
if (onlyGlobs.length > 0 || onlyPaths.length > 0) {
|
|
623
|
+
detail =
|
|
624
|
+
`'globs:' and 'paths:' carry different lists (only in globs: ${JSON.stringify(onlyGlobs)}; ` +
|
|
625
|
+
`only in paths: ${JSON.stringify(onlyPaths)}). Claude Code scopes by 'paths:', ` +
|
|
626
|
+
`rule-loader.mjs by 'globs:', so the two would activate this rule on different files. ` +
|
|
627
|
+
`Make both lists identical.`;
|
|
628
|
+
line = keyLine(globs.index <= paths.index ? globs : paths);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (detail) {
|
|
494
632
|
violations.push({
|
|
495
|
-
rule: 'paths-
|
|
633
|
+
rule: 'globs-paths-mismatch',
|
|
496
634
|
severity: 'error',
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
// since #795 — `rule-loader.mjs:275` accepts `paths` alongside `globs`, and `:313`
|
|
500
|
-
// treats it as a fallback alias (`globs:` wins silently when both are present), so a
|
|
501
|
-
// `paths:`-only rule IS glob-scoped. The probe itself stays: it enforces the canonical
|
|
502
|
-
// vendoring form, which is a convention gate, not a loader-compatibility gate. That
|
|
503
|
-
// intent survives #795 and is the subject of the #742 fleet canonicalisation sweep.
|
|
504
|
-
// SCOPE (2026-09-16): this fires only over `rules/` library sources — the module doc's
|
|
505
|
-
// § Scope section names every caller. Do NOT re-point it at a repo's own
|
|
506
|
-
// `.claude/rules/`: those consolidated files are `paths:`-canonical and obeying this
|
|
507
|
-
// remedy there would undo the consolidation (docs/rule-authoring.md point 3).
|
|
508
|
-
message:
|
|
509
|
-
`${relPath}: frontmatter declares a top-level 'paths:' key. It is a recognized alias ` +
|
|
510
|
-
`for 'globs:' (issue #795), so the rule does load glob-scoped — but 'globs:' is the ` +
|
|
511
|
-
`canonical form for a rule VENDORED OUT through the plugin's rules/ library, which is ` +
|
|
512
|
-
`this probe's only population. Migrate to 'globs:' (see issue #742). This does NOT ` +
|
|
513
|
-
`apply to a repo's own consolidated .claude/rules/ files — those are paths:-canonical ` +
|
|
514
|
-
`(Claude Code's native loader reads only 'paths:') and this gate never scans them.`,
|
|
515
|
-
line: lineWithinFrontmatter(fm.startLine, fm.body, pathsMatch.index),
|
|
635
|
+
message: `${relPath}: frontmatter ${detail}`,
|
|
636
|
+
line,
|
|
516
637
|
});
|
|
517
638
|
}
|
|
518
639
|
}
|
|
519
640
|
|
|
520
641
|
// ---- provenance-header (error, opt-in) ----
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
line
|
|
533
|
-
|
|
534
|
-
|
|
642
|
+
// A header on line 1 followed by a frontmatter is accepted HERE (the file
|
|
643
|
+
// has a header, and isPluginOwnedContent must keep old vendored copies
|
|
644
|
+
// plugin-owned); frontmatter-not-at-top reports that shape, once.
|
|
645
|
+
if (requireProvenance && !isPluginOwnedContent(content)) {
|
|
646
|
+
violations.push({
|
|
647
|
+
rule: 'provenance-header',
|
|
648
|
+
severity: 'error',
|
|
649
|
+
message:
|
|
650
|
+
`${relPath}: missing provenance header — a line starting with ` +
|
|
651
|
+
`${JSON.stringify(PLUGIN_HEADER_PREFIX)} (see rules-sync.mjs PLUGIN_HEADER_PREFIX) must ` +
|
|
652
|
+
`stand either on line 1 (a rule without frontmatter) or on the first non-blank line after ` +
|
|
653
|
+
`the closing '---' of a frontmatter that opens on line 1. Without it, rules-sync.mjs ` +
|
|
654
|
+
`mis-detects this file as a local override on the next re-sync and will never update it again.`,
|
|
655
|
+
line: 1,
|
|
656
|
+
});
|
|
535
657
|
}
|
|
536
658
|
|
|
537
659
|
// ---- placeholder (error) — skip matches inside fenced code blocks ----
|
|
@@ -569,6 +691,9 @@ export function validateRuleContent({ content, relPath, targetRoot = null, requi
|
|
|
569
691
|
}
|
|
570
692
|
|
|
571
693
|
// ---- globs: derived checks (zero-match-globs, foreign-glob) ----
|
|
694
|
+
// Driven by the loader's own parse, so these judge exactly the list
|
|
695
|
+
// rule-loader.mjs will scope by (`globs:`, else its `paths:` alias). On a
|
|
696
|
+
// misplaced block that yields no globs — frontmatter-not-at-top covers it.
|
|
572
697
|
let globs;
|
|
573
698
|
try {
|
|
574
699
|
({ globs } = parseGlobsFrontmatter(content));
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
* records that between them said nothing happened (#1151). Its count is
|
|
12
12
|
* carried by the run event's `skipped` total and `action_breakdown`, so the
|
|
13
13
|
* class stays measured — only its per-line locators are gone.
|
|
14
|
+
* `skipped-duplicate-session` (#1291) is one of these per-entry records:
|
|
15
|
+
* a `--kind session` line whose `session_id` a later line in the same
|
|
16
|
+
* source superseded, so it was never dispatched. It carries `record_id`
|
|
17
|
+
* and `line` and never `path` — no target file exists for it.
|
|
14
18
|
* - `orchestrator.vault.mirror_run_completed` — ONE record per CLI run,
|
|
15
19
|
* carrying the DENOMINATOR (`total` plus the per-class counts).
|
|
16
20
|
*
|
|
@@ -162,7 +166,10 @@ export async function emitMirrorEvent({
|
|
|
162
166
|
* @param {number} opts.updated — entries whose action was `updated`.
|
|
163
167
|
* @param {number} opts.skipped — entries skipped for a NON-failure reason
|
|
164
168
|
* (`skipped-noop`, `skipped-handwritten`, `skipped-quality-low`,
|
|
165
|
-
* `skipped-collision-resolved`, `skipped-abandoned
|
|
169
|
+
* `skipped-collision-resolved`, `skipped-abandoned`,
|
|
170
|
+
* `skipped-duplicate-session`). The last one (#1291) is what keeps
|
|
171
|
+
* `created + updated + skipped + failed === total` true for a `--kind
|
|
172
|
+
* session` run whose dedup pass collapsed a duplicate `session_id` line.
|
|
166
173
|
* @param {number} opts.failed — entries that produced `skipped-invalid`
|
|
167
174
|
* (validation error or mapper crash). Split out from `skipped` because these
|
|
168
175
|
* are the runs where a session silently ends up WITHOUT its vault note.
|
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Telemetry: every {@link mirrorBoard} call — and therefore every
|
|
14
14
|
* {@link sweepBoard} call — emits exactly ONE {@link BOARD_EVENT} record,
|
|
15
|
-
* including the no-op paths (`skipped-vault-disabled`,
|
|
16
|
-
* `skipped-
|
|
15
|
+
* including the no-op paths (`skipped-vault-disabled`,
|
|
16
|
+
* `skipped-vault-not-canonical`, `skipped-handwritten`, `skipped-noop`,
|
|
17
|
+
* `skipped-write-failed`). Those are the states that previously
|
|
17
18
|
* looked identical to a healthy write from outside the process. Emission is
|
|
18
19
|
* best-effort and can never fail a board write.
|
|
19
20
|
*
|
|
@@ -43,7 +44,8 @@
|
|
|
43
44
|
* 2. Defense-in-depth: writeBoard hard-refuses to touch `_overview.md`
|
|
44
45
|
* (sven-owned, must NEVER be written by this generator).
|
|
45
46
|
*
|
|
46
|
-
* No console noise — library code
|
|
47
|
+
* No console noise — library code — except the one stderr line on a
|
|
48
|
+
* canonical-vault refusal (#1450). Plain Node ESM. No external deps.
|
|
47
49
|
*/
|
|
48
50
|
|
|
49
51
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
@@ -60,6 +62,8 @@ import { enumerateCandidates } from '../dispatcher/enumerate.mjs';
|
|
|
60
62
|
import { atomicWriteWithBackup } from '../io.mjs';
|
|
61
63
|
import { withBoardLock } from './board-lock.mjs';
|
|
62
64
|
import { expandTilde } from '../common.mjs';
|
|
65
|
+
import { checkCanonicalVault, describeOriginForLog } from '../named-vault-resolver.mjs';
|
|
66
|
+
import { loadHostPaths } from '../config/host-paths.mjs';
|
|
63
67
|
|
|
64
68
|
/** Frontmatter sentinel that identifies generator-owned board files. */
|
|
65
69
|
export const GENERATOR_MARKER = 'session-orchestrator-active-sessions@1';
|
|
@@ -825,6 +829,8 @@ async function emitBoardEvent({ repoRoot, caller, action, path: outputPath, rows
|
|
|
825
829
|
* Vault path assertion (Epic #673 safety): the resolved vault dir MUST live
|
|
826
830
|
* under $HOME — a vault outside the home tree is refused as `skipped-vault-disabled`
|
|
827
831
|
* so a misconfigured path can never drive a write into an arbitrary location.
|
|
832
|
+
* It must also be a canonical Meta-Vault ({@link checkCanonicalVault}, #1450):
|
|
833
|
+
* otherwise `skipped-vault-not-canonical`, nothing written, no lock, dry-run included.
|
|
828
834
|
*
|
|
829
835
|
* @param {object} opts
|
|
830
836
|
* @param {string} opts.repoRoot — the repo whose row is being updated.
|
|
@@ -840,11 +846,14 @@ async function emitBoardEvent({ repoRoot, caller, action, path: outputPath, rows
|
|
|
840
846
|
* @param {Date} [opts.now]
|
|
841
847
|
* @param {boolean} [opts.dryRun]
|
|
842
848
|
* @param {object} [opts.fs] — injectable fs for tests.
|
|
843
|
-
* @param {{ env?: Record<string, string|undefined>, ownerConfig?: object
|
|
849
|
+
* @param {{ env?: Record<string, string|undefined>, ownerConfig?: object,
|
|
850
|
+
* readOriginUrl?: (vaultDir: string) => string }} [opts.hostPaths]
|
|
844
851
|
* — forwarded verbatim to {@link parseSessionConfig}'s `hostPaths` DI seam (issue #653).
|
|
852
|
+
* The same ctx feeds {@link checkCanonicalVault}; `readOriginUrl` replaces its
|
|
853
|
+
* `git remote get-url origin` read (tests only — tmp vaults have no origin).
|
|
845
854
|
* Tests MUST pass a hermetic ctx (e.g. `{ env: {}, ownerConfig: undefined }`) when
|
|
846
855
|
* asserting a fixture's committed `vault-dir` — omitting it reads the REAL host
|
|
847
|
-
* `owner.yaml`, whose `paths.vault-dir` override (if set) wins over the fixture value
|
|
856
|
+
* `owner.yaml`, whose `vault-dirs:` cwd match or `paths.vault-dir` override (if set) wins over the fixture value
|
|
848
857
|
* and bleeds into the assertion (issue #783). Production callers omit this — the
|
|
849
858
|
* default (real owner.yaml resolution) is the correct host-local behavior there.
|
|
850
859
|
* @returns {Promise<{ result: { action: string, path?: string }, rows?: number,
|
|
@@ -861,10 +870,19 @@ async function mirrorBoardInner({ repoRoot, repos, explicitStatus, now = new Dat
|
|
|
861
870
|
}
|
|
862
871
|
|
|
863
872
|
// Read + parse Session Config. Any failure → silent no-op.
|
|
873
|
+
// The host context is resolved ONCE here and reused by the canonical-vault
|
|
874
|
+
// guard below: loadOwnerConfig() has no cache and its section-drop WARNs are
|
|
875
|
+
// not rate-limited, so a second load would print every WARN of a broken
|
|
876
|
+
// owner.yaml twice per board write (#1450 follow-up). The config file is read
|
|
877
|
+
// BEFORE owner.yaml — the order narrative-mirror.mjs shares (#1479); vault-dir
|
|
878
|
+
// precedence is applied in parseSessionConfig, so the order changes only
|
|
879
|
+
// whether a repo without a config file reads owner.yaml at all.
|
|
864
880
|
let config;
|
|
881
|
+
let hostCtx;
|
|
865
882
|
try {
|
|
866
883
|
const text = await readConfigFile(repoRoot);
|
|
867
|
-
|
|
884
|
+
hostCtx = hostPaths ?? loadHostPaths();
|
|
885
|
+
config = parseSessionConfig(text, { hostPaths: hostCtx });
|
|
868
886
|
} catch {
|
|
869
887
|
return { result: { action: 'skipped-vault-disabled' } };
|
|
870
888
|
}
|
|
@@ -886,6 +904,24 @@ async function mirrorBoardInner({ repoRoot, repos, explicitStatus, now = new Dat
|
|
|
886
904
|
return { result: { action: 'skipped-vault-disabled' } };
|
|
887
905
|
}
|
|
888
906
|
|
|
907
|
+
// Safety: the vault must be a canonical Meta-Vault (#1450) — the same guard
|
|
908
|
+
// vault-mirror applies, so a vault-dir that resolved to a FOREIGN vault can
|
|
909
|
+
// never receive the board. Reuses the `hostCtx` the config parse used, so a
|
|
910
|
+
// hermetic ctx never reads the real owner.yaml and owner.yaml is loaded at
|
|
911
|
+
// most once. Runs before the dry-run branch and the lock: fail-closed.
|
|
912
|
+
const canonical = checkCanonicalVault({
|
|
913
|
+
vaultDir: expandedVault,
|
|
914
|
+
ownerConfig: hostCtx.ownerConfig,
|
|
915
|
+
env: hostCtx.env ?? process.env,
|
|
916
|
+
readOriginUrl: hostCtx.readOriginUrl,
|
|
917
|
+
});
|
|
918
|
+
if (!canonical.ok) {
|
|
919
|
+
process.stderr.write(
|
|
920
|
+
`vault-status board: refusing to write — vault is not the canonical Meta-Vault (expected git origin ending in one of: ${canonical.expected.join(', ')}; got ${describeOriginForLog(canonical.got) || 'no git origin'})\n`,
|
|
921
|
+
);
|
|
922
|
+
return { result: { action: 'skipped-vault-not-canonical' } };
|
|
923
|
+
}
|
|
924
|
+
|
|
889
925
|
// `vault-name` (#660) overrides the git-derived repo slug for per-project
|
|
890
926
|
// vault namespacing. Without it `collectRows` falls back to
|
|
891
927
|
// `path.basename(repoRoot)`, so a repo whose directory name differs from its
|
|
@@ -1112,10 +1148,10 @@ async function mirrorBoardInner({ repoRoot, repos, explicitStatus, now = new Dat
|
|
|
1112
1148
|
* Public {@link mirrorBoardInner} wrapper that emits exactly ONE
|
|
1113
1149
|
* {@link BOARD_EVENT} per call — on EVERY path, no-ops included.
|
|
1114
1150
|
*
|
|
1115
|
-
* The wrapper exists so the emit cannot be forgotten: the
|
|
1116
|
-
* inside {@link mirrorBoardInner} (five `skipped-vault-disabled` guards
|
|
1117
|
-
* whatever {@link writeBoard}
|
|
1118
|
-
*
|
|
1151
|
+
* The wrapper exists so the emit cannot be forgotten: the seven return points
|
|
1152
|
+
* inside {@link mirrorBoardInner} (five `skipped-vault-disabled` guards, the
|
|
1153
|
+
* `skipped-vault-not-canonical` guard, plus whatever {@link writeBoard}
|
|
1154
|
+
* decides) all funnel through here, and so does the eighth someone adds next. Emitting per-return instead would leave each new
|
|
1119
1155
|
* early return silent by default — which is the exact defect being fixed.
|
|
1120
1156
|
*
|
|
1121
1157
|
* A THROW from the inner function is deliberately NOT converted into an event:
|
|
@@ -1276,7 +1312,8 @@ export function buildSweepRepos(candidates, { thisRepoRoot } = {}) {
|
|
|
1276
1312
|
* @param {object} [opts.deps] — injectable deps for {@link enumerateCandidates}
|
|
1277
1313
|
* (test seam: `readdirSync`, `existsSync`, `readLock`, `isLockLive`,
|
|
1278
1314
|
* `getCrossRepoProjects`, `validatePathInsideProject`, `now`).
|
|
1279
|
-
* @param {{ env?: Record<string, string|undefined>, ownerConfig?: object
|
|
1315
|
+
* @param {{ env?: Record<string, string|undefined>, ownerConfig?: object,
|
|
1316
|
+
* readOriginUrl?: (vaultDir: string) => string }} [opts.hostPaths]
|
|
1280
1317
|
* — forwarded verbatim to every {@link mirrorBoard} call below (both the happy-path
|
|
1281
1318
|
* and the enumeration-failure-fallback path), which in turn forwards it to
|
|
1282
1319
|
* {@link parseSessionConfig}'s `hostPaths` DI seam (issue #653/#783). See
|
|
@@ -44,6 +44,8 @@ import { validatePathInsideProject } from '../path-utils.mjs';
|
|
|
44
44
|
import { createSecretValueMasker } from '../secret-masker.mjs';
|
|
45
45
|
import { expandTilde } from '../common.mjs';
|
|
46
46
|
import { readVaultSlug } from '../vault-yaml.mjs';
|
|
47
|
+
import { checkCanonicalVault, describeOriginForLog } from '../named-vault-resolver.mjs';
|
|
48
|
+
import { loadHostPaths } from '../config/host-paths.mjs';
|
|
47
49
|
|
|
48
50
|
/** Frontmatter sentinel that identifies generator-owned narrative files. */
|
|
49
51
|
export const GENERATOR_MARKER = 'session-orchestrator-vault-status-narrative@1';
|
|
@@ -763,9 +765,10 @@ async function emitMaskerEvent({ repoRoot, needleCount, hits, dryRun }) {
|
|
|
763
765
|
* before any write.
|
|
764
766
|
*
|
|
765
767
|
* THIS FUNCTION WRITES TO THE OPERATOR'S REAL VAULT UNLESS YOU STOP IT. `vault-dir`
|
|
766
|
-
* resolves HOST-LOCALLY — `SO_VAULT_DIR` > `owner.yaml` `
|
|
767
|
-
* committed Session Config (`
|
|
768
|
-
* value in a CLAUDE.md you just wrote
|
|
768
|
+
* resolves HOST-LOCALLY — `SO_VAULT_DIR` > `owner.yaml` `vault-dirs:` cwd match >
|
|
769
|
+
* `owner.yaml` `paths.vault-dir` > committed Session Config (`resolveVaultDir` in
|
|
770
|
+
* `scripts/lib/config.mjs`) — so the literal value in a CLAUDE.md you just wrote
|
|
771
|
+
* into a tmp dir LOSES to every override. A
|
|
769
772
|
* probe that passes a synthetic `repoRoot` and nothing else has already written
|
|
770
773
|
* into the live vault once (#1025 review). Any caller that must not touch the real
|
|
771
774
|
* vault passes `hostPaths: { env: {}, ownerConfig: undefined }` (see below),
|
|
@@ -777,13 +780,16 @@ async function emitMaskerEvent({ repoRoot, needleCount, hits, dryRun }) {
|
|
|
777
780
|
* now?: Date,
|
|
778
781
|
* dryRun?: boolean,
|
|
779
782
|
* fs?: object,
|
|
780
|
-
* hostPaths?: { env?: Record<string, string|undefined>, ownerConfig?: object
|
|
783
|
+
* hostPaths?: { env?: Record<string, string|undefined>, ownerConfig?: object,
|
|
784
|
+
* readOriginUrl?: (vaultDir: string) => string },
|
|
781
785
|
* }} opts
|
|
782
786
|
* `hostPaths` is forwarded verbatim to {@link parseSessionConfig}'s `hostPaths` DI
|
|
783
|
-
* seam (issue #653).
|
|
787
|
+
* seam (issue #653). The same ctx feeds {@link checkCanonicalVault} (#1450);
|
|
788
|
+
* `readOriginUrl` replaces its `git remote get-url origin` read (tests only —
|
|
789
|
+
* tmp vaults have no origin). Tests MUST pass a hermetic ctx (e.g. `{ env: {}, ownerConfig:
|
|
784
790
|
* undefined }`) when asserting a fixture's committed `vault-dir` — omitting it reads
|
|
785
|
-
* the REAL host `owner.yaml`, whose `paths.vault-dir`
|
|
786
|
-
* fixture value and bleeds into the assertion (issue #783). Production callers omit
|
|
791
|
+
* the REAL host `owner.yaml`, whose `vault-dirs:` cwd match or `paths.vault-dir`
|
|
792
|
+
* override (if set) wins over the fixture value and bleeds into the assertion (issue #783). Production callers omit
|
|
787
793
|
* this — the default (real owner.yaml resolution) is the correct host-local behavior.
|
|
788
794
|
* @returns {Promise<{ action: string, path?: string }>}
|
|
789
795
|
*/
|
|
@@ -829,7 +835,7 @@ export async function mirrorNarrative(opts) {
|
|
|
829
835
|
* Split out so that the two emit sites in `mirrorNarrative` (the narrative
|
|
830
836
|
* event AND, since #1028, the masker event) each cover EVERY outcome from
|
|
831
837
|
* ONE call: a future early return added inside this function is telemetered
|
|
832
|
-
* by construction, whereas hand-placing an emit beside each of the
|
|
838
|
+
* by construction, whereas hand-placing an emit beside each of the eight
|
|
833
839
|
* `return`s makes "forgot the new one" the default failure. The `chars` companion travels beside the result
|
|
834
840
|
* rather than inside it because the returned object is a PUBLIC shape that
|
|
835
841
|
* callers (and tests) compare with `toEqual` — adding a key there would be an
|
|
@@ -855,10 +861,19 @@ async function runNarrativeMirror(opts) {
|
|
|
855
861
|
const masker = createSecretValueMasker(process.env);
|
|
856
862
|
|
|
857
863
|
// Read Session Config (CLAUDE.md / AGENTS.md) and resolve vault settings.
|
|
864
|
+
// The host context is loaded ONCE and shared by the Session Config parse and
|
|
865
|
+
// the canonical-vault guard below, so owner.yaml is read a single time per run.
|
|
866
|
+
// Order (#1479, same as board-writer.mjs): config file first, owner.yaml
|
|
867
|
+
// second. The `vault-dir` precedence (SO_VAULT_DIR > owner.yaml > committed)
|
|
868
|
+
// is applied inside parseSessionConfig, after both are in hand, so the order
|
|
869
|
+
// cannot change the resolved dir; it only spares a repo without a config file
|
|
870
|
+
// the owner.yaml read and its section-drop WARNs on a run that skips anyway.
|
|
858
871
|
let config;
|
|
872
|
+
let hostCtx;
|
|
859
873
|
try {
|
|
860
874
|
const configText = await readConfigFile(repoRoot);
|
|
861
|
-
|
|
875
|
+
hostCtx = hostPaths ?? loadHostPaths();
|
|
876
|
+
config = parseSessionConfig(configText, { hostPaths: hostCtx });
|
|
862
877
|
} catch {
|
|
863
878
|
return { result: { action: 'skipped-vault-disabled' }, needleCount: masker.needleCount, hits: 0, dryRun };
|
|
864
879
|
}
|
|
@@ -900,6 +915,22 @@ async function runNarrativeMirror(opts) {
|
|
|
900
915
|
}
|
|
901
916
|
|
|
902
917
|
const vaultDir = path.resolve(expandTilde(rawVaultDir));
|
|
918
|
+
|
|
919
|
+
// #1450: same shared guard as board-writer.mjs and scripts/vault-mirror.mjs —
|
|
920
|
+
// refuse a vault-dir whose git origin is not a canonical Meta-Vault. The
|
|
921
|
+
// stderr line names the expected suffixes and the origin, never the vault
|
|
922
|
+
// path itself.
|
|
923
|
+
const canonical = checkCanonicalVault({
|
|
924
|
+
vaultDir,
|
|
925
|
+
ownerConfig: hostCtx.ownerConfig,
|
|
926
|
+
env: hostCtx.env ?? process.env,
|
|
927
|
+
readOriginUrl: hostCtx.readOriginUrl,
|
|
928
|
+
});
|
|
929
|
+
if (!canonical.ok) {
|
|
930
|
+
process.stderr.write(`narrative-mirror: refusing to write — vault is not the canonical Meta-Vault (expected git origin ending in one of: ${canonical.expected.join(', ')}; got ${describeOriginForLog(canonical.got) || 'no git origin'})\n`);
|
|
931
|
+
return { result: { action: 'skipped-vault-not-canonical' }, needleCount: masker.needleCount, hits: 0, dryRun };
|
|
932
|
+
}
|
|
933
|
+
|
|
903
934
|
const candidateSlug = subjectToSlug(repoName) || 'unknown';
|
|
904
935
|
// A declared `.vault.yaml` slug needs no healing — it IS the canonical folder
|
|
905
936
|
// name, so resolveLooseSlug is SKIPPED for it. Running the healer over it
|