@openscaffold/cli 0.1.2 → 0.1.3
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 +4 -2
- package/dist/index.js +58 -22
- package/dist/install.js +110 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ start, in any repo, loads the right customer deployment's briefing, and the Open
|
|
|
7
7
|
```sh
|
|
8
8
|
npx @openscaffold/cli setup acme.com ./customer-docs # sign in if needed, create the deployment, upload the folder, print the cited plan
|
|
9
9
|
npx @openscaffold/cli login # opens your browser: click Approve. That's it.
|
|
10
|
-
npx @openscaffold/cli review --
|
|
10
|
+
npx @openscaffold/cli review --req req_1 # review this folder's change against the customer's plan (agents run it)
|
|
11
11
|
npx @openscaffold/cli status # what's set up and the next step (--json for coding agents)
|
|
12
12
|
npx @openscaffold/cli logout # removes everything it installed and revokes the login
|
|
13
13
|
npx @openscaffold/cli help # commands and environment variables
|
|
@@ -29,7 +29,9 @@ MCP server and `/openscaffold:*` commands).
|
|
|
29
29
|
- a SessionStart hook in `~/.claude/settings.json` that prints the briefing
|
|
30
30
|
- two guardrail hooks in the same file: before a `git commit`, the customer's rules and must-haves as context; at Stop,
|
|
31
31
|
once per session, if Claude edited files without a review, it's asked to run `openscaffold review` (or `request_review`) and then
|
|
32
|
-
call `report_session`
|
|
32
|
+
call `report_session`; and after Claude switches or creates a customer (`use_deployment`, `create_deployment`), a hook that makes `review` and the guardrails follow it
|
|
33
|
+
|
|
34
|
+
Logged in before 2026-10-29? Run `npx @openscaffold/cli login` again to add the customer-switch hook (`status` says so).
|
|
33
35
|
- `/os-requirements`, `/os-what-changed`, `/os-check-plan`, `/os-demo-check` in `~/.claude/skills/`
|
|
34
36
|
|
|
35
37
|
Requires Node 22+ and Claude Code. Set `OPENSCAFFOLD_URL` to point at a self-hosted OpenScaffold. Set
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { execFileSync, spawn } from "node:child_process";
|
|
|
4
4
|
import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { homedir, hostname } from "node:os";
|
|
6
6
|
import { dirname, join, resolve } from "node:path";
|
|
7
|
-
import { briefingFacts, briefingStatus, browserCommand, collectDiff, gitInfo, hasOpenScaffoldPlugin, hookBase, HOSTED, installedHooks, loadedEntry, loginResultLine, MCP_NAME, mcpText, nameFromDomain, parseReviewArgs, parseSetupArgs, planSummary, pluginServes, preCommitOutput, selectFiles, SETUP_EXTENSIONS, setupPrompt, setupResultLine, skillFile, statusReport, stopOutput, transcriptActivity, windowsCommandLine, withHooks, withoutHooks,
|
|
7
|
+
import { briefingFacts, briefingStatus, browserCommand, collectDiff, gitInfo, hasOpenScaffoldPlugin, hookBase, HOSTED, installedHooks, loadedEntry, loginResultLine, MCP_NAME, mcpText, nameFromDomain, callerSession, commandLine, rememberedCache, DEFAULT_REVIEW_TITLE, parseReviewArgs, parseSetupArgs, planSummary, pluginServes, preCommitOutput, REVIEW_HELP, REVIEW_WAIT, reviewTitle, selectFiles, SETUP_EXTENSIONS, setupPrompt, setupResultLine, skillFile, statusReport, stopOutput, switchAllowed, switchedTo, transcriptActivity, whichCustomerError, windowsCommandLine, withHooks, withoutHooks, } from "./install.js";
|
|
8
8
|
const CONFIG = join(homedir(), ".config", "openscaffold");
|
|
9
9
|
const CREDENTIALS = join(CONFIG, "credentials.json");
|
|
10
10
|
const CACHE = join(CONFIG, "cache");
|
|
@@ -336,6 +336,17 @@ const git = (cwd, args) => {
|
|
|
336
336
|
return null;
|
|
337
337
|
}
|
|
338
338
|
};
|
|
339
|
+
/** Caches a deployment's briefing as this folder's (and this Claude session's) deployment, for the guardrail hooks and review. */
|
|
340
|
+
function remember(creds, deployment, briefing, cwd, sessionId, switching = false) {
|
|
341
|
+
mkdirSync(CACHE, { recursive: true });
|
|
342
|
+
writeFileSync(join(CACHE, `${deployment}.md`), briefing);
|
|
343
|
+
const entry = { deployment, at: new Date().toISOString(), login: loginKey(creds) };
|
|
344
|
+
// Per Claude session too: two sessions in one folder on different customers each keep their own guardrails.
|
|
345
|
+
const next = rememberedCache(readJson(join(CACHE, "index.json"), {}), readJson(SESSIONS, {}), cwd, sessionId, entry, switching);
|
|
346
|
+
writeJson(join(CACHE, "index.json"), next.index);
|
|
347
|
+
if (sessionId)
|
|
348
|
+
writeJson(SESSIONS, next.sessions);
|
|
349
|
+
}
|
|
339
350
|
/** Prints the briefing. Never breaks a session: on any problem, a cached briefing or one line, exit 0. */
|
|
340
351
|
async function sessionStart() {
|
|
341
352
|
let hook = {};
|
|
@@ -357,15 +368,8 @@ async function sessionStart() {
|
|
|
357
368
|
const briefing = String(r.body.briefing);
|
|
358
369
|
console.log(briefing);
|
|
359
370
|
const dep = r.body.deployment?.id;
|
|
360
|
-
if (dep)
|
|
361
|
-
|
|
362
|
-
writeFileSync(join(CACHE, `${dep}.md`), briefing);
|
|
363
|
-
const entry = { deployment: dep, at: new Date().toISOString(), login: loginKey(creds) };
|
|
364
|
-
writeJson(join(CACHE, "index.json"), { ...index, [cwd]: entry });
|
|
365
|
-
// Per Claude session too: two sessions in one folder on different customers each keep their own guardrails.
|
|
366
|
-
if (hook.session_id)
|
|
367
|
-
writeJson(SESSIONS, withSession(readJson(SESSIONS, {}), hook.session_id, entry));
|
|
368
|
-
}
|
|
371
|
+
if (dep)
|
|
372
|
+
remember(creds, dep, briefing, cwd, hook.session_id);
|
|
369
373
|
}
|
|
370
374
|
catch {
|
|
371
375
|
const last = index[cwd]?.login === loginKey(creds) ? index[cwd] : undefined; // a session started before a re-login may write late
|
|
@@ -411,28 +415,60 @@ async function stop() {
|
|
|
411
415
|
writeFileSync(marker, new Date().toISOString());
|
|
412
416
|
console.log(out);
|
|
413
417
|
}
|
|
418
|
+
/** The deployments this login has cached (session-start or an earlier switch), newest first, with their names when cached. */
|
|
419
|
+
const knownDeployments = (creds) => {
|
|
420
|
+
const entries = [...Object.values(readJson(SESSIONS, {})), ...Object.values(readJson(join(CACHE, "index.json"), {}))];
|
|
421
|
+
const ids = [...new Set(entries.filter((e) => e.login === loginKey(creds)).sort((a, b) => b.at.localeCompare(a.at)).map((e) => e.deployment))];
|
|
422
|
+
return ids.map((id) => {
|
|
423
|
+
const file = join(CACHE, `${id}.md`);
|
|
424
|
+
return { id, name: existsSync(file) ? /^# OpenScaffold — (.+)$/m.exec(readFileSync(file, "utf8"))?.[1]?.trim() ?? null : null };
|
|
425
|
+
});
|
|
426
|
+
};
|
|
427
|
+
/**
|
|
428
|
+
* PostToolUse after use_deployment / create_deployment: the new deployment becomes this session's and folder's, so review
|
|
429
|
+
* and the hooks follow it; only when it is this login's server and workspace (switchAllowed), never a foreign id.
|
|
430
|
+
*/
|
|
431
|
+
async function useDeployment() {
|
|
432
|
+
const hook = await hookInput();
|
|
433
|
+
const creds = credentials();
|
|
434
|
+
const got = switchedTo(hook.tool_response);
|
|
435
|
+
if (!creds || !got || !switchAllowed(hook.tool_name, got.deployment, creds.api_url, knownDeployments(creds).map((d) => d.id)))
|
|
436
|
+
return;
|
|
437
|
+
remember(creds, got.deployment, got.briefing, hook.cwd ?? process.cwd(), hook.session_id, true);
|
|
438
|
+
}
|
|
414
439
|
// --- review -------------------------------------------------------------------------------------
|
|
415
440
|
/**
|
|
416
|
-
* `review --title "…"
|
|
441
|
+
* `review [--req ids] [--title "…"] [--deployment dep_…] [files…]`: the coding agent's one command for a review. It
|
|
417
442
|
* collects the change itself (git, or the listed files where git sees none), calls request_review, waits on
|
|
418
443
|
* get_review (which holds up to 50 s per call) and prints the verdict. Nothing to paste, nothing to poll.
|
|
419
444
|
*/
|
|
420
445
|
async function review() {
|
|
421
|
-
const args = parseReviewArgs(process.argv.slice(3));
|
|
446
|
+
const args = parseReviewArgs(process.argv.slice(3), existsSync);
|
|
447
|
+
if ("help" in args)
|
|
448
|
+
return console.log(REVIEW_HELP);
|
|
422
449
|
if ("error" in args)
|
|
423
450
|
throw new Error(args.error);
|
|
424
451
|
const creds = credentials();
|
|
452
|
+
const again = process.argv.slice(2);
|
|
425
453
|
if (!creds)
|
|
426
|
-
throw new Error(
|
|
427
|
-
|
|
454
|
+
throw new Error(`Not signed in here. Run ${commandLine(["login"])} (one click in the browser), then ${commandLine(again)}. With the plugin or OAuth only, send the diff with the request_review tool instead.`);
|
|
455
|
+
// The calling Claude session's deployment first (another session in this folder may have switched), else the folder's.
|
|
456
|
+
const deployment = args.deployment ?? loadedHere(callerSession(process.env), process.cwd())?.deployment;
|
|
428
457
|
if (!deployment)
|
|
429
|
-
throw new Error(
|
|
458
|
+
throw new Error(whichCustomerError(again, knownDeployments(creds)));
|
|
430
459
|
const git = (a) => execFileSync("git", a, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
|
|
431
460
|
const got = collectDiff(args.files, git, (f) => (lstatSync(f).isSymbolicLink() ? { link: readlinkSync(f) } : readFileSync(f, "utf8")));
|
|
432
461
|
if ("error" in got)
|
|
433
462
|
throw new Error(got.error);
|
|
463
|
+
const cached = join(CACHE, `${deployment}.md`);
|
|
464
|
+
const briefing = existsSync(cached) ? readFileSync(cached, "utf8") : null;
|
|
465
|
+
const name = briefing && /^# OpenScaffold — (.+)$/m.exec(briefing)?.[1]?.trim();
|
|
466
|
+
const title = args.title === DEFAULT_REVIEW_TITLE ? reviewTitle(args.reqs, briefing) : args.title;
|
|
467
|
+
// No --deployment and no Claude session id in this shell: the folder's default, said out loud.
|
|
468
|
+
const which = !args.deployment && !callerSession(process.env) ? " (this folder's default customer; pass --deployment to choose)" : "";
|
|
469
|
+
console.log(`Reviewing "${title}" for ${name ? `${name} (${deployment})` : deployment}${which}. ${REVIEW_WAIT}`);
|
|
434
470
|
try {
|
|
435
|
-
const queued = JSON.parse(await tool(creds, "request_review", { deployment_id: deployment, title
|
|
471
|
+
const queued = JSON.parse(await tool(creds, "request_review", { deployment_id: deployment, title, requirement_ids: args.reqs, diff: got.diff }, 60_000));
|
|
436
472
|
if (!queued.review_id)
|
|
437
473
|
return console.log(queued.note ?? "Nothing to review.");
|
|
438
474
|
let out = "";
|
|
@@ -475,7 +511,7 @@ async function status() {
|
|
|
475
511
|
server,
|
|
476
512
|
mcp,
|
|
477
513
|
hook: hooks.includes("session-start"),
|
|
478
|
-
guardrails: hooks.includes("pre-commit") && hooks.includes("stop"),
|
|
514
|
+
guardrails: hooks.includes("pre-commit") && hooks.includes("stop") && hooks.includes("use-deployment"),
|
|
479
515
|
skills: names.filter((n) => onDisk.includes(n)).length,
|
|
480
516
|
skillsExpected: names.length,
|
|
481
517
|
plugin: mcp === null ? false : pluginInstalled() && pluginServes(creds?.api_url),
|
|
@@ -508,13 +544,13 @@ Usage: npx @openscaffold/cli@latest <command>
|
|
|
508
544
|
Everything in one go: sign in if needed, create the customer's deployment, upload
|
|
509
545
|
every .vtt .srt .txt .md .csv .json in the folder (default .), wait for the plan, print it
|
|
510
546
|
login Sign in (one browser click on Approve) and connect Claude Code
|
|
511
|
-
review
|
|
547
|
+
review [--req req_1,req_2] [--title "…"] [--deployment dep_…] [files…]
|
|
512
548
|
Review this folder's change against the customer's plan and rules: sends the git diff
|
|
513
|
-
(or the listed files, where git sees no change) and prints the verdict
|
|
549
|
+
(or the listed files, where git sees no change) and prints the verdict. review --help for more
|
|
514
550
|
status [--json] What's set up on this machine, and the next step
|
|
515
551
|
logout Remove everything login added and revoke this machine's login
|
|
516
552
|
help This text
|
|
517
|
-
(session-start, pre-commit and
|
|
553
|
+
(session-start, pre-commit, stop and use-deployment are run by Claude Code's hooks)
|
|
518
554
|
|
|
519
555
|
Environment:
|
|
520
556
|
OPENSCAFFOLD_URL Server to sign in to (default ${DEFAULT_URL}; e.g. http://localhost:3000)
|
|
@@ -522,7 +558,7 @@ Environment:
|
|
|
522
558
|
(SSH and headless Linux are detected and skip the browser by themselves)`;
|
|
523
559
|
const [cmd] = process.argv.slice(2);
|
|
524
560
|
/** Run by a hook: must never break the session or block a tool, so any problem exits 0 quietly. */
|
|
525
|
-
const HOOKS = new Set(["session-start", "pre-commit", "stop"]);
|
|
561
|
+
const HOOKS = new Set(["session-start", "pre-commit", "stop", "use-deployment"]);
|
|
526
562
|
if (cmd === "help" || cmd === "--help" || cmd === "-h") {
|
|
527
563
|
console.log(HELP);
|
|
528
564
|
process.exit(0);
|
|
@@ -536,7 +572,7 @@ if (Number(process.versions.node.split(".")[0]) < 22) {
|
|
|
536
572
|
console.log(msg);
|
|
537
573
|
process.exit(HOOKS.has(cmd ?? "") ? 0 : 1);
|
|
538
574
|
}
|
|
539
|
-
const commands = { setup, login: () => login(), review, status, "session-start": sessionStart, "pre-commit": preCommit, stop, logout };
|
|
575
|
+
const commands = { setup, login: () => login(), review, status, "session-start": sessionStart, "pre-commit": preCommit, stop, "use-deployment": useDeployment, logout };
|
|
540
576
|
const run = commands[cmd ?? ""];
|
|
541
577
|
if (!run) {
|
|
542
578
|
console.error(HELP);
|
package/dist/install.js
CHANGED
|
@@ -2,19 +2,21 @@
|
|
|
2
2
|
// Formats checked against code.claude.com docs (hooks, mcp, skills), 2026-10-23.
|
|
3
3
|
export const MCP_NAME = "openscaffold";
|
|
4
4
|
/** Our hook commands mention openscaffold and end in one of our subcommands (npx form or a local build path). */
|
|
5
|
-
const isOurCommand = (cmd) => /openscaffold/.test(cmd) && /\b(session-start|pre-commit|stop)"?$/.test(cmd.trim());
|
|
5
|
+
const isOurCommand = (cmd) => /openscaffold/.test(cmd) && /\b(session-start|pre-commit|stop|use-deployment)"?$/.test(cmd.trim());
|
|
6
6
|
const HOOK_MATCHER = "startup|resume|clear|compact";
|
|
7
7
|
const isOurs = (h) => typeof h.command === "string" && isOurCommand(h.command);
|
|
8
8
|
/**
|
|
9
9
|
* The hooks login installs (Claude Code docs, hooks, 2026-10-27): the briefing at session start; before a
|
|
10
10
|
* `git commit` (the handler's `if` is one permission rule, `Bash(git commit *)` also matches a bare `git commit`),
|
|
11
|
-
* the deployment's constraints and must-haves as context; at Stop, a one-time ask to review the changes (request_review) and report_session
|
|
11
|
+
* the deployment's constraints and must-haves as context; at Stop, a one-time ask to review the changes (request_review) and report_session;
|
|
12
|
+
* after the agent's use_deployment or create_deployment, the local cache follows the switch (so review, pre-commit and Stop use the new customer).
|
|
12
13
|
* Timeouts are seconds. None ever blocks a tool call.
|
|
13
14
|
*/
|
|
14
15
|
export const OUR_HOOKS = [
|
|
15
16
|
{ event: "SessionStart", sub: "session-start", matcher: HOOK_MATCHER, handler: { timeout: 10 } },
|
|
16
17
|
{ event: "PreToolUse", sub: "pre-commit", matcher: "Bash", handler: { if: "Bash(git commit *)", timeout: 2 } },
|
|
17
18
|
{ event: "Stop", sub: "stop", matcher: undefined, handler: { timeout: 5 } },
|
|
19
|
+
{ event: "PostToolUse", sub: "use-deployment", matcher: "mcp__.*openscaffold.*__(use|create)_deployment", handler: { timeout: 2 } },
|
|
18
20
|
];
|
|
19
21
|
const withoutOurs = (groups = []) => groups.map((g) => ({ ...g, hooks: g.hooks.filter((h) => !isOurs(h)) })).filter((g) => g.hooks.length > 0);
|
|
20
22
|
/** Adds (or replaces) our hooks, `<base> <subcommand>` each; leaves everything else in settings.json as it was. */
|
|
@@ -94,7 +96,7 @@ export function statusReport(s) {
|
|
|
94
96
|
`Signed in: ${s.workspace ? `yes, workspace "${s.workspace}" (${s.apiUrl}${s.server === "down" ? ", not answering" : s.server === "revoked" ? ", login revoked" : ""})` : "no"}`,
|
|
95
97
|
`MCP server: ${s.plugin ? "from the openscaffold plugin" : s.mcp === null ? "unknown (Claude Code not on PATH)" : s.mcp ? "added" : "missing"}`,
|
|
96
98
|
`Session hook: ${s.hook ? "installed" : "missing"}`,
|
|
97
|
-
...(s.guardrails === undefined ? [] : [`Guardrails: ${s.guardrails ? "pre-commit plan check and end-of-session report reminder" : "missing"}`]),
|
|
99
|
+
...(s.guardrails === undefined ? [] : [`Guardrails: ${s.guardrails ? "pre-commit plan check and end-of-session report reminder" : "missing or out of date (logins before 2026-10-29 lack the customer-switch hook): re-run npx @openscaffold/cli login"}`]),
|
|
98
100
|
`Commands: ${s.plugin ? "/openscaffold:* from the plugin" : commandsOk ? `${s.skills} /os-* commands` : s.skills ? `${s.skills} of ${s.skillsExpected || "?"} /os-* commands` : "missing"}`,
|
|
99
101
|
];
|
|
100
102
|
const next = !s.workspace || s.server === "revoked" || mcpOk === false || !s.hook || s.guardrails === false || !commandsOk
|
|
@@ -163,11 +165,53 @@ export function deploymentForCwd(index, cwd) {
|
|
|
163
165
|
dir = up;
|
|
164
166
|
}
|
|
165
167
|
}
|
|
168
|
+
/** A tool's output as text: a string, MCP content blocks, or `{content}` / `{text}` objects (transcripts, hook input). */
|
|
169
|
+
const textOf = (r) => typeof r === "string" ? r : Array.isArray(r) ? r.map(textOf).join("\n") : r && typeof r === "object" ? textOf(r.text ?? r.content) : "";
|
|
170
|
+
/**
|
|
171
|
+
* A use_deployment or create_deployment result (PostToolUse `tool_response`) → the deployment it switched to and a briefing
|
|
172
|
+
* to cache: use_deployment's own, or for create_deployment (JSON `{deployment: {id, name}}`, no briefing yet) just its name line.
|
|
173
|
+
*/
|
|
174
|
+
export function switchedTo(response) {
|
|
175
|
+
const briefing = textOf(response);
|
|
176
|
+
const deployment = /^Deployment id: (dep_\w+)/m.exec(briefing)?.[1];
|
|
177
|
+
if (deployment)
|
|
178
|
+
return { deployment, briefing };
|
|
179
|
+
try {
|
|
180
|
+
const created = JSON.parse(briefing).deployment;
|
|
181
|
+
if (typeof created?.id === "string" && /^dep_\w+$/.test(created.id))
|
|
182
|
+
return { deployment: created.id, briefing: `# OpenScaffold — ${String(created.name ?? created.id)}\n` };
|
|
183
|
+
}
|
|
184
|
+
catch { }
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Whether a switch seen by the use-deployment hook may replace the CLI's cache. The user-scope server (`mcp__openscaffold__…`)
|
|
189
|
+
* is the one login added with this CLI's token: same server and workspace. The plugin's server is the hosted app signed in
|
|
190
|
+
* by its own OAuth, maybe another workspace: only a deployment this login already cached (`known`) on the hosted app. Else no.
|
|
191
|
+
*/
|
|
192
|
+
export function switchAllowed(toolName, deployment, apiUrl, known) {
|
|
193
|
+
if (toolName?.startsWith(`mcp__${MCP_NAME}__`))
|
|
194
|
+
return true;
|
|
195
|
+
return Boolean(toolName?.startsWith("mcp__plugin_")) && pluginServes(apiUrl) && new Set(known).has(deployment);
|
|
196
|
+
}
|
|
197
|
+
/** The Claude Code session a CLI command runs in, when Claude Code says (env), so `review` follows that session's deployment. */
|
|
198
|
+
export const callerSession = (env) => env.CLAUDE_SESSION_ID || env.CLAUDE_CODE_SESSION_ID || undefined;
|
|
166
199
|
/** session-start's per-Claude-session record (cache sessions.json): this session's entry added, only the newest `keep` kept. */
|
|
167
200
|
export function withSession(sessions, sessionId, entry, keep = 200) {
|
|
168
201
|
const all = Object.entries({ ...sessions, [sessionId]: entry }).sort(([, a], [, b]) => b.at.localeCompare(a.at));
|
|
169
202
|
return Object.fromEntries(all.slice(0, keep));
|
|
170
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* The cache after loading a deployment: the Claude session's own entry, and the folder's default. A mid-session switch
|
|
206
|
+
* with a known session moves only that session's entry, so another session in the folder (or a `review` that can't tell
|
|
207
|
+
* which session called it) never picks it up; a session start, or a switch with no session, sets the folder default. Pure.
|
|
208
|
+
*/
|
|
209
|
+
export function rememberedCache(index, sessions, cwd, sessionId, entry, switching = false) {
|
|
210
|
+
return {
|
|
211
|
+
index: switching && sessionId ? index : { ...index, [cwd]: entry },
|
|
212
|
+
sessions: sessionId ? withSession(sessions, sessionId, entry) : sessions,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
171
215
|
/** The deployment a hook's session loaded: by its Claude session_id first (two sessions can share a folder), else the folder's. */
|
|
172
216
|
export const loadedEntry = (sessions, index, sessionId, cwd) => (sessionId ? sessions[sessionId] : undefined) ?? deploymentForCwd(index, cwd);
|
|
173
217
|
/** A briefing section's list lines (§8.3), citations dropped: `- On-prem only` from `- On-prem only [Ticket · doc_… “…”]`. */
|
|
@@ -209,16 +253,23 @@ export function preCommitOutput(briefing, plugin) {
|
|
|
209
253
|
const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
210
254
|
/** Our report_session tool, from the user-scope server or the plugin's (`mcp__plugin_openscaffold_openscaffold__…`). */
|
|
211
255
|
const REPORT = /^mcp__.*openscaffold.*__report_session$/;
|
|
212
|
-
|
|
213
|
-
|
|
256
|
+
/** The calls whose output can be a review: our get_review (user-scope or plugin), or the CLI's `review` through Bash. */
|
|
257
|
+
const GET_REVIEW = /^mcp__.*openscaffold.*__get_review$/;
|
|
214
258
|
const CLI_REVIEW = /(@openscaffold\/cli(@\S+)?|\bopenscaffold)\s+review\b/;
|
|
215
|
-
/**
|
|
259
|
+
/** A finished review's verdict line (get_review's Markdown, which the CLI prints). Running and failed reviews have none. */
|
|
260
|
+
const VERDICT = "\nVerdict: ";
|
|
261
|
+
/**
|
|
262
|
+
* From a session transcript (JSONL): did the agent edit files, get a finished review back, call report_session? A review
|
|
263
|
+
* counts only as the tool_result of a get_review or Bash `… review` call (matched by tool_use_id) holding a verdict, so a
|
|
264
|
+
* failed or still-running review, or reading a file that contains one, doesn't.
|
|
265
|
+
*/
|
|
216
266
|
export function transcriptActivity(jsonl) {
|
|
217
267
|
let edited = false;
|
|
218
268
|
let reported = false;
|
|
219
269
|
let reviewed = false;
|
|
270
|
+
const reviewCalls = new Set();
|
|
220
271
|
for (const line of jsonl.split("\n")) {
|
|
221
|
-
if (!line.includes('"tool_use"'))
|
|
272
|
+
if (!line.includes('"tool_use"') && !(line.includes('"tool_result"') && line.includes("Verdict: ")))
|
|
222
273
|
continue;
|
|
223
274
|
let content;
|
|
224
275
|
try {
|
|
@@ -230,23 +281,25 @@ export function transcriptActivity(jsonl) {
|
|
|
230
281
|
if (!Array.isArray(content))
|
|
231
282
|
continue;
|
|
232
283
|
for (const c of content) {
|
|
284
|
+
if (c?.type === "tool_result" && c.tool_use_id && reviewCalls.has(c.tool_use_id) && textOf(c.content).includes(VERDICT))
|
|
285
|
+
reviewed = true;
|
|
233
286
|
if (c?.type !== "tool_use" || typeof c.name !== "string")
|
|
234
287
|
continue;
|
|
235
288
|
if (EDIT_TOOLS.has(c.name))
|
|
236
289
|
edited = true;
|
|
237
290
|
if (REPORT.test(c.name))
|
|
238
291
|
reported = true;
|
|
239
|
-
if (
|
|
240
|
-
|
|
292
|
+
if (c.id && (GET_REVIEW.test(c.name) || (c.name === "Bash" && typeof c.input?.command === "string" && CLI_REVIEW.test(c.input.command))))
|
|
293
|
+
reviewCalls.add(c.id);
|
|
241
294
|
}
|
|
242
295
|
}
|
|
243
296
|
return { edited, reported, reviewed };
|
|
244
297
|
}
|
|
245
298
|
export const STOP_REASON = "Before you finish, call report_session with what you did.";
|
|
246
|
-
export const STOP_REVIEW_REASON = "Before you finish, review your changes against the customer's plan: run `npx -y @openscaffold/cli@latest review --
|
|
299
|
+
export const STOP_REVIEW_REASON = "Before you finish, review your changes against the customer's plan: run `npx -y @openscaffold/cli@latest review --req req_1,req_2` with the requirement ids you worked on (it sends the diff and prints the verdict; usually 30–90 s, up to ~7 min; don't wrap it in `timeout`), then report_session.";
|
|
247
300
|
/**
|
|
248
301
|
* The Stop hook's stdout: the documented block decision, once per session, when this session loaded a deployment and
|
|
249
|
-
* edited files: first ask for a review (§8.4) if
|
|
302
|
+
* edited files: first ask for a review (§8.4) if no review result came back, else for report_session if it never
|
|
250
303
|
* reported; else "". Never twice, and never while Claude is already continuing for a stop hook.
|
|
251
304
|
*/
|
|
252
305
|
export function stopOutput(s) {
|
|
@@ -355,28 +408,66 @@ export function mcpText(raw) {
|
|
|
355
408
|
// --- review: send this folder's change to OpenScaffold's reviewer (SPEC §8.1 request_review) ------
|
|
356
409
|
/** request_review's limit (web/lib/pipeline/review.ts MAX_DIFF_BYTES). */
|
|
357
410
|
export const MAX_DIFF_BYTES = 400 * 1024;
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
export
|
|
411
|
+
const REVIEW_CMD = "npx -y @openscaffold/cli@latest review";
|
|
412
|
+
const REVIEW_USAGE = `Usage: ${REVIEW_CMD} [--req req_1,req_2] [--title "…"] [--deployment dep_…] [files…], e.g. ${REVIEW_CMD} --req req_1`;
|
|
413
|
+
export const REVIEW_WAIT = "Usually 30–90 s, up to ~7 min; don't wrap it in `timeout`.";
|
|
414
|
+
export const REVIEW_HELP = [
|
|
415
|
+
REVIEW_USAGE,
|
|
416
|
+
"",
|
|
417
|
+
"Reviews this folder's change against the customer's plan and rules: sends the git diff (or the listed files, where git",
|
|
418
|
+
"sees no change) and prints the verdict.",
|
|
419
|
+
" --req ids The plan's requirement ids this change works on (from the briefing or get_plan)",
|
|
420
|
+
' --title "…" One line: what the change does (default: those requirements\' titles); a bare phrase works too',
|
|
421
|
+
" --deployment id The customer (default: the one this folder's session loaded)",
|
|
422
|
+
" files… Narrow the change to these paths; outside git, the files to send whole",
|
|
423
|
+
REVIEW_WAIT,
|
|
424
|
+
].join("\n");
|
|
425
|
+
/** The title when none is given and the cached briefing names none of the claimed requirements. */
|
|
426
|
+
export const DEFAULT_REVIEW_TITLE = "Review of current changes";
|
|
427
|
+
/**
|
|
428
|
+
* `review` arguments; files are optional (git finds the change) and needed only where git sees none. A bare argument
|
|
429
|
+
* that isn't a file (`exists`) is the title, so `review "Fix X"` works. With no title, DEFAULT_REVIEW_TITLE (see reviewTitle).
|
|
430
|
+
*/
|
|
431
|
+
export function parseReviewArgs(args, exists = () => true) {
|
|
361
432
|
const out = { title: "", reqs: [], deployment: undefined, files: [] };
|
|
362
433
|
for (let i = 0; i < args.length; i++) {
|
|
363
434
|
const eq = args[i].startsWith("--") ? args[i].indexOf("=") : -1;
|
|
364
435
|
const [flag, inline] = eq < 0 ? [args[i], undefined] : [args[i].slice(0, eq), args[i].slice(eq + 1)];
|
|
365
436
|
const value = () => inline ?? args[++i];
|
|
437
|
+
if (flag === "--help" || flag === "-h")
|
|
438
|
+
return { help: true };
|
|
366
439
|
if (flag === "--title")
|
|
367
440
|
out.title = value() ?? "";
|
|
368
441
|
else if (flag === "--req" || flag === "--requirements")
|
|
369
442
|
out.reqs.push(...(value() ?? "").split(/[,\s]+/).filter(Boolean));
|
|
370
443
|
else if (flag === "--deployment")
|
|
371
444
|
out.deployment = value();
|
|
372
|
-
else if (args[i]
|
|
445
|
+
else if (/^-./.test(args[i]))
|
|
373
446
|
return { error: `Unknown option ${flag}. ${REVIEW_USAGE}` };
|
|
447
|
+
else if (/^req_\w+$/.test(args[i]) && !exists(args[i]))
|
|
448
|
+
out.reqs.push(args[i]); // `--req req_1 req_2`
|
|
449
|
+
// ponytail: a deleted file named alone reads as the title; then the whole change is reviewed, which still includes it.
|
|
450
|
+
else if (!out.title && !exists(args[i]))
|
|
451
|
+
out.title = args[i];
|
|
374
452
|
else
|
|
375
453
|
out.files.push(args[i]);
|
|
376
454
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
455
|
+
return { ...out, title: out.title.trim() || DEFAULT_REVIEW_TITLE, reqs: [...new Set(out.reqs)] };
|
|
456
|
+
}
|
|
457
|
+
/** A CLI command line to copy and run: `npx -y @openscaffold/cli@latest <args>`, quoting what a shell would split. */
|
|
458
|
+
export const commandLine = (args) => ["npx -y @openscaffold/cli@latest", ...args.map((a) => (/^[\w./:@=,-]+$/.test(a) ? a : JSON.stringify(a)))].join(" ");
|
|
459
|
+
/** `review` with no --deployment and none cached for this session or folder: the same command with each cached customer's id. */
|
|
460
|
+
export function whichCustomerError(args, known) {
|
|
461
|
+
const head = "Which customer? This session has no OpenScaffold deployment cached here.";
|
|
462
|
+
if (!known.length)
|
|
463
|
+
return `${head} Add the id from the OpenScaffold briefing ("Deployment id: …", or call list_deployments), e.g. ${commandLine([...args, "--deployment", "dep_01EXAMPLE"])}`;
|
|
464
|
+
return [`${head} Run one of:`, ...known.map((d) => ` ${commandLine([...args, "--deployment", d.id])}${d.name ? ` # ${d.name}` : ""}`)].join("\n");
|
|
465
|
+
}
|
|
466
|
+
/** The default title: the claimed requirements' titles from the cached briefing's must-haves (`- req_1 Title — status`). */
|
|
467
|
+
export function reviewTitle(reqs, briefing) {
|
|
468
|
+
const line = (id) => briefing?.split("\n").find((l) => l.startsWith(`- ${id} `));
|
|
469
|
+
const titles = reqs.map((id) => line(id)?.slice(id.length + 3).replace(/^\[[^\]]*\] /, "").replace(/ — \S+$/, "")).filter(Boolean);
|
|
470
|
+
return titles.length ? titles.join("; ").slice(0, 300) : DEFAULT_REVIEW_TITLE;
|
|
380
471
|
}
|
|
381
472
|
/**
|
|
382
473
|
* A whole file as an added-file diff, as `git diff --no-index /dev/null <file>` prints it. Empty or binary: "". A
|
|
@@ -432,7 +523,7 @@ export function collectDiff(files, git, read) {
|
|
|
432
523
|
if (!diff.trim()) {
|
|
433
524
|
return { error: repo
|
|
434
525
|
? `Nothing to review: git sees no change here. If your files are in a git-ignored folder, list them: ${REVIEW_USAGE}`
|
|
435
|
-
: `This folder isn't a git repo, so list the files you changed
|
|
526
|
+
: `This folder isn't a git repo, so list the files you changed (each is sent whole as a new file), e.g. ${REVIEW_CMD} --req req_1 src/report.py. Or send \`git diff --no-index /dev/null <file>\` for each file with the request_review tool.` };
|
|
436
527
|
}
|
|
437
528
|
if (Buffer.byteLength(diff) > MAX_DIFF_BYTES)
|
|
438
529
|
return { error: `This change is ${Math.ceil(Buffer.byteLength(diff) / 1024)} KB; a review takes up to 400 KB. Review it in parts: list the files for one work item at a time.` };
|