Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion cli/src/limen/tabularius.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@

from limen.io import BoardCollapseError, load_limen_file, queue_lock, save_limen_file
from limen.materialize import EV_BOARD_META, EV_BOARD_ORDER, EV_TASK_UPSERT, Event, fold
from limen.models import LimenFile, Task
from limen.models import VALID_STATUSES, LimenFile, Task

# --- ticket intents (a superset of materialize's Event tags, plus the status convenience) --------
INTENT_UPSERT = "task.upsert" # create-or-merge a task field-set (patch may be full or partial)
Expand Down Expand Up @@ -176,6 +176,45 @@ def submit_task_upsert(
return submit_ticket(board_path, ticket)


def submit_task_status(
board_path: Path,
task_id: str,
*,
status: str,
agent: str,
session_id: str = "unknown",
output: str | None = None,
patch: dict[str, Any] | None = None,
now: datetime | None = None,
) -> Path:
"""One-line producer for status/result writers.

A dispatcher/harvester that used to mutate ``task.status`` and append a dispatch log can hand
the transition to TABVLARIVS instead. The optional ``patch`` is a field-level delta folded with
the status; it must not carry a conflicting status.
"""
if not task_id:
raise ValueError("task status requires a task_id")
if status not in VALID_STATUSES:
raise ValueError(f"status must be one of {', '.join(sorted(VALID_STATUSES))}")
fields = dict(patch or {})
if "status" in fields and fields["status"] != status:
raise ValueError("status patch conflicts with status argument")
fields["status"] = status
now = now or datetime.now(timezone.utc)
ticket = Ticket(
ticket_id=new_ticket_id(session_id, now),
timestamp=now,
agent=agent,
session_id=session_id,
intent=INTENT_STATUS,
task_id=task_id,
patch=fields,
log={"status": status, "output": output},
)
return submit_ticket(board_path, ticket)


@dataclass
class DrainResult:
"""The outcome of one drain pass — counts only (safe to log)."""
Expand Down
38 changes: 38 additions & 0 deletions cli/tests/test_tabularius.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
pending_count,
preserve_board_projection,
submit_ticket,
submit_task_status,
)

_NOW = datetime(2026, 7, 2, 12, 0, 0, tzinfo=timezone.utc)
Expand Down Expand Up @@ -156,6 +157,43 @@ def test_status_ticket_sets_status_and_appends_dispatch_log(tmp_path):
assert entry.agent == "claude" and entry.status == "done" and entry.output == "shipped PR #999"


def test_submit_task_status_emits_status_ticket(tmp_path):
board = _seed_board(tmp_path)
submit_task_status(
board,
"T-1",
status="failed",
agent="codex",
session_id="sess-status",
output="predicate failed",
patch={"priority": "high"},
now=_NOW,
)

result = drain_once(board)

assert result.applied == 1 and result.wrote is True
t1 = {t.id: t for t in load_limen_file(board).tasks}["T-1"]
assert t1.status == "failed"
assert t1.priority == "high"
assert len(t1.dispatch_log) == 1
assert t1.dispatch_log[0].agent == "codex"
assert t1.dispatch_log[0].session_id == "sess-status"
assert t1.dispatch_log[0].status == "failed"
assert t1.dispatch_log[0].output == "predicate failed"


def test_submit_task_status_rejects_invalid_or_conflicting_status(tmp_path):
import pytest

board = _seed_board(tmp_path)
with pytest.raises(ValueError, match="status must be one of"):
submit_task_status(board, "T-1", status="completed", agent="codex")
with pytest.raises(ValueError, match="conflicts"):
submit_task_status(board, "T-1", status="done", agent="codex", patch={"status": "failed"})
assert pending_count(board) == 0


def test_partial_patch_preserves_other_fields(tmp_path):
board = _seed_board(tmp_path)
# seed T-2 with a description, then patch only its priority — description must survive
Expand Down
10 changes: 7 additions & 3 deletions docs/tabularius-record-keeper.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,15 @@ archived ticket files are the append-only event log the board projects from.
| Piece | Where | Role |
|-------|-------|------|
| Engine | `cli/src/limen/tabularius.py` | `Ticket`, `submit_ticket()`, `submit_task_upsert()`, `drain_once()`, the fold/validate/seal + quarantine |
| Producer API | `cli/src/limen/tabularius.py` → `submit_task_upsert()` | the one-line conversion target: a writer swaps `save_limen_file` for this call per NEW task (validates up front, then hands the keeper an upsert ticket) |
| Producer API | `cli/src/limen/tabularius.py` → `submit_task_upsert()`, `submit_task_status()` | the one-line conversion target: a writer swaps `save_limen_file` for a keeper ticket per NEW task or status/result transition |
| Beat organ | `scripts/tabularius-organ.py` | thin per-beat wrapper (like `heal-board.py`); `--check`/`--dry-run`; writes the liveness stamp |
| Beat wiring | `scripts/heartbeat-loop.sh` | runs after `heal-board` (fold onto a *healthy* board), before the body's own mutation |
| Projection preservation | `limen.tabularius.preserve_board_projection()` | keeper-owned commit/push of the current `tasks.yaml` projection, under the queue lock, with a temporary git index so a push failure cannot strand the live checkout ahead |
| Writer audit | `scripts/task-writer-audit.py` | reports every remaining legacy direct board writer so the migration burns down explicitly instead of allowing another hidden writer |
| Proprioception | `scripts/organ-health.py` | a TABVLARIVS rung, green when `logs/tabularius-organ-state.json` is fresh |
| Keeper gate | `institutio/governance/parameters.yaml` → `LIMEN_TABVLARIVS` | master kill-switch for the keeper (default ON) |
| Cutover gate | `institutio/governance/parameters.yaml` → `LIMEN_TICKETS_PRODUCE` | flips converted writers from direct-write to producer (default **OFF** — a merge changes nothing live; the flip is the deliberate, revertible cutover) |
| Tests | `cli/tests/test_tabularius.py` | 16 tests: end-to-end submit→drain, ordering, quarantine, lock-deferral, collapse-guard, projection preservation, **and the producer≡direct-write identity invariant** |
| Tests | `cli/tests/test_tabularius.py` | focused tests: end-to-end submit→drain, ordering, quarantine, lock-deferral, collapse-guard, projection preservation, status-ticket validation, **and the producer≡direct-write identity invariant** |

### Safety invariants (each inherited from a shipped precedent)

Expand Down Expand Up @@ -95,7 +95,7 @@ archived ticket files are the append-only event log the board projects from.
it only ever emits brand-new ids. The rest (`generate-backlog`, `generate-revenue-backlog`,
`generate-organ-backlog`, `generate-positioning`, `discover-value`, `ingest-coverage`) are the
same one-line swap against the proven template.
2. **CLI harvest/dispatch result-apply** → emit a ticket instead of mutating the blob.
2. **CLI harvest/dispatch result-apply** → emit a `submit_task_status()` ticket instead of mutating the blob.
3. **MCP server** — replace the raw `yaml.dump` + git-push with a ticket append (removes the worst
offender: no lock, no atomic write, no collapse-guard, and its own drifted duplicate models).
4. **FastAPI + Worker endpoints** — enqueue a ticket and return; the keeper projects. This is the
Expand Down Expand Up @@ -135,6 +135,7 @@ above it is autonomous.
after producing tickets, so CI still commits the projection but the writer is the keeper.
`scripts/task-writer-audit.py` now reports 22 legacy direct writer calls (down from 29).
- [x] Step 2.2 owner-recorded — the status/result writer tier is no longer an implicit side channel.
`submit_task_status()` is the keeper API for status/result transitions, and
`scripts/task-writer-audit.py` now writes the tracked receipt
`docs/tabularius-writer-audit.md`, with every remaining direct writer mapped to a bounded owner
packet and zero unclassified rows. This does **not** mean the direct writers are burned down; it
Expand All @@ -158,6 +159,9 @@ above it is autonomous.
- [ ] Step 2.2E — `TAB-CREATION-FALLBACKS` and `TAB-MAINTENANCE-BOARD-FALLBACKS`: remove/gate legacy
fallback branches or explicitly move board-maintenance writers into the Tabularius/io allowlist.
Predicate: `python3 scripts/task-writer-audit.py`.
- [ ] Step 2.2F — `TAB-HUMAN-ATOM-STATUS-WRITERS`: convert dispatch-continuity and routine-freshness
`needs_human` atom refreshes to keeper-owned status/upsert tickets. Predicate:
`PYTHONPATH=cli/src python3 -m pytest cli/tests/test_tabularius.py -q`.
- [ ] Step 2.3 — MCP server → ticket producer (retire the raw write + duplicate models).
- [ ] Step 2.4 — live API/Worker tier (needs the consistency decision above; website-sensitive).
- [ ] Step 3 — flip SSOT to the event log; add an archive→`events.jsonl` compactor + a standing
Expand Down
24 changes: 12 additions & 12 deletions docs/tabularius-writer-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,35 @@
<!-- tabularius-writer-audit:owner-recorded -->

Direct writer calls: `24`
Unclassified calls: `2`
Unclassified calls: `0`

## Owner Packets

| Packet | Tier | Calls | Owner | Predicate | Disposition |
|---|---|---:|---|---|---|
| `TAB-CREATION-FALLBACKS` | `creation-fallback` | `2` | `codex-integrator` | `python3 scripts/task-writer-audit.py` | remove or gate legacy direct fallback branches after producer parity is proven live |
| `TAB-HUMAN-ATOM-STATUS-WRITERS` | `human-atom-status` | `2` | `codex-integrator` | `PYTHONPATH=cli/src python3 -m pytest cli/tests/test_tabularius.py -q` | convert continuity/routine needs_human atom upserts to keeper-owned status/upsert tickets |
| `TAB-MAINTENANCE-BOARD-FALLBACKS` | `board-maintenance` | `4` | `codex-integrator` | `python3 scripts/task-writer-audit.py` | decide whether each maintenance writer belongs to Tabularius/io allowlist or becomes a ticket producer |
| `TAB-ROUTE-RESIDUE-MUTATORS` | `routing-metadata` | `4` | `codex-integrator` | `PYTHONPATH=cli/src python3 -m pytest cli/tests/test_tabularius.py -q` | convert routing, residue, and self-improve board patches to keeper-owned tickets |
| `TAB-STATUS-ASYNC-HEAL` | `status-result` | `4` | `codex-integrator` | `PYTHONPATH=cli/src python3 -m pytest cli/tests/test_tabularius.py cli/tests/test_async_dispatch.py -q` | convert async reserve/reap/heal transitions to task.status tickets with no double-dispatch window |
| `TAB-STATUS-DISPATCH-RESULTS` | `status-result` | `5` | `codex-integrator` | `PYTHONPATH=cli/src python3 -m pytest cli/tests/test_tabularius.py -q` | convert dispatch claim/result application to task.status tickets or keeper-drained status batches |
| `TAB-STATUS-HARVEST-RESULTS` | `status-result` | `3` | `codex-integrator` | `PYTHONPATH=cli/src python3 -m pytest cli/tests/test_tabularius.py -q` | convert harvest/Jules landing result application to task.status tickets |
| `TAB-UNCLASSIFIED-WRITER` | `unclassified` | `2` | `codex-integrator` | `python3 scripts/task-writer-audit.py` | classify this writer before Step 2.2 can be owner-recorded |

## Direct Writers

| Path | Line | Call | Owner packet |
|---|---:|---|---|
| `cli/src/limen/cli.py` | `86` | `tasks_file.write_text` | `TAB-MAINTENANCE-BOARD-FALLBACKS` |
| `cli/src/limen/dispatch.py` | `2745` | `save_limen_file` | `TAB-STATUS-DISPATCH-RESULTS` |
| `cli/src/limen/dispatch.py` | `3304` | `save_limen_file` | `TAB-STATUS-DISPATCH-RESULTS` |
| `cli/src/limen/dispatch.py` | `3360` | `save_limen_file` | `TAB-STATUS-DISPATCH-RESULTS` |
| `cli/src/limen/dispatch.py` | `3444` | `save_limen_file` | `TAB-STATUS-DISPATCH-RESULTS` |
| `cli/src/limen/dispatch.py` | `3301` | `save_limen_file` | `TAB-STATUS-DISPATCH-RESULTS` |
| `cli/src/limen/dispatch.py` | `2756` | `save_limen_file` | `TAB-STATUS-DISPATCH-RESULTS` |
| `cli/src/limen/dispatch.py` | `3315` | `save_limen_file` | `TAB-STATUS-DISPATCH-RESULTS` |
| `cli/src/limen/dispatch.py` | `3371` | `save_limen_file` | `TAB-STATUS-DISPATCH-RESULTS` |
| `cli/src/limen/dispatch.py` | `3455` | `save_limen_file` | `TAB-STATUS-DISPATCH-RESULTS` |
| `cli/src/limen/dispatch.py` | `3312` | `save_limen_file` | `TAB-STATUS-DISPATCH-RESULTS` |
| `cli/src/limen/harvest.py` | `163` | `save_limen_file` | `TAB-STATUS-HARVEST-RESULTS` |
| `scripts/dispatch-async.py` | `325` | `save_limen_file` | `TAB-STATUS-ASYNC-HEAL` |
| `scripts/dispatch-async.py` | `750` | `save_limen_file` | `TAB-STATUS-ASYNC-HEAL` |
| `scripts/dispatch-async.py` | `492` | `save_limen_file` | `TAB-STATUS-ASYNC-HEAL` |
| `scripts/dispatch-continuity-check.py` | `334` | `save_limen_file` | `TAB-UNCLASSIFIED-WRITER` |
| `scripts/dispatch-async.py` | `328` | `save_limen_file` | `TAB-STATUS-ASYNC-HEAL` |
| `scripts/dispatch-async.py` | `799` | `save_limen_file` | `TAB-STATUS-ASYNC-HEAL` |
| `scripts/dispatch-async.py` | `495` | `save_limen_file` | `TAB-STATUS-ASYNC-HEAL` |
| `scripts/dispatch-continuity-check.py` | `334` | `save_limen_file` | `TAB-HUMAN-ATOM-STATUS-WRITERS` |
| `scripts/heal-board.py` | `251` | `atomic_write_text` | `TAB-MAINTENANCE-BOARD-FALLBACKS` |
| `scripts/heal-board.py` | `190` | `save_limen_file` | `TAB-MAINTENANCE-BOARD-FALLBACKS` |
| `scripts/heal-dispatch.py` | `147` | `save_limen_file` | `TAB-STATUS-ASYNC-HEAL` |
Expand All @@ -41,7 +41,7 @@ Unclassified calls: `2`
| `scripts/quicken.py` | `491` | `save_limen_file` | `TAB-ROUTE-RESIDUE-MUTATORS` |
| `scripts/rewrite-owners.py` | `107` | `save_limen_file` | `TAB-ROUTE-RESIDUE-MUTATORS` |
| `scripts/route.py` | `677` | `save_limen_file` | `TAB-ROUTE-RESIDUE-MUTATORS` |
| `scripts/routine-freshness-audit.py` | `268` | `save_limen_file` | `TAB-UNCLASSIFIED-WRITER` |
| `scripts/routine-freshness-audit.py` | `268` | `save_limen_file` | `TAB-HUMAN-ATOM-STATUS-WRITERS` |
| `scripts/self-heal.py` | `315` | `save_limen_file` | `TAB-CREATION-FALLBACKS` |
| `scripts/self-improve.py` | `435` | `save_limen_file` | `TAB-ROUTE-RESIDUE-MUTATORS` |
| `scripts/usage-telemetry.py` | `207` | `path.write_text` | `TAB-MAINTENANCE-BOARD-FALLBACKS` |
Expand Down
8 changes: 8 additions & 0 deletions scripts/task-writer-audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@
"predicate": "PYTHONPATH=cli/src python3 -m pytest cli/tests/test_tabularius.py -q",
"disposition": "convert harvest/Jules landing result application to task.status tickets",
},
"TAB-HUMAN-ATOM-STATUS-WRITERS": {
"tier": "human-atom-status",
"owner": "codex-integrator",
"predicate": "PYTHONPATH=cli/src python3 -m pytest cli/tests/test_tabularius.py -q",
"disposition": "convert continuity/routine needs_human atom upserts to keeper-owned status/upsert tickets",
},
"TAB-STATUS-ASYNC-HEAL": {
"tier": "status-result",
"owner": "codex-integrator",
Expand Down Expand Up @@ -168,6 +174,8 @@ def owner_packet(path: str) -> str:
return "TAB-STATUS-DISPATCH-RESULTS"
if path in {"cli/src/limen/harvest.py", "scripts/jules-land.py"}:
return "TAB-STATUS-HARVEST-RESULTS"
if path in {"scripts/dispatch-continuity-check.py", "scripts/routine-freshness-audit.py"}:
return "TAB-HUMAN-ATOM-STATUS-WRITERS"
if path in {"scripts/dispatch-async.py", "scripts/heal-dispatch.py"}:
return "TAB-STATUS-ASYNC-HEAL"
if path in {"scripts/quicken.py", "scripts/rewrite-owners.py", "scripts/route.py", "scripts/self-improve.py"}:
Expand Down
Loading