session-orchestrator 4.0.0 → 4.0.1

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 (117) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +3 -2
  4. package/.codex-plugin/skills/architecture/SKILL.md +20 -0
  5. package/.codex-plugin/skills/autopilot/SKILL.md +21 -0
  6. package/.codex-plugin/skills/autopilot/agents/openai.yaml +5 -0
  7. package/.codex-plugin/skills/bootstrap/SKILL.md +22 -0
  8. package/.codex-plugin/skills/bootstrap/agents/openai.yaml +5 -0
  9. package/.codex-plugin/skills/brainstorm/SKILL.md +22 -0
  10. package/.codex-plugin/skills/brainstorm/agents/openai.yaml +5 -0
  11. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +17 -0
  12. package/.codex-plugin/skills/close/SKILL.md +21 -0
  13. package/.codex-plugin/skills/close/agents/openai.yaml +5 -0
  14. package/.codex-plugin/skills/convergence-monitoring/SKILL.md +24 -0
  15. package/.codex-plugin/skills/debug/SKILL.md +21 -0
  16. package/.codex-plugin/skills/debug/agents/openai.yaml +5 -0
  17. package/.codex-plugin/skills/discovery/SKILL.md +21 -0
  18. package/.codex-plugin/skills/discovery/agents/openai.yaml +5 -0
  19. package/.codex-plugin/skills/dispatcher/SKILL.md +21 -0
  20. package/.codex-plugin/skills/dispatcher/agents/openai.yaml +5 -0
  21. package/.codex-plugin/skills/docs-orchestrator/SKILL.md +20 -0
  22. package/.codex-plugin/skills/ecosystem-health/SKILL.md +22 -0
  23. package/.codex-plugin/skills/eli5/SKILL.md +21 -0
  24. package/.codex-plugin/skills/eli5/agents/openai.yaml +5 -0
  25. package/.codex-plugin/skills/eval/SKILL.md +21 -0
  26. package/.codex-plugin/skills/eval/agents/openai.yaml +5 -0
  27. package/.codex-plugin/skills/evolve/SKILL.md +21 -0
  28. package/.codex-plugin/skills/evolve/agents/openai.yaml +5 -0
  29. package/.codex-plugin/skills/frontmatter-guard/SKILL.md +17 -0
  30. package/.codex-plugin/skills/gitlab-ops/SKILL.md +22 -0
  31. package/.codex-plugin/skills/gitlab-portfolio/SKILL.md +17 -0
  32. package/.codex-plugin/skills/go/SKILL.md +22 -0
  33. package/.codex-plugin/skills/go/agents/openai.yaml +5 -0
  34. package/.codex-plugin/skills/grill/SKILL.md +21 -0
  35. package/.codex-plugin/skills/grill/agents/openai.yaml +5 -0
  36. package/.codex-plugin/skills/harness-audit/SKILL.md +19 -0
  37. package/.codex-plugin/skills/harness-audit/agents/openai.yaml +5 -0
  38. package/.codex-plugin/skills/hook-development/SKILL.md +17 -0
  39. package/.codex-plugin/skills/mcp-builder/SKILL.md +17 -0
  40. package/.codex-plugin/skills/memory-cleanup/SKILL.md +21 -0
  41. package/.codex-plugin/skills/memory-cleanup/agents/openai.yaml +5 -0
  42. package/.codex-plugin/skills/mode-selector/SKILL.md +19 -0
  43. package/.codex-plugin/skills/npm-publish/SKILL.md +18 -0
  44. package/.codex-plugin/skills/peekaboo-driver/SKILL.md +20 -0
  45. package/.codex-plugin/skills/persona-panel/SKILL.md +22 -0
  46. package/.codex-plugin/skills/persona-panel/agents/openai.yaml +5 -0
  47. package/.codex-plugin/skills/plan/SKILL.md +22 -0
  48. package/.codex-plugin/skills/plan/agents/openai.yaml +5 -0
  49. package/.codex-plugin/skills/playwright-driver/SKILL.md +22 -0
  50. package/.codex-plugin/skills/portfolio/SKILL.md +21 -0
  51. package/.codex-plugin/skills/portfolio/agents/openai.yaml +5 -0
  52. package/.codex-plugin/skills/quality-gates/SKILL.md +22 -0
  53. package/.codex-plugin/skills/reconcile/SKILL.md +21 -0
  54. package/.codex-plugin/skills/reconcile/agents/openai.yaml +5 -0
  55. package/.codex-plugin/skills/release/SKILL.md +22 -0
  56. package/.codex-plugin/skills/release/agents/openai.yaml +5 -0
  57. package/.codex-plugin/skills/remote-offload/SKILL.md +22 -0
  58. package/.codex-plugin/skills/repo-audit/SKILL.md +19 -0
  59. package/.codex-plugin/skills/repo-audit/agents/openai.yaml +5 -0
  60. package/.codex-plugin/skills/session/SKILL.md +21 -0
  61. package/.codex-plugin/skills/session/agents/openai.yaml +5 -0
  62. package/.codex-plugin/skills/session-end/SKILL.md +22 -0
  63. package/.codex-plugin/skills/session-plan/SKILL.md +22 -0
  64. package/.codex-plugin/skills/session-start/SKILL.md +22 -0
  65. package/.codex-plugin/skills/spinout/SKILL.md +21 -0
  66. package/.codex-plugin/skills/spinout/agents/openai.yaml +5 -0
  67. package/.codex-plugin/skills/sunset-review/SKILL.md +21 -0
  68. package/.codex-plugin/skills/sunset-review/agents/openai.yaml +5 -0
  69. package/.codex-plugin/skills/templates-ack/SKILL.md +21 -0
  70. package/.codex-plugin/skills/templates-ack/agents/openai.yaml +5 -0
  71. package/.codex-plugin/skills/test/SKILL.md +21 -0
  72. package/.codex-plugin/skills/test/agents/openai.yaml +5 -0
  73. package/.codex-plugin/skills/test-runner/SKILL.md +22 -0
  74. package/.codex-plugin/skills/tmux-layout/SKILL.md +23 -0
  75. package/.codex-plugin/skills/using-orchestrator/SKILL.md +19 -0
  76. package/.codex-plugin/skills/vault-mirror/SKILL.md +17 -0
  77. package/.codex-plugin/skills/vault-sync/SKILL.md +17 -0
  78. package/.codex-plugin/skills/wave-executor/SKILL.md +22 -0
  79. package/.codex-plugin/skills/write-executable-plan/SKILL.md +24 -0
  80. package/{plugin.json → .cursor-plugin/plugin.json} +5 -2
  81. package/CHANGELOG.md +190 -1
  82. package/README.md +26 -18
  83. package/docs/codex-setup.md +43 -9
  84. package/docs/components.md +3 -2
  85. package/docs/instruction-delivery.md +12 -5
  86. package/docs/migration-v4.md +33 -9
  87. package/hooks/_lib/hook-import-set.json +4 -3
  88. package/hooks/hooks-codex.json +1 -1
  89. package/hooks/hooks.json +1 -1
  90. package/hooks/on-stop.mjs +25 -4
  91. package/package.json +2 -2
  92. package/scripts/generate-codex-skills.mjs +246 -0
  93. package/scripts/generate-hook-import-set.mjs +51 -8
  94. package/scripts/lib/codex/plugin-contract.mjs +6 -0
  95. package/scripts/lib/config/host-paths.mjs +20 -4
  96. package/scripts/lib/gates/gate-full.mjs +7 -3
  97. package/scripts/lib/owner-config-banner.mjs +7 -9
  98. package/scripts/lib/owner-yaml.mjs +8 -1
  99. package/scripts/lib/plugin-update-banner.mjs +10 -2
  100. package/scripts/lib/reconcile/engine.mjs +38 -7
  101. package/scripts/lib/session-schema/constants.mjs +38 -11
  102. package/scripts/lib/session-start-probes.mjs +12 -0
  103. package/scripts/lib/telemetry/schema.mjs +39 -18
  104. package/scripts/lib/telemetry-flush-health-banner.mjs +211 -0
  105. package/scripts/lib/validate/check-codex-skills.mjs +191 -0
  106. package/scripts/lib/validate/check-owner-leakage.mjs +91 -55
  107. package/scripts/lib/validate/check-skill-links.mjs +37 -7
  108. package/scripts/lib/validate/check-test-git-config-target.mjs +192 -12
  109. package/scripts/lib/validate/check-unwired-features.mjs +163 -13
  110. package/scripts/lib/validate/confidential-names.mjs +95 -30
  111. package/scripts/lib/validate/repo-files.mjs +48 -14
  112. package/scripts/release.mjs +109 -18
  113. package/scripts/site-numbers.mjs +344 -8
  114. package/scripts/validate-plugin.mjs +3 -0
  115. package/skills/session-start/SKILL.md +2 -2
  116. package/skills/session-start/references/phase-4-ssot-environment-check.md +5 -0
  117. package/skills/vault-sync/SKILL.md +10 -0
@@ -36,14 +36,12 @@
36
36
  * - Issue #820.
37
37
  */
38
38
 
39
- import { loadOwnerConfig, resolveOwnerYamlPath, getDefaults } from './owner-yaml.mjs';
40
-
41
- /**
42
- * OPTIONAL object sections (mirrors `owner-yaml.mjs`'s internal, unexported
43
- * `OPTIONAL_OBJECT_SECTIONS`) — the only sections the #1244 discard-branch
44
- * merge rule can carry through a whole-file discard.
45
- */
46
- const OPTIONAL_OBJECT_SECTION_NAMES = /** @type {const} */ (['paths', 'dispatcher']);
39
+ import {
40
+ loadOwnerConfig,
41
+ resolveOwnerYamlPath,
42
+ getDefaults,
43
+ OPTIONAL_OBJECT_SECTIONS,
44
+ } from './owner-yaml.mjs';
47
45
 
48
46
  /**
49
47
  * Format a `{ section, errors }` entry into a short `"name" (first error)` tag.
@@ -111,7 +109,7 @@ export function checkOwnerConfig({ loader = loadOwnerConfig } = {}) {
111
109
  const survivedSections = [];
112
110
  if (result.config && typeof result.config === 'object') {
113
111
  const defaults = getDefaults();
114
- for (const name of OPTIONAL_OBJECT_SECTION_NAMES) {
112
+ for (const name of OPTIONAL_OBJECT_SECTIONS) {
115
113
  if (JSON.stringify(result.config[name]) !== JSON.stringify(defaults[name])) {
116
114
  survivedSections.push(name);
117
115
  }
@@ -196,8 +196,15 @@ const REQUIRED_SECTIONS = /** @type {const} */ (['owner', 'tone', 'efficiency',
196
196
  * OPTIONAL object sections (#820) — a malformed entry is replaced by its
197
197
  * `getDefaults()` value and reported via `droppedSections` + a stderr WARN,
198
198
  * but does NOT discard the rest of the file.
199
+ *
200
+ * Exported (#1262.4) as the SSOT for consumers that must iterate exactly these
201
+ * sections — currently `scripts/lib/owner-config-banner.mjs`, which derived the
202
+ * #1244 "survived a whole-file discard" set from a hand-mirrored local copy.
203
+ * Frozen so a consumer cannot mutate the shared array.
199
204
  */
200
- const OPTIONAL_OBJECT_SECTIONS = /** @type {const} */ (['paths', 'dispatcher']);
205
+ export const OPTIONAL_OBJECT_SECTIONS = /** @type {const} */ (
206
+ Object.freeze(['paths', 'dispatcher'])
207
+ );
201
208
 
202
209
  /**
203
210
  * OPTIONAL list sections (#820) — malformed entries are passed through
@@ -346,7 +346,8 @@ function resolvePlatformFromEnv(env) {
346
346
  * @param {"claude"|"codex"|"cursor"|"pi"} [opts.platform] The harness driving
347
347
  * this session, when the caller already knows it (the SessionStart hook
348
348
  * computes this once and could pass it straight through). Falls back to
349
- * {@link resolvePlatformFromEnv} on `opts.env` when omitted — never throws,
349
+ * {@link resolvePlatformFromEnv} on `opts.env` when omitted OR when the value
350
+ * is not one of the four keys above — never throws,
350
351
  * never leaves the instruction generic just because the caller didn't wire
351
352
  * the parameter through yet.
352
353
  * @returns {Promise<{severity: 'warn', message: string, installed: string, latest: string}|null>}
@@ -391,8 +392,15 @@ export async function checkPluginUpdate({
391
392
  return null;
392
393
  }
393
394
 
395
+ // Allowlist, not "any non-empty string": only a value this table actually has
396
+ // a recipe for may pre-empt env resolution — a typo or a future harness name
397
+ // (`'CLAUDE'`, `'clade'`) falls through to the env signal instead of silently
398
+ // degrading to the generic instruction. `Object.hasOwn`, never `in`: `in`
399
+ // would accept prototype keys (`'toString'`) as platforms.
394
400
  const resolvedPlatform =
395
- typeof platform === 'string' && platform.length > 0 ? platform : resolvePlatformFromEnv(env);
401
+ typeof platform === 'string' && Object.hasOwn(PLATFORM_UPDATE_INSTRUCTIONS, platform)
402
+ ? platform
403
+ : resolvePlatformFromEnv(env);
396
404
  const instruction = PLATFORM_UPDATE_INSTRUCTIONS[resolvedPlatform] ?? GENERIC_UPDATE_INSTRUCTION;
397
405
 
398
406
  return {
@@ -389,6 +389,28 @@ const BODY_LEARNING_ID_RE = /-\s*learning-id:\s*`([^`]+)`/g;
389
389
  * on disk: re-proposing it is the issue #484 defect (9 of 10 proposals in one
390
390
  * run were learnings a `.claude/rules/` file already covered).
391
391
  *
392
+ * **This scan is the AUTHORITATIVE half of the dedupe contract** (#1242). The
393
+ * `.claude/rules/*.md` files it reads are TRACKED, so they survive a fresh
394
+ * clone, a wiped working copy, and any loss of `.orchestrator/runtime/` (which
395
+ * is gitignored — `.gitignore:114`). The idempotency sidecar consulted beside
396
+ * it is a CACHE that can only SHORT-CIRCUIT this scan, never replace it: on a
397
+ * fresh clone the sidecar is empty and correctness rests entirely on the
398
+ * markers below. Measured 2026-09-07 on this repo: with the sidecar emptied,
399
+ * the run produced the identical 10 proposals and 30 "already materialized"
400
+ * rejections; with this scan disabled instead, 5 already-consolidated
401
+ * learnings were re-proposed.
402
+ *
403
+ * Both marker forms are load-bearing. Frontmatter `learning-key:` is a YAML
404
+ * SCALAR and can name exactly ONE learning, so a CONSOLIDATED rule file (one
405
+ * file absorbing N learnings) carries the remaining N-1 identities ONLY as
406
+ * `## Provenance` body bullets. Breaking {@link BODY_LEARNING_KEY_RE} would
407
+ * therefore silently re-propose most of a consolidated corpus while every
408
+ * single-learning file still deduped correctly — pinned by the "fresh clone,
409
+ * consolidated shape" test in `tests/lib/reconcile/engine.test.mjs`.
410
+ *
411
+ * `rule-loader.mjs` only EXCLUDES expired rules from injection; it never
412
+ * deletes a file, so an expired rule keeps deduping through these markers.
413
+ *
392
414
  * Gated the same way as {@link defaultLoadCandidatesForDedupe}: an absent
393
415
  * `repoRoot` yields empty sets rather than falling back to `process.cwd()`.
394
416
  * Never throws — a missing `.claude/rules/` dir or an unreadable file
@@ -555,14 +577,23 @@ async function runReconcileInner(
555
577
  // new learning could use — the #484 defect measured on a real repo was
556
578
  // exactly this: 9 of 10 proposals in one run were learnings that already
557
579
  // had a `.claude/rules/` file on disk, crowding out the tenth new one.
558
- // Two independent sources both count as terminal, either is sufficient:
559
- // - the idempotency sidecar already carries a `processed_at` stamp for
560
- // this `learning_key` (`isProcessed`, previously computed but NEVER
561
- // called from this module — the other half of #484);
562
- // - a `.claude/rules/*.md` file already carries a matching
580
+ // Two independent sources both count as terminal, either is sufficient —
581
+ // but they are NOT peers (#1242). Their contract is:
582
+ // - AUTHORITATIVE: a `.claude/rules/*.md` file already carries a matching
563
583
  // `learning-key`/`learning-id` provenance marker, discovered by
564
- // scanning disk directly (covers the case where a rule was written
565
- // without ever going through this sidecar, e.g. hand-authored).
584
+ // scanning disk directly. Those files are TRACKED, so this source alone
585
+ // is sufficient on a fresh clone and covers rules written without ever
586
+ // going through the sidecar (hand-authored, or consolidated by hand).
587
+ // - CACHE: the idempotency sidecar already carries a `processed_at` stamp
588
+ // for this `learning_key` (`isProcessed`, previously computed but NEVER
589
+ // called from this module — the other half of #484). It lives in the
590
+ // GITIGNORED `.orchestrator/runtime/` (`.gitignore:114`), so it is
591
+ // absent on a fresh clone. It only SHORT-CIRCUITS the scan above (and
592
+ // carries forward verdicts the scan can no longer see, e.g. an operator
593
+ // decline (#1042) or a rule file since removed) — it never replaces it.
594
+ // Consequence: an empty sidecar must never change the verdict for a
595
+ // learning whose rule file exists. Pinned by the "fresh clone, consolidated
596
+ // shape" test in `tests/lib/reconcile/engine.test.mjs`.
566
597
  const { records: existingCandidates } = loadCandidatesForDedupe(repoRoot) ?? { records: [] };
567
598
  const materialized = readMaterializedProvenance(repoRoot) ?? { keys: new Set(), ids: new Set() };
568
599
 
@@ -5,7 +5,8 @@
5
5
  * Leaf module — no imports from siblings or parent.
6
6
  *
7
7
  * Exports: CURRENT_SESSION_SCHEMA_VERSION, SESSION_KEY_ALIASES,
8
- * VALID_SESSION_TYPES, REQUIRED_FIELDS, AGENT_SUMMARY_FIELDS
8
+ * VALID_SESSION_TYPES, VALID_SESSION_PROFILES, REQUIRED_FIELDS,
9
+ * AGENT_SUMMARY_FIELDS
9
10
  */
10
11
 
11
12
  // ---------------------------------------------------------------------------
@@ -82,6 +83,38 @@ export const SESSION_KEY_ALIASES = Object.freeze({
82
83
  */
83
84
  export const VALID_SESSION_TYPES = Object.freeze(['feature', 'deep', 'housekeeping', 'unknown']);
84
85
 
86
+ /**
87
+ * CLOSED set of valid `session_profile` values — the SSOT for this enum
88
+ * (GitLab #1252). `scripts/lib/telemetry/schema.mjs` re-exports this constant
89
+ * rather than defining a second literal; `server/ingest/validate.mjs`
90
+ * `SESSION_PROFILES` is a deliberate MIRROR (the server tree must not import
91
+ * from `scripts/`) held in lockstep by `tests/telemetry/parity.test.mjs`.
92
+ *
93
+ * A profile names a WAVE-SHAPE variant on top of an UNCHANGED `session_type`:
94
+ * an ultradeep session is `session_type: "deep"` PLUS
95
+ * `session_profile: "ultradeep"` — never `session_type: "ultradeep"`.
96
+ * PRD: docs/prd/2026-09-06-ultradeep-session-profile.md. Writer:
97
+ * `commands/session.md` § "Argument alias: ultradeep" → STATE.md frontmatter
98
+ * `session-profile`; wave shape: `skills/session-plan/SKILL.md`.
99
+ *
100
+ * DELIBERATELY NOT A MEMBER of VALID_SESSION_TYPES above: that set is consumed
101
+ * by `scripts/lib/telemetry/schema.mjs` (an unlisted type → 'other') and by
102
+ * `scripts/lib/wave-sizing.mjs` (an unlisted type → TypeError), where a new
103
+ * MODE would be MISLABELLED rather than rejected. That reasoning is unchanged
104
+ * by the `unknown` member: `unknown` is the absence of a measurement, not a
105
+ * mode, and nothing dispatches on it.
106
+ *
107
+ * WHY A WHITELIST AND NOT A REGEX: the value is copied from repo-authored
108
+ * STATE.md frontmatter, i.e. it is the only usage-ping field whose VALUE is
109
+ * free text. Two Wave-1 reviewers reproduced the leak end-to-end (2026-09-06):
110
+ * `session-profile: client-acme-private-repo` travelled verbatim to the ingest
111
+ * server's `raw_json`. A shape regex does not close it — that string already
112
+ * passes any lowercase-and-hyphens pattern. Only an enumeration of names that
113
+ * are public BY CONSTRUCTION does. Adding a profile therefore means a reviewed
114
+ * edit HERE and in the server mirror.
115
+ */
116
+ export const VALID_SESSION_PROFILES = Object.freeze(['ultradeep']);
117
+
85
118
  /**
86
119
  * Required fields for a schema_version=1 record. Validated by validateSession
87
120
  * before any write reaches disk.
@@ -154,15 +187,9 @@ export const OPTIONAL_FIELDS = Object.freeze([
154
187
  // into a note a human reads" ⊃ "schema-valid". A record missing it is a clean
155
188
  // vault-mirror skip, NOT a malformed record.
156
189
  'effectiveness',
157
- // PRD docs/prd/2026-09-06-ultradeep-session-profile.md — `session_profile`
158
- // names a WAVE-SHAPE variant on top of an unchanged `session_type`. It is
159
- // additive and optional on purpose: `ultradeep` is deliberately NOT a member
160
- // of VALID_SESSION_TYPES above, because that set is mirrored in
161
- // scripts/lib/telemetry/schema.mjs (an unlisted type -> 'other') and in
162
- // scripts/lib/wave-sizing.mjs (an unlisted type -> TypeError), where a new
163
- // MODE would be MISLABELLED rather than rejected. That reasoning is unchanged
164
- // by the `unknown` member added above: `unknown` is the absence of a
165
- // measurement, not a mode, and nothing dispatches on it. Every historical
166
- // record lacking the field validates unchanged.
190
+ // `session_profile` — a WAVE-SHAPE variant on top of an unchanged
191
+ // `session_type`. Additive and optional on purpose: every historical record
192
+ // lacking the field validates unchanged. Value set + the full rationale for
193
+ // why it is NOT a VALID_SESSION_TYPES member: VALID_SESSION_PROFILES above.
167
194
  'session_profile',
168
195
  ]);
@@ -168,6 +168,18 @@ export const PROBES = [
168
168
  // vault-staleness-banner.mjs's header for the "one vocabulary, not two"
169
169
  // rationale.
170
170
  },
171
+ {
172
+ id: 'telemetry-flush-health',
173
+ spec: local('./telemetry-flush-health-banner.mjs'),
174
+ fn: 'checkTelemetryFlushHealth',
175
+ network: false,
176
+ args: ({ repoRoot }) => ({ repoRoot }),
177
+ // No custom render/severityOf: the probe returns `{severity:'warn',
178
+ // message}` — exactly the shape the module-level defaults below read. It
179
+ // reports the LAST `orchestrator.telemetry.flush` record when that record
180
+ // is a `sandbox:*` refusal (#1255); the refusal reached the ledger and
181
+ // nothing else before this entry existed.
182
+ },
171
183
  {
172
184
  id: 'ci-status',
173
185
  spec: local('./ci-status-banner.mjs'),
@@ -38,6 +38,7 @@
38
38
  import { getPlatform, getPluginRoot } from '../platform.mjs';
39
39
  import { enumerateSurface } from '../sunset/walker.mjs';
40
40
  import { readPluginVersionFromPackageJson } from '../bootstrap-lock-freshness.mjs';
41
+ import { VALID_SESSION_PROFILES } from '../session-schema/constants.mjs';
41
42
 
42
43
  // ---------------------------------------------------------------------------
43
44
  // Constants
@@ -124,8 +125,31 @@ const ROSTER_OTHER = 'other';
124
125
  const MAX_NAME_LENGTH = 64;
125
126
  const MAX_NAMES = 100;
126
127
 
128
+ /**
129
+ * The two list bounds that are genuinely SHARED with the ingest server
130
+ * (`server/ingest/validate.mjs` `INGEST_LIST_BOUNDS` — MAX_LIST_ITEMS /
131
+ * MAX_LIST_ITEM_LEN). Exported so `tests/telemetry/parity.test.mjs` can compare
132
+ * the two trees instead of trusting two hand-typed numbers: a client that caps
133
+ * higher than the server emits pings the server 400s.
134
+ *
135
+ * The server's other five bounds (MAX_ANON_ID, MAX_SENT_AT, MAX_PLUGIN_VERSION,
136
+ * MAX_SESSION_TYPE, MAX_SESSION_PROFILE) are SERVER-ONLY BY DESIGN — they bound
137
+ * inputs from any client, including foreign or tampered ones, and have no client
138
+ * counterpart to keep in lockstep. Do not mirror them here.
139
+ */
140
+ export const SHARED_LIST_BOUNDS = Object.freeze({
141
+ maxItems: MAX_NAMES,
142
+ maxItemLength: MAX_NAME_LENGTH,
143
+ });
144
+
127
145
  /** Enum fallbacks. */
128
146
  const VALID_PLATFORMS = Object.freeze(['claude', 'codex', 'cursor', 'pi']);
147
+ // DELIBERATE ASYMMETRY, not a drift: this is the TELEMETRY type set and omits
148
+ // `'unknown'`, which session-schema/constants.mjs VALID_SESSION_TYPES carries.
149
+ // There `unknown` is a storable ledger value; here it is a NORMALIZER FALLBACK
150
+ // (SESSION_TYPE_UNKNOWN below) that must never be reachable from the input set,
151
+ // or "we could not tell" would be indistinguishable from a measured type. Not
152
+ // unified with constants.mjs for exactly that reason.
129
153
  const VALID_SESSION_TYPES = Object.freeze(['housekeeping', 'feature', 'deep']);
130
154
  const PLATFORM_OTHER = 'other';
131
155
  const SESSION_TYPE_OTHER = 'other';
@@ -145,27 +169,24 @@ const SESSION_TYPE_UNKNOWN = 'unknown';
145
169
  /**
146
170
  * CLOSED whitelist of PUBLIC session-profile names that may reach the wire.
147
171
  *
148
- * WHY A WHITELIST AND NOT A REGEX: `session_profile` is copied from the STATE.md
149
- * frontmatter key `session-profile`, which is written per repo by whoever runs
150
- * `/session <alias>` — i.e. it is the only field on the usage-ping whose VALUE is
151
- * repo-authored free text. Two independent Wave-1 reviewers reproduced the leak
152
- * end-to-end (2026-09-06): `session-profile: client-acme-private-repo` travelled
153
- * verbatim through `buildUsagePing` → `projectUsagePing` → the ingest server's
154
- * `raw_json` column. A shape regex does NOT close it — that example string is
155
- * already lowercase-and-hyphens and passes any such pattern. Only an
156
- * enumeration of names that are public BY CONSTRUCTION does.
172
+ * RE-EXPORT, not a definition (GitLab #1252): the SSOT is
173
+ * `scripts/lib/session-schema/constants.mjs` `VALID_SESSION_PROFILES`, whose
174
+ * docblock carries the reproduced leak and the whitelist-over-regex rationale.
157
175
  *
158
- * Today exactly one profile exists: `ultradeep`, the 7-wave variant of a `deep`
159
- * session (writer: `commands/session.md` § "Argument alias: ultradeep" → STATE.md
160
- * frontmatter `session-profile`; wave shape: `skills/session-plan/SKILL.md`).
161
- * Adding a profile there means adding it HERE and in the server's mirror
162
- * (`server/ingest/validate.mjs` `SESSION_PROFILES`) — a deliberate two-line,
163
- * reviewed edit, the same contract `ACCEPTED_VERSIONS` already uses server-side.
176
+ * Kept as a pure COMPATIBILITY SHIM, and measured as one: since 2026-09-07 no
177
+ * module in this repo imports the name from here (`rg -n VALID_SESSION_PROFILES
178
+ * scripts server hooks skills tests` — the two telemetry tests that used to now
179
+ * address constants.mjs). It stays because removing an export is a MAJOR change
180
+ * and this is a patch line; a deep importer outside the repo may still hold it.
181
+ * `tests/telemetry/schema.test.mjs` pins it with an IDENTITY assertion
182
+ * (`toBe`, not `toEqual`) so the shim can never quietly become a second
183
+ * definition of the whitelist.
164
184
  *
165
- * NOT a member of VALID_SESSION_TYPES: the profile is a SECOND axis. An
166
- * ultradeep session is `session_type: "deep"` PLUS `session_profile: "ultradeep"`.
185
+ * Adding a profile is a reviewed edit in constants.mjs AND in the server's
186
+ * mirror (`server/ingest/validate.mjs` `SESSION_PROFILES`) — the same two-tree
187
+ * contract `ACCEPTED_VERSIONS` already uses server-side.
167
188
  */
168
- export const VALID_SESSION_PROFILES = Object.freeze(['ultradeep']);
189
+ export { VALID_SESSION_PROFILES };
169
190
 
170
191
  /**
171
192
  * Closed sets for os/arch client-side normalization. A value outside the set —
@@ -0,0 +1,211 @@
1
+ /**
2
+ * telemetry-flush-health-banner.mjs — #1255
3
+ *
4
+ * Surfaces the ONE telemetry-flush outcome nobody could see: a flush the
5
+ * sandbox guard refused.
6
+ *
7
+ * ## Why this module exists
8
+ *
9
+ * `scripts/lib/telemetry/sync.mjs` fails CLOSED since 4.0.0 — when its
10
+ * environment probe cannot complete its checks it returns
11
+ * `{ sandbox: true, reason: 'sandbox:probe-failed' }` and sends nothing. That
12
+ * is the right default, and `sync.mjs` stays deliberately SILENT about it (it
13
+ * runs inside the SessionEnd teardown budget, where a banner has no reader).
14
+ *
15
+ * The refusal therefore reaches exactly one sink: `hooks/on-session-end.mjs`
16
+ * emits `orchestrator.telemetry.flush` with `{ outcome, reason }` into
17
+ * `.orchestrator/metrics/events.jsonl`. Measured 2026-09-07 (W1-D6 census):
18
+ * NO consumer read that reason back out — a repo could refuse every flush for
19
+ * weeks and the only visible difference from a healthy repo was an absence.
20
+ * That is `.claude/rules/host-resources.md` § HR-105 ("a rule you cannot
21
+ * falsify is not a rule") applied to the flush path.
22
+ *
23
+ * This probe closes the loop on the NEXT session start, which is the first
24
+ * moment a human is actually looking: it reads the LAST flush record and warns
25
+ * when that record is a sandbox refusal. Newest-wins by construction — a later
26
+ * successful flush silently clears the warning, so the banner reports the
27
+ * CURRENT state of the channel, never its history.
28
+ *
29
+ * Never throws. Never mutates input. No network.
30
+ *
31
+ * @module scripts/lib/telemetry-flush-health-banner
32
+ */
33
+
34
+ import { existsSync, openSync, readSync, fstatSync, closeSync } from 'node:fs';
35
+ import path from 'node:path';
36
+
37
+ /**
38
+ * Bytes of `events.jsonl` read from the END of the file.
39
+ *
40
+ * The ledger is append-only and grows without bound (33k+ records in this repo
41
+ * alone), so a full `readFileSync` on a SessionStart hook's critical path is
42
+ * the wrong shape — this probe shares a 2s budget with ~18 siblings.
43
+ *
44
+ * NAMED CEILING (BV-004): a flush record older than the last 64 KB of the
45
+ * ledger is invisible to this probe and reads as "no flush recorded" → `null`
46
+ * (silent). At the observed record width (~120-400 bytes) that window holds
47
+ * roughly 150-500 events, and a flush is emitted once per session close — so
48
+ * the window covers the last flush unless ~150+ events landed after it without
49
+ * one, which cannot happen inside a single session's teardown.
50
+ * REVISIT TRIGGER: if the per-session event volume ever exceeds ~150 records
51
+ * between two session ends, raise this or index the ledger — do not silently
52
+ * accept the truncation.
53
+ */
54
+ export const TAIL_BYTES = 64 * 1024;
55
+
56
+ /** The event name `hooks/on-session-end.mjs` emits for every flush attempt. */
57
+ const FLUSH_EVENT = 'orchestrator.telemetry.flush';
58
+
59
+ /**
60
+ * Bound an untrusted ledger `reason` before it is interpolated into a terminal
61
+ * banner: strip C0/DEL control bytes (a record could carry an ANSI escape) and
62
+ * cap the length. Mirrors the sibling bound in
63
+ * `scripts/lib/session-start-probes.mjs` (`.slice(0, 200)` on a probe error).
64
+ *
65
+ * @param {unknown} reason
66
+ * @returns {string}
67
+ */
68
+ function sanitizeReason(reason) {
69
+ // eslint-disable-next-line no-control-regex -- stripping control bytes IS the job
70
+ return String(reason).replace(/[\x00-\x1f\x7f]/g, '').slice(0, 120);
71
+ }
72
+
73
+ /**
74
+ * Read the last `TAIL_BYTES` of a file as UTF-8.
75
+ *
76
+ * Returns a DISCRIMINATED result rather than `string|null`, because "the
77
+ * ledger could not be read" and "the ledger holds nothing alarming" are the
78
+ * two states this probe exists to keep apart (HR-105): collapsing an EACCES
79
+ * onto `null` renders an unreadable channel as a healthy one.
80
+ *
81
+ * @param {string} file
82
+ * @returns {{text: string} | {missing: true} | {error: string}}
83
+ */
84
+ function readTail(file) {
85
+ let fd;
86
+ try {
87
+ fd = openSync(file, 'r');
88
+ const { size } = fstatSync(fd);
89
+ const length = Math.min(size, TAIL_BYTES);
90
+ const start = size - length;
91
+ const buf = Buffer.allocUnsafe(length);
92
+ let read = 0;
93
+ while (read < length) {
94
+ const n = readSync(fd, buf, read, length - read, start + read);
95
+ if (n <= 0) break;
96
+ read += n;
97
+ }
98
+ return { text: buf.subarray(0, read).toString('utf8') };
99
+ } catch (err) {
100
+ if (err?.code === 'ENOENT') return { missing: true };
101
+ return { error: typeof err?.code === 'string' ? err.code : 'EUNKNOWN' };
102
+ } finally {
103
+ if (fd !== undefined) {
104
+ try {
105
+ closeSync(fd);
106
+ } catch {
107
+ /* best effort */
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Classify the telemetry-flush channel from the LAST recorded flush event.
115
+ *
116
+ * Scans the ledger tail BACKWARDS and stops at the first
117
+ * `orchestrator.telemetry.flush` record it can parse — newest wins, so an
118
+ * older refusal followed by a successful flush produces no banner.
119
+ *
120
+ * Returns `null` (silent) when: `repoRoot` is not a string, the ledger is
121
+ * ABSENT, no flush record sits in the tail window, or the newest flush
122
+ * record's `reason` does not start with `sandbox:`. A malformed line is
123
+ * SKIPPED (it is not a flush record we can read), never a throw — the ledger's
124
+ * last line is routinely a partial write when another process is appending.
125
+ *
126
+ * An UNREADABLE ledger (EACCES/EIO/…, i.e. anything other than "not there") is
127
+ * NOT silent: it returns a `ledger-unreadable` warning. "The guard state
128
+ * cannot be confirmed" must not display like "the last flush was fine" — that
129
+ * collapse is the exact HR-105 defect this module's header cites as its reason
130
+ * to exist, one layer down.
131
+ *
132
+ * This probe reports the LAST outcome regardless of its age (no clock seam):
133
+ * a refusal does not expire on its own — only a later successful flush clears
134
+ * it.
135
+ *
136
+ * @param {{repoRoot: string}} opts
137
+ * @returns {null | {severity: 'warn', reason: string, message: string}}
138
+ */
139
+ export function checkTelemetryFlushHealth({ repoRoot } = {}) {
140
+ try {
141
+ if (!repoRoot || typeof repoRoot !== 'string') return null;
142
+
143
+ const file = path.join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl');
144
+ if (!existsSync(file)) return null;
145
+
146
+ const tail = readTail(file);
147
+ if (tail.missing) return null;
148
+ if (tail.error) {
149
+ return {
150
+ severity: 'warn',
151
+ reason: 'ledger-unreadable',
152
+ message:
153
+ '⚠ Telemetry: flush-health unknown — .orchestrator/metrics/events.jsonl ' +
154
+ `could not be read (${sanitizeReason(tail.error)}); ` +
155
+ 'the sandbox guard state cannot be confirmed.',
156
+ };
157
+ }
158
+ if (!tail.text) return null;
159
+
160
+ const lines = tail.text.split('\n');
161
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
162
+ const line = lines[i].trim();
163
+ if (!line) continue;
164
+
165
+ let record;
166
+ try {
167
+ record = JSON.parse(line);
168
+ } catch {
169
+ // Malformed (or a tail-truncated first line) — not readable as a flush
170
+ // record, so it cannot be the newest one. Keep scanning backwards.
171
+ continue;
172
+ }
173
+ if (!record || typeof record !== 'object') continue;
174
+ if (record.event !== FLUSH_EVENT) continue;
175
+
176
+ // First flush record found scanning backwards = the newest one.
177
+ const rawReason = record.reason;
178
+ if (typeof rawReason !== 'string' || !rawReason.startsWith('sandbox:')) return null;
179
+
180
+ // The ledger is an untrusted string source for banner purposes: bound the
181
+ // reason on BOTH surfaces (the field a consumer may render itself, and
182
+ // the message we render) rather than only on the one we happen to own.
183
+ const reason = sanitizeReason(rawReason);
184
+
185
+ return {
186
+ severity: 'warn',
187
+ reason,
188
+ message:
189
+ `⚠ Telemetry: last flush refused by the sandbox guard (${reason}) — ` +
190
+ 'the guard could not complete its checks and failed closed; no ping was sent. ' +
191
+ 'See docs/telemetry.md § Sandbox guard.',
192
+ };
193
+ }
194
+
195
+ return null;
196
+ } catch {
197
+ // Defensive catch-all — a banner probe must never throw (fail-open).
198
+ return null;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Convenience renderer: the banner message string, or `''` when silent.
204
+ *
205
+ * @param {{repoRoot: string}} opts
206
+ * @returns {string}
207
+ */
208
+ export function renderBanner({ repoRoot } = {}) {
209
+ const result = checkTelemetryFlushHealth({ repoRoot });
210
+ return result ? result.message : '';
211
+ }