session-orchestrator 4.0.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +3 -2
  4. package/.codex-plugin/skills/architecture/SKILL.md +20 -0
  5. package/.codex-plugin/skills/autopilot/SKILL.md +21 -0
  6. package/.codex-plugin/skills/autopilot/agents/openai.yaml +5 -0
  7. package/.codex-plugin/skills/bootstrap/SKILL.md +22 -0
  8. package/.codex-plugin/skills/bootstrap/agents/openai.yaml +5 -0
  9. package/.codex-plugin/skills/brainstorm/SKILL.md +22 -0
  10. package/.codex-plugin/skills/brainstorm/agents/openai.yaml +5 -0
  11. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +17 -0
  12. package/.codex-plugin/skills/close/SKILL.md +21 -0
  13. package/.codex-plugin/skills/close/agents/openai.yaml +5 -0
  14. package/.codex-plugin/skills/convergence-monitoring/SKILL.md +24 -0
  15. package/.codex-plugin/skills/debug/SKILL.md +21 -0
  16. package/.codex-plugin/skills/debug/agents/openai.yaml +5 -0
  17. package/.codex-plugin/skills/discovery/SKILL.md +21 -0
  18. package/.codex-plugin/skills/discovery/agents/openai.yaml +5 -0
  19. package/.codex-plugin/skills/dispatcher/SKILL.md +21 -0
  20. package/.codex-plugin/skills/dispatcher/agents/openai.yaml +5 -0
  21. package/.codex-plugin/skills/docs-orchestrator/SKILL.md +20 -0
  22. package/.codex-plugin/skills/ecosystem-health/SKILL.md +22 -0
  23. package/.codex-plugin/skills/eli5/SKILL.md +21 -0
  24. package/.codex-plugin/skills/eli5/agents/openai.yaml +5 -0
  25. package/.codex-plugin/skills/eval/SKILL.md +21 -0
  26. package/.codex-plugin/skills/eval/agents/openai.yaml +5 -0
  27. package/.codex-plugin/skills/evolve/SKILL.md +21 -0
  28. package/.codex-plugin/skills/evolve/agents/openai.yaml +5 -0
  29. package/.codex-plugin/skills/frontmatter-guard/SKILL.md +17 -0
  30. package/.codex-plugin/skills/gitlab-ops/SKILL.md +22 -0
  31. package/.codex-plugin/skills/gitlab-portfolio/SKILL.md +17 -0
  32. package/.codex-plugin/skills/go/SKILL.md +22 -0
  33. package/.codex-plugin/skills/go/agents/openai.yaml +5 -0
  34. package/.codex-plugin/skills/grill/SKILL.md +21 -0
  35. package/.codex-plugin/skills/grill/agents/openai.yaml +5 -0
  36. package/.codex-plugin/skills/harness-audit/SKILL.md +19 -0
  37. package/.codex-plugin/skills/harness-audit/agents/openai.yaml +5 -0
  38. package/.codex-plugin/skills/hook-development/SKILL.md +17 -0
  39. package/.codex-plugin/skills/mcp-builder/SKILL.md +17 -0
  40. package/.codex-plugin/skills/memory-cleanup/SKILL.md +21 -0
  41. package/.codex-plugin/skills/memory-cleanup/agents/openai.yaml +5 -0
  42. package/.codex-plugin/skills/mode-selector/SKILL.md +19 -0
  43. package/.codex-plugin/skills/npm-publish/SKILL.md +18 -0
  44. package/.codex-plugin/skills/peekaboo-driver/SKILL.md +20 -0
  45. package/.codex-plugin/skills/persona-panel/SKILL.md +22 -0
  46. package/.codex-plugin/skills/persona-panel/agents/openai.yaml +5 -0
  47. package/.codex-plugin/skills/plan/SKILL.md +22 -0
  48. package/.codex-plugin/skills/plan/agents/openai.yaml +5 -0
  49. package/.codex-plugin/skills/playwright-driver/SKILL.md +22 -0
  50. package/.codex-plugin/skills/portfolio/SKILL.md +21 -0
  51. package/.codex-plugin/skills/portfolio/agents/openai.yaml +5 -0
  52. package/.codex-plugin/skills/quality-gates/SKILL.md +22 -0
  53. package/.codex-plugin/skills/reconcile/SKILL.md +21 -0
  54. package/.codex-plugin/skills/reconcile/agents/openai.yaml +5 -0
  55. package/.codex-plugin/skills/release/SKILL.md +22 -0
  56. package/.codex-plugin/skills/release/agents/openai.yaml +5 -0
  57. package/.codex-plugin/skills/remote-offload/SKILL.md +22 -0
  58. package/.codex-plugin/skills/repo-audit/SKILL.md +19 -0
  59. package/.codex-plugin/skills/repo-audit/agents/openai.yaml +5 -0
  60. package/.codex-plugin/skills/session/SKILL.md +21 -0
  61. package/.codex-plugin/skills/session/agents/openai.yaml +5 -0
  62. package/.codex-plugin/skills/session-end/SKILL.md +22 -0
  63. package/.codex-plugin/skills/session-plan/SKILL.md +22 -0
  64. package/.codex-plugin/skills/session-start/SKILL.md +22 -0
  65. package/.codex-plugin/skills/spinout/SKILL.md +21 -0
  66. package/.codex-plugin/skills/spinout/agents/openai.yaml +5 -0
  67. package/.codex-plugin/skills/sunset-review/SKILL.md +21 -0
  68. package/.codex-plugin/skills/sunset-review/agents/openai.yaml +5 -0
  69. package/.codex-plugin/skills/templates-ack/SKILL.md +21 -0
  70. package/.codex-plugin/skills/templates-ack/agents/openai.yaml +5 -0
  71. package/.codex-plugin/skills/test/SKILL.md +21 -0
  72. package/.codex-plugin/skills/test/agents/openai.yaml +5 -0
  73. package/.codex-plugin/skills/test-runner/SKILL.md +22 -0
  74. package/.codex-plugin/skills/tmux-layout/SKILL.md +23 -0
  75. package/.codex-plugin/skills/using-orchestrator/SKILL.md +19 -0
  76. package/.codex-plugin/skills/vault-mirror/SKILL.md +17 -0
  77. package/.codex-plugin/skills/vault-sync/SKILL.md +17 -0
  78. package/.codex-plugin/skills/wave-executor/SKILL.md +22 -0
  79. package/.codex-plugin/skills/write-executable-plan/SKILL.md +24 -0
  80. package/{plugin.json → .cursor-plugin/plugin.json} +5 -2
  81. package/CHANGELOG.md +190 -1
  82. package/README.md +26 -18
  83. package/docs/codex-setup.md +43 -9
  84. package/docs/components.md +3 -2
  85. package/docs/instruction-delivery.md +12 -5
  86. package/docs/migration-v4.md +33 -9
  87. package/hooks/_lib/hook-import-set.json +4 -3
  88. package/hooks/hooks-codex.json +1 -1
  89. package/hooks/hooks.json +1 -1
  90. package/hooks/on-stop.mjs +25 -4
  91. package/package.json +2 -2
  92. package/scripts/generate-codex-skills.mjs +246 -0
  93. package/scripts/generate-hook-import-set.mjs +51 -8
  94. package/scripts/lib/codex/plugin-contract.mjs +6 -0
  95. package/scripts/lib/config/host-paths.mjs +20 -4
  96. package/scripts/lib/gates/gate-full.mjs +7 -3
  97. package/scripts/lib/owner-config-banner.mjs +7 -9
  98. package/scripts/lib/owner-yaml.mjs +8 -1
  99. package/scripts/lib/plugin-update-banner.mjs +10 -2
  100. package/scripts/lib/reconcile/engine.mjs +38 -7
  101. package/scripts/lib/session-schema/constants.mjs +38 -11
  102. package/scripts/lib/session-start-probes.mjs +12 -0
  103. package/scripts/lib/telemetry/schema.mjs +39 -18
  104. package/scripts/lib/telemetry-flush-health-banner.mjs +211 -0
  105. package/scripts/lib/validate/check-codex-skills.mjs +191 -0
  106. package/scripts/lib/validate/check-owner-leakage.mjs +91 -55
  107. package/scripts/lib/validate/check-skill-links.mjs +37 -7
  108. package/scripts/lib/validate/check-test-git-config-target.mjs +192 -12
  109. package/scripts/lib/validate/check-unwired-features.mjs +163 -13
  110. package/scripts/lib/validate/confidential-names.mjs +95 -30
  111. package/scripts/lib/validate/repo-files.mjs +48 -14
  112. package/scripts/release.mjs +109 -18
  113. package/scripts/site-numbers.mjs +344 -8
  114. package/scripts/validate-plugin.mjs +3 -0
  115. package/skills/session-start/SKILL.md +2 -2
  116. package/skills/session-start/references/phase-4-ssot-environment-check.md +5 -0
  117. package/skills/vault-sync/SKILL.md +10 -0
@@ -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). loadConfidentialNames caches per path, so calling this
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 — the documented degrade.
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 { loadConfidentialNames } = helpers.confidentialNames;
819
- const { loadOwnerConfig, resolveOwnerYamlPath } = helpers.ownerYaml;
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
- // Load owner.yaml ONCE and hand the same result to loadHostPaths, so the
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 (owner.reason === 'yaml-parser-missing') {
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 (owner.reason === 'unparseable') {
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 (owner.droppedSections?.some((d) => d.section === 'paths')) {
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. loadConfidentialNames() returns null for BOTH
879
- // "unusable" and "deliberately empty list", so classify the file first — an
880
- // empty list is an operator choice (inactive, silent), a missing/malformed
881
- // one is (c).
882
- const unusable = classifyNamesFile(namesPath);
883
- if (unusable) return { patterns: [], disabledReason: unusable };
884
-
885
- const names = loadConfidentialNames({ namesPath });
886
- if (!names) return { patterns: [] }; // readable, well-formed, zero usable entries.
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
- /** Surfaces whose markdown is instruction, i.e. read and acted on. */
47
- export const SCAN_DIRS = Object.freeze(['skills', 'commands', 'agents', '.claude/rules']);
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
- const SKIP_TARGET = /^(https?:|mailto:|#|\/)/i;
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 SCAN_DIRS) {
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);