aegis-desktop 0.5.4 → 0.6.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.
@@ -4,15 +4,21 @@
4
4
  * Minimal git worktree introspection: where the repo root is, and what is
5
5
  * dirty right now with a content hash per path.
6
6
  *
7
- * Ported from aegiscodex-dev/src/git-scope.js (ESM → CommonJS). Only the two
8
- * functions `turn-guard.js` needs are carried over — `gitRoot` and
9
- * `gitStatusSnapshot` (plus their `contentHash` helper). The source file's
10
- * `foreignChanges` and `scopedCommit` belong to the autonomous queue's
11
- * commit path, which the plugin does not host, so they are deliberately not
12
- * vendored: a copy of code nothing calls is a copy that drifts unnoticed.
7
+ * Ported from aegiscodex-dev/src/git-scope.js (ESM → CommonJS). `turn-guard.js`
8
+ * uses `gitRoot` and `gitStatusSnapshot`; the queue now hosts the commit path
9
+ * too, so `foreignChanges` and `scopedCommit` are carried over as well — they
10
+ * were deliberately absent while nothing here queued work, because a copy of
11
+ * code nothing calls is a copy that drifts unnoticed.
13
12
  *
14
- * The "dirty before AND byte-identical now" definition lives in
15
- * `gitStatusSnapshot`'s hashes; `turn-guard.js` is the only consumer.
13
+ * ATTRIBUTION IS TWO CONDITIONS, NOT ONE. "Dirty before AND byte-identical
14
+ * now" only excludes work that nobody touched — it cannot see a concurrent
15
+ * writer who edits a file *during* the task window, because that file is no
16
+ * longer byte-identical and so looks like ours. Commit 96fb64f swept exactly
17
+ * such a file (desktop/electron-builder.yml, edited by another live session)
18
+ * into an unrelated commit that way. So `scopedCommit` also requires positive
19
+ * attribution: a path is staged only if the caller reports that THIS task's own
20
+ * tool layer wrote it (`written`). Everything else dirty — foreign, or merely
21
+ * unattributed — is left alone and reported.
16
22
  */
17
23
 
18
24
  const fs = require('node:fs');
@@ -68,4 +74,152 @@ function gitStatusSnapshot(cwd) {
68
74
  return snap;
69
75
  }
70
76
 
71
- module.exports = { gitRoot, contentHash, gitStatusSnapshot };
77
+ /**
78
+ * Paths that were dirty *before* and are byte-identical now: another agent's
79
+ * in-flight work, not ours. These must never be committed.
80
+ *
81
+ * Byte-identical is the test, not "was dirty": a path that was dirty before and
82
+ * that this task then EDITED has a different hash, so it is ours to commit. Only
83
+ * untouched foreign work is excluded.
84
+ */
85
+ function foreignChanges(cwd, before) {
86
+ if (!before) return [];
87
+ const now = gitStatusSnapshot(cwd);
88
+ if (!now) return [];
89
+ const out = [];
90
+ for (const [p, hash] of now) {
91
+ if (before.has(p) && before.get(p) === hash) out.push(p);
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * A path the caller reported writing, as repo-root-relative POSIX text.
98
+ *
99
+ * The tool layer reports what the MODEL asked for (`file_path` as typed), which
100
+ * may be absolute or relative to the task's own working directory — not
101
+ * necessarily this process's `cwd`. Returns '' for anything outside the repo:
102
+ * a write that cannot be attributed to a path git reports is not something this
103
+ * function may guess about.
104
+ */
105
+ function toRepoRelative(root, base, p) {
106
+ const raw = String(p == null ? '' : p).trim();
107
+ if (!raw) return '';
108
+ const abs = path.isAbsolute(raw) ? raw : path.resolve(base || root, raw);
109
+ const rel = path.relative(root, abs);
110
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return '';
111
+ return rel.split(path.sep).join('/');
112
+ }
113
+
114
+ /** The repo-relative set of paths the task's own tool layer reported writing. */
115
+ function writtenSet(root, base, written) {
116
+ const out = new Set();
117
+ for (const p of written || []) {
118
+ const rel = toRepoRelative(root, base, p);
119
+ if (rel) out.add(rel);
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /**
125
+ * Split the current dirty set by attribution. `ours` is what this task wrote
126
+ * and changed; `left` is everything else git reports as dirty — a peer's
127
+ * in-flight work, plus our own changes we cannot positively attribute (a file a
128
+ * shell command created names no path in the tool call, so it is reported, not
129
+ * assumed).
130
+ *
131
+ * @returns {null|{root:string, ours:string[], left:string[], written:Set<string>}}
132
+ */
133
+ function attributedChanges(cwd, { before, written } = {}) {
134
+ const root = gitRoot(cwd);
135
+ if (!root) return null;
136
+ const now = gitStatusSnapshot(cwd);
137
+ if (!now) return null;
138
+ const mine = writtenSet(root, cwd, written);
139
+ const ours = [];
140
+ const left = [];
141
+ for (const [p, hash] of now) {
142
+ // "Differs from the pre-task snapshot": new since then, or the bytes moved.
143
+ const changed = !(before && before.has(p) && before.get(p) === hash);
144
+ if (changed && mine.has(p)) ours.push(p);
145
+ else left.push(p);
146
+ }
147
+ return { root, ours, left, written: mine };
148
+ }
149
+
150
+ /**
151
+ * Commit only the changes this task is known to have made.
152
+ *
153
+ * The rule, in one line: stage a dirty path only if it DIFFERS from the
154
+ * pre-task snapshot AND this task's tool layer reports writing it. Two
155
+ * conditions, because either alone is unsafe: "changed since the snapshot"
156
+ * sweeps a concurrent writer who edits mid-run, and "was dirty before and is
157
+ * byte-identical now" cannot be the only exclusion because it sees only the
158
+ * writer who stopped.
159
+ *
160
+ * `before` is the `gitStatusSnapshot(cwd)` taken immediately before the task
161
+ * ran and `written` the paths the caller's tool layer wrote during it; without
162
+ * either there is no basis for attribution, and the only safe move is to refuse
163
+ * (or stage nothing) — `git add -A` here would commit a concurrent agent's
164
+ * half-finished edit under this task's message.
165
+ *
166
+ * @returns {{ok?:boolean,skipped?:boolean,reason?:string,error?:string,
167
+ * message?:string,staged?:number,paths?:string[],foreign?:string[],
168
+ * identicalForeign?:string[]}}
169
+ */
170
+ function scopedCommit(cwd, { message, before, written } = {}) {
171
+ if (!gitRoot(cwd)) return { skipped: true, reason: 'not a git repo' };
172
+
173
+ // No basis for attribution — refuse rather than sweep.
174
+ if (!before) {
175
+ return { ok: false, error: 'no pre-task snapshot; refusing to sweep the working tree' };
176
+ }
177
+
178
+ const attributed = attributedChanges(cwd, { before, written });
179
+ if (!attributed) return { skipped: true, reason: 'not a git repo' };
180
+ const { root, ours, left } = attributed;
181
+
182
+ if (!ours.length) {
183
+ const untouched = foreignChanges(cwd, before);
184
+ return {
185
+ ok: true,
186
+ skipped: true,
187
+ foreign: left,
188
+ identicalForeign: untouched,
189
+ reason: left.length
190
+ ? `no changes of our own (left ${left.length} change(s) this task did not write)`
191
+ : 'no changes',
192
+ };
193
+ }
194
+
195
+ // pathspec-from-file keeps an arbitrarily long (or space/NUL-bearing) list
196
+ // safe — a 400-file commit is fine, a path with a newline in it is not.
197
+ const add = spawnSync('git', ['add', '-A', '--pathspec-from-file=-', '--pathspec-file-nul'], {
198
+ cwd: root,
199
+ encoding: 'utf8',
200
+ input: ours.join('\0'),
201
+ });
202
+ if (add.status !== 0) return { ok: false, error: add.stderr || 'git add failed' };
203
+
204
+ const commit = spawnSync('git', ['commit', '-m', message], { cwd: root, encoding: 'utf8' });
205
+ if (commit.status !== 0) {
206
+ // "nothing to commit" is a success for our purposes: the changes we staged
207
+ // were already committed by somebody else, and the task's work is done.
208
+ if (/nothing to commit|no changes added/i.test(commit.stdout + commit.stderr)) {
209
+ return { ok: true, skipped: true, foreign: left, reason: 'no changes' };
210
+ }
211
+ return { ok: false, error: commit.stderr || commit.stdout };
212
+ }
213
+ return { ok: true, message, foreign: left, paths: ours, staged: ours.length };
214
+ }
215
+
216
+ module.exports = {
217
+ gitRoot,
218
+ contentHash,
219
+ gitStatusSnapshot,
220
+ foreignChanges,
221
+ toRepoRelative,
222
+ writtenSet,
223
+ attributedChanges,
224
+ scopedCommit,
225
+ };