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
|
@@ -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
|
+
}
|
|
@@ -751,7 +751,7 @@ function rawConfidentialNamesKeyState(ownerYamlPath) {
|
|
|
751
751
|
* be called only from runScan() — NOT at module top-level. This module is
|
|
752
752
|
* dual-mode (CLI entry point AND importable library, `isMain` guard); a top-level
|
|
753
753
|
* fs read would fire on every library import (e.g. pseudonym-map.mjs importing
|
|
754
|
-
* isOwnerLeakySegment).
|
|
754
|
+
* isOwnerLeakySegment). inspectConfidentialNames caches per path, so calling this
|
|
755
755
|
* once per scan reads the file at most once.
|
|
756
756
|
*
|
|
757
757
|
* STANDALONE-SAFE: the two host-local helper modules are DYNAMICALLY imported
|
|
@@ -803,7 +803,26 @@ async function getConfidentialNamePatterns() {
|
|
|
803
803
|
};
|
|
804
804
|
} catch (err) {
|
|
805
805
|
if (err?.code === 'ERR_MODULE_NOT_FOUND' && isMissingDirectSibling(err)) {
|
|
806
|
-
// (a) standalone single-file vendoring
|
|
806
|
+
// (a) standalone single-file vendoring. The degrade is documented — but it
|
|
807
|
+
// must not swallow a CP11 that the operator DID configure (GitLab #1262).
|
|
808
|
+
// No helper resolved here, so the only configuration signal still readable
|
|
809
|
+
// is the raw env var: a literal mirror of ENV_KEYS['confidential-names-file']
|
|
810
|
+
// in scripts/lib/config/host-paths.mjs, which cannot be imported on this
|
|
811
|
+
// branch by construction.
|
|
812
|
+
//
|
|
813
|
+
// LIMIT (named, not hidden): an owner.yaml-configured names file is
|
|
814
|
+
// STRUCTURALLY undetectable in a standalone copy — resolveOwnerYamlPath()
|
|
815
|
+
// lives in the very module chain that failed to resolve, so there is no way
|
|
816
|
+
// to learn that owner.yaml even exists, let alone what it configures. Only
|
|
817
|
+
// the env route can fail closed here; the owner.yaml route degrades inert.
|
|
818
|
+
const envNamesPath = process.env.SO_CONFIDENTIAL_NAMES_FILE;
|
|
819
|
+
if (typeof envNamesPath === 'string' && envNamesPath.trim() !== '') {
|
|
820
|
+
return {
|
|
821
|
+
patterns: [],
|
|
822
|
+
disabledReason:
|
|
823
|
+
'CP11 configured via SO_CONFIDENTIAL_NAMES_FILE but the confidential-names helpers are not resolvable (standalone copy) — failing closed',
|
|
824
|
+
};
|
|
825
|
+
}
|
|
807
826
|
return {
|
|
808
827
|
patterns: [],
|
|
809
828
|
inertWarn: 'CP11 inert — confidential-names helpers not resolvable (standalone copy)',
|
|
@@ -815,35 +834,60 @@ async function getConfidentialNamePatterns() {
|
|
|
815
834
|
|
|
816
835
|
try {
|
|
817
836
|
const { loadHostPaths, resolveHostPath } = helpers.hostPaths;
|
|
818
|
-
const {
|
|
819
|
-
const {
|
|
837
|
+
const { inspectConfidentialNames } = helpers.confidentialNames;
|
|
838
|
+
const { resolveOwnerYamlPath } = helpers.ownerYaml;
|
|
839
|
+
|
|
840
|
+
// ONE owner.yaml load. Since #1251 loadHostPaths() passes the loader's own
|
|
841
|
+
// health (source / reason / droppedSections) straight through, so the
|
|
842
|
+
// env>owner.yaml>default precedence and the load's diagnosis come from the
|
|
843
|
+
// same read — no second loadOwnerConfig() call to recover what was discarded.
|
|
844
|
+
const hostCtx = loadHostPaths();
|
|
845
|
+
|
|
846
|
+
// FAIL-CLOSED on an unknowable load (LOW-2). `loadHostPaths` has its own
|
|
847
|
+
// defensive catch that swallows a THROWING owner loader and returns
|
|
848
|
+
// `{ ownerConfig: undefined, env, source: undefined, reason: undefined }`.
|
|
849
|
+
// Without this branch that shape resolves `namesPath` to '' , fires no
|
|
850
|
+
// reason branch, and returns `{ patterns: [] }` — a silent PASS.
|
|
851
|
+
// Only the throw produces all-undefined: `loadOwnerConfig` returns
|
|
852
|
+
// `{ config: getDefaults(), source: 'defaults', errors: [] }` for a
|
|
853
|
+
// genuinely ABSENT owner.yaml (owner-yaml.mjs:587-590) and `source:
|
|
854
|
+
// 'defaults'` with a `reason` for every read/parse failure — so a missing
|
|
855
|
+
// file keeps taking the normal path below. Latent today (the loader
|
|
856
|
+
// returns on every error path), load-bearing if that ever changes.
|
|
857
|
+
if (
|
|
858
|
+
hostCtx.ownerConfig === undefined &&
|
|
859
|
+
hostCtx.source === undefined &&
|
|
860
|
+
hostCtx.reason === undefined
|
|
861
|
+
) {
|
|
862
|
+
return {
|
|
863
|
+
patterns: [],
|
|
864
|
+
disabledReason:
|
|
865
|
+
'CP11 could not determine whether a confidential-names file is configured (owner config loader failed) — failing closed',
|
|
866
|
+
};
|
|
867
|
+
}
|
|
820
868
|
|
|
821
|
-
|
|
822
|
-
// env>owner.yaml>default precedence is unchanged while the load's own health
|
|
823
|
-
// (reason / droppedSections) stays visible here.
|
|
824
|
-
const owner = loadOwnerConfig();
|
|
825
|
-
const namesPath = resolveHostPath('confidential-names-file', '', loadHostPaths({ ownerLoader: () => owner }));
|
|
869
|
+
const namesPath = resolveHostPath('confidential-names-file', '', hostCtx);
|
|
826
870
|
|
|
827
871
|
if (typeof namesPath !== 'string' || namesPath.trim() === '') {
|
|
828
872
|
// Nothing resolves a names file. That is either (b) — genuinely
|
|
829
873
|
// unconfigured — or a state in which the answer is UNKNOWABLE because the
|
|
830
874
|
// owner.yaml that would carry it could not be read. Unknowable is (c).
|
|
831
875
|
if (existsSync(resolveOwnerYamlPath())) {
|
|
832
|
-
if (
|
|
876
|
+
if (hostCtx.reason === 'yaml-parser-missing') {
|
|
833
877
|
return {
|
|
834
878
|
patterns: [],
|
|
835
879
|
disabledReason:
|
|
836
880
|
"owner.yaml exists but 'js-yaml' is not installed, so a configured confidential-names-file cannot be resolved (run 'npm install')",
|
|
837
881
|
};
|
|
838
882
|
}
|
|
839
|
-
if (
|
|
883
|
+
if (hostCtx.reason === 'unparseable') {
|
|
840
884
|
return {
|
|
841
885
|
patterns: [],
|
|
842
886
|
disabledReason:
|
|
843
887
|
'owner.yaml exists but could not be parsed, so a configured confidential-names-file cannot be resolved',
|
|
844
888
|
};
|
|
845
889
|
}
|
|
846
|
-
if (
|
|
890
|
+
if (hostCtx.droppedSections?.some((d) => d.section === 'paths')) {
|
|
847
891
|
// The paths: section was replaced by its default because SOME key in it
|
|
848
892
|
// is invalid — which says nothing yet about whether CP11 was configured.
|
|
849
893
|
// Re-read the RAW file for that one key (the loader does not expose it on
|
|
@@ -875,15 +919,37 @@ async function getConfidentialNamePatterns() {
|
|
|
875
919
|
return { patterns: [] }; // (b) the ~99% default — inactive, silent.
|
|
876
920
|
}
|
|
877
921
|
|
|
878
|
-
// A names file IS configured.
|
|
879
|
-
//
|
|
880
|
-
//
|
|
881
|
-
//
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
922
|
+
// A names file IS configured. Since #1250 the loader REPORTS the class it
|
|
923
|
+
// used to collapse into `null`, so the verdict reads straight off `status` —
|
|
924
|
+
// no second read of the file to re-derive it. Only `[]` ('empty') is an
|
|
925
|
+
// operator choice (inactive, silent); 'missing'/'malformed'/'all-dropped'
|
|
926
|
+
// mean a configured guard cannot run, which is (c) and fails closed. The
|
|
927
|
+
// reasons carry no path (see above). `inspectConfidentialNames` is the
|
|
928
|
+
// discriminated entry point; `loadConfidentialNames` keeps its 4.0.0
|
|
929
|
+
// `string[] | null` shape for external deep-importers and cannot express
|
|
930
|
+
// this distinction.
|
|
931
|
+
const { status, names } = inspectConfidentialNames({ namesPath });
|
|
932
|
+
if (status === 'missing') {
|
|
933
|
+
return { patterns: [], disabledReason: 'a confidential-names-file is configured but does not exist' };
|
|
934
|
+
}
|
|
935
|
+
if (status === 'malformed') {
|
|
936
|
+
return {
|
|
937
|
+
patterns: [],
|
|
938
|
+
disabledReason: 'the configured confidential-names-file is unreadable or not a JSON array of names',
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
if (status === 'all-dropped') {
|
|
942
|
+
// W4 finding F3 (fail-open): the file listed entries and validation rejected
|
|
943
|
+
// every one of them (non-strings, blanks, over-long). That is a CORRUPTED
|
|
944
|
+
// list, not the `[]` opt-out — CP11 would run with zero patterns and report
|
|
945
|
+
// the clean verdict it never earned. Same class as 'malformed'.
|
|
946
|
+
return {
|
|
947
|
+
patterns: [],
|
|
948
|
+
disabledReason:
|
|
949
|
+
'every entry in the configured confidential-names-file was rejected as invalid or oversized',
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
if (status !== 'ok') return { patterns: [] }; // 'empty' — the operator's `[]` opt-out.
|
|
887
953
|
return { patterns: names.map((name) => new RegExp(`\\b${escapeRegex(name)}\\b`, 'i')) };
|
|
888
954
|
} catch (err) {
|
|
889
955
|
// The helpers resolved but something below threw. CP11 cannot run — fail closed.
|
|
@@ -891,40 +957,6 @@ async function getConfidentialNamePatterns() {
|
|
|
891
957
|
}
|
|
892
958
|
}
|
|
893
959
|
|
|
894
|
-
/**
|
|
895
|
-
* Classify a CONFIGURED confidential-names file as usable or not.
|
|
896
|
-
*
|
|
897
|
-
* `loadConfidentialNames()` collapses "file missing / unreadable / malformed /
|
|
898
|
-
* not an array" and "well-formed but empty" into one `null` return, which is
|
|
899
|
-
* exactly the distinction CP11's fail-closed verdict turns on. Rather than widen
|
|
900
|
-
* that module's contract (it has four other consumers of its `null`), this reads
|
|
901
|
-
* the file once more for classification only. It never returns file CONTENT — the
|
|
902
|
-
* reason string carries the error CLASS alone, matching the confidential-names
|
|
903
|
-
* privacy invariant and the no-path rule above.
|
|
904
|
-
*
|
|
905
|
-
* @param {string} namesPath
|
|
906
|
-
* @returns {string|null} a reason string when unusable, null when usable.
|
|
907
|
-
*/
|
|
908
|
-
function classifyNamesFile(namesPath) {
|
|
909
|
-
let raw;
|
|
910
|
-
try {
|
|
911
|
-
if (!existsSync(namesPath)) {
|
|
912
|
-
return 'a confidential-names-file is configured but does not exist';
|
|
913
|
-
}
|
|
914
|
-
raw = readFileSync(namesPath, 'utf8');
|
|
915
|
-
} catch (err) {
|
|
916
|
-
return `the configured confidential-names-file is unreadable (${err?.name ?? 'Error'})`;
|
|
917
|
-
}
|
|
918
|
-
try {
|
|
919
|
-
if (!Array.isArray(JSON.parse(raw))) {
|
|
920
|
-
return 'the configured confidential-names-file is not a JSON array';
|
|
921
|
-
}
|
|
922
|
-
} catch (err) {
|
|
923
|
-
return `the configured confidential-names-file contains malformed JSON (${err?.name ?? 'Error'})`;
|
|
924
|
-
}
|
|
925
|
-
return null;
|
|
926
|
-
}
|
|
927
|
-
|
|
928
960
|
// ---------------------------------------------------------------------------
|
|
929
961
|
// Text-scan extension allowlist (spec A.2)
|
|
930
962
|
// ---------------------------------------------------------------------------
|
|
@@ -1077,6 +1109,10 @@ function isAllowlisted(relPath, line) {
|
|
|
1077
1109
|
'site/index.html',
|
|
1078
1110
|
'site/impressum/index.html',
|
|
1079
1111
|
'site/datenschutz/index.html',
|
|
1112
|
+
// The German landing page (2026-09-07 redesign) carries the same publisher
|
|
1113
|
+
// identity (JSON-LD @id + footer rel="author") as site/index.html. Exact
|
|
1114
|
+
// path, never a site/de/ prefix — site/guide/index.html stays outside.
|
|
1115
|
+
'site/de/index.html',
|
|
1080
1116
|
]);
|
|
1081
1117
|
const inAllowlistedFile = ALLOWLISTED_URL_PATHS.has(norm);
|
|
1082
1118
|
|
|
@@ -43,13 +43,36 @@
|
|
|
43
43
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
44
44
|
import { join, dirname, resolve, relative, sep } from 'node:path';
|
|
45
45
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Surfaces whose markdown is instruction, i.e. read and acted on.
|
|
48
|
+
*
|
|
49
|
+
* `docs/` joined the list in #1258 after the annotate-before-widen step the sibling
|
|
50
|
+
* `check-skill-script-paths.mjs` ran for #1208: a read-only re-scan (`dirs: ['docs']`,
|
|
51
|
+
* 2026-09-07) returned 92 files / 111 relative links / 8 findings — six of them GitLab
|
|
52
|
+
* `-/issues/N` renderer links now carved out by `SKIP_TARGET`, two of them genuinely dangling
|
|
53
|
+
* links to PRDs that had been archived to the private Meta-Vault. Both classes were resolved
|
|
54
|
+
* before the widening, so the widening itself lands at 0 findings.
|
|
55
|
+
*/
|
|
56
|
+
export const SCAN_DIRS = Object.freeze(['skills', 'commands', 'agents', '.claude/rules', 'docs']);
|
|
48
57
|
|
|
49
58
|
/** Path segments that end the walk: vendored or machine-owned trees, never instruction. */
|
|
50
59
|
export const PRUNE_DIRS = new Set(['node_modules', '.git', 'coverage', 'dist', '.pnpm']);
|
|
51
60
|
|
|
52
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Targets this checker never resolves against the filesystem.
|
|
63
|
+
*
|
|
64
|
+
* The `-/issues/N` · `-/merge_requests/N` branch is a GitLab RENDERER convention, not a link
|
|
65
|
+
* defect: `../../../-/issues/174` is how a doc nested two levels deep points at the project's
|
|
66
|
+
* issue tracker, and GitLab resolves it correctly in its own Markdown view. It leaves the repo
|
|
67
|
+
* by construction, so a filesystem checker can only ever call it dangling. Measured 2026-09-07:
|
|
68
|
+
* 6 of the 8 `docs/` findings were exactly this shape, all in `docs/owner-config-schema.md`.
|
|
69
|
+
*
|
|
70
|
+
* The branch is anchored at BOTH ends. Left-unanchored it would still be a carve-out, but
|
|
71
|
+
* right-unanchored it swallowed every path that merely PASSES THROUGH such a segment —
|
|
72
|
+
* `docs/-/issues/12/../../secrets.md` is a genuinely dangling link and was SKIPped. The
|
|
73
|
+
* optional trailing `#note_123` is kept because GitLab issue links legitimately carry one.
|
|
74
|
+
*/
|
|
75
|
+
const SKIP_TARGET = /^(https?:|mailto:|#|\/)|(^|\/)-\/(issues|merge_requests)\/\d+(#[\w-]*)?$/i;
|
|
53
76
|
const LINK_RE = /\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
|
|
54
77
|
const INLINE_CODE_RE = /(`+)[^`]*?\1/g;
|
|
55
78
|
|
|
@@ -89,11 +112,15 @@ function fencedLineNumbers(text) {
|
|
|
89
112
|
* these four directories would also be checked — there are none, and one would be a finding worth
|
|
90
113
|
* seeing anyway.
|
|
91
114
|
*
|
|
115
|
+
* @param {string} repoRoot
|
|
116
|
+
* @param {{dirs?: readonly string[]}} [options] `dirs` defaults to {@link SCAN_DIRS}; it exists so
|
|
117
|
+
* an annotate-before-widen dry-run over a candidate surface needs no re-implementation of this
|
|
118
|
+
* predicate (same shape as `scanSkillScriptPaths({ dirs })`).
|
|
92
119
|
* @returns {string[]} repo-relative paths, sorted for stable output
|
|
93
120
|
*/
|
|
94
|
-
export function listMarkdown(repoRoot) {
|
|
121
|
+
export function listMarkdown(repoRoot, { dirs = SCAN_DIRS } = {}) {
|
|
95
122
|
const out = [];
|
|
96
|
-
for (const dir of
|
|
123
|
+
for (const dir of dirs) {
|
|
97
124
|
const abs = join(repoRoot, dir);
|
|
98
125
|
let entries;
|
|
99
126
|
try { entries = readdirSync(abs, { recursive: true, withFileTypes: true }); } catch { continue; }
|
|
@@ -112,12 +139,15 @@ export function listMarkdown(repoRoot) {
|
|
|
112
139
|
}
|
|
113
140
|
|
|
114
141
|
/**
|
|
142
|
+
* @param {string} [repoRoot]
|
|
143
|
+
* @param {{dirs?: readonly string[]}} [options] `dirs` defaults to {@link SCAN_DIRS} — see
|
|
144
|
+
* {@link listMarkdown}.
|
|
115
145
|
* @returns {{ok: boolean, checked: number, files: number, findings: Array<{file: string, line: number, target: string}>}}
|
|
116
146
|
*/
|
|
117
|
-
export function checkSkillLinks(repoRoot = process.cwd()) {
|
|
147
|
+
export function checkSkillLinks(repoRoot = process.cwd(), { dirs = SCAN_DIRS } = {}) {
|
|
118
148
|
const findings = [];
|
|
119
149
|
let checked = 0;
|
|
120
|
-
const files = listMarkdown(repoRoot);
|
|
150
|
+
const files = listMarkdown(repoRoot, { dirs });
|
|
121
151
|
|
|
122
152
|
for (const rel of files) {
|
|
123
153
|
const abs = join(repoRoot, rel);
|