@chrok/braid 0.1.1 → 0.1.3

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
@@ -2,6 +2,32 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.3 — 2026-09-29
6
+
7
+ - Add per-node `workspace: "read-only" | "worktree"` for execute/decision nodes.
8
+ Read-only nodes inspect the live source directory with Git inspection, without
9
+ writable tools, snapshots, worktrees, or merge sources. Existing defaults are
10
+ preserved. Pi's braid tool guides analysis/review/routing/synthesis to read-only
11
+ nodes and implementation to worktrees; explicit read-only workspace metadata
12
+ is included in results and events (#22).
13
+ - Skip the automatic merge agent when all remaining workspaces match their
14
+ snapshots, preserving analysis-only terminal outputs and original node errors.
15
+ Mixed runs pass only changed sources to the automatic merge. Unchanged sources
16
+ retain recovery refs pointing to their snapshots without empty checkpoint
17
+ commits; explicit merge nodes still run even for unchanged sources (#21).
18
+ - Prevent Pi TUI crashes in narrow terminals by truncating the flowchart fallback
19
+ message to the terminal width.
20
+ - Scan fork pull requests with CodeQL advanced setup so external contributions
21
+ can satisfy the required security checks.
22
+
23
+ ## 0.1.2 — 2026-09-27
24
+
25
+ - Make Pi depend on the exact `@chrok/braid` version instead of bundling core.
26
+ Export shared model-adapter helpers from the existing root entry point.
27
+ - Use one npm workspace lockfile and a development-only local core link; test
28
+ Pi-only installation against the unpublished core tarball outside the checkout.
29
+ - Wait for core registry metadata and tarball availability before publishing Pi.
30
+
5
31
  ## 0.1.1 — 2026-09-27
6
32
 
7
33
  - Validate the Pi integration against Pi 0.87.1; update GitHub Actions and tsx.
package/CONTRIBUTING.md CHANGED
@@ -12,13 +12,20 @@ Use Node.js 22.19+ and npm. From a fresh checkout:
12
12
  git clone https://github.com/Epsirom/braid.git
13
13
  cd braid
14
14
  npm ci
15
- npm ci --prefix integrations/pi
16
15
  npm run verify
17
16
  ```
18
17
 
19
- There are two packages and two lockfiles. The core has no runtime dependencies;
20
- Pi's dependencies belong in `integrations/pi`. Update and commit the corresponding
21
- lockfile when changing a dependency. Do not commit generated `dist` directories,
18
+ The root npm workspace lockfile covers both packages. A development-only
19
+ `@chrok/braid: file:.` dependency links the core checkout into `node_modules`;
20
+ Pi's manifest still declares the exact release version. This lets CI test a
21
+ new core before it is published. `check:pi`, `test:pi`, and `build:pi` rebuild
22
+ core so package imports resolve current JavaScript and declarations. Consumers
23
+ installing either published package do not install this development dependency.
24
+ Use `npm install --workspace @chrok/pi-braid <dependency>` for Pi dependency
25
+ updates; do not create a separate lockfile in `integrations/pi`.
26
+
27
+ The core has no runtime dependencies. Pi's dependencies belong in its workspace
28
+ manifest. Update and commit the root lockfile when changing a dependency. Do not commit generated `dist` directories,
22
29
  tarballs, credentials, or provider output containing private data.
23
30
 
24
31
  `verify` type-checks both packages, runs deterministic tests and offline examples,
package/README.md CHANGED
@@ -164,9 +164,10 @@ output. The graph has no special fork, branch, or join nodes.
164
164
 
165
165
  ```ts
166
166
  type BraidNode =
167
- | { type: "execute"; id: string; prompt: string; model?: string }
167
+ | { type: "execute"; id: string; prompt: string; model?: string;
168
+ workspace?: "read-only" | "worktree" }
168
169
  | { type: "decision"; id: string; prompt: string;
169
- choices: readonly string[]; model?: string }
170
+ choices: readonly string[]; model?: string; workspace?: "read-only" | "worktree" }
170
171
  | { type: "merge"; id: string; prompt?: string; model?: string };
171
172
 
172
173
  type Edge = { from: string; to: string; choice?: string };
@@ -175,6 +176,11 @@ type BraidInput = { goal: string; nodes: readonly BraidNode[]; edges: readonly E
175
176
 
176
177
  IDs are unique, non-empty strings. Prompts, goals, models, and choices must be
177
178
  non-empty strings when present. Decision choices must be non-empty and unique.
179
+ `workspace` is optional on execute/decision nodes and forbidden on merge nodes.
180
+ Use `"read-only"` for analysis, review, routing, and synthesis. Omit it or use
181
+ `"worktree"` for the existing behavior: an isolated writable worktree in Git,
182
+ read-only access outside Git. Explicit read-only nodes read the live source
183
+ directory, not a fixed snapshot; see [workspace semantics](#worktrees-and-merge-agents).
178
184
  Unknown fields, unsupported node types, missing references, duplicate exact
179
185
  edges, and cycles are rejected. Cycles are rejected even if a decision might
180
186
  make them inactive. Disconnected components are allowed; every root runs.
@@ -259,7 +265,8 @@ The event sequence includes:
259
265
  - `graph_created`, `node_created`, and `edge_created` when the submitted DAG is
260
266
  admitted; an appended final merge emits its own node/edge creation events.
261
267
  - `node_runnable` and `node_started` when scheduling admits a node.
262
- - `workspace_updated` for Git workspace preparation, checkpointing, and cleanup.
268
+ - `workspace_updated` for Git workspace preparation, checkpointing, cleanup, and
269
+ explicitly requested read-only workspaces.
263
270
  - `handoff` for every direct predecessor output passed to a downstream node,
264
271
  including the upstream decision when present.
265
272
  - `node_completed`, `node_skipped`, and `node_failed`, including output,
@@ -281,8 +288,9 @@ render live topology, handoffs, failures, and active nodes without reconstructin
281
288
  scheduler state from final results. Tool selection remains the responsibility of
282
289
  the host agent; the optional Pi adapter supplies explicit proactive-use guidance
283
290
  so Braid is considered for complex multi-branch reasoning without forcing it for
284
- every prompt. In the Pi adapter, Git nodes can inspect and edit individual
285
- worktrees; nodes outside Git stay read-only. Merge agents handle integration; the parent reviews results and runs shell commands and tests.
291
+ every prompt. In the Pi adapter, nodes can inspect the live source read-only or
292
+ edit individual Git worktrees; nodes outside Git stay read-only. Merge agents
293
+ handle integration; the parent reviews results and runs shell commands and tests.
286
294
 
287
295
  ## Context isolation and model runners
288
296
 
@@ -308,7 +316,8 @@ merge agents. Adapters must enforce workspace capabilities and wrap mutating
308
316
  file tools in `request.withWorkspaceWrite(operation)`, so cleanup waits for
309
317
  in-flight writes and rejects later writes. Core Git mutations use this barrier.
310
318
  The included Pi adapter provides guarded `write`/`edit` alongside its read tools.
311
- Outside Git, adapters must provide read-only capabilities. Pi never provides
319
+ For read-only workspaces, adapters must omit mutating tools; the core write
320
+ barrier also rejects writes. This includes all nodes outside Git. Pi never provides
312
321
  `bash`, `powershell`, or a test runner to nodes.
313
322
 
314
323
  `request.predecessors` contains direct active predecessors in incoming-edge
@@ -327,17 +336,39 @@ Read tools follow the host filesystem permissions and are not a security sandbox
327
336
 
328
337
  ### Worktrees and merge agents
329
338
 
330
- In a Git checkout, execute and decision nodes receive detached worktrees under
331
- `os.tmpdir()/braid-workspaces-*/<unique-id>`. The first node captures tracked
339
+ By default, execute and decision nodes in a Git checkout receive detached worktrees
340
+ under `os.tmpdir()/braid-workspaces-*/<unique-id>`. The first writable node captures tracked
332
341
  staged/unstaged changes, deletions, and non-ignored untracked files with a temporary
333
342
  index. Snapshot creation preserves the source index, branch, and files. Ignored
334
343
  files are not copied, and submodules are not initialized or recursively captured.
335
344
  Pi rejects writes inside submodules. If a custom runner populates one, core
336
345
  reports cleanup failure and retains the worktree rather than losing those files.
337
- Empty repositories are supported. Nodes share this baseline until a merge ends;
338
- subsequent nodes snapshot the current source checkout. Relative working directories
346
+ Empty repositories are supported. Worktree nodes share this baseline until a merge
347
+ ends; subsequent worktree nodes snapshot the current source checkout. Relative working directories
339
348
  are preserved. Code changes do not implicitly flow into successor worktrees.
340
349
 
350
+ Set `workspace: "read-only"` on execute/decision nodes that only inspect or reason
351
+ about files. They read the original `cwd`, including ignored files accessible to
352
+ the adapter, without creating a snapshot, worktree, checkpoint, or merge source.
353
+ In Git they retain inspection commands (`status`, `diff`, `show`, `log`,
354
+ `ls-files`, `rev-parse`), with optional Git index writes disabled. Relative paths
355
+ use the original `cwd`, including when it is a subdirectory of the repository.
356
+ These reads observe the live checkout: parent edits or concurrent merge nodes
357
+ may change files during execution. Use worktree mode when a fixed snapshot is
358
+ needed. Predecessor edits are visible in the source only after integration;
359
+ their worktrees/checkpoints can still be inspected explicitly.
360
+
361
+ Explicit read-only allocations appear in `workspace_updated`, node/predecessor
362
+ workspace metadata, and `result.workspaces` with mode `read-only` and state
363
+ `ready`. They have no cleanup lifecycle or recovery refs. Implicit non-Git
364
+ read-only runs retain their existing event/result behavior. A custom runner is
365
+ trusted code and must honor the workspace capability; this is not an OS sandbox.
366
+
367
+ For a mixed graph, give review branches `workspace: "read-only"` and leave
368
+ implementation branches in worktree mode. Use a read-only execute node to
369
+ summarize findings; a merge node integrates file changes and does not accept
370
+ the `workspace` field.
371
+
341
372
  Add `{ type: "merge", id: "integrate" }` with incoming edges from any number of
342
373
  sources. The merge agent receives predecessor errors, workspace paths, and Git
343
374
  checkpoint refs and operates directly in the invoking checkout. **Core does not
@@ -376,13 +407,24 @@ including intentionally discarded changes and ignored node output files. A
376
407
  later consumer can inspect a removed source through `git show <checkpointRef>`.
377
408
  Core also records `backupRef` for the source checkout before each merge agent.
378
409
 
379
- When declared nodes settle and worktrees remain, core appends an ordinary merge
410
+ When declared nodes settle, core releases worktrees whose contents match their
411
+ own snapshot, including those from failed nodes, as `discarded` with reason
412
+ `No changes from snapshot`. Their checkpoint refs point to the snapshot commit
413
+ without creating empty checkpoint commits; original node errors remain visible.
414
+ This comparison includes ignored output files. Explicit merge nodes still run
415
+ even when their sources have no changes.
416
+
417
+ When worktrees with changes remain, core appends an ordinary merge
380
418
  agent named `__braid_merge__` (with a suffix if needed), using the run's default
381
419
  model. It appears in results, events, usage, and terminal outputs. Merge nodes
382
420
  are exclusive within a graph and serialized per source checkout across runs in
383
421
  the same process. Avoid concurrent external edits to that checkout while merging;
384
422
  this lock does not coordinate other processes or the parent editor.
385
423
 
424
+ Analysis-only graphs therefore keep their declared terminal outputs and do not
425
+ incur an automatic merge model call. Consumers should not assume that every Git
426
+ run includes `__braid_merge__`; use `terminalOutputs` for the completed endpoints.
427
+
386
428
  Cancellation or graph timeout prevents new merge agents from starting. Core waits
387
429
  for tracked writes, archives remaining work, and removes its worktrees. Merge
388
430
  agent failure follows the same archive/cleanup path. It does not reset the source
package/SECURITY.md CHANGED
@@ -22,6 +22,10 @@ upgrade. Before the first release, report issues against the current main branch
22
22
  changes into the source checkout; they are not restricted to read-only analysis.
23
23
  Outside Git, Pi file tools stay read-only. Read paths can expose files outside
24
24
  the checkout and disclose content to a model provider.
25
+ - Execute/decision nodes can request `workspace: "read-only"` inside Git. Pi
26
+ omits write/edit tools, and core rejects write-barrier operations. Git inspection
27
+ remains available. These nodes read the live source directory, not an isolated
28
+ snapshot; custom runners must honor this capability themselves.
25
29
  - Guarded write tools reject external paths, Git metadata, symlinks, hard links,
26
30
  and special files, but are not an OS sandbox against concurrent filesystem
27
31
  attacks. Avoid concurrent external source edits while merge agents run. A
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export { braid } from "./runtime.js";
2
2
  export { validateGraph, GraphValidationError } from "./validate.js";
3
3
  export type { BraidInput, BraidNode, BraidOptions, BraidResult, DecisionNode, Edge, ExecuteNode, ExecutionContext, ExecutionError, ExecutionEvent, ModelRequest, ModelResponse, ModelRunner, MergeNode, MergeDisposition, MergeSource, GitPreview, SourceCheckoutStatus, NodeWorkspace, GitResult, NodeOutput, NodeResult, NodeStatus, PredecessorOutput, TokenUsage, } from "./types.js";
4
+ export { formatBudgetReminder } from "./budgets.js";
5
+ export { gitToolDefinition, finishMergeToolDefinition, mergeInstructions, parseGitToolArguments, parseFinishMergeArguments, } from "./merge-tools.js";
package/dist/index.js CHANGED
@@ -1,2 +1,5 @@
1
1
  export { braid } from "./runtime.js";
2
2
  export { validateGraph, GraphValidationError } from "./validate.js";
3
+ // Shared helpers for model adapters, including the Pi integration.
4
+ export { formatBudgetReminder } from "./budgets.js";
5
+ export { gitToolDefinition, finishMergeToolDefinition, mergeInstructions, parseGitToolArguments, parseFinishMergeArguments, } from "./merge-tools.js";
package/dist/runtime.js CHANGED
@@ -179,6 +179,7 @@ async function runNode(request, result, runner, timeoutMs, graphSignal, graphDea
179
179
  });
180
180
  void aborted.catch(() => { });
181
181
  let acceptingWrites = true;
182
+ let writableWorkspace = false;
182
183
  const writes = new Set();
183
184
  let merge;
184
185
  let acceptingDecisions = true;
@@ -191,6 +192,8 @@ async function runNode(request, result, runner, timeoutMs, graphSignal, graphDea
191
192
  signal.throwIfAborted();
192
193
  if (!acceptingWrites)
193
194
  throw new Error("Node has finished; further writes are unavailable");
195
+ if (!writableWorkspace)
196
+ throw new Error("Node workspace is read-only; writes are unavailable");
194
197
  const pending = Promise.resolve().then(() => {
195
198
  signal.throwIfAborted();
196
199
  return operation();
@@ -249,6 +252,7 @@ async function runNode(request, result, runner, timeoutMs, graphSignal, graphDea
249
252
  invocation.workspace = await workspaces.prepare(invocation);
250
253
  }
251
254
  invocation.workspace = structuredClone(invocation.workspace);
255
+ writableWorkspace = invocation.workspace.mode !== "read-only";
252
256
  if (invocation.workspace?.sourceRoot) {
253
257
  const gitRequest = { ...invocation, node: structuredClone(invocation.node), workspace: structuredClone(invocation.workspace) };
254
258
  invocation.git = (args, input) => workspaces.git(gitRequest, args, input);
@@ -375,8 +379,11 @@ export async function braid(input, options) {
375
379
  result.model = model;
376
380
  return [node.id, result];
377
381
  }));
382
+ const explicitReadOnly = new Set(graph.nodes.filter(node => node.type !== "merge" && node.workspace === "read-only").map(node => node.id));
383
+ // Preserve the existing event/result shape for implicit non-Git read-only runs.
384
+ const reportWorkspace = (workspace) => workspace.mode !== "read-only" || explicitReadOnly.has(workspace.nodeId);
378
385
  const workspaces = new GitWorkspaces(resolve(options.cwd ?? process.cwd()), workspace => {
379
- if (workspace.mode === "read-only")
386
+ if (!reportWorkspace(workspace))
380
387
  return;
381
388
  const node = results.get(workspace.nodeId);
382
389
  if (node)
@@ -438,6 +445,18 @@ export async function braid(input, options) {
438
445
  running.set(node.id, task);
439
446
  }
440
447
  if (running.size === 0) {
448
+ if (!automaticMergeAdded) {
449
+ try {
450
+ await workspaces.discardUnchanged();
451
+ }
452
+ catch (error) {
453
+ cleanupError = { code: "CLEANUP_FAILED", message: error instanceof Error ? error.message : String(error) };
454
+ break;
455
+ }
456
+ // Checkpointing/cleanup can outlast the deadline or trigger cancellation.
457
+ if (controller.signal.aborted || performance.now() >= graphDeadline)
458
+ continue;
459
+ }
441
460
  const pending = workspaces.pending();
442
461
  if (!automaticMergeAdded && pending.length) {
443
462
  automaticMergeAdded = true;
@@ -503,7 +522,7 @@ export async function braid(input, options) {
503
522
  terminalOutputs,
504
523
  events: [],
505
524
  nodes: Object.fromEntries(results),
506
- ...(Object.values(workspaces.all()).some(value => value.mode !== "read-only") ? { workspaces: workspaces.all() } : {}),
525
+ ...(Object.values(workspaces.all()).some(reportWorkspace) ? { workspaces: workspaces.all() } : {}),
507
526
  metadata: {
508
527
  ...execution,
509
528
  startedAt,
package/dist/types.d.ts CHANGED
@@ -3,6 +3,8 @@ export interface ExecuteNode {
3
3
  id: string;
4
4
  prompt: string;
5
5
  model?: string;
6
+ /** Read the live cwd without a worktree; defaults to worktree in Git, read-only elsewhere. */
7
+ workspace?: "read-only" | "worktree";
6
8
  }
7
9
  export interface DecisionNode {
8
10
  type: "decision";
@@ -10,6 +12,8 @@ export interface DecisionNode {
10
12
  prompt: string;
11
13
  choices: readonly string[];
12
14
  model?: string;
15
+ /** Read the live cwd without a worktree; defaults to worktree in Git, read-only elsewhere. */
16
+ workspace?: "read-only" | "worktree";
13
17
  }
14
18
  export interface MergeNode {
15
19
  type: "merge";
@@ -108,7 +112,7 @@ export interface ModelRequest {
108
112
  graph?: number;
109
113
  };
110
114
  workspace?: NodeWorkspace;
111
- /** Adapters must wrap mutating file tools so cancellation and cleanup wait for in-flight writes. */
115
+ /** Rejects read-only writes; adapters must wrap mutating file tools so cleanup waits for in-flight writes. */
112
116
  withWorkspaceWrite?: <T>(operation: () => Promise<T>) => Promise<T>;
113
117
  /** Local Git operations: inspection for workers, integration commands for merge nodes. */
114
118
  git?: (args: string[], input?: string) => Promise<GitResult>;
@@ -189,7 +193,7 @@ export type ExecutionEvent = Readonly<{
189
193
  export interface BraidOptions {
190
194
  runner: ModelRunner;
191
195
  defaultModel?: string;
192
- /** Source checkout. Git workspace management is automatic; outside Git, nodes are read-only. */
196
+ /** Source checkout. Nodes may opt into live read-only access; outside Git, all nodes are read-only. */
193
197
  cwd?: string;
194
198
  /** Positive integer. Defaults to 4. */
195
199
  maxConcurrency?: number;
package/dist/validate.js CHANGED
@@ -34,16 +34,21 @@ export function compileGraph(input) {
34
34
  requireValid(isRecord(node), "Node must be an object");
35
35
  requireValid(node.type === "execute" || node.type === "decision" || node.type === "merge", "Unknown node type");
36
36
  fields(node, node.type === "decision"
37
- ? ["type", "id", "prompt", "model", "choices"]
38
- : ["type", "id", "prompt", "model"], "Node");
37
+ ? ["type", "id", "prompt", "model", "choices", "workspace"]
38
+ : node.type === "execute"
39
+ ? ["type", "id", "prompt", "model", "workspace"]
40
+ : ["type", "id", "prompt", "model"], "Node");
39
41
  requireValid(text(node.id), "Node id must be a non-empty string");
40
42
  requireValid(!byId.has(node.id), `Duplicate node id '${node.id}'`);
41
43
  requireValid(text(node.prompt) || (node.type === "merge" && node.prompt === undefined), `Node '${node.id}' needs a non-empty prompt`);
42
44
  requireValid(node.model === undefined || text(node.model), `Invalid model on '${node.id}'`);
45
+ const workspace = node.type === "merge" ? undefined : node.workspace;
46
+ requireValid(workspace === undefined || workspace === "read-only" || workspace === "worktree", `Invalid workspace on '${node.id}'`);
43
47
  const common = {
44
48
  id: node.id,
45
49
  prompt: node.prompt ?? "Review all predecessor changes, decide how to integrate them into the source repository, and account for every source with finish_merge.",
46
50
  ...(node.model !== undefined ? { model: node.model } : {}),
51
+ ...(workspace !== undefined ? { workspace } : {}),
47
52
  };
48
53
  if (node.type === "decision") {
49
54
  requireValid(Array.isArray(node.choices) && node.choices.length > 0, `Decision '${node.id}' needs at least one choice`);
@@ -1,5 +1,5 @@
1
1
  import type { GitResult, MergeDisposition, MergeSource, ModelRequest, NodeWorkspace, SourceCheckoutStatus } from "./types.js";
2
- /** One source snapshot per graph; every invoked node gets its own detached worktree. */
2
+ /** Writable workers share a snapshot; explicit read-only workers inspect the live cwd. */
3
3
  export declare class GitWorkspaces {
4
4
  private readonly cwd;
5
5
  private readonly onWorkspace?;
@@ -9,12 +9,15 @@ export declare class GitWorkspaces {
9
9
  private allocated;
10
10
  constructor(cwd: string, onWorkspace?: ((workspace: NodeWorkspace) => void) | undefined);
11
11
  private report;
12
+ private sourceRoot;
12
13
  private snapshot;
13
14
  prepare(request: ModelRequest): Promise<NodeWorkspace>;
14
15
  all(): Record<string, NodeWorkspace>;
15
16
  pending(): string[];
16
17
  /** Checkpoint every file, including ignored node outputs, before releasing a worktree. */
17
18
  private checkpoint;
19
+ /** Run only after declared nodes settle, so consumers retain their source paths. */
20
+ discardUnchanged(): Promise<void>;
18
21
  private release;
19
22
  /** Only archive/remove here. Choosing merge/cherry-pick/apply always belongs to the agent. */
20
23
  archivePending(reason: string): Promise<void>;
@@ -91,7 +91,7 @@ async function gitPreview(cwd, args, limit) {
91
91
  }
92
92
  return { text: output.slice(0, limit), truncated: overflow || output.length > limit };
93
93
  }
94
- /** One source snapshot per graph; every invoked node gets its own detached worktree. */
94
+ /** Writable workers share a snapshot; explicit read-only workers inspect the live cwd. */
95
95
  export class GitWorkspaces {
96
96
  cwd;
97
97
  onWorkspace;
@@ -112,7 +112,7 @@ export class GitWorkspaces {
112
112
  // Observers cannot change permissions or fail node execution.
113
113
  }
114
114
  }
115
- async snapshot() {
115
+ async sourceRoot() {
116
116
  let ancestor = resolve(this.cwd);
117
117
  while (true) {
118
118
  try {
@@ -138,7 +138,12 @@ export class GitWorkspaces {
138
138
  return undefined;
139
139
  throw error;
140
140
  }
141
- sourceRoot = await realpath(sourceRoot);
141
+ return realpath(sourceRoot);
142
+ }
143
+ async snapshot() {
144
+ const sourceRoot = await this.sourceRoot();
145
+ if (!sourceRoot)
146
+ return undefined;
142
147
  const commonDirectory = await realpath(resolve(sourceRoot, await git(sourceRoot, ["rev-parse", "--git-common-dir"])));
143
148
  const cwdSuffix = relative(sourceRoot, await realpath(this.cwd));
144
149
  // Git records canonical worktree paths. In particular, Windows tmpdir()
@@ -183,7 +188,7 @@ export class GitWorkspaces {
183
188
  "-m", "Braid isolated workspace snapshot",
184
189
  ], options);
185
190
  const snapshot = {
186
- directory, commonDirectory, sourceRoot, cwdSuffix, snapshotCommit, hooksDirectory,
191
+ directory, commonDirectory, sourceRoot, cwdSuffix, snapshotTree: tree, snapshotCommit, hooksDirectory,
187
192
  ...(baseCommit ? { baseCommit } : {}),
188
193
  };
189
194
  this.allocated.add(snapshot);
@@ -200,6 +205,16 @@ export class GitWorkspaces {
200
205
  }
201
206
  async prepare(request) {
202
207
  request.signal.throwIfAborted();
208
+ if (request.node.type !== "merge" && request.node.workspace === "read-only") {
209
+ const sourceRoot = await this.sourceRoot();
210
+ request.signal.throwIfAborted();
211
+ const workspace = {
212
+ nodeId: request.node.id, mode: "read-only", workingDirectory: this.cwd, state: "ready",
213
+ ...(sourceRoot ? { sourceRoot } : {}),
214
+ };
215
+ this.report(workspace);
216
+ return workspace;
217
+ }
203
218
  let pending = this.snapshots.get(request.execution.runId);
204
219
  if (!pending) {
205
220
  // Do not bind the shared snapshot to one node's cancellation signal.
@@ -279,7 +294,7 @@ export class GitWorkspaces {
279
294
  }
280
295
  await git(cwd, ["add", "--force", "--all", "--", "."], options);
281
296
  const tree = await git(cwd, ["write-tree"], options);
282
- const commit = await git(cwd, [
297
+ const commit = tree === location.snapshotTree ? workspace.snapshotCommit : await git(cwd, [
283
298
  "-c", "user.name=Braid", "-c", "user.email=braid@localhost", "-c", "commit.gpgsign=false",
284
299
  "commit-tree", tree, "-p", workspace.snapshotCommit, "-m", `Braid node checkpoint: ${workspace.nodeId}`,
285
300
  ], options);
@@ -290,6 +305,27 @@ export class GitWorkspaces {
290
305
  workspace.checkpointRef = ref;
291
306
  this.report(workspace);
292
307
  }
308
+ /** Run only after declared nodes settle, so consumers retain their source paths. */
309
+ async discardUnchanged() {
310
+ const errors = [];
311
+ for (const id of this.pending()) {
312
+ const workspace = this.records.get(id);
313
+ // Incomplete preparation must follow the existing failure/recovery path.
314
+ // A failed invocation with a successfully prepared workspace is still ready.
315
+ if (workspace.state !== "ready")
316
+ continue;
317
+ try {
318
+ await this.checkpoint(workspace);
319
+ if (workspace.checkpointCommit === workspace.snapshotCommit)
320
+ await this.release(id, "discarded", "No changes from snapshot");
321
+ }
322
+ catch (error) {
323
+ errors.push(error);
324
+ }
325
+ }
326
+ if (errors.length)
327
+ throw new AggregateError(errors, "Some workspaces could not be checked or removed; their paths are retained in workspaces");
328
+ }
293
329
  async release(id, disposition, reason) {
294
330
  const workspace = this.records.get(id);
295
331
  const location = this.locations.get(id);
@@ -374,7 +410,8 @@ export class GitWorkspaces {
374
410
  }))
375
411
  throw new Error("Git arguments cannot override filesystem boundaries, execute external helpers, or select external strategies");
376
412
  const workspace = request.workspace;
377
- const cwd = workspace.mode === "merge" ? workspace.sourceRoot : workspace.worktreeRoot;
413
+ const cwd = workspace.mode === "read-only" ? workspace.workingDirectory
414
+ : workspace.mode === "merge" ? workspace.sourceRoot : workspace.worktreeRoot;
378
415
  const location = this.locations.get(request.node.id);
379
416
  const actualArgs = [command,
380
417
  ...(["diff", "show", "log"].includes(command) ? ["--no-ext-diff", "--no-textconv"] : []),
@@ -382,7 +419,9 @@ export class GitWorkspaces {
382
419
  ];
383
420
  const execute = () => new Promise((resolve, reject) => {
384
421
  const child = execFile("git", [
385
- "-c", `core.hooksPath=${location.hooksDirectory}`, "-c", "commit.gpgsign=false",
422
+ ...(location ? ["-c", `core.hooksPath=${location.hooksDirectory}`] : []),
423
+ ...(workspace.mode === "read-only" ? ["--no-optional-locks"] : []),
424
+ "-c", "commit.gpgsign=false",
386
425
  "-c", "core.fsmonitor=false", "--no-pager", "-C", cwd, ...actualArgs,
387
426
  ], {
388
427
  env: { ...gitEnvironment(), GIT_TERMINAL_PROMPT: "0", GIT_EDITOR: "true", GIT_SEQUENCE_EDITOR: "true", GIT_MERGE_AUTOEDIT: "no" },
@@ -23,11 +23,17 @@ exactly one automatic continuation when a job finishes during `agent_settled`.
23
23
  This covers the Pi 0.86/0.87 transcript and settling changes without provider
24
24
  credentials or network model calls. Version 0.1.0 was originally validated with
25
25
  Pi 0.85.1; the current checkout's pinned validation target is 0.87.1.
26
- The Pi npm package compiles and includes the same core source as the matching
27
- core release, so it does not need another installed copy of Braid or a checkout.
26
+ The Pi npm package declares an exact dependency on the matching `@chrok/braid`
27
+ release. npm installs the core automatically; Pi does not bundle another copy of
28
+ its runtime and does not need a source checkout.
28
29
 
29
30
  Git must be installed for workspace execution inside a Git checkout. Non-Git
30
31
  text-only runs do not require Git workspace management.
32
+ Execute/decision nodes can opt into `workspace: "read-only"`; omitted or
33
+ `"worktree"` values preserve the existing allocation behavior. Merge nodes reject
34
+ this field. Explicit read-only nodes report workspace metadata/events even when
35
+ the entire run is read-only; implicit non-Git runs keep their existing shapes.
36
+ Older versions reject the new field during validation.
31
37
 
32
38
  ## Versioning
33
39
 
@@ -36,8 +42,13 @@ preserve documented behavior; breaking API or semantic changes require a minor
36
42
  version bump, a changelog entry, and migration guidance. New optional fields or
37
43
  fixes that restore the documented contract may ship in a patch.
38
44
 
39
- Supported core entry points are `@chrok/braid` and `@chrok/braid/adapters/openai`. Internal
40
- files and Pi helper classes are not stable public APIs. Documented result/error
45
+ Supported core entry points are `@chrok/braid` and `@chrok/braid/adapters/openai`.
46
+ The root `@chrok/braid` entry point also exports adapter helpers:
47
+ `formatBudgetReminder`, `gitToolDefinition`, `finishMergeToolDefinition`,
48
+ `mergeInstructions`, `parseGitToolArguments`, and `parseFinishMergeArguments`.
49
+ These helpers share the core's compatibility policy; Git workspace implementation
50
+ classes remain internal. Internal files and Pi helper classes are not stable
51
+ public APIs. Documented result/error
41
52
  fields, routing behavior, `ModelRunner`, and existing event meanings are part of
42
53
  the public contract. Consumers should ignore new diagnostic fields and provide a
43
54
  fallback for new event types; event sequence numbers order events within a run,
package/docs/releasing.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Releasing
2
2
 
3
3
  Core and Pi are separate public npm packages built from one commit. Both use the
4
- same version. The core has no runtime dependencies. Pi bundles compiled core
5
- source so its installed extension never reaches outside its own package.
4
+ same version. The core has no runtime dependencies. Pi declares an exact
5
+ `@chrok/braid` dependency and includes only its own compiled integration code.
6
6
 
7
7
  ## Prepare a release
8
8
 
@@ -10,11 +10,14 @@ Version tags (`v*`) cannot be moved or deleted. New GitHub releases are immutabl
10
10
  prepare a draft and attach any assets before publishing. Published tag/asset
11
11
  corrections require a new version. See [repository settings](repository-settings.md).
12
12
 
13
- 1. Update both `package.json` versions and both lockfiles. Use a minor version
13
+ 1. Update both `package.json` versions, Pi's exact `@chrok/braid` dependency,
14
+ and the root workspace lockfile (`npm install --package-lock-only`). Use a minor version
14
15
  for breaking 0.x changes, and describe migrations in the changelog.
15
- 2. Run `npm ci`, `npm ci --prefix integrations/pi`, and `npm run verify`.
16
+ 2. Run `npm ci` and `npm run verify` from the repository root.
16
17
  The package check verifies clean builds, public ESM imports, declarations,
17
18
  licenses, and Pi registration in a temporary consumer outside the checkout.
19
+ A temporary local registry serves the unpublished core tarball; installing
20
+ only the Pi tarball must fetch core transitively through its version dependency.
18
21
  3. Update the changelog and supported Pi version. Commit and review the changes;
19
22
  require the CI matrix to pass before tagging that commit `vX.Y.Z`.
20
23
  4. Inspect `npm pack --dry-run` and `npm pack --dry-run` from `integrations/pi`.
@@ -64,7 +67,11 @@ npm trust list @chrok/pi-braid
64
67
 
65
68
  Publishing a non-prerelease GitHub release triggers `.github/workflows/release.yml`.
66
69
  It validates the tag/version relationship, repeats all checks, and publishes
67
- core then Pi using short-lived OIDC credentials. It does not require `NPM_TOKEN`.
70
+ core then Pi using short-lived OIDC credentials. After each publish (or retry
71
+ of an existing version), it waits for matching version/commit metadata and a
72
+ successful tarball download. Pi is not published until core is available. npm
73
+ processing is polled every 15 seconds for up to 10 minutes; a timeout fails the
74
+ workflow with retry instructions. It does not require `NPM_TOKEN`.
68
75
  The workflow installs npm 11 and uses Node 24. See
69
76
  [npm's trusted publishing documentation](https://docs.npmjs.com/trusted-publishers/).
70
77
 
@@ -57,8 +57,13 @@ Use a new version for a correction. See [the release guide](releasing.md).
57
57
  before using it.
58
58
  - Workflows from all external fork contributors require maintainer approval
59
59
  before running. Inspect workflow and code changes before approving a run.
60
- - CodeQL default setup scans GitHub Actions and JavaScript/TypeScript with the
61
- default query suite and remote threat model, including its weekly schedule.
60
+ - CodeQL advanced setup in [.github/workflows/codeql.yml](../.github/workflows/codeql.yml)
61
+ scans GitHub Actions and JavaScript/TypeScript with the default query suite and
62
+ remote threat model. It runs on pushes to `main`, pull requests targeting
63
+ `main` (including forks), and a weekly schedule, with a manual trigger available.
64
+ Default setup must remain disabled because it skips fork pull requests and
65
+ prevents advanced-setup analysis uploads. Fork PR scans use `pull_request`
66
+ and remain subject to the contributor approval policy above.
62
67
  - Dependabot alerts/security updates, secret scanning, secret push protection,
63
68
  and private vulnerability reporting are enabled. Dependency updates remain
64
69
  configured in [.github/dependabot.yml](../.github/dependabot.yml).
@@ -68,7 +73,8 @@ sign-off nor a CLA is required for contributions.
68
73
 
69
74
  ## Dependency maintenance
70
75
 
71
- Dependabot checks both npm manifests weekly. Pi host packages stay in a separate
76
+ Dependabot checks both npm workspace manifests through the root lockfile weekly.
77
+ The internal `@chrok/braid` dependency is updated by the coordinated release process. Pi host packages stay in a separate
72
78
  group because even 0.x minor releases can change extension contracts. Other npm
73
79
  minor/patch updates are grouped; GitHub Actions updates are grouped monthly and
74
80
  retain full commit SHA pins. Grouping does not enable automatic merging.
@@ -44,8 +44,10 @@ after exporting needed results. Reloading also cancels outstanding work.
44
44
  Git worktree preparation, checkpointing, tracked writes, and cleanup add disk,
45
45
  Git-process, and elapsed-time overhead. Cleanup may extend wall time beyond a
46
46
  model deadline. Automatically appended merge agents are additional model calls
47
- and share the graph's remaining time. Benchmark results measured outside Git do
48
- not include this lifecycle. Recoverable refs retain Git objects until removed.
47
+ and share the graph's remaining time; unchanged worktrees are released without
48
+ an automatic merge call, but still incur workspace and checkpoint overhead.
49
+ Benchmark results measured outside Git do not include this lifecycle. Recoverable
50
+ refs retain Git objects until removed.
49
51
 
50
52
  An aborted runtime slot can be reused even if an uncooperative provider continues
51
53
  working. Runners must forward `signal`; neither Braid nor JavaScript can forcibly
@@ -6,9 +6,9 @@ const diff = "- return cache[key];\n+ return cache[key] ?? await load(key);";
6
6
  const graph: BraidInput = {
7
7
  goal: `Review this proposed cache change:\n${diff}`,
8
8
  nodes: [
9
- { type: "execute", id: "correctness", prompt: "Review cache semantics and concurrent misses." },
10
- { type: "execute", id: "tests", prompt: "Identify regression cases worth testing." },
11
- { type: "execute", id: "review", prompt: "Synthesize actionable findings from both reviews." },
9
+ { type: "execute", id: "correctness", workspace: "read-only", prompt: "Review cache semantics and concurrent misses." },
10
+ { type: "execute", id: "tests", workspace: "read-only", prompt: "Identify regression cases worth testing." },
11
+ { type: "execute", id: "review", workspace: "read-only", prompt: "Synthesize actionable findings from both reviews." },
12
12
  ],
13
13
  edges: [{ from: "correctness", to: "review" }, { from: "tests", to: "review" }],
14
14
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrok/braid",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "license": "MIT",
5
5
  "description": "A small, framework-agnostic DAG runtime for isolated model invocations",
6
6
  "type": "module",
@@ -32,12 +32,12 @@
32
32
  "scripts": {
33
33
  "build": "node scripts/build.mjs",
34
34
  "check": "tsc --noEmit",
35
- "test": "tsx --test test/*.test.ts",
35
+ "test": "tsx --test test/*.test.ts test/*.test.mjs",
36
36
  "demo": "tsx examples/basic.ts",
37
- "check:pi": "npm --prefix integrations/pi run check",
38
- "test:pi": "npm --prefix integrations/pi test",
37
+ "check:pi": "npm run build && npm run check --workspace @chrok/pi-braid",
38
+ "test:pi": "npm run build && npm test --workspace @chrok/pi-braid",
39
39
  "prepack": "npm run build",
40
- "build:pi": "npm --prefix integrations/pi run build",
40
+ "build:pi": "npm run build --workspace @chrok/pi-braid",
41
41
  "test:package": "node scripts/package-smoke.mjs",
42
42
  "verify": "npm run check && npm test && npm run check:pi && npm run test:pi && npm run examples && npm run test:package",
43
43
  "examples": "tsx examples/basic.ts && tsx examples/code-review.ts && tsx examples/failure-handling.ts && tsx examples/custom-runner.ts",
@@ -48,7 +48,8 @@
48
48
  "devDependencies": {
49
49
  "@types/node": "^22.0.0",
50
50
  "tsx": "^4.23.15",
51
- "typescript": "^5.0.0"
51
+ "typescript": "^5.0.0",
52
+ "@chrok/braid": "file:."
52
53
  },
53
54
  "repository": {
54
55
  "type": "git",
@@ -68,5 +69,8 @@
68
69
  "publishConfig": {
69
70
  "access": "public",
70
71
  "registry": "https://registry.npmjs.org"
71
- }
72
+ },
73
+ "workspaces": [
74
+ "integrations/pi"
75
+ ]
72
76
  }