trantor 0.18.22 → 0.18.24
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/.claude-plugin/plugin.json +1 -1
- package/bin/bridge.mjs +4 -1
- package/bin/crew-payload.mjs +115 -0
- package/bin/crew-runner.mjs +42 -9
- package/hooks/sessionstart.mjs +17 -0
- package/hub.mjs +29 -2
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.24",
|
|
4
4
|
"description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
|
|
5
5
|
"mcpServers": {
|
|
6
6
|
"relay": {
|
package/bin/bridge.mjs
CHANGED
|
@@ -110,7 +110,10 @@ async function tick() {
|
|
|
110
110
|
if (differs && (aMoved || bMoved)) {
|
|
111
111
|
const aWins = aMoved && bMoved ? p.origin === "A" : aMoved;
|
|
112
112
|
const [src, dstHub, dstId] = aWins ? [ta, TO, p.bId] : [tb, FROM, p.aId];
|
|
113
|
-
|
|
113
|
+
// reassign:true — the bridge REPLICATES a hand-change that already happened on the source
|
|
114
|
+
// hub; without the explicit marker the #5406 assignee-immutability guard would 409 every
|
|
115
|
+
// cross-hub sync whose assignee moved (deepseek flagged this at review, 2026-08-31).
|
|
116
|
+
const r = await call(dstHub, "POST", "/task/update", { id: dstId, status: src.status, assignee: src.assignee || "", reassign: true, by: src.by || "bridge" });
|
|
114
117
|
if (aWins) { p.lastA = ta.updated || 0; p.lastB = r.task.updated || 0; }
|
|
115
118
|
else { p.lastB = tb.updated || 0; p.lastA = r.task.updated || 0; }
|
|
116
119
|
synced++;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Payload composition for crew-runner turn prompts — pure, unit-testable (card #5683).
|
|
2
|
+
//
|
|
3
|
+
// A fresh codex seat burned 306k tokens and crash-looped into a remote-compact 404. The runner-side
|
|
4
|
+
// part of that: every turn re-feeds the FULL lessons block (22,298 of the 24,698 chars in codex's
|
|
5
|
+
// last turn file — 90%), plus an unbounded FYI-broadcast backlog that grows across a failure streak
|
|
6
|
+
// and is replayed on every redelivery, on a RESUMED session where it all stacks. So every section
|
|
7
|
+
// here is capped, and the assembled prompt has ONE hard total cap with a visible truncation notice.
|
|
8
|
+
// Below the caps the output is byte-identical to the old string concatenation.
|
|
9
|
+
|
|
10
|
+
export const PAYLOAD_CAPS = Object.freeze({
|
|
11
|
+
wakeCount: 10, // direct/@mention messages: keep the last ~10
|
|
12
|
+
wakeMsgChars: 2000, // per-message body cap (the hub caps card notes at 2000 too)
|
|
13
|
+
bcastCount: 10, // FYI broadcast context: keep the last ~10
|
|
14
|
+
bcastMsgChars: 1000,
|
|
15
|
+
lessonsCount: 15, // top ~15 lessons, ranked by relevance to this turn's trigger
|
|
16
|
+
lessonsChars: 16000,
|
|
17
|
+
totalChars: 40000, // ONE hard total cap for the whole prompt
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const WORD_RE = /[a-z0-9]{4,}/g;
|
|
21
|
+
const n = v => Number(v).toLocaleString("en-US");
|
|
22
|
+
|
|
23
|
+
// ---- wake messages (the task): keep the last `wakeCount`, cap each body ----
|
|
24
|
+
export function capWake(wake, caps = PAYLOAD_CAPS) {
|
|
25
|
+
const list = Array.isArray(wake) ? wake : [];
|
|
26
|
+
const kept = list.slice(-caps.wakeCount);
|
|
27
|
+
const lines = kept.map(m => {
|
|
28
|
+
let body = String(m?.text ?? "");
|
|
29
|
+
if (body.length > caps.wakeMsgChars)
|
|
30
|
+
body = body.slice(0, caps.wakeMsgChars) + ` …[+${n(body.length - caps.wakeMsgChars)} chars of this message dropped]`;
|
|
31
|
+
return `[${m?.from}${m?.to === "all" ? " -> all (mentions you)" : ""}]: ${body}`;
|
|
32
|
+
});
|
|
33
|
+
return { text: lines.join("\n"), kept: kept.length, total: list.length };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---- FYI broadcast context (context only): keep the last `bcastCount`, cap each body ----
|
|
37
|
+
export function capBcast(bcast, caps = PAYLOAD_CAPS) {
|
|
38
|
+
const list = Array.isArray(bcast) ? bcast : [];
|
|
39
|
+
const kept = list.slice(-caps.bcastCount);
|
|
40
|
+
const lines = kept.map(m => {
|
|
41
|
+
let body = String(m?.text ?? "");
|
|
42
|
+
if (body.length > caps.bcastMsgChars)
|
|
43
|
+
body = body.slice(0, caps.bcastMsgChars) + ` …[+${n(body.length - caps.bcastMsgChars)} chars dropped]`;
|
|
44
|
+
return `[${m?.from} -> all]: ${body}`;
|
|
45
|
+
});
|
|
46
|
+
return { text: lines.join("\n"), kept: kept.length, total: list.length };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---- lessons: top `lessonsCount` ranked by word overlap with this turn's trigger, then char-capped.
|
|
50
|
+
// No trigger (kickoff/pulse) → no signal to rank on, so original order stands.
|
|
51
|
+
export function pickLessons(lessons, trigger = "", caps = PAYLOAD_CAPS) {
|
|
52
|
+
const list = Array.isArray(lessons) ? lessons.filter(Boolean) : [];
|
|
53
|
+
if (!list.length) return { text: "", kept: 0, total: 0 };
|
|
54
|
+
const tw = new Set(String(trigger).toLowerCase().match(WORD_RE) || []);
|
|
55
|
+
const ranked = list
|
|
56
|
+
.map((l, i) => {
|
|
57
|
+
const words = String(l?.text || "").toLowerCase().match(WORD_RE) || [];
|
|
58
|
+
let score = 0;
|
|
59
|
+
for (const w of words) if (tw.has(w)) score++;
|
|
60
|
+
return { l, i, score };
|
|
61
|
+
})
|
|
62
|
+
.sort((a, b) => b.score - a.score || a.i - b.i)
|
|
63
|
+
.slice(0, caps.lessonsCount)
|
|
64
|
+
.sort((a, b) => a.i - b.i);
|
|
65
|
+
let budget = caps.lessonsChars;
|
|
66
|
+
const lines = [];
|
|
67
|
+
for (const { l } of ranked) {
|
|
68
|
+
const line = `- [${l?.scope}] ${l?.text}`;
|
|
69
|
+
if (line.length > budget) continue; // doesn't fit — a shorter one may
|
|
70
|
+
budget -= line.length + 1;
|
|
71
|
+
lines.push(line);
|
|
72
|
+
}
|
|
73
|
+
if (!lines.length) return { text: "", kept: 0, total: list.length };
|
|
74
|
+
return {
|
|
75
|
+
text: "\n\nLESSONS from previous crews (hard-won — follow them):\n" + lines.join("\n"),
|
|
76
|
+
kept: lines.length,
|
|
77
|
+
total: list.length,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---- one composer, one hard total cap ----
|
|
82
|
+
// sections: [{ name, text, trim?, order? }] joined in order. `trim: "drop"` removes the whole
|
|
83
|
+
// section when the total is over `totalChars` (lowest `order` dropped first); `trim: "truncate"`
|
|
84
|
+
// cuts the section to the remaining budget. Sections without `trim` are never touched — they are
|
|
85
|
+
// the runner-authored frame. The payload carries a visible notice naming every trim.
|
|
86
|
+
export function composePrompt(sections, caps = PAYLOAD_CAPS) {
|
|
87
|
+
const secs = sections.map(s => ({ ...s, text: String(s?.text ?? "") }));
|
|
88
|
+
let total = secs.reduce((a, s) => a + s.text.length, 0);
|
|
89
|
+
const dropped = [];
|
|
90
|
+
const trimmable = secs.filter(s => s.trim).sort((a, b) => a.order - b.order);
|
|
91
|
+
for (const s of trimmable) {
|
|
92
|
+
if (total <= caps.totalChars) break;
|
|
93
|
+
if (s.trim === "drop") {
|
|
94
|
+
dropped.push(`${s.name} (${n(s.text.length)} chars dropped)`);
|
|
95
|
+
total -= s.text.length;
|
|
96
|
+
s.text = "";
|
|
97
|
+
} else {
|
|
98
|
+
const rest = total - s.text.length;
|
|
99
|
+
const budget = Math.max(0, caps.totalChars - rest);
|
|
100
|
+
dropped.push(`${s.name} (${n(s.text.length)} → ${n(budget)} chars)`);
|
|
101
|
+
s.text = s.text.slice(0, budget) + " …[truncated — payload hit the hard cap]";
|
|
102
|
+
total = rest + s.text.length;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const prompt = secs.map(s => s.text).join("")
|
|
106
|
+
+ (dropped.length
|
|
107
|
+
? `\n\n[PAYLOAD TRUNCATED: hard cap ${n(caps.totalChars)} chars — ${dropped.join("; ")}.]`
|
|
108
|
+
: "");
|
|
109
|
+
return {
|
|
110
|
+
prompt,
|
|
111
|
+
sections: secs.map(s => ({ name: s.name, chars: s.text.length })),
|
|
112
|
+
truncated: dropped.length > 0,
|
|
113
|
+
dropped,
|
|
114
|
+
};
|
|
115
|
+
}
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { resolveProject, resolveHub, withEnvFiles, hostId } from "../lib/project
|
|
|
16
16
|
import { loadOrCreate } from "../lib/identity.mjs";
|
|
17
17
|
import { signedHeaders } from "../lib/signed-fetch.mjs";
|
|
18
18
|
import { ensureEnrolled } from "../lib/enroll.mjs";
|
|
19
|
+
import { capWake, capBcast, pickLessons, composePrompt } from "./crew-payload.mjs";
|
|
19
20
|
|
|
20
21
|
const AGENT = process.argv[2];
|
|
21
22
|
const DIR = process.argv[3] || process.cwd();
|
|
@@ -483,14 +484,35 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
483
484
|
const KICKOFF = process.env.CREW_KICKOFF ||
|
|
484
485
|
`You just joined (your arrival was already announced on the bus). 1) relay_inbox — if a contract for you is already waiting, do it now per the Rules. 2) End your turn.\n\n${RULES}`;
|
|
485
486
|
|
|
486
|
-
let
|
|
487
|
+
let LESSONS_RAW = [];
|
|
487
488
|
async function loadLessons() {
|
|
488
489
|
try {
|
|
489
490
|
const { lessons } = await api(`/lessons?agent=${encodeURIComponent(AGENT)}`);
|
|
490
|
-
if (lessons?.length)
|
|
491
|
+
if (lessons?.length) LESSONS_RAW = lessons;
|
|
491
492
|
} catch {}
|
|
492
493
|
}
|
|
493
494
|
|
|
495
|
+
// card #5683: every section of a turn prompt is capped (bin/crew-payload.mjs) and the whole
|
|
496
|
+
// payload has ONE hard total cap. Codex burned 306k tokens into a remote-compact 404 crash-loop
|
|
497
|
+
// because a resumed session re-fed the full lessons block (22,298 of the 24,698 chars in its last
|
|
498
|
+
// turn file — 90%) plus an unbounded broadcast backlog on EVERY turn, redelivery after redelivery.
|
|
499
|
+
// Below the caps the composition is byte-identical to the old concatenation.
|
|
500
|
+
function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "", tailText = "", rulesText = "", lessons = null }) {
|
|
501
|
+
const built = composePrompt([
|
|
502
|
+
{ name: "base", text: base },
|
|
503
|
+
{ name: "wake", text: wakeText, trim: "truncate", order: 4 },
|
|
504
|
+
{ name: "ctx", text: ctxText, trim: "drop", order: 1 },
|
|
505
|
+
{ name: "again", text: againText },
|
|
506
|
+
{ name: "tail", text: tailText },
|
|
507
|
+
{ name: "rules", text: rulesText, trim: "drop", order: 3 },
|
|
508
|
+
{ name: "lessons", text: lessons?.text || "", trim: "drop", order: 2 },
|
|
509
|
+
]);
|
|
510
|
+
const parts = built.sections.filter(s => s.chars).map(s => `${s.name} ${s.chars.toLocaleString("en-US")}c`).join(" · ");
|
|
511
|
+
const lessonsNote = lessons && lessons.total ? ` (lessons ${lessons.kept}/${lessons.total})` : "";
|
|
512
|
+
log(`payload: ${parts}${lessonsNote} → ${built.prompt.length.toLocaleString("en-US")}c${built.truncated ? ` \x1b[33mTRUNCATED — ${built.dropped.join("; ")}\x1b[0m` : ""}`);
|
|
513
|
+
return built.prompt;
|
|
514
|
+
}
|
|
515
|
+
|
|
494
516
|
(async () => {
|
|
495
517
|
await loadLessons();
|
|
496
518
|
// start cursor at the CURRENT tip so we don't replay history
|
|
@@ -520,7 +542,7 @@ async function loadLessons() {
|
|
|
520
542
|
let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
|
|
521
543
|
if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
|
|
522
544
|
|
|
523
|
-
const ec0 = runTurn(KICKOFF
|
|
545
|
+
const ec0 = runTurn(composedTurn({ base: KICKOFF, lessons: pickLessons(LESSONS_RAW, "") }), true, "kickoff");
|
|
524
546
|
if (ec0) await reportFailure(ec0, "kickoff", pendingWake.length); // a failed kickoff = the "fired up, died, nobody knew" case
|
|
525
547
|
let lastTurnAt = Date.now();
|
|
526
548
|
if (PULSE_MS) log(`pulse armed — mission re-read every ${Math.round(PULSE_MS / 1000)}s (${MISSION_FILE})`);
|
|
@@ -530,7 +552,7 @@ async function loadLessons() {
|
|
|
530
552
|
// pulse first: a due mission beat runs even on a silent bus. Measured from the END of the
|
|
531
553
|
// last turn, so a long turn doesn't stack an immediate pulse on top of itself.
|
|
532
554
|
if (PULSE_MS && Date.now() - lastTurnAt >= PULSE_MS) {
|
|
533
|
-
const ecp = runTurn(PULSE_PROMPT + "\n\n"
|
|
555
|
+
const ecp = runTurn(composedTurn({ base: PULSE_PROMPT + "\n\n", rulesText: RULES, lessons: pickLessons(LESSONS_RAW, PULSE_PROMPT) }), false, "pulse");
|
|
534
556
|
if (ecp) await reportFailure(ecp, "pulse"); else await reportHealthy();
|
|
535
557
|
lastTurnAt = Date.now();
|
|
536
558
|
log("parked — waiting for the next message or pulse");
|
|
@@ -589,22 +611,33 @@ async function loadLessons() {
|
|
|
589
611
|
// queued, on disk, with a backoff — which is the whole point of the change.
|
|
590
612
|
async function deliverWake() {
|
|
591
613
|
const wake = pendingWake;
|
|
592
|
-
const
|
|
593
|
-
const
|
|
614
|
+
const wakeCapped = capWake(wake);
|
|
615
|
+
const bcastCapped = capBcast(pendingBcast);
|
|
616
|
+
const wakeText = wakeCapped.text
|
|
617
|
+
? `NEW BUS MESSAGE${wake.length > 1 ? "S" : ""} for you:\n${wakeCapped.text}\n`
|
|
618
|
+
: "";
|
|
619
|
+
const ctxText = bcastCapped.text
|
|
620
|
+
? `\nFYI broadcasts since your last turn (context only):\n${bcastCapped.text}\n`
|
|
621
|
+
: "";
|
|
594
622
|
// Say plainly that this is a second look. Without it the model re-reads an old escalation as
|
|
595
623
|
// brand new and can redo work it already half-did before the turn died.
|
|
596
|
-
const
|
|
624
|
+
const againText = deliveryFails
|
|
597
625
|
? `\n(REDELIVERY, attempt ${deliveryFails + 1} — an earlier turn failed before acting on ${wake.length > 1 ? "these" : "this"}. Check what you already did before repeating it.)\n`
|
|
598
626
|
: "";
|
|
599
|
-
const prompt = `NEW BUS MESSAGE${wake.length > 1 ? "S" : ""} for you:\n${lines}\n${ctx}${again}\nAct on what's addressed to you, then end your turn.\n\n${RULES}`;
|
|
600
627
|
await loadLessons();
|
|
628
|
+
const lessons = pickLessons(LESSONS_RAW, wakeCapped.text + " " + bcastCapped.text);
|
|
601
629
|
const trigger = wake.some(m => m.to === SESSION) ? "direct message" : "@mention";
|
|
602
630
|
// Who is owed an answer, captured BEFORE the turn: pendingWake is cleared on success.
|
|
603
631
|
const assigners = [];
|
|
604
632
|
for (const m of wake) if (m.from && !assigners.some(a => a.from === m.from)) assigners.push({ from: m.from, id: m.id });
|
|
605
633
|
const asked = String(wake[0]?.text || "").replace(/\s+/g, " ").trim().slice(0, 90);
|
|
606
634
|
const tStart = Date.now();
|
|
607
|
-
const
|
|
635
|
+
const prompt = composedTurn({
|
|
636
|
+
wakeText, ctxText, againText,
|
|
637
|
+
tailText: "\nAct on what's addressed to you, then end your turn.\n\n",
|
|
638
|
+
rulesText: RULES, lessons,
|
|
639
|
+
});
|
|
640
|
+
const ec = runTurn(prompt, false, deliveryFails ? `${trigger} (redelivery)` : trigger);
|
|
608
641
|
const secs = Math.round((Date.now() - tStart) / 1000);
|
|
609
642
|
if (ec) {
|
|
610
643
|
deliveryFails++;
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -369,6 +369,23 @@ try {
|
|
|
369
369
|
}
|
|
370
370
|
} catch {}
|
|
371
371
|
|
|
372
|
+
// THE ORCHESTRATOR ROLE (2026-08-31 — the operator had to say it by hand, twice in one day:
|
|
373
|
+
// "your job is to oversee and be a project manager, not a coder"). A session opened by
|
|
374
|
+
// `trantor open` carries TRANTOR_ORCH=<project>; every such session gets the doctrine at
|
|
375
|
+
// boot, so the role survives wakes, handoffs and restarts without anyone restating it.
|
|
376
|
+
try {
|
|
377
|
+
if (project && (process.env.TRANTOR_ORCH || "") === project) {
|
|
378
|
+
additionalContext += `<trantor-orchestrator-role project="${sanitize(project)}">\n` +
|
|
379
|
+
`🎛️ **You are this project's ORCHESTRATOR — a project manager, not a coder** (operator ruling, 2026-08-31).\n` +
|
|
380
|
+
`- Building goes to the CREW: invoke the trantor:crew skill (Advisor → \`trantor up\` seats → contracts over the bus), or relay_scrooge for grunt one-shots.\n` +
|
|
381
|
+
`- Spend your own tokens on design, contracts, supervision, integration and VERIFICATION — run the seats' tests yourself; bounce hollow dones.\n` +
|
|
382
|
+
`- Write code yourself ONLY for small one-head seam work that needs this session's full context — and say so when you do.\n` +
|
|
383
|
+
`- Check relay_inbox and the board before asking the operator anything a peer may already have answered.\n` +
|
|
384
|
+
`</trantor-orchestrator-role>\n`;
|
|
385
|
+
process.stderr.write(`[trantor] injected orchestrator-role doctrine for ${project}\n`);
|
|
386
|
+
}
|
|
387
|
+
} catch {}
|
|
388
|
+
|
|
372
389
|
// Update available? Surface it the way a terminal tool should — an in-terminal `systemMessage`
|
|
373
390
|
// line the USER sees at session start (NOT a macOS desktop popup, which macOS misattributes to
|
|
374
391
|
// Script Editor and which fires off-screen). It shows every session while an update is pending and
|
package/hub.mjs
CHANGED
|
@@ -435,7 +435,12 @@ setTimeout(overseerTick, 2000).unref?.();
|
|
|
435
435
|
// Env still wins at boot (an operator's declared config beats a seat's claim); otherwise the last
|
|
436
436
|
// registered seat is restored from state, so a hub restart doesn't silently end the duty feed.
|
|
437
437
|
let DUTY_SESSION = String(process.env.RELAY_DUTY_SESSION || state.dutySession || "");
|
|
438
|
-
|
|
438
|
+
// 2 MINUTES, not 10 (2026-08-31): scribe DMed the woken crebral-health session at 16:11 and the
|
|
439
|
+
// operator hand-relayed at 16:21:58 — beating the old 10m escalation by seconds. Two agents
|
|
440
|
+
// actively collaborating cannot wait ten minutes; with duty's direct-wake the full chain
|
|
441
|
+
// (escalate → duty nudge → target's hooks poll) now lands in ~3m. Duty's own batch rules
|
|
442
|
+
// (one nudge per recipient per batch, consumed on activity) keep the shorter window from nagging.
|
|
443
|
+
const DUTY_UNDELIVERED_MS = Number(process.env.RELAY_DUTY_UNDELIVERED_MS || 2 * 60 * 1000);
|
|
439
444
|
const dutyEscalated = new Set();
|
|
440
445
|
// #5686: the janitor died 08-27 and NOTHING noticed for 4 days — the hub kept escalating to a
|
|
441
446
|
// corpse. Duty liveness is now a first-class state: dark = configured but no heartbeat inside
|
|
@@ -1908,6 +1913,22 @@ const server = http.createServer(async (req, res) => {
|
|
|
1908
1913
|
if (req.method === "POST" && P === "/task/update") { // move/edit a card
|
|
1909
1914
|
const b = await body(req); const t = state.tasks.find(x => x.id === Number(b.id));
|
|
1910
1915
|
if (!t) return json(res, 404, { error: "no such task" });
|
|
1916
|
+
// Board integrity (#5406): a card can never change hands silently. The assignee is frozen once
|
|
1917
|
+
// set; a mutation is legitimate only as a HANDOFF (the current assignee reassigning to someone
|
|
1918
|
+
// else) or an EXPLICIT reassign (reassign:true — e.g. the orchestrator re-routing work after a
|
|
1919
|
+
// seat dies). A silent third-party overwrite 409s so the caller knows the board refused to move.
|
|
1920
|
+
// Runs BEFORE any other field mutation so a refused steal cannot half-apply a status move.
|
|
1921
|
+
if (b.assignee !== undefined) {
|
|
1922
|
+
const want = String(b.assignee).slice(0, 60);
|
|
1923
|
+
if (want !== t.assignee) {
|
|
1924
|
+
const mover = String(auth?.identity?.name || b.by || "").slice(0, 120);
|
|
1925
|
+
const isOwner = !!t.assignee && mover === t.assignee;
|
|
1926
|
+
const explicit = b.reassign === true;
|
|
1927
|
+
if (!isOwner && !explicit) {
|
|
1928
|
+
return json(res, 409, { error: "assignee is immutable", id: t.id, assignee: t.assignee });
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1911
1932
|
let eventType = "updated", eventFrom = null, eventTo = null;
|
|
1912
1933
|
if (b.status && ["todo","doing","testing","failed","done","blocked","stale"].includes(b.status) && b.status !== t.status) {
|
|
1913
1934
|
eventType = "moved"; eventFrom = t.status; eventTo = b.status;
|
|
@@ -1925,7 +1946,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
1925
1946
|
if (b.difficulty && ["easy","medium","hard"].includes(b.difficulty)) t.difficulty = b.difficulty;
|
|
1926
1947
|
if (b.model !== undefined) t.model = String(b.model).slice(0, 60);
|
|
1927
1948
|
if (Array.isArray(b.deps)) t.deps = [...new Set(b.deps.map(Number).filter(n => Number.isInteger(n) && n > 0 && n !== t.id))].slice(0, 20);
|
|
1928
|
-
if (b.assignee !== undefined
|
|
1949
|
+
if (b.assignee !== undefined && String(b.assignee).slice(0, 60) !== t.assignee) {
|
|
1950
|
+
const prev = t.assignee || "(none)";
|
|
1951
|
+
const mover = String(auth?.identity?.name || b.by || "").slice(0, 120);
|
|
1952
|
+
t.assignee = String(b.assignee).slice(0, 60);
|
|
1953
|
+
// the handover is part of the card's story, not a silent overwrite
|
|
1954
|
+
appendTaskLog(t, mover, `reassigned ${prev} → ${t.assignee}${b.reassign === true ? " (explicit)" : " (handoff)"}`, now());
|
|
1955
|
+
}
|
|
1929
1956
|
if (b.title !== undefined) t.title = String(b.title).slice(0,200);
|
|
1930
1957
|
// the narrative line a human reads on the board ("assigned — did"), written by the cheap
|
|
1931
1958
|
// summarizer; rides the tasks.extra column, so it survives restarts everywhere
|