@damiangilgonzalez/harness 1.3.12 → 1.3.14
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 -11
- package/manifest.json +10 -2
- package/package.json +3 -2
- package/scripts/doctor.js +122 -6
- package/scripts/install-apply.js +195 -14
- package/scripts/lib/backups.js +13 -0
- package/scripts/lib/capabilities.js +19 -2
- package/scripts/lib/divergence.js +8 -1
- package/scripts/lib/onboarding-apply.js +91 -13
- package/scripts/lib/onboarding.js +35 -2
- package/scripts/lib/repair.js +8 -4
- package/scripts/lib/review-findings.js +175 -0
- package/scripts/lib/versions.js +31 -0
- package/scripts/rsc.js +117 -19
- package/skills/animar/SKILL.md +1 -0
- package/skills/claude-project-setup/SKILL.md +1 -0
- package/skills/code-review/SKILL.md +18 -1
- package/skills/diseno-apple/SKILL.md +1 -0
- package/skills/diseno-landing/SKILL.md +1 -0
- package/skills/domain-modeling/SKILL.md +1 -0
- package/skills/flujo-interfaz/SKILL.md +1 -0
- package/skills/grill-me/SKILL.md +1 -0
- package/skills/grill-with-docs/SKILL.md +1 -0
- package/skills/grilling/SKILL.md +1 -0
- package/skills/handoff/SKILL.md +1 -0
- package/skills/harness/SKILL.md +6 -0
- package/skills/harness/references/wiki-protocol.md +5 -0
- package/skills/implement/SKILL.md +1 -0
- package/skills/ingeniero-diseno-web/SKILL.md +1 -0
- package/skills/init/references/accompaniment-and-profile.md +1 -1
- package/skills/leyes-de-percepcion/SKILL.md +1 -0
- package/skills/leyes-de-retencion/SKILL.md +1 -0
- package/skills/mejor-accesibilidad/SKILL.md +1 -0
- package/skills/mejor-colores/SKILL.md +1 -0
- package/skills/mejor-layout/SKILL.md +1 -0
- package/skills/mejor-redaccion/SKILL.md +1 -0
- package/skills/mejor-tipografia/SKILL.md +1 -0
- package/skills/mejor-ui/SKILL.md +1 -0
- package/skills/muscle-memory/SKILL.md +1 -0
- package/skills/orient/SKILL.md +9 -1
- package/skills/orient/references/orientation-contract.md +1 -1
- package/skills/prototype/SKILL.md +1 -0
- package/skills/research/SKILL.md +1 -0
- package/skills/revision-de-cambios/SKILL.md +1 -0
- package/skills/revision-interfaz/SKILL.md +1 -0
- package/skills/sitios-calidad-premio/SKILL.md +1 -0
- package/skills/suggest/SKILL.md +10 -9
- package/skills/tastemaker/SKILL.md +1 -0
- package/skills/teach/SKILL.md +1 -0
- package/skills/to-questionnaire/SKILL.md +1 -0
- package/skills/to-spec/SKILL.md +1 -0
- package/skills/to-tickets/SKILL.md +1 -0
- package/skills/video-a-superprompt/SKILL.md +1 -0
- package/skills/vocabulario-animacion/SKILL.md +1 -0
- package/skills/wait-what/SKILL.md +1 -0
- package/skills/wayfinder/SKILL.md +1 -0
- package/skills/write-adr/SKILL.md +1 -0
- package/skills/writing-for-agents/SKILL.md +1 -0
- package/targets/_md-block.js +8 -1
- package/targets/agents-md-shadow.js +153 -0
- package/targets/agents.js +46 -3
- package/targets/claude.js +76 -26
- package/targets/clone-bootstrap.mjs +454 -0
- package/targets/gitmoji-guard.mjs +30 -3
- package/targets/hook-once.mjs +1 -0
- package/targets/memory.js +1 -1
- package/targets/opt-outs.js +40 -0
- package/targets/sello.mjs +2 -2
- package/targets/session-memory-adapter.mjs +70 -14
- package/targets/session-memory-core.mjs +40 -8
- package/targets/session-start.mjs +65 -12
- package/targets/ship-guard.mjs +1 -1
- package/targets/worktree-reaper.mjs +175 -11
package/scripts/install-apply.js
CHANGED
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
import { rmSync, existsSync, cpSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, appendFileSync, readdirSync } from 'node:fs';
|
|
3
3
|
import { join, dirname, relative, resolve, sep } from 'node:path';
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
5
6
|
import { planInstall } from './install-plan.js';
|
|
6
7
|
import { targetPaths, writeSkill, wireHook, unwireHook, baseDir, TARGET_IDS } from '../targets/index.js';
|
|
8
|
+
import { shadowTarget } from '../targets/agents-md-shadow.js';
|
|
7
9
|
import {
|
|
8
10
|
targetHasAgents, reconcileAgents, agentPath, agentNames,
|
|
9
|
-
resolveAgentNames, agentByName, allAgentNames, readDeveloperTier,
|
|
11
|
+
resolveAgentNames, agentByName, allAgentNames, readDeveloperTier, writeDeveloperTier,
|
|
10
12
|
} from '../targets/agents.js';
|
|
13
|
+
import { projectOptOuts } from '../targets/opt-outs.js';
|
|
11
14
|
import { readState, writeState } from './lib/state.js';
|
|
15
|
+
import { withDefaultSkillFloor } from './lib/default-skill-floor.js';
|
|
12
16
|
import { readManifest, writeManifest } from './lib/manifest-file.js';
|
|
13
17
|
import { createBackup } from './lib/backups.js';
|
|
14
18
|
import {
|
|
@@ -66,14 +70,20 @@ function ensureBase(id, cwd, baseVersions) {
|
|
|
66
70
|
export function generatedHookFiles({ target, cwd, policy }) {
|
|
67
71
|
if (target !== 'claude') return [];
|
|
68
72
|
const lifecycle = [
|
|
73
|
+
// Committed, not machine state — and therefore the one entry here that, if omitted, leaves an
|
|
74
|
+
// executable behind in a tree the user pushes.
|
|
75
|
+
join(cwd, '.claude', 'rsc-bootstrap.mjs'),
|
|
69
76
|
join(cwd, '.rsc', 'session-start.mjs'),
|
|
70
77
|
join(cwd, '.rsc', 'worklog-checkpoint.mjs'),
|
|
71
78
|
join(cwd, '.rsc', 'hook-once.mjs'),
|
|
72
79
|
join(cwd, '.rsc', 'worktree-reaper.mjs'),
|
|
73
80
|
];
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
// The danger guard is declared on its own terms (#273): present unless the plan says otherwise,
|
|
82
|
+
// whether or not the code guards are.
|
|
83
|
+
const danger = policy?.dangerGuard === false ? [] : [join(cwd, '.rsc', 'danger-guard.mjs')];
|
|
84
|
+
if (policy?.codeHooks === false) return [...lifecycle, ...danger, join(cwd, '.rsc', 'suggest-always-on.md')];
|
|
85
|
+
return [...lifecycle, ...danger,
|
|
86
|
+
join(cwd, '.rsc', 'ship-guard.mjs'),
|
|
77
87
|
join(cwd, '.rsc', 'gitmoji-guard.mjs'), join(cwd, '.rsc', 'userprompt-gate.mjs'),
|
|
78
88
|
join(cwd, '.rsc', 'sello.mjs')];
|
|
79
89
|
}
|
|
@@ -109,6 +119,12 @@ export function managedPathsForInstall({ skillIds, agentIds = [], target, home,
|
|
|
109
119
|
out.push(step.to, ...generatedHookFiles({ target, cwd, policy }));
|
|
110
120
|
}
|
|
111
121
|
}
|
|
122
|
+
// The shadow CLAUDE.md that keeps a natively-read AGENTS.md from doubling the always-on body.
|
|
123
|
+
// Written by whichever of the two adapters is wired second, so it is declared for both families,
|
|
124
|
+
// not just for claude. A claude install must assume its own wiring: settings.json does not name
|
|
125
|
+
// it yet at plan time.
|
|
126
|
+
const shadow = shadowTarget(cwd, { assumeClaudeWired: target === 'claude' });
|
|
127
|
+
if (shadow) out.push(shadow);
|
|
112
128
|
return [...new Set(out)];
|
|
113
129
|
}
|
|
114
130
|
|
|
@@ -116,17 +132,56 @@ export function managedPathsForInstall({ skillIds, agentIds = [], target, home,
|
|
|
116
132
|
// which is machine-local and therefore lost on every clone: a team that disarmed the
|
|
117
133
|
// gitmoji guard found it armed again on the next checkout, with nobody having decided
|
|
118
134
|
// that. They are decisions, so they belong in the committed declaration.
|
|
135
|
+
//
|
|
136
|
+
// Two distinctions this function exists to keep, both of which used to be collapsed:
|
|
137
|
+
//
|
|
138
|
+
// ABSENT is not EMPTY. In a clone `.rsc/` does not exist, and reading "no markers here" as
|
|
139
|
+
// "the team re-armed everything" would have sync quietly delete the decision it came to
|
|
140
|
+
// rebuild. Unreadable returns null — "I know nothing" — and the caller keeps what was
|
|
141
|
+
// declared. A readable directory with no markers returns [], which IS a statement: somebody
|
|
142
|
+
// deleted the last marker, and deleting it is how a gate gets re-armed.
|
|
143
|
+
//
|
|
144
|
+
// Not every switch is the team's to make. `.no-harness` and friends are decisions of one
|
|
145
|
+
// machine (see targets/opt-outs.js), and committing them would impose one laptop's answer on
|
|
146
|
+
// every future clone. They stay local, and never reach the declaration.
|
|
119
147
|
function localDecisions(cwd) {
|
|
120
148
|
const dir = join(cwd, '.rsc');
|
|
121
|
-
let optOuts =
|
|
149
|
+
let optOuts = null;
|
|
122
150
|
try {
|
|
123
|
-
optOuts = readdirSync(dir).filter((f) => f.startsWith('.no-')).map((f) => f.slice(4))
|
|
124
|
-
} catch { /* no .rsc yet */ }
|
|
151
|
+
optOuts = projectOptOuts(readdirSync(dir).filter((f) => f.startsWith('.no-')).map((f) => f.slice(4)));
|
|
152
|
+
} catch { /* no .rsc yet → null, meaning "unknown", never "none" */ }
|
|
125
153
|
let tier = null;
|
|
126
154
|
try { tier = JSON.parse(readFileSync(join(dir, 'developer.json'), 'utf8')).tier ?? null; } catch { /* unset */ }
|
|
127
155
|
return { optOuts, tier };
|
|
128
156
|
}
|
|
129
157
|
|
|
158
|
+
// The other half of the same promise: what the declaration says is rebuilt as the real local
|
|
159
|
+
// state a hook can see. Hooks are materialized standalone under `.rsc/` and decide with one
|
|
160
|
+
// `existsSync` — they cannot read the manifest, and making them read it would let a `git pull`
|
|
161
|
+
// change how somebody's session behaves in silence, which `project-manifest` ruled out on
|
|
162
|
+
// purpose. So the manifest is applied HERE, inside the install the person chose to run.
|
|
163
|
+
//
|
|
164
|
+
// Only ever on a machine where `.rsc/` did not exist: there, "no marker" carries no information
|
|
165
|
+
// and the declaration is all there is. Where `.rsc/` is already built, the local state is the
|
|
166
|
+
// authority — otherwise the next sync would silently undo a re-arm, and the gate coming back
|
|
167
|
+
// would look like a bug in the guard rather than a decision nobody made.
|
|
168
|
+
export function hydrateLocalDecisions(cwd, manifest) {
|
|
169
|
+
const applied = { optOuts: [], tier: null };
|
|
170
|
+
if (!manifest) return applied;
|
|
171
|
+
const dir = join(cwd, '.rsc');
|
|
172
|
+
for (const name of projectOptOuts(manifest.optOuts)) {
|
|
173
|
+
const file = join(dir, `.no-${name}`);
|
|
174
|
+
if (existsSync(file)) continue;
|
|
175
|
+
mkdirSync(dir, { recursive: true });
|
|
176
|
+
writeFileSync(file, `Declared in .rsc.json by this project; rebuilt here by rsc ${CLI_VERSION}.\n`);
|
|
177
|
+
applied.optOuts.push(name);
|
|
178
|
+
}
|
|
179
|
+
if (manifest.tier && !existsSync(join(dir, 'developer.json'))) {
|
|
180
|
+
applied.tier = writeDeveloperTier(cwd, manifest.tier);
|
|
181
|
+
}
|
|
182
|
+
return applied;
|
|
183
|
+
}
|
|
184
|
+
|
|
130
185
|
// Record the decision, merging so a second assistant never erases the first: installing
|
|
131
186
|
// into codex on a machine that already had claude is adding, not replacing. Union, sorted
|
|
132
187
|
// where order carries no meaning, so the file stays diffable and a merge conflict stays
|
|
@@ -146,17 +201,25 @@ export function recordInManifest({ cwd, target, skillIds, agentIds = [], catalog
|
|
|
146
201
|
ownSkills: prev.ownSkills || [],
|
|
147
202
|
catalogVersion,
|
|
148
203
|
tier: tier ?? prev.tier ?? null,
|
|
149
|
-
optOuts: optOuts
|
|
204
|
+
optOuts: optOuts ?? projectOptOuts(prev.optOuts),
|
|
150
205
|
memory: prev.memory,
|
|
151
206
|
onboarding: onboarding ?? prev.onboarding,
|
|
152
207
|
});
|
|
153
208
|
}
|
|
154
209
|
|
|
155
210
|
export async function applyInstall({ skillIds = [], agentIds = [], target, home, cwd = process.cwd(), operation = 'install', dryRun = false, policy, onboarding }) {
|
|
211
|
+
// Read BEFORE anything can create it — the backup writes under `.rsc/`, and one line later the
|
|
212
|
+
// answer would always be "yes, it exists". This single boolean is what separates a clone (the
|
|
213
|
+
// declaration is the only thing that knows what the team decided) from a built machine (the
|
|
214
|
+
// local markers are the authority, and a pull must not overwrite them).
|
|
215
|
+
const fromScratch = !existsSync(join(cwd, '.rsc'));
|
|
156
216
|
const paths = targetPaths(target, home, cwd);
|
|
157
217
|
const plan = planInstall({ skillIds, target, home, cwd, hooks: policy?.alwaysOn !== false });
|
|
158
218
|
const managedPaths = managedPathsForInstall({ skillIds, agentIds, target, home, cwd, policy });
|
|
159
219
|
if (dryRun) return { dryRun: true, skills: skillIds, agents: agentIds, paths: managedPaths };
|
|
220
|
+
// Rebuild the declared decisions first, so everything below (the agent tier especially) reads
|
|
221
|
+
// the state the team declared rather than the default it would otherwise assume.
|
|
222
|
+
if (fromScratch) hydrateLocalDecisions(cwd, readManifest(cwd));
|
|
160
223
|
const state = readState(paths.stateFile);
|
|
161
224
|
const backup = createBackup({ cwd, operation, target, paths: managedPaths, cliVersion: CLI_VERSION });
|
|
162
225
|
// Decide base refresh per skill (see baseVersionsFile): a base is re-materialized when
|
|
@@ -219,6 +282,7 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
|
|
|
219
282
|
baseAgents: policy.baseAgents !== false,
|
|
220
283
|
alwaysOn: policy.alwaysOn !== false,
|
|
221
284
|
codeHooks: policy.codeHooks !== false,
|
|
285
|
+
dangerGuard: policy.dangerGuard !== false,
|
|
222
286
|
gitmojiGuard: policy.gitmojiGuard !== false,
|
|
223
287
|
memory: policy.memory !== false,
|
|
224
288
|
context7: policy.context7 === true,
|
|
@@ -237,8 +301,30 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
|
|
|
237
301
|
mkdirSync(dirname(versionFile(cwd)), { recursive: true });
|
|
238
302
|
writeFileSync(versionFile(cwd), CLI_VERSION + '\n');
|
|
239
303
|
recordInManifest({ cwd, target, skillIds, agentIds, onboarding });
|
|
304
|
+
// The worktree cleanup's trigger. It lives in `.git/hooks/`, which is NOT cloned, so it has to be
|
|
305
|
+
// (re)written by every operation that touches a user's repo — install, sync and repair all land
|
|
306
|
+
// here. Target-agnostic on purpose: it is a git hook, not an assistant hook, and a cleanup that
|
|
307
|
+
// only worked for one of the sixteen assistants would be a cleanup nobody could rely on. The
|
|
308
|
+
// reaper travels with it for the same reason; until now only the Claude adapter materialized it,
|
|
309
|
+
// although `managedPathsForInstall` has always declared it for every target.
|
|
310
|
+
// Only where the accepted plan governs code hooks. An operations harness — company, research,
|
|
311
|
+
// content — declares a narrower set of paths, and writing a git hook it never agreed to would be
|
|
312
|
+
// acting outside the plan the user accepted (P4). Caught by the onboarding route-inventory gate,
|
|
313
|
+
// which is exactly the kind of catch that justifies its existence.
|
|
314
|
+
// The answer used to be discarded, so a hook that could not arm looked exactly like one that
|
|
315
|
+
// did — and the only place that could have noticed, `doctor`, was reading the same wrong path.
|
|
316
|
+
// Reported 2026-09-18. Carried out, never thrown: the harness installing is the point, and the
|
|
317
|
+
// merge hook is a convenience layered on top of it.
|
|
318
|
+
let mergeHook = { installed: false, state: 'skipped' };
|
|
319
|
+
try {
|
|
320
|
+
if (policy?.codeHooks === false) throw new Error('code hooks not governed by the accepted plan');
|
|
321
|
+
mkdirSync(join(cwd, '.rsc'), { recursive: true });
|
|
322
|
+
cpSync(join(ROOT, 'targets', 'worktree-reaper.mjs'), join(cwd, '.rsc', 'worktree-reaper.mjs'));
|
|
323
|
+
const { installMergeHook } = await import('../targets/worktree-reaper.mjs');
|
|
324
|
+
if (existsSync(join(cwd, '.git'))) mergeHook = installMergeHook(cwd);
|
|
325
|
+
} catch (err) { mergeHook = { installed: false, state: 'skipped', reason: err.message }; }
|
|
240
326
|
ignoreLocalState(cwd, target);
|
|
241
|
-
return { ...state, backup };
|
|
327
|
+
return { ...state, backup, mergeHook };
|
|
242
328
|
}
|
|
243
329
|
|
|
244
330
|
// `.rsc/` holds local machine state — hook scripts, install markers, the sello's
|
|
@@ -287,13 +373,51 @@ export function ignoreLocalState(cwd = process.cwd(), target) {
|
|
|
287
373
|
// keeps this idempotent against a .gitignore a human wrote in their own spelling.
|
|
288
374
|
const norm = (l) => l.trim().replace(/^\//, '').replace(/\/$/, '');
|
|
289
375
|
const present = new Set(text.split('\n').map(norm));
|
|
376
|
+
// `.rsc/` is machine state and stays ignored. The two files below are the opposite: they are the
|
|
377
|
+
// whole reason a clone can help itself, and a project that excluded the assistant's directory
|
|
378
|
+
// wholesale (rsc's own repo does exactly that, so copying the example is likely) swallows both with
|
|
379
|
+
// no symptom at all.
|
|
380
|
+
//
|
|
381
|
+
// Asked of GIT, not of the pattern. The first attempt at this reasoned about spellings and shipped
|
|
382
|
+
// a negation that was INERT in every case it fired: git does not descend into an excluded
|
|
383
|
+
// directory, so `!.claude/rsc-bootstrap.mjs` under `.claude/` rescues nothing. Measured, not
|
|
384
|
+
// argued — and the lesson is the one this repo keeps relearning, that a rule believed and untrue is
|
|
385
|
+
// worse than no rule.
|
|
386
|
+
const RESCUE = ['.claude/rsc-bootstrap.mjs', '.claude/settings.json'];
|
|
387
|
+
const swallowed = RESCUE.filter((f) => {
|
|
388
|
+
try {
|
|
389
|
+
return spawnSync('git', ['check-ignore', '-q', '--', f], { cwd }).status === 0;
|
|
390
|
+
} catch {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
if (swallowed.length) {
|
|
395
|
+
// Append-only, and this exact order is what makes it work at all: re-include the directory so git
|
|
396
|
+
// will descend into it, exclude everything inside it again, then rescue by name. Verified against
|
|
397
|
+
// git itself — `settings.local.json` and the skill entries stay ignored.
|
|
398
|
+
const dir = swallowed[0].split('/')[0];
|
|
399
|
+
for (const line of [`!${dir}/`, `${dir}/*`, ...swallowed.map((f) => `!${f}`)]) wanted.push(line);
|
|
400
|
+
}
|
|
401
|
+
|
|
290
402
|
const add = wanted.filter((w) => !present.has(norm(w)));
|
|
291
|
-
|
|
403
|
+
// Every append used to open a new block with its own header, so a project synced across a dozen
|
|
404
|
+
// versions carried a dozen copies of it. Adjacent rsc blocks are one block: fold them (only our
|
|
405
|
+
// header lines and the blank seams between our blocks go; every entry stays, in order).
|
|
406
|
+
const folded = foldIgnoreBlocks(text);
|
|
407
|
+
if (!add.length) {
|
|
408
|
+
if (folded === text) return null;
|
|
409
|
+
try { writeFileSync(gi, folded); return gi; } catch { return null; }
|
|
410
|
+
}
|
|
292
411
|
|
|
293
|
-
//
|
|
294
|
-
|
|
412
|
+
// Never reorder, never touch a line of theirs. When the file already ends with our block, the new
|
|
413
|
+
// entries join it; otherwise they open one.
|
|
414
|
+
const base = folded === '' || folded.endsWith('\n') ? folded : `${folded}\n`;
|
|
415
|
+
const next = endsWithIgnoreBlock(base)
|
|
416
|
+
? `${base}${add.join('\n')}\n`
|
|
417
|
+
: `${base}${base === '' ? '' : '\n'}${RSC_IGNORE_HEADER}\n${add.join('\n')}\n`;
|
|
295
418
|
try {
|
|
296
|
-
|
|
419
|
+
if (folded === text && next.startsWith(text)) appendFileSync(gi, next.slice(text.length));
|
|
420
|
+
else writeFileSync(gi, next);
|
|
297
421
|
if (target) {
|
|
298
422
|
const statePath = targetPaths(target, undefined, cwd).stateFile;
|
|
299
423
|
const state = readState(statePath);
|
|
@@ -310,6 +434,14 @@ export function collisions({ cwd = process.cwd(), target, home, skillIds = [] })
|
|
|
310
434
|
return skillIds.filter((id) => !managed.has(id) && existsSync(paths.skillDir(id)));
|
|
311
435
|
}
|
|
312
436
|
|
|
437
|
+
// The same test as `collisions`, across every target of a plan, as the paths the user would recognise.
|
|
438
|
+
// `onboard` puts these in the plan itself, so the id the user accepts is an id that names them.
|
|
439
|
+
export function ownSkillPaths({ cwd = process.cwd(), targets = [], skillIds = [], home }) {
|
|
440
|
+
const found = targets.flatMap((target) => collisions({ cwd, target, home, skillIds })
|
|
441
|
+
.map((id) => relative(cwd, targetPaths(target, home, cwd).skillDir(id)).split(sep).join('/')));
|
|
442
|
+
return [...new Set(found)].sort();
|
|
443
|
+
}
|
|
444
|
+
|
|
313
445
|
export function listInstalled({ target, home, cwd = process.cwd() }) {
|
|
314
446
|
const paths = targetPaths(target, home, cwd);
|
|
315
447
|
return Object.keys(readState(paths.stateFile).skills);
|
|
@@ -482,7 +614,19 @@ export async function syncInstalled({ target, home, cwd = process.cwd(), dryRun
|
|
|
482
614
|
const ids = Object.keys(state.skills || {});
|
|
483
615
|
const manifest = readManifest(cwd);
|
|
484
616
|
const governedSkills = manifest?.onboarding?.plan?.policy?.skills;
|
|
485
|
-
|
|
617
|
+
// The accepted plan is a FLOOR, not a ceiling. Reading it as the whole declaration is what deleted
|
|
618
|
+
// users' skills: `add` recorded the new skill in `manifest.skills` — the living list — and sync
|
|
619
|
+
// then rebuilt from a governed list that predated it and pruned the difference, recursively.
|
|
620
|
+
// Reported by a user and reproduced 2026-09-17. The receipt stays untouched, because it is
|
|
621
|
+
// hash-checked against what the user accepted and is not a log of what happened since.
|
|
622
|
+
const declaredRaw = governedSkills
|
|
623
|
+
? [...new Set([...governedSkills, ...(manifest?.skills || [])])].sort()
|
|
624
|
+
: (ids.length ? ids : (manifest?.skills || []));
|
|
625
|
+
// A declaration is frozen at the moment it was written, so a skill the catalog later makes
|
|
626
|
+
// mandatory never reaches an existing harness: `add` and `install` apply the floor, sync did
|
|
627
|
+
// not, and sync is the only one an upgrade runs. Applied only to a harness that already declares
|
|
628
|
+
// something: a directory declaring nothing is not a harness missing its floor.
|
|
629
|
+
const declared = declaredRaw.length ? withDefaultSkillFloor(declaredRaw) : declaredRaw;
|
|
486
630
|
const declaredAgents = state.explicitAgents?.length ? state.explicitAgents : (manifest?.agents || []);
|
|
487
631
|
if (!declared.length && !declaredAgents.length) return dryRun ? { dryRun: true, synced: [], syncedAgents: [], paths: [] } : { synced: [], syncedAgents: [], backup: null };
|
|
488
632
|
if (dryRun) {
|
|
@@ -512,6 +656,43 @@ export async function syncInstalled({ target, home, cwd = process.cwd(), dryRun
|
|
|
512
656
|
// own knowledge — kept unless `withDocs` is set. Returns the paths touched.
|
|
513
657
|
// Note: backups live under `.rsc/backups/`, which this removes — so purge does not
|
|
514
658
|
// snapshot (a pre-purge backup would delete itself). It is the deliberate escape hatch.
|
|
659
|
+
const RSC_IGNORE_HEADER = '# rsc local state (hooks, seals, logs) and managed skill links — machine-local';
|
|
660
|
+
|
|
661
|
+
// Merge consecutive rsc blocks into the first: drop a repeated header and the blank seam before it
|
|
662
|
+
// when the block above was ours too. A block of the user's in between ends ours, so nothing of
|
|
663
|
+
// theirs is ever moved or absorbed.
|
|
664
|
+
export function foldIgnoreBlocks(text) {
|
|
665
|
+
const out = [];
|
|
666
|
+
let ours = false;
|
|
667
|
+
let afterBlank = false;
|
|
668
|
+
for (const line of text.split('\n')) {
|
|
669
|
+
if (line === RSC_IGNORE_HEADER) {
|
|
670
|
+
if (ours && afterBlank) {
|
|
671
|
+
while (out.length && out[out.length - 1].trim() === '') out.pop();
|
|
672
|
+
afterBlank = false;
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
ours = true;
|
|
676
|
+
} else if (line.trim() === '') {
|
|
677
|
+
afterBlank = true;
|
|
678
|
+
out.push(line);
|
|
679
|
+
continue;
|
|
680
|
+
} else if (afterBlank) {
|
|
681
|
+
ours = false;
|
|
682
|
+
}
|
|
683
|
+
afterBlank = false;
|
|
684
|
+
out.push(line);
|
|
685
|
+
}
|
|
686
|
+
return out.join('\n');
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function endsWithIgnoreBlock(text) {
|
|
690
|
+
const lines = text.replace(/\n+$/, '').split('\n');
|
|
691
|
+
let i = lines.length - 1;
|
|
692
|
+
while (i > 0 && lines[i - 1].trim() !== '') i -= 1;
|
|
693
|
+
return lines[i] === RSC_IGNORE_HEADER;
|
|
694
|
+
}
|
|
695
|
+
|
|
515
696
|
export async function purge({ home, cwd = process.cwd(), withDocs = false, dryRun = false } = {}) {
|
|
516
697
|
const removed = [];
|
|
517
698
|
const drop = (p, recursive = false) => {
|
package/scripts/lib/backups.js
CHANGED
|
@@ -40,6 +40,19 @@ export function createBackup({ cwd = process.cwd(), operation, target, paths, cl
|
|
|
40
40
|
return manifest;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Where the newest snapshot kept a path, relative to the project — what to tell someone whose own work
|
|
44
|
+
// was just replaced, so recovering it is a copy they can find rather than a restore they never heard of.
|
|
45
|
+
// A transaction snapshot is the fallback: it is the install's rollback, not the user's copy.
|
|
46
|
+
export function whereKept({ cwd = process.cwd(), relPath }) {
|
|
47
|
+
const backups = listBackups({ cwd });
|
|
48
|
+
const ordered = [...backups.filter((b) => !/transaction/.test(b.operation)), ...backups.filter((b) => /transaction/.test(b.operation))];
|
|
49
|
+
for (const b of ordered) {
|
|
50
|
+
const kept = join(backupsDir(cwd), b.id, 'files', relPath);
|
|
51
|
+
if (existsSync(kept)) return relative(cwd, kept).split(sep).join('/');
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
43
56
|
export function listBackups({ cwd = process.cwd() } = {}) {
|
|
44
57
|
const dir = backupsDir(cwd);
|
|
45
58
|
if (!existsSync(dir)) return [];
|
|
@@ -44,6 +44,12 @@ function agentSpecFor(target, root) {
|
|
|
44
44
|
return { dir: dirname(p), ext };
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// A real file, or a link that leads to one. `statSync` follows; a dangling link throws and is
|
|
48
|
+
// therefore not a file, which is the answer we want.
|
|
49
|
+
function isFile(path) {
|
|
50
|
+
try { return statSync(path).isFile(); } catch { return false; }
|
|
51
|
+
}
|
|
52
|
+
|
|
47
53
|
// Real files only. Directories (a dir named `foo.md` is not an agent), dangling
|
|
48
54
|
// symlinks and dotfiles are not capabilities.
|
|
49
55
|
function entriesWithExt(dir, ext) {
|
|
@@ -96,8 +102,19 @@ export function listSkills({ target, home, cwd = process.cwd() } = {}) {
|
|
|
96
102
|
try {
|
|
97
103
|
for (const e of readdirSync(paths.dir, { withFileTypes: true })) {
|
|
98
104
|
if (e.name.startsWith('.')) continue;
|
|
99
|
-
|
|
100
|
-
|
|
105
|
+
// Ask what a skill IS, never how it was put on disk. A `Dirent` from `withFileTypes`
|
|
106
|
+
// describes the LINK and not its target, so `isDirectory()`/`isFile()` are both false for
|
|
107
|
+
// every skill on macOS and Linux — where a symlink per assistant, pointing at one shared
|
|
108
|
+
// copy, is the layout the installer deliberately writes. Windows gets real files (relative
|
|
109
|
+
// dir symlinks need Developer Mode there), which is why this read as correct for so long
|
|
110
|
+
// and why CI never caught it.
|
|
111
|
+
//
|
|
112
|
+
// `existsSync` and `statSync` follow links on their own, so one question answers all three
|
|
113
|
+
// platforms with no branch: is there a SKILL.md behind this name? Following a link is not
|
|
114
|
+
// the same as believing anything — a dangling link, a directory with no SKILL.md and a
|
|
115
|
+
// plain file all still fail it.
|
|
116
|
+
if (ext) { if (e.name.endsWith(ext) && isFile(join(paths.dir, e.name))) out.push({ id: e.name.slice(0, -ext.length), scope }); }
|
|
117
|
+
else if (existsSync(join(paths.dir, e.name, 'SKILL.md'))) out.push({ id: e.name, scope });
|
|
101
118
|
}
|
|
102
119
|
} catch { /* unreadable scope → contributes nothing */ }
|
|
103
120
|
}
|
|
@@ -2,6 +2,7 @@ import { readManifest } from './manifest-file.js';
|
|
|
2
2
|
import { readState } from './state.js';
|
|
3
3
|
import { targetPaths } from '../../targets/index.js';
|
|
4
4
|
import { existsSync } from 'node:fs';
|
|
5
|
+
import { DEFAULT_SKILL_FLOOR } from './default-skill-floor.js';
|
|
5
6
|
|
|
6
7
|
// What the team declared versus what is on this machine.
|
|
7
8
|
//
|
|
@@ -15,10 +16,13 @@ import { existsSync } from 'node:fs';
|
|
|
15
16
|
// ownMissing — declared team skill, absent from the repo. Aligning says so and writes
|
|
16
17
|
// nothing: it comes from the repo, not from us.
|
|
17
18
|
// extra — installed BY RSC, no longer declared.
|
|
19
|
+
// floorMissing — a skill the catalog makes mandatory that this declaration predates.
|
|
20
|
+
// Not drift between teammates: drift between the harness and the catalog,
|
|
21
|
+
// and the only bucket that survives everyone running the same `git pull`.
|
|
18
22
|
// A hand-written skill that nobody declared is in none of them. Not declaring is a valid
|
|
19
23
|
// way to keep things of your own, not an oversight to correct.
|
|
20
24
|
export function divergence({ cwd = process.cwd(), target, home } = {}) {
|
|
21
|
-
const empty = { missing: [], extra: [], ownMissing: [] };
|
|
25
|
+
const empty = { missing: [], extra: [], ownMissing: [], floorMissing: [] };
|
|
22
26
|
const manifest = readManifest(cwd);
|
|
23
27
|
if (!manifest) return empty;
|
|
24
28
|
|
|
@@ -29,5 +33,8 @@ export function divergence({ cwd = process.cwd(), target, home } = {}) {
|
|
|
29
33
|
missing: (manifest.skills || []).filter((id) => !installed.has(id)),
|
|
30
34
|
extra: [...installed].filter((id) => !(manifest.skills || []).includes(id)),
|
|
31
35
|
ownMissing: (manifest.ownSkills || []).filter((name) => !existsSync(paths.skillDir(name))),
|
|
36
|
+
// Read off the declaration, not off the disk: a skill installed by hand into a harness
|
|
37
|
+
// that still does not declare it is one sync away from being pruned again.
|
|
38
|
+
floorMissing: DEFAULT_SKILL_FLOOR.filter((id) => !(manifest.skills || []).includes(id)),
|
|
32
39
|
};
|
|
33
40
|
}
|
|
@@ -4,6 +4,7 @@ import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { applyInstall, pruneSharedBases, removeTargetInstall } from '../install-apply.js';
|
|
6
6
|
import { targetPaths } from '../../targets/index.js';
|
|
7
|
+
import { targetHasAgents } from '../../targets/agents.js';
|
|
7
8
|
import { readState } from './state.js';
|
|
8
9
|
import { readManifest, writeManifest } from './manifest-file.js';
|
|
9
10
|
import { encodeGoal, identifyPlan } from './onboarding.js';
|
|
@@ -11,6 +12,22 @@ import { createBackup, restoreBackup } from './backups.js';
|
|
|
11
12
|
|
|
12
13
|
const sameSet = (a = [], b = []) => JSON.stringify([...a].sort()) === JSON.stringify([...b].sort());
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* What THIS target should be holding, which is not the same question as what the project decided.
|
|
17
|
+
*
|
|
18
|
+
* The policy is the PROJECT's: "these are the agents this project wants." Whether an assistant can
|
|
19
|
+
* hold a subagent at all is the TARGET's business, and nine of the seventeen cannot — amp, jules,
|
|
20
|
+
* zed, antigravity, windsurf, cline, roo, continue and aider have no such concept. The installer
|
|
21
|
+
* always knew this and correctly wrote none; the verifier did not, and demanded the project's list
|
|
22
|
+
* from every target, so onboarding could never complete on any of the nine. Since the failure rolls
|
|
23
|
+
* back, what the person got was empty directory shells and no harness — with a recovery command
|
|
24
|
+
* that leads straight back to the same wall.
|
|
25
|
+
*
|
|
26
|
+
* Asking amp for a subagent is not a divergence. It is a category error, and this is where the two
|
|
27
|
+
* categories stop being confused.
|
|
28
|
+
*/
|
|
29
|
+
export const expectedAgentsFor = (target, plan) => (targetHasAgents(target) ? (plan.policy.agents || []) : []);
|
|
30
|
+
|
|
14
31
|
function inside(root, path) {
|
|
15
32
|
return path === root || path.startsWith(`${root}${sep}`);
|
|
16
33
|
}
|
|
@@ -66,18 +83,69 @@ function recoveryCommand(plan, planId) {
|
|
|
66
83
|
}
|
|
67
84
|
|
|
68
85
|
export function renderOnboardingDocuments(plan, planId) {
|
|
69
|
-
|
|
86
|
+
// `accompaniment_level`, never `accompaniment` (#276). It sits beside `technical_level` in the same
|
|
87
|
+
// block, and it is the name the nine skill files that read the dial — orient first among them —
|
|
88
|
+
// have always used. This was the only writer of the short form, which left the dial chosen at
|
|
89
|
+
// onboarding invisible to the compass.
|
|
90
|
+
const profile = `---\ntechnical_level: ${plan.record.technicalLevel}\naccompaniment_level: ${plan.record.accompaniment}\nproject_kind: ${plan.record.projectKind}\n---\n\n# User profile\n\nGoal: ${plan.record.goal}\n`;
|
|
70
91
|
const rows = plan.decisions.map((d) => `| ${d.kind} | ${d.id} | ${d.state} | ${d.reason} | ${d.reevaluateWhen.join('; ') || '—'} |`).join('\n');
|
|
71
92
|
const installation = `# Accepted harness plan\n\nPlan id: \`${planId}\`\n\n| Kind | Component | Decision | Reason | Reevaluate when |\n| --- | --- | --- | --- | --- |\n${rows}\n`;
|
|
72
93
|
const decisions = `# Harness decisions\n\n- Accepted plan \`${planId}\`.\n- Project kind: ${plan.record.projectKind}.\n- SDD: ${plan.decisions.find((d) => d.id === 'sdd')?.state || 'selected through profile'}.\n`;
|
|
73
94
|
return { profile, installation, decisions };
|
|
74
95
|
}
|
|
75
96
|
|
|
76
|
-
|
|
97
|
+
const PROFILE_DIALS = [['technical_level', 'technicalLevel'], ['accompaniment_level', 'accompaniment']];
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The profile is the USER's file; accepting a plan seeds it, it does not own it (#278).
|
|
101
|
+
*
|
|
102
|
+
* `init` records there what it learns — the domain, the tools, what must not be touched — and `orient`
|
|
103
|
+
* rewrites the dial when someone asks for more or less explanation. Regenerating the file on every
|
|
104
|
+
* accept erased both, and re-accepting is routine: after an update, after a reassess.
|
|
105
|
+
*
|
|
106
|
+
* So: everything the plan does not own is kept byte for byte. `project_kind` and the goal are facts of
|
|
107
|
+
* the plan and are updated. The two dials are the subtle part — the plan carries them, but after first
|
|
108
|
+
* contact the user adjusts them — so an existing value stands when the plan did not CHANGE it relative
|
|
109
|
+
* to the previous receipt, and an explicit new value (a receipt that said L3, a plan that says L2) is
|
|
110
|
+
* applied. A profile written before the rename carries `accompaniment:`; it is migrated, never
|
|
111
|
+
* duplicated.
|
|
112
|
+
*/
|
|
113
|
+
export function mergeProfile(existing, plan, previousRecord) {
|
|
114
|
+
const fresh = renderOnboardingDocuments(plan, '').profile;
|
|
115
|
+
if (!existing || !existing.trim()) return fresh;
|
|
116
|
+
const text = existing.replace(/\r\n/g, '\n');
|
|
117
|
+
const block = /^---\n([\s\S]*?)\n---\n?/.exec(text);
|
|
118
|
+
let front = block ? block[1].split('\n') : [];
|
|
119
|
+
let body = block ? text.slice(block[0].length) : text;
|
|
120
|
+
const has = (key) => front.some((line) => line.startsWith(`${key}:`));
|
|
121
|
+
front = has('accompaniment_level')
|
|
122
|
+
? front.filter((line) => !/^accompaniment:\s/.test(line))
|
|
123
|
+
: front.map((line) => line.replace(/^accompaniment:(\s)/, 'accompaniment_level:$1'));
|
|
124
|
+
const get = (key) => front.find((line) => line.startsWith(`${key}:`))?.slice(key.length + 1).trim() || null;
|
|
125
|
+
const set = (key, value) => {
|
|
126
|
+
const at = front.findIndex((line) => line.startsWith(`${key}:`));
|
|
127
|
+
if (at >= 0) front[at] = `${key}: ${value}`;
|
|
128
|
+
else front.push(`${key}: ${value}`);
|
|
129
|
+
};
|
|
130
|
+
for (const [key, field] of PROFILE_DIALS) {
|
|
131
|
+
const planned = plan.record[field];
|
|
132
|
+
const keptByUser = get(key) && previousRecord && previousRecord[field] === planned;
|
|
133
|
+
if (!keptByUser) set(key, planned);
|
|
134
|
+
}
|
|
135
|
+
set('project_kind', plan.record.projectKind);
|
|
136
|
+
const goal = `Goal: ${plan.record.goal}`;
|
|
137
|
+
if (/^Goal: .*$/m.test(body)) body = body.replace(/^Goal: .*$/m, () => goal);
|
|
138
|
+
else if (/^# User profile[ \t]*$/m.test(body)) body = body.replace(/^# User profile[ \t]*$/m, () => `# User profile\n\n${goal}`);
|
|
139
|
+
else body = `${body.replace(/\s*$/, '')}\n\n${goal}\n`;
|
|
140
|
+
return `---\n${front.join('\n')}\n---\n${body.startsWith('\n') ? '' : '\n'}${body}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function writeOnboardingDocuments(cwd, plan, planId, previousRecord = null) {
|
|
77
144
|
const dir = join(cwd, 'docs', 'wiki', 'harness');
|
|
78
145
|
mkdirSync(dir, { recursive: true });
|
|
79
146
|
const docs = renderOnboardingDocuments(plan, planId);
|
|
80
|
-
|
|
147
|
+
const profilePath = join(dir, 'user-profile.md');
|
|
148
|
+
writeFileSync(profilePath, mergeProfile(existsSync(profilePath) ? readFileSync(profilePath, 'utf8') : '', plan, previousRecord));
|
|
81
149
|
writeFileSync(join(dir, 'installation-plan.md'), docs.installation);
|
|
82
150
|
const decisionsPath = join(dir, 'decisions.md');
|
|
83
151
|
if (!existsSync(decisionsPath)) writeFileSync(decisionsPath, docs.decisions);
|
|
@@ -92,7 +160,7 @@ export function verifyOnboarding(cwd, plan, planId) {
|
|
|
92
160
|
for (const target of plan.policy.targets) {
|
|
93
161
|
const state = readState(targetPaths(target, undefined, cwd).stateFile);
|
|
94
162
|
if (!sameSet(Object.keys(state.skills || {}), plan.policy.skills)) differences.push(`${target}: installed skills differ from accepted policy`);
|
|
95
|
-
if (!sameSet(state.agents || [], plan
|
|
163
|
+
if (!sameSet(state.agents || [], expectedAgentsFor(target, plan))) differences.push(`${target}: installed agents differ from accepted policy`);
|
|
96
164
|
if (state.policy?.alwaysOn !== plan.policy.alwaysOn) differences.push(`${target}: always-on policy differs`);
|
|
97
165
|
if (state.policy?.codeHooks !== plan.policy.codeHooks) differences.push(`${target}: code-hook policy differs`);
|
|
98
166
|
if (state.policy?.memory !== plan.policy.memory) differences.push(`${target}: memory policy differs`);
|
|
@@ -117,12 +185,15 @@ export function verifyOnboarding(cwd, plan, planId) {
|
|
|
117
185
|
}
|
|
118
186
|
const docsDir = join(cwd, 'docs', 'wiki', 'harness');
|
|
119
187
|
const profile = existsSync(join(docsDir, 'user-profile.md')) ? readFileSync(join(docsDir, 'user-profile.md'), 'utf8') : '';
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
`
|
|
125
|
-
|
|
188
|
+
// The plan's facts must be exactly there. The dials must be there under their one name with a valid
|
|
189
|
+
// value — not necessarily the plan's, because a dial the user adjusted and the plan did not change
|
|
190
|
+
// is theirs (see mergeProfile).
|
|
191
|
+
for (const line of [`project_kind: ${plan.record.projectKind}`, `Goal: ${plan.record.goal}`]) {
|
|
192
|
+
if (!profile.split('\n').includes(line)) differences.push(`profile content differs: ${line.split(':')[0]}`);
|
|
193
|
+
}
|
|
194
|
+
if (!/^technical_level: (non-technical|mixed|technical)$/m.test(profile)) differences.push('profile content differs: technical_level');
|
|
195
|
+
if (!/^accompaniment_level: L[0-3]$/m.test(profile)) differences.push('profile content differs: accompaniment_level');
|
|
196
|
+
if (/^accompaniment: /m.test(profile)) differences.push('profile carries the retired accompaniment key');
|
|
126
197
|
const installation = existsSync(join(docsDir, 'installation-plan.md')) ? readFileSync(join(docsDir, 'installation-plan.md'), 'utf8') : '';
|
|
127
198
|
if (!installation.includes(`Plan id: \`${planId}\``)) differences.push('installation plan identity differs');
|
|
128
199
|
for (const decision of plan.decisions || []) {
|
|
@@ -131,7 +202,8 @@ export function verifyOnboarding(cwd, plan, planId) {
|
|
|
131
202
|
}
|
|
132
203
|
const decisions = existsSync(join(docsDir, 'decisions.md')) ? readFileSync(join(docsDir, 'decisions.md'), 'utf8') : '';
|
|
133
204
|
if (!decisions.includes(`Accepted plan \`${planId}\``)) differences.push('decision ledger omits accepted plan');
|
|
134
|
-
|
|
205
|
+
// Every planned assistant is declared; an extra one added outside the plan may be too (#278).
|
|
206
|
+
if (!plan.policy.targets.every((t) => (manifest?.targets || []).includes(t))) differences.push('manifest targets differ from accepted policy');
|
|
135
207
|
if (!sameSet(manifest?.skills || [], plan.policy.skills)) differences.push('manifest skills differ from accepted policy');
|
|
136
208
|
if (!sameSet(manifest?.agents || [], plan.policy.agents || [])) differences.push('manifest agents differ from accepted policy');
|
|
137
209
|
return differences;
|
|
@@ -183,7 +255,7 @@ export async function applyAcceptedOnboarding({ cwd = process.cwd(), plan, planI
|
|
|
183
255
|
});
|
|
184
256
|
}
|
|
185
257
|
pruneSharedBases({ cwd, skillIds: plan.policy.skills });
|
|
186
|
-
writeOnboardingDocuments(cwd, plan, planId);
|
|
258
|
+
writeOnboardingDocuments(cwd, plan, planId, previousPlan?.record || null);
|
|
187
259
|
receipt.provenance.paths = Object.fromEntries((plan.governedPaths || []).map((path) => [
|
|
188
260
|
path,
|
|
189
261
|
existed[path] ? (previousReceipt ? 'conserved' : 'preexisting') : 'installed',
|
|
@@ -192,7 +264,13 @@ export async function applyAcceptedOnboarding({ cwd = process.cwd(), plan, planI
|
|
|
192
264
|
const manifest = readManifest(cwd);
|
|
193
265
|
writeManifest(cwd, {
|
|
194
266
|
...manifest,
|
|
195
|
-
|
|
267
|
+
// Union, with the distinction the removal loop above already makes (#278). An assistant that was
|
|
268
|
+
// in the previous PLAN and is not in this one was removed on purpose. One added OUTSIDE the plan
|
|
269
|
+
// — `sync --target codex` — is still installed, and dropping it from the declaration meant a
|
|
270
|
+
// clone would never build it again.
|
|
271
|
+
targets: [...plan.policy.targets, ...(previousManifest?.targets || []).filter((t) => !plan.policy.targets.includes(t)
|
|
272
|
+
&& !(previousPlan?.policy?.targets || []).includes(t)
|
|
273
|
+
&& existsSync(targetPaths(t, undefined, cwd).stateFile))],
|
|
196
274
|
skills: [...plan.policy.skills],
|
|
197
275
|
agents: [...(plan.policy.agents || [])],
|
|
198
276
|
onboarding: receipt,
|