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