session-orchestrator 4.0.1 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/.agents/skills/session-plan/SKILL.md +1 -1
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
  6. package/.cursor/skills/session-plan/SKILL.md +1 -1
  7. package/.cursor-plugin/plugin.json +1 -1
  8. package/CHANGELOG.md +57 -0
  9. package/README.md +55 -51
  10. package/agents/ux-evaluator.md +1 -1
  11. package/commands/close.md +3 -3
  12. package/commands/go.md +2 -0
  13. package/commands/memory-cleanup.md +4 -3
  14. package/commands/persona-panel.md +1 -1
  15. package/commands/release.md +4 -4
  16. package/commands/session.md +3 -2
  17. package/docs/README.md +4 -4
  18. package/docs/USER-GUIDE.md +115 -48
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +55 -1
  21. package/docs/ci-setup.md +1 -1
  22. package/docs/codex-setup.md +1 -0
  23. package/docs/components.md +2 -2
  24. package/docs/cursor-setup.md +1 -0
  25. package/docs/events-schema.md +4 -1
  26. package/docs/instruction-delivery.md +1 -1
  27. package/docs/memory-proposal-flow.md +3 -3
  28. package/docs/migration-v4.md +2 -2
  29. package/docs/owner-config-schema.md +74 -90
  30. package/docs/persona-panel.md +4 -4
  31. package/docs/pi-setup.md +1 -0
  32. package/docs/rule-authoring.md +13 -6
  33. package/docs/scope-collision-guard.md +2 -0
  34. package/docs/session-config-reference.md +55 -22
  35. package/docs/session-config-template.md +9 -5
  36. package/docs/vault-docs-architecture.md +4 -2
  37. package/hooks/_lib/hook-import-set.json +28 -3
  38. package/hooks/_lib/vcs-create-matcher.mjs +214 -16
  39. package/hooks/hooks-codex.json +1 -1
  40. package/hooks/hooks.json +1 -1
  41. package/hooks/pre-bash-issue-budget.mjs +123 -26
  42. package/hooks/subagent-telemetry.mjs +106 -20
  43. package/package.json +4 -4
  44. package/scripts/baseline-archetypes.mjs +28 -0
  45. package/scripts/ci/assert-coverage-green.mjs +100 -0
  46. package/scripts/lib/auto-dialectic.mjs +0 -68
  47. package/scripts/lib/baseline-archetypes.mjs +439 -0
  48. package/scripts/lib/build-live-signals.mjs +5 -6
  49. package/scripts/lib/config/issue-budget.mjs +68 -8
  50. package/scripts/lib/config/private-config-dir.mjs +3 -2
  51. package/scripts/lib/config/remote-hosts.mjs +2 -2
  52. package/scripts/lib/config-schema.mjs +79 -0
  53. package/scripts/lib/events.mjs +3 -3
  54. package/scripts/lib/file-lock.mjs +47 -5
  55. package/scripts/lib/issue-budget-reconcile.mjs +392 -0
  56. package/scripts/lib/issue-budget.mjs +76 -3
  57. package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
  58. package/scripts/lib/maintenance-due-banner.mjs +440 -0
  59. package/scripts/lib/owner-config.example.yaml +29 -46
  60. package/scripts/lib/owner-yaml.mjs +14 -13
  61. package/scripts/lib/project-hygiene.mjs +182 -6
  62. package/scripts/lib/quality-gate.mjs +13 -6
  63. package/scripts/lib/resource-probe/evaluate.mjs +19 -21
  64. package/scripts/lib/rules-sync.mjs +34 -4
  65. package/scripts/lib/session-close-backfill.mjs +182 -40
  66. package/scripts/lib/session-end/phase-skip.mjs +85 -86
  67. package/scripts/lib/session-end/tail-runner.mjs +178 -0
  68. package/scripts/lib/session-identity/own-session.mjs +24 -13
  69. package/scripts/lib/session-schema/constants.mjs +6 -0
  70. package/scripts/lib/session-schema/validator.mjs +20 -0
  71. package/scripts/lib/session-shape.mjs +558 -0
  72. package/scripts/lib/session-start-probes.mjs +10 -3
  73. package/scripts/lib/session-token-rollup.mjs +95 -10
  74. package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
  75. package/scripts/lib/state-md.mjs +1 -0
  76. package/scripts/lib/subagents-schema.mjs +77 -9
  77. package/scripts/lib/telemetry/pricing.mjs +197 -0
  78. package/scripts/lib/telemetry/sync.mjs +50 -1
  79. package/scripts/lib/validate/check-owner-leakage.mjs +17 -8
  80. package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
  81. package/scripts/lib/validate/check-unwired-features.mjs +8 -7
  82. package/scripts/lib/vault-mirror/process.mjs +2 -1
  83. package/scripts/lib/vault-mirror/render-sessions.mjs +8 -1
  84. package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
  85. package/scripts/lib/wave-resource-gate.mjs +23 -27
  86. package/scripts/lib/wave-sizing.mjs +10 -3
  87. package/scripts/materialize-wave-scope.mjs +68 -14
  88. package/scripts/print-applicable-rules.mjs +7 -6
  89. package/scripts/print-learnings-index.mjs +3 -2
  90. package/scripts/release.mjs +32 -11
  91. package/scripts/session-shape.mjs +266 -0
  92. package/skills/_shared/config-reading.md +15 -9
  93. package/skills/_shared/private-capability-context.md +89 -0
  94. package/skills/bootstrap/SKILL.md +61 -13
  95. package/skills/bootstrap/_shared-template.md +99 -14
  96. package/skills/bootstrap/deep-template.md +36 -26
  97. package/skills/bootstrap/fast-template.md +44 -8
  98. package/skills/bootstrap/intensity-heuristic.md +10 -4
  99. package/skills/bootstrap/private-contract.md +119 -0
  100. package/skills/bootstrap/public-fallback.md +30 -18
  101. package/skills/bootstrap/standard-template.md +39 -24
  102. package/skills/discovery/probes-ui.md +1 -1
  103. package/skills/docs-orchestrator/audience-mapping.md +1 -1
  104. package/skills/evolve/SKILL.md +2 -2
  105. package/skills/gitlab-ops/SKILL.md +3 -3
  106. package/skills/grill/SKILL.md +1 -1
  107. package/skills/memory-cleanup/SKILL.md +2 -2
  108. package/skills/plan/mode-new.md +9 -0
  109. package/skills/reconcile/SKILL.md +1 -1
  110. package/skills/session-end/SKILL.md +3 -2
  111. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  112. package/skills/session-end/phase-3-6-tail.md +23 -65
  113. package/skills/session-end/phase-3-7a-recommendations.md +2 -2
  114. package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
  115. package/skills/session-end/references/phase-5-issue-cleanup.md +26 -0
  116. package/skills/session-end/session-metrics-write.md +31 -12
  117. package/skills/session-plan/SKILL.md +56 -48
  118. package/skills/session-plan/wave-template.md +8 -15
  119. package/skills/session-start/SKILL.md +18 -2
  120. package/skills/session-start/phase-2-5-docs-planning.md +1 -1
  121. package/skills/session-start/phase-8-5-express-path.md +12 -9
  122. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
  123. package/skills/session-start/references/phase-4-ssot-environment-check.md +21 -5
  124. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
  125. package/skills/test-runner/rubric-v1.md +2 -2
  126. package/skills/wave-executor/SKILL.md +42 -12
  127. package/skills/wave-executor/circuit-breaker.md +3 -1
  128. package/skills/wave-executor/references/wave-loop-dispatch.md +4 -2
  129. package/skills/wave-executor/references/wave-loop-review.md +1 -1
  130. package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
  131. package/templates/nextjs-minimal/package.json +1 -1
  132. package/templates/node-minimal/package.json +1 -1
  133. package/scripts/lib/multi-provider-build/providers.mjs +0 -64
  134. package/scripts/lib/multi-provider-build/templating.mjs +0 -130
  135. package/scripts/lib/owner-config/coerce.mjs +0 -29
  136. package/scripts/lib/owner-config/constants.mjs +0 -21
  137. package/scripts/lib/owner-config/defaults.mjs +0 -50
  138. package/scripts/lib/owner-config/error.mjs +0 -19
  139. package/scripts/lib/owner-config/index.mjs +0 -13
  140. package/scripts/lib/owner-config/merge.mjs +0 -52
  141. package/scripts/lib/owner-config/validate.mjs +0 -259
  142. package/scripts/lib/owner-config-loader.mjs +0 -170
  143. package/scripts/lib/owner-config.mjs +0 -28
  144. package/scripts/lib/soul-resolve.mjs +0 -130
  145. package/scripts/lib/vault-mirror/render.mjs +0 -8
@@ -44,6 +44,8 @@
44
44
  import { execFileSync } from 'node:child_process';
45
45
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
46
46
  import { join } from 'node:path';
47
+ import yaml from 'js-yaml';
48
+ import { tokenizeCommand, splitChainSegments, resolveSegmentVerb } from './command-blocker.mjs';
47
49
 
48
50
  /** Commits past the newest tag before release hygiene is worth mentioning. */
49
51
  export const DEFAULT_RELEASE_DRIFT_COMMITS = 50;
@@ -408,8 +410,8 @@ export function checkStaleArtifacts(repoRoot, ageDays = DEFAULT_ARTIFACT_AGE_DAY
408
410
  /**
409
411
  * H4 — CI configuration hygiene.
410
412
  *
411
- * Measured 5/6. Reports only what is decidable by presence, never by parsing
412
- * pipeline semantics. The dependency-audit gap is the load-bearing one: three
413
+ * Measured 5/6. Checks locally declared executable commands without evaluating
414
+ * pipeline conditions or external includes. The dependency-audit gap is the load-bearing one: three
413
415
  * of the tested repos carried known-vulnerable dependencies that no pipeline
414
416
  * would ever surface.
415
417
  *
@@ -438,19 +440,21 @@ export function checkCiConfig(repoRoot) {
438
440
  return findings;
439
441
  }
440
442
 
441
- let ciText = '';
442
- if (hasGitlab) ciText += safeRead(gitlabCi);
443
+ const ciConfigs = [];
444
+ if (hasGitlab) ciConfigs.push({ text: safeRead(gitlabCi), platform: 'gitlab' });
443
445
  if (hasGithub) {
444
446
  try {
445
447
  for (const f of readdirSync(ghWorkflows)) {
446
- if (f.endsWith('.yml') || f.endsWith('.yaml')) ciText += safeRead(join(ghWorkflows, f));
448
+ if (f.endsWith('.yml') || f.endsWith('.yaml')) {
449
+ ciConfigs.push({ text: safeRead(join(ghWorkflows, f)), platform: 'github' });
450
+ }
447
451
  }
448
452
  } catch {
449
453
  /* unreadable workflows dir — fall through with what we have */
450
454
  }
451
455
  }
452
456
 
453
- if (ciText && !/\b(npm|pnpm|yarn) audit\b|pip-audit|cargo audit|osv-scanner|dependency.?check/i.test(ciText)) {
457
+ if (ciConfigs.some(({ text }) => text) && !ciConfigs.some(hasAuditStep)) {
454
458
  findings.push({
455
459
  check: 'ci-audit-job',
456
460
  fixable: true,
@@ -461,6 +465,178 @@ export function checkCiConfig(repoRoot) {
461
465
  return findings;
462
466
  }
463
467
 
468
+ const GITLAB_COMMAND_FIELDS = ['script', 'before_script', 'after_script'];
469
+ const GITLAB_GLOBAL_FIELDS = new Set([
470
+ 'stages', 'types', 'variables', 'default', 'include', 'workflow',
471
+ 'image', 'services', 'cache', 'before_script', 'after_script', 'spec',
472
+ ]);
473
+ const GITLAB_REFERENCE = Symbol('gitlab-reference');
474
+ const GITLAB_SCHEMA = yaml.DEFAULT_SCHEMA.extend([
475
+ new yaml.Type('!reference', {
476
+ kind: 'sequence',
477
+ construct: (path) => ({ [GITLAB_REFERENCE]: path }),
478
+ }),
479
+ ]);
480
+ const AUDIT_VALUE_FLAGS = new Set([
481
+ '--filter', '-F', '--dir', '-C', '--prefix', '--cwd', '--registry',
482
+ '--userconfig', '--globalconfig', '--workspace', '-w', '--cache', '--pm-on-fail',
483
+ ]);
484
+ const AUDIT_BOOLEAN_FLAGS = new Set([
485
+ '-r', '--recursive', '-s', '--silent', '-g', '--global', '--workspaces', '--offline',
486
+ ]);
487
+
488
+ /** Inspect executable job/step locations, never arbitrary keys named run/script. */
489
+ function hasAuditStep({ text, platform }) {
490
+ let config;
491
+ try {
492
+ if (platform === 'gitlab') {
493
+ const documents = yaml.loadAll(text, undefined, { schema: GITLAB_SCHEMA });
494
+ if (documents.length === 1) {
495
+ [config] = documents;
496
+ } else if (documents.length === 2 && isConfigMap(documents[0])
497
+ && Object.keys(documents[0]).length === 1 && isConfigMap(documents[0].spec)) {
498
+ // GitLab permits a spec header followed by --- and the job document.
499
+ // Header input defaults are data; unrelated multi-document YAML is ambiguous.
500
+ [, config] = documents;
501
+ } else {
502
+ return false;
503
+ }
504
+ } else {
505
+ config = yaml.load(text);
506
+ }
507
+ } catch {
508
+ return false;
509
+ }
510
+ if (!isConfigMap(config)) return false;
511
+ if (platform === 'github') {
512
+ if (!isConfigMap(config.jobs)) return false;
513
+ return Object.values(config.jobs).some((job) => isConfigMap(job)
514
+ && job.if !== false && Array.isArray(job.steps)
515
+ && job.steps.some((step) => isConfigMap(step) && step.if !== false
516
+ && typeof step.run === 'string' && hasAuditCommand(step.run)));
517
+ }
518
+
519
+ return Object.keys(config).some((name) => {
520
+ if (name.startsWith('.') || GITLAB_GLOBAL_FIELDS.has(name)) return false;
521
+ const job = resolveGitlabJob(config, name);
522
+ if (!job?.script || job.when === 'never') return false;
523
+ return GITLAB_COMMAND_FIELDS.some((field) => {
524
+ let commands = job[field];
525
+ if (!Object.hasOwn(job, field) && field !== 'script') {
526
+ const inherit = job.inherit?.default;
527
+ if (inherit === false || (Array.isArray(inherit) && !inherit.includes(field))) return false;
528
+ commands = Object.hasOwn(config.default ?? {}, field) ? config.default[field] : config[field];
529
+ }
530
+ return hasGitlabAuditCommands(commands, config);
531
+ });
532
+ });
533
+ }
534
+
535
+ function isConfigMap(value) {
536
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
537
+ && !Object.hasOwn(value, GITLAB_REFERENCE);
538
+ }
539
+
540
+ /** Local extends uses later-parent/child replacement for command arrays. */
541
+ function resolveGitlabJob(config, name, seen = new Set()) {
542
+ if (typeof name !== 'string' || !Object.hasOwn(config, name)
543
+ || !isConfigMap(config[name]) || seen.has(name)) return null;
544
+ seen.add(name);
545
+ const job = config[name];
546
+ let merged = {};
547
+ const parents = Array.isArray(job.extends) ? job.extends : [job.extends];
548
+ for (const parent of parents.filter((entry) => entry !== undefined)) {
549
+ const inherited = resolveGitlabJob(config, parent, seen);
550
+ // An unresolved later parent may override earlier commands. Only fields
551
+ // supplied by later local parents or the job itself remain evidence.
552
+ merged = inherited ? mergeGitlabJob(merged, inherited) : {};
553
+ }
554
+ seen.delete(name);
555
+ return mergeGitlabJob(merged, job);
556
+ }
557
+
558
+ function mergeGitlabJob(parent, child) {
559
+ const merged = { ...parent, ...child };
560
+ // Command fields replace wholesale; inherit is a map, so its default flag
561
+ // survives a child that overrides only inherit.variables.
562
+ if (isConfigMap(parent.inherit) && isConfigMap(child.inherit)) {
563
+ merged.inherit = { ...parent.inherit, ...child.inherit };
564
+ }
565
+ return merged;
566
+ }
567
+
568
+ /** Resolve only command strings/arrays and local !reference paths, with cycle guards. */
569
+ function hasGitlabAuditCommands(commands, config) {
570
+ const seen = new WeakSet();
571
+ function visit(value) {
572
+ if (typeof value === 'string') return hasAuditCommand(value);
573
+ if (!value || typeof value !== 'object' || seen.has(value)) return false;
574
+ seen.add(value);
575
+ let found = false;
576
+ if (Array.isArray(value)) {
577
+ found = value.some(visit);
578
+ } else if (Array.isArray(value[GITLAB_REFERENCE])) {
579
+ let target = config;
580
+ for (const key of value[GITLAB_REFERENCE]) {
581
+ target = target && typeof target === 'object' && Object.hasOwn(target, key) ? target[key] : undefined;
582
+ }
583
+ found = visit(target);
584
+ }
585
+ seen.delete(value);
586
+ return found;
587
+ }
588
+ return visit(commands);
589
+ }
590
+
591
+ /**
592
+ * Bounded command recognition: wrappers and options may precede the audit
593
+ * subcommand, but another verb (install/run/echo) ends the search. Unknown
594
+ * value-taking flags and commands hidden in external includes are not resolved.
595
+ */
596
+ function hasAuditCommand(command) {
597
+ return splitChainSegments(tokenizeCommand(command)).some((segment) => hasAuditSegment(segment));
598
+ }
599
+
600
+ function hasAuditSegment(segment, depth = 0) {
601
+ const resolved = resolveSegmentVerb(segment);
602
+ if (resolved.alt) return false;
603
+ let { verb, index } = resolved;
604
+ if (resolved.payloads.length > 0) {
605
+ if (depth >= 3 || resolved.payloads.length !== 1) return false;
606
+ const tokens = tokenizeCommand(resolved.payloads[0]);
607
+ const parts = splitChainSegments(tokens);
608
+ // env -S splits argv; it does not execute shell operators. Restrict this
609
+ // reuse of the shared tokenizer to one plain command with no redirects.
610
+ if (parts.length !== 1 || parts[0].length !== tokens.length
611
+ || tokens.some((token) => token.redirect)) return false;
612
+ const trailingArgs = index < 0 ? [] : segment.slice(index);
613
+ return hasAuditSegment([...tokens, ...trailingArgs], depth + 1);
614
+ }
615
+ if (verb === 'corepack' || verb === 'npx') {
616
+ index++;
617
+ while (['-y', '--yes', '--no-install', '--'].includes(segment[index]?.text)) index++;
618
+ verb = segment[index]?.text;
619
+ }
620
+ if (segment.slice(index + 1).some((token) => ['-h', '--help', '--version'].includes(token.text))) return false;
621
+ if (/^(?:pip-audit|osv-scanner|dependency-check(?:\.sh)?)$/.test(verb ?? '')) return true;
622
+ if (!/^(?:npm|pnpm|yarn|bun)(?:@[\w.+-]+)?$/.test(verb ?? '') && verb !== 'cargo') return false;
623
+ let i = index + 1;
624
+ while (segment[i]?.text.startsWith('-')) {
625
+ const flag = segment[i].text;
626
+ if (flag === '--') { i++; break; }
627
+ if (AUDIT_VALUE_FLAGS.has(flag)) {
628
+ if (!segment[i + 1] || segment[i + 1].redirect) return false;
629
+ i += 2;
630
+ } else if (AUDIT_BOOLEAN_FLAGS.has(flag) || /^--[\w-]+=/.test(flag)) {
631
+ i++;
632
+ } else {
633
+ return false;
634
+ }
635
+ }
636
+ if (/^yarn(?:@|$)/.test(verb) && segment[i]?.text === 'npm') i++;
637
+ return segment[i]?.text === 'audit';
638
+ }
639
+
464
640
  /** @param {string} p @returns {string} */
465
641
  function safeRead(p) {
466
642
  try {
@@ -117,16 +117,18 @@ function resolveRepoRoot(explicit) {
117
117
  * Closed set of `degraded` reasons for {@link loadCommandsFromSessionConfigDetailed}.
118
118
  *
119
119
  * Its own enum, deliberately not shared with `ci-status-banner.mjs`'s
120
- * `DEGRADED_REASONS`: the members below are exactly the three ways THIS
120
+ * `DEGRADED_REASONS`: the members below are the ways THIS
121
121
  * config-read can fail, and an enum whose members are not exhaustively
122
122
  * reachable cannot be switched on exhaustively.
123
123
  *
124
+ * - `invalid-repo-root` — no non-blank string root was supplied.
124
125
  * - `script-missing` — `scripts/parse-config.mjs` is not on disk.
125
126
  * - `spawn-failed` — the subprocess exited non-zero, timed out, or wrote
126
127
  * nothing to stdout.
127
128
  * - `parse-error` — stdout was not parseable JSON (or the read threw).
128
129
  */
129
130
  export const CONFIG_READ_DEGRADED_REASONS = Object.freeze([
131
+ 'invalid-repo-root',
130
132
  'script-missing',
131
133
  'spawn-failed',
132
134
  'parse-error',
@@ -168,13 +170,18 @@ function sessionConfigFileExists(repoRoot) {
168
170
  * `degraded` is OMITTED, not set to null, on the success path, so a strict
169
171
  * `toEqual({commands: {…}})` pin holds for every readable config.
170
172
  *
171
- * Never throws.
173
+ * Requires an explicit, non-blank repository root; callers that intend cwd
174
+ * must resolve that default before invoking this loader. Never throws.
172
175
  *
173
176
  * @param {string} repoRoot
174
177
  * @returns {{commands: {lint?: string, typecheck?: string, test?: string},
175
- * degraded?: 'script-missing'|'spawn-failed'|'parse-error'}}
178
+ * degraded?: 'invalid-repo-root'|'script-missing'|'spawn-failed'|'parse-error'}}
176
179
  */
177
180
  export function loadCommandsFromSessionConfigDetailed(repoRoot) {
181
+ // Node inherits cwd for undefined/null, which would read another repo's config.
182
+ if (typeof repoRoot !== 'string' || !repoRoot.trim()) {
183
+ return { commands: {}, degraded: 'invalid-repo-root' };
184
+ }
178
185
  try {
179
186
  const scriptPath = join(
180
187
  dirname(fileURLToPath(import.meta.url)),
@@ -229,9 +236,9 @@ export function loadCommandsFromSessionConfigDetailed(repoRoot) {
229
236
  * Returns a partial object — keys that fail to resolve are simply absent
230
237
  * (the caller falls through to DEFAULT_COMMANDS for those).
231
238
  *
232
- * Thin wrapper over {@link loadCommandsFromSessionConfigDetailed}; byte-identical
233
- * return value for every input, including every failure path. Callers that need
234
- * to tell a failed read from an empty config use the detailed variant.
239
+ * Returns the commands half of {@link loadCommandsFromSessionConfigDetailed},
240
+ * including an empty object for an invalid root. Callers that need to tell a
241
+ * failed read from an empty config use the detailed variant.
235
242
  *
236
243
  * Never throws.
237
244
  *
@@ -61,6 +61,14 @@
61
61
  * of starts is a broken instrument to be re-aimed, not a policy to obey.
62
62
  */
63
63
 
64
+ // `session-shape.mjs` is the ONE exported `agents-per-wave` resolver; this
65
+ // module used to carry a private third copy. Import-graph note (this file is
66
+ // hook-reachable via `hooks/_lib/hook-import-set.json`): `session-shape.mjs`
67
+ // statically imports only `session-schema/constants.mjs`, a pure-data leaf, and
68
+ // defers `events.mjs` to a lazy `await import()` — so nothing runs at module
69
+ // load here that did not run before.
70
+ import { resolveAgentCap } from '../session-shape.mjs';
71
+
64
72
  // ---------------------------------------------------------------------------
65
73
  // Verdict precedence helper
66
74
  // ---------------------------------------------------------------------------
@@ -252,29 +260,19 @@ function concurrencySignal(snapshot, concWarn) {
252
260
  }
253
261
 
254
262
  /**
255
- * Resolve an `agentsPerWave` option value into a plain numeric cap, or `null`
256
- * when no cap should apply. Mirrors `resolveApwCap()` in
257
- * `../wave-resource-gate.mjs` — kept as a local pure helper here rather than
258
- * a cross-module import since both sites are ≤10 lines and evolve
259
- * independently per their own gate's options shape.
263
+ * The session type this module resolves `agents-per-wave` FOR: none.
260
264
  *
261
- * `evaluate()` has no session-mode input in scope, so the object shape
262
- * resolves to `cap.default` the documented HR-003 convention writes the
263
- * override as `<default> (mode: <higher-ceiling>)`, i.e. `default` is the
264
- * MORE restrictive of the pair, so this can only under-apply a looser
265
- * mode-specific ceiling, never let a heavy repo exceed its base cap.
265
+ * `evaluate()` has no session-mode input in scope, so it deliberately asks
266
+ * `resolveAgentCap` for no type and takes the `.default` fallback. The HR-003
267
+ * convention writes the override as `<default> (mode: <higher-ceiling>)`, i.e.
268
+ * `default` is the MORE restrictive of the pair under-applying a looser
269
+ * mode-specific ceiling is safe, while passing a guessed `'deep'` here would
270
+ * silently RAISE a heavy repo's ceiling to the deep override on a loaded host.
271
+ * Naming the absence is what keeps that from being written by accident.
266
272
  *
267
- * @param {number|{default: number, [mode: string]: number}|*} cap
268
- * @returns {number|null}
273
+ * @type {undefined}
269
274
  */
270
- function resolveAgentsPerWaveCap(cap) {
271
- if (typeof cap === 'number') return Number.isFinite(cap) ? cap : null;
272
- if (cap !== null && typeof cap === 'object' && !Array.isArray(cap)) {
273
- const def = cap.default;
274
- return typeof def === 'number' && Number.isFinite(def) ? def : null;
275
- }
276
- return null;
277
- }
275
+ const MODE_BLIND_SESSION_TYPE = undefined;
278
276
 
279
277
  /**
280
278
  * Evaluate a snapshot against `resource-thresholds` (from Session Config #166)
@@ -423,7 +421,7 @@ export function evaluate(snapshot, thresholds, options = {}) {
423
421
  // the live-probe signals already computed.
424
422
  // ---------------------------------------------------------------------------
425
423
  const { heavyRepo, agentsPerWave } = options;
426
- const resolvedApwCap = resolveAgentsPerWaveCap(agentsPerWave);
424
+ const resolvedApwCap = resolveAgentCap(agentsPerWave, MODE_BLIND_SESSION_TYPE);
427
425
  if (heavyRepo === true && resolvedApwCap !== null) {
428
426
  cap = cap === null ? resolvedApwCap : Math.min(cap, resolvedApwCap);
429
427
  }
@@ -121,7 +121,7 @@ function listManifestCategories(indexContent) {
121
121
  * @param {string|null} explicitArchetype
122
122
  * @returns {{archetype: string|null, known: boolean}}
123
123
  */
124
- function resolveArchetype(repoRoot, explicitArchetype) {
124
+ export function resolveArchetype(repoRoot, explicitArchetype) {
125
125
  if (explicitArchetype) {
126
126
  return { archetype: explicitArchetype.trim().toLowerCase(), known: true };
127
127
  }
@@ -194,6 +194,12 @@ function escapeRegex(s) {
194
194
  * at least one concrete bullet entry. This keeps `/bootstrap --sync-rules`
195
195
  * ready for future opt-in categories without requiring CLI changes.
196
196
  *
197
+ * `requiredBasenames` optionally adds explicitly required manifest entries,
198
+ * regardless of category or archetype scope. The caller supplies this data;
199
+ * this synchronous writer performs no external lookup. Every requested name
200
+ * must resolve uniquely in the full manifest before ANY file is written.
201
+ * Source validation, provenance, local overrides and dry-run still apply.
202
+ *
197
203
  * Vendoring sanitizer (issue #1098): every source file that reaches the write
198
204
  * decision is additionally scanned by `scanVendoringLeaks()`, and its findings
199
205
  * are collected into the additive `sanitizer[]` array. This runs in
@@ -214,7 +220,8 @@ function escapeRegex(s) {
214
220
  * dryRun?: boolean,
215
221
  * validate?: boolean,
216
222
  * requireProvenance?: boolean,
217
- * archetype?: string|null
223
+ * archetype?: string|null,
224
+ * requiredBasenames?: string[]|null
218
225
  * }} opts
219
226
  * @returns {{
220
227
  * written: string[],
@@ -233,6 +240,7 @@ export function syncRules({
233
240
  validate = true,
234
241
  requireProvenance = true,
235
242
  archetype = null,
243
+ requiredBasenames = null,
236
244
  } = {}) {
237
245
  const written = [];
238
246
  const skipped = [];
@@ -268,13 +276,35 @@ export function syncRules({
268
276
  ? categories
269
277
  : listManifestCategories(indexContent);
270
278
  const entries = parseIndex(indexContent, selectedCategories);
279
+ const fullManifest = parseIndex(indexContent, listManifestCategories(indexContent));
280
+ const required = new Set();
281
+ if (requiredBasenames !== null) {
282
+ if (!Array.isArray(requiredBasenames)) {
283
+ errors.push({ file: '_index.md', reason: 'requiredBasenames must be an array of unique manifest basenames' });
284
+ } else {
285
+ for (const name of requiredBasenames) {
286
+ if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]*\.md$/.test(name) || required.has(name)) {
287
+ errors.push({ file: '_index.md', reason: 'invalid or duplicate required basename' });
288
+ continue;
289
+ }
290
+ required.add(name);
291
+ const matching = fullManifest.filter((entry) => basename(entry.relPath) === name);
292
+ if (matching.length !== 1) {
293
+ errors.push({ file: name, reason: 'required basename must resolve uniquely in _index.md' });
294
+ } else if (!entries.some((entry) => entry.relPath === matching[0].relPath)) {
295
+ entries.push(matching[0]);
296
+ }
297
+ }
298
+ }
299
+ if (errors.length > 0) return { written, skipped, preserved, errors, warnings, sanitizer };
300
+ }
271
301
 
272
302
  // Resolvability for the See-Also sanitizer is judged against the FULL
273
303
  // manifest, not `selectedCategories`: an archetype-scoped rule is a
274
304
  // legitimate citation target in every repo whose archetype matches, so
275
305
  // narrowing this to the current selection would report false leaks.
276
306
  const manifestBasenames = new Set(
277
- parseIndex(indexContent, listManifestCategories(indexContent)).map((e) => basename(e.relPath)),
307
+ fullManifest.map((e) => basename(e.relPath)),
278
308
  );
279
309
 
280
310
  if (entries.length === 0) {
@@ -297,7 +327,7 @@ export function syncRules({
297
327
 
298
328
  // Archetype filter (issue #722 Epic A Wave 3) — evaluated before any
299
329
  // file IO, so a skip never triggers a spurious "source file not found".
300
- if (archetypes !== null) {
330
+ if (archetypes !== null && !required.has(basename(relPath))) {
301
331
  if (!resolvedArchetype.known) {
302
332
  skipped.push({ file: relPath, reason: 'archetype-unknown' });
303
333
  continue;