session-orchestrator 3.16.0 → 3.19.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 +17 -1
- package/CHANGELOG.md +130 -412
- package/README.md +17 -12
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/docs/README.md +2 -1
- package/docs/USER-GUIDE.md +8 -3
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +444 -0
- package/docs/pi-setup.md +1 -1
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +306 -6
- package/docs/session-config-template.md +66 -3
- package/docs/telemetry/telemetry-claims.md +204 -0
- package/docs/telemetry.md +158 -0
- package/hooks/_lib/guard-source-loader.mjs +467 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +10 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +23 -0
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +525 -160
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/skill-invocation-telemetry.mjs +109 -10
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +16 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/compute-grounding-injection.sh +18 -3
- package/scripts/dialectic-deriver.mjs +7 -2
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/auto-dialectic.mjs +11 -2
- package/scripts/lib/auto-dream.mjs +16 -5
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/build-live-signals.mjs +7 -4
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1013 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/context-coverage.mjs +82 -0
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/moc-staleness.mjs +98 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config/worktree-orphans.mjs +138 -0
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +32 -0
- package/scripts/lib/context-coverage-banner.mjs +223 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +136 -30
- package/scripts/lib/dispatcher/rank.mjs +22 -8
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/moc-staleness-banner.mjs +267 -0
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +23 -4
- package/scripts/lib/reconcile/engine.mjs +147 -39
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +399 -98
- package/scripts/lib/session-close-backfill.mjs +61 -6
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/filters.mjs +88 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/skill-health/join.mjs +35 -9
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/telemetry/anon-id.mjs +141 -0
- package/scripts/lib/telemetry/consent.mjs +299 -0
- package/scripts/lib/telemetry/paths.mjs +27 -0
- package/scripts/lib/telemetry/queue.mjs +287 -0
- package/scripts/lib/telemetry/schema.mjs +384 -0
- package/scripts/lib/telemetry/sync.mjs +312 -0
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-rules.mjs +217 -35
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vault-status/board-writer.mjs +63 -5
- package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +32 -6
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +51 -12
- package/scripts/release.mjs +534 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/telemetry.mjs +250 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +1 -0
- package/skills/gitlab-ops/SKILL.md +20 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +97 -0
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +108 -14
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +2 -1
- package/skills/session-end/plan-verification.md +5 -2
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +149 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/SKILL.md +1 -1
- package/skills/vault-sync/package-lock.json +3 -3
- package/skills/vault-sync/validator.mjs +228 -40
- package/skills/wave-executor/SKILL.md +5 -2
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +163 -10
- package/templates/_shared/loop.md +4 -4
|
@@ -38,7 +38,9 @@ import { spawnSync } from 'node:child_process';
|
|
|
38
38
|
|
|
39
39
|
import { die, warn } from './lib/common.mjs';
|
|
40
40
|
import { loadQualityGatesPolicy, resolveCommand } from './lib/quality-gates-policy.mjs';
|
|
41
|
-
import { emitEvent } from './lib/events.mjs';
|
|
41
|
+
import { emitEvent, sessionAttribution } from './lib/events.mjs';
|
|
42
|
+
import { admitSuiteCounts } from './lib/gates/gate-helpers.mjs';
|
|
43
|
+
import { findScopeFile } from './lib/scope-gate.mjs';
|
|
42
44
|
|
|
43
45
|
// ---------------------------------------------------------------------------
|
|
44
46
|
// Constants
|
|
@@ -49,6 +51,14 @@ const GATES_DIR = join(__dirname, 'lib', 'gates');
|
|
|
49
51
|
|
|
50
52
|
const VALID_VARIANTS = ['baseline', 'incremental', 'full-gate', 'per-file'];
|
|
51
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Ceiling on the gate sub-script's captured stdout. A gate writes one JSON line
|
|
56
|
+
* (5-line command tails + ≤50 debug artifacts), so this is effectively
|
|
57
|
+
* unreachable — it exists only so a pathological gate cannot be killed
|
|
58
|
+
* mid-write by the default 1 MiB spawnSync cap.
|
|
59
|
+
*/
|
|
60
|
+
const GATE_STDOUT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
61
|
+
|
|
52
62
|
const DEFAULT_TEST_CMD = 'npm test';
|
|
53
63
|
const DEFAULT_TYPECHECK_CMD = 'npm run typecheck';
|
|
54
64
|
const DEFAULT_LINT_CMD = 'npm run lint';
|
|
@@ -148,6 +158,89 @@ function extractCommand(policy, policyKey, configKey, configJson, defaultCmd) {
|
|
|
148
158
|
return defaultCmd;
|
|
149
159
|
}
|
|
150
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Lift the suite counts out of a gate sub-script's JSON stdout envelope (#954).
|
|
163
|
+
*
|
|
164
|
+
* This function is the ENVELOPE ADAPTER only. The numeric admission policy —
|
|
165
|
+
* which triples count as a measurement and which are refused — lives once in
|
|
166
|
+
* {@link admitSuiteCounts} (`scripts/lib/gates/gate-helpers.mjs`, #967 item 2),
|
|
167
|
+
* shared with `suiteCountsFromOutput` in `scripts/lib/quality-gate.mjs`. Before
|
|
168
|
+
* that split, the same `counts` field was written under two different policies
|
|
169
|
+
* and a consumer had to know both to read one number.
|
|
170
|
+
*
|
|
171
|
+
* The four rejections that stay here are envelope-shaped, not numeric:
|
|
172
|
+
*
|
|
173
|
+
* 1. stdout is absent or not parseable JSON;
|
|
174
|
+
* 2. `test` is not the object form — only `gate-full.mjs` reports numbers;
|
|
175
|
+
* `gate-{baseline,incremental,per-file}.mjs` emit a bare status STRING, so
|
|
176
|
+
* a non-full-gate variant structurally cannot carry counts;
|
|
177
|
+
* 3. the test gate was skipped (`status` neither `pass` nor `fail`);
|
|
178
|
+
* 4. the test COMMAND was detected as a stub (`echo …` / no-op) — a stub's
|
|
179
|
+
* output parses to 0/0, which would be a fabricated zero.
|
|
180
|
+
*
|
|
181
|
+
* `failed` is NO LONGER derived here. Since #967 item 1 `gate-full.mjs`
|
|
182
|
+
* publishes it explicitly, so the whole `test` object is handed through and
|
|
183
|
+
* `admitSuiteCounts`'s `passed + failed === total` check becomes a real guard
|
|
184
|
+
* against producer/consumer envelope drift instead of an identity that a local
|
|
185
|
+
* `total - passed` derivation could never fail.
|
|
186
|
+
*
|
|
187
|
+
* Never throws — a malformed envelope yields `null`.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} stdout — the gate sub-script's captured stdout.
|
|
190
|
+
* @returns {{ passed: number, failed: number, total: number }|null}
|
|
191
|
+
*/
|
|
192
|
+
function suiteCountsFromGateStdout(stdout) {
|
|
193
|
+
if (typeof stdout !== 'string' || !stdout.trim()) return null;
|
|
194
|
+
|
|
195
|
+
let parsed;
|
|
196
|
+
try {
|
|
197
|
+
parsed = JSON.parse(stdout);
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
202
|
+
|
|
203
|
+
const test = parsed.test;
|
|
204
|
+
if (test === null || typeof test !== 'object' || Array.isArray(test)) return null;
|
|
205
|
+
if (test.status !== 'pass' && test.status !== 'fail') return null;
|
|
206
|
+
if (parsed.stubbed && typeof parsed.stubbed === 'object' && parsed.stubbed.test) return null;
|
|
207
|
+
|
|
208
|
+
return admitSuiteCounts(test);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Resolve the active wave number from the wave-scope sidecar (#966 step 1).
|
|
213
|
+
*
|
|
214
|
+
* Mirrors `resolveWave()` in `hooks/pre-bash-memory-propose-audit.mjs` — the
|
|
215
|
+
* same `.{pi,cursor,codex,claude}/wave-scope.json` precedence via
|
|
216
|
+
* {@link findScopeFile} — with ONE deliberate difference: the hook returns `0`
|
|
217
|
+
* for "no wave-scope file", this returns `null`.
|
|
218
|
+
*
|
|
219
|
+
* Absent is not zero. A human running `npm run quality-gate` from a `git push`
|
|
220
|
+
* has no wave at all, and that is the common case; publishing `wave_number: 0`
|
|
221
|
+
* would invent a wave 0 that every consumer then has to special-case. The
|
|
222
|
+
* caller spreads the result so the KEY is omitted, exactly as `counts` is.
|
|
223
|
+
*
|
|
224
|
+
* A non-positive or non-numeric `wave` field is treated the same way — waves
|
|
225
|
+
* are 1-indexed, so `0` on disk carries no more information than an absent file.
|
|
226
|
+
*
|
|
227
|
+
* Never throws.
|
|
228
|
+
*
|
|
229
|
+
* @param {string} projectDir — directory whose wave-scope sidecar to read.
|
|
230
|
+
* @returns {number|null} positive wave number, or `null` when there is no wave.
|
|
231
|
+
*/
|
|
232
|
+
function resolveWaveNumber(projectDir) {
|
|
233
|
+
try {
|
|
234
|
+
const waveFile = findScopeFile(projectDir);
|
|
235
|
+
if (!waveFile || !existsSync(waveFile)) return null;
|
|
236
|
+
const wave = JSON.parse(readFileSync(waveFile, 'utf8'))?.wave;
|
|
237
|
+
if (typeof wave !== 'number' || !Number.isFinite(wave) || wave <= 0) return null;
|
|
238
|
+
return Math.trunc(wave);
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
151
244
|
// Load policy file (never throws)
|
|
152
245
|
const repoRoot = process.cwd();
|
|
153
246
|
const policy = loadQualityGatesPolicy(repoRoot);
|
|
@@ -193,23 +286,48 @@ const env = {
|
|
|
193
286
|
SESSION_START_REF: sessionStartRef,
|
|
194
287
|
};
|
|
195
288
|
|
|
289
|
+
// stdout is PIPED (not inherited) so the suite counts the gate already computed
|
|
290
|
+
// can be lifted straight off its JSON envelope into telemetry (#954) instead of
|
|
291
|
+
// travelling as prose through the STATE.md header. The envelope is re-emitted
|
|
292
|
+
// verbatim below, so the stdout contract is unchanged — a gate sub-script writes
|
|
293
|
+
// exactly one JSON line at the very end (its own child commands are captured by
|
|
294
|
+
// `runCheck`), so nothing streamed before and nothing streams now. stderr stays
|
|
295
|
+
// inherited, keeping warnings live.
|
|
196
296
|
const result = spawnSync('node', [gatePath], {
|
|
197
297
|
env,
|
|
198
|
-
stdio: 'inherit',
|
|
298
|
+
stdio: ['inherit', 'pipe', 'inherit'],
|
|
299
|
+
encoding: 'utf8',
|
|
300
|
+
maxBuffer: GATE_STDOUT_MAX_BUFFER_BYTES,
|
|
199
301
|
});
|
|
200
302
|
|
|
201
|
-
|
|
303
|
+
const gateStdout = typeof result.stdout === 'string' ? result.stdout : '';
|
|
304
|
+
if (gateStdout) process.stdout.write(gateStdout);
|
|
305
|
+
|
|
306
|
+
if (result.error && typeof result.status !== 'number') {
|
|
202
307
|
die(`Failed to run gate script: ${result.error.message}`);
|
|
203
308
|
}
|
|
204
309
|
|
|
205
310
|
// Quality-gate telemetry — one canonical event per gate run via emitEvent
|
|
206
|
-
// (single emission path).
|
|
207
|
-
//
|
|
311
|
+
// (single emission path). `sessionAttribution` is the shared helper in
|
|
312
|
+
// events.mjs (#941); this CLI wrapper runs against the CWD `repoRoot`, so the
|
|
313
|
+
// bare emitEvent destination (SO_PROJECT_DIR default) is correct here.
|
|
314
|
+
// Best-effort: a telemetry failure must NEVER alter the gate's authoritative
|
|
315
|
+
// exit code — which is why the counts parse also lives inside this try.
|
|
208
316
|
const exitCode = result.status ?? 1;
|
|
209
317
|
try {
|
|
318
|
+
const counts = suiteCountsFromGateStdout(gateStdout);
|
|
319
|
+
// Wave-scope sidecar is read from the SAME project dir the event lands in
|
|
320
|
+
// (emitEvent's own destination precedence), so a tmp-scoped run cannot pick
|
|
321
|
+
// up the host repo's live wave. Mirrors the hook's projectDir resolution.
|
|
322
|
+
const waveNumber = resolveWaveNumber(
|
|
323
|
+
process.env.CLAUDE_PROJECT_DIR ?? process.env.CODEX_PROJECT_DIR ?? repoRoot,
|
|
324
|
+
);
|
|
210
325
|
await emitEvent(`orchestrator.quality_gate.${exitCode === 0 ? 'passed' : 'failed'}`, {
|
|
211
326
|
variant,
|
|
212
327
|
exit_code: exitCode,
|
|
328
|
+
...(counts ? { counts } : {}),
|
|
329
|
+
...(waveNumber !== null ? { wave_number: waveNumber } : {}),
|
|
330
|
+
...sessionAttribution(repoRoot),
|
|
213
331
|
});
|
|
214
332
|
} catch { /* best-effort telemetry — gate result is authoritative */ }
|
|
215
333
|
|
|
@@ -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
|
+
});
|