@1aboveio/skills 0.12.1 → 0.14.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 +44 -12
- package/package.json +1 -1
- package/runtime/skills/distribution/generated/recipes.json +17 -17
- package/runtime/skills/distribution/scripts/bundles.mjs +620 -72
- package/runtime/skills/engineering/engineering-runtime/scripts/invocation-policy.mjs +187 -0
- package/runtime/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +595 -0
- package/runtime/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +172 -0
- package/skills/cicd-pipeline/mergify/references/watch-contract.md +7 -0
- package/skills/cicd-pipeline/mergify/scripts/watch-pr-delivery-core.mjs +60 -2
- package/skills/engineering/engineering-runtime/coherence/workflow.json +23 -16
- package/skills/engineering/engineering-runtime/scripts/invocation-policy.mjs +187 -0
- package/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +19 -0
- package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +9 -3
- package/skills/engineering/resolve-issues/generated/workflow-repair-policy.json +20 -13
- package/skills/engineering/resolve-issues/scripts/run-state.mjs +14 -6
|
@@ -1,12 +1,23 @@
|
|
|
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
|
|
|
10
|
+
import {
|
|
11
|
+
applyInvocationPolicy,
|
|
12
|
+
reviewedSourceInjections,
|
|
13
|
+
} from '../../engineering/engineering-runtime/scripts/invocation-policy.mjs';
|
|
9
14
|
import { isMainModule } from '../../engineering/engineering-runtime/scripts/main-module.mjs';
|
|
15
|
+
// The digest authority for the update skip is the ONE existing predicate pair: contentDigest with
|
|
16
|
+
// the published-content scope for first-party members, exact-tree for external ones — the same
|
|
17
|
+
// semantics release coherence validates installed trees with. The coordinator never re-derives or
|
|
18
|
+
// widens that scope; it only asks "does what is installed already digest to what this run would
|
|
19
|
+
// install?" and skips the native add when the answer is yes.
|
|
20
|
+
import { contentDigest, firstPartySource } from '../../engineering/engineering-runtime/scripts/workflow-coherence.mjs';
|
|
10
21
|
|
|
11
22
|
// The coordinator is one command rendered into two transports: the private checkout and the
|
|
12
23
|
// unpacked @1aboveio/skills package. Both defaults below are anchored on this file's own location,
|
|
@@ -29,6 +40,173 @@ const COMMAND_TRANSPORTS = ['local', 'https'];
|
|
|
29
40
|
export const SUPPORT_GROUPS = new Set(['harness-runtime']);
|
|
30
41
|
const ACTIONS = new Set(['install', 'update']);
|
|
31
42
|
|
|
43
|
+
// Selection memory (ADR 0002/0004 amendment, epic #1124): the remembered user-facing group
|
|
44
|
+
// set lives in XDG state so a no-argument `update` can replay it instead of reopening the picker.
|
|
45
|
+
// The file records only user-facing successes (#1129): it is written when at least one user-facing
|
|
46
|
+
// member's native add succeeded or was already digest-equal unchanged, with acceptedMembers
|
|
47
|
+
// holding exactly those succeeded and unchanged members; failed and declined members are omitted,
|
|
48
|
+
// a selected group left with no recordable member is omitted from the written set rather than
|
|
49
|
+
// remembered with an empty acceptance (a failed run never advances its group ids into remembered
|
|
50
|
+
// groups), and a run where nothing recordable succeeded leaves the prior file byte-for-byte
|
|
51
|
+
// untouched. It is never a source of filesystem inference: missing or corrupt memory is a clear
|
|
52
|
+
// error, not a reason to guess groups from disk.
|
|
53
|
+
export const SELECTION_SCHEMA_VERSION = 1;
|
|
54
|
+
|
|
55
|
+
export function selectionStatePath(env = process.env) {
|
|
56
|
+
const stateHome = env.XDG_STATE_HOME || join(env.HOME || homedir(), '.local', 'state');
|
|
57
|
+
return join(stateHome, '1aboveio-skills', 'selection.json');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function selectionError(path, detail) {
|
|
61
|
+
return new Error(`Remembered selection at ${path} ${detail}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function loadSelectionState(env = process.env) {
|
|
65
|
+
const path = selectionStatePath(env);
|
|
66
|
+
let raw;
|
|
67
|
+
try {
|
|
68
|
+
raw = readFileSync(path, 'utf8');
|
|
69
|
+
} catch {
|
|
70
|
+
throw selectionError(path, 'does not exist. Run install or update with --group <id> or --all once to record a selection');
|
|
71
|
+
}
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(raw);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
throw selectionError(path, `is not valid JSON: ${error.message}`);
|
|
77
|
+
}
|
|
78
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
79
|
+
throw selectionError(path, 'is invalid: expected an object');
|
|
80
|
+
}
|
|
81
|
+
if (parsed.schemaVersion !== SELECTION_SCHEMA_VERSION) {
|
|
82
|
+
throw selectionError(path, `has unsupported schemaVersion ${JSON.stringify(parsed.schemaVersion)} (expected ${SELECTION_SCHEMA_VERSION})`);
|
|
83
|
+
}
|
|
84
|
+
if (!Array.isArray(parsed.groups)) {
|
|
85
|
+
throw selectionError(path, 'is invalid: groups must be an array');
|
|
86
|
+
}
|
|
87
|
+
if (parsed.tddImplicitInvocation !== undefined && typeof parsed.tddImplicitInvocation !== 'boolean') {
|
|
88
|
+
throw selectionError(path, 'is invalid: tddImplicitInvocation must be a boolean');
|
|
89
|
+
}
|
|
90
|
+
for (const group of parsed.groups) {
|
|
91
|
+
const valid = group
|
|
92
|
+
&& typeof group === 'object'
|
|
93
|
+
&& typeof group.id === 'string'
|
|
94
|
+
&& group.id.length > 0
|
|
95
|
+
&& Array.isArray(group.acceptedMembers)
|
|
96
|
+
&& group.acceptedMembers.every((member) => typeof member === 'string');
|
|
97
|
+
if (!valid) {
|
|
98
|
+
throw selectionError(path, 'is invalid: every group needs an id and an acceptedMembers string array');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { path, state: parsed };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function writeSelectionState(state, env = process.env) {
|
|
105
|
+
const path = selectionStatePath(env);
|
|
106
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
107
|
+
writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`);
|
|
108
|
+
return path;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The group ids a remembered selection resolves to against the CURRENT catalog. Remembered ids
|
|
112
|
+
// that are no longer selectable (renamed, removed, or a support group that must never be
|
|
113
|
+
// user-selected) are dropped with a warning; memory never resurrects them and never falls back to
|
|
114
|
+
// filesystem inference. Nothing left is an error, not an empty plan.
|
|
115
|
+
function rememberedGroupIds(state, catalog, stderr) {
|
|
116
|
+
const known = new Set(catalog.map((bundle) => bundle.id));
|
|
117
|
+
const kept = [];
|
|
118
|
+
const dropped = [];
|
|
119
|
+
for (const group of state.groups) {
|
|
120
|
+
if (!known.has(group.id) || SUPPORT_GROUPS.has(group.id)) {
|
|
121
|
+
if (!dropped.includes(group.id)) dropped.push(group.id);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (!kept.includes(group.id)) kept.push(group.id);
|
|
125
|
+
}
|
|
126
|
+
if (dropped.length > 0) {
|
|
127
|
+
stderr.write(`Ignoring unknown remembered bundle(s): ${dropped.join(', ')}\n`);
|
|
128
|
+
}
|
|
129
|
+
if (kept.length === 0) {
|
|
130
|
+
throw new Error('No known bundles remain in the remembered selection. Run update with --group <id> or --all to record a new selection');
|
|
131
|
+
}
|
|
132
|
+
return kept;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The replacement memory for one run: the user-facing groups this run selected that have at least
|
|
136
|
+
// one recordable member, each with acceptedMembers holding the user-facing members that were
|
|
137
|
+
// successfully added or already digest-equal this run. Declined and failed members are omitted;
|
|
138
|
+
// support-only members are never remembered; a group whose user-facing members all failed or were
|
|
139
|
+
// declined is dropped entirely, so the next no-arg update never replays a group this run did not
|
|
140
|
+
// actually deliver. The transport label is observational only — both render targets share this
|
|
141
|
+
// file.
|
|
142
|
+
export function selectionForRun(action, groupIds, {
|
|
143
|
+
renderTarget,
|
|
144
|
+
now = new Date(),
|
|
145
|
+
succeededMembers,
|
|
146
|
+
omitMembers,
|
|
147
|
+
tddImplicitInvocation = true,
|
|
148
|
+
...transport
|
|
149
|
+
} = {}) {
|
|
150
|
+
const omitted = new Set(omitMembers ?? []);
|
|
151
|
+
const recipes = readRecipes(resolveBundleTransport(transport).recipesPath);
|
|
152
|
+
const groups = new Map(recipes.groups.map((group) => [group.id, group]));
|
|
153
|
+
const succeeded = succeededMembers ? new Set(succeededMembers) : undefined;
|
|
154
|
+
return {
|
|
155
|
+
schemaVersion: SELECTION_SCHEMA_VERSION,
|
|
156
|
+
updatedAt: now.toISOString(),
|
|
157
|
+
transport: { renderTarget },
|
|
158
|
+
tddImplicitInvocation,
|
|
159
|
+
groups: groupIds
|
|
160
|
+
.map((groupId) => {
|
|
161
|
+
const group = groups.get(groupId);
|
|
162
|
+
if (!group) throw new Error(`Unknown bundle: ${groupId}`);
|
|
163
|
+
const acceptedMembers = [...new Set(group[action].steps.flatMap((step) => step.members))]
|
|
164
|
+
.filter((member) => !SUPPORT_GROUPS.has(member))
|
|
165
|
+
.filter((member) => !omitted.has(member))
|
|
166
|
+
.filter((member) => !succeeded || succeeded.has(member));
|
|
167
|
+
return { id: groupId, acceptedMembers };
|
|
168
|
+
})
|
|
169
|
+
// Zero user-facing accepted members means zero delivered acceptance for that group:
|
|
170
|
+
// remembering the id would let a later no-arg update treat it as consented with no record.
|
|
171
|
+
.filter((group) => group.acceptedMembers.length > 0),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Newly declared members (issue #1128): per selected group, the CURRENT user-facing recipe members
|
|
176
|
+
// the remembered acceptedMembers does not know yet. A group with no prior acceptance contributes
|
|
177
|
+
// nothing — a first-time selection is itself the consent (#1126 semantics). Support members
|
|
178
|
+
// (harness-runtime) are silent dependencies and are never presented as a new user capability, so
|
|
179
|
+
// they are filtered out even though acceptedMembers never lists them.
|
|
180
|
+
export function newMemberCandidates(action, groupIds, priorState, transport = {}) {
|
|
181
|
+
if (!priorState) return [];
|
|
182
|
+
const recipes = readRecipes(resolveBundleTransport(transport).recipesPath);
|
|
183
|
+
const groups = new Map(recipes.groups.map((group) => [group.id, group]));
|
|
184
|
+
const acceptedByGroup = new Map(priorState.groups.map((group) => [group.id, new Set(group.acceptedMembers)]));
|
|
185
|
+
const candidates = [];
|
|
186
|
+
for (const groupId of groupIds) {
|
|
187
|
+
const accepted = acceptedByGroup.get(groupId);
|
|
188
|
+
if (!accepted) continue;
|
|
189
|
+
const group = groups.get(groupId);
|
|
190
|
+
if (!group) continue;
|
|
191
|
+
const members = [...new Set(group[action].steps.flatMap((step) => step.members))]
|
|
192
|
+
.filter((member) => !SUPPORT_GROUPS.has(member));
|
|
193
|
+
for (const member of members) {
|
|
194
|
+
if (!accepted.has(member)) candidates.push({ groupId, groupName: group.displayName, member });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return candidates;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Prior acceptance for the new-member consent delta on explicit selections (picker, --group,
|
|
201
|
+
// --all). ABSENT memory is tolerated — a first-time selection needs no delta — but CORRUPT memory
|
|
202
|
+
// fails closed with the same clear error as the no-arg replay: guessing acceptance from a file we
|
|
203
|
+
// cannot read could silently install a member the user never consented to.
|
|
204
|
+
function loadPriorSelection(env) {
|
|
205
|
+
const path = selectionStatePath(env);
|
|
206
|
+
if (!statSync(path, { throwIfNoEntry: false })?.isFile()) return null;
|
|
207
|
+
return loadSelectionState(env).state;
|
|
208
|
+
}
|
|
209
|
+
|
|
32
210
|
export const selectorTerminalControl = Object.freeze({
|
|
33
211
|
enter: '\x1b[?1049h\x1b[?25l',
|
|
34
212
|
redraw: '\x1b[2J\x1b[H',
|
|
@@ -42,6 +220,23 @@ export function resolveBundleTransport({ recipesPath, localRoot } = {}) {
|
|
|
42
220
|
};
|
|
43
221
|
}
|
|
44
222
|
|
|
223
|
+
// The render target recorded in selection memory is declared by the recipes DOCUMENT, never
|
|
224
|
+
// inferred from the recipes path: inside the packaged @1aboveio/skills CLI the entrypoint injects
|
|
225
|
+
// the package's own staged recipes, which IS this file's default path, so a path comparison would
|
|
226
|
+
// mislabel every public-npm run as a private checkout. The value is validated against the known
|
|
227
|
+
// targets so a foreign or hand-written recipes file fails closed instead of recording a made-up
|
|
228
|
+
// channel.
|
|
229
|
+
const RENDER_TARGETS = new Set(['private-checkout', 'public-npm']);
|
|
230
|
+
|
|
231
|
+
function declaredRenderTarget(transport) {
|
|
232
|
+
const { recipesPath } = resolveBundleTransport(transport);
|
|
233
|
+
const recipes = readRecipes(recipesPath);
|
|
234
|
+
if (!RENDER_TARGETS.has(recipes.renderTarget)) {
|
|
235
|
+
throw new Error(`Bundle recipes at ${recipesPath} declare an unknown renderTarget: ${JSON.stringify(recipes.renderTarget)}`);
|
|
236
|
+
}
|
|
237
|
+
return recipes.renderTarget;
|
|
238
|
+
}
|
|
239
|
+
|
|
45
240
|
function readRecipes(recipesPath) {
|
|
46
241
|
let contents;
|
|
47
242
|
try {
|
|
@@ -65,7 +260,11 @@ export function bundleCatalog(transport = {}) {
|
|
|
65
260
|
.map((group) => ({
|
|
66
261
|
id: group.id,
|
|
67
262
|
displayName: group.displayName,
|
|
68
|
-
members:
|
|
263
|
+
// User-facing members only: the picker's count and Includes line are user-facing surfaces,
|
|
264
|
+
// and a support dependency (harness-runtime) is never presented as part of what a group
|
|
265
|
+
// offers — it installs silently with the group that needs it.
|
|
266
|
+
members: group.install.steps.flatMap((step) => step.members)
|
|
267
|
+
.filter((member) => !SUPPORT_GROUPS.has(member)),
|
|
69
268
|
}));
|
|
70
269
|
}
|
|
71
270
|
|
|
@@ -85,15 +284,24 @@ function localFirstPartyCommand(command, localRoot, groupId) {
|
|
|
85
284
|
return command.replace(NATIVE_ADD_SOURCE, (_match, prefix) => `${prefix}${shellArgument(localRoot)}`);
|
|
86
285
|
}
|
|
87
286
|
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
287
|
+
// The coordinator executes one native add PER SKILL (#1129) rather than coalescing a whole source
|
|
288
|
+
// into one invocation. A coalesced add can only attribute a failure to the entire source; per-skill
|
|
289
|
+
// adds let the run record exactly which skill failed, continue with the remaining skills, and keep
|
|
290
|
+
// only the succeeded skills in selection memory. Recipe step/member order is preserved
|
|
291
|
+
// (dependency-first), and a member shared by several selected groups (harness-runtime) is still
|
|
292
|
+
// planned exactly once, at its first occurrence. The pinned native CLI has no `--symlink` selector,
|
|
293
|
+
// so the coordinator adds `--yes` to choose its default symlink deployment without one installation-
|
|
294
|
+
// method prompt per skill. Attribution, ordering and deduplication remain coordinator-owned.
|
|
92
295
|
const SKILL_TAIL = /^(.*? --skill )(.+?)( && .*)?$/;
|
|
93
296
|
|
|
297
|
+
// The generated step binds its runtime suffix (` && npm ci ...`) to the whole coalesced command.
|
|
298
|
+
// Per skill it belongs to the runtime member's own add, so the dependency install runs only after
|
|
299
|
+
// engineering-runtime itself was actually added — never after a sibling, never when it was skipped.
|
|
300
|
+
const RUNTIME_MEMBER = 'engineering-runtime';
|
|
301
|
+
|
|
94
302
|
function skillsFromCommand(command) {
|
|
95
303
|
const match = SKILL_TAIL.exec(command);
|
|
96
|
-
if (!match) throw new Error(`Native add has no --skill list to
|
|
304
|
+
if (!match) throw new Error(`Native add has no --skill list to split: ${command}`);
|
|
97
305
|
return match[2].trim().split(/\s+/).filter(Boolean);
|
|
98
306
|
}
|
|
99
307
|
|
|
@@ -110,19 +318,17 @@ function withYesFlag(command) {
|
|
|
110
318
|
return `${match[1]}${match[2]} --yes${match[3] || ''}`;
|
|
111
319
|
}
|
|
112
320
|
|
|
113
|
-
function
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
merged.push(skill);
|
|
120
|
-
}
|
|
121
|
-
return merged;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export function planBundleCommands(action, groupIds, { yes = false, ...transport } = {}) {
|
|
321
|
+
export function planBundleCommands(action, groupIds, {
|
|
322
|
+
enableTdd = true,
|
|
323
|
+
skipMembers,
|
|
324
|
+
omitMembers,
|
|
325
|
+
...transport
|
|
326
|
+
} = {}) {
|
|
125
327
|
if (!ACTIONS.has(action)) throw new Error(`Unsupported bundle action: ${action}`);
|
|
328
|
+
// Members the user declined this run (#1128) are filtered out of every native --skill list.
|
|
329
|
+
// Support dependencies (harness-runtime) are never omittable: they are silent install
|
|
330
|
+
// dependencies, not user capabilities, so consent never applies to them.
|
|
331
|
+
const omit = new Set(omitMembers ?? []);
|
|
126
332
|
const { recipesPath, localRoot } = resolveBundleTransport(transport);
|
|
127
333
|
const recipes = readRecipes(recipesPath);
|
|
128
334
|
assertLocalRoot(localRoot);
|
|
@@ -133,11 +339,11 @@ export function planBundleCommands(action, groupIds, { yes = false, ...transport
|
|
|
133
339
|
if (!requested.includes(groupId)) requested.push(groupId);
|
|
134
340
|
}
|
|
135
341
|
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
const
|
|
140
|
-
const
|
|
342
|
+
// Plan from the selected groups (not recipes.all). A public package may expose only a subset of
|
|
343
|
+
// groups while still shipping a full recipes.all from the private catalog; using that all-plan
|
|
344
|
+
// would install skills that are not in the selection / not in the package.
|
|
345
|
+
const planned = [];
|
|
346
|
+
const plannedByMember = new Map();
|
|
141
347
|
|
|
142
348
|
for (const groupId of requested) {
|
|
143
349
|
const group = groups.get(groupId);
|
|
@@ -149,72 +355,227 @@ export function planBundleCommands(action, groupIds, { yes = false, ...transport
|
|
|
149
355
|
const command = step.sourceId === 'first-party'
|
|
150
356
|
? localFirstPartyCommand(generated.command, localRoot, groupId)
|
|
151
357
|
: generated.command;
|
|
152
|
-
|
|
358
|
+
// An explicit member subset (the digest skip) removes members from the native add but never
|
|
359
|
+
// reorders the rest: the remaining skills keep recipe order, support dependency first.
|
|
360
|
+
// Declined new members (#1128) are also filtered out, but consent never applies to support
|
|
361
|
+
// dependencies (harness-runtime): they are silent install dependencies, not capabilities.
|
|
362
|
+
const skills = skillsFromCommand(command)
|
|
363
|
+
.filter((skill) => !skipMembers?.has(skill))
|
|
364
|
+
.filter((skill) => SUPPORT_GROUPS.has(skill) || !omit.has(skill));
|
|
153
365
|
const suffix = SKILL_TAIL.exec(command)?.[3] || '';
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
366
|
+
const bare = suffix ? command.slice(0, command.length - suffix.length) : command;
|
|
367
|
+
for (const skill of skills) {
|
|
368
|
+
const existing = plannedByMember.get(skill);
|
|
369
|
+
if (existing) {
|
|
370
|
+
if (!existing.groupIds.includes(groupId)) existing.groupIds.push(groupId);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
let skillCommand = withSkills(bare, [skill]);
|
|
374
|
+
if (suffix && skill === RUNTIME_MEMBER) skillCommand = `${skillCommand}${suffix}`;
|
|
375
|
+
// The pinned CLI has no --symlink flag. --yes is its only supported way to select the
|
|
376
|
+
// default symlink deployment without presenting the install-method chooser.
|
|
377
|
+
skillCommand = withYesFlag(skillCommand);
|
|
378
|
+
const invocationPolicy = skill === 'tdd' && enableTdd
|
|
379
|
+
? undefined
|
|
380
|
+
: step.invocationPolicies?.find((candidate) => candidate.installName === skill)?.policy;
|
|
381
|
+
const entry = {
|
|
382
|
+
member: skill,
|
|
158
383
|
groupIds: [groupId],
|
|
159
|
-
|
|
384
|
+
groupId,
|
|
385
|
+
groupName: group.displayName,
|
|
160
386
|
sourceId: step.sourceId,
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
suffix,
|
|
387
|
+
...(invocationPolicy ? { invocationPolicy } : {}),
|
|
388
|
+
command: skillCommand,
|
|
164
389
|
cwd: localRoot,
|
|
165
390
|
failureMessage: step.onFailure.message,
|
|
166
|
-
}
|
|
167
|
-
|
|
391
|
+
};
|
|
392
|
+
plannedByMember.set(skill, entry);
|
|
393
|
+
planned.push(entry);
|
|
168
394
|
}
|
|
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
395
|
}
|
|
174
396
|
}
|
|
175
397
|
|
|
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
|
-
});
|
|
398
|
+
return planned;
|
|
190
399
|
}
|
|
191
400
|
|
|
401
|
+
// Continue-on-failure (#1129): every planned per-skill add is attempted in dependency order; a
|
|
402
|
+
// failed skill is recorded (its recipe failure message goes to stderr) and the run proceeds to the
|
|
403
|
+
// remaining skills. Nothing here stops early — the caller summarizes the buckets and decides the
|
|
404
|
+
// exit status, and selection persistence sees exactly which members succeeded.
|
|
192
405
|
export function runBundleCommands(commands, {
|
|
193
406
|
execute = (command, cwd) => spawnSync('/bin/bash', ['-lc', command], { cwd, stdio: 'inherit' }),
|
|
407
|
+
applyPolicy = applyInvocationPolicy,
|
|
408
|
+
skillsRoot,
|
|
194
409
|
stdout = process.stdout,
|
|
195
410
|
stderr = process.stderr,
|
|
196
411
|
} = {}) {
|
|
412
|
+
const succeeded = [];
|
|
413
|
+
const failed = [];
|
|
197
414
|
for (const planned of commands) {
|
|
198
|
-
stdout.write(`\nInstalling ${planned.
|
|
415
|
+
stdout.write(`\nInstalling ${planned.member} (${planned.sourceId})...\n`);
|
|
199
416
|
const result = execute(planned.command, planned.cwd);
|
|
200
|
-
if (result.status
|
|
201
|
-
|
|
202
|
-
|
|
417
|
+
if (result.status === 0) {
|
|
418
|
+
try {
|
|
419
|
+
if (planned.invocationPolicy) {
|
|
420
|
+
applyPolicy({
|
|
421
|
+
installName: planned.member,
|
|
422
|
+
policy: planned.invocationPolicy,
|
|
423
|
+
...(skillsRoot ? { skillsRoot } : {}),
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
succeeded.push(planned);
|
|
427
|
+
continue;
|
|
428
|
+
} catch (error) {
|
|
429
|
+
stderr.write(`Invocation policy failed for ${planned.member}: ${error.message}\n`);
|
|
430
|
+
failed.push({ planned, status: 1 });
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
203
433
|
}
|
|
434
|
+
stderr.write(`${planned.failureMessage}\n`);
|
|
435
|
+
failed.push({ planned, status: Number.isInteger(result.status) ? result.status : 1 });
|
|
204
436
|
}
|
|
205
|
-
return
|
|
437
|
+
return { succeeded, failed };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// The four run buckets, printed after every non-dry run. `unchanged` (digest-identical skip, #1127)
|
|
441
|
+
// and `declined` (new-member consent, #1128) render from empty lists until those units land — the
|
|
442
|
+
// summary contract is stable from here on, so both buckets are always shown.
|
|
443
|
+
function printRunSummary(stdout, { succeeded, failed, unchanged = [], declined = [] }) {
|
|
444
|
+
// The summary is a user-facing surface: support dependencies (harness-runtime) are planned and
|
|
445
|
+
// executed like any member, but they are dependency plumbing and are never named in a bucket.
|
|
446
|
+
const visible = (name) => !SUPPORT_GROUPS.has(name);
|
|
447
|
+
const render = (names) => (names.length > 0 ? names.join(', ') : '(none)');
|
|
448
|
+
stdout.write('\nRun summary:\n');
|
|
449
|
+
stdout.write(` succeeded: ${render(succeeded.map((planned) => planned.member).filter(visible))}\n`);
|
|
450
|
+
stdout.write(` unchanged: ${render(unchanged.filter(visible))}\n`);
|
|
451
|
+
stdout.write(` declined: ${render(declined.filter(visible))}\n`);
|
|
452
|
+
stdout.write(` failed: ${render(failed.map((entry) => entry.planned.member).filter(visible))}\n`);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// --- Digest classification (epic #1124 / issue #1127) --------------------------------------------
|
|
456
|
+
//
|
|
457
|
+
// An `update` that re-adds a skill whose installed content already digests to what this invocation
|
|
458
|
+
// would install buys nothing and replaces identical bytes. So update classifies every accepted
|
|
459
|
+
// member first and omits the digest-equal ones from the native add. The native CLI remains the sole
|
|
460
|
+
// write authority; the coordinator may not copy, link, or overwrite skill trees itself.
|
|
461
|
+
//
|
|
462
|
+
// The digest semantics are borrowed, never re-invented: first-party members digest their PUBLISHED
|
|
463
|
+
// content (`published: true`) on both transports, external members digest the exact reviewed tree,
|
|
464
|
+
// exactly as release coherence validates them. And a member with NO expected digest — Impeccable is
|
|
465
|
+
// the one on tip — can never be `unchanged`: there is nothing to compare against, so an installed
|
|
466
|
+
// one refreshes and an absent one is missing.
|
|
467
|
+
|
|
468
|
+
// The pure verdict, separated from the filesystem so the whole table is testable in one place.
|
|
469
|
+
export function classifyMember({ installed, expectedDigest, actualDigest }) {
|
|
470
|
+
if (!installed) return 'missing';
|
|
471
|
+
if (typeof expectedDigest !== 'string' || expectedDigest.length === 0) return 'refresh';
|
|
472
|
+
return actualDigest === expectedDigest ? 'unchanged' : 'refresh';
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// What this run expects a member to digest to. First-party expectation is computed from THIS
|
|
476
|
+
// invocation's local source root (the private checkout or the unpacked package — both stage the
|
|
477
|
+
// published file set, so the published-scope digest is the same on either transport); an external
|
|
478
|
+
// member's expectation is the reviewed tree digest the recipe carries, or null when it carries none.
|
|
479
|
+
export function expectedMemberDigest(source, member, { localRoot }) {
|
|
480
|
+
if (firstPartySource(source)) {
|
|
481
|
+
return contentDigest(join(localRoot, member.path), { published: true });
|
|
482
|
+
}
|
|
483
|
+
return typeof member?.contentDigest === 'string' ? member.contentDigest : null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Classify one installed member directory against its expectation. `skillsRoot` is the canonical
|
|
487
|
+
// installed store (`~/.agents/skills`); the member lives under it by installName.
|
|
488
|
+
export function classifyInstalledMember(source, member, { enableTdd = true, localRoot, skillsRoot }) {
|
|
489
|
+
const directory = join(skillsRoot, member.installName);
|
|
490
|
+
const entry = { member: member.installName, sourceId: source?.id ?? null, directory };
|
|
491
|
+
if (!statSync(directory, { throwIfNoEntry: false })?.isDirectory()) {
|
|
492
|
+
return { ...entry, status: 'missing' };
|
|
493
|
+
}
|
|
494
|
+
const expectedDigest = expectedMemberDigest(source, member, { localRoot });
|
|
495
|
+
let actualDigest;
|
|
496
|
+
try {
|
|
497
|
+
const policyInjections = member.installName === 'tdd' && member.invocationPolicy && !enableTdd
|
|
498
|
+
? reviewedSourceInjections(directory, member.invocationPolicy)
|
|
499
|
+
: [];
|
|
500
|
+
actualDigest = contentDigest(directory, {
|
|
501
|
+
injected: policyInjections,
|
|
502
|
+
published: firstPartySource(source),
|
|
503
|
+
});
|
|
504
|
+
} catch {
|
|
505
|
+
return { ...entry, status: 'refresh', expectedDigest, actualDigest: null };
|
|
506
|
+
}
|
|
507
|
+
return {
|
|
508
|
+
...entry,
|
|
509
|
+
status: classifyMember({ installed: true, expectedDigest, actualDigest }),
|
|
510
|
+
expectedDigest,
|
|
511
|
+
actualDigest,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Every member the selected groups would install/update, classified in recipe step order and
|
|
516
|
+
// deduplicated across groups (the shared support runtime classifies once). The buckets drive both
|
|
517
|
+
// the planner's skip set and the dry-run/no-op rendering. A member the recipe names but its source
|
|
518
|
+
// does not declare gets no expected digest — refresh-or-missing, never a silent skip.
|
|
519
|
+
export function classifySelectedMembers(action, groupIds, { enableTdd = true, skillsRoot, ...transport } = {}) {
|
|
520
|
+
if (!ACTIONS.has(action)) throw new Error(`Unsupported bundle action: ${action}`);
|
|
521
|
+
const { recipesPath, localRoot } = resolveBundleTransport(transport);
|
|
522
|
+
const recipes = readRecipes(recipesPath);
|
|
523
|
+
assertLocalRoot(localRoot);
|
|
524
|
+
const sourcesById = new Map((recipes.sources ?? []).map((source) => [source.id, source]));
|
|
525
|
+
const groups = new Map(recipes.groups.map((group) => [group.id, group]));
|
|
526
|
+
const seen = new Set();
|
|
527
|
+
const buckets = { unchanged: [], refresh: [], missing: [] };
|
|
528
|
+
for (const groupId of groupIds) {
|
|
529
|
+
const group = groups.get(groupId);
|
|
530
|
+
if (!group || SUPPORT_GROUPS.has(groupId)) throw new Error(`Unknown bundle: ${groupId}`);
|
|
531
|
+
for (const step of group[action].steps) {
|
|
532
|
+
const source = sourcesById.get(step.sourceId);
|
|
533
|
+
const membersByName = new Map((source?.members ?? []).map((member) => [member.installName, member]));
|
|
534
|
+
for (const installName of step.members) {
|
|
535
|
+
if (seen.has(installName)) continue;
|
|
536
|
+
seen.add(installName);
|
|
537
|
+
const member = membersByName.get(installName) ?? { installName };
|
|
538
|
+
const entry = classifyInstalledMember(source, member, { enableTdd, localRoot, skillsRoot });
|
|
539
|
+
buckets[entry.status].push(entry);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return buckets;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function printUpdateBuckets(stdout, buckets) {
|
|
547
|
+
// Bucket listings are user-facing: the support runtime classifies with everything else (it
|
|
548
|
+
// drives the planner's skip set) but is never named to the user.
|
|
549
|
+
const visible = (entry) => !SUPPORT_GROUPS.has(entry.member);
|
|
550
|
+
const unchanged = buckets.unchanged.filter(visible).map((entry) => entry.member);
|
|
551
|
+
const refresh = [
|
|
552
|
+
...buckets.refresh.filter(visible).map((entry) => entry.member),
|
|
553
|
+
// Missing accepted members are refresh candidates (reinstall), not "new" — they belong to the
|
|
554
|
+
// would-refresh bucket, annotated so a dry run explains why an absent skill will be added.
|
|
555
|
+
...buckets.missing.filter(visible).map((entry) => `${entry.member} (not installed)`),
|
|
556
|
+
];
|
|
557
|
+
stdout.write(`Unchanged (installed content matches): ${unchanged.length > 0 ? unchanged.join(', ') : 'none'}\n`);
|
|
558
|
+
stdout.write(`Would refresh: ${refresh.length > 0 ? refresh.join(', ') : 'none'}\n`);
|
|
206
559
|
}
|
|
207
560
|
|
|
208
561
|
function usage() {
|
|
209
562
|
return `Usage:
|
|
210
563
|
node skills/distribution/scripts/bundles.mjs list [--recipes <path>]
|
|
211
|
-
node skills/distribution/scripts/bundles.mjs <install|update> [--group <id> ... | --all] [--dry-run] [--yes] [--recipes <path>] [--local-root <path>]
|
|
564
|
+
node skills/distribution/scripts/bundles.mjs <install|update> [--group <id> ... | --all] [--enable-tdd|--disable-tdd] [--dry-run] [--yes] [--recipes <path>] [--local-root <path>]
|
|
565
|
+
|
|
566
|
+
install without --group/--all opens the interactive picker; update without them replays the
|
|
567
|
+
remembered selection (\${XDG_STATE_HOME:-~/.local/state}/1aboveio-skills/selection.json).
|
|
568
|
+
When a remembered group's current recipe declares members you have not accepted yet, each new skill
|
|
569
|
+
is offered individually and defaults to No; --yes accepts them all, --dry-run lists them without
|
|
570
|
+
prompting. TDD implicit model invocation is enabled by default; --disable-tdd makes TDD explicit-only,
|
|
571
|
+
and --enable-tdd restores implicit invocation. --manual-tdd remains a compatibility alias for
|
|
572
|
+
--disable-tdd.
|
|
212
573
|
`;
|
|
213
574
|
}
|
|
214
575
|
|
|
215
576
|
function parseArgs(args) {
|
|
216
577
|
const [action = 'install', ...options] = args;
|
|
217
|
-
const parsed = { action, groupIds: [], all: false, dryRun: false, yes: false };
|
|
578
|
+
const parsed = { action, groupIds: [], all: false, dryRun: false, yes: false, tddMode: null };
|
|
218
579
|
for (let index = 0; index < options.length; index += 1) {
|
|
219
580
|
const option = options[index];
|
|
220
581
|
if (option === '--group') {
|
|
@@ -230,9 +591,15 @@ function parseArgs(args) {
|
|
|
230
591
|
} else if (option === '--all') parsed.all = true;
|
|
231
592
|
else if (option === '--dry-run') parsed.dryRun = true;
|
|
232
593
|
else if (option === '--yes') parsed.yes = true;
|
|
594
|
+
else if (option === '--enable-tdd') parsed.tddMode = 'implicit';
|
|
595
|
+
else if (option === '--disable-tdd' || option === '--manual-tdd') parsed.tddMode = 'manual-only';
|
|
233
596
|
else throw new Error(`Unknown option: ${option}`);
|
|
234
597
|
}
|
|
235
598
|
if (parsed.all && parsed.groupIds.length > 0) throw new Error('Use either --all or --group, not both');
|
|
599
|
+
const tddOptions = options.filter((option) => ['--enable-tdd', '--disable-tdd', '--manual-tdd'].includes(option));
|
|
600
|
+
if (tddOptions.length > 1) {
|
|
601
|
+
throw new Error('Use only one of --enable-tdd or --disable-tdd');
|
|
602
|
+
}
|
|
236
603
|
return parsed;
|
|
237
604
|
}
|
|
238
605
|
|
|
@@ -310,35 +677,216 @@ async function promptForGroups(catalog, { stdin = process.stdin, stdout = proces
|
|
|
310
677
|
});
|
|
311
678
|
}
|
|
312
679
|
|
|
313
|
-
|
|
680
|
+
// A minimal line prompt on the injected streams — deliberately separate from the raw-mode group
|
|
681
|
+
// picker: this is a sequence of independent yes/no questions, not a multi-select. EOF or an empty
|
|
682
|
+
// line is the default answer (No), so a closed or ended stdin can never accept a new skill.
|
|
683
|
+
function createLinePrompter({ stdin, stdout }) {
|
|
684
|
+
let buffer = '';
|
|
685
|
+
let ended = false;
|
|
686
|
+
const waiters = [];
|
|
687
|
+
const flush = () => {
|
|
688
|
+
while (waiters.length > 0) {
|
|
689
|
+
const newline = buffer.search(/\r?\n/);
|
|
690
|
+
if (newline !== -1) {
|
|
691
|
+
const line = buffer.slice(0, newline);
|
|
692
|
+
buffer = buffer.slice(newline).replace(/^\r?\n/, '');
|
|
693
|
+
waiters.shift()(line.trim());
|
|
694
|
+
} else if (ended) {
|
|
695
|
+
const rest = buffer;
|
|
696
|
+
buffer = '';
|
|
697
|
+
waiters.shift()(rest.trim());
|
|
698
|
+
} else {
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
stdin.on('data', (chunk) => { buffer += String(chunk); flush(); });
|
|
704
|
+
stdin.on('end', () => { ended = true; flush(); });
|
|
705
|
+
stdin.resume?.();
|
|
706
|
+
return {
|
|
707
|
+
ask(question) {
|
|
708
|
+
stdout.write(question);
|
|
709
|
+
return new Promise((answer) => { waiters.push(answer); flush(); });
|
|
710
|
+
},
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Offer each newly declared member individually and return the DECLINED member names (#1128). The
|
|
715
|
+
// default is No: --yes accepts all without prompting; a non-interactive stdin declines all with a
|
|
716
|
+
// note rather than guessing consent; interactively, anything but an explicit y/yes declines.
|
|
717
|
+
async function consentNewMembers(candidates, { yes, stdin, stdout, stderr }) {
|
|
718
|
+
const declined = new Set();
|
|
719
|
+
if (yes) {
|
|
720
|
+
for (const candidate of candidates) {
|
|
721
|
+
stdout.write(`Accepting new skill "${candidate.member}" in ${candidate.groupName} (--yes)\n`);
|
|
722
|
+
}
|
|
723
|
+
return declined;
|
|
724
|
+
}
|
|
725
|
+
if (!stdin.isTTY) {
|
|
726
|
+
for (const candidate of candidates) {
|
|
727
|
+
stderr.write(`Declining new skill "${candidate.member}" in ${candidate.groupName}: no interactive terminal (default No; re-run with --yes to accept)\n`);
|
|
728
|
+
declined.add(candidate.member);
|
|
729
|
+
}
|
|
730
|
+
return declined;
|
|
731
|
+
}
|
|
732
|
+
const prompter = createLinePrompter({ stdin, stdout });
|
|
733
|
+
for (const candidate of candidates) {
|
|
734
|
+
const answer = await prompter.ask(`Add new skill "${candidate.member}" to ${candidate.groupName} (${candidate.groupId})? [y/N] `);
|
|
735
|
+
if (/^y(es)?$/i.test(answer)) {
|
|
736
|
+
stdout.write(`Adding ${candidate.member}\n`);
|
|
737
|
+
} else {
|
|
738
|
+
stdout.write(`Skipping ${candidate.member}\n`);
|
|
739
|
+
declined.add(candidate.member);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
return declined;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function selectionIncludesTdd(groupIds, catalog) {
|
|
746
|
+
const selected = new Set(groupIds);
|
|
747
|
+
return catalog.some((bundle) => selected.has(bundle.id) && bundle.members.includes('tdd'));
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Dependencies are injectable so the coordinator seam can be tested black-box: a stubbed native
|
|
751
|
+
// executor, controllable TTY streams, and a fixture XDG environment, without reaching process
|
|
752
|
+
// globals. The default execute is the real shell out to the native Skills CLI — the sole
|
|
753
|
+
// lifecycle write authority; the coordinator only selects, plans, invokes, and persists memory
|
|
754
|
+
// AFTER those native commands settle.
|
|
755
|
+
export async function runCli(args, {
|
|
756
|
+
execute,
|
|
757
|
+
applyPolicy,
|
|
758
|
+
stdin = process.stdin,
|
|
759
|
+
stdout = process.stdout,
|
|
760
|
+
stderr = process.stderr,
|
|
761
|
+
env = process.env,
|
|
762
|
+
} = {}) {
|
|
314
763
|
try {
|
|
315
764
|
const parsed = parseArgs(args);
|
|
316
765
|
const transport = { recipesPath: parsed.recipesPath, localRoot: parsed.localRoot };
|
|
317
766
|
if (parsed.action === 'list') {
|
|
318
|
-
printCatalog(
|
|
767
|
+
printCatalog(stdout, bundleCatalog(transport));
|
|
319
768
|
return 0;
|
|
320
769
|
}
|
|
321
770
|
if (!ACTIONS.has(parsed.action)) {
|
|
322
|
-
|
|
771
|
+
stderr.write(usage());
|
|
323
772
|
return 2;
|
|
324
773
|
}
|
|
325
774
|
const catalog = bundleCatalog(transport);
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
775
|
+
let groupIds;
|
|
776
|
+
let priorState = null;
|
|
777
|
+
if (parsed.all) {
|
|
778
|
+
groupIds = catalog.map((bundle) => bundle.id);
|
|
779
|
+
priorState = loadPriorSelection(env);
|
|
780
|
+
} else if (parsed.groupIds.length > 0) {
|
|
781
|
+
groupIds = parsed.groupIds;
|
|
782
|
+
priorState = loadPriorSelection(env);
|
|
783
|
+
} else if (parsed.action === 'update') {
|
|
784
|
+
// No-arg update replays the remembered selection. It never opens the picker and never
|
|
785
|
+
// infers groups from the filesystem: missing/corrupt/foreign memory is a hard error.
|
|
786
|
+
const { state } = loadSelectionState(env);
|
|
787
|
+
priorState = state;
|
|
788
|
+
groupIds = rememberedGroupIds(state, catalog, stderr);
|
|
789
|
+
} else {
|
|
790
|
+
groupIds = await promptForGroups(catalog, { stdin, stdout });
|
|
791
|
+
priorState = loadPriorSelection(env);
|
|
792
|
+
}
|
|
331
793
|
if (groupIds.length === 0) throw new Error('Select at least one bundle');
|
|
332
|
-
const
|
|
794
|
+
const includesTdd = selectionIncludesTdd(groupIds, catalog);
|
|
795
|
+
if (parsed.tddMode && !includesTdd) {
|
|
796
|
+
throw new Error(`${parsed.tddMode === 'implicit' ? '--enable-tdd' : '--disable-tdd'} requires a selected bundle containing tdd`);
|
|
797
|
+
}
|
|
798
|
+
let enableTdd = typeof priorState?.tddImplicitInvocation === 'boolean'
|
|
799
|
+
? priorState.tddImplicitInvocation
|
|
800
|
+
: true;
|
|
801
|
+
if (includesTdd) {
|
|
802
|
+
if (parsed.tddMode) enableTdd = parsed.tddMode === 'implicit';
|
|
803
|
+
stdout.write(`TDD invocation: ${enableTdd ? 'enabled' : 'disabled'}\n`);
|
|
804
|
+
}
|
|
805
|
+
// New-member consent (#1128): newly declared user-facing members of a group with prior
|
|
806
|
+
// acceptance are offered one at a time, defaulting to No. A dry run only LISTS the would-be
|
|
807
|
+
// candidates — it never prompts and never writes state.
|
|
808
|
+
const candidates = newMemberCandidates(parsed.action, groupIds, priorState, transport);
|
|
809
|
+
let declined = new Set();
|
|
810
|
+
if (candidates.length > 0 && parsed.dryRun) {
|
|
811
|
+
for (const candidate of candidates) {
|
|
812
|
+
stdout.write(`New skill "${candidate.member}" in ${candidate.groupName} (${candidate.groupId}) — would prompt for consent (default: No); --yes accepts all new skills\n`);
|
|
813
|
+
}
|
|
814
|
+
} else if (candidates.length > 0) {
|
|
815
|
+
declined = await consentNewMembers(candidates, { yes: parsed.yes, stdin, stdout, stderr });
|
|
816
|
+
}
|
|
817
|
+
// Digest skip (#1127): `update` classifies the accepted members against the installed tree and
|
|
818
|
+
// omits the digest-equal ones from the native add. `install` never skips — it is an explicit
|
|
819
|
+
// request to install/replace the selected members. Declined new members (#1128) are excluded
|
|
820
|
+
// from classification: they never run, so they are not buckets.
|
|
821
|
+
let buckets;
|
|
822
|
+
let skipMembers;
|
|
823
|
+
const skillsRoot = join(env.HOME || homedir(), '.agents', 'skills');
|
|
824
|
+
if (parsed.action === 'update') {
|
|
825
|
+
buckets = classifySelectedMembers(parsed.action, groupIds, { ...transport, enableTdd, skillsRoot });
|
|
826
|
+
skipMembers = new Set(buckets.unchanged.map((entry) => entry.member));
|
|
827
|
+
if (declined.size > 0) {
|
|
828
|
+
for (const key of Object.keys(buckets)) {
|
|
829
|
+
buckets[key] = buckets[key].filter((entry) => !declined.has(entry.member));
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
const commands = planBundleCommands(parsed.action, groupIds, {
|
|
834
|
+
...transport,
|
|
835
|
+
enableTdd,
|
|
836
|
+
skipMembers,
|
|
837
|
+
omitMembers: declined,
|
|
838
|
+
});
|
|
333
839
|
if (parsed.dryRun) {
|
|
840
|
+
if (buckets) printUpdateBuckets(stdout, buckets);
|
|
334
841
|
for (const planned of commands) {
|
|
335
|
-
|
|
842
|
+
stdout.write(`\n${planned.groupName} / ${planned.sourceId} / ${planned.member}\n${planned.command}\n`);
|
|
336
843
|
}
|
|
844
|
+
if (buckets && commands.length === 0) stdout.write('Nothing to do: every accepted skill is already up to date.\n');
|
|
337
845
|
return 0;
|
|
338
846
|
}
|
|
339
|
-
|
|
847
|
+
if (commands.length === 0 && buckets) {
|
|
848
|
+
stdout.write('All accepted skills are already up to date; nothing to do.\n');
|
|
849
|
+
} else if (buckets?.unchanged.length > 0) {
|
|
850
|
+
const skipping = buckets.unchanged.map((entry) => entry.member).filter((member) => !SUPPORT_GROUPS.has(member));
|
|
851
|
+
if (skipping.length > 0) stdout.write(`Up to date, skipping: ${skipping.join(', ')}\n`);
|
|
852
|
+
}
|
|
853
|
+
const { succeeded, failed } = runBundleCommands(commands, {
|
|
854
|
+
execute,
|
|
855
|
+
applyPolicy,
|
|
856
|
+
skillsRoot,
|
|
857
|
+
stdout,
|
|
858
|
+
stderr,
|
|
859
|
+
});
|
|
860
|
+
printRunSummary(stdout, {
|
|
861
|
+
succeeded,
|
|
862
|
+
failed,
|
|
863
|
+
unchanged: buckets?.unchanged.map((entry) => entry.member),
|
|
864
|
+
declined: [...declined],
|
|
865
|
+
});
|
|
866
|
+
// Mixed-success memory (#1129): retain only user-facing members that either succeeded at
|
|
867
|
+
// native add or were already digest-equal. A group with no recordable member is omitted from
|
|
868
|
+
// the written set, and a run with no recordable member at all leaves prior memory untouched;
|
|
869
|
+
// a decline never becomes remembered acceptance.
|
|
870
|
+
const succeededMembers = [
|
|
871
|
+
...succeeded.map((planned) => planned.member),
|
|
872
|
+
...(buckets?.unchanged.map((entry) => entry.member) ?? []),
|
|
873
|
+
];
|
|
874
|
+
if (succeededMembers.length > 0) {
|
|
875
|
+
const renderTarget = declaredRenderTarget(transport);
|
|
876
|
+
const selection = selectionForRun(parsed.action, groupIds, {
|
|
877
|
+
...transport,
|
|
878
|
+
renderTarget,
|
|
879
|
+
succeededMembers,
|
|
880
|
+
omitMembers: declined,
|
|
881
|
+
tddImplicitInvocation: enableTdd,
|
|
882
|
+
});
|
|
883
|
+
if (selection.groups.length > 0) {
|
|
884
|
+
writeSelectionState(selection, env);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
return failed.length > 0 ? failed[0].status : 0;
|
|
340
888
|
} catch (error) {
|
|
341
|
-
|
|
889
|
+
stderr.write(`${error.message}\n`);
|
|
342
890
|
return 2;
|
|
343
891
|
}
|
|
344
892
|
}
|