From 60bb0475195f4f55751e7c234e287ea772a5cc1a Mon Sep 17 00:00:00 2001 From: rlaope Date: Mon, 15 Jun 2026 14:32:54 +0900 Subject: [PATCH] Add OSS alpha readiness commands Signed-off-by: rlaope --- .github/pull_request_template.md | 5 +- .github/workflows/ci.yml | 36 +---- CONTRIBUTING.md | 4 +- README.md | 16 ++- bin/loop.js | 28 +++- docs/roadmap.md | 13 ++ package.json | 4 +- scripts/verify-package.mjs | 64 +++++++++ src/core/demo.js | 61 ++++++++ src/core/doctor.js | 234 +++++++++++++++++++++++++++++++ src/index.js | 6 + test/adapter.test.js | 53 ++++++- test/docs.test.js | 17 +++ 13 files changed, 491 insertions(+), 50 deletions(-) create mode 100644 scripts/verify-package.mjs create mode 100644 src/core/demo.js create mode 100644 src/core/doctor.js diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 7fa049c..9e7c853 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,10 +4,7 @@ ## Verification -- [ ] `npm run lint` -- [ ] `npm run typecheck` -- [ ] `npm test` -- [ ] `npm pack --dry-run --json` +- [ ] `npm run verify` ## Loop Safety Checklist diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 899591e..6c8fc79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,37 +31,5 @@ jobs: - name: Install dependencies run: npm ci - - name: Lint - run: npm run lint - - - name: Typecheck - run: npm run typecheck - - - name: Test - run: npm test - - - name: Verify package contents - run: | - npm pack --dry-run --json | node -e ' - let input = ""; - process.stdin.on("data", (chunk) => input += chunk); - process.stdin.on("end", () => { - const pack = JSON.parse(input)[0]; - const files = new Set(pack.files.map((file) => file.path)); - const required = [ - "README.md", - "LICENSE", - "bin/loop.js", - "src/index.js", - "skills/loop/SKILL.md", - ".codex-plugin/plugin.json", - "assets/loop-engineering-poster.png", - "assets/loop-engineering-components.png" - ]; - const missing = required.filter((file) => !files.has(file)); - if (missing.length > 0) { - console.error(`Missing package files: ${missing.join(", ")}`); - process.exit(1); - } - }); - ' + - name: Verify + run: npm run verify diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2928c37..5d28560 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,9 +9,7 @@ small, reviewable, and safety-first. ```sh npm install -npm test -npm run lint -npm run typecheck +npm run verify ``` ## Pull Requests diff --git a/README.md b/README.md index 774d000..2aaba09 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ Install Loop once: ```sh npm install -g github:rlaope/loop loop --version +loop doctor +loop demo ``` Create or enter the project you want the coding agent to work on: @@ -115,6 +117,12 @@ If you want to try Loop without installing it first: npm exec --yes --package github:rlaope/loop -- loop "Build a darkwear luxury exhibition site" ``` +Before a first real run, `loop doctor` checks the local Node.js, git, package, +repo-boundary, and optional agent CLI readiness without writing `.loop`, +launching agents, starting the dashboard, or calling the network. `loop demo` +prints a small command catalog for common first-run, explicit-agent, and +dry-run follow-up workflows; it is also read-only. + If Loop says the git root does not match, you probably passed an explicit `--expected-root` that does not match the current project. Run Loop from the folder you want the agent to edit, or pass the intended root explicitly. @@ -201,9 +209,7 @@ To verify the package: ```sh npm install -npm test -npm run lint -npm run typecheck +npm run verify ``` After the package is published to npm, the shorter registry form will be: @@ -252,9 +258,7 @@ observable and bounded. ```sh npm install -npm test -npm run lint -npm run typecheck +npm run verify ``` Build the loop, stay the engineer. diff --git a/bin/loop.js b/bin/loop.js index 6fe3c70..409cff2 100755 --- a/bin/loop.js +++ b/bin/loop.js @@ -16,6 +16,7 @@ import { dashboardUrl, deleteRunState, deleteWikiNote, + doctorExitCode, getDashboardStatus, evaluatePolicyGate, listRunStates, @@ -27,9 +28,12 @@ import { readRunState, readWikiNote, recordBudgetActivity, + renderDemoGuide, + renderDoctorReport, renderWikiList, runLogPath, runAgentProcess, + runDoctorChecks, runLoopTui, scriptPathFromImportMetaUrl, serveWikiDashboard, @@ -44,7 +48,7 @@ import { } from "../src/index.js"; const rawArgs = process.argv.slice(2); -const knownCommands = new Set(["run", "wiki", "status", "runs", "logs"]); +const knownCommands = new Set(["run", "wiki", "status", "runs", "logs", "doctor", "demo"]); const command = knownCommands.has(rawArgs[0]) ? rawArgs[0] : undefined; const args = command ? rawArgs.slice(1) : rawArgs; const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); @@ -1017,6 +1021,28 @@ if (command === "wiki") { await handleWikiCommand(); } +if (command === "doctor") { + try { + const result = runDoctorChecks({ + cwd: process.cwd(), + packageJson, + expectedRoot: valueFor("--expected-root"), + expectedRemote: valueFor("--expected-remote") + }); + process.stdout.write(renderDoctorReport(result)); + process.exit(doctorExitCode(result)); + } catch (error) { + process.stderr.write(`${errorMessage(error)}\n\n`); + printHelp(process.stderr); + process.exit(1); + } +} + +if (command === "demo") { + process.stdout.write(renderDemoGuide()); + process.exit(0); +} + let objective; let stateDir; try { diff --git a/docs/roadmap.md b/docs/roadmap.md index 4c7d4fa..709d323 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -6,6 +6,16 @@ and a prototype `loop run` surface for Codex and Claude Code. Rich automation, native command adapters, external sync, and hosted knowledge storage remain future work. +## Public Alpha Readiness + +- Keep `loop doctor` read-only and local-only so first-time users can inspect + Node.js, git, repo-boundary, package, and optional agent CLI readiness before + launching a loop. +- Keep `loop demo` as a read-only command catalog. It must not write `.loop`, + launch agents, start dashboards, or make network calls. +- Keep `npm run verify` as the contributor and CI quality gate for lint, + typecheck, tests, and package-content validation. + ## Claude Code Adapter - Harden the `loop run --agent claudecode` prototype with parity tests against @@ -47,3 +57,6 @@ future work. - A write-capable loop must fail closed on unknown policy modes, missing approvals, unsafe worktree state, missing repo-boundary evidence, and exhausted budgets. +- The public alpha CLI must expose `loop doctor`, `loop demo`, `loop --help`, + `loop --version`, `loop status`, `loop runs`, `loop logs`, and `loop wiki` + with matching README examples and local tests. diff --git a/package.json b/package.json index 4b2d158..8ca38b9 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,9 @@ "scripts": { "lint": "node scripts/lint.mjs", "test": "node --test", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "verify:package": "npm pack --dry-run --json | node scripts/verify-package.mjs", + "verify": "npm run lint && npm run typecheck && npm test && npm run verify:package" }, "keywords": [ "ai", diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs new file mode 100644 index 0000000..460cf05 --- /dev/null +++ b/scripts/verify-package.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node + +const requiredFiles = [ + "README.md", + "LICENSE", + "bin/loop.js", + "src/index.js", + "skills/loop/SKILL.md", + ".codex-plugin/plugin.json", + "assets/loop-engineering-poster.png", + "assets/loop-engineering-components.png" +]; + +/** + * @param {string} message + */ +function fail(message) { + console.error(message); + process.exit(1); +} + +let input = ""; +for await (const chunk of process.stdin) { + input += String(chunk); +} + +let parsed; +try { + parsed = JSON.parse(input); +} catch (error) { + fail(`Could not parse npm pack output: ${error instanceof Error ? error.message : String(error)}`); +} + +if (!Array.isArray(parsed) || parsed.length === 0) { + fail("npm pack output did not include a package entry."); +} + +const pack = parsed[0]; +if (!pack || typeof pack !== "object" || !Array.isArray(pack.files)) { + fail("npm pack output did not include a files list."); +} + +/** + * @param {unknown} file + */ +function filePath(file) { + if (!file || typeof file !== "object" || !("path" in file)) { + fail("npm pack file entry did not include a path."); + } + const path = /** @type {{ path: unknown }} */ (file).path; + if (typeof path !== "string") { + fail("npm pack file entry did not include a path."); + } + return path; +} + +const files = new Set(pack.files.map(filePath)); +const missing = requiredFiles.filter((file) => !files.has(file)); + +if (missing.length > 0) { + fail(`Missing package files: ${missing.join(", ")}`); +} + +console.log(`Package content verified: ${requiredFiles.length} required files present.`); diff --git a/src/core/demo.js b/src/core/demo.js new file mode 100644 index 0000000..f8a8ca0 --- /dev/null +++ b/src/core/demo.js @@ -0,0 +1,61 @@ +export const demoWorkflows = [ + { + title: "Darkwear luxury exhibition site", + description: "Start a fresh local project, let Loop create a git boundary, and watch the agent run.", + commands: [ + "mkdir darkwear-exhibit", + "cd darkwear-exhibit", + "loop doctor", + "loop \"Build a darkwear luxury exhibition site MVP\"", + "loop status", + "loop logs --follow", + "loop wiki" + ] + }, + { + title: "Explicit Codex run", + description: "Skip the agent picker when you already know which coding agent should receive the objective.", + commands: [ + "loop run --agent codex \"Build a quiet SaaS metrics dashboard MVP\"", + "loop runs", + "loop wiki list" + ] + }, + { + title: "Safe planning and follow-up", + description: "Record a dry-run plan first, then continue with a scoped write-capable loop when the goal is clear.", + commands: [ + "loop --dry-run --objective \"Audit failing tests and propose the smallest safe fix plan\"", + "loop wiki read ", + "loop run --agent codex --parent-run \"Fix the failing test with the smallest safe change\"" + ] + } +]; + +export function renderDemoGuide() { + const lines = [ + "Loop Demo", + "", + "This command prints examples only. It does not write .loop, launch agents, start services, or call the network.", + "" + ]; + + for (const workflow of demoWorkflows) { + lines.push(`## ${workflow.title}`); + lines.push(workflow.description); + lines.push(""); + lines.push("```sh"); + lines.push(...workflow.commands); + lines.push("```"); + lines.push(""); + } + + lines.push("Most users start with:"); + lines.push(""); + lines.push("```sh"); + lines.push("loop \"Build the thing you want\""); + lines.push("```"); + lines.push(""); + + return `${lines.join("\n")}\n`; +} diff --git a/src/core/doctor.js b/src/core/doctor.js new file mode 100644 index 0000000..9234939 --- /dev/null +++ b/src/core/doctor.js @@ -0,0 +1,234 @@ +import { spawnSync } from "node:child_process"; + +import { checkRepoBoundary } from "./preflight.js"; + +export const MIN_NODE_MAJOR = 20; + +/** + * @typedef {"pass" | "warn" | "fail"} DoctorStatus + * + * @typedef {object} DoctorCheck + * @property {string} name + * @property {DoctorStatus} status + * @property {string} summary + * @property {string} [detail] + * + * @typedef {object} DoctorResult + * @property {boolean} ok + * @property {string} cwd + * @property {string} packageName + * @property {string} packageVersion + * @property {DoctorCheck[]} checks + */ + +/** + * @param {unknown} value + * @returns {value is Record} + */ +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * @param {unknown} value + * @returns {string} + */ +function stringOrUnknown(value) { + return typeof value === "string" && value.trim() ? value : "unknown"; +} + +/** + * @param {string} version + */ +function majorVersion(version) { + const major = Number(version.split(".")[0]); + return Number.isInteger(major) ? major : 0; +} + +/** + * @param {string} output + */ +function firstLine(output) { + return output.trim().split(/\r?\n/)[0] ?? ""; +} + +/** + * @param {string} command + * @param {string[]} args + * @param {typeof spawnSync} spawnSyncImpl + */ +function commandCheck(command, args, spawnSyncImpl) { + const result = spawnSyncImpl(command, args, { + encoding: "utf8", + timeout: 1500 + }); + if (result.error) { + return { + ok: false, + summary: result.error.message + }; + } + if (result.status !== 0) { + return { + ok: false, + summary: firstLine(result.stderr || result.stdout) || `${command} exited with status ${result.status}` + }; + } + return { + ok: true, + summary: firstLine(result.stdout || result.stderr) || `${command} available` + }; +} + +/** + * @param {object} [options] + * @param {string} [options.cwd] + * @param {Record} [options.packageJson] + * @param {string} [options.expectedRoot] + * @param {string} [options.expectedRemote] + * @param {typeof spawnSync} [options.spawnSyncImpl] + * @returns {DoctorResult} + */ +export function runDoctorChecks({ + cwd = process.cwd(), + packageJson = {}, + expectedRoot, + expectedRemote, + spawnSyncImpl = spawnSync +} = {}) { + /** @type {DoctorCheck[]} */ + const checks = []; + const packageName = stringOrUnknown(packageJson.name); + const packageVersion = stringOrUnknown(packageJson.version); + + const nodeVersion = process.versions.node; + const nodeMajor = majorVersion(nodeVersion); + checks.push({ + name: "Node.js runtime", + status: nodeMajor >= MIN_NODE_MAJOR ? "pass" : "fail", + summary: `v${nodeVersion}`, + detail: `Loop requires Node.js >= ${MIN_NODE_MAJOR}.` + }); + + const git = commandCheck("git", ["--version"], spawnSyncImpl); + checks.push({ + name: "git CLI", + status: git.ok ? "pass" : "fail", + summary: git.summary, + detail: "Loop uses git to bound write-capable agent runs." + }); + + const npm = commandCheck("npm", ["--version"], spawnSyncImpl); + checks.push({ + name: "npm CLI", + status: npm.ok ? "pass" : "warn", + summary: npm.ok ? `npm ${npm.summary}` : npm.summary, + detail: "npm is only required for install, local development, and package verification." + }); + + const scripts = isRecord(packageJson.scripts) ? packageJson.scripts : {}; + const hasVerify = typeof scripts.verify === "string" && scripts.verify.trim().length > 0; + checks.push({ + name: "package metadata", + status: packageName !== "unknown" && packageVersion !== "unknown" ? "pass" : "warn", + summary: `${packageName}@${packageVersion}`, + detail: hasVerify ? "npm run verify is available." : "npm run verify is not defined in this package metadata." + }); + + if (!hasVerify) { + checks.push({ + name: "verify script", + status: "warn", + summary: "npm run verify is not available", + detail: "Installed users can ignore this; contributors should run verification from the source repository." + }); + } else { + checks.push({ + name: "verify script", + status: "pass", + summary: "npm run verify", + detail: "Runs lint, typecheck, tests, and package-content validation." + }); + } + + const boundary = checkRepoBoundary({ cwd, expectedRoot, expectedRemote }); + const explicitBoundary = Boolean(expectedRoot || expectedRemote); + if (boundary.ok) { + checks.push({ + name: "repo boundary", + status: "pass", + summary: boundary.root ? `git root ${boundary.root}` : "git root detected", + detail: boundary.remote ? `origin ${boundary.remote}` : "No origin remote is required for local first runs." + }); + } else { + checks.push({ + name: "repo boundary", + status: explicitBoundary ? "fail" : "warn", + summary: boundary.errors.join(" "), + detail: explicitBoundary + ? "The explicit --expected-root or --expected-remote check failed." + : "loop run can initialize a local git repository before launching a write-capable agent." + }); + } + + const codex = commandCheck("codex", ["--version"], spawnSyncImpl); + checks.push({ + name: "Codex CLI", + status: codex.ok ? "pass" : "warn", + summary: codex.ok ? codex.summary : "codex is not available on PATH", + detail: "Needed only when running loop with --agent codex." + }); + + const claude = commandCheck("claude", ["--version"], spawnSyncImpl); + checks.push({ + name: "Claude Code CLI", + status: claude.ok ? "pass" : "warn", + summary: claude.ok ? claude.summary : "claude is not available on PATH", + detail: "Needed only when running loop with --agent claudecode." + }); + + return { + ok: checks.every((check) => check.status !== "fail"), + cwd, + packageName, + packageVersion, + checks + }; +} + +/** + * @param {DoctorResult} result + */ +export function doctorExitCode(result) { + return result.ok ? 0 : 1; +} + +/** + * @param {DoctorResult} result + */ +export function renderDoctorReport(result) { + const lines = [ + "Loop Doctor", + "", + `Status: ${result.ok ? "ready" : "needs attention"}`, + `Package: ${result.packageName}@${result.packageVersion}`, + `Working directory: ${result.cwd}`, + "" + ]; + + for (const check of result.checks) { + lines.push(`[${check.status}] ${check.name}: ${check.summary}`); + if (check.detail) { + lines.push(` ${check.detail}`); + } + } + + lines.push(""); + lines.push("Next:"); + lines.push("- Run `loop demo` to see first-run command examples."); + lines.push("- Run `loop \"your objective\"` from the project folder when ready."); + lines.push("- Run `npm run verify` from the Loop source repository before contributing."); + lines.push(""); + + return `${lines.join("\n")}\n`; +} diff --git a/src/index.js b/src/index.js index 8da68d9..b505eda 100644 --- a/src/index.js +++ b/src/index.js @@ -20,6 +20,8 @@ export { } from "./core/actions.js"; export { evaluateBudget, recordBudgetActivity } from "./core/budget.js"; export { scriptPathFromImportMetaUrl, startDetachedWikiDashboard } from "./core/dashboard-process.js"; +export { demoWorkflows, renderDemoGuide } from "./core/demo.js"; +export { MIN_NODE_MAJOR, doctorExitCode, renderDoctorReport, runDoctorChecks } from "./core/doctor.js"; export { hasActiveApproval, requireWriteApproval } from "./core/approval.js"; export { checkIsolationDecision, checkRepoBoundary } from "./core/preflight.js"; export { evaluatePolicyGate } from "./core/policy.js"; @@ -101,6 +103,8 @@ export function printHelp(stream) { stream.write(` loop status\n`); stream.write(` loop runs [delete ]\n`); stream.write(` loop logs [run-id] [--follow]\n`); + stream.write(` loop doctor [--expected-root ] [--expected-remote ]\n`); + stream.write(` loop demo\n`); stream.write(` loop wiki [list|read |open |delete |serve]\n`); stream.write(` loop wiki add --kind plan --title "" --body "<note>" [--run <run-id>]\n`); stream.write(` loop --dry-run --objective "<objective>" [--state-dir .loop]\n`); @@ -135,6 +139,8 @@ export function printHelp(stream) { stream.write(` --no-interview Skip ambiguity interview for automation or tests.\n`); stream.write(`\n`); stream.write(`No-argument loop opens the local Agent Console TUI in an interactive terminal.\n`); + stream.write(`Doctor mode checks local readiness without writing state or launching agents.\n`); + stream.write(`Demo mode prints example workflows without writing state, starting services, or launching agents.\n`); stream.write(`Dry-run mode writes durable Loop state and local wiki artifacts only.\n`); stream.write(`Run mode records state, creates a local git boundary when needed, asks clarifying questions, then launches the selected agent.\n`); stream.write(`Wiki mode reads local .loop/wiki notes and opens a localhost dashboard with graph, note, log, follow-up, and Codex-open controls.\n`); diff --git a/test/adapter.test.js b/test/adapter.test.js index e933209..af3746c 100644 --- a/test/adapter.test.js +++ b/test/adapter.test.js @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { execFileSync, spawn, spawnSync } from "node:child_process"; -import { realpathSync } from "node:fs"; +import { existsSync, realpathSync } from "node:fs"; import { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { createServer as createNetServer } from "node:net"; import { join, resolve } from "node:path"; @@ -237,6 +237,8 @@ test("manifest capability matches explicit CLI surfaces", async () => { assert.match(help, /--dry-run/); assert.match(help, /loop run --agent codex/); assert.match(help, /loop run --agent claudecode/); + assert.match(help, /loop doctor/); + assert.match(help, /loop demo/); assert.match(help, /asks clarifying questions/); }); @@ -254,6 +256,55 @@ test("CLI prints help and package version", async () => { assert.equal(shortVersion.trim(), packageJson.version); }); +test("CLI doctor reports local readiness without requiring optional agents", async () => { + const result = spawnSync( + process.execPath, + ["bin/loop.js", "doctor", "--expected-root", process.cwd()], + { encoding: "utf8" } + ); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /Loop Doctor/); + assert.match(result.stdout, /Status: ready/); + assert.match(result.stdout, /\[pass\] Node\.js runtime/); + assert.match(result.stdout, /\[pass\] git CLI/); + assert.match(result.stdout, /\[pass\] repo boundary/); + assert.match(result.stdout, /Codex CLI/); + assert.match(result.stdout, /Claude Code CLI/); + assert.match(result.stdout, /npm run verify/); +}); + +test("CLI doctor fails explicit repo-boundary mismatches", async () => { + const wrongRoot = await mkdtemp(join(tmpdir(), "loop-wrong-root-")); + const result = spawnSync( + process.execPath, + ["bin/loop.js", "doctor", "--expected-root", wrongRoot], + { encoding: "utf8" } + ); + + assert.equal(result.status, 1); + assert.match(result.stdout, /Loop Doctor/); + assert.match(result.stdout, /\[fail\] repo boundary/); + assert.match(result.stdout, /git root mismatch/); +}); + +test("CLI demo prints workflows without writing local state", async () => { + const cwd = await mkdtemp(join(tmpdir(), "loop-demo-")); + const result = spawnSync( + process.execPath, + [resolve("bin/loop.js"), "demo"], + { cwd, encoding: "utf8" } + ); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /Loop Demo/); + assert.match(result.stdout, /darkwear luxury exhibition/i); + assert.match(result.stdout, /loop doctor/); + assert.match(result.stdout, /loop wiki/); + assert.match(result.stdout, /loop --dry-run/); + assert.equal(existsSync(join(cwd, ".loop")), false); +}); + test("CLI accepts equals-style option values", async () => { const stateDir = await mkdtemp(join(tmpdir(), "loop-cli-equals-state-")); const output = execFileSync( diff --git a/test/docs.test.js b/test/docs.test.js index cd14418..138d59d 100644 --- a/test/docs.test.js +++ b/test/docs.test.js @@ -2,6 +2,23 @@ import test from "node:test"; import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; +test("package exposes a single verify gate for local and CI checks", async () => { + const packageJson = JSON.parse(await readFile("package.json", "utf8")); + const ci = await readFile(".github/workflows/ci.yml", "utf8"); + const readme = await readFile("README.md", "utf8"); + + assert.equal(typeof packageJson.scripts.verify, "string"); + assert.equal(typeof packageJson.scripts["verify:package"], "string"); + assert.match(packageJson.scripts.verify, /npm run lint/); + assert.match(packageJson.scripts.verify, /npm run typecheck/); + assert.match(packageJson.scripts.verify, /npm test/); + assert.match(packageJson.scripts.verify, /npm run verify:package/); + assert.match(ci, /npm run verify/); + assert.match(readme, /loop doctor/); + assert.match(readme, /loop demo/); + assert.match(readme, /npm run verify/); +}); + test("README links the core documentation set", async () => { const readme = await readFile("README.md", "utf8");