@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.
@@ -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: group.install.steps.flatMap((step) => step.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
- // Native `skills add` prompts once per invocation for the installation method. Selecting several
89
- // bundles used to emit one add per group, so interactive installs asked the same question N times.
90
- // Coalesce by sourceId: one native add per upstream, with the union of --skill names, in the order
91
- // sources first appear across the selection. Identical full commands still collapse as before.
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 coalesce: ${command}`);
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 mergeSkillLists(existing, incoming) {
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 } = {}) {
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
- // Always coalesce from the selected groups (not recipes.all). A public package may expose only a
137
- // subset of groups while still shipping a full recipes.all from the private catalog; using that
138
- // all-plan would install skills that are not in the selection / not in the package.
139
- const bySource = new Map();
140
- const sourceOrder = [];
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
- const skills = skillsFromCommand(command);
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 existing = bySource.get(step.sourceId);
155
- if (!existing) {
156
- sourceOrder.push(step.sourceId);
157
- bySource.set(step.sourceId, {
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
- groupNames: [group.displayName],
384
+ groupId,
385
+ groupName: group.displayName,
160
386
  sourceId: step.sourceId,
161
- template: command,
162
- skills: [...skills],
163
- suffix,
387
+ ...(invocationPolicy ? { invocationPolicy } : {}),
388
+ command: skillCommand,
164
389
  cwd: localRoot,
165
390
  failureMessage: step.onFailure.message,
166
- });
167
- continue;
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 sourceOrder.map((sourceId) => {
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.groupName} (${planned.sourceId})...\n`);
415
+ stdout.write(`\nInstalling ${planned.member} (${planned.sourceId})...\n`);
199
416
  const result = execute(planned.command, planned.cwd);
200
- if (result.status !== 0) {
201
- stderr.write(`${planned.failureMessage}\n`);
202
- return Number.isInteger(result.status) ? result.status : 1;
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 0;
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
- export async function runCli(args) {
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(process.stdout, bundleCatalog(transport));
767
+ printCatalog(stdout, bundleCatalog(transport));
319
768
  return 0;
320
769
  }
321
770
  if (!ACTIONS.has(parsed.action)) {
322
- process.stderr.write(usage());
771
+ stderr.write(usage());
323
772
  return 2;
324
773
  }
325
774
  const catalog = bundleCatalog(transport);
326
- const groupIds = parsed.all
327
- ? catalog.map((bundle) => bundle.id)
328
- : parsed.groupIds.length > 0
329
- ? parsed.groupIds
330
- : await promptForGroups(catalog);
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 commands = planBundleCommands(parsed.action, groupIds, { ...transport, yes: parsed.yes });
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
- process.stdout.write(`\n${planned.groupName} / ${planned.sourceId}\n${planned.command}\n`);
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
- return runBundleCommands(commands);
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
- process.stderr.write(`${error.message}\n`);
889
+ stderr.write(`${error.message}\n`);
342
890
  return 2;
343
891
  }
344
892
  }