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.
- ddflow/__init__.py +15 -0
- ddflow/__main__.py +3 -0
- ddflow/api/__init__.py +242 -0
- ddflow/api/_base.py +42 -0
- ddflow/api/completion.py +38 -0
- ddflow/api/decisions.py +205 -0
- ddflow/api/flow.py +231 -0
- ddflow/api/gates.py +407 -0
- ddflow/api/items.py +611 -0
- ddflow/api/jobs.py +204 -0
- ddflow/api/knowledge.py +572 -0
- ddflow/api/lifecycle.py +880 -0
- ddflow/api/operations.py +347 -0
- ddflow/api/reporting.py +535 -0
- ddflow/api/review.py +298 -0
- ddflow/api/setup.py +716 -0
- ddflow/api/workflow.py +258 -0
- ddflow/config.py +1218 -0
- ddflow/core/__init__.py +0 -0
- ddflow/core/events.py +136 -0
- ddflow/core/flow.py +471 -0
- ddflow/core/ids.py +28 -0
- ddflow/core/model.py +1582 -0
- ddflow/core/outcome.py +162 -0
- ddflow/core/plain.py +28 -0
- ddflow/core/progress.py +476 -0
- ddflow/core/schedule.py +726 -0
- ddflow/infra/__init__.py +0 -0
- ddflow/infra/container.py +136 -0
- ddflow/infra/forge.py +485 -0
- ddflow/infra/log.py +767 -0
- ddflow/infra/paths.py +54 -0
- ddflow/infra/proc.py +54 -0
- ddflow/infra/store.py +539 -0
- ddflow/infra/tomlcfg.py +152 -0
- ddflow/infra/worktree.py +654 -0
- ddflow/services/__init__.py +0 -0
- ddflow/services/adopt.py +950 -0
- ddflow/services/cadence.py +65 -0
- ddflow/services/choices.py +145 -0
- ddflow/services/claudehooks.py +174 -0
- ddflow/services/cleanup.py +178 -0
- ddflow/services/companions.py +560 -0
- ddflow/services/completion.py +157 -0
- ddflow/services/configwrite.py +356 -0
- ddflow/services/docsync.py +346 -0
- ddflow/services/enforce.py +625 -0
- ddflow/services/external.py +88 -0
- ddflow/services/flow.py +996 -0
- ddflow/services/gates.py +1313 -0
- ddflow/services/help.py +222 -0
- ddflow/services/importer.py +2234 -0
- ddflow/services/inventory.py +180 -0
- ddflow/services/jobs.py +141 -0
- ddflow/services/leases.py +605 -0
- ddflow/services/macros.py +157 -0
- ddflow/services/obligations.py +157 -0
- ddflow/services/ports.py +195 -0
- ddflow/services/promotions.py +169 -0
- ddflow/services/prompts.py +495 -0
- ddflow/services/prosepin.py +268 -0
- ddflow/services/rates.py +203 -0
- ddflow/services/review.py +920 -0
- ddflow/services/sessions.py +448 -0
- ddflow/services/workflow.py +322 -0
- ddflow/surfaces/__init__.py +0 -0
- ddflow/surfaces/cli.py +1075 -0
- ddflow/surfaces/commands/__init__.py +5 -0
- ddflow/surfaces/commands/config.py +46 -0
- ddflow/surfaces/commands/decisions.py +176 -0
- ddflow/surfaces/commands/flow.py +175 -0
- ddflow/surfaces/commands/gates.py +117 -0
- ddflow/surfaces/commands/knowledge.py +374 -0
- ddflow/surfaces/commands/lifecycle.py +274 -0
- ddflow/surfaces/commands/operations.py +243 -0
- ddflow/surfaces/commands/queue.py +147 -0
- ddflow/surfaces/commands/reporting.py +232 -0
- ddflow/surfaces/commands/review.py +151 -0
- ddflow/surfaces/commands/setup.py +459 -0
- ddflow/surfaces/commands/workflow.py +214 -0
- ddflow/surfaces/context.py +177 -0
- ddflow/surfaces/history_verbs.py +44 -0
- ddflow/surfaces/mcp.py +3198 -0
- ddflow/templates/commands/claude/implement.md +18 -0
- ddflow/templates/companions.toml +149 -0
- ddflow/templates/drivers/deltas/aider.md +33 -0
- ddflow/templates/drivers/deltas/antigravity.md +26 -0
- ddflow/templates/drivers/deltas/claude-code.md +32 -0
- ddflow/templates/drivers/deltas/cline.md +31 -0
- ddflow/templates/drivers/deltas/codex-cli.md +23 -0
- ddflow/templates/drivers/deltas/cody.md +29 -0
- ddflow/templates/drivers/deltas/cursor.md +43 -0
- ddflow/templates/drivers/deltas/devin.md +28 -0
- ddflow/templates/drivers/deltas/gemini-cli.md +24 -0
- ddflow/templates/drivers/deltas/github-copilot.md +36 -0
- ddflow/templates/drivers/deltas/goose.md +34 -0
- ddflow/templates/drivers/deltas/kilo-cline.md +24 -0
- ddflow/templates/drivers/deltas/kimi-code.md +26 -0
- ddflow/templates/drivers/deltas/opencode.md +26 -0
- ddflow/templates/drivers/deltas/openhands.md +32 -0
- ddflow/templates/drivers/deltas/qodo.md +30 -0
- ddflow/templates/drivers/deltas/qwen-code.md +28 -0
- ddflow/templates/drivers/deltas/replit.md +24 -0
- ddflow/templates/drivers/deltas/tabnine.md +29 -0
- ddflow/templates/drivers/deltas/vscode.md +30 -0
- ddflow/templates/drivers/deltas/windsurf.md +31 -0
- ddflow/templates/drivers/deltas/zcode-glm.md +24 -0
- ddflow/templates/drivers/implement-phase.md +234 -0
- ddflow/templates/prompts/commands/all-tests.md +50 -0
- ddflow/templates/prompts/commands/bug-hunt.md +40 -0
- ddflow/templates/prompts/commands/code-clean.md +45 -0
- ddflow/templates/prompts/commands/code-deduplication.md +29 -0
- ddflow/templates/prompts/commands/implement.md +78 -0
- ddflow/templates/prompts/commands/import-existing-project.md +70 -0
- ddflow/templates/prompts/commands/install-companions.md +66 -0
- ddflow/templates/prompts/commands/research-companions.md +70 -0
- ddflow/templates/prompts/gate_instruction.md +25 -0
- ddflow/templates/prompts/help/cli.md +61 -0
- ddflow/templates/prompts/help/config.md +26 -0
- ddflow/templates/prompts/help/customise.md +57 -0
- ddflow/templates/prompts/help/gates.md +39 -0
- ddflow/templates/prompts/help/import.md +38 -0
- ddflow/templates/prompts/help/index.md +50 -0
- ddflow/templates/prompts/help/mcp.md +56 -0
- ddflow/templates/prompts/help/memory.md +30 -0
- ddflow/templates/prompts/help/parallel.md +50 -0
- ddflow/templates/prompts/help/recovery.md +35 -0
- ddflow/templates/prompts/help/workflow.md +36 -0
- ddflow/templates/prompts/mcp_instructions.md +286 -0
- ddflow/templates/prompts/review_system.md +29 -0
- ddflow/templates/prompts/review_user.md +15 -0
- ddflow/templates/prompts/session_brief_header.md +8 -0
- ddflow/views/__init__.py +0 -0
- ddflow/views/human.py +170 -0
- ddflow/views/markdown.py +516 -0
- ddflow_mcp-0.1.1.dist-info/METADATA +2637 -0
- ddflow_mcp-0.1.1.dist-info/RECORD +140 -0
- ddflow_mcp-0.1.1.dist-info/WHEEL +4 -0
- ddflow_mcp-0.1.1.dist-info/entry_points.txt +3 -0
- 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
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
|
ddflow/api/completion.py
ADDED
|
@@ -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
|
+
)
|
ddflow/api/decisions.py
ADDED
|
@@ -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)
|