diff --git a/CLAUDE.md b/CLAUDE.md index bb51496..c09d84f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,18 +10,18 @@ Hadron is the platform's institutional memory — assume it covers things not ob code alone (past incidents, decisions, conventions baked into several places). Relevant memories: -- `hrn:memory:hadronmemory.com::hadron-cli` — this CLI's own findings/conventions -- `hrn:memory:hadronmemory.com::dev` — shared findings, conventions, ops, the `preflight` routing index -- `hrn:memory:hadronmemory.com::hadron-server` — the GraphQL contract this CLI targets; server findings -- `hrn:memory:hadronmemory.com::specs` — product specs (loc-as-citation); the `spec` command group is the citation-aware surface over this corpus +- `hrn:mem:hadronmemory.com:hadron-cli` — this CLI's own findings/conventions +- `hrn:mem:hadronmemory.com:dev` — shared findings, conventions, ops, the `preflight` routing index +- `hrn:mem:hadronmemory.com:hadron-server` — the GraphQL contract this CLI targets; server findings +- `hrn:mem:hadronmemory.com:specs` — product specs (loc-as-citation); the `spec` command group is the citation-aware surface over this corpus (1) **Query Hadron before reading code.** For the topics/entities in a request, run `hadron_find_nodes` first, then `hadron_get_node` on promising hits; cite node `loc` values. (Note the CLI *is* a superset of the MCP tools — but for memory reads while developing it, the `hadron_*` MCP tools are simplest; don't rely on the dev binary you may be mid-change on.) -(2) Read `hadron_get_node hrn:node:hadronmemory.com::dev::instructions` once per session (what -Hadron is, URN grammar, the specs corpus), and `hadron_get_node hrn:node:hadronmemory.com::dev::preflight` +(2) Read `hadron_get_node hrn:node:hadronmemory.com:dev:instructions` once per session (what +Hadron is, URN grammar, the specs corpus), and `hadron_get_node hrn:node:hadronmemory.com:dev:preflight` before a change (the shared server/platform routing index). (3) Capture a non-obvious finding the moment it emerges (`hadron_create_node` / `hadron_update_node`) — @@ -67,7 +67,10 @@ Every command supports `--json`, and those shapes are a stable public contract t - Each group is `internal/cmd//` with a `NewCmd(*cmdutil.Factory)` constructor, wired in `internal/cmd/root.go`. - `cmdutil.Factory` is the DI seam: lazily resolves config, the token store, and the GraphQL client (`f.GraphQLClient()`), and carries the persistent `--json/--server/--app` flags plus `f.IOStreams`. Commands take the Factory; tests inject a fake one. - Destructive commands (`memory rm`, `node rm`, `edge rm`, `app uninstall`) prompt on a TTY and require `--yes` non-interactively (`cmdutil.ConfirmDeletion`). -- Node references are fully-qualified URNs `::::` (double-colon between segments — a loc contains single colons, so single-colon `org:memory:loc` is ambiguous and rejected); a bare loc is rejected *unless* `-m/--memory ` is given (single-colon `:` also accepted — `cmdutil.canonicalOrgMemory` normalizes it), which `node get|update|rm|export` and `edge add|ls` accept to name a bare `` (resolved via `cmdutil.ResolveNodeRef`, which joins memory+loc then defers to `ResolveNodeURN`). Memory refs likewise accept id / `hrn:memory:::` / `::` / `:` via `cmdutil.CanonicalMemoryRef`. `spec` commands likewise take `-m/--memory` + a bare citation (the loc *is* a legal-code citation — see `docs/how-to/maintain-product-specs.md`). +- **URN grammar — emit v2, accept everything.** The CLI *emits* the flat grammar-v2 form the server now hands out: `hrn:mem::` (`cmdutil.CanonicalMemoryRef`) and `hrn:node:::` (`cmdutil.NodeURN`). *Input* stays Postel-liberal — every v1 spelling (`hrn:memory:::`, `::::`) is accepted forever (#239), so don't "fix" a legacy ref in a test fixture; those are the acceptance coverage. See [docs/plans/urn-compose-v2.md](docs/plans/urn-compose-v2.md). + - A *scheme-less* node ref must use `::::`: a loc contains single colons, so `org:memory:loc` is ambiguous and rejected. The `hrn:node:` prefix removes the ambiguity, which is why the v2 flat form can use single colons throughout. + - A bare loc is rejected *unless* `-m/--memory ` is given (single-colon `:` also accepted — `cmdutil.canonicalOrgMemory` normalizes it), which `node get|update|rm|export` and `edge add|ls` accept to name a bare `` (resolved via `cmdutil.ResolveNodeRef`, which joins memory+loc then defers to `ResolveNodeURN`). Memory refs accept id / v2 / `::` / `:` / legacy `hrn:memory:` alike. `spec` commands likewise take `-m/--memory` + a bare citation (the loc *is* a legal-code citation — see `docs/how-to/maintain-product-specs.md`). + - **Exception:** the `spec` group composes v1 internally on purpose (`memoryRefV1`, `internal/cmd/spec/spec.go`) — a fixed-arity flat v2 node URN can't round-trip a COMPOUND app-mem memory (`:::app-mem:`). Don't migrate that one without solving the compound case. ## Whole-corpus reads — paginate, don't truncate @@ -82,6 +85,6 @@ Command-level tests live in `internal/cmd/*_test.go` against a fake GraphQL serv ## Conventions for changes - **Substantial features get a design-as-built plan doc in `docs/plans/`**, bundled in the PR for review (see the existing ones). -- **Before opening a PR, run the Hadron review flow** — the CLI-specific checklist pass, complementing the generic `/code-review` skill. Run the task node `hrn:node:hadronmemory.com::hadron-cli::tasks:review-changes` (read the `review` parent, then walk the applicable `Applies when …` children against your diff). When a defect or near-miss reveals a reusable pattern, capture it with `tasks:add-review-node`. +- **Before opening a PR, run the Hadron review flow** — the CLI-specific checklist pass, complementing the generic `/code-review` skill. Run the task node `hrn:node:hadronmemory.com:hadron-cli:tasks:review-changes` (read the `review` parent, then walk the applicable `Applies when …` children against your diff). When a defect or near-miss reveals a reusable pattern, capture it with `tasks:add-review-node`. - One change per PR, squash-merged; CI (build/test/lint + `goreleaser check`) must be green. - **Releasing is tag-driven** (push `vX.Y.Z` → goreleaser publishes archives + auto-bumps the Homebrew cask). See the README "Releasing" section. diff --git a/internal/cmd/access/access.go b/internal/cmd/access/access.go index a46c985..c8936db 100644 --- a/internal/cmd/access/access.go +++ b/internal/cmd/access/access.go @@ -24,6 +24,21 @@ func NewCmdAccess(f *cmdutil.Factory) *cobra.Command { return cmd } +// memoryURNExample prefixes an under-qualified shorthand with the memory-kind +// scheme that matches the separator the caller used: the canonical hrn:mem: +// for the single-colon short form, the legacy hrn:memory: for the "::" one. +// +// The server's decomposer is liberal enough to resolve either spelling against +// either separator — hrn:memory:acme.com:kb, hrn:mem:acme.com:kb and +// hrn:memory:acme.com::kb all resolve to the same memory — so this is about +// handing back a canonically-SHAPED example rather than about validity. +func memoryURNExample(ref string) string { + if strings.Contains(ref, "::") { + return "hrn:memory:" + ref + } + return "hrn:mem:" + ref +} + // normalizeResourceRef shapes a resource reference into what // effectiveAccess(resource:) expects: a fully-qualified hrn:: URN for a // memory/node/app/agent, or a bare id for an AiServiceConfig (the one URN-less @@ -42,8 +57,9 @@ func normalizeResourceRef(ref string) (string, error) { if strings.Contains(r, ":") { return "", exitcode.Newf(exitcode.Usage, "%q is not a fully-qualified resource URN — prefix it with its kind "+ - "(hrn:memory:, hrn:node:, hrn:app:, or hrn:agent:), e.g. hrn:memory:%[1]s; "+ - "a bare, colon-free id is read as an AiServiceConfig id", r) + "(hrn:mem:, hrn:node:, hrn:app:, or hrn:agent:; the legacy hrn:memory: "+ + "is also accepted), e.g. %s; "+ + "a bare, colon-free id is read as an AiServiceConfig id", r, memoryURNExample(r)) } return r, nil } diff --git a/internal/cmd/access/access_test.go b/internal/cmd/access/access_test.go new file mode 100644 index 0000000..4bb749a --- /dev/null +++ b/internal/cmd/access/access_test.go @@ -0,0 +1,67 @@ +package access + +import ( + "strings" + "testing" + + "github.com/hadron-memory/hadron-cli/internal/exitcode" +) + +// The suggested example must compose with the separator the caller used, so a +// single-colon shorthand isn't handed back a "::"-shaped v1 prefix. +func TestMemoryURNExampleMatchesSeparator(t *testing.T) { + cases := []struct{ in, want string }{ + {"acme.com:kb", "hrn:mem:acme.com:kb"}, // v2 short form → canonical prefix + {"acme.com::kb", "hrn:memory:acme.com::kb"}, // v1 short form → legacy prefix + {"acme.com::kb::start-here", "hrn:memory:acme.com::kb::start-here"}, + } + for _, tc := range cases { + if got := memoryURNExample(tc.in); got != tc.want { + t.Errorf("memoryURNExample(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestNormalizeResourceRef(t *testing.T) { + // Any scheme-prefixed ref passes through verbatim — the server dispatches + // on its type, and it accepts v2 and legacy spellings alike. + for _, ref := range []string{ + "hrn:mem:acme.com:kb", + "hrn:memory:acme.com::kb", + "hrn:node:acme.com:kb:start-here", + "hrn:node:acme.com::kb::start-here", + "hrn:app:acme.com::support", + "urn:memory:acme.com::kb", + } { + got, err := normalizeResourceRef(ref) + if err != nil { + t.Errorf("%q should pass through, got error %v", ref, err) + } + if got != ref { + t.Errorf("%q was rewritten to %q", ref, got) + } + } + + // A colon-free id is an AiServiceConfig id. + if got, err := normalizeResourceRef("cfg_123"); err != nil || got != "cfg_123" { + t.Errorf("bare id: got %q, %v", got, err) + } + + // An under-qualified shorthand is rejected with guidance that composes. + for _, ref := range []string{"acme.com:kb", "acme.com::kb"} { + _, err := normalizeResourceRef(ref) + if err == nil { + t.Fatalf("%q should be rejected", ref) + } + if got := exitcode.FromError(err); got != exitcode.Usage { + t.Errorf("%q should be a usage error, got %d", ref, got) + } + if !strings.Contains(err.Error(), memoryURNExample(ref)) { + t.Errorf("%q: the error should suggest %q, got %q", ref, memoryURNExample(ref), err.Error()) + } + } + + if _, err := normalizeResourceRef(" "); err == nil { + t.Error("an empty ref should be rejected") + } +} diff --git a/internal/cmd/access/check.go b/internal/cmd/access/check.go index 6a2605a..83d4ca7 100644 --- a/internal/cmd/access/check.go +++ b/internal/cmd/access/check.go @@ -55,14 +55,15 @@ func newCmdCheck(f *cmdutil.Factory) *cobra.Command { that confer it. The user is identified by id, email, or handle. The resource is a -fully-qualified URN — hrn:memory:…, hrn:node:…, hrn:app:…, or -hrn:agent:… — or a bare AiServiceConfig id. +fully-qualified URN — hrn:mem:…, hrn:node:…, hrn:app:…, or hrn:agent:… +— or a bare AiServiceConfig id. The legacy hrn:memory::: +spelling is still accepted. Reading this requires permission to audit the resource: a platform admin, an ADMIN/OWNER of the resource's owning org, or (for a strict- owner memory) the memory's principal.`, - Example: ` hadron access check alice@acme.com hrn:memory:acme.com::kb - hadron access check @alice hrn:node:acme.com::kb::start-here + Example: ` hadron access check alice@acme.com hrn:mem:acme.com:kb + hadron access check @alice hrn:node:acme.com:kb:start-here hadron access check usr_123 hrn:app:acme.com::support --json`, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/internal/cmd/agentic/agentic-usage.md b/internal/cmd/agentic/agentic-usage.md index 598e679..86f2d88 100644 --- a/internal/cmd/agentic/agentic-usage.md +++ b/internal/cmd/agentic/agentic-usage.md @@ -117,15 +117,24 @@ Conventions: `--handle` defers to the server. - `agent update --urn` also accepts the spec-047 user-author form `@handle:slug`. -- Memory references accept the memory id, the full `hrn:memory:::` - URN, or the short `::` / `:` forms (all resolve to the - same memory) across `memory get|set|attach|rm|member|share|export`. -- Node references are fully-qualified URNs +- **URN grammar: the CLI emits flat grammar-v2, and accepts every spelling.** + What it prints back — and what the server hands out — is + `hrn:mem::` for a memory and `hrn:node:::` for a + node (e.g. `hrn:node:hadronmemory.com:dev:start-here`). Prefer those when + composing a reference; every legacy form below stays accepted indefinitely, so + a URN you captured earlier keeps working. +- Memory references accept the memory id, the canonical `hrn:mem::` + URN, the legacy `hrn:memory:::`, or the short `::` / + `:` forms (all resolve to the same memory) across + `memory get|set|attach|rm|member|share|export`. +- Node references are fully-qualified URNs. Either the canonical flat v2 form + `hrn:node:::`, or the scheme-less `::::` (double-colon between segments — e.g. `hadronmemory.com::dev::start-here`), optionally `hrn:node:`-prefixed (legacy - `urn:node:` also accepted). Single-colon `::` is **not** a - valid full URN — a loc itself contains single colons - (`services:secureid:user-reporting`), so it's ambiguous. A bare loc is rejected + `urn:node:` also accepted). Scheme-less single-colon `::` is + **not** a valid full URN — a loc itself contains single colons + (`services:secureid:user-reporting`), so it's ambiguous; the `hrn:node:` prefix + is what removes that ambiguity for the flat v2 form. A bare loc is rejected (exit 2) *unless* you pass `-m/--memory ` (single-colon `:` also accepted) to name the memory — then `node get|update|move|clone|rm|export` and `edge add|list` take a bare `` @@ -579,7 +588,8 @@ Conventions: this resource?" — the authoritative, server-computed effective access plus the grants that confer it (no client-side re-derivation). `` is an id, email, or handle (resolved via `searchUsers`); `` is a fully-qualified URN - — `hrn:memory:…`, `hrn:node:…`, `hrn:app:…`, `hrn:agent:…` — or a bare + — `hrn:mem:…`, `hrn:node:…`, `hrn:app:…`, `hrn:agent:…` (the legacy + `hrn:memory:::` is also accepted) — or a bare AiServiceConfig id. Output carries `canRead/canWrite/canManage/canDelete`, a `role` label, and a `grants[]` array (each `{source, role, via}`); an empty `grants[]` is the first-class "no access" answer. Reading it requires audit diff --git a/internal/cmd/memory/attach.go b/internal/cmd/memory/attach.go index 9e717d1..3db7bd5 100644 --- a/internal/cmd/memory/attach.go +++ b/internal/cmd/memory/attach.go @@ -24,7 +24,7 @@ The memory must belong to you, the Agent must be installed in the App, and you must be an App member. The memory keeps its URN, class, and owner. Memory, App, and Agent references accept IDs, bare URNs, or prefixed URNs.`, Example: ` hadron memory attach acme.com::my-notes --app acme.com::coach --agent acme.com::agent - hadron memory attach hrn:memory:acme.com::private-notes --app app-id --agent agent-id --json`, + hadron memory attach hrn:mem:acme.com:private-notes --app app-id --agent agent-id --json`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if app == "" || agent == "" { diff --git a/internal/cmd/memory/memory.go b/internal/cmd/memory/memory.go index 425d726..b42b0aa 100644 --- a/internal/cmd/memory/memory.go +++ b/internal/cmd/memory/memory.go @@ -158,5 +158,5 @@ func resolveMemoryID(cmd *cobra.Command, client graphql.Client, ref string) (str // rejected short form isn't mistaken for a genuinely-absent memory (#108). func notFoundMemory(ref string) error { return exitcode.Newf(exitcode.NotFound, - "no memory found for %q — expected a memory id or an :: URN (single-colon : and an hrn:memory: prefix are also accepted)", ref) + "no memory found for %q — expected a memory id or a URN: hrn:mem:: (canonical), the :: / : short forms, or the legacy hrn:memory: prefix", ref) }