zames_pro 2.60.0 → 2.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -17,11 +17,13 @@ import { runConfigMenu } from './config-menu.js';
17
17
  import { translate, normalizeLocale, localeDisplayName, isLocale, } from './i18n.js';
18
18
  import { Transcript } from './transcript.js';
19
19
  import { UndoStore } from './undo.js';
20
+ import { CheckpointStore, pickRecord as pickCheckpoint } from './checkpoint.js';
20
21
  import { selfReview, selfDiff, selfApply, selfList } from './self-review.js';
21
- import { formatDiff, formatDiffStat, formatContextSources, diffGitArgs, parseTranscript, summarizeTranscript, renderCost, formatExport, defaultExportPath, renderDoctor, resolveExtraDir, buildReviewPrompt, parseBacklogItems, nextBacklogItem, buildImprovePrompt, formatDuration, formatRelativeTime, trimRestoredMessages, RESTORED_HISTORY_LIMIT, mergeMessages, hasQueuedJob, isSlashCommand, parseQueueCommand, parseLiveToggle, parseGoalCommand, isLiveConfigCommand, withGoal, formatQueueList, ctrlCEscalation, } from './commands.js';
22
+ import { formatDiff, formatDiffStat, formatContextSources, diffGitArgs, parseTranscript, summarizeTranscript, renderCost, formatExport, defaultExportPath, renderDoctor, resolveExtraDir, buildReviewPrompt, isDevOnlyCommand, parseBacklogItems, nextBacklogItem, buildImprovePrompt, formatDuration, formatRelativeTime, trimRestoredMessages, RESTORED_HISTORY_LIMIT, mergeMessages, hasQueuedJob, isSlashCommand, parseQueueCommand, parseLiveToggle, parseGoalCommand, isLiveConfigCommand, withGoal, formatQueueList, ctrlCEscalation, expandCommandArgs, missingCommandArgs, } from './commands.js';
22
23
  import { performCompact } from './compact.js';
23
24
  import { Scheduler, parseInterval, formatInterval, formatJobLine, parseCron, } from './scheduler.js';
24
25
  import { renderMarkdown, setAnswerWidth } from './markdown.js';
26
+ import { extractAtFileRefs } from './path-token.js';
25
27
  import { closeWeb } from './web.js';
26
28
  import { assertCommandInsideRoot, runShell } from './shell.js';
27
29
  import { saveSession, loadLastSession, readSession, listSessions, sessionsDir, loadHistory, saveHistory, } from './sessions.js';
@@ -188,10 +190,12 @@ const RELOADABLE = [
188
190
  'self-review',
189
191
  'diff',
190
192
  'undo',
193
+ 'checkpoint',
191
194
  'transcript',
192
195
  'spinner',
193
196
  'config',
194
197
  'fsutil',
198
+ 'backlog',
195
199
  ];
196
200
  const mod = {
197
201
  createTools,
@@ -204,6 +208,10 @@ const mod = {
204
208
  closeWeb,
205
209
  createSpinner,
206
210
  createMcpPool: null,
211
+ collapseBacklog: null,
212
+ appendBacklogItem: null,
213
+ backlogStats: null,
214
+ backlogNeedsPruning: null,
207
215
  };
208
216
  async function reloadModules() {
209
217
  const stamp = Date.now();
@@ -242,6 +250,14 @@ async function reloadModules() {
242
250
  mod.createSpinner = pick('spinner', 'createSpinner');
243
251
  if (pick('mcp', 'createMcpPool'))
244
252
  mod.createMcpPool = pick('mcp', 'createMcpPool');
253
+ if (pick('backlog', 'collapseBacklog'))
254
+ mod.collapseBacklog = pick('backlog', 'collapseBacklog');
255
+ if (pick('backlog', 'appendBacklogItem'))
256
+ mod.appendBacklogItem = pick('backlog', 'appendBacklogItem');
257
+ if (pick('backlog', 'backlogStats'))
258
+ mod.backlogStats = pick('backlog', 'backlogStats');
259
+ if (pick('backlog', 'backlogNeedsPruning'))
260
+ mod.backlogNeedsPruning = pick('backlog', 'backlogNeedsPruning');
245
261
  return { count: loaded.size, errors };
246
262
  }
247
263
  // Initial load so that mod.buildSystemPrompt and the rest are populated.
@@ -297,6 +313,42 @@ function warnIfBrowserChanged() {
297
313
  }
298
314
  // ---------- helpers ----------
299
315
  function printHelp() {
316
+ // Self-development commands (backlog / self-review) are shown ONLY in dev
317
+ // mode. A regular package install must not advertise them to the user — and
318
+ // the main loop rejects them too (see the guard at the dispatch).
319
+ const devHelp = devMode
320
+ ? ' ' +
321
+ t('help.cmd.improve') +
322
+ '\n ' +
323
+ t('help.cmd.backlog') +
324
+ '\n ' +
325
+ t('help.cmd.debug_dom') +
326
+ '\n ' +
327
+ t('help.cmd.help') +
328
+ '\n ' +
329
+ t('help.cmd.exit') +
330
+ '\n\n' +
331
+ theme.bold(t('help.self_review')) +
332
+ '\n ' +
333
+ t('help.self.review') +
334
+ '\n ' +
335
+ t('help.self.fix') +
336
+ '\n ' +
337
+ t('help.self.done') +
338
+ '\n ' +
339
+ t('help.self.list') +
340
+ '\n ' +
341
+ t('help.self.diff') +
342
+ '\n ' +
343
+ t('help.self.apply') +
344
+ '\n\n'
345
+ : ' ' +
346
+ t('help.cmd.debug_dom') +
347
+ '\n ' +
348
+ t('help.cmd.help') +
349
+ '\n ' +
350
+ t('help.cmd.exit') +
351
+ '\n';
300
352
  console.log(`
301
353
  ${theme.bold('zames')} — ${t('app.tagline')}
302
354
 
@@ -382,29 +434,20 @@ ${theme.bold(t('help.sec.files'))}
382
434
  ${t('help.cmd.transcript')}
383
435
  ${t('help.cmd.undo')}
384
436
  ${t('help.cmd.undo_list')}
437
+ ${t('help.cmd.rewind')}
438
+ ${t('help.cmd.rewind_list')}
385
439
  ${t('help.cmd.export')}
386
440
  ${t('help.cmd.cost')}
387
441
  ${t('help.cmd.copy')}
388
442
  ${t('help.cmd.doctor')}
389
443
  ${t('help.cmd.add_dir')}
390
444
  ${t('help.cmd.review')}
391
- ${t('help.cmd.improve')}
392
- ${t('help.cmd.debug_dom')}
393
- ${t('help.cmd.help')}
394
- ${t('help.cmd.exit')}
395
-
396
- ${theme.bold(t('help.self_review'))}
397
- ${t('help.self.review')}
398
- ${t('help.self.fix')}
399
- ${t('help.self.done')}
400
- ${t('help.self.list')}
401
- ${t('help.self.diff')}
402
- ${t('help.self.apply')}
403
- ${dynamicCommands.length ? '\n' + theme.bold(t('help.skills')) + '\n' + dynamicCommands.map((d) => ' ' + d.name.padEnd(24) + ' ' + d.description).join('\n') : ''}
445
+ ${devHelp}${dynamicCommands.length ? '\n' + theme.bold(t('help.skills')) + '\n' + dynamicCommands.map((d) => ' ' + (d.hint ? d.name + ' ' + d.hint : d.name).padEnd(24) + ' ' + d.description).join('\n') : ''}
404
446
 
405
447
  ${theme.bold(t('help.files'))}
406
448
  ${t('help.files.logs')} ${config.transcript.dir}
407
449
  ${t('help.files.undo')} ~/.zames/undo
450
+ ${t('help.files.checkpoints')} ~/.zames/checkpoints
408
451
  ${t('help.files.sessions')} ${sessionsDir()}
409
452
  ${t('help.files.profile')} ~/.zames/profile
410
453
  ${t('help.files.snapshots')} ~/.zames/snapshots
@@ -421,6 +464,12 @@ function dirLabel(p) {
421
464
  // List of slash commands for completion when you type «/» (Tab — complete).
422
465
  // The description is localized by the help.cmd.* key at display time — see
423
466
  // buildSlashCommands().
467
+ //
468
+ // Dev-only commands (backlog-driven self-improvement, self-review snapshots)
469
+ // are filtered out of the hints and rejected by the main loop unless the
470
+ // operator is in dev mode — a regular package install must never advertise
471
+ // them, and a muscle-memory `/improve` must not edit the user's BACKLOG.md.
472
+ // The list itself lives in commands.ts (`isDevOnlyCommand`), one source.
424
473
  const SLASH_COMMANDS = [
425
474
  { name: '/help', key: 'help.cmd.help' },
426
475
  { name: '/new', key: 'help.cmd.new' },
@@ -441,6 +490,8 @@ const SLASH_COMMANDS = [
441
490
  { name: '/reload', key: 'help.cmd.reload' },
442
491
  { name: '/undo', key: 'help.cmd.undo' },
443
492
  { name: '/undo-list', key: 'help.cmd.undo_list' },
493
+ { name: '/rewind', key: 'help.cmd.rewind' },
494
+ { name: '/rewind-list', key: 'help.cmd.rewind_list' },
444
495
  { name: '/transcript', key: 'help.cmd.transcript' },
445
496
  { name: '/diff', key: 'help.cmd.diff' },
446
497
  { name: '/diffstat', key: 'help.cmd.diffstat' },
@@ -450,6 +501,7 @@ const SLASH_COMMANDS = [
450
501
  { name: '/add-dir', key: 'help.cmd.add_dir' },
451
502
  { name: '/review', key: 'help.cmd.review' },
452
503
  { name: '/improve', key: 'help.cmd.improve' },
504
+ { name: '/backlog', key: 'help.cmd.backlog' },
453
505
  { name: '/goal', key: 'help.cmd.goal' },
454
506
  { name: '/loop', key: 'help.cmd.loop' },
455
507
  { name: '/cron', key: 'help.cmd.cron' },
@@ -495,9 +547,12 @@ async function refreshDynamicCommands(workdir) {
495
547
  }
496
548
  const cmds = await loadCommands(workdir);
497
549
  for (const c of cmds) {
550
+ // B7: carry the declared argument hint as a separate field so the hint is
551
+ // SHOWN in the suggest list but never inserted into the input line.
498
552
  out.push({
499
553
  name: '/' + c.name,
500
554
  description: c.description || t('help.cmd.custom'),
555
+ hint: c.argumentHint,
501
556
  });
502
557
  }
503
558
  }
@@ -507,16 +562,22 @@ async function refreshDynamicCommands(workdir) {
507
562
  dynamicCommands = out;
508
563
  }
509
564
  // Expand a slash target (skill or custom command) into a task string.
510
- // Returns null when the name is neither a skill nor a custom command.
565
+ // Returns null when the name is neither a skill nor a custom command. When a
566
+ // custom command declares mandatory `arguments:` and some are missing, returns
567
+ // `{ missing }` so the caller can print the usage hint instead of sending an
568
+ // incomplete prompt.
511
569
  async function expandSlashTarget(workdir, name, rest) {
512
570
  try {
513
571
  const { loadSkills, loadCommands, skillBody } = await import('./context.js');
514
572
  const commands = await loadCommands(workdir);
515
573
  const cmd = commands.find((c) => c.name.toLowerCase() === name.toLowerCase());
516
574
  if (cmd) {
517
- let body = cmd.body.replace(/\{\{args\}\}/g, rest);
518
- body = body.replace(/\$ARGUMENTS/g, rest);
519
- return body.trim() || rest;
575
+ // B7: refuse to run when declared positional arguments are missing.
576
+ const missing = missingCommandArgs(cmd.arguments, rest);
577
+ if (missing.length)
578
+ return { missing };
579
+ const body = expandCommandArgs(cmd.body, rest, cmd.arguments);
580
+ return { text: body || rest };
520
581
  }
521
582
  const skills = await loadSkills(workdir);
522
583
  const skill = skills.find((s) => s.name.toLowerCase() === name.toLowerCase() && s.userInvokable);
@@ -539,7 +600,7 @@ async function expandSlashTarget(workdir, name, rest) {
539
600
  const extra = rest
540
601
  ? '\n\nAdditional instructions from the operator: ' + rest
541
602
  : '';
542
- return header + '\n\n' + body + extra;
603
+ return { text: header + '\n\n' + body + extra };
543
604
  }
544
605
  }
545
606
  catch {
@@ -548,8 +609,10 @@ async function expandSlashTarget(workdir, name, rest) {
548
609
  return null;
549
610
  }
550
611
  // Slash-command descriptions in the current language (for LineEditor hints).
612
+ // Dev-only commands are filtered out unless the operator is in dev mode, so a
613
+ // regular install never advertises (or accepts) them.
551
614
  function buildSlashCommands() {
552
- const base = SLASH_COMMANDS.map((c) => ({
615
+ const base = SLASH_COMMANDS.filter((c) => devMode || !isDevOnlyCommand(c.name)).map((c) => ({
553
616
  name: c.name,
554
617
  description: t(c.key),
555
618
  }));
@@ -597,6 +660,55 @@ async function resolveAttachPath(workdir, raw) {
597
660
  }
598
661
  return null;
599
662
  }
663
+ // Per-file and total caps for `@path` inlining (B5). A reference should SAVE
664
+ // round-trips, not flood the context: a huge file is truncated with a note
665
+ // telling the agent to read the rest itself.
666
+ const AT_REF_FILE_CAP = 60 * 1024;
667
+ const AT_REF_TOTAL_CAP = 200 * 1024;
668
+ // Inline `@path` references found in a task text (B5). Returns the augmented
669
+ // text plus the list of files actually inlined (for the operator notice).
670
+ // Unknown/unreadable references are left in place untouched — they may be part
671
+ // of the prose; we never fail the task over a missing file.
672
+ async function inlineAtRefs(workdir, text) {
673
+ const refs = extractAtFileRefs(text);
674
+ if (!refs.length)
675
+ return { text, inlined: [] };
676
+ const blocks = [];
677
+ const inlined = [];
678
+ let total = 0;
679
+ for (const ref of refs) {
680
+ if (total >= AT_REF_TOTAL_CAP)
681
+ break;
682
+ const file = await resolveAttachPath(workdir, ref);
683
+ if (!file)
684
+ continue;
685
+ let raw;
686
+ try {
687
+ raw = await fs.readFile(file, 'utf-8');
688
+ }
689
+ catch {
690
+ continue;
691
+ }
692
+ let body = raw;
693
+ let note = '';
694
+ if (body.length > AT_REF_FILE_CAP) {
695
+ body = body.slice(0, AT_REF_FILE_CAP);
696
+ note = NL + NL + '(truncated at ' + AT_REF_FILE_CAP + ' chars)';
697
+ }
698
+ total += body.length;
699
+ inlined.push(path.relative(workdir, file) || file);
700
+ blocks.push('### ' + ref + NL + '```' + NL + body + NL + '```' + note);
701
+ }
702
+ if (!blocks.length)
703
+ return { text, inlined: [] };
704
+ const section = NL +
705
+ NL +
706
+ '## Files referenced with @ in the task' +
707
+ NL +
708
+ NL +
709
+ blocks.join(NL + NL);
710
+ return { text: text + section, inlined };
711
+ }
600
712
  async function cleanTmpDir() {
601
713
  try {
602
714
  await fs.rm(TMP_DIR, { recursive: true, force: true });
@@ -1055,8 +1167,29 @@ async function printRestoredHistory(browser, ui, chatId = null) {
1055
1167
  }
1056
1168
  }
1057
1169
  // ---------- task runner ----------
1170
+ // Ask the operator to approve a tool call flagged by an `ask` permission rule
1171
+ // (C1). While the LineEditor owns the terminal it must be paused first, or the
1172
+ // confirmation prompt and the input line fight for the same keys. A non-TTY run
1173
+ // has no way to answer, so it DENIES (never silently allows a guarded call).
1174
+ async function askOperatorConfirm(editor, question) {
1175
+ if (!(process.stdin.isTTY && process.stdout.isTTY))
1176
+ return false;
1177
+ if (editor)
1178
+ editor.pause();
1179
+ try {
1180
+ const a = await promptOnce(question + ' [y/N] ');
1181
+ return /^(y|yes|д|да)$/i.test(a.trim());
1182
+ }
1183
+ catch {
1184
+ return false;
1185
+ }
1186
+ finally {
1187
+ if (editor)
1188
+ editor.resume();
1189
+ }
1190
+ }
1058
1191
  async function runTask(browser, tools, taskText, workdir, opts, attachments = []) {
1059
- const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor, onChatReady, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, goal = null, todosQuery = null, } = opts;
1192
+ const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor, onChatReady, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, goal = null, todosQuery = null, selfImprovement = false, } = opts;
1060
1193
  // In TTY mode the UI is a LineEditor: it owns the input (queue, Esc,
1061
1194
  // Ctrl+C) and draws the status ABOVE the permanent input line. In non-TTY
1062
1195
  // mode (pipes) — a regular spinner + watchInput.
@@ -1152,6 +1285,19 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
1152
1285
  ui.assistant(msg);
1153
1286
  },
1154
1287
  onWarning: (msg) => ui.warning(msg),
1288
+ onAskPermission: async (info) => {
1289
+ // C1: a rule with action "ask". Show the reason and wait for the
1290
+ // operator. In non-TTY this denies (see askOperatorConfirm).
1291
+ ui.warning(t('perm.ask', { tool: info.tool, reason: info.reason }));
1292
+ const ok = await askOperatorConfirm(editor || null, t('perm.confirm'));
1293
+ ui.warning(ok ? t('perm.allowed') : t('perm.denied'));
1294
+ transcript?.log('permission_ask', {
1295
+ tool: info.tool,
1296
+ reason: info.reason,
1297
+ allowed: ok,
1298
+ });
1299
+ return ok;
1300
+ },
1155
1301
  onChatReady,
1156
1302
  debugLog: debug,
1157
1303
  locale: currentLocale,
@@ -1166,6 +1312,7 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
1166
1312
  autoCompactPct,
1167
1313
  contextLimit,
1168
1314
  getTokenUsage,
1315
+ selfImprovement,
1169
1316
  });
1170
1317
  // The loop may end WITHOUT a model answer: an exhausted iteration
1171
1318
  // limit or an ask() watchdog (the model stopped responding). In that
@@ -1308,6 +1455,7 @@ async function main() {
1308
1455
  console.log(theme.system(t('msg.transcript', { v: transcript.file })));
1309
1456
  }
1310
1457
  const undo = new UndoStore(config.undo);
1458
+ const checkpoints = new CheckpointStore(config.checkpoint);
1311
1459
  // MCP servers: optional external tool providers (e.g. @playwright/mcp).
1312
1460
  let mcpPool = null;
1313
1461
  try {
@@ -1402,6 +1550,7 @@ async function main() {
1402
1550
  transcript,
1403
1551
  freshChat,
1404
1552
  sendSystemPrompt,
1553
+ selfImprovement: devMode,
1405
1554
  onChatReady: (chatId) => {
1406
1555
  if (chatId)
1407
1556
  saveLastChat(chatId, currentWorkdir);
@@ -1423,6 +1572,21 @@ async function main() {
1423
1572
  if (!authMarkerExists()) {
1424
1573
  console.log(theme.warn(t('msg.first_login_hint')));
1425
1574
  }
1575
+ // Nudge when BACKLOG.md has grown large: /improve reads the whole file, so a
1576
+ // bloated backlog costs tokens on every self-improvement run. Best-effort.
1577
+ if (devMode) {
1578
+ try {
1579
+ const bl = await fs.readFile(path.join(currentWorkdir, 'BACKLOG.md'), 'utf-8');
1580
+ const st = mod.backlogStats(bl);
1581
+ if (mod.backlogNeedsPruning(st)) {
1582
+ console.log(theme.warn(t('backlog.warn', {
1583
+ lines: String(st.lines),
1584
+ chars: String(st.chars),
1585
+ })));
1586
+ }
1587
+ }
1588
+ catch { }
1589
+ }
1426
1590
  let freshChatNext = true;
1427
1591
  let sendSystemPromptNext = true;
1428
1592
  // Resuming an existing chat already has the system-prompt at its start, so
@@ -2226,6 +2390,16 @@ async function main() {
2226
2390
  const lower = trimmed.toLowerCase();
2227
2391
  if (['/exit', '/quit', 'exit', 'quit'].includes(lower))
2228
2392
  break;
2393
+ // Dev-only commands (backlog / self-review) must not run outside dev mode.
2394
+ // Hiding them from /help is not enough: a user could type the command by
2395
+ // hand, and it would rewrite their project's BACKLOG.md or snapshot src/.
2396
+ if (!devMode) {
2397
+ const devCmd = isDevOnlyCommand(lower);
2398
+ if (devCmd) {
2399
+ console.error(theme.warn(t('msg.dev_only', { v: devCmd })));
2400
+ continue;
2401
+ }
2402
+ }
2229
2403
  // `!command` — run a shell command directly, without asking the model
2230
2404
  // (like Claude Code's bash mode). The same sandbox guard as the Bash tool
2231
2405
  // applies, so a direct command cannot leave the project either.
@@ -3069,6 +3243,9 @@ async function main() {
3069
3243
  console.log(theme.system(t('status.undo', {
3070
3244
  v: config.undo.enabled ? t('common.on') : t('common.off'),
3071
3245
  })));
3246
+ console.log(theme.system(t('status.checkpoint', {
3247
+ v: config.checkpoint.enabled ? t('common.on') : t('common.off'),
3248
+ })));
3072
3249
  console.log(theme.system(t('status.transcript', {
3073
3250
  v: transcript.file || t('common.off'),
3074
3251
  })));
@@ -3328,6 +3505,7 @@ async function main() {
3328
3505
  tools: mod.createTools(currentWorkdir, { undo, todos: todoStore }),
3329
3506
  answerTimeoutMs: config.browser.answerTimeoutMs,
3330
3507
  fallbackLimit: COMPACT_FALLBACK_LIMIT,
3508
+ selfImprovement: devMode,
3331
3509
  });
3332
3510
  if (res.ok) {
3333
3511
  currentChatId = res.chatId;
@@ -3382,12 +3560,80 @@ async function main() {
3382
3560
  }
3383
3561
  },
3384
3562
  getTokenUsage: () => browser.getLastTokenUsage(),
3563
+ selfImprovement: devMode,
3385
3564
  });
3386
3565
  }
3387
3566
  finally {
3388
3567
  if (editor)
3389
3568
  editor.busy = false;
3390
3569
  }
3570
+ // Auto-prune: a finished item keeps its summary line but loses the
3571
+ // archived <details> copy of its original text, so BACKLOG.md does not
3572
+ // grow on every /improve. Best-effort — a read/write failure must not
3573
+ // break the command.
3574
+ try {
3575
+ const raw = await fs.readFile(path.join(currentWorkdir, 'BACKLOG.md'), 'utf-8');
3576
+ const col = mod.collapseBacklog(raw);
3577
+ if (col.changed) {
3578
+ await fs.writeFile(path.join(currentWorkdir, 'BACKLOG.md'), col.text, 'utf-8');
3579
+ console.log(theme.dim(t('improve.collapsed', { n: String(col.collapsed) })));
3580
+ }
3581
+ }
3582
+ catch { }
3583
+ continue;
3584
+ }
3585
+ if (lower === '/backlog' || lower.startsWith('/backlog ')) {
3586
+ // Record an improvement idea without implementing it. `/backlog
3587
+ // collapse` prunes the archived blocks instead. The LLM only PLACES
3588
+ // the item; the pure helpers in src/backlog.ts own the format.
3589
+ const note = trimmed.slice('/backlog'.length).trim();
3590
+ const backlogFile = path.join(currentWorkdir, 'BACKLOG.md');
3591
+ let backlogText;
3592
+ try {
3593
+ backlogText = await fs.readFile(backlogFile, 'utf-8');
3594
+ }
3595
+ catch {
3596
+ console.error(theme.warn(t('backlog.none')));
3597
+ continue;
3598
+ }
3599
+ if (!note) {
3600
+ console.log(theme.dim(t('backlog.usage')));
3601
+ continue;
3602
+ }
3603
+ if (note === 'collapse') {
3604
+ const col = mod.collapseBacklog(backlogText);
3605
+ if (!col.changed) {
3606
+ console.log(theme.dim(t('backlog.empty')));
3607
+ }
3608
+ else {
3609
+ await fs.writeFile(backlogFile, col.text, 'utf-8');
3610
+ console.log(theme.system(t('improve.collapsed', { n: String(col.collapsed) })));
3611
+ }
3612
+ continue;
3613
+ }
3614
+ // Deterministic append: no model round-trip is needed to record a
3615
+ // one-line note, and a pure helper cannot invent a different id than
3616
+ // the one the operator is shown. An optional leading `P0..P3` sets the
3617
+ // priority; the first line is the title, the rest becomes the body.
3618
+ let priority = 'P2';
3619
+ let body = note;
3620
+ for (const p of ['P0', 'P1', 'P2', 'P3']) {
3621
+ if (note.toUpperCase().startsWith(p + ' ')) {
3622
+ priority = p;
3623
+ body = note.slice(p.length + 1);
3624
+ break;
3625
+ }
3626
+ }
3627
+ const bodyLines = body.split(String.fromCharCode(10));
3628
+ const title = bodyLines[0];
3629
+ const rest = bodyLines.slice(1).join(String.fromCharCode(10)).trim();
3630
+ const res = mod.appendBacklogItem(backlogText, {
3631
+ title,
3632
+ priority,
3633
+ note: rest || undefined,
3634
+ });
3635
+ await fs.writeFile(backlogFile, res.text, 'utf-8');
3636
+ console.log(theme.system(t('backlog.adding', { v: res.id })));
3391
3637
  continue;
3392
3638
  }
3393
3639
  if (lower === '/review' || lower.startsWith('/review ')) {
@@ -3455,6 +3701,86 @@ async function main() {
3455
3701
  }
3456
3702
  continue;
3457
3703
  }
3704
+ if (lower === '/rewind-list' || lower === '/checkpoints') {
3705
+ const list = await checkpoints.list(10);
3706
+ if (!list.length) {
3707
+ console.log(theme.system(t('rewind.empty')));
3708
+ }
3709
+ else {
3710
+ console.log(theme.system(t('rewind.list_title')));
3711
+ list.forEach((r, i) => {
3712
+ const stamp = new Date(r.stamp).toLocaleString();
3713
+ const label = r.label ? ' [' + t('rewind.label_backup') + ']' : '';
3714
+ console.log(theme.system(` ${i + 1}. ${stamp} ${r.workdir}${label}`));
3715
+ });
3716
+ }
3717
+ continue;
3718
+ }
3719
+ if (lower === '/rewind' || lower.startsWith('/rewind ')) {
3720
+ const selector = trimmed.slice('/rewind'.length).trim() || '1';
3721
+ if (editor)
3722
+ editor.lock(t('msg.input_locked'));
3723
+ try {
3724
+ const list = await checkpoints.list(10);
3725
+ const target = pickCheckpoint(list, selector);
3726
+ if (!target) {
3727
+ console.error(theme.error(t('rewind.failed', { v: selector })));
3728
+ continue;
3729
+ }
3730
+ // Confirmation: a rewind overwrites the working tree, so it must never
3731
+ // happen on a muscle-memory Enter.
3732
+ const stamp = new Date(target.stamp).toLocaleString();
3733
+ let answer = 'n';
3734
+ if (process.stdin.isTTY && process.stdout.isTTY) {
3735
+ // promptOnce reads stdin itself. While the LineEditor owns the
3736
+ // terminal we must hand it over first (the same pause/resume the
3737
+ // /config menu uses), or both readers fight for the same keys.
3738
+ if (editor)
3739
+ editor.pause();
3740
+ try {
3741
+ answer = await promptOnce(theme.warn(t('rewind.confirm', { v: stamp })) + ' [y/N] ');
3742
+ }
3743
+ finally {
3744
+ if (editor)
3745
+ editor.resume();
3746
+ }
3747
+ }
3748
+ else if (trimmed.indexOf('--yes') !== -1) {
3749
+ // Non-TTY (piped): do not guess. Require an explicit --yes.
3750
+ answer = 'y';
3751
+ }
3752
+ if (!/^(y|yes|д|да)$/i.test(answer.trim())) {
3753
+ console.log(theme.system(t('rewind.cancelled')));
3754
+ continue;
3755
+ }
3756
+ const res = await checkpoints.restore(selector);
3757
+ if (res.ok && res.record) {
3758
+ console.log(theme.assistant(t('rewind.reverted', { v: String(res.record.stamp) })));
3759
+ if (res.backup) {
3760
+ console.log(theme.system(t('rewind.backup', { v: String(res.backup.stamp) })));
3761
+ }
3762
+ transcript.log('rewind', { stamp: res.record.stamp });
3763
+ }
3764
+ else {
3765
+ const reasonKey = res.reason === 'not_found'
3766
+ ? 'rewind.reason_not_found'
3767
+ : res.reason === 'archive_missing'
3768
+ ? 'rewind.reason_archive_missing'
3769
+ : res.reason === 'extract_failed'
3770
+ ? 'rewind.reason_extract_failed'
3771
+ : res.reason === 'disabled'
3772
+ ? 'rewind.reason_disabled'
3773
+ : null;
3774
+ const reason = reasonKey ? t(reasonKey) : String(res.reason ?? '');
3775
+ console.error(theme.error(t('rewind.failed', { v: reason })));
3776
+ }
3777
+ }
3778
+ finally {
3779
+ if (editor)
3780
+ editor.unlock();
3781
+ }
3782
+ continue;
3783
+ }
3458
3784
  if (lower === '/debug-dom') {
3459
3785
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
3460
3786
  const file = path.join(config.transcript.dir, `dom-${stamp}.html`);
@@ -3510,10 +3836,21 @@ async function main() {
3510
3836
  // task. Skills are instructions the agent follows; custom commands are
3511
3837
  // prompt templates with $ARGUMENTS / {{args}} placeholders.
3512
3838
  let expandedTask = null;
3839
+ let missingArgs = null;
3513
3840
  if (lower.startsWith('/')) {
3514
3841
  const name = trimmed.slice(1).split(/\s+/)[0];
3515
3842
  const rest = trimmed.slice(1 + name.length).trim();
3516
- expandedTask = await expandSlashTarget(currentWorkdir, name, rest);
3843
+ const res = await expandSlashTarget(currentWorkdir, name, rest);
3844
+ if (res && 'missing' in res)
3845
+ missingArgs = res.missing;
3846
+ else if (res)
3847
+ expandedTask = res.text;
3848
+ }
3849
+ // B7: a custom command with mandatory `arguments:` refuses to run until the
3850
+ // operator supplies them (by position).
3851
+ if (missingArgs) {
3852
+ console.error(theme.error(t('msg.missing_args', { v: missingArgs.join(', ') })));
3853
+ continue;
3517
3854
  }
3518
3855
  if (lower.startsWith('/') && expandedTask === null) {
3519
3856
  console.error(theme.error(t('msg.unknown_cmd', { v: trimmed })));
@@ -3521,7 +3858,20 @@ async function main() {
3521
3858
  }
3522
3859
  // ---- Regular task (including in review mode) ----
3523
3860
  const taskText = expandedTask !== null ? expandedTask : trimmed;
3524
- lastTaskText = taskText;
3861
+ // B5: inline any `@path` references into the task so the agent does not
3862
+ // spend a round-trip reading a file the operator already pointed at. Only
3863
+ // existing files are inlined; a bare `@name` in prose is left untouched.
3864
+ let finalTaskText = taskText;
3865
+ {
3866
+ const refRes = await inlineAtRefs(currentWorkdir, taskText);
3867
+ if (refRes.inlined.length) {
3868
+ finalTaskText = refRes.text;
3869
+ console.log(theme.system(t('msg.at_refs', { n: refRes.inlined.length }) +
3870
+ ' ' +
3871
+ refRes.inlined.join(', ')));
3872
+ }
3873
+ }
3874
+ lastTaskText = finalTaskText;
3525
3875
  // Dev mode: pick up fresh logic modules before the task.
3526
3876
  await autoReload();
3527
3877
  // Re-scan skills/custom commands before every task: a SKILL.md or a
@@ -3530,7 +3880,19 @@ async function main() {
3530
3880
  await refreshDynamicCommands(currentWorkdir);
3531
3881
  if (editor)
3532
3882
  editor.setCommands(buildSlashCommands());
3533
- transcript.log('user_task', { task: taskText, workdir: currentWorkdir });
3883
+ transcript.log('user_task', {
3884
+ task: finalTaskText,
3885
+ workdir: currentWorkdir,
3886
+ });
3887
+ // BACKLOG B3: snapshot the working tree BEFORE the task so /rewind can
3888
+ // undo the whole task in one step. Best-effort: create() never throws, so
3889
+ // a missing tar cannot abort the task it was meant to protect. Skipped
3890
+ // while reviewing a snapshot (workdir is a throwaway copy).
3891
+ if (!reviewMode) {
3892
+ const cp = await checkpoints.create(currentWorkdir);
3893
+ if (cp)
3894
+ transcript.log('checkpoint', { stamp: cp.stamp });
3895
+ }
3534
3896
  const tools = mod.createTools(currentWorkdir, {
3535
3897
  undo,
3536
3898
  todos: todoStore,
@@ -3541,7 +3903,7 @@ async function main() {
3541
3903
  if (editor)
3542
3904
  editor.busy = true;
3543
3905
  try {
3544
- await runTask(browser, tools, taskText, currentWorkdir, {
3906
+ await runTask(browser, tools, finalTaskText, currentWorkdir, {
3545
3907
  transcript,
3546
3908
  freshChat: freshChatNext,
3547
3909
  sendSystemPrompt: sendSystemPromptNext,
@@ -3567,7 +3929,7 @@ async function main() {
3567
3929
  currentChatId,
3568
3930
  workdir: currentWorkdir,
3569
3931
  locale: currentLocale,
3570
- task: taskText,
3932
+ task: finalTaskText,
3571
3933
  transcript,
3572
3934
  ui: editor,
3573
3935
  buildSystemPrompt: mod.buildSystemPrompt,
@@ -3578,6 +3940,7 @@ async function main() {
3578
3940
  answerTimeoutMs: config.browser.answerTimeoutMs,
3579
3941
  quiet: true,
3580
3942
  skipUiLock: true,
3943
+ selfImprovement: devMode,
3581
3944
  });
3582
3945
  if (res.ok) {
3583
3946
  currentChatId = res.chatId;
@@ -3591,6 +3954,7 @@ async function main() {
3591
3954
  getTokenUsage: () => browser.getLastTokenUsage(),
3592
3955
  goal: sessionGoal,
3593
3956
  todosQuery: () => todosSummary(),
3957
+ selfImprovement: devMode,
3594
3958
  }, inputAttachments);
3595
3959
  }
3596
3960
  finally {