@sabaiway/agent-workflow-kit 14.15.0 → 14.16.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 CHANGED
@@ -4,6 +4,45 @@ 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.16.0 — `/agent-workflow-kit jev` walks you to a first Jev request: what it is, what it is not, three steps and three prompts where it pays (AD-164)
8
+
9
+ **A new read-only command, `/agent-workflow-kit jev`, recommends Jev (TypeSafe), a decision model that returns a typed
10
+ answer with probabilities and a confidence.** It says what Jev is, why it pays in this workflow, and what it is not,
11
+ quoting the vendor's own sentence that it is not a drop-in replacement for the LLM behind Claude Code, Cursor, opencode
12
+ or Copilot, then the jagged edges of jev-1.13, the confidence caveat and the retention terms as the vendor states them,
13
+ and its sources, each with the date it was read.
14
+
15
+ - **Three steps to a first request.** The vendor skill: both install forms, Claude Code's and the other agents', in a
16
+ fixed order, and a mark: `found` names each of five skill paths that holds a `typesafe-ai/SKILL.md`, `not seen`
17
+ says a plugin install is not visible to a file check and names the checks that do see it. The key: where to get it,
18
+ `TYPESAFE_API_KEY` exported before the agent starts, never pasted into the chat, and a `set` / `not set` mark. The
19
+ first use: a prompt routing three tickets, then the vendor's smallest documented request and response with the
20
+ field and error meanings, quoted.
21
+ - **Three prompts where it pays here:** the first real error in a red gate log, the decided-register entry a council
22
+ finding repeats, a paragraph that restates a named rule. Each tells the agent to keep every request within the vendor
23
+ limits (255 options, 64k tokens, 32k for the state plus the longest question), and each answer is a hint the agent
24
+ checks, never a gate; every candidate text goes to the vendor.
25
+ - **Safe to run anywhere.** The guide writes nothing, opens no connection and never prints the key's value; `--json`
26
+ gives the same content as an envelope. The command appears in the command list, the skill description and the
27
+ README's Use table.
28
+
29
+ ## 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)
30
+
31
+ **`/agent-workflow-kit sandbox-masks` now withholds its verdict on a possible leftover.** While one sandboxed
32
+ command runs, a second one sees that command's masks as empty read-only regular files; until 14.15.1 the probe called
33
+ each of those fenced masks a real path, and the Recommendations screen offered `--apply --clear`, which would
34
+ remove a working block.
35
+
36
+ - **The refusal.** A fenced entry that neither rule calls a mask, and whose lstat is an empty regular file with a
37
+ numeric mode and no write bit, stops the probe with exit 1; the Recommendations screen records a skip. The message
38
+ lists every fenced entry that became a real path first, then each such file with its three readings: a
39
+ running sandbox's mask (re-run once no other sandboxed command runs), litter a sandbox left (remove the file outside
40
+ the sandbox, keep its line), or a real file (delete its line). No `--clear` is offered and no plain `--apply` drops
41
+ its line.
42
+ - **What stays.** An unfenced file of that shape is not a mask, a fenced foreign mount target of that shape stays a
43
+ mask, a stat without a numeric mode proves nothing, and `--apply --clear` still removes the block without judging
44
+ it. The derivation and the check of fenced entries share one lstat answer per work-tree path within a probe.
45
+
7
46
  ## 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
47
 
9
48
  **`/agent-workflow-kit sandbox-masks` now reads the Linux mount table.** Until 14.15.0 a mask was only a device, a
package/README.md CHANGED
@@ -235,6 +235,7 @@ file), or run the guarded `/agent-workflow-kit uninstall`.
235
235
  | `/agent-workflow-kit status` | any time | **read-only** single view of **versions + deployment + settings + bridges**: which members (kit / memory / engine / the two bridges) are installed and at what version (with an honest "installed on this machine" note when one is behind) and — in a project — what's deployed (`docs/ai`, the version stamps, and the **visibility**: visible / hidden / unclear), and the **feature-spec adoption state** (`not adopted` / `adopting (N draft)` / `adopted (N live, M draft)` / `could not be read`, plus `declined` when recorded), plus your settings (orchestration recipes, attribution, velocity) and the bridges' readiness. The two version axes (package number vs deployment-structure head) stay decoupled. Never writes, never commits, never runs a subscription CLI. |
236
236
  | `/agent-workflow-kit now` | any time | **read-only** answer to where work stands in four blocks: **NOW** names every plan in flight, its phase, current and next step, and the tree evidence; **STEPS** gives every ledger row its evidence-derived status; **QUEUE** folds named rows under their full heading paths and calls position priority only in the judged backlog; **CAMPAIGN** shows the source-size practice figures and config state. The plan's own text never sets a status; a contradicted claim prints beside it. A block that cannot be derived is WITHHELD with its cause; NOW has two halves, plans and tree, each independently a value or WITHHELD, and NOW itself is WITHHELD only when both are. Never writes, never commits, never runs a subscription CLI. |
237
237
  | `/agent-workflow-kit tier` | any time | **read-only** guide to the epic, story and task tier: what each level is, and the next step of the walk, with the command that runs it wherever a tool does. Never writes, never commits, never runs a subscription CLI. |
238
+ | `/agent-workflow-kit jev` | any time | **read-only** guide to Jev (TypeSafe), a decision model: what it is, why it pays here, what it is not, the three steps to a first request and three prompts where it pays. Opens no connection and never prints the key. |
238
239
  | `/agent-workflow-kit recipes` | any time | **read-only** orchestration advisor: presents five named recipes for composing the carriers of a step — the bridges and the full-tool executor subagent — into plan → execute → review — **Solo / Reviewed / Council / Delegated / Subagent** — plans + recommends one for your environment (degrading with a stated reason when a backend isn't ready, or when the executor vehicle is missing/unusable), and offers the choice. The activity/slot registry (six activities) is rendered by `procedures` and by `set-recipe --help`. The orchestrator runs it via the bridge skills or the executor vehicle and **always commits**; the kit never executes a recipe, never runs a subscription CLI, never commits. |
239
240
  | `/agent-workflow-kit procedures <activity>` | any time | **read-only** activity-procedures advisor: prints a named activity's ordered steps (`plan-authoring` / `plan-execution` / `routine` / `feedback-triage` / `epic` / `task`) read **live** from the engine, plus the **resolved recipe per slot** from your `docs/ai/orchestration.json` (agent-writable via `set-recipe`, or hand-edited) + carrier readiness (`plan-authoring`: author, fold, review; default Reviewed when a backend is ready, Council on request; slot-aware incl. Delegated and Subagent) — and, for every dispatched backend, the **full driving contract at the point of use** (exact copy-pasteable invocation, grounding levers like agy's `--facts`/`--decided`, the round-2 `--continue` delta, guarded passthrough), verbatim from the bridge manifests (drift-guarded; each wrapper's `--help` prints the same) — except `feedback-triage`, whose record-bound block carries the check with `--excerpts`, the resolved member lines (ready bridge commands, the ready lens brief, or explicit skipped members), and either the record round-table command for a bridge obligation or the no-receipt fact otherwise: the record is the artifact. `--override <slot>=<value>` adjusts one slot per run. Composes with `recipes`; never writes, never commits, never runs a subscription CLI. |
240
241
  | `/agent-workflow-kit set-recipe` | any time | **config writer** for `docs/ai/orchestration.json`: tell the agent your preference in plain language and it maps it to explicit `--set <activity>.<slot>=<value>` / `--unset` ops; the kit validates, **previews by default**, and writes only on `--write` (deployment-gated, atomic, symlink/TOCTOU-safe), resolving the effective recipe vs live readiness. Writes **only** that file — **never runs a backend or a subagent, never commits**; hand-editing stays fully supported. |
package/SKILL.md CHANGED
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: agent-workflow-kit
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`.
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`. A typed decision with a confidence from Jev? Run `/agent-workflow-kit jev`.
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '14.15.0'
6
+ version: '14.16.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
@@ -119,6 +119,10 @@ read-only — read `${CLAUDE_SKILL_DIR}/references/modes/now.md` before acting.
119
119
 
120
120
  read-only — read `${CLAUDE_SKILL_DIR}/references/modes/tier.md` before acting.
121
121
 
122
+ ### Mode: jev
123
+
124
+ read-only — read `${CLAUDE_SKILL_DIR}/references/modes/jev.md` before acting.
125
+
122
126
  ### Mode: recommendations
123
127
 
124
128
  read-only — read `${CLAUDE_SKILL_DIR}/references/modes/recommendations.md` before acting.
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.16.0",
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.16.0",
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",
@@ -0,0 +1,21 @@
1
+ ### Mode: jev
2
+
3
+ <!-- opt-in-capability: none — a read-only guide that configures nothing and calls no service -->
4
+
5
+ Read-only. The Jev guide: what Jev (TypeSafe) is, why it pays in this workflow, what it is not, the three steps to a first request and three prompts where it pays. It writes no file, opens no connection, never prints the key's value and never commits.
6
+
7
+ Run `node ${CLAUDE_SKILL_DIR}/tools/jev-guide.mjs --dir <project> --json`.
8
+
9
+ Render the returned envelope compactly in the user's conversational language. Never paste the JSON. Keep every path and every command verbatim, each command on its own line so it runs as printed; translate only the prose around them. Keep every vendor-quoted text verbatim and in quotes, never translated or reflowed: the not-a-replacement sentence, the request, the response, the three field meanings and the four error meanings.
10
+
11
+ Keep this order: the fixed text (WHAT, WHY HERE, NOT, SOURCES); then the three steps — the vendor skill with its install lines and its mark, the key with its mark, the first use; then the three prompts where it pays.
12
+
13
+ Exit codes:
14
+
15
+ | Code | Meaning |
16
+ |------|---------|
17
+ | 0 | Rendered, or `--help` / `-h` as the whole invocation. |
18
+ | 1 | Nothing rendered: a stat of the `--dir` does not report a directory; the cause is printed on stderr. |
19
+ | 2 | Usage: an unknown argument, a `--dir` without a value, or `--help` / `-h` combined with anything. |
20
+
21
+ **Invariants:** read-only · no network · the key's value never printed · compact user-language render with paths, commands and vendor quotes verbatim · `process.exitCode`, never `process.exit()`.
@@ -98,6 +98,13 @@ const CATALOG = [
98
98
  kind: READ_ONLY,
99
99
  oneLine: 'Explain the epic, story and task tier in plain words and name the next step of the walk, with the command that runs it wherever a tool does: the tier guide reads the disk alone.',
100
100
  },
101
+ {
102
+ key: 'jev',
103
+ invocation: invocationOf('jev'),
104
+ group: 'Inspect',
105
+ kind: READ_ONLY,
106
+ oneLine: 'Guide to Jev (TypeSafe), a decision model: what it is, why it pays here, the steps to a first request and three prompts where it pays; the guide opens no connection.',
107
+ },
101
108
  {
102
109
  key: 'backends',
103
110
  invocation: invocationOf('backends'),
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+ // spec:jev-guide — docs/ai/specs/kit/jev-guide/index.md
3
+ import { statSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+ import { fail } from '../references/scripts/markdown-blocks.mjs';
7
+ import { isDirectRun } from './direct-run.mjs';
8
+
9
+ const SCHEMA = 1;
10
+ const KEY_VARIABLE = 'TYPESAFE_API_KEY';
11
+ const SKILL_FILE = join('typesafe-ai', 'SKILL.md');
12
+ const HELP = `jev — read-only: what Jev (TypeSafe) is, why it pays here, and the steps to a first request.
13
+
14
+ Usage:
15
+ node jev-guide.mjs [--dir PROJECT] [--json]
16
+
17
+ --dir the project to read (default: the current directory).
18
+ --json the JSON envelope instead of the plain text.
19
+ --help answered only when it is the whole invocation.
20
+
21
+ Exit codes: 0 rendered; 1 nothing rendered (a --dir that is not a directory); 2 usage.
22
+
23
+ Reads only: the --dir, the home directory, the key variable's presence and five skill paths; no network.`;
24
+
25
+ export const JEV_TEXT = Object.freeze([
26
+ 'WHAT: Jev is a decision model: it takes a state and typed questions (choice, score, noul) and returns typed answers with probabilities and a confidence; it does not write text.',
27
+ 'WHY HERE: a typed decision with a confidence instead of prompt-and-parse, in the user\'s own code and in the agent\'s scratch scripts.',
28
+ 'Price and latency as the vendor states them: 0.042 dollars per million input tokens, output free; sub-second.',
29
+ 'NOT: "Jev is not a drop-in replacement for the LLM behind Claude Code, Cursor, opencode, Copilot" (the vendor).',
30
+ 'Jagged edge of jev-1.13: it reads literally.',
31
+ 'Jagged edge of jev-1.13: no counting or arithmetic.',
32
+ 'Jagged edge of jev-1.13: dates are read as text.',
33
+ 'Jagged edge of jev-1.13: a large irrelevant state distracts it.',
34
+ 'Jagged edge of jev-1.13: adversarial content can move the answer.',
35
+ 'Jagged edge of jev-1.13: indirection lowers accuracy.',
36
+ 'Jagged edge of jev-1.13: no structural invariants across questions.',
37
+ 'Jagged edge of jev-1.13: no generation.',
38
+ 'A confidence is not a correctness: start with conservative thresholds and test with your own data.',
39
+ 'Retention as the vendor states it: zero data retention for enterprise customers only; a data processing agreement covers retention; a commitment not to train on user data.',
40
+ 'SOURCES: docs.typesafe.ai for every Jev claim, read 2026-09-29.',
41
+ 'The skills CLI README (github.com/vercel-labs/skills) for the skill paths, read 2026-09-29.',
42
+ 'code.claude.com for claude plugin list, read 2026-09-29.',
43
+ ]);
44
+
45
+ const INSTALL = [
46
+ 'Claude Code:',
47
+ 'claude plugin marketplace add typesafe-ai/skills',
48
+ 'claude plugin install typesafe@typesafe-ai',
49
+ 'Other agents:',
50
+ 'npx skills add typesafe-ai/skills --skill typesafe-ai',
51
+ ];
52
+ const NOT_SEEN = [
53
+ 'skill: not seen at the five skill paths. A plugin install is not visible to a file check; check it by its owners:',
54
+ '/typesafe:typesafe-ai',
55
+ 'in a Claude Code session (the vendor); ask any other agent to use the TypeSafe skill (the vendor); or run Claude Code\'s own',
56
+ 'claude plugin list',
57
+ ];
58
+ const KEY_TEXT = [
59
+ 'Get a key at console.typesafe.ai and set it on every host the agent runs on.',
60
+ `Export ${KEY_VARIABLE}, the variable the vendor's SDKs read, in the shell the agent is started from: an agent inherits the environment its session started with, so export before starting it or restart it.`,
61
+ 'Never paste the key into the chat or into a project file.',
62
+ ];
63
+ const KEY_NOT_SET = `key: ${KEY_VARIABLE} not set. A host setting that filters the environment of the agent's commands also hides it.`;
64
+ const FIRST_USE = [
65
+ 'Give the agent this prompt: using the TypeSafe skill, route these three tickets to billing, technical or sales, one request each, and print each choice and confidence — "I was charged twice this month.", "The app crashes when I upload a file.", "Is there a discount for a yearly plan?".',
66
+ 'The vendor\'s smallest documented request:',
67
+ 'POST https://api.typesafe.ai/v1/systemone',
68
+ 'Authorization: Bearer <API_KEY>',
69
+ '{"state":"Help! My payouts have been failing for 3 days.","model":"jev-latest","questions":{"department":{"type":"choice","instructions":"Which team should handle this?","criteria":{"billing":"Payments, invoicing, refunds","technical":"Bugs, outages, integrations","sales":"Pricing, upgrades, new accounts"}}}}',
70
+ 'Its documented response:',
71
+ '{"model":"jev-1.13.0","answers":{"department":{"type":"choice","choice":"billing","probabilities":{"billing":0.88,"technical":0.12,"sales":0.0},"confidence":0.81}},"usage":{"input_tokens":318,"output_tokens":34}}',
72
+ 'choice: "The highest-probability option."',
73
+ 'probabilities: "Every option mapped to its probability (floats that sum to 1)."',
74
+ 'confidence: "How certain the model is, derived from probabilities."',
75
+ '401: "Missing or invalid API key. Check the `Authorization` header."',
76
+ '422: "The request body failed validation — for example a missing required field or a malformed question. The body details the offending field."',
77
+ '429: "You have exceeded your rate limit. Back off and retry after a short delay."',
78
+ '529: "TypeSafe is temporarily overloaded. Retry after a short delay."',
79
+ 'Success: three choices, each with a confidence.',
80
+ ];
81
+ const LIMITS = 'Each request stays within the vendor limits: 255 options, 64k tokens, 32k for the state plus the longest question.';
82
+ const HINT = 'The answer is a hint the agent checks, never a gate; every candidate text is sent to the vendor.';
83
+ const PROMPTS = [
84
+ `(a) A red gate log: give each failing gate's own lines ids and ask one choice of the first real error per failing gate. ${LIMITS} ${HINT}`,
85
+ `(b) Council findings against the decided register: for each finding, ask a choice of the register id it repeats, or none. ${LIMITS} ${HINT}`,
86
+ `(c) Candidate paragraphs with ids: ask one noul each that the paragraph restates a named rule. ${LIMITS} ${HINT}`,
87
+ ];
88
+
89
+ const valueAt = (argv, index, flag) => {
90
+ const value = argv[index + 1];
91
+ if (value === undefined || value === '' || value.startsWith('-')) throw fail(2, `${flag} takes a value`);
92
+ return value;
93
+ };
94
+ const parseArgv = (argv) => {
95
+ if (argv.includes('--help') || argv.includes('-h')) {
96
+ if (argv.length !== 1) throw fail(2, '--help is answered only when it is the whole invocation');
97
+ return { help: true };
98
+ }
99
+ return argv.reduce((parsed, arg, index) => {
100
+ if (parsed.skip) return { ...parsed, skip: false };
101
+ if (arg === '--dir') return { ...parsed, dir: valueAt(argv, index, arg), skip: true };
102
+ if (arg === '--json') return { ...parsed, json: true };
103
+ throw fail(2, `unknown argument "${arg}" — run with --help`);
104
+ }, { help: false, dir: '.', json: false, skip: false });
105
+ };
106
+
107
+ const statIs = (path, kind) => {
108
+ try { return kind === 'file' ? statSync(path).isFile() : statSync(path).isDirectory(); } catch { return false; }
109
+ };
110
+ const skillPaths = (dir, home) => [...new Set([join(dir, '.claude', 'skills'), join(dir, '.agents', 'skills'),
111
+ join(home, '.claude', 'skills'), join(home, '.codex', 'skills'), join(home, '.cursor', 'skills')]
112
+ .map((root) => join(root, SKILL_FILE)))];
113
+ const keySet = (env) => typeof env[KEY_VARIABLE] === 'string' && env[KEY_VARIABLE].trim() !== '';
114
+
115
+ const renderSteps = (found, set) => [
116
+ ['STEP 1 — the vendor skill', ...INSTALL,
117
+ ...(found.length ? found.map((path) => `skill: found at ${path}`) : NOT_SEEN)],
118
+ ['STEP 2 — the key', ...KEY_TEXT, set ? `key: ${KEY_VARIABLE} set.` : KEY_NOT_SET],
119
+ ['STEP 3 — the first use', ...FIRST_USE],
120
+ ];
121
+
122
+ export const main = (argv, io = {}) => {
123
+ const log = io.log ?? console.log;
124
+ const error = io.error ?? console.error;
125
+ const parsed = (() => {
126
+ try { return parseArgv(argv); } catch (err) { error(err.message); return null; }
127
+ })();
128
+ if (parsed === null) return 2;
129
+ if (parsed.help) { log(HELP); return 0; }
130
+ const dir = resolve(io.cwd ?? process.cwd(), parsed.dir);
131
+ if (!statIs(dir, 'directory')) { error(`${dir} is not a directory`); return 1; }
132
+ const found = skillPaths(dir, io.home ?? homedir()).filter((path) => statIs(path, 'file'));
133
+ const set = keySet(io.env ?? process.env);
134
+ const steps = renderSteps(found, set);
135
+ log(parsed.json
136
+ ? JSON.stringify({ schema: SCHEMA, command: 'jev', dir, text: JEV_TEXT, skill: { found }, key: { set },
137
+ steps, prompts: PROMPTS }, null, 2)
138
+ : [...JEV_TEXT, ...steps.flat(), 'WHERE IT PAYS IN THIS WORKFLOW', ...PROMPTS].join('\n'));
139
+ return 0;
140
+ };
141
+
142
+ if (isDirectRun(import.meta.url)) process.exitCode = main(process.argv.slice(2));
@@ -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')) {