@sabaiway/agent-workflow-kit 3.12.0 → 3.14.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/CHANGELOG.md +92 -0
- package/README.md +2 -1
- package/SKILL.md +5 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/hooks/state-block-guard.mjs +363 -0
- package/references/modes/commit-guard.md +14 -7
- package/references/modes/hook.md +3 -1
- package/references/modes/state-block-guard.md +188 -0
- package/tools/commands.mjs +8 -1
- package/tools/commit-guard.mjs +139 -8
- package/tools/core-evidence.mjs +229 -18
package/tools/core-evidence.mjs
CHANGED
|
@@ -152,29 +152,240 @@ export const computeTreeFingerprint = (cwd, fsx) => {
|
|
|
152
152
|
return payload == null ? null : createHash('sha256').update(payload).digest('hex');
|
|
153
153
|
};
|
|
154
154
|
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
155
|
+
// The index↔worktree split the fingerprint deliberately CANNOT see: the payload above concatenates
|
|
156
|
+
// the staged and unstaged diffs, so against an otherwise-empty index a hunk moving into the index
|
|
157
|
+
// leaves it byte-identical — while `git commit` builds the commit from the INDEX alone. This is the
|
|
158
|
+
// ONE computation of that split; isTreeClean and the commit guard's index-lag arm both read it, so
|
|
159
|
+
// they can never disagree about what "the index carries the verified tree" means. Submodule paths
|
|
160
|
+
// are separated because a root-level `git add -A` cannot reach a submodule's own worktree, so they
|
|
161
|
+
// need their own recovery. Anchored at the work-tree ROOT (ls-files is cwd-scoped); null when not
|
|
162
|
+
// decidable — every consumer treats that as fail-closed, never as clean.
|
|
163
|
+
const LS_FILES_TAG_SKIP_WORKTREE = 'S';
|
|
164
|
+
const GITLINK_MODE = '160000';
|
|
165
|
+
const SYMLINK_MODE = '120000';
|
|
166
|
+
const EXECUTABLE_MODE = '100755';
|
|
167
|
+
const OWNER_EXECUTE_BIT = 0o100;
|
|
168
|
+
// `ls-files -v` lowercases the tag for assume-unchanged; skip-worktree is `S`, and an entry
|
|
169
|
+
// carrying BOTH bits prints lowercase `s` (live-pinned by test). The skip test is therefore
|
|
170
|
+
// case-INSENSITIVE — a case-sensitive one would lose skip-worktree on such an entry and turn a
|
|
171
|
+
// legitimate sparse checkout into an endless refusal.
|
|
172
|
+
const isAssumeUnchangedTag = (tag) => tag >= 'a' && tag <= 'z';
|
|
173
|
+
const isSkipWorktreeTag = (tag) => tag.toUpperCase() === LS_FILES_TAG_SKIP_WORKTREE;
|
|
174
|
+
|
|
175
|
+
// `git diff` SKIPS index entries carrying skip-worktree or assume-unchanged, so such a path can
|
|
176
|
+
// hold worktree bytes the gates read while `git commit` takes the stale INDEX blob — the same
|
|
177
|
+
// capture blindness one layer down, and invisible to the plain probe. Those entries are therefore
|
|
178
|
+
// compared DIRECTLY against the worktree. A MISSING skip-worktree path is an ordinary sparse
|
|
179
|
+
// checkout and never a lag; a missing assume-unchanged path is one. Gitlinks belong to the
|
|
180
|
+
// submodule lane. Any probe that cannot answer counts the path as lagging (fail-safe).
|
|
181
|
+
const flaggedIndexLag = (top, runGit, lstat, readlink) => {
|
|
182
|
+
const buf = (args) => {
|
|
183
|
+
const r = runGit(args, top);
|
|
184
|
+
return r.error || r.status !== 0 ? null : r.stdout;
|
|
185
|
+
};
|
|
186
|
+
const splitZ = (b) => b.toString('utf8').split('\0').filter(Boolean);
|
|
187
|
+
// Git emits raw path BYTES. Decoding to UTF-8 first turns a name carrying invalid bytes into a
|
|
188
|
+
// DIFFERENT path, whose lstat then answers ENOENT — which for a skip-worktree entry reads as a
|
|
189
|
+
// de-materialised sparse path and lets a stale index walk through. So each record keeps its
|
|
190
|
+
// original slice and a name that does not survive a byte round-trip is lagging by construction.
|
|
191
|
+
const splitZBytes = (b) => {
|
|
192
|
+
const out = [];
|
|
193
|
+
let start = 0;
|
|
194
|
+
for (let i = 0; i < b.length; i += 1) {
|
|
195
|
+
if (b[i] !== 0) continue;
|
|
196
|
+
if (i > start) out.push(b.subarray(start, i));
|
|
197
|
+
start = i + 1;
|
|
198
|
+
}
|
|
199
|
+
if (start < b.length) out.push(b.subarray(start));
|
|
200
|
+
return out;
|
|
201
|
+
};
|
|
202
|
+
const decodesExactly = (slice) => Buffer.from(slice.toString('utf8'), 'utf8').equals(slice);
|
|
203
|
+
const taggedZ = buf(['ls-files', '-v', '-z']);
|
|
204
|
+
if (taggedZ == null) return null;
|
|
205
|
+
// The two bits are INDEPENDENT — an entry can carry both (which is what lowercases the `S`), so
|
|
206
|
+
// they travel as a pair and the recovery clears whichever are actually set.
|
|
207
|
+
const flagged = splitZBytes(taggedZ)
|
|
208
|
+
.map((record) => {
|
|
209
|
+
const tag = record.subarray(0, 1).toString('utf8');
|
|
210
|
+
const pathBytes = record.subarray(2);
|
|
211
|
+
return {
|
|
212
|
+
rel: pathBytes.toString('utf8'),
|
|
213
|
+
pathBytes,
|
|
214
|
+
exactName: decodesExactly(pathBytes),
|
|
215
|
+
skipWorktree: isSkipWorktreeTag(tag),
|
|
216
|
+
assumeUnchanged: isAssumeUnchangedTag(tag),
|
|
217
|
+
};
|
|
218
|
+
})
|
|
219
|
+
.filter(({ skipWorktree, assumeUnchanged }) => skipWorktree || assumeUnchanged);
|
|
220
|
+
if (flagged.length === 0) return { paths: [], submodules: [], flagged: [] };
|
|
221
|
+
const stagedZ = buf(['ls-files', '-s', '-z']);
|
|
222
|
+
if (stagedZ == null) return null;
|
|
223
|
+
const entries = new Map();
|
|
224
|
+
for (const line of splitZ(stagedZ)) {
|
|
225
|
+
const tab = line.indexOf('\t');
|
|
226
|
+
if (tab === -1) continue;
|
|
227
|
+
const [mode, oid] = line.slice(0, tab).split(' ');
|
|
228
|
+
entries.set(line.slice(tab + 1), { mode, oid });
|
|
229
|
+
}
|
|
230
|
+
// `git diff` honours core.fileMode; mirroring it keeps a false-mode host (WSL, network mounts)
|
|
231
|
+
// from reading every executable bit as a lag. Exit 1 is the only "unset" (git's default is true);
|
|
232
|
+
// any other failure leaves the comparison undecidable rather than guessing.
|
|
233
|
+
const boolConfig = (key) => {
|
|
234
|
+
const r = runGit(['config', '--type=bool', '--get', key], top);
|
|
235
|
+
if (r.error || (r.status !== 0 && r.status !== 1)) return null; // undecidable — never guessed
|
|
236
|
+
return r.status === 1 || r.stdout.toString('utf8').trim() !== 'false'; // exit 1 = unset = git's default true
|
|
237
|
+
};
|
|
238
|
+
const honoursFileMode = boolConfig('core.fileMode');
|
|
239
|
+
// On a host without symlink support git materialises a symlink as a REGULAR FILE holding the
|
|
240
|
+
// target bytes; demanding a real link there would refuse forever.
|
|
241
|
+
const materialisesSymlinks = boolConfig('core.symlinks');
|
|
242
|
+
if (honoursFileMode === null || materialisesSymlinks === null) return null;
|
|
243
|
+
const lagging = [];
|
|
244
|
+
const laggingSubmodules = [];
|
|
245
|
+
const laggingFlags = [];
|
|
246
|
+
for (const { rel, pathBytes, exactName, skipWorktree, assumeUnchanged } of flagged) {
|
|
247
|
+
const entry = entries.get(rel);
|
|
248
|
+
if (entry === undefined) continue;
|
|
249
|
+
const isGitlink = entry.mode === GITLINK_MODE;
|
|
250
|
+
// Every path this loop reports needs its index bits cleared before ANY staging command can
|
|
251
|
+
// pick it up — `git add -A` alone is a recovery that silently does nothing here.
|
|
252
|
+
const lag = () => {
|
|
253
|
+
(isGitlink ? laggingSubmodules : lagging).push(rel);
|
|
254
|
+
laggingFlags.push({ rel, skipWorktree, assumeUnchanged, exactName });
|
|
255
|
+
};
|
|
256
|
+
// Probed by RAW BYTES, never by the decoded name: a lossy decode addresses a DIFFERENT path,
|
|
257
|
+
// and answering ENOENT for it would either wave a stale index through or — worse — call a
|
|
258
|
+
// legitimately absent sparse entry "lagging", whose prescribed bit-clear plus `git add -A`
|
|
259
|
+
// would then stage its DELETION.
|
|
260
|
+
const absPath = Buffer.concat([Buffer.from(top), Buffer.from(sep), pathBytes]);
|
|
261
|
+
let stat = null;
|
|
262
|
+
let absent = false;
|
|
263
|
+
try {
|
|
264
|
+
stat = lstat(absPath);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
// ONLY a genuine absence is the sparse-checkout case; EACCES / EIO leave the path unproven,
|
|
267
|
+
// and an unproven path can never be waved through as "not materialised".
|
|
268
|
+
absent = err != null && err.code === 'ENOENT';
|
|
269
|
+
}
|
|
270
|
+
if (stat === null) {
|
|
271
|
+
if (!absent || !skipWorktree) lag();
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (!exactName) {
|
|
275
|
+
// Materialised, but the name cannot be addressed through an argv string, so it can never be
|
|
276
|
+
// PROVEN current — fail-safe. Absence was already decided above, on the real bytes.
|
|
277
|
+
lag();
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
// A flagged GITLINK is hidden from `git diff` too, so the submodule lane would never see it —
|
|
281
|
+
// and it is deliberately NOT proven current here. Three consecutive review rounds each found a
|
|
282
|
+
// new way for a nested probe to answer "clean" wrongly (inherited superproject GIT_*, status
|
|
283
|
+
// config blindness, the submodule's OWN flagged entries, a symlink standing in for the
|
|
284
|
+
// directory). The set was not shrinking, so the verdict is REDUCTION rather than another patch:
|
|
285
|
+
// a materialised flagged gitlink LAGS by construction. It is a refusal, never an endless one —
|
|
286
|
+
// the printed recovery (clear the bit, then git add -A) converges, and an UNflagged submodule
|
|
287
|
+
// is unaffected because the ordinary --ignore-submodules=none probe still judges it.
|
|
288
|
+
if (isGitlink) {
|
|
289
|
+
lag();
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (entry.mode === SYMLINK_MODE && !stat.isSymbolicLink() && !materialisesSymlinks) {
|
|
293
|
+
// The placeholder file's RAW bytes are the stored target — no filters ever apply to a link.
|
|
294
|
+
const placeholder = runGit(['hash-object', '--no-filters', '-t', 'blob', '--', join(top, rel)], top);
|
|
295
|
+
if (placeholder.error || placeholder.status !== 0 || placeholder.stdout.toString('utf8').trim() !== entry.oid) lag();
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (entry.mode === SYMLINK_MODE || stat.isSymbolicLink()) {
|
|
299
|
+
if (entry.mode !== SYMLINK_MODE || !stat.isSymbolicLink()) {
|
|
300
|
+
lag();
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
let target = null;
|
|
304
|
+
try {
|
|
305
|
+
target = readlink(join(top, rel));
|
|
306
|
+
} catch {
|
|
307
|
+
target = null;
|
|
308
|
+
}
|
|
309
|
+
if (target === null) {
|
|
310
|
+
lag(); // an unreadable link can never be proven current — fail-safe
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const hashed = runGit(['hash-object', '-t', 'blob', '--stdin'], top, target);
|
|
314
|
+
if (hashed.error || hashed.status !== 0 || hashed.stdout.toString('utf8').trim() !== entry.oid) lag();
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (!stat.isFile()) {
|
|
318
|
+
lag();
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
// Git canonicalises the executable mode on the OWNER bit alone — a file with only group/other
|
|
322
|
+
// exec set is still `100644` to git, so testing 0o111 would call a stale index current.
|
|
323
|
+
if (honoursFileMode && (entry.mode === EXECUTABLE_MODE) !== ((stat.mode & OWNER_EXECUTE_BIT) !== 0)) {
|
|
324
|
+
lag();
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const hashed = runGit(['hash-object', '--path', rel, '--', join(top, rel)], top);
|
|
328
|
+
if (hashed.error || hashed.status !== 0 || hashed.stdout.toString('utf8').trim() !== entry.oid) lag();
|
|
329
|
+
}
|
|
330
|
+
return { paths: lagging, submodules: laggingSubmodules, flagged: laggingFlags };
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
export const computeWorkingState = (cwd, { lstat = lstatSync, readlink = readlinkSync, runGit = null } = {}) => {
|
|
334
|
+
const run = runGit ?? ((args, dir, input, env) => spawnSync('git', args, { cwd: dir, input, env: env ?? process.env, maxBuffer: GIT_MAX_BUFFER, windowsHide: true }));
|
|
335
|
+
const topRun = run(['rev-parse', '--show-toplevel'], cwd);
|
|
336
|
+
if (topRun.error || topRun.status !== 0) return null;
|
|
337
|
+
const top = topRun.stdout.toString('utf8').replace(/\r?\n$/, '');
|
|
338
|
+
// `--ignore-submodules=none` on the staged probe too: a config-hidden STAGED gitlink would
|
|
339
|
+
// otherwise make stagedDirty false, and isTreeClean would call such a tree clean.
|
|
340
|
+
const staged = run(['diff', '--cached', '--quiet', '--ignore-submodules=none'], top);
|
|
341
|
+
// ONLY 0 or 1 is a usable answer: a signal-killed probe reports status null, which the old
|
|
342
|
+
// `> 1` guard let through as "nothing staged" — a fail-OPEN the whole arm cannot afford.
|
|
343
|
+
if (staged.error || (staged.status !== 0 && staged.status !== 1)) return null;
|
|
344
|
+
const buf = (args) => {
|
|
345
|
+
const r = run(args, top);
|
|
346
|
+
return r.error || r.status !== 0 ? null : r.stdout;
|
|
347
|
+
};
|
|
348
|
+
// The FULL probe forces submodules back in: diff.ignoreSubmodules / submodule.<n>.ignore would
|
|
349
|
+
// otherwise erase a dirty submodule from the comparison entirely.
|
|
350
|
+
const changedZ = buf(['diff', '--name-only', '-z', '--ignore-submodules=none']);
|
|
351
|
+
const plainZ = buf(['diff', '--name-only', '-z', '--ignore-submodules=all']);
|
|
352
|
+
const untrackedZ = buf(['ls-files', '--others', '--exclude-standard', '-z']);
|
|
353
|
+
if (changedZ == null || plainZ == null || untrackedZ == null) return null;
|
|
354
|
+
const split = (b) => b.toString('utf8').split('\0').filter(Boolean);
|
|
355
|
+
const changed = split(changedZ);
|
|
356
|
+
const plain = new Set(split(plainZ));
|
|
357
|
+
const flagged = flaggedIndexLag(top, run, lstat, readlink);
|
|
358
|
+
if (flagged == null) return null;
|
|
359
|
+
const unstaged = changed.filter((rel) => plain.has(rel));
|
|
360
|
+
const submodules = changed.filter((rel) => !plain.has(rel));
|
|
361
|
+
return {
|
|
362
|
+
stagedDirty: staged.status === 1,
|
|
363
|
+
unstagedPaths: [...unstaged, ...flagged.paths.filter((rel) => !unstaged.includes(rel))],
|
|
364
|
+
unstagedSubmodulePaths: [...submodules, ...flagged.submodules.filter((rel) => !submodules.includes(rel))],
|
|
365
|
+
// Which lagging paths carry index bits, and which — `git add -A` cannot restage these at all.
|
|
366
|
+
flaggedPaths: flagged.flagged,
|
|
367
|
+
untrackedPaths: split(untrackedZ).filter((rel) => {
|
|
171
368
|
try {
|
|
172
369
|
return !isNeverCommittableStat(lstat(join(top, rel)));
|
|
173
370
|
} catch {
|
|
174
371
|
return true;
|
|
175
372
|
}
|
|
176
|
-
})
|
|
177
|
-
|
|
373
|
+
}),
|
|
374
|
+
};
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// Clean = nothing staged, nothing unstaged, no REVIEWABLE untracked-not-ignored paths — the same
|
|
378
|
+
// never-committable filter as the fingerprint, so the two can never disagree about a masks-only
|
|
379
|
+
// tree. Null when not decidable.
|
|
380
|
+
export const isTreeClean = (cwd, fsx) => {
|
|
381
|
+
const state = computeWorkingState(cwd, fsx);
|
|
382
|
+
if (state == null) return null;
|
|
383
|
+
return (
|
|
384
|
+
!state.stagedDirty &&
|
|
385
|
+
state.unstagedPaths.length === 0 &&
|
|
386
|
+
state.unstagedSubmodulePaths.length === 0 &&
|
|
387
|
+
state.untrackedPaths.length === 0
|
|
388
|
+
);
|
|
178
389
|
};
|
|
179
390
|
|
|
180
391
|
// ── the review-receipt read path + attesting predicate (ONE home) ────────────────────────────────
|