crewforth 3.1.1 → 3.2.0-rc.10
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/CHANGELOG.md +323 -0
- package/README.md +6 -3
- package/VERSION +1 -1
- package/adopt.sh +66 -6
- package/bin/cli.js +3 -2
- package/kit/AGENT_TEMPLATE.md +2 -1
- package/kit/CLAUDE.md +15 -14
- package/kit/agents/crew-commit-agent.md +11 -4
- package/kit/agents/crew-docs-agent.md +71 -0
- package/kit/agents/crew-planner.md +2 -0
- package/kit/agents/crew-refuter.md +77 -0
- package/kit/agents/crew-review-agent.md +2 -0
- package/kit/agents/crew-security-expert.md +3 -1
- package/kit/agents/crew-session-manager.md +11 -9
- package/kit/eval/doctor.sh +128 -6
- package/kit/eval/gate-report.sh +2 -0
- package/kit/eval/golden-routing.txt +9 -0
- package/kit/eval/lib/crew-map/crew-map.sh +26 -0
- package/kit/eval/lib/crew-map/detect.mjs +238 -0
- package/kit/eval/lib/crew-map/narrative.mjs +132 -0
- package/kit/eval/lib/crew-map/parse.mjs +305 -0
- package/kit/eval/lib/crew-map/project.mjs +164 -0
- package/kit/eval/lib/crew-map/rules/names.json +38 -0
- package/kit/eval/lib/crew-map/rules/packages.json +142 -0
- package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
- package/kit/eval/lib/crew-map/rules/tech.json +35 -0
- package/kit/eval/lib/crew-map/scan.mjs +266 -0
- package/kit/eval/lib/crew-map/stacks.mjs +547 -0
- package/kit/eval/lib/crew-map/surface.mjs +535 -0
- package/kit/eval/lib/findings/findings.mjs +410 -0
- package/kit/eval/lib/findings/findings.schema.json +122 -0
- package/kit/eval/lib/findings/findings.sh +29 -0
- package/kit/eval/lib/gate-log.sh +52 -0
- package/kit/eval/lib/json-string.awk +27 -0
- package/kit/eval/lib/preset.sh +109 -0
- package/kit/eval/lib/presets/default.json +7 -0
- package/kit/eval/lib/presets/solo.json +7 -0
- package/kit/eval/lib/presets/strict.json +11 -0
- package/kit/eval/lib/preview.sh +150 -0
- package/kit/eval/lib/review-bench/bench.mjs +273 -0
- package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
- package/kit/eval/lib/review-prepass/prepass.sh +23 -0
- package/kit/eval/lib/settings-default.sh +36 -0
- package/kit/eval/lib/settings-json.awk +19 -4
- package/kit/eval/lib/star.sh +23 -7
- package/kit/eval/lib/statusline.settings.json +6 -0
- package/kit/eval/lib/statusline.sh +100 -0
- package/kit/eval/lib/verify-cmd.sh +17 -0
- package/kit/eval/lib/version.sh +41 -0
- package/kit/eval/review-bench.sh +26 -0
- package/kit/eval/smoke-test.sh +878 -53
- package/kit/hooks/agent-outcome.sh +44 -12
- package/kit/hooks/board-sync.sh +2 -23
- package/kit/hooks/guard-agent-model.sh +177 -24
- package/kit/hooks/guard-bash.sh +295 -204
- package/kit/hooks/guard-commit-scan.sh +10 -2
- package/kit/hooks/guard-question.sh +73 -0
- package/kit/hooks/guard-write.sh +24 -7
- package/kit/hooks/precompact-snapshot.sh +75 -0
- package/kit/hooks/prompt-approval.sh +389 -103
- package/kit/hooks/refuter-ledger.sh +80 -0
- package/kit/hooks/session-rehydrate.sh +25 -2
- package/kit/hooks/session-update-check.sh +9 -8
- package/kit/hooks/trace-blocklist.txt +9 -0
- package/kit/owned-blobs.tsv +4 -4
- package/kit/settings.json +51 -0
- package/kit/skills/crew-approve/SKILL.md +7 -0
- package/kit/skills/crew-code-review/SKILL.md +16 -0
- package/kit/skills/crew-doctor/SKILL.md +5 -1
- package/kit/skills/crew-handoff/SKILL.md +3 -2
- package/kit/skills/crew-map/SKILL.md +55 -0
- package/kit/skills/crew-review/SKILL.md +4 -1
- package/kit/skills/crew-ship/SKILL.md +3 -1
- package/kit/skills/crew-update/SKILL.md +1 -1
- package/kit/skills/incident-runbook/SKILL.md +2 -2
- package/kit/skills/security-scan/references/verify.md +6 -3
- package/kit/studio/README.md +25 -2
- package/kit/studio/server/index.js +187 -21
- package/kit/studio/server/lib/code-stamp.js +35 -0
- package/kit/studio/server/lib/crew-map.js +113 -0
- package/kit/studio/server/lib/graph.js +9 -1
- package/kit/studio/server/lib/kit-telemetry.js +30 -5
- package/kit/studio/server/lib/lan.js +87 -0
- package/kit/studio/server/lib/model-outcomes.js +130 -15
- package/kit/studio/server/lib/owned-stream.js +56 -0
- package/kit/studio/server/lib/pricing.js +3 -0
- package/kit/studio/server/lib/projects.js +11 -4
- package/kit/studio/server/lib/redact.js +60 -0
- package/kit/studio/server/lib/remote.js +185 -0
- package/kit/studio/server/lib/session.js +1 -1
- package/kit/studio/server/lib/terminal.js +51 -13
- package/kit/studio/server/lib/transcript.js +32 -0
- package/kit/studio/server/lib/usage.js +74 -14
- package/kit/studio/web/app.js +287 -17
- package/kit/studio/web/canvas.js +58 -122
- package/kit/studio/web/chat.js +114 -24
- package/kit/studio/web/graph-plan.js +13 -6
- package/kit/studio/web/index.html +10 -5
- package/kit/studio/web/liveness.js +14 -0
- package/kit/studio/web/map-plan.js +410 -0
- package/kit/studio/web/map.js +596 -0
- package/kit/studio/web/models-plan.js +61 -7
- package/kit/studio/web/models.js +19 -3
- package/kit/studio/web/qr.js +200 -0
- package/kit/studio/web/style.css +165 -0
- package/kit/studio/web/tokens.css +6 -0
- package/kit/studio/web/usage.js +12 -0
- package/kit/studio/web/viewport.js +160 -0
- package/package.json +6 -5
- package/start.sh +24 -1
|
@@ -21,25 +21,34 @@ import { projectDir, listSessions, findSession, listProjects, sessionCwd, agentM
|
|
|
21
21
|
import { agentOrigins } from './lib/agent-origin.js';
|
|
22
22
|
import { buildGraph, agentDetail, conversation, STALE_MS } from './lib/graph.js';
|
|
23
23
|
import { cachedUsage } from './lib/usage.js';
|
|
24
|
-
import {
|
|
24
|
+
import { streamOwned } from './lib/owned-stream.js';
|
|
25
|
+
import { stampAtStart } from './lib/code-stamp.js';
|
|
26
|
+
import { readModels, readRecord, readCosts, readAboveCosts } from './lib/model-outcomes.js';
|
|
27
|
+
import { readMap, behindOf, compareMaps, mapSummary, MAP_FILE } from './lib/crew-map.js';
|
|
25
28
|
|
|
26
29
|
const USAGE_BATCH = 12;
|
|
27
30
|
import { palette } from './lib/palette.js';
|
|
28
31
|
import { latestVersion, latestVersionCached, kitStatus } from './lib/kit.js';
|
|
29
32
|
import { parsePeers, askAll, ask } from './lib/peers.js';
|
|
30
33
|
import {
|
|
31
|
-
createSession, getSession, listSessionsOwned, reap, stopAll, dropSpools, ALLOWED_MODES,
|
|
34
|
+
createSession, getSession, listSessionsOwned, reap, stopAll, dropSpools, ALLOWED_MODES, isHeldOpen,
|
|
32
35
|
} from './lib/session.js';
|
|
33
36
|
import { decide, pending, alwaysList, revoke, sweepLeft } from './lib/permissions.js';
|
|
34
|
-
import { open as openTerminal, plan as terminalPlan } from './lib/terminal.js';
|
|
37
|
+
import { open as openTerminal, plan as terminalPlan, desktopLine } from './lib/terminal.js';
|
|
35
38
|
import { gateLog, gateReport, sessionStats, board } from './lib/kit-telemetry.js';
|
|
36
39
|
import { writeState, clearStateSync, findRunning } from './lib/instance.js';
|
|
37
40
|
import { remoteRoster } from './lib/roster.js';
|
|
41
|
+
import { addViewer, listViewers, viewerByToken, revokeViewer, noteRequest, noteRefusal, viewerMay, MAX_VIEWERS, VIEWER_TTL_MS,
|
|
42
|
+
liveCount, remoteLog, sweep as sweepViewers, onViewersChange, onLog } from './lib/remote.js';
|
|
43
|
+
import { redact, transcriptOnly } from './lib/redact.js';
|
|
44
|
+
import { lanListener } from './lib/lan.js';
|
|
38
45
|
import { randomUUID } from 'node:crypto';
|
|
39
46
|
import { spawn } from 'node:child_process';
|
|
40
47
|
|
|
41
48
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
42
49
|
const WEB_ROOT = path.resolve(HERE, '..', 'web');
|
|
50
|
+
// The server's own code as it was when it started: an update on disk since then is said to the page (code-stamp.js).
|
|
51
|
+
const codeChanged = stampAtStart(HERE);
|
|
43
52
|
const LOOPBACK = '127.0.0.1';
|
|
44
53
|
|
|
45
54
|
const MIME = {
|
|
@@ -92,8 +101,12 @@ function send(res, status, body, headers = {}) {
|
|
|
92
101
|
res.end(body);
|
|
93
102
|
}
|
|
94
103
|
|
|
104
|
+
// The answers that go to a viewer whose link hides transcripts: every one of them passes through redact() on its way
|
|
105
|
+
// out, here and in the event stream, so no route has to remember to.
|
|
106
|
+
const REDACTED = new WeakSet();
|
|
107
|
+
|
|
95
108
|
function sendJson(res, status, value) {
|
|
96
|
-
send(res, status, JSON.stringify(value, null, 2), {
|
|
109
|
+
send(res, status, JSON.stringify(REDACTED.has(res) ? redact(value) : value, null, 2), {
|
|
97
110
|
'content-type': 'application/json; charset=utf-8',
|
|
98
111
|
});
|
|
99
112
|
}
|
|
@@ -108,15 +121,35 @@ function sendJson(res, status, value) {
|
|
|
108
121
|
const TOKEN = process.env.CREW_STUDIO_TOKEN || randomUUID();
|
|
109
122
|
const TOKEN_GENERATED = !process.env.CREW_STUDIO_TOKEN;
|
|
110
123
|
|
|
124
|
+
// The Wi-Fi listener (lib/lan.js): set by main() once the port is known, and opened and closed with the number of
|
|
125
|
+
// live viewers. Null in the self-check unless it makes one.
|
|
126
|
+
let LAN = null;
|
|
127
|
+
|
|
128
|
+
/** For the self-check, which asks a real server as the owner and as a viewer. */
|
|
129
|
+
export const _internals = { token: TOKEN, setLan: (l) => { LAN = l; } };
|
|
130
|
+
|
|
111
131
|
// Peers are other machines running Studio, reached over a forwarded port. This
|
|
112
132
|
// server still listens on loopback only; a peer never widens that.
|
|
113
133
|
let PEERS = [];
|
|
114
134
|
let SELF_NAME = 'this machine';
|
|
115
135
|
|
|
116
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Whose request this is: the owner's, a read-only viewer's (lib/remote.js), or nobody's.
|
|
138
|
+
* The token is read from the same two places for both, so a viewer's link works exactly as the owner's does.
|
|
139
|
+
*/
|
|
140
|
+
function whoAsks(req, url) {
|
|
117
141
|
const header = req.headers['authorization'];
|
|
118
|
-
|
|
119
|
-
|
|
142
|
+
const bearer = typeof header === 'string' && header.startsWith('Bearer ') ? header.slice(7) : null;
|
|
143
|
+
const given = [bearer, url.searchParams.get('token')].filter(Boolean);
|
|
144
|
+
// The panel's own token is accepted on loopback only. From the Wi-Fi it is not a token at all: a phone holding it
|
|
145
|
+
// would hold the panel, and nothing on the network should.
|
|
146
|
+
if (!req.crewLan && given.includes(TOKEN)) return { role: 'owner' };
|
|
147
|
+
for (const t of given) {
|
|
148
|
+
const viewer = viewerByToken(t);
|
|
149
|
+
if (viewer?.expired) return { role: 'expired', label: viewer.expired };
|
|
150
|
+
if (viewer) return { role: 'viewer', viewer };
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
120
153
|
}
|
|
121
154
|
|
|
122
155
|
/**
|
|
@@ -167,10 +200,16 @@ async function modelCosts(cwd) {
|
|
|
167
200
|
for (const s of (await listSessions(dir)).filter((x) => x.modifiedAt >= since).slice(0, MODELS_SESSIONS_MAX)) {
|
|
168
201
|
const u = await cachedUsage(s);
|
|
169
202
|
read += 1;
|
|
170
|
-
for (const [id, a] of Object.entries(u.agents ?? {})) per.set(id, a
|
|
203
|
+
for (const [id, a] of Object.entries(u.agents ?? {})) per.set(id, a);
|
|
171
204
|
}
|
|
172
205
|
}
|
|
173
|
-
return {
|
|
206
|
+
return {
|
|
207
|
+
measured: true,
|
|
208
|
+
costs: readCosts(cwd, (id) => per.get(id)?.cost ?? null,
|
|
209
|
+
(id) => (per.get(id)?.advisorCalls ? { calls: per.get(id).advisorCalls, cost: per.get(id).advisorCost } : null)),
|
|
210
|
+
above: readAboveCosts(cwd, (id) => (per.has(id) ? { cost: per.get(id).cost, atNormal: per.get(id).costAtNormal } : null)),
|
|
211
|
+
sessionsRead: read,
|
|
212
|
+
};
|
|
174
213
|
}
|
|
175
214
|
const MODELS_SESSIONS_MAX = 60;
|
|
176
215
|
|
|
@@ -214,23 +253,52 @@ async function serveStatic(res, urlPath) {
|
|
|
214
253
|
}
|
|
215
254
|
}
|
|
216
255
|
|
|
217
|
-
async function handle(req, res) {
|
|
256
|
+
export async function handle(req, res) {
|
|
218
257
|
const url = new URL(req.url, `http://${LOOPBACK}`);
|
|
219
258
|
|
|
220
259
|
// The token guards data and actions, not the app shell. A browser does not
|
|
221
260
|
// carry the page's query string into its sub-resource requests, so demanding
|
|
222
261
|
// it for /style.css served an unstyled page and protected nothing: the shell
|
|
223
262
|
// holds no secrets, and every /api/ path below is still gated.
|
|
224
|
-
|
|
225
|
-
|
|
263
|
+
const who = url.pathname.startsWith('/api/') ? whoAsks(req, url) : null;
|
|
264
|
+
// From the Wi-Fi, no token (or the panel's own) is 401: the network is asked who it is. On loopback the answer
|
|
265
|
+
// stays 403, as it always was.
|
|
266
|
+
if (url.pathname.startsWith('/api/') && !who) {
|
|
267
|
+
if (req.crewLan) noteRefusal(req, { status: 401, reason: 'no viewer token' });
|
|
268
|
+
return sendJson(res, req.crewLan ? 401 : 403, { ok: false, reason: 'token required' });
|
|
269
|
+
}
|
|
270
|
+
if (who?.role === 'expired') {
|
|
271
|
+
noteRefusal(req, { viewer: who.label, status: 401, reason: 'expired' });
|
|
272
|
+
return sendJson(res, 401, { ok: false, expired: true, reason: 'this link has expired: viewer links last eight hours' });
|
|
273
|
+
}
|
|
274
|
+
// A viewer is refused HERE, before any handler: no route below has to remember that a viewer exists, and a
|
|
275
|
+
// route added tomorrow is read-only for a viewer without anyone writing a line for it. The same holds for a
|
|
276
|
+
// link that hides transcripts: the routes that are nothing but transcript are refused here, and every other
|
|
277
|
+
// answer is filtered on its way out (REDACTED).
|
|
278
|
+
if (who?.role === 'viewer') {
|
|
279
|
+
const may = viewerMay(req.method, url.pathname);
|
|
280
|
+
if (!may.ok) { noteRefusal(req, { viewer: who.viewer.label, status: 403, reason: may.reason }); return sendJson(res, 403, { ok: false, reason: may.reason }); }
|
|
281
|
+
if (!who.viewer.transcripts) {
|
|
282
|
+
if (transcriptOnly(url.pathname)) {
|
|
283
|
+
noteRefusal(req, { viewer: who.viewer.label, status: 403, reason: 'transcripts hidden' });
|
|
284
|
+
return sendJson(res, 403, { ok: false, transcriptsHidden: true, reason: 'transcripts are hidden on this link' });
|
|
285
|
+
}
|
|
286
|
+
REDACTED.add(res);
|
|
287
|
+
}
|
|
288
|
+
noteRequest(who.viewer, req, res);
|
|
226
289
|
}
|
|
227
290
|
|
|
228
291
|
if (url.pathname === '/api/health') {
|
|
229
292
|
return sendJson(res, 200, {
|
|
230
293
|
ok: true,
|
|
294
|
+
role: who.role,
|
|
295
|
+
...(who.role === 'viewer' ? { transcripts: who.viewer.transcripts, expiresAt: who.viewer.expiresAt } : {}),
|
|
231
296
|
node: process.version,
|
|
232
297
|
pid: process.pid,
|
|
233
298
|
uptimeMs: Math.round(process.uptime() * 1000),
|
|
299
|
+
// `changed`: the server's files on disk are not the ones it started with. A page that finds no `code` at all
|
|
300
|
+
// is talking to a server older than this field, which is the same thing.
|
|
301
|
+
code: codeChanged(),
|
|
234
302
|
});
|
|
235
303
|
}
|
|
236
304
|
|
|
@@ -288,7 +356,12 @@ async function handle(req, res) {
|
|
|
288
356
|
// dead time whenever the feed hung, measured. The refresh runs behind this response.
|
|
289
357
|
const latest = latestVersionCached();
|
|
290
358
|
const projects = await listProjects({ currentCwd: cwd, kitOf: (c) => kitStatus(c, latest) });
|
|
291
|
-
for (const p of projects) {
|
|
359
|
+
for (const p of projects) {
|
|
360
|
+
p.origin = SELF_NAME; p.local = true;
|
|
361
|
+
// The navigator's Map entry and its count: the findings the map shows unasked. A stat per project; the map
|
|
362
|
+
// is parsed again only when its file changes (crew-map.js).
|
|
363
|
+
p.map = p.exists && p.cwd ? mapSummary(p.cwd) : null;
|
|
364
|
+
}
|
|
292
365
|
|
|
293
366
|
const origins = [{ name: SELF_NAME, local: true, ok: true, projects: projects.length }];
|
|
294
367
|
if (PEERS.length) {
|
|
@@ -339,7 +412,7 @@ async function handle(req, res) {
|
|
|
339
412
|
const sid = url.searchParams.get('session');
|
|
340
413
|
const session = sid ? await findSession(sid) : null;
|
|
341
414
|
const cwd = url.searchParams.get('cwd') || (session ? await sessionCwd(session.file, session.bytes) : null);
|
|
342
|
-
if (!cwd) return sendJson(res, 200, { measured: false, reason: 'no project named', classes: [], suggestions: [], byAgent: {} });
|
|
415
|
+
if (!cwd) return sendJson(res, 200, { measured: false, reason: 'no project named', classes: [], suggestions: [], above: [], byAgent: {} });
|
|
343
416
|
return sendJson(res, 200, readModels(cwd, { agents: await agentIdsOf(session) }));
|
|
344
417
|
}
|
|
345
418
|
|
|
@@ -347,10 +420,29 @@ async function handle(req, res) {
|
|
|
347
420
|
const sid = url.searchParams.get('session');
|
|
348
421
|
const session = sid ? await findSession(sid) : null;
|
|
349
422
|
const cwd = url.searchParams.get('cwd') || (session ? await sessionCwd(session.file, session.bytes) : null);
|
|
350
|
-
if (!cwd) return sendJson(res, 200, { measured: false, reason: 'no project named', costs: {}, sessionsRead: 0 });
|
|
423
|
+
if (!cwd) return sendJson(res, 200, { measured: false, reason: 'no project named', costs: {}, above: {}, sessionsRead: 0 });
|
|
351
424
|
return sendJson(res, 200, await modelCosts(cwd));
|
|
352
425
|
}
|
|
353
426
|
|
|
427
|
+
// The project's crew map, as Crewforth's scanner wrote it, and how far HEAD has moved since. Nothing is scanned here.
|
|
428
|
+
// `compare` names another project of this machine by its directory; only a project the panel lists, with a map
|
|
429
|
+
// of its own, is read, so the route cannot be pointed at an arbitrary path.
|
|
430
|
+
if (url.pathname === '/api/map') {
|
|
431
|
+
const sid = url.searchParams.get('session');
|
|
432
|
+
const session = sid ? await findSession(sid) : null;
|
|
433
|
+
const cwd = url.searchParams.get('cwd') || (session ? await sessionCwd(session.file, session.bytes) : null);
|
|
434
|
+
if (!cwd) return sendJson(res, 200, { measured: false, reason: 'no project named', others: [] });
|
|
435
|
+
const map = readMap(cwd);
|
|
436
|
+
const others = (await listProjects({ currentCwd: cwd })).filter((p) => p.cwd && p.cwd !== cwd && readMap(p.cwd).measured)
|
|
437
|
+
.map((p) => ({ label: p.label, cwd: p.cwd }));
|
|
438
|
+
if (!map.measured) return sendJson(res, 200, { ...map, cwd, others });
|
|
439
|
+
const asked = url.searchParams.get('compare');
|
|
440
|
+
const peer = asked ? others.find((o) => o.cwd === asked) : null;
|
|
441
|
+
const compare = peer ? { label: peer.label, cwd: peer.cwd, clients: compareMaps(map.doc, readMap(peer.cwd).doc) }
|
|
442
|
+
: asked ? { label: null, cwd: asked, clients: [], reason: 'not a project of this machine with a crew map' } : null;
|
|
443
|
+
return sendJson(res, 200, { ...map, cwd, behind: await behindOf(cwd, map.doc.source?.commit), others, compare });
|
|
444
|
+
}
|
|
445
|
+
|
|
354
446
|
if (url.pathname === '/api/kit') {
|
|
355
447
|
const cwd = url.searchParams.get('cwd') || process.cwd();
|
|
356
448
|
const sid = url.searchParams.get('session');
|
|
@@ -441,8 +533,12 @@ async function handle(req, res) {
|
|
|
441
533
|
const cwd = url.searchParams.get('cwd') || (session ? await sessionCwd(session.file, session.bytes) : null);
|
|
442
534
|
|
|
443
535
|
if (req.method === 'GET') {
|
|
444
|
-
// What WOULD run, so the panel can show it before anything is launched
|
|
445
|
-
|
|
536
|
+
// What WOULD run, so the panel can show it before anything is launched; and, before Continue here is pressed,
|
|
537
|
+
// what it will do: continue this same session (nothing else holds it), or start a copy (something does). The
|
|
538
|
+
// answer is the one createSession acts on, asked the same way.
|
|
539
|
+
const held = await isHeldOpen(id);
|
|
540
|
+
return sendJson(res, 200, { measured: true, cwd, plan: cwd ? terminalPlan({ cwd, sessionId: id }) : null,
|
|
541
|
+
continueAs: held ? 'copy' : 'same', desktop: cwd ? desktopLine({ cwd, sessionId: id }) : null });
|
|
446
542
|
}
|
|
447
543
|
|
|
448
544
|
const gate = writeAllowed(req);
|
|
@@ -454,7 +550,6 @@ async function handle(req, res) {
|
|
|
454
550
|
const out = openTerminal({
|
|
455
551
|
cwd: typeof body.cwd === 'string' ? body.cwd : cwd,
|
|
456
552
|
sessionId: id,
|
|
457
|
-
command: typeof body.command === 'string' ? body.command : undefined,
|
|
458
553
|
});
|
|
459
554
|
return sendJson(res, out.ok ? 200 : 400, out);
|
|
460
555
|
}
|
|
@@ -463,6 +558,36 @@ async function handle(req, res) {
|
|
|
463
558
|
return stream(req, res, url);
|
|
464
559
|
}
|
|
465
560
|
|
|
561
|
+
/* --------------------------------------------------------- remote ---
|
|
562
|
+
Read-only viewers: who holds a link, and the way to take it back. Only
|
|
563
|
+
the owner reaches this far; a viewer was refused above. */
|
|
564
|
+
|
|
565
|
+
if (url.pathname === '/api/remote' && req.method === 'GET') {
|
|
566
|
+
return sendJson(res, 200, { measured: true, viewers: listViewers(), max: MAX_VIEWERS, ttlMs: VIEWER_TTL_MS, now: Date.now(),
|
|
567
|
+
lan: LAN ? LAN.state() : { listening: false, addresses: [], errors: [] }, log: remoteLog(30) });
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const remoteMatch = url.pathname.match(/^\/api\/remote(?:\/([^/]+))?$/);
|
|
571
|
+
if (remoteMatch) {
|
|
572
|
+
const gate = writeAllowed(req);
|
|
573
|
+
if (!gate.ok) return sendJson(res, 403, { ok: false, reason: gate.reason });
|
|
574
|
+
if (req.method === 'POST' && !remoteMatch[1]) {
|
|
575
|
+
const body = await readBody(req);
|
|
576
|
+
if (!body) return sendJson(res, 400, { ok: false, reason: 'body was not JSON' });
|
|
577
|
+
const made = addViewer(body.label, { transcripts: body.transcripts === true });
|
|
578
|
+
// The Wi-Fi listener opens with the first viewer; its links are known once it listens.
|
|
579
|
+
if (made.ok && LAN) await LAN.sync(liveCount());
|
|
580
|
+
const lanUrls = made.ok && LAN ? LAN.urls(made.viewer.token) : [];
|
|
581
|
+
return sendJson(res, made.ok ? 201 : 409, { ...made, lanUrls, lan: LAN ? LAN.state() : null, viewers: listViewers() });
|
|
582
|
+
}
|
|
583
|
+
if (req.method === 'DELETE' && remoteMatch[1]) {
|
|
584
|
+
const out = revokeViewer(decodeURIComponent(remoteMatch[1]));
|
|
585
|
+
if (LAN) await LAN.sync(liveCount());
|
|
586
|
+
return sendJson(res, 200, { ...out, lan: LAN ? LAN.state() : null, viewers: listViewers() });
|
|
587
|
+
}
|
|
588
|
+
return sendJson(res, 405, { ok: false, reason: 'POST to add a viewer, or DELETE a viewer id' });
|
|
589
|
+
}
|
|
590
|
+
|
|
466
591
|
/* ---------------------------------------------------------- owned ---
|
|
467
592
|
Sessions the panel started. Their transcripts land in the usual place,
|
|
468
593
|
so the graph endpoints already cover them; only the conversation needs
|
|
@@ -525,6 +650,10 @@ async function handle(req, res) {
|
|
|
525
650
|
return sendJson(res, out.ok ? 200 : 400, out);
|
|
526
651
|
}
|
|
527
652
|
|
|
653
|
+
// Every open conversation on ONE connection (lib/owned-stream.js). Reading, so it sits beside /events, not
|
|
654
|
+
// behind the write gate.
|
|
655
|
+
if (url.pathname === '/api/owned-events') return streamOwned(req, res, url, { getSession, stateAfter: STATE_AFTER });
|
|
656
|
+
|
|
528
657
|
const ownedMatch = url.pathname.match(/^\/api\/owned\/([^/]+)(?:\/(message|stop|events|mode))?$/);
|
|
529
658
|
if (ownedMatch) {
|
|
530
659
|
const s = getSession(decodeURIComponent(ownedMatch[1]));
|
|
@@ -655,7 +784,7 @@ async function stream(req, res, url) {
|
|
|
655
784
|
});
|
|
656
785
|
|
|
657
786
|
const send = (event, data) => {
|
|
658
|
-
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
787
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(REDACTED.has(res) ? redact(data) : data)}\n\n`);
|
|
659
788
|
};
|
|
660
789
|
|
|
661
790
|
// A session the panel just started has no transcript until its first turn
|
|
@@ -830,6 +959,28 @@ async function selftest() {
|
|
|
830
959
|
fleet.measured ? `${fleet.sessions.length} session(s)` : `not measured: ${fleet.reason}`,
|
|
831
960
|
);
|
|
832
961
|
|
|
962
|
+
// The Map view: its files are installed, and the reader takes a crew map and refuses one of another schema. On a
|
|
963
|
+
// scratch map of its own, so the verdict does not depend on whether this project has one; this project's own map,
|
|
964
|
+
// when there is one, is reported beside it.
|
|
965
|
+
check('map view installed', ['map.js', 'map-plan.js', 'viewport.js'].every((f) => fs.existsSync(path.join(WEB_ROOT, f))), null);
|
|
966
|
+
{
|
|
967
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'crew-studio-map-'));
|
|
968
|
+
try {
|
|
969
|
+
fs.mkdirSync(path.join(dir, '.crewforth'));
|
|
970
|
+
const doc = { schema: 'crewforth.crew-map/1', nodes: [{ id: 'mod:x:a', kind: 'module', name: 'a' }], edges: [], findings: [], notMeasured: [] };
|
|
971
|
+
fs.writeFileSync(path.join(dir, MAP_FILE), JSON.stringify(doc));
|
|
972
|
+
const read = readMap(dir);
|
|
973
|
+
// A different size, not only a different schema: the reader caches on mtime and size, and on Windows both writes
|
|
974
|
+
// land on one timestamp, so a same-size rewrite read back the first map (seen on a CI runner).
|
|
975
|
+
fs.writeFileSync(path.join(dir, MAP_FILE), JSON.stringify({ ...doc, schema: 'crewforth.crew-map/99' }));
|
|
976
|
+
const other = readMap(dir);
|
|
977
|
+
check('map read', read.measured === true && read.doc.nodes.length === 1 && other.measured === false,
|
|
978
|
+
read.measured ? `a scratch map read; one of another schema refused (${other.reason ?? 'read'})` : read.reason);
|
|
979
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
980
|
+
const own = readMap(process.cwd());
|
|
981
|
+
process.stdout.write(`INFO this project's map — ${own.measured ? `${own.doc.nodes.length} node(s), ${own.doc.findings.length} finding(s)` : own.reason}\n`);
|
|
982
|
+
}
|
|
983
|
+
|
|
833
984
|
const cost = await measureSpawnCost(3);
|
|
834
985
|
if (cost === null) skip('spawn cost measured', 'claude CLI absent');
|
|
835
986
|
else check('spawn cost measured', true, `min ${cost.minMs}ms · median ${cost.medianMs}ms · max ${cost.maxMs}ms`);
|
|
@@ -877,6 +1028,11 @@ async function main() {
|
|
|
877
1028
|
' --help this text\n\n' +
|
|
878
1029
|
'Set CREW_STUDIO_TOKEN to require a bearer token, CREW_STUDIO_PEERS for a\n' +
|
|
879
1030
|
'comma-separated peer list.\n\n' +
|
|
1031
|
+
'To watch from a phone on the same Wi-Fi, add a viewer under Machines in\n' +
|
|
1032
|
+
'the panel and scan its QR code. A viewer reads and changes nothing, its\n' +
|
|
1033
|
+
'link lasts eight hours, and transcripts stay hidden unless you show them.\n' +
|
|
1034
|
+
'While a viewer is live the server also listens on this machine\'s Wi-Fi\n' +
|
|
1035
|
+
'address, for viewer links only; with none it is on loopback alone.\n\n' +
|
|
880
1036
|
'Peers are normally reached over a forwarded port, which keeps every\n' +
|
|
881
1037
|
'Studio on loopback:\n' +
|
|
882
1038
|
' ssh -N -L 7778:127.0.0.1:7777 other-machine\n' +
|
|
@@ -893,12 +1049,21 @@ async function main() {
|
|
|
893
1049
|
// Only a server that is about to run does this; asking for --help or --selftest removes nothing.
|
|
894
1050
|
sweepLeft();
|
|
895
1051
|
|
|
896
|
-
const
|
|
1052
|
+
const handler = (req, res) => {
|
|
897
1053
|
handle(req, res).catch((e) => {
|
|
898
1054
|
process.stderr.write(`crewforth-studio: unhandled: ${e?.stack ?? e}\n`);
|
|
899
1055
|
if (!res.headersSent) send(res, 500, 'internal error');
|
|
900
1056
|
});
|
|
901
|
-
}
|
|
1057
|
+
};
|
|
1058
|
+
const server = http.createServer(handler);
|
|
1059
|
+
|
|
1060
|
+
// Viewers on the same Wi-Fi: a second listener on this machine's private address, open only while a viewer is
|
|
1061
|
+
// live (lib/lan.js). It closes when the last one is revoked or expires; the sweep ends expired ones even when no
|
|
1062
|
+
// request comes to notice them.
|
|
1063
|
+
LAN = lanListener({ handler, port: args.port });
|
|
1064
|
+
onViewersChange((n) => { LAN.sync(n); });
|
|
1065
|
+
onLog((e) => process.stdout.write(` viewer ${e.event}: ${e.viewer ?? 'unknown'}${e.ip ? ` from ${e.ip}` : ''}${e.over ? ` (${e.over})` : ''}${e.result ? ` — ${e.result}` : ''}\n`));
|
|
1066
|
+
setInterval(sweepViewers, 60 * 1000).unref();
|
|
902
1067
|
|
|
903
1068
|
// A BUSY PORT IS NOT AUTOMATICALLY A PROBLEM. Before 2.10.2 this printed "try --port n+1" and exited 1, which
|
|
904
1069
|
// is the right answer only when a stranger holds the port. When the holder is our OWN panel -- the common case,
|
|
@@ -979,6 +1144,7 @@ async function main() {
|
|
|
979
1144
|
// leaks running sessions is worse than one that never started them.
|
|
980
1145
|
stopAll();
|
|
981
1146
|
dropSpools();
|
|
1147
|
+
LAN?.close();
|
|
982
1148
|
server.close(() => process.exit(0));
|
|
983
1149
|
setTimeout(() => process.exit(0), 2000).unref();
|
|
984
1150
|
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// code-stamp.js — is the code this server runs still the code on disk?
|
|
2
|
+
//
|
|
3
|
+
// A server keeps the code it started with. An update replaces the files on disk, the page is served fresh from
|
|
4
|
+
// them, and the two disagree: measured in a real project, a server started before 3.2.0 answered the new Map
|
|
5
|
+
// view's /api/map with 404, which the view showed as "Not read — HTTP 404". The stamp is the size and mtime of
|
|
6
|
+
// every file of the server, taken at start and again when asked; when they differ, the page says to restart.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
/** Size and mtime of every .js/.mjs/.sh file under `dir` (the server's own code), as one string. */
|
|
11
|
+
export function codeStamp(dir) {
|
|
12
|
+
const parts = [];
|
|
13
|
+
const walk = (d) => {
|
|
14
|
+
let entries;
|
|
15
|
+
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
16
|
+
for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
|
17
|
+
const p = path.join(d, e.name);
|
|
18
|
+
if (e.isDirectory()) { if (e.name !== 'node_modules') walk(p); continue; }
|
|
19
|
+
if (!/\.(m?js|sh)$/.test(e.name)) continue;
|
|
20
|
+
try { const st = fs.statSync(p); parts.push(`${path.relative(dir, p)}:${st.size}:${Math.round(st.mtimeMs)}`); } catch { /* gone */ }
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
walk(dir);
|
|
24
|
+
return parts.join('|');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Stamp the code now, and answer later whether it has changed on disk (re-read at most every `everyMs`). */
|
|
28
|
+
export function stampAtStart(dir, { everyMs = 5000, now = () => Date.now() } = {}) {
|
|
29
|
+
const boot = codeStamp(dir);
|
|
30
|
+
let last = boot; let at = now();
|
|
31
|
+
return () => {
|
|
32
|
+
if (now() - at >= everyMs) { last = codeStamp(dir); at = now(); }
|
|
33
|
+
return { changed: last !== boot };
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// What Crewforth's crew map says about a project, for the Map view.
|
|
2
|
+
//
|
|
3
|
+
// The map is `.crewforth/crew-map.json`, written by Crewforth's scanner (kit/eval/lib/crew-map/scan.mjs): the
|
|
4
|
+
// project's modules, the external resources it touches, the endpoints it provides and calls, and the findings
|
|
5
|
+
// between them, every one citing file:line. This file reads it and runs nothing that writes: no scan is started
|
|
6
|
+
// from here, and a map that is not on disk is said to be missing. The one process it starts is `git rev-list
|
|
7
|
+
// --count`, to say how far the project has moved since the commit the map was scanned at.
|
|
8
|
+
//
|
|
9
|
+
// Compare with: another project's map is read the same way. The scanner already says which of a project's calls
|
|
10
|
+
// no endpoint in that project provides (they go somewhere else); those are the calls compared here. One the other
|
|
11
|
+
// project provides is a match, one it does not is missing.
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { execFile } from 'node:child_process';
|
|
16
|
+
|
|
17
|
+
export const MAP_FILE = path.join('.crewforth', 'crew-map.json');
|
|
18
|
+
// How a project writes its map, said wherever a map is missing. The one place it is written: the view and the
|
|
19
|
+
// page show what the server sends.
|
|
20
|
+
// /crew-map in Claude Code: the scan, then the descriptions, in either edition (the plugin has no .claude/eval).
|
|
21
|
+
export const WRITER = '/crew-map in Claude Code';
|
|
22
|
+
export const SCHEMA = /^crewforth\.crew-map\/1$/;
|
|
23
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
24
|
+
const GIT_TIMEOUT_MS = 5000;
|
|
25
|
+
|
|
26
|
+
const maps = new Map(); // cwd -> { sig, value }
|
|
27
|
+
export const _stats = { parsed: 0 }; // how many times a map was really read: the claims count it
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The map of one project, read once and kept until the file changes (its time or its size).
|
|
31
|
+
* @returns { measured: true, file, bytes, doc } or { measured: false, reason }
|
|
32
|
+
*/
|
|
33
|
+
export function readMap(cwd) {
|
|
34
|
+
const file = path.join(cwd, MAP_FILE);
|
|
35
|
+
let st;
|
|
36
|
+
try { st = fs.statSync(file); } catch {
|
|
37
|
+
return { measured: false, missing: true, reason: `no ${MAP_FILE.replaceAll('\\', '/')} in this project. Write one there with: ${WRITER}` };
|
|
38
|
+
}
|
|
39
|
+
const sig = `${st.mtimeMs}:${st.size}`;
|
|
40
|
+
const hit = maps.get(cwd);
|
|
41
|
+
if (hit && hit.sig === sig) return hit.value;
|
|
42
|
+
const value = parseMap(file, st.size);
|
|
43
|
+
_stats.parsed += 1;
|
|
44
|
+
if (maps.size > 50) maps.clear();
|
|
45
|
+
maps.set(cwd, { sig, value });
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseMap(file, size) {
|
|
50
|
+
const shown = MAP_FILE.replaceAll('\\', '/');
|
|
51
|
+
// Read whole or not at all: half a map would draw modules without the edges that belong to them.
|
|
52
|
+
if (size > MAX_BYTES) return { measured: false, reason: `${shown} is ${size} bytes, over the ${MAX_BYTES} Studio reads` };
|
|
53
|
+
let doc;
|
|
54
|
+
try { doc = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { return { measured: false, reason: `${shown} is not JSON (${e.message.slice(0, 80)})` }; }
|
|
55
|
+
if (!SCHEMA.test(String(doc?.schema))) return { measured: false, reason: `${shown} says schema ${JSON.stringify(doc?.schema ?? null)}, and Studio reads crewforth.crew-map/1` };
|
|
56
|
+
for (const k of ['nodes', 'edges', 'findings', 'notMeasured']) {
|
|
57
|
+
if (!Array.isArray(doc[k])) return { measured: false, reason: `${shown} has no ${k} list` };
|
|
58
|
+
}
|
|
59
|
+
return { measured: true, file: shown, bytes: size, doc };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What the navigator says of a project's map: null with none, else how many findings it shows unasked. */
|
|
63
|
+
export function mapSummary(cwd) {
|
|
64
|
+
const m = readMap(cwd);
|
|
65
|
+
if (!m.measured) return m.missing ? null : { findings: null, reason: m.reason };
|
|
66
|
+
return { findings: m.doc.findings.filter((f) => !f?.data?.defaultHidden).length };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* How many commits HEAD is past the commit the map was scanned at: { count } or { count: null, reason }.
|
|
71
|
+
* Only a commit id is handed to git, so nothing in the file can become an option.
|
|
72
|
+
*/
|
|
73
|
+
export function behindOf(cwd, commit) {
|
|
74
|
+
if (typeof commit !== 'string' || !/^[0-9a-f]{7,64}$/.test(commit)) return Promise.resolve({ count: null, reason: 'the map names no commit' });
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
execFile('git', ['rev-list', '--count', `${commit}..HEAD`], { cwd, timeout: GIT_TIMEOUT_MS, windowsHide: true }, (err, stdout) => {
|
|
77
|
+
const n = Number.parseInt(String(stdout).trim(), 10);
|
|
78
|
+
if (err || !Number.isFinite(n)) resolve({ count: null, reason: 'git could not say (the commit may not be in this clone)' });
|
|
79
|
+
else resolve({ count: n });
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One endpoint as it is matched: the method, and the path with each parameter written as one `*`. */
|
|
85
|
+
export function endpointKey(e) {
|
|
86
|
+
const p = String(e?.path ?? '').split('?')[0].replace(/\/+$/, '') || '/';
|
|
87
|
+
const segs = p.split('/').map((s) => (/^(:\w+|\{[^}]*\}|<[^>]*>|\*)$/.test(s) ? '*' : s));
|
|
88
|
+
return `${String(e?.method ?? '').toUpperCase()} ${segs.join('/')}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Another project's calls into this one. `doc` is this project's map, `other` the other project's. A call of the
|
|
93
|
+
* other project is compared when no endpoint of its own provides it (the scanner's own endpoint-missing). Each
|
|
94
|
+
* calling module of the other project is one client: what it calls here that this project provides (`calls`,
|
|
95
|
+
* each with `to`, the module of this project that provides it),
|
|
96
|
+
* and what else it calls that its own project does not provide either (`missing`). A module none of whose calls
|
|
97
|
+
* this project provides is not a client of it, and is not listed: its calls go to a third place, and naming them
|
|
98
|
+
* missing here would say something the two maps do not.
|
|
99
|
+
*/
|
|
100
|
+
export function compareMaps(doc, other) {
|
|
101
|
+
const own = new Set((other?.provides?.endpoints ?? []).map(endpointKey));
|
|
102
|
+
const here = new Map((doc?.provides?.endpoints ?? []).map((e) => [endpointKey(e), e.module]));
|
|
103
|
+
const names = new Map((other?.nodes ?? []).filter((n) => n.kind === 'module').map((n) => [n.id, n]));
|
|
104
|
+
const by = new Map();
|
|
105
|
+
for (const e of other?.consumes?.endpoints ?? []) {
|
|
106
|
+
const k = endpointKey(e);
|
|
107
|
+
if (own.has(k)) continue;
|
|
108
|
+
const c = by.get(e.module) ?? by.set(e.module, { id: e.module, name: names.get(e.module)?.name ?? e.module, tech: names.get(e.module)?.tech ?? [], calls: [], missing: [] }).get(e.module);
|
|
109
|
+
const call = { method: e.method, path: e.path, evidence: (e.evidence ?? []).slice(0, 1) };
|
|
110
|
+
if (here.has(k)) c.calls.push({ ...call, to: here.get(k) }); else c.missing.push(call);
|
|
111
|
+
}
|
|
112
|
+
return [...by.values()].filter((c) => c.calls.length).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
113
|
+
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import fsp from 'node:fs/promises';
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
|
|
20
|
-
import { readAll, contextFill } from './transcript.js';
|
|
20
|
+
import { readAll, contextFill, contextMix } from './transcript.js';
|
|
21
21
|
import { agentMetaFiles } from './projects.js';
|
|
22
22
|
import { Usage, withTimes } from './usage.js';
|
|
23
23
|
import { detailOf } from './permissions.js';
|
|
@@ -139,6 +139,7 @@ export function linkRetries(calls) {
|
|
|
139
139
|
if (!c.refused) return;
|
|
140
140
|
const again = list.slice(i + 1).find((n) => n.subagentType === c.subagentType && n.description === c.description);
|
|
141
141
|
if (!again) return;
|
|
142
|
+
c.retried = true;
|
|
142
143
|
// A call refused twice keeps the first thing that was asked.
|
|
143
144
|
again.firstTry = c.firstTry ?? { asked: c.model, floor: c.refused.floor, text: c.refused.text };
|
|
144
145
|
});
|
|
@@ -456,7 +457,14 @@ export async function buildGraph(session, { staleMs = STALE_MS } = {}) {
|
|
|
456
457
|
version: main.version,
|
|
457
458
|
status: 'session',
|
|
458
459
|
turns: main.userTurns,
|
|
460
|
+
// Agent calls the model gate refused and the session has not made again: no agent started, so nothing else on
|
|
461
|
+
// the canvas says they happened (seen on a real project: two refused calls, an empty canvas, "2 failed" only in
|
|
462
|
+
// the session's own view). A refused call made again is shown on the call that ran (`firstTry`).
|
|
463
|
+
refusedCalls: [...main.agentCalls.values()].filter((c) => c.refused && !c.retried)
|
|
464
|
+
.map((c) => ({ agent: c.subagentType ?? null, model: c.model ?? null, text: c.refused.text })),
|
|
459
465
|
tokens: contextFill(records),
|
|
466
|
+
// What that context is made of: the tool-output share since the last compaction, and the compactions so far.
|
|
467
|
+
context: contextMix(records),
|
|
460
468
|
startedAt: main.startedAt,
|
|
461
469
|
endedAt: main.updatedAt,
|
|
462
470
|
usage: withTimes(usage, records),
|
|
@@ -52,11 +52,36 @@ function kitPaths(cwd) {
|
|
|
52
52
|
* empty unless CREW_GATE_LOG_CMD=1 was set, and there is no timestamp at all —
|
|
53
53
|
* both facts are returned rather than papered over.
|
|
54
54
|
*/
|
|
55
|
+
/**
|
|
56
|
+
* Where the gate log is, by the rule the gates write by (eval/lib/gate-log.sh): .claude/gate-log.tsv, else the
|
|
57
|
+
* repository's own .git/crewforth-gate-log.tsv (a project that tracks .claude/ has it there), searched from the
|
|
58
|
+
* project root up. null when there is none yet.
|
|
59
|
+
*/
|
|
60
|
+
export function gateLogPath(cwd) {
|
|
61
|
+
const own = path.join(cwd, '.claude', 'gate-log.tsv');
|
|
62
|
+
if (fs.existsSync(own)) return own;
|
|
63
|
+
let d = cwd;
|
|
64
|
+
for (let n = 0; n < 12; n += 1) {
|
|
65
|
+
const g = path.join(d, '.git');
|
|
66
|
+
let st = null;
|
|
67
|
+
try { st = fs.statSync(g); } catch { /* not here */ }
|
|
68
|
+
if (st?.isDirectory()) { const f = path.join(g, 'crewforth-gate-log.tsv'); return fs.existsSync(f) ? f : null; }
|
|
69
|
+
if (st?.isFile()) {
|
|
70
|
+
const m = /^gitdir: (.+)$/m.exec(fs.readFileSync(g, 'utf8'));
|
|
71
|
+
if (!m) return null;
|
|
72
|
+
const gd = path.resolve(d, m[1].trim()); const f = path.join(gd, 'crewforth-gate-log.tsv');
|
|
73
|
+
return fs.existsSync(f) ? f : null;
|
|
74
|
+
}
|
|
75
|
+
const up = path.dirname(d); if (up === d) return null; d = up;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
55
80
|
export function gateLog(cwd, { limit = 200 } = {}) {
|
|
56
|
-
const file =
|
|
81
|
+
const file = gateLogPath(cwd);
|
|
57
82
|
let st;
|
|
58
|
-
try { st = fs.statSync(file); } catch {
|
|
59
|
-
return { measured: false, reason: 'no .claude/gate-log.tsv
|
|
83
|
+
try { st = fs.statSync(file ?? ''); } catch {
|
|
84
|
+
return { measured: false, reason: 'no gate log in this project yet (.claude/gate-log.tsv, or .git/crewforth-gate-log.tsv where .claude/ is tracked)', entries: [] };
|
|
60
85
|
}
|
|
61
86
|
|
|
62
87
|
// Only the tail: this file reached 21,730 lines in Crewforth's own checkout and
|
|
@@ -119,9 +144,9 @@ export async function gateReport(cwd) {
|
|
|
119
144
|
// should read is one level up. Both layouts are given what they expect rather
|
|
120
145
|
// than the script being asked to guess.
|
|
121
146
|
const from = kit.kind === 'installed' ? cwd : path.dirname(kit.hooks);
|
|
122
|
-
const logPath =
|
|
147
|
+
const logPath = gateLogPath(cwd);
|
|
123
148
|
const env = { ...process.env };
|
|
124
|
-
if (
|
|
149
|
+
if (logPath) env.CREW_GATE_LOG = logPath;
|
|
125
150
|
|
|
126
151
|
const r = await run('bash', [script, '--json'], { cwd: from, env });
|
|
127
152
|
// 3 means there is no log to read, which the script calls "not an error"; 4
|