@openscaffold/cli 0.1.4 → 0.1.6
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 +6 -0
- package/dist/index.js +156 -20
- package/dist/install.js +381 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,6 +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 --req req_1 # review this folder's change against the customer's plan (agents run it)
|
|
10
11
|
npx @openscaffold/cli status # what's set up and the next step (--json for coding agents)
|
|
11
12
|
npx @openscaffold/cli logout # removes everything it installed and revokes the login
|
|
12
13
|
npx @openscaffold/cli help # commands and environment variables
|
|
@@ -26,6 +27,11 @@ MCP server and `/openscaffold:*` commands).
|
|
|
26
27
|
- `~/.config/openscaffold/credentials.json` (your login, mode 0600)
|
|
27
28
|
- the `openscaffold` MCP server in Claude Code (user scope)
|
|
28
29
|
- a SessionStart hook in `~/.claude/settings.json` that prints the briefing
|
|
30
|
+
- two guardrail hooks in the same file: before a `git commit`, the customer's rules and must-haves as context; at Stop,
|
|
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`; 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).
|
|
29
35
|
- `/os-requirements`, `/os-what-changed`, `/os-check-plan`, `/os-demo-check` in `~/.claude/skills/`
|
|
30
36
|
|
|
31
37
|
Requires Node 22+ and Claude Code. Set `OPENSCAFFOLD_URL` to point at a self-hosted OpenScaffold. Set
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// `npx @openscaffold/cli setup | login | status | session-start | logout` (SPEC §8.4). Node 22+, standard library only.
|
|
3
3
|
import { execFileSync, spawn } from "node:child_process";
|
|
4
|
-
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { homedir, hostname } from "node:os";
|
|
6
|
-
import { dirname, join, resolve } from "node:path";
|
|
7
|
-
import { briefingStatus, browserCommand, gitInfo, hasOpenScaffoldPlugin, hookBase, HOSTED, installedHooks, loadedEntry, loginResultLine, MCP_NAME, mcpText, nameFromDomain, parseSetupArgs, planSummary, pluginServes, preCommitOutput, selectFiles, SETUP_EXTENSIONS, setupPrompt, setupResultLine, skillFile, statusReport, stopOutput, transcriptActivity, windowsCommandLine, withHooks, withoutHooks,
|
|
6
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
7
|
+
import { briefingFacts, briefingStatus, browserCommand, collectDiff, gitInfo, hasOpenScaffoldPlugin, hookBase, HOSTED, installedHooks, loadedEntry, loginResultLine, MCP_NAME, mcpText, nameFromDomain, cacheFromToolCall, callerSession, commandLine, editedPaths, filesModifiedSince, insideFolder, sessionFileChoice, sessionStartedAt, gitTracks, rememberedCache, DEFAULT_REVIEW_TITLE, parseReviewArgs, parseSetupArgs, planSummary, pluginServes, preCommitOutput, REVIEW_HELP, REVIEW_WAIT, reviewTitle, selectFiles, SETUP_EXTENSIONS, setupPrompt, setupResultLine, skillFile, statusReport, stopOutput, 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");
|
|
@@ -113,8 +113,8 @@ async function login({ next = true } = {}) {
|
|
|
113
113
|
throw new Error(`Couldn't reach OpenScaffold at ${url} (${start.status}).`);
|
|
114
114
|
const { code, login_url, poll } = start.body;
|
|
115
115
|
console.log(BY_AGENT
|
|
116
|
-
? `Tell the person: open ${login_url}
|
|
117
|
-
: `Opening ${login_url}\
|
|
116
|
+
? `Tell the person: open ${login_url}, check the workspace on the page, then click Approve. The page shows the code ${code}. Signing up, if they need to, happens on that page too.`
|
|
117
|
+
: `Opening ${login_url}\nCheck the workspace on the page, then click Approve. The page should show the code ${code}.`);
|
|
118
118
|
openBrowser(login_url);
|
|
119
119
|
let creds = null;
|
|
120
120
|
const deadline = Date.now() + 10 * 60_000;
|
|
@@ -282,6 +282,7 @@ async function setup() {
|
|
|
282
282
|
console.log("\nReading the sources, then writing the plan (usually 2–4 minutes).");
|
|
283
283
|
const deadline = Date.now() + 20 * 60_000;
|
|
284
284
|
let last = "";
|
|
285
|
+
const shown = new Set(); // facts already printed, so each prints once as it's found
|
|
285
286
|
let idle = 0;
|
|
286
287
|
let nudged = false;
|
|
287
288
|
for (;;) {
|
|
@@ -296,6 +297,13 @@ async function setup() {
|
|
|
296
297
|
}
|
|
297
298
|
if (status && status !== last)
|
|
298
299
|
console.log(` ${(last = status)}`);
|
|
300
|
+
const facts = briefing ? briefingFacts(briefing).filter((f) => !shown.has(f)) : [];
|
|
301
|
+
if (facts.length && !shown.size)
|
|
302
|
+
console.log(" Found so far, quoted from their sources:");
|
|
303
|
+
for (const f of facts) {
|
|
304
|
+
console.log(` ${f}`);
|
|
305
|
+
shown.add(f);
|
|
306
|
+
}
|
|
299
307
|
idle = status?.startsWith("No plan yet") ? idle + 1 : 0;
|
|
300
308
|
if (idle >= 2) {
|
|
301
309
|
if (nudged)
|
|
@@ -328,6 +336,19 @@ const git = (cwd, args) => {
|
|
|
328
336
|
return null;
|
|
329
337
|
}
|
|
330
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, transcript) {
|
|
341
|
+
mkdirSync(CACHE, { recursive: true });
|
|
342
|
+
// No briefing (a plain tool call named the deployment): keep the cached one, if any; review works without it.
|
|
343
|
+
if (briefing !== null)
|
|
344
|
+
writeFileSync(join(CACHE, `${deployment}.md`), briefing);
|
|
345
|
+
const entry = { deployment, at: new Date().toISOString(), login: loginKey(creds), ...(transcript ? { transcript } : {}) };
|
|
346
|
+
// Per Claude session too: two sessions in one folder on different customers each keep their own guardrails.
|
|
347
|
+
const next = rememberedCache(readJson(join(CACHE, "index.json"), {}), readJson(SESSIONS, {}), cwd, sessionId, entry, switching);
|
|
348
|
+
writeJson(join(CACHE, "index.json"), next.index);
|
|
349
|
+
if (sessionId)
|
|
350
|
+
writeJson(SESSIONS, next.sessions);
|
|
351
|
+
}
|
|
331
352
|
/** Prints the briefing. Never breaks a session: on any problem, a cached briefing or one line, exit 0. */
|
|
332
353
|
async function sessionStart() {
|
|
333
354
|
let hook = {};
|
|
@@ -349,15 +370,8 @@ async function sessionStart() {
|
|
|
349
370
|
const briefing = String(r.body.briefing);
|
|
350
371
|
console.log(briefing);
|
|
351
372
|
const dep = r.body.deployment?.id;
|
|
352
|
-
if (dep)
|
|
353
|
-
|
|
354
|
-
writeFileSync(join(CACHE, `${dep}.md`), briefing);
|
|
355
|
-
const entry = { deployment: dep, at: new Date().toISOString(), login: loginKey(creds) };
|
|
356
|
-
writeJson(join(CACHE, "index.json"), { ...index, [cwd]: entry });
|
|
357
|
-
// Per Claude session too: two sessions in one folder on different customers each keep their own guardrails.
|
|
358
|
-
if (hook.session_id)
|
|
359
|
-
writeJson(SESSIONS, withSession(readJson(SESSIONS, {}), hook.session_id, entry));
|
|
360
|
-
}
|
|
373
|
+
if (dep)
|
|
374
|
+
remember(creds, dep, briefing, cwd, hook.session_id, false, hook.transcript_path);
|
|
361
375
|
}
|
|
362
376
|
catch {
|
|
363
377
|
const last = index[cwd]?.login === loginKey(creds) ? index[cwd] : undefined; // a session started before a re-login may write late
|
|
@@ -388,14 +402,14 @@ async function preCommit() {
|
|
|
388
402
|
if (out)
|
|
389
403
|
console.log(out);
|
|
390
404
|
}
|
|
391
|
-
/** Stop: once per session, if it edited files and never called report_session, ask
|
|
405
|
+
/** Stop: once per session, if it edited files and never called request_review (or report_session), ask for it. */
|
|
392
406
|
async function stop() {
|
|
393
407
|
const hook = await hookInput();
|
|
394
408
|
if (!hook.session_id || hook.stop_hook_active)
|
|
395
409
|
return;
|
|
396
410
|
const marker = join(CACHE, `reminded-${hook.session_id.replace(/[^\w-]/g, "")}`);
|
|
397
411
|
const transcript = hook.transcript_path?.replace(/^~(?=[\\/])/, homedir());
|
|
398
|
-
const activity = transcript && existsSync(transcript) ? transcriptActivity(readFileSync(transcript, "utf8")) : { edited: false, reported: false };
|
|
412
|
+
const activity = transcript && existsSync(transcript) ? transcriptActivity(readFileSync(transcript, "utf8")) : { edited: false, reported: false, reviewed: false };
|
|
399
413
|
const out = stopOutput({ stopHookActive: false, reminded: existsSync(marker), loaded: Boolean(loadedHere(hook.session_id, hook.cwd ?? process.cwd())), ...activity });
|
|
400
414
|
if (!out)
|
|
401
415
|
return;
|
|
@@ -403,6 +417,125 @@ async function stop() {
|
|
|
403
417
|
writeFileSync(marker, new Date().toISOString());
|
|
404
418
|
console.log(out);
|
|
405
419
|
}
|
|
420
|
+
/** The deployments this login has cached (session-start or an earlier switch), newest first, with their names when cached. */
|
|
421
|
+
const knownDeployments = (creds) => {
|
|
422
|
+
const entries = [...Object.values(readJson(SESSIONS, {})), ...Object.values(readJson(join(CACHE, "index.json"), {}))];
|
|
423
|
+
const ids = [...new Set(entries.filter((e) => e.login === loginKey(creds)).sort((a, b) => b.at.localeCompare(a.at)).map((e) => e.deployment))];
|
|
424
|
+
return ids.map((id) => {
|
|
425
|
+
const file = join(CACHE, `${id}.md`);
|
|
426
|
+
return { id, name: existsSync(file) ? /^# OpenScaffold — (.+)$/m.exec(readFileSync(file, "utf8"))?.[1]?.trim() ?? null : null };
|
|
427
|
+
});
|
|
428
|
+
};
|
|
429
|
+
/**
|
|
430
|
+
* PostToolUse after any OpenScaffold tool: use_deployment / create_deployment switch this session (and, with no session, the
|
|
431
|
+
* folder); any other call naming a deployment_id makes it this session's, so `review` finds the customer the agent loaded
|
|
432
|
+
* with get_briefing. Only this login's server and workspace, never a foreign id (cacheFromToolCall). Local files only.
|
|
433
|
+
*/
|
|
434
|
+
async function useDeployment() {
|
|
435
|
+
const hook = await hookInput();
|
|
436
|
+
const creds = credentials();
|
|
437
|
+
if (!creds)
|
|
438
|
+
return;
|
|
439
|
+
const current = hook.session_id ? readJson(SESSIONS, {})[hook.session_id]?.deployment : undefined;
|
|
440
|
+
const got = cacheFromToolCall(hook, creds.api_url, knownDeployments(creds).map((d) => d.id), current);
|
|
441
|
+
if (got)
|
|
442
|
+
remember(creds, got.deployment, got.briefing, hook.cwd ?? process.cwd(), hook.session_id, true, hook.transcript_path);
|
|
443
|
+
}
|
|
444
|
+
/** The Claude session's transcript: the path its hooks cached, else ~/.claude/projects/<any>/<session id>.jsonl. */
|
|
445
|
+
const transcriptOf = (sessionId, entry) => {
|
|
446
|
+
if (entry?.transcript && existsSync(entry.transcript))
|
|
447
|
+
return entry.transcript;
|
|
448
|
+
const projects = join(CLAUDE, "projects");
|
|
449
|
+
const dir = existsSync(projects) ? readdirSync(projects).find((d) => existsSync(join(projects, d, `${sessionId}.jsonl`))) : undefined;
|
|
450
|
+
return dir ? join(projects, dir, `${sessionId}.jsonl`) : undefined;
|
|
451
|
+
};
|
|
452
|
+
/** Outside git with no files named: what this Claude session changed under the cwd, from its transcript, else by mtime. */
|
|
453
|
+
function sessionFiles(sessionId, cwd) {
|
|
454
|
+
if (!sessionId)
|
|
455
|
+
return null;
|
|
456
|
+
const entry = readJson(SESSIONS, {})[sessionId];
|
|
457
|
+
const transcript = transcriptOf(sessionId, entry);
|
|
458
|
+
const text = transcript ? readFileSync(transcript, "utf8") : null;
|
|
459
|
+
const edited = text === null ? null : editedPaths(text).map((p) => relative(cwd, resolve(cwd, p))).filter((p) => p && !p.startsWith("..") && !isAbsolute(p));
|
|
460
|
+
const since = sessionStartedAt(text, transcript ? statSync(transcript).birthtimeMs : null, entry?.started);
|
|
461
|
+
// Automatic picks only: links resolved, each must still be inside this folder (a path the agent names is sent as named).
|
|
462
|
+
return sessionFileChoice(edited && insideFolder(edited, cwd), since, (at) => insideFolder(filesModifiedSince(cwd, at), cwd));
|
|
463
|
+
}
|
|
464
|
+
/** OpenScaffold's own record of this Claude session's deployment (the session-start endpoint), cached for next time. */
|
|
465
|
+
async function serverSession(creds, sessionId, cwd) {
|
|
466
|
+
try {
|
|
467
|
+
// peek: learn the deployment without marking the briefing read; an existing row for this session is reused, never duplicated.
|
|
468
|
+
const r = await api(creds.api_url, "/api/sessions/start", { claude_session_id: sessionId, cwd, peek: true, ...gitInfo(git(cwd, ["remote", "get-url", "origin"]), git(cwd, ["branch", "--show-current"])) }, { token: creds.token, timeoutMs: 5000 });
|
|
469
|
+
const dep = r.status === 200 ? r.body.deployment?.id : undefined;
|
|
470
|
+
if (dep)
|
|
471
|
+
remember(creds, dep, String(r.body.briefing), cwd, sessionId, true);
|
|
472
|
+
return dep;
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
return undefined;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// --- review -------------------------------------------------------------------------------------
|
|
479
|
+
/**
|
|
480
|
+
* `review [--req ids] [--title "…"] [--deployment dep_…] [files…]`: the coding agent's one command for a review. It
|
|
481
|
+
* collects the change itself (git, or the listed files where git sees none), calls request_review, waits on
|
|
482
|
+
* get_review (which holds up to 50 s per call) and prints the verdict. Nothing to paste, nothing to poll.
|
|
483
|
+
*/
|
|
484
|
+
async function review() {
|
|
485
|
+
const args = parseReviewArgs(process.argv.slice(3), (p) => existsSync(p) || gitTracks(p, (a) => execFileSync("git", a, { stdio: "ignore" })));
|
|
486
|
+
if ("help" in args)
|
|
487
|
+
return console.log(REVIEW_HELP);
|
|
488
|
+
if ("error" in args)
|
|
489
|
+
throw new Error(args.error);
|
|
490
|
+
const creds = credentials();
|
|
491
|
+
const again = process.argv.slice(2);
|
|
492
|
+
if (!creds)
|
|
493
|
+
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.`);
|
|
494
|
+
// The calling Claude session's deployment first (another session in this folder may have switched), else the folder's.
|
|
495
|
+
// Nothing cached (the agent loaded the customer some other way, or no hooks): ask OpenScaffold which deployment this session used.
|
|
496
|
+
const sid = callerSession(process.env);
|
|
497
|
+
const deployment = args.deployment ?? loadedHere(sid, process.cwd())?.deployment ?? (sid ? await serverSession(creds, sid, process.cwd()) : undefined);
|
|
498
|
+
if (!deployment)
|
|
499
|
+
throw new Error(whichCustomerError(again, knownDeployments(creds)));
|
|
500
|
+
const git = (a) => execFileSync("git", a, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
|
|
501
|
+
let repo = false;
|
|
502
|
+
try {
|
|
503
|
+
repo = git(["rev-parse", "--is-inside-work-tree"]).trim() === "true";
|
|
504
|
+
}
|
|
505
|
+
catch { }
|
|
506
|
+
if (!repo && !args.files.length) {
|
|
507
|
+
const found = sessionFiles(sid, process.cwd());
|
|
508
|
+
if (found?.files.length) {
|
|
509
|
+
args.files.push(...found.files);
|
|
510
|
+
console.log(`Not a git repo: reviewing the ${found.files.length} file${found.files.length === 1 ? "" : "s"} ${found.from}: ${found.files.join(", ")}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const got = collectDiff(args.files, git, (f) => (lstatSync(f).isSymbolicLink() ? { link: readlinkSync(f) } : readFileSync(f, "utf8")));
|
|
514
|
+
if ("error" in got)
|
|
515
|
+
throw new Error(got.error);
|
|
516
|
+
const cached = join(CACHE, `${deployment}.md`);
|
|
517
|
+
const briefing = existsSync(cached) ? readFileSync(cached, "utf8") : null;
|
|
518
|
+
const name = briefing && /^# OpenScaffold — (.+)$/m.exec(briefing)?.[1]?.trim();
|
|
519
|
+
const title = args.title === DEFAULT_REVIEW_TITLE ? reviewTitle(args.reqs, briefing) : args.title;
|
|
520
|
+
// No --deployment and no Claude session id in this shell: the folder's default, said out loud.
|
|
521
|
+
const which = !args.deployment && !callerSession(process.env) ? " (this folder's default customer; pass --deployment to choose)" : "";
|
|
522
|
+
console.log(`Reviewing "${title}" for ${name ? `${name} (${deployment})` : deployment}${which}. ${REVIEW_WAIT}`);
|
|
523
|
+
try {
|
|
524
|
+
const queued = JSON.parse(await tool(creds, "request_review", { deployment_id: deployment, title, requirement_ids: args.reqs, diff: got.diff }, 60_000));
|
|
525
|
+
if (!queued.review_id)
|
|
526
|
+
return console.log(queued.note ?? "Nothing to review.");
|
|
527
|
+
let out = "";
|
|
528
|
+
// Each get_review waits up to 50 s on the server; a review that outlasts four of them is reported as still running.
|
|
529
|
+
for (let i = 0; i < 4 && (!out || out.includes("Still reviewing")); i++)
|
|
530
|
+
out = await tool(creds, "get_review", { deployment_id: deployment, review_id: queued.review_id }, 90_000);
|
|
531
|
+
console.log(out);
|
|
532
|
+
}
|
|
533
|
+
catch (e) {
|
|
534
|
+
if (e instanceof LoggedOut)
|
|
535
|
+
throw new Error(`${e.message} Run ${commandLine(["login"])}, then ${commandLine(again)}.`);
|
|
536
|
+
throw e;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
406
539
|
// --- status -------------------------------------------------------------------------------------
|
|
407
540
|
/** Read-only: what's set up on this machine and the next step. `--json` for agents. Always exits 0. */
|
|
408
541
|
async function status() {
|
|
@@ -431,7 +564,7 @@ async function status() {
|
|
|
431
564
|
server,
|
|
432
565
|
mcp,
|
|
433
566
|
hook: hooks.includes("session-start"),
|
|
434
|
-
guardrails: hooks.includes("pre-commit") && hooks.includes("stop"),
|
|
567
|
+
guardrails: hooks.includes("pre-commit") && hooks.includes("stop") && hooks.includes("use-deployment"),
|
|
435
568
|
skills: names.filter((n) => onDisk.includes(n)).length,
|
|
436
569
|
skillsExpected: names.length,
|
|
437
570
|
plugin: mcp === null ? false : pluginInstalled() && pluginServes(creds?.api_url),
|
|
@@ -464,10 +597,13 @@ Usage: npx @openscaffold/cli@latest <command>
|
|
|
464
597
|
Everything in one go: sign in if needed, create the customer's deployment, upload
|
|
465
598
|
every .vtt .srt .txt .md .csv .json in the folder (default .), wait for the plan, print it
|
|
466
599
|
login Sign in (one browser click on Approve) and connect Claude Code
|
|
600
|
+
review [--req req_1,req_2] [--title "…"] [--deployment dep_…] [files…]
|
|
601
|
+
Review this folder's change against the customer's plan and rules: sends the git diff
|
|
602
|
+
(or the listed files, where git sees no change) and prints the verdict. review --help for more
|
|
467
603
|
status [--json] What's set up on this machine, and the next step
|
|
468
604
|
logout Remove everything login added and revoke this machine's login
|
|
469
605
|
help This text
|
|
470
|
-
(session-start, pre-commit and
|
|
606
|
+
(session-start, pre-commit, stop and use-deployment are run by Claude Code's hooks)
|
|
471
607
|
|
|
472
608
|
Environment:
|
|
473
609
|
OPENSCAFFOLD_URL Server to sign in to (default ${DEFAULT_URL}; e.g. http://localhost:3000)
|
|
@@ -475,7 +611,7 @@ Environment:
|
|
|
475
611
|
(SSH and headless Linux are detected and skip the browser by themselves)`;
|
|
476
612
|
const [cmd] = process.argv.slice(2);
|
|
477
613
|
/** Run by a hook: must never break the session or block a tool, so any problem exits 0 quietly. */
|
|
478
|
-
const HOOKS = new Set(["session-start", "pre-commit", "stop"]);
|
|
614
|
+
const HOOKS = new Set(["session-start", "pre-commit", "stop", "use-deployment"]);
|
|
479
615
|
if (cmd === "help" || cmd === "--help" || cmd === "-h") {
|
|
480
616
|
console.log(HELP);
|
|
481
617
|
process.exit(0);
|
|
@@ -489,7 +625,7 @@ if (Number(process.versions.node.split(".")[0]) < 22) {
|
|
|
489
625
|
console.log(msg);
|
|
490
626
|
process.exit(HOOKS.has(cmd ?? "") ? 0 : 1);
|
|
491
627
|
}
|
|
492
|
-
const commands = { setup, login: () => login(), status, "session-start": sessionStart, "pre-commit": preCommit, stop, logout };
|
|
628
|
+
const commands = { setup, login: () => login(), review, status, "session-start": sessionStart, "pre-commit": preCommit, stop, "use-deployment": useDeployment, logout };
|
|
493
629
|
const run = commands[cmd ?? ""];
|
|
494
630
|
if (!run) {
|
|
495
631
|
console.error(HELP);
|
package/dist/install.js
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
// Pure helpers for what `openscaffold login`/`logout` write into Claude Code (SPEC §8.4).
|
|
2
2
|
// Formats checked against code.claude.com docs (hooks, mcp, skills), 2026-10-23.
|
|
3
|
+
import { readdirSync, realpathSync, statSync } from "node:fs";
|
|
4
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
5
|
export const MCP_NAME = "openscaffold";
|
|
4
6
|
/** 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());
|
|
7
|
+
const isOurCommand = (cmd) => /openscaffold/.test(cmd) && /\b(session-start|pre-commit|stop|use-deployment)"?$/.test(cmd.trim());
|
|
6
8
|
const HOOK_MATCHER = "startup|resume|clear|compact";
|
|
7
9
|
const isOurs = (h) => typeof h.command === "string" && isOurCommand(h.command);
|
|
8
10
|
/**
|
|
9
11
|
* The hooks login installs (Claude Code docs, hooks, 2026-10-27): the briefing at session start; before a
|
|
10
12
|
* `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 report_session
|
|
13
|
+
* the deployment's constraints and must-haves as context; at Stop, a one-time ask to review the changes (request_review) and report_session;
|
|
14
|
+
* 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
15
|
* Timeouts are seconds. None ever blocks a tool call.
|
|
13
16
|
*/
|
|
14
17
|
export const OUR_HOOKS = [
|
|
15
18
|
{ event: "SessionStart", sub: "session-start", matcher: HOOK_MATCHER, handler: { timeout: 10 } },
|
|
16
19
|
{ event: "PreToolUse", sub: "pre-commit", matcher: "Bash", handler: { if: "Bash(git commit *)", timeout: 2 } },
|
|
17
20
|
{ event: "Stop", sub: "stop", matcher: undefined, handler: { timeout: 5 } },
|
|
21
|
+
{ event: "PostToolUse", sub: "use-deployment", matcher: "mcp__.*openscaffold.*__.*", handler: { timeout: 2 } },
|
|
18
22
|
];
|
|
19
23
|
const withoutOurs = (groups = []) => groups.map((g) => ({ ...g, hooks: g.hooks.filter((h) => !isOurs(h)) })).filter((g) => g.hooks.length > 0);
|
|
20
24
|
/** Adds (or replaces) our hooks, `<base> <subcommand>` each; leaves everything else in settings.json as it was. */
|
|
@@ -74,8 +78,11 @@ export function gitInfo(remoteOut, branchOut) {
|
|
|
74
78
|
const branch = branchOut?.trim() || undefined;
|
|
75
79
|
return { git_remote: remote, git_branch: branch };
|
|
76
80
|
}
|
|
77
|
-
/**
|
|
78
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Which of our hooks are in settings.json, e.g. ["session-start", "pre-commit", "stop"]: each with today's matcher, so an
|
|
83
|
+
* older login's (the use-deployment hook's narrow `(use|create)_deployment` matcher) reads as missing and status asks for login.
|
|
84
|
+
*/
|
|
85
|
+
export const installedHooks = (settings) => OUR_HOOKS.filter((h) => (settings.hooks?.[h.event] ?? []).some((g) => g.matcher === h.matcher && g.hooks.some((x) => isOurs(x) && x.command.trim().replace(/"$/, "").endsWith(h.sub)))).map((h) => h.sub);
|
|
79
86
|
export const hasSessionHook = (settings) => installedHooks(settings).includes("session-start");
|
|
80
87
|
/** The hosted app, which the Claude Code plugin's MCP server always points at (plugin/.mcp.json). */
|
|
81
88
|
export const HOSTED = "https://app.openscaffold.ai";
|
|
@@ -94,7 +101,7 @@ export function statusReport(s) {
|
|
|
94
101
|
`Signed in: ${s.workspace ? `yes, workspace "${s.workspace}" (${s.apiUrl}${s.server === "down" ? ", not answering" : s.server === "revoked" ? ", login revoked" : ""})` : "no"}`,
|
|
95
102
|
`MCP server: ${s.plugin ? "from the openscaffold plugin" : s.mcp === null ? "unknown (Claude Code not on PATH)" : s.mcp ? "added" : "missing"}`,
|
|
96
103
|
`Session hook: ${s.hook ? "installed" : "missing"}`,
|
|
97
|
-
...(s.guardrails === undefined ? [] : [`Guardrails: ${s.guardrails ? "pre-commit plan check and end-of-session report reminder" : "missing"}`]),
|
|
104
|
+
...(s.guardrails === undefined ? [] : [`Guardrails: ${s.guardrails ? "pre-commit plan check and end-of-session report reminder" : "missing or out of date (an older login's customer-switch hook): re-run npx @openscaffold/cli login"}`]),
|
|
98
105
|
`Commands: ${s.plugin ? "/openscaffold:* from the plugin" : commandsOk ? `${s.skills} /os-* commands` : s.skills ? `${s.skills} of ${s.skillsExpected || "?"} /os-* commands` : "missing"}`,
|
|
99
106
|
];
|
|
100
107
|
const next = !s.workspace || s.server === "revoked" || mcpOk === false || !s.hook || s.guardrails === false || !commandsOk
|
|
@@ -163,11 +170,204 @@ export function deploymentForCwd(index, cwd) {
|
|
|
163
170
|
dir = up;
|
|
164
171
|
}
|
|
165
172
|
}
|
|
173
|
+
/** A tool's output as text: a string, MCP content blocks, or `{content}` / `{text}` objects (transcripts, hook input). */
|
|
174
|
+
const textOf = (r) => typeof r === "string" ? r : Array.isArray(r) ? r.map(textOf).join("\n") : r && typeof r === "object" ? textOf(r.text ?? r.content) : "";
|
|
175
|
+
/**
|
|
176
|
+
* A use_deployment or create_deployment result (PostToolUse `tool_response`) → the deployment it switched to and a briefing
|
|
177
|
+
* to cache: use_deployment's own, or for create_deployment (JSON `{deployment: {id, name}}`, no briefing yet) just its name line.
|
|
178
|
+
*/
|
|
179
|
+
export function switchedTo(response) {
|
|
180
|
+
const briefing = textOf(response);
|
|
181
|
+
const deployment = /^Deployment id: (dep_\w+)/m.exec(briefing)?.[1];
|
|
182
|
+
if (deployment)
|
|
183
|
+
return { deployment, briefing };
|
|
184
|
+
try {
|
|
185
|
+
const created = JSON.parse(briefing).deployment;
|
|
186
|
+
if (typeof created?.id === "string" && /^dep_\w+$/.test(created.id))
|
|
187
|
+
return { deployment: created.id, briefing: `# OpenScaffold — ${String(created.name ?? created.id)}\n` };
|
|
188
|
+
}
|
|
189
|
+
catch { }
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Whether a switch seen by the use-deployment hook may replace the CLI's cache. The user-scope server (`mcp__openscaffold__…`)
|
|
194
|
+
* is the one login added with this CLI's token: same server and workspace. The plugin's server is the hosted app signed in
|
|
195
|
+
* by its own OAuth, maybe another workspace: only a deployment this login already cached (`known`) on the hosted app. Else no.
|
|
196
|
+
*/
|
|
197
|
+
export function switchAllowed(toolName, deployment, apiUrl, known) {
|
|
198
|
+
if (toolName?.startsWith(`mcp__${MCP_NAME}__`))
|
|
199
|
+
return true;
|
|
200
|
+
return Boolean(toolName?.startsWith("mcp__plugin_")) && pluginServes(apiUrl) && new Set(known).has(deployment);
|
|
201
|
+
}
|
|
202
|
+
/** The Claude Code session a CLI command runs in, when Claude Code says (env), so `review` follows that session's deployment. */
|
|
203
|
+
export const callerSession = (env) => env.CLAUDE_SESSION_ID || env.CLAUDE_CODE_SESSION_ID || undefined;
|
|
166
204
|
/** session-start's per-Claude-session record (cache sessions.json): this session's entry added, only the newest `keep` kept. */
|
|
167
205
|
export function withSession(sessions, sessionId, entry, keep = 200) {
|
|
168
206
|
const all = Object.entries({ ...sessions, [sessionId]: entry }).sort(([, a], [, b]) => b.at.localeCompare(a.at));
|
|
169
207
|
return Object.fromEntries(all.slice(0, keep));
|
|
170
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* The cache after loading a deployment: the Claude session's own entry, and the folder's default. A mid-session switch
|
|
211
|
+
* with a known session moves only that session's entry, so another session in the folder (or a `review` that can't tell
|
|
212
|
+
* which session called it) never picks it up; a session start, or a switch with no session, sets the folder default. Pure.
|
|
213
|
+
*/
|
|
214
|
+
export function rememberedCache(index, sessions, cwd, sessionId, entry, switching = false) {
|
|
215
|
+
const prev = sessionId ? sessions[sessionId] : undefined;
|
|
216
|
+
return {
|
|
217
|
+
index: switching && sessionId ? index : { ...index, [cwd]: { deployment: entry.deployment, at: entry.at, login: entry.login } },
|
|
218
|
+
sessions: sessionId ? withSession(sessions, sessionId, { ...entry, started: prev?.started ?? entry.at, transcript: entry.transcript ?? prev?.transcript }) : sessions,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* What the use-deployment hook (PostToolUse on every OpenScaffold tool) caches, or null. use_deployment / create_deployment:
|
|
223
|
+
* the deployment it switched to, with its briefing. Any other call with a `deployment_id` (get_briefing, get_plan, …): that
|
|
224
|
+
* deployment, for the calling session only (`sessionOnly`; no session id, nothing), no briefing. Unchanged (`current`) or
|
|
225
|
+
* not this login's server and workspace (switchAllowed): null.
|
|
226
|
+
*/
|
|
227
|
+
export function cacheFromToolCall(call, apiUrl, known, current) {
|
|
228
|
+
const tool = /^mcp__.*openscaffold.*__(\w+)$/.exec(call.tool_name ?? "")?.[1];
|
|
229
|
+
if (!tool || toolFailed(call.tool_response))
|
|
230
|
+
return null; // an error ("No active deployment…") keeps the current cache
|
|
231
|
+
let got = null;
|
|
232
|
+
if (tool === "use_deployment" || tool === "create_deployment") {
|
|
233
|
+
const s = switchedTo(call.tool_response);
|
|
234
|
+
got = s && { ...s, sessionOnly: false };
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
const id = call.tool_input?.deployment_id;
|
|
238
|
+
if (typeof id === "string" && /^dep_\w+$/.test(id) && call.session_id && id !== current)
|
|
239
|
+
got = { deployment: id, briefing: null, sessionOnly: true };
|
|
240
|
+
}
|
|
241
|
+
return got && switchAllowed(call.tool_name, got.deployment, apiUrl, known) ? got : null;
|
|
242
|
+
}
|
|
243
|
+
/** A tool_response that reports an error: MCP `isError`, an `{error}` object, or text that starts "Error" / "MCP error". */
|
|
244
|
+
const toolFailed = (r) => (Boolean(r) && typeof r === "object" && !Array.isArray(r) && (r.isError === true || "error" in r))
|
|
245
|
+
|| /^(MCP )?error\b/i.test(textOf(r).trim());
|
|
246
|
+
/**
|
|
247
|
+
* A file automatic selection never sends (a path the agent names still goes): every dot-file (.env, .npmrc, .netrc, …)
|
|
248
|
+
* and names that look like keys or credentials. ponytail: name patterns, not content scanning; extend the list as found.
|
|
249
|
+
*/
|
|
250
|
+
export const looksSecret = (path) => {
|
|
251
|
+
const name = path.split(/[\\/]/).pop() ?? path;
|
|
252
|
+
return name.startsWith(".") || /\.(pem|key|p12|pfx|jks|keystore|asc|gpg|ppk)$|\.env$|\.env\.|^id_(rsa|dsa|ecdsa|ed25519)|credential|secret/i.test(name);
|
|
253
|
+
};
|
|
254
|
+
/**
|
|
255
|
+
* Outside git with no files named: the session's edited files (transcript) when any are usable, else files modified since
|
|
256
|
+
* `since` (sessionStart); likely secrets dropped either way. `edited`: null with no transcript.
|
|
257
|
+
*/
|
|
258
|
+
export function sessionFileChoice(edited, since, modifiedSince) {
|
|
259
|
+
const safe = (fs) => fs.filter((f) => !looksSecret(f));
|
|
260
|
+
const fromTranscript = safe(edited ?? []);
|
|
261
|
+
if (fromTranscript.length)
|
|
262
|
+
return { files: fromTranscript, from: "edited in this session" };
|
|
263
|
+
return { files: safe(modifiedSince(since.at)), from: `modified ${since.label}` };
|
|
264
|
+
}
|
|
265
|
+
/** The mtime fallback's window when nothing says when the session started. */
|
|
266
|
+
export const UNKNOWN_START_WINDOW_MS = 12 * 3_600_000;
|
|
267
|
+
/**
|
|
268
|
+
* When the Claude session started, independent of our cache: the earliest `"timestamp"` in its transcript, else the
|
|
269
|
+
* transcript file's birthtime, else the cache's `started` (first cache write: late when hooks are missing), else the
|
|
270
|
+
* last 12 hours.
|
|
271
|
+
*/
|
|
272
|
+
export function sessionStartedAt(transcript, birthtimeMs, cachedStarted, now = Date.now()) {
|
|
273
|
+
let first = Infinity;
|
|
274
|
+
for (const m of (transcript ?? "").matchAll(/"timestamp":"([^"]+)"/g)) {
|
|
275
|
+
const t = Date.parse(m[1]);
|
|
276
|
+
if (t < first)
|
|
277
|
+
first = t;
|
|
278
|
+
}
|
|
279
|
+
const iso = (t) => new Date(t).toISOString();
|
|
280
|
+
if (Number.isFinite(first))
|
|
281
|
+
return { at: first, label: `since this session started (${iso(first)})` };
|
|
282
|
+
if (birthtimeMs && birthtimeMs > 0)
|
|
283
|
+
return { at: birthtimeMs, label: `since this session started (${iso(birthtimeMs)})` };
|
|
284
|
+
// Our cache's `started` is only when we first cached a deployment (after a server recovery, moments ago), never the
|
|
285
|
+
// session's start: it can widen the window, never narrow it below the last 12 hours.
|
|
286
|
+
const cached = cachedStarted ? Date.parse(cachedStarted) : NaN;
|
|
287
|
+
const window = now - UNKNOWN_START_WINDOW_MS;
|
|
288
|
+
if (Number.isFinite(cached) && cached < window)
|
|
289
|
+
return { at: cached, label: `since ${cachedStarted}` };
|
|
290
|
+
return { at: window, label: "in the last 12 hours (session start unknown)" };
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Automatic selection only: the paths (relative to `cwd`) whose real location, links resolved, is still inside the real
|
|
294
|
+
* `cwd`, so `linked/config.json` through a link to another folder is never sent. Missing or unreadable paths drop out.
|
|
295
|
+
*/
|
|
296
|
+
export function insideFolder(paths, cwd) {
|
|
297
|
+
let root;
|
|
298
|
+
try {
|
|
299
|
+
root = realpathSync(cwd);
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return [];
|
|
303
|
+
}
|
|
304
|
+
return paths.filter((p) => {
|
|
305
|
+
try {
|
|
306
|
+
const r = relative(root, realpathSync(resolve(cwd, p)));
|
|
307
|
+
return Boolean(r) && r !== ".." && !r.startsWith(`..${sep}`) && !isAbsolute(r);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
/** The files a session's transcript (JSONL) shows it wrote or edited, in order, each once: file_path / notebook_path. */
|
|
315
|
+
export function editedPaths(jsonl) {
|
|
316
|
+
const out = new Set();
|
|
317
|
+
for (const line of jsonl.split("\n")) {
|
|
318
|
+
if (!line.includes('"tool_use"'))
|
|
319
|
+
continue;
|
|
320
|
+
let content;
|
|
321
|
+
try {
|
|
322
|
+
content = JSON.parse(line).message?.content;
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (!Array.isArray(content))
|
|
328
|
+
continue;
|
|
329
|
+
for (const c of content) {
|
|
330
|
+
const path = c?.input?.file_path ?? c?.input?.notebook_path;
|
|
331
|
+
if (c?.type === "tool_use" && EDIT_TOOLS.has(c.name ?? "") && typeof path === "string")
|
|
332
|
+
out.add(path);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return [...out];
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Files under `root` modified after `since` (ms), paths relative to it, sorted: the change outside git when no transcript
|
|
339
|
+
* says what was edited. Skips node_modules, dot-folders (.git, .venv, …) and likely secrets (looksSecret); at most `cap` files.
|
|
340
|
+
*/
|
|
341
|
+
export function filesModifiedSince(root, since, cap = 200) {
|
|
342
|
+
const out = [];
|
|
343
|
+
const walk = (dir, rel) => {
|
|
344
|
+
let entries;
|
|
345
|
+
try {
|
|
346
|
+
entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
for (const e of entries) {
|
|
352
|
+
if (out.length >= cap)
|
|
353
|
+
return;
|
|
354
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
355
|
+
if (e.isDirectory()) {
|
|
356
|
+
if (e.name !== "node_modules" && !e.name.startsWith("."))
|
|
357
|
+
walk(join(dir, e.name), r);
|
|
358
|
+
}
|
|
359
|
+
else if (e.isFile() && !looksSecret(e.name)) {
|
|
360
|
+
try {
|
|
361
|
+
if (statSync(join(dir, e.name)).mtimeMs > since)
|
|
362
|
+
out.push(r);
|
|
363
|
+
}
|
|
364
|
+
catch { }
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
walk(root, "");
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
171
371
|
/** The deployment a hook's session loaded: by its Claude session_id first (two sessions can share a folder), else the folder's. */
|
|
172
372
|
export const loadedEntry = (sessions, index, sessionId, cwd) => (sessionId ? sessions[sessionId] : undefined) ?? deploymentForCwd(index, cwd);
|
|
173
373
|
/** A briefing section's list lines (§8.3), citations dropped: `- On-prem only` from `- On-prem only [Ticket · doc_… “…”]`. */
|
|
@@ -209,12 +409,23 @@ export function preCommitOutput(briefing, plugin) {
|
|
|
209
409
|
const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
210
410
|
/** Our report_session tool, from the user-scope server or the plugin's (`mcp__plugin_openscaffold_openscaffold__…`). */
|
|
211
411
|
const REPORT = /^mcp__.*openscaffold.*__report_session$/;
|
|
212
|
-
/**
|
|
412
|
+
/** The calls whose output can be a review: our get_review (user-scope or plugin), or the CLI's `review` through Bash. */
|
|
413
|
+
const GET_REVIEW = /^mcp__.*openscaffold.*__get_review$/;
|
|
414
|
+
const CLI_REVIEW = /(@openscaffold\/cli(@\S+)?|\bopenscaffold)\s+review\b/;
|
|
415
|
+
/** A finished review's verdict line (get_review's Markdown, which the CLI prints). Running and failed reviews have none. */
|
|
416
|
+
const VERDICT = "\nVerdict: ";
|
|
417
|
+
/**
|
|
418
|
+
* From a session transcript (JSONL): did the agent edit files, get a finished review back, call report_session? A review
|
|
419
|
+
* counts only as the tool_result of a get_review or Bash `… review` call (matched by tool_use_id) holding a verdict, so a
|
|
420
|
+
* failed or still-running review, or reading a file that contains one, doesn't.
|
|
421
|
+
*/
|
|
213
422
|
export function transcriptActivity(jsonl) {
|
|
214
423
|
let edited = false;
|
|
215
424
|
let reported = false;
|
|
425
|
+
let reviewed = false;
|
|
426
|
+
const reviewCalls = new Set();
|
|
216
427
|
for (const line of jsonl.split("\n")) {
|
|
217
|
-
if (!line.includes('"tool_use"'))
|
|
428
|
+
if (!line.includes('"tool_use"') && !(line.includes('"tool_result"') && line.includes("Verdict: ")))
|
|
218
429
|
continue;
|
|
219
430
|
let content;
|
|
220
431
|
try {
|
|
@@ -226,25 +437,35 @@ export function transcriptActivity(jsonl) {
|
|
|
226
437
|
if (!Array.isArray(content))
|
|
227
438
|
continue;
|
|
228
439
|
for (const c of content) {
|
|
440
|
+
if (c?.type === "tool_result" && c.tool_use_id && reviewCalls.has(c.tool_use_id) && textOf(c.content).includes(VERDICT))
|
|
441
|
+
reviewed = true;
|
|
229
442
|
if (c?.type !== "tool_use" || typeof c.name !== "string")
|
|
230
443
|
continue;
|
|
231
444
|
if (EDIT_TOOLS.has(c.name))
|
|
232
445
|
edited = true;
|
|
233
446
|
if (REPORT.test(c.name))
|
|
234
447
|
reported = true;
|
|
448
|
+
if (c.id && (GET_REVIEW.test(c.name) || (c.name === "Bash" && typeof c.input?.command === "string" && CLI_REVIEW.test(c.input.command))))
|
|
449
|
+
reviewCalls.add(c.id);
|
|
235
450
|
}
|
|
236
451
|
}
|
|
237
|
-
return { edited, reported };
|
|
452
|
+
return { edited, reported, reviewed };
|
|
238
453
|
}
|
|
239
454
|
export const STOP_REASON = "Before you finish, call report_session with what you did.";
|
|
455
|
+
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.";
|
|
240
456
|
/**
|
|
241
|
-
* The Stop hook's stdout: the documented block decision, once per session, when this session loaded a deployment
|
|
242
|
-
* edited files
|
|
457
|
+
* The Stop hook's stdout: the documented block decision, once per session, when this session loaded a deployment and
|
|
458
|
+
* edited files: first ask for a review (§8.4) if no review result came back, else for report_session if it never
|
|
459
|
+
* reported; else "". Never twice, and never while Claude is already continuing for a stop hook.
|
|
243
460
|
*/
|
|
244
461
|
export function stopOutput(s) {
|
|
245
|
-
if (s.stopHookActive || s.reminded || !s.loaded || !s.edited
|
|
462
|
+
if (s.stopHookActive || s.reminded || !s.loaded || !s.edited)
|
|
246
463
|
return "";
|
|
247
|
-
|
|
464
|
+
if (!s.reviewed)
|
|
465
|
+
return JSON.stringify({ decision: "block", reason: STOP_REVIEW_REASON });
|
|
466
|
+
if (!s.reported)
|
|
467
|
+
return JSON.stringify({ decision: "block", reason: STOP_REASON });
|
|
468
|
+
return "";
|
|
248
469
|
}
|
|
249
470
|
// --- setup: one command from a folder of customer files to a cited plan (SPEC §8.4) -------------
|
|
250
471
|
/** What `setup` uploads: the upload panel's file types (§6.1). CSV/JSON become one ticket per row. */
|
|
@@ -318,6 +539,10 @@ export function briefingStatus(briefing) {
|
|
|
318
539
|
// Written for an agent: drop the sentences that tell it which tool to call next.
|
|
319
540
|
return line.split(/(?<=[.;])\s+/).filter((x) => !/`|tell the FDE/i.test(x)).join(" ").replace(/;$/, ".");
|
|
320
541
|
}
|
|
542
|
+
/** The verified facts a pre-plan briefing's Status lists ("- Goal: … [title · doc_… “quote”]"), without the document ids. */
|
|
543
|
+
export function briefingFacts(briefing) {
|
|
544
|
+
return section(briefing, "Status").filter((l) => l.startsWith("- ")).map((l) => l.replace(/ · doc_\w+/, ""));
|
|
545
|
+
}
|
|
321
546
|
/**
|
|
322
547
|
* One MCP `tools/call` response body (JSON, or a streamable-HTTP SSE frame) → the tool's text, or its error. The first
|
|
323
548
|
* content block only: a second one may carry the "Since your briefing" freshness line.
|
|
@@ -336,3 +561,147 @@ export function mcpText(raw) {
|
|
|
336
561
|
const text = msg.result?.content?.[0]?.text ?? "";
|
|
337
562
|
return msg.result?.isError ? { error: text || "OpenScaffold refused the call." } : { text };
|
|
338
563
|
}
|
|
564
|
+
// --- review: send this folder's change to OpenScaffold's reviewer (SPEC §8.1 request_review) ------
|
|
565
|
+
/** request_review's limit (web/lib/pipeline/review.ts MAX_DIFF_BYTES). */
|
|
566
|
+
export const MAX_DIFF_BYTES = 400 * 1024;
|
|
567
|
+
const REVIEW_CMD = "npx -y @openscaffold/cli@latest review";
|
|
568
|
+
const REVIEW_USAGE = `Usage: ${REVIEW_CMD} [--req req_1,req_2] [--title "…"] [--deployment dep_…] [files…], e.g. ${REVIEW_CMD} --req req_1`;
|
|
569
|
+
export const REVIEW_WAIT = "Usually 30–90 s, up to ~7 min; don't wrap it in `timeout`.";
|
|
570
|
+
export const REVIEW_HELP = [
|
|
571
|
+
REVIEW_USAGE,
|
|
572
|
+
"",
|
|
573
|
+
"Reviews this folder's change against the customer's plan and rules: sends the git diff (or the listed files, where git",
|
|
574
|
+
"sees no change) and prints the verdict.",
|
|
575
|
+
" --req ids The plan's requirement ids this change works on (from the briefing or get_plan)",
|
|
576
|
+
' --title "…" One line: what the change does (default: those requirements\' titles); a bare phrase works too',
|
|
577
|
+
" --deployment id The customer (default: the one this folder's session loaded)",
|
|
578
|
+
" files… Narrow the change to these paths; outside git, the files to send whole",
|
|
579
|
+
REVIEW_WAIT,
|
|
580
|
+
].join("\n");
|
|
581
|
+
/** The title when none is given and the cached briefing names none of the claimed requirements. */
|
|
582
|
+
export const DEFAULT_REVIEW_TITLE = "Review of current changes";
|
|
583
|
+
/**
|
|
584
|
+
* `review` arguments; files are optional (git finds the change) and needed only where git sees none. A bare argument
|
|
585
|
+
* that isn't a file (`exists`) is the title, so `review "Fix X"` works. With no title, DEFAULT_REVIEW_TITLE (see reviewTitle).
|
|
586
|
+
*/
|
|
587
|
+
export function parseReviewArgs(args, exists = () => true) {
|
|
588
|
+
const out = { title: "", reqs: [], deployment: undefined, files: [] };
|
|
589
|
+
for (let i = 0; i < args.length; i++) {
|
|
590
|
+
const eq = args[i].startsWith("--") ? args[i].indexOf("=") : -1;
|
|
591
|
+
const [flag, inline] = eq < 0 ? [args[i], undefined] : [args[i].slice(0, eq), args[i].slice(eq + 1)];
|
|
592
|
+
const value = () => inline ?? args[++i];
|
|
593
|
+
if (flag === "--help" || flag === "-h")
|
|
594
|
+
return { help: true };
|
|
595
|
+
if (flag === "--title")
|
|
596
|
+
out.title = value() ?? "";
|
|
597
|
+
else if (flag === "--req" || flag === "--requirements")
|
|
598
|
+
out.reqs.push(...(value() ?? "").split(/[,\s]+/).filter(Boolean));
|
|
599
|
+
else if (flag === "--deployment")
|
|
600
|
+
out.deployment = value();
|
|
601
|
+
else if (/^-./.test(args[i]))
|
|
602
|
+
return { error: `Unknown option ${flag}. ${REVIEW_USAGE}` };
|
|
603
|
+
else if (/^req_\w+$/.test(args[i]) && !exists(args[i]))
|
|
604
|
+
out.reqs.push(args[i]); // `--req req_1 req_2`
|
|
605
|
+
// `exists` also knows paths git tracks (a deleted file), so a listed deletion is a file, not the title.
|
|
606
|
+
else if (!out.title && !exists(args[i]))
|
|
607
|
+
out.title = args[i];
|
|
608
|
+
else
|
|
609
|
+
out.files.push(args[i]);
|
|
610
|
+
}
|
|
611
|
+
return { ...out, title: out.title.trim() || DEFAULT_REVIEW_TITLE, reqs: [...new Set(out.reqs)] };
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* A CLI command line to copy and run: `npx @openscaffold/cli <args>` (no `@latest`: the version already on this machine), each argument single-quoted when a shell
|
|
615
|
+
* would split or expand it ($, backticks, spaces), so the copy keeps the exact text: PowerShell's form on Windows.
|
|
616
|
+
*/
|
|
617
|
+
export const commandLine = (args, platform = process.platform) => ["npx @openscaffold/cli", ...args.map((a) => /^[\w./:@=,-]+$/.test(a) ? a
|
|
618
|
+
// PowerShell: single quotes are literal and a quote is doubled; elsewhere POSIX's '\'' escape.
|
|
619
|
+
: platform === "win32" ? `'${a.replace(/'/g, "''")}'` : `'${a.replace(/'/g, `'\\''`)}'`)].join(" ");
|
|
620
|
+
/**
|
|
621
|
+
* Whether git tracks a path given relative to the current folder: in the index, or in HEAD (a staged deletion).
|
|
622
|
+
* `HEAD:./p` resolves against the current folder, not the repository root. `git` runs git and throws on failure.
|
|
623
|
+
*/
|
|
624
|
+
export function gitTracks(path, git) {
|
|
625
|
+
return [["ls-files", "--error-unmatch", "--", path], ["cat-file", "-e", `HEAD:./${path.replace(/^\.\//, "")}`]].some((a) => {
|
|
626
|
+
try {
|
|
627
|
+
git(a);
|
|
628
|
+
return true;
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
/** `review` with no --deployment and none cached for this session or folder: the same command with each cached customer's id. */
|
|
636
|
+
export function whichCustomerError(args, known) {
|
|
637
|
+
const head = "Which customer? This session has no OpenScaffold deployment cached here.";
|
|
638
|
+
if (!known.length)
|
|
639
|
+
return `${head} Add the id from the OpenScaffold briefing ("Deployment id: …", or call list_deployments), e.g. ${commandLine([...args, "--deployment", "dep_01EXAMPLE"])}`;
|
|
640
|
+
return [`${head} Run one of:`, ...known.map((d) => ` ${commandLine([...args, "--deployment", d.id])}${d.name ? ` # ${d.name}` : ""}`)].join("\n");
|
|
641
|
+
}
|
|
642
|
+
/** The default title: the claimed requirements' titles from the cached briefing's must-haves (`- req_1 Title — status`). */
|
|
643
|
+
export function reviewTitle(reqs, briefing) {
|
|
644
|
+
const line = (id) => briefing?.split("\n").find((l) => l.startsWith(`- ${id} `));
|
|
645
|
+
const titles = reqs.map((id) => line(id)?.slice(id.length + 3).replace(/^\[[^\]]*\] /, "").replace(/ — \S+$/, "")).filter(Boolean);
|
|
646
|
+
return titles.length ? titles.join("; ").slice(0, 300) : DEFAULT_REVIEW_TITLE;
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* A whole file as an added-file diff, as `git diff --no-index /dev/null <file>` prints it. Empty or binary: "". A
|
|
650
|
+
* symbolic link is a link (mode 120000, its target path as the content), as git records it.
|
|
651
|
+
*/
|
|
652
|
+
export function newFileDiff(path, file) {
|
|
653
|
+
const link = typeof file === "object";
|
|
654
|
+
const text = link ? file.link : file;
|
|
655
|
+
if (!text || text.includes("\0"))
|
|
656
|
+
return "";
|
|
657
|
+
const lines = link ? [text] : text.replace(/\r\n/g, "\n").split("\n");
|
|
658
|
+
const newline = !link && lines.at(-1) === "";
|
|
659
|
+
if (newline)
|
|
660
|
+
lines.pop();
|
|
661
|
+
return [`diff --git a/${path} b/${path}`, `new file mode ${link ? "120000" : "100644"}`, "--- /dev/null", `+++ b/${path}`, `@@ -0,0 +1,${lines.length} @@`,
|
|
662
|
+
...lines.map((l) => `+${l}`), ...(newline ? [] : ["\"]), ""].join("\n");
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* The change to review, from where the agent works: in a git repo, everything since HEAD (staged, unstaged, and
|
|
666
|
+
* untracked files as new files), narrowed to `files` when given. Where git sees nothing (not a repo, a git-ignored
|
|
667
|
+
* folder, a repo with no commit), the listed files are sent whole as new files. `git` runs one git command in the
|
|
668
|
+
* folder and throws when git fails; `read` reads a file.
|
|
669
|
+
*/
|
|
670
|
+
export function collectDiff(files, git, read) {
|
|
671
|
+
let diff = "";
|
|
672
|
+
let repo = false;
|
|
673
|
+
try {
|
|
674
|
+
repo = git(["rev-parse", "--is-inside-work-tree"]).trim() === "true";
|
|
675
|
+
}
|
|
676
|
+
catch { }
|
|
677
|
+
if (repo) {
|
|
678
|
+
const only = files.length ? ["--", ...files] : [];
|
|
679
|
+
let head = true;
|
|
680
|
+
try {
|
|
681
|
+
git(["rev-parse", "--verify", "-q", "HEAD"]);
|
|
682
|
+
}
|
|
683
|
+
catch {
|
|
684
|
+
head = false;
|
|
685
|
+
}
|
|
686
|
+
// No commit yet: compare with the empty tree, so staged and unstaged files both show.
|
|
687
|
+
const base = head ? "HEAD" : git(["hash-object", "-t", "tree", "/dev/null"]).trim();
|
|
688
|
+
// --no-ext-diff / --no-textconv: a repo's configured diff drivers and converters are commands; we never run them.
|
|
689
|
+
diff = git(["diff", "--no-color", "--no-ext-diff", "--no-textconv", base, ...only]);
|
|
690
|
+
const untracked = git(["ls-files", "--others", "--exclude-standard", "-z", ...only]).split("\0").filter(Boolean);
|
|
691
|
+
// Files the agent named that git ignores still get reviewed, even when other files changed.
|
|
692
|
+
if (files.length)
|
|
693
|
+
untracked.push(...git(["ls-files", "--others", "--ignored", "--exclude-standard", "-z", ...only]).split("\0").filter(Boolean));
|
|
694
|
+
const prefix = git(["rev-parse", "--show-prefix"]).trim();
|
|
695
|
+
diff += untracked.map((f) => newFileDiff(prefix + f, read(f))).join(""); // ls-files paths are from this folder; diff paths from the root
|
|
696
|
+
}
|
|
697
|
+
if (!diff.trim() && files.length)
|
|
698
|
+
diff = files.map((f) => newFileDiff(f.replace(/^\.\//, ""), read(f))).join("");
|
|
699
|
+
if (!diff.trim()) {
|
|
700
|
+
return { error: repo
|
|
701
|
+
? `Nothing to review: git sees no change here. If your files are in a git-ignored folder, list them: ${REVIEW_USAGE}`
|
|
702
|
+
: `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.` };
|
|
703
|
+
}
|
|
704
|
+
if (Buffer.byteLength(diff) > MAX_DIFF_BYTES)
|
|
705
|
+
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.` };
|
|
706
|
+
return { diff };
|
|
707
|
+
}
|