@xhulz/nina 0.51.0 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/package.json +1 -1
- package/releases/0.52.0/core/GAPS.md +125 -0
- package/releases/0.52.0/core/answered.json +68 -0
- package/releases/0.52.0/core/defaults/tree/.claude/agents/architect.md +1 -0
- package/releases/0.52.0/core/defaults/tree/.claude/agents/dba.md +3 -0
- package/releases/0.52.0/core/defaults/tree/.claude/agents/devops.md +1 -0
- package/releases/0.52.0/core/defaults/tree/.claude/agents/implementer.md +1 -0
- package/releases/0.52.0/core/defaults/tree/.claude/agents/integration-tester.md +3 -0
- package/releases/0.52.0/core/defaults/tree/.claude/agents/qa.md +7 -0
- package/releases/0.52.0/core/defaults/tree/.claude/agents/reviewer.md +3 -0
- package/releases/0.52.0/core/defaults/tree/.claude/agents/secops.md +3 -0
- package/releases/0.52.0/core/defaults/tree/.claude/agents/solidity-auditor.md +3 -0
- package/releases/0.52.0/core/defaults/tree/.claude/agents/solidity-dev.md +3 -0
- package/releases/0.52.0/core/tree/.claude/agents/architect.md +129 -0
- package/releases/0.52.0/core/tree/.claude/agents/dba.md +117 -0
- package/releases/0.52.0/core/tree/.claude/agents/devops.md +118 -0
- package/releases/0.52.0/core/tree/.claude/agents/implementer.md +163 -0
- package/releases/0.52.0/core/tree/.claude/agents/integration-tester.md +168 -0
- package/releases/0.52.0/core/tree/.claude/agents/planner.md +132 -0
- package/releases/0.52.0/core/tree/.claude/agents/qa.md +150 -0
- package/releases/0.52.0/core/tree/.claude/agents/reviewer.md +184 -0
- package/releases/0.52.0/core/tree/.claude/agents/secops.md +120 -0
- package/releases/0.52.0/core/tree/.claude/agents/solidity-auditor.md +126 -0
- package/releases/0.52.0/core/tree/.claude/agents/solidity-dev.md +123 -0
- package/releases/0.52.0/core/tree/.claude/agents-overview.md +64 -0
- package/releases/0.52.0/core/tree/.claude/frontend.md +10 -0
- package/releases/0.52.0/core/tree/.claude/graph.md +75 -0
- package/releases/0.52.0/core/tree/.claude/patterns.md +251 -0
- package/releases/0.52.0/core/tree/.claude/pills/README.md +150 -0
- package/releases/0.52.0/core/tree/.claude/pipeline.md +101 -0
- package/releases/0.52.0/core/tree/.claude/retrieval.md +103 -0
- package/releases/0.52.0/core/tree/.claude/router.md +202 -0
- package/releases/0.52.0/core/tree/.claude/templates/integration.md +46 -0
- package/releases/0.52.0/core/tree/CLAUDE.md +164 -0
- package/releases/0.52.0/core/tree/scripts/cost-watch.mjs +25 -0
- package/releases/0.52.0/core/tree/scripts/edit-guard.mjs +24 -0
- package/releases/0.52.0/core/tree/scripts/harness-check.mjs +71 -0
- package/releases/0.52.0/core/tree/scripts/loop-gate.mjs +19 -0
- package/releases/0.52.0/core/vocabulary.json +13 -0
- package/releases/0.52.0/surfaces/blockchain/tree/.claude/agents/architect.md +5 -0
- package/releases/0.52.0/surfaces/blockchain/tree/.claude/agents/devops.md +5 -0
- package/releases/0.52.0/surfaces/blockchain/tree/.claude/agents/reviewer.md +5 -0
- package/releases/0.52.0/surfaces/blockchain/tree/.claude/agents/solidity-auditor.md +18 -0
- package/releases/0.52.0/surfaces/blockchain/tree/.claude/agents/solidity-dev.md +16 -0
- package/releases/0.52.0/surfaces/blockchain/tree/.claude/agents-overview.md +6 -0
- package/releases/0.52.0/surfaces/blockchain/tree/.claude/graph.md +11 -0
- package/releases/0.52.0/surfaces/blockchain/tree/.claude/router.md +6 -0
- package/releases/0.52.0/surfaces/blockchain/tree/CLAUDE.md +12 -0
- package/releases/0.52.0/surfaces/d1/tree/.claude/agents/dba.md +15 -0
- package/releases/0.52.0/surfaces/d1/tree/.claude/agents/devops.md +2 -0
- package/releases/0.52.0/surfaces/d1/tree/.claude/agents/implementer.md +2 -0
- package/releases/0.52.0/surfaces/d1/tree/.claude/patterns.md +8 -0
- package/releases/0.52.0/surfaces/d1/tree/CLAUDE.md +2 -0
- package/releases/0.52.0/surfaces/db/tree/.claude/agents/architect.md +5 -0
- package/releases/0.52.0/surfaces/db/tree/.claude/agents/dba.md +54 -0
- package/releases/0.52.0/surfaces/db/tree/.claude/agents/devops.md +5 -0
- package/releases/0.52.0/surfaces/db/tree/.claude/agents/implementer.md +2 -0
- package/releases/0.52.0/surfaces/db/tree/.claude/agents/integration-tester.md +10 -0
- package/releases/0.52.0/surfaces/db/tree/.claude/agents/planner.md +2 -0
- package/releases/0.52.0/surfaces/db/tree/.claude/agents/reviewer.md +22 -0
- package/releases/0.52.0/surfaces/db/tree/.claude/agents/secops.md +2 -0
- package/releases/0.52.0/surfaces/db/tree/.claude/agents-overview.md +5 -0
- package/releases/0.52.0/surfaces/db/tree/.claude/graph.md +8 -0
- package/releases/0.52.0/surfaces/db/tree/.claude/patterns.md +22 -0
- package/releases/0.52.0/surfaces/db/tree/.claude/pipeline.md +13 -0
- package/releases/0.52.0/surfaces/db/tree/.claude/retrieval.md +5 -0
- package/releases/0.52.0/surfaces/db/tree/.claude/router.md +4 -0
- package/releases/0.52.0/surfaces/db/tree/CLAUDE.md +17 -0
- package/releases/0.52.0/surfaces/drizzle/tree/.claude/agents/dba.md +9 -0
- package/releases/0.52.0/surfaces/drizzle/tree/.claude/agents/devops.md +5 -0
- package/releases/0.52.0/surfaces/drizzle/tree/.claude/agents/implementer.md +2 -0
- package/releases/0.52.0/surfaces/drizzle/tree/.claude/patterns.md +7 -0
- package/releases/0.52.0/surfaces/edge-cf/tree/.claude/agents/architect.md +14 -0
- package/releases/0.52.0/surfaces/edge-cf/tree/.claude/agents/dba.md +2 -0
- package/releases/0.52.0/surfaces/edge-cf/tree/.claude/agents/devops.md +29 -0
- package/releases/0.52.0/surfaces/edge-cf/tree/.claude/agents/implementer.md +14 -0
- package/releases/0.52.0/surfaces/edge-cf/tree/.claude/agents/integration-tester.md +11 -0
- package/releases/0.52.0/surfaces/edge-cf/tree/.claude/agents/qa.md +3 -0
- package/releases/0.52.0/surfaces/edge-cf/tree/.claude/agents/reviewer.md +8 -0
- package/releases/0.52.0/surfaces/edge-cf/tree/.claude/agents/secops.md +8 -0
- package/releases/0.52.0/surfaces/edge-cf/tree/.claude/patterns.md +17 -0
- package/releases/0.52.0/surfaces/edge-cf/tree/.claude/retrieval.md +8 -0
- package/releases/0.52.0/surfaces/edge-cf/tree/CLAUDE.md +27 -0
- package/releases/0.52.0/surfaces/frontend/tree/.claude/agents/architect.md +20 -0
- package/releases/0.52.0/surfaces/frontend/tree/.claude/agents/devops.md +28 -0
- package/releases/0.52.0/surfaces/frontend/tree/.claude/agents/implementer.md +25 -0
- package/releases/0.52.0/surfaces/frontend/tree/.claude/agents/qa.md +17 -0
- package/releases/0.52.0/surfaces/frontend/tree/.claude/agents/reviewer.md +56 -0
- package/releases/0.52.0/surfaces/frontend/tree/.claude/patterns.md +8 -0
- package/releases/0.52.0/surfaces/frontend/tree/.claude/retrieval.md +2 -0
- package/releases/0.52.0/surfaces/frontend/tree/.claude/router.md +14 -0
- package/releases/0.52.0/surfaces/frontend/tree/CLAUDE.md +5 -0
- package/releases/0.52.0/surfaces/integrations/tree/.claude/agents/architect.md +16 -0
- package/releases/0.52.0/surfaces/integrations/tree/.claude/agents/implementer.md +12 -0
- package/releases/0.52.0/surfaces/integrations/tree/.claude/agents/integration-tester.md +20 -0
- package/releases/0.52.0/surfaces/integrations/tree/.claude/agents/planner.md +2 -0
- package/releases/0.52.0/surfaces/integrations/tree/.claude/agents/reviewer.md +21 -0
- package/releases/0.52.0/surfaces/integrations/tree/.claude/agents/secops.md +2 -0
- package/releases/0.52.0/surfaces/integrations/tree/.claude/agents-overview.md +8 -0
- package/releases/0.52.0/surfaces/integrations/tree/.claude/graph.md +10 -0
- package/releases/0.52.0/surfaces/integrations/tree/.claude/patterns.md +38 -0
- package/releases/0.52.0/surfaces/integrations/tree/.claude/pipeline.md +16 -0
- package/releases/0.52.0/surfaces/integrations/tree/.claude/retrieval.md +16 -0
- package/releases/0.52.0/surfaces/integrations/tree/.claude/router.md +6 -0
- package/releases/0.52.0/surfaces/integrations/tree/CLAUDE.md +17 -0
- package/releases/0.52.0/surfaces/money/tree/.claude/agents/architect.md +12 -0
- package/releases/0.52.0/surfaces/money/tree/.claude/agents/dba.md +14 -0
- package/releases/0.52.0/surfaces/money/tree/.claude/agents/implementer.md +11 -0
- package/releases/0.52.0/surfaces/money/tree/.claude/agents/integration-tester.md +3 -0
- package/releases/0.52.0/surfaces/money/tree/.claude/agents/reviewer.md +23 -0
- package/releases/0.52.0/surfaces/money/tree/.claude/agents/secops.md +5 -0
- package/releases/0.52.0/surfaces/money/tree/.claude/agents/solidity-auditor.md +4 -0
- package/releases/0.52.0/surfaces/money/tree/.claude/agents/solidity-dev.md +4 -0
- package/releases/0.52.0/surfaces/money/tree/.claude/graph.md +2 -0
- package/releases/0.52.0/surfaces/money/tree/.claude/patterns.md +49 -0
- package/releases/0.52.0/surfaces/money/tree/.claude/pipeline.md +2 -0
- package/releases/0.52.0/surfaces/money/tree/.claude/retrieval.md +2 -0
- package/releases/0.52.0/surfaces/money/tree/CLAUDE.md +11 -0
- package/releases/0.52.0/surfaces/pii/tree/.claude/agents/dba.md +2 -0
- package/releases/0.52.0/surfaces/pii/tree/.claude/agents/devops.md +8 -0
- package/releases/0.52.0/surfaces/pii/tree/.claude/agents/reviewer.md +8 -0
- package/releases/0.52.0/surfaces/pii/tree/.claude/agents/secops.md +2 -0
- package/releases/0.52.0/surfaces/pii/tree/.claude/agents/solidity-auditor.md +5 -0
- package/releases/0.52.0/surfaces/pii/tree/CLAUDE.md +5 -0
- package/releases/0.52.0/surfaces/prisma/tree/.claude/agents/architect.md +2 -0
- package/releases/0.52.0/surfaces/prisma/tree/.claude/agents/dba.md +42 -0
- package/releases/0.52.0/surfaces/prisma/tree/.claude/agents/devops.md +11 -0
- package/releases/0.52.0/surfaces/prisma/tree/.claude/agents/implementer.md +2 -0
- package/releases/0.52.0/surfaces/prisma/tree/.claude/agents/integration-tester.md +2 -0
- package/releases/0.52.0/surfaces/prisma/tree/.claude/agents/reviewer.md +2 -0
- package/releases/0.52.0/surfaces/prisma/tree/.claude/patterns.md +15 -0
- package/releases/0.52.0/surfaces/prisma/tree/CLAUDE.md +5 -0
- package/src/commands/stats.mjs +28 -8
- package/src/gate.mjs +14 -2
- package/src/office.html +142 -4
- package/src/office.mjs +144 -21
- package/src/store.mjs +13 -1
package/src/office.html
CHANGED
|
@@ -14,15 +14,41 @@
|
|
|
14
14
|
#zoom button { font: 600 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; min-width: 34px; height: 30px; padding: 0 9px; color: #fff8e8; background: rgba(34, 32, 43, 0.88); border: 2px solid #262331; border-radius: 4px; cursor: pointer; }
|
|
15
15
|
#zoom button:hover { background: #3a3f58; }
|
|
16
16
|
#zoom button[aria-pressed="true"] { color: #ffd34d; }
|
|
17
|
+
#tasks { position: fixed; top: 0; right: 0; bottom: 0; width: 300px; box-sizing: border-box; padding: 16px 16px 24px 18px; overflow-y: auto; color: #e6e8ee; background: #22202b; border-left: 2px solid #262331; font: 13px/1.35 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
18
|
+
#tasks.overlay { top: 50px; width: min(300px, 88vw); border-top: 2px solid #262331; box-shadow: -6px 6px 18px rgba(0, 0, 0, 0.4); }
|
|
19
|
+
#tasks[hidden] { display: none; }
|
|
20
|
+
#tasks h2 { margin: 0; font: 600 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; letter-spacing: 0.08em; text-transform: uppercase; color: #ffd34d; }
|
|
21
|
+
#tasks .sub { margin: 2px 0 16px; color: #8a8fa8; font-size: 12px; }
|
|
22
|
+
#tasks .empty { color: #8a8fa8; margin: 0; }
|
|
23
|
+
#tasks ol { list-style: none; margin: 0; padding: 0 0 0 14px; border-left: 2px solid #3a3f58; }
|
|
24
|
+
#tasks li { position: relative; margin: 0 0 16px; padding-left: 8px; }
|
|
25
|
+
#tasks li::before { content: ''; position: absolute; left: -21px; top: 3px; width: 10px; height: 10px; border-radius: 50%; background: #6b7089; border: 2px solid #22202b; }
|
|
26
|
+
#tasks li.running::before { background: #ffd34d; animation: beat 1.2s ease-in-out infinite; }
|
|
27
|
+
#tasks li.idle::before { background: #c9a227; }
|
|
28
|
+
#tasks li.done::before { background: #3ec74d; }
|
|
29
|
+
#tasks .name { font-weight: 600; color: #fff8e8; overflow-wrap: anywhere; }
|
|
30
|
+
#tasks .when { margin-top: 3px; color: #a9adbf; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
31
|
+
#tasks .who { margin-top: 3px; color: #c9cde0; font-size: 12px; overflow-wrap: anywhere; }
|
|
32
|
+
#tasks .now { margin-top: 2px; color: #ffd34d; font-size: 12px; }
|
|
33
|
+
#tasks .now.quiet { color: #8a8fa8; }
|
|
34
|
+
@keyframes beat { 50% { opacity: 0.35; } }
|
|
35
|
+
@media (prefers-reduced-motion: reduce) { #tasks li.running::before { animation: none; } }
|
|
17
36
|
</style>
|
|
18
37
|
</head>
|
|
19
38
|
<body>
|
|
20
39
|
<canvas id="screen"></canvas>
|
|
21
40
|
<div id="zoom" role="toolbar" aria-label="zoom">
|
|
41
|
+
<button id="tasks-toggle" type="button" title="the session's tasks (t)" aria-controls="tasks">tasks</button>
|
|
22
42
|
<button id="zoom-out" type="button" title="zoom out (−)" aria-label="zoom out">−</button>
|
|
23
43
|
<button id="zoom-fit" type="button" title="the whole office (0)">fit</button>
|
|
24
44
|
<button id="zoom-in" type="button" title="zoom in (+)" aria-label="zoom in">+</button>
|
|
25
45
|
</div>
|
|
46
|
+
<aside id="tasks" aria-label="the session's tasks" hidden>
|
|
47
|
+
<h2>Tasks</h2>
|
|
48
|
+
<p class="sub">this session, newest first</p>
|
|
49
|
+
<p id="task-empty" class="empty">Nothing yet: the first dispatch starts a task.</p>
|
|
50
|
+
<ol id="task-list"></ol>
|
|
51
|
+
</aside>
|
|
26
52
|
<script>
|
|
27
53
|
'use strict';
|
|
28
54
|
/*
|
|
@@ -525,6 +551,7 @@ function settleCap() {
|
|
|
525
551
|
}
|
|
526
552
|
|
|
527
553
|
function onBeat(b) {
|
|
554
|
+
if (b.type === 'work' || b.type === 'verdict') lastActivity = Math.max(lastActivity, b.at);
|
|
528
555
|
const stage = b.role ? stages.get(b.role) : null;
|
|
529
556
|
if (b.type === 'work') {
|
|
530
557
|
if (!stage) return;
|
|
@@ -687,6 +714,9 @@ function reset() {
|
|
|
687
714
|
place(p, p.home);
|
|
688
715
|
}
|
|
689
716
|
lines.length = 0;
|
|
717
|
+
tasks = [];
|
|
718
|
+
lastActivity = 0;
|
|
719
|
+
drawTasks();
|
|
690
720
|
confetti = [];
|
|
691
721
|
banner = null;
|
|
692
722
|
capRole = null;
|
|
@@ -704,6 +734,10 @@ events.addEventListener('hello', (m) => {
|
|
|
704
734
|
status = hello.session ? (hello.replay ? 'replay' : 'live') : 'waiting for the first dispatch';
|
|
705
735
|
});
|
|
706
736
|
events.addEventListener('beat', (m) => onBeat(JSON.parse(m.data)));
|
|
737
|
+
events.addEventListener('timeline', (m) => {
|
|
738
|
+
tasks = JSON.parse(m.data).tasks ?? [];
|
|
739
|
+
drawTasks();
|
|
740
|
+
});
|
|
707
741
|
events.addEventListener('end', () => (status = 'replay finished — reload to watch it again'));
|
|
708
742
|
events.onerror = () => {
|
|
709
743
|
if (!status.startsWith('replay finished')) status = 'reconnecting…';
|
|
@@ -808,11 +842,114 @@ function syncButtons() {
|
|
|
808
842
|
document.getElementById('zoom-fit').setAttribute('aria-pressed', String(level() <= (fit / dpr) * 1.001));
|
|
809
843
|
}
|
|
810
844
|
|
|
845
|
+
// ─── the timeline beside the room ─────────────────────────────────────────────────────────────
|
|
846
|
+
/*
|
|
847
|
+
* The session's work, task by task, as the server cuts it — the way `nina runs` does: each named by the words the
|
|
848
|
+
* orchestrator gave its first dispatch, with the stages that worked on it, from when that went out to the verdict
|
|
849
|
+
* that closed it. The server sends the whole list whenever it changes, and it replaces what is here. A task still
|
|
850
|
+
* open counts up while anyone is at work on it, and stops at the last thing that happened while nobody is.
|
|
851
|
+
*/
|
|
852
|
+
const SIDE = 300;
|
|
853
|
+
/** Narrower than this, the timeline is drawn over the room, and starts hidden. */
|
|
854
|
+
const NARROW = 760;
|
|
855
|
+
const TASKS_KEY = 'nina-office-tasks';
|
|
856
|
+
let tasks = [];
|
|
857
|
+
/** The newest dispatch or verdict: a replay's clock, and where an idle task's count stops. */
|
|
858
|
+
let lastActivity = 0;
|
|
859
|
+
let tasksOpen = (() => {
|
|
860
|
+
try {
|
|
861
|
+
const kept = localStorage.getItem(TASKS_KEY);
|
|
862
|
+
if (kept !== null) return kept === 'open';
|
|
863
|
+
} catch {
|
|
864
|
+
// Kept for this page only.
|
|
865
|
+
}
|
|
866
|
+
return window.innerWidth >= NARROW;
|
|
867
|
+
})();
|
|
868
|
+
|
|
869
|
+
/** The stages someone is at work for now. */
|
|
870
|
+
const atWork = () => [...new Set([...people.values()].filter((p) => p.work && !p.reported && !p.leaving).map((p) => p.role))];
|
|
871
|
+
|
|
872
|
+
const two = (n) => String(n).padStart(2, '0');
|
|
873
|
+
/** A moment as the hour, with the day when it is not the day of `beside` — now, or the moment it follows. */
|
|
874
|
+
function stamp(at, beside) {
|
|
875
|
+
const t = new Date(at);
|
|
876
|
+
const hm = `${two(t.getHours())}:${two(t.getMinutes())}`;
|
|
877
|
+
return t.toDateString() === new Date(beside).toDateString() ? hm : `${t.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${hm}`;
|
|
878
|
+
}
|
|
879
|
+
/** A finished length of time: 2h05m, 41m, 50s. */
|
|
880
|
+
function span(ms) {
|
|
881
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
882
|
+
if (s < 60) return `${s}s`;
|
|
883
|
+
const m = Math.round(s / 60);
|
|
884
|
+
return m >= 60 ? `${Math.floor(m / 60)}h${two(m % 60)}m` : `${m}m`;
|
|
885
|
+
}
|
|
886
|
+
/** A running count: 1:27:04, 27:04. */
|
|
887
|
+
function counter(ms) {
|
|
888
|
+
const s = Math.max(0, Math.floor(ms / 1000));
|
|
889
|
+
const h = Math.floor(s / 3600);
|
|
890
|
+
return h > 0 ? `${h}:${two(Math.floor((s % 3600) / 60))}:${two(s % 60)}` : `${Math.floor(s / 60)}:${two(s % 60)}`;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function drawTasks() {
|
|
894
|
+
if (!tasksOpen) return;
|
|
895
|
+
const clock = status.startsWith('replay') ? lastActivity : Date.now();
|
|
896
|
+
const working = atWork();
|
|
897
|
+
const line = (cls, text) => {
|
|
898
|
+
const el = document.createElement('div');
|
|
899
|
+
el.className = cls;
|
|
900
|
+
el.textContent = text;
|
|
901
|
+
return el;
|
|
902
|
+
};
|
|
903
|
+
const shown = [...tasks].sort((a, b) => b.start - a.start);
|
|
904
|
+
document.getElementById('task-list').replaceChildren(
|
|
905
|
+
...shown.map((t) => {
|
|
906
|
+
const li = document.createElement('li');
|
|
907
|
+
const running = t.end === null;
|
|
908
|
+
const idle = running && working.length === 0;
|
|
909
|
+
li.className = running ? (idle ? 'idle' : 'running') : t.how === 'closed' ? 'done' : 'ended';
|
|
910
|
+
li.append(line('name', t.name || '(no description)'));
|
|
911
|
+
if (t.who?.length > 0) li.append(line('who', t.who.map(([role, n]) => (n > 1 ? `${role} ×${n}` : role)).join(' · ')));
|
|
912
|
+
if (running) {
|
|
913
|
+
const upTo = idle ? Math.max(t.start, lastActivity) : Math.max(t.start, clock);
|
|
914
|
+
li.append(line('when', `${stamp(t.start, clock)} → now · ${counter(upTo - t.start)}`));
|
|
915
|
+
li.append(line('now', idle ? `idle since ${stamp(lastActivity, clock)}` : `at work: ${working.join(', ')}`));
|
|
916
|
+
} else {
|
|
917
|
+
li.append(line('when', `${stamp(t.start, clock)} → ${stamp(t.end, t.start)} · ${span(t.end - t.start)}`));
|
|
918
|
+
// A task that waited — overnight, on the owner — says how much of it anyone was at work.
|
|
919
|
+
if (t.busy !== null && t.busy < (t.end - t.start) * 0.8) li.append(line('now quiet', `${span(t.busy)} at work`));
|
|
920
|
+
}
|
|
921
|
+
return li;
|
|
922
|
+
}),
|
|
923
|
+
);
|
|
924
|
+
document.getElementById('task-empty').hidden = tasks.length > 0;
|
|
925
|
+
}
|
|
926
|
+
setInterval(drawTasks, 1000);
|
|
927
|
+
|
|
928
|
+
function showTasks(open) {
|
|
929
|
+
tasksOpen = open;
|
|
930
|
+
try {
|
|
931
|
+
localStorage.setItem(TASKS_KEY, open ? 'open' : 'closed');
|
|
932
|
+
} catch {
|
|
933
|
+
// Kept for this page only.
|
|
934
|
+
}
|
|
935
|
+
resize();
|
|
936
|
+
drawTasks();
|
|
937
|
+
}
|
|
938
|
+
document.getElementById('tasks-toggle').addEventListener('click', () => showTasks(!tasksOpen));
|
|
939
|
+
|
|
811
940
|
function resize() {
|
|
812
941
|
dpr = window.devicePixelRatio || 1;
|
|
813
|
-
|
|
942
|
+
// The timeline takes its width from the room on a wide window, and is drawn over it on a narrow one.
|
|
943
|
+
const overlay = window.innerWidth < NARROW;
|
|
944
|
+
const side = tasksOpen && !overlay ? SIDE : 0;
|
|
945
|
+
const aside = document.getElementById('tasks');
|
|
946
|
+
aside.hidden = !tasksOpen;
|
|
947
|
+
aside.classList.toggle('overlay', overlay);
|
|
948
|
+
document.getElementById('zoom').style.right = `${side + 10}px`;
|
|
949
|
+
document.getElementById('tasks-toggle').setAttribute('aria-pressed', String(tasksOpen));
|
|
950
|
+
screen.width = Math.floor((window.innerWidth - side) * dpr);
|
|
814
951
|
screen.height = Math.floor(window.innerHeight * dpr);
|
|
815
|
-
screen.style.width = `${window.innerWidth}px`;
|
|
952
|
+
screen.style.width = `${window.innerWidth - side}px`;
|
|
816
953
|
screen.style.height = `${window.innerHeight}px`;
|
|
817
954
|
band = Math.round(BAND * dpr);
|
|
818
955
|
fit = Math.min(screen.width / LW, (screen.height - band) / LH);
|
|
@@ -849,6 +986,7 @@ window.addEventListener('keydown', (e) => {
|
|
|
849
986
|
if (e.key === '+' || e.key === '=') zoomBy(1.25);
|
|
850
987
|
else if (e.key === '-' || e.key === '_') zoomBy(0.8);
|
|
851
988
|
else if (e.key === '0') zoomBy(0);
|
|
989
|
+
else if (e.key === 't') showTasks(!tasksOpen);
|
|
852
990
|
});
|
|
853
991
|
let drag = null;
|
|
854
992
|
screen.addEventListener('pointerdown', (e) => {
|
|
@@ -1059,13 +1197,13 @@ function drawScreen(now) {
|
|
|
1059
1197
|
const size = ui(12);
|
|
1060
1198
|
{
|
|
1061
1199
|
// Whether anything is going on: between dispatches the room is still, and says so.
|
|
1062
|
-
const working =
|
|
1200
|
+
const working = atWork();
|
|
1063
1201
|
const going = status === 'live' || status === 'replay' ? (working.length > 0 ? ` · at work: ${working.join(', ')}` : ' · waiting for the next dispatch') : '';
|
|
1064
1202
|
const title = `NINA office · ${ROOM.project}${session ? ` · ${session.slice(0, 8)}` : ''} · ${status}${going}`;
|
|
1065
1203
|
s.font = `${size}px ${MONO}`;
|
|
1066
1204
|
s.fillStyle = '#1b1d26';
|
|
1067
1205
|
s.fillRect(0, 0, screen.width, band);
|
|
1068
|
-
const width = Math.min(s.measureText(title).width + ui(16), screen.width - ui(
|
|
1206
|
+
const width = Math.min(s.measureText(title).width + ui(16), screen.width - ui(230));
|
|
1069
1207
|
panel(ui(10), ui(9), width, ui(30));
|
|
1070
1208
|
text(title, ui(18), ui(24), size, '#e6e8ee', 'left', width - ui(16));
|
|
1071
1209
|
}
|
package/src/office.mjs
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* `nina pipeline --view` — the pipeline as an office seen from above, drawn in a browser page.
|
|
3
3
|
*
|
|
4
4
|
* Each stage of the project's composed graph has a desk, and each agent the loop gate saw launched is someone
|
|
5
|
-
* sitting at one. The gate's ledger is
|
|
6
|
-
*
|
|
5
|
+
* sitting at one. The gate's ledger is what is read, and it holds metadata only, so the office shows who worked,
|
|
6
|
+
* what they answered, the ids of the issues they named and the few words the orchestrator gave each dispatch —
|
|
7
|
+
* never what an agent wrote or reported. Beside the room, a timeline of the session's tasks (`officeTimeline`), cut
|
|
8
|
+
* and named as `nina runs` cuts and names them:
|
|
7
9
|
*
|
|
8
10
|
* a dispatch someone sits down to work, the folder carried over by whoever handed it on
|
|
9
11
|
* a verdict a check over their head, or a cross when it sends work back
|
|
@@ -28,6 +30,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
28
30
|
import { TERMINALS, parseGraph } from './graph.mjs';
|
|
29
31
|
import { readLedger, sessionLedgers } from './gate.mjs';
|
|
30
32
|
import { readProfile } from './paths.mjs';
|
|
33
|
+
import { projectStoreFile, readStoreFile } from './store.mjs';
|
|
34
|
+
import { IN_FLIGHT, cyclesOf } from './commands/stats.mjs';
|
|
31
35
|
import { isLoopBack } from './transcripts.mjs';
|
|
32
36
|
import { shapeOf } from './commands/pipeline.mjs';
|
|
33
37
|
|
|
@@ -64,26 +68,38 @@ const REFUSED_AFTER_MS = 5000;
|
|
|
64
68
|
/** How often a live office looks at the ledger. The gate appends to it as each fact happens. */
|
|
65
69
|
const POLL_MS = 700;
|
|
66
70
|
|
|
71
|
+
/**
|
|
72
|
+
* How long anyone was at work on a piece of work: its runs' spans joined, so runs side by side count once and a
|
|
73
|
+
* night between two of them not at all — a piece begun one evening and closed the next morning was at work two hours.
|
|
74
|
+
*
|
|
75
|
+
* @param {{at: number, end: number|null}[]} starts - Its runs.
|
|
76
|
+
* @returns {number} Milliseconds.
|
|
77
|
+
*/
|
|
78
|
+
export function busyFor(starts) {
|
|
79
|
+
const spans = starts.map((s) => [s.at, Math.max(s.at, s.end ?? s.at)]).sort((a, b) => a[0] - b[0]);
|
|
80
|
+
let total = 0;
|
|
81
|
+
let open = null;
|
|
82
|
+
for (const [from, to] of spans) {
|
|
83
|
+
if (open && from <= open[1]) open[1] = Math.max(open[1], to);
|
|
84
|
+
else {
|
|
85
|
+
if (open) total += open[1] - open[0];
|
|
86
|
+
open = [from, to];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return open ? total + open[1] - open[0] : total;
|
|
90
|
+
}
|
|
91
|
+
|
|
67
92
|
/** The longest a replay waits between two beats, and the shortest: hours of a session play in minutes. */
|
|
68
93
|
const REPLAY_MAX_GAP_MS = 3000;
|
|
69
94
|
const REPLAY_MIN_GAP_MS = 500;
|
|
70
95
|
|
|
71
96
|
/**
|
|
72
|
-
*
|
|
97
|
+
* Each run the ledger saw start, in order: when, at which stage, by which agent once known, whether it was a message
|
|
98
|
+
* to a running agent, when it was refused, and when its verdict ended it. The beats and the timeline read the same.
|
|
73
99
|
*
|
|
74
|
-
*
|
|
75
|
-
* starts where its `pre` entry stands, joined to the agent by the tool_use id; until that entry is written, the
|
|
76
|
-
* agent is not known yet, and the page binds the next verdict from that desk to it. The work ends — a party — on a
|
|
77
|
-
* pass when nothing since the last dispatch was sent back, something since then could route to `done`, nobody is
|
|
78
|
-
* still at work, and no dispatch follows within `PARTY_QUIET_MS`; at once when the pass routes only to a terminal.
|
|
79
|
-
* A party that waits on the quiet is only beaten once `now` has passed it.
|
|
80
|
-
*
|
|
81
|
-
* @param {object[]} entries - The ledger, oldest first.
|
|
82
|
-
* @param {{edges: {from: string, to: string, token: string}[]}} graph - The project's parsed graph.
|
|
83
|
-
* @param {number} [now] - The time a waiting party is judged against; `Infinity` for a session that has ended.
|
|
84
|
-
* @returns {object[]} Beats, each `{id, at, type, ...}`; the id is stable as the ledger grows.
|
|
100
|
+
* @param {object[]} ledger - The ledger, oldest first.
|
|
85
101
|
*/
|
|
86
|
-
|
|
102
|
+
function readStarts(ledger) {
|
|
87
103
|
const entries = ledger.filter((e) => Number.isFinite(Date.parse(e?.at)));
|
|
88
104
|
const time = (e) => Date.parse(e.at);
|
|
89
105
|
const dispatches = new Map();
|
|
@@ -113,11 +129,28 @@ export function officeBeats(ledger, graph, now = Date.now()) {
|
|
|
113
129
|
const after = e.k === 'pre' && e.id && asked.has(e.id) && !dispatches.has(e.id) ? entries.slice(i + 1).find((x) => x.k === 'pre' && time(x) > at + REFUSED_AFTER_MS) : null;
|
|
114
130
|
starts.push({ i, at, role: e.role, agent: launched?.agent ?? null, message, refusedAt: after ? time(after) : null });
|
|
115
131
|
});
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
132
|
+
const verdictOf = (s) => verdicts.find((x) => time(x) >= s.at && (s.agent ? x.agent === s.agent : x.role === s.role)) ?? null;
|
|
133
|
+
for (const s of starts) s.end = s.message ? s.at : verdictOf(s) ? time(verdictOf(s)) : null;
|
|
134
|
+
return { entries, time, startsWork, launchOf, starts, verdictOf };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A session's ledger as the office's beats, in the order they happened.
|
|
139
|
+
*
|
|
140
|
+
* A foreground dispatch's `dispatch` entry is written when the agent has finished, after its own verdict, so work
|
|
141
|
+
* starts where its `pre` entry stands, joined to the agent by the tool_use id; until that entry is written, the
|
|
142
|
+
* agent is not known yet, and the page binds the next verdict from that desk to it. The work ends — a party — on a
|
|
143
|
+
* pass when nothing since the last dispatch was sent back, something since then could route to `done`, nobody is
|
|
144
|
+
* still at work, and no dispatch follows within `PARTY_QUIET_MS`; at once when the pass routes only to a terminal.
|
|
145
|
+
* A party that waits on the quiet is only beaten once `now` has passed it.
|
|
146
|
+
*
|
|
147
|
+
* @param {object[]} entries - The ledger, oldest first.
|
|
148
|
+
* @param {{edges: {from: string, to: string, token: string}[]}} graph - The project's parsed graph.
|
|
149
|
+
* @param {number} [now] - The time a waiting party is judged against; `Infinity` for a session that has ended.
|
|
150
|
+
* @returns {object[]} Beats, each `{id, at, type, ...}`; the id is stable as the ledger grows.
|
|
151
|
+
*/
|
|
152
|
+
export function officeBeats(ledger, graph, now = Date.now()) {
|
|
153
|
+
const { entries, time, startsWork, launchOf, starts } = readStarts(ledger);
|
|
121
154
|
const busyAt = (moment) =>
|
|
122
155
|
starts.some((s) => !s.message && s.at <= moment && moment - s.at < STALE_MS && !(s.refusedAt !== null && s.refusedAt <= moment) && !(s.end !== null && s.end <= moment));
|
|
123
156
|
|
|
@@ -187,6 +220,74 @@ export function officeBeats(ledger, graph, now = Date.now()) {
|
|
|
187
220
|
return beats.sort((a, b) => a.at - b.at);
|
|
188
221
|
}
|
|
189
222
|
|
|
223
|
+
/**
|
|
224
|
+
* The session's tasks for the timeline beside the room, oldest first: its runs cut the way `nina runs` cuts a session
|
|
225
|
+
* (`cyclesOf`), each named by the description its first dispatch was given — the ledger's, or, for a ledger from
|
|
226
|
+
* before the gate kept one, the measurement store's (`names`) — with who worked on it, when it started and ended, and
|
|
227
|
+
* how long anyone was at work on it.
|
|
228
|
+
*
|
|
229
|
+
* It is the whole list, cut again each time, and the page replaces what it has with it: the cut moves as the ledger
|
|
230
|
+
* grows — a run still at work lets nothing through until its verdict, a dispatch is known refused only seconds later
|
|
231
|
+
* — and sent piece by piece, a task that a later fact merged away stayed on the page for good.
|
|
232
|
+
*
|
|
233
|
+
* @param {object[]} ledger - The ledger, oldest first.
|
|
234
|
+
* @param {number} [now] - The moment it is read at: a run younger than `STALE_MS` with no verdict is still at work.
|
|
235
|
+
* @param {Map<string, string>} [names] - Descriptions by dispatch id, for entries that carry none.
|
|
236
|
+
* @returns {{key: string, name: string|null, start: number, end: number|null, how: string|null, busy: number|null, who: [string, number][]}[]}
|
|
237
|
+
*/
|
|
238
|
+
export function officeTimeline(ledger, now = Date.now(), names = new Map()) {
|
|
239
|
+
const { entries, starts, verdictOf } = readStarts(ledger);
|
|
240
|
+
const runs = starts
|
|
241
|
+
.filter((s) => !s.message && s.refusedAt === null)
|
|
242
|
+
.map((s) => {
|
|
243
|
+
const e = entries[s.i];
|
|
244
|
+
const v = verdictOf(s);
|
|
245
|
+
return { ts: e.at, role: s.role, verdict: v ? v.verdict : now < s.at + STALE_MS ? IN_FLIGHT : null, desc: e.desc ?? names.get(e.id) ?? null, start: s, key: e.id ?? `at${s.i}` };
|
|
246
|
+
});
|
|
247
|
+
return cyclesOf(runs).map((cycle) => {
|
|
248
|
+
const first = cycle.runs[0];
|
|
249
|
+
const who = new Map();
|
|
250
|
+
for (const r of cycle.runs) who.set(r.role, (who.get(r.role) ?? 0) + 1);
|
|
251
|
+
const open = cycle.end === 'open';
|
|
252
|
+
return {
|
|
253
|
+
key: first.key,
|
|
254
|
+
name: first.desc ? String(first.desc) : null,
|
|
255
|
+
start: first.start.at,
|
|
256
|
+
end: open ? null : Math.max(...cycle.runs.map((r) => r.start.end ?? r.start.at)),
|
|
257
|
+
how: open ? null : cycle.end,
|
|
258
|
+
busy: open ? null : busyFor(cycle.runs.map((r) => r.start)),
|
|
259
|
+
who: [...who],
|
|
260
|
+
};
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Descriptions by dispatch id from a project's measurement store, for a ledger written before the gate kept them:
|
|
266
|
+
* read again only when the store has changed since — a dispatch the store never names would otherwise have it read
|
|
267
|
+
* whole every few seconds for as long as the office runs.
|
|
268
|
+
*
|
|
269
|
+
* @param {string} target - The project directory.
|
|
270
|
+
* @returns {(entries: object[]) => Map<string, string>}
|
|
271
|
+
*/
|
|
272
|
+
export function storeNames(target) {
|
|
273
|
+
let seen = { file: null, mtime: -1, names: new Map() };
|
|
274
|
+
return (entries) => {
|
|
275
|
+
const missing = entries.some((e) => (e.k === 'pre' || e.k === 'dispatch') && e.via !== 'SendMessage' && e.id && !e.desc && !seen.names.has(e.id));
|
|
276
|
+
if (!missing) return seen.names;
|
|
277
|
+
try {
|
|
278
|
+
const file = projectStoreFile(target);
|
|
279
|
+
const mtime = file ? statSync(file).mtimeMs : -1;
|
|
280
|
+
if (file !== seen.file || mtime !== seen.mtime) {
|
|
281
|
+
const records = file ? readStoreFile(file) : [];
|
|
282
|
+
seen = { file, mtime, names: new Map(records.filter((r) => r.dispatch_id && r.desc).map((r) => [r.dispatch_id, String(r.desc)])) };
|
|
283
|
+
}
|
|
284
|
+
} catch {
|
|
285
|
+
// A store that cannot be read names nothing; the ledger's own words still do.
|
|
286
|
+
}
|
|
287
|
+
return seen.names;
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
190
291
|
/**
|
|
191
292
|
* What the page is told about the room: the project, its stages in the order the line takes them, the stages that
|
|
192
293
|
* run as several agents at once, and what to call the owner.
|
|
@@ -222,6 +323,8 @@ export async function serveOffice({ target, wanted, replay = false, port = OFFIC
|
|
|
222
323
|
const clients = new Set();
|
|
223
324
|
const timers = new Set();
|
|
224
325
|
|
|
326
|
+
const names = storeNames(target);
|
|
327
|
+
|
|
225
328
|
/** The session to show, and its entries; read again only when its ledger changed. */
|
|
226
329
|
let cached = { path: null, size: -1, mtime: 0, entries: [] };
|
|
227
330
|
const current = () => {
|
|
@@ -248,19 +351,34 @@ export async function serveOffice({ target, wanted, replay = false, port = OFFIC
|
|
|
248
351
|
client.since = client.restoring ? Number.NEGATIVE_INFINITY : Date.now() - SWITCH_GRACE_MS;
|
|
249
352
|
client.session = state.session;
|
|
250
353
|
client.sent = new Set();
|
|
354
|
+
client.timeline = null;
|
|
251
355
|
send(client.res, 'hello', { session: state.session, replay });
|
|
252
356
|
};
|
|
253
357
|
|
|
358
|
+
// The timeline, whole, to a client whose copy differs from it.
|
|
359
|
+
const tell = (client, tasks) => {
|
|
360
|
+
const text = JSON.stringify(tasks);
|
|
361
|
+
if (client.timeline === text) return;
|
|
362
|
+
client.timeline = text;
|
|
363
|
+
send(client.res, 'timeline', { tasks });
|
|
364
|
+
};
|
|
365
|
+
|
|
254
366
|
const tick = () => {
|
|
367
|
+
// Nobody watching live: nothing to cut or send.
|
|
368
|
+
if (![...clients].some((client) => !client.replay)) return;
|
|
255
369
|
const state = current();
|
|
370
|
+
const now = Date.now();
|
|
371
|
+
const beats = officeBeats(state.entries, graph, now);
|
|
372
|
+
const tasks = officeTimeline(state.entries, now, names(state.entries));
|
|
256
373
|
for (const client of clients) {
|
|
257
374
|
if (client.replay) continue;
|
|
258
375
|
if (client.session !== state.session) greet(client, state);
|
|
259
|
-
for (const beat of
|
|
376
|
+
for (const beat of beats) {
|
|
260
377
|
if (client.sent.has(beat.id)) continue;
|
|
261
378
|
client.sent.add(beat.id);
|
|
262
379
|
send(client.res, 'beat', { ...beat, instant: client.restoring || beat.at < client.since });
|
|
263
380
|
}
|
|
381
|
+
tell(client, tasks);
|
|
264
382
|
client.restoring = false;
|
|
265
383
|
}
|
|
266
384
|
};
|
|
@@ -269,10 +387,14 @@ export async function serveOffice({ target, wanted, replay = false, port = OFFIC
|
|
|
269
387
|
const state = current();
|
|
270
388
|
greet(client, state);
|
|
271
389
|
const beats = officeBeats(state.entries, graph, Number.POSITIVE_INFINITY);
|
|
390
|
+
// The timeline as it stood at each beat: cut from the ledger up to that moment, so a replay tells nothing early.
|
|
391
|
+
const known = names(state.entries);
|
|
392
|
+
const tasksAt = (moment) => officeTimeline(state.entries.filter((e) => Date.parse(e?.at) <= moment), moment, known);
|
|
272
393
|
// What came before the first dispatch sets the room, at once: a session often opens on many of the owner's
|
|
273
394
|
// messages, and played out they kept the office empty for a minute.
|
|
274
395
|
let i = Math.max(0, beats.findIndex((b) => b.type === 'work'));
|
|
275
396
|
for (const beat of beats.slice(0, i)) send(client.res, 'beat', { ...beat, instant: true });
|
|
397
|
+
if (i > 0) tell(client, tasksAt(beats[i - 1].at));
|
|
276
398
|
const next = () => {
|
|
277
399
|
if (!clients.has(client)) return;
|
|
278
400
|
if (i >= beats.length) {
|
|
@@ -281,6 +403,7 @@ export async function serveOffice({ target, wanted, replay = false, port = OFFIC
|
|
|
281
403
|
}
|
|
282
404
|
const beat = beats[i];
|
|
283
405
|
send(client.res, 'beat', beat);
|
|
406
|
+
tell(client, tasksAt(beat.at));
|
|
284
407
|
i += 1;
|
|
285
408
|
// The owner speaking moves nobody across the room: it passes quickly. An answer keeps its wait, or the question
|
|
286
409
|
// it answers would not be seen.
|
package/src/store.mjs
CHANGED
|
@@ -92,9 +92,21 @@ function storeNamesOf(dir) {
|
|
|
92
92
|
* @returns {object[]|null} Null when the store has nothing for it.
|
|
93
93
|
*/
|
|
94
94
|
export function projectRecords(dir, store = snapshotsDir()) {
|
|
95
|
+
const file = projectStoreFile(dir, store);
|
|
96
|
+
return file === null ? null : readStoreFile(file);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The file a project's records are kept in, or null when the store has none for it.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} dir - The project.
|
|
103
|
+
* @param {string} [store] - The store directory.
|
|
104
|
+
* @returns {string|null}
|
|
105
|
+
*/
|
|
106
|
+
export function projectStoreFile(dir, store = snapshotsDir()) {
|
|
95
107
|
for (const name of storeNamesOf(dir)) {
|
|
96
108
|
const file = join(store, `${name}.jsonl`);
|
|
97
|
-
if (existsSync(file)) return
|
|
109
|
+
if (existsSync(file)) return file;
|
|
98
110
|
}
|
|
99
111
|
return null;
|
|
100
112
|
}
|