Skip to content

Named verbs cascade env → sdlc.local.yaml → sdlc.yaml → default; project-sourced verbs run only in a trusted repo

State: open/proposed

  • verbs: map replaces three list keys. worktree_init, commit_worktree_init, and quality_checks become named entries in a Record<string, string[]> schema. No aliases or deprecation period; old keys fail validation.
  • Cascade, highest per name wins. Verbs resolve in order: env vars (SDLC_VERB_<NAME>), sdlc.local.yaml (uncommitted, beside sdlc.yaml), committed sdlc.yaml, implicit default. Each layer replaces the whole list for that verb name.
  • Baseline names: setup, setup-hooks, check. These are the names sdlc’s own ops execute: setup readies a checkout (task-work Step 4, pr review), setup-hooks (absent → setup fallback) arms ephemeral commit worktrees, check gates pre-PR verification (task-work Step 7, quality run --config <sdlc.yaml>; without --config the op runs only the verbs given on its command line). Every other name, S-0013-baseline-task-vocabulary names included, is stored and resolved but nothing in sdlc invokes it unless a caller asks for it by name.
  • Trust gates project-sourced verbs. Verbs read from the project (committed sdlc.yaml, sdlc.local.yaml, or implicit default) require explicit sdlc repo trust or per-run --trust. Env-sourced verbs run ungated. The gate is a check inside the op that is about to execute the list, run immediately before execution; it is not a git hook and not a judgement an agent makes. An agent only sees the CLI’s UNTRUSTED exit and its hint.
  • sdlc pr review runs setup on every launch. Before the review agent starts, the setup verb runs on created and reused worktrees alike. New pr_review.setup config key (default true) and --no-setup flag for overrides. Failure aborts before the host launches.
  • The machine file may pre-trust repos by path or owner. trust.paths is a glob over the resolved repo root; trust.owners matches the origin remote’s owner case-insensitively. Precedence: a marker grant (sdlc repo trust) first, then trust.paths, then trust.owners. A policy grant is not a marker: sdlc repo untrust cannot remove it, only editing the machine file can.
  • Hard cutover, zero backwards compatibility. Every consumer repo in this family migrates in a single PR. Stale keys fail the ordinary unrecognized-key check; no bespoke diagnostics mask the transition.

SDLC has three config surfaces that grew separately: the committed project sdlc.yaml, the uncommitted machine file at <config-home>/sdlc/config.yaml, and the repo-wide S-0013-baseline-task-vocabulary carried by moon, just, or package.json. Lifecycle verbs exist only as anonymous shell lists under three ad-hoc keys: quality_checks, worktree_init, commit_worktree_init. There is no baseline vocabulary SDLC itself understands, no per-checkout override facility besides the global machine file, and no persisted trust before running a repo’s scripts.

Today, sdlc pr review runs worktree_init only when it creates the worktree and never on reuse. Worktree bootstrap happens at two sites (arm-worktree.ts, pr/review/worktree.ts) via the same ArmExec seam. Trust is fork-only and unpersisted. The machine file merges per-key but carries no verbs. S-0013 has no setup name; it is ten runner names, and install is a moon dependency, not a verb.

This state makes it hard to keep pr review setup idempotent and to ask a user to trust a repo once rather than repeat it per run or per tool.

Replace the three ad-hoc keys with a named verbs: Record<string, string[]> schema in SdlcConfigSchema. Implement a cascade resolver that picks a verb list by name from the highest layer present (env, sdlc.local.yaml, project sdlc.yaml, or implicit default). Gate project-sourced verbs on persistent trust stored in $XDG_STATE_HOME/sdlc/trusted/. Make pr review run setup on every launch idempotently.

Schema. verbs is an optional map; empty by default. Baseline names:

  • setup: readies a checkout for development. Run by task-work Step 4, pr review before launch, any future “open this repo” flow.
  • setup-hooks: arms an ephemeral detached worktree for committing to main (used by commitToMainViaWorktree, backlog-capture). Absent → falls back to setup; explicit [] → nothing.
  • check: pre-PR gates. Run by task-work Step 7 and sdlc quality run. S-0013-baseline-task-vocabulary names (fmt, fmt-check, lint, lint-fix, typecheck, test, build, clean, dev) pass through. Unknown names allowed and validated for shape only. pr_update.verify enum value quality_checks becomes check.

Cascade. Resolver resolveVerb(name, repoRoot, { env? }) → { verbs: string[], source: 'env'|'local'|'project'|'default'|'none' } picks the list by name and reports its source:

LayerWhereSource tagTrust-gated
EnvironmentSDLC_VERB_<NAME> (uppercase, - → _), newline-separatedenvNo
Local<repoRoot>/sdlc.local.yaml, partial schema, deep-mergedlocalYes
Project<repoRoot>/sdlc.yaml via loadConfigprojectYes
DefaultHeuristic: `setupinstallbootstrap` from just/moon/mise, else lockfile-driven install
NoneNo verbs found for this namenone—

Highest layer wins; each layer replaces the whole list for that name. setup-hooks → setup fallback is built into the resolver.

sdlc.local.yaml. New uncommitted, gitignored file beside sdlc.yaml. Parsed as a partial SdlcConfigSchema and deep-merged into the project config inside loadConfig (lists replace, maps merge), generically for every key. sdlc project setup adds it to .gitignore; sdlc config scaffold --local renders a commented template. The merged config cannot say which file a value came from, so the resolver also reads the raw local file: source: 'local' means the name was written in sdlc.local.yaml, source: 'project' means it came from sdlc.yaml. The tag matters because the trust gate and the op outputs (pr review, project doctor) report it.

Cross-repo validation. When repoRoot differs from the preflighted cwd project (e.g., sdlc pr review <url> targeting another repo), the resolver calls validateFile and throws OpError('SCHEMA_ERROR', …) so the silent all-defaults fallback never masks a stale key in a target repo.

Trust. New reusable facility packages/ts/cli-tool/trust/ ported from detect-runners/src/trust.ts (marker files <basename>-<sha16> under $XDG_STATE_HOME/<tool>/trusted/, resolved-root key, temp-file+rename write), parameterized by tool. SDLC instantiates with SDLC_TOOL. Gate rule: verbs whose source is project, local, or default (read from inside the repo) require the root to be trusted or a per-run --trust. Env-sourced verbs run ungated. The default layer consults trust twice: the detection probe may execute moon/mise task listings only in a trusted repo (untrusted, it parses justfiles and lockfiles and nothing else), so trust changes what the layer resolves to; the execution gate then decides whether the result may run. New ops sdlc repo trust [path] [--list|--show] and sdlc repo untrust [path]. New exit code UNTRUSTED in lib/registry.ts: exit codes are sdlc’s CLI contract, and OP_ERROR_EXIT_CODES there is the one roster every OpError maps through (schema errors, the lease band, and so on). The cli-tool trust facility is a library with no CLI; it reports a reason (untrusted), and each tool that mounts it maps that reason onto its own exit-code space. One hint function mirrors execWithheldHint.

Policy: trust by path or owner. The machine config’s trust: key gains paths (globs over the trust store’s resolved, symlink-free repo root) and owners (matched case-insensitively against the origin remote’s owner via @sksizer/easy-git’s parseGitUrl, built on hosted-git-info). Neither lives in sdlc.yaml; a repository must not be able to grant itself trust. The policy engine itself now lives in @sksizer/sdlc-tool, the repo-aware substrate downstream of cli-tool/easy-git/easy-gh that sdlc mounts, behind a thin sdlc adapter. trustDecision(root) checks the marker store first, then trust.paths, then trust.owners, and reports which layer decided (by) and, for a policy hit, the matching rule; isRepoTrusted reads through it unchanged, so every existing gate picks the policy up for free. A policy grant is not a marker: sdlc repo untrust cannot remove it, only editing the machine file can — it instead reports that the root stays trusted by policy. Threat model: the origin URL lives in .git/config, written by the operator at clone time, not by anything in the repository’s tracked content — a stranger’s repo cannot grant itself owner trust. The two real exposures are an unpacked archive or copied directory that ships its own .git/config (so trust.owners is only sound for repos the operator actually cloned), and that trust is repository-level, not branch-level — every linked worktree shares the common git dir and its origin, so a PR worktree checked out from a stranger’s branch is still owner-trusted; pr_review.untrusted remains the per-PR guard for fork PRs.

pr review setup. After realizeWorktree (created) or immediately (existing), resolve setup for the TARGET repoRoot and run it in the worktree plan path on every launch. New pr_review.setup: boolean (default true) and --no-setup flag. Progress [i/n] verb lines to stderr via ArmExec. Failure aborts with OpError('SERVICE_ERROR') before the host launches. Output includes setup: { ran, verbs, source }. The create-time initVerbs path in realizeWorktree is removed. The fork rule pr_review.untrusted keeps its semantics on top.

Mandate: clean this up as much as possible and maintain nothing for the old shape. Concretely:

  • worktree_init, commit_worktree_init, quality_checks are deleted from the schema. A file carrying one fails .strict() with Zod’s ordinary unrecognized-key line. No bespoke “move it to verbs.setup” wording — that is code kept forever for a shape that no longer exists.
  • loadVerbList, SCHEMA_OWNED_KEYS, and sdlc quality run --key are deleted, not kept as a raw-override path. quality run takes --name <verb> (default check) and positional verbs only.
  • The four ops get/set-quality-checks and get/set-worktree-init are deleted and replaced by get-verbs/set-verbs --name. The two skills find-quality-checks and find-worktree-init are deleted and replaced by one find-verbs --name <verb> skill.
  • pr_update.verify: quality_checks becomes check; the old value is a validation error.
  • realizeWorktree’s initVerbs option is removed, not left as an unused parameter.
  • detect-worktree-init op and module are renamed to detect-setup.
  • No SDLC_* env var, flag, or config value accepts the old names.
  • Historical planning entities (tasks, backlog, decisions, milestones) that mention the old keys are records and stay as written; live docs, skills, fixtures, goldens, and both sdlc.yaml files in this repo change.

One resolver over anonymous lists. Today, each op that runs verbs has its own load logic or copy of a verb list. A single resolver in lib/config/verbs.ts, sdlc’s own config tier (Zod schema and loader, in process, not the shared @sksizer/cli-tool package), becomes the canonical source. Every sdlc op calls it in process; skills and other agents reach it through the CLI (sdlc quality run --name <verb>). The resolver itself leans on two libraries: @sksizer/detect-runners for the implicit setup probe and @sksizer/cli-tool/trust for the trust store.

Per-checkout override belongs beside the file it overrides. The machine file applies to every repo on a machine; no verb honestly applies to that scope. sdlc.local.yaml is the per-checkout override surface and sits beside sdlc.yaml in the same directory, making the override relationship clear.

Trust must be consent a repo cannot grant itself. A setting inside the repo cannot gate running the repo, because a repository that can write the setting can grant itself permission. Trust is stored outside the repo, in the user’s state directory, and only a person writes it.

Idempotent setup on every launch beats “did init run once” bookkeeping. Worktree reuse means the setup may or may not have run. Running it again is cheap and idempotent (by contract); checking whether it ran is a cache that can rot. The simpler model is always run.

No compatibility layer, because the shape is being settled now, before sdlc is open-sourced and before other consumer families exist. A compatibility layer would be Option B below: the old keys kept in the schema and mapped onto verbs internally, with a deprecation window. Carrying two shapes into the first public release would make the old one part of the public contract; a clean break now, while the only consumers are this repo and its close relatives, is the cheapest moment it will ever be.

Option A: Machine-file verbs: entries (global and/or per-repo)

Section titled “Option A: Machine-file verbs: entries (global and/or per-repo)”

A machine file could carry global verbs or per-repo overrides, side by side with checkout and repos keys. Rejected: no verb honestly applies to every repo on a machine. Per-repo machine entries duplicate sdlc.local.yaml (checkout override already has a place) and add complexity to the machine file schema. The door is left open: if a machine-wide verb appears in practice (e.g., a wrapper that applies to every repo in a team), it becomes a future machine-file key.

Keep the old list keys in the schema and map them to verbs internally (e.g., worktree_init → verbs.setup), silently for backward compatibility. Rejected by the hard-cutover mandate. A compatibility layer tests poorly (which direction is the source of truth?), documents poorly (live docs carry two shapes), and persists code for a shape that no longer exists. Full cutover is cleaner.

Option C: Reusing waypost’s trust store as-is

Section titled “Option C: Reusing waypost’s trust store as-is”

detect-runners/src/trust.ts already implements marker-file trust. Reuse it without porting. Deferred: the store is waypost-branded; consolidating the tool suite’s trust stores is a follow-up that can happen in a separate PR. For now, SDLC gets its own $XDG_STATE_HOME/sdlc/trusted/ directory.

Each scenario shows the files present, what resolveVerb('setup') returns (verbs and source), whether trust gates it, and the exact sdlc pr review behaviour.

Scenario 1: Third-party repo, no sdlc support

Section titled “Scenario 1: Third-party repo, no sdlc support”

No sdlc.yaml, no sdlc.local.yaml, no env var. Implicit default applies.

Terminal window
resolveVerb('setup', repoRoot)
# → { verbs: ['bun install'], source: 'default' } (or just/moon equiv)

Trust gates it (source is default). First sdlc pr review 123 withholds setup with the trust hint. User runs sdlc repo trust once. Then setup runs every launch.

Scenario 2: Company repo, no sdlc.yaml, conventions known

Section titled “Scenario 2: Company repo, no sdlc.yaml, conventions known”

No project sdlc.yaml. Create sdlc.local.yaml with:

verbs:
setup:
- bun install
- bun run build
check:
- bun run lint
Terminal window
resolveVerb('setup', repoRoot)
# → { verbs: ['bun install', 'bun run build'], source: 'local' }

Trust gates it (source is local). Trust once. Local wins over implicit default.

Scenario 3: Third-party repo adhering to sdlc conventions

Section titled “Scenario 3: Third-party repo adhering to sdlc conventions”

Committed sdlc.yaml declares:

verbs:
setup:
- just setup
check:
- just test
Terminal window
resolveVerb('setup', repoRoot)
# → { verbs: ['just setup'], source: 'project' }

Trust gates it (source is project). Trust once. sdlc.local.yaml can still override one name without editing their file.

Committed verbs in this repo. Task-work Step 4 and Step 7 use them; sdlc pr review withholds nothing because trust is already set.

Terminal window
resolveVerb('setup', repoRoot)
# → { verbs: [...], source: 'project' }
sdlc pr review 1234
# [1/1] bun install (or equivalent)
# Launching…

A PR from a fork into this repo. pr_review.untrusted: skip-init withholds setup even though the repo root is trusted, because the tree being set up is a stranger’s, not the trusted root’s.

Terminal window
sdlc pr review 5678
# warning: PR #5678 is from a fork; setup is skipped ... Pass --trust to run it.
sdlc pr review 5678 --trust
# [1/1] bun install

Override the verb at runtime without editing config:

Terminal window
SDLC_VERB_SETUP='echo skip' sdlc pr review 999
# [1/1] echo skip (runs ungated, source: env)

Env bypasses the repo-trust gate only. The fork rule (Scenario 5) still withholds setup on a fork PR whatever the verb’s source, because it guards the fork’s checkout contents, not the verb’s origin.

A checkout still carries worktree_init: (old key). Preflight validation runs on every op:

Terminal window
sdlc quality run
# <root>/sdlc.yaml is not a valid sdlc.yaml; fix it or run `sdlc config scaffold` ...
# at worktree_init: Unrecognized key
# exit 14

The diagnostic is Zod’s ordinary unrecognized-key line. Nothing in the code knows the old name.

Scenario 8: Cross-repo review from another cwd

Section titled “Scenario 8: Cross-repo review from another cwd”

sdlc pr review <url> in repo A targeting repo B. Resolver validates repo B’s file loudly (not silently all-defaults):

Terminal window
cd repo-a
sdlc pr review https://github.com/user/repo-b/pull/123
# <repo-b>/sdlc.yaml is not a valid sdlc.yaml; ...
# at quality_checks: Unrecognized key
# exit 14

Easier. One verb resolution path for every tool that runs verbs. pr review is always ready to run setup without special case logic. Other tools (quality run, task-work) can ask for verbs by name without knowing where they live. sdlc.local.yaml lets developers customize setup in their checkout without touching the project file.

Binds future work. Every verb-running op goes through the resolver and the trust gate. New baseline verb names are added in one place (lib/config/verbs.ts defaults and this decision record). The trust store lives in the @sksizer/cli-tool substrate, so other tools of that shape can adopt it (tskr and waypost are the follow-up).

Breaks. Every consumer sdlc.yaml must migrate in a single PR. Stale files fail preflight validation (this is intentional; no silent fallback masks the transition). pr review realizes the worktree before it resolves and gates setup, so an UNTRUSTED or SCHEMA_ERROR exit leaves the fetched worktree on disk; the next launch reuses it.

Five-part delivery (stacked PRs; each green and self-contained):

  • PR0: This decision record. Frontmatter, body with eight scenarios, and a validation pass via sdlc entities audit.
  • PR1: verbs: substrate and hard cutover. Schema change, verbs.ts resolver, sdlc.local.yaml overlay, implicit default heuristic, cross-repo loud validation. Delete loadVerbList, SCHEMA_OWNED_KEYS, four get/set ops, two find-* skills (replace with find-verbs). Rename detect-worktree-init to detect-setup. Migrate this repo’s sdlc.yaml and fixtures. Update consumer skills and conventions docs.
  • PR2: Repo trust. Port packages/ts/cli-tool/trust/ facility, wire gates at quality run and armCommitWorktree, new ops sdlc repo trust/untrust, new UNTRUSTED exit code. Update docs and project doctor output.
  • PR3: pr review setup. Setup step in pr/ops/review.ts after realizeWorktree (which becomes fetch+add only), pr_review.setup config and --no-setup flag, trust gate, setup output block. Tests for created and reused worktrees, fork rule integration.
  • PR4: Trust by path or owner. Machine config gains trust.paths/ trust.owners. trustDecision checks the marker store, then paths, then owners, and reports which; repo trust/repo trust --show/repo untrust and project doctor surface the layer and rule. Update this record and the conventions doc with the policy and its threat model. The policy engine moves to @sksizer/sdlc-tool, the repo-aware substrate downstream of cli-tool/easy-git/easy-gh that sdlc mounts, behind a thin sdlc adapter.

Consolidating waypost/tskr trust stores. Machine-file verbs (revisit if a real machine-wide verb emerges). Extension verbs beyond shape validation (future layer).

  • Implicit-default heuristic refinement. The default for setup walks just, moon, mise, and lockfiles in order. Real-world repos may reveal that the order needs tuning or that the heuristic itself is wrong; iteration expected.
  • sdlc.local.yaml scope. The file accepts every sdlc.yaml key. Whether non-verb overrides (pr_review.setup, pr_review.untrusted) see real use decides whether the generic deep-merge earns its keep.

← Back to Decisions