@bridge4dev/runner 0.30.0 → 0.33.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/dist/protocol.js CHANGED
@@ -52,9 +52,52 @@ export const SessionDescriptorSchema = z.object({
52
52
  * every project the moment a runner updated ahead of its API.
53
53
  */
54
54
  agentAutoCommit: z.boolean().optional(),
55
+ /**
56
+ * The project's own prompt file, relative to `path` — its contents reach
57
+ * the agent as SYSTEM-prompt text (see `agent-prompt.ts` for why the level
58
+ * matters).
59
+ *
60
+ * `.catch(undefined)` for the same reason as `branchHint` and `branchPlan`:
61
+ * this field travels inside `hello_ack`, which carries EVERY session of the
62
+ * server, so a malformed value must cost its own session's prompt at most —
63
+ * never the frame (QA-100 MAJOR-1). The runner re-validates the path from
64
+ * scratch anyway; the bounds here only keep nonsense off the wire.
65
+ */
66
+ agentPromptPath: z.string().max(300).nullable().optional().catch(undefined),
67
+ /**
68
+ * Session 18: the project's git policy for agents.
69
+ *
70
+ * `.optional().catch(undefined)` for the QA-100 MAJOR-1 reason above — this
71
+ * rides inside `hello_ack` with every session on the server, so a malformed
72
+ * value must cost its own session's policy at most, never the frame.
73
+ *
74
+ * «Costs its own session's policy» is safe here BECAUSE of the polarity in
75
+ * `policy.ts`: `agentPushBan` resolves `undefined` to «refuse», so a value
76
+ * this schema drops leaves the session strictly more restricted, never less.
77
+ * That property is the whole reason `.catch(undefined)` is acceptable on a
78
+ * security field, and it is why the resolution lives in exactly one
79
+ * function rather than at each point of use.
80
+ */
81
+ agentPushBan: z.boolean().optional().catch(undefined),
82
+ agentProtectedBranches: z
83
+ .array(z
84
+ .string()
85
+ .max(200)
86
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._\-/]*$/))
87
+ .max(20)
88
+ .optional()
89
+ .catch(undefined),
90
+ agentAllowForcePush: z.boolean().optional().catch(undefined),
91
+ agentAllowDestructiveGit: z.boolean().optional().catch(undefined),
55
92
  budgetUsd: z.number().nullable(),
56
93
  budgetMinutes: z.number().nullable(),
57
94
  }),
95
+ /**
96
+ * «Run this one without the project's agent prompt.» Absent means no — both
97
+ * because an older API never sends it and because that is the default a human
98
+ * gets when they do not touch the checkbox.
99
+ */
100
+ skipAgentPrompt: z.boolean().optional().catch(undefined),
58
101
  tickets: z.array(z.object({ id: z.string().uuid(), number: z.number(), title: z.string() })),
59
102
  // Branch the API would like this session to use (derived from its ticket
60
103
  // group). Optional: an older API omits it and the runner keeps its
@@ -212,6 +255,19 @@ export const GatewayFrameSchema = z.discriminatedUnion('type', [
212
255
  workspaceId: z.string().min(1).max(64),
213
256
  trustMode: z.enum(['STRICT', 'NORMAL', 'AUTO']).optional(),
214
257
  agentAutoCommit: z.boolean().optional(),
258
+ // Session 18 — the git policy, for the same reason: read on every tool
259
+ // call, so switching «Принудительно запретить push» back on must not
260
+ // require stopping the agent first.
261
+ agentPushBan: z.boolean().optional(),
262
+ agentProtectedBranches: z
263
+ .array(z
264
+ .string()
265
+ .max(200)
266
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._\-/]*$/))
267
+ .max(20)
268
+ .optional(),
269
+ agentAllowForcePush: z.boolean().optional(),
270
+ agentAllowDestructiveGit: z.boolean().optional(),
215
271
  }),
216
272
  z.object({
217
273
  type: z.literal('session_settings'),
@@ -118,6 +118,26 @@ export declare class Supervisor {
118
118
  * stay queued rather than be marked delivered (session 9).
119
119
  */
120
120
  private launchAgent;
121
+ /**
122
+ * The project's own prompt file, read fresh for THIS agent process.
123
+ *
124
+ * Read per launch rather than per session on purpose: a system prompt only
125
+ * ever changes when the process restarts, so «edit the file, then press
126
+ * Continue» is the honest contract, and re-reading is what makes it true.
127
+ *
128
+ * Every outcome is said out loud. The whole point of moving the project's
129
+ * rules into the system prompt is that they can no longer be quietly
130
+ * outranked — so «the prompt did not load» must never be indistinguishable
131
+ * from «the prompt loaded». Supervisor notices repeat on every launch (only
132
+ * ADAPTER notices are de-duplicated — gotcha #148), which is exactly what is
133
+ * wanted here: each agent process either has the prompt or does not.
134
+ *
135
+ * Returns the text AND the absolute file, because layer 1 needs the second to
136
+ * refuse writes to it: in `workMode: DIRECT` the project folder is the
137
+ * agent's own working directory, so without that rule a session could rewrite
138
+ * the prompt it will itself be started with next time (QA-130 MAJOR-3).
139
+ */
140
+ private resolveAgentPrompt;
121
141
  /** Warn the user when this share of the budget is gone. */
122
142
  private static readonly BUDGET_WARN_RATIO;
123
143
  /** Is the agent actually working right now (i.e. should the clock run)? */
@@ -408,6 +428,14 @@ export declare function composeInitialPrompt(descriptor: SessionDescriptor): str
408
428
  * A repository that wants both agents equipped ships both files, or symlinks
409
429
  * one to the other. That is a repository convention and not something a runner
410
430
  * should paper over.
431
+ *
432
+ * `agentPrompt` is the one exception, and it is an exception for a reason this
433
+ * function cannot do anything about: `CLAUDE.md` and `AGENTS.md` arrive at the
434
+ * USER level, and a project's standing process rules have to sit at the system
435
+ * level to survive both a compaction and a rule the CLI states about itself
436
+ * (`agent-prompt.ts`). It arrives here already read and already checked — this
437
+ * function still opens no files — and it goes LAST, so a project overrides us
438
+ * exactly the way `CLAUDE.md` does by being read last.
411
439
  */
412
- export declare function composeWorkspaceContext(descriptor: SessionDescriptor): string;
440
+ export declare function composeWorkspaceContext(descriptor: SessionDescriptor, agentPrompt?: string): string;
413
441
  //# sourceMappingURL=supervisor.d.ts.map
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { log } from './log.js';
4
4
  import { claimAutoResume, clearAutoResume, pruneAutoResume } from './auto-resume.js';
5
5
  import { evaluateRecipeCommand, maskSecrets, maskString } from './policy.js';
6
+ import { agentPromptSizeLabel, quotePath, readAgentPrompt } from './agent-prompt.js';
6
7
  import { JournalStore } from './journal.js';
7
8
  import { deleteSessionBranch, ensurePreviewWorktree, ensureSessionWorktree, prepareDirectWorkspace, previewWorktreePath, removePreviewWorktree, removeSessionWorktree, repoKeyFor, sessionWorktreePath, validateWorkspacePath, } from './git.js';
8
9
  import { readRecipeProposal } from './recipe.js';
@@ -21,6 +22,28 @@ import { availableModes, MODE_REFUSED_TEXT } from './adapters/types.js';
21
22
  /** Refusals shared by every checkpoint command (ticket #126). */
22
23
  const CHECKPOINTS_OFF = 'Restore points are switched off on this server ([checkpoints] enabled = false)';
23
24
  const AGENT_BUSY = 'The agent is still working — stop the turn first';
25
+ /**
26
+ * The project's git policy, lifted out of a session descriptor (session 18).
27
+ *
28
+ * One function so that «which descriptor fields are the git policy» is answered
29
+ * once. Absent fields are passed through as absent — `resolveGitPolicy` in
30
+ * `policy.ts` is the single place that decides what absent MEANS, and inventing
31
+ * a default here would put a second such place in the codebase, facing the
32
+ * other way.
33
+ */
34
+ function gitPolicyOf(descriptor) {
35
+ const w = descriptor.workspace;
36
+ return {
37
+ ...(w.agentPushBan !== undefined ? { agentPushBan: w.agentPushBan } : {}),
38
+ ...(w.agentProtectedBranches !== undefined
39
+ ? { agentProtectedBranches: w.agentProtectedBranches }
40
+ : {}),
41
+ ...(w.agentAllowForcePush !== undefined ? { agentAllowForcePush: w.agentAllowForcePush } : {}),
42
+ ...(w.agentAllowDestructiveGit !== undefined
43
+ ? { agentAllowDestructiveGit: w.agentAllowDestructiveGit }
44
+ : {}),
45
+ };
46
+ }
24
47
  export class Supervisor {
25
48
  ws;
26
49
  opts;
@@ -130,11 +153,43 @@ export class Supervisor {
130
153
  if (frame.agentAutoCommit !== undefined) {
131
154
  running.descriptor.workspace.agentAutoCommit = frame.agentAutoCommit;
132
155
  }
156
+ /**
157
+ * Session 18: the git policy, applied live for the same reason as the
158
+ * two above — it is read on every tool call, so «switch push back
159
+ * off» must not mean «stop the agent first».
160
+ *
161
+ * The four fields move together, and the API sends all four in every
162
+ * frame that touches any of them, so a partial update cannot leave
163
+ * the runner with «push is allowed now» beside a protected list from
164
+ * before the change. `!== undefined` on the FRAME is what tells a
165
+ * policy change apart from a trust-only frame; inside the object,
166
+ * `undefined` keeps its restrictive meaning as everywhere else.
167
+ */
168
+ const policyChanged = frame.agentPushBan !== undefined ||
169
+ frame.agentProtectedBranches !== undefined ||
170
+ frame.agentAllowForcePush !== undefined ||
171
+ frame.agentAllowDestructiveGit !== undefined;
172
+ if (policyChanged) {
173
+ const next = {
174
+ ...(frame.agentPushBan !== undefined ? { agentPushBan: frame.agentPushBan } : {}),
175
+ ...(frame.agentProtectedBranches !== undefined
176
+ ? { agentProtectedBranches: frame.agentProtectedBranches }
177
+ : {}),
178
+ ...(frame.agentAllowForcePush !== undefined
179
+ ? { agentAllowForcePush: frame.agentAllowForcePush }
180
+ : {}),
181
+ ...(frame.agentAllowDestructiveGit !== undefined
182
+ ? { agentAllowDestructiveGit: frame.agentAllowDestructiveGit }
183
+ : {}),
184
+ };
185
+ Object.assign(running.descriptor.workspace, next);
186
+ }
133
187
  running.session?.setWorkspacePolicy({
134
188
  ...(frame.trustMode !== undefined ? { trustMode: frame.trustMode } : {}),
135
189
  ...(frame.agentAutoCommit !== undefined
136
190
  ? { agentAutoCommit: frame.agentAutoCommit }
137
191
  : {}),
192
+ ...(policyChanged ? { gitPolicy: gitPolicyOf(running.descriptor) } : {}),
138
193
  });
139
194
  }
140
195
  break;
@@ -380,9 +435,11 @@ export class Supervisor {
380
435
  return false;
381
436
  }
382
437
  running.lastPrompt = prompt;
383
- // Facts about this session only. The project's own documentation is read by
384
- // each agent itself — see `composeWorkspaceContext`.
385
- const workspaceContext = composeWorkspaceContext(descriptor);
438
+ // Facts about this session only, plus the one file the project named. The
439
+ // rest of the project's documentation is read by each agent itself — see
440
+ // `composeWorkspaceContext`.
441
+ const agentPrompt = this.resolveAgentPrompt(running);
442
+ const workspaceContext = composeWorkspaceContext(descriptor, agentPrompt?.text);
386
443
  const rewind = running.rewindAnchor;
387
444
  delete running.rewindAnchor;
388
445
  // A rewind resumes the conversation the POINT names, which is not always
@@ -396,10 +453,18 @@ export class Supervisor {
396
453
  cwd: running.worktreePath,
397
454
  ...(prompt ? { prompt } : {}),
398
455
  ...(workspaceContext ? { workspaceContext } : {}),
456
+ // Only when it was actually read: layer 1 must refuse writes to the file
457
+ // this process was given, not to a path it was merely told about.
458
+ ...(agentPrompt ? { agentPromptFile: agentPrompt.absPath } : {}),
399
459
  trustMode: descriptor.workspace.trustMode,
400
460
  ...(descriptor.workspace.agentAutoCommit === undefined
401
461
  ? {}
402
462
  : { agentAutoCommit: descriptor.workspace.agentAutoCommit }),
463
+ // Session 18. Always present, even when every field inside it is absent:
464
+ // an absent OBJECT and an object of absent fields resolve identically
465
+ // (`resolveGitPolicy` gives both the restrictive reading), and passing it
466
+ // unconditionally keeps one code path instead of two.
467
+ gitPolicy: gitPolicyOf(descriptor),
403
468
  mode: running.mode,
404
469
  ...(running.model ? { model: running.model } : {}),
405
470
  ...(running.effort ? { effort: running.effort } : {}),
@@ -426,6 +491,70 @@ export class Supervisor {
426
491
  void this.pumpEvents(running);
427
492
  return true;
428
493
  }
494
+ /**
495
+ * The project's own prompt file, read fresh for THIS agent process.
496
+ *
497
+ * Read per launch rather than per session on purpose: a system prompt only
498
+ * ever changes when the process restarts, so «edit the file, then press
499
+ * Continue» is the honest contract, and re-reading is what makes it true.
500
+ *
501
+ * Every outcome is said out loud. The whole point of moving the project's
502
+ * rules into the system prompt is that they can no longer be quietly
503
+ * outranked — so «the prompt did not load» must never be indistinguishable
504
+ * from «the prompt loaded». Supervisor notices repeat on every launch (only
505
+ * ADAPTER notices are de-duplicated — gotcha #148), which is exactly what is
506
+ * wanted here: each agent process either has the prompt or does not.
507
+ *
508
+ * Returns the text AND the absolute file, because layer 1 needs the second to
509
+ * refuse writes to it: in `workMode: DIRECT` the project folder is the
510
+ * agent's own working directory, so without that rule a session could rewrite
511
+ * the prompt it will itself be started with next time (QA-130 MAJOR-3).
512
+ */
513
+ resolveAgentPrompt(running) {
514
+ const { descriptor } = running;
515
+ const configured = descriptor.workspace.agentPromptPath?.trim();
516
+ if (!configured)
517
+ return undefined;
518
+ // The session was started with «without the project's agent prompt». Said
519
+ // out loud too: a session behaving unlike every other session on the
520
+ // project should carry the reason in its own feed.
521
+ if (descriptor.skipAgentPrompt) {
522
+ this.sendEvent(running, 'notice', {
523
+ level: 'info',
524
+ text: 'Project prompt is switched off for this session. DevBridge rules still apply.',
525
+ });
526
+ return undefined;
527
+ }
528
+ const result = readAgentPrompt(descriptor.workspace.path, configured);
529
+ if (!result.ok) {
530
+ log.warn('agent prompt not loaded', {
531
+ sessionId: descriptor.id,
532
+ path: configured,
533
+ reason: result.reason,
534
+ });
535
+ this.sendEvent(running, 'notice', {
536
+ level: 'warn',
537
+ text: `Project prompt ${quotePath(configured)} was NOT loaded: ${result.reason}. The agent is running without it.`,
538
+ });
539
+ return undefined;
540
+ }
541
+ log.info('agent prompt loaded', {
542
+ sessionId: descriptor.id,
543
+ path: result.relPath,
544
+ bytes: result.bytes,
545
+ sha: result.sha,
546
+ });
547
+ // The `sha` is in the line a person reads, not only in journald. It is the
548
+ // one signal that the file behind an unchanged path has changed — which is
549
+ // exactly what an agent editing its own rules in DIRECT mode looks like
550
+ // (QA-130 MAJOR-3), and layer 1 cannot promise to prevent that in every
551
+ // mode.
552
+ this.sendEvent(running, 'notice', {
553
+ level: 'info',
554
+ text: `Project prompt loaded from ${quotePath(result.relPath)} — ${agentPromptSizeLabel(result.bytes)}, sha ${result.sha}.`,
555
+ });
556
+ return { text: result.text, absPath: result.absPath };
557
+ }
429
558
  // ─── Time budget (session 7) ───────────────────────────────────────
430
559
  //
431
560
  // The budget measures the AGENT's working time, not the calendar. Everything
@@ -3276,20 +3405,70 @@ export function composeInitialPrompt(descriptor) {
3276
3405
  * A repository that wants both agents equipped ships both files, or symlinks
3277
3406
  * one to the other. That is a repository convention and not something a runner
3278
3407
  * should paper over.
3408
+ *
3409
+ * `agentPrompt` is the one exception, and it is an exception for a reason this
3410
+ * function cannot do anything about: `CLAUDE.md` and `AGENTS.md` arrive at the
3411
+ * USER level, and a project's standing process rules have to sit at the system
3412
+ * level to survive both a compaction and a rule the CLI states about itself
3413
+ * (`agent-prompt.ts`). It arrives here already read and already checked — this
3414
+ * function still opens no files — and it goes LAST, so a project overrides us
3415
+ * exactly the way `CLAUDE.md` does by being read last.
3279
3416
  */
3280
- export function composeWorkspaceContext(descriptor) {
3417
+ export function composeWorkspaceContext(descriptor, agentPrompt) {
3281
3418
  const sections = [];
3282
3419
  const plan = descriptor.branchPlan;
3420
+ /**
3421
+ * What this session may do about push, in one sentence (session 18).
3422
+ *
3423
+ * `!== false` — silence refuses, exactly as `resolveGitPolicy` reads it. This
3424
+ * text and the rule in `policy.ts` have to agree; an agent told it may push
3425
+ * and then refused has been lied to, and an agent told it may not while the
3426
+ * project allows it wastes the freedom the owner paid for.
3427
+ */
3428
+ const pushLine = (() => {
3429
+ // `?.` and not `.`: this function is exported and called with descriptors
3430
+ // assembled by hand in tests and by older code paths. A missing `workspace`
3431
+ // must land on the refusing branch like every other unknown here, not throw
3432
+ // and take the whole session start with it.
3433
+ if (descriptor.workspace?.agentPushBan !== false) {
3434
+ return '- You cannot push: `git push` is refused for this project. A human presses «Push» in DevBridge when they want the branch on the remote.';
3435
+ }
3436
+ const guarded = descriptor.workspace?.agentProtectedBranches ?? ['main', 'master'];
3437
+ return guarded.length > 0
3438
+ ? `- You may push, except to these protected branches: ${guarded.join(', ')}. Name the branch explicitly — \`git push <remote> <branch>\`.`
3439
+ : '- You may push.';
3440
+ })();
3283
3441
  if (plan) {
3284
3442
  const forked = plan.baseBranch ? ` It was branched from \`${plan.baseBranch}\`.` : '';
3285
3443
  sections.push([
3286
3444
  'Git in this session:',
3287
3445
  `- You are in a dedicated worktree on branch \`${plan.branch}\`.${forked}`,
3288
3446
  '- Commit to this branch. Do not switch branches and do not merge into the base branch yourself — a human presses «Apply» in DevBridge, which squash-merges your branch for them.',
3289
- '- You cannot push: `git push` is refused. A human presses «Push» when they want the branch on the remote.',
3447
+ pushLine,
3290
3448
  `- Put a plan or working notes in \`docs/devbridge/${plan.branch.replace(/\//g, '-')}.md\`, unless this repository already has its own convention for where such documents live — if it does, follow that.`,
3291
3449
  ].join('\n'));
3292
3450
  }
3451
+ else {
3452
+ /**
3453
+ * A DIRECT session got NO «Git in this session» block at all, and that is
3454
+ * the older half of this bug (session 16 shipped DIRECT as the default;
3455
+ * this block stayed gated on `branchPlan`, which only a BRANCH session
3456
+ * has). So the arrangement that writes into the person's OWN project
3457
+ * folder was the one told nothing about where it was — while the adapter's
3458
+ * static line went on claiming «a dedicated git worktree on a session
3459
+ * branch», which in DIRECT is simply false.
3460
+ *
3461
+ * Being in somebody's working copy is the case that needs saying MORE, not
3462
+ * less: there is no «Apply» to undo it, and uncommitted changes in that
3463
+ * folder may not be the agent's.
3464
+ */
3465
+ sections.push([
3466
+ 'Git in this session:',
3467
+ '- You are working in the PROJECT FOLDER ITSELF, on the branch it is already checked out on — not in a worktree of your own. There is no session branch and nothing to «apply» afterwards: your commits are immediately on the branch the person works on.',
3468
+ '- Uncommitted changes you find here may be a human’s, or another session’s. Never discard or reset what you did not write.',
3469
+ pushLine,
3470
+ ].join('\n'));
3471
+ }
3293
3472
  // The ticket convention lives HERE, not in the first chat message (session
3294
3473
  // 16). It is a standing rule of the project — true on the twentieth turn as
3295
3474
  // much as the first — and putting it in the visible prompt only trained the
@@ -3305,6 +3484,8 @@ export function composeWorkspaceContext(descriptor) {
3305
3484
  else if (descriptor.tickets.length > 0) {
3306
3485
  sections.push('DevBridge tickets are attached to this chat for CONTEXT only. Read them with the DevBridge MCP tools; do not change their status — this session is not assigned to them.');
3307
3486
  }
3487
+ if (agentPrompt)
3488
+ sections.push(agentPrompt);
3308
3489
  return sections.join('\n\n');
3309
3490
  }
3310
3491
  //# sourceMappingURL=supervisor.js.map
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.30.0";
1
+ export declare const RUNNER_VERSION = "0.33.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in sync with package.json by the release script (manual for now).
2
- export const RUNNER_VERSION = '0.30.0';
2
+ export const RUNNER_VERSION = '0.33.0';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.30.0",
3
+ "version": "0.33.0",
4
4
  "description": "DevBridge dev runner — connects a dev server to DevBridge and runs agent sessions (Claude Code / Codex)",
5
5
  "homepage": "https://bridge4.dev",
6
6
  "license": "MIT",