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
@@ -3,11 +3,13 @@
3
3
  * Check: every `scripts/**.mjs` path cited in `skills/`, `commands/`,
4
4
  * `agents/` and `docs/` either EXISTS or is annotated as deliberately absent
5
5
  * (#1176). Extended (#1187) to also cite `scripts/**.sh` and `hooks/**.sh` —
6
- * see "## Mode: BLOCKING for `.mjs`, ADVISORY for `.sh`" below for why that
6
+ * see "## Mode" below for why that
7
7
  * half is advisory, not blocking. `docs/` joined `SCAN_DIRS` in #1208, after
8
8
  * the 22 dead paths it carried at the time (9 `.mjs`, all ADR/reference
9
9
  * prose) were annotated — see that section below for the census and why
10
10
  * widening the scan root had to wait for the annotation pass, not precede it.
11
+ * #1241 adds blocking repo-rooted `.md` paths quoted as complete inline-code
12
+ * spans, with the same annotations and fence handling. Scan roots stay fixed.
11
13
  *
12
14
  * ## Why
13
15
  *
@@ -50,7 +52,7 @@
50
52
  * looking misplaced. A malformed marker (unknown class, or `planned` without a
51
53
  * `#<iid>`) is itself a finding for the same reason — it must never fail silent.
52
54
  *
53
- * ## Mode: BLOCKING for `.mjs`, ADVISORY for `.sh` (#1187)
55
+ * ## Mode: BLOCKING for `.mjs` / `.md`, ADVISORY for `.sh` (#1187)
54
56
  *
55
57
  * Unlike `check-doc-cli-commands.mjs`, the oracle here is the repository's own
56
58
  * filesystem, not a locally installed third-party binary — there is no version
@@ -115,6 +117,22 @@ export const SCAN_DIRS = Object.freeze(['skills', 'commands', 'agents', 'docs'])
115
117
  */
116
118
  const CITATION_RE = /scripts\/[a-zA-Z0-9_/-]*\.(?:mjs|sh)|hooks\/[a-zA-Z0-9_/-]*\.sh/g;
117
119
 
120
+ /**
121
+ * A complete inline-code span, including spans delimited by multiple backticks.
122
+ * Consume the outer span before judging its content, so a quoted command that
123
+ * contains backticks cannot donate a nested path-looking substring.
124
+ */
125
+ const INLINE_CODE_RE = /(?<!`)(`+)(.+?)(?<!`)\1(?!`)/g;
126
+
127
+ /**
128
+ * #1241's bounded grammar: a literal repo-rooted .md path, optionally followed
129
+ * by :line, :line:end / :line-end, and #anchor. Only the file is checked.
130
+ * URLs, commands, absolute paths, skill-relative references/, variables and
131
+ * globs are outside this grammar. Matching is line-local; revisit if the
132
+ * instruction corpus adopts multiline path spans or another target root.
133
+ */
134
+ const MARKDOWN_CITATION_RE = /^((?:docs|skills|commands|agents|templates|rules|scripts|hooks|tests|\.(?:claude|codex|cursor|pi|orchestrator|gitlab|github))\/[a-zA-Z0-9_./-]+\.md)(?::\d+(?:[:-]\d+)?)?(?:#[^\s`]+)?$/;
135
+
118
136
  /**
119
137
  * Filename fragments that mark a citation as an ILLUSTRATIVE placeholder —
120
138
  * `scripts/example.sh`, `hooks/my-hook.sh`, `scripts/<name>.sh` — rather than
@@ -211,6 +229,10 @@ export function extractCitations(lines) {
211
229
  for (const hit of raw.matchAll(CITATION_RE)) {
212
230
  citations.push({ line: lineNumber, path: hit[0] });
213
231
  }
232
+ for (const span of raw.matchAll(INLINE_CODE_RE)) {
233
+ const hit = span[2].match(MARKDOWN_CITATION_RE);
234
+ if (hit) citations.push({ line: lineNumber, path: hit[1] });
235
+ }
214
236
  };
215
237
 
216
238
  // A blockquoted fence is still a fence — the shared tracker strips the `>`
@@ -234,12 +256,12 @@ export function extractCitations(lines) {
234
256
 
235
257
  /**
236
258
  * Census the documentation corpus for dead `scripts/**.mjs`/`.sh` and
237
- * `hooks/**.sh` citations.
259
+ * `hooks/**.sh` citations, plus repo-rooted `.md` inline-code paths.
238
260
  *
239
261
  * @param {{pluginRoot: string, dirs?: string[], strictSh?: boolean}} options
240
262
  * `strictSh` (default `false`) promotes a dead `.sh` citation from
241
263
  * `severity: 'warn'` to `severity: 'fail'` — see the module docblock
242
- * "Mode: BLOCKING for `.mjs`, ADVISORY for `.sh`" for why the default stays
264
+ * "Mode" for why the default stays
243
265
  * advisory in this release.
244
266
  * @returns {{ok: boolean, summary: object, findings: {kind: string, file: string, line: number, path: string, annotation: string | null, message: string, severity: 'fail' | 'warn'}[], toolError: boolean}}
245
267
  */
@@ -344,10 +366,11 @@ export function scanSkillScriptPaths({ pluginRoot, dirs = SCAN_DIRS, strictSh =
344
366
  summary.existing += 1;
345
367
  continue;
346
368
  }
347
- // An illustrative placeholder name needs no marker — see
369
+ // A script placeholder name needs no marker — see
348
370
  // `isPlaceholderCitation`'s docblock for the closed fragment list and
349
- // its named ceiling.
350
- if (isPlaceholderCitation(citation.path)) {
371
+ // its named ceiling. Markdown examples require explicit annotations:
372
+ // a real missing docs/foobar.md must not disappear because of its name.
373
+ if (path.extname(citation.path) !== '.md' && isPlaceholderCitation(citation.path)) {
351
374
  summary.placeholders += 1;
352
375
  continue;
353
376
  }
@@ -366,7 +389,7 @@ export function scanSkillScriptPaths({ pluginRoot, dirs = SCAN_DIRS, strictSh =
366
389
  }
367
390
  if (marker && !marker.ok) continue; // already reported as bad-annotation
368
391
 
369
- // `.mjs` is blocking exactly as before this module grew a `.sh` half.
392
+ // `.mjs` and `.md` are blocking.
370
393
  // `.sh` is advisory (`warn`) unless the caller opted into `strictSh`.
371
394
  const isSh = path.extname(citation.path) === '.sh';
372
395
  const severity = isSh && !strictSh ? 'warn' : 'fail';
@@ -405,7 +428,7 @@ export function scanSkillScriptPaths({ pluginRoot, dirs = SCAN_DIRS, strictSh =
405
428
  * @returns {number} 0 = clean, 1 = findings, 2 = tool error
406
429
  */
407
430
  export function runCheckSkillScriptPaths(pluginRoot, { strictSh = false } = {}) {
408
- console.log('--- Check: scripts/*.mjs (+ *.sh) paths cited in skills/commands/agents exist ---');
431
+ console.log('--- Check: script and Markdown paths cited in skills/commands/agents/docs exist ---');
409
432
  const inspection = scanSkillScriptPaths({ pluginRoot, strictSh });
410
433
 
411
434
  for (const item of inspection.findings) {
@@ -427,7 +450,7 @@ export function runCheckSkillScriptPaths(pluginRoot, { strictSh = false } = {})
427
450
  const blockingCount = inspection.findings.filter((f) => f.severity !== 'warn').length;
428
451
  if (inspection.ok) {
429
452
  console.log(
430
- ` PASS: ${s.citations} script citation(s) in ${s.filesScanned} doc file(s) — ` +
453
+ ` PASS: ${s.citations} file citation(s) in ${s.filesScanned} doc file(s) — ` +
431
454
  `${s.existing} exist, ${s.annotated} annotated as deliberately absent, ` +
432
455
  `${s.placeholders} placeholder(s)` +
433
456
  (s.warnings > 0 ? `, ${s.warnings} advisory .sh warning(s) (see --strict-sh)` : ''),
@@ -629,28 +629,42 @@ function importClosure(repoRoot, entry, readSource) {
629
629
  */
630
630
  function checkIgnoreBatch(repoRoot, specs, out) {
631
631
  if (specs.length === 0) return;
632
- const ci = spawnSync('git', ['check-ignore', '--stdin'], {
633
- cwd: repoRoot, input: specs.join('\n'), encoding: 'utf8',
634
- maxBuffer: 64 * 1024 * 1024,
635
- });
636
- // 0 = at least one ignored (listed on stdout); 1 = none ignored; else fatal.
637
- if (ci.status === 0) {
638
- for (const line of (ci.stdout || '').split('\n')) {
639
- const p = line.trim();
640
- if (p) out.add(p);
632
+ let pending = specs;
633
+ for (;;) {
634
+ const ci = spawnSync('git', ['check-ignore', '--stdin'], {
635
+ cwd: repoRoot, input: pending.join('\n'), encoding: 'utf8',
636
+ maxBuffer: 64 * 1024 * 1024,
637
+ });
638
+ // 0 = at least one ignored (listed on stdout); 1 = none ignored; else fatal.
639
+ if (ci.status === 0) {
640
+ for (const line of (ci.stdout || '').split('\n')) {
641
+ const p = line.trim();
642
+ if (p) out.add(p);
643
+ }
644
+ return;
641
645
  }
646
+ if (ci.status === 1) return;
647
+ // Git identifies this refused candidate precisely. Retrying only the rest
648
+ // preserves the old dropped-candidate semantics without bisecting healthy
649
+ // paths (78 subprocesses for five symlink descendants in the tracked tree).
650
+ const rejected = /^fatal: pathspec '([^'\n]+)' is beyond a symbolic link\r?\n?$/.exec(ci.stderr || '')?.[1];
651
+ if (rejected && pending.includes(rejected)) {
652
+ process.stderr.write(` WARN: git check-ignore rejected "${rejected}" — candidate dropped (${(ci.stderr || '').trim()})\n`);
653
+ pending = pending.filter((spec) => spec !== rejected);
654
+ if (pending.length === 0) return;
655
+ continue;
656
+ }
657
+ if (pending.length === 1) {
658
+ process.stderr.write(
659
+ ` WARN: git check-ignore rejected "${pending[0]}" — candidate dropped (${(ci.stderr || '').trim().split('\n')[0]})\n`,
660
+ );
661
+ return;
662
+ }
663
+ const mid = pending.length >> 1;
664
+ checkIgnoreBatch(repoRoot, pending.slice(0, mid), out);
665
+ checkIgnoreBatch(repoRoot, pending.slice(mid), out);
642
666
  return;
643
667
  }
644
- if (ci.status === 1) return;
645
- if (specs.length === 1) {
646
- process.stderr.write(
647
- ` WARN: git check-ignore rejected "${specs[0]}" — candidate dropped (${(ci.stderr || '').trim().split('\n')[0]})\n`,
648
- );
649
- return;
650
- }
651
- const mid = specs.length >> 1;
652
- checkIgnoreBatch(repoRoot, specs.slice(0, mid), out);
653
- checkIgnoreBatch(repoRoot, specs.slice(mid), out);
654
668
  }
655
669
 
656
670
  /**
@@ -114,8 +114,9 @@
114
114
  * `CHANGELOG.md` is excluded from the prose corpus for the same reason: it is an
115
115
  * append-only record of what a PAST release shipped, so it names the symbols of
116
116
  * code that may since have died. Counting it silenced a true positive
117
- * (`soul-resolve.mjs`, whose only live claim is in `.claude/rules/owner-persona.md`
118
- * but whose symbols appear in a 2026-06 changelog entry).
117
+ * (`soul-resolve.mjs` since DELETED as dead: its only live claim was in
118
+ * `.claude/rules/owner-persona.md`, while its symbols appeared in a 2026-06
119
+ * changelog entry).
119
120
  *
120
121
  * ## S4 `unreachable-library-module` — the question the machine can answer
121
122
  *
@@ -777,11 +778,11 @@ function mentionedModuleTokens(lines) {
777
778
  * ## Cluster roots — one defect, one line
778
779
  *
779
780
  * Only the ROOT of each unreachable cluster is reported: a module no OTHER
780
- * unreachable module references. `scripts/lib/owner-config.mjs` has no importer
781
- * and drags its whole 7-file `owner-config/` subtree down with it; reporting the
782
- * six interior files would multiply one deletion into seven findings that all
783
- * disappear together. Measured on the live tree: 71 unreachable modules collapse
784
- * to 50 roots. This is category separation in the sense of
781
+ * unreachable module references. A drag-cluster looks like this: an unimported
782
+ * top-level module whose own subtree of interior files goes down with it
783
+ * reporting the interior files too would multiply one deletion into N findings
784
+ * that all disappear together. Measured on the live tree: 71 unreachable modules
785
+ * collapse to 50 roots. This is category separation in the sense of
785
786
  * `.claude/rules/development.md` § Guard & Threshold Design — a structural split,
786
787
  * never a raised threshold.
787
788
  *
@@ -794,9 +795,18 @@ function mentionedModuleTokens(lines) {
794
795
  * switched off. Revisit if a real module-resolver (import-specifier resolution
795
796
  * relative to the importing file) becomes cheap, or if a collided basename is
796
797
  * ever confirmed to mask a true positive. The `coordinator-invoked-module`
797
- * DOWNGRADE is exempt: there a colliding basename must be named with its
798
- * `dirname/base` suffix, because that match moves a module OUT of the
799
- * reportable class and would otherwise hide a true unreachable sibling.
798
+ * DOWNGRADE is exempt, and since #1293 so is the CLUSTER-ROOT filter: in both
799
+ * a colliding basename must be named with its `dirname/base` suffix, because
800
+ * those matches move a module OUT of the reportable class and would otherwise
801
+ * hide a true unreachable sibling. Measured cost of leaving the root filter
802
+ * on bare basenames: `locks/index.mjs` and `worktree/index.mjs` were both
803
+ * suppressed by a third unreachable module that merely mentioned bare
804
+ * `index.mjs`, and only resurfaced when that module was deleted for an
805
+ * unrelated reason. Remaining ceiling: two ambiguous roots whose mentioning
806
+ * module ALSO carries the qualified form (e.g. an unreachable module that
807
+ * literally writes `locks/index.mjs`) are still suppressed — correct when it
808
+ * is a real reference, a mask when it is prose. Revisit if a qualified
809
+ * mention is ever confirmed to hide a root.
800
810
  * - **Reachable ≠ executed.** A module imported by a hook that never takes that
801
811
  * branch reads as wired here. Proving execution needs coverage data, not a graph.
802
812
  * - **Reachable from SOME entrypoint is not reachable from the PROMISED one.**
@@ -839,6 +849,10 @@ export function collectUnreachableLibraryModules(pluginRoot) {
839
849
  // module an operator flagged. Measured 2026-08-28 on the first S4
840
850
  // allowlist entry: 52 → 51 unreachable modules plus one bogus stale line.
841
851
  mentions: relative === SELF_REL ? new Set() : mentionedModuleTokens(lines),
852
+ // Raw text, kept for the QUALIFIED (`dirname/base`) re-check in the
853
+ // root filter below: `mentionedModuleTokens` strips the directory, so
854
+ // a colliding basename can only be disambiguated against the body.
855
+ rawBody: relative === SELF_REL ? '' : body,
842
856
  };
843
857
  });
844
858
 
@@ -876,10 +890,38 @@ export function collectUnreachableLibraryModules(pluginRoot) {
876
890
  (module) => !reachable.has(module.relative) && !module.entrypoint && module.exports.length > 0,
877
891
  );
878
892
  const unreachableSet = new Set(unreachable.map((module) => module.relative));
879
- const roots = unreachable.filter(
880
- (module) =>
881
- !unreachable.some((other) => other.relative !== module.relative && other.mentions.has(module.base)),
882
- );
893
+ // Basename census, shared by the root filter below and the downgrade half
894
+ // further down. A bare basename is only a valid module reference when it is
895
+ // UNIQUE in the corpus: `writer.mjs` names both `peer-cards/writer.mjs` and
896
+ // `reconcile/writer.mjs` (measured 2026-09-07), so a doc naming ONE of them
897
+ // would otherwise downgrade BOTH out of the reportable class — a true
898
+ // unreachable silently moved into the advisory half. For a colliding basename
899
+ // the reference must therefore carry at least the `dirname/base` suffix
900
+ // (`reconcile/writer.mjs`); unique basenames keep the cheaper bare match.
901
+ // Direction matters in both consumers: this can only ever ADD findings back to
902
+ // the reportable class, never remove one.
903
+ /** @type {Map<string, number>} */
904
+ const basenameCount = new Map();
905
+ for (const module of modules) basenameCount.set(module.base, (basenameCount.get(module.base) ?? 0) + 1);
906
+
907
+ const roots = unreachable.filter((module) => {
908
+ // A bare-basename mention only suppresses when the basename is UNIQUE
909
+ // (see the census above). When it collides, the mentioning module must name
910
+ // the `dirname/base` form in its body — otherwise ONE unreachable module
911
+ // mentioning bare `index.mjs` masks EVERY differently-pathed `index.mjs`
912
+ // root at once (#1293: `locks/index.mjs` + `worktree/index.mjs` were masked
913
+ // by a third unreachable module until that module was deleted for an
914
+ // unrelated reason).
915
+ const ambiguous = (basenameCount.get(module.base) ?? 0) > 1;
916
+ const qualified = module.relative.split(path.sep).slice(-2).join('/');
917
+ const qualifiedRe = ambiguous ? tokenMatcher(qualified) : null;
918
+ return !unreachable.some(
919
+ (other) =>
920
+ other.relative !== module.relative &&
921
+ other.mentions.has(module.base) &&
922
+ (qualifiedRe === null || qualifiedRe.test(other.rawBody)),
923
+ );
924
+ });
883
925
 
884
926
  // Category split (see § Category split in the doc block above): an INSTRUCTION
885
927
  // document that names both the module AND one of its exported symbols is an
@@ -893,19 +935,6 @@ export function collectUnreachableLibraryModules(pluginRoot) {
893
935
  .sort()
894
936
  .map((file) => ({ relative: path.relative(pluginRoot, file), body: readFileSync(file, 'utf8') }));
895
937
 
896
- // Basename census for the downgrade half. A bare basename is only a valid
897
- // module reference when it is UNIQUE in the corpus: `writer.mjs` names both
898
- // `peer-cards/writer.mjs` and `reconcile/writer.mjs` (measured 2026-09-07),
899
- // so a doc naming ONE of them would otherwise downgrade BOTH out of the
900
- // reportable class — a true unreachable silently moved into the advisory
901
- // half. For a colliding basename the doc must therefore carry at least the
902
- // `dirname/base` suffix (`reconcile/writer.mjs`); unique basenames keep the
903
- // cheaper bare match. Direction matters: this can only ever ADD findings back
904
- // to the reportable class, never remove one.
905
- /** @type {Map<string, number>} */
906
- const basenameCount = new Map();
907
- for (const module of modules) basenameCount.set(module.base, (basenameCount.get(module.base) ?? 0) + 1);
908
-
909
938
  let coordinatorInvoked = 0;
910
939
  const findings = roots.map((module) => {
911
940
  // Docs write POSIX separators regardless of host; `path.relative` does not.
@@ -879,7 +879,8 @@ export async function processSession(rawEntry, _lineNum, ctx) {
879
879
  // This is deliberately REDUNDANT with the renderer's status mapping (#909,
880
880
  // render-sessions.mjs) — see that module's header. The filter removes one
881
881
  // status from the vault; the mapping keeps every OTHER status honest, and
882
- // guards the generators' other entry point (the render.mjs barrel).
882
+ // guards the generators' other entry point (this module imports
883
+ // render-learnings.mjs and render-sessions.mjs directly — no barrel).
883
884
  if (!isRealSession(entry)) {
884
885
  return emitEntryAction(_lineNum, ctx, {
885
886
  action: 'skipped-abandoned',
@@ -60,6 +60,15 @@ const DEFAULT_POLL_MS = 50;
60
60
  * REVISIT when a board sweep is measured above 30 s (half the TTL — the point
61
61
  * at which a slow host crosses it), or when an `onLockOutcome` carrying
62
62
  * `staleOverride` is observed in the events ledger on a host that had no crash.
63
+ *
64
+ * What expiry does NOT do: protect the section it bounds — past 60 s the next
65
+ * writer takes over while this one may still run (see file-lock.mjs § Lease
66
+ * semantics). What IS guaranteed since #1285: the late writer's release can no
67
+ * longer delete the successor's lock, because release runs under the same
68
+ * `.acquire` guard as takeover. A `not-owner` release here therefore means THIS
69
+ * writer's lease expired mid-section — the lost-update case above, never a
70
+ * benign miss. Since #1336 that release result reaches callers through
71
+ * `onReleaseOutcome` (see withBoardLock).
63
72
  */
64
73
  const DEFAULT_STALE_MS = 60_000;
65
74
 
@@ -105,6 +114,11 @@ export function boardLockPathFor(vaultDir) {
105
114
  * is present only when this acquire OVERRODE an aged lock, and carries
106
115
  * `file-lock.mjs`'s own reason token — the observable behind the
107
116
  * DEFAULT_STALE_MS revisit trigger.
117
+ * @param {(release: { ok: boolean, reason?: string }) => void} [opts.onReleaseOutcome]
118
+ * — diagnostic sink for the release result, called at most once, AFTER `fn`
119
+ * (only on the locked path). `{ ok: false, reason: 'not-owner' }` means this
120
+ * writer's lease expired mid-section; `'busy'` means the release gave up on
121
+ * the `.acquire` guard and left the lock in place.
108
122
  * @param {(lockPath: string, fn: Function, opts: object) => Promise<object>} [opts.lockImpl]
109
123
  * — test seam; defaults to {@link withFileLock}. Must honour the same
110
124
  * `{ ok: true, value } | { ok: false, reason }` contract.
@@ -123,6 +137,7 @@ export async function withBoardLock(vaultDir, fn, opts = {}) {
123
137
  staleMs = DEFAULT_STALE_MS,
124
138
  holder: holderOpt,
125
139
  onLockOutcome,
140
+ onReleaseOutcome,
126
141
  lockImpl = withFileLock,
127
142
  warn = (msg) => process.stderr.write(msg),
128
143
  } = opts;
@@ -167,6 +182,9 @@ export async function withBoardLock(vaultDir, fn, opts = {}) {
167
182
  indent: 2,
168
183
  tmpPrefix: '.board.lock',
169
184
  warn: warnAndWatch,
185
+ // Separate sink, not a second onLockOutcome call — that one stays
186
+ // "exactly once, before fn".
187
+ ...(typeof onReleaseOutcome === 'function' ? { onRelease: onReleaseOutcome } : {}),
170
188
  },
171
189
  );
172
190
 
@@ -1095,6 +1095,14 @@ async function mirrorBoardInner({ repoRoot, repos, explicitStatus, now = new Dat
1095
1095
  waited_ms: Date.now() - acquireStartedAt,
1096
1096
  };
1097
1097
  },
1098
+ // #1336: the release runs in withFileLock's finally, i.e. before
1099
+ // withBoardLock returns — so this lands on the SAME board_written event,
1100
+ // which mirrorBoard emits only after this function returns.
1101
+ onReleaseOutcome: (r) => {
1102
+ if (lockOutcome && r && r.ok === false && typeof r.reason === 'string') {
1103
+ lockOutcome.release = r.reason;
1104
+ }
1105
+ },
1098
1106
  });
1099
1107
 
1100
1108
  return lockOutcome === undefined ? inner : { ...inner, lock: lockOutcome };
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * narrative-mirror.mjs — Durable per-repo narrative mirror (Epic #673 Phase 1, #675).
3
3
  *
4
- * At session-end, extract from a repo's `.claude/STATE.md` the DURABLE narrative —
4
+ * At session-end, extract from a repo's active `STATE.md` the DURABLE narrative —
5
5
  * `## Wave History`, `## Deviations`, `## What Not To Retry`, plus the mission-status
6
6
  * rollup — and idempotently mirror it into a generator-owned per-repo vault file, so a
7
7
  * reviewer or stand-in can read PER REPO what was done, what failed, and what not to
@@ -31,7 +31,7 @@ import { readFile } from 'node:fs/promises';
31
31
  import path from 'node:path';
32
32
 
33
33
  import { emitEvent, sessionAttribution } from '../events.mjs';
34
- import { parseStateMd, parseMissionStatus } from '../state-md.mjs';
34
+ import { parseStateMd, parseMissionStatus, resolveStateMdPath } from '../state-md.mjs';
35
35
  import {
36
36
  parseFrontmatter,
37
37
  toDate,
@@ -328,7 +328,7 @@ export function renderNarrative(opts) {
328
328
  lines.push(`# ${repo ?? 'unknown'} — Session Narrative`);
329
329
  lines.push('');
330
330
  lines.push(
331
- '> Durable per-repo narrative mirrored from `.claude/STATE.md` (Epic #673). ' +
331
+ '> Durable per-repo narrative mirrored from the repo\'s active `STATE.md` (Epic #673). ' +
332
332
  'What was done, what failed, and what not to retry — readable without opening the repo.',
333
333
  );
334
334
  lines.push('');
@@ -909,7 +909,7 @@ async function runNarrativeMirror(opts) {
909
909
  }
910
910
 
911
911
  // Read STATE.md (best-effort; absent STATE.md → nothing to mirror).
912
- const stateMdPath = path.join(repoRoot, '.claude', 'STATE.md');
912
+ const stateMdPath = resolveStateMdPath(repoRoot);
913
913
  let stateContents;
914
914
  try {
915
915
  stateContents = await readFile(stateMdPath, 'utf8');
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { probe, evaluate } from './resource-probe.mjs';
11
+ import { resolveAgentCap } from './session-shape.mjs';
11
12
 
12
13
  /**
13
14
  * Wave roles that may run on a declared remote host, mapped to the
@@ -194,7 +195,10 @@ async function applyOffloadDecision(result, opts) {
194
195
  // ceiling, which is a property of the REPO and holds wherever the wave runs.
195
196
  // Restoring plannedAgents unconditionally here would let a heavy repo exceed
196
197
  // its own cap by way of a remote host.
197
- const cap = config?.['heavy-repo'] === true ? resolveApwCap(config['agents-per-wave']) : null;
198
+ const cap =
199
+ config?.['heavy-repo'] === true
200
+ ? resolveAgentCap(config['agents-per-wave'], MODE_BLIND_SESSION_TYPE)
201
+ : null;
198
202
  const agents = cap === null ? plannedAgents : Math.min(plannedAgents, cap);
199
203
 
200
204
  return {
@@ -210,42 +214,34 @@ async function applyOffloadDecision(result, opts) {
210
214
  }
211
215
 
212
216
  /**
213
- * Resolve an `agents-per-wave` config value into a plain numeric cap, or
214
- * `null` when no cap should apply.
217
+ * The session type this module resolves `agents-per-wave` FOR: none.
215
218
  *
216
219
  * `_coerceInteger()` (scripts/lib/config/coercers.mjs) parses the documented
217
220
  * HR-003 parenthetical-override syntax — `agents-per-wave: 4 (deep: 18)` —
218
- * into an OBJECT `{ default: 4, deep: 18 }`, not a plain number. Feeding that
219
- * object straight into a `typeof cap !== 'number'` guard makes the heavy-repo
220
- * cap silently no-op for every repo using the override syntax, which defeats
221
- * HR-004 exactly where it matters most (a heavy repo that also runs deep
222
- * sessions).
221
+ * into an OBJECT `{ default: 4, deep: 18 }`, not a plain number, so the cap
222
+ * must be resolved rather than type-guarded (a bare `typeof cap !== 'number'`
223
+ * check no-ops HR-004 for every repo using the override syntax).
223
224
  *
224
- * `evaluateWaveResourceGate()` has no session-mode input in scope — `waveRole`
225
- * is a wave role (e.g. "Impl-Core"), not a session mode (e.g. "deep") — so the
226
- * object shape resolves to `cap.default` here. That is the conservative
227
- * choice: the documented HR-003 convention writes the override as
228
- * `<default> (mode: <higher-ceiling>)`, i.e. `default` is the MORE
229
- * restrictive of the pair. Falling back to it can only under-apply a looser
230
- * mode-specific ceiling; it never lets a heavy repo exceed its base cap.
225
+ * This gate has NO session mode in scope — `waveRole` is a wave role (e.g.
226
+ * "Impl-Core"), never a session mode (e.g. "deep") — so it deliberately asks
227
+ * `resolveAgentCap` for no type and takes the `.default` fallback. That is the
228
+ * conservative reading: the HR-003 convention writes the override as
229
+ * `<default> (mode: <higher-ceiling>)`, i.e. `default` is the MORE restrictive
230
+ * of the pair. Under-applying a looser mode-specific ceiling is safe; passing a
231
+ * guessed `'deep'` here would silently RAISE a heavy repo's ceiling to the deep
232
+ * override, which is the bug this named constant exists to make un-writable.
231
233
  *
232
- * @param {number|{default: number, [mode: string]: number}|*} cap
233
- * @returns {number|null}
234
+ * @type {undefined}
234
235
  */
235
- function resolveApwCap(cap) {
236
- if (typeof cap === 'number') return Number.isFinite(cap) ? cap : null;
237
- if (cap !== null && typeof cap === 'object' && !Array.isArray(cap)) {
238
- const def = cap.default;
239
- return typeof def === 'number' && Number.isFinite(def) ? def : null;
240
- }
241
- return null;
242
- }
236
+ const MODE_BLIND_SESSION_TYPE = undefined;
243
237
 
244
238
  /**
245
239
  * HR-003/HR-004 heavy-repo preflight ceiling (baseline #60). A STATIC cap
246
240
  * independent of the live resource-probe verdict: when `config['heavy-repo']`
247
241
  * is `true`, `agents` is clamped to at most `config['agents-per-wave']`
248
- * (resolved via {@link resolveApwCap} to handle the object-override shape).
242
+ * (resolved via `resolveAgentCap` from `./session-shape.mjs` the ONE exported
243
+ * copy of that resolver — to handle the object-override shape; see
244
+ * {@link MODE_BLIND_SESSION_TYPE} for why no session type is passed).
249
245
  * More-restrictive-wins — this only ever LOWERS `agents`, never raises it
250
246
  * above what the resource-driven rules already decided (e.g. a
251
247
  * coordinator-direct 0 stays 0).
@@ -257,7 +253,7 @@ function resolveApwCap(cap) {
257
253
  function applyHeavyRepoCap(result, opts) {
258
254
  const { config } = opts;
259
255
  if (!config || config['heavy-repo'] !== true) return result;
260
- const cap = resolveApwCap(config['agents-per-wave']);
256
+ const cap = resolveAgentCap(config['agents-per-wave'], MODE_BLIND_SESSION_TYPE);
261
257
  if (cap === null) return result;
262
258
  if (result.agents <= cap) return result; // already within the ceiling — never raise
263
259
  return {
@@ -3,10 +3,17 @@
3
3
  * Learning: coordinator-over-worktree-on-shared-files caused back-to-back regressions.
4
4
  */
5
5
 
6
- // 'unknown' is accepted because scripts/lib/session-close-backfill.mjs writes it for records whose
6
+ // SSOT for the session-type enum. It used to be RE-DECLARED here while this very comment named
7
+ // constants.mjs as the source of truth — two literals that agreed only by luck (and already
8
+ // differed in member order). The direction of the dependency is forced: constants.mjs declares
9
+ // itself a leaf ("no imports from siblings or parent") and mentions wave-sizing.mjs only in prose,
10
+ // so this module imports, never the reverse; no cycle exists in either direction.
11
+ //
12
+ // 'unknown' is a member because scripts/lib/session-close-backfill.mjs writes it for records whose
7
13
  // type it could not measure (4.0.0). It is NOT a mode anyone selects — isolation resolves as for
8
- // 'deep' (the conservative end). SSOT for the ledger enum: scripts/lib/session-schema/constants.mjs.
9
- const VALID_SESSION_TYPES = ['housekeeping', 'feature', 'deep', 'unknown'];
14
+ // 'deep' (the conservative end).
15
+ import { VALID_SESSION_TYPES } from './session-schema/constants.mjs';
16
+
10
17
  const VALID_COLLISION_RISKS = ['low', 'medium', 'high'];
11
18
  const VALID_CONFIG_ISOLATIONS = ['auto', 'worktree', 'none'];
12
19
  const VALID_ENFORCEMENTS = ['strict', 'warn', 'off'];