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