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.
- package/dist/adapters/pailot/gateway.d.ts +28 -0
- package/dist/adapters/pailot/gateway.d.ts.map +1 -1
- package/dist/adapters/pailot/gateway.js +102 -36
- package/dist/adapters/pailot/gateway.js.map +1 -1
- package/dist/daemon/cli.js +14 -2
- package/dist/daemon/cli.js.map +1 -1
- package/dist/daemon/funnel-watchdog.d.ts +8 -0
- package/dist/daemon/funnel-watchdog.d.ts.map +1 -1
- package/dist/daemon/funnel-watchdog.js +46 -1
- package/dist/daemon/funnel-watchdog.js.map +1 -1
- package/dist/daemon/inbound.d.ts +79 -23
- package/dist/daemon/inbound.d.ts.map +1 -1
- package/dist/daemon/inbound.js +195 -6
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/manage.d.ts +98 -0
- package/dist/daemon/manage.d.ts.map +1 -1
- package/dist/daemon/manage.js +367 -5
- package/dist/daemon/manage.js.map +1 -1
- package/dist/daemon/todoist-webhook.d.ts.map +1 -1
- package/dist/daemon/todoist-webhook.js +22 -4
- package/dist/daemon/todoist-webhook.js.map +1 -1
- package/dist/mcp/index.js +6 -4
- package/dist/mcp/index.js.map +1 -1
- package/package.json +1 -1
package/dist/daemon/manage.d.ts
CHANGED
|
@@ -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;
|
|
1
|
+
{"version":3,"file":"manage.d.ts","sourceRoot":"","sources":["../../src/daemon/manage.ts"],"names":[],"mappings":"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"}
|
package/dist/daemon/manage.js
CHANGED
|
@@ -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
|
|
290
|
-
if (!
|
|
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
|
|
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;
|