@mmerterden/multi-agent-pipeline 20.28.0 → 20.30.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 (186) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +19 -6
  3. package/README.tr.md +5 -5
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/facts.json +7 -7
  7. package/install/catalog-history.json +1 -1
  8. package/install/claude.mjs +122 -1
  9. package/install/codex.mjs +2 -0
  10. package/install/copilot.mjs +13 -0
  11. package/install/templates/claude-hooks.json +30 -1
  12. package/manifest.json +189 -77
  13. package/package.json +2 -1
  14. package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -1
  15. package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +13 -15
  16. package/pipeline/commands/multi-agent/office-off/SKILL.md +27 -0
  17. package/pipeline/commands/multi-agent/office-on/SKILL.md +50 -0
  18. package/pipeline/commands/multi-agent/office-status/SKILL.md +29 -0
  19. package/pipeline/commands/multi-agent/resume/SKILL.md +2 -1
  20. package/pipeline/commands/multi-agent/serve/SKILL.md +4 -2
  21. package/pipeline/commands/multi-agent/stack/SKILL.md +15 -8
  22. package/pipeline/commands/multi-agent/steer/SKILL.md +10 -9
  23. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  24. package/pipeline/contract/CHANGELOG.md +62 -0
  25. package/pipeline/contract/README.md +52 -6
  26. package/pipeline/contract/fixtures/error-invalid-request.json +1 -1
  27. package/pipeline/contract/fixtures/error-unauthorized.json +1 -1
  28. package/pipeline/contract/fixtures/error-unsigned.json +1 -1
  29. package/pipeline/contract/manifest.json +367 -22
  30. package/pipeline/contract/types/index.d.ts +531 -4
  31. package/pipeline/lib/hook-registration.mjs +252 -0
  32. package/pipeline/lib/prefs-value.mjs +44 -0
  33. package/pipeline/lib/run-completion.mjs +148 -0
  34. package/pipeline/multi-agent-refs/analysis/render.md +4 -4
  35. package/pipeline/multi-agent-refs/cross-cli-contract.md +5 -4
  36. package/pipeline/multi-agent-refs/features/doctor.md +11 -0
  37. package/pipeline/multi-agent-refs/features/office-threat-model.md +91 -0
  38. package/pipeline/multi-agent-refs/help/en.md +3 -0
  39. package/pipeline/multi-agent-refs/help/tr.md +3 -0
  40. package/pipeline/multi-agent-refs/phases/operations.md +17 -0
  41. package/pipeline/multi-agent-refs/phases/phase-0-init.md +6 -2
  42. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
  43. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +1 -1
  44. package/pipeline/multi-agent-refs/phases/phase-3-review.md +2 -2
  45. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -6
  46. package/pipeline/multi-agent-refs/phases/phase-5-report.md +5 -4
  47. package/pipeline/office/NOTICE.md +42 -0
  48. package/pipeline/office/assets/BIN.png +0 -0
  49. package/pipeline/office/assets/BOOKSHELF.png +0 -0
  50. package/pipeline/office/assets/CACTUS.png +0 -0
  51. package/pipeline/office/assets/CLOCK.png +0 -0
  52. package/pipeline/office/assets/COFFEE.png +0 -0
  53. package/pipeline/office/assets/COFFEE_TABLE.png +0 -0
  54. package/pipeline/office/assets/CUSHIONED_BENCH.png +0 -0
  55. package/pipeline/office/assets/CUSHIONED_CHAIR_BACK.png +0 -0
  56. package/pipeline/office/assets/CUSHIONED_CHAIR_FRONT.png +0 -0
  57. package/pipeline/office/assets/CUSHIONED_CHAIR_SIDE.png +0 -0
  58. package/pipeline/office/assets/DESK_FRONT.png +0 -0
  59. package/pipeline/office/assets/DESK_SIDE.png +0 -0
  60. package/pipeline/office/assets/DOUBLE_BOOKSHELF.png +0 -0
  61. package/pipeline/office/assets/HANGING_PLANT.png +0 -0
  62. package/pipeline/office/assets/LARGE_PAINTING.png +0 -0
  63. package/pipeline/office/assets/LARGE_PLANT.png +0 -0
  64. package/pipeline/office/assets/PC_BACK.png +0 -0
  65. package/pipeline/office/assets/PC_FRONT_OFF.png +0 -0
  66. package/pipeline/office/assets/PC_FRONT_ON_1.png +0 -0
  67. package/pipeline/office/assets/PC_FRONT_ON_2.png +0 -0
  68. package/pipeline/office/assets/PC_FRONT_ON_3.png +0 -0
  69. package/pipeline/office/assets/PC_SIDE.png +0 -0
  70. package/pipeline/office/assets/PLANT.png +0 -0
  71. package/pipeline/office/assets/PLANT_2.png +0 -0
  72. package/pipeline/office/assets/POT.png +0 -0
  73. package/pipeline/office/assets/SMALL_PAINTING.png +0 -0
  74. package/pipeline/office/assets/SMALL_PAINTING_2.png +0 -0
  75. package/pipeline/office/assets/SMALL_TABLE_FRONT.png +0 -0
  76. package/pipeline/office/assets/SMALL_TABLE_SIDE.png +0 -0
  77. package/pipeline/office/assets/SOFA_BACK.png +0 -0
  78. package/pipeline/office/assets/SOFA_FRONT.png +0 -0
  79. package/pipeline/office/assets/SOFA_SIDE.png +0 -0
  80. package/pipeline/office/assets/TABLE_FRONT.png +0 -0
  81. package/pipeline/office/assets/WHITEBOARD.png +0 -0
  82. package/pipeline/office/assets/WOODEN_BENCH.png +0 -0
  83. package/pipeline/office/assets/WOODEN_CHAIR_BACK.png +0 -0
  84. package/pipeline/office/assets/WOODEN_CHAIR_FRONT.png +0 -0
  85. package/pipeline/office/assets/WOODEN_CHAIR_SIDE.png +0 -0
  86. package/pipeline/office/assets/char_0.png +0 -0
  87. package/pipeline/office/assets/char_1.png +0 -0
  88. package/pipeline/office/assets/char_2.png +0 -0
  89. package/pipeline/office/assets/char_3.png +0 -0
  90. package/pipeline/office/assets/char_4.png +0 -0
  91. package/pipeline/office/assets/char_5.png +0 -0
  92. package/pipeline/office/assets/floor_0.png +0 -0
  93. package/pipeline/office/assets/floor_1.png +0 -0
  94. package/pipeline/office/assets/floor_2.png +0 -0
  95. package/pipeline/office/assets/floor_3.png +0 -0
  96. package/pipeline/office/assets/floor_4.png +0 -0
  97. package/pipeline/office/assets/floor_5.png +0 -0
  98. package/pipeline/office/assets/floor_6.png +0 -0
  99. package/pipeline/office/assets/floor_7.png +0 -0
  100. package/pipeline/office/assets/floor_8.png +0 -0
  101. package/pipeline/office/assets/wall_0.png +0 -0
  102. package/pipeline/office/index.html +5319 -0
  103. package/pipeline/preferences-template.json +7 -0
  104. package/pipeline/rules/figma-pipeline.md +2 -0
  105. package/pipeline/schemas/activity-chat.schema.json +78 -0
  106. package/pipeline/schemas/activity-roles.json +48 -0
  107. package/pipeline/schemas/activity-roles.schema.json +49 -0
  108. package/pipeline/schemas/activity.schema.json +429 -0
  109. package/pipeline/schemas/agent-state.schema.json +20 -5
  110. package/pipeline/schemas/autopilot-on-request.schema.json +33 -0
  111. package/pipeline/schemas/autopilot-on.schema.json +111 -0
  112. package/pipeline/schemas/contract-error.schema.json +33 -3
  113. package/pipeline/schemas/issue-maturity.schema.json +68 -0
  114. package/pipeline/schemas/ledger.schema.json +202 -0
  115. package/pipeline/schemas/permissions.schema.json +47 -0
  116. package/pipeline/schemas/phases.json +3 -2
  117. package/pipeline/schemas/phases.schema.json +5 -1
  118. package/pipeline/schemas/prefs.schema.json +56 -0
  119. package/pipeline/schemas/prs.schema.json +73 -0
  120. package/pipeline/schemas/review-verdict-request.schema.json +20 -0
  121. package/pipeline/schemas/review-verdict.schema.json +62 -0
  122. package/pipeline/schemas/routine-delete.schema.json +59 -0
  123. package/pipeline/schemas/routines.schema.json +38 -0
  124. package/pipeline/schemas/run-evidence.schema.json +29 -0
  125. package/pipeline/schemas/run-results.schema.json +218 -0
  126. package/pipeline/schemas/runs-index.schema.json +1 -1
  127. package/pipeline/schemas/service-read.schema.json +58 -0
  128. package/pipeline/schemas/stack-change.schema.json +82 -0
  129. package/pipeline/schemas/stack-request.schema.json +33 -0
  130. package/pipeline/schemas/stack.schema.json +59 -0
  131. package/pipeline/schemas/steer-request.schema.json +25 -0
  132. package/pipeline/schemas/steer.schema.json +59 -0
  133. package/pipeline/schemas/tool-call-request.schema.json +19 -0
  134. package/pipeline/schemas/tool-call.schema.json +47 -0
  135. package/pipeline/schemas/tools.schema.json +60 -0
  136. package/pipeline/scripts/_active-run.mjs +158 -0
  137. package/pipeline/scripts/_autopilot-outcomes.mjs +1 -0
  138. package/pipeline/scripts/_cost.mjs +66 -1
  139. package/pipeline/scripts/_guard_rules.py +605 -0
  140. package/pipeline/scripts/_office-smoke-lib.sh +115 -0
  141. package/pipeline/scripts/_spawn-async.mjs +73 -0
  142. package/pipeline/scripts/_transcript-tail.mjs +274 -0
  143. package/pipeline/scripts/activity-snapshot.mjs +1027 -0
  144. package/pipeline/scripts/agent-guard.py +118 -7
  145. package/pipeline/scripts/agent-guard.sh +50 -3
  146. package/pipeline/scripts/argv-secret-scan.mjs +114 -0
  147. package/pipeline/scripts/autopilot-control.mjs +214 -9
  148. package/pipeline/scripts/autopilot-runner.mjs +8 -2
  149. package/pipeline/scripts/confirm-command.mjs +70 -0
  150. package/pipeline/scripts/contract-server.mjs +1178 -48
  151. package/pipeline/scripts/cost-analyze.mjs +2 -53
  152. package/pipeline/scripts/cost-table.json +1 -0
  153. package/pipeline/scripts/doctor.mjs +54 -0
  154. package/pipeline/scripts/evidence-gate.mjs +8 -4
  155. package/pipeline/scripts/gate-ledger.mjs +163 -7
  156. package/pipeline/scripts/office-build.mjs +89 -0
  157. package/pipeline/scripts/office-control.mjs +383 -0
  158. package/pipeline/scripts/office-ledger.mjs +409 -0
  159. package/pipeline/scripts/plan-coverage-gate.mjs +9 -8
  160. package/pipeline/scripts/pre-commit-check.sh +12 -0
  161. package/pipeline/scripts/routine-registry.mjs +42 -6
  162. package/pipeline/scripts/run-results.mjs +373 -0
  163. package/pipeline/scripts/run-scope-guard.mjs +188 -0
  164. package/pipeline/scripts/run-scope-guard.sh +23 -0
  165. package/pipeline/scripts/run-stop-guard.mjs +246 -0
  166. package/pipeline/scripts/run-stop-guard.sh +25 -0
  167. package/pipeline/scripts/service-read.mjs +244 -0
  168. package/pipeline/scripts/stack-toggle.mjs +285 -0
  169. package/pipeline/scripts/steer-run.mjs +97 -0
  170. package/pipeline/scripts/toolkit-tools.mjs +599 -0
  171. package/pipeline/scripts/uninstall.mjs +4 -0
  172. package/pipeline/scripts/validate-state.mjs +26 -0
  173. package/pipeline/scripts/work-items.mjs +304 -0
  174. package/pipeline/scripts/write-state.mjs +114 -0
  175. package/pipeline/skills/.skill-manifest.json +19 -7
  176. package/pipeline/skills/.skills-index.json +34 -1
  177. package/pipeline/skills/shared/README.md +6 -3
  178. package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +13 -15
  179. package/pipeline/skills/shared/core/multi-agent-office-off/SKILL.md +16 -0
  180. package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +22 -0
  181. package/pipeline/skills/shared/core/multi-agent-office-status/SKILL.md +17 -0
  182. package/pipeline/skills/shared/core/multi-agent-serve/SKILL.md +4 -2
  183. package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +14 -6
  184. package/pipeline/skills/shared/core/multi-agent-steer/SKILL.md +10 -9
  185. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  186. package/pipeline/skills/skills-index.md +4 -1
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * run-stop-guard.mjs - a session driving a pipeline run does not end its turn
4
+ * mid-phase without saying why.
5
+ *
6
+ * Claude Code Stop hook. When the session that is driving an active run tries
7
+ * to stop while the run is `in_progress`, below its mode's last phase, with no
8
+ * `haltReason` and no `waitingFor`, it answers
9
+ * `{"decision":"block","reason":...}`. The reason names the phase to finish
10
+ * and the two legal ways out: finish it, or record a halt (operations.md,
11
+ * "Halt visibility").
12
+ *
13
+ * Which run: _active-run.mjs findActiveRun, and only a run bound to this
14
+ * session by `sessionId` (MULTI_AGENT_SESSION_ID) or `hostSessionId`. A run
15
+ * found only because its worktree is the session's cwd never holds a stop: an
16
+ * old local-mode run has the project root as its worktree and would trap
17
+ * every later session opened there. No bound run: exit 0, no output.
18
+ *
19
+ * The stop is let through, with no output, whenever the session has a reason
20
+ * to stop that the state does not record:
21
+ * - the turn ends waiting for the user: `last_assistant_message` ends in a
22
+ * question or asks for a reply (the pipeline's plain-text waits: plan
23
+ * approval, commit, PR, a local-test handoff);
24
+ * - background tasks or scheduled crons are running, so the run is driven
25
+ * elsewhere and this session is waiting;
26
+ * - prefs.global.stopGuard.enabled is false.
27
+ *
28
+ * SubagentStop is never held: a subagent finishes its piece and the session
29
+ * that dispatched it decides what comes next.
30
+ *
31
+ * Repeat ceiling: the host sets `stop_hook_active` while it continues because
32
+ * of a Stop hook, and overrides a hook that blocks too many times in a row.
33
+ * This hook counts its own consecutive blocks per run in `.stop-guard.json`
34
+ * next to the state. After REPEAT_LIMIT blocks it stops blocking, writes the
35
+ * halt record itself (`status: paused`, `haltReason: "<phase>:stop-guard: ..."`)
36
+ * and tells the user through `systemMessage`. write-state.mjs clears that
37
+ * halt the moment the run advances a phase, so it never outlives the run
38
+ * moving on. A stop with `stop_hook_active` false is a new turn and starts the
39
+ * count again.
40
+ *
41
+ * This is a completion aid, not a security gate: any error (unparseable
42
+ * payload, unreadable state, a failed write) exits 0 with no decision and a
43
+ * line in <log root>/hook-failures.log.
44
+ *
45
+ * @module pipeline/scripts/run-stop-guard
46
+ */
47
+
48
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
49
+ import { join } from "node:path";
50
+
51
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
52
+ import { globalPref } from "../lib/prefs-value.mjs";
53
+ import {
54
+ finalPhaseOf,
55
+ findActiveRun,
56
+ logHookFailure,
57
+ modeOf,
58
+ phaseName,
59
+ runDirOf,
60
+ } from "./_active-run.mjs";
61
+
62
+ export const REPEAT_LIMIT = 3;
63
+ export const STOP_GUARD_HALT = "stop-guard:";
64
+ const COUNTER_FILE = ".stop-guard.json";
65
+ const HOOK = "run-stop-guard";
66
+ const SESSION_BOUND = new Set(["session", "host-session"]);
67
+
68
+ // A reply the turn asks for, in the closing text: a question mark (ASCII or
69
+ // full-width) at the end of a line, or a request addressed to the user. A
70
+ // request is an imperative that opens a sentence ("Confirm the plan.") or a
71
+ // second-person phrase ("can you", "tell me"); a reply verb inside a
72
+ // statement ("Next I will confirm the build") is the model's own plan and
73
+ // does not count, nor does one used as a label ("Answer: the cache was stale").
74
+ const QUESTION_END = /[??]\s*(?:\(.{0,40}\))?\s*$/;
75
+ const REPLY_VERB =
76
+ "(?:reply|respond|answer|confirm|choose|pick|approve|send|write your|tell me|let me know)";
77
+ const ASKS_FOR_REPLY = new RegExp(
78
+ [
79
+ `(?:^|[.!:;]\\s+)(?:please\\s+)?${REPLY_VERB}\\b(?!\\s*:)`,
80
+ `\\bplease\\s+${REPLY_VERB}\\b(?!\\s*:)`,
81
+ "\\b(?:can|could|would|will) you\\b",
82
+ "\\b(?:do|would) you (?:want|like|prefer)\\b",
83
+ "\\b(?:let me know|tell me)\\b",
84
+ "\\b(?:reply|respond|answer) with\\b",
85
+ "\\(\\s*(?:yes|y)\\s*\\/\\s*(?:no|n)\\s*\\)",
86
+ ].join("|"),
87
+ "im",
88
+ );
89
+
90
+ /**
91
+ * True when the closing assistant text waits for the user.
92
+ *
93
+ * @param {unknown} message last_assistant_message
94
+ */
95
+ export function asksTheUser(message) {
96
+ if (typeof message !== "string" || !message.trim()) return false;
97
+ const lines = message
98
+ .trim()
99
+ .split("\n")
100
+ .map((l) => l.replace(/[*_`>#]+/g, "").trim())
101
+ .filter(Boolean);
102
+ const tail = lines.slice(-3);
103
+ if (tail.some((l) => QUESTION_END.test(l))) return true;
104
+ return ASKS_FOR_REPLY.test(tail.join("\n"));
105
+ }
106
+
107
+ /**
108
+ * Why the run may stop now, or null when it is mid-phase and must not.
109
+ *
110
+ * @param {object} state
111
+ * @returns {string|null}
112
+ */
113
+ export function stopAllowedBecause(state) {
114
+ if (!state || typeof state !== "object") return "no state";
115
+ if (state.status !== "in_progress") return `status is ${state.status ?? "unset"}`;
116
+ if (state.haltReason) return "a halt is recorded";
117
+ if (state.waitingFor) return `waiting for ${state.waitingFor}`;
118
+ const final = finalPhaseOf(state);
119
+ if (final === null) return `mode ${modeOf(state)} is not declared in phases.json`;
120
+ const cur = Number(state.currentPhase);
121
+ if (!Number.isInteger(cur)) return "currentPhase is not recorded";
122
+ if (cur >= final) return "the final phase is reached";
123
+ return null;
124
+ }
125
+
126
+ /** The block reason: where the run is and the two ways out. */
127
+ export function blockReason(state, statePath) {
128
+ const cur = Number(state.currentPhase);
129
+ const final = finalPhaseOf(state);
130
+ const id = state.taskId || "this run";
131
+ return [
132
+ `Pipeline run ${id} (mode ${modeOf(state)}) is at Phase ${cur} (${phaseName(cur)}) of ${final}, status in_progress, and no halt or wait is recorded.`,
133
+ `Now finish Phase ${cur} (${phaseName(cur)}) and go on as its document says.`,
134
+ `If you are waiting for the user, ask the question so the turn ends on it, or record the wait: printf '{"waitingFor":"attended-reply"}' | node "$HOME/.claude/scripts/write-state.mjs" "${statePath}".`,
135
+ `If the run cannot go on, record the halt before stopping: printf '{"status":"paused","haltReason":"${cur}:<cause>"}' | node "$HOME/.claude/scripts/write-state.mjs" "${statePath}" (operations.md, Halt visibility).`,
136
+ ].join(" ");
137
+ }
138
+
139
+ function readCounter(dir) {
140
+ try {
141
+ const doc = JSON.parse(readFileSync(join(dir, COUNTER_FILE), "utf8"));
142
+ return Number.isInteger(doc?.count) ? doc.count : 0;
143
+ } catch {
144
+ return 0;
145
+ }
146
+ }
147
+
148
+ function writeCounter(dir, count) {
149
+ writeFileSync(
150
+ join(dir, COUNTER_FILE),
151
+ `${JSON.stringify({ count, at: new Date().toISOString() })}\n`,
152
+ );
153
+ }
154
+
155
+ function clearCounter(dir) {
156
+ rmSync(join(dir, COUNTER_FILE), { force: true });
157
+ }
158
+
159
+ async function recordHalt(statePath, state, blocks) {
160
+ const { updateState } = await import("./gate-ledger.mjs");
161
+ const cur = Number(state.currentPhase);
162
+ const cause = `${STOP_GUARD_HALT} the guard blocked ${blocks} consecutive stops before Phase ${cur} (${phaseName(cur)}) finished`;
163
+ await updateState(statePath, (s) => ({
164
+ ...s,
165
+ status: "paused",
166
+ haltReason: `${cur}:${cause}`,
167
+ }));
168
+ return cause;
169
+ }
170
+
171
+ const nonEmpty = (v) => Array.isArray(v) && v.length > 0;
172
+
173
+ /**
174
+ * The hook's answer for one payload.
175
+ *
176
+ * @param {object} payload
177
+ * @param {Record<string,string|undefined>} [env]
178
+ * @returns {Promise<object|null>} the JSON to print, or null for no output
179
+ */
180
+ export async function decide(payload, env = process.env) {
181
+ if (globalPref("stopGuard.enabled", true, env) === false) return null;
182
+ if (!payload || typeof payload !== "object") return null;
183
+ const event = payload.hook_event_name || "Stop";
184
+ if (event !== "Stop") return null;
185
+ if (nonEmpty(payload.background_tasks) || nonEmpty(payload.session_crons)) return null;
186
+ if (asksTheUser(payload.last_assistant_message)) return null;
187
+ const run = findActiveRun({
188
+ sessionId: typeof payload.session_id === "string" ? payload.session_id : null,
189
+ cwd: typeof payload.cwd === "string" ? payload.cwd : null,
190
+ env,
191
+ });
192
+ if (!run || !SESSION_BOUND.has(run.matchedBy)) {
193
+ if (!run && findActiveRun.lastUnreadable > 0) {
194
+ logHookFailure(
195
+ HOOK,
196
+ `${findActiveRun.lastUnreadable} run state file(s) did not parse; allowed the stop`,
197
+ env,
198
+ );
199
+ }
200
+ return null;
201
+ }
202
+ const dir = runDirOf(run.statePath);
203
+ if (stopAllowedBecause(run.state) !== null) {
204
+ if (existsSync(join(dir, COUNTER_FILE))) clearCounter(dir);
205
+ return null;
206
+ }
207
+
208
+ const prior = payload.stop_hook_active === true ? readCounter(dir) : 0;
209
+ if (prior >= REPEAT_LIMIT) {
210
+ const cause = await recordHalt(run.statePath, run.state, prior);
211
+ clearCounter(dir);
212
+ return {
213
+ systemMessage: `run-stop-guard: recorded a halt on ${run.state.taskId || run.statePath} (${cause}). Resume it with /multi-agent:resume.`,
214
+ };
215
+ }
216
+ writeCounter(dir, prior + 1);
217
+ return { decision: "block", reason: blockReason(run.state, run.statePath) };
218
+ }
219
+
220
+ async function main() {
221
+ let raw;
222
+ try {
223
+ raw = readFileSync(0, "utf8");
224
+ } catch {
225
+ raw = "";
226
+ }
227
+ let payload;
228
+ try {
229
+ payload = JSON.parse(raw);
230
+ } catch {
231
+ logHookFailure(HOOK, "the hook payload is not JSON; allowed the stop");
232
+ return;
233
+ }
234
+ try {
235
+ const out = await decide(payload);
236
+ if (out) process.stdout.write(`${JSON.stringify(out)}\n`);
237
+ } catch (err) {
238
+ logHookFailure(HOOK, `${err?.message || err}; allowed the stop`);
239
+ }
240
+ }
241
+
242
+ const onFatal = (err) => logHookFailure(HOOK, `${err?.message || err}; allowed the stop`);
243
+
244
+ // Exit 0 on every path: nothing above sets a non-zero code, and a failure
245
+ // is logged and allowed.
246
+ if (invokedDirectly(import.meta.url)) main().catch(onFatal);
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env bash
2
+ # run-stop-guard.sh - Stop hook for the multi-agent pipeline.
3
+ #
4
+ # A session driving a pipeline run that tries to end its turn mid-run, with no
5
+ # halt recorded, is told to continue or record the halt. The decision is in
6
+ # run-stop-guard.mjs; this wrapper exists because every hook the pipeline
7
+ # registers is a `bash "$HOME/.<host>/scripts/<name>"` command.
8
+ #
9
+ # Contract (Claude Code Stop hook):
10
+ # - Reads the hook JSON on stdin.
11
+ # - Prints {"decision":"block","reason":...} to keep the session going, or
12
+ # {"systemMessage":...} when it gives up and records the halt itself, or
13
+ # nothing.
14
+ # - Exits 0 on every path. A missing node or any internal error means no
15
+ # output: this is a completion aid, not a security gate.
16
+
17
+ set -u
18
+
19
+ HERE="$(cd "$(dirname "$0")" 2>/dev/null && pwd || true)"
20
+ CORE="$HERE/run-stop-guard.mjs"
21
+ [ -f "$CORE" ] || exit 0
22
+ command -v node >/dev/null 2>&1 || exit 0
23
+
24
+ node "$CORE" 2>/dev/null || true
25
+ exit 0
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file service-read.mjs - read one thing from Confluence, Crashlytics or
4
+ * Figma through the clients the pipeline already ships, for a client that
5
+ * shows it next to a run:
6
+ *
7
+ * confluence <page> lib/fetch-confluence.sh: a page by id or by https
8
+ * URL, both only on the configured
9
+ * prefs.global.hosts.confluence; a URL on any other
10
+ * host, or any URL when no host is configured, is
11
+ * refused before the script runs; title, markdown
12
+ * body, API contracts count
13
+ * crashlytics <issue-url> lib/fetch-crashlytics.sh: one Crashlytics issue,
14
+ * title, occurrences, impacted users, top frames
15
+ * figma <file-key> <node> lib/figma-screenshot.sh (REST with the Figma
16
+ * token, tier 2): one PNG render of a frame. The
17
+ * Figma MCP is never called here; the pipeline
18
+ * keeps MCP access to the analysis phase.
19
+ *
20
+ * Search across Confluence, a per-version Crashlytics trend and a Figma file
21
+ * browser have no client in the pipeline, so none is offered; a client asks
22
+ * an agent instead.
23
+ *
24
+ * The scripts run as child processes without blocking the caller's event
25
+ * loop (contract-server.mjs serves other routes meanwhile).
26
+ *
27
+ * Credentials are read only by those scripts, through credential-store.sh.
28
+ * Every text that comes back is scrubbed and redacted; an image comes back as
29
+ * base64.
30
+ *
31
+ * Usage:
32
+ * service-read.mjs confluence <page-id|https-url>
33
+ * service-read.mjs crashlytics <https-issue-url>
34
+ * service-read.mjs figma <file-key> <node-id>
35
+ *
36
+ * MA_FETCH_CONFLUENCE, MA_FETCH_CRASHLYTICS and MA_FIGMA_SCREENSHOT stand in
37
+ * for the scripts (tests).
38
+ *
39
+ * Exit: 0 printed, 1 refused or failed, 2 usage.
40
+ *
41
+ * @module pipeline/scripts/service-read
42
+ */
43
+
44
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
45
+ import { homedir, tmpdir } from "node:os";
46
+ import { dirname, join } from "node:path";
47
+ import { fileURLToPath } from "node:url";
48
+ import { createRedactor } from "./transcript-index.mjs";
49
+ import { spawnAsync } from "./_spawn-async.mjs";
50
+ import { runMain } from "../lib/fatal.mjs";
51
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
52
+
53
+ export const CONTRACT_VERSION = "1.0.0";
54
+ const HERE = dirname(fileURLToPath(import.meta.url));
55
+ const TIMEOUT_MS = 90_000;
56
+ const MAX_TEXT = 100_000;
57
+ const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
58
+ export const PAGE_ID = /^[0-9]{1,20}$/;
59
+ export const FIGMA_KEY = /^[A-Za-z0-9]{10,64}$/;
60
+ export const FIGMA_NODE = /^[0-9]{1,10}[:-][0-9]{1,10}$/;
61
+
62
+ export class ServiceError extends Error {
63
+ constructor(code, message) {
64
+ super(message);
65
+ this.code = code;
66
+ }
67
+ }
68
+
69
+ const script = (env, name, override) =>
70
+ env[override] ||
71
+ [join(HERE, "..", "lib", name), join(HERE, "..", "..", "lib", name)].find((p) => existsSync(p));
72
+
73
+ function httpsUrl(v, host = null) {
74
+ if (typeof v !== "string" || v.length > 2048) return null;
75
+ let u;
76
+ try {
77
+ u = new URL(v);
78
+ } catch {
79
+ return null;
80
+ }
81
+ if (u.protocol !== "https:" || u.username || u.password) return null;
82
+ if (host && u.hostname !== host) return null;
83
+ return u.toString();
84
+ }
85
+
86
+ async function run(env, file, args, failCodes) {
87
+ if (!file) throw new ServiceError("not-installed", "the service client is not installed");
88
+ const r = await spawnAsync("bash", [file, ...args], { env, timeoutMs: TIMEOUT_MS });
89
+ if (r.status !== 0) {
90
+ const why = failCodes[r.status] ?? "the service could not be read";
91
+ throw new ServiceError("service-failed", why);
92
+ }
93
+ try {
94
+ return JSON.parse(r.stdout);
95
+ } catch {
96
+ throw new ServiceError(
97
+ "service-failed",
98
+ "the service answered with something that is not JSON",
99
+ );
100
+ }
101
+ }
102
+
103
+ function confluenceHost(env) {
104
+ try {
105
+ const prefs = JSON.parse(
106
+ readFileSync(
107
+ env.MULTI_AGENT_PREFS ||
108
+ join(env.HOME || homedir(), ".claude", "multi-agent-preferences.json"),
109
+ "utf8",
110
+ ),
111
+ );
112
+ const h = prefs?.global?.hosts?.confluence;
113
+ return typeof h === "string" && h ? h.replace(/^https?:\/\//, "").replace(/\/.*$/, "") : null;
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /** One Confluence page, by id or by a URL on the configured host only. */
120
+ export async function confluencePage(page, env = process.env) {
121
+ let args;
122
+ const host = confluenceHost(env);
123
+ if (PAGE_ID.test(String(page))) {
124
+ if (!host) throw new ServiceError("not-configured", "no Confluence host is configured");
125
+ args = ["--host", host, "--page-id", String(page)];
126
+ } else {
127
+ const url = httpsUrl(page);
128
+ if (!url) throw new ServiceError("bad-input", "a page id or an https URL is required");
129
+ if (!host) {
130
+ throw new ServiceError(
131
+ "host-not-allowed",
132
+ "no Confluence host is configured, so only a page id is accepted",
133
+ );
134
+ }
135
+ if (new URL(url).hostname !== host) {
136
+ throw new ServiceError("host-not-allowed", "that host is not the configured Confluence host");
137
+ }
138
+ args = [url];
139
+ }
140
+ const d = await run(env, script(env, "fetch-confluence.sh", "MA_FETCH_CONFLUENCE"), args, {
141
+ 2: "the Confluence credential is missing or was refused",
142
+ 3: "Confluence could not be reached",
143
+ 6: "that host is not the configured Confluence host",
144
+ });
145
+ const R = createRedactor();
146
+ return {
147
+ contractVersion: CONTRACT_VERSION,
148
+ service: "confluence",
149
+ pageId: d?.source?.pageId ? String(d.source.pageId) : null,
150
+ title: R.excerpt(d?.title ?? "", 300),
151
+ body: R.clean(String(d?.bodyMarkdown ?? "").slice(0, MAX_TEXT)),
152
+ apiContracts: Array.isArray(d?.apiContracts) ? d.apiContracts.length : 0,
153
+ };
154
+ }
155
+
156
+ /** One Crashlytics issue. */
157
+ export async function crashlyticsIssue(issueUrl, env = process.env) {
158
+ const url = httpsUrl(issueUrl, "console.firebase.google.com");
159
+ if (!url) throw new ServiceError("bad-input", "a Firebase console issue URL is required");
160
+ const d = await run(env, script(env, "fetch-crashlytics.sh", "MA_FETCH_CRASHLYTICS"), [url], {
161
+ 2: "the Firebase credential is missing or was refused",
162
+ 3: "Crashlytics could not be reached",
163
+ });
164
+ const R = createRedactor();
165
+ const i = d?.issue ?? {};
166
+ return {
167
+ contractVersion: CONTRACT_VERSION,
168
+ service: "crashlytics",
169
+ title: R.excerpt(i.title ?? "", 300),
170
+ type: i.type ? String(i.type) : null,
171
+ occurrences: Number.isFinite(i.occurrences) ? i.occurrences : null,
172
+ impactedUsers: Number.isFinite(i.impactedUsers) ? i.impactedUsers : null,
173
+ stackTrace: (Array.isArray(i.stackTrace) ? i.stackTrace : [])
174
+ .slice(0, 40)
175
+ .map((f) => R.excerpt(typeof f === "string" ? f : JSON.stringify(f), 300)),
176
+ };
177
+ }
178
+
179
+ /** One frame rendered as PNG. */
180
+ export async function figmaFrame(fileKey, nodeId, env = process.env) {
181
+ if (!FIGMA_KEY.test(String(fileKey)) || !FIGMA_NODE.test(String(nodeId))) {
182
+ throw new ServiceError("bad-input", "a Figma file key and a node id (1:2) are required");
183
+ }
184
+ const out = mkdtempSync(join(tmpdir(), "ma-figma-"));
185
+ try {
186
+ const file = script(env, "figma-screenshot.sh", "MA_FIGMA_SCREENSHOT");
187
+ if (!file) throw new ServiceError("not-installed", "the service client is not installed");
188
+ const r = await spawnAsync(
189
+ "bash",
190
+ [file, "--file-key", fileKey, "--node-id", nodeId.replace("-", ":"), "--output-dir", out],
191
+ { env, timeoutMs: TIMEOUT_MS, maxBytes: 1024 * 1024 },
192
+ );
193
+ const why = {
194
+ 2: "the Figma token is missing or was refused",
195
+ 4: "no such frame",
196
+ 5: "Figma is rate limiting; try again later",
197
+ 6: "Figma could not be reached",
198
+ };
199
+ if (r.status !== 0)
200
+ throw new ServiceError("service-failed", why[r.status] ?? "the frame could not be rendered");
201
+ const png = readdirSync(out).find((f) => f.endsWith(".png"));
202
+ if (!png || statSync(join(out, png)).size > MAX_IMAGE_BYTES) {
203
+ throw new ServiceError("service-failed", "the frame could not be rendered");
204
+ }
205
+ return {
206
+ contractVersion: CONTRACT_VERSION,
207
+ service: "figma",
208
+ fileKey,
209
+ nodeId: nodeId.replace("-", ":"),
210
+ mime: "image/png",
211
+ data: readFileSync(join(out, png)).toString("base64"),
212
+ };
213
+ } finally {
214
+ rmSync(out, { recursive: true, force: true });
215
+ }
216
+ }
217
+
218
+ async function main(argv) {
219
+ const [svc, a, b, ...rest] = argv;
220
+ try {
221
+ let out;
222
+ if (svc === "confluence" && a && !b) out = await confluencePage(a);
223
+ else if (svc === "crashlytics" && a && !b) out = await crashlyticsIssue(a);
224
+ else if (svc === "figma" && a && b && !rest.length) out = await figmaFrame(a, b);
225
+ else {
226
+ process.stderr.write(
227
+ "usage: service-read.mjs confluence <page> | crashlytics <issue-url> | figma <file-key> <node-id>\n",
228
+ );
229
+ return 2;
230
+ }
231
+ process.stdout.write(JSON.stringify(out, null, 2) + "\n");
232
+ return 0;
233
+ } catch (err) {
234
+ if (!(err instanceof ServiceError)) throw err;
235
+ process.stderr.write(`service-read: ${err.message}\n`);
236
+ return 1;
237
+ }
238
+ }
239
+
240
+ if (invokedDirectly(import.meta.url)) {
241
+ runMain("service-read", async () => {
242
+ process.exitCode = await main(process.argv.slice(2));
243
+ });
244
+ }