session-orchestrator 3.20.0 → 3.22.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 (202) 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/000-session-orchestrator.mdc +3 -2
  5. package/.cursor/rules/030-wave-execution.mdc +10 -8
  6. package/.cursor/rules/040-discovery.mdc +6 -6
  7. package/.cursor/rules/050-plan.mdc +8 -8
  8. package/CHANGELOG.md +515 -0
  9. package/README.md +16 -11
  10. package/agents/analyst.md +1 -1
  11. package/agents/architect-reviewer.md +1 -1
  12. package/agents/code-implementer.md +4 -2
  13. package/agents/db-specialist.md +1 -1
  14. package/agents/dialectic-deriver.md +1 -1
  15. package/agents/docs-writer.md +1 -1
  16. package/agents/memory-proposal-collector.md +7 -5
  17. package/agents/qa-strategist.md +1 -1
  18. package/agents/security-reviewer.md +1 -1
  19. package/agents/session-reviewer.md +42 -1
  20. package/agents/skill-applied-judge.md +1 -1
  21. package/agents/test-writer.md +1 -1
  22. package/agents/ui-developer.md +1 -1
  23. package/agents/ux-evaluator.md +1 -1
  24. package/commands/eli5.md +33 -0
  25. package/commands/release.md +62 -0
  26. package/commands/test.md +2 -2
  27. package/docs/components.md +6 -5
  28. package/docs/migration-v3.md +9 -6
  29. package/docs/persona-panel.md +3 -1
  30. package/docs/scope-collision-guard.md +167 -0
  31. package/docs/session-config-reference.md +31 -8
  32. package/hooks/_lib/lock-bootstrap.mjs +19 -13
  33. package/hooks/enforce-scope.mjs +103 -3
  34. package/hooks/hooks-codex.json +1 -1
  35. package/hooks/hooks.json +21 -1
  36. package/hooks/on-session-end.mjs +76 -97
  37. package/hooks/on-session-start.mjs +195 -104
  38. package/hooks/on-stop.mjs +127 -12
  39. package/hooks/post-bash-write-verify.mjs +8 -32
  40. package/hooks/pre-auq-clarity.mjs +787 -0
  41. package/hooks/pre-bash-issue-budget.mjs +17 -18
  42. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  43. package/package.json +3 -1
  44. package/pi/prompts/eli5.md +12 -0
  45. package/pi/prompts/release.md +12 -0
  46. package/scripts/auq-audit.mjs +825 -0
  47. package/scripts/autopilot.mjs +10 -9
  48. package/scripts/emit-session.mjs +42 -0
  49. package/scripts/export-hw-learnings.mjs +61 -2
  50. package/scripts/lib/auq/clarity.mjs +1314 -0
  51. package/scripts/lib/auq/parse.mjs +1006 -0
  52. package/scripts/lib/auq/schema.mjs +1457 -0
  53. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  54. package/scripts/lib/backlog-scan.mjs +106 -15
  55. package/scripts/lib/build-live-signals.mjs +7 -3
  56. package/scripts/lib/ci-status-banner.mjs +267 -77
  57. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  58. package/scripts/lib/config/vault-integration.mjs +12 -1
  59. package/scripts/lib/dispatcher/rank.mjs +4 -7
  60. package/scripts/lib/gates/gate-full.mjs +3 -3
  61. package/scripts/lib/gates/gate-helpers.mjs +17 -6
  62. package/scripts/lib/git-config-drift.mjs +471 -0
  63. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  64. package/scripts/lib/io.mjs +432 -7
  65. package/scripts/lib/issue-budget.mjs +63 -9
  66. package/scripts/lib/learnings/select.mjs +157 -3
  67. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  68. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  69. package/scripts/lib/named-vault-resolver.mjs +105 -16
  70. package/scripts/lib/owner-interview.mjs +78 -32
  71. package/scripts/lib/peer-cards/schema.mjs +6 -2
  72. package/scripts/lib/peer-discovery.mjs +73 -22
  73. package/scripts/lib/project-hygiene.mjs +64 -4
  74. package/scripts/lib/reconcile/renderer.mjs +17 -4
  75. package/scripts/lib/reconcile/writer.mjs +69 -30
  76. package/scripts/lib/redact-spans.mjs +89 -0
  77. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  78. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  79. package/scripts/lib/resource-probe.mjs +18 -2
  80. package/scripts/lib/scope-baseline.mjs +77 -17
  81. package/scripts/lib/scope-gate.mjs +658 -0
  82. package/scripts/lib/secret-masker.mjs +262 -0
  83. package/scripts/lib/session-lock.mjs +34 -10
  84. package/scripts/lib/session-registry.mjs +9 -1
  85. package/scripts/lib/spiral-carryover.mjs +23 -2
  86. package/scripts/lib/state-md/mission-status.mjs +164 -58
  87. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  88. package/scripts/lib/validate/check-agents.mjs +77 -5
  89. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  90. package/scripts/lib/validate/check-commands.mjs +2 -20
  91. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
  93. package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
  94. package/scripts/lib/validate/check-rules.mjs +153 -9
  95. package/scripts/lib/validate/check-skills.mjs +191 -0
  96. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  97. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  98. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  99. package/scripts/lib/validate/check-unwired-features.mjs +219 -11
  100. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  101. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  102. package/scripts/lib/validate/tier-inference.mjs +46 -8
  103. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  104. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  105. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  106. package/scripts/lib/vault-mirror/process.mjs +264 -31
  107. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  108. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  109. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  110. package/scripts/lib/vcs-repo-spec.mjs +680 -30
  111. package/scripts/lib/wave-resource-gate.mjs +67 -73
  112. package/scripts/materialize-wave-scope.mjs +281 -0
  113. package/scripts/print-learnings-index.mjs +30 -3
  114. package/scripts/release.mjs +983 -107
  115. package/scripts/run-quality-gate.mjs +14 -0
  116. package/scripts/site-numbers.mjs +1049 -0
  117. package/scripts/validate-plugin.mjs +64 -0
  118. package/scripts/validate-wave-scope.mjs +286 -12
  119. package/scripts/vault-backfill.mjs +32 -5
  120. package/scripts/vault-mirror.mjs +26 -1
  121. package/skills/_shared/monitor-patterns.md +24 -4
  122. package/skills/_shared/parallel-aware-auq.md +30 -24
  123. package/skills/_shared/parallel-aware-preamble.md +31 -2
  124. package/skills/_shared/state-ownership.md +49 -6
  125. package/skills/bootstrap/SKILL.md +2 -1
  126. package/skills/brainstorm/SKILL.md +18 -18
  127. package/skills/brainstorm/soul.md +12 -0
  128. package/skills/claude-md-drift-check/SKILL.md +9 -1
  129. package/skills/debug/SKILL.md +4 -1
  130. package/skills/discovery/SKILL.md +28 -24
  131. package/skills/discovery/issue-templates.md +4 -4
  132. package/skills/discovery/probes-code.md +2 -2
  133. package/skills/discovery/probes-feature.md +6 -6
  134. package/skills/discovery/probes-infra.md +2 -2
  135. package/skills/discovery/probes-session.md +5 -5
  136. package/skills/dispatcher/SKILL.md +10 -1
  137. package/skills/eli5/SKILL.md +43 -0
  138. package/skills/evolve/SKILL.md +8 -9
  139. package/skills/frontmatter-guard/SKILL.md +9 -1
  140. package/skills/gitlab-ops/SKILL.md +73 -59
  141. package/skills/gitlab-portfolio/SKILL.md +10 -1
  142. package/skills/grill/SKILL.md +6 -6
  143. package/skills/grill/soul.md +16 -0
  144. package/skills/memory-cleanup/SKILL.md +20 -7
  145. package/skills/npm-publish/SKILL.md +23 -51
  146. package/skills/peekaboo-driver/SKILL.md +3 -3
  147. package/skills/persona-panel/SKILL.md +3 -1
  148. package/skills/plan/SKILL.md +18 -16
  149. package/skills/plan/mode-feature.md +1 -1
  150. package/skills/plan/mode-new.md +42 -12
  151. package/skills/plan/soul.md +12 -0
  152. package/skills/reconcile/SKILL.md +3 -3
  153. package/skills/repo-audit/SKILL.md +10 -1
  154. package/skills/session-end/SKILL.md +97 -22
  155. package/skills/session-end/metrics-collection.md +1 -1
  156. package/skills/session-end/phase-3-6-tail.md +37 -2
  157. package/skills/session-end/session-metrics-write.md +4 -10
  158. package/skills/session-plan/SKILL.md +2 -2
  159. package/skills/session-plan/wave-template.md +1 -1
  160. package/skills/session-start/SKILL.md +82 -36
  161. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  162. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  163. package/skills/session-start/soul.md +110 -0
  164. package/skills/spinout/SKILL.md +5 -1
  165. package/skills/sunset-review/SKILL.md +11 -1
  166. package/skills/test-runner/SKILL.md +2 -2
  167. package/skills/tmux-layout/SKILL.md +7 -2
  168. package/skills/using-orchestrator/SKILL.md +1 -1
  169. package/skills/vault-mirror/SKILL.md +10 -1
  170. package/skills/vault-sync/SKILL.md +10 -1
  171. package/skills/vault-sync/validator.mjs +55 -6
  172. package/skills/wave-executor/wave-loop.md +64 -12
  173. package/skills/write-executable-plan/SKILL.md +6 -6
  174. package/scripts/lib/mission-status-schema.mjs +0 -114
  175. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  176. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  177. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  178. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  179. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  180. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  181. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  182. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  183. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  184. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  185. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  186. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  187. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  188. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  189. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  190. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  191. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  192. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  193. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  194. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  195. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  196. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  197. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  198. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  199. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  200. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  201. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  202. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
@@ -16,6 +16,29 @@
16
16
  * IO is fully injectable (existsSync, realpathSync, env, gitRemote) so every
17
17
  * branch is unit-testable without touching disk or git.
18
18
  *
19
+ * ── Remote resolution (#1039) ────────────────────────────────────────────────
20
+ *
21
+ * The default `gitRemote` reads the repo's remote through the shared core in
22
+ * `vcs-repo-spec.mjs` ({@link resolvePreferredRemote}), VCS-LESS. Two properties
23
+ * of that call are load-bearing here and must not be "tidied":
24
+ *
25
+ * 1. **`vcs` is deliberately omitted.** This module needs a URL, not a
26
+ * platform family, and the vcs-less preference order is `origin` first.
27
+ * That order is what keeps the derived slug STABLE: in a repo carrying both
28
+ * `origin` (→ `<group>/<repo>`) and `gitlab`/`github` mirrors under other
29
+ * namespaces, a `gitlab`-first order would re-namespace — i.e. silently
30
+ * RENAME — every vault note already written under the origin namespace.
31
+ * 2. **Absence and query-failure are kept apart.** The former
32
+ * `git remote get-url origin` implementation returned `''` for *both* "no
33
+ * remote configured" and "git blew up / this is not a repo", and a falsy
34
+ * URL skips the walk-up entirely. The vault then resolved to the
35
+ * single-vault fallback with no trace of why — one `source:'fallback'`
36
+ * label covering a benign repo state and a broken measurement. The
37
+ * fallback result now carries an optional `remoteError` (a
38
+ * `REMOTE_RESOLUTION_REASONS` value) so the two are separable BY VALUE, and
39
+ * only a query failure ({@link isQueryFailure}) WARNs — an absence is
40
+ * normal and stays silent.
41
+ *
19
42
  * ── Exports ──────────────────────────────────────────────────────────────────
20
43
  *
21
44
  * parseNamedVaults(ownerConfig)
@@ -28,7 +51,7 @@
28
51
 
29
52
  import { join, dirname } from 'node:path';
30
53
  import { existsSync as nodeExistsSync, realpathSync as nodeRealpathSync } from 'node:fs';
31
- import { spawnSync } from 'node:child_process';
54
+ import { resolvePreferredRemote, isQueryFailure } from './vcs-repo-spec.mjs';
32
55
 
33
56
  // ---------------------------------------------------------------------------
34
57
  // Internal helpers
@@ -289,12 +312,31 @@ export function findRepoRoot(cwd = process.cwd(), { existsSync = nodeExistsSync,
289
312
  * `gitRemote(repoRoot)` is called ONLY in the walk-up path — injecting a
290
313
  * stub that throws proves the explicit path never calls it.
291
314
  *
315
+ * `gitRemote` keeps its `(repoRoot) => string` contract verbatim; the optional
316
+ * second argument is an OUT-parameter the default implementation uses to report
317
+ * WHY it returned `''`. A one-arg stub ignores it and classifies as
318
+ * `'no-remotes'` on `''` / `'git-error'` on a throw — so every pre-existing
319
+ * injection still works unchanged.
320
+ *
321
+ * `gitRun` is injected into the DEFAULT `gitRemote` only, and is ignored when
322
+ * `gitRemote` is supplied (the caller has replaced the thing that would use it).
323
+ * It exists so the default remote-resolution path — preference order, reason
324
+ * propagation — is testable at all; a `gitRemote` stub answers that question
325
+ * before the code under test runs and can only ever confirm itself.
326
+ *
327
+ * `remoteError` is present ONLY on a `source:'fallback'` result that was reached
328
+ * because the remote query produced nothing; it is absent when the fallback was
329
+ * reached for any other reason (no vaults configured, no repo root, the repo IS
330
+ * a vault, no org-prefix match). Additive — pre-#1039 readers see the identical
331
+ * four fields.
332
+ *
292
333
  * @param {{
293
334
  * vaultName?: string|null,
294
335
  * cwd?: string,
295
336
  * ownerConfig?: object,
296
337
  * env?: Record<string, string|undefined>,
297
- * gitRemote?: (repoRoot: string) => string,
338
+ * gitRemote?: (repoRoot: string, out?: {reason?: string}) => string,
339
+ * gitRun?: (args: string[]) => {ok: boolean, stdout: string, stderr: string, status?: number, code?: string},
298
340
  * existsSync?: Function,
299
341
  * realpathSync?: Function,
300
342
  * }} [opts]
@@ -302,7 +344,8 @@ export function findRepoRoot(cwd = process.cwd(), { existsSync = nodeExistsSync,
302
344
  * root: string|null,
303
345
  * suffix: string,
304
346
  * name: string|null,
305
- * source: 'explicit'|'walkup'|'fallback'
347
+ * source: 'explicit'|'walkup'|'fallback',
348
+ * remoteError?: string
306
349
  * }}
307
350
  */
308
351
  export function resolveNamedVault({
@@ -310,11 +353,14 @@ export function resolveNamedVault({
310
353
  cwd = process.cwd(),
311
354
  ownerConfig,
312
355
  env = process.env,
313
- gitRemote = _defaultGitRemote,
356
+ gitRemote,
357
+ gitRun,
314
358
  existsSync = nodeExistsSync,
315
359
  realpathSync = nodeRealpathSync,
316
360
  } = {}) {
317
361
  const vaults = parseNamedVaults(ownerConfig);
362
+ const readRemote =
363
+ typeof gitRemote === 'function' ? gitRemote : (root, out) => _defaultGitRemote(root, out, gitRun);
318
364
 
319
365
  // ── Path 1: explicit vault-name ──────────────────────────────────────────
320
366
  const trimmedName = typeof vaultName === 'string' ? vaultName.trim() : '';
@@ -330,18 +376,39 @@ export function resolveNamedVault({
330
376
  }
331
377
 
332
378
  // ── Path 2: walk-up org-prefix match ────────────────────────────────────
379
+ /** @type {string|undefined} — set ONLY when the remote query is why we fall through */
380
+ let remoteError;
381
+
333
382
  if (vaults.length > 0) {
334
383
  const repoRoot = findRepoRoot(cwd, { existsSync, realpathSync });
335
384
  if (repoRoot !== null) {
336
- // Get the git remote for origin
385
+ // Resolve the repo's preferred remote (vcs-less origin-first; see the
386
+ // module docblock for why that order must not move).
387
+ /** @type {{reason?: string, stderr?: string}} */
388
+ const remoteOut = {};
337
389
  let remoteUrl;
338
390
  try {
339
- remoteUrl = gitRemote(repoRoot);
340
- } catch {
391
+ remoteUrl = readRemote(repoRoot, remoteOut);
392
+ } catch (err) {
393
+ // A throwing gitRemote could not answer the question — that is a query
394
+ // failure, never "this repo has no remote".
341
395
  remoteUrl = '';
396
+ remoteOut.reason = 'git-error';
397
+ remoteOut.stderr = err instanceof Error ? err.message : String(err);
342
398
  }
343
399
 
344
- if (remoteUrl) {
400
+ if (!remoteUrl) {
401
+ // Falsy URL: keep the REASON instead of collapsing it into a bare
402
+ // `source:'fallback'`. A one-arg stub that reported nothing means the
403
+ // benign "no remote configured" case.
404
+ remoteError = remoteOut.reason ?? 'no-remotes';
405
+ if (isQueryFailure(remoteError)) {
406
+ const detail = remoteOut.stderr ? `: ${String(remoteOut.stderr).trim()}` : '';
407
+ process.stderr.write(
408
+ `WARN named-vault-resolver: could not read the git remote of "${repoRoot}" (${remoteError})${detail}; falling back to the single-vault default\n`,
409
+ );
410
+ }
411
+ } else {
345
412
  // Derive org/repo from the remote URL (strip suffix/.git/scheme)
346
413
  const repoSlug = _deriveSlugFromRemote(remoteUrl);
347
414
 
@@ -366,11 +433,14 @@ export function resolveNamedVault({
366
433
  }
367
434
 
368
435
  // ── Path 3: single-vault fallback ────────────────────────────────────────
436
+ // `remoteError` is spread in only when set, so a fallback reached for any
437
+ // other reason keeps the exact pre-#1039 four-field shape.
369
438
  return {
370
439
  root: null,
371
440
  suffix: _resolveEnvSuffix(env) ?? DEFAULT_SUFFIX,
372
441
  name: null,
373
442
  source: 'fallback',
443
+ ...(remoteError === undefined ? {} : { remoteError }),
374
444
  };
375
445
  }
376
446
 
@@ -420,14 +490,33 @@ function _deriveSlugFromRemote(url) {
420
490
  }
421
491
 
422
492
  /**
423
- * Default gitRemote implementation: runs `git -C <repoRoot> remote get-url origin`.
493
+ * Default gitRemote implementation (#1039).
494
+ *
495
+ * Delegates to the shared {@link resolvePreferredRemote} core VCS-LESS, which
496
+ * makes this resolver work in the repos the old hard-coded
497
+ * `git remote get-url origin` was blind in — a repo whose remotes are named
498
+ * `gitlab`/`github`, a fork whose sole remote is `upstream`. The vcs-less
499
+ * preference order still tries `origin` FIRST, so the slug derived for a repo
500
+ * that has an `origin` is byte-identical to the pre-#1039 value.
501
+ *
502
+ * Return type stays `string` (the `gitRemote` DI contract depends on it). The
503
+ * failure reason travels through the optional `out` OUT-parameter instead:
504
+ * folding it into the return value would have meant changing that contract for
505
+ * every injected stub.
506
+ *
424
507
  * @param {string} repoRoot
425
- * @returns {string}
508
+ * @param {{reason?: string, stderr?: string}} [out] — populated with the
509
+ * {@link REMOTE_RESOLUTION_REASONS} reason when the resolution failed
510
+ * @param {Function} [gitRun] — injectable git runner; `undefined` uses the real one
511
+ * @returns {string} the remote URL, or `''` when none resolved
426
512
  */
427
- function _defaultGitRemote(repoRoot) {
428
- const res = spawnSync('git', ['-C', repoRoot, 'remote', 'get-url', 'origin'], {
429
- encoding: 'utf8',
430
- });
431
- if (res.status !== 0) return '';
432
- return res.stdout.trim();
513
+ function _defaultGitRemote(repoRoot, out, gitRun) {
514
+ // `vcs` deliberately omitted see the module docblock (origin-first order).
515
+ const resolved = resolvePreferredRemote({ repoRoot, gitRun });
516
+ if (resolved.ok) return resolved.url;
517
+ if (isPlainObject(out)) {
518
+ out.reason = resolved.reason;
519
+ if (resolved.stderr) out.stderr = resolved.stderr;
520
+ }
521
+ return '';
433
522
  }
@@ -13,10 +13,16 @@
13
13
  * to AskUserQuestion in the coordinator. Each object has the shape:
14
14
  * { question, header, options: [{ label, description }], multiSelect }
15
15
  *
16
+ * optionValue(label)
17
+ * Maps a displayed option label to the value stored in owner.yaml by
18
+ * stripping the trailing `(Recommended)` marker. The label and the stored
19
+ * value are deliberately NOT the same string — see the function comment.
20
+ *
16
21
  * applyInterviewAnswers(answers, { path? } = {})
17
22
  * Accepts an array of selected option labels (one per question, same order
18
- * as getInterviewQuestions()), validates the result against validateOwnerConfig,
19
- * and writes owner.yaml via writeOwnerConfig.
23
+ * as getInterviewQuestions()), resolves each through optionValue(), validates
24
+ * the result against validateOwnerConfig, and writes owner.yaml via
25
+ * writeOwnerConfig.
20
26
  * Returns { ok, path, errors }.
21
27
  *
22
28
  * runOwnerInterview({ skipIfExists?, force?, path? } = {})
@@ -37,6 +43,40 @@ import {
37
43
  getDefaults,
38
44
  } from './owner-yaml.mjs';
39
45
 
46
+ // ---------------------------------------------------------------------------
47
+ // Label ←→ stored value
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * A trailing recommendation marker on an option LABEL — the display half of a
52
+ * label, never part of the stored value.
53
+ *
54
+ * Anchored at the end and non-greedy about whitespace so it can only ever strip
55
+ * a suffix; a value that merely CONTAINS the word (there is none today) survives.
56
+ */
57
+ const RECOMMENDED_SUFFIX = /\s*\((?:Recommended|Empfohlen|Default)\)\s*$/u;
58
+
59
+ /**
60
+ * Map an AUQ option label back to the value that is written to owner.yaml.
61
+ *
62
+ * The label and the stored value used to be the SAME string, which made the
63
+ * label unchangeable: `applyInterviewAnswers()` matches the answer against
64
+ * closed enums (`['direct','neutral','friendly']` and friends) and falls back to
65
+ * a default on any miss. Adding the `(Recommended)` marker AUQ-003 requires
66
+ * would therefore have turned a picked `direct` into a silently-stored
67
+ * `neutral` — a wrong answer written to disk with no error anywhere.
68
+ *
69
+ * Splitting the two keeps the label free for the operator and the value pinned
70
+ * to the enum. Everything before the marker is the value, verbatim.
71
+ *
72
+ * @param {unknown} label — the option label as selected by the operator
73
+ * @returns {string} the enum value to store, or '' for a non-string input
74
+ */
75
+ export function optionValue(label) {
76
+ if (typeof label !== 'string') return '';
77
+ return label.replace(RECOMMENDED_SUFFIX, '').trim();
78
+ }
79
+
40
80
  // ---------------------------------------------------------------------------
41
81
  // Question definitions
42
82
  // ---------------------------------------------------------------------------
@@ -50,51 +90,51 @@ import {
50
90
  export function getInterviewQuestions() {
51
91
  return [
52
92
  {
53
- question: 'Which language should the assistant use for its responses?',
54
- header: 'Owner Interview — Language (1/5)',
93
+ question: 'Which language should the assistant answer in?',
94
+ header: 'Language 1/5',
55
95
  options: [
56
- { label: 'de', description: 'German — responses, narration, and questions in Deutsch.' },
57
- { label: 'en', description: 'English — responses, narration, and questions in English.' },
58
- { label: 'other', description: 'Otherwrite your ISO-639-1 code when prompted.' },
96
+ { label: 'de', description: 'German — answers, narration and questions all in Deutsch.' },
97
+ { label: 'en', description: 'English — answers, narration and questions all in English.' },
98
+ { label: 'other', description: 'Stored as English either way only de and en are accepted. Change it later in owner.yaml (your settings file).' },
59
99
  ],
60
100
  multiSelect: false,
61
101
  },
62
102
  {
63
- question: 'What communication tone style do you prefer?',
64
- header: 'Owner Interview — Tone Style (2/5)',
103
+ question: 'How should the assistant talk to you?',
104
+ header: 'Tone 2/5',
65
105
  options: [
66
- { label: 'direct', description: '(Recommended for pros) No filler phrases, straight to the point.' },
67
- { label: 'neutral', description: 'Balanced: professional without being terse.' },
68
- { label: 'friendly', description: 'Warm and conversational good for exploratory sessions.' },
106
+ { label: 'direct (Recommended)', description: 'No filler, no praise, straight to the point — fastest to read once you know the project.' },
107
+ { label: 'neutral', description: 'Professional without being terse. Pick this if direct reads too blunt.' },
108
+ { label: 'friendly', description: 'Warm and conversational. Costs a few lines per answer, and suits open-ended exploration.' },
69
109
  ],
70
110
  multiSelect: false,
71
111
  },
72
112
  {
73
- question: 'How much output should the assistant produce by default?',
74
- header: 'Owner Interview — Output Level (3/5)',
113
+ question: 'How much should the assistant write by default?',
114
+ header: 'Output 3/5',
75
115
  options: [
76
- { label: 'lite', description: 'Verbose mode articles, explanations, and context kept. Good for learning.' },
77
- { label: 'full', description: '(Default) Terse but complete. Narration trimmed, data preserved.' },
78
- { label: 'ultra', description: 'Telegraphic — code and decisions only, no narration.' },
116
+ { label: 'full (Recommended)', description: 'Terse but complete: narration trimmed, every fact kept. Safe default — you lose words, never data.' },
117
+ { label: 'lite', description: 'Keeps the explanations and background too. Slower to read, better while the codebase is still new to you.' },
118
+ { label: 'ultra', description: 'Code and decisions only, no narration. You will have to ask why more often.' },
79
119
  ],
80
120
  multiSelect: false,
81
121
  },
82
122
  {
83
- question: 'How should the assistant handle preamble before taking actions?',
84
- header: 'Owner Interview — Preamble (4/5)',
123
+ question: 'How much should the assistant explain before it starts working?',
124
+ header: 'Preamble 4/5',
85
125
  options: [
86
- { label: 'minimal', description: 'One-line status updates only. Jump straight to execution.' },
87
- { label: 'verbose', description: 'Explain plan + rationale before each major action.' },
126
+ { label: 'minimal (Recommended)', description: 'One line of status, then it works. Safe default you can still ask for the reasoning afterwards.' },
127
+ { label: 'verbose', description: 'Plan and reasoning before each major action. Costs a few lines every step.' },
88
128
  ],
89
129
  multiSelect: false,
90
130
  },
91
131
  {
92
- question: 'Hardware-sharing consent: may the plugin share anonymized hardware patterns to improve resource defaults? (Issue #173 C4)',
93
- header: 'Owner Interview — Hardware Sharing Consent (5/5)',
132
+ question: 'May the plugin share anonymized hardware data to improve its resource defaults?',
133
+ header: 'Sharing 5/5',
94
134
  options: [
95
- { label: 'No', description: '(Default) No data is shared. Fully private.' },
96
- { label: 'Yes', description: 'Share anonymized patterns (hashed, no PII). Helps tune wave/session defaults.' },
97
- { label: 'Preview', description: 'Show exactly what would be shared before deciding.' },
135
+ { label: 'No (Recommended)', description: 'Nothing leaves this machine. Safe default — you can switch it on later without redoing this interview.' },
136
+ { label: 'Yes', description: 'Shares hashed hardware patterns — never file names, paths or content. Helps tune the wave and session defaults.' },
137
+ { label: 'Preview', description: 'Shows exactly what would be sent, then asks again. Costs one extra step.' },
98
138
  ],
99
139
  multiSelect: false,
100
140
  },
@@ -108,12 +148,15 @@ export function getInterviewQuestions() {
108
148
  /**
109
149
  * Map interview answer labels to an owner.yaml config object and write it.
110
150
  *
151
+ * Each answer is the LABEL the operator picked, which may carry a trailing
152
+ * `(Recommended)` marker; optionValue() strips it before the enum match below.
153
+ *
111
154
  * @param {string[]} answers - Array of selected option labels, one per question (5 total).
112
- * answers[0] = language label ('de' | 'en' | free-text)
113
- * answers[1] = tone style label
114
- * answers[2] = output level label
115
- * answers[3] = preamble label
116
- * answers[4] = hardware-sharing label ('Yes' | 'No' | 'Preview')
155
+ * answers[0] = language label ('de' | 'en' | 'other')
156
+ * answers[1] = tone style label ('direct' | 'neutral' | 'friendly', ± marker)
157
+ * answers[2] = output level label ('full' | 'lite' | 'ultra', ± marker)
158
+ * answers[3] = preamble label ('minimal' | 'verbose', ± marker)
159
+ * answers[4] = hardware-sharing label ('Yes' | 'No' | 'Preview', ± marker)
117
160
  * @param {{ path?: string }} [opts]
118
161
  * @returns {{ ok: boolean, path: string, errors: string[] }}
119
162
  */
@@ -124,7 +167,10 @@ export function applyInterviewAnswers(answers, opts = {}) {
124
167
  return { ok: false, path: filePath, errors: ['applyInterviewAnswers requires exactly 5 answers'] };
125
168
  }
126
169
 
127
- const [langRaw, toneRaw, outputLevelRaw, preambleRaw, hwConsentRaw] = answers;
170
+ // Strip the display-only `(Recommended)` marker before matching against the
171
+ // enums below — see optionValue(). A non-string answer becomes '' and falls
172
+ // through to the same default it always did.
173
+ const [langRaw, toneRaw, outputLevelRaw, preambleRaw, hwConsentRaw] = answers.map(optionValue);
128
174
 
129
175
  // --- Language ---
130
176
  // Accept 'de', 'en', or treat anything else as a free-text language code.
@@ -6,8 +6,12 @@
6
6
  * YAML frontmatter on read and before write.
7
7
  *
8
8
  * Design notes:
9
- * - Follows the `mission-status-schema.mjs` convention (pure regex/checks)
10
- * rather than `skills/vault-sync/validator.mjs` (zod). The repo root has
9
+ * - Pure regex/checks rather than `skills/vault-sync/validator.mjs` (zod).
10
+ * (This line used to cite `scripts/lib/mission-status-schema.mjs` as the
11
+ * exemplar for that convention; that module was deleted on 2026-08-15 for
12
+ * having zero production callers while three prose sites promised it ran.
13
+ * The convention below stands on its own reason, stated next.)
14
+ * The repo root has
11
15
  * no `zod` dependency — only `skills/vault-sync/` does, scoped to that
12
16
  * skill's own `node_modules`. Keeping this validator dependency-free lets
13
17
  * callers from anywhere under `scripts/lib/` use it without extra setup.
@@ -67,6 +67,7 @@ import { discoverActiveSessions } from './session-discovery.mjs';
67
67
  import { readLock, isLockLive, LOCK_PATH } from './session-lock.mjs';
68
68
  import { checkPeerStateMd } from './state-md-peer-guard.mjs';
69
69
  import { listWorktreesChecked } from './worktree/listing.mjs';
70
+ import { parseSessionId } from './session-id.mjs';
70
71
 
71
72
  /** Closed enum of provenance sources. */
72
73
  const SOURCE_DISCOVERED = 'discovered'; // lock + registry unified (irreversibly merged upstream)
@@ -120,24 +121,64 @@ function _peerFromDiscovered(s, nowMs) {
120
121
  return peer;
121
122
  }
122
123
 
124
+ /**
125
+ * Resolve a semantic hint to the concrete local raw ID for discovered surfaces.
126
+ *
127
+ * Semantic labels are attribution, not ownership. A mapping is usable only when
128
+ * current-session.json names the hint and its raw id exactly matches the local
129
+ * readable session.lock. Otherwise return null so a potentially foreign record
130
+ * stays visible. Raw caller ids retain the legacy direct comparison unchanged.
131
+ *
132
+ * This is a consistency check on two repo-global files, NOT a proof of ownership;
133
+ * a colliding semantic label from a foreign live session satisfies it. See the
134
+ * call site in findPeers for the measured case and why it is not closed here.
135
+ *
136
+ * @param {string|null} mySessionId
137
+ * @param {string} repoRoot
138
+ * @returns {string|null}
139
+ */
140
+ function _discoveredSelfSessionId(mySessionId, repoRoot) {
141
+ if (typeof mySessionId !== 'string' || mySessionId.length === 0) return null;
142
+ if (parseSessionId(mySessionId)?.format !== 'semantic') return mySessionId;
143
+
144
+ try {
145
+ const raw = fs.readFileSync(
146
+ path.join(repoRoot, '.orchestrator', 'current-session.json'),
147
+ 'utf8',
148
+ );
149
+ const current = JSON.parse(raw);
150
+ const lock = readLock({ repoRoot });
151
+ if (
152
+ current &&
153
+ typeof current === 'object' &&
154
+ !Array.isArray(current) &&
155
+ typeof current.session_id === 'string' &&
156
+ current.session_id.length > 0 &&
157
+ current.semantic_session_id === mySessionId &&
158
+ lock !== null &&
159
+ typeof lock.session_id === 'string' &&
160
+ lock.session_id === current.session_id
161
+ ) {
162
+ return lock.session_id;
163
+ }
164
+ } catch {
165
+ // Missing or malformed local state cannot prove a semantic alias.
166
+ }
167
+ return null;
168
+ }
169
+
123
170
  /**
124
171
  * findPeers — union of all 3 peer-discovery surfaces, fail-open per surface.
125
172
  *
126
173
  * @param {string} repoRoot Absolute path to the repository root.
127
174
  * @param {object} [opts] passthrough seams shared with the underlying surfaces.
128
- * @param {string|null} [opts.mySessionId] Current session id, used for
129
- * self-exclusion on BOTH surfaces but the two surfaces read different
130
- * id-spaces. Surface A+B (discoverActiveSessions) compares against
131
- * `session_id` from session.lock / the host registry, which is ALWAYS the
132
- * UUID (never `semantic_session_id` see session-lock.mjs). Surface C
133
- * (checkPeerStateMd) compares against STATE.md's `session:` frontmatter
134
- * field, which callers may populate with either id-space as long as it is
135
- * the SAME id-space `mySessionId` was derived from (see the PRECONDITION
136
- * note in state-md-peer-guard.mjs). Passing a semantic id here self-excludes
137
- * correctly on Surface C but NOT on Surface A+B (the UUID lock/registry
138
- * entry for the same session will still surface as a 'discovered' peer of
139
- * itself) — callers that need both surfaces to self-exclude MUST pass the
140
- * UUID.
175
+ * @param {string|null} [opts.mySessionId] Current session id hint. Raw
176
+ * ids self-exclude directly on Surface A+B. A semantic hint maps to a raw
177
+ * discovered-surface id only after the local current-session.json semantic
178
+ * label matches it AND that file's raw id exactly equals local
179
+ * session.lock.session_id. Missing, malformed, or mismatched local state
180
+ * maps nothing, leaving discovered entries visible. Surface C receives this
181
+ * original hint unchanged and compares it to STATE.md's `session:` field.
141
182
  * @param {number} [opts.now] ms-since-epoch (test seam for freshness/age).
142
183
  * @param {number} [opts.freshnessMin] Registry-entry freshness threshold (minutes).
143
184
  * @param {number} [opts.maxAgeHours] STATE.md abandonment threshold (hours).
@@ -155,6 +196,7 @@ function _peerFromDiscovered(s, nowMs) {
155
196
  export async function findPeers(repoRoot, opts = {}) {
156
197
  const nowMs = typeof opts.now === 'number' ? opts.now : Date.now();
157
198
  const mySessionId = opts.mySessionId ?? null;
199
+ const discoveredSelfSessionId = _discoveredSelfSessionId(mySessionId, repoRoot);
158
200
 
159
201
  const peers = [];
160
202
 
@@ -172,15 +214,24 @@ export async function findPeers(repoRoot, opts = {}) {
172
214
  });
173
215
  if (Array.isArray(discovered)) {
174
216
  for (const s of discovered) {
175
- // Self-exclusion (#798): discoverActiveSessions has no notion of "my
176
- // session" it returns every live lock/registry entry, including the
177
- // caller's own SessionStart-hook heartbeat. Exclude it here so it
178
- // never surfaces as a source:'discovered' peer of itself. Mirrors the
179
- // same guard in session-registry.mjs detectPeers() and
180
- // hooks/on-session-start.mjs. `mySessionId === null` needs no special
181
- // case: `!==` against a string sessionId is always true when
182
- // mySessionId is null, so a foreign entry is never filtered.
183
- if (s && typeof s.sessionId === 'string' && s.sessionId !== mySessionId) {
217
+ // Self-exclusion (#798, #1085): discovered entries carry raw ids. A
218
+ // semantic caller hint filters one such id only after the local raw
219
+ // binding was verified above.
220
+ //
221
+ // The binding proves CONSISTENCY of the two local files, not OWNERSHIP.
222
+ // Both are repo-global, so if a foreign live session wrote them last and
223
+ // its semantic label collides with this hint (labels like
224
+ // `main-2026-08-21-session-2` are routinely shared see
225
+ // hooks/on-session-start.mjs), that foreign raw id is filtered and the
226
+ // peer PSA-002 exists to surface goes missing. Measured 2026-08-21:
227
+ // null hint -> peer visible, colliding semantic hint -> peers: [].
228
+ // Closing it needs a per-process ownership proof, not a stronger read
229
+ // of the same two files.
230
+ if (
231
+ s &&
232
+ typeof s.sessionId === 'string' &&
233
+ s.sessionId !== discoveredSelfSessionId
234
+ ) {
184
235
  peers.push(_peerFromDiscovered(s, nowMs));
185
236
  }
186
237
  }
@@ -212,6 +212,21 @@ function duBytes(absPath) {
212
212
  }
213
213
  }
214
214
 
215
+ /**
216
+ * Human-readable byte size. Sub-megabyte totals are the common case once the
217
+ * aged subset is sized correctly, and `Math.round(bytes / MB)` renders every
218
+ * one of them as "0 MB" — which is why the unit is chosen, not fixed.
219
+ * @param {number} bytes
220
+ * @returns {string}
221
+ */
222
+ function formatBytes(bytes) {
223
+ const mb = bytes / (1024 * 1024);
224
+ if (mb >= 10) return `${Math.round(mb)} MB`;
225
+ if (mb >= 1) return `${mb.toFixed(2)} MB`;
226
+ if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
227
+ return `${bytes} B`;
228
+ }
229
+
215
230
  /**
216
231
  * H3 — Aged orchestrator artifacts.
217
232
  *
@@ -219,17 +234,48 @@ function duBytes(absPath) {
219
234
  * 147 MB were Playwright test-run captures dating back seven weeks, plus
220
235
  * 592 files older than 30 days.
221
236
  *
237
+ * Counts and sizes ONLY untracked aged files. Two properties are load-bearing
238
+ * and were both defects until 2026-08-22:
239
+ *
240
+ * 1. The byte total describes the set the sentence names — the aged files —
241
+ * not the whole directory. Sizing the directory overstated the reachable
242
+ * win by a factor of ~18 in this repo (11 MB claimed, 0.68 MB real).
243
+ * 2. Version-tracked paths are excluded. `.orchestrator/policy/*.json` and
244
+ * `.orchestrator/steering/*.md` are read at runtime by hooks and skills;
245
+ * their age is a sign of stability, not decay. Proposing them for
246
+ * "pruning" is proposing to delete source.
247
+ *
222
248
  * @param {string} repoRoot
223
249
  * @param {number} ageDays
224
250
  * @param {number} now
225
- * @returns {object|null}
251
+ * @returns {{check: string, fixable: boolean, agedFiles: number, agedBytes: number, message: string}|null}
226
252
  */
227
253
  export function checkStaleArtifacts(repoRoot, ageDays = DEFAULT_ARTIFACT_AGE_DAYS, now = Date.now()) {
228
254
  const dir = join(repoRoot, '.orchestrator');
229
255
  if (!existsSync(dir)) return null;
230
256
 
257
+ // Resolve the tracked set ONCE per call — `git ls-files --error-unmatch` per
258
+ // file would cost one process per candidate for the same answer.
259
+ //
260
+ // FAIL-SAFE, NOT FAIL-OPEN: when git cannot answer (no repo, git absent,
261
+ // non-zero exit) source and artifact are indistinguishable, so the probe
262
+ // stays SILENT rather than falling back to "nothing is tracked" — that
263
+ // fallback IS the defect this exclusion closes, and it would return
264
+ // invisibly. Losing an advisory finding costs nothing; proposing to delete
265
+ // versioned files costs a restore. `-z` suppresses git's path quoting, so
266
+ // non-ASCII and space-bearing paths compare byte-exactly.
267
+ const trackedRaw = git(['ls-files', '-z', '--', '.orchestrator'], repoRoot);
268
+ if (trackedRaw === null) return null;
269
+ const tracked = new Set(
270
+ trackedRaw
271
+ .split('\0')
272
+ .filter(Boolean)
273
+ .map((p) => join(repoRoot, p)),
274
+ );
275
+
231
276
  const cutoff = now - ageDays * 24 * 60 * 60 * 1000;
232
277
  let aged = 0;
278
+ let agedBytes = 0;
233
279
  let scanned = 0;
234
280
 
235
281
  /** @param {string} d @param {number} depth */
@@ -248,9 +294,16 @@ export function checkStaleArtifacts(repoRoot, ageDays = DEFAULT_ARTIFACT_AGE_DAY
248
294
  if (e.isDirectory()) {
249
295
  walk(full, depth + 1);
250
296
  } else if (e.isFile()) {
297
+ // Counted before the tracked-skip so the 20k bound still measures the
298
+ // walk, not the reportable subset.
251
299
  scanned++;
300
+ if (tracked.has(full)) continue;
252
301
  try {
253
- if (statSync(full).mtimeMs < cutoff) aged++;
302
+ const st = statSync(full);
303
+ if (st.mtimeMs < cutoff) {
304
+ aged++;
305
+ agedBytes += st.size;
306
+ }
254
307
  } catch {
255
308
  /* vanished mid-scan — ignore */
256
309
  }
@@ -261,11 +314,18 @@ export function checkStaleArtifacts(repoRoot, ageDays = DEFAULT_ARTIFACT_AGE_DAY
261
314
 
262
315
  if (aged === 0) return null;
263
316
 
264
- const mb = Math.round((duBytes(dir) ?? 0) / (1024 * 1024));
317
+ // `fixable: true` stays. The only consumer is skills/session-start/SKILL.md
318
+ // (Phase 4), which routes fixable findings to "safe batch work" instead of
319
+ // the operator Q&A — prose guidance to the coordinator, not an automatic
320
+ // deletion run; `grep -rn "fixable" scripts/ skills/ hooks/` finds no other
321
+ // reader than the `mechanical` count below. With tracked paths excluded the
322
+ // claim is now true: every reported path is an untracked artifact.
265
323
  return {
266
324
  check: 'stale-artifacts',
267
325
  fixable: true,
268
- message: `${aged} file(s) under .orchestrator/ older than ${ageDays}d${mb > 0 ? ` (${mb} MB total)` : ''} — candidates for pruning`,
326
+ agedFiles: aged,
327
+ agedBytes,
328
+ message: `${aged} untracked file(s) under .orchestrator/ older than ${ageDays}d${agedBytes > 0 ? ` (${formatBytes(agedBytes)} total)` : ''} — candidates for pruning`,
269
329
  };
270
330
  }
271
331