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
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Telemetry
|
|
2
|
+
|
|
3
|
+
Session Orchestrator ships an **optional, strictly opt-in** anonymous
|
|
4
|
+
usage-telemetry client. This page is the transparency contract: exactly what
|
|
5
|
+
is collected, what is never collected, how consent works, every kill switch,
|
|
6
|
+
where the data goes, and how long it is kept. Nothing here is aspirational —
|
|
7
|
+
it is the locked v1 schema and consent precedence this plugin implements.
|
|
8
|
+
|
|
9
|
+
## TL;DR
|
|
10
|
+
|
|
11
|
+
- **Off by default.** Nothing is sent until you explicitly consent.
|
|
12
|
+
- **One prompt, ever.** An interactive session asks once, at most; the
|
|
13
|
+
answer is saved locally and never asked again (until you reset it).
|
|
14
|
+
- **Trivial to turn off**, at any time, with an environment variable or a
|
|
15
|
+
one-line CLI command — no restart, no config-file archaeology.
|
|
16
|
+
- **No CI/headless sends, ever.** Non-interactive sessions never prompt and
|
|
17
|
+
never send, regardless of prior consent state.
|
|
18
|
+
- **Fully open source.** The client code and the ingest-server code both
|
|
19
|
+
live in this repository — nothing is a black box.
|
|
20
|
+
- **Not the same thing as the local metrics used in marketing claims.** See
|
|
21
|
+
[Relationship to `telemetry-claims.md`](#relationship-to-telemetry-claimsmd)
|
|
22
|
+
below.
|
|
23
|
+
|
|
24
|
+
## What we collect
|
|
25
|
+
|
|
26
|
+
When telemetry is enabled and a batch is flushed, the payload is built from
|
|
27
|
+
a strict field whitelist — nothing outside this list is ever included, and a
|
|
28
|
+
projection unit test enforces the drop of any non-whitelisted input field.
|
|
29
|
+
|
|
30
|
+
| Field | Meaning |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `record_kind` | Always `"usage-ping"` for this record type. |
|
|
33
|
+
| `schema_version` | Currently `1`. Additive-only evolution within a version — see [Schema evolution](#schema-evolution). |
|
|
34
|
+
| `anon_id` | A random UUID, not derived from any machine identifier. Rotates every 90 days; the old ID is discarded, not linked to the new one. |
|
|
35
|
+
| `sent_at` | Timestamp of the flush. |
|
|
36
|
+
| `plugin_version` | The installed plugin's semver. |
|
|
37
|
+
| `platform` | One of `claude`, `codex`, `cursor`, `pi`, `other`. |
|
|
38
|
+
| `os` | Operating system family (e.g. `darwin`, `linux`, `win32`). |
|
|
39
|
+
| `arch` | CPU architecture (e.g. `arm64`, `x64`). |
|
|
40
|
+
| `node_major` | Major Node.js version in use. |
|
|
41
|
+
| `ci` | Boolean — whether the run was detected as a CI environment. |
|
|
42
|
+
| `fleet` | Boolean — whether this send came from an operator's own fleet-mode host (`owner.yaml` opt-in), as opposed to an external install. |
|
|
43
|
+
| `session_type` | One of `housekeeping`, `feature`, `deep`, `other`. |
|
|
44
|
+
| `duration_bucket` | One of `<15m`, `15-60m`, `1-3h`, `>3h` — a coarse bucket, never an exact duration. |
|
|
45
|
+
| `skills[]` | Names of invoked skills, filtered against the shipped plugin roster — any name not in that roster becomes `"other"`. |
|
|
46
|
+
| `commands[]` | Same filtering rule as `skills[]`. |
|
|
47
|
+
|
|
48
|
+
## What we never collect
|
|
49
|
+
|
|
50
|
+
This list is a hard invariant, not a deferral:
|
|
51
|
+
|
|
52
|
+
- No repository names, no file paths, no git remotes.
|
|
53
|
+
- No prompts, no session transcripts, no free-form text of any kind.
|
|
54
|
+
- No command arguments — only whitelisted command/skill *names*, and only
|
|
55
|
+
from the shipped roster (anything else is reduced to `"other"`).
|
|
56
|
+
- No hostnames.
|
|
57
|
+
- No IP addresses stored. The ingest server uses the requester's IP
|
|
58
|
+
**transiently, in memory, only** to enforce a per-IP rate limit — it is
|
|
59
|
+
never written to disk, and access logging is disabled on the telemetry
|
|
60
|
+
vhost.
|
|
61
|
+
- No email addresses, no git author identity, no account identifiers.
|
|
62
|
+
|
|
63
|
+
If a skill or command name isn't part of the plugin's own shipped roster —
|
|
64
|
+
including any custom or third-party skill you've added locally — it never
|
|
65
|
+
leaves your machine; it is projected to `"other"` before the payload is
|
|
66
|
+
built.
|
|
67
|
+
|
|
68
|
+
## Consent & kill switches
|
|
69
|
+
|
|
70
|
+
Precedence, highest wins:
|
|
71
|
+
|
|
72
|
+
1. **`DO_NOT_TRACK`** — any non-empty value except `0`/`false` disables
|
|
73
|
+
telemetry unconditionally. This is the industry-standard signal and
|
|
74
|
+
overrides everything else, including a fleet force-enable.
|
|
75
|
+
2. **`SO_TELEMETRY_DISABLED=1`** — explicit per-shell disable.
|
|
76
|
+
3. **`SO_TELEMETRY=1`** — explicit per-shell force-enable (used for fleet
|
|
77
|
+
testing without touching the consent file).
|
|
78
|
+
4. **`owner.yaml` `telemetry.enabled`** — host-local fleet-mode opt-in (see
|
|
79
|
+
below); has no effect on a machine without that file.
|
|
80
|
+
5. **Saved consent** — `~/.config/session-orchestrator/telemetry.json`,
|
|
81
|
+
written the first time you answer the consent prompt. Never inside any
|
|
82
|
+
repository, never committed.
|
|
83
|
+
6. **First-run prompt** — shown at most once, **interactively only**. A
|
|
84
|
+
headless or CI invocation never shows this prompt and never sends
|
|
85
|
+
telemetry, regardless of any saved state.
|
|
86
|
+
|
|
87
|
+
If the consent file is corrupt or unreadable, the client fails **closed**:
|
|
88
|
+
telemetry state degrades to "no consent" (nothing sent) rather than
|
|
89
|
+
guessing, with a one-line stderr hint pointing at the CLI below.
|
|
90
|
+
|
|
91
|
+
**CLI:**
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
node scripts/telemetry.mjs status # show current consent + kill-switch state
|
|
95
|
+
node scripts/telemetry.mjs enable # opt in
|
|
96
|
+
node scripts/telemetry.mjs disable # opt out
|
|
97
|
+
node scripts/telemetry.mjs show # print the last built payload, don't send
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Debug flag:** set `SO_TELEMETRY_DEBUG=1` to print the exact payload that
|
|
101
|
+
*would* be sent to stderr instead of sending it — useful for verifying the
|
|
102
|
+
whitelist projection yourself before ever trusting it.
|
|
103
|
+
|
|
104
|
+
**Fleet mode.** An operator running many repos on one host can set
|
|
105
|
+
`telemetry.enabled: true` (and optionally `telemetry.fleet: true`) in their
|
|
106
|
+
own `owner.yaml` — a host-local, never-committed file outside every repo —
|
|
107
|
+
to enable telemetry across all adopted repos without a per-repo prompt.
|
|
108
|
+
Records sent this way carry `fleet: true`. `DO_NOT_TRACK` and
|
|
109
|
+
`SO_TELEMETRY_DISABLED=1` still win over fleet mode in the same shell.
|
|
110
|
+
|
|
111
|
+
## Where it goes
|
|
112
|
+
|
|
113
|
+
Consented payloads are sent as a batched `POST` to:
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
https://telemetry.session-orchestrator.com/v1/records
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
This endpoint is operated by the plugin's maintainer. The server-side code
|
|
120
|
+
is open source in this same repository, under `server/ingest/` — a
|
|
121
|
+
dependency-light Node service that validates the payload against the
|
|
122
|
+
per-`record_kind` schema, stores it in SQLite, and rejects anything that
|
|
123
|
+
doesn't fit the schema (unknown `record_kind`, oversized body, or a schema
|
|
124
|
+
violation). There is no third-party analytics vendor in this path — no
|
|
125
|
+
Segment, no Mixpanel, no Google Analytics.
|
|
126
|
+
|
|
127
|
+
The send is fire-and-forget with a short timeout; if the endpoint is
|
|
128
|
+
unreachable, the batch queues locally (bounded size, oldest entries dropped
|
|
129
|
+
first) and retries later. Telemetry never blocks or slows down a session
|
|
130
|
+
beyond that short timeout budget.
|
|
131
|
+
|
|
132
|
+
## Retention
|
|
133
|
+
|
|
134
|
+
- **Raw records:** kept 24 months, then pruned. The retention window exists
|
|
135
|
+
to support year-over-year product decisions (what to deepen, what to
|
|
136
|
+
sunset) without keeping data indefinitely.
|
|
137
|
+
- **Aggregates:** kept indefinitely; aggregates carry no record-level
|
|
138
|
+
identifiers by construction.
|
|
139
|
+
- **Anonymous ID rotation:** every 90 days, independent of retention — a
|
|
140
|
+
rotated ID cannot be linked back to the one it replaced.
|
|
141
|
+
|
|
142
|
+
## Schema evolution
|
|
143
|
+
|
|
144
|
+
The schema is **additive-only** within a given `schema_version`: new
|
|
145
|
+
optional fields may appear, but no field is ever repurposed or removed
|
|
146
|
+
without a version bump. The server accepts both the current and the
|
|
147
|
+
immediately previous `schema_version`, so a slightly-outdated client is
|
|
148
|
+
never hard-broken by a server-side schema update.
|
|
149
|
+
|
|
150
|
+
## Relationship to `telemetry-claims.md`
|
|
151
|
+
|
|
152
|
+
This page describes the **opt-in, client-side usage-telemetry pipeline**
|
|
153
|
+
above. It is a distinct data flow from
|
|
154
|
+
[`docs/telemetry/telemetry-claims.md`](telemetry/telemetry-claims.md), which
|
|
155
|
+
documents the methodology behind the maintainer's separate **local, private**
|
|
156
|
+
metrics aggregates (`.orchestrator/metrics/*.jsonl`, gitignored, never
|
|
157
|
+
transmitted anywhere) used in marketing claims such as "645 orchestrated
|
|
158
|
+
sessions." Neither pipeline feeds the other.
|
package/hooks/hooks-codex.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"hooks": [
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
|
10
|
-
"command": "echo '🎯 Session Orchestrator v3.
|
|
10
|
+
"command": "echo '🎯 Session Orchestrator v3.17.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
|
|
11
11
|
"async": false
|
|
12
12
|
},
|
|
13
13
|
{
|
package/hooks/hooks.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
9
|
-
"command": "echo '🎯 Session Orchestrator v3.
|
|
9
|
+
"command": "echo '🎯 Session Orchestrator v3.17.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
|
|
10
10
|
"async": false
|
|
11
11
|
},
|
|
12
12
|
{
|
|
@@ -6,23 +6,35 @@
|
|
|
6
6
|
* Fires when the Skill tool is invoked (a skill is selected). Writes a
|
|
7
7
|
* selection record to `.orchestrator/metrics/skill-invocations.jsonl`.
|
|
8
8
|
*
|
|
9
|
-
* Decision flow:
|
|
9
|
+
* Decision flow (when run as the hook, not imported):
|
|
10
10
|
* 1. shouldRunHook gate — exit 0 immediately when the hook is disabled.
|
|
11
11
|
* 2. Read JSON payload from stdin: { tool_name, tool_input: { skill }, session_id }.
|
|
12
12
|
* 3. Belt-and-suspenders guard: if tool_name !== "Skill", exit 0 immediately.
|
|
13
13
|
* 4. Build a 'selected' record and call appendSkillInvocation().
|
|
14
|
-
* 5.
|
|
14
|
+
* 5. Daily-fallback telemetry flush check (Epic #841, #844) — non-blocking:
|
|
15
|
+
* when a bounded offline queue has aged past 24h AND consent resolves to
|
|
16
|
+
* send, spawn a detached child that runs `telemetry _flush`. Cheap by
|
|
17
|
+
* construction (env kill-switch pre-check → queue+state stat → consent)
|
|
18
|
+
* and never loads the roster on the hot hook path.
|
|
19
|
+
* 6. Output: nothing on stdout. Diagnostic errors to stderr only.
|
|
15
20
|
*
|
|
16
21
|
* Exit codes: 0 always (informational, never blocking).
|
|
22
|
+
*
|
|
23
|
+
* The module is import-safe: the self-execution block below is guarded so that a
|
|
24
|
+
* test can `import { maybeSpawnDailyFlush }` without triggering the hook's
|
|
25
|
+
* stdin-read + exit path.
|
|
17
26
|
*/
|
|
18
27
|
|
|
19
|
-
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
20
|
-
// Exit 0 immediately when disabled via SO_HOOK_PROFILE / SO_DISABLED_HOOKS.
|
|
21
|
-
if (!shouldRunHook('skill-invocation-telemetry')) process.exit(0);
|
|
22
|
-
|
|
23
28
|
import path from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
import { spawn } from 'node:child_process';
|
|
31
|
+
|
|
32
|
+
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
24
33
|
import { appendSkillInvocation } from '../scripts/lib/skill-invocations-schema.mjs';
|
|
25
34
|
import { SO_PROJECT_DIR } from '../scripts/lib/platform.mjs';
|
|
35
|
+
import { shouldDailyFlush } from '../scripts/lib/telemetry/sync.mjs';
|
|
36
|
+
import { resolveConsent, readTelemetryState } from '../scripts/lib/telemetry/consent.mjs';
|
|
37
|
+
import { loadOwnerConfig } from '../scripts/lib/owner-yaml.mjs';
|
|
26
38
|
|
|
27
39
|
// ---------------------------------------------------------------------------
|
|
28
40
|
// Constants
|
|
@@ -30,10 +42,29 @@ import { SO_PROJECT_DIR } from '../scripts/lib/platform.mjs';
|
|
|
30
42
|
|
|
31
43
|
const JSONL_PATH = path.join(SO_PROJECT_DIR, '.orchestrator', 'metrics', 'skill-invocations.jsonl');
|
|
32
44
|
|
|
45
|
+
/** Absolute path to the telemetry CLI (carries the hidden `_flush` subcommand). */
|
|
46
|
+
const TELEMETRY_CLI_PATH = path.resolve(
|
|
47
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
48
|
+
'../scripts/telemetry.mjs',
|
|
49
|
+
);
|
|
50
|
+
|
|
33
51
|
// ---------------------------------------------------------------------------
|
|
34
52
|
// Helpers
|
|
35
53
|
// ---------------------------------------------------------------------------
|
|
36
54
|
|
|
55
|
+
/**
|
|
56
|
+
* True when an env var carries a truthy "on" signal (present, non-empty, not
|
|
57
|
+
* '0'/'false'). Kept local so the hook does not pull anything extra from consent.mjs.
|
|
58
|
+
* @param {unknown} raw
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
function isTruthyEnvFlag(raw) {
|
|
62
|
+
if (raw === undefined || raw === null) return false;
|
|
63
|
+
const t = String(raw).trim();
|
|
64
|
+
if (t === '' || t === '0') return false;
|
|
65
|
+
return t.toLowerCase() !== 'false';
|
|
66
|
+
}
|
|
67
|
+
|
|
37
68
|
/**
|
|
38
69
|
* Read stdin to EOF (best-effort). Returns parsed JSON or null on failure.
|
|
39
70
|
* Uses a 5 s timeout consistent with Claude Code hook contract.
|
|
@@ -61,6 +92,61 @@ function readStdinJson() {
|
|
|
61
92
|
});
|
|
62
93
|
}
|
|
63
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Non-blocking daily-fallback flush trigger. Ordered cheapest-first:
|
|
97
|
+
* 1. env kill-switches (no I/O) — DO_NOT_TRACK / SO_TELEMETRY_DISABLED.
|
|
98
|
+
* 2. shouldDailyFlush (one telemetry.json read + one queue stat) — the common
|
|
99
|
+
* case (empty queue) returns here WITHOUT loading owner.yaml or the roster.
|
|
100
|
+
* 3. full consent resolution — spawn a detached `telemetry _flush` only when it
|
|
101
|
+
* resolves to send.
|
|
102
|
+
*
|
|
103
|
+
* Never throws; the caller's hook must always exit 0.
|
|
104
|
+
*
|
|
105
|
+
* @param {object} [opts]
|
|
106
|
+
* @param {NodeJS.ProcessEnv} [opts.env] Env source (default process.env).
|
|
107
|
+
* @param {typeof spawn} [opts.spawnFn] Spawn function (test injection).
|
|
108
|
+
* @param {number} [opts.now] Reference time epoch-ms (default Date.now()).
|
|
109
|
+
* @param {string} [opts.statePath] telemetry.json path override (test injection).
|
|
110
|
+
* @param {string} [opts.queuePath] queue path override (test injection).
|
|
111
|
+
* @returns {{ spawned: boolean, reason: string }}
|
|
112
|
+
*/
|
|
113
|
+
export function maybeSpawnDailyFlush({
|
|
114
|
+
env = process.env,
|
|
115
|
+
spawnFn = spawn,
|
|
116
|
+
now = Date.now(),
|
|
117
|
+
statePath,
|
|
118
|
+
queuePath,
|
|
119
|
+
} = {}) {
|
|
120
|
+
try {
|
|
121
|
+
// 1. Cheapest gate: env kill-switches, no file I/O.
|
|
122
|
+
if (isTruthyEnvFlag(env?.DO_NOT_TRACK) || env?.SO_TELEMETRY_DISABLED === '1') {
|
|
123
|
+
return { spawned: false, reason: 'disabled-env' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 2. Cheap backlog check — bails out before owner.yaml load in the common case.
|
|
127
|
+
if (!shouldDailyFlush({ statePath, queuePath, now })) {
|
|
128
|
+
return { spawned: false, reason: 'not-due' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 3. Authoritative consent gate.
|
|
132
|
+
const ownerConfig = loadOwnerConfig().config;
|
|
133
|
+
const { record } = readTelemetryState({ path: statePath });
|
|
134
|
+
const consent = resolveConsent({ env, ownerConfig, state: record, interactive: false });
|
|
135
|
+
if (consent.send !== true) {
|
|
136
|
+
return { spawned: false, reason: 'gated' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const child = spawnFn(process.execPath, [TELEMETRY_CLI_PATH, '_flush'], {
|
|
140
|
+
detached: true,
|
|
141
|
+
stdio: 'ignore',
|
|
142
|
+
});
|
|
143
|
+
if (child && typeof child.unref === 'function') child.unref();
|
|
144
|
+
return { spawned: true, reason: 'spawned' };
|
|
145
|
+
} catch {
|
|
146
|
+
return { spawned: false, reason: 'error' };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
64
150
|
// ---------------------------------------------------------------------------
|
|
65
151
|
// Main
|
|
66
152
|
// ---------------------------------------------------------------------------
|
|
@@ -91,9 +177,22 @@ async function main() {
|
|
|
91
177
|
};
|
|
92
178
|
|
|
93
179
|
await appendSkillInvocation(JSONL_PATH, record);
|
|
180
|
+
|
|
181
|
+
// Daily-fallback telemetry flush — non-blocking, best-effort, never throws.
|
|
182
|
+
maybeSpawnDailyFlush();
|
|
94
183
|
}
|
|
95
184
|
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Self-execution guard — run only when invoked directly (not when imported).
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
const isMain = process.argv[1] === fileURLToPath(import.meta.url);
|
|
190
|
+
if (isMain) {
|
|
191
|
+
// Exit 0 immediately when disabled via SO_HOOK_PROFILE / SO_DISABLED_HOOKS.
|
|
192
|
+
if (!shouldRunHook('skill-invocation-telemetry')) process.exit(0);
|
|
193
|
+
|
|
194
|
+
// Exit 0 always — informational hook must never block the Skill tool.
|
|
195
|
+
main().catch((err) => {
|
|
196
|
+
process.stderr.write(`[skill-invocation-telemetry] ERROR: ${err?.message ?? err}\n`);
|
|
197
|
+
}).finally(() => process.exit(0));
|
|
198
|
+
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "session-orchestrator",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.17.0",
|
|
4
4
|
"description": "Loop engineering for AI coding agents — turn ad-hoc sessions into a repeatable research → plan → wave-execute → close loop with verification gates. Runs on Claude Code, Codex CLI, Cursor, and Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://session-orchestrator.com",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"pi-package",
|
|
9
9
|
"claude-code",
|
|
10
|
+
"claude-code-plugin",
|
|
10
11
|
"codex",
|
|
11
12
|
"cursor",
|
|
12
13
|
"ai-agents",
|
|
13
|
-
"
|
|
14
|
+
"agentic-coding",
|
|
15
|
+
"subagents",
|
|
16
|
+
"orchestration",
|
|
17
|
+
"developer-tools",
|
|
18
|
+
"mcp",
|
|
19
|
+
"llm"
|
|
14
20
|
],
|
|
15
21
|
"engines": {
|
|
16
22
|
"node": ">=24.0.0"
|
|
@@ -33,6 +39,7 @@
|
|
|
33
39
|
".codex-plugin/",
|
|
34
40
|
".cursor/",
|
|
35
41
|
"docs/*.md",
|
|
42
|
+
"docs/telemetry/telemetry-claims.md",
|
|
36
43
|
"NOTICE",
|
|
37
44
|
"SECURITY.md",
|
|
38
45
|
"!**/node_modules/**",
|
|
@@ -80,6 +87,9 @@
|
|
|
80
87
|
"type": "git",
|
|
81
88
|
"url": "git+https://github.com/Kanevry/session-orchestrator.git"
|
|
82
89
|
},
|
|
90
|
+
"bugs": {
|
|
91
|
+
"url": "https://github.com/Kanevry/session-orchestrator/issues"
|
|
92
|
+
},
|
|
83
93
|
"pi": {
|
|
84
94
|
"extensions": [
|
|
85
95
|
"./pi/extensions/session-orchestrator.ts"
|
|
@@ -54,10 +54,25 @@ SESSION_ID="${SESSION_ID:-}"
|
|
|
54
54
|
WAVE="${WAVE:-0}"
|
|
55
55
|
AGENT_TYPE="${AGENT_TYPE:-}"
|
|
56
56
|
|
|
57
|
-
# --- Build list of last 3 session_ids from sessions.jsonl ---
|
|
58
|
-
|
|
57
|
+
# --- Build list of last 3 REAL session_ids from sessions.jsonl ---
|
|
58
|
+
|
|
59
|
+
# Filter out phantom `status: 'abandoned'` stubs (#834, session-close-backfill
|
|
60
|
+
# — 0 waves, seconds of runtime) BEFORE taking the tail. Otherwise a recent
|
|
61
|
+
# phantom can evict the one real session carrying the stagnation evidence,
|
|
62
|
+
# silently suppressing grounding injection for a genuinely stagnating file.
|
|
63
|
+
#
|
|
64
|
+
# `-R` (raw-input) + `fromjson?` parses each line individually and SKIPS
|
|
65
|
+
# unparseable ones instead of aborting the whole stream — plain
|
|
66
|
+
# `jq -c 'select(...)'` aborts at the FIRST malformed line (jq: parse error,
|
|
67
|
+
# exit 5). sessions.jsonl is append-only from multiple writers, so a torn
|
|
68
|
+
# write earlier in the file must not silently starve LAST_SESSIONS down to
|
|
69
|
+
# "[]" (fail-closed-empty is still wrong here, just a quieter wrong than the
|
|
70
|
+
# stale-session-id case this fixes) — the whole point is that corruption
|
|
71
|
+
# anywhere in the file no longer poisons the tail. Mirrors the per-line
|
|
72
|
+
# try/catch behaviour of the .mjs path (scripts/lib/session-schema/filters.mjs).
|
|
59
73
|
# Extract session_id values (skip entries without one, skip empty lines)
|
|
60
|
-
LAST_SESSIONS=$(
|
|
74
|
+
LAST_SESSIONS=$(jq -R -c 'fromjson? | select(.status != "abandoned")' "$SESSIONS_JSONL" 2>/dev/null \
|
|
75
|
+
| tail -n 3 \
|
|
61
76
|
| jq -r 'select(.session_id != null and .session_id != "") | .session_id' 2>/dev/null \
|
|
62
77
|
| jq -R -s 'split("\n") | map(select(length > 0))' 2>/dev/null) || LAST_SESSIONS="[]"
|
|
63
78
|
|
|
@@ -46,6 +46,7 @@ import { join } from 'node:path';
|
|
|
46
46
|
import { randomBytes } from 'node:crypto';
|
|
47
47
|
|
|
48
48
|
import { readPeerCards } from './lib/peer-cards/reader.mjs';
|
|
49
|
+
import { filterRealSessions } from './lib/session-schema/filters.mjs';
|
|
49
50
|
|
|
50
51
|
// ---------------------------------------------------------------------------
|
|
51
52
|
// Constants
|
|
@@ -392,7 +393,11 @@ async function readTopLearnings(repoRoot, topN) {
|
|
|
392
393
|
}
|
|
393
394
|
|
|
394
395
|
/**
|
|
395
|
-
* Read and rank sessions:
|
|
396
|
+
* Read and rank sessions: filter out phantom `status: 'abandoned'` stubs
|
|
397
|
+
* (#834, session-close-backfill), sort by `completed_at` DESC, take last K.
|
|
398
|
+
*
|
|
399
|
+
* Without the filter, phantoms (0 waves, seconds of runtime) can displace
|
|
400
|
+
* REAL session context out of the K-window the derivation prompt sees.
|
|
396
401
|
*
|
|
397
402
|
* @param {string} repoRoot
|
|
398
403
|
* @param {number} lastK
|
|
@@ -400,7 +405,7 @@ async function readTopLearnings(repoRoot, topN) {
|
|
|
400
405
|
*/
|
|
401
406
|
async function readLastSessions(repoRoot, lastK) {
|
|
402
407
|
const path = join(repoRoot, '.orchestrator', 'metrics', 'sessions.jsonl');
|
|
403
|
-
const entries = await readJsonlBestEffort(path);
|
|
408
|
+
const entries = filterRealSessions(await readJsonlBestEffort(path));
|
|
404
409
|
entries.sort((a, b) => {
|
|
405
410
|
const ta = typeof a?.completed_at === 'string' ? a.completed_at : '';
|
|
406
411
|
const tb = typeof b?.completed_at === 'string' ? b.completed_at : '';
|
|
@@ -22,6 +22,8 @@ import { existsSync } from 'node:fs';
|
|
|
22
22
|
import { randomUUID } from 'node:crypto';
|
|
23
23
|
import path from 'node:path';
|
|
24
24
|
|
|
25
|
+
import { filterRealSessions } from './session-schema.mjs';
|
|
26
|
+
|
|
25
27
|
// ---------------------------------------------------------------------------
|
|
26
28
|
// Constants
|
|
27
29
|
// ---------------------------------------------------------------------------
|
|
@@ -121,13 +123,20 @@ export async function readDialecticSignals({ repoRoot } = {}) {
|
|
|
121
123
|
try {
|
|
122
124
|
const raw = await readFile(sessionsPath, 'utf8');
|
|
123
125
|
const lines = raw.split('\n').filter((l) => l.length > 0);
|
|
126
|
+
const entries = [];
|
|
124
127
|
for (const line of lines) {
|
|
125
|
-
let entry;
|
|
126
128
|
try {
|
|
127
|
-
|
|
129
|
+
entries.push(JSON.parse(line));
|
|
128
130
|
} catch {
|
|
129
131
|
continue; // malformed line — skip silently
|
|
130
132
|
}
|
|
133
|
+
}
|
|
134
|
+
// Abandoned-session filter (#834): mirrors auto-dream.mjs
|
|
135
|
+
// readDreamSignals() — phantom `status: 'abandoned'` stubs are
|
|
136
|
+
// legitimate DATA but not legitimate SIGNAL, and must not fire the
|
|
137
|
+
// /evolve --dialectic cadence off zero real work.
|
|
138
|
+
const realEntries = filterRealSessions(entries);
|
|
139
|
+
for (const entry of realEntries) {
|
|
131
140
|
const startedAt = entry.started_at;
|
|
132
141
|
if (typeof startedAt !== 'string' || startedAt.length === 0) continue;
|
|
133
142
|
if (lastRunAt === null || startedAt > lastRunAt) {
|
|
@@ -25,6 +25,8 @@ import { existsSync, statSync } from 'node:fs';
|
|
|
25
25
|
import { randomUUID } from 'node:crypto';
|
|
26
26
|
import path from 'node:path';
|
|
27
27
|
|
|
28
|
+
import { filterRealSessions } from './session-schema.mjs';
|
|
29
|
+
|
|
28
30
|
// ---------------------------------------------------------------------------
|
|
29
31
|
// Signal reader — MEMORY.md size + sessions-since-last-cleanup
|
|
30
32
|
// ---------------------------------------------------------------------------
|
|
@@ -74,8 +76,16 @@ export async function readDreamSignals({ repoRoot, memoryDir }) {
|
|
|
74
76
|
}
|
|
75
77
|
}
|
|
76
78
|
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
+
// Abandoned-session filter (#834): sessions.jsonl carries phantom
|
|
80
|
+
// `status: 'abandoned'` stubs (session-close-backfill records for
|
|
81
|
+
// sessions that ended without a real close). They are legitimate DATA
|
|
82
|
+
// but not legitimate SIGNAL — a burst of abandoned stubs must not fire
|
|
83
|
+
// /memory-cleanup off zero real work. filterRealSessions() is the
|
|
84
|
+
// shared, tested implementation (scripts/lib/session-schema/filters.mjs).
|
|
85
|
+
const realEntries = filterRealSessions(entries);
|
|
86
|
+
|
|
87
|
+
// Find the most recent memory_cleanup_at timestamp across all REAL entries.
|
|
88
|
+
for (const entry of realEntries) {
|
|
79
89
|
const ts = entry.memory_cleanup_at;
|
|
80
90
|
if (typeof ts === 'string' && ts.length > 0) {
|
|
81
91
|
if (lastCleanupAt === null || ts > lastCleanupAt) {
|
|
@@ -84,11 +94,12 @@ export async function readDreamSignals({ repoRoot, memoryDir }) {
|
|
|
84
94
|
}
|
|
85
95
|
}
|
|
86
96
|
|
|
87
|
-
// Count entries newer than the last cleanup (or total
|
|
97
|
+
// Count REAL entries newer than the last cleanup (or total REAL entries
|
|
98
|
+
// when no cleanup ever ran).
|
|
88
99
|
if (lastCleanupAt === null) {
|
|
89
|
-
sessionsSinceCleanup =
|
|
100
|
+
sessionsSinceCleanup = realEntries.length;
|
|
90
101
|
} else {
|
|
91
|
-
for (const entry of
|
|
102
|
+
for (const entry of realEntries) {
|
|
92
103
|
const startedAt = entry.started_at;
|
|
93
104
|
if (typeof startedAt === 'string' && startedAt > lastCleanupAt) {
|
|
94
105
|
sessionsSinceCleanup += 1;
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { existsSync, readFileSync } from 'node:fs';
|
|
17
17
|
import { resolve } from 'node:path';
|
|
18
18
|
import { parseStateMd, parseRecommendations } from './state-md.mjs';
|
|
19
|
-
import { normalizeSession } from './session-schema.mjs';
|
|
19
|
+
import { normalizeSession, tailRealSessions } from './session-schema.mjs';
|
|
20
20
|
import { parseBootstrapLock } from './bootstrap-lock-freshness.mjs';
|
|
21
21
|
import { scanBacklog } from './backlog-scan.mjs';
|
|
22
22
|
|
|
@@ -98,9 +98,12 @@ export async function buildLiveSignals(opts = {}) {
|
|
|
98
98
|
.split('\n')
|
|
99
99
|
.map((l) => l.trim())
|
|
100
100
|
.filter((l) => l.length > 0);
|
|
101
|
-
|
|
101
|
+
// #834: parse ALL lines (not just the naive last-N) before windowing —
|
|
102
|
+
// `status: 'abandoned'` phantom stubs must be filtered out BEFORE the
|
|
103
|
+
// tail is taken, or sessionTailN silently means "last N LINES" instead
|
|
104
|
+
// of "last N REAL sessions". tailRealSessions() does the filter + tail.
|
|
102
105
|
const parsed = [];
|
|
103
|
-
for (const line of
|
|
106
|
+
for (const line of lines) {
|
|
104
107
|
try {
|
|
105
108
|
const obj = JSON.parse(line);
|
|
106
109
|
parsed.push(normalizeSession(obj));
|
|
@@ -108,7 +111,7 @@ export async function buildLiveSignals(opts = {}) {
|
|
|
108
111
|
// Branch 4: skip malformed lines silently
|
|
109
112
|
}
|
|
110
113
|
}
|
|
111
|
-
recentSessions = parsed;
|
|
114
|
+
recentSessions = tailRealSessions(parsed, sessionTailN);
|
|
112
115
|
}
|
|
113
116
|
} catch {
|
|
114
117
|
// Branch 3: file unreadable — recentSessions stays []
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { matchBlockHeader } from './block-header.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* context-coverage.mjs — Parser for the top-level `context-coverage:` YAML block.
|
|
5
|
+
*
|
|
6
|
+
* Config block shape (see docs/session-config-template.md):
|
|
7
|
+
* context-coverage:
|
|
8
|
+
* enabled: false
|
|
9
|
+
* mode: warn
|
|
10
|
+
*
|
|
11
|
+
* Modelled on `./docs-staleness.mjs`'s parser design (issue #781, Epic #774) —
|
|
12
|
+
* the block is scoped from the raw file content, independent of the
|
|
13
|
+
* `## Session Config` section boundary, so a baseline using either the plain
|
|
14
|
+
* `key:` header or the bold-bullet markdown rendering
|
|
15
|
+
* (`- **context-coverage:**`, tolerated via `matchBlockHeader()`) parses
|
|
16
|
+
* identically.
|
|
17
|
+
*
|
|
18
|
+
* ZERO imports other than `./block-header.mjs` — `tests/lib/config/cycle-guard.test.mjs`
|
|
19
|
+
* forbids importing `../config.mjs` from this directory; this module keeps a
|
|
20
|
+
* clean leaf with no other dependencies at all.
|
|
21
|
+
*
|
|
22
|
+
* Not registered in `scripts/lib/config.mjs` here — the coordinator wires
|
|
23
|
+
* `_parseContextCoverage` into the orchestrator's config-object assembly
|
|
24
|
+
* separately (see `context-coverage-banner.mjs`'s header for the exact
|
|
25
|
+
* import + call lines to add there).
|
|
26
|
+
*
|
|
27
|
+
* Shipped default: `{ enabled: false, mode: 'warn' }` — opt-in (issue #831).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse the top-level `context-coverage:` YAML block from markdown content.
|
|
32
|
+
* Defaults: enabled=false, mode="warn". Malformed values fall back per key.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} content — full file contents
|
|
35
|
+
* @returns {{enabled: boolean, mode: string}}
|
|
36
|
+
*/
|
|
37
|
+
export function _parseContextCoverage(content) {
|
|
38
|
+
const defaults = { enabled: false, mode: 'warn' };
|
|
39
|
+
|
|
40
|
+
const lines = content.split(/\r?\n/);
|
|
41
|
+
let inBlock = false;
|
|
42
|
+
const blockLines = [];
|
|
43
|
+
|
|
44
|
+
for (const rawLine of lines) {
|
|
45
|
+
const line = rawLine.replace(/\r$/, '');
|
|
46
|
+
if (!inBlock) {
|
|
47
|
+
if (matchBlockHeader(line, 'context-coverage')) inBlock = true;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (line.length > 0 && !/^\s/.test(line)) break;
|
|
51
|
+
blockLines.push(line);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (blockLines.length === 0) return defaults;
|
|
55
|
+
|
|
56
|
+
let ccEnabled = false;
|
|
57
|
+
let ccMode = 'warn';
|
|
58
|
+
|
|
59
|
+
for (const rawLine of blockLines) {
|
|
60
|
+
const clean = rawLine.replace(/\s*#.*$/, '').replace(/\s+$/, '');
|
|
61
|
+
if (!clean.trim()) continue;
|
|
62
|
+
|
|
63
|
+
const kvMatch = clean.match(/^\s+([a-zA-Z_-]+):\s*(.*)/);
|
|
64
|
+
if (!kvMatch) continue;
|
|
65
|
+
|
|
66
|
+
const k = kvMatch[1];
|
|
67
|
+
let v = kvMatch[2].trim();
|
|
68
|
+
if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) v = v.slice(1, -1);
|
|
69
|
+
else if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) v = v.slice(1, -1);
|
|
70
|
+
|
|
71
|
+
switch (k) {
|
|
72
|
+
case 'enabled':
|
|
73
|
+
ccEnabled = v.toLowerCase() === 'true';
|
|
74
|
+
break;
|
|
75
|
+
case 'mode':
|
|
76
|
+
if (['strict', 'warn', 'off'].includes(v)) ccMode = v;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { enabled: ccEnabled, mode: ccMode };
|
|
82
|
+
}
|