@mnemonik/shared 7.49.1 → 7.51.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/gitEditDetector.d.ts +61 -0
- package/dist/gitEditDetector.d.ts.map +1 -0
- package/dist/gitEditDetector.js +165 -0
- package/dist/gitEditDetector.js.map +1 -0
- package/dist/hookRuntime.d.ts.map +1 -1
- package/dist/hookRuntime.js +3 -2
- package/dist/hookRuntime.js.map +1 -1
- package/package.json +5 -1
- package/src/gitEditDetector.ts +167 -0
- package/src/hookRuntime.ts +2 -1
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell-write classification via filesystem observation, not command parsing.
|
|
3
|
+
*
|
|
4
|
+
* Permissive/auto-approve modes steer agents toward the shell for file work on
|
|
5
|
+
* every host, and a shell payload carries a command string, not a file path —
|
|
6
|
+
* so post-shell events reached the server with no edit to report and the
|
|
7
|
+
* session's filesEdited/ideEditCount stayed empty, silencing every edit-gated
|
|
8
|
+
* signal (checkpoint pressure above all). Parsing the command text for written
|
|
9
|
+
* paths was considered and rejected (decision 2026-08-19: shell semantics make
|
|
10
|
+
* path extraction guesswork). What git reports about the working tree is not
|
|
11
|
+
* guesswork.
|
|
12
|
+
*
|
|
13
|
+
* Mechanism: snapshot the set of dirty paths (`git status --porcelain -uall`)
|
|
14
|
+
* at session start, and after each shell call diff the current set against the
|
|
15
|
+
* snapshot. Paths that are newly dirty were written between the two
|
|
16
|
+
* observations; report those as edits and roll the snapshot forward. Only
|
|
17
|
+
* additions count — a commit or stash SHRINKS the set, and saving work is not
|
|
18
|
+
* new work. Edit tool writes are unioned into the snapshot by each host's edit
|
|
19
|
+
* handler so they are never re-credited to the next shell call.
|
|
20
|
+
*
|
|
21
|
+
* Host-agnostic by construction: the caller owns session identity and passes
|
|
22
|
+
* the absolute path of its own private snapshot file. An empty `snapshotFile`
|
|
23
|
+
* is the caller's "no usable session identity" signal and every entry point
|
|
24
|
+
* no-ops on it — a synthesized or fallback id must never own a snapshot.
|
|
25
|
+
* Because the diff rolls the snapshot forward, it is idempotent: a host with
|
|
26
|
+
* no post-shell event may call it from several carrier events and each new
|
|
27
|
+
* write is still reported exactly once.
|
|
28
|
+
*
|
|
29
|
+
* Known limits, accepted by design:
|
|
30
|
+
* - A file already dirty at baseline that the shell modifies again is not
|
|
31
|
+
* re-reported (set membership, not content, is compared).
|
|
32
|
+
* - Any working-tree change between two observations is credited to the shell
|
|
33
|
+
* call, including background processes. For "does this session have unsaved
|
|
34
|
+
* work" that is the right signal.
|
|
35
|
+
* - Only the repo containing cwd is observed. Not a git repo → report nothing
|
|
36
|
+
* rather than guess.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* The current dirty set as absolute paths, or null when cwd is not inside a
|
|
40
|
+
* git repo, git is unavailable, a bound is exceeded, or the set is too large
|
|
41
|
+
* to be agent work.
|
|
42
|
+
*/
|
|
43
|
+
export declare function listGitDirtyPaths(cwd: string): {
|
|
44
|
+
root: string;
|
|
45
|
+
paths: string[];
|
|
46
|
+
} | null;
|
|
47
|
+
/** Session start: baseline the dirty set so pre-existing dirt is never reported. */
|
|
48
|
+
export declare function captureGitDirtyBaseline(snapshotFile: string, cwd: string): void;
|
|
49
|
+
/**
|
|
50
|
+
* After a shell call: paths newly dirty since the last observation, capped.
|
|
51
|
+
* Rolls the snapshot forward. Without a usable baseline it establishes one
|
|
52
|
+
* and reports nothing — degrading toward silence, never toward a false edit.
|
|
53
|
+
*/
|
|
54
|
+
export declare function diffGitDirtySnapshot(snapshotFile: string, cwd: string): string[];
|
|
55
|
+
/**
|
|
56
|
+
* After an edit tool: fold tool-reported edits into the snapshot so the next
|
|
57
|
+
* shell diff does not re-credit them. Only when a baseline exists — seeding a
|
|
58
|
+
* partial snapshot would make later diffs report pre-existing dirt.
|
|
59
|
+
*/
|
|
60
|
+
export declare function addPathsToGitDirtySnapshot(snapshotFile: string, paths: string[]): void;
|
|
61
|
+
//# sourceMappingURL=gitEditDetector.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gitEditDetector.d.ts","sourceRoot":"","sources":["../src/gitEditDetector.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAsCH;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,IAAI,CAuBvF;AA0BD,oFAAoF;AACpF,wBAAgB,uBAAuB,CAAC,YAAY,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAK/E;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAUhF;AAED;;;;GAIG;AACH,wBAAgB,0BAA0B,CAAC,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAQtF"}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell-write classification via filesystem observation, not command parsing.
|
|
3
|
+
*
|
|
4
|
+
* Permissive/auto-approve modes steer agents toward the shell for file work on
|
|
5
|
+
* every host, and a shell payload carries a command string, not a file path —
|
|
6
|
+
* so post-shell events reached the server with no edit to report and the
|
|
7
|
+
* session's filesEdited/ideEditCount stayed empty, silencing every edit-gated
|
|
8
|
+
* signal (checkpoint pressure above all). Parsing the command text for written
|
|
9
|
+
* paths was considered and rejected (decision 2026-08-19: shell semantics make
|
|
10
|
+
* path extraction guesswork). What git reports about the working tree is not
|
|
11
|
+
* guesswork.
|
|
12
|
+
*
|
|
13
|
+
* Mechanism: snapshot the set of dirty paths (`git status --porcelain -uall`)
|
|
14
|
+
* at session start, and after each shell call diff the current set against the
|
|
15
|
+
* snapshot. Paths that are newly dirty were written between the two
|
|
16
|
+
* observations; report those as edits and roll the snapshot forward. Only
|
|
17
|
+
* additions count — a commit or stash SHRINKS the set, and saving work is not
|
|
18
|
+
* new work. Edit tool writes are unioned into the snapshot by each host's edit
|
|
19
|
+
* handler so they are never re-credited to the next shell call.
|
|
20
|
+
*
|
|
21
|
+
* Host-agnostic by construction: the caller owns session identity and passes
|
|
22
|
+
* the absolute path of its own private snapshot file. An empty `snapshotFile`
|
|
23
|
+
* is the caller's "no usable session identity" signal and every entry point
|
|
24
|
+
* no-ops on it — a synthesized or fallback id must never own a snapshot.
|
|
25
|
+
* Because the diff rolls the snapshot forward, it is idempotent: a host with
|
|
26
|
+
* no post-shell event may call it from several carrier events and each new
|
|
27
|
+
* write is still reported exactly once.
|
|
28
|
+
*
|
|
29
|
+
* Known limits, accepted by design:
|
|
30
|
+
* - A file already dirty at baseline that the shell modifies again is not
|
|
31
|
+
* re-reported (set membership, not content, is compared).
|
|
32
|
+
* - Any working-tree change between two observations is credited to the shell
|
|
33
|
+
* call, including background processes. For "does this session have unsaved
|
|
34
|
+
* work" that is the right signal.
|
|
35
|
+
* - Only the repo containing cwd is observed. Not a git repo → report nothing
|
|
36
|
+
* rather than guess.
|
|
37
|
+
*/
|
|
38
|
+
import { execFileSync } from 'node:child_process';
|
|
39
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
40
|
+
import { dirname, join } from 'node:path';
|
|
41
|
+
/** Bounded so a pathological repo cannot stall the hook's PostToolUse turn. */
|
|
42
|
+
const GIT_REV_PARSE_TIMEOUT_MS = 400;
|
|
43
|
+
const GIT_STATUS_TIMEOUT_MS = 800;
|
|
44
|
+
/** T5, the widest volume tier, needs 15 files; 25 keeps tier semantics intact. */
|
|
45
|
+
const MAX_REPORTED_PATHS_PER_CALL = 25;
|
|
46
|
+
/**
|
|
47
|
+
* A dirty set larger than this is not agent work (mass generation, a missing
|
|
48
|
+
* .gitignore). Classification is skipped rather than flooding filesEdited.
|
|
49
|
+
*/
|
|
50
|
+
const MAX_SNAPSHOT_PATHS = 2000;
|
|
51
|
+
function runGit(cwd, args, timeout) {
|
|
52
|
+
try {
|
|
53
|
+
// --no-optional-locks is a TOP-LEVEL git option: passed after the
|
|
54
|
+
// subcommand git exits 129 and every observation silently reports nothing.
|
|
55
|
+
return execFileSync('git', ['--no-optional-locks', '-C', cwd, ...args], {
|
|
56
|
+
timeout,
|
|
57
|
+
encoding: 'utf8',
|
|
58
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
59
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The current dirty set as absolute paths, or null when cwd is not inside a
|
|
68
|
+
* git repo, git is unavailable, a bound is exceeded, or the set is too large
|
|
69
|
+
* to be agent work.
|
|
70
|
+
*/
|
|
71
|
+
export function listGitDirtyPaths(cwd) {
|
|
72
|
+
const rootOut = runGit(cwd, ['rev-parse', '--show-toplevel'], GIT_REV_PARSE_TIMEOUT_MS);
|
|
73
|
+
const root = rootOut?.trim();
|
|
74
|
+
if (!root)
|
|
75
|
+
return null;
|
|
76
|
+
const statusOut = runGit(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=all'], GIT_STATUS_TIMEOUT_MS);
|
|
77
|
+
if (statusOut === null)
|
|
78
|
+
return null;
|
|
79
|
+
const paths = [];
|
|
80
|
+
const tokens = statusOut.split('\0');
|
|
81
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
82
|
+
const entry = tokens[i];
|
|
83
|
+
if (!entry || entry.length < 4)
|
|
84
|
+
continue;
|
|
85
|
+
// "XY <path>"; a rename/copy entry is followed by the ORIGINAL path as its
|
|
86
|
+
// own NUL token — consume it so it is never misread as a status entry.
|
|
87
|
+
const statusCode = entry.slice(0, 2);
|
|
88
|
+
paths.push(join(root, entry.slice(3)));
|
|
89
|
+
if (statusCode.startsWith('R') || statusCode.startsWith('C'))
|
|
90
|
+
i++;
|
|
91
|
+
if (paths.length > MAX_SNAPSHOT_PATHS)
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
return { root, paths };
|
|
95
|
+
}
|
|
96
|
+
function readSnapshot(snapshotFile) {
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(readFileSync(snapshotFile, 'utf8'));
|
|
99
|
+
if (parsed?.v !== 1 || typeof parsed.root !== 'string' || !Array.isArray(parsed.paths)) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
return parsed;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function writeSnapshot(snapshotFile, root, paths) {
|
|
109
|
+
try {
|
|
110
|
+
mkdirSync(dirname(snapshotFile), { recursive: true, mode: 0o700 });
|
|
111
|
+
writeFileSync(snapshotFile, JSON.stringify({ v: 1, root, paths }), {
|
|
112
|
+
mode: 0o600,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Best-effort: a missing snapshot degrades to "report nothing", never to a
|
|
117
|
+
// wrong report.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Session start: baseline the dirty set so pre-existing dirt is never reported. */
|
|
121
|
+
export function captureGitDirtyBaseline(snapshotFile, cwd) {
|
|
122
|
+
if (!snapshotFile)
|
|
123
|
+
return;
|
|
124
|
+
const current = listGitDirtyPaths(cwd);
|
|
125
|
+
if (!current)
|
|
126
|
+
return;
|
|
127
|
+
writeSnapshot(snapshotFile, current.root, current.paths);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* After a shell call: paths newly dirty since the last observation, capped.
|
|
131
|
+
* Rolls the snapshot forward. Without a usable baseline it establishes one
|
|
132
|
+
* and reports nothing — degrading toward silence, never toward a false edit.
|
|
133
|
+
*/
|
|
134
|
+
export function diffGitDirtySnapshot(snapshotFile, cwd) {
|
|
135
|
+
if (!snapshotFile)
|
|
136
|
+
return [];
|
|
137
|
+
const current = listGitDirtyPaths(cwd);
|
|
138
|
+
if (!current)
|
|
139
|
+
return [];
|
|
140
|
+
const baseline = readSnapshot(snapshotFile);
|
|
141
|
+
writeSnapshot(snapshotFile, current.root, current.paths);
|
|
142
|
+
if (!baseline || baseline.root !== current.root)
|
|
143
|
+
return [];
|
|
144
|
+
const known = new Set(baseline.paths);
|
|
145
|
+
const additions = current.paths.filter((p) => !known.has(p));
|
|
146
|
+
return additions.slice(0, MAX_REPORTED_PATHS_PER_CALL);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* After an edit tool: fold tool-reported edits into the snapshot so the next
|
|
150
|
+
* shell diff does not re-credit them. Only when a baseline exists — seeding a
|
|
151
|
+
* partial snapshot would make later diffs report pre-existing dirt.
|
|
152
|
+
*/
|
|
153
|
+
export function addPathsToGitDirtySnapshot(snapshotFile, paths) {
|
|
154
|
+
if (!snapshotFile || paths.length === 0)
|
|
155
|
+
return;
|
|
156
|
+
const snapshot = readSnapshot(snapshotFile);
|
|
157
|
+
if (!snapshot)
|
|
158
|
+
return;
|
|
159
|
+
const known = new Set(snapshot.paths);
|
|
160
|
+
const additions = paths.filter((p) => !known.has(p));
|
|
161
|
+
if (additions.length === 0)
|
|
162
|
+
return;
|
|
163
|
+
writeSnapshot(snapshotFile, snapshot.root, [...snapshot.paths, ...additions]);
|
|
164
|
+
}
|
|
165
|
+
//# sourceMappingURL=gitEditDetector.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gitEditDetector.js","sourceRoot":"","sources":["../src/gitEditDetector.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hookRuntime.d.ts","sourceRoot":"","sources":["../src/hookRuntime.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,eAAe;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;CACrB;AAID,wBAAsB,mBAAmB,CACvC,QAAQ,EAAE,MAAM,EAChB,IAAI,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GAC/B,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,
|
|
1
|
+
{"version":3,"file":"hookRuntime.d.ts","sourceRoot":"","sources":["../src/hookRuntime.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,eAAe;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;CACrB;AAID,wBAAsB,mBAAmB,CACvC,QAAQ,EAAE,MAAM,EAChB,IAAI,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GAC/B,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CA8BjC;AAID,wBAAsB,iBAAiB,CAAC,CAAC,EAAE,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,CAAC,CAAC,CAYzE"}
|
package/dist/hookRuntime.js
CHANGED
|
@@ -5,8 +5,9 @@ const identityCache = new Map();
|
|
|
5
5
|
export async function findProjectIdentity(startCwd, opts = {}) {
|
|
6
6
|
if (!startCwd)
|
|
7
7
|
return null;
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
const cached = identityCache.get(startCwd);
|
|
9
|
+
if (cached !== undefined)
|
|
10
|
+
return cached;
|
|
10
11
|
let directory = isAbsolute(startCwd) ? startCwd : resolve(startCwd);
|
|
11
12
|
const maxDepth = opts.maxDepth ?? 10;
|
|
12
13
|
for (let depth = 0; depth <= maxDepth; depth += 1) {
|
package/dist/hookRuntime.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hookRuntime.js","sourceRoot":"","sources":["../src/hookRuntime.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC/D,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAQtC,MAAM,aAAa,GAAG,IAAI,GAAG,EAAkC,CAAC;AAEhE,MAAM,CAAC,KAAK,UAAU,mBAAmB,CACvC,QAAgB,EAChB,IAAI,GAA0B,EAAE;IAEhC,IAAI,CAAC,QAAQ;QAAE,OAAO,IAAI,CAAC;IAC3B,
|
|
1
|
+
{"version":3,"file":"hookRuntime.js","sourceRoot":"","sources":["../src/hookRuntime.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC/D,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAQtC,MAAM,aAAa,GAAG,IAAI,GAAG,EAAkC,CAAC;AAEhE,MAAM,CAAC,KAAK,UAAU,mBAAmB,CACvC,QAAgB,EAChB,IAAI,GAA0B,EAAE;IAEhC,IAAI,CAAC,QAAQ;QAAE,OAAO,IAAI,CAAC;IAC3B,MAAM,MAAM,GAAG,aAAa,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IAC3C,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,MAAM,CAAC;IAExC,IAAI,SAAS,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;IACpE,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,IAAI,EAAE,CAAC;IACrC,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,IAAI,QAAQ,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;QAClD,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CACvB,MAAM,QAAQ,CAAC,IAAI,CAAC,SAAS,EAAE,gBAAgB,CAAC,EAAE,MAAM,CAAC,CAC/B,CAAC;YAC7B,IAAI,OAAO,MAAM,CAAC,SAAS,KAAK,QAAQ,IAAI,MAAM,CAAC,SAAS,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;gBACxE,MAAM,QAAQ,GAAoB;oBAChC,SAAS,EAAE,MAAM,CAAC,SAAS;oBAC3B,WAAW,EAAE,OAAO,MAAM,CAAC,WAAW,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS;oBACpF,WAAW,EAAE,SAAS;iBACvB,CAAC;gBACF,aAAa,CAAC,GAAG,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;gBACtC,OAAO,QAAQ,CAAC;YAClB,CAAC;QACH,CAAC;QAAC,MAAM,CAAC;YACP,0DAA0D;QAC5D,CAAC;QACD,MAAM,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;QAClC,IAAI,MAAM,KAAK,SAAS;YAAE,MAAM;QAChC,SAAS,GAAG,MAAM,CAAC;IACrB,CAAC;IACD,aAAa,CAAC,GAAG,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;IAClC,OAAO,IAAI,CAAC;AACd,CAAC;AAED,IAAI,eAAe,GAAG,KAAK,CAAC;AAE5B,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAI,QAAkB;IAC3D,MAAM,IAAI,GAAG,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAM,CAAC;IAC1C,MAAM,MAAM,GAAG,IAAyC,CAAC;IACzD,IAAI,CAAC,eAAe,IAAI,MAAM,EAAE,EAAE,KAAK,KAAK,IAAI,MAAM,CAAC,MAAM,KAAK,mBAAmB,EAAE,CAAC;QACtF,MAAM,CAAC,KAAK,CACV,mFAAmF;YACjF,qEAAqE;YACrE,0GAA0G,CAC7G,CAAC;QACF,eAAe,GAAG,IAAI,CAAC;IACzB,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mnemonik/shared",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.51.0",
|
|
4
4
|
"description": "Shared constants and utilities for Mnemonik packages",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
"./context-budget": {
|
|
31
31
|
"import": "./dist/contextBudget.js",
|
|
32
32
|
"types": "./dist/contextBudget.d.ts"
|
|
33
|
+
},
|
|
34
|
+
"./git-edit-detector": {
|
|
35
|
+
"import": "./dist/gitEditDetector.js",
|
|
36
|
+
"types": "./dist/gitEditDetector.d.ts"
|
|
33
37
|
}
|
|
34
38
|
},
|
|
35
39
|
"scripts": {
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell-write classification via filesystem observation, not command parsing.
|
|
3
|
+
*
|
|
4
|
+
* Permissive/auto-approve modes steer agents toward the shell for file work on
|
|
5
|
+
* every host, and a shell payload carries a command string, not a file path —
|
|
6
|
+
* so post-shell events reached the server with no edit to report and the
|
|
7
|
+
* session's filesEdited/ideEditCount stayed empty, silencing every edit-gated
|
|
8
|
+
* signal (checkpoint pressure above all). Parsing the command text for written
|
|
9
|
+
* paths was considered and rejected (decision 2026-08-19: shell semantics make
|
|
10
|
+
* path extraction guesswork). What git reports about the working tree is not
|
|
11
|
+
* guesswork.
|
|
12
|
+
*
|
|
13
|
+
* Mechanism: snapshot the set of dirty paths (`git status --porcelain -uall`)
|
|
14
|
+
* at session start, and after each shell call diff the current set against the
|
|
15
|
+
* snapshot. Paths that are newly dirty were written between the two
|
|
16
|
+
* observations; report those as edits and roll the snapshot forward. Only
|
|
17
|
+
* additions count — a commit or stash SHRINKS the set, and saving work is not
|
|
18
|
+
* new work. Edit tool writes are unioned into the snapshot by each host's edit
|
|
19
|
+
* handler so they are never re-credited to the next shell call.
|
|
20
|
+
*
|
|
21
|
+
* Host-agnostic by construction: the caller owns session identity and passes
|
|
22
|
+
* the absolute path of its own private snapshot file. An empty `snapshotFile`
|
|
23
|
+
* is the caller's "no usable session identity" signal and every entry point
|
|
24
|
+
* no-ops on it — a synthesized or fallback id must never own a snapshot.
|
|
25
|
+
* Because the diff rolls the snapshot forward, it is idempotent: a host with
|
|
26
|
+
* no post-shell event may call it from several carrier events and each new
|
|
27
|
+
* write is still reported exactly once.
|
|
28
|
+
*
|
|
29
|
+
* Known limits, accepted by design:
|
|
30
|
+
* - A file already dirty at baseline that the shell modifies again is not
|
|
31
|
+
* re-reported (set membership, not content, is compared).
|
|
32
|
+
* - Any working-tree change between two observations is credited to the shell
|
|
33
|
+
* call, including background processes. For "does this session have unsaved
|
|
34
|
+
* work" that is the right signal.
|
|
35
|
+
* - Only the repo containing cwd is observed. Not a git repo → report nothing
|
|
36
|
+
* rather than guess.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { execFileSync } from 'node:child_process';
|
|
40
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
41
|
+
import { dirname, join } from 'node:path';
|
|
42
|
+
|
|
43
|
+
/** Bounded so a pathological repo cannot stall the hook's PostToolUse turn. */
|
|
44
|
+
const GIT_REV_PARSE_TIMEOUT_MS = 400;
|
|
45
|
+
const GIT_STATUS_TIMEOUT_MS = 800;
|
|
46
|
+
/** T5, the widest volume tier, needs 15 files; 25 keeps tier semantics intact. */
|
|
47
|
+
const MAX_REPORTED_PATHS_PER_CALL = 25;
|
|
48
|
+
/**
|
|
49
|
+
* A dirty set larger than this is not agent work (mass generation, a missing
|
|
50
|
+
* .gitignore). Classification is skipped rather than flooding filesEdited.
|
|
51
|
+
*/
|
|
52
|
+
const MAX_SNAPSHOT_PATHS = 2000;
|
|
53
|
+
|
|
54
|
+
interface DirtySnapshot {
|
|
55
|
+
v: 1;
|
|
56
|
+
root: string;
|
|
57
|
+
paths: string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function runGit(cwd: string, args: string[], timeout: number): string | null {
|
|
61
|
+
try {
|
|
62
|
+
// --no-optional-locks is a TOP-LEVEL git option: passed after the
|
|
63
|
+
// subcommand git exits 129 and every observation silently reports nothing.
|
|
64
|
+
return execFileSync('git', ['--no-optional-locks', '-C', cwd, ...args], {
|
|
65
|
+
timeout,
|
|
66
|
+
encoding: 'utf8',
|
|
67
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
68
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
69
|
+
});
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The current dirty set as absolute paths, or null when cwd is not inside a
|
|
77
|
+
* git repo, git is unavailable, a bound is exceeded, or the set is too large
|
|
78
|
+
* to be agent work.
|
|
79
|
+
*/
|
|
80
|
+
export function listGitDirtyPaths(cwd: string): { root: string; paths: string[] } | null {
|
|
81
|
+
const rootOut = runGit(cwd, ['rev-parse', '--show-toplevel'], GIT_REV_PARSE_TIMEOUT_MS);
|
|
82
|
+
const root = rootOut?.trim();
|
|
83
|
+
if (!root) return null;
|
|
84
|
+
const statusOut = runGit(
|
|
85
|
+
cwd,
|
|
86
|
+
['status', '--porcelain=v1', '-z', '--untracked-files=all'],
|
|
87
|
+
GIT_STATUS_TIMEOUT_MS
|
|
88
|
+
);
|
|
89
|
+
if (statusOut === null) return null;
|
|
90
|
+
const paths: string[] = [];
|
|
91
|
+
const tokens = statusOut.split('\0');
|
|
92
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
93
|
+
const entry = tokens[i];
|
|
94
|
+
if (!entry || entry.length < 4) continue;
|
|
95
|
+
// "XY <path>"; a rename/copy entry is followed by the ORIGINAL path as its
|
|
96
|
+
// own NUL token — consume it so it is never misread as a status entry.
|
|
97
|
+
const statusCode = entry.slice(0, 2);
|
|
98
|
+
paths.push(join(root, entry.slice(3)));
|
|
99
|
+
if (statusCode.startsWith('R') || statusCode.startsWith('C')) i++;
|
|
100
|
+
if (paths.length > MAX_SNAPSHOT_PATHS) return null;
|
|
101
|
+
}
|
|
102
|
+
return { root, paths };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function readSnapshot(snapshotFile: string): DirtySnapshot | null {
|
|
106
|
+
try {
|
|
107
|
+
const parsed = JSON.parse(readFileSync(snapshotFile, 'utf8')) as DirtySnapshot;
|
|
108
|
+
if (parsed?.v !== 1 || typeof parsed.root !== 'string' || !Array.isArray(parsed.paths)) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
return parsed;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function writeSnapshot(snapshotFile: string, root: string, paths: string[]): void {
|
|
118
|
+
try {
|
|
119
|
+
mkdirSync(dirname(snapshotFile), { recursive: true, mode: 0o700 });
|
|
120
|
+
writeFileSync(snapshotFile, JSON.stringify({ v: 1, root, paths } satisfies DirtySnapshot), {
|
|
121
|
+
mode: 0o600,
|
|
122
|
+
});
|
|
123
|
+
} catch {
|
|
124
|
+
// Best-effort: a missing snapshot degrades to "report nothing", never to a
|
|
125
|
+
// wrong report.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Session start: baseline the dirty set so pre-existing dirt is never reported. */
|
|
130
|
+
export function captureGitDirtyBaseline(snapshotFile: string, cwd: string): void {
|
|
131
|
+
if (!snapshotFile) return;
|
|
132
|
+
const current = listGitDirtyPaths(cwd);
|
|
133
|
+
if (!current) return;
|
|
134
|
+
writeSnapshot(snapshotFile, current.root, current.paths);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* After a shell call: paths newly dirty since the last observation, capped.
|
|
139
|
+
* Rolls the snapshot forward. Without a usable baseline it establishes one
|
|
140
|
+
* and reports nothing — degrading toward silence, never toward a false edit.
|
|
141
|
+
*/
|
|
142
|
+
export function diffGitDirtySnapshot(snapshotFile: string, cwd: string): string[] {
|
|
143
|
+
if (!snapshotFile) return [];
|
|
144
|
+
const current = listGitDirtyPaths(cwd);
|
|
145
|
+
if (!current) return [];
|
|
146
|
+
const baseline = readSnapshot(snapshotFile);
|
|
147
|
+
writeSnapshot(snapshotFile, current.root, current.paths);
|
|
148
|
+
if (!baseline || baseline.root !== current.root) return [];
|
|
149
|
+
const known = new Set(baseline.paths);
|
|
150
|
+
const additions = current.paths.filter((p) => !known.has(p));
|
|
151
|
+
return additions.slice(0, MAX_REPORTED_PATHS_PER_CALL);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* After an edit tool: fold tool-reported edits into the snapshot so the next
|
|
156
|
+
* shell diff does not re-credit them. Only when a baseline exists — seeding a
|
|
157
|
+
* partial snapshot would make later diffs report pre-existing dirt.
|
|
158
|
+
*/
|
|
159
|
+
export function addPathsToGitDirtySnapshot(snapshotFile: string, paths: string[]): void {
|
|
160
|
+
if (!snapshotFile || paths.length === 0) return;
|
|
161
|
+
const snapshot = readSnapshot(snapshotFile);
|
|
162
|
+
if (!snapshot) return;
|
|
163
|
+
const known = new Set(snapshot.paths);
|
|
164
|
+
const additions = paths.filter((p) => !known.has(p));
|
|
165
|
+
if (additions.length === 0) return;
|
|
166
|
+
writeSnapshot(snapshotFile, snapshot.root, [...snapshot.paths, ...additions]);
|
|
167
|
+
}
|
package/src/hookRuntime.ts
CHANGED
|
@@ -15,7 +15,8 @@ export async function findProjectIdentity(
|
|
|
15
15
|
opts: { maxDepth?: number } = {}
|
|
16
16
|
): Promise<ProjectIdentity | null> {
|
|
17
17
|
if (!startCwd) return null;
|
|
18
|
-
|
|
18
|
+
const cached = identityCache.get(startCwd);
|
|
19
|
+
if (cached !== undefined) return cached;
|
|
19
20
|
|
|
20
21
|
let directory = isAbsolute(startCwd) ? startCwd : resolve(startCwd);
|
|
21
22
|
const maxDepth = opts.maxDepth ?? 10;
|