Decisions Log Guide¶
When to use this: You want a durable record of implementation choices — so settled decisions aren't re-litigated, team rules are enforced automatically, and automation pauses on issues with unresolved options instead of guessing.
Record implementation choices, enforce team rules, and prevent automation from proceeding on unresolved options.
Table of Contents¶
- Why Record Decisions?
- What Is the Decisions Log?
- The Four Entry Types
- The Automation Workflow
- Using /ll:decide-issue Manually
- Creating Entries via CLI
- Rules & Active Rules Sync
- Auto-generating from History
- Promoting Decisions to Rules
- Recording Outcomes
- Superseding Old Entries
- Configuration
- See Also
Why Record Decisions?¶
- Prevents re-litigating settled choices. When a question comes up again ("why didn't we use approach B?"),
ll-issues decisions listanswers it in seconds instead ofgit logspelunking. - Drives implementation constraints automatically. Required rules (
enforcement: required) are propagated to.ll/ll.local.mdand injected into every planning session — Claude sees them without you having to repeat them. - Creates an audit trail. The
outcome:field closes the loop: after a decision ages 3+ months in production, you can record whether it worked, mixed, or was reversed. - Gates automation on unresolved options. When
confidence-checkdetects competing approaches, it setsdecision_needed: true. Automation won't implement until/ll:decide-issueclears the flag.
What Is the Decisions Log?¶
.ll/decisions.yaml is a project-level governance file that records four types of entries: architectural decisions made, rules the team enforces, exceptions granted to those rules, and coupling contracts that tell wire-issue what to audit when specific files change.
The log serves two purposes: institutional memory (settled choices stay queryable via ll-issues decisions list) and automation gating (ll-auto and ll-parallel will not implement an issue while decision_needed: true is set — see The Automation Workflow).
Storage Layout¶
Storage is hybrid. New entries are written as append-only per-entry
fragments under .ll/decisions.d/<uuid4>.json (one file per entry, added via
atomic_write_json), which sidesteps the concurrent-append id collisions that
blocked EPIC merges (BUG-2642). A legacy .ll/decisions.yaml flat file may also
exist. Both tiers are read as a union — ll-issues decisions list and
load_decisions() merge the flat file with all *.json fragments (sorted by
timestamp), so you should query through the CLI rather than cat/grep-ing a
single file. A fresh install therefore has only .ll/decisions.d/ and no
.ll/decisions.yaml until a compaction runs. Compaction (save_decisions())
folds every fragment back into the flat file and deletes the fragment directory.
Because a never-compacted install has no flat file, presence gates must accept
either tier — e.g. [ -f .ll/decisions.yaml ] || [ -d .ll/decisions.d ], or
simply gate on ll-issues decisions list returning entries. Gating on the flat
file alone silently skips governance on fresh installs.
The Four Entry Types¶
Decision¶
Records a choice made with rationale and the alternatives considered. Optionally accepts an outcome later once the decision has been measured in production.
- id: ARCHITECTURE-004
type: decision
timestamp: '2026-06-04T23:32:01Z'
category: architecture
labels: [design, fsm]
rationale: >
Option A scored 11/12 vs Option B 5/12. _config_candidates() is a
near-identical precedent in the codebase.
rule: 'Option A: Host-aware get_project_folder()'
alternatives_rejected: 'Option B: New resolve_session_dir() wrapper'
scope: issue
issue: ENH-1945
After the change ships, record whether it worked. Here's what the same entry looks like after 3 months in production:
- id: ARCHITECTURE-004
type: decision
timestamp: '2026-06-04T23:32:01Z'
category: architecture
labels: [design, fsm]
rationale: >
Option A scored 11/12 vs Option B 5/12. _config_candidates() is a
near-identical precedent in the codebase.
rule: 'Option A: Host-aware get_project_folder()'
alternatives_rejected: 'Option B: New resolve_session_dir() wrapper'
scope: issue
issue: ENH-1945
outcome:
result: worked # worked | did_not_work | mixed | reversed
measured_at: '2026-09-01T00:00:00Z'
notes: >
No call-site breakage in 90 days. The backward-compatible parameter
addition held through three subsequent refactors. Would make the same
call again.
Rule¶
An enforced team invariant. Rules marked enforcement: required are propagated automatically to .ll/ll.local.md via ll-issues decisions sync.
- id: RULE-TESTING-001
type: rule
timestamp: '2026-05-15T12:00:00Z'
category: testing
labels: [mandatory, test-coverage]
rationale: Prevents shipping CLI commands without regression coverage.
rule: All new CLI commands must have corresponding tests in scripts/tests/.
enforcement: required # required | advisory
Exception¶
A one-time override of a rule, linked back to the rule it excepts.
- id: EXCEPTION-2026-001
type: exception
timestamp: '2026-06-01T09:00:00Z'
category: testing
labels: [one-time]
rationale: >
ll-gitignore is a thin wrapper over an existing, well-tested library.
The exception is bounded to this one command.
rule_ref: RULE-TESTING-001 # links to the rule being excepted
issue: FEAT-700
alternatives_rejected: Writing tests that only test the underlying library
Coupling¶
Declares a file-change → audit-target contract. When wire-issue sees that a proposed change touches a file matching if_changed, it flags the then_check targets as required review. Used to automate wiring gap detection.
- id: COUPLING-ARCH-CLI-001
type: coupling
timestamp: '2026-05-20T10:00:00Z'
category: architecture
labels: [add-cli-command]
rationale: New CLI commands always need test coverage and reference docs.
if_changed: 'scripts/little_loops/cli/**/*.py'
then_check:
- scripts/tests/test_*_cli.py
- docs/reference/CLI.md
tier: soft # hard | soft | fyi
archetype: add-cli-command
enforcement: advisory
tier controls how wire-issue treats the gap: hard blocks implementation, soft warns, fyi notes only.
The Automation Workflow¶
This is the end-to-end flow when you're running issues through ll-auto, ll-parallel, or the autodev loop:
┌─ Issue captured ──────────────────────────────────────────────────┐
│ │
│ /ll:refine-issue │
│ Deposits multiple options in "## Proposed Solution" │
│ (e.g., "Option A: ... Option B: ...") │
│ ↓ │
│ /ll:confidence-check │
│ Detects signal phrases in Outcome Risk Factors: │
│ "unresolved decision", "Option A or B", "either/or", etc. │
│ → sets decision_needed: true in frontmatter │
│ ↓ │
│ ll-auto / ll-parallel / autodev loop │
│ Reads decision_needed: true │
│ → pauses implementation │
│ → invokes /ll:decide-issue ISSUE_ID --auto │
│ ↓ │
│ /ll:decide-issue │
│ Scores each option: Consistency / Simplicity / │
│ Testability / Risk (0–3 each, max 12) │
│ → inserts > **Selected:** callout into issue │
│ → sets decision_needed: false │
│ → appends DecisionEntry to .ll/decisions.yaml │
│ ↓ │
│ Automation resumes with the decided issue │
└───────────────────────────────────────────────────────────────────┘
The decision_needed flag is the handshake. confidence-check sets it when it sees ambiguity; decide-issue clears it after selecting an option. Automation never implements an issue while the flag is set.
The structural-vs-semantic gap (ENH-2443): decision_needed: true sometimes has nothing to decide — the ## Proposed Solution section is structurally complete but has no enumerable options (no ### Option A/B, no bullet alternatives). ll-issues format-check reports this as compliant, since the gap is semantic, not structural. /ll:decide-issue's Phase 2.5 catches this: OPTIONS_MISSING on a --validate-only probe, or — in --auto mode — one bounded /ll:refine-issue --auto retry to deposit options before falling through to Phase 3b's inline provisional-language scan (BUG-2606), which can still lock in a clear winner from prose recommendations even without formal option blocks. Only if Phase 3b also finds nothing does decision_needed stay true — MANUAL_REVIEW_RECOMMENDED (distinct from MANUAL_REVIEW_NEEDED) is an FSM-level diagnostic derived from the deposit-attempt marker, not emitted by this phase directly. FSM (finite-state machine) loop callers (rn-remediate, autodev) pre-check with the deterministic ll-issues check-decidable <ID> CLI rather than paying for a full decide pass with nothing to score.
Signal phrases that trigger decision_needed: true:
- "open decision"
- "unresolved decision"
- "resolve before implementing"
- "decision point"
- "either/or" / "either...or"
- "Option A or" / "Option A/B"
Using /ll:decide-issue Manually¶
You don't have to wait for automation to reach the issue. Run decide-issue yourself as soon as you see multiple options in the Proposed Solution:
Use --dry-run to preview the scoring table and selected option without modifying the issue file:
Two more flags are available: --auto runs non-interactively, writing the decision without prompting for confirmation, and --validate-only only probes whether the issue has a decidable set of options (Phases 1–2.5) — it does no scoring and makes no writes, exiting 0 if there is something to decide or exiting 1 with OPTIONS_MISSING otherwise. --validate-only is intended for direct/interactive use (not automation subprocesses).
Sample output:
DECIDE ISSUE: FEAT-1933
OPTIONS FOUND (2 total)
Option A: Host-aware get_project_folder()
Option B: New resolve_session_dir() wrapper
SCORING
| Option | Consistency | Simplicity | Testability | Risk | Total |
|--------|-------------|------------|-------------|------|-------|
| A | 3/3 | 3/3 | 2/3 | 3/3 | 11/12 |
| B | 2/3 | 1/3 | 1/3 | 1/3 | 5/12 |
DECISION
✓ Selected: Option A (score: 11/12)
Reasoning: Option A reuses _config_candidates() precedent pattern with
zero call-site breakage. Backward-compatible parameter addition.
CHANGES APPLIED
- Annotated issue with > **Selected:** callout
- Appended ### Decision Rationale section
- decision_needed: set to false
CHANGES APPLIED reports only these three issue-file edits (each line flips to "Skipped (idempotent)" / "already false — no change" on a repeat run). The decisions.yaml log append happens separately in Phase 7b via ll-issues decisions add — it's a silent no-op when decisions.yaml doesn't exist, and isn't itself listed in the CHANGES APPLIED block.
Creating Entries via CLI¶
Use ll-issues decisions add to create entries directly, without going through the issue workflow:
# Record an architectural decision
ll-issues decisions add \
--type decision \
--category architecture \
--rule "Use SQLite for the session store (not PostgreSQL)" \
--rationale "No server to manage; project scope is single-user local" \
--issue FEAT-324
# Create a required rule
ll-issues decisions add \
--type rule \
--category testing \
--rule "All new CLI commands must have corresponding tests in scripts/tests/" \
--enforcement required
# Create a coupling contract
ll-issues decisions add \
--type coupling \
--category architecture \
--if-changed "scripts/little_loops/cli/**/*.py" \
--then-check "scripts/tests/test_*_cli.py,docs/reference/CLI.md" \
--tier soft \
--archetype add-cli-command \
--rationale "New CLI commands need test coverage and reference docs"
# Grant a one-time exception to a rule
ll-issues decisions add \
--type exception \
--category testing \
--rule-ref RULE-TESTING-001 \
--rationale "Thin wrapper over well-tested library; exception bounded to ll-gitignore" \
--issue FEAT-700
Entry IDs are auto-generated as a random UUID4 (e.g.
4e1ec28d-ae1d-4af7-b32c-d084668d36b1). Override with --entry-id if you need a
specific ID. The older count-based scheme (ARCHITECTURE-005, TESTING-002) was
retired in BUG-2642 — sequential ids collided when concurrent EPIC-branch appends
minted the same {category}-{count+1} value; UUID4s never collide, which is what
makes per-entry fragment files (see Storage Layout) safe to
merge. Historical entries below that still carry ARCHITECTURE-NNN ids predate
the change; they remain valid.
List all entries with filtering:
ll-issues decisions list
ll-issues decisions list --type rule
ll-issues decisions list --type decision --category architecture
ll-issues decisions list --active-only # exclude superseded entries
ll-issues decisions list --format json
Rules & Active Rules Sync¶
Required rules (those with enforcement: required) are automatically propagated to .ll/ll.local.md so they're visible to every developer and to Claude without requiring a manual read of the YAML file.
This rebuilds the ## Active Rules section in .ll/ll.local.md:
Before sync:
After sync:
# Local Settings Notes
Personal development preferences.
## Active Rules
- All new CLI commands must have corresponding tests in scripts/tests/
- Config changes must be backward-compatible for at least two releases
The
## Active Rulessection is machine-written. Don't hand-edit it — it will be overwritten on the nextsync. Advisory rules (enforcement: advisory) are not included.
Sync runs automatically after ll-issues decisions promote --enforcement required (the default). Run it manually after ll-issues decisions add, after editing .ll/decisions.yaml directly, or after extract-from-completed.
Auto-generating from History¶
If you've been running issues through little-loops for a while without the decisions log, bootstrap it from completed issues:
Creates one DecisionEntry per completed issue that doesn't already have an entry. Uses .ll/history.db when present for faster scanning. Each entry gets ID DEC-{ISSUE_ID}, a timestamp from completed_at, and labels from issue type and priority.
These auto-generated entries are minimal stubs — they record that a decision happened (an issue was completed) without knowing what the decision was. They're useful as a starting point for retroactive annotation: run ll-issues decisions outcome DEC-FEAT-1933 --result worked --notes "..." to enrich them.
LLM Extraction: extract-from-completed¶
For semantic extraction that populates actual rule text, use:
ll-issues decisions extract-from-completed [--since YYYY-MM-DD] [--issue ID] [--dry-run] [--min-confidence 0.7]
Unlike generate (which creates empty stubs), extract-from-completed sends each completed issue to an LLM and returns concrete, imperative rules:
ll-issues decisions extract-from-completed # Extract from all completed issues
ll-issues decisions extract-from-completed --since 2026-01-01 # Only issues completed since date
ll-issues decisions extract-from-completed --issue ENH-2151 # One issue only
ll-issues decisions extract-from-completed --dry-run # Preview without writing
ll-issues decisions extract-from-completed --min-confidence 0.85 # Stricter quality gate
Each accepted candidate becomes a RuleEntry with enforcement: advisory, labeled extracted and the candidate's scope value (global or issue), and linked back to the source issue via the issue: field.
Deduplication runs at two levels: 1. Issue-level: if any existing entry references the issue ID, that issue is skipped entirely. 2. Content-level: if the extracted rule shares ≥60% of significant tokens with an existing rule, it is discarded as a near-duplicate.
Automated Extraction Loop¶
.loops/distill-decisions.yaml automates extraction on a recurring basis. Each run:
- Captures a baseline count of current
decisions.yamlentries. - Runs
extract-from-completed --since <last-checkpoint>(checkpoint at.loops/distill-decisions-checkpoint.txt). - Verifies the count increased (
output_numericevaluator — non-LLM). - On success, writes today's date to the checkpoint so the next run skips already-processed issues.
- On no new entries, exits cleanly without modifying the checkpoint.
Trigger manually:
Or hook it into your automation pipeline to run automatically after issues transition to done. The issue-completion-log.sh hook fires extract-from-completed --issue <ID> in a background subshell after each issue closes, so extraction happens asynchronously without blocking the session (see Built-in Hooks Guide).
Promoting Decisions to Rules¶
After recording several decisions, patterns emerge — the same guidance recurs across multiple issues, which is a signal that it belongs as a standing rule rather than a one-off record. Two subcommands manage this lifecycle.
Finding Candidates: suggest-rules¶
suggest-rules scans all DecisionEntry records in .ll/decisions.yaml, groups them by category and shared terminology, and surfaces entries that appear to encode recurring guidance:
Sample output:
[SUGGEST][high-signal] ARCH-001, ARCH-002, ARCH-003 share category=architecture and reference sub_loop
— consider promoting to a rule: "Use sub-loop composition always"
• ARCH-001: Use sub-loop composition always
• ARCH-002: Register adapters via Protocol
• ARCH-003: Prefer file-poller for callbacks
The [high-signal] tag appears when a category has 3+ decisions sharing common tokens. Without the tag, the cluster was detected via pairwise token overlap in a smaller group.
suggest-rulesrequires at least 3DecisionEntryrecords to run. It exits 1 if fewer exist, or if all decisions are one-off choices (entries whoseruletext starts withOption A,Option B,Option C,NO-GO, orCaptured:). It operates only onDecisionEntryrecords — existingRuleEntryrecords are not considered for promotion.
Promoting to a Standing Rule: promote¶
Once you've identified a good candidate, promote it:
ll-issues decisions promote ARCH-001 # Promote as required rule (auto-syncs)
ll-issues decisions promote ARCH-001 --enforcement advisory # Promote as advisory rule (no auto-sync)
promote replaces the DecisionEntry in-place with a RuleEntry, preserving the same id, category, rationale, and rule text. The type field changes from "decision" to "rule", and an enforcement field is added.
When
--enforcement requiredis used (the default),promoteautomatically runssync— see Rules & Active Rules Sync for what that writes to.ll/ll.local.md. Using--enforcement advisoryskips the auto-sync; advisory rules appear inll-issues decisions list --type rulebut are not propagated to.ll/ll.local.md.
There is no demote subcommand — promotion is one-way via CLI. To revert, edit .ll/decisions.yaml directly and change the type: field back to "decision".
Recording Outcomes¶
After a decision has been in production long enough to evaluate, record what happened:
ll-issues decisions outcome ARCHITECTURE-004 --result worked
ll-issues decisions outcome ARCHITECTURE-004 --result mixed --notes "Worked for the common case; edge cases needed a follow-up patch"
ll-issues decisions outcome ARCHITECTURE-004 --result reversed --notes "Reverted in ENH-2100 — the API changed and the assumption broke"
Results: worked | did_not_work | mixed | reversed
Recording outcomes builds a searchable record of which approaches held up. Over time, ll-issues decisions list --type decision shows you which categories of decisions tend to get reversed — a useful signal for where to slow down and explore more options.
Use --force to overwrite an existing outcome.
Superseding Old Entries¶
When a rule or decision is replaced by a newer one, mark the old entry as superseded rather than deleting it:
ll-issues decisions add \
--type rule \
--category testing \
--rule "All new CLI commands must have tests AND type annotations" \
--enforcement required \
--supersedes RULE-TESTING-001
The old entry stays in the YAML for audit trail purposes. --active-only filters it out of list results. decisions sync excludes superseded rules from .ll/ll.local.md.
Configuration¶
The decisions feature has a small config namespace in .ll/ll-config.json. Defaults shown below; auto_generate defaults to [] (no auto-generation) — the example sets it to ["FEAT", "ENH"] to illustrate a common customization that skips BUG entries:
{
"decisions": {
"enabled": false,
"log_path": ".ll/decisions.yaml",
"auto_generate": ["FEAT", "ENH"]
}
}
| Key | Default | Description |
|---|---|---|
decisions.enabled |
false |
Feature gate; the log still works when false, but automation gating on decision_needed requires this to be true |
decisions.log_path |
".ll/decisions.yaml" |
Path to the legacy flat file. The per-entry fragment directory is derived from this — always log_path's sibling with a .d suffix (.ll/decisions.d/) — and is not independently configurable (BUG-2647, Option A) |
decisions.auto_generate |
[] |
Issue type prefixes to auto-generate entries from when ll-issues decisions generate runs (e.g., ["FEAT", "ENH"] skips BUG entries) |
Load-Time Validation¶
Both storage tiers — the flat .ll/decisions.yaml and the
.ll/decisions.d/*.json fragments — are gated by ll-verify-decisions
(ENH-2589) at three transport layers, listed in order of when they fire.
ll-verify-decisions re-globs the fragment directory in a strict second pass
(bypassing the read path's silent skip), so a single malformed fragment fails the
gate:
- Git pre-commit hook (ENH-2590) —
repo: localblock in.pre-commit-config.yamlinvokesll-verify-decisionson staged changes to.ll/decisions.yamlor.ll/decisions.d/*.json(matched by^\.ll/decisions(\.yaml|\.d/.*\.json)$). Blocksgit commiton anyyaml.YAMLError, missing required field, or unknown entry-type discriminator. Active afterpre-commit install. - Pytest CI belt (ENH-2591) — wraps the same validator as a
subprocess-asserting gate in
python -m pytest scripts/tests/, sogit commit --no-verifyand non-hook edit paths still cannot land a corruption onmain. - Claude Code
PreToolUsehook (ENH-2592,hooks/scripts/check-decisions-yaml.sh) — blocks the corruption in the editor session, before the file is even written. Fires onWrite/Editof either.ll/decisions.yamlor a.ll/decisions.d/*.jsonfragment withtimeout: 5. The hook stages the candidate content (tool_input.contentfor Write, or the post-Edit result reconstructed fromold_string→new_string) in a temporary<tmp>/.ll/decisions.yamland runsll-verify-decisions --config-root <tmp>against it — validating the candidate before mutation, not the on-disk file. Corruption (anyyaml.YAMLError/KeyError/ValueError) bubbles up as host-level exit 2 with the validator's single-lineERROR:on stderr; clean candidates exit 0 and let Claude write through. Skips gracefully whenpython3orll-verify-decisionsis missing — the pre-commit and pytest belts remain authoritative. Gated byscripts/tests/test_check_decisions_yaml_hook.py.
All three layers share the validator's exit-code contract: 0 on a clean
file, 1 with a single-line ERROR: message on stderr pointing at the
file path for any caught corruption class. Manually re-run the validator
against an arbitrary config root with:
See scripts/tests/test_decisions_yaml_pre_commit_gate.py
for the end-to-end pre-commit fixture pattern and
scripts/tests/test_decisions_yaml_gate.py
for the pytest CI belt (positive live-file case + negative OTHE-203
fixture case against ll-verify-decisions).
See Also¶
- Issue Management Guide —
decision_neededin the full refinement pipeline - Loops Guide — how
autodevandrecursive-refinehandle the decision gate and skip decision-blocked issues