@mnemonik/shared 7.120.0 → 7.120.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.
@@ -47,6 +47,24 @@
47
47
  * tree clean by the time it is observed, so the dirty diff sees nothing.
48
48
  * HEAD is recorded per root at every observation; only commits made since
49
49
  * the last observation count, and never a merge.
50
+ *
51
+ * Three more (2026-10-07), found by a session whose push was gated a dozen
52
+ * times for edits it never made:
53
+ * - A nested repository or worktree directory is not an edit. `git status`
54
+ * lists one as a single `dir/` entry when its parent does not ignore it, so
55
+ * creating a worktree under the checkout counted as a file edit.
56
+ * - A shell call that runs a git command importing other content (merge,
57
+ * cherry-pick, rebase, pull, am, revert, reset, stash pop/apply) credits
58
+ * nothing: what it leaves dirty or commits was written elsewhere.
59
+ * - A pending report for a path whose repository directory no longer exists
60
+ * (a removed worktree) is dropped, not re-sent: the files are gone because
61
+ * the checkout was removed, not because this session deleted them.
62
+ *
63
+ * Each report also carries where the edit happened (`editScope`): the git
64
+ * worktree and repository of the file, or a mark that the file lies outside
65
+ * the project's repository (/tmp, another repository, a `.git` directory). The
66
+ * server counts only project edits, and gates a push on the uncheckpointed
67
+ * files it carries (`describeHandoffs`).
50
68
  */
51
69
  /**
52
70
  * The current dirty set as absolute paths, or null when cwd is not inside a
@@ -74,6 +92,79 @@ export declare function diffGitDirtySnapshot(snapshotFile: string, cwd: string,
74
92
  * report pre-existing dirt.
75
93
  */
76
94
  export declare function addPathsToGitDirtySnapshot(snapshotFile: string, paths: string[]): void;
95
+ /** Where a reported edit happened, as sent to track-ide-edit. */
96
+ export interface EditScope {
97
+ /** The git worktree root that holds the file. Absent when unknown or not in git. */
98
+ editRoot?: string;
99
+ /**
100
+ * The repository (git common directory) that holds the file: the same for
101
+ * every worktree of one repository, so an edit made in a subagent's worktree
102
+ * still matches a push of the merged result from the main checkout.
103
+ */
104
+ editRepo?: string;
105
+ /** The file is not project work: outside the project's repository, or inside `.git`. */
106
+ outsideProject?: true;
107
+ }
108
+ /**
109
+ * Where `filePath` lies relative to the project the agent works in.
110
+ *
111
+ * `anchorDir` is the project directory: the host's project root when it has
112
+ * one (Claude Code's CLAUDE_PROJECT_DIR), else the event's cwd. A file is
113
+ * project work when it is in the same git repository as the anchor (any of
114
+ * its worktrees), or, when the anchor is not in git, inside the anchor.
115
+ * Anything that cannot be determined is reported as unknown (no claim), so an
116
+ * unreadable directory never hides an edit.
117
+ */
118
+ export declare function editScope(filePath: string, cwd: string, anchorDir?: string): EditScope;
119
+ /** One handoff in a shell command. */
120
+ export interface HandoffTarget {
121
+ /** Where it runs: null means the event's cwd. */
122
+ directory: string | null;
123
+ /** For a git push: the revisions whose new commits it sends. Absent for other handoffs. */
124
+ revisions?: string[];
125
+ }
126
+ /**
127
+ * The handoffs a shell command performs. Empty when it hands nothing off.
128
+ * Quoted text and here-document bodies are data, not commands; a `git push`
129
+ * that only deletes refs or is a dry run is not a handoff. `cd <dir>` before
130
+ * the handoff, and `git -C <dir>`, move it; a directory that cannot be known
131
+ * from the text (`cd ~`, `cd $X`, `cd -`) falls back to the event's cwd.
132
+ */
133
+ export declare function parseHandoffCommand(toolName: string, command: string): HandoffTarget[];
134
+ /** Most commits and files a push description carries. */
135
+ /**
136
+ * Bounds of a push description. Past any of them the description says only
137
+ * the repository, and the server falls back to every uncheckpointed edit in
138
+ * it: a description must never be partial, because a partial one could leave
139
+ * an edited file out and quiet the gate. The server's request schema accepts
140
+ * exactly these bounds, so a description can never fail the whole request.
141
+ */
142
+ export declare const HANDOFF_LIMITS: {
143
+ readonly commits: 200;
144
+ readonly files: 500;
145
+ readonly pathLength: 1024;
146
+ readonly handoffs: 10;
147
+ };
148
+ /** What one handoff carries, as the host sends it to the server's handoff gate. */
149
+ export interface HandoffDescription {
150
+ /** The repository (git common directory) it runs in. Absent when not in git or unknown. */
151
+ repo?: string;
152
+ /**
153
+ * For a git push: the repository-relative paths changed by the commits it
154
+ * sends (those on no remote-tracking ref). Absent when unknown, and for
155
+ * other handoffs (a deploy ships the working tree, not commits).
156
+ */
157
+ files?: string[];
158
+ }
159
+ /**
160
+ * Per handoff in a shell command, what it carries: its repository and, for a
161
+ * git push, the files its new commits change. The server gates the handoff
162
+ * only when one of those files was edited by the session (any of its agents)
163
+ * after its last checkpoint. Empty when the command hands nothing off. A
164
+ * description that cannot be complete omits `files`, and the server then
165
+ * gates on every uncheckpointed edit in the repository.
166
+ */
167
+ export declare function describeHandoffs(toolName: string, command: string, cwd: string): HandoffDescription[];
77
168
  /**
78
169
  * The fingerprint a host sends with an edit report: the first 32 hex chars of
79
170
  * the sha256 of the file's current content, 'absent' when the file does not
@@ -1 +1 @@
1
- {"version":3,"file":"gitEditDetector.d.ts","sourceRoot":"","sources":["../src/gitEditDetector.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAmDH;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,IAAI,CAuBvF;AAuGD,oFAAoF;AACpF,wBAAgB,uBAAuB,CAAC,YAAY,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAK/E;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO,SAAK,GAAG,MAAM,EAAE,CAqB9F;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAWtF;AAKD;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,SAAK,GAAG,MAAM,GAAG,SAAS,CAS1E;AAgCD,eAAO,MAAM,iBAAiB;IAC5B,2DAA2D;aAC3D,QAAQ,EAAE,IAAK;IACf,kEAAkE;aAClE,gBAAgB,EAAE,IAAK;IACvB,iCAAiC;aACjC,WAAW,EAAE,CAAC;aACd,WAAW,EAAE,CAAC;IACd,wEAAwE;aACxE,aAAa,EAAE,IAAK;aACpB,YAAY;IACZ,0EAA0E;aAC1E,QAAQ;IACR,6DAA6D;aAC7D,iBAAiB,EAAE,KAAM;aACzB,kBAAkB,EAAE,KAAM;aAC1B,cAAc;IACd,oDAAoD;aACpD,UAAU,EAAE,GAAG;CACP,CAAC;AAEX;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAChC,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,SAAS,GAAE,MAA0B,GACpC,MAAM,CAKR;AAED,wEAAwE;AACxE,MAAM,MAAM,gBAAgB,GACxB;IAAE,IAAI,EAAE,WAAW,CAAA;CAAE,GACrB;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAE,GACzD;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACrC;IAAE,IAAI,EAAE,cAAc,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAEpD;;;;GAIG;AACH,wBAAgB,0BAA0B,CACxC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,OAAO,EACb,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,GACzB,gBAAgB,CAoBlB;AAyCD,2EAA2E;AAC3E,wBAAgB,kBAAkB,CAAC,YAAY,EAAE,MAAM,EAAE,GAAG,SAAa,GAAG,MAAM,EAAE,CAMnF;AA6BD;;;;;GAKG;AACH,wBAAsB,uBAAuB,CAAC,OAAO,EAAE;IACrD,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IACzB,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,gBAAgB,CAAC,CAAC;IACvE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACjC,GAAG,OAAO,CAAC;IAAE,SAAS,EAAE,MAAM,EAAE,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,CA2F1E"}
1
+ {"version":3,"file":"gitEditDetector.d.ts","sourceRoot":"","sources":["../src/gitEditDetector.ts"],"names":[],"mappings":"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"}
@@ -47,11 +47,29 @@
47
47
  * tree clean by the time it is observed, so the dirty diff sees nothing.
48
48
  * HEAD is recorded per root at every observation; only commits made since
49
49
  * the last observation count, and never a merge.
50
+ *
51
+ * Three more (2026-10-07), found by a session whose push was gated a dozen
52
+ * times for edits it never made:
53
+ * - A nested repository or worktree directory is not an edit. `git status`
54
+ * lists one as a single `dir/` entry when its parent does not ignore it, so
55
+ * creating a worktree under the checkout counted as a file edit.
56
+ * - A shell call that runs a git command importing other content (merge,
57
+ * cherry-pick, rebase, pull, am, revert, reset, stash pop/apply) credits
58
+ * nothing: what it leaves dirty or commits was written elsewhere.
59
+ * - A pending report for a path whose repository directory no longer exists
60
+ * (a removed worktree) is dropped, not re-sent: the files are gone because
61
+ * the checkout was removed, not because this session deleted them.
62
+ *
63
+ * Each report also carries where the edit happened (`editScope`): the git
64
+ * worktree and repository of the file, or a mark that the file lies outside
65
+ * the project's repository (/tmp, another repository, a `.git` directory). The
66
+ * server counts only project edits, and gates a push on the uncheckpointed
67
+ * files it carries (`describeHandoffs`).
50
68
  */
51
69
  import { execFileSync } from 'node:child_process';
52
70
  import { createHash, randomUUID } from 'node:crypto';
53
- import { mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
54
- import { dirname, isAbsolute, join } from 'node:path';
71
+ import { mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
72
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
55
73
  import { performance } from 'node:perf_hooks';
56
74
  /** Bounded so a pathological repo cannot stall the hook's PostToolUse turn. */
57
75
  const GIT_REV_PARSE_TIMEOUT_MS = 400;
@@ -100,14 +118,28 @@ export function listGitDirtyPaths(cwd) {
100
118
  // "XY <path>"; a rename/copy entry is followed by the ORIGINAL path as its
101
119
  // own NUL token - consume it so it is never misread as a status entry.
102
120
  const statusCode = entry.slice(0, 2);
103
- paths.push(join(root, entry.slice(3)));
104
121
  if (statusCode.startsWith('R') || statusCode.startsWith('C'))
105
122
  i++;
123
+ // With --untracked-files=all every untracked FILE is listed on its own; the
124
+ // only entry that ends in a slash is a nested repository or worktree, which
125
+ // git never descends into. Its appearance is a checkout, not an edit.
126
+ if (entry.endsWith('/'))
127
+ continue;
128
+ paths.push(join(root, entry.slice(3)));
106
129
  if (paths.length > MAX_SNAPSHOT_PATHS)
107
130
  return null;
108
131
  }
109
132
  return { root, paths };
110
133
  }
134
+ /**
135
+ * Git commands that bring content written elsewhere into the working tree.
136
+ * What a merge (squash included), cherry-pick, rebase, pull, am, revert, reset
137
+ * or stash pop/apply leaves dirty or commits is not this session's edit.
138
+ * Classifying the command is coarse on purpose: a shell call that also edits a
139
+ * file in the same command is credited nothing, which errs toward silence
140
+ * (the next edit to that file is still credited).
141
+ */
142
+ const GIT_CONTENT_IMPORT_RE = /\bgit\s+(?:-C\s+\S+\s+)?(?:merge|cherry-pick|rebase|pull|am|revert|reset|stash\s+(?:pop|apply))\b/;
111
143
  /** HEAD of the repo at cwd, or undefined before the first commit. */
112
144
  function currentHead(cwd) {
113
145
  return (runGit(cwd, ['rev-parse', '--verify', '-q', 'HEAD'], GIT_REV_PARSE_TIMEOUT_MS)?.trim() ||
@@ -232,6 +264,11 @@ export function diffGitDirtySnapshot(snapshotFile, cwd, command = '') {
232
264
  writeRoot(snapshotFile, current.root, { paths: current.paths, head });
233
265
  return [];
234
266
  }
267
+ if (GIT_CONTENT_IMPORT_RE.test(command)) {
268
+ // Absorb: everything dirty now is known, nothing is credited.
269
+ writeRoot(snapshotFile, current.root, { paths: current.paths, head });
270
+ return [];
271
+ }
235
272
  const known = new Set(baseline.paths);
236
273
  const additions = current.paths.filter((path) => !known.has(path));
237
274
  if (head && head !== baseline.head && /\bgit\s+commit\b/.test(command)) {
@@ -266,6 +303,470 @@ export function addPathsToGitDirtySnapshot(snapshotFile, paths) {
266
303
  writeRoot(snapshotFile, root, entry);
267
304
  }
268
305
  }
306
+ /**
307
+ * One process handles one hook event, and an event reports up to 25 paths,
308
+ * mostly in the same few directories: remember each directory's answer.
309
+ */
310
+ const gitLocationCache = new Map();
311
+ function canonicalPath(path) {
312
+ try {
313
+ return realpathSync(path);
314
+ }
315
+ catch {
316
+ return path;
317
+ }
318
+ }
319
+ /** The directory a `.git` FILE (a linked worktree or submodule) points at, or null. */
320
+ function gitDirFromFile(dotGitFile, root) {
321
+ try {
322
+ const match = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGitFile, 'utf8'));
323
+ return match?.[1] ? resolve(root, match[1].trim()) : null;
324
+ }
325
+ catch {
326
+ return null;
327
+ }
328
+ }
329
+ /** A linked worktree's git dir names its repository in `commondir`; otherwise it is its own. */
330
+ function commonDirOf(gitDir) {
331
+ try {
332
+ const relative = readFileSync(join(gitDir, 'commondir'), 'utf8').trim();
333
+ return relative ? resolve(gitDir, relative) : gitDir;
334
+ }
335
+ catch {
336
+ return gitDir;
337
+ }
338
+ }
339
+ /**
340
+ * Git skips a `.git` that is not a repository (a stray empty `/tmp/.git` is
341
+ * real): it must hold `HEAD`, and its repository must hold `objects`.
342
+ */
343
+ function isGitDir(gitDir) {
344
+ try {
345
+ return (statSync(join(gitDir, 'HEAD')).isFile() &&
346
+ statSync(join(commonDirOf(gitDir), 'objects')).isDirectory());
347
+ }
348
+ catch {
349
+ return false;
350
+ }
351
+ }
352
+ /**
353
+ * The git location of a directory, found the way git finds it: the nearest
354
+ * ancestor holding a valid `.git`. A `.git` directory is the repository; a `.git`
355
+ * file (linked worktree) points at a git dir whose `commondir` names the
356
+ * repository. 'none' when no ancestor holds `.git`; null when the file system
357
+ * would not say (permissions), which callers treat as unknown, never as
358
+ * "outside". Read from the file system rather than by running git: a hook
359
+ * reports up to 25 paths inside a time budget, and a process per path would
360
+ * spend it.
361
+ */
362
+ function gitLocation(dir) {
363
+ const cached = gitLocationCache.get(dir);
364
+ if (cached !== undefined)
365
+ return cached;
366
+ let result = 'none';
367
+ for (let current = dir;;) {
368
+ const dotGit = join(current, '.git');
369
+ let stat;
370
+ try {
371
+ stat = statSync(dotGit);
372
+ }
373
+ catch (error) {
374
+ const code = error.code;
375
+ if (code !== 'ENOENT' && code !== 'ENOTDIR') {
376
+ result = null;
377
+ break;
378
+ }
379
+ }
380
+ if (stat?.isDirectory() && isGitDir(dotGit)) {
381
+ result = { root: current, commonDir: canonicalPath(dotGit) };
382
+ break;
383
+ }
384
+ if (stat?.isFile()) {
385
+ const gitDir = gitDirFromFile(dotGit, current);
386
+ if (gitDir && isGitDir(gitDir)) {
387
+ result = { root: current, commonDir: canonicalPath(commonDirOf(gitDir)) };
388
+ break;
389
+ }
390
+ }
391
+ const parent = dirname(current);
392
+ if (parent === current)
393
+ break;
394
+ current = parent;
395
+ }
396
+ gitLocationCache.set(dir, result);
397
+ return result;
398
+ }
399
+ /** The deepest existing directory at or above `path` (a deleted file's parent may be gone). */
400
+ function nearestExistingDirectory(path) {
401
+ let current = path;
402
+ for (;;) {
403
+ try {
404
+ if (statSync(current).isDirectory())
405
+ return current;
406
+ }
407
+ catch {
408
+ // Missing: try the parent.
409
+ }
410
+ const parent = dirname(current);
411
+ if (parent === current)
412
+ return current;
413
+ current = parent;
414
+ }
415
+ }
416
+ function isWithin(path, dir) {
417
+ const base = dir.endsWith('/') ? dir : `${dir}/`;
418
+ return path === dir || path.startsWith(base);
419
+ }
420
+ /**
421
+ * Where `filePath` lies relative to the project the agent works in.
422
+ *
423
+ * `anchorDir` is the project directory: the host's project root when it has
424
+ * one (Claude Code's CLAUDE_PROJECT_DIR), else the event's cwd. A file is
425
+ * project work when it is in the same git repository as the anchor (any of
426
+ * its worktrees), or, when the anchor is not in git, inside the anchor.
427
+ * Anything that cannot be determined is reported as unknown (no claim), so an
428
+ * unreadable directory never hides an edit.
429
+ */
430
+ export function editScope(filePath, cwd, anchorDir = cwd) {
431
+ if (!filePath)
432
+ return {};
433
+ const absolute = isAbsolute(filePath) ? filePath : join(cwd, filePath);
434
+ if (/(?:^|\/)\.git(?:\/|$)/.test(absolute))
435
+ return { outsideProject: true };
436
+ const file = gitLocation(nearestExistingDirectory(dirname(absolute)));
437
+ const anchor = anchorDir ? gitLocation(anchorDir) : null;
438
+ const editRoot = file && file !== 'none' ? { editRoot: file.root, editRepo: file.commonDir } : {};
439
+ if (file === null || anchor === null)
440
+ return editRoot;
441
+ if (anchor === 'none') {
442
+ return isWithin(absolute, anchorDir) ? editRoot : { outsideProject: true };
443
+ }
444
+ if (file === 'none' || file.commonDir !== anchor.commonDir)
445
+ return { outsideProject: true };
446
+ return editRoot;
447
+ }
448
+ // - Which shell commands hand work off, and what they hand off -
449
+ //
450
+ // The server gates a handoff (git push, PR, deploy) on uncheckpointed work.
451
+ // It used to match handoff words anywhere in the command text and count every
452
+ // edit the session had ever reported. One parser, shared by the server and the
453
+ // hosts, now says which commands hand work off; the host, which has the
454
+ // repository, says what a push carries.
455
+ const SHELL_TOOLS = new Set(['bash', 'shell', 'terminal', 'run_terminal_command']);
456
+ /** Here-document bodies are data (a script being written, a commit message), never commands. */
457
+ function stripHereDocuments(command) {
458
+ const kept = [];
459
+ let terminator = null;
460
+ for (const line of command.split('\n')) {
461
+ if (terminator) {
462
+ const candidate = terminator.stripTabs ? line.replace(/^\t+/, '') : line;
463
+ if (candidate.trim() === terminator.word)
464
+ terminator = null;
465
+ continue;
466
+ }
467
+ kept.push(line);
468
+ const opener = /(?<!<)<<(?!<)(-?)\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2/.exec(line);
469
+ if (opener)
470
+ terminator = { word: opener[3] ?? '', stripTabs: opener[1] === '-' };
471
+ }
472
+ return kept.join('\n');
473
+ }
474
+ /** Placeholder delimiters for quoted text: private-use code points, never typed in a command. */
475
+ const QUOTE_OPEN = '';
476
+ const QUOTE_CLOSE = '';
477
+ /**
478
+ * Replace every quoted string with an opaque placeholder so handoff words
479
+ * inside quotes (a grep pattern, a commit message) are never read as commands.
480
+ * The quoted text is kept so a quoted directory or ref can be restored.
481
+ */
482
+ function maskQuotes(text) {
483
+ const quoted = [];
484
+ let skeleton = '';
485
+ let i = 0;
486
+ while (i < text.length) {
487
+ const char = text[i];
488
+ if (char === '\\' && i + 1 < text.length) {
489
+ skeleton += text.slice(i, i + 2);
490
+ i += 2;
491
+ continue;
492
+ }
493
+ if (char === "'" || char === '"') {
494
+ let j = i + 1;
495
+ let body = '';
496
+ while (j < text.length && text[j] !== char) {
497
+ if (char === '"' && text[j] === '\\' && j + 1 < text.length) {
498
+ body += text[j + 1];
499
+ j += 2;
500
+ continue;
501
+ }
502
+ body += text[j];
503
+ j += 1;
504
+ }
505
+ quoted.push(body);
506
+ skeleton += `${QUOTE_OPEN}${quoted.length - 1}${QUOTE_CLOSE}`;
507
+ i = j + 1;
508
+ continue;
509
+ }
510
+ skeleton += char;
511
+ i += 1;
512
+ }
513
+ return { skeleton, quoted };
514
+ }
515
+ function restoreQuoted(word, quoted) {
516
+ return word.replace(new RegExp(`${QUOTE_OPEN}(\\d+)${QUOTE_CLOSE}`, 'g'), (_match, index) => quoted[Number(index)] ?? '');
517
+ }
518
+ /** Command separators; `&` only when it is not part of a redirection (2>&1, &>). */
519
+ const SEGMENT_SEPARATOR = /\|\||&&|;|\||\n|[()]|(?<![<>])&(?!>)/;
520
+ /** A token that is a redirection (2>&1, >file, >>, <); a bare operator takes the next token. */
521
+ const REDIRECTION = /^\d*(?:[<>]|&>)/;
522
+ const BARE_REDIRECTION = /^\d*(?:[<>]{1,2}|&>)&?$/;
523
+ /** Options of `git push` that take a separate value. */
524
+ const PUSH_OPTIONS_WITH_VALUE = new Set([
525
+ '-o',
526
+ '--push-option',
527
+ '--repo',
528
+ '--receive-pack',
529
+ '--exec',
530
+ ]);
531
+ /**
532
+ * The revisions a `git push <args>` sends, or null when it sends no work:
533
+ * only ref deletions (`--delete`, `-d`, `:ref` refspecs) or a dry run.
534
+ */
535
+ function pushRevisions(args) {
536
+ const positional = [];
537
+ const extra = [];
538
+ for (let k = 0; k < args.length; k++) {
539
+ const arg = args[k] ?? '';
540
+ if (REDIRECTION.test(arg)) {
541
+ if (BARE_REDIRECTION.test(arg))
542
+ k += 1;
543
+ continue;
544
+ }
545
+ if (arg === '--delete' || arg === '--dry-run')
546
+ return null;
547
+ if (/^-[A-Za-z]+$/.test(arg) && /[dn]/.test(arg.slice(1)))
548
+ return null;
549
+ if (arg === '--all' || arg === '--branches' || arg === '--mirror')
550
+ extra.push('--branches');
551
+ if (arg === '--tags')
552
+ extra.push('--tags');
553
+ if (arg.startsWith('-')) {
554
+ if (PUSH_OPTIONS_WITH_VALUE.has(arg))
555
+ k += 1;
556
+ continue;
557
+ }
558
+ positional.push(arg);
559
+ }
560
+ const refspecs = positional.slice(1);
561
+ if (refspecs.length > 0 && refspecs.every((refspec) => refspec.startsWith(':')))
562
+ return null;
563
+ const revisions = [];
564
+ for (let k = 0; k < refspecs.length; k++) {
565
+ const refspec = refspecs[k] ?? '';
566
+ if (refspec === 'tag' && refspecs[k + 1]) {
567
+ revisions.push(`refs/tags/${refspecs[k + 1]}`);
568
+ k += 1;
569
+ continue;
570
+ }
571
+ const source = refspec.replace(/^\+/, '').split(':')[0];
572
+ if (source)
573
+ revisions.push(source);
574
+ }
575
+ if (revisions.length === 0 && extra.length === 0)
576
+ revisions.push('HEAD');
577
+ return [...revisions, ...extra];
578
+ }
579
+ /** `git [global options] push <args>`: the push args and any `-C` directory, else null. */
580
+ function gitPush(words) {
581
+ for (let i = 0; i < words.length; i++) {
582
+ if (words[i] !== 'git' && !/\/git$/.test(words[i] ?? ''))
583
+ continue;
584
+ let j = i + 1;
585
+ let directory = null;
586
+ while (j < words.length && (words[j] ?? '').startsWith('-')) {
587
+ const option = words[j];
588
+ if (option === '-C' && j + 1 < words.length) {
589
+ directory = words[j + 1] ?? null;
590
+ j += 2;
591
+ continue;
592
+ }
593
+ if (option === '-c' || /^--(?:git-dir|work-tree|namespace)$/.test(option ?? '')) {
594
+ j += 2;
595
+ continue;
596
+ }
597
+ j += 1;
598
+ }
599
+ if (words[j] === 'push')
600
+ return { args: words.slice(j + 1), directory };
601
+ }
602
+ return null;
603
+ }
604
+ const OTHER_HANDOFFS = [
605
+ /\bgh\s+(?:pr\s+(?:create|merge)|release\s+create)\b/,
606
+ /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?deploy\b/,
607
+ /\b(?:vercel|wrangler|serverless|sls|sam|cdk|sst|fly|railway|netlify|dokploy)\s+deploy\b/,
608
+ /\bterraform\s+apply\b/,
609
+ /\bkubectl\s+apply\b/,
610
+ ];
611
+ function joinDirectory(base, next) {
612
+ if (isAbsolute(next) || base === null)
613
+ return next;
614
+ return join(base, next);
615
+ }
616
+ /**
617
+ * The handoffs a shell command performs. Empty when it hands nothing off.
618
+ * Quoted text and here-document bodies are data, not commands; a `git push`
619
+ * that only deletes refs or is a dry run is not a handoff. `cd <dir>` before
620
+ * the handoff, and `git -C <dir>`, move it; a directory that cannot be known
621
+ * from the text (`cd ~`, `cd $X`, `cd -`) falls back to the event's cwd.
622
+ */
623
+ export function parseHandoffCommand(toolName, command) {
624
+ if (!SHELL_TOOLS.has(toolName.trim().toLowerCase()))
625
+ return [];
626
+ if (!command.trim())
627
+ return [];
628
+ const { skeleton, quoted } = maskQuotes(stripHereDocuments(command));
629
+ const targets = [];
630
+ let directory = null;
631
+ for (const segment of skeleton.split(SEGMENT_SEPARATOR)) {
632
+ const words = segment.trim().split(/\s+/).filter(Boolean);
633
+ if (words.length === 0)
634
+ continue;
635
+ if (words[0] === 'cd' || words[0] === 'pushd') {
636
+ const target = words[1] === undefined ? undefined : restoreQuoted(words[1], quoted);
637
+ directory =
638
+ target === undefined || target === '-' || target.startsWith('~') || target.includes('$')
639
+ ? null
640
+ : joinDirectory(directory, target);
641
+ continue;
642
+ }
643
+ const push = gitPush(words);
644
+ if (push) {
645
+ const revisions = pushRevisions(push.args.map((arg) => restoreQuoted(arg, quoted)));
646
+ if (revisions) {
647
+ targets.push({
648
+ directory: push.directory
649
+ ? joinDirectory(directory, restoreQuoted(push.directory, quoted))
650
+ : directory,
651
+ revisions,
652
+ });
653
+ }
654
+ continue;
655
+ }
656
+ if (OTHER_HANDOFFS.some((pattern) => pattern.test(segment.toLowerCase()))) {
657
+ targets.push({ directory });
658
+ }
659
+ }
660
+ return targets;
661
+ }
662
+ /** Most commits and files a push description carries. */
663
+ /**
664
+ * Bounds of a push description. Past any of them the description says only
665
+ * the repository, and the server falls back to every uncheckpointed edit in
666
+ * it: a description must never be partial, because a partial one could leave
667
+ * an edited file out and quiet the gate. The server's request schema accepts
668
+ * exactly these bounds, so a description can never fail the whole request.
669
+ */
670
+ export const HANDOFF_LIMITS = {
671
+ commits: 200,
672
+ files: 500,
673
+ pathLength: 1024,
674
+ handoffs: 10,
675
+ };
676
+ /** Marks the start of each commit in the `git log` output below. */
677
+ const COMMIT_MARKER = '\u001ecommit';
678
+ /**
679
+ * Files changed by commits reachable from `revisions` and on no
680
+ * remote-tracking ref, or undefined when that cannot be said in full: git
681
+ * failed (a revision it does not know, a timeout), or the push is past a
682
+ * bound in HANDOFF_LIMITS.
683
+ */
684
+ function pushedFiles(dir, revisions) {
685
+ const out = runGit(dir, [
686
+ 'log',
687
+ '--no-renames',
688
+ '--name-only',
689
+ // git refuses a raw control character in a format; %x1e is its escape.
690
+ '--format=%x1ecommit',
691
+ '-n',
692
+ String(HANDOFF_LIMITS.commits + 1),
693
+ ...revisions,
694
+ '--not',
695
+ '--remotes',
696
+ '--',
697
+ ], GIT_STATUS_TIMEOUT_MS);
698
+ if (out === null)
699
+ return undefined;
700
+ const lines = out.split('\n').filter(Boolean);
701
+ const commits = lines.filter((line) => line === COMMIT_MARKER).length;
702
+ const files = [...new Set(lines.filter((line) => line !== COMMIT_MARKER))];
703
+ if (commits > HANDOFF_LIMITS.commits ||
704
+ files.length > HANDOFF_LIMITS.files ||
705
+ files.some((path) => path.length > HANDOFF_LIMITS.pathLength)) {
706
+ return undefined;
707
+ }
708
+ return files;
709
+ }
710
+ /**
711
+ * Per handoff in a shell command, what it carries: its repository and, for a
712
+ * git push, the files its new commits change. The server gates the handoff
713
+ * only when one of those files was edited by the session (any of its agents)
714
+ * after its last checkpoint. Empty when the command hands nothing off. A
715
+ * description that cannot be complete omits `files`, and the server then
716
+ * gates on every uncheckpointed edit in the repository.
717
+ */
718
+ export function describeHandoffs(toolName, command, cwd) {
719
+ const targets = parseHandoffCommand(toolName, command);
720
+ // More handoffs than the server accepts: describe none, and the server
721
+ // judges each by every uncheckpointed edit.
722
+ if (targets.length > HANDOFF_LIMITS.handoffs)
723
+ return [];
724
+ return targets.map((target) => {
725
+ const dir = target.directory ? resolve(cwd, target.directory) : cwd;
726
+ const location = gitLocation(dir);
727
+ if (!location || location === 'none')
728
+ return {};
729
+ const files = target.revisions ? pushedFiles(dir, target.revisions) : undefined;
730
+ return { repo: location.commonDir, ...(files ? { files } : {}) };
731
+ });
732
+ }
733
+ /** Forget every recorded repository whose directory no longer exists. */
734
+ function pruneRemovedRoots(snapshotFile) {
735
+ const snapshot = readSnapshot(snapshotFile);
736
+ if (!snapshot)
737
+ return;
738
+ const roots = Object.fromEntries(Object.entries(snapshot.roots).filter(([root]) => {
739
+ try {
740
+ return statSync(root).isDirectory();
741
+ }
742
+ catch {
743
+ return false;
744
+ }
745
+ }));
746
+ try {
747
+ writeFileWhole(snapshotFile, JSON.stringify({ v: 2, roots }));
748
+ }
749
+ catch {
750
+ // Best-effort: a stale root only costs a stat on the next event.
751
+ }
752
+ }
753
+ /**
754
+ * The recorded repository root holding `path` no longer exists: its worktree
755
+ * was removed. A report for such a path is not an edit by this session.
756
+ */
757
+ function recordedRootRemoved(snapshot, path) {
758
+ if (!snapshot)
759
+ return false;
760
+ const root = rootFor(snapshot, path);
761
+ if (!root)
762
+ return false;
763
+ try {
764
+ return !statSync(root).isDirectory();
765
+ }
766
+ catch {
767
+ return true;
768
+ }
769
+ }
269
770
  /** Largest file whose content is hashed for an edit fingerprint, in bytes. */
270
771
  const EDIT_FINGERPRINT_MAX_BYTES = 8 * 1024 * 1024;
271
772
  /**
@@ -477,6 +978,17 @@ export async function reportEditsWithinBudget(options) {
477
978
  for (const path of options.paths) {
478
979
  retryState.pending[path] ??= { attempts: 0, firstAt: start, nextAt: start };
479
980
  }
981
+ const snapshot = readSnapshot(options.snapshotFile);
982
+ let rootsRemoved = false;
983
+ for (const path of Object.keys(retryState.pending)) {
984
+ if (!recordedRootRemoved(snapshot, path))
985
+ continue;
986
+ delete retryState.pending[path];
987
+ rootsRemoved = true;
988
+ log(`mnemonik: edit report for ${path} dropped (its repository was removed)`);
989
+ }
990
+ if (rootsRemoved)
991
+ pruneRemovedRoots(options.snapshotFile);
480
992
  const due = [...new Set([...options.paths, ...Object.keys(retryState.pending)])].filter((path) => retryState.pending[path] && retryState.pending[path].nextAt <= start);
481
993
  let queue = (retryState.pausedUntil ?? 0) > start ? [] : due;
482
994
  while (queue.length > 0) {