session-orchestrator 3.16.0 → 3.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/CHANGELOG.md +25 -0
  5. package/README.md +13 -11
  6. package/docs/README.md +2 -1
  7. package/docs/components.md +2 -2
  8. package/docs/pi-setup.md +1 -1
  9. package/docs/session-config-reference.md +65 -0
  10. package/docs/session-config-template.md +27 -0
  11. package/docs/telemetry/telemetry-claims.md +204 -0
  12. package/docs/telemetry.md +158 -0
  13. package/hooks/hooks-codex.json +1 -1
  14. package/hooks/hooks.json +1 -1
  15. package/hooks/skill-invocation-telemetry.mjs +109 -10
  16. package/package.json +12 -2
  17. package/scripts/compute-grounding-injection.sh +18 -3
  18. package/scripts/dialectic-deriver.mjs +7 -2
  19. package/scripts/lib/auto-dialectic.mjs +11 -2
  20. package/scripts/lib/auto-dream.mjs +16 -5
  21. package/scripts/lib/build-live-signals.mjs +7 -4
  22. package/scripts/lib/config/context-coverage.mjs +82 -0
  23. package/scripts/lib/config/moc-staleness.mjs +98 -0
  24. package/scripts/lib/config/worktree-orphans.mjs +138 -0
  25. package/scripts/lib/config.mjs +15 -0
  26. package/scripts/lib/context-coverage-banner.mjs +223 -0
  27. package/scripts/lib/dispatcher/enumerate.mjs +151 -31
  28. package/scripts/lib/dispatcher/rank.mjs +22 -8
  29. package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
  30. package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
  31. package/scripts/lib/harness-audit/categories/category4.mjs +13 -2
  32. package/scripts/lib/moc-staleness-banner.mjs +267 -0
  33. package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
  34. package/scripts/lib/session-schema/filters.mjs +88 -0
  35. package/scripts/lib/session-schema.mjs +1 -0
  36. package/scripts/lib/skill-health/join.mjs +35 -9
  37. package/scripts/lib/telemetry/anon-id.mjs +141 -0
  38. package/scripts/lib/telemetry/consent.mjs +299 -0
  39. package/scripts/lib/telemetry/paths.mjs +27 -0
  40. package/scripts/lib/telemetry/queue.mjs +287 -0
  41. package/scripts/lib/telemetry/schema.mjs +384 -0
  42. package/scripts/lib/telemetry/sync.mjs +312 -0
  43. package/scripts/lib/vault-status/board-writer.mjs +63 -5
  44. package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
  45. package/scripts/mcp-server.sh +15 -3
  46. package/scripts/telemetry.mjs +250 -0
  47. package/skills/npm-publish/SKILL.md +81 -0
  48. package/skills/session-end/SKILL.md +74 -1
  49. package/skills/session-start/SKILL.md +77 -1
  50. package/skills/vault-sync/SKILL.md +1 -1
  51. package/skills/vault-sync/package-lock.json +3 -3
  52. package/skills/vault-sync/validator.mjs +121 -34
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * telemetry.mjs — operator CLI for anonymous usage telemetry (Epic #841, S3 /
4
+ * GitLab #844; PRD docs/prd/2026-07-20-anonymous-usage-telemetry.md §3-FA3).
5
+ *
6
+ * Subcommands:
7
+ * status show the resolved consent posture + queue occupancy
8
+ * enable grant consent (persist to telemetry.json)
9
+ * disable deny consent (persist to telemetry.json)
10
+ * show preview the usage-ping payload WITHOUT minting/persisting an anon-ID
11
+ *
12
+ * (`_flush` is an internal, hidden subcommand used by the daily-fallback hook to
13
+ * run a detached flush; it is intentionally omitted from --help.)
14
+ *
15
+ * Follows .claude/rules/cli-design.md:
16
+ * - `--json` for machine output; human-readable by default.
17
+ * - Data → stdout, diagnostics → stderr.
18
+ * - Exit codes: 0 success · 1 user error (unknown subcommand) · 2 system error.
19
+ *
20
+ * All host-local state (telemetry.json, telemetry-queue.ndjson) is homedir-based;
21
+ * tests isolate via an injected HOME.
22
+ */
23
+
24
+ import { parseArgs } from 'node:util';
25
+
26
+ import {
27
+ resolveConsent,
28
+ readTelemetryState,
29
+ grantConsent,
30
+ denyConsent,
31
+ } from './lib/telemetry/consent.mjs';
32
+ import { queueStats } from './lib/telemetry/queue.mjs';
33
+ import { flush, buildBatch } from './lib/telemetry/sync.mjs';
34
+ import { loadOwnerConfig } from './lib/owner-yaml.mjs';
35
+ import { readPluginVersionFromPackageJson } from './lib/bootstrap-lock-freshness.mjs';
36
+ import { SO_PLUGIN_ROOT } from './lib/platform.mjs';
37
+
38
+ const EXIT_OK = 0;
39
+ const EXIT_USER = 1;
40
+ const EXIT_SYSTEM = 2;
41
+
42
+ const HELP = `telemetry — anonymous usage-telemetry consent + inspection CLI
43
+
44
+ USAGE
45
+ telemetry <status|enable|disable|show> [--json]
46
+ telemetry --help | --version
47
+
48
+ SUBCOMMANDS
49
+ status show the resolved consent posture, anon-ID presence, and queue stats
50
+ enable grant consent (persisted to ~/.config/session-orchestrator/telemetry.json)
51
+ disable deny consent (persisted; subsequent sessions send nothing)
52
+ show preview the exact usage-ping payload — never mints an ID, never sends
53
+
54
+ OPTIONS
55
+ --json emit machine-readable JSON on stdout
56
+ --help show this help
57
+ --version print the plugin version
58
+
59
+ EXIT CODES
60
+ 0 success
61
+ 1 user error (unknown subcommand)
62
+ 2 system error
63
+
64
+ ENV KILL-SWITCHES
65
+ DO_NOT_TRACK=1 / SO_TELEMETRY_DISABLED=1 disable telemetry for this shell
66
+ SO_TELEMETRY=1 force-enable (fleet) without a prompt
67
+ SO_TELEMETRY_DEBUG=1 print the payload instead of sending
68
+ `;
69
+
70
+ /** Plugin version for `--version` — single-sourced via readPluginVersionFromPackageJson (null → 'unknown'). */
71
+ function readPkgVersion() {
72
+ return readPluginVersionFromPackageJson(SO_PLUGIN_ROOT) ?? 'unknown';
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Subcommand handlers
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /** Resolve the current consent posture from env + owner.yaml + telemetry.json. */
80
+ function currentPosture() {
81
+ const ownerConfig = loadOwnerConfig().config;
82
+ const { record } = readTelemetryState();
83
+ const consent = resolveConsent({ env: process.env, ownerConfig, state: record, interactive: false });
84
+ const queue = queueStats();
85
+ return { record, consent, queue };
86
+ }
87
+
88
+ function runStatus(json) {
89
+ const { record, consent, queue } = currentPosture();
90
+ const out = {
91
+ state: consent.state,
92
+ send: consent.send,
93
+ prompt: consent.prompt,
94
+ consent: record.consent,
95
+ anon_id_present: typeof record.anon_id === 'string' && record.anon_id.trim() !== '',
96
+ last_flush_at: record.last_flush_at,
97
+ queue: { count: queue.count, bytes: queue.bytes },
98
+ };
99
+
100
+ if (json) {
101
+ process.stdout.write(`${JSON.stringify(out)}\n`);
102
+ } else {
103
+ process.stdout.write(
104
+ [
105
+ `state: ${out.state}`,
106
+ `send: ${out.send}`,
107
+ `consent: ${out.consent ?? '(none)'}`,
108
+ `anon_id: ${out.anon_id_present ? 'present' : 'not yet minted'}`,
109
+ `last_flush_at: ${out.last_flush_at ?? '(never)'}`,
110
+ `queue: ${out.queue.count} batch(es), ${out.queue.bytes} byte(s)`,
111
+ ].join('\n') + '\n',
112
+ );
113
+ }
114
+ return EXIT_OK;
115
+ }
116
+
117
+ /**
118
+ * Persist a consent decision, then warn on stdout/stderr if an env kill-switch
119
+ * still overrides the file (so the operator is never misled that enabling took
120
+ * effect when DO_NOT_TRACK / SO_TELEMETRY_DISABLED wins for this shell).
121
+ */
122
+ function runSetConsent(decision, json) {
123
+ const res = decision === 'granted' ? grantConsent() : denyConsent();
124
+ if (!res.ok) {
125
+ process.stderr.write('telemetry: failed to persist consent to telemetry.json\n');
126
+ return EXIT_SYSTEM;
127
+ }
128
+
129
+ // Re-resolve to detect an env override that still forces disabled.
130
+ const ownerConfig = loadOwnerConfig().config;
131
+ const consent = resolveConsent({ env: process.env, ownerConfig, state: res.record, interactive: false });
132
+ const envOverrides = decision === 'granted' && consent.state === 'disabled-env';
133
+ if (envOverrides) {
134
+ process.stderr.write(
135
+ 'telemetry: WARN — DO_NOT_TRACK / SO_TELEMETRY_DISABLED overrides the file setting; ' +
136
+ 'nothing will be sent from this shell.\n',
137
+ );
138
+ }
139
+
140
+ if (json) {
141
+ process.stdout.write(`${JSON.stringify({ consent: decision, effective_state: consent.state, send: consent.send })}\n`);
142
+ } else {
143
+ process.stdout.write(
144
+ decision === 'granted'
145
+ ? `Telemetry enabled (consent granted). Effective state: ${consent.state}.\n`
146
+ : 'Telemetry disabled (consent denied). Nothing will be sent.\n',
147
+ );
148
+ }
149
+ return EXIT_OK;
150
+ }
151
+
152
+ /**
153
+ * Preview the usage-ping payload WITHOUT minting or persisting an anon-ID
154
+ * (persist:false in buildBatch). Never sends. When no ID exists yet, a
155
+ * placeholder is shown in the anon_id slot.
156
+ */
157
+ function runShow(json) {
158
+ const { record, reason } = buildBatch({ persist: false });
159
+ if (!record) {
160
+ process.stderr.write(`telemetry: cannot build preview (${reason ?? 'unknown'})\n`);
161
+ return EXIT_SYSTEM;
162
+ }
163
+
164
+ if (json) {
165
+ process.stdout.write(`${JSON.stringify(record)}\n`);
166
+ } else {
167
+ process.stdout.write(
168
+ 'Usage-ping preview (NOT sent; anon-ID is not minted by `show`):\n' +
169
+ `${JSON.stringify(record, null, 2)}\n`,
170
+ );
171
+ }
172
+ return EXIT_OK;
173
+ }
174
+
175
+ /** Internal detached-child entry: run one flush, swallow everything, exit 0. */
176
+ async function runFlush() {
177
+ try {
178
+ await flush();
179
+ } catch {
180
+ // never-throw contract; the detached child produces no user-facing output.
181
+ }
182
+ return EXIT_OK;
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Main
187
+ // ---------------------------------------------------------------------------
188
+
189
+ async function main() {
190
+ let values;
191
+ let positionals;
192
+ try {
193
+ ({ values, positionals } = parseArgs({
194
+ options: {
195
+ json: { type: 'boolean', default: false },
196
+ help: { type: 'boolean', default: false },
197
+ version: { type: 'boolean', default: false },
198
+ },
199
+ allowPositionals: true,
200
+ }));
201
+ } catch (err) {
202
+ process.stderr.write(`telemetry: argument error: ${err?.message ?? String(err)}\n`);
203
+ process.exit(EXIT_USER);
204
+ return;
205
+ }
206
+
207
+ if (values.help) {
208
+ process.stdout.write(HELP);
209
+ process.exit(EXIT_OK);
210
+ }
211
+ if (values.version) {
212
+ process.stdout.write(`${readPkgVersion()}\n`);
213
+ process.exit(EXIT_OK);
214
+ }
215
+
216
+ const sub = positionals[0];
217
+ let code;
218
+ switch (sub) {
219
+ case 'status':
220
+ code = runStatus(values.json);
221
+ break;
222
+ case 'enable':
223
+ code = runSetConsent('granted', values.json);
224
+ break;
225
+ case 'disable':
226
+ code = runSetConsent('denied', values.json);
227
+ break;
228
+ case 'show':
229
+ code = runShow(values.json);
230
+ break;
231
+ case '_flush':
232
+ code = await runFlush();
233
+ break;
234
+ default:
235
+ process.stderr.write(
236
+ sub
237
+ ? `telemetry: unknown subcommand "${sub}". Try: status | enable | disable | show (--help).\n`
238
+ : 'telemetry: missing subcommand. Try: status | enable | disable | show (--help).\n',
239
+ );
240
+ process.exit(EXIT_USER);
241
+ return;
242
+ }
243
+
244
+ process.exit(code);
245
+ }
246
+
247
+ main().catch((err) => {
248
+ process.stderr.write(`telemetry: system error: ${err?.message ?? String(err)}\n`);
249
+ process.exit(EXIT_SYSTEM);
250
+ });
@@ -0,0 +1,81 @@
1
+ ---
2
+ name: npm-publish
3
+ user-invocable: true
4
+ model: sonnet
5
+ description: Use when publishing this package to npm — a version release (npm publish), verifying the registry/pi.dev listing, or diagnosing npm auth failures (E403 2FA/token errors). Token-based flow via NPM_TOKEN in .env.local with a temp userconfig, leakage-gate greps before every publish, post-publish verification and marker/badge upkeep. Trigger on "publish to npm", "npm release", "E403 publish error".
6
+ ---
7
+
8
+ # npm-publish — Token-based publish runbook
9
+
10
+ > Companion to `docs/distribution/npm-publish-checklist.md` (the original 7-step operator runbook). This skill adds the token-auth mechanics and the failure-mode diagnosis learned during the v3.16.0 first publish (2026-07-19).
11
+
12
+ ## Why this skill exists
13
+
14
+ npm requires 2FA **or** a granular access token with "Bypass 2FA" for every publish (policy active since 2025; legacy tokens were removed Nov 2025 — only granular tokens exist). The failure mode is confusing: `npm publish` fails with **E403 and NO OTP prompt** when the account either has no 2FA enrolled or the supplied token lacks the bypass flag. Three dead ends verified empirically: plain `npm publish` (E403), `--auth-type=web` (no web flow exists for publish), PTY-forced publish (same E403). The ONLY non-interactive path is a correctly-configured granular token.
15
+
16
+ ## Token requirements (all four mandatory)
17
+
18
+ Create at https://www.npmjs.com/settings/<user>/tokens → Generate New Token → **Granular Access Token**:
19
+
20
+ 1. **Permissions: Read and write** (Packages and scopes).
21
+ 2. **Packages: "All packages"** for a FIRST publish (the package does not exist yet, so per-package selection cannot include it). After the first publish, re-create scoped to the single package — least privilege.
22
+ 3. **"Bypass two-factor authentication (2FA)" enabled** — this is the checkbox whose absence produces the E403-without-prompt. npm shows a red security warning here and recommends Trusted Publishing for CI/CD; for interactive operator-assisted releases the short-lived bypass token is acceptable.
23
+ 4. **Short expiration** — write tokens default to 7 days (90 max). Take the default.
24
+
25
+ ## Auth resolution order
26
+
27
+ 1. `NPM_TOKEN` in `.env.local` at the repo root (gitignored — verify with `git check-ignore .env.local` before writing; also confirm no `.env` pattern in the `files` whitelist of package.json).
28
+ 2. Interactive fallback: operator runs `npm publish --access public` in a real terminal (only works when account 2FA is enrolled — OTP prompt appears).
29
+
30
+ **Never** put the token in the tracked `.npmrc` (it holds `ignore-scripts=true` per SEC-020 and is committed), never persist it into `~/.npmrc`, never echo it into logs.
31
+
32
+ ## Publish flow
33
+
34
+ ```bash
35
+ # 1. Pre-flight (first publish: expect E404 = name free; upgrade: expect the previous version)
36
+ npm view session-orchestrator version
37
+
38
+ # 2. Leakage gate — every grep MUST print 0 (from docs/distribution/npm-publish-checklist.md)
39
+ npm pack --dry-run 2>&1 | grep -cE "npm notice.* tests/"
40
+ npm pack --dry-run 2>&1 | grep -c "npm notice.*\.orchestrator/"
41
+ npm pack --dry-run 2>&1 | grep -cE "npm notice.*[[:space:]]\.claude/"
42
+ npm pack --dry-run 2>&1 | grep -c "npm notice.*\.github/"
43
+ npm pack --dry-run 2>&1 | grep -c "node_modules"
44
+ npm pack --dry-run 2>&1 | grep -ci "\.env"
45
+ npm pack --dry-run 2>&1 | grep -ci "owner\.yaml"
46
+
47
+ # 3. Publish via temp userconfig (never a persistent npmrc)
48
+ NPM_TOKEN=$(grep '^NPM_TOKEN=' .env.local | cut -d= -f2-)
49
+ TMPRC=$(mktemp) && printf '//registry.npmjs.org/:_authToken=%s\n' "$NPM_TOKEN" > "$TMPRC" && chmod 600 "$TMPRC"
50
+ npm publish --access public --userconfig "$TMPRC"; rm "$TMPRC"
51
+
52
+ # 4. Verify
53
+ npm view session-orchestrator version # must print the new version
54
+ ```
55
+
56
+ Success marker: `+ session-orchestrator@<version>` on the publish output.
57
+
58
+ ## Post-publish checklist
59
+
60
+ 1. **Verify registry**: `npm view session-orchestrator version dist.unpackedSize keywords` — `pi-package` keyword must be present.
61
+ 2. **pi.dev gallery**: indexing is asynchronous — check https://pi.dev/packages later; do not block on it.
62
+ 3. **Marker upkeep** (first publish only — done in v3.16.0): README install matrix + npm badge, `site/index.html` install section, `docs/pi-setup.md` availability paragraph.
63
+ 4. **Rotate/delete the token** at https://www.npmjs.com/settings/<user>/tokens once the release is done — especially if the token value ever transited chat, a screenshot, or any log. A token pasted into a conversation is burned: rotate immediately after use.
64
+ 5. Update the release issue / CHANGELOG if the publish was part of a tracked release.
65
+
66
+ ## Failure-mode table
67
+
68
+ | Symptom | Cause | Fix |
69
+ |---|---|---|
70
+ | `E403 ... Two-factor authentication or granular access token with bypass 2fa enabled is required` — no OTP prompt | Account has no 2FA enrolled AND token (if any) lacks Bypass-2FA | Create granular token with all four requirements above, or enroll 2FA |
71
+ | Same E403 despite a fresh token | Token created without the Bypass-2FA checkbox, or Read-only, or package-scoped on a first publish | Re-create: RW + All packages + Bypass-2FA |
72
+ | `E404` on `npm view` after publish | Registry propagation (rare, seconds) or publish actually failed | Re-check the publish output for `+ <name>@<version>` |
73
+ | `ENEEDAUTH` | No login/token at all | Token flow above, or `npm login` |
74
+ | OTP prompt appears but flow is non-interactive (`!`-prefix, script) | No TTY for the prompt | Use the token flow, or a real terminal |
75
+
76
+ ## Security invariants
77
+
78
+ - `.env.local` is gitignored AND absent from the npm `files` whitelist — verify both before writing a token into it.
79
+ - Temp userconfig: `chmod 600`, deleted immediately after publish.
80
+ - The leakage gate runs before EVERY publish, not only the first.
81
+ - npm's own recommendation for unattended CI/CD is **Trusted Publishing** (OIDC) — evaluate it if publishing ever moves into CI (ref: https://docs.npmjs.com/about-access-tokens).
@@ -576,7 +576,7 @@ Review `<state-dir>/rules/` files that are relevant to this session's work:
576
576
 
577
577
  > **Ownership Reference:** See `skills/_shared/state-ownership.md`. session-end is authorized to set `status: completed` plus the optional `updated` timestamp (#184), and — as of Phase A of Epic #271 — the 5 Recommendation fields written by Phase 3.7a. No other fields.
578
578
 
579
- > **Runtime Ordering Note (Epic #271 Phase A):** Phase 3.4's `status: completed` write executes LAST in Phase 3, AFTER Phase 3.7 (sessions.jsonl) and Phase 3.7a (Compute and Write Recommendations). The ordinal position here (3.4) is kept for historical compatibility; the canonical runtime order is `3.1 → 3.2 → 3.3 → 3.4a → 3.5 → 3.5a → 3.6 → 3.6.3 → 3.6.4 → 3.6.5 → 3.6.6 → 3.6.7 → 3.6.8 → 3.7 → 3.7a → 3.7b → 3.7c → 3.7d → 3.4` (3.6.3/3.6.4/3.6.6 were missing from this note pre-#724; the Tail-Diät skip-plan dispatcher now dispatches the full six-phase tail mechanically, so the note is corrected to list all six). Rationale: Phase 3.7a reads in-memory session metrics and writes the 5 Recommendation fields via `updateFrontmatterFields`; that write must complete BEFORE the STATE.md frontmatter is finalized with `status: completed` so the Recommendation fields are visible to the next session-start while STATE.md is still `status: active`. Crash-resilience: if `/close` aborts between 3.7a and 3.4, STATE.md carries `status: active` + Recommendations; session-start Phase 1.5 offers resume (and the banner renders). If the reverse ordering were used (status: completed first), a crash would leave `status: completed` without Recommendations — the Reader would silently no-op the banner, losing the handoff. Phase 3.7d (Session-Eval, #803) sits AFTER Phase 3.7 because it scores the `sessions.jsonl` record that phase just wrote — the record must exist first — and BEFORE Phase 3.4 because its `eval.jsonl` output is advisory and must never block the close.
579
+ > **Runtime Ordering Note (Epic #271 Phase A):** Phase 3.4's `status: completed` write executes LAST in Phase 3, AFTER Phase 3.7 (sessions.jsonl) and Phase 3.7a (Compute and Write Recommendations). The ordinal position here (3.4) is kept for historical compatibility; the canonical runtime order is `3.1 → 3.2 → 3.3 → 3.4a → 3.5 → 3.5a → 3.6 → 3.6.3 → 3.6.4 → 3.6.5 → 3.6.6 → 3.6.7 → 3.6.8 → 3.7 → 3.45 → 3.7a → 3.7b → 3.7c → 3.7d → 3.4` (3.6.3/3.6.4/3.6.6 were missing from this note pre-#724; the Tail-Diät skip-plan dispatcher now dispatches the full six-phase tail mechanically, so the note is corrected to list all six). Rationale: Phase 3.7a reads in-memory session metrics and writes the 5 Recommendation fields via `updateFrontmatterFields`; that write must complete BEFORE the STATE.md frontmatter is finalized with `status: completed` so the Recommendation fields are visible to the next session-start while STATE.md is still `status: active`. Crash-resilience: if `/close` aborts between 3.7a and 3.4, STATE.md carries `status: active` + Recommendations; session-start Phase 1.5 offers resume (and the banner renders). If the reverse ordering were used (status: completed first), a crash would leave `status: completed` without Recommendations — the Reader would silently no-op the banner, losing the handoff. Phase 3.45 (Telemetry Flush, #844) sits AFTER Phase 3.7 because it drains the send-queue with the just-written `sessions.jsonl` record already included, and BEFORE Phase 3.7a because it is a fire-and-forget side-effect with no dependency on the Recommendation-write ordering below it. Phase 3.7d (Session-Eval, #803) sits AFTER Phase 3.7 because it scores the `sessions.jsonl` record that phase just wrote — the record must exist first — and BEFORE Phase 3.4 because its `eval.jsonl` output is advisory and must never block the close.
580
580
 
581
581
  > Gate: Only run if `persistence` is enabled in Session Config and `<state-dir>/STATE.md` exists.
582
582
  1. Set frontmatter `status: completed`
@@ -622,6 +622,24 @@ Failures in either step are logged to stderr but do **not** block session close
622
622
 
623
623
  This cleanup is the counterpart to the session-start Phase 1.5 recovery prompt: once a session closes cleanly, future sessions must not be offered recovery for its snapshots.
624
624
 
625
+ ### 3.45: Telemetry Flush (advisory, #844)
626
+
627
+ > Skip silently when `persistence: false` in Session Config. There is **no dedicated config key** for this phase — the send-gate is `resolveConsent()` inside `sync.mjs` itself (fail-closed: a `disabled` / `no-consent` / headless posture makes `flush()` a no-op in <5ms, sending nothing). This phase runs late in the close, after Phase 3.7 has written `sessions.jsonl`, so any session-summary event enqueued at metrics-write time is included in the drain; the ordinal position `3.45` is kept for readability (mirrors the Phase 3.4 Runtime Ordering Note idiom of ordinal ≠ runtime order).
628
+
629
+ Drain the host-local telemetry send-queue once, fire-and-forget. The flush is **advisory** — the close must never fail, stall, or surface an error because of telemetry:
630
+
631
+ ```javascript
632
+ import { flush } from '${PLUGIN_ROOT}/scripts/lib/telemetry/sync.mjs';
633
+
634
+ // Fire-and-forget. flush() is contractually never-throw + internally gated (resolveConsent)
635
+ // + 3s-timeout-bounded; the try/catch is defense-in-depth, never a real failure path.
636
+ try { await flush(); } catch { /* nie blockierend — der Close darf durch Telemetrie nie scheitern */ }
637
+ ```
638
+
639
+ **Semantics.** `flush()` is fire-and-forget with an internal ~3s timeout. When the ingest endpoint is unreachable (offline), events stay in the bounded host-local queue (oldest-dropped on overflow) and are retried on a later close — nothing is lost or blocked. A one-line result MAY be surfaced in the Phase 6 close summary (`Telemetry: sent` / `queued` / `gated`), but a failure NEVER renders an error banner: under no circumstances may telemetry make `/close` fail or take materially longer than ~3s. The gate lives in the module (fail-closed via `resolveConsent`), so this phase carries no config-key check of its own beyond the `persistence: false` skip above.
640
+
641
+ Cross-reference: GitLab #844 (Epic #841); `docs/prd/2026-07-20-anonymous-usage-telemetry.md` FA3; `docs/telemetry.md`; flush API in `scripts/lib/telemetry/sync.mjs` (`flush` — fire-and-forget, gated, never-throw).
642
+
625
643
  ### 3.5 Session Memory
626
644
 
627
645
  > Gate: Only run if `persistence` is enabled in Session Config AND platform is Claude Code (session memory at `~/.claude/projects/` is Claude Code-only). Learnings (Phase 3.5a) and metrics (Phase 3.7) still write to `.orchestrator/metrics/` on all platforms.
@@ -892,6 +910,60 @@ Reply with the number of your choice.
892
910
  - **AUQ rule:** `.claude/rules/ask-via-tool.md` AUQ-004 — coordinator-only invocation
893
911
  - **Companion phases:** P3.1 PROMOTION_OFFER (`enterWorktree()` in `parallel-aware-auq.md`) creates the worktree; this phase removes it.
894
912
 
913
+ ## Phase 4b: Worktree-Orphan Sweep (#831/B5)
914
+
915
+ > Skip if `persistence: false` in Session Config. Skip silently unless `worktree-orphans.enabled: true` (opt-in; default `false`).
916
+
917
+ Sweep the repo's worktree set for branches with **0 commits ahead of the base branch** — orphans left behind by finished sessions. Distinct from Phase 4a: 4a asks "did *this* session run in a promoted worktree?", 4b asks "which worktrees from *past* sessions have nothing left in them?".
918
+
919
+ > **Ordering rationale (#490 durableCommit dependency):** Phase 4b runs AFTER the Phase 4 commit+push, NOT before — the same invariant that governs Phase 4a. Removing a worktree before commit+push would lose its `STATE.md` before the Phase 3.4 `sessions.jsonl` metrics writes are committed. See `docs/adr/0008-worktree-cleanup-ordering.md`.
920
+
921
+ ### The module proposes; the coordinator disposes
922
+
923
+ > **Authoritative impl:** `scripts/lib/session-end/worktree-orphan-sweep.mjs` — `checkWorktreeOrphans({ repoRoot, mainCheckoutRoot, config, execFileFn })`. Import and call; do NOT re-implement from this doc.
924
+
925
+ `checkWorktreeOrphans()` **executes zero mutating commands.** Its complete argv set is four read-only shapes — `worktree list --porcelain`, `rev-list --count --end-of-options <base>..<branch>`, and (via the reused Phase 4a helper `isWorktreeClean`) `status --porcelain` and `status --short --branch` — all via the injection-safe arg-array form (`execFileSync('git', ['-C', dir, …])`, #577 HARDEN-001), never a template-literal shell string. It returns `null` (silent no-op) or ONE object `{ severity: 'warn', message, candidates: [{ wtPath, branch, sessionId, aheadCount: 0 }] }`.
926
+
927
+ **`--end-of-options` is load-bearing, not decoration.** `base-branch` comes from Session Config, and a value shaped like a git flag (e.g. `--glob=refs/heads/*`) is otherwise parsed by `rev-list` as an OPTION rather than a revision range — which exits 0 and prints `0`, silently marking EVERY worktree as a 0-ahead orphan and offering the operator "Löschen" for worktrees full of live work. That is not an error path the conservative default catches, because `0` parses fine. `--end-of-options` turns the payload into a hard git error that DOES fall into the conservative `continue`, and `_isSafeBaseBranch` in the config parser rejects leading-dash values at the source. No attacker is needed for this — a typo reaches the same outcome.
928
+
929
+ **The gate is opt-in and fails CLOSED:** the module returns `null` unless `enabled === true`. It accepts either the full config object or the already-indexed `worktree-orphans` block, so neither call shape can accidentally open the gate.
930
+
931
+ **A dirty worktree is never a candidate.** Orphan-ness is not decided by commit count alone — `isWorktreeClean()` is consulted first, and any uncommitted, staged or untracked work (or any git error while checking) excludes the worktree entirely. A worktree that is 0-ahead but holds live work is exactly the case where a deletion prompt would cost real data.
932
+
933
+ The return field is named `candidates`, not `orphans` or `removals`, and the name is load-bearing: **the coordinator decides, the module never does.** Grounding: `.claude/rules/parallel-sessions.md` § PSA-003 — *"Did I create this file/commit/change? If not, it is not mine to touch."* A sweep probe created none of the worktrees it inspects.
934
+
935
+ **Nothing is removed without explicit operator confirmation.** The rendered banner always ends with the literal clause `nothing was removed.` — the operator-visible proof of the invariant.
936
+
937
+ **Conservative default (safety-critical):** any git error, unparseable `rev-list` output, detached HEAD, unresolvable branch, or ambiguity of any kind → that worktree is NOT reported as a candidate. A failing sibling never suppresses a healthy finding, and silence is never to be read as "safe to delete".
938
+
939
+ ### The AUQ is rendered by the coordinator, never by the module
940
+
941
+ `AskUserQuestion` is unavailable inside dispatched subagents (`.claude/rules/ask-via-tool.md` AUQ-004), so the module returns data only and the **coordinator** renders the picker — one call per candidate.
942
+
943
+ **Option order is locked and is itself a safety property (#580-AUQ-001): the non-destructive option goes FIRST and is marked `(Recommended)`, so an accidental Enter keypress can never destroy a worktree.**
944
+
945
+ `[ Behalten (Recommended) / Löschen / Manuell ]`
946
+
947
+ - **Behalten (Recommended)** — leave the worktree in place; re-surfaces next session.
948
+ - **Löschen** — operator explicitly authorises removal; the coordinator performs it, subject to PSA-003.
949
+ - **Manuell** — operator handles it outside the session; no further prompting this session.
950
+
951
+ ```js
952
+ import { checkWorktreeOrphans } from '${PLUGIN_ROOT}/scripts/lib/session-end/worktree-orphan-sweep.mjs';
953
+
954
+ const sweep = checkWorktreeOrphans({
955
+ repoRoot: process.cwd(),
956
+ config: config['worktree-orphans'],
957
+ });
958
+
959
+ if (sweep) {
960
+ console.warn(sweep.message);
961
+ // → coordinator renders the AUQ per sweep.candidates entry.
962
+ // [ Behalten (Recommended) / Löschen / Manuell ]
963
+ // Nothing is removed unless the operator picks "Löschen".
964
+ }
965
+ ```
966
+
895
967
  ## Phase 5: Issue Cleanup
896
968
 
897
969
  > **VCS Reference:** Use CLI commands per the "Common CLI Commands" section of the gitlab-ops skill.
@@ -1016,6 +1088,7 @@ Present to the user:
1016
1088
  | `learning-patterns.md` | Phases 3.5a + 3.6 extraction heuristics, confidence updates, passive decay, and JSONL write procedure |
1017
1089
  | `phase-3-6-tail.md` | Phase 3.6.x tail — full unabridged detail procedures for all six tail phases: 3.6.3 Memory-Proposals Collection (`collectProposals` + AUQ multiSelect + `promoteAndClear`, composing `writeApproved` + `clearProposalsJsonl` behind a mechanical write-before-clear guard, #828), 3.6.4 Expired-Learnings Sweep (Epic #723 B4), 3.6.5 Auto-Dream nudge (`shouldDispatchAutoDream`, #614), 3.6.6 Skill-Applied Judge (#645 L3 — `runSkillJudge`, coordinator-writes), 3.6.7 Auto-Dialectic nudge (`shouldDispatchAutoDialectic`, #614), 3.6.8 Reconciliation Rule Proposals (#696 FA3 — `runReconcile` + AUQ + `writeApprovedRules`). Loaded on demand by the SKILL.md skip-plan dispatcher (#724) — only phases with `run: true` in the `planTailPhases()` plan execute |
1018
1090
  | `scripts/lib/session-end/phase-skip.mjs` | Phase 3.6.x tail skip-plan aggregator (#724) — `planTailPhases({repoRoot, config, sessionId, platform})` → `{plan, skippedReport}`; side-effect-free (reconcile/sweep via dry-run — no writes), never-throws (per-phase probe error fail-opens to `run: true`); wraps the six existing signal helpers with config gates first, then input detection |
1091
+ | (inline) Phase 3.45 | Telemetry Flush (advisory, #844) — `flush()` from `scripts/lib/telemetry/sync.mjs` drains the host-local send-queue fire-and-forget; no config key (send-gate is `resolveConsent()` inside the module, fail-closed); skip when `persistence: false`; never-throw + ~3s-bounded, offline → bounded oldest-dropped queue, optional `Telemetry: sent/queued/gated` close-summary line, NEVER an error banner; runs late in the close after Phase 3.7 |
1019
1092
  | `session-metrics-write.md` | Phase 3.7 JSONL append, vault-mirror invocation, durable narrative mirror (`mirrorNarrative`, #675), and behavior matrix |
1020
1093
  | `phase-3-7a-recommendations.md` | Phase 3.7a full procedural body — computeV0Recommendation call, STATE.md field write, data source guarantee, error mode |
1021
1094
  | `phase-3-7a-recommendations.md` § 3.7b | Phase 3.7b full procedural body — `withDurableCommit` invocation for `sessions.jsonl` + `STATE.md` (#490 AC2), `enabled:false` local no-op, autopilot.jsonl exclusion note |
@@ -714,7 +714,19 @@ Group issues by:
714
714
 
715
715
  Non-blocking. Cross-reference: `.claude/rules/owner-persona.md` (host-wide `owner.yaml` schema + privacy contract) and issue #820.
716
716
 
717
- All banners are non-blocking display in the Session Overview, do not halt the session. If `bootstrap-lock-freshness.mjs` is absent (pre-#186 plugin install) or `peer-cards/staleness-banner.mjs` is absent (pre-#503 plugin install) or `loop-readiness-banner.mjs` is absent (pre-#633 plugin install) or `instruction-budget-guard.mjs` is absent (pre-#687 plugin install) or `reconcile-nudge-banner.mjs` is absent (pre-#723 plugin install) or `sessions-staleness-banner.mjs` is absent (pre-#724 plugin install) or `owner-config-banner.mjs` is absent (pre-#820 plugin install), skip silently.
717
+ Additionally, invoke the MOC-staleness probe (`scripts/lib/moc-staleness-banner.mjs`) via `checkMocStaleness({ repoRoot, config: $CONFIG })` (synchronous — no await). The helper returns `null` (silent no-op) when `repoRoot` is missing/non-string, when `moc-staleness.enabled` is `false` or `moc-staleness.mode` is `off` (checked BEFORE any filesystem I/O), when no vault dir resolves (neither an explicit `vaultDir` test seam nor `config['vault-integration']['vault-dir']`), when `<vaultDir>/08-topics/` is absent, when no `*-moc.md` exists there, or when every present MOC's `updated:` frontmatter is missing/unparseable. When a non-null result is returned (`{ severity: 'warn', message, stale }`), render `result.message` alongside the other banners:
718
+ - **Stale MOC(s)** (`updated:` older than the threshold, default 90 days): `"⚠ moc-staleness: <N> MOCs stale (>90 days) — <file> (<N>d), … — review and refresh the \`updated:\` frontmatter."`
719
+ - **Healthy / disabled / no MOCs / all excluded**: silent (no banner). A MOC whose `updated:` is missing or unparseable is deliberately EXCLUDED rather than reported — the corrective action there is "fix the frontmatter", not the banner's hint (same rule as `peer-cards/staleness-banner.mjs`).
720
+
721
+ Non-blocking. Cross-reference: `scripts/lib/config/moc-staleness.mjs` (`_parseMocStaleness`) and issue #831.
722
+
723
+ Additionally, invoke the context-coverage probe (`scripts/lib/context-coverage-banner.mjs`) via `checkContextCoverage({ repoRoot, config: $CONFIG })` (synchronous — no await). The helper returns `null` (silent no-op) when `repoRoot` is missing/non-string, when `context-coverage.enabled` is `false` or `context-coverage.mode` is `off` (checked BEFORE any filesystem I/O), when no vault dir resolves, when `<vaultDir>/01-projects/` is absent or empty, when zero registered projects exist, or when every registered project already carries a `context.md` or `_passive.md`. When a non-null result is returned (`{ severity: 'warn', message, gaps, registered, covered }`), render `result.message` alongside the other banners:
724
+ - **Gaps found**: `"⚠ context-coverage: <N> of <M> registered projects lack context.md and _passive.md — <slug>, … — add a context.md or mark the project passive with _passive.md."` A project counts as **registered** iff its `01-projects/<slug>/` directory contains `_overview.md` — the same convention `discoverVaultRepos()` uses. Directories lacking `_overview.md` are never counted and never listed as gaps.
725
+ - **Fully covered / no vault configured / disabled**: silent (no banner).
726
+
727
+ Non-blocking. Cross-reference: `scripts/lib/gitlab-portfolio/vcs-detect.mjs` (`discoverVaultRepos` — the canonical "registered" definition), `scripts/lib/config/context-coverage.mjs` (`_parseContextCoverage`), and issue #831.
728
+
729
+ All banners are non-blocking — display in the Session Overview, do not halt the session. If `bootstrap-lock-freshness.mjs` is absent (pre-#186 plugin install) or `peer-cards/staleness-banner.mjs` is absent (pre-#503 plugin install) or `loop-readiness-banner.mjs` is absent (pre-#633 plugin install) or `instruction-budget-guard.mjs` is absent (pre-#687 plugin install) or `reconcile-nudge-banner.mjs` is absent (pre-#723 plugin install) or `sessions-staleness-banner.mjs` is absent (pre-#724 plugin install) or `owner-config-banner.mjs` is absent (pre-#820 plugin install) or `moc-staleness-banner.mjs` / `context-coverage-banner.mjs` are absent (pre-#831 plugin install), skip silently.
718
730
 
719
731
  ## Phase 4.5: Resource Health (v3.1.0)
720
732
 
@@ -940,6 +952,69 @@ if (bannerText) {
940
952
 
941
953
  Cross-reference: PRD F2.3 acceptance criteria (#505); `scripts/lib/memory-banner.mjs` API (`renderMemoryBanner`, `readBannerInputs`; test-only exports `_formatBanner`, `_extractCardExcerpt` carry the `_`-prefix per #542 convention).
942
954
 
955
+ ## Phase 6.8: Telemetry Consent (one-time, #845)
956
+
957
+ > Skip this phase silently when `persistence: false` in Session Config. Also skip silently when non-interactive (headless / CI — no TTY to prompt on), and when the consent decision has already been made (stored `granted`/`denied`, an env override, or the fleet flag). In all of these `resolveConsent().prompt` is `false` and the phase is a no-op — it must NEVER print anything or slow session-start in the common (already-decided / headless) case.
958
+
959
+ Anonymous usage telemetry is **strictly opt-in** and, on a host that has never decided, is offered exactly once via a single interactive AskUserQuestion. The consent machine lives in `scripts/lib/telemetry/consent.mjs`; this phase only decides *whether* to prompt and then records the operator's answer. The `resolveConsent()` precedence machine is fail-closed — `prompt` is `true` only for a fresh, interactive, not-yet-decided, not-fleet, not-env-overridden host.
960
+
961
+ ```javascript
962
+ import { readTelemetryState, resolveConsent, isHeadless, grantConsent, denyConsent } from '${PLUGIN_ROOT}/scripts/lib/telemetry/consent.mjs';
963
+ import { loadOwnerConfig } from '${PLUGIN_ROOT}/scripts/lib/owner-yaml.mjs';
964
+
965
+ const c = resolveConsent({
966
+ env: process.env,
967
+ ownerConfig: loadOwnerConfig().config, // fleet flag lives at .telemetry.enabled (host-local owner.yaml, never committed)
968
+ state: readTelemetryState().record, // persisted per-user decision (~/.config/session-orchestrator/telemetry.json)
969
+ interactive: !isHeadless(), // fail-closed toward headless — anything but a confirmed TTY counts as headless
970
+ });
971
+ if (!c.prompt) {
972
+ // silent no-op — already decided, env-override, fleet-enabled, or headless. Do NOT print, do NOT prompt.
973
+ }
974
+ ```
975
+
976
+ **When `c.prompt === true`**, the coordinator renders EXACTLY ONE `AskUserQuestion` (per `.claude/rules/ask-via-tool.md` AUQ-003 — the tool, never inline prose):
977
+
978
+ ```js
979
+ AskUserQuestion({
980
+ questions: [{
981
+ question: "Anonyme Usage-Telemetrie aktivieren? Strikt opt-in, whitelist-projiziert (keine Repo-Namen/Pfade/Prompts), jederzeit abschaltbar — Details: docs/telemetry.md",
982
+ header: "Usage Telemetry",
983
+ multiSelect: false,
984
+ options: [
985
+ { label: "Ja, aktivieren", description: "Anonymer Zähl-/Struktur-Datensatz (Skill-/Phasen-Nutzung, Erfolg/Abbruch) — whitelist-projiziert, keine Pfade/Prompts/Repo-Namen. Details: docs/telemetry.md" },
986
+ { label: "Nein", description: "Keine Telemetrie senden. Jederzeit später aktivierbar via node scripts/telemetry.mjs." },
987
+ ],
988
+ }],
989
+ });
990
+ ```
991
+
992
+ > **Consent-Neutralität (deliberate AUQ-003 deviation):** this is the ONE AskUserQuestion in the session flow that carries **no `(Recommended)` label on either option** — neither "Ja" nor "Nein" is tagged. AUQ-003's "option 1 is always the recommendation" convention is intentionally NOT applied here, so the operator's consent is unbiased. Do not add a recommendation to either option.
993
+
994
+ - **Codex CLI / Cursor IDE fallback (numbered Markdown list — AUQ-004 exception 1):**
995
+ ```
996
+ Anonyme Usage-Telemetrie aktivieren? Strikt opt-in, whitelist-projiziert (keine Repo-Namen/Pfade/Prompts), jederzeit abschaltbar — Details: docs/telemetry.md
997
+ 1. Ja, aktivieren — anonymer Zähl-/Struktur-Datensatz, keine Pfade/Prompts/Repo-Namen.
998
+ 2. Nein — keine Telemetrie senden.
999
+ Reply with the number of your choice. (No option is pre-recommended — the choice is yours.)
1000
+ ```
1001
+
1002
+ On the operator's answer:
1003
+ - **"Ja, aktivieren"** → call `grantConsent()`. Then add a single confirmation line to the Session Overview: `Telemetry: enabled — ändern via node scripts/telemetry.mjs`.
1004
+ - **"Nein"** → call `denyConsent()`. Then add: `Telemetry: disabled — ändern via node scripts/telemetry.mjs`.
1005
+
1006
+ Both helpers atomically persist the decision (read-modify-write, `anon_id` fields preserved) to `~/.config/session-orchestrator/telemetry.json`.
1007
+
1008
+ ### Fleet mode (host-local, no prompt)
1009
+
1010
+ Setting `telemetry:\n enabled: true` in the host-local `~/.config/session-orchestrator/owner.yaml` (never committed — same host-local-data contract as `.claude/rules/owner-persona.md`) enables telemetry across every repo on the host WITHOUT ever prompting: `resolveConsent()` then returns `prompt: false` with state `enabled-fleet`, so this phase is a silent no-op. The per-shell escape hatches `SO_TELEMETRY_DISABLED=1` and `DO_NOT_TRACK` outrank the fleet flag for a single shell. See `docs/telemetry.md` for the full precedence table (PRD FA5).
1011
+
1012
+ ### One-time guarantee
1013
+
1014
+ The decision persists host-locally in `~/.config/session-orchestrator/telemetry.json`; once `consent` is non-`null` (granted or denied), `resolveConsent().prompt` stays `false` and this phase never fires again on that host — no repeat prompting across repos or sessions.
1015
+
1016
+ Cross-reference: GitLab #845 (Epic #841); `docs/prd/2026-07-20-anonymous-usage-telemetry.md` §3 FA1/FA5; `docs/telemetry.md`; consent API in `scripts/lib/telemetry/consent.mjs` (`resolveConsent`, `grantConsent`, `denyConsent`, `isHeadless`, `readTelemetryState`).
1017
+
943
1018
  ## Phase 7: Research (session type dependent)
944
1019
 
945
1020
  > **Note:** Implementation-specific research (library APIs, best practices for specific code changes) is deferred to session-plan, which knows the exact scope. Session-start focuses on state analysis.
@@ -1038,6 +1113,7 @@ After user alignment:
1038
1113
  | (inline) Phase 2.7 | GitLab Portfolio Snapshot — dry-run aggregation banner; gated on `gitlab-portfolio.enabled: true` + `vault-integration.enabled: true`; dispatches `scripts/lib/gitlab-portfolio/cli.mjs --dry-run`; 8s timeout; never blocks session-start |
1039
1114
  | `phase-4-5-resource-health.md` | Phase 4.5 full procedural body — resource probe, adaptive thresholds table, AUQ presentation, session-plan cap handoff |
1040
1115
  | (inline) Phase 6.7 | Memory Banner — `renderMemoryBanner` from `scripts/lib/memory-banner.mjs` (#505); silent no-op when `memory.banner.enabled: false` or `persistence: false` |
1116
+ | (inline) Phase 6.8 | Telemetry Consent (one-time, #845) — `resolveConsent()` from `scripts/lib/telemetry/consent.mjs` decides `prompt`; when true, ONE consent-neutral `AskUserQuestion` (no `(Recommended)` on either option) → `grantConsent()`/`denyConsent()`; silent no-op when `persistence: false`, headless/CI, already-decided, fleet-enabled (`owner.yaml telemetry.enabled`), or env-overridden (`SO_TELEMETRY_DISABLED=1`/`DO_NOT_TRACK`); host-local one-time guarantee via `~/.config/session-orchestrator/telemetry.json` |
1041
1117
  | `phase-7-1-premise-check.md` | Phase 7.1 full procedural body — claim extraction, one-grep-per-claim verification, verdict table, emission block format |
1042
1118
  | `phase-7-5-mode-selector.md` | Phase 7.5 full procedural body — buildLiveSignals, selectMode invocation, banner rendering, AUQ ordering protocol, graceful no-op rules, accuracy learning write |
1043
1119
  | `phase-8-5-express-path.md` | Phase 8.5 full procedural body — activation conditions, banner, coordinator-direct execution, STATE.md logging, condition examples table |
@@ -18,7 +18,7 @@ Phase 1 ships a self-contained validator that reads every `.md` file under `VAUL
18
18
 
19
19
  ### Files
20
20
 
21
- - `validator.mjs` — Node.js ESM validator. Uses `zod` + `yaml` npm packages. Reads `VAULT_DIR` (env or default cwd), walks the tree, skipping `node_modules/`, `.git/`, `.obsidian/`, `90-archive/`. For each `.md`: parses frontmatter, validates against the inline Zod schema, extracts `[[wiki-links]]`, verifies each target resolves. Emits JSON report on stdout.
21
+ - `validator.mjs` — Node.js ESM validator. Uses `zod` + `yaml` npm packages. Reads `VAULT_DIR` (env or default cwd), walks the tree, skipping `node_modules/`, `.git/`, `.obsidian/`. `90-archive/` is walked but never *checked* (#833): archived notes stay in the link-target register — so an inbound `[[wiki-link]]` to an archived note resolves instead of dangling — while their frontmatter is skipped and counted in `archived_skipped_count`. For each `.md`: parses frontmatter, validates against the inline Zod schema, extracts `[[wiki-links]]`, verifies each target resolves. Emits JSON report on stdout.
22
22
  - `validator.sh` — Thin POSIX wrapper. Resolves `VAULT_DIR` from arg 1 or env, self-bootstraps deps via `pnpm install --silent` on first run, execs the Node validator. Session-end and other callers use this entry point.
23
23
  - `package.json` — Declares `zod` (`^3.24.0`, matching projects-baseline) and `yaml` (`^2.5.0`) as deps. `pnpm-lock.yaml` is committed; `node_modules/` is gitignored.
24
24
  - `tests/validator.bats` — 16 BATS cases covering clean vaults, broken frontmatter, missing required fields, dangling links, no-vault skipping, README-style files, nested directories, and archive/obsidian exclusion.
@@ -13,9 +13,9 @@
13
13
  }
14
14
  },
15
15
  "node_modules/yaml": {
16
- "version": "2.9.0",
17
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
18
- "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
16
+ "version": "2.8.3",
17
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
18
+ "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
19
19
  "license": "ISC",
20
20
  "bin": {
21
21
  "yaml": "bin.mjs"