@claude-flow/cli 3.64.0 → 3.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/helpers/helpers.manifest.json +2 -2
- package/catalog-manifest.json +2 -2
- package/dist/src/commands/cost.d.ts +18 -0
- package/dist/src/commands/cost.js +195 -0
- package/dist/src/commands/doctor-auto-approve.d.ts +9 -0
- package/dist/src/commands/doctor-auto-approve.js +32 -0
- package/dist/src/commands/doctor.js +21 -3
- package/dist/src/commands/hive-mind-worker.d.ts +2 -0
- package/dist/src/commands/hive-mind-worker.js +9 -3
- package/dist/src/commands/hive-mind.js +15 -1
- package/dist/src/commands/hooks.js +8 -2
- package/dist/src/commands/index.js +2 -0
- package/dist/src/commands/init.js +76 -2
- package/dist/src/commands/mods.js +13 -0
- package/dist/src/commands/plugins-paths.d.ts +13 -0
- package/dist/src/commands/plugins-paths.js +55 -0
- package/dist/src/commands/plugins-verify.d.ts +9 -0
- package/dist/src/commands/plugins-verify.js +92 -0
- package/dist/src/commands/plugins.js +13 -1
- package/dist/src/cost/attribution.d.ts +35 -0
- package/dist/src/cost/attribution.js +101 -0
- package/dist/src/cost/budget-file.d.ts +57 -0
- package/dist/src/cost/budget-file.js +113 -0
- package/dist/src/cost/litellm.d.ts +69 -0
- package/dist/src/cost/litellm.js +135 -0
- package/dist/src/init/executor.d.ts +7 -2
- package/dist/src/init/executor.js +64 -10
- package/dist/src/init/helper-commands.d.ts +16 -0
- package/dist/src/init/helper-commands.js +73 -0
- package/dist/src/init/risky-settings-gate.d.ts +39 -0
- package/dist/src/init/risky-settings-gate.js +130 -0
- package/dist/src/init/settings-risk-scanner.d.ts +35 -0
- package/dist/src/init/settings-risk-scanner.js +85 -0
- package/dist/src/init/types.d.ts +12 -0
- package/dist/src/mcp-tools/capability-brain.d.ts +1 -1
- package/dist/src/mcp-tools/capability-brain.js +1 -1
- package/dist/src/mcp-tools/hooks-tools.js +2 -1
- package/dist/src/memory/verified-guards.d.ts +70 -0
- package/dist/src/memory/verified-guards.js +101 -0
- package/dist/src/memory/verified-outcomes.d.ts +68 -3
- package/dist/src/memory/verified-outcomes.js +88 -19
- package/dist/src/memory/verified-reverify.d.ts +63 -0
- package/dist/src/memory/verified-reverify.js +174 -0
- package/dist/src/missions/schemas.d.ts +4 -4
- package/dist/src/mods/apply.d.ts +2 -0
- package/dist/src/mods/apply.js +68 -8
- package/dist/src/mods/command-registry/schema.d.ts +36 -36
- package/dist/src/mods/compact-snapshots.d.ts +21 -0
- package/dist/src/mods/compact-snapshots.js +45 -0
- package/dist/src/mods/headless-check.d.ts +58 -0
- package/dist/src/mods/headless-check.js +137 -0
- package/dist/src/mods/headless-plugins.d.ts +44 -0
- package/dist/src/mods/headless-plugins.js +92 -0
- package/dist/src/mods/install-trust.d.ts +28 -0
- package/dist/src/mods/install-trust.js +127 -0
- package/dist/src/mods/install.d.ts +19 -0
- package/dist/src/mods/install.js +38 -1
- package/dist/src/mods/plugin-paths.d.ts +60 -0
- package/dist/src/mods/plugin-paths.js +131 -0
- package/dist/src/mods/plugin-repair.d.ts +8 -0
- package/dist/src/mods/plugin-repair.js +7 -0
- package/dist/src/plugins/marketplace-trust/scan.d.ts +23 -0
- package/dist/src/plugins/marketplace-trust/scan.js +255 -0
- package/dist/src/plugins/marketplace-trust/signature.d.ts +53 -0
- package/dist/src/plugins/marketplace-trust/signature.js +140 -0
- package/dist/src/plugins/marketplace-trust/verify-status.d.ts +42 -0
- package/dist/src/plugins/marketplace-trust/verify-status.js +110 -0
- package/dist/src/services/headless-worker-executor.d.ts +2 -0
- package/dist/src/services/headless-worker-executor.js +13 -3
- package/package.json +2 -1
- package/plugins/ruflo-metaharness/.claude-plugin/plugin.json +8 -1
- package/plugins/ruflo-metaharness/CHANGELOG.md +6 -0
- package/plugins/ruflo-metaharness/README.md +14 -1
- package/plugins/ruflo-metaharness/agents/metaharness-architect.md +2 -0
- package/plugins/ruflo-metaharness/hooks/hooks.json +16 -2
- package/plugins/ruflo-metaharness/scripts/_redblue.mjs +12 -2
- package/plugins/ruflo-metaharness/scripts/_secret-env.cjs +50 -0
- package/plugins/ruflo-metaharness/scripts/_secret.mjs +35 -0
- package/plugins/ruflo-metaharness/scripts/smoke.sh +7 -7
- package/plugins/ruflo-metaharness/skills/harness-bench/SKILL.md +2 -1
- package/plugins/ruflo-metaharness/skills/harness-drift-from-history/SKILL.md +1 -1
- package/plugins/ruflo-metaharness/skills/harness-evolve/SKILL.md +2 -1
- package/plugins/ruflo-metaharness/skills/harness-gepa/SKILL.md +1 -1
- package/plugins/ruflo-metaharness/skills/harness-learn/SKILL.md +2 -1
- package/plugins/ruflo-metaharness/skills/harness-mint/SKILL.md +2 -1
- package/plugins/ruflo-metaharness/skills/harness-oia-audit/SKILL.md +1 -0
- package/plugins/ruflo-metaharness/skills/harness-score/SKILL.md +1 -1
- package/plugins/ruflo-metaharness/skills/harness-security-bench/SKILL.md +2 -1
- package/plugins/ruflo-metaharness/skills/harness-similarity/SKILL.md +1 -1
|
@@ -19,10 +19,18 @@
|
|
|
19
19
|
* verified outcomes produced, those at or above the SONA routing gate as
|
|
20
20
|
* lessons, and repeated verified failures as cautions. The mod reads that
|
|
21
21
|
* file and adds at most three cited lessons to a prompt.
|
|
22
|
+
*
|
|
23
|
+
* ADR-503 hardens it against gamed signals (verified-guards.ts,
|
|
24
|
+
* verified-reverify.ts): a tainted success does not train unless the project
|
|
25
|
+
* allows its taint class, an opt-in re-verify can downgrade a success before
|
|
26
|
+
* it trains, and an anomaly gate holds suspicious patterns back from the
|
|
27
|
+
* prompt. Every one of these can only downgrade.
|
|
22
28
|
*/
|
|
23
29
|
import { createHash } from 'node:crypto';
|
|
24
30
|
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
25
31
|
import { dirname, join } from 'node:path';
|
|
32
|
+
import { addOutcome, blockedTaints, emptyStats, holdReason, parseStats, parseTaint, readLoopConfig } from './verified-guards.js';
|
|
33
|
+
import { gitWorktreeRunner, ReverifySession } from './verified-reverify.js';
|
|
26
34
|
export const VERIFIED_PATH = join('.claude-flow', 'data', 'verified-trajectories.jsonl');
|
|
27
35
|
export const LEDGER_PATH = join('.claude-flow', 'data', 'verified-ingest.json');
|
|
28
36
|
export const LESSONS_PATH = join('.claude-flow', 'data', 'learned-lessons.json');
|
|
@@ -35,6 +43,7 @@ export const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
|
35
43
|
export const MAX_LESSONS = 20;
|
|
36
44
|
/** Verified failures (and more failures than successes) before a pattern becomes a caution. */
|
|
37
45
|
export const CAUTION_MIN_FAILURES = 2;
|
|
46
|
+
export const MAX_REVERIFY_LOG = 50;
|
|
38
47
|
const OUTCOMES = ['verified-success', 'verified-failure', 'unknown'];
|
|
39
48
|
const KINDS = ['test', 'build', 'typecheck', 'lint'];
|
|
40
49
|
const ID = /^vt-[A-Za-z0-9_-]{1,80}$/;
|
|
@@ -81,10 +90,12 @@ export function parseRecord(line) {
|
|
|
81
90
|
.filter((s) => !!s && typeof s === 'object' && typeof s.tool === 'string' && TOOL.test(s.tool))
|
|
82
91
|
.map(s => ({ tool: s.tool, ok: s.ok === true }))
|
|
83
92
|
: [];
|
|
84
|
-
|
|
93
|
+
const taint = outcome === 'verified-success' ? parseTaint(r.taint) : [];
|
|
94
|
+
const session = typeof r.session === 'string' && /^[A-Za-z0-9_-]{1,40}$/.test(r.session) ? r.session : null;
|
|
95
|
+
return { id: r.id, task, agent, outcome, verifier, steps, edits: num(r.edits, 10_000), taint, session, startedAt: num(r.startedAt), endedAt: num(r.endedAt) };
|
|
85
96
|
}
|
|
86
97
|
export function emptyLedger() {
|
|
87
|
-
return { version: 1, ids: [], lastMtimeMs: 0, totals: { verifiedSuccess: 0, verifiedFailure: 0, unknown: 0, rejected: 0 }, patterns: {} };
|
|
98
|
+
return { version: 1, ids: [], lastMtimeMs: 0, totals: { verifiedSuccess: 0, verifiedFailure: 0, unknown: 0, rejected: 0, tainted: 0 }, patterns: {}, reverify: { cursor: 0, log: [] } };
|
|
88
99
|
}
|
|
89
100
|
/** A ledger file read back; a corrupt or hostile one starts over (re-ingest is bounded by the mod's 500-line cap). */
|
|
90
101
|
export function readLedger(raw) {
|
|
@@ -99,14 +110,21 @@ export function readLedger(raw) {
|
|
|
99
110
|
for (const [k, v] of Object.entries(j.patterns && typeof j.patterns === 'object' ? j.patterns : {})) {
|
|
100
111
|
if (k === '__proto__' || typeof k !== 'string' || k.length > 400 || !v || typeof v !== 'object')
|
|
101
112
|
continue;
|
|
102
|
-
patterns[k] = { evidence: text(v.evidence, 80), at: num(v.at) };
|
|
113
|
+
patterns[k] = { evidence: text(v.evidence, 80), at: num(v.at), stats: parseStats(v.stats) };
|
|
103
114
|
}
|
|
115
|
+
const rv = (j.reverify && typeof j.reverify === 'object' ? j.reverify : {});
|
|
116
|
+
const STATUS = ['pass', 'fail', 'timeout', 'error'];
|
|
117
|
+
const log = (Array.isArray(rv.log) ? rv.log : [])
|
|
118
|
+
.filter((e) => !!e && typeof e === 'object' && typeof e.id === 'string' && ID.test(e.id) && STATUS.includes(e.status))
|
|
119
|
+
.slice(-MAX_REVERIFY_LOG)
|
|
120
|
+
.map(e => ({ id: e.id, check: typeof e.check === 'string' ? text(e.check, 40) : null, commit: typeof e.commit === 'string' && /^[0-9a-f]{40,64}$/.test(e.commit) ? e.commit : null, status: e.status, at: num(e.at) }));
|
|
104
121
|
return {
|
|
105
122
|
version: 1,
|
|
106
123
|
ids: j.ids.filter((id) => typeof id === 'string' && ID.test(id)).slice(-MAX_LEDGER_IDS),
|
|
107
124
|
lastMtimeMs: num(j.lastMtimeMs),
|
|
108
|
-
totals: { verifiedSuccess: num(t.verifiedSuccess), verifiedFailure: num(t.verifiedFailure), unknown: num(t.unknown), rejected: num(t.rejected) },
|
|
125
|
+
totals: { verifiedSuccess: num(t.verifiedSuccess), verifiedFailure: num(t.verifiedFailure), unknown: num(t.unknown), rejected: num(t.rejected), tainted: num(t.tainted) },
|
|
109
126
|
patterns,
|
|
127
|
+
reverify: { cursor: num(rv.cursor, 1_000_000_000), log },
|
|
110
128
|
};
|
|
111
129
|
}
|
|
112
130
|
catch {
|
|
@@ -127,9 +145,13 @@ async function redact(task) {
|
|
|
127
145
|
const SAFE_KEYWORD = /^[a-z0-9][a-z0-9 _./-]{0,40}$/;
|
|
128
146
|
/** A lesson's citation: a short hash of the SONA pattern key, so the key's own text never reaches a prompt. */
|
|
129
147
|
export const lessonId = (patternKey) => `L-${createHash('sha256').update(patternKey).digest('hex').slice(0, 10)}`;
|
|
130
|
-
/**
|
|
148
|
+
/**
|
|
149
|
+
* Builds the lessons file from SONA's patterns, keeping only the ones verified outcomes trained. A would-be lesson the
|
|
150
|
+
* anomaly gate holds (ADR-503 §2.4) is listed under `held` instead: it stays in SONA, never in a prompt.
|
|
151
|
+
*/
|
|
131
152
|
export function buildLessons(patterns, verified, now) {
|
|
132
153
|
const lessons = [];
|
|
154
|
+
const held = [];
|
|
133
155
|
let belowGate = 0;
|
|
134
156
|
for (const [id, p] of Object.entries(patterns)) {
|
|
135
157
|
const v = verified[id];
|
|
@@ -141,13 +163,19 @@ export function buildLessons(patterns, verified, now) {
|
|
|
141
163
|
const base = { id: lessonId(id), agent: p.agent, keywords, confidence: Math.round(p.confidence * 1000) / 1000, successes: p.successCount, failures: p.failureCount, evidence: v.evidence };
|
|
142
164
|
if (p.failureCount >= CAUTION_MIN_FAILURES && p.failureCount > p.successCount)
|
|
143
165
|
lessons.push({ ...base, kind: 'caution' });
|
|
144
|
-
else if (p.confidence >= LESSON_GATE)
|
|
145
|
-
|
|
166
|
+
else if (p.confidence >= LESSON_GATE) {
|
|
167
|
+
const reason = holdReason(v.stats ?? emptyStats(), p.successCount);
|
|
168
|
+
if (reason)
|
|
169
|
+
held.push({ id: base.id, reason });
|
|
170
|
+
else
|
|
171
|
+
lessons.push({ ...base, kind: 'lesson' });
|
|
172
|
+
}
|
|
146
173
|
else
|
|
147
174
|
belowGate++;
|
|
148
175
|
}
|
|
149
176
|
lessons.sort((a, b) => b.confidence - a.confidence || b.successes + b.failures - (a.successes + a.failures));
|
|
150
|
-
|
|
177
|
+
held.sort((a, b) => a.id.localeCompare(b.id));
|
|
178
|
+
return { version: 1, gate: LESSON_GATE, generatedAt: now, lessons: lessons.slice(0, MAX_LESSONS), belowGate, held: held.slice(0, MAX_LESSONS) };
|
|
151
179
|
}
|
|
152
180
|
function writeAtomic(path, body) {
|
|
153
181
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -165,11 +193,13 @@ export async function ingestVerifiedOutcomes(options = {}) {
|
|
|
165
193
|
const root = options.root ?? process.cwd();
|
|
166
194
|
const now = options.now ?? Date.now();
|
|
167
195
|
const file = join(root, VERIFIED_PATH);
|
|
196
|
+
const config = options.config ?? readLoopConfig(root);
|
|
197
|
+
const reverify = { enabled: config.reverify, ran: 0, passed: 0, downgraded: 0, deferred: 0 };
|
|
168
198
|
if (!options.force && !options.dryRun && options.sona !== null) {
|
|
169
199
|
try {
|
|
170
200
|
const memo = lastIngested.get(root);
|
|
171
201
|
if (memo && memo.mtimeMs === statSync(file).mtimeMs) {
|
|
172
|
-
return { ingested: { verifiedSuccess: 0, verifiedFailure: 0, unknown: 0, rejected: 0 }, trained: 0, skipped: 'unchanged', lessons: 0, cautions: 0, belowGate: 0, totals: { ...memo.totals }, sonaAvailable: true };
|
|
202
|
+
return { ingested: { verifiedSuccess: 0, verifiedFailure: 0, unknown: 0, rejected: 0, tainted: 0 }, trained: 0, skipped: 'unchanged', lessons: 0, cautions: 0, belowGate: 0, held: [], reverify, totals: { ...memo.totals }, sonaAvailable: true };
|
|
173
203
|
}
|
|
174
204
|
}
|
|
175
205
|
catch {
|
|
@@ -178,7 +208,7 @@ export async function ingestVerifiedOutcomes(options = {}) {
|
|
|
178
208
|
}
|
|
179
209
|
const ledgerPath = join(root, LEDGER_PATH);
|
|
180
210
|
const ledger = readLedger(existsSync(ledgerPath) ? readFileSync(ledgerPath, 'utf8') : null);
|
|
181
|
-
const ingested = { verifiedSuccess: 0, verifiedFailure: 0, unknown: 0, rejected: 0 };
|
|
211
|
+
const ingested = { verifiedSuccess: 0, verifiedFailure: 0, unknown: 0, rejected: 0, tainted: 0 };
|
|
182
212
|
const done = (skipped, sonaAvailable, lessons) => ({
|
|
183
213
|
ingested,
|
|
184
214
|
trained: ingested.verifiedSuccess + ingested.verifiedFailure,
|
|
@@ -186,6 +216,8 @@ export async function ingestVerifiedOutcomes(options = {}) {
|
|
|
186
216
|
lessons: lessons?.lessons.filter(l => l.kind === 'lesson').length ?? 0,
|
|
187
217
|
cautions: lessons?.lessons.filter(l => l.kind === 'caution').length ?? 0,
|
|
188
218
|
belowGate: lessons?.belowGate ?? 0,
|
|
219
|
+
held: lessons?.held ?? [],
|
|
220
|
+
reverify,
|
|
189
221
|
totals: ledger.totals,
|
|
190
222
|
sonaAvailable,
|
|
191
223
|
});
|
|
@@ -206,6 +238,9 @@ export async function ingestVerifiedOutcomes(options = {}) {
|
|
|
206
238
|
sona = null;
|
|
207
239
|
}
|
|
208
240
|
}
|
|
241
|
+
// ADR-503 §2.3: one re-verify session per ingest; the cursor advances once, so checks rotate across ingests.
|
|
242
|
+
const runReverify = config.reverify && !options.dryRun && options.allowReverify === true;
|
|
243
|
+
const session = runReverify ? new ReverifySession(root, ledger.reverify.cursor, config.reverifyTimeoutMs, options.reverifier ?? gitWorktreeRunner) : null;
|
|
209
244
|
const seen = new Set(ledger.ids);
|
|
210
245
|
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
211
246
|
if (!line.trim())
|
|
@@ -220,20 +255,42 @@ export async function ingestVerifiedOutcomes(options = {}) {
|
|
|
220
255
|
// Without SONA nothing verified can train yet: leave it un-ingested for the next call.
|
|
221
256
|
if (record.outcome !== 'unknown' && !sona)
|
|
222
257
|
continue;
|
|
258
|
+
const success = record.outcome === 'verified-success';
|
|
259
|
+
// ADR-503 §2.2: a success with a taint its project did not allow is counted and never trains.
|
|
260
|
+
const tainted = success && blockedTaints(record.taint, config).length > 0;
|
|
261
|
+
// ADR-503 §2.3: with re-verify on, a caller that may not run project code leaves a trainable success for later.
|
|
262
|
+
if (success && !tainted && config.reverify && !options.dryRun && !runReverify) {
|
|
263
|
+
reverify.deferred++;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
223
266
|
seen.add(record.id);
|
|
224
267
|
ledger.ids.push(record.id);
|
|
225
268
|
if (record.outcome === 'unknown') {
|
|
226
269
|
ingested.unknown++;
|
|
227
270
|
continue;
|
|
228
271
|
}
|
|
272
|
+
if (tainted) {
|
|
273
|
+
ingested.tainted++;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
229
276
|
if (options.dryRun) {
|
|
230
|
-
if (
|
|
277
|
+
if (success)
|
|
231
278
|
ingested.verifiedSuccess++;
|
|
232
279
|
else
|
|
233
280
|
ingested.verifiedFailure++;
|
|
234
281
|
continue;
|
|
235
282
|
}
|
|
236
|
-
|
|
283
|
+
if (success && session) {
|
|
284
|
+
// Only the record's verifier KIND is used, to pick a different allowlisted check; its text never runs.
|
|
285
|
+
const { check, outcome } = await session.verify(record.verifier.kind);
|
|
286
|
+
ledger.reverify.log.push({ id: record.id, check, commit: outcome.commit, status: outcome.status, at: now });
|
|
287
|
+
if (outcome.status !== 'pass') {
|
|
288
|
+
reverify.downgraded++;
|
|
289
|
+
ingested.unknown++;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
reverify.passed++;
|
|
293
|
+
}
|
|
237
294
|
const result = sona.processTrajectoryOutcome({
|
|
238
295
|
trajectoryId: record.id,
|
|
239
296
|
task: await redact(record.task),
|
|
@@ -243,8 +300,13 @@ export async function ingestVerifiedOutcomes(options = {}) {
|
|
|
243
300
|
duration: record.endedAt > record.startedAt ? record.endedAt - record.startedAt : 0,
|
|
244
301
|
feedback: `verified by ${record.verifier.kind}: ${record.verifier.command}`,
|
|
245
302
|
});
|
|
246
|
-
if (result.learned && result.patternKey)
|
|
247
|
-
ledger.patterns[result.patternKey]
|
|
303
|
+
if (result.learned && result.patternKey) {
|
|
304
|
+
const entry = ledger.patterns[result.patternKey] ?? { evidence: '', at: now, stats: emptyStats() };
|
|
305
|
+
entry.evidence = record.verifier.command;
|
|
306
|
+
entry.at = now;
|
|
307
|
+
addOutcome(entry.stats, success, record.taint, record.session);
|
|
308
|
+
ledger.patterns[result.patternKey] = entry;
|
|
309
|
+
}
|
|
248
310
|
if (success)
|
|
249
311
|
ingested.verifiedSuccess++;
|
|
250
312
|
else
|
|
@@ -252,14 +314,21 @@ export async function ingestVerifiedOutcomes(options = {}) {
|
|
|
252
314
|
}
|
|
253
315
|
if (options.dryRun)
|
|
254
316
|
return done(null, !!sona);
|
|
317
|
+
if (session) {
|
|
318
|
+
reverify.ran = session.ran;
|
|
319
|
+
if (session.ran > 0)
|
|
320
|
+
ledger.reverify.cursor = (ledger.reverify.cursor + 1) % 1_000_000_000;
|
|
321
|
+
ledger.reverify.log = ledger.reverify.log.slice(-MAX_REVERIFY_LOG);
|
|
322
|
+
}
|
|
255
323
|
ledger.ids = ledger.ids.slice(-MAX_LEDGER_IDS);
|
|
256
|
-
|
|
257
|
-
|
|
324
|
+
for (const k of Object.keys(ingested))
|
|
325
|
+
ledger.totals[k] += ingested[k];
|
|
326
|
+
// Without SONA, or with successes deferred for re-verify, records stayed un-ingested: keep the mtime stale so the
|
|
327
|
+
// next call retries them.
|
|
328
|
+
if (sona && reverify.deferred === 0) {
|
|
258
329
|
ledger.lastMtimeMs = st.mtimeMs;
|
|
259
330
|
lastIngested.set(root, { mtimeMs: st.mtimeMs, totals: { ...ledger.totals } });
|
|
260
331
|
}
|
|
261
|
-
for (const k of Object.keys(ingested))
|
|
262
|
-
ledger.totals[k] += ingested[k];
|
|
263
332
|
// Keep the verified-pattern map bounded to what SONA still holds.
|
|
264
333
|
const patterns = sona ? sona.exportPatterns() : {};
|
|
265
334
|
if (sona)
|
|
@@ -294,7 +363,7 @@ export function learningLoopHealth(root = process.cwd()) {
|
|
|
294
363
|
const records = lines.map(parseRecord).filter((r) => r !== null);
|
|
295
364
|
const waiting = records.filter(r => !seen.has(r.id) && r.outcome !== 'unknown').length;
|
|
296
365
|
const verified = records.filter(r => r.outcome !== 'unknown').length;
|
|
297
|
-
const summary = `${records.length} turn(s) recorded, ${verified} verified; trained so far ${ledger.totals.verifiedSuccess} success / ${ledger.totals.verifiedFailure} failure, ${ledger.totals.unknown} unknown (never trained)`;
|
|
366
|
+
const summary = `${records.length} turn(s) recorded, ${verified} verified; trained so far ${ledger.totals.verifiedSuccess} success / ${ledger.totals.verifiedFailure} failure, ${ledger.totals.unknown} unknown, ${ledger.totals.tainted} tainted (never trained)`;
|
|
298
367
|
if (waiting > 0) {
|
|
299
368
|
return { status: 'warn', message: `${summary}; ${waiting} verified outcome(s) not yet ingested`, fix: 'ruflo hooks verified-ingest (or any hooks_route / hooks_session-end call ingests them)' };
|
|
300
369
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-verify before training (ADR-503 §2.3). Opt-in: `.claude-flow/config.json`
|
|
3
|
+
* `learningLoop.reverify: true`. Off by default because it runs project code.
|
|
4
|
+
*
|
|
5
|
+
* - The command comes only from a fixed allowlist built from the project's own
|
|
6
|
+
* manifest (`package.json` scripts named test/typecheck/build/lint, or
|
|
7
|
+
* `cargo test`/`cargo check` when `Cargo.toml` exists). A record's text is
|
|
8
|
+
* never executed: it only selects, by its verifier *kind*, which allowlisted
|
|
9
|
+
* check NOT to run.
|
|
10
|
+
* - Checks rotate round-robin across ingests, preferring one the record did
|
|
11
|
+
* not use, so no single check is the one that counts.
|
|
12
|
+
* - The real runner works in a temporary detached `git worktree` at HEAD,
|
|
13
|
+
* with a hard timeout; timeout, error or a non-zero exit downgrades the
|
|
14
|
+
* record to `unknown`.
|
|
15
|
+
*/
|
|
16
|
+
import type { VerifierKind } from './verified-outcomes.js';
|
|
17
|
+
export interface ReverifyCheck {
|
|
18
|
+
/** `npm:test`, `cargo:check`, ...: a stable name for the ledger. */
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly kind: VerifierKind;
|
|
21
|
+
readonly argv: readonly string[];
|
|
22
|
+
}
|
|
23
|
+
export interface ReverifyOutcome {
|
|
24
|
+
readonly status: 'pass' | 'fail' | 'timeout' | 'error';
|
|
25
|
+
/** The commit the check ran at, when known. */
|
|
26
|
+
readonly commit: string | null;
|
|
27
|
+
}
|
|
28
|
+
/** Runs one allowlisted check for `root` at HEAD. Injectable so tests never start a process. */
|
|
29
|
+
export type ReverifyRunner = (root: string, check: ReverifyCheck, timeoutMs: number) => Promise<ReverifyOutcome>;
|
|
30
|
+
/**
|
|
31
|
+
* The allowlist for a project. Only the script NAMES are read from the
|
|
32
|
+
* manifest; the argv is fixed here (`npm run <name>`, `cargo test`, ...).
|
|
33
|
+
*/
|
|
34
|
+
export declare function reverifyChecks(root: string): ReverifyCheck[];
|
|
35
|
+
/**
|
|
36
|
+
* The check to run for a record decided by a `used` verifier: round-robin by
|
|
37
|
+
* `cursor` over the checks of another kind, or over all of them when every
|
|
38
|
+
* check is that kind. Null when the project has none.
|
|
39
|
+
*/
|
|
40
|
+
export declare function pickCheck(checks: readonly ReverifyCheck[], used: VerifierKind, cursor: number): ReverifyCheck | null;
|
|
41
|
+
/**
|
|
42
|
+
* The real runner: a detached `git worktree` at HEAD in a temp directory, the
|
|
43
|
+
* root's `node_modules` linked in (a fresh worktree has none), the check run,
|
|
44
|
+
* the worktree removed. Any failure along the way is `error`.
|
|
45
|
+
*/
|
|
46
|
+
export declare const gitWorktreeRunner: ReverifyRunner;
|
|
47
|
+
/** One ingest's re-verification: picks per record, runs each distinct check at most once, never throws. */
|
|
48
|
+
export declare class ReverifySession {
|
|
49
|
+
private readonly root;
|
|
50
|
+
private readonly cursor;
|
|
51
|
+
private readonly timeoutMs;
|
|
52
|
+
private readonly runner;
|
|
53
|
+
private readonly results;
|
|
54
|
+
private readonly checks;
|
|
55
|
+
ran: number;
|
|
56
|
+
constructor(root: string, cursor: number, timeoutMs: number, runner: ReverifyRunner, checks?: ReverifyCheck[]);
|
|
57
|
+
/** Re-verifies a success decided by a `used` verifier. The record's own command and task are never passed. */
|
|
58
|
+
verify(used: VerifierKind): Promise<{
|
|
59
|
+
check: string | null;
|
|
60
|
+
outcome: ReverifyOutcome;
|
|
61
|
+
}>;
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=verified-reverify.d.ts.map
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-verify before training (ADR-503 §2.3). Opt-in: `.claude-flow/config.json`
|
|
3
|
+
* `learningLoop.reverify: true`. Off by default because it runs project code.
|
|
4
|
+
*
|
|
5
|
+
* - The command comes only from a fixed allowlist built from the project's own
|
|
6
|
+
* manifest (`package.json` scripts named test/typecheck/build/lint, or
|
|
7
|
+
* `cargo test`/`cargo check` when `Cargo.toml` exists). A record's text is
|
|
8
|
+
* never executed: it only selects, by its verifier *kind*, which allowlisted
|
|
9
|
+
* check NOT to run.
|
|
10
|
+
* - Checks rotate round-robin across ingests, preferring one the record did
|
|
11
|
+
* not use, so no single check is the one that counts.
|
|
12
|
+
* - The real runner works in a temporary detached `git worktree` at HEAD,
|
|
13
|
+
* with a hard timeout; timeout, error or a non-zero exit downgrades the
|
|
14
|
+
* record to `unknown`.
|
|
15
|
+
*/
|
|
16
|
+
import { execFile, spawn } from 'node:child_process';
|
|
17
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync } from 'node:fs';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
/** package.json scripts that may be re-run, in rotation order, and the kind each verifies. */
|
|
21
|
+
const NPM_SCRIPTS = [
|
|
22
|
+
['test', 'test'],
|
|
23
|
+
['typecheck', 'typecheck'],
|
|
24
|
+
['build', 'build'],
|
|
25
|
+
['lint', 'lint'],
|
|
26
|
+
];
|
|
27
|
+
/**
|
|
28
|
+
* The allowlist for a project. Only the script NAMES are read from the
|
|
29
|
+
* manifest; the argv is fixed here (`npm run <name>`, `cargo test`, ...).
|
|
30
|
+
*/
|
|
31
|
+
export function reverifyChecks(root) {
|
|
32
|
+
const checks = [];
|
|
33
|
+
try {
|
|
34
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
35
|
+
const scripts = pkg && typeof pkg.scripts === 'object' && pkg.scripts !== null ? pkg.scripts : {};
|
|
36
|
+
for (const [name, kind] of NPM_SCRIPTS) {
|
|
37
|
+
if (typeof scripts[name] === 'string' && scripts[name].trim())
|
|
38
|
+
checks.push({ name: `npm:${name}`, kind, argv: ['npm', 'run', name] });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// no or unreadable package.json: no npm checks
|
|
43
|
+
}
|
|
44
|
+
if (existsSync(join(root, 'Cargo.toml'))) {
|
|
45
|
+
checks.push({ name: 'cargo:test', kind: 'test', argv: ['cargo', 'test'] });
|
|
46
|
+
checks.push({ name: 'cargo:check', kind: 'typecheck', argv: ['cargo', 'check'] });
|
|
47
|
+
}
|
|
48
|
+
return checks;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The check to run for a record decided by a `used` verifier: round-robin by
|
|
52
|
+
* `cursor` over the checks of another kind, or over all of them when every
|
|
53
|
+
* check is that kind. Null when the project has none.
|
|
54
|
+
*/
|
|
55
|
+
export function pickCheck(checks, used, cursor) {
|
|
56
|
+
if (checks.length === 0)
|
|
57
|
+
return null;
|
|
58
|
+
const others = checks.filter(c => c.kind !== used);
|
|
59
|
+
const pool = others.length > 0 ? others : checks;
|
|
60
|
+
return pool[Math.abs(Math.floor(cursor)) % pool.length];
|
|
61
|
+
}
|
|
62
|
+
const COMMIT = /^[0-9a-f]{40,64}$/;
|
|
63
|
+
function git(args, cwd, timeout = 60_000) {
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
execFile('git', args, { cwd, timeout, maxBuffer: 1024 * 1024 }, (error, stdout) => (error ? reject(error) : resolve(String(stdout))));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/** Runs argv (no shell) in `cwd`; the whole process group is killed at the timeout. */
|
|
69
|
+
function runCheck(argv, cwd, timeoutMs) {
|
|
70
|
+
return new Promise(resolve => {
|
|
71
|
+
let settled = false;
|
|
72
|
+
const finish = (status) => {
|
|
73
|
+
if (settled)
|
|
74
|
+
return;
|
|
75
|
+
settled = true;
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
resolve(status);
|
|
78
|
+
};
|
|
79
|
+
let child;
|
|
80
|
+
try {
|
|
81
|
+
child = spawn(argv[0], argv.slice(1), { cwd, shell: false, stdio: 'ignore', detached: process.platform !== 'win32', env: { ...process.env, CI: '1' } });
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
resolve('error');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const timer = setTimeout(() => {
|
|
88
|
+
try {
|
|
89
|
+
if (child.pid && process.platform !== 'win32')
|
|
90
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
91
|
+
else
|
|
92
|
+
child.kill('SIGKILL');
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// already gone
|
|
96
|
+
}
|
|
97
|
+
finish('timeout');
|
|
98
|
+
}, timeoutMs);
|
|
99
|
+
child.on('error', () => finish('error'));
|
|
100
|
+
child.on('exit', code => finish(code === 0 ? 'pass' : 'fail'));
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The real runner: a detached `git worktree` at HEAD in a temp directory, the
|
|
105
|
+
* root's `node_modules` linked in (a fresh worktree has none), the check run,
|
|
106
|
+
* the worktree removed. Any failure along the way is `error`.
|
|
107
|
+
*/
|
|
108
|
+
export const gitWorktreeRunner = async (root, check, timeoutMs) => {
|
|
109
|
+
let commit = null;
|
|
110
|
+
const base = mkdtempSync(join(tmpdir(), 'ruflo-reverify-'));
|
|
111
|
+
const dir = join(base, 'wt');
|
|
112
|
+
let added = false;
|
|
113
|
+
try {
|
|
114
|
+
const head = (await git(['rev-parse', 'HEAD'], root, 10_000)).trim();
|
|
115
|
+
if (!COMMIT.test(head))
|
|
116
|
+
return { status: 'error', commit: null };
|
|
117
|
+
commit = head;
|
|
118
|
+
await git(['worktree', 'add', '--detach', dir, head], root);
|
|
119
|
+
added = true;
|
|
120
|
+
if (existsSync(join(root, 'node_modules')) && !existsSync(join(dir, 'node_modules')))
|
|
121
|
+
symlinkSync(join(root, 'node_modules'), join(dir, 'node_modules'), 'dir');
|
|
122
|
+
return { status: await runCheck(check.argv, dir, timeoutMs), commit };
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return { status: 'error', commit };
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
if (added)
|
|
129
|
+
await git(['worktree', 'remove', '--force', dir], root).catch(() => undefined);
|
|
130
|
+
rmSync(base, { recursive: true, force: true });
|
|
131
|
+
if (added)
|
|
132
|
+
await git(['worktree', 'prune'], root).catch(() => undefined);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
/** A runner that never settles still ends: past the deadline its outcome is `timeout`. */
|
|
136
|
+
function withDeadline(run, ms) {
|
|
137
|
+
let timer;
|
|
138
|
+
const deadline = new Promise(resolve => {
|
|
139
|
+
timer = setTimeout(() => resolve({ status: 'timeout', commit: null }), ms);
|
|
140
|
+
timer.unref?.();
|
|
141
|
+
});
|
|
142
|
+
return Promise.race([run, deadline]).finally(() => clearTimeout(timer));
|
|
143
|
+
}
|
|
144
|
+
/** One ingest's re-verification: picks per record, runs each distinct check at most once, never throws. */
|
|
145
|
+
export class ReverifySession {
|
|
146
|
+
root;
|
|
147
|
+
cursor;
|
|
148
|
+
timeoutMs;
|
|
149
|
+
runner;
|
|
150
|
+
results = new Map();
|
|
151
|
+
checks;
|
|
152
|
+
ran = 0;
|
|
153
|
+
constructor(root, cursor, timeoutMs, runner, checks) {
|
|
154
|
+
this.root = root;
|
|
155
|
+
this.cursor = cursor;
|
|
156
|
+
this.timeoutMs = timeoutMs;
|
|
157
|
+
this.runner = runner;
|
|
158
|
+
this.checks = checks ?? reverifyChecks(root);
|
|
159
|
+
}
|
|
160
|
+
/** Re-verifies a success decided by a `used` verifier. The record's own command and task are never passed. */
|
|
161
|
+
async verify(used) {
|
|
162
|
+
const check = pickCheck(this.checks, used, this.cursor);
|
|
163
|
+
if (!check)
|
|
164
|
+
return { check: null, outcome: { status: 'error', commit: null } };
|
|
165
|
+
let pending = this.results.get(check.name);
|
|
166
|
+
if (!pending) {
|
|
167
|
+
this.ran++;
|
|
168
|
+
pending = withDeadline(Promise.resolve().then(() => this.runner(this.root, check, this.timeoutMs)), this.timeoutMs + Math.min(this.timeoutMs, 30_000)).then(o => (o && ['pass', 'fail', 'timeout', 'error'].includes(o.status) ? { status: o.status, commit: typeof o.commit === 'string' && COMMIT.test(o.commit) ? o.commit : null } : { status: 'error', commit: null }), () => ({ status: 'error', commit: null }));
|
|
169
|
+
this.results.set(check.name, pending);
|
|
170
|
+
}
|
|
171
|
+
return { check: check.name, outcome: await pending };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
//# sourceMappingURL=verified-reverify.js.map
|
|
@@ -45,12 +45,12 @@ export declare const missionRecordSchema: z.ZodObject<{
|
|
|
45
45
|
digest: z.ZodString;
|
|
46
46
|
taskGraphRef: z.ZodString;
|
|
47
47
|
}, "strict", z.ZodTypeAny, {
|
|
48
|
-
revision: number;
|
|
49
48
|
digest: string;
|
|
49
|
+
revision: number;
|
|
50
50
|
taskGraphRef: string;
|
|
51
51
|
}, {
|
|
52
|
-
revision: number;
|
|
53
52
|
digest: string;
|
|
53
|
+
revision: number;
|
|
54
54
|
taskGraphRef: string;
|
|
55
55
|
}>;
|
|
56
56
|
policyRef: z.ZodString;
|
|
@@ -82,8 +82,8 @@ export declare const missionRecordSchema: z.ZodObject<{
|
|
|
82
82
|
criteriaDigest: string;
|
|
83
83
|
};
|
|
84
84
|
plan: {
|
|
85
|
-
revision: number;
|
|
86
85
|
digest: string;
|
|
86
|
+
revision: number;
|
|
87
87
|
taskGraphRef: string;
|
|
88
88
|
};
|
|
89
89
|
objective: string;
|
|
@@ -108,8 +108,8 @@ export declare const missionRecordSchema: z.ZodObject<{
|
|
|
108
108
|
criteriaDigest: string;
|
|
109
109
|
};
|
|
110
110
|
plan: {
|
|
111
|
-
revision: number;
|
|
112
111
|
digest: string;
|
|
112
|
+
revision: number;
|
|
113
113
|
taskGraphRef: string;
|
|
114
114
|
};
|
|
115
115
|
objective: string;
|
package/dist/src/mods/apply.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface ApplyOptions {
|
|
|
18
18
|
skipInTestEnv?: boolean;
|
|
19
19
|
/** The marketplace to declare when the settings have none (default github ruvnet/ruflo). */
|
|
20
20
|
marketplace?: MarketplaceEntry;
|
|
21
|
+
/** ADR-509 §2.2: plugins (name or id) to install past a high scan finding; each override is recorded. */
|
|
22
|
+
acceptRisk?: readonly string[];
|
|
21
23
|
/** Injectable for tests. */
|
|
22
24
|
exec?: Exec;
|
|
23
25
|
}
|
package/dist/src/mods/apply.js
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
import { readFileSync } from 'node:fs';
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
9
|
import { output } from '../output.js';
|
|
10
|
-
import { installMod, MARKETPLACE_NAME, MARKETPLACE_SOURCE, MOD_PLUGINS, readRecord, recordClaudeInstalled, scopeOfSettingsFile, uninstallMod, } from './install.js';
|
|
10
|
+
import { disableBlockedPlugins, installMod, MARKETPLACE_NAME, MARKETPLACE_SOURCE, MOD_PLUGIN_IDS, MOD_PLUGINS, readRecord, readSettingsFile, settingsFileFor, recordClaudeInstalled, scopeOfSettingsFile, uninstallMod, } from './install.js';
|
|
11
11
|
import { findClaudeBinary, installArgv, marketplaceArgv, repairPluginInstall, uninstallArgv, uninstallPlugins } from './plugin-repair.js';
|
|
12
12
|
import { claudeConfigDir, describeSource, installedState, marketplaceState, repairCommands, resolveFindings, sameSource } from './plugin-resolve.js';
|
|
13
|
+
import { scanBeforeInstall, verifyAndRecord } from './install-trust.js';
|
|
13
14
|
/** One line per thing a run added; empty on a re-run. */
|
|
14
15
|
export function describeAdded(added) {
|
|
15
16
|
return [
|
|
@@ -68,14 +69,39 @@ function noteRewrite(file, before) {
|
|
|
68
69
|
// Not ours to judge further.
|
|
69
70
|
}
|
|
70
71
|
}
|
|
72
|
+
/** The marketplace the settings file already declares, else the requested one. */
|
|
73
|
+
function declaredMarketplace(root, scope, requested) {
|
|
74
|
+
try {
|
|
75
|
+
const markets = readSettingsFile(settingsFileFor(root, scope)).extraKnownMarketplaces;
|
|
76
|
+
return markets?.[MARKETPLACE_NAME] ?? requested;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return requested;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
71
82
|
export async function applyMods(root, opts) {
|
|
72
|
-
const install = installMod(root, opts.scope, opts.dryRun === true, undefined, opts.marketplace ?? MARKETPLACE_SOURCE);
|
|
73
83
|
const configDir = claudeConfigDir(process.env, homedir());
|
|
84
|
+
const acceptRisk = new Set(opts.acceptRisk ?? []);
|
|
85
|
+
const seen = new Set();
|
|
86
|
+
const blocked = new Map();
|
|
87
|
+
const told = new Set();
|
|
88
|
+
// ADR-509 §2.2: Claude Code loads an enabled plugin straight from the
|
|
89
|
+
// marketplace clone, install or not. So where a clone already exists, scan it
|
|
90
|
+
// BEFORE enabling anything from it, and never enable what the scan blocks.
|
|
91
|
+
const preClone = opts.dryRun ? null : marketplaceState(configDir).location;
|
|
92
|
+
const scannable = declaredMarketplace(root, opts.scope, opts.marketplace ?? MARKETPLACE_SOURCE).source.source !== 'directory';
|
|
93
|
+
if (preClone && scannable) {
|
|
94
|
+
for (const [id, why] of scanBeforeInstall(root, preClone, MOD_PLUGINS, acceptRisk, undefined, seen).blocked)
|
|
95
|
+
blocked.set(id, why);
|
|
96
|
+
}
|
|
97
|
+
const install = installMod(root, opts.scope, opts.dryRun === true, MOD_PLUGIN_IDS.filter((id) => !blocked.has(id)), opts.marketplace ?? MARKETPLACE_SOURCE);
|
|
98
|
+
// A blocked plugin enabled by an earlier run (or by hand) is turned off too.
|
|
99
|
+
turnOff(root, install, blocked, told);
|
|
74
100
|
const lines = describeAdded(install.added);
|
|
75
101
|
// Only what is enabled in the file: `claude plugin install` would flip a
|
|
76
102
|
// plugin someone set to false back to true.
|
|
77
103
|
const enabled = (install.next.enabledPlugins ?? {});
|
|
78
|
-
const plugins = MOD_PLUGINS.filter((p) => enabled[p.id] === true);
|
|
104
|
+
const plugins = MOD_PLUGINS.filter((p) => enabled[p.id] === true && !blocked.has(p.id));
|
|
79
105
|
// The marketplace the file declares wins: a person's own declaration is kept and used.
|
|
80
106
|
const declared = install.next.extraKnownMarketplaces?.[MARKETPLACE_NAME] ?? MARKETPLACE_SOURCE;
|
|
81
107
|
const market = marketplaceState(configDir);
|
|
@@ -111,16 +137,25 @@ export async function applyMods(root, opts) {
|
|
|
111
137
|
for (const line of repairCommands(root, opts.scope, k, plugins, declared))
|
|
112
138
|
output.writeln(` ${line}`);
|
|
113
139
|
};
|
|
114
|
-
|
|
115
|
-
|
|
140
|
+
// The `claude` step may not run below: say what is enabled without a scan.
|
|
141
|
+
const unscanned = () => {
|
|
142
|
+
if (!preClone && scannable && plugins.length)
|
|
143
|
+
output.printWarning(`No ruflo marketplace clone yet, so nothing was scanned (ADR-509): ${plugins.map((p) => p.id).join(', ')} enabled unscanned; Claude Code clones it on the first trusted start. Run ruflo mods install again after that to scan it.`);
|
|
144
|
+
};
|
|
145
|
+
if (opts.pluginInstall === false) {
|
|
146
|
+
unscanned();
|
|
147
|
+
return { install, resolvable: blocked.size === 0, skipped: '--no-plugin-install' };
|
|
148
|
+
}
|
|
116
149
|
if (opts.skipInTestEnv && (process.env.VITEST || process.env.CI)) {
|
|
117
150
|
output.writeln(output.dim(`claude plugin step skipped (${process.env.VITEST ? 'VITEST' : 'CI'} set).`));
|
|
151
|
+
unscanned();
|
|
118
152
|
manual();
|
|
119
153
|
return { install, resolvable: false, skipped: 'test or CI environment' };
|
|
120
154
|
}
|
|
121
155
|
const claude = findClaudeBinary(process.env, homedir());
|
|
122
156
|
if (!claude) {
|
|
123
157
|
output.printWarning('No runnable claude binary on PATH: the plugins are enabled in settings, not installed.');
|
|
158
|
+
unscanned();
|
|
124
159
|
manual();
|
|
125
160
|
return { install, resolvable: false, skipped: 'no claude on PATH' };
|
|
126
161
|
}
|
|
@@ -130,8 +165,21 @@ export async function applyMods(root, opts) {
|
|
|
130
165
|
// Never claim (and so never uninstall) a plugin that was installed before, or one ruflo did not enable.
|
|
131
166
|
const before = new Set(plugins.filter((p) => installedState(root, configDir, p.id).installed).map((p) => p.id));
|
|
132
167
|
const textBefore = readText(install.settingsFile);
|
|
133
|
-
const repair = await repairPluginInstall({
|
|
168
|
+
const repair = await repairPluginInstall({
|
|
169
|
+
projectRoot: root, scope: opts.scope, configDir, claude, plugins, marketplace: declared, exec: opts.exec,
|
|
170
|
+
// ADR-509 §2.2: scan the refreshed clone before any install (a directory marketplace has no install step to gate).
|
|
171
|
+
beforeInstall: (clone) => {
|
|
172
|
+
const gate = scanBeforeInstall(root, clone, plugins, acceptRisk, undefined, seen);
|
|
173
|
+
for (const [id, why] of gate.blocked)
|
|
174
|
+
blocked.set(id, why);
|
|
175
|
+
return gate;
|
|
176
|
+
},
|
|
177
|
+
});
|
|
134
178
|
printSteps(repair.steps);
|
|
179
|
+
// The refresh may have brought a finding the first scan did not see: that plugin is turned off as well.
|
|
180
|
+
turnOff(root, install, blocked, told);
|
|
181
|
+
// ADR-509 §2.1: verify what Claude Code will load and leave the result for the mod's trust gate.
|
|
182
|
+
verifyAndRecord(configDir, marketplaceState(configDir).location, plugins.map((p) => p.id.split('@')[0]));
|
|
135
183
|
if (declared.source.source === 'directory' && repair.ok)
|
|
136
184
|
output.writeln(output.dim(`Directory marketplace: plugins load live from ${declared.source.path} (no clone, no install).`));
|
|
137
185
|
const enabledByRuflo = new Set(readRecord(root)?.files[install.settingsFile]?.plugins ?? []);
|
|
@@ -141,8 +189,8 @@ export async function applyMods(root, opts) {
|
|
|
141
189
|
.filter((id) => !before.has(id) && enabledByRuflo.has(id));
|
|
142
190
|
recordClaudeInstalled(root, install.settingsFile, ours);
|
|
143
191
|
noteRewrite(install.settingsFile, textBefore);
|
|
144
|
-
const failed = resolveFindings(root, opts.scope, configDir, undefined, plugins.map((p) => p.id), declared).filter((f) => f.status === 'fail');
|
|
145
|
-
if (repair.ok && failed.length === 0)
|
|
192
|
+
const failed = resolveFindings(root, opts.scope, configDir, undefined, plugins.filter((p) => !blocked.has(p.id)).map((p) => p.id), declared).filter((f) => f.status === 'fail');
|
|
193
|
+
if (repair.ok && failed.length === 0 && blocked.size === 0)
|
|
146
194
|
return { install, resolvable: true };
|
|
147
195
|
for (const f of failed)
|
|
148
196
|
output.writeln(`${output.error('✗')} ${f.name}: ${f.message}`);
|
|
@@ -150,6 +198,18 @@ export async function applyMods(root, opts) {
|
|
|
150
198
|
manual(now.known && sameSource(now.source, declared.source));
|
|
151
199
|
return { install, resolvable: false };
|
|
152
200
|
}
|
|
201
|
+
/** ADR-509 §2.2: turn off (enabledPlugins + install record) each blocked plugin, and say so. */
|
|
202
|
+
function turnOff(root, install, blocked, told) {
|
|
203
|
+
if (install.dryRun || blocked.size === 0)
|
|
204
|
+
return;
|
|
205
|
+
const off = disableBlockedPlugins(root, install.settingsFile, [...blocked.keys()]);
|
|
206
|
+
for (const [id, why] of blocked) {
|
|
207
|
+
if (told.has(id) && !off.includes(id))
|
|
208
|
+
continue; // already said, and nothing changed since
|
|
209
|
+
told.add(id);
|
|
210
|
+
output.writeln(`${output.error('✗')} ${id}: ${off.includes(id) ? `enabledPlugins["${id}"] removed from ${install.settingsFile}` : 'not enabled'}; ${why}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
153
213
|
/**
|
|
154
214
|
* `ruflo mods uninstall`: `claude plugin uninstall` exactly the plugins ruflo
|
|
155
215
|
* installed, then remove exactly the settings keys it added.
|