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
package/hooks/on-stop.mjs
CHANGED
|
@@ -48,7 +48,7 @@ import { AGENT_ID_RE, resolveSubagentSidecar } from './_lib/subagent-paths.mjs';
|
|
|
48
48
|
if (!shouldRunHook('on-stop')) process.exit(0);
|
|
49
49
|
|
|
50
50
|
import { emitEvent } from '../scripts/lib/events.mjs';
|
|
51
|
-
import { getProjectDir } from '../scripts/lib/platform.mjs';
|
|
51
|
+
import { detectPlatform, getProjectDir } from '../scripts/lib/platform.mjs';
|
|
52
52
|
import { parseSessionId } from '../scripts/lib/session-id.mjs';
|
|
53
53
|
import { heartbeat, logSweepEvent } from '../scripts/lib/session-registry.mjs';
|
|
54
54
|
import { readLock, updateHeartbeat } from '../scripts/lib/session-lock.mjs';
|
|
@@ -772,7 +772,7 @@ async function main() {
|
|
|
772
772
|
await handleStop(input);
|
|
773
773
|
} finally {
|
|
774
774
|
// terminalSequence is only meaningful for Stop (session-level) events.
|
|
775
|
-
process.stdout.write(buildTerminalSequenceJson());
|
|
775
|
+
process.stdout.write(buildTerminalSequenceJson(detectPlatform()));
|
|
776
776
|
}
|
|
777
777
|
}
|
|
778
778
|
}
|
|
@@ -781,15 +781,36 @@ async function main() {
|
|
|
781
781
|
// terminal notification
|
|
782
782
|
// ---------------------------------------------------------------------------
|
|
783
783
|
|
|
784
|
+
/**
|
|
785
|
+
* Display label per harness, keyed exactly like PLATFORM_UPDATE_INSTRUCTIONS in
|
|
786
|
+
* `scripts/lib/plugin-update-banner.mjs`.
|
|
787
|
+
* @type {Record<string, string>}
|
|
788
|
+
*/
|
|
789
|
+
const PLATFORM_NOTIFY_LABELS = {
|
|
790
|
+
claude: 'Claude Code',
|
|
791
|
+
codex: 'Codex',
|
|
792
|
+
cursor: 'Cursor',
|
|
793
|
+
pi: 'Pi',
|
|
794
|
+
};
|
|
795
|
+
|
|
784
796
|
/**
|
|
785
797
|
* Emit a cross-platform desktop notification via the CC 2.1.141+ terminalSequence
|
|
786
798
|
* output field. Supports OSC 9 (iTerm2, Windows Terminal, WezTerm, ConEmu) and
|
|
787
799
|
* OSC 777 (Ghostty, urxvt, Warp). Both sequences are emitted together; unsupported
|
|
788
800
|
* terminals silently ignore. Returns the JSON string to write to stdout.
|
|
801
|
+
* Pure: the caller resolves the platform, so this stays unit-testable.
|
|
802
|
+
* @param {string} [platform] - resolved harness key ('claude'|'codex'|'cursor'|'pi')
|
|
789
803
|
* @returns {string}
|
|
790
804
|
*/
|
|
791
|
-
function buildTerminalSequenceJson() {
|
|
792
|
-
|
|
805
|
+
function buildTerminalSequenceJson(platform) {
|
|
806
|
+
// #1254: the notification LABEL follows the harness the session runs on.
|
|
807
|
+
// This is NOT in tension with the "Do NOT add a platform-detecting deregister
|
|
808
|
+
// branch here" note above — that note is scoped to DEREGISTRATION (the
|
|
809
|
+
// two-teardown-paths shape Epic #583 removed); a display label has no
|
|
810
|
+
// teardown semantics at all. Keys mirror PLATFORM_UPDATE_INSTRUCTIONS in
|
|
811
|
+
// scripts/lib/plugin-update-banner.mjs; unknown/undefined falls back to
|
|
812
|
+
// 'Claude Code' (the same signal-free default detectPlatform() returns).
|
|
813
|
+
const title = PLATFORM_NOTIFY_LABELS[platform] ?? 'Claude Code';
|
|
793
814
|
const body = 'Session stopped — your turn';
|
|
794
815
|
const osc9 = `\x1b]9;${title}: ${body}\x07`;
|
|
795
816
|
const osc777 = `\x1b]777;notify;${title};${body}\x07`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "session-orchestrator",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "Loop engineering for AI coding agents — turn ad-hoc sessions into a repeatable research → plan → wave-execute → close loop with verification gates. Runs on Claude Code, Codex CLI, Cursor, and Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://session-orchestrator.com",
|
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
".mcp.json",
|
|
26
26
|
"AGENTS.md",
|
|
27
27
|
"CHANGELOG.md",
|
|
28
|
-
"plugin.json",
|
|
29
28
|
"pi/",
|
|
30
29
|
"skills/",
|
|
31
30
|
"agents/",
|
|
@@ -39,6 +38,7 @@
|
|
|
39
38
|
"assets/",
|
|
40
39
|
".claude-plugin/",
|
|
41
40
|
".codex-plugin/",
|
|
41
|
+
".cursor-plugin/",
|
|
42
42
|
".agents/",
|
|
43
43
|
".cursor/",
|
|
44
44
|
".orchestrator/policy/",
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Repository CI only: requires the checkout's Vitest config and dev dependencies.
|
|
3
|
+
// Test-result JSON does not prove coverage. Check the structured coverage summary
|
|
4
|
+
// against the same global percentage thresholds Vitest uses before writing a marker.
|
|
5
|
+
// The caller removes both reports and the marker before starting the current run.
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import config from '../../vitest.config.mjs';
|
|
9
|
+
|
|
10
|
+
const metrics = ['lines', 'functions', 'statements', 'branches'];
|
|
11
|
+
const [summaryPath = 'coverage/coverage-summary.json', xmlPath = 'coverage/cobertura-coverage.xml'] = process.argv.slice(2);
|
|
12
|
+
|
|
13
|
+
function requireValid(condition, reason) {
|
|
14
|
+
if (!condition) throw new Error(reason);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Check completeness of the XML emitted by the configured Istanbul reporter.
|
|
18
|
+
// This is an artifact check, not a DTD/schema evaluator; never fetch the DOCTYPE.
|
|
19
|
+
function checkCobertura(xml, total) {
|
|
20
|
+
const stack = [];
|
|
21
|
+
let root;
|
|
22
|
+
let hasClassLine = false;
|
|
23
|
+
const parts = xml.match(/<[^>]*>|[^<]+/g) ?? [];
|
|
24
|
+
requireValid(parts.join('') === xml, 'malformed Cobertura artifact');
|
|
25
|
+
for (const part of parts) {
|
|
26
|
+
if (/^<\?xml\s[^<>]*\?>$|^<!DOCTYPE coverage SYSTEM "[^"<>]*">$/.test(part)) {
|
|
27
|
+
requireValid(!root, 'unexpected Cobertura declaration');
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (!part.startsWith('<')) {
|
|
31
|
+
requireValid(!part.trim() || stack.at(-1) === 'source', 'unexpected Cobertura content');
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const tag = part.match(/^<(\/?)([A-Za-z][\w.-]*)((?:\s+[\w:-]+="[^"<>]*")*)\s*(\/?)>$/);
|
|
35
|
+
requireValid(tag, 'malformed Cobertura tag');
|
|
36
|
+
const [, closing, name, attributes, selfClosing] = tag;
|
|
37
|
+
if (closing) {
|
|
38
|
+
requireValid(!attributes && !selfClosing && stack.pop() === name, 'unbalanced Cobertura tags');
|
|
39
|
+
} else {
|
|
40
|
+
const entries = [...attributes.matchAll(/([\w:-]+)="([^"]*)"/g)].map((match) => [match[1], match[2]]);
|
|
41
|
+
requireValid(new Set(entries.map(([key]) => key)).size === entries.length, 'duplicate Cobertura attributes');
|
|
42
|
+
const values = Object.fromEntries(entries);
|
|
43
|
+
if (stack.length === 0) {
|
|
44
|
+
requireValid(!root && name === 'coverage' && !selfClosing, 'invalid Cobertura root');
|
|
45
|
+
root = values;
|
|
46
|
+
}
|
|
47
|
+
const path = [...stack, name].join('/');
|
|
48
|
+
if (path === 'coverage/packages/package/classes/class') {
|
|
49
|
+
requireValid(typeof values.filename === 'string' && values.filename.trim(), 'Cobertura class is missing a filename');
|
|
50
|
+
}
|
|
51
|
+
if (path === 'coverage/packages/package/classes/class/lines/line') {
|
|
52
|
+
requireValid(/^\d+$/.test(values.number) && Number(values.number) > 0
|
|
53
|
+
&& /^\d+$/.test(values.hits), 'invalid Cobertura line record');
|
|
54
|
+
hasClassLine = true;
|
|
55
|
+
}
|
|
56
|
+
if (!selfClosing) stack.push(name);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
requireValid(root && stack.length === 0, 'empty or incomplete Cobertura artifact');
|
|
60
|
+
requireValid(total.lines.total === 0 || hasClassLine, 'Cobertura artifact contains no class line records');
|
|
61
|
+
for (const [attribute, expected] of Object.entries({
|
|
62
|
+
'lines-valid': total.lines.total,
|
|
63
|
+
'lines-covered': total.lines.covered,
|
|
64
|
+
'line-rate': total.lines.pct / 100,
|
|
65
|
+
'branches-valid': total.branches.total,
|
|
66
|
+
'branches-covered': total.branches.covered,
|
|
67
|
+
'branch-rate': total.branches.pct / 100,
|
|
68
|
+
})) {
|
|
69
|
+
requireValid(root[attribute] !== undefined && root[attribute] !== ''
|
|
70
|
+
&& Number(root[attribute]) === expected, `Cobertura ${attribute} disagrees with coverage summary`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const thresholds = config.test?.coverage?.thresholds;
|
|
76
|
+
requireValid(thresholds && Object.keys(thresholds).every((key) => metrics.includes(key)),
|
|
77
|
+
'unsupported coverage thresholds: expected global percentage thresholds');
|
|
78
|
+
const { total } = JSON.parse(readFileSync(summaryPath, 'utf8'));
|
|
79
|
+
requireValid(total && typeof total === 'object', 'missing coverage totals');
|
|
80
|
+
for (const metric of metrics) {
|
|
81
|
+
const threshold = thresholds[metric];
|
|
82
|
+
requireValid(Number.isFinite(threshold) && threshold >= 0 && threshold <= 100,
|
|
83
|
+
`missing or invalid configured ${metric} threshold`);
|
|
84
|
+
const data = total[metric];
|
|
85
|
+
requireValid(data && ['total', 'covered', 'skipped'].every((key) => Number.isSafeInteger(data[key]) && data[key] >= 0)
|
|
86
|
+
&& data.total > 0 && data.covered <= data.total && data.skipped <= data.total
|
|
87
|
+
&& Number.isFinite(data.pct) && data.pct >= 0 && data.pct <= 100,
|
|
88
|
+
`missing or invalid ${metric} coverage data`);
|
|
89
|
+
// Istanbul truncates percentages to two decimals; compare the reported
|
|
90
|
+
// percentage (Vitest's threshold input) only after checking its counters.
|
|
91
|
+
const percentage = Math.floor((100000 * data.covered / data.total) / 10) / 100;
|
|
92
|
+
requireValid(data.pct === percentage, `inconsistent ${metric} coverage percentage`);
|
|
93
|
+
requireValid(data.pct >= threshold, `${metric} coverage ${data.pct}% is below configured ${threshold}%`);
|
|
94
|
+
}
|
|
95
|
+
checkCobertura(readFileSync(xmlPath, 'utf8'), total);
|
|
96
|
+
console.log(`[ci] coverage verified: ${metrics.map((metric) => `${metric}=${total[metric].pct}%`).join(' ')}`);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
console.error(`[ci] coverage NOT VERIFIED: ${error.message}`);
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Generate the Codex discovery surface from commands/ and skills/.
|
|
4
|
+
* Commands own overlapping public names; canonical workflow bodies stay in place.
|
|
5
|
+
* Usage: node scripts/generate-codex-skills.mjs [--plugin-root <dir>] [--check] [--json]
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { dirname, join, resolve } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import yaml from 'js-yaml';
|
|
11
|
+
import { toPortableFrontmatter } from './generate-agents-skills.mjs';
|
|
12
|
+
|
|
13
|
+
const OUTPUT = '.codex-plugin/skills';
|
|
14
|
+
const MARKER = 'Generated by scripts/generate-codex-skills.mjs; do not edit.';
|
|
15
|
+
|
|
16
|
+
/** @typedef {{name: string, path: string, command: boolean, fm: Record<string, unknown>, policy?: boolean}} Source */
|
|
17
|
+
|
|
18
|
+
const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
19
|
+
|
|
20
|
+
/** @param {unknown} fm @param {string} name @param {string} path */
|
|
21
|
+
function validateFrontmatter(fm, name, path) {
|
|
22
|
+
const fail = (message) => { throw new Error(`${path}: ${message}`); };
|
|
23
|
+
if (!isRecord(fm)) fail('YAML frontmatter must be a mapping');
|
|
24
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name) || name.length > 64) fail('name must be kebab-case, at most 64 characters');
|
|
25
|
+
if (Object.hasOwn(fm, 'name') && fm.name !== name) fail('name must match its file or directory name');
|
|
26
|
+
if (typeof fm.description !== 'string' || !fm.description.trim() || fm.description.trim().length > 1024) {
|
|
27
|
+
fail('description must be a non-empty string of at most 1024 characters');
|
|
28
|
+
}
|
|
29
|
+
for (const key of ['disable-model-invocation', 'user-invocable']) {
|
|
30
|
+
if (Object.hasOwn(fm, key) && typeof fm[key] !== 'boolean') fail(`${key} must be a boolean`);
|
|
31
|
+
}
|
|
32
|
+
for (const key of ['argument-hint', 'license', 'compatibility']) {
|
|
33
|
+
if (Object.hasOwn(fm, key) && typeof fm[key] !== 'string') fail(`${key} must be a string`);
|
|
34
|
+
}
|
|
35
|
+
if (Object.hasOwn(fm, 'metadata') && !isRecord(fm.metadata)) fail('metadata must be a mapping');
|
|
36
|
+
for (const key of ['allowed-tools', 'tools']) {
|
|
37
|
+
if (!Object.hasOwn(fm, key)) continue;
|
|
38
|
+
const value = fm[key];
|
|
39
|
+
if (!(typeof value === 'string' || (Array.isArray(value) && value.every((item) => typeof item === 'string' && item.trim())))) {
|
|
40
|
+
fail(`${key} must be a string or an array of non-empty strings`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Existing native policy is meaningful; Claude's user-invocable flag is not a substitute. */
|
|
46
|
+
function readNativePolicy(root, name) {
|
|
47
|
+
const path = `skills/${name}/agents/openai.yaml`;
|
|
48
|
+
if (!existsSync(join(root, path))) return undefined;
|
|
49
|
+
let native;
|
|
50
|
+
try { native = yaml.load(readFileSync(join(root, path), 'utf8'), { schema: yaml.CORE_SCHEMA }); }
|
|
51
|
+
catch (error) { throw new Error(`${path}: invalid YAML: ${error.message}`, { cause: error }); }
|
|
52
|
+
if (!isRecord(native) || (Object.hasOwn(native, 'policy') && !isRecord(native.policy))) {
|
|
53
|
+
throw new Error(`${path}: native skill metadata and policy must be mappings`);
|
|
54
|
+
}
|
|
55
|
+
if (!native.policy || !Object.hasOwn(native.policy, 'allow_implicit_invocation')) return undefined;
|
|
56
|
+
if (typeof native.policy.allow_implicit_invocation !== 'boolean') {
|
|
57
|
+
throw new Error(`${path}: policy.allow_implicit_invocation must be a boolean`);
|
|
58
|
+
}
|
|
59
|
+
return native.policy.allow_implicit_invocation;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {string} root @returns {Source[]} */
|
|
63
|
+
function readSources(root) {
|
|
64
|
+
/** @type {Source[]} */
|
|
65
|
+
const sources = [];
|
|
66
|
+
for (const directory of ['skills', 'commands']) {
|
|
67
|
+
const directoryPath = join(root, directory);
|
|
68
|
+
if (!existsSync(directoryPath)) continue;
|
|
69
|
+
for (const entry of readdirSync(directoryPath, { withFileTypes: true })) {
|
|
70
|
+
if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
|
|
71
|
+
const command = directory === 'commands';
|
|
72
|
+
if (command ? !entry.isFile() || !entry.name.endsWith('.md') : !entry.isDirectory()) continue;
|
|
73
|
+
const name = command ? entry.name.slice(0, -3) : entry.name;
|
|
74
|
+
const path = command ? `commands/${name}.md` : `skills/${name}/SKILL.md`;
|
|
75
|
+
if (!existsSync(join(root, path))) continue;
|
|
76
|
+
const raw = readFileSync(join(root, path), 'utf8');
|
|
77
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw);
|
|
78
|
+
if (!match) throw new Error(`${path}: missing YAML frontmatter`);
|
|
79
|
+
let fm;
|
|
80
|
+
try { fm = yaml.load(match[1], { schema: yaml.CORE_SCHEMA }); }
|
|
81
|
+
catch (error) { throw new Error(`${path}: invalid YAML: ${error.message}`, { cause: error }); }
|
|
82
|
+
validateFrontmatter(fm, name, path);
|
|
83
|
+
sources.push({ name, path, command, fm, policy: command ? fm['disable-model-invocation'] !== true : readNativePolicy(root, name) });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return sources;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Only these two artifact shapes and their exact ownership marker belong to this generator. */
|
|
90
|
+
function isOwned(path, raw) {
|
|
91
|
+
if (/^\.codex-plugin\/skills\/[^/]+\/SKILL\.md$/.test(path)) return raw.includes(`\n<!-- ${MARKER} -->\n`);
|
|
92
|
+
return /^\.codex-plugin\/skills\/[^/]+\/agents\/openai\.yaml$/.test(path) && raw.startsWith(`# ${MARKER}\n`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Read the tree without following symlinks, before any updates are permitted. */
|
|
96
|
+
function readOutput(root, directory = OUTPUT) {
|
|
97
|
+
const absolute = join(root, directory);
|
|
98
|
+
if (!existsSync(absolute)) return [];
|
|
99
|
+
if (!lstatSync(absolute).isDirectory() || lstatSync(absolute).isSymbolicLink()) {
|
|
100
|
+
throw new Error(`${directory}: generated directory must be a real directory`);
|
|
101
|
+
}
|
|
102
|
+
const files = [];
|
|
103
|
+
for (const entry of readdirSync(absolute, { withFileTypes: true })) {
|
|
104
|
+
const path = `${directory}/${entry.name}`;
|
|
105
|
+
if (entry.isSymbolicLink()) throw new Error(`${path}: refusing to follow a symlink in the generated surface`);
|
|
106
|
+
if (entry.isDirectory()) files.push(...readOutput(root, path));
|
|
107
|
+
else if (entry.isFile()) files.push({ path, raw: readFileSync(join(root, path), 'utf8') });
|
|
108
|
+
else throw new Error(`${path}: unsupported file type in generated surface`);
|
|
109
|
+
}
|
|
110
|
+
return files;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @param {Source} source @param {string[]} warnings */
|
|
114
|
+
function renderSkill(source, warnings) {
|
|
115
|
+
const projected = toPortableFrontmatter({ ...source.fm, name: source.name }, source.name);
|
|
116
|
+
warnings.push(...projected.warnings);
|
|
117
|
+
const lines = [
|
|
118
|
+
'---',
|
|
119
|
+
yaml.dump(projected.fm, { lineWidth: -1, noRefs: true }).trimEnd(),
|
|
120
|
+
'---',
|
|
121
|
+
'',
|
|
122
|
+
`<!-- ${MARKER} -->`,
|
|
123
|
+
'',
|
|
124
|
+
`# ${source.name}`,
|
|
125
|
+
'',
|
|
126
|
+
`Read [\`${source.path}\`](../../../${source.path}) in full before taking any action, then follow its complete workflow.`,
|
|
127
|
+
'The linked document is authoritative, including its prechecks, argument rules, and stop conditions.',
|
|
128
|
+
'',
|
|
129
|
+
'Resolve this link relative to this SKILL.md, not the project working directory. The plugin root is three directories above this file. Resolve package paths such as `skills/` and `scripts/` from that root; resolve relative links inside the canonical document from its own directory. Keep the user’s project as the target of project operations.',
|
|
130
|
+
];
|
|
131
|
+
if (source.command) lines.push(
|
|
132
|
+
'',
|
|
133
|
+
'`$ARGUMENTS` means the trailing user input after the selected command skill, or an empty string when absent. Preserve flags, quoted text, and Unicode as data. Do not perform global substitution in the command document, shell expansion on the argument string, or execution of that string as shell code. When the workflow needs a command, pass its arguments through structured tool parameters or safely quoted individual arguments.',
|
|
134
|
+
'',
|
|
135
|
+
'Read the full command before invoking an internal skill. An instruction to invoke a skill (including `session-orchestrator:<name>` or the `Skill` tool) means read and follow the canonical `skills/<name>/SKILL.md` beneath the plugin root. Do not redispatch the public command adapter: a same-named command and internal skill are distinct documents, and redispatch would recurse.',
|
|
136
|
+
);
|
|
137
|
+
lines.push('', 'Regenerate with `node scripts/generate-codex-skills.mjs`.', '');
|
|
138
|
+
return lines.join('\n');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {{pluginRoot: string, check?: boolean}} options
|
|
143
|
+
* @returns {{ok: boolean, skills: string[], files: string[], written: string[], drift: string[], warnings: string[], errors: string[]}}
|
|
144
|
+
*/
|
|
145
|
+
export function generateCodexSurface({ pluginRoot, check = false }) {
|
|
146
|
+
const result = { ok: true, skills: [], files: [], written: [], drift: [], warnings: [], errors: [] };
|
|
147
|
+
try {
|
|
148
|
+
if (!existsSync(pluginRoot) || !statSync(pluginRoot).isDirectory()) throw new Error(`${pluginRoot}: plugin root must be an existing directory`);
|
|
149
|
+
const sources = new Map(readSources(pluginRoot).map((source) => [source.name, source]));
|
|
150
|
+
result.skills = [...sources.keys()].sort();
|
|
151
|
+
const artifacts = new Map();
|
|
152
|
+
for (const name of result.skills) {
|
|
153
|
+
const source = sources.get(name);
|
|
154
|
+
artifacts.set(`${OUTPUT}/${name}/SKILL.md`, renderSkill(source, result.warnings));
|
|
155
|
+
if (source.policy !== undefined) artifacts.set(`${OUTPUT}/${name}/agents/openai.yaml`, [
|
|
156
|
+
`# ${MARKER}`,
|
|
157
|
+
yaml.dump({
|
|
158
|
+
interface: { display_name: name },
|
|
159
|
+
policy: { allow_implicit_invocation: source.policy },
|
|
160
|
+
}, { lineWidth: -1, noRefs: true }).trimEnd(),
|
|
161
|
+
'',
|
|
162
|
+
].join('\n'));
|
|
163
|
+
}
|
|
164
|
+
result.files = [...artifacts.keys()];
|
|
165
|
+
|
|
166
|
+
// Preflight every destination and existing artifact before the first mutation.
|
|
167
|
+
// A conflict or invalid source must never leave a partly refreshed surface.
|
|
168
|
+
for (const path of [`${OUTPUT}/.preflight`, ...result.files]) {
|
|
169
|
+
const destination = join(pluginRoot, path);
|
|
170
|
+
if (artifacts.has(path) && existsSync(destination) && !lstatSync(destination).isFile()) {
|
|
171
|
+
throw new Error(`${path}: generated artifact must be a regular file`);
|
|
172
|
+
}
|
|
173
|
+
let parent = dirname(path);
|
|
174
|
+
while (parent !== '.') {
|
|
175
|
+
const absolute = join(pluginRoot, parent);
|
|
176
|
+
if (existsSync(absolute) && (!lstatSync(absolute).isDirectory() || lstatSync(absolute).isSymbolicLink())) {
|
|
177
|
+
throw new Error(`${parent}: generated directory must be a real directory`);
|
|
178
|
+
}
|
|
179
|
+
parent = dirname(parent);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const obsolete = [];
|
|
183
|
+
for (const { path, raw } of readOutput(pluginRoot)) {
|
|
184
|
+
if (artifacts.has(path)) {
|
|
185
|
+
if (!isOwned(path, raw)) throw new Error(`${path}: refusing to overwrite an unrelated artifact without the generated marker`);
|
|
186
|
+
} else if (isOwned(path, raw)) obsolete.push(path);
|
|
187
|
+
else result.warnings.push(`${path}: retained unrelated file`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const [path, content] of artifacts) {
|
|
191
|
+
const absolute = join(pluginRoot, path);
|
|
192
|
+
if (existsSync(absolute) && readFileSync(absolute, 'utf8') === content) continue;
|
|
193
|
+
if (check) result.drift.push(`${path} is missing or stale`);
|
|
194
|
+
else {
|
|
195
|
+
mkdirSync(dirname(absolute), { recursive: true });
|
|
196
|
+
writeFileSync(absolute, content);
|
|
197
|
+
result.written.push(path);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
for (const path of obsolete) {
|
|
201
|
+
if (check) result.drift.push(`${path} is obsolete`);
|
|
202
|
+
else {
|
|
203
|
+
unlinkSync(join(pluginRoot, path));
|
|
204
|
+
result.written.push(`removed ${path}`);
|
|
205
|
+
let parent = dirname(path);
|
|
206
|
+
while (parent !== OUTPUT && readdirSync(join(pluginRoot, parent)).length === 0) {
|
|
207
|
+
rmdirSync(join(pluginRoot, parent));
|
|
208
|
+
parent = dirname(parent);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} catch (error) {
|
|
213
|
+
result.errors.push(error.message);
|
|
214
|
+
}
|
|
215
|
+
result.ok = result.errors.length === 0 && result.drift.length === 0;
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function runCli() {
|
|
220
|
+
let pluginRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
221
|
+
let check = false;
|
|
222
|
+
let json = false;
|
|
223
|
+
const args = process.argv.slice(2);
|
|
224
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
225
|
+
if (args[i] === '--check') check = true;
|
|
226
|
+
else if (args[i] === '--json') json = true;
|
|
227
|
+
else if (args[i] === '--plugin-root' && args[i + 1]) pluginRoot = resolve(args[++i]);
|
|
228
|
+
else if (args[i] === '--help') {
|
|
229
|
+
process.stdout.write('Usage: generate-codex-skills.mjs [--plugin-root <dir>] [--check] [--json]\n');
|
|
230
|
+
return;
|
|
231
|
+
} else {
|
|
232
|
+
process.stderr.write(`Unknown or incomplete argument: ${args[i]}\n`);
|
|
233
|
+
process.exitCode = 1;
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const result = generateCodexSurface({ pluginRoot, check });
|
|
238
|
+
if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
239
|
+
else {
|
|
240
|
+
process.stdout.write(`codex-surface: ${result.files.length} artefact(s), ${result.ok ? (check ? 'in sync' : 'generated') : 'failed'}\n`);
|
|
241
|
+
for (const message of [...result.errors, ...result.drift, ...result.warnings]) process.stderr.write(`${message}\n`);
|
|
242
|
+
}
|
|
243
|
+
process.exitCode = result.ok ? 0 : 1;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (process.argv[1] && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) runCli();
|
|
@@ -17,8 +17,11 @@
|
|
|
17
17
|
*
|
|
18
18
|
* Usage:
|
|
19
19
|
* node scripts/generate-hook-import-set.mjs [--plugin-root <dir>] [--out <file>] [--check]
|
|
20
|
+
* node scripts/generate-hook-import-set.mjs --help
|
|
20
21
|
*
|
|
21
|
-
* Exit codes: 0 = written / in sync; 1 = drift (with --check)
|
|
22
|
+
* Exit codes: 0 = written / in sync (or --help); 1 = drift (with --check);
|
|
23
|
+
* 2 = usage error (unknown flag, or a valued flag missing its value) — the
|
|
24
|
+
* write is NEVER performed on a usage error.
|
|
22
25
|
*/
|
|
23
26
|
|
|
24
27
|
import { readFileSync, writeFileSync, existsSync, statSync, realpathSync } from 'node:fs';
|
|
@@ -178,19 +181,59 @@ export function buildArtifact(pluginRoot) {
|
|
|
178
181
|
// CLI
|
|
179
182
|
// ---------------------------------------------------------------------------
|
|
180
183
|
|
|
184
|
+
const USAGE = [
|
|
185
|
+
'Usage: node scripts/generate-hook-import-set.mjs [--plugin-root <dir>] [--out <file>] [--check]',
|
|
186
|
+
'',
|
|
187
|
+
' --plugin-root <dir> repository/plugin root to crawl (default: the repo this script lives in)',
|
|
188
|
+
' --out <file> artefact path (default: <plugin-root>/hooks/_lib/hook-import-set.json)',
|
|
189
|
+
' --check re-crawl and diff against the committed artefact instead of writing',
|
|
190
|
+
' --help, -h print this help',
|
|
191
|
+
'',
|
|
192
|
+
'Exit codes: 0 = written / in sync; 1 = drift (with --check); 2 = usage error.',
|
|
193
|
+
].join('\n');
|
|
194
|
+
|
|
181
195
|
/**
|
|
182
196
|
* @param {string[]} argv
|
|
183
197
|
* @returns {number} process exit code
|
|
184
198
|
*/
|
|
185
199
|
export function main(argv) {
|
|
186
200
|
const args = argv.slice(2);
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
201
|
+
|
|
202
|
+
/** @type {{'--plugin-root': string|null, '--out': string|null}} */
|
|
203
|
+
const valued = { '--plugin-root': null, '--out': null };
|
|
204
|
+
let check = false;
|
|
205
|
+
|
|
206
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
207
|
+
const arg = args[i];
|
|
208
|
+
if (arg === '--help' || arg === '-h') {
|
|
209
|
+
process.stdout.write(`${USAGE}\n`);
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
if (arg === '--check') {
|
|
213
|
+
check = true;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (arg === '--plugin-root' || arg === '--out') {
|
|
217
|
+
// A valued flag whose value is missing — or whose "value" is the next FLAG
|
|
218
|
+
// — is a usage error, not a silent default. Without the leading-dash test,
|
|
219
|
+
// `--out --check` swallows `--check` as the artefact path: checking is
|
|
220
|
+
// disabled, the WRITE branch runs, and a file literally named `--check`
|
|
221
|
+
// lands in the cwd.
|
|
222
|
+
const value = args[i + 1];
|
|
223
|
+
if (value === undefined || value.startsWith('-')) {
|
|
224
|
+
process.stderr.write(`✗ hook-import-set: ${arg} requires a value\n${USAGE}\n`);
|
|
225
|
+
return 2;
|
|
226
|
+
}
|
|
227
|
+
valued[arg] = value;
|
|
228
|
+
i += 1;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
process.stderr.write(`✗ hook-import-set: unknown argument "${arg}"\n${USAGE}\n`);
|
|
232
|
+
return 2;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const pluginRoot = path.resolve(valued['--plugin-root'] ?? path.resolve(import.meta.dirname, '..'));
|
|
236
|
+
const out = path.resolve(valued['--out'] ?? path.join(pluginRoot, 'hooks', '_lib', 'hook-import-set.json'));
|
|
194
237
|
|
|
195
238
|
const fresh = buildArtifact(pluginRoot);
|
|
196
239
|
|
|
@@ -7,6 +7,7 @@ const MANIFEST_KEYS = new Set([
|
|
|
7
7
|
'description',
|
|
8
8
|
'keywords',
|
|
9
9
|
'skills',
|
|
10
|
+
'commands',
|
|
10
11
|
'mcpServers',
|
|
11
12
|
'apps',
|
|
12
13
|
'hooks',
|
|
@@ -206,6 +207,11 @@ function validateManifest(manifest, root, expectedBaseVersion, errors) {
|
|
|
206
207
|
validatePathCollection(manifest.skills, '$.manifest.skills', 'directory', root, errors, {
|
|
207
208
|
required: true,
|
|
208
209
|
});
|
|
210
|
+
// Native [] suppresses Codex's conventional commands/ fallback. Nonempty
|
|
211
|
+
// paths would register automatic aliases beside our generated skill surface.
|
|
212
|
+
if (Object.hasOwn(manifest, 'commands') && (!Array.isArray(manifest.commands) || manifest.commands.length !== 0)) {
|
|
213
|
+
addError(errors, '$.manifest.commands', 'disabled-command-discovery', 'commands must be an empty array to disable automatic command conversion');
|
|
214
|
+
}
|
|
209
215
|
validateMcpServers(manifest.mcpServers, root, errors);
|
|
210
216
|
if (manifest.apps !== undefined) {
|
|
211
217
|
validateContractPath(manifest.apps, '$.manifest.apps', 'file', root, errors);
|
|
@@ -40,17 +40,33 @@ const ENV_KEYS = /** @type {const} */ ({
|
|
|
40
40
|
* merged with defaults, so a real owner.yaml without a `paths:` section yields
|
|
41
41
|
* `ownerConfig.paths === undefined`. Callers must read defensively.
|
|
42
42
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
43
|
+
* HEALTH PASSTHROUGH (#1251): the owner loader reports HOW its result came about
|
|
44
|
+
* (`source`, `reason`, `droppedSections`). Discarding those forced callers that
|
|
45
|
+
* need them — CP11 in check-owner-leakage.mjs is the live one — to load owner.yaml
|
|
46
|
+
* a SECOND time just to see the health of the load this function already did. They
|
|
47
|
+
* are passed through verbatim; `ownerConfig` and `env` keep their exact prior
|
|
48
|
+
* meaning, so callers reading only those two (scripts/lib/config.mjs,
|
|
49
|
+
* scripts/lib/vault-mirror/namespace.mjs) are unaffected. On a throwing loader all
|
|
50
|
+
* three health fields stay `undefined`, exactly like `ownerConfig`.
|
|
51
|
+
*
|
|
52
|
+
* @param {{ env?: Record<string, string|undefined>, ownerLoader?: () => { config: object, source?: string, reason?: string, droppedSections?: Array<{section: string, errors: string[]}> } }} [opts]
|
|
53
|
+
* @returns {{ ownerConfig: object|undefined, env: Record<string, string|undefined>, source: string|undefined, reason: string|undefined, droppedSections: Array<{section: string, errors: string[]}>|undefined }}
|
|
45
54
|
*/
|
|
46
55
|
export function loadHostPaths({ env = process.env, ownerLoader = loadOwnerConfig } = {}) {
|
|
47
56
|
let ownerConfig;
|
|
57
|
+
let source;
|
|
58
|
+
let reason;
|
|
59
|
+
let droppedSections;
|
|
48
60
|
try {
|
|
49
|
-
|
|
61
|
+
const loaded = ownerLoader();
|
|
62
|
+
ownerConfig = loaded?.config;
|
|
63
|
+
source = loaded?.source;
|
|
64
|
+
reason = loaded?.reason;
|
|
65
|
+
droppedSections = loaded?.droppedSections;
|
|
50
66
|
} catch {
|
|
51
67
|
ownerConfig = undefined;
|
|
52
68
|
}
|
|
53
|
-
return { ownerConfig, env };
|
|
69
|
+
return { ownerConfig, env, source, reason, droppedSections };
|
|
54
70
|
}
|
|
55
71
|
|
|
56
72
|
/**
|
package/scripts/lib/events.mjs
CHANGED
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
* 3. **Never a peer's id (#1123).** A shared working copy means
|
|
28
28
|
* `session.lock` can name a PEER session that won the acquire race. The
|
|
29
29
|
* lock alone therefore does not prove ownership; the fill happens only
|
|
30
|
-
* when a PROCESS-LOCAL id (`CLAUDE_CODE_SESSION_ID
|
|
31
|
-
* `
|
|
30
|
+
* when a confirmed native PROCESS-LOCAL id (`CLAUDE_CODE_SESSION_ID` or
|
|
31
|
+
* `CODEX_THREAD_ID`) equals the lock's raw `session_id`. STATE.md is NOT a
|
|
32
32
|
* witness here (#1177 FX1): it is a shared working-copy file written by
|
|
33
33
|
* the lock holder, so under a peer-owned lock both agreed about the peer
|
|
34
34
|
* and the union stamped the peer's ids. See {@link attributionForRecord}.
|
|
@@ -124,7 +124,7 @@ const STATE_DIR_CANDIDATES = ['.claude', '.codex', '.cursor', '.pi'];
|
|
|
124
124
|
* when one exists it decides alone.**
|
|
125
125
|
*
|
|
126
126
|
* - No lock (CI, a bare script) → `{}`. Nothing to attribute to.
|
|
127
|
-
* - No process-local id (
|
|
127
|
+
* - No confirmed process-local id (absent or ambiguous native env) → `{}`. Ownership is
|
|
128
128
|
* UNPROVEN, and an unproven attribution is exactly the peer-id write #1123
|
|
129
129
|
* forbids; an absent key costs a correlation, a wrong key costs a false one.
|
|
130
130
|
* - A process-local id that equals the lock's raw `session_id` → fill BOTH
|
|
@@ -72,9 +72,13 @@ const testCounts =
|
|
|
72
72
|
// no name is what made the 2026-09-06 pre-push block unusable —
|
|
73
73
|
// `files_failed: 1` out of 662, and reconstructing WHICH file cost a manual
|
|
74
74
|
// re-materialisation of the tracked tree. An EMPTY array here is meaningful and
|
|
75
|
-
// is NOT the absent case: it
|
|
76
|
-
//
|
|
77
|
-
//
|
|
75
|
+
// is NOT the absent case: it means vitest reported file-level failures but the
|
|
76
|
+
// file-name parser (`extractFailedTestFiles`) matched none of them out of the
|
|
77
|
+
// captured output — a parser gap worth seeing. The consumer,
|
|
78
|
+
// `failedFilesFromGateStdout()` in `scripts/run-quality-gate.mjs`, normalizes
|
|
79
|
+
// this `[]` to `null` (same as the true-absent case) before it reaches its own
|
|
80
|
+
// caller — so this array's "parsed but empty" distinction is visible here, in
|
|
81
|
+
// this envelope, and nowhere downstream.
|
|
78
82
|
const failedTestFiles = testResult.status === 'fail'
|
|
79
83
|
? extractFailedTestFiles(testResult.fullOutput ?? testResult.output ?? '')
|
|
80
84
|
: [];
|
|
@@ -36,14 +36,12 @@
|
|
|
36
36
|
* - Issue #820.
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
-
import {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
*/
|
|
46
|
-
const OPTIONAL_OBJECT_SECTION_NAMES = /** @type {const} */ (['paths', 'dispatcher']);
|
|
39
|
+
import {
|
|
40
|
+
loadOwnerConfig,
|
|
41
|
+
resolveOwnerYamlPath,
|
|
42
|
+
getDefaults,
|
|
43
|
+
OPTIONAL_OBJECT_SECTIONS,
|
|
44
|
+
} from './owner-yaml.mjs';
|
|
47
45
|
|
|
48
46
|
/**
|
|
49
47
|
* Format a `{ section, errors }` entry into a short `"name" (first error)` tag.
|
|
@@ -111,7 +109,7 @@ export function checkOwnerConfig({ loader = loadOwnerConfig } = {}) {
|
|
|
111
109
|
const survivedSections = [];
|
|
112
110
|
if (result.config && typeof result.config === 'object') {
|
|
113
111
|
const defaults = getDefaults();
|
|
114
|
-
for (const name of
|
|
112
|
+
for (const name of OPTIONAL_OBJECT_SECTIONS) {
|
|
115
113
|
if (JSON.stringify(result.config[name]) !== JSON.stringify(defaults[name])) {
|
|
116
114
|
survivedSections.push(name);
|
|
117
115
|
}
|
|
@@ -196,8 +196,15 @@ const REQUIRED_SECTIONS = /** @type {const} */ (['owner', 'tone', 'efficiency',
|
|
|
196
196
|
* OPTIONAL object sections (#820) — a malformed entry is replaced by its
|
|
197
197
|
* `getDefaults()` value and reported via `droppedSections` + a stderr WARN,
|
|
198
198
|
* but does NOT discard the rest of the file.
|
|
199
|
+
*
|
|
200
|
+
* Exported (#1262.4) as the SSOT for consumers that must iterate exactly these
|
|
201
|
+
* sections — currently `scripts/lib/owner-config-banner.mjs`, which derived the
|
|
202
|
+
* #1244 "survived a whole-file discard" set from a hand-mirrored local copy.
|
|
203
|
+
* Frozen so a consumer cannot mutate the shared array.
|
|
199
204
|
*/
|
|
200
|
-
const OPTIONAL_OBJECT_SECTIONS = /** @type {const} */ (
|
|
205
|
+
export const OPTIONAL_OBJECT_SECTIONS = /** @type {const} */ (
|
|
206
|
+
Object.freeze(['paths', 'dispatcher'])
|
|
207
|
+
);
|
|
201
208
|
|
|
202
209
|
/**
|
|
203
210
|
* OPTIONAL list sections (#820) — malformed entries are passed through
|