@monster-orange/den 0.4.5 → 0.4.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/CHANGELOG.md +140 -0
- package/README.md +21 -7
- package/docs/distributions.md +221 -0
- package/package.json +4 -3
- package/skills/install-den/SKILL.md +114 -0
- package/src/shared/agent-setup.ts +9 -0
- package/src/shared/agents/den-claude.ts +19 -5
- package/src/shared/agents/guardrails/hooks.json +27 -0
- package/src/shared/agents/guardrails/instructions.md +39 -0
- package/src/shared/agents/guardrails/pretooluse.mjs +430 -0
- package/src/shared/agents/guardrails/stop-hook.ts +66 -0
- package/src/shared/agents/guardrails.ts +114 -0
- package/src/shared/agents/handback.ts +241 -0
- package/src/shared/agents/personas.ts +55 -6
- package/src/shared/constants.ts +7 -0
- package/src/shared/den-config.ts +67 -6
- package/src/shared/dev-metrics/emit.ts +59 -5
- package/src/shared/dev-metrics/polling.ts +135 -0
- package/src/shared/engine.ts +14 -2
- package/src/shared/port-pick.ts +60 -0
- package/src/ui/cli/agent-ops.ts +7 -3
- package/src/ui/cli/agents-command.ts +51 -1
- package/src/ui/cli/create-main.ts +1 -1
- package/src/ui/cli/create.ts +263 -30
- package/src/ui/cli/doctor-command.ts +87 -4
- package/src/ui/cli/doctor-onboard.ts +291 -0
- package/src/ui/cli/flag-parser.ts +5 -0
- package/src/ui/cli/index.ts +352 -5
- package/src/ui/cli/json-out.ts +49 -0
- package/src/ui/cli/mcp-connect.ts +179 -15
- package/src/ui/cli/repo-command.ts +490 -0
- package/src/ui/cli/repo-detect.ts +192 -0
- package/src/ui/cli/web-command.ts +3 -0
- package/src/ui/mcp/connect.ts +134 -0
- package/src/ui/server/public/settings-registry.js +3 -3
- package/src/ui/server/request-guard.ts +1 -1
|
@@ -19,6 +19,12 @@
|
|
|
19
19
|
* else). A DSN, in the env or a synced dev-metrics.config, is where to send
|
|
20
20
|
* once opted in; on its own it never sends.
|
|
21
21
|
*
|
|
22
|
+
* Each line also has `cwd` (den#5): where the command ran, relative to the
|
|
23
|
+
* distribution root ("." for the root itself), or "outside" for anywhere
|
|
24
|
+
* else, never an absolute or home path. `<cli> doctor` reads it to spot
|
|
25
|
+
* polling loops (polling.ts). Like every field, it is sent only when opted
|
|
26
|
+
* in; the envelope format is unchanged.
|
|
27
|
+
*
|
|
22
28
|
* Guarantees: never throws, never blocks the caller (the Sentry ship is
|
|
23
29
|
* fire-and-forget, bounded to ~2s). CI=true / DEV_METRICS_DISABLED=1 suppress
|
|
24
30
|
* shipping but still buffer the JSONL locally.
|
|
@@ -31,7 +37,7 @@
|
|
|
31
37
|
|
|
32
38
|
import * as fs from "fs";
|
|
33
39
|
import * as path from "path";
|
|
34
|
-
import { execFileSync } from "child_process";
|
|
40
|
+
import { execFileSync, spawn } from "child_process";
|
|
35
41
|
import { createHash, randomUUID } from "crypto";
|
|
36
42
|
import * as yaml from "yaml";
|
|
37
43
|
import { getDistRoot } from "../paths.js";
|
|
@@ -115,6 +121,29 @@ function commonFields(event: string, root: string): Record<string, string> {
|
|
|
115
121
|
};
|
|
116
122
|
}
|
|
117
123
|
|
|
124
|
+
/** The JSONL file, under the repo root. */
|
|
125
|
+
export function eventsFile(root: string = repoRoot()): string {
|
|
126
|
+
return path.join(root, ".dev-metrics", "events.jsonl");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* `cwd` for a local line: the path from the root to cwd ("." for the root,
|
|
131
|
+
* "/"-separated), or "outside" when cwd is not under the root.
|
|
132
|
+
*/
|
|
133
|
+
export function cwdField(root: string, cwd: string = process.cwd()): string {
|
|
134
|
+
const real = (p: string): string => {
|
|
135
|
+
try {
|
|
136
|
+
return fs.realpathSync(p);
|
|
137
|
+
} catch {
|
|
138
|
+
return path.resolve(p);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const rel = path.relative(real(root), real(cwd));
|
|
142
|
+
if (rel === "") return ".";
|
|
143
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) return "outside";
|
|
144
|
+
return rel.split(path.sep).join("/");
|
|
145
|
+
}
|
|
146
|
+
|
|
118
147
|
function isoNoMillis(): string {
|
|
119
148
|
return new Date().toISOString().replace(/\.\d+Z$/, "Z");
|
|
120
149
|
}
|
|
@@ -241,6 +270,32 @@ export function buildEnvelope(
|
|
|
241
270
|
return `{}\n${itemHeader}\n${payload}\n`;
|
|
242
271
|
}
|
|
243
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Send from a short-lived detached process instead of this one. The CLI
|
|
275
|
+
* records its run in the `exit` handler, where an async fetch never runs;
|
|
276
|
+
* a detached sender outlives the CLI and gives up after SHIP_TIMEOUT_MS.
|
|
277
|
+
* The long-lived server keeps the in-process fetch.
|
|
278
|
+
*/
|
|
279
|
+
let shipDetached = false;
|
|
280
|
+
export function shipFromDetachedSender(): void {
|
|
281
|
+
shipDetached = true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const SENDER = `fetch(process.env.DM_URL,{method:"POST",headers:{"Content-Type":"application/x-sentry-envelope","X-Sentry-Auth":process.env.DM_AUTH},body:process.env.DM_BODY,signal:AbortSignal.timeout(${SHIP_TIMEOUT_MS})}).catch(()=>{}).finally(()=>process.exit(0))`;
|
|
285
|
+
|
|
286
|
+
function sendDetached(url: string, auth: string, body: string): void {
|
|
287
|
+
try {
|
|
288
|
+
spawn(process.execPath, ["-e", SENDER], {
|
|
289
|
+
detached: true,
|
|
290
|
+
stdio: "ignore",
|
|
291
|
+
// Only what the sender needs: none of the caller's environment.
|
|
292
|
+
env: { DM_URL: url, DM_AUTH: auth, DM_BODY: body },
|
|
293
|
+
}).unref();
|
|
294
|
+
} catch {
|
|
295
|
+
/* fail-open: the JSONL line is already buffered */
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
244
299
|
function ship(
|
|
245
300
|
record: Record<string, string>,
|
|
246
301
|
epoch: number,
|
|
@@ -266,6 +321,7 @@ function ship(
|
|
|
266
321
|
|
|
267
322
|
const url = `${parsed.scheme}://${parsed.host}/api/${parsed.projectId}/envelope/`;
|
|
268
323
|
const auth = `Sentry sentry_key=${parsed.key}, sentry_version=7, sentry_client=dev-metrics-${product().cli}/1.0`;
|
|
324
|
+
if (shipDetached) return sendDetached(url, auth, envelope);
|
|
269
325
|
// Fire-and-forget: never awaited, so the caller never blocks. Bounded by a 2s
|
|
270
326
|
// abort so a hung ingest can't leak a pending request in the long-lived server.
|
|
271
327
|
void fetch(url, {
|
|
@@ -307,14 +363,12 @@ export function record(
|
|
|
307
363
|
for (const [k, v] of Object.entries(fields)) {
|
|
308
364
|
obj[k] = v == null ? "" : String(v);
|
|
309
365
|
}
|
|
366
|
+
obj.cwd = cwdField(root);
|
|
310
367
|
|
|
311
368
|
try {
|
|
312
369
|
const dir = path.join(root, ".dev-metrics");
|
|
313
370
|
fs.mkdirSync(dir, { recursive: true });
|
|
314
|
-
fs.appendFileSync(
|
|
315
|
-
path.join(dir, "events.jsonl"),
|
|
316
|
-
JSON.stringify(obj) + "\n",
|
|
317
|
-
);
|
|
371
|
+
fs.appendFileSync(eventsFile(root), JSON.stringify(obj) + "\n");
|
|
318
372
|
} catch {
|
|
319
373
|
/* non-writable dir → fail-open */
|
|
320
374
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Polling-loop warning (den#5) for `<cli> doctor`: one command, run from
|
|
3
|
+
* one place, more than POLL_LIMIT times in any POLL_WINDOW_MS window looks
|
|
4
|
+
* like an agent polling it. Local only: it reads the tail of the local
|
|
5
|
+
* .dev-metrics/events.jsonl and sends nothing anywhere.
|
|
6
|
+
*
|
|
7
|
+
* A line's place is its `cwd` (emit.ts). An older line has none, so it is
|
|
8
|
+
* keyed by its `repo` instead.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "fs";
|
|
11
|
+
|
|
12
|
+
/** More calls than this in one window is a polling loop. */
|
|
13
|
+
export const POLL_LIMIT = 600;
|
|
14
|
+
/** The window: an hour. */
|
|
15
|
+
export const POLL_WINDOW_MS = 60 * 60 * 1000;
|
|
16
|
+
/** At most this much of the file's end is read, however big it is. */
|
|
17
|
+
export const TAIL_BYTES = 8 * 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The file's last `max` bytes as whole lines: a line cut by the start of
|
|
21
|
+
* the slice is dropped. A missing or unreadable file is no lines.
|
|
22
|
+
*/
|
|
23
|
+
export function readTailLines(
|
|
24
|
+
file: string,
|
|
25
|
+
max: number = TAIL_BYTES,
|
|
26
|
+
): string[] {
|
|
27
|
+
let fd: number | undefined;
|
|
28
|
+
try {
|
|
29
|
+
fd = fs.openSync(file, "r");
|
|
30
|
+
const size = fs.fstatSync(fd).size;
|
|
31
|
+
const len = Math.min(size, max);
|
|
32
|
+
const buf = Buffer.alloc(len);
|
|
33
|
+
let got = 0;
|
|
34
|
+
while (got < len) {
|
|
35
|
+
const n = fs.readSync(fd, buf, got, len - got, size - len + got);
|
|
36
|
+
if (n <= 0) break;
|
|
37
|
+
got += n;
|
|
38
|
+
}
|
|
39
|
+
const lines = buf.subarray(0, got).toString("utf-8").split("\n");
|
|
40
|
+
if (len < size) lines.shift();
|
|
41
|
+
return lines.filter((l) => l.trim());
|
|
42
|
+
} catch {
|
|
43
|
+
return [];
|
|
44
|
+
} finally {
|
|
45
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface PollingLoop {
|
|
50
|
+
command: string;
|
|
51
|
+
/** Where: the line's cwd, or `repo <name>` for an older line. */
|
|
52
|
+
where: string;
|
|
53
|
+
/** Calls in the busiest window. */
|
|
54
|
+
calls: number;
|
|
55
|
+
/** The busiest window's first and last call (ISO, no millis). */
|
|
56
|
+
from: string;
|
|
57
|
+
to: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const iso = (ms: number): string =>
|
|
61
|
+
new Date(ms).toISOString().replace(/\.\d+Z$/, "Z");
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Every command+place with more than `limit` calls in some `windowMs`
|
|
65
|
+
* window, with its busiest window. Lines that aren't JSON, or have no
|
|
66
|
+
* command or time, are skipped.
|
|
67
|
+
*/
|
|
68
|
+
export function findPollingLoops(
|
|
69
|
+
lines: string[],
|
|
70
|
+
limit: number = POLL_LIMIT,
|
|
71
|
+
windowMs: number = POLL_WINDOW_MS,
|
|
72
|
+
): PollingLoop[] {
|
|
73
|
+
const byKey = new Map<
|
|
74
|
+
string,
|
|
75
|
+
{ command: string; where: string; times: number[] }
|
|
76
|
+
>();
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
let o: Record<string, unknown>;
|
|
79
|
+
try {
|
|
80
|
+
o = JSON.parse(line) as Record<string, unknown>;
|
|
81
|
+
} catch {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (!o || typeof o !== "object") continue;
|
|
85
|
+
const command = typeof o.command === "string" ? o.command : "";
|
|
86
|
+
const t = typeof o.ts === "string" ? Date.parse(o.ts) : NaN;
|
|
87
|
+
if (!command || Number.isNaN(t)) continue;
|
|
88
|
+
const where =
|
|
89
|
+
typeof o.cwd === "string" && o.cwd
|
|
90
|
+
? o.cwd
|
|
91
|
+
: `repo ${typeof o.repo === "string" && o.repo ? o.repo : "?"}`;
|
|
92
|
+
const key = `${command}\u0000${where}`;
|
|
93
|
+
let e = byKey.get(key);
|
|
94
|
+
if (!e) byKey.set(key, (e = { command, where, times: [] }));
|
|
95
|
+
e.times.push(t);
|
|
96
|
+
}
|
|
97
|
+
const out: PollingLoop[] = [];
|
|
98
|
+
for (const { command, where, times } of byKey.values()) {
|
|
99
|
+
if (times.length <= limit) continue;
|
|
100
|
+
times.sort((a, b) => a - b);
|
|
101
|
+
let best = 0;
|
|
102
|
+
let bi = 0;
|
|
103
|
+
let bj = 0;
|
|
104
|
+
for (let i = 0, j = 0; j < times.length; j++) {
|
|
105
|
+
while (times[j] - times[i] >= windowMs) i++;
|
|
106
|
+
if (j - i + 1 > best) {
|
|
107
|
+
best = j - i + 1;
|
|
108
|
+
bi = i;
|
|
109
|
+
bj = j;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (best > limit)
|
|
113
|
+
out.push({
|
|
114
|
+
command,
|
|
115
|
+
where,
|
|
116
|
+
calls: best,
|
|
117
|
+
from: iso(times[bi]),
|
|
118
|
+
to: iso(times[bj]),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return out.sort((a, b) => b.calls - a.calls);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** One warning line, as doctor prints it. */
|
|
125
|
+
export function pollingWarning(p: PollingLoop): string {
|
|
126
|
+
return `looks like a polling loop: ${p.command} in ${p.where}, ${p.calls} calls in an hour (${p.from}–${p.to})`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** doctor's warnings for this events file (none when it is fine or absent). */
|
|
130
|
+
export function pollingWarnings(
|
|
131
|
+
file: string,
|
|
132
|
+
max: number = TAIL_BYTES,
|
|
133
|
+
): string[] {
|
|
134
|
+
return findPollingLoops(readTailLines(file, max)).map(pollingWarning);
|
|
135
|
+
}
|
package/src/shared/engine.ts
CHANGED
|
@@ -93,11 +93,23 @@ export function playwrightCommand(...args: string[]): EngineCommand {
|
|
|
93
93
|
* or with DEN_USE_DIST=1. Mirrors the web server's resolveServerEntrypoint.
|
|
94
94
|
*/
|
|
95
95
|
export function cliEntrypoint(engine: string = getEngineRoot()): EngineCommand {
|
|
96
|
-
|
|
96
|
+
return scriptEntrypoint("src/ui/cli/index.ts", engine);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* How to run one of Den's TypeScript entry points (`src/….ts`, relative to
|
|
101
|
+
* the engine root) as a node process: cliEntrypoint's rules, for any
|
|
102
|
+
* script (an agent's hook, guardrails/stop-hook.ts).
|
|
103
|
+
*/
|
|
104
|
+
export function scriptEntrypoint(
|
|
105
|
+
rel: string,
|
|
106
|
+
engine: string = getEngineRoot(),
|
|
107
|
+
): EngineCommand {
|
|
108
|
+
const built = path.join(engine, "dist", rel.replace(/\.ts$/, ".js"));
|
|
97
109
|
if (process.env.DEN_USE_DIST === "1" && fs.existsSync(built))
|
|
98
110
|
return { command: process.execPath, args: [built] };
|
|
99
111
|
const loader = engineModuleFile("tsx/dist/loader.mjs", engine);
|
|
100
|
-
const src = path.join(engine,
|
|
112
|
+
const src = path.join(engine, rel);
|
|
101
113
|
if (loader && fs.existsSync(src))
|
|
102
114
|
return {
|
|
103
115
|
command: process.execPath,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Picking a free localhost port for a new distribution's web server
|
|
3
|
+
* (`create`, ON-2), so a second Den on one machine doesn't collide with the
|
|
4
|
+
* first on DEFAULT_PORT.
|
|
5
|
+
*
|
|
6
|
+
* "In use" is checked without talking to whoever holds the port: a TCP
|
|
7
|
+
* connect that is closed as soon as it opens (no byte is sent), then a
|
|
8
|
+
* bind. Either one finding something there means in use.
|
|
9
|
+
*/
|
|
10
|
+
import * as net from "net";
|
|
11
|
+
import { RESERVED_DEN_PORTS } from "./constants.js";
|
|
12
|
+
|
|
13
|
+
/** Whether something holds `port` on localhost. Never sends a byte. */
|
|
14
|
+
export function portInUse(port: number, host = "localhost"): Promise<boolean> {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const sock = net.connect({ host, port });
|
|
17
|
+
const done = (inUse: boolean | null) => {
|
|
18
|
+
sock.destroy();
|
|
19
|
+
if (inUse !== null) return resolve(inUse);
|
|
20
|
+
// Nothing accepted a connection: can we bind it ourselves?
|
|
21
|
+
const srv = net.createServer();
|
|
22
|
+
srv.once("error", () => resolve(true));
|
|
23
|
+
srv.listen({ port, host, exclusive: true }, () =>
|
|
24
|
+
srv.close(() => resolve(false)),
|
|
25
|
+
);
|
|
26
|
+
};
|
|
27
|
+
sock.setTimeout(1_000);
|
|
28
|
+
sock.once("connect", () => done(true));
|
|
29
|
+
// Something there that doesn't complete a handshake: in use.
|
|
30
|
+
sock.once("timeout", () => done(true));
|
|
31
|
+
sock.once("error", (e: NodeJS.ErrnoException) =>
|
|
32
|
+
done(e.code === "ECONNREFUSED" ? null : true),
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PickPortOptions {
|
|
38
|
+
/** The port wanted (the default); the search goes up from here. */
|
|
39
|
+
start: number;
|
|
40
|
+
/** Never chosen. */
|
|
41
|
+
reserved?: readonly number[];
|
|
42
|
+
/** How many ports above `start` to try. */
|
|
43
|
+
span?: number;
|
|
44
|
+
inUse?: (port: number) => Promise<boolean>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `start` when it is free, else the first free port above it that is not
|
|
49
|
+
* reserved; null when none in the span is free.
|
|
50
|
+
*/
|
|
51
|
+
export async function pickFreePort(o: PickPortOptions): Promise<number | null> {
|
|
52
|
+
const reserved = o.reserved ?? RESERVED_DEN_PORTS;
|
|
53
|
+
const inUse = o.inUse ?? ((p: number) => portInUse(p));
|
|
54
|
+
const span = o.span ?? 100;
|
|
55
|
+
for (let p = o.start; p <= Math.min(o.start + span, 65535); p++) {
|
|
56
|
+
if (reserved.includes(p)) continue;
|
|
57
|
+
if (!(await inUse(p))) return p;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
package/src/ui/cli/agent-ops.ts
CHANGED
|
@@ -437,20 +437,24 @@ function personas(
|
|
|
437
437
|
cfg: ResolvedAgentsConfig,
|
|
438
438
|
): Map<string, Persona> {
|
|
439
439
|
try {
|
|
440
|
-
return loadPersonas(
|
|
440
|
+
return loadPersonas(
|
|
441
|
+
resolvePackDirs(cfg.personaPacks, d.distRoot()),
|
|
442
|
+
undefined,
|
|
443
|
+
{ defaultPack: cfg.defaultPackGuardrails, packs: cfg.packGuardrails },
|
|
444
|
+
);
|
|
441
445
|
} catch (e) {
|
|
442
446
|
throw refused((e as Error).message, [], 422);
|
|
443
447
|
}
|
|
444
448
|
}
|
|
445
449
|
|
|
446
450
|
/** A persona as listed: everything but its role prompt. */
|
|
447
|
-
export type ListedPersona = Omit<Persona, "prompt">;
|
|
451
|
+
export type ListedPersona = Omit<Persona, "prompt" | "guardrailsSet">;
|
|
448
452
|
|
|
449
453
|
/** Den's default pack, then agents.persona_packs, by id. */
|
|
450
454
|
export function listPersonas(d: AgentsResolved): ListedPersona[] {
|
|
451
455
|
return [...personas(d, agentsConfig(d)).values()]
|
|
452
456
|
.sort((a, b) => a.id.localeCompare(b.id))
|
|
453
|
-
.map(({ prompt: _p, ...rest }) => rest);
|
|
457
|
+
.map(({ prompt: _p, guardrailsSet: _g, ...rest }) => rest);
|
|
454
458
|
}
|
|
455
459
|
|
|
456
460
|
export interface RuntimeChoice {
|
|
@@ -33,13 +33,18 @@
|
|
|
33
33
|
* fix when it is missing. Exit 1 when the default runtime is
|
|
34
34
|
* unavailable. It resolves the login shell's PATH first, as the
|
|
35
35
|
* web server does at start.
|
|
36
|
+
* handback-check <file|-> (den#5) the hand-back check (handback.ts) on a
|
|
37
|
+
* file or stdin: each problem on its own line, exit 1 when there
|
|
38
|
+
* is one, 0 when the hand-back is complete and plain.
|
|
36
39
|
*
|
|
37
40
|
* Nothing here takes a grant: grants come from the org config only. The
|
|
38
41
|
* host enforces agents.max_concurrent on every start, counting agents only.
|
|
39
42
|
*/
|
|
43
|
+
import * as fs from "fs";
|
|
40
44
|
import { DEFAULT_RUNTIME_ID } from "../../shared/agents/runtimes.js";
|
|
41
45
|
import { initLoginPath } from "../../shared/agents/runtime-search.js";
|
|
42
46
|
import { HostError } from "../../shared/sessions/client.js";
|
|
47
|
+
import { checkHandback } from "../../shared/agents/handback.js";
|
|
43
48
|
import {
|
|
44
49
|
AgentError,
|
|
45
50
|
listAgents,
|
|
@@ -75,7 +80,7 @@ export {
|
|
|
75
80
|
} from "./agent-ops.js";
|
|
76
81
|
|
|
77
82
|
export const AGENTS_HELP = {
|
|
78
|
-
usage: "agents <list|start|stop|resume|personas|doctor> ...",
|
|
83
|
+
usage: "agents <list|start|stop|resume|personas|doctor|handback-check> ...",
|
|
79
84
|
lines: [
|
|
80
85
|
"Agents: a persona and a workspace, run by a runtime in a session that",
|
|
81
86
|
"outlives the web server (see `{cli} sessions`).",
|
|
@@ -94,6 +99,10 @@ export const AGENTS_HELP = {
|
|
|
94
99
|
" each runtime, installed first: status, the CLI it runs and where it came",
|
|
95
100
|
" from, its version, the directories searched, and the fix; exit 1 when the",
|
|
96
101
|
" default is missing. -w: as that workspace's agents find them",
|
|
102
|
+
"{cli} agents handback-check <file|-> [--json]",
|
|
103
|
+
" checks a hand-back to a person: URL, machine, branch, what changed, what",
|
|
104
|
+
" to click and decisions are all there, every ID is explained, and each",
|
|
105
|
+
" decision is a numbered yes/no question; exit 1 with what to fix",
|
|
97
106
|
"",
|
|
98
107
|
"Runtimes: den-claude (default; Claude Code's claude), codex (OpenAI's Codex",
|
|
99
108
|
"CLI), gemini (Google's Gemini CLI), grok (xAI's Grok Build), and generic (the",
|
|
@@ -276,6 +285,45 @@ async function listPersonas(
|
|
|
276
285
|
export interface AgentsDoctorDeps {
|
|
277
286
|
/** Resolve the login shell's PATH first (default: initLoginPath). */
|
|
278
287
|
loginPath?: () => Promise<void>;
|
|
288
|
+
/** Read a hand-back: a file path, or "-" for stdin (handback-check). */
|
|
289
|
+
readHandback?: (src: string) => Promise<string>;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function readFileOrStdin(src: string): Promise<string> {
|
|
293
|
+
if (src !== "-") return fs.promises.readFile(src, "utf-8");
|
|
294
|
+
let text = "";
|
|
295
|
+
for await (const chunk of process.stdin) text += String(chunk);
|
|
296
|
+
return text;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** `agents handback-check <file|-> [--json]`. */
|
|
300
|
+
async function handbackCheck(
|
|
301
|
+
args: string[],
|
|
302
|
+
d: AgentsResolved,
|
|
303
|
+
deps: AgentsDoctorDeps,
|
|
304
|
+
): Promise<number> {
|
|
305
|
+
const where = "agents handback-check";
|
|
306
|
+
const p = parseArgs(where, args, { "--json": false });
|
|
307
|
+
if (p.positional.length !== 1 || p.rest)
|
|
308
|
+
throw new UsageError(`${where} takes one file, or - for stdin`);
|
|
309
|
+
const src = p.positional[0];
|
|
310
|
+
let text: string;
|
|
311
|
+
try {
|
|
312
|
+
text = await (deps.readHandback ?? readFileOrStdin)(src);
|
|
313
|
+
} catch (e) {
|
|
314
|
+
d.err(
|
|
315
|
+
`✗ ${where}: can't read ${src} (${(e as NodeJS.ErrnoException).code ?? "error"})`,
|
|
316
|
+
);
|
|
317
|
+
return 1;
|
|
318
|
+
}
|
|
319
|
+
const r = checkHandback(text);
|
|
320
|
+
if (p.flags["--json"]) d.out(JSON.stringify(r, null, 2));
|
|
321
|
+
else if (r.ok) d.out("✓ the hand-back is complete and plain");
|
|
322
|
+
else {
|
|
323
|
+
d.out("✗ the hand-back is not ready:");
|
|
324
|
+
for (const l of r.problems) d.out(` - ${l}`);
|
|
325
|
+
}
|
|
326
|
+
return r.ok ? 0 : 1;
|
|
279
327
|
}
|
|
280
328
|
|
|
281
329
|
const SOURCE_WORD: Record<string, string> = {
|
|
@@ -399,6 +447,8 @@ export async function cmdAgents(
|
|
|
399
447
|
// -w checks the workspace exists, and reads its runtime paths.
|
|
400
448
|
await loadAgentWorkspaces(deps);
|
|
401
449
|
return await doctor(rest, d, deps);
|
|
450
|
+
case "handback-check":
|
|
451
|
+
return await handbackCheck(rest, d, deps);
|
|
402
452
|
default:
|
|
403
453
|
throw new UsageError(`unknown agents command "${sub}"`);
|
|
404
454
|
}
|