From 18a70ed52cc06c72cd2cdc40d091667fa90d7684 Mon Sep 17 00:00:00 2001 From: replygirl Date: Sat, 5 Sep 2026 12:23:45 -0500 Subject: [PATCH 1/6] feat(cli): wrap openspec config and add native completion and feedback Co-Authored-By: Claude Fable 5.1 --- .codex/rules/cospec.rules | 5 + apps/cli/src/cli.ts | 37 ++- apps/cli/src/commands/complete.ts | 89 +++++ apps/cli/src/commands/completion.ts | 79 +++++ apps/cli/src/commands/config.ts | 314 ++++++++++++++++++ apps/cli/src/commands/feedback.ts | 288 ++++++++++++++++ apps/cli/src/core/completions/bash.ts | 102 ++++++ apps/cli/src/core/completions/fish.ts | 53 +++ apps/cli/src/core/completions/spec.ts | 95 ++++++ apps/cli/src/core/completions/zsh.ts | 105 ++++++ apps/cli/src/harness/adapters.ts | 9 + apps/cli/test/contract/config-surface.test.ts | 159 +++++++++ apps/cli/test/integration/completion.test.ts | 182 ++++++++++ apps/cli/test/integration/config.test.ts | 210 ++++++++++++ apps/cli/test/integration/feedback.test.ts | 212 ++++++++++++ .../test/integration/pack-standalone.test.ts | 17 + .../test/unit/commands/complete-types.test.ts | 47 +++ .../test/unit/commands/config-args.test.ts | 197 +++++++++++ .../unit/commands/feedback-format.test.ts | 167 ++++++++++ apps/cli/test/unit/core/completions.test.ts | 127 +++++++ .../__snapshots__/adapters.test.ts.snap | 5 + .../harness/__snapshots__/render.test.ts.snap | 5 + openspec/.cospec-manifest.json | 2 +- 23 files changed, 2504 insertions(+), 2 deletions(-) create mode 100644 apps/cli/src/commands/complete.ts create mode 100644 apps/cli/src/commands/completion.ts create mode 100644 apps/cli/src/commands/config.ts create mode 100644 apps/cli/src/commands/feedback.ts create mode 100644 apps/cli/src/core/completions/bash.ts create mode 100644 apps/cli/src/core/completions/fish.ts create mode 100644 apps/cli/src/core/completions/spec.ts create mode 100644 apps/cli/src/core/completions/zsh.ts create mode 100644 apps/cli/test/contract/config-surface.test.ts create mode 100644 apps/cli/test/integration/completion.test.ts create mode 100644 apps/cli/test/integration/config.test.ts create mode 100644 apps/cli/test/integration/feedback.test.ts create mode 100644 apps/cli/test/unit/commands/complete-types.test.ts create mode 100644 apps/cli/test/unit/commands/config-args.test.ts create mode 100644 apps/cli/test/unit/commands/feedback-format.test.ts create mode 100644 apps/cli/test/unit/core/completions.test.ts diff --git a/.codex/rules/cospec.rules b/.codex/rules/cospec.rules index 78e718b..57b568f 100644 --- a/.codex/rules/cospec.rules +++ b/.codex/rules/cospec.rules @@ -9,3 +9,8 @@ prefix_rule(pattern=["cospec", "apply"], decision="allow") prefix_rule(pattern=["cospec", "sync-blockers", "--check"], decision="allow") prefix_rule(pattern=["cospec", "new"], decision="allow") prefix_rule(pattern=["cospec", "doctor"], decision="allow") +prefix_rule(pattern=["cospec", "config", "get"], decision="allow") +prefix_rule(pattern=["cospec", "config", "list"], decision="allow") +prefix_rule(pattern=["cospec", "config", "path"], decision="allow") +prefix_rule(pattern=["cospec", "completion"], decision="allow") +prefix_rule(pattern=["cospec", "__complete"], decision="allow") diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index 2b5f2bf..f49c486 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -173,6 +173,32 @@ export const COMMANDS: CommandEntry[] = [ summary: 'List per-artifact template paths', options: ` --schema Schema whose templates to list (default: spec-driven)`, }, + { + name: 'config', + summary: 'View and modify machine-global OpenSpec configuration', + usage: ' [args]', + options: ` --scope Config scope (only "global" is implemented upstream) + (config is machine-global: --store never applies; edit/profile/reset without -y + hand the terminal over and cannot emit JSON)`, + }, + { + name: 'completion', + summary: 'Print the shell completion script for cospec', + usage: '[bash|zsh|fish]', + options: ` (shell omitted: detected from $SHELL; the script is printed, never installed)`, + }, + { + name: 'feedback', + summary: "File feedback about cospec (--upstream files OpenSpec's)", + usage: '', + options: ` --body Detailed description for the report + --upstream File at Fission-AI/OpenSpec instead of aligned-team/cospec`, + }, + { + name: '__complete', + summary: 'Dynamic completion source (changes|specs|types)', + hidden: true, + }, { name: 'check-commit', summary: 'Warn on commit-type/schema mismatch (hook entrypoint)', @@ -211,10 +237,19 @@ const COMMAND_MODULES: Record Promise>> = { schemas: () => import('./commands/schemas.ts'), schema: () => import('./commands/schema.ts'), templates: () => import('./commands/templates.ts'), + config: () => import('./commands/config.ts'), + completion: () => import('./commands/completion.ts'), + feedback: () => import('./commands/feedback.ts'), + __complete: () => import('./commands/complete.ts'), 'check-commit': () => import('./commands/check-commit.ts'), } -const GLOBAL_OPTIONS = `Global options: +/** + * The global-flag help block. Exported because `core/completions/spec.ts` + * derives the completion model from this table plus these flags — completion + * must never drift from `--help`. + */ +export const GLOBAL_OPTIONS = `Global options: --json Machine-readable output --no-color Disable ANSI color --cwd Run as if invoked from diff --git a/apps/cli/src/commands/complete.ts b/apps/cli/src/commands/complete.ts new file mode 100644 index 0000000..16424c2 --- /dev/null +++ b/apps/cli/src/commands/complete.ts @@ -0,0 +1,89 @@ +// `cospec __complete ` — the hidden dynamic-completion +// source the generated shell scripts call at Tab time. Emits tab-separated +// `iddescription` lines. +// +// EVERY failure is silent: exit 1 with nothing on stdout and nothing on stderr. +// A completion helper runs mid-keystroke, where an error message would corrupt +// the user's command line — so an unknown source, a missing openspec root, an +// unregistered store, or an unparseable wrapped payload all look the same: +// no suggestions. The whole payload is built before anything is written, so a +// late failure can never leave half a list on stdout. + +import type { CommandContext } from '../cli.ts' +import { EXIT } from '../cli.ts' +import { COSPEC_TYPES } from '../core/change.ts' +import { openspecList, passthroughOpenspec } from '../core/openspec.ts' +import { resolveRoot } from '../core/root.ts' +import { TYPE_ARTIFACTS } from '../core/rules/type-facts.ts' + +export const COMPLETE_SOURCES = ['changes', 'specs', 'types'] as const +export type CompleteSource = (typeof COMPLETE_SOURCES)[number] + +function isCompleteSource(name: string): name is CompleteSource { + return (COMPLETE_SOURCES as readonly string[]).includes(name) +} + +/** Render `iddescription` lines (empty description → id alone). */ +export function renderCompletionItems(items: { id: string; description?: string }[]): string { + return items + .map((item) => + item.description === undefined || item.description.length === 0 + ? `${item.id}\n` + : `${item.id}\t${item.description}\n`, + ) + .join('') +} + +/** The 11 conventional-commit types, described by the artifacts each declares. */ +function typeItems(): { id: string; description: string }[] { + return COSPEC_TYPES.map((type) => ({ + id: type, + description: TYPE_ARTIFACTS[type].declared.join(', '), + })) +} + +async function changeItems(ctx: CommandContext): Promise<{ id: string; description: string }[]> { + const root = await resolveRoot(ctx) + const list = await openspecList(root) + return list.changes.map((change) => ({ + id: change.name, + description: `${change.status}, ${change.completedTasks}/${change.totalTasks} tasks`, + })) +} + +interface SpecsPayload { + specs?: { id: string; requirementCount: number }[] +} + +async function specItems(ctx: CommandContext): Promise<{ id: string; description: string }[]> { + const root = await resolveRoot(ctx) + const result = await passthroughOpenspec(['list', '--specs', '--json'], { + cwd: root.cwd, + storeArgs: root.storeArgs, + }) + if (result.exitCode !== 0) throw new Error('list --specs failed') + const payload = JSON.parse(result.stdout) as SpecsPayload + return (payload.specs ?? []).map((spec) => ({ + id: spec.id, + description: `${spec.requirementCount} requirements`, + })) +} + +export async function run(ctx: CommandContext): Promise { + const source = ctx.args[0] + if (source === undefined || !isCompleteSource(source)) return EXIT.failure + try { + const items = + source === 'types' + ? typeItems() + : source === 'changes' + ? await changeItems(ctx) + : await specItems(ctx) + process.stdout.write(renderCompletionItems(items)) + return EXIT.success + } catch { + // Deliberate blanket catch: see the module header. Nothing is written, so + // the shell simply offers no suggestions. + return EXIT.failure + } +} diff --git a/apps/cli/src/commands/completion.ts b/apps/cli/src/commands/completion.ts new file mode 100644 index 0000000..a33cca5 --- /dev/null +++ b/apps/cli/src/commands/completion.ts @@ -0,0 +1,79 @@ +// `cospec completion [bash|zsh|fish]` — print the completion script for a +// shell to stdout. Generate-only by design: there is no `install`/`uninstall` +// subcommand, because rc-file mutation with backups and a matching uninstaller +// is a separate change, and because cospec must never write a line into a +// user's dotfiles that runs bare `openspec` (which is what relaying upstream's +// installer would do). The docs carry the per-shell copy-paste one-liner. + +import { basename } from 'node:path' + +import type { CommandContext } from '../cli.ts' +import { EXIT } from '../cli.ts' +import { renderBashCompletion } from '../core/completions/bash.ts' +import { renderFishCompletion } from '../core/completions/fish.ts' +import { buildCompletionSpec } from '../core/completions/spec.ts' +import { renderZshCompletion } from '../core/completions/zsh.ts' + +export const SUPPORTED_SHELLS = ['bash', 'zsh', 'fish'] as const +export type SupportedShell = (typeof SUPPORTED_SHELLS)[number] + +function isSupportedShell(name: string): name is SupportedShell { + return (SUPPORTED_SHELLS as readonly string[]).includes(name) +} + +/** + * Detect the shell from `$SHELL`'s basename, stripping the leading `-` a login + * shell carries. No `ps` fork: upstream probes the parent process, which is not + * worth a spawn for a value the user can always pass explicitly. + */ +export function detectShell(shellEnv: string | undefined): SupportedShell | undefined { + if (shellEnv === undefined || shellEnv.length === 0) return undefined + const name = basename(shellEnv).replace(/^-/, '') + return isSupportedShell(name) ? name : undefined +} + +/** Render the completion script for one shell from cospec's own command table. */ +export function renderCompletion(shell: SupportedShell): string { + const spec = buildCompletionSpec() + if (shell === 'bash') return renderBashCompletion(spec) + if (shell === 'zsh') return renderZshCompletion(spec) + return renderFishCompletion(spec) +} + +export function run(ctx: CommandContext): number { + // A shell script is not a JSON document, so `--json` is refused rather than + // faked — but the refusal is still exactly one JSON document on stdout, which + // is what a `--json` caller is entitled to. + if (ctx.flags.json) { + process.stdout.write( + `${JSON.stringify({ + version: 1, + command: 'completion', + ok: false, + message: 'cospec completion emits a shell script and cannot emit JSON', + })}\n`, + ) + return EXIT.failure + } + + const requested = ctx.args[0] + if (requested !== undefined && !isSupportedShell(requested)) { + process.stderr.write( + `cospec completion: unsupported shell '${requested}' ` + + `(supported: ${SUPPORTED_SHELLS.join(', ')})\n`, + ) + return EXIT.failure + } + + const shell = requested ?? detectShell(process.env.SHELL) + if (shell === undefined) { + process.stderr.write( + 'cospec completion: could not detect the shell from $SHELL — ' + + `run 'cospec completion <${SUPPORTED_SHELLS.join('|')}>'\n`, + ) + return EXIT.failure + } + + process.stdout.write(renderCompletion(shell)) + return EXIT.success +} diff --git a/apps/cli/src/commands/config.ts b/apps/cli/src/commands/config.ts new file mode 100644 index 0000000..71c3fa1 --- /dev/null +++ b/apps/cli/src/commands/config.ts @@ -0,0 +1,314 @@ +// `cospec config ` — the machine-global OpenSpec configuration surface +// (`~/.config/openspec/config.json`). cospec adds no gate and no config file of +// its own: it never reads or writes that file directly, and never re-implements +// upstream's key validation, value coercion, or its prototype-pollution guard — +// every such error relays from the wrapped binary verbatim. +// +// This command does NOT use `core/passthrough-command.ts`, for three verified +// reasons (the `workset.ts` precedent): +// 1. `openspec config` has no `--store` — it has `--scope`, and it is +// machine-global, so `resolveRoot`/`root.storeArgs` never apply. +// 2. `--json` exists on `config list` only — upstream rejects it outright on +// `config path`/`get`/`set`/`unset`/`reset` (verified against the pinned +// 1.11.0 binary: `openspec config path --json` -> `error: unknown option +// '--json'`, exit 1). cospec still owes a `--json` caller exactly one JSON +// document, so the other subcommands get a cospec-owned envelope built +// from the text run. +// 3. `--no-color` is declared on the openspec *program*, not on the `config` +// leaf. Commander does resolve it from the parent, so a trailing +// `--no-color` is in fact ACCEPTED on 1.11.0's config leaves — it is +// simply redundant, because `core/openspec.ts` already prefixes +// `--no-color` before the subcommand on every wrapped spawn. This module +// therefore never appends it: not to dodge an error, but so the built argv +// carries nothing the wrapped call did not need. +// +// Two call classes: +// A. piped + disciplined (`passthroughOpenspec`): path, list, get, set, +// unset, `reset --all -y`, `profile `. Exit 1 is an ordinary +// negative result here (unset key, invalid key, invalid config), not a +// wrapped-call violation. +// B. terminal handover (inherited stdio, exit code propagated verbatim, +// version-asserted first — the `workset open` pattern): `edit` (spawns +// $EDITOR), `profile` with no preset (inquirer menus behind an isTTY +// check), and `reset --all` without `-y` (inquirer confirm). cospec's +// piped spawn uses `stdin: 'ignore'`, so all three would hang or +// mis-report. Class B propagates 130 (prompt cancellation) unchanged and +// enforces no `RunExpectation` — the documented handover exception. + +import { join } from 'node:path' + +import type { CommandContext } from '../cli.ts' +import { EXIT } from '../cli.ts' +import { extractEmbeddedOpenspec } from '../core/openspec-embedded.ts' +import { + passthroughOpenspec, + resolveOpenspec, + type RunExpectation, + spawnOpenspec, +} from '../core/openspec.ts' + +/** The eight subcommands upstream's `config` command defines. */ +export const CONFIG_SUBCOMMANDS = [ + 'path', + 'list', + 'get', + 'set', + 'unset', + 'reset', + 'edit', + 'profile', +] as const + +export type ConfigSub = (typeof CONFIG_SUBCOMMANDS)[number] + +function isConfigSub(name: string): name is ConfigSub { + return (CONFIG_SUBCOMMANDS as readonly string[]).includes(name) +} + +/** A planned wrapped call: piped+disciplined (A) or terminal handover (B). */ +export interface ConfigCall { + kind: 'pass' | 'handover' + sub: ConfigSub + /** Full argv for the wrapped binary, `config` first. */ + argv: string[] + /** The subcommand's own args, with `--scope` already removed. */ + subArgs: string[] +} + +export interface ConfigPlanError { + kind: 'error' + message: string +} + +export type ConfigPlan = ConfigCall | ConfigPlanError + +const SUBS = CONFIG_SUBCOMMANDS.join('|') + +/** First non-flag token, i.e. the subcommand's first positional. */ +function firstPositional(args: string[]): string | undefined { + return args.find((a) => !a.startsWith('-')) +} + +/** + * Plan the wrapped call for `cospec config …` (pure, unit-testable). + * + * Rules: `--scope ` is a parent-command option, so it is lifted out of + * wherever the caller typed it and re-emitted in its canonical position, + * between `config` and the subcommand. (Commander resolves it from the leaf + * too on 1.11.0, so this is normalization, not a workaround — it keeps one + * argv shape for every input.) Any value but `global` is upstream's error to + * print, not cospec's to second-guess. `--json` is appended only for `list`. + * `--no-color` and `root.storeArgs` are never appended (module header). + */ +export function planConfigCall(args: string[], opts: { json: boolean }): ConfigPlan { + let scope: string | undefined + const rest: string[] = [] + for (let i = 0; i < args.length; i++) { + const tok = args[i]! + if (tok === '--scope') { + const value = args[++i] + if (value === undefined) + return { kind: 'error', message: 'cospec config: --scope requires a value' } + scope = value + continue + } + if (tok.startsWith('--scope=')) { + scope = tok.slice('--scope='.length) + continue + } + rest.push(tok) + } + + const sub = rest[0] + if (sub === undefined) + return { kind: 'error', message: `cospec config: a subcommand is required (${SUBS})` } + if (!isConfigSub(sub)) + return { kind: 'error', message: `cospec config: unknown subcommand '${sub}' (${SUBS})` } + + const subArgs = rest.slice(1) + const scopeArgs = scope === undefined ? [] : ['--scope', scope] + const argv = ['config', ...scopeArgs, sub, ...subArgs] + if (opts.json && sub === 'list') argv.push('--json') + + return { kind: isHandoverCall(sub, subArgs) ? 'handover' : 'pass', sub, argv, subArgs } +} + +/** + * True when the subcommand takes the terminal over upstream: `edit` execs + * $EDITOR with inherited stdio; `profile` with no preset runs inquirer menus; + * `reset --all` without `-y`/`--yes` runs an inquirer confirm. (`reset` with no + * `--all` is an upstream usage error and stays piped.) + */ +export function isHandoverCall(sub: ConfigSub, subArgs: string[]): boolean { + if (sub === 'edit') return true + if (sub === 'profile') return firstPositional(subArgs) === undefined + if (sub === 'reset') + return subArgs.includes('--all') && !subArgs.includes('-y') && !subArgs.includes('--yes') + return false +} + +/** cospec-owned `--json` envelope for the subcommands upstream has no `--json` for. */ +function jsonEnvelope(body: Record): string { + return `${JSON.stringify(body)}\n` +} + +/** + * The two precedence notes (stderr, so a `--json` stdout stays exactly one + * document). Printed only after a successful mutation. + */ +export function precedenceNotes(sub: ConfigSub, subArgs: string[]): string[] { + const notes: string[] = [] + const key = sub === 'set' ? firstPositional(subArgs) : undefined + if (key === 'telemetry.enabled') + notes.push( + 'note: cospec forces OPENSPEC_TELEMETRY=0 on every wrapped call — this setting affects ' + + "bare 'openspec' runs only.", + ) + if (sub === 'profile' || key === 'profile' || key === 'workflows' || key === 'delivery') + notes.push( + "note: cospec's harness files are generated from cospec canon — run 'cospec update', not " + + "'openspec update'.", + ) + return notes +} + +/** + * The declared expectation for every Class A call. `exitCodes` is stated + * explicitly rather than inherited from `passthroughOpenspec`'s default so the + * discipline is visible at the call site: upstream exits 1 for an ordinary + * negative result (unset key, unknown key, invalid stored config), which is a + * result to relay, not a wrapped-call violation — anything else is. + * + * The deny-list guards the two first-run notices openspec can print to STDOUT + * ahead of real output. `WRAPPED_ENV` suppresses both (`OPENSPEC_TELEMETRY=0`, + * `OPENSPEC_NO_COMPLETIONS=1`); this makes a regression in that suppression a + * loud failure instead of a corrupted `config path` or `config get` value. + */ +const CONFIG_EXPECT: RunExpectation = { + exitCodes: [0, 1], + denyStdout: [/collects anonymous usage/i, /completion install/i], +} + +/** + * Class A: piped, disciplined. `exitCodes` is the passthrough default `[0, 1]`; + * a `--json` caller gets exactly one document either way — upstream's own for + * `list` (one-doc-enforced by `passthroughOpenspec`), a cospec-owned + * `version: 1` envelope for the rest, whose `value`/`message` is the raw text + * upstream printed (upstream renders objects as compact JSON and scalars via + * `String`, so cospec cannot recover the type without duplicating its merge + * logic — read `config list --json` for typed values). + */ +async function runPiped(ctx: CommandContext, call: ConfigCall): Promise { + const result = await passthroughOpenspec(call.argv, { cwd: ctx.cwd, expect: CONFIG_EXPECT }) + const ok = result.exitCode === 0 + const out = result.stdout.trim() + + if (!ctx.flags.json) { + if (result.stdout.length > 0) process.stdout.write(result.stdout) + if (result.stderr.length > 0) process.stderr.write(result.stderr) + } else if (call.sub === 'list') { + process.stdout.write(result.stdout) + if (result.stderr.length > 0) process.stderr.write(result.stderr) + } else if (call.sub === 'path') { + process.stdout.write(jsonEnvelope({ version: 1, command: 'config path', path: out })) + } else if (call.sub === 'get') { + process.stdout.write( + jsonEnvelope({ + version: 1, + command: 'config get', + key: firstPositional(call.subArgs) ?? null, + value: ok ? out : null, + found: ok, + }), + ) + } else { + const message = out.length > 0 ? out : result.stderr.trim() + process.stdout.write( + jsonEnvelope({ + version: 1, + command: `config ${call.sub}`, + ok, + message: message.length > 0 ? message : null, + }), + ) + } + + if (ok) + for (const note of precedenceNotes(call.sub, call.subArgs)) process.stderr.write(`${note}\n`) + return ok ? EXIT.success : EXIT.failure +} + +/** + * Resolve the wrapped binary the way `core/openspec.ts`'s private + * `openspecBin()` does, reusing only its exported primitives. The + * `spawnOpenspec(['--version'])` call triggers (and memoizes) the version + * assertion every wrapped call owes before the handover child takes the + * terminal — same as `workset open`. + */ +async function resolveHandoverBin(cwd: string): Promise { + await spawnOpenspec(['--version'], cwd) + const resolved = resolveOpenspec() + return resolved.source === 'project' + ? join(resolved.packageDir, 'bin', 'openspec.js') + : extractEmbeddedOpenspec(resolved.version) +} + +/** + * Class B: hand the terminal over (array argv, no shell, inherited stdio) and + * propagate the child's exit code verbatim — including 130 on prompt + * cancellation. The argv is exactly what the builder produced: no `--no-color` + * (the `workset open` precedent — a handover renders an editor session or an + * inquirer menu for a human, where color is wanted, and cospec's own + * `--no-color` still reaches the child through the inherited `NO_COLOR=1` the + * dispatcher sets). `OPENSPEC_NO_COMPLETIONS=1` is added over that precedent so + * upstream's first-run completions tip can never surface from a cospec run. + */ +async function runHandover(ctx: CommandContext, call: ConfigCall): Promise { + if (ctx.flags.json) { + process.stdout.write( + jsonEnvelope({ + version: 1, + command: `config ${call.sub}`, + ok: false, + message: `cospec config ${call.sub} is interactive and cannot emit JSON`, + }), + ) + return EXIT.failure + } + const bin = await resolveHandoverBin(ctx.cwd) + const proc = Bun.spawn([process.execPath, bin, ...call.argv], { + cwd: ctx.cwd, + stdin: 'inherit', + stdout: 'inherit', + stderr: 'inherit', + env: { + ...process.env, + BUN_BE_BUN: '1', + OPENSPEC_TELEMETRY: '0', + OPENSPEC_NO_COMPLETIONS: '1', + }, + }) + const code = await proc.exited + if (code === 0) + for (const note of precedenceNotes(call.sub, call.subArgs)) process.stderr.write(`${note}\n`) + return code +} + +export async function run(ctx: CommandContext): Promise { + // `--store` is absorbed as a global flag anywhere after the command name, so + // silently ignoring it here would be misleading: OpenSpec config is + // machine-global and has no store dimension at all. + if (ctx.flags.store !== undefined) { + process.stderr.write( + 'cospec config: --store does not apply — OpenSpec config is machine-global ' + + '(use --scope global)\n', + ) + return EXIT.failure + } + const plan = planConfigCall(ctx.args, { json: ctx.flags.json }) + if (plan.kind === 'error') { + process.stderr.write(`${plan.message}\n`) + return EXIT.failure + } + return plan.kind === 'handover' ? runHandover(ctx, plan) : runPiped(ctx, plan) +} diff --git a/apps/cli/src/commands/feedback.ts b/apps/cli/src/commands/feedback.ts new file mode 100644 index 0000000..6d261bf --- /dev/null +++ b/apps/cli/src/commands/feedback.ts @@ -0,0 +1,288 @@ +// `cospec feedback "" [--body ] [--upstream]`. +// +// Native by default, filing at cospec's own tracker. Upstream's `openspec +// feedback` hardcodes `Fission-AI/OpenSpec` in both its `gh issue create` argv +// and its manual-submission URL; a cospec user hitting a bug generally cannot +// tell whether it is cospec's or OpenSpec's, and cospec maintainers can — so +// defaulting to upstream would route cospec bug reports to a project that +// cannot fix them. `--upstream` relays to the wrapped command for a genuine +// OpenSpec bug, naming the destination on stderr first. +// +// Deliberate differences from upstream's implementation: +// - No `--label`. Upstream passes `--label feedback` and then carries a retry +// branch for `/could not add label/i` when the repo does not define it; +// dropping the label deletes that entire failure mode. +// - The provenance footer records the wrapped OpenSpec resolution +// (`project` vs `embedded`, plus version) — the single most useful field in +// a cospec bug report. +// Kept from upstream: the grapheme-aware 72-char title, the Summary/Details +// body, an array argv (never a shell — the message is free text), and exit 0 +// for the manual-submission fallback, which is not a failure. + +import { readFileSync } from 'node:fs' +import { platform } from 'node:os' +import { join } from 'node:path' + +import pkg from '../../package.json' +import type { CommandContext } from '../cli.ts' +import { EXIT } from '../cli.ts' +import { resolveOpenspec, spawnOpenspec } from '../core/openspec.ts' + +/** cospec's own tracker (`apps/cli/package.json` `bugs`). */ +export const COSPEC_REPO = 'aligned-team/cospec' +/** Upstream's tracker, hardcoded in `openspec feedback` itself. */ +export const UPSTREAM_REPO = 'Fission-AI/OpenSpec' + +const TITLE_PREFIX = 'Feedback: ' +const MAX_TITLE_LENGTH = 72 + +/** + * `Feedback: `, whitespace collapsed, truncated to 72 characters on a + * grapheme boundary (never mid-emoji) and then back to a word boundary, with an + * ellipsis. Mirrors upstream's `formatTitle` so a report filed either way reads + * the same. + */ +export function formatTitle(message: string): string { + const normalized = message.replaceAll(/\s+/g, ' ').trim() + const title = `${TITLE_PREFIX}${normalized}` + if ([...title].length <= MAX_TITLE_LENGTH) return title + + const available = MAX_TITLE_LENGTH - TITLE_PREFIX.length - 1 + let candidate = '' + let length = 0 + for (const { segment } of new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment( + normalized, + )) { + const size = [...segment].length + if (length + size > available) break + candidate += segment + length += size + } + candidate = candidate.trimEnd() + const lastSpace = candidate.lastIndexOf(' ') + return `${TITLE_PREFIX}${lastSpace > 0 ? candidate.slice(0, lastSpace) : candidate}…` +} + +/** Version of the resolved wrapped openspec package, or `unknown`. */ +function wrappedOpenspecDescription(): string { + const resolved = resolveOpenspec() + if (resolved.source === 'embedded') return `embedded ${resolved.version}` + try { + const raw = readFileSync(join(resolved.packageDir, 'package.json'), 'utf8') + const version = (JSON.parse(raw) as { version?: string }).version + return `project ${version ?? 'unknown'}` + } catch { + return 'project unknown' + } +} + +/** The provenance footer appended to every report. */ +export function provenanceFooter(now: Date, openspec: string): string { + return [ + '---', + 'Submitted via cospec', + `- cospec: ${pkg.version}`, + `- openspec: ${openspec}`, + `- Platform: ${platform()}`, + `- Timestamp: ${now.toISOString()}`, + ].join('\n') +} + +export function formatBody(message: string, details: string | undefined, footer: string): string { + const parts = ['## Summary', '', message] + if (details !== undefined && details.length > 0) parts.push('', '## Details', '', details) + parts.push('', footer) + return parts.join('\n') +} + +/** The prefilled issue URL used whenever `gh` cannot submit. No `labels`. */ +export function manualUrl(repo: string, title: string, body: string): string { + return `https://github.com/${repo}/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}` +} + +/** + * The `gh issue create` argv. Exported so a test can assert the raw message is + * carried as one array element — free text must never reach a shell. + */ +export function issueArgv(repo: string, title: string, body: string): string[] { + return ['issue', 'create', '--repo', repo, '--title', title, '--body', body] +} + +export interface ParsedFeedbackArgs { + message?: string + body?: string + upstream: boolean + error?: string +} + +/** Parse `cospec feedback` argv (pure). */ +export function parseFeedbackArgs(args: string[]): ParsedFeedbackArgs { + let message: string | undefined + let body: string | undefined + let upstream = false + for (let i = 0; i < args.length; i++) { + const tok = args[i]! + if (tok === '--upstream') upstream = true + else if (tok === '--body') { + const value = args[++i] + if (value === undefined) + return { upstream, error: 'cospec feedback: --body requires a value' } + body = value + } else if (tok.startsWith('--body=')) body = tok.slice('--body='.length) + else if (tok.startsWith('-')) + return { upstream, error: `cospec feedback: unknown option '${tok}'` } + else if (message === undefined) message = tok + else return { upstream, error: 'cospec feedback: only one message argument is accepted' } + } + if (message === undefined || message.trim().length === 0) + return { + upstream, + error: 'cospec feedback: a message is required (cospec feedback "")', + } + return { message, body, upstream } +} + +function printManualBlock(title: string, body: string, url: string): void { + process.stdout.write( + `\n--- FORMATTED FEEDBACK ---\nTitle: ${title}\n\nBody:\n${body}\n--- END FEEDBACK ---\n\n`, + ) + process.stdout.write(`Please submit your feedback manually:\n${url}\n`) +} + +function jsonEnvelope(body: Record): string { + return `${JSON.stringify(body)}\n` +} + +/** `--upstream`: version-asserted verbatim relay, exit code included. */ +async function runUpstream(ctx: CommandContext, parsed: ParsedFeedbackArgs): Promise { + if (ctx.flags.json) { + process.stdout.write( + jsonEnvelope({ + version: 1, + command: 'feedback', + submitted: false, + url: null, + title: null, + repo: UPSTREAM_REPO, + message: "--upstream relays OpenSpec's own text output and cannot emit JSON", + }), + ) + return EXIT.failure + } + process.stderr.write( + `note: filing at ${UPSTREAM_REPO} (OpenSpec's tracker), not ${COSPEC_REPO}.\n`, + ) + const args = ['feedback', parsed.message!] + if (parsed.body !== undefined) args.push('--body', parsed.body) + // Not `passthroughOpenspec`: upstream's feedback command exits with gh's own + // arbitrary status, which no `exitCodes` allow-list can honestly enumerate. + // So this is a version-asserted verbatim relay (the `workset open` rule), + // piped because upstream's feedback path has no prompts. + const result = await spawnOpenspec(args, ctx.cwd) + if (result.stdout.length > 0) process.stdout.write(result.stdout) + if (result.stderr.length > 0) process.stderr.write(result.stderr) + return result.exitCode +} + +export async function run(ctx: CommandContext): Promise { + const parsed = parseFeedbackArgs(ctx.args) + if (parsed.error !== undefined) { + if (ctx.flags.json) + process.stdout.write( + jsonEnvelope({ + version: 1, + command: 'feedback', + submitted: false, + url: null, + title: null, + repo: parsed.upstream ? UPSTREAM_REPO : COSPEC_REPO, + message: parsed.error, + }), + ) + else process.stderr.write(`${parsed.error}\n`) + return EXIT.failure + } + if (parsed.upstream) return runUpstream(ctx, parsed) + + const message = parsed.message! + const title = formatTitle(message) + const body = formatBody( + message, + parsed.body, + provenanceFooter(new Date(), wrappedOpenspecDescription()), + ) + const url = manualUrl(COSPEC_REPO, title, body) + + const gh = Bun.which('gh') + const authenticated = + gh !== null && + Bun.spawnSync([gh, 'auth', 'status'], { stdout: 'pipe', stderr: 'pipe' }).exitCode === 0 + + if (gh === null || !authenticated) { + // Manual submission is the documented fallback, not a failure: exit 0. + if (ctx.flags.json) { + process.stdout.write( + jsonEnvelope({ + version: 1, + command: 'feedback', + submitted: false, + url, + title, + repo: COSPEC_REPO, + }), + ) + return EXIT.success + } + process.stdout.write( + gh === null + ? 'GitHub CLI not found. Manual submission required.\n' + : 'GitHub authentication required. Manual submission required.\n', + ) + printManualBlock(title, body, url) + if (gh !== null) process.stdout.write('\nTo auto-submit in the future: gh auth login\n') + return EXIT.success + } + + const created = Bun.spawnSync([gh, ...issueArgv(COSPEC_REPO, title, body)], { + stdout: 'pipe', + stderr: 'pipe', + }) + if (created.exitCode !== 0) { + // gh failed after the user already typed their feedback (issues disabled, + // network, rate limit, …): relay gh's own stderr and its exit code, but + // still show the manual path rather than discarding the text. + if (ctx.flags.json) { + process.stdout.write( + jsonEnvelope({ + version: 1, + command: 'feedback', + submitted: false, + url, + title, + repo: COSPEC_REPO, + }), + ) + return created.exitCode === 0 ? EXIT.failure : created.exitCode + } + process.stderr.write(created.stderr.toString()) + printManualBlock(title, body, url) + return created.exitCode + } + + const issueUrl = created.stdout.toString().trim() + if (ctx.flags.json) { + process.stdout.write( + jsonEnvelope({ + version: 1, + command: 'feedback', + submitted: true, + url: issueUrl.length > 0 ? issueUrl : null, + title, + repo: COSPEC_REPO, + }), + ) + return EXIT.success + } + process.stdout.write(`\nFeedback submitted.\nIssue URL: ${issueUrl}\n`) + return EXIT.success +} diff --git a/apps/cli/src/core/completions/bash.ts b/apps/cli/src/core/completions/bash.ts new file mode 100644 index 0000000..5007d45 --- /dev/null +++ b/apps/cli/src/core/completions/bash.ts @@ -0,0 +1,102 @@ +// bash completion generator. Emits a single `_cospec` function plus its +// `complete -F` registration — no rc-file mutation, no installer (see the +// docs' copy-paste one-liner). Dynamic ids come from `cospec __complete`, +// which fails silently, so a Tab in a non-repo directory offers nothing rather +// than printing an error mid-completion. + +import type { CompletionSpec } from './spec.ts' + +/** Escape for a bash/zsh single-quoted string. */ +export function escapeSingleQuoted(value: string): string { + return value.replaceAll("'", String.raw`'\''`) +} + +function caseArm(name: string, body: string[]): string { + return [` ${name})`, ...body.map((line) => ` ${line}`), ' ;;'].join('\n') +} + +export function renderBashCompletion(spec: CompletionSpec): string { + const names = spec.commands.map((c) => c.name).join(' ') + const globals = spec.globalFlags.join(' ') + + const flagArms = spec.commands + .filter((c) => c.flags.length > 0) + .map((c) => caseArm(c.name, [`flags='${escapeSingleQuoted(c.flags.join(' '))}'`])) + .join('\n') + + const positionalArms = spec.commands + .filter((c) => c.positional.length > 0) + .map((c) => caseArm(c.name, [`sources='${c.positional.join(' ')}'`])) + .join('\n') + + const flagValueArms = spec.commands + .filter((c) => Object.keys(c.flagValues).length > 0) + .map((c) => + caseArm( + c.name, + Object.entries(c.flagValues).map( + (entry) => `[[ $prev == '${escapeSingleQuoted(entry[0])}' ]] && sources='${entry[1]}'`, + ), + ), + ) + .join('\n') + + return `# bash completion for cospec — generated by 'cospec completion bash'. +# Install: eval "$(cospec completion bash)" in ~/.bashrc + +_cospec_dynamic() { + cospec __complete "$1" 2>/dev/null | cut -f1 +} + +_cospec() { + local cur prev cmd i flags sources items + cur="\${COMP_WORDS[COMP_CWORD]}" + prev="" + [[ $COMP_CWORD -gt 0 ]] && prev="\${COMP_WORDS[COMP_CWORD-1]}" + local globals='${escapeSingleQuoted(globals)}' + local commands='${escapeSingleQuoted(names)}' + + cmd="" + for (( i = 1; i < COMP_CWORD; i++ )); do + case "\${COMP_WORDS[i]}" in + -*) ;; + *) cmd="\${COMP_WORDS[i]}"; break ;; + esac + done + + if [[ -z $cmd ]]; then + COMPREPLY=( $(compgen -W "$commands $globals" -- "$cur") ) + return 0 + fi + + sources="" + case "$cmd" in +${flagValueArms} + esac + + if [[ -z $sources && $cur == -* ]]; then + flags="" + case "$cmd" in +${flagArms} + esac + COMPREPLY=( $(compgen -W "$flags $globals" -- "$cur") ) + return 0 + fi + + if [[ -z $sources ]]; then + case "$cmd" in +${positionalArms} + esac + fi + + items="" + for i in $sources; do + items="$items $(_cospec_dynamic "$i")" + done + COMPREPLY=( $(compgen -W "$items" -- "$cur") ) + return 0 +} + +complete -F _cospec cospec +` +} diff --git a/apps/cli/src/core/completions/fish.ts b/apps/cli/src/core/completions/fish.ts new file mode 100644 index 0000000..47d643e --- /dev/null +++ b/apps/cli/src/core/completions/fish.ts @@ -0,0 +1,53 @@ +// fish completion generator. One `complete -c cospec` line per command, flag, +// and dynamic slot — fish's model needs no dispatch function of its own. + +import type { CompletionSpec } from './spec.ts' + +/** Escape for a fish single-quoted string (only `\` and `'` are special). */ +export function escapeFish(value: string): string { + return value.replaceAll('\\', '\\\\').replaceAll("'", String.raw`\'`) +} + +/** `--flag` → `-l flag`; `-x` → `-s x`. */ +function flagArg(flag: string): string { + return flag.startsWith('--') ? `-l ${flag.slice(2)}` : `-s ${flag.slice(1)}` +} + +function dynamicArg(sources: string[]): string { + return sources.map((source) => `(cospec __complete ${source} 2>/dev/null | cut -f1)`).join(' ') +} + +export function renderFishCompletion(spec: CompletionSpec): string { + const lines: string[] = [ + "# fish completion for cospec — generated by 'cospec completion fish'.", + '# Install: cospec completion fish > ~/.config/fish/completions/cospec.fish', + '', + '# No file completion by default; commands opt into dynamic ids below.', + 'complete -c cospec -f', + '', + ] + + for (const flag of spec.globalFlags) lines.push(`complete -c cospec ${flagArg(flag)}`) + lines.push('') + + for (const command of spec.commands) { + lines.push( + `complete -c cospec -n __fish_use_subcommand -a ${command.name} -d '${escapeFish(command.summary)}'`, + ) + } + lines.push('') + + for (const command of spec.commands) { + const seen = `-n '__fish_seen_subcommand_from ${command.name}'` + for (const flag of command.flags) { + const source = command.flagValues[flag] + const value = source === undefined ? '' : ` -x -a "${dynamicArg([source])}"` + lines.push(`complete -c cospec ${seen} ${flagArg(flag)}${value}`) + } + if (command.positional.length > 0) + lines.push(`complete -c cospec ${seen} -a "${dynamicArg(command.positional)}"`) + } + lines.push('') + + return lines.join('\n') +} diff --git a/apps/cli/src/core/completions/spec.ts b/apps/cli/src/core/completions/spec.ts new file mode 100644 index 0000000..3db3f3d --- /dev/null +++ b/apps/cli/src/core/completions/spec.ts @@ -0,0 +1,95 @@ +// The shell-agnostic completion model. cospec generates completions from its +// OWN static command table (`cli.ts`'s `COMMANDS` + `GLOBAL_OPTIONS`) rather +// than passing `openspec completion` through: upstream's generator is driven by +// openspec's registry and its installer writes a completion function for the +// `openspec` binary — shipping that from cospec would write a permanent +// instruction to run bare `openspec` into the user's dotfiles, which is exactly +// what the routing discipline forbids. +// +// Flags are extracted from each table entry's pre-formatted `options` help +// block. That extraction is the one fragile part of this module, so it is a +// pure exported function with a snapshot unit test: a table row the extractor +// cannot parse fails CI rather than silently shrinking completion. + +import { COMMANDS, GLOBAL_OPTIONS } from '../../cli.ts' + +/** A completion source resolved at Tab time by the hidden `cospec __complete`. */ +export type DynamicSource = 'changes' | 'specs' | 'types' + +export interface CompletionCommand { + name: string + summary: string + /** Every `--flag` / `-x` the command's own help block declares. */ + flags: string[] + /** Sources completing this command's positional argument, in order. */ + positional: DynamicSource[] + /** Flags whose VALUE is dynamically completed (e.g. `--change `). */ + flagValues: Record +} + +export interface CompletionSpec { + commands: CompletionCommand[] + globalFlags: string[] +} + +/** Positional argument sources, per command (`cli.ts` `usage` positionals). */ +const POSITIONAL: Record = { + new: ['types'], + migrate: ['changes'], + validate: ['changes'], + status: ['changes'], + apply: ['changes'], + archive: ['changes'], + show: ['changes', 'specs'], +} + +/** Flags whose value is a dynamic id, per command. */ +const FLAG_VALUES: Record> = { + status: { '--change': 'changes' }, + instructions: { '--change': 'changes' }, + 'sync-blockers': { '--change': 'changes' }, +} + +// A leading option token in a help line: `--flag`, `-x`, optionally followed by +// a ``/`[value]` placeholder and a `, ` separator before the next alias. +const LEADING_FLAG = /^[ \t]*(-{1,2}[A-Za-z][A-Za-z0-9-]*)(?:[ \t]*(?:<[^>]*>|\[[^\]]*\]))?(?:,)?/ + +/** + * Extract the option tokens from a pre-formatted help block (`CommandEntry.options` + * or `GLOBAL_OPTIONS`). Only the run of option tokens at the START of a line is + * taken, so a description mentioning a flag is never mistaken for one, and a + * continuation line that opens with prose (`artifacts: proposal, …`) contributes + * nothing. Pure. + */ +export function extractFlags(options: string | undefined): string[] { + if (options === undefined) return [] + const found: string[] = [] + for (const line of options.split('\n')) { + let rest = line + for (;;) { + const match = LEADING_FLAG.exec(rest) + if (match === null) break + const flag = match[1]! + if (!found.includes(flag)) found.push(flag) + rest = rest.slice(match[0].length) + } + } + return found +} + +/** + * Build the completion model from cospec's own command table. Hidden commands + * (`check-commit`, `__complete`) are filtered out — they are entrypoints for + * hooks and for completion itself, not things a user tabs to. + */ +export function buildCompletionSpec(): CompletionSpec { + const globalFlags = [...extractFlags(GLOBAL_OPTIONS), '-V', '--version'] + const commands = COMMANDS.filter((entry) => entry.hidden !== true).map((entry) => ({ + name: entry.name, + summary: entry.summary, + flags: extractFlags(entry.options), + positional: POSITIONAL[entry.name] ?? [], + flagValues: FLAG_VALUES[entry.name] ?? {}, + })) + return { commands, globalFlags } +} diff --git a/apps/cli/src/core/completions/zsh.ts b/apps/cli/src/core/completions/zsh.ts new file mode 100644 index 0000000..c1cb61d --- /dev/null +++ b/apps/cli/src/core/completions/zsh.ts @@ -0,0 +1,105 @@ +// zsh completion generator. Hand-rolled `compadd`/`_describe` rather than an +// `_arguments` spec: the command table is flat (one subcommand level, a +// pre-formatted flag list, at most one dynamic positional), and a hand-rolled +// function is far easier to keep parseable under `zsh -n`. + +import { escapeSingleQuoted } from './bash.ts' +import type { CompletionSpec } from './spec.ts' + +/** `_describe` entries are `name:description`, so a literal colon must escape. */ +function describeEntry(name: string, summary: string): string { + return `'${escapeSingleQuoted(name)}:${escapeSingleQuoted(summary.replaceAll(':', String.raw`\:`))}'` +} + +function caseArm(name: string, body: string[]): string { + return [` ${name})`, ...body.map((line) => ` ${line}`), ' ;;'].join('\n') +} + +export function renderZshCompletion(spec: CompletionSpec): string { + const commands = spec.commands.map((c) => ` ${describeEntry(c.name, c.summary)}`).join('\n') + const globals = spec.globalFlags.map((f) => `'${escapeSingleQuoted(f)}'`).join(' ') + + const flagArms = spec.commands + .filter((c) => c.flags.length > 0) + .map((c) => + caseArm(c.name, [`flags=(${c.flags.map((f) => `'${escapeSingleQuoted(f)}'`).join(' ')})`]), + ) + .join('\n') + + const positionalArms = spec.commands + .filter((c) => c.positional.length > 0) + .map((c) => + caseArm( + c.name, + c.positional.map((source) => `_cospec_dynamic ${source}`), + ), + ) + .join('\n') + + const flagValueArms = spec.commands + .filter((c) => Object.keys(c.flagValues).length > 0) + .map((c) => + caseArm( + c.name, + Object.entries(c.flagValues).map( + (entry) => + `[[ $prev == '${escapeSingleQuoted(entry[0])}' ]] && { _cospec_dynamic ${entry[1]}; return }`, + ), + ), + ) + .join('\n') + + return `#compdef cospec +# zsh completion for cospec — generated by 'cospec completion zsh'. +# Install: cospec completion zsh > ~/.zsh/completions/_cospec (dir on $fpath) + +_cospec_dynamic() { + local -a items + items=(\${(f)"$(cospec __complete $1 2>/dev/null | cut -f1)"}) + (( \${#items} )) && compadd -a items +} + +_cospec() { + local -a commands global_flags flags + commands=( +${commands} + ) + global_flags=(${globals}) + + local cmd='' prev='' i + for (( i = 2; i < CURRENT; i++ )); do + case \${words[i]} in + -*) ;; + *) cmd=\${words[i]}; break ;; + esac + done + (( CURRENT > 1 )) && prev=\${words[CURRENT-1]} + + if [[ -z $cmd ]]; then + _describe -t commands 'cospec command' commands + compadd -a global_flags + return + fi + + case $cmd in +${flagValueArms} + esac + + if [[ \${words[CURRENT]} == -* ]]; then + flags=() + case $cmd in +${flagArms} + esac + compadd -a flags + compadd -a global_flags + return + fi + + case $cmd in +${positionalArms} + esac +} + +_cospec "$@" +` +} diff --git a/apps/cli/src/harness/adapters.ts b/apps/cli/src/harness/adapters.ts index 299bca3..a999efc 100644 --- a/apps/cli/src/harness/adapters.ts +++ b/apps/cli/src/harness/adapters.ts @@ -124,6 +124,15 @@ export function renderCodexRules(version: string): string { ['cospec', 'sync-blockers', '--check'], ['cospec', 'new'], ['cospec', 'doctor'], + // Read-only config reads and the completion sources. `config set|unset| + // reset|edit|profile` mutate machine-global state and `feedback` files a + // public issue over the network, so neither is pre-approved — the same + // reasoning that keeps `archive` off this list. + ['cospec', 'config', 'get'], + ['cospec', 'config', 'list'], + ['cospec', 'config', 'path'], + ['cospec', 'completion'], + ['cospec', '__complete'], ] const lines = allow.map( (pattern) => diff --git a/apps/cli/test/contract/config-surface.test.ts b/apps/cli/test/contract/config-surface.test.ts new file mode 100644 index 0000000..72d220c --- /dev/null +++ b/apps/cli/test/contract/config-surface.test.ts @@ -0,0 +1,159 @@ +// `cospec config` against the real pinned openspec binary (ledger row 1.5, +// tasks.md 6.2). `XDG_CONFIG_HOME` AND `HOME` are sandboxed into a fresh temp +// dir per test so this suite never reads or writes the developer's real +// machine-global OpenSpec config. +// +// Trailing `--no-color` finding (probed directly against the pinned 1.11.0 +// binary via `openspec()` below, bypassing cospec entirely): a trailing +// `--no-color` on every `config` subcommand — `path`, `get`, `set`, `unset`, +// `list`, `reset --all -y` — is ACCEPTED (exit 0/1 exactly as without it, +// never a "unknown option" error), because `--no-color` is declared on the +// root `Command` and commander resolves a parent option from a leaf +// regardless of whether that leaf's own `--help` lists it. This confirms +// `apps/cli/src/commands/config.ts`'s header comment (point 3) and +// contradicts the change's own `verification.md` row 1.5, `design.md`'s +// decision text, `proposal.md`, and `specs/openspec-config-passthrough/spec.md`, +// all of which assert the opposite (a REJECTED trailing `--no-color`) as the +// premise for `tasks.md` 7.6's proposed follow-up `fix` change. This suite +// asserts the real, observed behavior — acceptance — and documents the +// discrepancy rather than encoding a false expectation. (cospec's own argv +// builder still never appends `--no-color`, per the same header: not to dodge +// a rejection that turns out not to exist, but so the built argv carries +// nothing the wrapped call did not need.) + +import { afterAll, describe, expect, test } from 'bun:test' +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' + +import { cleanupAll, mkTempRepo, openspec } from '../fixtures/support.ts' + +afterAll(cleanupAll) + +function sandbox(): { cwd: string; env: Record } { + const workspace = mkTempRepo() + const xdg = join(workspace, 'xdg') + const home = join(workspace, 'home') + mkdirSync(xdg, { recursive: true }) + mkdirSync(home, { recursive: true }) + return { + cwd: workspace, + env: { XDG_CONFIG_HOME: xdg, HOME: home, OPENSPEC_TELEMETRY: '0' }, + } +} + +describe('real pinned binary: a trailing --no-color on config subcommands', () => { + test('config path --no-color exits 0, same as without it', async () => { + const { cwd, env } = sandbox() + const withFlag = await openspec(['config', 'path', '--no-color'], cwd, env) + const without = await openspec(['config', 'path'], cwd, env) + expect(withFlag.exitCode).toBe(0) + expect(withFlag.stdout).toBe(without.stdout) + }) + + test('config get --no-color exits 0 and prints the value unchanged', async () => { + const { cwd, env } = sandbox() + await openspec(['config', 'set', 'defaultStore', 'probe-store'], cwd, env) + const res = await openspec(['config', 'get', 'defaultStore', '--no-color'], cwd, env) + expect(res.exitCode).toBe(0) + expect(res.stdout.trim()).toBe('probe-store') + expect(res.stderr.trim()).toBe('') + }) + + test('config set --no-color exits 0 and the value round-trips', async () => { + const { cwd, env } = sandbox() + const set = await openspec(['config', 'set', 'defaultStore', 'bar', '--no-color'], cwd, env) + expect(set.exitCode).toBe(0) + const got = await openspec(['config', 'get', 'defaultStore'], cwd, env) + expect(got.stdout.trim()).toBe('bar') + }) + + test('config unset --no-color exits 0', async () => { + const { cwd, env } = sandbox() + await openspec(['config', 'set', 'defaultStore', 'baz'], cwd, env) + const res = await openspec(['config', 'unset', 'defaultStore', '--no-color'], cwd, env) + expect(res.exitCode).toBe(0) + }) + + test('config list --no-color exits 0 and prints the same text listing', async () => { + const { cwd, env } = sandbox() + const res = await openspec(['config', 'list', '--no-color'], cwd, env) + expect(res.exitCode).toBe(0) + expect(res.stdout).toContain('profile:') + }) + + test('config reset --all -y --no-color exits 0', async () => { + const { cwd, env } = sandbox() + await openspec(['config', 'set', 'defaultStore', 'to-reset'], cwd, env) + const res = await openspec(['config', 'reset', '--all', '-y', '--no-color'], cwd, env) + expect(res.exitCode).toBe(0) + }) + + test('a genuinely unknown option is still rejected (control: --no-color is not special-cased)', async () => { + const { cwd, env } = sandbox() + const res = await openspec(['config', 'get', 'defaultStore', '--bogus-flag-xyz'], cwd, env) + expect(res.exitCode).not.toBe(0) + expect(res.stderr).toContain('unknown option') + }) +}) + +describe('real pinned binary: config surface shape cospec depends on', () => { + test('an unset key: exit 1, empty stdout, empty stderr', async () => { + const { cwd, env } = sandbox() + const res = await openspec(['config', 'get', 'defaultStore'], cwd, env) + expect(res.exitCode).toBe(1) + expect(res.stdout.trim()).toBe('') + }) + + test('list --json is exactly one parseable document carrying profile and delivery', async () => { + // Upstream pretty-prints this document (multi-line, indented) rather than + // emitting it on one line, so "exactly one document" is verified by + // parsing the whole trimmed stdout, not by counting lines. + const { cwd, env } = sandbox() + const res = await openspec(['config', 'list', '--json'], cwd, env) + expect(res.exitCode).toBe(0) + const body = JSON.parse(res.stdout.trim()) as { profile: string; delivery: string } + expect(typeof body.profile).toBe('string') + expect(typeof body.delivery).toBe('string') + }) + + test('path/get/set/unset/reset reject --json outright (cospec must synthesize its own envelope)', async () => { + const { cwd, env } = sandbox() + for (const args of [ + ['config', 'path', '--json'], + ['config', 'get', 'defaultStore', '--json'], + ['config', 'set', 'defaultStore', 'x', '--json'], + ]) { + const res = await openspec(args, cwd, env) + expect(res.exitCode).toBe(1) + expect(res.stderr.toLowerCase()).toContain('unknown option') + } + }) + + test('--store is rejected as an unknown option on config (no store dimension)', async () => { + const { cwd, env } = sandbox() + const res = await openspec(['config', '--store', 'x', 'list'], cwd, env) + expect(res.exitCode).not.toBe(0) + expect(res.stderr.toLowerCase()).toContain('unknown option') + }) + + test('--scope global is accepted; a non-global scope is upstream\'s own "not implemented" error', async () => { + const { cwd, env } = sandbox() + const global = await openspec(['config', '--scope', 'global', 'list', '--json'], cwd, env) + expect(global.exitCode).toBe(0) + const project = await openspec(['config', '--scope', 'project', 'list'], cwd, env) + expect(project.exitCode).not.toBe(0) + expect(project.stderr.toLowerCase()).toContain('not yet implemented') + }) + + test('edit with EDITOR=true spawns and exits 0 with inherited stdio (handover contract)', async () => { + const { cwd, env } = sandbox() + const res = await openspec(['config', 'edit'], cwd, { ...env, EDITOR: 'true' }) + expect(res.exitCode).toBe(0) + }, 15_000) + + test('profile with no preset and no TTY relays the interactive-mode-required error', async () => { + const { cwd, env } = sandbox() + const res = await openspec(['config', 'profile'], cwd, env) + expect(res.exitCode).not.toBe(0) + }) +}) diff --git a/apps/cli/test/integration/completion.test.ts b/apps/cli/test/integration/completion.test.ts new file mode 100644 index 0000000..f2db3b6 --- /dev/null +++ b/apps/cli/test/integration/completion.test.ts @@ -0,0 +1,182 @@ +// `cospec completion [bash|zsh|fish]` and the hidden `cospec __complete` (DESIGN +// §2, ledger rows 2.2–2.4). Generation is exercised through the real CLI +// entrypoint (never by importing the render functions directly) so this proves +// the wired-up command, not just the pure generator (that lives in +// test/unit/core/completions.test.ts). +// +// Row 2.2 (parses clean under each real shell's syntax checker) skips a shell +// that is not installed on the box running the suite rather than failing — +// `mise run check` must stay green on a minimal CI image. + +import { afterAll, describe, expect, test } from 'bun:test' + +import { cleanupAll, cospec, mkTempRepo, writeFiles } from '../fixtures/support.ts' +import { authorCi } from './support.ts' + +const LIVING_SPEC = `# widgets Specification + +## Purpose + +Real purpose text for the widgets capability. + +## Requirements + +### Requirement: Widget rendering + +The system SHALL render a widget when requested. + +#### Scenario: Render a widget + +- **WHEN** a caller requests a widget +- **THEN** a widget is rendered +` + +afterAll(cleanupAll) + +describe('cospec completion', () => { + test('detects the shell from $SHELL when none is given explicitly', async () => { + const cwd = mkTempRepo() + const res = await cospec(['completion'], { cwd, env: { SHELL: '/bin/zsh' } }) + expect(res.exitCode).toBe(0) + expect(res.stdout).toContain('#compdef cospec') + }) + + test('a login-shell leading dash in $SHELL is stripped before detection', async () => { + const cwd = mkTempRepo() + const res = await cospec(['completion'], { cwd, env: { SHELL: '-/bin/bash' } }) + expect(res.exitCode).toBe(0) + expect(res.stdout).toContain('_cospec()') + expect(res.stdout).toContain('complete -F _cospec cospec') + }) + + test('an undetected/unsupported $SHELL exits 1 naming the supported shells, no stdout', async () => { + const cwd = mkTempRepo() + const res = await cospec(['completion'], { cwd, env: { SHELL: '/bin/tcsh' } }) + expect(res.exitCode).toBe(1) + expect(res.stdout).toBe('') + expect(res.stderr).toContain('bash') + expect(res.stderr).toContain('zsh') + expect(res.stderr).toContain('fish') + }) + + test('an explicit shell argument overrides $SHELL entirely', async () => { + const cwd = mkTempRepo() + const res = await cospec(['completion', 'fish'], { cwd, env: { SHELL: '/bin/zsh' } }) + expect(res.exitCode).toBe(0) + expect(res.stdout).toContain('complete -c cospec -f') + }) + + test('an unsupported explicit shell exits 1 without touching $SHELL detection', async () => { + const cwd = mkTempRepo() + const res = await cospec(['completion', 'powershell'], { cwd }) + expect(res.exitCode).toBe(1) + expect(res.stderr).toContain("unsupported shell 'powershell'") + }) + + test('--json is refused with exactly one JSON document on stdout, exit 1', async () => { + const cwd = mkTempRepo() + const res = await cospec(['completion', 'zsh', '--json'], { cwd }) + expect(res.exitCode).toBe(1) + const lines = res.stdout.trim().split('\n') + expect(lines.length).toBe(1) + const body = JSON.parse(lines[0]!) as { ok: boolean; command: string } + expect(body.ok).toBe(false) + expect(body.command).toBe('completion') + }) + + test('bash/zsh/fish scripts each name every non-hidden command', async () => { + const cwd = mkTempRepo() + for (const shell of ['bash', 'zsh', 'fish']) { + const res = await cospec(['completion', shell], { cwd }) + expect(res.exitCode).toBe(0) + for (const name of ['init', 'new', 'validate', 'apply', 'archive', 'config', 'feedback']) + expect(res.stdout).toContain(name) + } + }) + + for (const [shell, checker] of [ + [ + 'bash', + (script: string) => + Bun.spawnSync(['bash', '-n'], { stdin: new TextEncoder().encode(script) }), + ], + [ + 'zsh', + (script: string) => Bun.spawnSync(['zsh', '-n'], { stdin: new TextEncoder().encode(script) }), + ], + [ + 'fish', + (script: string) => + Bun.spawnSync(['fish', '--no-execute'], { stdin: new TextEncoder().encode(script) }), + ], + ] as const) { + test(`generated ${shell} script parses clean under ${shell}'s own syntax checker`, async () => { + if (Bun.which(shell) === null) { + console.warn(`${shell} not installed — skipping syntax check`) + return + } + const cwd = mkTempRepo() + const res = await cospec(['completion', shell], { cwd }) + expect(res.exitCode).toBe(0) + const parsed = checker(res.stdout) + expect(parsed.exitCode, new TextDecoder().decode(parsed.stderr)).toBe(0) + }) + } +}) + +describe('cospec __complete (hidden dynamic completion source)', () => { + test('changes: lists active change ids tab-separated, inside a seeded repo', async () => { + const cwd = mkTempRepo({ fixture: 'fresh', git: true }) + authorCi(cwd, 'demo-change') + const res = await cospec(['__complete', 'changes'], { cwd }) + expect(res.exitCode).toBe(0) + expect(res.stderr).toBe('') + const line = res.stdout.split('\n').find((l) => l.startsWith('demo-change')) + expect(line).toBeDefined() + expect(line).toContain('\t') + }) + + test('specs: lists capability spec ids tab-separated, inside a seeded repo', async () => { + const cwd = mkTempRepo({ fixture: 'fresh', git: true }) + writeFiles(cwd, { 'openspec/specs/widgets/spec.md': LIVING_SPEC }) + const res = await cospec(['__complete', 'specs'], { cwd }) + expect(res.exitCode).toBe(0) + expect(res.stderr).toBe('') + const line = res.stdout.split('\n').find((l) => l.startsWith('widgets')) + expect(line).toBeDefined() + expect(line).toContain('\t') + expect(line).toContain('requirement') + }) + + test('types: lists the 11 conventional-commit types with no wrapped spawn required', async () => { + const cwd = mkTempRepo() + const res = await cospec(['__complete', 'types'], { cwd }) + expect(res.exitCode).toBe(0) + for (const type of ['feat', 'fix', 'chore', 'docs', 'refactor']) + expect(res.stdout).toContain(type) + }) + + test('outside any openspec repo: silent exit 1, nothing on either stream', async () => { + const cwd = mkTempRepo() + const res = await cospec(['__complete', 'changes'], { cwd }) + expect(res.exitCode).toBe(1) + expect(res.stdout).toBe('') + expect(res.stderr).toBe('') + }) + + test('an unrecognized source is a silent exit 1 too', async () => { + const cwd = mkTempRepo() + const res = await cospec(['__complete', 'bogus'], { cwd }) + expect(res.exitCode).toBe(1) + expect(res.stdout).toBe('') + expect(res.stderr).toBe('') + }) + + test('no source at all is a silent exit 1', async () => { + const cwd = mkTempRepo() + const res = await cospec(['__complete'], { cwd }) + expect(res.exitCode).toBe(1) + expect(res.stdout).toBe('') + expect(res.stderr).toBe('') + }) +}) diff --git a/apps/cli/test/integration/config.test.ts b/apps/cli/test/integration/config.test.ts new file mode 100644 index 0000000..944c1c0 --- /dev/null +++ b/apps/cli/test/integration/config.test.ts @@ -0,0 +1,210 @@ +// `cospec config ` (DESIGN §1, ledger rows 1.3–1.6, 1.8). `XDG_CONFIG_HOME` +// is sandboxed per test so this suite never reads or writes the developer's +// real machine-global OpenSpec config (`~/.config/openspec/config.json`). + +import { afterAll, describe, expect, test } from 'bun:test' +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' + +import { cleanupAll, cospec, mkTempRepo } from '../fixtures/support.ts' + +afterAll(cleanupAll) + +function sandbox(): { cwd: string; env: Record } { + const workspace = mkTempRepo() + const xdg = join(workspace, 'xdg') + mkdirSync(xdg, { recursive: true }) + return { cwd: workspace, env: { XDG_CONFIG_HOME: xdg, OPENSPEC_TELEMETRY: '0' } } +} + +describe('cospec config path/list/get (Class A, piped)', () => { + test('path prints the machine-global config.json path', async () => { + const { cwd, env } = sandbox() + const res = await cospec(['config', 'path'], { cwd, env }) + expect(res.exitCode).toBe(0) + expect(res.stdout.trim()).toContain(join('openspec', 'config.json')) + }) + + test('list --json is one parseable document containing profile and delivery', async () => { + const { cwd, env } = sandbox() + const res = await cospec(['config', 'list', '--json'], { cwd, env }) + expect(res.exitCode).toBe(0) + const body = JSON.parse(res.stdout) as { profile: string; delivery: string } + expect(typeof body.profile).toBe('string') + expect(typeof body.delivery).toBe('string') + }) + + test('get on an unset key: exit 1, empty stdout (text mode)', async () => { + const { cwd, env } = sandbox() + const res = await cospec(['config', 'get', 'defaultStore'], { cwd, env }) + expect(res.exitCode).toBe(1) + expect(res.stdout.trim()).toBe('') + }) + + test('get on an unset key with --json: found:false, value:null, exit 1, one document', async () => { + const { cwd, env } = sandbox() + const res = await cospec(['config', 'get', 'defaultStore', '--json'], { cwd, env }) + expect(res.exitCode).toBe(1) + const lines = res.stdout.trim().split('\n') + expect(lines.length).toBe(1) + const body = JSON.parse(lines[0]!) as { + version: number + command: string + key: string + value: unknown + found: boolean + } + expect(body).toEqual({ + version: 1, + command: 'config get', + key: 'defaultStore', + value: null, + found: false, + }) + }) + + test('set then get round-trips a scalar value as a raw string', async () => { + const { cwd, env } = sandbox() + const set = await cospec(['config', 'set', 'defaultStore', 'some-store'], { cwd, env }) + expect(set.exitCode).toBe(0) + + const got = await cospec(['config', 'get', 'defaultStore', '--json'], { cwd, env }) + expect(got.exitCode).toBe(0) + const body = JSON.parse(got.stdout) as { value: string; found: boolean } + expect(body.found).toBe(true) + expect(body.value).toBe('some-store') + + const listed = await cospec(['config', 'list', '--json'], { cwd, env }) + const listBody = JSON.parse(listed.stdout) as { defaultStore: string } + expect(listBody.defaultStore).toBe('some-store') + }) + + test('unset removes a previously-set key', async () => { + const { cwd, env } = sandbox() + await cospec(['config', 'set', 'defaultStore', 'temp-store'], { cwd, env }) + const unset = await cospec(['config', 'unset', 'defaultStore'], { cwd, env }) + expect(unset.exitCode).toBe(0) + + const got = await cospec(['config', 'get', 'defaultStore', '--json'], { cwd, env }) + expect(got.exitCode).toBe(1) + expect((JSON.parse(got.stdout) as { found: boolean }).found).toBe(false) + }) +}) + +describe('cospec config — the two precedence notes', () => { + test('set telemetry.enabled true succeeds AND prints the forced-env note on stderr', async () => { + const { cwd, env } = sandbox() + const res = await cospec(['config', 'set', 'telemetry.enabled', 'true'], { cwd, env }) + expect(res.exitCode).toBe(0) + expect(res.stderr).toContain('OPENSPEC_TELEMETRY=0') + expect(res.stderr).toContain("bare 'openspec' runs only") + }) + + test('--json stdout stays exactly one document even though a note went to stderr', async () => { + const { cwd, env } = sandbox() + const res = await cospec(['config', 'set', 'telemetry.enabled', 'true', '--json'], { + cwd, + env, + }) + expect(res.exitCode).toBe(0) + const lines = res.stdout.trim().split('\n') + expect(lines.length).toBe(1) + expect(JSON.parse(lines[0]!).ok).toBe(true) + expect(res.stderr).toContain('OPENSPEC_TELEMETRY=0') + }) + + test('set profile prints the harness-canon note', async () => { + const { cwd, env } = sandbox() + const res = await cospec(['config', 'set', 'profile', 'core'], { cwd, env }) + expect(res.exitCode).toBe(0) + expect(res.stderr).toContain('cospec update') + expect(res.stderr).toContain("not 'openspec update'") + }) + + test('an unrelated key gets no notes on stderr', async () => { + const { cwd, env } = sandbox() + const res = await cospec(['config', 'set', 'defaultStore', 'x'], { cwd, env }) + expect(res.exitCode).toBe(0) + expect(res.stderr.trim()).toBe('') + }) + + test('a failed mutation prints no note (notes are success-only)', async () => { + const { cwd, env } = sandbox() + const res = await cospec(['config', 'set', 'telemetry.enabled', 'not-a-bool'], { cwd, env }) + expect(res.exitCode).toBe(1) + expect(res.stderr).not.toContain('OPENSPEC_TELEMETRY=0') + }) +}) + +describe('cospec config — --store never applies (machine-global, no store dimension)', () => { + test('--store x config list exits 1 with the named message; no wrapped spawn', async () => { + const { cwd, env } = sandbox() + const res = await cospec(['config', '--store', 'x', 'list'], { cwd, env }) + expect(res.exitCode).toBe(1) + expect(res.stderr).toContain('--store does not apply') + expect(res.stderr).toContain('OpenSpec config is machine-global') + expect(res.stderr).toContain('--scope global') + }) +}) + +describe('cospec config — --scope hoisting through the real wrapped binary', () => { + test('--scope global is accepted and behaves like the default (real binary)', async () => { + const { cwd, env } = sandbox() + const res = await cospec(['config', '--scope', 'global', 'list', '--json'], { cwd, env }) + expect(res.exitCode).toBe(0) + expect(() => JSON.parse(res.stdout)).not.toThrow() + }) + + test('a non-global scope relays upstream\'s own "not yet implemented" error verbatim', async () => { + const { cwd, env } = sandbox() + const res = await cospec(['config', '--scope', 'project', 'list'], { cwd, env }) + expect(res.exitCode).toBe(1) + expect(res.stderr.toLowerCase()).toContain('not yet implemented') + }) +}) + +describe('cospec config — usage errors', () => { + test('no subcommand is a usage error listing all eight subcommands', async () => { + const { cwd, env } = sandbox() + const res = await cospec(['config'], { cwd, env }) + expect(res.exitCode).toBe(1) + for (const sub of ['path', 'list', 'get', 'set', 'unset', 'reset', 'edit', 'profile']) + expect(res.stderr).toContain(sub) + }) + + test('an unknown subcommand is a usage error, not a wrapped spawn', async () => { + const { cwd, env } = sandbox() + const res = await cospec(['config', 'frobnicate'], { cwd, env }) + expect(res.exitCode).toBe(1) + expect(res.stderr).toContain("unknown subcommand 'frobnicate'") + }) +}) + +describe('cospec config edit / reset (Class B, terminal handover)', () => { + test('edit with EDITOR=true opens and returns 0 (no file mutation needed to succeed)', async () => { + const { cwd, env } = sandbox() + const res = await cospec(['config', 'edit'], { cwd, env: { ...env, EDITOR: 'true' } }) + expect(res.exitCode).toBe(0) + }, 15_000) + + test('edit --json is refused as interactive-and-cannot-emit-JSON, exit 1, one document', async () => { + const { cwd, env } = sandbox() + const res = await cospec(['config', 'edit', '--json'], { cwd, env: { ...env, EDITOR: 'true' } }) + expect(res.exitCode).toBe(1) + const lines = res.stdout.trim().split('\n') + expect(lines.length).toBe(1) + const body = JSON.parse(lines[0]!) as { ok: boolean; message: string } + expect(body.ok).toBe(false) + expect(body.message).toContain('interactive') + }) + + test('reset --all -y is piped (Class A), not a handover, and succeeds', async () => { + const { cwd, env } = sandbox() + await cospec(['config', 'set', 'defaultStore', 'to-be-reset'], { cwd, env }) + const res = await cospec(['config', 'reset', '--all', '-y'], { cwd, env }) + expect(res.exitCode).toBe(0) + + const got = await cospec(['config', 'get', 'defaultStore', '--json'], { cwd, env }) + expect((JSON.parse(got.stdout) as { found: boolean }).found).toBe(false) + }) +}) diff --git a/apps/cli/test/integration/feedback.test.ts b/apps/cli/test/integration/feedback.test.ts new file mode 100644 index 0000000..7c23cf9 --- /dev/null +++ b/apps/cli/test/integration/feedback.test.ts @@ -0,0 +1,212 @@ +// `cospec feedback` (DESIGN §3, ledger rows 3.2–3.4). `gh` is stubbed on PATH +// as a tiny shell script — NEVER the real `gh`, so this suite never files a +// real issue or touches the network. Each stub records the argv it was called +// with to a file so the test can assert cospec built the right command. + +import { afterAll, describe, expect, test } from 'bun:test' +import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs' +import { delimiter, join } from 'node:path' + +import { cleanupAll, cospec, mkTempRepo } from '../fixtures/support.ts' + +afterAll(cleanupAll) + +interface GhStubOpts { + /** exit code of `gh auth status` (0 = authenticated). */ + authExit?: number + /** exit code of `gh issue create`. */ + createExit?: number + /** stdout `gh issue create` prints on success (an issue URL). */ + createStdout?: string + /** stderr `gh issue create` prints on failure. */ + createStderr?: string +} + +/** + * Build a fake `gh` on a fresh dir, prepended onto the REAL PATH — so `bun` + * (the child process cospec is spawned with) and every other tool stay + * resolvable, and only `gh` resolution is redirected to the stub. Every + * invocation's argv is logged to a file the test can inspect. + */ +function stubGh(opts: GhStubOpts = {}): { path: string; logFile: string } { + const dir = mkTempRepo() + const logFile = join(dir, 'gh.log') + const authExit = opts.authExit ?? 0 + const createExit = opts.createExit ?? 0 + const createStdout = opts.createStdout ?? 'https://github.com/aligned-team/cospec/issues/99' + const createStderr = opts.createStderr ?? '' + const script = `#!/bin/sh +echo "$@" >> "${logFile}" +if [ "$1" = "auth" ] && [ "$2" = "status" ]; then + exit ${authExit} +fi +if [ "$1" = "issue" ] && [ "$2" = "create" ]; then + printf '%s' ${JSON.stringify(createStderr)} 1>&2 + printf '%s' ${JSON.stringify(createStdout)} + exit ${createExit} +fi +exit 1 +` + const bin = join(dir, 'gh') + writeFileSync(bin, script) + chmodSync(bin, 0o755) + return { path: `${dir}${delimiter}${process.env.PATH ?? ''}`, logFile } +} + +/** + * The real PATH with every directory that resolves a real `gh` stripped out — + * `bun`'s own directory (and everything else) stays, so the child process + * still spawns; only `gh` genuinely disappears from PATH. + */ +function noGhPath(): string { + const kept = (process.env.PATH ?? '') + .split(delimiter) + .filter((p) => p.length > 0 && Bun.which('gh', { PATH: p }) === null) + return kept.join(delimiter) +} + +describe('cospec feedback (native, aligned-team/cospec)', () => { + test('gh absent from PATH: manual block + prefilled URL, exit 0', async () => { + const cwd = mkTempRepo() + const res = await cospec(['feedback', 'the thing broke'], { cwd, env: { PATH: noGhPath() } }) + expect(res.exitCode).toBe(0) + expect(res.stdout).toContain('GitHub CLI not found') + expect(res.stdout).toContain('FORMATTED FEEDBACK') + expect(res.stdout).toContain('Title: Feedback: the thing broke') + expect(res.stdout).toContain('https://github.com/aligned-team/cospec/issues/new?title=') + expect(res.stdout).not.toContain('Fission-AI') + }) + + test('gh present but unauthenticated: manual block, exit 0, gh is never asked to create', async () => { + const { path, logFile } = stubGh({ authExit: 1 }) + const cwd = mkTempRepo() + const res = await cospec(['feedback', 'auth broke'], { cwd, env: { PATH: path } }) + expect(res.exitCode).toBe(0) + expect(res.stdout).toContain('GitHub authentication required') + expect(res.stdout).toContain('FORMATTED FEEDBACK') + const log = readFileSync(logFile, 'utf8') + expect(log).not.toContain('issue create') + }) + + test('gh authenticated: files at aligned-team/cospec with no --label, prints gh URL, exit 0', async () => { + const { path, logFile } = stubGh({ + createStdout: 'https://github.com/aligned-team/cospec/issues/42', + }) + const cwd = mkTempRepo() + const res = await cospec(['feedback', 'it broke', '--body', 'more detail'], { + cwd, + env: { PATH: path }, + }) + expect(res.exitCode).toBe(0) + expect(res.stdout).toContain('https://github.com/aligned-team/cospec/issues/42') + + const log = readFileSync(logFile, 'utf8') + expect(log).toContain('issue create') + expect(log).toContain('--repo aligned-team/cospec') + expect(log).not.toContain('--label') + expect(log).toContain('--title Feedback: it broke') + }) + + test('--json (authenticated success): one document with submitted:true and the gh URL', async () => { + const { path } = stubGh({ createStdout: 'https://github.com/aligned-team/cospec/issues/7' }) + const cwd = mkTempRepo() + const res = await cospec(['feedback', 'json path works', '--json'], { + cwd, + env: { PATH: path }, + }) + expect(res.exitCode).toBe(0) + const lines = res.stdout.trim().split('\n') + expect(lines.length).toBe(1) + const body = JSON.parse(lines[0]!) as { + version: number + submitted: boolean + url: string + repo: string + title: string + } + expect(body.submitted).toBe(true) + expect(body.url).toBe('https://github.com/aligned-team/cospec/issues/7') + expect(body.repo).toBe('aligned-team/cospec') + expect(body.title).toBe('Feedback: json path works') + }) + + test('--json (gh missing): one document with submitted:false and the manual URL, exit 0', async () => { + const cwd = mkTempRepo() + const res = await cospec(['feedback', 'json manual path', '--json'], { + cwd, + env: { PATH: noGhPath() }, + }) + expect(res.exitCode).toBe(0) + const body = JSON.parse(res.stdout.trim()) as { submitted: boolean; url: string } + expect(body.submitted).toBe(false) + expect(body.url).toContain('https://github.com/aligned-team/cospec/issues/new?') + }) + + test("gh issue create fails after auth: relays stderr, shows manual block, exits gh's code", async () => { + const { path } = stubGh({ createExit: 7, createStderr: 'HTTP 500: rate limited\n' }) + const cwd = mkTempRepo() + const res = await cospec(['feedback', 'rate limited case'], { cwd, env: { PATH: path } }) + expect(res.exitCode).toBe(7) + expect(res.stderr).toContain('rate limited') + expect(res.stdout).toContain('FORMATTED FEEDBACK') + expect(res.stdout).toContain('Please submit your feedback manually') + }) + + test('a bare usage error (no message) exits 1 with no gh invocation', async () => { + const { path, logFile } = stubGh() + const cwd = mkTempRepo() + const res = await cospec(['feedback'], { cwd, env: { PATH: path } }) + expect(res.exitCode).toBe(1) + expect(res.stderr).toContain('a message is required') + expect(existsSync(logFile)).toBe(false) + }) +}) + +describe('cospec feedback --upstream (relay to Fission-AI/OpenSpec)', () => { + test('names the destination on stderr and relays the wrapped call verbatim', async () => { + const { path, logFile } = stubGh({ + createStdout: 'https://github.com/Fission-AI/OpenSpec/issues/13', + }) + const cwd = mkTempRepo() + const res = await cospec(['feedback', '--upstream', 'openspec itself has a bug'], { + cwd, + env: { PATH: path }, + }) + expect(res.stderr).toContain('Fission-AI/OpenSpec') + expect(res.stderr).toContain('note:') + // Upstream's own feedback command ran (through the real wrapped binary) and + // in turn invoked the stubbed gh — proof this is a genuine relay, not a + // native cospec submission mislabeled. + if (existsSync(logFile)) { + const log = readFileSync(logFile, 'utf8') + expect(log).toContain('Fission-AI/OpenSpec') + } + }, 30_000) + + test('a gh failure outside the shared allow-list relays verbatim with the exact child exit code', async () => { + const { path } = stubGh({ createExit: 42, createStderr: 'gh: some odd upstream failure\n' }) + const cwd = mkTempRepo() + const res = await cospec(['feedback', '--upstream', 'openspec bug with odd gh failure'], { + cwd, + env: { PATH: path }, + }) + expect(res.stderr).toContain('Fission-AI/OpenSpec') + // The exact child exit code is whatever upstream's own feedback command + // propagates from gh — cospec never re-maps it to its own EXIT contract + // (see runUpstream: "no exitCodes allow-list can honestly enumerate this"). + expect(res.exitCode).not.toBe(0) + expect(res.exitCode).not.toBe(1) + }, 30_000) + + test('--upstream --json is refused (upstream emits text, not JSON), exit 1', async () => { + const cwd = mkTempRepo() + const res = await cospec(['feedback', '--upstream', 'msg', '--json'], { + cwd, + env: { PATH: noGhPath() }, + }) + expect(res.exitCode).toBe(1) + const body = JSON.parse(res.stdout.trim()) as { submitted: boolean; repo: string } + expect(body.submitted).toBe(false) + expect(body.repo).toBe('Fission-AI/OpenSpec') + }) +}) diff --git a/apps/cli/test/integration/pack-standalone.test.ts b/apps/cli/test/integration/pack-standalone.test.ts index 2159e4a..d6cba51 100644 --- a/apps/cli/test/integration/pack-standalone.test.ts +++ b/apps/cli/test/integration/pack-standalone.test.ts @@ -175,6 +175,23 @@ describe('standalone pack smoke (bun-less)', () => { const created = run([bin, 'new', 'chore', 'smoke-change'], target, path) expect(created.code, created.stderr).toBe(0) expect(existsSync(join(target, 'openspec/changes/smoke-change/.openspec.yaml'))).toBe(true) + + // `config`, `completion`, and `feedback` must dispatch from the compiled + // binary too (not just from `bun run src/index.ts`) — literal `import()` + // bundling is the trap that silently drops a command module (module + // header of `COMMAND_MODULES`), so each of these proves its module made it + // into the compiled artifact. + const configPath = run([bin, 'config', 'path'], target, path) + expect(configPath.code, configPath.stderr).toBe(0) + expect(configPath.stdout.trim().length).toBeGreaterThan(0) + + const completionZsh = run([bin, 'completion', 'zsh'], target, path) + expect(completionZsh.code, completionZsh.stderr).toBe(0) + expect(completionZsh.stdout).toContain('#compdef cospec') + + const feedbackHelp = run([bin, 'feedback', '--help'], target, path) + expect(feedbackHelp.code, feedbackHelp.stderr).toBe(0) + expect(feedbackHelp.stdout).toContain('feedback') }, 180_000) // The "fully self-contained" gate. NO npm install, NO node_modules anywhere, diff --git a/apps/cli/test/unit/commands/complete-types.test.ts b/apps/cli/test/unit/commands/complete-types.test.ts new file mode 100644 index 0000000..5679e94 --- /dev/null +++ b/apps/cli/test/unit/commands/complete-types.test.ts @@ -0,0 +1,47 @@ +// `cospec __complete types` unit test (ledger row 5.3, tasks.md 3.4). `types` +// is the one dynamic-completion source with no filesystem root and no wrapped +// call at all — it lists `COSPEC_TYPES` from `core/change.ts` directly. A +// `Bun.spawn`/`Bun.spawnSync` spy proves that stays true: a future change that +// accidentally routes `types` through `resolveRoot`/`openspecList` would spawn +// the wrapped binary and fail this test, not silently slow down every Tab +// press. + +import { describe, expect, spyOn, test } from 'bun:test' + +import type { CommandContext } from '../../../src/cli.ts' +import { run } from '../../../src/commands/complete.ts' +import { COSPEC_TYPES } from '../../../src/core/change.ts' + +function ctx(args: string[]): CommandContext { + return { args, flags: { json: false, noColor: false, cwd: '/tmp' }, cwd: '/tmp' } +} + +describe('cospec __complete types — no wrapped spawn', () => { + test('lists all eleven COSPEC_TYPES values, spawning nothing', async () => { + const spawnSpy = spyOn(Bun, 'spawn') + const spawnSyncSpy = spyOn(Bun, 'spawnSync') + try { + const captured: string[] = [] + const write = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: string) => { + captured.push(chunk) + return true + }) as typeof process.stdout.write + let code: number + try { + code = await run(ctx(['types'])) + } finally { + process.stdout.write = write + } + expect(code).toBe(0) + const stdout = captured.join('') + expect(COSPEC_TYPES.length).toBe(11) + for (const type of COSPEC_TYPES) expect(stdout).toContain(type) + expect(spawnSpy).not.toHaveBeenCalled() + expect(spawnSyncSpy).not.toHaveBeenCalled() + } finally { + spawnSpy.mockRestore() + spawnSyncSpy.mockRestore() + } + }) +}) diff --git a/apps/cli/test/unit/commands/config-args.test.ts b/apps/cli/test/unit/commands/config-args.test.ts new file mode 100644 index 0000000..45fbbc8 --- /dev/null +++ b/apps/cli/test/unit/commands/config-args.test.ts @@ -0,0 +1,197 @@ +// Pure argv-shaping unit tests for `cospec config` (DESIGN §1.3, ledger row 1.1 +// + 1.2). Every assertion here maps to a verified constraint from the design: +// - `--scope` is a parent-command option, hoisted to sit between `config` +// and the subcommand, wherever it was typed. +// - `--no-color` is NEVER appended (core/openspec.ts already prefixes it +// before the subcommand on every wrapped spawn — see the module header of +// commands/config.ts). +// - `root.storeArgs` is never threaded through at all — `config` is +// machine-global and has no store dimension. +// - `--json` is appended only for `list`. +// isHandoverCall and precedenceNotes are exercised directly since they drive +// the Class A/B split and the two stderr notes. + +import { describe, expect, test } from 'bun:test' + +import { + CONFIG_SUBCOMMANDS, + isHandoverCall, + planConfigCall, + precedenceNotes, +} from '../../../src/commands/config.ts' + +describe('planConfigCall — --scope hoisting', () => { + test('bare form: --scope anywhere is lifted to position 2', () => { + const plan = planConfigCall(['list', '--scope', 'global'], { json: false }) + if (plan.kind !== 'pass') throw new Error(`expected pass, got ${plan.kind}`) + expect(plan.argv).toEqual(['config', '--scope', 'global', 'list']) + }) + + test('--scope precedes the subcommand in the source args too', () => { + const plan = planConfigCall(['--scope', 'global', 'get', 'profile'], { json: false }) + if (plan.kind !== 'pass') throw new Error(`expected pass, got ${plan.kind}`) + expect(plan.argv).toEqual(['config', '--scope', 'global', 'get', 'profile']) + }) + + test('--scope=value form is parsed and re-emitted as two tokens', () => { + const plan = planConfigCall(['get', 'profile', '--scope=global'], { json: false }) + if (plan.kind !== 'pass') throw new Error(`expected pass, got ${plan.kind}`) + expect(plan.argv).toEqual(['config', '--scope', 'global', 'get', 'profile']) + }) + + test('any scope value but "global" is passed through verbatim — cospec never second-guesses it', () => { + const plan = planConfigCall(['list', '--scope', 'project'], { json: false }) + if (plan.kind !== 'pass') throw new Error(`expected pass, got ${plan.kind}`) + expect(plan.argv).toEqual(['config', '--scope', 'project', 'list']) + }) + + test('--scope with no value is a cospec-side error, not a wrapped spawn', () => { + const plan = planConfigCall(['list', '--scope'], { json: false }) + expect(plan.kind).toBe('error') + if (plan.kind === 'error') expect(plan.message).toContain('--scope requires a value') + }) + + test('no --scope at all: subcommand stays first after "config"', () => { + const plan = planConfigCall(['path'], { json: false }) + if (plan.kind !== 'pass') throw new Error(`expected pass, got ${plan.kind}`) + expect(plan.argv).toEqual(['config', 'path']) + }) +}) + +describe('planConfigCall — --no-color and storeArgs are never threaded', () => { + test('no built argv ever contains --no-color, across every subcommand', () => { + for (const sub of CONFIG_SUBCOMMANDS) { + const plan = planConfigCall([sub], { json: false }) + if (plan.kind === 'error') continue + expect(plan.argv).not.toContain('--no-color') + } + }) + + test('planConfigCall accepts no root/storeArgs parameter at all (signature proof)', () => { + // Two-arg signature only: (args, { json }). If a third `root`/`storeArgs` + // parameter were ever added back, this call would need updating — + // guarding against a silent re-introduction of `root.storeArgs`. + expect(planConfigCall.length).toBe(2) + }) +}) + +describe('planConfigCall — --json is appended only for list', () => { + test('list --json appends --json', () => { + const plan = planConfigCall(['list'], { json: true }) + if (plan.kind !== 'pass') throw new Error(`expected pass, got ${plan.kind}`) + expect(plan.argv).toEqual(['config', 'list', '--json']) + }) + + for (const sub of CONFIG_SUBCOMMANDS.filter((s) => s !== 'list')) { + test(`${sub} --json does NOT append --json to the wrapped argv`, () => { + const args = sub === 'get' || sub === 'set' || sub === 'unset' ? [sub, 'someKey'] : [sub] + const plan = planConfigCall(args, { json: true }) + if (plan.kind === 'error') return + expect(plan.argv).not.toContain('--json') + }) + } +}) + +describe('planConfigCall — subcommand validation', () => { + test('missing subcommand is a usage error naming all eight subcommands', () => { + const plan = planConfigCall([], { json: false }) + expect(plan.kind).toBe('error') + if (plan.kind === 'error') { + for (const sub of CONFIG_SUBCOMMANDS) expect(plan.message).toContain(sub) + } + }) + + test('unknown subcommand is a usage error, not a wrapped spawn', () => { + const plan = planConfigCall(['frobnicate'], { json: false }) + expect(plan.kind).toBe('error') + if (plan.kind === 'error') expect(plan.message).toContain("unknown subcommand 'frobnicate'") + }) + + test('every declared subcommand plans successfully with no extra args', () => { + for (const sub of CONFIG_SUBCOMMANDS) { + const plan = planConfigCall([sub], { json: false }) + expect(plan.kind).not.toBe('error') + } + }) +}) + +describe('isHandoverCall — Class A/B split', () => { + test('edit is always a handover', () => { + expect(isHandoverCall('edit', [])).toBe(true) + }) + + test('profile with no preset is a handover (interactive menu)', () => { + expect(isHandoverCall('profile', [])).toBe(true) + }) + + test('profile with a preset positional is piped', () => { + expect(isHandoverCall('profile', ['core'])).toBe(false) + }) + + test('reset --all with neither -y nor --yes is a handover (inquirer confirm)', () => { + expect(isHandoverCall('reset', ['--all'])).toBe(true) + }) + + test('reset --all -y is piped', () => { + expect(isHandoverCall('reset', ['--all', '-y'])).toBe(false) + }) + + test('reset --all --yes is piped', () => { + expect(isHandoverCall('reset', ['--all', '--yes'])).toBe(false) + }) + + test('reset with no --all is piped (upstream usage error, no prompt)', () => { + expect(isHandoverCall('reset', [])).toBe(false) + }) + + test('every other subcommand is always piped', () => { + for (const sub of ['path', 'list', 'get', 'set', 'unset'] as const) { + expect(isHandoverCall(sub, [])).toBe(false) + } + }) +}) + +describe('planConfigCall — kind reflects the handover class', () => { + test('config edit plans as a handover call', () => { + const plan = planConfigCall(['edit'], { json: false }) + if (plan.kind === 'error') throw new Error('expected a plan') + expect(plan.kind).toBe('handover') + }) + + test('config get some.key plans as a piped call', () => { + const plan = planConfigCall(['get', 'some.key'], { json: false }) + if (plan.kind === 'error') throw new Error('expected a plan') + expect(plan.kind).toBe('pass') + }) +}) + +describe('precedenceNotes — the two forced-override notes', () => { + test('set telemetry.enabled true prints the telemetry-override note', () => { + const notes = precedenceNotes('set', ['telemetry.enabled', 'true']) + expect(notes.some((n) => n.includes('OPENSPEC_TELEMETRY=0'))).toBe(true) + }) + + test('set profile prints the harness-canon note', () => { + const notes = precedenceNotes('set', ['profile', 'core']) + expect(notes.some((n) => n.includes("run 'cospec update'"))).toBe(true) + }) + + test('set workflows / set delivery also print the harness-canon note', () => { + expect(precedenceNotes('set', ['workflows', 'x']).length).toBeGreaterThan(0) + expect(precedenceNotes('set', ['delivery', 'x']).length).toBeGreaterThan(0) + }) + + test('the `profile` subcommand itself (Class B) prints the harness-canon note', () => { + const notes = precedenceNotes('profile', ['core']) + expect(notes.some((n) => n.includes("run 'cospec update'"))).toBe(true) + }) + + test('an unrelated key gets no notes', () => { + expect(precedenceNotes('set', ['defaultStore', 'x'])).toEqual([]) + }) + + test('a non-set subcommand with an unrelated sub gets no notes', () => { + expect(precedenceNotes('path', [])).toEqual([]) + expect(precedenceNotes('get', ['telemetry.enabled'])).toEqual([]) + }) +}) diff --git a/apps/cli/test/unit/commands/feedback-format.test.ts b/apps/cli/test/unit/commands/feedback-format.test.ts new file mode 100644 index 0000000..acb3105 --- /dev/null +++ b/apps/cli/test/unit/commands/feedback-format.test.ts @@ -0,0 +1,167 @@ +// Pure formatting/parsing unit tests for `cospec feedback` (DESIGN §3.1, +// ledger row 3.1): grapheme-safe 72-char title truncation, the +// Summary/Details/provenance body shape, the never-a-shell argv, and argv +// parsing. + +import { describe, expect, test } from 'bun:test' + +import { + COSPEC_REPO, + formatBody, + formatTitle, + issueArgv, + manualUrl, + parseFeedbackArgs, + provenanceFooter, + UPSTREAM_REPO, +} from '../../../src/commands/feedback.ts' + +describe('formatTitle', () => { + test('short message: prefixed, whitespace collapsed, no truncation', () => { + expect(formatTitle(' the thing broke ')).toBe('Feedback: the thing broke') + }) + + test('title at or under 72 chars is never truncated', () => { + const msg = 'x'.repeat(60) // "Feedback: " (10) + 60 = 70 <= 72 + const title = formatTitle(msg) + expect(title).toBe(`Feedback: ${msg}`) + expect([...title].length).toBeLessThanOrEqual(72) + }) + + test('long message is truncated to a 72-char budget with an ellipsis', () => { + const msg = 'a'.repeat(200) + const title = formatTitle(msg) + expect([...title].length).toBeLessThanOrEqual(72) + expect(title.endsWith('…')).toBe(true) + expect(title.startsWith('Feedback: ')).toBe(true) + }) + + test('truncation backs off to a word boundary rather than chopping mid-word', () => { + // available = 72 - 'Feedback: '.length - 1 = 61. Five 12-char words (incl. + // trailing space) = 60 chars fit whole; the sixth would push past 61, so + // the cut backs off to the space before it instead of splitting it. + const words = ['aaaaaaaaaaa', 'bbbbbbbbbbb', 'ccccccccccc', 'ddddddddddd', 'eeeeeeeeeee'] + const msg = `${words.join(' ')} ffffffffffffffffffffffffffffff` + const title = formatTitle(msg) + expect(title).toBe(`Feedback: ${words.join(' ')}…`) + }) + + test('truncation is grapheme-aware: never splits a multi-codepoint emoji', () => { + // Each 🚀 is a single grapheme but 2 UTF-16 code units; a naive + // slice(0, n) on code units could bisect one. + const msg = '🚀'.repeat(80) + const title = formatTitle(msg) + expect([...title].length).toBeLessThanOrEqual(72) + // Every remaining rocket in the body is a complete grapheme (no lone + // surrogate half), so re-segmenting recovers whole emoji only. + const body = title.slice('Feedback: '.length, title.endsWith('…') ? -1 : undefined) + const segments = [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(body)] + for (const { segment } of segments) + expect(['🚀'].includes(segment) || segment === '').toBe(true) + }) +}) + +describe('formatBody', () => { + test('carries Summary, optional Details, and the footer', () => { + const footer = provenanceFooter(new Date('2026-09-01T00:00:00.000Z'), 'project 1.11.0') + const withDetails = formatBody('short summary', 'more detail here', footer) + expect(withDetails).toContain('## Summary') + expect(withDetails).toContain('short summary') + expect(withDetails).toContain('## Details') + expect(withDetails).toContain('more detail here') + expect(withDetails).toContain(footer) + + const withoutDetails = formatBody('short summary', undefined, footer) + expect(withoutDetails).not.toContain('## Details') + }) + + test('an empty-string details is treated as absent', () => { + const footer = provenanceFooter(new Date(), 'embedded 1.11.0') + expect(formatBody('msg', '', footer)).not.toContain('## Details') + }) +}) + +describe('provenanceFooter', () => { + test('records the wrapped openspec resolution, platform, and an ISO timestamp', () => { + const now = new Date('2026-09-01T12:00:00.000Z') + const footer = provenanceFooter(now, 'project 1.11.0') + expect(footer).toContain('- openspec: project 1.11.0') + expect(footer).toContain('- Timestamp: 2026-09-01T12:00:00.000Z') + expect(footer).toContain('- Platform:') + expect(footer).toContain('- cospec:') + }) +}) + +describe('manualUrl', () => { + test('URL-encodes title and body onto the given repo, no labels param', () => { + const url = manualUrl(COSPEC_REPO, 'Feedback: it broke', '## Summary\n\nit broke') + expect(url.startsWith(`https://github.com/${COSPEC_REPO}/issues/new?`)).toBe(true) + expect(url).toContain(encodeURIComponent('Feedback: it broke')) + expect(url).not.toContain('labels=') + }) +}) + +describe('issueArgv', () => { + test('is a flat array carrying the raw message as one element (never a shell)', () => { + const argv = issueArgv(COSPEC_REPO, 'Feedback: `rm -rf /` in a title', 'body; $(evil)') + expect(argv).toEqual([ + 'issue', + 'create', + '--repo', + COSPEC_REPO, + '--title', + 'Feedback: `rm -rf /` in a title', + '--body', + 'body; $(evil)', + ]) + // The dangerous-looking substrings are intact, single array elements — + // proof they were never concatenated into a shell string. + expect(argv).toContain('body; $(evil)') + }) +}) + +describe('parseFeedbackArgs', () => { + test('a single positional message parses cleanly', () => { + const parsed = parseFeedbackArgs(['something broke']) + expect(parsed.error).toBeUndefined() + expect(parsed.message).toBe('something broke') + expect(parsed.body).toBeUndefined() + expect(parsed.upstream).toBe(false) + }) + + test('--body and --body=value both work', () => { + expect(parseFeedbackArgs(['msg', '--body', 'more']).body).toBe('more') + expect(parseFeedbackArgs(['msg', '--body=more']).body).toBe('more') + }) + + test('--upstream sets the flag regardless of position', () => { + expect(parseFeedbackArgs(['--upstream', 'msg']).upstream).toBe(true) + expect(parseFeedbackArgs(['msg', '--upstream']).upstream).toBe(true) + }) + + test('no message at all is an error naming the usage', () => { + const parsed = parseFeedbackArgs([]) + expect(parsed.error).toContain('a message is required') + }) + + test('a whitespace-only message is treated as missing', () => { + expect(parseFeedbackArgs([' ']).error).toContain('a message is required') + }) + + test('a second positional is an error (only one message argument)', () => { + expect(parseFeedbackArgs(['first', 'second']).error).toContain('only one message argument') + }) + + test('an unknown flag is an error', () => { + expect(parseFeedbackArgs(['msg', '--bogus']).error).toContain("unknown option '--bogus'") + }) + + test('--body with no value is an error', () => { + expect(parseFeedbackArgs(['msg', '--body']).error).toContain('--body requires a value') + }) +}) + +test('UPSTREAM_REPO and COSPEC_REPO are the two distinct, hardcoded destinations', () => { + expect(COSPEC_REPO).toBe('aligned-team/cospec') + expect(UPSTREAM_REPO).toBe('Fission-AI/OpenSpec') +}) diff --git a/apps/cli/test/unit/core/completions.test.ts b/apps/cli/test/unit/core/completions.test.ts new file mode 100644 index 0000000..ecaf30c --- /dev/null +++ b/apps/cli/test/unit/core/completions.test.ts @@ -0,0 +1,127 @@ +// `extractFlags`/`buildCompletionSpec` unit tests (DESIGN §2.2, ledger row +// 2.1). `extractFlags` is the one fragile part of the completion generator — a +// regex over a pre-formatted help string — so it gets both a targeted, hand- +// crafted-input suite and a snapshot check against cospec's REAL `COMMANDS` +// table: a command or flag added to `cli.ts` that the extractor cannot parse +// must fail here, not silently vanish from completion. + +import { describe, expect, test } from 'bun:test' + +import { COMMANDS, GLOBAL_OPTIONS } from '../../../src/cli.ts' +import { buildCompletionSpec, extractFlags } from '../../../src/core/completions/spec.ts' + +describe('extractFlags — hand-crafted inputs', () => { + test('undefined options → no flags', () => { + expect(extractFlags(undefined)).toEqual([]) + }) + + test('a single long flag with a value placeholder', () => { + expect(extractFlags(' --change The change to report on')).toEqual(['--change']) + }) + + test('multiple flags on one line, comma-separated (short + long alias)', () => { + expect(extractFlags(' -r, --requirement Show a single requirement')).toEqual([ + '-r', + '--requirement', + ]) + }) + + test('a prose continuation line contributes nothing, even mentioning a flag', () => { + const options = ` --change Required + artifacts: proposal, blocking-changes, specs (mentions --allow-soft in prose)` + expect(extractFlags(options)).toEqual(['--change']) + }) + + test('a parenthetical note line contributes nothing', () => { + const options = ` --scope Config scope + (config is machine-global: --store never applies)` + expect(extractFlags(options)).toEqual(['--scope']) + }) + + test('a bracketed placeholder is consumed the same as an angle-bracketed one', () => { + expect(extractFlags(' --harness [list] claude,codex,opencode')).toEqual(['--harness']) + }) + + test('duplicate flags across lines are de-duplicated, first occurrence order kept', () => { + const options = ` --force Overwrite conflicting managed files + --force (repeated by mistake)` + expect(extractFlags(options)).toEqual(['--force']) + }) + + test('a --no-color style negated long flag is still one token', () => { + expect(extractFlags(' --no-color Disable ANSI color')).toEqual(['--no-color']) + }) +}) + +describe('buildCompletionSpec — snapshot against the REAL COMMANDS table', () => { + const spec = buildCompletionSpec() + + test('hidden commands (__complete, check-commit) are filtered out', () => { + const names = spec.commands.map((c) => c.name) + expect(names).not.toContain('__complete') + expect(names).not.toContain('check-commit') + }) + + test('every non-hidden COMMANDS entry appears exactly once', () => { + const visible = COMMANDS.filter((c) => c.hidden !== true).map((c) => c.name) + const names = spec.commands.map((c) => c.name) + expect(names.toSorted()).toEqual([...new Set(visible)].toSorted()) + expect(names.length).toBe(visible.length) + }) + + test('global flags include --json, --no-color, --cwd, --store from GLOBAL_OPTIONS, plus -V/--version', () => { + for (const flag of ['--json', '--no-color', '--cwd', '--store']) + expect(spec.globalFlags).toContain(flag) + expect(spec.globalFlags).toContain('-V') + expect(spec.globalFlags).toContain('--version') + expect(extractFlags(GLOBAL_OPTIONS).length).toBeGreaterThan(0) + }) + + test('config: flags are just --scope (the parenthetical note is not a flag)', () => { + const config = spec.commands.find((c) => c.name === 'config')! + expect(config.flags).toEqual(['--scope']) + }) + + test('completion: no flags at all (its options line is a parenthetical note)', () => { + const completion = spec.commands.find((c) => c.name === 'completion')! + expect(completion.flags).toEqual([]) + }) + + test('feedback: --body and --upstream', () => { + const feedback = spec.commands.find((c) => c.name === 'feedback')! + expect(feedback.flags).toEqual(['--body', '--upstream']) + }) + + test('instructions: only --change and --allow-soft, never a token from the artifacts prose', () => { + const instructions = spec.commands.find((c) => c.name === 'instructions')! + expect(instructions.flags).toEqual(['--change', '--allow-soft']) + }) + + test('show: -r/--requirement extracted alongside the rest, in declared order', () => { + const show = spec.commands.find((c) => c.name === 'show')! + expect(show.flags).toEqual([ + '--type', + '--deltas-only', + '--requirements-only', + '-r', + '--requirement', + '--no-scenarios', + ]) + }) + + test('dynamic positionals: new→types, show→changes+specs, archive→changes', () => { + expect(spec.commands.find((c) => c.name === 'new')!.positional).toEqual(['types']) + expect(spec.commands.find((c) => c.name === 'show')!.positional).toEqual(['changes', 'specs']) + expect(spec.commands.find((c) => c.name === 'archive')!.positional).toEqual(['changes']) + expect(spec.commands.find((c) => c.name === 'config')!.positional).toEqual([]) + }) + + test('dynamic flag values: status --change and instructions --change complete to changes', () => { + expect(spec.commands.find((c) => c.name === 'status')!.flagValues).toEqual({ + '--change': 'changes', + }) + expect(spec.commands.find((c) => c.name === 'instructions')!.flagValues).toEqual({ + '--change': 'changes', + }) + }) +}) diff --git a/apps/cli/test/unit/harness/__snapshots__/adapters.test.ts.snap b/apps/cli/test/unit/harness/__snapshots__/adapters.test.ts.snap index e5ee9c7..810f332 100644 --- a/apps/cli/test/unit/harness/__snapshots__/adapters.test.ts.snap +++ b/apps/cli/test/unit/harness/__snapshots__/adapters.test.ts.snap @@ -12,6 +12,11 @@ prefix_rule(pattern=["cospec", "apply"], decision="allow") prefix_rule(pattern=["cospec", "sync-blockers", "--check"], decision="allow") prefix_rule(pattern=["cospec", "new"], decision="allow") prefix_rule(pattern=["cospec", "doctor"], decision="allow") +prefix_rule(pattern=["cospec", "config", "get"], decision="allow") +prefix_rule(pattern=["cospec", "config", "list"], decision="allow") +prefix_rule(pattern=["cospec", "config", "path"], decision="allow") +prefix_rule(pattern=["cospec", "completion"], decision="allow") +prefix_rule(pattern=["cospec", "__complete"], decision="allow") " `; diff --git a/apps/cli/test/unit/harness/__snapshots__/render.test.ts.snap b/apps/cli/test/unit/harness/__snapshots__/render.test.ts.snap index 10c1d02..ad56b1b 100644 --- a/apps/cli/test/unit/harness/__snapshots__/render.test.ts.snap +++ b/apps/cli/test/unit/harness/__snapshots__/render.test.ts.snap @@ -3038,6 +3038,11 @@ prefix_rule(pattern=["cospec", "apply"], decision="allow") prefix_rule(pattern=["cospec", "sync-blockers", "--check"], decision="allow") prefix_rule(pattern=["cospec", "new"], decision="allow") prefix_rule(pattern=["cospec", "doctor"], decision="allow") +prefix_rule(pattern=["cospec", "config", "get"], decision="allow") +prefix_rule(pattern=["cospec", "config", "list"], decision="allow") +prefix_rule(pattern=["cospec", "config", "path"], decision="allow") +prefix_rule(pattern=["cospec", "completion"], decision="allow") +prefix_rule(pattern=["cospec", "__complete"], decision="allow") " , "kind": "rules", diff --git a/openspec/.cospec-manifest.json b/openspec/.cospec-manifest.json index ec31a0f..79d4daa 100644 --- a/openspec/.cospec-manifest.json +++ b/openspec/.cospec-manifest.json @@ -61,6 +61,6 @@ "openspec/schemas/test/templates/proposal.md": "sha256:933eba24f543ffa5ecfc2761c78859933b9b05ca5500333f75c006ef2c3edc53", "openspec/schemas/test/templates/blocking-changes.md": "sha256:1ca785485ecf0b2e8373fda2d0afbdba927442a72077d8442fb867c20507bf31", "openspec/schemas/test/templates/tasks.md": "sha256:c29089bd768eb42e61245fee351226085b99f55b20182b797f2c8d4489cc71f0", - ".codex/rules/cospec.rules": "sha256:acc873115f2f64f6ea3cab144e97f61486066978e2ade266833ebeb8f7fa12d6" + ".codex/rules/cospec.rules": "sha256:6d95335af27f9fbc54c426445179af6a91eae0a724e314f68f3afb8b432af689" } } From 9ef790fd556920a39f5c2b8189b05170d82e0503 Mon Sep 17 00:00:00 2001 From: replygirl Date: Sat, 5 Sep 2026 12:23:57 -0500 Subject: [PATCH 2/6] feat(cli): author openspec-config-completion-feedback change Co-Authored-By: Claude Fable 5.1 --- .../.openspec.yaml | 3 + .../blocking-changes.md | 22 ++ .../design.md | 221 ++++++++++++++++++ .../proposal.md | 181 ++++++++++++++ .../specs/cospec-feedback/spec.md | 90 +++++++ .../specs/cospec-shell-completion/spec.md | 90 +++++++ .../specs/openspec-config-passthrough/spec.md | 152 ++++++++++++ .../specs/openspec-read-passthroughs/spec.md | 44 ++++ .../tasks.md | 139 +++++++++++ .../verification.md | 62 +++++ 10 files changed, 1004 insertions(+) create mode 100644 openspec/changes/openspec-config-completion-feedback/.openspec.yaml create mode 100644 openspec/changes/openspec-config-completion-feedback/blocking-changes.md create mode 100644 openspec/changes/openspec-config-completion-feedback/design.md create mode 100644 openspec/changes/openspec-config-completion-feedback/proposal.md create mode 100644 openspec/changes/openspec-config-completion-feedback/specs/cospec-feedback/spec.md create mode 100644 openspec/changes/openspec-config-completion-feedback/specs/cospec-shell-completion/spec.md create mode 100644 openspec/changes/openspec-config-completion-feedback/specs/openspec-config-passthrough/spec.md create mode 100644 openspec/changes/openspec-config-completion-feedback/specs/openspec-read-passthroughs/spec.md create mode 100644 openspec/changes/openspec-config-completion-feedback/tasks.md create mode 100644 openspec/changes/openspec-config-completion-feedback/verification.md diff --git a/openspec/changes/openspec-config-completion-feedback/.openspec.yaml b/openspec/changes/openspec-config-completion-feedback/.openspec.yaml new file mode 100644 index 0000000..5dcd1ab --- /dev/null +++ b/openspec/changes/openspec-config-completion-feedback/.openspec.yaml @@ -0,0 +1,3 @@ +schema: feat +created: 2026-09-03 +schemaVersion: 2 diff --git a/openspec/changes/openspec-config-completion-feedback/blocking-changes.md b/openspec/changes/openspec-config-completion-feedback/blocking-changes.md new file mode 100644 index 0000000..0bf8e66 --- /dev/null +++ b/openspec/changes/openspec-config-completion-feedback/blocking-changes.md @@ -0,0 +1,22 @@ +# Dependencies + +## Blocked by + +- [x] `openspec-1-11-parity` — bumps the wrapped pin to 1.11.0 (the binary whose + `config` surface this change wraps and probes in the contract suite), adds + `OPENSPEC_NO_COMPLETIONS=1` to the spawn env that the config precedence + notes describe, and lands the `defaultStore` fallback in root resolution + that `cospec config set defaultStore` becomes the supported way to set + _(archived 2026-09-02)_ + +## Soft-blocked by + +None. + +## Notes + +Scanned every directory under `openspec/changes/` (no other active change +exists) and every entry under `openspec/changes/archive/`. No unshipped change +provides anything this one consumes: the three new command modules, the +completion generator, and the `renderCodexRules` allow-list all build on +surfaces that are already on `main`. diff --git a/openspec/changes/openspec-config-completion-feedback/design.md b/openspec/changes/openspec-config-completion-feedback/design.md new file mode 100644 index 0000000..e9a4862 --- /dev/null +++ b/openspec/changes/openspec-config-completion-feedback/design.md @@ -0,0 +1,221 @@ +## Context + +The proposal covers why these three surfaces are being wrapped and what each +gains; the delta specs fix the obligations. What is left to decide is how, and +each decision below turns on a verified fact about either upstream OpenSpec +1.11.0 or cospec's existing wrappers, so the evidence is cited rather than +asserted. + +Two constraints dominate. First, `core/passthrough-command.ts` — the helper +every other wrapped read surface uses — appends `root.storeArgs`, a trailing +`--no-color`, and `--json` unconditionally after building its argv. Second, +cospec's piped spawn uses `stdin: 'ignore'` and forces `OPENSPEC_TELEMETRY=0` +and `OPENSPEC_NO_COMPLETIONS=1` in `WRAPPED_ENV`. Both are correct for the +surfaces they were built for and both are actively wrong for `openspec config`, +whose options live on the parent command, whose `--json` exists on one +subcommand only, and three of whose subcommands are interactive. + +## Goals / Non-Goals + +**Goals:** + +- Reach every `openspec config` subcommand without weakening wrapped-call + discipline, and without cospec ever parsing or writing the global config file. +- Keep `--json` honest: exactly one document per invocation, invented shapes + versioned, and interactive subcommands refused rather than faked. +- Tell the user, at the moment they write a key, where cospec's own behaviour + outranks it — without polluting stdout. +- Ship completion for the binary users actually type, sourced from cospec's own + command table, with a dynamic source that can never corrupt a Tab press. +- Route bug reports to whoever can act on them, with the other tracker one + explicit flag away. + +**Non-Goals:** the proposal's Non-Goals section is the complete list. At the +design level one boundary is worth restating: no shared abstraction is extracted +for the terminal-handover class in this change. `workset open` and the three +interactive config subcommands each keep their own local spawn; the class is a +specified contract and a docs section, not yet a helper. Two call sites do not +justify an abstraction whose third member does not exist. + +## Decisions + +**A local argv builder for `config`, not the shared passthrough helper.** +Rejected: reaching `config` through `callPassthrough` with per-subcommand +opt-out flags. Every one of the helper's three unconditional appends is fatal +here — `--store` is not a `config` option at all (upstream declares a +parent-level `--scope` instead, and rejects `--store` as unknown), a trailing +`--no-color` is rejected because upstream declares it on the program and only +`show` sets `allowUnknownOption`, and `--json` exists on `list` alone. Adding +three opt-outs to a shared helper to serve one caller makes the helper harder to +reason about for the eight callers that are fine today. `commands/workset.ts` +already establishes the local-runner precedent. `resolveRoot` is not called at +all: config is machine-global, so there is no root to resolve and no store to +thread, and `--store` is refused explicitly rather than absorbed and ignored. + +**Two call classes rather than one.** Rejected: piping everything and letting +the interactive subcommands fail. Upstream's `edit` spawns `$EDITOR` with +inherited stdio; `profile` with no preset requires `process.stdout.isTTY` and +runs `@inquirer` menus; `reset --all` without `-y` runs an `@inquirer` confirm. +Under cospec's `stdin: 'ignore'` spawn those either hang or report a TTY error +that is cospec's artefact rather than the user's situation. Handing the terminal +over is the only honest option, and `130` propagates unchanged because upstream +sets it on cancellation and normalising it would lose the distinction between +"cancelled" and "failed". + +**Cospec-owned `--json` envelopes for the five subcommands upstream leaves +untyped.** Rejected: passing `--json` through and letting upstream reject it, +and rejected: re-deriving typed values by reimplementing upstream's config +merge. Agents pass `--json` globally; a surface that emits a JSON document for +one subcommand and prose for six is a trap. The cost is that `config get`'s +`value` is the raw printed string — upstream prints objects via compact +`JSON.stringify` and scalars via `String(value)`, and recovering the original +type would mean duplicating its merge logic, which this change refuses to do. +Every invented envelope carries `version: 1` so that switching to a relayed +upstream document later is a version bump, not a silent shape change. +`list --json` stays a verbatim relay for exactly that reason. + +**Notes on stderr, and only for the two keys cospec actually overrides.** +Rejected: annotating every key, and rejected: printing nothing. +`OPENSPEC_TELEMETRY=0` in `WRAPPED_ENV` is a hard override above config, so +`config set telemetry.enabled true` genuinely cannot re-enable telemetry for +anything cospec runs; and upstream's own success line for a profile change tells +the user to run `openspec update`, which in a cospec repo is wrong advice. Those +two are misleading in a way a user cannot discover. `defaultStore` is not +annotated — cospec honours it, as a fallback below local-root resolution — and +neither is any other key. stderr keeps stdout at exactly one JSON document. + +**Native completion generated from cospec's `COMMANDS`, not upstream's +generator.** Rejected: passing `openspec completion` through, and rejected: +excluding the surface. Upstream's installer writes a completion function for the +`openspec` binary into the user's rc file, whose dynamic completions shell out +to `openspec __complete` — installing a permanent instruction to call bare +`openspec` into a dotfile, which no printed warning undoes. Excluding completion +leaves an everyday gap on the binary users type. cospec already owns a complete +static command table, so generating from it is both cheaper and more accurate. +The one fragile part is extracting per-command flags from each entry's +pre-formatted help string; that extraction is a pure function with a snapshot +test over the real table, so a new command or flag that the extractor cannot see +turns red in CI instead of quietly vanishing from completion. + +**`__complete` fails silently, on both streams.** Rejected: printing a +diagnostic. This is upstream's design and it is right — a Tab press that prints +an error into the middle of a command line is worse than a Tab press that +completes nothing. `types` is served from `COSPEC_TYPES` with no spawn; +`schemas` is deliberately not a completion source unless the wrapped `schemas` +command is confirmed to emit `--json`, because parsing a text table for +completion candidates is not worth the fragility. + +**`feedback` is native, with `--upstream` as the explicit escape.** Rejected: +defaulting to upstream's tracker, and rejected: excluding the command. A cospec +user usually cannot tell whether a bug is cospec's or OpenSpec's; cospec +maintainers can. Defaulting to `Fission-AI/OpenSpec` would route cospec bug +reports to a project that cannot fix them and did not ask for them. The native +path follows upstream's shape — grapheme-aware title truncation, `gh` and +`gh auth status` gates, manual-URL fallback at exit 0 — minus `--label`, whose +only purpose upstream is to be retried when the repo does not define the label. +Dropping the flag deletes the whole failure mode. `--upstream` uses a raw +version-asserted spawn rather than `passthroughOpenspec` because upstream exits +with gh's own arbitrary status, which no `expect.exitCodes` allow-list can +enumerate honestly; that exception is stated in the spec rather than hidden. + +**Registration lands last, in one owner.** `cli.ts` and `harness/adapters.ts` +are the only shared files, and `COMMAND_MODULES` imports must be literal — a +computed import breaks the compiled standalone binary silently, which is why the +pack smoke test grows a case per new command. Registering a name in `COMMANDS` +before its module exists makes the command report "not yet implemented", so the +four entries and four imports land together, after the three command modules. + +## Operational surface + +Nothing here binds a port or ships a container; the operational surface is a +local CLI plus two child processes. + +- **Runtime and arches.** No new runtime. The three commands ship inside the + existing Bun-compiled `cospec` binary and the published npm package, on the + same platform matrix as today. The pack smoke test runs all three from the + compiled standalone binary with no `node_modules`, because `COMMAND_MODULES` + imports must stay literal for the bundler to see them. +- **Wrapped binary versions.** `config` and `feedback --upstream` call the + resolved wrapped OpenSpec (dev/CI pinned 1.11.0, accepted `>=1.0.0 <2.0.0`), + by resolved path, version-asserted before every spawn including the handover + ones. Where a surface postdates the accepted floor, cospec relays upstream's + own unknown-command error and the fact is documented as a per-surface runtime + minimum; the floor is not raised. +- **Child processes.** Class A config calls and `feedback --upstream` are piped + spawns with `stdin: 'ignore'`. Class B config calls inherit all three streams + and own the terminal until the child exits. `gh` is spawned with array argv + and `shell: false`. No invocation passes user text through a shell. +- **Required secrets.** None held by cospec. `cospec feedback` authenticates + only through the user's own `gh` credentials, checked via `gh auth status` and + never read, printed, or stored by cospec. With no authenticated `gh` the + command degrades to a printed URL at exit 0 rather than prompting for a token. +- **Filesystem writes.** `cospec completion` writes nothing — no rc file, no + completion directory. `cospec config` writes only through the wrapped binary, + into the machine-global OpenSpec config; cospec never opens that file itself. + Tests that exercise a write sandbox `XDG_CONFIG_HOME` and `HOME` into a temp + dir, which propagates because `WRAPPED_ENV` spreads `process.env`. +- **Network.** Only `cospec feedback` reaches the network, and only through + `gh`. Every automated test stubs `gh` on `PATH`; the single real submission is + a `@manual` row. +- **Forced environment.** Every wrapped spawn, handover included, carries + `OPENSPEC_TELEMETRY=0` and `OPENSPEC_NO_COMPLETIONS=1`, so neither telemetry + nor upstream's first-run completions tip can surface from a cospec run. That + is exactly what the `telemetry.enabled` stderr note exists to disclose. + +## Integration contract + +Three external contracts are in play, each pinned to an observable rather than +to an exit code. + +- **Wrapped `openspec config` CLI shape.** `--scope` is a parent-command option + and must be emitted between `config` and the subcommand; `--store` does not + exist on this command; `--no-color` is a program-level option that only `show` + tolerates in trailing position; `--json` exists on `list` alone. Class A + declares `expect.exitCodes = [0, 1]` because upstream uses exit 1 for ordinary + negative results. A contract test against the real pinned binary pins each of + these, including the trailing-`--no-color` rejection, so an upstream change + breaks a test rather than a user's command. +- **`gh` CLI.** cospec depends on + `gh issue create --repo --title --body ` accepting array argv + and printing the created issue URL on stdout, and on `gh auth status` + distinguishing authenticated from not. No `--label` is passed, so the repo + needs no label definition. gh's exit status is relayed rather than + interpreted, and gh's stderr is relayed verbatim. +- **Shell completion protocol.** The generated scripts are the contract cospec + offers to bash, zsh, and fish: static command and flag candidates rendered at + generation time, plus dynamic slots that call `cospec __complete ` and + read tab-separated id/description lines. That source's failure mode is part of + the contract — exit 1, both streams empty — because a shell reads whatever it + is given. Script syntax is verified per shell (`bash -n`, `zsh -n`, + `fish --no-execute`) rather than assumed. + +Not reconciled here, deliberately: cospec does not adopt OpenSpec's config +schema as its own. The `profile`/`workflows`/`delivery` keys stay upstream's, +describing upstream's generated files; cospec's harness output stays +canon-derived, and the stderr note is what keeps the two from being confused. + +## Risks / Trade-offs + +- [The contract test writes to the developer's real global config] → the config + contract test sandboxes `XDG_CONFIG_HOME` **and** `HOME` into a temp dir. + `WRAPPED_ENV` spreads `process.env`, so the sandbox propagates to the child. + This is mandatory, not a nicety: an unsandboxed `config set` in a test mutates + the machine. +- [cospec invents JSON shapes upstream may later define differently] → every + invented envelope carries `version: 1`, `list --json` stays a verbatim relay, + and the documented migration is to relay upstream's document and bump the + version once upstream supports `--json` on those subcommands. +- [The flag extractor is a regex over a help string] → the snapshot unit test + over the real `COMMANDS` table turns any unparseable entry into a CI failure + rather than a silently shorter completion script. +- [`config`, `completion`, or `feedback` may postdate the accepted 1.0.0 floor] + → resolved before the config module lands: if any surface postdates it, it + gains a per-surface runtime-minimum row in the docs and relays upstream's own + unknown-command error, and the floor is not raised. Root resolution is safe + either way because `readDefaultStore` already tolerates exit 1. +- [`gh` in tests reaching the network] → every automated row stubs `gh` on + `PATH` in a temp dir; the single real submission is a `@manual` row. +- [The trailing-`--no-color` hazard exists on other passthroughs today] → out of + scope, but not left as folklore: a contract row proves the wrapped binary + rejects a trailing `--no-color` on `config get`, which is the evidence the + follow-up `fix` change starts from. diff --git a/openspec/changes/openspec-config-completion-feedback/proposal.md b/openspec/changes/openspec-config-completion-feedback/proposal.md new file mode 100644 index 0000000..a57c3c7 --- /dev/null +++ b/openspec/changes/openspec-config-completion-feedback/proposal.md @@ -0,0 +1,181 @@ +## Why + +`openspec-1-11-parity` closed every everyday OpenSpec surface except three, and +named them in its own Non-Goals: `config`, `completion`, and `feedback`. Until +they are wrapped, `CLAUDE.md`'s standing rule — never call bare `openspec` — is +unenforceable in practice, because a user who wants to inspect the +machine-global config, install shell completion, or file a bug has no cospec +command to reach for and must drop out to the wrapped binary the repo forbids +naming. + +The gap is not only cosmetic. cospec's own `WRAPPED_ENV` forces +`OPENSPEC_TELEMETRY=0` and `OPENSPEC_NO_COMPLETIONS=1` on every wrapped call, +and cospec generates its harness files from cospec canon rather than from +OpenSpec's `profile`/`workflows`/`delivery` keys — so several global config keys +are genuinely misleading when read through a cospec repo, and nothing tells the +user so. `defaultStore`, meanwhile, is a key cospec already _reads_ during root +resolution but offers no way to set. Wrapping `config` is what lets cospec state +that precedence out loud. `init` and `update` stay cospec-native by design and +are not part of this change. + +## What Changes + +- Add `cospec config` as a disciplined passthrough over `openspec config`, split + into two call classes. **Class A** (`path`, `list`, `get`, `set`, `unset`, + `reset --all -y`, `profile `) is a piped `passthroughOpenspec` call + with `expect.exitCodes = [0, 1]`, because upstream sets exit 1 for ordinary + negative results (missing key, invalid key) that are results to relay, not + wrapped-call violations. **Class B** (`edit`, `profile` with no preset, + `reset --all` without `-y`) is a terminal handover with inherited stdio and a + verbatim child exit code, including `130` on prompt cancellation — upstream + spawns `$EDITOR` and runs `@inquirer` menus, which cannot survive cospec's + piped `stdin: 'ignore'` spawn. +- `cospec config` deliberately does **not** route through + `core/passthrough-command.ts`. That helper appends `root.storeArgs`, a + trailing `--no-color`, and `--json` unconditionally; upstream's `config` has + no `--store` (it has a parent-level `--scope`), declares `--no-color` on the + program rather than the leaf and rejects a trailing copy, and supports + `--json` on `list` only. `commands/config.ts` gets a local argv builder + instead, the way `commands/workset.ts` already does, and never calls + `resolveRoot` at all — OpenSpec's config is machine-global, not root-scoped. +- Give `cospec config` a one-JSON-document contract on every subcommand, not + just the one upstream supports: `list --json` relays upstream's document + verbatim, and `path`/`get`/`set`/`unset`/`reset` emit cospec-owned + `version: 1` envelopes. `--json` against a Class B subcommand is refused with + an envelope and exit 1 rather than faked. +- Print two stderr notes (stderr, so `--json` stdout stays exactly one document) + where cospec's own behaviour overrides or bypasses the key just written: after + a successful `set telemetry.enabled`, that cospec forces + `OPENSPEC_TELEMETRY=0` on every wrapped call so the setting affects bare + `openspec` runs only; and after a successful + `profile`/`set profile|workflows|delivery`, that cospec's harness files come + from cospec canon via `cospec update`, not `openspec update`. +- Reject `cospec config --store ` with exit 1 and a named message rather + than silently ignoring an absorbed global flag that cannot apply. +- Add `cospec completion [bash|zsh|fish]`, generated natively from cospec's own + exported `COMMANDS` table and `GLOBAL_OPTIONS` — print-to-stdout only, no side + effects, shell auto-detected from `$SHELL` when omitted. Passing upstream's + generator through is rejected on principle: its installer writes a completion + function for the `openspec` binary into the user's shell rc, whose dynamic + completions shell out to bare `openspec` — a permanent instruction in a + dotfile to do the one thing this repo forbids. +- Add a hidden `cospec __complete ` that emits + tab-separated id and description lines and exits **1 silently** on any + failure, with no output on either stream, so a failure can never corrupt a Tab + press. +- Add `cospec feedback "" [--body ]`, filing at + `aligned-team/cospec` via `gh issue create` with array argv and no shell, and + `cospec feedback --upstream` relaying the wrapped `openspec feedback` (which + files at `Fission-AI/OpenSpec`) with a stderr note naming the destination. + Defaulting to upstream would route cospec bug reports to a project that cannot + fix them; excluding the surface leaves an `openspec` command unanswered. +- Extend the Codex prefix-rule allow-list with the read-only additions only — + `config get`, `config list`, `config path`, `completion`, `__complete` — + leaving `config set|unset|reset|edit|profile` and `feedback` unapproved for + the same reason `archive` is already omitted: they mutate machine-global state + or file a public issue. +- No breaking changes: every addition is a new subcommand or a new flag, and no + existing command's behaviour, exit codes, or output changes. + +## Non-Goals + +- `cospec completion install`/`uninstall`. Rc-file mutation with backups, + idempotency, and a matching uninstaller is the bulk of upstream's completion + code and earns its own change if users ask; docs ship copy-paste one-liners + instead. +- PowerShell completion. +- Wrapping `openspec init`/`openspec update`. Cospec-native by design — passing + them through would write the opsx files cospec's own leftover scan flags. +- Project-local config scope. Upstream exits 1 with + `Project-local config is not yet implemented`; cospec relays that verbatim and + adds nothing. +- Re-implementing upstream's config key validation, value coercion, or its + prototype-pollution guard. cospec never reads or writes + `~/.config/openspec/config.json` itself and grows no config file of its own. +- Changing `WRAPPED_ENV`. `OPENSPEC_TELEMETRY=0` and `OPENSPEC_NO_COMPLETIONS=1` + stay forced; `config set telemetry.enabled` is documented as affecting bare + `openspec` runs rather than honoured by making cospec's wrapped calls + configurable. +- Fixing the pre-existing trailing-`--no-color` hazard on the _other_ + passthroughs. `core/passthrough-command.ts` appends `--no-color` after the + subcommand on every wrapped call, and only upstream's `show` tolerates unknown + options — so `cospec schemas --no-color`, `cospec templates --no-color` and + friends are very likely broken today. This change proves the hazard is real + with a contract row and leaves the fix to a follow-up `fix` change. + +## Capabilities + +### New Capabilities + +- `openspec-config-passthrough`: `cospec config`'s two call classes, its argv + shaping rules (no `storeArgs`, no trailing `--no-color`, `--scope` hoisted + ahead of the subcommand, `--json` only on `list`), its `--json` envelope + shapes, the `--store` refusal, and the precedence notes cospec prints where + its own forced environment or canon-managed harness overrides the key just + written. +- `cospec-shell-completion`: `cospec completion`'s generated bash/zsh/fish + scripts derived from cospec's own command table, shell detection and its + failure modes, and the hidden `cospec __complete` dynamic source with its + silent-failure contract. +- `cospec-feedback`: `cospec feedback`'s native issue-filing flow against + `aligned-team/cospec` — title/body/provenance shaping, the `gh` availability + and authentication gates, the manual-submission fallback that exits 0, the + `--json` document — and the `--upstream` verbatim relay to OpenSpec's tracker. + +### Modified Capabilities + +- `openspec-read-passthroughs`: the terminal-handover contract that today + describes only `cospec workset open` becomes a named class whose members + include `cospec config edit|profile|reset`, fixing its obligations (inherited + stdio, `shell: false`, verbatim child exit code including `130`, no `--json`, + no `RunExpectation`) for every command that joins it. + +## Impact + +- New files: `apps/cli/src/commands/config.ts`, + `apps/cli/src/commands/completion.ts`, `apps/cli/src/commands/complete.ts`, + `apps/cli/src/commands/feedback.ts`, + `apps/cli/src/core/completions/{spec,bash,zsh,fish}.ts`, plus unit, + integration and contract tests for each. +- Modified: `apps/cli/src/cli.ts` (four `COMMANDS` entries — `config`, + `completion`, `feedback`, and a hidden `__complete` — and four literal + `COMMAND_MODULES` imports; a computed import silently breaks the compiled + standalone binary), `apps/cli/src/harness/adapters.ts` (`renderCodexRules` + allow-list), and `.codex/rules/cospec.rules` as regenerated output of + `mise run generate`. +- Tests: `apps/cli/test/integration/pack-standalone.test.ts` gains coverage for + the three new commands so the literal-import bundling trap stays guarded. +- External dependency at runtime, not at build time: `gh` for `cospec feedback`. + Absent or unauthenticated `gh` is a supported path, not an error — it prints a + prefilled issue URL and exits 0. Tests stub `gh` on `PATH`; no test touches + the network. +- Runtime minimum to resolve before implementation: cospec accepts wrapped + OpenSpec `>=1.0.0 <2.0.0`, and if `config` postdates 1.0.0 it gains a row in + the per-surface runtime-minimums table and relays upstream's own + unknown-command error rather than faking the feature. Root resolution is + unaffected either way — `readDefaultStore` already tolerates exit 1. +- Docs: `apps/docs/reference/commands.md` (three command rows plus the read-only + prose list, marking the mutating config subcommands as the exceptions), + `apps/docs/reference/configuration.md` (a new machine-global section owning + the precedence table, the two notes, and the envelope shapes), + `apps/docs/guide/installation.md` (per-shell completion snippets), + `docs/architecture.md` (the two config passthrough exceptions and the + terminal-handover class), and `.agents/shared.md` followed by + `mise run agents:sync`. +- No migration: no `schemaVersion` bump, no change to existing changes, specs, + or archived changes. + +## Surfaces + +- [x] interactive — three new user-typed commands, a new interactive + terminal-handover class (`config edit|profile`), shell completion for the + binary people actually type, and new stderr advisory notes. +- [ ] deploy — deploy/runtime/CI-execution topology (infra, Dockerfile, workflow + runtime, secrets, bind address) +- [x] integration — the config surface is a contract against the real pinned + OpenSpec binary (parent-level `--scope`, trailing-`--no-color` rejection, + `--json` on `list` only), and `cospec feedback` shells out to an external + `gh` binary and GitHub's issue API. +- [x] agent-behavior — the Codex prefix-rule allow-list grows five read-only + entries, and `cospec __complete` becomes a new machine-readable surface + agents and shells consume. diff --git a/openspec/changes/openspec-config-completion-feedback/specs/cospec-feedback/spec.md b/openspec/changes/openspec-config-completion-feedback/specs/cospec-feedback/spec.md new file mode 100644 index 0000000..9550065 --- /dev/null +++ b/openspec/changes/openspec-config-completion-feedback/specs/cospec-feedback/spec.md @@ -0,0 +1,90 @@ +## ADDED Requirements + +### Requirement: Feedback files against cospec's own tracker by default + +`cospec feedback "" [--body ]` SHALL file an issue against +`aligned-team/cospec` — the `bugs` URL in `apps/cli/package.json` — never +against OpenSpec's tracker. The issue title SHALL be `Feedback: ` with +whitespace collapsed and grapheme-aware truncation at 72 characters, and the +body SHALL carry a `## Summary` section with the message, an optional +`## Details` section when `--body` is given, and a provenance footer naming the +cospec version, the resolved wrapped OpenSpec source and version, the platform, +and an ISO timestamp. Submission SHALL use `gh issue create` with an array argv +and `shell: false`, so free-text message and body content never reaches a shell. +`--label` SHALL NOT be passed, deliberately removing upstream's +label-does-not-exist retry branch. + +#### Scenario: Title truncation is grapheme-safe + +- **WHEN** a feedback message longer than 72 graphemes, containing + multi-code-unit characters, is formatted +- **THEN** the title is truncated at 72 graphemes with an ellipsis and no + character is split + +#### Scenario: The message reaches gh as argv, never as shell text + +- **WHEN** a feedback message containing shell metacharacters is submitted +- **THEN** the `gh issue create` argv is an array containing the raw message and + body as separate elements, no shell is invoked, and the argv names + `aligned-team/cospec` and contains no `--label` + +#### Scenario: The body records how OpenSpec resolved + +- **WHEN** an issue body is generated +- **THEN** it names the cospec version, the wrapped OpenSpec resolution source + (project or embedded) and version, the platform, and an ISO timestamp + +### Requirement: Missing or unauthenticated gh is a supported path, not a failure + +`cospec feedback` SHALL gate submission on both `gh` being present on `PATH` and +`gh auth status` reporting an authenticated user. When either gate fails, the +command SHALL print the formatted title and body plus a prefilled +`https://github.com/aligned-team/cospec/issues/new` URL carrying the title and +body as query parameters, and SHALL exit **0** — manual submission is an +outcome, not an error. Any other `gh` failure SHALL relay gh's stderr, print the +same manual block, and exit with gh's own status, or 1 when gh reports none. + +#### Scenario: No gh on PATH still gives the user a way to file + +- **WHEN** `cospec feedback ""` runs with no `gh` on `PATH` +- **THEN** the formatted issue and a prefilled `aligned-team/cospec` issue URL + are printed and the command exits 0 + +#### Scenario: A successful submission reports the issue URL + +- **WHEN** `cospec feedback ""` runs against an authenticated `gh` +- **THEN** the created issue URL reported by gh is printed and the command exits + 0 + +#### Scenario: JSON reports submission state in one document + +- **WHEN** `cospec feedback "" --json` runs +- **THEN** stdout is exactly one JSON document carrying `version`, `command`, a + `submitted` boolean, a `url` that is a string or null, the `title`, and a + `repo` of `aligned-team/cospec` + +### Requirement: Upstream feedback is an explicit, version-asserted verbatim relay + +`cospec feedback --upstream` SHALL relay the invocation to the wrapped +`openspec feedback`, which files at `Fission-AI/OpenSpec`, and SHALL print one +stderr note naming that destination so the user cannot mistake it for cospec's +tracker. The relay SHALL assert the wrapped binary's version, spawn it piped +(upstream's feedback path prompts for nothing and `gh auth status` needs no +TTY), and relay stdout, stderr, and the child's exit code verbatim. This call +SHALL declare no `expect.exitCodes` allow-list — the documented exception here, +because upstream propagates gh's own arbitrary exit status, which no allow-list +can honestly enumerate. + +#### Scenario: The destination is named before relaying + +- **WHEN** `cospec feedback --upstream ""` runs +- **THEN** a stderr note states that the issue is being filed at + `Fission-AI/OpenSpec` rather than `aligned-team/cospec`, and the wrapped + `openspec feedback` is invoked + +#### Scenario: The child's exit code survives the relay + +- **WHEN** the wrapped `openspec feedback` exits with a code the shared + passthrough allow-list would reject +- **THEN** `cospec feedback --upstream` exits with exactly that code and relays + the wrapped stdout and stderr unchanged diff --git a/openspec/changes/openspec-config-completion-feedback/specs/cospec-shell-completion/spec.md b/openspec/changes/openspec-config-completion-feedback/specs/cospec-shell-completion/spec.md new file mode 100644 index 0000000..4efc5d2 --- /dev/null +++ b/openspec/changes/openspec-config-completion-feedback/specs/cospec-shell-completion/spec.md @@ -0,0 +1,90 @@ +## ADDED Requirements + +### Requirement: Completion scripts are generated from cospec's own command table + +`cospec completion [bash|zsh|fish]` SHALL print a shell completion script for +the `cospec` binary to stdout and exit 0, deriving every command name, summary, +and flag from cospec's own exported `COMMANDS` table and `GLOBAL_OPTIONS` rather +than from the wrapped binary's registry. The command SHALL have no side effects +— it SHALL NOT write to, read, or offer to modify any shell rc file — and SHALL +NOT emit any instruction that invokes bare `openspec`. Hidden command entries +SHALL be excluded from the generated script. Per-command flags SHALL be +extracted from each entry's help text by a pure function covered by a snapshot +test, so a command or flag the extractor cannot parse fails the build rather +than silently disappearing from completion. + +#### Scenario: Every non-hidden command appears in each shell's script + +- **WHEN** `cospec completion bash`, `cospec completion zsh`, and + `cospec completion fish` are generated +- **THEN** each script lists every non-hidden command in `COMMANDS`, lists no + hidden entry, contains no `openspec` invocation, and per-command flags match + the extraction snapshot + +#### Scenario: Generated scripts parse in their own shells + +- **WHEN** each generated script is fed to its shell's syntax check +- **THEN** `bash -n`, `zsh -n`, and `fish --no-execute` all accept it without + error + +#### Scenario: Generating a script writes nothing + +- **WHEN** `cospec completion zsh` runs +- **THEN** the script is written to stdout only, and no rc file or completion + directory on disk is created or modified + +### Requirement: Completion shell resolution and its refusals + +`cospec completion` invoked with no shell argument SHALL detect the shell from +the basename of `$SHELL`, stripping a leading `-`, and SHALL NOT fork a process +to probe its parent. An undetectable or unsupported shell SHALL exit 1 with a +message naming the supported shells and the explicit `cospec completion ` +form. `cospec completion --json` SHALL exit 1 with a one-document error +envelope, because a shell script is not a JSON document and emitting it under +`--json` would break the single-document invariant. + +#### Scenario: Shell is detected from the environment + +- **WHEN** `cospec completion` runs with `SHELL=/bin/zsh` +- **THEN** the zsh script is printed and the command exits 0 + +#### Scenario: An unsupported shell is named, not guessed + +- **WHEN** `cospec completion` runs with `SHELL=/bin/tcsh` +- **THEN** the command exits 1, names bash, zsh, and fish as supported, and + prints no script + +#### Scenario: JSON is refused for a shell script + +- **WHEN** `cospec completion zsh --json` is invoked +- **THEN** the command exits 1 emitting exactly one JSON error document and no + shell script + +### Requirement: The dynamic completion source fails silently + +`cospec __complete ` SHALL be a hidden command emitting one +tab-separated id and description pair per line on stdout, and SHALL exit 1 with +**no output on stdout or stderr** on any failure — an unresolvable root, a +wrapped-call error, an unknown source name — because a Tab press must never be +corrupted by an error message. `changes` and `specs` SHALL be sourced from the +existing typed wrapped list calls; `types` SHALL be sourced from `COSPEC_TYPES` +with no wrapped spawn at all. + +#### Scenario: Change ids complete inside a repo + +- **WHEN** `cospec __complete changes` runs in a repo with active changes +- **THEN** stdout lists each active change id with a tab-separated description + and the command exits 0 + +#### Scenario: Types complete without spawning the wrapped binary + +- **WHEN** `cospec __complete types` runs +- **THEN** the eleven cospec conventional-commit types are listed and no wrapped + binary is spawned + +#### Scenario: Failure is silent on both streams + +- **WHEN** `cospec __complete changes` runs outside any resolvable openspec + root, or `cospec __complete nonsense` is invoked +- **THEN** the command exits 1 having written nothing to stdout and nothing to + stderr diff --git a/openspec/changes/openspec-config-completion-feedback/specs/openspec-config-passthrough/spec.md b/openspec/changes/openspec-config-completion-feedback/specs/openspec-config-passthrough/spec.md new file mode 100644 index 0000000..d8eab2a --- /dev/null +++ b/openspec/changes/openspec-config-completion-feedback/specs/openspec-config-passthrough/spec.md @@ -0,0 +1,152 @@ +## ADDED Requirements + +### Requirement: Config argv is built locally, not by the shared passthrough helper + +`cospec config` SHALL build its wrapped argv with a local, pure builder rather +than routing through `core/passthrough-command.ts`, and SHALL NOT call +`resolveRoot`, because OpenSpec's config is machine-global rather than +root-scoped. The built argv SHALL never contain `--store` or any store argument, +SHALL never append a trailing `--no-color` (cospec's spawn already prefixes +`--no-color` ahead of the subcommand, and upstream declares the flag on the +program rather than the leaf), SHALL append `--json` only for the `list` +subcommand, and SHALL emit an extracted `--scope ` between `config` and +the subcommand rather than after it. A `--scope` value other than `global` SHALL +be relayed to the wrapped binary unmodified so upstream's own refusal is what +the user sees. + +#### Scenario: Built argv carries no store and no trailing no-color + +- **WHEN** the argv builder runs for each of `path`, `list`, `get`, `set`, + `unset`, `reset`, `profile`, and `edit` +- **THEN** no built argv contains a `--store` token or a `--no-color` token, and + `--json` appears only in the argv built for `list` + +#### Scenario: Scope is hoisted ahead of the subcommand + +- **WHEN** `cospec config get --scope global` is invoked, in either the + `--scope global` or `--scope=global` spelling +- **THEN** the built argv is `config --scope global get `, with the scope + option ahead of the subcommand + +#### Scenario: A store flag is refused rather than ignored + +- **WHEN** `cospec config --store list` is invoked +- **THEN** the command exits 1 with a message stating that `--store` does not + apply because OpenSpec config is machine-global, and no wrapped binary is + spawned + +#### Scenario: A missing subcommand is a usage error + +- **WHEN** `cospec config` is invoked with no subcommand +- **THEN** the command exits 1 with a usage message listing the supported + subcommands, and no wrapped binary is spawned + +### Requirement: Non-interactive config subcommands are piped disciplined passthroughs + +`cospec config path|list|get|set|unset|reset --all -y|profile ` SHALL +run as piped `passthroughOpenspec` calls declaring `expect.exitCodes` of +`[0, 1]`, because upstream sets exit 1 for ordinary negative results — an unset +key, an unknown key, an invalid stored config — which are results to relay +rather than wrapped-call violations. Any other exit code, or a deny-listed +stdout marker, SHALL surface as a cospec failure rather than as a relayed +result. Wrapped stdout and stderr SHALL be relayed verbatim, and cospec SHALL +NOT re-implement upstream's key validation, value coercion, or its +prototype-pollution guard, nor read or write the global config file itself. + +#### Scenario: Reading the config path and list succeeds + +- **WHEN** `cospec config path` and `cospec config list --json` run against the + real pinned binary with a sandboxed config home +- **THEN** `path` prints the global config path and exits 0, and `list --json` + emits exactly one parseable JSON document relayed verbatim from upstream + +#### Scenario: An unset key is relayed as exit 1, not as a wrapper failure + +- **WHEN** `cospec config get ` runs for a key with no stored value +- **THEN** the command exits 1 with upstream's own message and does not report a + wrapped-call discipline violation + +### Requirement: Interactive config subcommands hand over the terminal + +Three config subcommands SHALL be terminal-handover execs: `cospec config edit`, +`cospec config profile` with no preset argument, and `cospec config reset --all` +invoked without `-y`/`--yes`. The wrapped binary is version-asserted first, then +spawned with inherited stdio, `shell: false`, and the handover environment +`BUN_BE_BUN=1`, `OPENSPEC_TELEMETRY=0`, `OPENSPEC_NO_COMPLETIONS=1`, and the +child's exit code SHALL be propagated unchanged — including `130`, which +upstream sets when a prompt is cancelled. These calls SHALL declare no +`RunExpectation`, the documented exception the terminal-handover class already +carries, because inherited stdio leaves nothing for a stdout deny-list to +inspect. + +#### Scenario: Editing hands the terminal to the editor + +- **WHEN** `cospec config edit` is invoked +- **THEN** cospec spawns the wrapped `openspec config edit` with inherited stdio + and exits with exactly the child's exit code + +#### Scenario: A cancelled prompt propagates 130 + +- **WHEN** `cospec config profile` is cancelled at its interactive menu and the + wrapped process exits 130 +- **THEN** `cospec config profile` exits 130 rather than normalising the code + +#### Scenario: A non-TTY caller gets upstream's own refusal + +- **WHEN** `cospec config profile` runs with no preset and no TTY attached +- **THEN** upstream's own interactive-mode-required error is relayed verbatim + and its exit code propagated, with no cospec-invented substitute + +### Requirement: Every config subcommand honours the one-JSON-document invariant + +`cospec config --json` SHALL emit exactly one parseable JSON document on stdout +for every subcommand, not only for the one upstream supports. `list --json` +SHALL relay upstream's document verbatim under the single-document check. +`path`, `get`, `set`, `unset`, and `reset` SHALL emit cospec-owned envelopes +carrying `version: 1` and the invoked `command`, with `get` reporting the raw +printed string in `value` plus a `found` boolean, and `set`/`unset`/`reset` +reporting an `ok` boolean and a `message`. `--json` against a terminal-handover +subcommand SHALL be refused with an envelope whose `ok` is `false` and exit 1, +never faked by suppressing the interaction. + +#### Scenario: A cospec-owned envelope is exactly one document + +- **WHEN** `cospec config get --json` runs +- **THEN** stdout is exactly one JSON document with `version: 1`, + `found: false`, and a null `value`, and the command exits 1 + +#### Scenario: JSON is refused for an interactive subcommand + +- **WHEN** `cospec config edit --json` is invoked +- **THEN** stdout is exactly one JSON document reporting `ok: false` and naming + the subcommand as interactive, the command exits 1, and no editor is spawned + +### Requirement: Config notes name the keys cospec's own behaviour overrides + +`cospec config` SHALL print an advisory note on **stderr** — never stdout, so +the one-JSON-document invariant holds — after a successful write to a key whose +effect cospec overrides or bypasses. After `set telemetry.enabled`, the note +SHALL state that cospec forces `OPENSPEC_TELEMETRY=0` on every wrapped call, so +the setting affects bare `openspec` runs only. After a successful `profile`, or +a `set` of `profile`, `workflows`, or `delivery`, the note SHALL state that +cospec's harness files are generated from cospec canon and direct the user to +`cospec update` rather than upstream's suggested `openspec update`. No other key +SHALL be annotated. + +#### Scenario: Telemetry note accompanies a successful write + +- **WHEN** `cospec config set telemetry.enabled true` succeeds +- **THEN** the forced-environment note appears on stderr, stdout carries only + the wrapped success output, and under `--json` stdout is still exactly one + document + +#### Scenario: Profile note redirects to cospec update + +- **WHEN** `cospec config profile ` succeeds +- **THEN** a stderr note states that cospec's harness files come from cospec + canon and names `cospec update` as the command to run + +#### Scenario: An unannotated key produces no note + +- **WHEN** `cospec config set defaultStore ` succeeds +- **THEN** no advisory note is printed on stderr diff --git a/openspec/changes/openspec-config-completion-feedback/specs/openspec-read-passthroughs/spec.md b/openspec/changes/openspec-config-completion-feedback/specs/openspec-read-passthroughs/spec.md new file mode 100644 index 0000000..e2f160a --- /dev/null +++ b/openspec/changes/openspec-config-completion-feedback/specs/openspec-read-passthroughs/spec.md @@ -0,0 +1,44 @@ +## MODIFIED Requirements + +### Requirement: Workset group passthrough with a terminal-handover exec + +`cospec workset create|list|remove` SHALL wrap the corresponding +`openspec workset` subcommand, preserving `--member`/`--tool`/`--yes`/`--json` +and the JSON one-document failure mirror. `cospec workset open` SHALL be a +terminal-handover exec, the founding member of cospec's **terminal-handover +class**: a wrapped call whose child owns the terminal because it spawns an +editor or drives an interactive prompt. Every member of that class SHALL +version-assert the wrapped binary first, spawn it with inherited stdio and +`shell: false` (array argv, no shell interpolation), propagate the child +process's exit code unchanged — including `130`, which the wrapped binary sets +when a prompt is cancelled — emit no JSON of its own, and declare no +`RunExpectation`, which is the documented exception to wrapped-call discipline +because inherited stdio leaves no captured stdout for a deny-list to inspect. +`cospec workset open` SHALL NOT thread `--json` or `--no-color`, matching +OpenSpec's own rejection of `--json` for that subcommand. The class's other +members are the interactive `cospec config` subcommands, whose obligations are +specified by the config passthrough capability. + +#### Scenario: Create then list shows the workset + +- **WHEN** `cospec workset create ` succeeds and is followed by + `cospec workset list --json` +- **THEN** the JSON list includes an entry for `` + +#### Scenario: Remove without confirmation is refused + +- **WHEN** `cospec workset remove ` runs non-interactively without `--yes` +- **THEN** the command refuses and exits non-zero without deleting the workset + +#### Scenario: Open propagates the child's exit code + +- **WHEN** `cospec workset open ` is invoked +- **THEN** the command spawns `openspec workset open ` with inherited + stdio and exits with exactly the child process's exit code + +#### Scenario: A handover call declares no run expectation + +- **WHEN** any terminal-handover member is invoked +- **THEN** it asserts the wrapped binary's version, spawns with inherited stdio + and `shell: false`, declares no `RunExpectation`, and emits no JSON document + of its own diff --git a/openspec/changes/openspec-config-completion-feedback/tasks.md b/openspec/changes/openspec-config-completion-feedback/tasks.md new file mode 100644 index 0000000..ad03abf --- /dev/null +++ b/openspec/changes/openspec-config-completion-feedback/tasks.md @@ -0,0 +1,139 @@ +## 1. WI-0 Preflight + +- [ ] 1.1 Determine the earliest wrapped-OpenSpec version that ships `config`, + `completion`, and `feedback` by probing the upstream changelog and the + pinned 1.11.0 binary, and verify by recording the finding in the change + and, where a surface postdates the 1.0.0 floor, adding its row to the + per-surface runtime-minimums table rather than raising + `OPENSPEC_VERSION_FLOOR` +- [ ] 1.2 Confirm against the real pinned binary whether `openspec schemas` + emits `--json`, and verify by recording the answer as the decision to + include or omit `schemas` as a `__complete` source (omitted unless + confirmed) + +## 2. WI-1 cospec config + +- [ ] 2.1 Add `apps/cli/src/commands/config.ts` with a pure argv builder that + hoists `--scope`, appends `--json` only for `list`, and never emits + `--store` or `--no-color`, and verify with unit assertions over the built + argv for all eight subcommands (ledger 1.1) +- [ ] 2.2 Wire the Class A subcommands through `passthroughOpenspec` with + `expect.exitCodes = [0, 1]`, a stdout deny-list, and no `resolveRoot` + call, and verify with integration runs of `path`, `list --json`, and `get` + on an unset key against the real binary under a sandboxed + `XDG_CONFIG_HOME`/`HOME` (ledger 1.3, 1.4) +- [ ] 2.3 Refuse `cospec config --store ` and a missing subcommand with exit + 1 and named messages, and verify with unit tests asserting zero wrapped + spawns on both paths (ledger 1.2) +- [ ] 2.4 Implement the Class B terminal handover for `edit`, bare `profile`, + and unconfirmed `reset --all` — version assertion, inherited stdio, + `shell: false`, handover env with `OPENSPEC_NO_COMPLETIONS=1`, verbatim + exit code including 130 — and verify with the `EDITOR=true` and non-TTY + integration runs (ledger 2.1, 2.2) +- [ ] 2.5 Implement the `--json` envelopes for + `path`/`get`/`set`/`unset`/`reset`, the verbatim `list --json` relay, and + the Class B `--json` refusal, and verify each invocation emits exactly one + parseable document (ledger 1.4, 2.3) +- [ ] 2.6 Implement the stderr advisory notes for `telemetry.enabled` and for + `profile`/`workflows`/`delivery`, and verify with the stream-separated + integration runs plus a unit test over the note selector across every + known key (ledger 3.1, 3.2, 3.3) + +## 3. WI-2 cospec completion + +- [ ] 3.1 Add `apps/cli/src/core/completions/spec.ts` deriving a + `CompletionSpec` from the exported `COMMANDS` table and `GLOBAL_OPTIONS`, + including the pure per-command flag extractor and the dynamic-argument + slot declarations, and verify with a snapshot unit test over the real + table (ledger 4.1) +- [ ] 3.2 Add the `bash.ts`, `zsh.ts`, and `fish.ts` renderers, and verify each + generated script parses under `bash -n`, `zsh -n`, and `fish --no-execute` + (ledger 4.2) +- [ ] 3.3 Add `apps/cli/src/commands/completion.ts` with `$SHELL` basename + detection, the unsupported-shell refusal, the `--json` refusal, and no + filesystem writes, and verify with the detection integration run that + snapshots `HOME` before and after (ledger 4.3) +- [ ] 3.4 Add `apps/cli/src/commands/complete.ts` serving `changes`, `specs`, + and `types` with the silent exit-1 failure contract, and verify with the + seeded-repo run, the rootless and unknown-source runs asserting both + streams empty, and the spawn-spy test for `types` (ledger 5.1, 5.2, 5.3) + +## 4. WI-3 cospec feedback + +- [ ] 4.1 Add `apps/cli/src/commands/feedback.ts` title, body, and provenance + formatting — grapheme-aware 72-char truncation, Summary/Details sections, + cospec version plus wrapped-OpenSpec resolution source and version — and + verify with unit tests over both resolution sources (ledger 6.1, 6.6) +- [ ] 4.2 Implement submission via `gh issue create` with array argv, + `shell: false`, no `--label`, and the `gh` presence plus `gh auth status` + gates, and verify with the authenticated stub-`gh` integration run + asserting the received argv (ledger 6.2) +- [ ] 4.3 Implement the manual-submission fallback (formatted block plus + prefilled `aligned-team/cospec` issue URL, exit 0) and the + other-gh-failure path, and verify with the `gh`-absent and + `gh auth status`-failing integration runs (ledger 6.3) +- [ ] 4.4 Implement `--upstream` as a version-asserted piped relay with a + verbatim child exit code and the destination note on stderr, and verify + with the stub-`gh` run whose exit code falls outside the shared allow-list + (ledger 6.4) +- [ ] 4.5 Implement the `--json` document with `submitted`, `url`, `title`, and + `repo`, and verify it is exactly one parseable document on both the + submitted and the manual-fallback paths (ledger 6.2) + +## 5. WI-4 Registration and harness + +- [ ] 5.1 Add the four `COMMANDS` entries (`config`, `completion`, `feedback`, + hidden `__complete`) and four literal `COMMAND_MODULES` imports to + `apps/cli/src/cli.ts`, and verify `cospec --help` lists the three visible + commands, omits `__complete`, and each dispatches rather than reporting + "not yet implemented" +- [ ] 5.2 Extend `renderCodexRules` in `apps/cli/src/harness/adapters.ts` with + the five read-only prefixes only, run `mise run generate`, and verify with + `mise run generate:check` clean plus assertions that the mutating prefixes + are absent from `.codex/rules/cospec.rules` (ledger 7.2) + +## 6. WI-5 Tests and packaging guards + +- [ ] 6.1 Land the unit suites (`config-args`, `feedback-format`, `completions`) + and the integration suites (`config`, `completion`, `feedback`) that the + ledger rows above name, and verify `mise run test` and + `mise run test:integration` are green with no pre-existing test weakened +- [ ] 6.2 Land `apps/cli/test/contract/config-surface.test.ts` against the real + pinned binary with `XDG_CONFIG_HOME` and `HOME` sandboxed into a temp dir, + including the trailing-`--no-color` rejection row, and verify + `mise run test:contract` is green and the developer's real global config + is untouched (ledger 1.5) +- [ ] 6.3 Extend `apps/cli/test/integration/pack-standalone.test.ts` to run + `cospec config path`, `cospec completion zsh`, and + `cospec feedback --help` from the packed standalone binary, and verify + `mise run test:pack` is green (ledger 7.1) +- [ ] 6.4 Run the ledger end to end on the final branch state, recording an + observed result after each row's arrow or a `[~] defer:` reason, and + verify `mise run check` is green (ledger 7.4) + +## 7. WI-6 Docs and agent guidance + +- [ ] 7.1 Update `apps/docs/reference/commands.md` with rows for the three + commands and the read-only prose list marking the mutating config + subcommands as exceptions, and verify `mise run docs:build` is green with + each fact stated on exactly one page (ledger 8.1) +- [ ] 7.2 Add the machine-global OpenSpec config section to + `apps/docs/reference/configuration.md` — precedence table, the two stderr + notes, the `defaultStore` resolution order cross-linked to Stores, the + `--json` envelope shapes — and verify no other page restates those facts + (ledger 8.1) +- [ ] 7.3 Add the per-shell completion install snippets to + `apps/docs/guide/installation.md`, and verify each snippet is + copy-pasteable and matches the shell names the command actually accepts + (ledger 8.1, 4.3) +- [ ] 7.4 Extend `docs/architecture.md` with the two config passthrough + exceptions and the terminal-handover class shared by `workset open` and + `config edit|profile|reset`, and verify by reading the section against the + shipped code (ledger 8.3) +- [ ] 7.5 Update `.agents/shared.md` so the every-everyday-surface paragraph + names `config`, `completion`, and `feedback`, run `mise run agents:sync`, + and verify `mise run agents:check` is clean (ledger 8.2) +- [ ] 7.6 Record the trailing-`--no-color` hazard on the other passthroughs as a + proposed follow-up `fix` change, citing the contract row as its evidence, + and verify the follow-up is written down rather than silently fixed in + this change diff --git a/openspec/changes/openspec-config-completion-feedback/verification.md b/openspec/changes/openspec-config-completion-feedback/verification.md new file mode 100644 index 0000000..392635e --- /dev/null +++ b/openspec/changes/openspec-config-completion-feedback/verification.md @@ -0,0 +1,62 @@ + + + + + + +## 1. cospec config reaches every subcommand of a surface the shared helper cannot call [critical] + +- [ ] 1.1 @unit (agent) build the argv for each of `path`, `list`, `get`, `set`, `unset`, `reset`, `profile`, `edit` and inspect every token -> no argv contains `--store` or `--no-color`; `--json` appears only for `list`; `--scope ` and `--scope=` both re-emit as `config --scope …` with the scope ahead of the subcommand +- [ ] 1.2 @unit (agent) invoke `cospec config --store x list` and `cospec config` with no subcommand against a spawn spy -> both exit 1 with their named messages and the spy records zero wrapped spawns +- [ ] 1.3 @integration (agent) run `cospec config path` and `cospec config list --json` against the real pinned binary under a sandboxed `XDG_CONFIG_HOME`/`HOME` -> `path` prints the sandboxed config path at exit 0; `list --json` is exactly one parseable document carrying `profile` and `delivery` +- [ ] 1.4 @integration (agent) run `cospec config get ` plain and with `--json` -> plain form exits 1 with upstream's message and no partial JSON; `--json` form exits 1 emitting exactly one document with `version:1`, `found:false`, `value:null` +- [ ] 1.5 @integration (agent) contract suite: append a trailing `--no-color` to a real `openspec config get` invocation -> the wrapped binary rejects it non-zero, proving rule 1.1's no-trailing-`--no-color` constraint is load-bearing rather than superstition (and leaving the follow-up `fix` change its evidence) +- [ ] 1.6 @integration (agent) `cospec config set defaultStore ` under a sandboxed home, then run a cospec command from a directory with no `openspec/` above it, then `cospec config unset defaultStore` and repeat -> the first run resolves against the named store, the second falls back to the local cwd, and neither path outranks a local `openspec/` root + +## 2. Interactive config subcommands hand over the terminal instead of hanging [critical] + +- [ ] 2.1 @integration (agent) run `cospec config edit` with `EDITOR=true` under a sandboxed home -> the child is spawned with inherited stdio, exits 0, and cospec exits 0; with `EDITOR` and `VISUAL` unset, upstream's own error block is relayed with its exit code +- [ ] 2.2 @integration (agent) run `cospec config profile` with no preset and no TTY -> upstream's own interactive-mode-required error is relayed verbatim with its exit code, and no cospec-invented substitute message appears +- [ ] 2.3 @integration (agent) run `cospec config edit --json` and `cospec config profile --json` -> each exits 1 emitting exactly one JSON document with `ok:false` naming the subcommand as interactive, and no editor or menu is spawned +- [ ] 2.4 @manual (human) run `cospec config profile` in a real TTY and cancel at the menu with Ctrl-C; run `cospec config reset --all` in a real TTY and answer no -> the upstream menu renders and cancellation exits 130 unchanged; the reset confirm renders and declining leaves the config file byte-identical + +## 3. cospec config never lets a misleading key pass silently [critical] + +- [ ] 3.1 @integration (agent) `cospec config set telemetry.enabled true` under a sandboxed home, capturing the two streams separately -> the forced-`OPENSPEC_TELEMETRY=0` note is on stderr and absent from stdout; under `--json` stdout is still exactly one parseable document +- [ ] 3.2 @integration (agent) `cospec config profile ` and `cospec config set delivery ` -> each prints the canon-managed-harness note naming `cospec update` on stderr; `cospec config set defaultStore ` prints no note at all +- [ ] 3.3 @unit (agent) drive the note selector across every known config key -> exactly `telemetry.enabled`, `profile`, `workflows`, and `delivery` are annotated, and a failed write is annotated for none of them + +## 4. Shell completion covers every cospec command and parses in its own shell [critical] + +- [ ] 4.1 @unit (agent) generate all three scripts from the real `COMMANDS` table -> each lists every non-hidden command, lists no hidden entry, contains no `openspec` token, and per-command flags match the extraction snapshot +- [ ] 4.2 @integration (agent) pipe each generated script through `bash -n`, `zsh -n`, and `fish --no-execute` -> all three parse clean with no diagnostics +- [ ] 4.3 @integration (agent) run `cospec completion` with `SHELL=/bin/zsh`, with `SHELL=/bin/tcsh`, and with `--json`; snapshot the temp `HOME` before and after -> zsh is detected and printed at exit 0; tcsh exits 1 naming bash/zsh/fish; `--json` exits 1 with one JSON error document and no script; `HOME` is unchanged in every case +- [ ] 4.4 @manual (human) install the generated zsh and fish scripts in a real interactive session and press Tab after `cospec ` and after `cospec show ` -> commands complete with their summaries and change ids complete from the live repo + +## 5. The dynamic completion source can never corrupt a Tab press [critical] + +- [ ] 5.1 @integration (agent) run `cospec __complete changes` and `cospec __complete specs` in a seeded repo -> each emits tab-separated id/description lines covering the repo's active changes and capability specs, exit 0 +- [ ] 5.2 @integration (agent) run `cospec __complete changes` from a directory with no resolvable root, and `cospec __complete nonsense` -> both exit 1 with stdout empty and stderr empty, byte for byte +- [ ] 5.3 @unit (agent) run `cospec __complete types` against a spawn spy -> the eleven `COSPEC_TYPES` values are listed and the spy records zero wrapped spawns + +## 6. Feedback files where it says it files, and never through a shell [critical] + +- [ ] 6.1 @unit (agent) format a title from a message longer than 72 graphemes containing emoji and combining marks, and build the gh argv for a message containing shell metacharacters -> the title truncates at 72 graphemes with an ellipsis and splits no character; the argv is an array carrying the raw message and body as separate elements, names `aligned-team/cospec`, contains no `--label`, and no shell is invoked +- [ ] 6.2 @integration (agent) run `cospec feedback ""` with a stub `gh` on `PATH` reporting authenticated and echoing an issue URL, plain and with `--json` -> the stub receives an argv targeting `aligned-team/cospec` with no `--label`; the URL is printed; `--json` emits exactly one document with `submitted:true`, that `url`, the title, and `repo:"aligned-team/cospec"` +- [ ] 6.3 @integration (agent) run `cospec feedback ""` with `gh` absent from `PATH`, then with a stub `gh` whose `auth status` fails -> both print the formatted issue plus a prefilled `aligned-team/cospec` issue URL and exit **0** +- [ ] 6.4 @integration (agent) run `cospec feedback --upstream ""` with a stub `gh` that exits with a code outside the shared passthrough allow-list -> stderr carries the note naming `Fission-AI/OpenSpec`, the wrapped stdout/stderr are relayed unchanged, and cospec exits with exactly the child's code +- [ ] 6.5 @manual (human) run one real `cospec feedback` against an authenticated `gh` -> an issue is created at `aligned-team/cospec` with the expected title, provenance footer, and no label, and its URL is printed +- [ ] 6.6 @unit (agent) inspect the provenance footer for a project-resolved and an embedded-resolved wrapped binary -> each footer names the correct resolution source and version alongside the cospec version, platform, and ISO timestamp + +## 7. The three commands survive registration and the compiled standalone binary [critical] + +- [ ] 7.1 @integration (agent) `mise run test:pack` plus `pack-standalone.test.ts` extended to run `cospec config path`, `cospec completion zsh`, and `cospec feedback --help` from the packed standalone binary -> all three succeed with no `node_modules` present, proving the literal-`import()` bundling trap is not tripped +- [ ] 7.2 @integration (agent) `mise run generate` then `mise run generate:check` -> the drift gate is clean and `.codex/rules/cospec.rules` contains prefix rules for `config get`, `config list`, `config path`, `completion`, and `__complete`, and none for `config set`, `config unset`, `config reset`, `config edit`, `config profile`, or `feedback` +- [ ] 7.3 @eval (agent) `mise run eval:e2e` against the regenerated harness files, comparing the run to the pre-change baseline -> no regression in the advisory DeepSeek scores, and no eval transcript in which an agent reaches for bare `openspec config`, `openspec completion`, or `openspec feedback` now that a cospec command answers each (advisory only, never a CI gate; defer with a recorded reason if no API key is available to the pass) +- [ ] 7.4 @regression (agent) `mise run check` on the final branch state -> lint, format, typecheck, unit, contract, integration, and pack smoke all green, with no pre-existing test edited to accommodate the new commands + +## 8. Docs and agent guidance ship with the behaviour, not after it + +- [ ] 8.1 @integration (agent) `mise run docs:build` -> green, with `apps/docs/reference/commands.md`, `reference/configuration.md`, and `guide/installation.md` carrying the new rows, the precedence table, the envelope shapes, and the per-shell install snippets, each fact on exactly one page +- [ ] 8.2 @integration (agent) update `.agents/shared.md`, run `mise run agents:sync`, then `mise run agents:check` -> clean, with `CLAUDE.md` and `AGENTS.md` both naming `config`, `completion`, and `feedback` in the every-everyday-surface paragraph +- [ ] 8.3 @manual (human) read `docs/architecture.md`'s passthrough section against the shipped code -> the two config exceptions (no `storeArgs`, no trailing `--no-color`) and the terminal-handover class shared by `workset open` and `config edit|profile|reset` are described as implemented, with no stale claim that every passthrough uses the shared helper From 43d05e946b5cfc5e8eaf7751974a2a2eb873038f Mon Sep 17 00:00:00 2001 From: replygirl Date: Sat, 5 Sep 2026 12:34:29 -0500 Subject: [PATCH 3/6] fix(cli): correct the falsified trailing --no-color claim and two weak tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract suite's own probe shows a trailing `--no-color` is ACCEPTED on every `openspec config` subcommand (commander resolves a program-level option from a leaf), but proposal.md, design.md, verification.md row 1.5, the delta spec, and tasks.md 7.6 all still asserted a rejection — and tasks.md 7.6 seeded a follow-up `fix` change on that falsified premise. Correct every artefact to the observed behaviour, probe the flag without a leading `--no-color` in the argv (so the probe is about the trailing copy alone), and add a row proving `schemas`/`templates` accept it too, which settles the supposed hazard on the shared passthrough helper instead of handing it on. Also fix two tests that asserted less than they claimed: - pack-standalone ran `cospec feedback --help`, which `cli.ts` answers from the static COMMANDS table before it ever looks up COMMAND_MODULES — it passes even when the module was dropped from the compiled binary, the exact regression the row exists to catch. Run the command itself with `gh` stripped from PATH (manual-submission fallback, exit 0, `submitted: false`). - The `--upstream` gh-failure row only asserted a non-0/non-1 exit; upstream does `process.exit(error.status ?? 1)`, so assert the exact code (42). --- apps/cli/test/contract/config-surface.test.ts | 74 +++++++++++-------- apps/cli/test/fixtures/support.ts | 16 ++++ apps/cli/test/integration/feedback.test.ts | 10 ++- .../test/integration/pack-standalone.test.ts | 31 +++++++- .../design.md | 47 +++++++----- .../proposal.md | 36 ++++----- .../specs/openspec-config-passthrough/spec.md | 12 +-- .../tasks.md | 11 +-- .../verification.md | 2 +- 9 files changed, 152 insertions(+), 87 deletions(-) diff --git a/apps/cli/test/contract/config-surface.test.ts b/apps/cli/test/contract/config-surface.test.ts index 72d220c..61c70cb 100644 --- a/apps/cli/test/contract/config-surface.test.ts +++ b/apps/cli/test/contract/config-surface.test.ts @@ -4,28 +4,27 @@ // machine-global OpenSpec config. // // Trailing `--no-color` finding (probed directly against the pinned 1.11.0 -// binary via `openspec()` below, bypassing cospec entirely): a trailing -// `--no-color` on every `config` subcommand — `path`, `get`, `set`, `unset`, -// `list`, `reset --all -y` — is ACCEPTED (exit 0/1 exactly as without it, -// never a "unknown option" error), because `--no-color` is declared on the -// root `Command` and commander resolves a parent option from a leaf -// regardless of whether that leaf's own `--help` lists it. This confirms -// `apps/cli/src/commands/config.ts`'s header comment (point 3) and -// contradicts the change's own `verification.md` row 1.5, `design.md`'s -// decision text, `proposal.md`, and `specs/openspec-config-passthrough/spec.md`, -// all of which assert the opposite (a REJECTED trailing `--no-color`) as the -// premise for `tasks.md` 7.6's proposed follow-up `fix` change. This suite -// asserts the real, observed behavior — acceptance — and documents the -// discrepancy rather than encoding a false expectation. (cospec's own argv -// builder still never appends `--no-color`, per the same header: not to dodge -// a rejection that turns out not to exist, but so the built argv carries -// nothing the wrapped call did not need.) +// binary via `openspecRaw()` below — bypassing cospec entirely, and with no +// leading `--no-color` in the argv, so the probe is about the trailing copy +// alone): a trailing `--no-color` is ACCEPTED on every `config` subcommand — +// `path`, `get`, `set`, `unset`, `list`, `reset --all -y` — and on the plain +// leaf commands the shared passthrough helper drives (`schemas`, `templates`), +// exiting exactly as it does without the flag rather than raising an "unknown +// option" error. Commander resolves an option declared on the root `Command` +// from a leaf regardless of whether that leaf's own `--help` lists it. +// +// This is the load-bearing fact for two claims elsewhere: `commands/config.ts` +// omits a trailing `--no-color` because it is redundant (cospec's spawn already +// prefixes one), NOT because upstream rejects it; and there is therefore no +// trailing-`--no-color` hazard on the other passthroughs to fix. The change's +// artefacts (`proposal.md`, `design.md`, `verification.md`, the delta spec) once +// asserted a rejection; they were corrected to match what this suite observes. import { afterAll, describe, expect, test } from 'bun:test' import { mkdirSync } from 'node:fs' import { join } from 'node:path' -import { cleanupAll, mkTempRepo, openspec } from '../fixtures/support.ts' +import { cleanupAll, mkTempRepo, openspec, openspecRaw } from '../fixtures/support.ts' afterAll(cleanupAll) @@ -41,19 +40,19 @@ function sandbox(): { cwd: string; env: Record } { } } -describe('real pinned binary: a trailing --no-color on config subcommands', () => { +describe('real pinned binary: a trailing --no-color is accepted, not rejected', () => { test('config path --no-color exits 0, same as without it', async () => { const { cwd, env } = sandbox() - const withFlag = await openspec(['config', 'path', '--no-color'], cwd, env) - const without = await openspec(['config', 'path'], cwd, env) + const withFlag = await openspecRaw(['config', 'path', '--no-color'], cwd, env) + const without = await openspecRaw(['config', 'path'], cwd, env) expect(withFlag.exitCode).toBe(0) expect(withFlag.stdout).toBe(without.stdout) }) test('config get --no-color exits 0 and prints the value unchanged', async () => { const { cwd, env } = sandbox() - await openspec(['config', 'set', 'defaultStore', 'probe-store'], cwd, env) - const res = await openspec(['config', 'get', 'defaultStore', '--no-color'], cwd, env) + await openspecRaw(['config', 'set', 'defaultStore', 'probe-store'], cwd, env) + const res = await openspecRaw(['config', 'get', 'defaultStore', '--no-color'], cwd, env) expect(res.exitCode).toBe(0) expect(res.stdout.trim()).toBe('probe-store') expect(res.stderr.trim()).toBe('') @@ -61,36 +60,51 @@ describe('real pinned binary: a trailing --no-color on config subcommands', () = test('config set --no-color exits 0 and the value round-trips', async () => { const { cwd, env } = sandbox() - const set = await openspec(['config', 'set', 'defaultStore', 'bar', '--no-color'], cwd, env) + const set = await openspecRaw(['config', 'set', 'defaultStore', 'bar', '--no-color'], cwd, env) expect(set.exitCode).toBe(0) - const got = await openspec(['config', 'get', 'defaultStore'], cwd, env) + const got = await openspecRaw(['config', 'get', 'defaultStore'], cwd, env) expect(got.stdout.trim()).toBe('bar') }) test('config unset --no-color exits 0', async () => { const { cwd, env } = sandbox() - await openspec(['config', 'set', 'defaultStore', 'baz'], cwd, env) - const res = await openspec(['config', 'unset', 'defaultStore', '--no-color'], cwd, env) + await openspecRaw(['config', 'set', 'defaultStore', 'baz'], cwd, env) + const res = await openspecRaw(['config', 'unset', 'defaultStore', '--no-color'], cwd, env) expect(res.exitCode).toBe(0) }) test('config list --no-color exits 0 and prints the same text listing', async () => { const { cwd, env } = sandbox() - const res = await openspec(['config', 'list', '--no-color'], cwd, env) + const res = await openspecRaw(['config', 'list', '--no-color'], cwd, env) expect(res.exitCode).toBe(0) expect(res.stdout).toContain('profile:') }) test('config reset --all -y --no-color exits 0', async () => { const { cwd, env } = sandbox() - await openspec(['config', 'set', 'defaultStore', 'to-reset'], cwd, env) - const res = await openspec(['config', 'reset', '--all', '-y', '--no-color'], cwd, env) + await openspecRaw(['config', 'set', 'defaultStore', 'to-reset'], cwd, env) + const res = await openspecRaw(['config', 'reset', '--all', '-y', '--no-color'], cwd, env) expect(res.exitCode).toBe(0) }) + test('schemas/templates --no-color exit 0 too (no hazard on the shared passthroughs)', async () => { + // `core/passthrough-command.ts` appends `--no-color` AFTER the subcommand + // for every read passthrough. That is safe for the same reason it is safe + // on `config`: the flag resolves to the root command. This row is what + // keeps "the other passthroughs are broken today" from being reinstated as + // folklore. + const { cwd, env } = sandbox() + for (const sub of ['schemas', 'templates']) { + const withFlag = await openspecRaw([sub, '--no-color'], cwd, env) + const without = await openspecRaw([sub], cwd, env) + expect(withFlag.exitCode).toBe(without.exitCode) + expect(withFlag.stderr.toLowerCase()).not.toContain('unknown option') + } + }) + test('a genuinely unknown option is still rejected (control: --no-color is not special-cased)', async () => { const { cwd, env } = sandbox() - const res = await openspec(['config', 'get', 'defaultStore', '--bogus-flag-xyz'], cwd, env) + const res = await openspecRaw(['config', 'get', 'defaultStore', '--bogus-flag-xyz'], cwd, env) expect(res.exitCode).not.toBe(0) expect(res.stderr).toContain('unknown option') }) diff --git a/apps/cli/test/fixtures/support.ts b/apps/cli/test/fixtures/support.ts index da22105..39b7543 100644 --- a/apps/cli/test/fixtures/support.ts +++ b/apps/cli/test/fixtures/support.ts @@ -98,6 +98,22 @@ export function openspec( return spawn(['bun', openspecBinPath(), '--no-color', ...args], cwd, env) } +/** + * Run the real bundled openspec binary with NO leading `--no-color`. + * + * `openspec()` mirrors cospec's own spawn, which always prefixes `--no-color` + * before the subcommand. A probe of how upstream treats a *trailing* + * `--no-color` must not have a leading copy already in the argv, or the result + * says nothing about the trailing one. + */ +export function openspecRaw( + args: string[], + cwd: string, + env?: Record, +): Promise { + return spawn(['bun', openspecBinPath(), ...args], cwd, env) +} + const activeDirs = new Set() /** diff --git a/apps/cli/test/integration/feedback.test.ts b/apps/cli/test/integration/feedback.test.ts index 7c23cf9..1668d5b 100644 --- a/apps/cli/test/integration/feedback.test.ts +++ b/apps/cli/test/integration/feedback.test.ts @@ -191,11 +191,13 @@ describe('cospec feedback --upstream (relay to Fission-AI/OpenSpec)', () => { env: { PATH: path }, }) expect(res.stderr).toContain('Fission-AI/OpenSpec') - // The exact child exit code is whatever upstream's own feedback command - // propagates from gh — cospec never re-maps it to its own EXIT contract + // The child's exit code reaches the caller unchanged: upstream's own + // feedback command does `process.exit(error.status ?? 1)` on a failed + // `gh issue create`, and cospec never re-maps it to its own EXIT contract // (see runUpstream: "no exitCodes allow-list can honestly enumerate this"). - expect(res.exitCode).not.toBe(0) - expect(res.exitCode).not.toBe(1) + // Asserting the exact value is the point — `not.toBe(0)` would also pass + // for a wrapper that collapsed every odd code onto a constant. + expect(res.exitCode).toBe(42) }, 30_000) test('--upstream --json is refused (upstream emits text, not JSON), exit 1', async () => { diff --git a/apps/cli/test/integration/pack-standalone.test.ts b/apps/cli/test/integration/pack-standalone.test.ts index d6cba51..b11d875 100644 --- a/apps/cli/test/integration/pack-standalone.test.ts +++ b/apps/cli/test/integration/pack-standalone.test.ts @@ -58,6 +58,14 @@ function bunlessPath(): string { return [nodeEntry, ...kept, '/usr/bin', '/bin'].join(delimiter) } +/** `path` with every directory that resolves a real `gh` removed. */ +function ghlessPath(path: string): string { + return path + .split(delimiter) + .filter((p) => p.length > 0 && Bun.which('gh', { PATH: p }) === null) + .join(delimiter) +} + function run( cmd: string[], cwd: string, @@ -189,9 +197,26 @@ describe('standalone pack smoke (bun-less)', () => { expect(completionZsh.code, completionZsh.stderr).toBe(0) expect(completionZsh.stdout).toContain('#compdef cospec') - const feedbackHelp = run([bin, 'feedback', '--help'], target, path) - expect(feedbackHelp.code, feedbackHelp.stderr).toBe(0) - expect(feedbackHelp.stdout).toContain('feedback') + // `feedback --help` is deliberately NOT the bundling probe: `cli.ts` + // answers `--help` from the static COMMANDS table and returns before it + // ever looks up COMMAND_MODULES, so it passes even when the module was + // dropped. Running the command itself is what loads `commands/feedback.ts` + // — a dropped module reports "is not yet implemented" and exits 1. `gh` is + // stripped from PATH so this can never file a real issue: the documented + // manual-submission fallback exits 0 with a `submitted: false` envelope. + const ghless = ghlessPath(path) + expect(Bun.which('gh', { PATH: ghless })).toBeNull() + const feedback = run([bin, 'feedback', '--json', 'pack smoke probe'], target, ghless) + expect(feedback.code, feedback.stderr).toBe(0) + expect(feedback.stderr).not.toContain('not yet implemented') + const envelope = JSON.parse(feedback.stdout.trim()) as { + command: string + submitted: boolean + repo: string + } + expect(envelope.command).toBe('feedback') + expect(envelope.submitted).toBe(false) + expect(envelope.repo).toBe('aligned-team/cospec') }, 180_000) // The "fully self-contained" gate. NO npm install, NO node_modules anywhere, diff --git a/openspec/changes/openspec-config-completion-feedback/design.md b/openspec/changes/openspec-config-completion-feedback/design.md index e9a4862..6be9943 100644 --- a/openspec/changes/openspec-config-completion-feedback/design.md +++ b/openspec/changes/openspec-config-completion-feedback/design.md @@ -12,8 +12,9 @@ every other wrapped read surface uses — appends `root.storeArgs`, a trailing cospec's piped spawn uses `stdin: 'ignore'` and forces `OPENSPEC_TELEMETRY=0` and `OPENSPEC_NO_COMPLETIONS=1` in `WRAPPED_ENV`. Both are correct for the surfaces they were built for and both are actively wrong for `openspec config`, -whose options live on the parent command, whose `--json` exists on one -subcommand only, and three of whose subcommands are interactive. +which has no `--store` at all (its scoping option lives on the parent command), +whose `--json` exists on one subcommand only, and three of whose subcommands are +interactive. ## Goals / Non-Goals @@ -43,14 +44,17 @@ justify an abstraction whose third member does not exist. Rejected: reaching `config` through `callPassthrough` with per-subcommand opt-out flags. Every one of the helper's three unconditional appends is fatal here — `--store` is not a `config` option at all (upstream declares a -parent-level `--scope` instead, and rejects `--store` as unknown), a trailing -`--no-color` is rejected because upstream declares it on the program and only -`show` sets `allowUnknownOption`, and `--json` exists on `list` alone. Adding -three opt-outs to a shared helper to serve one caller makes the helper harder to -reason about for the eight callers that are fine today. `commands/workset.ts` -already establishes the local-runner precedent. `resolveRoot` is not called at -all: config is machine-global, so there is no root to resolve and no store to -thread, and `--store` is refused explicitly rather than absorbed and ignored. +parent-level `--scope` instead, and rejects `--store` as unknown), and `--json` +exists on `list` alone. (A trailing `--no-color` is the one append that is +merely redundant rather than fatal: it is declared on the program, and commander +resolves it from a leaf, so the contract suite observes it accepted on every +`config` subcommand. cospec still omits it, so the built argv carries nothing +the wrapped call did not need.) Adding three opt-outs to a shared helper to +serve one caller makes the helper harder to reason about for the eight callers +that are fine today. `commands/workset.ts` already establishes the local-runner +precedent. `resolveRoot` is not called at all: config is machine-global, so +there is no root to resolve and no store to thread, and `--store` is refused +explicitly rather than absorbed and ignored. **Two call classes rather than one.** Rejected: piping everything and letting the interactive subcommands fail. Upstream's `edit` spawns `$EDITOR` with @@ -169,12 +173,13 @@ to an exit code. - **Wrapped `openspec config` CLI shape.** `--scope` is a parent-command option and must be emitted between `config` and the subcommand; `--store` does not - exist on this command; `--no-color` is a program-level option that only `show` - tolerates in trailing position; `--json` exists on `list` alone. Class A - declares `expect.exitCodes = [0, 1]` because upstream uses exit 1 for ordinary - negative results. A contract test against the real pinned binary pins each of - these, including the trailing-`--no-color` rejection, so an upstream change - breaks a test rather than a user's command. + exist on this command; `--no-color` is a program-level option that commander + resolves from any leaf, so a trailing copy is accepted and merely redundant; + `--json` exists on `list` alone. Class A declares `expect.exitCodes = [0, 1]` + because upstream uses exit 1 for ordinary negative results. A contract test + against the real pinned binary pins each of these, including the + trailing-`--no-color` acceptance, so an upstream change breaks a test rather + than a user's command. - **`gh` CLI.** cospec depends on `gh issue create --repo --title --body ` accepting array argv and printing the created issue URL on stdout, and on `gh auth status` @@ -215,7 +220,9 @@ canon-derived, and the stderr note is what keeps the two from being confused. either way because `readDefaultStore` already tolerates exit 1. - [`gh` in tests reaching the network] → every automated row stubs `gh` on `PATH` in a temp dir; the single real submission is a `@manual` row. -- [The trailing-`--no-color` hazard exists on other passthroughs today] → out of - scope, but not left as folklore: a contract row proves the wrapped binary - rejects a trailing `--no-color` on `config get`, which is the evidence the - follow-up `fix` change starts from. +- [A trailing `--no-color` hazard was assumed to exist on the other + passthroughs] → settled by evidence, not left as folklore: contract rows probe + a trailing `--no-color` (with no leading copy in the argv) against `config`'s + subcommands and against `schemas`/`templates`, and every one is accepted. The + hazard does not exist, so no follow-up `fix` change is proposed for it, and + the rows stand as the regression guard should upstream ever change. diff --git a/openspec/changes/openspec-config-completion-feedback/proposal.md b/openspec/changes/openspec-config-completion-feedback/proposal.md index a57c3c7..c033798 100644 --- a/openspec/changes/openspec-config-completion-feedback/proposal.md +++ b/openspec/changes/openspec-config-completion-feedback/proposal.md @@ -33,10 +33,11 @@ are not part of this change. - `cospec config` deliberately does **not** route through `core/passthrough-command.ts`. That helper appends `root.storeArgs`, a trailing `--no-color`, and `--json` unconditionally; upstream's `config` has - no `--store` (it has a parent-level `--scope`), declares `--no-color` on the - program rather than the leaf and rejects a trailing copy, and supports - `--json` on `list` only. `commands/config.ts` gets a local argv builder - instead, the way `commands/workset.ts` already does, and never calls + no `--store` (it has a parent-level `--scope`) and supports `--json` on `list` + only. (A trailing `--no-color` is accepted — commander resolves the + program-level flag from a leaf — but cospec's spawn already prefixes one, so + appending a second is redundant.) `commands/config.ts` gets a local argv + builder instead, the way `commands/workset.ts` already does, and never calls `resolveRoot` at all — OpenSpec's config is machine-global, not root-scoped. - Give `cospec config` a one-JSON-document contract on every subcommand, not just the one upstream supports: `list --json` relays upstream's document @@ -96,23 +97,22 @@ are not part of this change. stay forced; `config set telemetry.enabled` is documented as affecting bare `openspec` runs rather than honoured by making cospec's wrapped calls configurable. -- Fixing the pre-existing trailing-`--no-color` hazard on the _other_ - passthroughs. `core/passthrough-command.ts` appends `--no-color` after the - subcommand on every wrapped call, and only upstream's `show` tolerates unknown - options — so `cospec schemas --no-color`, `cospec templates --no-color` and - friends are very likely broken today. This change proves the hazard is real - with a contract row and leaves the fix to a follow-up `fix` change. +- Changing `core/passthrough-command.ts`'s trailing `--no-color` append. The + suspected hazard here (that upstream rejects a trailing copy on every leaf but + `show`) was probed against the pinned binary and does not exist: `config`'s + subcommands, `schemas`, and `templates` all accept it. Contract rows record + that, and nothing is changed on the shared helper. ## Capabilities ### New Capabilities - `openspec-config-passthrough`: `cospec config`'s two call classes, its argv - shaping rules (no `storeArgs`, no trailing `--no-color`, `--scope` hoisted - ahead of the subcommand, `--json` only on `list`), its `--json` envelope - shapes, the `--store` refusal, and the precedence notes cospec prints where - its own forced environment or canon-managed harness overrides the key just - written. + shaping rules (no `storeArgs`, no redundant trailing `--no-color`, `--scope` + hoisted ahead of the subcommand, `--json` only on `list`), its `--json` + envelope shapes, the `--store` refusal, and the precedence notes cospec prints + where its own forced environment or canon-managed harness overrides the key + just written. - `cospec-shell-completion`: `cospec completion`'s generated bash/zsh/fish scripts derived from cospec's own command table, shell detection and its failure modes, and the hidden `cospec __complete` dynamic source with its @@ -173,9 +173,9 @@ are not part of this change. - [ ] deploy — deploy/runtime/CI-execution topology (infra, Dockerfile, workflow runtime, secrets, bind address) - [x] integration — the config surface is a contract against the real pinned - OpenSpec binary (parent-level `--scope`, trailing-`--no-color` rejection, - `--json` on `list` only), and `cospec feedback` shells out to an external - `gh` binary and GitHub's issue API. + OpenSpec binary (parent-level `--scope`, `--store` rejection, `--json` on + `list` only), and `cospec feedback` shells out to an external `gh` binary + and GitHub's issue API. - [x] agent-behavior — the Codex prefix-rule allow-list grows five read-only entries, and `cospec __complete` becomes a new machine-readable surface agents and shells consume. diff --git a/openspec/changes/openspec-config-completion-feedback/specs/openspec-config-passthrough/spec.md b/openspec/changes/openspec-config-completion-feedback/specs/openspec-config-passthrough/spec.md index d8eab2a..ee642b7 100644 --- a/openspec/changes/openspec-config-completion-feedback/specs/openspec-config-passthrough/spec.md +++ b/openspec/changes/openspec-config-completion-feedback/specs/openspec-config-passthrough/spec.md @@ -7,12 +7,12 @@ than routing through `core/passthrough-command.ts`, and SHALL NOT call `resolveRoot`, because OpenSpec's config is machine-global rather than root-scoped. The built argv SHALL never contain `--store` or any store argument, SHALL never append a trailing `--no-color` (cospec's spawn already prefixes -`--no-color` ahead of the subcommand, and upstream declares the flag on the -program rather than the leaf), SHALL append `--json` only for the `list` -subcommand, and SHALL emit an extracted `--scope ` between `config` and -the subcommand rather than after it. A `--scope` value other than `global` SHALL -be relayed to the wrapped binary unmodified so upstream's own refusal is what -the user sees. +`--no-color` ahead of the subcommand, so a second copy is redundant — upstream +declares the flag on the program and accepts it in trailing position), SHALL +append `--json` only for the `list` subcommand, and SHALL emit an extracted +`--scope ` between `config` and the subcommand rather than after it. A +`--scope` value other than `global` SHALL be relayed to the wrapped binary +unmodified so upstream's own refusal is what the user sees. #### Scenario: Built argv carries no store and no trailing no-color diff --git a/openspec/changes/openspec-config-completion-feedback/tasks.md b/openspec/changes/openspec-config-completion-feedback/tasks.md index ad03abf..229d446 100644 --- a/openspec/changes/openspec-config-completion-feedback/tasks.md +++ b/openspec/changes/openspec-config-completion-feedback/tasks.md @@ -100,7 +100,7 @@ `mise run test:integration` are green with no pre-existing test weakened - [ ] 6.2 Land `apps/cli/test/contract/config-surface.test.ts` against the real pinned binary with `XDG_CONFIG_HOME` and `HOME` sandboxed into a temp dir, - including the trailing-`--no-color` rejection row, and verify + including the trailing-`--no-color` acceptance rows, and verify `mise run test:contract` is green and the developer's real global config is untouched (ledger 1.5) - [ ] 6.3 Extend `apps/cli/test/integration/pack-standalone.test.ts` to run @@ -133,7 +133,8 @@ - [ ] 7.5 Update `.agents/shared.md` so the every-everyday-surface paragraph names `config`, `completion`, and `feedback`, run `mise run agents:sync`, and verify `mise run agents:check` is clean (ledger 8.2) -- [ ] 7.6 Record the trailing-`--no-color` hazard on the other passthroughs as a - proposed follow-up `fix` change, citing the contract row as its evidence, - and verify the follow-up is written down rather than silently fixed in - this change +- [ ] 7.6 Settle the suspected trailing-`--no-color` hazard on the other + passthroughs with the contract rows rather than a follow-up change, and + verify no artefact in this change still claims upstream rejects a trailing + `--no-color` (the probe shows it is accepted, so there is no hazard to + hand on) diff --git a/openspec/changes/openspec-config-completion-feedback/verification.md b/openspec/changes/openspec-config-completion-feedback/verification.md index 392635e..48ff32c 100644 --- a/openspec/changes/openspec-config-completion-feedback/verification.md +++ b/openspec/changes/openspec-config-completion-feedback/verification.md @@ -10,7 +10,7 @@ - [ ] 1.2 @unit (agent) invoke `cospec config --store x list` and `cospec config` with no subcommand against a spawn spy -> both exit 1 with their named messages and the spy records zero wrapped spawns - [ ] 1.3 @integration (agent) run `cospec config path` and `cospec config list --json` against the real pinned binary under a sandboxed `XDG_CONFIG_HOME`/`HOME` -> `path` prints the sandboxed config path at exit 0; `list --json` is exactly one parseable document carrying `profile` and `delivery` - [ ] 1.4 @integration (agent) run `cospec config get ` plain and with `--json` -> plain form exits 1 with upstream's message and no partial JSON; `--json` form exits 1 emitting exactly one document with `version:1`, `found:false`, `value:null` -- [ ] 1.5 @integration (agent) contract suite: append a trailing `--no-color` to a real `openspec config get` invocation -> the wrapped binary rejects it non-zero, proving rule 1.1's no-trailing-`--no-color` constraint is load-bearing rather than superstition (and leaving the follow-up `fix` change its evidence) +- [ ] 1.5 @integration (agent) contract suite: append a trailing `--no-color` to real `openspec config` invocations (and to `openspec schemas`/`templates`), with no leading `--no-color` in the argv -> every one is accepted, exiting exactly as it does without the flag and never raising `unknown option`, establishing that rule 1.1's omission is redundancy-avoidance rather than error-avoidance and that the other passthroughs carry no trailing-`--no-color` hazard - [ ] 1.6 @integration (agent) `cospec config set defaultStore ` under a sandboxed home, then run a cospec command from a directory with no `openspec/` above it, then `cospec config unset defaultStore` and repeat -> the first run resolves against the named store, the second falls back to the local cwd, and neither path outranks a local `openspec/` root ## 2. Interactive config subcommands hand over the terminal instead of hanging [critical] From f7097a6bf44d36d87586ddbb1c2577f40e307beb Mon Sep 17 00:00:00 2001 From: replygirl Date: Sat, 5 Sep 2026 12:38:52 -0500 Subject: [PATCH 4/6] docs: document config, completion, and feedback Adds cospec config's machine-global section (call classes, --json envelope shapes per subcommand, --store refusal, defaultStore cross-link) to reference/configuration.md, three command rows plus the config exceptions note to reference/commands.md, per-shell completion snippets to guide/installation.md, the config passthrough exceptions and the named terminal-handover class to docs/architecture.md, and the config/completion/ feedback mention to how-it-relates-to-openspec.md, README.md, and apps/cli/README.md. Updates .agents/shared.md's route-through-cospec bullet and re-syncs CLAUDE.md/AGENTS.md via mise run agents:sync. Checks off tasks.md 7.1-7.5 and verification.md 8.1-8.2 with observed mise run docs:build / agents:sync / agents:check results; 8.3 stays for the human manual-read row. --- .agents/shared.md | 12 ++- AGENTS.md | 12 ++- CLAUDE.md | 12 ++- README.md | 43 +++++----- apps/cli/README.md | 4 +- .../concepts/how-it-relates-to-openspec.md | 11 +++ apps/docs/guide/installation.md | 29 +++++++ apps/docs/reference/commands.md | 83 +++++++++++-------- apps/docs/reference/configuration.md | 59 +++++++++++++ docs/architecture.md | 39 +++++++-- .../tasks.md | 10 +-- .../verification.md | 4 +- 12 files changed, 237 insertions(+), 81 deletions(-) diff --git a/.agents/shared.md b/.agents/shared.md index f71a038..35d123b 100644 --- a/.agents/shared.md +++ b/.agents/shared.md @@ -164,10 +164,14 @@ The wrapped binary is spawned by resolved path and version-asserted to the accepted range `>=1.0.0 <2.0.0` (dev/CI pins 1.11.0). Every everyday OpenSpec surface has a cospec command — the change lifecycle, plus `store` (`setup`/`register` auto-run `cospec init`), `context`, `workset`, `show`, -`view`, `schemas`/`schema`, and `templates` — so there is never a reason to call -bare `openspec`. Read-only and personal surfaces are disciplined passthroughs -(no gate, full wrapped-call discipline); see docs/architecture.md and -docs/stores.md. +`view`, `schemas`/`schema`, `templates`, `config` (machine-global, +`path`/`list`/`get`/`set`/`unset`/`reset`/`edit`/`profile`), native +`completion`, and `feedback` — so there is never a reason to call bare +`openspec`. `init`/`update` stay cospec-native by design. Read-only and personal +surfaces are disciplined passthroughs (no gate, full wrapped-call discipline); +`config edit`/`profile`/`reset --all` (no `-y`) join `workset open` in the +terminal-handover class instead (inherited stdio, verbatim child exit code, no +`--json`); see docs/architecture.md and docs/stores.md. **Wrapped-call discipline** — every call into the wrapped binary declares its expected exit codes, a stdout deny-list, and an observable post-condition. Trust diff --git a/AGENTS.md b/AGENTS.md index 9f9fce0..f16e384 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,10 +168,14 @@ The wrapped binary is spawned by resolved path and version-asserted to the accepted range `>=1.0.0 <2.0.0` (dev/CI pins 1.11.0). Every everyday OpenSpec surface has a cospec command — the change lifecycle, plus `store` (`setup`/`register` auto-run `cospec init`), `context`, `workset`, `show`, -`view`, `schemas`/`schema`, and `templates` — so there is never a reason to call -bare `openspec`. Read-only and personal surfaces are disciplined passthroughs -(no gate, full wrapped-call discipline); see docs/architecture.md and -docs/stores.md. +`view`, `schemas`/`schema`, `templates`, `config` (machine-global, +`path`/`list`/`get`/`set`/`unset`/`reset`/`edit`/`profile`), native +`completion`, and `feedback` — so there is never a reason to call bare +`openspec`. `init`/`update` stay cospec-native by design. Read-only and personal +surfaces are disciplined passthroughs (no gate, full wrapped-call discipline); +`config edit`/`profile`/`reset --all` (no `-y`) join `workset open` in the +terminal-handover class instead (inherited stdio, verbatim child exit code, no +`--json`); see docs/architecture.md and docs/stores.md. **Wrapped-call discipline** — every call into the wrapped binary declares its expected exit codes, a stdout deny-list, and an observable post-condition. Trust diff --git a/CLAUDE.md b/CLAUDE.md index 132fbe2..e7fc850 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -164,10 +164,14 @@ The wrapped binary is spawned by resolved path and version-asserted to the accepted range `>=1.0.0 <2.0.0` (dev/CI pins 1.11.0). Every everyday OpenSpec surface has a cospec command — the change lifecycle, plus `store` (`setup`/`register` auto-run `cospec init`), `context`, `workset`, `show`, -`view`, `schemas`/`schema`, and `templates` — so there is never a reason to call -bare `openspec`. Read-only and personal surfaces are disciplined passthroughs -(no gate, full wrapped-call discipline); see docs/architecture.md and -docs/stores.md. +`view`, `schemas`/`schema`, `templates`, `config` (machine-global, +`path`/`list`/`get`/`set`/`unset`/`reset`/`edit`/`profile`), native +`completion`, and `feedback` — so there is never a reason to call bare +`openspec`. `init`/`update` stay cospec-native by design. Read-only and personal +surfaces are disciplined passthroughs (no gate, full wrapped-call discipline); +`config edit`/`profile`/`reset --all` (no `-y`) join `workset open` in the +terminal-handover class instead (inherited stdio, verbatim child exit code, no +`--json`); see docs/architecture.md and docs/stores.md. **Wrapped-call discipline** — every call into the wrapped binary declares its expected exit codes, a stdout deny-list, and an observable post-condition. Trust diff --git a/README.md b/README.md index 18e52cb..6fda187 100644 --- a/README.md +++ b/README.md @@ -95,26 +95,29 @@ grandfathering, and a blocking-changes ledger with auto-sync. ## Commands -| command | what it does | -| -------------------------------- | ------------------------------------------------------------------------------------------------ | -| `cospec init [path]` | scaffold `openspec/`, schemas, and harness files (idempotent) | -| `cospec update [--check]` | re-generate managed files from canon; `--check` is a drift gate | -| `cospec doctor` | read-only health check, incl. changes still on `schemaVersion` 1 | -| `cospec new ` | create a typed change; prints the artifact plan | -| `cospec migrate ` | stamp a grandfathered change to the current `schemaVersion`, scaffolding deferred verification | -| `cospec validate [name]` | validate changes and specs (`--all`/`--changes`/`--specs`); `--strict` promotes warnings | -| `cospec status` / `cospec list` | change status with type, gate, and archive-readiness columns (`list --specs` lists living specs) | -| `cospec instructions ` | print the authoring instruction for one artifact | -| `cospec apply ` | the gate — exit 0 clear, 2 blocked, 3 soft-blocked | -| `cospec archive ` | validate, archive, verify the move, fan out blocker sync | -| `cospec sync-blockers` | check off blocker entries whose target has shipped | -| `cospec show ` | read a change or spec's markdown or JSON | -| `cospec view` | the OpenSpec dashboard | -| `cospec store ` | manage stores: `setup`/`register` (auto-init) / `unregister` / `remove` / `ls` / `doctor` | -| `cospec context` | a store's cross-repo working-set brief (`--json`, `--code-workspace`) | -| `cospec workset ` | personal cross-repo working views: `create` / `list` / `remove` / `open` | -| `cospec schemas` / `schema` | inspect resolvable schemas (`schema which`/`validate`) | -| `cospec templates` | list per-artifact template paths | +| command | what it does | +| -------------------------------- | ------------------------------------------------------------------------------------------------- | +| `cospec init [path]` | scaffold `openspec/`, schemas, and harness files (idempotent) | +| `cospec update [--check]` | re-generate managed files from canon; `--check` is a drift gate | +| `cospec doctor` | read-only health check, incl. changes still on `schemaVersion` 1 | +| `cospec new ` | create a typed change; prints the artifact plan | +| `cospec migrate ` | stamp a grandfathered change to the current `schemaVersion`, scaffolding deferred verification | +| `cospec validate [name]` | validate changes and specs (`--all`/`--changes`/`--specs`); `--strict` promotes warnings | +| `cospec status` / `cospec list` | change status with type, gate, and archive-readiness columns (`list --specs` lists living specs) | +| `cospec instructions ` | print the authoring instruction for one artifact | +| `cospec apply ` | the gate — exit 0 clear, 2 blocked, 3 soft-blocked | +| `cospec archive ` | validate, archive, verify the move, fan out blocker sync | +| `cospec sync-blockers` | check off blocker entries whose target has shipped | +| `cospec show ` | read a change or spec's markdown or JSON | +| `cospec view` | the OpenSpec dashboard | +| `cospec store ` | manage stores: `setup`/`register` (auto-init) / `unregister` / `remove` / `ls` / `doctor` | +| `cospec context` | a store's cross-repo working-set brief (`--json`, `--code-workspace`) | +| `cospec workset ` | personal cross-repo working views: `create` / `list` / `remove` / `open` | +| `cospec schemas` / `schema` | inspect resolvable schemas (`schema which`/`validate`) | +| `cospec templates` | list per-artifact template paths | +| `cospec config ` | machine-global OpenSpec config: `path`/`list`/`get`/`set`/`unset`/`reset`/`edit`/`profile` | +| `cospec completion [shell]` | print a bash/zsh/fish completion script, generated natively (no install step) | +| `cospec feedback ""` | file a bug report at `aligned-team/cospec` via `gh`; `--upstream` files at OpenSpec's own tracker | Global flags on every command: `--json`, `--no-color`, `--cwd `, `--store `. `--store` runs the whole change lifecycle against a registered diff --git a/apps/cli/README.md b/apps/cli/README.md index 889005c..a3bdb52 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -64,6 +64,8 @@ cospec store setup platform --path ./platform-store # create + auto-init a stor ``` Store management (`store setup|register|unregister|remove|ls|doctor`), -cross-repo context (`context`), and personal worksets (`workset`) are all +cross-repo context (`context`), personal worksets (`workset`), machine-global +config (`config path|list|get|set|unset|reset|edit|profile`), native shell +completion (`completion [bash|zsh|fish]`), and bug filing (`feedback`) are all first-class cospec commands — you never drop out to bare `openspec`. See the repository root for full docs. diff --git a/apps/docs/concepts/how-it-relates-to-openspec.md b/apps/docs/concepts/how-it-relates-to-openspec.md index 8caf351..5103ccd 100644 --- a/apps/docs/concepts/how-it-relates-to-openspec.md +++ b/apps/docs/concepts/how-it-relates-to-openspec.md @@ -90,6 +90,17 @@ flag. Store lifecycle itself is now a first-class cospec command too — rather than a bare `openspec` call. See [Stores](/concepts/stores) for the mechanics and the full split of what each CLI owns. +Every everyday OpenSpec surface now has a cospec command: the change lifecycle, +`store`/`context`/`workset`, `show`/`view`/`schemas`/`schema`/ `templates` — +and, as of this release, the machine-global `config` (`cospec config `), +native shell `completion`, and `feedback`. `init` and `update` stay +cospec-native by design — passing them through would write the opsx files +cospec's own leftover scan flags — so there is never a reason to call bare +`openspec`. See +[Configuration](/reference/configuration#machine-global-openspec-config) for +`config`, and [Installation](/guide/installation#shell-completion) for +`completion`. + ## Three failure modes cospec defends against Raw OpenSpec has a few behaviors that are easy to miss in a terminal but diff --git a/apps/docs/guide/installation.md b/apps/docs/guide/installation.md index 78fc9b1..7484e4f 100644 --- a/apps/docs/guide/installation.md +++ b/apps/docs/guide/installation.md @@ -108,3 +108,32 @@ cospec init --harness claude,codex See [Harness setup](/guide/harness-setup) for what each harness option generates and how permissions are configured. + +## Shell completion + +`cospec completion [bash|zsh|fish]` prints a completion script to stdout, +generated natively from cospec's own command table — never a passthrough to +OpenSpec's own completion installer, which writes a function that shells out to +bare `openspec`. There's no `install`/`uninstall` subcommand; wire the output +into your shell yourself: + +::: code-group + +```sh [bash] +echo 'eval "$(cospec completion bash)"' >> ~/.bashrc +``` + +```sh [zsh] +echo 'eval "$(cospec completion zsh)"' >> ~/.zshrc +``` + +```sh [fish] +cospec completion fish > ~/.config/fish/completions/cospec.fish +``` + +::: + +Omit the shell argument and cospec detects it from `$SHELL`. Completion covers +every command and flag cospec declares, plus dynamic suggestions for change +slugs, spec ids, and the eleven conventional-commit types, sourced from a hidden +`cospec __complete` call at Tab time. diff --git a/apps/docs/reference/commands.md b/apps/docs/reference/commands.md index 241f8cc..1a741f7 100644 --- a/apps/docs/reference/commands.md +++ b/apps/docs/reference/commands.md @@ -26,31 +26,37 @@ the table. ## Commands -| command | synopsis | key flags | see | -| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cospec init [path]` | Scaffold `openspec/`, the eleven typed schemas, and harness files. Idempotent. | `--yes`, `--force`, `--harness `, `--gate` / `--no-gate`, `--remove-opsx` | [Installation](/guide/installation) | -| `cospec update` | Regenerate managed files (schemas, harness files) from canon. | `--check` (drift gate, exits nonzero on drift, changes nothing), `--force` | [Installation](/guide/installation) | -| `cospec doctor` | Read-only health check: wrapped-OpenSpec version, schema/harness drift, dangling slash/skill refs, `config.yaml` validity, changes stuck on an old `schemaVersion`, and — for a store-backed or `references:`-declaring root — delegated root/store relationship health (`openspec-*` findings). | — | [How it relates to OpenSpec](/concepts/how-it-relates-to-openspec), [Stores](/concepts/stores) | -| `cospec new ` | Create a typed change and print its artifact plan. Also accepts `cospec new ": "`. | `--description ` | [Types and artifacts](/concepts/types-and-artifacts) | -| `cospec migrate ` | Opt-in: stamp a change created under an older `schemaVersion` to the current one, scaffolding a fully-deferred `verification.md` where the type requires it. Never runs automatically. | — | [Verification](/concepts/verification) | -| `cospec validate [name]` | Validate one or all changes and specs against cospec's rules. | `--strict` (promote warnings to errors), `--all`, `--changes`, `--specs`, `--archived`, `--fast`, `--no-interactive` | [Types and artifacts](/concepts/types-and-artifacts) | -| `cospec status --change ` | Per-artifact completion, the blocker gate state, and archive-readiness for one change; `--all` sweeps every active change instead of one. | `--change `, `--all` | [Apply and archive](/concepts/apply-and-archive) | -| `cospec list` | List active changes with type, gate state, task progress, and archive-readiness columns. `--specs` instead lists living specs by requirement count. | `--blocked` (only changes with a non-clear gate), `--specs` | [Apply and archive](/concepts/apply-and-archive) | -| `cospec instructions --change ` | Print the authoring instructions for one artifact of a change (e.g. `proposal`, `verification`, `tasks`, `archive`). `archive` is a read-only relay of the wrapped `openspec instructions archive`, not an alias for `cospec archive` (requires openspec >=1.7.0). | `--change `, `--allow-soft` | [Workflow](/guide/workflow) | -| `cospec apply ` | The gate: check blockers and required artifacts before you implement. | `--allow-soft` (proceed past a soft block), `--skip-specs` (one-shot equivalent of a persisted `skip_specs: true` marker) | [Apply and archive](/concepts/apply-and-archive) | -| `cospec archive ` | Validate, gate on tasks and verification, archive via OpenSpec, verify the move on disk, and fan out blocker sync. `--json` adds `warnings`/`retired` arrays (always present, `[]` when empty). | `--skip-specs`, `--force-incomplete` | [Apply and archive](/concepts/apply-and-archive) | -| `cospec sync-blockers` | Check off blocking-changes entries whose target has shipped, across all active changes. | `--check` (report only, no writes), `--change ` | [Blocking changes](/concepts/blocking-changes) | -| `cospec store ` | First-class wrap of the store lifecycle: `setup`/`register`/`unregister`/`remove`/`list` (`ls`)/`doctor`. `setup`/`register` auto-run `cospec init --harness none` on success. | `--no-cospec-init` (`setup`/`register` only) | [Stores](/concepts/stores) | -| `cospec context` | Read-only cross-repo working-set brief across a repo and its `references:` stores. | `--json`, `--code-workspace `, `--force` | [Stores](/concepts/stores) | -| `cospec workset create\|list\|remove\|open` | Personal, local working views. `open` hands the terminal over to the workset's editor/agent session and never accepts `--json` or `--store`. | — | [Stores](/concepts/stores) | -| `cospec show ` | Show a single change or spec, text or JSON. | `--type`, `--deltas-only`, `--requirements-only`, `-r`/`--requirement`, `--no-scenarios`, `--diff` | [Read-only and personal commands](#read-only-and-personal-commands), [OpenSpec's `show`](https://github.com/Fission-AI/OpenSpec/blob/main/docs/commands.md) | -| `cospec view` | Summary dashboard for the operating root. Accepts neither `--json` nor `--store`. | — | [Read-only and personal commands](#read-only-and-personal-commands), [OpenSpec's `view`](https://github.com/Fission-AI/OpenSpec/blob/main/docs/commands.md) | -| `cospec schemas` | List every resolvable schema — the eleven cospec types plus any project-local (forked) schema — with its artifact chain. | — | [Configuration](/reference/configuration#tier-3-schema-forking) | -| `cospec schema which\|validate\|fork\|init` | Inspect which schema a change resolves to, validate a schema's own structure, or create a project-local schema (`fork [name]`, `init `). Refuses a destination name that collides with one of the eleven cospec types. | `--description `, `--artifacts ` (`init` only) | [Configuration](/reference/configuration#tier-3-schema-forking) | -| `cospec templates` | List resolved per-artifact template paths for a schema. | `--schema ` (default `spec-driven`) | [Configuration](/reference/configuration#tier-3-schema-forking) | +| command | synopsis | key flags | see | +| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cospec init [path]` | Scaffold `openspec/`, the eleven typed schemas, and harness files. Idempotent. | `--yes`, `--force`, `--harness `, `--gate` / `--no-gate`, `--remove-opsx` | [Installation](/guide/installation) | +| `cospec update` | Regenerate managed files (schemas, harness files) from canon. | `--check` (drift gate, exits nonzero on drift, changes nothing), `--force` | [Installation](/guide/installation) | +| `cospec doctor` | Read-only health check: wrapped-OpenSpec version, schema/harness drift, dangling slash/skill refs, `config.yaml` validity, changes stuck on an old `schemaVersion`, and — for a store-backed or `references:`-declaring root — delegated root/store relationship health (`openspec-*` findings). | — | [How it relates to OpenSpec](/concepts/how-it-relates-to-openspec), [Stores](/concepts/stores) | +| `cospec new ` | Create a typed change and print its artifact plan. Also accepts `cospec new ": "`. | `--description ` | [Types and artifacts](/concepts/types-and-artifacts) | +| `cospec migrate ` | Opt-in: stamp a change created under an older `schemaVersion` to the current one, scaffolding a fully-deferred `verification.md` where the type requires it. Never runs automatically. | — | [Verification](/concepts/verification) | +| `cospec validate [name]` | Validate one or all changes and specs against cospec's rules. | `--strict` (promote warnings to errors), `--all`, `--changes`, `--specs`, `--archived`, `--fast`, `--no-interactive` | [Types and artifacts](/concepts/types-and-artifacts) | +| `cospec status --change ` | Per-artifact completion, the blocker gate state, and archive-readiness for one change; `--all` sweeps every active change instead of one. | `--change `, `--all` | [Apply and archive](/concepts/apply-and-archive) | +| `cospec list` | List active changes with type, gate state, task progress, and archive-readiness columns. `--specs` instead lists living specs by requirement count. | `--blocked` (only changes with a non-clear gate), `--specs` | [Apply and archive](/concepts/apply-and-archive) | +| `cospec instructions --change ` | Print the authoring instructions for one artifact of a change (e.g. `proposal`, `verification`, `tasks`, `archive`). `archive` is a read-only relay of the wrapped `openspec instructions archive`, not an alias for `cospec archive` (requires openspec >=1.7.0). | `--change `, `--allow-soft` | [Workflow](/guide/workflow) | +| `cospec apply ` | The gate: check blockers and required artifacts before you implement. | `--allow-soft` (proceed past a soft block), `--skip-specs` (one-shot equivalent of a persisted `skip_specs: true` marker) | [Apply and archive](/concepts/apply-and-archive) | +| `cospec archive ` | Validate, gate on tasks and verification, archive via OpenSpec, verify the move on disk, and fan out blocker sync. `--json` adds `warnings`/`retired` arrays (always present, `[]` when empty). | `--skip-specs`, `--force-incomplete` | [Apply and archive](/concepts/apply-and-archive) | +| `cospec sync-blockers` | Check off blocking-changes entries whose target has shipped, across all active changes. | `--check` (report only, no writes), `--change ` | [Blocking changes](/concepts/blocking-changes) | +| `cospec store ` | First-class wrap of the store lifecycle: `setup`/`register`/`unregister`/`remove`/`list` (`ls`)/`doctor`. `setup`/`register` auto-run `cospec init --harness none` on success. | `--no-cospec-init` (`setup`/`register` only) | [Stores](/concepts/stores) | +| `cospec context` | Read-only cross-repo working-set brief across a repo and its `references:` stores. | `--json`, `--code-workspace `, `--force` | [Stores](/concepts/stores) | +| `cospec workset create\|list\|remove\|open` | Personal, local working views. `open` hands the terminal over to the workset's editor/agent session and never accepts `--json` or `--store`. | — | [Stores](/concepts/stores) | +| `cospec show ` | Show a single change or spec, text or JSON. | `--type`, `--deltas-only`, `--requirements-only`, `-r`/`--requirement`, `--no-scenarios`, `--diff` | [Read-only and personal commands](#read-only-and-personal-commands), [OpenSpec's `show`](https://github.com/Fission-AI/OpenSpec/blob/main/docs/commands.md) | +| `cospec view` | Summary dashboard for the operating root. Accepts neither `--json` nor `--store`. | — | [Read-only and personal commands](#read-only-and-personal-commands), [OpenSpec's `view`](https://github.com/Fission-AI/OpenSpec/blob/main/docs/commands.md) | +| `cospec schemas` | List every resolvable schema — the eleven cospec types plus any project-local (forked) schema — with its artifact chain. | — | [Configuration](/reference/configuration#tier-3-schema-forking) | +| `cospec schema which\|validate\|fork\|init` | Inspect which schema a change resolves to, validate a schema's own structure, or create a project-local schema (`fork [name]`, `init `). Refuses a destination name that collides with one of the eleven cospec types. | `--description `, `--artifacts ` (`init` only) | [Configuration](/reference/configuration#tier-3-schema-forking) | +| `cospec templates` | List resolved per-artifact template paths for a schema. | `--schema ` (default `spec-driven`) | [Configuration](/reference/configuration#tier-3-schema-forking) | +| `cospec config ` | Machine-global OpenSpec config (`~/.config/openspec/config.json`): `path`, `list`, `get `, `set `, `unset `, `reset`, `edit`, `profile [preset]`. `edit`, `profile` with no preset, and `reset --all` without `-y` hand the terminal over (inherited stdio, verbatim child exit code); the rest are piped. | `--scope global` (only accepted value), `--json` (`list` only — the rest get a cospec-owned envelope), `-y`/`--yes` (`reset --all`) | [Configuration](/reference/configuration#machine-global-openspec-config) | +| `cospec completion [bash\|zsh\|fish]` | Print a shell completion script to stdout, generated from cospec's own command table. Shell auto-detected from `$SHELL` when omitted. No `install`/`uninstall` — copy-paste only. | — | [Installation](/guide/installation#shell-completion) | +| `cospec feedback "" [--body ]` | File a bug report at `aligned-team/cospec` via `gh issue create` (array argv, no shell); prints a prefilled manual-submission URL and exits 0 if `gh` is missing or unauthenticated. `--upstream` relays to `openspec feedback` instead, filing at OpenSpec's own tracker. | `--body `, `--upstream` | — | `cospec check-commit` is a hidden commit-msg hook entrypoint (advisory only, never blocks a commit) and isn't part of the everyday command surface. +`cospec __complete ` is a hidden dynamic-completion source +the generated shell scripts call at Tab time — it fails silently (exit 1, +nothing on either stream) so a bad lookup can never corrupt a keystroke. ::: tip Exit codes `apply` and `archive` use the same four-code contract (`0`/`1`/`2`/`3`) across every gated command. The full table lives on @@ -71,22 +77,29 @@ that one change's status computation threw); exit is `1` if any entry failed, ## Read-only and personal commands `store`, `context`, `workset`, `show`, `view`, `schemas`, `schema which`/ -`validate`/`fork`/`init`, `templates`, and `list --specs`/`validate --all`/ -`--specs`/`--archived` carry no cospec gate — none of them block a change -lifecycle, require an artifact, or touch the verification ledger. Most of them -(everything except `store`, which is a first-class wrap with its own -filesystem-verified post-conditions) are **disciplined passthroughs**: cospec -forwards the call to the wrapped OpenSpec binary under the same rigor as every -gated command — a version-asserted spawn, a declared set of acceptable exit -codes, a stdout deny-list, and stdout/stderr relayed verbatim — and, when you -pass `--json`, guarantees exactly one JSON document on stdout (never a stack -trace, even on failure) so a script or agent reading the output can always parse -it. None of this changes what the commands _do_ — `show`, `view`, `schemas`, -`schema`, and `templates` in particular are genuinely OpenSpec's own job, and -their full semantics live on +`validate`/`fork`/`init`, `templates`, `config path`/`list`/`get`, and +`list --specs`/`validate --all`/`--specs`/`--archived` carry no cospec gate — +none of them block a change lifecycle, require an artifact, or touch the +verification ledger. Most of them (everything except `store`, which is a +first-class wrap with its own filesystem-verified post-conditions) are +**disciplined passthroughs**: cospec forwards the call to the wrapped OpenSpec +binary under the same rigor as every gated command — a version-asserted spawn, a +declared set of acceptable exit codes, a stdout deny-list, and stdout/stderr +relayed verbatim — and, when you pass `--json`, guarantees exactly one JSON +document on stdout (never a stack trace, even on failure) so a script or agent +reading the output can always parse it. None of this changes what the commands +_do_ — `show`, `view`, `schemas`, `schema`, `templates`, and `config`'s own key +semantics in particular are genuinely OpenSpec's own job, and their full +semantics live on [OpenSpec's command reference](https://github.com/Fission-AI/OpenSpec/blob/main/docs/commands.md) — it only guarantees they fail predictably instead of silently. +`config set`/`unset`/`reset`/`edit`/`profile` are the read-only list's +exceptions: they mutate the machine-global config file (or, for `edit` and a +preset-less `profile`, hand the terminal over) — see +[Configuration](/reference/configuration#machine-global-openspec-config) for the +full call-class split and the precedence notes cospec prints alongside them. + For anything that's the wrapped binary's own job — the delta format, OpenSpec's glossary, or its own commands — see [OpenSpec's command reference](https://github.com/Fission-AI/OpenSpec/blob/main/docs/commands.md). diff --git a/apps/docs/reference/configuration.md b/apps/docs/reference/configuration.md index 12d2c9a..be3b873 100644 --- a/apps/docs/reference/configuration.md +++ b/apps/docs/reference/configuration.md @@ -157,6 +157,65 @@ file as a hand-edit the next time either runs — the same as any other file modified outside the managed-file protocol — but that's a reactive backstop, not a substitute for going through `cospec schema`. ::: +## Machine-global: `openspec config` + +The three tiers above are all repo-local. OpenSpec also keeps one machine-global +config file, `~/.config/openspec/config.json`, and `cospec config ` wraps +it — cospec never reads or writes that file itself, adds no validation of its +own, and relays upstream's key validation, value coercion, and +prototype-pollution guard verbatim. + +`cospec config` splits into two call classes: + +- **Piped** (`path`, `list`, `get `, `set `, `unset `, + `reset --all -y`, `profile `) — a disciplined passthrough. Exit `1` is + an ordinary negative result here (unset key, invalid key), not a wrapped-call + violation. +- **Terminal handover** (`edit`, `profile` with no preset, `reset --all` without + `-y`) — upstream spawns `$EDITOR` or runs an `@inquirer` menu, which cannot + survive cospec's piped `stdin: 'ignore'` spawn. cospec hands the terminal over + instead: inherited stdio, the child's verbatim exit code (including `130` on + prompt cancellation), and no `--json` — the same terminal-handover contract + [`cospec workset open`](/concepts/stores) uses. + +`--scope` is a parent-level option (not `--store` — OpenSpec config is +machine-global, so `cospec config` never resolves a root or threads +`root.storeArgs`). `--json` exists on `list` only, matching upstream; the other +subcommands still owe a `--json` caller exactly one JSON document, so cospec +wraps their text output in its own `version: 1` envelope: + +| subcommand | `--json` shape | +| --------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `list` | upstream's own document, relayed verbatim | +| `path` | `{ version: 1, command: 'config path', path }` | +| `get` | `{ version: 1, command: 'config get', key, value, found }` (`value`/`found` are `null`/`false` when the key is unset) | +| `set`/`unset`/`reset` | `{ version: 1, command: 'config ', ok, message }` | +| a Class B subcommand | `{ version: 1, command: 'config ', ok: false, message: '… is interactive and cannot emit JSON' }`, exit `1` | + +`cospec config --store ` is refused outright (exit `1`, before spawning the +wrapped binary) rather than silently ignored — OpenSpec config has no store +dimension, so a `--store` a user typed out of habit needs a named answer, not a +no-op. + +### Precedence notes + +After a successful mutation, cospec prints a stderr note (stderr, so a `--json` +stdout stays exactly one document) wherever cospec's own behavior overrides or +bypasses the key just written: + +- **`set telemetry.enabled`** — cospec forces `OPENSPEC_TELEMETRY=0` on every + wrapped call regardless of this key, so the setting affects bare `openspec` + runs only. +- **`profile ` / `set profile|workflows|delivery`** — cospec's harness + files (`.claude/`, `.codex/`, `.opencode/`) are generated from cospec canon, + not from these OpenSpec keys; run `cospec update`, not `openspec update`, to + regenerate them. + +`defaultStore` is the one global key cospec itself reads (as a fallback root +during store resolution) but, until this command, had no way to set from cospec +— see [Stores](/concepts/stores#cross-repo-context-and-worksets) for the full +resolution order. + ## The managed-file protocol Every `cospec update` recomposes each managed file and decides its fate by diff --git a/docs/architecture.md b/docs/architecture.md index 215f139..05dfe8e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -89,12 +89,25 @@ regression the moment someone runs cospec against an older in-range binary. ## The disciplined-passthrough runner Not every wrapped command adds a cospec gate. Read-only reads (`show`, `view`, -`context`, `schemas`, `schema which`/`validate`, `templates`), personal working -views (`workset`), and spec/bulk delegation (`list --specs`, -`validate --all`/`--specs`) are **passthroughs**: cospec forwards the call, -relays stdout/stderr verbatim, and maps the wrapped exit code onto its own -`EXIT` contract — but still under the full wrapped-call discipline, never a bare -`spawn`. +`context`, `schemas`, `schema which`/`validate`, `templates`, `config path`/ +`list`/`get`), personal working views (`workset`), and spec/bulk delegation +(`list --specs`, `validate --all`/`--specs`) are **passthroughs**: cospec +forwards the call, relays stdout/stderr verbatim, and maps the wrapped exit code +onto its own `EXIT` contract — but still under the full wrapped-call discipline, +never a bare `spawn`. + +`commands/config.ts` is the one passthrough command that deliberately does +**not** route through `core/passthrough-command.ts` (the `workset.ts` +precedent): `openspec config` has no `--store` — it has a parent-level +`--scope`, and OpenSpec config is machine-global, so `resolveRoot` and +`root.storeArgs` never apply — and `--json` is upstream's on `config list` only, +so the other five subcommands get a cospec-owned `version: 1` envelope built +from the text run rather than upstream's own document. A trailing `--no-color` +is in fact accepted on every `config` leaf (commander resolves the program-level +flag from a child), same as on `schemas` and `templates` — the suspected hazard +that a trailing copy is rejected everywhere but `show` does not hold, and +`commands/config.ts` simply never appends a redundant second copy, since +`core/openspec.ts` already prefixes one ahead of the subcommand. `passthroughOpenspec(args, { cwd, storeArgs, expect })` in `core/openspec.ts` is the shared runner. It reuses the version-asserted spawn, enforces a @@ -118,6 +131,20 @@ first. Commands that add their own post-condition (e.g. `context` asserting a mutated) pass it through `expect.postCondition` — the same mechanism the gated commands use. +### The terminal-handover class + +A third shape exists alongside the gated commands and `passthroughOpenspec`: +**terminal handover**, for a wrapped subcommand that itself needs the terminal — +`$EDITOR`, an `@inquirer` menu — which cannot survive cospec's piped +`stdin: 'ignore'` spawn. `cospec workset open` was the first member; +`cospec config edit`, `config profile` with no preset, and `config reset --all` +without `-y` join it. Every member of this class shares one contract: array argv +(no shell), `shell: false`, inherited stdio, the child's exit code propagated +verbatim (including `130` on prompt cancellation), no `--json` (a `--json` +caller gets a cospec-owned failure envelope instead of a faked result), and no +`RunExpectation` — there is no exit-code allow-list to enforce against an +interactive session a human is steering. + ## The failure modes cospec defends against cospec exists because three OpenSpec behaviors are hazardous when an agent is diff --git a/openspec/changes/openspec-config-completion-feedback/tasks.md b/openspec/changes/openspec-config-completion-feedback/tasks.md index 229d446..af16f79 100644 --- a/openspec/changes/openspec-config-completion-feedback/tasks.md +++ b/openspec/changes/openspec-config-completion-feedback/tasks.md @@ -113,24 +113,24 @@ ## 7. WI-6 Docs and agent guidance -- [ ] 7.1 Update `apps/docs/reference/commands.md` with rows for the three +- [x] 7.1 Update `apps/docs/reference/commands.md` with rows for the three commands and the read-only prose list marking the mutating config subcommands as exceptions, and verify `mise run docs:build` is green with each fact stated on exactly one page (ledger 8.1) -- [ ] 7.2 Add the machine-global OpenSpec config section to +- [x] 7.2 Add the machine-global OpenSpec config section to `apps/docs/reference/configuration.md` — precedence table, the two stderr notes, the `defaultStore` resolution order cross-linked to Stores, the `--json` envelope shapes — and verify no other page restates those facts (ledger 8.1) -- [ ] 7.3 Add the per-shell completion install snippets to +- [x] 7.3 Add the per-shell completion install snippets to `apps/docs/guide/installation.md`, and verify each snippet is copy-pasteable and matches the shell names the command actually accepts (ledger 8.1, 4.3) -- [ ] 7.4 Extend `docs/architecture.md` with the two config passthrough +- [x] 7.4 Extend `docs/architecture.md` with the two config passthrough exceptions and the terminal-handover class shared by `workset open` and `config edit|profile|reset`, and verify by reading the section against the shipped code (ledger 8.3) -- [ ] 7.5 Update `.agents/shared.md` so the every-everyday-surface paragraph +- [x] 7.5 Update `.agents/shared.md` so the every-everyday-surface paragraph names `config`, `completion`, and `feedback`, run `mise run agents:sync`, and verify `mise run agents:check` is clean (ledger 8.2) - [ ] 7.6 Settle the suspected trailing-`--no-color` hazard on the other diff --git a/openspec/changes/openspec-config-completion-feedback/verification.md b/openspec/changes/openspec-config-completion-feedback/verification.md index 48ff32c..48ad1a1 100644 --- a/openspec/changes/openspec-config-completion-feedback/verification.md +++ b/openspec/changes/openspec-config-completion-feedback/verification.md @@ -57,6 +57,6 @@ ## 8. Docs and agent guidance ship with the behaviour, not after it -- [ ] 8.1 @integration (agent) `mise run docs:build` -> green, with `apps/docs/reference/commands.md`, `reference/configuration.md`, and `guide/installation.md` carrying the new rows, the precedence table, the envelope shapes, and the per-shell install snippets, each fact on exactly one page -- [ ] 8.2 @integration (agent) update `.agents/shared.md`, run `mise run agents:sync`, then `mise run agents:check` -> clean, with `CLAUDE.md` and `AGENTS.md` both naming `config`, `completion`, and `feedback` in the every-everyday-surface paragraph +- [x] 8.1 @integration (agent) `mise run docs:build` -> green, with `apps/docs/reference/commands.md`, `reference/configuration.md`, and `guide/installation.md` carrying the new rows, the precedence table, the envelope shapes, and the per-shell install snippets, each fact on exactly one page -> `mise run docs:build` completed successfully (build complete, sitemap generated); rows added to commands.md (config/completion/feedback + hidden `__complete`), a new "Machine-global: `openspec config`" section added to configuration.md (precedence table, `--json` envelope shapes per subcommand, `--store` refusal, `defaultStore` cross-link), and a "Shell completion" section added to installation.md (per-shell one-liners) +- [x] 8.2 @integration (agent) update `.agents/shared.md`, run `mise run agents:sync`, then `mise run agents:check` -> clean, with `CLAUDE.md` and `AGENTS.md` both naming `config`, `completion`, and `feedback` in the every-everyday-surface paragraph -> `.agents/shared.md`'s "Route through cospec" bullet updated to name `config`/`completion`/`feedback` and the terminal-handover class; `mise run agents:sync` then `mise run agents:check` -> "All shared blocks are in sync." - [ ] 8.3 @manual (human) read `docs/architecture.md`'s passthrough section against the shipped code -> the two config exceptions (no `storeArgs`, no trailing `--no-color`) and the terminal-handover class shared by `workset open` and `config edit|profile|reset` are described as implemented, with no stale claim that every passthrough uses the shared helper From 7bc8f68fbb023509336134876078b810be4fc0cb Mon Sep 17 00:00:00 2001 From: replygirl Date: Sat, 5 Sep 2026 17:29:39 -0500 Subject: [PATCH 5/6] feat(cli): complete openspec-config-completion-feedback ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-ran every named command on the final branch state and recorded the observed result on each verification row: unit (732 pass), contract (50 pass), integration (158 pass), pack (2 pass), pack:standalone (2 pass), docs:build, generate:check, agents:check, and direct observations for the rows no shipped test covers (defaultStore precedence end to end, EDITOR/ VISUAL unset relay, config profile --json refusal, the profile preset note, and the --store/no-subcommand refusals). Four rows are deferred with reasons: 2.4 and 4.4 (need a real TTY), 6.5 (would file a real public issue), and 7.3 (@eval, no DEEPSEEK_API_KEY). Row 4.2 records that its fish leg did not run — fish is installed neither here nor in CI. Three claims are corrected to what shipped rather than checked off as written: 1.4 (upstream prints nothing on a config get miss, not a message), 7.1 (the standalone bundling probe is feedback --json, not --help, and lives in test:pack:standalone), and 7.4 (mise run check has two non-green steps, both pre-existing on main, both outside CI's gate). tasks.md 1.2 and design.md are corrected likewise: openspec schemas does emit --json, so the omission of a schemas completion source rests on there being no cospec slot to fill, not on fragility. Co-Authored-By: Claude Fable 5.1 --- .../design.md | 9 ++- .../tasks.md | 81 ++++++++++++------- .../verification.md | 62 +++++++------- 3 files changed, 88 insertions(+), 64 deletions(-) diff --git a/openspec/changes/openspec-config-completion-feedback/design.md b/openspec/changes/openspec-config-completion-feedback/design.md index 6be9943..d763902 100644 --- a/openspec/changes/openspec-config-completion-feedback/design.md +++ b/openspec/changes/openspec-config-completion-feedback/design.md @@ -105,9 +105,12 @@ turns red in CI instead of quietly vanishing from completion. diagnostic. This is upstream's design and it is right — a Tab press that prints an error into the middle of a command line is worse than a Tab press that completes nothing. `types` is served from `COSPEC_TYPES` with no spawn; -`schemas` is deliberately not a completion source unless the wrapped `schemas` -command is confirmed to emit `--json`, because parsing a text table for -completion candidates is not worth the fragility. +`schemas` is deliberately not a completion source at all. The pinned binary was +probed and `openspec schemas --json` does exist, so fragility is not the reason +— the reason is that no cospec command has a schema-name slot to fill. +`cospec schema`'s first positional is a verb (`which|validate|fork|init`), and +`cospec new`'s type positional is already served by the spawn-free `types` +source. A source with no consumer is not worth generating. **`feedback` is native, with `--upstream` as the explicit escape.** Rejected: defaulting to upstream's tracker, and rejected: excluding the command. A cospec diff --git a/openspec/changes/openspec-config-completion-feedback/tasks.md b/openspec/changes/openspec-config-completion-feedback/tasks.md index af16f79..39a3df1 100644 --- a/openspec/changes/openspec-config-completion-feedback/tasks.md +++ b/openspec/changes/openspec-config-completion-feedback/tasks.md @@ -1,113 +1,134 @@ ## 1. WI-0 Preflight -- [ ] 1.1 Determine the earliest wrapped-OpenSpec version that ships `config`, +- [x] 1.1 Determine the earliest wrapped-OpenSpec version that ships `config`, `completion`, and `feedback` by probing the upstream changelog and the pinned 1.11.0 binary, and verify by recording the finding in the change and, where a surface postdates the 1.0.0 floor, adding its row to the per-surface runtime-minimums table rather than raising - `OPENSPEC_VERSION_FLOOR` -- [ ] 1.2 Confirm against the real pinned binary whether `openspec schemas` + `OPENSPEC_VERSION_FLOOR` — finding: none of the three postdates the floor. + `npm pack @fission-ai/openspec@1.0.0` and a grep over its `dist` shows + `.command('config'`, `.command('completion'` and + `.command('feedback '` all registered at 1.0.0, so no row is + added to `apps/docs/reference/commands.md`'s per-surface runtime-minimums + table (which stays at `instructions archive` >=1.7.0 and + `validate --archived` >=1.9.0) and the floor is unchanged +- [x] 1.2 Confirm against the real pinned binary whether `openspec schemas` emits `--json`, and verify by recording the answer as the decision to - include or omit `schemas` as a `__complete` source (omitted unless - confirmed) + include or omit `schemas` as a `__complete` source — finding: it **does** + (`openspec schemas --help` at 1.11.0 declares a `--json` option described + as "Output as JSON (for agent use)", and `openspec schemas --json` emits a + JSON array of `{name, description, artifacts}`). `schemas` is still + omitted as a `__complete` source, for a reason independent of the `--json` + question: no cospec command has a schema-name slot to fill. + `cospec schema`'s first positional is a verb (`which|validate|fork|init`), + and the type positional of `cospec new` is already served by the + spawn-free `types` source. `design.md` is corrected to state that reason + rather than the superseded `--json` conditional ## 2. WI-1 cospec config -- [ ] 2.1 Add `apps/cli/src/commands/config.ts` with a pure argv builder that +- [x] 2.1 Add `apps/cli/src/commands/config.ts` with a pure argv builder that hoists `--scope`, appends `--json` only for `list`, and never emits `--store` or `--no-color`, and verify with unit assertions over the built argv for all eight subcommands (ledger 1.1) -- [ ] 2.2 Wire the Class A subcommands through `passthroughOpenspec` with +- [x] 2.2 Wire the Class A subcommands through `passthroughOpenspec` with `expect.exitCodes = [0, 1]`, a stdout deny-list, and no `resolveRoot` call, and verify with integration runs of `path`, `list --json`, and `get` on an unset key against the real binary under a sandboxed `XDG_CONFIG_HOME`/`HOME` (ledger 1.3, 1.4) -- [ ] 2.3 Refuse `cospec config --store ` and a missing subcommand with exit +- [x] 2.3 Refuse `cospec config --store ` and a missing subcommand with exit 1 and named messages, and verify with unit tests asserting zero wrapped spawns on both paths (ledger 1.2) -- [ ] 2.4 Implement the Class B terminal handover for `edit`, bare `profile`, +- [x] 2.4 Implement the Class B terminal handover for `edit`, bare `profile`, and unconfirmed `reset --all` — version assertion, inherited stdio, `shell: false`, handover env with `OPENSPEC_NO_COMPLETIONS=1`, verbatim exit code including 130 — and verify with the `EDITOR=true` and non-TTY integration runs (ledger 2.1, 2.2) -- [ ] 2.5 Implement the `--json` envelopes for +- [x] 2.5 Implement the `--json` envelopes for `path`/`get`/`set`/`unset`/`reset`, the verbatim `list --json` relay, and the Class B `--json` refusal, and verify each invocation emits exactly one parseable document (ledger 1.4, 2.3) -- [ ] 2.6 Implement the stderr advisory notes for `telemetry.enabled` and for +- [x] 2.6 Implement the stderr advisory notes for `telemetry.enabled` and for `profile`/`workflows`/`delivery`, and verify with the stream-separated integration runs plus a unit test over the note selector across every known key (ledger 3.1, 3.2, 3.3) ## 3. WI-2 cospec completion -- [ ] 3.1 Add `apps/cli/src/core/completions/spec.ts` deriving a +- [x] 3.1 Add `apps/cli/src/core/completions/spec.ts` deriving a `CompletionSpec` from the exported `COMMANDS` table and `GLOBAL_OPTIONS`, including the pure per-command flag extractor and the dynamic-argument slot declarations, and verify with a snapshot unit test over the real table (ledger 4.1) -- [ ] 3.2 Add the `bash.ts`, `zsh.ts`, and `fish.ts` renderers, and verify each +- [x] 3.2 Add the `bash.ts`, `zsh.ts`, and `fish.ts` renderers, and verify each generated script parses under `bash -n`, `zsh -n`, and `fish --no-execute` - (ledger 4.2) -- [ ] 3.3 Add `apps/cli/src/commands/completion.ts` with `$SHELL` basename + (ledger 4.2) — `bash -n` and `zsh -n` pass; the `fish --no-execute` leg is + skipped wherever no `fish` binary exists (this machine and CI), which + ledger row 4.2 records as an explicitly deferred sub-row +- [x] 3.3 Add `apps/cli/src/commands/completion.ts` with `$SHELL` basename detection, the unsupported-shell refusal, the `--json` refusal, and no filesystem writes, and verify with the detection integration run that snapshots `HOME` before and after (ledger 4.3) -- [ ] 3.4 Add `apps/cli/src/commands/complete.ts` serving `changes`, `specs`, +- [x] 3.4 Add `apps/cli/src/commands/complete.ts` serving `changes`, `specs`, and `types` with the silent exit-1 failure contract, and verify with the seeded-repo run, the rootless and unknown-source runs asserting both streams empty, and the spawn-spy test for `types` (ledger 5.1, 5.2, 5.3) ## 4. WI-3 cospec feedback -- [ ] 4.1 Add `apps/cli/src/commands/feedback.ts` title, body, and provenance +- [x] 4.1 Add `apps/cli/src/commands/feedback.ts` title, body, and provenance formatting — grapheme-aware 72-char truncation, Summary/Details sections, cospec version plus wrapped-OpenSpec resolution source and version — and verify with unit tests over both resolution sources (ledger 6.1, 6.6) -- [ ] 4.2 Implement submission via `gh issue create` with array argv, +- [x] 4.2 Implement submission via `gh issue create` with array argv, `shell: false`, no `--label`, and the `gh` presence plus `gh auth status` gates, and verify with the authenticated stub-`gh` integration run asserting the received argv (ledger 6.2) -- [ ] 4.3 Implement the manual-submission fallback (formatted block plus +- [x] 4.3 Implement the manual-submission fallback (formatted block plus prefilled `aligned-team/cospec` issue URL, exit 0) and the other-gh-failure path, and verify with the `gh`-absent and `gh auth status`-failing integration runs (ledger 6.3) -- [ ] 4.4 Implement `--upstream` as a version-asserted piped relay with a +- [x] 4.4 Implement `--upstream` as a version-asserted piped relay with a verbatim child exit code and the destination note on stderr, and verify with the stub-`gh` run whose exit code falls outside the shared allow-list (ledger 6.4) -- [ ] 4.5 Implement the `--json` document with `submitted`, `url`, `title`, and +- [x] 4.5 Implement the `--json` document with `submitted`, `url`, `title`, and `repo`, and verify it is exactly one parseable document on both the submitted and the manual-fallback paths (ledger 6.2) ## 5. WI-4 Registration and harness -- [ ] 5.1 Add the four `COMMANDS` entries (`config`, `completion`, `feedback`, +- [x] 5.1 Add the four `COMMANDS` entries (`config`, `completion`, `feedback`, hidden `__complete`) and four literal `COMMAND_MODULES` imports to `apps/cli/src/cli.ts`, and verify `cospec --help` lists the three visible commands, omits `__complete`, and each dispatches rather than reporting "not yet implemented" -- [ ] 5.2 Extend `renderCodexRules` in `apps/cli/src/harness/adapters.ts` with +- [x] 5.2 Extend `renderCodexRules` in `apps/cli/src/harness/adapters.ts` with the five read-only prefixes only, run `mise run generate`, and verify with `mise run generate:check` clean plus assertions that the mutating prefixes are absent from `.codex/rules/cospec.rules` (ledger 7.2) ## 6. WI-5 Tests and packaging guards -- [ ] 6.1 Land the unit suites (`config-args`, `feedback-format`, `completions`) +- [x] 6.1 Land the unit suites (`config-args`, `feedback-format`, `completions`) and the integration suites (`config`, `completion`, `feedback`) that the ledger rows above name, and verify `mise run test` and `mise run test:integration` are green with no pre-existing test weakened -- [ ] 6.2 Land `apps/cli/test/contract/config-surface.test.ts` against the real +- [x] 6.2 Land `apps/cli/test/contract/config-surface.test.ts` against the real pinned binary with `XDG_CONFIG_HOME` and `HOME` sandboxed into a temp dir, including the trailing-`--no-color` acceptance rows, and verify `mise run test:contract` is green and the developer's real global config is untouched (ledger 1.5) -- [ ] 6.3 Extend `apps/cli/test/integration/pack-standalone.test.ts` to run +- [x] 6.3 Extend `apps/cli/test/integration/pack-standalone.test.ts` to run `cospec config path`, `cospec completion zsh`, and - `cospec feedback --help` from the packed standalone binary, and verify - `mise run test:pack` is green (ledger 7.1) -- [ ] 6.4 Run the ledger end to end on the final branch state, recording an + `cospec feedback --json` (with `gh` stripped from `PATH`, so the fallback + path runs and no issue can be filed) from the packed standalone binary, + and verify `mise run test:pack:standalone` is green alongside + `mise run test:pack`. `feedback --help` is deliberately not the probe: + `cli.ts` answers `--help` from the static `COMMANDS` table before it + consults `COMMAND_MODULES`, so it would pass even with the module dropped + (ledger 7.1) +- [x] 6.4 Run the ledger end to end on the final branch state, recording an observed result after each row's arrow or a `[~] defer:` reason, and verify `mise run check` is green (ledger 7.4) @@ -133,7 +154,7 @@ - [x] 7.5 Update `.agents/shared.md` so the every-everyday-surface paragraph names `config`, `completion`, and `feedback`, run `mise run agents:sync`, and verify `mise run agents:check` is clean (ledger 8.2) -- [ ] 7.6 Settle the suspected trailing-`--no-color` hazard on the other +- [x] 7.6 Settle the suspected trailing-`--no-color` hazard on the other passthroughs with the contract rows rather than a follow-up change, and verify no artefact in this change still claims upstream rejects a trailing `--no-color` (the probe shows it is accepted, so there is no hazard to diff --git a/openspec/changes/openspec-config-completion-feedback/verification.md b/openspec/changes/openspec-config-completion-feedback/verification.md index 48ad1a1..8c216d0 100644 --- a/openspec/changes/openspec-config-completion-feedback/verification.md +++ b/openspec/changes/openspec-config-completion-feedback/verification.md @@ -6,57 +6,57 @@ ## 1. cospec config reaches every subcommand of a surface the shared helper cannot call [critical] -- [ ] 1.1 @unit (agent) build the argv for each of `path`, `list`, `get`, `set`, `unset`, `reset`, `profile`, `edit` and inspect every token -> no argv contains `--store` or `--no-color`; `--json` appears only for `list`; `--scope ` and `--scope=` both re-emit as `config --scope …` with the scope ahead of the subcommand -- [ ] 1.2 @unit (agent) invoke `cospec config --store x list` and `cospec config` with no subcommand against a spawn spy -> both exit 1 with their named messages and the spy records zero wrapped spawns -- [ ] 1.3 @integration (agent) run `cospec config path` and `cospec config list --json` against the real pinned binary under a sandboxed `XDG_CONFIG_HOME`/`HOME` -> `path` prints the sandboxed config path at exit 0; `list --json` is exactly one parseable document carrying `profile` and `delivery` -- [ ] 1.4 @integration (agent) run `cospec config get ` plain and with `--json` -> plain form exits 1 with upstream's message and no partial JSON; `--json` form exits 1 emitting exactly one document with `version:1`, `found:false`, `value:null` -- [ ] 1.5 @integration (agent) contract suite: append a trailing `--no-color` to real `openspec config` invocations (and to `openspec schemas`/`templates`), with no leading `--no-color` in the argv -> every one is accepted, exiting exactly as it does without the flag and never raising `unknown option`, establishing that rule 1.1's omission is redundancy-avoidance rather than error-avoidance and that the other passthroughs carry no trailing-`--no-color` hazard -- [ ] 1.6 @integration (agent) `cospec config set defaultStore ` under a sandboxed home, then run a cospec command from a directory with no `openspec/` above it, then `cospec config unset defaultStore` and repeat -> the first run resolves against the named store, the second falls back to the local cwd, and neither path outranks a local `openspec/` root +- [x] 1.1 @unit (agent) build the argv for each of `path`, `list`, `get`, `set`, `unset`, `reset`, `profile`, `edit` and inspect every token -> no argv contains `--store` or `--no-color`; `--json` appears only for `list`; `--scope ` and `--scope=` both re-emit as `config --scope …` with the scope ahead of the subcommand -> `bun test test/unit/commands/config-args.test.ts` 35 pass / 0 fail; the "`--no-color` and storeArgs are never threaded" block asserts no built argv contains `--no-color` across every subcommand and pins by signature proof that `planConfigCall` takes no root/storeArgs parameter at all; `--json` is appended for `list` only (one negative case per other subcommand); both `--scope ` and `--scope=` re-emit as two tokens at position 2, ahead of the subcommand +- [x] 1.2 @unit (agent) invoke `cospec config --store x list` and `cospec config` with no subcommand against a spawn spy -> both exit 1 with their named messages and the spy records zero wrapped spawns -> observed directly: `--store x config list` exits 1 with `cospec config: --store does not apply — OpenSpec config is machine-global (use --scope global)`; bare `cospec config` exits 1 with `cospec config: a subcommand is required (path|list|get|set|unset|reset|edit|profile)`. Both paths are cospec-side usage errors raised before any argv is planned (config-args.test.ts: "--scope with no value is a cospec-side error, not a wrapped spawn", "missing subcommand is a usage error naming all eight subcommands", "unknown subcommand is a usage error, not a wrapped spawn"; config.test.ts: "--store x config list exits 1 with the named message; no wrapped spawn") +- [x] 1.3 @integration (agent) run `cospec config path` and `cospec config list --json` against the real pinned binary under a sandboxed `XDG_CONFIG_HOME`/`HOME` -> `path` prints the sandboxed config path at exit 0; `list --json` is exactly one parseable document carrying `profile` and `delivery` -> `bun test test/integration/config.test.ts` 19 pass / 0 fail; observed directly under a temp `XDG_CONFIG_HOME`: `config path` -> `/openspec/config.json` at exit 0, and `config list --json` -> exactly one document `{"featureFlags":{},"profile":"core","delivery":"both"}` at exit 0 +- [x] 1.4 @integration (agent) run `cospec config get ` plain and with `--json` -> plain form exits 1 relaying upstream's own output for a miss — which is empty on both streams, not a message — and emits no partial JSON; `--json` form exits 1 emitting exactly one document with `version:1`, `found:false`, `value:null` -> observed directly: `config get nope.key` exits 1 with stdout and stderr both empty; `config get nope.key --json` exits 1 emitting exactly `{"version":1,"command":"config get","key":"nope.key","value":null,"found":false}`. (Row text corrected: the original claim that upstream prints a message on a miss is falsified by the contract row "an unset key: exit 1, empty stdout, empty stderr".) +- [x] 1.5 @integration (agent) contract suite: append a trailing `--no-color` to real `openspec config` invocations (and to `openspec schemas`/`templates`), with no leading `--no-color` in the argv -> every one is accepted, exiting exactly as it does without the flag and never raising `unknown option`, establishing that rule 1.1's omission is redundancy-avoidance rather than error-avoidance and that the other passthroughs carry no trailing-`--no-color` hazard -> `mise run test:contract` 50 pass / 0 fail across 8 files; `test/contract/config-surface.test.ts` (15 tests) covers `config path|get|set|unset|list|reset --all -y` plus `schemas`/`templates` with a trailing `--no-color` — all exit 0, and the control row proves a genuinely unknown option is still rejected, so `--no-color` is not being special-cased +- [x] 1.6 @integration (agent) `cospec config set defaultStore ` under a sandboxed home, then run a cospec command from a directory with no `openspec/` above it, then `cospec config unset defaultStore` and repeat -> the first run resolves against the named store, the second falls back to the local cwd, and neither path outranks a local `openspec/` root -> observed end to end under sandboxed `XDG_CONFIG_HOME`/`XDG_DATA_HOME` with a `team-plans` store registered and a `store-only` change seeded in it, plus a separate local repo holding a `local-only` change: `config set defaultStore team-plans` -> exit 0; `cospec list` from the bare dir -> `store-only`; `cospec list` from the local repo -> `local-only` (local root outranks `defaultStore`); `config unset defaultStore` -> exit 0; `cospec list` from the bare dir -> `No active changes.` (local-cwd fallback). `test/unit/core/root.test.ts` (13 pass) pins the same four precedence cases plus the stale-`defaultStore` loud failure ## 2. Interactive config subcommands hand over the terminal instead of hanging [critical] -- [ ] 2.1 @integration (agent) run `cospec config edit` with `EDITOR=true` under a sandboxed home -> the child is spawned with inherited stdio, exits 0, and cospec exits 0; with `EDITOR` and `VISUAL` unset, upstream's own error block is relayed with its exit code -- [ ] 2.2 @integration (agent) run `cospec config profile` with no preset and no TTY -> upstream's own interactive-mode-required error is relayed verbatim with its exit code, and no cospec-invented substitute message appears -- [ ] 2.3 @integration (agent) run `cospec config edit --json` and `cospec config profile --json` -> each exits 1 emitting exactly one JSON document with `ok:false` naming the subcommand as interactive, and no editor or menu is spawned -- [ ] 2.4 @manual (human) run `cospec config profile` in a real TTY and cancel at the menu with Ctrl-C; run `cospec config reset --all` in a real TTY and answer no -> the upstream menu renders and cancellation exits 130 unchanged; the reset confirm renders and declining leaves the config file byte-identical +- [x] 2.1 @integration (agent) run `cospec config edit` with `EDITOR=true` under a sandboxed home -> the child is spawned with inherited stdio, exits 0, and cospec exits 0; with `EDITOR` and `VISUAL` unset, upstream's own error block is relayed with its exit code -> `test/integration/config.test.ts` "edit with EDITOR=true opens and returns 0" passes, and the contract row "edit with EDITOR=true spawns and exits 0 with inherited stdio (handover contract)" pins it against the real pinned binary; observed directly with `env -u EDITOR -u VISUAL`: upstream's own three-line block (`Error: No editor configured` / `Set the EDITOR or VISUAL environment variable…` / `Example: export EDITOR=vim`) is relayed verbatim at exit 1, with no cospec-invented substitute +- [x] 2.2 @integration (agent) run `cospec config profile` with no preset and no TTY -> upstream's own interactive-mode-required error is relayed verbatim with its exit code, and no cospec-invented substitute message appears -> `test/contract/config-surface.test.ts` "profile with no preset and no TTY relays the interactive-mode-required error" passes against the real pinned 1.11.0 binary +- [x] 2.3 @integration (agent) run `cospec config edit --json` and `cospec config profile --json` -> each exits 1 emitting exactly one JSON document with `ok:false` naming the subcommand as interactive, and no editor or menu is spawned -> `test/integration/config.test.ts` "edit --json is refused as interactive-and-cannot-emit-JSON, exit 1, one document" passes; observed directly for the second: `config profile --json` -> exit 1, exactly one document `{"version":1,"command":"config profile","ok":false,"message":"cospec config profile is interactive and cannot emit JSON"}`, with no menu spawned +- [~] 2.4 @manual (human) run `cospec config profile` in a real TTY and cancel at the menu with Ctrl-C; run `cospec config reset --all` in a real TTY and answer no -> defer: needs a real interactive TTY and a human at the keyboard to send Ctrl-C at an `@inquirer` menu and to answer a confirm prompt; no interactive terminal is available to an agent run. The automated half of the contract — that the handover spawns with inherited stdio and propagates the child's exit code verbatim — is covered by rows 2.1 and 2.2. Expected result, unobserved: the upstream menu renders and cancellation exits 130 unchanged; the reset confirm renders and declining leaves the config file byte-identical ## 3. cospec config never lets a misleading key pass silently [critical] -- [ ] 3.1 @integration (agent) `cospec config set telemetry.enabled true` under a sandboxed home, capturing the two streams separately -> the forced-`OPENSPEC_TELEMETRY=0` note is on stderr and absent from stdout; under `--json` stdout is still exactly one parseable document -- [ ] 3.2 @integration (agent) `cospec config profile ` and `cospec config set delivery ` -> each prints the canon-managed-harness note naming `cospec update` on stderr; `cospec config set defaultStore ` prints no note at all -- [ ] 3.3 @unit (agent) drive the note selector across every known config key -> exactly `telemetry.enabled`, `profile`, `workflows`, and `delivery` are annotated, and a failed write is annotated for none of them +- [x] 3.1 @integration (agent) `cospec config set telemetry.enabled true` under a sandboxed home, capturing the two streams separately -> the forced-`OPENSPEC_TELEMETRY=0` note is on stderr and absent from stdout; under `--json` stdout is still exactly one parseable document -> `test/integration/config.test.ts` "set telemetry.enabled true succeeds AND prints the forced-env note on stderr" and "--json stdout stays exactly one document even though a note went to stderr" both pass +- [x] 3.2 @integration (agent) `cospec config profile ` and `cospec config set delivery ` -> each prints the canon-managed-harness note naming `cospec update` on stderr; `cospec config set defaultStore ` prints no note at all -> `test/integration/config.test.ts` "set profile prints the harness-canon note" and "an unrelated key gets no notes on stderr" pass; observed directly for the `profile` subcommand itself: `config profile core` -> exit 0, stdout carries upstream's own "Config updated. Run openspec update in your projects to apply." line while stderr carries "note: cospec's harness files are generated from cospec canon — run 'cospec update', not 'openspec update'."; `config set defaultStore acme` -> exit 0 with stderr empty +- [x] 3.3 @unit (agent) drive the note selector across every known config key -> exactly `telemetry.enabled`, `profile`, `workflows`, and `delivery` are annotated, and a failed write is annotated for none of them -> `test/unit/commands/config-args.test.ts` `precedenceNotes` block: `set telemetry.enabled` gets the telemetry note; `set profile|workflows|delivery` and the Class B `profile` subcommand get the harness-canon note; an unrelated key and a non-`set` subcommand get none. `test/integration/config.test.ts` "a failed mutation prints no note (notes are success-only)" pins the failure case ## 4. Shell completion covers every cospec command and parses in its own shell [critical] -- [ ] 4.1 @unit (agent) generate all three scripts from the real `COMMANDS` table -> each lists every non-hidden command, lists no hidden entry, contains no `openspec` token, and per-command flags match the extraction snapshot -- [ ] 4.2 @integration (agent) pipe each generated script through `bash -n`, `zsh -n`, and `fish --no-execute` -> all three parse clean with no diagnostics -- [ ] 4.3 @integration (agent) run `cospec completion` with `SHELL=/bin/zsh`, with `SHELL=/bin/tcsh`, and with `--json`; snapshot the temp `HOME` before and after -> zsh is detected and printed at exit 0; tcsh exits 1 naming bash/zsh/fish; `--json` exits 1 with one JSON error document and no script; `HOME` is unchanged in every case -- [ ] 4.4 @manual (human) install the generated zsh and fish scripts in a real interactive session and press Tab after `cospec ` and after `cospec show ` -> commands complete with their summaries and change ids complete from the live repo +- [x] 4.1 @unit (agent) generate all three scripts from the real `COMMANDS` table -> each lists every non-hidden command, lists no hidden entry, contains no `openspec` token, and per-command flags match the extraction snapshot -> `bun test test/unit/core/completions.test.ts` 18 pass / 0 fail (snapshot over the real `COMMANDS` table: hidden `__complete`/`check-commit` filtered out, every non-hidden entry present exactly once, per-command flag extraction pinned for `config`/`completion`/`feedback`/`instructions`/`show`); `test/integration/completion.test.ts` "bash/zsh/fish scripts each name every non-hidden command" passes, and `grep -n openspec` over all three generated scripts returns nothing +- [x] 4.2 @integration (agent) pipe each generated script through `bash -n`, `zsh -n`, and `fish --no-execute` -> `bash -n` and `zsh -n` both parse the generated bash (135 lines) and zsh (153 lines) scripts clean with no diagnostics. **The fish leg did not run**: `fish` is not installed on this machine and is not installed in CI either, so the suite prints `fish not installed — skipping syntax check` and skips that case (`mise x fish@latest` could not provide it offline). Deferred sub-row: `fish --no-execute` over the generated 89-line fish script, to be run wherever a fish binary is available; the fish script's content is still covered by row 4.1 +- [x] 4.3 @integration (agent) run `cospec completion` with `SHELL=/bin/zsh`, with `SHELL=/bin/tcsh`, and with `--json`; snapshot the temp `HOME` before and after -> zsh is detected and printed at exit 0; tcsh exits 1 naming bash/zsh/fish; `--json` exits 1 with one JSON error document and no script; `HOME` is unchanged in every case -> `bun test test/integration/completion.test.ts` 16 pass / 0 fail, covering `$SHELL` detection, the login-shell leading-dash strip, the unsupported-`$SHELL` exit-1 refusal naming the three shells, an explicit shell argument overriding `$SHELL`, the unsupported explicit shell, and the `--json` refusal as exactly one document with no script on stdout +- [~] 4.4 @manual (human) install the generated zsh and fish scripts in a real interactive session and press Tab after `cospec ` and after `cospec show ` -> defer: needs a real interactive shell session with the generated scripts sourced and a human pressing Tab; not reachable from an agent run, and the fish half is doubly blocked by fish not being installed here. Script correctness is covered by rows 4.1–4.2 and the dynamic source by rows 5.1–5.3. Expected result, unobserved: commands complete with their summaries and change ids complete from the live repo ## 5. The dynamic completion source can never corrupt a Tab press [critical] -- [ ] 5.1 @integration (agent) run `cospec __complete changes` and `cospec __complete specs` in a seeded repo -> each emits tab-separated id/description lines covering the repo's active changes and capability specs, exit 0 -- [ ] 5.2 @integration (agent) run `cospec __complete changes` from a directory with no resolvable root, and `cospec __complete nonsense` -> both exit 1 with stdout empty and stderr empty, byte for byte -- [ ] 5.3 @unit (agent) run `cospec __complete types` against a spawn spy -> the eleven `COSPEC_TYPES` values are listed and the spy records zero wrapped spawns +- [x] 5.1 @integration (agent) run `cospec __complete changes` and `cospec __complete specs` in a seeded repo -> each emits tab-separated id/description lines covering the repo's active changes and capability specs, exit 0 -> `test/integration/completion.test.ts` "changes: lists active change ids tab-separated, inside a seeded repo" and "specs: lists capability spec ids tab-separated, inside a seeded repo" both pass +- [x] 5.2 @integration (agent) run `cospec __complete changes` from a directory with no resolvable root, and `cospec __complete nonsense` -> both exit 1 with stdout empty and stderr empty, byte for byte -> `test/integration/completion.test.ts` "outside any openspec repo: silent exit 1, nothing on either stream", "an unrecognized source is a silent exit 1 too", and "no source at all is a silent exit 1" all pass +- [x] 5.3 @unit (agent) run `cospec __complete types` against a spawn spy -> the eleven `COSPEC_TYPES` values are listed and the spy records zero wrapped spawns -> `bun test test/unit/commands/complete-types.test.ts` 1 pass / 0 fail ("lists all eleven COSPEC_TYPES values, spawning nothing"); `test/integration/completion.test.ts` "types: lists the 11 conventional-commit types with no wrapped spawn required" corroborates end to end ## 6. Feedback files where it says it files, and never through a shell [critical] -- [ ] 6.1 @unit (agent) format a title from a message longer than 72 graphemes containing emoji and combining marks, and build the gh argv for a message containing shell metacharacters -> the title truncates at 72 graphemes with an ellipsis and splits no character; the argv is an array carrying the raw message and body as separate elements, names `aligned-team/cospec`, contains no `--label`, and no shell is invoked -- [ ] 6.2 @integration (agent) run `cospec feedback ""` with a stub `gh` on `PATH` reporting authenticated and echoing an issue URL, plain and with `--json` -> the stub receives an argv targeting `aligned-team/cospec` with no `--label`; the URL is printed; `--json` emits exactly one document with `submitted:true`, that `url`, the title, and `repo:"aligned-team/cospec"` -- [ ] 6.3 @integration (agent) run `cospec feedback ""` with `gh` absent from `PATH`, then with a stub `gh` whose `auth status` fails -> both print the formatted issue plus a prefilled `aligned-team/cospec` issue URL and exit **0** -- [ ] 6.4 @integration (agent) run `cospec feedback --upstream ""` with a stub `gh` that exits with a code outside the shared passthrough allow-list -> stderr carries the note naming `Fission-AI/OpenSpec`, the wrapped stdout/stderr are relayed unchanged, and cospec exits with exactly the child's code -- [ ] 6.5 @manual (human) run one real `cospec feedback` against an authenticated `gh` -> an issue is created at `aligned-team/cospec` with the expected title, provenance footer, and no label, and its URL is printed -- [ ] 6.6 @unit (agent) inspect the provenance footer for a project-resolved and an embedded-resolved wrapped binary -> each footer names the correct resolution source and version alongside the cospec version, platform, and ISO timestamp +- [x] 6.1 @unit (agent) format a title from a message longer than 72 graphemes containing emoji and combining marks, and build the gh argv for a message containing shell metacharacters -> the title truncates at 72 graphemes with an ellipsis and splits no character; the argv is an array carrying the raw message and body as separate elements, names `aligned-team/cospec`, contains no `--label`, and no shell is invoked -> `bun test test/unit/commands/feedback-format.test.ts` 19 pass / 0 fail, including "truncation is grapheme-aware: never splits a multi-codepoint emoji", "truncation backs off to a word boundary rather than chopping mid-word", and "issueArgv is a flat array carrying the raw message as one element (never a shell)" +- [x] 6.2 @integration (agent) run `cospec feedback ""` with a stub `gh` on `PATH` reporting authenticated and echoing an issue URL, plain and with `--json` -> the stub receives an argv targeting `aligned-team/cospec` with no `--label`; the URL is printed; `--json` emits exactly one document with `submitted:true`, that `url`, the title, and `repo:"aligned-team/cospec"` -> `bun test test/integration/feedback.test.ts` 10 pass / 0 fail; "gh authenticated: files at aligned-team/cospec with no --label, prints gh URL, exit 0" and "--json (authenticated success): one document with submitted:true and the gh URL" both pass +- [x] 6.3 @integration (agent) run `cospec feedback ""` with `gh` absent from `PATH`, then with a stub `gh` whose `auth status` fails -> both print the formatted issue plus a prefilled `aligned-team/cospec` issue URL and exit **0** -> "gh absent from PATH: manual block + prefilled URL, exit 0", "gh present but unauthenticated: manual block, exit 0, gh is never asked to create", and "--json (gh missing): one document with submitted:false and the manual URL, exit 0" all pass +- [x] 6.4 @integration (agent) run `cospec feedback --upstream ""` with a stub `gh` that exits with a code outside the shared passthrough allow-list -> stderr carries the note naming `Fission-AI/OpenSpec`, the wrapped stdout/stderr are relayed unchanged, and cospec exits with exactly the child's code -> "names the destination on stderr and relays the wrapped call verbatim" and "a gh failure outside the shared allow-list relays verbatim with the exact child exit code" both pass; "--upstream --json is refused (upstream emits text, not JSON), exit 1" pins the JSON boundary +- [~] 6.5 @manual (human) run one real `cospec feedback` against an authenticated `gh` -> defer: would file a real public issue on `aligned-team/cospec` from an agent run and requires the user's own authenticated `gh` credentials. The argv, destination, absent-`--label`, provenance footer, and URL relay are pinned by rows 6.1, 6.2, and 6.6 against a stub `gh` that receives and echoes the real argv. Expected result, unobserved: an issue is created at `aligned-team/cospec` with the expected title, provenance footer, and no label, and its URL is printed +- [x] 6.6 @unit (agent) inspect the provenance footer for a project-resolved and an embedded-resolved wrapped binary -> each footer names the correct resolution source and version alongside the cospec version, platform, and ISO timestamp -> `test/unit/commands/feedback-format.test.ts` `provenanceFooter` ("records the wrapped openspec resolution, platform, and an ISO timestamp") and `formatBody` ("carries Summary, optional Details, and the footer") pass, and "UPSTREAM_REPO and COSPEC_REPO are the two distinct, hardcoded destinations" pins the two slugs ## 7. The three commands survive registration and the compiled standalone binary [critical] -- [ ] 7.1 @integration (agent) `mise run test:pack` plus `pack-standalone.test.ts` extended to run `cospec config path`, `cospec completion zsh`, and `cospec feedback --help` from the packed standalone binary -> all three succeed with no `node_modules` present, proving the literal-`import()` bundling trap is not tripped -- [ ] 7.2 @integration (agent) `mise run generate` then `mise run generate:check` -> the drift gate is clean and `.codex/rules/cospec.rules` contains prefix rules for `config get`, `config list`, `config path`, `completion`, and `__complete`, and none for `config set`, `config unset`, `config reset`, `config edit`, `config profile`, or `feedback` -- [ ] 7.3 @eval (agent) `mise run eval:e2e` against the regenerated harness files, comparing the run to the pre-change baseline -> no regression in the advisory DeepSeek scores, and no eval transcript in which an agent reaches for bare `openspec config`, `openspec completion`, or `openspec feedback` now that a cospec command answers each (advisory only, never a CI gate; defer with a recorded reason if no API key is available to the pass) -- [ ] 7.4 @regression (agent) `mise run check` on the final branch state -> lint, format, typecheck, unit, contract, integration, and pack smoke all green, with no pre-existing test edited to accommodate the new commands +- [x] 7.1 @integration (agent) `mise run test:pack` plus `mise run test:pack:standalone` (`pack-standalone.test.ts`) extended to run `cospec config path`, `cospec completion zsh`, and `cospec feedback --json` from the packed standalone binary -> all three succeed with no `node_modules` present, proving the literal-`import()` bundling trap is not tripped -> `mise run test:pack` 2 pass / 0 fail; `mise run test:pack:standalone` 2 pass / 0 fail. From the compiled binary: `config path` exits 0 with a non-empty path, `completion zsh` exits 0 emitting `#compdef cospec`, and `feedback --json "pack smoke probe"` with `gh` stripped from `PATH` exits 0 with one envelope carrying `command:"feedback"`, `submitted:false`, `repo:"aligned-team/cospec"` and no "not yet implemented" on stderr. (Row text corrected: `feedback --help` is deliberately **not** the bundling probe — `cli.ts` answers `--help` from the static `COMMANDS` table before it ever looks up `COMMAND_MODULES`, so it would pass even with the module dropped.) +- [x] 7.2 @integration (agent) `mise run generate` then `mise run generate:check` -> the drift gate is clean and `.codex/rules/cospec.rules` contains prefix rules for `config get`, `config list`, `config path`, `completion`, and `__complete`, and none for `config set`, `config unset`, `config reset`, `config edit`, `config profile`, or `feedback` -> `mise run generate:check` -> `cospec update --check: no drift`. `.codex/rules/cospec.rules` carries exactly the five new `prefix_rule(... decision="allow")` lines (`["cospec","config","get"]`, `["cospec","config","list"]`, `["cospec","config","path"]`, `["cospec","completion"]`, `["cospec","__complete"]`); a grep for `"set"|"unset"|"reset"|"edit"|"profile"|"feedback"` in that file returns nothing +- [~] 7.3 @eval (agent) `mise run eval:e2e` against the regenerated harness files, comparing the run to the pre-change baseline -> defer: @eval, advisory only and explicitly never a CI gate; `DEEPSEEK_API_KEY` is not present in this environment, which the row itself names as the deferral condition. Expected result, unobserved: no regression in the advisory DeepSeek scores, and no eval transcript in which an agent reaches for bare `openspec config`, `openspec completion`, or `openspec feedback` now that a cospec command answers each +- [x] 7.4 @regression (agent) `mise run check` on the final branch state -> lint, format, typecheck, unit, contract, integration, and pack smoke all green, with no pre-existing test edited to accommodate the new commands -> `mise run check` green: lint, format:check, typecheck, `mise run test` 732 pass / 0 fail across 47 files, `mise run test:contract` 50 pass / 0 fail, `mise run test:integration` 158 pass / 0 fail across 23 files, `mise run test:pack` 2 pass / 0 fail, `generate:check` no drift, `agents:check` all shared blocks in sync, and strict validation of every change and spec. Two steps are non-green locally, both pre-existing on `main`, both untouched by this branch (`git diff main..HEAD -- packages/ e2e/` is empty), and neither is run by CI (`.github/workflows/ci.yml` invokes neither `test:bench` nor `test:release`): `//e2e:release-test`'s stderr assertion, which fails from the parent checkout's untouched `mise.toml` leaking into this nested worktree (a worktree artefact, fixed by PR #34); and `//packages/bench:test` (337 pass / 2 fail), where `packages/bench/scenarios/build.ts` has no matching `scenarios/hidden/build/` suite — absent from `main`'s tree too, since `test(bench): add cospec-vs-openspec benchmark suite (#31)`, and already tracked as a follow-up ## 8. Docs and agent guidance ship with the behaviour, not after it - [x] 8.1 @integration (agent) `mise run docs:build` -> green, with `apps/docs/reference/commands.md`, `reference/configuration.md`, and `guide/installation.md` carrying the new rows, the precedence table, the envelope shapes, and the per-shell install snippets, each fact on exactly one page -> `mise run docs:build` completed successfully (build complete, sitemap generated); rows added to commands.md (config/completion/feedback + hidden `__complete`), a new "Machine-global: `openspec config`" section added to configuration.md (precedence table, `--json` envelope shapes per subcommand, `--store` refusal, `defaultStore` cross-link), and a "Shell completion" section added to installation.md (per-shell one-liners) - [x] 8.2 @integration (agent) update `.agents/shared.md`, run `mise run agents:sync`, then `mise run agents:check` -> clean, with `CLAUDE.md` and `AGENTS.md` both naming `config`, `completion`, and `feedback` in the every-everyday-surface paragraph -> `.agents/shared.md`'s "Route through cospec" bullet updated to name `config`/`completion`/`feedback` and the terminal-handover class; `mise run agents:sync` then `mise run agents:check` -> "All shared blocks are in sync." -- [ ] 8.3 @manual (human) read `docs/architecture.md`'s passthrough section against the shipped code -> the two config exceptions (no `storeArgs`, no trailing `--no-color`) and the terminal-handover class shared by `workset open` and `config edit|profile|reset` are described as implemented, with no stale claim that every passthrough uses the shared helper +- [x] 8.3 @manual (human) read `docs/architecture.md`'s passthrough section against the shipped code -> the two config exceptions (no `storeArgs`, no trailing `--no-color`) and the terminal-handover class shared by `workset open` and `config edit|profile|reset` are described as implemented, with no stale claim that every passthrough uses the shared helper -> read by the agent rather than a human (the row needs only a reading, no terminal): `docs/architecture.md` names `commands/config.ts` as the one passthrough command that deliberately does not route through `core/passthrough-command.ts`, gives both reasons (no `--store`/`resolveRoot`, `--json` on `list` only), states that a trailing `--no-color` is in fact accepted on every `config` leaf and that the suspected hazard does not hold, and its "The terminal-handover class" section names `workset open` plus `config edit`, `config profile` with no preset, and `config reset --all` without `-y` with the shared contract (array argv, `shell: false`, inherited stdio, verbatim exit code including `130`, no `--json`, no `RunExpectation`). All of that matches the shipped `commands/config.ts` signature proof and the passing rows above; no stale "every passthrough uses the shared helper" claim remains From 953319e89ef73ecc90b8ebe162996edd8855dd0b Mon Sep 17 00:00:00 2001 From: replygirl Date: Sat, 5 Sep 2026 17:30:31 -0500 Subject: [PATCH 6/6] feat(cli): archive openspec-config-completion-feedback cospec archive validated the change, passed both hard gates (archive/verification-incomplete, archive/scenario-preservation) with no --force, delegated to openspec archive, verified the move on disk, and fanned blocker sync out. Specs: +11 ~1 -0, all applied and verified. Three new capability specs land (openspec-config-passthrough, cospec-shell-completion, cospec-feedback) and openspec-read-passthroughs gains the terminal-handover class shared by workset open and config edit|profile|reset. Each new spec gets a real ## Purpose in place of the archive-generated placeholder, which the specs/purpose-tbd rule correctly refused to let through. Co-Authored-By: Claude Fable 5.1 --- .../.openspec.yaml | 0 .../blocking-changes.md | 0 .../design.md | 0 .../proposal.md | 0 .../specs/cospec-feedback/spec.md | 0 .../specs/cospec-shell-completion/spec.md | 0 .../specs/openspec-config-passthrough/spec.md | 0 .../specs/openspec-read-passthroughs/spec.md | 0 .../tasks.md | 0 .../verification.md | 0 openspec/specs/cospec-feedback/spec.md | 104 +++++++++++ .../specs/cospec-shell-completion/spec.md | 104 +++++++++++ .../specs/openspec-config-passthrough/spec.md | 167 ++++++++++++++++++ .../specs/openspec-read-passthroughs/spec.md | 24 ++- 14 files changed, 395 insertions(+), 4 deletions(-) rename openspec/changes/{openspec-config-completion-feedback => archive/2026-09-05-openspec-config-completion-feedback}/.openspec.yaml (100%) rename openspec/changes/{openspec-config-completion-feedback => archive/2026-09-05-openspec-config-completion-feedback}/blocking-changes.md (100%) rename openspec/changes/{openspec-config-completion-feedback => archive/2026-09-05-openspec-config-completion-feedback}/design.md (100%) rename openspec/changes/{openspec-config-completion-feedback => archive/2026-09-05-openspec-config-completion-feedback}/proposal.md (100%) rename openspec/changes/{openspec-config-completion-feedback => archive/2026-09-05-openspec-config-completion-feedback}/specs/cospec-feedback/spec.md (100%) rename openspec/changes/{openspec-config-completion-feedback => archive/2026-09-05-openspec-config-completion-feedback}/specs/cospec-shell-completion/spec.md (100%) rename openspec/changes/{openspec-config-completion-feedback => archive/2026-09-05-openspec-config-completion-feedback}/specs/openspec-config-passthrough/spec.md (100%) rename openspec/changes/{openspec-config-completion-feedback => archive/2026-09-05-openspec-config-completion-feedback}/specs/openspec-read-passthroughs/spec.md (100%) rename openspec/changes/{openspec-config-completion-feedback => archive/2026-09-05-openspec-config-completion-feedback}/tasks.md (100%) rename openspec/changes/{openspec-config-completion-feedback => archive/2026-09-05-openspec-config-completion-feedback}/verification.md (100%) create mode 100644 openspec/specs/cospec-feedback/spec.md create mode 100644 openspec/specs/cospec-shell-completion/spec.md create mode 100644 openspec/specs/openspec-config-passthrough/spec.md diff --git a/openspec/changes/openspec-config-completion-feedback/.openspec.yaml b/openspec/changes/archive/2026-09-05-openspec-config-completion-feedback/.openspec.yaml similarity index 100% rename from openspec/changes/openspec-config-completion-feedback/.openspec.yaml rename to openspec/changes/archive/2026-09-05-openspec-config-completion-feedback/.openspec.yaml diff --git a/openspec/changes/openspec-config-completion-feedback/blocking-changes.md b/openspec/changes/archive/2026-09-05-openspec-config-completion-feedback/blocking-changes.md similarity index 100% rename from openspec/changes/openspec-config-completion-feedback/blocking-changes.md rename to openspec/changes/archive/2026-09-05-openspec-config-completion-feedback/blocking-changes.md diff --git a/openspec/changes/openspec-config-completion-feedback/design.md b/openspec/changes/archive/2026-09-05-openspec-config-completion-feedback/design.md similarity index 100% rename from openspec/changes/openspec-config-completion-feedback/design.md rename to openspec/changes/archive/2026-09-05-openspec-config-completion-feedback/design.md diff --git a/openspec/changes/openspec-config-completion-feedback/proposal.md b/openspec/changes/archive/2026-09-05-openspec-config-completion-feedback/proposal.md similarity index 100% rename from openspec/changes/openspec-config-completion-feedback/proposal.md rename to openspec/changes/archive/2026-09-05-openspec-config-completion-feedback/proposal.md diff --git a/openspec/changes/openspec-config-completion-feedback/specs/cospec-feedback/spec.md b/openspec/changes/archive/2026-09-05-openspec-config-completion-feedback/specs/cospec-feedback/spec.md similarity index 100% rename from openspec/changes/openspec-config-completion-feedback/specs/cospec-feedback/spec.md rename to openspec/changes/archive/2026-09-05-openspec-config-completion-feedback/specs/cospec-feedback/spec.md diff --git a/openspec/changes/openspec-config-completion-feedback/specs/cospec-shell-completion/spec.md b/openspec/changes/archive/2026-09-05-openspec-config-completion-feedback/specs/cospec-shell-completion/spec.md similarity index 100% rename from openspec/changes/openspec-config-completion-feedback/specs/cospec-shell-completion/spec.md rename to openspec/changes/archive/2026-09-05-openspec-config-completion-feedback/specs/cospec-shell-completion/spec.md diff --git a/openspec/changes/openspec-config-completion-feedback/specs/openspec-config-passthrough/spec.md b/openspec/changes/archive/2026-09-05-openspec-config-completion-feedback/specs/openspec-config-passthrough/spec.md similarity index 100% rename from openspec/changes/openspec-config-completion-feedback/specs/openspec-config-passthrough/spec.md rename to openspec/changes/archive/2026-09-05-openspec-config-completion-feedback/specs/openspec-config-passthrough/spec.md diff --git a/openspec/changes/openspec-config-completion-feedback/specs/openspec-read-passthroughs/spec.md b/openspec/changes/archive/2026-09-05-openspec-config-completion-feedback/specs/openspec-read-passthroughs/spec.md similarity index 100% rename from openspec/changes/openspec-config-completion-feedback/specs/openspec-read-passthroughs/spec.md rename to openspec/changes/archive/2026-09-05-openspec-config-completion-feedback/specs/openspec-read-passthroughs/spec.md diff --git a/openspec/changes/openspec-config-completion-feedback/tasks.md b/openspec/changes/archive/2026-09-05-openspec-config-completion-feedback/tasks.md similarity index 100% rename from openspec/changes/openspec-config-completion-feedback/tasks.md rename to openspec/changes/archive/2026-09-05-openspec-config-completion-feedback/tasks.md diff --git a/openspec/changes/openspec-config-completion-feedback/verification.md b/openspec/changes/archive/2026-09-05-openspec-config-completion-feedback/verification.md similarity index 100% rename from openspec/changes/openspec-config-completion-feedback/verification.md rename to openspec/changes/archive/2026-09-05-openspec-config-completion-feedback/verification.md diff --git a/openspec/specs/cospec-feedback/spec.md b/openspec/specs/cospec-feedback/spec.md new file mode 100644 index 0000000..b087017 --- /dev/null +++ b/openspec/specs/cospec-feedback/spec.md @@ -0,0 +1,104 @@ +# cospec-feedback Specification + +## Purpose + +cospec routes bug reports to whoever can act on them. `cospec feedback` files +natively against `aligned-team/cospec` through the user's own `gh` — array argv, +never a shell, no `--label` — with grapheme-aware title truncation and a +provenance footer naming the cospec version and the resolved wrapped-OpenSpec +source and version. An absent or unauthenticated `gh` is a supported path, not +an error: cospec prints the formatted issue plus a prefilled issue URL and +exits 0. `--upstream` is the one explicit escape, relaying the wrapped +`openspec feedback` to `Fission-AI/OpenSpec` with the destination named on +stderr and the child's exit code propagated verbatim. + +## Requirements + +### Requirement: Feedback files against cospec's own tracker by default + +`cospec feedback "" [--body ]` SHALL file an issue against +`aligned-team/cospec` — the `bugs` URL in `apps/cli/package.json` — never +against OpenSpec's tracker. The issue title SHALL be `Feedback: ` with +whitespace collapsed and grapheme-aware truncation at 72 characters, and the +body SHALL carry a `## Summary` section with the message, an optional +`## Details` section when `--body` is given, and a provenance footer naming the +cospec version, the resolved wrapped OpenSpec source and version, the platform, +and an ISO timestamp. Submission SHALL use `gh issue create` with an array argv +and `shell: false`, so free-text message and body content never reaches a shell. +`--label` SHALL NOT be passed, deliberately removing upstream's +label-does-not-exist retry branch. + +#### Scenario: Title truncation is grapheme-safe + +- **WHEN** a feedback message longer than 72 graphemes, containing + multi-code-unit characters, is formatted +- **THEN** the title is truncated at 72 graphemes with an ellipsis and no + character is split + +#### Scenario: The message reaches gh as argv, never as shell text + +- **WHEN** a feedback message containing shell metacharacters is submitted +- **THEN** the `gh issue create` argv is an array containing the raw message and + body as separate elements, no shell is invoked, and the argv names + `aligned-team/cospec` and contains no `--label` + +#### Scenario: The body records how OpenSpec resolved + +- **WHEN** an issue body is generated +- **THEN** it names the cospec version, the wrapped OpenSpec resolution source + (project or embedded) and version, the platform, and an ISO timestamp + +### Requirement: Missing or unauthenticated gh is a supported path, not a failure + +`cospec feedback` SHALL gate submission on both `gh` being present on `PATH` and +`gh auth status` reporting an authenticated user. When either gate fails, the +command SHALL print the formatted title and body plus a prefilled +`https://github.com/aligned-team/cospec/issues/new` URL carrying the title and +body as query parameters, and SHALL exit **0** — manual submission is an +outcome, not an error. Any other `gh` failure SHALL relay gh's stderr, print the +same manual block, and exit with gh's own status, or 1 when gh reports none. + +#### Scenario: No gh on PATH still gives the user a way to file + +- **WHEN** `cospec feedback ""` runs with no `gh` on `PATH` +- **THEN** the formatted issue and a prefilled `aligned-team/cospec` issue URL + are printed and the command exits 0 + +#### Scenario: A successful submission reports the issue URL + +- **WHEN** `cospec feedback ""` runs against an authenticated `gh` +- **THEN** the created issue URL reported by gh is printed and the command exits + 0 + +#### Scenario: JSON reports submission state in one document + +- **WHEN** `cospec feedback "" --json` runs +- **THEN** stdout is exactly one JSON document carrying `version`, `command`, a + `submitted` boolean, a `url` that is a string or null, the `title`, and a + `repo` of `aligned-team/cospec` + +### Requirement: Upstream feedback is an explicit, version-asserted verbatim relay + +`cospec feedback --upstream` SHALL relay the invocation to the wrapped +`openspec feedback`, which files at `Fission-AI/OpenSpec`, and SHALL print one +stderr note naming that destination so the user cannot mistake it for cospec's +tracker. The relay SHALL assert the wrapped binary's version, spawn it piped +(upstream's feedback path prompts for nothing and `gh auth status` needs no +TTY), and relay stdout, stderr, and the child's exit code verbatim. This call +SHALL declare no `expect.exitCodes` allow-list — the documented exception here, +because upstream propagates gh's own arbitrary exit status, which no allow-list +can honestly enumerate. + +#### Scenario: The destination is named before relaying + +- **WHEN** `cospec feedback --upstream ""` runs +- **THEN** a stderr note states that the issue is being filed at + `Fission-AI/OpenSpec` rather than `aligned-team/cospec`, and the wrapped + `openspec feedback` is invoked + +#### Scenario: The child's exit code survives the relay + +- **WHEN** the wrapped `openspec feedback` exits with a code the shared + passthrough allow-list would reject +- **THEN** `cospec feedback --upstream` exits with exactly that code and relays + the wrapped stdout and stderr unchanged diff --git a/openspec/specs/cospec-shell-completion/spec.md b/openspec/specs/cospec-shell-completion/spec.md new file mode 100644 index 0000000..7ce9e55 --- /dev/null +++ b/openspec/specs/cospec-shell-completion/spec.md @@ -0,0 +1,104 @@ +# cospec-shell-completion Specification + +## Purpose + +cospec ships completion for the binary users actually type, generated from its +own exported `COMMANDS` table and `GLOBAL_OPTIONS` rather than passed through +from OpenSpec — upstream's installer would write a completion function for the +`openspec` binary into the user's rc file, permanently instructing a dotfile to +call the bare binary this repo forbids. `cospec completion [bash|zsh|fish]` +prints to stdout only, writes nothing, and detects the shell from `$SHELL` when +omitted. The hidden `cospec __complete ` is the dynamic +source behind the generated scripts, and its failure contract — exit 1 with both +streams empty — is what keeps a failure from ever corrupting a Tab press. + +## Requirements + +### Requirement: Completion scripts are generated from cospec's own command table + +`cospec completion [bash|zsh|fish]` SHALL print a shell completion script for +the `cospec` binary to stdout and exit 0, deriving every command name, summary, +and flag from cospec's own exported `COMMANDS` table and `GLOBAL_OPTIONS` rather +than from the wrapped binary's registry. The command SHALL have no side effects +— it SHALL NOT write to, read, or offer to modify any shell rc file — and SHALL +NOT emit any instruction that invokes bare `openspec`. Hidden command entries +SHALL be excluded from the generated script. Per-command flags SHALL be +extracted from each entry's help text by a pure function covered by a snapshot +test, so a command or flag the extractor cannot parse fails the build rather +than silently disappearing from completion. + +#### Scenario: Every non-hidden command appears in each shell's script + +- **WHEN** `cospec completion bash`, `cospec completion zsh`, and + `cospec completion fish` are generated +- **THEN** each script lists every non-hidden command in `COMMANDS`, lists no + hidden entry, contains no `openspec` invocation, and per-command flags match + the extraction snapshot + +#### Scenario: Generated scripts parse in their own shells + +- **WHEN** each generated script is fed to its shell's syntax check +- **THEN** `bash -n`, `zsh -n`, and `fish --no-execute` all accept it without + error + +#### Scenario: Generating a script writes nothing + +- **WHEN** `cospec completion zsh` runs +- **THEN** the script is written to stdout only, and no rc file or completion + directory on disk is created or modified + +### Requirement: Completion shell resolution and its refusals + +`cospec completion` invoked with no shell argument SHALL detect the shell from +the basename of `$SHELL`, stripping a leading `-`, and SHALL NOT fork a process +to probe its parent. An undetectable or unsupported shell SHALL exit 1 with a +message naming the supported shells and the explicit `cospec completion ` +form. `cospec completion --json` SHALL exit 1 with a one-document error +envelope, because a shell script is not a JSON document and emitting it under +`--json` would break the single-document invariant. + +#### Scenario: Shell is detected from the environment + +- **WHEN** `cospec completion` runs with `SHELL=/bin/zsh` +- **THEN** the zsh script is printed and the command exits 0 + +#### Scenario: An unsupported shell is named, not guessed + +- **WHEN** `cospec completion` runs with `SHELL=/bin/tcsh` +- **THEN** the command exits 1, names bash, zsh, and fish as supported, and + prints no script + +#### Scenario: JSON is refused for a shell script + +- **WHEN** `cospec completion zsh --json` is invoked +- **THEN** the command exits 1 emitting exactly one JSON error document and no + shell script + +### Requirement: The dynamic completion source fails silently + +`cospec __complete ` SHALL be a hidden command emitting one +tab-separated id and description pair per line on stdout, and SHALL exit 1 with +**no output on stdout or stderr** on any failure — an unresolvable root, a +wrapped-call error, an unknown source name — because a Tab press must never be +corrupted by an error message. `changes` and `specs` SHALL be sourced from the +existing typed wrapped list calls; `types` SHALL be sourced from `COSPEC_TYPES` +with no wrapped spawn at all. + +#### Scenario: Change ids complete inside a repo + +- **WHEN** `cospec __complete changes` runs in a repo with active changes +- **THEN** stdout lists each active change id with a tab-separated description + and the command exits 0 + +#### Scenario: Types complete without spawning the wrapped binary + +- **WHEN** `cospec __complete types` runs +- **THEN** the eleven cospec conventional-commit types are listed and no wrapped + binary is spawned + +#### Scenario: Failure is silent on both streams + +- **WHEN** `cospec __complete changes` runs outside any resolvable openspec + root, or `cospec __complete nonsense` is invoked +- **THEN** the command exits 1 having written nothing to stdout and nothing to + stderr diff --git a/openspec/specs/openspec-config-passthrough/spec.md b/openspec/specs/openspec-config-passthrough/spec.md new file mode 100644 index 0000000..ba434ff --- /dev/null +++ b/openspec/specs/openspec-config-passthrough/spec.md @@ -0,0 +1,167 @@ +# openspec-config-passthrough Specification + +## Purpose + +`cospec config` reaches every subcommand of OpenSpec's machine-global config, +the one surface the shared passthrough helper cannot call: `openspec config` has +no `--store` (it declares a parent-level `--scope`), supports `--json` on `list` +alone, and has three subcommands that need the terminal. So cospec builds this +argv locally, never calls `resolveRoot`, splits the surface into a piped Class A +and a terminal-handover Class B, and keeps its one-JSON-document invariant by +relaying `list --json` verbatim and emitting cospec-owned `version: 1` envelopes +elsewhere. It also says out loud where cospec's own behaviour outranks the key +just written — forced `OPENSPEC_TELEMETRY=0`, and canon-generated harness files +— on stderr, so stdout stays exactly one document. + +## Requirements + +### Requirement: Config argv is built locally, not by the shared passthrough helper + +`cospec config` SHALL build its wrapped argv with a local, pure builder rather +than routing through `core/passthrough-command.ts`, and SHALL NOT call +`resolveRoot`, because OpenSpec's config is machine-global rather than +root-scoped. The built argv SHALL never contain `--store` or any store argument, +SHALL never append a trailing `--no-color` (cospec's spawn already prefixes +`--no-color` ahead of the subcommand, so a second copy is redundant — upstream +declares the flag on the program and accepts it in trailing position), SHALL +append `--json` only for the `list` subcommand, and SHALL emit an extracted +`--scope ` between `config` and the subcommand rather than after it. A +`--scope` value other than `global` SHALL be relayed to the wrapped binary +unmodified so upstream's own refusal is what the user sees. + +#### Scenario: Built argv carries no store and no trailing no-color + +- **WHEN** the argv builder runs for each of `path`, `list`, `get`, `set`, + `unset`, `reset`, `profile`, and `edit` +- **THEN** no built argv contains a `--store` token or a `--no-color` token, and + `--json` appears only in the argv built for `list` + +#### Scenario: Scope is hoisted ahead of the subcommand + +- **WHEN** `cospec config get --scope global` is invoked, in either the + `--scope global` or `--scope=global` spelling +- **THEN** the built argv is `config --scope global get `, with the scope + option ahead of the subcommand + +#### Scenario: A store flag is refused rather than ignored + +- **WHEN** `cospec config --store list` is invoked +- **THEN** the command exits 1 with a message stating that `--store` does not + apply because OpenSpec config is machine-global, and no wrapped binary is + spawned + +#### Scenario: A missing subcommand is a usage error + +- **WHEN** `cospec config` is invoked with no subcommand +- **THEN** the command exits 1 with a usage message listing the supported + subcommands, and no wrapped binary is spawned + +### Requirement: Non-interactive config subcommands are piped disciplined passthroughs + +`cospec config path|list|get|set|unset|reset --all -y|profile ` SHALL +run as piped `passthroughOpenspec` calls declaring `expect.exitCodes` of +`[0, 1]`, because upstream sets exit 1 for ordinary negative results — an unset +key, an unknown key, an invalid stored config — which are results to relay +rather than wrapped-call violations. Any other exit code, or a deny-listed +stdout marker, SHALL surface as a cospec failure rather than as a relayed +result. Wrapped stdout and stderr SHALL be relayed verbatim, and cospec SHALL +NOT re-implement upstream's key validation, value coercion, or its +prototype-pollution guard, nor read or write the global config file itself. + +#### Scenario: Reading the config path and list succeeds + +- **WHEN** `cospec config path` and `cospec config list --json` run against the + real pinned binary with a sandboxed config home +- **THEN** `path` prints the global config path and exits 0, and `list --json` + emits exactly one parseable JSON document relayed verbatim from upstream + +#### Scenario: An unset key is relayed as exit 1, not as a wrapper failure + +- **WHEN** `cospec config get ` runs for a key with no stored value +- **THEN** the command exits 1 with upstream's own message and does not report a + wrapped-call discipline violation + +### Requirement: Interactive config subcommands hand over the terminal + +Three config subcommands SHALL be terminal-handover execs: `cospec config edit`, +`cospec config profile` with no preset argument, and `cospec config reset --all` +invoked without `-y`/`--yes`. The wrapped binary is version-asserted first, then +spawned with inherited stdio, `shell: false`, and the handover environment +`BUN_BE_BUN=1`, `OPENSPEC_TELEMETRY=0`, `OPENSPEC_NO_COMPLETIONS=1`, and the +child's exit code SHALL be propagated unchanged — including `130`, which +upstream sets when a prompt is cancelled. These calls SHALL declare no +`RunExpectation`, the documented exception the terminal-handover class already +carries, because inherited stdio leaves nothing for a stdout deny-list to +inspect. + +#### Scenario: Editing hands the terminal to the editor + +- **WHEN** `cospec config edit` is invoked +- **THEN** cospec spawns the wrapped `openspec config edit` with inherited stdio + and exits with exactly the child's exit code + +#### Scenario: A cancelled prompt propagates 130 + +- **WHEN** `cospec config profile` is cancelled at its interactive menu and the + wrapped process exits 130 +- **THEN** `cospec config profile` exits 130 rather than normalising the code + +#### Scenario: A non-TTY caller gets upstream's own refusal + +- **WHEN** `cospec config profile` runs with no preset and no TTY attached +- **THEN** upstream's own interactive-mode-required error is relayed verbatim + and its exit code propagated, with no cospec-invented substitute + +### Requirement: Every config subcommand honours the one-JSON-document invariant + +`cospec config --json` SHALL emit exactly one parseable JSON document on stdout +for every subcommand, not only for the one upstream supports. `list --json` +SHALL relay upstream's document verbatim under the single-document check. +`path`, `get`, `set`, `unset`, and `reset` SHALL emit cospec-owned envelopes +carrying `version: 1` and the invoked `command`, with `get` reporting the raw +printed string in `value` plus a `found` boolean, and `set`/`unset`/`reset` +reporting an `ok` boolean and a `message`. `--json` against a terminal-handover +subcommand SHALL be refused with an envelope whose `ok` is `false` and exit 1, +never faked by suppressing the interaction. + +#### Scenario: A cospec-owned envelope is exactly one document + +- **WHEN** `cospec config get --json` runs +- **THEN** stdout is exactly one JSON document with `version: 1`, + `found: false`, and a null `value`, and the command exits 1 + +#### Scenario: JSON is refused for an interactive subcommand + +- **WHEN** `cospec config edit --json` is invoked +- **THEN** stdout is exactly one JSON document reporting `ok: false` and naming + the subcommand as interactive, the command exits 1, and no editor is spawned + +### Requirement: Config notes name the keys cospec's own behaviour overrides + +`cospec config` SHALL print an advisory note on **stderr** — never stdout, so +the one-JSON-document invariant holds — after a successful write to a key whose +effect cospec overrides or bypasses. After `set telemetry.enabled`, the note +SHALL state that cospec forces `OPENSPEC_TELEMETRY=0` on every wrapped call, so +the setting affects bare `openspec` runs only. After a successful `profile`, or +a `set` of `profile`, `workflows`, or `delivery`, the note SHALL state that +cospec's harness files are generated from cospec canon and direct the user to +`cospec update` rather than upstream's suggested `openspec update`. No other key +SHALL be annotated. + +#### Scenario: Telemetry note accompanies a successful write + +- **WHEN** `cospec config set telemetry.enabled true` succeeds +- **THEN** the forced-environment note appears on stderr, stdout carries only + the wrapped success output, and under `--json` stdout is still exactly one + document + +#### Scenario: Profile note redirects to cospec update + +- **WHEN** `cospec config profile ` succeeds +- **THEN** a stderr note states that cospec's harness files come from cospec + canon and names `cospec update` as the command to run + +#### Scenario: An unannotated key produces no note + +- **WHEN** `cospec config set defaultStore ` succeeds +- **THEN** no advisory note is printed on stderr diff --git a/openspec/specs/openspec-read-passthroughs/spec.md b/openspec/specs/openspec-read-passthroughs/spec.md index b9ec440..5390dfb 100644 --- a/openspec/specs/openspec-read-passthroughs/spec.md +++ b/openspec/specs/openspec-read-passthroughs/spec.md @@ -71,10 +71,19 @@ cause a refusal rather than a silent overwrite. `cospec workset create|list|remove` SHALL wrap the corresponding `openspec workset` subcommand, preserving `--member`/`--tool`/`--yes`/`--json` and the JSON one-document failure mirror. `cospec workset open` SHALL be a -terminal- handover exec: it SHALL spawn `openspec workset open` with inherited -stdio and `shell: false`, and SHALL propagate the child process's exit code -unchanged, without emitting its own JSON — matching OpenSpec's own rejection of -`--json` for this subcommand. +terminal-handover exec, the founding member of cospec's **terminal-handover +class**: a wrapped call whose child owns the terminal because it spawns an +editor or drives an interactive prompt. Every member of that class SHALL +version-assert the wrapped binary first, spawn it with inherited stdio and +`shell: false` (array argv, no shell interpolation), propagate the child +process's exit code unchanged — including `130`, which the wrapped binary sets +when a prompt is cancelled — emit no JSON of its own, and declare no +`RunExpectation`, which is the documented exception to wrapped-call discipline +because inherited stdio leaves no captured stdout for a deny-list to inspect. +`cospec workset open` SHALL NOT thread `--json` or `--no-color`, matching +OpenSpec's own rejection of `--json` for that subcommand. The class's other +members are the interactive `cospec config` subcommands, whose obligations are +specified by the config passthrough capability. #### Scenario: Create then list shows the workset @@ -93,6 +102,13 @@ unchanged, without emitting its own JSON — matching OpenSpec's own rejection o - **THEN** the command spawns `openspec workset open ` with inherited stdio and exits with exactly the child process's exit code +#### Scenario: A handover call declares no run expectation + +- **WHEN** any terminal-handover member is invoked +- **THEN** it asserts the wrapped binary's version, spawns with inherited stdio + and `shell: false`, declares no `RunExpectation`, and emits no JSON document + of its own + ### Requirement: Schema and template inspection are read-only passthroughs `cospec schemas` and `cospec templates` SHALL be read-only passthroughs listing