diff --git a/README.md b/README.md index 819c6a9f..5a3ee2c8 100644 --- a/README.md +++ b/README.md @@ -240,7 +240,7 @@ be checked, maintained, and restored across bounded runs: | `prose compile` | Run the bundled OpenProse compiler program, emit `/dist/manifest.next.json`, and validate it before success | | `prose serve` | Load active IR and run local cron and HTTP trigger adapters | | `prose run` | Execute one bounded OpenProse VM activation | -| `prose write` | Author a validated OpenProse program package from rough English/pseudo-Prose, asking targeted shape/root questions in interactive hosts and returning `unresolved-intent` only for non-interactive missing decisions | +| `prose write` | Author a validated OpenProse program package from rough English/pseudo-Prose; `--out --apply` writes the package after lint passes, and host adapters that support `--run` expand it to write/apply followed by ordinary `prose run ` | | `prose status` | Inspect active IR, diagnostics, trigger plan, recent runs, and responsibility status/pressure | The compiled Responsibility Runtime manifest preserves responsibilities as diff --git a/packages/std/ops/README.md b/packages/std/ops/README.md index 181adc07..e0aa9816 100644 --- a/packages/std/ops/README.md +++ b/packages/std/ops/README.md @@ -19,7 +19,7 @@ come back as `unresolved-intent` instead of terminal prompts. | `preflight.prose.md` | `prose preflight ` | Check that dependencies are installed and environment variables are set | | `wire.prose.md` | `prose run std/ops/wire` | Run Forme wiring to produce an execution manifest | | `status.prose.md` | `prose status` | Show recent runs with system name, duration, and pass/fail status | -| `prose-author.prose.md` | `prose write [request...]` | Interactive-by-default authoring of a validated OpenProse package from rough English or pseudo-Prose | +| `prose-author.prose.md` | `prose write [--out ] [--apply] [--run] [request...]` | Interactive-by-default authoring of a validated OpenProse package from rough English or pseudo-Prose; `--out --apply` may write it, and host adapters that support `--run` expand it to write/apply followed by ordinary `prose run ` | | `diagnose.prose.md` | `prose run std/ops/diagnose` | Diagnose why a run failed -- root cause analysis with fix recommendations | | `profiler.prose.md` | `prose run std/ops/profiler` | Profile a run for cost, tokens, and time using actual API session data | diff --git a/packages/std/ops/prose-author.prose.md b/packages/std/ops/prose-author.prose.md index 8d47ac4b..957b1286 100644 --- a/packages/std/ops/prose-author.prose.md +++ b/packages/std/ops/prose-author.prose.md @@ -41,13 +41,25 @@ decisions instead of guessing. ### Requires - `output_mode`: output mode requested by the caller. `prose write` passes - `source-package-only`; in that mode the system must return source content and - apply notes only. + `source-package-only` by default; shell `--out --apply` and `--out --run` + pass `source-package-and-files`, where the system must write the generated + source after validation and still return a reviewable summary. - `apply`: whether this run is allowed to write generated source into the - caller's repository. `prose write` passes `false`. + caller's repository. `prose write` passes `false` by default; shell + `--apply` and `--run` pass `true`. +- `target_path`: optional root-relative file or folder path selected by the + caller. When present with `apply: true`, generated files must stay under this + path. Folder targets use `index.prose.md` as the root file; file targets must + end in `.prose.md`. +- `post_apply_action`: optional non-operational host follow-up marker. `none` + means no known host follow-up; `host-will-run-root` means the invoking host + adapter intends to run the generated root as a separate top-level + `prose run` after successful apply. This marker does not authorize + `prose-author` to run, simulate, or publish receipts for the generated root. - `run_state`: preferred run-state mode. `prose write` passes `in-context` so package-only authoring avoids creating run artifacts in the caller's - workspace when the host can honor that mode. + workspace when the host can honor that mode. Apply-enabled shell runs may + pass `filesystem`. - `terminal_summary`: whether a concise final terminal status block is required. `prose write` passes `required`. - `interactive`: whether the host may ask targeted follow-up questions before @@ -106,6 +118,9 @@ decisions instead of guessing. - Landscape inspection is read-only: it may list and read nearby source, configuration, and OpenProse layout markers, but it must not create, modify, delete, format, install, compile, or migrate files. +- Apply-enabled source writing may happen only after planning, linting, and + repair pass. It must be limited to the generated files under `target_path` + and must not modify unrelated repository files. - When `output_mode` is `source-package-only` or `apply` is `false`, generated files are returned as source package content and optional apply notes only. The authoring run must not write generated files to the caller's repository, @@ -148,6 +163,14 @@ decisions instead of guessing. issue trackers, status pages, deploy systems, feature flags, and similar integrations are declarations or future runtime tools in the returned package, never actions performed by `prose-author`. +- Authoring must not create optional memory notes, mycelium entries, + recognition memories, issue drafts, run evidence, or other non-source + artifacts while applying a generated package. Successful authoring depends + only on the generated source package, lint/repair checks, and explicitly + requested writes under `target_path`. +- `post_apply_action` is advisory only. `prose-author` must not execute, + simulate, or publish run receipts for the generated root; follow-up execution + belongs to the host adapter and ordinary `prose run` semantics. ### Tools @@ -177,7 +200,9 @@ decisions instead of guessing. `co/...`, or `github.com/owner/repo/path`. Do not invent bare `owner/repo` registry references. - Do not write directly to the caller's repository. Return a package and patch - notes; a separate file-writing step can apply it after human review. + notes unless `apply: true` and `target_path` are explicitly present. In + apply-enabled mode, write only the generated source package under + `target_path` after lint passes. - Treat any request phrase such as "under src/foo" or "add it to this repo" as a desired package path when `apply: false`, not as permission to create files. - Use CLI-facing language for `prose write`: describe the result as an @@ -194,6 +219,8 @@ let authoring_intent = call intent-normalizer request: request output_mode: output_mode apply: apply + target_path: target_path + post_apply_action: post_apply_action run_state: run_state terminal_summary: terminal_summary interactive: interactive @@ -230,6 +257,9 @@ let source_plan = call source-planner landscape: landscape shape_decision: triage_result.shape_decision guidance_report: guidance_report + apply: apply + target_path: target_path + post_apply_action: post_apply_action let draft_source_package = call source-author source_plan: source_plan @@ -239,6 +269,7 @@ let draft_source_package = call source-author guidance_report: guidance_report output_mode: output_mode apply: apply + target_path: target_path let lint_report = call source-linter draft_source_package: draft_source_package @@ -270,6 +301,9 @@ let assembled = call package-assembler landscape: landscape shape_decision: triage_result.shape_decision guidance_report: guidance_report + apply: apply + target_path: target_path + post_apply_action: post_apply_action return { source_package: assembled.source_package, @@ -291,6 +325,10 @@ Normalize the caller's rough request into an explicit authoring intent. brief from the caller - `output_mode`: caller output mode, usually `source-package-only` - `apply`: whether this authoring run may write files, usually `false` +- `target_path`: optional root-relative destination for generated source +- `post_apply_action`: non-operational host follow-up marker, usually `none`; + `host-will-run-root` means the host adapter intends to invoke ordinary + `prose run` after successful apply - `run_state`: preferred run-state mode, usually `in-context` - `terminal_summary`: whether the final status block is required - `interactive`: whether targeted follow-up questions are allowed before source @@ -309,6 +347,9 @@ Normalize the caller's rough request into an explicit authoring intent. persistence, memory, and safety boundaries implied by the request - output_mode: caller output mode, preserved for downstream authoring - apply: caller apply flag, preserved for downstream authoring + - target_path: caller destination path, preserved for downstream authoring + - post_apply_action: caller follow-up marker, preserved for downstream + authoring but never executed by `prose-author` - run_state: caller run-state preference, preserved for downstream authoring - terminal_summary: caller terminal-summary requirement, preserved for @@ -458,6 +499,10 @@ planning begins. or a graph that should be edited service-by-service later. - Choose a native OpenProse root only when the landscape already looks native or the user is clearly creating an OpenProse repository. +- When `target_path` is present, treat it as the binding destination for the + package. If it ends in `.prose.md`, choose single-file output at that file. + Otherwise choose a folder-shaped package rooted at `target_path` with + `index.prose.md` as the runnable root unless the request makes that invalid. - Choose an attached sidecar root for ordinary application repositories where generated Prose should live alongside, not inside, the app source. - Choose user-global only when the user asks for a personal agent, cross-repo @@ -573,6 +618,11 @@ Plan the generated file tree and contracts before drafting source. `interactive-triage` - `guidance_report`: baseline and shape-specific guidance from `guidance-loader` +- `apply`: whether this run may write generated files +- `target_path`: optional root-relative destination for generated files +- `post_apply_action`: non-operational host follow-up marker; + `host-will-run-root` means the host adapter intends to run the generated root + after successful apply ### Ensures @@ -647,6 +697,8 @@ Draft the planned source package. `guidance-loader` - `output_mode`: caller output mode, usually `source-package-only` - `apply`: whether this authoring run may write files, usually `false` +- `target_path`: optional root-relative destination for apply-enabled source + writing ### Ensures @@ -669,6 +721,9 @@ Draft the planned source package. - In `source-package-only` or `apply: false` mode, include the chosen paths and full file contents in `draft_source_package`; do not write those paths to the filesystem. +- In `source-package-and-files` with `apply: true`, still draft the package as + explicit file paths plus contents. The later `package-assembler` performs the + filesystem write only after lint passes. - Use current Contract Markdown headings exactly. Unknown `###` sections may be preserved only as documentation, never as hidden runtime behavior. - Make every `### Ensures` item named, evaluable, and specific enough for a @@ -800,6 +855,11 @@ Repair blocking lint findings without changing the caller's intent. `interactive-triage` - `guidance_report`: baseline and shape-specific guidance from `guidance-loader` +- `apply`: whether this authoring run may write files +- `target_path`: optional root-relative destination for generated source +- `post_apply_action`: non-operational host follow-up marker; + `host-will-run-root` means the host adapter intends to run the generated root + after successful apply ### Ensures @@ -862,10 +922,11 @@ Publish the validated source package and concise next-step notes. - `source_package`: `returned` - `shape`: chosen package shape - `root`: selected root file or root folder - - `apply`: `false` for `prose write` - - `files_written`: `none` when no generated source files were applied + - `apply`: whether generated source files were applied + - `files_written`: `none` when no generated source files were applied, or + a compact list/count of files written under `target_path` - `lint`: `pass` - - `next`: recommended command or manual apply step + - `next`: recommended command, host follow-up note, or a manual apply step ### Errors @@ -876,8 +937,14 @@ Publish the validated source package and concise next-step notes. - Do not publish a package when `lint_report` has blocking findings. - Keep final output reviewable: include paths first, then file contents in the same order a human would open them. +- When `apply: true`, write the generated source files only after + `lint_report.status` is `pass`, only under `target_path`, and report the + concrete files in `files_written`. - Do not claim files were written to the caller's repository unless this run actually wrote them through an explicitly requested file-writing step. +- Do not claim a follow-up run happened during authoring. When + `post_apply_action` is `host-will-run-root`, report only that the host adapter + is expected to invoke ordinary `prose run` after this authoring run succeeds. - End every successful package-only response with `final_status_summary` so a terminal user can distinguish success from validation failure at a glance. - When `terminal_summary` is `required`, treat the final status block as part of diff --git a/skills/open-prose/SKILL.md b/skills/open-prose/SKILL.md index 29da8615..b2aa751a 100644 --- a/skills/open-prose/SKILL.md +++ b/skills/open-prose/SKILL.md @@ -128,7 +128,7 @@ executing the system. The shell executable is the agent runner, e.g. | `prose run runtime/judge-responsibility.prose.md` | Resolve from the OpenProse skill root; judge one responsibility from activation context | | `prose run //[/path]` | Resolve installed dependency service or system, detect format, then route as above | | `prose run std/...` / `co/...` | Expand OpenProse package shorthand, resolve installed dependency service or system, then route as above | -| `prose write [request...]` | Interactive-by-default authoring: load `contract-markdown.md`, `guidance/tenets.md`, and `guidance/authoring.md`; run `std/ops/prose-author`; scan the local landscape read-only, decide shape/root/path, load shape-specific guidance, ask a small number of targeted `ask_user` questions when the host can support them, then return a fully validated source package. If the caller or host marks the run non-interactive, return `unresolved-intent` with the missing decisions instead of guessing. Do not apply files unless the caller explicitly asks for that follow-up | +| `prose write [--out ] [--apply] [--run] [request...]` | Interactive-by-default authoring: load `contract-markdown.md`, `guidance/tenets.md`, and `guidance/authoring.md`; run `std/ops/prose-author`; scan the local landscape read-only, decide shape/root/path, load shape-specific guidance, ask a small number of targeted `ask_user` questions when the host can support them, then return a fully validated source package. If the caller or host marks the run non-interactive, return `unresolved-intent` with the missing decisions instead of guessing. Apply generated files only when the caller explicitly passes apply permission, normally through `--out --apply`; `--run` is a host-adapter macro that implies apply permission and expands to an ordinary `prose run ` only after authoring succeeds | | `prose lint ` | Validate Contract Markdown structure, headers, frontmatter, contracts, shapes, and wiring | | `prose preflight ` | Check dependencies and `### Environment` declarations without executing | | `prose test ` | Load `contract-markdown.md`, `state/README.md` plus the selected backend, and `prose.md`; run `kind: test` file(s) | @@ -142,6 +142,13 @@ executing the system. The shell executable is the agent runner, e.g. | `prose examples` | List or run bundled examples from `examples/` | | Other | Interpret intent and load the smallest relevant spec set | +`prose write --run` is a host-adapter macro. A host that supports it must run +it as two top-level operations: invoke `prose-author` with apply permission, +then invoke ordinary `prose run` semantics for the generated root only if +authoring succeeds. A host that cannot perform this macro must reject +`prose write --run` before authoring and must not pass the macro into +`prose-author`. + There is one skill: `open-prose`. Do not look for separate `prose-run`, `prose-lint`, `prose-compile`, or `prose-boot` skills. @@ -351,6 +358,12 @@ When writing a `### Execution` block or pattern delegation, also load You do not owe OpenProse anything. But if a run went well and you want to help the next agent succeed, here are the ways — and the gates each one needs. +Forwarded CLI commands, non-interactive runs, and `prose write` authoring runs +must not perform giving-back actions, save recognition memories, create +mycelium notes, file issues, or otherwise write optional non-source artifacts +unless the outer user explicitly asked for that action in the current run. +Authoring success must not depend on optional memory or note writes. + You are the user's proxy. Actions attach to identities, and the identity determines what you can do on your own vs. what needs approval: | Identity attached | Bucket | Approval gate | diff --git a/skills/open-prose/help.md b/skills/open-prose/help.md index 6623a654..34f484bd 100644 --- a/skills/open-prose/help.md +++ b/skills/open-prose/help.md @@ -48,7 +48,7 @@ Options: | `prose compile [path] [--out ]` | Compile source into `/dist/manifest.next.json` | | `prose serve` | Serve the active IR as local cron and HTTP trigger adapters | | `prose run ` | Run a service or system | -| `prose write [request...]` | Interactive-by-default authoring from rough English/pseudo-Prose into a validated source package; non-interactive runs return `unresolved-intent` when more detail is required | +| `prose write [--out ] [--apply] [--run] [request...]` | Interactive-by-default authoring from rough English/pseudo-Prose into a validated source package; `--out --apply` writes it after lint passes, and host adapters that support `--run` expand it to write/apply followed by ordinary `prose run ` | | `prose lint ` | Validate structure, schema, and contracts | | `prose preflight ` | Check dependencies and environment | | `prose test ` | Run tests with assertions | diff --git a/skills/open-prose/prose.md b/skills/open-prose/prose.md index 4968998e..1bc5b304 100644 --- a/skills/open-prose/prose.md +++ b/skills/open-prose/prose.md @@ -45,7 +45,7 @@ codex exec "prose run system.prose.md" | `prose run /` | Reserved for the OpenProse registry (future home at `p.prose.md`) | | `prose run ...@` | Pin to a SHA or tag; require that version in `/deps/` | | `prose run ... --offline` | Require disk-only resolution; error if not in `/deps/` | -| `prose write [request...]` | Interactive-by-default authoring through `std/ops/prose-author`, asking targeted shape/root questions when supported and returning a validated source package | +| `prose write [--out ] [--apply] [--run] [request...]` | Interactive-by-default authoring through `std/ops/prose-author`, asking targeted shape/root questions when supported and returning a validated source package; `--out --apply` writes it when permitted, and host adapters that support `--run` expand it to write/apply followed by ordinary `prose run ` | | `prose lint ` | Validate structure, schema, shapes, and contracts | | `prose preflight ` | Check dependencies, declared tools, and environment variables | | `prose test ` | Run test(s) and report results | diff --git a/tools/cli/README.md b/tools/cli/README.md index 04e2e58a..728e05ea 100644 --- a/tools/cli/README.md +++ b/tools/cli/README.md @@ -76,6 +76,7 @@ Examples: prose run std/evals/inspector prose run std/evals/prose-contributor -- subjects: 20260406-201439-1a3369 prose write "draft a release readiness responsibility" +prose write --out src/release-readiness --run "draft a release readiness responsibility" cat brief.txt | prose write --harness codex-sdk prose run std/evals/inspector --harness codex-sdk prose run co/systems/company-repo-checker --harness claude-sdk @@ -160,6 +161,20 @@ missing blocking decisions are reported as `unresolved-intent` instead of a mid-run terminal prompt. CLI flags that would imply mid-run interaction are rejected; put literal text after `--` or pipe it on stdin. +By default, `prose write` returns a source package for review and does not +modify the repository. Use `--out ` with `--apply` to let the authoring +run write the generated package after lint passes. This CLI is a host adapter +that supports the `--run` macro: `--out --run` writes the package and +then starts a separate ordinary `prose run` for the generated root file only +after authoring succeeds. Directory targets run `/index.prose.md`; file +targets must end in `.prose.md`. + +`--out` is a validated, root-relative target contract passed to the authoring +harness. The CLI rejects absolute paths, parent traversal, and file targets +that do not end in `.prose.md`; the actual write is still performed by the +selected harness under the `prose-author` contract, not by a separate CLI +filesystem sandbox. + ```bash prose compile prose compile src/responsibilities --out dist @@ -167,6 +182,8 @@ cp dist/manifest.next.json dist/manifest.active.json prose serve prose run src/systems/reviewer.prose.md prose write "draft a release readiness responsibility" +prose write --out src/reviewer --apply "draft a reviewer system" +prose write --out src/reviewer --run "draft a reviewer system" prose write < brief.txt prose run co/systems/company-repo-checker --harness claude-sdk prose upgrade diff --git a/tools/cli/src/commands/base.ts b/tools/cli/src/commands/base.ts index d805dfd6..a4144f90 100644 --- a/tools/cli/src/commands/base.ts +++ b/tools/cli/src/commands/base.ts @@ -1,9 +1,9 @@ import { Command } from "@oclif/core"; import type { CommandName } from "../prose/index.js"; -import { canonicalPrompt, CommandModelError, usageFor } from "../prose/index.js"; +import { canonicalPrompt, CommandModelError, parseWriteCommand, resolveWriteRunTarget, usageFor } from "../prose/index.js"; import { recordForwardedFulfillmentArtifact } from "../prose/fulfillment-artifact.js"; import { createHarness, type HarnessName } from "../harnesses/index.js"; -import type { Harness, WritableStreamLike } from "../harnesses/types.js"; +import type { Harness, HarnessRunOptions, WritableStreamLike } from "../harnesses/types.js"; import { ensureOpenProseSkill, loadOpenProseSkillBootstrap, type OpenProseSkillBootstrap } from "../skills/open-prose.js"; export interface SkillPreflightOptions { @@ -95,6 +95,8 @@ export async function runForwardedProseCommand(options: ForwardRunOptions): Prom const { harness, args } = splitHarnessArgs(options.argv, options.env, options.command); const promptArgs = await hydrateForwardedArgs(options.command, args, options.stdin); const prompt = canonicalPrompt(options.command, promptArgs); + const writeRequiresFilesystem = options.command === "write" ? parseWriteCommand(promptArgs).apply : false; + const writeRunTarget = options.command === "write" ? resolveWriteRunTarget(promptArgs) : undefined; if (shouldRunSkillPreflight(options)) { await runSkillPreflight(harness, options); } @@ -103,19 +105,11 @@ export async function runForwardedProseCommand(options: ForwardRunOptions): Prom : undefined; const selectedHarness = (options.harnessFactory ?? createHarness)(harness); - const exitCode = await selectedHarness.run(prompt, { - ...(skillBootstrap === undefined - ? {} - : { - additionalDirectories: skillBootstrap.additionalDirectories, - systemPromptAppend: skillBootstrap.systemPromptAppend, - }), - cwd: options.cwd, - env: { ...options.env }, - stdout: options.stdout, - stderr: options.stderr, - ...(options.signal === undefined ? {} : { signal: options.signal }), + const harnessRunOptions = buildHarnessRunOptions(options, skillBootstrap, { + harness, + requiresFilesystemWrites: writeRequiresFilesystem, }); + const exitCode = await selectedHarness.run(prompt, harnessRunOptions); await recordForwardedFulfillmentArtifact({ command: options.command, argv: args, @@ -125,7 +119,68 @@ export async function runForwardedProseCommand(options: ForwardRunOptions): Prom harness, prompt, }); - return exitCode; + + if (exitCode !== 0 || writeRunTarget === undefined) { + return exitCode; + } + if (options.signal?.aborted) { + return 143; + } + + const runPrompt = canonicalPrompt("run", [writeRunTarget]); + const runExitCode = await selectedHarness.run(runPrompt, harnessRunOptions); + await recordForwardedFulfillmentArtifact({ + command: "run", + argv: [writeRunTarget], + cwd: options.cwd, + env: options.env, + exitCode: runExitCode, + harness, + prompt: runPrompt, + }); + return runExitCode; +} + +function buildHarnessRunOptions( + options: ForwardRunOptions, + skillBootstrap: OpenProseSkillBootstrap | undefined, + behavior: { + harness: string; + requiresFilesystemWrites: boolean; + }, +): HarnessRunOptions { + const env = writeEnabledEnv(options.env, behavior); + return { + ...(skillBootstrap === undefined + ? {} + : { + additionalDirectories: skillBootstrap.additionalDirectories, + systemPromptAppend: skillBootstrap.systemPromptAppend, + }), + cwd: options.cwd, + env: { ...env }, + stdout: options.stdout, + stderr: options.stderr, + ...(options.signal === undefined ? {} : { signal: options.signal }), + }; +} + +function writeEnabledEnv( + env: Readonly>, + behavior: { harness: string; requiresFilesystemWrites: boolean }, +): Readonly> { + if ( + behavior.harness !== "codex-sdk" || + !behavior.requiresFilesystemWrites || + env.PROSE_CODEX_SANDBOX_MODE !== undefined + ) { + return env; + } + + return { + ...env, + PROSE_CODEX_SANDBOX_MODE: "workspace-write", + }; } async function hydrateForwardedArgs( diff --git a/tools/cli/src/commands/index.ts b/tools/cli/src/commands/index.ts index 73fa55ee..1fa2420d 100644 --- a/tools/cli/src/commands/index.ts +++ b/tools/cli/src/commands/index.ts @@ -65,10 +65,11 @@ const forwardCommandDefinitions = { command: "write", examples: [ '<%= config.bin %> write "draft a release readiness responsibility" --harness codex-sdk', + '<%= config.bin %> write --out src/release-readiness --run "draft a release readiness responsibility"', "cat brief.txt | <%= config.bin %> write --harness codex-sdk", ], summary: "Write linted OpenProse source from rough intent.", - usage: "write [request...] [--harness ]", + usage: "write [--out ] [--apply] [--run] [request...] [--harness ]", }, } satisfies Record; diff --git a/tools/cli/src/prose/command-model.ts b/tools/cli/src/prose/command-model.ts index f1b384e5..2e9bdb6b 100644 --- a/tools/cli/src/prose/command-model.ts +++ b/tools/cli/src/prose/command-model.ts @@ -1,3 +1,5 @@ +import { isAbsolute } from "node:path"; + export type CommandName = | "compile" | "run" @@ -40,7 +42,7 @@ export const supportedCommands = [ const usageByCommand: Record = { compile: "prose compile [path] [--out ]", run: "prose run [inputs...]", - write: "prose write [request...]", + write: "prose write [--out ] [--apply] [--run] [request...]", lint: "prose lint ", preflight: "prose preflight ", test: "prose test ", @@ -55,21 +57,25 @@ const usageByCommand: Record = { export function canonicalPrompt(command: CommandName, args: readonly string[]): string { validate(command, args); if (command === "write") { + const write = parseWriteCommand(args); return shellJoin([ "prose", "write", "output_mode:", - "source-package-only", + write.apply ? "source-package-and-files" : "source-package-only", "apply:", - "false", + String(write.apply), + ...(write.out === undefined ? [] : ["target_path:", write.out]), + "post_apply_action:", + write.run ? "host-will-run-root" : "none", "run_state:", - "in-context", + write.apply ? "filesystem" : "in-context", "terminal_summary:", "required", "interactive:", "false", "request:", - parseWriteRequest(args), + write.request, ]); } return shellJoin(["prose", command, ...args]); @@ -79,6 +85,102 @@ export function usageFor(command: CommandName): string { return usageByCommand[command]; } +export interface WriteCommandOptions { + apply: boolean; + out?: string; + request: string; + run: boolean; +} + +export function parseWriteCommand(args: readonly string[]): WriteCommandOptions { + const requestParts: string[] = []; + let literalRequest = false; + let out: string | undefined; + let apply = false; + let run = false; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === undefined) { + continue; + } + + if (!literalRequest && arg === "--") { + literalRequest = true; + continue; + } + + if (!literalRequest) { + if (arg === "--interactive" || arg === "--no-interactive") { + fail( + "write", + "'prose write' does not support interactive flags. Pass all authoring context in argv/stdin, or run prose-author from a host that supports ask_user.", + ); + } + + if (arg === "--apply") { + apply = true; + continue; + } + + if (arg === "--run") { + apply = true; + run = true; + continue; + } + + if (arg === "--out") { + if (out !== undefined) { + fail("write", "Duplicate option for 'prose write'."); + } + const value = args[index + 1]; + if (!value || value.startsWith("-")) { + fail("write", "Missing value for --out."); + } + out = normalizeWriteTargetPath(value); + index += 1; + continue; + } + + if (arg.startsWith("--out=")) { + if (out !== undefined) { + fail("write", "Duplicate option for 'prose write'."); + } + out = normalizeWriteTargetPath(arg.slice("--out=".length)); + continue; + } + } + + requestParts.push(arg); + } + + const request = requestParts.join(" "); + if (request.trim() === "") { + fail("write", "Missing request text for 'prose write'. Pass text arguments or pipe stdin."); + } + if (apply && out === undefined) { + fail("write", "'prose write --apply' and 'prose write --run' require --out ."); + } + + return { + apply, + ...(out === undefined ? {} : { out }), + request, + run, + }; +} + +export function resolveWriteRunTarget(args: readonly string[]): string | undefined { + const write = parseWriteCommand(args); + if (!write.run) { + return undefined; + } + if (write.out === undefined) { + return undefined; + } + return rootFileForWriteTarget(write.out); +} + function validate(command: CommandName, args: readonly string[]): void { switch (command) { case "compile": @@ -135,34 +237,11 @@ function requireAtLeastOne(command: CommandName, args: readonly string[], label: } function requireNonBlankWriteRequest(command: "write", args: readonly string[]): void { - if (parseWriteRequest(args).trim() === "") { + if (parseWriteCommand(args).request.trim() === "") { fail(command, "Missing request text for 'prose write'. Pass text arguments or pipe stdin."); } } -function parseWriteRequest(args: readonly string[]): string { - const requestParts: string[] = []; - let literalRequest = false; - - for (const arg of args) { - if (!literalRequest && arg === "--") { - literalRequest = true; - continue; - } - - if (!literalRequest && (arg === "--interactive" || arg === "--no-interactive")) { - fail( - "write", - "'prose write' does not support interactive flags. Pass all authoring context in argv/stdin, or run prose-author from a host that supports ask_user.", - ); - } - - requestParts.push(arg); - } - - return requestParts.join(" "); -} - function requireExactlyOne(command: CommandName, args: readonly string[], label: string): void { requireAtLeastOne(command, args, label); if (args.length > 1) { @@ -231,6 +310,37 @@ function fail(command: CommandName, message: string): never { throw new CommandModelError(message, usageByCommand[command]); } +function normalizeWriteTargetPath(value: string): string { + const normalized = value.trim().replaceAll("\\", "/").replace(/^\.\/+/, "").replace(/\/+$/, ""); + if (normalized === "") { + fail("write", "Missing value for --out."); + } + if (value.includes("\0")) { + fail("write", "--out must be a root-relative path."); + } + if (isAbsolute(value) || normalized.startsWith("/")) { + fail("write", "--out must be a root-relative path."); + } + if (normalized.split("/").includes("..")) { + fail("write", "--out must stay inside the OpenProse root."); + } + const lastSegment = normalized.split("/").at(-1) ?? normalized; + if (normalized !== "." && lastSegment.includes(".") && !normalized.endsWith(".prose.md")) { + fail("write", "--out file paths must end in .prose.md."); + } + return normalized === "." ? "." : normalized; +} + +function rootFileForWriteTarget(out: string): string { + if (out.endsWith(".prose.md")) { + return out; + } + if (out === ".") { + return "index.prose.md"; + } + return `${out}/index.prose.md`; +} + function shellJoin(tokens: readonly string[]): string { return tokens.map(shellQuote).join(" "); } diff --git a/tools/cli/src/prose/index.ts b/tools/cli/src/prose/index.ts index bd610c7f..ef3593ea 100644 --- a/tools/cli/src/prose/index.ts +++ b/tools/cli/src/prose/index.ts @@ -1,9 +1,12 @@ export { CommandModelError, canonicalPrompt, + parseWriteCommand, + resolveWriteRunTarget, supportedCommands, usageFor, type CommandName, + type WriteCommandOptions, } from "./command-model.js"; export { ATTACHED_OPENPROSE_ROOT_PATH, diff --git a/tools/cli/tests/cli/cli.test.ts b/tools/cli/tests/cli/cli.test.ts index b617d37b..e0c7ead6 100644 --- a/tools/cli/tests/cli/cli.test.ts +++ b/tools/cli/tests/cli/cli.test.ts @@ -1,6 +1,7 @@ import { chmodSync, copyFileSync, + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -376,7 +377,7 @@ describe("runForwardedProseCommand", () => { expect(exitCode).toBe(0); expect(seen).toEqual([ - "prose write output_mode: source-package-only apply: false run_state: in-context terminal_summary: required interactive: false request: 'draft release readiness'", + "prose write output_mode: source-package-only apply: false post_apply_action: none run_state: in-context terminal_summary: required interactive: false request: 'draft release readiness'", ]); }); @@ -404,10 +405,235 @@ describe("runForwardedProseCommand", () => { expect(exitCode).toBe(0); expect(seen).toEqual([ - "prose write output_mode: source-package-only apply: false run_state: in-context terminal_summary: required interactive: false request: 'draft a release readiness responsibility'", + "prose write output_mode: source-package-only apply: false post_apply_action: none run_state: in-context terminal_summary: required interactive: false request: 'draft a release readiness responsibility'", ]); }); + it("applies and runs the exact vulnerability detection write example", async () => { + const cwd = mkdtempSync(join(tmpdir(), "prose-write-run-")); + const io = memoryStreams(); + const seen: string[] = []; + const rootFile = join(cwd, "src", "vulnerability-detection", "index.prose.md"); + const exactRequest = + "a vulnerability detection system that uses lessons from https://blog.cloudflare.com/cyber-frontier-models/"; + + try { + const exitCode = await runForwardedProseCommand({ + command: "write", + argv: ["--out", "src/vulnerability-detection", "--run", exactRequest, "--harness", "mock"], + cwd, + env: {}, + stdout: io.streams.stdout, + stderr: io.streams.stderr, + harnessFactory: () => ({ + name: "mock", + async run(prompt) { + seen.push(prompt); + if (prompt.startsWith("prose write ")) { + mkdirSync(join(cwd, "src", "vulnerability-detection"), { recursive: true }); + writeFileSync( + rootFile, + `--- +name: vulnerability-detection +kind: system +--- + +# Vulnerability Detection + +### Services + +- \`reporter\` +`, + ); + } + if (prompt.startsWith("prose run ")) { + expect(existsSync(rootFile)).toBe(true); + } + return 0; + }, + }), + }); + + expect(exitCode).toBe(0); + expect(seen).toEqual([ + "prose write output_mode: source-package-and-files apply: true target_path: src/vulnerability-detection post_apply_action: host-will-run-root run_state: filesystem terminal_summary: required interactive: false request: 'a vulnerability detection system that uses lessons from https://blog.cloudflare.com/cyber-frontier-models/'", + "prose run src/vulnerability-detection/index.prose.md", + ]); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + it("does not start the write-run macro when authoring fails", async () => { + const io = memoryStreams(); + const seen: string[] = []; + + const exitCode = await runForwardedProseCommand({ + command: "write", + argv: ["--out", "src/release-readiness", "--run", "draft release readiness", "--harness", "mock"], + cwd: "/repo", + env: {}, + stdout: io.streams.stdout, + stderr: io.streams.stderr, + harnessFactory: () => ({ + name: "mock", + async run(prompt) { + seen.push(prompt); + return 7; + }, + }), + }); + + expect(exitCode).toBe(7); + expect(seen).toEqual([ + "prose write output_mode: source-package-and-files apply: true target_path: src/release-readiness post_apply_action: host-will-run-root run_state: filesystem terminal_summary: required interactive: false request: 'draft release readiness'", + ]); + }); + + it("returns the follow-up run exit code from the write-run macro", async () => { + const io = memoryStreams(); + const seen: string[] = []; + + const exitCode = await runForwardedProseCommand({ + command: "write", + argv: ["--out", "src/release-readiness", "--run", "draft release readiness", "--harness", "mock"], + cwd: "/repo", + env: {}, + stdout: io.streams.stdout, + stderr: io.streams.stderr, + harnessFactory: () => ({ + name: "mock", + async run(prompt) { + seen.push(prompt); + return prompt.startsWith("prose run ") ? 9 : 0; + }, + }), + }); + + expect(exitCode).toBe(9); + expect(seen).toEqual([ + "prose write output_mode: source-package-and-files apply: true target_path: src/release-readiness post_apply_action: host-will-run-root run_state: filesystem terminal_summary: required interactive: false request: 'draft release readiness'", + "prose run src/release-readiness/index.prose.md", + ]); + }); + + it("does not start the write follow-up run when aborted after apply", async () => { + const io = memoryStreams(); + const seen: string[] = []; + const controller = new AbortController(); + + const exitCode = await runForwardedProseCommand({ + command: "write", + argv: ["--out", "src/release-readiness", "--run", "draft release readiness", "--harness", "mock"], + cwd: "/repo", + env: {}, + stdout: io.streams.stdout, + stderr: io.streams.stderr, + signal: controller.signal, + harnessFactory: () => ({ + name: "mock", + async run(prompt) { + seen.push(prompt); + controller.abort(); + return 0; + }, + }), + }); + + expect(exitCode).toBe(143); + expect(seen).toEqual([ + "prose write output_mode: source-package-and-files apply: true target_path: src/release-readiness post_apply_action: host-will-run-root run_state: filesystem terminal_summary: required interactive: false request: 'draft release readiness'", + ]); + }); + + it("defaults codex write apply runs to workspace-write when no sandbox is configured", async () => { + const io = memoryStreams(); + const seenSandboxModes: Array = []; + + const exitCode = await runForwardedProseCommand({ + command: "write", + argv: ["--out", "src/release-readiness", "--apply", "draft release readiness", "--harness", "codex-sdk"], + cwd: "/repo", + env: {}, + stdout: io.streams.stdout, + stderr: io.streams.stderr, + skillBootstrap: false, + skillPreflight: false, + harnessFactory: () => ({ + name: "codex-sdk", + async run(_prompt, options) { + seenSandboxModes.push(options.env?.PROSE_CODEX_SANDBOX_MODE); + return 0; + }, + }), + }); + + expect(exitCode).toBe(0); + expect(seenSandboxModes).toEqual(["workspace-write"]); + }); + + it("derives codex sandbox defaults from the forwarded env only", async () => { + const io = memoryStreams(); + const seenSandboxModes: Array = []; + const previous = process.env.PROSE_CODEX_SANDBOX_MODE; + process.env.PROSE_CODEX_SANDBOX_MODE = "danger-full-access"; + + try { + const exitCode = await runForwardedProseCommand({ + command: "write", + argv: ["--out", "src/release-readiness", "--apply", "draft release readiness", "--harness", "codex-sdk"], + cwd: "/repo", + env: {}, + stdout: io.streams.stdout, + stderr: io.streams.stderr, + skillBootstrap: false, + skillPreflight: false, + harnessFactory: () => ({ + name: "codex-sdk", + async run(_prompt, options) { + seenSandboxModes.push(options.env?.PROSE_CODEX_SANDBOX_MODE); + return 0; + }, + }), + }); + + expect(exitCode).toBe(0); + expect(seenSandboxModes).toEqual(["workspace-write"]); + } finally { + if (previous === undefined) { + delete process.env.PROSE_CODEX_SANDBOX_MODE; + } else { + process.env.PROSE_CODEX_SANDBOX_MODE = previous; + } + } + }); + + it("does not override an explicit codex sandbox for write apply runs", async () => { + const io = memoryStreams(); + const seenSandboxModes: Array = []; + + const exitCode = await runForwardedProseCommand({ + command: "write", + argv: ["--out", "src/release-readiness", "--apply", "draft release readiness", "--harness", "codex-sdk"], + cwd: "/repo", + env: { PROSE_CODEX_SANDBOX_MODE: "danger-full-access" }, + stdout: io.streams.stdout, + stderr: io.streams.stderr, + skillBootstrap: false, + skillPreflight: false, + harnessFactory: () => ({ + name: "codex-sdk", + async run(_prompt, options) { + seenSandboxModes.push(options.env?.PROSE_CODEX_SANDBOX_MODE); + return 0; + }, + }), + }); + + expect(exitCode).toBe(0); + expect(seenSandboxModes).toEqual(["danger-full-access"]); + }); + it("rejects write flags that would imply unsupported CLI interaction", async () => { const io = memoryStreams(); let harnessCalled = false; diff --git a/tools/cli/tests/prose/command-model.test.ts b/tools/cli/tests/prose/command-model.test.ts index 703ad34e..4e4a684f 100644 --- a/tools/cli/tests/prose/command-model.test.ts +++ b/tools/cli/tests/prose/command-model.test.ts @@ -16,8 +16,12 @@ describe("command model", () => { ["run", ["system.prose.md", "--activation-context", "{\"kind\":\"openprose.activation\"}"]], "prose run system.prose.md --activation-context '{\"kind\":\"openprose.activation\"}'", ], - [["write", ["draft a release readiness responsibility"]], "prose write output_mode: source-package-only apply: false run_state: in-context terminal_summary: required interactive: false request: 'draft a release readiness responsibility'"], - [["write", ["--", "--no-interactive is literal request text"]], "prose write output_mode: source-package-only apply: false run_state: in-context terminal_summary: required interactive: false request: '--no-interactive is literal request text'"], + [["write", ["draft a release readiness responsibility"]], "prose write output_mode: source-package-only apply: false post_apply_action: none run_state: in-context terminal_summary: required interactive: false request: 'draft a release readiness responsibility'"], + [["write", ["--out", "src/release-readiness", "draft a release readiness responsibility"]], "prose write output_mode: source-package-only apply: false target_path: src/release-readiness post_apply_action: none run_state: in-context terminal_summary: required interactive: false request: 'draft a release readiness responsibility'"], + [["write", ["--out", "src/release-readiness", "--apply", "draft a release readiness responsibility"]], "prose write output_mode: source-package-and-files apply: true target_path: src/release-readiness post_apply_action: none run_state: filesystem terminal_summary: required interactive: false request: 'draft a release readiness responsibility'"], + [["write", ["--out=src/release-readiness", "--run", "draft a release readiness responsibility"]], "prose write output_mode: source-package-and-files apply: true target_path: src/release-readiness post_apply_action: host-will-run-root run_state: filesystem terminal_summary: required interactive: false request: 'draft a release readiness responsibility'"], + [["write", ["--out", "src/vulnerability-detection", "--run", "a vulnerability detection system that uses lessons from https://blog.cloudflare.com/cyber-frontier-models/"]], "prose write output_mode: source-package-and-files apply: true target_path: src/vulnerability-detection post_apply_action: host-will-run-root run_state: filesystem terminal_summary: required interactive: false request: 'a vulnerability detection system that uses lessons from https://blog.cloudflare.com/cyber-frontier-models/'"], + [["write", ["--", "--no-interactive is literal request text"]], "prose write output_mode: source-package-only apply: false post_apply_action: none run_state: in-context terminal_summary: required interactive: false request: '--no-interactive is literal request text'"], [["lint", ["system.prose.md"]], "prose lint system.prose.md"], [["preflight", ["system.prose.md"]], "prose preflight system.prose.md"], [["test", ["tests/systems"]], "prose test tests/systems"], @@ -53,10 +57,19 @@ describe("command model", () => { [["run", []], "Missing required argument ", "prose run [inputs...]"], [["run", ["system.md"]], "Expected ", "prose run [inputs...]"], [["run", ["script.prose"]], "Expected ", "prose run [inputs...]"], - [["write", []], "Pass text arguments or pipe stdin", "prose write [request...]"], - [["write", [" "]], "Pass text arguments or pipe stdin", "prose write [request...]"], - [["write", ["--interactive", "draft"]], "does not support interactive flags", "prose write [request...]"], - [["write", ["--no-interactive", "draft"]], "does not support interactive flags", "prose write [request...]"], + [["write", []], "Pass text arguments or pipe stdin", "prose write [--out ] [--apply] [--run] [request...]"], + [["write", [" "]], "Pass text arguments or pipe stdin", "prose write [--out ] [--apply] [--run] [request...]"], + [["write", ["--interactive", "draft"]], "does not support interactive flags", "prose write [--out ] [--apply] [--run] [request...]"], + [["write", ["--no-interactive", "draft"]], "does not support interactive flags", "prose write [--out ] [--apply] [--run] [request...]"], + [["write", ["--apply", "draft"]], "require --out ", "prose write [--out ] [--apply] [--run] [request...]"], + [["write", ["--run", "draft"]], "require --out ", "prose write [--out ] [--apply] [--run] [request...]"], + [["write", ["--out"]], "Missing value for --out", "prose write [--out ] [--apply] [--run] [request...]"], + [["write", ["--out=", "draft"]], "Missing value for --out", "prose write [--out ] [--apply] [--run] [request...]"], + [["write", ["--out", "/tmp/source", "draft"]], "root-relative path", "prose write [--out ] [--apply] [--run] [request...]"], + [["write", ["--out", "../source", "draft"]], "inside the OpenProse root", "prose write [--out ] [--apply] [--run] [request...]"], + [["write", ["--out", "source.md", "draft"]], "end in .prose.md", "prose write [--out ] [--apply] [--run] [request...]"], + [["write", ["--out", "src/source.txt", "draft"]], "end in .prose.md", "prose write [--out ] [--apply] [--run] [request...]"], + [["write", ["--out", "one", "--out", "two", "draft"]], "Duplicate option", "prose write [--out ] [--apply] [--run] [request...]"], [["inspect", []], "Missing required argument ", "prose inspect "], [["lint", ["system.md"]], "Expected ", "prose lint "], [["preflight", ["system.md"]], "Expected ", "prose preflight "], diff --git a/tools/cli/tests/prose/prose-author-contract.test.ts b/tools/cli/tests/prose/prose-author-contract.test.ts index c5c0a939..e4a5848d 100644 --- a/tools/cli/tests/prose/prose-author-contract.test.ts +++ b/tools/cli/tests/prose/prose-author-contract.test.ts @@ -64,6 +64,20 @@ describe("prose-author contract", () => { expect(source).toContain("not invoke external operational systems while authoring"); expect(source).toContain("never actions performed by `prose-author`"); + expect(source).toContain("must not create optional memory notes"); + expect(source).toContain("recognition memories"); + }); + + it("keeps write-run as a non-operational host follow-up", () => { + const source = proseAuthorSource(); + const normalized = source.replace(/\s+/g, " "); + + expect(source).toContain("post_apply_action"); + expect(source).toContain("host-will-run-root"); + expect(normalized).toContain("does not authorize `prose-author` to run"); + expect(normalized).toContain("must not execute, simulate, or publish run receipts"); + expect(source).not.toContain("run_after_write"); + expect(source).not.toContain("host-managed"); }); it("requires a terminal-friendly success summary for prose write", () => { diff --git a/tools/cli/tests/skills/open-prose.test.ts b/tools/cli/tests/skills/open-prose.test.ts index b27ad539..7bf8d66a 100644 --- a/tools/cli/tests/skills/open-prose.test.ts +++ b/tools/cli/tests/skills/open-prose.test.ts @@ -1,6 +1,7 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import type { ProcessRunner } from "../../src/harnesses/index.js"; import { @@ -13,6 +14,8 @@ import { resolveOpenProseSkill, } from "../../src/skills/open-prose.js"; +const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url)); + function tempDir(): string { return mkdtempSync(join(tmpdir(), "prose-skill-")); } @@ -58,6 +61,26 @@ function memoryStream() { } describe("OpenProse skill checks", () => { + it("documents write-run as a host-adapter macro that unsupported session routers must reject", () => { + const source = readFileSync(join(repoRoot, "skills/open-prose/SKILL.md"), "utf8"); + const normalized = source.replace(/\s+/g, " "); + + expect(source).toContain("host-adapter macro"); + expect(normalized).toContain("reject `prose write --run` before authoring"); + expect(normalized).toContain("must not pass the macro into `prose-author`"); + expect(normalized).toContain("ordinary `prose run` semantics"); + }); + + it("keeps optional giving-back side effects out of forwarded write runs", () => { + const source = readFileSync(join(repoRoot, "skills/open-prose/SKILL.md"), "utf8"); + const normalized = source.replace(/\s+/g, " "); + + expect(normalized).toContain("Forwarded CLI commands, non-interactive runs, and `prose write` authoring runs"); + expect(normalized).toContain("must not perform giving-back actions"); + expect(normalized).toContain("create mycelium notes"); + expect(normalized).toContain("Authoring success must not depend on optional memory or note writes"); + }); + it("builds a bootstrap prompt with the full skill text and skill root instructions", () => { const bootstrap = buildOpenProseSkillBootstrapPrompt({ skillPath: "/home/prose/.agents/skills/open-prose/SKILL.md",