aibroker 0.38.0 → 0.40.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.
@@ -95,6 +95,42 @@ export interface ManagedSession {
95
95
  handsUntil?: number;
96
96
  /** Which state the timer was set in, so reverting means the opposite of it. */
97
97
  handsWas?: boolean;
98
+ /**
99
+ * The checkout this session's state belongs to, decided once.
100
+ *
101
+ * It used to be resolved fresh on every write, from whatever the pane's
102
+ * process happened to be reading. That is right for a session that moves and
103
+ * catastrophic for several sessions at once: a mis-resolution then writes one
104
+ * worker's state over another's, silently, in a checkout neither of them is
105
+ * working in. It has already put a file in an unrelated repository once.
106
+ *
107
+ * So it is pinned when management starts. If the live answer later disagrees,
108
+ * nothing is written and it is said out loud once — a mirror that follows the
109
+ * pane to a new repository is not a mirror, it is a second author.
110
+ */
111
+ repoRoot?: string;
112
+ /** Said once when the pane's checkout stopped matching the pinned one. */
113
+ repoDriftReported?: boolean;
114
+ /**
115
+ * A bounded stretch of autonomous work on the tracker's open issues.
116
+ *
117
+ * This exists because the operator was writing the same long paragraph every
118
+ * night — which list, in what order, report where, commit how, screen or no
119
+ * screen, for how long — and any clause forgotten in a hurry was a rule that
120
+ * silently did not apply. A shift is that paragraph reduced to its three
121
+ * variables: how long, with the screen or without, and how many workers.
122
+ */
123
+ shift?: {
124
+ /** When it ends. Stopping claiming is not the same as being killed. */
125
+ until: number;
126
+ /** Upper bound on concurrent workers. See the fleet design note. */
127
+ workers: number;
128
+ /** Whether the screen was handed over for the duration. */
129
+ visual: boolean;
130
+ startedAt: number;
131
+ /** Said once, so the end of a shift is announced and not merely obeyed. */
132
+ endReported?: boolean;
133
+ };
98
134
  startedAt: number;
99
135
  }
100
136
  /**
@@ -152,6 +188,68 @@ export declare function resolveSession(idOrName: string): {
152
188
  * text, operator notes — including the ones added later.
153
189
  */
154
190
  export declare function oneLine(text: string): string;
191
+ /**
192
+ * The standing rules, or "" when none are set. Never throws.
193
+ *
194
+ * A file whose first line is `@` followed by a path is a POINTER, and the rules
195
+ * are read from there instead. That exists so the text can have one owner: the
196
+ * same rules belong in the working repository, where they are version
197
+ * controlled, reviewed with the code and read by sessions nobody is managing —
198
+ * and copying them here as well would be two places holding one piece of
199
+ * knowledge, which is a certainty of drift rather than a risk of it. Point at
200
+ * the repository's copy and an edit there is in force at the next arming.
201
+ *
202
+ * One level of indirection only. A pointer to a pointer is a loop waiting to
203
+ * be written, and nothing here is worth that.
204
+ */
205
+ export declare function readStandingRules(path?: string): string;
206
+ /** Where the rules are read from, for saying so out loud. */
207
+ export declare function standingRulesSource(path?: string): string;
208
+ /**
209
+ * A path with the home directory folded back to `~`, and the reverse.
210
+ *
211
+ * The pointer is written by a machine and read by a person, and an absolute
212
+ * path carries the account name of whoever ran the command. That is nobody's
213
+ * business in a file that may be copied to another machine, pasted into a bug
214
+ * report or checked in by accident — and the same rules file on two machines
215
+ * should not need two different pointers.
216
+ */
217
+ export declare function foldHome(p: string, home?: string): string;
218
+ export declare function expandHome(p: string, home?: string): string;
219
+ /** Write the standing rules. Passing empty text removes them. */
220
+ export declare function writeStandingRules(text: string, path?: string): void;
221
+ /**
222
+ * Read a shift request out of a sentence.
223
+ *
224
+ * Deliberately forgiving about wording and strict about values: the caller is a
225
+ * model turning "you are free to work on the issues for eight hours with your
226
+ * controls, two workers at most" into an action, and the failure to avoid is a
227
+ * shift that silently lasts a different length than the one that was said.
228
+ */
229
+ export declare function parseShift(text: string): {
230
+ hours: number;
231
+ visual: boolean;
232
+ workers: number;
233
+ };
234
+ /**
235
+ * The standing objective for a shift, written once here instead of by hand
236
+ * every night. It says what to work on and what "done" means; how to work is
237
+ * the standing rules, which ride along with every arming.
238
+ */
239
+ export declare function shiftObjective(): string;
240
+ /**
241
+ * What a goal is made of, in the order a reader needs it.
242
+ *
243
+ * Task first, because that is what the session is being asked to do. Standing
244
+ * rules second, because they qualify the task rather than replace it. The
245
+ * screen state and any one-shot operator note last, because they are about
246
+ * right now rather than about the job.
247
+ *
248
+ * Exported so the composition can be pinned by a test: this string is typed
249
+ * into a live session, and a mistake in it is a mistake that arrives as
250
+ * keystrokes.
251
+ */
252
+ export declare function composeGoal(objective: string, rules: string, hands: string, extra: string, rulesPath?: string): string;
155
253
  export declare function startManagerLoop(): void;
156
254
  export interface ManageResult {
157
255
  ok: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"manage.d.ts","sourceRoot":"","sources":["../../src/daemon/manage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AA+LH,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,gFAAgF;IAChF,IAAI,EAAE,MAAM,CAAC;IACb,2DAA2D;IAC3D,SAAS,EAAE,MAAM,CAAC;IAClB,oEAAoE;IACpE,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,4DAA4D;IAC5D,OAAO,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACxC,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;IAChB;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;6DACyD;IACzD,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,oFAAoF;IACpF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,2EAA2E;IAC3E,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;uEACmE;IACnE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,6EAA6E;IAC7E,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,mFAAmF;IACnF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,8EAA8E;IAC9E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;;;;;;;;;;;OAeG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,8EAA8E;IAC9E,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,2EAA2E;IAC3E,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,gFAAgF;IAChF,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;kFAC8E;IAC9E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,+EAA+E;IAC/E,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;CACnB;AAyLD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAgB/D;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAE5D;AAED;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,GAAE,IAAiB,GAAG,MAAM,CAUnF;AA6KD;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAY3F;AA8PD;;;;;;;;;;;;GAYG;AACH,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE5C;AAgmBD,wBAAgB,gBAAgB,IAAI,IAAI,CASvC;AAED,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,OAAO,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED;;;;;;;;;GASG;AACH,wBAAsB,YAAY,CAAC,eAAe,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CA8ZjG"}
1
+ {"version":3,"file":"manage.d.ts","sourceRoot":"","sources":["../../src/daemon/manage.ts"],"names":[],"mappings":"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"}
@@ -19,9 +19,9 @@
19
19
  * carries one-shot instructions from the operator into the next arming, and it
20
20
  * says what it did. Everything requiring judgement stays with the person.
21
21
  */
22
- import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, mkdirSync } from "node:fs";
22
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, mkdirSync, unlinkSync } from "node:fs";
23
23
  import { execFileSync } from "node:child_process";
24
- import { join } from "node:path";
24
+ import { join, dirname } from "node:path";
25
25
  import { homedir } from "node:os";
26
26
  import { log } from "../core/log.js";
27
27
  import { readSessionContent } from "./session-content.js";
@@ -286,9 +286,25 @@ function mirrorToRepo(m) {
286
286
  const proc = tty ? processReading(tty) : { isSession: false, pid: null };
287
287
  if (!proc.pid)
288
288
  return;
289
- const cwd = repoRootFor(proc.pid);
290
- if (!cwd)
289
+ const live = repoRootFor(proc.pid);
290
+ if (!live)
291
+ return;
292
+ // Pin on the first successful resolution — including for sessions that
293
+ // were being managed before this field existed, which is why it is set
294
+ // here rather than only at creation.
295
+ if (!m.repoRoot)
296
+ m.repoRoot = live;
297
+ if (m.repoRoot !== live) {
298
+ // Positive evidence, not a guess: the pane is reading a different
299
+ // checkout than the one this session's state belongs to. Refuse, and say
300
+ // so once — repeating it every twenty seconds would bury the log.
301
+ if (!m.repoDriftReported) {
302
+ m.repoDriftReported = true;
303
+ log(`[manage:${m.name}] pane is now in a different checkout than the one pinned at start — not mirroring state, to avoid writing it into somebody else's repository`);
304
+ }
291
305
  return;
306
+ }
307
+ const cwd = m.repoRoot;
292
308
  const dir = join(cwd, ".aibroker");
293
309
  if (!existsSync(dir))
294
310
  mkdirSync(dir, { recursive: true });
@@ -444,6 +460,22 @@ function snapshotTty(sessionId) {
444
460
  return discoverLiveSessions().find((s) => s.id === sessionId)?.tty;
445
461
  }
446
462
  /** The checkout a process is sitting in, or null if it is not in one. */
463
+ /**
464
+ * The checkout a session is working in, or undefined when it cannot be told.
465
+ *
466
+ * Undefined is a real answer here and must not be faked: a session whose
467
+ * checkout is unknown gets no mirror at all, which is better than a mirror
468
+ * written somewhere plausible.
469
+ */
470
+ function repoRootForSession(sessionId) {
471
+ const tty = snapshotTty(sessionId);
472
+ if (!tty)
473
+ return undefined;
474
+ const proc = processReading(tty);
475
+ if (!proc.pid)
476
+ return undefined;
477
+ return repoRootFor(proc.pid) ?? undefined;
478
+ }
447
479
  function repoRootFor(pid) {
448
480
  try {
449
481
  const cwdOut = execFileSync("/usr/sbin/lsof", ["-p", pid, "-a", "-d", "cwd", "-Fn"], {
@@ -872,6 +904,197 @@ function landsWhen(m) {
872
904
  return (" This changes what the next arming says; it types nothing now.\n" +
873
905
  " The next arming comes when the session stops — or immediately, with `now`.");
874
906
  }
907
+ /**
908
+ * Standing rules: how to work, written once, typed at every arming.
909
+ *
910
+ * WHY THIS IS NOT PART OF THE OBJECTIVE. The objective is re-typed verbatim on
911
+ * every arming, so for a long time it was the only thing that survived — and
912
+ * that made it the only place to put a rule you wanted obeyed all night. The
913
+ * result was an operator hand-writing the same paragraph into every goal, for
914
+ * every project: bound your waits, report when you start an item and when you
915
+ * finish, one commit per item, never send the quit keystroke, bring the app to
916
+ * the front before looking at it, put test files here. Two thirds of a goal
917
+ * that was supposed to say "work through this list".
918
+ *
919
+ * That cost never announced itself. Nothing failed; a person just retyped
920
+ * knowledge the machine already had, and any line they forgot that night was a
921
+ * rule that silently did not apply. So the rules live in one file, apply to
922
+ * every managed session, and the objective goes back to being the task.
923
+ *
924
+ * A FILE RATHER THAN A CLI FIELD, deliberately: these are read and edited far
925
+ * more often than they are written, and a paragraph is easier to revise in an
926
+ * editor than to re-type through a shell. `manage rules` prints the path.
927
+ */
928
+ const RULES_FILE = join(homedir(), ".aibroker", "manage-rules.txt");
929
+ /**
930
+ * The standing rules, or "" when none are set. Never throws.
931
+ *
932
+ * A file whose first line is `@` followed by a path is a POINTER, and the rules
933
+ * are read from there instead. That exists so the text can have one owner: the
934
+ * same rules belong in the working repository, where they are version
935
+ * controlled, reviewed with the code and read by sessions nobody is managing —
936
+ * and copying them here as well would be two places holding one piece of
937
+ * knowledge, which is a certainty of drift rather than a risk of it. Point at
938
+ * the repository's copy and an edit there is in force at the next arming.
939
+ *
940
+ * One level of indirection only. A pointer to a pointer is a loop waiting to
941
+ * be written, and nothing here is worth that.
942
+ */
943
+ export function readStandingRules(path = RULES_FILE) {
944
+ try {
945
+ if (!existsSync(path))
946
+ return "";
947
+ const raw = readFileSync(path, "utf8");
948
+ const target = raw.trim().match(/^@\s*(\S.*)$/m);
949
+ if (target) {
950
+ const p = expandHome(target[1].trim());
951
+ return existsSync(p) ? oneLine(readFileSync(p, "utf8")) : "";
952
+ }
953
+ return oneLine(raw);
954
+ }
955
+ catch {
956
+ return "";
957
+ }
958
+ }
959
+ /** Where the rules are read from, for saying so out loud. */
960
+ export function standingRulesSource(path = RULES_FILE) {
961
+ try {
962
+ if (!existsSync(path))
963
+ return path;
964
+ const target = readFileSync(path, "utf8").trim().match(/^@\s*(\S.*)$/m);
965
+ return target ? target[1].trim() : foldHome(path);
966
+ }
967
+ catch {
968
+ return path;
969
+ }
970
+ }
971
+ /**
972
+ * A path with the home directory folded back to `~`, and the reverse.
973
+ *
974
+ * The pointer is written by a machine and read by a person, and an absolute
975
+ * path carries the account name of whoever ran the command. That is nobody's
976
+ * business in a file that may be copied to another machine, pasted into a bug
977
+ * report or checked in by accident — and the same rules file on two machines
978
+ * should not need two different pointers.
979
+ */
980
+ export function foldHome(p, home = homedir()) {
981
+ return p === home ? "~" : p.startsWith(`${home}/`) ? `~/${p.slice(home.length + 1)}` : p;
982
+ }
983
+ export function expandHome(p, home = homedir()) {
984
+ return p === "~" ? home : p.startsWith("~/") ? join(home, p.slice(2)) : p;
985
+ }
986
+ /** Write the standing rules. Passing empty text removes them. */
987
+ export function writeStandingRules(text, path = RULES_FILE) {
988
+ const body = text.trim();
989
+ if (!body) {
990
+ try {
991
+ if (existsSync(path))
992
+ unlinkSync(path);
993
+ }
994
+ catch { /* nothing to remove */ }
995
+ return;
996
+ }
997
+ mkdirSync(dirname(path), { recursive: true });
998
+ writeFileSync(path, body.endsWith("\n") ? body : `${body}\n`);
999
+ }
1000
+ /**
1001
+ * The most a goal may be, in characters.
1002
+ *
1003
+ * Measured, not guessed: the receiving prompt answers "Goal condition is
1004
+ * limited to 4000 characters" and rejects the whole thing. It cost an evening
1005
+ * of armings that reported "typed, but the words never appeared" — true, and
1006
+ * silent about the reason. Kept under the real limit so a rules file that grows
1007
+ * by a sentence does not walk back over it.
1008
+ */
1009
+ const GOAL_MAX_CHARS = 3800;
1010
+ /** Sensible default when a shift names no length. Long enough to be worth arming. */
1011
+ const SHIFT_DEFAULT_HOURS = 8;
1012
+ /** Nobody should be able to hand over a machine for longer than a day by accident. */
1013
+ const SHIFT_MAX_HOURS = 24;
1014
+ /**
1015
+ * Read a shift request out of a sentence.
1016
+ *
1017
+ * Deliberately forgiving about wording and strict about values: the caller is a
1018
+ * model turning "you are free to work on the issues for eight hours with your
1019
+ * controls, two workers at most" into an action, and the failure to avoid is a
1020
+ * shift that silently lasts a different length than the one that was said.
1021
+ */
1022
+ export function parseShift(text) {
1023
+ const t = text.toLowerCase();
1024
+ const h = t.match(/(\d+(?:[.,]\d+)?)\s*(h\b|hours?|hrs?)/);
1025
+ const m = t.match(/(\d+)\s*(m\b|minutes?|mins?)/);
1026
+ const hours = h ? Number(h[1].replace(",", ".")) : m ? Number(m[1]) / 60 : SHIFT_DEFAULT_HOURS;
1027
+ // The screen is withheld unless it was actually offered. A shift that grants
1028
+ // the pointer because nobody said not to is the wrong way round.
1029
+ const visual = /\b(your controls|with controls|visual|you have the screen|screen is yours)\b/.test(t)
1030
+ && !/\b(no screen|without the screen|not visual|headless|screen is mine|i need the screen)\b/.test(t);
1031
+ const w = t.match(/(\d+)\s*(workers?|sessions?|in parallel)|(?:max(?:imum)?|up to)\s*(?:of\s*)?(\d+)/);
1032
+ const workers = w ? Number(w[1] ?? w[3]) : 1;
1033
+ return {
1034
+ hours: Math.min(SHIFT_MAX_HOURS, Math.max(0.25, hours)),
1035
+ visual,
1036
+ workers: Math.min(8, Math.max(1, workers)),
1037
+ };
1038
+ }
1039
+ /**
1040
+ * The standing objective for a shift, written once here instead of by hand
1041
+ * every night. It says what to work on and what "done" means; how to work is
1042
+ * the standing rules, which ride along with every arming.
1043
+ */
1044
+ export function shiftObjective() {
1045
+ return oneLine(`
1046
+ Work the open issues in this repository's tracker, one at a time, and do not stop.
1047
+ Take the oldest open issue you can act on that nobody has claimed. Claim it by
1048
+ assigning it to yourself and adding the in-progress label, then RE-READ the issue
1049
+ and confirm the claim is yours before doing anything else — if somebody else holds
1050
+ it, drop it and take the next.
1051
+ Work on a branch named for the issue. Comment on the issue when you start and again
1052
+ when you finish, and read a clock for the timestamp.
1053
+ Prove the problem is still real before fixing it, and prove the fix on the evidence
1054
+ the issue names.
1055
+ Commit per item. Merge only when it is a fast-forward and the checks pass; anything
1056
+ else gets a label and is left for a person rather than forced.
1057
+ When an issue is done, close it, release the claim, and take the next one.
1058
+ If there is no issue you can act on, say so and wait rather than inventing work.
1059
+ `);
1060
+ }
1061
+ /**
1062
+ * What a goal is made of, in the order a reader needs it.
1063
+ *
1064
+ * Task first, because that is what the session is being asked to do. Standing
1065
+ * rules second, because they qualify the task rather than replace it. The
1066
+ * screen state and any one-shot operator note last, because they are about
1067
+ * right now rather than about the job.
1068
+ *
1069
+ * Exported so the composition can be pinned by a test: this string is typed
1070
+ * into a live session, and a mistake in it is a mistake that arrives as
1071
+ * keystrokes.
1072
+ */
1073
+ export function composeGoal(objective, rules, hands, extra, rulesPath) {
1074
+ const withoutRules = oneLine(`/goal ${objective}${hands}${extra}`);
1075
+ if (!rules)
1076
+ return withoutRules;
1077
+ const inlined = oneLine(`/goal ${objective} ALWAYS, on every item: ${rules}${hands}${extra}`);
1078
+ if (inlined.length <= GOAL_MAX_CHARS)
1079
+ return inlined;
1080
+ /*
1081
+ * Too long to paste, so point at it instead.
1082
+ *
1083
+ * The receiving prompt refuses a goal over a few thousand characters, and it
1084
+ * refuses the WHOLE goal — so a rules file that grew by a paragraph silently
1085
+ * stopped every arming, and the manager reported "typed but the words never
1086
+ * appeared", which is true and says nothing about why. The rules were fine;
1087
+ * the goal was rejected at the door.
1088
+ *
1089
+ * Pointing keeps every rule in force. Reading a named file is a discrete act
1090
+ * that either happened or did not, which is the kind of instruction sessions
1091
+ * actually follow; pasting is only better when it fits.
1092
+ */
1093
+ const pointer = rulesPath
1094
+ ? ` FIRST, before anything else: read ${rulesPath} and follow every rule in it for the whole of this work — they are not optional and they are not summarised here.`
1095
+ : "";
1096
+ return oneLine(`/goal ${objective}${pointer}${hands}${extra}`);
1097
+ }
875
1098
  /** The text actually typed at the session. Short goal, context by reference. */
876
1099
  function goalText(m) {
877
1100
  const extra = m.pending.length ? ` OPERATOR, since you were last armed: ${m.pending.join(" ")}` : "";
@@ -882,7 +1105,7 @@ function goalText(m) {
882
1105
  const hands = m.noScreen
883
1106
  ? " THE OPERATOR HAS THE SCREEN: do no screen or pointer work at all, and do not ask for it. Everything else continues as normal. Where something would need checking on screen, write down what would need checking instead of checking it."
884
1107
  : "";
885
- return oneLine(`/goal ${m.objective}${hands}${extra}`);
1108
+ return composeGoal(m.objective, readStandingRules(), hands, extra, standingRulesSource());
886
1109
  }
887
1110
  /**
888
1111
  * Did it land? Look for the goal's own words in the transcript.
@@ -1125,6 +1348,20 @@ async function tick() {
1125
1348
  * it happens without a person: "hands on for eight hours" has to hand the
1126
1349
  * screen back at the eighth hour whether or not anybody is awake to ask.
1127
1350
  */
1351
+ /**
1352
+ * The end of a shift, which is a stand-down and not a kill.
1353
+ *
1354
+ * Arming stops; nothing is interrupted. A session mid-turn finishes it, and
1355
+ * the objective stays so that `resume` picks the work up rather than
1356
+ * starting it over. Announced once, because a fleet that quietly stopped
1357
+ * looks exactly like a fleet that is still going.
1358
+ */
1359
+ if (m.shift && now >= m.shift.until && !m.shift.endReported) {
1360
+ m.shift.endReported = true;
1361
+ m.paused = true;
1362
+ notify(m, `shift over after ${Math.round((now - m.shift.startedAt) / 3_600_000)}h — no longer arming; the objective is kept, \`resume\` continues it`);
1363
+ dirty = true;
1364
+ }
1128
1365
  if (m.handsUntil && now >= m.handsUntil) {
1129
1366
  const wasOff = m.handsWas === true;
1130
1367
  delete m.handsUntil;
@@ -1487,6 +1724,14 @@ export async function handleManage(sessionIdOrName, rawArg) {
1487
1724
  ` manager is a one-shot note; this changes what it re-arms\n` +
1488
1725
  ` add <text> EXTEND the standing objective. Say it to the session\n` +
1489
1726
  ` instead and the next arming forgets it\n` +
1727
+ ` rules [text|from <path>|clear]\n` +
1728
+ ` HOW to work, as opposed to what to work on: typed into every\n` +
1729
+ ` arming of every managed session, so it is written once rather\n` +
1730
+ ` than into each objective by hand. No argument shows them\n` +
1731
+ ` shift [8h] [your controls] [2 workers]\n` +
1732
+ ` a bounded stretch of autonomous work on the tracker's open\n` +
1733
+ ` issues: objective, screen decision, expiry and arming in one\n` +
1734
+ ` sentence. \`shift off\` stands it down without killing it\n` +
1490
1735
  ` now arm immediately, whatever the signals say\n` +
1491
1736
  ` pause stop arming, keep the objective\n` +
1492
1737
  ` resume start arming again\n` +
@@ -1501,6 +1746,120 @@ export async function handleManage(sessionIdOrName, rawArg) {
1501
1746
  `${existing ? `Currently managing ${name}: ${existing.objective}` : `${name} is not being managed.`}`,
1502
1747
  };
1503
1748
  }
1749
+ /**
1750
+ * rules — the standing "how to work" text, shared by every managed session.
1751
+ *
1752
+ * Answers on its own rather than through a session, because the rules are not
1753
+ * a property of one: setting them from whichever pane happens to be to hand
1754
+ * must not depend on which session that is.
1755
+ */
1756
+ const rulesMatch = arg.match(/^rules(?:\s+([\s\S]+))?$/i);
1757
+ if (rulesMatch) {
1758
+ const rest = (rulesMatch[1] ?? "").trim();
1759
+ if (!rest || rest.toLowerCase() === "show") {
1760
+ const current = readStandingRules();
1761
+ return {
1762
+ ok: true,
1763
+ managed: !!existing,
1764
+ message: current
1765
+ ? `standing rules, typed into every arming of every managed session:\n\n ${current}\n\n` +
1766
+ ` read from ${standingRulesSource()} — edit them there.\n` +
1767
+ ` \`manage rules from <path>\` points at a file the working repository owns.\n` +
1768
+ ` \`manage rules clear\` removes them.`
1769
+ : `no standing rules set.\n\n` +
1770
+ ` These are the lines you would otherwise write into every objective by hand —\n` +
1771
+ ` how to work, as opposed to what to work on. They are typed at every arming, so\n` +
1772
+ ` they survive a compaction, and they are shared by every managed session.\n\n` +
1773
+ ` Set them with \`manage rules <text>\`, write ${RULES_FILE} directly, or\n` +
1774
+ ` \`manage rules from <path>\` to let the working repository own the text.`,
1775
+ };
1776
+ }
1777
+ const from = rest.match(/^from\s+(\S.*)$/i);
1778
+ if (from) {
1779
+ const target = foldHome(expandHome(from[1].trim()));
1780
+ writeStandingRules(`@${target}`);
1781
+ const got = readStandingRules();
1782
+ return {
1783
+ ok: true,
1784
+ managed: !!existing,
1785
+ message: got
1786
+ ? `standing rules now read from ${target} — edit them there and the next arming has them.\n ${got.slice(0, 160)}${got.length > 160 ? "…" : ""}`
1787
+ : `pointed at ${target}, but nothing readable is there yet. The pointer stays; armings carry no rules until that file exists.`,
1788
+ };
1789
+ }
1790
+ if (rest.toLowerCase() === "clear") {
1791
+ writeStandingRules("");
1792
+ return { ok: true, managed: !!existing, message: "standing rules cleared — objectives now carry only themselves." };
1793
+ }
1794
+ writeStandingRules(rest);
1795
+ return {
1796
+ ok: true,
1797
+ managed: !!existing,
1798
+ message: `standing rules set — they go into every arming, of every managed session.\n ${oneLine(rest).slice(0, 200)}${rest.length > 200 ? "…" : ""}\n` +
1799
+ ` Stored at ${RULES_FILE}.`,
1800
+ };
1801
+ }
1802
+ /**
1803
+ * shift — a bounded stretch of autonomous work, set up in one sentence.
1804
+ *
1805
+ * Everything the operator used to type by hand: the objective, the screen
1806
+ * decision and its expiry, the arming, and how long it all lasts.
1807
+ */
1808
+ /*
1809
+ * Only the word "shift", and only leading.
1810
+ *
1811
+ * The first version also accepted "go" and "work", which read well and was
1812
+ * wrong: "work through the open items first" is how half of all objectives
1813
+ * begin, and it would have been swallowed as a shift request — silently
1814
+ * replacing what the operator typed with the issue-driven objective. A verb
1815
+ * that can be mistaken for the start of a sentence is not a verb.
1816
+ */
1817
+ const shiftMatch = arg.match(/^shift\b\s*([\s\S]*)$/i);
1818
+ if (shiftMatch) {
1819
+ const rest = (shiftMatch[1] ?? "").trim();
1820
+ if (!existing) {
1821
+ return { ok: false, message: `${name} is not being managed yet. Start with an objective first, or use \`manage ${name} <objective>\`.` };
1822
+ }
1823
+ if (/^(off|stop|end|stand down)\b/i.test(rest)) {
1824
+ const had = existing.shift;
1825
+ delete existing.shift;
1826
+ existing.paused = true;
1827
+ note(existing, "shift ended by the operator — no longer arming");
1828
+ saveState(state);
1829
+ return {
1830
+ ok: true,
1831
+ managed: true,
1832
+ message: had
1833
+ ? `shift ended. ${name} keeps its objective and is no longer re-armed; whatever it is doing now runs to its natural stop.\n \`manage ${name} resume\` picks it up again.`
1834
+ : `${name} had no shift running. It is paused now either way.`,
1835
+ };
1836
+ }
1837
+ const { hours, visual, workers } = parseShift(rest);
1838
+ const until = Date.now() + hours * 3_600_000;
1839
+ existing.objective = shiftObjective();
1840
+ existing.pending = [];
1841
+ existing.paused = false;
1842
+ existing.noScreen = !visual;
1843
+ existing.handsUntil = until;
1844
+ existing.handsWas = !visual;
1845
+ existing.shift = { until, workers, visual, startedAt: Date.now() };
1846
+ // Arm on the next tick rather than typing over whatever is on screen now.
1847
+ existing.lastRearmAt = 0;
1848
+ note(existing, `shift started: ${hours}h, ${visual ? "screen granted" : "no screen"}, up to ${workers} worker(s)`);
1849
+ saveState(state);
1850
+ const ends = new Date(until).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
1851
+ return {
1852
+ ok: true,
1853
+ managed: true,
1854
+ message: `${name} is on shift for ${hours} hour(s), until ${ends}.\n` +
1855
+ ` ${visual ? "The screen is its own until then, and reverts by itself." : "No screen work — the operator has the machine."}\n` +
1856
+ ` Objective set to the tracker's open issues; the standing rules ride along with every arming.\n` +
1857
+ (workers > 1
1858
+ ? ` Asked for ${workers} workers. Only one runs today — worktrees and the claim protocol are designed but not built, and a second worker without them would share a checkout with the first.\n`
1859
+ : "") +
1860
+ ` It arms on the next tick. \`manage ${name} shift off\` stands it down without killing it.`,
1861
+ };
1862
+ }
1504
1863
  if (word === "off" || word === "stop") {
1505
1864
  if (!existing)
1506
1865
  return { ok: true, message: `${name} was not being managed`, managed: false };
@@ -1794,6 +2153,9 @@ export async function handleManage(sessionIdOrName, rawArg) {
1794
2153
  lastHash: hash(readPane(sessionId)),
1795
2154
  tty: snapshotTty(sessionId),
1796
2155
  paused: false,
2156
+ // Pinned here so every later write goes to the checkout this session was
2157
+ // started in, whatever the pane does afterwards.
2158
+ repoRoot: repoRootForSession(sessionId),
1797
2159
  startedAt: Date.now(),
1798
2160
  };
1799
2161
  state[sessionId] = m;