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.
- 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 +213 -1
- package/README.md +70 -58
- package/commands/release.md +4 -4
- 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/ci/assert-coverage-green.mjs +100 -0
- 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/events.mjs +3 -3
- 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/project-hygiene.mjs +182 -6
- package/scripts/lib/reconcile/engine.mjs +38 -7
- package/scripts/lib/session-identity/own-session.mjs +24 -13
- 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 +107 -62
- 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/lib/vault-mirror/render-sessions.mjs +8 -1
- package/scripts/release.mjs +141 -29
- 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
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* telemetry-flush-health-banner.mjs — #1255
|
|
3
|
+
*
|
|
4
|
+
* Surfaces the ONE telemetry-flush outcome nobody could see: a flush the
|
|
5
|
+
* sandbox guard refused.
|
|
6
|
+
*
|
|
7
|
+
* ## Why this module exists
|
|
8
|
+
*
|
|
9
|
+
* `scripts/lib/telemetry/sync.mjs` fails CLOSED since 4.0.0 — when its
|
|
10
|
+
* environment probe cannot complete its checks it returns
|
|
11
|
+
* `{ sandbox: true, reason: 'sandbox:probe-failed' }` and sends nothing. That
|
|
12
|
+
* is the right default, and `sync.mjs` stays deliberately SILENT about it (it
|
|
13
|
+
* runs inside the SessionEnd teardown budget, where a banner has no reader).
|
|
14
|
+
*
|
|
15
|
+
* The refusal therefore reaches exactly one sink: `hooks/on-session-end.mjs`
|
|
16
|
+
* emits `orchestrator.telemetry.flush` with `{ outcome, reason }` into
|
|
17
|
+
* `.orchestrator/metrics/events.jsonl`. Measured 2026-09-07 (W1-D6 census):
|
|
18
|
+
* NO consumer read that reason back out — a repo could refuse every flush for
|
|
19
|
+
* weeks and the only visible difference from a healthy repo was an absence.
|
|
20
|
+
* That is `.claude/rules/host-resources.md` § HR-105 ("a rule you cannot
|
|
21
|
+
* falsify is not a rule") applied to the flush path.
|
|
22
|
+
*
|
|
23
|
+
* This probe closes the loop on the NEXT session start, which is the first
|
|
24
|
+
* moment a human is actually looking: it reads the LAST flush record and warns
|
|
25
|
+
* when that record is a sandbox refusal. Newest-wins by construction — a later
|
|
26
|
+
* successful flush silently clears the warning, so the banner reports the
|
|
27
|
+
* CURRENT state of the channel, never its history.
|
|
28
|
+
*
|
|
29
|
+
* Never throws. Never mutates input. No network.
|
|
30
|
+
*
|
|
31
|
+
* @module scripts/lib/telemetry-flush-health-banner
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { existsSync, openSync, readSync, fstatSync, closeSync } from 'node:fs';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Bytes of `events.jsonl` read from the END of the file.
|
|
39
|
+
*
|
|
40
|
+
* The ledger is append-only and grows without bound (33k+ records in this repo
|
|
41
|
+
* alone), so a full `readFileSync` on a SessionStart hook's critical path is
|
|
42
|
+
* the wrong shape — this probe shares a 2s budget with ~18 siblings.
|
|
43
|
+
*
|
|
44
|
+
* NAMED CEILING (BV-004): a flush record older than the last 64 KB of the
|
|
45
|
+
* ledger is invisible to this probe and reads as "no flush recorded" → `null`
|
|
46
|
+
* (silent). At the observed record width (~120-400 bytes) that window holds
|
|
47
|
+
* roughly 150-500 events, and a flush is emitted once per session close — so
|
|
48
|
+
* the window covers the last flush unless ~150+ events landed after it without
|
|
49
|
+
* one, which cannot happen inside a single session's teardown.
|
|
50
|
+
* REVISIT TRIGGER: if the per-session event volume ever exceeds ~150 records
|
|
51
|
+
* between two session ends, raise this or index the ledger — do not silently
|
|
52
|
+
* accept the truncation.
|
|
53
|
+
*/
|
|
54
|
+
export const TAIL_BYTES = 64 * 1024;
|
|
55
|
+
|
|
56
|
+
/** The event name `hooks/on-session-end.mjs` emits for every flush attempt. */
|
|
57
|
+
const FLUSH_EVENT = 'orchestrator.telemetry.flush';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Bound an untrusted ledger `reason` before it is interpolated into a terminal
|
|
61
|
+
* banner: strip C0/DEL control bytes (a record could carry an ANSI escape) and
|
|
62
|
+
* cap the length. Mirrors the sibling bound in
|
|
63
|
+
* `scripts/lib/session-start-probes.mjs` (`.slice(0, 200)` on a probe error).
|
|
64
|
+
*
|
|
65
|
+
* @param {unknown} reason
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
function sanitizeReason(reason) {
|
|
69
|
+
// eslint-disable-next-line no-control-regex -- stripping control bytes IS the job
|
|
70
|
+
return String(reason).replace(/[\x00-\x1f\x7f]/g, '').slice(0, 120);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read the last `TAIL_BYTES` of a file as UTF-8.
|
|
75
|
+
*
|
|
76
|
+
* Returns a DISCRIMINATED result rather than `string|null`, because "the
|
|
77
|
+
* ledger could not be read" and "the ledger holds nothing alarming" are the
|
|
78
|
+
* two states this probe exists to keep apart (HR-105): collapsing an EACCES
|
|
79
|
+
* onto `null` renders an unreadable channel as a healthy one.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} file
|
|
82
|
+
* @returns {{text: string} | {missing: true} | {error: string}}
|
|
83
|
+
*/
|
|
84
|
+
function readTail(file) {
|
|
85
|
+
let fd;
|
|
86
|
+
try {
|
|
87
|
+
fd = openSync(file, 'r');
|
|
88
|
+
const { size } = fstatSync(fd);
|
|
89
|
+
const length = Math.min(size, TAIL_BYTES);
|
|
90
|
+
const start = size - length;
|
|
91
|
+
const buf = Buffer.allocUnsafe(length);
|
|
92
|
+
let read = 0;
|
|
93
|
+
while (read < length) {
|
|
94
|
+
const n = readSync(fd, buf, read, length - read, start + read);
|
|
95
|
+
if (n <= 0) break;
|
|
96
|
+
read += n;
|
|
97
|
+
}
|
|
98
|
+
return { text: buf.subarray(0, read).toString('utf8') };
|
|
99
|
+
} catch (err) {
|
|
100
|
+
if (err?.code === 'ENOENT') return { missing: true };
|
|
101
|
+
return { error: typeof err?.code === 'string' ? err.code : 'EUNKNOWN' };
|
|
102
|
+
} finally {
|
|
103
|
+
if (fd !== undefined) {
|
|
104
|
+
try {
|
|
105
|
+
closeSync(fd);
|
|
106
|
+
} catch {
|
|
107
|
+
/* best effort */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Classify the telemetry-flush channel from the LAST recorded flush event.
|
|
115
|
+
*
|
|
116
|
+
* Scans the ledger tail BACKWARDS and stops at the first
|
|
117
|
+
* `orchestrator.telemetry.flush` record it can parse — newest wins, so an
|
|
118
|
+
* older refusal followed by a successful flush produces no banner.
|
|
119
|
+
*
|
|
120
|
+
* Returns `null` (silent) when: `repoRoot` is not a string, the ledger is
|
|
121
|
+
* ABSENT, no flush record sits in the tail window, or the newest flush
|
|
122
|
+
* record's `reason` does not start with `sandbox:`. A malformed line is
|
|
123
|
+
* SKIPPED (it is not a flush record we can read), never a throw — the ledger's
|
|
124
|
+
* last line is routinely a partial write when another process is appending.
|
|
125
|
+
*
|
|
126
|
+
* An UNREADABLE ledger (EACCES/EIO/…, i.e. anything other than "not there") is
|
|
127
|
+
* NOT silent: it returns a `ledger-unreadable` warning. "The guard state
|
|
128
|
+
* cannot be confirmed" must not display like "the last flush was fine" — that
|
|
129
|
+
* collapse is the exact HR-105 defect this module's header cites as its reason
|
|
130
|
+
* to exist, one layer down.
|
|
131
|
+
*
|
|
132
|
+
* This probe reports the LAST outcome regardless of its age (no clock seam):
|
|
133
|
+
* a refusal does not expire on its own — only a later successful flush clears
|
|
134
|
+
* it.
|
|
135
|
+
*
|
|
136
|
+
* @param {{repoRoot: string}} opts
|
|
137
|
+
* @returns {null | {severity: 'warn', reason: string, message: string}}
|
|
138
|
+
*/
|
|
139
|
+
export function checkTelemetryFlushHealth({ repoRoot } = {}) {
|
|
140
|
+
try {
|
|
141
|
+
if (!repoRoot || typeof repoRoot !== 'string') return null;
|
|
142
|
+
|
|
143
|
+
const file = path.join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl');
|
|
144
|
+
if (!existsSync(file)) return null;
|
|
145
|
+
|
|
146
|
+
const tail = readTail(file);
|
|
147
|
+
if (tail.missing) return null;
|
|
148
|
+
if (tail.error) {
|
|
149
|
+
return {
|
|
150
|
+
severity: 'warn',
|
|
151
|
+
reason: 'ledger-unreadable',
|
|
152
|
+
message:
|
|
153
|
+
'⚠ Telemetry: flush-health unknown — .orchestrator/metrics/events.jsonl ' +
|
|
154
|
+
`could not be read (${sanitizeReason(tail.error)}); ` +
|
|
155
|
+
'the sandbox guard state cannot be confirmed.',
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (!tail.text) return null;
|
|
159
|
+
|
|
160
|
+
const lines = tail.text.split('\n');
|
|
161
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
162
|
+
const line = lines[i].trim();
|
|
163
|
+
if (!line) continue;
|
|
164
|
+
|
|
165
|
+
let record;
|
|
166
|
+
try {
|
|
167
|
+
record = JSON.parse(line);
|
|
168
|
+
} catch {
|
|
169
|
+
// Malformed (or a tail-truncated first line) — not readable as a flush
|
|
170
|
+
// record, so it cannot be the newest one. Keep scanning backwards.
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (!record || typeof record !== 'object') continue;
|
|
174
|
+
if (record.event !== FLUSH_EVENT) continue;
|
|
175
|
+
|
|
176
|
+
// First flush record found scanning backwards = the newest one.
|
|
177
|
+
const rawReason = record.reason;
|
|
178
|
+
if (typeof rawReason !== 'string' || !rawReason.startsWith('sandbox:')) return null;
|
|
179
|
+
|
|
180
|
+
// The ledger is an untrusted string source for banner purposes: bound the
|
|
181
|
+
// reason on BOTH surfaces (the field a consumer may render itself, and
|
|
182
|
+
// the message we render) rather than only on the one we happen to own.
|
|
183
|
+
const reason = sanitizeReason(rawReason);
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
severity: 'warn',
|
|
187
|
+
reason,
|
|
188
|
+
message:
|
|
189
|
+
`⚠ Telemetry: last flush refused by the sandbox guard (${reason}) — ` +
|
|
190
|
+
'the guard could not complete its checks and failed closed; no ping was sent. ' +
|
|
191
|
+
'See docs/telemetry.md § Sandbox guard.',
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return null;
|
|
196
|
+
} catch {
|
|
197
|
+
// Defensive catch-all — a banner probe must never throw (fail-open).
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Convenience renderer: the banner message string, or `''` when silent.
|
|
204
|
+
*
|
|
205
|
+
* @param {{repoRoot: string}} opts
|
|
206
|
+
* @returns {string}
|
|
207
|
+
*/
|
|
208
|
+
export function renderBanner({ repoRoot } = {}) {
|
|
209
|
+
const result = checkTelemetryFlushHealth({ repoRoot });
|
|
210
|
+
return result ? result.message : '';
|
|
211
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Independently inspect Codex discovery artifacts; generator freshness alone
|
|
3
|
+
// cannot detect a generator that consistently emits an invalid skill contract.
|
|
4
|
+
import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
|
|
5
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
import yaml from 'js-yaml';
|
|
8
|
+
|
|
9
|
+
const SKILL_ROOT = '.codex-plugin/skills';
|
|
10
|
+
const FRONTMATTER_KEYS = new Set([
|
|
11
|
+
'name', 'description', 'license', 'compatibility', 'metadata', 'allowed-tools',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
/** @param {unknown} value */
|
|
15
|
+
function isRecord(value) {
|
|
16
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Read emitted artifacts against their source contract without importing the
|
|
21
|
+
* generator or comparing its prose. Paths are resolved as installed files.
|
|
22
|
+
* @param {string} pluginRoot
|
|
23
|
+
* @returns {{skills: string[], violations: string[]}}
|
|
24
|
+
*/
|
|
25
|
+
export function validateCodexSkills(pluginRoot) {
|
|
26
|
+
const root = realpathSync(pluginRoot);
|
|
27
|
+
const violations = [];
|
|
28
|
+
const sources = new Map();
|
|
29
|
+
const skills = [];
|
|
30
|
+
const label = (file) => relative(root, file).split(sep).join('/');
|
|
31
|
+
|
|
32
|
+
function text(file) {
|
|
33
|
+
try {
|
|
34
|
+
const actual = realpathSync(file);
|
|
35
|
+
if (!actual.startsWith(`${root}${sep}`)) {
|
|
36
|
+
violations.push(`${label(file)}: outside the plugin package`);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return readFileSync(file, 'utf8');
|
|
40
|
+
} catch (error) {
|
|
41
|
+
violations.push(`${label(file)}: missing or unreadable (${error.code ?? error.message})`);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function mapping(raw, file) {
|
|
47
|
+
if (raw === null) return null;
|
|
48
|
+
try {
|
|
49
|
+
const parsed = yaml.load(raw, { schema: yaml.CORE_SCHEMA });
|
|
50
|
+
if (!isRecord(parsed)) throw new Error('expected a YAML mapping');
|
|
51
|
+
return parsed;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
violations.push(`${label(file)}: invalid YAML (${error.message.split('\n')[0]})`);
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function frontmatter(file) {
|
|
59
|
+
const raw = text(file);
|
|
60
|
+
if (raw === null) return null;
|
|
61
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw);
|
|
62
|
+
if (!match) {
|
|
63
|
+
violations.push(`${label(file)}: missing YAML frontmatter`);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const fm = mapping(match[1], file);
|
|
67
|
+
return fm && { fm, body: raw.slice(match[0].length) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Codex's Agent Plugins loader fixes skills to ./skills; its native overlay
|
|
71
|
+
// cannot override skills or version. A valid native manifest alone is not
|
|
72
|
+
// enough when a standard root manifest intercepts discovery (CLI 0.153.3/4).
|
|
73
|
+
const rootManifestFile = join(root, 'plugin.json');
|
|
74
|
+
if (existsSync(rootManifestFile)) {
|
|
75
|
+
try {
|
|
76
|
+
const manifest = JSON.parse(text(rootManifestFile));
|
|
77
|
+
if (typeof manifest?.$schema === 'string' && manifest.$schema.startsWith('https://agent-plugins.org/schemas/')) {
|
|
78
|
+
violations.push('Root Agent Plugins manifest overrides native Codex skills and cache version; use the native harness manifests');
|
|
79
|
+
}
|
|
80
|
+
} catch (error) {
|
|
81
|
+
violations.push(`Root plugin manifest: invalid JSON (${error.message})`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const manifestFile = join(root, '.codex-plugin/plugin.json');
|
|
86
|
+
try {
|
|
87
|
+
const raw = text(manifestFile);
|
|
88
|
+
const manifest = raw === null ? null : JSON.parse(raw);
|
|
89
|
+
if (manifest?.skills !== `./${SKILL_ROOT}/`) {
|
|
90
|
+
violations.push(`Codex manifest skills must register only ./${SKILL_ROOT}/`);
|
|
91
|
+
}
|
|
92
|
+
if (!Array.isArray(manifest?.commands) || manifest.commands.length !== 0) {
|
|
93
|
+
violations.push('Codex manifest commands must be an explicit empty array to disable automatic command migration');
|
|
94
|
+
}
|
|
95
|
+
} catch (error) {
|
|
96
|
+
violations.push(`Codex manifest: invalid JSON (${error.message})`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Skill first, command second: a command owns the public entry on overlap.
|
|
100
|
+
for (const kind of ['skill', 'command']) {
|
|
101
|
+
const directory = join(root, kind === 'skill' ? 'skills' : 'commands');
|
|
102
|
+
if (!existsSync(directory)) {
|
|
103
|
+
violations.push(`${label(directory)}: canonical source directory is missing`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
107
|
+
const name = kind === 'command' ? entry.name.replace(/\.md$/, '') : entry.name;
|
|
108
|
+
if (name.startsWith('.') || name.startsWith('_')) continue;
|
|
109
|
+
if (kind === 'command' && !entry.name.endsWith('.md')) continue;
|
|
110
|
+
const file = kind === 'command' ? join(directory, entry.name) : join(directory, name, 'SKILL.md');
|
|
111
|
+
if (kind === 'skill' && !existsSync(file)) continue;
|
|
112
|
+
const source = frontmatter(file);
|
|
113
|
+
sources.set(name, { kind, file, fm: source?.fm });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (sources.size === 0) violations.push('No canonical commands or skills found');
|
|
117
|
+
|
|
118
|
+
const generated = join(root, SKILL_ROOT);
|
|
119
|
+
const actualNames = existsSync(generated)
|
|
120
|
+
? readdirSync(generated, { withFileTypes: true })
|
|
121
|
+
.filter((entry) => (entry.isDirectory() || entry.isSymbolicLink()) && existsSync(join(generated, entry.name, 'SKILL.md')))
|
|
122
|
+
.map((entry) => entry.name)
|
|
123
|
+
: [];
|
|
124
|
+
for (const name of [...new Set([...sources.keys(), ...actualNames])].sort()) {
|
|
125
|
+
const source = sources.get(name);
|
|
126
|
+
if (!source) {
|
|
127
|
+
violations.push(`${SKILL_ROOT}/${name}: no canonical source`);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const file = join(generated, name, 'SKILL.md');
|
|
131
|
+
const artifact = frontmatter(file);
|
|
132
|
+
if (!artifact) continue;
|
|
133
|
+
skills.push(name);
|
|
134
|
+
const { fm, body } = artifact;
|
|
135
|
+
if (fm.name !== name) violations.push(`${label(file)}: name must equal ${name}`);
|
|
136
|
+
if (typeof fm.description !== 'string' || !fm.description.trim() || fm.description.length > 1024) {
|
|
137
|
+
violations.push(`${label(file)}: description must be a non-empty string of at most 1024 characters`);
|
|
138
|
+
}
|
|
139
|
+
for (const key of Object.keys(fm)) {
|
|
140
|
+
if (!FRONTMATTER_KEYS.has(key)) violations.push(`${label(file)}: unsupported frontmatter key ${key}`);
|
|
141
|
+
}
|
|
142
|
+
if (fm.metadata !== undefined && (!isRecord(fm.metadata) || Object.values(fm.metadata).some((value) => typeof value !== 'string'))) {
|
|
143
|
+
violations.push(`${label(file)}: metadata must contain string values`);
|
|
144
|
+
}
|
|
145
|
+
const links = [...body.matchAll(/\]\(([^\s)]+)\)/g)].map((match) => match[1]);
|
|
146
|
+
if (!links.some((target) => !target.includes(':') && !target.startsWith('/') && resolve(dirname(file), target) === source.file)) {
|
|
147
|
+
violations.push(`${label(file)}: missing canonical link to ${label(source.file)}`);
|
|
148
|
+
}
|
|
149
|
+
if (source.kind === 'command' && source.fm) {
|
|
150
|
+
const disabled = source.fm['disable-model-invocation'];
|
|
151
|
+
if (disabled !== undefined && typeof disabled !== 'boolean') {
|
|
152
|
+
violations.push(`${label(source.file)}: disable-model-invocation must be boolean`);
|
|
153
|
+
}
|
|
154
|
+
const policyFile = join(generated, name, 'agents/openai.yaml');
|
|
155
|
+
const sidecar = mapping(text(policyFile), policyFile);
|
|
156
|
+
if (sidecar && (typeof sidecar.policy?.allow_implicit_invocation !== 'boolean'
|
|
157
|
+
|| sidecar.policy.allow_implicit_invocation !== (disabled !== true))) {
|
|
158
|
+
violations.push(`${label(policyFile)}: allow_implicit_invocation must preserve the command's boolean policy`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { skills, violations };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function main(pluginRoot) {
|
|
166
|
+
if (!pluginRoot) throw new Error('Usage: check-codex-skills.mjs <plugin-root>');
|
|
167
|
+
console.log('--- Check: Codex command skills and generated freshness ---');
|
|
168
|
+
const { skills, violations } = validateCodexSkills(pluginRoot);
|
|
169
|
+
if (violations.length === 0) {
|
|
170
|
+
// Invoke the function directly: a child CLI that silently skips its entry
|
|
171
|
+
// point must never turn a missing freshness comparison into success.
|
|
172
|
+
const { generateCodexSurface } = await import('../../generate-codex-skills.mjs');
|
|
173
|
+
const fresh = generateCodexSurface({ pluginRoot, check: true });
|
|
174
|
+
if (!fresh.ok || !Array.isArray(fresh.files) || fresh.files.length === 0) {
|
|
175
|
+
violations.push(...(fresh.drift ?? []), ...(fresh.errors ?? []));
|
|
176
|
+
if (violations.length === 0) violations.push('Codex generator did not report a successful artifact comparison');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
for (const violation of violations) console.log(` FAIL: ${violation}`);
|
|
180
|
+
if (violations.length === 0) console.log(` PASS: ${skills.length} unique Codex skills, command policies and generated files in sync`);
|
|
181
|
+
console.log(`Results: ${violations.length === 0 ? 1 : 0} passed, ${violations.length} failed`);
|
|
182
|
+
if (violations.length) console.log('Remedy: node scripts/generate-codex-skills.mjs');
|
|
183
|
+
process.exitCode = violations.length ? 1 : 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
187
|
+
main(process.argv[2]).catch((error) => {
|
|
188
|
+
console.error(`FAIL: Codex skills check: ${error.message}`);
|
|
189
|
+
process.exitCode = 1;
|
|
190
|
+
});
|
|
191
|
+
}
|