session-orchestrator 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/.agents/skills/session-plan/SKILL.md +1 -1
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
  6. package/.cursor/skills/session-plan/SKILL.md +1 -1
  7. package/.cursor-plugin/plugin.json +1 -1
  8. package/CHANGELOG.md +34 -0
  9. package/README.md +8 -8
  10. package/agents/ux-evaluator.md +1 -1
  11. package/commands/close.md +3 -3
  12. package/commands/go.md +2 -0
  13. package/commands/memory-cleanup.md +4 -3
  14. package/commands/persona-panel.md +1 -1
  15. package/commands/session.md +3 -2
  16. package/docs/README.md +4 -4
  17. package/docs/USER-GUIDE.md +115 -48
  18. package/docs/agent-authoring.md +2 -2
  19. package/docs/baseline.md +55 -1
  20. package/docs/ci-setup.md +1 -1
  21. package/docs/codex-setup.md +1 -0
  22. package/docs/components.md +2 -2
  23. package/docs/cursor-setup.md +1 -0
  24. package/docs/events-schema.md +4 -1
  25. package/docs/instruction-delivery.md +1 -1
  26. package/docs/memory-proposal-flow.md +3 -3
  27. package/docs/migration-v4.md +2 -2
  28. package/docs/owner-config-schema.md +74 -90
  29. package/docs/persona-panel.md +4 -4
  30. package/docs/pi-setup.md +1 -0
  31. package/docs/rule-authoring.md +13 -6
  32. package/docs/scope-collision-guard.md +2 -0
  33. package/docs/session-config-reference.md +55 -22
  34. package/docs/session-config-template.md +9 -5
  35. package/docs/vault-docs-architecture.md +4 -2
  36. package/hooks/_lib/hook-import-set.json +28 -3
  37. package/hooks/_lib/vcs-create-matcher.mjs +214 -16
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks.json +1 -1
  40. package/hooks/pre-bash-issue-budget.mjs +123 -26
  41. package/hooks/subagent-telemetry.mjs +106 -20
  42. package/package.json +4 -4
  43. package/scripts/baseline-archetypes.mjs +28 -0
  44. package/scripts/lib/auto-dialectic.mjs +0 -68
  45. package/scripts/lib/baseline-archetypes.mjs +439 -0
  46. package/scripts/lib/build-live-signals.mjs +5 -6
  47. package/scripts/lib/config/issue-budget.mjs +68 -8
  48. package/scripts/lib/config/private-config-dir.mjs +3 -2
  49. package/scripts/lib/config/remote-hosts.mjs +2 -2
  50. package/scripts/lib/config-schema.mjs +79 -0
  51. package/scripts/lib/file-lock.mjs +47 -5
  52. package/scripts/lib/issue-budget-reconcile.mjs +392 -0
  53. package/scripts/lib/issue-budget.mjs +76 -3
  54. package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
  55. package/scripts/lib/maintenance-due-banner.mjs +440 -0
  56. package/scripts/lib/owner-config.example.yaml +29 -46
  57. package/scripts/lib/owner-yaml.mjs +14 -13
  58. package/scripts/lib/quality-gate.mjs +13 -6
  59. package/scripts/lib/resource-probe/evaluate.mjs +19 -21
  60. package/scripts/lib/rules-sync.mjs +34 -4
  61. package/scripts/lib/session-close-backfill.mjs +182 -40
  62. package/scripts/lib/session-end/phase-skip.mjs +85 -86
  63. package/scripts/lib/session-end/tail-runner.mjs +178 -0
  64. package/scripts/lib/session-schema/constants.mjs +6 -0
  65. package/scripts/lib/session-schema/validator.mjs +20 -0
  66. package/scripts/lib/session-shape.mjs +558 -0
  67. package/scripts/lib/session-start-probes.mjs +10 -3
  68. package/scripts/lib/session-token-rollup.mjs +95 -10
  69. package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
  70. package/scripts/lib/state-md.mjs +1 -0
  71. package/scripts/lib/subagents-schema.mjs +77 -9
  72. package/scripts/lib/telemetry/pricing.mjs +197 -0
  73. package/scripts/lib/telemetry/sync.mjs +50 -1
  74. package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
  75. package/scripts/lib/validate/check-unwired-features.mjs +8 -7
  76. package/scripts/lib/vault-mirror/process.mjs +2 -1
  77. package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
  78. package/scripts/lib/wave-resource-gate.mjs +23 -27
  79. package/scripts/lib/wave-sizing.mjs +10 -3
  80. package/scripts/materialize-wave-scope.mjs +68 -14
  81. package/scripts/print-applicable-rules.mjs +7 -6
  82. package/scripts/print-learnings-index.mjs +3 -2
  83. package/scripts/session-shape.mjs +266 -0
  84. package/skills/_shared/config-reading.md +15 -9
  85. package/skills/_shared/private-capability-context.md +89 -0
  86. package/skills/bootstrap/SKILL.md +61 -13
  87. package/skills/bootstrap/_shared-template.md +99 -14
  88. package/skills/bootstrap/deep-template.md +36 -26
  89. package/skills/bootstrap/fast-template.md +44 -8
  90. package/skills/bootstrap/intensity-heuristic.md +10 -4
  91. package/skills/bootstrap/private-contract.md +119 -0
  92. package/skills/bootstrap/public-fallback.md +30 -18
  93. package/skills/bootstrap/standard-template.md +39 -24
  94. package/skills/discovery/probes-ui.md +1 -1
  95. package/skills/docs-orchestrator/audience-mapping.md +1 -1
  96. package/skills/evolve/SKILL.md +2 -2
  97. package/skills/gitlab-ops/SKILL.md +3 -3
  98. package/skills/grill/SKILL.md +1 -1
  99. package/skills/memory-cleanup/SKILL.md +2 -2
  100. package/skills/plan/mode-new.md +9 -0
  101. package/skills/reconcile/SKILL.md +1 -1
  102. package/skills/session-end/SKILL.md +3 -2
  103. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  104. package/skills/session-end/phase-3-6-tail.md +23 -65
  105. package/skills/session-end/phase-3-7a-recommendations.md +2 -2
  106. package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
  107. package/skills/session-end/references/phase-5-issue-cleanup.md +26 -0
  108. package/skills/session-end/session-metrics-write.md +31 -12
  109. package/skills/session-plan/SKILL.md +56 -48
  110. package/skills/session-plan/wave-template.md +8 -15
  111. package/skills/session-start/SKILL.md +18 -2
  112. package/skills/session-start/phase-2-5-docs-planning.md +1 -1
  113. package/skills/session-start/phase-8-5-express-path.md +12 -9
  114. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
  115. package/skills/session-start/references/phase-4-ssot-environment-check.md +21 -5
  116. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
  117. package/skills/test-runner/rubric-v1.md +2 -2
  118. package/skills/wave-executor/SKILL.md +42 -12
  119. package/skills/wave-executor/circuit-breaker.md +3 -1
  120. package/skills/wave-executor/references/wave-loop-dispatch.md +4 -2
  121. package/skills/wave-executor/references/wave-loop-review.md +1 -1
  122. package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
  123. package/templates/nextjs-minimal/package.json +1 -1
  124. package/templates/node-minimal/package.json +1 -1
  125. package/scripts/lib/multi-provider-build/providers.mjs +0 -64
  126. package/scripts/lib/multi-provider-build/templating.mjs +0 -130
  127. package/scripts/lib/owner-config/coerce.mjs +0 -29
  128. package/scripts/lib/owner-config/constants.mjs +0 -21
  129. package/scripts/lib/owner-config/defaults.mjs +0 -50
  130. package/scripts/lib/owner-config/error.mjs +0 -19
  131. package/scripts/lib/owner-config/index.mjs +0 -13
  132. package/scripts/lib/owner-config/merge.mjs +0 -52
  133. package/scripts/lib/owner-config/validate.mjs +0 -259
  134. package/scripts/lib/owner-config-loader.mjs +0 -170
  135. package/scripts/lib/owner-config.mjs +0 -28
  136. package/scripts/lib/soul-resolve.mjs +0 -130
  137. package/scripts/lib/vault-mirror/render.mjs +0 -8
@@ -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)` : ''),
@@ -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
  *
@@ -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',
@@ -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'];
@@ -22,7 +22,7 @@
22
22
  * `allowedPaths` never grants a peer's territory to this wave's agents.
23
23
  */
24
24
 
25
- import { readdirSync, readFileSync, unlinkSync } from 'node:fs';
25
+ import { existsSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
26
26
  import { resolve } from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
28
  import { writeJsonAtomicSync } from './lib/io.mjs';
@@ -49,6 +49,12 @@ Options:
49
49
  it, orphans are reported and RETAINED, never deleted.
50
50
  --json Emit {ok, aggregatePath, perAgentPaths, removedOrphans,
51
51
  retainedOrphans} to stdout.
52
+ --warn-missing Warn on stderr about absent concrete scope paths, resolved
53
+ from the project working directory (not --state-dir).
54
+ Glob/prefix grants containing * or ending / are skipped.
55
+ --new-file <path> Suppress a missing-path warning for an intended new file.
56
+ Repeatable; each path must exactly match a declared scope
57
+ path and pass scope validation, even without --warn-missing.
52
58
  -h, --help Show this help and exit 0.
53
59
 
54
60
  Output:
@@ -85,13 +91,16 @@ class WriteError extends Error {}
85
91
 
86
92
  /**
87
93
  * @param {string[]} argv
88
- * @returns {{ stateDir: string, wave: number, session: string|null, json: boolean, help: boolean }}
94
+ * @returns {{ stateDir: string, wave: number, session: string|null, json: boolean,
95
+ * warnMissing: boolean, newFiles: string[], help: boolean }}
89
96
  */
90
97
  export function parseCliArgs(argv) {
91
98
  let stateDir;
92
99
  let waveRaw;
93
100
  let session = null;
94
101
  let json = false;
102
+ let warnMissing = false;
103
+ const newFiles = [];
95
104
  let help = false;
96
105
 
97
106
  for (let index = 0; index < argv.length; index++) {
@@ -100,16 +109,22 @@ export function parseCliArgs(argv) {
100
109
  json = true;
101
110
  continue;
102
111
  }
112
+ if (arg === '--warn-missing') {
113
+ warnMissing = true;
114
+ continue;
115
+ }
103
116
  if (arg === '--help' || arg === '-h') {
104
117
  help = true;
105
118
  continue;
106
119
  }
107
- if (arg === '--state-dir' || arg === '--wave' || arg === '--session') {
120
+ if (arg === '--state-dir' || arg === '--wave' || arg === '--session' || arg === '--new-file') {
108
121
  const value = argv[index + 1];
109
122
  if (value === undefined || value.startsWith('--')) {
110
123
  throw new InputError(`${arg} requires a value`);
111
124
  }
112
- if (arg === '--state-dir') {
125
+ if (arg === '--new-file') {
126
+ newFiles.push(value);
127
+ } else if (arg === '--state-dir') {
113
128
  if (stateDir !== undefined) throw new InputError('--state-dir may be specified only once');
114
129
  stateDir = value;
115
130
  } else if (arg === '--session') {
@@ -128,7 +143,7 @@ export function parseCliArgs(argv) {
128
143
  throw new InputError(`unknown argument: ${arg}`);
129
144
  }
130
145
 
131
- if (help) return { stateDir: '', wave: 0, session, json, help: true };
146
+ if (help) return { stateDir: '', wave: 0, session, json, warnMissing, newFiles, help: true };
132
147
  if (stateDir === undefined) throw new InputError('--state-dir is required');
133
148
  if (waveRaw === undefined) throw new InputError('--wave is required');
134
149
  if (stateDir.length === 0 || /[\0\r\n]/.test(stateDir)) {
@@ -140,7 +155,7 @@ export function parseCliArgs(argv) {
140
155
 
141
156
  const wave = Number(waveRaw);
142
157
  if (!Number.isSafeInteger(wave)) throw new InputError('--wave must be a safe positive integer');
143
- return { stateDir, wave, session, json, help: false };
158
+ return { stateDir, wave, session, json, warnMissing, newFiles, help: false };
144
159
  }
145
160
 
146
161
  /**
@@ -157,18 +172,17 @@ function isRecord(value) {
157
172
  * are rejected because no scope consumer can safely interpret them verbatim.
158
173
  *
159
174
  * @param {unknown} value
160
- * @param {number} recordIndex
161
- * @param {number} fileIndex
175
+ * @param {string} description
162
176
  */
163
- function validateScopePath(value, recordIndex, fileIndex) {
177
+ function validateScopePath(value, description) {
164
178
  if (typeof value !== 'string' || value.length === 0 || value.trim().length === 0) {
165
- throw new InputError(`record #${recordIndex} files[${fileIndex}] must be a non-empty string`);
179
+ throw new InputError(`${description} must be a non-empty string`);
166
180
  }
167
181
  if (/[\0\r\n]/.test(value)) {
168
- throw new InputError(`record #${recordIndex} files[${fileIndex}] must not contain NUL or newline characters`);
182
+ throw new InputError(`${description} must not contain NUL or newline characters`);
169
183
  }
170
184
  if (value.split(/[\\/]+/).includes('..')) {
171
- throw new InputError(`record #${recordIndex} files[${fileIndex}] must not contain path traversal`);
185
+ throw new InputError(`${description} must not contain path traversal`);
172
186
  }
173
187
  }
174
188
 
@@ -210,7 +224,7 @@ export function validateScopeRecords(value) {
210
224
  throw new InputError(`record #${recordIndex} (${record.id}) must have a files string array`);
211
225
  }
212
226
  for (let fileIndex = 0; fileIndex < record.files.length; fileIndex++) {
213
- validateScopePath(record.files[fileIndex], recordIndex, fileIndex);
227
+ validateScopePath(record.files[fileIndex], `record #${recordIndex} files[${fileIndex}]`);
214
228
  }
215
229
  }
216
230
 
@@ -220,6 +234,42 @@ export function validateScopeRecords(value) {
220
234
  return value;
221
235
  }
222
236
 
237
+ /**
238
+ * Validate exceptions before any published declarations can be invalidated.
239
+ * Keep exact spelling: these are diagnostic exceptions, never scope rewrites.
240
+ *
241
+ * @param {string[]} newFiles
242
+ * @param {Array<{id: string, files: string[]}>} records
243
+ * @returns {Set<string>}
244
+ */
245
+ function validateNewFiles(newFiles, records) {
246
+ const declared = new Set(records.flatMap(({ files }) => files));
247
+ for (const file of newFiles) {
248
+ validateScopePath(file, '--new-file');
249
+ if (!declared.has(file)) throw new InputError(`--new-file path is not declared in scope records: ${file}`);
250
+ }
251
+ return new Set(newFiles);
252
+ }
253
+
254
+ /**
255
+ * Missing-file diagnostics are opt-in and never change scope grants or stdout.
256
+ * `*` and trailing `/` are the scope consumer's glob/prefix grammar; `?` and
257
+ * braces are literal names (scripts/lib/scope-gate.mjs isGlobScopeEntry).
258
+ *
259
+ * @param {Array<{id: string, files: string[]}>} records
260
+ * @param {Set<string>} newFiles
261
+ */
262
+ function warnMissingPaths(records, newFiles) {
263
+ for (const { id, files } of records) {
264
+ for (const file of files) {
265
+ if (newFiles.has(file) || file.includes('*') || file.endsWith('/')) continue;
266
+ if (!existsSync(resolve(file))) {
267
+ process.stderr.write(`materialize-wave-scope: WARN ${id}: declared path not found: ${file}\n`);
268
+ }
269
+ }
270
+ }
271
+ }
272
+
223
273
 
224
274
  /**
225
275
  * Read the session ids the sibling manifest `<state-dir>/wave-scope.json`
@@ -462,14 +512,18 @@ export function main() {
462
512
  return;
463
513
  }
464
514
  const records = validateScopeRecords(readStdinJson());
515
+ const newFiles = validateNewFiles(args.newFiles, records);
465
516
  const { aggregatePath, perAgentPaths, removedOrphans, retainedOrphans } =
466
517
  materializeWaveScope(records, args);
467
518
 
519
+ if (args.warnMissing) warnMissingPaths(records, newFiles);
520
+
468
521
  // stderr carries ONLY the anomalous cases. Measured constraint, not taste:
469
522
  // the corpus pins byte-empty stderr on this command's success path
470
523
  // (tests/scripts/materialize-wave-scope.test.mjs and
471
524
  // tests/integration/wave-scope-producer.test.mjs), and a wave with no
472
- // orphans IS the success path. Both lists always reach --json.
525
+ // orphans IS the success path unless missing-path diagnostics are explicitly
526
+ // requested above. Both orphan lists always reach --json.
473
527
  for (const file of removedOrphans) {
474
528
  process.stderr.write(`materialize-wave-scope: removed orphaned declaration ${file} (id absent from this wave's records)\n`);
475
529
  }
@@ -11,9 +11,9 @@
11
11
  * it to each dispatched agent's prompt.
12
12
  *
13
13
  * Resolution:
14
- * - scopePaths ← `allowedPaths` from `.claude/wave-scope.json`
14
+ * - scopePaths ← `allowedPaths` from the active harness wave-scope.json
15
15
  * (override: --wave-scope <path>)
16
- * - mode ← `session-type:` frontmatter in `.claude/STATE.md`
16
+ * - mode ← `session-type:` frontmatter in the active harness STATE.md
17
17
  * (override: --mode <m>; unreadable → null = no mode gating)
18
18
  * - hostClass ← `host_class` from `.orchestrator/host.json` via readHostClass
19
19
  * (override: --host-class <c>; unreadable → null = no gating)
@@ -103,6 +103,7 @@ import { createHash } from 'node:crypto';
103
103
  import { findProjectRoot } from './lib/common.mjs';
104
104
  import { loadApplicableRules } from './lib/rule-loader.mjs';
105
105
  import { readHostClass } from './lib/autopilot/telemetry.mjs';
106
+ import { resolveStateArtifactPath, resolveStateMdPath } from './lib/state-md.mjs';
106
107
 
107
108
  // ---------------------------------------------------------------------------
108
109
  // EPIPE hardening (regression follow-up on #876)
@@ -136,10 +137,10 @@ as an injectable Markdown block, for the wave-executor to prepend to each
136
137
  dispatched agent's prompt (#336 / #694).
137
138
 
138
139
  Options:
139
- --wave-scope <path> Path to wave-scope.json (default: .claude/wave-scope.json).
140
+ --wave-scope <path> Path to wave-scope.json (default: active harness, then legacy).
140
141
  Its "allowedPaths" array is used as scopePaths.
141
142
  --mode <m> Override session mode (default: session-type: from
142
- .claude/STATE.md; unreadable -> no mode gating).
143
+ active harness STATE.md; unreadable -> no mode gating).
143
144
  --host-class <c> Override host class (default: host_class from
144
145
  .orchestrator/host.json; unreadable -> no gating).
145
146
  --context <c> Caller context for tier gating: 'wave' | 'coordinator'.
@@ -322,8 +323,8 @@ const repoRoot = findProjectRoot(process.cwd());
322
323
  const rulesDir = join(repoRoot, '.claude', 'rules');
323
324
  const waveScopePath = opts['wave-scope']
324
325
  ? opts['wave-scope']
325
- : join(repoRoot, '.claude', 'wave-scope.json');
326
- const stateMdPath = join(repoRoot, '.claude', 'STATE.md');
326
+ : resolveStateArtifactPath(repoRoot, 'wave-scope.json');
327
+ const stateMdPath = resolveStateMdPath(repoRoot);
327
328
  const hostJsonPath = join(repoRoot, '.orchestrator', 'host.json');
328
329
 
329
330
  // ---------------------------------------------------------------------------
@@ -101,6 +101,7 @@ import { join, dirname } from 'node:path';
101
101
  import { fileURLToPath } from 'node:url';
102
102
 
103
103
  import { findProjectRoot } from './lib/common.mjs';
104
+ import { resolveStateArtifactPath } from './lib/state-md.mjs';
104
105
  import {
105
106
  CANDIDATE_POOL_SIZE,
106
107
  DEFAULT_MAX_GLOBAL,
@@ -141,7 +142,7 @@ Options:
141
142
  agent's declared "Files:" scope. Preferred input.
142
143
  Unreadable or malformed -> exit 1.
143
144
  --wave-scope <path> Fallback scope source; reads "allowedPaths" (default:
144
- .claude/wave-scope.json). An EXPLICIT path that is
145
+ active harness, then legacy). An EXPLICIT path that is
145
146
  unreadable/malformed -> exit 1; the DEFAULT path being
146
147
  absent -> stderr diagnostic + empty scope, exit 0.
147
148
  --task-text <text> Optional agent task title/description. Feeds the token
@@ -314,7 +315,7 @@ if (scopePaths.length === 0) {
314
315
  const waveScopeExplicit = Boolean(opts['wave-scope']);
315
316
  const waveScopePath = waveScopeExplicit
316
317
  ? opts['wave-scope']
317
- : join(repoRoot, '.claude', 'wave-scope.json');
318
+ : resolveStateArtifactPath(repoRoot, 'wave-scope.json');
318
319
  const doc = readJsonOrNull(waveScopePath, waveScopeExplicit, '--wave-scope');
319
320
  if (doc === null) {
320
321
  note(`wave-scope not found at ${waveScopePath} — using empty scope`);