session-orchestrator 4.0.1 → 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.
Files changed (145) hide show
  1. package/.agents/skills/session-plan/SKILL.md +1 -1
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
  6. package/.cursor/skills/session-plan/SKILL.md +1 -1
  7. package/.cursor-plugin/plugin.json +1 -1
  8. package/CHANGELOG.md +57 -0
  9. package/README.md +55 -51
  10. package/agents/ux-evaluator.md +1 -1
  11. package/commands/close.md +3 -3
  12. package/commands/go.md +2 -0
  13. package/commands/memory-cleanup.md +4 -3
  14. package/commands/persona-panel.md +1 -1
  15. package/commands/release.md +4 -4
  16. package/commands/session.md +3 -2
  17. package/docs/README.md +4 -4
  18. package/docs/USER-GUIDE.md +115 -48
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +55 -1
  21. package/docs/ci-setup.md +1 -1
  22. package/docs/codex-setup.md +1 -0
  23. package/docs/components.md +2 -2
  24. package/docs/cursor-setup.md +1 -0
  25. package/docs/events-schema.md +4 -1
  26. package/docs/instruction-delivery.md +1 -1
  27. package/docs/memory-proposal-flow.md +3 -3
  28. package/docs/migration-v4.md +2 -2
  29. package/docs/owner-config-schema.md +74 -90
  30. package/docs/persona-panel.md +4 -4
  31. package/docs/pi-setup.md +1 -0
  32. package/docs/rule-authoring.md +13 -6
  33. package/docs/scope-collision-guard.md +2 -0
  34. package/docs/session-config-reference.md +55 -22
  35. package/docs/session-config-template.md +9 -5
  36. package/docs/vault-docs-architecture.md +4 -2
  37. package/hooks/_lib/hook-import-set.json +28 -3
  38. package/hooks/_lib/vcs-create-matcher.mjs +214 -16
  39. package/hooks/hooks-codex.json +1 -1
  40. package/hooks/hooks.json +1 -1
  41. package/hooks/pre-bash-issue-budget.mjs +123 -26
  42. package/hooks/subagent-telemetry.mjs +106 -20
  43. package/package.json +4 -4
  44. package/scripts/baseline-archetypes.mjs +28 -0
  45. package/scripts/ci/assert-coverage-green.mjs +100 -0
  46. package/scripts/lib/auto-dialectic.mjs +0 -68
  47. package/scripts/lib/baseline-archetypes.mjs +439 -0
  48. package/scripts/lib/build-live-signals.mjs +5 -6
  49. package/scripts/lib/config/issue-budget.mjs +68 -8
  50. package/scripts/lib/config/private-config-dir.mjs +3 -2
  51. package/scripts/lib/config/remote-hosts.mjs +2 -2
  52. package/scripts/lib/config-schema.mjs +79 -0
  53. package/scripts/lib/events.mjs +3 -3
  54. package/scripts/lib/file-lock.mjs +47 -5
  55. package/scripts/lib/issue-budget-reconcile.mjs +392 -0
  56. package/scripts/lib/issue-budget.mjs +76 -3
  57. package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
  58. package/scripts/lib/maintenance-due-banner.mjs +440 -0
  59. package/scripts/lib/owner-config.example.yaml +29 -46
  60. package/scripts/lib/owner-yaml.mjs +14 -13
  61. package/scripts/lib/project-hygiene.mjs +182 -6
  62. package/scripts/lib/quality-gate.mjs +13 -6
  63. package/scripts/lib/resource-probe/evaluate.mjs +19 -21
  64. package/scripts/lib/rules-sync.mjs +34 -4
  65. package/scripts/lib/session-close-backfill.mjs +182 -40
  66. package/scripts/lib/session-end/phase-skip.mjs +85 -86
  67. package/scripts/lib/session-end/tail-runner.mjs +178 -0
  68. package/scripts/lib/session-identity/own-session.mjs +24 -13
  69. package/scripts/lib/session-schema/constants.mjs +6 -0
  70. package/scripts/lib/session-schema/validator.mjs +20 -0
  71. package/scripts/lib/session-shape.mjs +558 -0
  72. package/scripts/lib/session-start-probes.mjs +10 -3
  73. package/scripts/lib/session-token-rollup.mjs +95 -10
  74. package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
  75. package/scripts/lib/state-md.mjs +1 -0
  76. package/scripts/lib/subagents-schema.mjs +77 -9
  77. package/scripts/lib/telemetry/pricing.mjs +197 -0
  78. package/scripts/lib/telemetry/sync.mjs +50 -1
  79. package/scripts/lib/validate/check-owner-leakage.mjs +17 -8
  80. package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
  81. package/scripts/lib/validate/check-unwired-features.mjs +8 -7
  82. package/scripts/lib/vault-mirror/process.mjs +2 -1
  83. package/scripts/lib/vault-mirror/render-sessions.mjs +8 -1
  84. package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
  85. package/scripts/lib/wave-resource-gate.mjs +23 -27
  86. package/scripts/lib/wave-sizing.mjs +10 -3
  87. package/scripts/materialize-wave-scope.mjs +68 -14
  88. package/scripts/print-applicable-rules.mjs +7 -6
  89. package/scripts/print-learnings-index.mjs +3 -2
  90. package/scripts/release.mjs +32 -11
  91. package/scripts/session-shape.mjs +266 -0
  92. package/skills/_shared/config-reading.md +15 -9
  93. package/skills/_shared/private-capability-context.md +89 -0
  94. package/skills/bootstrap/SKILL.md +61 -13
  95. package/skills/bootstrap/_shared-template.md +99 -14
  96. package/skills/bootstrap/deep-template.md +36 -26
  97. package/skills/bootstrap/fast-template.md +44 -8
  98. package/skills/bootstrap/intensity-heuristic.md +10 -4
  99. package/skills/bootstrap/private-contract.md +119 -0
  100. package/skills/bootstrap/public-fallback.md +30 -18
  101. package/skills/bootstrap/standard-template.md +39 -24
  102. package/skills/discovery/probes-ui.md +1 -1
  103. package/skills/docs-orchestrator/audience-mapping.md +1 -1
  104. package/skills/evolve/SKILL.md +2 -2
  105. package/skills/gitlab-ops/SKILL.md +3 -3
  106. package/skills/grill/SKILL.md +1 -1
  107. package/skills/memory-cleanup/SKILL.md +2 -2
  108. package/skills/plan/mode-new.md +9 -0
  109. package/skills/reconcile/SKILL.md +1 -1
  110. package/skills/session-end/SKILL.md +3 -2
  111. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  112. package/skills/session-end/phase-3-6-tail.md +23 -65
  113. package/skills/session-end/phase-3-7a-recommendations.md +2 -2
  114. package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
  115. package/skills/session-end/references/phase-5-issue-cleanup.md +26 -0
  116. package/skills/session-end/session-metrics-write.md +31 -12
  117. package/skills/session-plan/SKILL.md +56 -48
  118. package/skills/session-plan/wave-template.md +8 -15
  119. package/skills/session-start/SKILL.md +18 -2
  120. package/skills/session-start/phase-2-5-docs-planning.md +1 -1
  121. package/skills/session-start/phase-8-5-express-path.md +12 -9
  122. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
  123. package/skills/session-start/references/phase-4-ssot-environment-check.md +21 -5
  124. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
  125. package/skills/test-runner/rubric-v1.md +2 -2
  126. package/skills/wave-executor/SKILL.md +42 -12
  127. package/skills/wave-executor/circuit-breaker.md +3 -1
  128. package/skills/wave-executor/references/wave-loop-dispatch.md +4 -2
  129. package/skills/wave-executor/references/wave-loop-review.md +1 -1
  130. package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
  131. package/templates/nextjs-minimal/package.json +1 -1
  132. package/templates/node-minimal/package.json +1 -1
  133. package/scripts/lib/multi-provider-build/providers.mjs +0 -64
  134. package/scripts/lib/multi-provider-build/templating.mjs +0 -130
  135. package/scripts/lib/owner-config/coerce.mjs +0 -29
  136. package/scripts/lib/owner-config/constants.mjs +0 -21
  137. package/scripts/lib/owner-config/defaults.mjs +0 -50
  138. package/scripts/lib/owner-config/error.mjs +0 -19
  139. package/scripts/lib/owner-config/index.mjs +0 -13
  140. package/scripts/lib/owner-config/merge.mjs +0 -52
  141. package/scripts/lib/owner-config/validate.mjs +0 -259
  142. package/scripts/lib/owner-config-loader.mjs +0 -170
  143. package/scripts/lib/owner-config.mjs +0 -28
  144. package/scripts/lib/soul-resolve.mjs +0 -130
  145. package/scripts/lib/vault-mirror/render.mjs +0 -8
@@ -23,7 +23,6 @@ import { randomUUID } from 'node:crypto';
23
23
  import path from 'node:path';
24
24
 
25
25
  import { filterRealSessions } from './session-schema.mjs';
26
- import { emitEvent, sessionAttribution } from './events.mjs';
27
26
 
28
27
  // ---------------------------------------------------------------------------
29
28
  // Constants
@@ -246,73 +245,6 @@ export async function shouldDispatchAutoDialectic({
246
245
  };
247
246
  }
248
247
 
249
- // ---------------------------------------------------------------------------
250
- // Decision + mechanical telemetry (#1200 part c)
251
- // ---------------------------------------------------------------------------
252
-
253
- /**
254
- * `shouldDispatchAutoDialectic()` plus a MECHANICAL `orchestrator.dialectic.nudge_decided`
255
- * record, so the nudge decision is observable without depending on the
256
- * session-end skill prose actually reaching the emit step (#1200: 0 records of
257
- * this class across 164k fleet events despite the nudge firing every close).
258
- *
259
- * Contract-preserving wrapper: calls `shouldDispatchAutoDialectic()` unchanged
260
- * and returns its decision object verbatim — the emit is a side effect bolted
261
- * on, never a change to the decision logic or its return shape.
262
- *
263
- * Emits on ALL FOUR return paths (kill-switch, no-new-input,
264
- * cadence-threshold-met, under-threshold) — best-effort, try/catch-wrapped,
265
- * because `emitEvent()` throws `EventValidationError` on a malformed record
266
- * and a telemetry failure must never change what the caller decides to do
267
- * (same posture as `scripts/lib/reconcile/engine.mjs`'s `emitReconcileCompleted`
268
- * wrapper).
269
- *
270
- * @param {object} args
271
- * @param {string} args.repoRoot
272
- * @param {number} [args.cadence=DEFAULT_CADENCE] `dialectic.cadence` from config.
273
- * @param {object} [args.signals] Pre-computed signals (skips disk reads) — forwarded verbatim.
274
- * @param {Function|null} [args.emitFn=emitEvent] DI hook for testing / disabling
275
- * emission; defaults to `emitEvent` from `./events.mjs`. Any error it throws
276
- * is swallowed — it never changes the returned decision.
277
- * @param {boolean} [args.record=true] When `false`, no event is emitted at all —
278
- * for read-only PROBE callers (e.g. the session-end Phase 3.6.x tail-skip
279
- * aggregator, `scripts/lib/session-end/phase-skip.mjs`, whose documented
280
- * contract is side-effect-free) where the decision is computed for internal
281
- * branching only and must never itself be recorded as a nudge decision.
282
- * @returns {Promise<{trigger:boolean, reason:string, signals:object}>}
283
- */
284
- export async function decideAndRecordAutoDialectic({
285
- repoRoot,
286
- cadence = DEFAULT_CADENCE,
287
- signals,
288
- emitFn = emitEvent,
289
- record = true,
290
- } = {}) {
291
- const decision = await shouldDispatchAutoDialectic({ repoRoot, cadence, signals });
292
-
293
- if (record && typeof emitFn === 'function') {
294
- try {
295
- await emitFn(
296
- 'orchestrator.dialectic.nudge_decided',
297
- {
298
- ...sessionAttribution(repoRoot),
299
- decided: decision.trigger,
300
- reason: decision.reason,
301
- cadence,
302
- sessions_since: decision.signals?.sessionsSinceLast,
303
- learnings_since: decision.signals?.learningsSinceLast,
304
- },
305
- { repoRoot },
306
- );
307
- } catch {
308
- // best-effort — a telemetry failure must never block or change the
309
- // decision the caller already has in hand.
310
- }
311
- }
312
-
313
- return decision;
314
- }
315
-
316
248
  // ---------------------------------------------------------------------------
317
249
  // last-run — atomic write
318
250
  // ---------------------------------------------------------------------------
@@ -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 '<repoRoot>/.claude/STATE.md'
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 = resolve(
65
- repoRoot,
66
- typeof opts.statePath === 'string' ? opts.statePath : '.claude/STATE.md'
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'