session-orchestrator 4.1.0 → 5.0.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 (230) hide show
  1. package/.agents/skills/session-plan/SKILL.md +1 -1
  2. package/.agents/skills/session-start/SKILL.md +1 -1
  3. package/.agents/skills/ux-grill/SKILL.md +22 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +3 -2
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
  8. package/.codex-plugin/skills/session-start/SKILL.md +1 -1
  9. package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
  10. package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
  11. package/.cursor/commands/ux-grill.md +14 -0
  12. package/.cursor/skills/session-plan/SKILL.md +1 -1
  13. package/.cursor/skills/session-start/SKILL.md +1 -1
  14. package/.cursor/skills/ux-grill/SKILL.md +13 -0
  15. package/.cursor-plugin/plugin.json +1 -1
  16. package/AGENTS.md +2 -1
  17. package/CHANGELOG.md +128 -1
  18. package/README.md +98 -86
  19. package/agents/dialectic-deriver.md +11 -0
  20. package/agents/ux-evaluator.md +1 -1
  21. package/commands/close.md +3 -3
  22. package/commands/go.md +2 -0
  23. package/commands/memory-cleanup.md +4 -3
  24. package/commands/persona-panel.md +1 -1
  25. package/commands/session.md +3 -2
  26. package/commands/ux-grill.md +51 -0
  27. package/docs/README.md +4 -4
  28. package/docs/USER-GUIDE.md +117 -50
  29. package/docs/agent-authoring.md +2 -2
  30. package/docs/baseline.md +55 -1
  31. package/docs/ci-setup.md +1 -1
  32. package/docs/codex-setup.md +9 -0
  33. package/docs/components.md +9 -9
  34. package/docs/cursor-setup.md +1 -0
  35. package/docs/events-schema.md +13 -6
  36. package/docs/github-mirror-protection.md +61 -20
  37. package/docs/instruction-delivery.md +1 -1
  38. package/docs/memory-proposal-flow.md +3 -3
  39. package/docs/migration-v4.md +2 -2
  40. package/docs/migration-v5.md +62 -0
  41. package/docs/owner-config-schema.md +74 -90
  42. package/docs/persona-panel.md +4 -4
  43. package/docs/pi-setup.md +1 -0
  44. package/docs/rule-authoring.md +13 -6
  45. package/docs/scope-collision-guard.md +16 -0
  46. package/docs/session-config-reference.md +55 -22
  47. package/docs/session-config-template.md +9 -5
  48. package/docs/vault-docs-architecture.md +4 -2
  49. package/hooks/_lib/hook-import-set.json +70 -3
  50. package/hooks/_lib/lock-bootstrap.mjs +84 -1
  51. package/hooks/_lib/vcs-create-matcher.mjs +401 -16
  52. package/hooks/enforce-scope.mjs +201 -0
  53. package/hooks/hooks-codex.json +1 -1
  54. package/hooks/hooks-cursor.json +5 -0
  55. package/hooks/hooks.json +7 -2
  56. package/hooks/on-session-start.mjs +171 -49
  57. package/hooks/post-bash-issue-budget-refund.mjs +375 -0
  58. package/hooks/pre-auq-clarity.mjs +70 -18
  59. package/hooks/pre-bash-issue-budget.mjs +170 -26
  60. package/hooks/subagent-telemetry.mjs +106 -20
  61. package/package.json +5 -4
  62. package/pi/prompts/ux-grill.md +12 -0
  63. package/scripts/baseline-archetypes.mjs +28 -0
  64. package/scripts/ci/assert-vitest-green.mjs +4 -2
  65. package/scripts/dialectic-deriver.mjs +32 -8
  66. package/scripts/emit-session.mjs +72 -1
  67. package/scripts/lib/agent-status.mjs +441 -9
  68. package/scripts/lib/auq/schema.mjs +10 -3
  69. package/scripts/lib/auto-dialectic.mjs +0 -68
  70. package/scripts/lib/baseline-archetypes.mjs +439 -0
  71. package/scripts/lib/build-live-signals.mjs +5 -6
  72. package/scripts/lib/ci-status-banner.mjs +29 -6
  73. package/scripts/lib/claude-md-budget-lint.mjs +52 -2
  74. package/scripts/lib/config/issue-budget.mjs +68 -8
  75. package/scripts/lib/config/private-config-dir.mjs +3 -2
  76. package/scripts/lib/config/remote-hosts.mjs +2 -2
  77. package/scripts/lib/config-schema.mjs +79 -0
  78. package/scripts/lib/config.mjs +12 -1
  79. package/scripts/lib/eval/engine.mjs +7 -1
  80. package/scripts/lib/file-lock.mjs +151 -8
  81. package/scripts/lib/git-porcelain.mjs +113 -0
  82. package/scripts/lib/instruction-budget-guard.mjs +415 -47
  83. package/scripts/lib/io.mjs +29 -4
  84. package/scripts/lib/issue-budget-reconcile.mjs +392 -0
  85. package/scripts/lib/issue-budget.mjs +412 -9
  86. package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
  87. package/scripts/lib/learnings/sizing-subject.mjs +44 -0
  88. package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
  89. package/scripts/lib/locks/state-md-lock.mjs +19 -41
  90. package/scripts/lib/maintenance-due-banner.mjs +450 -0
  91. package/scripts/lib/owner-config.example.yaml +29 -46
  92. package/scripts/lib/owner-yaml.mjs +14 -13
  93. package/scripts/lib/peer-cards/merger.mjs +143 -0
  94. package/scripts/lib/pre-dispatch-check.mjs +20 -14
  95. package/scripts/lib/project-hygiene.mjs +81 -30
  96. package/scripts/lib/quality-gate.mjs +27 -71
  97. package/scripts/lib/reconcile/engine.mjs +19 -1
  98. package/scripts/lib/reconcile/writer.mjs +278 -11
  99. package/scripts/lib/resource-probe/evaluate.mjs +19 -21
  100. package/scripts/lib/rules-sync.mjs +34 -4
  101. package/scripts/lib/scope-echo.mjs +346 -0
  102. package/scripts/lib/session-close-backfill.mjs +182 -40
  103. package/scripts/lib/session-end/phase-skip.mjs +85 -86
  104. package/scripts/lib/session-end/tail-runner.mjs +178 -0
  105. package/scripts/lib/session-lock.mjs +62 -2
  106. package/scripts/lib/session-record-repair.mjs +91 -0
  107. package/scripts/lib/session-schema/constants.mjs +6 -0
  108. package/scripts/lib/session-schema/filters.mjs +26 -1
  109. package/scripts/lib/session-schema/validator.mjs +20 -0
  110. package/scripts/lib/session-shape.mjs +558 -0
  111. package/scripts/lib/session-start-probes.mjs +429 -56
  112. package/scripts/lib/session-token-rollup.mjs +95 -10
  113. package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
  114. package/scripts/lib/state-md.mjs +1 -0
  115. package/scripts/lib/subagents-schema.mjs +77 -9
  116. package/scripts/lib/telemetry/pricing.mjs +197 -0
  117. package/scripts/lib/telemetry/sync.mjs +50 -1
  118. package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
  119. package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
  120. package/scripts/lib/tmux-layout/layouts.mjs +62 -4
  121. package/scripts/lib/ux-grill/collect.mjs +1163 -0
  122. package/scripts/lib/ux-grill/compare.mjs +285 -0
  123. package/scripts/lib/ux-grill/manifest.mjs +618 -0
  124. package/scripts/lib/ux-grill/measures.mjs +431 -0
  125. package/scripts/lib/ux-grill/paths.mjs +224 -0
  126. package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
  127. package/scripts/lib/ux-grill/reconcile.mjs +344 -0
  128. package/scripts/lib/ux-grill/run-record.mjs +316 -0
  129. package/scripts/lib/ux-grill/schema.mjs +321 -0
  130. package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
  131. package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
  132. package/scripts/lib/validate/check-unwired-features.mjs +56 -27
  133. package/scripts/lib/vault-mirror/process.mjs +2 -1
  134. package/scripts/lib/vault-status/board-lock.mjs +18 -0
  135. package/scripts/lib/vault-status/board-writer.mjs +8 -0
  136. package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
  137. package/scripts/lib/wave-resource-gate.mjs +23 -27
  138. package/scripts/lib/wave-sizing.mjs +10 -3
  139. package/scripts/materialize-wave-scope.mjs +68 -14
  140. package/scripts/mcp-server.sh +16 -1
  141. package/scripts/print-applicable-rules.mjs +7 -6
  142. package/scripts/print-learnings-index.mjs +3 -2
  143. package/scripts/release.mjs +7 -2
  144. package/scripts/session-shape.mjs +266 -0
  145. package/skills/_shared/config-reading.md +15 -9
  146. package/skills/_shared/private-capability-context.md +89 -0
  147. package/skills/bootstrap/SKILL.md +60 -209
  148. package/skills/bootstrap/_shared-template.md +99 -14
  149. package/skills/bootstrap/deep-template.md +36 -26
  150. package/skills/bootstrap/fast-template.md +44 -8
  151. package/skills/bootstrap/intensity-heuristic.md +10 -4
  152. package/skills/bootstrap/private-contract.md +119 -0
  153. package/skills/bootstrap/public-fallback.md +30 -18
  154. package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
  155. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
  156. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
  157. package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
  158. package/skills/bootstrap/standard-template.md +39 -24
  159. package/skills/claude-md-drift-check/SKILL.md +9 -2
  160. package/skills/claude-md-drift-check/checker.mjs +213 -21
  161. package/skills/discovery/SKILL.md +6 -173
  162. package/skills/discovery/probes/vault-staleness.mjs +35 -5
  163. package/skills/discovery/probes-docs.md +8 -4
  164. package/skills/discovery/probes-supply-chain.md +4 -2
  165. package/skills/discovery/probes-ui.md +8 -4
  166. package/skills/discovery/probes-vault.md +12 -4
  167. package/skills/discovery/references/discovery-interactive-triage.md +139 -0
  168. package/skills/discovery/references/discovery-triage-state.md +54 -0
  169. package/skills/docs-orchestrator/audience-mapping.md +1 -1
  170. package/skills/eval/rubric-v1.md +13 -0
  171. package/skills/evolve/SKILL.md +2 -458
  172. package/skills/evolve/references/evolve-analyze-mode.md +360 -0
  173. package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
  174. package/skills/gitlab-ops/SKILL.md +3 -3
  175. package/skills/grill/SKILL.md +1 -1
  176. package/skills/memory-cleanup/SKILL.md +2 -2
  177. package/skills/plan/mode-new.md +9 -0
  178. package/skills/plan/mode-retro.md +4 -3
  179. package/skills/reconcile/SKILL.md +11 -1
  180. package/skills/session-end/SKILL.md +3 -2
  181. package/skills/session-end/drift-operations.md +20 -5
  182. package/skills/session-end/metrics-collection.md +1 -0
  183. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  184. package/skills/session-end/phase-3-6-tail.md +27 -67
  185. package/skills/session-end/phase-3-7a-recommendations.md +2 -2
  186. package/skills/session-end/references/phase-2-quality-gate.md +3 -3
  187. package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
  188. package/skills/session-end/references/phase-5-issue-cleanup.md +32 -1
  189. package/skills/session-end/session-metrics-write.md +33 -12
  190. package/skills/session-plan/SKILL.md +46 -180
  191. package/skills/session-plan/references/session-plan-task-classification.md +152 -0
  192. package/skills/session-plan/wave-template.md +8 -15
  193. package/skills/session-start/SKILL.md +41 -7
  194. package/skills/session-start/phase-2-5-docs-planning.md +1 -1
  195. package/skills/session-start/phase-8-5-express-path.md +12 -9
  196. package/skills/session-start/references/operations-contract.md +114 -0
  197. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
  198. package/skills/session-start/references/phase-4-ssot-environment-check.md +42 -24
  199. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
  200. package/skills/session-start/soul.md +2 -2
  201. package/skills/test-runner/SKILL.md +1 -1
  202. package/skills/test-runner/rubric-v1.md +2 -2
  203. package/skills/tmux-layout/SKILL.md +3 -1
  204. package/skills/ux-grill/SKILL.md +211 -0
  205. package/skills/ux-grill/rubric-v2.md +201 -0
  206. package/skills/ux-grill/soul.md +76 -0
  207. package/skills/wave-executor/SKILL.md +32 -127
  208. package/skills/wave-executor/circuit-breaker.md +3 -1
  209. package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
  210. package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
  211. package/skills/wave-executor/references/wave-loop-dispatch.md +12 -2
  212. package/skills/wave-executor/references/wave-loop-review.md +19 -6
  213. package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
  214. package/templates/_shared/ux-manifest.template.md +149 -0
  215. package/templates/nextjs-minimal/package.json +1 -1
  216. package/templates/node-minimal/package.json +1 -1
  217. package/scripts/lib/multi-provider-build/providers.mjs +0 -64
  218. package/scripts/lib/multi-provider-build/templating.mjs +0 -130
  219. package/scripts/lib/owner-config/coerce.mjs +0 -29
  220. package/scripts/lib/owner-config/constants.mjs +0 -21
  221. package/scripts/lib/owner-config/defaults.mjs +0 -50
  222. package/scripts/lib/owner-config/error.mjs +0 -19
  223. package/scripts/lib/owner-config/index.mjs +0 -13
  224. package/scripts/lib/owner-config/merge.mjs +0 -52
  225. package/scripts/lib/owner-config/validate.mjs +0 -259
  226. package/scripts/lib/owner-config-loader.mjs +0 -170
  227. package/scripts/lib/owner-config.mjs +0 -28
  228. package/scripts/lib/soul-resolve.mjs +0 -130
  229. package/scripts/lib/vault-mirror/render.mjs +0 -8
  230. package/templates/_shared/journey-manifest.md +0 -114
@@ -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'
@@ -531,7 +531,9 @@ async function checkGitlab(repoRoot, now, deps = {}) {
531
531
  return null;
532
532
  }
533
533
 
534
- const currentSha = await getHeadSha(repoRoot, deps);
534
+ // #1332: an explicit `deps.sha` (validated full hex SHA, see checkCiStatus)
535
+ // replaces the local HEAD lookup — the caller asks about a NAMED commit.
536
+ const currentSha = deps.sha ?? (await getHeadSha(repoRoot, deps));
535
537
  const apiDeps = { ...deps, repoHost: project.host };
536
538
  const projectPath = `projects/${project.encodedProjectPath}`;
537
539
  // `'array'` is load-bearing, not decoration: before it, a `glab api` that
@@ -700,8 +702,11 @@ async function checkGitlab(repoRoot, now, deps = {}) {
700
702
  * (cross-family guard, unsafe-argv guard), and `normalizeGithubSpec` falls
701
703
  * back to the raw URL on an unrecognised remote shape.
702
704
  *
705
+ * `deps.sha` (#1332): when set, the check-runs query names THAT commit
706
+ * instead of the literal `HEAD` ref.
707
+ *
703
708
  * @param {string} repoRoot
704
- * @param {{ execFile?: Function, timeoutMs?: number, repoSpec?: string, repoHost?: string }} deps
709
+ * @param {{ execFile?: Function, timeoutMs?: number, repoSpec?: string, repoHost?: string, sha?: string }} deps
705
710
  * @returns {Promise<object|null>}
706
711
  */
707
712
  async function checkGithub(repoRoot, deps = {}) {
@@ -726,7 +731,7 @@ async function checkGithub(repoRoot, deps = {}) {
726
731
  );
727
732
 
728
733
  const data = await ghApi(
729
- `repos/${nameWithOwner}/commits/HEAD/check-runs`,
734
+ `repos/${nameWithOwner}/commits/${deps.sha ?? 'HEAD'}/check-runs`,
730
735
  repoRoot,
731
736
  deps,
732
737
  'object',
@@ -807,7 +812,12 @@ async function checkGithub(repoRoot, deps = {}) {
807
812
  * vcs?: 'gitlab'|'github',
808
813
  * timeoutMs?: number,
809
814
  * now?: number,
810
- * }} opts
815
+ * sha?: string,
816
+ * }} opts `sha` (#1332): query the verdict for THIS commit instead of the
817
+ * local HEAD. Must be a full hex object id (40 or 64 chars) — it is matched
818
+ * against GitLab's full pipeline SHAs and interpolated into a `gh api` path,
819
+ * so anything else is refused as `query-failed` before any spawn. Absent →
820
+ * behaviour identical to before the option existed.
811
821
  * @param {{
812
822
  * execFile?: Function,
813
823
  * resolveRepoSpec?: (opts: { repoRoot: string, vcs: 'gitlab'|'github' }) => string|undefined,
@@ -842,8 +852,21 @@ export async function checkCiStatus(opts = {}, deps = {}) {
842
852
  vcs: forcedVcs,
843
853
  timeoutMs = DEFAULT_TIMEOUT_MS,
844
854
  now = Date.now(),
855
+ sha: rawSha,
845
856
  } = opts;
846
857
 
858
+ // Validate at the boundary (#1332): the value reaches an API path and an
859
+ // equality match against full SHAs. A short or non-hex SHA would silently
860
+ // match nothing (GitLab) or re-route the request path (GitHub).
861
+ let sha;
862
+ if (rawSha !== undefined) {
863
+ const candidate = typeof rawSha === 'string' ? rawSha.trim().toLowerCase() : '';
864
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(candidate)) {
865
+ return degradedResult('query-failed', 'sha must be a full hex commit id');
866
+ }
867
+ sha = candidate;
868
+ }
869
+
847
870
  const execFileDep = deps.execFile
848
871
  ? promisify(deps.execFile)
849
872
  : execFileAsync;
@@ -914,13 +937,13 @@ export async function checkCiStatus(opts = {}, deps = {}) {
914
937
  'a GitLab remote was detected but its host/project path could not be derived',
915
938
  );
916
939
  }
917
- return await checkGitlab(repoRoot, now, { ...depsWithExec, gitlabProject });
940
+ return await checkGitlab(repoRoot, now, { ...depsWithExec, gitlabProject, sha });
918
941
  }
919
942
 
920
943
  if (vcs === 'github') {
921
944
  const repoSpec = resolveRepoSpecDep({ repoRoot, vcs });
922
945
  const repoHost = resolveRepoHostDep({ repoRoot, vcs });
923
- return await checkGithub(repoRoot, { ...depsWithExec, repoSpec, repoHost });
946
+ return await checkGithub(repoRoot, { ...depsWithExec, repoSpec, repoHost, sha });
924
947
  }
925
948
 
926
949
  // Unknown VCS value — silent no-op.