session-orchestrator 3.20.0 → 3.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +10 -8
  5. package/CHANGELOG.md +414 -0
  6. package/README.md +16 -11
  7. package/agents/analyst.md +1 -1
  8. package/agents/architect-reviewer.md +1 -1
  9. package/agents/code-implementer.md +4 -2
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/dialectic-deriver.md +1 -1
  12. package/agents/docs-writer.md +1 -1
  13. package/agents/memory-proposal-collector.md +1 -1
  14. package/agents/qa-strategist.md +1 -1
  15. package/agents/security-reviewer.md +1 -1
  16. package/agents/session-reviewer.md +42 -1
  17. package/agents/skill-applied-judge.md +1 -1
  18. package/agents/test-writer.md +1 -1
  19. package/agents/ui-developer.md +1 -1
  20. package/agents/ux-evaluator.md +1 -1
  21. package/commands/release.md +60 -0
  22. package/docs/migration-v3.md +9 -6
  23. package/docs/persona-panel.md +3 -1
  24. package/docs/scope-collision-guard.md +167 -0
  25. package/hooks/enforce-scope.mjs +103 -3
  26. package/hooks/hooks-codex.json +1 -1
  27. package/hooks/hooks.json +11 -1
  28. package/hooks/on-session-end.mjs +52 -5
  29. package/hooks/on-stop.mjs +127 -12
  30. package/hooks/post-bash-write-verify.mjs +8 -32
  31. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  32. package/package.json +1 -1
  33. package/pi/prompts/release.md +12 -0
  34. package/scripts/autopilot.mjs +3 -1
  35. package/scripts/emit-session.mjs +42 -0
  36. package/scripts/export-hw-learnings.mjs +61 -2
  37. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  38. package/scripts/lib/backlog-scan.mjs +106 -15
  39. package/scripts/lib/build-live-signals.mjs +7 -3
  40. package/scripts/lib/ci-status-banner.mjs +207 -23
  41. package/scripts/lib/git-config-drift.mjs +471 -0
  42. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  43. package/scripts/lib/io.mjs +193 -7
  44. package/scripts/lib/learnings/select.mjs +157 -3
  45. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  46. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  47. package/scripts/lib/named-vault-resolver.mjs +105 -16
  48. package/scripts/lib/peer-cards/schema.mjs +6 -2
  49. package/scripts/lib/reconcile/writer.mjs +69 -30
  50. package/scripts/lib/redact-spans.mjs +89 -0
  51. package/scripts/lib/scope-baseline.mjs +77 -17
  52. package/scripts/lib/scope-gate.mjs +658 -0
  53. package/scripts/lib/secret-masker.mjs +262 -0
  54. package/scripts/lib/session-lock.mjs +34 -10
  55. package/scripts/lib/session-registry.mjs +9 -1
  56. package/scripts/lib/state-md/mission-status.mjs +21 -12
  57. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  58. package/scripts/lib/validate/check-agents.mjs +77 -5
  59. package/scripts/lib/validate/check-commands.mjs +2 -20
  60. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  61. package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
  62. package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
  63. package/scripts/lib/validate/check-skills.mjs +191 -0
  64. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  65. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  66. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  67. package/scripts/lib/validate/check-unwired-features.mjs +219 -11
  68. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  69. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  70. package/scripts/lib/validate/tier-inference.mjs +46 -8
  71. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  72. package/scripts/lib/vault-mirror/process.mjs +264 -31
  73. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  74. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  75. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  76. package/scripts/lib/vcs-repo-spec.mjs +500 -19
  77. package/scripts/print-learnings-index.mjs +30 -3
  78. package/scripts/release.mjs +616 -61
  79. package/scripts/site-numbers.mjs +1049 -0
  80. package/scripts/validate-plugin.mjs +61 -0
  81. package/scripts/validate-wave-scope.mjs +281 -12
  82. package/scripts/vault-mirror.mjs +26 -1
  83. package/skills/_shared/monitor-patterns.md +24 -4
  84. package/skills/_shared/state-ownership.md +17 -0
  85. package/skills/claude-md-drift-check/SKILL.md +9 -1
  86. package/skills/debug/SKILL.md +4 -1
  87. package/skills/discovery/issue-templates.md +4 -4
  88. package/skills/discovery/probes-code.md +2 -2
  89. package/skills/discovery/probes-feature.md +6 -6
  90. package/skills/discovery/probes-infra.md +2 -2
  91. package/skills/discovery/probes-session.md +5 -5
  92. package/skills/dispatcher/SKILL.md +10 -1
  93. package/skills/frontmatter-guard/SKILL.md +9 -1
  94. package/skills/gitlab-ops/SKILL.md +49 -39
  95. package/skills/gitlab-portfolio/SKILL.md +10 -1
  96. package/skills/memory-cleanup/SKILL.md +18 -5
  97. package/skills/npm-publish/SKILL.md +22 -50
  98. package/skills/persona-panel/SKILL.md +3 -1
  99. package/skills/plan/mode-new.md +23 -5
  100. package/skills/repo-audit/SKILL.md +10 -1
  101. package/skills/session-end/SKILL.md +44 -2
  102. package/skills/session-end/metrics-collection.md +1 -1
  103. package/skills/session-end/session-metrics-write.md +4 -10
  104. package/skills/session-plan/SKILL.md +2 -2
  105. package/skills/session-plan/wave-template.md +1 -1
  106. package/skills/session-start/SKILL.md +13 -1
  107. package/skills/spinout/SKILL.md +5 -1
  108. package/skills/sunset-review/SKILL.md +11 -1
  109. package/skills/tmux-layout/SKILL.md +7 -2
  110. package/skills/vault-mirror/SKILL.md +10 -1
  111. package/skills/vault-sync/SKILL.md +10 -1
  112. package/skills/vault-sync/validator.mjs +55 -6
  113. package/skills/wave-executor/wave-loop.md +42 -12
  114. package/scripts/lib/mission-status-schema.mjs +0 -114
@@ -0,0 +1,262 @@
1
+ /**
2
+ * secret-masker.mjs — value-based secret masking over arbitrary log text (#974).
3
+ *
4
+ * Builds a masking function from an env-like object: every value that survives
5
+ * three filters becomes a NEEDLE, and every occurrence of a needle in the input
6
+ * text is replaced with the repo-wide `[REDACTED]` marker.
7
+ *
8
+ * Pure and synchronous — no I/O, no module state, no dependencies beyond the
9
+ * sibling `redact-spans.mjs` primitive — because hot-path consumers import it.
10
+ * This module deliberately does NOT wire itself into any consumer; wiring is a
11
+ * separate concern (and a separate wave).
12
+ *
13
+ * ---------------------------------------------------------------------------
14
+ * THE NEEDLE SET IS A FUNCTION OF THE CALLER'S ENV — AND THAT IS NOT A DEFECT
15
+ * ---------------------------------------------------------------------------
16
+ * Two runs of the same consumer over the same records mask DIFFERENTLY when the
17
+ * env differs between them (#1025): with `FOO_TOKEN` set, its value becomes
18
+ * `[REDACTED]`; without it, the same text passes through verbatim. Consumers that
19
+ * compare a previously-written artifact against a freshly-rendered candidate must
20
+ * therefore treat an already-redacted span as a WILDCARD, or a later
21
+ * partially-populated run re-writes the raw value it had already redacted (see
22
+ * `matchesModuloRedaction` in `scripts/lib/vault-mirror/process.mjs`).
23
+ *
24
+ * The tempting fix — persist the needle set so a later run can mask without the
25
+ * env — is REJECTED: it breaks the purity contract in the paragraph above and
26
+ * puts a plaintext secrets file on disk to defend against secrets on disk. The
27
+ * env dependency stays; consumers compensate.
28
+ *
29
+ * ---------------------------------------------------------------------------
30
+ * WHY THE FILTER ORDER IS ALLOWLIST → KEY-NAME → LENGTH, AND NOT LENGTH FIRST
31
+ * ---------------------------------------------------------------------------
32
+ * A "mask every env value longer than N characters" masker is the obvious
33
+ * design and it is measurably WRONG. Discovery measured 40 env values of ≥8
34
+ * characters on the development host against 5.2 MB of real log text; six of
35
+ * them collide with ordinary log content:
36
+ *
37
+ * len 21 → 157 corpus hits (USER, LOGNAME)
38
+ * len 28 → 157 corpus hits (HOME)
39
+ * len 58 → 154 corpus hits (PWD, OLDPWD)
40
+ * len 36 → 39 corpus hits (CLAUDE_CODE_SESSION_ID)
41
+ *
42
+ * The shortest REAL secret on that host is 32 characters; the longest collider
43
+ * is 58. The bands overlap completely: any threshold ≤58 admits PWD (154
44
+ * mis-maskings), any threshold >58 discards three real secrets (32/50/51). There
45
+ * is no separating value — length is provably not a discriminating feature.
46
+ * Length therefore runs LAST, purely as a guard against degenerate short values
47
+ * (`X_KEY=1`), never as the primary filter.
48
+ *
49
+ * ---------------------------------------------------------------------------
50
+ * CLAUDE_CODE_SESSION_ID — the sharpest boundary case, and why it is NOT
51
+ * allowlisted
52
+ * ---------------------------------------------------------------------------
53
+ * It is 36 characters with 39 corpus hits: length-indistinguishable from a
54
+ * 32-character token, yet a legitimate correlation key that appears in ordinary
55
+ * log lines. Masking it would destroy the log's correlation value; not masking
56
+ * it costs nothing, because a session id is not a credential (the credential
57
+ * beside it is CLAUDE_CODE_MESSAGING_TOKEN, which DOES qualify via `_TOKEN`).
58
+ *
59
+ * The decision is to leave it OUT of the allowlist and let filter 2 exclude it
60
+ * structurally: it ends in `_ID`, which the key-name heuristic does not match,
61
+ * so it can never become a needle. An allowlist entry would be redundant today
62
+ * AND silent tomorrow — if someone later widens the heuristic to `_ID`, an
63
+ * allowlist entry would quietly absorb the change, whereas the pinned test
64
+ * (`tests/lib/secret-masker.test.mjs`) goes RED and forces the decision to be
65
+ * taken again. The louder mechanism wins.
66
+ *
67
+ * ---------------------------------------------------------------------------
68
+ * NEEDLE SET IS `{raw}` ONLY — no base64, no percent-encoding
69
+ * ---------------------------------------------------------------------------
70
+ * Encoded variants are theoretical in this repo: the only encoders encode FILE
71
+ * PATHS (`scripts/lib/fetch-baseline.mjs`, `scripts/lib/vault-backfill/glab.mjs`)
72
+ * or HOOK SOURCE (`hooks/_lib/guard-source-loader.mjs`) — never credentials. And
73
+ * typical token alphabets are URI-unreserved, so percent-encoding would be the
74
+ * identity transform for them anyway. Each extra variant triples the needle set
75
+ * and with it the collision surface, buying coverage for no reachable path.
76
+ *
77
+ * @see scripts/lib/redact-spans.mjs — the overlap-safe span merge this delegates to
78
+ * @see scripts/lib/quality-gate/diagnostics.mjs — SECRET_ENV_NAME_RE, the shape mirrored below
79
+ */
80
+
81
+ import { redactSpans } from './redact-spans.mjs';
82
+
83
+ /**
84
+ * Filter 1 — deny-by-default for MASKING: these keys never become needles.
85
+ *
86
+ * Measured, not guessed: every entry either appears verbatim in ordinary log
87
+ * text (paths, usernames, terminal identity) or is a public toolchain constant.
88
+ * Masking any of them corrupts the log without protecting anything.
89
+ *
90
+ * HONEST NOTE ON ITS CURRENT REACH — measured, not assumed: no key in this set
91
+ * ends in a secret-bearing suffix, so filter 2 alone already rejects every one
92
+ * of them. Proven by a fake-regression probe: with this whole set bypassed and
93
+ * filter 2 intact, the over-masking tests in `tests/lib/secret-masker.test.mjs`
94
+ * stayed GREEN (16/17 — the single failure was the unrelated escaping probe run
95
+ * in the same pass). This set is therefore defense-in-depth plus a record of the
96
+ * measurement — NOT the load-bearing filter. Do not read a green over-masking
97
+ * test as proof that this list stopped the collision; the key-name heuristic
98
+ * did. Adding a secret-NAMED key here would open a silent hole in which a real
99
+ * credential is never masked, so any such addition needs its own justification
100
+ * beside the entry.
101
+ *
102
+ * @type {ReadonlySet<string>}
103
+ */
104
+ const NEVER_MASK_KEYS = new Set([
105
+ // A — POSIX / shell identity and paths; guaranteed to occur in log text.
106
+ 'PATH',
107
+ 'FPATH',
108
+ 'MANPATH',
109
+ 'INFOPATH',
110
+ 'XDG_DATA_DIRS',
111
+ 'HOME',
112
+ 'PWD',
113
+ 'OLDPWD',
114
+ 'TMPDIR',
115
+ 'SHELL',
116
+ 'USER',
117
+ 'LOGNAME',
118
+ 'LANG',
119
+ 'TERM',
120
+ 'TERMINFO',
121
+ 'COLORTERM',
122
+ 'COMMAND_MODE',
123
+ '_',
124
+ // B — toolchain install prefixes; public constants that appear in every
125
+ // build/tool log line.
126
+ 'HOMEBREW_PREFIX',
127
+ 'HOMEBREW_REPOSITORY',
128
+ 'HOMEBREW_CELLAR',
129
+ 'BUN_INSTALL',
130
+ 'PNPM_HOME',
131
+ 'DOTNET_ROOT',
132
+ // C — terminal / platform identity.
133
+ 'GHOSTTY_SHELL_FEATURES',
134
+ 'GHOSTTY_BIN_DIR',
135
+ 'GHOSTTY_RESOURCES_DIR',
136
+ '__CF_USER_TEXT_ENCODING',
137
+ '__CFBundleIdentifier',
138
+ 'AI_AGENT',
139
+ 'CLAUDE_CODE_EXECPATH',
140
+ 'SSH_AUTH_SOCK',
141
+ ]);
142
+
143
+ /**
144
+ * Filter 2 — key-name heuristic: only semantically secret-bearing NAMES qualify.
145
+ *
146
+ * This MIRRORS the shape of `SECRET_ENV_NAME_RE` in
147
+ * `scripts/lib/quality-gate/diagnostics.mjs:49` but is deliberately a SEPARATE
148
+ * declaration rather than an import. Three reasons:
149
+ *
150
+ * 1. Layering. That module is a quality-gate diagnostics helper; this one must
151
+ * be importable from a hot path. Importing it would drag the diagnostics
152
+ * layer (and its redaction-pattern table) into every consumer for one regex.
153
+ * 2. Different populations. There the input is a captured env SNAPSHOT inside a
154
+ * JSON bundle and the effect is "replace the value in place"; here the input
155
+ * is the live `process.env` of the host and the effect is "search for this
156
+ * value in unrelated text". The second is far more destructive when it
157
+ * over-matches, so the two are free to diverge — and SHOULD be.
158
+ * 3. Recorded prior art: consolidating a primitive does not consolidate what
159
+ * its call sites feed it (learnings-index, conf 0.9). A shared regex here
160
+ * would create exactly that illusion of a single decision point.
161
+ *
162
+ * Two deliberate widenings over the mirrored shape:
163
+ * - a bare name (`TOKEN`, `PASSWORD`, `SECRET`, `API_KEY`-less `KEY`) qualifies;
164
+ * the diagnostics form requires at least one character before the suffix.
165
+ * - the key is upper-cased before testing, so lowercase env keys
166
+ * (`github_token`) qualify too. `npm_config_*` keys are unaffected — none of
167
+ * them end in a secret-bearing suffix.
168
+ */
169
+ const SECRET_KEY_RE =
170
+ /^(?:[A-Z][A-Z0-9_]*(?:_TOKEN|_KEY|_SECRET|_PASSWORD|_CREDENTIAL[A-Z_]*)|TOKEN|KEY|SECRET|PASSWORD)$/;
171
+
172
+ /**
173
+ * Filter 3 — minimum value length.
174
+ *
175
+ * NAMED CEILING (this is a deliberate simplification, per the repo's
176
+ * build-value rule): 8 is a DEGENERATE-VALUE guard, not a discriminator. It
177
+ * exists so that a secret-named key carrying a trivial value (`X_KEY=1`,
178
+ * `MY_TOKEN=abc`) cannot turn into a needle that shreds every line of the log.
179
+ * It is explicitly NOT tuned to separate secrets from non-secrets — the header
180
+ * shows that no such threshold exists on the measured host.
181
+ *
182
+ * REVISIT TRIGGER: if a consumer reports over-masking, the answer is an
183
+ * allowlist entry or a narrower key-name heuristic — NOT a higher threshold.
184
+ * Raising it past 32 starts discarding real secrets; every value below 58 still
185
+ * admits PWD. Any patch that moves this number is treating a measurement as a
186
+ * dial.
187
+ */
188
+ const MIN_MASKABLE_LENGTH = 8;
189
+
190
+ /**
191
+ * Decide whether a single env entry qualifies as a masking needle.
192
+ *
193
+ * The three filters run in the order documented in the module header:
194
+ * allowlist → key-name heuristic → length.
195
+ *
196
+ * @param {string} key
197
+ * @param {unknown} value
198
+ * @returns {boolean}
199
+ */
200
+ function qualifiesAsNeedle(key, value) {
201
+ if (typeof key !== 'string' || typeof value !== 'string') return false;
202
+ if (NEVER_MASK_KEYS.has(key)) return false; // 1 — allowlist
203
+ if (!SECRET_KEY_RE.test(key.toUpperCase())) return false; // 2 — key-name heuristic
204
+ if (value.length < MIN_MASKABLE_LENGTH) return false; // 3 — degenerate-value guard
205
+ return true;
206
+ }
207
+
208
+ /**
209
+ * Build a value-based secret masker from an env-like object.
210
+ *
211
+ * Matching is LITERAL and CASE-SENSITIVE, with no word boundaries — two
212
+ * deliberate departures from the confidential-names patterns that
213
+ * `redactSpans` was extracted for:
214
+ * - No `\b`: a secret is not a word. Tokens routinely abut quotes, `=`, `:`
215
+ * and newlines, and many end in `-` or `_`, where `\b` simply fails to
216
+ * anchor. A boundary condition here would silently skip real hits.
217
+ * - Case-sensitive: secrets are case-sensitive by construction, and folding
218
+ * case only widens the collision surface for free.
219
+ * Every value is regex-escaped before it becomes a pattern, so a secret
220
+ * containing `.`, `(`, `[` or `*` matches itself and nothing else.
221
+ * (`RegExp.escape` is stdlib from Node 23.5; this package requires `>=24.0.0`.)
222
+ *
223
+ * Returns an OBJECT rather than a bare function so the needle count is
224
+ * available to callers without a function-property trick: a consumer can log
225
+ * "masking active, N needles" (a count is safe to print; a value never is) and
226
+ * can branch on `needleCount === 0` for a fast path. The masking function
227
+ * itself is `mask`.
228
+ *
229
+ * @param {Record<string, unknown>} env — e.g. `process.env`. A non-object
230
+ * yields a masker with zero needles (identity) rather than throwing: a hot
231
+ * path must not die on a malformed argument, and there is genuinely nothing
232
+ * to mask in that case.
233
+ * @returns {{ mask: (text: string) => string, needleCount: number }}
234
+ */
235
+ export function createSecretValueMasker(env) {
236
+ /** @type {Set<string>} deduped — two keys sharing a value need one pattern. */
237
+ const values = new Set();
238
+ if (env && typeof env === 'object') {
239
+ for (const [key, value] of Object.entries(env)) {
240
+ if (qualifiesAsNeedle(key, value)) values.add(/** @type {string} */ (value));
241
+ }
242
+ }
243
+
244
+ const patterns = [...values].map((v) => new RegExp(RegExp.escape(v)));
245
+
246
+ /**
247
+ * Replace every needle occurrence in `text` with `[REDACTED]`.
248
+ *
249
+ * Delegates the overlap-safe interval merge to `redactSpans`, so a needle
250
+ * that is a prefix of another needle yields ONE marker, not a nested pair —
251
+ * no splice logic is re-derived here.
252
+ *
253
+ * @param {string} text
254
+ * @returns {string}
255
+ */
256
+ const mask = (text) => {
257
+ if (patterns.length === 0 || typeof text !== 'string') return text;
258
+ return redactSpans(text, patterns);
259
+ };
260
+
261
+ return { mask, needleCount: patterns.length };
262
+ }
@@ -161,6 +161,14 @@ function lockAgeHours(lock) {
161
161
  * effective heartbeat, so TTL freshness still rescues recent locks even when
162
162
  * the writer process is dead (the D2/D5 production case).
163
163
  *
164
+ * RETAINED, not forgotten (#595, re-verified 2026-08-15): zero v1 files exist
165
+ * on this host, but this normalisation is MIRRORED in
166
+ * `scripts/lib/harness-audit/categories/category4.mjs` `lockIsLive()` and
167
+ * pinned by `tests/lib/lock-ttl-parity.test.mjs`. Removing it here alone
168
+ * breaks that parity by construction — see
169
+ * `skills/_shared/state-ownership.md` § Schema v1 Sunset for the full
170
+ * co-change set the removal needs.
171
+ *
164
172
  * @param {string} raw
165
173
  * @returns {object|null}
166
174
  */
@@ -230,6 +238,12 @@ function buildLock({ sessionId, mode, ttlHours, semanticSessionId }) {
230
238
  *
231
239
  * Liveness rule: a lock is live when (now - last_heartbeat) < ttl_hours.
232
240
  *
241
+ * The `started_at` fallback below is RETAINED, not forgotten (#595,
242
+ * re-verified 2026-08-15): it is mirrored verbatim in
243
+ * `scripts/lib/harness-audit/categories/category4.mjs` `lockIsLive()` and
244
+ * pinned by `tests/lib/lock-ttl-parity.test.mjs`. See
245
+ * `skills/_shared/state-ownership.md` § Schema v1 Sunset.
246
+ *
233
247
  * @param {{ last_heartbeat: string, started_at: string, ttl_hours?: number }} lock
234
248
  * @param {number} [nowMs]
235
249
  * @returns {boolean}
@@ -863,13 +877,18 @@ export function loadOwnerProof({ repoRoot } = {}) {
863
877
  * (see `buildLockOwnerProof()` / `isLockOwnedByProof()`) to require a SECOND
864
878
  * identity factor beyond `session_id` before deleting — this is what makes
865
879
  * a same-day semantic-id collision safe to release against. When `proof` is
866
- * omitted (the default), behaviour is BYTE-IDENTICAL to before this change:
867
- * only the `session_id` match gates the delete. This keeps the ONE other
868
- * external caller (`scripts/lib/autopilot/worktree-pipeline.mjs`, which does
869
- * not pass `proof`) working unchanged it stays on the weaker,
870
- * session_id-only path.
880
+ * ABSENT (omitted, `undefined`, or `null`), behaviour is BYTE-IDENTICAL to
881
+ * before this change: only the `session_id` match gates the delete.
882
+ *
883
+ * `null` is treated as absent DELIBERATELY (#989): the two producers of a
884
+ * proof — `loadOwnerProof()` and `buildLockOwnerProof()` — both return `null`
885
+ * to mean "cannot prove ownership". Gating on `proof !== undefined` forced
886
+ * every call site to spread-guard (`...(proof ? { proof } : {})`) or brick
887
+ * release with a permanent `proof-mismatch`; treating both absent spellings
888
+ * alike moves that contract inside the API where it belongs. A caller that
889
+ * wants fail-closed-on-unprovable must branch itself before calling.
871
890
  *
872
- * @param {{ sessionId: string, repoRoot?: string, proof?: { pid: number, host: string, startedAt: string } }} args
891
+ * @param {{ sessionId: string, repoRoot?: string, proof?: { pid: number, host: string, startedAt: string }|null }} args
873
892
  * @returns {{ ok: true, deleted: boolean, reason?: string, verified?: boolean }}
874
893
  */
875
894
  export function release({ sessionId, repoRoot, proof } = {}) {
@@ -886,11 +905,16 @@ export function release({ sessionId, repoRoot, proof } = {}) {
886
905
  }
887
906
 
888
907
  // Proof-gated release (additive, #906-class fix): when the caller supplies
889
- // `proof`, the session_id match above is NOT sufficient by itself —
908
+ // a proof, the session_id match above is NOT sufficient by itself —
890
909
  // session_id collisions are the documented root cause behind this check.
891
- // Omitting `proof` leaves this branch dead code, preserving the exact
892
- // pre-existing behaviour for callers that don't pass it.
893
- if (proof !== undefined && !isLockOwnedByProof(existing, proof)) {
910
+ // Absent-proof handling (#989): an absent proof — omitted, `undefined`, or
911
+ // the `null` that loadOwnerProof()/buildLockOwnerProof() return for
912
+ // "cannot prove" leaves this branch dead code and preserves the exact
913
+ // pre-existing session_id-only behaviour. No call-site spread-guard needed.
914
+ // Spelled out rather than as `proof != null` because the repo's `eqeqeq`
915
+ // lint rule admits no null-loose exception.
916
+ const proofSupplied = proof !== undefined && proof !== null;
917
+ if (proofSupplied && !isLockOwnedByProof(existing, proof)) {
894
918
  return { ok: true, deleted: false, reason: 'proof-mismatch' };
895
919
  }
896
920
 
@@ -23,7 +23,8 @@
23
23
  * registry-sourced peer was bucketed as `mode='session'` → classifyMode threw
24
24
  * → fell back to `parallel-ok`, silently bypassing the exclusivity matrix
25
25
  * for cross-repo entries (D5 from Epic #583 audit). The field is optional
26
- * on read for back-compat with v1 entries (defaults to null).
26
+ * on read for back-compat with v1 entries (defaults to null) — retained
27
+ * deliberately, see `skills/_shared/state-ownership.md` § Schema v1 Sunset.
27
28
  */
28
29
 
29
30
  import os from 'node:os';
@@ -115,6 +116,13 @@ function _validEntry(obj) {
115
116
  // Schema v2 (Epic #583): `mode` is optional. When present it MUST be a string
116
117
  // (no number / object / array smuggling). When absent (v1 entry), it is
117
118
  // accepted — back-compat with pre-#583 registry files.
119
+ //
120
+ // RETAINED, not forgotten (#595, re-verified 2026-08-15): zero mode-less
121
+ // entries exist on this host, but rejecting one would make a LIVE peer
122
+ // invisible to the exclusivity matrix — a strict weakening of
123
+ // parallel-session detection for zero functional gain (an absent mode
124
+ // already degrades to the `parallel-ok` bucket). See
125
+ // `skills/_shared/state-ownership.md` § Schema v1 Sunset.
118
126
  if ('mode' in obj && obj.mode !== null && typeof obj.mode !== 'string') {
119
127
  return false;
120
128
  }
@@ -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
@@ -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;',