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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +10 -8
- package/CHANGELOG.md +414 -0
- package/README.md +16 -11
- package/agents/analyst.md +1 -1
- package/agents/architect-reviewer.md +1 -1
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +1 -1
- package/agents/dialectic-deriver.md +1 -1
- package/agents/docs-writer.md +1 -1
- package/agents/memory-proposal-collector.md +1 -1
- package/agents/qa-strategist.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +42 -1
- package/agents/skill-applied-judge.md +1 -1
- package/agents/test-writer.md +1 -1
- package/agents/ui-developer.md +1 -1
- package/agents/ux-evaluator.md +1 -1
- package/commands/release.md +60 -0
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -0
- package/hooks/enforce-scope.mjs +103 -3
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +11 -1
- package/hooks/on-session-end.mjs +52 -5
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +1 -1
- package/pi/prompts/release.md +12 -0
- package/scripts/autopilot.mjs +3 -1
- package/scripts/emit-session.mjs +42 -0
- package/scripts/export-hw-learnings.mjs +61 -2
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/backlog-scan.mjs +106 -15
- package/scripts/lib/build-live-signals.mjs +7 -3
- package/scripts/lib/ci-status-banner.mjs +207 -23
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +193 -7
- package/scripts/lib/learnings/select.mjs +157 -3
- package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
- package/scripts/lib/mirror-issues-banner.mjs +266 -0
- package/scripts/lib/named-vault-resolver.mjs +105 -16
- package/scripts/lib/peer-cards/schema.mjs +6 -2
- package/scripts/lib/reconcile/writer.mjs +69 -30
- package/scripts/lib/redact-spans.mjs +89 -0
- package/scripts/lib/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +658 -0
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/state-md/mission-status.mjs +21 -12
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-commands.mjs +2 -20
- package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
- package/scripts/lib/validate/check-skills.mjs +191 -0
- package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
- package/scripts/lib/validate/check-unwired-features.mjs +219 -11
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
- package/scripts/lib/validate/frontmatter-block.mjs +61 -0
- package/scripts/lib/validate/tier-inference.mjs +46 -8
- package/scripts/lib/vault-mirror/namespace.mjs +146 -1
- package/scripts/lib/vault-mirror/process.mjs +264 -31
- package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
- package/scripts/lib/vault-status/board-writer.mjs +300 -56
- package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
- package/scripts/lib/vcs-repo-spec.mjs +500 -19
- package/scripts/print-learnings-index.mjs +30 -3
- package/scripts/release.mjs +616 -61
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/validate-plugin.mjs +61 -0
- package/scripts/validate-wave-scope.mjs +281 -12
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/state-ownership.md +17 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- package/skills/discovery/issue-templates.md +4 -4
- package/skills/discovery/probes-code.md +2 -2
- package/skills/discovery/probes-feature.md +6 -6
- package/skills/discovery/probes-infra.md +2 -2
- package/skills/discovery/probes-session.md +5 -5
- package/skills/dispatcher/SKILL.md +10 -1
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +49 -39
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/memory-cleanup/SKILL.md +18 -5
- package/skills/npm-publish/SKILL.md +22 -50
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/mode-new.md +23 -5
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +44 -2
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/session-metrics-write.md +4 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +13 -1
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/tmux-layout/SKILL.md +7 -2
- package/skills/vault-mirror/SKILL.md +10 -1
- package/skills/vault-sync/SKILL.md +10 -1
- package/skills/vault-sync/validator.mjs +55 -6
- package/skills/wave-executor/wave-loop.md +42 -12
- 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
|
-
*
|
|
867
|
-
* only the `session_id` match gates the delete.
|
|
868
|
-
*
|
|
869
|
-
*
|
|
870
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
892
|
-
//
|
|
893
|
-
|
|
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
|
|
28
|
-
*
|
|
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
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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
|
|
97
|
-
*
|
|
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 {{
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
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;',
|