@bridge4dev/runner 0.61.0 → 0.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,12 +49,18 @@ it. If you would rather run it in the foreground, or under your own supervisor,
49
49
 
50
50
  ## What it does on your machine
51
51
 
52
- - Creates a **git worktree and a branch per session**, so an agent never works in your
53
- working copy and never touches your current branch.
54
- - Enforces a local policy layer the dashboard **cannot** raise: an allow/deny list for
55
- commands, secret masking in everything it streams, and its own ceiling on how many
56
- sessions may run at once. Anything the server asks for is bounded by what this config
57
- permits.
52
+ - Runs a session either **in the project folder itself** — the default: the branch it is
53
+ already on, nothing to apply afterwards — or in a **git worktree on a session branch of
54
+ its own**, which DevBridge asks for when the folder is busy or a branch was named. Which
55
+ one a session gets is decided per session, in the dashboard.
56
+ - Enforces a local policy layer. Part of it the dashboard **cannot** raise: secret files and
57
+ keys are never read, nothing is written into any repository's `.git`, and `sudo`, service
58
+ control, docker and the firewall are refused in every trust level. Part of it is the bound
59
+ project's own setting, chosen by a manager in DevBridge: whether the agent may `git push`
60
+ and to which branches, and whether it may read and change files **outside the project
61
+ folder** — other projects on the same machine included. Out of the box the agent pushes
62
+ nothing and stays in its folder. Plus secret masking in everything it streams, and its own
63
+ ceiling on how many sessions may run at once.
58
64
  - Streams the session journal with sequence numbers and acknowledgements, so a restart of
59
65
  either side resumes instead of losing work.
60
66
  - Never uploads your source. Diffs and file views are requested per file, and files
@@ -8,7 +8,7 @@ import { mcpConfigPath } from '../paths.js';
8
8
  import { lowerPriority } from '../process-priority.js';
9
9
  import { cageSpawn, noteSessionAgentPid, memoryDeathSentence, releaseSessionScope, sessionMemoryEnv, sessionMemoryPromptLine, } from '../session-cage.js';
10
10
  import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
11
- import { availableModes, cardDescription, DIRECT_BRANCH_RULE, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
11
+ import { availableModes, cardDescription, DIRECT_BRANCH_RULE, folderRuleFor, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
12
12
  import { percentFromUtilization, RATE_WINDOW_MINUTES, rateWindowKey } from './rate-limits.js';
13
13
  import { applyUsagePercentages, lastUsageRows, readUsageRows } from './claude-usage.js';
14
14
  import { assertClaudeInstalled, claudeExecutableOption, sessionClaudePath, } from '../agent-binary.js';
@@ -166,14 +166,19 @@ const MODE_TO_PERMISSION = {
166
166
  * no line: the agent plans around a restriction that is not there, or walks
167
167
  * into one it was told did not exist.
168
168
  */
169
- function systemAppendFor(spec) {
169
+ function systemAppendFor(spec, mode) {
170
170
  const pushBanned = spec.gitPolicy?.agentPushBan !== false;
171
+ // #418: which folder line this session gets, if any — see `folderRuleFor`.
172
+ const folderRule = folderRuleFor(spec, mode);
171
173
  const guarded = spec.gitPolicy?.agentProtectedBranches ?? ['main', 'master'];
172
174
  const memoryLine = sessionMemoryPromptLine(spec.sessionId);
173
175
  return [
174
176
  'You are running inside a DevBridge dev session, controlled from the DevBridge dashboard.',
175
177
  'Rules:',
176
- '- Work ONLY inside the current working directory.',
178
+ // #418. The three cases live in `folderRuleFor` — one copy for both
179
+ // adapters, and the Codex adapter asks the same function whether a mode
180
+ // switch would change what the agent was told.
181
+ ...(folderRule === null ? [] : [folderRule]),
177
182
  // Session 13: pushing was refused outright by layer 1 in every trust mode,
178
183
  // and saying so here saved the agent a turn spent discovering it. Session
179
184
  // 18 makes it the project's decision — so the sentence has to follow the
@@ -203,8 +208,8 @@ function systemAppendFor(spec) {
203
208
  ].join('\n');
204
209
  }
205
210
  /** DevBridge's own rules, then whatever this workspace adds (session 13). */
206
- function composeSystemAppend(spec) {
207
- const base = systemAppendFor(spec);
211
+ function composeSystemAppend(spec, mode) {
212
+ const base = systemAppendFor(spec, mode);
208
213
  return spec.workspaceContext ? `${base}\n\n${spec.workspaceContext}` : base;
209
214
  }
210
215
  /**
@@ -585,7 +590,7 @@ class ClaudeSession {
585
590
  systemPrompt: {
586
591
  type: 'preset',
587
592
  preset: 'claude_code',
588
- append: composeSystemAppend(spec),
593
+ append: composeSystemAppend(spec, this.mode),
589
594
  },
590
595
  canUseTool: (toolName, input, opts) => this.onCanUseTool(toolName, input, opts),
591
596
  // Ticket #113: a running subagent forks its own conversation every ~30s
@@ -6,7 +6,7 @@ import { RUNNER_VERSION } from '../version.js';
6
6
  import { repairCodexAuth } from './codex-home.js';
7
7
  import { AppServerClient, asRecord, num, RpcError, RpcTimeoutError, str, } from './codex-protocol.js';
8
8
  import { truncate } from './claude.js';
9
- import { availableModes, cardDescription, DIRECT_BRANCH_RULE, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
9
+ import { availableModes, cardDescription, DIRECT_BRANCH_RULE, folderRuleFor, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
10
10
  import { clampPercent, rateWindowKeyFromMinutes } from './rate-limits.js';
11
11
  import { answerSummary, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
12
12
  // Codex adapter over `codex app-server` (stage C). The normalized AgentEvent
@@ -60,6 +60,37 @@ const MODE_POLICY = {
60
60
  // exactly like Claude's bypassPermissions. The dashboard says so.
61
61
  full: { approvalPolicy: 'never', sandbox: 'danger-full-access', plan: false },
62
62
  };
63
+ /**
64
+ * Where a `workspace-write` turn may write (#418).
65
+ *
66
+ * Codex confines writes in the KERNEL, not in our policy: under `auto` the
67
+ * sandbox is `workspace-write` with `writableRoots: [cwd]`, so a write outside
68
+ * fails inside the CLI, comes back as a «retry without sandbox?» approval, and
69
+ * only then reaches layer 1. With the project's permission on, that round trip
70
+ * is exactly the refusal the ticket exists to remove — so the ROOT is widened.
71
+ *
72
+ * **The sandbox is widened, never dropped**, and that is the whole safety of
73
+ * this function. `danger-full-access` looks like the simpler answer and is the
74
+ * wrong one: for Codex the kernel sandbox refusing something IS the only
75
+ * channel that reaches `evaluateToolUse` (the adapter calls it from
76
+ * `onApproval` and nowhere else). Remove the sandbox and nothing ever
77
+ * escalates, so `sudo`, docker, the firewall, secret paths, any repository's
78
+ * `.git`, the project's prompt file and the project's own git policy would all
79
+ * stop being enforced for Codex — the very list the setting promises to keep.
80
+ * Keeping `workspaceWrite` keeps that channel, and keeps `networkAccess: false`
81
+ * with it, so the permission moves one boundary and no other.
82
+ *
83
+ * **STRICT does not widen.** There a step outside must still raise a card, and
84
+ * the only way to raise one for Codex is to let the sandbox refuse it first.
85
+ *
86
+ * Read on every turn rather than captured at launch, because the setting can be
87
+ * switched while the session runs.
88
+ */
89
+ function writableRootsFor(spec) {
90
+ if (spec.trustMode === 'STRICT')
91
+ return [spec.cwd];
92
+ return spec.gitPolicy?.agentAllowOutsideFolder === true ? ['/'] : [spec.cwd];
93
+ }
63
94
  /**
64
95
  * DevBridge's own rules — composed per session since session 18, and kept
65
96
  * deliberately in step with `systemAppendFor` in the Claude adapter.
@@ -69,14 +100,19 @@ const MODE_POLICY = {
69
100
  * `workMode: DIRECT` (the default since session 16), and the push sentence is
70
101
  * only true when the project has «Принудительно запретить push» switched on.
71
102
  */
72
- function systemAppendFor(spec) {
103
+ function systemAppendFor(spec, mode) {
73
104
  const pushBanned = spec.gitPolicy?.agentPushBan !== false;
105
+ // #418: which folder line this session gets, if any — see `folderRuleFor`.
106
+ const folderRule = folderRuleFor(spec, mode);
74
107
  const guarded = spec.gitPolicy?.agentProtectedBranches ?? ['main', 'master'];
75
108
  const memoryLine = sessionMemoryPromptLine(spec.sessionId);
76
109
  return [
77
110
  'You are running inside a DevBridge dev session, controlled from the DevBridge dashboard.',
78
111
  'Rules:',
79
- '- Work ONLY inside the current working directory.',
112
+ // #418. The three cases live in `folderRuleFor` — one copy for both
113
+ // adapters, and the Codex adapter asks the same function whether a mode
114
+ // switch would change what the agent was told.
115
+ ...(folderRule === null ? [] : [folderRule]),
80
116
  pushBanned
81
117
  ? '- Commit your work in the current branch with clear messages. You cannot push: `git push` is blocked for this project. A human presses «Push» and «Apply» in DevBridge when the branch is ready.'
82
118
  : guarded.length > 0
@@ -96,8 +132,8 @@ function systemAppendFor(spec) {
96
132
  ].join('\n');
97
133
  }
98
134
  /** DevBridge's own rules, then whatever this workspace adds (session 13). */
99
- function composeSystemAppend(spec) {
100
- const base = systemAppendFor(spec);
135
+ function composeSystemAppend(spec, mode) {
136
+ const base = systemAppendFor(spec, mode);
101
137
  return spec.workspaceContext ? `${base}\n\n${spec.workspaceContext}` : base;
102
138
  }
103
139
  // Allowlist, not denylist: whatever secrets live in the daemon's environment
@@ -534,8 +570,14 @@ class CodexSession {
534
570
  return {
535
571
  cwd: this.spec.cwd,
536
572
  approvalPolicy: policy.approvalPolicy,
573
+ // #418 does NOT change this one, and that is deliberate. The thread-level
574
+ // channel takes the CLI-style MODE string, which has no way to say «this
575
+ // root»; the mode itself is unchanged (`workspace-write` stays
576
+ // `workspace-write`), and the roots travel with every turn below. Both
577
+ // channels therefore still describe the same sandbox, which is what R10
578
+ // of the plan asks for — there is no second shape to keep in step.
537
579
  sandbox: policy.sandbox,
538
- developerInstructions: composeSystemAppend(this.spec),
580
+ developerInstructions: composeSystemAppend(this.spec, this.mode),
539
581
  ...(this.model ? { model: this.model } : {}),
540
582
  ...(this.spec.mcp ? { config: this.mcpOverlay() } : {}),
541
583
  };
@@ -582,7 +624,8 @@ class CodexSession {
582
624
  // [] is accepted, null is rejected outright.
583
625
  input: [{ type: 'text', text, text_elements: [] }],
584
626
  approvalPolicy: policy.approvalPolicy,
585
- sandboxPolicy: sandboxPolicyFor(policy.sandbox, this.spec.cwd),
627
+ // #418: the roots live here and only here — see `writableRootsFor`.
628
+ sandboxPolicy: sandboxPolicyFor(policy.sandbox, this.spec),
586
629
  ...(this.model ? { model: this.model } : {}),
587
630
  // "Override the reasoning effort for this turn and subsequent turns" —
588
631
  // the same sticky-override channel the model uses (there is still no
@@ -793,12 +836,29 @@ class CodexSession {
793
836
  this.refreshCapabilities();
794
837
  }
795
838
  /**
796
- * Codex needs no new process for any mode: `approvalPolicy` and the sandbox
797
- * policy travel with the next `turn/start`, so the change is in force from
798
- * the next turn whatever it is (ticket #156).
839
+ * Codex needs no new process for its POLICY: `approvalPolicy` and the sandbox
840
+ * travel with the next `turn/start`, so the change is in force from the next
841
+ * turn whatever it is (ticket #156).
842
+ *
843
+ * Its RULES are the other half, and they cannot follow (#418). DevBridge's
844
+ * own instructions reach Codex ONLY as the thread's `developerInstructions`,
845
+ * fixed when the thread opens: this protocol version has no `settings/update`,
846
+ * and putting the text on the turn instead is the duplicated-prompt defect of
847
+ * ticket #179. So a line that has to DISAPPEAR in «Unrestricted» — where
848
+ * layer 1 is never consulted and the sentence would be the only thing left
849
+ * enforcing it — can only disappear with a new thread.
850
+ *
851
+ * Asked precisely rather than by hard-coding the `full` boundary: would the
852
+ * folder line actually change? With «may work outside the project folder» ON
853
+ * the answer is never — the sentence is then the same in every mode — so
854
+ * those projects keep Codex's free, instant mode switching. Only a session
855
+ * that would be told something different pays for a new process, and it pays
856
+ * exactly what a Claude session has always paid for the same move.
799
857
  */
800
- modeSwitchNeedsRelaunch(_mode) {
801
- return false;
858
+ modeSwitchNeedsRelaunch(mode) {
859
+ if (!availableModes(this.spec.trustMode).includes(mode))
860
+ return false;
861
+ return folderRuleFor(this.spec, mode) !== folderRuleFor(this.spec, this.mode);
802
862
  }
803
863
  async setMode(mode) {
804
864
  if (!availableModes(this.spec.trustMode).includes(mode)) {
@@ -2255,9 +2315,10 @@ class CodexSession {
2255
2315
  }
2256
2316
  class ResumeFailed extends Error {
2257
2317
  }
2258
- function sandboxPolicyFor(mode, cwd) {
2318
+ function sandboxPolicyFor(mode, spec) {
2259
2319
  // turn/start takes the structured SandboxPolicy, while thread/start takes the
2260
- // CLI-style SandboxMode string. Same intent, two shapes.
2320
+ // CLI-style SandboxMode string. Same intent, two shapes — and only this one
2321
+ // can name the writable roots, which is why #418 lives here.
2261
2322
  switch (mode) {
2262
2323
  case 'read-only':
2263
2324
  return { type: 'readOnly', networkAccess: false };
@@ -2266,7 +2327,11 @@ function sandboxPolicyFor(mode, cwd) {
2266
2327
  default:
2267
2328
  return {
2268
2329
  type: 'workspaceWrite',
2269
- writableRoots: [cwd],
2330
+ writableRoots: writableRootsFor(spec),
2331
+ // Unchanged by #418, on purpose: the permission is about the file
2332
+ // system, and letting the network out with it would be a second
2333
+ // boundary nobody asked to move. A network command still fails in the
2334
+ // sandbox, still escalates, and still ends at layer 1.
2270
2335
  networkAccess: false,
2271
2336
  excludeTmpdirEnvVar: false,
2272
2337
  excludeSlashTmp: false,
@@ -322,6 +322,56 @@ export declare function cardDescription(verdict: PolicyDecision, own: string | u
322
322
  * into a card.
323
323
  */
324
324
  export declare const DIRECT_BRANCH_RULE = "- This session works directly in the project folder, which other sessions and people share. Stay on the current branch: do not `git checkout <branch>` or `git switch` here. To put a file back use `git checkout -- <path>` or `git restore <path>`.";
325
+ /**
326
+ * «Stay in this folder» — the line layer 1 used to back up (#418).
327
+ *
328
+ * Here, beside `DIRECT_BRANCH_RULE` and for the same reason: the two
329
+ * `systemAppendFor` texts are hand-synced copies of each other, and a rule that
330
+ * has to appear in one of them and not the other is the kind of thing that
331
+ * drifts silently. Exported so the tests can name the exact string rather than
332
+ * a substring of it.
333
+ *
334
+ * NOT written when the session is «Unrestricted» — there the CLI never asks
335
+ * layer 1 anything, so this sentence was the only thing left enforcing it, and
336
+ * a rule with nothing underneath is a rule the agent refuses itself by — nor
337
+ * when the project has switched «may work outside the project folder» on, where
338
+ * `outsideFolderRule` takes its place.
339
+ */
340
+ export declare const WORKING_DIRECTORY_RULE = "- Work ONLY inside the current working directory.";
341
+ /**
342
+ * What replaces the line above when the project allows work outside the folder
343
+ * (#418).
344
+ *
345
+ * It says three things and no more: where the agent is, that it may go
346
+ * elsewhere when the task needs it, and what it gives up by doing so — nothing
347
+ * outside the folder reaches «Changes», «Apply» or a restore point. That last
348
+ * half is the part the agent cannot discover for itself and the part a person
349
+ * would otherwise find out from a diff that is missing a file.
350
+ */
351
+ export declare function outsideFolderRule(cwd: string): string;
352
+ /**
353
+ * Which of the two lines above this session gets, or neither (#418).
354
+ *
355
+ * One function rather than the same three-way choice written out in both
356
+ * `systemAppendFor` texts — and it has a second caller that made it worth
357
+ * extracting: the Codex adapter asks it whether a MODE SWITCH would change what
358
+ * the agent was told, because Codex's developer instructions are fixed when the
359
+ * thread opens and cannot be edited afterwards.
360
+ *
361
+ * The three cases, in order:
362
+ * - the project allows work outside the folder → say where the folder is and
363
+ * what leaving it costs, in EVERY mode (the permission does not depend on
364
+ * the mode, so neither does the sentence);
365
+ * - «Unrestricted» → `null`, nothing at all. Layer 1 is never consulted there,
366
+ * so this line would be the only thing enforcing the rule: the agent refuses
367
+ * itself by a sentence nothing backs up, which is the complaint #418 came
368
+ * from;
369
+ * - otherwise → the rule as it has always been, with layer 1 behind it.
370
+ *
371
+ * The mode passed in must be the one the session is actually IN, not the one it
372
+ * was asked for: a `full` refused on a STRICT workspace keeps the line.
373
+ */
374
+ export declare function folderRuleFor(spec: SessionSpec, mode: AgentMode | undefined): string | null;
325
375
  /**
326
376
  * One question inside an agent's question call (session 12).
327
377
  *
@@ -100,6 +100,65 @@ export function cardDescription(verdict, own) {
100
100
  * into a card.
101
101
  */
102
102
  export const DIRECT_BRANCH_RULE = '- This session works directly in the project folder, which other sessions and people share. Stay on the current branch: do not `git checkout <branch>` or `git switch` here. To put a file back use `git checkout -- <path>` or `git restore <path>`.';
103
+ /**
104
+ * «Stay in this folder» — the line layer 1 used to back up (#418).
105
+ *
106
+ * Here, beside `DIRECT_BRANCH_RULE` and for the same reason: the two
107
+ * `systemAppendFor` texts are hand-synced copies of each other, and a rule that
108
+ * has to appear in one of them and not the other is the kind of thing that
109
+ * drifts silently. Exported so the tests can name the exact string rather than
110
+ * a substring of it.
111
+ *
112
+ * NOT written when the session is «Unrestricted» — there the CLI never asks
113
+ * layer 1 anything, so this sentence was the only thing left enforcing it, and
114
+ * a rule with nothing underneath is a rule the agent refuses itself by — nor
115
+ * when the project has switched «may work outside the project folder» on, where
116
+ * `outsideFolderRule` takes its place.
117
+ */
118
+ export const WORKING_DIRECTORY_RULE = '- Work ONLY inside the current working directory.';
119
+ /**
120
+ * What replaces the line above when the project allows work outside the folder
121
+ * (#418).
122
+ *
123
+ * It says three things and no more: where the agent is, that it may go
124
+ * elsewhere when the task needs it, and what it gives up by doing so — nothing
125
+ * outside the folder reaches «Changes», «Apply» or a restore point. That last
126
+ * half is the part the agent cannot discover for itself and the part a person
127
+ * would otherwise find out from a diff that is missing a file.
128
+ */
129
+ export function outsideFolderRule(cwd) {
130
+ return (`- Your working directory is ${cwd}; you may read and change files elsewhere on this server ` +
131
+ `when the task needs it. Only what is inside ${cwd} shows up in the DevBridge diff and restore points.`);
132
+ }
133
+ /**
134
+ * Which of the two lines above this session gets, or neither (#418).
135
+ *
136
+ * One function rather than the same three-way choice written out in both
137
+ * `systemAppendFor` texts — and it has a second caller that made it worth
138
+ * extracting: the Codex adapter asks it whether a MODE SWITCH would change what
139
+ * the agent was told, because Codex's developer instructions are fixed when the
140
+ * thread opens and cannot be edited afterwards.
141
+ *
142
+ * The three cases, in order:
143
+ * - the project allows work outside the folder → say where the folder is and
144
+ * what leaving it costs, in EVERY mode (the permission does not depend on
145
+ * the mode, so neither does the sentence);
146
+ * - «Unrestricted» → `null`, nothing at all. Layer 1 is never consulted there,
147
+ * so this line would be the only thing enforcing the rule: the agent refuses
148
+ * itself by a sentence nothing backs up, which is the complaint #418 came
149
+ * from;
150
+ * - otherwise → the rule as it has always been, with layer 1 behind it.
151
+ *
152
+ * The mode passed in must be the one the session is actually IN, not the one it
153
+ * was asked for: a `full` refused on a STRICT workspace keeps the line.
154
+ */
155
+ export function folderRuleFor(spec, mode) {
156
+ // `=== true` — silence keeps the agent in, exactly as `policy.ts` resolves the
157
+ // same field.
158
+ if (spec.gitPolicy?.agentAllowOutsideFolder === true)
159
+ return outsideFolderRule(spec.cwd);
160
+ return mode === 'full' ? null : WORKING_DIRECTORY_RULE;
161
+ }
103
162
  /**
104
163
  * The name our MCP server is registered under inside an agent session.
105
164
  *
@@ -18,6 +18,17 @@ export interface CheckpointRecord {
18
18
  kind: CheckpointKind;
19
19
  /** HEAD of the worktree when the checkpoint was taken. */
20
20
  headSha: string;
21
+ /**
22
+ * #137: there was no commit at all when this point was taken — an empty
23
+ * `headSha` that MEANS something.
24
+ *
25
+ * Written down rather than inferred, because the empty string is already the
26
+ * value `decodeMeta` invents for a record whose metadata it could not read.
27
+ * Those two states need opposite treatment: «nothing was committed yet» can
28
+ * say truthfully what arrived since (all of it), while «we do not know what
29
+ * HEAD was» must not claim the whole history arrived after the point.
30
+ */
31
+ unborn?: boolean;
21
32
  /** Paths that were staged in the project's index at that moment. */
22
33
  stagedPaths: string[];
23
34
  createdAt: number;
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { promisify } from 'node:util';
5
5
  import { createHash } from 'node:crypto';
6
6
  import { checkpointsDir } from './paths.js';
7
+ import { EMPTY_TREE_SHA } from './gitops.js';
7
8
  import { isSecretPath } from './policy.js';
8
9
  import { log } from './log.js';
9
10
  const execFileAsync = promisify(execFile);
@@ -410,8 +411,30 @@ async function runBatched(store, worktreePath, indexFile, args, paths) {
410
411
  async function buildIndex(store, worktreePath, indexFile) {
411
412
  fs.mkdirSync(path.dirname(indexFile), { recursive: true, mode: 0o700 });
412
413
  fs.rmSync(indexFile, { force: true });
413
- const headSha = await gitIn(worktreePath, 'rev-parse', 'HEAD');
414
- await gitStore(store, worktreePath, indexFile, 'read-tree', headSha);
414
+ // #137: a repository with no commits has no HEAD to build the index from,
415
+ // and the bare `rev-parse HEAD` threw git's manual page — which the refusal
416
+ // classifier then read as «this folder is not a git repository», so every
417
+ // single step of every session in a fresh `git init` folder wrote that
418
+ // sentence into the feed. It is the wrong sentence twice over: the folder IS
419
+ // a repository, and a restore point in it is perfectly possible.
420
+ //
421
+ // The empty tree is what git itself compares a first commit against, so it is
422
+ // the honest starting index — and `gitStatus` in DIRECT mode has been using
423
+ // exactly this constant for the same reason since session 16.
424
+ //
425
+ // The exit code is READ, not caught away — the same rule `headState` follows
426
+ // in `git.ts`, and for a bigger reason here. A bare `.catch` would read a
427
+ // timeout, an OOM kill or a lost `safe.directory` as «no commits yet» on a
428
+ // repository that has plenty: the point would then be built from the empty
429
+ // tree, look perfectly normal in the feed, and a rewind to it would offer to
430
+ // DELETE every file the checkpoint did not happen to cover. «git could not
431
+ // look» must abort the point, exactly as it did before #137.
432
+ const headSha = await gitIn(worktreePath, 'rev-parse', '--verify', '--quiet', 'HEAD').then((sha) => sha, (error) => {
433
+ if (error.code === 1)
434
+ return '';
435
+ throw error;
436
+ });
437
+ await gitStore(store, worktreePath, indexFile, 'read-tree', headSha || EMPTY_TREE_SHA);
415
438
  const { paths, secrets } = await changedPaths(worktreePath);
416
439
  const excluded = new Set(secrets);
417
440
  const included = [];
@@ -496,6 +519,7 @@ function decodeMeta(message) {
496
519
  return {
497
520
  kind: kind === 'SAFETY' || kind === 'MANUAL' ? kind : 'TURN',
498
521
  headSha: typeof parsed['headSha'] === 'string' ? parsed['headSha'] : '',
522
+ ...(parsed['unborn'] === true ? { unborn: true } : {}),
499
523
  stagedPaths: Array.isArray(parsed['stagedPaths'])
500
524
  ? parsed['stagedPaths'].filter((p) => typeof p === 'string')
501
525
  : [],
@@ -549,7 +573,13 @@ export async function createCheckpoint(input) {
549
573
  /** An error on the way to a restore point, read as a reason to report. */
550
574
  function checkpointRefusal(sessionId, error) {
551
575
  const detail = String(error instanceof Error ? error.message : error).slice(0, 300);
552
- if (/not a git repository|ambiguous argument 'HEAD'|unknown revision/i.test(detail)) {
576
+ // #137: «ambiguous argument 'HEAD'» is git's answer to a repository with no
577
+ // commits in it, and reading it as «not a git repository» produced the one
578
+ // sentence in the feed that was flatly untrue — on a folder the person had
579
+ // just created and bound. An unborn HEAD does not reach here at all any more
580
+ // (`buildIndex` starts from the empty tree), and if some other unborn-HEAD
581
+ // read ever does, «git refused» is the honest bucket for it, not «not a repo».
582
+ if (/not a git repository/i.test(detail)) {
553
583
  return { created: false, reason: 'not-a-repo', detail };
554
584
  }
555
585
  log.warn('checkpoints: could not create a restore point', { sessionId, error: detail });
@@ -573,6 +603,10 @@ async function takeCheckpoint(store, input) {
573
603
  const meta = {
574
604
  kind,
575
605
  headSha,
606
+ // Only when it is true: an absent key is what every record written before
607
+ // this release carries, and those were all taken on a repository with a
608
+ // HEAD.
609
+ ...(headSha ? {} : { unborn: true }),
576
610
  stagedPaths,
577
611
  createdAt: Date.now(),
578
612
  fileCount: included.length,
@@ -721,10 +755,35 @@ async function buildPreview(store, indexFile, input) {
721
755
  const total = restore.length + remove.length + recreate.length;
722
756
  let blockedReason = total > MAX_PREVIEW_ENTRIES ? 'too-many-changes' : undefined;
723
757
  const commitsSince = [];
724
- if (!blockedReason && record.headSha && record.headSha !== headSha) {
758
+ // #137: an empty string is «there was no commit at all when this point was
759
+ // taken» — a real state now that a repository with no commits can hold a
760
+ // session. It must still count as movement: the first commit arriving between
761
+ // the point and the rewind moves HEAD exactly as any later one does, and the
762
+ // old `record.headSha &&` guard read that case as «HEAD has not moved» and
763
+ // let the rewind run without a word.
764
+ if (!blockedReason && record.headSha !== headSha) {
725
765
  blockedReason = 'head-moved';
726
766
  try {
727
- const listed = await gitIn(worktreePath, 'log', '--format=%h%x00%s', '--max-count=20', `${record.headSha}..${headSha}`);
767
+ // A range needs two ends. With no commit at all behind the point, «what
768
+ // arrived since» is the whole history that exists — the first commit and
769
+ // whatever followed it — so the range collapses to one end. An empty
770
+ // left side would NOT do that: `..<sha>` means `HEAD..<sha>` to git,
771
+ // which is a different question and usually an empty answer.
772
+ //
773
+ // The one-ended form is used ONLY for a point that recorded «there was
774
+ // nothing here yet». A record whose metadata could not be read carries
775
+ // the same empty `headSha` and means something else entirely — listing
776
+ // the repository's whole history under «what arrived since this point»
777
+ // would be a confident, wrong answer. That record still refuses the
778
+ // rewind, which is the safe direction; it simply names no commits.
779
+ const range = record.headSha
780
+ ? `${record.headSha}..${headSha}`
781
+ : record.unborn
782
+ ? headSha
783
+ : null;
784
+ const listed = headSha && range
785
+ ? await gitIn(worktreePath, 'log', '--format=%h%x00%s', '--max-count=20', range)
786
+ : '';
728
787
  for (const line of listed.split('\n')) {
729
788
  if (!line.trim())
730
789
  continue;
package/dist/git.d.ts CHANGED
@@ -30,11 +30,67 @@ export declare class WorktreePrepareError extends Error {
30
30
  baseSha?: string;
31
31
  });
32
32
  }
33
+ /**
34
+ * A repository with no commits cannot hand out a copy of itself (#137).
35
+ *
36
+ * The mirror of `NO_COMMITS_YET_MESSAGE` in `@devbridge/shared`, which this
37
+ * package cannot import — it is published to npm on its own, and the import
38
+ * would make the tarball unresolvable (`recipe-schema.ts` explains it in full).
39
+ * Same arrangement as the level-event constants and the session limits: the
40
+ * text lives twice and a test pins the two copies together, because the API
41
+ * refuses this case before a session exists and the runner refuses it again if
42
+ * one ever gets that far — and the person must read one sentence, not two.
43
+ *
44
+ * `git.test.ts` compares this against the shared copy byte for byte.
45
+ */
46
+ export declare const NO_COMMITS_YET_MESSAGE = "This repository has no commits yet \u2014 start a session in the project folder itself; a branch of its own becomes possible after the first commit";
47
+ /**
48
+ * Where HEAD points — with the three states told apart properly (#137).
49
+ *
50
+ * `rev-parse --abbrev-ref HEAD` cannot answer this and never could. On a
51
+ * repository straight out of `git init` it exits 128 with git's own manual
52
+ * page («ambiguous argument 'HEAD'»), and it ALSO prints the literal word
53
+ * `HEAD` — the same word it prints on a detached HEAD. So the one call that
54
+ * everything here used to be built on both throws where it should not and,
55
+ * caught, answers «detached» for a folder that is plainly on a branch. That is
56
+ * the whole of #137: a directory with no commits in it could not be bound, its
57
+ * first session refused to start, and its restore points reported «this is not
58
+ * a git repository».
59
+ *
60
+ * Two calls answer it honestly, and both are cheap:
61
+ *
62
+ * `symbolic-ref --short HEAD` — the branch NAME, whether or not it has a
63
+ * commit; fails only on a detached HEAD.
64
+ * `rev-parse --verify --quiet HEAD` — is there a commit at all.
65
+ *
66
+ * branch + commit → an ordinary checkout
67
+ * branch, no commit → unborn: a legitimate, fully workable state. The person
68
+ * ran `git init` and has not committed yet; nobody makes
69
+ * that first commit for them (#137, plan S3a).
70
+ * no branch, commit → detached: the refusal that was always meant here.
71
+ * neither → not a repository. Every caller establishes that
72
+ * separately, so this simply reports nothing.
73
+ */
74
+ export interface HeadState {
75
+ /** The branch HEAD is on, including one that has no commit yet. */
76
+ branch: string | null;
77
+ /** On a branch that has no commit yet — a fresh `git init`. */
78
+ unborn: boolean;
79
+ /** On a commit rather than on a branch. */
80
+ detached: boolean;
81
+ }
82
+ export declare function headState(workspacePath: string): Promise<HeadState>;
33
83
  export interface PathValidation {
34
84
  ok: boolean;
35
85
  exists: boolean;
36
86
  isGitRepo: boolean;
37
87
  branch?: string;
88
+ /**
89
+ * #137: the branch has no commit on it yet. `branch` above is still its name
90
+ * and the directory still binds — this says «do not expect a HEAD here», and
91
+ * it is what lets every reader downstream stop guessing «detached».
92
+ */
93
+ unborn?: boolean;
38
94
  /** The repository's own main branch, as this machine can see it without the network. */
39
95
  defaultBranch?: string;
40
96
  error?: string;