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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +3 -2
- package/.codex-plugin/skills/architecture/SKILL.md +20 -0
- package/.codex-plugin/skills/autopilot/SKILL.md +21 -0
- package/.codex-plugin/skills/autopilot/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/bootstrap/SKILL.md +22 -0
- package/.codex-plugin/skills/bootstrap/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/brainstorm/SKILL.md +22 -0
- package/.codex-plugin/skills/brainstorm/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +17 -0
- package/.codex-plugin/skills/close/SKILL.md +21 -0
- package/.codex-plugin/skills/close/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/convergence-monitoring/SKILL.md +24 -0
- package/.codex-plugin/skills/debug/SKILL.md +21 -0
- package/.codex-plugin/skills/debug/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/discovery/SKILL.md +21 -0
- package/.codex-plugin/skills/discovery/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/dispatcher/SKILL.md +21 -0
- package/.codex-plugin/skills/dispatcher/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/docs-orchestrator/SKILL.md +20 -0
- package/.codex-plugin/skills/ecosystem-health/SKILL.md +22 -0
- package/.codex-plugin/skills/eli5/SKILL.md +21 -0
- package/.codex-plugin/skills/eli5/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/eval/SKILL.md +21 -0
- package/.codex-plugin/skills/eval/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/evolve/SKILL.md +21 -0
- package/.codex-plugin/skills/evolve/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/frontmatter-guard/SKILL.md +17 -0
- package/.codex-plugin/skills/gitlab-ops/SKILL.md +22 -0
- package/.codex-plugin/skills/gitlab-portfolio/SKILL.md +17 -0
- package/.codex-plugin/skills/go/SKILL.md +22 -0
- package/.codex-plugin/skills/go/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/grill/SKILL.md +21 -0
- package/.codex-plugin/skills/grill/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/harness-audit/SKILL.md +19 -0
- package/.codex-plugin/skills/harness-audit/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/hook-development/SKILL.md +17 -0
- package/.codex-plugin/skills/mcp-builder/SKILL.md +17 -0
- package/.codex-plugin/skills/memory-cleanup/SKILL.md +21 -0
- package/.codex-plugin/skills/memory-cleanup/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/mode-selector/SKILL.md +19 -0
- package/.codex-plugin/skills/npm-publish/SKILL.md +18 -0
- package/.codex-plugin/skills/peekaboo-driver/SKILL.md +20 -0
- package/.codex-plugin/skills/persona-panel/SKILL.md +22 -0
- package/.codex-plugin/skills/persona-panel/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/plan/SKILL.md +22 -0
- package/.codex-plugin/skills/plan/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/playwright-driver/SKILL.md +22 -0
- package/.codex-plugin/skills/portfolio/SKILL.md +21 -0
- package/.codex-plugin/skills/portfolio/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/quality-gates/SKILL.md +22 -0
- package/.codex-plugin/skills/reconcile/SKILL.md +21 -0
- package/.codex-plugin/skills/reconcile/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/release/SKILL.md +22 -0
- package/.codex-plugin/skills/release/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/remote-offload/SKILL.md +22 -0
- package/.codex-plugin/skills/repo-audit/SKILL.md +19 -0
- package/.codex-plugin/skills/repo-audit/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/session/SKILL.md +21 -0
- package/.codex-plugin/skills/session/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/session-end/SKILL.md +22 -0
- package/.codex-plugin/skills/session-plan/SKILL.md +22 -0
- package/.codex-plugin/skills/session-start/SKILL.md +22 -0
- package/.codex-plugin/skills/spinout/SKILL.md +21 -0
- package/.codex-plugin/skills/spinout/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/sunset-review/SKILL.md +21 -0
- package/.codex-plugin/skills/sunset-review/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/templates-ack/SKILL.md +21 -0
- package/.codex-plugin/skills/templates-ack/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/test/SKILL.md +21 -0
- package/.codex-plugin/skills/test/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/test-runner/SKILL.md +22 -0
- package/.codex-plugin/skills/tmux-layout/SKILL.md +23 -0
- package/.codex-plugin/skills/using-orchestrator/SKILL.md +19 -0
- package/.codex-plugin/skills/vault-mirror/SKILL.md +17 -0
- package/.codex-plugin/skills/vault-sync/SKILL.md +17 -0
- package/.codex-plugin/skills/wave-executor/SKILL.md +22 -0
- package/.codex-plugin/skills/write-executable-plan/SKILL.md +24 -0
- package/{plugin.json → .cursor-plugin/plugin.json} +5 -2
- package/CHANGELOG.md +190 -1
- package/README.md +26 -18
- package/docs/codex-setup.md +43 -9
- package/docs/components.md +3 -2
- package/docs/instruction-delivery.md +12 -5
- package/docs/migration-v4.md +33 -9
- package/hooks/_lib/hook-import-set.json +4 -3
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-stop.mjs +25 -4
- package/package.json +2 -2
- package/scripts/generate-codex-skills.mjs +246 -0
- package/scripts/generate-hook-import-set.mjs +51 -8
- package/scripts/lib/codex/plugin-contract.mjs +6 -0
- package/scripts/lib/config/host-paths.mjs +20 -4
- package/scripts/lib/gates/gate-full.mjs +7 -3
- package/scripts/lib/owner-config-banner.mjs +7 -9
- package/scripts/lib/owner-yaml.mjs +8 -1
- package/scripts/lib/plugin-update-banner.mjs +10 -2
- package/scripts/lib/reconcile/engine.mjs +38 -7
- package/scripts/lib/session-schema/constants.mjs +38 -11
- package/scripts/lib/session-start-probes.mjs +12 -0
- package/scripts/lib/telemetry/schema.mjs +39 -18
- package/scripts/lib/telemetry-flush-health-banner.mjs +211 -0
- package/scripts/lib/validate/check-codex-skills.mjs +191 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +91 -55
- package/scripts/lib/validate/check-skill-links.mjs +37 -7
- package/scripts/lib/validate/check-test-git-config-target.mjs +192 -12
- package/scripts/lib/validate/check-unwired-features.mjs +163 -13
- package/scripts/lib/validate/confidential-names.mjs +95 -30
- package/scripts/lib/validate/repo-files.mjs +48 -14
- package/scripts/release.mjs +109 -18
- package/scripts/site-numbers.mjs +344 -8
- package/scripts/validate-plugin.mjs +3 -0
- package/skills/session-start/SKILL.md +2 -2
- package/skills/session-start/references/phase-4-ssot-environment-check.md +5 -0
- package/skills/vault-sync/SKILL.md +10 -0
|
@@ -36,14 +36,12 @@
|
|
|
36
36
|
* - Issue #820.
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
-
import {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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} */ (
|
|
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
|
|
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' &&
|
|
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
|
-
//
|
|
560
|
-
//
|
|
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
|
|
565
|
-
//
|
|
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,
|
|
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
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
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
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
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
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
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
|
-
*
|
|
166
|
-
*
|
|
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
|
|
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
|
+
}
|