marcus-toolkit 0.1.0__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.
- marcus_toolkit/__init__.py +27 -0
- marcus_toolkit/agent.py +451 -0
- marcus_toolkit/candidates.py +489 -0
- marcus_toolkit/client.py +319 -0
- marcus_toolkit/funnel.py +154 -0
- marcus_toolkit/gate.py +150 -0
- marcus_toolkit/integrity.py +251 -0
- marcus_toolkit/livecheck.py +223 -0
- marcus_toolkit/measure.py +197 -0
- marcus_toolkit/pricing.py +96 -0
- marcus_toolkit/record.py +84 -0
- marcus_toolkit/sampler.py +169 -0
- marcus_toolkit/seed.py +189 -0
- marcus_toolkit/server.py +348 -0
- marcus_toolkit/tender.py +369 -0
- marcus_toolkit/tending.py +946 -0
- marcus_toolkit/usage.py +222 -0
- marcus_toolkit/views.py +150 -0
- marcus_toolkit/watchdog.py +264 -0
- marcus_toolkit-0.1.0.dist-info/METADATA +98 -0
- marcus_toolkit-0.1.0.dist-info/RECORD +25 -0
- marcus_toolkit-0.1.0.dist-info/WHEEL +5 -0
- marcus_toolkit-0.1.0.dist-info/entry_points.txt +5 -0
- marcus_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- marcus_toolkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The candidate lifecycle: agents ratify, the controller promotes.
|
|
3
|
+
|
|
4
|
+
Decided by Larry on 2026-11-06 for the Minecraft experiment
|
|
5
|
+
(``minecraft-spike/DESIGN-ratified-growth.md``) and lifted here unchanged in
|
|
6
|
+
its rules. A coordinator does not grow the task graph by itself. What it would
|
|
7
|
+
have added as a task is posted as a *candidate*: a marker under the reserved
|
|
8
|
+
subject prefix ``candidate.`` whose text carries the task spec, visible to
|
|
9
|
+
every agent like any other opinion. An agent supports a candidate by posting
|
|
10
|
+
the same subject on its own report or opinion, with its evidence; it objects
|
|
11
|
+
with a text beginning ``OBJECT:``; it abstains with ``NO EVIDENCE:``. A
|
|
12
|
+
candidate is promoted to a task when two distinct agents support it and
|
|
13
|
+
supporters outnumber objectors; it evaporates once three distinct agents have
|
|
14
|
+
been shown it and declined it. At most crew-size candidates are open at once.
|
|
15
|
+
The controller's own posting never counts, and only the controller closes one.
|
|
16
|
+
|
|
17
|
+
Everything here is a pure rule over the entries that
|
|
18
|
+
``query_project_history(..., "opinions")`` returns, so it is tested on its
|
|
19
|
+
own; :mod:`marcus_toolkit.tending` applies it against a live board.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from datetime import UTC, datetime
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
CANDIDATE_PREFIX = "candidate."
|
|
32
|
+
PROPOSAL_PREFIX = "propose."
|
|
33
|
+
#: "Only two agents need to support a promotion." (Larry, 2026-11-06)
|
|
34
|
+
THRESHOLD = 2
|
|
35
|
+
#: The superseded seconds window (Larry, 2026-11-06, "evaporation after 4.5
|
|
36
|
+
#: minutes"), kept so the runs measured with it stay reproducible.
|
|
37
|
+
EVAPORATION_SECONDS = 270
|
|
38
|
+
#: "views, k=3" (Larry, 2026-11-06): a candidate evaporates once this many
|
|
39
|
+
#: distinct agents have been shown it and then had a turn in which they could
|
|
40
|
+
#: have supported it and did not. A candidate no agent has declined never
|
|
41
|
+
#: evaporates, so "open" is a terminal state in the record.
|
|
42
|
+
DECLINES_NEEDED = 3
|
|
43
|
+
#: A candidate born from a corroborated observation carries its observers as
|
|
44
|
+
#: supporters and promotes once this many seconds pass with no objection.
|
|
45
|
+
VETO_SECONDS = 120
|
|
46
|
+
POST_MARK = "CANDIDATE: "
|
|
47
|
+
OBJECT_MARK = "OBJECT:"
|
|
48
|
+
#: How an agent says it cannot judge a candidate: neither support nor an
|
|
49
|
+
#: objection, and a decline the moment it is written.
|
|
50
|
+
ABSTAIN_MARK = "NO EVIDENCE:"
|
|
51
|
+
#: What the controller posts on a candidate's own subject once it is closed.
|
|
52
|
+
CLOSE_MARK = "CLOSED:"
|
|
53
|
+
#: What became of a promoted candidate's task, posted by the controller on the
|
|
54
|
+
#: candidate's subject whenever it changes: ``TASK: open``, ``TASK: held by
|
|
55
|
+
#: <agent>``, ``TASK: blocked``, ``TASK: done``.
|
|
56
|
+
TASK_MARK = "TASK:"
|
|
57
|
+
TASK_STATES = ("open", "held", "blocked", "done")
|
|
58
|
+
#: The label every promoted or proposed task carries, so a record can tell
|
|
59
|
+
#: seeded work from work the crew ratified.
|
|
60
|
+
PLANNED_LABEL = "planned"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def task_state(task: dict[str, Any]) -> tuple[str, str | None]:
|
|
64
|
+
"""
|
|
65
|
+
Read one of ``TASK_STATES`` and the holder off a board task row.
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
task : Dict[str, Any]
|
|
70
|
+
A task row with ``status`` and ``assigned_to``.
|
|
71
|
+
|
|
72
|
+
Returns
|
|
73
|
+
-------
|
|
74
|
+
Tuple[str, Optional[str]]
|
|
75
|
+
``("done", None)``, ``("blocked", None)``, ``("held", agent)`` for a
|
|
76
|
+
task in progress or assigned, else ``("open", None)``.
|
|
77
|
+
"""
|
|
78
|
+
status = str(task.get("status") or "").strip().lower()
|
|
79
|
+
holder = str(task.get("assigned_to") or "").strip() or None
|
|
80
|
+
if status in ("done", "completed"):
|
|
81
|
+
return "done", None
|
|
82
|
+
if status == "blocked":
|
|
83
|
+
return "blocked", None
|
|
84
|
+
if status == "in_progress" or holder:
|
|
85
|
+
return "held", holder
|
|
86
|
+
return "open", None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def task_line(state: str, held_by: str | None = None) -> str:
|
|
90
|
+
"""Render the controller's status line: ``TASK: held by Ash``, ``TASK: blocked``."""
|
|
91
|
+
if state == "held" and held_by:
|
|
92
|
+
return f"{TASK_MARK} held by {held_by}"
|
|
93
|
+
return f"{TASK_MARK} {state}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def parse_task_line(text: str) -> tuple[str, str | None] | None:
|
|
97
|
+
"""
|
|
98
|
+
Read the state and holder a ``TASK:`` line carries, or ``None`` for other text.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
text : str
|
|
103
|
+
An opinion's text.
|
|
104
|
+
|
|
105
|
+
Returns
|
|
106
|
+
-------
|
|
107
|
+
Optional[Tuple[str, Optional[str]]]
|
|
108
|
+
``("held", "Ash")``, ``("blocked", None)``; a line with an unknown
|
|
109
|
+
state gives ``None``, so a typo never reads as a status.
|
|
110
|
+
"""
|
|
111
|
+
body = str(text or "").strip()
|
|
112
|
+
if not body.upper().startswith(TASK_MARK):
|
|
113
|
+
return None
|
|
114
|
+
rest = body[len(TASK_MARK) :].strip()
|
|
115
|
+
if rest.lower().startswith("held by "):
|
|
116
|
+
holder = rest[len("held by ") :].strip()
|
|
117
|
+
return ("held", holder or None)
|
|
118
|
+
state = rest.lower()
|
|
119
|
+
return (state, None) if state in TASK_STATES else None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class Candidate:
|
|
124
|
+
"""One open candidate as read off the board."""
|
|
125
|
+
|
|
126
|
+
subject: str
|
|
127
|
+
spec: dict[str, Any]
|
|
128
|
+
posted_at: str
|
|
129
|
+
supporters: list[str] = field(default_factory=list)
|
|
130
|
+
objectors: list[str] = field(default_factory=list)
|
|
131
|
+
#: Agents whose agreement on an observation produced the candidate; they
|
|
132
|
+
#: count as supporters, and support born this way waits the veto window.
|
|
133
|
+
observers: list[str] = field(default_factory=list)
|
|
134
|
+
#: Agents that answered ``NO EVIDENCE:``.
|
|
135
|
+
abstainers: list[str] = field(default_factory=list)
|
|
136
|
+
#: When each agent's current answer landed, by agent.
|
|
137
|
+
answered_at: dict[str, str] = field(default_factory=dict)
|
|
138
|
+
|
|
139
|
+
def support_complete_at(self) -> str | None:
|
|
140
|
+
"""
|
|
141
|
+
Return when the last supporter needed to reach the threshold posted.
|
|
142
|
+
|
|
143
|
+
The settling window runs from here, not from the posting.
|
|
144
|
+
|
|
145
|
+
Returns
|
|
146
|
+
-------
|
|
147
|
+
Optional[str]
|
|
148
|
+
The timestamp, or ``None`` when support is short of the threshold
|
|
149
|
+
or the record carries no times.
|
|
150
|
+
"""
|
|
151
|
+
observers = set(self.observers)
|
|
152
|
+
times = sorted(
|
|
153
|
+
self.answered_at[agent]
|
|
154
|
+
for agent in self.supporters
|
|
155
|
+
if agent not in observers and self.answered_at.get(agent)
|
|
156
|
+
)
|
|
157
|
+
return times[THRESHOLD - 1] if len(times) >= THRESHOLD else None
|
|
158
|
+
|
|
159
|
+
def age_seconds(self, now: str) -> float:
|
|
160
|
+
"""Return the seconds between the controller's posting and ``now``."""
|
|
161
|
+
return (_when(now) - _when(self.posted_at)).total_seconds()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _when(value: str) -> datetime:
|
|
165
|
+
text = str(value).replace("Z", "+00:00")
|
|
166
|
+
when = datetime.fromisoformat(text)
|
|
167
|
+
return when if when.tzinfo else when.replace(tzinfo=UTC)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _slug(name: str) -> str:
|
|
171
|
+
return ".".join(t for t in re.split(r"[^a-z0-9]+", str(name).lower()) if t)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def candidate_subject(name: str) -> str:
|
|
175
|
+
"""Return the reserved subject a candidate with this name is posted under."""
|
|
176
|
+
return CANDIDATE_PREFIX + _slug(name)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def post_text(spec: dict[str, Any]) -> str:
|
|
180
|
+
"""Render the controller's posting: the spec as JSON after the mark."""
|
|
181
|
+
return POST_MARK + json.dumps(spec, ensure_ascii=False)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def parse_candidate(
|
|
185
|
+
entry: dict[str, Any], controller_ids: set[str]
|
|
186
|
+
) -> Candidate | None:
|
|
187
|
+
"""
|
|
188
|
+
Read one opinions entry as a candidate, or ``None`` if it is not one.
|
|
189
|
+
|
|
190
|
+
Parameters
|
|
191
|
+
----------
|
|
192
|
+
entry : Dict[str, Any]
|
|
193
|
+
One entry from ``query_project_history(..., "opinions")``: ``subject``
|
|
194
|
+
and ``opinions`` (each with ``agent_id``, ``what``, ``timestamp``).
|
|
195
|
+
controller_ids : Set[str]
|
|
196
|
+
The controller processes' agent ids. Their postings define the
|
|
197
|
+
candidate and never count as support.
|
|
198
|
+
|
|
199
|
+
Returns
|
|
200
|
+
-------
|
|
201
|
+
Optional[Candidate]
|
|
202
|
+
The candidate with its supporters and objectors in order of first
|
|
203
|
+
appearance, each agent counted once; an agent's latest word wins.
|
|
204
|
+
A closed candidate parses as ``None``.
|
|
205
|
+
"""
|
|
206
|
+
subject = str(entry.get("subject") or "")
|
|
207
|
+
if not subject.startswith(CANDIDATE_PREFIX):
|
|
208
|
+
return None
|
|
209
|
+
opinions = sorted(
|
|
210
|
+
entry.get("opinions") or [], key=lambda o: str(o.get("timestamp") or "")
|
|
211
|
+
)
|
|
212
|
+
spec: dict[str, Any] | None = None
|
|
213
|
+
posted_at: str | None = None
|
|
214
|
+
for o in opinions:
|
|
215
|
+
if o.get("agent_id") in controller_ids and str(o.get("what") or "").startswith(
|
|
216
|
+
POST_MARK
|
|
217
|
+
):
|
|
218
|
+
try:
|
|
219
|
+
spec = json.loads(str(o["what"])[len(POST_MARK) :])
|
|
220
|
+
except json.JSONDecodeError:
|
|
221
|
+
continue
|
|
222
|
+
posted_at = str(o.get("timestamp"))
|
|
223
|
+
break
|
|
224
|
+
if spec is None or posted_at is None:
|
|
225
|
+
return None
|
|
226
|
+
if any(
|
|
227
|
+
o.get("agent_id") in controller_ids
|
|
228
|
+
and str(o.get("what") or "").strip().upper().startswith(CLOSE_MARK)
|
|
229
|
+
for o in opinions
|
|
230
|
+
):
|
|
231
|
+
return None
|
|
232
|
+
latest: dict[str, str] = {}
|
|
233
|
+
latest_at: dict[str, str] = {}
|
|
234
|
+
order: list[str] = []
|
|
235
|
+
for o in opinions:
|
|
236
|
+
agent = str(o.get("agent_id") or "")
|
|
237
|
+
if not agent or agent in controller_ids:
|
|
238
|
+
continue
|
|
239
|
+
if agent not in latest:
|
|
240
|
+
order.append(agent)
|
|
241
|
+
latest[agent] = str(o.get("what") or "").strip()
|
|
242
|
+
stamp = str(o.get("timestamp") or "").strip()
|
|
243
|
+
if stamp:
|
|
244
|
+
latest_at[agent] = stamp
|
|
245
|
+
|
|
246
|
+
def mark(agent: str) -> str:
|
|
247
|
+
said = latest[agent].strip().upper()
|
|
248
|
+
if said.startswith(OBJECT_MARK):
|
|
249
|
+
return "object"
|
|
250
|
+
if said.startswith(ABSTAIN_MARK):
|
|
251
|
+
return "abstain"
|
|
252
|
+
return "support"
|
|
253
|
+
|
|
254
|
+
marks = {a: mark(a) for a in order}
|
|
255
|
+
supporters = [a for a in order if marks[a] == "support"]
|
|
256
|
+
objectors = [a for a in order if marks[a] == "object"]
|
|
257
|
+
abstainers = [a for a in order if marks[a] == "abstain"]
|
|
258
|
+
observers = [
|
|
259
|
+
str(a)
|
|
260
|
+
for a in (spec.get("observers") or [])
|
|
261
|
+
if str(a) and str(a) not in controller_ids
|
|
262
|
+
]
|
|
263
|
+
for agent in observers:
|
|
264
|
+
answered = agent in supporters or agent in objectors or agent in abstainers
|
|
265
|
+
if not answered:
|
|
266
|
+
supporters.append(agent)
|
|
267
|
+
proposer = str(spec.get("proposer") or "")
|
|
268
|
+
if proposer and proposer not in controller_ids:
|
|
269
|
+
if (
|
|
270
|
+
proposer not in supporters
|
|
271
|
+
and proposer not in objectors
|
|
272
|
+
and proposer not in abstainers
|
|
273
|
+
):
|
|
274
|
+
supporters.insert(0, proposer)
|
|
275
|
+
proposed_at = str(spec.get("proposed_at") or "").strip()
|
|
276
|
+
if proposed_at and proposer not in latest_at:
|
|
277
|
+
latest_at[proposer] = proposed_at
|
|
278
|
+
return Candidate(
|
|
279
|
+
subject=subject,
|
|
280
|
+
spec=spec,
|
|
281
|
+
posted_at=posted_at,
|
|
282
|
+
supporters=supporters,
|
|
283
|
+
objectors=objectors,
|
|
284
|
+
observers=observers,
|
|
285
|
+
abstainers=abstainers,
|
|
286
|
+
answered_at=latest_at,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def decide(
|
|
291
|
+
candidate: Candidate,
|
|
292
|
+
now: str,
|
|
293
|
+
veto_seconds: int = VETO_SECONDS,
|
|
294
|
+
evaporation_seconds: int = EVAPORATION_SECONDS,
|
|
295
|
+
attention_seconds: float | None = None,
|
|
296
|
+
declined_by: int | None = None,
|
|
297
|
+
declines_needed: int = DECLINES_NEEDED,
|
|
298
|
+
awaiting: int | None = None,
|
|
299
|
+
settling_seconds: int = 0,
|
|
300
|
+
) -> str:
|
|
301
|
+
"""
|
|
302
|
+
Apply the fixed rule: ``"promote"``, ``"wait"`` or ``"evaporate"``.
|
|
303
|
+
|
|
304
|
+
A candidate promotes when at least two distinct agents support it and
|
|
305
|
+
supporters outnumber objectors (a tie does not carry; declines are not
|
|
306
|
+
votes against). Support born from observers promotes only after
|
|
307
|
+
``veto_seconds``. There is no clock-based promotion: silence never puts
|
|
308
|
+
work on the board.
|
|
309
|
+
|
|
310
|
+
Parameters
|
|
311
|
+
----------
|
|
312
|
+
candidate : Candidate
|
|
313
|
+
The candidate, with its supporters and objectors.
|
|
314
|
+
now : str
|
|
315
|
+
The current time, ISO-8601.
|
|
316
|
+
veto_seconds : int
|
|
317
|
+
The window before an observation-born candidate promotes.
|
|
318
|
+
evaporation_seconds : int
|
|
319
|
+
The superseded seconds window, used only when ``declined_by`` is ``None``.
|
|
320
|
+
attention_seconds : Optional[float]
|
|
321
|
+
The superseded attention clock.
|
|
322
|
+
declined_by : Optional[int]
|
|
323
|
+
Distinct agents that were shown the candidate and then had a turn in
|
|
324
|
+
which they could have supported it and did not. When given, this is
|
|
325
|
+
what evaporates a candidate and nothing else does.
|
|
326
|
+
declines_needed : int
|
|
327
|
+
How many declines evaporate a candidate.
|
|
328
|
+
awaiting : Optional[int]
|
|
329
|
+
Distinct agents that could still answer. At zero the candidate is
|
|
330
|
+
settled whichever way it falls, so a contested one cannot hang.
|
|
331
|
+
settling_seconds : int
|
|
332
|
+
How long after support reaches the threshold before promotion, so a
|
|
333
|
+
late objection can still land. An objection inside the window blocks.
|
|
334
|
+
|
|
335
|
+
Returns
|
|
336
|
+
-------
|
|
337
|
+
str
|
|
338
|
+
"""
|
|
339
|
+
age = candidate.age_seconds(now)
|
|
340
|
+
if declined_by is not None:
|
|
341
|
+
expired = declined_by >= declines_needed
|
|
342
|
+
else:
|
|
343
|
+
aged = attention_seconds if attention_seconds is not None else age
|
|
344
|
+
expired = aged > evaporation_seconds
|
|
345
|
+
exhausted = awaiting is not None and awaiting <= 0
|
|
346
|
+
observers = set(candidate.observers)
|
|
347
|
+
posted = {s for s in candidate.supporters if s not in observers}
|
|
348
|
+
objectors = len(set(candidate.objectors))
|
|
349
|
+
if len(posted) >= THRESHOLD and len(posted) > objectors:
|
|
350
|
+
if settling_seconds <= 0 or exhausted:
|
|
351
|
+
return "promote"
|
|
352
|
+
complete = candidate.support_complete_at()
|
|
353
|
+
if complete is None:
|
|
354
|
+
return "promote"
|
|
355
|
+
waited = (_when(now) - _when(complete)).total_seconds()
|
|
356
|
+
return "promote" if waited >= settling_seconds else "wait"
|
|
357
|
+
if (
|
|
358
|
+
len(set(candidate.supporters)) >= THRESHOLD
|
|
359
|
+
and len(set(candidate.supporters)) > objectors
|
|
360
|
+
):
|
|
361
|
+
return "promote" if age >= veto_seconds else "wait"
|
|
362
|
+
return "evaporate" if expired or exhausted else "wait"
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def can_post(open_count: int, crew: int) -> bool:
|
|
366
|
+
"""Return whether another candidate may open: at most the crew size at once."""
|
|
367
|
+
return open_count < crew
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def default_criterion(name: str) -> str:
|
|
371
|
+
"""Return the completion criterion a spec gets when it names none."""
|
|
372
|
+
return (
|
|
373
|
+
f"The condition described in '{name}' holds, and the completion report"
|
|
374
|
+
" says what was done and carries the evidence for it"
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def candidate_from_proposal(
|
|
379
|
+
entry: dict[str, Any], labels: Sequence[str] = (PLANNED_LABEL,)
|
|
380
|
+
) -> tuple[dict[str, Any], str]:
|
|
381
|
+
"""
|
|
382
|
+
Turn an agent's ``propose.*`` opinion into a candidate spec.
|
|
383
|
+
|
|
384
|
+
The proposer is the candidate's first supporter: the proposal is its
|
|
385
|
+
evidence. One more distinct agent promotes it.
|
|
386
|
+
|
|
387
|
+
Parameters
|
|
388
|
+
----------
|
|
389
|
+
entry : Dict[str, Any]
|
|
390
|
+
The ``propose.*`` opinions entry.
|
|
391
|
+
labels : Sequence[str]
|
|
392
|
+
The labels the task carries; never a ``build``-family label.
|
|
393
|
+
|
|
394
|
+
Returns
|
|
395
|
+
-------
|
|
396
|
+
Tuple[Dict[str, Any], str]
|
|
397
|
+
The seed spec and the proposer.
|
|
398
|
+
"""
|
|
399
|
+
subject = str(entry.get("subject") or "")
|
|
400
|
+
name = " ".join(subject[len(PROPOSAL_PREFIX) :].split(".")).strip() or "proposal"
|
|
401
|
+
opinions = sorted(
|
|
402
|
+
entry.get("opinions") or [], key=lambda o: str(o.get("timestamp") or "")
|
|
403
|
+
)
|
|
404
|
+
first = opinions[0] if opinions else {}
|
|
405
|
+
proposer = str(first.get("agent_id") or "")
|
|
406
|
+
description = str(first.get("what") or "").strip() or name
|
|
407
|
+
spec = {
|
|
408
|
+
"name": name,
|
|
409
|
+
"description": description,
|
|
410
|
+
"labels": list(labels),
|
|
411
|
+
"completion_criteria": [default_criterion(name)],
|
|
412
|
+
"estimated_hours": 0.4,
|
|
413
|
+
"priority": "medium",
|
|
414
|
+
"because": [subject],
|
|
415
|
+
# Not a create_tasks key, so it never reaches the board; it is the
|
|
416
|
+
# time the proposer's support landed, for the settling window.
|
|
417
|
+
"proposed_at": str(first.get("timestamp") or "").strip(),
|
|
418
|
+
}
|
|
419
|
+
return spec, proposer
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def candidate_from_observation(
|
|
423
|
+
entry: dict[str, Any],
|
|
424
|
+
build_seed: Callable[[dict[str, Any]], dict[str, Any] | None],
|
|
425
|
+
labels: Sequence[str] = (PLANNED_LABEL,),
|
|
426
|
+
) -> dict[str, Any] | None:
|
|
427
|
+
"""
|
|
428
|
+
Turn an observation subject two distinct agents posted on into a candidate spec.
|
|
429
|
+
|
|
430
|
+
Parameters
|
|
431
|
+
----------
|
|
432
|
+
entry : Dict[str, Any]
|
|
433
|
+
The observation's opinions entry, with ``agents``.
|
|
434
|
+
build_seed : Callable[[Dict[str, Any]], Dict[str, Any]]
|
|
435
|
+
The extension's rule for the task an observation becomes: its name,
|
|
436
|
+
description and fixed criteria. The toolkit has no domain to supply one.
|
|
437
|
+
labels : Sequence[str]
|
|
438
|
+
Labels every such task carries.
|
|
439
|
+
|
|
440
|
+
Returns
|
|
441
|
+
-------
|
|
442
|
+
Optional[Dict[str, Any]]
|
|
443
|
+
The spec, with the observers carried so they count as supporters and
|
|
444
|
+
the veto window applies; None when the extension has no card for it.
|
|
445
|
+
"""
|
|
446
|
+
built = build_seed(entry)
|
|
447
|
+
if built is None:
|
|
448
|
+
return None
|
|
449
|
+
spec = dict(built)
|
|
450
|
+
have = [str(label) for label in (spec.get("labels") or [])]
|
|
451
|
+
for label in labels:
|
|
452
|
+
if label not in have:
|
|
453
|
+
have.append(label)
|
|
454
|
+
spec["labels"] = have
|
|
455
|
+
spec["observers"] = sorted({str(a) for a in (entry.get("agents") or []) if str(a)})
|
|
456
|
+
spec["because"] = [str(entry.get("subject"))]
|
|
457
|
+
if not spec.get("completion_criteria"):
|
|
458
|
+
spec["completion_criteria"] = [default_criterion(str(spec.get("name") or ""))]
|
|
459
|
+
return spec
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def lineage_line(
|
|
463
|
+
spec: dict[str, Any], supporters: Iterable[str], authors: dict[str, list[str]]
|
|
464
|
+
) -> str:
|
|
465
|
+
"""
|
|
466
|
+
Render the paragraph a promoted task carries: its sources and its supporters.
|
|
467
|
+
|
|
468
|
+
Avoids the words plan, document, check, verify, test and resolve, which an
|
|
469
|
+
older kernel's phase classifier read as task phases.
|
|
470
|
+
|
|
471
|
+
Parameters
|
|
472
|
+
----------
|
|
473
|
+
spec : Dict[str, Any]
|
|
474
|
+
The candidate's spec; ``because`` names its sources.
|
|
475
|
+
supporters : Iterable[str]
|
|
476
|
+
Who supported it.
|
|
477
|
+
authors : Dict[str, List[str]]
|
|
478
|
+
Marker subject to its authors.
|
|
479
|
+
|
|
480
|
+
Returns
|
|
481
|
+
-------
|
|
482
|
+
str
|
|
483
|
+
"""
|
|
484
|
+
parts = []
|
|
485
|
+
for source in spec.get("because") or []:
|
|
486
|
+
who = authors.get(source)
|
|
487
|
+
parts.append(f"{source} ({', '.join(who)})" if who else str(source))
|
|
488
|
+
sources = ", ".join(parts) if parts else "the objective and the board"
|
|
489
|
+
return f"Lineage: from {sources}; supported by {', '.join(supporters)}"
|