Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions docs/specs/2026-07-31-33-describe-leaf-command-paths.md
Original file line number Diff line number Diff line change
@@ -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 <parent> <leaf> --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.
41 changes: 32 additions & 9 deletions scripts/better-harness-cli/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
audienceIncludes,
commandInventory,
commandMetadata,
commandPathMetadata,
directDispatchFor,
groupCommand,
listVisibleCommandNames,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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`;
}
Expand Down Expand Up @@ -356,20 +361,38 @@ export function resolveDispatch(argv = []) {
return rootError({
code: "UNKNOWN_COMMAND_SUBCOMMAND",
message: `Unknown subcommand for command: ${subcommand ?? ""}`.trim(),
hint: "Use `better-harness command describe <command> --json`.",
hint: "Use `better-harness command describe <command> [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 <command> [subcommand] --json`.",
machine,
});
}
const parentMetadata = commandMetadata(name);
if (!parentMetadata) {
return rootError({
code: "UNKNOWN_COMMAND",
message: `Unknown command: ${name ?? ""}`.trim(),
hint: "Use `better-harness commands --json` to list command names.",
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",
Expand Down
15 changes: 15 additions & 0 deletions scripts/better-harness-cli/registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
80 changes: 80 additions & 0 deletions test/better-harness-cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);

Expand Down
2 changes: 1 addition & 1 deletion test/fixtures/scripts-refactor-contract/root-help.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down