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.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +2 -2
- package/.cursor-plugin/plugin.json +2 -2
- package/.orchestrator/policy/blocked-commands.json +10 -0
- package/CHANGELOG.md +19 -0
- package/README.md +72 -235
- package/docs/USER-GUIDE.md +24 -0
- package/docs/components.md +2 -0
- package/docs/events-schema.md +1 -0
- package/docs/install.md +112 -0
- package/docs/session-config-template.md +38 -0
- package/docs/telemetry.md +15 -0
- package/hooks/enforce-scope.mjs +42 -1
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/package.json +2 -2
- package/scripts/lib/command-blocker.mjs +76 -4
- package/scripts/lib/playwright-driver/runner.mjs +61 -1
- package/scripts/lib/validate/check-unwired-features.mjs +175 -4
- package/scripts/release.mjs +44 -0
- package/skills/playwright-driver/SKILL.md +7 -10
|
@@ -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:
|
|
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');
|
package/scripts/release.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|