zames_pro 2.61.0 → 2.63.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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, isDevOnlyCommand, 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,6 +190,7 @@ const RELOADABLE = [
188
190
  'self-review',
189
191
  'diff',
190
192
  'undo',
193
+ 'checkpoint',
191
194
  'transcript',
192
195
  'spinner',
193
196
  'config',
@@ -431,17 +434,20 @@ ${theme.bold(t('help.sec.files'))}
431
434
  ${t('help.cmd.transcript')}
432
435
  ${t('help.cmd.undo')}
433
436
  ${t('help.cmd.undo_list')}
437
+ ${t('help.cmd.rewind')}
438
+ ${t('help.cmd.rewind_list')}
434
439
  ${t('help.cmd.export')}
435
440
  ${t('help.cmd.cost')}
436
441
  ${t('help.cmd.copy')}
437
442
  ${t('help.cmd.doctor')}
438
443
  ${t('help.cmd.add_dir')}
439
444
  ${t('help.cmd.review')}
440
- ${devHelp}${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') : ''}
441
446
 
442
447
  ${theme.bold(t('help.files'))}
443
448
  ${t('help.files.logs')} ${config.transcript.dir}
444
449
  ${t('help.files.undo')} ~/.zames/undo
450
+ ${t('help.files.checkpoints')} ~/.zames/checkpoints
445
451
  ${t('help.files.sessions')} ${sessionsDir()}
446
452
  ${t('help.files.profile')} ~/.zames/profile
447
453
  ${t('help.files.snapshots')} ~/.zames/snapshots
@@ -484,6 +490,8 @@ const SLASH_COMMANDS = [
484
490
  { name: '/reload', key: 'help.cmd.reload' },
485
491
  { name: '/undo', key: 'help.cmd.undo' },
486
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' },
487
495
  { name: '/transcript', key: 'help.cmd.transcript' },
488
496
  { name: '/diff', key: 'help.cmd.diff' },
489
497
  { name: '/diffstat', key: 'help.cmd.diffstat' },
@@ -539,9 +547,12 @@ async function refreshDynamicCommands(workdir) {
539
547
  }
540
548
  const cmds = await loadCommands(workdir);
541
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.
542
552
  out.push({
543
553
  name: '/' + c.name,
544
554
  description: c.description || t('help.cmd.custom'),
555
+ hint: c.argumentHint,
545
556
  });
546
557
  }
547
558
  }
@@ -551,16 +562,22 @@ async function refreshDynamicCommands(workdir) {
551
562
  dynamicCommands = out;
552
563
  }
553
564
  // Expand a slash target (skill or custom command) into a task string.
554
- // 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.
555
569
  async function expandSlashTarget(workdir, name, rest) {
556
570
  try {
557
571
  const { loadSkills, loadCommands, skillBody } = await import('./context.js');
558
572
  const commands = await loadCommands(workdir);
559
573
  const cmd = commands.find((c) => c.name.toLowerCase() === name.toLowerCase());
560
574
  if (cmd) {
561
- let body = cmd.body.replace(/\{\{args\}\}/g, rest);
562
- body = body.replace(/\$ARGUMENTS/g, rest);
563
- 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 };
564
581
  }
565
582
  const skills = await loadSkills(workdir);
566
583
  const skill = skills.find((s) => s.name.toLowerCase() === name.toLowerCase() && s.userInvokable);
@@ -583,7 +600,7 @@ async function expandSlashTarget(workdir, name, rest) {
583
600
  const extra = rest
584
601
  ? '\n\nAdditional instructions from the operator: ' + rest
585
602
  : '';
586
- return header + '\n\n' + body + extra;
603
+ return { text: header + '\n\n' + body + extra };
587
604
  }
588
605
  }
589
606
  catch {
@@ -643,6 +660,55 @@ async function resolveAttachPath(workdir, raw) {
643
660
  }
644
661
  return null;
645
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
+ }
646
712
  async function cleanTmpDir() {
647
713
  try {
648
714
  await fs.rm(TMP_DIR, { recursive: true, force: true });
@@ -1101,6 +1167,27 @@ async function printRestoredHistory(browser, ui, chatId = null) {
1101
1167
  }
1102
1168
  }
1103
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
+ }
1104
1191
  async function runTask(browser, tools, taskText, workdir, opts, attachments = []) {
1105
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;
1106
1193
  // In TTY mode the UI is a LineEditor: it owns the input (queue, Esc,
@@ -1198,6 +1285,19 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
1198
1285
  ui.assistant(msg);
1199
1286
  },
1200
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
+ },
1201
1301
  onChatReady,
1202
1302
  debugLog: debug,
1203
1303
  locale: currentLocale,
@@ -1355,6 +1455,7 @@ async function main() {
1355
1455
  console.log(theme.system(t('msg.transcript', { v: transcript.file })));
1356
1456
  }
1357
1457
  const undo = new UndoStore(config.undo);
1458
+ const checkpoints = new CheckpointStore(config.checkpoint);
1358
1459
  // MCP servers: optional external tool providers (e.g. @playwright/mcp).
1359
1460
  let mcpPool = null;
1360
1461
  try {
@@ -3142,6 +3243,9 @@ async function main() {
3142
3243
  console.log(theme.system(t('status.undo', {
3143
3244
  v: config.undo.enabled ? t('common.on') : t('common.off'),
3144
3245
  })));
3246
+ console.log(theme.system(t('status.checkpoint', {
3247
+ v: config.checkpoint.enabled ? t('common.on') : t('common.off'),
3248
+ })));
3145
3249
  console.log(theme.system(t('status.transcript', {
3146
3250
  v: transcript.file || t('common.off'),
3147
3251
  })));
@@ -3597,6 +3701,86 @@ async function main() {
3597
3701
  }
3598
3702
  continue;
3599
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
+ }
3600
3784
  if (lower === '/debug-dom') {
3601
3785
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
3602
3786
  const file = path.join(config.transcript.dir, `dom-${stamp}.html`);
@@ -3652,10 +3836,21 @@ async function main() {
3652
3836
  // task. Skills are instructions the agent follows; custom commands are
3653
3837
  // prompt templates with $ARGUMENTS / {{args}} placeholders.
3654
3838
  let expandedTask = null;
3839
+ let missingArgs = null;
3655
3840
  if (lower.startsWith('/')) {
3656
3841
  const name = trimmed.slice(1).split(/\s+/)[0];
3657
3842
  const rest = trimmed.slice(1 + name.length).trim();
3658
- 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;
3659
3854
  }
3660
3855
  if (lower.startsWith('/') && expandedTask === null) {
3661
3856
  console.error(theme.error(t('msg.unknown_cmd', { v: trimmed })));
@@ -3663,7 +3858,20 @@ async function main() {
3663
3858
  }
3664
3859
  // ---- Regular task (including in review mode) ----
3665
3860
  const taskText = expandedTask !== null ? expandedTask : trimmed;
3666
- 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;
3667
3875
  // Dev mode: pick up fresh logic modules before the task.
3668
3876
  await autoReload();
3669
3877
  // Re-scan skills/custom commands before every task: a SKILL.md or a
@@ -3672,7 +3880,19 @@ async function main() {
3672
3880
  await refreshDynamicCommands(currentWorkdir);
3673
3881
  if (editor)
3674
3882
  editor.setCommands(buildSlashCommands());
3675
- 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
+ }
3676
3896
  const tools = mod.createTools(currentWorkdir, {
3677
3897
  undo,
3678
3898
  todos: todoStore,
@@ -3683,7 +3903,7 @@ async function main() {
3683
3903
  if (editor)
3684
3904
  editor.busy = true;
3685
3905
  try {
3686
- await runTask(browser, tools, taskText, currentWorkdir, {
3906
+ await runTask(browser, tools, finalTaskText, currentWorkdir, {
3687
3907
  transcript,
3688
3908
  freshChat: freshChatNext,
3689
3909
  sendSystemPrompt: sendSystemPromptNext,
@@ -3709,7 +3929,7 @@ async function main() {
3709
3929
  currentChatId,
3710
3930
  workdir: currentWorkdir,
3711
3931
  locale: currentLocale,
3712
- task: taskText,
3932
+ task: finalTaskText,
3713
3933
  transcript,
3714
3934
  ui: editor,
3715
3935
  buildSystemPrompt: mod.buildSystemPrompt,
package/dist/input.js CHANGED
@@ -692,10 +692,11 @@ export class LineEditor {
692
692
  const shown = sugg.slice(off, off + pageSize);
693
693
  this._suggestOffset = off;
694
694
  this._suggestCount = sugg.length;
695
- const maxName = Math.max(...shown.map((c) => c.name.length));
695
+ const label = (c) => c.hint ? c.name + ' ' + c.hint : c.name;
696
+ const maxName = Math.max(...shown.map((c) => label(c).length));
696
697
  const lines = shown.map((c, i) => {
697
698
  const selected = off + i === this._suggestSelected && this._suggestSelected > 0;
698
- const name = theme.prompt(c.name.padEnd(maxName));
699
+ const name = theme.prompt(label(c).padEnd(maxName));
699
700
  const desc = theme.dim(' ' + c.description);
700
701
  return (selected ? theme.prompt(' ▸ ') : ' ') + name + desc;
701
702
  });
@@ -1266,7 +1267,13 @@ export class LineEditor {
1266
1267
  const label = translate(this.locale)('spinner.pause', { n: seconds });
1267
1268
  if (this._animating && this._dotTimer) {
1268
1269
  this._thinkBase = theme.brown(stripEllipsis(label));
1269
- this.setStatus(this._thinkBase + this._dots(this._dotPhase) + this._hint());
1270
+ // Keep the SAME tail as the dot timer renders (elapsed + hint). Omitting
1271
+ // the elapsed label here made it blink once per second: the timer showed
1272
+ // "· 1m 50s" and the per-second sendPause update erased it.
1273
+ this.setStatus(this._thinkBase +
1274
+ this._dots(this._dotPhase) +
1275
+ this._elapsedLabel() +
1276
+ this._hint());
1270
1277
  return;
1271
1278
  }
1272
1279
  this._startAnimated(label);
@@ -51,6 +51,81 @@ function stripPunct(tok) {
51
51
  t = t.slice(1);
52
52
  return t;
53
53
  }
54
+ // Characters that may directly precede an '@' for it to count as a file
55
+ // reference rather than a decorator/email. Without this guard `user@host` and
56
+ // `@Component` would be misread as paths.
57
+ function isRefBoundary(ch) {
58
+ return (ch === ' ' ||
59
+ ch === '\t' ||
60
+ ch === '\n' ||
61
+ ch === '\r' ||
62
+ ch === '(' ||
63
+ ch === '[' ||
64
+ ch === '{' ||
65
+ ch === String.fromCharCode(34) ||
66
+ ch === String.fromCharCode(39) ||
67
+ ch === '<');
68
+ }
69
+ // End of a reference token: whitespace, a closing bracket/quote, or an
70
+ // angle bracket (so `@a.ts>` stops at `>`).
71
+ function isRefEnd(ch) {
72
+ return (ch === ' ' ||
73
+ ch === '\t' ||
74
+ ch === '\n' ||
75
+ ch === '\r' ||
76
+ ch === ')' ||
77
+ ch === ']' ||
78
+ ch === '}' ||
79
+ ch === String.fromCharCode(34) ||
80
+ ch === String.fromCharCode(39) ||
81
+ ch === '<' ||
82
+ ch === '>');
83
+ }
84
+ // Strip trailing punctuation a path may have picked up from prose, e.g.
85
+ // "see @src/a.ts, please" -> `src/a.ts`.
86
+ function stripRefPunct(tok) {
87
+ let t = tok;
88
+ while (t.length && ',.;:!?'.includes(t[t.length - 1]))
89
+ t = t.slice(0, -1);
90
+ return t;
91
+ }
92
+ // Find all `@path` references in a task text (B5). Returns the unique paths in
93
+ // order of appearance. A reference is an '@' at a word boundary followed by a
94
+ // token that looks like a file name/path (a known extension). Implemented
95
+ // without regular expressions, like extractPathToken, to keep the source free
96
+ // of escaping pitfalls.
97
+ export function extractAtFileRefs(text) {
98
+ const s = String(text || '');
99
+ const chars = Array.from(s);
100
+ const n = chars.length;
101
+ const out = [];
102
+ const seen = new Set();
103
+ let i = 0;
104
+ while (i < n) {
105
+ if (chars[i] !== '@') {
106
+ i++;
107
+ continue;
108
+ }
109
+ const prev = i > 0 ? chars[i - 1] : '';
110
+ if (prev && !isRefBoundary(prev)) {
111
+ i++;
112
+ continue;
113
+ }
114
+ let j = i + 1;
115
+ let tok = '';
116
+ while (j < n && !isRefEnd(chars[j])) {
117
+ tok += chars[j];
118
+ j++;
119
+ }
120
+ const cleaned = stripRefPunct(tok);
121
+ if (cleaned && looksLikeToken(cleaned) && !seen.has(cleaned)) {
122
+ seen.add(cleaned);
123
+ out.push(cleaned);
124
+ }
125
+ i = j > i + 1 ? j : i + 1;
126
+ }
127
+ return out;
128
+ }
54
129
  function looksLikeToken(tok) {
55
130
  const t = String(tok || '');
56
131
  if (!t || t.length > 500)
@@ -0,0 +1,98 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ export function permissionsPath(workdir) {
4
+ return path.join(workdir, '.zames', 'permissions.json');
5
+ }
6
+ const ACTIONS = ['allow', 'deny', 'ask'];
7
+ function normAltion(v) {
8
+ if (typeof v !== 'string')
9
+ return null;
10
+ const s = v.toLowerCase();
11
+ return ACTIONS.includes(s) ? s : null;
12
+ }
13
+ /** Parse the JSON of a permissions file into a policy, or null when empty/broken. */
14
+ export function parsePermissions(raw) {
15
+ if (!raw || typeof raw !== 'object')
16
+ return null;
17
+ const obj = raw;
18
+ const defAction = normAltion(obj['default']) ?? 'allow';
19
+ const rules = Array.isArray(obj['rules']) ? obj['rules'] : [];
20
+ const out = [];
21
+ for (const item of rules) {
22
+ if (!item || typeof item !== 'object')
23
+ continue;
24
+ const e = item;
25
+ const action = normAltion(e['action']);
26
+ if (!action)
27
+ continue;
28
+ out.push({
29
+ action,
30
+ tool: typeof e['tool'] === 'string' ? e['tool'] : undefined,
31
+ command: typeof e['command'] === 'string' ? e['command'] : undefined,
32
+ path: typeof e['path'] === 'string' ? e['path'] : undefined,
33
+ });
34
+ }
35
+ if (!out.length && defAction === 'allow')
36
+ return null;
37
+ return { default: defAction, rules: out };
38
+ }
39
+ /** Read `.zames/permissions.json`. Missing/broken => null (no policy). */
40
+ export function loadPermissions(workdir) {
41
+ let raw;
42
+ try {
43
+ raw = fs.readFileSync(permissionsPath(workdir), 'utf-8');
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ try {
49
+ return parsePermissions(JSON.parse(raw));
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ function safeRegex(source) {
56
+ try {
57
+ return new RegExp(source);
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ // A file path may arrive in any of these keys depending on the tool.
64
+ // We collect them all and match the path regex against ANY of them.
65
+ const PATH_KEYS = ['path', 'file_path', 'notebook_path', 'from', 'to'];
66
+ export function decidePermission(policy, tool, args) {
67
+ if (!policy)
68
+ return { action: 'allow', reason: 'no policy' };
69
+ const a = (args ?? {});
70
+ const command = typeof a['command'] === 'string' ? a['command'] : '';
71
+ const paths = PATH_KEYS.map((k) => a[k]).filter((v) => typeof v === 'string');
72
+ for (const r of policy.rules) {
73
+ if (r.tool) {
74
+ const re = safeRegex(r.tool);
75
+ if (!re || !re.test(tool))
76
+ continue;
77
+ }
78
+ if (r.command) {
79
+ const re = safeRegex(r.command);
80
+ if (!re || !command || !re.test(command))
81
+ continue;
82
+ }
83
+ if (r.path) {
84
+ const re = safeRegex(r.path);
85
+ if (!re || !paths.some((p) => re.test(p)))
86
+ continue;
87
+ }
88
+ const desc = [
89
+ r.tool ? 'tool=' + r.tool : '',
90
+ r.command ? 'command=' + r.command : '',
91
+ r.path ? 'path=' + r.path : '',
92
+ ]
93
+ .filter(Boolean)
94
+ .join(' ');
95
+ return { action: r.action, reason: 'rule ' + desc };
96
+ }
97
+ return { action: policy.default, reason: 'default' };
98
+ }
package/dist/spinner.js CHANGED
@@ -151,7 +151,10 @@ export function createSpinner(locale = 'ru') {
151
151
  // so the countdown refreshes without resetting the dots.
152
152
  if (animating && dotTimer && spinner) {
153
153
  animBase = theme.brown(stripEllipsis(label));
154
- spinner.text = animBase + renderDots(dotPhase) + elapsedLabel() + HINT;
154
+ // Same tail as the timer renders (elapsed + tasks + hint), or the
155
+ // per-second update blinks the elapsed label / task badge away.
156
+ spinner.text =
157
+ animBase + renderDots(dotPhase) + elapsedLabel() + tasksBadge() + HINT;
155
158
  return;
156
159
  }
157
160
  startAnimated(label);
@@ -1,3 +1,4 @@
1
+ import path from 'path';
1
2
  import { translate } from './i18n.js';
2
3
  // The dev-mode BACKLOG instruction. English (agent-facing) like the rest of the
3
4
  // prompt. Deliberately narrow: one short note at most, and only when the idea
@@ -45,6 +46,20 @@ export function renderContextSection(context) {
45
46
  f.content +
46
47
  '\n';
47
48
  }
49
+ // B6: nested AGENTS.md/MEMORY.md from the directories this task touches.
50
+ // Deliberately a SEPARATE section: these apply only to part of the tree and
51
+ // must not be mistaken for project-wide rules.
52
+ for (const f of context.scopedAgents || []) {
53
+ out +=
54
+ '\n## Scoped instructions (' +
55
+ f.path +
56
+ ')\n\n' +
57
+ 'These rules apply ONLY to files under ' +
58
+ path.dirname(f.path) +
59
+ ' (the current task touches that directory). Follow them for those files, in addition to the project instructions above.\n\n' +
60
+ f.content +
61
+ '\n';
62
+ }
48
63
  if (context.memory.length) {
49
64
  out +=
50
65
  '\nWhen you learn a durable fact about this project or the operator preferences (build quirks, ' +
package/logo-small.jpg ADDED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.61.0",
3
+ "version": "2.63.1",
4
4
  "description": "Terminal coding agent that drives chat.deepseek.com through Playwright: reads and edits files, runs commands, commits to git.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,8 @@
12
12
  "README.md",
13
13
  "CHANGELOG.md",
14
14
  "LICENSE",
15
- "logo.jpg"
15
+ "logo.jpg",
16
+ "logo-small.jpg"
16
17
  ],
17
18
  "scripts": {
18
19
  "start": "node ./dist/index.js",
@@ -28,6 +28,8 @@ const FLOORS = {
28
28
  'tools.ts': 90,
29
29
  'system-prompt.ts': 90,
30
30
  'undo.ts': 85,
31
+ 'checkpoint.ts': 80,
32
+ 'permissions.ts': 85,
31
33
  }
32
34
 
33
35
  const run = spawnSync(