session-orchestrator 3.19.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 (158) 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 +494 -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/commands/session.md +6 -2
  23. package/docs/USER-GUIDE.md +1 -1
  24. package/docs/instruction-delivery.md +350 -0
  25. package/docs/migration-v3.md +9 -6
  26. package/docs/persona-panel.md +3 -1
  27. package/docs/scope-collision-guard.md +167 -0
  28. package/docs/session-config-reference.md +1 -41
  29. package/docs/session-config-template.md +0 -23
  30. package/hooks/_lib/guard-source-loader.mjs +304 -91
  31. package/hooks/enforce-commands.mjs +216 -17
  32. package/hooks/enforce-scope.mjs +236 -12
  33. package/hooks/hooks-codex.json +1 -1
  34. package/hooks/hooks.json +11 -1
  35. package/hooks/on-session-end.mjs +52 -5
  36. package/hooks/on-session-start.mjs +7 -4
  37. package/hooks/on-stop.mjs +127 -12
  38. package/hooks/post-bash-write-verify.mjs +8 -32
  39. package/hooks/pre-bash-destructive-guard.mjs +146 -59
  40. package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
  41. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  42. package/package.json +2 -2
  43. package/pi/prompts/release.md +12 -0
  44. package/scripts/autopilot.mjs +3 -1
  45. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  46. package/scripts/emit-session.mjs +45 -40
  47. package/scripts/export-hw-learnings.mjs +61 -2
  48. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  49. package/scripts/lib/backlog-scan.mjs +106 -15
  50. package/scripts/lib/build-live-signals.mjs +7 -3
  51. package/scripts/lib/ci-status-banner.mjs +207 -23
  52. package/scripts/lib/command-blocker.mjs +322 -62
  53. package/scripts/lib/git-config-drift.mjs +471 -0
  54. package/scripts/lib/hardening.mjs +9 -9
  55. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  56. package/scripts/lib/io.mjs +193 -7
  57. package/scripts/lib/learnings/affinity.mjs +434 -0
  58. package/scripts/lib/learnings/candidates.mjs +736 -0
  59. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  60. package/scripts/lib/learnings/judgment.mjs +782 -0
  61. package/scripts/lib/learnings/kebab.mjs +128 -0
  62. package/scripts/lib/learnings/select.mjs +704 -0
  63. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  64. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  65. package/scripts/lib/named-vault-resolver.mjs +105 -16
  66. package/scripts/lib/peer-cards/schema.mjs +6 -2
  67. package/scripts/lib/reconcile/emitter.mjs +107 -22
  68. package/scripts/lib/reconcile/engine.mjs +9 -15
  69. package/scripts/lib/reconcile/renderer.mjs +141 -25
  70. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  71. package/scripts/lib/reconcile/writer.mjs +134 -1
  72. package/scripts/lib/redact-spans.mjs +89 -0
  73. package/scripts/lib/scope-baseline.mjs +77 -17
  74. package/scripts/lib/scope-gate.mjs +852 -72
  75. package/scripts/lib/secret-masker.mjs +262 -0
  76. package/scripts/lib/session-close-backfill.mjs +2 -2
  77. package/scripts/lib/session-lock.mjs +34 -10
  78. package/scripts/lib/session-record-repair.mjs +551 -0
  79. package/scripts/lib/session-registry.mjs +9 -1
  80. package/scripts/lib/session-schema/serializer.mjs +54 -0
  81. package/scripts/lib/session-schema.mjs +1 -0
  82. package/scripts/lib/session-token-rollup.mjs +68 -6
  83. package/scripts/lib/soul-resolve.mjs +12 -0
  84. package/scripts/lib/state-md/mission-status.mjs +21 -12
  85. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  86. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  87. package/scripts/lib/validate/check-agents.mjs +77 -5
  88. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  89. package/scripts/lib/validate/check-commands.mjs +2 -20
  90. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  91. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
  93. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  94. package/scripts/lib/validate/check-owner-leakage.mjs +188 -20
  95. package/scripts/lib/validate/check-rules.mjs +31 -5
  96. package/scripts/lib/validate/check-skills.mjs +191 -0
  97. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  98. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  99. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  100. package/scripts/lib/validate/check-unwired-features.mjs +757 -0
  101. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  102. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  103. package/scripts/lib/validate/tier-inference.mjs +46 -8
  104. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  105. package/scripts/lib/vault-mirror/process.mjs +264 -31
  106. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  107. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  108. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  109. package/scripts/lib/vcs-repo-spec.mjs +500 -19
  110. package/scripts/print-applicable-rules.mjs +170 -7
  111. package/scripts/print-learnings-index.mjs +501 -0
  112. package/scripts/release.mjs +616 -61
  113. package/scripts/repair-invalid-sessions.mjs +209 -0
  114. package/scripts/site-numbers.mjs +1049 -0
  115. package/scripts/sweep-expired-learnings.mjs +192 -32
  116. package/scripts/validate-plugin.mjs +82 -0
  117. package/scripts/validate-wave-scope.mjs +281 -12
  118. package/scripts/vault-mirror.mjs +26 -1
  119. package/skills/_shared/monitor-patterns.md +24 -4
  120. package/skills/_shared/state-ownership.md +17 -0
  121. package/skills/brainstorm/soul.md +47 -1
  122. package/skills/claude-md-drift-check/SKILL.md +9 -1
  123. package/skills/debug/SKILL.md +4 -1
  124. package/skills/discovery/issue-templates.md +4 -4
  125. package/skills/discovery/probes-code.md +2 -2
  126. package/skills/discovery/probes-feature.md +6 -6
  127. package/skills/discovery/probes-infra.md +2 -2
  128. package/skills/discovery/probes-session.md +5 -5
  129. package/skills/dispatcher/SKILL.md +10 -1
  130. package/skills/evolve/SKILL.md +116 -18
  131. package/skills/frontmatter-guard/SKILL.md +9 -1
  132. package/skills/gitlab-ops/SKILL.md +54 -39
  133. package/skills/gitlab-portfolio/SKILL.md +10 -1
  134. package/skills/grill/soul.md +44 -1
  135. package/skills/memory-cleanup/SKILL.md +18 -5
  136. package/skills/npm-publish/SKILL.md +22 -50
  137. package/skills/persona-panel/SKILL.md +3 -1
  138. package/skills/plan/mode-new.md +23 -5
  139. package/skills/plan/soul.md +46 -3
  140. package/skills/repo-audit/SKILL.md +10 -1
  141. package/skills/session-end/SKILL.md +45 -26
  142. package/skills/session-end/metrics-collection.md +1 -1
  143. package/skills/session-end/phase-3-6-tail.md +30 -1
  144. package/skills/session-end/plan-verification.md +1 -5
  145. package/skills/session-end/session-metrics-write.md +6 -10
  146. package/skills/session-plan/SKILL.md +2 -2
  147. package/skills/session-plan/wave-template.md +1 -1
  148. package/skills/session-start/SKILL.md +15 -1
  149. package/skills/session-start/soul.md +41 -1
  150. package/skills/spinout/SKILL.md +5 -1
  151. package/skills/sunset-review/SKILL.md +11 -1
  152. package/skills/tmux-layout/SKILL.md +7 -2
  153. package/skills/vault-mirror/SKILL.md +10 -1
  154. package/skills/vault-sync/SKILL.md +10 -1
  155. package/skills/vault-sync/validator.mjs +55 -6
  156. package/skills/wave-executor/SKILL.md +1 -5
  157. package/skills/wave-executor/wave-loop.md +77 -82
  158. package/scripts/lib/mission-status-schema.mjs +0 -114
@@ -3,7 +3,9 @@
3
3
  *
4
4
  * Reads `.orchestrator/metrics/subagents.jsonl` (or a caller-supplied path),
5
5
  * filters to a given `parent_session_id`, and sums `token_input` /
6
- * `token_output` across all matched records, skipping null/undefined values.
6
+ * `token_output` across the records whose token fields are TRUSTWORTHY — see
7
+ * § Token provenance below, which is the whole reason this module is not a
8
+ * two-line sum.
7
9
  *
8
10
  * Design notes:
9
11
  * - Pure function — no top-level side effects, no writes.
@@ -12,7 +14,44 @@
12
14
  * "session was genuinely free / cost $0".
13
15
  * - Malformed JSONL lines are silently skipped (resilience over strictness).
14
16
  * - `subagents_with_tokens` counts distinct agent_ids that have at least one
15
- * record with a non-null token_input or token_output value (coverage metric).
17
+ * TOKEN-BEARING record (coverage metric).
18
+ *
19
+ * ## Token provenance — why a bare Σ over token_input is wrong (#949)
20
+ *
21
+ * Two record classes in this ledger carry a `token_input` that must NEVER be
22
+ * summed, and both look identical to a naive reader:
23
+ *
24
+ * 1. **Pre-#949 records** (written before 2026-07-31). The producer read the
25
+ * PARENT session transcript instead of the subagent's own, so every stop
26
+ * record carries the parent's running totals. Summing them counts the parent
27
+ * once per subagent. `hooks/subagent-telemetry.mjs` § TOKEN-DATA PROVENANCE
28
+ * states the consumer obligation outright: "Consumers MUST discard token_* on
29
+ * every stop record written before this fix landed."
30
+ * 2. **Phantom stops** (#939). The harness fires `SubagentStop` for an ephemeral
31
+ * agent class that never fires `SubagentStart` and for which no subagent ever
32
+ * existed. These carry null tokens today — harmless to sum, but they inflate
33
+ * any coverage ratio computed against `matched_records`.
34
+ *
35
+ * `subagent_transcript_found === true` settles both at once and is the flag the
36
+ * producer writes for exactly this purpose. It is a sufficient cutoff on its own:
37
+ * the field did not exist before the #949 fix, so `=== true` excludes every
38
+ * pre-fix record without needing a date comparison.
39
+ *
40
+ * Measured over this repo's ledger on 2026-08-11 (3,981 records / 116 sessions):
41
+ * 73 sessions summed to 96,148,781 tokens that no agent ever spent — every one of
42
+ * them a pre-#949 parent total. Under this filter those sessions correctly report
43
+ * null ("no token data") instead.
44
+ *
45
+ * jq -r 'select(.event=="stop" and .subagent_transcript_found==true and .token_input==null)' \
46
+ * .orchestrator/metrics/subagents.jsonl | wc -l # → 0
47
+ *
48
+ * i.e. the flag never excludes a record that genuinely had tokens.
49
+ *
50
+ * FORWARD-ONLY. Session totals already written into `sessions.jsonl` by the
51
+ * unfiltered recipe are NOT recomputed — that ledger is append-only and the
52
+ * transcripts that produced the oldest records have aged out, so a rewrite would
53
+ * be reconstruction, not correction. Consumers comparing token totals across the
54
+ * 2026-08-11 boundary must treat it as a series break.
16
55
  *
17
56
  * @module session-token-rollup
18
57
  */
@@ -30,12 +69,28 @@ const DEFAULT_SUBAGENTS_PATH = '.orchestrator/metrics/subagents.jsonl';
30
69
  // Public API
31
70
  // ---------------------------------------------------------------------------
32
71
 
72
+ /**
73
+ * Is this record's token data trustworthy enough to sum? (#949)
74
+ *
75
+ * The producer sets `subagent_transcript_found: true` only when it located and
76
+ * read the subagent's OWN transcript. Every other shape — a phantom stop, a
77
+ * start record, or any record written before the flag existed — is excluded.
78
+ * See the module header § Token provenance for why this single flag is a
79
+ * sufficient cutoff and what it costs to omit it.
80
+ *
81
+ * @param {object} record — a parsed subagents.jsonl record
82
+ * @returns {boolean}
83
+ */
84
+ function isTokenBearing(record) {
85
+ return record?.subagent_transcript_found === true;
86
+ }
87
+
33
88
  /**
34
89
  * @typedef {Object} TokenRollupResult
35
- * @property {number|null} total_token_input - Sum of token_input across matched records; null when no record had a non-null value.
36
- * @property {number|null} total_token_output - Sum of token_output across matched records; null when no record had a non-null value.
37
- * @property {number} subagents_with_tokens - Count of distinct agent_ids that had at least one non-null token value.
38
- * @property {number} matched_records - Total count of JSONL records matched by parentSessionId (includes null-token records).
90
+ * @property {number|null} total_token_input - Sum of token_input across TOKEN-BEARING matched records; null when none had a non-null value.
91
+ * @property {number|null} total_token_output - Sum of token_output across TOKEN-BEARING matched records; null when none had a non-null value.
92
+ * @property {number} subagents_with_tokens - Count of distinct agent_ids with at least one token-bearing record. This is the numerator of the honest coverage ratio.
93
+ * @property {number} matched_records - Total count of JSONL records matched by parentSessionId. Counts start records, phantom stops and pre-#949 records alike, so it is NOT the denominator for a token-coverage ratio — dividing by it is what made healthy sessions read as 12% covered.
39
94
  */
40
95
 
41
96
  /**
@@ -107,6 +162,13 @@ export function rollupSessionTokens({
107
162
  const agentsWithTokens = new Set();
108
163
 
109
164
  for (const record of matched) {
165
+ // Provenance gate (#949) — a record whose tokens describe the PARENT
166
+ // transcript, or no transcript at all, contributes nothing. Skipping it
167
+ // entirely (rather than treating its values as 0) preserves the null
168
+ // sentinel: a session of only untrustworthy records reports "no data",
169
+ // which is true, instead of a fabricated 0.
170
+ if (!isTokenBearing(record)) continue;
171
+
110
172
  const inp = record.token_input;
111
173
  const out = record.token_output;
112
174
 
@@ -5,6 +5,18 @@
5
5
  * config loaded via `owner-yaml.mjs` (D1). Pure at the `resolveSoul` level;
6
6
  * `loadAndResolveSoul` performs disk I/O.
7
7
  *
8
+ * ── NO RUNTIME CALLER — read this before assuming a soul.md is resolved ──────
9
+ *
10
+ * Nothing in `scripts/`, `hooks/`, or any skill body calls either export. Skill
11
+ * bodies instruct the coordinator to read soul.md DIRECTLY, so whatever is in
12
+ * the file on disk is what the coordinator sees — an unsubstituted `{{slot}}`
13
+ * reaches the model verbatim and instructs nothing. `skills/session-start/soul.md`
14
+ * is therefore authored pre-resolved: it carries no slots, and the operator's
15
+ * `efficiency.output-level` selects one of its literal `### output-level: <value>`
16
+ * blocks (see that file's § Output Levels; the skill body performs the lookup).
17
+ * Slots that remain in other soul.md files are inert for the same reason.
18
+ * Do not add a slot to a soul.md expecting substitution — wire a caller first.
19
+ *
8
20
  * ── Slot syntax ──────────────────────────────────────────────────────────────
9
21
  *
10
22
  * {{owner.language}} → 'de' | 'en'
@@ -24,8 +24,9 @@ import { updateFrontmatterFields, writeStateMd } from './frontmatter-mutators.mj
24
24
  * STATE.md files). Returns `[]` when the key is present but the value is an empty
25
25
  * array. Returns the array of entries when present and non-empty.
26
26
  *
27
- * Does NOT validate individual entry shapes callers that need schema validation
28
- * should use `validateMissionStatusEntry` from mission-status-schema.mjs.
27
+ * Does NOT validate individual entry shapes, and no helper in this repo does:
28
+ * entry shape and the `status` enum are coordinator convention, deliberately not a
29
+ * mechanical gate (see `syncFrontmatterMissionStatus` below for why).
29
30
  *
30
31
  * @param {object} frontmatter
31
32
  * @returns {object[]|null}
@@ -55,9 +56,9 @@ export function parseMissionStatus(frontmatter) {
55
56
  * - Works on string input (pure — no file I/O). Returns the updated STATE.md contents.
56
57
  * - No-ops if `contents` has no parseable frontmatter (returns input unchanged).
57
58
  *
58
- * Individual entry objects must conform to the shape validated by
59
- * `validateMissionStatusEntry` in mission-status-schema.mjs, but this function does
60
- * NOT enforce that constraint — callers are responsible for pre-validation.
59
+ * Individual entry objects are expected to carry `{ id, task, wave, status }`, but
60
+ * this function does NOT enforce that shape and no validator in this repo does
61
+ * callers own the invariant.
61
62
  *
62
63
  * @param {string} contents
63
64
  * @param {object[]|null|undefined} missionStatusArray
@@ -85,16 +86,24 @@ export function writeMissionStatus(contents, missionStatusArray) {
85
86
  * UPDATE-ONLY by design: when the key is absent, is not an array, or holds no entry
86
87
  * with a matching `id`, the frontmatter is returned unchanged. It is deliberately
87
88
  * neither created nor an error, because `setMissionStatus(contents, taskId, status)`
88
- * knows only `id` and `status` — it lacks the `task` and `wave` fields that
89
- * `validateMissionStatusEntry` (mission-status-schema.mjs) requires, so a synthesised
90
- * entry would be schema-invalid yet look authoritative to frontmatter consumers such
91
- * as `vault-status/narrative-mirror.mjs`. Throwing is likewise excluded by the
92
- * never-throw contract of `setMissionStatus`.
89
+ * knows only `id` and `status` — it lacks the `task` and `wave` fields a full entry
90
+ * carries, so a synthesised entry would be shape-invalid yet look authoritative to
91
+ * frontmatter consumers such as `vault-status/narrative-mirror.mjs`. Throwing is
92
+ * likewise excluded by the never-throw contract of `setMissionStatus`.
93
93
  *
94
94
  * `status` is mirrored verbatim without an enum check on purpose: gating it would
95
95
  * reintroduce the exact divergence (body says X, frontmatter says Y) this sync exists
96
- * to remove. An out-of-enum value now lands on BOTH surfaces, where the repo's own
97
- * validator can see it.
96
+ * to remove. An out-of-enum value therefore lands visibly on BOTH surfaces rather than
97
+ * being silently rejected on one.
98
+ *
99
+ * There is deliberately NO transition validator behind this. A `mission-status-schema.mjs`
100
+ * once existed (#340) offering `isValidMissionStatusTransition`; it was never wired and
101
+ * was removed, because the only guard form it enabled — read the current status, reject a
102
+ * disallowed transition — would reject legitimate live writes. Measured against a copy of
103
+ * this repo's own STATE.md carrying 24 items: 12 had no body entry yet (current status
104
+ * reads `null`, so every transition out of them is "invalid"), and the routine
105
+ * `in-dev` → `completed` write is not in the strict forward chain either — 18 of 24
106
+ * writes would have been refused. The enum is a vocabulary, not a state machine.
98
107
  *
99
108
  * @param {object} frontmatter
100
109
  * @param {string} taskId
@@ -18,25 +18,52 @@
18
18
  import { appendFileSync, mkdirSync, existsSync } from 'node:fs';
19
19
  import path from 'node:path';
20
20
 
21
- const EVENTS_PATH = '.orchestrator/metrics/events.jsonl';
21
+ import { findProjectRoot } from '../common.mjs';
22
22
 
23
23
  /**
24
- * Emit a single tmux-layout event to events.jsonl.
24
+ * Path FRAGMENT joined against a resolved repo root at write time — NOT a
25
+ * relative path constant. A relative constant resolves against process.cwd(),
26
+ * which is how ~8k test-emitted tmux events landed in the real ledger: the
27
+ * suite spawns scripts/tmux-layout.mjs with cwd = repo root, so every
28
+ * telemetry write went straight into production telemetry.
29
+ * Same shape as scripts/lib/session-close-backfill.mjs § EVENTS_REL.
30
+ */
31
+ const EVENTS_REL = ['.orchestrator', 'metrics', 'events.jsonl'];
32
+
33
+ /**
34
+ * True when this process is a vitest run, or a child spawned by one
35
+ * (vitest sets VITEST=true and the child inherits process.env).
36
+ * @returns {boolean}
37
+ */
38
+ function isTestRunner() {
39
+ return Boolean(process.env.VITEST) || process.env.VITEST_WORKER_ID !== undefined;
40
+ }
41
+
42
+ /**
43
+ * Emit a single tmux-layout event to <repoRoot>/.orchestrator/metrics/events.jsonl.
25
44
  * Best-effort — never throws (telemetry must not block the layout itself).
26
45
  *
46
+ * Under a test runner an emit WITHOUT an explicit `repoRoot` is dropped: a test
47
+ * process has no business appending to a real ledger, and telemetry is
48
+ * best-effort by contract, so dropping is the correct degradation. Tests that
49
+ * assert on the write pass `repoRoot` and get the full write path.
50
+ *
27
51
  * @param {string} eventType - 'tmux-layout.invoked' | 'tmux-layout.degraded' | 'tmux-layout.completed'
28
52
  * @param {object} [payload] - additional fields (layout, duration_ms, reason, etc.)
53
+ * @param {{ repoRoot?: string }} [opts] - repoRoot the ledger is resolved against (default: findProjectRoot())
29
54
  */
30
- export function emit(eventType, payload = {}) {
55
+ export function emit(eventType, payload = {}, { repoRoot } = {}) {
31
56
  try {
32
- const dir = path.dirname(EVENTS_PATH);
57
+ if (!repoRoot && isTestRunner()) return;
58
+ const eventsPath = path.join(repoRoot || findProjectRoot(), ...EVENTS_REL);
59
+ const dir = path.dirname(eventsPath);
33
60
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
34
61
  const record = {
35
62
  event: eventType,
36
63
  timestamp: new Date().toISOString(),
37
64
  ...payload,
38
65
  };
39
- appendFileSync(EVENTS_PATH, JSON.stringify(record) + '\n');
66
+ appendFileSync(eventsPath, JSON.stringify(record) + '\n');
40
67
  } catch {
41
68
  // Best-effort — swallow all errors. Telemetry must not block layout.
42
69
  }
@@ -45,18 +72,24 @@ export function emit(eventType, payload = {}) {
45
72
  /**
46
73
  * Wrap a layout function with telemetry. Emits invoked → completed/degraded.
47
74
  *
75
+ * The repoRoot is taken ONLY from this explicit option — never derived from the
76
+ * wrapped call's own `projectRoot` argument. Deriving it would hand the spawned
77
+ * CLI an explicit root inside the test suite and re-open the exact
78
+ * production-ledger contamination path the emit() guard closes.
79
+ *
48
80
  * @param {string} layoutName - 'default' | 'debug'
49
81
  * @param {Function} fn - async function returning { ok, oneliner, panes, degraded, attachCommand, error? }
82
+ * @param {{ repoRoot?: string }} [opts] - repoRoot the ledger is resolved against (default: findProjectRoot())
50
83
  * @returns {Function} wrapped function with same signature
51
84
  * @throws {TypeError} synchronously when fn is not a function
52
85
  */
53
- export function withTelemetry(layoutName, fn) {
86
+ export function withTelemetry(layoutName, fn, { repoRoot } = {}) {
54
87
  if (typeof fn !== 'function') {
55
88
  throw new TypeError(`withTelemetry: fn must be a function (got ${typeof fn})`);
56
89
  }
57
90
  return async function telemetryWrapped(...args) {
58
91
  const startedAt = Date.now();
59
- emit('tmux-layout.invoked', { layout: layoutName });
92
+ emit('tmux-layout.invoked', { layout: layoutName }, { repoRoot });
60
93
  try {
61
94
  const result = await fn(...args);
62
95
  const durationMs = Date.now() - startedAt;
@@ -66,13 +99,13 @@ export function withTelemetry(layoutName, fn) {
66
99
  duration_ms: durationMs,
67
100
  panes: result.panes ?? null,
68
101
  degraded: result.degraded === true,
69
- });
102
+ }, { repoRoot });
70
103
  } else {
71
104
  emit('tmux-layout.degraded', {
72
105
  layout: layoutName,
73
106
  duration_ms: durationMs,
74
107
  reason: result?.error ?? 'unknown',
75
- });
108
+ }, { repoRoot });
76
109
  }
77
110
  return result;
78
111
  } catch (err) {
@@ -81,7 +114,7 @@ export function withTelemetry(layoutName, fn) {
81
114
  layout: layoutName,
82
115
  duration_ms: durationMs,
83
116
  reason: `exception: ${err?.message ?? String(err)}`,
84
- });
117
+ }, { repoRoot });
85
118
  throw err;
86
119
  }
87
120
  };
@@ -6,20 +6,116 @@
6
6
  * so all commands are wrapped in a `while true; sleep` loop per D5 findings.
7
7
  *
8
8
  * Issue #561 — ADR-0007 tmux-visualization substrate.
9
+ *
10
+ * ## The probed pane commands — SSOT for the lockstep test
11
+ *
12
+ * The two lines below are the argv this module emits, with the resolved repo
13
+ * spec written as `<spec>` and the `2>&1` redirect dropped. They are not a
14
+ * description of the commands: `tests/lib/tmux-layout/vcs-detector.test.mjs`
15
+ * reconstructs the same normalisation from `detectVcsCommand()` and asserts
16
+ * equality, so a flag added to the emitted command without a matching probe
17
+ * here turns that test RED. See § Why a probe line, not prose.
18
+ *
19
+ * PROBE 2026-08-14 glab 1.91.0: glab ci status -R <spec> --output json
20
+ * PROBE 2026-08-14 gh 2.86.0: gh pr checks --watch
21
+ *
22
+ * ## Why `glab ci status` carries no pipeline selector (#1022)
23
+ *
24
+ * The glab pane emitted `--pipeline-id LATEST` until 2026-08-14. That flag does
25
+ * not exist on any `glab ci` subcommand, and `glab ci status --help` (1.91.0)
26
+ * lists only `-b --branch`, `-c --compact`, `-l --live`, `-F --output`,
27
+ * `-R --repo`. Probed from a non-git cwd against this repo's own remote:
28
+ *
29
+ * with the flag → `ERROR Unknown flag: --pipeline-id.`, exit 1
30
+ * without it → exit 0, `{"jobs":[…],"pipeline":{…}}`
31
+ *
32
+ * So the flag was the sole cause and it has NO replacement: the argument-less
33
+ * form already means "the pipeline of the current branch" (glab's own help
34
+ * example). `--branch=<name>` is the only selector glab offers and is
35
+ * deliberately NOT used — it would freeze a render-time branch snapshot into a
36
+ * pane that must follow the operator's checkout, the same reason the gh pane
37
+ * takes no positional.
38
+ *
39
+ * The `jq` filter was dead by the same measurement and is fixed with it: the
40
+ * payload is an OBJECT keyed `jobs`/`pipeline`, so the old `.[]` raised
41
+ * `Cannot index array with string "name"` (jq exit 5) on every real response.
42
+ * `.jobs[]` returns one `<status> <name>` line per job. Because the whole
43
+ * pipeline is `… 2>&1 | jq … 2>/dev/null || echo …`, both failures were
44
+ * swallowed into the fallback text — the pane printed "glab not available or no
45
+ * pipeline" against a healthy pipeline, indistinguishable from a missing binary.
46
+ *
47
+ * ## Why only the glab pane carries `-R` (#971)
48
+ *
49
+ * The pane commands run in whatever cwd the operator pastes the one-liner into,
50
+ * so an omitted `-R`/`--repo` silently targets the ambient cwd remote. Both
51
+ * halves were probed against the installed binaries on 2026-08-14
52
+ * (glab 1.91.0 / gh 2.86.0) and they do NOT behave the same:
53
+ *
54
+ * - `glab ci status -R <spec> --output json` from a NON-git cwd → exit 0 with
55
+ * real pipeline JSON. The flag is applicable and makes the pane
56
+ * cwd-independent, so it is passed whenever `resolveRepoSpec()` resolves one.
57
+ * - `gh pr checks -R <spec>` with NO positional argument →
58
+ * `argument required when using the --repo flag` (exit non-zero, no output).
59
+ * gh selects "the PR of the current branch" ONLY in the argument-less form;
60
+ * `-R` is legal there only alongside a `<number> | <url> | <branch>`
61
+ * positional, which this pane deliberately does not have (the watched branch
62
+ * must follow the operator's checkout, and a render-time branch snapshot
63
+ * would go stale on the next `git switch`). Adding `-R` there would break the
64
+ * pane at runtime — the exact defect shape recorded in
65
+ * `scripts/lib/ci-status-banner.mjs` (`-R` on `gh repo view`, which rejects
66
+ * it). The gh pane therefore stays cwd-scoped BY MEASUREMENT, not by
67
+ * oversight.
68
+ *
69
+ * ## Why a probe line, not prose
70
+ *
71
+ * The #971 sweep added `-R` here and left `--pipeline-id` untouched, then wrote
72
+ * a header documenting a probe of `glab ci status -R <spec> --output json` —
73
+ * a command the pane never emitted. The measurement was real and cited for
74
+ * something it had not measured, so a dead flag rode through a green test.
75
+ * NAMED CEILING: the lockstep only proves that command and probe were changed
76
+ * together; it cannot prove the probe was re-executed. It buys the one step
77
+ * that was skipped — no flag change without touching the measurement record.
78
+ * Revisit if a pane ever needs a runtime-varying flag, which this shape cannot
79
+ * express.
80
+ */
81
+
82
+ import { resolveRepoSpec } from '../vcs-repo-spec.mjs';
83
+
84
+ /**
85
+ * Shell-safe spec shape. A git remote URL (`git@host:group/project.git`,
86
+ * `https://host/group/project.git`) and an `OWNER/REPO` spec are both fully
87
+ * inside this character class, so a matching value can be spliced into the pane
88
+ * command line without quoting. A value OUTSIDE it is DROPPED (the flag is
89
+ * omitted) rather than quoted — this mirrors `resolveRepoSpec`'s own contract
90
+ * that an unresolvable spec means "omit the flag entirely", never "emit a
91
+ * broken one", and keeps a hostile remote URL out of the pane's shell.
9
92
  */
93
+ const SHELL_SAFE_SPEC_RE = /^[A-Za-z0-9._:/@+-]+$/;
10
94
 
11
95
  /**
12
96
  * Resolve the CI-watch pane command based on Session Config vcs: key.
13
97
  *
14
- * @param {{ config?: object, projectRoot: string }} args
98
+ * @param {{
99
+ * config?: object,
100
+ * projectRoot: string,
101
+ * gitRun?: (args: string[]) => { ok: boolean, stdout: string, stderr: string }
102
+ * }} args
103
+ * - gitRun: optional `resolveRepoSpec` seam, so callers/tests can pin the
104
+ * resolved repo spec instead of spawning `git` against the real checkout.
15
105
  * @returns {{ bin: 'glab'|'gh'|null, command: string, fallback: string, blocking: boolean, platform: 'gitlab'|'github'|null }}
16
106
  * - command: the shell command for Pane 3 (wrapped in poll-loop since glab/gh are poll-based, not live-tail)
17
107
  * - fallback: shell command shown when bin is not available in PATH
18
108
  */
19
- export function detectVcsCommand({ config, projectRoot: _projectRoot }) {
109
+ export function detectVcsCommand({ config, projectRoot, gitRun }) {
20
110
  const vcs = config?.vcs; // 'gitlab' | 'github' | undefined
21
111
 
22
112
  if (vcs === 'gitlab') {
113
+ const spec = resolveRepoSpec({
114
+ repoRoot: projectRoot,
115
+ vcs: 'gitlab',
116
+ ...(typeof gitRun === 'function' ? { gitRun } : {}),
117
+ });
118
+ const repoFlag = spec && SHELL_SAFE_SPEC_RE.test(spec) ? ` -R ${spec}` : '';
23
119
  return {
24
120
  bin: 'glab',
25
121
  platform: 'gitlab',
@@ -29,8 +125,12 @@ export function detectVcsCommand({ config, projectRoot: _projectRoot }) {
29
125
  ' clear;',
30
126
  ' date;',
31
127
  " echo '--- glab ci status (refresh: 15s) ---';",
32
- ' glab ci status --pipeline-id LATEST --output json 2>&1',
33
- " | jq -r '.[] | \"\\(.status) \\(.name)\"' 2>/dev/null",
128
+ // No pipeline selector: `--pipeline-id` is not a glab flag (1.91.0
129
+ // rejects it outright) and the argument-less form already means "the
130
+ // current branch's pipeline". `.jobs[]`, not `.[]` — the payload is an
131
+ // object. Both measured 2026-08-14; see the module header.
132
+ ` glab ci status${repoFlag} --output json 2>&1`,
133
+ " | jq -r '.jobs[] | \"\\(.status) \\(.name)\"' 2>/dev/null",
34
134
  " || echo 'glab not available or no pipeline';",
35
135
  ' sleep 15;',
36
136
  'done',
@@ -55,6 +155,10 @@ export function detectVcsCommand({ config, projectRoot: _projectRoot }) {
55
155
  ' clear;',
56
156
  ' date;',
57
157
  " echo '--- gh pr checks --watch (will exit on PR completion) ---';",
158
+ // No `-R` here BY MEASUREMENT, not by oversight: `gh pr checks -R <spec>`
159
+ // without a positional PR/branch argument is rejected outright
160
+ // ("argument required when using the `--repo` flag", gh 2.86.0), and this
161
+ // pane must follow the operator's current branch. See the module header.
58
162
  ' gh pr checks --watch 2>&1',
59
163
  " || echo 'no PR in current branch';",
60
164
  ' sleep 15;',
@@ -7,7 +7,9 @@
7
7
  import { readFileSync, readdirSync, existsSync } from 'node:fs';
8
8
  import { join, resolve, dirname } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
+ import yaml from 'js-yaml';
10
11
  import { ALLOWED_MODEL_ALIASES, MODEL_ID_RE } from '../agent-frontmatter.mjs';
12
+ import { extractInitialFrontmatter } from './frontmatter-block.mjs';
11
13
 
12
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
13
15
 
@@ -57,10 +59,45 @@ function fail(msg) {
57
59
  /**
58
60
  * Extract YAML frontmatter content (between first --- and second ---).
59
61
  * Returns null if no valid frontmatter block found.
62
+ *
63
+ * Delegates to the shared extractor. This file carried a THIRD verbatim copy of
64
+ * that regex until now (check-skills.mjs and check-commands.mjs held the other
65
+ * two, which is why the extraction was lifted into frontmatter-block.mjs). Only
66
+ * the block-finding is shared — the field RULES stay per-gate and disagree on
67
+ * purpose; see the header of frontmatter-block.mjs.
68
+ *
69
+ * Checks 7-10 only need "text, or nothing"; Check 6 reads the shared
70
+ * diagnostic directly for a precise message.
60
71
  */
61
72
  function extractFrontmatter(content) {
62
- const m = content.match(/^---\n([\s\S]*?)\n---/);
63
- return m ? m[1] : null;
73
+ const extracted = extractInitialFrontmatter(content);
74
+ return extracted.ok ? extracted.yamlText : null;
75
+ }
76
+
77
+ /**
78
+ * Parse a frontmatter block with a real YAML parser (CORE_SCHEMA, matching
79
+ * check-commands.mjs and check-skills.mjs).
80
+ *
81
+ * @param {string} yamlText - the block's inner text, no fences
82
+ * @returns {{ ok: true, data: unknown } | { ok: false, message: string }}
83
+ * On failure, `message` is a caller-printable reason with a frontmatter-local
84
+ * line/column when js-yaml supplies one.
85
+ */
86
+ function parseFrontmatterYaml(yamlText) {
87
+ let data;
88
+ try {
89
+ data = yaml.load(yamlText, { schema: yaml.CORE_SCHEMA });
90
+ } catch (error) {
91
+ const reason = error?.reason ?? error?.message ?? String(error);
92
+ const location = error?.mark
93
+ ? ` at frontmatter line ${error.mark.line + 1}, column ${error.mark.column + 1}`
94
+ : '';
95
+ return { ok: false, message: `invalid YAML frontmatter: ${reason}${location}` };
96
+ }
97
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
98
+ return { ok: false, message: 'YAML frontmatter must be a non-null mapping/object' };
99
+ }
100
+ return { ok: true, data };
64
101
  }
65
102
 
66
103
  /**
@@ -146,17 +183,48 @@ const mdFiles = readdirSync(agentsDir).filter(isAgentDefFile);
146
183
  if (mdFiles.length === 0) {
147
184
  fail('agents directory is empty (no .md files)');
148
185
  } else {
186
+ let parseable = 0;
187
+
149
188
  for (const agentFile of mdFiles) {
150
189
  const agentName = agentFile;
151
190
  const filePath = join(agentsDir, agentFile);
152
191
  const content = readFileSync(filePath, 'utf8');
153
192
 
154
- const frontmatter = extractFrontmatter(content);
193
+ const extracted = extractInitialFrontmatter(content);
155
194
 
156
- if (!frontmatter) {
157
- fail(`${agentName}: missing YAML frontmatter`);
195
+ if (!extracted.ok) {
196
+ fail(`${agentName}: ${extracted.diagnostic}`);
158
197
  continue;
159
198
  }
199
+ const frontmatter = extracted.yamlText;
200
+
201
+ // ------------------------------------------------------------------
202
+ // The block is valid YAML.
203
+ //
204
+ // Every rule below reads the block with LINE REGEXES (getField/hasField),
205
+ // which model Claude Code's own lenient loader. That leniency is exactly
206
+ // how 14 of 16 agent definitions sat unparseable in the tree unnoticed: an
207
+ // unquoted single-line `description:` containing a `: ` (e.g.
208
+ // `<example>Context: /plan ...`) is not YAML, and a regex looking only for
209
+ // `^description:` sees nothing wrong. A ` #` inside such a value is worse
210
+ // still — it opens a YAML comment, so the block PARSES while silently
211
+ // truncating the description at that point.
212
+ //
213
+ // A parse failure is reported alone (no fall-through to the field rules):
214
+ // the block is broken at the root, and six regex verdicts stacked on top
215
+ // of it are cascade noise that buries the one defect worth fixing. Same
216
+ // posture as check-skills.mjs R8.
217
+ //
218
+ // ORTHOGONAL to the `description` block-scalar ban further down: this rule
219
+ // demands the block BE YAML; that one forbids one particular valid YAML
220
+ // form the agent loader cannot read. Neither implies the other.
221
+ // ------------------------------------------------------------------
222
+ const parsedFm = parseFrontmatterYaml(frontmatter);
223
+ if (!parsedFm.ok) {
224
+ fail(`${agentName}: ${parsedFm.message}`);
225
+ continue;
226
+ }
227
+ parseable++;
160
228
 
161
229
  // ------------------------------------------------------------------
162
230
  // Required fields check
@@ -236,6 +304,10 @@ if (mdFiles.length === 0) {
236
304
  }
237
305
  }
238
306
  }
307
+
308
+ if (parseable === mdFiles.length) {
309
+ pass(`all ${mdFiles.length} agent frontmatter blocks parse as YAML mappings`);
310
+ }
239
311
  }
240
312
 
241
313
  // ============================================================================