@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.
- package/CHANGELOG.md +19 -0
- package/README.md +19 -6
- package/README.tr.md +5 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +7 -7
- package/install/catalog-history.json +1 -1
- package/install/claude.mjs +122 -1
- package/install/codex.mjs +2 -0
- package/install/copilot.mjs +13 -0
- package/install/templates/claude-hooks.json +30 -1
- package/manifest.json +189 -77
- package/package.json +2 -1
- package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -1
- package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +13 -15
- package/pipeline/commands/multi-agent/office-off/SKILL.md +27 -0
- package/pipeline/commands/multi-agent/office-on/SKILL.md +50 -0
- package/pipeline/commands/multi-agent/office-status/SKILL.md +29 -0
- package/pipeline/commands/multi-agent/resume/SKILL.md +2 -1
- package/pipeline/commands/multi-agent/serve/SKILL.md +4 -2
- package/pipeline/commands/multi-agent/stack/SKILL.md +15 -8
- package/pipeline/commands/multi-agent/steer/SKILL.md +10 -9
- package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
- package/pipeline/contract/CHANGELOG.md +62 -0
- package/pipeline/contract/README.md +52 -6
- package/pipeline/contract/fixtures/error-invalid-request.json +1 -1
- package/pipeline/contract/fixtures/error-unauthorized.json +1 -1
- package/pipeline/contract/fixtures/error-unsigned.json +1 -1
- package/pipeline/contract/manifest.json +367 -22
- package/pipeline/contract/types/index.d.ts +531 -4
- package/pipeline/lib/hook-registration.mjs +252 -0
- package/pipeline/lib/prefs-value.mjs +44 -0
- package/pipeline/lib/run-completion.mjs +148 -0
- package/pipeline/multi-agent-refs/analysis/render.md +4 -4
- package/pipeline/multi-agent-refs/cross-cli-contract.md +5 -4
- package/pipeline/multi-agent-refs/features/doctor.md +11 -0
- package/pipeline/multi-agent-refs/features/office-threat-model.md +91 -0
- package/pipeline/multi-agent-refs/help/en.md +3 -0
- package/pipeline/multi-agent-refs/help/tr.md +3 -0
- package/pipeline/multi-agent-refs/phases/operations.md +17 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +6 -2
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +2 -2
- package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -6
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +5 -4
- package/pipeline/office/NOTICE.md +42 -0
- package/pipeline/office/assets/BIN.png +0 -0
- package/pipeline/office/assets/BOOKSHELF.png +0 -0
- package/pipeline/office/assets/CACTUS.png +0 -0
- package/pipeline/office/assets/CLOCK.png +0 -0
- package/pipeline/office/assets/COFFEE.png +0 -0
- package/pipeline/office/assets/COFFEE_TABLE.png +0 -0
- package/pipeline/office/assets/CUSHIONED_BENCH.png +0 -0
- package/pipeline/office/assets/CUSHIONED_CHAIR_BACK.png +0 -0
- package/pipeline/office/assets/CUSHIONED_CHAIR_FRONT.png +0 -0
- package/pipeline/office/assets/CUSHIONED_CHAIR_SIDE.png +0 -0
- package/pipeline/office/assets/DESK_FRONT.png +0 -0
- package/pipeline/office/assets/DESK_SIDE.png +0 -0
- package/pipeline/office/assets/DOUBLE_BOOKSHELF.png +0 -0
- package/pipeline/office/assets/HANGING_PLANT.png +0 -0
- package/pipeline/office/assets/LARGE_PAINTING.png +0 -0
- package/pipeline/office/assets/LARGE_PLANT.png +0 -0
- package/pipeline/office/assets/PC_BACK.png +0 -0
- package/pipeline/office/assets/PC_FRONT_OFF.png +0 -0
- package/pipeline/office/assets/PC_FRONT_ON_1.png +0 -0
- package/pipeline/office/assets/PC_FRONT_ON_2.png +0 -0
- package/pipeline/office/assets/PC_FRONT_ON_3.png +0 -0
- package/pipeline/office/assets/PC_SIDE.png +0 -0
- package/pipeline/office/assets/PLANT.png +0 -0
- package/pipeline/office/assets/PLANT_2.png +0 -0
- package/pipeline/office/assets/POT.png +0 -0
- package/pipeline/office/assets/SMALL_PAINTING.png +0 -0
- package/pipeline/office/assets/SMALL_PAINTING_2.png +0 -0
- package/pipeline/office/assets/SMALL_TABLE_FRONT.png +0 -0
- package/pipeline/office/assets/SMALL_TABLE_SIDE.png +0 -0
- package/pipeline/office/assets/SOFA_BACK.png +0 -0
- package/pipeline/office/assets/SOFA_FRONT.png +0 -0
- package/pipeline/office/assets/SOFA_SIDE.png +0 -0
- package/pipeline/office/assets/TABLE_FRONT.png +0 -0
- package/pipeline/office/assets/WHITEBOARD.png +0 -0
- package/pipeline/office/assets/WOODEN_BENCH.png +0 -0
- package/pipeline/office/assets/WOODEN_CHAIR_BACK.png +0 -0
- package/pipeline/office/assets/WOODEN_CHAIR_FRONT.png +0 -0
- package/pipeline/office/assets/WOODEN_CHAIR_SIDE.png +0 -0
- package/pipeline/office/assets/char_0.png +0 -0
- package/pipeline/office/assets/char_1.png +0 -0
- package/pipeline/office/assets/char_2.png +0 -0
- package/pipeline/office/assets/char_3.png +0 -0
- package/pipeline/office/assets/char_4.png +0 -0
- package/pipeline/office/assets/char_5.png +0 -0
- package/pipeline/office/assets/floor_0.png +0 -0
- package/pipeline/office/assets/floor_1.png +0 -0
- package/pipeline/office/assets/floor_2.png +0 -0
- package/pipeline/office/assets/floor_3.png +0 -0
- package/pipeline/office/assets/floor_4.png +0 -0
- package/pipeline/office/assets/floor_5.png +0 -0
- package/pipeline/office/assets/floor_6.png +0 -0
- package/pipeline/office/assets/floor_7.png +0 -0
- package/pipeline/office/assets/floor_8.png +0 -0
- package/pipeline/office/assets/wall_0.png +0 -0
- package/pipeline/office/index.html +5319 -0
- package/pipeline/preferences-template.json +7 -0
- package/pipeline/rules/figma-pipeline.md +2 -0
- package/pipeline/schemas/activity-chat.schema.json +78 -0
- package/pipeline/schemas/activity-roles.json +48 -0
- package/pipeline/schemas/activity-roles.schema.json +49 -0
- package/pipeline/schemas/activity.schema.json +429 -0
- package/pipeline/schemas/agent-state.schema.json +20 -5
- package/pipeline/schemas/autopilot-on-request.schema.json +33 -0
- package/pipeline/schemas/autopilot-on.schema.json +111 -0
- package/pipeline/schemas/contract-error.schema.json +33 -3
- package/pipeline/schemas/issue-maturity.schema.json +68 -0
- package/pipeline/schemas/ledger.schema.json +202 -0
- package/pipeline/schemas/permissions.schema.json +47 -0
- package/pipeline/schemas/phases.json +3 -2
- package/pipeline/schemas/phases.schema.json +5 -1
- package/pipeline/schemas/prefs.schema.json +56 -0
- package/pipeline/schemas/prs.schema.json +73 -0
- package/pipeline/schemas/review-verdict-request.schema.json +20 -0
- package/pipeline/schemas/review-verdict.schema.json +62 -0
- package/pipeline/schemas/routine-delete.schema.json +59 -0
- package/pipeline/schemas/routines.schema.json +38 -0
- package/pipeline/schemas/run-evidence.schema.json +29 -0
- package/pipeline/schemas/run-results.schema.json +218 -0
- package/pipeline/schemas/runs-index.schema.json +1 -1
- package/pipeline/schemas/service-read.schema.json +58 -0
- package/pipeline/schemas/stack-change.schema.json +82 -0
- package/pipeline/schemas/stack-request.schema.json +33 -0
- package/pipeline/schemas/stack.schema.json +59 -0
- package/pipeline/schemas/steer-request.schema.json +25 -0
- package/pipeline/schemas/steer.schema.json +59 -0
- package/pipeline/schemas/tool-call-request.schema.json +19 -0
- package/pipeline/schemas/tool-call.schema.json +47 -0
- package/pipeline/schemas/tools.schema.json +60 -0
- package/pipeline/scripts/_active-run.mjs +158 -0
- package/pipeline/scripts/_autopilot-outcomes.mjs +1 -0
- package/pipeline/scripts/_cost.mjs +66 -1
- package/pipeline/scripts/_guard_rules.py +605 -0
- package/pipeline/scripts/_office-smoke-lib.sh +115 -0
- package/pipeline/scripts/_spawn-async.mjs +73 -0
- package/pipeline/scripts/_transcript-tail.mjs +274 -0
- package/pipeline/scripts/activity-snapshot.mjs +1027 -0
- package/pipeline/scripts/agent-guard.py +118 -7
- package/pipeline/scripts/agent-guard.sh +50 -3
- package/pipeline/scripts/argv-secret-scan.mjs +114 -0
- package/pipeline/scripts/autopilot-control.mjs +214 -9
- package/pipeline/scripts/autopilot-runner.mjs +8 -2
- package/pipeline/scripts/confirm-command.mjs +70 -0
- package/pipeline/scripts/contract-server.mjs +1178 -48
- package/pipeline/scripts/cost-analyze.mjs +2 -53
- package/pipeline/scripts/cost-table.json +1 -0
- package/pipeline/scripts/doctor.mjs +54 -0
- package/pipeline/scripts/evidence-gate.mjs +8 -4
- package/pipeline/scripts/gate-ledger.mjs +163 -7
- package/pipeline/scripts/office-build.mjs +89 -0
- package/pipeline/scripts/office-control.mjs +383 -0
- package/pipeline/scripts/office-ledger.mjs +409 -0
- package/pipeline/scripts/plan-coverage-gate.mjs +9 -8
- package/pipeline/scripts/pre-commit-check.sh +12 -0
- package/pipeline/scripts/routine-registry.mjs +42 -6
- package/pipeline/scripts/run-results.mjs +373 -0
- package/pipeline/scripts/run-scope-guard.mjs +188 -0
- package/pipeline/scripts/run-scope-guard.sh +23 -0
- package/pipeline/scripts/run-stop-guard.mjs +246 -0
- package/pipeline/scripts/run-stop-guard.sh +25 -0
- package/pipeline/scripts/service-read.mjs +244 -0
- package/pipeline/scripts/stack-toggle.mjs +285 -0
- package/pipeline/scripts/steer-run.mjs +97 -0
- package/pipeline/scripts/toolkit-tools.mjs +599 -0
- package/pipeline/scripts/uninstall.mjs +4 -0
- package/pipeline/scripts/validate-state.mjs +26 -0
- package/pipeline/scripts/work-items.mjs +304 -0
- package/pipeline/scripts/write-state.mjs +114 -0
- package/pipeline/skills/.skill-manifest.json +19 -7
- package/pipeline/skills/.skills-index.json +34 -1
- package/pipeline/skills/shared/README.md +6 -3
- package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +13 -15
- package/pipeline/skills/shared/core/multi-agent-office-off/SKILL.md +16 -0
- package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +22 -0
- package/pipeline/skills/shared/core/multi-agent-office-status/SKILL.md +17 -0
- package/pipeline/skills/shared/core/multi-agent-serve/SKILL.md +4 -2
- package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +14 -6
- package/pipeline/skills/shared/core/multi-agent-steer/SKILL.md +10 -9
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
- 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
|
+
}
|