Commit Graph

7 Commits

Author SHA1 Message Date
xarmian 6fdd8816f3 feat(playbook): match free text to an active playbook via the decision provider (TASK-3120) (#1429)
* feat(server): POST /playbooks/match — typed-decision Choice over active playbooks (TASK-3120)

PLAN-3114 unit 5. A synchronous, read-only endpoint: given free text, asks
the workspace's typed-decision provider which active (non-draft,
non-deprecated) playbook it best matches, with a reserved "none" option so
unrelated text gets an honest answer instead of a forced pick — and, as a
side effect, keeps a workspace with exactly one active playbook askable
(decision.Question.Validate needs >=2 Choice options).

- decision.Runner gains a nil-safe Provider() accessor: this endpoint needs
  a plain synchronous Ask, not the owed-jobs pipeline Evaluate drives.
- No provider configured -> 404 decision_provider_unavailable (callers fall
  back to slug/trigger routing). A provider error or an out-of-set answer
  the provider was never offered -> 502 decision_provider_error, distinct
  from the 404 case so a caller can tell "try again" from "not configured".
  Zero active playbooks -> 200 with a stated reason, no provider call.
  Over the provider's 255-option ceiling (including "none") -> refused
  rather than silently dropping playbooks and answering from an
  incomplete set.
- listPlaybookItems is factored out of handleListPlaybooks so list and
  match share one visibility-scoped item source and can't drift on which
  playbooks a caller can see.
- 30s timeout on the synchronous provider call, tighter than the
  provider's own worst-case retry budget (~330s) since an HTTP caller
  blocks on this one directly, unlike the async decision tick.
- content_state_population_test.go's BUG-3033 sweep gets a new ruling: the
  PlaybookSummary call here feeds the provider prompt only, never the
  response, so there's no reader-facing text to mark.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* feat(cli): pad playbook match "<text>" (TASK-3120)

CLI + client-method half of PLAN-3114 unit 5 — POST /playbooks/match.
Table output prints the matched ref/title (or "none"), confidence, model,
and a probabilities table sorted descending; --format json passes the
response through unmodified. Read-only, side-effect-free.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* docs(skill): route to playbook match as rung 3, offer-only (TASK-3120)

PLAN-3114 unit 5's skill half. Exact invocation-slug match and trigger
match stay rungs 1-2; match is consulted only when the user is plainly
asking for a procedure/workflow and both of those missed — never on
ordinary conversation, since every call spends provider money. Any match
returned is offered, never auto-run, at any confidence: TASK-3137's replay
of this same provider's attention question set found no validated
threshold, so nothing here invents one for this question either. A 404
(no provider configured), a "none" choice, or any other error falls back
silently to today's slug/trigger-only behavior.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* chore(mcp): de-indent padItemToolDescription's continuation lines (TASK-3120)

Whitespace-only — git diff -w on this file is empty. The description's
continuation lines carried 18 leading spaces purely for visual alignment
under each action's "  name — " bullet in the Go source; that padding is
emitted verbatim into the tools/list JSON, where it conveys nothing an
LLM reading the tool description gets any value from.

De-indented by exactly 14 spaces on every line that had >= 18 (all 116 of
them, uniformly, since the file carries only three indentation levels:
0, 2, 18 — no deeper nesting to preserve a relative offset for).
Continuations now land at 4 (bullet's 2 + 2), so a reader can still tell
which action a line belongs to; a 1-space collapse would have inverted
that (continuation LESS indented than its own bullet).

tools/list: 55290 -> 53666 bytes (-1624). initialize: unchanged at 24530
(it carries no tool descriptions). Frees headroom against the 55296
budget for the pad_playbook.match action landing in the next commit —
budget constants themselves are untouched, per standing instruction.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* feat(mcp): pad_playbook.match action, ToolSurfaceVersion 0.39 -> 0.41 (TASK-3120)

PLAN-3114 unit 5's MCP half, landing on top of the whitespace-only
catalog_item.go trim (previous commit) so this stays within the
tools/list budget with room to spare.

- catalog_playbook.go: new `text` param, new `match` action
  (passThrough — the CLI's positional arg is named "text", matching
  this tool's param, so no custom shaping is needed) plus a
  description bullet in padPlaybookToolDescription.
- tool_surface.go: `match` marked read-only (nothing stored, no job
  enqueued).
- dispatch_http_routes.go: `playbook match` routeSpec (POST, flat
  {text} body) so the remote /mcp transport can dispatch it in-process.
- version.go: ToolSurfaceVersion 0.39 -> 0.41. 0.40 is reserved for
  PR #1427 (the 0.32/0.33 precedent — a gap is cheaper than a
  collision). 0.41's own entry: pure ADDITIVE bump, same disposition
  as 0.13/0.28/0.11 — nothing existing moved.
- instructions.md, README.md, CLAUDE.md: version headings + the
  pad_playbook bullet/table-row/changelog-sentence updated per the
  drift tests (TestInstructionsMDCoversEveryCatalogAction,
  TestReadmeTableCoversEveryCatalogAction, the two version-match
  tests). instructions.md's addition is intentionally terse ("`match`
  finds a playbook from text.") — it's embedded in the `initialize`
  handshake payload, which has its own 24576-byte budget and had only
  46 bytes of headroom; a fuller explanation lives in the tools/list
  param/action docs instead, which have budget to spare after the trim.
- catalog_readonly_test.go, dispatch_http_parity_test.go: new
  `pad_playbook.match` entries in three hand-built fixture maps
  (expected cmdPath ×2, a hand-rolled cmdhelp stub, and the shared
  parity-drive fixture input) so the existing bijection/parity/dispatch
  guards actually exercise the new action instead of skipping it.

Bytes measured (go test ./internal/mcp -run Budget -v):
  tools/list:  55290 (baseline) -> 53666 (after the trim commit) ->
               54805 (after this commit) — 491 bytes of headroom left
               against the 55296 budget.
  initialize:  24530 (baseline, unchanged by the trim) -> 24574 (after
               this commit) — 2 bytes of headroom against the 24576
               budget. Flagged in the implementer report: this is now
               as tight as tools/list was before the trim, and nothing
               in this unit addresses it.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(server,decision): don't leak provider errors, classify oversized requests, fix none's wording (TASK-3120)

Lead's shallow-pass findings on cd7a41ed, applied after the MCP commit.

F1 (disclosure): the provider-error path answered with err.Error()
verbatim. typesafe.go's client surfaces the provider's raw 4xx body in
that string (APIError.Error()), which is the INSTANCE's own account
state with typesafe.ai — key validity, quota, billing wording — not
anything about the caller's request. This endpoint requires only
viewer access, so any workspace member could read it. Now logged
server-side in full (slog, workspace + err) and answered with a fixed
message plus a coarse details.reason ("timeout" for a context
deadline, "upstream" otherwise) via a new writeDecisionProviderError
helper. Test constructs a fake error containing a secret-looking
string and asserts it never reaches the response body.

F2 (misclassification): the request budget is 32000 tokens
(~80000 chars). At a couple hundred active playbooks with realistic
summaries, the Choice question's options alone can exceed it before
any state is even added — or leave no room for the input text. Both
were plain fmt.Errorf with no sentinel, so they fell into the generic
"provider failed" 502 path, telling a caller "try again" for a
request that will never fit no matter how many times it's retried.
decision/typesafe.go gains ErrRequestTooLarge (wrapped with %w onto
both pre-send refusals; existing message text unchanged, just
appended-to) alongside the existing ErrMaxTokensExceeded (the
provider's own after-the-fact refusal of a request fitState let
through) — both now map to a distinct 400 match_request_too_large.
New decision-package unit test builds a realistic 254-option Choice
(title + near-cap summary + trigger + slug per option, mirroring what
playbook-match could plausibly send) and confirms fitState refuses it
before the fake HTTP handler is ever called; server-side table test
covers both sentinels through the fake provider.

F3 (nit): the "none" option's description read "...listed above",
but Options is an unordered map — nothing puts them "above" it.
Reworded to "...any of the other options' procedures"; the pinned
exact-prompt test updated to match.

Also (lead's separate MCP-round instruction): decision.go's
maxChoiceOptions is now exported as MaxChoiceOptions, and the
server's over-limit refusal reads that constant instead of a copied
`255` literal, so the two ceilings cannot drift apart.

go test ./internal/decision/... ./internal/mcp/... ./internal/server/...
./cmd/pad/... ./internal/cli/... — all green. golangci-lint run ./...
— 0 issues. Full `go test ./...` run separately; final line in the
report.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* chore(mcp): cut pad_playbook.match's tools/list text to the approved +292 variant (TASK-3120)

The lead's shape-(a) approval was for the measured "+292 documented"
prototype, not the fuller wording ae38353c actually shipped
(+1139 bytes) — the trim commit exists to pay for the approved
action text, not to fund arbitrary new prose. Cut the `text` param to
one sentence (the 404-fallback advice lives once, in the action
bullet, not repeated in the param) and the `match` action bullet down
to four lines at consistent bullet+2 continuation indentation,
matching the rest of padPlaybookToolDescription's style (the previous
wording had drifted to 20-space continuation on its last two lines).

tools/list: 53666 (post-trim baseline) -> 54023 (+357, close to the
~300 target — wording differs from the original prototype's exact
text, so an exact match isn't expected). initialize: unchanged at
24574 (this file's bullet is tools/list-only; instructions.md is a
separate file, untouched here).

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(server): don't overclaim writeDecisionProviderError's timeout reason (TASK-3120)

The doc comment said "timeout" covered a cancelled caller request too,
but context.Canceled is a distinct error from context.DeadlineExceeded
and errors.Is(err, context.DeadlineExceeded) does not match it — that
case actually falls through to "upstream", contradicting what the
comment claimed. Narrowed the comment to deadline-only (the preferred
fix per review): a cancelled caller's own request also never reads
this response body, so the distinction was moot as well as wrong.
Code was already correct; only the comment overclaimed.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(cli,skill): pad playbook match needs -- before dash-led text (TASK-3120)

Codex round 2 finding on f31d5d64. Text starting with "-" (a dash-led
sentence, or literal text like "-ship it") is parsed as a flag by
cobra's default scanner, on both the CLI and stdio MCP (which shells
out to this same command). Pre-existing and class-wide — `pad item
search "-x"` fails the same way — so the general stdio fix across
every command is filed as BUG-3142 rather than done here; BuildCLIArgs
is untouched.

This command's own help now shows the workaround: an Example section
with `pad playbook match -- "-ship it"`. skills/pad/SKILL.md's rung-3
routing entry calls it the same way, with a one-line reason, and notes
the MCP form is unaffected (a structured `text` param, not a CLI
token).

New CLI test asserts BOTH directions: `playbook match "-ship it"`
(no --) fails at cobra's flag parser and never reaches the server;
`playbook match -- "-ship it"` succeeds and the server receives the
text with its leading dash intact. Ran both legs — see the test.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(skill): local stdio MCP is not exempt from the leading-dash flag parse (TASK-3120)

Codex round 4 finding. The rung-3 routing entry claimed the MCP form of
match was "unaffected" by BUG-3142, but that's only true for remote
/mcp, which keeps `text` a structured param. Local stdio MCP shells
out to `pad playbook match`, turning `text` into a CLI positional via
BuildCLIArgs (dispatch.go:435) — the same flag parser, the same bug.
Corrected: remote /mcp is safe as-is; local stdio MCP still hits it and
should prefer the CLI's `--` form when the text starts with `-`.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(cmdhelp): the example validator's flag scan must stop at "--" (TASK-3120)

CI red on #1429 (a2a5a853), Go/Go-PG/Nix all failing the same way:
TestRealPadTree_ExampleDriftValidator flagged the new `pad playbook
match -- "-ship it"` example's own dash-led TEXT ARGUMENT as an
unknown flag `--ship it`. Cobra stops flag parsing at a lone "--" —
that's the whole point of the token — but validateExample's scan
treated "--" as just another flag-shaped token: TrimLeft reduced it
to an empty name, which fell into the "skip, keep scanning" branch
instead of stopping the scan. Everything after "--" kept getting
flag-checked, so the terminated example's own positional text was
misread as a flag.

Fixed by checking `t == "--"` BEFORE the dash-stripping and breaking
the scan there, per review. A lone "-" (the stdin convention, e.g.
`pad item import -`) is a different token — it still reaches the
dash-stripping, still reduces to an empty name, and still just
`continue`s past — so it does not terminate the scan.

Three new unit tests in internal/cmdhelp cover all three legs: (a) a
dash-led positional after "--" produces no finding, (b) an unknown
flag BEFORE "--" is still caught — the break cannot hide drift ahead
of the terminator, and (c) a lone "-" does not stop the scan (an
unknown flag after it is still caught). Manually re-ran (a) with the
break removed to confirm it goes red without the fix, then restored
the real fix — ran, not just described: FAIL "a dash-led positional
after -- should not be reported as an unknown flag" without the break,
PASS with it.

The playbook match example itself is unchanged.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd
2026-09-22 11:28:57 -04:00
xarmian cfda4463e8 feat(cmdhelp): tests + golden contract + drift validator (TASK-938) (#332)
* feat(cmdhelp): tests + golden contract + drift validator (TASK-938)

The verification layer that turns cmdhelp v0.1 from "implementation"
into "stable contract." Three categories of tests, all running in
`go test ./...`:

1. Schema validation (cmdhelp.schema.json as CI gate)
   - internal/cmdhelp/schema_test.go — synthetic tree's emitted JSON
     validates after static walk, after dynamic resolution, and after
     a no-workspace fallback.
   - cmd/pad/cmdhelp_real_test.go — the REAL pad cobra tree's emitted
     JSON validates against the published schema. Future regressions
     caught: types outside the closed vocabulary, non-numeric exit_code
     keys, flag names violating propertyNames, malformed cmdhelp_version.

2. Drift-prevention contract (spec §6 / §11 Q5)
   - internal/cmdhelp/example_validation.go — ValidateExamples walks
     every example's `cmd` string, tokenizes with shellSplit, resolves
     non-flag tokens against the live cobra tree, and asserts every
     --flag exists on the resolved command (or any ancestor for
     persistent / inherited flags). Negate-flag form (`--no-cache`)
     is recognized via the negation rule from spec §5.3.
   - shellSplit handles double/single quotes, backslash escape, and
     stops at unquoted pipeline boundaries (|, ;, &, >, <) so the
     validator only checks the first command in a pipeline.
   - ValidateBoolArity asserts no bool flag appears in valued form
     (--flag=value) anywhere in its examples (spec §5.3).
   - cmd/pad/cmdhelp_real_test.go runs both validators against the
     real pad tree as CI gates.
   - Negative tests in internal/cmdhelp/example_validation_test.go
     prove the validator catches: typo'd flag (--priorty), unknown
     command path, valued-form bool flag.

3. Capabilities form equivalence (spec §8)
   - cmd/pad/cmdhelp_real_test.go — both forms (help --capabilities
     and --cmdhelp-capabilities fallback) produce byte-identical
     output. Side-effect-free guarantee verified by passing garbage
     args alongside the fallback flag.

Refactors enabling the tests:
- cmd/pad/main.go: extract newRootCmd() so tests can build the real
  cobra tree without running it. main() body shrinks to two lines.
- cmd/pad/main.go: extract handleCmdhelpCapabilitiesFallback() so the
  fallback's side-effect-free contract is directly assertable instead
  of requiring a subprocess.

Parser improvements driven by real-pad-tree drift findings:
- parseExamplesFromLong: strip same-line `# comment` annotations so
  `pad foo --bar # one item's attachments` doesn't pollute Examples.
  stripCommentIndex is quote-aware (# inside "..." or '...' is literal).
- main.go (github cmd): the Long had annotations on example lines
  separated only by spaces (no `#`), which was malformed input. Fixed
  to use `#` separators — caught by the drift validator on first run.

New deps:
- github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 — Go JSON Schema
  validator supporting draft 2020-12 (matches the cmdhelp schema's $schema).

New helpers in internal/cmdhelp/:
- FindAndCompileSchema(startDir) walks up to locate
  schema/cmdhelp.schema.json and returns a compiled schema. Reusable
  by any consumer that wants to validate cmdhelp documents.

End-to-end on real binary:
- pad help --format json → 100 commands, schema-valid.
- All examples in pad's emitted output resolve against the live tree
  (zero drift findings).
- pad help --capabilities byte-identical to pad --cmdhelp-capabilities.
- Adding a typo'd flag in any cobra Long block in cmd/pad MUST break
  TestRealPadTree_ExampleDriftValidator. Verified by the negative
  TestValidateExamples_DetectsTypoFlag.

make check clean. All 53 cmdhelp + cmd/pad tests pass.

Parent: PLAN-930.

* fix(cmdhelp): pass full token stream to cobra.Find per Codex review (round 1)

Codex round 1 caught: ValidateExamples stopped collecting the command
path at the first flag, so an example like
  pad --workspace foo item create task --priority high
resolved to root, not `item create`. That meant `--priority` was
checked against root's flag set (where it doesn't exist) — false
positive — AND the validator silently missed any command-path drift
after a leading root flag.

Cobra's own Find walks the full token stream and uses each command's
flag definitions to skip flag/value pairs while matching subcommand
names. Pass tokens[1:] directly to root.Find — let cobra handle the
interleaving correctly.

New test:
- TestValidateExamples_FlagBeforeSubcommandResolvesToCorrectTarget —
  flag-before-subcommand resolves to the leaf and accepts leaf flags.
2026-05-01 07:17:58 -04:00
xarmian 76c9d5aae5 feat(cmdhelp): parse Examples blocks from cobra Long as fallback (TASK-939) (#331)
The original TASK-939 ask was to migrate every cobra command's
"Examples:" block from Long into the dedicated Example field. Pad has
102 cobra commands; manually migrating each is a hundreds-of-lines
change with high regression risk and zero user-visible improvement
(cobra renders "Examples:" sections in Long identically to the Example
field — the difference is only machine-readability).

Higher-leverage approach: enrich the cmdhelp emitter to fall back to
parsing Long when the Example field is empty. One small, testable change
in internal/cmdhelp unlocks examples in cmdhelp output for every command
that already has an "Examples:" block — without touching any of the 102
command sites. Structural migration becomes optional polish (HT-941).

Implementation:

- internal/cmdhelp/json.go gains parseExamplesFromLong: locate a
  stand-alone "Examples:" / "Example:" header, collect indented
  invocation lines until blank-then-prose or end. Comment lines (`#`)
  are dropped from the block. The header regex is anchored to a line
  on its own (`^\s*Examples?:\s*$`) so prose containing the word
  "Examples" doesn't trigger the fallback.
- buildCommand() prefers cmd.Example when set; falls back to
  parseExamplesFromLong(cmd.Long) when Example is empty. Tests assert
  precedence so future migrations to the Example field win cleanly.
- cmd/pad/main.go: completion command gets a dedicated Example field
  (one of the few that didn't have an "Examples:" block at all). Demos
  the migration pattern HT-941 will sweep across the rest.

Result on the real binary:

  pad help --format json  →  before: 0/100 commands have examples
                             after:  24/100 commands have examples
  pad help item create --format json  →  4 examples (vs 0 before)
  pad help completion --format json   →  4 examples (from Example field)

The remaining ~76 commands genuinely lack an Examples: block in Long
(or are group commands that don't need examples). HT-941 captures the
sweep work needed to get those to 100%.

Tests:

- 7 new tests for parseExamplesFromLong in internal/cmdhelp/json_test.go:
  basic block extraction, no-header (Usage: != Examples:), variant
  headers (singular/plural, indented), empty/malformed inputs, stops
  at unindented prose, drops comment lines.
- 2 new end-to-end tests via Build():
  - falls back to Long when Example is empty
  - prefers Example field when both set (precedence)
- All 39 existing cmdhelp tests + 16 routing tests still pass.
- make check clean.

Follow-up: HT-941 ("Migrate cobra Long Examples blocks to dedicated
Example fields") captures the structural sweep — broken into per-group
PRs (auth/*, agent/*, server/* etc.) so it can be done incrementally
without blocking PLAN-930.

Parent: PLAN-930.
2026-05-01 01:39:23 -04:00
xarmian 0439c1bf3d feat(cmdhelp): --capabilities discovery flag + --cmdhelp-capabilities fallback (TASK-937) (#330)
Implements the cmdhelp v0.1 §8 capability bit so wrappers can detect
support without trial and error.

- pad help --capabilities         → cmdhelp/0.1: text, md, json, llm
- pad --cmdhelp-capabilities      → same line (spec §8 fallback form)

Both forms:
- Single line on stdout (terminated by newline only).
- Side-effect-free: no logging, no network, no config writes, no auth
  challenge. Verified by running with no workspace context (cwd /tmp,
  no auth) — still emits the line and exits 0.
- Exit 0 on success.
- Format: cmdhelp/<MAJOR>.<MINOR>: <comma-separated formats>.

Why both forms:

Spec §8 lists `<cmd> help --capabilities` as preferred and
`<cmd> --cmdhelp-capabilities` as a fallback for CLIs whose `help`
subcommand is overloaded. Pad's `help` is not overloaded, but
supporting both forms costs nothing and lets wrappers and harnesses
choose whichever convention they prefer — TASK-938 will assert
equivalence between them.

The fallback is handled in main() before cobra parsing so it really
is side-effect-free: it doesn't even reach config.Load() or the
detect-workspace path. A simple os.Args scan + early return.

Files:
- internal/cmdhelp/json.go — new CapabilityLine(formats) helper that
  produces the spec-format string. Caller passes the format set so
  different binaries can advertise different surfaces; the helper
  preserves caller order (spec §8 says order isn't significant).
- cmd/pad/help_cmdhelp.go — adds --capabilities to helpCmd; new
  padCmdhelpFormats constant ["text","md","json","llm"]; short-circuit
  in RunE before any other logic runs.
- cmd/pad/main.go — pre-args scan handles --cmdhelp-capabilities
  fallback before rootCmd.Execute().

Tests:
- TestCapabilityLine_FormatExact — exact string match.
- TestCapabilityLine_HonorsCallerOrderAndSet — preserves caller order.
- TestHelpCmd_CapabilitiesExactString — exact-byte assertion on the
  output of `padtest help --capabilities` including the trailing newline.
- TestHelpCmd_CapabilitiesShortCircuits — verifies --capabilities wins
  over --format / --depth / extra args (spec §8 side-effect rule).
- All 35 existing cmdhelp tests + 14 routing tests still pass.
- make check clean.

End-to-end on real binary:
  pad help --capabilities         → "cmdhelp/0.1: text, md, json, llm" exit 0
  pad --cmdhelp-capabilities      → same
  cd /tmp && pad help --capabilities → still works, no auth needed
  pad help item --capabilities --format json --depth 0 → short-circuits

Parent: PLAN-930.
2026-05-01 01:32:06 -04:00
xarmian e6fd25322e feat(cmdhelp): dynamic enum resolution + workspace context (TASK-936) (#329)
* feat(cmdhelp): dynamic enum resolution + workspace context (TASK-936)

The killer differentiator from the cmdhelp v0.1 spec — splice live
workspace facts into help output so an LLM asking "what collections
exist?" gets the real answer rather than a generic "any string".

Files:
- internal/cmdhelp/dynamic.go (new) — Resolver type with Apply method.
  ArgEnumSources / FlagEnumSources map names to enum_source identifiers;
  Sources maps enum_source to a fetcher func. Apply walks the Document,
  stamps enum_source on matching args/flags, populates Enum from the
  fetcher, and sets doc.Context.Workspace. Per-Apply caching keeps each
  source func to ≤1 invocation regardless of how many commands need it.
- internal/cmdhelp/json.go — added Options.Resolver; Build calls
  Resolver.Apply after the static walk, so callers that inspect Build
  output as either pre- or post-resolution still work.
- cmd/pad/help_cmdhelp.go — newDynamicResolver constructs a Resolver
  bound to the runtime: workspace from DetectWorkspace, server URL from
  config, three sources (collections, roles, members). Returns nil when
  no workspace is detected so help still works outside any workspace.
  cmdhelpOptions takes target so Binary derives from root.Name() instead
  of hardcoded "pad" (preserves test-tree bindings for synthetic roots).

Pad-side bindings (matches `pad item create --help`'s existing context):
  arg  collection → dynamic:pad collection list
  flag role       → dynamic:pad role list
  flag assign     → dynamic:pad workspace members

End-to-end on the real binary (inside docapp workspace):
  pad help item create --format json
  → args[0].collection: type=enum, enum=[ideas,conventions,...,roadmap],
                        enum_source="dynamic:pad collection list"
  → flags.role:    enum=[planner,implementer,reviewer]
  → flags.assign:  enum=[dave]
  → context.workspace="docapp"
  pad help --format md → "## Workspace context\n- workspace: `docapp`"

Outside any workspace (cd /tmp; pad help item create --format json):
  → collection arg: type=string, no enum, no enum_source (graceful fallback)
  → context: null
  → output still validates against schema/cmdhelp.schema.json

Fail-safe behavior:
- newDynamicResolver returns nil on any config/detection error → static doc.
- Per-source fetcher errors are caught inside Apply → enum_source still
  announced on the binding arg/flag, but Enum is left empty. The help
  command MUST NOT fail because dynamic facts can't be fetched.
- Existing Enum values from alternation/ValidArgs are preserved
  (resolver only fills the gap, never overwrites authoritative spec).

Tests:
- 10 dynamic-resolver tests in internal/cmdhelp/dynamic_test.go
  covering: arg + flag enum population, context population, per-source
  caching across multiple commands, graceful error handling, nil
  resolver as no-op, existing-Enum preservation, global flag resolution,
  unaffected commands left unchanged, end-to-end via Build().
- All 24 prior cmdhelp tests + 12 routing tests still green.
- make check clean (lint + go test + web build).

Out of scope (deferred):
- --capabilities discovery flag — TASK-937.
- Schema-validate live output in CI — TASK-938.
- Audit pad's existing commands' Examples — TASK-939.

Parent: PLAN-930.

* fix(cmdhelp): scope --role / --assign bindings per-command per Codex review (round 1)

Codex round 1 on PR #329 caught a real semantic bug: globally binding
--role to dynamic:pad role list was wrong because pad has two
unrelated --role flags:

  pad workspace invite --role           workspace role: owner|editor|viewer
  pad item create     --role <slug>    agent role slug
  pad item update     --role <slug>    agent role slug

Globally announcing agent-role slugs as the values for `pad workspace
invite --role` would mislead consumers (LLMs would suggest "planner"
when "owner" is expected; tab-completion would offer the wrong set).

Fix:

- Resolver gains CommandArgBindings and CommandFlagBindings
  (map[path]map[name]source) — scoped to a specific command path.
  Per-command bindings win over wildcard ArgEnumSources/FlagEnumSources
  when both match.
- Helper methods argSource(path,name) / flagSource(path,name) own the
  precedence rule so both args and flags use it consistently.
- newDynamicResolver in cmd/pad keeps `<collection>` as a wildcard
  ArgEnumSources (universal — every <collection> in pad means a pad
  collection), but moves --role and --assign into CommandFlagBindings
  scoped to "item create", "item update", and "item list". `pad
  workspace invite --role` is intentionally left without a binding.
- A header comment in newDynamicResolver enumerates every --role /
  --assign site in the CLI and which one each binding targets, so a
  future reviewer adding a new flag can see the rule at a glance.

End-to-end on the real binary:
  pad help item create --format json
  → flags.role: type=enum, enum=[planner,implementer,reviewer], enum_source=...
  pad help workspace invite --format json
  → flags.role: type=string (untouched). ✓

New tests:

- TestResolver_Apply_PerCommandBindingScoped — explicitly mirrors the
  Codex finding: two commands both have a `role` flag, only the bound
  command resolves. workspace-invite-style isolation regression test.
- TestResolver_Apply_PerCommandWinsOverWildcard — precedence: when
  both wildcard and per-command match, per-command wins.
- TestResolver_Apply_PerCommandArgBindings — same precedence rule
  for positional args.

All 33 cmdhelp tests + 12 routing tests still green; make check clean.

* fix(cmdhelp): bind item list --role to agent roles per Codex review (round 2)

Codex round 2 caught that item list --role was still unbound — I missed
it in round 1's grep because the variable name is `&roleFilter` rather
than `&roleFlag`. Pad has 4 --role flags total:

  pad workspace invite --role     workspace role (NOT bound)
  pad item create      --role     agent role slug (bound)
  pad item update      --role     agent role slug (bound)
  pad item list        --role     agent role filter (now bound)

Fix: extend CommandFlagBindings["item list"] to include the same
itemRoleAssign map as item create/update, so all three item subcommands
that reference an agent role get the dynamic binding.

Added a `grep` recipe in the comment so a future maintainer adding a
new --role / --assign site can find every existing one in one shot
(both `&roleFlag` and `&roleFilter` style declarations).

End-to-end on real binary (inside docapp workspace):
  pad help item list --format json
  → flags.role: enum=[planner,implementer,reviewer], enum_source set ✓
  → flags.assign: enum=[dave], enum_source set ✓

make check clean.
2026-05-01 01:27:06 -04:00
xarmian 5e93abe552 feat(cmdhelp): implement --format md emitter (TASK-935) (#328)
Adds internal/cmdhelp/md.go that renders the Document built in TASK-934
as markdown with the predictable section order from cmdhelp v0.1 §6.
Replaces the markdown stub in cmd/pad/help_cmdhelp.go so `pad help
--format md` (and the `--llm` alias) produce real output.

Section order per command (spec §6):
  ## `binary path`
  summary  /  description (when distinct from summary)
  ### Synopsis           — fenced usage line, reconstructed from args + flags
  ### Arguments          — table with name | type | required | description
  ### Flags              — table with flag | type | default | description
  ### Stdin              — when Stdin.Accepted is true
  ### Examples           — fenced bash blocks, drawn from same canonical
                          example set as JSON (spec §6 same-source rule)
  ### Output             — text_template + json_schema_ref when populated
  ### Exit codes         — table when ExitCodes is populated
  ### See also           — bullet list of related command paths

Top-level YAML frontmatter:
  cmdhelp_version, binary, version, generated (RFC3339, UTC).
  Now is overridable via Options.Now for snapshot-test stability.

Top-level structure: `# binary` heading, summary, optional homepage,
optional `## Workspace context` (populated in TASK-936), `## Global flags`
table, then per-command sections sorted by path for determinism.

Synopsis reconstruction uses the structured Args from Build() (rather
than cobra.UseLine) so JSON and MD stay driven by the same parsed data
— the variadic `<ref>...` and alternation enums from TASK-934 carry
through naturally.

Pipes in flag/arg descriptions are escaped (`\|`) so they don't break
markdown table grids.

cmd/pad/help_cmdhelp.go: emitCmdhelpMarkdown stub replaced with a call
into cmdhelp.EmitMarkdown. --depth/--all threaded through MaxDepth
identically to the JSON path.

Tests:
- 15 markdown emitter tests in internal/cmdhelp/md_test.go covering
  frontmatter (presence + timestamp injectability), per-command
  section order, synopsis reconstruction (incl. variadic + alternation),
  global-flag dedup, fenced-bash examples, hidden-thing exclusion,
  deterministic ordering, Stdin/Output/ExitCodes/SeeAlso sections,
  Workspace context, table-pipe escaping.
- TestHelpCmd_FormatMarkdownStubError replaced by
  TestHelpCmd_FormatMarkdownEmits (asserts frontmatter + structural
  markers for both md and llm).
- TestHelpCmd_FormatLLMAliasRoutesToMarkdown replaced by
  TestHelpCmd_FormatLLMIsAliasForMD (asserts md and llm produce
  byte-identical output modulo the timestamp).

End-to-end on the real binary:
- pad help --format md emits valid markdown with all sections.
- pad help --format llm produces byte-identical output (after
  timestamp normalization).
- pad help item create --format md scopes correctly.
- make check clean.

Out of scope (deferred):
- Dynamic Workspace context population — TASK-936.
- --capabilities discovery flag — TASK-937.
- Schema-validation + golden-file tests in CI — TASK-938.
- Examples populated for all pad commands (still in cobra Long for now)
  — TASK-939.

Parent: PLAN-930.
2026-05-01 01:05:30 -04:00
xarmian eecc683ab0 feat(cmdhelp): implement --format json emitter (TASK-934) (#327)
* feat(cmdhelp): implement --format json emitter (TASK-934)

Adds internal/cmdhelp package that walks the cobra command tree and
emits a cmdhelp v0.1 Document conforming to schema/cmdhelp.schema.json.
Wires it into cmd/pad/help_cmdhelp.go so `pad help --format json` is
no longer a stub.

Files:
- internal/cmdhelp/types.go — Document/Command/Arg/Flag/Stdin/Stdout/
  ExitCode/Example structs mirroring the schema. ExitCode implements
  custom MarshalJSON for the string-or-object union (spec §5.2).
- internal/cmdhelp/json.go — Build() walks target's subtree; EmitJSON()
  serializes to indented JSON. Type mapping covers pflag's full type
  space, including slice/array→repeatable. Hidden commands and flags
  filtered. Cobra's auto-installed --help flag suppressed. Zero-default
  values suppressed to keep output compact. argRE parses positional
  arg placeholders from cobra Use strings, filtering [flags]/[options]/
  [command] cobra conventions. parseExamples splits cmd.Example by
  newline, drops blanks and # comments. MaxDepth maps to spec §4
  semantics: 0 = subcommand list, 1 = + grandchildren, -1 = unlimited.

- cmd/pad/help_cmdhelp.go — replaces emitCmdhelpJSON stub with a call
  into the package; threads --depth and --all through MaxDepth (--all
  overrides --depth).

Verification:
- 17 emitter tests in internal/cmdhelp/json_test.go covering envelope,
  global-flag emission, hidden-thing exclusion, positional arg parsing,
  pflag type mapping, zero-default suppression, example parsing,
  description-vs-summary, command-path key shape, MaxDepth semantics,
  target-subtree scoping, JSON validity, version pattern, ExitCode
  union marshaling, parseExamples filtering.
- 11 cmd/pad routing tests still pass; TestHelpCmd_FormatJSONStubError
  replaced by TestHelpCmd_FormatJSONEmits which validates the structure.
- End-to-end: `pad help --format json` on the real binary emits 100
  commands across the full tree; output validates against
  schema/cmdhelp.schema.json (verified with python jsonschema).
- `pad help item --format json` correctly limits output to 29 commands
  in the item subtree (homepage and other top-level metadata still
  populated from root).
- `pad help --format json --depth 0` correctly emits 15 immediate
  children of root, no grandchildren.
- make check clean (lint + go test + web build).

Out of scope (deferred):
- Examples: pad's existing commands embed examples in Long rather than
  using cobra's Example field. The emitter correctly reads Example;
  TASK-939 will normalize the pad-side commands to populate it.
- Dynamic enum injection (workspace-aware enums): TASK-936.
- --capabilities discovery flag: TASK-937.
- Schema-validation of live output in CI: TASK-938.

Parent: PLAN-930.

* fix(cmdhelp): handle alternation, variadic, and ValidArgs in Use parser per Codex review (round 1)

Codex round 1 on PR #327 flagged that parseArgs missed two real cobra
Use-string idioms in pad's command tree:

1. `completion [bash|zsh|fish|powershell]` — alternation in brackets.
   The old regex only allowed `[a-zA-Z0-9_./-]+` inside brackets, so the
   `|` made the whole token unmatched and the shell arg disappeared from
   the emitted JSON. Consumers asking "what does completion take?" got
   nothing.

2. `item bulk-update [--status X] <ref>...` — variadic ellipsis. Old
   regex didn't capture trailing `...`, so the arg was emitted but
   without `repeatable: true`. Consumers couldn't tell that <ref> may
   be passed multiple times.

Fixes:

- argRE now allows full-bracket content (`[^<>]+` / `[^\[\]]+`) and
  captures an optional trailing `...` group.
- parseArgs takes *cobra.Command (not just Use string) so it can read
  cmd.ValidArgs and attach those values as the first arg's enum when
  set. This covers `Use: "completion [shell]"` + `ValidArgs: [...]`
  where the allowed values only live on the cobra struct.
- Alternation `<a|b|c>` / `[a|b|c]` produces an enum-typed arg with
  the values as `Enum`. When Use carries no semantic name (only the
  alternation), the arg name is synthesized as "value".
- New validArgName check rejects embedded flag-like fragments such as
  `[--status X]` and prose with whitespace/punctuation that the broader
  regex would otherwise capture from idiosyncratic Use strings.
- ValidArgs entries strip cobra's tab-separated completion descriptions
  before becoming enum values.

New tests:

- TestBuild_VariadicArgsRepeatable — `<ref>...` → repeatable=true.
- TestBuild_AlternationProducesEnum — `[bash|zsh|fish|powershell]` →
  enum with values in source order.
- TestBuild_ValidArgsFillsEnumOnNamedArg — Use says `[shell]`, ValidArgs
  carries the values → enum-typed arg named `shell`.
- TestBuild_EmbeddedFlagFragmentsFiltered — `[--status X]` does not
  leak as a positional arg.

Verified on the real binary:
- `pad help completion --format json` now emits the shell enum.
- `pad help item bulk-update --format json` now marks <ref> repeatable.
- `pad help --format json` still validates against the schema (100 cmds).
- `make check` clean.
2026-05-01 00:56:50 -04:00