From a7fb8e4cac9b3f1e93b00649fe4b8c91680917bf Mon Sep 17 00:00:00 2001 From: Song Luo Date: Fri, 31 Jul 2026 15:33:05 +0800 Subject: [PATCH] fix(cli): describe exact leaf command paths Resolve two-segment command describe requests through the root registry so human and JSON output identify the exact registered leaf without invoking its capability. Unknown leaves and longer paths now fail instead of falling back to parent metadata. Implements #33 using docs/specs/2026-07-31-33-describe-leaf-command-paths.md. Focused CLI and frozen-contract tests passed; broader Windows failures were limited to baseline EBUSY and EPERM host behavior. Co-authored-by: Codex (GPT 5.6 Sol) --- CHANGELOG.md | 3 + ...26-07-31-33-describe-leaf-command-paths.md | 77 ++++++++++++++++++ scripts/better-harness-cli/cli.mjs | 41 +++++++--- scripts/better-harness-cli/registry.mjs | 15 ++++ test/better-harness-cli.test.mjs | 80 +++++++++++++++++++ .../scripts-refactor-contract/root-help.txt | 2 +- 6 files changed, 208 insertions(+), 10 deletions(-) create mode 100644 docs/specs/2026-07-31-33-describe-leaf-command-paths.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b4a7268..dd5c5b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ observable behavior and compatibility, not every internal refactor. ### Fixed +- Make `command describe` resolve exact registered leaf paths instead of + returning the parent command metadata. + - The Portable HTML report route in `templates/reporting/routing.md` now lists WorkBuddy, so agents on WorkBuddy are routed to the self-contained HTML + Markdown output the 0.4.0 host adapter already ships. A derived diff --git a/docs/specs/2026-07-31-33-describe-leaf-command-paths.md b/docs/specs/2026-07-31-33-describe-leaf-command-paths.md new file mode 100644 index 0000000..7ec6c88 --- /dev/null +++ b/docs/specs/2026-07-31-33-describe-leaf-command-paths.md @@ -0,0 +1,77 @@ +# Describe exact leaf command paths + +## Traceability + +- Spec ID: `issue-33-describe-leaf-command-paths` +- Story: `#33` +- Status: Implemented + +## Intent + +Make `better-harness command describe` resolve the complete registered command +path so contributors and automation can inspect a leaf command without executing +its capability. A two-segment request must not silently fall back to metadata for +the parent command. + +## Acceptance Scenarios + +- AC-1: `command describe --json` returns parser-safe metadata + for the exact registered leaf, including its canonical path, audience, and + script, without dispatching the leaf. +- AC-2: The human form identifies the canonical two-segment path and reports the + leaf audience, script, and summary or description when present. +- AC-3: One-segment parent descriptions and parent-alias canonicalization remain + unchanged. +- AC-4: An unknown leaf fails with the existing unknown-subcommand diagnostic in + human and JSON modes instead of returning parent metadata. +- AC-5: A third command-path segment fails with a bounded invalid-path diagnostic + in human and JSON modes instead of being ignored. + +## Non-goals + +- Do not execute a described command or inspect capability-private runtime state. +- Do not add leaf aliases, change command registration, or change dispatch. +- Do not redesign command inventory, OpenCLI schema, global option parsing, or + command output contracts outside `command describe`. + +## Plan and Tasks + +1. Add a registry-owned lookup for an exact canonical command path while + preserving the existing parent metadata lookup. +2. Parse the describe path after removing the recognized `--json` bootstrap flag, + reject paths longer than two command segments, and distinguish unknown parents + from unknown leaves. +3. Render leaf descriptions through the existing human and JSON output paths. +4. Add focused regression coverage for group and direct-command leaves, human and + JSON output, parent compatibility, unknown leaves, extra path segments, and + non-dispatch behavior. +5. Record the user-visible correction in `CHANGELOG.md` and run focused, full, + documentation, package, and diff validation. + +## Test and Review Evidence + +- Before implementation, the five focused #33 regression cases failed against + `origin/main`: 0 passed, 5 failed. +- AC-1 through AC-5 and frozen root CLI contracts: + `node --test --test-concurrency=1 test/better-harness-cli.test.mjs + test/scripts-refactor-contract.test.mjs` completed with 39 passed, 0 failed, + and 1 Windows symlink-permission skip. +- The additional sparse leaf human-output check passed: 1 passed, 0 failed. +- Documentation integrity: + `node scripts/doc-link-graph/cli.mjs skills/better-harness` reported 34 files + and 50 links; `node --test test/doc-link-graph.test.mjs` passed 6 of 6. +- Package boundary: `npm run pack:verify` passed with 359 npm entries and 382 + runtime-zip entries. +- Final full regression: `node --test` completed with 1015 passed, 10 failed, + and 3 skipped. Every failure was a Windows host error before or after the + assertion body: directory cleanup returned `EBUSY`, or symlink creation + returned `EPERM`. The changed CLI test file passed 32 tests with one symlink + skip and no failure. +- A same-name selected run on untouched `origin/main` reproduced 6 of the 10 + failures with the same `EBUSY`/`EPERM` classes. Four cleanup-sensitive cases + passed in that attempt; the baseline also produced nondeterministic `EBUSY` + failures in the same unchanged test areas. +- `git diff --check` passed. + +Risk is limited to root CLI introspection. Dispatch, registry contents, parent +metadata, schemas, and capability-owned scripts remain unchanged. diff --git a/scripts/better-harness-cli/cli.mjs b/scripts/better-harness-cli/cli.mjs index 705086e..84be755 100644 --- a/scripts/better-harness-cli/cli.mjs +++ b/scripts/better-harness-cli/cli.mjs @@ -6,6 +6,7 @@ import { audienceIncludes, commandInventory, commandMetadata, + commandPathMetadata, directDispatchFor, groupCommand, listVisibleCommandNames, @@ -157,7 +158,7 @@ function usage({ color = shouldUseColor(), audience = "workflow" } = {}) { "", style.bold("Discovery:"), commandRow("commands", "List available commands; add --json for machine-readable inventory", style), - commandRow("command describe", "Describe one command; add --json for the command contract", style), + commandRow("command describe", "Describe one command path; add --json for the command contract", style), commandRow("schema", "Emit the OpenCLI schema as JSON", style), commandRow("--help --audience advanced", "Include workflow and advanced commands", style), commandRow("--help --audience maintainer", "Include every registered command", style), @@ -215,24 +216,28 @@ function groupUsage(name, group, { color = shouldUseColor(), audience = "workflo function commandDescription(command) { const lines = [ - `Command: ${command.name}`, + `Command: ${(command.path ?? [command.name]).join(" ")}`, `Audience: ${command.audience}`, - `Summary: ${command.summary}`, ]; + if (command.summary) { + lines.push(`Summary: ${command.summary}`); + } if (command.description && command.description !== command.summary) { lines.push(`Description: ${command.description}`); } - if (command.aliases.length > 0) { + if (command.aliases?.length > 0) { lines.push(`Aliases: ${command.aliases.map((alias) => alias.name).join(", ")}`); } if (command.kind === "direct") { lines.push(`Script: ${command.script}`); - } else { + } else if (command.kind === "group") { lines.push("Subcommands:"); for (const subcommand of command.subcommands) { const summary = subcommand.summary ? ` - ${subcommand.summary}` : ""; lines.push(` ${subcommand.name.padEnd(24)} [${subcommand.audience}] ${subcommand.script}${summary}`); } + } else if (command.script) { + lines.push(`Script: ${command.script}`); } return `${lines.join("\n")}\n`; } @@ -356,13 +361,22 @@ export function resolveDispatch(argv = []) { return rootError({ code: "UNKNOWN_COMMAND_SUBCOMMAND", message: `Unknown subcommand for command: ${subcommand ?? ""}`.trim(), - hint: "Use `better-harness command describe --json`.", + hint: "Use `better-harness command describe [subcommand] --json`.", machine, }); } - const [name] = rest; - const metadata = commandMetadata(name); - if (!metadata) { + const commandPath = rest.filter((value) => value !== "--json"); + const [name, leafName] = commandPath; + if (commandPath.length > 2) { + return rootError({ + code: "INVALID_COMMAND_PATH", + message: `Invalid command path: ${commandPath.join(" ")}`, + hint: "Use `better-harness command describe [subcommand] --json`.", + machine, + }); + } + const parentMetadata = commandMetadata(name); + if (!parentMetadata) { return rootError({ code: "UNKNOWN_COMMAND", message: `Unknown command: ${name ?? ""}`.trim(), @@ -370,6 +384,15 @@ export function resolveDispatch(argv = []) { machine, }); } + const metadata = commandPathMetadata(name, leafName); + if (!metadata) { + return rootError({ + code: "UNKNOWN_SUBCOMMAND", + message: `Unknown subcommand for ${parentMetadata.name}: ${leafName}`, + hint: `Use \`better-harness ${parentMetadata.name} --help\` to list subcommands.`, + machine, + }); + } if (!machine) { return { kind: "help", diff --git a/scripts/better-harness-cli/registry.mjs b/scripts/better-harness-cli/registry.mjs index c80acba..5414bec 100644 --- a/scripts/better-harness-cli/registry.mjs +++ b/scripts/better-harness-cli/registry.mjs @@ -369,6 +369,21 @@ export function commandMetadata(name, { audience = "all" } = {}) { return filterCommandAudience(metadata, normalizedAudience); } +export function commandPathMetadata(name, subcommandName, { audience = "all" } = {}) { + const command = commandMetadata(name, { audience }); + if (!command || subcommandName === undefined) { + return command; + } + const subcommand = command.subcommands?.find((entry) => entry.name === subcommandName); + if (!subcommand) { + return undefined; + } + return { + ...subcommand, + path: [command.name, subcommand.name], + }; +} + export function directDispatchFor(name, subcommandName) { const command = findCommand(name); if (command?.kind !== "direct") { diff --git a/test/better-harness-cli.test.mjs b/test/better-harness-cli.test.mjs index 3d19c25..5cc5034 100644 --- a/test/better-harness-cli.test.mjs +++ b/test/better-harness-cli.test.mjs @@ -290,6 +290,86 @@ test("better-harness CLI describes one command as JSON without dispatching it", assert.equal(payload.data.command.subcommands.some((subcommand) => subcommand.name === "diff-impact"), true); }); +test("better-harness CLI describes an exact group leaf as JSON without dispatching it", () => { + const result = runBetterHarness(["command", "describe", "harness", "render", "--json"]); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + const payload = JSON.parse(result.stdout); + assert.equal(payload.ok, true); + assert.equal(payload.format_version, "1.0"); + assert.deepEqual(payload.data.command.path, ["harness", "render"]); + assert.equal(payload.data.command.name, "render"); + assert.equal(payload.data.command.audience, "advanced"); + assert.equal(payload.data.command.script, "scripts/harness-analysis/render-report.mjs"); + assert.equal(payload.data.command.subcommands, undefined); +}); + +test("better-harness CLI describes a registered direct-command leaf", () => { + const result = runBetterHarness(["command", "describe", "session-analysis", "facts", "--json"]); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + const command = JSON.parse(result.stdout).data.command; + assert.deepEqual(command.path, ["session-analysis", "facts"]); + assert.equal(command.name, "facts"); + assert.equal(command.audience, "advanced"); + assert.equal(command.script, "scripts/session-analysis.mjs"); +}); + +test("better-harness CLI renders the canonical leaf path in human descriptions", () => { + const result = runBetterHarness(["command", "describe", "harness", "render"]); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + assert.match(result.stdout, /^Command: harness render$/mu); + assert.match(result.stdout, /^Audience: advanced$/mu); + assert.match(result.stdout, /^Summary: Render reviewed findings data into report artifacts\.$/mu); + assert.match(result.stdout, /^Script: scripts\/harness-analysis\/render-report\.mjs$/mu); + assert.doesNotMatch(result.stdout, /^Subcommands:$/mu); + + const sparse = runBetterHarness(["command", "describe", "core-change-watch", "project-profile"]); + assert.equal(sparse.status, 0, sparse.stderr); + assert.match(sparse.stdout, /^Command: core-change-watch project-profile$/mu); + assert.match(sparse.stdout, /^Script: scripts\/core-change-watch\/project-profile\.mjs$/mu); + assert.doesNotMatch(sparse.stdout, /undefined/u); +}); + +test("better-harness CLI rejects unknown describe leaves in human and JSON modes", () => { + const human = runBetterHarness(["command", "describe", "harness", "missing"]); + assert.equal(human.status, 1); + assert.equal(human.stdout, ""); + assert.match(human.stderr, /^Unknown subcommand for harness: missing$/mu); + + const machine = runBetterHarness(["command", "describe", "harness", "missing", "--json"]); + assert.equal(machine.status, 1); + assert.equal(machine.stderr, ""); + const payload = JSON.parse(machine.stdout); + assert.equal(payload.ok, false); + assert.equal(payload.error.code, "UNKNOWN_SUBCOMMAND"); + assert.equal(payload.error.message, "Unknown subcommand for harness: missing"); +}); + +test("better-harness CLI rejects extra describe path segments in human and JSON modes", () => { + const human = runBetterHarness(["command", "describe", "harness", "render", "extra"]); + assert.equal(human.status, 1); + assert.equal(human.stdout, ""); + assert.match(human.stderr, /^Invalid command path: harness render extra$/mu); + + const machine = runBetterHarness(["command", "describe", "harness", "render", "extra", "--json"]); + assert.equal(machine.status, 1); + assert.equal(machine.stderr, ""); + const payload = JSON.parse(machine.stdout); + assert.equal(payload.ok, false); + assert.equal(payload.error.code, "INVALID_COMMAND_PATH"); + assert.equal(payload.error.message, "Invalid command path: harness render extra"); + + const unknownParent = runBetterHarness(["command", "describe", "missing", "leaf", "extra", "--json"]); + assert.equal(unknownParent.status, 1); + assert.equal(unknownParent.stderr, ""); + assert.equal(JSON.parse(unknownParent.stdout).error.code, "INVALID_COMMAND_PATH"); +}); + test("better-harness CLI describes command aliases as their canonical command", () => { const result = runBetterHarness(["command", "describe", "customize", "--json"]); diff --git a/test/fixtures/scripts-refactor-contract/root-help.txt b/test/fixtures/scripts-refactor-contract/root-help.txt index 637c4d0..89dd275 100644 --- a/test/fixtures/scripts-refactor-contract/root-help.txt +++ b/test/fixtures/scripts-refactor-contract/root-help.txt @@ -56,7 +56,7 @@ Examples: Discovery: commands List available commands; add --json for machine-readable inventory - command describe Describe one command; add --json for the command contract + command describe Describe one command path; add --json for the command contract schema Emit the OpenCLI schema as JSON --help --audience advanced Include workflow and advanced commands --help --audience maintainer Include every registered command