@a2f0/agent-tool 0.1.29 → 0.1.31

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/README.md CHANGED
@@ -81,7 +81,7 @@ validation suite, and release procedures.
81
81
  PR checks/threads, cleanup, CI aggregate gates, release verification, durable
82
82
  validation receipts, and toolchain/protection diagnostics.
83
83
  - [CLI reference](docs/reference.md): existing review, version, skill installation,
84
- command capture, configuration, and distribution contracts.
84
+ command capture, import-graph checks, configuration, and distribution contracts.
85
85
  - [Development](docs/development.md): setup, source layout, validation, and release
86
86
  procedures for this package.
87
87
  - [Images](docs/images.md): declarative brand sources and generated outputs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a2f0/agent-tool",
3
- "version": "0.1.29",
3
+ "version": "0.1.31",
4
4
  "packageManager": "bun@1.4.3",
5
5
  "type": "module",
6
6
  "description": "Portable cross-agent review, PR workflows, and shared skills",
@@ -33,4 +33,5 @@ open PRs and requires the branch to be pushed at its local HEAD. It does not
33
33
  push for you. Resolve a fork's head/base identities explicitly if the repository
34
34
  uses fork PRs; the initial open helper supports same-repository branches.
35
35
 
36
- Report the created PR link and any remaining validation or review work.
36
+ Report the created PR link and any remaining validation or review work. Run
37
+ `agent-tool teardown` first, stop what the work started, and report what remains.
@@ -39,6 +39,8 @@ switch, and update by fast-forward only. Never discard or automatically stash
39
39
  edits, force a diverged default branch, or infer shipping from Git ancestry after
40
40
  a squash merge.
41
41
 
42
+ Run `agent-tool teardown` and stop what the finished work left running
43
+ (`teardown --apply` for recorded processes); report anything that remains.
42
44
  Run the repository's documented dependency/hook setup if required. When copied
43
45
  hooks are configured, use `agent-tool hooks check` and `hooks install --apply`;
44
46
  inspect unmanaged hooks before an explicit `--adopt`, which backs them up.
@@ -23,6 +23,9 @@ machine-readable receipts and review findings visible by running `versions
23
23
  prepare`, `review`, and other commands whose output drives a decision directly.
24
24
  Prefer documented compact test reporters when they preserve required evidence;
25
25
  do not reduce test scope, skip checks, silence warnings, or mask failures.
26
+ Start servers, watchers, and other long-running processes with
27
+ `agent-tool proc start --label <name> -- <command>`, never a backgrounded shell
28
+ command, so they can be found and stopped.
26
29
 
27
30
  1. Finish the requested change on a feature branch. Use
28
31
  `agent-tool branch create <branch> --base <base-branch> --remote <remote>`
@@ -73,8 +76,15 @@ do not reduce test scope, skip checks, silence warnings, or mask failures.
73
76
  live checks. Report deployment failures
74
77
  separately from a successful merge; follow project policy for repairs.
75
78
 
79
+ Before the final report, run `agent-tool teardown` in each repository this work
80
+ touched. Stop what the work started (`teardown --apply`, `proc stop`), stop any
81
+ untracked process it started, and remove worktrees it added. Report what
82
+ remains and why; the work is not done while teardown fails for anything it
83
+ started.
84
+
76
85
  Never infer a clean review from process success alone or carry a review across
77
86
  content changes. Do not weaken configured CI policy to get a merge through.
78
87
  If a step fails, preserve useful intermediate work and report the concrete
79
88
  remaining issue. Report PR link, merged commit, review verdict, validation, final
80
- checkout state, and whether the shipped local branch was pruned or preserved.
89
+ checkout state, the teardown result, and whether the shipped local branch was
90
+ pruned or preserved.
@@ -7,7 +7,7 @@ import { assert, flag, option, output } from "../workflows/core";
7
7
  import { bunExecute } from "./execute";
8
8
  import { pinnedBun, type BunExecutable } from "./toolchain";
9
9
  import { lockedWorkspaceVersions, lostWorkspaceReferences } from "../version/lockfileVersions";
10
- import { packageDirectories, workspacePackage } from "./workspace";
10
+ import { packageDirectories, workspacePackage } from "../system/workspace";
11
11
 
12
12
  const SECTIONS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"] as const;
13
13
  type Section = typeof SECTIONS[number];
@@ -8,7 +8,7 @@ import { CLASSES } from "../permissions/catalog";
8
8
  import { checkPermissions } from "../permissions/install";
9
9
  import { projectCommands } from "../permissions/project";
10
10
  import { pinnedBun } from "./toolchain";
11
- import { workspacePackage } from "./workspace";
11
+ import { workspacePackage } from "../system/workspace";
12
12
 
13
13
  const USAGE = `Usage:
14
14
  agent-tool bun test [--cwd <dir>] [--log] [-- args...]
@@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process";
2
2
  import { accessSync, constants, existsSync, readFileSync, realpathSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import path from "node:path";
5
- import { outsideExecutable } from "../review/executable";
5
+ import { outsideExecutable } from "../system/executable";
6
6
  import { assert } from "../workflows/core";
7
7
 
8
8
  export interface BunExecutable { path: string; version: string; source: "mise" | "path"; pinned: string | null }
@@ -1,40 +1,2 @@
1
- import { spawnSync } from "node:child_process";
2
- import { existsSync, readFileSync, realpathSync } from "node:fs";
3
- import path from "node:path";
4
- import { assert } from "../workflows/core";
5
-
6
- /** The checkout root, where the workspace manifest and its lockfile live. */
7
- export function projectRoot(directory: string): string {
8
- const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: directory, encoding: "utf8" });
9
- assert(result.status === 0, "Run Bun commands inside a Git checkout.");
10
- return realpathSync(result.stdout.trim());
11
- }
12
-
13
- /** The root package and its declared Bun workspaces: the packages that share the root lockfile. */
14
- export function packageDirectories(root: string): string[] {
15
- const manifest = path.join(root, "package.json");
16
- if (!existsSync(manifest)) return [];
17
- const value = JSON.parse(readFileSync(manifest, "utf8")) as { workspaces?: unknown };
18
- const patterns = Array.isArray(value.workspaces) ? value.workspaces : (value.workspaces as { packages?: unknown } | undefined)?.packages;
19
- const list = (Array.isArray(patterns) ? patterns : []).filter((pattern): pattern is string => typeof pattern === "string");
20
- // `!pattern` entries exclude directories that the other patterns match.
21
- const excluded = list.filter(pattern => pattern.startsWith("!")).map(pattern => new Bun.Glob(pattern.slice(1).replace(/\/$/, "")));
22
- const directories = new Set([root]);
23
- for (const pattern of list.filter(pattern => !pattern.startsWith("!"))) {
24
- for (const match of new Bun.Glob(`${pattern.replace(/\/$/, "")}/package.json`).scanSync({ cwd: root, onlyFiles: true })) {
25
- const relative = path.dirname(match);
26
- if (!match.split("/").includes("node_modules") && !excluded.some(glob => glob.match(relative))) directories.add(path.join(root, relative));
27
- }
28
- }
29
- return [...directories];
30
- }
31
-
32
- /** Resolve a package directory that belongs to the checkout's workspace, refusing standalone packages. */
33
- export function workspacePackage(directory: string): { root: string; directory: string } {
34
- const resolved = realpathSync(directory);
35
- const root = projectRoot(resolved);
36
- // Harness rules come from the invoking checkout; --cwd may not reach another repository.
37
- assert(root === projectRoot(process.cwd()), "--cwd must name a package in this checkout.");
38
- assert(packageDirectories(root).some(item => realpathSync(item) === resolved), "Use the root package or a declared workspace; standalone packages keep their own lockfiles.");
39
- return { root, directory: resolved };
40
- }
1
+ // Moved to the primitives layer; re-exported so existing deep imports keep working.
2
+ export { packageDirectories, projectRoot, workspacePackage } from "../system/workspace";
package/src/cli.ts CHANGED
@@ -5,6 +5,7 @@ import { DEFAULT_CONFIG, loadConfig } from "./config";
5
5
  import { doctor } from "./doctor";
6
6
  import { checkTerraformPlan } from "./dependencies/terraformPlan";
7
7
  import { dependencyDiff } from "./dependencies/diff";
8
+ import { checkGraph } from "./graph/check";
8
9
  import { review } from "./harnesses";
9
10
  import { runAgentToolAction } from "./runAgentToolAction";
10
11
  import { BUNDLED_SKILLS } from "./skills/bundled";
@@ -26,6 +27,7 @@ import { hooksCommand } from "./workflows/hooks";
26
27
  import { repoCommand, policyCommand, toolchainCommand } from "./workflows/repository";
27
28
  import { prWorkflowCommand } from "./workflows/pr";
28
29
  import { releaseCommand } from "./release/commands";
30
+ import { procCommand, teardownCommand } from "./processes/commands";
29
31
 
30
32
  export const HELP = `agent-tool ${metadata.version}
31
33
 
@@ -33,6 +35,9 @@ Usage: agent-tool [--repo <directory>] [--config <file>] <command>
33
35
 
34
36
  run [--label text] [--max-bytes N] [--tail-lines N] -- <command> [args...]
35
37
  run-group <plan.json> Sequential checks with durable source-bound receipts
38
+ proc start [--label text] [--keep] -- <command> [args...] Recorded long-running process
39
+ proc list | stop <id...>|--all [--grace-ms N]
40
+ teardown [--apply] What this checkout still runs; --apply stops recorded processes
36
41
  runs list [--head oid] [--branch name] [--since ISO-date]
37
42
  branch check [name...|--pre-push] | status
38
43
  branch create <name> [--base branch] [--remote origin]
@@ -72,6 +77,7 @@ Usage: agent-tool [--repo <directory>] [--config <file>] <command>
72
77
  permissions check [--harness <all|claude|codex>]
73
78
  images list [target...] JSON files each image target writes
74
79
  images build [target...] [--out <dir>] Render assets/brand/*.svg per agent-tool.json
80
+ graph check Import cycles and layer violations per agent-tool.json
75
81
  init Create agent-tool.json without overwriting
76
82
  config show
77
83
  doctor JSON versions and supported review flags
@@ -109,6 +115,8 @@ export function main(argv = process.argv.slice(2)): number | Promise<number> {
109
115
  if (command === "run") return runCommand(args);
110
116
  if (command === "ci") { usage(args.shift() === "gate", "Usage: ci gate <needs.json|-> [--policy policy.json]"); return gateCommand(args); }
111
117
  if (command === "release") return releaseCommand(args);
118
+ if (command === "proc") return procCommand(args);
119
+ if (command === "teardown") return teardownCommand(args);
112
120
  if (command === "logs") return inspectLog(args);
113
121
  if (command === "dependencies" && args[0] === "diff") {
114
122
  usage(args.length === 3 && args[1] === "--base" && !!args[2] && !args[2].startsWith("-"), "Usage: agent-tool dependencies diff --base <ref>");
@@ -149,6 +157,11 @@ export function main(argv = process.argv.slice(2)): number | Promise<number> {
149
157
  output(action === "list" ? listImages(images, names) : await buildImages(process.cwd(), images, names, out)); return 0;
150
158
  });
151
159
  }
160
+ if (command === "graph") {
161
+ usage(args.length === 1 && args[0] === "check", "Usage: agent-tool graph check");
162
+ const report = checkGraph(process.cwd(), loadConfig().graph);
163
+ output(report); return report.ok ? 0 : 1;
164
+ }
152
165
  if (command === "skills") {
153
166
  const action = args.shift();
154
167
  if (action === "list") { usage(args.length === 0, "Usage: agent-tool skills list"); output(Object.keys(BUNDLED_SKILLS)); return 0; }
@@ -6,7 +6,7 @@ import { createLog } from "./logFile";
6
6
 
7
7
  const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
8
8
 
9
- export interface RunReceipt { status: string; label?: string; exitCode: number; signal: string | null; durationMs: number; outputBytes: number; log: string }
9
+ export interface RunReceipt { status: string; label?: string; exitCode: number; signal: string | null; durationMs: number; outputBytes: number; log: string; leftoverProcessGroup?: number }
10
10
 
11
11
  /** Run directly into a private file: child output never accumulates in memory. */
12
12
  export async function runCommand(args: string[], record?: (receipt: RunReceipt) => void, options: { quiet?: boolean; reraiseChildSignal?: boolean } = {}): Promise<number> {
@@ -79,9 +79,18 @@ export async function runCommand(args: string[], record?: (receipt: RunReceipt)
79
79
  forward("SIGKILL");
80
80
  }
81
81
  const exitCode = signal ? 128 + (constants.signals[signal] ?? 0) : status ?? 1;
82
- const receipt: RunReceipt = { status: exitCode === 0 ? "passed" : "failed", ...(label ? { label } : {}), exitCode, signal, durationMs: Math.round(performance.now() - started), outputBytes: fstatSync(fd).size, log };
82
+ // A finite command that left processes behind in its group, such as `server &`, is reported:
83
+ // nothing else would notice them before the work is called done.
84
+ let leftover: number | undefined;
85
+ if (grouped && !interrupted && child.pid) {
86
+ // EPERM still means the group exists, now holding a process this user cannot signal.
87
+ try { process.kill(-child.pid, 0); leftover = child.pid; }
88
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "EPERM") leftover = child.pid; }
89
+ }
90
+ const receipt: RunReceipt = { status: exitCode === 0 ? "passed" : "failed", ...(label ? { label } : {}), exitCode, signal, durationMs: Math.round(performance.now() - started), outputBytes: fstatSync(fd).size, log, ...(leftover ? { leftoverProcessGroup: leftover } : {}) };
83
91
  record?.(receipt);
84
92
  if (!options.quiet) writeSync(1, `${JSON.stringify(receipt)}\n`);
93
+ if (leftover) writeSync(2, `Processes this command started are still running in its process group ${leftover} (\`kill -TERM -${leftover}\`). Stop them, or start long-running processes with \`agent-tool proc start\`.\n`);
85
94
  if (exitCode !== 0) printLogTail(log, limits, 2);
86
95
  } finally {
87
96
  if (timer) clearTimeout(timer);
package/src/config.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, lstatSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
+ import { GRAPH_DEFAULTS, parseGraph, type GraphConfig } from "./graph/config";
3
4
  import { parseImages, type ImagesConfig } from "./images/config";
4
5
  import { CLASSES, type PermissionClass } from "./permissions/catalog";
5
6
  import { WORKFLOW_DEFAULTS, validateWorkflowConfig, type WorkflowConfig } from "./workflows/config";
@@ -17,6 +18,7 @@ export interface AgentToolConfig extends WorkflowConfig {
17
18
  versions: { packages: string[] | null; bundles: string[]; lockfile: "bun.lock" | null; validate: string[][]; commitMessage: string };
18
19
  pr: { rejectClaudeBranding: boolean };
19
20
  images: ImagesConfig;
21
+ graph: GraphConfig;
20
22
  permissions: { executables: Record<string, PermissionClass> };
21
23
  }
22
24
 
@@ -36,6 +38,7 @@ export const DEFAULT_CONFIG: AgentToolConfig = {
36
38
  versions: { packages: null, bundles: [], lockfile: "bun.lock", validate: [], commitMessage: "chore: bump package versions" },
37
39
  pr: { rejectClaudeBranding: false },
38
40
  images: { targets: {} },
41
+ graph: structuredClone(GRAPH_DEFAULTS),
39
42
  permissions: { executables: {} },
40
43
  };
41
44
 
@@ -69,6 +72,7 @@ export function parseConfig(source: string): AgentToolConfig {
69
72
  Object.assign(result[section], settings);
70
73
  }
71
74
  if (input.images !== undefined) result.images = parseImages(input.images);
75
+ if (input.graph !== undefined) result.graph = parseGraph(input.graph);
72
76
  if (input.branch !== undefined && !Object.hasOwn(input.branch as object, "enforce")) result.branch.enforce = true;
73
77
  const invalid = (setting: string): never => { throw new Error(`Invalid config setting '${setting}'.`); };
74
78
  if (typeof result.subject.conventional !== "boolean") invalid("subject.conventional");
@@ -0,0 +1,139 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import type { GraphConfig } from "./config";
4
+ import { relativeImports } from "./scan";
5
+
6
+ /** Extensions `graph check` scans; declaration files (`.d.ts`, `.d.mts`, `.d.cts`) are skipped. */
7
+ const SOURCE = /\.(?:ts|tsx|mts|cts)$/;
8
+ const DECLARATION = /\.d\.(?:ts|mts|cts)$/;
9
+ // TypeScript resolves an emitted-JavaScript extension to its source.
10
+ const EMITTED: Record<string, string[]> = { ".js": [".ts", ".tsx"], ".jsx": [".tsx"], ".mjs": [".mts"], ".cjs": [".cts"] };
11
+
12
+ export type Graph = ReadonlyMap<string, ReadonlySet<string>>;
13
+
14
+ export interface GraphReport {
15
+ ok: boolean;
16
+ files: number;
17
+ imports: number;
18
+ cycles: string[][];
19
+ violations: string[];
20
+ unlayered: string[];
21
+ }
22
+
23
+ const matcher = (patterns: readonly string[]) => {
24
+ const globs = patterns.map(pattern => new Bun.Glob(pattern));
25
+ return (file: string) => globs.some(glob => glob.match(file));
26
+ };
27
+
28
+ /** Repository-relative POSIX paths of the scanned sources, sorted. */
29
+ export function sourceFiles(root: string, config: GraphConfig): string[] {
30
+ const excluded = matcher(config.exclude);
31
+ const files = new Set<string>();
32
+ for (const pattern of config.include) {
33
+ // Hidden directories hold sources too; Git metadata and installed packages never do.
34
+ for (const file of new Bun.Glob(pattern).scanSync({ cwd: root, onlyFiles: true, dot: true })) {
35
+ const relative = file.split(path.sep).join("/");
36
+ const parts = relative.split("/");
37
+ if (SOURCE.test(relative) && !DECLARATION.test(relative) && !parts.includes("node_modules") && !parts.includes(".git") && !excluded(relative)) files.add(relative);
38
+ }
39
+ }
40
+ return [...files].sort();
41
+ }
42
+
43
+ function isFile(root: string, file: string): boolean {
44
+ try { return statSync(path.join(root, file)).isFile(); } catch { return false; }
45
+ }
46
+
47
+ /**
48
+ * The scanned-or-not source file a relative specifier names: the exact path, an
49
+ * emitted `.js`/`.jsx`/`.mjs`/`.cjs` name's TypeScript source, an extensionless
50
+ * `.ts` or `.tsx`, or a directory's `index.ts`/`index.tsx`, in TypeScript's order.
51
+ */
52
+ export function resolveImport(root: string, from: string, specifier: string): string | undefined {
53
+ const base = path.posix.normalize(path.posix.join(path.posix.dirname(from), specifier)).replace(/\/+$/, "");
54
+ if (base.startsWith("../") || base === "..") return undefined;
55
+ // `.`, `..`, and a trailing slash name a directory, so only its index can match.
56
+ const directory = /(?:^|\/)\.{1,2}$|\/$/.test(specifier);
57
+ // `.` is the repository root itself, whose index has no directory prefix.
58
+ const prefix = base === "." ? "" : `${base}/`;
59
+ const extension = path.posix.extname(base);
60
+ const stem = base.slice(0, base.length - extension.length);
61
+ const candidates = [
62
+ ...(directory ? [] : [base, ...(EMITTED[extension] ?? []).map(source => stem + source), `${base}.ts`, `${base}.tsx`]),
63
+ `${prefix}index.ts`, `${prefix}index.tsx`,
64
+ ];
65
+ return candidates.find(candidate => SOURCE.test(candidate) && !DECLARATION.test(candidate) && isFile(root, candidate));
66
+ }
67
+
68
+ /** Edges between scanned files; imports of files outside the scan are leaves. */
69
+ export function importGraph(root: string, files: readonly string[], read = (file: string) => readFileSync(path.join(root, file), "utf8")): Map<string, Set<string>> {
70
+ const scanned = new Set(files);
71
+ const graph = new Map<string, Set<string>>();
72
+ for (const file of files) {
73
+ const targets = new Set<string>();
74
+ for (const specifier of relativeImports(read(file))) {
75
+ const target = resolveImport(root, file, specifier);
76
+ if (target && scanned.has(target)) targets.add(target);
77
+ }
78
+ graph.set(file, targets);
79
+ }
80
+ return graph;
81
+ }
82
+
83
+ /** Each cycle once, rotated to start at its smallest module; at least one per strongly connected component. */
84
+ export function findCycles(graph: Graph): string[][] {
85
+ const cycles = new Map<string, string[]>();
86
+ const done = new Set<string>();
87
+ const stack: string[] = [];
88
+ const onStack = new Set<string>();
89
+ const visit = (file: string) => {
90
+ if (onStack.has(file)) {
91
+ const cycle = stack.slice(stack.indexOf(file));
92
+ const start = cycle.indexOf([...cycle].sort()[0]!);
93
+ const rotated = [...cycle.slice(start), ...cycle.slice(0, start)];
94
+ cycles.set(rotated.join(" -> "), rotated);
95
+ return;
96
+ }
97
+ if (done.has(file)) return;
98
+ stack.push(file); onStack.add(file);
99
+ for (const target of [...(graph.get(file) ?? [])].sort()) visit(target);
100
+ stack.pop(); onStack.delete(file);
101
+ done.add(file);
102
+ };
103
+ for (const file of [...graph.keys()].sort()) visit(file);
104
+ return [...cycles.values()].sort((a, b) => a.join(" -> ").localeCompare(b.join(" -> ")));
105
+ }
106
+
107
+ /**
108
+ * Layer and test-only violations. A file belongs to the first layer, lowest first,
109
+ * with a matching glob; production files may import only their own layer or lower
110
+ * ones, and never a test-only file. Test-only files may import anything.
111
+ */
112
+ export function layerViolations(graph: Graph, config: Pick<GraphConfig, "tests" | "layers">): { violations: string[]; unlayered: string[] } {
113
+ const isTest = matcher(config.tests);
114
+ const layers = config.layers.map(layer => matcher(layer.include));
115
+ const layerOf = (file: string) => layers.findIndex(owns => owns(file));
116
+ const violations: string[] = [];
117
+ const unlayered = new Set<string>();
118
+ for (const [file, targets] of graph) {
119
+ if (isTest(file)) continue;
120
+ const from = layerOf(file);
121
+ if (layers.length && from === -1) unlayered.add(file);
122
+ for (const target of targets) {
123
+ if (isTest(target)) { violations.push(`${file} imports test-only ${target}`); continue; }
124
+ const to = layerOf(target);
125
+ if (from !== -1 && to > from) violations.push(`${file} (${config.layers[from]!.name}) imports ${target} (${config.layers[to]!.name})`);
126
+ }
127
+ }
128
+ return { violations: violations.sort(), unlayered: [...unlayered].sort() };
129
+ }
130
+
131
+ export function checkGraph(root: string, config: GraphConfig): GraphReport {
132
+ if (config.include.length === 0) throw new Error("graph.include is empty; list the sources to scan in the graph section of agent-tool.json.");
133
+ const files = sourceFiles(root, config);
134
+ const graph = importGraph(root, files);
135
+ const cycles = findCycles(graph);
136
+ const { violations, unlayered } = layerViolations(graph, config);
137
+ const imports = [...graph.values()].reduce((total, targets) => total + targets.size, 0);
138
+ return { ok: cycles.length === 0 && violations.length === 0 && unlayered.length === 0, files: files.length, imports, cycles, violations, unlayered };
139
+ }
@@ -0,0 +1,43 @@
1
+ /** The data-only `graph` section of agent-tool.json: which TypeScript files `graph check` scans and their layers. */
2
+
3
+ export interface GraphLayer { name: string; include: string[] }
4
+ export interface GraphConfig { include: string[]; exclude: string[]; tests: string[]; layers: GraphLayer[] }
5
+
6
+ export const GRAPH_DEFAULTS: GraphConfig = { include: [], exclude: [], tests: [], layers: [] };
7
+
8
+ function fail(setting: string): never { throw new Error(`Invalid config setting '${setting}'.`); }
9
+ function object(value: unknown, setting: string, allowed: string[]): Record<string, unknown> {
10
+ if (!value || typeof value !== "object" || Array.isArray(value)) fail(setting);
11
+ for (const key of Object.keys(value)) if (!allowed.includes(key)) throw new Error(`Unknown ${setting} setting '${key}'.`);
12
+ return value as Record<string, unknown>;
13
+ }
14
+
15
+ /** Repository-relative glob: no absolute paths, backslashes, negations, or `.`/`..` segments. */
16
+ function globs(value: unknown, setting: string): string[] {
17
+ if (!Array.isArray(value)) fail(setting);
18
+ for (const pattern of value) {
19
+ if (typeof pattern !== "string" || !pattern || pattern.startsWith("/") || pattern.startsWith("!") || pattern.includes("\\")
20
+ || pattern.split("/").some(part => !part || part === "." || part === "..")) fail(setting);
21
+ }
22
+ return [...value] as string[];
23
+ }
24
+
25
+ export function parseGraph(value: unknown): GraphConfig {
26
+ const input = object(value, "graph", Object.keys(GRAPH_DEFAULTS));
27
+ const result = structuredClone(GRAPH_DEFAULTS);
28
+ for (const key of ["include", "exclude", "tests"] as const) if (input[key] !== undefined) result[key] = globs(input[key], `graph.${key}`);
29
+ if (input.layers !== undefined) {
30
+ if (!Array.isArray(input.layers)) fail("graph.layers");
31
+ result.layers = input.layers.map((entry, index) => {
32
+ const setting = `graph.layers[${index}]`;
33
+ const layer = object(entry, setting, ["name", "include"]);
34
+ if (typeof layer.name !== "string" || !/^[\w.-]+$/.test(layer.name)) fail(`${setting}.name`);
35
+ const include = globs(layer.include, `${setting}.include`);
36
+ if (include.length === 0) fail(`${setting}.include`);
37
+ return { name: layer.name, include };
38
+ });
39
+ const names = result.layers.map(layer => layer.name);
40
+ if (new Set(names).size !== names.length) throw new Error("graph.layers names must be unique.");
41
+ }
42
+ return result;
43
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Import specifiers of a TypeScript source, type-only imports included. TypeScript 7
3
+ * has no JS compiler API and Bun.Transpiler.scanImports drops type-only imports, so
4
+ * this tokenizes just enough of the language to tell code from comments, strings,
5
+ * template text, and regular expression literals, then matches import forms.
6
+ */
7
+
8
+ /** Whether a `/` after this code starts a regular expression literal rather than a division. */
9
+ function startsRegex(before: string): boolean {
10
+ const code = before.trimEnd();
11
+ // `<` is left out so JSX closing tags (`</a>`) do not read as expressions; `a++ / b` divides.
12
+ // A postfix `!` after an operand is a non-null assertion (`value! / 2`), not logical not.
13
+ if (/(?:\+\+|--|[\w$)\]]!)$/.test(code)) return false;
14
+ return code === "" || /[(,=:[!&|?{};+\-*%>~^]$/.test(code)
15
+ || /(?<![.$\w])(?:return|typeof|case|in|of|delete|void|throw|new|yield|await|else|do)$/.test(code);
16
+ }
17
+
18
+ // A string or template right after one of these is a module specifier.
19
+ const SPECIFIER_CONTEXT = /(?:(?<![.$\w])from|(?<![.$\w])import\s*\(?|(?<![.$\w])require\s*\()\s*$/;
20
+ const CALL_CONTEXT = /(?<![.$\w])(?:import|require)\s*\(\s*$/;
21
+ // A `)` closing the condition of one of these is followed by a statement, so a `/` there starts a regex.
22
+ const CONTROL = /(?<![.$\w])(?:if|while|for(?:\s+await)?|with)\s*$/;
23
+
24
+ /**
25
+ * Blanks comments, template-literal text, regular expression bodies, and every string
26
+ * that is not a module specifier, keeping the code inside `${...}` interpolations,
27
+ * which can import too. Offsets and line breaks are preserved.
28
+ */
29
+ export function stripNonCode(source: string): string {
30
+ const out = source.split("");
31
+ const blank = (from: number, to: number) => { for (let i = from; i < to; i++) if (out[i] !== "\n") out[i] = " "; };
32
+ // The code before offset i with whitespace runs (including blanked comments) collapsed, so a
33
+ // long comment between `from` and its specifier cannot push `from` out of view.
34
+ const before = (i: number) => {
35
+ let text = "";
36
+ for (let j = i - 1; j >= 0 && text.length < 40; j--) {
37
+ if (/\s/.test(out[j]!)) { if (!text.startsWith(" ")) text = ` ${text}`; } else text = out[j] + text;
38
+ }
39
+ return text;
40
+ };
41
+ const lastCode = (i: number) => { let j = i - 1; while (j >= 0 && /\s/.test(out[j]!)) j--; return j; };
42
+ const regexAt = (i: number) => {
43
+ const last = out[lastCode(i)];
44
+ if (last === ")") return controlClose.has(lastCode(i));
45
+ // `}/>` closes a JSX element whose last attribute is an expression (`<A x={1}/>`).
46
+ if (last === "}" && source[i + 1] === ">") return false;
47
+ return startsRegex(before(i));
48
+ };
49
+ // Open parentheses, each true when it opened a control-flow condition, and the offsets of `)`s that closed one.
50
+ const parens: boolean[] = [];
51
+ const controlClose = new Set<number>();
52
+ // Each entry is the brace depth of an open interpolation; template text resumes when it closes.
53
+ const interpolations: number[] = [];
54
+ let depth = 0;
55
+ let i = 0;
56
+ if (source.startsWith("#!")) { i = source.indexOf("\n"); if (i === -1) i = source.length; blank(0, i); }
57
+ const skipTemplateText = () => {
58
+ const start = i;
59
+ while (i < source.length) {
60
+ if (source[i] === "\\") { i += 2; continue; }
61
+ if (source[i] === "`") { blank(start, i); i++; return; }
62
+ if (source[i] === "$" && source[i + 1] === "{") { blank(start, i); interpolations.push(depth); depth++; i += 2; return; }
63
+ i++;
64
+ }
65
+ blank(start, source.length);
66
+ };
67
+ while (i < source.length) {
68
+ const char = source[i]!;
69
+ if (char === "/" && source[i + 1] === "/") {
70
+ const end = source.indexOf("\n", i);
71
+ const stop = end === -1 ? source.length : end;
72
+ blank(i, stop); i = stop;
73
+ } else if (char === "/" && source[i + 1] === "*") {
74
+ const end = source.indexOf("*/", i + 2);
75
+ const stop = end === -1 ? source.length : end + 2;
76
+ blank(i, stop); i = stop;
77
+ } else if (char === "/" && regexAt(i)) {
78
+ // Skip to the closing slash, honoring escapes and character classes, where `/` is literal.
79
+ const start = ++i;
80
+ let inClass = false;
81
+ while (i < source.length && source[i] !== "\n" && (inClass || source[i] !== "/")) {
82
+ if (source[i] === "\\") i++;
83
+ else if (source[i] === "[") inClass = true;
84
+ else if (source[i] === "]") inClass = false;
85
+ i++;
86
+ }
87
+ blank(start, Math.min(i, source.length));
88
+ i++;
89
+ } else if (char === "\"" || char === "'") {
90
+ const specifier = SPECIFIER_CONTEXT.test(before(i));
91
+ const start = ++i;
92
+ while (i < source.length && source[i] !== char && source[i] !== "\n") i += source[i] === "\\" ? 2 : 1;
93
+ if (!specifier) blank(start, Math.min(i, source.length));
94
+ i++;
95
+ } else if (char === "`") {
96
+ // A substitution-free template right after `import(` or `require(` is a specifier; keep it.
97
+ const close = source.indexOf("`", i + 1);
98
+ const literal = close === -1 ? "" : source.slice(i + 1, close);
99
+ if (close !== -1 && !literal.includes("${") && !literal.includes("\\") && CALL_CONTEXT.test(before(i))) i = close + 1;
100
+ else { i++; skipTemplateText(); }
101
+ } else if (char === "(") { parens.push(CONTROL.test(before(i))); i++; }
102
+ else if (char === ")") { if (parens.pop()) controlClose.add(i); i++; }
103
+ else if (char === "{") { depth++; i++; }
104
+ else if (char === "}") {
105
+ depth--; i++;
106
+ if (interpolations.at(-1) === depth) { interpolations.pop(); skipTemplateText(); }
107
+ } else i++;
108
+ }
109
+ return out.join("");
110
+ }
111
+
112
+ const SPECIFIER = /(?<![.$\w])(?:import|export)(?:\s+type\b)?\s*(?:[\w*{}\s,$"']*?\bfrom\s*)?["']([^"'\n]+)["']|(?<![.$\w])(?:import|require)\s*\(\s*["'`]([^"'`\n]+)["'`]\s*[,)]/g;
113
+
114
+ /** Every module specifier in source order, bare packages included. */
115
+ export function importSpecifiers(source: string): string[] {
116
+ return [...stripNonCode(source).matchAll(SPECIFIER)].map(match => (match[1] ?? match[2])!);
117
+ }
118
+
119
+ /** Relative specifiers (`./` or `../`) in source order; bare packages are leaves of the graph. */
120
+ export function relativeImports(source: string): string[] {
121
+ return importSpecifiers(source).filter(specifier => specifier === "." || specifier === ".." || specifier.startsWith("./") || specifier.startsWith("../"));
122
+ }
@@ -19,12 +19,13 @@ export type Decision = "allow" | "ask";
19
19
  export interface CatalogEntry { command: string; class: PermissionClass; applyClass?: PermissionClass }
20
20
 
21
21
  export const CATALOG: CatalogEntry[] = [
22
- ...["--help", "--version", "logs", "runs list", "doctor", "config show", "branch check", "branch status", "commits check", "checks binaries", "repo context", "policy check", "toolchain check", "ci gate", "release plan", "release verify", "pr context", "pr checks", "pr logs", "pr preflight", "pr threads list", "versions plan", "versions check", "dependencies check-terraform-plan", "dependencies diff", "skills list", "skills check", "hooks check", "images list", "permissions show", "permissions check"]
22
+ ...["--help", "--version", "logs", "runs list", "doctor", "config show", "branch check", "branch status", "commits check", "checks binaries", "repo context", "policy check", "toolchain check", "ci gate", "release plan", "release verify", "pr context", "pr checks", "pr logs", "pr preflight", "pr threads list", "versions plan", "versions check", "dependencies check-terraform-plan", "dependencies diff", "skills list", "skills check", "hooks check", "images list", "graph check", "permissions show", "permissions check", "proc list"]
23
23
  .map(command => ({ command, class: "read" as const })),
24
- ...["bun install", "bun update", "bun remove", "bun test", "branch create", "versions bump", "versions prepare", "versions resolve-conflicts", "images build", "review", "init"]
24
+ ...["bun install", "bun update", "bun remove", "bun test", "branch create", "versions bump", "versions prepare", "versions resolve-conflicts", "images build", "review", "init", "proc stop"]
25
25
  .map(command => ({ command, class: "local" as const })),
26
26
  { command: "skills install", class: "read", applyClass: "local" },
27
27
  { command: "hooks install", class: "read", applyClass: "local" },
28
+ { command: "teardown", class: "read", applyClass: "local" },
28
29
  { command: "permissions install", class: "read", applyClass: "policy" },
29
30
  ...["branch push", "pr open", "pr threads reply", "pr threads resolve"].map(command => ({ command, class: "share" as const })),
30
31
  { command: "pr merge", class: "release" },
@@ -36,7 +37,7 @@ export const CATALOG: CatalogEntry[] = [
36
37
  { command: "openPr", class: "share" },
37
38
  { command: "squashMerge", class: "release" },
38
39
  ];
39
- // `run` and `run-group` execute arbitrary commands, so they stay unclassified and keep the harness default.
40
+ // `run`, `run-group` and `proc start` execute arbitrary commands, so they stay unclassified and keep the harness default.
40
41
  // `bun x` runs only executables that `permissions.executables` classifies; project.ts adds those.
41
42
  // Package scripts are not classified, so `bun run` keeps the harness default.
42
43