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.
Files changed (117) 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 +190 -1
  82. package/README.md +26 -18
  83. package/docs/codex-setup.md +43 -9
  84. package/docs/components.md +3 -2
  85. package/docs/instruction-delivery.md +12 -5
  86. package/docs/migration-v4.md +33 -9
  87. package/hooks/_lib/hook-import-set.json +4 -3
  88. package/hooks/hooks-codex.json +1 -1
  89. package/hooks/hooks.json +1 -1
  90. package/hooks/on-stop.mjs +25 -4
  91. package/package.json +2 -2
  92. package/scripts/generate-codex-skills.mjs +246 -0
  93. package/scripts/generate-hook-import-set.mjs +51 -8
  94. package/scripts/lib/codex/plugin-contract.mjs +6 -0
  95. package/scripts/lib/config/host-paths.mjs +20 -4
  96. package/scripts/lib/gates/gate-full.mjs +7 -3
  97. package/scripts/lib/owner-config-banner.mjs +7 -9
  98. package/scripts/lib/owner-yaml.mjs +8 -1
  99. package/scripts/lib/plugin-update-banner.mjs +10 -2
  100. package/scripts/lib/reconcile/engine.mjs +38 -7
  101. package/scripts/lib/session-schema/constants.mjs +38 -11
  102. package/scripts/lib/session-start-probes.mjs +12 -0
  103. package/scripts/lib/telemetry/schema.mjs +39 -18
  104. package/scripts/lib/telemetry-flush-health-banner.mjs +211 -0
  105. package/scripts/lib/validate/check-codex-skills.mjs +191 -0
  106. package/scripts/lib/validate/check-owner-leakage.mjs +91 -55
  107. package/scripts/lib/validate/check-skill-links.mjs +37 -7
  108. package/scripts/lib/validate/check-test-git-config-target.mjs +192 -12
  109. package/scripts/lib/validate/check-unwired-features.mjs +163 -13
  110. package/scripts/lib/validate/confidential-names.mjs +95 -30
  111. package/scripts/lib/validate/repo-files.mjs +48 -14
  112. package/scripts/release.mjs +109 -18
  113. package/scripts/site-numbers.mjs +344 -8
  114. package/scripts/validate-plugin.mjs +3 -0
  115. package/skills/session-start/SKILL.md +2 -2
  116. package/skills/session-start/references/phase-4-ssot-environment-check.md +5 -0
  117. package/skills/vault-sync/SKILL.md +10 -0
@@ -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
 
@@ -121,12 +121,10 @@ export const SURFACES = [
121
121
  patterns: [/"version":\s*"(\d+\.\d+\.\d+)"/],
122
122
  },
123
123
  {
124
- // Root `plugin.json` — the agent-plugins.org 1.0.0 manifest, added in 4.0.0. It is a
125
- // FOURTH manifest carrying the version, and it was missing from this table on the 4.0.0
126
- // cut: `--set-version` bumped the other three and `validate-plugin` then failed with
127
- // "plugin.json version '3.24.0' != package.json version '4.0.0'". Exactly the incident
128
- // class the table exists for, caught one gate later than it should have been.
129
- file: 'plugin.json',
124
+ // Cursor's native manifest replaces the root Agent Plugins manifest so
125
+ // Codex can load its own component paths and cache version independently.
126
+ // It remains a required version surface for every release.
127
+ file: '.cursor-plugin/plugin.json',
130
128
  patterns: [/"version":\s*"(\d+\.\d+\.\d+)"/],
131
129
  },
132
130
  {
@@ -191,6 +189,14 @@ export const SURFACES = [
191
189
  patterns: [/data-metric="version"[^>]*>(\d+\.\d+\.\d+)</g],
192
190
  checkOnly: true,
193
191
  },
192
+ {
193
+ // The German landing page (2026-09-07 redesign) mirrors the EN metric cells
194
+ // one-to-one; same single writer (scripts/site-numbers.mjs --write), so it is
195
+ // checkOnly for the same reason as site/index.html above.
196
+ file: 'site/de/index.html',
197
+ patterns: [/data-metric="version"[^>]*>(\d+\.\d+\.\d+)</g],
198
+ checkOnly: true,
199
+ },
194
200
  {
195
201
  file: 'site/llms.txt',
196
202
  patterns: [/Version:\s*(\d+\.\d+\.\d+)/],
@@ -362,7 +368,18 @@ const INTENTIONAL_TEST_ASSET_PATHS = new Set([
362
368
  // tests/scripts/site-numbers.test.mjs, which forbids ANY vX.Y.Z and the current
363
369
  // package version outside a `data-metric` cell on EVERY shipped page, and
364
370
  // exempts exactly the lines marked `site-numbers:historical`.
365
- export const HISTORY_ALLOWLIST = /^(CHANGELOG\.md|README\.md|docs\/|tests\/|skills\/npm-publish\/|scripts\/release\.mjs|\.orchestrator\/|site\/leaderboard\.json|site\/guide\/index\.html|commands\/release\.md)/;
371
+ // 4.0.1 (measured 2026-09-07): the 4.0.0 sweep would have flagged five files whose only literal
372
+ // is PROSE HISTORY of the major ("moved out of agents/ in 4.0.0", "removed in 4.0.0",
373
+ // "releases 4.0.0 would have blocked on itself") — CLAUDE.md, its generated twin AGENTS.md,
374
+ // CONTRIBUTING.md, NOTICE and .husky/pre-push. None of them is a version SURFACE (no
375
+ // `"version":`, badge or `vX.Y.Z` form), so they join the history allowlist rather than being
376
+ // reworded to dodge the sweep.
377
+ // Four more prose-history files surfaced once the detail line stopped truncating at five hits
378
+ // (same 2026-09-07 sweep): site/llms-full.txt ("The v4.0.0 release REMOVES public surfaces" — its
379
+ // version SURFACE is checked separately by the SURFACES row, so the sweep on it is redundant),
380
+ // skills/architecture/references/domain-model.md ("Merged here in v4.0.0"), skills/autopilot/SKILL.md
381
+ // ("4.0.0 — see docs/migration-v4.md") and templates/_shared/journey-manifest.md ("Retired … in 4.0.0").
382
+ export const HISTORY_ALLOWLIST = /^(CHANGELOG\.md|README\.md|CLAUDE\.md|AGENTS\.md|CONTRIBUTING\.md|NOTICE|\.husky\/pre-push|docs\/|tests\/|skills\/npm-publish\/|skills\/architecture\/references\/domain-model\.md|skills\/autopilot\/SKILL\.md|templates\/_shared\/journey-manifest\.md|scripts\/release\.mjs|\.orchestrator\/|site\/leaderboard\.json|site\/guide\/index\.html|site\/llms-full\.txt|commands\/release\.md)/;
366
383
 
367
384
  /** Pure check over packed-entry lines. Returns violations: {name, line}[]. */
368
385
  export function checkLeakage(lines) {
@@ -443,28 +460,95 @@ export const MIN_PACKED_ENTRIES = 400;
443
460
  // ---------------------------------------------------------------------------
444
461
 
445
462
  /**
446
- * Is EVERY occurrence of `literal` on this line a dependency range (`^X.Y.Z` / `~X.Y.Z`)?
463
+ * A regex matching `literal` as a VERSION TOKEN, not as a substring.
464
+ *
465
+ * THE BUG (measured 2026-09-07, mid-release): the sweep matched the previous tag `4.0.0`
466
+ * inside `>=24.0.0` — `package.json`'s own engines field and a `scripts/lib/` string that
467
+ * quotes it — so a release could not be cut without either rewording an engines constraint or
468
+ * widening the allowlist over two files that carry no version surface at all. The boundary is
469
+ * therefore part of WHAT IS SWEPT FOR, not an allowlist row: `24.0.0`, `14.0.0` and `4.0.0.1`
470
+ * are different literals, at every path, forever.
471
+ *
472
+ * @param {string} literal — the previous release version
473
+ * @returns {RegExp} global regex; `4.0.0` matches only when not preceded by `[0-9.]` and not
474
+ * continued by a further numeric component (`(?!\.?[0-9])`).
475
+ */
476
+ function versionTokenRegex(literal) {
477
+ return new RegExp(`(?<![0-9.])${literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?!\\.?[0-9])`, 'g');
478
+ }
479
+
480
+ /**
481
+ * Is EVERY token occurrence of `literal` on this line a dependency range (`^X.Y.Z` / `~X.Y.Z`)?
447
482
  *
448
483
  * One bare occurrence anywhere on the line is enough to call the whole line drift — a comment
449
- * that also happens to mention a ranged dep must not be excused by that mention.
484
+ * that also happens to mention a ranged dep must not be excused by that mention. Occurrences are
485
+ * counted with {@link versionTokenRegex}, so `>=24.0.0` is not an occurrence of `4.0.0` here
486
+ * either — otherwise a line pinning `^4.0.0` beside an engines constraint would read as drift.
450
487
  *
451
488
  * @param {string} content — the matching line's text
452
- * @param {string} literal — the previous release version, matched literally
489
+ * @param {string} literal — the previous release version, matched as a token
453
490
  * @returns {boolean}
454
491
  */
455
492
  export function isDependencyRangeOnly(content, literal) {
456
- let from = 0;
493
+ const re = versionTokenRegex(literal);
457
494
  let seen = 0;
458
- for (;;) {
459
- const at = content.indexOf(literal, from);
460
- if (at === -1) break;
495
+ for (let m = re.exec(content); m; m = re.exec(content)) {
461
496
  seen += 1;
462
- if (!(at > 0 && (content[at - 1] === '^' || content[at - 1] === '~'))) return false;
463
- from = at + literal.length;
497
+ const before = m.index > 0 ? content[m.index - 1] : '';
498
+ if (before !== '^' && before !== '~') return false;
464
499
  }
465
500
  return seen > 0;
466
501
  }
467
502
 
503
+ /** Lockfiles whose dependency entries are third-party history, never our surface. */
504
+ const LOCKFILE_BASENAMES = new Set(['package-lock.json', 'npm-shrinkwrap.json']);
505
+
506
+ /** Code files in which a `//`, `*`, `/*` or `#` line is comment prose, never a version surface. */
507
+ const CODE_COMMENT_EXTENSIONS = new Set(['.mjs', '.js', '.cjs', '.ts', '.sh']);
508
+
509
+ /**
510
+ * Is this `path:line:content` row version HISTORY rather than a stale surface?
511
+ *
512
+ * Three classes, all measured on the 4.0.1 cut (2026-09-07) as FALSE POSITIVES of the raw
513
+ * substring sweep, and all expressed as PREDICATES for the same reason the range carve-out
514
+ * above is one: a per-path allowlist row fixes the instance and leaves the class open.
515
+ *
516
+ * 1. **Dependency range** — `^X.Y.Z` / `~X.Y.Z` (see {@link isDependencyRangeOnly}).
517
+ * 2. **Lockfile dependency entry** — `package-lock.json` carried 72 hits for `4.0.0`, every one
518
+ * a third-party package version or an engines range. Our OWN entry there is still swept: the
519
+ * root package's `"version"` line, which npm writes in the `packages[""]` record at the top
520
+ * of the file. NAMED CEILING (BV-004): "at the top" is read as `line <= 20`, which covers
521
+ * every lockfileVersion-3 file npm writes today (the root record starts at line 5). Revisit
522
+ * trigger: a lockfile whose root `"version"` sits below line 20 — then key on the enclosing
523
+ * JSON path instead of the line number.
524
+ * 3. **Comment prose in a code file** — `// (pre-4.0.0 checkouts, …)` and a `* since 4.0.0`
525
+ * docblock line. No SURFACES pattern is ever a comment (every one is `"version": "X.Y.Z"`,
526
+ * `vX.Y.Z` or a badge), so excusing comment lines cannot mask a stale surface.
527
+ *
528
+ * @param {string} file — repo-relative path
529
+ * @param {number} line — 1-based line number
530
+ * @param {string} content — the matching line's text
531
+ * @param {string} prevTag — the previous release literal being swept for
532
+ * @returns {boolean} true = history, skip the row
533
+ */
534
+ function isHistoryRow(file, line, content, prevTag) {
535
+ if (!versionTokenRegex(prevTag).test(content)) return true;
536
+ if (isDependencyRangeOnly(content, prevTag)) return true;
537
+ const base = file.split('/').pop();
538
+ if (LOCKFILE_BASENAMES.has(base)) {
539
+ const isRootVersionLine = line <= 20 && new RegExp(`"version":\\s*"${prevTag.replace(/\./g, '\\.')}"`).test(content);
540
+ return !isRootVersionLine;
541
+ }
542
+ const dot = base.lastIndexOf('.');
543
+ const ext = dot === -1 ? '' : base.slice(dot);
544
+ if (CODE_COMMENT_EXTENSIONS.has(ext)) {
545
+ const trimmed = content.trim();
546
+ if (trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*')) return true;
547
+ if (ext === '.sh' && trimmed.startsWith('#')) return true;
548
+ }
549
+ return false;
550
+ }
551
+
468
552
  /**
469
553
  * Drift sweep verdict over a grep-shaped result (`-l` file list or `-n` line hits).
470
554
  *
@@ -488,6 +572,13 @@ export function isDependencyRangeOnly(content, literal) {
488
572
  * written as one (see SURFACES above: every pattern is an exact `"version": "X.Y.Z"`, `vX.Y.Z`
489
573
  * or badge form), so the predicate cannot mask a stale surface.
490
574
  *
575
+ * THREE FURTHER CLASSES are history for the same reason, all measured 2026-09-07 mid-release
576
+ * and all decided by {@link isHistoryRow}, never by an allowlist row: a literal that is only a
577
+ * SUBSTRING of a longer version (`4.0.0` inside `>=24.0.0`), a `package-lock.json` /
578
+ * `npm-shrinkwrap.json` row that is not the root package's own `"version"` line (72 of the 72
579
+ * lockfile hits on that cut were third-party), and COMMENT PROSE in a code file
580
+ * (`// (pre-4.0.0 checkouts, …)`). See that function for each one's ceiling.
581
+ *
491
582
  * Accepts BOTH `git grep` output shapes. A bare `path` (from `-l`) carries no content and is
492
583
  * therefore always a hit — the fail-closed reading, unchanged, and the shape {@link collectDriftHits}
493
584
  * emits for a file it could not READ. `path:line:content` (from `-n`)
@@ -510,13 +601,13 @@ export function evaluateDriftSweep(grep, prevTag, allowlist) {
510
601
  const withContent = row.match(/^(.+?):(\d+):(.*)$/);
511
602
  const file = withContent ? withContent[1] : row;
512
603
  if (allowlist.test(file)) continue;
513
- if (withContent && isDependencyRangeOnly(withContent[3], prevTag)) continue;
604
+ if (withContent && isHistoryRow(file, Number(withContent[2]), withContent[3], prevTag)) continue;
514
605
  if (!hits.includes(file)) hits.push(file);
515
606
  }
516
607
  return {
517
608
  ok: hits.length === 0,
518
609
  detail: hits.length
519
- ? `still carry ${prevTag}: ${hits.slice(0, 5).join(', ')}`
610
+ ? `still carry ${prevTag} (${hits.length} file(s)): ${hits.slice(0, 5).join(', ')}${hits.length > 5 ? ', …' : ''}`
520
611
  : `no file outside the allowlist still carries ${prevTag} (tracked + untracked-not-ignored)`,
521
612
  };
522
613
  }