@dzhechkov/harness-cli 0.3.250 → 0.3.252

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dzhechkov/harness-cli",
3
- "version": "0.3.250",
3
+ "version": "0.3.252",
4
4
  "description": "The dz CLI — install AI skills for Claude Code, Codex, OpenCode, Hermes, OpenClaude, GitHub Copilot. 35 commands, 13 presets, 6 platform targets.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,7 +41,7 @@
41
41
  "sbom.json"
42
42
  ],
43
43
  "dependencies": {
44
- "@dzhechkov/harness-core": "^0.3.141",
44
+ "@dzhechkov/harness-core": "^0.3.143",
45
45
  "@dzhechkov/harness-presets": "^0.5.0",
46
46
  "@dzhechkov/scout": "^0.8.0",
47
47
  "@dzhechkov/skills-devops": "^0.3.0",
package/sbom.json CHANGED
@@ -25,7 +25,7 @@
25
25
  "hashes": [
26
26
  {
27
27
  "alg": "SHA-256",
28
- "content": "1559995482381fd6ac146e6d229cb7d33476cdca8fad21fd361467dd698bd065"
28
+ "content": "ff1b1a2239a254f500c999c5c25ab9303561b433fb1cac420a2b28a7bd0d9fd6"
29
29
  }
30
30
  ]
31
31
  },
@@ -105,7 +105,7 @@
105
105
  "hashes": [
106
106
  {
107
107
  "alg": "SHA-256",
108
- "content": "e41e9538474ad80901474841e66f3be8b9f4fdc24f85b4168a13958f19f0762c"
108
+ "content": "c9b415287eda0a7c2ad32dbb7cfc3ecce19aa3b90c96d3fbeedd62d20fe34bc7"
109
109
  }
110
110
  ]
111
111
  },
@@ -115,7 +115,7 @@
115
115
  "hashes": [
116
116
  {
117
117
  "alg": "SHA-256",
118
- "content": "038b4bf4c9295202129662e1caf3b8b139c91f69b2e292373066154b644cc97c"
118
+ "content": "56b4da2639c1721180387f28166d84d065718a4002f556307a6598570d335809"
119
119
  }
120
120
  ]
121
121
  },
@@ -125,7 +125,7 @@
125
125
  "hashes": [
126
126
  {
127
127
  "alg": "SHA-256",
128
- "content": "dafbac9c642da206ddfadb9081f01ef2a4cfba98dee719ef64200241d2b76726"
128
+ "content": "4a172ba5384c9716fa48570979b7ffba969599ed5c639d7e7268a829d2b9b4e1"
129
129
  }
130
130
  ]
131
131
  },
@@ -185,7 +185,7 @@
185
185
  "hashes": [
186
186
  {
187
187
  "alg": "SHA-256",
188
- "content": "4e22f0553a19181eea750db59bd3eec84fd880473ac523705a1a26197665e87e"
188
+ "content": "65e245ef567ccecdc382bb040f27dd98e01ce2c3a104935aa2dc0558b22220e2"
189
189
  }
190
190
  ]
191
191
  },
@@ -205,7 +205,7 @@
205
205
  "hashes": [
206
206
  {
207
207
  "alg": "SHA-256",
208
- "content": "9a32862f99d3d0211e08ea49c7091f5697acdd2789ac05137203abd6ed7a9b3e"
208
+ "content": "1aceceef391cac55d00f7872632d28c4116a82025b9235343b81398fc515f944"
209
209
  }
210
210
  ]
211
211
  },
@@ -225,7 +225,7 @@
225
225
  "hashes": [
226
226
  {
227
227
  "alg": "SHA-256",
228
- "content": "2e013ab73b4eb3dd248ed0fbbd5c050911ad667bb78054fde0bb021fbd1815db"
228
+ "content": "7d6ba26ec1188db3b3ac89f50b015ba1f0c6117b5c30d99b22da41da10c96c12"
229
229
  }
230
230
  ]
231
231
  },
package/src/cli.ts CHANGED
@@ -117,6 +117,7 @@ import {
117
117
  auditRecord,
118
118
  guardExitCode,
119
119
  DEFAULT_RULES,
120
+ parsePnpmLockImporters,
120
121
  decideProvenance,
121
122
  isInsideTree,
122
123
  signManifest,
@@ -214,6 +215,10 @@ import {
214
215
  ideaId,
215
216
  dedupIdea,
216
217
  readGoalMap,
218
+ readGoalMapDetailed,
219
+ parseEffort,
220
+ ensureBacklogGitignored,
221
+ isSafeId,
217
222
  alignIdea,
218
223
  mirrorIdeaVector,
219
224
  snapshotIdeas,
@@ -4855,11 +4860,17 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
4855
4860
  // a resolvable workspace dep becomes its real semver (safe → the rule passes); an UNRESOLVABLE one (points at
4856
4861
  // no workspace package, or not a pnpm workspace) stays `workspace:*` so the rule catches a dep that WOULD ship
4857
4862
  // raw. That is the genuinely dangerous case the rule exists for.
4858
- const manifests: { name?: string; version?: string; private?: boolean; dependencies?: Record<string, string> }[] = [];
4863
+ type Manifest = { name?: string; version?: string; private?: boolean; dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
4864
+ const manifests: Manifest[] = [];
4865
+ const located: { dir: string; m: Manifest }[] = [];
4859
4866
  try {
4860
4867
  const out = execSync('git ls-files "packages/@dzhechkov/*/package.json"', { cwd: root, encoding: 'utf-8' });
4861
4868
  for (const rel of out.split('\n').map((s) => s.trim()).filter(Boolean)) {
4862
- try { manifests.push(JSON.parse(readFileSync(join(root, rel), 'utf8')) as typeof manifests[number]); } catch { /* skip unreadable */ }
4869
+ try {
4870
+ const m = JSON.parse(readFileSync(join(root, rel), 'utf8')) as Manifest;
4871
+ manifests.push(m);
4872
+ located.push({ dir: rel.replace(/\/package\.json$/, ''), m });
4873
+ } catch { /* skip unreadable */ }
4863
4874
  }
4864
4875
  } catch { /* not a git repo */ }
4865
4876
  const versionByName = new Map<string, string>();
@@ -4909,6 +4920,28 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
4909
4920
  }
4910
4921
  facts['skillPacks'] = skillPacks;
4911
4922
  } catch { /* unreadable packages dir — rule reports nothing rather than inventing a violation */ }
4923
+
4924
+ // lockfile-in-sync: the ERR_PNPM_OUTDATED_LOCKFILE class — a @dzhechkov/* dep spec bumped in a
4925
+ // package.json while pnpm-lock.yaml still records the old specifier. FAIL-OPEN by construction: an
4926
+ // unreadable/unparseable lockfile yields `{parsed:false}` and the rule reports NOTHING.
4927
+ try {
4928
+ const lockText = readFileSync(join(root, 'pnpm-lock.yaml'), 'utf8');
4929
+ const importers = parsePnpmLockImporters(lockText);
4930
+ if (importers === undefined) {
4931
+ facts['lockfile'] = { parsed: false };
4932
+ } else {
4933
+ const rows = located
4934
+ .map(({ dir, m }) => {
4935
+ const declared: Record<string, string> = {};
4936
+ for (const [dep, spec] of Object.entries({ ...(m.dependencies ?? {}), ...(m.devDependencies ?? {}) })) {
4937
+ if (dep.startsWith('@dzhechkov/') && typeof spec === 'string') declared[dep] = spec;
4938
+ }
4939
+ return { importer: dir, declared, locked: importers[dir] };
4940
+ })
4941
+ .filter((r) => Object.keys(r.declared).length > 0);
4942
+ facts['lockfile'] = { parsed: true, importers: rows };
4943
+ }
4944
+ } catch { facts['lockfile'] = { parsed: false }; /* no lockfile (not a pnpm workspace) — rule stays silent */ }
4912
4945
  }
4913
4946
  if (op === 'consolidate') {
4914
4947
  try { facts['drift'] = sweepSkillDrift(root, { scope: 'packages', allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name); } catch { /* skip */ }
@@ -6199,9 +6232,18 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
6199
6232
  if (sub === 'add') {
6200
6233
  const text = (options.get('_positional_1') ?? '').trim();
6201
6234
  if (text === '') return emitErr('an idea text is required: dz backlog add "<idea>"');
6202
- const effort = Number(options.get('effort') ?? cfg.roulette.defaultEffort);
6235
+ // The clamp is ECHOED, never silent (idea 86096d6d): `--effort 13` used to store 5 and say nothing.
6236
+ const eff = parseEffort(options.get('effort'), cfg.roulette.defaultEffort);
6237
+ if (eff.adjusted && !json && eff.note !== undefined) write(`dz backlog: ${eff.note}`);
6203
6238
  const dryRun = flags.has('dry-run');
6204
6239
  const verdict = await dedupIdea(projectRoot, text, cfg);
6240
+ // The TOP-MATCH pair (id @ cosine) is the ADR-002 calibration surface (idea ce914ac2) — observational
6241
+ // only: the band itself is unchanged, but a RELATED verdict now shows WHICH idea produced the cosine.
6242
+ // A negative cosine is a VALID observation (anti-correlated embedding) — only non-finite is absent
6243
+ // (Codex re-QE LOW: `>= 0` suppressed a legitimate negative top match).
6244
+ const topMatch = verdict.topMatchId !== undefined && Number.isFinite(verdict.cosine)
6245
+ ? { id: verdict.topMatchId, cosine: verdict.cosine }
6246
+ : undefined;
6205
6247
  if (verdict.action === 'duplicate') {
6206
6248
  // DUPLICATE ⇒ snapshot + reinforce the existing root; NO new record (ADR-002 T-002b).
6207
6249
  const ideas = readIdeas(projectRoot);
@@ -6212,8 +6254,11 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
6212
6254
  match.uses += 1;
6213
6255
  writeIdeas(projectRoot, ideas);
6214
6256
  }
6215
- if (json) write(JSON.stringify({ action: 'duplicate', matchedId: verdict.matchedId, cosine: verdict.cosine, exitCode: 0 }, null, 2));
6216
- else write(`dz backlog: DUPLICATE of ${verdict.matchedId} (cosine ${verdict.cosine.toFixed(3)}${verdict.exactTextOnly ? ', exact-text' : ''}) — reinforced, no new record`);
6257
+ if (json) write(JSON.stringify({ action: 'duplicate', matchedId: verdict.matchedId, cosine: verdict.cosine, ...(topMatch !== undefined ? { topMatch } : {}), ...(eff.note !== undefined ? { effortNote: eff.note } : {}), exitCode: 0 }, null, 2));
6258
+ else {
6259
+ write(`dz backlog: DUPLICATE of ${verdict.matchedId} (cosine ${verdict.cosine.toFixed(3)}${verdict.exactTextOnly ? ', exact-text' : ''}) — reinforced, no new record`);
6260
+ if (topMatch !== undefined) write(` top match ${topMatch.id} @ cosine ${topMatch.cosine.toFixed(3)} (DUPLICATE band ≥ ${cfg.dedup.duplicateThreshold})`);
6261
+ }
6217
6262
  return 0;
6218
6263
  }
6219
6264
  const goalMap = readGoalMap(projectRoot);
@@ -6224,7 +6269,7 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
6224
6269
  text,
6225
6270
  status: 'new',
6226
6271
  createdTs,
6227
- effort: Number.isFinite(effort) ? Math.max(1, Math.min(5, Math.floor(effort))) : cfg.roulette.defaultEffort,
6272
+ effort: eff.effort,
6228
6273
  goalId: align.goalId,
6229
6274
  goalAlignment: align.goalAlignment,
6230
6275
  relatedIds: [...verdict.relatedIds],
@@ -6234,19 +6279,39 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
6234
6279
  const proposal = options.get('proposal');
6235
6280
  if (proposal !== undefined) rec.proposal = proposal; // agent prose ONLY — the CLI never fabricates it
6236
6281
  if (dryRun) {
6237
- if (json) write(JSON.stringify({ action: verdict.action, dryRun: true, idea: rec, exitCode: 0 }, null, 2));
6238
- else write(`dz backlog (dry-run): ${verdict.action.toUpperCase()} — would create ${rec.id}; align ${rec.goalAlignment.toFixed(3)}${rec.goalId !== null ? ` → ${rec.goalId}` : ''}`);
6282
+ if (json) write(JSON.stringify({ action: verdict.action, dryRun: true, idea: rec, ...(topMatch !== undefined ? { topMatch } : {}), ...(eff.note !== undefined ? { effortNote: eff.note } : {}), exitCode: 0 }, null, 2));
6283
+ else {
6284
+ write(`dz backlog (dry-run): ${verdict.action.toUpperCase()} — would create ${rec.id}; align ${rec.goalAlignment.toFixed(3)}${rec.goalId !== null ? ` → ${rec.goalId}` : ''}`);
6285
+ // The calibration surface belongs on the dry-run too (QE LOW-8) — a dry-run is exactly where a
6286
+ // user checks whether a near-duplicate should have crossed the band.
6287
+ if (topMatch !== undefined) write(` top match ${topMatch.id} @ cosine ${topMatch.cosine.toFixed(3)} (DUPLICATE band ≥ ${cfg.dedup.duplicateThreshold})`);
6288
+ }
6239
6289
  return 0;
6240
6290
  }
6291
+ // Privacy scaffold (idea ec4cd60d): the store we are about to create holds raw prompt-class ideas —
6292
+ // ensure `.dz/backlog/` is gitignored BEFORE the first write. No-op once covered.
6293
+ const ignore = ensureBacklogGitignored(projectRoot);
6241
6294
  const ideas = readIdeas(projectRoot);
6242
6295
  ideas.push(rec);
6243
6296
  writeIdeas(projectRoot, ideas);
6244
6297
  const mirror = await mirrorIdeaVector(projectRoot, rec); // best-effort — never blocks capture
6245
- if (json) write(JSON.stringify({ action: verdict.action, idea: rec, related: verdict.relatedIds, exitCode: 0 }, null, 2));
6298
+ if (json) write(JSON.stringify({ action: verdict.action, idea: rec, related: verdict.relatedIds, ...(topMatch !== undefined ? { topMatch } : {}), ...(eff.note !== undefined ? { effortNote: eff.note } : {}), gitignore: ignore, exitCode: 0 }, null, 2));
6246
6299
  else {
6247
6300
  write(`dz backlog: ${verdict.action.toUpperCase()} — captured ${rec.id}`);
6301
+ if (verdict.action === 'related' && topMatch !== undefined) {
6302
+ write(` top match ${topMatch.id} @ cosine ${topMatch.cosine.toFixed(3)} (DUPLICATE band ≥ ${cfg.dedup.duplicateThreshold})`);
6303
+ }
6248
6304
  if (rec.goalId !== null) write(` top goal: ${rec.goalId} (alignment ${rec.goalAlignment.toFixed(3)})`);
6249
6305
  if (verdict.relatedIds.length > 0) write(` related: ${verdict.relatedIds.join(', ')}`);
6306
+ if (ignore.action === 'created' || ignore.action === 'appended') {
6307
+ write(` privacy: ${ignore.action} \`.dz/backlog/\` in ${ignore.path} (ideas are private prompt-class content)`);
6308
+ } else if (ignore.action === 'skipped') {
6309
+ // A privacy scaffold that FAILED must never be silent (QE MED-5): the store now holds raw ideas
6310
+ // that git can see, and only the user can fix it.
6311
+ write(` ⚠ privacy: could NOT gitignore \`.dz/backlog/\` (${ignore.reason ?? 'unknown error'}) — your ideas are visible to git; add \`.dz/backlog/\` to ${ignore.path} yourself`);
6312
+ } else if (ignore.action === 'user-opted-out') {
6313
+ write(' privacy: `.dz/backlog/` is explicitly un-ignored (a `!` negation in .gitignore) — respecting that; ideas WILL be tracked by git');
6314
+ }
6250
6315
  write(' proposal: (write your own — dz backlog add --proposal "<text>"; the CLI never fabricates it)');
6251
6316
  if (mirror.error !== undefined) write(` ↳ vector mirror deferred: ${mirror.error}`);
6252
6317
  }
@@ -6282,21 +6347,42 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
6282
6347
  }
6283
6348
 
6284
6349
  if (sub === 'goals') {
6285
- const goalMap = readGoalMap(projectRoot);
6286
6350
  if (flags.has('validate')) {
6351
+ // A vacuous "valid (0 goal(s))" HID the user's mistake (idea 960c9f26): the defensive reader silently
6352
+ // dropped every malformed entry (e.g. `text` where `statement` belongs). Validate reads the DETAILED
6353
+ // view, warns per dropped entry, and refuses to call a fully-dropped compass valid.
6354
+ const detail = readGoalMapDetailed(projectRoot);
6355
+ const goalMap = detail.goalMap;
6287
6356
  const problems: string[] = [];
6357
+ const warnings: string[] = [];
6358
+ if (detail.parseError !== undefined) problems.push(detail.parseError);
6359
+ for (const d of detail.dropped) warnings.push(`goal[${d.index}]: ${d.reason} — dropped`);
6360
+ // The reader CLAMPS a bad weight before any validation could see it, so validating the clamped
6361
+ // value can only ever pass (QE MED-7 — the branch below was dead code). Warn from the RAW value
6362
+ // the user wrote, and say which value the runtime is actually using.
6363
+ for (const r of detail.repaired) warnings.push(`goal[${r.index}] (${r.id}): ${r.reason} — using ${r.used}`);
6288
6364
  const seen = new Set<string>();
6289
6365
  for (const g of goalMap.goals) {
6290
6366
  if (seen.has(g.id)) problems.push(`duplicate goal id: ${g.id}`);
6291
6367
  seen.add(g.id);
6292
6368
  if (g.statement.trim() === '') problems.push(`goal ${g.id} has an empty statement`);
6369
+ // Defence in depth: the reader guarantees the clamp, so this can only fire if that ever regresses.
6293
6370
  if (!(g.weight > 0 && g.weight <= 1)) problems.push(`goal ${g.id} weight ${g.weight} is out of (0,1]`);
6294
6371
  }
6295
- if (json) write(JSON.stringify({ valid: problems.length === 0, problems, goals: goalMap.goals.length, exitCode: problems.length === 0 ? 0 : 1 }, null, 2));
6296
- else if (problems.length === 0) write(`dz backlog goals: valid (${goalMap.goals.length} goal(s))`);
6297
- else { write('dz backlog goals: INVALID'); for (const p of problems) write(` - ${p}`); }
6298
- return problems.length === 0 ? 0 : 1;
6372
+ // A compass whose entries were ALL dropped is a user error, not an empty compass.
6373
+ const allDropped = detail.present > 0 && goalMap.goals.length === 0;
6374
+ if (allDropped) problems.push(`all ${detail.present} goal entr${detail.present === 1 ? 'y was' : 'ies were'} dropped as malformed the compass is EMPTY, not valid`);
6375
+ const valid = problems.length === 0;
6376
+ if (json) {
6377
+ write(JSON.stringify({ valid, problems, warnings, dropped: detail.dropped, repaired: detail.repaired, goals: goalMap.goals.length, present: detail.present, exitCode: valid ? 0 : 1 }, null, 2));
6378
+ } else {
6379
+ for (const w of warnings) write(`dz backlog goals: WARNING ${w}`);
6380
+ if (valid) write(`dz backlog goals: valid (${goalMap.goals.length} goal(s)${detail.dropped.length > 0 ? `, ${detail.dropped.length} dropped` : ''})`);
6381
+ else { write('dz backlog goals: INVALID'); for (const p of problems) write(` - ${p}`); }
6382
+ }
6383
+ return valid ? 0 : 1;
6299
6384
  }
6385
+ const goalMap = readGoalMap(projectRoot);
6300
6386
  if (json) {
6301
6387
  write(JSON.stringify({ goals: goalMap.goals, exitCode: 0 }, null, 2));
6302
6388
  return 0;
@@ -6313,7 +6399,37 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
6313
6399
  if (sub === 'roulette') {
6314
6400
  const ideas = readIdeas(projectRoot);
6315
6401
  const pool = eligibleIdeas(ideas);
6316
- if (pool.length === 0) {
6402
+ // ── Commit-argument validation runs BEFORE any early return (QE HIGH-2): an empty eligible pool
6403
+ // used to return 0 first, so `--commit <garbage>` reported success having validated nothing. A
6404
+ // mutation request is answered on its own merits, whatever the pool looks like.
6405
+ // parseArgs turns `--commit <id>` into an OPTION and a bare `--commit` into a FLAG. A trailing
6406
+ // positional id is only a commit target when --commit was actually passed (QE HIGH-1): a bare
6407
+ // `dz backlog roulette <id>` must never mutate.
6408
+ const positionalId = options.get('_positional_1');
6409
+ const commitOpt = options.get('commit');
6410
+ const wantsCommit = flags.has('commit') || commitOpt !== undefined;
6411
+ if (!wantsCommit && positionalId !== undefined) {
6412
+ return emitErr(`an idea id alone does not start it — a spin never mutates. Use: dz backlog roulette --commit ${positionalId}`);
6413
+ }
6414
+ const commitId = wantsCommit ? commitOpt ?? positionalId : undefined;
6415
+ if (wantsCommit && commitId !== undefined) {
6416
+ if (!isSafeId(commitId)) return emitErr(`refusing an unsafe idea id: ${JSON.stringify(commitId)}`);
6417
+ const target = ideas.find((i) => i.id === commitId);
6418
+ if (target === undefined) return emitErr(`no idea ${commitId} — run dz backlog list to see the ids`);
6419
+ // TERMINAL ideas must not be silently resurrected (Codex re-QE). in-progress is allowed —
6420
+ // re-committing it is an idempotent no-op, not a resurrection.
6421
+ if (target.status === 'shipped' || target.status === 'dropped') {
6422
+ return emitErr(`idea ${commitId} is ${target.status} — a terminal idea cannot be (re)started`);
6423
+ }
6424
+ }
6425
+ // `--pick N` is a read-only ranking view; combining it with a mutation request is ambiguous and was
6426
+ // silently ignoring the commit (Codex re-QE) — refuse the combination explicitly.
6427
+ if (wantsCommit && options.get('pick') !== undefined) {
6428
+ return emitErr('--pick cannot be combined with --commit — commit a single explicit id without --pick');
6429
+ }
6430
+ if (pool.length === 0 && commitId === undefined) {
6431
+ // A bare `--commit` with nothing to spin cannot name a pick to paste, so it says exactly that.
6432
+ if (wantsCommit) return emitErr('roulette --commit requires an idea id, and there is no eligible idea to suggest one — run dz backlog list');
6317
6433
  write(json ? JSON.stringify({ pick: null, eligible: 0, exitCode: 0 }) : 'dz backlog roulette: no eligible ideas (status new/enriched)');
6318
6434
  return 0;
6319
6435
  }
@@ -6328,18 +6444,31 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
6328
6444
  }
6329
6445
  const seedOpt = options.get('seed');
6330
6446
  const seed = seedOpt !== undefined && Number.isFinite(Number(seedOpt)) ? Number(seedOpt) : Math.floor(Math.random() * 2 ** 31);
6331
- const pick = spinRoulette(ideas, cfg.roulette, seededRng(seed), now)!;
6447
+ // With an empty pool but a valid --commit id, there is nothing to spin — the commit still stands.
6448
+ const spun = pool.length > 0 ? spinRoulette(ideas, cfg.roulette, seededRng(seed), now) : undefined;
6449
+ if (wantsCommit && commitId === undefined) {
6450
+ // --commit takes an EXPLICIT id (idea 5fcd9a2d): the spin is a function of the CURRENT pool, so a
6451
+ // bare `--commit` after the pool changed could start a DIFFERENT idea than the dry-run showed. The
6452
+ // id the user saw is the id that gets committed — the spin no longer decides a mutation.
6453
+ const msg = `roulette --commit requires the idea id you are committing to: dz backlog roulette --commit ${spun!.id} (this spin's pick) — a bare --commit could start a different idea than the one you were shown`;
6454
+ if (json) write(JSON.stringify({ error: msg, pickId: spun!.id, exitCode: 1 }, null, 2));
6455
+ else write(`dz backlog: ${msg}`);
6456
+ return 1;
6457
+ }
6458
+ let pick = spun;
6332
6459
  let committed = false;
6333
- if (flags.has('commit')) {
6334
- const idx = ideas.findIndex((i) => i.id === pick.id);
6460
+ if (commitId !== undefined) {
6461
+ // Validated above (safe id + known id) BEFORE any early return.
6462
+ const idx = ideas.findIndex((i) => i.id === commitId);
6335
6463
  ideas[idx]!.status = 'in-progress';
6336
6464
  writeIdeas(projectRoot, ideas);
6465
+ pick = ideas[idx]!;
6337
6466
  committed = true;
6338
6467
  }
6339
- if (json) write(JSON.stringify({ pick, seed, committed, exitCode: 0 }, null, 2));
6340
- else {
6468
+ if (json) write(JSON.stringify({ pick: pick ?? null, seed, committed, exitCode: 0 }, null, 2));
6469
+ else if (pick !== undefined) {
6341
6470
  write(`dz backlog roulette (seed ${seed}): ${pick.id} — ${pick.text.slice(0, 70)}`);
6342
- write(` align ${pick.goalAlignment.toFixed(3)}${pick.goalId !== null ? ` (${pick.goalId})` : ''} · effort ${pick.effort}${committed ? ' · → in-progress' : ' · (add --commit to start it)'}`);
6471
+ write(` align ${pick.goalAlignment.toFixed(3)}${pick.goalId !== null ? ` (${pick.goalId})` : ''} · effort ${pick.effort}${committed ? ' · → in-progress' : ` · (start it with: dz backlog roulette --commit ${pick.id})`}`);
6343
6472
  }
6344
6473
  return 0;
6345
6474
  }
@@ -6408,7 +6537,7 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
6408
6537
  write(' list [--status <s>][--goal <id>] list ideas');
6409
6538
  write(' show <id> full record');
6410
6539
  write(' goals [--validate] the compass (.dz/backlog/goals.json)');
6411
- write(' roulette [--pick N][--seed n][--commit] weighted draw for what to work on next');
6540
+ write(' roulette [--pick N][--seed n][--commit <id>] weighted draw; --commit takes the id you saw');
6412
6541
  write(' enrich <id> stage the idea2prd hand-off (agent expands)');
6413
6542
  write(` jira <id> draft a Jira issue (adapter: ${[...BACKLOG_BACKENDS].join('|')})`);
6414
6543
  write(' harmonize [--apply][--threshold 0-1] batch semantic dedup of the backlog');