session-orchestrator 3.16.0 → 3.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/CHANGELOG.md +25 -0
  5. package/README.md +13 -11
  6. package/docs/README.md +2 -1
  7. package/docs/components.md +2 -2
  8. package/docs/pi-setup.md +1 -1
  9. package/docs/session-config-reference.md +65 -0
  10. package/docs/session-config-template.md +27 -0
  11. package/docs/telemetry/telemetry-claims.md +204 -0
  12. package/docs/telemetry.md +158 -0
  13. package/hooks/hooks-codex.json +1 -1
  14. package/hooks/hooks.json +1 -1
  15. package/hooks/skill-invocation-telemetry.mjs +109 -10
  16. package/package.json +12 -2
  17. package/scripts/compute-grounding-injection.sh +18 -3
  18. package/scripts/dialectic-deriver.mjs +7 -2
  19. package/scripts/lib/auto-dialectic.mjs +11 -2
  20. package/scripts/lib/auto-dream.mjs +16 -5
  21. package/scripts/lib/build-live-signals.mjs +7 -4
  22. package/scripts/lib/config/context-coverage.mjs +82 -0
  23. package/scripts/lib/config/moc-staleness.mjs +98 -0
  24. package/scripts/lib/config/worktree-orphans.mjs +138 -0
  25. package/scripts/lib/config.mjs +15 -0
  26. package/scripts/lib/context-coverage-banner.mjs +223 -0
  27. package/scripts/lib/dispatcher/enumerate.mjs +151 -31
  28. package/scripts/lib/dispatcher/rank.mjs +22 -8
  29. package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
  30. package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
  31. package/scripts/lib/harness-audit/categories/category4.mjs +13 -2
  32. package/scripts/lib/moc-staleness-banner.mjs +267 -0
  33. package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
  34. package/scripts/lib/session-schema/filters.mjs +88 -0
  35. package/scripts/lib/session-schema.mjs +1 -0
  36. package/scripts/lib/skill-health/join.mjs +35 -9
  37. package/scripts/lib/telemetry/anon-id.mjs +141 -0
  38. package/scripts/lib/telemetry/consent.mjs +299 -0
  39. package/scripts/lib/telemetry/paths.mjs +27 -0
  40. package/scripts/lib/telemetry/queue.mjs +287 -0
  41. package/scripts/lib/telemetry/schema.mjs +384 -0
  42. package/scripts/lib/telemetry/sync.mjs +312 -0
  43. package/scripts/lib/vault-status/board-writer.mjs +63 -5
  44. package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
  45. package/scripts/mcp-server.sh +15 -3
  46. package/scripts/telemetry.mjs +250 -0
  47. package/skills/npm-publish/SKILL.md +81 -0
  48. package/skills/session-end/SKILL.md +74 -1
  49. package/skills/session-start/SKILL.md +77 -1
  50. package/skills/vault-sync/SKILL.md +1 -1
  51. package/skills/vault-sync/package-lock.json +3 -3
  52. package/skills/vault-sync/validator.mjs +121 -34
@@ -0,0 +1,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.
@@ -7,7 +7,7 @@
7
7
  "hooks": [
8
8
  {
9
9
  "type": "command",
10
- "command": "echo '🎯 Session Orchestrator v3.16.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
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.16.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
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. Output: nothing on stdout. Diagnostic errors to stderr only.
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
- // Exit 0 always — informational hook must never block the Skill tool.
97
- main().catch((err) => {
98
- process.stderr.write(`[skill-invocation-telemetry] ERROR: ${err?.message ?? err}\n`);
99
- }).finally(() => process.exit(0));
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.16.0",
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
- "orchestration"
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=$(tail -n 3 "$SESSIONS_JSONL" \
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: sort by `completed_at` DESC, take last K.
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
- entry = JSON.parse(line);
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
- // Find the most recent memory_cleanup_at timestamp across all entries.
78
- for (const entry of entries) {
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 when no cleanup ever ran).
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 = entries.length;
100
+ sessionsSinceCleanup = realEntries.length;
90
101
  } else {
91
- for (const entry of entries) {
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
- const tail = lines.slice(-sessionTailN);
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 tail) {
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
+ }