session-orchestrator 5.0.0 → 5.1.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.
@@ -166,6 +166,43 @@
166
166
  * both classes stay in `findings`, and either half collapsing to zero is itself
167
167
  * pinned by a test.
168
168
  *
169
+ * ## S5 `hand-keyed-learning-subject` — the prose-only DERIVATION
170
+ *
171
+ * S1-S4 all ask about a module or a key. S5 asks about a VALUE that prose tells
172
+ * an LLM to derive: `skills/evolve/references/evolve-analyze-mode.md` says the
173
+ * `effective-sizing` subject must come from `sizingSubject()` and that nobody may
174
+ * hand-concatenate it. Nothing enforced that. An analyzer run that writes
175
+ * `${session_type}-session-sizing` by hand — which is what it did before #1247,
176
+ * and which the helper's own profile-less branch still returns byte-identically —
177
+ * silently re-merges a 7-wave `ultradeep` session onto the 5-wave `deep` row, and
178
+ * no test, gate or checker notices.
179
+ *
180
+ * S5 therefore imports the helper (which is also what gives it a MECHANICAL
181
+ * consumer at all) and re-derives the subject of every written `effective-sizing`
182
+ * learning from the session record it names, reporting any mismatch. The
183
+ * comparison is the point: the check cannot drift from the helper, because it IS
184
+ * the helper.
185
+ *
186
+ * ### What it judges, and the measured reason it judges no more than that
187
+ *
188
+ * Only a subject already in the canonical `<...>-session-sizing` SHAPE is judged.
189
+ * Measured 2026-09-13 on this repo's live ledger (6 `effective-sizing` learnings):
190
+ * exactly ONE is canonically keyed (`deep-session-sizing`, and it is correct); the
191
+ * other five are free-form sentences (`deep session of 10 small follow-up
192
+ * issues`, `full-gate-workers-under-host-contention`, …). Judging those would put
193
+ * this class at 83% on day one — the broken instrument
194
+ * `.claude/rules/host-resources.md` § HR-101 forbids — and it would be judging
195
+ * prose STYLE, not a keying defect. A free-form subject cannot COLLIDE two
196
+ * profiles onto one row, which is the whole bug #1247 fixed.
197
+ *
198
+ * A learning whose `source_session` resolves to no session record is skipped and
199
+ * counted, never reported: the ledger is host-local and append-only, so an old
200
+ * learning routinely outlives the record it names, and reporting that would be a
201
+ * finding about ledger retention rather than about keying.
202
+ *
203
+ * Both ledger files are gitignored host-local telemetry. Absent → S5 is a silent
204
+ * no-op (CI sees no ledger), never a tool error.
205
+ *
169
206
  * ## Consumer scope, and why "prose-only" is a finding rather than an error
170
207
  *
171
208
  * Read sites are counted in `scripts/**` and `hooks/**` (`.mjs`/`.js`/`.cjs`),
@@ -230,6 +267,8 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs';
230
267
  import path from 'node:path';
231
268
  import { pathToFileURL } from 'node:url';
232
269
 
270
+ import { sizingSubject } from '../learnings/sizing-subject.mjs';
271
+
233
272
  /** Documented config surface — every `yaml` fence in this file is a declaration. */
234
273
  const TEMPLATE_REL = 'docs/session-config-template.md';
235
274
 
@@ -331,6 +370,17 @@ const WIRING_DIRS = Object.freeze([
331
370
  ['hooks', Object.freeze(['.json', '.sh'])],
332
371
  ]);
333
372
 
373
+ /**
374
+ * Host-local telemetry the S5 subject-parity check reads. Both are gitignored
375
+ * (`.gitignore:55` `.orchestrator/metrics/*.jsonl`), so both are routinely
376
+ * ABSENT — that is a no-op, not an error. See the header, § S5.
377
+ */
378
+ const LEARNINGS_REL = path.join('.orchestrator', 'metrics', 'learnings.jsonl');
379
+ const SESSIONS_REL = path.join('.orchestrator', 'metrics', 'sessions.jsonl');
380
+
381
+ /** The canonical `effective-sizing` subject shape `sizingSubject()` produces. */
382
+ const SIZING_SUBJECT_RE = /-session-sizing$/;
383
+
334
384
  /**
335
385
  * The config-parser layer: the files a Session Config key must pass through to
336
386
  * become a runtime value. Signal S2 (see header) checks top-level keys against
@@ -372,7 +422,7 @@ const ALLOWLIST = Object.freeze({
372
422
  * @typedef {{
373
423
  * kind: 'unwired-config-key' | 'parser-orphan-config-key' | 'allowlist-missing-reason'
374
424
  * | 'allowlist-stale' | 'orphaned-prose-module' | 'unreachable-library-module'
375
- * | 'coordinator-invoked-module'
425
+ * | 'coordinator-invoked-module' | 'hand-keyed-learning-subject'
376
426
  * | 'tool-error',
377
427
  * key: string,
378
428
  * message: string,
@@ -848,7 +898,18 @@ export function collectUnreachableLibraryModules(pluginRoot) {
848
898
  // `allowlist-stale` — the check silently blinding itself to exactly the
849
899
  // module an operator flagged. Measured 2026-08-28 on the first S4
850
900
  // allowlist entry: 52 → 51 unreachable modules plus one bogus stale line.
851
- mentions: relative === SELF_REL ? new Set() : mentionedModuleTokens(lines),
901
+ mentions:
902
+ relative === SELF_REL
903
+ ? // This file blinds itself to its own ALLOWLIST literals (above) — but
904
+ // NOT to its own real imports. Dropping every edge here made a module
905
+ // this file genuinely `import`s (`learnings/sizing-subject.mjs`, the
906
+ // S5 check) still read as unreachable, which is the identical
907
+ // self-blinding the exclusion exists to prevent, in the other
908
+ // direction. Static import/export lines carry real edges; an
909
+ // ALLOWLIST key is never one, so scoping the scan to them keeps both
910
+ // properties.
911
+ mentionedModuleTokens(lines.filter((line) => /^\s*(?:import|export)\b/.test(line)))
912
+ : mentionedModuleTokens(lines),
852
913
  // Raw text, kept for the QUALIFIED (`dirname/base`) re-check in the
853
914
  // root filter below: `mentionedModuleTokens` strips the directory, so
854
915
  // a colliding basename can only be disambiguated against the body.
@@ -992,6 +1053,100 @@ export function collectUnreachableLibraryModules(pluginRoot) {
992
1053
  };
993
1054
  }
994
1055
 
1056
+ /**
1057
+ * Read a JSONL ledger, skipping blank and unparseable lines.
1058
+ *
1059
+ * A single truncated line (an interrupted append) must not blind the whole
1060
+ * check — the same fail-soft posture the rest of this file takes toward a
1061
+ * missing surface.
1062
+ *
1063
+ * @param {string} file absolute path
1064
+ * @returns {Record<string, unknown>[]}
1065
+ */
1066
+ function readJsonl(file) {
1067
+ if (!existsSync(file)) return [];
1068
+ /** @type {Record<string, unknown>[]} */
1069
+ const records = [];
1070
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
1071
+ const trimmed = line.trim();
1072
+ if (trimmed === '') continue;
1073
+ try {
1074
+ const parsed = JSON.parse(trimmed);
1075
+ if (parsed && typeof parsed === 'object') records.push(parsed);
1076
+ } catch {
1077
+ // truncated / partial append — skip the line, keep the census
1078
+ }
1079
+ }
1080
+ return records;
1081
+ }
1082
+
1083
+ /**
1084
+ * Signal S5 — an `effective-sizing` learning whose subject was hand-written
1085
+ * instead of derived from `sizingSubject()`.
1086
+ *
1087
+ * See the header § S5 for the defect, the deliberate narrowness of what is
1088
+ * judged, and the measured reason a free-form subject is not a finding.
1089
+ *
1090
+ * @param {string} pluginRoot absolute plugin root
1091
+ * @returns {{findings: Finding[], scanned: {judged: number, unattributed: number}}}
1092
+ */
1093
+ export function collectHandKeyedLearningSubjects(pluginRoot) {
1094
+ /** @type {Finding[]} */
1095
+ const findings = [];
1096
+ let judged = 0;
1097
+ let unattributed = 0;
1098
+
1099
+ const learnings = readJsonl(path.join(pluginRoot, LEARNINGS_REL));
1100
+ if (learnings.length === 0) return { findings, scanned: { judged, unattributed } };
1101
+
1102
+ // A session record can be addressed by either identity form (see
1103
+ // `.claude/rules/identity-and-locks.md`: raw UUID + semantic id), and a
1104
+ // learning's `source_session` carries whichever the writer had. Index both, so
1105
+ // a resolvable record is never mis-counted as unattributed.
1106
+ /** @type {Map<string, Record<string, unknown>>} */
1107
+ const sessionsById = new Map();
1108
+ for (const record of readJsonl(path.join(pluginRoot, SESSIONS_REL))) {
1109
+ for (const field of ['session_id', 'semantic_session_id']) {
1110
+ const id = record[field];
1111
+ if (typeof id === 'string' && id !== '' && !sessionsById.has(id)) sessionsById.set(id, record);
1112
+ }
1113
+ }
1114
+
1115
+ for (const learning of learnings) {
1116
+ if (learning.type !== 'effective-sizing') continue;
1117
+ const subject = learning.subject;
1118
+ if (typeof subject !== 'string' || !SIZING_SUBJECT_RE.test(subject)) continue;
1119
+
1120
+ const sourceSession = typeof learning.source_session === 'string' ? learning.source_session : '';
1121
+ const record = sessionsById.get(sourceSession);
1122
+ if (!record) {
1123
+ unattributed += 1;
1124
+ continue;
1125
+ }
1126
+
1127
+ judged += 1;
1128
+ const expected = sizingSubject({
1129
+ session_type: record.session_type,
1130
+ session_profile: record.session_profile,
1131
+ });
1132
+ if (subject === expected) continue;
1133
+
1134
+ findings.push({
1135
+ kind: 'hand-keyed-learning-subject',
1136
+ key: typeof learning.id === 'string' ? learning.id : subject,
1137
+ message:
1138
+ `effective-sizing learning keyed '${subject}', but sizingSubject() derives '${expected}' from ` +
1139
+ `${sourceSession} (session_type=${String(record.session_type)}, session_profile=` +
1140
+ `${record.session_profile === undefined ? 'absent' : String(record.session_profile)}) — the ` +
1141
+ 'subject was hand-concatenated instead of derived via ' +
1142
+ 'scripts/lib/learnings/sizing-subject.mjs, so two session shapes can silently share one row ' +
1143
+ '(GitLab #1247); re-key the learning and derive via the helper',
1144
+ });
1145
+ }
1146
+
1147
+ return { findings, scanned: { judged, unattributed } };
1148
+ }
1149
+
995
1150
  /**
996
1151
  * Run the full census.
997
1152
  *
@@ -1000,7 +1155,8 @@ export function collectUnreachableLibraryModules(pluginRoot) {
1000
1155
  * ok: boolean,
1001
1156
  * summary: {declaredKeys: number, consumerFiles: number, unwired: number, allowlisted: number,
1002
1157
  * orphanedModules: number, unreachableModules: number,
1003
- * coordinatorInvokedModules: number},
1158
+ * coordinatorInvokedModules: number, handKeyedSubjects: number,
1159
+ * judgedSubjects: number},
1004
1160
  * sourcesScanned: string[],
1005
1161
  * findings: Finding[],
1006
1162
  * toolError: boolean,
@@ -1019,6 +1175,8 @@ export function inspectUnwiredFeatures(pluginRoot) {
1019
1175
  orphanedModules: 0,
1020
1176
  unreachableModules: 0,
1021
1177
  coordinatorInvokedModules: 0,
1178
+ handKeyedSubjects: 0,
1179
+ judgedSubjects: 0,
1022
1180
  },
1023
1181
  /** @type {string[]} */
1024
1182
  sourcesScanned: [],
@@ -1036,6 +1194,8 @@ export function inspectUnwiredFeatures(pluginRoot) {
1036
1194
  let orphans;
1037
1195
  /** @type {ReturnType<typeof collectUnreachableLibraryModules>} */
1038
1196
  let unreachable;
1197
+ /** @type {ReturnType<typeof collectHandKeyedLearningSubjects>} */
1198
+ let handKeyed;
1039
1199
  try {
1040
1200
  declared = collectDeclaredKeys(pluginRoot);
1041
1201
  corpus = CONSUMER_DIRS.flatMap((dir) => walkCode(path.join(pluginRoot, dir)))
@@ -1054,6 +1214,7 @@ export function inspectUnwiredFeatures(pluginRoot) {
1054
1214
  .join('\n');
1055
1215
  orphans = collectOrphanedProseModules(pluginRoot);
1056
1216
  unreachable = collectUnreachableLibraryModules(pluginRoot);
1217
+ handKeyed = collectHandKeyedLearningSubjects(pluginRoot);
1057
1218
  } catch (error) {
1058
1219
  result.toolError = true;
1059
1220
  findings.push({
@@ -1134,6 +1295,13 @@ export function inspectUnwiredFeatures(pluginRoot) {
1134
1295
  findings.push(finding);
1135
1296
  }
1136
1297
 
1298
+ // S5 — a written learning subject that prose said must come from the helper,
1299
+ // and did not. Not allowlistable: the fix is to re-key the record, and an
1300
+ // exemption would preserve exactly the collision #1247 removed.
1301
+ result.summary.handKeyedSubjects = handKeyed.findings.length;
1302
+ result.summary.judgedSubjects = handKeyed.scanned.judged;
1303
+ findings.push(...handKeyed.findings);
1304
+
1137
1305
  for (const key of Object.keys(ALLOWLIST).sort()) {
1138
1306
  if (flagged.has(key)) continue;
1139
1307
  findings.push({
@@ -1177,6 +1345,8 @@ export function runCheckUnwiredFeatures(pluginRoot, { list = false } = {}) {
1177
1345
  orphanedModules,
1178
1346
  unreachableModules,
1179
1347
  coordinatorInvokedModules,
1348
+ handKeyedSubjects,
1349
+ judgedSubjects,
1180
1350
  } = inspection.summary;
1181
1351
 
1182
1352
  // S4 is a BACKLOG, not a per-run alarm: 50 findings on the live tree against
@@ -1215,7 +1385,8 @@ export function runCheckUnwiredFeatures(pluginRoot, { list = false } = {}) {
1215
1385
  ` PASS: censused ${declaredKeys} declared key(s) from ${inspection.sourcesScanned.join(' + ') || '(no source)'} ` +
1216
1386
  `against ${consumerFiles} consumer file(s) — ${unwired} unwired, ${allowlisted} allowlisted, ` +
1217
1387
  `${orphanedModules} prose-orphaned module(s), ${unreachableModules} unreachable module(s), ` +
1218
- `${coordinatorInvokedModules} coordinator-invoked module(s)`,
1388
+ `${coordinatorInvokedModules} coordinator-invoked module(s), ${handKeyedSubjects} hand-keyed ` +
1389
+ `learning subject(s) of ${judgedSubjects} judged`,
1219
1390
  );
1220
1391
  console.log('');
1221
1392
  console.log('Results: 1 passed, 0 failed');
@@ -511,6 +511,49 @@ const LOCKFILE_BASENAMES = new Set(['package-lock.json', 'npm-shrinkwrap.json'])
511
511
  /** Code files in which a `//`, `*`, `/*` or `#` line is comment prose, never a version surface. */
512
512
  const CODE_COMMENT_EXTENSIONS = new Set(['.mjs', '.js', '.cjs', '.ts', '.sh']);
513
513
 
514
+ /** Workflow/CI files in which a SHA-pinned `uses:` carries a third-party version as a trailing comment. */
515
+ const YAML_EXTENSIONS = new Set(['.yml', '.yaml']);
516
+
517
+ /**
518
+ * Is this YAML row a SHA-pinned THIRD-PARTY action whose version lives only in the
519
+ * trailing comment?
520
+ *
521
+ * Measured on the 5.1.0 cut (2026-09-13): `.github/workflows/test.yml` carries
522
+ * `uses: actions/setup-node@a0853c2… # v5.0.0` twice. That `5.0.0` is setup-node's
523
+ * version, not ours — the sweep matched it only because our previous release happened
524
+ * to land on the same number, so the false positive is triggered by COINCIDENCE and
525
+ * would reappear for any action whose pin equals our next version.
526
+ *
527
+ * Expressed as a PREDICATE, like the three classes above, for the reason
528
+ * `.claude/rules/measurement-discipline.md` records: a per-path allowlist row fixes
529
+ * this file and leaves the class open for the next workflow.
530
+ *
531
+ * Two conditions, both required, so the predicate cannot mask a stale surface of ours:
532
+ * 1. the line pins a reference with `uses:` BEFORE the comment marker, and
533
+ * 2. EVERY occurrence of the literal sits AFTER that marker.
534
+ * A version in the pin itself (`uses: foo@v5.0.0`) fails condition 2 and is still swept.
535
+ *
536
+ * NAMED CEILING (BV-004): no SURFACES pattern targets a `.yml`/`.yaml` file today, so a
537
+ * YAML hit is never our own surface anyway. Revisit trigger: the first version surface
538
+ * added to a YAML file — then this predicate must also exclude that file's pattern.
539
+ *
540
+ * @param {string} content — the matching line's text
541
+ * @param {string} literal — the previous release literal being swept for
542
+ * @returns {boolean} true = third-party pin comment, skip the row
543
+ */
544
+ export function isPinnedActionComment(content, literal) {
545
+ const hash = content.indexOf('#');
546
+ if (hash === -1) return false;
547
+ if (!/\buses:\s*\S/.test(content.slice(0, hash))) return false;
548
+ const re = versionTokenRegex(literal);
549
+ let seen = 0;
550
+ for (let m = re.exec(content); m; m = re.exec(content)) {
551
+ seen += 1;
552
+ if (m.index < hash) return false;
553
+ }
554
+ return seen > 0;
555
+ }
556
+
514
557
  /**
515
558
  * Is this `path:line:content` row version HISTORY rather than a stale surface?
516
559
  *
@@ -546,6 +589,7 @@ function isHistoryRow(file, line, content, prevTag) {
546
589
  }
547
590
  const dot = base.lastIndexOf('.');
548
591
  const ext = dot === -1 ? '' : base.slice(dot);
592
+ if (YAML_EXTENSIONS.has(ext) && isPinnedActionComment(content, prevTag)) return true;
549
593
  if (CODE_COMMENT_EXTENSIONS.has(ext)) {
550
594
  const trimmed = content.trim();
551
595
  if (trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*')) return true;
@@ -39,22 +39,19 @@ This driver wraps `playwright` (Apache-2.0, Microsoft). It does NOT use `@playwr
39
39
  | `@playwright/cli` | 0.1.13 | Unrelated, unstable — DO NOT USE |
40
40
  | `@playwright/mcp` | 0.0.75 | MCP adapter — R5 hard-gate blocks this |
41
41
 
42
- Verified via `npm view playwright version` → `1.60.0` (2026-05-14 probe). The binary the orchestrator dispatches is named `playwright` (not `playwright-cli`). Use `playwright@^1.60.0` for compatible-minor updates or pin to `playwright@1.60.0` for reproducibility.
42
+ Verified via `npm view playwright version` → `1.60.0` (2026-05-14 probe). The binary the orchestrator dispatches is named `playwright` (not `playwright-cli`), and it is deliberately the TARGET repo's own `@playwright/test` dependency — the driver never relies on a globally installed binary, so `scripts/lib/playwright-driver/runner.mjs` aborts with exit 2 when the target cannot resolve Playwright locally rather than letting `npx` download it mid-run. Use `playwright@^1.60.0` for compatible-minor updates or pin to `playwright@1.60.0` for reproducibility.
43
43
 
44
44
  ## Install
45
45
 
46
- ```bash
47
- npm i -g playwright@1.60.0
48
- playwright install chromium # download browser binaries
49
- ```
50
-
51
- For project-local install (preferred in CI):
46
+ Install into the TARGET repo — that is the only install this driver uses, in CI and locally alike:
52
47
 
53
48
  ```bash
54
- npm install --save-dev playwright@1.60.0
55
- npx playwright install chromium
49
+ npm install --save-dev @playwright/test@1.60.0
50
+ npx playwright install chromium # download browser binaries
56
51
  ```
57
52
 
53
+ Both commands run in the target repo, so `npx` resolves the local binary. A global install is neither used nor sufficient: the driver's preflight requires `@playwright/test` (or `playwright`) under the target's own `node_modules`.
54
+
58
55
  ## Canonical Usage
59
56
 
60
57
  The orchestrator (`skills/test-runner/`) dispatches this driver via Bash. Session naming follows the artifact-paths contract:
@@ -183,7 +180,7 @@ bash -c "PLAYWRIGHT_HTML_OUTPUT_DIR=${RUN_DIR}/report \
183
180
  |---|---|---|
184
181
  | 0 | All tests passed | Record pass, continue |
185
182
  | 1 | At least one test failed | Failures become findings (non-fatal) |
186
- | 2 | Framework error (network, browser-install) | Surface as driver error, halt run |
183
+ | 2 | Framework error (network, browser-install), or no local Playwright in the target repo (preflight) | Surface as driver error, halt run |
187
184
 
188
185
  ### Outputs the Orchestrator MUST Parse
189
186