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.
- 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 +57 -0
- package/README.md +55 -51
- 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/release.md +4 -4
- 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/ci/assert-coverage-green.mjs +100 -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/events.mjs +3 -3
- 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/project-hygiene.mjs +182 -6
- 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-identity/own-session.mjs +24 -13
- 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-owner-leakage.mjs +17 -8
- 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-mirror/render-sessions.mjs +8 -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/release.mjs +32 -11
- 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
|
@@ -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
|
|
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'
|