session-orchestrator 4.0.0 → 4.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.
Files changed (123) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +3 -2
  4. package/.codex-plugin/skills/architecture/SKILL.md +20 -0
  5. package/.codex-plugin/skills/autopilot/SKILL.md +21 -0
  6. package/.codex-plugin/skills/autopilot/agents/openai.yaml +5 -0
  7. package/.codex-plugin/skills/bootstrap/SKILL.md +22 -0
  8. package/.codex-plugin/skills/bootstrap/agents/openai.yaml +5 -0
  9. package/.codex-plugin/skills/brainstorm/SKILL.md +22 -0
  10. package/.codex-plugin/skills/brainstorm/agents/openai.yaml +5 -0
  11. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +17 -0
  12. package/.codex-plugin/skills/close/SKILL.md +21 -0
  13. package/.codex-plugin/skills/close/agents/openai.yaml +5 -0
  14. package/.codex-plugin/skills/convergence-monitoring/SKILL.md +24 -0
  15. package/.codex-plugin/skills/debug/SKILL.md +21 -0
  16. package/.codex-plugin/skills/debug/agents/openai.yaml +5 -0
  17. package/.codex-plugin/skills/discovery/SKILL.md +21 -0
  18. package/.codex-plugin/skills/discovery/agents/openai.yaml +5 -0
  19. package/.codex-plugin/skills/dispatcher/SKILL.md +21 -0
  20. package/.codex-plugin/skills/dispatcher/agents/openai.yaml +5 -0
  21. package/.codex-plugin/skills/docs-orchestrator/SKILL.md +20 -0
  22. package/.codex-plugin/skills/ecosystem-health/SKILL.md +22 -0
  23. package/.codex-plugin/skills/eli5/SKILL.md +21 -0
  24. package/.codex-plugin/skills/eli5/agents/openai.yaml +5 -0
  25. package/.codex-plugin/skills/eval/SKILL.md +21 -0
  26. package/.codex-plugin/skills/eval/agents/openai.yaml +5 -0
  27. package/.codex-plugin/skills/evolve/SKILL.md +21 -0
  28. package/.codex-plugin/skills/evolve/agents/openai.yaml +5 -0
  29. package/.codex-plugin/skills/frontmatter-guard/SKILL.md +17 -0
  30. package/.codex-plugin/skills/gitlab-ops/SKILL.md +22 -0
  31. package/.codex-plugin/skills/gitlab-portfolio/SKILL.md +17 -0
  32. package/.codex-plugin/skills/go/SKILL.md +22 -0
  33. package/.codex-plugin/skills/go/agents/openai.yaml +5 -0
  34. package/.codex-plugin/skills/grill/SKILL.md +21 -0
  35. package/.codex-plugin/skills/grill/agents/openai.yaml +5 -0
  36. package/.codex-plugin/skills/harness-audit/SKILL.md +19 -0
  37. package/.codex-plugin/skills/harness-audit/agents/openai.yaml +5 -0
  38. package/.codex-plugin/skills/hook-development/SKILL.md +17 -0
  39. package/.codex-plugin/skills/mcp-builder/SKILL.md +17 -0
  40. package/.codex-plugin/skills/memory-cleanup/SKILL.md +21 -0
  41. package/.codex-plugin/skills/memory-cleanup/agents/openai.yaml +5 -0
  42. package/.codex-plugin/skills/mode-selector/SKILL.md +19 -0
  43. package/.codex-plugin/skills/npm-publish/SKILL.md +18 -0
  44. package/.codex-plugin/skills/peekaboo-driver/SKILL.md +20 -0
  45. package/.codex-plugin/skills/persona-panel/SKILL.md +22 -0
  46. package/.codex-plugin/skills/persona-panel/agents/openai.yaml +5 -0
  47. package/.codex-plugin/skills/plan/SKILL.md +22 -0
  48. package/.codex-plugin/skills/plan/agents/openai.yaml +5 -0
  49. package/.codex-plugin/skills/playwright-driver/SKILL.md +22 -0
  50. package/.codex-plugin/skills/portfolio/SKILL.md +21 -0
  51. package/.codex-plugin/skills/portfolio/agents/openai.yaml +5 -0
  52. package/.codex-plugin/skills/quality-gates/SKILL.md +22 -0
  53. package/.codex-plugin/skills/reconcile/SKILL.md +21 -0
  54. package/.codex-plugin/skills/reconcile/agents/openai.yaml +5 -0
  55. package/.codex-plugin/skills/release/SKILL.md +22 -0
  56. package/.codex-plugin/skills/release/agents/openai.yaml +5 -0
  57. package/.codex-plugin/skills/remote-offload/SKILL.md +22 -0
  58. package/.codex-plugin/skills/repo-audit/SKILL.md +19 -0
  59. package/.codex-plugin/skills/repo-audit/agents/openai.yaml +5 -0
  60. package/.codex-plugin/skills/session/SKILL.md +21 -0
  61. package/.codex-plugin/skills/session/agents/openai.yaml +5 -0
  62. package/.codex-plugin/skills/session-end/SKILL.md +22 -0
  63. package/.codex-plugin/skills/session-plan/SKILL.md +22 -0
  64. package/.codex-plugin/skills/session-start/SKILL.md +22 -0
  65. package/.codex-plugin/skills/spinout/SKILL.md +21 -0
  66. package/.codex-plugin/skills/spinout/agents/openai.yaml +5 -0
  67. package/.codex-plugin/skills/sunset-review/SKILL.md +21 -0
  68. package/.codex-plugin/skills/sunset-review/agents/openai.yaml +5 -0
  69. package/.codex-plugin/skills/templates-ack/SKILL.md +21 -0
  70. package/.codex-plugin/skills/templates-ack/agents/openai.yaml +5 -0
  71. package/.codex-plugin/skills/test/SKILL.md +21 -0
  72. package/.codex-plugin/skills/test/agents/openai.yaml +5 -0
  73. package/.codex-plugin/skills/test-runner/SKILL.md +22 -0
  74. package/.codex-plugin/skills/tmux-layout/SKILL.md +23 -0
  75. package/.codex-plugin/skills/using-orchestrator/SKILL.md +19 -0
  76. package/.codex-plugin/skills/vault-mirror/SKILL.md +17 -0
  77. package/.codex-plugin/skills/vault-sync/SKILL.md +17 -0
  78. package/.codex-plugin/skills/wave-executor/SKILL.md +22 -0
  79. package/.codex-plugin/skills/write-executable-plan/SKILL.md +24 -0
  80. package/{plugin.json → .cursor-plugin/plugin.json} +5 -2
  81. package/CHANGELOG.md +213 -1
  82. package/README.md +70 -58
  83. package/commands/release.md +4 -4
  84. package/docs/codex-setup.md +43 -9
  85. package/docs/components.md +3 -2
  86. package/docs/instruction-delivery.md +12 -5
  87. package/docs/migration-v4.md +33 -9
  88. package/hooks/_lib/hook-import-set.json +4 -3
  89. package/hooks/hooks-codex.json +1 -1
  90. package/hooks/hooks.json +1 -1
  91. package/hooks/on-stop.mjs +25 -4
  92. package/package.json +2 -2
  93. package/scripts/ci/assert-coverage-green.mjs +100 -0
  94. package/scripts/generate-codex-skills.mjs +246 -0
  95. package/scripts/generate-hook-import-set.mjs +51 -8
  96. package/scripts/lib/codex/plugin-contract.mjs +6 -0
  97. package/scripts/lib/config/host-paths.mjs +20 -4
  98. package/scripts/lib/events.mjs +3 -3
  99. package/scripts/lib/gates/gate-full.mjs +7 -3
  100. package/scripts/lib/owner-config-banner.mjs +7 -9
  101. package/scripts/lib/owner-yaml.mjs +8 -1
  102. package/scripts/lib/plugin-update-banner.mjs +10 -2
  103. package/scripts/lib/project-hygiene.mjs +182 -6
  104. package/scripts/lib/reconcile/engine.mjs +38 -7
  105. package/scripts/lib/session-identity/own-session.mjs +24 -13
  106. package/scripts/lib/session-schema/constants.mjs +38 -11
  107. package/scripts/lib/session-start-probes.mjs +12 -0
  108. package/scripts/lib/telemetry/schema.mjs +39 -18
  109. package/scripts/lib/telemetry-flush-health-banner.mjs +211 -0
  110. package/scripts/lib/validate/check-codex-skills.mjs +191 -0
  111. package/scripts/lib/validate/check-owner-leakage.mjs +107 -62
  112. package/scripts/lib/validate/check-skill-links.mjs +37 -7
  113. package/scripts/lib/validate/check-test-git-config-target.mjs +192 -12
  114. package/scripts/lib/validate/check-unwired-features.mjs +163 -13
  115. package/scripts/lib/validate/confidential-names.mjs +95 -30
  116. package/scripts/lib/validate/repo-files.mjs +48 -14
  117. package/scripts/lib/vault-mirror/render-sessions.mjs +8 -1
  118. package/scripts/release.mjs +141 -29
  119. package/scripts/site-numbers.mjs +344 -8
  120. package/scripts/validate-plugin.mjs +3 -0
  121. package/skills/session-start/SKILL.md +2 -2
  122. package/skills/session-start/references/phase-4-ssot-environment-check.md +5 -0
  123. package/skills/vault-sync/SKILL.md +10 -0
@@ -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 absolute = CONSUMER_DIRS.flatMap((dir) => walkCode(path.join(pluginRoot, dir))).sort();
759
- const modules = absolute.map((file) => {
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}. Only markdown names it, and ` +
830
- 'prose is an instruction to an LLM, not a caller: wire it, delete it, or allowlist it with a reason',
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: { modules: modules.length, roots: roots.length, unreachable: unreachable.length },
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.unreachableModules += 1;
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 { declaredKeys, consumerFiles, unwired, allowlisted, orphanedModules, unreachableModules } =
1015
- inspection.summary;
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 === 'unreachable-library-module') continue;
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');
@@ -15,12 +15,33 @@
15
15
  * names and REDACTS any match from its output (a CP11 hit printed verbatim to the
16
16
  * public CI log would be a WORSE leak than the one being guarded).
17
17
  *
18
- * Contract:
19
- * loadConfidentialNames({ namesPath, deps? }) → string[] | null
18
+ * Contract (#1250 + #1264 — TWO entry points; the discriminated one is ADDITIVE):
19
+ * loadConfidentialNames({ namesPath, deps? }) → string[] | null (4.0.0 shape)
20
+ * inspectConfidentialNames({ namesPath, deps? }) → { status, names } (discriminated)
20
21
  *
21
- * - `namesPath` empty/whitespace/non-string null (no list configured; SILENT —
22
- * this is the default for the ~99% of hosts without a list).
23
- * - file missing / unreadable / malformed-JSON / non-array → null + one stderr WARN.
22
+ * status 'ok' | 'empty' | 'all-dropped' | 'missing' | 'malformed' | 'unconfigured'
23
+ * names is the validated list for 'ok', and `[]` for every other status.
24
+ *
25
+ * `loadConfidentialNames` is the 4.0.0 PUBLIC contract and is preserved verbatim
26
+ * (`string[] | null`, null-collapsing): `package.json` carries no `exports` map,
27
+ * so a consumer repo can deep-import this module and a PATCH release must not
28
+ * break it. It is a thin wrapper over `inspectConfidentialNames`, which reports
29
+ * the class the older shape collapsed into `null` — exactly the distinction
30
+ * CP11's fail-closed verdict turns on: 'unconfigured' and 'empty' are operator
31
+ * choices (inactive, PASS), while 'missing', 'malformed' and 'all-dropped' mean
32
+ * a configured guard could not run (fail closed). The scanner had to re-read and
33
+ * re-classify the file to recover a class the loader already knew; it no longer does.
34
+ *
35
+ * - `namesPath` empty/whitespace/non-string → 'unconfigured' (SILENT — this is
36
+ * the default for the ~99% of hosts without a list).
37
+ * - file missing → 'missing' + one stderr WARN.
38
+ * - unreadable / malformed-JSON / non-array → 'malformed' + one stderr WARN.
39
+ * - readable, well-formed, parsed array of length 0 → 'empty'. The operator
40
+ * deliberately wrote `[]` to switch CP11 off; that is a silent PASS.
41
+ * - readable, well-formed, parsed array NON-empty but every entry dropped by
42
+ * validation → 'all-dropped'. Distinct from 'empty' on purpose: the operator
43
+ * INTENDED names here, so a guard that ends up with zero patterns must fail
44
+ * closed rather than pass silently (W4 finding F3).
24
45
  * - Each entry is validated: it must be a non-empty string within a length cap
25
46
  * (MAX_NAME_LENGTH — a ReDoS/DoS guard against a manipulated host-local file;
26
47
  * a real customer/repo name never exceeds it). Entries failing either check
@@ -29,11 +50,16 @@
29
50
  * - Result is CACHED per process, keyed by namesPath (the scanner reads it once).
30
51
  *
31
52
  * Privacy: this module never writes the list anywhere; it only reads the operator's
32
- * host-local file. WARN messages carry the file PATH (the operator's own config
33
- * path, shown transiently on their terminal) but NEVER the confidential names.
53
+ * host-local file. WARN messages carry NEITHER the confidential names NOR the file
54
+ * PATH only `basename(namesPath)` (W4 finding F1). The full path is host-local
55
+ * (`/Users/<name>/…`), and these WARNs fire on exactly the branches the scanner turns
56
+ * into a `FAIL` + exit 1 — output an operator pastes into a PUBLIC CI log, where the
57
+ * path would leak the very shape CP1 exists to block. The scanner's own
58
+ * `disabledReason` strings have always been path-free; the loader now matches them.
34
59
  */
35
60
 
36
61
  import { readFileSync, existsSync } from 'node:fs';
62
+ import { basename } from 'node:path';
37
63
 
38
64
  /**
39
65
  * Max characters for a single confidential name. A real customer / repo name is
@@ -43,7 +69,7 @@ import { readFileSync, existsSync } from 'node:fs';
43
69
  */
44
70
  const MAX_NAME_LENGTH = 256;
45
71
 
46
- /** Per-process cache: namesPath → (string[] | null). */
72
+ /** Per-process cache: namesPath → `{ status, names }` (shared by BOTH entry points). */
47
73
  const _cache = new Map();
48
74
 
49
75
  /**
@@ -63,14 +89,18 @@ const DEFAULT_DEPS = {
63
89
 
64
90
  /**
65
91
  * Parse + validate the raw JSON body into a list of confidential names.
66
- * Returns null when the body is malformed or yields zero usable entries.
92
+ *
93
+ * Distinguishes THREE zero-name outcomes, because the caller's verdict differs
94
+ * between them: 'malformed' (unparseable or not an array), 'empty' (a parsed array
95
+ * of length 0 — the operator deliberately switched CP11 off) and 'all-dropped'
96
+ * (the operator DID list entries, and validation rejected every one of them).
67
97
  *
68
98
  * @param {string} raw
69
- * @param {string} namesPath
99
+ * @param {string} label - basename of the names file, for WARN text (never the path)
70
100
  * @param {{ warn: (msg: string) => void }} d
71
- * @returns {string[]|null}
101
+ * @returns {{ status: 'ok'|'empty'|'all-dropped'|'malformed', names: string[] }}
72
102
  */
73
- function parseNames(raw, namesPath, d) {
103
+ function parseNames(raw, label, d) {
74
104
  let parsed;
75
105
  try {
76
106
  parsed = JSON.parse(raw);
@@ -78,19 +108,20 @@ function parseNames(raw, namesPath, d) {
78
108
  // Fix 3 (security-reviewer): NEVER embed err.message — V8's JSON.parse error
79
109
  // text echoes the first ~10 chars of the file body, which for a confidential-
80
110
  // names file is a would-be confidential-name prefix. Log only the error CLASS
81
- // (err.name, e.g. SyntaxError) + the path. Keeps the module-docstring invariant
82
- // ("WARN messages … NEVER the confidential names") true.
111
+ // (err.name, e.g. SyntaxError) + the file BASENAME (never the host-local path,
112
+ // W4 finding F1). Keeps the module-docstring invariant ("WARN messages … NEVER
113
+ // the confidential names") true.
83
114
  d.warn(
84
- `WARN validate/confidential-names: malformed JSON in ${namesPath} (${err.name}); CP11 inactive\n`,
115
+ `WARN validate/confidential-names: malformed JSON in ${label} (${err.name}); CP11 inactive\n`,
85
116
  );
86
- return null;
117
+ return { status: 'malformed', names: [] };
87
118
  }
88
119
 
89
120
  if (!Array.isArray(parsed)) {
90
121
  d.warn(
91
- `WARN validate/confidential-names: ${namesPath} must be a JSON array of strings; ignoring the list (CP11 inactive)\n`,
122
+ `WARN validate/confidential-names: ${label} must be a JSON array of strings; ignoring the list (CP11 inactive)\n`,
92
123
  );
93
- return null;
124
+ return { status: 'malformed', names: [] };
94
125
  }
95
126
 
96
127
  const names = [];
@@ -115,55 +146,89 @@ function parseNames(raw, namesPath, d) {
115
146
  // Deliberately omit the offending entries — logging them would leak the very
116
147
  // confidential names the list exists to keep host-local. COUNTS only.
117
148
  d.warn(
118
- `WARN validate/confidential-names: ignored ${ignoredInvalid} invalid and ${ignoredOversized} oversized (>${MAX_NAME_LENGTH} chars) name entr(ies) in ${namesPath}\n`,
149
+ `WARN validate/confidential-names: ignored ${ignoredInvalid} invalid and ${ignoredOversized} oversized (>${MAX_NAME_LENGTH} chars) name entr(ies) in ${label}\n`,
119
150
  );
120
151
  }
121
152
 
122
- return names.length > 0 ? names : null;
153
+ if (names.length > 0) return { status: 'ok', names };
154
+ // F3 (W4 panel, fail-open): a file whose entries were ALL dropped by validation
155
+ // is NOT the operator's `[]` opt-out — they listed names and meant them to bind.
156
+ // Collapsing both into 'empty' made the scanner treat a corrupted list as a
157
+ // deliberate opt-out and PASS silently with CP11 inactive.
158
+ return parsed.length > 0
159
+ ? { status: 'all-dropped', names: [] }
160
+ : { status: 'empty', names: [] };
123
161
  }
124
162
 
125
163
  /**
126
- * Load and validate the host-local confidential-names list. Defensive never throws.
164
+ * Load and validate the host-local confidential-names list, reporting WHY the list
165
+ * is unusable when it is. Defensive — never throws.
127
166
  *
128
167
  * @param {object} opts
129
168
  * @param {string|null|undefined} opts.namesPath - absolute path to the names JSON, or
130
169
  * empty/absent when no list is configured.
131
170
  * @param {Partial<typeof DEFAULT_DEPS>} [opts.deps] - injected fs / warn (tests).
132
- * @returns {string[]|null} the validated names, or null when unconfigured/unusable.
171
+ * @returns {{ status: 'ok'|'empty'|'all-dropped'|'missing'|'malformed'|'unconfigured', names: string[] }}
172
+ * the validated names under `status: 'ok'`; `names` is `[]` for every other status.
133
173
  */
134
- export function loadConfidentialNames({ namesPath, deps = {} } = {}) {
174
+ export function inspectConfidentialNames({ namesPath, deps = {} } = {}) {
135
175
  const d = { ...DEFAULT_DEPS, ...deps };
136
176
 
137
177
  // Unconfigured → no list, no noise. This is the normal case for public repos
138
178
  // and for any host that has not opted into confidential-name scanning.
139
179
  if (typeof namesPath !== 'string' || namesPath.trim() === '') {
140
- return null;
180
+ return { status: 'unconfigured', names: [] };
141
181
  }
142
182
 
143
183
  if (_cache.has(namesPath)) {
144
184
  return _cache.get(namesPath);
145
185
  }
146
186
 
147
- let result = null; // default when the file is missing/unreadable/unusable
187
+ // Default when the file is missing; the read/parse branches below overwrite it.
188
+ let result = { status: 'missing', names: [] };
148
189
  try {
149
190
  if (!d.existsSync(namesPath)) {
150
191
  d.warn(
151
- `WARN validate/confidential-names: confidential-names-file is set but the file does not exist: ${namesPath}; CP11 inactive\n`,
192
+ `WARN validate/confidential-names: confidential-names-file is set but the file does not exist: ${basename(namesPath)}; CP11 inactive\n`,
152
193
  );
153
194
  } else {
154
195
  const raw = d.readFileSync(namesPath, 'utf8');
155
- result = parseNames(raw, namesPath, d);
196
+ result = parseNames(raw, basename(namesPath), d);
156
197
  }
157
198
  } catch (err) {
158
199
  // Fix 3 (security-reviewer): log the error CLASS, not err.message. A filesystem
159
200
  // error rarely embeds file content, but keeping the invariant uniform ("the WARN
160
- // carries only counts / err-class + the path, never file body") removes the last
161
- // err.message sink in this module.
201
+ // carries only counts / err-class + the file basename, never the path and never
202
+ // file body") removes the last err.message sink in this module.
162
203
  d.warn(
163
- `WARN validate/confidential-names: failed to read confidential-names file at ${namesPath} (${err.name}); CP11 inactive\n`,
204
+ `WARN validate/confidential-names: failed to read confidential-names file ${basename(namesPath)} (${err.name}); CP11 inactive\n`,
164
205
  );
206
+ // An unreadable file is NOT 'missing' — existsSync said it is there. It shares
207
+ // the 'malformed' verdict (configured but unusable → the caller fails closed).
208
+ result = { status: 'malformed', names: [] };
165
209
  }
166
210
 
167
211
  _cache.set(namesPath, result);
168
212
  return result;
169
213
  }
214
+
215
+ /**
216
+ * The 4.0.0 PUBLIC contract, preserved verbatim: the validated names, or `null`
217
+ * whenever no usable list could be loaded (unconfigured, missing, malformed,
218
+ * empty, all-dropped alike). `package.json` has no `exports` map, so a consumer
219
+ * repo may deep-import this function; a PATCH release must not change its shape.
220
+ *
221
+ * In-tree callers that need to distinguish an operator OPT-OUT from a guard that
222
+ * FAILED TO RUN must use `inspectConfidentialNames` instead — that distinction is
223
+ * precisely what this return type cannot express.
224
+ *
225
+ * @param {object} opts
226
+ * @param {string|null|undefined} opts.namesPath
227
+ * @param {Partial<typeof DEFAULT_DEPS>} [opts.deps]
228
+ * @returns {string[] | null}
229
+ */
230
+ export function loadConfidentialNames({ namesPath, deps = {} } = {}) {
231
+ // Shares the one cache entry: inspect() keys it, this derives from the result.
232
+ const { names } = inspectConfidentialNames({ namesPath, deps });
233
+ return names.length > 0 ? names : null;
234
+ }
@@ -178,6 +178,37 @@ function walk(absDir, matches, exclude, acc = []) {
178
178
  return acc;
179
179
  }
180
180
 
181
+ /**
182
+ * Error codes that mean "this tracked path is not in the working tree" — a
183
+ * sparse checkout, or a deletion staged from somewhere else. Both are ordinary
184
+ * repository states, so the path is dropped from the census silently.
185
+ *
186
+ * Every OTHER stat error (EACCES on an unreadable parent, EIO, ELOOP, ENAMETOOLONG)
187
+ * describes a filesystem the caller cannot enumerate. Swallowing those returned a
188
+ * SHORTER census that looked exactly like a smaller repository, which is the
189
+ * failure mode a scanner can neither see nor report.
190
+ */
191
+ const ABSENT_FROM_WORKTREE = Object.freeze(['ENOENT', 'ENOTDIR']);
192
+
193
+ /**
194
+ * True when `absolute` is a regular file present in the working tree; false
195
+ * when it is absent for one of the {@link ABSENT_FROM_WORKTREE} reasons.
196
+ * Rethrows every other stat error.
197
+ *
198
+ * @param {string} absolute
199
+ * @returns {boolean}
200
+ */
201
+ function isPresentFile(absolute) {
202
+ try {
203
+ return statSync(absolute).isFile();
204
+ } catch (err) {
205
+ if (ABSENT_FROM_WORKTREE.includes(/** @type {NodeJS.ErrnoException} */ (err).code)) {
206
+ return false;
207
+ }
208
+ throw err;
209
+ }
210
+ }
211
+
181
212
  /**
182
213
  * Resolve the `dirs` option to absolute directories under `root`.
183
214
  * `'.'` (or an empty list) means the root itself.
@@ -212,33 +243,36 @@ export function listRepoFiles(root, options = {}) {
212
243
 
213
244
  if (isGitToplevel(root, env)) {
214
245
  const pathspecs = dirs && dirs.length > 0 ? dirs.filter((d) => d !== '.') : [];
246
+ /** @type {string | null} */
247
+ let out = null;
215
248
  try {
216
- const out = execFileSync('git', ['ls-files', '-z', '--', ...pathspecs], {
249
+ out = execFileSync('git', ['ls-files', '-z', '--', ...pathspecs], {
217
250
  cwd: root,
218
251
  encoding: 'utf8',
219
252
  stdio: ['ignore', 'pipe', 'ignore'],
220
253
  maxBuffer: 64 * 1024 * 1024,
221
254
  env,
222
255
  });
256
+ } catch {
257
+ // fall through to the walk — a git that answered rev-parse but failed
258
+ // ls-files leaves us with no index to trust.
259
+ //
260
+ // This catch covers the `ls-files` INVOCATION only. The census below is
261
+ // deliberately outside it: a stat error there is not "git has no index",
262
+ // and folding the two together would turn an unreadable working tree
263
+ // into a silent full-repo re-walk.
264
+ //
265
+ // `out` keeps its `null` initialiser here — no reassignment, so the
266
+ // `out !== null` test below is the single place the two paths diverge.
267
+ }
268
+ if (out !== null) {
223
269
  return out
224
270
  .split('\0')
225
271
  .filter(Boolean)
226
272
  .map((rel) => path.join(root, rel))
227
273
  .filter(matches)
228
- // A tracked path can be absent from the working tree (sparse checkout,
229
- // a deletion staged elsewhere). A scanner that then read it would
230
- // report a tool-error for a file nobody removed.
231
- .filter((absolute) => {
232
- try {
233
- return statSync(absolute).isFile();
234
- } catch {
235
- return false;
236
- }
237
- })
274
+ .filter(isPresentFile)
238
275
  .sort();
239
- } catch {
240
- // fall through to the walk — a git that answered rev-parse but failed
241
- // ls-files leaves us with no index to trust.
242
276
  }
243
277
  }
244
278
 
@@ -473,12 +473,19 @@ export function generateSessionNote(entry, options = {}) {
473
473
  // that "repaired" that one too would be inventing a measurement.
474
474
  const waveRows = waves
475
475
  .map((w) => {
476
+ // #1276: lifecycle-only records omit every older count alias. Started
477
+ // measures participation; completed/planned-only counts retain their
478
+ // labels so a plan or a completion count never claims dispatch coverage.
479
+ const completedAgents = waveCount(w.agent_count_completed);
480
+ const plannedAgents = waveCount(w.agent_count_planned);
476
481
  const agentsCell =
477
482
  waveCount(w.agent_count) ??
478
483
  waveCount(w.agents) ??
479
484
  waveCount(w.agents_dispatched) ??
480
485
  waveCount(w.dispatched) ??
481
- MISSING_CELL;
486
+ waveCount(w.agent_count_started) ??
487
+ (completedAgents === undefined ? undefined : `${completedAgents} completed`) ??
488
+ (plannedAgents === undefined ? MISSING_CELL : `${plannedAgents} planned`);
482
489
  const filesCell = waveCount(w.files_changed) ?? waveCount(w.files) ?? MISSING_CELL;
483
490
  const qualityCell =
484
491
  w.quality ?? w.quality_check ?? w.status ?? w.result ?? w.outcome ?? MISSING_CELL;