Skip to content

T-F8NL-hooks-workflow-step-grammar-when-deps-missing-on-fail-warn

State: closed/done · Impact: medium · Complexity: large

hooks: uses the workflow step grammar, plus when: deps-missing (reusing detectDeps/NODE_LOCKFILE_MANAGERS) and on_fail: warn|block|park. park signals the loop to park the task and notify rather than stall. apply writes shims into the --git-common-dir hooks dir, chaining existing hooks and printing entries when lefthook or husky owns the dir.

Delivered in two parts. Part 1 (this change): the hooks: schema, the sdlc hooks run runtime a shim calls, the park marker and the work-tick consumer. Part 2 (a follow-up change): sdlc apply writing the shims.

Part 1 — schema and runtime:

  1. hooks: in SdlcConfigSchema becomes a partial record keyed by a closed enum of client-side git hook names (GIT_HOOK_NAMES: pre-commit, pre-merge-commit, prepare-commit-msg, commit-msg, post-commit, pre-rebase, post-checkout, post-merge, pre-push, post-rewrite) whose values are ordered hook-step lists. A hook step extends WorkflowRunStepSchema/WorkflowScriptStepSchema with name?, when (always | deps-missing, default always) and on_fail (warn | block | park, default block). A skill/prompt step is rejected with a message saying a git hook cannot drive a session. The key keeps its .meta() (applies_via: apply:hooks, runs_in: [hook]).
  2. depsMissing(root) / missingDeps(root) in lib/config/detect-deps.ts: a detectDeps candidate whose local deps dir is absent (node_modules for the JS managers, also .pnp.cjs for yarn; .venv for uv). Ecosystems with no project-local deps dir (cargo, go, bundler, poetry, pip) never count as missing.
  3. sdlc hooks run <hook> [--stdin -] [-- args...] (lib/services/hooks/ops/run.ts over lib/services/hooks/run.ts): resolves the checkout root (Git#topLevel), reads config once, and exits 0 silently when that hook has no steps. Otherwise it filters when: deps-missing steps (skipped for a post-checkout file checkout, flag arg 0), gates the remaining steps on repo trust ONCE before any step runs (exit 21 when untrusted; both run and script steps come from repo-writable config), then runs them in order: run through the resolveVerb cascade, script through the command seam, each as sh -c <text> <hook> <args...> with cwd = checkout root, so hook args are the step’s $1… (no {placeholder} templating).
  4. on_fail: warn prints and continues; block stops and exits 1 (git ignores the exit code of post-commit/post-checkout/post-merge/ post-rewrite); park stops, exits 0, writes the park marker and fires hook.parked. A block failure fires hook.blocked.
  5. Park marker (lib/services/hooks/park.ts): <git-common-dir>/sdlc/park/<branch-slug>.json carrying branch, hook, step, exit code, stderr tail and timestamp — visible from every worktree. The work tick consumes the marker for task/<basename> before dispatching a candidate and after each workflow step, parks that task (stops its sequence, parked on the dispatch entry) and fires task.parked.

Part 2 — apply framework and applier (delivered):

  1. Apply framework (lib/services/apply/): types.ts — Applier { name, plan(ctx): Promise<Change[]>, apply(ctx, changes): Promise<void> }, Change { kind: create|update|remove|manual, target, summary, instructions? }. registry.ts — APPLIERS keyed by ApplierName (lib/config/meta.ts), planAll/applyAll; registry.test.ts asserts every apply:<x> some config key’s meta declares has a registered applier (completeness, mirroring meta.test.ts). harness.ts — a thin adapter over the existing harness install claude op (calls its .handler directly rather than through invokeOp, which needs the op registered via discoverOps() — not guaranteed for a caller like doctor that imports this applier without running discovery); plan passes dryRun: true, apply calls it for real. mcp.ts — a no-op planner: one manual note when mcp.allow/mcp.deny is non-empty (“not yet applied — Phase 9”), else [].
  2. sdlc apply [--dry-run|--check] [--only <applier>] [--trust] (lib/services/apply/run.ts’s runApply, registered as a normal op at path: ['apply', 'run'] in lib/services/apply/ops/run.ts) — reachable ONLY as the bare sdlc apply: T-OOOE’s TOP_LEVEL_VERBS mechanism (cli/sdlc.ts) claims that path exclusively, excluding it from the apply noun’s own subtree entirely (so the noun — which would otherwise have this as its sole verb — never appears; sdlc apply run does not resolve). --dry-run (the adapter’s own global flag) plans and exits 0 always; --check plans and exits 1 iff any change is pending (0 clean); neither ever gates trust. A real apply gates trust ONCE (OpError('UNTRUSTED'), exit 21, reusing requireTrustedVerbs) before any write, unless every planned change is manual.
  3. The hooks applier (lib/services/apply/hooks.ts, one of the three Appliers sdlc apply runs):
    • Unowned hooks dir (no lefthook/husky detected): plan proposes create/update/remove changes for a small POSIX sh shim per configured hook, marked with # sdlc-managed hook shim v1 so re-apply recognizes it. The shim sets SDLC_CWD_DEV=0 (so the INSTALLED sdlc answers even in a fresh worktree with no node_modules) and execs sdlc hooks run <hook> [--stdin -] -- "$@". A pre-existing FOREIGN hook is chained: moved to <hook>.pre-sdlc, run first by the new shim (stdin captured once and replayed to both, for pre-push/post-rewrite, since a pipe reads empty the second time). Unconfiguring a hook removes the shim and restores the chained original. sdlc missing from PATH always warns on stderr and exits 0 — an environment gap never breaks git.
    • Owned hooks dir (a lefthook config file, or .husky/a husky fingerprint — detectHookManager): nothing is written; plan reports a manual change carrying the exact entry a person adds (lefthookEntry(hook) — a commands: sdlc: run: SDLC_CWD_DEV=0 sdlc hooks run <hook> [--stdin -] -- {0} block, {0} being lefthook’s space-joined all-args placeholder, use_stdin: true for pre-push/post-rewrite; huskyLine(hook) — the one line to append to .husky/<hook>), and reports no drift once that entry is actually present.
    • Trust: sdlc apply gates on repo trust (OpError('UNTRUSTED'), exit 21) before any write, UNLESS every planned change is manual.
    • project doctor gained an applyDrift section listing every applier’s pending change with the sdlc apply fix hint.
AreaNote
solutions/ontological/lib/config/load.tshooks: schema, GIT_HOOK_NAMES, hook-step schemas
solutions/ontological/lib/config/detect-deps.tsdepsMissing / missingDeps
solutions/ontological/lib/services/hooks/runner, park marker, hooks run op
solutions/ontological/lib/services/notify/dispatch.tshook.parked, hook.blocked, task.parked events
solutions/ontological/lib/services/orchestrator/ticks/work.tspark-marker consumption
solutions/ontological/lib/services/apply/Applier/Change types, the applier registry, runApply, the hooks/harness/mcp appliers
solutions/ontological/lib/services/apply/ops/run.tsThe apply run op (sdlc apply’s registry backing)
solutions/ontological/cli/sdlc.tsTOP_LEVEL_VERBS entry for apply (shared with T-OOOE’s init/run)
solutions/ontological/lib/services/project/ops/doctor.tsapplyDrift section
  • AC-1: A hooks: entry with on_fail: park causes a failing hook to park the task rather than block the commit.
  • AC-2: sdlc apply chains a pre-existing FOREIGN hook (moves it to <hook>.pre-sdlc, runs it first) in an unowned hooks dir, and restores it on unconfigure. In a lefthook/husky-owned dir it writes nothing and reports the exact entry to add instead (revised from the original wording, which conflated the two cases before the design was resolved).
  • AC-3: The schema rejects a skill/prompt hook step and an unknown hook name, and accepts run/script steps with name, when, on_fail.
  • AC-4: A when: deps-missing step runs in a temp repo with a lockfile and no node_modules, and is skipped once node_modules exists.
  • AC-5: on_fail: warn exits 0, block exits non-zero, park exits 0 and writes the park marker and fires the notifier.
  • AC-6: An untrusted repo with a configured step exits 21; an untrusted repo with no steps for the hook exits 0 silently.
  • AC-7: The work tick consumes a park marker for a task’s branch, parks that task and sends a task.parked notification.
  • Notifier channels and the event catalog beyond the three new event names (Phase 8).
  • T-OOOE (the TOP_LEVEL_VERBS mechanism sdlc apply’s CLI reachability reuses).

← Back to Tasks