@mmerterden/multi-agent-pipeline 20.29.0 → 20.33.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 (227) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +50 -5
  3. package/README.tr.md +64 -5
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/facts.json +9 -9
  7. package/install/_unattended-profile.mjs +61 -0
  8. package/install/catalog-history.json +1 -1
  9. package/install/claude.mjs +19 -3
  10. package/install/codex.mjs +2 -0
  11. package/install/copilot.mjs +13 -0
  12. package/install/index.mjs +10 -1
  13. package/install/templates/claude-hooks.json +12 -1
  14. package/manifest.json +230 -88
  15. package/package.json +3 -1
  16. package/pipeline/commands/multi-agent/analysis/SKILL.md +4 -4
  17. package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +13 -15
  18. package/pipeline/commands/multi-agent/office-off/SKILL.md +27 -0
  19. package/pipeline/commands/multi-agent/office-on/SKILL.md +51 -0
  20. package/pipeline/commands/multi-agent/office-status/SKILL.md +29 -0
  21. package/pipeline/commands/multi-agent/prune-logs/SKILL.md +4 -1
  22. package/pipeline/commands/multi-agent/review/SKILL.md +4 -3
  23. package/pipeline/commands/multi-agent/scenario-audit/SKILL.md +4 -2
  24. package/pipeline/commands/multi-agent/serve/SKILL.md +4 -2
  25. package/pipeline/commands/multi-agent/stack/SKILL.md +15 -8
  26. package/pipeline/commands/multi-agent/steer/SKILL.md +10 -9
  27. package/pipeline/commands/multi-agent/sync/SKILL.md +15 -3
  28. package/pipeline/commands/multi-agent/test-gen/SKILL.md +29 -3
  29. package/pipeline/contract/CHANGELOG.md +108 -0
  30. package/pipeline/contract/README.md +72 -12
  31. package/pipeline/contract/fixtures/error-invalid-request.json +1 -1
  32. package/pipeline/contract/fixtures/error-unauthorized.json +1 -1
  33. package/pipeline/contract/fixtures/error-unsigned.json +1 -1
  34. package/pipeline/contract/fixtures/launch-plan.json +1 -1
  35. package/pipeline/contract/fixtures/runs-awaiting-question.json +6 -1
  36. package/pipeline/contract/fixtures/runs-empty.json +1 -1
  37. package/pipeline/contract/fixtures/runs-failed.json +6 -1
  38. package/pipeline/contract/fixtures/runs-old-schema.json +6 -1
  39. package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +5 -1
  40. package/pipeline/contract/fixtures/runs-pr-opened.json +6 -1
  41. package/pipeline/contract/fixtures/runs-running.json +6 -1
  42. package/pipeline/contract/manifest.json +506 -27
  43. package/pipeline/contract/types/index.d.ts +829 -4
  44. package/pipeline/lib/bg-launch.mjs +106 -0
  45. package/pipeline/lib/code-digest.mjs +49 -0
  46. package/pipeline/lib/credential-resolve.mjs +42 -2
  47. package/pipeline/multi-agent-refs/cross-cli-contract.md +5 -4
  48. package/pipeline/multi-agent-refs/features/office-threat-model.md +109 -0
  49. package/pipeline/multi-agent-refs/features/unattended-security.md +1 -1
  50. package/pipeline/multi-agent-refs/help/en.md +3 -0
  51. package/pipeline/multi-agent-refs/help/tr.md +3 -0
  52. package/pipeline/multi-agent-refs/phases/modes.md +7 -7
  53. package/pipeline/multi-agent-refs/phases/phase-5-report.md +16 -10
  54. package/pipeline/multi-agent-refs/review-standalone-tracker.md +37 -0
  55. package/pipeline/multi-agent-refs/tracker-contract.md +8 -3
  56. package/pipeline/office/NOTICE.md +45 -0
  57. package/pipeline/office/assets/ARCHIVE_BOXES.png +0 -0
  58. package/pipeline/office/assets/BIN.png +0 -0
  59. package/pipeline/office/assets/BOOKSHELF.png +0 -0
  60. package/pipeline/office/assets/CACTUS.png +0 -0
  61. package/pipeline/office/assets/CLOCK.png +0 -0
  62. package/pipeline/office/assets/COFFEE.png +0 -0
  63. package/pipeline/office/assets/COFFEE_MACHINE.png +0 -0
  64. package/pipeline/office/assets/COFFEE_TABLE.png +0 -0
  65. package/pipeline/office/assets/COUNTER_SINK.png +0 -0
  66. package/pipeline/office/assets/CUSHIONED_BENCH.png +0 -0
  67. package/pipeline/office/assets/CUSHIONED_CHAIR_BACK.png +0 -0
  68. package/pipeline/office/assets/CUSHIONED_CHAIR_FRONT.png +0 -0
  69. package/pipeline/office/assets/CUSHIONED_CHAIR_SIDE.png +0 -0
  70. package/pipeline/office/assets/DESK_FRONT.png +0 -0
  71. package/pipeline/office/assets/DESK_SIDE.png +0 -0
  72. package/pipeline/office/assets/DEVICE_STAND.png +0 -0
  73. package/pipeline/office/assets/DOUBLE_BOOKSHELF.png +0 -0
  74. package/pipeline/office/assets/FILING_CABINET.png +0 -0
  75. package/pipeline/office/assets/FRIDGE.png +0 -0
  76. package/pipeline/office/assets/HANGING_PLANT.png +0 -0
  77. package/pipeline/office/assets/LARGE_PAINTING.png +0 -0
  78. package/pipeline/office/assets/LARGE_PLANT.png +0 -0
  79. package/pipeline/office/assets/MAILBOX.png +0 -0
  80. package/pipeline/office/assets/MOP_BUCKET.png +0 -0
  81. package/pipeline/office/assets/PC_BACK.png +0 -0
  82. package/pipeline/office/assets/PC_FRONT_OFF.png +0 -0
  83. package/pipeline/office/assets/PC_FRONT_ON_1.png +0 -0
  84. package/pipeline/office/assets/PC_FRONT_ON_2.png +0 -0
  85. package/pipeline/office/assets/PC_FRONT_ON_3.png +0 -0
  86. package/pipeline/office/assets/PC_SIDE.png +0 -0
  87. package/pipeline/office/assets/PLANT.png +0 -0
  88. package/pipeline/office/assets/PLANT_2.png +0 -0
  89. package/pipeline/office/assets/POT.png +0 -0
  90. package/pipeline/office/assets/PRINTER.png +0 -0
  91. package/pipeline/office/assets/RECEPTION_DESK.png +0 -0
  92. package/pipeline/office/assets/SERVER_RACK.png +0 -0
  93. package/pipeline/office/assets/SERVER_RACK_2.png +0 -0
  94. package/pipeline/office/assets/SMALL_PAINTING.png +0 -0
  95. package/pipeline/office/assets/SMALL_PAINTING_2.png +0 -0
  96. package/pipeline/office/assets/SMALL_TABLE_FRONT.png +0 -0
  97. package/pipeline/office/assets/SMALL_TABLE_SIDE.png +0 -0
  98. package/pipeline/office/assets/SOFA_BACK.png +0 -0
  99. package/pipeline/office/assets/SOFA_FRONT.png +0 -0
  100. package/pipeline/office/assets/SOFA_SIDE.png +0 -0
  101. package/pipeline/office/assets/TABLE_FRONT.png +0 -0
  102. package/pipeline/office/assets/TOILET_STALL.png +0 -0
  103. package/pipeline/office/assets/VENDING.png +0 -0
  104. package/pipeline/office/assets/WASHBASIN.png +0 -0
  105. package/pipeline/office/assets/WATER_COOLER.png +0 -0
  106. package/pipeline/office/assets/WHITEBOARD.png +0 -0
  107. package/pipeline/office/assets/WOODEN_BENCH.png +0 -0
  108. package/pipeline/office/assets/WOODEN_CHAIR_BACK.png +0 -0
  109. package/pipeline/office/assets/WOODEN_CHAIR_FRONT.png +0 -0
  110. package/pipeline/office/assets/WOODEN_CHAIR_SIDE.png +0 -0
  111. package/pipeline/office/assets/char_0.png +0 -0
  112. package/pipeline/office/assets/char_1.png +0 -0
  113. package/pipeline/office/assets/char_2.png +0 -0
  114. package/pipeline/office/assets/char_3.png +0 -0
  115. package/pipeline/office/assets/char_4.png +0 -0
  116. package/pipeline/office/assets/char_5.png +0 -0
  117. package/pipeline/office/assets/floor_0.png +0 -0
  118. package/pipeline/office/assets/floor_1.png +0 -0
  119. package/pipeline/office/assets/floor_2.png +0 -0
  120. package/pipeline/office/assets/floor_3.png +0 -0
  121. package/pipeline/office/assets/floor_4.png +0 -0
  122. package/pipeline/office/assets/floor_5.png +0 -0
  123. package/pipeline/office/assets/floor_6.png +0 -0
  124. package/pipeline/office/assets/floor_7.png +0 -0
  125. package/pipeline/office/assets/floor_8.png +0 -0
  126. package/pipeline/office/assets/wall_0.png +0 -0
  127. package/pipeline/office/index.html +8431 -0
  128. package/pipeline/office/toolkit.json +3 -0
  129. package/pipeline/schemas/activity-chat.schema.json +78 -0
  130. package/pipeline/schemas/activity-roles.json +48 -0
  131. package/pipeline/schemas/activity-roles.schema.json +49 -0
  132. package/pipeline/schemas/activity.schema.json +613 -0
  133. package/pipeline/schemas/agent-state.schema.json +493 -115
  134. package/pipeline/schemas/autopilot-on-request.schema.json +33 -0
  135. package/pipeline/schemas/autopilot-on.schema.json +111 -0
  136. package/pipeline/schemas/contract-error.schema.json +45 -4
  137. package/pipeline/schemas/issue-maturity.schema.json +68 -0
  138. package/pipeline/schemas/job-change.schema.json +72 -0
  139. package/pipeline/schemas/job-request.schema.json +38 -0
  140. package/pipeline/schemas/launch.json +4 -4
  141. package/pipeline/schemas/ledger.schema.json +202 -0
  142. package/pipeline/schemas/office-credentials.schema.json +71 -0
  143. package/pipeline/schemas/office-jobs.json +253 -0
  144. package/pipeline/schemas/office-jobs.schema.json +62 -0
  145. package/pipeline/schemas/office-memory.schema.json +105 -0
  146. package/pipeline/schemas/office-settings-request.schema.json +30 -0
  147. package/pipeline/schemas/office-settings.schema.json +67 -0
  148. package/pipeline/schemas/permissions.schema.json +47 -0
  149. package/pipeline/schemas/phases.json +10 -2
  150. package/pipeline/schemas/phases.schema.json +86 -19
  151. package/pipeline/schemas/prefs.schema.json +19 -0
  152. package/pipeline/schemas/prs.schema.json +78 -0
  153. package/pipeline/schemas/prune-logs-request.schema.json +26 -0
  154. package/pipeline/schemas/prune-logs.schema.json +52 -0
  155. package/pipeline/schemas/repo-change.schema.json +54 -0
  156. package/pipeline/schemas/repo-request.schema.json +29 -0
  157. package/pipeline/schemas/repos.schema.json +49 -1
  158. package/pipeline/schemas/review-verdict-request.schema.json +20 -0
  159. package/pipeline/schemas/review-verdict.schema.json +62 -0
  160. package/pipeline/schemas/routine-delete.schema.json +59 -0
  161. package/pipeline/schemas/routines.schema.json +38 -0
  162. package/pipeline/schemas/run-evidence.schema.json +29 -0
  163. package/pipeline/schemas/run-results.schema.json +218 -0
  164. package/pipeline/schemas/runs-index.schema.json +257 -46
  165. package/pipeline/schemas/service-read.schema.json +58 -0
  166. package/pipeline/schemas/stack-change.schema.json +82 -0
  167. package/pipeline/schemas/stack-request.schema.json +33 -0
  168. package/pipeline/schemas/stack.schema.json +59 -0
  169. package/pipeline/schemas/steer-request.schema.json +25 -0
  170. package/pipeline/schemas/steer.schema.json +59 -0
  171. package/pipeline/schemas/system-status.schema.json +111 -0
  172. package/pipeline/schemas/tool-call-request.schema.json +19 -0
  173. package/pipeline/schemas/tool-call.schema.json +47 -0
  174. package/pipeline/schemas/tools.schema.json +65 -0
  175. package/pipeline/schemas/unattended-profile.json +7 -2
  176. package/pipeline/scripts/_active-run.mjs +2 -1
  177. package/pipeline/scripts/_cost.mjs +66 -1
  178. package/pipeline/scripts/_office-smoke-lib.sh +115 -0
  179. package/pipeline/scripts/_spawn-async.mjs +77 -0
  180. package/pipeline/scripts/_transcript-tail.mjs +274 -0
  181. package/pipeline/scripts/activity-snapshot.mjs +1223 -0
  182. package/pipeline/scripts/autopilot-control.mjs +214 -9
  183. package/pipeline/scripts/autopilot-runner.mjs +7 -5
  184. package/pipeline/scripts/contract-server.mjs +1853 -197
  185. package/pipeline/scripts/cost-analyze.mjs +2 -53
  186. package/pipeline/scripts/cost-table.json +1 -0
  187. package/pipeline/scripts/gen-mode-dispatch.mjs +3 -1
  188. package/pipeline/scripts/job-launch.mjs +477 -0
  189. package/pipeline/scripts/launch-request.mjs +94 -9
  190. package/pipeline/scripts/office-build.mjs +226 -0
  191. package/pipeline/scripts/office-control.mjs +410 -0
  192. package/pipeline/scripts/office-credentials.mjs +106 -0
  193. package/pipeline/scripts/office-ledger.mjs +409 -0
  194. package/pipeline/scripts/office-memory.mjs +200 -0
  195. package/pipeline/scripts/office-prune.mjs +125 -0
  196. package/pipeline/scripts/office-settings.mjs +145 -0
  197. package/pipeline/scripts/phase-tracker.sh +12 -5
  198. package/pipeline/scripts/prune-logs.sh +53 -2
  199. package/pipeline/scripts/routine-registry.mjs +42 -6
  200. package/pipeline/scripts/run-results.mjs +373 -0
  201. package/pipeline/scripts/runs-index.mjs +62 -2
  202. package/pipeline/scripts/service-read.mjs +244 -0
  203. package/pipeline/scripts/stack-toggle.mjs +285 -0
  204. package/pipeline/scripts/steer-run.mjs +97 -0
  205. package/pipeline/scripts/system-status.mjs +196 -0
  206. package/pipeline/scripts/task-mirror.mjs +150 -0
  207. package/pipeline/scripts/task-mirror.sh +20 -0
  208. package/pipeline/scripts/toolkit-tools.mjs +625 -0
  209. package/pipeline/scripts/transcript-index.mjs +4 -1
  210. package/pipeline/scripts/uninstall.mjs +3 -0
  211. package/pipeline/scripts/work-items.mjs +473 -0
  212. package/pipeline/skills/.skill-manifest.json +23 -11
  213. package/pipeline/skills/.skills-index.json +34 -1
  214. package/pipeline/skills/shared/README.md +6 -3
  215. package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +13 -15
  216. package/pipeline/skills/shared/core/multi-agent-office-off/SKILL.md +16 -0
  217. package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +21 -0
  218. package/pipeline/skills/shared/core/multi-agent-office-status/SKILL.md +17 -0
  219. package/pipeline/skills/shared/core/multi-agent-prune-logs/SKILL.md +4 -1
  220. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +19 -0
  221. package/pipeline/skills/shared/core/multi-agent-scenario-audit/SKILL.md +4 -2
  222. package/pipeline/skills/shared/core/multi-agent-serve/SKILL.md +4 -2
  223. package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +14 -6
  224. package/pipeline/skills/shared/core/multi-agent-steer/SKILL.md +10 -9
  225. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  226. package/pipeline/skills/shared/core/multi-agent-test-gen/SKILL.md +26 -3
  227. package/pipeline/skills/skills-index.md +4 -1
@@ -50,7 +50,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSy
50
50
  import { homedir } from "node:os";
51
51
  import { dirname, join } from "node:path";
52
52
  import { fileURLToPath } from "node:url";
53
- import { costUsd } from "./_cost.mjs";
53
+ import { modelRateLookup, priceUsage as priceRecord } from "./_cost.mjs";
54
54
  import { readJsonl } from "./_jsonl.mjs";
55
55
  import { runMain } from "../lib/fatal.mjs";
56
56
  import { messages, outputLanguage } from "../lib/user-facing.mjs";
@@ -167,58 +167,7 @@ const asNumber = (v, fallback) => {
167
167
 
168
168
  const TABLE = JSON.parse(readFileSync(join(HERE, "cost-table.json"), "utf-8"));
169
169
 
170
- /**
171
- * Wire ID -> rate. Transcripts carry the wire value ("claude-opus-5-5"); the
172
- * price table is keyed by rung, so each rung's `modelId` and `copilotModelId`
173
- * map to its rate, `legacy` adds previous-generation IDs at their own rates,
174
- * and `models` overrides the rung rate for a dispatched ID whose model is not
175
- * the rung's (Copilot CLI's older models bill at their own rate). Matching on
176
- * the ID rather than the rung name keeps a generation move a data edit, which
177
- * is the contract cost-table.json states for itself.
178
- */
179
- const RATE_BY_MODEL_ID = new Map([
180
- ...Object.entries(TABLE.legacy || {}),
181
- ...Object.values(TABLE.prices || {}).flatMap((p) =>
182
- [p.modelId, p.copilotModelId].filter(Boolean).map((id) => [id, p]),
183
- ),
184
- ...Object.entries(TABLE.models || {}),
185
- ]);
186
- const IDS_LONGEST_FIRST = [...RATE_BY_MODEL_ID.keys()].sort((a, b) => b.length - a.length);
187
-
188
- function rateFor(modelId) {
189
- if (!modelId) return null;
190
- const exact = RATE_BY_MODEL_ID.get(modelId);
191
- if (exact) return exact;
192
- // A dated snapshot id (a known id, a hyphen, a release date) prices as that
193
- // id; the longest known id wins, so a current id is never read as a shorter
194
- // legacy one. Written as a shape rather than as a literal, because
195
- // smoke-model-fallback.sh reads every model id in this tree and requires it
196
- // to exist in cost-table.json. An id nobody knows prices as nothing and is
197
- // COUNTED as unpriced rather than folded in at zero.
198
- const id = IDS_LONGEST_FIRST.find(
199
- (known) => modelId.startsWith(`${known}-`) || modelId.startsWith(`${known}[`),
200
- );
201
- return id ? RATE_BY_MODEL_ID.get(id) : null;
202
- }
203
-
204
- /**
205
- * Cache WRITES are not a discount, they are a surcharge: Anthropic prices a
206
- * 1h cache write above fresh input. cost-table.json carries cacheReadPerMtok
207
- * only, so the write rate is derived here at the published 1.25x of input and
208
- * labelled as derived - an unpriced component silently dropped would make
209
- * every long-context session look cheap, which is the direction that hurts.
210
- */
211
- const CACHE_WRITE_MULTIPLIER = 1.25;
212
-
213
- function priceRecord(rate, u) {
214
- const fresh = Number(u.input_tokens || 0);
215
- const out = Number(u.output_tokens || 0);
216
- const cacheRead = Number(u.cache_read_input_tokens || 0);
217
- const cacheWrite = Number(u.cache_creation_input_tokens || 0);
218
- const base = costUsd(rate, fresh, out, cacheRead);
219
- if (base === null) return null;
220
- return base + (cacheWrite / 1_000_000) * (rate.inPerMtok || 0) * CACHE_WRITE_MULTIPLIER;
221
- }
170
+ const rateFor = modelRateLookup(TABLE);
222
171
 
223
172
  // --- the series --------------------------------------------------------------
224
173
 
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "_readme": "Per-model unit prices in USD per million tokens. Source: Anthropic public pricing (Claude-family rows verified 2026-10-14 against the current model table; the gpt-* rows remain approximate). Update when Anthropic publishes new tiers. Rung names (fable / opus / sonnet / haiku) are the pipeline's stable identifiers and `modelId` is the wire value they currently resolve to - dispatch reads the rung, so a generation move is a `modelId` edit here plus the phase specs, never a rename of the rungs. `copilotModelId`, where present, is the name Copilot CLI dispatches for the rung: one of the dotted IDs `copilot help config` lists (Copilot rejects any other as not available), the newest of the rung's family there. A rung without one (fable) is not offered on Copilot CLI, where its personas take the opus rung. smoke-model-fallback.sh checks the IDs against `copilot help config` when the CLI is installed. `provider` says which layer a rung belongs to: an `anthropic` rung is reachable from every call site; `free-chain` (the `free` rung) only from bulk-read and delegate, where the multi-agent-toolkit's verified free-model chain answers and the caller's default takes over when it escalates; any other provider from no call site. model-dispatch.sh enforces this. Unknown models render USD as ' - ' and emit a footnote - never block PR-body generation. cacheReadPerMtok is the discounted rate for prompt-cache hits, published per model and well below inPerMtok; the renderer prices a phase's tokens_cached at this rate when the tracker records it, so resume/cache reuse is visible in the ledger.",
3
3
  "schemaVersion": "1.1.0",
4
+ "verifiedAt": "2026-10-14",
4
5
  "_legacyReadme": "`legacy` prices previous-generation wire IDs a transcript may still name, keyed by the exact ID (a dated snapshot of one prices as it). Dispatch never reads it; cost-analyze consults it after the current `modelId` values and the `models` map. Official Anthropic prices per million tokens.",
5
6
  "legacy": {
6
7
  "claude-fable-5": { "inPerMtok": 10.0, "outPerMtok": 50.0, "cacheReadPerMtok": 1.0 },
@@ -40,10 +40,12 @@ const CONTRACT = JSON.parse(
40
40
  readFileSync(new URL("../schemas/phases.json", import.meta.url), "utf8"),
41
41
  );
42
42
  const PHASES = CONTRACT.phases;
43
+ // A mode may name its steps itself: the analysis command runs the six slots as
44
+ // Intake, Evidence, Synthesis, Review, Publish and Report.
43
45
  const modePhases = (name) =>
44
46
  CONTRACT.modes[name].phases.map((id) => {
45
47
  const p = PHASES.find((x) => x.id === id);
46
- return `${p.id}:${p.name}`;
48
+ return `${p.id}:${CONTRACT.modes[name].names?.[id] ?? p.name}`;
47
49
  });
48
50
 
49
51
  const PHASE_NAMES = PHASES.map((p) => `${p.id}:${p.name}`);
@@ -0,0 +1,477 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * job-launch.mjs - turn an office job into one unattended background session.
4
+ *
5
+ * A job is an entry of schemas/office-jobs.json: a command, the role that does
6
+ * it, whether it is a team job (a pipeline run, which meets in a room) or a
7
+ * solo one (one agent at a desk), and typed parameters. Nothing here takes
8
+ * free text except a team job's work item, which goes through the launch
9
+ * request rules (launch-request.mjs inputErrors) like every other launch.
10
+ *
11
+ * planJob checks a job against the catalog and builds what the server starts:
12
+ * the same `claude --bg` shape launch.json declares for background runs, the
13
+ * unattended permission profile, no permission prompts, and the environment
14
+ * that marks the session unattended. A team job writes a launch request the
15
+ * run's Phase 0 reads; a solo job names its command and arguments directly.
16
+ * startJob starts it through lib/bg-launch.mjs with the autopilot runner's
17
+ * sandbox check, and records the session under the office log so the floor
18
+ * can seat it where it belongs. A team job is refused while another run is
19
+ * live in the same repository, as the autopilot queue does: two runs in one
20
+ * checkout fight over the git index.
21
+ *
22
+ * node job-launch.mjs list the catalog with availability (JSON)
23
+ *
24
+ * @module pipeline/scripts/job-launch
25
+ */
26
+
27
+ import { randomUUID } from "node:crypto";
28
+ import {
29
+ existsSync,
30
+ mkdirSync,
31
+ readFileSync,
32
+ readdirSync,
33
+ realpathSync,
34
+ rmSync,
35
+ statSync,
36
+ writeFileSync,
37
+ } from "node:fs";
38
+ import { homedir } from "node:os";
39
+ import { isAbsolute, join, normalize, sep } from "node:path";
40
+
41
+ import { startBackgroundAsync } from "../lib/bg-launch.mjs";
42
+ import { listSessions, sessionLiveness } from "../lib/claude-sessions.mjs";
43
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
44
+ import { runMain } from "../lib/fatal.mjs";
45
+ import { unattendedLogsRoot } from "../lib/pr-request-location.mjs";
46
+ import { unattendedSettingsPath } from "../lib/unattended-settings-location.mjs";
47
+ import { commandsDir } from "./commands.mjs";
48
+ import {
49
+ CONTRACT_VERSION as LAUNCH_VERSION,
50
+ configuredJiraHost,
51
+ inputErrors,
52
+ launchSpec,
53
+ planLaunch,
54
+ workingTreeState,
55
+ writeRequestFile,
56
+ } from "./launch-request.mjs";
57
+ import { listRuns, logsRoot } from "./_run-paths.mjs";
58
+
59
+ export const CONTRACT_VERSION = "1.0.0";
60
+
61
+ const JOBS = JSON.parse(
62
+ readFileSync(new URL("../schemas/office-jobs.json", import.meta.url), "utf8"),
63
+ );
64
+
65
+ const JIRA_KEY = /^[A-Z][A-Z0-9]+-[0-9]+$/;
66
+ const GH_OWNER = "[A-Za-z0-9][A-Za-z0-9-]{0,38}";
67
+ const GH_REPO = "[A-Za-z0-9._-]{1,100}";
68
+ const PR_URL = new RegExp(`^https://github\\.com/${GH_OWNER}/${GH_REPO}/pull/[1-9][0-9]{0,9}$`);
69
+ const ISSUE_URL = new RegExp(
70
+ `^https://github\\.com/${GH_OWNER}/${GH_REPO}/issues/[1-9][0-9]{0,9}$`,
71
+ );
72
+ const SHORT_REF = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}#[1-9][0-9]{0,9}$/;
73
+ const NUMBER_REF = /^#[1-9][0-9]{0,9}$/;
74
+ const RUN_ID = /^[A-Za-z0-9#][A-Za-z0-9#._-]{0,127}$/;
75
+ const BRANCH = /^(?!-)(?!.*\.\.)(?!.*\/\/)[A-Za-z0-9._/-]{1,200}(?<![./])$/;
76
+ const REPO_PATH = /^(?![/-])(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._/-]{1,300}$/;
77
+
78
+ export class JobError extends Error {
79
+ constructor(code, message, extra = {}) {
80
+ super(message);
81
+ this.code = code;
82
+ this.extra = extra;
83
+ }
84
+ }
85
+
86
+ /** Every job in the catalog, in catalog order. */
87
+ export const jobs = () => JOBS.jobs;
88
+
89
+ export const jobById = (id) => JOBS.jobs.find((j) => j.id === id) ?? null;
90
+
91
+ /**
92
+ * The catalog as GET /v1/jobs serves it: each job with whether its command is
93
+ * installed here, and why not when it is not.
94
+ */
95
+ export function jobsDocument() {
96
+ const dir = commandsDir();
97
+ return {
98
+ contractVersion: CONTRACT_VERSION,
99
+ jobs: JOBS.jobs.map((j) => {
100
+ const installed =
101
+ j.command === "multi-agent" || Boolean(dir && existsSync(join(dir, j.command, "SKILL.md")));
102
+ return { ...j, available: installed, reason: installed ? null : "command-not-installed" };
103
+ }),
104
+ };
105
+ }
106
+
107
+ /** A path inside the repository, relative, that exists there. */
108
+ function insideRepo(repoPath, rel) {
109
+ if (!REPO_PATH.test(rel)) return false;
110
+ const full = normalize(join(repoPath, rel));
111
+ if (full !== repoPath && !full.startsWith(repoPath + sep)) return false;
112
+ try {
113
+ const real = realpathSync(full);
114
+ const root = realpathSync(repoPath);
115
+ return real === root || real.startsWith(root + sep);
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Every reason `args` do not fit `job`. A run id must be a run the index
123
+ * knows; a repository path must stay inside the repository.
124
+ */
125
+ export function paramErrors(
126
+ job,
127
+ args,
128
+ { repoPath, jiraHost = configuredJiraHost(), knownRun } = {},
129
+ ) {
130
+ const errors = [];
131
+ const given = args && typeof args === "object" && !Array.isArray(args) ? args : null;
132
+ if (!given) return ["args must be an object"];
133
+ for (const k of Object.keys(given)) {
134
+ if (!job.params.some((p) => p.name === k)) errors.push(`unknown parameter ${k}`);
135
+ }
136
+ for (const p of job.params) {
137
+ const v = given[p.name];
138
+ if (v === undefined || v === "") {
139
+ if (p.required) errors.push(`${p.name} is required`);
140
+ continue;
141
+ }
142
+ if (typeof v !== "string" || v.length > 4000) {
143
+ errors.push(`${p.name} must be a string`);
144
+ continue;
145
+ }
146
+ const bad = (why) => errors.push(`${p.name}: ${why}`);
147
+ switch (p.kind) {
148
+ case "work-item":
149
+ for (const e of inputErrors(v, { channel: "desktop", jiraHost })) bad(e);
150
+ break;
151
+ case "pull-request":
152
+ if (!PR_URL.test(v) && !SHORT_REF.test(v) && !NUMBER_REF.test(v))
153
+ bad("a GitHub pull request URL, repo#N or #N");
154
+ break;
155
+ case "github-issue":
156
+ if (!ISSUE_URL.test(v) && !SHORT_REF.test(v) && !NUMBER_REF.test(v))
157
+ bad("a GitHub issue URL, repo#N or #N");
158
+ break;
159
+ case "jira-key":
160
+ if (!JIRA_KEY.test(v)) bad("a Jira key such as PROJ-123");
161
+ break;
162
+ case "branch":
163
+ if (!BRANCH.test(v)) bad("a branch name");
164
+ break;
165
+ case "run-id":
166
+ if (!RUN_ID.test(v)) bad("a run id");
167
+ else if (knownRun && !knownRun(v)) bad("no run has that id");
168
+ break;
169
+ case "enum":
170
+ if (!p.values.includes(v)) bad(`one of ${p.values.join(", ")}`);
171
+ break;
172
+ case "repo-path":
173
+ if (!repoPath || !insideRepo(repoPath, v)) bad("a path inside the repository");
174
+ break;
175
+ default:
176
+ bad("unknown kind");
177
+ }
178
+ }
179
+ return errors;
180
+ }
181
+
182
+ // A team job started here holds its room until its run writes agent-state.json,
183
+ // which takes a moment; without this a second start in that moment would pass.
184
+ const HOLD_MS = 5 * 60 * 1000;
185
+ const LIST_GRACE_MS = 60 * 1000;
186
+ const held = new Map();
187
+
188
+ /** Hold the room; returns the key to release it with, or null. */
189
+ function holdRoom(repoPath, taskId, sessionId) {
190
+ try {
191
+ const key = realpathSync(repoPath);
192
+ held.set(key, { taskId, sessionId, at: Date.now() });
193
+ return key;
194
+ } catch {
195
+ // A folder that cannot be resolved has no room to hold.
196
+ return null;
197
+ }
198
+ }
199
+
200
+ /** The run live in `repoPath` right now, or null. A list that cannot be read concludes nothing. */
201
+ export function liveRunIn(repoPath, { sessions = listSessions() } = {}) {
202
+ let root;
203
+ try {
204
+ root = realpathSync(repoPath);
205
+ } catch {
206
+ return null;
207
+ }
208
+ const hold = held.get(root);
209
+ // The hold ends after HOLD_MS, or once its session has had time to show up
210
+ // in the session list and is not there or not live.
211
+ const age = hold ? Date.now() - hold.at : Infinity;
212
+ const gone =
213
+ Boolean(hold) && age > LIST_GRACE_MS && sessionLiveness(hold.sessionId, sessions) === false;
214
+ if (hold && age < HOLD_MS && !gone) return hold.taskId;
215
+ if (hold) held.delete(root);
216
+ for (const rec of startedJobs().values()) {
217
+ if (!rec.team || !rec.repoPath) continue;
218
+ let at;
219
+ try {
220
+ at = realpathSync(rec.repoPath);
221
+ } catch {
222
+ continue;
223
+ }
224
+ if (at === root && sessionLiveness(rec.sessionId, sessions) === true) return `job ${rec.jobId}`;
225
+ }
226
+ for (const base of new Set([logsRoot(), unattendedLogsRoot()])) {
227
+ for (const run of listRuns(base)) {
228
+ let state;
229
+ try {
230
+ state = JSON.parse(readFileSync(join(run.dir, "agent-state.json"), "utf8"));
231
+ } catch {
232
+ continue;
233
+ }
234
+ if (!["in_progress", "awaiting_input"].includes(state?.status)) continue;
235
+ let at;
236
+ try {
237
+ at = realpathSync(state.projectRoot ?? "");
238
+ } catch {
239
+ continue;
240
+ }
241
+ if (at !== root) continue;
242
+ if (sessionLiveness(state.sessionId, sessions) === false) continue;
243
+ return run.taskId;
244
+ }
245
+ }
246
+ return null;
247
+ }
248
+
249
+ const fill = (template, values) =>
250
+ template.replace(/\{([a-zA-Z]+)\}/g, (whole, k) => (k in values ? values[k] : whole));
251
+
252
+ /** The prompt a solo job's session receives: the command and its arguments, in catalog order. */
253
+ export function soloPrompt(job, args) {
254
+ const parts = job.params.map((p) => args[p.name]).filter((v) => typeof v === "string" && v);
255
+ return [`/multi-agent:${job.command}`, ...parts].join(" ");
256
+ }
257
+
258
+ /**
259
+ * What the server starts for a job. Pure apart from the files it reads (the
260
+ * launch spec, the questions) and, for a team job, the request file it writes
261
+ * when `write` is true.
262
+ *
263
+ * @returns {{argv: string[], cwd: string, env: Record<string,string>, prompt: string,
264
+ * requestFile: string|null}}
265
+ */
266
+ export function planJob({ job, args, answers = {}, repoPath, sessionId, write = false }) {
267
+ const settingsFile = unattendedSettingsPath();
268
+ const runLogsRoot = unattendedLogsRoot();
269
+ if (job.launch) {
270
+ const mode = job.launch === "autopilot" ? "autopilot" : "background";
271
+ const request = {
272
+ contractVersion: LAUNCH_VERSION,
273
+ input: args.input,
274
+ mode,
275
+ ...(mode === "background" ? { kind: job.launch } : {}),
276
+ answers,
277
+ };
278
+ const requestFile = write
279
+ ? writeRequestFile(request, sessionId)
280
+ : join(runLogsRoot, "pending", `${sessionId}.json`);
281
+ const r = planLaunch({
282
+ request,
283
+ requestFile,
284
+ repoPath,
285
+ sessionId,
286
+ settingsFile,
287
+ runLogsRoot,
288
+ workingTree: workingTreeState(repoPath),
289
+ });
290
+ if (r.code !== 0) {
291
+ throw new JobError(r.reason ?? "invalid-request", "the launch request was refused", {
292
+ errors: r.out.errors,
293
+ });
294
+ }
295
+ return {
296
+ argv: r.out.argv,
297
+ cwd: r.out.cwd,
298
+ env: r.out.env,
299
+ prompt: r.out.argv[r.out.argv.length - 1],
300
+ requestFile: write ? requestFile : null,
301
+ };
302
+ }
303
+ const shape = launchSpec().hosts.claude.background.development;
304
+ const values = { sessionId, settingsFile, runLogsRoot, repoPath, requestFile: "" };
305
+ const prompt = soloPrompt(job, args);
306
+ const argv = shape.argv.slice(0, -1).map((a) => fill(a, values));
307
+ const env = Object.fromEntries(
308
+ Object.entries(shape.env)
309
+ // A solo job reads the operator's runs, so it keeps the operator's logs root.
310
+ .filter(([k]) => k !== "MA_LAUNCH_REQUEST" && k !== "LOGS_ROOT")
311
+ .map(([k, v]) => [k, fill(v, values)]),
312
+ );
313
+ return { argv: [...argv, prompt], cwd: repoPath, env, prompt, requestFile: null };
314
+ }
315
+
316
+ /** Where the office records the sessions it started, one 0600 file each. */
317
+ export const jobsDir = (env = process.env) =>
318
+ join(env.HOME || homedir(), ".claude", "logs", "multi-agent", "office", "jobs");
319
+
320
+ // A record seats its session for a day at most; older ones are dropped when
321
+ // the next job starts, so the directory stays small for a server that runs on.
322
+ const KEEP_MS = 24 * 60 * 60 * 1000;
323
+
324
+ function pruneJobs(env) {
325
+ const dir = jobsDir(env);
326
+ let names;
327
+ try {
328
+ names = readdirSync(dir);
329
+ } catch {
330
+ return;
331
+ }
332
+ const now = Date.now();
333
+ for (const n of names) {
334
+ if (!/^[0-9a-f-]{36}\.json$/.test(n)) continue;
335
+ try {
336
+ if (now - statSync(join(dir, n)).mtimeMs > KEEP_MS) rmSync(join(dir, n), { force: true });
337
+ } catch {
338
+ // Gone already.
339
+ }
340
+ }
341
+ }
342
+
343
+ /** The jobs the office started, by session id, for the floor to seat. */
344
+ export function startedJobs(env = process.env) {
345
+ const out = new Map();
346
+ const dir = jobsDir(env);
347
+ if (!existsSync(dir)) return out;
348
+ for (const n of readdirSync(dir)) {
349
+ if (!/^[0-9a-f-]{36}\.json$/.test(n)) continue;
350
+ try {
351
+ const rec = JSON.parse(readFileSync(join(dir, n), "utf8"));
352
+ if (
353
+ rec &&
354
+ typeof rec.sessionId === "string" &&
355
+ Date.now() - Date.parse(rec.startedAt) < KEEP_MS
356
+ )
357
+ out.set(rec.sessionId, rec);
358
+ } catch {
359
+ // A record that does not parse seats nobody.
360
+ }
361
+ }
362
+ return out;
363
+ }
364
+
365
+ /**
366
+ * Check a job request and describe it, starting nothing: the preview the
367
+ * confirmation shows.
368
+ */
369
+ export function previewJob({ jobId, args = {}, answers, repoPath, knownRun, sessions }) {
370
+ const job = jobById(jobId);
371
+ if (!job) throw new JobError("unknown-job", "no such job");
372
+ const doc = jobsDocument().jobs.find((j) => j.id === jobId);
373
+ if (!doc.available) throw new JobError("command-not-installed", "that command is not installed");
374
+ if (!isAbsolute(repoPath ?? ""))
375
+ throw new JobError("invalid-repo", "the repository path must be absolute");
376
+ const errors = paramErrors(job, args, { repoPath, knownRun });
377
+ if (
378
+ answers !== undefined &&
379
+ (!job.launch || typeof answers !== "object" || Array.isArray(answers))
380
+ )
381
+ errors.push("answers are only for a pipeline run, as an object");
382
+ if (errors.length)
383
+ throw new JobError("invalid-args", "the job does not fit its parameters", { errors });
384
+ if (job.team) {
385
+ const busy = liveRunIn(repoPath, sessions ? { sessions } : undefined);
386
+ if (busy)
387
+ throw new JobError("room-busy", "a run is already live in this repository", { taskId: busy });
388
+ }
389
+ const plan = planJob({ job, args, answers, repoPath, sessionId: randomUUID() });
390
+ return { job, prompt: plan.prompt };
391
+ }
392
+
393
+ /**
394
+ * Start a job that previewJob accepted. `precondition` is the unattended
395
+ * sandbox check; `start` is lib/bg-launch.mjs startBackgroundAsync (a test
396
+ * passes a stand-in). Resolves to the record written.
397
+ */
398
+ export async function startJob({
399
+ jobId,
400
+ args = {},
401
+ answers,
402
+ repoPath,
403
+ repoName,
404
+ knownRun,
405
+ sessions,
406
+ precondition,
407
+ start = startBackgroundAsync,
408
+ env = process.env,
409
+ }) {
410
+ const { job } = previewJob({ jobId, args, answers, repoPath, knownRun, sessions });
411
+ const sessionId = randomUUID();
412
+ const plan = planJob({ job, args, answers, repoPath, sessionId, write: true });
413
+ const { MA_RUN_BASE_SHA: _stale, ...base } = env;
414
+ const deny = [base.MCP_TOOLKIT_INDEX_DENY, plan.env.MCP_TOOLKIT_INDEX_DENY]
415
+ .flatMap((v) => String(v || "").split(","))
416
+ .map((s) => s.trim())
417
+ .filter(Boolean);
418
+ const childEnv = {
419
+ ...base,
420
+ ...plan.env,
421
+ ...(deny.length ? { MCP_TOOLKIT_INDEX_DENY: [...new Set(deny)].join(",") } : {}),
422
+ };
423
+ const key = job.team ? holdRoom(repoPath, `job ${job.id}`, sessionId) : null;
424
+ const release = () => {
425
+ if (key) held.delete(key);
426
+ if (plan.requestFile) rmSync(plan.requestFile, { force: true });
427
+ };
428
+ let r;
429
+ try {
430
+ r = await start({ argv: plan.argv, cwd: plan.cwd, env: childEnv, precondition });
431
+ } catch (err) {
432
+ release();
433
+ throw err;
434
+ }
435
+ if (r.blocked || r.status !== 0) {
436
+ // A launcher that timed out may have left its session running: the room
437
+ // stays held and the request file stays for it.
438
+ if (!r.timedOut) release();
439
+ // What claude printed can name paths; the page gets a fixed message and
440
+ // the reason stays in the server's own output.
441
+ const why = String(r.blocked || r.stderr || r.error?.message || "").trim();
442
+ if (why) process.stderr.write(`job-launch: ${job.id}: ${why.slice(0, 300)}\n`);
443
+ if (r.blocked)
444
+ throw new JobError("sandbox-unavailable", "the unattended sandbox is not available");
445
+ throw new JobError("launch-failed", "the session did not start");
446
+ }
447
+ const record = {
448
+ contractVersion: CONTRACT_VERSION,
449
+ sessionId,
450
+ jobId: job.id,
451
+ command: job.command,
452
+ role: job.role,
453
+ team: job.team,
454
+ repo: repoName,
455
+ repoPath,
456
+ prompt: plan.prompt,
457
+ startedAt: new Date().toISOString(),
458
+ };
459
+ mkdirSync(jobsDir(env), { recursive: true, mode: 0o700 });
460
+ pruneJobs(env);
461
+ writeFileSync(join(jobsDir(env), `${sessionId}.json`), JSON.stringify(record, null, 2) + "\n", {
462
+ mode: 0o600,
463
+ flag: "wx",
464
+ });
465
+ return record;
466
+ }
467
+
468
+ if (invokedDirectly(import.meta.url)) {
469
+ runMain("job-launch", () => {
470
+ const [cmd] = process.argv.slice(2);
471
+ if (cmd !== "list") {
472
+ process.stderr.write("usage: job-launch.mjs list\n");
473
+ process.exit(2);
474
+ }
475
+ process.stdout.write(JSON.stringify(jobsDocument(), null, 2) + "\n");
476
+ });
477
+ }