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
@@ -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
  }
@@ -35,8 +35,14 @@
35
35
  */
36
36
 
37
37
  import { execFileSync } from 'node:child_process';
38
+ import { readFileSync } from 'node:fs';
39
+ import path from 'node:path';
38
40
  import { digestSha256Short } from './crypto-digest-utils.mjs';
39
- import { chargeIssueBudget, formatBlockReason } from './issue-budget.mjs';
41
+ import {
42
+ chargeIssueBudget,
43
+ formatBlockReason,
44
+ resolveIssueBudgetSessionId,
45
+ } from './issue-budget.mjs';
40
46
  import { resolveRepoSpec } from './vcs-repo-spec.mjs';
41
47
 
42
48
  /**
@@ -99,10 +105,25 @@ function runCli(cmd, args) {
99
105
  if (isIssueCreateArgv(cmd, args)) {
100
106
  try {
101
107
  const repoRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
108
+ // The harness exports CLAUDE_CODE_SESSION_ID (measured 2026-08-21: it is
109
+ // present in the Bash tool environment this module runs in). There is no
110
+ // CLAUDE_SESSION_ID — reading that name made this whole block dead code
111
+ // and left the cap permanently off on the programmatic path.
112
+ const nativeRawId = process.env.CLAUDE_CODE_SESSION_ID ?? null;
113
+ let currentSession = null;
114
+ if (typeof nativeRawId === 'string' && nativeRawId.length > 0) {
115
+ try {
116
+ currentSession = JSON.parse(
117
+ readFileSync(path.join(repoRoot, '.orchestrator', 'current-session.json'), 'utf8'),
118
+ );
119
+ } catch {
120
+ // Missing or malformed records conservatively retain the native env key.
121
+ }
122
+ }
102
123
  const titleIdx = args.indexOf('--title');
103
124
  const verdict = chargeIssueBudget({
104
125
  repoRoot,
105
- sessionId: process.env.CLAUDE_SESSION_ID || null,
126
+ sessionId: resolveIssueBudgetSessionId(nativeRawId, currentSession),
106
127
  command: [cmd, ...args].join(' '),
107
128
  title: titleIdx >= 0 ? (args[titleIdx + 1] ?? null) : null,
108
129
  });