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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +25 -0
- package/README.md +13 -11
- package/docs/README.md +2 -1
- package/docs/components.md +2 -2
- package/docs/pi-setup.md +1 -1
- package/docs/session-config-reference.md +65 -0
- package/docs/session-config-template.md +27 -0
- package/docs/telemetry/telemetry-claims.md +204 -0
- package/docs/telemetry.md +158 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/skill-invocation-telemetry.mjs +109 -10
- package/package.json +12 -2
- package/scripts/compute-grounding-injection.sh +18 -3
- package/scripts/dialectic-deriver.mjs +7 -2
- package/scripts/lib/auto-dialectic.mjs +11 -2
- package/scripts/lib/auto-dream.mjs +16 -5
- package/scripts/lib/build-live-signals.mjs +7 -4
- package/scripts/lib/config/context-coverage.mjs +82 -0
- package/scripts/lib/config/moc-staleness.mjs +98 -0
- package/scripts/lib/config/worktree-orphans.mjs +138 -0
- package/scripts/lib/config.mjs +15 -0
- package/scripts/lib/context-coverage-banner.mjs +223 -0
- package/scripts/lib/dispatcher/enumerate.mjs +151 -31
- package/scripts/lib/dispatcher/rank.mjs +22 -8
- package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
- package/scripts/lib/harness-audit/categories/category4.mjs +13 -2
- package/scripts/lib/moc-staleness-banner.mjs +267 -0
- package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
- package/scripts/lib/session-schema/filters.mjs +88 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/skill-health/join.mjs +35 -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/vault-status/board-writer.mjs +63 -5
- package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
- package/scripts/mcp-server.sh +15 -3
- package/scripts/telemetry.mjs +250 -0
- package/skills/npm-publish/SKILL.md +81 -0
- package/skills/session-end/SKILL.md +74 -1
- package/skills/session-start/SKILL.md +77 -1
- package/skills/vault-sync/SKILL.md +1 -1
- package/skills/vault-sync/package-lock.json +3 -3
- package/skills/vault-sync/validator.mjs +121 -34
|
@@ -15,6 +15,18 @@
|
|
|
15
15
|
* the session is still in progress, or the record was never written), the session
|
|
16
16
|
* contributes `unknown` to the outcome tally — it is never silently dropped.
|
|
17
17
|
*
|
|
18
|
+
* ABANDONED-SESSION HANDLING (#834): a session_id CAN be found in sessions.jsonl
|
|
19
|
+
* yet be a phantom stub (`status: 'abandoned'`, written by session-close-backfill
|
|
20
|
+
* for a session that ended without a real close — 0 waves, all-zero agent_summary).
|
|
21
|
+
* Counting such a join as `sessionsJoined` would inflate the skill's join
|
|
22
|
+
* denominator with zero real contribution — the join "succeeds" but carries no
|
|
23
|
+
* signal. Per this module's own "never silently dropped" contract, an abandoned
|
|
24
|
+
* join is routed to a DISTINCT `abandoned` outcome bucket rather than either (a)
|
|
25
|
+
* silently folding into `sessionsJoined`/the numeric outcome fields with zero
|
|
26
|
+
* contribution (inflates the denominator invisibly), or (b) folding into
|
|
27
|
+
* `unknown` (which means "not found in the ledger at all" — a different failure
|
|
28
|
+
* mode a caller may want to distinguish from "found but phantom").
|
|
29
|
+
*
|
|
18
30
|
* Part of Epic #645 — Skill Self-Evolution Foundation, Layer 2.
|
|
19
31
|
*/
|
|
20
32
|
|
|
@@ -22,6 +34,8 @@ import { promises as fs } from 'node:fs';
|
|
|
22
34
|
import path from 'node:path';
|
|
23
35
|
import { fileURLToPath } from 'node:url';
|
|
24
36
|
|
|
37
|
+
import { isRealSession } from '../session-schema/filters.mjs';
|
|
38
|
+
|
|
25
39
|
const DEFAULT_INVOCATIONS_PATH = path.resolve(
|
|
26
40
|
fileURLToPath(import.meta.url),
|
|
27
41
|
'../../../../.orchestrator/metrics/skill-invocations.jsonl',
|
|
@@ -61,18 +75,21 @@ async function readJsonl(filePath) {
|
|
|
61
75
|
}
|
|
62
76
|
|
|
63
77
|
/**
|
|
64
|
-
* Builds a Map<session_id,
|
|
78
|
+
* Builds a Map<session_id, { agentSummary, real }> from sessions.jsonl records.
|
|
65
79
|
* Records without a session_id or with a non-object agent_summary are skipped.
|
|
80
|
+
* `real` is false for phantom `status: 'abandoned'` stubs (#834) — callers use
|
|
81
|
+
* it to route the join to the `abandoned` outcome bucket instead of counting
|
|
82
|
+
* a zero-signal join as `sessionsJoined`.
|
|
66
83
|
*
|
|
67
84
|
* @param {object[]} sessionRecords
|
|
68
|
-
* @returns {Map<string, { complete: number, partial: number, failed: number, spiral: number }>}
|
|
85
|
+
* @returns {Map<string, { agentSummary: { complete: number, partial: number, failed: number, spiral: number }, real: boolean }>}
|
|
69
86
|
*/
|
|
70
87
|
function buildSessionMap(sessionRecords) {
|
|
71
88
|
const map = new Map();
|
|
72
89
|
for (const rec of sessionRecords) {
|
|
73
90
|
if (typeof rec.session_id !== 'string' || !rec.session_id) continue;
|
|
74
91
|
if (rec.agent_summary && typeof rec.agent_summary === 'object') {
|
|
75
|
-
map.set(rec.session_id, rec.agent_summary);
|
|
92
|
+
map.set(rec.session_id, { agentSummary: rec.agent_summary, real: isRealSession(rec) });
|
|
76
93
|
}
|
|
77
94
|
}
|
|
78
95
|
return map;
|
|
@@ -91,12 +108,13 @@ function buildSessionMap(sessionRecords) {
|
|
|
91
108
|
* skill: string,
|
|
92
109
|
* selections: number,
|
|
93
110
|
* sessions: string[],
|
|
94
|
-
* outcomes: { complete: number, partial: number, failed: number, spiral: number, unknown: number }
|
|
111
|
+
* outcomes: { complete: number, partial: number, failed: number, spiral: number, unknown: number, abandoned: number }
|
|
95
112
|
* }
|
|
96
113
|
* },
|
|
97
114
|
* totalSelections: number,
|
|
98
115
|
* sessionsJoined: number,
|
|
99
|
-
* sessionsUnknown: number
|
|
116
|
+
* sessionsUnknown: number,
|
|
117
|
+
* sessionsAbandoned: number
|
|
100
118
|
* }>}
|
|
101
119
|
*/
|
|
102
120
|
export async function joinSkillOutcomes({
|
|
@@ -116,6 +134,7 @@ export async function joinSkillOutcomes({
|
|
|
116
134
|
let totalSelections = 0;
|
|
117
135
|
let sessionsJoined = 0;
|
|
118
136
|
let sessionsUnknown = 0;
|
|
137
|
+
let sessionsAbandoned = 0;
|
|
119
138
|
|
|
120
139
|
for (const inv of invocations) {
|
|
121
140
|
// Only process skill-selection events with a valid skill field
|
|
@@ -132,7 +151,7 @@ export async function joinSkillOutcomes({
|
|
|
132
151
|
skill,
|
|
133
152
|
selectionCount: 0,
|
|
134
153
|
sessions: new Set(),
|
|
135
|
-
outcomes: { complete: 0, partial: 0, failed: 0, spiral: 0, unknown: 0 },
|
|
154
|
+
outcomes: { complete: 0, partial: 0, failed: 0, spiral: 0, unknown: 0, abandoned: 0 },
|
|
136
155
|
});
|
|
137
156
|
}
|
|
138
157
|
|
|
@@ -144,14 +163,21 @@ export async function joinSkillOutcomes({
|
|
|
144
163
|
record.sessions.add(sessionId);
|
|
145
164
|
|
|
146
165
|
if (isNew) {
|
|
147
|
-
const
|
|
148
|
-
if (
|
|
166
|
+
const entry = sessionMap.get(sessionId);
|
|
167
|
+
if (entry && entry.real) {
|
|
168
|
+
const summary = entry.agentSummary;
|
|
149
169
|
// Sum session-level aggregate outcomes into this skill's buckets
|
|
150
170
|
record.outcomes.complete += typeof summary.complete === 'number' ? summary.complete : 0;
|
|
151
171
|
record.outcomes.partial += typeof summary.partial === 'number' ? summary.partial : 0;
|
|
152
172
|
record.outcomes.failed += typeof summary.failed === 'number' ? summary.failed : 0;
|
|
153
173
|
record.outcomes.spiral += typeof summary.spiral === 'number' ? summary.spiral : 0;
|
|
154
174
|
sessionsJoined += 1;
|
|
175
|
+
} else if (entry && !entry.real) {
|
|
176
|
+
// Found in sessions.jsonl but a phantom abandoned stub (#834) — a
|
|
177
|
+
// zero-signal join. Route to a distinct bucket instead of inflating
|
|
178
|
+
// sessionsJoined or conflating with "not found at all" (unknown).
|
|
179
|
+
record.outcomes.abandoned += 1;
|
|
180
|
+
sessionsAbandoned += 1;
|
|
155
181
|
} else {
|
|
156
182
|
// Session id not found in sessions.jsonl — count as unknown, never drop
|
|
157
183
|
record.outcomes.unknown += 1;
|
|
@@ -177,5 +203,5 @@ export async function joinSkillOutcomes({
|
|
|
177
203
|
};
|
|
178
204
|
}
|
|
179
205
|
|
|
180
|
-
return { bySkill: bySkillObj, totalSelections, sessionsJoined, sessionsUnknown };
|
|
206
|
+
return { bySkill: bySkillObj, totalSelections, sessionsJoined, sessionsUnknown, sessionsAbandoned };
|
|
181
207
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* telemetry/anon-id.mjs — rotating anonymous ID for usage-telemetry (Epic #841,
|
|
3
|
+
* S2 / GitLab #843; PRD docs/prd/2026-07-20-anonymous-usage-telemetry.md §3-FA2).
|
|
4
|
+
*
|
|
5
|
+
* PURE, no I/O. The anonymous ID is a random UUID that rotates every
|
|
6
|
+
* ANON_ID_MAX_AGE_DAYS days. It is NEVER machine-derived (no hostname, MAC,
|
|
7
|
+
* install path, or any stable hardware/user identifier) — this is the privacy
|
|
8
|
+
* invariant that avoids the persistent-ID correlation criticism (PRD §4
|
|
9
|
+
* "Privacy engineering"). Rotation discards the old ID entirely.
|
|
10
|
+
*
|
|
11
|
+
* All time is passed IN as a parameter (`now`), never read from the clock inside
|
|
12
|
+
* this module, so callers stay deterministic and testable.
|
|
13
|
+
*
|
|
14
|
+
* Contract:
|
|
15
|
+
* newAnonId() → a fresh random UUID (v4).
|
|
16
|
+
* isExpired(createdAtISO, now, maxAge) → boolean; unparsable createdAt ⇒ true.
|
|
17
|
+
* ensureAnonId(record, opts) → { record, anon_id, rotated, created }.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { randomUUID } from 'node:crypto';
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Constants
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/** Rotate the anonymous ID after this many days. */
|
|
27
|
+
export const ANON_ID_MAX_AGE_DAYS = 90;
|
|
28
|
+
|
|
29
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Internal helpers
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
function isPlainObject(v) {
|
|
36
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve a `now` argument to epoch-ms. Accepts either a number (already epoch-ms)
|
|
41
|
+
* or an ISO 8601 string. Returns NaN when it cannot be parsed.
|
|
42
|
+
* @param {number|string} now
|
|
43
|
+
* @returns {number}
|
|
44
|
+
*/
|
|
45
|
+
function toEpochMs(now) {
|
|
46
|
+
if (typeof now === 'number') return now;
|
|
47
|
+
if (typeof now === 'string') return Date.parse(now);
|
|
48
|
+
return NaN;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Public API
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Mint a fresh anonymous ID. A random UUID (v4) — never derived from any machine
|
|
57
|
+
* or user attribute.
|
|
58
|
+
*
|
|
59
|
+
* @returns {string} a v4 UUID
|
|
60
|
+
*/
|
|
61
|
+
export function newAnonId() {
|
|
62
|
+
return randomUUID();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Decide whether an anonymous ID minted at `createdAtISO` is older than
|
|
67
|
+
* `maxAgeDays` relative to `now` and must be rotated.
|
|
68
|
+
*
|
|
69
|
+
* Fail-safe posture: an unparsable / missing `createdAtISO` (or `now`) returns
|
|
70
|
+
* `true` (rotate) — an ID whose age cannot be verified is treated as stale
|
|
71
|
+
* rather than trusted indefinitely. The age comparison is strict: exactly
|
|
72
|
+
* `maxAgeDays` old is NOT expired (only strictly older rotates).
|
|
73
|
+
*
|
|
74
|
+
* @param {string} createdAtISO — ISO 8601 timestamp the current ID was minted at.
|
|
75
|
+
* @param {number|string} now — reference time (epoch-ms or ISO 8601 string).
|
|
76
|
+
* @param {number} [maxAgeDays=ANON_ID_MAX_AGE_DAYS]
|
|
77
|
+
* @returns {boolean} true ⇒ rotate.
|
|
78
|
+
*/
|
|
79
|
+
export function isExpired(createdAtISO, now, maxAgeDays = ANON_ID_MAX_AGE_DAYS) {
|
|
80
|
+
const createdMs = typeof createdAtISO === 'string' ? Date.parse(createdAtISO) : NaN;
|
|
81
|
+
if (Number.isNaN(createdMs)) return true; // unverifiable age ⇒ rotate
|
|
82
|
+
|
|
83
|
+
const nowMs = toEpochMs(now);
|
|
84
|
+
if (Number.isNaN(nowMs)) return true; // unverifiable reference ⇒ rotate
|
|
85
|
+
|
|
86
|
+
const ageMs = nowMs - createdMs;
|
|
87
|
+
const maxMs = maxAgeDays * MS_PER_DAY;
|
|
88
|
+
return ageMs > maxMs;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Ensure `record` carries a fresh-enough anonymous ID, returning a NEW record
|
|
93
|
+
* (the input is never mutated). Three outcomes:
|
|
94
|
+
*
|
|
95
|
+
* - created — the record had no `anon_id`: mint one, stamp `anon_id_created_at`
|
|
96
|
+
* to `now`. Returns `{ created: true, rotated: false }`.
|
|
97
|
+
* - rotated — the existing ID is older than `maxAgeDays` (or its
|
|
98
|
+
* `anon_id_created_at` is unparsable): mint a new one, re-stamp
|
|
99
|
+
* `anon_id_created_at`, discard the old ID.
|
|
100
|
+
* Returns `{ created: false, rotated: true }`.
|
|
101
|
+
* - unchanged — the ID is present and fresh: return it as-is (in a shallow
|
|
102
|
+
* copy). Returns `{ created: false, rotated: false }`.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} record — a record that may carry `anon_id` + `anon_id_created_at`.
|
|
105
|
+
* @param {{now?: string, maxAgeDays?: number}} [opts]
|
|
106
|
+
* @returns {{record: object, anon_id: string, rotated: boolean, created: boolean}}
|
|
107
|
+
*/
|
|
108
|
+
export function ensureAnonId(record, { now = new Date().toISOString(), maxAgeDays = ANON_ID_MAX_AGE_DAYS } = {}) {
|
|
109
|
+
const rec = isPlainObject(record) ? record : {};
|
|
110
|
+
const currentId = rec.anon_id;
|
|
111
|
+
|
|
112
|
+
// created — no usable ID present.
|
|
113
|
+
if (typeof currentId !== 'string' || currentId.trim() === '') {
|
|
114
|
+
const anon_id = newAnonId();
|
|
115
|
+
return {
|
|
116
|
+
record: { ...rec, anon_id, anon_id_created_at: now },
|
|
117
|
+
anon_id,
|
|
118
|
+
rotated: false,
|
|
119
|
+
created: true,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// rotated — present ID is too old (or its created_at is unparsable).
|
|
124
|
+
if (isExpired(rec.anon_id_created_at, now, maxAgeDays)) {
|
|
125
|
+
const anon_id = newAnonId();
|
|
126
|
+
return {
|
|
127
|
+
record: { ...rec, anon_id, anon_id_created_at: now },
|
|
128
|
+
anon_id,
|
|
129
|
+
rotated: true,
|
|
130
|
+
created: false,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// unchanged — present and fresh. Return a copy so the input stays untouched.
|
|
135
|
+
return {
|
|
136
|
+
record: { ...rec },
|
|
137
|
+
anon_id: currentId,
|
|
138
|
+
rotated: false,
|
|
139
|
+
created: false,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* consent.mjs — anonymous-usage-telemetry consent layer (Epic #841, S1 / GL #842).
|
|
3
|
+
*
|
|
4
|
+
* Owns the persisted consent record (`~/.config/session-orchestrator/telemetry.json`)
|
|
5
|
+
* and the pure `resolveConsent()` precedence machine that decides — from env vars,
|
|
6
|
+
* the host-local owner.yaml fleet flag, and the stored per-user decision — whether
|
|
7
|
+
* telemetry may be SENT at all.
|
|
8
|
+
*
|
|
9
|
+
* ── Fail-closed by design (Learning conf 0.9) ────────────────────────────────
|
|
10
|
+
* The `send` bit is true ONLY when an explicitly affirmative signal is present
|
|
11
|
+
* (`SO_TELEMETRY=1`, `owner.yaml telemetry.enabled === true`, or a stored
|
|
12
|
+
* `consent: 'granted'`). Every ambiguous, missing, or corrupt state resolves to
|
|
13
|
+
* `send: false`. "Not explicitly disabled" is NEVER treated as consent.
|
|
14
|
+
*
|
|
15
|
+
* ── Precedence (highest wins) ────────────────────────────────────────────────
|
|
16
|
+
* 1. DO_NOT_TRACK (set, non-empty, not '0'/'false') → disabled-env
|
|
17
|
+
* 2. SO_TELEMETRY_DISABLED === '1' → disabled-env
|
|
18
|
+
* 3. SO_TELEMETRY === '1' → enabled-env
|
|
19
|
+
* 4. ownerConfig.telemetry.enabled === true (strict) → enabled-fleet
|
|
20
|
+
* 5. state.consent === 'granted' → enabled-consent
|
|
21
|
+
* 6. state.consent === 'denied' → disabled-consent
|
|
22
|
+
* 7. (otherwise) → no-consent
|
|
23
|
+
*
|
|
24
|
+
* The env pair (1/2) is the per-shell escape hatch that outranks the fleet flag
|
|
25
|
+
* (PRD AC FA5): a fleet-opted host still honours `SO_TELEMETRY_DISABLED=1` /
|
|
26
|
+
* `DO_NOT_TRACK` for a single shell. The fleet flag (4) intentionally outranks a
|
|
27
|
+
* stored `denied` (6) — owner.yaml is the operator's host-level decision.
|
|
28
|
+
*
|
|
29
|
+
* No `anon_id` is ever minted here — id generation is lazy and lives in a sibling
|
|
30
|
+
* module; this layer only preserves the field across read/modify/write.
|
|
31
|
+
*
|
|
32
|
+
* Node ESM, no external deps beyond `scripts/lib/io.mjs` (atomic write).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { readFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
36
|
+
import { dirname } from 'node:path';
|
|
37
|
+
|
|
38
|
+
import { writeJsonAtomicSync } from '../io.mjs';
|
|
39
|
+
import { TELEMETRY_DIR, TELEMETRY_JSON_PATH, TELEMETRY_QUEUE_PATH } from './paths.mjs';
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Constants
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/** Schema version stamped into every telemetry.json record. */
|
|
46
|
+
export const CONSENT_SCHEMA_VERSION = 1;
|
|
47
|
+
|
|
48
|
+
// Path constants are single-sourced in ./paths.mjs — a constants-only leaf
|
|
49
|
+
// module that both consent.mjs (policy) and queue.mjs (storage) import, so
|
|
50
|
+
// neither depends on the other. Re-exported here for backward-compat: these
|
|
51
|
+
// were consent.mjs exports before the extraction.
|
|
52
|
+
export { TELEMETRY_DIR, TELEMETRY_JSON_PATH, TELEMETRY_QUEUE_PATH };
|
|
53
|
+
|
|
54
|
+
/** Consent enum stored in the record's `consent` field. */
|
|
55
|
+
const CONSENT_GRANTED = 'granted';
|
|
56
|
+
const CONSENT_DENIED = 'denied';
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Helpers
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
function isPlainObject(v) {
|
|
63
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A fresh default record: everything null, schema_version pinned. Returned when
|
|
68
|
+
* the file is missing or corrupt so callers never see partial/garbage state.
|
|
69
|
+
* @returns {{schema_version: number, consent: null, decided_at: null, anon_id: null, anon_id_created_at: null, last_flush_at: null}}
|
|
70
|
+
*/
|
|
71
|
+
function defaultRecord() {
|
|
72
|
+
return {
|
|
73
|
+
schema_version: CONSENT_SCHEMA_VERSION,
|
|
74
|
+
consent: null,
|
|
75
|
+
decided_at: null,
|
|
76
|
+
anon_id: null,
|
|
77
|
+
anon_id_created_at: null,
|
|
78
|
+
last_flush_at: null,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* True when an env var carries a truthy "on" signal: present, trims to a
|
|
84
|
+
* non-empty string that is neither '0' nor (case-insensitively) 'false'.
|
|
85
|
+
* @param {unknown} raw
|
|
86
|
+
* @returns {boolean}
|
|
87
|
+
*/
|
|
88
|
+
function isTruthyEnvFlag(raw) {
|
|
89
|
+
if (raw === undefined || raw === null) return false;
|
|
90
|
+
const t = String(raw).trim();
|
|
91
|
+
if (t === '' || t === '0') return false;
|
|
92
|
+
if (t.toLowerCase() === 'false') return false;
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Persistence
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Read and normalise the persisted consent record. NEVER throws.
|
|
102
|
+
*
|
|
103
|
+
* - Missing file → `{ source: 'default' }` + a fresh default record.
|
|
104
|
+
* - Unparseable / non-object → `{ source: 'corrupt' }` + default record + a
|
|
105
|
+
* stderr WARN pointing at `telemetry status`; the errors array names the fault.
|
|
106
|
+
* - Valid object → `{ source: 'file' }`; missing known fields are
|
|
107
|
+
* filled from defaults and UNKNOWN fields are preserved (additive tolerance).
|
|
108
|
+
*
|
|
109
|
+
* @param {object} [opts]
|
|
110
|
+
* @param {string} [opts.path] Override the read path (test injection).
|
|
111
|
+
* @returns {{ record: object, source: 'file'|'default'|'corrupt', errors: string[] }}
|
|
112
|
+
*/
|
|
113
|
+
export function readTelemetryState({ path } = {}) {
|
|
114
|
+
const target = path || TELEMETRY_JSON_PATH;
|
|
115
|
+
|
|
116
|
+
if (!existsSync(target)) {
|
|
117
|
+
return { record: defaultRecord(), source: 'default', errors: [] };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let raw;
|
|
121
|
+
try {
|
|
122
|
+
raw = readFileSync(target, 'utf8');
|
|
123
|
+
} catch (err) {
|
|
124
|
+
const msg = `telemetry.json unreadable: ${err?.message ?? String(err)}`;
|
|
125
|
+
console.error(`⚠ telemetry: ${msg}. Using defaults — run 'telemetry status' to inspect.`);
|
|
126
|
+
return { record: defaultRecord(), source: 'corrupt', errors: [msg] };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let parsed;
|
|
130
|
+
try {
|
|
131
|
+
parsed = JSON.parse(raw);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
const msg = `telemetry.json is not valid JSON: ${err?.message ?? String(err)}`;
|
|
134
|
+
console.error(`⚠ telemetry: ${msg}. Using defaults — run 'telemetry status' to inspect.`);
|
|
135
|
+
return { record: defaultRecord(), source: 'corrupt', errors: [msg] };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!isPlainObject(parsed)) {
|
|
139
|
+
const msg = `telemetry.json is not an object (got ${Array.isArray(parsed) ? 'array' : typeof parsed})`;
|
|
140
|
+
console.error(`⚠ telemetry: ${msg}. Using defaults — run 'telemetry status' to inspect.`);
|
|
141
|
+
return { record: defaultRecord(), source: 'corrupt', errors: [msg] };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Additive tolerance: defaults fill missing known fields, `parsed` overrides
|
|
145
|
+
// and carries any unknown fields through untouched.
|
|
146
|
+
const record = { ...defaultRecord(), ...parsed };
|
|
147
|
+
return { record, source: 'file', errors: [] };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Atomically persist a consent record via {@link writeJsonAtomicSync}. Creates
|
|
152
|
+
* the parent directory first. NEVER throws — filesystem failures are returned.
|
|
153
|
+
*
|
|
154
|
+
* @param {object} record The full record to persist.
|
|
155
|
+
* @param {object} [opts]
|
|
156
|
+
* @param {string} [opts.path] Override the write path (test injection).
|
|
157
|
+
* @returns {{ ok: boolean, error?: string }}
|
|
158
|
+
*/
|
|
159
|
+
export function writeTelemetryState(record, { path } = {}) {
|
|
160
|
+
const target = path || TELEMETRY_JSON_PATH;
|
|
161
|
+
try {
|
|
162
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return { ok: false, error: err?.message ?? String(err) };
|
|
165
|
+
}
|
|
166
|
+
const res = writeJsonAtomicSync(target, record);
|
|
167
|
+
return res.ok ? { ok: true } : { ok: false, error: res.error };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Consent decision
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @typedef {'disabled-env'|'disabled-consent'|'enabled-env'|'enabled-fleet'|'enabled-consent'|'no-consent'} ConsentState
|
|
176
|
+
*/
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Resolve the effective telemetry posture from all signals. Pure — no I/O.
|
|
180
|
+
* Fail-closed: `send` is true ONLY for the three `enabled-*` states.
|
|
181
|
+
*
|
|
182
|
+
* @param {object} [opts]
|
|
183
|
+
* @param {Record<string, string|undefined>} [opts.env] Env source (default process.env).
|
|
184
|
+
* @param {object} [opts.ownerConfig] Parsed owner.yaml object (fleet flag lives at `.telemetry.enabled`).
|
|
185
|
+
* @param {object|null} [opts.state] A persisted record (from {@link readTelemetryState}).
|
|
186
|
+
* @param {boolean} [opts.interactive] Whether a TTY prompt is possible right now.
|
|
187
|
+
* @returns {{ state: ConsentState, send: boolean, prompt: boolean, reason: string }}
|
|
188
|
+
*/
|
|
189
|
+
export function resolveConsent({ env = process.env, ownerConfig = {}, state = null, interactive = false } = {}) {
|
|
190
|
+
// 1. DO_NOT_TRACK — universal opt-out, per-shell escape above everything.
|
|
191
|
+
if (isTruthyEnvFlag(env?.DO_NOT_TRACK)) {
|
|
192
|
+
return { state: 'disabled-env', send: false, prompt: false, reason: 'DO_NOT_TRACK is set' };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 2. SO_TELEMETRY_DISABLED=1 — per-shell escape, outranks SO_TELEMETRY and fleet.
|
|
196
|
+
if (env?.SO_TELEMETRY_DISABLED === '1') {
|
|
197
|
+
return { state: 'disabled-env', send: false, prompt: false, reason: 'SO_TELEMETRY_DISABLED=1' };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 3. SO_TELEMETRY=1 — explicit per-shell opt-in.
|
|
201
|
+
if (env?.SO_TELEMETRY === '1') {
|
|
202
|
+
return { state: 'enabled-env', send: true, prompt: false, reason: 'SO_TELEMETRY=1' };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 4. Fleet flag — owner.yaml telemetry.enabled must be STRICTLY boolean true.
|
|
206
|
+
if (ownerConfig?.telemetry?.enabled === true) {
|
|
207
|
+
return { state: 'enabled-fleet', send: true, prompt: false, reason: 'owner.yaml telemetry.enabled=true' };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 5/6. Stored per-user decision.
|
|
211
|
+
if (state?.consent === CONSENT_GRANTED) {
|
|
212
|
+
return { state: 'enabled-consent', send: true, prompt: false, reason: 'stored consent: granted' };
|
|
213
|
+
}
|
|
214
|
+
if (state?.consent === CONSENT_DENIED) {
|
|
215
|
+
return { state: 'disabled-consent', send: false, prompt: false, reason: 'stored consent: denied' };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 7. No decision on record — only prompt when a TTY is available; never send.
|
|
219
|
+
return {
|
|
220
|
+
state: 'no-consent',
|
|
221
|
+
send: false,
|
|
222
|
+
prompt: interactive === true,
|
|
223
|
+
reason: 'no consent decision recorded',
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Read-modify-atomic-write helper shared by grant/deny. Preserves the anon_id
|
|
229
|
+
* fields and any unknown fields already on the record; starts from a clean
|
|
230
|
+
* default record when the on-disk file is missing or corrupt (no garbage merge).
|
|
231
|
+
*
|
|
232
|
+
* @param {'granted'|'denied'} decision
|
|
233
|
+
* @param {{ path?: string, now: string }} args
|
|
234
|
+
* @returns {{ ok: boolean, record: object }}
|
|
235
|
+
*/
|
|
236
|
+
function setConsentDecision(decision, { path, now }) {
|
|
237
|
+
const { record } = readTelemetryState({ path });
|
|
238
|
+
const next = { ...record, consent: decision, decided_at: now };
|
|
239
|
+
const res = writeTelemetryState(next, { path });
|
|
240
|
+
return { ok: res.ok, record: next };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Record an affirmative consent decision. anon_id fields are left untouched.
|
|
245
|
+
*
|
|
246
|
+
* @param {object} [opts]
|
|
247
|
+
* @param {string} [opts.path] Override the state path (test injection).
|
|
248
|
+
* @param {string} [opts.now] ISO timestamp for `decided_at` (defaults to now).
|
|
249
|
+
* @returns {{ ok: boolean, record: object }}
|
|
250
|
+
*/
|
|
251
|
+
export function grantConsent({ path, now = new Date().toISOString() } = {}) {
|
|
252
|
+
return setConsentDecision(CONSENT_GRANTED, { path, now });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Record a refusal decision. anon_id fields are left untouched.
|
|
257
|
+
*
|
|
258
|
+
* @param {object} [opts]
|
|
259
|
+
* @param {string} [opts.path] Override the state path (test injection).
|
|
260
|
+
* @param {string} [opts.now] ISO timestamp for `decided_at` (defaults to now).
|
|
261
|
+
* @returns {{ ok: boolean, record: object }}
|
|
262
|
+
*/
|
|
263
|
+
export function denyConsent({ path, now = new Date().toISOString() } = {}) {
|
|
264
|
+
return setConsentDecision(CONSENT_DENIED, { path, now });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// Environment probes
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* True when running in a recognised CI environment: `CI` set to a truthy value,
|
|
273
|
+
* or any of the well-known CI marker vars present and non-empty.
|
|
274
|
+
*
|
|
275
|
+
* @param {Record<string, string|undefined>} [env] Env source (default process.env).
|
|
276
|
+
* @returns {boolean}
|
|
277
|
+
*/
|
|
278
|
+
export function isCiEnv(env = process.env) {
|
|
279
|
+
if (isTruthyEnvFlag(env?.CI)) return true;
|
|
280
|
+
for (const key of ['GITHUB_ACTIONS', 'GITLAB_CI', 'CONTINUOUS_INTEGRATION']) {
|
|
281
|
+
const v = env?.[key];
|
|
282
|
+
if (v !== undefined && v !== null && String(v).trim() !== '') return true;
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* True when there is no interactive TTY to prompt on: any CI environment, or a
|
|
289
|
+
* stdout that is not a TTY. Fail-closed toward headless — anything that is not a
|
|
290
|
+
* confirmed interactive TTY counts as headless.
|
|
291
|
+
*
|
|
292
|
+
* @param {Record<string, string|undefined>} [env] Env source (default process.env).
|
|
293
|
+
* @param {{ stdout?: { isTTY?: boolean } }} [streams] Stream source (default process).
|
|
294
|
+
* @returns {boolean}
|
|
295
|
+
*/
|
|
296
|
+
export function isHeadless(env = process.env, streams = process) {
|
|
297
|
+
if (isCiEnv(env)) return true;
|
|
298
|
+
return streams?.stdout?.isTTY !== true;
|
|
299
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* telemetry/paths.mjs — host-local telemetry paths (Epic #841).
|
|
3
|
+
*
|
|
4
|
+
* Host-local telemetry paths — constants only, imported by both consent.mjs
|
|
5
|
+
* (policy) and queue.mjs (storage) to avoid a wrong-direction dependency.
|
|
6
|
+
*
|
|
7
|
+
* This module holds NO logic and performs NO I/O beyond computing three path
|
|
8
|
+
* constants under `~/.config/session-orchestrator/` at import time. Because it
|
|
9
|
+
* depends on nothing else in the telemetry tree, both the policy layer
|
|
10
|
+
* (consent.mjs) and the storage layer (queue.mjs) single-source their paths
|
|
11
|
+
* here without either importing the other. The earlier queue.mjs → consent.mjs
|
|
12
|
+
* edge was a wrong-direction dependency — the generic offline queue must not
|
|
13
|
+
* hang off the telemetry consent policy; routing both through this leaf module
|
|
14
|
+
* removes that coupling while keeping a single source of truth for the paths.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
|
|
20
|
+
/** Host-local config directory holding all telemetry state. */
|
|
21
|
+
export const TELEMETRY_DIR = join(homedir(), '.config', 'session-orchestrator');
|
|
22
|
+
|
|
23
|
+
/** Default path for the persisted consent record. */
|
|
24
|
+
export const TELEMETRY_JSON_PATH = join(TELEMETRY_DIR, 'telemetry.json');
|
|
25
|
+
|
|
26
|
+
/** Default path for the pending-events send queue (owned by queue.mjs). */
|
|
27
|
+
export const TELEMETRY_QUEUE_PATH = join(TELEMETRY_DIR, 'telemetry-queue.ndjson');
|