@chrok/braid 0.1.2 → 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,24 @@
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
+
5
23
  ## 0.1.2 — 2026-09-27
6
24
 
7
25
  - Make Pi depend on the exact `@chrok/braid` version instead of bundling core.
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/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" },
@@ -29,6 +29,11 @@ its runtime and does not need a source checkout.
29
29
 
30
30
  Git must be installed for workspace execution inside a Git checkout. Non-Git
31
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.
32
37
 
33
38
  ## Versioning
34
39
 
@@ -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).
@@ -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.2",
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",