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
|
@@ -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? })
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
33
|
-
*
|
|
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 → (
|
|
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
|
-
*
|
|
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}
|
|
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[]
|
|
101
|
+
* @returns {{ status: 'ok'|'empty'|'all-dropped'|'malformed', names: string[] }}
|
|
72
102
|
*/
|
|
73
|
-
function parseNames(raw,
|
|
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
|
|
82
|
-
// ("WARN messages … NEVER
|
|
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 ${
|
|
115
|
+
`WARN validate/confidential-names: malformed JSON in ${label} (${err.name}); CP11 inactive\n`,
|
|
85
116
|
);
|
|
86
|
-
return
|
|
117
|
+
return { status: 'malformed', names: [] };
|
|
87
118
|
}
|
|
88
119
|
|
|
89
120
|
if (!Array.isArray(parsed)) {
|
|
90
121
|
d.warn(
|
|
91
|
-
`WARN validate/confidential-names: ${
|
|
122
|
+
`WARN validate/confidential-names: ${label} must be a JSON array of strings; ignoring the list (CP11 inactive)\n`,
|
|
92
123
|
);
|
|
93
|
-
return
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/scripts/release.mjs
CHANGED
|
@@ -121,12 +121,10 @@ export const SURFACES = [
|
|
|
121
121
|
patterns: [/"version":\s*"(\d+\.\d+\.\d+)"/],
|
|
122
122
|
},
|
|
123
123
|
{
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
463
|
-
|
|
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 &&
|
|
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
|
}
|