agentmash 0.3.0 → 0.4.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/README.md +29 -1
- package/agentmash.mjs +341 -8
- package/crew/README.md +52 -0
- package/crew/bundle.json +223 -0
- package/crew/crew.lock.json +14 -0
- package/crew/install.mjs +210 -0
- package/crew/team-plan.mjs +725 -0
- package/hooks/mcp_launcher.mjs +1 -1
- package/package.json +3 -2
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
// Pure planning logic for installing agentmash crew teams into a repository.
|
|
2
|
+
//
|
|
3
|
+
// Nothing here touches the file system, the process or the network: every function takes plain data (the bundle and
|
|
4
|
+
// a snapshot of the repository) and returns plain data. lib/install.mjs performs the side effects.
|
|
5
|
+
//
|
|
6
|
+
// Other installers (the agentmash CLI, the agentmash.dev web installer) must produce byte-identical files from the
|
|
7
|
+
// same bundle, so every ordering, substitution and serialization rule below is part of the documented format
|
|
8
|
+
// (docs/bundle-format.md): sort by UTF-16 code units, substitute placeholders in a single pass, serialize JSON as
|
|
9
|
+
// JSON.stringify(value, null, 2) + "\n", and never write timestamps.
|
|
10
|
+
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
|
|
13
|
+
export const SETTINGS_PATH = '.claude/settings.json';
|
|
14
|
+
export const MANIFEST_SCHEMA = 1;
|
|
15
|
+
|
|
16
|
+
const BUNDLE_SCHEMA = 1;
|
|
17
|
+
const RESERVED_PREFIX = '.claude/agentmash/'; // owned by the agentmash CLI, never ours
|
|
18
|
+
const NAME_MAX = 60;
|
|
19
|
+
const ROLE_MAX = 60;
|
|
20
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
21
|
+
const PATH_RE = /^[A-Za-z0-9._/-]+$/;
|
|
22
|
+
const AGENT_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
23
|
+
const SHA256_RE = /^[0-9a-f]{64}$/;
|
|
24
|
+
const PLACEHOLDER_RE = /\{\{([a-z_]+)\}\}/g;
|
|
25
|
+
const PLACEHOLDERS = new Set([
|
|
26
|
+
'team_name', 'team_slug', 'skill_name', 'members_inline', 'roster_table', 'missing_inline', 'add_command',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
// Russian and Kazakh Cyrillic to Latin. Hard and soft signs vanish instead of becoming a separator.
|
|
30
|
+
const TRANSLIT = {
|
|
31
|
+
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i', й: 'i', к: 'k', л: 'l',
|
|
32
|
+
м: 'm', н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh',
|
|
33
|
+
щ: 'shch', ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya',
|
|
34
|
+
ә: 'a', ғ: 'g', қ: 'q', ң: 'n', ө: 'o', ұ: 'u', ү: 'u', һ: 'h', і: 'i',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
38
|
+
// Small helpers
|
|
39
|
+
|
|
40
|
+
export function sha256Hex(data) {
|
|
41
|
+
return createHash('sha256').update(data).digest('hex');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const toJson = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
|
45
|
+
const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
46
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
47
|
+
const quote = (value) => JSON.stringify(value);
|
|
48
|
+
|
|
49
|
+
// Single pass: substituted values are never scanned again, so a team named "{{team_slug}}" stays literal.
|
|
50
|
+
// Unknown placeholders are left as they are (verifyBundle rejects templates that contain any).
|
|
51
|
+
function fill(text, values) {
|
|
52
|
+
return text.replace(PLACEHOLDER_RE, (match, key) => (key in values ? values[key] : match));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sortedObject(record) {
|
|
56
|
+
const out = {};
|
|
57
|
+
for (const key of Object.keys(record).sort(compare)) out[key] = record[key];
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
62
|
+
// Paths
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Returns why `path` is not an acceptable install target, or null when it is.
|
|
66
|
+
*
|
|
67
|
+
* Paths are plain ASCII so that letter case is the only way two spellings can name one file. The protected paths are
|
|
68
|
+
* compared ignoring ASCII case, because on a case-insensitive file system (macOS, Windows) `.claude/Settings.json`
|
|
69
|
+
* is `.claude/settings.json`.
|
|
70
|
+
*/
|
|
71
|
+
export function pathProblem(path) {
|
|
72
|
+
if (typeof path !== 'string' || path === '') return 'must be a non-empty string';
|
|
73
|
+
if (path.startsWith('/') || /^[A-Za-z]:/.test(path)) return 'must be relative';
|
|
74
|
+
if (path.includes('\\')) return 'must not contain a backslash';
|
|
75
|
+
if (path.includes('..')) return 'must not contain ".."';
|
|
76
|
+
if (!path.startsWith('.claude/')) return 'must start with ".claude/"';
|
|
77
|
+
if (!PATH_RE.test(path)) return 'must use only ASCII letters, digits, ".", "_", "-" and "/"';
|
|
78
|
+
if (path.split('/').some((segment) => segment === '' || segment === '.')) return 'must not contain empty or "." segments';
|
|
79
|
+
const folded = path.toLowerCase();
|
|
80
|
+
if (folded.startsWith(RESERVED_PREFIX) || `${folded}/` === RESERVED_PREFIX) {
|
|
81
|
+
return `must not be inside ${RESERVED_PREFIX} (owned by the agentmash CLI), in any letter case`;
|
|
82
|
+
}
|
|
83
|
+
if (folded === SETTINGS_PATH) return 'must not be the settings file, in any letter case (it is merged, never overwritten)';
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function assertSafePath(path, what = 'path') {
|
|
88
|
+
const problem = pathProblem(path);
|
|
89
|
+
if (problem) throw new Error(`${what} ${quote(path)} ${problem}`);
|
|
90
|
+
return path;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
94
|
+
// Bundle
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Checks the bundle's shape, every sha256 and every path. Returns the bundle; throws on the first problem.
|
|
98
|
+
* The agents array order is the canonical order, and the conductor must come first.
|
|
99
|
+
*/
|
|
100
|
+
export function verifyBundle(bundle) {
|
|
101
|
+
const fail = (message) => {
|
|
102
|
+
throw new Error(`invalid bundle: ${message}`);
|
|
103
|
+
};
|
|
104
|
+
const requireString = (value, where) => {
|
|
105
|
+
if (typeof value !== 'string' || value === '') fail(`${where} must be a non-empty string`);
|
|
106
|
+
};
|
|
107
|
+
const seenPaths = new Set();
|
|
108
|
+
const claimPath = (path, where) => {
|
|
109
|
+
const problem = pathProblem(path);
|
|
110
|
+
if (problem) fail(`${where} ${quote(path)} ${problem}`);
|
|
111
|
+
if (seenPaths.has(path)) fail(`${where} ${quote(path)} is used twice`);
|
|
112
|
+
seenPaths.add(path);
|
|
113
|
+
};
|
|
114
|
+
const verifyFile = (file, where) => {
|
|
115
|
+
if (!isObject(file)) fail(`${where} must be an object`);
|
|
116
|
+
claimPath(file.path, `${where}.path`);
|
|
117
|
+
if (typeof file.content !== 'string') fail(`${where}.content must be a string`);
|
|
118
|
+
if (typeof file.sha256 !== 'string' || !SHA256_RE.test(file.sha256)) fail(`${where}.sha256 must be 64 hex chars`);
|
|
119
|
+
if (sha256Hex(file.content) !== file.sha256) fail(`${where} (${file.path}): sha256 does not match the content`);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (!isObject(bundle)) fail('not a JSON object');
|
|
123
|
+
if (bundle.schema !== BUNDLE_SCHEMA) fail(`schema ${quote(bundle.schema)} is not supported (expected ${BUNDLE_SCHEMA})`);
|
|
124
|
+
|
|
125
|
+
const { meta, agents, shared, templates } = bundle;
|
|
126
|
+
if (!isObject(meta)) fail('meta must be an object');
|
|
127
|
+
for (const key of ['name', 'version', 'url', 'conductor', 'addCommand', 'manifestPath']) {
|
|
128
|
+
requireString(meta[key], `meta.${key}`);
|
|
129
|
+
}
|
|
130
|
+
if (!meta.addCommand.includes('<team>')) fail('meta.addCommand must contain <team>');
|
|
131
|
+
claimPath(meta.manifestPath, 'meta.manifestPath');
|
|
132
|
+
|
|
133
|
+
const { hook } = meta;
|
|
134
|
+
if (!isObject(hook)) fail('meta.hook must be an object');
|
|
135
|
+
for (const key of ['event', 'matcher', 'command', 'marker']) requireString(hook[key], `meta.hook.${key}`);
|
|
136
|
+
if (!Number.isInteger(hook.timeout) || hook.timeout <= 0) fail('meta.hook.timeout must be a positive integer');
|
|
137
|
+
// Without the marker in the command we could never recognise our own hook and would add it on every run.
|
|
138
|
+
if (!hook.command.includes(hook.marker)) fail('meta.hook.command must contain meta.hook.marker');
|
|
139
|
+
|
|
140
|
+
if (!Array.isArray(agents) || agents.length === 0) fail('agents must be a non-empty array');
|
|
141
|
+
const names = new Set();
|
|
142
|
+
const shorts = new Set();
|
|
143
|
+
agents.forEach((agent, i) => {
|
|
144
|
+
const where = `agents[${i}]`;
|
|
145
|
+
if (!isObject(agent)) fail(`${where} must be an object`);
|
|
146
|
+
if (typeof agent.name !== 'string' || !AGENT_NAME_RE.test(agent.name)) fail(`${where}.name ${quote(agent.name)} is not a valid agent name`);
|
|
147
|
+
if (agent.short !== agent.name.split('-')[0]) fail(`${where}.short must be the part of the name before the first "-"`);
|
|
148
|
+
if (names.has(agent.name) || shorts.has(agent.short)) fail(`${where}: agent ${agent.name} is not unique`);
|
|
149
|
+
names.add(agent.name);
|
|
150
|
+
shorts.add(agent.short);
|
|
151
|
+
requireString(agent.title, `${where}.title`);
|
|
152
|
+
requireString(agent.group, `${where}.group`);
|
|
153
|
+
requireString(agent.role, `${where}.role`);
|
|
154
|
+
// The role lands in a markdown table cell, so it must stay one short line without pipes.
|
|
155
|
+
if (Array.from(agent.role).length > ROLE_MAX || /[\n\r|]/.test(agent.role)) {
|
|
156
|
+
fail(`${where}.role must be one line of at most ${ROLE_MAX} characters without "|"`);
|
|
157
|
+
}
|
|
158
|
+
verifyFile(agent, where);
|
|
159
|
+
});
|
|
160
|
+
if (agents[0].name !== meta.conductor) fail(`meta.conductor ${quote(meta.conductor)} must be the first agent`);
|
|
161
|
+
|
|
162
|
+
if (!Array.isArray(shared)) fail('shared must be an array');
|
|
163
|
+
shared.forEach((file, i) => verifyFile(file, `shared[${i}]`));
|
|
164
|
+
|
|
165
|
+
const teamSkill = isObject(templates) ? templates.teamSkill : undefined;
|
|
166
|
+
if (!isObject(teamSkill)) fail('templates.teamSkill must be an object');
|
|
167
|
+
requireString(teamSkill.pathPattern, 'templates.teamSkill.pathPattern');
|
|
168
|
+
if (typeof teamSkill.content !== 'string') fail('templates.teamSkill.content must be a string');
|
|
169
|
+
if (!teamSkill.pathPattern.includes('{{team_slug}}')) fail('templates.teamSkill.pathPattern must contain {{team_slug}}');
|
|
170
|
+
for (const [, key] of teamSkill.pathPattern.matchAll(PLACEHOLDER_RE)) {
|
|
171
|
+
if (key !== 'team_slug') fail(`templates.teamSkill.pathPattern may only use {{team_slug}}, found {{${key}}}`);
|
|
172
|
+
}
|
|
173
|
+
const samplePath = fill(teamSkill.pathPattern, { team_slug: 'sample' });
|
|
174
|
+
const problem = pathProblem(samplePath);
|
|
175
|
+
if (problem) fail(`templates.teamSkill.pathPattern renders to ${quote(samplePath)}, which ${problem}`);
|
|
176
|
+
for (const [, key] of teamSkill.content.matchAll(PLACEHOLDER_RE)) {
|
|
177
|
+
if (!PLACEHOLDERS.has(key)) fail(`templates.teamSkill.content uses unknown placeholder {{${key}}}`);
|
|
178
|
+
}
|
|
179
|
+
return bundle;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
183
|
+
// Team names, slugs and members
|
|
184
|
+
|
|
185
|
+
/** Trims and validates a display name such as "agentmash crew". Length is counted in Unicode code points. */
|
|
186
|
+
export function validateTeamName(name) {
|
|
187
|
+
if (typeof name !== 'string') throw new Error('team name must be a string');
|
|
188
|
+
const trimmed = name.trim();
|
|
189
|
+
const length = Array.from(trimmed).length;
|
|
190
|
+
if (length === 0) throw new Error('team name is empty');
|
|
191
|
+
if (length > NAME_MAX) throw new Error(`team name is ${length} characters long; the limit is ${NAME_MAX}`);
|
|
192
|
+
if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(trimmed)) {
|
|
193
|
+
throw new Error('team name must not contain line breaks or control characters');
|
|
194
|
+
}
|
|
195
|
+
// The name is substituted into a double-quoted YAML string in the team skill's frontmatter, and into shell commands
|
|
196
|
+
// inside double quotes (the team skill's add command, the printed commit command), where $, ` and ! still expand.
|
|
197
|
+
if (/["\\$`!]/.test(trimmed)) {
|
|
198
|
+
throw new Error('team name must not contain double quotes, backslashes, "$", "`" or "!" (it goes into YAML and shell commands)');
|
|
199
|
+
}
|
|
200
|
+
return trimmed;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function validateSlug(slug) {
|
|
204
|
+
if (typeof slug !== 'string' || !SLUG_RE.test(slug)) {
|
|
205
|
+
throw new Error(`invalid slug ${quote(slug)}: use 1-40 characters from a-z, 0-9 and "-", starting with a letter or digit`);
|
|
206
|
+
}
|
|
207
|
+
return slug;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** "Команда 123" -> "komanda-123". Throws when nothing usable is left (e.g. a name written only in CJK). */
|
|
211
|
+
export function slugify(name) {
|
|
212
|
+
const latin = Array.from(String(name).toLowerCase(), (char) => TRANSLIT[char] ?? char).join('');
|
|
213
|
+
const slug = latin
|
|
214
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
215
|
+
.replace(/^-+|-+$/g, '')
|
|
216
|
+
.slice(0, 40)
|
|
217
|
+
.replace(/-+$/, '');
|
|
218
|
+
if (!SLUG_RE.test(slug)) throw new Error(`cannot derive a slug from ${quote(name)}; pass one with --slug, e.g. --slug my-team`);
|
|
219
|
+
return slug;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Filters `names` to known agents, deduplicated, in the bundle's canonical order. */
|
|
223
|
+
function canonical(bundle, names) {
|
|
224
|
+
const wanted = new Set(names);
|
|
225
|
+
return bundle.agents.map((agent) => agent.name).filter((name) => wanted.has(name));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Resolves CLI tokens (short or full names, any case, or "all") to full agent names in canonical order. */
|
|
229
|
+
function lookupAgents(bundle, tokens) {
|
|
230
|
+
const index = new Map();
|
|
231
|
+
for (const agent of bundle.agents) {
|
|
232
|
+
index.set(agent.name, agent.name);
|
|
233
|
+
index.set(agent.short, agent.name);
|
|
234
|
+
}
|
|
235
|
+
const picked = [];
|
|
236
|
+
const unknown = [];
|
|
237
|
+
for (const raw of tokens) {
|
|
238
|
+
const token = String(raw).trim().toLowerCase();
|
|
239
|
+
if (token === '') continue;
|
|
240
|
+
if (token === 'all') picked.push(...index.values());
|
|
241
|
+
else if (index.has(token)) picked.push(index.get(token));
|
|
242
|
+
else unknown.push(String(raw).trim());
|
|
243
|
+
}
|
|
244
|
+
if (unknown.length > 0) {
|
|
245
|
+
const valid = bundle.agents.map((agent) => agent.short).join(', ');
|
|
246
|
+
throw new Error(`unknown agent${unknown.length > 1 ? 's' : ''} ${unknown.map(quote).join(', ')}; valid names: ${valid} (or all)`);
|
|
247
|
+
}
|
|
248
|
+
return canonical(bundle, picked);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Like lookupAgents, but the conductor is always included (and, by canonical order, always first). */
|
|
252
|
+
export function resolveMembers(bundle, tokens) {
|
|
253
|
+
return canonical(bundle, [bundle.meta.conductor, ...lookupAgents(bundle, tokens)]);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
257
|
+
// Rendering
|
|
258
|
+
|
|
259
|
+
export function teamSkillPath(bundle, slug) {
|
|
260
|
+
return assertSafePath(fill(bundle.templates.teamSkill.pathPattern, { team_slug: validateSlug(slug) }), 'team skill path');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Renders the per-team skill. Same bundle + same team -> byte-identical output. */
|
|
264
|
+
export function renderTeamSkill(bundle, team) {
|
|
265
|
+
const name = validateTeamName(team.name);
|
|
266
|
+
const slug = validateSlug(team.slug);
|
|
267
|
+
const byName = new Map(bundle.agents.map((agent) => [agent.name, agent]));
|
|
268
|
+
const unknown = team.members.filter((member) => !byName.has(member));
|
|
269
|
+
if (unknown.length > 0) throw new Error(`team ${quote(name)} lists unknown agents: ${unknown.join(', ')}`);
|
|
270
|
+
|
|
271
|
+
const members = canonical(bundle, [bundle.meta.conductor, ...team.members]);
|
|
272
|
+
const missing = bundle.agents.map((agent) => agent.name).filter((member) => !members.includes(member));
|
|
273
|
+
const rows = members.map((member) => {
|
|
274
|
+
const agent = byName.get(member);
|
|
275
|
+
return `| ${agent.title} | \`${agent.name}\` | ${agent.role} |`;
|
|
276
|
+
});
|
|
277
|
+
const values = {
|
|
278
|
+
team_name: name,
|
|
279
|
+
team_slug: slug,
|
|
280
|
+
skill_name: `team-${slug}`,
|
|
281
|
+
members_inline: members.join(', '),
|
|
282
|
+
roster_table: ['| Agent | Name | Role |', '| --- | --- | --- |', ...rows].join('\n'),
|
|
283
|
+
missing_inline: missing.length > 0 ? missing.join(', ') : 'none',
|
|
284
|
+
add_command: bundle.meta.addCommand.split('<team>').join(`"${name}"`),
|
|
285
|
+
};
|
|
286
|
+
return { path: teamSkillPath(bundle, slug), content: fill(bundle.templates.teamSkill.content, values) };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
290
|
+
// settings.json
|
|
291
|
+
|
|
292
|
+
function parseSettings(text) {
|
|
293
|
+
let settings;
|
|
294
|
+
try {
|
|
295
|
+
settings = JSON.parse(text);
|
|
296
|
+
} catch (error) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
`${SETTINGS_PATH} is not valid JSON (${error.message}); comments and trailing commas are not supported, fix the file and rerun`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
if (!isObject(settings)) throw new Error(`${SETTINGS_PATH} must contain a JSON object`);
|
|
302
|
+
if (settings.hooks !== undefined && !isObject(settings.hooks)) throw new Error(`${SETTINGS_PATH}: "hooks" must be an object`);
|
|
303
|
+
return settings;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function hookGroups(settings, event) {
|
|
307
|
+
const groups = settings.hooks?.[event];
|
|
308
|
+
if (groups !== undefined && !Array.isArray(groups)) throw new Error(`${SETTINGS_PATH}: "hooks.${event}" must be an array`);
|
|
309
|
+
return groups;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const hasMarker = (value, marker) => JSON.stringify(value).includes(marker);
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Adds our hook group once. `text` is the current settings.json or null when it does not exist.
|
|
316
|
+
* Returns the new text (the original text, untouched, when nothing changed).
|
|
317
|
+
*/
|
|
318
|
+
export function mergeSettings(text, hook) {
|
|
319
|
+
const group = { matcher: hook.matcher, hooks: [{ type: 'command', command: hook.command, timeout: hook.timeout }] };
|
|
320
|
+
if (text === null || text === undefined) {
|
|
321
|
+
return { text: toJson({ hooks: { [hook.event]: [group] } }), changed: true, created: true };
|
|
322
|
+
}
|
|
323
|
+
const settings = parseSettings(text);
|
|
324
|
+
const groups = hookGroups(settings, hook.event);
|
|
325
|
+
if (groups?.some((existing) => hasMarker(existing, hook.marker))) return { text, changed: false, created: false };
|
|
326
|
+
|
|
327
|
+
settings.hooks ??= {};
|
|
328
|
+
settings.hooks[hook.event] = [...(groups ?? []), group];
|
|
329
|
+
return { text: toJson(settings), changed: true, created: false };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Removes our hook: every entry of a group's "hooks" array whose JSON mentions `marker`. Someone may have added their
|
|
334
|
+
* own hook to that group, so only the entry goes; the group goes too when nothing but `matcher` and an empty "hooks"
|
|
335
|
+
* array is left. `keptGroups` counts groups that lost our entry but stay because they hold something else. The event
|
|
336
|
+
* and "hooks" keys are dropped if they end up empty. With deleteIfEmpty (we created the file), an empty result means
|
|
337
|
+
* "delete the file" and comes back as text null.
|
|
338
|
+
*/
|
|
339
|
+
export function unmergeSettings(text, marker, { event = 'PreToolUse', deleteIfEmpty = false } = {}) {
|
|
340
|
+
const unchanged = { text: text ?? null, changed: false, keptGroups: 0 };
|
|
341
|
+
if (text === null || text === undefined) return unchanged;
|
|
342
|
+
const settings = parseSettings(text);
|
|
343
|
+
const groups = hookGroups(settings, event);
|
|
344
|
+
if (groups === undefined) return unchanged;
|
|
345
|
+
|
|
346
|
+
const kept = [];
|
|
347
|
+
let changed = false;
|
|
348
|
+
let keptGroups = 0;
|
|
349
|
+
for (const group of groups) {
|
|
350
|
+
const entries = isObject(group) && Array.isArray(group.hooks) ? group.hooks : [];
|
|
351
|
+
const rest = entries.filter((entry) => !hasMarker(entry, marker));
|
|
352
|
+
if (rest.length === entries.length) {
|
|
353
|
+
kept.push(group); // none of our entries in it
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
changed = true;
|
|
357
|
+
if (rest.length === 0 && Object.keys(group).every((key) => key === 'matcher' || key === 'hooks')) continue;
|
|
358
|
+
kept.push({ ...group, hooks: rest });
|
|
359
|
+
keptGroups += 1;
|
|
360
|
+
}
|
|
361
|
+
if (!changed) return unchanged;
|
|
362
|
+
|
|
363
|
+
if (kept.length > 0) settings.hooks[event] = kept;
|
|
364
|
+
else delete settings.hooks[event];
|
|
365
|
+
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
366
|
+
if (deleteIfEmpty && Object.keys(settings).length === 0) return { text: null, changed: true, keptGroups };
|
|
367
|
+
return { text: toJson(settings), changed: true, keptGroups };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
371
|
+
// Manifest (.claude/agentmash-crew/teams.json)
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The spelling the bundle uses for `path` when the two are equal ignoring ASCII case, or null when the bundle cannot
|
|
375
|
+
* produce such a path. Team skill paths are matched through the path pattern, for any slug.
|
|
376
|
+
*/
|
|
377
|
+
function bundleSpelling(bundle, path) {
|
|
378
|
+
const folded = path.toLowerCase();
|
|
379
|
+
const fixed = [bundle.meta.manifestPath, ...bundle.shared.map((file) => file.path), ...bundle.agents.map((agent) => agent.path)];
|
|
380
|
+
const known = fixed.find((candidate) => candidate.toLowerCase() === folded);
|
|
381
|
+
if (known) return known;
|
|
382
|
+
const { pathPattern } = bundle.templates.teamSkill;
|
|
383
|
+
const pattern = pathPattern
|
|
384
|
+
.split('{{team_slug}}')
|
|
385
|
+
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
386
|
+
.join('([a-z0-9][a-z0-9-]{0,39})');
|
|
387
|
+
const match = new RegExp(`^${pattern}$`, 'i').exec(path);
|
|
388
|
+
if (!match) return null;
|
|
389
|
+
const spelled = fill(pathPattern, { team_slug: match[1].toLowerCase() });
|
|
390
|
+
return spelled.toLowerCase() === folded ? spelled : null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Parses and validates a manifest. It is committed to the repository, so it is untrusted: every `files` key gets the
|
|
395
|
+
* bundle path checks, and a key that names a file another way (another letter case of a path the bundle writes, of the
|
|
396
|
+
* manifest itself or of another key) is refused, so a tampered manifest cannot aim a deletion at a file we must keep.
|
|
397
|
+
*/
|
|
398
|
+
export function parseManifest(text, where, bundle) {
|
|
399
|
+
const fail = (message) => {
|
|
400
|
+
throw new Error(`${where}: ${message}; fix the file and rerun`);
|
|
401
|
+
};
|
|
402
|
+
let manifest;
|
|
403
|
+
try {
|
|
404
|
+
manifest = JSON.parse(text);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
fail(`not valid JSON (${error.message})`);
|
|
407
|
+
}
|
|
408
|
+
if (!isObject(manifest)) fail('must contain a JSON object');
|
|
409
|
+
if (manifest.schema !== MANIFEST_SCHEMA) fail(`schema ${quote(manifest.schema)} is not supported (expected ${MANIFEST_SCHEMA})`);
|
|
410
|
+
if (!Array.isArray(manifest.teams)) fail('"teams" must be an array');
|
|
411
|
+
if (!isObject(manifest.files)) fail('"files" must be an object');
|
|
412
|
+
|
|
413
|
+
const slugs = new Set();
|
|
414
|
+
const teams = manifest.teams.map((team, i) => {
|
|
415
|
+
if (!isObject(team)) fail(`teams[${i}] must be an object`);
|
|
416
|
+
try {
|
|
417
|
+
validateSlug(team.slug);
|
|
418
|
+
validateTeamName(team.name);
|
|
419
|
+
} catch (error) {
|
|
420
|
+
fail(`teams[${i}]: ${error.message}`);
|
|
421
|
+
}
|
|
422
|
+
if (slugs.has(team.slug)) fail(`slug ${team.slug} is used by two teams`);
|
|
423
|
+
slugs.add(team.slug);
|
|
424
|
+
if (!Array.isArray(team.members) || !team.members.every((m) => typeof m === 'string' && AGENT_NAME_RE.test(m))) {
|
|
425
|
+
fail(`teams[${i}].members must be an array of agent names`);
|
|
426
|
+
}
|
|
427
|
+
return { slug: team.slug, name: team.name.trim(), members: [...team.members] };
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
const files = {};
|
|
431
|
+
const folded = new Map();
|
|
432
|
+
for (const [path, sha] of Object.entries(manifest.files)) {
|
|
433
|
+
const problem = pathProblem(path);
|
|
434
|
+
if (problem) fail(`files: ${quote(path)} ${problem}`);
|
|
435
|
+
const spelled = bundleSpelling(bundle, path);
|
|
436
|
+
if (spelled === bundle.meta.manifestPath) fail(`files: ${quote(path)} is the manifest itself`);
|
|
437
|
+
if (spelled !== null && spelled !== path) fail(`files: ${quote(path)} differs only in letter case from ${quote(spelled)}`);
|
|
438
|
+
const twin = folded.get(path.toLowerCase());
|
|
439
|
+
if (twin !== undefined) fail(`files: ${quote(path)} and ${quote(twin)} differ only in letter case`);
|
|
440
|
+
folded.set(path.toLowerCase(), path);
|
|
441
|
+
if (typeof sha !== 'string' || !SHA256_RE.test(sha)) fail(`files: ${quote(path)} needs a 64-char sha256`);
|
|
442
|
+
files[path] = sha;
|
|
443
|
+
}
|
|
444
|
+
return { schema: MANIFEST_SCHEMA, source: manifest.source, createdSettings: manifest.createdSettings === true, teams, files };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
448
|
+
// Planning
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Where `add` lands: an existing team matched by exact name or slug, or a new one. The name is matched first, so a team
|
|
452
|
+
* created with --slug (a name the slug rules cannot turn into a slug, such as "日本") can be extended by name alone.
|
|
453
|
+
*/
|
|
454
|
+
function addTarget(teams, op) {
|
|
455
|
+
const name = validateTeamName(op.name);
|
|
456
|
+
const byName = teams.find((team) => team.name === name);
|
|
457
|
+
if (byName && op.slug === undefined) return { name, slug: byName.slug, existing: byName };
|
|
458
|
+
const slug = op.slug === undefined ? slugify(name) : validateSlug(op.slug);
|
|
459
|
+
if (byName && byName.slug !== slug) {
|
|
460
|
+
throw new Error(`team ${quote(name)} already exists with slug ${byName.slug}; drop --slug or pass --slug ${byName.slug}`);
|
|
461
|
+
}
|
|
462
|
+
const existing = byName ?? teams.find((team) => team.slug === slug) ?? null;
|
|
463
|
+
return { name, slug: existing ? existing.slug : slug, existing };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Finds a team by exact name, exact slug, or the slug its name would produce. */
|
|
467
|
+
function findTeam(teams, ref) {
|
|
468
|
+
const token = String(ref ?? '').trim();
|
|
469
|
+
let derived = null;
|
|
470
|
+
try {
|
|
471
|
+
derived = slugify(token);
|
|
472
|
+
} catch {
|
|
473
|
+
// a reference with no usable characters can still match an exact name
|
|
474
|
+
}
|
|
475
|
+
return teams.find((team) => team.name === token || team.slug === token) ?? teams.find((team) => team.slug === derived) ?? null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Every repository path planChange may need a disk hash for. The installer snapshots exactly these
|
|
480
|
+
* (plus settings.json) before planning.
|
|
481
|
+
*/
|
|
482
|
+
export function statePaths(bundle, manifest, op) {
|
|
483
|
+
const paths = new Set([bundle.meta.manifestPath, ...bundle.shared.map((f) => f.path), ...bundle.agents.map((a) => a.path)]);
|
|
484
|
+
for (const path of Object.keys(manifest?.files ?? {})) paths.add(path);
|
|
485
|
+
for (const team of manifest?.teams ?? []) paths.add(teamSkillPath(bundle, team.slug));
|
|
486
|
+
if (op.type === 'add') paths.add(teamSkillPath(bundle, addTarget(manifest?.teams ?? [], op).slug));
|
|
487
|
+
return [...paths].sort(compare);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Brings teams from an older manifest in line with this bundle: known agents only, conductor first, canonical order. */
|
|
491
|
+
function normalizeTeams(bundle, teams, warnings) {
|
|
492
|
+
const known = new Set(bundle.agents.map((agent) => agent.name));
|
|
493
|
+
return teams.map((team) => {
|
|
494
|
+
const dropped = team.members.filter((member) => !known.has(member));
|
|
495
|
+
if (dropped.length > 0) warnings.push(`team ${quote(team.name)}: dropping ${dropped.join(', ')} (not in this crew version)`);
|
|
496
|
+
return { ...team, members: canonical(bundle, [bundle.meta.conductor, ...team.members]) };
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function applyAdd(bundle, teams, op, warnings) {
|
|
501
|
+
const { conductor } = bundle.meta;
|
|
502
|
+
const target = addTarget(teams, op);
|
|
503
|
+
const requested = resolveMembers(bundle, op.members ?? []);
|
|
504
|
+
if (!target.existing) {
|
|
505
|
+
if (requested.length < 2) {
|
|
506
|
+
throw new Error(`a new team needs at least one agent besides ${conductor}, e.g. --agents pipe,flex,probe`);
|
|
507
|
+
}
|
|
508
|
+
const team = { slug: target.slug, name: target.name, members: requested };
|
|
509
|
+
return { teams: [...teams, team], target: team, targetIsNew: true };
|
|
510
|
+
}
|
|
511
|
+
if (target.existing.name !== target.name) {
|
|
512
|
+
warnings.push(`${quote(target.name)} has the same slug as team ${quote(target.existing.name)}; extending that team`);
|
|
513
|
+
}
|
|
514
|
+
const team = { ...target.existing, members: canonical(bundle, [...target.existing.members, ...requested]) };
|
|
515
|
+
return { teams: teams.map((t) => (t === target.existing ? team : t)), target: team };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function applyRemove(bundle, teams, op, warnings) {
|
|
519
|
+
const { conductor } = bundle.meta;
|
|
520
|
+
if (teams.length === 0) throw new Error('no agentmash teams are installed in this repository');
|
|
521
|
+
const team = findTeam(teams, op.team);
|
|
522
|
+
if (!team) {
|
|
523
|
+
const installed = teams.map((t) => `${quote(t.name)} (${t.slug})`).join(', ');
|
|
524
|
+
throw new Error(`no team ${quote(String(op.team ?? ''))}; installed: ${installed}`);
|
|
525
|
+
}
|
|
526
|
+
if (op.members === undefined) {
|
|
527
|
+
return { teams: teams.filter((t) => t !== team), target: { ...team, members: null } };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const names = lookupAgents(bundle, op.members);
|
|
531
|
+
if (names.length === 0) throw new Error('no agents given to remove');
|
|
532
|
+
if (names.includes(conductor)) {
|
|
533
|
+
throw new Error(`${conductor} is the conductor and cannot be removed from a team; remove the team instead: team remove ${quote(team.name)}`);
|
|
534
|
+
}
|
|
535
|
+
const absent = names.filter((name) => !team.members.includes(name));
|
|
536
|
+
if (absent.length > 0) warnings.push(`not in team ${quote(team.name)}: ${absent.join(', ')}`);
|
|
537
|
+
const members = team.members.filter((member) => !names.includes(member));
|
|
538
|
+
if (members.length < 2) {
|
|
539
|
+
throw new Error(`that would leave only ${conductor} in ${quote(team.name)}; remove the team instead: team remove ${quote(team.name)}`);
|
|
540
|
+
}
|
|
541
|
+
const updated = { ...team, members };
|
|
542
|
+
return { teams: teams.map((t) => (t === team ? updated : t)), target: updated };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Every file the teams need, path -> content. */
|
|
546
|
+
function desiredFiles(bundle, teams) {
|
|
547
|
+
const files = new Map();
|
|
548
|
+
if (teams.length === 0) return files;
|
|
549
|
+
for (const file of bundle.shared) files.set(file.path, file.content);
|
|
550
|
+
const used = new Set(teams.flatMap((team) => team.members));
|
|
551
|
+
for (const agent of bundle.agents) if (used.has(agent.name)) files.set(agent.path, agent.content);
|
|
552
|
+
for (const team of teams) {
|
|
553
|
+
const skill = renderTeamSkill(bundle, team);
|
|
554
|
+
files.set(skill.path, skill.content);
|
|
555
|
+
}
|
|
556
|
+
return files;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Compares what we want on disk with what is there and what we wrote last time (`owned`: path -> sha).
|
|
561
|
+
* Actions: created, updated, overwritten (--force), unchanged, kept (changed locally), deleted, left (changed locally,
|
|
562
|
+
* no longer needed, so no longer tracked either).
|
|
563
|
+
*/
|
|
564
|
+
function reconcile(desired, owned, onDisk, force, warnings) {
|
|
565
|
+
const diskSha = (path) => {
|
|
566
|
+
if (!(path in onDisk)) throw new Error(`internal error: the repository snapshot has no entry for ${path}`);
|
|
567
|
+
return onDisk[path];
|
|
568
|
+
};
|
|
569
|
+
const writes = [];
|
|
570
|
+
const deletes = [];
|
|
571
|
+
const changes = [];
|
|
572
|
+
const tracked = {};
|
|
573
|
+
const foreign = [];
|
|
574
|
+
|
|
575
|
+
for (const path of [...desired.keys()].sort(compare)) {
|
|
576
|
+
const content = desired.get(path);
|
|
577
|
+
const sha = sha256Hex(content);
|
|
578
|
+
const disk = diskSha(path);
|
|
579
|
+
const known = owned[path]; // undefined: we never wrote this file
|
|
580
|
+
let action;
|
|
581
|
+
if (disk === sha) action = 'unchanged';
|
|
582
|
+
else if (disk === null) action = 'created';
|
|
583
|
+
else if (known === disk) action = 'updated';
|
|
584
|
+
else if (force) action = 'overwritten';
|
|
585
|
+
else if (known === undefined) action = 'foreign';
|
|
586
|
+
else action = 'kept';
|
|
587
|
+
|
|
588
|
+
if (action === 'foreign') {
|
|
589
|
+
foreign.push(path);
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
if (action === 'kept') {
|
|
593
|
+
tracked[path] = known; // keep the old sha so the file still counts as changed locally next time
|
|
594
|
+
if (sha !== known) warnings.push(`kept ${path}: it was changed locally, so it did not get the new version; rerun with --force to overwrite`);
|
|
595
|
+
} else {
|
|
596
|
+
tracked[path] = sha;
|
|
597
|
+
}
|
|
598
|
+
if (action === 'created' || action === 'updated' || action === 'overwritten') writes.push({ path, content, sha256: sha });
|
|
599
|
+
changes.push({ path, action });
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
for (const path of Object.keys(owned).filter((p) => !desired.has(p)).sort(compare)) {
|
|
603
|
+
const disk = diskSha(path);
|
|
604
|
+
if (disk === null) continue; // already gone
|
|
605
|
+
if (disk === owned[path]) {
|
|
606
|
+
deletes.push(path);
|
|
607
|
+
changes.push({ path, action: 'deleted' });
|
|
608
|
+
} else {
|
|
609
|
+
changes.push({ path, action: 'left' });
|
|
610
|
+
warnings.push(`left ${path}: no longer needed, but it was changed locally; delete it yourself if you do not need it`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (foreign.length > 0) {
|
|
615
|
+
throw new Error(
|
|
616
|
+
[
|
|
617
|
+
'refusing to overwrite files that agentmash-crew did not write:',
|
|
618
|
+
...foreign.map((path) => ` ${path}`),
|
|
619
|
+
'move them away, or rerun with --force to overwrite them',
|
|
620
|
+
].join('\n'),
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
changes.sort((a, b) => compare(a.path, b.path));
|
|
624
|
+
return { writes, deletes, changes, tracked };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Plans one operation against a snapshot of the repository.
|
|
629
|
+
*
|
|
630
|
+
* state: { manifest: parsed teams.json | null, settingsText: string | null, onDisk: { path: sha256 | null } } covering
|
|
631
|
+
* every path from statePaths().
|
|
632
|
+
* op: { type: 'add', name, slug?, members: string[], force? }
|
|
633
|
+
* | { type: 'remove', team, members?: string[], force? }
|
|
634
|
+
* | { type: 'update', force? }
|
|
635
|
+
*
|
|
636
|
+
* Throws (before anything is written) on invalid input, foreign files and unparseable settings.
|
|
637
|
+
*/
|
|
638
|
+
export function planChange(bundle, state, op) {
|
|
639
|
+
const { meta } = bundle;
|
|
640
|
+
const manifest = state.manifest ?? null;
|
|
641
|
+
const warnings = [];
|
|
642
|
+
// add and remove also rewrite every installed file to this bundle's version, which may be older than the one a
|
|
643
|
+
// teammate installed. update exists for exactly that, so it gets no warning.
|
|
644
|
+
const installedVersion = manifest?.source?.version;
|
|
645
|
+
if (op.type !== 'update' && typeof installedVersion === 'string' && installedVersion !== meta.version) {
|
|
646
|
+
warnings.push(
|
|
647
|
+
`the teams here were installed by agentmash-crew ${installedVersion}; this is ${meta.version}, so the files it writes are the ${meta.version} versions`,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
const current = normalizeTeams(bundle, manifest?.teams ?? [], warnings);
|
|
651
|
+
|
|
652
|
+
let result;
|
|
653
|
+
if (op.type === 'add') result = applyAdd(bundle, current, op, warnings);
|
|
654
|
+
else if (op.type === 'remove') result = applyRemove(bundle, current, op, warnings);
|
|
655
|
+
else if (op.type === 'update') {
|
|
656
|
+
if (!manifest) throw new Error('no agentmash teams are installed in this repository; nothing to update');
|
|
657
|
+
result = { teams: current, target: null };
|
|
658
|
+
} else throw new Error(`unknown operation ${quote(op.type)}`);
|
|
659
|
+
|
|
660
|
+
const teams = [...result.teams].sort((a, b) => compare(a.slug, b.slug));
|
|
661
|
+
const { writes, deletes, changes, tracked } = reconcile(
|
|
662
|
+
desiredFiles(bundle, teams),
|
|
663
|
+
manifest?.files ?? {},
|
|
664
|
+
state.onDisk,
|
|
665
|
+
Boolean(op.force),
|
|
666
|
+
warnings,
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
const settingsText = state.settingsText ?? null;
|
|
670
|
+
let settings;
|
|
671
|
+
let createdSettings = false;
|
|
672
|
+
if (teams.length > 0) {
|
|
673
|
+
const merged = mergeSettings(settingsText, meta.hook);
|
|
674
|
+
createdSettings = Boolean(manifest?.createdSettings) || merged.created;
|
|
675
|
+
const action = merged.created ? 'created' : merged.changed ? 'updated' : 'unchanged';
|
|
676
|
+
settings = { path: SETTINGS_PATH, text: merged.text, changed: merged.changed, action };
|
|
677
|
+
} else {
|
|
678
|
+
const removed = unmergeSettings(settingsText, meta.hook.marker, {
|
|
679
|
+
event: meta.hook.event,
|
|
680
|
+
deleteIfEmpty: Boolean(manifest?.createdSettings),
|
|
681
|
+
});
|
|
682
|
+
const action = !removed.changed ? 'unchanged' : removed.text === null ? 'deleted' : 'updated';
|
|
683
|
+
settings = { path: SETTINGS_PATH, text: removed.text, changed: removed.changed, action };
|
|
684
|
+
if (removed.keptGroups > 0) {
|
|
685
|
+
warnings.push(
|
|
686
|
+
`${SETTINGS_PATH}: removed only the crew guard entry from its ${meta.hook.event} hook group, which also holds hooks or keys the installer did not add; the rest stays`,
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
let nextManifest = null;
|
|
692
|
+
let manifestText = null;
|
|
693
|
+
if (teams.length > 0) {
|
|
694
|
+
nextManifest = {
|
|
695
|
+
schema: MANIFEST_SCHEMA,
|
|
696
|
+
source: { name: meta.name, version: meta.version, url: meta.url },
|
|
697
|
+
createdSettings,
|
|
698
|
+
teams: teams.map((team) => ({ slug: team.slug, name: team.name, members: team.members })),
|
|
699
|
+
files: sortedObject(tracked),
|
|
700
|
+
};
|
|
701
|
+
manifestText = toJson(nextManifest);
|
|
702
|
+
}
|
|
703
|
+
const manifestOnDisk = state.onDisk[meta.manifestPath] ?? null;
|
|
704
|
+
let manifestAction = 'unchanged';
|
|
705
|
+
if (manifestText === null) {
|
|
706
|
+
if (manifestOnDisk !== null) manifestAction = 'deleted';
|
|
707
|
+
} else if (manifestOnDisk === null) manifestAction = 'created';
|
|
708
|
+
else if (manifestOnDisk !== sha256Hex(manifestText)) manifestAction = 'updated';
|
|
709
|
+
|
|
710
|
+
return {
|
|
711
|
+
teams,
|
|
712
|
+
target: result.target,
|
|
713
|
+
targetIsNew: Boolean(result.targetIsNew),
|
|
714
|
+
writes,
|
|
715
|
+
deletes,
|
|
716
|
+
changes,
|
|
717
|
+
settings,
|
|
718
|
+
manifestPath: meta.manifestPath,
|
|
719
|
+
manifest: nextManifest,
|
|
720
|
+
manifestText,
|
|
721
|
+
manifestAction,
|
|
722
|
+
changed: writes.length > 0 || deletes.length > 0 || settings.changed || manifestAction !== 'unchanged',
|
|
723
|
+
warnings,
|
|
724
|
+
};
|
|
725
|
+
}
|