@1aboveio/skills 0.12.1 → 0.13.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 +36 -6
- package/package.json +1 -1
- package/runtime/skills/distribution/generated/recipes.json +13 -13
- package/runtime/skills/distribution/scripts/bundles.mjs +530 -70
- package/runtime/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +576 -0
- package/runtime/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +166 -0
- package/skills/engineering/engineering-runtime/coherence/workflow.json +14 -14
- package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
- package/skills/engineering/resolve-issues/generated/workflow-repair-policy.json +11 -11
- package/skills/engineering/resolve-issues/scripts/run-state.mjs +1 -1
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
|
-
import { readFileSync, statSync } from 'node:fs';
|
|
4
|
+
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
5
6
|
import { emitKeypressEvents } from 'node:readline';
|
|
6
7
|
import { dirname, join, resolve } from 'node:path';
|
|
7
8
|
import { fileURLToPath } from 'node:url';
|
|
8
9
|
|
|
9
10
|
import { isMainModule } from '../../engineering/engineering-runtime/scripts/main-module.mjs';
|
|
11
|
+
// The digest authority for the update skip is the ONE existing predicate pair: contentDigest with
|
|
12
|
+
// the published-content scope for first-party members, exact-tree for external ones — the same
|
|
13
|
+
// semantics release coherence validates installed trees with. The coordinator never re-derives or
|
|
14
|
+
// widens that scope; it only asks "does what is installed already digest to what this run would
|
|
15
|
+
// install?" and skips the native add when the answer is yes.
|
|
16
|
+
import { contentDigest, firstPartySource } from '../../engineering/engineering-runtime/scripts/workflow-coherence.mjs';
|
|
10
17
|
|
|
11
18
|
// The coordinator is one command rendered into two transports: the private checkout and the
|
|
12
19
|
// unpacked @1aboveio/skills package. Both defaults below are anchored on this file's own location,
|
|
@@ -29,6 +36,162 @@ const COMMAND_TRANSPORTS = ['local', 'https'];
|
|
|
29
36
|
export const SUPPORT_GROUPS = new Set(['harness-runtime']);
|
|
30
37
|
const ACTIONS = new Set(['install', 'update']);
|
|
31
38
|
|
|
39
|
+
// Selection memory (ADR 0002/0004 amendment, epic #1124): the remembered user-facing group
|
|
40
|
+
// set lives in XDG state so a no-argument `update` can replay it instead of reopening the picker.
|
|
41
|
+
// The file records only user-facing successes (#1129): it is written when at least one user-facing
|
|
42
|
+
// member's native add succeeded or was already digest-equal unchanged, with acceptedMembers
|
|
43
|
+
// holding exactly those succeeded and unchanged members; failed and declined members are omitted,
|
|
44
|
+
// a selected group left with no recordable member is omitted from the written set rather than
|
|
45
|
+
// remembered with an empty acceptance (a failed run never advances its group ids into remembered
|
|
46
|
+
// groups), and a run where nothing recordable succeeded leaves the prior file byte-for-byte
|
|
47
|
+
// untouched. It is never a source of filesystem inference: missing or corrupt memory is a clear
|
|
48
|
+
// error, not a reason to guess groups from disk.
|
|
49
|
+
export const SELECTION_SCHEMA_VERSION = 1;
|
|
50
|
+
|
|
51
|
+
export function selectionStatePath(env = process.env) {
|
|
52
|
+
const stateHome = env.XDG_STATE_HOME || join(env.HOME || homedir(), '.local', 'state');
|
|
53
|
+
return join(stateHome, '1aboveio-skills', 'selection.json');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function selectionError(path, detail) {
|
|
57
|
+
return new Error(`Remembered selection at ${path} ${detail}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function loadSelectionState(env = process.env) {
|
|
61
|
+
const path = selectionStatePath(env);
|
|
62
|
+
let raw;
|
|
63
|
+
try {
|
|
64
|
+
raw = readFileSync(path, 'utf8');
|
|
65
|
+
} catch {
|
|
66
|
+
throw selectionError(path, 'does not exist. Run install or update with --group <id> or --all once to record a selection');
|
|
67
|
+
}
|
|
68
|
+
let parsed;
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(raw);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
throw selectionError(path, `is not valid JSON: ${error.message}`);
|
|
73
|
+
}
|
|
74
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
75
|
+
throw selectionError(path, 'is invalid: expected an object');
|
|
76
|
+
}
|
|
77
|
+
if (parsed.schemaVersion !== SELECTION_SCHEMA_VERSION) {
|
|
78
|
+
throw selectionError(path, `has unsupported schemaVersion ${JSON.stringify(parsed.schemaVersion)} (expected ${SELECTION_SCHEMA_VERSION})`);
|
|
79
|
+
}
|
|
80
|
+
if (!Array.isArray(parsed.groups)) {
|
|
81
|
+
throw selectionError(path, 'is invalid: groups must be an array');
|
|
82
|
+
}
|
|
83
|
+
for (const group of parsed.groups) {
|
|
84
|
+
const valid = group
|
|
85
|
+
&& typeof group === 'object'
|
|
86
|
+
&& typeof group.id === 'string'
|
|
87
|
+
&& group.id.length > 0
|
|
88
|
+
&& Array.isArray(group.acceptedMembers)
|
|
89
|
+
&& group.acceptedMembers.every((member) => typeof member === 'string');
|
|
90
|
+
if (!valid) {
|
|
91
|
+
throw selectionError(path, 'is invalid: every group needs an id and an acceptedMembers string array');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { path, state: parsed };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function writeSelectionState(state, env = process.env) {
|
|
98
|
+
const path = selectionStatePath(env);
|
|
99
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
100
|
+
writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`);
|
|
101
|
+
return path;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The group ids a remembered selection resolves to against the CURRENT catalog. Remembered ids
|
|
105
|
+
// that are no longer selectable (renamed, removed, or a support group that must never be
|
|
106
|
+
// user-selected) are dropped with a warning; memory never resurrects them and never falls back to
|
|
107
|
+
// filesystem inference. Nothing left is an error, not an empty plan.
|
|
108
|
+
function rememberedGroupIds(state, catalog, stderr) {
|
|
109
|
+
const known = new Set(catalog.map((bundle) => bundle.id));
|
|
110
|
+
const kept = [];
|
|
111
|
+
const dropped = [];
|
|
112
|
+
for (const group of state.groups) {
|
|
113
|
+
if (!known.has(group.id) || SUPPORT_GROUPS.has(group.id)) {
|
|
114
|
+
if (!dropped.includes(group.id)) dropped.push(group.id);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (!kept.includes(group.id)) kept.push(group.id);
|
|
118
|
+
}
|
|
119
|
+
if (dropped.length > 0) {
|
|
120
|
+
stderr.write(`Ignoring unknown remembered bundle(s): ${dropped.join(', ')}\n`);
|
|
121
|
+
}
|
|
122
|
+
if (kept.length === 0) {
|
|
123
|
+
throw new Error('No known bundles remain in the remembered selection. Run update with --group <id> or --all to record a new selection');
|
|
124
|
+
}
|
|
125
|
+
return kept;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The replacement memory for one run: the user-facing groups this run selected that have at least
|
|
129
|
+
// one recordable member, each with acceptedMembers holding the user-facing members that were
|
|
130
|
+
// successfully added or already digest-equal this run. Declined and failed members are omitted;
|
|
131
|
+
// support-only members are never remembered; a group whose user-facing members all failed or were
|
|
132
|
+
// declined is dropped entirely, so the next no-arg update never replays a group this run did not
|
|
133
|
+
// actually deliver. The transport label is observational only — both render targets share this
|
|
134
|
+
// file.
|
|
135
|
+
export function selectionForRun(action, groupIds, { renderTarget, now = new Date(), succeededMembers, omitMembers, ...transport } = {}) {
|
|
136
|
+
const omitted = new Set(omitMembers ?? []);
|
|
137
|
+
const recipes = readRecipes(resolveBundleTransport(transport).recipesPath);
|
|
138
|
+
const groups = new Map(recipes.groups.map((group) => [group.id, group]));
|
|
139
|
+
const succeeded = succeededMembers ? new Set(succeededMembers) : undefined;
|
|
140
|
+
return {
|
|
141
|
+
schemaVersion: SELECTION_SCHEMA_VERSION,
|
|
142
|
+
updatedAt: now.toISOString(),
|
|
143
|
+
transport: { renderTarget },
|
|
144
|
+
groups: groupIds
|
|
145
|
+
.map((groupId) => {
|
|
146
|
+
const group = groups.get(groupId);
|
|
147
|
+
if (!group) throw new Error(`Unknown bundle: ${groupId}`);
|
|
148
|
+
const acceptedMembers = [...new Set(group[action].steps.flatMap((step) => step.members))]
|
|
149
|
+
.filter((member) => !SUPPORT_GROUPS.has(member))
|
|
150
|
+
.filter((member) => !omitted.has(member))
|
|
151
|
+
.filter((member) => !succeeded || succeeded.has(member));
|
|
152
|
+
return { id: groupId, acceptedMembers };
|
|
153
|
+
})
|
|
154
|
+
// Zero user-facing accepted members means zero delivered acceptance for that group:
|
|
155
|
+
// remembering the id would let a later no-arg update treat it as consented with no record.
|
|
156
|
+
.filter((group) => group.acceptedMembers.length > 0),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Newly declared members (issue #1128): per selected group, the CURRENT user-facing recipe members
|
|
161
|
+
// the remembered acceptedMembers does not know yet. A group with no prior acceptance contributes
|
|
162
|
+
// nothing — a first-time selection is itself the consent (#1126 semantics). Support members
|
|
163
|
+
// (harness-runtime) are silent dependencies and are never presented as a new user capability, so
|
|
164
|
+
// they are filtered out even though acceptedMembers never lists them.
|
|
165
|
+
export function newMemberCandidates(action, groupIds, priorState, transport = {}) {
|
|
166
|
+
if (!priorState) return [];
|
|
167
|
+
const recipes = readRecipes(resolveBundleTransport(transport).recipesPath);
|
|
168
|
+
const groups = new Map(recipes.groups.map((group) => [group.id, group]));
|
|
169
|
+
const acceptedByGroup = new Map(priorState.groups.map((group) => [group.id, new Set(group.acceptedMembers)]));
|
|
170
|
+
const candidates = [];
|
|
171
|
+
for (const groupId of groupIds) {
|
|
172
|
+
const accepted = acceptedByGroup.get(groupId);
|
|
173
|
+
if (!accepted) continue;
|
|
174
|
+
const group = groups.get(groupId);
|
|
175
|
+
if (!group) continue;
|
|
176
|
+
const members = [...new Set(group[action].steps.flatMap((step) => step.members))]
|
|
177
|
+
.filter((member) => !SUPPORT_GROUPS.has(member));
|
|
178
|
+
for (const member of members) {
|
|
179
|
+
if (!accepted.has(member)) candidates.push({ groupId, groupName: group.displayName, member });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return candidates;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Prior acceptance for the new-member consent delta on explicit selections (picker, --group,
|
|
186
|
+
// --all). ABSENT memory is tolerated — a first-time selection needs no delta — but CORRUPT memory
|
|
187
|
+
// fails closed with the same clear error as the no-arg replay: guessing acceptance from a file we
|
|
188
|
+
// cannot read could silently install a member the user never consented to.
|
|
189
|
+
function loadPriorSelection(env) {
|
|
190
|
+
const path = selectionStatePath(env);
|
|
191
|
+
if (!statSync(path, { throwIfNoEntry: false })?.isFile()) return null;
|
|
192
|
+
return loadSelectionState(env).state;
|
|
193
|
+
}
|
|
194
|
+
|
|
32
195
|
export const selectorTerminalControl = Object.freeze({
|
|
33
196
|
enter: '\x1b[?1049h\x1b[?25l',
|
|
34
197
|
redraw: '\x1b[2J\x1b[H',
|
|
@@ -42,6 +205,23 @@ export function resolveBundleTransport({ recipesPath, localRoot } = {}) {
|
|
|
42
205
|
};
|
|
43
206
|
}
|
|
44
207
|
|
|
208
|
+
// The render target recorded in selection memory is declared by the recipes DOCUMENT, never
|
|
209
|
+
// inferred from the recipes path: inside the packaged @1aboveio/skills CLI the entrypoint injects
|
|
210
|
+
// the package's own staged recipes, which IS this file's default path, so a path comparison would
|
|
211
|
+
// mislabel every public-npm run as a private checkout. The value is validated against the known
|
|
212
|
+
// targets so a foreign or hand-written recipes file fails closed instead of recording a made-up
|
|
213
|
+
// channel.
|
|
214
|
+
const RENDER_TARGETS = new Set(['private-checkout', 'public-npm']);
|
|
215
|
+
|
|
216
|
+
function declaredRenderTarget(transport) {
|
|
217
|
+
const { recipesPath } = resolveBundleTransport(transport);
|
|
218
|
+
const recipes = readRecipes(recipesPath);
|
|
219
|
+
if (!RENDER_TARGETS.has(recipes.renderTarget)) {
|
|
220
|
+
throw new Error(`Bundle recipes at ${recipesPath} declare an unknown renderTarget: ${JSON.stringify(recipes.renderTarget)}`);
|
|
221
|
+
}
|
|
222
|
+
return recipes.renderTarget;
|
|
223
|
+
}
|
|
224
|
+
|
|
45
225
|
function readRecipes(recipesPath) {
|
|
46
226
|
let contents;
|
|
47
227
|
try {
|
|
@@ -65,7 +245,11 @@ export function bundleCatalog(transport = {}) {
|
|
|
65
245
|
.map((group) => ({
|
|
66
246
|
id: group.id,
|
|
67
247
|
displayName: group.displayName,
|
|
68
|
-
members:
|
|
248
|
+
// User-facing members only: the picker's count and Includes line are user-facing surfaces,
|
|
249
|
+
// and a support dependency (harness-runtime) is never presented as part of what a group
|
|
250
|
+
// offers — it installs silently with the group that needs it.
|
|
251
|
+
members: group.install.steps.flatMap((step) => step.members)
|
|
252
|
+
.filter((member) => !SUPPORT_GROUPS.has(member)),
|
|
69
253
|
}));
|
|
70
254
|
}
|
|
71
255
|
|
|
@@ -85,15 +269,24 @@ function localFirstPartyCommand(command, localRoot, groupId) {
|
|
|
85
269
|
return command.replace(NATIVE_ADD_SOURCE, (_match, prefix) => `${prefix}${shellArgument(localRoot)}`);
|
|
86
270
|
}
|
|
87
271
|
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
272
|
+
// The coordinator executes one native add PER SKILL (#1129) rather than coalescing a whole source
|
|
273
|
+
// into one invocation. A coalesced add can only attribute a failure to the entire source; per-skill
|
|
274
|
+
// adds let the run record exactly which skill failed, continue with the remaining skills, and keep
|
|
275
|
+
// only the succeeded skills in selection memory. Recipe step/member order is preserved
|
|
276
|
+
// (dependency-first), and a member shared by several selected groups (harness-runtime) is still
|
|
277
|
+
// planned exactly once, at its first occurrence. Native `skills add` prompts once per invocation
|
|
278
|
+
// for the installation method, so per-skill execution relies on the native CLI's own non-interactive
|
|
279
|
+
// handling; what the coordinator must guarantee is attribution, ordering and deduplication.
|
|
92
280
|
const SKILL_TAIL = /^(.*? --skill )(.+?)( && .*)?$/;
|
|
93
281
|
|
|
282
|
+
// The generated step binds its runtime suffix (` && npm ci ...`) to the whole coalesced command.
|
|
283
|
+
// Per skill it belongs to the runtime member's own add, so the dependency install runs only after
|
|
284
|
+
// engineering-runtime itself was actually added — never after a sibling, never when it was skipped.
|
|
285
|
+
const RUNTIME_MEMBER = 'engineering-runtime';
|
|
286
|
+
|
|
94
287
|
function skillsFromCommand(command) {
|
|
95
288
|
const match = SKILL_TAIL.exec(command);
|
|
96
|
-
if (!match) throw new Error(`Native add has no --skill list to
|
|
289
|
+
if (!match) throw new Error(`Native add has no --skill list to split: ${command}`);
|
|
97
290
|
return match[2].trim().split(/\s+/).filter(Boolean);
|
|
98
291
|
}
|
|
99
292
|
|
|
@@ -110,19 +303,12 @@ function withYesFlag(command) {
|
|
|
110
303
|
return `${match[1]}${match[2]} --yes${match[3] || ''}`;
|
|
111
304
|
}
|
|
112
305
|
|
|
113
|
-
function
|
|
114
|
-
const seen = new Set(existing);
|
|
115
|
-
const merged = [...existing];
|
|
116
|
-
for (const skill of incoming) {
|
|
117
|
-
if (seen.has(skill)) continue;
|
|
118
|
-
seen.add(skill);
|
|
119
|
-
merged.push(skill);
|
|
120
|
-
}
|
|
121
|
-
return merged;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export function planBundleCommands(action, groupIds, { yes = false, ...transport } = {}) {
|
|
306
|
+
export function planBundleCommands(action, groupIds, { yes = false, skipMembers, omitMembers, ...transport } = {}) {
|
|
125
307
|
if (!ACTIONS.has(action)) throw new Error(`Unsupported bundle action: ${action}`);
|
|
308
|
+
// Members the user declined this run (#1128) are filtered out of every native --skill list.
|
|
309
|
+
// Support dependencies (harness-runtime) are never omittable: they are silent install
|
|
310
|
+
// dependencies, not user capabilities, so consent never applies to them.
|
|
311
|
+
const omit = new Set(omitMembers ?? []);
|
|
126
312
|
const { recipesPath, localRoot } = resolveBundleTransport(transport);
|
|
127
313
|
const recipes = readRecipes(recipesPath);
|
|
128
314
|
assertLocalRoot(localRoot);
|
|
@@ -133,11 +319,11 @@ export function planBundleCommands(action, groupIds, { yes = false, ...transport
|
|
|
133
319
|
if (!requested.includes(groupId)) requested.push(groupId);
|
|
134
320
|
}
|
|
135
321
|
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
const
|
|
140
|
-
const
|
|
322
|
+
// Plan from the selected groups (not recipes.all). A public package may expose only a subset of
|
|
323
|
+
// groups while still shipping a full recipes.all from the private catalog; using that all-plan
|
|
324
|
+
// would install skills that are not in the selection / not in the package.
|
|
325
|
+
const planned = [];
|
|
326
|
+
const plannedByMember = new Map();
|
|
141
327
|
|
|
142
328
|
for (const groupId of requested) {
|
|
143
329
|
const group = groups.get(groupId);
|
|
@@ -149,66 +335,188 @@ export function planBundleCommands(action, groupIds, { yes = false, ...transport
|
|
|
149
335
|
const command = step.sourceId === 'first-party'
|
|
150
336
|
? localFirstPartyCommand(generated.command, localRoot, groupId)
|
|
151
337
|
: generated.command;
|
|
152
|
-
|
|
338
|
+
// An explicit member subset (the digest skip) removes members from the native add but never
|
|
339
|
+
// reorders the rest: the remaining skills keep recipe order, support dependency first.
|
|
340
|
+
// Declined new members (#1128) are also filtered out, but consent never applies to support
|
|
341
|
+
// dependencies (harness-runtime): they are silent install dependencies, not capabilities.
|
|
342
|
+
const skills = skillsFromCommand(command)
|
|
343
|
+
.filter((skill) => !skipMembers?.has(skill))
|
|
344
|
+
.filter((skill) => SUPPORT_GROUPS.has(skill) || !omit.has(skill));
|
|
153
345
|
const suffix = SKILL_TAIL.exec(command)?.[3] || '';
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
346
|
+
const bare = suffix ? command.slice(0, command.length - suffix.length) : command;
|
|
347
|
+
for (const skill of skills) {
|
|
348
|
+
const existing = plannedByMember.get(skill);
|
|
349
|
+
if (existing) {
|
|
350
|
+
if (!existing.groupIds.includes(groupId)) existing.groupIds.push(groupId);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
let skillCommand = withSkills(bare, [skill]);
|
|
354
|
+
if (suffix && skill === RUNTIME_MEMBER) skillCommand = `${skillCommand}${suffix}`;
|
|
355
|
+
if (yes) skillCommand = withYesFlag(skillCommand);
|
|
356
|
+
const entry = {
|
|
357
|
+
member: skill,
|
|
158
358
|
groupIds: [groupId],
|
|
159
|
-
|
|
359
|
+
groupId,
|
|
360
|
+
groupName: group.displayName,
|
|
160
361
|
sourceId: step.sourceId,
|
|
161
|
-
|
|
162
|
-
skills: [...skills],
|
|
163
|
-
suffix,
|
|
362
|
+
command: skillCommand,
|
|
164
363
|
cwd: localRoot,
|
|
165
364
|
failureMessage: step.onFailure.message,
|
|
166
|
-
}
|
|
167
|
-
|
|
365
|
+
};
|
|
366
|
+
plannedByMember.set(skill, entry);
|
|
367
|
+
planned.push(entry);
|
|
168
368
|
}
|
|
169
|
-
existing.groupIds.push(groupId);
|
|
170
|
-
if (!existing.groupNames.includes(group.displayName)) existing.groupNames.push(group.displayName);
|
|
171
|
-
existing.skills = mergeSkillLists(existing.skills, skills);
|
|
172
|
-
if (!existing.suffix && suffix) existing.suffix = suffix;
|
|
173
369
|
}
|
|
174
370
|
}
|
|
175
371
|
|
|
176
|
-
return
|
|
177
|
-
const planned = bySource.get(sourceId);
|
|
178
|
-
let command = withSkills(planned.template, planned.skills);
|
|
179
|
-
if (planned.suffix && !command.includes(' && ')) command = `${command}${planned.suffix}`;
|
|
180
|
-
if (yes) command = withYesFlag(command);
|
|
181
|
-
return {
|
|
182
|
-
groupId: planned.groupIds.length === 1 ? planned.groupIds[0] : planned.groupIds.join('+'),
|
|
183
|
-
groupName: planned.groupNames.length === 1 ? planned.groupNames[0] : planned.groupNames.join(' + '),
|
|
184
|
-
sourceId: planned.sourceId,
|
|
185
|
-
command,
|
|
186
|
-
cwd: planned.cwd,
|
|
187
|
-
failureMessage: planned.failureMessage,
|
|
188
|
-
};
|
|
189
|
-
});
|
|
372
|
+
return planned;
|
|
190
373
|
}
|
|
191
374
|
|
|
375
|
+
// Continue-on-failure (#1129): every planned per-skill add is attempted in dependency order; a
|
|
376
|
+
// failed skill is recorded (its recipe failure message goes to stderr) and the run proceeds to the
|
|
377
|
+
// remaining skills. Nothing here stops early — the caller summarizes the buckets and decides the
|
|
378
|
+
// exit status, and selection persistence sees exactly which members succeeded.
|
|
192
379
|
export function runBundleCommands(commands, {
|
|
193
380
|
execute = (command, cwd) => spawnSync('/bin/bash', ['-lc', command], { cwd, stdio: 'inherit' }),
|
|
194
381
|
stdout = process.stdout,
|
|
195
382
|
stderr = process.stderr,
|
|
196
383
|
} = {}) {
|
|
384
|
+
const succeeded = [];
|
|
385
|
+
const failed = [];
|
|
197
386
|
for (const planned of commands) {
|
|
198
|
-
stdout.write(`\nInstalling ${planned.
|
|
387
|
+
stdout.write(`\nInstalling ${planned.member} (${planned.sourceId})...\n`);
|
|
199
388
|
const result = execute(planned.command, planned.cwd);
|
|
200
|
-
if (result.status
|
|
201
|
-
|
|
202
|
-
|
|
389
|
+
if (result.status === 0) {
|
|
390
|
+
succeeded.push(planned);
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
stderr.write(`${planned.failureMessage}\n`);
|
|
394
|
+
failed.push({ planned, status: Number.isInteger(result.status) ? result.status : 1 });
|
|
395
|
+
}
|
|
396
|
+
return { succeeded, failed };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// The four run buckets, printed after every non-dry run. `unchanged` (digest-identical skip, #1127)
|
|
400
|
+
// and `declined` (new-member consent, #1128) render from empty lists until those units land — the
|
|
401
|
+
// summary contract is stable from here on, so both buckets are always shown.
|
|
402
|
+
function printRunSummary(stdout, { succeeded, failed, unchanged = [], declined = [] }) {
|
|
403
|
+
// The summary is a user-facing surface: support dependencies (harness-runtime) are planned and
|
|
404
|
+
// executed like any member, but they are dependency plumbing and are never named in a bucket.
|
|
405
|
+
const visible = (name) => !SUPPORT_GROUPS.has(name);
|
|
406
|
+
const render = (names) => (names.length > 0 ? names.join(', ') : '(none)');
|
|
407
|
+
stdout.write('\nRun summary:\n');
|
|
408
|
+
stdout.write(` succeeded: ${render(succeeded.map((planned) => planned.member).filter(visible))}\n`);
|
|
409
|
+
stdout.write(` unchanged: ${render(unchanged.filter(visible))}\n`);
|
|
410
|
+
stdout.write(` declined: ${render(declined.filter(visible))}\n`);
|
|
411
|
+
stdout.write(` failed: ${render(failed.map((entry) => entry.planned.member).filter(visible))}\n`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// --- Digest classification (epic #1124 / issue #1127) --------------------------------------------
|
|
415
|
+
//
|
|
416
|
+
// An `update` that re-adds a skill whose installed content already digests to what this invocation
|
|
417
|
+
// would install buys nothing and costs the native overwrite prompt. So update classifies every
|
|
418
|
+
// accepted member first and omits the digest-equal ones from the native add — skipping is the ONLY
|
|
419
|
+
// way an unchanged member avoids the prompt, because the native CLI is the sole write authority and
|
|
420
|
+
// the coordinator may not copy, link, or overwrite anything itself.
|
|
421
|
+
//
|
|
422
|
+
// The digest semantics are borrowed, never re-invented: first-party members digest their PUBLISHED
|
|
423
|
+
// content (`published: true`) on both transports, external members digest the exact reviewed tree,
|
|
424
|
+
// exactly as release coherence validates them. And a member with NO expected digest — Impeccable is
|
|
425
|
+
// the one on tip — can never be `unchanged`: there is nothing to compare against, so an installed
|
|
426
|
+
// one refreshes and an absent one is missing.
|
|
427
|
+
|
|
428
|
+
// The pure verdict, separated from the filesystem so the whole table is testable in one place.
|
|
429
|
+
export function classifyMember({ installed, expectedDigest, actualDigest }) {
|
|
430
|
+
if (!installed) return 'missing';
|
|
431
|
+
if (typeof expectedDigest !== 'string' || expectedDigest.length === 0) return 'refresh';
|
|
432
|
+
return actualDigest === expectedDigest ? 'unchanged' : 'refresh';
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// What this run expects a member to digest to. First-party expectation is computed from THIS
|
|
436
|
+
// invocation's local source root (the private checkout or the unpacked package — both stage the
|
|
437
|
+
// published file set, so the published-scope digest is the same on either transport); an external
|
|
438
|
+
// member's expectation is the reviewed tree digest the recipe carries, or null when it carries none.
|
|
439
|
+
export function expectedMemberDigest(source, member, { localRoot }) {
|
|
440
|
+
if (firstPartySource(source)) {
|
|
441
|
+
return contentDigest(join(localRoot, member.path), { published: true });
|
|
442
|
+
}
|
|
443
|
+
return typeof member?.contentDigest === 'string' ? member.contentDigest : null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Classify one installed member directory against its expectation. `skillsRoot` is the canonical
|
|
447
|
+
// installed store (`~/.agents/skills`); the member lives under it by installName.
|
|
448
|
+
export function classifyInstalledMember(source, member, { localRoot, skillsRoot }) {
|
|
449
|
+
const directory = join(skillsRoot, member.installName);
|
|
450
|
+
const entry = { member: member.installName, sourceId: source?.id ?? null, directory };
|
|
451
|
+
if (!statSync(directory, { throwIfNoEntry: false })?.isDirectory()) {
|
|
452
|
+
return { ...entry, status: 'missing' };
|
|
453
|
+
}
|
|
454
|
+
const expectedDigest = expectedMemberDigest(source, member, { localRoot });
|
|
455
|
+
const actualDigest = contentDigest(directory, { published: firstPartySource(source) });
|
|
456
|
+
return {
|
|
457
|
+
...entry,
|
|
458
|
+
status: classifyMember({ installed: true, expectedDigest, actualDigest }),
|
|
459
|
+
expectedDigest,
|
|
460
|
+
actualDigest,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Every member the selected groups would install/update, classified in recipe step order and
|
|
465
|
+
// deduplicated across groups (the shared support runtime classifies once). The buckets drive both
|
|
466
|
+
// the planner's skip set and the dry-run/no-op rendering. A member the recipe names but its source
|
|
467
|
+
// does not declare gets no expected digest — refresh-or-missing, never a silent skip.
|
|
468
|
+
export function classifySelectedMembers(action, groupIds, { skillsRoot, ...transport } = {}) {
|
|
469
|
+
if (!ACTIONS.has(action)) throw new Error(`Unsupported bundle action: ${action}`);
|
|
470
|
+
const { recipesPath, localRoot } = resolveBundleTransport(transport);
|
|
471
|
+
const recipes = readRecipes(recipesPath);
|
|
472
|
+
assertLocalRoot(localRoot);
|
|
473
|
+
const sourcesById = new Map((recipes.sources ?? []).map((source) => [source.id, source]));
|
|
474
|
+
const groups = new Map(recipes.groups.map((group) => [group.id, group]));
|
|
475
|
+
const seen = new Set();
|
|
476
|
+
const buckets = { unchanged: [], refresh: [], missing: [] };
|
|
477
|
+
for (const groupId of groupIds) {
|
|
478
|
+
const group = groups.get(groupId);
|
|
479
|
+
if (!group || SUPPORT_GROUPS.has(groupId)) throw new Error(`Unknown bundle: ${groupId}`);
|
|
480
|
+
for (const step of group[action].steps) {
|
|
481
|
+
const source = sourcesById.get(step.sourceId);
|
|
482
|
+
const membersByName = new Map((source?.members ?? []).map((member) => [member.installName, member]));
|
|
483
|
+
for (const installName of step.members) {
|
|
484
|
+
if (seen.has(installName)) continue;
|
|
485
|
+
seen.add(installName);
|
|
486
|
+
const member = membersByName.get(installName) ?? { installName };
|
|
487
|
+
const entry = classifyInstalledMember(source, member, { localRoot, skillsRoot });
|
|
488
|
+
buckets[entry.status].push(entry);
|
|
489
|
+
}
|
|
203
490
|
}
|
|
204
491
|
}
|
|
205
|
-
return
|
|
492
|
+
return buckets;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function printUpdateBuckets(stdout, buckets) {
|
|
496
|
+
// Bucket listings are user-facing: the support runtime classifies with everything else (it
|
|
497
|
+
// drives the planner's skip set) but is never named to the user.
|
|
498
|
+
const visible = (entry) => !SUPPORT_GROUPS.has(entry.member);
|
|
499
|
+
const unchanged = buckets.unchanged.filter(visible).map((entry) => entry.member);
|
|
500
|
+
const refresh = [
|
|
501
|
+
...buckets.refresh.filter(visible).map((entry) => entry.member),
|
|
502
|
+
// Missing accepted members are refresh candidates (reinstall), not "new" — they belong to the
|
|
503
|
+
// would-refresh bucket, annotated so a dry run explains why an absent skill will be added.
|
|
504
|
+
...buckets.missing.filter(visible).map((entry) => `${entry.member} (not installed)`),
|
|
505
|
+
];
|
|
506
|
+
stdout.write(`Unchanged (installed content matches): ${unchanged.length > 0 ? unchanged.join(', ') : 'none'}\n`);
|
|
507
|
+
stdout.write(`Would refresh: ${refresh.length > 0 ? refresh.join(', ') : 'none'}\n`);
|
|
206
508
|
}
|
|
207
509
|
|
|
208
510
|
function usage() {
|
|
209
511
|
return `Usage:
|
|
210
512
|
node skills/distribution/scripts/bundles.mjs list [--recipes <path>]
|
|
211
513
|
node skills/distribution/scripts/bundles.mjs <install|update> [--group <id> ... | --all] [--dry-run] [--yes] [--recipes <path>] [--local-root <path>]
|
|
514
|
+
|
|
515
|
+
install without --group/--all opens the interactive picker; update without them replays the
|
|
516
|
+
remembered selection (\${XDG_STATE_HOME:-~/.local/state}/1aboveio-skills/selection.json).
|
|
517
|
+
When a remembered group's current recipe declares members you have not accepted yet, each new skill
|
|
518
|
+
is offered individually and defaults to No; --yes accepts them all, --dry-run lists them without
|
|
519
|
+
prompting.
|
|
212
520
|
`;
|
|
213
521
|
}
|
|
214
522
|
|
|
@@ -310,35 +618,187 @@ async function promptForGroups(catalog, { stdin = process.stdin, stdout = proces
|
|
|
310
618
|
});
|
|
311
619
|
}
|
|
312
620
|
|
|
313
|
-
|
|
621
|
+
// A minimal line prompt on the injected streams — deliberately separate from the raw-mode group
|
|
622
|
+
// picker: this is a sequence of independent yes/no questions, not a multi-select. EOF or an empty
|
|
623
|
+
// line is the default answer (No), so a closed or ended stdin can never accept a new skill.
|
|
624
|
+
function createLinePrompter({ stdin, stdout }) {
|
|
625
|
+
let buffer = '';
|
|
626
|
+
let ended = false;
|
|
627
|
+
const waiters = [];
|
|
628
|
+
const flush = () => {
|
|
629
|
+
while (waiters.length > 0) {
|
|
630
|
+
const newline = buffer.search(/\r?\n/);
|
|
631
|
+
if (newline !== -1) {
|
|
632
|
+
const line = buffer.slice(0, newline);
|
|
633
|
+
buffer = buffer.slice(newline).replace(/^\r?\n/, '');
|
|
634
|
+
waiters.shift()(line.trim());
|
|
635
|
+
} else if (ended) {
|
|
636
|
+
const rest = buffer;
|
|
637
|
+
buffer = '';
|
|
638
|
+
waiters.shift()(rest.trim());
|
|
639
|
+
} else {
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
stdin.on('data', (chunk) => { buffer += String(chunk); flush(); });
|
|
645
|
+
stdin.on('end', () => { ended = true; flush(); });
|
|
646
|
+
stdin.resume?.();
|
|
647
|
+
return {
|
|
648
|
+
ask(question) {
|
|
649
|
+
stdout.write(question);
|
|
650
|
+
return new Promise((answer) => { waiters.push(answer); flush(); });
|
|
651
|
+
},
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Offer each newly declared member individually and return the DECLINED member names (#1128). The
|
|
656
|
+
// default is No: --yes accepts all without prompting; a non-interactive stdin declines all with a
|
|
657
|
+
// note rather than guessing consent; interactively, anything but an explicit y/yes declines.
|
|
658
|
+
async function consentNewMembers(candidates, { yes, stdin, stdout, stderr }) {
|
|
659
|
+
const declined = new Set();
|
|
660
|
+
if (yes) {
|
|
661
|
+
for (const candidate of candidates) {
|
|
662
|
+
stdout.write(`Accepting new skill "${candidate.member}" in ${candidate.groupName} (--yes)\n`);
|
|
663
|
+
}
|
|
664
|
+
return declined;
|
|
665
|
+
}
|
|
666
|
+
if (!stdin.isTTY) {
|
|
667
|
+
for (const candidate of candidates) {
|
|
668
|
+
stderr.write(`Declining new skill "${candidate.member}" in ${candidate.groupName}: no interactive terminal (default No; re-run with --yes to accept)\n`);
|
|
669
|
+
declined.add(candidate.member);
|
|
670
|
+
}
|
|
671
|
+
return declined;
|
|
672
|
+
}
|
|
673
|
+
const prompter = createLinePrompter({ stdin, stdout });
|
|
674
|
+
for (const candidate of candidates) {
|
|
675
|
+
const answer = await prompter.ask(`Add new skill "${candidate.member}" to ${candidate.groupName} (${candidate.groupId})? [y/N] `);
|
|
676
|
+
if (/^y(es)?$/i.test(answer)) {
|
|
677
|
+
stdout.write(`Adding ${candidate.member}\n`);
|
|
678
|
+
} else {
|
|
679
|
+
stdout.write(`Skipping ${candidate.member}\n`);
|
|
680
|
+
declined.add(candidate.member);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return declined;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Dependencies are injectable so the coordinator seam can be tested black-box: a stubbed native
|
|
687
|
+
// executor, controllable TTY streams, and a fixture XDG environment, without reaching process
|
|
688
|
+
// globals. The default execute is the real shell out to the native Skills CLI — the sole
|
|
689
|
+
// lifecycle write authority; the coordinator only selects, plans, invokes, and persists memory
|
|
690
|
+
// AFTER those native commands settle.
|
|
691
|
+
export async function runCli(args, {
|
|
692
|
+
execute,
|
|
693
|
+
stdin = process.stdin,
|
|
694
|
+
stdout = process.stdout,
|
|
695
|
+
stderr = process.stderr,
|
|
696
|
+
env = process.env,
|
|
697
|
+
} = {}) {
|
|
314
698
|
try {
|
|
315
699
|
const parsed = parseArgs(args);
|
|
316
700
|
const transport = { recipesPath: parsed.recipesPath, localRoot: parsed.localRoot };
|
|
317
701
|
if (parsed.action === 'list') {
|
|
318
|
-
printCatalog(
|
|
702
|
+
printCatalog(stdout, bundleCatalog(transport));
|
|
319
703
|
return 0;
|
|
320
704
|
}
|
|
321
705
|
if (!ACTIONS.has(parsed.action)) {
|
|
322
|
-
|
|
706
|
+
stderr.write(usage());
|
|
323
707
|
return 2;
|
|
324
708
|
}
|
|
325
709
|
const catalog = bundleCatalog(transport);
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
710
|
+
let groupIds;
|
|
711
|
+
let priorState = null;
|
|
712
|
+
if (parsed.all) {
|
|
713
|
+
groupIds = catalog.map((bundle) => bundle.id);
|
|
714
|
+
priorState = loadPriorSelection(env);
|
|
715
|
+
} else if (parsed.groupIds.length > 0) {
|
|
716
|
+
groupIds = parsed.groupIds;
|
|
717
|
+
priorState = loadPriorSelection(env);
|
|
718
|
+
} else if (parsed.action === 'update') {
|
|
719
|
+
// No-arg update replays the remembered selection. It never opens the picker and never
|
|
720
|
+
// infers groups from the filesystem: missing/corrupt/foreign memory is a hard error.
|
|
721
|
+
const { state } = loadSelectionState(env);
|
|
722
|
+
priorState = state;
|
|
723
|
+
groupIds = rememberedGroupIds(state, catalog, stderr);
|
|
724
|
+
} else {
|
|
725
|
+
groupIds = await promptForGroups(catalog, { stdin, stdout });
|
|
726
|
+
priorState = loadPriorSelection(env);
|
|
727
|
+
}
|
|
331
728
|
if (groupIds.length === 0) throw new Error('Select at least one bundle');
|
|
332
|
-
|
|
729
|
+
// New-member consent (#1128): newly declared user-facing members of a group with prior
|
|
730
|
+
// acceptance are offered one at a time, defaulting to No. A dry run only LISTS the would-be
|
|
731
|
+
// candidates — it never prompts and never writes state.
|
|
732
|
+
const candidates = newMemberCandidates(parsed.action, groupIds, priorState, transport);
|
|
733
|
+
let declined = new Set();
|
|
734
|
+
if (candidates.length > 0 && parsed.dryRun) {
|
|
735
|
+
for (const candidate of candidates) {
|
|
736
|
+
stdout.write(`New skill "${candidate.member}" in ${candidate.groupName} (${candidate.groupId}) — would prompt for consent (default: No); --yes accepts all new skills\n`);
|
|
737
|
+
}
|
|
738
|
+
} else if (candidates.length > 0) {
|
|
739
|
+
declined = await consentNewMembers(candidates, { yes: parsed.yes, stdin, stdout, stderr });
|
|
740
|
+
}
|
|
741
|
+
// Digest skip (#1127): `update` classifies the accepted members against the installed tree and
|
|
742
|
+
// omits the digest-equal ones from the native add. `install` never skips — it is an explicit
|
|
743
|
+
// request to install, whose overwrite consent belongs to the native prompt. Declined new
|
|
744
|
+
// members (#1128) are excluded from classification: they never run, so they are not buckets.
|
|
745
|
+
let buckets;
|
|
746
|
+
let skipMembers;
|
|
747
|
+
if (parsed.action === 'update') {
|
|
748
|
+
const skillsRoot = join(env.HOME || homedir(), '.agents', 'skills');
|
|
749
|
+
buckets = classifySelectedMembers(parsed.action, groupIds, { ...transport, skillsRoot });
|
|
750
|
+
skipMembers = new Set(buckets.unchanged.map((entry) => entry.member));
|
|
751
|
+
if (declined.size > 0) {
|
|
752
|
+
for (const key of Object.keys(buckets)) {
|
|
753
|
+
buckets[key] = buckets[key].filter((entry) => !declined.has(entry.member));
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
const commands = planBundleCommands(parsed.action, groupIds, { ...transport, yes: parsed.yes, skipMembers, omitMembers: declined });
|
|
333
758
|
if (parsed.dryRun) {
|
|
759
|
+
if (buckets) printUpdateBuckets(stdout, buckets);
|
|
334
760
|
for (const planned of commands) {
|
|
335
|
-
|
|
761
|
+
stdout.write(`\n${planned.groupName} / ${planned.sourceId} / ${planned.member}\n${planned.command}\n`);
|
|
336
762
|
}
|
|
763
|
+
if (buckets && commands.length === 0) stdout.write('Nothing to do: every accepted skill is already up to date.\n');
|
|
337
764
|
return 0;
|
|
338
765
|
}
|
|
339
|
-
|
|
766
|
+
if (commands.length === 0 && buckets) {
|
|
767
|
+
stdout.write('All accepted skills are already up to date; nothing to do.\n');
|
|
768
|
+
} else if (buckets?.unchanged.length > 0) {
|
|
769
|
+
const skipping = buckets.unchanged.map((entry) => entry.member).filter((member) => !SUPPORT_GROUPS.has(member));
|
|
770
|
+
if (skipping.length > 0) stdout.write(`Up to date, skipping: ${skipping.join(', ')}\n`);
|
|
771
|
+
}
|
|
772
|
+
const { succeeded, failed } = runBundleCommands(commands, { execute, stdout, stderr });
|
|
773
|
+
printRunSummary(stdout, {
|
|
774
|
+
succeeded,
|
|
775
|
+
failed,
|
|
776
|
+
unchanged: buckets?.unchanged.map((entry) => entry.member),
|
|
777
|
+
declined: [...declined],
|
|
778
|
+
});
|
|
779
|
+
// Mixed-success memory (#1129): retain only user-facing members that either succeeded at
|
|
780
|
+
// native add or were already digest-equal. A group with no recordable member is omitted from
|
|
781
|
+
// the written set, and a run with no recordable member at all leaves prior memory untouched;
|
|
782
|
+
// a decline never becomes remembered acceptance.
|
|
783
|
+
const succeededMembers = [
|
|
784
|
+
...succeeded.map((planned) => planned.member),
|
|
785
|
+
...(buckets?.unchanged.map((entry) => entry.member) ?? []),
|
|
786
|
+
];
|
|
787
|
+
if (succeededMembers.length > 0) {
|
|
788
|
+
const renderTarget = declaredRenderTarget(transport);
|
|
789
|
+
const selection = selectionForRun(parsed.action, groupIds, {
|
|
790
|
+
...transport,
|
|
791
|
+
renderTarget,
|
|
792
|
+
succeededMembers,
|
|
793
|
+
omitMembers: declined,
|
|
794
|
+
});
|
|
795
|
+
if (selection.groups.length > 0) {
|
|
796
|
+
writeSelectionState(selection, env);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return failed.length > 0 ? failed[0].status : 0;
|
|
340
800
|
} catch (error) {
|
|
341
|
-
|
|
801
|
+
stderr.write(`${error.message}\n`);
|
|
342
802
|
return 2;
|
|
343
803
|
}
|
|
344
804
|
}
|