session-orchestrator 4.1.0 → 4.2.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/.agents/skills/session-plan/SKILL.md +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
- package/.cursor/skills/session-plan/SKILL.md +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +34 -0
- package/README.md +8 -8
- package/agents/ux-evaluator.md +1 -1
- package/commands/close.md +3 -3
- package/commands/go.md +2 -0
- package/commands/memory-cleanup.md +4 -3
- package/commands/persona-panel.md +1 -1
- package/commands/session.md +3 -2
- package/docs/README.md +4 -4
- package/docs/USER-GUIDE.md +115 -48
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +55 -1
- package/docs/ci-setup.md +1 -1
- package/docs/codex-setup.md +1 -0
- package/docs/components.md +2 -2
- package/docs/cursor-setup.md +1 -0
- package/docs/events-schema.md +4 -1
- package/docs/instruction-delivery.md +1 -1
- package/docs/memory-proposal-flow.md +3 -3
- package/docs/migration-v4.md +2 -2
- package/docs/owner-config-schema.md +74 -90
- package/docs/persona-panel.md +4 -4
- package/docs/pi-setup.md +1 -0
- package/docs/rule-authoring.md +13 -6
- package/docs/scope-collision-guard.md +2 -0
- package/docs/session-config-reference.md +55 -22
- package/docs/session-config-template.md +9 -5
- package/docs/vault-docs-architecture.md +4 -2
- package/hooks/_lib/hook-import-set.json +28 -3
- package/hooks/_lib/vcs-create-matcher.mjs +214 -16
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/pre-bash-issue-budget.mjs +123 -26
- package/hooks/subagent-telemetry.mjs +106 -20
- package/package.json +4 -4
- package/scripts/baseline-archetypes.mjs +28 -0
- package/scripts/lib/auto-dialectic.mjs +0 -68
- package/scripts/lib/baseline-archetypes.mjs +439 -0
- package/scripts/lib/build-live-signals.mjs +5 -6
- package/scripts/lib/config/issue-budget.mjs +68 -8
- package/scripts/lib/config/private-config-dir.mjs +3 -2
- package/scripts/lib/config/remote-hosts.mjs +2 -2
- package/scripts/lib/config-schema.mjs +79 -0
- package/scripts/lib/file-lock.mjs +47 -5
- package/scripts/lib/issue-budget-reconcile.mjs +392 -0
- package/scripts/lib/issue-budget.mjs +76 -3
- package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
- package/scripts/lib/maintenance-due-banner.mjs +440 -0
- package/scripts/lib/owner-config.example.yaml +29 -46
- package/scripts/lib/owner-yaml.mjs +14 -13
- package/scripts/lib/quality-gate.mjs +13 -6
- package/scripts/lib/resource-probe/evaluate.mjs +19 -21
- package/scripts/lib/rules-sync.mjs +34 -4
- package/scripts/lib/session-close-backfill.mjs +182 -40
- package/scripts/lib/session-end/phase-skip.mjs +85 -86
- package/scripts/lib/session-end/tail-runner.mjs +178 -0
- package/scripts/lib/session-schema/constants.mjs +6 -0
- package/scripts/lib/session-schema/validator.mjs +20 -0
- package/scripts/lib/session-shape.mjs +558 -0
- package/scripts/lib/session-start-probes.mjs +10 -3
- package/scripts/lib/session-token-rollup.mjs +95 -10
- package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
- package/scripts/lib/state-md.mjs +1 -0
- package/scripts/lib/subagents-schema.mjs +77 -9
- package/scripts/lib/telemetry/pricing.mjs +197 -0
- package/scripts/lib/telemetry/sync.mjs +50 -1
- package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
- package/scripts/lib/validate/check-unwired-features.mjs +8 -7
- package/scripts/lib/vault-mirror/process.mjs +2 -1
- package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
- package/scripts/lib/wave-resource-gate.mjs +23 -27
- package/scripts/lib/wave-sizing.mjs +10 -3
- package/scripts/materialize-wave-scope.mjs +68 -14
- package/scripts/print-applicable-rules.mjs +7 -6
- package/scripts/print-learnings-index.mjs +3 -2
- package/scripts/session-shape.mjs +266 -0
- package/skills/_shared/config-reading.md +15 -9
- package/skills/_shared/private-capability-context.md +89 -0
- package/skills/bootstrap/SKILL.md +61 -13
- package/skills/bootstrap/_shared-template.md +99 -14
- package/skills/bootstrap/deep-template.md +36 -26
- package/skills/bootstrap/fast-template.md +44 -8
- package/skills/bootstrap/intensity-heuristic.md +10 -4
- package/skills/bootstrap/private-contract.md +119 -0
- package/skills/bootstrap/public-fallback.md +30 -18
- package/skills/bootstrap/standard-template.md +39 -24
- package/skills/discovery/probes-ui.md +1 -1
- package/skills/docs-orchestrator/audience-mapping.md +1 -1
- package/skills/evolve/SKILL.md +2 -2
- package/skills/gitlab-ops/SKILL.md +3 -3
- package/skills/grill/SKILL.md +1 -1
- package/skills/memory-cleanup/SKILL.md +2 -2
- package/skills/plan/mode-new.md +9 -0
- package/skills/reconcile/SKILL.md +1 -1
- package/skills/session-end/SKILL.md +3 -2
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +23 -65
- package/skills/session-end/phase-3-7a-recommendations.md +2 -2
- package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
- package/skills/session-end/references/phase-5-issue-cleanup.md +26 -0
- package/skills/session-end/session-metrics-write.md +31 -12
- package/skills/session-plan/SKILL.md +56 -48
- package/skills/session-plan/wave-template.md +8 -15
- package/skills/session-start/SKILL.md +18 -2
- package/skills/session-start/phase-2-5-docs-planning.md +1 -1
- package/skills/session-start/phase-8-5-express-path.md +12 -9
- package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +21 -5
- package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
- package/skills/test-runner/rubric-v1.md +2 -2
- package/skills/wave-executor/SKILL.md +42 -12
- package/skills/wave-executor/circuit-breaker.md +3 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +4 -2
- package/skills/wave-executor/references/wave-loop-review.md +1 -1
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
- package/templates/nextjs-minimal/package.json +1 -1
- package/templates/node-minimal/package.json +1 -1
- package/scripts/lib/multi-provider-build/providers.mjs +0 -64
- package/scripts/lib/multi-provider-build/templating.mjs +0 -130
- package/scripts/lib/owner-config/coerce.mjs +0 -29
- package/scripts/lib/owner-config/constants.mjs +0 -21
- package/scripts/lib/owner-config/defaults.mjs +0 -50
- package/scripts/lib/owner-config/error.mjs +0 -19
- package/scripts/lib/owner-config/index.mjs +0 -13
- package/scripts/lib/owner-config/merge.mjs +0 -52
- package/scripts/lib/owner-config/validate.mjs +0 -259
- package/scripts/lib/owner-config-loader.mjs +0 -170
- package/scripts/lib/owner-config.mjs +0 -28
- package/scripts/lib/soul-resolve.mjs +0 -130
- package/scripts/lib/vault-mirror/render.mjs +0 -8
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/** Offline consumer of a configured baseline's reduced archetype export.
|
|
2
|
+
* Lookup is read-only; command strings are documentation, never shell input.
|
|
3
|
+
*/
|
|
4
|
+
import { constants, copyFileSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { homedir, tmpdir } from 'node:os';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { readConfigFile } from './config/io.mjs';
|
|
9
|
+
import { _extractConfigSection, _parseKV, findSessionConfigBlock } from './config/section-extractor.mjs';
|
|
10
|
+
import { _coerceString } from './config/coercers.mjs';
|
|
11
|
+
import { loadHostPaths, resolveHostPath } from './config/host-paths.mjs';
|
|
12
|
+
import { resolveNamedBaseline } from './named-baseline-resolver.mjs';
|
|
13
|
+
import { resolveArchetype, syncRules } from './rules-sync.mjs';
|
|
14
|
+
|
|
15
|
+
const DEFAULT_PLUGIN_ROOT = path.resolve(import.meta.dirname, '../..');
|
|
16
|
+
const MAX_BYTES = 1024 * 1024;
|
|
17
|
+
const ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
18
|
+
const BASENAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*\.md$/;
|
|
19
|
+
const unique = (items) => [...new Set(items)].sort();
|
|
20
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
21
|
+
|
|
22
|
+
export class BaselineContractError extends Error {
|
|
23
|
+
constructor(reason) { super(`Baseline archetype lookup failed (${reason}).`); this.name = 'BaselineContractError'; this.reason = reason; }
|
|
24
|
+
}
|
|
25
|
+
function fail(reason = 'invalid-contract') { throw new BaselineContractError(reason); }
|
|
26
|
+
function check(condition) { if (!condition) fail(); }
|
|
27
|
+
function shape(value, required, optional = []) {
|
|
28
|
+
check(object(value) && required.every((key) => Object.hasOwn(value, key)) && Object.keys(value).every((key) => [...required, ...optional].includes(key)));
|
|
29
|
+
}
|
|
30
|
+
function text(value) {
|
|
31
|
+
check(typeof value === 'string' && value.length > 0 && value.length <= 2048 && ![...value].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127));
|
|
32
|
+
// The reduced export must not carry host locations, URLs, or absolute commands.
|
|
33
|
+
check(!/(?:https?|file):\/\/|(?:^|[\s='"])(?:\/|~\/|[A-Za-z]:\\)/u.test(value));
|
|
34
|
+
}
|
|
35
|
+
function array(value, visit, minimum = 0) {
|
|
36
|
+
check(Array.isArray(value) && value.length >= minimum && value.length <= 256);
|
|
37
|
+
value.forEach(visit);
|
|
38
|
+
}
|
|
39
|
+
function strings(value, pattern) {
|
|
40
|
+
array(value, (item) => { text(item); if (pattern) check(pattern.test(item)); });
|
|
41
|
+
check(new Set(value).size === value.length);
|
|
42
|
+
}
|
|
43
|
+
function relative(value, wildcard = false) {
|
|
44
|
+
text(value);
|
|
45
|
+
check((wildcard ? /^[A-Za-z0-9_.@/*{}[\]()-]+$/ : /^[A-Za-z0-9_.@/{}[\]()-]+$/).test(value));
|
|
46
|
+
check(!value.split('/').some((part) => !part || part === '.' || part === '..'));
|
|
47
|
+
}
|
|
48
|
+
function signal(value, allowAll = true) {
|
|
49
|
+
if (value?.kind === 'all' && allowAll) {
|
|
50
|
+
shape(value, ['kind', 'conditions']); array(value.conditions, (part) => signal(part, false), 1); return;
|
|
51
|
+
}
|
|
52
|
+
shape(value, ['kind', 'value']);
|
|
53
|
+
check(['path', 'packageDependency', 'packageField'].includes(value.kind));
|
|
54
|
+
if (value.kind === 'path') relative(value.value, true); else text(value.value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Validate the public-safe schema without an installed dependency or private ID matrix. */
|
|
58
|
+
export function validateBaselineContract(value) {
|
|
59
|
+
shape(value, ['schemaVersion', 'source', 'browserAutomation', 'rulePolicy', 'archetypes']);
|
|
60
|
+
check(value.schemaVersion === 1 && value.source === 'templates/archetypes.json');
|
|
61
|
+
shape(value.browserAutomation, ['agent', 'repeatable', 'browserMcp']);
|
|
62
|
+
text(value.browserAutomation.agent); text(value.browserAutomation.repeatable); check(typeof value.browserAutomation.browserMcp === 'boolean');
|
|
63
|
+
shape(value.rulePolicy, ['conditional']);
|
|
64
|
+
array(value.rulePolicy.conditional, (rule) => {
|
|
65
|
+
shape(rule, ['id', 'dependencies', 'dependencyPrefixes', 'targets']); text(rule.id);
|
|
66
|
+
strings(rule.dependencies); strings(rule.dependencyPrefixes); strings(rule.targets, BASENAME);
|
|
67
|
+
});
|
|
68
|
+
array(value.archetypes, (item) => {
|
|
69
|
+
shape(item, ['id', 'order', 'templatePath', 'runtimes', 'packageManagers', 'ui', 'api', 'deploy', 'detection', 'qualityGates', 'commands', 'ci', 'ruleTargets']);
|
|
70
|
+
check(typeof item.id === 'string' && ID.test(item.id));
|
|
71
|
+
check(Number.isSafeInteger(item.order) && item.order >= 1);
|
|
72
|
+
relative(item.templatePath); check(item.templatePath === `templates/${item.id}`);
|
|
73
|
+
for (const key of ['runtimes', 'packageManagers']) array(item[key], (runtime) => { shape(runtime, ['name', 'version']); text(runtime.name); text(runtime.version); }, 1);
|
|
74
|
+
shape(item.ui, ['mode', 'framework', 'tailwind']); text(item.ui.mode); text(item.ui.framework); check(typeof item.ui.tailwind === 'boolean');
|
|
75
|
+
shape(item.api, ['mode', 'framework']); text(item.api.mode); text(item.api.framework);
|
|
76
|
+
shape(item.deploy, ['default', 'alternatives']); text(item.deploy.default); strings(item.deploy.alternatives);
|
|
77
|
+
shape(item.detection, ['priority', 'signals']); check(Number.isSafeInteger(item.detection.priority) && item.detection.priority >= 0);
|
|
78
|
+
array(item.detection.signals, (part) => signal(part), 1);
|
|
79
|
+
array(item.qualityGates, (gate) => { shape(gate, ['id', 'command'], ['packageScript']); text(gate.id); text(gate.command); if (gate.packageScript !== undefined) text(gate.packageScript); });
|
|
80
|
+
check(new Set(item.qualityGates.map((gate) => gate.id)).size === item.qualityGates.length);
|
|
81
|
+
array(item.commands, (command) => { shape(command, ['command', 'description']); text(command.command); text(command.description); });
|
|
82
|
+
shape(item.ci, ['required', 'profile']); check(typeof item.ci.required === 'boolean'); text(item.ci.profile);
|
|
83
|
+
strings(item.ruleTargets, BASENAME);
|
|
84
|
+
}, 1);
|
|
85
|
+
for (const key of ['id', 'order', 'templatePath']) check(new Set(value.archetypes.map((item) => item[key])).size === value.archetypes.length);
|
|
86
|
+
check(new Set(value.rulePolicy.conditional.map((item) => item.id)).size === value.rulePolicy.conditional.length);
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Internal child-process entry. Existing resolvers may print private diagnostic
|
|
91
|
+
* context; only resolveBaselineLocation calls this, with stderr captured.
|
|
92
|
+
*/
|
|
93
|
+
export function resolveConfiguredBaselinePath({ repoRoot, committed, hostPaths }) {
|
|
94
|
+
const context = hostPaths ?? loadHostPaths();
|
|
95
|
+
const named = resolveNamedBaseline({ ...context, cwd: repoRoot });
|
|
96
|
+
return named.path ?? resolveHostPath('baseline-path', committed, context) ?? null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Internal host-local location; never serialize this value into bootstrap artifacts. */
|
|
100
|
+
export async function resolveBaselineLocation({ repoRoot = process.cwd(), hostPaths } = {}) {
|
|
101
|
+
let content = '';
|
|
102
|
+
try { content = await readConfigFile(repoRoot); } catch { /* A new repo has no instruction file yet. */ }
|
|
103
|
+
const kv = _parseKV(_extractConfigSection(content));
|
|
104
|
+
const resolved = spawnSync(process.execPath, ['--input-type=module', '-e', `
|
|
105
|
+
import { readFileSync } from 'node:fs';
|
|
106
|
+
const { resolveConfiguredBaselinePath } = await import(process.argv[1]);
|
|
107
|
+
process.stdout.write(JSON.stringify(resolveConfiguredBaselinePath(JSON.parse(readFileSync(0, 'utf8')))));
|
|
108
|
+
`, import.meta.url], {
|
|
109
|
+
input: JSON.stringify({ repoRoot, committed: _coerceString(kv, 'plan-baseline-path'), hostPaths }),
|
|
110
|
+
encoding: 'utf8', shell: false, timeout: 5000, maxBuffer: MAX_BYTES, killSignal: 'SIGKILL',
|
|
111
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
112
|
+
});
|
|
113
|
+
if (resolved.error || resolved.status !== 0) fail('config-unavailable');
|
|
114
|
+
let configured;
|
|
115
|
+
try { configured = JSON.parse(resolved.stdout); } catch { fail('config-unavailable'); }
|
|
116
|
+
if (typeof configured !== 'string' || !configured.trim()) return null;
|
|
117
|
+
const expanded = configured.trim().replace(/^~(?=\/|$)/u, homedir());
|
|
118
|
+
const root = path.resolve(repoRoot, expanded);
|
|
119
|
+
let stat;
|
|
120
|
+
try { stat = lstatSync(root); } catch (error) { if (error.code === 'ENOENT') return null; fail('baseline-unavailable'); }
|
|
121
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) fail('baseline-unavailable');
|
|
122
|
+
return realpathSync(root);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Resolve a canonical relative source and reject symlinks at every component. */
|
|
126
|
+
export function baselineSourcePath(root, source, directory = false) {
|
|
127
|
+
relative(source);
|
|
128
|
+
let current = root;
|
|
129
|
+
try {
|
|
130
|
+
for (const component of source.split('/')) {
|
|
131
|
+
current = path.join(current, component);
|
|
132
|
+
if (lstatSync(current).isSymbolicLink()) fail('unsafe-source');
|
|
133
|
+
}
|
|
134
|
+
const stat = lstatSync(current);
|
|
135
|
+
if (directory ? !stat.isDirectory() : !stat.isFile()) fail('unsafe-source');
|
|
136
|
+
if (!realpathSync(current).startsWith(`${realpathSync(root)}${path.sep}`)) fail('unsafe-source');
|
|
137
|
+
return current;
|
|
138
|
+
} catch { fail('unsafe-source'); }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function projection(root, args, { timeoutMs = 5000, maxBuffer = MAX_BYTES } = {}) {
|
|
142
|
+
const script = baselineSourcePath(root, 'scripts/archetype-manifest.mjs');
|
|
143
|
+
const result = spawnSync(process.execPath, [script, ...args], {
|
|
144
|
+
cwd: root, encoding: 'utf8', timeout: Math.min(timeoutMs, 5000), maxBuffer: Math.min(maxBuffer, MAX_BYTES),
|
|
145
|
+
shell: false, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'pipe'],
|
|
146
|
+
});
|
|
147
|
+
if (result.error || result.status !== 0) fail('producer-failed');
|
|
148
|
+
if (result.stdout.includes(root)) fail();
|
|
149
|
+
return result.stdout;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const ignored = new Set(['.git', 'node_modules', '.next', '.turbo', '.build', '.venv', 'target', 'dist', 'build', 'vendor']);
|
|
153
|
+
function collectFacts(repoRoot) {
|
|
154
|
+
const files = [];
|
|
155
|
+
function visit(relativePath, depth) {
|
|
156
|
+
if (depth > 4) return;
|
|
157
|
+
for (const entry of readdirSync(path.join(repoRoot, relativePath), { withFileTypes: true })) {
|
|
158
|
+
if (entry.isSymbolicLink() || ignored.has(entry.name)) continue;
|
|
159
|
+
const name = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
160
|
+
files.push(name); if (files.length > 20000) fail('repository-unavailable');
|
|
161
|
+
if (entry.isDirectory()) visit(name, depth + 1);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
visit('', 0);
|
|
165
|
+
let packageJson = {};
|
|
166
|
+
if (files.includes('package.json')) {
|
|
167
|
+
const filename = baselineSourcePath(repoRoot, 'package.json');
|
|
168
|
+
if (lstatSync(filename).size > MAX_BYTES) fail('repository-unavailable');
|
|
169
|
+
try { packageJson = JSON.parse(readFileSync(filename, 'utf8')); } catch { fail('repository-unavailable'); }
|
|
170
|
+
if (!object(packageJson)) fail('repository-unavailable');
|
|
171
|
+
}
|
|
172
|
+
const dependencies = unique(['dependencies', 'devDependencies', 'optionalDependencies'].flatMap((key) => object(packageJson[key]) ? Object.keys(packageJson[key]) : []));
|
|
173
|
+
return { files, packageJson, dependencies };
|
|
174
|
+
}
|
|
175
|
+
function matches(signal, facts) {
|
|
176
|
+
if (signal.kind === 'all') return signal.conditions.every((part) => matches(part, facts));
|
|
177
|
+
if (signal.kind === 'packageDependency') return facts.dependencies.includes(signal.value);
|
|
178
|
+
if (signal.kind === 'packageField') return Object.hasOwn(facts.packageJson, signal.value);
|
|
179
|
+
const pattern = signal.value.split('*').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*');
|
|
180
|
+
return facts.files.some((file) => new RegExp(`^${pattern}$`).test(file));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** All plugin-owned basenames, including currently nonmatching scoped rules. */
|
|
184
|
+
export function pluginRuleTargets(pluginRoot = DEFAULT_PLUGIN_ROOT) {
|
|
185
|
+
const content = readFileSync(path.join(pluginRoot, 'rules/_index.md'), 'utf8');
|
|
186
|
+
return unique([...content.matchAll(/^-\s+`([^`<>]+\.md)`/gm)].map((match) => path.posix.basename(match[1])));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Resolve private selection and expectations; no baseline means the public flow. */
|
|
190
|
+
export async function loadBaselineArchetypes(options = {}) {
|
|
191
|
+
const { repoRoot = process.cwd(), archetype, pluginRoot = DEFAULT_PLUGIN_ROOT } = options;
|
|
192
|
+
try {
|
|
193
|
+
const root = await resolveBaselineLocation({ ...options, repoRoot });
|
|
194
|
+
if (!root) return { status: 'public', reason: 'baseline-absent', archetypes: [], selected: null };
|
|
195
|
+
let manifest;
|
|
196
|
+
try { manifest = validateBaselineContract(JSON.parse(projection(root, ['export'], options))); }
|
|
197
|
+
catch (error) { if (error instanceof BaselineContractError) throw error; fail(); }
|
|
198
|
+
for (const item of manifest.archetypes) baselineSourcePath(root, item.templatePath, true);
|
|
199
|
+
const facts = collectFacts(repoRoot);
|
|
200
|
+
const ordered = [...manifest.archetypes].sort((a, b) => a.order - b.order);
|
|
201
|
+
const archetypes = ordered.map(({ id, order, runtimes, packageManagers, ui, api, deploy }) => ({ id, order, runtimes, packageManagers, ui, api, deploy }));
|
|
202
|
+
const selected = archetype
|
|
203
|
+
? manifest.archetypes.find((item) => item.id === archetype)
|
|
204
|
+
: manifest.archetypes.filter((item) => item.detection.signals.some((signal) => matches(signal, facts)))
|
|
205
|
+
.sort((a, b) => b.detection.priority - a.detection.priority || a.id.localeCompare(b.id))[0];
|
|
206
|
+
if (!selected) {
|
|
207
|
+
if (archetype) fail('unknown-archetype');
|
|
208
|
+
return { status: 'private', reason: 'insufficient-evidence', archetypes, selected: null };
|
|
209
|
+
}
|
|
210
|
+
const conditional = manifest.rulePolicy.conditional.filter((rule) => facts.dependencies.some((name) => rule.dependencies.includes(name) || rule.dependencyPrefixes.some((prefix) => name.startsWith(prefix))));
|
|
211
|
+
const ruleTargets = unique([...selected.ruleTargets, ...conditional.flatMap((rule) => rule.targets)]);
|
|
212
|
+
const owned = pluginRuleTargets(pluginRoot);
|
|
213
|
+
const sources = projection(root, ['rules', selected.id, '--repo', repoRoot], options).trim().split('\n').filter(Boolean);
|
|
214
|
+
const baselineRules = [];
|
|
215
|
+
const seen = new Set();
|
|
216
|
+
for (const source of sources) {
|
|
217
|
+
relative(source);
|
|
218
|
+
if (!/^(?:\.claude\/rules|templates\/shared\/\.claude\/rules)\/[A-Za-z0-9][A-Za-z0-9_.-]*\.md$/u.test(source)) fail('unsafe-source');
|
|
219
|
+
const name = path.posix.basename(source);
|
|
220
|
+
if (!ruleTargets.includes(name) || seen.has(name)) fail('invalid-rule-projection');
|
|
221
|
+
seen.add(name);
|
|
222
|
+
if (owned.includes(name)) continue;
|
|
223
|
+
baselineSourcePath(root, source);
|
|
224
|
+
baselineRules.push({ source, target: `.claude/rules/${name}` });
|
|
225
|
+
}
|
|
226
|
+
if (ruleTargets.some((name) => !seen.has(name))) fail('invalid-rule-projection');
|
|
227
|
+
baselineRules.sort((a, b) => a.target.localeCompare(b.target));
|
|
228
|
+
return { status: 'private', reason: archetype ? 'selected' : 'detected', archetypes, selected: {
|
|
229
|
+
...selected, browserAutomation: manifest.browserAutomation, ruleTargets,
|
|
230
|
+
pluginRuleTargets: ruleTargets.filter((name) => owned.includes(name)), baselineRules,
|
|
231
|
+
} };
|
|
232
|
+
} catch (error) {
|
|
233
|
+
return { status: 'error', reason: error instanceof BaselineContractError ? error.reason : 'lookup-failed', archetypes: [], selected: null };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function sourceFiles(root) {
|
|
238
|
+
const files = [];
|
|
239
|
+
let entries = 0;
|
|
240
|
+
function visit(relativePath, depth) {
|
|
241
|
+
if (depth > 16) fail('unsafe-source');
|
|
242
|
+
for (const entry of readdirSync(path.join(root, relativePath), { withFileTypes: true })) {
|
|
243
|
+
if (++entries > 20000 || entry.isSymbolicLink()) fail('unsafe-source');
|
|
244
|
+
const name = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
245
|
+
relative(name);
|
|
246
|
+
if (entry.isDirectory()) visit(name, depth + 1);
|
|
247
|
+
else if (entry.isFile()) files.push(name);
|
|
248
|
+
else fail('unsafe-source');
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
visit('', 0);
|
|
252
|
+
return files.sort();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Preflight the entire destination before the first copy; a user symlink is not
|
|
256
|
+
// an overwrite permission, including symlinks in a target's parent directories.
|
|
257
|
+
function copyPlan(sourceRoot, repoRoot, mappings) {
|
|
258
|
+
const plan = [];
|
|
259
|
+
for (const { source, target } of mappings) {
|
|
260
|
+
const from = baselineSourcePath(sourceRoot, source);
|
|
261
|
+
plan.push({ from, target, ...destinationState(repoRoot, target) });
|
|
262
|
+
}
|
|
263
|
+
return plan;
|
|
264
|
+
}
|
|
265
|
+
function destinationState(repoRoot, target) {
|
|
266
|
+
relative(target);
|
|
267
|
+
let current = repoRoot;
|
|
268
|
+
const segments = target.split('/');
|
|
269
|
+
let exists = false;
|
|
270
|
+
for (const [index, segment] of segments.entries()) {
|
|
271
|
+
current = path.join(current, segment);
|
|
272
|
+
try {
|
|
273
|
+
const stat = lstatSync(current);
|
|
274
|
+
if (stat.isSymbolicLink() || (index < segments.length - 1 ? !stat.isDirectory() : !stat.isFile())) fail('unsafe-destination');
|
|
275
|
+
if (index === segments.length - 1) exists = true;
|
|
276
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
277
|
+
}
|
|
278
|
+
return { to: current, exists };
|
|
279
|
+
}
|
|
280
|
+
function applyCopies(plan) {
|
|
281
|
+
const created = []; const preserved = [];
|
|
282
|
+
try {
|
|
283
|
+
for (const item of plan) {
|
|
284
|
+
if (item.exists) { preserved.push(item.target); continue; }
|
|
285
|
+
mkdirSync(path.dirname(item.to), { recursive: true });
|
|
286
|
+
copyFileSync(item.from, item.to, constants.COPYFILE_EXCL);
|
|
287
|
+
created.push(item.target);
|
|
288
|
+
}
|
|
289
|
+
} catch { return { status: 'error', reason: 'copy-failed', created, preserved }; }
|
|
290
|
+
return { status: 'applied', created, preserved };
|
|
291
|
+
}
|
|
292
|
+
function applyFailure(error) {
|
|
293
|
+
return { status: 'error', reason: error instanceof BaselineContractError ? error.reason : 'apply-failed', created: [], preserved: [] };
|
|
294
|
+
}
|
|
295
|
+
async function selectedContext(options) {
|
|
296
|
+
const result = await loadBaselineArchetypes(options);
|
|
297
|
+
if (result.status !== 'private' || !result.selected) fail(result.reason);
|
|
298
|
+
const root = await resolveBaselineLocation(options);
|
|
299
|
+
if (!root) fail('baseline-unavailable');
|
|
300
|
+
return { root, selected: result.selected };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function selectedCommands(selected) {
|
|
304
|
+
const ids = ['test', 'typecheck', 'lint'];
|
|
305
|
+
const unavailableGates = ids.filter((id) => !selected.qualityGates.some((gate) => gate.id === id));
|
|
306
|
+
const commands = Object.fromEntries(ids.map((id) => [id, { command: selected.qualityGates.find((gate) => gate.id === id)?.command ?? 'false', required: true }]));
|
|
307
|
+
return { commands, unavailableGates };
|
|
308
|
+
}
|
|
309
|
+
function normalizeStagedCommands(staging, selected, files) {
|
|
310
|
+
const { commands, unavailableGates } = selectedCommands(selected);
|
|
311
|
+
const ids = Object.keys(commands);
|
|
312
|
+
const lines = ids.map((id) => {
|
|
313
|
+
const { command } = commands[id];
|
|
314
|
+
const line = `${id}-command: ${command}${unavailableGates.includes(id) ? ` # ${id} unavailable in selected baseline contract` : ''}`;
|
|
315
|
+
// Existing Session Config parsing must preserve the command verbatim.
|
|
316
|
+
if (_parseKV([line]).get(`${id}-command`) !== command) fail('unsupported-command-config');
|
|
317
|
+
return line;
|
|
318
|
+
});
|
|
319
|
+
for (const name of ['CLAUDE.md', 'AGENTS.md']) {
|
|
320
|
+
if (!files.includes(name)) continue;
|
|
321
|
+
const filename = baselineSourcePath(staging, name);
|
|
322
|
+
const content = readFileSync(filename, 'utf8');
|
|
323
|
+
const block = findSessionConfigBlock(content);
|
|
324
|
+
let updated;
|
|
325
|
+
if (block) {
|
|
326
|
+
const preserved = block.body.split('\n').filter((line) => !ids.some((id) => _parseKV([line]).has(`${id}-command`)));
|
|
327
|
+
updated = `${content.slice(0, block.bodyStart)}${preserved.join('\n').trimEnd()}\n${lines.join('\n')}\n\n${content.slice(block.bodyEnd)}`;
|
|
328
|
+
} else updated = `${content.trimEnd()}\n\n## Session Config\n\n${lines.join('\n')}\n`;
|
|
329
|
+
writeFileSync(filename, updated);
|
|
330
|
+
}
|
|
331
|
+
return unavailableGates;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Explicit bootstrap action: stage the canonical local renderer, then add only
|
|
335
|
+
* missing non-rule files. Lookup never calls this function. No Git/install/API.
|
|
336
|
+
*/
|
|
337
|
+
export async function scaffoldBaselineArchetype(options = {}) {
|
|
338
|
+
let staging;
|
|
339
|
+
try {
|
|
340
|
+
const { repoRoot = process.cwd(), projectName } = options;
|
|
341
|
+
if (typeof projectName !== 'string' || !ID.test(projectName)) fail('invalid-project-name');
|
|
342
|
+
const { root, selected } = await selectedContext(options);
|
|
343
|
+
const template = baselineSourcePath(root, selected.templatePath, true);
|
|
344
|
+
sourceFiles(template);
|
|
345
|
+
const shared = baselineSourcePath(root, 'templates/shared', true);
|
|
346
|
+
sourceFiles(shared);
|
|
347
|
+
const common = baselineSourcePath(root, 'scripts/lib/common.sh');
|
|
348
|
+
const renderer = baselineSourcePath(root, 'scripts/lib/render-archetype.sh');
|
|
349
|
+
staging = mkdtempSync(path.join(tmpdir(), 'baseline-bootstrap-'));
|
|
350
|
+
const result = spawnSync('bash', ['-euo', 'pipefail', '-c', [
|
|
351
|
+
'source "$1"', 'source "$2"', 'export TEMPLATES_DIR="$3/templates"',
|
|
352
|
+
'render_archetype_dir "$4" "$6"', 'render_shared_and_substitute "$5" "$6"',
|
|
353
|
+
'render_archetype_metadata "$4" "$5" "$6"',
|
|
354
|
+
].join('\n'), 'baseline-render', common, renderer, root, selected.id, projectName, staging], {
|
|
355
|
+
cwd: root, encoding: 'utf8', shell: false, timeout: 30000, maxBuffer: MAX_BYTES, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'pipe'],
|
|
356
|
+
});
|
|
357
|
+
if (result.error || result.status !== 0) fail('render-failed');
|
|
358
|
+
const files = sourceFiles(staging).filter((name) => !name.startsWith('.claude/rules/') && !name.startsWith('.git/') && !name.startsWith('.orchestrator/'));
|
|
359
|
+
if (files.some((name) => name.endsWith('.template'))) fail('incomplete-render');
|
|
360
|
+
// A declared CI requirement remains archetype-owned, regardless of tier.
|
|
361
|
+
if (selected.ci.required && !files.some((name) => name === '.gitlab-ci.yml' || name.startsWith('.github/workflows/'))) fail('incomplete-render');
|
|
362
|
+
const unavailableGates = normalizeStagedCommands(staging, selected, files);
|
|
363
|
+
return { ...applyCopies(copyPlan(staging, repoRoot, files.map((name) => ({ source: name, target: name })))), unavailableGates };
|
|
364
|
+
} catch (error) { return applyFailure(error); }
|
|
365
|
+
finally { if (staging) rmSync(staging, { recursive: true, force: true }); }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Explicit S99 private action: re-resolve conditions from the rendered repo,
|
|
369
|
+
* validate every source, and preserve all existing local files. Plugin-owned
|
|
370
|
+
* basenames never enter the copy plan; rules-sync remains their sole writer.
|
|
371
|
+
*/
|
|
372
|
+
export async function applyBaselineRules(options = {}) {
|
|
373
|
+
try {
|
|
374
|
+
const { repoRoot = process.cwd() } = options;
|
|
375
|
+
const { root, selected } = await selectedContext(options);
|
|
376
|
+
return applyCopies(copyPlan(root, repoRoot, selected.baselineRules));
|
|
377
|
+
} catch (error) { return applyFailure(error); }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function hasUnselectedFastLock(repoRoot) {
|
|
381
|
+
try {
|
|
382
|
+
const content = readFileSync(baselineSourcePath(repoRoot, '.orchestrator/bootstrap.lock'), 'utf8');
|
|
383
|
+
return Object.entries({ version: '1', tier: 'fast', archetype: 'null' }).every(([key, expected]) => {
|
|
384
|
+
const matches = [...content.matchAll(new RegExp(`^${key}:[ \\t]*(.*)$`, 'gm'))];
|
|
385
|
+
if (matches.length !== 1) return false;
|
|
386
|
+
const value = matches[0][1].replace(/\s+#.*$/, '').trim().replace(/^(?:"(.*)"|'(.*)')$/, '$1$2');
|
|
387
|
+
return value === expected;
|
|
388
|
+
});
|
|
389
|
+
} catch { return false; }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Bootstrap rule action. Contract lookup stays outside the synchronous writer;
|
|
393
|
+
* private required basenames add to its normal selection, never bypass gates.
|
|
394
|
+
* A later --sync-rules uses the lock ID before falling back to repo markers.
|
|
395
|
+
*/
|
|
396
|
+
export async function syncBootstrapRules(options = {}) {
|
|
397
|
+
try {
|
|
398
|
+
const { repoRoot = process.cwd(), pluginRoot = DEFAULT_PLUGIN_ROOT, dryRun = false } = options;
|
|
399
|
+
const archetype = resolveArchetype(repoRoot, options.archetype).archetype;
|
|
400
|
+
// Standalone Fast has no selected archetype and deliberately uses only
|
|
401
|
+
// ordinary plugin rules, just as its minimal scaffold does.
|
|
402
|
+
const contract = options.minimal ? { status: 'public', selected: null }
|
|
403
|
+
: await loadBaselineArchetypes({ ...options, repoRoot, pluginRoot, archetype });
|
|
404
|
+
if (contract.status === 'error') fail(contract.reason);
|
|
405
|
+
if (contract.status === 'private' && !contract.selected && !hasUnselectedFastLock(repoRoot)) fail(contract.reason);
|
|
406
|
+
const before = new Set();
|
|
407
|
+
for (const name of pluginRuleTargets(pluginRoot)) {
|
|
408
|
+
if (contract.status === 'private') {
|
|
409
|
+
if (destinationState(repoRoot, `.claude/rules/${name}`).exists) before.add(name);
|
|
410
|
+
} else {
|
|
411
|
+
try { lstatSync(path.join(repoRoot, '.claude/rules', name)); before.add(name); }
|
|
412
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const result = syncRules({ pluginRoot, repoRoot, dryRun, categories: options.categories, archetype: contract.selected?.id ?? archetype,
|
|
416
|
+
requiredBasenames: contract.selected?.pluginRuleTargets ?? null });
|
|
417
|
+
return { ...result, status: result.errors.length ? 'error' : 'applied',
|
|
418
|
+
created: dryRun ? [] : result.written.filter((name) => !before.has(name)).map((name) => `.claude/rules/${name}`) };
|
|
419
|
+
} catch (error) { return { ...applyFailure(error), written: [], skipped: [], errors: [], warnings: [], sanitizer: [] }; }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Explicit private policy action. Existing owner policy is never overwritten;
|
|
423
|
+
* absent generic slots fail honestly instead of inheriting package defaults.
|
|
424
|
+
*/
|
|
425
|
+
export async function writeBaselineQualityPolicy(options = {}) {
|
|
426
|
+
try {
|
|
427
|
+
const { repoRoot = process.cwd() } = options;
|
|
428
|
+
const target = '.orchestrator/policy/quality-gates.json';
|
|
429
|
+
const destination = destinationState(repoRoot, target);
|
|
430
|
+
if (destination.exists) return { status: 'applied', created: [], preserved: [target] };
|
|
431
|
+
const { selected } = await selectedContext(options);
|
|
432
|
+
const { commands, unavailableGates } = selectedCommands(selected);
|
|
433
|
+
const rationale = ['Commands from the selected baseline contract; only exact test/typecheck/lint IDs are used.',
|
|
434
|
+
...unavailableGates.map((id) => `${id} unavailable in selected baseline contract; false prevents a passing substitute.`)].join(' ');
|
|
435
|
+
mkdirSync(path.dirname(destination.to), { recursive: true });
|
|
436
|
+
writeFileSync(destination.to, `${JSON.stringify({ version: 1, rationale, commands }, null, 2)}\n`, { flag: 'wx' });
|
|
437
|
+
return { status: 'applied', created: [target], preserved: [], unavailableGates };
|
|
438
|
+
} catch (error) { return applyFailure(error); }
|
|
439
|
+
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { existsSync, readFileSync } from 'node:fs';
|
|
18
18
|
import { resolve } from 'node:path';
|
|
19
|
-
import { parseStateMd, parseRecommendations } from './state-md.mjs';
|
|
19
|
+
import { parseStateMd, parseRecommendations, resolveStateMdPath } from './state-md.mjs';
|
|
20
20
|
import { normalizeSession, tailRealSessions } from './session-schema.mjs';
|
|
21
21
|
import { parseBootstrapLock } from './bootstrap-lock-freshness.mjs';
|
|
22
22
|
import { scanBacklog, DEFAULT_BACKLOG_LIMIT } from './backlog-scan.mjs';
|
|
@@ -41,7 +41,7 @@ import { readCanonicalSessions } from './sessions-canonical.mjs';
|
|
|
41
41
|
* sessions.jsonl than the one it was reporting on — measured as
|
|
42
42
|
* `recentSessions: []` against a checkout holding 245 session records.
|
|
43
43
|
* An explicit absolute `statePath`/`sessionsPath`/`lockPath` still wins.
|
|
44
|
-
* @param {string} [opts.statePath] — defaults to
|
|
44
|
+
* @param {string} [opts.statePath] — defaults to the active harness STATE.md, with legacy fallback
|
|
45
45
|
* @param {string} [opts.sessionsPath] — defaults to '<repoRoot>/.orchestrator/metrics/sessions.jsonl'
|
|
46
46
|
* @param {string} [opts.lockPath] — defaults to '<repoRoot>/.orchestrator/bootstrap.lock'
|
|
47
47
|
* @param {Array} [opts.learnings] — pre-surfaced top-N learnings; defaults to []
|
|
@@ -61,10 +61,9 @@ export async function buildLiveSignals(opts = {}) {
|
|
|
61
61
|
: process.cwd();
|
|
62
62
|
// `resolve(root, p)` returns `p` unchanged when `p` is absolute — explicit
|
|
63
63
|
// per-file overrides therefore keep precedence over repoRoot.
|
|
64
|
-
const statePath =
|
|
65
|
-
repoRoot,
|
|
66
|
-
|
|
67
|
-
);
|
|
64
|
+
const statePath = typeof opts.statePath === 'string'
|
|
65
|
+
? resolve(repoRoot, opts.statePath)
|
|
66
|
+
: resolveStateMdPath(repoRoot);
|
|
68
67
|
const sessionsPath = resolve(
|
|
69
68
|
repoRoot,
|
|
70
69
|
typeof opts.sessionsPath === 'string'
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { matchBlockHeader } from './block-header.mjs';
|
|
2
2
|
import { preprocessBlockLines } from './block-preprocess.mjs';
|
|
3
|
+
import { _coerceInteger } from './coercers.mjs';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* issue-budget.mjs — Parser for the `issue-budget:` block-style Session Config key.
|
|
@@ -17,10 +18,13 @@ import { preprocessBlockLines } from './block-preprocess.mjs';
|
|
|
17
18
|
* `scripts/lib/spiral-carryover.mjs` `runCli()` (programmatic path), both via
|
|
18
19
|
* `scripts/lib/issue-budget.mjs`.
|
|
19
20
|
*
|
|
20
|
-
* Returns `{ "max-per-session", mode, overflow }`.
|
|
21
|
-
* Tolerant parser: malformed values
|
|
21
|
+
* Returns `{ "max-per-session", "max-per-session-raw", mode, overflow }`.
|
|
22
|
+
* Tolerant parser: malformed values fall back to defaults (the
|
|
22
23
|
* `reconcile.min-rule-days` posture, NOT the noisier handover-gate WARN) —
|
|
23
|
-
* a hook must never spam stderr on every single Bash call.
|
|
24
|
+
* a hook must never spam stderr on every single Bash call. ONE exception, and
|
|
25
|
+
* it is a safety one: a malformed per-session OVERRIDE with a valid base number
|
|
26
|
+
* (`7 (feature: x)`) keeps the base 7 and WARNs on stderr, because falling back
|
|
27
|
+
* to the built-in 12 there would silently LOOSEN a cap the operator tightened.
|
|
24
28
|
*
|
|
25
29
|
* Consumers: `scripts/lib/config.mjs`, `scripts/lib/issue-budget.mjs`,
|
|
26
30
|
* `hooks/pre-bash-issue-budget.mjs`, `skills/session-end/SKILL.md` Phase 5.
|
|
@@ -44,7 +48,9 @@ const OVERFLOW_SINKS = ['collect-issue', 'vault-note'];
|
|
|
44
48
|
* Defaults:
|
|
45
49
|
* max-per-session: 12 integer >= 0; 0 means "no issue may be created"
|
|
46
50
|
* (a valid, deliberately harsh setting). Malformed
|
|
47
|
-
* or negative input falls back to 12
|
|
51
|
+
* or negative input falls back to 12 — EXCEPT a
|
|
52
|
+
* malformed override over a valid base (`7 (x: y)`),
|
|
53
|
+
* which keeps 7 and WARNs.
|
|
48
54
|
* mode: strict strict | warn | off
|
|
49
55
|
* overflow: collect-issue collect-issue | vault-note
|
|
50
56
|
*
|
|
@@ -54,12 +60,29 @@ const OVERFLOW_SINKS = ['collect-issue', 'vault-note'];
|
|
|
54
60
|
* mode: strict
|
|
55
61
|
* overflow: collect-issue
|
|
56
62
|
*
|
|
63
|
+
* NEW POLICY (not a restatement of an existing rule): `max-per-session` accepts
|
|
64
|
+
* the SAME per-session-type override syntax `agents-per-wave` already uses —
|
|
65
|
+
* `12 (feature: 6)` parses via `_coerceInteger` into
|
|
66
|
+
* `{ default: 12, feature: 6 }`. The key set is OPEN (any session-type label
|
|
67
|
+
* the operator writes), because the session-type vocabulary lives in
|
|
68
|
+
* session-start, not here.
|
|
69
|
+
*
|
|
70
|
+
* The parsed override object is returned under `"max-per-session-raw"`, while
|
|
71
|
+
* `"max-per-session"` stays STRICTLY NUMERIC (the `.default`). That split is
|
|
72
|
+
* load-bearing: three consumers read the key as a number
|
|
73
|
+
* (`scripts/lib/issue-budget.mjs` `chargeIssueBudget`,
|
|
74
|
+
* `hooks/pre-bash-issue-budget.mjs`, and the config-parity docs), and handing
|
|
75
|
+
* any of them an object would surface as `[object Object]` in a cap comparison
|
|
76
|
+
* rather than as an error. Resolution against the CURRENT session type is
|
|
77
|
+
* `resolveMaxPerSession()` in `scripts/lib/issue-budget.mjs`.
|
|
78
|
+
*
|
|
57
79
|
* @param {string} content — full file contents
|
|
58
|
-
* @returns {{ "max-per-session": number, mode: string, overflow: string }}
|
|
80
|
+
* @returns {{ "max-per-session": number, "max-per-session-raw": number|{default: number, [k: string]: number}, mode: string, overflow: string }}
|
|
59
81
|
*/
|
|
60
82
|
export function _parseIssueBudget(content) {
|
|
61
83
|
const defaults = {
|
|
62
84
|
'max-per-session': 12,
|
|
85
|
+
'max-per-session-raw': 12,
|
|
63
86
|
mode: 'strict',
|
|
64
87
|
overflow: 'collect-issue',
|
|
65
88
|
};
|
|
@@ -84,6 +107,7 @@ export function _parseIssueBudget(content) {
|
|
|
84
107
|
if (blockLines.length === 0) return { ...defaults };
|
|
85
108
|
|
|
86
109
|
let maxPerSession = defaults['max-per-session'];
|
|
110
|
+
let maxPerSessionRaw = defaults['max-per-session-raw'];
|
|
87
111
|
let mode = defaults.mode;
|
|
88
112
|
let overflow = defaults.overflow;
|
|
89
113
|
|
|
@@ -101,10 +125,45 @@ export function _parseIssueBudget(content) {
|
|
|
101
125
|
else if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) v = v.slice(1, -1);
|
|
102
126
|
|
|
103
127
|
switch (k) {
|
|
104
|
-
case 'max-per-session':
|
|
105
|
-
// Non-negative integer
|
|
106
|
-
|
|
128
|
+
case 'max-per-session': {
|
|
129
|
+
// Non-negative integer, or the `N (type: M)` override form. A leading
|
|
130
|
+
// '-' fails \d+ inside `_coerceInteger`, which throws → default.
|
|
131
|
+
// Tolerant by contract: this parser runs on every Bash call via the
|
|
132
|
+
// hook, so a malformed value must fall back silently, never throw.
|
|
133
|
+
let coerced;
|
|
134
|
+
try {
|
|
135
|
+
coerced = _coerceInteger(new Map([['max-per-session', v]]), 'max-per-session', 12);
|
|
136
|
+
} catch {
|
|
137
|
+
// A malformed OVERRIDE (`7 (feature: x)`) must never restore the
|
|
138
|
+
// built-in 12: the operator wrote a base cap of 7, and discarding the
|
|
139
|
+
// whole value because the parenthesised part is unparseable LOOSENS
|
|
140
|
+
// the cap by three on one typo — silently, in the direction nobody
|
|
141
|
+
// would choose. Keep the base number, drop the override, and say so
|
|
142
|
+
// once (this branch is reachable only on a malformed value, so it
|
|
143
|
+
// cannot become per-Bash-call stderr spam).
|
|
144
|
+
const base = v.match(/^(\d+)\s*\(/);
|
|
145
|
+
if (base) {
|
|
146
|
+
const n = Number.parseInt(base[1], 10);
|
|
147
|
+
if (Number.isInteger(n) && n >= 0) {
|
|
148
|
+
maxPerSession = n;
|
|
149
|
+
maxPerSessionRaw = n;
|
|
150
|
+
process.stderr.write(
|
|
151
|
+
`⚠ issue-budget: malformed per-session override in 'max-per-session': '${v}' — ` +
|
|
152
|
+
`kept the base cap ${n} and dropped the override.\n`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
if (typeof coerced === 'number') {
|
|
159
|
+
maxPerSession = coerced;
|
|
160
|
+
maxPerSessionRaw = coerced;
|
|
161
|
+
} else if (Number.isInteger(coerced?.default) && coerced.default >= 0) {
|
|
162
|
+
maxPerSession = coerced.default;
|
|
163
|
+
maxPerSessionRaw = coerced;
|
|
164
|
+
}
|
|
107
165
|
break;
|
|
166
|
+
}
|
|
108
167
|
|
|
109
168
|
case 'mode':
|
|
110
169
|
if (MODES.includes(v.toLowerCase())) mode = v.toLowerCase();
|
|
@@ -118,6 +177,7 @@ export function _parseIssueBudget(content) {
|
|
|
118
177
|
|
|
119
178
|
return {
|
|
120
179
|
'max-per-session': maxPerSession,
|
|
180
|
+
'max-per-session-raw': maxPerSessionRaw,
|
|
121
181
|
mode,
|
|
122
182
|
overflow,
|
|
123
183
|
};
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* `owner.yaml`, the self-alias ledger) moves with it, so the precedence has to
|
|
6
6
|
* live in exactly ONE place. Before #1223 three copies with three different
|
|
7
7
|
* precedences existed: `owner-yaml.mjs`'s import-time homedir-only
|
|
8
|
-
* `OWNER_YAML_PATH`, `owner-config-loader.mjs`'s XDG-only +
|
|
9
|
-
* `resolveOwnerConfigPath()`, and `host-identity.mjs`'s
|
|
8
|
+
* `OWNER_YAML_PATH`, the now-removed `owner-config-loader.mjs`'s XDG-only +
|
|
9
|
+
* untrimmed `resolveOwnerConfigPath()`, and `host-identity.mjs`'s
|
|
10
|
+
* `_privateDir()`. The
|
|
10
11
|
* live consequence: `SO_CONFIG_HOME=<sandbox>` moved the alias ledger but NOT
|
|
11
12
|
* owner.yaml, which kept being read from the operator's REAL home — the
|
|
12
13
|
* CLAUDE.md (Codex CLI alias: AGENTS.md) "vault-dir resolves HOST-LOCALLY"
|
|
@@ -40,8 +40,8 @@ export const REMOTE_HOST_DEFAULTS = Object.freeze({
|
|
|
40
40
|
* scripts/lib/config.mjs — impl / db / security / compliance / docs stay local.
|
|
41
41
|
*
|
|
42
42
|
* NOT the wave-role enum ("Impl-Core", "Quality", …): those are two different
|
|
43
|
-
* enums and conflating them is the documented trap (see
|
|
44
|
-
* in wave-resource-gate.mjs). The wave→agent-mapping-role translation is
|
|
43
|
+
* enums and conflating them is the documented trap (see the MODE_BLIND_SESSION_TYPE
|
|
44
|
+
* note in wave-resource-gate.mjs). The wave→agent-mapping-role translation is
|
|
45
45
|
* `OFFLOADABLE_WAVE_ROLES` in that same module.
|
|
46
46
|
*
|
|
47
47
|
* @type {readonly string[]}
|