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.
- 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 +494 -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/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +1 -1
- package/docs/instruction-delivery.md +350 -0
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -0
- package/docs/session-config-reference.md +1 -41
- package/docs/session-config-template.md +0 -23
- package/hooks/_lib/guard-source-loader.mjs +304 -91
- package/hooks/enforce-commands.mjs +216 -17
- package/hooks/enforce-scope.mjs +236 -12
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +11 -1
- package/hooks/on-session-end.mjs +52 -5
- package/hooks/on-session-start.mjs +7 -4
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-bash-destructive-guard.mjs +146 -59
- package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +2 -2
- package/pi/prompts/release.md +12 -0
- package/scripts/autopilot.mjs +3 -1
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/emit-session.mjs +45 -40
- 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/command-blocker.mjs +322 -62
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +193 -7
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/select.mjs +704 -0
- 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/emitter.mjs +107 -22
- package/scripts/lib/reconcile/engine.mjs +9 -15
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +134 -1
- package/scripts/lib/redact-spans.mjs +89 -0
- package/scripts/lib/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +852 -72
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-close-backfill.mjs +2 -2
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/state-md/mission-status.mjs +21 -12
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- 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-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +188 -20
- package/scripts/lib/validate/check-rules.mjs +31 -5
- 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 +757 -0
- 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-applicable-rules.mjs +170 -7
- package/scripts/print-learnings-index.mjs +501 -0
- package/scripts/release.mjs +616 -61
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +82 -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/brainstorm/soul.md +47 -1
- 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/evolve/SKILL.md +116 -18
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +54 -39
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/grill/soul.md +44 -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/plan/soul.md +46 -3
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +45 -26
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +30 -1
- package/skills/session-end/plan-verification.md +1 -5
- package/skills/session-end/session-metrics-write.md +6 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +15 -1
- package/skills/session-start/soul.md +41 -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/SKILL.md +1 -5
- package/skills/wave-executor/wave-loop.md +77 -82
- package/scripts/lib/mission-status-schema.mjs +0 -114
|
@@ -1,20 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* memory-cleanup-stamp.mjs —
|
|
3
|
-
*
|
|
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.
|
|
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
|
-
* -
|
|
13
|
-
* - No-throw — invalid inputs
|
|
14
|
-
*
|
|
15
|
-
* -
|
|
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 {
|
|
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.
|