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
@@ -22,8 +22,8 @@
22
22
  * G7 transcript inspection via hooks/_lib/transcript-history.mjs.
23
23
  * If any prior Read tool call matches one of the host-specific template
24
24
  * paths from the policy, exit 0.
25
- * G8 fall-through: emit deny via stdout JSON + structured stderr listing the
26
- * template paths, and exit 2.
25
+ * G8 fall-through: emit the PreToolUse deny envelope on stdout via emitDeny
26
+ * (exit 0) with the template-path list + ack hint inside the reason.
27
27
  *
28
28
  * Fail-safe posture: any internal exception is swallowed in main().catch and
29
29
  * the hook exits 0 (allow). Rationale matches pre-bash-destructive-guard.mjs:
@@ -31,14 +31,17 @@
31
31
  * worst case is a missed enforcement, not a wedged session.
32
32
  *
33
33
  * Exit codes:
34
- * 0 — pass-through (G1-G3 short-circuits, bypass match, acknowledgement, Read found, error)
35
- * 2 — deny + structured JSON on stdout (PRD § 3 Gherkin Pattern 3)
34
+ * 0 — every path. Pass-through (G1-G3 short-circuits, bypass match,
35
+ * acknowledgement, Read found, error) emits nothing; G8 emits the deny
36
+ * envelope on stdout. Exit 2 is NEVER used: Claude Code discards stdout
37
+ * on exit 2, which would throw away the deny envelope entirely (#906).
36
38
  */
37
39
 
38
- import { readStdin, emitAllow } from '../scripts/lib/io.mjs';
40
+ import { readStdin, emitAllow, emitDeny } from '../scripts/lib/io.mjs';
39
41
  import { resolveProjectDir, resolvePluginRoot } from '../scripts/lib/platform.mjs';
40
42
  import { readJson } from '../scripts/lib/common.mjs';
41
43
  import { hasReadInSession } from './_lib/transcript-history.mjs';
44
+ import { resolveHost, matchesBypass } from './_lib/vcs-create-matcher.mjs';
42
45
 
43
46
  import { shouldRunHook } from './_lib/profile-gate.mjs';
44
47
  import { existsSync, readdirSync, lstatSync } from 'node:fs';
@@ -54,13 +57,11 @@ if (!shouldRunHook('pre-bash-templates-first')) process.exit(0);
54
57
  // ---------------------------------------------------------------------------
55
58
 
56
59
  /**
57
- * Matches the canonical `gh` / `glab` issue/PR/MR creation invocations.
58
- * Anchored at start (^) with optional leading whitespace to catch indented
59
- * shell snippets. Word-boundary at the end avoids false positives on tokens
60
- * like `created` or `news`. Edit operations (`gh pr edit`, `glab mr edit`)
61
- * are deliberately out of scope per PRD § 2 Out-of-Scope.
60
+ * The `gh`/`glab` create matcher (`CREATE_REGEX`, `resolveHost`,
61
+ * `matchesBypass`) now lives in `hooks/_lib/vcs-create-matcher.mjs` so the
62
+ * sibling issue-budget hook parses create commands with byte-identical
63
+ * semantics instead of a diverging copy. Behaviour here is unchanged.
62
64
  */
63
- const CREATE_REGEX = /^\s*(gh|glab)\s+(pr|mr|issue)\s+(create|new)\b/;
64
65
 
65
66
  /**
66
67
  * Default acknowledgement path, relative to project root. Used when the
@@ -68,14 +69,91 @@ const CREATE_REGEX = /^\s*(gh|glab)\s+(pr|mr|issue)\s+(create|new)\b/;
68
69
  */
69
70
  const DEFAULT_ACK_PATH = '.orchestrator/runtime/templates-acknowledged.json';
70
71
 
72
+ /**
73
+ * Ceiling (in characters) for the raw bash command echoed into the deny reason.
74
+ *
75
+ * ## Why this file needs its own bound at all (#919, follow-up to #906)
76
+ *
77
+ * `command` is the ONE attacker/agent-controlled term in the reason — every
78
+ * other line is fixed text or a repo-derived path list. Echoing it unbounded
79
+ * made this hook the shortest path to the failure mode #906 was repaired for:
80
+ * a reason large enough to push the stdout envelope past the 65 536-byte kernel
81
+ * pipe buffer, where a truncated envelope reads as "no decision" and the tool
82
+ * call is ALLOWED. `emitDeny`'s {@link DENY_REASON_MAX} clamp (16 000) now
83
+ * stands in that path, but a consumer that respects the bound itself is the
84
+ * more robust shape — defence in depth, not reliance on the single downstream
85
+ * clamp.
86
+ *
87
+ * The clamp alone is also NOT sufficient here, which is the concrete bug this
88
+ * constant fixes rather than merely hardens against. `Command:` is line 2 of 6;
89
+ * the template-path list and the `/templates-ack` hint are lines 3-6. A
90
+ * 200 000-char command therefore consumed the entire 16 000-char budget and cut
91
+ * the remedy off the end — the deny still bit, but PRD § 3 Gherkin Pattern 3's
92
+ * required content never reached the reader.
93
+ *
94
+ * ## Why 512 and not the 80 used at the two stderr sites below
95
+ *
96
+ * This file already truncates `command` twice (bypass-matched and
97
+ * no-templates-found), both at `slice(0, 80)`. Those are one-line **stderr log**
98
+ * lines, where 80 is the terminal-width convention — a different consumer class.
99
+ * The repo's precedent for bounding a bash command inside a **structured record
100
+ * field** is `hooks/pre-bash-staging-fence.mjs` (`staged_paths[].command`,
101
+ * `slice(0, 512)`), and `permissionDecisionReason` is exactly that: a structured
102
+ * field, not a log line. Reusing 512 follows the matching house convention
103
+ * instead of inventing a third number. Reusing 80 would clip a realistic
104
+ * `gh pr create --title … --body …` mid-flag, defeating the recognisability the
105
+ * line exists to provide.
106
+ *
107
+ * Bound check: the fixed part of this reason measures 348 chars (see the
108
+ * call-site table in `scripts/lib/io.mjs`), so the worst case is 348 + 512 +
109
+ * the path list — ~19× below `DENY_REASON_MAX` and ~76× below the pipe buffer.
110
+ * This hook no longer contributes an unbounded term to the envelope at all.
111
+ */
112
+ const COMMAND_ECHO_MAX = 512;
113
+
71
114
  // ---------------------------------------------------------------------------
72
115
  // Helpers
73
116
  // ---------------------------------------------------------------------------
74
117
 
75
118
  /**
76
- * Block the create command: write structured deny JSON to stdout + exit 2.
77
- * Mirrors the exact format from pre-bash-destructive-guard.mjs blockCommand()
78
- * so downstream Claude Code rendering is consistent.
119
+ * Clip the echoed command to {@link COMMAND_ECHO_MAX}, marking the cut so the
120
+ * reader can tell the command was truncated rather than ending there.
121
+ *
122
+ * The marker is budgeted INSIDE the ceiling — mirroring `_clampReason` in
123
+ * `scripts/lib/io.mjs` — so the returned string never exceeds it and the
124
+ * worst-case reason length stays a fixed, auditable number.
125
+ *
126
+ * @param {string} command
127
+ * @returns {string}
128
+ */
129
+ function clipCommand(command) {
130
+ if (command.length <= COMMAND_ECHO_MAX) return command;
131
+ const marker = `… [truncated: showing ${COMMAND_ECHO_MAX} of ${command.length} characters]`;
132
+ return command.slice(0, COMMAND_ECHO_MAX - marker.length) + marker;
133
+ }
134
+
135
+ /**
136
+ * Block the create command: emit the PreToolUse deny envelope via emitDeny
137
+ * (exit 0). Mirrors pre-bash-destructive-guard.mjs blockCommand() so downstream
138
+ * Claude Code rendering is consistent.
139
+ *
140
+ * PRD § 3 Gherkin Pattern 3 requires the template-path list plus the
141
+ * `/templates-ack` hint to reach the reader. That requirement is UNCHANGED —
142
+ * only the channel moved (#906). Until #906 this text was written to stderr AND
143
+ * duplicated into an `exit 2` stdout envelope, the mixed form the hook docs
144
+ * forbid ("choose one approach per hook, not both"). Under exit 0, stderr is
145
+ * only surfaced in the debug log — invisible to both operator and model — so a
146
+ * stderr write would look alive while being dead. The full multi-line text
147
+ * therefore travels as the emitDeny `reason`, landing in
148
+ * `permissionDecisionReason`, which is fed to **Claude** — the actor that has
149
+ * to read the template or run `/templates-ack`. The operator sees the first
150
+ * line via the derived `systemMessage` headline.
151
+ *
152
+ * The echoed `command` is bounded by {@link COMMAND_ECHO_MAX} before it enters
153
+ * the reason: it is the only agent-controlled term here, and left unbounded it
154
+ * both risked the pipe-buffer fail-open #906 repaired AND pushed the template
155
+ * list plus the ack hint past `emitDeny`'s clamp — i.e. truncated away exactly
156
+ * the content the PRD requires. See that constant for the full rationale.
79
157
  *
80
158
  * @param {{ host: string, command: string, templatePaths: string[],
81
159
  * ackFile: string }} ctx
@@ -88,21 +166,16 @@ function blockCreate(ctx) {
88
166
  : ' (none configured)';
89
167
  const reason = [
90
168
  `pre-bash-templates-first: ${host} create call detected without prior template Read.`,
91
- `Command: ${command}`,
169
+ `Command: ${clipCommand(command)}`,
92
170
  `Found templates:`,
93
171
  pathList,
94
172
  `Read one of these first, OR run \`/templates-ack\` (writes ${ackFile}) to bypass for this session.`,
95
173
  `See: issue #519, "gsd Pattern Adoption Quick-Wins" (archived in the private Meta-Vault) (Pattern 3)`,
96
174
  ].join('\n');
97
175
 
98
- // PRD § 3 Gherkin Pattern 3 spec: stderr lists template paths + ack hint.
99
- // We emit BOTH stderr (human-readable per spec) AND the structured stdout
100
- // JSON envelope (machine-readable for Claude Code hook protocol).
101
- process.stderr.write(reason + '\n');
102
- process.stdout.write(
103
- JSON.stringify({ permissionDecision: 'deny', reason }) + '\n',
104
- );
105
- process.exit(2);
176
+ // Single channel: the full multi-line reason (template paths + ack hint)
177
+ // rides in permissionDecisionReason. Never returns.
178
+ emitDeny(reason);
106
179
  }
107
180
 
108
181
  /**
@@ -142,46 +215,6 @@ function resolvePolicyPath(projectDir) {
142
215
  return null;
143
216
  }
144
217
 
145
- /**
146
- * Determine which host the command targets by inspecting the CREATE_REGEX
147
- * capture. `gh` → "github", `glab` → "gitlab".
148
- *
149
- * @param {string} command
150
- * @returns {"github"|"gitlab"|null}
151
- */
152
- function resolveHost(command) {
153
- const m = command.match(CREATE_REGEX);
154
- if (!m) return null;
155
- return m[1] === 'gh' ? 'github' : 'gitlab';
156
- }
157
-
158
- /**
159
- * True when the command starts with any of the bypass patterns. Bypass match
160
- * is a prefix check with a word/EOL boundary on the trailing edge — this
161
- * prevents trivial bypass via prefix-inclusion (e.g. policy entry
162
- * "gh issue create --label bot" must not match "gh issue create --label botanical").
163
- *
164
- * @param {string} command
165
- * @param {string[]} bypassPatterns
166
- * @returns {boolean}
167
- */
168
- function matchesBypass(command, bypassPatterns) {
169
- if (!Array.isArray(bypassPatterns) || bypassPatterns.length === 0) {
170
- return false;
171
- }
172
- const stripped = command.replace(/^\s+/, '');
173
- for (const pat of bypassPatterns) {
174
- if (typeof pat !== 'string' || pat.length === 0) continue;
175
- const patStripped = pat.replace(/^\s+/, '');
176
- if (!stripped.startsWith(patStripped)) continue;
177
- // Boundary check: next character must be whitespace, EOL, or absent.
178
- // This prevents "gh foo --label bot" from matching policy "gh foo --label botanical".
179
- const nextChar = stripped.charAt(patStripped.length);
180
- if (nextChar === '' || /\s/.test(nextChar)) return true;
181
- }
182
- return false;
183
- }
184
-
185
218
  /**
186
219
  * Check the acknowledgement file for the current session_id. Best-effort:
187
220
  * any read or parse error means "no acknowledgement" (returns false) so the
@@ -6,23 +6,35 @@
6
6
  * Fires when the Skill tool is invoked (a skill is selected). Writes a
7
7
  * selection record to `.orchestrator/metrics/skill-invocations.jsonl`.
8
8
  *
9
- * Decision flow:
9
+ * Decision flow (when run as the hook, not imported):
10
10
  * 1. shouldRunHook gate — exit 0 immediately when the hook is disabled.
11
11
  * 2. Read JSON payload from stdin: { tool_name, tool_input: { skill }, session_id }.
12
12
  * 3. Belt-and-suspenders guard: if tool_name !== "Skill", exit 0 immediately.
13
13
  * 4. Build a 'selected' record and call appendSkillInvocation().
14
- * 5. Output: nothing on stdout. Diagnostic errors to stderr only.
14
+ * 5. Daily-fallback telemetry flush check (Epic #841, #844) non-blocking:
15
+ * when a bounded offline queue has aged past 24h AND consent resolves to
16
+ * send, spawn a detached child that runs `telemetry _flush`. Cheap by
17
+ * construction (env kill-switch pre-check → queue+state stat → consent)
18
+ * and never loads the roster on the hot hook path.
19
+ * 6. Output: nothing on stdout. Diagnostic errors to stderr only.
15
20
  *
16
21
  * Exit codes: 0 always (informational, never blocking).
22
+ *
23
+ * The module is import-safe: the self-execution block below is guarded so that a
24
+ * test can `import { maybeSpawnDailyFlush }` without triggering the hook's
25
+ * stdin-read + exit path.
17
26
  */
18
27
 
19
- import { shouldRunHook } from './_lib/profile-gate.mjs';
20
- // Exit 0 immediately when disabled via SO_HOOK_PROFILE / SO_DISABLED_HOOKS.
21
- if (!shouldRunHook('skill-invocation-telemetry')) process.exit(0);
22
-
23
28
  import path from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+ import { spawn } from 'node:child_process';
31
+
32
+ import { shouldRunHook } from './_lib/profile-gate.mjs';
24
33
  import { appendSkillInvocation } from '../scripts/lib/skill-invocations-schema.mjs';
25
34
  import { SO_PROJECT_DIR } from '../scripts/lib/platform.mjs';
35
+ import { shouldDailyFlush } from '../scripts/lib/telemetry/sync.mjs';
36
+ import { resolveConsent, readTelemetryState } from '../scripts/lib/telemetry/consent.mjs';
37
+ import { loadOwnerConfig } from '../scripts/lib/owner-yaml.mjs';
26
38
 
27
39
  // ---------------------------------------------------------------------------
28
40
  // Constants
@@ -30,10 +42,29 @@ import { SO_PROJECT_DIR } from '../scripts/lib/platform.mjs';
30
42
 
31
43
  const JSONL_PATH = path.join(SO_PROJECT_DIR, '.orchestrator', 'metrics', 'skill-invocations.jsonl');
32
44
 
45
+ /** Absolute path to the telemetry CLI (carries the hidden `_flush` subcommand). */
46
+ const TELEMETRY_CLI_PATH = path.resolve(
47
+ path.dirname(fileURLToPath(import.meta.url)),
48
+ '../scripts/telemetry.mjs',
49
+ );
50
+
33
51
  // ---------------------------------------------------------------------------
34
52
  // Helpers
35
53
  // ---------------------------------------------------------------------------
36
54
 
55
+ /**
56
+ * True when an env var carries a truthy "on" signal (present, non-empty, not
57
+ * '0'/'false'). Kept local so the hook does not pull anything extra from consent.mjs.
58
+ * @param {unknown} raw
59
+ * @returns {boolean}
60
+ */
61
+ function isTruthyEnvFlag(raw) {
62
+ if (raw === undefined || raw === null) return false;
63
+ const t = String(raw).trim();
64
+ if (t === '' || t === '0') return false;
65
+ return t.toLowerCase() !== 'false';
66
+ }
67
+
37
68
  /**
38
69
  * Read stdin to EOF (best-effort). Returns parsed JSON or null on failure.
39
70
  * Uses a 5 s timeout consistent with Claude Code hook contract.
@@ -61,6 +92,61 @@ function readStdinJson() {
61
92
  });
62
93
  }
63
94
 
95
+ /**
96
+ * Non-blocking daily-fallback flush trigger. Ordered cheapest-first:
97
+ * 1. env kill-switches (no I/O) — DO_NOT_TRACK / SO_TELEMETRY_DISABLED.
98
+ * 2. shouldDailyFlush (one telemetry.json read + one queue stat) — the common
99
+ * case (empty queue) returns here WITHOUT loading owner.yaml or the roster.
100
+ * 3. full consent resolution — spawn a detached `telemetry _flush` only when it
101
+ * resolves to send.
102
+ *
103
+ * Never throws; the caller's hook must always exit 0.
104
+ *
105
+ * @param {object} [opts]
106
+ * @param {NodeJS.ProcessEnv} [opts.env] Env source (default process.env).
107
+ * @param {typeof spawn} [opts.spawnFn] Spawn function (test injection).
108
+ * @param {number} [opts.now] Reference time epoch-ms (default Date.now()).
109
+ * @param {string} [opts.statePath] telemetry.json path override (test injection).
110
+ * @param {string} [opts.queuePath] queue path override (test injection).
111
+ * @returns {{ spawned: boolean, reason: string }}
112
+ */
113
+ export function maybeSpawnDailyFlush({
114
+ env = process.env,
115
+ spawnFn = spawn,
116
+ now = Date.now(),
117
+ statePath,
118
+ queuePath,
119
+ } = {}) {
120
+ try {
121
+ // 1. Cheapest gate: env kill-switches, no file I/O.
122
+ if (isTruthyEnvFlag(env?.DO_NOT_TRACK) || env?.SO_TELEMETRY_DISABLED === '1') {
123
+ return { spawned: false, reason: 'disabled-env' };
124
+ }
125
+
126
+ // 2. Cheap backlog check — bails out before owner.yaml load in the common case.
127
+ if (!shouldDailyFlush({ statePath, queuePath, now })) {
128
+ return { spawned: false, reason: 'not-due' };
129
+ }
130
+
131
+ // 3. Authoritative consent gate.
132
+ const ownerConfig = loadOwnerConfig().config;
133
+ const { record } = readTelemetryState({ path: statePath });
134
+ const consent = resolveConsent({ env, ownerConfig, state: record, interactive: false });
135
+ if (consent.send !== true) {
136
+ return { spawned: false, reason: 'gated' };
137
+ }
138
+
139
+ const child = spawnFn(process.execPath, [TELEMETRY_CLI_PATH, '_flush'], {
140
+ detached: true,
141
+ stdio: 'ignore',
142
+ });
143
+ if (child && typeof child.unref === 'function') child.unref();
144
+ return { spawned: true, reason: 'spawned' };
145
+ } catch {
146
+ return { spawned: false, reason: 'error' };
147
+ }
148
+ }
149
+
64
150
  // ---------------------------------------------------------------------------
65
151
  // Main
66
152
  // ---------------------------------------------------------------------------
@@ -91,9 +177,22 @@ async function main() {
91
177
  };
92
178
 
93
179
  await appendSkillInvocation(JSONL_PATH, record);
180
+
181
+ // Daily-fallback telemetry flush — non-blocking, best-effort, never throws.
182
+ maybeSpawnDailyFlush();
94
183
  }
95
184
 
96
- // Exit 0 always — informational hook must never block the Skill tool.
97
- main().catch((err) => {
98
- process.stderr.write(`[skill-invocation-telemetry] ERROR: ${err?.message ?? err}\n`);
99
- }).finally(() => process.exit(0));
185
+ // ---------------------------------------------------------------------------
186
+ // Self-execution guard — run only when invoked directly (not when imported).
187
+ // ---------------------------------------------------------------------------
188
+
189
+ const isMain = process.argv[1] === fileURLToPath(import.meta.url);
190
+ if (isMain) {
191
+ // Exit 0 immediately when disabled via SO_HOOK_PROFILE / SO_DISABLED_HOOKS.
192
+ if (!shouldRunHook('skill-invocation-telemetry')) process.exit(0);
193
+
194
+ // Exit 0 always — informational hook must never block the Skill tool.
195
+ main().catch((err) => {
196
+ process.stderr.write(`[skill-invocation-telemetry] ERROR: ${err?.message ?? err}\n`);
197
+ }).finally(() => process.exit(0));
198
+ }