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,40 @@
16
16
  import { parseStateMd, serializeStateMd } from './yaml-parser.mjs';
17
17
  import { updateFrontmatterFields, writeStateMd } from './frontmatter-mutators.mjs';
18
18
 
19
+ const MISSION_STATUS_HEADING_RE = /^##\s+Mission Status\s*$/;
20
+ const WRITER_TIMESTAMP_SOURCE = '\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z';
21
+ const WRITER_TIMESTAMP_RE = new RegExp(`^${WRITER_TIMESTAMP_SOURCE}$`);
22
+ const CANONICAL_MISSION_STATUS_ENTRY_RE = new RegExp(
23
+ `^- ([a-z][a-z0-9]*(?:-[a-z0-9]+)*-\\d+): (.*) \\(updated (${WRITER_TIMESTAMP_SOURCE})\\)$`
24
+ );
25
+
26
+ /**
27
+ * Finds the first exact `## Mission Status` section and its closing heading.
28
+ *
29
+ * @param {string[]} lines
30
+ * @returns {{ headingIdx: number, sectionEnd: number }|null}
31
+ */
32
+ function findMissionStatusSection(lines) {
33
+ if (!Array.isArray(lines)) return null;
34
+ const headingIdx = lines.findIndex((line) => MISSION_STATUS_HEADING_RE.test(line));
35
+ if (headingIdx === -1) return null;
36
+
37
+ let sectionEnd = lines.length;
38
+ for (let i = headingIdx + 1; i < lines.length; i++) {
39
+ if (/^##\s+/.test(lines[i])) {
40
+ sectionEnd = i;
41
+ break;
42
+ }
43
+ }
44
+ return { headingIdx, sectionEnd };
45
+ }
46
+
47
+ function isWriterTimestamp(timestamp) {
48
+ if (!WRITER_TIMESTAMP_RE.test(timestamp)) return false;
49
+ const parsed = new Date(timestamp);
50
+ return !Number.isNaN(parsed.getTime()) && parsed.toISOString() === timestamp;
51
+ }
52
+
19
53
  /**
20
54
  * Parses the optional `mission-status:` block from a STATE.md frontmatter object
21
55
  * (as returned by `parseStateMd(...).frontmatter`).
@@ -24,8 +58,9 @@ import { updateFrontmatterFields, writeStateMd } from './frontmatter-mutators.mj
24
58
  * STATE.md files). Returns `[]` when the key is present but the value is an empty
25
59
  * array. Returns the array of entries when present and non-empty.
26
60
  *
27
- * Does NOT validate individual entry shapes callers that need schema validation
28
- * should use `validateMissionStatusEntry` from mission-status-schema.mjs.
61
+ * Does NOT validate individual entry shapes, and no helper in this repo does:
62
+ * entry shape and the `status` enum are coordinator convention, deliberately not a
63
+ * mechanical gate (see `syncFrontmatterMissionStatus` below for why).
29
64
  *
30
65
  * @param {object} frontmatter
31
66
  * @returns {object[]|null}
@@ -55,9 +90,9 @@ export function parseMissionStatus(frontmatter) {
55
90
  * - Works on string input (pure — no file I/O). Returns the updated STATE.md contents.
56
91
  * - No-ops if `contents` has no parseable frontmatter (returns input unchanged).
57
92
  *
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.
93
+ * Individual entry objects are expected to carry `{ id, task, wave, status }`, but
94
+ * this function does NOT enforce that shape and no validator in this repo does
95
+ * callers own the invariant.
61
96
  *
62
97
  * @param {string} contents
63
98
  * @param {object[]|null|undefined} missionStatusArray
@@ -82,19 +117,31 @@ export function writeMissionStatus(contents, missionStatusArray) {
82
117
  * entry level — the input is never mutated, which keeps `parseMissionStatus`'s
83
118
  * shallow-copy contract intact for anything else holding the same nested entries).
84
119
  *
85
- * UPDATE-ONLY by design: when the key is absent, is not an array, or holds no entry
86
- * with a matching `id`, the frontmatter is returned unchanged. It is deliberately
87
- * 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`.
120
+ * UPDATE-ONLY by design: when the key is absent, is not an array, or holds no
121
+ * matching entry in a populated array, the frontmatter is returned unchanged. It is
122
+ * deliberately neither created nor an error, because `setMissionStatus(contents,
123
+ * taskId, status)` knows only `id` and `status` — it lacks the `task` and `wave`
124
+ * fields a full entry carries, so a synthesised entry would be shape-invalid yet look
125
+ * authoritative to frontmatter consumers such as `vault-status/narrative-mirror.mjs`.
126
+ * Throwing is likewise excluded by the never-throw contract of `setMissionStatus`.
127
+ *
128
+ * An empty array is recovered from the final body by
129
+ * `recoverFrontmatterMissionStatus`, which persists only the truthful `id` and
130
+ * `status` values available there. It does not fabricate absent metadata.
93
131
  *
94
132
  * `status` is mirrored verbatim without an enum check on purpose: gating it would
95
133
  * 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.
134
+ * to remove. An out-of-enum value therefore lands visibly on BOTH surfaces rather than
135
+ * being silently rejected on one.
136
+ *
137
+ * There is deliberately NO transition validator behind this. A `mission-status-schema.mjs`
138
+ * once existed (#340) offering `isValidMissionStatusTransition`; it was never wired and
139
+ * was removed, because the only guard form it enabled — read the current status, reject a
140
+ * disallowed transition — would reject legitimate live writes. Measured against a copy of
141
+ * this repo's own STATE.md carrying 24 items: 12 had no body entry yet (current status
142
+ * reads `null`, so every transition out of them is "invalid"), and the routine
143
+ * `in-dev` → `completed` write is not in the strict forward chain either — 18 of 24
144
+ * writes would have been refused. The enum is a vocabulary, not a state machine.
98
145
  *
99
146
  * @param {object} frontmatter
100
147
  * @param {string} taskId
@@ -116,6 +163,80 @@ function syncFrontmatterMissionStatus(frontmatter, taskId, status) {
116
163
  return { ...frontmatter, 'mission-status': entries };
117
164
  }
118
165
 
166
+ /**
167
+ * Mirrors body-only task IDs into the frontmatter `mission-status` registry as
168
+ * partial `{ id, status }` entries. This is a SUPERSET merge: existing entries are
169
+ * never rewritten or reordered, so their full metadata (`task`, `wave`) survives —
170
+ * only IDs absent from the registry are appended.
171
+ *
172
+ * It must not be gated on an EMPTY registry. Recovering only from empty froze the
173
+ * registry after its first recovery: the sync path (`syncFrontmatterMissionStatus`)
174
+ * is update-only, so every subsequently added task ID was never mirrored again, and
175
+ * Phase 1.9/1.10 read a plausible undercount instead of the obvious zero. That is
176
+ * #1084 one write later — measured m-1/m-2/m-3 in the body against `[m-1]` in the
177
+ * frontmatter.
178
+ *
179
+ * The section parse stays strict and all-or-nothing: any nonblank line that is not a
180
+ * unique canonical writer bullet (unsafe ID, pipe, malformed timestamp, duplicate,
181
+ * prose) aborts the whole merge rather than fabricating entries from an ambiguous
182
+ * body. Known limitation: one foreign-but-writer-accepted ID (e.g. `Docs_2`) or a
183
+ * hand-written legacy bullet without a timestamp therefore suppresses the merge for
184
+ * the whole file, silently.
185
+ *
186
+ * @param {object} frontmatter
187
+ * @param {string} body
188
+ * @returns {object}
189
+ */
190
+ function recoverFrontmatterMissionStatus(frontmatter, body) {
191
+ if (frontmatter === null || typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
192
+ return frontmatter;
193
+ }
194
+ const raw = frontmatter['mission-status'];
195
+ if (!Array.isArray(raw) || typeof body !== 'string') return frontmatter;
196
+
197
+ const lines = body.split('\n');
198
+ const section = findMissionStatusSection(lines);
199
+ if (section === null) return frontmatter;
200
+
201
+ const entries = [];
202
+ const ids = new Set();
203
+ for (let i = section.headingIdx + 1; i < section.sectionEnd; i++) {
204
+ const line = lines[i];
205
+ if (line.trim() === '') continue;
206
+
207
+ const match = CANONICAL_MISSION_STATUS_ENTRY_RE.exec(line);
208
+ if (match === null) return frontmatter;
209
+ const [, id, status, timestamp] = match;
210
+ if (
211
+ status.length === 0 ||
212
+ status.includes('|') ||
213
+ status.includes('\n') ||
214
+ !isWriterTimestamp(timestamp) ||
215
+ ids.has(id)
216
+ ) {
217
+ return frontmatter;
218
+ }
219
+ ids.add(id);
220
+ entries.push({ id, status });
221
+ }
222
+
223
+ const known = new Set(
224
+ raw
225
+ .filter((e) => e !== null && typeof e === 'object' && !Array.isArray(e))
226
+ .map((e) => e.id)
227
+ );
228
+ const added = entries.filter((e) => !known.has(e.id));
229
+ if (added.length === 0) return frontmatter;
230
+ return { ...frontmatter, 'mission-status': [...raw, ...added] };
231
+ }
232
+
233
+ function serializeMissionStatusUpdate(frontmatter, body) {
234
+ return serializeStateMd({
235
+ frontmatter: recoverFrontmatterMissionStatus(frontmatter, body),
236
+ body,
237
+ });
238
+ }
239
+
119
240
  /**
120
241
  * Sets (or updates) the mission status for a single task in the `## Mission Status` body
121
242
  * section of STATE.md. Creates the section if it does not exist.
@@ -128,7 +249,10 @@ function syncFrontmatterMissionStatus(frontmatter, taskId, status) {
128
249
  * during a wave; the frontmatter array is what `parseMissionStatus` consumers read
129
250
  * (`vault-status/narrative-mirror.mjs`, session-end Phase 1.9/1.10). Before this sync the
130
251
  * live writer and the reader sat on different surfaces and drifted apart in both
131
- * directions. Frontmatter mirroring is UPDATE-ONLY see `syncFrontmatterMissionStatus`.
252
+ * directions. A legacy empty registry is recovered from canonical body bullets as
253
+ * partial `{ id, status }` entries, which lets frontmatter readers classify the work
254
+ * without fabricated metadata. Ambiguous legacy bodies remain empty; other unmatched
255
+ * populated entries remain update-only. See `syncFrontmatterMissionStatus`.
132
256
  *
133
257
  * Pure function — no I/O. Returns original `contents` unchanged on bad input.
134
258
  *
@@ -151,31 +275,16 @@ export function setMissionStatus(contents, taskId, status) {
151
275
  const bullet = `- ${taskId}: ${status} (updated ${timestamp})`;
152
276
  const lines = parsed.body.split('\n');
153
277
 
154
- // Find existing ## Mission Status section
155
- let headingIdx = -1;
156
- for (let i = 0; i < lines.length; i++) {
157
- if (/^##\s+Mission Status\b/.test(lines[i])) {
158
- headingIdx = i;
159
- break;
160
- }
161
- }
162
-
163
- if (headingIdx === -1) {
278
+ const section = findMissionStatusSection(lines);
279
+ if (section === null) {
164
280
  // Section does not exist — append it at the end
165
281
  let bodyOut = parsed.body;
166
282
  if (!bodyOut.endsWith('\n')) bodyOut += '\n';
167
283
  bodyOut += `\n## Mission Status\n\n${bullet}\n`;
168
- return serializeStateMd({ frontmatter, body: bodyOut });
284
+ return serializeMissionStatusUpdate(frontmatter, bodyOut);
169
285
  }
170
286
 
171
- // Find end of section: next ## heading or end of lines
172
- let sectionEnd = lines.length;
173
- for (let i = headingIdx + 1; i < lines.length; i++) {
174
- if (/^##\s+/.test(lines[i])) {
175
- sectionEnd = i;
176
- break;
177
- }
178
- }
287
+ const { headingIdx, sectionEnd } = section;
179
288
 
180
289
  // Look for an existing entry with this taskId within the section
181
290
  const entryRe = new RegExp(`^-\\s+${taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:`);
@@ -205,18 +314,20 @@ export function setMissionStatus(contents, taskId, status) {
205
314
  const before = lines.slice(0, headingIdx + 1);
206
315
  const after = lines.slice(insertAt);
207
316
  const rebuilt = [...before, '', bullet, ...after];
208
- return serializeStateMd({ frontmatter, body: rebuilt.join('\n') });
317
+ return serializeMissionStatusUpdate(frontmatter, rebuilt.join('\n'));
209
318
  }
210
319
  }
211
320
 
212
- return serializeStateMd({ frontmatter, body: lines.join('\n') });
321
+ return serializeMissionStatusUpdate(frontmatter, lines.join('\n'));
213
322
  }
214
323
 
215
324
  /**
216
325
  * Reads the current mission status for a single task from the `## Mission Status` body
217
326
  * section of STATE.md.
218
327
  *
219
- * Returns the status string (e.g. `'in-dev'`) or `null` if the task is not found or the
328
+ * Returns the full status string before a current-writer timestamp (e.g. `'in-dev'`
329
+ * or `'needs manual testing'`). For legacy body lines that lack that exact form, falls
330
+ * back to the first status token. Returns `null` if the task is not found or the
220
331
  * section does not exist. Never throws — returns `null` on any bad input.
221
332
  *
222
333
  * @param {string} contents - Current STATE.md file contents (string)
@@ -230,29 +341,24 @@ export function readMissionStatus(contents, taskId) {
230
341
  if (parsed === null) return null;
231
342
 
232
343
  const lines = parsed.body.split('\n');
233
- let headingIdx = -1;
234
- for (let i = 0; i < lines.length; i++) {
235
- if (/^##\s+Mission Status\b/.test(lines[i])) {
236
- headingIdx = i;
237
- break;
238
- }
239
- }
240
- if (headingIdx === -1) return null;
344
+ const section = findMissionStatusSection(lines);
345
+ if (section === null) return null;
241
346
 
242
- let sectionEnd = lines.length;
243
- for (let i = headingIdx + 1; i < lines.length; i++) {
244
- if (/^##\s+/.test(lines[i])) {
245
- sectionEnd = i;
246
- break;
247
- }
347
+ // Prefer the full current-writer status, including internal spaces.
348
+ const escapedId = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
349
+ const writerEntryRe = new RegExp(
350
+ `^- ${escapedId}: (.*) \\(updated ${WRITER_TIMESTAMP_SOURCE}\\)$`
351
+ );
352
+ for (let i = section.headingIdx + 1; i < section.sectionEnd; i++) {
353
+ const match = writerEntryRe.exec(lines[i]);
354
+ if (match) return match[1];
248
355
  }
249
356
 
250
- // Match: - <taskId>: <status> (updated ...)
251
- const escapedId = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
252
- const entryRe = new RegExp(`^-\\s+${escapedId}:\\s+(\\S+)`);
253
- for (let i = headingIdx + 1; i < sectionEnd; i++) {
254
- const m = entryRe.exec(lines[i]);
255
- if (m) return m[1];
357
+ // Reader-only legacy compatibility. Recovery remains canonical and all-or-nothing.
358
+ const legacyEntryRe = new RegExp(`^-\\s+${escapedId}:\\s+(\\S+)`);
359
+ for (let i = section.headingIdx + 1; i < section.sectionEnd; i++) {
360
+ const match = legacyEntryRe.exec(lines[i]);
361
+ if (match) return match[1];
256
362
  }
257
363
  return null;
258
364
  }
@@ -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
  // ============================================================================