@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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
4
4
|
import { resolve, join, dirname } from 'node:path';
|
|
5
5
|
import { capture, resume } from './session-memory-core.mjs';
|
|
6
6
|
|
|
@@ -56,15 +56,35 @@ function hookEventName(target, event, native) {
|
|
|
56
56
|
return names[event] || event;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
const COMPACTION_HINT = 'rsc memory: consider compacting at the next phase boundary; nothing was compacted automatically.';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Two different things used to share one channel, and that is the whole bug.
|
|
63
|
+
*
|
|
64
|
+
* The resume CONTEXT is information for the model, and it is only ever produced on `start` —
|
|
65
|
+
* `SessionStart`, one of the few events whose output may carry `hookSpecificOutput.additionalContext`.
|
|
66
|
+
* The compaction HINT is a message for the PERSON, and it was going out through the same field on
|
|
67
|
+
* whatever event happened to be running. On `Stop`, `PreCompact` and `SessionEnd` the client rejects
|
|
68
|
+
* that outright ("Hook JSON output validation failed — hookSpecificOutput.hookEventName: expected one
|
|
69
|
+
* of …"), so the hook meant to help ended up printing an error after every turn, and after
|
|
70
|
+
* `/compact` in particular — which is exactly what the hint had just told the person to run.
|
|
71
|
+
*
|
|
72
|
+
* So the hint moves to `systemMessage`, which every event accepts. This needs no table of which
|
|
73
|
+
* event carries what, and it cannot rot when the client adds an event: anything addressed to the
|
|
74
|
+
* person travels by the universal channel, and only genuine model context uses the narrow one.
|
|
75
|
+
*/
|
|
59
76
|
function nativeOutput(target, eventName, context = '', notice = null, compactionHint = false) {
|
|
60
|
-
const hint = compactionHint ?
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
|
|
77
|
+
const hint = compactionHint ? COMPACTION_HINT : '';
|
|
78
|
+
if (!context && !hint && !notice) return {};
|
|
79
|
+
if (target === 'cursor') {
|
|
80
|
+
const message = [notice, hint].filter(Boolean).join('\n');
|
|
81
|
+
return { ...(context ? { additional_context: context } : {}), ...(message ? { user_message: message } : {}) };
|
|
82
|
+
}
|
|
83
|
+
if (target === 'opencode') return { context, notice: [notice, hint].filter(Boolean).join('\n') || null };
|
|
84
|
+
const message = [notice, hint].filter(Boolean).join('\n');
|
|
65
85
|
return {
|
|
66
|
-
...(
|
|
67
|
-
...(
|
|
86
|
+
...(message ? { systemMessage: message } : {}),
|
|
87
|
+
...(context ? { hookSpecificOutput: { hookEventName: eventName, additionalContext: context } } : {}),
|
|
68
88
|
};
|
|
69
89
|
}
|
|
70
90
|
|
|
@@ -78,15 +98,18 @@ export function handleLifecycle({ target, event, native = {}, cwd, settings } =
|
|
|
78
98
|
try {
|
|
79
99
|
if (!LOCAL_TARGETS.has(target)) throw new Error(`unsupported memory target: ${target}`);
|
|
80
100
|
if (isRemote(target, native)) return { output: {}, capture: null, remote: true, degraded: false };
|
|
81
|
-
|
|
101
|
+
// Two answers from one path, kept apart on purpose: `project` is where the journal is KEPT (the
|
|
102
|
+
// nearest harness), `here` is where the agent WORKS and therefore who the session is.
|
|
103
|
+
const here = resolve(cwd || native.cwd || process.env.RSC_PROJECT_CWD || process.cwd());
|
|
104
|
+
const project = nearestHarness(here);
|
|
82
105
|
if (!existsSync(project)) throw new Error('project directory unavailable');
|
|
83
106
|
const config = settings || projectSettings(project);
|
|
84
107
|
if (config.enabled === false) return { output: {}, capture: null, remote: false, degraded: false };
|
|
85
108
|
const id = sessionId(native, target);
|
|
86
109
|
const eventName = hookEventName(target, event, native);
|
|
87
110
|
if (event === 'start') {
|
|
88
|
-
const started = capture({ cwd: project, sessionId: id, target, event: 'start', settings: config });
|
|
89
|
-
const resumed = resume({ cwd: project, target, settings: config });
|
|
111
|
+
const started = capture({ cwd: project, worktreeCwd: here, sessionId: id, target, event: 'start', settings: config });
|
|
112
|
+
const resumed = resume({ cwd: project, worktreeCwd: here, target, settings: config });
|
|
90
113
|
return {
|
|
91
114
|
output: nativeOutput(target, eventName, resumed.context),
|
|
92
115
|
capture: started,
|
|
@@ -97,6 +120,7 @@ export function handleLifecycle({ target, event, native = {}, cwd, settings } =
|
|
|
97
120
|
}
|
|
98
121
|
const captureInput = {
|
|
99
122
|
cwd: project,
|
|
123
|
+
worktreeCwd: here,
|
|
100
124
|
sessionId: id,
|
|
101
125
|
target,
|
|
102
126
|
event: event === 'end' ? 'sessionEnd' : event,
|
|
@@ -106,8 +130,13 @@ export function handleLifecycle({ target, event, native = {}, cwd, settings } =
|
|
|
106
130
|
if (typeof native.cost === 'number') captureInput.cost = native.cost;
|
|
107
131
|
if (Number.isInteger(native.tool_calls)) captureInput.toolCalls = native.tool_calls;
|
|
108
132
|
const captured = capture(captureInput);
|
|
133
|
+
// `stop_hook_active` is the client saying "this turn is already running BECAUSE a stop hook
|
|
134
|
+
// asked for it". Speaking again here is how a Stop hook wakes itself: the student's session did
|
|
135
|
+
// nine rounds of hook → "—" → hook before the client broke the loop. Record the turn, say
|
|
136
|
+
// nothing.
|
|
137
|
+
const silenced = event === 'turn' && Boolean(native?.stop_hook_active);
|
|
109
138
|
return {
|
|
110
|
-
output: nativeOutput(target, eventName, '', captured.notice, captured.compactionHint),
|
|
139
|
+
output: silenced ? {} : nativeOutput(target, eventName, '', captured.notice, captured.compactionHint),
|
|
111
140
|
capture: captured,
|
|
112
141
|
remote: false,
|
|
113
142
|
degraded: false,
|
|
@@ -126,7 +155,34 @@ function stdinJson() {
|
|
|
126
155
|
}
|
|
127
156
|
}
|
|
128
157
|
|
|
129
|
-
|
|
158
|
+
/**
|
|
159
|
+
* "Was this file run directly?" — the same question `clone-bootstrap.mjs` answers, and the same two
|
|
160
|
+
* traps, so the same answer. Written out here rather than imported because hooks are materialized
|
|
161
|
+
* file by file under `.rsc/`: an import would be a second file to copy for six lines.
|
|
162
|
+
*
|
|
163
|
+
* `process.argv[1]` MAY NOT EXIST. Under `node -e`, `--input-type=module` or the REPL there is no
|
|
164
|
+
* script path, and `pathToFileURL(undefined)` throws — at module load, so merely IMPORTING this
|
|
165
|
+
* file takes the importer down with it. A module that cannot be imported is a module nobody can
|
|
166
|
+
* build a tool on top of, and people do exactly that with this package.
|
|
167
|
+
*
|
|
168
|
+
* SYMLINKS MAKE THE TWO SIDES DISAGREE. `import.meta.url` is what the loader resolved (symlinks
|
|
169
|
+
* RESOLVED); `process.argv[1]` is the raw string the client passed (symlinks INTACT). One
|
|
170
|
+
* symlinked component — `/tmp` and `/var` on macOS, or an ordinary `~/code -> /Volumes/…` — and a
|
|
171
|
+
* string compare says "no", the main block never runs, node exits 0 having printed nothing, and
|
|
172
|
+
* the hook becomes a silent no-op that `doctor` still reports as wired.
|
|
173
|
+
*/
|
|
174
|
+
function isMainModule(metaUrl) {
|
|
175
|
+
const invoked = process.argv[1];
|
|
176
|
+
if (!invoked) return false;
|
|
177
|
+
const self = fileURLToPath(metaUrl);
|
|
178
|
+
try {
|
|
179
|
+
return realpathSync(self) === realpathSync(invoked);
|
|
180
|
+
} catch {
|
|
181
|
+
return self === invoked;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (isMainModule(import.meta.url)) {
|
|
130
186
|
const result = handleLifecycle({ target: process.argv[2], event: process.argv[3], native: stdinJson() });
|
|
131
187
|
process.stdout.write(`${JSON.stringify(result.output)}\n`);
|
|
132
188
|
}
|
|
@@ -48,7 +48,7 @@ function cleanId(value, prefix = 'session') {
|
|
|
48
48
|
|
|
49
49
|
function git(cwd, args) {
|
|
50
50
|
try {
|
|
51
|
-
return { ok: true, out: execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 2 * 1024 * 1024 }).trim() };
|
|
51
|
+
return { ok: true, out: execFileSync('git', args, { windowsHide: true, cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 2 * 1024 * 1024 }).trim() };
|
|
52
52
|
} catch {
|
|
53
53
|
return { ok: false, out: '' };
|
|
54
54
|
}
|
|
@@ -284,6 +284,12 @@ function readRecords(root) {
|
|
|
284
284
|
|
|
285
285
|
export function capture(input = {}) {
|
|
286
286
|
const cwd = resolve(input.cwd || process.cwd());
|
|
287
|
+
// WHERE the journal lives and WHO the session is are two different questions. `cwd` answers the
|
|
288
|
+
// first — the nearest harness, so a child with no manifest of its own does not scatter journals.
|
|
289
|
+
// The second has to be asked of git from where the agent actually works: asked from the harness,
|
|
290
|
+
// every worktree nested under it came back as the harness itself, and a sibling's record then
|
|
291
|
+
// "matched exactly". See GHSA-8gjp-3r7c-33f4.
|
|
292
|
+
const here = resolve(input.worktreeCwd || cwd);
|
|
287
293
|
const config = settings(input.settings);
|
|
288
294
|
if (!config.enabled) return { record: null, path: null, notice: null, compactionHint: false };
|
|
289
295
|
const store = chooseMemoryRoot(cwd);
|
|
@@ -299,7 +305,7 @@ export function capture(input = {}) {
|
|
|
299
305
|
const recordPath = join(store.root, 'sessions', `${storageId}.json`);
|
|
300
306
|
let anchor = readJson(anchorPath);
|
|
301
307
|
let existing = readJson(recordPath);
|
|
302
|
-
const firstSnapshot = snapshot(
|
|
308
|
+
const firstSnapshot = snapshot(here, null);
|
|
303
309
|
if (!anchor) {
|
|
304
310
|
anchor = {
|
|
305
311
|
sessionId,
|
|
@@ -312,10 +318,23 @@ export function capture(input = {}) {
|
|
|
312
318
|
}
|
|
313
319
|
if (input.event === 'start' && !existing) return { record: null, path: null, notice: null, compactionHint: false };
|
|
314
320
|
|
|
315
|
-
const repo = snapshot(
|
|
321
|
+
const repo = snapshot(here, anchor.baselineHead);
|
|
322
|
+
// Count what CHANGED, not which tool changed it. `editDelta` is +1 per Edit/Write tool event, and
|
|
323
|
+
// on its own it was blind to everything done through the shell — a heredoc, `sed -i`, a script, a
|
|
324
|
+
// formatter — which is how automated sessions work. The session that found this had touched dozens
|
|
325
|
+
// of files and its record said editCount=1, so the compaction hint never reached the long sessions
|
|
326
|
+
// it exists for.
|
|
327
|
+
//
|
|
328
|
+
// `snapshot` already fingerprints every modified file on every event. Comparing those fingerprints
|
|
329
|
+
// with the previous event's counts a change from any source. `max`, not `+`: an Edit that changed a
|
|
330
|
+
// file shows up in BOTH signals, and must count once.
|
|
331
|
+
const previousFingerprints = anchor.lastFingerprints || anchor.baselineFingerprints || {};
|
|
332
|
+
const observed = Object.entries(repo.fingerprints || {})
|
|
333
|
+
.filter(([path, fingerprint]) => fingerprint && previousFingerprints[path] !== fingerprint).length;
|
|
334
|
+
const editDelta = Math.max(finiteOrNull(input.editDelta, true) || 0, observed);
|
|
316
335
|
// Compaction closes a context window, so the edit budget starts again from zero.
|
|
317
336
|
const previousEdits = input.event === 'compact' ? 0 : (existing?.editCount || 0);
|
|
318
|
-
const editCount = Math.max(0, previousEdits +
|
|
337
|
+
const editCount = Math.max(0, previousEdits + editDelta);
|
|
319
338
|
// Hint once per threshold crossed, and only on the edit that crosses it: re-emitting on
|
|
320
339
|
// every later event (Stop in particular) wakes the model again and loops.
|
|
321
340
|
const crossedThreshold = editCount > previousEdits
|
|
@@ -358,11 +377,18 @@ export function capture(input = {}) {
|
|
|
358
377
|
if (errors.length) return { record: null, path: null, notice: null, compactionHint: false, errors };
|
|
359
378
|
atomicJson(recordPath, record);
|
|
360
379
|
existing = record;
|
|
380
|
+
const compactionHint = Boolean(config.compactionHint && crossedThreshold);
|
|
381
|
+
// The fingerprints the next event compares against live on the ANCHOR, not on the record: the
|
|
382
|
+
// record's schema is closed and every field of it is required, so a new field there would fail
|
|
383
|
+
// validation on every record already written. Written only when they moved, so a read-only shell
|
|
384
|
+
// command costs no extra disk.
|
|
385
|
+
const fingerprintsMoved = observed > 0 || !anchor.lastFingerprints;
|
|
386
|
+
if (fingerprintsMoved) atomicJson(anchorPath, { ...anchor, lastFingerprints: repo.fingerprints || {} });
|
|
361
387
|
return {
|
|
362
388
|
record,
|
|
363
389
|
path: recordPath,
|
|
364
390
|
notice: consumeNotice(store),
|
|
365
|
-
compactionHint
|
|
391
|
+
compactionHint,
|
|
366
392
|
};
|
|
367
393
|
}
|
|
368
394
|
|
|
@@ -414,13 +440,19 @@ export function resume(input = {}) {
|
|
|
414
440
|
ensureStore(store.root);
|
|
415
441
|
const now = iso(input.now);
|
|
416
442
|
prune(store.root, now, config);
|
|
417
|
-
const
|
|
418
|
-
const
|
|
443
|
+
const here = resolve(input.worktreeCwd || cwd);
|
|
444
|
+
const current = snapshot(here, null);
|
|
445
|
+
const records = readRecords(store.root).filter((record) => validAnchor(here, record))
|
|
419
446
|
.sort((a, b) => b.timestamps.updatedAt.localeCompare(a.timestamps.updatedAt));
|
|
420
447
|
let record = records.find((candidate) => candidate.branch === current.branch && candidate.worktree === current.worktree) || null;
|
|
421
448
|
let match = record ? 'exact' : 'none';
|
|
449
|
+
// Only ever this worktree's own records. The chain used to end in `|| records[0]` — the newest
|
|
450
|
+
// record in the store, whoever wrote it — which a moved or renamed checkout reached every time,
|
|
451
|
+
// because its stale records match nothing. A shared store is a place to KEEP several worktrees'
|
|
452
|
+
// journals, never a reason to hand one of them to another. No continuation is the honest answer
|
|
453
|
+
// when there is nothing of yours to continue.
|
|
422
454
|
if (!record) {
|
|
423
|
-
record = records.find((candidate) => candidate.worktree === current.worktree) ||
|
|
455
|
+
record = records.find((candidate) => candidate.worktree === current.worktree) || null;
|
|
424
456
|
if (record) match = 'nearby';
|
|
425
457
|
}
|
|
426
458
|
const lessons = selectedLessons(store.root, config);
|
|
@@ -30,14 +30,17 @@ const has = (...p) => existsSync(join(root, ...p));
|
|
|
30
30
|
|
|
31
31
|
// The npm lookup (up to 1.5 s) starts now and runs while the local checks below do their work;
|
|
32
32
|
// the update block at the end only awaits it.
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// The registry returns the whole package.json on `/latest`, so `rscFixes` arrives in the same
|
|
34
|
+
// response as the version. RSC_LATEST_JSON / RSC_LATEST override the lookup (tests / mirrors).
|
|
35
|
+
const latestDoc = process.env.RSC_NO_UPDATE_CHECK ? Promise.resolve(null)
|
|
36
|
+
: process.env.RSC_LATEST_JSON ? Promise.resolve().then(() => JSON.parse(process.env.RSC_LATEST_JSON)).catch(() => null)
|
|
37
|
+
: process.env.RSC_LATEST ? Promise.resolve({ version: process.env.RSC_LATEST })
|
|
35
38
|
: (async () => {
|
|
36
39
|
const ctrl = new AbortController();
|
|
37
40
|
const timer = setTimeout(() => ctrl.abort(), 1500);
|
|
38
41
|
try {
|
|
39
42
|
const res = await fetch('https://registry.npmjs.org/@damiangilgonzalez%2fharness/latest', { signal: ctrl.signal });
|
|
40
|
-
return
|
|
43
|
+
return await res.json();
|
|
41
44
|
} catch { return null; } finally { clearTimeout(timer); }
|
|
42
45
|
})();
|
|
43
46
|
|
|
@@ -208,19 +211,31 @@ ACTION: run \`npx @damiangilgonzalez/harness audit\`. Opt out with .rsc/.no-audi
|
|
|
208
211
|
// it's a relocation, not a delete). Opt out with .rsc/.no-claudemd-check.
|
|
209
212
|
const CLAUDEMD_MAX_LINES = 200;
|
|
210
213
|
if (!has('.rsc', '.no-claudemd-check')) {
|
|
214
|
+
// WHICH file is the project's instructions is no longer a constant. From Claude Code 2.1.277, a
|
|
215
|
+
// project with no CLAUDE.md is read through its root AGENTS.md instead — same per-turn cost, same
|
|
216
|
+
// adherence rot when it grows, and until now nothing measured it. Measure the file that is
|
|
217
|
+
// actually being loaded: CLAUDE.md when there is one, otherwise AGENTS.md.
|
|
218
|
+
const name = has('CLAUDE.md') ? 'CLAUDE.md' : (has('AGENTS.md') ? 'AGENTS.md' : null);
|
|
211
219
|
try {
|
|
212
|
-
const lines = readFileSync(join(root,
|
|
220
|
+
const lines = readFileSync(join(root, name), 'utf8').split('\n').length;
|
|
213
221
|
if (lines > CLAUDEMD_MAX_LINES) {
|
|
222
|
+
// Only the CLAUDE.md claim is unconditional. A client older than 2.1.277 does not read
|
|
223
|
+
// AGENTS.md at all, and this hook cannot tell which it is talking to (the SessionStart
|
|
224
|
+
// payload carries no version), so the AGENTS.md line says when the cost applies instead of
|
|
225
|
+
// asserting it — a notice that overstates its case is the kind that gets opted out of.
|
|
226
|
+
const why = name === 'CLAUDE.md'
|
|
227
|
+
? "it's read every turn, so each line costs context"
|
|
228
|
+
: "Claude Code 2.1.277+ loads it every turn when there is no CLAUDE.md, so each line costs context";
|
|
214
229
|
process.stdout.write(`
|
|
215
|
-
===== rsc
|
|
216
|
-
|
|
230
|
+
===== rsc ${name} hygiene =====
|
|
231
|
+
${name} is ${lines} lines — over the ~${CLAUDEMD_MAX_LINES}-line budget (${why}).
|
|
217
232
|
ACTION: offload the Knowledge map / overgrown sections into docs/wiki/index.md and leave a short
|
|
218
|
-
pointer in
|
|
233
|
+
pointer in ${name} (no info lost — it's a move). The \`harness\` skill owns the procedure.
|
|
219
234
|
Opt out with .rsc/.no-claudemd-check.
|
|
220
235
|
=================================
|
|
221
236
|
`);
|
|
222
237
|
}
|
|
223
|
-
} catch { /* no
|
|
238
|
+
} catch { /* no instructions file (name === null) → nothing to check */ }
|
|
224
239
|
}
|
|
225
240
|
|
|
226
241
|
// Worktree sweep: a worktree whose work already reached the trunk is landed work wearing the same
|
|
@@ -240,8 +255,8 @@ if (has('.git')) {
|
|
|
240
255
|
// those make the fingerprint and an unchanged one reuses the last notice. Safe because `reap`
|
|
241
256
|
// re-classifies before it removes anything.
|
|
242
257
|
const trunk = W.resolveTrunk(root);
|
|
243
|
-
const tip = trunk ? spawnSync('git', ['-C', root, 'rev-parse', trunk], { encoding: 'utf8' }).stdout : '';
|
|
244
|
-
const list = spawnSync('git', ['-C', root, 'worktree', 'list', '--porcelain'], { encoding: 'utf8' }).stdout;
|
|
258
|
+
const tip = trunk ? spawnSync('git', ['-C', root, 'rev-parse', trunk], { encoding: 'utf8', windowsHide: true }).stdout : '';
|
|
259
|
+
const list = spawnSync('git', ['-C', root, 'worktree', 'list', '--porcelain'], { encoding: 'utf8', windowsHide: true }).stdout;
|
|
245
260
|
const fingerprint = `${W.isCleanupEnabled(root)}\n${trunk}\n${tip}\n${list}`;
|
|
246
261
|
const cacheFile = join(root, '.rsc', 'worktree-sweep.json');
|
|
247
262
|
let cached = null;
|
|
@@ -287,12 +302,49 @@ function isNewer(a, b) {
|
|
|
287
302
|
return false;
|
|
288
303
|
}
|
|
289
304
|
|
|
305
|
+
// "rsc X is out" was being ignored — the maintainer's own workspace sat on 2.0.4 for days with this
|
|
306
|
+
// notice on every session, and two people in two days reported bugs that were already fixed. A
|
|
307
|
+
// version number names nothing anyone recognises. A symptom does: someone who sees a red error after
|
|
308
|
+
// every turn updates the moment they are told that is what the update fixes (`suggest` §3: say what
|
|
309
|
+
// is wrong as a symptom, not as a cause).
|
|
310
|
+
//
|
|
311
|
+
// The symptoms travel for free: the registry's `/latest` document is the published package.json, so
|
|
312
|
+
// its `rscFixes` field — `{ fixedIn, symptom }` — arrives in the response this check already makes.
|
|
313
|
+
// It is text a model will read, so every entry is reduced to one short plain line or dropped.
|
|
314
|
+
const SEMVER_ONLY = /^\d+\.\d+\.\d+$/;
|
|
315
|
+
function fixesSince(list, installed, latest) {
|
|
316
|
+
if (!Array.isArray(list)) return [];
|
|
317
|
+
const out = [];
|
|
318
|
+
for (const item of list) {
|
|
319
|
+
if (!item || typeof item !== 'object' || typeof item.symptom !== 'string') continue;
|
|
320
|
+
if (!SEMVER_ONLY.test(String(item.fixedIn))) continue;
|
|
321
|
+
if (!isNewer(item.fixedIn, installed) || isNewer(item.fixedIn, latest)) continue;
|
|
322
|
+
const symptom = item.symptom.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200);
|
|
323
|
+
if (symptom) out.push({ fixedIn: item.fixedIn, symptom });
|
|
324
|
+
}
|
|
325
|
+
return out.slice(0, 8);
|
|
326
|
+
}
|
|
327
|
+
|
|
290
328
|
if (!process.env.RSC_NO_UPDATE_CHECK) {
|
|
291
329
|
try {
|
|
292
330
|
const installed = readFileSync(join(root, '.rsc', '.version'), 'utf8').trim();
|
|
293
|
-
const
|
|
331
|
+
const doc = await latestDoc;
|
|
332
|
+
const latest = doc?.version;
|
|
294
333
|
if (installed && latest && isNewer(latest, installed)) {
|
|
295
|
-
|
|
334
|
+
let fixes = [];
|
|
335
|
+
try { fixes = fixesSince(doc.rscFixes, installed, latest); } catch { fixes = []; }
|
|
336
|
+
if (fixes.length) {
|
|
337
|
+
process.stdout.write(`
|
|
338
|
+
===== rsc update available =====
|
|
339
|
+
rsc ${latest} is out — you have ${installed}. It fixes problems this install still has:
|
|
340
|
+
${fixes.map((f) => ` - ${f.symptom} (fixed in ${f.fixedIn})`).join('\n')}
|
|
341
|
+
ACTION: before starting the task, tell the user in one line that the update fixes what they
|
|
342
|
+
may be seeing, and if they say yes, run it in this project:
|
|
343
|
+
npx @damiangilgonzalez/harness@latest
|
|
344
|
+
================================
|
|
345
|
+
`);
|
|
346
|
+
} else {
|
|
347
|
+
process.stdout.write(`
|
|
296
348
|
===== rsc update available =====
|
|
297
349
|
rsc ${latest} is out — you have ${installed}.
|
|
298
350
|
ACTION: tell the user a new version is available and, if they say yes, run:
|
|
@@ -300,6 +352,7 @@ ACTION: tell the user a new version is available and, if they say yes, run:
|
|
|
300
352
|
(That reinstalls and refreshes the skill content to the latest.)
|
|
301
353
|
================================
|
|
302
354
|
`);
|
|
355
|
+
}
|
|
303
356
|
}
|
|
304
357
|
} catch { /* offline / no baseline / parse error → stay silent */ }
|
|
305
358
|
}
|
package/targets/ship-guard.mjs
CHANGED
|
@@ -67,7 +67,7 @@ const MERGE = /\bgit\s+merge\b/;
|
|
|
67
67
|
if (!TRUNK.test(command) && !MERGE.test(command)) allow();
|
|
68
68
|
|
|
69
69
|
const git = (...args) => {
|
|
70
|
-
const r = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
70
|
+
const r = spawnSync('git', ['-C', root, ...args], { windowsHide: true, encoding: 'utf8' });
|
|
71
71
|
return r.status === 0 ? (r.stdout || '').trim() : null;
|
|
72
72
|
};
|
|
73
73
|
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
//
|
|
17
17
|
// Imported by `.rsc/session-start.mjs` (the sweep) and by `scripts/rsc.js` (`rsc worktrees`), so the
|
|
18
18
|
// rule exists once and both entry points cannot drift apart. Same shape as `sello.mjs`.
|
|
19
|
-
import { existsSync, realpathSync } from 'node:fs';
|
|
19
|
+
import { existsSync, realpathSync, mkdirSync, readFileSync, writeFileSync, renameSync, chmodSync } from 'node:fs';
|
|
20
20
|
import { join, resolve, dirname, basename, relative, sep } from 'node:path';
|
|
21
21
|
import { spawnSync } from 'node:child_process';
|
|
22
22
|
import { pathToFileURL } from 'node:url';
|
|
@@ -54,14 +54,26 @@ export const REGENERABLE_FILES = ['.DS_Store', 'Thumbs.db', '.coverage'];
|
|
|
54
54
|
// Trunk candidates, most authoritative first. The remote tip beats a local branch that may be stale.
|
|
55
55
|
const TRUNKS = ['origin/main', 'main', 'origin/master', 'master'];
|
|
56
56
|
|
|
57
|
-
// Branch shapes
|
|
58
|
-
|
|
57
|
+
// Branch shapes the catalog's isolation produces.
|
|
58
|
+
//
|
|
59
|
+
// `worktrees` documents `feat/<slug>`, and while SDD was the only lane that was the whole set:
|
|
60
|
+
// every isolated branch was a feature. 2.0.0 made FTD the default and FTD names a branch for what
|
|
61
|
+
// it is — a fix is not a feature — so `fix/`, `docs/` and `chore/` fell outside, scored as half a
|
|
62
|
+
// signal, and were never swept, while `ftd` promises in writing that the cleanup is automatic once
|
|
63
|
+
// the branch lands. Found by using it: the two fixes released on 2026-09-18 both had to be removed
|
|
64
|
+
// by hand. These are the Conventional Commits types, which is what the commit guard already
|
|
65
|
+
// enforces on every commit in a project rsc governs — so the branch shape and the commit shape now
|
|
66
|
+
// come from the same vocabulary instead of two that drifted apart.
|
|
67
|
+
//
|
|
68
|
+
// Widening one signal, never the conjunction: a branch outside this vocabulary is still `ambiguous`
|
|
69
|
+
// wherever it sits, and one of these in a directory rsc does not own is still `ambiguous` too.
|
|
70
|
+
const RSC_BRANCH = /^(?:feat|feature|fix|docs|chore|refactor|test|perf|ci|build|style)\//;
|
|
59
71
|
|
|
60
72
|
function git(cwd, args) {
|
|
61
73
|
// 64 MiB, because node's 1 MiB default kills git mid-write on any worktree with a few thousand
|
|
62
74
|
// stray files and hands back a truncated-but-successful-looking result. Measured: in the 1.0-1.15 MB
|
|
63
75
|
// band the same unchanged worktree flipped between verdicts across runs.
|
|
64
|
-
const r = spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
76
|
+
const r = spawnSync('git', ['-C', cwd, ...args], { windowsHide: true, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
65
77
|
// `raw` matters for porcelain output: its leading space is a status code, not padding, and
|
|
66
78
|
// trimming it shifted every path by one character — so the message naming the file about to be
|
|
67
79
|
// lost named a file that does not exist. A refusal has to be true to be actionable (P6).
|
|
@@ -183,13 +195,23 @@ export function provenanceOf(root, wt) {
|
|
|
183
195
|
* The reflog is git's own record of the ref moving; when it cannot answer, the answer is no, because
|
|
184
196
|
* accumulating a stale worktree costs a directory and the other mistake costs someone's afternoon.
|
|
185
197
|
*/
|
|
186
|
-
export function hasLandedWork(root, wt
|
|
187
|
-
const tip = git(root, ['rev-parse', `${trunk}^{commit}`]);
|
|
188
|
-
if (tip.ok && wt.head && tip.out === wt.head) return false;
|
|
198
|
+
export function hasLandedWork(root, wt) {
|
|
189
199
|
if (!wt.branch) return false;
|
|
200
|
+
// Ask the reflog FIRST. It is the only record that distinguishes the two states that look
|
|
201
|
+
// identical from the outside: a worktree sitting at the trunk because it was just created there,
|
|
202
|
+
// and one sitting at the trunk because its work landed by fast-forward and took the trunk with
|
|
203
|
+
// it. Until 2.0.3 the tip comparison below ran first and answered "nothing landed" for both, so
|
|
204
|
+
// every branch landed the ordinary FTD way — commit, push, fast-forward — was kept for ever.
|
|
205
|
+
// Found on 2026-09-18 watching this repo refuse to sweep its own branch for the wrong reason.
|
|
190
206
|
const log = git(root, ['reflog', 'show', '--format=%gs', wt.branch]);
|
|
191
|
-
if (
|
|
192
|
-
|
|
207
|
+
if (log.ok && log.out) return log.out.split('\n').some((line) => line.startsWith('commit'));
|
|
208
|
+
// No reflog to ask — a fresh clone, or a branch whose reflog has expired. There is then no
|
|
209
|
+
// evidence this branch ever carried anything, and this whole module fails towards keeping the
|
|
210
|
+
// directory: unproven is not proven. (The tip comparison that used to sit here is deliberately
|
|
211
|
+
// gone rather than kept as a fallback: both of its outcomes are this same answer, and a line
|
|
212
|
+
// that cannot change the result while reading like a guard is the kind of decoration that made
|
|
213
|
+
// the bug above hard to see.)
|
|
214
|
+
return false;
|
|
193
215
|
}
|
|
194
216
|
|
|
195
217
|
export function integrationOf(root, wt, trunk) {
|
|
@@ -283,7 +305,7 @@ export function classifyWorktrees(root) {
|
|
|
283
305
|
|
|
284
306
|
const integration = integrationOf(root, wt, trunk);
|
|
285
307
|
if (integration !== 'integrated') return { ...base, reasons: [integration] };
|
|
286
|
-
if (!hasLandedWork(root, wt
|
|
308
|
+
if (!hasLandedWork(root, wt)) return { ...base, reasons: ['nothing-landed'] };
|
|
287
309
|
|
|
288
310
|
const { dirty, outside, readable } = contentOutsideHistory(wt.path);
|
|
289
311
|
if (!readable) return { ...base, reasons: ['unreadable'] };
|
|
@@ -373,6 +395,144 @@ export function reapWorktree(root, targetPath, { confirmed = false } = {}) {
|
|
|
373
395
|
* README.md", judged that a nuisance, confirmed, and the confirmation landed on a `production.env`
|
|
374
396
|
* the message never mentioned. The module knew. It just did not say.
|
|
375
397
|
*/
|
|
398
|
+
/**
|
|
399
|
+
* The unattended half: remove what classification already called `safe`, and nothing else.
|
|
400
|
+
*
|
|
401
|
+
* `sweep` offers and never acts, on purpose — there is a human reading it. This runs from a git hook
|
|
402
|
+
* the moment work lands, where there is nobody to ask, so the two verdicts that would have become a
|
|
403
|
+
* question become a refusal instead. It adds NO judgement of its own: the judgement is the dangerous
|
|
404
|
+
* part, it is written above, and it is already tested in both directions.
|
|
405
|
+
*
|
|
406
|
+
* Two properties its caller depends on, both load-bearing:
|
|
407
|
+
* - it never throws. The caller is git, mid-merge. A throw here would turn a cleanup into a failed
|
|
408
|
+
* merge, which is a far worse bug than the one this fixes.
|
|
409
|
+
* - it is silent when there is nothing to do, so the common merge prints nothing.
|
|
410
|
+
*/
|
|
411
|
+
export function autoReap(root) {
|
|
412
|
+
const result = { reaped: [], skipped: [], disabled: false };
|
|
413
|
+
try {
|
|
414
|
+
if (!isCleanupEnabled(root)) {
|
|
415
|
+
result.disabled = true;
|
|
416
|
+
return result;
|
|
417
|
+
}
|
|
418
|
+
for (const candidate of classifyWorktrees(root)) {
|
|
419
|
+
// Not the protection, and it must not be mistaken for one: `reapWorktree` refuses an `ask`
|
|
420
|
+
// on its own, and that refusal is the gate — mutation-tested by 18b and 23. Verified here on
|
|
421
|
+
// 2026-09-17 by removing this line: every test still passed. It stays because skipping early
|
|
422
|
+
// avoids re-classifying the whole repository once per candidate, and because the recorded
|
|
423
|
+
// reason is then the verdict itself rather than a message written for a human to read.
|
|
424
|
+
if (candidate.verdict !== 'safe') {
|
|
425
|
+
result.skipped.push({ path: candidate.path, reason: candidate.reasons.join(', ') || candidate.verdict });
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
const out = reapWorktree(root, candidate.path);
|
|
429
|
+
if (out.removed) result.reaped.push(candidate.path);
|
|
430
|
+
else result.skipped.push({ path: candidate.path, reason: out.reason });
|
|
431
|
+
}
|
|
432
|
+
} catch (err) {
|
|
433
|
+
// Swallowed deliberately, and recorded rather than discarded: the merge must survive whatever
|
|
434
|
+
// went wrong in here, but a silent failure that leaves no trace is how this rots unnoticed.
|
|
435
|
+
result.skipped.push({ path: root, reason: `cleanup could not run: ${err.message}` });
|
|
436
|
+
}
|
|
437
|
+
return result;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export const HOOK_MARKER = '# rsc-managed worktree cleanup (post-merge) v1';
|
|
441
|
+
|
|
442
|
+
// Every branch exits 0. That is the whole contract with git: this hook runs in the middle of
|
|
443
|
+
// somebody's merge, and a cleanup that can turn a good merge into a failed one is a worse bug than
|
|
444
|
+
// the accumulation it exists to fix. Missing node, missing reaper, broken reaper, unreadable repo —
|
|
445
|
+
// all of them are "do nothing", never "fail".
|
|
446
|
+
const HOOK_BODY = `#!/bin/sh
|
|
447
|
+
${'# rsc-managed worktree cleanup (post-merge) v1'}
|
|
448
|
+
# Retires worktrees whose work has just landed. Installed by rsc; safe to delete.
|
|
449
|
+
# Turn it off for this project with: .rsc/.no-worktree-cleanup
|
|
450
|
+
hook_dir=$(dirname "$0")
|
|
451
|
+
if [ -x "$hook_dir/post-merge.rsc-local" ]; then
|
|
452
|
+
"$hook_dir/post-merge.rsc-local" "$@" || true
|
|
453
|
+
fi
|
|
454
|
+
root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
|
|
455
|
+
[ -f "$root/.rsc/worktree-reaper.mjs" ] || exit 0
|
|
456
|
+
command -v node >/dev/null 2>&1 || exit 0
|
|
457
|
+
node "$root/.rsc/worktree-reaper.mjs" "$root" auto 2>/dev/null || true
|
|
458
|
+
exit 0
|
|
459
|
+
`;
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Where git will ACTUALLY look for hooks, asked of git rather than assumed.
|
|
463
|
+
*
|
|
464
|
+
* The assumption was `<root>/.git/hooks`, and it is wrong in three ordinary situations at once:
|
|
465
|
+
* a repo that sets `core.hooksPath` (husky v9 writes `.husky/_`, lefthook and committed
|
|
466
|
+
* `.githooks/` dispatchers do the same), a linked worktree where `.git` is a FILE and the mkdir
|
|
467
|
+
* raised ENOTDIR, and a submodule. Reported from a user's repo on 2026-09-18 against 2.0.1: rsc
|
|
468
|
+
* wrote the hook where git does not look, `doctor` read the same wrong path back, and the report
|
|
469
|
+
* said `armed` about something that could never run. Verified there and here that a single
|
|
470
|
+
* `rev-parse` answers all three.
|
|
471
|
+
*
|
|
472
|
+
* `--path-format=absolute` needs git 2.31 (Mar 2021). Older git gets the old path — which is
|
|
473
|
+
* correct whenever `core.hooksPath` is unset, i.e. the case it was always right about.
|
|
474
|
+
*/
|
|
475
|
+
export function hooksDir(root) {
|
|
476
|
+
const r = git(root, ['rev-parse', '--path-format=absolute', '--git-path', 'hooks']);
|
|
477
|
+
if (r.ok && r.out) return r.out;
|
|
478
|
+
return join(real(root), '.git', 'hooks');
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Whether that directory is ours to write in. Inside the repository's own git dir: yes, it is
|
|
482
|
+
// private plumbing that is never cloned and never committed. Anywhere else it belongs to the user
|
|
483
|
+
// or to another tool — a committed `.githooks/` is their source tree (writing there would put rsc
|
|
484
|
+
// in their diff, and moving an existing hook aside would rename a TRACKED file), and `.husky/_` is
|
|
485
|
+
// regenerated by husky, so anything we left there disappears on their next install. Report it and
|
|
486
|
+
// let the person decide; that is P4, and it is the same reasoning the comment on this function has
|
|
487
|
+
// claimed since the hook shipped, finally applied instead of only stated.
|
|
488
|
+
export function hooksDirIsOurs(root, dir) {
|
|
489
|
+
const r = git(root, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
490
|
+
if (!r.ok || !r.out) return false;
|
|
491
|
+
const gitDir = real(r.out);
|
|
492
|
+
const target = real(dir);
|
|
493
|
+
return target === gitDir || target.startsWith(gitDir + sep);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Install the trigger. The judgement has existed and been tested for a while; what never existed was
|
|
498
|
+
* something that runs it at the moment work lands. `post-merge` is that moment, and it covers both
|
|
499
|
+
* of ship's landing paths — a local `merge --no-ff` and the `pull --ff-only` after a forge merge.
|
|
500
|
+
*
|
|
501
|
+
* A hook lives in `.git/hooks/`, which is not cloned. So this is called on install AND on repair,
|
|
502
|
+
* and `doctor` reports its absence: a trigger nobody re-installs is a trigger that quietly stops
|
|
503
|
+
* existing on every machine but the one that ran the installer.
|
|
504
|
+
*
|
|
505
|
+
* Somebody else's post-merge is moved aside and chained, never overwritten. Husky and lefthook put
|
|
506
|
+
* real work in there, and eating it to install a convenience would be indefensible.
|
|
507
|
+
*/
|
|
508
|
+
export function installMergeHook(root) {
|
|
509
|
+
try {
|
|
510
|
+
const dir = hooksDir(root);
|
|
511
|
+
if (!hooksDirIsOurs(root, dir)) {
|
|
512
|
+
return { installed: false, state: 'unreachable', reason: `git reads hooks from ${dir}, which belongs to this repository or to another tool — rsc will not write there.` };
|
|
513
|
+
}
|
|
514
|
+
const hook = join(dir, 'post-merge');
|
|
515
|
+
mkdirSync(dir, { recursive: true });
|
|
516
|
+
if (existsSync(hook)) {
|
|
517
|
+
const current = readFileSync(hook, 'utf8');
|
|
518
|
+
if (current.includes(HOOK_MARKER)) {
|
|
519
|
+
// Already ours. Rewrite so an older body converges, but never chain ourselves behind
|
|
520
|
+
// ourselves — repair runs this repeatedly and stacking would run the cleanup N times.
|
|
521
|
+
writeFileSync(hook, HOOK_BODY);
|
|
522
|
+
chmodSync(hook, 0o755);
|
|
523
|
+
return { installed: true, chained: existsSync(join(dir, 'post-merge.rsc-local')) };
|
|
524
|
+
}
|
|
525
|
+
renameSync(hook, join(dir, 'post-merge.rsc-local'));
|
|
526
|
+
chmodSync(join(dir, 'post-merge.rsc-local'), 0o755);
|
|
527
|
+
}
|
|
528
|
+
writeFileSync(hook, HOOK_BODY);
|
|
529
|
+
chmodSync(hook, 0o755);
|
|
530
|
+
return { installed: true, chained: existsSync(join(dir, 'post-merge.rsc-local')) };
|
|
531
|
+
} catch (err) {
|
|
532
|
+
return { installed: false, state: 'failed', reason: err.message };
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
376
536
|
export function refusal(candidate) {
|
|
377
537
|
const d = candidate.details || {};
|
|
378
538
|
const parts = (candidate.reasons || []).map((reason) => {
|
|
@@ -449,7 +609,11 @@ export function summarize(candidate, root) {
|
|
|
449
609
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
450
610
|
const root = resolve(process.argv[2] || process.cwd());
|
|
451
611
|
const candidates = classifyWorktrees(root).filter((c) => c.verdict !== 'skip');
|
|
452
|
-
if (process.argv[3] === '
|
|
612
|
+
if (process.argv[3] === 'auto') {
|
|
613
|
+
// The unattended entry point, called by the post-merge hook. Prints only what it actually did.
|
|
614
|
+
const out = autoReap(root);
|
|
615
|
+
for (const p of out.reaped) process.stdout.write(`rsc: retired worktree ${p}\n`);
|
|
616
|
+
} else if (process.argv[3] === 'reap') {
|
|
453
617
|
const one = process.argv[4];
|
|
454
618
|
const targets = one ? [resolve(one)] : candidates.filter((c) => c.verdict === 'safe').map((c) => c.path);
|
|
455
619
|
for (const t of targets) {
|