session-orchestrator 3.16.0 → 3.19.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 (220) 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 +17 -1
  5. package/CHANGELOG.md +130 -412
  6. package/README.md +17 -12
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/docs/README.md +2 -1
  21. package/docs/USER-GUIDE.md +8 -3
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +7 -7
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +444 -0
  28. package/docs/pi-setup.md +1 -1
  29. package/docs/rule-authoring.md +58 -9
  30. package/docs/session-config-reference.md +306 -6
  31. package/docs/session-config-template.md +66 -3
  32. package/docs/telemetry/telemetry-claims.md +204 -0
  33. package/docs/telemetry.md +158 -0
  34. package/hooks/_lib/guard-source-loader.mjs +467 -0
  35. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  36. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  37. package/hooks/config-protection.mjs +0 -0
  38. package/hooks/enforce-commands.mjs +10 -2
  39. package/hooks/hooks-codex.json +1 -1
  40. package/hooks/hooks-cursor.json +11 -2
  41. package/hooks/hooks-pi.json +10 -0
  42. package/hooks/hooks.json +21 -1
  43. package/hooks/on-session-end.mjs +178 -18
  44. package/hooks/on-session-start.mjs +23 -0
  45. package/hooks/post-bash-write-verify.mjs +977 -0
  46. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  47. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  48. package/hooks/pre-bash-issue-budget.mjs +167 -0
  49. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  50. package/hooks/pre-bash-templates-first.mjs +96 -63
  51. package/hooks/skill-invocation-telemetry.mjs +109 -10
  52. package/hooks/subagent-telemetry.mjs +527 -37
  53. package/package.json +16 -3
  54. package/pi/prompts/contract-version-bump.md +12 -0
  55. package/rules/README.md +32 -0
  56. package/scripts/archive-closed-prds.mjs +12 -22
  57. package/scripts/autopilot-multi.mjs +103 -20
  58. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  59. package/scripts/check-doc-consistency.sh +17 -1
  60. package/scripts/compute-grounding-injection.sh +18 -3
  61. package/scripts/dialectic-deriver.mjs +7 -2
  62. package/scripts/eval-session.mjs +50 -9
  63. package/scripts/fleet-instruction-scan.mjs +141 -0
  64. package/scripts/lib/auto-dialectic.mjs +11 -2
  65. package/scripts/lib/auto-dream.mjs +16 -5
  66. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  67. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  68. package/scripts/lib/backlog-scan.mjs +39 -6
  69. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  70. package/scripts/lib/build-live-signals.mjs +7 -4
  71. package/scripts/lib/ci-status-banner.mjs +75 -12
  72. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  73. package/scripts/lib/command-blocker.mjs +1013 -58
  74. package/scripts/lib/config/config-protection.mjs +2 -1
  75. package/scripts/lib/config/context-coverage.mjs +82 -0
  76. package/scripts/lib/config/drift-check.mjs +9 -1
  77. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  78. package/scripts/lib/config/issue-budget.mjs +123 -0
  79. package/scripts/lib/config/moc-staleness.mjs +98 -0
  80. package/scripts/lib/config/reconcile.mjs +21 -0
  81. package/scripts/lib/config/section-extractor.mjs +121 -1
  82. package/scripts/lib/config/worktree-orphans.mjs +138 -0
  83. package/scripts/lib/config-schema.mjs +23 -3
  84. package/scripts/lib/config.mjs +32 -0
  85. package/scripts/lib/context-coverage-banner.mjs +223 -0
  86. package/scripts/lib/convergence-monitor.mjs +49 -3
  87. package/scripts/lib/description-surface.mjs +535 -0
  88. package/scripts/lib/dispatcher/enumerate.mjs +136 -30
  89. package/scripts/lib/dispatcher/rank.mjs +22 -8
  90. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  91. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  92. package/scripts/lib/eval/engine.mjs +47 -5
  93. package/scripts/lib/events.mjs +59 -7
  94. package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
  95. package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
  96. package/scripts/lib/gates/gate-full.mjs +15 -3
  97. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  98. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  99. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  100. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  101. package/scripts/lib/handover-gate.mjs +7 -3
  102. package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
  103. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  104. package/scripts/lib/io.mjs +345 -10
  105. package/scripts/lib/issue-budget.mjs +269 -0
  106. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  107. package/scripts/lib/label-scope.mjs +47 -0
  108. package/scripts/lib/learnings/schema.mjs +43 -3
  109. package/scripts/lib/lock-reaper.mjs +1 -2
  110. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  111. package/scripts/lib/moc-staleness-banner.mjs +267 -0
  112. package/scripts/lib/peer-discovery.mjs +645 -0
  113. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  114. package/scripts/lib/product-repo-detect.mjs +9 -8
  115. package/scripts/lib/project-hygiene.mjs +432 -0
  116. package/scripts/lib/quality-gate.mjs +167 -0
  117. package/scripts/lib/recommendations-v0.mjs +1 -1
  118. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  119. package/scripts/lib/reconcile/emitter.mjs +23 -4
  120. package/scripts/lib/reconcile/engine.mjs +147 -39
  121. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  122. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  123. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  124. package/scripts/lib/resource-probe.mjs +19 -0
  125. package/scripts/lib/rule-loader.mjs +6 -0
  126. package/scripts/lib/scope-baseline.mjs +564 -0
  127. package/scripts/lib/scope-gate.mjs +399 -98
  128. package/scripts/lib/session-close-backfill.mjs +61 -6
  129. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  130. package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
  131. package/scripts/lib/session-id.mjs +221 -41
  132. package/scripts/lib/session-lock.mjs +304 -6
  133. package/scripts/lib/session-schema/constants.mjs +22 -3
  134. package/scripts/lib/session-schema/filters.mjs +88 -0
  135. package/scripts/lib/session-schema/validator.mjs +16 -0
  136. package/scripts/lib/session-schema.mjs +1 -0
  137. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  138. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  139. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  140. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  141. package/scripts/lib/skill-health/join.mjs +35 -9
  142. package/scripts/lib/spiral-carryover.mjs +142 -30
  143. package/scripts/lib/state-md/mission-status.mjs +53 -3
  144. package/scripts/lib/subagents-schema.mjs +43 -9
  145. package/scripts/lib/telemetry/anon-id.mjs +141 -0
  146. package/scripts/lib/telemetry/consent.mjs +299 -0
  147. package/scripts/lib/telemetry/paths.mjs +27 -0
  148. package/scripts/lib/telemetry/queue.mjs +287 -0
  149. package/scripts/lib/telemetry/schema.mjs +384 -0
  150. package/scripts/lib/telemetry/sync.mjs +312 -0
  151. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  152. package/scripts/lib/tests-src-ratio.mjs +484 -0
  153. package/scripts/lib/validate/check-agents.mjs +56 -0
  154. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  155. package/scripts/lib/validate/check-rules.mjs +217 -35
  156. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  157. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  158. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  159. package/scripts/lib/vault-archive.mjs +17 -2
  160. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  161. package/scripts/lib/vault-mirror/process.mjs +30 -0
  162. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  163. package/scripts/lib/vault-status/board-writer.mjs +63 -5
  164. package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
  165. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  166. package/scripts/lib/wave-resource-gate.mjs +115 -11
  167. package/scripts/lib/worktree/listing.mjs +44 -7
  168. package/scripts/mcp-server.sh +32 -6
  169. package/scripts/measure-context-overhead.sh +151 -0
  170. package/scripts/memory-propose.mjs +72 -9
  171. package/scripts/print-applicable-rules.mjs +51 -12
  172. package/scripts/release.mjs +534 -0
  173. package/scripts/run-quality-gate.mjs +123 -5
  174. package/scripts/telemetry.mjs +250 -0
  175. package/scripts/validate-wave-scope.mjs +182 -17
  176. package/scripts/vault-integration-watcher.mjs +32 -10
  177. package/skills/_shared/config-reading.md +2 -2
  178. package/skills/bootstrap/fast-template.md +1 -1
  179. package/skills/claude-md-drift-check/checker.mjs +145 -28
  180. package/skills/contract-version-bump/SKILL.md +219 -0
  181. package/skills/discovery/SKILL.md +4 -4
  182. package/skills/discovery/issue-templates.md +11 -11
  183. package/skills/discovery/probes-audit.md +1 -1
  184. package/skills/discovery/probes-feature.md +1 -1
  185. package/skills/discovery/probes-session.md +26 -5
  186. package/skills/ecosystem-health/SKILL.md +1 -1
  187. package/skills/ecosystem-health/wizard.md +4 -4
  188. package/skills/evolve/SKILL.md +1 -0
  189. package/skills/gitlab-ops/SKILL.md +20 -12
  190. package/skills/gitlab-portfolio/SKILL.md +2 -2
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +97 -0
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/quality-gates/SKILL.md +1 -1
  199. package/skills/reconcile/SKILL.md +21 -4
  200. package/skills/session-end/SKILL.md +108 -14
  201. package/skills/session-end/discovery-scan.md +4 -2
  202. package/skills/session-end/drift-operations.md +4 -4
  203. package/skills/session-end/metrics-collection.md +13 -0
  204. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  205. package/skills/session-end/phase-3-6-tail.md +2 -1
  206. package/skills/session-end/plan-verification.md +5 -2
  207. package/skills/session-end/vault-operations.md +1 -1
  208. package/skills/session-end/verification-checklist.md +1 -1
  209. package/skills/session-plan/SKILL.md +6 -2
  210. package/skills/session-plan/wave-template.md +2 -0
  211. package/skills/session-start/SKILL.md +149 -7
  212. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  213. package/skills/test-runner/SKILL.md +2 -2
  214. package/skills/vault-sync/SKILL.md +1 -1
  215. package/skills/vault-sync/package-lock.json +3 -3
  216. package/skills/vault-sync/validator.mjs +228 -40
  217. package/skills/wave-executor/SKILL.md +5 -2
  218. package/skills/wave-executor/circuit-breaker.md +2 -0
  219. package/skills/wave-executor/wave-loop.md +163 -10
  220. package/templates/_shared/loop.md +4 -4
@@ -205,20 +205,132 @@ export function selectPiHooks(manifest, piEventName, event = {}) {
205
205
  }
206
206
 
207
207
  /**
208
- * @param {string} stdout
209
- * @returns {Record<string, unknown>|null}
208
+ * Substrings that identify a line as an *attempted* decision envelope even when
209
+ * it no longer parses — the tell-tale of a truncated `emitDeny` payload.
210
210
  */
211
- function parseLastJsonLine(stdout) {
212
- const lines = stdout.split('\n').map((l) => l.trim()).filter(Boolean);
213
- for (let i = lines.length - 1; i >= 0; i--) {
211
+ const DECISION_MARKERS = Object.freeze(['permissionDecision', 'hookSpecificOutput']);
212
+
213
+ /**
214
+ * Decide whether an unparseable stdout line is corrupt hook PROTOCOL output
215
+ * (fail closed) or merely human-facing chatter (fail open).
216
+ *
217
+ * The discriminator is deliberately narrow. "stdout is non-empty but nothing
218
+ * parsed ⇒ block" — the obvious fail-closed rule — would turn every third-party
219
+ * hook that prints a progress line or a debug trace into a permanent blocker,
220
+ * because the Pi bridge spawns whatever the manifest points at. A line that
221
+ * OPENS A JSON OBJECT, or that name-drops a decision field, is by contrast
222
+ * malformed by its own protocol's standard; no legitimate log line looks like
223
+ * that.
224
+ *
225
+ * `[` is deliberately NOT an opener here even though it opens a JSON array: a
226
+ * decision envelope is always an object, so an array line can never be a
227
+ * truncated one — while `[####----] 50%` is a perfectly ordinary progress bar.
228
+ * Treating `[` as protocol output blocked that hook in an earlier cut of this
229
+ * function; `tests/lib/pi-hook-bridge.test.mjs` pins the carve-out.
230
+ *
231
+ * @param {string} line Trimmed, non-empty stdout line that failed JSON.parse.
232
+ * @returns {boolean}
233
+ */
234
+ function looksLikeBrokenEnvelope(line) {
235
+ if (line.startsWith('{')) return true;
236
+ return DECISION_MARKERS.some((marker) => line.includes(marker));
237
+ }
238
+
239
+ /**
240
+ * Pull `{decision, reason}` out of ONE parsed envelope, accepting both
241
+ * generations of the deny protocol.
242
+ *
243
+ * @param {unknown} parsed
244
+ * @returns {{ decision: string|null, reason: string|null }}
245
+ */
246
+ function decisionFromEnvelope(parsed) {
247
+ const envelope = objectOrEmpty(parsed);
248
+ const hookSpecific = objectOrEmpty(envelope.hookSpecificOutput);
249
+
250
+ const decision = typeof hookSpecific.permissionDecision === 'string'
251
+ ? hookSpecific.permissionDecision
252
+ : (typeof envelope.permissionDecision === 'string' ? envelope.permissionDecision : null);
253
+
254
+ const reason = typeof hookSpecific.permissionDecisionReason === 'string'
255
+ ? hookSpecific.permissionDecisionReason
256
+ : (typeof envelope.reason === 'string' ? envelope.reason : null);
257
+
258
+ return { decision, reason };
259
+ }
260
+
261
+ /**
262
+ * Extract the permission decision + reason from a hook's stdout, accepting BOTH
263
+ * generations of the deny protocol.
264
+ *
265
+ * - **Current** (`scripts/lib/io.mjs#emitDeny`, exit 0):
266
+ * `{ hookSpecificOutput: { hookEventName, permissionDecision, permissionDecisionReason } }`
267
+ * - **Legacy** (pre-#906 in-repo hooks, and third-party hooks outside this repo
268
+ * that still emit the deprecated flat shape): `{ permissionDecision, reason }`
269
+ *
270
+ * Legacy support is NOT optional: the bridge spawns whatever commands the Pi
271
+ * manifest points at, including hooks this repo does not own. Dropping the flat
272
+ * form would silently stop honouring their denies.
273
+ *
274
+ * Reading the reason out of the envelope also avoids the failure mode where the
275
+ * caller falls through to `stdout.trim()` and surfaces the raw JSON string as
276
+ * the human-readable block reason.
277
+ *
278
+ * ## Why this reads raw stdout, and why the FIRST envelope wins
279
+ *
280
+ * Since #906 every in-repo PreToolUse hook denies with **exit 0** plus this
281
+ * envelope, so on the Pi lane `status === 2` no longer fires and the stdout read
282
+ * is the ONLY surviving block signal. Two ways the previous last-parseable-line
283
+ * scan dropped that signal:
284
+ *
285
+ * 1. **Anything printed after the envelope shadowed it.** `emitSystemMessage`,
286
+ * or any third-party hook's trailing JSON, became "the" decision line and the
287
+ * deny read as "no decision" ⇒ allow. Scanning forward and keeping the first
288
+ * line that actually carries a `permissionDecision` removes that shadowing:
289
+ * non-decision JSON lines are now skipped rather than mistaken for a verdict.
290
+ * 2. **A truncated envelope parsed as nothing at all** ⇒ allow. `malformed`
291
+ * reports that case up to {@link runPiHookCommand}, which fails closed for
292
+ * PreToolUse. It stays set even when a later line does carry a decision:
293
+ * garbage interleaved with a verdict is a protocol violation, and a mangled
294
+ * line is exactly as likely to have been a deny as an allow.
295
+ *
296
+ * @param {unknown} input Raw hook stdout (preferred), or an already-parsed
297
+ * envelope object. Non-string input skips the line scan and can never
298
+ * report `malformed`.
299
+ * @returns {{ decision: string|null, reason: string|null, deny: boolean, malformed: boolean }}
300
+ */
301
+ export function readHookDecision(input) {
302
+ if (typeof input !== 'string') {
303
+ const { decision, reason } = decisionFromEnvelope(input);
304
+ return { decision, reason, deny: decision === 'deny', malformed: false };
305
+ }
306
+
307
+ let found = null;
308
+ let malformed = false;
309
+
310
+ for (const rawLine of input.split('\n')) {
311
+ const line = rawLine.trim();
312
+ if (line === '') continue;
313
+
314
+ let parsed;
214
315
  try {
215
- const parsed = JSON.parse(lines[i]);
216
- if (parsed && typeof parsed === 'object') return parsed;
316
+ parsed = JSON.parse(line);
217
317
  } catch {
218
- // Try the previous line.
318
+ if (looksLikeBrokenEnvelope(line)) malformed = true;
319
+ continue;
219
320
  }
321
+
322
+ if (found !== null) continue;
323
+ const candidate = decisionFromEnvelope(parsed);
324
+ if (candidate.decision !== null) found = candidate;
220
325
  }
221
- return null;
326
+
327
+ const decision = found?.decision ?? null;
328
+ return {
329
+ decision,
330
+ reason: found?.reason ?? null,
331
+ deny: decision === 'deny',
332
+ malformed,
333
+ };
222
334
  }
223
335
 
224
336
  /**
@@ -261,19 +373,36 @@ export function runPiHookCommand(hook, payload, options) {
261
373
 
262
374
  const stdout = result.stdout ?? '';
263
375
  const stderr = result.stderr ?? '';
264
- const parsed = parseLastJsonLine(stdout);
376
+ const decision = readHookDecision(stdout);
265
377
  const isPreToolUse = payload.hook_event_name === 'PreToolUse';
378
+ // Since #906 in-repo hooks deny with exit 0, so `status` alone carries no block
379
+ // signal any more and `decision` is load-bearing on its own. `decision.malformed`
380
+ // is therefore counted as an infrastructure failure: a hook whose decision
381
+ // channel is corrupt has told us nothing, and for a PreToolUse guard "nothing"
382
+ // must not read as "allow".
266
383
  const infraFailure = Boolean(result.error) ||
267
384
  result.status === null ||
268
- (result.status !== 0 && result.status !== 2);
385
+ (result.status !== 0 && result.status !== 2) ||
386
+ decision.malformed;
387
+ // `status === 2` stays: legacy/third-party hooks still signal a block that way.
388
+ // `decision.deny` covers both the current exit-0 envelope and the legacy flat form.
269
389
  const blocked = result.status === 2 ||
270
- parsed?.permissionDecision === 'deny' ||
390
+ decision.deny ||
271
391
  (isPreToolUse && infraFailure);
272
- const reason = typeof parsed?.reason === 'string'
273
- ? parsed.reason
274
- : (blocked
275
- ? (result.error?.message || stdout.trim() || stderr.trim() || `Hook command blocked: ${command}`)
276
- : null);
392
+
393
+ let reason = decision.reason;
394
+ if (reason === null && blocked) {
395
+ // Prefer a diagnosis over echoing the corrupt payload: `stdout.trim()` on a
396
+ // truncated envelope would surface the whole mangled JSON blob as the
397
+ // operator-facing block reason.
398
+ reason = result.error?.message
399
+ || (decision.malformed
400
+ ? `Hook stdout is not a parseable decision envelope (possible truncated output) — failing closed: ${command}`
401
+ : '')
402
+ || stdout.trim()
403
+ || stderr.trim()
404
+ || `Hook command blocked: ${command}`;
405
+ }
277
406
 
278
407
  return {
279
408
  command,
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { existsSync, readFileSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
+ import { findSessionConfigBlock } from './config/section-extractor.mjs';
10
11
 
11
12
  const FRAMEWORK_DEPS = [
12
13
  'next',
@@ -110,12 +111,12 @@ export function hasVaultConfig(configFilePath) {
110
111
  return false;
111
112
  }
112
113
  // Scan only within the Session Config section (bounded by the next ## heading).
113
- const startMatch = text.match(/^## Session Config[ \t]*(?:\r?\n|$)/m);
114
- if (!startMatch || startMatch.index === undefined) return false;
115
- const afterBlock = text.slice(startMatch.index + startMatch[0].length);
116
- const nextHeadingMatch = afterBlock.match(/^## /m);
117
- const blockContent = nextHeadingMatch
118
- ? afterBlock.slice(0, nextHeadingMatch.index)
119
- : afterBlock;
120
- return /^\s*vault\s*:/m.test(blockContent);
114
+ // Heading + span both come from the SSOT (#968): the previous local regex
115
+ // `/^## Session Config[ \t]*(?:\r?\n|$)/m` accepted a heading with trailing
116
+ // spaces/tab that `_extractConfigSection` rejects, so a `vault:` key under
117
+ // `## Session Config␠` was reported as configured while the runtime read
118
+ // nothing at all from that block.
119
+ const block = findSessionConfigBlock(text);
120
+ if (!block) return false;
121
+ return /^\s*vault\s*:/m.test(block.body);
121
122
  }
@@ -0,0 +1,432 @@
1
+ /**
2
+ * project-hygiene.mjs — Project-hygiene probe family (housekeeping canon).
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ---------------
6
+ * session-start Phase 4 runs 13 probes. Exactly one of them (`ci-status`)
7
+ * inspects the PROJECT; the rest inspect the orchestrator's own substrate
8
+ * (vault, peer-cards, loop readiness, instruction budget, its own ledger).
9
+ * A read-only diagnostic run of the housekeeping flow across six real
10
+ * consumer repos (9 to 2099 tracked files) found the checks the flow does
11
+ * NOT perform outscored the ones it does in every single repo — by up to 7x.
12
+ *
13
+ * This module adds the highest-yield missing checks. Each one was selected by
14
+ * measured hit rate across those six repos, not by intuition:
15
+ *
16
+ * releaseHygiene 6/6 repos tags/CHANGELOG vs. commits since
17
+ * ignoredBallast 6/6 repos ignored-and-untracked working-tree mass
18
+ * staleArtifacts 5/6 repos aged .orchestrator/ artifacts
19
+ * ciConfig 5/6 repos pipeline present, audit job, image pinning
20
+ *
21
+ * DELIBERATE OMISSIONS (documented so nobody "completes" the set later):
22
+ *
23
+ * docs-drift (6/6 hit rate) is NOT reimplemented here. `claude-md-drift-check`
24
+ * already covers it with ten checks; it merely runs at session-END, i.e.
25
+ * after the work. The fix is scheduling, not a second implementation.
26
+ *
27
+ * env-hygiene (5/6 hit rate) is NOT implemented as a dead-variable list.
28
+ * The naive approach — diff `process.env.X` reads against `.env.example` —
29
+ * produced a 100% false-positive rate in the largest repo tested, because
30
+ * variables were read centrally through a Zod schema rather than at their
31
+ * use sites. This module reports only the falsifiable part: whether an
32
+ * `.env.example` exists at all when env vars are read. Counting or naming
33
+ * "dead" variables requires resolving indirection and is left to the
34
+ * per-repo parity tests that some repos already implement correctly.
35
+ *
36
+ * CONTRACT
37
+ * --------
38
+ * Mirrors the established banner-probe shape (see ci-status-banner.mjs):
39
+ * returns `null` for a silent no-op, or `{ severity, message, findings }`.
40
+ * Never throws, never writes, never blocks. Every finding carries `fixable`
41
+ * so a caller can split mechanical work from work needing human judgement.
42
+ */
43
+
44
+ import { execFileSync } from 'node:child_process';
45
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
46
+ import { join } from 'node:path';
47
+
48
+ /** Commits past the newest tag before release hygiene is worth mentioning. */
49
+ export const DEFAULT_RELEASE_DRIFT_COMMITS = 50;
50
+ /** Megabytes of ignored working-tree mass before it is worth mentioning. */
51
+ export const DEFAULT_BALLAST_MB = 500;
52
+ /** Days before an .orchestrator/ artifact counts as aged. */
53
+ export const DEFAULT_ARTIFACT_AGE_DAYS = 30;
54
+
55
+ /**
56
+ * Run a git command, returning trimmed stdout or null on any failure.
57
+ * Never throws — a missing repo, missing ref, or absent git all yield null.
58
+ * @param {string[]} args
59
+ * @param {string} cwd
60
+ * @returns {string|null}
61
+ */
62
+ function git(args, cwd) {
63
+ try {
64
+ return execFileSync('git', args, {
65
+ cwd,
66
+ encoding: 'utf8',
67
+ stdio: ['ignore', 'pipe', 'ignore'],
68
+ timeout: 10_000,
69
+ }).trim();
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * H1 — Release hygiene.
77
+ *
78
+ * Measured 6/6. The severe real-world case: 980 commits, live in production,
79
+ * zero release tags, CHANGELOG frozen 460 commits back — no rollback anchor.
80
+ * Distinguishes "no tags at all" from "tags exist but HEAD has run far past
81
+ * the newest one", because the remedies differ.
82
+ *
83
+ * @param {string} repoRoot
84
+ * @param {number} driftCommits
85
+ * @returns {object|null}
86
+ */
87
+ export function checkReleaseHygiene(repoRoot, driftCommits = DEFAULT_RELEASE_DRIFT_COMMITS) {
88
+ const totalCommits = Number(git(['rev-list', '--count', 'HEAD'], repoRoot));
89
+ if (!Number.isFinite(totalCommits) || totalCommits === 0) return null;
90
+
91
+ const tags = git(['tag', '--list'], repoRoot);
92
+ if (tags === null) return null;
93
+ const tagList = tags.split('\n').filter(Boolean);
94
+
95
+ // A young repo without tags is normal, not a finding.
96
+ if (tagList.length === 0) {
97
+ if (totalCommits < driftCommits) return null;
98
+ return {
99
+ check: 'release-hygiene',
100
+ fixable: false,
101
+ message: `no release tags across ${totalCommits} commits — no rollback anchor and no deploy identity`,
102
+ };
103
+ }
104
+
105
+ // `describe` fails when no ANNOTATED tag is reachable; fall back to any tag.
106
+ const described = git(['describe', '--tags', '--abbrev=0'], repoRoot);
107
+ if (!described) {
108
+ return {
109
+ check: 'release-hygiene',
110
+ fixable: false,
111
+ message: `${tagList.length} tag(s) exist but none is reachable from HEAD (git describe fails) — releases are not anchored to this history`,
112
+ };
113
+ }
114
+
115
+ const since = Number(git(['rev-list', '--count', `${described}..HEAD`], repoRoot));
116
+ if (!Number.isFinite(since) || since < driftCommits) return null;
117
+
118
+ return {
119
+ check: 'release-hygiene',
120
+ fixable: false,
121
+ message: `${since} commits since the newest tag (${described}) — release identity is ${since} commits stale`,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * H2 — Ignored working-tree ballast.
127
+ *
128
+ * Measured 6/6. Largest observed: a 7.4 GB working tree carrying 30.8 MB of
129
+ * tracked content (factor 240), entirely invisible to `git status`, which
130
+ * reported clean, and to `git count-objects`, which reported a healthy pack.
131
+ *
132
+ * Also reports files that are NEITHER tracked NOR ignored — the state where a
133
+ * `.gitignore` intends to version something that was never committed. In one
134
+ * repo this hid 23 rule files the `.gitignore` explicitly un-ignored.
135
+ *
136
+ * @param {string} repoRoot
137
+ * @param {number} ballastMb
138
+ * @returns {object[]}
139
+ */
140
+ export function checkIgnoredBallast(repoRoot, ballastMb = DEFAULT_BALLAST_MB) {
141
+ const findings = [];
142
+
143
+ const ignored = git(
144
+ ['status', '--ignored=matching', '--porcelain', '--untracked-files=all'],
145
+ repoRoot,
146
+ );
147
+ if (ignored === null) return findings;
148
+
149
+ const ignoredPaths = [];
150
+ let untrackedUnignored = 0;
151
+ for (const line of ignored.split('\n').filter(Boolean)) {
152
+ if (line.startsWith('!! ')) ignoredPaths.push(line.slice(3));
153
+ else if (line.startsWith('?? ')) untrackedUnignored++;
154
+ }
155
+
156
+ // Size only the top-level ignored entries — recursing every path would cost
157
+ // more than the finding is worth on a large tree.
158
+ let totalBytes = 0;
159
+ const heaviest = [];
160
+ for (const p of ignoredPaths) {
161
+ const abs = join(repoRoot, p);
162
+ const bytes = duBytes(abs);
163
+ if (bytes === null) continue;
164
+ totalBytes += bytes;
165
+ heaviest.push({ path: p, bytes });
166
+ }
167
+
168
+ const totalMb = Math.round(totalBytes / (1024 * 1024));
169
+ if (totalMb >= ballastMb) {
170
+ heaviest.sort((a, b) => b.bytes - a.bytes);
171
+ const top = heaviest
172
+ .slice(0, 3)
173
+ .map((h) => `${h.path} ${Math.round(h.bytes / (1024 * 1024))}MB`)
174
+ .join(', ');
175
+ findings.push({
176
+ check: 'ignored-ballast',
177
+ fixable: true,
178
+ message: `${totalMb} MB of ignored files in the working tree (largest: ${top}) — invisible to git status`,
179
+ });
180
+ }
181
+
182
+ if (untrackedUnignored > 0) {
183
+ findings.push({
184
+ check: 'untracked-unignored',
185
+ fixable: false,
186
+ message: `${untrackedUnignored} file(s) are neither tracked nor ignored — either commit them or add them to .gitignore, since nothing currently decides`,
187
+ });
188
+ }
189
+
190
+ return findings;
191
+ }
192
+
193
+ /**
194
+ * Directory/file size in bytes via `du -sk`, or null when unavailable.
195
+ * `du` is POSIX and present on macOS and Linux; Windows yields null, which
196
+ * degrades the ballast check to a silent skip rather than a crash.
197
+ * @param {string} absPath
198
+ * @returns {number|null}
199
+ */
200
+ function duBytes(absPath) {
201
+ try {
202
+ if (!existsSync(absPath)) return null;
203
+ const out = execFileSync('du', ['-sk', absPath], {
204
+ encoding: 'utf8',
205
+ stdio: ['ignore', 'pipe', 'ignore'],
206
+ timeout: 10_000,
207
+ });
208
+ const kb = Number(out.trim().split(/\s+/)[0]);
209
+ return Number.isFinite(kb) ? kb * 1024 : null;
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * H3 — Aged orchestrator artifacts.
217
+ *
218
+ * Measured 5/6. Largest observed: 184 MB under .orchestrator/, of which
219
+ * 147 MB were Playwright test-run captures dating back seven weeks, plus
220
+ * 592 files older than 30 days.
221
+ *
222
+ * @param {string} repoRoot
223
+ * @param {number} ageDays
224
+ * @param {number} now
225
+ * @returns {object|null}
226
+ */
227
+ export function checkStaleArtifacts(repoRoot, ageDays = DEFAULT_ARTIFACT_AGE_DAYS, now = Date.now()) {
228
+ const dir = join(repoRoot, '.orchestrator');
229
+ if (!existsSync(dir)) return null;
230
+
231
+ const cutoff = now - ageDays * 24 * 60 * 60 * 1000;
232
+ let aged = 0;
233
+ let scanned = 0;
234
+
235
+ /** @param {string} d @param {number} depth */
236
+ const walk = (d, depth) => {
237
+ // Bounded: a runaway scan on a huge artifact tree would cost more than
238
+ // the finding. 20k entries is far above any healthy .orchestrator/.
239
+ if (depth > 6 || scanned > 20_000) return;
240
+ let entries;
241
+ try {
242
+ entries = readdirSync(d, { withFileTypes: true });
243
+ } catch {
244
+ return;
245
+ }
246
+ for (const e of entries) {
247
+ const full = join(d, e.name);
248
+ if (e.isDirectory()) {
249
+ walk(full, depth + 1);
250
+ } else if (e.isFile()) {
251
+ scanned++;
252
+ try {
253
+ if (statSync(full).mtimeMs < cutoff) aged++;
254
+ } catch {
255
+ /* vanished mid-scan — ignore */
256
+ }
257
+ }
258
+ }
259
+ };
260
+ walk(dir, 0);
261
+
262
+ if (aged === 0) return null;
263
+
264
+ const mb = Math.round((duBytes(dir) ?? 0) / (1024 * 1024));
265
+ return {
266
+ check: 'stale-artifacts',
267
+ fixable: true,
268
+ message: `${aged} file(s) under .orchestrator/ older than ${ageDays}d${mb > 0 ? ` (${mb} MB total)` : ''} — candidates for pruning`,
269
+ };
270
+ }
271
+
272
+ /**
273
+ * H4 — CI configuration hygiene.
274
+ *
275
+ * Measured 5/6. Reports only what is decidable by presence, never by parsing
276
+ * pipeline semantics. The dependency-audit gap is the load-bearing one: three
277
+ * of the tested repos carried known-vulnerable dependencies that no pipeline
278
+ * would ever surface.
279
+ *
280
+ * @param {string} repoRoot
281
+ * @returns {object[]}
282
+ */
283
+ export function checkCiConfig(repoRoot) {
284
+ const findings = [];
285
+ const gitlabCi = join(repoRoot, '.gitlab-ci.yml');
286
+ const ghWorkflows = join(repoRoot, '.github', 'workflows');
287
+ const hasGitlab = existsSync(gitlabCi);
288
+ const hasGithub = existsSync(ghWorkflows);
289
+
290
+ // Only meaningful for repos that actually build something.
291
+ const hasManifest = ['package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod'].some((m) =>
292
+ existsSync(join(repoRoot, m)),
293
+ );
294
+ if (!hasManifest) return findings;
295
+
296
+ if (!hasGitlab && !hasGithub) {
297
+ findings.push({
298
+ check: 'ci-config',
299
+ fixable: false,
300
+ message: 'no CI pipeline configured (.gitlab-ci.yml / .github/workflows) — nothing verifies commits',
301
+ });
302
+ return findings;
303
+ }
304
+
305
+ let ciText = '';
306
+ if (hasGitlab) ciText += safeRead(gitlabCi);
307
+ if (hasGithub) {
308
+ try {
309
+ for (const f of readdirSync(ghWorkflows)) {
310
+ if (f.endsWith('.yml') || f.endsWith('.yaml')) ciText += safeRead(join(ghWorkflows, f));
311
+ }
312
+ } catch {
313
+ /* unreadable workflows dir — fall through with what we have */
314
+ }
315
+ }
316
+
317
+ if (ciText && !/\b(npm|pnpm|yarn) audit\b|pip-audit|cargo audit|osv-scanner|dependency.?check/i.test(ciText)) {
318
+ findings.push({
319
+ check: 'ci-audit-job',
320
+ fixable: true,
321
+ message: 'CI runs no dependency-audit step — known-vulnerable dependencies cannot surface in the pipeline',
322
+ });
323
+ }
324
+
325
+ return findings;
326
+ }
327
+
328
+ /** @param {string} p @returns {string} */
329
+ function safeRead(p) {
330
+ try {
331
+ return readFileSync(p, 'utf8');
332
+ } catch {
333
+ return '';
334
+ }
335
+ }
336
+
337
+ /**
338
+ * H5 — Env documentation presence (deliberately narrow; see module header).
339
+ *
340
+ * Reports ONLY the falsifiable case: code reads environment variables and no
341
+ * `.env.example` exists anywhere. Does NOT attempt to name dead or missing
342
+ * variables — that requires resolving indirection (central schema modules,
343
+ * injected config objects) and produced a 100% false-positive rate when
344
+ * attempted naively during the diagnostic runs.
345
+ *
346
+ * @param {string} repoRoot
347
+ * @returns {object|null}
348
+ */
349
+ export function checkEnvDocumentation(repoRoot) {
350
+ const tracked = git(['ls-files'], repoRoot);
351
+ if (!tracked) return null;
352
+
353
+ const files = tracked.split('\n').filter((f) => /\.(mjs|cjs|js|ts|tsx|py|go|rs)$/.test(f));
354
+ if (files.length === 0) return null;
355
+
356
+ const hasExample = ['.env.example', '.env.sample', '.env.template'].some((n) =>
357
+ existsSync(join(repoRoot, n)),
358
+ );
359
+ if (hasExample) return null;
360
+
361
+ // Sample rather than read every file — one hit is enough to decide.
362
+ let readsEnv = false;
363
+ for (const f of files.slice(0, 400)) {
364
+ const text = safeRead(join(repoRoot, f));
365
+ if (/process\.env\.\w|os\.environ|std::env::var|os\.Getenv/.test(text)) {
366
+ readsEnv = true;
367
+ break;
368
+ }
369
+ }
370
+ if (!readsEnv) return null;
371
+
372
+ return {
373
+ check: 'env-documentation',
374
+ fixable: false,
375
+ message: 'code reads environment variables but no .env.example/.env.sample exists — required configuration is undiscoverable',
376
+ };
377
+ }
378
+
379
+ /**
380
+ * Aggregate the project-hygiene family into one banner result.
381
+ *
382
+ * Returns a SINGLE result rather than one banner per check: Phase 4 already
383
+ * renders 13 banners, and the diagnostic runs showed a flat list stops being
384
+ * read past roughly 25 findings. The caller renders the summary plus the top
385
+ * findings and can use `mechanical` to route the fixable subset separately.
386
+ *
387
+ * @param {{ repoRoot: string, now?: number, thresholds?: object }} opts
388
+ * @returns {{ severity: 'warn', message: string, findings: object[], mechanical: number } | null}
389
+ */
390
+ export function checkProjectHygiene({ repoRoot, now = Date.now(), thresholds = {} } = {}) {
391
+ if (!repoRoot || typeof repoRoot !== 'string') return null;
392
+ if (!existsSync(join(repoRoot, '.git'))) return null;
393
+
394
+ const {
395
+ releaseDriftCommits = DEFAULT_RELEASE_DRIFT_COMMITS,
396
+ ballastMb = DEFAULT_BALLAST_MB,
397
+ artifactAgeDays = DEFAULT_ARTIFACT_AGE_DAYS,
398
+ } = thresholds;
399
+
400
+ const findings = [];
401
+ try {
402
+ const release = checkReleaseHygiene(repoRoot, releaseDriftCommits);
403
+ if (release) findings.push(release);
404
+
405
+ findings.push(...checkIgnoredBallast(repoRoot, ballastMb));
406
+
407
+ const stale = checkStaleArtifacts(repoRoot, artifactAgeDays, now);
408
+ if (stale) findings.push(stale);
409
+
410
+ findings.push(...checkCiConfig(repoRoot));
411
+
412
+ const env = checkEnvDocumentation(repoRoot);
413
+ if (env) findings.push(env);
414
+ } catch {
415
+ // Probe family is advisory — a partial result beats a crashed session-start.
416
+ if (findings.length === 0) return null;
417
+ }
418
+
419
+ if (findings.length === 0) return null;
420
+
421
+ const mechanical = findings.filter((f) => f.fixable).length;
422
+ const head = findings.slice(0, 3).map((f) => `• ${f.message}`).join('\n');
423
+ const rest = findings.length > 3 ? `\n …and ${findings.length - 3} more.` : '';
424
+
425
+ return {
426
+ severity: 'warn',
427
+ message:
428
+ `⚠ project-hygiene: ${findings.length} finding(s), ${mechanical} mechanically fixable:\n${head}${rest}`,
429
+ findings,
430
+ mechanical,
431
+ };
432
+ }