@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,252 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hook-registration.mjs - are the hooks the template ships actually
4
+ * registered where Claude Code will run them, and which preferences do
5
+ * nothing because their hook is not?
6
+ *
7
+ * Read the way Claude Code reads it, as autopilot-runner.mjs guardRegistration
8
+ * does for agent-guard: the user settings (CLAUDE_CONFIG_DIR, else ~/.claude:
9
+ * settings.json and settings.local.json) and the macOS managed settings. A
10
+ * template entry is registered when an entry of the same event runs the same
11
+ * command (the $HOME / ${HOME} and quoting spellings are one command) and its
12
+ * matcher covers every tool name the template's matcher lists. A matcher is a
13
+ * regex over the tool name; an alternative that is itself a regex
14
+ * (`mcp__.*[Ff]igma.*`) is covered only by the identical alternative or a
15
+ * match-all matcher. The command's script has to exist under
16
+ * ~/.claude/scripts: a hook whose script is missing exits non-zero and blocks
17
+ * nothing. `disableAllHooks` in any of the files turns every hook off, and
18
+ * `allowManagedHooksOnly` limits the source to the managed file.
19
+ *
20
+ * Inert preferences: a preference whose only effect is through a hook does
21
+ * nothing while that hook is unregistered (INERT_PREFS).
22
+ *
23
+ * Usage:
24
+ * hook-registration.mjs check [--json] [--template <claude-hooks.json>]
25
+ *
26
+ * Exit: 0 every template hook registered and no inert preference, 1 a gap,
27
+ * 3 the template could not be read.
28
+ *
29
+ * @module pipeline/lib/hook-registration
30
+ */
31
+
32
+ import { existsSync, readFileSync } from "node:fs";
33
+ import { homedir } from "node:os";
34
+ import { dirname, join } from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+
37
+ import { invokedDirectly } from "./invoked-directly.mjs";
38
+ import { globalPref } from "./prefs-value.mjs";
39
+
40
+ const MANAGED_SETTINGS = "/Library/Application Support/ClaudeCode/managed-settings.json";
41
+ const SIMPLE_TOOL = /^[A-Za-z0-9_-]+$/;
42
+
43
+ /**
44
+ * Preferences that act only through a hook: the check that says whether the
45
+ * preference is set to something a hook would act on, and the script.
46
+ */
47
+ export const INERT_PREFS = [
48
+ {
49
+ pref: "editSyntaxGate.enabled",
50
+ active: (v) => v === true,
51
+ script: "edit-syntax-gate.sh",
52
+ },
53
+ {
54
+ pref: "bulkRead.mode",
55
+ active: (v) => v === "observe" || v === "enforce",
56
+ script: "check-read-size.sh",
57
+ },
58
+ {
59
+ pref: "attendedCommitGate.mode",
60
+ active: (v) => v === undefined || v === "warn" || v === "block",
61
+ script: "pre-commit-check.sh",
62
+ },
63
+ {
64
+ pref: "readOnlyRepos",
65
+ active: (v) => Array.isArray(v) && v.length > 0,
66
+ script: "agent-guard.sh",
67
+ },
68
+ ];
69
+
70
+ function readJson(p) {
71
+ try {
72
+ return JSON.parse(readFileSync(p, "utf8"));
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /** The template this install ships: the installed copy, else the source tree's. */
79
+ export function defaultTemplatePath(home = homedir()) {
80
+ const installed = join(home, ".claude", "templates", "claude-hooks.json");
81
+ if (existsSync(installed)) return installed;
82
+ const here = dirname(fileURLToPath(import.meta.url));
83
+ return join(here, "..", "..", "install", "templates", "claude-hooks.json");
84
+ }
85
+
86
+ /** The script file name a pipeline hook command runs, or null. */
87
+ export function scriptOf(command) {
88
+ const m = /scripts\/([\w.-]+\.(?:sh|mjs|py))/.exec(String(command || ""));
89
+ return m ? m[1] : null;
90
+ }
91
+
92
+ function normCommand(c) {
93
+ return String(c || "")
94
+ .replaceAll("${HOME}", "$HOME")
95
+ .replaceAll('"', "")
96
+ .replace(/\s+/g, " ")
97
+ .trim();
98
+ }
99
+
100
+ /** Every (event, matcher, command) the template registers. */
101
+ export function templateEntries(template) {
102
+ const out = [];
103
+ for (const [event, groups] of Object.entries(template?.hooks || {})) {
104
+ for (const g of Array.isArray(groups) ? groups : []) {
105
+ for (const h of Array.isArray(g?.hooks) ? g.hooks : []) {
106
+ if (h && typeof h.command === "string") {
107
+ out.push({ event, matcher: g.matcher ?? null, command: h.command });
108
+ }
109
+ }
110
+ }
111
+ }
112
+ return out;
113
+ }
114
+
115
+ function matcherCovers(have, want) {
116
+ const h = typeof have === "string" ? have : "";
117
+ if (h === "" || h === "*" || h === ".*") return true;
118
+ if (want === null || want === undefined || want === "") return h === "" || h === "*";
119
+ if (h === want) return true;
120
+ return String(want)
121
+ .split("|")
122
+ .every((alt) => {
123
+ if (!SIMPLE_TOOL.test(alt)) return h.split("|").includes(alt);
124
+ try {
125
+ return new RegExp(`^(?:${h})$`).test(alt);
126
+ } catch {
127
+ return false;
128
+ }
129
+ });
130
+ }
131
+
132
+ /**
133
+ * Which template hooks are not registered.
134
+ *
135
+ * @param {{template: object, home?: string, env?: Record<string,string|undefined>, managedPath?: string}} opts
136
+ * @returns {{disabled: string|null, missing: {event: string, matcher: string|null, command: string, script: string|null, why: string}[], registeredScripts: Set<string>}}
137
+ */
138
+ export function registrationGaps({
139
+ template,
140
+ home = homedir(),
141
+ env = process.env,
142
+ managedPath = MANAGED_SETTINGS,
143
+ }) {
144
+ const userDir = env.CLAUDE_CONFIG_DIR || join(home, ".claude");
145
+ const docs = [join(userDir, "settings.json"), join(userDir, "settings.local.json"), managedPath]
146
+ .map(readJson)
147
+ .filter((d) => d && typeof d === "object");
148
+ const wanted = templateEntries(template);
149
+ const off = docs.find((d) => d.disableAllHooks === true);
150
+ if (off) {
151
+ return {
152
+ disabled: "disableAllHooks is set",
153
+ missing: wanted.map((w) => ({ ...w, script: scriptOf(w.command), why: "hooks disabled" })),
154
+ registeredScripts: new Set(),
155
+ };
156
+ }
157
+ const managed = readJson(managedPath);
158
+ const sources = managed?.allowManagedHooksOnly === true ? [managed] : docs;
159
+ const scriptsDir = join(home, ".claude", "scripts");
160
+ const registeredScripts = new Set();
161
+ const missing = [];
162
+ for (const w of wanted) {
163
+ const script = scriptOf(w.command);
164
+ const groups = sources.flatMap((d) =>
165
+ Array.isArray(d?.hooks?.[w.event]) ? d.hooks[w.event] : [],
166
+ );
167
+ const hit = groups.some(
168
+ (g) =>
169
+ g &&
170
+ matcherCovers(g.matcher, w.matcher) &&
171
+ Array.isArray(g.hooks) &&
172
+ g.hooks.some((h) => h && normCommand(h.command) === normCommand(w.command)),
173
+ );
174
+ if (!hit) {
175
+ missing.push({ ...w, script, why: "not registered" });
176
+ continue;
177
+ }
178
+ if (script && !existsSync(join(scriptsDir, script))) {
179
+ missing.push({ ...w, script, why: `registered, but ~/.claude/scripts/${script} is missing` });
180
+ continue;
181
+ }
182
+ if (script) registeredScripts.add(script);
183
+ }
184
+ // A script registered on one matcher still counts as reachable for a
185
+ // preference; the per-matcher gap is reported in `missing`.
186
+ return { disabled: null, missing, registeredScripts };
187
+ }
188
+
189
+ /**
190
+ * Preferences set to act while their hook is not registered at all.
191
+ *
192
+ * @param {Set<string>} registeredScripts
193
+ * @param {Record<string,string|undefined>} [env]
194
+ * @returns {{pref: string, script: string}[]}
195
+ */
196
+ export function inertPrefs(registeredScripts, env = process.env) {
197
+ return INERT_PREFS.filter((e) => e.active(globalPref(e.pref, undefined, env)))
198
+ .filter((e) => !registeredScripts.has(e.script))
199
+ .map(({ pref, script }) => ({ pref, script }));
200
+ }
201
+
202
+ /** The whole report for one machine. */
203
+ export function hookReport({
204
+ templatePath,
205
+ home = homedir(),
206
+ env = process.env,
207
+ managedPath,
208
+ } = {}) {
209
+ const path = templatePath || defaultTemplatePath(home);
210
+ const template = readJson(path);
211
+ if (!template)
212
+ return { ok: false, error: `cannot read the hooks template at ${path}`, templatePath: path };
213
+ const gaps = registrationGaps({ template, home, env, managedPath });
214
+ const inert = inertPrefs(gaps.registeredScripts, env);
215
+ return {
216
+ ok: gaps.missing.length === 0 && inert.length === 0,
217
+ templatePath: path,
218
+ disabled: gaps.disabled,
219
+ missing: gaps.missing,
220
+ inert,
221
+ };
222
+ }
223
+
224
+ function main(argv) {
225
+ if (argv[0] !== "check") {
226
+ process.stderr.write("usage: hook-registration.mjs check [--json] [--template <path>]\n");
227
+ return 3;
228
+ }
229
+ const i = argv.indexOf("--template");
230
+ const r = hookReport({ templatePath: i === -1 ? undefined : argv[i + 1] });
231
+ if (argv.includes("--json")) {
232
+ process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
233
+ } else if (r.error) {
234
+ process.stderr.write(`hook-registration: ${r.error}\n`);
235
+ } else {
236
+ for (const m of r.missing) {
237
+ process.stdout.write(
238
+ `missing ${m.event}${m.matcher ? ` ${m.matcher}` : ""} ${m.script || m.command} (${m.why})\n`,
239
+ );
240
+ }
241
+ for (const p of r.inert) {
242
+ process.stdout.write(
243
+ `inert prefs.global.${p.pref} needs ${p.script}, which is not registered\n`,
244
+ );
245
+ }
246
+ if (r.ok) process.stdout.write("hook-registration: every template hook is registered\n");
247
+ }
248
+ if (r.error) return 3;
249
+ return r.ok ? 0 : 1;
250
+ }
251
+
252
+ if (invokedDirectly(import.meta.url)) process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1,44 @@
1
+ /**
2
+ * prefs-value.mjs - one `prefs.global.*` value, read the way the hooks read
3
+ * it: from the first preferences file that exists, MULTI_AGENT_PREFS first,
4
+ * then ~/.claude, then ~/.config/multi-agent-pipeline. A file that does not
5
+ * parse yields the fallback rather than an error, because the callers are
6
+ * hooks that must not fail a tool call over a malformed preference.
7
+ *
8
+ * @module pipeline/lib/prefs-value
9
+ */
10
+
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ /** The preferences files a hook consults, in order. */
16
+ export function prefsCandidates(env = process.env) {
17
+ const home = env.HOME || homedir();
18
+ return [
19
+ env.MULTI_AGENT_PREFS,
20
+ join(home, ".claude", "multi-agent-preferences.json"),
21
+ join(home, ".config", "multi-agent-pipeline", "multi-agent-preferences.json"),
22
+ ].filter(Boolean);
23
+ }
24
+
25
+ /**
26
+ * `prefs.global.<dotted>` from the first preferences file that exists.
27
+ *
28
+ * @param {string} dotted e.g. "attendedCommitGate.mode"
29
+ * @param {unknown} fallback
30
+ * @param {Record<string,string|undefined>} [env]
31
+ */
32
+ export function globalPref(dotted, fallback, env = process.env) {
33
+ for (const p of prefsCandidates(env)) {
34
+ if (!existsSync(p)) continue;
35
+ try {
36
+ let node = JSON.parse(readFileSync(p, "utf8"))?.global;
37
+ for (const key of dotted.split(".")) node = node?.[key];
38
+ return node === undefined ? fallback : node;
39
+ } catch {
40
+ return fallback;
41
+ }
42
+ }
43
+ return fallback;
44
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * run-completion.mjs - where a run stands against its mode's phase list, and
3
+ * whether it has earned `status: complete`.
4
+ *
5
+ * `complete` is the word every reader trusts: runs-index shows the run as
6
+ * done, the autopilot runner files it as finished and never queues it again.
7
+ * So it is held to evidence that is already in the state, not to the claim:
8
+ *
9
+ * 1. `currentPhase` is the last phase of the run's mode (schemas/phases.json).
10
+ * 2. Phase 4 left a trace, unless the mode declares `commits: false`:
11
+ * `commit.sha`, a `pr`, a runner `publish` record, a `projects[].commit`
12
+ * or `projects[].pr`, or an explicit no-commit decision
13
+ * (`phases["4"].status: "skipped"` with a `notes` reason).
14
+ * 3. The plan's coverage was judged, unless the mode declares
15
+ * `commits: false`: a `plan-coverage` entry in `gates[]` with a passing
16
+ * verdict. plan-coverage-gate.mjs records it attended and unattended.
17
+ *
18
+ * A mode phases.json does not declare is not judged here: such a command runs
19
+ * its own flow and its own definition of done.
20
+ *
21
+ * Writers: write-state.mjs refuses a transition into `complete` that fails
22
+ * this; the autopilot runner files such a state as `stopped-incomplete`.
23
+ *
24
+ * @module pipeline/lib/run-completion
25
+ */
26
+
27
+ import { readFileSync } from "node:fs";
28
+
29
+ const PHASES_URL = new URL("../schemas/phases.json", import.meta.url);
30
+ const PASSING = new Set(["pass", "not-applicable"]);
31
+
32
+ let contractCache = null;
33
+
34
+ /** schemas/phases.json, read once per process. */
35
+ export function phaseContract() {
36
+ if (!contractCache) contractCache = JSON.parse(readFileSync(PHASES_URL, "utf8"));
37
+ return contractCache;
38
+ }
39
+
40
+ /**
41
+ * The mode a state runs in, as phases.json names it. No `mode` is the full
42
+ * pipeline, or autopilot when the state carries `autopilot: true`.
43
+ *
44
+ * @param {object} state
45
+ * @returns {string}
46
+ */
47
+ export function modeOf(state) {
48
+ if (state && typeof state.mode === "string" && state.mode) return state.mode;
49
+ return state && state.autopilot === true ? "autopilot" : "full";
50
+ }
51
+
52
+ /** The phases.json entry of the state's mode, or null when it is undeclared. */
53
+ export function modeEntry(state) {
54
+ const m = phaseContract().modes?.[modeOf(state)];
55
+ return m && Array.isArray(m.phases) && m.phases.length ? m : null;
56
+ }
57
+
58
+ /** The ordered phase ids of the state's mode, or null for an undeclared mode. */
59
+ export function modePhases(state) {
60
+ return modeEntry(state)?.phases ?? null;
61
+ }
62
+
63
+ /** The last phase of the state's mode, or null for an undeclared mode. */
64
+ export function finalPhaseOf(state) {
65
+ const list = modePhases(state);
66
+ return list ? list[list.length - 1] : null;
67
+ }
68
+
69
+ /** The name phases.json gives a phase id. */
70
+ export function phaseName(id) {
71
+ const entry = (phaseContract().phases || []).find((p) => p.id === Number(id));
72
+ return entry?.name || `Phase ${id}`;
73
+ }
74
+
75
+ /**
76
+ * The phase a run at `currentPhase` enters next, or null at or past its last
77
+ * phase, or for an undeclared mode.
78
+ *
79
+ * @param {object} state
80
+ * @returns {{id: number, name: string}|null}
81
+ */
82
+ export function nextPhaseOf(state) {
83
+ const list = modePhases(state);
84
+ const cur = Number(state?.currentPhase);
85
+ if (!list || !Number.isInteger(cur)) return null;
86
+ const id = list.find((p) => p > cur);
87
+ return id === undefined ? null : { id, name: phaseName(id) };
88
+ }
89
+
90
+ const hasSha = (c) => Boolean(c && typeof c.sha === "string" && c.sha.length >= 7);
91
+ const hasPr = (p) =>
92
+ Boolean(p && ((typeof p.url === "string" && p.url) || Number.isInteger(p.number)));
93
+
94
+ /** True when Phase 4 left evidence of delivering, or of deciding not to. */
95
+ export function phase4Evidence(state) {
96
+ if (hasSha(state.commit) || hasPr(state.pr)) return true;
97
+ if (state.publish && typeof state.publish === "object") return true;
98
+ const projects = Array.isArray(state.projects) ? state.projects : [];
99
+ if (projects.some((p) => p && (hasSha(p.commit) || hasPr(p.pr)))) return true;
100
+ const p4 = state.phases?.["4"];
101
+ return Boolean(p4 && p4.status === "skipped" && typeof p4.notes === "string" && p4.notes.trim());
102
+ }
103
+
104
+ /** True when a passing plan-coverage verdict is in the gate ledger. */
105
+ export function planCoverageRecorded(state) {
106
+ const gates = Array.isArray(state.gates) ? state.gates : [];
107
+ let latest = null;
108
+ for (const e of gates) {
109
+ if (!e || e.gate !== "plan-coverage") continue;
110
+ if (!latest || (Date.parse(e.at) || 0) >= (Date.parse(latest.at) || 0)) latest = e;
111
+ }
112
+ return Boolean(latest && PASSING.has(latest.verdict));
113
+ }
114
+
115
+ /**
116
+ * What stands between this state and `status: complete`. Empty means it may
117
+ * complete (or its mode is not judged here).
118
+ *
119
+ * @param {object} state the document as it would be written
120
+ * @returns {string[]}
121
+ */
122
+ export function completionGaps(state) {
123
+ if (!state || typeof state !== "object") return ["the state is not an object"];
124
+ const entry = modeEntry(state);
125
+ if (!entry) return [];
126
+ const mode = modeOf(state);
127
+ const final = entry.phases[entry.phases.length - 1];
128
+ const gaps = [];
129
+ const cur = Number(state.currentPhase);
130
+ if (!Number.isInteger(cur) || cur !== final) {
131
+ gaps.push(
132
+ `currentPhase is ${state.currentPhase ?? "unset"}, and mode ${mode} ends at Phase ${final} (${phaseName(final)})`,
133
+ );
134
+ }
135
+ if (entry.commits !== false) {
136
+ if (!phase4Evidence(state)) {
137
+ gaps.push(
138
+ 'no Phase 4 evidence: record commit.sha (or pr), or the no-commit decision as phases["4"] = {status: "skipped", notes: "<why>"}',
139
+ );
140
+ }
141
+ if (!planCoverageRecorded(state)) {
142
+ gaps.push(
143
+ "no passing plan-coverage verdict in gates[]: run plan-coverage-gate.mjs --state <agent-state.json> (Phase 4 Step 0a)",
144
+ );
145
+ }
146
+ }
147
+ return gaps;
148
+ }
@@ -23,7 +23,7 @@
23
23
  content: <markdown>
24
24
  ```
25
25
 
26
- **Explicit punctuation policy** (enforced by `stripFancyPunctuation: true`): no em-dash (U+2014), no en-dash (U+2013), no horizontal ellipsis (U+2026), no curly quotes (U+2018, U+2019, U+201C, U+201D), no section sign (U+00A7). The humanizer replaces these with ASCII equivalents (`-`, `:`, `,`, `...`, `'`, `"`, and `bölüm` / `section` for the section sign per `outputLanguage`) before emit. Tables, code blocks, URLs, and front-matter YAML are exempt. Verify with `node $HOME/.claude/scripts/validate-analysis-doc.mjs <file>`, not a Perl-regex grep (BSD grep lacks it, so "zero matches" is trivially true). Readability rules live in the humanizer skill; this paragraph owns only the punctuation policy. **Diacritics are PRESERVED: only the listed codepoints are replaced. Turkish letters (ş, ç, ğ, ı, İ, ö, ü) stay verbatim; never ASCII-fold (`Geliştirme Özeti`, not `Gelistirme Ozeti`). ASCII-folded Turkish fails review.**
26
+ **Explicit punctuation policy** (enforced by `stripFancyPunctuation: true`): no em-dash (U+2014), no en-dash (U+2013), no horizontal ellipsis (U+2026), no curly quotes (U+2018, U+2019, U+201C, U+201D), no section sign (U+00A7). The humanizer replaces these with ASCII equivalents (`-`, `:`, `,`, `...`, `'`, `"`, and `bölüm` / `section` for the section sign per `outputLanguage`) before emit. Tables, code blocks, URLs, and front-matter YAML are exempt. Verify with `node $HOME/.claude/scripts/validate-analysis-doc.mjs <file>`. **Diacritics are PRESERVED: only the listed codepoints are replaced. Turkish letters (ş, ç, ğ, ı, İ, ö, ü) stay verbatim; never ASCII-fold (`Geliştirme Özeti`, not `Gelistirme Ozeti`). ASCII-folded Turkish fails review.**
27
27
 
28
28
  3b. **Build Section 21 References** (Locked 33). Fingerprint the sources first (`features/analysis-sources.md`), then emit the table:
29
29
 
@@ -131,7 +131,7 @@ for f in /tmp/analysis-<feature-slug>-<ts>/*.md; do
131
131
  done
132
132
  ```
133
133
 
134
- `validate-analysis-doc.mjs` enforces the mechanically-checkable Locked decisions on the emitted markdown itself (front-matter completeness, never-omitted sections per Locked 2, humanizer punctuation per Locked 7, Full-mode business-rule traceability per Locked 30, and in the corporate profile the backbone presence and `EKLENECEK`-to-Section-20 pairing per Locked 32). `build-references.mjs --check` runs the References coverage gate (Locked 33): a source the run consumed but did not list, or a listed row with no evidence behind it, blocks dispatch. `verify-citations.mjs` resolves each claimed `file:line` at HEAD with `git cat-file`, so `Foo.swift:9999` in a repo with no Foo.swift blocks dispatch (Locked 3, Locked 36). Any ERROR blocks dispatch: fix the draft and re-validate. Warnings are advisory (run with `--strict` to treat them as blocking). This turns the "fails the dispatch gate" prose into a real, model-independent check.
134
+ `validate-analysis-doc.mjs` enforces the mechanically-checkable Locked decisions on the emitted markdown itself (front-matter completeness, never-omitted sections per Locked 2, humanizer punctuation per Locked 7, Full-mode business-rule traceability per Locked 30, and in the corporate profile the backbone presence and `EKLENECEK`-to-Section-20 pairing per Locked 32). `build-references.mjs --check` runs the References coverage gate (Locked 33): a source the run consumed but did not list, or a listed row with no evidence behind it, blocks dispatch. `verify-citations.mjs` resolves each claimed `file:line` at HEAD with `git cat-file`, so `Foo.swift:9999` in a repo with no Foo.swift blocks dispatch (Locked 3, Locked 36). Any ERROR blocks dispatch: fix the draft and re-validate. Warnings are advisory (run with `--strict` to treat them as blocking).
135
135
 
136
136
  Iterate `state.analysisSpec.outputs.requested`. For each target:
137
137
 
@@ -155,12 +155,12 @@ After dispatch, print a summary to the user (in `outputLanguage`). Example shape
155
155
  STATE="$HOME/.claude/logs/multi-agent/$TASK_ID/agent-state.json"
156
156
  mkdir -p "$(dirname "$STATE")"
157
157
  STATUS=complete # or failed when the run could not emit its document
158
- printf '%s' "{\"taskId\":\"$TASK_ID\",\"command\":\"/multi-agent:analysis\",\"mode\":\"analysis\",\"project\":${PROJECT_JSON:-null},\"status\":\"$STATUS\",\"finishedAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" \
158
+ printf '%s' "{\"taskId\":\"$TASK_ID\",\"command\":\"/multi-agent:analysis\",\"mode\":\"analysis\",\"project\":${PROJECT_JSON:-null},\"status\":\"$STATUS\",\"currentPhase\":5,\"finishedAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" \
159
159
  | node "$HOME/.claude/scripts/write-state.mjs" "$STATE" >/dev/null 2>&1 || true
160
160
  node "$HOME/.claude/scripts/usage-report.mjs" --state "$STATE" >/dev/null 2>&1 || true
161
161
  ```
162
162
 
163
- `mode` is `analysis`, one of the modes `schemas/phases.json` declares; the reporter sends only those, and anything else as `unknown`. `status` is `complete` or `failed` (agent-state.schema.json), which the reporter turns into the completed or failed run status. `project` is digested by the reporter before it leaves the machine, and is `null` on a stack-optional run - there is no repository to name. A failure here never affects the run: the reporter is best-effort and the summary above is already printed.
163
+ `mode` is `analysis`, one of the modes `schemas/phases.json` declares; the reporter sends only those, and anything else as `unknown`. `status` is `complete` or `failed` (agent-state.schema.json), which the reporter turns into the completed or failed run status. `project` is digested by the reporter before it leaves the machine, and is `null` on a stack-optional run - there is no repository to name. A failure here never affects the run: the reporter is best-effort.
164
164
 
165
165
  ```
166
166
  Feature: <featureName>
@@ -1,7 +1,7 @@
1
1
  # Cross-CLI Contract (Claude Code · Copilot CLI · Codex CLI)
2
2
 
3
3
  <!-- toc -->
4
- - [1. Command Inventory (66 commands)](#1-command-inventory-66-commands)
4
+ - [1. Command Inventory (69 commands)](#1-command-inventory-69-commands)
5
5
  - [2. Canonical Placeholder Vocabulary](#2-canonical-placeholder-vocabulary)
6
6
  - [2.6 Intentional structural divergence - thin dispatcher vs inlined orchestrator](#26-intentional-structural-divergence---thin-dispatcher-vs-inlined-orchestrator)
7
7
  - [2.7 One command, three different meanings: `/multi-agent:model`](#27-one-command-three-different-meanings-multi-agentmodel)
@@ -20,14 +20,15 @@
20
20
 
21
21
  ---
22
22
 
23
- ## 1. Command Inventory (66 commands)
23
+ ## 1. Command Inventory (69 commands)
24
24
 
25
25
  ```
26
26
  analysis, analysis-jira, analysis-resolve, autopilot, autopilot-off,
27
27
  autopilot-on, autopilot-status, bug-bash, build-optimize, channels, complaint-analysis,
28
28
  create-jira, design-check, diff-explain, doctor, estimate, feedback, forget,
29
29
  garbage-collect, graph, help, ios-coding-standard, issue, jira, kill,
30
- language, log, manual-test, model, postmortem, prune-logs,
30
+ language, log, manual-test, model, office-off, office-on, office-status,
31
+ postmortem, prune-logs,
31
32
  prune-prompts, purge, refactor, research, resume, review,
32
33
  review-analysis, review-issue, review-jira, route-off, route-on,
33
34
  route-status, routines, save, scaffold, scan, scenario-audit, search,
@@ -42,7 +43,7 @@ Categories:
42
43
  - **Issue generator** (one-shot, no worktree, asks type Task/Bug/Story, hard approval gate before create): `create-jira`
43
44
  - **Pipeline entries**: `autopilot` (plus the bare `/multi-agent` in the dispatcher). The phase set is a property of the command, and every mode runs its whole set.
44
45
  - **Tail path** (the pipeline tail over work already on a branch, no run behind it): `resume`
45
- - **Ops commands** (one-shot, no worktree): `status`, `log`, `kill`, `steer`, `purge`, `uninstall`, `resume`, `review`, `review-jira`, `review-issue`, `analysis`, `analysis-resolve`, `research`, `scaffold`, `complaint-analysis`, `bug-bash`, `test-gen`, `build-optimize`, `channels`, `scan`, `search`, `diff-explain`, `garbage-collect`, `graph`, `postmortem`, `prune-logs`, `prune-prompts`, `serve`
46
+ - **Ops commands** (one-shot, no worktree): `status`, `log`, `kill`, `steer`, `purge`, `uninstall`, `resume`, `review`, `review-jira`, `review-issue`, `analysis`, `analysis-resolve`, `research`, `scaffold`, `complaint-analysis`, `bug-bash`, `test-gen`, `build-optimize`, `channels`, `scan`, `search`, `diff-explain`, `garbage-collect`, `graph`, `postmortem`, `prune-logs`, `prune-prompts`, `serve`, `office-on`, `office-off`, `office-status`
46
47
  - **Local audits** (worktree only to build; no commit, push, PR or channels): `design-check`, `testflight-validation`, `ios-coding-standard`. `testflight-validation` additionally never invokes `altool --upload-app` - a validation run must not be able to ship a build by accident.
47
48
  - **Meta-ops**: `setup`, `sync`, `update`, `help`, `refactor`, `test`, `stack`, `manual-test`, `language`
48
49
  - **Routines** (user-defined routine registry; the routines they create are local-only and never synced): `save`, `routines`, `forget`
@@ -164,6 +164,17 @@ after the user has already answered the pickers.
164
164
  A git identity resolves for the account the run would commit as. WARN: the run
165
165
  reaches Phase 4 and stops there.
166
166
 
167
+ ### hook-reachability
168
+
169
+ What Claude Code will actually run, read the way the autopilot runner reads the
170
+ agent-guard registration (`lib/hook-registration.mjs`): every template hook has
171
+ an entry of the same event whose matcher covers the template's tool names, runs
172
+ the same command, and finds its script under `~/.claude/scripts`; no
173
+ `disableAllHooks`; and no preference is set to act through a hook that is not
174
+ registered (`editSyntaxGate.enabled`, `bulkRead.mode`, `attendedCommitGate.mode`,
175
+ `readOnlyRepos`). WARN, with each gap in `--explain`. SKIP when the template is
176
+ not installed.
177
+
167
178
  ### hook-coverage
168
179
 
169
180
  The blocking `PreToolUse` gates from `templates/claude-hooks.json` are present in
@@ -0,0 +1,91 @@
1
+ # Office routes: threat model
2
+
3
+ The office routes of `scripts/contract-server.mjs` let a client watch the
4
+ agents on this machine, read what they said, answer and steer runs, and change
5
+ what runs here (autopilot, stacks, routines). A browser panel may be served at
6
+ `GET /office`. This file names what is protected, from whom, by which control,
7
+ and which test performs the attack.
8
+
9
+ Scope: the local server only. A remote carrier (a private network or a relay to
10
+ a phone) is a later stage; its additional threats are listed at the end and are
11
+ not mitigated here.
12
+
13
+ ## Assets
14
+
15
+ | Asset | Where it lives | Why it matters |
16
+ |---|---|---|
17
+ | Bearer token | the server's memory and a 0600 file under `~/.claude/logs/multi-agent/contract-server/` | holds every desktop route |
18
+ | Transcripts | `~/.claude/projects/**.jsonl` | everything a session read, including file contents and command output |
19
+ | Credentials | the credential store (keychain), read only by the pipeline's own scripts | a leak is an account takeover |
20
+ | Run state | `agent-state.json`, `tracker-state.json` | an answer or steer written here changes what a run does |
21
+ | Machine configuration | autopilot `config.json` and launchd jobs, a repo's `.claude/settings.json`, routine commands | widening any of them changes what runs unattended |
22
+ | PR verdicts | posted to GitHub or Bitbucket with the user's own credential | an approval in the user's name |
23
+
24
+ ## Attackers
25
+
26
+ 1. **A malicious web page** in the user's browser, while the server runs.
27
+ 2. **Another local user or process** that can reach 127.0.0.1.
28
+ 3. **Hostile transcript content**: text an agent read (a web page, an issue, a
29
+ file) written to make the panel run script or leak data.
30
+ 4. **Later, with a remote carrier**: a compromised relay or site, a stolen
31
+ phone.
32
+
33
+ ## Threats and controls
34
+
35
+ | # | Threat | Control | Proof |
36
+ |---|---|---|---|
37
+ | T1 | DNS rebinding: a page on an attacker host resolves to 127.0.0.1 and calls the API under its own name | Every request, `/office` and the event stream included, must carry `Host: 127.0.0.1:<port>` or `localhost:<port>`; anything else is 403 before routing | `smoke-office-security.sh` T1, `test/office-routes.test.mjs` "403 under a rebound Host" |
38
+ | T2 | CSRF: a page posts to a write route | Writes (POST, DELETE) accept no `Origin` but the server's own, even an origin allowed with `--allow-origin`; a foreign preflight is 403 and no CORS header is ever sent to a foreign page; writes that widen what runs here take a second request with a single-use, route-bound confirm token | `smoke-office-security.sh` T2, `office-routes.test.mjs` "a write from a foreign page is refused" |
39
+ | T3 | Token theft or replay | The token is read from the `Authorization` header only (never a cookie or a query parameter), compared in constant time over digests, never written into a response, an error or a log line. It never appears in a URL: `office-on` opens the page with a one-time code in the fragment (10 seconds, one use, minted by the bearer holder at `POST /office/code` with no Origin), and the page trades it at `POST /office/exchange`, which accepts only the server's own Origin; a second use, an expired or an unknown code is 401. The code never reaches a command line, which every local user can read (`ps`): the browser is opened on a 0600 launcher page in the 0700 state directory that carries the code and forwards with `location.replace`, under a CSP that allows only its own script by hash; the launcher is removed after 15 seconds. An Origin header can be forged by a non-browser client, so the code's secrecy and its 10 second, single-use life are what protect the exchange. The page keeps the token in memory and strips the fragment. The server exits after 8 hours however busy it is, so a token cannot outlive a working day | `office-control.test.mjs` "the opener's argv carries no code", "the launcher page has a strict CSP", "the launcher is deleted"; `office-routes.test.mjs` "a panel code trades for the token once", "a code is refused from a foreign page", "an expired code is 401", "exits at its absolute lifetime"; `smoke-office-page.sh` "the code trades once" |
40
+ | T4 | Secret exposure through a response | Transcripts are read through `_transcript-tail.mjs`, which never parses api-request records and drops thinking blocks; every string passes the credential redactor and the scrubber; the finished office and chat documents are checked by the outbound gate; seat labels come from allow-listed fields (tool name, file basename, sidecar description), never a command line; credentials are read only by the service scripts and never returned | `smoke-office-privacy.sh`, `smoke-office-security.sh` T4, `test/office-activity.test.mjs` "carries no credential" |
41
+ | T5 | Path traversal into arbitrary files | Session, agent, run, routine, tool and evidence ids are matched against safe character sets after decoding; transcripts are looked up by name under the projects root only; a screenshot must resolve (real path) inside the run's evidence directory; an analysis document named in run state is read only when its real path is inside the run's log directory, its artifacts or a worktree git lists under the state's projectRoot, and only when it is Markdown or HTML | `smoke-office-security.sh` T5, `test/office-work.test.mjs` "nothing outside its directory", "analysis documents are read only inside the run's roots", "a worktreePath counts only when git lists it" |
42
+ | T6 | Oversized or malformed input | Bodies are capped at 64 KiB and validated against the route's schema; tool arguments are checked against the tool's inputSchema; launch input goes through `launch-request.mjs` validation only; chat paging is capped at 500 | `smoke-office-security.sh` T6, `office-work.test.mjs` "refuses an unknown tool, invalid arguments and an oversized body" |
43
+ | T7 | Resource exhaustion | At most four event streams and 64 connections; a phone request has five seconds to send its body before any signature work; at most 256 confirm tokens, the oldest dropped first; service clients, the work list and autopilot control run as child processes, never blocking the event loop, one at a time per service (429 `busy` for a second one); one snapshot is computed per poll from bounded tail reads; the session list is cached for ten seconds; the server exits after its idle time, after 8 hours in any case, and on SIGTERM | `smoke-office-security.sh` T7; `office-routes.test.mjs` "connections past the cap are dropped", "never sends its body", "confirm tokens are capped"; `office-work.test.mjs` "a slow service read neither blocks other routes nor runs twice at once" |
44
+ | T8 | XSS from hostile transcript or issue text | The panel renders every server string with `textContent` or canvas text; markdown is shown as plain text; server images only as `data:` URLs of image types; the office sprites are same-origin PNGs from `/office/assets/`, which answers only a plain file name (no separator or escape) whose real path is a PNG directly in the installed assets directory, and 404 otherwise; the page language the server stamps comes from a two-value set (`en`, `tr`), whatever the preferences file holds; the page's CSP allows only its per-response nonce for script and style, `connect-src 'self'`, no framing, no base URI, no form action | `smoke-office-page.sh`, `office-routes.test.mjs` "stamps the page language from outputLanguage", "serves a sprite and nothing else under /office/assets/", `office-build.test.mjs`, `smoke-office-runtime.sh` |
45
+ | T9 | Clickjacking | `frame-ancestors 'none'` on the page and on every API response | `office-routes.test.mjs` "serves the page without a token, with a nonce CSP" |
46
+ | T10 | An action nobody can account for | Every write leaves one line (who, route, step, status; never a body or token) in `office-actions.jsonl` | `office-routes.test.mjs` "every write leaves a line" |
47
+ | T11 | The panel widening the machine silently | Autopilot on, stack changes and routine removal are tagged `local-only` and `high`, take two requests, and run the same scripts as the terminal commands | `smoke-office-routes.sh`, `smoke-office-single-source.sh` |
48
+ | T12 | A toolkit tool used as a reader of local files or services (a path, a `file://` or loopback URL, a shared browser page, an index of files) | Tool calls are denied by default: only a tool that declares readOnlyHint, is not destructive, is not in the `web` or `context` family and whose inputSchema takes no location (a property named for a path, URL, file, directory, output, project, root and similar, or a uri format, at any depth) is callable; everything else is 403 `tool-not-allowed` and never reaches the server. A `confirm` argument is refused, so a tool's own preview is never skipped. The toolkit child always runs with `MULTI_AGENT_UNATTENDED=1` and `MCP_TOOLKIT_URL_POLICY=strict`, applied after the registration's own environment. An `@latest` registration is pinned to a configured version or refused. The route is `local-only`, and its audit line names the tool and carries a SHA-256 of the arguments, never the arguments | `office-work.test.mjs` "refuses every tool outside the allowlist with 403 and never runs it", "refuses a confirm argument", "runs unattended with the strict URL policy", "pinned to a known version or refused", "the audit line names the tool and hashes the arguments" |
49
+ | T13 | A steer instruction injected into an unattended run | Steering is tagged `launch` and `high` and takes two requests: the first shows the text, the run's phase and the unread instruction it replaces, and returns a token bound to that run and exactly that text; the second carries only the token. A finished run cannot be steered | `office-routes.test.mjs` "previews first, then queues on confirm", "a steer token is single use, bound to its run and cannot carry new text" |
50
+ | T14 | A review verdict posted from a stale or unfinished review | A verdict is previewed only for a finished review whose every finding is accepted, rejected or deferred (409 `review-incomplete` otherwise); the confirm token is bound to the decision and a digest of the findings, and a confirmation after either changed is 409 `confirm-mismatch` | `office-work.test.mjs` "a running review or one with an untriaged finding cannot be previewed", "refused when the findings changed after the preview" |
51
+ | T15 | A service read pointed at another host | A Confluence URL is accepted only on the configured Confluence host, and with no host configured only a page id is accepted (409 `host-not-allowed`), before the client script runs; Crashlytics takes only a Firebase console URL | `office-work.test.mjs` "a Confluence URL on another host is refused before the client runs", "only a page id is accepted" |
52
+ | T16 | Machine paths leaking through errors, or `office-off` signalling a reused pid | Error bodies name no absolute path (the launchd refusal and the unreadable settings message say what failed, not where); `office-off` sends SIGTERM only when the recorded pid's token file still names it, its command is `contract-server.mjs` and its start time matches the token file's | `office-routes.test.mjs` "refused without naming its path", "reported without the plist path"; `smoke-office-control.sh` "off never signals a pid that is not its server" |
53
+
54
+ ## Residual risks
55
+
56
+ - **Figma REST from the panel**: `GET /v1/services/figma` renders a frame
57
+ with the user's Figma token through `figma-screenshot.sh`. It runs only for
58
+ a panel request, in the server's own process, never inside a pipeline run's
59
+ context; the pipeline's own rule that Figma is fetched only in the analysis
60
+ phase is about runs and is unaffected. The Figma MCP is never called here.
61
+
62
+ - **Another process of the same user** can read the token file and call the
63
+ API. The server does not defend against code already running as the user;
64
+ nothing local does.
65
+ - **A page on the server's own origin** is trusted. Only the page this server
66
+ serves can be there, and its CSP keeps other code out.
67
+ - **Starting a session**: the server returns the launch plan and never starts
68
+ a host process, so a panel without a local process to run the plan cannot
69
+ start work by itself. That is a deliberate boundary of the contract.
70
+ - **Host permission prompts** are not forwarded: the host records none where
71
+ another process can read them (`GET /v1/permissions` says `available:
72
+ false`).
73
+
74
+ ## Later, with a remote carrier
75
+
76
+ Not mitigated by this server alone; the carrier must add them before any route
77
+ leaves the machine:
78
+
79
+ - a compromised relay or site: end-to-end encryption with keys only on the
80
+ phone and the machine, a separate locked origin with a strict CSP;
81
+ - a stolen phone: device keys bound to a passkey, revocation
82
+ (`phone-devices.mjs revoke`), and per-operation passkey signatures for every
83
+ `high` route;
84
+ - the `remote` and `risk` tags on every route are the input for that
85
+ enforcement; `local-only` routes are never offered remotely;
86
+ - steering from a phone takes a closed set of canned templates (for example
87
+ "stop after the current phase", "keep the change minimal", "skip the UI
88
+ test"), chosen by id and expanded on the machine, never free text typed on
89
+ the phone; free-text steer stays a desktop route;
90
+ - toolkit tool calls stay `local-only`; a phone gets their results only
91
+ through a run.
@@ -86,6 +86,9 @@ Status & Resume:
86
86
  /multi-agent:autopilot-status Running, queued, awaiting an answer, today's PRs
87
87
  /multi-agent:autopilot-off Stop picking work up (running work finishes; --now stops it)
88
88
  /multi-agent:serve Contract server for a client: 127.0.0.1, token, stops when idle
89
+ /multi-agent:office-on Open the office panel in your browser: agents at work, chats, ledger, runs
90
+ /multi-agent:office-status Is the panel running, its URL (never the token), its last actions
91
+ /multi-agent:office-off Close the panel now (it also stops by itself when idle)
89
92
 
90
93
  Post-Hoc & Side-Channel:
91
94