@sabaiway/agent-workflow-kit 14.23.0 → 14.25.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 +55 -0
- package/README.md +16 -5
- package/SKILL.md +1 -1
- package/bin/install.mjs +15 -15
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/bootstrap.md +5 -1
- package/references/modes/mcp.md +9 -4
- package/references/modes/recommendations.md +20 -11
- package/references/modes/upgrade.md +3 -2
- package/references/modes/velocity.md +1 -1
- package/tools/ack-write.mjs +1 -1
- package/tools/bridge-sandbox-recipe.mjs +40 -0
- package/tools/init-preflight.mjs +92 -0
- package/tools/init-project.mjs +400 -0
- package/tools/manifest/schema.md +9 -9
- package/tools/manifest/validate.mjs +5 -5
- package/tools/mcp.mjs +77 -19
- package/tools/recommendations.mjs +71 -108
- package/tools/velocity-profile.mjs +64 -14
- package/tools/write-lanes.mjs +171 -0
|
@@ -50,7 +50,7 @@ Honesty notes: tier entries get **NO PreToolUse-hook residual coverage — with
|
|
|
50
50
|
|
|
51
51
|
**The boundary of that qualifier, stated so it is not guessed.** It covers claims about `sandbox.*` keys — the block whose honouring was observed to vary by host. It deliberately does NOT cover `permissions.*` semantics (an allow rule auto-approving a command, `local > project` precedence for a non-sandbox key): that is the harness's own documented permission model, the layer this whole mode rests on, and hedging it would say nothing true. **Stated residual:** which sentences count as settings-derived runtime claims is decided by review, not by a checker — deciding whether a sentence asserts a runtime effect is a question about prose meaning, and this family deliberately has no mechanism that judges that (the same limit that retired the byte-scanning deny rung). A new claim-bearing line therefore has to carry the qualifier by authorship; the acceptance criterion is that every line stating what a `sandbox.*` key DOES carries it, and the tests pin the surfaces that exist today. What does NOT change is the CLASSIFICATION: which red-line a key would weaken, and that a proven bridge-tier exclusion is a note rather than a weakening, are properties of the declaration and stay flat statements.
|
|
52
52
|
|
|
53
|
-
**The `--bridge-tier` (own opt-in, AD-044).** Seeds what a promptless council review run needs —
|
|
53
|
+
**The `--bridge-tier` (own opt-in, AD-044).** Seeds what a promptless council review run needs — three surfaces: `permissions.allow` prefix rules, the wrapper names in `sandbox.excludedCommands` (where the host honors the settings sandbox keys the harness runs an excluded command OUTSIDE the sandbox — the wrappers need network — so a plain allowlisted invocation triggers no sandbox-bypass approval; where it does not, the wrapper starts sandboxed and simply prompts or fails loudly — fail-safe, never a silent widening), and the placed bridges' declared hosts in `sandbox.network.allowedDomains` (below). All three land in the **project** `.claude/settings.json` — the file this writer owns; an exclusion placed only in `settings.local.json` was live-observed NOT to route the command outside the sandbox (2026-07-11: the wrapper then starts sandboxed and dies on a read-only HOME + a network prompt), so hand-wiring the local file is not a working substitute for this tier. **Honesty note:** a session whose sandbox is imposed by the harness runtime itself (e.g. an IDE-managed session sandbox) may ignore settings-level exclusions entirely — there the wrappers need the session-level sandbox config (or a per-run consented bypass); the tier's seeded posture is correct for a settings-native sandbox that honours the exclusion and simply prompts again elsewhere (fail-safe, never a silent widening). A harness that reads the settings sandbox keys may still drop this exclusion (observed 2026-10-06: Claude Code 2.1.291 ran an agent's plain `codex-review` inside its sandbox with the project entry present); its lane is the settings-native one in `${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`. Each bridge's observed egress hosts are declared in its `capability.json` `networkHosts` — the single documentation source (observed-minimal — a blocked host names itself at run time; read the manifests, this doc deliberately retypes no host list). The tier ALSO seeds the placed bridges' declared hosts — the `networkHosts` of every bundled manifest whose review wrapper is placed — into the project's `sandbox.network.allowedDomains` (AD-172, which reverses the bridge council of 2026-07-11 for those hosts only): where the host honors the settings sandbox keys, a wrapper a harness keeps sandboxed despite the exclusion is no longer refused them, and every sandboxed command of the project can then reach those hosts, not only the wrappers — that widening is part of this consent, and the `--autonomy` preview reports it as a network weakening. `sandbox.filesystem.allowWrite` stays unseeded: a write allowance on CLI state dirs (`~/.codex`, `~/.gemini/…`) would make credential dirs writable to every sandboxed command, so the writable state dirs stay hand-apply. Per-bridge picture under a harness-managed sandbox (live-observed 2026-07-11/12): an IDE-managed session sandbox ignores hand-applied `sandbox.network.allowedDomains` / `sandbox.filesystem.allowWrite` in BOTH settings scopes — its own per-host network consents govern egress; the durable zero-prompt lanes there are the session/host sandbox config (hosts from `networkHosts` + the CLI state-dir writes) or the per-run consented bypass. codex additionally needs a writable HOME (EROFS `~/.codex`); note the apex-vs-wildcard nuance — an apex domain is NOT covered by its `*.`-wildcard form, so the manifests carry both forms where observed (the blocked host names itself at run time). The upgrade Recommendations advisor surfaces exactly this recipe (hosts ∪ resolved `writableDirs`) as the `sandbox-lane` discoverability item — a consent-gated **ack writer** converging on a neutral fingerprint acknowledgement recorded into the family-owned `docs/ai/acks.json` (the writable state dirs stay hand-apply; the hosts are the tier's), with the posture notes at the consent moment (`${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`). Membership is the FROZEN review-wrapper constant, **never** the execution/probe wrappers (`codex-exec`, `agy-run` keep their human prompt — delegated execution is not covered by this consent; codex-exec's nested-sandbox recovery is the canon's observed-failure lane, not a preemptive tier seed), and only the **`code` review mode** — a `plan`/`diff` invocation takes a file argument that can point OUTSIDE the repo, so those modes keep their prompt; each wrapper entry derives ONLY when its bridge is **placed on PATH** (an absent bridge is a stated skip). The seeded byte-forms (this list IS the documented-invocation source for the bridge tier):
|
|
54
54
|
|
|
55
55
|
- `Bash(codex-review code:*)` — the code-mode prefix, args wildcard; plus `codex-review` in `sandbox.excludedCommands`
|
|
56
56
|
- `Bash(agy-review code:*)` — the code-mode prefix, args wildcard; plus `agy-review` in `sandbox.excludedCommands`
|
package/tools/ack-write.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// ANSWERED renders THIS tool's PREVIEW one-liner; the preview prints the exact `--apply` command;
|
|
5
5
|
// the agent runs `--apply` only after the mode doc's §3 informed-consent confirmation. It records a
|
|
6
6
|
// NEUTRAL fingerprint acknowledgement — never a security key; the kit never writes sandbox
|
|
7
|
-
//
|
|
7
|
+
// filesystem allowances, and allowedDomains only through velocity-profile --bridge-tier.
|
|
8
8
|
//
|
|
9
9
|
// The wording is per-LANE or genuinely neutral, never "recipe": the sandbox lane acknowledges a
|
|
10
10
|
// session-sandbox recipe, but the coverage-domain lane acknowledges a census FACT and the
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// bridge-sandbox-recipe.mjs — the one reader of the bundled bridges' declared sandbox surfaces
|
|
2
|
+
// (contract: bridges/velocity-profile). The bridge tier seeds the hosts it returns and the
|
|
3
|
+
// Recommendations sandbox-lane recipe renders hosts and dirs from it, so the two cannot drift.
|
|
4
|
+
//
|
|
5
|
+
// Dependency-free, Node >= 22. No side effects on import.
|
|
6
|
+
|
|
7
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { DEFAULT_BUNDLE_ROOT } from './bridge-settings-read.mjs';
|
|
10
|
+
|
|
11
|
+
// The manifest-declared session-sandbox recipe surfaces of every BUNDLED bridge whose review
|
|
12
|
+
// wrapper is in the wired set — networkHosts ∪ writableDirs, derived from the manifests (the
|
|
13
|
+
// single documentation source), never hardcoded here.
|
|
14
|
+
export const bundledSandboxRecipe = (placedWrappers, deps) => {
|
|
15
|
+
const readFile = deps.readFile ?? readFileSync;
|
|
16
|
+
const readDir = deps.readdir ?? readdirSync;
|
|
17
|
+
const bundleRoot = deps.bundleRoot ?? DEFAULT_BUNDLE_ROOT;
|
|
18
|
+
const hosts = [];
|
|
19
|
+
const dirEntries = [];
|
|
20
|
+
for (const dir of readDir(bundleRoot)) {
|
|
21
|
+
// An unreadable/unparsable bundled manifest must NOT thin the recipe silently — a partial
|
|
22
|
+
// recipe rendered as complete is worse than none. The throw reaches the caller: a stated skip
|
|
23
|
+
// in the advisor, a STOP with zero writes in the bridge tier.
|
|
24
|
+
let manifest;
|
|
25
|
+
try {
|
|
26
|
+
manifest = JSON.parse(readFile(join(bundleRoot, dir, 'capability.json'), 'utf8'));
|
|
27
|
+
} catch (err) {
|
|
28
|
+
// ENOTDIR = the entry is a stray regular file (.DS_Store, a README), not a bridge bundle.
|
|
29
|
+
if (err?.code === 'ENOTDIR') continue;
|
|
30
|
+
throw new Error(`bundled manifest unreadable: ${join(dir, 'capability.json')} — ${err?.message ?? err}`);
|
|
31
|
+
}
|
|
32
|
+
const reviewCmd = manifest?.roles?.review?.cmd;
|
|
33
|
+
if (!reviewCmd || !placedWrappers.includes(reviewCmd)) continue;
|
|
34
|
+
if (Array.isArray(manifest.networkHosts)) {
|
|
35
|
+
for (const h of manifest.networkHosts) if (!hosts.includes(h)) hosts.push(h);
|
|
36
|
+
}
|
|
37
|
+
if (Array.isArray(manifest.writableDirs)) dirEntries.push(...manifest.writableDirs);
|
|
38
|
+
}
|
|
39
|
+
return { hosts, dirEntries };
|
|
40
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { closeSync, constants, lstatSync, openSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const AGENT_SESSION_MARKERS = Object.freeze([
|
|
5
|
+
'CLAUDECODE',
|
|
6
|
+
'CLAUDE_CODE_SESSION_ID',
|
|
7
|
+
'CLAUDE_CODE_ENTRYPOINT',
|
|
8
|
+
'SANDBOX_RUNTIME',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
const OWNER_WRITE_BIT = 0o200;
|
|
12
|
+
const PROBE_FLAGS = constants.O_WRONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK;
|
|
13
|
+
const WRITE_REFUSAL_CODES = new Set(['EROFS', 'EPERM', 'EACCES']);
|
|
14
|
+
|
|
15
|
+
const throwPathError = (path, error) => {
|
|
16
|
+
throw Object.assign(new Error(`${path}: ${error?.code ?? ''} ${error?.message ?? String(error)}`, {
|
|
17
|
+
cause: error,
|
|
18
|
+
}), { code: error?.code });
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const readStat = (path, io) => {
|
|
22
|
+
try {
|
|
23
|
+
return io.lstat(path);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null;
|
|
26
|
+
throwPathError(path, error);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const openProbe = (path, io) => {
|
|
31
|
+
try {
|
|
32
|
+
return { descriptor: io.openSync(path, PROBE_FLAGS), cause: null };
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (WRITE_REFUSAL_CODES.has(error?.code)) {
|
|
35
|
+
return { cause: `${path}: a write-open answered ${error.code}` };
|
|
36
|
+
}
|
|
37
|
+
throwPathError(path, error);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const probePath = (path, io) => {
|
|
42
|
+
const stat = readStat(path, io);
|
|
43
|
+
if (stat === null) return null;
|
|
44
|
+
if (stat.isCharacterDevice() || stat.isBlockDevice()) {
|
|
45
|
+
return `${path}: a device-node mask`;
|
|
46
|
+
}
|
|
47
|
+
const canProbe = stat.isFile()
|
|
48
|
+
&& (stat.mode & OWNER_WRITE_BIT) !== 0
|
|
49
|
+
&& typeof io.geteuid === 'function'
|
|
50
|
+
&& stat.uid === io.geteuid();
|
|
51
|
+
if (!canProbe) return null;
|
|
52
|
+
const opened = openProbe(path, io);
|
|
53
|
+
if (opened.cause !== null) return opened.cause;
|
|
54
|
+
try {
|
|
55
|
+
return null;
|
|
56
|
+
} finally {
|
|
57
|
+
try {
|
|
58
|
+
io.closeSync(opened.descriptor);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
throwPathError(path, error);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const preflightInit = ({ root = null, home, env = process.env, io = {} }) => {
|
|
66
|
+
for (const marker of AGENT_SESSION_MARKERS) {
|
|
67
|
+
if (typeof env[marker] === 'string' && env[marker].length > 0) {
|
|
68
|
+
return { ok: false, cause: `marker ${marker} is set` };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const probes = {
|
|
72
|
+
lstat: lstatSync,
|
|
73
|
+
openSync,
|
|
74
|
+
closeSync,
|
|
75
|
+
geteuid: process.geteuid,
|
|
76
|
+
...io,
|
|
77
|
+
};
|
|
78
|
+
const paths = [join(home, '.claude/settings.json')];
|
|
79
|
+
if (root !== null) {
|
|
80
|
+
paths.push(
|
|
81
|
+
join(root, '.claude/settings.json'),
|
|
82
|
+
join(root, '.claude/settings.local.json'),
|
|
83
|
+
join(root, '.mcp.json'),
|
|
84
|
+
join(root, '.git/config'),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
for (const path of paths) {
|
|
88
|
+
const cause = probePath(path, probes);
|
|
89
|
+
if (cause !== null) return { ok: false, cause };
|
|
90
|
+
}
|
|
91
|
+
return { ok: true, cause: null };
|
|
92
|
+
};
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { closeSync, lstatSync, openSync, readFileSync, readdirSync, readlinkSync, statSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
5
|
+
import { createInterface } from 'node:readline/promises';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { resolveGitHooksPath } from './commit-guard.mjs';
|
|
8
|
+
import { dirCovers } from './declared-paths.mjs';
|
|
9
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
10
|
+
import { GIT_MAX_BUFFER } from './git-env.mjs';
|
|
11
|
+
import { preflightInit } from './init-preflight.mjs';
|
|
12
|
+
import { buildRecommendations, hostFacts } from './recommendations.mjs';
|
|
13
|
+
import { compareSemver } from './semver-lite.mjs';
|
|
14
|
+
import { EXPECTED_WORKFLOW_VERSION } from './velocity-profile.mjs';
|
|
15
|
+
import { LANE_CONSOLE, VARIANT_LANES, consoleArgv, isProtectedPath, laneOf, routeLine, writesOf } from './write-lanes.mjs';
|
|
16
|
+
|
|
17
|
+
const INSIDE_LINE = 'you are inside an agent; run this in your own terminal';
|
|
18
|
+
const APPLY_PROMPT = 'apply? y/N: ';
|
|
19
|
+
const RUN_NONE = 'y applies every preview above (a sudo install asks once more on its own line); run none of their commands yourself';
|
|
20
|
+
const RESTART_LINE = 'restart the agent from this folder: init changed its configuration';
|
|
21
|
+
const SETTINGS = ['.claude/settings.json', '.claude/settings.local.json', '.mcp.json', '.git/config'];
|
|
22
|
+
const FILE_DIRS = ['.claude/agents', '.claude/hooks'];
|
|
23
|
+
// The record key whose state is the sorted *.json names of its directory, so a created file is a change.
|
|
24
|
+
const JSON_NAMES = '*.json';
|
|
25
|
+
const DANGLING = new Set(['ENOENT', 'ENOTDIR', 'ELOOP']);
|
|
26
|
+
const INSTALL_VARIANT = 'sandbox-provision.installable';
|
|
27
|
+
const DEFAULT_IO = { lstat: lstatSync, stat: statSync, readlink: readlinkSync, readFile: readFileSync, readdir: readdirSync,
|
|
28
|
+
openSync, closeSync, geteuid: process.geteuid };
|
|
29
|
+
const isYes = (answer) => answer === 'y' || answer === 'Y';
|
|
30
|
+
|
|
31
|
+
const readStat = (path, io) => {
|
|
32
|
+
try {
|
|
33
|
+
return io.lstat(path);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const findProject = ({ cwd, lstat = lstatSync }) => {
|
|
41
|
+
const candidates = [];
|
|
42
|
+
const visit = (root) => {
|
|
43
|
+
const io = { lstat };
|
|
44
|
+
if (readStat(join(root, 'docs/ai'), io)?.isDirectory()) {
|
|
45
|
+
const shape = readStat(join(root, 'docs/ai/.workflow-version'), io) ? 'stamped' : 'pre-versioned';
|
|
46
|
+
candidates.push({ root, shape });
|
|
47
|
+
}
|
|
48
|
+
const parent = dirname(root);
|
|
49
|
+
if (!readStat(join(root, '.git'), io) && parent !== root) visit(parent);
|
|
50
|
+
};
|
|
51
|
+
visit(resolve(cwd));
|
|
52
|
+
return { candidates };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const readEntries = (dir, io) => readStat(dir, io)?.isDirectory() ? io.readdir(dir).sort() : [];
|
|
56
|
+
const collectPaths = ({ root, home, hooksDir }, io) => {
|
|
57
|
+
const paths = [join(home, '.claude/settings.json')];
|
|
58
|
+
if (root === null) return paths;
|
|
59
|
+
const docs = join(root, 'docs/ai');
|
|
60
|
+
paths.push(...SETTINGS.map((path) => join(root, path)), join(docs, '.workflow-version'), join(docs, JSON_NAMES));
|
|
61
|
+
for (const dir of [...FILE_DIRS.map((path) => join(root, path)), hooksDir]) {
|
|
62
|
+
paths.push(dir, ...readEntries(dir, io).map((name) => join(dir, name)));
|
|
63
|
+
}
|
|
64
|
+
paths.push(...readEntries(docs, io).filter((name) => name.endsWith('.json')).map((name) => join(docs, name)));
|
|
65
|
+
return paths;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const fileState = (path, io) => `file:${Buffer.from(io.readFile(path)).toString('hex')}`;
|
|
69
|
+
const otherState = (stat) => `other:${stat.mode}:${stat.rdev}`;
|
|
70
|
+
const entryState = (path, io) => {
|
|
71
|
+
const stat = io.lstat(path);
|
|
72
|
+
if (stat.isDirectory()) return 'directory';
|
|
73
|
+
if (stat.isSymbolicLink()) return `link:${io.readlink(path)}`;
|
|
74
|
+
return stat.isFile() ? fileState(path, io) : otherState(stat);
|
|
75
|
+
};
|
|
76
|
+
// A directory reads as its sorted entries with each entry's own state, one level deep, so a changed file in it is a change.
|
|
77
|
+
const statState = (path, stat, io) => {
|
|
78
|
+
if (stat.isDirectory()) return `directory:${JSON.stringify(io.readdir(path).sort().map((name) => [name, entryState(join(path, name), io)]))}`;
|
|
79
|
+
return stat.isFile() ? fileState(path, io) : otherState(stat);
|
|
80
|
+
};
|
|
81
|
+
const followedState = (path, io) => {
|
|
82
|
+
try {
|
|
83
|
+
return statState(path, io.stat(path), io);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (DANGLING.has(error.code)) return 'absent';
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const readState = (path, io) => {
|
|
90
|
+
if (basename(path) === JSON_NAMES) {
|
|
91
|
+
const names = readStat(dirname(path), io)?.isDirectory() ? io.readdir(dirname(path)).filter((name) => name.endsWith('.json')) : null;
|
|
92
|
+
return names === null ? 'absent' : `names:${JSON.stringify(names.sort())}`;
|
|
93
|
+
}
|
|
94
|
+
const stat = readStat(path, io);
|
|
95
|
+
if (stat === null) return 'absent';
|
|
96
|
+
if (stat.isSymbolicLink()) return `link:${io.readlink(path)}:${followedState(path, io)}`;
|
|
97
|
+
return statState(path, stat, io);
|
|
98
|
+
};
|
|
99
|
+
const stateOf = (path, io) => {
|
|
100
|
+
try {
|
|
101
|
+
return readState(path, io);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
throw new Error(`${path} could not be read: ${error.code ?? error.message}`);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const recordPaths = (paths, io) => new Map([...new Set(paths)].map((path) => [path, stateOf(path, io)]));
|
|
107
|
+
const findChanged = (record, io) => [...record].find(([path, state]) => {
|
|
108
|
+
try {
|
|
109
|
+
return readState(path, io) !== state;
|
|
110
|
+
} catch {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
})?.[0];
|
|
114
|
+
// After an apply a path that cannot be read is recorded as such, so the next comparison stops the batch on it.
|
|
115
|
+
const refreshState = (path, io) => {
|
|
116
|
+
try {
|
|
117
|
+
return readState(path, io);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return `unreadable:${error.code ?? error.message}`;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const DIRECTORY = 'directory:';
|
|
123
|
+
const entriesOf = (state) => (state === 'absent' ? [] : JSON.parse(state.slice(DIRECTORY.length)));
|
|
124
|
+
// A recorded parent moves only by the written entry, so an entry created or removed beside it still stops the batch.
|
|
125
|
+
const refreshParent = (record, path, io) => {
|
|
126
|
+
const [parent, name, current] = [dirname(path), basename(path), refreshState(dirname(path), io)];
|
|
127
|
+
const recorded = record.get(parent);
|
|
128
|
+
if (!(recorded === 'absent' || recorded.startsWith(DIRECTORY)) || !current.startsWith(DIRECTORY)) return;
|
|
129
|
+
const merged = [...entriesOf(recorded).filter(([entry]) => entry !== name), ...entriesOf(current).filter(([entry]) => entry === name)];
|
|
130
|
+
record.set(parent, DIRECTORY + JSON.stringify(merged.sort(([a], [b]) => (a < b ? -1 : 1))));
|
|
131
|
+
};
|
|
132
|
+
const refreshWrites = (record, paths, io) => {
|
|
133
|
+
for (const path of paths) {
|
|
134
|
+
record.set(path, refreshState(path, io));
|
|
135
|
+
if (record.has(dirname(path))) refreshParent(record, path, io);
|
|
136
|
+
if (readStat(path, io)?.isDirectory()) {
|
|
137
|
+
for (const watched of record.keys()) {
|
|
138
|
+
if (watched.startsWith(path + sep)) record.set(watched, refreshState(watched, io));
|
|
139
|
+
}
|
|
140
|
+
for (const child of readEntries(path, io).map((name) => join(path, name))) record.set(child, refreshState(child, io));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const findMasks = (paths, facts, io, print) => {
|
|
146
|
+
const masks = new Set();
|
|
147
|
+
const root = facts.root ?? facts.cwd;
|
|
148
|
+
for (const path of new Set(paths)) {
|
|
149
|
+
if (!isProtectedPath(path, { root, home: facts.home, hooksDir: join(root, '.git/hooks') })) continue;
|
|
150
|
+
const stat = readStat(path, io);
|
|
151
|
+
if (!stat?.isFile() || stat.size !== 0 || (stat.mode & 0o222) !== 0) continue;
|
|
152
|
+
masks.add(path);
|
|
153
|
+
print(`${path}: an empty read-only file — a running agent command's mask (run init again once it ends) or litter a sandbox left (remove it, then run init again)`);
|
|
154
|
+
}
|
|
155
|
+
return masks;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const launch = async (context, argv, stdio) => {
|
|
159
|
+
try {
|
|
160
|
+
const result = await context.spawn(argv, { cwd: context.facts.root ?? context.cwd, env: context.spawnEnv, stdio });
|
|
161
|
+
if (!result || typeof result !== 'object') return { status: null, stdout: '', stderr: 'empty answer', valid: false };
|
|
162
|
+
return {
|
|
163
|
+
status: result.status ?? null,
|
|
164
|
+
stdout: String(result.stdout ?? ''),
|
|
165
|
+
stderr: String(result.stderr ?? result.error?.message ?? result.signal ?? ''),
|
|
166
|
+
valid: typeof result.status === 'number' && !result.error && !result.signal,
|
|
167
|
+
};
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return { status: null, stdout: '', stderr: String(error.code ?? error.message ?? error), valid: false };
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Outside a project only the host-scoped variants reach the queue: an apply-only one or one judged over a claude dir.
|
|
174
|
+
const reachesQueue = (variant, facts) => {
|
|
175
|
+
if (facts.root !== null) return true;
|
|
176
|
+
const argv = consoleArgv(variant, facts);
|
|
177
|
+
return Boolean(argv) && (argv.preview === null || argv.preview.includes('--claude-dir'));
|
|
178
|
+
};
|
|
179
|
+
const isMasked = (variant, context) => (writesOf(variant, context.facts) ?? [])
|
|
180
|
+
.some((write) => [...context.masks].some((mask) => dirCovers(write, mask)));
|
|
181
|
+
|
|
182
|
+
// context.items keeps the console items of the advisor's latest return, before the queue's filters.
|
|
183
|
+
const getQueue = async (context) => {
|
|
184
|
+
const { items } = await context.recommend(context.facts.root, { keySet: context.keySet });
|
|
185
|
+
context.items = items.filter(({ variant }) => laneOf(variant) === LANE_CONSOLE);
|
|
186
|
+
return context.items.filter(({ variant }) => reachesQueue(variant, context.facts) && !isMasked(variant, context));
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const printNotPending = (context) => {
|
|
190
|
+
const plain = routeLine('velocity-core');
|
|
191
|
+
const variants = Object.keys(VARIANT_LANES).filter((variant) => laneOf(variant) === LANE_CONSOLE && routeLine(variant)?.length > plain.length);
|
|
192
|
+
const keys = new Set();
|
|
193
|
+
for (const variant of variants) {
|
|
194
|
+
const key = variant.split('.')[0];
|
|
195
|
+
if (keys.has(key)) continue;
|
|
196
|
+
keys.add(key);
|
|
197
|
+
if (!reachesQueue(variant, context.facts) || context.items.some((item) => item.key === key)) continue;
|
|
198
|
+
const preview = consoleArgv(variant, context.facts)?.preview;
|
|
199
|
+
const index = preview?.indexOf('--claude-dir') ?? -1;
|
|
200
|
+
const judged = index < 0 ? '' : ` (judged ${preview[index + 1]})`;
|
|
201
|
+
context.print(`not pending in this console: ${key}${judged}`);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const printNote = (context, key) => {
|
|
206
|
+
const lines = String(context.io.readFile(join(context.facts.toolsDir, '../references/modes/recommendations.md'), 'utf8')).split('\n');
|
|
207
|
+
const start = lines.findIndex((line) => line.startsWith(`- \`${key}\` — `));
|
|
208
|
+
if (start < 0) return;
|
|
209
|
+
const end = lines.findIndex((line, index) => index > start && !line.startsWith(' ') && !line.startsWith('\t'));
|
|
210
|
+
for (const line of lines.slice(start, end < 0 ? lines.length : end)) context.print(line);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const previewQueue = async (context, queue) => {
|
|
214
|
+
const batch = [];
|
|
215
|
+
for (const item of queue) {
|
|
216
|
+
const argv = consoleArgv(item.variant, context.facts);
|
|
217
|
+
if (!argv?.preview) continue;
|
|
218
|
+
context.print(`preview: ${item.variant}`);
|
|
219
|
+
const result = await launch(context, argv.preview, 'pipe');
|
|
220
|
+
const success = result.valid && (result.status === 0 || (result.status === 3 && result.stdout.includes('status=missing-binaries')));
|
|
221
|
+
if (!success) {
|
|
222
|
+
context.print(`preview refused (exit ${result.status}): ${item.variant}`);
|
|
223
|
+
if (result.stdout) context.print(result.stdout);
|
|
224
|
+
if (result.stderr) context.print(result.stderr);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
context.print(result.stdout);
|
|
228
|
+
const tuple = result.stdout.match(/--apply\s+(\S+)/)?.[1];
|
|
229
|
+
const facts = { ...context.facts, tuple };
|
|
230
|
+
batch.push({ ...item, applyArgv: consoleArgv(item.variant, facts).apply, writes: writesOf(item.variant, facts) });
|
|
231
|
+
printNote(context, item.key);
|
|
232
|
+
}
|
|
233
|
+
for (const item of queue.filter(({ variant }) => consoleArgv(variant, context.facts) === null)) {
|
|
234
|
+
context.print(`listed, not applied here: ${item.variant} — ${item.what}`);
|
|
235
|
+
}
|
|
236
|
+
return batch;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const isConfiguration = (path, root) => root !== null && (
|
|
240
|
+
SETTINGS.slice(0, 3).some((rel) => path === join(root, rel))
|
|
241
|
+
|| ['.claude/agents', '.claude/hooks'].some((rel) => path === join(root, rel) || path.startsWith(join(root, rel) + sep))
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
// The record is the state the first preview reads: taken before any preview, compared before each apply.
|
|
245
|
+
const takeRecord = (context, queue) => {
|
|
246
|
+
const previewable = queue.filter(({ variant }) => consoleArgv(variant, context.facts)?.preview);
|
|
247
|
+
if (previewable.length === 0) return new Map();
|
|
248
|
+
const paths = [...collectPaths(context.facts, context.io), ...previewable.flatMap(({ variant }) => writesOf(variant, context.facts))];
|
|
249
|
+
try {
|
|
250
|
+
return recordPaths(paths, context.io);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
for (const item of previewable) context.print(`not applied: ${item.variant} — ${error.message}`);
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const runPass = async (context, queue) => {
|
|
258
|
+
const record = takeRecord(context, queue);
|
|
259
|
+
if (record === null) return { code: 1, successes: 0, variants: [] };
|
|
260
|
+
const batch = await previewQueue(context, queue);
|
|
261
|
+
if (batch.length === 0) return { code: 0, successes: 0, variants: [] };
|
|
262
|
+
const before = new Map(record);
|
|
263
|
+
const outcome = { code: 0, successes: 0, variants: batch.map((item) => item.variant) };
|
|
264
|
+
context.print(RUN_NONE);
|
|
265
|
+
if (!isYes(await context.terminal.ask(APPLY_PROMPT))) return outcome;
|
|
266
|
+
for (const [index, item] of batch.entries()) {
|
|
267
|
+
if (item.variant === INSTALL_VARIANT && !isYes(await context.terminal.ask('the sandbox install may ask for your sudo password: y runs it, Enter skips: '))) continue;
|
|
268
|
+
const changed = findChanged(record, context.io);
|
|
269
|
+
if (changed) {
|
|
270
|
+
for (const later of batch.slice(index)) context.print(`not applied: ${later.variant} — ${changed} changed outside this batch`);
|
|
271
|
+
outcome.code = 1;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
const result = await launch(context, item.applyArgv, ['inherit', 'pipe', 'inherit']);
|
|
275
|
+
if (result.stdout) context.print(result.stdout);
|
|
276
|
+
const success = result.valid && (result.status === 0 || (result.status === 5 && result.stdout.includes('install completed')));
|
|
277
|
+
if (!success) {
|
|
278
|
+
context.print(`apply failed (exit ${result.status}): ${item.variant}`);
|
|
279
|
+
if (result.stderr) context.print(result.stderr);
|
|
280
|
+
for (const later of batch.slice(index + 1)) context.print(`not applied: ${later.variant} — an earlier apply failed`);
|
|
281
|
+
outcome.code = 1;
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
outcome.successes += 1;
|
|
285
|
+
refreshWrites(record, item.writes, context.io);
|
|
286
|
+
}
|
|
287
|
+
if (outcome.successes > 0) {
|
|
288
|
+
const afterPaths = [...collectPaths(context.facts, context.io), ...record.keys()];
|
|
289
|
+
context.restart ||= afterPaths.some((path) => isConfiguration(path, context.facts.root)
|
|
290
|
+
&& refreshState(path, context.io) !== (before.get(path) ?? 'absent'));
|
|
291
|
+
}
|
|
292
|
+
return outcome;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const selectRoot = async (context) => {
|
|
296
|
+
const { candidates } = findProject({ cwd: context.cwd, lstat: context.io.lstat });
|
|
297
|
+
if (candidates.length === 0) {
|
|
298
|
+
context.print("no agent-workflow project here — the project items apply when init runs from that project's folder");
|
|
299
|
+
return { root: null };
|
|
300
|
+
}
|
|
301
|
+
if (candidates.length === 1) return candidates[0];
|
|
302
|
+
candidates.forEach(({ root }, index) => context.print(` ${index + 1}. ${root}`));
|
|
303
|
+
const answer = await context.terminal.ask(`which project? 1-${candidates.length}, Enter skips: `);
|
|
304
|
+
return candidates.find((_, index) => String(index + 1) === answer) ?? null;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const gateStamp = (candidate, context) => {
|
|
308
|
+
if (candidate.root === null) return null;
|
|
309
|
+
const stamp = (() => {
|
|
310
|
+
try {
|
|
311
|
+
return String(context.io.readFile(join(candidate.root, 'docs/ai/.workflow-version'), 'utf8')).trim();
|
|
312
|
+
} catch {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
})();
|
|
316
|
+
if (candidate.shape === 'stamped' && stamp === EXPECTED_WORKFLOW_VERSION) return candidate.root;
|
|
317
|
+
if (compareSemver(stamp, EXPECTED_WORKFLOW_VERSION) === 1) {
|
|
318
|
+
context.print(`this project's stamp ${stamp} is ahead of this kit's lineage ${EXPECTED_WORKFLOW_VERSION} — update the kit, then init again`);
|
|
319
|
+
} else {
|
|
320
|
+
context.print('run /agent-workflow-kit upgrade, then init again');
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
export const runInitProject = async (deps) => {
|
|
326
|
+
const { terminal } = deps;
|
|
327
|
+
if (terminal.isTTY !== true) {
|
|
328
|
+
terminal.print("project step skipped: no terminal — run init in a console from the project's folder");
|
|
329
|
+
return { code: 0 };
|
|
330
|
+
}
|
|
331
|
+
const io = { ...DEFAULT_IO, ...deps.io };
|
|
332
|
+
const print = (line) => terminal.print(line);
|
|
333
|
+
if (!preflightInit({ ...deps, root: null, io }).ok) {
|
|
334
|
+
print(INSIDE_LINE);
|
|
335
|
+
return { code: 0 };
|
|
336
|
+
}
|
|
337
|
+
const spawnEnv = { ...deps.env };
|
|
338
|
+
delete spawnEnv.GIT_DIR;
|
|
339
|
+
delete spawnEnv.GIT_WORK_TREE;
|
|
340
|
+
const context = { ...deps, io, print, spawnEnv, masks: new Set(), keySet: false, restart: false };
|
|
341
|
+
const candidate = await selectRoot(context);
|
|
342
|
+
if (candidate === null) return { code: 0 };
|
|
343
|
+
if (!preflightInit({ ...deps, root: candidate.root, io }).ok) {
|
|
344
|
+
print(INSIDE_LINE);
|
|
345
|
+
return { code: 0 };
|
|
346
|
+
}
|
|
347
|
+
const root = gateStamp(candidate, context);
|
|
348
|
+
// The hooks dir git itself reports (a linked worktree's or core.hooksPath), as the advisor reads it.
|
|
349
|
+
const hooksDir = root === null ? null : (deps.gitHooksPath ?? resolveGitHooksPath)(root) ?? join(root, '.git/hooks');
|
|
350
|
+
context.facts = { toolsDir: deps.toolsDir, root, env: deps.env, home: deps.home, platform: deps.platform, tuple: undefined, hooksDir };
|
|
351
|
+
context.recommend ??= (root, facts) => buildRecommendations({ cwd: root ?? deps.cwd,
|
|
352
|
+
deps: { ...io, env: deps.env, getenv: deps.env, home: deps.home, platform: deps.platform,
|
|
353
|
+
jevHost: { ...hostFacts(), home: deps.home, platform: deps.platform }, ...facts } });
|
|
354
|
+
const queue = await getQueue(context);
|
|
355
|
+
const maskPaths = [...collectPaths(context.facts, io), ...queue.flatMap(({ variant }) => writesOf(variant, context.facts) ?? [])];
|
|
356
|
+
context.masks = findMasks(maskPaths, { ...context.facts, cwd: deps.cwd }, io, print);
|
|
357
|
+
const state = { queue: queue.filter(({ variant }) => !isMasked(variant, context)) };
|
|
358
|
+
for (const item of state.queue.filter(({ variant }) => consoleArgv(variant, context.facts)?.preview === null)) {
|
|
359
|
+
print(item.what);
|
|
360
|
+
print(`it writes: ${writesOf(item.variant, context.facts).join(', ')}`);
|
|
361
|
+
printNote(context, item.key);
|
|
362
|
+
if (!isYes(await terminal.ask('y runs its command on this terminal, Enter skips: '))) continue;
|
|
363
|
+
const result = await launch(context, consoleArgv(item.variant, context.facts).apply, 'inherit');
|
|
364
|
+
if (result.valid && result.status === 0) {
|
|
365
|
+
context.keySet = true;
|
|
366
|
+
state.queue = await getQueue(context);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
state.queue = state.queue.filter(({ variant }) => consoleArgv(variant, context.facts)?.preview !== null);
|
|
370
|
+
printNotPending(context);
|
|
371
|
+
const first = await runPass(context, state.queue);
|
|
372
|
+
const outcome = { code: first.code };
|
|
373
|
+
if (first.code === 0 && first.successes > 0) {
|
|
374
|
+
const next = (await getQueue(context)).filter(({ variant }) => !first.variants.includes(variant) && consoleArgv(variant, context.facts)?.preview);
|
|
375
|
+
outcome.code = (await runPass(context, next)).code;
|
|
376
|
+
}
|
|
377
|
+
if (context.restart) print(RESTART_LINE);
|
|
378
|
+
return outcome;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
export const main = async (argv = process.argv.slice(2), deps = {}) => {
|
|
382
|
+
if (argv.length > 0) {
|
|
383
|
+
(deps.errlog ?? console.error)('Usage: node init-project.mjs');
|
|
384
|
+
return 2;
|
|
385
|
+
}
|
|
386
|
+
const readline = deps.terminal ? null : createInterface({ input: process.stdin, output: process.stdout });
|
|
387
|
+
const terminal = deps.terminal ?? { isTTY: process.stdin.isTTY === true && process.stdout.isTTY === true,
|
|
388
|
+
ask: (prompt) => readline.question(prompt), print: (line) => console.log(line) };
|
|
389
|
+
try {
|
|
390
|
+
const result = await runInitProject({ cwd: process.cwd(), env: process.env, home: homedir(), platform: process.platform,
|
|
391
|
+
toolsDir: dirname(fileURLToPath(import.meta.url)), terminal,
|
|
392
|
+
spawn: (args, options) => spawnSync(process.execPath, args.slice(1), { ...options, encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER }),
|
|
393
|
+
...deps });
|
|
394
|
+
return result.code;
|
|
395
|
+
} finally {
|
|
396
|
+
readline?.close();
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
if (isDirectRun(import.meta.url)) process.exitCode = await main();
|
package/tools/manifest/schema.md
CHANGED
|
@@ -99,18 +99,18 @@ drift-guarded set-equal to this block by the bridge `bin/*.test.mjs` suites (hel
|
|
|
99
99
|
|
|
100
100
|
`networkHosts` declares the hosts the backend CLI is **observed** to contact (synthetic examples:
|
|
101
101
|
`*.api.backend.example`, `accounts.backend.example`; the real observed lists live in each bridge's
|
|
102
|
-
`capability.json`) — the **single
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
`capability.json`) — the **single source** of the hosts `velocity-profile --bridge-tier` seeds
|
|
103
|
+
into the project's `sandbox.network.allowedDomains` for every placed bridge, and of a hand-applied
|
|
104
|
+
session sandbox allowlist. Rules:
|
|
105
105
|
|
|
106
106
|
- Each entry is a bare dotted hostname or a `*.family` wildcard — never a scheme/path/port.
|
|
107
107
|
Entries must be unique. A malformed list **fails** `--strict` (the entries are pasted verbatim
|
|
108
108
|
into allowlist lines by the Recommendations advisor).
|
|
109
|
-
- **The
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
109
|
+
- **The `--bridge-tier` seed is the primary lane** (AD-172): beside the wrappers' exclusion it
|
|
110
|
+
merges the placed bridges' hosts into the project's `sandbox.network.allowedDomains`, so a
|
|
111
|
+
settings-native harness that drops the exclusion still admits them; that pre-allow widens egress
|
|
112
|
+
for **every** sandboxed command, which the tier's consent states. The **hand-apply** fallback
|
|
113
|
+
stays for a harness-managed sandbox that ignores the settings keys.
|
|
114
114
|
- Observed-minimal, honestly incomplete: a blocked host names itself at run time — extend the
|
|
115
115
|
hand-applied list by hand; the manifest list records what was actually observed.
|
|
116
116
|
|
|
@@ -132,7 +132,7 @@ declared path is only the **default under no override**. Rules:
|
|
|
132
132
|
relative path. The advisor anchors those to the **target project root** (its pinned `--cwd`),
|
|
133
133
|
matching what a wrapper invoked from the project root resolves — the documented dispatch form
|
|
134
134
|
(the wrapper itself anchors to its invocation `$PWD`); else the `default` applies.
|
|
135
|
-
-
|
|
135
|
+
- Unlike `networkHosts`, this is a **documentation source** only: the kit never seeds
|
|
136
136
|
`sandbox.filesystem.allowWrite` or any other filesystem allowance — the entries feed the
|
|
137
137
|
sandbox-lane discoverability item (session/host sandbox config is hand-applied territory).
|
|
138
138
|
|
|
@@ -638,10 +638,10 @@ export const validateManifest = (skillDir) => {
|
|
|
638
638
|
}
|
|
639
639
|
|
|
640
640
|
// `networkHosts` (AD-044 Plan 4, consult-locked): the backend CLI's OBSERVED egress host
|
|
641
|
-
// families
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
// Recommendations advisor renders it verbatim, so a bad entry would corrupt a
|
|
641
|
+
// families. `velocity-profile --bridge-tier` seeds them into sandbox.network.allowedDomains for
|
|
642
|
+
// placed bridges (AD-172; the pre-allow widens egress for EVERY sandboxed command, stated at that
|
|
643
|
+
// consent), and a malformed list FAILS --strict like `settings`: the tier writes it and the
|
|
644
|
+
// Recommendations advisor renders it verbatim, so a bad entry would corrupt a seeded line.
|
|
645
645
|
const networkHosts = manifest.networkHosts;
|
|
646
646
|
if (networkHosts != null) {
|
|
647
647
|
if (!Array.isArray(networkHosts) || networkHosts.length === 0) {
|
|
@@ -661,7 +661,7 @@ export const validateManifest = (skillDir) => {
|
|
|
661
661
|
|
|
662
662
|
// `writableDirs` (REC-UX-REWORK, D6): the backend CLI's writable state-dir declarations —
|
|
663
663
|
// {env, default} entries the Recommendations advisor RESOLVES at run time (a NON-EMPTY env
|
|
664
|
-
// value wins, else the default) and renders into the sandbox-lane recipe.
|
|
664
|
+
// value wins, else the default) and renders into the sandbox-lane recipe. Unlike `networkHosts`
|
|
665
665
|
// this is a DOCUMENTATION source (the kit never seeds filesystem allowances), and a malformed
|
|
666
666
|
// list FAILS --strict for the same reason: the resolved dir is rendered into a hand-applied line.
|
|
667
667
|
const writableDirs = manifest.writableDirs;
|