@sabaiway/agent-workflow-kit 14.15.0 → 14.15.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.
- package/CHANGELOG.md +17 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/tools/sandbox-masks.mjs +28 -29
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
|
|
|
4
4
|
is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
|
|
5
5
|
every `migrations/<version>-<slug>.md` newer than it, in semver order.
|
|
6
6
|
|
|
7
|
+
## 14.15.1 — a fenced empty read-only file no rule calls a mask stops the probe, so a concurrent sandboxed command no longer turns its masks into real paths (AD-163)
|
|
8
|
+
|
|
9
|
+
**`/agent-workflow-kit sandbox-masks` now withholds its verdict on a possible leftover.** While one sandboxed
|
|
10
|
+
command runs, a second one sees that command's masks as empty read-only regular files; until 14.15.1 the probe called
|
|
11
|
+
each of those fenced masks a real path, and the Recommendations screen offered `--apply --clear`, which would
|
|
12
|
+
remove a working block.
|
|
13
|
+
|
|
14
|
+
- **The refusal.** A fenced entry that neither rule calls a mask, and whose lstat is an empty regular file with a
|
|
15
|
+
numeric mode and no write bit, stops the probe with exit 1; the Recommendations screen records a skip. The message
|
|
16
|
+
lists every fenced entry that became a real path first, then each such file with its three readings: a
|
|
17
|
+
running sandbox's mask (re-run once no other sandboxed command runs), litter a sandbox left (remove the file outside
|
|
18
|
+
the sandbox, keep its line), or a real file (delete its line). No `--clear` is offered and no plain `--apply` drops
|
|
19
|
+
its line.
|
|
20
|
+
- **What stays.** An unfenced file of that shape is not a mask, a fenced foreign mount target of that shape stays a
|
|
21
|
+
mask, a stat without a numeric mode proves nothing, and `--apply --clear` still removes the block without judging
|
|
22
|
+
it. The derivation and the check of fenced entries share one lstat answer per work-tree path within a probe.
|
|
23
|
+
|
|
7
24
|
## 14.15.0 — a sandbox mask made by a bind mount counts as a mask, so the advisor no longer offers to clear a working exclude block (AD-162)
|
|
8
25
|
|
|
9
26
|
**`/agent-workflow-kit sandbox-masks` now reads the Linux mount table.** Until 14.15.0 a mask was only a device, a
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: agent-workflow-kit
|
|
|
3
3
|
description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow". New to the epic, story and task tier? Run `/agent-workflow-kit tier`.
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
metadata:
|
|
6
|
-
version: '14.15.
|
|
6
|
+
version: '14.15.1'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-kit
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "14.15.
|
|
3
|
+
"version": "14.15.1",
|
|
4
4
|
"description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
package/tools/sandbox-masks.mjs
CHANGED
|
@@ -86,6 +86,9 @@ const mountTargets = (root, readMountinfo) => {
|
|
|
86
86
|
}
|
|
87
87
|
};
|
|
88
88
|
|
|
89
|
+
// A LEFTOVER: an empty regular file with a numeric mode and no write bit — it only withholds a verdict.
|
|
90
|
+
const isLeftoverStat = (stat) => stat?.isFile?.() === true && stat.size === 0 && typeof stat.mode === 'number' && (stat.mode & 0o222) === 0;
|
|
91
|
+
|
|
89
92
|
// A root-anchored gitignore pattern for one relative path: leading `/` (root-anchored — a mask
|
|
90
93
|
// name can never shadow a deeper real path), glob metacharacters + leading-! / leading-# handled
|
|
91
94
|
// by the anchor, trailing whitespace escaped (gitignore trims it otherwise).
|
|
@@ -150,9 +153,7 @@ export const patternToRel = (line) => {
|
|
|
150
153
|
return t.slice(1).replace(/\\([\\*?[\] ])/g, '$1');
|
|
151
154
|
};
|
|
152
155
|
|
|
153
|
-
//
|
|
154
|
-
// that is not a mask (the derivation's predicate) is a STALE-REAL flag — the D5 watch (a real file
|
|
155
|
-
// at an excluded path is silently skipped by bulk staging; delete the line before trusting git).
|
|
156
|
+
// A fenced entry that exists and fails the derivation's predicate is STALE-REAL (the D5 watch).
|
|
156
157
|
export const revalidateFence = (bodyLines, { root, lstat = lstatSync, readMountinfo = readMountinfoDefault, foreign = mountTargets(root, readMountinfo).foreign } = {}) => {
|
|
157
158
|
const staleReal = [];
|
|
158
159
|
for (const line of bodyLines) {
|
|
@@ -172,8 +173,8 @@ export const revalidateFence = (bodyLines, { root, lstat = lstatSync, readMounti
|
|
|
172
173
|
// ── the probe (read-only; exported — the Recommendations advisor consumes it) ──────
|
|
173
174
|
|
|
174
175
|
// probeSandboxMasks({ cwd, ... }) → everything the render/apply/advisor needs, or null when not a
|
|
175
|
-
// git work tree. Read-only always.
|
|
176
|
-
export const probeSandboxMasks = ({ cwd = process.cwd(), lstat = lstatSync, listUntracked = listUntrackedUnfilteredZ, readFile = readFileSync, readMountinfo = readMountinfoDefault } = {}) => {
|
|
176
|
+
// git work tree; a fenced LEFTOVER refuses unless clearOnly. Read-only always.
|
|
177
|
+
export const probeSandboxMasks = ({ cwd = process.cwd(), lstat = lstatSync, listUntracked = listUntrackedUnfilteredZ, readFile = readFileSync, readMountinfo = readMountinfoDefault, clearOnly = false } = {}) => {
|
|
177
178
|
const root = gitLine(['rev-parse', '--show-toplevel'], cwd);
|
|
178
179
|
if (root == null) return null;
|
|
179
180
|
const gitPathRaw = gitLine(['rev-parse', '--git-path', 'info/exclude'], cwd);
|
|
@@ -183,23 +184,23 @@ export const probeSandboxMasks = ({ cwd = process.cwd(), lstat = lstatSync, list
|
|
|
183
184
|
// The write-containment root: info/exclude lives in the COMMON git dir (worktree-safe),
|
|
184
185
|
// and the apply must never write through a symlinked component or a non-regular leaf.
|
|
185
186
|
const gitCommonDir = resolve(cwd, commonDirRaw);
|
|
186
|
-
let text; // read ONCE: derivation and revalidation judge one table
|
|
187
|
+
let text; // read ONCE: derivation and revalidation judge one table, and one lstat answer per path
|
|
187
188
|
const once = () => (text === undefined ? (text = readMountinfo()) : text);
|
|
188
|
-
const
|
|
189
|
+
const answers = new Map();
|
|
190
|
+
const lstatOnce = (p) => {
|
|
191
|
+
if (!answers.has(p)) answers.set(p, (() => { try { return { stat: lstat(p) }; } catch (error) { return { error }; } })());
|
|
192
|
+
if (answers.get(p).error) throw answers.get(p).error;
|
|
193
|
+
return answers.get(p).stat;
|
|
194
|
+
};
|
|
195
|
+
const derived = deriveMasks({ root, lstat: lstatOnce, listUntracked, readMountinfo: once });
|
|
189
196
|
if (derived == null) return null;
|
|
190
197
|
const { masks, unrenderable, mountOnly, mountSignal } = derived;
|
|
191
|
-
// The
|
|
192
|
-
// symlinked parent (.git/info) must never be read through, and a FIFO/socket leaf would HANG
|
|
193
|
-
// the read-only probe — parent-chain containment + leaf lstat first, fail closed on everything
|
|
194
|
-
// but a plain absent file, then read the existing REGULAR content. The apply re-checks
|
|
195
|
-
// (defense in depth) before writing.
|
|
198
|
+
// The whole chain is guarded BEFORE any read (a symlinked component; a FIFO/socket leaf would hang).
|
|
196
199
|
try {
|
|
197
200
|
assertContainedRealPath(gitCommonDir, excludeFile, { lstat });
|
|
198
201
|
} catch (err) {
|
|
199
202
|
throw fail(1, `refusing to touch ${excludeFile}: ${err.message}`);
|
|
200
203
|
}
|
|
201
|
-
// The chain guard above already refused symlinked components (the leaf included) and PROPAGATED
|
|
202
|
-
// any non-ENOENT lstat failure — after it, the leaf can only be absent or exist non-symlinked.
|
|
203
204
|
const leafStat = (() => {
|
|
204
205
|
try {
|
|
205
206
|
return lstat(excludeFile);
|
|
@@ -222,16 +223,15 @@ export const probeSandboxMasks = ({ cwd = process.cwd(), lstat = lstatSync, list
|
|
|
222
223
|
}
|
|
223
224
|
const lines = content.split('\n');
|
|
224
225
|
const fence = findMasksFence(lines);
|
|
225
|
-
const staleReal = fence.state === 'ok' ? revalidateFence(fence.body, { root, lstat, readMountinfo: once }) : [];
|
|
226
|
+
const staleReal = fence.state === 'ok' ? revalidateFence(fence.body, { root, lstat: lstatOnce, readMountinfo: once }) : [];
|
|
227
|
+
const leftovers = clearOnly ? [] : staleReal.filter((rel) => isLeftoverStat(lstatOnce(join(root, rel))));
|
|
228
|
+
if (leftovers.length > 0) throw fail(1, [...staleReal.filter((rel) => !leftovers.includes(rel)).map(staleRealLine), ...leftovers.map(leftoverLine)].join('\n'));
|
|
226
229
|
const applyCmd = `node ${shellQuoteArg(join(HERE, 'sandbox-masks.mjs'))} --cwd ${shellQuoteArg(root)} --apply`;
|
|
227
230
|
return { root, excludeFile, gitCommonDir, content, lines, fence, masks, unrenderable, mountOnly, mountSignal, staleReal, applyCmd };
|
|
228
231
|
};
|
|
229
232
|
|
|
230
|
-
// needsMasksApply(probe) →
|
|
231
|
-
//
|
|
232
|
-
// became a real path (stale-real), or when visible masks exist and the derived set ≠ the fenced
|
|
233
|
-
// set. A malformed fence is NOT an apply item (the apply would fail closed — it needs a hand fix,
|
|
234
|
-
// which the probe render itself flags loudly). Pure over the probe result.
|
|
233
|
+
// needsMasksApply(probe) → the advisor's fire condition: a stale-real entry, or visible masks whose set
|
|
234
|
+
// differs from the fenced one; a malformed fence is never an item (the render flags it). Pure.
|
|
235
235
|
export const needsMasksApply = (probe) => {
|
|
236
236
|
if (probe == null) return false;
|
|
237
237
|
if (probe.staleReal.length > 0) return true;
|
|
@@ -262,10 +262,8 @@ export const planApply = (probe, { clear = false } = {}) => {
|
|
|
262
262
|
}
|
|
263
263
|
const masks = clear ? [] : probe.masks;
|
|
264
264
|
const block = masks.length === 0 ? [] : [MASKS_FENCE_START, ...masks.map(toExcludePattern), MASKS_FENCE_END];
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
// string offsets around the fence lines — a split/join('\n') rebuild would silently normalize
|
|
268
|
-
// CRLF hand content outside the block. Only the fenced block itself is ours (always LF).
|
|
265
|
+
// Every hand byte outside the fence is preserved EXACTLY (raw offsets, never a split/join rebuild
|
|
266
|
+
// that would normalize CRLF); only the fenced block itself is ours (always LF).
|
|
269
267
|
const rendered = block.length ? `${block.join('\n')}\n` : '';
|
|
270
268
|
let content;
|
|
271
269
|
if (probe.fence.state === 'ok') {
|
|
@@ -283,6 +281,9 @@ export const planApply = (probe, { clear = false } = {}) => {
|
|
|
283
281
|
|
|
284
282
|
// ── rendering ───────────────────────────────────────────────────────────────────────
|
|
285
283
|
|
|
284
|
+
const staleRealLine = (rel) => ` ⚠ fenced entry became a REAL path: ${rel} — delete its line from the managed block BEFORE relying on git for this path: an excluded real file is silently skipped by bulk staging (git add -A / git add .), so it stays OUT of your commits, and an explicit git add refuses it without -f; .git/info/exclude never warns`;
|
|
285
|
+
const leftoverLine = (rel) => ` ⚠ refusing: fenced entry ${rel} is an empty read-only file no arm calls a mask, so neither verdict is given — a running sandbox's mask: re-run once no other sandboxed command runs; litter a sandbox left: remove the file outside the sandbox and keep its line; a real file: delete its line`;
|
|
286
|
+
|
|
286
287
|
const formatProbe = (probe) => {
|
|
287
288
|
const lines = [
|
|
288
289
|
`sandbox-masks — sandbox masks (device/FIFO/socket or foreign mount target) in ${probe.root}`,
|
|
@@ -293,9 +294,7 @@ const formatProbe = (probe) => {
|
|
|
293
294
|
lines.push(` masks visible now: ${probe.masks.length === 0 ? '0 (outside the sandbox or clean)' : ''}`);
|
|
294
295
|
for (const m of probe.masks) lines.push(` · ${m}${probe.mountOnly.includes(m) ? ' (mount-only: it rides git add -A and the review fingerprint while git does not ignore it)' : ''}`);
|
|
295
296
|
if (probe.mountSignal === 'absent') lines.push(' mount signal: not read on this platform — only device/FIFO/socket masks are judged');
|
|
296
|
-
|
|
297
|
-
lines.push(` ⚠ fenced entry became a REAL path: ${rel} — delete its line from the managed block BEFORE relying on git for this path: an excluded real file is silently skipped by bulk staging (git add -A / git add .), so it stays OUT of your commits, and an explicit git add refuses it without -f; .git/info/exclude never warns`);
|
|
298
|
-
}
|
|
297
|
+
lines.push(...probe.staleReal.map(staleRealLine));
|
|
299
298
|
for (const rel of probe.unrenderable) {
|
|
300
299
|
lines.push(` ⚠ mask name carries a newline or ends with a tab and cannot be expressed as ONE exclude rule — NOT written (the mask itself stays untracked): ${JSON.stringify(rel)}`);
|
|
301
300
|
}
|
|
@@ -343,9 +342,9 @@ export const main = (argv, ctx = {}) => {
|
|
|
343
342
|
const cwd = cwdIdx !== -1 ? argv[cwdIdx + 1] : (ctx.cwd ?? process.cwd());
|
|
344
343
|
if (cwdIdx !== -1 && (!cwd || cwd.startsWith('--'))) throw fail(2, '--cwd requires a directory argument');
|
|
345
344
|
if (argv.includes('--clear') && !argv.includes('--apply')) throw fail(2, '--clear is only valid together with --apply');
|
|
346
|
-
// --apply --clear needs no derivation
|
|
345
|
+
// --apply --clear needs no derivation: it never reads the mount table and never judges the fence.
|
|
347
346
|
const clearOnly = argv.includes('--apply') && argv.includes('--clear');
|
|
348
|
-
const probe = probeSandboxMasks({ cwd, ...(ctx.deps ?? {}), ...(clearOnly ? { readMountinfo: () => null } : {}) });
|
|
347
|
+
const probe = probeSandboxMasks({ cwd, ...(ctx.deps ?? {}), ...(clearOnly ? { readMountinfo: () => null, clearOnly } : {}) });
|
|
349
348
|
if (probe == null) throw fail(1, `not a git work tree: ${cwd}`);
|
|
350
349
|
if (!argv.includes('--apply')) {
|
|
351
350
|
if (argv.includes('--json')) {
|