@dzhechkov/harness-cli 0.4.2 → 0.4.3

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.4.2",
3
+ "version": "0.4.3",
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",
package/sbom.json CHANGED
@@ -25,7 +25,7 @@
25
25
  "hashes": [
26
26
  {
27
27
  "alg": "SHA-256",
28
- "content": "4e4049b871ded58ff38fc36b60387a2324610db81dedcb623fb9cce6e77b057b"
28
+ "content": "532051106b153c0d9bb7ad2abbeb71d418f88f1bc88e60a35e29cf42162af243"
29
29
  }
30
30
  ]
31
31
  },
@@ -95,7 +95,7 @@
95
95
  "hashes": [
96
96
  {
97
97
  "alg": "SHA-256",
98
- "content": "a260f0e8d698ac142b38e33ef4d4ed0a21ccee96b179514bcb01bf81c5dce00f"
98
+ "content": "5bdc153e789ac180543e9b4955ea36666dc27b7238637b05875e048fc49ae30f"
99
99
  }
100
100
  ]
101
101
  },
@@ -105,7 +105,7 @@
105
105
  "hashes": [
106
106
  {
107
107
  "alg": "SHA-256",
108
- "content": "b8a9c6d50cea7eda1065d76adb83e7c853ed9d82db72e7b24f952598e9dbd9d9"
108
+ "content": "0183c01a98e232554a7460be48ee5fdf59dd3a7d09c719a31b08acc9c6b3528c"
109
109
  }
110
110
  ]
111
111
  },
@@ -115,7 +115,7 @@
115
115
  "hashes": [
116
116
  {
117
117
  "alg": "SHA-256",
118
- "content": "4af214e9233bd8f37ab92b95b90fcf05bd46752ecdc0d9491484ee8a7e55c539"
118
+ "content": "c5bd1b0b852dd21de328494d7a99db336afbde4397b7c687d416f1a6f3c297b2"
119
119
  }
120
120
  ]
121
121
  },
@@ -175,7 +175,7 @@
175
175
  "hashes": [
176
176
  {
177
177
  "alg": "SHA-256",
178
- "content": "fa591b644f5abda81022a2cfe37996d739a2290ac46e09dcb958faa4e12be9f6"
178
+ "content": "4a164e5ac53ceec178463c1bdab9f80c1755311d5e4202c1f6234ca22c473b37"
179
179
  }
180
180
  ]
181
181
  },
@@ -195,7 +195,7 @@
195
195
  "hashes": [
196
196
  {
197
197
  "alg": "SHA-256",
198
- "content": "c9847974744f5bfd383866eb1832e97cb856e73d6f65cad19abfb91f22998cae"
198
+ "content": "0a1097fc3a2294f934fc50f5871b287cfbc5ec85becdab914b7caeca42cb7d1a"
199
199
  }
200
200
  ]
201
201
  },
@@ -475,7 +475,7 @@
475
475
  "hashes": [
476
476
  {
477
477
  "alg": "SHA-256",
478
- "content": "3dd056f9d05712e45173b0911fc5900f0f52f8deba506d57c0e2b8281eb36a74"
478
+ "content": "fe8e5792626cf58678c1e2bcb809f63413655c2e171439157b74deb8b4fd80fb"
479
479
  }
480
480
  ]
481
481
  },
@@ -489,6 +489,16 @@
489
489
  }
490
490
  ]
491
491
  },
492
+ {
493
+ "type": "file",
494
+ "name": "test/trace-bundle-cli.test.ts",
495
+ "hashes": [
496
+ {
497
+ "alg": "SHA-256",
498
+ "content": "fdaad63359ca7ce351e505e613b547bbc653e0847403edfae88d22a8a524b7a0"
499
+ }
500
+ ]
501
+ },
492
502
  {
493
503
  "type": "file",
494
504
  "name": "test/workflow-init-lint-clean.test.ts",
package/src/cli.ts CHANGED
@@ -105,6 +105,7 @@ import {
105
105
  deriveUsageCalibration,
106
106
  normalizeClaudeUsageModelKey,
107
107
  readUsageLimits,
108
+ parseWeeklyResetAnchor,
108
109
  claimCheck,
109
110
  summarize,
110
111
  queryBookKnowledge,
@@ -222,6 +223,10 @@ import {
222
223
  selectAutoCost,
223
224
  recordProvisional,
224
225
  finalizeOutcome,
226
+ harvestStageOutcomes,
227
+ recommendModels,
228
+ planFeed,
229
+ GRADE_SUCCESS_FLOOR,
225
230
  COST_LADDER,
226
231
  splitScenarios,
227
232
  budgetPlan,
@@ -297,6 +302,8 @@ import {
297
302
  makeBacklogIO,
298
303
  harmonizeBacklog,
299
304
  transitionIdeas,
305
+ editIdea,
306
+ clearEmbedStale,
300
307
  BACKLOG_BACKENDS,
301
308
  applyDomainBoost,
302
309
  DZ_OWNED_TASK_TYPES,
@@ -326,6 +333,13 @@ import {
326
333
  mutationGateExitCode,
327
334
  summarizeMutationResults,
328
335
  renderMutationReport,
336
+ TRACE_BUNDLE_LEDGER_PATH,
337
+ TRACE_BUNDLE_SCHEMA,
338
+ TRACE_BUNDLE_RUN_META_FILE,
339
+ buildBundle,
340
+ serializeBundle,
341
+ parseBundle,
342
+ planImport,
329
343
  } from '@dzhechkov/harness-core';
330
344
  import type { MutationEntryResult, MutationObservation, MutationRegistryEntry } from '@dzhechkov/harness-core';
331
345
  import type { ReqeDebt } from '@dzhechkov/harness-core';
@@ -355,6 +369,8 @@ Usage:
355
369
  dz workflow blobs [--check] (list/self-check the subsystem blob registry)
356
370
  dz workflow-lint <script.js> [--plan <plan.json>] [--require-plan|--legacy] [--json] (layer-1 gate; exit 0/1/3 — inconclusive is never a pass)
357
371
  dz workflow-trace <runDir|--slug <s>|--run <id>> [--invariants <plan.json>] [--html <out.html>] [--json] (timeline + SEQ invariant runner)
372
+ dz workflow-trace export <run> --o <file> [--include-pairs --yes] [--strict] (one run's telemetry as ONE movable file: events, not aggregates; degradation is typed and LOUD, --strict fails closed)
373
+ dz workflow-trace import <bundle> --into <root> [--force] [--with-pairs] (reconstruct that run under an explicit root; FAIL-CLOSED — never writes over a run that already has content)
358
374
  dz install <npm-pkg> [--target <name>] [--project <dir>] [--force]
359
375
  dz bundle [--preset <name> | --select id,id,...] [--out <dir>] [--skills-dir <dir>] [--force] (portable self-contained skill bundles for a generic/LangGraph consumer)
360
376
  dz doctor [--project <dir>] [--pubkey <path>] [--require-signing] (health + signature check of installed packs)
@@ -381,6 +397,8 @@ Usage:
381
397
  dz backlog roulette [--pick <N>] [--seed <n>] [--commit] [--project <dir>] [--json] (WEIGHTED draw over eligible ideas: alignment^alpha * recencyDecay * 1/effort, seeded; --pick N = ranked shortlist; --commit flips the pick to in-progress)
382
398
  dz backlog ship <id> [<id>…] [--reason <t>] [--dry-run] [--project <dir>] [--json] (mark work DONE: new|enriched|in-progress → shipped, removing it from the roulette pool — run it after finishing a task; short id prefixes ok, ambiguous = loud error)
383
399
  dz backlog drop <id> [<id>…] [--reason <t>] [--dry-run] [--project <dir>] [--json] (retire an idea: new|enriched|in-progress → dropped)
400
+ dz backlog edit <id> --text "<new>" | --append "<more>" [--dry-run] [--project <dir>] [--json] (rewrite ONE idea's text, preserving every other field; re-embeds the dedup vector, and on a failed re-embed MARKS the record embedStale so dedup refuses to trust it — previous text preserved in .dz/backlog/edits.jsonl)
401
+ dz routing recommend [--tier <t>] [--apply] [--json] (per-stage args.models suggestion from REAL telemetry — harness records + imported run-meta sidecars — printed WITH its basis: n runs, window, the grade-floor rule, skip reasons; qe is FORCED cross-family of code; --apply feeds .dz/routing-outcomes.json idempotently by runId)
384
402
  dz backlog reopen <id> [<id>…] [--reason <t>] [--dry-run] [--project <dir>] [--json] (back to the pool: shipped|dropped|in-progress → new)
385
403
  dz backlog enrich <id> [--project <dir>] [--json] (stage the idea2prd input scaffold in features/<slug>/ and hand off to the idea2prd-manual skill — the CLI never fabricates a PRD)
386
404
  dz backlog jira <id> [--project <dir>] [--json] (draft a Jira issue via the configurable adapter (backlog.jira.adapter: jira-mcp|copilot-mcp|none); none writes an auditable jira-outbox/<id>.json stub)
@@ -1143,7 +1161,215 @@ function cmdWorkflowLint(options: Map<string, string>, flags: Set<string>, cwd:
1143
1161
  /** `dz workflow-trace` — timeline + invariant runner over a run's trace.jsonl. Scope is CAPPED
1144
1162
  * (AM-8): <runDir|--slug|--run>, --invariants, --html, --json. NO watch/filter/compare/search/
1145
1163
  * retention/access-control — adding one needs an ADR amendment (the surface test pins this). */
1164
+ /** The tool version stamped into a bundle's provenance. Unknown is honest; a throw is not. */
1165
+ function traceBundleToolVersion(): string {
1166
+ try {
1167
+ const req = createRequire(import.meta.url);
1168
+ const pkg = req('../package.json') as { version?: unknown };
1169
+ return typeof pkg.version === 'string' ? pkg.version : 'unknown';
1170
+ } catch { return 'unknown'; }
1171
+ }
1172
+
1173
+ /** Resolve a run the SAME three ways the timeline reader does — positional, --slug, --run — so a
1174
+ * bundle can never address a run by a scheme the rest of the command does not understand. */
1175
+ function resolveTraceRun(options: Map<string, string>, cwd: string, positional: string | undefined):
1176
+ { runDir: string; slug: string | null; runId: string | null } | null {
1177
+ const slug = options.get('slug');
1178
+ const runId = options.get('run');
1179
+ if (positional !== undefined && positional !== '') {
1180
+ return { runDir: resolve(cwd, positional), slug: slug ?? null, runId: runId ?? null };
1181
+ }
1182
+ if (slug !== undefined) return { runDir: resolve(cwd, 'features', slug), slug, runId: runId ?? null };
1183
+ if (runId !== undefined) return { runDir: resolve(cwd, '.dz', 'loop-trace', runId), slug: slug ?? null, runId };
1184
+ return null;
1185
+ }
1186
+
1187
+ /** Every harness workflow record under the project, already parsed. An unparseable file is SKIPPED
1188
+ * here rather than guessed at — the pure half answers `unreadable` from what it is handed. */
1189
+ function readHarnessRecords(cwd: string): unknown[] {
1190
+ const out: unknown[] = [];
1191
+ const base = join(cwd, 'roam', 'claude-state');
1192
+ if (!existsSync(base)) return out;
1193
+ let sessions: string[] = [];
1194
+ try { sessions = readdirSync(base); } catch { return out; }
1195
+ for (const session of sessions) {
1196
+ const dir = join(base, session, 'workflows');
1197
+ if (!existsSync(dir)) continue;
1198
+ let names: string[] = [];
1199
+ try { names = readdirSync(dir); } catch { continue; }
1200
+ for (const name of names) {
1201
+ if (!name.endsWith('.json')) continue;
1202
+ try { out.push(JSON.parse(readFileSync(join(dir, name), 'utf-8'))); } catch { /* skipped, never guessed */ }
1203
+ }
1204
+ }
1205
+ return out;
1206
+ }
1207
+
1208
+ /** `dz workflow-trace export` — one run's telemetry as ONE movable file. */
1209
+ function cmdWorkflowTraceExport(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
1210
+ const out = options.get('o') ?? options.get('out');
1211
+ const run = resolveTraceRun(options, cwd, options.get('_positional_1'));
1212
+ if (run === null || out === undefined || out === '') {
1213
+ write('dz workflow-trace export: usage — dz workflow-trace export <runDir|--slug <s>|--run <id>> --o <file> [--include-pairs --yes] [--strict]');
1214
+ return 1;
1215
+ }
1216
+ const wantPairs = flags.has('include-pairs');
1217
+ if (wantPairs && !flags.has('yes')) {
1218
+ write('dz workflow-trace export: --include-pairs needs --yes — training pairs may carry TARGET-REPO CODE and full prompts, so shipping them off this machine is a second, explicit decision');
1219
+ return 1;
1220
+ }
1221
+
1222
+ const readSlot = (abs: string, origin: string) =>
1223
+ existsSync(abs)
1224
+ ? { present: true as const, member: { origin, content: readFileSync(abs, 'utf-8') } }
1225
+ : { present: false as const, reason: `absent on disk at ${origin}` };
1226
+
1227
+ const trace = readSlot(join(run.runDir, 'trace.jsonl'), 'trace.jsonl');
1228
+ const checkpoints = readSlot(join(run.runDir, '.fa-state', 'checkpoints.jsonl'), '.fa-state/checkpoints.jsonl');
1229
+
1230
+ const ledgerAbs = join(cwd, TRACE_BUNDLE_LEDGER_PATH);
1231
+ // An empty trailing line is not a malformed row — hand the pure half only real lines, or the
1232
+ // bundle reports a phantom parse failure on every well-formed ledger (MEASURED on first run).
1233
+ const ledgerLines = existsSync(ledgerAbs)
1234
+ ? readFileSync(ledgerAbs, 'utf-8').split('\n').filter((line) => line.trim() !== '')
1235
+ : null;
1236
+
1237
+ const pairFiles: { origin: string; content: string }[] = [];
1238
+ if (wantPairs && run.slug !== null) {
1239
+ const pairDir = join(cwd, '.dz', 'fa-training', run.slug);
1240
+ if (existsSync(pairDir)) {
1241
+ for (const name of readdirSync(pairDir)) {
1242
+ if (!name.endsWith('.jsonl')) continue;
1243
+ pairFiles.push({ origin: `.dz/fa-training/${run.slug}/${name}`, content: readFileSync(join(pairDir, name), 'utf-8') });
1244
+ }
1245
+ }
1246
+ }
1247
+
1248
+ const bundle = buildBundle({
1249
+ sourceRoot: cwd,
1250
+ runAddress: options.get('_positional_1') ?? (run.slug !== null ? `--slug ${run.slug}` : `--run ${String(run.runId)}`),
1251
+ slug: run.slug,
1252
+ runId: run.runId,
1253
+ toolVersion: traceBundleToolVersion(),
1254
+ createdAt: null,
1255
+ trace,
1256
+ checkpoints,
1257
+ ledgerLines,
1258
+ ...(ledgerLines === null ? { ledgerReason: `no ledger at ${TRACE_BUNDLE_LEDGER_PATH}` } : {}),
1259
+ includePairs: wantPairs,
1260
+ pairFiles: wantPairs ? pairFiles : null,
1261
+ records: readHarnessRecords(cwd),
1262
+ });
1263
+
1264
+ try {
1265
+ mkdirSync(dirname(resolve(cwd, out)), { recursive: true });
1266
+ writeFileSync(resolve(cwd, out), serializeBundle(bundle));
1267
+ } catch (err) {
1268
+ write(`dz workflow-trace export: could not write ${out} — ${err instanceof Error ? err.message : String(err)}`);
1269
+ return 1;
1270
+ }
1271
+
1272
+ // Degradation is NAMED, never silent — and exactly one reason is actionable.
1273
+ let degraded = 0;
1274
+ if (!bundle.trace.present) { degraded++; write(`dz workflow-trace export: trace absent — ${bundle.trace.reason}`); }
1275
+ if (!bundle.checkpoints.present) { degraded++; write(`dz workflow-trace export: checkpoints absent — ${bundle.checkpoints.reason}`); }
1276
+ if (!bundle.ledger.present) { degraded++; write(`dz workflow-trace export: ledger absent — ${String(bundle.ledger.reason)}`); }
1277
+ if (!bundle.runMeta.resolved) {
1278
+ degraded++;
1279
+ const reason = bundle.runMeta.reason;
1280
+ write(reason === 'layout-unrecognised'
1281
+ ? 'dz workflow-trace export: runMeta UNRESOLVED (layout-unrecognised) — the harness workflow-record layout CHANGED; update the reader in harness-core/src/trace-bundle.ts. This is the one reason that needs action'
1282
+ : `dz workflow-trace export: runMeta unresolved (${reason}) — no action needed, this is history or absence, not a layout change`);
1283
+ }
1284
+ write(`dz workflow-trace export: wrote ${out} (${bundle.schema}; ledger rows scanned ${bundle.ledger.scanned}, matched ${bundle.ledger.matched}, malformed ${bundle.ledger.malformed})`);
1285
+ if (degraded > 0 && flags.has('strict')) {
1286
+ write(`dz workflow-trace export: --strict — ${degraded} member(s) degraded, failing closed`);
1287
+ return 1;
1288
+ }
1289
+ return 0;
1290
+ }
1291
+
1292
+ /** `dz workflow-trace import` — reconstruct a run under an explicit root. FAIL-CLOSED. */
1293
+ function cmdWorkflowTraceImport(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
1294
+ const file = options.get('_positional_1');
1295
+ const into = options.get('into');
1296
+ if (file === undefined || file === '' || into === undefined || into === '') {
1297
+ write('dz workflow-trace import: usage — dz workflow-trace import <bundle.json> --into <root> [--force] [--with-pairs]');
1298
+ return 1;
1299
+ }
1300
+ const abs = resolve(cwd, file);
1301
+ if (!existsSync(abs)) { write(`dz workflow-trace import: no such bundle: ${file}`); return 1; }
1302
+
1303
+ const parsed = parseBundle(readFileSync(abs, 'utf-8'));
1304
+ if (!parsed.ok) {
1305
+ write(parsed.reason === 'unknown-schema'
1306
+ ? `dz workflow-trace import: REFUSED — unknown bundle schema "${parsed.found}"; this dz reads ${TRACE_BUNDLE_SCHEMA}. A future bundle is never best-effort parsed`
1307
+ : parsed.reason === 'member-shape'
1308
+ ? `dz workflow-trace import: REFUSED — member "${parsed.member}" fails its shape check; a partial import is never left behind`
1309
+ : 'dz workflow-trace import: REFUSED — the bundle is not parseable JSON');
1310
+ return 1;
1311
+ }
1312
+ const bundle = parsed.bundle;
1313
+
1314
+ const root = resolve(cwd, into);
1315
+ const runDirRel = bundle.provenance.slug !== null ? join('features', bundle.provenance.slug) : join('.dz', 'loop-trace', String(bundle.provenance.runId ?? 'unknown-run'));
1316
+ const runDirAbs = join(root, runDirRel);
1317
+ let runDirHasContent = false;
1318
+ try { runDirHasContent = existsSync(runDirAbs) && readdirSync(runDirAbs).length > 0; } catch { runDirHasContent = false; }
1319
+
1320
+ let runIdentity: { slug: string | null; runId: string | null } | null = null;
1321
+ const metaAbs = join(runDirAbs, TRACE_BUNDLE_RUN_META_FILE);
1322
+ if (existsSync(metaAbs)) {
1323
+ try {
1324
+ const meta = JSON.parse(readFileSync(metaAbs, 'utf-8')) as { slug?: unknown; runId?: unknown };
1325
+ runIdentity = {
1326
+ slug: typeof meta.slug === 'string' ? meta.slug : null,
1327
+ runId: typeof meta.runId === 'string' ? meta.runId : null,
1328
+ };
1329
+ } catch { runIdentity = null; }
1330
+ }
1331
+
1332
+ const plan = planImport(bundle, {
1333
+ runDir: runDirRel,
1334
+ existingPaths: [],
1335
+ runDirHasContent,
1336
+ runIdentity,
1337
+ force: flags.has('force'),
1338
+ withPairs: flags.has('with-pairs'),
1339
+ bundleName: basename(abs),
1340
+ });
1341
+
1342
+ if (!plan.ok) {
1343
+ for (const refusal of plan.refusals) write(`dz workflow-trace import: REFUSED ${refusal.path} — ${refusal.reason}`);
1344
+ write('dz workflow-trace import: nothing was written');
1345
+ return 1;
1346
+ }
1347
+
1348
+ let written = 0;
1349
+ const failures: string[] = [];
1350
+ for (const entry of plan.writes) {
1351
+ const target = join(root, entry.path);
1352
+ try {
1353
+ mkdirSync(dirname(target), { recursive: true });
1354
+ writeFileSync(target, entry.content);
1355
+ written++;
1356
+ } catch (err) {
1357
+ failures.push(`${entry.path} — ${err instanceof Error ? err.message : String(err)}`);
1358
+ }
1359
+ }
1360
+ // Report what ACTUALLY landed, computed from the writes, not from the plan.
1361
+ for (const skipped of plan.refusals) write(`dz workflow-trace import: skipped ${skipped.path} — ${skipped.reason}`);
1362
+ for (const failure of failures) write(`dz workflow-trace import: FAILED ${failure}`);
1363
+ write(`dz workflow-trace import: wrote ${written} of ${plan.writes.length} member(s) under ${into}`);
1364
+ return failures.length > 0 ? 1 : 0;
1365
+ }
1366
+
1146
1367
  function cmdWorkflowTrace(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
1368
+ // Two subcommands ride the SAME run addressing as the timeline reader (AM-8 keeps the surface
1369
+ // capped; these are bundle transport, not new query verbs).
1370
+ const sub = options.get('_positional_0');
1371
+ if (sub === 'export') return cmdWorkflowTraceExport(options, flags, cwd, write);
1372
+ if (sub === 'import') return cmdWorkflowTraceImport(options, flags, cwd, write);
1147
1373
  const runDirArg = options.get('_positional_0');
1148
1374
  const slug = options.get('slug');
1149
1375
  const runId = options.get('run');
@@ -2054,7 +2280,16 @@ function cmdUsage(
2054
2280
  const s = u.sessionPct === null ? 'n/a' : '~' + u.sessionPct + '%';
2055
2281
  const binding = hasModelLimits && u.weeklyBindingModel !== undefined ? ' ' + u.weeklyBindingModel + '-bound' : '';
2056
2282
  const w = u.weeklyPct === null ? 'n/a' : '~' + u.weeklyPct + '%' + binding;
2057
- write('usage: session ' + s + ' (resets ' + clock(u.sessionResetsAt) + ') · week ' + w + ' (resets ' + clock(u.weeklyResetsAt) + ') · estimated');
2283
+ // The weekly reset is WEEKLY: print the anchor verbatim (weekday + offset), not a bare clock
2284
+ // time — 'resets 08:59' reads as daily and hides the weekday (idea c8513be9: the bare form
2285
+ // misread a Monday reading as '41 minutes after the boundary' when the boundary was Wednesday's).
2286
+ const weeklyAnchorLabel = typeof lim.weeklyResetAnchor === 'string' && lim.weeklyResetAnchor !== ''
2287
+ ? lim.weeklyResetAnchor
2288
+ : clock(u.weeklyResetsAt);
2289
+ write('usage: session ' + s + ' (resets ' + clock(u.sessionResetsAt) + ') · week ' + w + ' (resets ' + weeklyAnchorLabel + ') · estimated');
2290
+ if (typeof lim.weeklyResetAnchor === 'string' && parseWeeklyResetAnchor(lim.weeklyResetAnchor)?.offsetMinutes === undefined) {
2291
+ write(' ⚠ weeklyResetAnchor has NO utc offset — the boundary follows the SERVER timezone, not your account\'s true reset instant (measured: the same moment lands a week apart under UTC vs +03:00). Pin it: "' + lim.weeklyResetAnchor + ' +03:00" (your offset) in .dz/config.json');
2292
+ }
2058
2293
  // re-QE debt surfacing (backlog 6b40e667): the moment someone checks usage is the moment a
2059
2294
  // usage-switched self-review debt should be visible. Best-effort — never breaks the contract.
2060
2295
  try {
@@ -8577,6 +8812,45 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
8577
8812
  return report.ok ? 0 : 1;
8578
8813
  }
8579
8814
 
8815
+ // ── edit — replace/extend ONE idea's text (idea 1fde7bf6). The verb exists because a hand-edit
8816
+ // does not re-embed: the dedup vector keeps describing the OLD text. editIdea owns the text change
8817
+ // and MARKS the record embedStale; this layer owns the async re-embed and clears the mark ONLY
8818
+ // after the vector tier confirms. A failed re-embed is loud here AND enforced at the harm point:
8819
+ // classifyDedup refuses vector candidacy for a marked record (ADR-001).
8820
+ if (sub === 'edit') {
8821
+ const id = options.get('_positional_1');
8822
+ if (id === undefined) return emitErr('an idea id is required: dz backlog edit <id> --text "<new>" | --append "<more>" [--dry-run]');
8823
+ const report = editIdea(projectRoot, id, {
8824
+ ...(options.get('text') !== undefined ? { text: options.get('text')! } : {}),
8825
+ ...(options.get('append') !== undefined ? { append: options.get('append')! } : {}),
8826
+ dryRun: flags.has('dry-run'),
8827
+ });
8828
+ let embed: 'ok' | 'stale' | 'skipped' = 'skipped';
8829
+ if (report.ok && report.written && report.id !== undefined) {
8830
+ const updated = readIdeas(projectRoot).find((i) => i.id === report.id);
8831
+ if (updated !== undefined) {
8832
+ const mirror = await mirrorIdeaVector(projectRoot, updated);
8833
+ if (mirror.mirrored > 0 && mirror.error === undefined && clearEmbedStale(projectRoot, report.id)) embed = 'ok';
8834
+ else embed = 'stale';
8835
+ } else embed = 'stale';
8836
+ }
8837
+ if (json) {
8838
+ write(JSON.stringify({ verb: 'edit', ...report, embed, exitCode: report.ok ? (embed === 'stale' ? 1 : 0) : 1 }, null, 2));
8839
+ return report.ok ? (embed === 'stale' ? 1 : 0) : 1;
8840
+ }
8841
+ for (const e of report.errors) write(`dz backlog edit: ${e}`);
8842
+ if (report.ok && report.id !== undefined) {
8843
+ if (!report.written && !report.dryRun) write(`dz backlog edit: ${report.id} — text unchanged, nothing to do`);
8844
+ else write(`dz backlog edit${report.dryRun ? ' (dry-run)' : ''}: ${report.id}\n was: ${report.previousText?.slice(0, 100)}\n now: ${report.newText?.slice(0, 100)}`);
8845
+ if (report.written) {
8846
+ if (embed === 'ok') write(' vector re-embedded (dedup form) — the record is fully consistent');
8847
+ else write(' ⚠ vector NOT re-embedded — the record is MARKED embedStale: dedup will refuse to trust its similarity until `dz vector reindex` repairs it (the text edit itself landed)');
8848
+ }
8849
+ if (report.dryRun) write(' (dry-run — nothing written; re-run without --dry-run to apply)');
8850
+ }
8851
+ return report.ok ? (embed === 'stale' ? 1 : 0) : 1;
8852
+ }
8853
+
8580
8854
  if (sub === 'enrich') {
8581
8855
  const id = options.get('_positional_1');
8582
8856
  if (id === undefined) return emitErr('an idea id is required: dz backlog enrich <id>');
@@ -8667,6 +8941,55 @@ function cmdRouting(options: Map<string, string>, flags: Set<string>, cwd: strin
8667
8941
  let repoRoot = cwd;
8668
8942
  try { repoRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf-8' }).trim() || cwd; } catch { /* not git */ }
8669
8943
 
8944
+ // ── recommend — harvest real telemetry, print per-stage picks WITH THE BASIS, optionally feed the
8945
+ // store (a9c3dd5c fn 3, ADR-001). Sources: live harness records + imported run-meta.json sidecars.
8946
+ if (options.get('_positional_0') === 'recommend') {
8947
+ const records: unknown[] = readHarnessRecords(repoRoot);
8948
+ // imported runs (trace-bundle ADR-001 D4): run-meta sidecars carry runMeta.records of the SAME shape
8949
+ for (const base of [join(repoRoot, 'features'), join(repoRoot, '.dz', 'loop-trace')]) {
8950
+ if (!existsSync(base)) continue;
8951
+ let names: string[] = [];
8952
+ try { names = readdirSync(base); } catch { continue; }
8953
+ for (const name of names) {
8954
+ const sidecar = join(base, name, 'run-meta.json');
8955
+ if (!existsSync(sidecar)) continue;
8956
+ try {
8957
+ const meta = JSON.parse(readFileSync(sidecar, 'utf-8')) as { runMeta?: { resolved?: boolean; records?: unknown[] } };
8958
+ if (meta.runMeta?.resolved === true && Array.isArray(meta.runMeta.records)) records.push(...meta.runMeta.records);
8959
+ } catch { /* an unreadable sidecar contributes nothing — counted below as noResult */ }
8960
+ }
8961
+ }
8962
+ const harvest = harvestStageOutcomes(records);
8963
+ const rec = recommendModels(harvest, { ...(options.get('tier') !== undefined ? { tier: options.get('tier')! } : {}) });
8964
+ if (flags.has('apply')) {
8965
+ const fedPath = join(repoRoot, '.dz', 'routing-fed.json');
8966
+ let alreadyFed: string[] = [];
8967
+ try { alreadyFed = JSON.parse(readFileSync(fedPath, 'utf-8')) as string[]; } catch { /* first apply */ }
8968
+ const plan = planFeed(harvest.samples, alreadyFed);
8969
+ for (const sample of plan.toFeed) finalizeOutcome(repoRoot, sample.stage, sample.tier, sample.model, sample.success);
8970
+ try {
8971
+ mkdirSync(join(repoRoot, '.dz'), { recursive: true });
8972
+ writeFileSync(fedPath, JSON.stringify(plan.fedAfter));
8973
+ } catch (e) {
8974
+ write(`dz routing recommend: fed ${plan.toFeed.length} sample(s) but could NOT persist the fed-set — a re-apply WILL double-count: ${(e as Error).message}`);
8975
+ return 1;
8976
+ }
8977
+ write(`dz routing recommend --apply: fed ${plan.toFeed.length} sample(s) into .dz/routing-outcomes.json${plan.skippedRuns.length > 0 ? `; skipped ${plan.skippedRuns.length} already-fed run(s) (idempotent by runId)` : ''}`);
8978
+ }
8979
+ if (flags.has('json')) {
8980
+ write(JSON.stringify({ recommendation: rec, exitCode: 0 }, null, 2));
8981
+ return 0;
8982
+ }
8983
+ write(`dz routing recommend — args.models suggestion from ${rec.basis.runsUsed} run(s)${rec.basis.window !== null ? ` (${rec.basis.window.min.slice(0, 10)}..${rec.basis.window.max.slice(0, 10)})` : ''}:`);
8984
+ for (const s of rec.perStage) {
8985
+ write(` ${s.stage.padEnd(14)} ${s.spec.padEnd(20)} ${s.insufficientData ? '[insufficient data — cold-start pick]' : '[quality bar met]'} ${s.pick.evidence}`);
8986
+ }
8987
+ write(` basis: ${rec.basis.rule}`);
8988
+ write(` basis: skipped records — no result ${rec.basis.skipped.noResult}, no modelsUsed ${rec.basis.skipped.noModels} (predates model routing — history, not an error), no grade ${rec.basis.skipped.noGrade}, unknown model ${rec.basis.skipped.unknownModel}`);
8989
+ write(` basis: ${rec.basis.crossFamilyNote}`);
8990
+ return 0;
8991
+ }
8992
+
8670
8993
  const stage = options.get('stage');
8671
8994
  const tier = options.get('tier');
8672
8995
  const model = options.get('model');