session-orchestrator 3.20.0 → 3.21.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 (114) 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 +10 -8
  5. package/CHANGELOG.md +414 -0
  6. package/README.md +16 -11
  7. package/agents/analyst.md +1 -1
  8. package/agents/architect-reviewer.md +1 -1
  9. package/agents/code-implementer.md +4 -2
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/dialectic-deriver.md +1 -1
  12. package/agents/docs-writer.md +1 -1
  13. package/agents/memory-proposal-collector.md +1 -1
  14. package/agents/qa-strategist.md +1 -1
  15. package/agents/security-reviewer.md +1 -1
  16. package/agents/session-reviewer.md +42 -1
  17. package/agents/skill-applied-judge.md +1 -1
  18. package/agents/test-writer.md +1 -1
  19. package/agents/ui-developer.md +1 -1
  20. package/agents/ux-evaluator.md +1 -1
  21. package/commands/release.md +60 -0
  22. package/docs/migration-v3.md +9 -6
  23. package/docs/persona-panel.md +3 -1
  24. package/docs/scope-collision-guard.md +167 -0
  25. package/hooks/enforce-scope.mjs +103 -3
  26. package/hooks/hooks-codex.json +1 -1
  27. package/hooks/hooks.json +11 -1
  28. package/hooks/on-session-end.mjs +52 -5
  29. package/hooks/on-stop.mjs +127 -12
  30. package/hooks/post-bash-write-verify.mjs +8 -32
  31. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  32. package/package.json +1 -1
  33. package/pi/prompts/release.md +12 -0
  34. package/scripts/autopilot.mjs +3 -1
  35. package/scripts/emit-session.mjs +42 -0
  36. package/scripts/export-hw-learnings.mjs +61 -2
  37. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  38. package/scripts/lib/backlog-scan.mjs +106 -15
  39. package/scripts/lib/build-live-signals.mjs +7 -3
  40. package/scripts/lib/ci-status-banner.mjs +207 -23
  41. package/scripts/lib/git-config-drift.mjs +471 -0
  42. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  43. package/scripts/lib/io.mjs +193 -7
  44. package/scripts/lib/learnings/select.mjs +157 -3
  45. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  46. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  47. package/scripts/lib/named-vault-resolver.mjs +105 -16
  48. package/scripts/lib/peer-cards/schema.mjs +6 -2
  49. package/scripts/lib/reconcile/writer.mjs +69 -30
  50. package/scripts/lib/redact-spans.mjs +89 -0
  51. package/scripts/lib/scope-baseline.mjs +77 -17
  52. package/scripts/lib/scope-gate.mjs +658 -0
  53. package/scripts/lib/secret-masker.mjs +262 -0
  54. package/scripts/lib/session-lock.mjs +34 -10
  55. package/scripts/lib/session-registry.mjs +9 -1
  56. package/scripts/lib/state-md/mission-status.mjs +21 -12
  57. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  58. package/scripts/lib/validate/check-agents.mjs +77 -5
  59. package/scripts/lib/validate/check-commands.mjs +2 -20
  60. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  61. package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
  62. package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
  63. package/scripts/lib/validate/check-skills.mjs +191 -0
  64. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  65. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  66. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  67. package/scripts/lib/validate/check-unwired-features.mjs +219 -11
  68. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  69. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  70. package/scripts/lib/validate/tier-inference.mjs +46 -8
  71. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  72. package/scripts/lib/vault-mirror/process.mjs +264 -31
  73. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  74. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  75. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  76. package/scripts/lib/vcs-repo-spec.mjs +500 -19
  77. package/scripts/print-learnings-index.mjs +30 -3
  78. package/scripts/release.mjs +616 -61
  79. package/scripts/site-numbers.mjs +1049 -0
  80. package/scripts/validate-plugin.mjs +61 -0
  81. package/scripts/validate-wave-scope.mjs +281 -12
  82. package/scripts/vault-mirror.mjs +26 -1
  83. package/skills/_shared/monitor-patterns.md +24 -4
  84. package/skills/_shared/state-ownership.md +17 -0
  85. package/skills/claude-md-drift-check/SKILL.md +9 -1
  86. package/skills/debug/SKILL.md +4 -1
  87. package/skills/discovery/issue-templates.md +4 -4
  88. package/skills/discovery/probes-code.md +2 -2
  89. package/skills/discovery/probes-feature.md +6 -6
  90. package/skills/discovery/probes-infra.md +2 -2
  91. package/skills/discovery/probes-session.md +5 -5
  92. package/skills/dispatcher/SKILL.md +10 -1
  93. package/skills/frontmatter-guard/SKILL.md +9 -1
  94. package/skills/gitlab-ops/SKILL.md +49 -39
  95. package/skills/gitlab-portfolio/SKILL.md +10 -1
  96. package/skills/memory-cleanup/SKILL.md +18 -5
  97. package/skills/npm-publish/SKILL.md +22 -50
  98. package/skills/persona-panel/SKILL.md +3 -1
  99. package/skills/plan/mode-new.md +23 -5
  100. package/skills/repo-audit/SKILL.md +10 -1
  101. package/skills/session-end/SKILL.md +44 -2
  102. package/skills/session-end/metrics-collection.md +1 -1
  103. package/skills/session-end/session-metrics-write.md +4 -10
  104. package/skills/session-plan/SKILL.md +2 -2
  105. package/skills/session-plan/wave-template.md +1 -1
  106. package/skills/session-start/SKILL.md +13 -1
  107. package/skills/spinout/SKILL.md +5 -1
  108. package/skills/sunset-review/SKILL.md +11 -1
  109. package/skills/tmux-layout/SKILL.md +7 -2
  110. package/skills/vault-mirror/SKILL.md +10 -1
  111. package/skills/vault-sync/SKILL.md +10 -1
  112. package/skills/vault-sync/validator.mjs +55 -6
  113. package/skills/wave-executor/wave-loop.md +42 -12
  114. package/scripts/lib/mission-status-schema.mjs +0 -114
@@ -41,6 +41,19 @@
41
41
  * for values that bypass the source strip (e.g. a `--repo` override). See
42
42
  * `stripUrlCredentials` / `userinfoIsCredential`.
43
43
  *
44
+ * Remote-resolution core (#1039): since the `-R` spec is only ONE of several
45
+ * questions a caller asks about a repo's remotes, the file now carries a shared
46
+ * core below the frozen `-R`/`--hostname` exports — one primitive
47
+ * ({@link listRemotes}, a single `git remote -v` spawn) and three projections
48
+ * ({@link resolvePreferredRemote}, {@link detectVcsFamily},
49
+ * {@link resolveBaselineRange}). Every one of them returns a DISCRIMINATED
50
+ * result carrying a {@link REMOTE_RESOLUTION_REASONS} reason instead of a
51
+ * `T | null`, because `null` folds "no remote configured" onto "the query
52
+ * failed" — a fold that currently scores a fail-open 2/2 in
53
+ * `harness-audit/categories/category6.mjs`. `resolveRepoSpec` /
54
+ * `resolveRepoHost` keep their `string|undefined` contract verbatim and are now
55
+ * thin projections of that core.
56
+ *
44
57
  * Lifted out of `scripts/archive-closed-prds.mjs::defaultGlabRepo` (that
45
58
  * script's docblock described this exact problem months before #839 was
46
59
  * filed) into a shared `scripts/lib/` module so
@@ -158,12 +171,23 @@ export function redactUrlCredentials(text) {
158
171
  }
159
172
 
160
173
  /**
161
- * Default git-remote runner: `git -C <repoRoot> remote get-url <name>`.
162
- * Never throws — returns `{ ok:false, stdout:'', stderr }` on any failure
163
- * (missing remote, not a git repo, git not on PATH, ...).
174
+ * Default git runner: `git <gitArgs>`.
175
+ * Never throws — returns `{ ok:false, stdout:'', stderr, status, code }` on any
176
+ * failure (missing remote, not a git repo, git not on PATH, ...).
177
+ *
178
+ * `status` (process exit code) and `code` (spawn errno, e.g. `'ENOENT'`) were
179
+ * added for {@link listRemotes}'s failure taxonomy: git's own exit codes are
180
+ * the ONLY signal that distinguishes "this is not a git repository" (128) from
181
+ * "git is not installed" (spawn ENOENT) from "there are simply no remotes"
182
+ * (exit 0, empty stdout). Folding those three onto one falsy value is the
183
+ * defect class #1039 was filed against — see {@link REMOTE_RESOLUTION_REASONS}.
184
+ *
185
+ * Both fields are OPTIONAL in the `gitRun` DI contract: an injected test stub
186
+ * that returns only `{ ok, stdout, stderr }` still works, and its failures
187
+ * classify as the generic `'git-error'`.
164
188
  *
165
189
  * @param {string[]} gitArgs
166
- * @returns {{ ok: boolean, stdout: string, stderr: string }}
190
+ * @returns {GitRunResult}
167
191
  */
168
192
  function defaultGitRun(gitArgs) {
169
193
  try {
@@ -171,11 +195,17 @@ function defaultGitRun(gitArgs) {
171
195
  encoding: 'utf8',
172
196
  stdio: ['ignore', 'pipe', 'pipe'],
173
197
  });
174
- return { ok: true, stdout: String(stdout ?? ''), stderr: '' };
198
+ return { ok: true, stdout: String(stdout ?? ''), stderr: '', status: 0 };
175
199
  } catch (err) {
176
200
  const stderr =
177
201
  err && err.stderr ? String(err.stderr) : err && err.message ? String(err.message) : 'unknown error';
178
- return { ok: false, stdout: '', stderr };
202
+ return {
203
+ ok: false,
204
+ stdout: '',
205
+ stderr,
206
+ status: err && typeof err.status === 'number' ? err.status : undefined,
207
+ code: err && typeof err.code === 'string' ? err.code : undefined,
208
+ };
179
209
  }
180
210
  }
181
211
 
@@ -261,26 +291,29 @@ function normalizeGithubSpec(url) {
261
291
  * see {@link stripUrlCredentials}. A credential-free URL is unchanged
262
292
  * (byte-identical), so #839/#872 behaviour is preserved.
263
293
  *
294
+ * Since #1039 this is a THIN projection of {@link resolvePreferredRemote} and
295
+ * performs no git call of its own. Two reasons the delegation is load-bearing:
296
+ *
297
+ * 1. **One credential-strip source (#907, CWE-214).** The userinfo strip now
298
+ * lives in {@link listRemotes}, at the single point every remote URL in
299
+ * this module enters from. A second code path *around* that source would
300
+ * re-open the leak — which is exactly why this function must not read a
301
+ * remote URL itself.
302
+ * 2. **One git call instead of N.** The former implementation ran one
303
+ * `git remote get-url <name>` spawn PER preference entry, on the
304
+ * session-start hot path. `listRemotes` runs `git remote -v` exactly once.
305
+ *
264
306
  * @param {{
265
307
  * repoRoot?: string,
266
308
  * vcs?: 'gitlab' | 'github',
267
- * gitRun?: (args: string[]) => { ok: boolean, stdout: string, stderr: string }
309
+ * gitRun?: GitRun
268
310
  * }} [opts]
269
311
  * @returns {string|undefined}
270
312
  */
271
313
  function resolveRawRemoteUrl({ repoRoot, vcs = 'gitlab', gitRun = defaultGitRun } = {}) {
272
314
  const vcsResolved = vcs === 'github' ? 'github' : 'gitlab';
273
- const root = repoRoot ?? process.cwd();
274
- const wrongFamilyHost = WRONG_FAMILY_HOST[vcsResolved];
275
-
276
- for (const remote of REMOTE_PREFERENCE[vcsResolved]) {
277
- const { ok, stdout } = gitRun(['-C', root, 'remote', 'get-url', remote]);
278
- const url = ok ? stripUrlCredentials(stdout.trim()) : '';
279
- if (!url) continue;
280
- if (extractHost(url) === wrongFamilyHost) continue;
281
- return url;
282
- }
283
- return undefined;
315
+ const resolved = resolvePreferredRemote({ repoRoot, vcs: vcsResolved, gitRun });
316
+ return resolved.ok ? resolved.url : undefined;
284
317
  }
285
318
 
286
319
  /**
@@ -306,10 +339,22 @@ function resolveRawRemoteUrl({ repoRoot, vcs = 'gitlab', gitRun = defaultGitRun
306
339
  * argv-boundary guard ({@link isUnsafeForArgv}), which this function applies
307
340
  * to the FINAL spec value (post `normalizeGithubSpec`, when applicable).
308
341
  *
342
+ * **CHANGELOG-worthy behaviour change (#1039, operator-approved.)** This
343
+ * function inherits the SOLE-REMOTE FALLBACK from the shared core (see
344
+ * {@link resolvePreferredRemote}): a repo whose only remote is named something
345
+ * else (`upstream` in a fork, `gl` in a hand-configured clone) now resolves to
346
+ * that remote instead of returning `undefined`. The fallback fires EXCLUSIVELY
347
+ * where `undefined` was returned before — it can never redirect an
348
+ * already-resolving repo to a DIFFERENT target, because the preference order
349
+ * ({@link REMOTE_PREFERENCE}) is still consulted first and is byte-identical to
350
+ * the pre-#1039 list. The cross-family guard applies to the fallback candidate
351
+ * too, so a lone `github.com` remote under `vcs:'gitlab'` still yields
352
+ * `undefined` rather than a spec `glab` is guaranteed to reject.
353
+ *
309
354
  * @param {{
310
355
  * repoRoot?: string,
311
356
  * vcs?: 'gitlab' | 'github',
312
- * gitRun?: (args: string[]) => { ok: boolean, stdout: string, stderr: string }
357
+ * gitRun?: GitRun
313
358
  * }} [opts]
314
359
  * @returns {string|undefined}
315
360
  */
@@ -360,3 +405,439 @@ export function resolveRepoHost({ repoRoot, vcs, gitRun } = {}) {
360
405
  export function defaultGlabRepo(repoRoot, gitRunFn) {
361
406
  return resolveRepoSpec({ repoRoot, vcs: 'gitlab', gitRun: gitRunFn });
362
407
  }
408
+
409
+ /* ------------------------------------------------------------------------ *
410
+ * #1039 — remote resolution core: one primitive, three projections.
411
+ *
412
+ * Four probes across the repo resolved their git remote as the hard-coded
413
+ * literal `origin` and were therefore BLIND in every repo whose remotes are
414
+ * named `gitlab`/`github` (this repo's own shape: `github` + `origin`). The
415
+ * shared core below replaces that literal.
416
+ *
417
+ * The contract's load-bearing decision is that a resolution result is NEVER
418
+ * `T | null`. `null` folds "no remote is configured" (a legitimate, benign
419
+ * repo state) onto "the query failed" (a broken tool or a non-repo), and that
420
+ * fold is LIVE in this codebase: `scripts/lib/harness-audit/categories/
421
+ * category6.mjs:145-155` awards 2 of 2 points with the message "no github
422
+ * mirror remote configured — skipped" when its `git remote` call returns
423
+ * `null` — which it also does outside a git repo, and when git is not on PATH.
424
+ * A fail-open scoring 100%.
425
+ *
426
+ * So every projection returns a DISCRIMINATED result carrying a reason from
427
+ * {@link REMOTE_RESOLUTION_REASONS}, and {@link isQueryFailure} is the single
428
+ * predicate that separates "I could not ask" from "I asked, the answer is no".
429
+ * ------------------------------------------------------------------------ */
430
+
431
+ /**
432
+ * Every reason a remote resolution can fail, frozen so consumers can switch on
433
+ * it exhaustively. The list splits into two classes — the split, not the
434
+ * individual strings, is the point:
435
+ *
436
+ * QUERY FAILURE (the question could not be asked; see {@link isQueryFailure})
437
+ * - `not-a-git-repo` git exited 128 — the path is not inside a work tree.
438
+ * - `git-unavailable` the spawn failed with ENOENT — git is not on PATH.
439
+ * - `git-error` any other non-zero exit, or an injected `gitRun`
440
+ * stub that reported failure without an exit code.
441
+ *
442
+ * ABSENCE (the question was answered; the answer is "no remote for you")
443
+ * - `no-remotes` git exited 0 with an empty remote list — a fresh
444
+ * `git init`, or a clone-less work tree. BENIGN.
445
+ * - `no-matching-remote` >= 2 remotes exist and none matches the requested
446
+ * preference order. Deliberately NOT a guess: picking
447
+ * arbitrarily here means querying the WRONG project
448
+ * successfully, which is worse than not querying.
449
+ * - `unsafe-value` the chosen remote's name or URL carries whitespace /
450
+ * a C0 control character and must not reach an argv
451
+ * position ({@link isUnsafeForArgv}).
452
+ *
453
+ * @type {readonly RemoteResolutionReason[]}
454
+ */
455
+ export const REMOTE_RESOLUTION_REASONS = Object.freeze([
456
+ 'not-a-git-repo',
457
+ 'git-unavailable',
458
+ 'git-error',
459
+ 'no-remotes',
460
+ 'no-matching-remote',
461
+ 'unsafe-value',
462
+ ]);
463
+
464
+ /** @type {ReadonlySet<string>} */
465
+ const QUERY_FAILURE_REASONS = new Set(['not-a-git-repo', 'git-unavailable', 'git-error']);
466
+
467
+ /**
468
+ * `true` when `reason` means the question could not be ASKED (broken tool, no
469
+ * repo), `false` when it means the question was answered in the negative (no
470
+ * remote configured, no match, unsafe value).
471
+ *
472
+ * Consumers MUST branch on this rather than on truthiness: a query failure is a
473
+ * degraded measurement and should be surfaced (WARN / skip-with-reason), while
474
+ * an absence is a real, reportable repo state. Treating them alike is the
475
+ * category6.mjs fail-open documented above.
476
+ *
477
+ * An unknown / absent reason returns `false` — fail-safe toward "this is a real
478
+ * answer", so a future reason added to {@link REMOTE_RESOLUTION_REASONS}
479
+ * without updating this predicate never silently masks a genuine finding as a
480
+ * tooling glitch.
481
+ *
482
+ * @param {RemoteResolutionReason|string|undefined} reason
483
+ * @returns {boolean}
484
+ */
485
+ export function isQueryFailure(reason) {
486
+ return typeof reason === 'string' && QUERY_FAILURE_REASONS.has(reason);
487
+ }
488
+
489
+ /**
490
+ * Classify a failed {@link GitRunResult} into a query-failure reason.
491
+ *
492
+ * Named ceiling (BV-004): exit 128 is mapped to `not-a-git-repo` because that
493
+ * is what `git remote -v` returns for "not a git repository", and this module
494
+ * only ever runs read-only remote/ref plumbing where 128 has no other common
495
+ * cause. It is NOT a general git-exit-code taxonomy — revisit if a caller
496
+ * starts routing write commands (`git push`, `git fetch`) through `gitRun`,
497
+ * where 128 also covers auth and network fatals.
498
+ *
499
+ * @param {GitRunResult} res
500
+ * @returns {RemoteResolutionReason}
501
+ */
502
+ function classifyGitFailure(res) {
503
+ if (res && res.code === 'ENOENT') return 'git-unavailable';
504
+ if (res && res.status === 128) return 'not-a-git-repo';
505
+ return 'git-error';
506
+ }
507
+
508
+ /**
509
+ * One `git remote -v` output line: `<name>\t<url> (fetch|push)`.
510
+ *
511
+ * `(.*?)` is lazy with an anchored tail, so a URL containing a space (a
512
+ * corrupted `.git/config`, the argv-boundary guard's realistic source) is
513
+ * captured whole rather than truncated at the space. A line that does not match
514
+ * this shape at all is DROPPED — see {@link listRemotes}.
515
+ */
516
+ const REMOTE_V_LINE_RE = /^(\S+)\s+(.*?)\s+\((fetch|push)\)$/;
517
+
518
+ /**
519
+ * THE PRIMITIVE. Enumerate the repo's git remotes in ONE `git remote -v` spawn.
520
+ *
521
+ * Contract:
522
+ * - `{ ok: true, remotes: [{ name, url }] }` — the list, in git's own output
523
+ * order (alphabetical by remote name). **`remotes: []` is a VALID `ok:true`
524
+ * result** and means "this repo has no remotes", never "the query failed".
525
+ * Conflating the two is the defect this whole module section exists for.
526
+ * - `{ ok: false, reason, stderr }` — the query itself failed; `reason`
527
+ * always satisfies {@link isQueryFailure}.
528
+ *
529
+ * Only FETCH URLs are reported, one entry per remote name (first fetch line
530
+ * wins). Push URLs are a separate `remote.<name>.pushurl` concept that no
531
+ * `-R`/`--repo`/baseline-range consumer in this repo wants.
532
+ *
533
+ * Credential safety (#907, CWE-214): every URL passes through
534
+ * {@link stripUrlCredentials} HERE, at the single point remote URLs enter this
535
+ * module. Every other function in the file — including `resolveRepoSpec` and
536
+ * `resolveRepoHost` — reads its URLs from this function's output, so there is
537
+ * exactly ONE strip source and no path around it.
538
+ *
539
+ * Unparseable lines are dropped silently rather than failing the call: git
540
+ * cannot emit them, so their only source is a corrupted config or an embedded
541
+ * newline, and in both cases the remaining well-formed remotes are still the
542
+ * best available answer. A repo whose EVERY line is unparseable therefore
543
+ * reports `no-remotes` (absence), which is correct — nothing usable was found,
544
+ * and git did answer.
545
+ *
546
+ * @param {{ repoRoot?: string, gitRun?: GitRun }} [opts]
547
+ * @returns {{ ok: true, remotes: GitRemote[] }
548
+ * | { ok: false, reason: RemoteResolutionReason, stderr: string }}
549
+ */
550
+ export function listRemotes({ repoRoot, gitRun = defaultGitRun } = {}) {
551
+ const root = repoRoot ?? process.cwd();
552
+ const res = gitRun(['-C', root, 'remote', '-v']) ?? { ok: false, stdout: '', stderr: '' };
553
+
554
+ if (!res.ok) {
555
+ return { ok: false, reason: classifyGitFailure(res), stderr: String(res.stderr ?? '') };
556
+ }
557
+
558
+ /** @type {GitRemote[]} */
559
+ const remotes = [];
560
+ const seen = new Set();
561
+
562
+ for (const rawLine of String(res.stdout ?? '').split('\n')) {
563
+ const line = rawLine.replace(/\r$/, '');
564
+ const match = REMOTE_V_LINE_RE.exec(line);
565
+ if (match === null) continue;
566
+ const [, name, rawUrl, direction] = match;
567
+ if (direction !== 'fetch') continue;
568
+ if (seen.has(name)) continue;
569
+ const url = stripUrlCredentials(rawUrl).trim();
570
+ if (url === '') continue;
571
+ seen.add(name);
572
+ remotes.push({ name, url });
573
+ }
574
+
575
+ return { ok: true, remotes };
576
+ }
577
+
578
+ /**
579
+ * Remote-name preference order when NO `vcs` is supplied.
580
+ *
581
+ * **Operator decision (#1039) — deliberately DIFFERENT from
582
+ * {@link REMOTE_PREFERENCE}, do not "fix" the divergence.** The vcs-pinned
583
+ * lists put the platform-named remote first because the caller has already
584
+ * declared which platform it is talking to. The vcs-less order puts `origin`
585
+ * first because its callers (baseline ranges, family detection, vault-note
586
+ * namespacing) derive an IDENTITY from the answer, and identity must not move.
587
+ *
588
+ * Concretely, in THIS repo (`origin` → `…/infrastructure/session-orchestrator`,
589
+ * `github` → `…/Kanevry/session-orchestrator`), a `gitlab`-first order would
590
+ * re-namespace every existing vault note from `infrastructure/…` to
591
+ * `Kanevry/…`. Silent mass-rename of historical notes is not an improvement.
592
+ */
593
+ const VCS_LESS_PREFERENCE = Object.freeze(['origin', 'gitlab', 'github']);
594
+
595
+ /**
596
+ * Pick the one remote a repo-scoped command should target.
597
+ *
598
+ * Resolution, in order:
599
+ * 1. **Preference.** With `vcs` set: `REMOTE_PREFERENCE[vcs]`
600
+ * (`['<vcs>', 'origin']`, byte-identical to the pre-#1039 list, because
601
+ * 13 production importers of `resolveRepoSpec` inherit their `-R` target
602
+ * from it — a reordering would silently switch every one of them in a repo
603
+ * that has both remotes). With `vcs` omitted: {@link VCS_LESS_PREFERENCE}.
604
+ * 2. **Cross-family guard** (only when `vcs` is set): a candidate whose host
605
+ * is the OTHER platform's well-known public host is SKIPPED and resolution
606
+ * continues with the next preference entry. Passing `github.com` to
607
+ * `glab -R` is a guaranteed hard failure — strictly worse than resolving
608
+ * nothing. Unchanged from #839.
609
+ * 3. **Sole-remote fallback.** Exactly one remote configured and no
610
+ * preference hit → use it, `via:'sole-remote'`. This is what makes a fork
611
+ * (`upstream`) or a hand-named clone (`gl`) resolvable at all. The
612
+ * cross-family guard still applies to this candidate.
613
+ * 4. Otherwise `{ ok:false }` with `no-remotes` (nothing configured) or
614
+ * `no-matching-remote` (>= 2 remotes, none matched). With two or more
615
+ * candidates and no preference signal there is no non-arbitrary pick, and
616
+ * guessing means successfully querying the WRONG project — the failure
617
+ * mode is a silent wrong answer, not an error. The full `remotes` list
618
+ * rides along so the caller can surface the ambiguity to the operator.
619
+ *
620
+ * The argv-boundary guard runs AFTER the candidate is chosen, and an unsafe
621
+ * candidate ENDS resolution with `unsafe-value` rather than falling through to
622
+ * the next preference entry — preserving pre-#1039 behaviour, where an unsafe
623
+ * value likewise produced `undefined` and no retry.
624
+ *
625
+ * @param {{ repoRoot?: string, vcs?: 'gitlab'|'github', gitRun?: GitRun }} [opts]
626
+ * @returns {{ ok: true, name: string, url: string, via: 'preference'|'sole-remote' }
627
+ * | { ok: false, reason: RemoteResolutionReason, remotes?: GitRemote[], stderr?: string }}
628
+ */
629
+ export function resolvePreferredRemote({ repoRoot, vcs, gitRun = defaultGitRun } = {}) {
630
+ const listed = listRemotes({ repoRoot, gitRun });
631
+ if (!listed.ok) return { ok: false, reason: listed.reason, stderr: listed.stderr };
632
+
633
+ const { remotes } = listed;
634
+ if (remotes.length === 0) return { ok: false, reason: 'no-remotes', remotes };
635
+
636
+ const vcsPinned = vcs === 'github' || vcs === 'gitlab' ? vcs : null;
637
+ const order = vcsPinned ? REMOTE_PREFERENCE[vcsPinned] : VCS_LESS_PREFERENCE;
638
+ const wrongFamilyHost = vcsPinned ? WRONG_FAMILY_HOST[vcsPinned] : null;
639
+ const isWrongFamily = (url) => wrongFamilyHost !== null && extractHost(url) === wrongFamilyHost;
640
+
641
+ /** @param {GitRemote} remote @param {'preference'|'sole-remote'} via */
642
+ const accept = (remote, via) =>
643
+ isUnsafeForArgv(remote.url) || isUnsafeForArgv(remote.name)
644
+ ? { ok: false, reason: /** @type {RemoteResolutionReason} */ ('unsafe-value'), remotes }
645
+ : { ok: true, name: remote.name, url: remote.url, via };
646
+
647
+ for (const name of order) {
648
+ const candidate = remotes.find((remote) => remote.name === name);
649
+ if (candidate === undefined) continue;
650
+ if (isWrongFamily(candidate.url)) continue;
651
+ return accept(candidate, 'preference');
652
+ }
653
+
654
+ if (remotes.length === 1 && !isWrongFamily(remotes[0].url)) {
655
+ return accept(remotes[0], 'sole-remote');
656
+ }
657
+
658
+ return { ok: false, reason: 'no-matching-remote', remotes };
659
+ }
660
+
661
+ /**
662
+ * Classify a single remote into a VCS family from its URL host, then its name.
663
+ *
664
+ * Host rule: `github.com` or any `github.*` host → github; `gitlab.com` or any
665
+ * `gitlab.*` host → gitlab. The `github.*` half is what keeps GitHub Enterprise
666
+ * (`github.example.com`) out of the gitlab bucket — a `url.includes('github.com')`
667
+ * test classifies it as gitlab and points `glab` at a GitHub instance.
668
+ *
669
+ * @param {GitRemote} remote
670
+ * @returns {{ family: 'gitlab'|'github', via: 'host-match'|'remote-name' }|null}
671
+ */
672
+ function classifyRemoteFamily(remote) {
673
+ const host = extractHost(remote.url);
674
+ if (host !== null) {
675
+ if (host === 'github.com' || host.startsWith('github.')) return { family: 'github', via: 'host-match' };
676
+ if (host === 'gitlab.com' || host.startsWith('gitlab.')) return { family: 'gitlab', via: 'host-match' };
677
+ }
678
+ if (remote.name === 'github') return { family: 'github', via: 'remote-name' };
679
+ if (remote.name === 'gitlab') return { family: 'gitlab', via: 'remote-name' };
680
+ return null;
681
+ }
682
+
683
+ /**
684
+ * Decide which VCS family a repo belongs to, from its remotes — the projection
685
+ * that replaces "assume gitlab because the config says so".
686
+ *
687
+ * Precedence per remote: URL host, then remote name (see
688
+ * {@link classifyRemoteFamily}). Among classified remotes the representative is
689
+ * picked by {@link VCS_LESS_PREFERENCE}, then by git's own listing order — so
690
+ * in this repo (`github` → github.com, `origin` → gitlab.…) the answer is
691
+ * `gitlab` via `origin`, not `github` via the alphabetically-first remote.
692
+ *
693
+ * When NO remote classifies, `via:'default'` + `vcs:'gitlab'` preserves today's
694
+ * behaviour (every `resolveRepoSpec` caller already defaults to gitlab) —
695
+ * provided a representative remote can be named at all. When it cannot (>= 2
696
+ * unclassifiable remotes), the call fails with `no-matching-remote` rather than
697
+ * inventing one: naming the wrong remote is what #1039 is about.
698
+ *
699
+ * `ambiguous` is `true` when two or more remotes classify into DIFFERENT
700
+ * families — the ordinary GitLab-primary / GitHub-mirror shape. It is a signal
701
+ * for the caller to disclose the choice, not an error: `vcs` is still the
702
+ * preference-ordered answer, and `alternatives` names the remotes that would
703
+ * have said otherwise.
704
+ *
705
+ * @param {{ repoRoot?: string, gitRun?: GitRun }} [opts]
706
+ * @returns {{ ok: true, vcs: 'gitlab'|'github', name: string, url: string,
707
+ * via: 'host-match'|'remote-name'|'default', ambiguous: boolean,
708
+ * alternatives: string[] }
709
+ * | { ok: false, reason: RemoteResolutionReason, remotes?: GitRemote[], stderr?: string }}
710
+ */
711
+ export function detectVcsFamily({ repoRoot, gitRun = defaultGitRun } = {}) {
712
+ const listed = listRemotes({ repoRoot, gitRun });
713
+ if (!listed.ok) return { ok: false, reason: listed.reason, stderr: listed.stderr };
714
+
715
+ const { remotes } = listed;
716
+ if (remotes.length === 0) return { ok: false, reason: 'no-remotes', remotes };
717
+
718
+ const classified = remotes
719
+ .map((remote) => ({ remote, verdict: classifyRemoteFamily(remote) }))
720
+ .filter((entry) => entry.verdict !== null);
721
+
722
+ if (classified.length === 0) {
723
+ // No family signal anywhere. Fall back to today's implicit default
724
+ // (gitlab), but only if a representative remote can be NAMED — the
725
+ // preferred-remote resolution below refuses to guess among >= 2.
726
+ const preferred = resolvePreferredRemote({ repoRoot, gitRun });
727
+ if (!preferred.ok) return preferred;
728
+ return {
729
+ ok: true,
730
+ vcs: 'gitlab',
731
+ name: preferred.name,
732
+ url: preferred.url,
733
+ via: 'default',
734
+ ambiguous: false,
735
+ alternatives: [],
736
+ };
737
+ }
738
+
739
+ const chosen =
740
+ VCS_LESS_PREFERENCE.map((name) => classified.find((entry) => entry.remote.name === name)).find(
741
+ (entry) => entry !== undefined,
742
+ ) ?? classified[0];
743
+
744
+ const alternatives = classified
745
+ .filter((entry) => entry.verdict.family !== chosen.verdict.family)
746
+ .map((entry) => entry.remote.name);
747
+
748
+ return {
749
+ ok: true,
750
+ vcs: chosen.verdict.family,
751
+ name: chosen.remote.name,
752
+ url: chosen.remote.url,
753
+ via: chosen.verdict.via,
754
+ ambiguous: alternatives.length > 0,
755
+ alternatives,
756
+ };
757
+ }
758
+
759
+ /**
760
+ * Resolve the three-dot diff range a session-drift / scope measurement should
761
+ * run against — the projection that replaces the hard-coded literal
762
+ * `'origin/main...HEAD'` (live at `scripts/lib/scope-baseline.mjs:519`, which is
763
+ * silently inert in any repo whose remote is not named `origin` or whose default
764
+ * branch is not `main`).
765
+ *
766
+ * Chain, first hit wins:
767
+ * 1. {@link resolvePreferredRemote} (vcs-less) → the remote `R`.
768
+ * 2. `git symbolic-ref --short refs/remotes/<R>/HEAD` → `via:'remote-head'`.
769
+ * Only populated by an explicit `git remote set-head -a`, so it is the
770
+ * most authoritative and the least often present.
771
+ * 3. `git rev-parse --verify --quiet refs/remotes/<R>/main`, then `…/master`
772
+ * → `via:'remote-default-branch'`. Covers the freshly-pushed repo where
773
+ * nobody ever ran `set-head`.
774
+ * 4. `refs/heads/main`, then `refs/heads/master` → `via:'local-default-branch'`,
775
+ * gated on `allowLocalFallback` (default `true`). A local branch is a
776
+ * weaker baseline than a tracking ref — it does not know what the remote
777
+ * has — so a caller that needs a remote-anchored measurement passes
778
+ * `allowLocalFallback:false` and gets `no-tracking-ref` instead.
779
+ * 5. `{ ok:false, reason:'no-tracking-ref' }`, or `'unborn-head'` when the
780
+ * repo has no commit at all (probed only on this path, so the happy path
781
+ * costs nothing).
782
+ *
783
+ * **No root-commit fallback, by operator decision.** Diffing against the first
784
+ * commit of the repository yields a ratio over the ENTIRE history, which is not
785
+ * a session-drift measurement — it is a number that looks like one. An honest
786
+ * `no-tracking-ref` lets the caller skip with a reason.
787
+ *
788
+ * The range is always three-dot (`<base>...HEAD`, merge-base relative),
789
+ * identical to the semantics of the literal it replaces.
790
+ *
791
+ * @param {{ repoRoot?: string, gitRun?: GitRun, allowLocalFallback?: boolean }} [opts]
792
+ * @returns {{ ok: true, range: string, base: string, remote: string,
793
+ * via: 'remote-head'|'remote-default-branch'|'local-default-branch' }
794
+ * | { ok: false, reason: RemoteResolutionReason|'no-tracking-ref'|'unborn-head',
795
+ * remotes?: GitRemote[], stderr?: string }}
796
+ */
797
+ export function resolveBaselineRange({ repoRoot, gitRun = defaultGitRun, allowLocalFallback = true } = {}) {
798
+ const preferred = resolvePreferredRemote({ repoRoot, gitRun });
799
+ if (!preferred.ok) return preferred;
800
+
801
+ const root = repoRoot ?? process.cwd();
802
+ const remote = preferred.name;
803
+ const run = (args) => gitRun(['-C', root, ...args]) ?? { ok: false, stdout: '', stderr: '' };
804
+ const done = (base, via) => ({ ok: /** @type {true} */ (true), range: `${base}...HEAD`, base, remote, via });
805
+
806
+ const head = run(['symbolic-ref', '--short', `refs/remotes/${remote}/HEAD`]);
807
+ const headRef = head.ok ? head.stdout.trim() : '';
808
+ if (headRef !== '' && !isUnsafeForArgv(headRef)) return done(headRef, 'remote-head');
809
+
810
+ for (const branch of ['main', 'master']) {
811
+ const verified = run(['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${branch}`]);
812
+ if (verified.ok && verified.stdout.trim() !== '') return done(`${remote}/${branch}`, 'remote-default-branch');
813
+ }
814
+
815
+ if (allowLocalFallback) {
816
+ for (const branch of ['main', 'master']) {
817
+ const verified = run(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
818
+ if (verified.ok && verified.stdout.trim() !== '') return done(branch, 'local-default-branch');
819
+ }
820
+ }
821
+
822
+ const headCommit = run(['rev-parse', '--verify', '--quiet', 'HEAD']);
823
+ const unborn = !headCommit.ok || headCommit.stdout.trim() === '';
824
+ return { ok: false, reason: unborn ? 'unborn-head' : 'no-tracking-ref' };
825
+ }
826
+
827
+ /**
828
+ * @typedef {'not-a-git-repo'|'git-unavailable'|'git-error'|'no-remotes'|'no-matching-remote'|'unsafe-value'} RemoteResolutionReason
829
+ */
830
+
831
+ /**
832
+ * @typedef {{ name: string, url: string }} GitRemote
833
+ */
834
+
835
+ /**
836
+ * @typedef {{ ok: boolean, stdout: string, stderr: string, status?: number, code?: string }} GitRunResult
837
+ */
838
+
839
+ /**
840
+ * Injectable git runner. `status`/`code` are OPTIONAL — a stub that omits them
841
+ * still works; its failures classify as the generic `git-error`.
842
+ * @typedef {(args: string[]) => GitRunResult} GitRun
843
+ */
@@ -23,6 +23,13 @@
23
23
  * block therefore rides the dispatch-prompt channel the repo already owns and
24
24
  * writes itself — it adds no new delivery mechanism.
25
25
  *
26
+ * With ONE exception, closed in #1019: a learning that `/reconcile` has already
27
+ * turned into a `.claude/rules/*.md` file DOES have a native path, and shipping
28
+ * it here too is the same duplication in miniature. `--rules-dir` (default
29
+ * `.claude/rules`) feeds that set to the selector, which drops those records
30
+ * before its Top-N cut so the freed slot goes to a learning the agent has no
31
+ * other way to see. A repo with no rules directory is unaffected, byte for byte.
32
+ *
26
33
  * ## An INDEX, not a corpus
27
34
  *
28
35
  * One line per learning plus a retrieval pointer. The agent that needs the full
@@ -65,7 +72,8 @@
65
72
  * `<learnings-<token>>` … `</learnings-<token>>` fence. Empty
66
73
  * selection → NO output at all (exit 0) so the caller prepends
67
74
  * nothing.
68
- * - --json → `{ count, scopeMatched, rejected, learnings: [...] }`
75
+ * - --json → `{ count, scopeMatched, rejected, deliveredFiltered,
76
+ * learnings: [...] }`
69
77
  *
70
78
  * Exit codes (per .claude/rules/cli-design.md):
71
79
  * 0 — success, INCLUDING EPIPE (a truncating reader — `| head`, `| grep -q` —
@@ -144,6 +152,10 @@ Options:
144
152
  --max-chars <n> Hard cap on the rendered index body (default: ${LEARNINGS_INDEX_MAX_CHARS}).
145
153
  --pool-size <n> Active entries pulled before ranking (default: ${CANDIDATE_POOL_SIZE}).
146
154
  --learnings <path> Learnings JSONL (default: .orchestrator/metrics/learnings.jsonl).
155
+ --rules-dir <path> Natively-delivered rule corpus (default: .claude/rules).
156
+ Learnings already delivered as a rule file there are
157
+ excluded from the index (#1019). A path that does not
158
+ exist means "this repo delivers no rules" -> no filtering.
147
159
  --no-event Suppress the orchestrator.learnings.index.injected event.
148
160
  --json Emit { count, scopeMatched, learnings:[...] } instead of
149
161
  the Markdown block.
@@ -194,6 +206,7 @@ try {
194
206
  'max-chars': { type: 'string' },
195
207
  'pool-size': { type: 'string' },
196
208
  learnings: { type: 'string' },
209
+ 'rules-dir': { type: 'string' },
197
210
  'no-event': { type: 'boolean', default: false },
198
211
  json: { type: 'boolean', default: false },
199
212
  },
@@ -236,6 +249,13 @@ const learningsPath = opts.learnings
236
249
  ? opts.learnings
237
250
  : join(repoRoot, '.orchestrator', 'metrics', 'learnings.jsonl');
238
251
  const eventsPath = join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl');
252
+ // #1019 — the natively-delivered rule corpus. Every `.claude/rules/*.md` reaches
253
+ // a dispatched agent in FULL through Claude Code's own project-instruction
254
+ // loading (`docs/instruction-delivery.md` §1: the `globs:`/`tier:` frontmatter is
255
+ // inert because `rule-loader.mjs` does not run on that path), so a learning that
256
+ // already became a rule must not also spend a slot in this index. An absent
257
+ // directory yields an empty set and the index is byte-identical to before.
258
+ const rulesDir = opts['rules-dir'] ? opts['rules-dir'] : join(repoRoot, '.claude', 'rules');
239
259
 
240
260
  // ---------------------------------------------------------------------------
241
261
  // Scope resolution ladder: --file-scope -> --wave-scope allowedPaths -> empty
@@ -284,7 +304,8 @@ if (opts['file-scope']) {
284
304
  const doc = readJsonOrNull(opts['file-scope'], true, '--file-scope');
285
305
  // The agent's "Files:" scope is written as a bare JSON array (#796
286
306
  // $AGENT_FILESCOPE_JSON); tolerate an {allowedPaths:[...]} wrapper too so the
287
- // same temp file can be reused for either flag without a reshape step.
307
+ // same file can be reused for either flag without a reshape step. That file is
308
+ // `<state-dir>/filescopes/wave-<N>/<agent-id>.json` (#1020), never a $TMPDIR copy.
288
309
  scopePaths = cleanPaths(Array.isArray(doc) ? doc : doc?.allowedPaths);
289
310
  if (scopePaths.length > 0) scopeSource = 'file-scope';
290
311
  }
@@ -315,7 +336,7 @@ try {
315
336
  selection = await selectLearningsFromFile(
316
337
  learningsPath,
317
338
  { file_paths: scopePaths, text: taskText },
318
- { maxScoped, maxGlobal, maxChars, poolSize },
339
+ { maxScoped, maxGlobal, maxChars, poolSize, rulesDir },
319
340
  );
320
341
  } catch (err) {
321
342
  // `selectLearningsFromFile` is contractually total (contract point 1), so this
@@ -419,6 +440,11 @@ function emitInjectedEvent(bytes) {
419
440
  // Non-zero means the untrusted-text guard dropped a record. Carried in the
420
441
  // event so a drop is observable after the fact rather than silent.
421
442
  rejected: selection.rejected,
443
+ // #1019 — records skipped because `.claude/rules/*.md` already delivers
444
+ // them natively. Same reason as `rejected`: without the count, a filter
445
+ // that stopped biting looks exactly like a corpus with no rule-derived
446
+ // learnings in it.
447
+ delivered_filtered: selection.deliveredFiltered,
422
448
  bytes,
423
449
  scope_source: scopeSource,
424
450
  });
@@ -451,6 +477,7 @@ if (opts.json) {
451
477
  count: selected.length,
452
478
  scopeMatched,
453
479
  rejected: selection.rejected,
480
+ deliveredFiltered: selection.deliveredFiltered,
454
481
  learnings: selected.map((e) => ({
455
482
  id: e.id,
456
483
  type: e.type,