Skip to content

Op-substrate surface: path[] command paths, umbrella nouns, defineService, render hook

Status: open/accepted

  • A command is a 2–3 segment kebab path (path: string[], hard cap 3), replacing the flat {noun, verb} descriptor. Depth-3 only where a group has ≥2 members; singleton groups are banned.
  • The surface lands at 11 visible nouns (task backlog milestone standard entities commit report docs quality project dashboard) and 4 hidden (plugin pr gate lease); nouns added by later decisions (config, orchestrate, reference, term) follow the same rules. Entity CRUD is untouched; the 18 remaining script capabilities get final homes (notably task inflight, project scan {todos,candidates}, quality baseline {capture,diff,prune}).
  • defineService is a registry sibling to defineOp for long-running capabilities (dashboard, lease heartbeat-loop): same path-keyed index and help surface, lifecycle dispatch (start/stop?/list?) instead of request/response. Resolves D-0007’s open question.
  • One output contract (§4): every op’s canonical result is its zod output object; a single --output text|json|jsonl parameter shapes the projection over two op classes (sync, streaming). Defaults are op-declared: a descriptor-supplied cli.render(output, io) hook is the text projection and makes text the default (parity for line-oriented consumers like task next and quality run --log); without one, json — today’s adapter behavior.
  • Adapter projection is a pure function of the path: CLI segments verbatim; MCP tool name = underscore-join (forward-only, uniqueness enforced by project-check, not reversibility); HTTP route = /<seg>/<seg>[/<seg>] with method by verb class.
  • This amends D-0007-deterministic-op-substrate (§2 descriptor shape) and resolves two of its open questions (long-running ops; skill-local triage). Migration sequencing lives in the op-substrate full-sweep milestone.

D-0007 fixed the architecture — entity-oriented lib, op registry, generated adapters — and T-0010/T-0014 landed the registry and the generated CLI. The remaining sweep (promote every capability, codemod callers, delete shims) needed a final command surface. A first flat proposal added 13 new top-level nouns; the human asked for a more elegant shape with nesting where it pays.

The surface below came out of an adversarial redesign: four competing designs (umbrella grouping, strict-flat, domain-ontology, caller- ergonomics), three hostile critics per design, a three-judge panel (registry-maintainer, skill-author, MCP-consumer personas — unanimous for umbrella), synthesis with grafts, then a refutation round and a plan re-audit. Five contested calls were arbitrated by the human on 2026-06-05. The working plan with full tables is docs/plans/op-substrate-full-sweep.md (Rev 2).

OpDescriptor.path: string[] — 2 or 3 kebab segments, each KEBAB-validated, 3 a hard cap. Registry key = path.join(' ') (byte-identical for existing 2-level ops). A {noun, verb} export without path is normalized to path: [noun, verb], so existing descriptors compile unedited. noun = path[0], verb = path.at(-1), groups = path.slice(1, -1) become derived accessors.

Nesting rule: a group exists only where the domain genuinely nests AND the group has ≥2 members. Today exactly three qualify: project scan {todos, candidates}, quality baseline {capture, diff, prune}, and the lease two-axis (lease task {claim, acquire, reacquire, fence, transition, archive}, lease op claim).

NounVerbs (* hidden)Notes
taskcreate, update, next, inflight, preview-id, probe-state, resolve, check-claims*, close-commit*, gap-report*, lint-state-origin*, parse-touchpoints*, resolve-touchpoints*, scan-placeholders*inflight re-homed from count_inflight_tasks; next owns pickup order (D-Q2WR-task-pickup-order); the task-state lefthook gate sits on its entity
backlogcreate, capture, preview-id, updateentity CRUD; the last LEGACY_NOUNS shim
milestonecreateentity CRUD
standardcreate, update, supersede, preview-identity CRUD
entitiesvalidate, audit*, migrate*, check-identifiers*all four register via the explicit @lib/model/ops barrel import at CLI bootstrap; validate absorbs the validate_frontmatter contract
commit / reportcreate, get-schema / render, get-schemaunchanged sibling parity
docsgenerate, build-subsitesnoun renamed from index by D-B4CA-term-entity-and-generated-glossary; lib→skill inversion fixed
qualityrun, detect, baseline {capture, diff, prune}baseline visible; each mode its own contract
projectsetup, doctor, cleanup, scan {todos, candidates}, detect-worktree-init, preflight-worktree, teardown-worktreethe consuming-project hub
dashboardstart, stop, listtop-level: list is machine-global, nesting under project would lie
plugin*info, resolve, install-permissionshidden meta-noun; long names free
pr*classifycursor ledger travels with the op
gate*skill-prose, worktree-scope, markdown-fixtureslefthook invokes the CLI directly
lease*task {claim, acquire, reacquire, fence, transition, archive}, op claim, heartbeat, release, inspect, list, parse-footer, reconcile, migrate, heartbeat-looptwo-axis depth-3 preserved; heartbeat (single-shot op) distinct from heartbeat-loop (service)

Nouns added by later decisions (config, orchestrate, reference, term) follow the same rules; sdlc --help is the authoritative roster.

Naming principles, in priority order:

  1. Nest only where the domain nests, and only with ≥2 members.
  2. Names truthful about direction and action.
  3. An op routes to the noun its data is about.
  4. Keep learned symmetries intact — when sibling nouns share a verb a user has already memorized (commit get-schema / report get-schema), rename both or neither; entity CRUD stays untouched.
  5. Human-vs-machine asymmetry — brevity is a budget spent on human-typed verbs; machine-only (hidden) ops prefer fully explicit names (plugin install-permissions) because no human types them. A permission to be verbose for clarity, not a length mandate.
  6. Preserve real domain distinctions over flattening.

defineService({ path, summary, hidden?, lifecycle: { start, stop?, list? } })kind: 'service' brand, registered into the same path-keyed index, validated by a parallel validator (services carry no input/output/handler), dispatched by a lifecycle dispatcher that bypasses invokeOp’s output parsing (a non-returning loop would hang it). Service modules live at services/<svc>/<name>-service.ts, found by a second walk traversal. Lifecycle failures map to a SERVICE_ERROR exit tier (already-running, stale pidfile, port-in-use, SIGTERM-to-dead-pid).

Canonical shape. Every op’s result is its zod output object — the single machine shape all doors share. Everything below is a projection of that object; no op has a stdout-only truth.

Two op classes × two projections:

textjson / jsonl
sync (request/response)the op’s cli.render(output, io) writes deterministic lines/tables; may end in a terminal marker line (PR: <url>, CLAIMED task=…)the validated output object, sorted keys, one document
streaming (emits events while running)progress lines as events occur, then a deterministic terminal lineJSONL — one event object per line, then the terminal result object

One parameter shapes it: --output text|json|jsonl (--json kept as an alias for --output json). Rules:

  • Defaults are op-declared, never TTY-sniffed: an op with a render hook defaults to text (parity — sdlc task next emits its lines with no flags); an op without one defaults to json (today’s adapter behavior). Deterministic for skills and CI.
  • Streaming is an op property, not a flag — declared on the descriptor (quality run --log, lease heartbeat-loop, dashboard logs). A streaming op under --output json buffers and emits only the terminal object; a sync op under --output jsonl emits its single object as one line. The 2×2 is total: every combination is defined.
  • stderr carries diagnostics and legacy failure routing where a ported contract requires it (quality run FAIL lines); exit codes come from the OpError/SERVICE_ERROR tables, with the render hook able to shape legacy exit semantics.
  • All three modes are live in the CLI adapter (quality run is the first streaming op); the MCP/HTTP adapters, when built, consume the same contract.

Behavior-lock goldens — the migration’s parity instrument: before a script moves, capture its exact stdout/stderr/exit for fixed inputs as committed fixtures (the “golden”); after the move, a test spawns the CLI (bun run cli/sdlc.ts <path…>) and asserts byte-identical output. Spawning the CLI rather than importing the module means the golden survives the relocation unchanged — it locks behavior across the move.

One path, three doors, all pure functions of the path:

  • CLI: segments verbatim; interior segments are description-only group subcommands.
  • MCP tool name: underscore-join with intra-segment hyphens preserved (project_scan_todos, gate_worktree-scope). A deterministic forward map — not reversible from the string; uniqueness is enforced by a project-check forbidding any two paths whose join collides.
  • HTTP route: /<seg>/<seg>[/<seg>]; method by verb class (create/update/start/claim/… → POST; get-schema/audit/list/scan-*/… → GET; unenumerated defaults to POST).
  • hidden suppresses help/manifest listing only; every hidden op resolves when addressed directly (lefthook and external agents reach them).
QuestionResolution
Gate family splitEntity affinity — task lint-state-origin on task; only the two non-entity gates under gate
quality baseline sub-modesDepth-3 leaves, each with its own zod contract — no --action discriminated union
Top-level countdashboard stays top-level (11 visible) — list is machine-global
entities wiringExplicit barrel import at composeNouns; T-U72C keeps the walk-root question
validate_frontmatterAbsorb the full contract into entities validate (3-tier resolution, exit-1-on-fail, text render); delete the shim; no retained adapters
  • Predictability with a scannable front door. Eleven visible nouns, each a clean domain, beats both the 13-new-noun flat sprawl and a deep tree. The judge panel’s guessing-game tests drove the re-homings (task inflight fixed the consensus worst guess).
  • The machinery cost is honest and one-time. The path[] change, hidden rendering, second walk, and render hook are a real generator rewrite — priced into one blocking task (T-NV49-op-path-substrate) with byte-identical help snapshots as its rollback gate, rather than smeared across ports.
  • Renames are nearly free right now. The codemod task rewrites every caller anyway, so fixing existing names (count_inflight_taskstask inflight) costs one more mapping row, not a migration of its own.
  • Amends D-0007-deterministic-op-substrate: the §2 descriptor’s {noun, verb} becomes path: string[]; §2’s “registry owns the exit table” gains the SERVICE_ERROR tier; the §5 adapter list gains the projection contract above. D-0007’s open questions on long-running ops and skill-local triage are resolved here. The code shipped via T-NV49-op-path-substrate.
  • Binds T-902K: task is established as the lifecycle-verb home — the future task transition sits flat beside create/update/next/ inflight, no task lifecycle subgroup.
  • Binds the MCP/HTTP adapters (when built) to the projection rules in §5 — tool names and routes are derived, never hand-assigned.
  • End state (reached): plugin/scripts/ and plugin/validators/ are deleted; lefthook invokes the CLI directly; LEGACY_NOUNS holds only backlog.
  • Migration sequencing, task breakdown, and risk controls live in the op-substrate full-sweep milestone (M-0003, closed) and docs/plans/op-substrate-full-sweep.md — this decision fixes the surface and machinery contract, not the order of work.

← Back to Decisions