@openscaffold/cli 0.1.3 → 0.1.4

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 CHANGED
@@ -7,7 +7,6 @@ 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)
11
10
  npx @openscaffold/cli status # what's set up and the next step (--json for coding agents)
12
11
  npx @openscaffold/cli logout # removes everything it installed and revokes the login
13
12
  npx @openscaffold/cli help # commands and environment variables
@@ -27,11 +26,6 @@ MCP server and `/openscaffold:*` commands).
27
26
  - `~/.config/openscaffold/credentials.json` (your login, mode 0600)
28
27
  - the `openscaffold` MCP server in Claude Code (user scope)
29
28
  - 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).
35
29
  - `/os-requirements`, `/os-what-changed`, `/os-check-plan`, `/os-demo-check` in `~/.claude/skills/`
36
30
 
37
31
  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, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, 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, 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";
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, withSession, } 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}, 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}.`);
116
+ ? `Tell the person: open ${login_url} and click Approve. The page shows the code ${code}. Signing up, if they need to, happens on that page too.`
117
+ : `Opening ${login_url}\nClick Approve in your browser. 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,7 +282,6 @@ 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
286
285
  let idle = 0;
287
286
  let nudged = false;
288
287
  for (;;) {
@@ -297,13 +296,6 @@ async function setup() {
297
296
  }
298
297
  if (status && status !== last)
299
298
  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
- }
307
299
  idle = status?.startsWith("No plan yet") ? idle + 1 : 0;
308
300
  if (idle >= 2) {
309
301
  if (nudged)
@@ -336,17 +328,6 @@ const git = (cwd, args) => {
336
328
  return null;
337
329
  }
338
330
  };
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
- }
350
331
  /** Prints the briefing. Never breaks a session: on any problem, a cached briefing or one line, exit 0. */
351
332
  async function sessionStart() {
352
333
  let hook = {};
@@ -368,8 +349,15 @@ async function sessionStart() {
368
349
  const briefing = String(r.body.briefing);
369
350
  console.log(briefing);
370
351
  const dep = r.body.deployment?.id;
371
- if (dep)
372
- remember(creds, dep, briefing, cwd, hook.session_id);
352
+ if (dep) {
353
+ mkdirSync(CACHE, { recursive: true });
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
361
  }
374
362
  catch {
375
363
  const last = index[cwd]?.login === loginKey(creds) ? index[cwd] : undefined; // a session started before a re-login may write late
@@ -400,14 +388,14 @@ async function preCommit() {
400
388
  if (out)
401
389
  console.log(out);
402
390
  }
403
- /** Stop: once per session, if it edited files and never called request_review (or report_session), ask for it. */
391
+ /** Stop: once per session, if it edited files and never called report_session, ask the agent to report. */
404
392
  async function stop() {
405
393
  const hook = await hookInput();
406
394
  if (!hook.session_id || hook.stop_hook_active)
407
395
  return;
408
396
  const marker = join(CACHE, `reminded-${hook.session_id.replace(/[^\w-]/g, "")}`);
409
397
  const transcript = hook.transcript_path?.replace(/^~(?=[\\/])/, homedir());
410
- const activity = transcript && existsSync(transcript) ? transcriptActivity(readFileSync(transcript, "utf8")) : { edited: false, reported: false, reviewed: false };
398
+ const activity = transcript && existsSync(transcript) ? transcriptActivity(readFileSync(transcript, "utf8")) : { edited: false, reported: false };
411
399
  const out = stopOutput({ stopHookActive: false, reminded: existsSync(marker), loaded: Boolean(loadedHere(hook.session_id, hook.cwd ?? process.cwd())), ...activity });
412
400
  if (!out)
413
401
  return;
@@ -415,74 +403,6 @@ async function stop() {
415
403
  writeFileSync(marker, new Date().toISOString());
416
404
  console.log(out);
417
405
  }
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
- }
439
- // --- review -------------------------------------------------------------------------------------
440
- /**
441
- * `review [--req ids] [--title "…"] [--deployment dep_…] [files…]`: the coding agent's one command for a review. It
442
- * collects the change itself (git, or the listed files where git sees none), calls request_review, waits on
443
- * get_review (which holds up to 50 s per call) and prints the verdict. Nothing to paste, nothing to poll.
444
- */
445
- async function review() {
446
- const args = parseReviewArgs(process.argv.slice(3), existsSync);
447
- if ("help" in args)
448
- return console.log(REVIEW_HELP);
449
- if ("error" in args)
450
- throw new Error(args.error);
451
- const creds = credentials();
452
- const again = process.argv.slice(2);
453
- if (!creds)
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;
457
- if (!deployment)
458
- throw new Error(whichCustomerError(again, knownDeployments(creds)));
459
- const git = (a) => execFileSync("git", a, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
460
- const got = collectDiff(args.files, git, (f) => (lstatSync(f).isSymbolicLink() ? { link: readlinkSync(f) } : readFileSync(f, "utf8")));
461
- if ("error" in got)
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}`);
470
- try {
471
- const queued = JSON.parse(await tool(creds, "request_review", { deployment_id: deployment, title, requirement_ids: args.reqs, diff: got.diff }, 60_000));
472
- if (!queued.review_id)
473
- return console.log(queued.note ?? "Nothing to review.");
474
- let out = "";
475
- // Each get_review waits up to 50 s on the server; a review that outlasts four of them is reported as still running.
476
- for (let i = 0; i < 4 && (!out || out.includes("Still reviewing")); i++)
477
- out = await tool(creds, "get_review", { deployment_id: deployment, review_id: queued.review_id }, 90_000);
478
- console.log(out);
479
- }
480
- catch (e) {
481
- if (e instanceof LoggedOut)
482
- throw new Error(`${e.message} Run \`npx -y @openscaffold/cli@latest login\`, then review again.`);
483
- throw e;
484
- }
485
- }
486
406
  // --- status -------------------------------------------------------------------------------------
487
407
  /** Read-only: what's set up on this machine and the next step. `--json` for agents. Always exits 0. */
488
408
  async function status() {
@@ -511,7 +431,7 @@ async function status() {
511
431
  server,
512
432
  mcp,
513
433
  hook: hooks.includes("session-start"),
514
- guardrails: hooks.includes("pre-commit") && hooks.includes("stop") && hooks.includes("use-deployment"),
434
+ guardrails: hooks.includes("pre-commit") && hooks.includes("stop"),
515
435
  skills: names.filter((n) => onDisk.includes(n)).length,
516
436
  skillsExpected: names.length,
517
437
  plugin: mcp === null ? false : pluginInstalled() && pluginServes(creds?.api_url),
@@ -544,13 +464,10 @@ Usage: npx @openscaffold/cli@latest <command>
544
464
  Everything in one go: sign in if needed, create the customer's deployment, upload
545
465
  every .vtt .srt .txt .md .csv .json in the folder (default .), wait for the plan, print it
546
466
  login Sign in (one browser click on Approve) and connect Claude Code
547
- review [--req req_1,req_2] [--title "…"] [--deployment dep_…] [files…]
548
- Review this folder's change against the customer's plan and rules: sends the git diff
549
- (or the listed files, where git sees no change) and prints the verdict. review --help for more
550
467
  status [--json] What's set up on this machine, and the next step
551
468
  logout Remove everything login added and revoke this machine's login
552
469
  help This text
553
- (session-start, pre-commit, stop and use-deployment are run by Claude Code's hooks)
470
+ (session-start, pre-commit and stop are run by Claude Code's hooks)
554
471
 
555
472
  Environment:
556
473
  OPENSCAFFOLD_URL Server to sign in to (default ${DEFAULT_URL}; e.g. http://localhost:3000)
@@ -558,7 +475,7 @@ Environment:
558
475
  (SSH and headless Linux are detected and skip the browser by themselves)`;
559
476
  const [cmd] = process.argv.slice(2);
560
477
  /** Run by a hook: must never break the session or block a tool, so any problem exits 0 quietly. */
561
- const HOOKS = new Set(["session-start", "pre-commit", "stop", "use-deployment"]);
478
+ const HOOKS = new Set(["session-start", "pre-commit", "stop"]);
562
479
  if (cmd === "help" || cmd === "--help" || cmd === "-h") {
563
480
  console.log(HELP);
564
481
  process.exit(0);
@@ -572,7 +489,7 @@ if (Number(process.versions.node.split(".")[0]) < 22) {
572
489
  console.log(msg);
573
490
  process.exit(HOOKS.has(cmd ?? "") ? 0 : 1);
574
491
  }
575
- const commands = { setup, login: () => login(), review, status, "session-start": sessionStart, "pre-commit": preCommit, stop, "use-deployment": useDeployment, logout };
492
+ const commands = { setup, login: () => login(), status, "session-start": sessionStart, "pre-commit": preCommit, stop, logout };
576
493
  const run = commands[cmd ?? ""];
577
494
  if (!run) {
578
495
  console.error(HELP);
package/dist/install.js CHANGED
@@ -2,21 +2,19 @@
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|use-deployment)"?$/.test(cmd.trim());
5
+ const isOurCommand = (cmd) => /openscaffold/.test(cmd) && /\b(session-start|pre-commit|stop)"?$/.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;
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).
11
+ * the deployment's constraints and must-haves as context; at Stop, a one-time report_session reminder.
13
12
  * Timeouts are seconds. None ever blocks a tool call.
14
13
  */
15
14
  export const OUR_HOOKS = [
16
15
  { event: "SessionStart", sub: "session-start", matcher: HOOK_MATCHER, handler: { timeout: 10 } },
17
16
  { event: "PreToolUse", sub: "pre-commit", matcher: "Bash", handler: { if: "Bash(git commit *)", timeout: 2 } },
18
17
  { event: "Stop", sub: "stop", matcher: undefined, handler: { timeout: 5 } },
19
- { event: "PostToolUse", sub: "use-deployment", matcher: "mcp__.*openscaffold.*__(use|create)_deployment", handler: { timeout: 2 } },
20
18
  ];
21
19
  const withoutOurs = (groups = []) => groups.map((g) => ({ ...g, hooks: g.hooks.filter((h) => !isOurs(h)) })).filter((g) => g.hooks.length > 0);
22
20
  /** Adds (or replaces) our hooks, `<base> <subcommand>` each; leaves everything else in settings.json as it was. */
@@ -96,7 +94,7 @@ export function statusReport(s) {
96
94
  `Signed in: ${s.workspace ? `yes, workspace "${s.workspace}" (${s.apiUrl}${s.server === "down" ? ", not answering" : s.server === "revoked" ? ", login revoked" : ""})` : "no"}`,
97
95
  `MCP server: ${s.plugin ? "from the openscaffold plugin" : s.mcp === null ? "unknown (Claude Code not on PATH)" : s.mcp ? "added" : "missing"}`,
98
96
  `Session hook: ${s.hook ? "installed" : "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"}`]),
97
+ ...(s.guardrails === undefined ? [] : [`Guardrails: ${s.guardrails ? "pre-commit plan check and end-of-session report reminder" : "missing"}`]),
100
98
  `Commands: ${s.plugin ? "/openscaffold:* from the plugin" : commandsOk ? `${s.skills} /os-* commands` : s.skills ? `${s.skills} of ${s.skillsExpected || "?"} /os-* commands` : "missing"}`,
101
99
  ];
102
100
  const next = !s.workspace || s.server === "revoked" || mcpOk === false || !s.hook || s.guardrails === false || !commandsOk
@@ -165,53 +163,11 @@ export function deploymentForCwd(index, cwd) {
165
163
  dir = up;
166
164
  }
167
165
  }
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;
199
166
  /** session-start's per-Claude-session record (cache sessions.json): this session's entry added, only the newest `keep` kept. */
200
167
  export function withSession(sessions, sessionId, entry, keep = 200) {
201
168
  const all = Object.entries({ ...sessions, [sessionId]: entry }).sort(([, a], [, b]) => b.at.localeCompare(a.at));
202
169
  return Object.fromEntries(all.slice(0, keep));
203
170
  }
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
- }
215
171
  /** The deployment a hook's session loaded: by its Claude session_id first (two sessions can share a folder), else the folder's. */
216
172
  export const loadedEntry = (sessions, index, sessionId, cwd) => (sessionId ? sessions[sessionId] : undefined) ?? deploymentForCwd(index, cwd);
217
173
  /** A briefing section's list lines (§8.3), citations dropped: `- On-prem only` from `- On-prem only [Ticket · doc_… “…”]`. */
@@ -253,23 +209,12 @@ export function preCommitOutput(briefing, plugin) {
253
209
  const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
254
210
  /** Our report_session tool, from the user-scope server or the plugin's (`mcp__plugin_openscaffold_openscaffold__…`). */
255
211
  const REPORT = /^mcp__.*openscaffold.*__report_session$/;
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$/;
258
- const CLI_REVIEW = /(@openscaffold\/cli(@\S+)?|\bopenscaffold)\s+review\b/;
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
- */
212
+ /** From a session transcript (JSONL): did the agent edit files, and did it call report_session? */
266
213
  export function transcriptActivity(jsonl) {
267
214
  let edited = false;
268
215
  let reported = false;
269
- let reviewed = false;
270
- const reviewCalls = new Set();
271
216
  for (const line of jsonl.split("\n")) {
272
- if (!line.includes('"tool_use"') && !(line.includes('"tool_result"') && line.includes("Verdict: ")))
217
+ if (!line.includes('"tool_use"'))
273
218
  continue;
274
219
  let content;
275
220
  try {
@@ -281,35 +226,25 @@ export function transcriptActivity(jsonl) {
281
226
  if (!Array.isArray(content))
282
227
  continue;
283
228
  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;
286
229
  if (c?.type !== "tool_use" || typeof c.name !== "string")
287
230
  continue;
288
231
  if (EDIT_TOOLS.has(c.name))
289
232
  edited = true;
290
233
  if (REPORT.test(c.name))
291
234
  reported = true;
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);
294
235
  }
295
236
  }
296
- return { edited, reported, reviewed };
237
+ return { edited, reported };
297
238
  }
298
239
  export const STOP_REASON = "Before you finish, call report_session with what you did.";
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.";
300
240
  /**
301
- * The Stop hook's stdout: the documented block decision, once per session, when this session loaded a deployment and
302
- * edited files: first ask for a review (§8.4) if no review result came back, else for report_session if it never
303
- * reported; else "". Never twice, and never while Claude is already continuing for a stop hook.
241
+ * The Stop hook's stdout: the documented block decision, once per session, when this session loaded a deployment,
242
+ * edited files and hasn't reported; else "". Never while Claude is already continuing for a stop hook.
304
243
  */
305
244
  export function stopOutput(s) {
306
- if (s.stopHookActive || s.reminded || !s.loaded || !s.edited)
245
+ if (s.stopHookActive || s.reminded || !s.loaded || !s.edited || s.reported)
307
246
  return "";
308
- if (!s.reviewed)
309
- return JSON.stringify({ decision: "block", reason: STOP_REVIEW_REASON });
310
- if (!s.reported)
311
- return JSON.stringify({ decision: "block", reason: STOP_REASON });
312
- return "";
247
+ return JSON.stringify({ decision: "block", reason: STOP_REASON });
313
248
  }
314
249
  // --- setup: one command from a folder of customer files to a cited plan (SPEC §8.4) -------------
315
250
  /** What `setup` uploads: the upload panel's file types (§6.1). CSV/JSON become one ticket per row. */
@@ -383,10 +318,6 @@ export function briefingStatus(briefing) {
383
318
  // Written for an agent: drop the sentences that tell it which tool to call next.
384
319
  return line.split(/(?<=[.;])\s+/).filter((x) => !/`|tell the FDE/i.test(x)).join(" ").replace(/;$/, ".");
385
320
  }
386
- /** The verified facts a pre-plan briefing's Status lists ("- Goal: … [title · doc_… “quote”]"), without the document ids. */
387
- export function briefingFacts(briefing) {
388
- return section(briefing, "Status").filter((l) => l.startsWith("- ")).map((l) => l.replace(/ · doc_\w+/, ""));
389
- }
390
321
  /**
391
322
  * One MCP `tools/call` response body (JSON, or a streamable-HTTP SSE frame) → the tool's text, or its error. The first
392
323
  * content block only: a second one may carry the "Since your briefing" freshness line.
@@ -405,127 +336,3 @@ export function mcpText(raw) {
405
336
  const text = msg.result?.content?.[0]?.text ?? "";
406
337
  return msg.result?.isError ? { error: text || "OpenScaffold refused the call." } : { text };
407
338
  }
408
- // --- review: send this folder's change to OpenScaffold's reviewer (SPEC §8.1 request_review) ------
409
- /** request_review's limit (web/lib/pipeline/review.ts MAX_DIFF_BYTES). */
410
- export const MAX_DIFF_BYTES = 400 * 1024;
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) {
432
- const out = { title: "", reqs: [], deployment: undefined, files: [] };
433
- for (let i = 0; i < args.length; i++) {
434
- const eq = args[i].startsWith("--") ? args[i].indexOf("=") : -1;
435
- const [flag, inline] = eq < 0 ? [args[i], undefined] : [args[i].slice(0, eq), args[i].slice(eq + 1)];
436
- const value = () => inline ?? args[++i];
437
- if (flag === "--help" || flag === "-h")
438
- return { help: true };
439
- if (flag === "--title")
440
- out.title = value() ?? "";
441
- else if (flag === "--req" || flag === "--requirements")
442
- out.reqs.push(...(value() ?? "").split(/[,\s]+/).filter(Boolean));
443
- else if (flag === "--deployment")
444
- out.deployment = value();
445
- else if (/^-./.test(args[i]))
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];
452
- else
453
- out.files.push(args[i]);
454
- }
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;
471
- }
472
- /**
473
- * A whole file as an added-file diff, as `git diff --no-index /dev/null <file>` prints it. Empty or binary: "". A
474
- * symbolic link is a link (mode 120000, its target path as the content), as git records it.
475
- */
476
- export function newFileDiff(path, file) {
477
- const link = typeof file === "object";
478
- const text = link ? file.link : file;
479
- if (!text || text.includes("\0"))
480
- return "";
481
- const lines = link ? [text] : text.replace(/\r\n/g, "\n").split("\n");
482
- const newline = !link && lines.at(-1) === "";
483
- if (newline)
484
- lines.pop();
485
- return [`diff --git a/${path} b/${path}`, `new file mode ${link ? "120000" : "100644"}`, "--- /dev/null", `+++ b/${path}`, `@@ -0,0 +1,${lines.length} @@`,
486
- ...lines.map((l) => `+${l}`), ...(newline ? [] : ["\"]), ""].join("\n");
487
- }
488
- /**
489
- * The change to review, from where the agent works: in a git repo, everything since HEAD (staged, unstaged, and
490
- * untracked files as new files), narrowed to `files` when given. Where git sees nothing (not a repo, a git-ignored
491
- * folder, a repo with no commit), the listed files are sent whole as new files. `git` runs one git command in the
492
- * folder and throws when git fails; `read` reads a file.
493
- */
494
- export function collectDiff(files, git, read) {
495
- let diff = "";
496
- let repo = false;
497
- try {
498
- repo = git(["rev-parse", "--is-inside-work-tree"]).trim() === "true";
499
- }
500
- catch { }
501
- if (repo) {
502
- const only = files.length ? ["--", ...files] : [];
503
- let head = true;
504
- try {
505
- git(["rev-parse", "--verify", "-q", "HEAD"]);
506
- }
507
- catch {
508
- head = false;
509
- }
510
- // No commit yet: compare with the empty tree, so staged and unstaged files both show.
511
- const base = head ? "HEAD" : git(["hash-object", "-t", "tree", "/dev/null"]).trim();
512
- // --no-ext-diff / --no-textconv: a repo's configured diff drivers and converters are commands; we never run them.
513
- diff = git(["diff", "--no-color", "--no-ext-diff", "--no-textconv", base, ...only]);
514
- const untracked = git(["ls-files", "--others", "--exclude-standard", "-z", ...only]).split("\0").filter(Boolean);
515
- // Files the agent named that git ignores still get reviewed, even when other files changed.
516
- if (files.length)
517
- untracked.push(...git(["ls-files", "--others", "--ignored", "--exclude-standard", "-z", ...only]).split("\0").filter(Boolean));
518
- const prefix = git(["rev-parse", "--show-prefix"]).trim();
519
- diff += untracked.map((f) => newFileDiff(prefix + f, read(f))).join(""); // ls-files paths are from this folder; diff paths from the root
520
- }
521
- if (!diff.trim() && files.length)
522
- diff = files.map((f) => newFileDiff(f.replace(/^\.\//, ""), read(f))).join("");
523
- if (!diff.trim()) {
524
- return { error: repo
525
- ? `Nothing to review: git sees no change here. If your files are in a git-ignored folder, list them: ${REVIEW_USAGE}`
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.` };
527
- }
528
- if (Buffer.byteLength(diff) > MAX_DIFF_BYTES)
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.` };
530
- return { diff };
531
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openscaffold/cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Connect Claude Code to your OpenScaffold workspace: each session loads the right customer deployment.",
5
5
  "type": "module",
6
6
  "bin": {