Named verbs cascade env → sdlc.local.yaml → sdlc.yaml → default; project-sourced verbs run only in a trusted repo
State: open/proposed
Summary
Section titled “Summary”verbs:map replaces three list keys.worktree_init,commit_worktree_init, andquality_checksbecome named entries in aRecord<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, besidesdlc.yaml), committedsdlc.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:setupreadies a checkout (task-work Step 4,pr review),setup-hooks(absent →setupfallback) arms ephemeral commit worktrees,checkgates pre-PR verification (task-work Step 7,quality run --config <sdlc.yaml>; without--configthe 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 explicitsdlc repo trustor 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’sUNTRUSTEDexit and its hint. sdlc pr reviewruns setup on every launch. Before the review agent starts, the setup verb runs on created and reused worktrees alike. Newpr_review.setupconfig key (default true) and--no-setupflag for overrides. Failure aborts before the host launches.- The machine file may pre-trust repos by path or owner.
trust.pathsis a glob over the resolved repo root;trust.ownersmatches theoriginremote’s owner case-insensitively. Precedence: a marker grant (sdlc repo trust) first, thentrust.paths, thentrust.owners. A policy grant is not a marker:sdlc repo untrustcannot 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.
Context
Section titled “Context”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.
Decision
Section titled “Decision”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 bycommitToMainViaWorktree, backlog-capture). Absent → falls back tosetup; explicit[]→ nothing.check: pre-PR gates. Run by task-work Step 7 andsdlc 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.verifyenum valuequality_checksbecomescheck.
Cascade. Resolver resolveVerb(name, repoRoot, { env? }) → { verbs: string[], source: 'env'|'local'|'project'|'default'|'none' } picks
the list by name and reports its source:
| Layer | Where | Source tag | Trust-gated |
|---|---|---|---|
| Environment | SDLC_VERB_<NAME> (uppercase, - → _), newline-separated | env | No |
| Local | <repoRoot>/sdlc.local.yaml, partial schema, deep-merged | local | Yes |
| Project | <repoRoot>/sdlc.yaml via loadConfig | project | Yes |
| Default | Heuristic: `setup | install | bootstrap` from just/moon/mise, else lockfile-driven install |
| None | No verbs found for this name | none | — |
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.
Hard cutover
Section titled “Hard cutover”Mandate: clean this up as much as possible and maintain nothing for the old shape. Concretely:
worktree_init,commit_worktree_init,quality_checksare 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, andsdlc quality run --keyare deleted, not kept as a raw-override path.quality runtakes--name <verb>(defaultcheck) and positional verbs only.- The four ops
get/set-quality-checksandget/set-worktree-initare deleted and replaced byget-verbs/set-verbs --name. The two skillsfind-quality-checksandfind-worktree-initare deleted and replaced by onefind-verbs --name <verb>skill. pr_update.verify: quality_checksbecomescheck; the old value is a validation error.realizeWorktree’sinitVerbsoption is removed, not left as an unused parameter.detect-worktree-initop and module are renamed todetect-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.yamlfiles 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.
Options considered
Section titled “Options considered”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.
Option B: Aliasing the old keys
Section titled “Option B: Aliasing the old keys”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.
Scenarios
Section titled “Scenarios”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.
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 lintresolveVerb('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 testresolveVerb('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.
Scenario 4: Own repo, same-repo PR
Section titled “Scenario 4: Own repo, same-repo PR”Committed verbs in this repo. Task-work Step 4 and Step 7 use them;
sdlc pr review withholds nothing because trust is already set.
resolveVerb('setup', repoRoot)# → { verbs: [...], source: 'project' }sdlc pr review 1234# [1/1] bun install (or equivalent)# Launching…Scenario 5: Fork PR into own repo
Section titled “Scenario 5: Fork PR into own repo”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.
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 installScenario 6: One-off override
Section titled “Scenario 6: One-off override”Override the verb at runtime without editing config:
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.
Scenario 7: Stale config
Section titled “Scenario 7: Stale config”A checkout still carries worktree_init: (old key). Preflight validation runs
on every op:
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 14The 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):
cd repo-asdlc 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 14Consequences
Section titled “Consequences”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.
Migration
Section titled “Migration”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.tsresolver,sdlc.local.yamloverlay, implicit default heuristic, cross-repo loud validation. DeleteloadVerbList,SCHEMA_OWNED_KEYS, four get/set ops, two find-* skills (replace withfind-verbs). Renamedetect-worktree-inittodetect-setup. Migrate this repo’ssdlc.yamland fixtures. Update consumer skills and conventions docs. - PR2: Repo trust. Port
packages/ts/cli-tool/trust/facility, wire gates atquality runandarmCommitWorktree, new opssdlc repo trust/untrust, newUNTRUSTEDexit code. Update docs andproject doctoroutput. - PR3: pr review setup. Setup step in
pr/ops/review.tsafterrealizeWorktree(which becomes fetch+add only),pr_review.setupconfig and--no-setupflag, trust gate,setupoutput block. Tests for created and reused worktrees, fork rule integration. - PR4: Trust by path or owner. Machine config gains
trust.paths/trust.owners.trustDecisionchecks the marker store, then paths, then owners, and reports which;repo trust/repo trust --show/repo untrustandproject doctorsurface 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.
Out of scope
Section titled “Out of scope”Consolidating waypost/tskr trust stores. Machine-file verbs (revisit if a real machine-wide verb emerges). Extension verbs beyond shape validation (future layer).
Open questions
Section titled “Open questions”- Implicit-default heuristic refinement. The default for
setupwalks 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.yamlkey. Whether non-verb overrides (pr_review.setup,pr_review.untrusted) see real use decides whether the generic deep-merge earns its keep.
References
Section titled “References”- S-0013-baseline-task-vocabulary · D-0007-deterministic-op-substrate · D-LLJY-cli-tool-substrate · T-HQ2D-config-needs