session-orchestrator 3.16.0 → 3.19.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 (220) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +130 -412
  6. package/README.md +17 -12
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/docs/README.md +2 -1
  21. package/docs/USER-GUIDE.md +8 -3
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +7 -7
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +444 -0
  28. package/docs/pi-setup.md +1 -1
  29. package/docs/rule-authoring.md +58 -9
  30. package/docs/session-config-reference.md +306 -6
  31. package/docs/session-config-template.md +66 -3
  32. package/docs/telemetry/telemetry-claims.md +204 -0
  33. package/docs/telemetry.md +158 -0
  34. package/hooks/_lib/guard-source-loader.mjs +467 -0
  35. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  36. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  37. package/hooks/config-protection.mjs +0 -0
  38. package/hooks/enforce-commands.mjs +10 -2
  39. package/hooks/hooks-codex.json +1 -1
  40. package/hooks/hooks-cursor.json +11 -2
  41. package/hooks/hooks-pi.json +10 -0
  42. package/hooks/hooks.json +21 -1
  43. package/hooks/on-session-end.mjs +178 -18
  44. package/hooks/on-session-start.mjs +23 -0
  45. package/hooks/post-bash-write-verify.mjs +977 -0
  46. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  47. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  48. package/hooks/pre-bash-issue-budget.mjs +167 -0
  49. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  50. package/hooks/pre-bash-templates-first.mjs +96 -63
  51. package/hooks/skill-invocation-telemetry.mjs +109 -10
  52. package/hooks/subagent-telemetry.mjs +527 -37
  53. package/package.json +16 -3
  54. package/pi/prompts/contract-version-bump.md +12 -0
  55. package/rules/README.md +32 -0
  56. package/scripts/archive-closed-prds.mjs +12 -22
  57. package/scripts/autopilot-multi.mjs +103 -20
  58. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  59. package/scripts/check-doc-consistency.sh +17 -1
  60. package/scripts/compute-grounding-injection.sh +18 -3
  61. package/scripts/dialectic-deriver.mjs +7 -2
  62. package/scripts/eval-session.mjs +50 -9
  63. package/scripts/fleet-instruction-scan.mjs +141 -0
  64. package/scripts/lib/auto-dialectic.mjs +11 -2
  65. package/scripts/lib/auto-dream.mjs +16 -5
  66. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  67. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  68. package/scripts/lib/backlog-scan.mjs +39 -6
  69. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  70. package/scripts/lib/build-live-signals.mjs +7 -4
  71. package/scripts/lib/ci-status-banner.mjs +75 -12
  72. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  73. package/scripts/lib/command-blocker.mjs +1013 -58
  74. package/scripts/lib/config/config-protection.mjs +2 -1
  75. package/scripts/lib/config/context-coverage.mjs +82 -0
  76. package/scripts/lib/config/drift-check.mjs +9 -1
  77. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  78. package/scripts/lib/config/issue-budget.mjs +123 -0
  79. package/scripts/lib/config/moc-staleness.mjs +98 -0
  80. package/scripts/lib/config/reconcile.mjs +21 -0
  81. package/scripts/lib/config/section-extractor.mjs +121 -1
  82. package/scripts/lib/config/worktree-orphans.mjs +138 -0
  83. package/scripts/lib/config-schema.mjs +23 -3
  84. package/scripts/lib/config.mjs +32 -0
  85. package/scripts/lib/context-coverage-banner.mjs +223 -0
  86. package/scripts/lib/convergence-monitor.mjs +49 -3
  87. package/scripts/lib/description-surface.mjs +535 -0
  88. package/scripts/lib/dispatcher/enumerate.mjs +136 -30
  89. package/scripts/lib/dispatcher/rank.mjs +22 -8
  90. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  91. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  92. package/scripts/lib/eval/engine.mjs +47 -5
  93. package/scripts/lib/events.mjs +59 -7
  94. package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
  95. package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
  96. package/scripts/lib/gates/gate-full.mjs +15 -3
  97. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  98. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  99. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  100. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  101. package/scripts/lib/handover-gate.mjs +7 -3
  102. package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
  103. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  104. package/scripts/lib/io.mjs +345 -10
  105. package/scripts/lib/issue-budget.mjs +269 -0
  106. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  107. package/scripts/lib/label-scope.mjs +47 -0
  108. package/scripts/lib/learnings/schema.mjs +43 -3
  109. package/scripts/lib/lock-reaper.mjs +1 -2
  110. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  111. package/scripts/lib/moc-staleness-banner.mjs +267 -0
  112. package/scripts/lib/peer-discovery.mjs +645 -0
  113. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  114. package/scripts/lib/product-repo-detect.mjs +9 -8
  115. package/scripts/lib/project-hygiene.mjs +432 -0
  116. package/scripts/lib/quality-gate.mjs +167 -0
  117. package/scripts/lib/recommendations-v0.mjs +1 -1
  118. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  119. package/scripts/lib/reconcile/emitter.mjs +23 -4
  120. package/scripts/lib/reconcile/engine.mjs +147 -39
  121. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  122. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  123. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  124. package/scripts/lib/resource-probe.mjs +19 -0
  125. package/scripts/lib/rule-loader.mjs +6 -0
  126. package/scripts/lib/scope-baseline.mjs +564 -0
  127. package/scripts/lib/scope-gate.mjs +399 -98
  128. package/scripts/lib/session-close-backfill.mjs +61 -6
  129. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  130. package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
  131. package/scripts/lib/session-id.mjs +221 -41
  132. package/scripts/lib/session-lock.mjs +304 -6
  133. package/scripts/lib/session-schema/constants.mjs +22 -3
  134. package/scripts/lib/session-schema/filters.mjs +88 -0
  135. package/scripts/lib/session-schema/validator.mjs +16 -0
  136. package/scripts/lib/session-schema.mjs +1 -0
  137. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  138. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  139. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  140. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  141. package/scripts/lib/skill-health/join.mjs +35 -9
  142. package/scripts/lib/spiral-carryover.mjs +142 -30
  143. package/scripts/lib/state-md/mission-status.mjs +53 -3
  144. package/scripts/lib/subagents-schema.mjs +43 -9
  145. package/scripts/lib/telemetry/anon-id.mjs +141 -0
  146. package/scripts/lib/telemetry/consent.mjs +299 -0
  147. package/scripts/lib/telemetry/paths.mjs +27 -0
  148. package/scripts/lib/telemetry/queue.mjs +287 -0
  149. package/scripts/lib/telemetry/schema.mjs +384 -0
  150. package/scripts/lib/telemetry/sync.mjs +312 -0
  151. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  152. package/scripts/lib/tests-src-ratio.mjs +484 -0
  153. package/scripts/lib/validate/check-agents.mjs +56 -0
  154. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  155. package/scripts/lib/validate/check-rules.mjs +217 -35
  156. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  157. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  158. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  159. package/scripts/lib/vault-archive.mjs +17 -2
  160. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  161. package/scripts/lib/vault-mirror/process.mjs +30 -0
  162. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  163. package/scripts/lib/vault-status/board-writer.mjs +63 -5
  164. package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
  165. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  166. package/scripts/lib/wave-resource-gate.mjs +115 -11
  167. package/scripts/lib/worktree/listing.mjs +44 -7
  168. package/scripts/mcp-server.sh +32 -6
  169. package/scripts/measure-context-overhead.sh +151 -0
  170. package/scripts/memory-propose.mjs +72 -9
  171. package/scripts/print-applicable-rules.mjs +51 -12
  172. package/scripts/release.mjs +534 -0
  173. package/scripts/run-quality-gate.mjs +123 -5
  174. package/scripts/telemetry.mjs +250 -0
  175. package/scripts/validate-wave-scope.mjs +182 -17
  176. package/scripts/vault-integration-watcher.mjs +32 -10
  177. package/skills/_shared/config-reading.md +2 -2
  178. package/skills/bootstrap/fast-template.md +1 -1
  179. package/skills/claude-md-drift-check/checker.mjs +145 -28
  180. package/skills/contract-version-bump/SKILL.md +219 -0
  181. package/skills/discovery/SKILL.md +4 -4
  182. package/skills/discovery/issue-templates.md +11 -11
  183. package/skills/discovery/probes-audit.md +1 -1
  184. package/skills/discovery/probes-feature.md +1 -1
  185. package/skills/discovery/probes-session.md +26 -5
  186. package/skills/ecosystem-health/SKILL.md +1 -1
  187. package/skills/ecosystem-health/wizard.md +4 -4
  188. package/skills/evolve/SKILL.md +1 -0
  189. package/skills/gitlab-ops/SKILL.md +20 -12
  190. package/skills/gitlab-portfolio/SKILL.md +2 -2
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +97 -0
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/quality-gates/SKILL.md +1 -1
  199. package/skills/reconcile/SKILL.md +21 -4
  200. package/skills/session-end/SKILL.md +108 -14
  201. package/skills/session-end/discovery-scan.md +4 -2
  202. package/skills/session-end/drift-operations.md +4 -4
  203. package/skills/session-end/metrics-collection.md +13 -0
  204. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  205. package/skills/session-end/phase-3-6-tail.md +2 -1
  206. package/skills/session-end/plan-verification.md +5 -2
  207. package/skills/session-end/vault-operations.md +1 -1
  208. package/skills/session-end/verification-checklist.md +1 -1
  209. package/skills/session-plan/SKILL.md +6 -2
  210. package/skills/session-plan/wave-template.md +2 -0
  211. package/skills/session-start/SKILL.md +149 -7
  212. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  213. package/skills/test-runner/SKILL.md +2 -2
  214. package/skills/vault-sync/SKILL.md +1 -1
  215. package/skills/vault-sync/package-lock.json +3 -3
  216. package/skills/vault-sync/validator.mjs +228 -40
  217. package/skills/wave-executor/SKILL.md +5 -2
  218. package/skills/wave-executor/circuit-breaker.md +2 -0
  219. package/skills/wave-executor/wave-loop.md +163 -10
  220. package/templates/_shared/loop.md +4 -4
@@ -62,6 +62,7 @@ import { fileURLToPath } from 'node:url';
62
62
  import { z } from 'zod';
63
63
  import YAML from 'yaml';
64
64
  import { resolveInstructionFile } from '../../scripts/lib/common.mjs';
65
+ import { findSessionConfigBlock } from '../../scripts/lib/config/section-extractor.mjs';
65
66
  import {
66
67
  computeSchemaHash,
67
68
  writeBaseline,
@@ -159,14 +160,27 @@ const _schemaHash = (() => {
159
160
  const args = process.argv.slice(2);
160
161
  const checkExpires = args.includes('--check-expires');
161
162
 
162
- // Parse --mode <hard|warn|off|baseline|diff|full> (default: hard)
163
+ // Parse --mode <hard|strict|warn|off|baseline|diff|full> (default: hard)
163
164
  // hard — legacy alias; identical to full enforcement
165
+ // strict — alias of hard (#835). The Session Config schema
166
+ // (scripts/lib/config-schema.mjs VAULT_MODE_VALUES) uses
167
+ // strict|warn|off, so a caller forwarding the configured value
168
+ // verbatim would otherwise hit exit 2. Normalized to 'hard' at
169
+ // parse time so exactly one internal value flows downstream.
164
170
  // full — enforce: exit 1 on errors
165
171
  // warn — report but exit 0
166
172
  // off — skip entirely
167
173
  // baseline — write snapshot then exit 0
168
174
  // diff — compare against snapshot, emit JSON diff
169
175
  // Parse --exclude <glob> (repeatable)
176
+ const MODE_VALUES = new Set(['hard', 'strict', 'warn', 'off', 'baseline', 'diff', 'full']);
177
+ const MODE_EXPECTED = 'hard|strict|warn|off|baseline|diff|full';
178
+
179
+ /** Normalize CLI mode aliases to the single internal value used downstream. */
180
+ function normalizeMode(v) {
181
+ return v === 'strict' ? 'hard' : v;
182
+ }
183
+
170
184
  let mode = 'hard';
171
185
  const excludePatterns = [];
172
186
 
@@ -204,22 +218,22 @@ for (let i = 0; i < args.length; i++) {
204
218
  const a = args[i];
205
219
  if (a === '--mode') {
206
220
  const v = args[i + 1];
207
- if (v === 'hard' || v === 'warn' || v === 'off' || v === 'baseline' || v === 'diff' || v === 'full') {
208
- mode = v;
221
+ if (MODE_VALUES.has(v)) {
222
+ mode = normalizeMode(v);
209
223
  } else {
210
224
  process.stderr.write(
211
- `validator.mjs: invalid --mode value "${v}" (expected hard|warn|off|baseline|diff|full)\n`,
225
+ `validator.mjs: invalid --mode value "${v}" (expected ${MODE_EXPECTED})\n`,
212
226
  );
213
227
  process.exit(2);
214
228
  }
215
229
  i++;
216
230
  } else if (a.startsWith('--mode=')) {
217
231
  const v = a.slice('--mode='.length);
218
- if (v === 'hard' || v === 'warn' || v === 'off' || v === 'baseline' || v === 'diff' || v === 'full') {
219
- mode = v;
232
+ if (MODE_VALUES.has(v)) {
233
+ mode = normalizeMode(v);
220
234
  } else {
221
235
  process.stderr.write(
222
- `validator.mjs: invalid --mode value "${v}" (expected hard|warn|off|baseline|diff|full)\n`,
236
+ `validator.mjs: invalid --mode value "${v}" (expected ${MODE_EXPECTED})\n`,
223
237
  );
224
238
  process.exit(2);
225
239
  }
@@ -283,18 +297,38 @@ function isExcluded(relPath) {
283
297
  // Returns true if dir contains at least one recognized vault marker:
284
298
  // 1. _meta/ directory
285
299
  // 2. CLAUDE.md or AGENTS.md (alias — see skills/_shared/instruction-file-resolution.md)
286
- // containing both "## Session Config" and "vault-sync:". The instruction
287
- // file is resolved via resolveInstructionFile() so CLAUDE.md wins ties and
288
- // AGENTS.md is accepted on Codex CLI repos.
300
+ // whose `## Session Config` block declares a `vault-sync:` key. The
301
+ // instruction file is resolved via resolveInstructionFile() so CLAUDE.md
302
+ // wins ties and AGENTS.md is accepted on Codex CLI repos.
289
303
  // 3. .obsidian/ directory
304
+ //
305
+ // Marker 2 was two whole-file substring tests until #968:
306
+ // content.includes('## Session Config') && content.includes('vault-sync:')
307
+ // A substring test is the wrong instrument for "does this directory look like
308
+ // a vault" in three independent ways, all reachable:
309
+ // (a) `'### Session Config'.includes('## Session Config')` is TRUE (from
310
+ // index 1), so an H3 — or any deeper heading — passed the marker.
311
+ // HTML comment matched. Any document ABOUT session-orchestrator config
312
+ // reads as a vault marker.
313
+ // (b) A `## Session Config` mention in ordinary prose (not at line start)
314
+ // matched, e.g. "see the ## Session Config block" mid-sentence.
315
+ // (c) `vault-sync:` was accepted ANYWHERE in the file — a prose sentence or
316
+ // a doc-comment sufficed; it never had to be a config key, let alone one
317
+ // inside the Session Config block.
318
+ // Misclassifying a directory as a vault is not cosmetic: it makes the
319
+ // validator crawl and enforce vault frontmatter over an arbitrary tree, and
320
+ // (via the cwd branch below) silently adopt it as VAULT_DIR.
321
+ //
322
+ // The fix uses the SSOT block extractor, so the heading must be a real
323
+ // heading LINE and `vault-sync:` must be a key INSIDE that block.
290
324
  function isVaultDir(dir) {
291
325
  if (existsSync(join(dir, '_meta')) && statSync(join(dir, '_meta')).isDirectory()) return true;
292
326
  if (existsSync(join(dir, '.obsidian')) && statSync(join(dir, '.obsidian')).isDirectory()) return true;
293
327
  const instr = resolveInstructionFile(dir);
294
328
  if (instr) {
295
329
  try {
296
- const content = readFileSync(instr.path, 'utf8');
297
- if (content.includes('## Session Config') && content.includes('vault-sync:')) return true;
330
+ const block = findSessionConfigBlock(readFileSync(instr.path, 'utf8'));
331
+ if (block && /^\s*(?:-\s+)?(?:\*\*)?vault-sync(?:\*\*)?\s*:/m.test(block.body)) return true;
298
332
  } catch {
299
333
  // unreadable instruction file — not a vault marker
300
334
  }
@@ -335,13 +369,29 @@ if (process.env.VAULT_DIR) {
335
369
  }
336
370
  }
337
371
 
372
+ // Directories the crawler never descends into. These are structurally
373
+ // invisible: their notes are neither checked NOR available as link targets.
338
374
  const EXCLUDED_DIRS = new Set([
339
375
  'node_modules',
340
376
  '.git',
341
377
  '.obsidian',
342
- '90-archive',
343
378
  ]);
344
379
 
380
+ // Top-level directories whose notes are skipped by the CHECK set but remain in
381
+ // the link-target register (#833). Before this split, '90-archive' lived in
382
+ // EXCLUDED_DIRS, so archiving a note silently turned every inbound
383
+ // [[wiki-link]] into a dangling-link warning. The register and the check-set
384
+ // are now decoupled: walk() still visits these notes (so they resolve links),
385
+ // the validation loop skips them (so their frontmatter never blocks a close).
386
+ const CHECK_EXCLUDED_TOP_DIRS = new Set(['90-archive']);
387
+
388
+ /** True when relPath's FIRST path segment is a check-excluded top-level dir. */
389
+ function isArchived(relPath) {
390
+ const p = String(relPath).replace(/\\/g, '/');
391
+ const top = p.split('/', 1)[0];
392
+ return CHECK_EXCLUDED_TOP_DIRS.has(top);
393
+ }
394
+
345
395
  function emit(obj) {
346
396
  process.stdout.write(JSON.stringify(obj) + '\n');
347
397
  }
@@ -354,6 +404,7 @@ if (mode === 'off') {
354
404
  vault_dir: vaultDir,
355
405
  files_checked: 0,
356
406
  excluded_count: 0,
407
+ archived_skipped_count: 0,
357
408
  files_skipped_no_frontmatter: 0,
358
409
  errors: [],
359
410
  warnings: [],
@@ -415,16 +466,86 @@ function parseFrontmatter(raw) {
415
466
  }
416
467
  }
417
468
 
418
- // ── Build link index (filename -> path) ─────────────────────────────────────
419
- const fileIndex = new Map(); // basename-without-ext -> [absolute paths]
420
- for (const f of mdFiles) {
421
- const key = basename(f, '.md');
422
- if (!fileIndex.has(key)) fileIndex.set(key, []);
423
- fileIndex.get(key).push(f);
469
+ // ── Wiki-link regex — captures link body for target parsing ─────────────────
470
+ // NOTE: intentionally NOT module-level. A shared `/g` regex driven by
471
+ // `.exec()` in a loop carries `lastIndex` state across calls; the previous
472
+ // module-level `WIKILINK_RE` was safe only because the loop always ran to
473
+ // exhaustion (never an early return/continue). Constructing a fresh regex
474
+ // per `extractWikiLinks()` call removes that hazard structurally rather than
475
+ // relying on "the loop happens to always finish" (#852).
476
+ function wikilinkRegex() {
477
+ return /\[\[([^\]]+)\]\]/g;
424
478
  }
425
479
 
426
- // ── Wiki-link regex captures link body for target parsing ─────────────────
427
- const WIKILINK_RE = /\[\[([^\]]+)\]\]/g;
480
+ // ── Code-span stripping (#852, hardened post-review) ────────────────────────
481
+ // A wikilink written as inline code (`` `[[target]]` ``) or inside a fenced /
482
+ // indented code block is pedagogical prose ABOUT the Obsidian wiki-link
483
+ // convention (#159 pattern: keep named invariants in backticks), not a real
484
+ // link, and must never surface a dangling-wiki-link warning. All span kinds
485
+ // are blanked out (non-newline chars -> space, newlines preserved) BEFORE the
486
+ // wikilink regex runs, so their `[[...]]` content is invisible to
487
+ // extractWikiLinks() while line count / offsets stay stable for any future
488
+ // position-aware consumer.
489
+ //
490
+ // NOTE: intentionally functions, not module-level `/g` regex constants. Every
491
+ // call below builds a fresh RegExp so no shared `lastIndex` state can leak
492
+ // across calls (same rationale as wikilinkRegex() above, #852).
493
+ //
494
+ // Order: fenced blocks (multi-line) -> indented code blocks (line-based) ->
495
+ // inline spans (single/double backtick, same line only). Fenced blocks run
496
+ // first because they are the only multi-line shape; the other two are
497
+ // line-local and their relative order does not change the result (a line
498
+ // already blanked by an earlier pass is all-spaces and matches idempotently
499
+ // under either later pass).
500
+ //
501
+ // Fenced-block fix (post-#852 QA review): the ORIGINAL `/```[\s\S]*?```/g`
502
+ // paired ANY two ``` runs in the whole file — including a ``` merely
503
+ // MENTIONED mid-sentence in prose — which silently blanked (and hid dangling
504
+ // links inside) everything between an unrelated mention and the next real
505
+ // fence. Real CommonMark fences must open at the START of a line (up to 3
506
+ // leading spaces); this regex is now line-anchored so a mid-line mention can
507
+ // never open a fence. The closing fence must reuse the SAME fence character
508
+ // (backtick vs tilde) with a run of at least 3 — `(?:\1){3,}` repeats the
509
+ // single captured fence character, so a backtick fence cannot be closed by a
510
+ // tilde run or vice versa. Also handles `~~~` fences and fenced blocks that
511
+ // carry an info string (e.g. ```` ```md ````) — the info string is just the
512
+ // rest of the opening line, already consumed by `[^\n]*`.
513
+ function fencedCodeRegex() {
514
+ return /^ {0,3}(`|~)\1{2,}[^\n]*\n[\s\S]*?^ {0,3}(?:\1){3,}[ \t]*$/gm;
515
+ }
516
+
517
+ // Indented code blocks (4+ leading spaces, or a leading tab) are CommonMark
518
+ // code too. Required to be flanked by a blank line (or start/end of text) on
519
+ // both sides — mirrors CommonMark's "separated from surrounding paragraph
520
+ // text" rule closely enough to avoid blanking ordinary 4-space-indented list
521
+ // continuation text that isn't actually a code block, while still catching
522
+ // the FP shape this fixes.
523
+ function indentedCodeRegex() {
524
+ return /(?<=^|\n\n)(?: {4,}|\t)[^\n]*(?:\n(?: {4,}|\t)[^\n]*)*/g;
525
+ }
526
+
527
+ // Inline code spans: single-backtick (`` ` `` ... `` ` ``) or double-backtick
528
+ // (`` `` `` ... `` `` ``, used when the wrapped content itself contains a
529
+ // backtick) delimiters, same line only. The closing delimiter must be the
530
+ // SAME LENGTH as the opening one (`\1` backreference on the captured 1-2
531
+ // backtick run) so a double-backtick span isn't mis-closed by the first lone
532
+ // backtick inside its own content — the over-strip hazard called out in
533
+ // #852. A lone/stray backtick with no same-length partner on the same line
534
+ // therefore matches nothing and is left as plain text.
535
+ function inlineCodeRegex() {
536
+ return /(`{1,2})[^\n]*?\1(?!`)/g;
537
+ }
538
+
539
+ function blank(match) {
540
+ return match.replace(/[^\n]/g, ' ');
541
+ }
542
+
543
+ function stripCodeSpans(text) {
544
+ let out = text.replace(fencedCodeRegex(), blank);
545
+ out = out.replace(indentedCodeRegex(), blank);
546
+ out = out.replace(inlineCodeRegex(), blank);
547
+ return out;
548
+ }
428
549
 
429
550
  function findAliasSeparatorIndex(linkBody) {
430
551
  for (let i = 0; i < linkBody.length; i++) {
@@ -441,18 +562,81 @@ function extractWikiLinkTarget(linkBody) {
441
562
  }
442
563
 
443
564
  function extractWikiLinks(content) {
565
+ const stripped = stripCodeSpans(content);
444
566
  const targets = new Set();
567
+ const re = wikilinkRegex();
445
568
  let m;
446
- while ((m = WIKILINK_RE.exec(content)) !== null) {
569
+ while ((m = re.exec(stripped)) !== null) {
447
570
  const target = extractWikiLinkTarget(m[1]);
448
571
  if (target.length > 0) targets.add(target);
449
572
  }
450
573
  return [...targets];
451
574
  }
452
575
 
576
+ // ── Pass 1: read every file ONCE ────────────────────────────────────────────
577
+ // Builds the record set the link-target register (below) and the validation
578
+ // loop (further down) both consume. Net-neutral on I/O versus the previous
579
+ // shape, which read the file once but ran the frontmatter match twice.
580
+ // A malformed note must never abort this pass — every failure is recorded on
581
+ // the record and surfaced later by the validation loop.
582
+ const records = [];
583
+ for (const file of mdFiles) {
584
+ const rel = relative(vaultDir, file);
585
+ let raw;
586
+ try {
587
+ raw = readFileSync(file, 'utf8');
588
+ } catch (err) {
589
+ records.push({ file, rel, readError: err.message || String(err) });
590
+ continue;
591
+ }
592
+ let fm;
593
+ let links;
594
+ try {
595
+ fm = parseFrontmatter(raw);
596
+ const body = raw.slice(raw.match(FRONTMATTER_RE)?.[0].length || 0);
597
+ links = extractWikiLinks(body);
598
+ } catch (err) {
599
+ fm = { hasFrontmatter: true, parseError: err.message || String(err) };
600
+ links = [];
601
+ }
602
+ records.push({ file, rel, fm, links });
603
+ }
604
+
605
+ // ── Link-target register ────────────────────────────────────────────────────
606
+ // Keys: basename-without-ext, frontmatter `id`, and every frontmatter `aliases`
607
+ // entry (#833). Keys are normalized NFC + lowercase at BOTH insert and lookup:
608
+ // NFC is required, not cosmetic — APFS returns decomposed (NFD) filenames and
609
+ // this is a German-language corpus, so "Übung" from a filename and "Übung" from
610
+ // a YAML string would otherwise be different strings. Values are already
611
+ // arrays, so a case-collision (Topic.md + topic.md) merely appends.
612
+ const fileIndex = new Map(); // normalized key -> [absolute paths]
613
+
614
+ function indexKey(k) {
615
+ return String(k).normalize('NFC').toLowerCase();
616
+ }
617
+
618
+ function addIndexKey(key, file) {
619
+ if (typeof key !== 'string' || key.length === 0) return;
620
+ const k = indexKey(key);
621
+ if (!fileIndex.has(k)) fileIndex.set(k, []);
622
+ fileIndex.get(k).push(file);
623
+ }
624
+
625
+ for (const rec of records) {
626
+ addIndexKey(basename(rec.file, '.md'), rec.file);
627
+ const data = rec.fm && rec.fm.hasFrontmatter && !rec.fm.parseError ? rec.fm.data : null;
628
+ if (!data || typeof data !== 'object') continue;
629
+ // Type-guard: `id` must be a string, `aliases` an array of strings. A note
630
+ // with `aliases: some-scalar` contributes no alias keys instead of throwing.
631
+ if (typeof data.id === 'string') addIndexKey(data.id, rec.file);
632
+ if (Array.isArray(data.aliases)) {
633
+ for (const alias of data.aliases) addIndexKey(alias, rec.file);
634
+ }
635
+ }
636
+
453
637
  function resolveWikiLink(target, sourceFile) {
454
638
  // Target may be a bare name ("my-note") or a path ("01-projects/foo/_overview").
455
- // Try exact path first (relative to vault), then basename lookup.
639
+ // Try exact path first (relative to vault), then register lookup.
456
640
  const candidate1 = resolve(vaultDir, target.endsWith('.md') ? target : target + '.md');
457
641
  if (existsSync(candidate1)) return true;
458
642
 
@@ -463,9 +647,8 @@ function resolveWikiLink(target, sourceFile) {
463
647
  );
464
648
  if (existsSync(candidate2)) return true;
465
649
 
466
- // Try basename lookup anywhere in index
467
- const key = basename(target, '.md');
468
- if (fileIndex.has(key)) return true;
650
+ // Try register lookup anywhere in the vault (basename / id / alias)
651
+ if (fileIndex.has(indexKey(basename(target, '.md')))) return true;
469
652
 
470
653
  return false;
471
654
  }
@@ -476,29 +659,31 @@ const warnings = [];
476
659
  let filesChecked = 0;
477
660
  let filesSkippedNoFrontmatter = 0;
478
661
  let excludedCount = 0;
662
+ let archivedSkippedCount = 0;
479
663
 
480
664
  const todayIso = new Date().toISOString().slice(0, 10);
481
665
 
482
- for (const file of mdFiles) {
483
- const rel = relative(vaultDir, file);
666
+ // ── Pass 2: validate ────────────────────────────────────────────────────────
667
+ for (const rec of records) {
668
+ const { file, rel, fm } = rec;
484
669
  if (isExcluded(rel)) {
485
670
  excludedCount++;
486
671
  continue;
487
672
  }
488
- let raw;
489
- try {
490
- raw = readFileSync(file, 'utf8');
491
- } catch (err) {
673
+ // Archived notes stay in the link-target register but are never CHECKED.
674
+ if (isArchived(rel)) {
675
+ archivedSkippedCount++;
676
+ continue;
677
+ }
678
+ if (rec.readError) {
492
679
  errors.push({
493
680
  file: rel,
494
681
  path: '',
495
- message: `Cannot read file: ${err.message || err}`,
682
+ message: `Cannot read file: ${rec.readError}`,
496
683
  });
497
684
  continue;
498
685
  }
499
686
 
500
- const fm = parseFrontmatter(raw);
501
-
502
687
  if (!fm.hasFrontmatter) {
503
688
  filesSkippedNoFrontmatter++;
504
689
  continue;
@@ -527,10 +712,8 @@ for (const file of mdFiles) {
527
712
  // Even if frontmatter is invalid, still check wiki-links to surface all problems.
528
713
  }
529
714
 
530
- // Wiki-link check
531
- const body = raw.slice(raw.match(FRONTMATTER_RE)?.[0].length || 0);
532
- const links = extractWikiLinks(body);
533
- for (const target of links) {
715
+ // Wiki-link check (links were extracted in pass 1)
716
+ for (const target of rec.links) {
534
717
  if (!resolveWikiLink(target, file)) {
535
718
  warnings.push({
536
719
  file: rel,
@@ -589,6 +772,7 @@ if (mode === 'diff') {
589
772
  vault_dir: vaultDir,
590
773
  files_checked: filesChecked,
591
774
  excluded_count: excludedCount,
775
+ archived_skipped_count: archivedSkippedCount,
592
776
  files_skipped_no_frontmatter: filesSkippedNoFrontmatter,
593
777
  errors,
594
778
  warnings,
@@ -608,6 +792,7 @@ if (mode === 'diff') {
608
792
  vault_dir: vaultDir,
609
793
  files_checked: filesChecked,
610
794
  excluded_count: excludedCount,
795
+ archived_skipped_count: archivedSkippedCount,
611
796
  files_skipped_no_frontmatter: filesSkippedNoFrontmatter,
612
797
  errors,
613
798
  warnings,
@@ -629,6 +814,7 @@ if (mode === 'diff') {
629
814
  vault_dir: vaultDir,
630
815
  files_checked: filesChecked,
631
816
  excluded_count: excludedCount,
817
+ archived_skipped_count: archivedSkippedCount,
632
818
  files_skipped_no_frontmatter: filesSkippedNoFrontmatter,
633
819
  });
634
820
 
@@ -640,7 +826,8 @@ if (mode === 'diff') {
640
826
  }
641
827
 
642
828
  // ── mode=full | hard | warn ───────────────────────────────────────────────
643
- // 'full' and 'hard' are identical (hard is the legacy alias).
829
+ // 'full' and 'hard' are identical (hard is the legacy alias; 'strict' was
830
+ // normalized to 'hard' at parse time — see normalizeMode, #835).
644
831
  // In warn mode, errors are reported but the status is "ok" for exit-code purposes.
645
832
  // The errors array is still populated so the caller can surface them as warnings.
646
833
  const status = hasErrors ? (mode === 'warn' ? 'ok' : 'invalid') : 'ok';
@@ -650,6 +837,7 @@ emit({
650
837
  vault_dir: vaultDir,
651
838
  files_checked: filesChecked,
652
839
  excluded_count: excludedCount,
840
+ archived_skipped_count: archivedSkippedCount,
653
841
  files_skipped_no_frontmatter: filesSkippedNoFrontmatter,
654
842
  errors,
655
843
  warnings,
@@ -246,7 +246,7 @@ Each agent prompt MUST include:
246
246
  2. **Full context**: file paths, current code structure, issue description. If a bite-sized executable plan exists at `docs/plans/<feature>.md` for the wave's tasks (see `skills/write-executable-plan/SKILL.md`), include the path in each agent's prompt and instruct the agent to follow the plan's 5-step structure verbatim.
247
247
  3. **Acceptance criteria**: measurable definition of done
248
248
  4. **Rule references**: the wave's applicable rules are injected automatically as the `<APPLICABLE-RULES>` block produced by `scripts/print-applicable-rules.mjs` (see `wave-loop.md` § "Pre-Dispatch: Glob-Scoped Rule Injection (#336/#694)"). The block is computed once per wave from the wave's `allowedPaths` and prepended to every agent prompt — do not hand-copy rule paths into the prompt.
249
- 5. **Testing expectation**: "Write tests for your changes" or "Run existing tests"
249
+ 5. **Testing expectation** (need-gated): "Before writing any test, name the concrete bug a NEW test would catch that the existing suite does not. No nameable bug → write NO test and report `no-tests-needed: <reason>` that is a SUCCESS outcome, not a gap. With a nameable bug: exactly one test for it. Running existing tests is always mandatory."
250
250
  6. **Commit instruction**: "Do NOT commit. The coordinator handles commits."
251
251
  7. **Turn limit**: Include the maxTurns instruction from `circuit-breaker.md`
252
252
  8. **Verification before completion**: Before claiming any task done, run the verification command and quote the evidence inline. See `.claude/rules/verification-before-completion.md`.
@@ -272,10 +272,13 @@ During this wave, you may propose a learning to the session's memory via the CLI
272
272
  --subject "one-line title (max 100 chars, no newlines)" \
273
273
  --insight "your discovery paragraph (max 2000 chars)" \
274
274
  --evidence "concrete proof: code citation / log excerpt / commit ref (max 5000 chars)" \
275
- --confidence <0.5 to 1.0>
275
+ --confidence <0.5 to 1.0> \
276
+ --file-paths "scripts/lib/a.mjs,scripts/lib/b.mjs"
276
277
 
277
278
  MUST prefix with `SO_WAVE_AGENT=1` — without it the CLI returns exit 3 `rejected-wrong-context`. The env-var is the per-process guard that distinguishes wave-executor agents from coordinator-context invocations.
278
279
 
280
+ `--file-paths` is optional but strongly encouraged: repo-relative path(s) this learning applies to (repeatable and/or comma-separated, deduped; rejects absolute paths, `..` segments, embedded newlines, entries over 256 chars, and more than 20 entries). Without `--file-paths` this learning can never become `/reconcile`-eligible — the reconciliation engine can only convert a learning into a conditional `.claude/rules/*.md` rule when it carries a non-empty scope (issue #900).
281
+
279
282
  Exit code 0 = queued (the coordinator will present at session-end via AskUserQuestion); 1 = quota-exceeded; 2 = rejected-low-confidence (below floor 0.5); 3 = rejected-wrong-context (STATE.md not active OR SO_WAVE_AGENT != "1"); 4 = error (arg validation or internal).
280
283
 
281
284
  Use ONLY when you find a recurring pattern, anti-pattern, or constraint worth carrying into future sessions. The coordinator confirms each proposal before it lands in learnings.jsonl. Do NOT over-propose — quota is bounded per wave.
@@ -31,6 +31,7 @@ The coordinator determines each agent's status after the wave completes:
31
31
  1. **Read the agent's final output** and look for the `STATUS:` line
32
32
  2. **Map to status**:
33
33
  - `STATUS: done` → agent completed successfully
34
+ - `STATUS: no-tests-needed` → **SUCCESS variant, treat exactly like `done`.** test-writer-specific: the agent found no nameable bug the existing suite misses, so writing a test was the wrong move (`.claude/rules/test-value.md`; enum in `agents/schemas/test-writer.schema.json`). NEVER map this to `failed` or carry it over.
34
35
  - `STATUS: partial` → agent hit turn limit or couldn't finish (PARTIAL)
35
36
  - `STATUS: failed` → agent encountered an error it couldn't recover from (FAILED)
36
37
  - No STATUS line found → infer from output: if agent produced changes, mark as `done`; if it reported errors, mark as `failed`; if output is truncated, mark as `partial`
@@ -45,6 +46,7 @@ The coordinator determines each agent's status after the wave completes:
45
46
  | Status | Meaning | Trigger | Recovery |
46
47
  |--------|---------|---------|----------|
47
48
  | **done** | Agent completed all assigned work | Agent reports `STATUS: done` | None needed |
49
+ | **no-tests-needed** | SUCCESS variant of **done** — test-writer found no nameable gap, so no test was the correct outcome | Agent reports `STATUS: no-tests-needed` | None needed — never a failure, never a carryover |
48
50
  | **partial** | Agent made progress but couldn't finish | Turn limit hit, or agent reports `STATUS: partial` | Carry forward remaining work to next wave with context |
49
51
  | **failed** | Agent couldn't make meaningful progress | Tool errors, invalid assumptions, agent reports `STATUS: failed` | Re-dispatch with corrected instructions and narrower scope |
50
52
  | **spiral** | Agent got stuck in an edit loop | Same file edited 3+ times (detected post-wave) | Revert agent's changes, narrow scope, split task if needed |