@claude-flow/cli 3.64.0 → 3.65.1

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.
Files changed (93) hide show
  1. package/.claude/helpers/helpers.manifest.json +2 -2
  2. package/catalog-manifest.json +2 -2
  3. package/dist/src/commands/cost.d.ts +18 -0
  4. package/dist/src/commands/cost.js +195 -0
  5. package/dist/src/commands/doctor-auto-approve.d.ts +9 -0
  6. package/dist/src/commands/doctor-auto-approve.js +32 -0
  7. package/dist/src/commands/doctor.js +21 -3
  8. package/dist/src/commands/hive-mind-worker.d.ts +2 -0
  9. package/dist/src/commands/hive-mind-worker.js +9 -3
  10. package/dist/src/commands/hive-mind.js +15 -1
  11. package/dist/src/commands/hooks.js +8 -2
  12. package/dist/src/commands/index.js +2 -0
  13. package/dist/src/commands/init.js +76 -2
  14. package/dist/src/commands/mods.js +13 -0
  15. package/dist/src/commands/plugins-paths.d.ts +13 -0
  16. package/dist/src/commands/plugins-paths.js +55 -0
  17. package/dist/src/commands/plugins-verify.d.ts +9 -0
  18. package/dist/src/commands/plugins-verify.js +92 -0
  19. package/dist/src/commands/plugins.js +13 -1
  20. package/dist/src/cost/attribution.d.ts +35 -0
  21. package/dist/src/cost/attribution.js +101 -0
  22. package/dist/src/cost/budget-file.d.ts +57 -0
  23. package/dist/src/cost/budget-file.js +113 -0
  24. package/dist/src/cost/litellm.d.ts +69 -0
  25. package/dist/src/cost/litellm.js +135 -0
  26. package/dist/src/init/executor.d.ts +7 -2
  27. package/dist/src/init/executor.js +64 -10
  28. package/dist/src/init/helper-commands.d.ts +16 -0
  29. package/dist/src/init/helper-commands.js +73 -0
  30. package/dist/src/init/risky-settings-gate.d.ts +39 -0
  31. package/dist/src/init/risky-settings-gate.js +130 -0
  32. package/dist/src/init/settings-risk-scanner.d.ts +35 -0
  33. package/dist/src/init/settings-risk-scanner.js +85 -0
  34. package/dist/src/init/types.d.ts +12 -0
  35. package/dist/src/mcp-tools/capability-brain.d.ts +1 -1
  36. package/dist/src/mcp-tools/capability-brain.js +1 -1
  37. package/dist/src/mcp-tools/hooks-tools.js +2 -1
  38. package/dist/src/mcp-tools/http-fetch-pinned.d.ts +44 -0
  39. package/dist/src/mcp-tools/http-fetch-pinned.js +148 -0
  40. package/dist/src/mcp-tools/http-fetch-tools.d.ts +11 -2
  41. package/dist/src/mcp-tools/http-fetch-tools.js +19 -70
  42. package/dist/src/memory/verified-guards.d.ts +70 -0
  43. package/dist/src/memory/verified-guards.js +101 -0
  44. package/dist/src/memory/verified-outcomes.d.ts +68 -3
  45. package/dist/src/memory/verified-outcomes.js +88 -19
  46. package/dist/src/memory/verified-reverify.d.ts +63 -0
  47. package/dist/src/memory/verified-reverify.js +174 -0
  48. package/dist/src/missions/schemas.d.ts +4 -4
  49. package/dist/src/mods/apply.d.ts +2 -0
  50. package/dist/src/mods/apply.js +68 -8
  51. package/dist/src/mods/command-registry/schema.d.ts +36 -36
  52. package/dist/src/mods/compact-snapshots.d.ts +21 -0
  53. package/dist/src/mods/compact-snapshots.js +45 -0
  54. package/dist/src/mods/headless-check.d.ts +58 -0
  55. package/dist/src/mods/headless-check.js +137 -0
  56. package/dist/src/mods/headless-plugins.d.ts +44 -0
  57. package/dist/src/mods/headless-plugins.js +92 -0
  58. package/dist/src/mods/install-trust.d.ts +28 -0
  59. package/dist/src/mods/install-trust.js +127 -0
  60. package/dist/src/mods/install.d.ts +19 -0
  61. package/dist/src/mods/install.js +38 -1
  62. package/dist/src/mods/plugin-paths.d.ts +60 -0
  63. package/dist/src/mods/plugin-paths.js +131 -0
  64. package/dist/src/mods/plugin-repair.d.ts +8 -0
  65. package/dist/src/mods/plugin-repair.js +7 -0
  66. package/dist/src/plugins/marketplace-trust/scan.d.ts +23 -0
  67. package/dist/src/plugins/marketplace-trust/scan.js +255 -0
  68. package/dist/src/plugins/marketplace-trust/signature.d.ts +53 -0
  69. package/dist/src/plugins/marketplace-trust/signature.js +140 -0
  70. package/dist/src/plugins/marketplace-trust/verify-status.d.ts +42 -0
  71. package/dist/src/plugins/marketplace-trust/verify-status.js +110 -0
  72. package/dist/src/services/headless-worker-executor.d.ts +2 -0
  73. package/dist/src/services/headless-worker-executor.js +13 -3
  74. package/package.json +2 -1
  75. package/plugins/ruflo-metaharness/.claude-plugin/plugin.json +8 -1
  76. package/plugins/ruflo-metaharness/CHANGELOG.md +6 -0
  77. package/plugins/ruflo-metaharness/README.md +14 -1
  78. package/plugins/ruflo-metaharness/agents/metaharness-architect.md +2 -0
  79. package/plugins/ruflo-metaharness/hooks/hooks.json +16 -2
  80. package/plugins/ruflo-metaharness/scripts/_redblue.mjs +12 -2
  81. package/plugins/ruflo-metaharness/scripts/_secret-env.cjs +50 -0
  82. package/plugins/ruflo-metaharness/scripts/_secret.mjs +35 -0
  83. package/plugins/ruflo-metaharness/scripts/smoke.sh +7 -7
  84. package/plugins/ruflo-metaharness/skills/harness-bench/SKILL.md +2 -1
  85. package/plugins/ruflo-metaharness/skills/harness-drift-from-history/SKILL.md +1 -1
  86. package/plugins/ruflo-metaharness/skills/harness-evolve/SKILL.md +2 -1
  87. package/plugins/ruflo-metaharness/skills/harness-gepa/SKILL.md +1 -1
  88. package/plugins/ruflo-metaharness/skills/harness-learn/SKILL.md +2 -1
  89. package/plugins/ruflo-metaharness/skills/harness-mint/SKILL.md +2 -1
  90. package/plugins/ruflo-metaharness/skills/harness-oia-audit/SKILL.md +1 -0
  91. package/plugins/ruflo-metaharness/skills/harness-score/SKILL.md +1 -1
  92. package/plugins/ruflo-metaharness/skills/harness-security-bench/SKILL.md +2 -1
  93. 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
- return { id: r.id, task, agent, outcome, verifier, steps, edits: num(r.edits, 10_000), startedAt: num(r.startedAt), endedAt: num(r.endedAt) };
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
- /** Builds the lessons file from SONA's patterns, keeping only the ones verified outcomes trained. */
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
- lessons.push({ ...base, kind: 'lesson' });
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
- return { version: 1, gate: LESSON_GATE, generatedAt: now, lessons: lessons.slice(0, MAX_LESSONS), belowGate };
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 (record.outcome === 'verified-success')
277
+ if (success)
231
278
  ingested.verifiedSuccess++;
232
279
  else
233
280
  ingested.verifiedFailure++;
234
281
  continue;
235
282
  }
236
- const success = record.outcome === 'verified-success';
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] = { evidence: record.verifier.command, at: now };
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
- // Without SONA, verified records stayed un-ingested: keep the mtime stale so the next call retries them.
257
- if (sona) {
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;
@@ -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
  }
@@ -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
- if (opts.pluginInstall === false)
115
- return { install, resolvable: true, skipped: '--no-plugin-install' };
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({ projectRoot: root, scope: opts.scope, configDir, claude, plugins, marketplace: declared, exec: opts.exec });
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.