company-skill 4.6.5 → 4.6.7
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 +32 -6
- package/hooks/context-guard.js +33 -9
- package/package.json +18 -2
- package/scripts/company-autoloop.js +597 -0
- package/scripts/dashboard.js +195 -44
package/README.md
CHANGED
|
@@ -33,12 +33,15 @@ Optionally define your team first in `COMPANY.md` (skip it and a minimal company
|
|
|
33
33
|
|
|
34
34
|
Every criterion starts failing. Workers run in dependency waves under delegation contracts. At the end of each cycle, the Internal Reviewer re-runs every VERIFY-WITH command and the Devil's Advocate attacks everything marked passing. The stop guard physically blocks exit until every criterion has `passes: true` with reproduced evidence. Once done, `STATUS.md` and a `playbook.md` update are written for the next session.
|
|
35
35
|
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
```mermaid
|
|
37
|
+
flowchart TD
|
|
38
|
+
GOAL --> THINK
|
|
39
|
+
THINK --> EXECUTE["EXECUTE (parallel waves)"]
|
|
40
|
+
EXECUTE --> VERIFY
|
|
41
|
+
VERIFY -->|all criteria pass| DONE["Done (STATUS.md + playbook)"]
|
|
42
|
+
VERIFY -->|not done| COMPRESS
|
|
43
|
+
COMPRESS --> NEXT["THINK (next cycle)"]
|
|
44
|
+
NEXT --> EXECUTE
|
|
42
45
|
```
|
|
43
46
|
|
|
44
47
|
**Roles:** CEO orchestrator, Internal Reviewer, Devil's Advocate, Digest Writer. The orchestrator reads `COMPANY.md`, activates only the roles the goal needs, and writes delegation contracts in dependency order. Workers append FINDING + SOURCE lines to findings files. The Digest Writer compresses each finished cycle into the next cycle's briefing so the orchestrator never carries raw worker output in its own context.
|
|
@@ -65,6 +68,29 @@ What you see, panel by panel:
|
|
|
65
68
|
The dashboard binds 127.0.0.1 only, reads local files, and sends nothing anywhere. Override the port with `COMPANY_DASHBOARD_PORT`.
|
|
66
69
|
|
|
67
70
|
|
|
71
|
+
## Unattended auto-restart loop
|
|
72
|
+
|
|
73
|
+
`scripts/company-autoloop.js` runs `/company` unattended across many sessions. It restarts into a fresh context automatically when a work session approaches the context threshold, so a long goal keeps going without a human pasting the restart prompt each time.
|
|
74
|
+
|
|
75
|
+
A script is needed because a fully unattended fresh-context restart cannot be done with Claude Code native features alone. Hooks cannot run `/clear` or start a fresh turn, a Stop-hook block keeps the same context, and auto-compaction only summarizes. The supervisor is the external driver that owns the restart decision. This is the validated finding.
|
|
76
|
+
|
|
77
|
+
Each turn runs a headless `claude -p` session while the supervisor watches the session context fill. At the threshold it drives `/company restart` to emit the `NEXT.md` continuation, then launches a fresh session seeded from it.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
node scripts/company-autoloop.js --max-turns 100 "/company GOAL: <your goal>"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Key flags and env:
|
|
84
|
+
|
|
85
|
+
- `--project-dir <path>` the project the run targets (default: current dir)
|
|
86
|
+
- `--company-dir <path>` override the `.company` dir (default: `<project-dir>/.company`)
|
|
87
|
+
- `--max-turns <n>` hard cap on work turns across all sessions (default: 100)
|
|
88
|
+
- `--restart-timeout-secs <n>` max wait for the restart markers (default: 420)
|
|
89
|
+
- `COMPANY_CONTEXT_THRESHOLD` fill fraction or percent that triggers a restart (default: 0.50, set a low value for testing)
|
|
90
|
+
|
|
91
|
+
It runs with `--permission-mode bypassPermissions` for the autonomy an unattended loop needs. The threshold is configurable, so you can drop it low to exercise the restart path during testing.
|
|
92
|
+
|
|
93
|
+
|
|
68
94
|
## Cost and quality
|
|
69
95
|
|
|
70
96
|
Multi-agent orchestration buys quality with tokens. /company's answer to the token cost: spend strong-model tokens only where they buy quality, and report the bill every cycle.
|
package/hooks/context-guard.js
CHANGED
|
@@ -48,6 +48,20 @@ function parseThreshold() {
|
|
|
48
48
|
return v > 1 ? v / 100 : v;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// Hard ceiling: the toggle CANNOT suppress a block at or above this level.
|
|
52
|
+
// Default 0.80. Overridable via COMPANY_CONTEXT_HARD_CEILING (fraction or percent).
|
|
53
|
+
// If the parsed value is below the soft threshold, it is clamped up to the threshold.
|
|
54
|
+
function parseHardCeiling(threshold) {
|
|
55
|
+
const raw = process.env.COMPANY_CONTEXT_HARD_CEILING;
|
|
56
|
+
if (!raw) return 0.80;
|
|
57
|
+
const v = parseFloat(raw);
|
|
58
|
+
if (isNaN(v)) return 0.80;
|
|
59
|
+
// accept either fraction (0.8) or percent (80)
|
|
60
|
+
const frac = v > 1 ? v / 100 : v;
|
|
61
|
+
// hard ceiling must be >= soft threshold
|
|
62
|
+
return frac >= threshold ? frac : threshold;
|
|
63
|
+
}
|
|
64
|
+
|
|
51
65
|
// Read stdin synchronously (fd 0). Fail-open on any error.
|
|
52
66
|
let stdinData = null;
|
|
53
67
|
try {
|
|
@@ -152,6 +166,7 @@ const used =
|
|
|
152
166
|
|
|
153
167
|
const contextWindow = detectWindow(lastModelId);
|
|
154
168
|
const threshold = parseThreshold();
|
|
169
|
+
const hardCeiling = parseHardCeiling(threshold);
|
|
155
170
|
const fill = used / contextWindow;
|
|
156
171
|
|
|
157
172
|
if (fill < threshold) process.exit(0);
|
|
@@ -159,9 +174,10 @@ if (fill < threshold) process.exit(0);
|
|
|
159
174
|
// Per-session toggle: read .company/context-guard-config.json for this session.
|
|
160
175
|
// Shape: { "sessions": { "<id>": { "enforceRestart": true|false } } }
|
|
161
176
|
// Default (no file, no entry, parse error) = enforceRestart: true (safe default).
|
|
162
|
-
// If enforceRestart === false for this session,
|
|
163
|
-
//
|
|
164
|
-
|
|
177
|
+
// If enforceRestart === false for this session, and fill is BELOW the hard ceiling,
|
|
178
|
+
// the toggle suppresses the soft-threshold block. At or above the hard ceiling the
|
|
179
|
+
// toggle has no effect and we fall through to block unconditionally.
|
|
180
|
+
if (sessionId && fill < hardCeiling) {
|
|
165
181
|
const cfgPath = path.join(companyDir, 'context-guard-config.json');
|
|
166
182
|
try {
|
|
167
183
|
const cfgRaw = fs.readFileSync(cfgPath, 'utf8');
|
|
@@ -170,7 +186,7 @@ if (sessionId) {
|
|
|
170
186
|
cfg.sessions && typeof cfg.sessions === 'object' &&
|
|
171
187
|
cfg.sessions[sessionId] && typeof cfg.sessions[sessionId] === 'object' &&
|
|
172
188
|
cfg.sessions[sessionId].enforceRestart === false) {
|
|
173
|
-
// Toggle is OFF: allow through without blocking
|
|
189
|
+
// Toggle is OFF and below the hard ceiling: allow through without blocking
|
|
174
190
|
process.exit(0);
|
|
175
191
|
}
|
|
176
192
|
// Any other value (true, missing, or unknown) falls through to block
|
|
@@ -264,13 +280,21 @@ try {
|
|
|
264
280
|
|
|
265
281
|
const pct = Math.round(fill * 100);
|
|
266
282
|
const threshPct = Math.round(threshold * 100);
|
|
283
|
+
const ceilPct = Math.round(hardCeiling * 100);
|
|
267
284
|
const sessionTag = sessionId ? ' [session ' + sessionId + ']' : '';
|
|
268
285
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
286
|
+
// Emit a distinct reason when the hard ceiling is what's enforcing the block.
|
|
287
|
+
const atHardCeiling = fill >= hardCeiling;
|
|
288
|
+
const reason = atHardCeiling
|
|
289
|
+
? '[COMPANY] Context at ' + pct + '% (>= ' + ceilPct + '% HARD CEILING). ' +
|
|
290
|
+
'The per-session enforceRestart toggle CANNOT suppress this restart. ' +
|
|
291
|
+
'Run /company restart NOW: quiesce in-flight agents, commit their work as draft PRs, ' +
|
|
292
|
+
'refresh STATUS/NEXT, run the restart debate gate, and emit the verified continuation prompt.' +
|
|
293
|
+
sessionTag
|
|
294
|
+
: '[COMPANY] Context at ' + pct + '% (>= ' + threshPct + '% threshold). ' +
|
|
272
295
|
'Run /company restart NOW: quiesce in-flight agents, commit their work as draft PRs, ' +
|
|
273
296
|
'refresh STATUS/NEXT, run the restart debate gate, and emit the verified continuation prompt. ' +
|
|
274
|
-
'This is enforced, not advisory - do not continue new work.' + sessionTag
|
|
275
|
-
|
|
297
|
+
'This is enforced, not advisory - do not continue new work.' + sessionTag;
|
|
298
|
+
|
|
299
|
+
console.log(JSON.stringify({ decision: 'block', reason: reason }));
|
|
276
300
|
process.exit(0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "company-skill",
|
|
3
|
-
"version": "4.6.
|
|
3
|
+
"version": "4.6.7",
|
|
4
4
|
"description": "Goal-driven multi-employee company for Claude Code. Give it a goal, it runs until done.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"company-skill": "./bin/install.js"
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"scripts/check-contracts.js",
|
|
23
23
|
"scripts/check-findings.js",
|
|
24
24
|
"scripts/restart-debate.js",
|
|
25
|
+
"scripts/company-autoloop.js",
|
|
25
26
|
"scripts/dashboard.js",
|
|
26
27
|
"scripts/secret-scan.js",
|
|
27
28
|
"scripts/reset-company-guard.js",
|
|
@@ -35,7 +36,22 @@
|
|
|
35
36
|
"README.md",
|
|
36
37
|
"LICENSE"
|
|
37
38
|
],
|
|
38
|
-
"keywords": [
|
|
39
|
+
"keywords": [
|
|
40
|
+
"claude-code",
|
|
41
|
+
"skill",
|
|
42
|
+
"multi-agent",
|
|
43
|
+
"company",
|
|
44
|
+
"orchestration",
|
|
45
|
+
"model-agnostic",
|
|
46
|
+
"autonomous-agents",
|
|
47
|
+
"stop-hook",
|
|
48
|
+
"agent-orchestration",
|
|
49
|
+
"ai-agents",
|
|
50
|
+
"fable",
|
|
51
|
+
"claude-fable",
|
|
52
|
+
"mythos",
|
|
53
|
+
"claude-mythos"
|
|
54
|
+
],
|
|
39
55
|
"author": "jagmarques",
|
|
40
56
|
"license": "MIT",
|
|
41
57
|
"repository": {
|
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// company-autoloop.js - External supervisor for /company auto-restart cycles.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS IS A SEPARATE SCRIPT (not native hooks):
|
|
5
|
+
// A fully unattended detect->restart->fresh-context loop is IMPOSSIBLE with
|
|
6
|
+
// Claude Code native features alone. Stop-hook decision:block keeps the SAME
|
|
7
|
+
// context (does not reset). /clear is interactive-only. Auto-compaction
|
|
8
|
+
// summarizes in-session, never resets. Fresh context requires a NEW process
|
|
9
|
+
// invocation - i.e. this external script. See auto-restart-research.md.
|
|
10
|
+
//
|
|
11
|
+
// VALIDATED HEADLESS REALITY (orchestrator real 1% runs, 2026-06-13):
|
|
12
|
+
// A headless `claude -p` session does NOT self-trigger /company restart. The
|
|
13
|
+
// company Stop guards (stop-guard, context-guard) are effectively INTERACTIVE
|
|
14
|
+
// ONLY: a -p session completes its turn and EXITS regardless of decision:block,
|
|
15
|
+
// so it never writes NEXT.md / RESTART_DEBATE_CONFIRMED on its own. The earlier
|
|
16
|
+
// "wait for the child to self-restart" premise was therefore WRONG for headless.
|
|
17
|
+
// THE FIX, PROVEN LIVE: the supervisor must DRIVE the restart. Running
|
|
18
|
+
// `claude -p --resume <sessionId> "/company restart"` executes restart mode
|
|
19
|
+
// headlessly and writes NEXT.md (+ RESTART_DEBATE_CONFIRMED after the full
|
|
20
|
+
// 3-role debate). So the supervisor owns the threshold decision itself.
|
|
21
|
+
//
|
|
22
|
+
// HOW IT WORKS (threshold-driven, restart-via-resume):
|
|
23
|
+
// One logical run is MANY sessions. The supervisor tracks the work session's
|
|
24
|
+
// context FILL itself (the guards do not fire headless) and drives turns until
|
|
25
|
+
// the threshold is crossed, then drives a /company restart for fresh context.
|
|
26
|
+
// 1. Fresh session: spawn `claude -p --session-id <uuid> ... "<goal-or-NEXT>"`.
|
|
27
|
+
// Continuation of the SAME session under threshold: `--resume <uuid>`.
|
|
28
|
+
// Each -p call runs to completion and exits (expected headless behavior).
|
|
29
|
+
// 2. After a turn: CANCEL -> exit 0. Goal done (criteria all pass) -> exit 0.
|
|
30
|
+
// 3. Compute fill from the transcript jsonl (last assistant usage block) and
|
|
31
|
+
// the model window. Log it.
|
|
32
|
+
// 4. fill >= threshold: DRIVE `claude -p --resume <uuid> "/company restart"`
|
|
33
|
+
// ASYNC (do not await its exit - the company stop-guard blocks its stop so
|
|
34
|
+
// a headless restart invocation does NOT exit on its own), CONCURRENTLY
|
|
35
|
+
// poll for RESTART_DEBATE_CONFIRMED + a fresh NEXT.md, then KILL the restart
|
|
36
|
+
// process group and seed a NEW --session-id from NEXT.md. THIS is the
|
|
37
|
+
// fresh-context handoff.
|
|
38
|
+
// 5. fill < threshold: continue the SAME session another turn via --resume.
|
|
39
|
+
//
|
|
40
|
+
// --resume is used in exactly two legitimate places: continuing the same work
|
|
41
|
+
// session under threshold, and driving /company restart on that session. The
|
|
42
|
+
// fresh continuation AFTER a restart is ALWAYS a brand-new --session-id seeded
|
|
43
|
+
// from NEXT.md. --continue is NEVER used, and --resume NEVER carries work into a
|
|
44
|
+
// new logical cycle - that handoff is always a new session id.
|
|
45
|
+
//
|
|
46
|
+
// USAGE:
|
|
47
|
+
// node scripts/company-autoloop.js [options] "<goal>"
|
|
48
|
+
// node scripts/company-autoloop.js [options] --prompt-file /path/to/goal.txt
|
|
49
|
+
//
|
|
50
|
+
// OPTIONS:
|
|
51
|
+
// --project-dir <path> Project dir the /company run targets (default: cwd)
|
|
52
|
+
// --company-dir <path> Override .company dir (default: <project-dir>/.company)
|
|
53
|
+
// --max-turns <n> Hard cap on work turns across all sessions (default: 100)
|
|
54
|
+
// --permission-mode <m> claude --permission-mode value (default: bypassPermissions)
|
|
55
|
+
// --prompt-file <path> Read initial goal from file instead of CLI arg
|
|
56
|
+
// --restart-timeout-secs <n> Max wait for restart markers after driving it (default: 420)
|
|
57
|
+
// --turn-timeout-secs <n> Wall-clock backstop kill for a single work turn (default: 900)
|
|
58
|
+
// --projects-dir <path> Override the ~/.claude/projects transcript root (testing)
|
|
59
|
+
// --help Show this message
|
|
60
|
+
//
|
|
61
|
+
// ENV:
|
|
62
|
+
// CLAUDE_BIN Path to the claude binary (default: claude)
|
|
63
|
+
// COMPANY_DIR Overrides --company-dir
|
|
64
|
+
// COMPANY_TRANSCRIPT_DIR Overrides the transcript projects root (testing)
|
|
65
|
+
// COMPANY_CONTEXT_THRESHOLD Fill fraction or percent that triggers restart (default: 0.50)
|
|
66
|
+
// COMPANY_CONTEXT_WINDOW Force the context window size (overrides model detection)
|
|
67
|
+
|
|
68
|
+
'use strict';
|
|
69
|
+
|
|
70
|
+
const fs = require('node:fs');
|
|
71
|
+
const path = require('node:path');
|
|
72
|
+
const os = require('node:os');
|
|
73
|
+
const { randomUUID } = require('node:crypto');
|
|
74
|
+
const { spawn } = require('node:child_process');
|
|
75
|
+
|
|
76
|
+
// ---------- arg parsing ----------
|
|
77
|
+
const argv = process.argv.slice(2);
|
|
78
|
+
|
|
79
|
+
function argValue(flag) {
|
|
80
|
+
const i = argv.indexOf(flag);
|
|
81
|
+
return i >= 0 && argv[i + 1] ? argv[i + 1] : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function hasFlag(flag) {
|
|
85
|
+
return argv.indexOf(flag) !== -1;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (hasFlag('--help') || hasFlag('-h')) {
|
|
89
|
+
process.stdout.write(fs.readFileSync(__filename, 'utf8').split('\n')
|
|
90
|
+
.filter(l => l.startsWith('//'))
|
|
91
|
+
.map(l => l.slice(3))
|
|
92
|
+
.join('\n') + '\n');
|
|
93
|
+
process.exit(0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const projectDir = path.resolve(argValue('--project-dir') || process.cwd());
|
|
97
|
+
const companyDirArg = argValue('--company-dir');
|
|
98
|
+
// COMPANY_DIR env wins, then --company-dir, then <projectDir>/.company
|
|
99
|
+
const companyDir = path.resolve(
|
|
100
|
+
process.env.COMPANY_DIR || companyDirArg || path.join(projectDir, '.company')
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const maxTurns = parseInt(argValue('--max-turns') || '100', 10);
|
|
104
|
+
const permissionMode = argValue('--permission-mode') || 'bypassPermissions';
|
|
105
|
+
const promptFile = argValue('--prompt-file');
|
|
106
|
+
const restartTimeoutSecs = parseInt(argValue('--restart-timeout-secs') || '420', 10);
|
|
107
|
+
// Generous wall-clock backstop: kills a work turn that never exits (model looping
|
|
108
|
+
// on a blocked stop). Tuned NOT to kill legitimate long work - natural exit is the
|
|
109
|
+
// primary end of a turn, this is only a last resort.
|
|
110
|
+
const turnTimeoutSecs = parseInt(argValue('--turn-timeout-secs') || '900', 10);
|
|
111
|
+
const claudeBin = process.env.CLAUDE_BIN || 'claude';
|
|
112
|
+
|
|
113
|
+
// Transcript projects root: env wins, then --projects-dir, then ~/.claude/projects.
|
|
114
|
+
const projectsDir = process.env.COMPANY_TRANSCRIPT_DIR ||
|
|
115
|
+
argValue('--projects-dir') ||
|
|
116
|
+
path.join(os.homedir(), '.claude', 'projects');
|
|
117
|
+
|
|
118
|
+
// Positional goal: last non-flag arg that is not a flag value
|
|
119
|
+
let goalArg = null;
|
|
120
|
+
const flagsWithValues = new Set([
|
|
121
|
+
'--project-dir', '--company-dir', '--max-turns',
|
|
122
|
+
'--permission-mode', '--prompt-file', '--restart-timeout-secs',
|
|
123
|
+
'--turn-timeout-secs', '--projects-dir',
|
|
124
|
+
]);
|
|
125
|
+
for (let i = 0; i < argv.length; i++) {
|
|
126
|
+
if (flagsWithValues.has(argv[i])) { i++; continue; }
|
|
127
|
+
if (argv[i].startsWith('--')) continue;
|
|
128
|
+
goalArg = argv[i];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function readInitialPrompt() {
|
|
132
|
+
if (promptFile) {
|
|
133
|
+
try {
|
|
134
|
+
return fs.readFileSync(promptFile, 'utf8').trim();
|
|
135
|
+
} catch (e) {
|
|
136
|
+
die('Cannot read --prompt-file ' + promptFile + ': ' + e.message);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (goalArg) return goalArg.trim();
|
|
140
|
+
die('No goal provided. Pass a goal string or --prompt-file <path>.');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------- helpers ----------
|
|
144
|
+
function log(msg) {
|
|
145
|
+
const ts = new Date().toISOString();
|
|
146
|
+
process.stderr.write('[autoloop ' + ts + '] ' + msg + '\n');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function die(msg) {
|
|
150
|
+
process.stderr.write('ERROR: ' + msg + '\n');
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function sleep(ms) {
|
|
155
|
+
return new Promise(function (resolve) { setTimeout(resolve, ms); });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------- state file paths ----------
|
|
159
|
+
const nextMdPath = path.join(companyDir, 'NEXT.md');
|
|
160
|
+
const cancelPath = path.join(companyDir, 'CANCEL');
|
|
161
|
+
const criteriaPath = path.join(companyDir, 'criteria.json');
|
|
162
|
+
const debateConfirmedPath = path.join(companyDir, 'RESTART_DEBATE_CONFIRMED');
|
|
163
|
+
|
|
164
|
+
// ---------- goal-done check ----------
|
|
165
|
+
// Real criteria.json is an object {goal, criteria:[{passes:bool,...}]}.
|
|
166
|
+
// Accept both shapes: parsed.criteria array, or a bare top-level array.
|
|
167
|
+
// An entry is done when passes === true (primary) or status done/pass (legacy).
|
|
168
|
+
// Empty/missing criteria, or a parse error: not done.
|
|
169
|
+
function isGoalDone() {
|
|
170
|
+
try {
|
|
171
|
+
const raw = fs.readFileSync(criteriaPath, 'utf8');
|
|
172
|
+
const parsed = JSON.parse(raw);
|
|
173
|
+
let list;
|
|
174
|
+
if (parsed && Array.isArray(parsed.criteria)) {
|
|
175
|
+
list = parsed.criteria;
|
|
176
|
+
} else if (Array.isArray(parsed)) {
|
|
177
|
+
list = parsed;
|
|
178
|
+
} else {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
if (list.length === 0) return false;
|
|
182
|
+
return list.every(function (c) {
|
|
183
|
+
if (c && c.passes === true) return true;
|
|
184
|
+
const s = (c && c.status ? String(c.status) : '').toLowerCase();
|
|
185
|
+
return s === 'done' || s === 'pass';
|
|
186
|
+
});
|
|
187
|
+
} catch (e) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------- context-window detection (mirrors hooks/context-guard.js) ----------
|
|
193
|
+
// Known 1M-context model id substrings. Unknown/null defaults to 1M (fail-open),
|
|
194
|
+
// matching the guard so the supervisor never false-fires a restart on an unknown id.
|
|
195
|
+
const KNOWN_1M_SUBSTRINGS = [
|
|
196
|
+
'[1m]',
|
|
197
|
+
'claude-opus-4',
|
|
198
|
+
'claude-opus-4-5',
|
|
199
|
+
'claude-opus-4-8',
|
|
200
|
+
];
|
|
201
|
+
const DEFAULT_WINDOW = 1000000;
|
|
202
|
+
const WINDOW_200K = 200000;
|
|
203
|
+
|
|
204
|
+
function is1MModel(modelId) {
|
|
205
|
+
if (!modelId) return true; // unknown defaults to 1M (fail-open)
|
|
206
|
+
const lower = modelId.toLowerCase();
|
|
207
|
+
for (let i = 0; i < KNOWN_1M_SUBSTRINGS.length; i++) {
|
|
208
|
+
if (lower.indexOf(KNOWN_1M_SUBSTRINGS[i]) !== -1) return true;
|
|
209
|
+
}
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function detectWindow(modelId) {
|
|
214
|
+
const envVal = process.env.COMPANY_CONTEXT_WINDOW;
|
|
215
|
+
if (envVal) {
|
|
216
|
+
const n = parseInt(envVal, 10);
|
|
217
|
+
if (n > 0) return n;
|
|
218
|
+
}
|
|
219
|
+
return is1MModel(modelId) ? DEFAULT_WINDOW : WINDOW_200K;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function parseThreshold() {
|
|
223
|
+
const raw = process.env.COMPANY_CONTEXT_THRESHOLD;
|
|
224
|
+
if (!raw) return 0.50;
|
|
225
|
+
const v = parseFloat(raw);
|
|
226
|
+
if (isNaN(v)) return 0.50;
|
|
227
|
+
// accept either fraction (0.5) or percent (50)
|
|
228
|
+
return v > 1 ? v / 100 : v;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Sum every token field that counts against the context window.
|
|
232
|
+
// Matches the Claude Code status line: input + cache_read + cache_creation + output.
|
|
233
|
+
function usedTokens(usage) {
|
|
234
|
+
return (usage.input_tokens || 0) +
|
|
235
|
+
(usage.cache_read_input_tokens || 0) +
|
|
236
|
+
(usage.cache_creation_input_tokens || 0) +
|
|
237
|
+
(usage.output_tokens || 0);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------- locate a session transcript jsonl ----------
|
|
241
|
+
// Sessions persist at ~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl. The
|
|
242
|
+
// encoded-cwd segment is not worth reconstructing by hand, so glob every project
|
|
243
|
+
// dir for <sessionId>.jsonl and take the newest match. Returns null if missing.
|
|
244
|
+
function findTranscript(sessionId) {
|
|
245
|
+
let entries;
|
|
246
|
+
try {
|
|
247
|
+
entries = fs.readdirSync(projectsDir, { withFileTypes: true });
|
|
248
|
+
} catch (e) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
let best = null;
|
|
252
|
+
let bestMtime = -1;
|
|
253
|
+
for (let i = 0; i < entries.length; i++) {
|
|
254
|
+
if (!entries[i].isDirectory()) continue;
|
|
255
|
+
const candidate = path.join(projectsDir, entries[i].name, sessionId + '.jsonl');
|
|
256
|
+
let st;
|
|
257
|
+
try { st = fs.statSync(candidate); } catch (e) { continue; }
|
|
258
|
+
if (st.mtimeMs > bestMtime) { bestMtime = st.mtimeMs; best = candidate; }
|
|
259
|
+
}
|
|
260
|
+
return best;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---------- compute the work session's context fill ----------
|
|
264
|
+
// Read the LAST assistant usage block from the session transcript, sum the token
|
|
265
|
+
// fields, divide by the detected window. A missing transcript is not a crash: log
|
|
266
|
+
// a warning and treat fill as 0 so the loop keeps making progress.
|
|
267
|
+
function computeFill(sessionId) {
|
|
268
|
+
const transcript = findTranscript(sessionId);
|
|
269
|
+
if (!transcript) {
|
|
270
|
+
log('WARN no transcript found for session ' + sessionId + ' under ' + projectsDir +
|
|
271
|
+
' - treating fill as 0');
|
|
272
|
+
return { fill: 0, used: 0, window: DEFAULT_WINDOW, modelId: null, transcript: null };
|
|
273
|
+
}
|
|
274
|
+
let lastUsage = null;
|
|
275
|
+
let lastModelId = null;
|
|
276
|
+
try {
|
|
277
|
+
const raw = fs.readFileSync(transcript, 'utf8');
|
|
278
|
+
const lines = raw.split('\n');
|
|
279
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
280
|
+
const line = lines[i].trim();
|
|
281
|
+
if (!line) continue;
|
|
282
|
+
let msg;
|
|
283
|
+
try { msg = JSON.parse(line); } catch (e) { continue; }
|
|
284
|
+
const inner = msg.message || msg;
|
|
285
|
+
if (inner && inner.role === 'assistant' && inner.usage) {
|
|
286
|
+
lastUsage = inner.usage;
|
|
287
|
+
if (typeof inner.model === 'string') lastModelId = inner.model;
|
|
288
|
+
else if (typeof msg.model === 'string') lastModelId = msg.model;
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
} catch (e) {
|
|
293
|
+
log('WARN could not read transcript ' + transcript + ': ' + e.message + ' - fill 0');
|
|
294
|
+
return { fill: 0, used: 0, window: DEFAULT_WINDOW, modelId: null, transcript };
|
|
295
|
+
}
|
|
296
|
+
if (!lastUsage) {
|
|
297
|
+
return { fill: 0, used: 0, window: DEFAULT_WINDOW, modelId: lastModelId, transcript };
|
|
298
|
+
}
|
|
299
|
+
const used = usedTokens(lastUsage);
|
|
300
|
+
const window = detectWindow(lastModelId);
|
|
301
|
+
return { fill: used / window, used, window, modelId: lastModelId, transcript };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---------- kill a child's whole process group (SIGTERM then SIGKILL) ----------
|
|
305
|
+
// Children are spawned detached so each has its own process group (pgid === pid).
|
|
306
|
+
// Signalling the negative pid hits the whole tree (e.g. restart's 3 debate
|
|
307
|
+
// sub-agents), not just the top claude process. Best-effort: a gone group is fine.
|
|
308
|
+
function killChildGroup(child, graceMs) {
|
|
309
|
+
if (!child || child.pid == null) return Promise.resolve();
|
|
310
|
+
const pgid = child.pid;
|
|
311
|
+
function signal(sig) {
|
|
312
|
+
try { process.kill(-pgid, sig); } catch (e) {
|
|
313
|
+
// Group already gone or never a group leader: fall back to the bare pid.
|
|
314
|
+
try { process.kill(pgid, sig); } catch (e2) {}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
signal('SIGTERM');
|
|
318
|
+
return new Promise(function (resolve) {
|
|
319
|
+
let done = false;
|
|
320
|
+
function finish() { if (!done) { done = true; resolve(); } }
|
|
321
|
+
child.once('exit', finish);
|
|
322
|
+
setTimeout(function () { signal('SIGKILL'); finish(); }, graceMs || 1500);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---------- spawn one claude -p turn (async, caller owns the handle) ----------
|
|
327
|
+
// freshSession=true seeds a NEW --session-id. Otherwise --resume the same id.
|
|
328
|
+
// The prompt argument is the goal/NEXT on a fresh session, the continue nudge on
|
|
329
|
+
// a resume, or "/company restart" for restart mode. Spawned DETACHED so the child
|
|
330
|
+
// leads its own process group and the whole tree can be killed via killChildGroup.
|
|
331
|
+
// stdout/stderr are drained so a full pipe never blocks the child. Returns the
|
|
332
|
+
// child handle plus a `done` promise resolving with the exit result.
|
|
333
|
+
function spawnTurn(opts) {
|
|
334
|
+
const spawnArgs = ['-p'];
|
|
335
|
+
if (opts.freshSession) spawnArgs.push('--session-id', opts.sessionId);
|
|
336
|
+
else spawnArgs.push('--resume', opts.sessionId);
|
|
337
|
+
spawnArgs.push(
|
|
338
|
+
'--output-format', 'stream-json',
|
|
339
|
+
'--verbose',
|
|
340
|
+
'--include-hook-events',
|
|
341
|
+
'--permission-mode', permissionMode,
|
|
342
|
+
opts.prompt
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
const spawnEnv = Object.assign({}, process.env, { COMPANY_DIR: companyDir });
|
|
346
|
+
const mode = opts.freshSession ? '--session-id' : '--resume';
|
|
347
|
+
log('spawning: ' + claudeBin + ' -p ' + mode + ' ' + opts.sessionId +
|
|
348
|
+
(opts.label ? ' [' + opts.label + ']' : '') + ' ...');
|
|
349
|
+
|
|
350
|
+
const child = spawn(claudeBin, spawnArgs, {
|
|
351
|
+
cwd: projectDir,
|
|
352
|
+
env: spawnEnv,
|
|
353
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
354
|
+
detached: true,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
let stderrTail = '';
|
|
358
|
+
let stdoutBytes = 0;
|
|
359
|
+
child.stdout.on('data', function (d) { stdoutBytes += d.length; });
|
|
360
|
+
child.stderr.on('data', function (d) { stderrTail = (stderrTail + d.toString()).slice(-2000); });
|
|
361
|
+
child.stdout.on('error', function () {});
|
|
362
|
+
child.stderr.on('error', function () {});
|
|
363
|
+
|
|
364
|
+
const done = new Promise(function (resolve) {
|
|
365
|
+
child.on('error', function (e) {
|
|
366
|
+
log('turn spawn error: ' + e.message);
|
|
367
|
+
resolve({ code: 127, stdoutBytes: 0, stderrTail: e.message, killed: false });
|
|
368
|
+
});
|
|
369
|
+
child.on('exit', function (code, signalName) {
|
|
370
|
+
resolve({ code: code, stdoutBytes: stdoutBytes, stderrTail: stderrTail,
|
|
371
|
+
killed: signalName != null });
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
return { child: child, done: done };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ---------- run one work turn with a wall-clock backstop ----------
|
|
379
|
+
// Awaits natural exit (the expected headless behavior). If the turn never exits
|
|
380
|
+
// within turnTimeoutSecs (model looping on a blocked stop, or a sub-agent holding
|
|
381
|
+
// a pipe open), kill its process group and resolve a timed-out result. Once the
|
|
382
|
+
// timer fires we mark this run as timed-out FIRST, so whichever settle wins (the
|
|
383
|
+
// group-kill resolver OR the child exit event that the kill triggers) reports
|
|
384
|
+
// timedOut:true. Without that flag the kill makes the child exit, handle.done
|
|
385
|
+
// resolves with a plain code=143 result, that settle wins the race, and the main
|
|
386
|
+
// loop misclassifies a killed-on-timeout turn as an error (real9 evidence). So the
|
|
387
|
+
// flag is the source of truth for the timeout, not whichever exit path lands first.
|
|
388
|
+
function runTurn(opts) {
|
|
389
|
+
const handle = spawnTurn(opts);
|
|
390
|
+
return new Promise(function (resolve) {
|
|
391
|
+
let settled = false;
|
|
392
|
+
let timedOut = false;
|
|
393
|
+
function settle(res) {
|
|
394
|
+
if (settled) return;
|
|
395
|
+
settled = true;
|
|
396
|
+
clearTimeout(timer);
|
|
397
|
+
// The backstop owns the timeout classification: if the timer fired, this run
|
|
398
|
+
// is timed-out regardless of which exit path resolved it.
|
|
399
|
+
if (timedOut) res = Object.assign({}, res, { timedOut: true, killed: true });
|
|
400
|
+
resolve(res);
|
|
401
|
+
}
|
|
402
|
+
const timer = setTimeout(function () {
|
|
403
|
+
if (settled) return;
|
|
404
|
+
timedOut = true;
|
|
405
|
+
log('WARN work turn exceeded ' + turnTimeoutSecs + 's wall clock - killing it');
|
|
406
|
+
// Kill the detached process group (the work child AND its sub-agents), then
|
|
407
|
+
// resolve a timed-out result so the main loop proceeds to the fill check on
|
|
408
|
+
// the partial transcript. Do not wait on handle.done: resolve on our own.
|
|
409
|
+
killChildGroup(handle.child, 1500).then(function () {
|
|
410
|
+
settle({ code: null, stdoutBytes: 0, stderrTail: '', killed: true, timedOut: true });
|
|
411
|
+
});
|
|
412
|
+
}, turnTimeoutSecs * 1000);
|
|
413
|
+
handle.done.then(function (res) { settle(res); });
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ---------- drive a /company restart and wait for the handoff markers ----------
|
|
418
|
+
// MONITOR-AND-KILL: the restart invocation is spawned ASYNC and NOT awaited. A
|
|
419
|
+
// headless `claude -p "/company restart"` does NOT exit on its own (the company
|
|
420
|
+
// stop-guard blocks its stop), so awaiting it would hang and the marker poll below
|
|
421
|
+
// would never run (the real-world bug). Instead we poll CONCURRENTLY for
|
|
422
|
+
// RESTART_DEBATE_CONFIRMED present AND a NEXT.md whose mtime is newer than the
|
|
423
|
+
// restart start, then KILL the restart process group (its 3 debate sub-agents
|
|
424
|
+
// included) and return NEXT.md. If the invocation exits on its own first (e.g. a
|
|
425
|
+
// done goal), the markers are still evaluated. On timeout with no markers: kill
|
|
426
|
+
// the invocation and return null.
|
|
427
|
+
async function driveRestart(sessionId) {
|
|
428
|
+
const restartStart = Date.now();
|
|
429
|
+
// Clear any stale confirmed marker so we only react to THIS restart.
|
|
430
|
+
try { fs.unlinkSync(debateConfirmedPath); } catch (e) {}
|
|
431
|
+
|
|
432
|
+
const handle = spawnTurn({ sessionId: sessionId, freshSession: false,
|
|
433
|
+
prompt: '/company restart', label: 'company restart' });
|
|
434
|
+
|
|
435
|
+
// Track natural exit without awaiting it (a done goal can exit cleanly).
|
|
436
|
+
let exited = false;
|
|
437
|
+
handle.done.then(function () { exited = true; });
|
|
438
|
+
|
|
439
|
+
function readMarkers() {
|
|
440
|
+
let confirmed = false;
|
|
441
|
+
try { confirmed = fs.existsSync(debateConfirmedPath); } catch (e) {}
|
|
442
|
+
if (!confirmed) return null;
|
|
443
|
+
let freshNext = false;
|
|
444
|
+
try { freshNext = fs.statSync(nextMdPath).mtimeMs > restartStart; } catch (e) {}
|
|
445
|
+
if (!freshNext) return null;
|
|
446
|
+
let next = '';
|
|
447
|
+
try { next = fs.readFileSync(nextMdPath, 'utf8').trim(); } catch (e) {}
|
|
448
|
+
return next || null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const deadline = restartStart + restartTimeoutSecs * 1000;
|
|
452
|
+
while (Date.now() < deadline) {
|
|
453
|
+
const next = readMarkers();
|
|
454
|
+
if (next) {
|
|
455
|
+
log('restart markers present - killing the restart invocation');
|
|
456
|
+
await killChildGroup(handle.child, 1500);
|
|
457
|
+
return next;
|
|
458
|
+
}
|
|
459
|
+
if (exited) {
|
|
460
|
+
// The invocation exited on its own and the markers never appeared.
|
|
461
|
+
return readMarkers();
|
|
462
|
+
}
|
|
463
|
+
await sleep(2000);
|
|
464
|
+
}
|
|
465
|
+
// Timeout with no markers: kill the (likely hung) invocation, signal failure.
|
|
466
|
+
await killChildGroup(handle.child, 1500);
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ---------- main loop ----------
|
|
471
|
+
async function main() {
|
|
472
|
+
const initialPrompt = readInitialPrompt();
|
|
473
|
+
|
|
474
|
+
log('supervisor start | project=' + projectDir + ' company=' + companyDir +
|
|
475
|
+
' max-turns=' + maxTurns + ' permission-mode=' + permissionMode +
|
|
476
|
+
' threshold=' + parseThreshold() + ' restart-timeout-secs=' + restartTimeoutSecs +
|
|
477
|
+
' turn-timeout-secs=' + turnTimeoutSecs);
|
|
478
|
+
log('initial prompt length=' + initialPrompt.length + ' chars');
|
|
479
|
+
|
|
480
|
+
// A logical run = many sessions. sessionId is the current work session.
|
|
481
|
+
let sessionId = randomUUID();
|
|
482
|
+
let prompt = initialPrompt;
|
|
483
|
+
let freshSession = true;
|
|
484
|
+
|
|
485
|
+
const threshold = parseThreshold();
|
|
486
|
+
let errorStreak = 0;
|
|
487
|
+
const MAX_ERROR_STREAK = 3;
|
|
488
|
+
|
|
489
|
+
for (let turn = 1; turn <= maxTurns; turn++) {
|
|
490
|
+
// Exit-before-work: a CANCEL or an already-done goal short-circuits the turn.
|
|
491
|
+
if (fs.existsSync(cancelPath)) {
|
|
492
|
+
log('CANCEL file present - exiting 0');
|
|
493
|
+
process.exit(0);
|
|
494
|
+
}
|
|
495
|
+
if (isGoalDone()) {
|
|
496
|
+
log('goal DONE (all criteria pass) before turn ' + turn + ' - exiting 0');
|
|
497
|
+
process.exit(0);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
log('turn ' + turn + '/' + maxTurns + ' | session-id=' + sessionId +
|
|
501
|
+
' | mode=' + (freshSession ? 'fresh' : 'resume'));
|
|
502
|
+
|
|
503
|
+
const continuePrompt = freshSession ? prompt
|
|
504
|
+
: 'Continue the /company goal. Keep working the THINK-EXECUTE-VERIFY loop ' +
|
|
505
|
+
'until every criterion passes.';
|
|
506
|
+
const turnResult = await runTurn({
|
|
507
|
+
sessionId: sessionId,
|
|
508
|
+
freshSession: freshSession,
|
|
509
|
+
prompt: continuePrompt,
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// The turn ran. CANCEL and goal-done both win over fill.
|
|
513
|
+
if (fs.existsSync(cancelPath)) {
|
|
514
|
+
log('CANCEL file present after turn ' + turn + ' - exiting 0');
|
|
515
|
+
process.exit(0);
|
|
516
|
+
}
|
|
517
|
+
if (isGoalDone()) {
|
|
518
|
+
log('goal DONE after turn ' + turn + ' - exiting 0');
|
|
519
|
+
process.exit(0);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// A timed-out turn is NOT an error: the backstop killed a hung/over-long turn,
|
|
523
|
+
// so proceed to the fill check on the partial transcript (skip the error path).
|
|
524
|
+
if (turnResult.timedOut) {
|
|
525
|
+
log('turn ' + turn + ' hit the ' + turnTimeoutSecs +
|
|
526
|
+
's backstop - proceeding to fill on the partial transcript');
|
|
527
|
+
} else if (turnResult.code !== 0) {
|
|
528
|
+
// A turn that errored out (bad binary, crash) counts toward the error streak.
|
|
529
|
+
errorStreak += 1;
|
|
530
|
+
log('WARN turn ' + turn + ' exited ' + turnResult.code +
|
|
531
|
+
' (error streak ' + errorStreak + '/' + MAX_ERROR_STREAK + ')' +
|
|
532
|
+
(turnResult.stderrTail ? ' stderr: ' + turnResult.stderrTail.slice(-300) : ''));
|
|
533
|
+
if (errorStreak >= MAX_ERROR_STREAK) {
|
|
534
|
+
process.stderr.write(
|
|
535
|
+
'ERROR: ' + MAX_ERROR_STREAK + ' consecutive failed turns.\n' +
|
|
536
|
+
'Last exit code: ' + turnResult.code + '\n' +
|
|
537
|
+
'Check: ' + companyDir + '/NEXT.md + criteria.json + CANCEL\n'
|
|
538
|
+
);
|
|
539
|
+
process.exit(2);
|
|
540
|
+
}
|
|
541
|
+
// Retry the same session, same prompt.
|
|
542
|
+
freshSession = false;
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
errorStreak = 0;
|
|
546
|
+
|
|
547
|
+
// The supervisor owns the threshold decision (the guards do not fire headless).
|
|
548
|
+
const f = computeFill(sessionId);
|
|
549
|
+
const pct = (f.fill * 100).toFixed(1);
|
|
550
|
+
log('turn ' + turn + ' fill=' + pct + '% (' + f.used + '/' + f.window +
|
|
551
|
+
' tokens, model=' + (f.modelId || 'unknown') + ', threshold=' +
|
|
552
|
+
(threshold * 100).toFixed(0) + '%)');
|
|
553
|
+
|
|
554
|
+
if (f.fill >= threshold) {
|
|
555
|
+
// DRIVE the restart on this session, then seed a FRESH session from NEXT.md.
|
|
556
|
+
log('fill >= threshold - driving /company restart on session ' + sessionId);
|
|
557
|
+
const next = await driveRestart(sessionId);
|
|
558
|
+
if (!next) {
|
|
559
|
+
errorStreak += 1;
|
|
560
|
+
log('WARN restart markers did not appear within ' + restartTimeoutSecs +
|
|
561
|
+
's (error streak ' + errorStreak + '/' + MAX_ERROR_STREAK + ')');
|
|
562
|
+
if (errorStreak >= MAX_ERROR_STREAK) {
|
|
563
|
+
process.stderr.write(
|
|
564
|
+
'ERROR: ' + MAX_ERROR_STREAK + ' consecutive restart-drive failures.\n' +
|
|
565
|
+
'The /company restart did not write NEXT.md + RESTART_DEBATE_CONFIRMED.\n'
|
|
566
|
+
);
|
|
567
|
+
process.exit(2);
|
|
568
|
+
}
|
|
569
|
+
// Retry the same session another turn rather than seeding a blank cycle.
|
|
570
|
+
freshSession = false;
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
log('restart confirmed - fresh-context handoff (NEXT.md ' + next.length + ' chars)');
|
|
574
|
+
sessionId = randomUUID();
|
|
575
|
+
prompt = next;
|
|
576
|
+
freshSession = true;
|
|
577
|
+
errorStreak = 0;
|
|
578
|
+
// Clean the marker so the next restart starts from a clean slate.
|
|
579
|
+
try { fs.unlinkSync(debateConfirmedPath); } catch (e) {}
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Under threshold: continue the SAME session another turn.
|
|
584
|
+
freshSession = false;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
process.stderr.write(
|
|
588
|
+
'ERROR: max-turns (' + maxTurns + ') reached without goal completion.\n' +
|
|
589
|
+
'Increase --max-turns or verify criteria.json.\n'
|
|
590
|
+
);
|
|
591
|
+
process.exit(3);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
main().catch(function (e) {
|
|
595
|
+
process.stderr.write('FATAL: ' + (e && e.stack ? e.stack : e) + '\n');
|
|
596
|
+
process.exit(1);
|
|
597
|
+
});
|
package/scripts/dashboard.js
CHANGED
|
@@ -606,6 +606,15 @@ function parseContextThreshold() {
|
|
|
606
606
|
return v > 1 ? v / 100 : v;
|
|
607
607
|
}
|
|
608
608
|
|
|
609
|
+
// Sum all token fields that count against the context window.
|
|
610
|
+
// Matches the Claude Code status line: input + cache_read + cache_creation + output.
|
|
611
|
+
function usedTokens(usage) {
|
|
612
|
+
return (usage.input_tokens || 0) +
|
|
613
|
+
(usage.cache_read_input_tokens || 0) +
|
|
614
|
+
(usage.cache_creation_input_tokens || 0) +
|
|
615
|
+
(usage.output_tokens || 0);
|
|
616
|
+
}
|
|
617
|
+
|
|
609
618
|
function computeContextFill(transcriptFile, overrideModel) {
|
|
610
619
|
const threshold = parseContextThreshold();
|
|
611
620
|
if (!transcriptFile) return { used: 0, window: DEFAULT_WINDOW, fill: 0, threshold, modelId: null };
|
|
@@ -634,9 +643,7 @@ function computeContextFill(transcriptFile, overrideModel) {
|
|
|
634
643
|
return { used: 0, window: DEFAULT_WINDOW, fill: 0, threshold, modelId: null };
|
|
635
644
|
}
|
|
636
645
|
if (!lastUsage) return { used: 0, window: DEFAULT_WINDOW, fill: 0, threshold, modelId: lastModelId };
|
|
637
|
-
const used = (lastUsage
|
|
638
|
-
(lastUsage.cache_read_input_tokens || 0) +
|
|
639
|
-
(lastUsage.cache_creation_input_tokens || 0);
|
|
646
|
+
const used = usedTokens(lastUsage);
|
|
640
647
|
const contextWindow = detectWindow(lastModelId);
|
|
641
648
|
const fill = used / contextWindow;
|
|
642
649
|
return { used, window: contextWindow, fill, threshold, modelId: lastModelId };
|
|
@@ -1290,7 +1297,7 @@ footer { margin-top: 2rem; font-size: 12.5px; color: var(--dim); border-top: 1px
|
|
|
1290
1297
|
<button class="tree-btn" id="zoom-in">+</button>
|
|
1291
1298
|
<button class="tree-btn" id="zoom-out">-</button>
|
|
1292
1299
|
<button class="tree-btn" id="zoom-reset">reset</button>
|
|
1293
|
-
<button class="tree-btn" id="tree-fullscreen"
|
|
1300
|
+
<button class="tree-btn" id="tree-fullscreen" aria-label="Expand" title="Expand"><svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><polyline points="1,5 1,1 5,1"/><polyline points="9,1 13,1 13,5"/><polyline points="13,9 13,13 9,13"/><polyline points="5,13 1,13 1,9"/></svg></button>
|
|
1294
1301
|
</div>
|
|
1295
1302
|
</div>
|
|
1296
1303
|
<div class="tree-container" id="tree-container">
|
|
@@ -1356,6 +1363,28 @@ const fmtBytes = (n) => {
|
|
|
1356
1363
|
return n + ' B';
|
|
1357
1364
|
};
|
|
1358
1365
|
const shortModel = (m) => m ? String(m).replace(/^claude-/, '') : '?';
|
|
1366
|
+
// Human-readable model label: "Opus 4.8 (1M context)" style.
|
|
1367
|
+
// Keep in sync with the server-side humanizeModel() in dashboard.js.
|
|
1368
|
+
function humanizeModel(modelId) {
|
|
1369
|
+
if (!modelId) return '?';
|
|
1370
|
+
const m = String(modelId).toLowerCase();
|
|
1371
|
+
let name;
|
|
1372
|
+
if (m.includes('fable') || m.includes('mythos')) name = 'Fable 5';
|
|
1373
|
+
else if (m.includes('opus-4-8')) name = 'Opus 4.8';
|
|
1374
|
+
else if (m.includes('opus-4-5')) name = 'Opus 4.5';
|
|
1375
|
+
else if (m.includes('opus-4')) name = 'Opus 4';
|
|
1376
|
+
else if (m.includes('sonnet-4')) name = 'Sonnet 4';
|
|
1377
|
+
else if (m.includes('sonnet-3-7')) name = 'Sonnet 3.7';
|
|
1378
|
+
else if (m.includes('sonnet-3-5')) name = 'Sonnet 3.5';
|
|
1379
|
+
else if (m.includes('sonnet')) name = 'Sonnet';
|
|
1380
|
+
else if (m.includes('haiku')) name = 'Haiku';
|
|
1381
|
+
else name = modelId.replace(/^claude-/, '');
|
|
1382
|
+
// 1M window: ids containing [1m] or known opus-4 family
|
|
1383
|
+
const is1M = m.includes('[1m]') || m.includes('claude-opus-4') ||
|
|
1384
|
+
m.includes('fable') || m.includes('mythos');
|
|
1385
|
+
const win = is1M ? '1M context' : '200K context';
|
|
1386
|
+
return name + ' (' + win + ')';
|
|
1387
|
+
}
|
|
1359
1388
|
|
|
1360
1389
|
function renderHeader(s) {
|
|
1361
1390
|
const stats = $('stats');
|
|
@@ -1375,8 +1404,8 @@ function renderHeader(s) {
|
|
|
1375
1404
|
const su = t.session && t.session.usage;
|
|
1376
1405
|
add('session cost', su ? fmtUsd(su.cost) : '?', su ? fmtTok(su.total) + ' tokens' : 'session ' + (t.session && t.session.id || '?'));
|
|
1377
1406
|
if (s.savings && s.savings.tieringSaved !== null) {
|
|
1378
|
-
add('saved by model tiering', fmtUsd(s.savings.tieringSaved) + (s.savings.estimated ? ' est.' : ''), 'vs all-' + shortModel(s.savings.topTierModel) + ' (current top tier)');
|
|
1379
|
-
add('saved by prompt caching', fmtUsd(s.savings.cacheSaved) + (s.savings.estimated ? ' est.' : ''), 'cache reads vs full input price');
|
|
1407
|
+
add('saved by model tiering', fmtUsd(s.savings.tieringSaved) + (s.savings.estimated ? ' est.' : ''), 'approx - vs all-' + shortModel(s.savings.topTierModel) + ' (current top tier)');
|
|
1408
|
+
add('saved by prompt caching', fmtUsd(s.savings.cacheSaved) + (s.savings.estimated ? ' est.' : ''), 'approx - cache reads vs full input price');
|
|
1380
1409
|
}
|
|
1381
1410
|
}
|
|
1382
1411
|
const bar = $('splitbar'), legend = $('legend');
|
|
@@ -1408,7 +1437,7 @@ function renderBand(s) {
|
|
|
1408
1437
|
band.appendChild(d);
|
|
1409
1438
|
};
|
|
1410
1439
|
add('policy', p.policy || '?');
|
|
1411
|
-
add('session model',
|
|
1440
|
+
add('session model', humanizeModel(p.sessionModel));
|
|
1412
1441
|
add('owners', String(p.ownerCount ?? '?'));
|
|
1413
1442
|
if (p.halt) band.appendChild(el('span', 'halt', 'HALT REQUESTED'));
|
|
1414
1443
|
if (s.warning) {
|
|
@@ -1489,7 +1518,7 @@ function renderContextFill(s) {
|
|
|
1489
1518
|
const row = el('div', 'ctx-row');
|
|
1490
1519
|
row.appendChild(el('span', 'ctx-pct ' + colorClass, pct + '%'));
|
|
1491
1520
|
row.appendChild(el('span', 'muted', fmtTok(ctx.used) + ' / ' + fmtTok(ctx.window) + ' tokens'));
|
|
1492
|
-
if (ctx.modelId) row.appendChild(el('span', 'muted',
|
|
1521
|
+
if (ctx.modelId) row.appendChild(el('span', 'muted', humanizeModel(ctx.modelId)));
|
|
1493
1522
|
if (ctx.fill >= ctx.threshold && ctx.enforceRestart !== false) row.appendChild(el('span', 'pill warn', 'restart due'));
|
|
1494
1523
|
root.appendChild(row);
|
|
1495
1524
|
}
|
|
@@ -1585,61 +1614,86 @@ function nodeFill(status, tier) {
|
|
|
1585
1614
|
return 'rgba(89,99,110,0.06)';
|
|
1586
1615
|
}
|
|
1587
1616
|
|
|
1588
|
-
// Compute x/y positions for each node
|
|
1617
|
+
// Compute x/y positions for each node with no overlapping rectangles.
|
|
1618
|
+
// Strategy: compute each tier-1 subtree width, pack left-to-right with a gap,
|
|
1619
|
+
// center each lead over its children, grow total canvas to fit.
|
|
1589
1620
|
function layoutTree(org) {
|
|
1590
1621
|
const nodes = org.nodes || [];
|
|
1591
1622
|
if (!nodes.length) return { placed: {}, W: 400, H: 100 };
|
|
1592
|
-
const W = 900;
|
|
1593
1623
|
const nodeW = 140, nodeH = 36;
|
|
1594
|
-
const
|
|
1624
|
+
const colGap = 12; // horizontal gap between node columns
|
|
1625
|
+
const rowGap = 14; // vertical gap between rows within a subtree
|
|
1626
|
+
const leadGap = 24; // horizontal gap between adjacent subtrees
|
|
1627
|
+
const tierY = { 0: 40, 1: 140, 2: 256 };
|
|
1595
1628
|
const placed = {};
|
|
1596
1629
|
|
|
1597
1630
|
const tier0 = nodes.filter(n => n.tier === 0);
|
|
1598
1631
|
const tier1 = nodes.filter(n => n.tier === 1);
|
|
1599
1632
|
const tier2 = nodes.filter(n => n.tier === 2);
|
|
1600
1633
|
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
const t1Step = tier1.length > 0 ? Math.max(nodeW + 16, W / tier1.length) : W;
|
|
1604
|
-
tier1.forEach((n, i) => {
|
|
1605
|
-
placed[n.id] = { x: Math.max(0, t1Step * i + (t1Step - nodeW) / 2), y: tierY[1] };
|
|
1606
|
-
});
|
|
1607
|
-
|
|
1608
|
-
// Group tier-2 under their lead
|
|
1634
|
+
// Build tier-2 children list per tier-1 lead (only direct edges)
|
|
1609
1635
|
const byLead = {};
|
|
1610
1636
|
for (const e of (org.edges || [])) {
|
|
1611
1637
|
if (!byLead[e.from]) byLead[e.from] = [];
|
|
1612
1638
|
byLead[e.from].push(e.to);
|
|
1613
1639
|
}
|
|
1614
|
-
|
|
1615
|
-
for (
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
const
|
|
1619
|
-
|
|
1620
|
-
|
|
1640
|
+
|
|
1641
|
+
// Subtree width for a tier-1 node = max(nodeW, cols*nodeW + (cols-1)*colGap)
|
|
1642
|
+
// where cols = min(3, childCount)
|
|
1643
|
+
function subtreeWidth(leadId) {
|
|
1644
|
+
const children = (byLead[leadId] || []).filter(cid => {
|
|
1645
|
+
const cn = nodes.find(n => n.id === cid);
|
|
1646
|
+
return cn && cn.tier === 2;
|
|
1647
|
+
});
|
|
1648
|
+
if (!children.length) return nodeW;
|
|
1649
|
+
const cols = Math.min(3, children.length);
|
|
1650
|
+
return cols * nodeW + (cols - 1) * colGap;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// Place tier-1 subtrees left to right, each subtree occupying its full width.
|
|
1654
|
+
let cursor = 0;
|
|
1655
|
+
let totalH = tierY[2] + nodeH + 20;
|
|
1656
|
+
|
|
1657
|
+
tier1.forEach((lead) => {
|
|
1658
|
+
const sw = subtreeWidth(lead.id);
|
|
1659
|
+
// Center the lead node over its subtree band
|
|
1660
|
+
placed[lead.id] = { x: cursor + (sw - nodeW) / 2, y: tierY[1] };
|
|
1661
|
+
|
|
1662
|
+
// Place tier-2 children under this lead, left-aligned within the band
|
|
1663
|
+
const children = (byLead[lead.id] || []).filter(cid => {
|
|
1621
1664
|
const cn = nodes.find(n => n.id === cid);
|
|
1622
1665
|
return cn && cn.tier === 2;
|
|
1623
1666
|
});
|
|
1624
|
-
const cols = Math.min(3,
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
const
|
|
1630
|
-
const row = Math.floor(i / 3);
|
|
1631
|
-
const y = tierY[2] + row * (nodeH + 14);
|
|
1632
|
-
const x = startX + col * slotW;
|
|
1667
|
+
const cols = Math.min(3, children.length);
|
|
1668
|
+
children.forEach((cid, i) => {
|
|
1669
|
+
const col = i % cols;
|
|
1670
|
+
const row = Math.floor(i / cols);
|
|
1671
|
+
const x = cursor + col * (nodeW + colGap);
|
|
1672
|
+
const y = tierY[2] + row * (nodeH + rowGap);
|
|
1633
1673
|
placed[cid] = { x, y };
|
|
1634
1674
|
if (y + nodeH + 20 > totalH) totalH = y + nodeH + 20;
|
|
1635
1675
|
});
|
|
1636
|
-
|
|
1676
|
+
|
|
1677
|
+
cursor += sw + leadGap;
|
|
1678
|
+
});
|
|
1679
|
+
|
|
1680
|
+
// Orphan tier-2 nodes (no lead parent placed): append at the right edge
|
|
1637
1681
|
for (const n of tier2) {
|
|
1638
1682
|
if (!placed[n.id]) {
|
|
1639
|
-
placed[n.id] = { x:
|
|
1683
|
+
placed[n.id] = { x: cursor, y: tierY[2] };
|
|
1684
|
+
cursor += nodeW + colGap;
|
|
1640
1685
|
}
|
|
1641
1686
|
}
|
|
1642
|
-
|
|
1687
|
+
|
|
1688
|
+
// Total canvas width: max of cursor (after all subtrees) and a minimum
|
|
1689
|
+
const totalW = Math.max(400, cursor - leadGap);
|
|
1690
|
+
|
|
1691
|
+
// Center tier-0 (CEO) over the whole canvas
|
|
1692
|
+
if (tier0.length) {
|
|
1693
|
+
placed[tier0[0].id] = { x: totalW / 2 - nodeW / 2, y: tierY[0] };
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
return { placed, W: totalW, H: Math.max(320, totalH) };
|
|
1643
1697
|
}
|
|
1644
1698
|
|
|
1645
1699
|
function renderTree(s) {
|
|
@@ -1892,6 +1946,17 @@ function setupTreeInteractions() {
|
|
|
1892
1946
|
if (!document.fullscreenElement) card.requestFullscreen().catch(() => {});
|
|
1893
1947
|
else document.exitFullscreen().catch(() => {});
|
|
1894
1948
|
});
|
|
1949
|
+
// SVG icons for expand (two arrows out) and contract (two arrows in)
|
|
1950
|
+
const ICON_EXPAND = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><polyline points="1,5 1,1 5,1"/><polyline points="9,1 13,1 13,5"/><polyline points="13,9 13,13 9,13"/><polyline points="5,13 1,13 1,9"/></svg>';
|
|
1951
|
+
const ICON_CONTRACT = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><polyline points="5,1 5,5 1,5"/><polyline points="13,5 9,5 9,1"/><polyline points="9,13 9,9 13,9"/><polyline points="1,9 5,9 5,13"/></svg>';
|
|
1952
|
+
document.addEventListener('fullscreenchange', () => {
|
|
1953
|
+
const btn = $('tree-fullscreen');
|
|
1954
|
+
if (!btn) return;
|
|
1955
|
+
const active = !!document.fullscreenElement;
|
|
1956
|
+
btn.innerHTML = active ? ICON_CONTRACT : ICON_EXPAND;
|
|
1957
|
+
btn.setAttribute('aria-label', active ? 'Exit fullscreen' : 'Expand');
|
|
1958
|
+
btn.setAttribute('title', active ? 'Exit fullscreen' : 'Expand');
|
|
1959
|
+
});
|
|
1895
1960
|
}
|
|
1896
1961
|
|
|
1897
1962
|
// Per-feed-item expand: sessionStorage set of expanded item keys, keyed per session
|
|
@@ -2019,7 +2084,7 @@ function renderCriteria(s) {
|
|
|
2019
2084
|
dot.style.flexShrink = '0';
|
|
2020
2085
|
header.appendChild(dot);
|
|
2021
2086
|
const titleSpan = el('span');
|
|
2022
|
-
titleSpan.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
|
|
2087
|
+
titleSpan.style.cssText = 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
|
|
2023
2088
|
titleSpan.textContent = itemId + '. ' + item.description;
|
|
2024
2089
|
header.appendChild(titleSpan);
|
|
2025
2090
|
const caret = el('span', 'muted');
|
|
@@ -2071,11 +2136,10 @@ function renderCycles(s) {
|
|
|
2071
2136
|
thead.appendChild(hr);
|
|
2072
2137
|
table.appendChild(thead);
|
|
2073
2138
|
const tbody = el('tbody');
|
|
2139
|
+
// Show counts and dates as headlines; drop bare byte sizes as primary values.
|
|
2074
2140
|
const rows = [
|
|
2075
|
-
['
|
|
2076
|
-
['briefings', (c.briefingCount ?? '?')
|
|
2077
|
-
['playbook', fmtBytes(c.playbookBytes) + (c.playbookGrowthBytes ? ' (+' + fmtBytes(c.playbookGrowthBytes) + ')' : '')],
|
|
2078
|
-
['active tasks', fmtBytes(c.activeTasksBytes)],
|
|
2141
|
+
['cycles run', String(c.cycleDirs ?? '?')],
|
|
2142
|
+
['briefings logged', String(c.briefingCount ?? '?')],
|
|
2079
2143
|
['last compaction', c.lastCompaction ? new Date(c.lastCompaction).toLocaleString() : 'none seen'],
|
|
2080
2144
|
['compactions observed', String(c.compactionsObserved ?? 0)],
|
|
2081
2145
|
];
|
|
@@ -2239,4 +2303,91 @@ server.on('error', (err) => {
|
|
|
2239
2303
|
process.exit(1);
|
|
2240
2304
|
});
|
|
2241
2305
|
|
|
2242
|
-
|
|
2306
|
+
// Guard: only bind the port when run directly, not when require()'d for tests.
|
|
2307
|
+
if (require.main === module) startListening();
|
|
2308
|
+
|
|
2309
|
+
// Map a raw model id to a human-readable label with context-window note.
|
|
2310
|
+
// Mirrors the client-side copy inside PAGE for testability.
|
|
2311
|
+
function humanizeModel(modelId) {
|
|
2312
|
+
if (!modelId) return '?';
|
|
2313
|
+
const m = String(modelId).toLowerCase();
|
|
2314
|
+
let name;
|
|
2315
|
+
if (m.includes('fable') || m.includes('mythos')) name = 'Fable 5';
|
|
2316
|
+
else if (m.includes('opus-4-8')) name = 'Opus 4.8';
|
|
2317
|
+
else if (m.includes('opus-4-5')) name = 'Opus 4.5';
|
|
2318
|
+
else if (m.includes('opus-4')) name = 'Opus 4';
|
|
2319
|
+
else if (m.includes('sonnet-4')) name = 'Sonnet 4';
|
|
2320
|
+
else if (m.includes('sonnet-3-7')) name = 'Sonnet 3.7';
|
|
2321
|
+
else if (m.includes('sonnet-3-5')) name = 'Sonnet 3.5';
|
|
2322
|
+
else if (m.includes('sonnet')) name = 'Sonnet';
|
|
2323
|
+
else if (m.includes('haiku')) name = 'Haiku';
|
|
2324
|
+
else name = modelId.replace(/^claude-/, '');
|
|
2325
|
+
const win = is1MModel(modelId) ? '1M context' : '200K context';
|
|
2326
|
+
return name + ' (' + win + ')';
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
// Server-side copy of layoutTree (mirrors the client-side copy in PAGE) for test export.
|
|
2330
|
+
// Keep in sync with the client-side definition inside the PAGE template.
|
|
2331
|
+
function layoutTree(org) {
|
|
2332
|
+
const nodes = org.nodes || [];
|
|
2333
|
+
if (!nodes.length) return { placed: {}, W: 400, H: 100 };
|
|
2334
|
+
const nodeW = 140, nodeH = 36;
|
|
2335
|
+
const colGap = 12;
|
|
2336
|
+
const rowGap = 14;
|
|
2337
|
+
const leadGap = 24;
|
|
2338
|
+
const tierY = { 0: 40, 1: 140, 2: 256 };
|
|
2339
|
+
const placed = {};
|
|
2340
|
+
const tier0 = nodes.filter(n => n.tier === 0);
|
|
2341
|
+
const tier1 = nodes.filter(n => n.tier === 1);
|
|
2342
|
+
const tier2 = nodes.filter(n => n.tier === 2);
|
|
2343
|
+
const byLead = {};
|
|
2344
|
+
for (const e of (org.edges || [])) {
|
|
2345
|
+
if (!byLead[e.from]) byLead[e.from] = [];
|
|
2346
|
+
byLead[e.from].push(e.to);
|
|
2347
|
+
}
|
|
2348
|
+
function subtreeWidth(leadId) {
|
|
2349
|
+
const children = (byLead[leadId] || []).filter(cid => {
|
|
2350
|
+
const cn = nodes.find(n => n.id === cid);
|
|
2351
|
+
return cn && cn.tier === 2;
|
|
2352
|
+
});
|
|
2353
|
+
if (!children.length) return nodeW;
|
|
2354
|
+
const cols = Math.min(3, children.length);
|
|
2355
|
+
return cols * nodeW + (cols - 1) * colGap;
|
|
2356
|
+
}
|
|
2357
|
+
let cursor = 0;
|
|
2358
|
+
let totalH = tierY[2] + nodeH + 20;
|
|
2359
|
+
tier1.forEach((lead) => {
|
|
2360
|
+
const sw = subtreeWidth(lead.id);
|
|
2361
|
+
placed[lead.id] = { x: cursor + (sw - nodeW) / 2, y: tierY[1] };
|
|
2362
|
+
const children = (byLead[lead.id] || []).filter(cid => {
|
|
2363
|
+
const cn = nodes.find(n => n.id === cid);
|
|
2364
|
+
return cn && cn.tier === 2;
|
|
2365
|
+
});
|
|
2366
|
+
const cols = Math.min(3, children.length);
|
|
2367
|
+
children.forEach((cid, i) => {
|
|
2368
|
+
const col = i % cols;
|
|
2369
|
+
const row = Math.floor(i / cols);
|
|
2370
|
+
const x = cursor + col * (nodeW + colGap);
|
|
2371
|
+
const y = tierY[2] + row * (nodeH + rowGap);
|
|
2372
|
+
placed[cid] = { x, y };
|
|
2373
|
+
if (y + nodeH + 20 > totalH) totalH = y + nodeH + 20;
|
|
2374
|
+
});
|
|
2375
|
+
cursor += sw + leadGap;
|
|
2376
|
+
});
|
|
2377
|
+
for (const n of tier2) {
|
|
2378
|
+
if (!placed[n.id]) {
|
|
2379
|
+
placed[n.id] = { x: cursor, y: tierY[2] };
|
|
2380
|
+
cursor += nodeW + colGap;
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
const totalW = Math.max(400, cursor - leadGap);
|
|
2384
|
+
if (tier0.length) {
|
|
2385
|
+
placed[tier0[0].id] = { x: totalW / 2 - nodeW / 2, y: tierY[0] };
|
|
2386
|
+
}
|
|
2387
|
+
return { placed, W: totalW, H: Math.max(320, totalH) };
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
// Test-only exports; the server path never calls require() on itself.
|
|
2391
|
+
if (typeof module !== 'undefined') {
|
|
2392
|
+
module.exports = { usedTokens, humanizeModel, layoutTree };
|
|
2393
|
+
}
|