session-orchestrator 3.16.0 → 3.17.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 (52) 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/CHANGELOG.md +25 -0
  5. package/README.md +13 -11
  6. package/docs/README.md +2 -1
  7. package/docs/components.md +2 -2
  8. package/docs/pi-setup.md +1 -1
  9. package/docs/session-config-reference.md +65 -0
  10. package/docs/session-config-template.md +27 -0
  11. package/docs/telemetry/telemetry-claims.md +204 -0
  12. package/docs/telemetry.md +158 -0
  13. package/hooks/hooks-codex.json +1 -1
  14. package/hooks/hooks.json +1 -1
  15. package/hooks/skill-invocation-telemetry.mjs +109 -10
  16. package/package.json +12 -2
  17. package/scripts/compute-grounding-injection.sh +18 -3
  18. package/scripts/dialectic-deriver.mjs +7 -2
  19. package/scripts/lib/auto-dialectic.mjs +11 -2
  20. package/scripts/lib/auto-dream.mjs +16 -5
  21. package/scripts/lib/build-live-signals.mjs +7 -4
  22. package/scripts/lib/config/context-coverage.mjs +82 -0
  23. package/scripts/lib/config/moc-staleness.mjs +98 -0
  24. package/scripts/lib/config/worktree-orphans.mjs +138 -0
  25. package/scripts/lib/config.mjs +15 -0
  26. package/scripts/lib/context-coverage-banner.mjs +223 -0
  27. package/scripts/lib/dispatcher/enumerate.mjs +151 -31
  28. package/scripts/lib/dispatcher/rank.mjs +22 -8
  29. package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
  30. package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
  31. package/scripts/lib/harness-audit/categories/category4.mjs +13 -2
  32. package/scripts/lib/moc-staleness-banner.mjs +267 -0
  33. package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
  34. package/scripts/lib/session-schema/filters.mjs +88 -0
  35. package/scripts/lib/session-schema.mjs +1 -0
  36. package/scripts/lib/skill-health/join.mjs +35 -9
  37. package/scripts/lib/telemetry/anon-id.mjs +141 -0
  38. package/scripts/lib/telemetry/consent.mjs +299 -0
  39. package/scripts/lib/telemetry/paths.mjs +27 -0
  40. package/scripts/lib/telemetry/queue.mjs +287 -0
  41. package/scripts/lib/telemetry/schema.mjs +384 -0
  42. package/scripts/lib/telemetry/sync.mjs +312 -0
  43. package/scripts/lib/vault-status/board-writer.mjs +63 -5
  44. package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
  45. package/scripts/mcp-server.sh +15 -3
  46. package/scripts/telemetry.mjs +250 -0
  47. package/skills/npm-publish/SKILL.md +81 -0
  48. package/skills/session-end/SKILL.md +74 -1
  49. package/skills/session-start/SKILL.md +77 -1
  50. package/skills/vault-sync/SKILL.md +1 -1
  51. package/skills/vault-sync/package-lock.json +3 -3
  52. package/skills/vault-sync/validator.mjs +121 -34
@@ -159,14 +159,27 @@ const _schemaHash = (() => {
159
159
  const args = process.argv.slice(2);
160
160
  const checkExpires = args.includes('--check-expires');
161
161
 
162
- // Parse --mode <hard|warn|off|baseline|diff|full> (default: hard)
162
+ // Parse --mode <hard|strict|warn|off|baseline|diff|full> (default: hard)
163
163
  // hard — legacy alias; identical to full enforcement
164
+ // strict — alias of hard (#835). The Session Config schema
165
+ // (scripts/lib/config-schema.mjs VAULT_MODE_VALUES) uses
166
+ // strict|warn|off, so a caller forwarding the configured value
167
+ // verbatim would otherwise hit exit 2. Normalized to 'hard' at
168
+ // parse time so exactly one internal value flows downstream.
164
169
  // full — enforce: exit 1 on errors
165
170
  // warn — report but exit 0
166
171
  // off — skip entirely
167
172
  // baseline — write snapshot then exit 0
168
173
  // diff — compare against snapshot, emit JSON diff
169
174
  // Parse --exclude <glob> (repeatable)
175
+ const MODE_VALUES = new Set(['hard', 'strict', 'warn', 'off', 'baseline', 'diff', 'full']);
176
+ const MODE_EXPECTED = 'hard|strict|warn|off|baseline|diff|full';
177
+
178
+ /** Normalize CLI mode aliases to the single internal value used downstream. */
179
+ function normalizeMode(v) {
180
+ return v === 'strict' ? 'hard' : v;
181
+ }
182
+
170
183
  let mode = 'hard';
171
184
  const excludePatterns = [];
172
185
 
@@ -204,22 +217,22 @@ for (let i = 0; i < args.length; i++) {
204
217
  const a = args[i];
205
218
  if (a === '--mode') {
206
219
  const v = args[i + 1];
207
- if (v === 'hard' || v === 'warn' || v === 'off' || v === 'baseline' || v === 'diff' || v === 'full') {
208
- mode = v;
220
+ if (MODE_VALUES.has(v)) {
221
+ mode = normalizeMode(v);
209
222
  } else {
210
223
  process.stderr.write(
211
- `validator.mjs: invalid --mode value "${v}" (expected hard|warn|off|baseline|diff|full)\n`,
224
+ `validator.mjs: invalid --mode value "${v}" (expected ${MODE_EXPECTED})\n`,
212
225
  );
213
226
  process.exit(2);
214
227
  }
215
228
  i++;
216
229
  } else if (a.startsWith('--mode=')) {
217
230
  const v = a.slice('--mode='.length);
218
- if (v === 'hard' || v === 'warn' || v === 'off' || v === 'baseline' || v === 'diff' || v === 'full') {
219
- mode = v;
231
+ if (MODE_VALUES.has(v)) {
232
+ mode = normalizeMode(v);
220
233
  } else {
221
234
  process.stderr.write(
222
- `validator.mjs: invalid --mode value "${v}" (expected hard|warn|off|baseline|diff|full)\n`,
235
+ `validator.mjs: invalid --mode value "${v}" (expected ${MODE_EXPECTED})\n`,
223
236
  );
224
237
  process.exit(2);
225
238
  }
@@ -335,13 +348,29 @@ if (process.env.VAULT_DIR) {
335
348
  }
336
349
  }
337
350
 
351
+ // Directories the crawler never descends into. These are structurally
352
+ // invisible: their notes are neither checked NOR available as link targets.
338
353
  const EXCLUDED_DIRS = new Set([
339
354
  'node_modules',
340
355
  '.git',
341
356
  '.obsidian',
342
- '90-archive',
343
357
  ]);
344
358
 
359
+ // Top-level directories whose notes are skipped by the CHECK set but remain in
360
+ // the link-target register (#833). Before this split, '90-archive' lived in
361
+ // EXCLUDED_DIRS, so archiving a note silently turned every inbound
362
+ // [[wiki-link]] into a dangling-link warning. The register and the check-set
363
+ // are now decoupled: walk() still visits these notes (so they resolve links),
364
+ // the validation loop skips them (so their frontmatter never blocks a close).
365
+ const CHECK_EXCLUDED_TOP_DIRS = new Set(['90-archive']);
366
+
367
+ /** True when relPath's FIRST path segment is a check-excluded top-level dir. */
368
+ function isArchived(relPath) {
369
+ const p = String(relPath).replace(/\\/g, '/');
370
+ const top = p.split('/', 1)[0];
371
+ return CHECK_EXCLUDED_TOP_DIRS.has(top);
372
+ }
373
+
345
374
  function emit(obj) {
346
375
  process.stdout.write(JSON.stringify(obj) + '\n');
347
376
  }
@@ -354,6 +383,7 @@ if (mode === 'off') {
354
383
  vault_dir: vaultDir,
355
384
  files_checked: 0,
356
385
  excluded_count: 0,
386
+ archived_skipped_count: 0,
357
387
  files_skipped_no_frontmatter: 0,
358
388
  errors: [],
359
389
  warnings: [],
@@ -415,14 +445,6 @@ function parseFrontmatter(raw) {
415
445
  }
416
446
  }
417
447
 
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);
424
- }
425
-
426
448
  // ── Wiki-link regex — captures link body for target parsing ─────────────────
427
449
  const WIKILINK_RE = /\[\[([^\]]+)\]\]/g;
428
450
 
@@ -450,9 +472,70 @@ function extractWikiLinks(content) {
450
472
  return [...targets];
451
473
  }
452
474
 
475
+ // ── Pass 1: read every file ONCE ────────────────────────────────────────────
476
+ // Builds the record set the link-target register (below) and the validation
477
+ // loop (further down) both consume. Net-neutral on I/O versus the previous
478
+ // shape, which read the file once but ran the frontmatter match twice.
479
+ // A malformed note must never abort this pass — every failure is recorded on
480
+ // the record and surfaced later by the validation loop.
481
+ const records = [];
482
+ for (const file of mdFiles) {
483
+ const rel = relative(vaultDir, file);
484
+ let raw;
485
+ try {
486
+ raw = readFileSync(file, 'utf8');
487
+ } catch (err) {
488
+ records.push({ file, rel, readError: err.message || String(err) });
489
+ continue;
490
+ }
491
+ let fm;
492
+ let links;
493
+ try {
494
+ fm = parseFrontmatter(raw);
495
+ const body = raw.slice(raw.match(FRONTMATTER_RE)?.[0].length || 0);
496
+ links = extractWikiLinks(body);
497
+ } catch (err) {
498
+ fm = { hasFrontmatter: true, parseError: err.message || String(err) };
499
+ links = [];
500
+ }
501
+ records.push({ file, rel, fm, links });
502
+ }
503
+
504
+ // ── Link-target register ────────────────────────────────────────────────────
505
+ // Keys: basename-without-ext, frontmatter `id`, and every frontmatter `aliases`
506
+ // entry (#833). Keys are normalized NFC + lowercase at BOTH insert and lookup:
507
+ // NFC is required, not cosmetic — APFS returns decomposed (NFD) filenames and
508
+ // this is a German-language corpus, so "Übung" from a filename and "Übung" from
509
+ // a YAML string would otherwise be different strings. Values are already
510
+ // arrays, so a case-collision (Topic.md + topic.md) merely appends.
511
+ const fileIndex = new Map(); // normalized key -> [absolute paths]
512
+
513
+ function indexKey(k) {
514
+ return String(k).normalize('NFC').toLowerCase();
515
+ }
516
+
517
+ function addIndexKey(key, file) {
518
+ if (typeof key !== 'string' || key.length === 0) return;
519
+ const k = indexKey(key);
520
+ if (!fileIndex.has(k)) fileIndex.set(k, []);
521
+ fileIndex.get(k).push(file);
522
+ }
523
+
524
+ for (const rec of records) {
525
+ addIndexKey(basename(rec.file, '.md'), rec.file);
526
+ const data = rec.fm && rec.fm.hasFrontmatter && !rec.fm.parseError ? rec.fm.data : null;
527
+ if (!data || typeof data !== 'object') continue;
528
+ // Type-guard: `id` must be a string, `aliases` an array of strings. A note
529
+ // with `aliases: some-scalar` contributes no alias keys instead of throwing.
530
+ if (typeof data.id === 'string') addIndexKey(data.id, rec.file);
531
+ if (Array.isArray(data.aliases)) {
532
+ for (const alias of data.aliases) addIndexKey(alias, rec.file);
533
+ }
534
+ }
535
+
453
536
  function resolveWikiLink(target, sourceFile) {
454
537
  // 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.
538
+ // Try exact path first (relative to vault), then register lookup.
456
539
  const candidate1 = resolve(vaultDir, target.endsWith('.md') ? target : target + '.md');
457
540
  if (existsSync(candidate1)) return true;
458
541
 
@@ -463,9 +546,8 @@ function resolveWikiLink(target, sourceFile) {
463
546
  );
464
547
  if (existsSync(candidate2)) return true;
465
548
 
466
- // Try basename lookup anywhere in index
467
- const key = basename(target, '.md');
468
- if (fileIndex.has(key)) return true;
549
+ // Try register lookup anywhere in the vault (basename / id / alias)
550
+ if (fileIndex.has(indexKey(basename(target, '.md')))) return true;
469
551
 
470
552
  return false;
471
553
  }
@@ -476,29 +558,31 @@ const warnings = [];
476
558
  let filesChecked = 0;
477
559
  let filesSkippedNoFrontmatter = 0;
478
560
  let excludedCount = 0;
561
+ let archivedSkippedCount = 0;
479
562
 
480
563
  const todayIso = new Date().toISOString().slice(0, 10);
481
564
 
482
- for (const file of mdFiles) {
483
- const rel = relative(vaultDir, file);
565
+ // ── Pass 2: validate ────────────────────────────────────────────────────────
566
+ for (const rec of records) {
567
+ const { file, rel, fm } = rec;
484
568
  if (isExcluded(rel)) {
485
569
  excludedCount++;
486
570
  continue;
487
571
  }
488
- let raw;
489
- try {
490
- raw = readFileSync(file, 'utf8');
491
- } catch (err) {
572
+ // Archived notes stay in the link-target register but are never CHECKED.
573
+ if (isArchived(rel)) {
574
+ archivedSkippedCount++;
575
+ continue;
576
+ }
577
+ if (rec.readError) {
492
578
  errors.push({
493
579
  file: rel,
494
580
  path: '',
495
- message: `Cannot read file: ${err.message || err}`,
581
+ message: `Cannot read file: ${rec.readError}`,
496
582
  });
497
583
  continue;
498
584
  }
499
585
 
500
- const fm = parseFrontmatter(raw);
501
-
502
586
  if (!fm.hasFrontmatter) {
503
587
  filesSkippedNoFrontmatter++;
504
588
  continue;
@@ -527,10 +611,8 @@ for (const file of mdFiles) {
527
611
  // Even if frontmatter is invalid, still check wiki-links to surface all problems.
528
612
  }
529
613
 
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) {
614
+ // Wiki-link check (links were extracted in pass 1)
615
+ for (const target of rec.links) {
534
616
  if (!resolveWikiLink(target, file)) {
535
617
  warnings.push({
536
618
  file: rel,
@@ -589,6 +671,7 @@ if (mode === 'diff') {
589
671
  vault_dir: vaultDir,
590
672
  files_checked: filesChecked,
591
673
  excluded_count: excludedCount,
674
+ archived_skipped_count: archivedSkippedCount,
592
675
  files_skipped_no_frontmatter: filesSkippedNoFrontmatter,
593
676
  errors,
594
677
  warnings,
@@ -608,6 +691,7 @@ if (mode === 'diff') {
608
691
  vault_dir: vaultDir,
609
692
  files_checked: filesChecked,
610
693
  excluded_count: excludedCount,
694
+ archived_skipped_count: archivedSkippedCount,
611
695
  files_skipped_no_frontmatter: filesSkippedNoFrontmatter,
612
696
  errors,
613
697
  warnings,
@@ -629,6 +713,7 @@ if (mode === 'diff') {
629
713
  vault_dir: vaultDir,
630
714
  files_checked: filesChecked,
631
715
  excluded_count: excludedCount,
716
+ archived_skipped_count: archivedSkippedCount,
632
717
  files_skipped_no_frontmatter: filesSkippedNoFrontmatter,
633
718
  });
634
719
 
@@ -640,7 +725,8 @@ if (mode === 'diff') {
640
725
  }
641
726
 
642
727
  // ── mode=full | hard | warn ───────────────────────────────────────────────
643
- // 'full' and 'hard' are identical (hard is the legacy alias).
728
+ // 'full' and 'hard' are identical (hard is the legacy alias; 'strict' was
729
+ // normalized to 'hard' at parse time — see normalizeMode, #835).
644
730
  // In warn mode, errors are reported but the status is "ok" for exit-code purposes.
645
731
  // The errors array is still populated so the caller can surface them as warnings.
646
732
  const status = hasErrors ? (mode === 'warn' ? 'ok' : 'invalid') : 'ok';
@@ -650,6 +736,7 @@ emit({
650
736
  vault_dir: vaultDir,
651
737
  files_checked: filesChecked,
652
738
  excluded_count: excludedCount,
739
+ archived_skipped_count: archivedSkippedCount,
653
740
  files_skipped_no_frontmatter: filesSkippedNoFrontmatter,
654
741
  errors,
655
742
  warnings,