@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 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.0'
6
+ version: '14.15.1'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "14.15.0",
6
+ "version": "14.15.1",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "14.15.0",
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",
@@ -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
- // Revalidate the CURRENT fence body against the disk: an entry whose path now exists as anything
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 derived = deriveMasks({ root, lstat, listUntracked, readMountinfo: once });
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 WHOLE chain is guarded BEFORE any read: a symlinked exclude leaf OR a
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) → whether the CURRENT derivation diverges from the fenced block — the
231
- // Recommendations advisor's fire condition (Phase 3, AD-044 Plan 4). True when any fenced entry
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
- // EVERY hand byte outside the managed fence is preserved EXACTLY: the first-apply
266
- // append adds at most ONE separator newline, and the existing-block replace splices via RAW
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
- for (const rel of probe.staleReal) {
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, so it never reads the mount table (the absent answer).
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')) {