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
|
@@ -40,6 +40,37 @@
|
|
|
40
40
|
* measures that second population so the gap is visible in the OUTPUT, not
|
|
41
41
|
* only in this comment.
|
|
42
42
|
*
|
|
43
|
+
* ## Wrapper-aware: `fixtureGit` / `fixtureGitSpawn` are git invocations
|
|
44
|
+
*
|
|
45
|
+
* Since 2026-09-06 most fixtures do not spawn `git` themselves — they call
|
|
46
|
+
* `fixtureGit(args, cwd, opts)` / `fixtureGitSpawn(…)` from
|
|
47
|
+
* `tests/_helpers/tmp-fixture.mjs`, which prepend `...NO_BACKGROUND_WRITER` and
|
|
48
|
+
* spawn the binary inside the helper. Two consequences, both measured
|
|
49
|
+
* 2026-09-07 against this tree:
|
|
50
|
+
*
|
|
51
|
+
* - Resolving only the helper's own two `execFileSync`/`spawnSync` lines
|
|
52
|
+
* (i.e. teaching {@link classifyArgv} to skip the allowlisted flag-only
|
|
53
|
+
* spread, see {@link NOOP_PREFIX_SPREADS}) changes NOTHING: `applicable`
|
|
54
|
+
* stays at **17**, because the token after the allowlisted spread is
|
|
55
|
+
* `...args` — an unknown spread the census must still refuse to guess past.
|
|
56
|
+
* - Recognising the two helpers BY NAME, and reading the argv array's first
|
|
57
|
+
* element as the subcommand, lifts `applicable` to **~200** with **0**
|
|
58
|
+
* findings (197 → 198 → 200 across the 2026-09-07 re-measurements as
|
|
59
|
+
* sibling waves added fixtures; re-measure before quoting) — the census
|
|
60
|
+
* then sees the real test population instead of a residue. That population is **244** `fixtureGit`/`fixtureGitSpawn` call
|
|
61
|
+
* sites, measured 2026-09-07 with
|
|
62
|
+
* `rg -o "\bfixtureGit(Spawn)?\s*\(" tests/ -g '*.mjs' | wc -l`
|
|
63
|
+
* (the header claimed 239 until that re-measurement; `applicable` is the
|
|
64
|
+
* smaller number because read-only and unresolved-argv calls are excluded).
|
|
65
|
+
*
|
|
66
|
+
* Wrapper-aware is therefore the mode this file implements. It costs one extra
|
|
67
|
+
* rule: the helper takes its target as the SECOND POSITIONAL, not as a `cwd:`
|
|
68
|
+
* key, and `undefined`, `null` or an ABSENT second positional all mean "no
|
|
69
|
+
* destination was handed to the helper" — see {@link wrapperHasCwd}, which
|
|
70
|
+
* judges the COMMENT-STRIPPED tail so an inline comment cannot stand in for the
|
|
71
|
+
* argument. Judging a wrapper call by the `cwd:` scan alone would have reported
|
|
72
|
+
* nearly every routed fixture as untargeted.
|
|
73
|
+
*
|
|
43
74
|
* Further named gaps:
|
|
44
75
|
* - **A non-literal argv array is never judged.** `execFileSync('git', args, …)`
|
|
45
76
|
* cannot be classified from the call site; counted as `unresolvedArgv`.
|
|
@@ -163,6 +194,16 @@ const TARGET_DECLARING_GLOBALS = Object.freeze(['-C', '--git-dir', '--work-tree'
|
|
|
163
194
|
/** Call shapes that hand an argv ARRAY to a `git` binary. */
|
|
164
195
|
const ARGV_CALL_RE = /\b(?:execFileSync|execFile|spawnSync|spawn)\s*\(\s*['"]git['"]\s*,\s*/g;
|
|
165
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Call shapes that route a `git` argv through this suite's fixture helpers.
|
|
199
|
+
*
|
|
200
|
+
* `fixtureGit(args, cwd, opts)` / `fixtureGitSpawn(args, cwd, opts)` from
|
|
201
|
+
* `tests/_helpers/tmp-fixture.mjs` ARE git invocations — the binary is inside
|
|
202
|
+
* the helper. Recognising them by name is what makes the census reflect the
|
|
203
|
+
* real test population; see § Wrapper-aware in the header.
|
|
204
|
+
*/
|
|
205
|
+
const WRAPPER_CALL_RE = /\b(?:fixtureGit|fixtureGitSpawn)\s*\(/g;
|
|
206
|
+
|
|
166
207
|
/** Call shapes that hand a SHELL STRING opening with `git` to a shell. */
|
|
167
208
|
const SHELL_CALL_RE = /\b(?:execSync|exec)\s*\(\s*(['"`])\s*git\s/g;
|
|
168
209
|
|
|
@@ -226,15 +267,30 @@ function matchBracket(text, open, openChar, closeChar) {
|
|
|
226
267
|
}
|
|
227
268
|
|
|
228
269
|
/**
|
|
229
|
-
* @typedef {{t:'lit', v:string} | {t:'spread'} | {t:'expr'}} ArgvToken
|
|
230
|
-
* `lit` a string literal · `spread` a `...rest` element
|
|
270
|
+
* @typedef {{t:'lit', v:string} | {t:'spread', name:string} | {t:'expr'}} ArgvToken
|
|
271
|
+
* `lit` a string literal · `spread` a `...rest` element, carrying the spread
|
|
272
|
+
* identifier (`''` when it is not a plain identifier) · `expr` any other
|
|
231
273
|
* expression (an identifier, a member access, an interpolated template).
|
|
232
274
|
*
|
|
233
275
|
* The `spread`/`expr` split is load-bearing, not cosmetic: `['init','-q',dir]`
|
|
234
276
|
* carries its target in an `expr` positional, while `['-C',dir,...args]`
|
|
235
277
|
* carries an unknowable tail in a `spread`. Folding both to "opaque" is what
|
|
236
278
|
* produced the first measurement's 11 false positives.
|
|
279
|
+
*
|
|
280
|
+
* The spread's NAME is load-bearing in turn — see {@link NOOP_PREFIX_SPREADS}.
|
|
281
|
+
*/
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Spread identifiers that are known to contain ONLY `git` global flags, so a
|
|
285
|
+
* leading `...NAME` may be skipped exactly as a literal `-c k=v` run is.
|
|
286
|
+
*
|
|
287
|
+
* An explicit allowlist, never a heuristic: any OTHER spread keeps the
|
|
288
|
+
* fail-closed `subcommand: null`, because guessing past an unknown spread is
|
|
289
|
+
* precisely what produced the 11 false positives named in the header.
|
|
290
|
+
*
|
|
291
|
+
* @type {ReadonlySet<string>}
|
|
237
292
|
*/
|
|
293
|
+
const NOOP_PREFIX_SPREADS = new Set(['NO_BACKGROUND_WRITER']);
|
|
238
294
|
|
|
239
295
|
/**
|
|
240
296
|
* Tokenize a literal argv array body into ordered tokens.
|
|
@@ -245,13 +301,13 @@ function matchBracket(text, open, openChar, closeChar) {
|
|
|
245
301
|
export function tokenizeArgv(inner) {
|
|
246
302
|
/** @type {ArgvToken[]} */
|
|
247
303
|
const tokens = [];
|
|
248
|
-
const re = /(['"])((?:\\.|(?!\1)[^\\])*)\1|`([^`$]*)`|(\.\.\.)[\w$.[\]]
|
|
304
|
+
const re = /(['"])((?:\\.|(?!\1)[^\\])*)\1|`([^`$]*)`|(\.\.\.)([\w$.[\]]*)|([A-Za-z_$][\w$.[\]]*|`[^`]*`)/g;
|
|
249
305
|
/** @type {RegExpExecArray|null} */
|
|
250
306
|
let m;
|
|
251
307
|
while ((m = re.exec(inner)) !== null) {
|
|
252
308
|
if (m[2] !== undefined) tokens.push({ t: 'lit', v: m[2] });
|
|
253
309
|
else if (m[3] !== undefined) tokens.push({ t: 'lit', v: m[3] });
|
|
254
|
-
else if (m[4] !== undefined) tokens.push({ t: 'spread' });
|
|
310
|
+
else if (m[4] !== undefined) tokens.push({ t: 'spread', name: m[5] ?? '' });
|
|
255
311
|
else tokens.push({ t: 'expr' });
|
|
256
312
|
}
|
|
257
313
|
return tokens;
|
|
@@ -268,8 +324,13 @@ export function classifyArgv(tokens) {
|
|
|
268
324
|
let index = 0;
|
|
269
325
|
while (index < tokens.length) {
|
|
270
326
|
const token = tokens[index];
|
|
271
|
-
//
|
|
272
|
-
|
|
327
|
+
// An ALLOWLISTED flag-only spread is skipped exactly like a `-c k=v` run.
|
|
328
|
+
if (token.t === 'spread' && NOOP_PREFIX_SPREADS.has(token.name)) {
|
|
329
|
+
index += 1;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
// Any other non-literal in leading-flag position makes the subcommand
|
|
333
|
+
// unknowable; never guess past it.
|
|
273
334
|
if (token.t !== 'lit') return { hasArgvTarget, subcommand: null, rest: [] };
|
|
274
335
|
if (!token.v.startsWith('-')) break;
|
|
275
336
|
|
|
@@ -416,6 +477,84 @@ export function insideStringLiteral(body, index) {
|
|
|
416
477
|
return quote !== null;
|
|
417
478
|
}
|
|
418
479
|
|
|
480
|
+
/**
|
|
481
|
+
* Replace every comment in `text` with a single space, leaving string literals
|
|
482
|
+
* untouched.
|
|
483
|
+
*
|
|
484
|
+
* Not cosmetic: {@link wrapperHasCwd} judges the SECOND POSITIONAL textually,
|
|
485
|
+
* so an inline `/* … *\/` between the comma and the argument used to shift the
|
|
486
|
+
* argument out of view and the call read as targeted — a comment that says
|
|
487
|
+
* "no cwd" measured as a cwd. `inCommentLine` cannot serve here: it answers a
|
|
488
|
+
* question about a whole LINE, while this one runs inside a call expression.
|
|
489
|
+
*
|
|
490
|
+
* @param {string} text
|
|
491
|
+
* @returns {string} same length semantics, comments blanked to one space
|
|
492
|
+
*/
|
|
493
|
+
export function stripComments(text) {
|
|
494
|
+
let out = '';
|
|
495
|
+
/** @type {string|null} */
|
|
496
|
+
let quote = null;
|
|
497
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
498
|
+
const ch = text[i];
|
|
499
|
+
if (quote !== null) {
|
|
500
|
+
out += ch;
|
|
501
|
+
if (ch === '\\') {
|
|
502
|
+
out += text[i + 1] ?? '';
|
|
503
|
+
i += 1;
|
|
504
|
+
} else if (ch === quote) quote = null;
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
508
|
+
quote = ch;
|
|
509
|
+
out += ch;
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
513
|
+
const end = text.indexOf('*/', i + 2);
|
|
514
|
+
i = end === -1 ? text.length : end + 1;
|
|
515
|
+
out += ' ';
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
if (ch === '/' && text[i + 1] === '/') {
|
|
519
|
+
const end = text.indexOf('\n', i);
|
|
520
|
+
i = end === -1 ? text.length : end - 1;
|
|
521
|
+
out += ' ';
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
out += ch;
|
|
525
|
+
}
|
|
526
|
+
return out;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Decide whether a fixture-helper call names a working directory.
|
|
531
|
+
*
|
|
532
|
+
* `fixtureGit(args, cwd, opts)` takes the target as its SECOND POSITIONAL, not
|
|
533
|
+
* as a `cwd:` key — so the textual `cwd:` scan that judges a raw `execFileSync`
|
|
534
|
+
* would report every one of these as untargeted.
|
|
535
|
+
*
|
|
536
|
+
* Three shapes are explicitly NOT a target, because none of them hands the
|
|
537
|
+
* helper a destination: a literal `undefined` (the documented "the argv names
|
|
538
|
+
* the target" form), a literal `null` (same at runtime — `spawnSync` inherits
|
|
539
|
+
* the ambient cwd for both), and an ABSENT second positional. Judging is done
|
|
540
|
+
* on the COMMENT-STRIPPED tail: `fixtureGit([…], /* no cwd *\/ undefined)` read
|
|
541
|
+
* as targeted before 2026-09-07 — the comment, not the argument, was what the
|
|
542
|
+
* second-positional scan saw.
|
|
543
|
+
*
|
|
544
|
+
* @param {string} tail call arguments AFTER the argv array's `]`, bounded by
|
|
545
|
+
* the call's closing paren
|
|
546
|
+
* @returns {boolean}
|
|
547
|
+
*/
|
|
548
|
+
export function wrapperHasCwd(tail) {
|
|
549
|
+
const clean = stripComments(tail);
|
|
550
|
+
const m = /^\s*,\s*/.exec(clean);
|
|
551
|
+
if (m) {
|
|
552
|
+
const second = clean.slice(m[0].length);
|
|
553
|
+
if (second !== '' && !/^(?:undefined|null)\b/.test(second) && !/^,/.test(second)) return true;
|
|
554
|
+
}
|
|
555
|
+
return CWD_OPTION_RE.test(clean);
|
|
556
|
+
}
|
|
557
|
+
|
|
419
558
|
/**
|
|
420
559
|
* Scan one file for `git` invocations.
|
|
421
560
|
*
|
|
@@ -428,7 +567,7 @@ export function scanFile(relative, body, tally) {
|
|
|
428
567
|
/** @type {Array<{kind: string, file: string, line: number, form: string, command: string, message: string}>} */
|
|
429
568
|
const findings = [];
|
|
430
569
|
|
|
431
|
-
/** @type {Array<{index: number, form: 'argv'|'shell', tokens: Array<string|null>, tail: string}>} */
|
|
570
|
+
/** @type {Array<{index: number, form: 'argv'|'shell'|'wrapper', tokens: Array<string|null>, tail: string}>} */
|
|
432
571
|
const calls = [];
|
|
433
572
|
|
|
434
573
|
ARGV_CALL_RE.lastIndex = 0;
|
|
@@ -454,6 +593,35 @@ export function scanFile(relative, body, tally) {
|
|
|
454
593
|
});
|
|
455
594
|
}
|
|
456
595
|
|
|
596
|
+
WRAPPER_CALL_RE.lastIndex = 0;
|
|
597
|
+
while ((m = WRAPPER_CALL_RE.exec(body)) !== null) {
|
|
598
|
+
const openParen = m.index + m[0].length - 1;
|
|
599
|
+
const callEnd = matchBracket(body, openParen, '(', ')');
|
|
600
|
+
if (callEnd === -1) {
|
|
601
|
+
tally.unresolvedArgv += 1;
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
const argsText = body.slice(openParen + 1, callEnd);
|
|
605
|
+
const arrayStart = argsText.search(/\S/);
|
|
606
|
+
if (arrayStart === -1 || argsText[arrayStart] !== '[') {
|
|
607
|
+
// `fixtureGit(args, dir)` — a variable argv, unjudgeable like its
|
|
608
|
+
// `execFileSync` counterpart.
|
|
609
|
+
tally.unresolvedArgv += 1;
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
const close = matchBracket(argsText, arrayStart, '[', ']');
|
|
613
|
+
if (close === -1) {
|
|
614
|
+
tally.unresolvedArgv += 1;
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
calls.push({
|
|
618
|
+
index: m.index,
|
|
619
|
+
form: 'wrapper',
|
|
620
|
+
tokens: tokenizeArgv(argsText.slice(arrayStart + 1, close)),
|
|
621
|
+
tail: argsText.slice(close + 1),
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
457
625
|
SHELL_CALL_RE.lastIndex = 0;
|
|
458
626
|
while ((m = SHELL_CALL_RE.exec(body)) !== null) {
|
|
459
627
|
const quote = m[1];
|
|
@@ -487,11 +655,20 @@ export function scanFile(relative, body, tally) {
|
|
|
487
655
|
}
|
|
488
656
|
tally.applicable += 1;
|
|
489
657
|
|
|
490
|
-
// The options object: everything up to the end of the call expression.
|
|
658
|
+
// The options object: everything up to the end of the call expression. For
|
|
659
|
+
// a wrapper call `call.tail` is ALREADY bounded by the call's own closing
|
|
660
|
+
// paren, and its second positional — not a `cwd:` key — carries the target.
|
|
661
|
+
const isWrapper = call.form === 'wrapper';
|
|
491
662
|
const callEnd = matchBracket(call.tail, call.tail.indexOf('('), '(', ')');
|
|
492
|
-
const optionsText =
|
|
493
|
-
|
|
494
|
-
|
|
663
|
+
const optionsText = isWrapper
|
|
664
|
+
? call.tail
|
|
665
|
+
: callEnd === -1
|
|
666
|
+
? call.tail.slice(0, 400)
|
|
667
|
+
: call.tail.slice(0, callEnd);
|
|
668
|
+
const opaqueOptions = !isWrapper && OPAQUE_OPTIONS_RE.test(call.tail);
|
|
669
|
+
const hasCwd = isWrapper
|
|
670
|
+
? wrapperHasCwd(call.tail)
|
|
671
|
+
: CWD_OPTION_RE.test(optionsText);
|
|
495
672
|
const hasEnvTarget = GIT_ENV_TARGET_RE.test(optionsText);
|
|
496
673
|
const hasTarget =
|
|
497
674
|
hasArgvTarget || hasCwd || hasEnvTarget || hasSubcommandTarget(subcommand, rest);
|
|
@@ -582,7 +759,10 @@ export function inspectTestGitConfigTarget(pluginRoot) {
|
|
|
582
759
|
});
|
|
583
760
|
return result;
|
|
584
761
|
}
|
|
585
|
-
|
|
762
|
+
// Cheap prefilter. The second alternative is load-bearing since the census
|
|
763
|
+
// became wrapper-aware: a file that only calls `fixtureGit([...])` never
|
|
764
|
+
// writes the quoted binary name and would otherwise be skipped unseen.
|
|
765
|
+
if (!/['"`]\s*git[\s'"`]/.test(body) && !/\bfixtureGit(?:Spawn)?\s*\(/.test(body)) continue;
|
|
586
766
|
findings.push(...scanFile(relative, body, tally));
|
|
587
767
|
}
|
|
588
768
|
|
|
@@ -140,6 +140,31 @@
|
|
|
140
140
|
* census behind `--list` — see `runCheckUnwiredFeatures`. The `findings` array
|
|
141
141
|
* always carries every finding, so no programmatic consumer loses data.
|
|
142
142
|
*
|
|
143
|
+
* ### S4 category split, measured 2026-09-07 (#1239)
|
|
144
|
+
*
|
|
145
|
+
* The single S4 class had grown into a broken instrument. Live run at that date
|
|
146
|
+
* (`node scripts/lib/validate/check-unwired-features.mjs . --list`) reported 53
|
|
147
|
+
* findings: S1=0, S2=0, S3=1, S4=52. Classifying the 52 by hand:
|
|
148
|
+
*
|
|
149
|
+
* - **46 (88.5%)** were named by an INSTRUCTION surface (`skills/`, `commands/`,
|
|
150
|
+
* `agents/`, `.claude/rules/`) that ALSO named at least one of the module's
|
|
151
|
+
* exported symbols — i.e. an LLM is told to call it. That is this plugin's
|
|
152
|
+
* architecture, not a defect, and a class firing on 88.5% of its own
|
|
153
|
+
* population is what `.claude/rules/host-resources.md` § HR-101 forbids.
|
|
154
|
+
* They are now `coordinator-invoked-module`, an ADVISORY kind: aggregated
|
|
155
|
+
* into one CLI line, never a per-module WARN, and it does not change the
|
|
156
|
+
* exit code (which was already 0 — see § Mode below).
|
|
157
|
+
* - **1** was a corpus gap: `scripts/lib/vault-sync-baseline.mjs` is statically
|
|
158
|
+
* imported by `skills/vault-sync/validator.mjs:71`, but `skills/**` was not an
|
|
159
|
+
* edge source. Fixed by `S4_EDGE_DIRS` — code under `skills/` is code.
|
|
160
|
+
* - **5** were true positives and remain `unreachable-library-module`.
|
|
161
|
+
*
|
|
162
|
+
* After the split, on the same tree: 5 unreachable, 46 coordinator-invoked, exit
|
|
163
|
+
* 0 unchanged. Per `.claude/rules/development.md` § Guard & Threshold Design this
|
|
164
|
+
* is a category separation, never a raised threshold — nothing is suppressed,
|
|
165
|
+
* both classes stay in `findings`, and either half collapsing to zero is itself
|
|
166
|
+
* pinned by a test.
|
|
167
|
+
*
|
|
143
168
|
* ## Consumer scope, and why "prose-only" is a finding rather than an error
|
|
144
169
|
*
|
|
145
170
|
* Read sites are counted in `scripts/**` and `hooks/**` (`.mjs`/`.js`/`.cjs`),
|
|
@@ -213,6 +238,35 @@ const INSTRUCTION_FILES = Object.freeze(['CLAUDE.md', 'AGENTS.md']);
|
|
|
213
238
|
/** Directories whose code counts as a runtime consumer. */
|
|
214
239
|
const CONSUMER_DIRS = Object.freeze(['scripts', 'hooks']);
|
|
215
240
|
|
|
241
|
+
/**
|
|
242
|
+
* S4-only EDGE sources: directories whose `.mjs` files are real code with real
|
|
243
|
+
* static imports, but which are not themselves S4 candidates.
|
|
244
|
+
*
|
|
245
|
+
* `skills/**\/*.mjs` is the measured instance. `skills/vault-sync/validator.mjs:71`
|
|
246
|
+
* statically imports `scripts/lib/vault-sync-baseline.mjs`, yet before 2026-09-07
|
|
247
|
+
* `skills/` was not walked at all, so that import was invisible and the imported
|
|
248
|
+
* module was reported unreachable — a CORPUS GAP, not a defect in the module.
|
|
249
|
+
*
|
|
250
|
+
* They are edge sources only: their own reachability is not judged here (a skill
|
|
251
|
+
* body invokes them by path, which is the same design boundary CLI entrypoints
|
|
252
|
+
* get), so they seed the walk and never appear in a finding. This does NOT make
|
|
253
|
+
* `skills/` a prose surface for S4 — markdown under `skills/` still names, never
|
|
254
|
+
* calls.
|
|
255
|
+
*/
|
|
256
|
+
const S4_EDGE_DIRS = Object.freeze(['skills']);
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* INSTRUCTION surfaces: the directories whose markdown addresses an LLM that
|
|
260
|
+
* will act on it. Used only to split the S4 census (see
|
|
261
|
+
* `collectUnreachableLibraryModules` § Category split).
|
|
262
|
+
*/
|
|
263
|
+
const INSTRUCTION_DIRS = Object.freeze([
|
|
264
|
+
'skills',
|
|
265
|
+
'commands',
|
|
266
|
+
'agents',
|
|
267
|
+
path.join('.claude', 'rules'),
|
|
268
|
+
]);
|
|
269
|
+
|
|
216
270
|
/** Extensions that can hold a runtime read site. */
|
|
217
271
|
const CODE_EXTENSIONS = Object.freeze(['.mjs', '.js', '.cjs']);
|
|
218
272
|
|
|
@@ -317,6 +371,7 @@ const ALLOWLIST = Object.freeze({
|
|
|
317
371
|
* @typedef {{
|
|
318
372
|
* kind: 'unwired-config-key' | 'parser-orphan-config-key' | 'allowlist-missing-reason'
|
|
319
373
|
* | 'allowlist-stale' | 'orphaned-prose-module' | 'unreachable-library-module'
|
|
374
|
+
* | 'coordinator-invoked-module'
|
|
320
375
|
* | 'tool-error',
|
|
321
376
|
* key: string,
|
|
322
377
|
* message: string,
|
|
@@ -738,7 +793,10 @@ function mentionedModuleTokens(lines) {
|
|
|
738
793
|
* one, which is the right direction for a check whose failure mode is being
|
|
739
794
|
* switched off. Revisit if a real module-resolver (import-specifier resolution
|
|
740
795
|
* relative to the importing file) becomes cheap, or if a collided basename is
|
|
741
|
-
* ever confirmed to mask a true positive.
|
|
796
|
+
* ever confirmed to mask a true positive. The `coordinator-invoked-module`
|
|
797
|
+
* DOWNGRADE is exempt: there a colliding basename must be named with its
|
|
798
|
+
* `dirname/base` suffix, because that match moves a module OUT of the
|
|
799
|
+
* reportable class and would otherwise hide a true unreachable sibling.
|
|
742
800
|
* - **Reachable ≠ executed.** A module imported by a hook that never takes that
|
|
743
801
|
* branch reads as wired here. Proving execution needs coverage data, not a graph.
|
|
744
802
|
* - **Reachable from SOME entrypoint is not reachable from the PROMISED one.**
|
|
@@ -755,14 +813,21 @@ function mentionedModuleTokens(lines) {
|
|
|
755
813
|
* @returns {{findings: Finding[], scanned: {modules: number, roots: number, unreachable: number}}}
|
|
756
814
|
*/
|
|
757
815
|
export function collectUnreachableLibraryModules(pluginRoot) {
|
|
758
|
-
const
|
|
759
|
-
|
|
816
|
+
const candidates = CONSUMER_DIRS.flatMap((dir) => walkCode(path.join(pluginRoot, dir)))
|
|
817
|
+
.sort()
|
|
818
|
+
.map((file) => ({ file, edgeOnly: false }));
|
|
819
|
+
// Edge-only sources contribute imports without being judged (see S4_EDGE_DIRS).
|
|
820
|
+
const edges = S4_EDGE_DIRS.flatMap((dir) => walkCode(path.join(pluginRoot, dir)))
|
|
821
|
+
.sort()
|
|
822
|
+
.map((file) => ({ file, edgeOnly: true }));
|
|
823
|
+
const modules = [...candidates, ...edges].map(({ file, edgeOnly }) => {
|
|
760
824
|
const body = readFileSync(file, 'utf8');
|
|
761
825
|
const lines = body.split('\n');
|
|
762
826
|
const relative = path.relative(pluginRoot, file);
|
|
763
827
|
return {
|
|
764
828
|
relative,
|
|
765
829
|
base: path.basename(file),
|
|
830
|
+
edgeOnly,
|
|
766
831
|
entrypoint: isCliEntrypoint(body),
|
|
767
832
|
exports: collectExportedSymbols(body),
|
|
768
833
|
// This file contributes NO edges — the S4 counterpart of the SELF_REL
|
|
@@ -791,7 +856,7 @@ export function collectUnreachableLibraryModules(pluginRoot) {
|
|
|
791
856
|
/** @type {string[]} */
|
|
792
857
|
const stack = [];
|
|
793
858
|
for (const module of modules) {
|
|
794
|
-
if (!module.entrypoint && !wiringTokens.has(module.base)) continue;
|
|
859
|
+
if (!module.edgeOnly && !module.entrypoint && !wiringTokens.has(module.base)) continue;
|
|
795
860
|
reachable.add(module.relative);
|
|
796
861
|
stack.push(module.relative);
|
|
797
862
|
}
|
|
@@ -816,7 +881,63 @@ export function collectUnreachableLibraryModules(pluginRoot) {
|
|
|
816
881
|
!unreachable.some((other) => other.relative !== module.relative && other.mentions.has(module.base)),
|
|
817
882
|
);
|
|
818
883
|
|
|
884
|
+
// Category split (see § Category split in the doc block above): an INSTRUCTION
|
|
885
|
+
// document that names both the module AND one of its exported symbols is an
|
|
886
|
+
// order addressed to a reader who will execute it — the same grammar
|
|
887
|
+
// discriminator S3 condition 5 uses, applied here to separate the plugin's
|
|
888
|
+
// architecture from the defect. Prose corpus is instruction surfaces only.
|
|
889
|
+
const instructionDocs = INSTRUCTION_DIRS.flatMap((dir) =>
|
|
890
|
+
walkCode(path.join(pluginRoot, dir), [], PROSE_EXTENSIONS, PROSE_EXCLUDED_DIRS),
|
|
891
|
+
)
|
|
892
|
+
.filter((file) => !PROSE_EXCLUDED_FILES.includes(path.basename(file)))
|
|
893
|
+
.sort()
|
|
894
|
+
.map((file) => ({ relative: path.relative(pluginRoot, file), body: readFileSync(file, 'utf8') }));
|
|
895
|
+
|
|
896
|
+
// Basename census for the downgrade half. A bare basename is only a valid
|
|
897
|
+
// module reference when it is UNIQUE in the corpus: `writer.mjs` names both
|
|
898
|
+
// `peer-cards/writer.mjs` and `reconcile/writer.mjs` (measured 2026-09-07),
|
|
899
|
+
// so a doc naming ONE of them would otherwise downgrade BOTH out of the
|
|
900
|
+
// reportable class — a true unreachable silently moved into the advisory
|
|
901
|
+
// half. For a colliding basename the doc must therefore carry at least the
|
|
902
|
+
// `dirname/base` suffix (`reconcile/writer.mjs`); unique basenames keep the
|
|
903
|
+
// cheaper bare match. Direction matters: this can only ever ADD findings back
|
|
904
|
+
// to the reportable class, never remove one.
|
|
905
|
+
/** @type {Map<string, number>} */
|
|
906
|
+
const basenameCount = new Map();
|
|
907
|
+
for (const module of modules) basenameCount.set(module.base, (basenameCount.get(module.base) ?? 0) + 1);
|
|
908
|
+
|
|
909
|
+
let coordinatorInvoked = 0;
|
|
819
910
|
const findings = roots.map((module) => {
|
|
911
|
+
// Docs write POSIX separators regardless of host; `path.relative` does not.
|
|
912
|
+
const relativePosix = module.relative.split(path.sep).join('/');
|
|
913
|
+
const qualified = relativePosix.split('/').slice(-2).join('/');
|
|
914
|
+
const ambiguous = (basenameCount.get(module.base) ?? 0) > 1;
|
|
915
|
+
// Whole-token match, not substring: `body.includes('writer.mjs')` also fires
|
|
916
|
+
// inside `config-writer.mjs`, which downgrades a genuinely unreachable
|
|
917
|
+
// module into the advisory class on a doc that never named it. `tokenMatcher`
|
|
918
|
+
// is the same boundary the export half already uses (it rejects
|
|
919
|
+
// `[A-Za-z0-9_$-]` on either side), applied to the module reference.
|
|
920
|
+
const nameRe = tokenMatcher(ambiguous ? qualified : module.base);
|
|
921
|
+
const namesThisModule = (/** @type {string} */ body) => nameRe.test(body);
|
|
922
|
+
const invokers = instructionDocs.filter(
|
|
923
|
+
(doc) =>
|
|
924
|
+
namesThisModule(doc.body) &&
|
|
925
|
+
module.exports.some((symbol) => tokenMatcher(symbol).test(doc.body)),
|
|
926
|
+
);
|
|
927
|
+
if (invokers.length > 0) {
|
|
928
|
+
coordinatorInvoked += 1;
|
|
929
|
+
return /** @type {Finding} */ ({
|
|
930
|
+
kind: 'coordinator-invoked-module',
|
|
931
|
+
key: module.relative,
|
|
932
|
+
message:
|
|
933
|
+
`no hook, npm script, CI job or husky stage reaches it, but ${invokers
|
|
934
|
+
.slice(0, 2)
|
|
935
|
+
.map((doc) => doc.relative)
|
|
936
|
+
.join(' + ')} instructs a coordinator to call ` +
|
|
937
|
+
`${module.exports.slice(0, 3).join(', ')} — advisory: LLM-dispatch IS this plugin's ` +
|
|
938
|
+
'architecture. Re-check only if that instruction is ever removed',
|
|
939
|
+
});
|
|
940
|
+
}
|
|
820
941
|
const dragged = [...module.mentions].filter(
|
|
821
942
|
(token) => token !== module.base && [...unreachableSet].some((rel) => path.basename(rel) === token),
|
|
822
943
|
);
|
|
@@ -826,14 +947,19 @@ export function collectUnreachableLibraryModules(pluginRoot) {
|
|
|
826
947
|
key: module.relative,
|
|
827
948
|
message:
|
|
828
949
|
`exports ${module.exports.length} symbol(s) (${module.exports.slice(0, 3).join(', ')}) but no hook, ` +
|
|
829
|
-
`npm script, CI job or husky stage reaches it — transitively${tail}.
|
|
830
|
-
'
|
|
950
|
+
`npm script, CI job or husky stage reaches it — transitively${tail}. No instruction surface names ` +
|
|
951
|
+
'one of its exports either: wire it, delete it, or allowlist it with a reason',
|
|
831
952
|
});
|
|
832
953
|
});
|
|
833
954
|
|
|
834
955
|
return {
|
|
835
956
|
findings,
|
|
836
|
-
scanned: {
|
|
957
|
+
scanned: {
|
|
958
|
+
modules: modules.length,
|
|
959
|
+
roots: roots.length,
|
|
960
|
+
unreachable: unreachable.length,
|
|
961
|
+
coordinatorInvoked,
|
|
962
|
+
},
|
|
837
963
|
};
|
|
838
964
|
}
|
|
839
965
|
|
|
@@ -844,7 +970,8 @@ export function collectUnreachableLibraryModules(pluginRoot) {
|
|
|
844
970
|
* @returns {{
|
|
845
971
|
* ok: boolean,
|
|
846
972
|
* summary: {declaredKeys: number, consumerFiles: number, unwired: number, allowlisted: number,
|
|
847
|
-
* orphanedModules: number
|
|
973
|
+
* orphanedModules: number, unreachableModules: number,
|
|
974
|
+
* coordinatorInvokedModules: number},
|
|
848
975
|
* sourcesScanned: string[],
|
|
849
976
|
* findings: Finding[],
|
|
850
977
|
* toolError: boolean,
|
|
@@ -862,6 +989,7 @@ export function inspectUnwiredFeatures(pluginRoot) {
|
|
|
862
989
|
allowlisted: 0,
|
|
863
990
|
orphanedModules: 0,
|
|
864
991
|
unreachableModules: 0,
|
|
992
|
+
coordinatorInvokedModules: 0,
|
|
865
993
|
},
|
|
866
994
|
/** @type {string[]} */
|
|
867
995
|
sourcesScanned: [],
|
|
@@ -972,7 +1100,8 @@ export function inspectUnwiredFeatures(pluginRoot) {
|
|
|
972
1100
|
flagged.add(finding.key);
|
|
973
1101
|
continue;
|
|
974
1102
|
}
|
|
975
|
-
result.summary.
|
|
1103
|
+
if (finding.kind === 'coordinator-invoked-module') result.summary.coordinatorInvokedModules += 1;
|
|
1104
|
+
else result.summary.unreachableModules += 1;
|
|
976
1105
|
findings.push(finding);
|
|
977
1106
|
}
|
|
978
1107
|
|
|
@@ -1011,8 +1140,15 @@ export function runCheckUnwiredFeatures(pluginRoot, { list = false } = {}) {
|
|
|
1011
1140
|
return 2;
|
|
1012
1141
|
}
|
|
1013
1142
|
|
|
1014
|
-
const {
|
|
1015
|
-
|
|
1143
|
+
const {
|
|
1144
|
+
declaredKeys,
|
|
1145
|
+
consumerFiles,
|
|
1146
|
+
unwired,
|
|
1147
|
+
allowlisted,
|
|
1148
|
+
orphanedModules,
|
|
1149
|
+
unreachableModules,
|
|
1150
|
+
coordinatorInvokedModules,
|
|
1151
|
+
} = inspection.summary;
|
|
1016
1152
|
|
|
1017
1153
|
// S4 is a BACKLOG, not a per-run alarm: 50 findings on the live tree against
|
|
1018
1154
|
// 1-2 WARN lines from every sibling check. Printing all 50 every run is the
|
|
@@ -1020,11 +1156,24 @@ export function runCheckUnwiredFeatures(pluginRoot, { list = false } = {}) {
|
|
|
1020
1156
|
// file's own header names. So the default carries the NUMBER (which ratchets,
|
|
1021
1157
|
// and which a reviewer can compare run to run) plus the first few paths; the
|
|
1022
1158
|
// full census is one `--list` away. Nothing is suppressed — only deferred.
|
|
1159
|
+
//
|
|
1160
|
+
// `coordinator-invoked-module` is deferred on the SAME terms and for a stronger
|
|
1161
|
+
// reason: it is not a backlog but an ADVISORY class describing this plugin's
|
|
1162
|
+
// architecture — an instruction surface tells an LLM to call the module.
|
|
1163
|
+
// Measured 2026-09-07: 46 of the 52 findings the single S4 class carried.
|
|
1164
|
+
// Printing 46 WARN lines for the design is the broken instrument HR-101 forbids.
|
|
1165
|
+
const DEFERRED = Object.freeze(['unreachable-library-module', 'coordinator-invoked-module']);
|
|
1023
1166
|
const s4 = inspection.findings.filter((item) => item.kind === 'unreachable-library-module');
|
|
1024
1167
|
for (const item of inspection.findings) {
|
|
1025
|
-
if (!list && item.kind
|
|
1168
|
+
if (!list && DEFERRED.includes(item.kind)) continue;
|
|
1026
1169
|
console.log(` WARN: [${item.kind}] ${item.key} — ${item.message}`);
|
|
1027
1170
|
}
|
|
1171
|
+
// No aggregate WARN for the advisory class: it describes this plugin's
|
|
1172
|
+
// architecture and therefore fires on every run with no action attached — the
|
|
1173
|
+
// 100%-firing instrument `.claude/rules/host-resources.md` HR-101 forbids,
|
|
1174
|
+
// which only trains the operator to skim past the sibling WARNs that DO act.
|
|
1175
|
+
// The PASS line below still carries its count (it ratchets, run to run), and
|
|
1176
|
+
// `--list` still prints the per-module census.
|
|
1028
1177
|
if (!list && s4.length > 0) {
|
|
1029
1178
|
console.log(
|
|
1030
1179
|
` WARN: [unreachable-library-module] ${s4.length} library module(s) that no hook, npm script, ` +
|
|
@@ -1036,7 +1185,8 @@ export function runCheckUnwiredFeatures(pluginRoot, { list = false } = {}) {
|
|
|
1036
1185
|
console.log(
|
|
1037
1186
|
` PASS: censused ${declaredKeys} declared key(s) from ${inspection.sourcesScanned.join(' + ') || '(no source)'} ` +
|
|
1038
1187
|
`against ${consumerFiles} consumer file(s) — ${unwired} unwired, ${allowlisted} allowlisted, ` +
|
|
1039
|
-
`${orphanedModules} prose-orphaned module(s), ${unreachableModules} unreachable module(s)
|
|
1188
|
+
`${orphanedModules} prose-orphaned module(s), ${unreachableModules} unreachable module(s), ` +
|
|
1189
|
+
`${coordinatorInvokedModules} coordinator-invoked module(s)`,
|
|
1040
1190
|
);
|
|
1041
1191
|
console.log('');
|
|
1042
1192
|
console.log('Results: 1 passed, 0 failed');
|