ddflow-mcp 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. ddflow/__init__.py +15 -0
  2. ddflow/__main__.py +3 -0
  3. ddflow/api/__init__.py +242 -0
  4. ddflow/api/_base.py +42 -0
  5. ddflow/api/completion.py +38 -0
  6. ddflow/api/decisions.py +205 -0
  7. ddflow/api/flow.py +231 -0
  8. ddflow/api/gates.py +407 -0
  9. ddflow/api/items.py +611 -0
  10. ddflow/api/jobs.py +204 -0
  11. ddflow/api/knowledge.py +572 -0
  12. ddflow/api/lifecycle.py +880 -0
  13. ddflow/api/operations.py +347 -0
  14. ddflow/api/reporting.py +535 -0
  15. ddflow/api/review.py +298 -0
  16. ddflow/api/setup.py +716 -0
  17. ddflow/api/workflow.py +258 -0
  18. ddflow/config.py +1218 -0
  19. ddflow/core/__init__.py +0 -0
  20. ddflow/core/events.py +136 -0
  21. ddflow/core/flow.py +471 -0
  22. ddflow/core/ids.py +28 -0
  23. ddflow/core/model.py +1582 -0
  24. ddflow/core/outcome.py +162 -0
  25. ddflow/core/plain.py +28 -0
  26. ddflow/core/progress.py +476 -0
  27. ddflow/core/schedule.py +726 -0
  28. ddflow/infra/__init__.py +0 -0
  29. ddflow/infra/container.py +136 -0
  30. ddflow/infra/forge.py +485 -0
  31. ddflow/infra/log.py +767 -0
  32. ddflow/infra/paths.py +54 -0
  33. ddflow/infra/proc.py +54 -0
  34. ddflow/infra/store.py +539 -0
  35. ddflow/infra/tomlcfg.py +152 -0
  36. ddflow/infra/worktree.py +654 -0
  37. ddflow/services/__init__.py +0 -0
  38. ddflow/services/adopt.py +950 -0
  39. ddflow/services/cadence.py +65 -0
  40. ddflow/services/choices.py +145 -0
  41. ddflow/services/claudehooks.py +174 -0
  42. ddflow/services/cleanup.py +178 -0
  43. ddflow/services/companions.py +560 -0
  44. ddflow/services/completion.py +157 -0
  45. ddflow/services/configwrite.py +356 -0
  46. ddflow/services/docsync.py +346 -0
  47. ddflow/services/enforce.py +625 -0
  48. ddflow/services/external.py +88 -0
  49. ddflow/services/flow.py +996 -0
  50. ddflow/services/gates.py +1313 -0
  51. ddflow/services/help.py +222 -0
  52. ddflow/services/importer.py +2234 -0
  53. ddflow/services/inventory.py +180 -0
  54. ddflow/services/jobs.py +141 -0
  55. ddflow/services/leases.py +605 -0
  56. ddflow/services/macros.py +157 -0
  57. ddflow/services/obligations.py +157 -0
  58. ddflow/services/ports.py +195 -0
  59. ddflow/services/promotions.py +169 -0
  60. ddflow/services/prompts.py +495 -0
  61. ddflow/services/prosepin.py +268 -0
  62. ddflow/services/rates.py +203 -0
  63. ddflow/services/review.py +920 -0
  64. ddflow/services/sessions.py +448 -0
  65. ddflow/services/workflow.py +322 -0
  66. ddflow/surfaces/__init__.py +0 -0
  67. ddflow/surfaces/cli.py +1075 -0
  68. ddflow/surfaces/commands/__init__.py +5 -0
  69. ddflow/surfaces/commands/config.py +46 -0
  70. ddflow/surfaces/commands/decisions.py +176 -0
  71. ddflow/surfaces/commands/flow.py +175 -0
  72. ddflow/surfaces/commands/gates.py +117 -0
  73. ddflow/surfaces/commands/knowledge.py +374 -0
  74. ddflow/surfaces/commands/lifecycle.py +274 -0
  75. ddflow/surfaces/commands/operations.py +243 -0
  76. ddflow/surfaces/commands/queue.py +147 -0
  77. ddflow/surfaces/commands/reporting.py +232 -0
  78. ddflow/surfaces/commands/review.py +151 -0
  79. ddflow/surfaces/commands/setup.py +459 -0
  80. ddflow/surfaces/commands/workflow.py +214 -0
  81. ddflow/surfaces/context.py +177 -0
  82. ddflow/surfaces/history_verbs.py +44 -0
  83. ddflow/surfaces/mcp.py +3198 -0
  84. ddflow/templates/commands/claude/implement.md +18 -0
  85. ddflow/templates/companions.toml +149 -0
  86. ddflow/templates/drivers/deltas/aider.md +33 -0
  87. ddflow/templates/drivers/deltas/antigravity.md +26 -0
  88. ddflow/templates/drivers/deltas/claude-code.md +32 -0
  89. ddflow/templates/drivers/deltas/cline.md +31 -0
  90. ddflow/templates/drivers/deltas/codex-cli.md +23 -0
  91. ddflow/templates/drivers/deltas/cody.md +29 -0
  92. ddflow/templates/drivers/deltas/cursor.md +43 -0
  93. ddflow/templates/drivers/deltas/devin.md +28 -0
  94. ddflow/templates/drivers/deltas/gemini-cli.md +24 -0
  95. ddflow/templates/drivers/deltas/github-copilot.md +36 -0
  96. ddflow/templates/drivers/deltas/goose.md +34 -0
  97. ddflow/templates/drivers/deltas/kilo-cline.md +24 -0
  98. ddflow/templates/drivers/deltas/kimi-code.md +26 -0
  99. ddflow/templates/drivers/deltas/opencode.md +26 -0
  100. ddflow/templates/drivers/deltas/openhands.md +32 -0
  101. ddflow/templates/drivers/deltas/qodo.md +30 -0
  102. ddflow/templates/drivers/deltas/qwen-code.md +28 -0
  103. ddflow/templates/drivers/deltas/replit.md +24 -0
  104. ddflow/templates/drivers/deltas/tabnine.md +29 -0
  105. ddflow/templates/drivers/deltas/vscode.md +30 -0
  106. ddflow/templates/drivers/deltas/windsurf.md +31 -0
  107. ddflow/templates/drivers/deltas/zcode-glm.md +24 -0
  108. ddflow/templates/drivers/implement-phase.md +234 -0
  109. ddflow/templates/prompts/commands/all-tests.md +50 -0
  110. ddflow/templates/prompts/commands/bug-hunt.md +40 -0
  111. ddflow/templates/prompts/commands/code-clean.md +45 -0
  112. ddflow/templates/prompts/commands/code-deduplication.md +29 -0
  113. ddflow/templates/prompts/commands/implement.md +78 -0
  114. ddflow/templates/prompts/commands/import-existing-project.md +70 -0
  115. ddflow/templates/prompts/commands/install-companions.md +66 -0
  116. ddflow/templates/prompts/commands/research-companions.md +70 -0
  117. ddflow/templates/prompts/gate_instruction.md +25 -0
  118. ddflow/templates/prompts/help/cli.md +61 -0
  119. ddflow/templates/prompts/help/config.md +26 -0
  120. ddflow/templates/prompts/help/customise.md +57 -0
  121. ddflow/templates/prompts/help/gates.md +39 -0
  122. ddflow/templates/prompts/help/import.md +38 -0
  123. ddflow/templates/prompts/help/index.md +50 -0
  124. ddflow/templates/prompts/help/mcp.md +56 -0
  125. ddflow/templates/prompts/help/memory.md +30 -0
  126. ddflow/templates/prompts/help/parallel.md +50 -0
  127. ddflow/templates/prompts/help/recovery.md +35 -0
  128. ddflow/templates/prompts/help/workflow.md +36 -0
  129. ddflow/templates/prompts/mcp_instructions.md +286 -0
  130. ddflow/templates/prompts/review_system.md +29 -0
  131. ddflow/templates/prompts/review_user.md +15 -0
  132. ddflow/templates/prompts/session_brief_header.md +8 -0
  133. ddflow/views/__init__.py +0 -0
  134. ddflow/views/human.py +170 -0
  135. ddflow/views/markdown.py +516 -0
  136. ddflow_mcp-0.1.1.dist-info/METADATA +2637 -0
  137. ddflow_mcp-0.1.1.dist-info/RECORD +140 -0
  138. ddflow_mcp-0.1.1.dist-info/WHEEL +4 -0
  139. ddflow_mcp-0.1.1.dist-info/entry_points.txt +3 -0
  140. ddflow_mcp-0.1.1.dist-info/licenses/LICENSE +21 -0
ddflow/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """ddflow — a portable, agent-agnostic work-queue kernel for AI coding agents.
2
+
3
+ The whole system rests on one decision: **the append-only event log is the source of
4
+ truth**, and every other artefact (the SQLite index, the markdown boards, the lessons
5
+ search index, the session replay bundle) is a *derived projection* that can be thrown
6
+ away and re-folded from the log at any time.
7
+
8
+ Read `docs/ARCHITECTURE.md` for why, and `docs/RESEARCH.md` for the probes that
9
+ decided each choice.
10
+ """
11
+
12
+ #: One of the SIX places the version is declared; `scripts/bump.sh` moves them together
13
+ #: and `tests/test_packaging.py::test_the_declared_versions_agree` holds them equal.
14
+ #: This one sat at 0.1.0 through the 0.1.1 release because neither knew it existed.
15
+ __version__ = "0.1.1"
ddflow/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .surfaces.cli import main
2
+
3
+ raise SystemExit(main())
ddflow/api/__init__.py ADDED
@@ -0,0 +1,242 @@
1
+ """The application layer: one typed entry point per operation, for both surfaces.
2
+
3
+ `surfaces/mcp.py` reached `surfaces/cli.py` — a protocol adapter depending on a
4
+ presentation layer — and the edge was carried by **strings**. Typed MCP arguments were
5
+ flattened to argv, re-parsed by argparse, and the result recovered by scraping stdout
6
+ plus an exit code. Two costs are visible in the code itself:
7
+
8
+ * `mcp._opt(..., clearable=True)` exists only to rebuild the "absent vs empty"
9
+ distinction argv erased. `ddflow_update(id="X", needs="")` silently did nothing while
10
+ `ddflow update X --needs ""` cleared the field — and breaking a dependency cycle is
11
+ exactly the operation that needs it, and the one the loop detector tells you to do.
12
+ In a typed call `None` and `""` are simply different values and no helper is needed.
13
+ * `mcp._run_cli` swapped process-global `sys.stdout`/`sys.stderr` for every call — not
14
+ reentrant, which forecloses concurrency in a server for a tool whose entire purpose is
15
+ parallel agents. **Both are gone.** The migration completed on 2026-09-27: every one of
16
+ the 63 tools dispatches through this layer, `ARGV_TOOLS_CEILING` is 0, and `_run_cli`
17
+ was deleted with the last tool that needed it (B97). `surfaces/mcp.py` no longer imports
18
+ `surfaces/cli.py` at all, so the latent `mcp <-> cli` cycle is structurally impossible
19
+ rather than merely guarded.
20
+
21
+ Parity was held by ratchets where types would hold it structurally, and those ratchets
22
+ catch a *missing* flag, never a *changed encoding*.
23
+
24
+ **Migration, not a rewrite — and it is finished.** Each operation here is one a surface
25
+ used to implement inline, moved one family at a time across twelve slices, each with its
26
+ own mutation-verified probes. `ARGV_TOOLS_CEILING` went 62 -> 0 and may never rise: a new
27
+ tool goes through this layer. A big-bang port of ~60 commands would have been one
28
+ unreviewable change against a suite that cannot tell which half broke.
29
+
30
+ Every function takes plain values and returns an `Outcome` — one description of a
31
+ result, from which both the machine view (`data`) and the human view are derived. It is
32
+ never both surfaces describing the same thing independently, which is the shape that
33
+ produced the bug still commented in `cmd_complete`: a coverage gap printed in human
34
+ mode only, invisible to the agent reading JSON that most needed it.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ # Re-exported so `api.progress(...)` keeps working after the split. The surfaces reach
40
+ # this layer through the PACKAGE (`_api().progress`), never through a family module, so
41
+ # moving an operation between families is not a breaking change.
42
+ from ._base import _load
43
+ from .completion import completion_verdict
44
+ from .decisions import (
45
+ decision_add,
46
+ decision_applicable,
47
+ decision_list,
48
+ decision_search,
49
+ decision_show,
50
+ decision_supersede,
51
+ )
52
+ from .flow import (
53
+ flow_choose,
54
+ flow_show,
55
+ pr_status,
56
+ pr_sync,
57
+ promote_add,
58
+ promote_status,
59
+ version_cut,
60
+ version_show,
61
+ )
62
+ from .gates import Evidence as GateEvidence
63
+ from .gates import record as gate_record
64
+ from .gates import run as gate_run
65
+ from .gates import status as gate_status
66
+ from .gates import verify as gate_verify
67
+ from .items import DEFAULT_PRIORITY, ItemEdit, phase_add, resolve, split, task_add, update
68
+ from .jobs import job_add, job_end, job_list, job_run
69
+ from .knowledge import (
70
+ Finding as ResearchFinding,
71
+ )
72
+ from .knowledge import (
73
+ LessonDraft,
74
+ bug_fixed,
75
+ bug_found,
76
+ history,
77
+ lesson_add,
78
+ lesson_search,
79
+ lessons_verify,
80
+ memory_add,
81
+ memory_forget,
82
+ memory_list,
83
+ recall,
84
+ research_add,
85
+ session_end,
86
+ session_note,
87
+ session_prompt,
88
+ session_start,
89
+ )
90
+ from .lifecycle import (
91
+ DEFAULT_CHECK_RECOVERY,
92
+ DEFAULT_NEXT_KIND,
93
+ abandon,
94
+ block,
95
+ brief,
96
+ claim,
97
+ complete,
98
+ heartbeat,
99
+ unblock,
100
+ )
101
+ from .lifecycle import merge as merge_item
102
+ from .lifecycle import next_ as next_item
103
+ from .lifecycle import release as release_item
104
+ from .lifecycle import remove as remove_item
105
+ from .operations import cadence, cleanup, external_sync, import_project, import_verify, pins
106
+ from .reporting import (
107
+ DEFAULT_RENDER_DIR,
108
+ board,
109
+ doctor,
110
+ loops,
111
+ progress,
112
+ rebuild,
113
+ recover,
114
+ render,
115
+ replay,
116
+ show,
117
+ status,
118
+ )
119
+ from .review import review as run_review
120
+ from .review import reviewers_detect, reviewers_list
121
+
122
+ # `run_review`, not `review`: a name re-exported here SHADOWS the submodule of the same
123
+ # name, so `from ddflow.api import review` would bind the function and every
124
+ # `review.reviewers_list` would raise `'function' object has no attribute`. Caught by
125
+ # `test_no_reexport_shadows_a_submodule`, which exists because of this line.
126
+ from .setup import (
127
+ Adoption,
128
+ ConfigEdit,
129
+ Registration,
130
+ companions_add,
131
+ configure,
132
+ help_topic,
133
+ hooks,
134
+ prompts,
135
+ )
136
+ from .setup import companions as companions_list
137
+ from .setup import setup as adopt_project
138
+ from .workflow import GateEdit as WorkflowGateEdit
139
+ from .workflow import drop as workflow_drop
140
+ from .workflow import gate as workflow_gate
141
+ from .workflow import pipeline as workflow_pipeline
142
+ from .workflow import show as workflow_show
143
+
144
+ __all__ = [
145
+ "DEFAULT_CHECK_RECOVERY",
146
+ "DEFAULT_NEXT_KIND",
147
+ "DEFAULT_PRIORITY",
148
+ "DEFAULT_RENDER_DIR",
149
+ "Adoption",
150
+ "ConfigEdit",
151
+ "GateEvidence",
152
+ "ItemEdit",
153
+ "LessonDraft",
154
+ "Registration",
155
+ "ResearchFinding",
156
+ "WorkflowGateEdit",
157
+ "_load",
158
+ "abandon",
159
+ "adopt_project",
160
+ "block",
161
+ "board",
162
+ "brief",
163
+ "bug_fixed",
164
+ "bug_found",
165
+ "cadence",
166
+ "claim",
167
+ "cleanup",
168
+ "companions_add",
169
+ "companions_list",
170
+ "complete",
171
+ "completion_verdict",
172
+ "configure",
173
+ "decision_add",
174
+ "decision_applicable",
175
+ "decision_list",
176
+ "decision_search",
177
+ "decision_show",
178
+ "decision_supersede",
179
+ "doctor",
180
+ "external_sync",
181
+ "flow_choose",
182
+ "flow_show",
183
+ "gate_record",
184
+ "gate_run",
185
+ "gate_status",
186
+ "gate_verify",
187
+ "heartbeat",
188
+ "help_topic",
189
+ "history",
190
+ "hooks",
191
+ "import_project",
192
+ "import_verify",
193
+ "job_add",
194
+ "job_end",
195
+ "job_list",
196
+ "job_run",
197
+ "lesson_add",
198
+ "lesson_search",
199
+ "lessons_verify",
200
+ "loops",
201
+ "memory_add",
202
+ "memory_forget",
203
+ "memory_list",
204
+ "merge_item",
205
+ "next_item",
206
+ "phase_add",
207
+ "pins",
208
+ "pr_status",
209
+ "pr_sync",
210
+ "progress",
211
+ "promote_add",
212
+ "promote_status",
213
+ "prompts",
214
+ "rebuild",
215
+ "recall",
216
+ "recover",
217
+ "release_item",
218
+ "remove_item",
219
+ "render",
220
+ "replay",
221
+ "research_add",
222
+ "resolve",
223
+ "reviewers_detect",
224
+ "reviewers_list",
225
+ "run_review",
226
+ "session_end",
227
+ "session_note",
228
+ "session_prompt",
229
+ "session_start",
230
+ "show",
231
+ "split",
232
+ "status",
233
+ "task_add",
234
+ "unblock",
235
+ "update",
236
+ "version_cut",
237
+ "version_show",
238
+ "workflow_drop",
239
+ "workflow_gate",
240
+ "workflow_pipeline",
241
+ "workflow_show",
242
+ ]
ddflow/api/_base.py ADDED
@@ -0,0 +1,42 @@
1
+ """What every operation in this layer needs before it can answer anything.
2
+
3
+ Separate from `__init__` so a family module can import it without importing the
4
+ package that imports the family module. One file, one import edge, no cycle.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from ..config import Config
12
+ from ..core.model import State, fold
13
+ from ..infra.log import EventLog, resolve_agent_id
14
+
15
+
16
+ def _load(repo: Path, agent: str = "") -> tuple[EventLog, Config, State]:
17
+ """Config FIRST, then the log — because the identity depends on the config.
18
+
19
+ This used to be `EventLog(repo, agent)` with an empty `agent`, which falls to
20
+ `default_agent_id()` and reads neither `DDFLOW_AGENT` nor `[agent].id`. The argv
21
+ path resolved all four layers; this one resolved one. Same connection, same call,
22
+ two identities, two shards.
23
+ """
24
+ cfg = Config.load(repo)
25
+ # Resolved AND written back, exactly as `surfaces/context.Ctx` does. Resolving without
26
+ # writing back is a third copy of the same drift: `config --explain` reported
27
+ # `agent.id = "" [default]` from this path while the argv path reported the value the
28
+ # env var actually set. The identity is a fact about this invocation, and a config
29
+ # object that does not carry it is a config object two surfaces disagree about.
30
+ resolved, layer = resolve_agent_id(repo, cfg, agent)
31
+ if resolved != cfg.agent.id:
32
+ cfg.agent.id = resolved
33
+ # The layer that actually WON, not a guess from comparing values.
34
+ cfg.sources["agent.id"] = layer
35
+ log = EventLog(repo, resolved, lock_timeout_s=cfg.lease.acquire_timeout_s, log_cfg=cfg.log)
36
+ st = fold(log.read_all(), strict=False)
37
+ # Workflow choices recorded in the log fill in wherever the config file is silent --
38
+ # HERE, once, so every operation reads `cfg.flow.*` and sees the same answer.
39
+ from ..services.choices import overlay
40
+
41
+ overlay(cfg, st)
42
+ return log, cfg, st
@@ -0,0 +1,38 @@
1
+ """May this item complete, and did it?"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from ..core import outcome as O
9
+ from ._base import _load
10
+
11
+
12
+ def completion_verdict(repo: Path, item: str, *, model: str = "") -> O.Outcome:
13
+ """Would this item complete, and if not, why not? Reads only.
14
+
15
+ Exposed as its own operation because "may I" and "do it" are different questions,
16
+ and an agent that can only ask by *attempting* learns the answer by causing the
17
+ thing it was checking for.
18
+ """
19
+ from ..services import completion as CM
20
+
21
+ _log, cfg, st = _load(repo)
22
+ v = CM.verdict(st, cfg, item, repo=repo, model=model)
23
+ data: dict[str, Any] = {
24
+ "id": item,
25
+ "may_complete": v.may_complete,
26
+ "blockers": v.blockers,
27
+ "warnings": v.warnings,
28
+ "coverage_gaps": v.coverage_gaps,
29
+ "coverage_note": v.coverage_note,
30
+ "independence": v.independence,
31
+ }
32
+ if v.may_complete:
33
+ return O.ok("completion.verdict", **data)
34
+ return O.refused(
35
+ "completion.verdict",
36
+ f"{len(v.blockers)} unmet condition(s): " + "; ".join(v.blockers),
37
+ **data,
38
+ )
@@ -0,0 +1,205 @@
1
+ """Architectural decisions: record them, consult them, supersede them.
2
+
3
+ The CONSULT half is why this family is worth a typed layer. A decision nobody reads is
4
+ a filed document; `applicable()` is the mechanism that turns it into something an agent
5
+ about to write a set of paths is HANDED, without having to know it exists or guess a
6
+ search term. That only works if both surfaces can ask the same question and get the
7
+ same answer, which is what an `Outcome` per operation buys.
8
+
9
+ Every function here returns one. The human renderings live in
10
+ `surfaces/commands/decisions.py` and derive from `data` — they do not recompute.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from ..config import Config, csv_list
20
+ from ..core import outcome as O
21
+ from ..core.ids import auto_id
22
+ from ..core.plain import plain as _plain
23
+ from ..infra.store import Store
24
+ from ._base import _load
25
+
26
+
27
+ def _store(repo: Path, log, cfg: Config) -> Store:
28
+ st = Store(repo, cfg)
29
+ st.ensure(log)
30
+ return st
31
+
32
+
33
+ @dataclass
34
+ class Draft:
35
+ """The decision record, named once.
36
+
37
+ These twelve fields were spelled out three times — argparse flags, the MCP input
38
+ schema, and the event payload — and a field added to one of them is a field the
39
+ other two silently drop. A dataclass with defaults also answers the lint that
40
+ `decision_add(repo, title, decision, context, consequences, alternatives, globs,
41
+ tags, sources, status, by, item, supersedes, ...)` was a 15-argument function: the
42
+ argument count was a symptom of the missing type, not a reason to shorten the name.
43
+
44
+ `globs`, `tags`, `sources` and `supersedes` arrive as comma-separated strings
45
+ because that is what both an argparse flag and a JSON string field give you;
46
+ `csv_list` is the ONE parser for that notation.
47
+ """
48
+
49
+ title: str
50
+ decision: str = ""
51
+ id: str = ""
52
+ context: str = ""
53
+ consequences: str = ""
54
+ alternatives: str = ""
55
+ globs: str = ""
56
+ tags: str = ""
57
+ sources: str = ""
58
+ status: str = "accepted"
59
+ by: str = ""
60
+ item: str = ""
61
+ supersedes: str = ""
62
+
63
+
64
+ def decision_add(repo: Path, draft: Draft, *, agent: str = "") -> O.Outcome:
65
+ """Record a decision. Refuses without a stated DECISION, not merely a discussion.
66
+
67
+ The refusal is the point: a record saying what was *discussed* is indistinguishable
68
+ from notes, and the next agent cannot act on it.
69
+ """
70
+ if not draft.decision:
71
+ return O.failed(
72
+ "decision.recorded",
73
+ "--decision is required: the record must say what was DECIDED, not only "
74
+ "what was discussed.",
75
+ )
76
+ log, _cfg, _st = _load(repo, agent)
77
+ did = draft.id or auto_id("D", draft.title, draft.decision)
78
+ fields: dict[str, Any] = {
79
+ "title": draft.title,
80
+ "context": draft.context,
81
+ "decision": draft.decision,
82
+ "consequences": draft.consequences,
83
+ "alternatives": draft.alternatives,
84
+ "globs": csv_list(draft.globs),
85
+ "tags": csv_list(draft.tags),
86
+ "sources": csv_list(draft.sources),
87
+ "status": draft.status,
88
+ "decided_by": draft.by,
89
+ "item": draft.item,
90
+ "supersedes": csv_list(draft.supersedes),
91
+ }
92
+ log.append("decision.recorded", did, fields)
93
+ return O.ok(
94
+ "decision.recorded",
95
+ id=did,
96
+ status=draft.status,
97
+ supersedes=fields["supersedes"],
98
+ # Carried so BOTH surfaces can warn. A decision with no globs cannot be
99
+ # surfaced automatically to an agent working the code it governs -- it will
100
+ # only ever be found by someone who already went looking. That warning used to
101
+ # print in human mode only.
102
+ ungoverned=not fields["globs"],
103
+ )
104
+
105
+
106
+ def decision_supersede(
107
+ repo: Path, item: str, *, by: str = "", reason: str = "", agent: str = ""
108
+ ) -> O.Outcome:
109
+ """Replace a decision. Never deletes one: how the architecture got here is the part
110
+ a rebuild most needs."""
111
+ log, _cfg, st = _load(repo, agent)
112
+ if item not in st.decisions:
113
+ return O.failed("decision.superseded", f"no such decision {item!r}", id=item)
114
+ if not by:
115
+ return O.failed(
116
+ "decision.superseded",
117
+ "--by <new decision id> is required: a decision is never simply deleted, "
118
+ "it is replaced by one that says what is true now.",
119
+ id=item,
120
+ )
121
+ log.append("decision.superseded", item, {"by": by, "reason": reason})
122
+ # Built directly: `reason` is a WIRE field here (why the decision was replaced) and
123
+ # also the Outcome's own, and the helpers refuse to guess which one you meant.
124
+ return O.Outcome(kind="decision.superseded", data={"id": item, "by": by, "reason": reason})
125
+
126
+
127
+ def decision_show(repo: Path, item: str) -> O.Outcome:
128
+ """One decision in full. The wire body is the decision object itself."""
129
+ _log, _cfg, st = _load(repo)
130
+ d = st.decisions.get(item)
131
+ if not d:
132
+ return O.failed("decision.show", f"no such decision {item!r}", id=item, decision=None)
133
+ return O.ok("decision.show", id=item, decision=_plain(d))
134
+
135
+
136
+ def decision_applicable(repo: Path, item: str) -> O.Outcome:
137
+ """Decisions governing an item's declared files.
138
+
139
+ Exit 2 when nothing governs it: "no decision applies" is an answer, and reporting
140
+ it as success is how a caller comes to believe it consulted something.
141
+ """
142
+ from ..core.schedule import conflicts
143
+
144
+ _log, _cfg, st = _load(repo)
145
+ it = st.items.get(item)
146
+ if it is None or it.removed:
147
+ gone = " (it was removed from the queue)" if it is not None else ""
148
+ return O.failed(
149
+ "decision.applicable",
150
+ f"no such item {item!r}{gone}",
151
+ id=item,
152
+ applicable=[],
153
+ project_wide=[],
154
+ )
155
+ hits = [d for d in st.decisions.values() if d.live and d.globs and conflicts(it.globs, d.globs)]
156
+ wide = [d for d in st.decisions.values() if d.live and not d.globs]
157
+ data: dict[str, Any] = {
158
+ "id": item,
159
+ "globs": list(it.globs),
160
+ "applicable": [_plain(d) for d in hits],
161
+ "project_wide": [_plain(d) for d in wide],
162
+ }
163
+ if not hits and not wide:
164
+ return O.nothing(
165
+ "decision.applicable",
166
+ f"No architectural decisions govern {item}'s files "
167
+ f"({', '.join(it.globs) or 'no globs declared'}).",
168
+ **data,
169
+ )
170
+ return O.ok("decision.applicable", **data)
171
+
172
+
173
+ def decision_search(repo: Path, query: str, *, limit: int = 20) -> O.Outcome:
174
+ """Free-text search across decisions."""
175
+ log, cfg, _st = _load(repo)
176
+ hits = _store(repo, log, cfg).search("decisions", query, limit)
177
+ data: dict[str, Any] = {"query": query, "hits": hits, "count": len(hits)}
178
+ if not hits:
179
+ return O.nothing("decision.search", "no matching decisions", **data)
180
+ return O.ok("decision.search", **data)
181
+
182
+
183
+ def decision_list(repo: Path, *, all: bool = False) -> O.Outcome:
184
+ """Decisions in force; `all` includes the superseded ones."""
185
+ _log, _cfg, st = _load(repo)
186
+ live = [d for d in st.decisions.values() if d.live]
187
+ dead = [d for d in st.decisions.values() if not d.live]
188
+ rows = live + dead if all else live
189
+ data: dict[str, Any] = {
190
+ "rows": [_plain(d) for d in sorted(rows, key=lambda x: x.at)],
191
+ "live": len(live),
192
+ # How many exist but were not shown -- so the human renderer can say so without
193
+ # folding a second time, and a machine caller can tell "none recorded" from
194
+ # "none in force".
195
+ "hidden": 0 if all else len(dead),
196
+ "all": all,
197
+ }
198
+ if not rows:
199
+ return O.nothing(
200
+ "decision.list",
201
+ "No architectural decisions recorded.\n"
202
+ " ddflow decision add --title '...' --decision '...' --globs 'src/x/*'",
203
+ **data,
204
+ )
205
+ return O.ok("decision.list", **data)