@thegitai/cli 1.0.0-beta.2 → 1.0.0-beta.21

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.
Files changed (55) hide show
  1. package/README.md +37 -2
  2. package/dist/bin/ai.js +148 -75
  3. package/dist/parsers/NOTICE +18 -0
  4. package/dist/src/agent-mode.js +5 -0
  5. package/dist/src/api/auth.js +6 -4
  6. package/dist/src/api/browser-login.js +7 -41
  7. package/dist/src/api/chat.js +77 -20
  8. package/dist/src/api/http.js +81 -4
  9. package/dist/src/api/models.js +26 -18
  10. package/dist/src/artifact-policy.js +12 -0
  11. package/dist/src/background-jobs.js +410 -0
  12. package/dist/src/cli-args.js +60 -0
  13. package/dist/src/client-environment.js +129 -0
  14. package/dist/src/colors.js +50 -0
  15. package/dist/src/core/clipboard.js +75 -0
  16. package/dist/src/core/image-path-extractor.js +144 -0
  17. package/dist/src/edit-journal.js +39 -6
  18. package/dist/src/executor.js +48 -12
  19. package/dist/src/help-text.js +24 -5
  20. package/dist/src/markdown-renderer.js +1 -1
  21. package/dist/src/patcher.js +17 -2
  22. package/dist/src/scanner.js +58 -17
  23. package/dist/src/scratch-dir.js +57 -0
  24. package/dist/src/secret-preview.js +0 -10
  25. package/dist/src/session-safety.js +64 -31
  26. package/dist/src/session-store.js +0 -1
  27. package/dist/src/todo-list.js +106 -0
  28. package/dist/src/tool-executor.js +164 -18
  29. package/dist/src/tools/delete-file.js +1 -1
  30. package/dist/src/tools/index.js +8 -0
  31. package/dist/src/tools/patch-file.js +16 -2
  32. package/dist/src/tools/path-suggest.js +139 -0
  33. package/dist/src/tools/read-document.js +15 -4
  34. package/dist/src/tools/read-file.js +23 -7
  35. package/dist/src/tools/replace-document-text.js +234 -0
  36. package/dist/src/tools/restore-checkpoint.js +1 -1
  37. package/dist/src/tools/run-command.js +83 -16
  38. package/dist/src/tools/run-node-script.js +3 -1
  39. package/dist/src/tools/shell-job-kill.js +48 -0
  40. package/dist/src/tools/shell-job-output.js +51 -0
  41. package/dist/src/tools/str-replace.js +16 -2
  42. package/dist/src/tools/undo-edit.js +7 -5
  43. package/dist/src/tools/update-todos.js +27 -0
  44. package/dist/src/tools/write-file.js +14 -1
  45. package/dist/src/tree-sitter-runtime.js +8 -1
  46. package/dist/src/ui/repl.js +315 -24
  47. package/dist/src/ui/tui/bridge.js +2 -6
  48. package/dist/src/ui/tui/build-frame.js +225 -25
  49. package/dist/src/ui/tui/shell-input.js +42 -5
  50. package/dist/src/version.js +29 -0
  51. package/dist/vendor/web-tree-sitter/LICENSE +21 -0
  52. package/dist/vendor/web-tree-sitter/NOTICE +13 -0
  53. package/dist/vendor/web-tree-sitter/web-tree-sitter.cjs +4063 -0
  54. package/dist/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  55. package/package.json +14 -15
@@ -1,14 +1,12 @@
1
1
  import { execSync } from 'child_process';
2
- import { readFileSync, statSync } from 'fs';
3
- import { glob } from 'glob';
2
+ import { readdirSync, readFileSync, statSync } from 'fs';
4
3
  import path from 'path';
5
4
  import { getNodePrimarySignature, getStructuralChildren, parseRepoSource, } from './tree-sitter-runtime.js';
6
- import { ARTIFACT_FALLBACK_IGNORE_GLOBS, ARTIFACT_IGNORE_DIRS, ARTIFACT_IGNORE_FILES, ARTIFACT_INSPECT_BLOCK_DIRS, BINARY_ARTIFACT_EXTENSIONS, isSensitiveProjectPath, shouldIgnoreArtifactPath, } from './artifact-policy.js';
5
+ import { ARTIFACT_IGNORE_DIRS, ARTIFACT_IGNORE_FILES, ARTIFACT_INSPECT_BLOCK_DIRS, BINARY_ARTIFACT_EXTENSIONS, isSensitiveProjectPath, shouldIgnoreArtifactPath, } from './artifact-policy.js';
7
6
  const BINARY_EXTENSIONS = BINARY_ARTIFACT_EXTENSIONS;
8
7
  const ALWAYS_IGNORE_FILES = ARTIFACT_IGNORE_FILES;
9
8
  export const ALWAYS_IGNORE_DIRS = ARTIFACT_IGNORE_DIRS;
10
9
  export const BLOCKED_PATH_INSPECT_DIRS = ARTIFACT_INSPECT_BLOCK_DIRS;
11
- const FALLBACK_IGNORE = ARTIFACT_FALLBACK_IGNORE_GLOBS;
12
10
  export const SCANNER_MAX_SOURCE_FILE_BYTES = 100 * 1024;
13
11
  const MAX_FILE_SIZE = SCANNER_MAX_SOURCE_FILE_BYTES;
14
12
  const MAX_CHUNKS = 2000;
@@ -16,26 +14,69 @@ const TARGET_CHUNK_CHARS = 1800;
16
14
  const MAX_CHUNK_CHARS = 2800;
17
15
  const FALLBACK_OVERLAP_LINES = 10;
18
16
  const MAX_STRUCTURE_DEPTH = 2;
17
+ function parseGitLsFilesOutput(output) {
18
+ return String(output)
19
+ .split('\0')
20
+ .filter(Boolean)
21
+ .filter((filePath) => !shouldIgnorePath(filePath));
22
+ }
19
23
  function getFiles(rootDir, { limit = Infinity } = {}) {
20
24
  try {
21
- const output = execSync('git ls-files --cached --others --exclude-standard', { cwd: rootDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
22
- const files = output
23
- .split('\n')
24
- .filter(Boolean)
25
- .filter((filePath) => !shouldIgnorePath(filePath));
25
+ const output = execSync('git ls-files -z --cached --others --exclude-standard', { cwd: rootDir, stdio: ['pipe', 'pipe', 'pipe'] });
26
+ const files = parseGitLsFilesOutput(output);
26
27
  return Number.isFinite(limit) ? files.slice(0, limit) : files;
27
28
  }
28
29
  catch {
29
- return glob
30
- .sync('**/*', {
31
- cwd: rootDir,
32
- nodir: true,
33
- dot: false,
34
- ignore: FALLBACK_IGNORE,
35
- })
36
- .slice(0, Number.isFinite(limit) ? limit : undefined);
30
+ return walkProjectFilesFallback(rootDir, Number.isFinite(limit) ? limit : Infinity);
37
31
  }
38
32
  }
33
+ const FALLBACK_LOCKFILES = new Set([
34
+ 'package-lock.json',
35
+ 'yarn.lock',
36
+ 'pnpm-lock.yaml',
37
+ ]);
38
+ function isFallbackIgnoredFile(relPath, fileName) {
39
+ if (shouldIgnorePath(relPath))
40
+ return true;
41
+ if (fileName.endsWith('.lock'))
42
+ return true;
43
+ return FALLBACK_LOCKFILES.has(fileName);
44
+ }
45
+ function walkProjectFilesFallback(rootDir, limit) {
46
+ const results = [];
47
+ const visit = (relDir) => {
48
+ if (results.length >= limit)
49
+ return;
50
+ let entries;
51
+ try {
52
+ entries = readdirSync(path.join(rootDir, relDir), {
53
+ withFileTypes: true,
54
+ });
55
+ }
56
+ catch {
57
+ return;
58
+ }
59
+ for (const entry of entries) {
60
+ if (results.length >= limit)
61
+ return;
62
+ const name = entry.name;
63
+ if (name.startsWith('.'))
64
+ continue;
65
+ const relPath = relDir ? `${relDir}/${name}` : name;
66
+ if (entry.isDirectory()) {
67
+ if (ALWAYS_IGNORE_DIRS.has(name) || shouldIgnoreArtifactPath(relPath)) {
68
+ continue;
69
+ }
70
+ visit(relPath);
71
+ }
72
+ else if (entry.isFile() && !isFallbackIgnoredFile(relPath, name)) {
73
+ results.push(relPath);
74
+ }
75
+ }
76
+ };
77
+ visit('');
78
+ return results;
79
+ }
39
80
  function shouldSkipFile(relPath, stat) {
40
81
  if (shouldIgnorePath(relPath))
41
82
  return true;
@@ -0,0 +1,57 @@
1
+ import { chmodSync, lstatSync, mkdirSync, mkdtempSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ let cachedScratchDir = null;
5
+ export function sessionScratchDir() {
6
+ if (!cachedScratchDir) {
7
+ cachedScratchDir = path.join(os.tmpdir(), `thegitai-${process.pid}`);
8
+ }
9
+ return cachedScratchDir;
10
+ }
11
+ function isSquattedScratchRoot(dir) {
12
+ try {
13
+ const st = lstatSync(dir);
14
+ if (st.isSymbolicLink() || !st.isDirectory())
15
+ return true;
16
+ if (process.platform !== 'win32' &&
17
+ typeof process.getuid === 'function' &&
18
+ st.uid !== process.getuid()) {
19
+ return true;
20
+ }
21
+ return false;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ export function ensureSessionScratchDir() {
28
+ const dir = sessionScratchDir();
29
+ try {
30
+ if (isSquattedScratchRoot(dir)) {
31
+ cachedScratchDir = mkdtempSync(path.join(os.tmpdir(), 'thegitai-'));
32
+ return cachedScratchDir;
33
+ }
34
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
35
+ if (process.platform !== 'win32') {
36
+ chmodSync(dir, 0o700);
37
+ }
38
+ }
39
+ catch {
40
+ }
41
+ return sessionScratchDir();
42
+ }
43
+ export function isInsideTheGitAiScratch(absPath) {
44
+ const tempRoot = path.resolve(os.tmpdir());
45
+ const relPath = path.relative(tempRoot, path.resolve(absPath));
46
+ if (!relPath || relPath.startsWith('..') || path.isAbsolute(relPath)) {
47
+ return false;
48
+ }
49
+ const first = relPath.split(/[\\/]/, 1)[0] ?? '';
50
+ if (!/^thegitai(?:$|[-.])/i.test(first)) {
51
+ return false;
52
+ }
53
+ if (/[*?[\]{}]/.test(first)) {
54
+ return false;
55
+ }
56
+ return !isSquattedScratchRoot(path.join(tempRoot, first));
57
+ }
@@ -6,11 +6,7 @@ const PRIVATE_KEY_REDACTION = '[REDACTED: private key]';
6
6
  const SENSITIVE_JSON_KEY_PATTERN = /^(?:private[_-]?key|secret|api[_-]?key|password|client_secret|refresh_token|access_token|id_token|auth_provider_x509_cert_url)$/i;
7
7
  const PEM_BLOCK_PATTERN = /-----BEGIN [^-]*(?:PRIVATE KEY|SECRET KEY|OPENSSH PRIVATE KEY)[\s\S]*?-----END [^-]*(?:PRIVATE KEY|SECRET KEY|OPENSSH PRIVATE KEY)-----/gi;
8
8
  const PEM_SECRET_PATH_PATTERN = /\.(?:pem|key)$/i;
9
- // Password embedded in a connection-string URL, e.g.
10
- // `postgresql://user:PASS@host`. Redacted from shell output so secrets in
11
- // commands like `cat .env` do not leak into history or telemetry.
12
9
  const URL_CREDENTIALS_PATTERN = /\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)(@)/gi;
13
- /** Redact only userinfo passwords in connection-string URLs (zero false positives). */
14
10
  export function redactConnectionStringCredentials(text) {
15
11
  return text.replace(URL_CREDENTIALS_PATTERN, (_match, prefix, _password, at) => `${prefix}${VALUE_REDACTION}${at}`);
16
12
  }
@@ -72,12 +68,6 @@ export function isDotenvLikePath(value) {
72
68
  const base = path.posix.basename(text.replace(/\\/g, '/'));
73
69
  return DOTENV_BASENAME_PATTERN.test(base);
74
70
  }
75
- /**
76
- * True only for a clean dotenv file we can safely show with keys visible and
77
- * values tokenized: no PEM block, not JSON, and every non-blank/non-comment line
78
- * is a `KEY=VALUE` assignment. Anything ambiguous (a stray line that might be a
79
- * raw secret) returns false so the caller keeps the opaque blackout instead.
80
- */
81
71
  export function looksLikeEditableDotenv(content) {
82
72
  PEM_BLOCK_PATTERN.lastIndex = 0;
83
73
  if (PEM_BLOCK_PATTERN.test(content))
@@ -2,14 +2,14 @@ import { execFileSync } from 'node:child_process';
2
2
  import { lstatSync, readdirSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { ARTIFACT_IGNORE_DIRS, normalizeProjectRelativePath, shouldIgnoreArtifactPath, } from './artifact-policy.js';
5
- import { hashContent, readFileEditSnapshot } from './edit-journal.js';
6
- import { deleteProjectFile, resolveProjectPath, writeProjectFile } from './patcher.js';
5
+ import { hashBytes, readFileEditSnapshot, storedContentBuffer, } from './edit-journal.js';
6
+ import { deleteProjectFile, resolveProjectPath, writeProjectFile, writeProjectFileBuffer, } from './patcher.js';
7
7
  import { removeIndexFile, upsertIndexFile } from './project-index.js';
8
8
  const MAX_CHECKPOINTS = 20;
9
9
  const MAX_SESSION_EDITS = 500;
10
10
  const MAX_READ_RECORDS = 200;
11
11
  const MAX_REDACTION_TOKENS = 500;
12
- const MAX_SNAPSHOT_CONTENT_CHARS = 1_000_000;
12
+ const MAX_SNAPSHOT_CONTENT_CHARS = 8_000_000;
13
13
  const MAX_MUTATION_SCAN_FILES = 2000;
14
14
  const MAX_BASELINE_CONTENT_BYTES = 50 * 1024 * 1024;
15
15
  export function createSessionSafetyState() {
@@ -34,6 +34,14 @@ function normalizeEditOperation(value) {
34
34
  ? text
35
35
  : null;
36
36
  }
37
+ function normalizeStoredContentEncoding(value) {
38
+ return value === 'base64' ? 'base64' : 'utf8';
39
+ }
40
+ function writeStoredProjectFile(rootDir, filePath, content, encoding) {
41
+ return encoding === 'base64'
42
+ ? writeProjectFileBuffer(rootDir, filePath, storedContentBuffer(content, encoding))
43
+ : writeProjectFile(rootDir, filePath, content);
44
+ }
37
45
  export function normalizeSessionSafetyState(value) {
38
46
  const raw = value && typeof value === 'object' ? value : {};
39
47
  const safety = createSessionSafetyState();
@@ -57,6 +65,7 @@ export function normalizeSessionSafetyState(value) {
57
65
  exists: file.exists === true,
58
66
  hash: typeof file.hash === 'string' ? file.hash : null,
59
67
  content: typeof file.content === 'string' ? file.content : null,
68
+ contentEncoding: normalizeStoredContentEncoding(file.contentEncoding),
60
69
  skipped: typeof file.skipped === 'string' && file.skipped.trim()
61
70
  ? file.skipped.trim()
62
71
  : undefined,
@@ -143,6 +152,7 @@ export function normalizeSessionSafetyState(value) {
143
152
  beforeHash: typeof item.beforeHash === 'string' ? item.beforeHash : null,
144
153
  afterHash: typeof item.afterHash === 'string' ? item.afterHash : null,
145
154
  beforeContent: typeof item.beforeContent === 'string' ? item.beforeContent : null,
155
+ beforeContentEncoding: normalizeStoredContentEncoding(item.beforeContentEncoding),
146
156
  createdAt: typeof item.createdAt === 'string' && item.createdAt
147
157
  ? item.createdAt
148
158
  : new Date().toISOString(),
@@ -242,7 +252,10 @@ export function mergeLocalSessionSafetyState(local, incoming) {
242
252
  for (const file of checkpoint.files) {
243
253
  if (file.content == null)
244
254
  continue;
245
- localCheckpointContent.set(`${checkpoint.id}\0${file.filePath}\0${file.hash ?? ''}`, file.content);
255
+ localCheckpointContent.set(`${checkpoint.id}\0${file.filePath}\0${file.hash ?? ''}`, {
256
+ content: file.content,
257
+ contentEncoding: file.contentEncoding,
258
+ });
246
259
  }
247
260
  }
248
261
  next.checkpoints = next.checkpoints.map((checkpoint) => ({
@@ -251,17 +264,35 @@ export function mergeLocalSessionSafetyState(local, incoming) {
251
264
  if (file.content != null)
252
265
  return file;
253
266
  const content = localCheckpointContent.get(`${checkpoint.id}\0${file.filePath}\0${file.hash ?? ''}`);
254
- return content == null ? file : { ...file, content };
267
+ return content == null
268
+ ? file
269
+ : {
270
+ ...file,
271
+ content: content.content,
272
+ contentEncoding: content.contentEncoding,
273
+ };
255
274
  }),
256
275
  }));
257
276
  const localBeforeContent = new Map(localState.sessionEdits
258
277
  .filter((edit) => edit.beforeContent != null)
259
- .map((edit) => [edit.id, edit.beforeContent]));
278
+ .map((edit) => [
279
+ edit.id,
280
+ {
281
+ beforeContent: edit.beforeContent,
282
+ beforeContentEncoding: edit.beforeContentEncoding,
283
+ },
284
+ ]));
260
285
  next.sessionEdits = next.sessionEdits.map((edit) => {
261
286
  if (edit.beforeContent != null)
262
287
  return edit;
263
288
  const beforeContent = localBeforeContent.get(edit.id);
264
- return beforeContent == null ? edit : { ...edit, beforeContent };
289
+ return beforeContent == null
290
+ ? edit
291
+ : {
292
+ ...edit,
293
+ beforeContent: beforeContent.beforeContent,
294
+ beforeContentEncoding: beforeContent.beforeContentEncoding,
295
+ };
265
296
  });
266
297
  return next;
267
298
  }
@@ -279,6 +310,7 @@ function readCheckpointSnapshot(rootDir, filePath) {
279
310
  exists: false,
280
311
  hash: null,
281
312
  content: null,
313
+ contentEncoding: 'utf8',
282
314
  skipped: `Refusing to checkpoint ignored or out-of-project path: ${filePath}`,
283
315
  };
284
316
  }
@@ -289,6 +321,7 @@ function readCheckpointSnapshot(rootDir, filePath) {
289
321
  exists: snapshot.exists,
290
322
  hash: snapshot.hash,
291
323
  content: null,
324
+ contentEncoding: snapshot.contentEncoding,
292
325
  skipped: snapshot.error,
293
326
  };
294
327
  }
@@ -298,6 +331,7 @@ function readCheckpointSnapshot(rootDir, filePath) {
298
331
  exists: snapshot.exists,
299
332
  hash: snapshot.hash,
300
333
  content: null,
334
+ contentEncoding: snapshot.contentEncoding,
301
335
  skipped: `File is too large to checkpoint (${snapshot.content.length} chars).`,
302
336
  };
303
337
  }
@@ -306,6 +340,7 @@ function readCheckpointSnapshot(rootDir, filePath) {
306
340
  exists: snapshot.exists,
307
341
  hash: snapshot.hash,
308
342
  content: snapshot.content,
343
+ contentEncoding: snapshot.contentEncoding,
309
344
  };
310
345
  }
311
346
  export function createPromptCheckpoint(state, label, turnId) {
@@ -419,17 +454,6 @@ export function resolveRedactionTokens(state, text, filePath, hash) {
419
454
  }
420
455
  const DOTENV_ASSIGNMENT_PATTERN = /^(\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=)(.*)$/;
421
456
  const DOTENV_COMMENT_PATTERN = /^(\s*#\s*)(\S.*)$/;
422
- /**
423
- * Redact a dotenv file's values while leaving keys visible. Every assignment's
424
- * value is replaced with a stable, reversible token so the agent can see the
425
- * file's structure and edit it (remove or replace lines) without ever seeing a
426
- * secret value; `resolveRedactionTokens` swaps the real values back on write.
427
- * Comment bodies are tokenized too, because developers routinely leave
428
- * commented-out credentials in dotenv files and those must not leak where the
429
- * opaque preview would have hidden them. Callers must confirm the content is
430
- * clean dotenv (`looksLikeEditableDotenv`) first so the only non-assignment
431
- * lines reaching here are blanks and comments.
432
- */
433
457
  export function redactDotenvWithStableTokens(state, content, filePath, hash) {
434
458
  const tokens = [];
435
459
  const redactedLines = content.split('\n').map((line) => {
@@ -455,14 +479,6 @@ export function redactDotenvWithStableTokens(state, content, filePath, hash) {
455
479
  });
456
480
  return { content: redactedLines.join('\n'), tokens };
457
481
  }
458
- /**
459
- * The redaction-token registry is capped at `MAX_REDACTION_TOKENS`; a read that
460
- * emits more tokens than that would evict its own oldest tokens, leaving
461
- * `[REDACTED:n]` markers in the preview that `write_file`/`str_replace` can no
462
- * longer resolve (silently writing the literal token back). So a dotenv file
463
- * with more tokenizable lines than the budget must not use the editable preview
464
- * — the caller falls back to the opaque blackout instead.
465
- */
466
482
  export function dotenvFitsRedactionBudget(content) {
467
483
  let count = 0;
468
484
  for (const line of content.split('\n')) {
@@ -661,7 +677,7 @@ export async function restoreCheckpointFiles(args) {
661
677
  const before = readFileEditSnapshot(args.rootDir, snapshot.filePath);
662
678
  try {
663
679
  if (snapshot.exists) {
664
- const result = writeProjectFile(args.rootDir, snapshot.filePath, snapshot.content ?? '');
680
+ const result = writeStoredProjectFile(args.rootDir, snapshot.filePath, snapshot.content ?? '', snapshot.contentEncoding);
665
681
  if (result.changed)
666
682
  changed = true;
667
683
  if (syncPolicy)
@@ -693,7 +709,7 @@ export async function restoreCheckpointFiles(args) {
693
709
  if (item.before.content == null) {
694
710
  throw new Error('previous file content is unavailable');
695
711
  }
696
- writeProjectFile(args.rootDir, item.snapshot.filePath, item.before.content);
712
+ writeStoredProjectFile(args.rootDir, item.snapshot.filePath, item.before.content, item.before.contentEncoding);
697
713
  if (syncPolicy) {
698
714
  await upsertIndexFile(args.projectIndex, item.snapshot.filePath);
699
715
  }
@@ -749,6 +765,7 @@ export async function restoreCheckpointFiles(args) {
749
765
  beforeHash: item.before.hash,
750
766
  afterHash: item.after.hash,
751
767
  beforeContent: item.before.content,
768
+ beforeContentEncoding: item.before.contentEncoding,
752
769
  checkpointId: checkpoint.id,
753
770
  });
754
771
  restored.push({
@@ -777,6 +794,18 @@ function git(args, cwd) {
777
794
  return null;
778
795
  }
779
796
  }
797
+ function gitBuffer(args, cwd) {
798
+ try {
799
+ return execFileSync('git', args, {
800
+ cwd,
801
+ stdio: ['ignore', 'pipe', 'ignore'],
802
+ timeout: 10_000,
803
+ });
804
+ }
805
+ catch {
806
+ return null;
807
+ }
808
+ }
780
809
  export function findGitRoot(startDir) {
781
810
  const root = git(['rev-parse', '--show-toplevel'], startDir);
782
811
  return root ? path.resolve(root) : null;
@@ -897,14 +926,15 @@ function readGitHeadSnapshot(rootDir, filePath) {
897
926
  return null;
898
927
  const abs = resolveProjectPath(rootDir, filePath);
899
928
  const relToGit = path.relative(gitRoot, abs).split(path.sep).join('/');
900
- const content = git(['show', `HEAD:${relToGit}`], gitRoot);
929
+ const content = gitBuffer(['show', `HEAD:${relToGit}`], gitRoot);
901
930
  if (content == null)
902
931
  return null;
903
932
  return {
904
933
  filePath,
905
934
  exists: true,
906
- hash: hashContent(content),
907
- content,
935
+ hash: hashBytes(content),
936
+ content: content.toString('base64'),
937
+ contentEncoding: 'base64',
908
938
  };
909
939
  }
910
940
  export function captureMutationBaseline(rootDir, options) {
@@ -932,6 +962,7 @@ export function captureMutationBaseline(rootDir, options) {
932
962
  exists: true,
933
963
  hash: null,
934
964
  content: null,
965
+ contentEncoding: 'utf8',
935
966
  skipped: `Pre-command snapshot skipped: project baseline content budget exceeded (${contentBudget} bytes). Restore for this file is not available.`,
936
967
  });
937
968
  continue;
@@ -978,6 +1009,7 @@ export function collectCommandMutations(args) {
978
1009
  exists: false,
979
1010
  hash: null,
980
1011
  content: null,
1012
+ contentEncoding: 'utf8',
981
1013
  };
982
1014
  if (before.hash === after.hash && !before.skipped)
983
1015
  continue;
@@ -1000,6 +1032,7 @@ export function collectCommandMutations(args) {
1000
1032
  beforeHash: before.hash,
1001
1033
  afterHash: after.hash,
1002
1034
  beforeContent: before.content,
1035
+ beforeContentEncoding: before.contentEncoding,
1003
1036
  checkpointId: args.checkpointId,
1004
1037
  });
1005
1038
  records.push(record);
@@ -122,7 +122,6 @@ function loadAllSnapshots(rootDir, env = process.env) {
122
122
  snapshots.push(loadSnapshotFile(filePath, rootDir));
123
123
  }
124
124
  catch {
125
- // Skip corrupted snapshots silently — customers have no actionable debug path here.
126
125
  }
127
126
  }
128
127
  return snapshots.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
@@ -0,0 +1,106 @@
1
+ export const MAX_TODO_ITEMS = 20;
2
+ export const MAX_TODO_TEXT_CHARS = 160;
3
+ let items = [];
4
+ let activeSessionId = null;
5
+ const STATUS_ALIASES = {
6
+ pending: 'pending',
7
+ todo: 'pending',
8
+ not_started: 'pending',
9
+ in_progress: 'in_progress',
10
+ active: 'in_progress',
11
+ doing: 'in_progress',
12
+ completed: 'completed',
13
+ complete: 'completed',
14
+ done: 'completed',
15
+ };
16
+ function normalizeStatus(raw) {
17
+ const key = String(raw ?? '')
18
+ .trim()
19
+ .toLowerCase()
20
+ .replace(/[-\s]+/g, '_');
21
+ return STATUS_ALIASES[key] ?? null;
22
+ }
23
+ export function isCompletedStatus(raw) {
24
+ return normalizeStatus(raw) === 'completed';
25
+ }
26
+ const TODOS_ARG_ALIASES = ['items', 'todo_list', 'todoList', 'list', 'tasks'];
27
+ export function extractTodosArg(args) {
28
+ if (!args || typeof args !== 'object')
29
+ return undefined;
30
+ const record = args;
31
+ if (record.todos !== undefined)
32
+ return record.todos;
33
+ for (const key of TODOS_ARG_ALIASES) {
34
+ if (record[key] !== undefined)
35
+ return record[key];
36
+ }
37
+ return undefined;
38
+ }
39
+ export function setTodoSession(sessionId) {
40
+ const next = String(sessionId ?? '').trim() || null;
41
+ if (activeSessionId !== next) {
42
+ items = [];
43
+ }
44
+ activeSessionId = next;
45
+ }
46
+ export function listTodos() {
47
+ return items.map((item) => ({ ...item }));
48
+ }
49
+ export function getTodoSnapshot() {
50
+ return {
51
+ items: listTodos(),
52
+ completedCount: items.filter((item) => item.status === 'completed').length,
53
+ totalCount: items.length,
54
+ };
55
+ }
56
+ export function clearTodos() {
57
+ items = [];
58
+ }
59
+ export function replaceTodos(raw) {
60
+ if (!Array.isArray(raw)) {
61
+ return { ok: false, error: 'todos must be an array of { text, status } items.' };
62
+ }
63
+ const normalizations = [];
64
+ if (raw.length > MAX_TODO_ITEMS) {
65
+ return {
66
+ ok: false,
67
+ error: `todos supports at most ${MAX_TODO_ITEMS} items; got ${raw.length}. Use fewer, broader steps.`,
68
+ };
69
+ }
70
+ const next = [];
71
+ let sawInProgress = false;
72
+ for (const [index, entry] of raw.entries()) {
73
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
74
+ return { ok: false, error: `todos[${index}] must be an object with text and status.` };
75
+ }
76
+ const text = String(entry.text ?? '')
77
+ .replace(/\s+/g, ' ')
78
+ .trim();
79
+ if (!text) {
80
+ return { ok: false, error: `todos[${index}].text must be a non-empty string.` };
81
+ }
82
+ const status = normalizeStatus(entry.status);
83
+ if (!status) {
84
+ return {
85
+ ok: false,
86
+ error: `todos[${index}].status must be one of: pending, in_progress, completed.`,
87
+ };
88
+ }
89
+ let boundedText = text;
90
+ if (boundedText.length > MAX_TODO_TEXT_CHARS) {
91
+ boundedText = `${boundedText.slice(0, MAX_TODO_TEXT_CHARS - 1)}…`;
92
+ normalizations.push(`todos[${index}].text truncated to ${MAX_TODO_TEXT_CHARS} chars`);
93
+ }
94
+ let finalStatus = status;
95
+ if (status === 'in_progress') {
96
+ if (sawInProgress) {
97
+ finalStatus = 'pending';
98
+ normalizations.push(`todos[${index}] demoted to pending: only one item can be in_progress`);
99
+ }
100
+ sawInProgress = true;
101
+ }
102
+ next.push({ text: boundedText, status: finalStatus });
103
+ }
104
+ items = next;
105
+ return { ok: true, snapshot: getTodoSnapshot(), normalizations };
106
+ }