tonedial 0.1.5 → 0.1.6

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/dist/cli.js CHANGED
@@ -36,13 +36,14 @@ export const JSON_SCHEMA = 3;
36
36
  * run time is the one thing that breaks all three binary packagers at once. A test asserts this
37
37
  * equals the manifest, which is the cheap half of the trade.
38
38
  */
39
- export const VERSION = "0.1.5";
40
- const FLAG_NAMES = ["project", "check", "explain", "global", "here", "migrateLegacy", "auto", "dryRun", "fix", "rollback", "fromOperation", "fromPreset", "restorePreset", "preset", "slug", "acceptUsage", "lang", "commands", "tools"];
39
+ export const VERSION = "0.1.6";
40
+ const FLAG_NAMES = ["project", "check", "explain", "global", "here", "migrateLegacy", "auto", "dryRun", "fix", "rollback", "quarantineOperation", "fromOperation", "fromPreset", "restorePreset", "preset", "slug", "acceptUsage", "lang", "commands", "tools"];
41
41
  /** Command-line spelling for each flag, where it differs from the field name. */
42
42
  const FLAG_SPELLING = {
43
43
  project: "project", check: "check", explain: "explain",
44
44
  global: "global", auto: "auto", dryRun: "dry-run", fix: "fix", lang: "lang", commands: "commands",
45
45
  tools: "tools", rollback: "rollback", here: "here", migrateLegacy: "migrate-legacy",
46
+ quarantineOperation: "quarantine-operation",
46
47
  fromOperation: "from-operation", fromPreset: "from-preset", restorePreset: "restore-preset",
47
48
  preset: "preset", slug: "slug", acceptUsage: "accept-usage",
48
49
  };
@@ -68,7 +69,7 @@ const COMMANDS = {
68
69
  unwire: { run: runUnwire, flags: ["global", "here", "project", "auto", "dryRun"], max: 1 },
69
70
  init: { run: runInit, flags: ["global", "here", "project", "migrateLegacy", "auto", "dryRun", "lang", "commands", "tools"], max: 0, context: "setup" },
70
71
  // Doctor checks mutation permission after its recovery-first diagnosis, even with bad config.
71
- doctor: { run: runDoctor, flags: ["global", "here", "project", "auto", "dryRun", "fix", "rollback", "fromOperation", "fromPreset", "restorePreset", "tools"], max: 0 },
72
+ doctor: { run: runDoctor, flags: ["global", "here", "project", "auto", "dryRun", "fix", "rollback", "quarantineOperation", "fromOperation", "fromPreset", "restorePreset", "tools"], max: 0 },
72
73
  eject: { run: runEject, flags: ["global", "here", "project", "auto", "dryRun"], max: 0 },
73
74
  };
74
75
  const CONNECTION_COMMANDS = ["init", "wire", "doctor", "unwire", "eject"];
@@ -130,7 +131,8 @@ const COMMAND_EXAMPLES = {
130
131
  wire: ["tonedial wire codex --here --dry-run"],
131
132
  unwire: ["tonedial unwire codex --here --dry-run"],
132
133
  init: ["tonedial init --tools none --global --auto", "tonedial init --tools codex --here"],
133
- doctor: ["tonedial doctor", "tonedial doctor --fix --global --tools none --dry-run"],
134
+ doctor: ["tonedial doctor", "tonedial doctor --fix --global --tools none --dry-run",
135
+ "tonedial doctor --fix --quarantine-operation ID --global"],
134
136
  eject: ["tonedial eject --here --dry-run"],
135
137
  };
136
138
  const FLAG_SYNTAX = [
@@ -145,6 +147,7 @@ const FLAG_SYNTAX = [
145
147
  ["tools", "--tools=LIST"],
146
148
  ["fix", "--fix"],
147
149
  ["rollback", "--rollback=ID"],
150
+ ["quarantineOperation", "--quarantine-operation=ID"],
148
151
  ["fromOperation", "--from-operation=ID"],
149
152
  ["fromPreset", "--from-preset=SLUG"],
150
153
  ["restorePreset", "--restore-preset=SLUG"],
@@ -298,6 +301,7 @@ export async function run(argv, env = process.env, cwd = process.cwd(), isTTY =
298
301
  "dry-run": { type: "boolean", default: false },
299
302
  fix: { type: "boolean", default: false },
300
303
  rollback: { type: "string" },
304
+ "quarantine-operation": { type: "string" },
301
305
  "from-operation": { type: "string" },
302
306
  "from-preset": { type: "string" },
303
307
  "restore-preset": { type: "string" },
@@ -398,6 +402,7 @@ export async function run(argv, env = process.env, cwd = process.cwd(), isTTY =
398
402
  dryRun: parsed.values["dry-run"] === true,
399
403
  fix: parsed.values.fix === true,
400
404
  rollback: typeof parsed.values.rollback === "string" ? parsed.values.rollback : null,
405
+ quarantineOperation: typeof parsed.values["quarantine-operation"] === "string" ? parsed.values["quarantine-operation"] : null,
401
406
  fromOperation: typeof parsed.values["from-operation"] === "string" ? parsed.values["from-operation"] : null,
402
407
  fromPreset: typeof parsed.values["from-preset"] === "string" ? parsed.values["from-preset"] : null,
403
408
  restorePreset: typeof parsed.values["restore-preset"] === "string" ? parsed.values["restore-preset"] : null,
@@ -424,9 +429,9 @@ export async function run(argv, env = process.env, cwd = process.cwd(), isTTY =
424
429
  if (command.max !== null && rest.length > command.max) {
425
430
  return fail(asJson, say.errors.tooManyArguments(name, command.max, rest.length), 2, uiErr);
426
431
  }
427
- if (name === "doctor" && ((!flags.fix && (flags.fromOperation !== null || flags.fromPreset !== null || flags.restorePreset !== null || flags.tools !== null)) ||
432
+ if (name === "doctor" && ((!flags.fix && (flags.quarantineOperation !== null || flags.fromOperation !== null || flags.fromPreset !== null || flags.restorePreset !== null || flags.tools !== null)) ||
428
433
  (flags.restorePreset !== null && flags.fromOperation === null) ||
429
- (flags.rollback !== null && (flags.fromOperation !== null || flags.fromPreset !== null || flags.restorePreset !== null || flags.tools !== null)))) {
434
+ (flags.rollback !== null && (flags.quarantineOperation !== null || flags.fromOperation !== null || flags.fromPreset !== null || flags.restorePreset !== null || flags.tools !== null)))) {
430
435
  return fail(asJson, say.recovery.invalidOptions, 2, uiErr);
431
436
  }
432
437
  try {
@@ -472,6 +472,20 @@ export async function runDoctor(ctx, _args) {
472
472
  if (!ctx.flags.fix)
473
473
  return doctorReport(ctx);
474
474
  const app = await runAppRebuild(ctx);
475
+ if (app.quarantineOnly) {
476
+ const rollbackLines = app.json.applied === true ? recoveryRollbackLines(ctx.t, app.json.operationId, null) : [];
477
+ const remaining = Array.isArray(app.json.remainingInvalidOperations)
478
+ ? app.json.remainingInvalidOperations.filter((id) => typeof id === "string") : [];
479
+ const next = typeof app.json.next === "string" ? app.json.next : null;
480
+ return {
481
+ ...app,
482
+ lines: [...app.lines, ...rollbackLines, ...(next === null ? [] : [
483
+ ` ${ctx.t.recovery.quarantineRemaining(remaining.length)}`, "", ` ${next}`,
484
+ ])],
485
+ json: { ...app.json, root: ctx.agent.root, stateRoot: ctx.paths.root, agentRoot: ctx.agent.root,
486
+ live: ctx.agent.live, fixed: app.json.applied === true, appHealthy: false },
487
+ };
488
+ }
475
489
  if (ctx.flags.dryRun) {
476
490
  const native = await runRebuildNative(ctx, app.legacy, app.proposedConfig);
477
491
  return {
@@ -517,10 +531,14 @@ async function doctorReport(ctx, showRollbackCommands = true, showCodexAction =
517
531
  if (inspected.lock.state !== "none" || inspected.journals.some(({ state }) => state === "pending" || state === "invalid")) {
518
532
  const owner = inspected.lock.owner;
519
533
  const blocking = inspected.journals.filter(({ state }) => state === "pending" || state === "invalid");
534
+ const invalid = blocking.filter(({ state }) => state === "invalid");
535
+ const quarantineable = inspected.lock.state === "none" && blocking.length === invalid.length && invalid.length > 0;
536
+ const singleQuarantine = quarantineable && invalid.length === 1;
520
537
  const shown = ctx.flags.verbose ? inspected.journals : inspected.journals.filter(({ operationId, state }) => state === "pending" || state === "invalid" || operationId === owner?.operationId);
521
538
  const lines = [
522
- ...wrap(ctx.t.doctor.recoveryPending, ctx.ui.width - 2).map(line => ` ${ctx.ui.warn(line)}`),
523
- ...shown.map(({ operationId, state }) => ` ${operationId} ${ctx.t.doctor.operationState[state]}`),
539
+ ...wrap(quarantineable ? ctx.t.doctor.invalidRecoveryPending : ctx.t.doctor.recoveryPending, ctx.ui.width - 2).map(line => ` ${ctx.ui.warn(line)}`),
540
+ ...shown.filter(({ operationId }) => !singleQuarantine || operationId !== invalid[0].operationId)
541
+ .map(({ operationId, state }) => ` ${operationId} ${ctx.t.doctor.operationState[state]}`),
524
542
  ];
525
543
  if (owner !== null && !shown.some(({ operationId }) => operationId === owner.operationId))
526
544
  lines.push(` ${owner.operationId}`);
@@ -540,7 +558,14 @@ async function doctorReport(ctx, showRollbackCommands = true, showCodexAction =
540
558
  }
541
559
  }
542
560
  }
543
- lines.push(...nextStep(`${repairCommand(ctx, "doctor")} --json`, ctx.ui));
561
+ if (quarantineable) {
562
+ lines.push("", ...(singleQuarantine
563
+ ? wrap(ctx.t.recovery.quarantineCandidate(invalid[0].operationId), ctx.ui.width - 2)
564
+ : wrap(ctx.t.recovery.quarantineRemaining(invalid.length), ctx.ui.width - 2)).map(line => ` ${line}`), ...nextStep(repairCommand(ctx, `doctor --fix --quarantine-operation ${invalid[0].operationId}`), ctx.ui).slice(1));
565
+ }
566
+ else {
567
+ lines.push(...nextStep(`${repairCommand(ctx, "doctor")} --json`, ctx.ui));
568
+ }
544
569
  return {
545
570
  lines,
546
571
  // The damaged config is deliberately not parsed here. `operation` is the only safe context
@@ -297,6 +297,9 @@ async function rebuildExisting(entry) {
297
297
  const resultHeading = ctx.canPrompt && !ctx.flags.auto && !ctx.flags.dryRun
298
298
  ? section(copy.init.resultHeading, ctx.ui) : [];
299
299
  const app = await runAppRebuild(ctx);
300
+ if (app.quarantineOnly) {
301
+ return { ...app, lines: [...(ctx.canPrompt ? [] : introduction), ...resultHeading, ...app.lines] };
302
+ }
300
303
  if (ctx.flags.dryRun) {
301
304
  const answers = await ask(ctx, copy, false, selection);
302
305
  const tools = selection ?? answers.tools;
@@ -11,7 +11,7 @@ import { recoverLegacyTone } from "../core/legacy-tone.js";
11
11
  import { connectionScopeFlag, exportAliasesState, stateOverlapsInstructions } from "../core/paths.js";
12
12
  import { applyPreset, matchesPreset, assertPresetSelection, BUNDLED, customPresetDir, inspectPresetLibrary, parsePreset, PRESET_MAX_BYTES, resolvePreset, serializePreset, SLUG_PATTERN, } from "../core/presets.js";
13
13
  import { contextArtifactTargets, generatedSeedSelection, planGeneratedSeedRebuild, seedFiles, SEED_VERSION, } from "../core/seeds.js";
14
- import { inspectOperations, readJournal, retireOperationForAppRebuild, } from "../core/operations.js";
14
+ import { inspectOperations, readJournal, retireOperationForAppRebuild, serializeOperationQuarantine, } from "../core/operations.js";
15
15
  import { assertFresh, captureRawEntry, gate, planEntryRebuild, rawReadCheck, validateFileAction, } from "../wiring/apply.js";
16
16
  import { confirm } from "../ui/prompt.js";
17
17
  import { wrap } from "../ui/width.js";
@@ -218,13 +218,83 @@ function privateCapture(capture) {
218
218
  boundary: capture.boundary, boundaryIdentity: capture.boundaryIdentity,
219
219
  parents: capture.parents.map(parent => ({ ...parent })) };
220
220
  }
221
+ function planOperationQuarantine(ctx, entry) {
222
+ const capture = captureRawEntry(entry.path, ctx.t, { within: ctx.paths.root });
223
+ if (capture.type !== "regular" || capture.bytes === null)
224
+ throw new ConfigError(ctx.t.recovery.quarantineUnavailable(entry.operationId));
225
+ return planEntryRebuild(capture, serializeOperationQuarantine(ctx.paths, entry.operationId, capture.bytes), ctx.t, { reason: "quarantine-operation", mode: capture.mode ?? 0o600 }).action;
226
+ }
227
+ async function quarantineOneOperation(ctx, entry, remaining) {
228
+ const action = planOperationQuarantine(ctx, entry);
229
+ const presentation = [ctx.t.recovery.quarantine(entry.operationId)]
230
+ .flatMap(line => wrap(line, ctx.ui.width - 2).map(part => ` ${part}`));
231
+ const result = await gate([], {
232
+ paths: ctx.paths, operationActions: [action], retention: { kind: "migration", reason: "app-rebuild" },
233
+ auto: ctx.flags.auto, dryRun: ctx.flags.dryRun, verbose: ctx.flags.verbose, ui: ctx.ui, t: ctx.t,
234
+ heading: ctx.t.recovery.heading, interactive: () => ctx.canPrompt, ...(ctx.canPrompt ? {} : { out: () => { } }),
235
+ recoveryPresentation: { lines: presentation,
236
+ failureMessage: (id, phase, path, code) => `${ctx.t.recovery.failure(id, phase, path, code)}\ntonedial doctor ${connectionScopeFlag(ctx.agent)} --json` },
237
+ verify: () => {
238
+ const state = inspectOperations(ctx.paths).journals.find(({ operationId }) => operationId === entry.operationId)?.state;
239
+ if (state !== "quarantined")
240
+ throw new ConfigError(ctx.t.recovery.sourceInvalid);
241
+ },
242
+ });
243
+ const lines = [...result.lines];
244
+ if (result.reason === "written") {
245
+ lines.push(` ${ctx.t.recovery.quarantineComplete(entry.operationId)}`);
246
+ }
247
+ else {
248
+ lines.push(` ${result.reason === "dry-run" ? ctx.t.wiring.dryRun : ctx.t.wiring.declined}`);
249
+ }
250
+ return {
251
+ lines,
252
+ json: {
253
+ appRebuilt: false, applied: result.applied, reason: result.reason, operationId: result.operationId ?? null,
254
+ root: ctx.paths.root, quarantinedOperation: entry.operationId, remainingInvalidOperations: [...remaining],
255
+ next: result.applied && remaining.length > 0
256
+ ? `tonedial doctor --fix --quarantine-operation ${remaining[0]} ${connectionScopeFlag(ctx.agent)}` : null,
257
+ nativeAttention: false, activationVerified: false, diff: result.diff,
258
+ },
259
+ rebuiltConfig: null,
260
+ proposedConfig: null,
261
+ legacy: null,
262
+ quarantineOnly: true,
263
+ };
264
+ }
221
265
  export async function runAppRebuild(ctx) {
222
266
  const t = ctx.t;
223
267
  const operations = inspectOperations(ctx.paths);
224
- const pending = operations.journals.filter(entry => entry.state === "pending" || entry.state === "invalid");
225
- const blocked = operations.lock.state !== "none" || pending.length > 0;
226
- if (blocked && (operations.lock.state === "live" || operations.lock.state === "unknown" ||
227
- pending.some(entry => entry.journal === null) || (!ctx.flags.fromOperation && (ctx.flags.auto || !ctx.canPrompt)))) {
268
+ const pending = operations.journals.filter(entry => entry.state === "pending");
269
+ const invalid = operations.journals.filter(entry => entry.state === "invalid");
270
+ let quarantined = invalid.find(entry => entry.operationId === ctx.flags.quarantineOperation) ?? null;
271
+ if (ctx.flags.quarantineOperation && quarantined === null)
272
+ throw new ConfigError(t.recovery.quarantineUnavailable(ctx.flags.quarantineOperation));
273
+ if (invalid.length > 0 && quarantined === null && operations.lock.state === "none" && pending.length === 0 && invalid.length === 1 &&
274
+ !ctx.flags.auto && ctx.canPrompt) {
275
+ const candidate = invalid[0];
276
+ const input = createInterface({ input: process.stdin, output: process.stdout });
277
+ try {
278
+ const notice = t.recovery.quarantineCandidate(candidate.operationId).split("\n").flatMap(line => line === candidate.operationId ? [line] : wrap(line, ctx.ui.width - 2));
279
+ process.stdout.write(`${notice.map(line => ` ${line}`).join("\n")}\n`);
280
+ const question = wrap(t.recovery.quarantineQuestion, ctx.ui.width - 3).map(line => ` ${line}`).join("\n");
281
+ const answer = (await input.question(`${question} `)).trim();
282
+ if (answer === "")
283
+ throw new ConfigError(t.recovery.cancelled);
284
+ if (answer !== candidate.operationId)
285
+ throw new ConfigError(t.recovery.operationMismatch);
286
+ quarantined = candidate;
287
+ }
288
+ finally {
289
+ input.close();
290
+ }
291
+ }
292
+ if (invalid.length > 0 && quarantined === null) {
293
+ const candidate = invalid[0];
294
+ const command = `tonedial doctor --fix --quarantine-operation ${candidate.operationId} ${connectionScopeFlag(ctx.agent)}`;
295
+ throw new ConfigError(t.recovery.quarantineRequired(candidate.operationId, command));
296
+ }
297
+ if (quarantined !== null && (operations.lock.state !== "none" || pending.length > 0)) {
228
298
  throw new ConfigError(t.doctor.recoveryPending);
229
299
  }
230
300
  const scopeExplicit = ctx.scopeExplicit ?? (ctx.flags.global || ctx.flags.here === true || ctx.flags.project !== null);
@@ -232,6 +302,14 @@ export async function runAppRebuild(ctx) {
232
302
  throw new ConfigError(t.errors.scopeRequired);
233
303
  if (ctx.flags.tools !== null)
234
304
  parseToolSelection(ctx.flags.tools, true, t);
305
+ if (quarantined !== null && invalid.length > 1) {
306
+ return quarantineOneOperation(ctx, quarantined, invalid.filter(entry => entry !== quarantined).map(entry => entry.operationId));
307
+ }
308
+ const blocked = operations.lock.state !== "none" || pending.length > 0;
309
+ if (blocked && (operations.lock.state === "live" || operations.lock.state === "unknown" ||
310
+ pending.some(entry => entry.journal === null) || (!ctx.flags.fromOperation && (ctx.flags.auto || !ctx.canPrompt)))) {
311
+ throw new ConfigError(t.doctor.recoveryPending);
312
+ }
235
313
  const configCapture = captureRawEntry(ctx.paths.config, t, { within: ctx.paths.root, maxBytes: CONFIG_MAX_BYTES });
236
314
  const selected = ctx.flags.fromOperation ? selectedJournal(ctx, ctx.flags.fromOperation) : null;
237
315
  let retirementId = null;
@@ -405,7 +483,11 @@ export async function runAppRebuild(ctx) {
405
483
  config.managedSeeds = seeds.records;
406
484
  config.seedVersion = SEED_VERSION;
407
485
  const artifact = compile(toCompileInput(config));
408
- const operationActions = [...seeds.operationActions];
486
+ let quarantinePlan = null;
487
+ if (quarantined !== null) {
488
+ quarantinePlan = planOperationQuarantine(ctx, quarantined);
489
+ }
490
+ const operationActions = [...(quarantinePlan ? [quarantinePlan] : []), ...seeds.operationActions];
409
491
  for (const target of artifacts) {
410
492
  const capture = target.capture ?? captureRawEntry(target.path, t, { within: ctx.paths.root, allowLink: true });
411
493
  const plan = planEntryRebuild(capture, artifact, t, { reason: "generated" });
@@ -457,6 +539,7 @@ export async function runAppRebuild(ctx) {
457
539
  ...(missingFields.length ? [` ${t.recovery.missing(missingFields)}`] : []),
458
540
  ...(copies.length ? [` ${t.recovery.copies(copies)}`] : []),
459
541
  ...(nativeAttention ? [` ${t.recovery.nativePending}`] : []),
542
+ ...(quarantined ? [` ${t.recovery.quarantine(quarantined.operationId)}`] : []),
460
543
  ...(retirementId ? [` ${t.recovery.retire(retirementId)}`] : []),
461
544
  ].flatMap(line => wrap(line.trimStart(), ctx.ui.width - 2).map(part => ` ${part}`));
462
545
  const options = {
@@ -484,6 +567,12 @@ export async function runAppRebuild(ctx) {
484
567
  if (capturedText(captureRawEntry(target.path, t, { within: ctx.paths.root })) !== artifact)
485
568
  throw mismatch();
486
569
  }
570
+ if (quarantined !== null) {
571
+ verificationPath = quarantined.path;
572
+ const entry = inspectOperations(ctx.paths).journals.find(({ operationId }) => operationId === quarantined.operationId);
573
+ if (entry?.state !== "quarantined")
574
+ throw mismatch();
575
+ }
487
576
  verificationPath = ctx.paths.tone;
488
577
  if (capturedText(captureRawEntry(ctx.paths.tone, t, { within: ctx.paths.root })) !== artifact)
489
578
  throw mismatch();
@@ -521,7 +610,7 @@ export async function runAppRebuild(ctx) {
521
610
  if (!ctx.flags.auto) {
522
611
  process.stdout.write(`${(preview.reason === "nothing" ? [...preview.lines, ...presentation] : preview.lines).join("\n")}\n`);
523
612
  if (await confirm(` ${t.wiring.applyQuestion(preview.diff.length)}`) === "no") {
524
- return { lines: [` ${t.wiring.declined}`], json: { appRebuilt: false, applied: false, reason: "declined", diff: preview.diff }, rebuiltConfig: null, proposedConfig: config, legacy };
613
+ return { lines: [` ${t.wiring.declined}`], json: { appRebuilt: false, applied: false, reason: "declined", diff: preview.diff }, rebuiltConfig: null, proposedConfig: config, legacy, quarantineOnly: false };
525
614
  }
526
615
  }
527
616
  const snapshots = [configCapture, ...seeds.reads, ...artifacts.flatMap(target => target.capture === null ? [] : [target.capture]),
@@ -551,12 +640,13 @@ export async function runAppRebuild(ctx) {
551
640
  lines.push(...presentation);
552
641
  lines.push(` ${applied ? result.reason === "nothing" ? t.recovery.unchanged : t.recovery.rebuilt : result.reason === "dry-run" ? t.wiring.dryRun : t.wiring.declined}`);
553
642
  return {
554
- lines, rebuiltConfig: applied ? config : null, proposedConfig: config, legacy,
643
+ lines, rebuiltConfig: applied ? config : null, proposedConfig: config, legacy, quarantineOnly: false,
555
644
  json: {
556
645
  appRebuilt: applied, applied: result.applied, reason: result.reason, operationId: result.operationId ?? null,
557
646
  root: ctx.paths.root, source, recovered: [...kept], defaulted: [...defaulted], invalid: damagedFields,
558
647
  seeded: seeds.labels, chars: [...artifact].length, riskAxes: riskyAxes(config.axes),
559
648
  recoveredPresets: copies, restoredPreset: restoredPreset?.slug ?? null, retiredOperation: retirementId,
649
+ quarantinedOperation: quarantined?.operationId ?? null,
560
650
  retiredExternalActions,
561
651
  nativeAttention, activationVerified: false, diff: result.diff,
562
652
  },
package/dist/core/i18n.js CHANGED
@@ -112,7 +112,7 @@ const enMessages = {
112
112
  choosePreset: "Choose a preset for values with no valid saved source:",
113
113
  sourceRequired: "Saved values are incomplete. Run tonedial doctor --fix interactively, or select --from-preset SLUG or --from-operation ID before unattended recovery.",
114
114
  sourceInvalid: "The selected source cannot supply valid settings. The original was kept.",
115
- invalidOptions: "Recovery source and tool options require doctor --fix. --restore-preset requires --from-operation; --rollback cannot be combined with source, preset, or tool choices.",
115
+ invalidOptions: "Recovery source, quarantine and tool options require doctor --fix. --restore-preset requires --from-operation; --rollback cannot be combined with quarantine, source, preset, or tool choices.",
116
116
  keptSettings: "Saved settings and custom presets kept.",
117
117
  obsoleteWidthDiscarded: "Discard invalid retired fixed_width input. Output follows terminal width.",
118
118
  obsoleteGuardsDiscarded: "Discard invalid retired guards input. ToneDial no longer adds guard requests.",
@@ -125,6 +125,13 @@ const enMessages = {
125
125
  rollback: (id) => `Restore the previous files:\ntonedial doctor --fix --rollback ${id}`,
126
126
  rollbackOrder: "Restore connections first, then application files:",
127
127
  noSnapshot: (id) => `${id}: no valid settings snapshot for this state root`,
128
+ quarantineCandidate: (id) => `Operation ID:\n${id}\nQuarantine it before rebuilding; the original bytes remain available through rollback.`,
129
+ quarantineQuestion: "Type the full operation ID above to quarantine it; Enter cancels:",
130
+ quarantineRequired: (id, command) => `${id}: this unreadable operation record needs explicit quarantine before recovery. No files were changed.\n${command}`,
131
+ quarantineUnavailable: (id) => `${id}: no matching unreadable operation record can be quarantined. No files were changed.`,
132
+ quarantine: (id) => `Quarantine unreadable operation ${id}; keep its original bytes in rollback history.`,
133
+ quarantineComplete: (id) => `Unreadable operation ${id} quarantined.`,
134
+ quarantineRemaining: (count) => `${count} unreadable operation ${count === 1 ? "record remains" : "records remain"}. Quarantine the next record before rebuilding.`,
128
135
  retire: (id) => `Retire stopped operation ${id} as history before a new application rebuild. Its external changes are not certified or undone.`,
129
136
  resumeOperation: (id) => `Stopped operation:\n${id}`,
130
137
  resumeQuestion: "Type the full operation ID above to continue; Enter cancels:",
@@ -280,6 +287,7 @@ const enMessages = {
280
287
  seedsNotRun: "no bundled files · init has not run here",
281
288
  seedsAttention: "bundled files need attention",
282
289
  recoveryPending: "An operation is active or needs recovery. No files were changed by this command.",
290
+ invalidRecoveryPending: "Unreadable operation history is blocking recovery. No files were changed by this command.",
283
291
  resumeOperation: "Continue application repair:",
284
292
  contextCurrent: "AGENTS.md and TONE.md layout is current.",
285
293
  contextRepair: "Managed context files need repair. Check the guide and tone-file diagnostics.",
@@ -302,7 +310,7 @@ const enMessages = {
302
310
  changed: "Reader or config changed during inspection. Retry after changes stop.",
303
311
  probe: "The controlled probe failed. Check Node and temporary-directory access, then rerun doctor.",
304
312
  },
305
- operationState: { pending: "pending", complete: "complete", "rolled-back": "restored", "retired-for-app-rebuild": "retired for rebuild", invalid: "invalid record" },
313
+ operationState: { pending: "pending", complete: "complete", "rolled-back": "restored", "retired-for-app-rebuild": "retired for rebuild", quarantined: "quarantined", invalid: "invalid record" },
306
314
  rollbackStatus: {
307
315
  ready: "Approval will restore the selected operation.",
308
316
  busy: "Operation still active. Wait for its owner to finish.",
@@ -470,6 +478,7 @@ const enMessages = {
470
478
  tools: "agents to connect · init: none = state only",
471
479
  fix: "doctor: repair fixable findings",
472
480
  rollback: "doctor --fix: restore one operation",
481
+ quarantineOperation: "doctor --fix: quarantine one unreadable operation record",
473
482
  fromOperation: "doctor --fix: select saved settings from an operation",
474
483
  fromPreset: "doctor --fix: preset for unavailable settings",
475
484
  restorePreset: "doctor --fix: restore one missing or damaged user preset",
@@ -766,7 +775,7 @@ const koMessages = {
766
775
  choosePreset: "저장된 값이 없는 항목에 사용할 프리셋을 선택하세요:",
767
776
  sourceRequired: "저장된 값이 부족합니다. 터미널에서 tonedial doctor --fix를 실행하거나 자동 복구 전에 --from-preset SLUG 또는 --from-operation ID를 선택하세요.",
768
777
  sourceInvalid: "선택한 원본에서 올바른 설정을 읽지 못했습니다. 기존 파일은 유지했습니다.",
769
- invalidOptions: "복구 원본과 도구 옵션은 doctor --fix에서 쓰세요. --restore-preset에는 --from-operation이 필요하며, --rollback은 원본·프리셋·도구 선택과 함께 쓸 수 없습니다.",
778
+ invalidOptions: "복구 원본·격리·도구 옵션은 doctor --fix에서 쓰세요. --restore-preset에는 --from-operation이 필요하며, --rollback은 격리·원본·프리셋·도구 선택과 함께 쓸 수 없습니다.",
770
779
  keptSettings: "저장된 설정·사용자 프리셋 유지",
771
780
  obsoleteWidthDiscarded: "더 이상 쓰지 않는 fixed_width의 잘못된 값을 버립니다. 출력은 터미널 폭에 맞춥니다.",
772
781
  obsoleteGuardsDiscarded: "더 이상 쓰지 않는 guards의 잘못된 값을 버립니다. ToneDial은 가드 요청을 넣지 않습니다.",
@@ -779,6 +788,13 @@ const koMessages = {
779
788
  rollback: (id) => `이전 파일로 돌아가기:\ntonedial doctor --fix --rollback ${id}`,
780
789
  rollbackOrder: "연결 파일을 먼저 되돌린 뒤 앱 파일을 되돌리세요:",
781
790
  noSnapshot: (id) => `${id}: 이 상태 경로에서 쓸 수 있는 설정 기록이 없습니다`,
791
+ quarantineCandidate: (id) => `작업 ID:\n${id}\n앱을 복구하려면 이 기록을 먼저 격리해야 합니다. 원본 바이트는 롤백 기록에 보존됩니다.`,
792
+ quarantineQuestion: "격리하려면 위 작업 ID 전체를 입력하세요. Enter를 누르면 취소합니다:",
793
+ quarantineRequired: (id, command) => `${id}: 복구 전에 읽을 수 없는 작업 기록을 명시적으로 격리해야 합니다. 파일은 바꾸지 않았습니다.\n${command}`,
794
+ quarantineUnavailable: (id) => `${id}: 격리할 수 있는 읽기 불가 작업 기록을 찾지 못했습니다. 파일은 바꾸지 않았습니다.`,
795
+ quarantine: (id) => `읽을 수 없는 작업 ${id}를 격리합니다. 원본 바이트는 롤백 기록에 보존합니다.`,
796
+ quarantineComplete: (id) => `읽을 수 없는 작업 ${id}를 격리했습니다.`,
797
+ quarantineRemaining: (count) => `읽을 수 없는 작업 기록 ${count}개가 남았습니다. 다음 기록을 격리한 뒤 복구를 계속하세요.`,
782
798
  retire: (id) => `중단된 작업 ${id}를 기록으로 보관한 뒤 새 앱 복구를 시작합니다. 그 작업의 외부 파일 변경은 확인하거나 되돌리지 않습니다.`,
783
799
  resumeOperation: (id) => `중단된 작업:\n${id}`,
784
800
  resumeQuestion: "계속하려면 위 작업 ID 전체를 입력하세요. Enter를 누르면 취소합니다:",
@@ -937,6 +953,7 @@ const koMessages = {
937
953
  seedsNotRun: "기본 파일 없음 · init으로 준비하세요",
938
954
  seedsAttention: "기본 파일 확인 필요",
939
955
  recoveryPending: "진행 중이거나 복구가 필요한 작업이 있습니다. 이번 실행에서는 파일을 바꾸지 않았습니다.",
956
+ invalidRecoveryPending: "읽을 수 없는 작업 기록 때문에 복구를 진행할 수 없습니다. 이번 실행에서는 파일을 바꾸지 않았습니다.",
940
957
  resumeOperation: "앱 복구를 이어가려면:",
941
958
  contextCurrent: "AGENTS.md와 TONE.md 구성이 최신입니다.",
942
959
  contextRepair: "관리 중인 컨텍스트 파일을 복구해야 합니다. 안내 파일과 톤 파일의 진단을 확인해 주세요.",
@@ -959,7 +976,7 @@ const koMessages = {
959
976
  changed: "검사 중 전달 스크립트나 설정이 바뀌었습니다. 변경이 끝난 뒤 다시 시도해 주세요.",
960
977
  probe: "제어된 검사에 실패했습니다. Node와 임시 디렉터리 접근 권한을 확인한 뒤 doctor를 다시 실행해 주세요.",
961
978
  },
962
- operationState: { pending: "미완료", complete: "완료", "rolled-back": "복구됨", "retired-for-app-rebuild": "앱 복구를 위해 보관됨", invalid: "잘못된 기록" },
979
+ operationState: { pending: "미완료", complete: "완료", "rolled-back": "복구됨", "retired-for-app-rebuild": "앱 복구를 위해 보관됨", quarantined: "격리됨", invalid: "잘못된 기록" },
963
980
  rollbackStatus: {
964
981
  ready: "승인하면 선택한 작업을 복구합니다.",
965
982
  busy: "아직 진행 중인 작업입니다. 작업이 끝날 때까지 기다려 주세요.",
@@ -1127,6 +1144,7 @@ const koMessages = {
1127
1144
  tools: "연결할 에이전트 · init에서 none은 상태만 준비",
1128
1145
  fix: "doctor: 고칠 수 있는 문제 복구",
1129
1146
  rollback: "doctor --fix: 작업 하나 복구",
1147
+ quarantineOperation: "doctor --fix: 읽을 수 없는 작업 기록 하나 격리",
1130
1148
  fromOperation: "doctor --fix: 작업 기록에서 설정 선택",
1131
1149
  fromPreset: "doctor --fix: 없는 설정에 사용할 프리셋",
1132
1150
  restorePreset: "doctor --fix: 없거나 손상된 사용자 프리셋 하나 복원",
@@ -101,7 +101,7 @@ const messages = {
101
101
  choosePreset: "Elige un preajuste para los valores sin un origen guardado válido:",
102
102
  sourceRequired: "Los valores guardados están incompletos. Ejecuta tonedial doctor --fix de forma interactiva o selecciona --from-preset SLUG o --from-operation ID antes de recuperar sin interacción.",
103
103
  sourceInvalid: "El origen elegido no proporciona ajustes válidos. Se conservó el original.",
104
- invalidOptions: "El origen de recuperación y las herramientas requieren doctor --fix. --restore-preset requiere --from-operation; --rollback no se combina con origen, preajuste ni herramientas.",
104
+ invalidOptions: "El origen, la cuarentena y las herramientas requieren doctor --fix. --restore-preset requiere --from-operation; --rollback no se combina con cuarentena, origen, preajuste ni herramientas.",
105
105
  keptSettings: "Se conservaron los ajustes y preajustes personalizados.",
106
106
  obsoleteWidthDiscarded: "Se descarta el valor no válido del antiguo fixed_width. La salida se adapta al ancho del terminal.",
107
107
  obsoleteGuardsDiscarded: "Se descarta el valor no válido del antiguo guards. ToneDial ya no añade solicitudes de protección.",
@@ -114,6 +114,13 @@ const messages = {
114
114
  rollback: (id) => `Restaura los archivos anteriores:\ntonedial doctor --fix --rollback ${id}`,
115
115
  rollbackOrder: "Restaura primero las conexiones y después los archivos de la aplicación:",
116
116
  noSnapshot: (id) => `${id}: no hay una instantánea válida para este directorio de estado`,
117
+ quarantineCandidate: (id) => `ID de operación:\n${id}\nPara reconstruir, primero debes poner este registro en cuarentena; los bytes originales quedarán en el historial de reversión.`,
118
+ quarantineQuestion: "Escribe el ID completo de arriba para ponerlo en cuarentena; Enter cancela:",
119
+ quarantineRequired: (id, command) => `${id}: debes poner en cuarentena este registro ilegible antes de recuperar. No se cambió ningún archivo.\n${command}`,
120
+ quarantineUnavailable: (id) => `${id}: no hay un registro de operación ilegible que se pueda poner en cuarentena. No se cambió ningún archivo.`,
121
+ quarantine: (id) => `Pon en cuarentena la operación ilegible ${id}; conserva sus bytes originales en el historial de reversión.`,
122
+ quarantineComplete: (id) => `La operación ilegible ${id} quedó en cuarentena.`,
123
+ quarantineRemaining: (count) => `${count === 1 ? "Queda 1 registro de operación ilegible" : `Quedan ${count} registros de operación ilegibles`}. Pon el siguiente en cuarentena antes de continuar la recuperación.`,
117
124
  retire: (id) => `Archiva la operación detenida ${id} antes de reconstruir. Sus cambios externos no se certifican ni se deshacen.`,
118
125
  resumeOperation: (id) => `Operación detenida:\n${id}`,
119
126
  resumeQuestion: "Para continuar, escribe el ID completo de arriba; Enter cancela:",
@@ -269,6 +276,7 @@ const messages = {
269
276
  seedsNotRun: "sin archivos incluidos · aquí no se ha ejecutado init",
270
277
  seedsAttention: "los archivos incluidos requieren atención",
271
278
  recoveryPending: "Hay una operación en curso o que necesita recuperación. Esta ejecución no cambió archivos.",
279
+ invalidRecoveryPending: "Un historial de operaciones ilegible está bloqueando la recuperación. Esta ejecución no cambió archivos.",
272
280
  resumeOperation: "Continuar la reparación de la aplicación:",
273
281
  contextCurrent: "La estructura de AGENTS.md y TONE.md está al día.",
274
282
  contextRepair: "Los archivos de contexto gestionados necesitan reparación. Revisa el diagnóstico de la guía y del tono.",
@@ -291,7 +299,7 @@ const messages = {
291
299
  changed: "El lector o los ajustes cambiaron durante la inspección. Reintenta cuando terminen los cambios.",
292
300
  probe: "Falló la comprobación controlada. Revisa Node y el acceso al directorio temporal; después repite doctor.",
293
301
  },
294
- operationState: { pending: "pendiente", complete: "completa", "rolled-back": "restaurada", "retired-for-app-rebuild": "archivada para reconstruir", invalid: "registro no válido" },
302
+ operationState: { pending: "pendiente", complete: "completa", "rolled-back": "restaurada", "retired-for-app-rebuild": "archivada para reconstruir", quarantined: "en cuarentena", invalid: "registro no válido" },
295
303
  rollbackStatus: {
296
304
  ready: "La aprobación restaurará la operación elegida.",
297
305
  busy: "La operación sigue activa. Espera a que termine su propietario.",
@@ -455,6 +463,7 @@ const messages = {
455
463
  tools: "agentes que conectar · init: none solo crea el estado",
456
464
  fix: "doctor: reparar los problemas que se puedan",
457
465
  rollback: "doctor --fix: restaurar una operación",
466
+ quarantineOperation: "doctor --fix: poner en cuarentena un registro ilegible",
458
467
  fromOperation: "doctor --fix: elegir ajustes guardados de una operación",
459
468
  fromPreset: "doctor --fix: preajuste para ajustes no disponibles",
460
469
  restorePreset: "doctor --fix: restaurar un preajuste ausente o dañado",
@@ -101,7 +101,7 @@ const messages = {
101
101
  choosePreset: "有効な保存元がない値の復元用プリセットを選んでください:",
102
102
  sourceRequired: "保存値が不完全です。tonedial doctor --fix を対話的に実行するか、無人復旧の前に --from-preset SLUG または --from-operation ID を選んでください。",
103
103
  sourceInvalid: "選択元から有効な設定を取得できません。元のファイルは保持しました。",
104
- invalidOptions: "復旧元とツールの指定には doctor --fix が必要です。--restore-preset には --from-operation が必要です。--rollback は復旧元、プリセット、ツール指定と併用できません。",
104
+ invalidOptions: "復旧元、隔離、ツールの指定には doctor --fix が必要です。--restore-preset には --from-operation が必要です。--rollback は隔離、復旧元、プリセット、ツール指定と併用できません。",
105
105
  keptSettings: "保存済み設定とカスタムプリセットを保持しました。",
106
106
  obsoleteWidthDiscarded: "廃止済み fixed_width の無効な値を破棄します。出力はターミナル幅に従います。",
107
107
  obsoleteGuardsDiscarded: "廃止済み guards の無効な値を破棄します。ToneDialはガード要求を追加しません。",
@@ -114,6 +114,13 @@ const messages = {
114
114
  rollback: (id) => `以前のファイルに戻す:\ntonedial doctor --fix --rollback ${id}`,
115
115
  rollbackOrder: "先に接続を復元し、その後アプリケーションのファイルを復元してください:",
116
116
  noSnapshot: (id) => `${id}: この状態ルート用の有効な設定スナップショットがありません`,
117
+ quarantineCandidate: (id) => `操作ID:\n${id}\n再構築するには、先にこの記録を隔離する必要があります。元のバイト列はロールバック履歴に保持されます。`,
118
+ quarantineQuestion: "隔離するには上の操作ID全体を入力してください。Enterで中止:",
119
+ quarantineRequired: (id, command) => `${id}: 復旧前に、読み取れない操作記録を明示的に隔離する必要があります。ファイルは変更していません。\n${command}`,
120
+ quarantineUnavailable: (id) => `${id}: 隔離できる読み取り不能な操作記録が見つかりません。ファイルは変更していません。`,
121
+ quarantine: (id) => `読み取れない操作 ${id} を隔離し、元のバイト列をロールバック履歴に保持します。`,
122
+ quarantineComplete: (id) => `読み取れない操作 ${id} を隔離しました。`,
123
+ quarantineRemaining: (count) => `読み取れない操作記録が ${count} 件残っています。次の記録を隔離してから復旧を続けてください。`,
117
124
  retire: (id) => `停止した操作 ${id} を履歴に移してから再構築してください。外部への変更は検証も取り消しもされません。`,
118
125
  resumeOperation: (id) => `停止した操作:\n${id}`,
119
126
  resumeQuestion: "続行するには上の操作ID全体を入力してください。Enterで中止:",
@@ -269,6 +276,7 @@ const messages = {
269
276
  seedsNotRun: "同梱ファイルなし · ここでは init 未実行",
270
277
  seedsAttention: "同梱ファイルの確認が必要",
271
278
  recoveryPending: "実行中または復旧が必要な操作があります。この実行ではファイルを変更していません。",
279
+ invalidRecoveryPending: "読み取れない操作履歴が復旧を妨げています。この実行ではファイルを変更していません。",
272
280
  resumeOperation: "アプリ復旧を続けるには:",
273
281
  contextCurrent: "AGENTS.md と TONE.md の構成は最新です。",
274
282
  contextRepair: "管理対象のコンテキストファイルに修復が必要です。ガイドと口調ファイルの診断を確認してください。",
@@ -291,7 +299,7 @@ const messages = {
291
299
  changed: "確認中にリーダーか設定が変更されました。変更が終わってから再試行してください。",
292
300
  probe: "制御下の確認に失敗しました。Nodeと一時ディレクトリへのアクセスを確認し、doctor を再実行してください。",
293
301
  },
294
- operationState: { pending: "保留中", complete: "完了", "rolled-back": "復元済み", "retired-for-app-rebuild": "再構築用に履歴へ移行", invalid: "無効な記録" },
302
+ operationState: { pending: "保留中", complete: "完了", "rolled-back": "復元済み", "retired-for-app-rebuild": "再構築用に履歴へ移行", quarantined: "隔離済み", invalid: "無効な記録" },
295
303
  rollbackStatus: {
296
304
  ready: "承認すると選択した操作を復元します。",
297
305
  busy: "まだ実行中です。所有者の完了を待ってください。",
@@ -455,6 +463,7 @@ const messages = {
455
463
  tools: "接続するエージェント · init: none は状態のみ",
456
464
  fix: "doctor: 修復可能な問題を修復",
457
465
  rollback: "doctor --fix: 操作を1つ復元",
466
+ quarantineOperation: "doctor --fix: 読み取れない操作記録を1つ隔離",
458
467
  fromOperation: "doctor --fix: 操作から保存済み設定を選択",
459
468
  fromPreset: "doctor --fix: 不足した設定用のプリセット",
460
469
  restorePreset: "doctor --fix: 不足・破損したユーザープリセットを復元",
@@ -101,7 +101,7 @@ const messages = {
101
101
  choosePreset: "请为缺少有效保存来源的值选择预设:",
102
102
  sourceRequired: "保存值不完整。请交互运行 tonedial doctor --fix,或在无人值守恢复前选择 --from-preset SLUG 或 --from-operation ID。",
103
103
  sourceInvalid: "所选来源无法提供有效设置,已保留原文件。",
104
- invalidOptions: "恢复来源和工具选项需要 doctor --fix。--restore-preset 需要 --from-operation;--rollback 不能与来源、预设或工具选项同时使用。",
104
+ invalidOptions: "恢复来源、隔离和工具选项需要 doctor --fix。--restore-preset 需要 --from-operation;--rollback 不能与隔离、来源、预设或工具选项同时使用。",
105
105
  keptSettings: "已保留保存的设置和自定义预设。",
106
106
  obsoleteWidthDiscarded: "丢弃已停用的无效 fixed_width 值。输出将跟随终端宽度。",
107
107
  obsoleteGuardsDiscarded: "丢弃已停用的无效 guards 值。ToneDial 不再添加防护请求。",
@@ -114,6 +114,13 @@ const messages = {
114
114
  rollback: (id) => `恢复之前的文件:\ntonedial doctor --fix --rollback ${id}`,
115
115
  rollbackOrder: "请先恢复连接,再恢复应用文件:",
116
116
  noSnapshot: (id) => `${id}:没有此状态根目录的有效设置快照`,
117
+ quarantineCandidate: (id) => `操作 ID:\n${id}\n要重建应用,必须先隔离这条记录;原始字节仍保存在回滚历史中。`,
118
+ quarantineQuestion: "请输入上方完整的操作 ID 以隔离该记录;按 Enter 取消:",
119
+ quarantineRequired: (id, command) => `${id}:恢复前必须明确隔离这条无法读取的操作记录。未更改文件。\n${command}`,
120
+ quarantineUnavailable: (id) => `${id}:找不到可隔离的无法读取操作记录。未更改文件。`,
121
+ quarantine: (id) => `隔离无法读取的操作 ${id};原始字节保留在回滚历史中。`,
122
+ quarantineComplete: (id) => `已隔离无法读取的操作 ${id}。`,
123
+ quarantineRemaining: (count) => `仍有 ${count} 条无法读取的操作记录。请隔离下一条记录后继续恢复。`,
117
124
  retire: (id) => `重建应用前,请将已停止的操作 ${id} 归档为历史。其外部变更不会被认证或撤销。`,
118
125
  resumeOperation: (id) => `已停止的操作:\n${id}`,
119
126
  resumeQuestion: "要继续,请输入上方完整的操作 ID;按 Enter 取消:",
@@ -269,6 +276,7 @@ const messages = {
269
276
  seedsNotRun: "无内置文件 · 尚未在此运行 init",
270
277
  seedsAttention: "内置文件需要处理",
271
278
  recoveryPending: "有操作正在运行或需要恢复。本次执行未更改文件。",
279
+ invalidRecoveryPending: "无法读取的操作历史阻止了恢复。本次执行未更改文件。",
272
280
  resumeOperation: "继续应用恢复:",
273
281
  contextCurrent: "AGENTS.md 与 TONE.md 布局已是最新。",
274
282
  contextRepair: "受管理的上下文文件需要修复,请检查指南和语气文件诊断。",
@@ -291,7 +299,7 @@ const messages = {
291
299
  changed: "检查时读取程序或设置发生变化,请等修改结束后重试。",
292
300
  probe: "受控探测失败。请检查 Node 和临时目录访问权限,再运行 doctor。",
293
301
  },
294
- operationState: { pending: "待处理", complete: "已完成", "rolled-back": "已恢复", "retired-for-app-rebuild": "已归档以供重建", invalid: "无效记录" },
302
+ operationState: { pending: "待处理", complete: "已完成", "rolled-back": "已恢复", "retired-for-app-rebuild": "已归档以供重建", quarantined: "已隔离", invalid: "无效记录" },
295
303
  rollbackStatus: {
296
304
  ready: "批准后将恢复所选操作。",
297
305
  busy: "操作仍在运行,请等待其所有者完成。",
@@ -455,6 +463,7 @@ const messages = {
455
463
  tools: "要连接的代理 · init:none 仅初始化状态",
456
464
  fix: "doctor:修复可处理的问题",
457
465
  rollback: "doctor --fix:恢复一次操作",
466
+ quarantineOperation: "doctor --fix:隔离一条无法读取的操作记录",
458
467
  fromOperation: "doctor --fix:从操作中选择保存设置",
459
468
  fromPreset: "doctor --fix:为不可用设置选择预设",
460
469
  restorePreset: "doctor --fix:恢复一个缺失或损坏的用户预设",
@@ -387,6 +387,27 @@ export function readJournal(paths, operationIdValue) {
387
387
  const journal = readJson(path);
388
388
  return validJournal(journal, paths, operationIdValue) ? journal : null;
389
389
  }
390
+ function readOperationQuarantine(paths, operationIdValue) {
391
+ if (!existingOperationsDirectory(paths))
392
+ return null;
393
+ const value = readJson(operationJournalPath(paths, operationIdValue));
394
+ return isPlainObject(value) && hasExactKeys(value, ["kind", "operationId", "originalSha256", "root", "schema"]) &&
395
+ value.schema === 1 && value.kind === "quarantined-operation" && value.operationId === operationIdValue &&
396
+ value.root === paths.root && /^[0-9a-f]{64}$/.test(value.originalSha256)
397
+ ? value : null;
398
+ }
399
+ /** Replace an unreadable journal with a nonblocking marker; the enclosing repair journal keeps its original bytes. */
400
+ export function serializeOperationQuarantine(paths, operationIdValue, original) {
401
+ if (!validOperationId(operationIdValue))
402
+ throw new OperationError("invalid", "operation id is not valid");
403
+ return `${JSON.stringify({
404
+ schema: 1,
405
+ kind: "quarantined-operation",
406
+ operationId: operationIdValue,
407
+ root: paths.root,
408
+ originalSha256: createHash("sha256").update(original).digest("hex"),
409
+ }, null, 2)}\n`;
410
+ }
390
411
  function journalTime(journal) {
391
412
  const updated = Date.parse(journal.updatedAt);
392
413
  if (Number.isFinite(updated))
@@ -889,12 +910,13 @@ export function inspectOperations(paths) {
889
910
  continue;
890
911
  const id = match.groups.id;
891
912
  const journal = readJournal(paths, id);
913
+ const quarantine = journal === null ? readOperationQuarantine(paths, id) : null;
892
914
  journals.push({
893
915
  path: join(paths.operations, entry.name),
894
916
  operationId: id,
895
- state: journal?.state ?? "invalid",
917
+ state: journal?.state ?? (quarantine === null ? "invalid" : "quarantined"),
896
918
  journal,
897
- ...(journal === null ? { message: "mutation journal is invalid" } : {}),
919
+ ...(journal === null ? { message: quarantine === null ? "mutation journal is invalid" : "invalid journal is quarantined in rollback history" } : {}),
898
920
  });
899
921
  }
900
922
  const ownerOnly = ownerOnlyInspection(paths, lock);
@@ -11,7 +11,7 @@ import { captureRawEntry, caseRenameMatchesCurrent, isNoop, planChange, planEntr
11
11
  import { BUNDLED, builtinPresetDir, LEGACY_BUILTIN_SLUGS, RETIRED_PRESETS } from "./presets.js";
12
12
  import { SKILL_MD, SKILL_NAME, SKILL_TRAITS_MD } from "./skill.js";
13
13
  // Bump for changed generated content; freshness also checks each managed seed's bytes/provenance.
14
- export const SEED_VERSION = 21;
14
+ export const SEED_VERSION = 23;
15
15
  /** The state-root operating boundaries. The compiler owns the current tone separately. */
16
16
  export const GUIDE_MD = `${GUIDE_FORMAT_MARKER}
17
17
  ToneDial changes manner only; it never expands task scope or permissions.
@@ -195,6 +195,12 @@ If repair fails, report its phase, affected path, safe error code and operation
195
195
  Some file changes may already be applied; a failure is not evidence that all files stayed unchanged.
196
196
  Preserve the operation record and follow the reported scoped read-only diagnosis.
197
197
  Default doctor output focuses on blockers; \`--verbose\` and JSON retain the operation history.
198
+ If doctor reports an unreadable operation record, use its exact scoped
199
+ \`doctor --fix --quarantine-operation <id>\` command. Preview or approve that replacement through
200
+ the normal repair gate; never delete the record directly. The replacement stops the damaged
201
+ record from blocking recovery, while the repair journal keeps its original bytes and mode for
202
+ explicit rollback. If several records are unreadable, run the next exact command doctor reports;
203
+ each invocation quarantines one record, and the last continues the application rebuild.
198
204
  For an explicitly selected stopped operation, \`doctor --fix --from-operation <id>\` continues
199
205
  application repair using valid current settings first; it does not undo the earlier changes.
200
206
  Preview that exact ID and scope with \`--dry-run\` before applying the approved repair.
@@ -452,7 +458,7 @@ selected by critique or persona. Bravado grades effort claims without replacing
452
458
  * command name stays `/tone` even though the tool is `tonedial`: it is what a person types
453
459
  * mid-sentence, and the two extra characters are paid every time.
454
460
  */
455
- export const TONE_COMMAND_VERSION = 13;
461
+ export const TONE_COMMAND_VERSION = 15;
456
462
  export const TONE_COMMAND_MD = `---
457
463
  description: Check, change, or reload this agent's tone, preset, or ToneDial settings
458
464
  ---
@@ -832,7 +832,7 @@ function validateEntryRebuildAction(action, prepared = false) {
832
832
  const value = snapshotRecord(action.metadata ?? null);
833
833
  if (action.kind !== "entry-rebuild" || !normalizedPath(action.path) || action.realPath !== action.path || value === null ||
834
834
  Object.keys(value).sort().join(",") !== ["schema", "reason", "boundary", "boundaryIdentity", "parents", "stash", "stashIdentity", "archive", "prepared", "preparedIdentity", "createdParents", ...(Object.hasOwn(value, "recipes") ? ["recipes"] : [])].sort().join(",") ||
835
- value.schema !== 1 || !["generated", "restore-config", "restore-preset"].includes(value.reason) ||
835
+ value.schema !== 1 || !["generated", "restore-config", "restore-preset", "quarantine-operation"].includes(value.reason) ||
836
836
  !normalizedPath(value.boundary) || !lexicallyInside(action.path, value.boundary) || !identityTextValue(value.boundaryIdentity) ||
837
837
  !Array.isArray(value.parents) || value.parents.length === 0 || !normalizedPath(value.stash) ||
838
838
  dirname(value.stash) !== dirname(action.path) || !new RegExp(`^\\.${basename(action.path).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.tonedial-rebuild-[0-9a-f]{32}$`).test(basename(value.stash)) ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tonedial",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Set a coding agent's manner once and keep it across every project.",
5
5
  "license": "MIT",
6
6
  "type": "module",