scitex_cards — Python API
scitex-cards: a canonical task-card store with pluggable adapters.
The task store (SQLite, one tasks table) is the single source of
truth. Adapters render or import it; the mermaid adapter (store -> dependency
PNG) ships today. See the project roadmap for org and Web-UI adapters.
Quick Start
>>> import scitex_cards as card
>>> tasks = card.load_tasks()
>>> src = card.build_mermaid(tasks)
>>> card.render(src, "tasks.png")
'mmdc'
- class scitex_cards.AgentDirectoryPort(*args, **kwargs)[source]
Bases:
ProtocolRead-only feed of agent-runtime rows to enrich board membership.
scitex-cards is SSOT for board membership; scitex-agent-container is SSOT for agent runtime. This port is how the runtime SSOT feeds the board so a member row can show “running / stopped” without scitex-cards importing sac (ADR-0009). The join key is
host@name.Default impl:
EmptyAgentDirectory—list_agents()returns[]andget_agent()returnsNone. Installed by default so the board works STANDALONE when no provider is present (mirrors howscitex_cards._adapters.OpenACLis the defaultIdentityACLPort).Provider impl (lives OUTSIDE this package, e.g. in scitex-agent-container): registers a zero-arg factory under
AGENT_DIRECTORY_GROUPthat returns an object satisfying this Protocol — typically wrappingsac agents list --json. Discovered byresolve_agent_directory().The port is a LIBRARY SEAM, not a board verb: there is intentionally no MCP tool for it. Membership stays authoritative on the card side; the directory only annotates.
- exception scitex_cards.AgentIdentityError[source]
Bases:
ValueErrorA caller passed a malformed agent identity string.
Raised by
canonical_agent_id()/parse_agent_id()on an empty / whitespace-only name or a structurally invalidhost@name. The message always echoes the offending value (fail-loud per the SciTeX constitution).
- class scitex_cards.AgentInfo(host_at_name, name, host, status=None, extra=<factory>)[source]
Bases:
objectOne agent row surfaced by an
AgentDirectoryPort.The shared shape scitex-cards uses to enrich board membership with runtime facts from a provider. The
host_at_namefield is the canonical join key (seecanonical_agent_id()) and the dedup key (seededup_agents()).- Variables:
host_at_name (str) – Canonical
host@namejoin key. REQUIRED and the only field the core relies on for identity; everything else is descriptive.name (str) – The agent’s short name (the part after
@).host (str) – The host the agent runs on (the part before
@);""when the host is unknown (a bare id).status (str | None) – Runtime status as the provider reports it — conventionally one of
"running"/"idle"/"stopped"/"unknown"— orNonewhen the provider declines to say.extra (dict) – Open bag for provider-specific fields (heartbeat, current task, quota %, …). The core never interprets these; downstream consumers may. Defaults to an empty dict.
- class scitex_cards.EmptyAgentDirectory[source]
Bases:
objectStandalone-safe default
AgentDirectoryPort.Knows about zero agents — the board runs with no runtime enrichment and never depends on a provider being installed. This is what
resolve_agent_directory()returns when no entry-point provider is registered.Examples
>>> d = EmptyAgentDirectory() >>> d.list_agents() [] >>> d.get_agent("anyhost@anyname") is None True
- exception scitex_cards.TaskNotFoundError[source]
Bases:
KeyErrorRaised when an update/complete target id is not in the store.
- exception scitex_cards.TaskValidationError[source]
Bases:
ValueErrorRaised when a task store fails structural validation.
- scitex_cards.ack_notifications(agent, ids, store=None)[source]
CONFIRM delivery of
ids— the only cursor-advancing verb.Idempotent. Returns
{agent, recipient_id, store, requested, confirmed, already_confirmed, unknown}. Compare the returnedstorewith the onepoll_notifications()reported: DIFFERENT positively identifies a split; EQUAL is cannot-tell, not all-clear.- Return type:
- scitex_cards.add_task(store=None, *, id, title, status='deferred', scope=None, assignee=None, priority=None, parent=None, note=None, depends_on=None, blocks=None, repo=None, created_by=None, entry_points=None, **extras)[source]
Append a new task to
storeand persist viasave_tasks().Returns the inserted task mapping (a fresh dict, not the underlying YAML node) for convenient round-trip use by callers — the CLI prints it, the MCP tools serialize it as the JSON result.
The
**extraskeyword catches operator-co-designed Task dataclass fields (task/project/host/agent/goal/last_activity/blocker/pr_url/issue_url/kind+ compute metadatajob_id/command/started_at/finished_at) without an explosion of named parameters.Nonevalues are dropped; non-Nonevalues flow into the new task dict and the writer’s validator gates closed enums (status/kind/blocker) — typos raiseTaskValidationErrorwith the bad value and the valid set. Unknown keys are accepted at this layer (forward-compat); the validator decides whether they’re shape-valid.- Raises:
TaskValidationError – On duplicate id or any other structural fault — save_tasks re-runs the full validation gate before touching disk.
- Return type:
- scitex_cards.canonical_agent_id(name, host=None)[source]
Return the canonical agent id in `host@name` form.
The canonical join key between scitex-cards board membership and the sac agent runtime (ADR-0009). One agent may run on exactly one host, so the pair
(host, name)uniquely identifies it.Resolution rules (in order):
If
namealready contains@, it is treated as an already-qualifiedhost@nameand returned as-is after validation — passing an already-joined id through this function is idempotent (canonical_agent_id("h@a") == "h@a"). An explicithostargument is ignored in this case (the embedded host wins); this keeps the function a pure normaliser rather than a re-joiner.Else, if
hostis truthy (non-empty after strip), returnf"{host}@{name}".Else (no host known), fall back to the bare
name. A bare id is valid and round-trips throughparse_agent_id()with an empty host — it represents an agent whose host is not yet known (e.g. a board-only human member, or a row before the runtime provider has reported in).
Fail-loud: an empty / whitespace-only
nameraisesAgentIdentityError. Anamecontaining@is validated as a well-formedhost@name(non-empty host AND non-empty name, a single@) before being returned.- Parameters:
- Return type:
Examples
>>> canonical_agent_id("worker-1", "ywata-note-win") 'ywata-note-win@worker-1' >>> canonical_agent_id("ywata-note-win@worker-1") # already-qualified 'ywata-note-win@worker-1' >>> canonical_agent_id("worker-1") # no host → bare fallback 'worker-1'
- scitex_cards.comment_task(store=None, task_id=None, text=None, by=None, kind=None, entry_points=None)[source]
Append an entry to
task.comments[](the established Issue- activity-log shape from skill 30, Gitea-compatible field).by overrides the $SCITEX_CARDS_AGENT_ID → $USER precedence used by add_task / complete_task.
kind is an optional feedback-ring / event tag (e.g.
push/done/card-message) stamped onto the entry so the board can render “how the card was routed” (operator 2026-06-17). Lenient: the model only requirestext, so the extra key round-trips cleanly.entry_points is forwarded to
scitex_cards._hooks.dispatch_event()for thecard-messagebus emit below: an explicit iterable of entry-point-shaped objects to receive the event instead of the ones discovered from packaging metadata.None(the default) uses the real installed plugins. This is the in-process injection seam used by in-process consumers and by no-mock tests (PA-306-compliant) that observe the emitted event via a real fake handler.- Return type:
- scitex_cards.complete_task(store=None, task_id=None, *, by=None, entry_points=None)[source]
Mark
task_idasdoneand stamp_log_meta.completed_{at,by}.Idempotent per
GITIGNORED/QUESTIONS.md#3: re-completing adonetask is a no-op (timestamps stay frozen from the first completion). Passby=to override the$SCITEX_CARDS_AGENT_ID→$USER→"unknown"precedence chain.Returns the (post-mutation) task mapping.
- Raises:
TaskNotFoundError – If no task matches
task_id.- Return type:
- scitex_cards.dedup_agents(agents)[source]
De-duplicate
agentsby theirhost_at_namejoin key.First wins: when two rows share a
host_at_name, the first one encountered is kept and later duplicates are dropped. Order is otherwise preserved (stable). Used to merge rows from one or more providers onto the board without double-listing an agent.Examples
>>> a = AgentInfo("h@x", "x", "h", "running") >>> b = AgentInfo("h@x", "x", "h", "stopped") # same key, later >>> [r.status for r in dedup_agents([a, b])] ['running']
- scitex_cards.delete_task(store=None, task_id=None)[source]
TOMBSTONE a task + scrub references to it. Returns the lossless payload the client can pass to
restore_taskfor Undo.2026-07-21 P0 (third board wipe) — operator ruling 一度書いたものは 消えない, “a written card never disappears”: this NO LONGER physically removes the row. It marks it in place —
statusflips tocancelled,_log_meta.deleted_at(+deleted_by) records when and who — and the row is retained forever (see_task._is_tombstoned()). Physical removal is IMPOSSIBLE through this, the normal API; a genuine purge is a deliberate admin verb, not this one.The board v3 Delete-with-Undo flow uses this via
handlers/crud.py; exposing the same operation here lets MCP agents do the same delete + later undo without round-tripping HTTP. Reads (list_tasks/get_task/set_edge/ every other lookup) treat a tombstoned row as ABSENT by default, so board behaviour is unchanged.Returns
{"removed": <full pre-tombstone task dict>, "refs": [<refs scrubbed>]}where each ref is the id of another task whose depends_on / blocks / parent pointed at the deleted task (the client passesremovedback torestore_taskto lossless-revert).- Return type:
- scitex_cards.dm_list(peer=None, ack=False, store=None, sender=None)[source]
Read this agent’s DM thread with
peer(default: the operator).Returns
{"thread": <id>, "peer": <peer>, "messages": [...]}in chronological order.ack=Truemarks the messages addressed to this agent read, advancing the unread cursor the board’s /chat view shows.- Return type:
- scitex_cards.dm_send(to, body, store=None, sender=None)[source]
Send a text direct message to
to. Returns the stored DM record.TEXT ONLY — use
dm_send_document()to send a file. Describing a file in prose, or pasting a filesystem path, hands the operator something they cannot open from a browser.- Return type:
- scitex_cards.dm_send_document(to, file_path, caption=None, store=None, sender=None)[source]
Send a FILE to
toas a direct message — READABLE ON THIS HOST ONLY.Copies the bytes into the board’s attachment store — the same store, url shape and renderer the operator’s own uploads use — so a file the caller later moves or deletes is still served. Returns
{"message": <DM record>, "attachment": {url, filename, mime_type, size, host, replicated}}.THE BYTES DO NOT CROSS HOSTS AND THE RECORD DOES. The attachment root is a local directory; the DM record replicates to every seat. So a recipient reading from another host receives a message ending in a url that resolves to nothing, and neither side is told. Measured 2026-08-23 against a peer on another seat: 84KB “sent”, nothing received, success reported both ways.
attachment["host"]therefore names the ONE machine that can serve the result, and the caller is expected to read it rather than to readsizeas delivery. For a recipient elsewhere, paste the content inline instead — an ugly message that arrives beats a tidy one that does not. The reader’s counterpart is_attachments.attachment_status(url), which answers whether the bytes are reachable from where IT is standing.Raises
RemoteHubAttachmentUnsupportedwhen a remote hub is configured — the same divergence, caught for the one topology the process can actually detect — and_attachments.AttachmentErrorfor a missing file, a non-regular file, or one over the size ceiling.- Return type:
- scitex_cards.get_task(store=None, task_id=None)[source]
Return a single task by id, or raise
TaskNotFoundError.Companion to
add_task/update_task/list_tasks— the natural “read one” verb every CRUD surface expects but the Python API was missing (PR #56 audit gap). The MCP wrapper exposes this asget_taskper Convention A.A TOMBSTONED row (see
scitex_cards._task._is_tombstoned()) is treated as NOT FOUND — the 2026-07-21 tombstone change keeps a deleted card’s row on disk forever, but this read must behave exactly as it did whendelete_taskphysically removed it.- Return type:
- scitex_cards.health(*, store=None, agent_id=None, unseen_threshold=50)[source]
Run every scitex-cards health check and return the standard report.
- Parameters:
store (
str|Path|None) – Task-store override.Noneresolves via the package precedence chain (and enables project-shadow detection); an explicit path is taken as the intended store (hermetic tests,--tasks).agent_id (
str|None) – Agent identity override.Noneresolves$SCITEX_CARDS_AGENT_ID.unseen_threshold (
int) – Unseen-backlog ceiling for_check_channel_drain().
- Returns:
{"package", "ok", "checks", "summary"}— EXACTLY these four keys, and each check record has exactly{name, ok, detail, hint}. That shape is a cross-package contract sac and cct parse; severity is therefore expressed throughokandsummaryrather than through keys they would not read.okIS TRUE IFF NO BLOCKING CHECK FAILED — not iff every check passed. It answers “can I use this cards database”, which is the question every caller actually has. Delivery and advisory failures are named insummaryand keep their ownok: falseinchecks, so nothing is hidden; they simply no longer decide availability. Before this, thirteen untidy rows could report the store as broken, and on 2026-08-12 an agent believed it and stopped working for hours.NEVER raises.
- Return type:
- scitex_cards.help_clear(store=None, agent=None)[source]
Resolve the agent’s
help-<agent>-waitingcard (status=done, clear blocker).No-op when the card does not exist — returns
{"task_id": <id>, "cleared": False}rather than raising, so the thin hook trigger can call this unconditionally (the operator may have already resolved the card on the board). When the card IS present it flipsstatustodoneand drops theblockerfield (same in-place mutation contract as the board Resolve button).- Return type:
- scitex_cards.help_wait(store=None, agent=None, *, question=None, host=None)[source]
UPSERT the canonical “agent is waiting on the operator” card.
Idempotent: exactly ONE
help-<agent>-waitingcard per agent. A re-run refreshesnote+last_activity(andhost/ status / blocker) in place rather than inserting a duplicate. The whole read-decide-write runs under the store lock so two concurrent callers can’t both miss the existing card and double-insert.agentis taken as already-sanitized by the caller (the hook owns sanitization); this only trims surrounding whitespace. Returns the upserted card mapping (a fresh dict).- Return type:
- scitex_cards.list_tasks(store=None, *, scope=None, assignee=None, status=None, statuses=None, agent=None, project=None, host=None, repo=None, blocker=None, kind=None, id_prefix=None, blocking_me=False, overdue=False)[source]
Snapshot the store, then filter by any combination of fields.
Filter semantics:
scope=None(default): use$SCITEX_CARDS_SCOPEif set, else no filter.scope=""opts out of the env default explicitly.scope="agent:<id>"names an OWNER, not a lens: it returns every card assigned to<id>as well as those filed under that scope, so work a peer filed underfleetor under no scope still reaches the agent responsible for it (_in_scope()).assignee/agent/project/host/repo/status:None= no filter; any string = exact match. (Generic Req 8 — no fuzzy / glob; callers compose.)repomatches the card’srepofield (owner/repo) — the reusable seam a producer uses to resolve repo->card at emit time (find-card verb). (hook-bypass: line-limit)statuses(list) ANDstatus(single) are OR-combined.blocker="__none"matches rows with no blocker field; any other value is an exact match (closed-enum gating at the CLI layer).kind="task"matches both explicit"task"AND absent rows (since absent ≡"task"per ADR-0002).id_prefixmatches the front ofid(cheap project-rollup lookup without exact id).blocking_me=Trueis the board’s BLOCKING-YOU predicate (status == "blocked" AND blocker == "operator-decision"); composes with the other filters via AND.
The returned list contains fresh dicts, safe to mutate without affecting the on-disk store (no save here).
Reads always go through
scitex_cards._model.load_tasks(), which reads the ONE canonical SQLite database and raises rather than returning an empty or stale document when the store cannot be resolved (see the module docstring — the S2 SQLite-indexed accelerator that used to dispatch here is deleted).
- scitex_cards.parse_agent_id(host_at_name)[source]
Split a canonical agent id into its
(host, name)pair.Inverse of
canonical_agent_id(). A bare id (no@) yields an empty host string —parse_agent_id("worker-1") == ("", "worker-1")— so callers can branch onhost == ""to mean “host unknown”.Fail-loud: an empty / whitespace-only input, or a malformed
host@name(empty host, empty name, or more than one@), raisesAgentIdentityErrorechoing the bad value.Examples
>>> parse_agent_id("ywata-note-win@worker-1") ('ywata-note-win', 'worker-1') >>> parse_agent_id("worker-1") ('', 'worker-1')
- scitex_cards.poll_notifications(agent, unseen_only=True, ack=False, store=None)[source]
PULL
agent’s pending notifications. READING NEVER CONFIRMS.Returns the inbox payload —
{agent, recipient_id, store, notifications, unconfirmed, confirm_with}. Anything you do not pass toack_notifications()stays unseen and comes back on the next poll, which is the point: a consumer that dies between reading and delivering must lose nothing.ack=Trueis DEPRECATED and destroys undelivered messages by advancing the cursor at handover; it is honoured, not recommended. See the MCP tool’s docstring for the incident that named it.- Return type:
- scitex_cards.reassign_task(store=None, task_id=None, new_owner=None, *, by=None, entry_points=None)[source]
Atomically change a card’s owner — the primitive the board lacked.
C5 (
cards-reassign-verb-with-owner-notify). In ONE locked write:set
agent = assignee = new_owner(keep the legacyassigneein lock-step with the operator-co-designedagentso every reader — old dict-style and new — agrees on the owner), ANDset
scope = "agent:<new_owner>"(the convention the fleet slices on), ANDappend an audit comment
"reassigned <old> -> <new> by <actor>".
THEN (post-persist, outside the lock, fail-soft) emit a canonical
reassignedcard-event withextra={"from_owner", "to_owner"}. The EVENT is the notification path — there is intentionally NO bespoke notify/delivery here (delivery is C4, a separate card; this primitive EMITS, it does not deliver).Idempotent: reassigning to the SAME current owner is a no-op — no write, no audit comment, no spurious event — so a replayed/duplicate reassign is harmless.
- Parameters:
new_owner (
str|None) – The new owning agent (required, non-empty).by (
str|None) – The actor performing the reassignment; resolved through the usual$SCITEX_CARDS_AGENT_ID→$USER→"unknown"chain.entry_points (iterable, optional) – In-process injection seam forwarded to the event emit (real fake handler in tests);
Noneuses real plugin discovery.
- Returns:
{"task_id", "from_owner", "to_owner", "actor", "changed", "task"}wherechangedisFalseon the same-owner no-op path.- Return type:
- Raises:
ValueError – If
task_idornew_owneris missing/empty.TaskNotFoundError – If no task matches
task_id.
- scitex_cards.reopen_task(store=None, task_id=None, by=None)[source]
Un-resolve a task — flip
status=doneback toblockedwithblocker=operator-decision(the original LOUD halo state). Used by the board v3 Resolve→Undo loop.ALSO CLEARS
_log_meta.completed_{at,by}. Un-completing a card that keeps its completion stamp is not a reopen — it is a card that is open and completed at the same time, and the stamp is the half that gets believed:_django/handlers/fleet/timing.pyandtimeline.pyaggregate throughput solely oncompleted_at, never onstatus. So a stamped-but-open card is counted as delivered work forever, while simultaneously nagging its owner as backlog.(2026-07-14: found 5 such cards on the live board — one of them
sac-keystone, whose status had just been corrected from a mistakendonetocancelled. The STATUS was fixed; the STAMP was not, so the false completion survived the correction. A lie outlives its retraction if it is written in two places and you only fix one.)- Return type:
- scitex_cards.rescore_task(store=None, task_id=None, *, urgency, importance, by=None, entry_points=None)[source]
Set one card’s axes and recompute the whole rank order — one write.
Returns
{"task": <card copy>, "rank": r, "of": N}whereris the card’s new rank (Nonewhen the card is terminal) andNthe scored-set size.- Return type:
- scitex_cards.resolve_agent_directory(entry_points=None)[source]
Return an installed agent-directory provider, or the empty default.
Discovers a provider registered under
AGENT_DIRECTORY_GROUPand calls its zero-arg factory to obtain the port object. ReturnsEmptyAgentDirectorywhen no provider is installed — so the board is always usable STANDALONE.Multi-provider resolution: if more than one provider is registered, the one whose entry-point name sorts FIRST lexicographically wins (deterministic + stable across packaging-metadata implementations). A provider whose factory fails to load or raises is logged and skipped — one broken provider must not break the board (mirrors
scitex_cards._hooks._run_plugins()).- Parameters:
entry_points (
Optional[Iterable]) – Explicit set of entry-point-shaped objects (each with a.nameattribute and a.load()method returning the zero-arg factory) to use instead of packaging-metadata discovery.None(the default) reads the realAGENT_DIRECTORY_GROUPgroup via_iter_agent_directory_entry_points(). This is the in-process injection seam (mirrorsscitex_cards._hooks._run_plugins()’sentry_points=): tests pass a concrete list of real fake entry points — no monkeypatch ofimportlib.metadatarequired (PA-306-compliant).- Return type:
- scitex_cards.resolve_store(store=None)[source]
Return the resolved task store path and the precedence chain.
Mirrors the data the scitex-cards resolve-store CLI verb and the resolve_store MCP tool emit. Keeping a Python API by the same name as the MCP tool satisfies audit §6 (Convention A: tool_name == api_name).
Output shape:
{ "resolved": "/abs/path/to/cards.db", "explicit": <the `store` arg you passed, or None>, "db_env": <value of $SCITEX_CARDS_DB, or None>, "user_store": "/abs/path/to/~/.scitex/cards/cards.db", "pkg_short": "cards", "exists": bool, "store_uuid": <the database's own identity, or None>, "expected_uuid": <$SCITEX_CARDS_STORE_UUID, or None>, "instance_id": <the SERVER's own identity, or None>, "expected_instance": <$SCITEX_CARDS_STORE_INSTANCE, or None>, "identity_verdict": "matches" | "differs" | "cannot-tell", "identity_reason": <why, when the verdict is not "matches">, "may_proceed": bool, }store_uuidis contract point 8, machine-readable half (design §11). The identity is what a host registry must record next to this board’s endpoint, and “open the database and run a SQL query” is archaeology. This function already answers “WHICH store did I actually resolve”; it answers “and WHAT IS IT” in the same breath.Nonemeans the database is absent or carries no identity yet — bind it withscitex-cards store adopt-uuid.expected_uuidis reported beside it deliberately: the two most useful facts about an identity mismatch are the value the database carries and the value this process was told to expect, and reading them from two different surfaces is how a mismatch stays undiagnosed.instance_idAND WHYstore_uuidWAS NOT ENOUGH. On 2026-08-12 three live PostgreSQL databases all answeredstore_uuid = 1d55dd6e-3d2a-4c24-a429-a78835ab988fwhile holding 3843, 3743 and 3422 cards.store_uuidis aschema_metaROW and a dump/restore carries rows, so every field reported here was byte-identical across stores that were hundreds of cards apart — a report that cannot distinguish them is a report that confirms whichever one you happened to reach.instance_idis the SERVER’s ownsystem_identifier, minted byinitdband present in no dump, so it is the one value a copy cannot carry. See_store_instanceand_store_pin.may_proceedIS THE FIELD TO BRANCH ON, neveridentity_verdict. A caller testingverdict != "differs"reads “I cannot tell which store this is” as a pass, which is exactly how three databases shared one identity for five days without anything complaining.THIS FUNCTION IS PURE REPORTING — AND THAT INCLUDES THE VERDICT. It reports a refusal; it does not perform one. Reading the identity here never mints one, never stamps one, and never changes what resolves, and a
differsverdict raises nothing: this is the verb an operator runs WHEN THINGS ARE ALREADY BROKEN, and on 2026-07-31 it was the one verb that CRASHED on the case being diagnosed. The enforcing twin is_store_pin.require_pinned_store(), which raises.- Return type:
- scitex_cards.resolve_task(store=None, task_id=None, actor=None, *, entry_points=None)[source]
Flip a task from
status=blocked(typicallyblocker=operator- decision) todoneand clear the blocker. Appends an audit comment naming the actor.Idempotent on already-resolved tasks (re-resolves are no-ops, just log a “noop” comment).
- Return type:
- scitex_cards.restore_task(store=None, task=None, refs=None)[source]
Undo a
delete_task: UN-TOMBSTONE the row back to its pre-delete state (or, for a row with no tombstone at all — legacy/never-deleted — re-insert it, the original pre-tombstone-era behaviour).Idempotent on a duplicate id that is NOT a tombstone — raises
ValueError(useupdate_taskto mutate; this verb is the Delete-Undo partner only). A tombstoned row is exactly what this verb expects to find and reverses in place.- Return type:
- scitex_cards.set_collaborator(store=None, *, task_id=None, who=None, action='add')[source]
Add or remove
whoon a card’scollaborators(ADR-0009).actionin {“add”, “remove”}. Adding a collaborator ALSO subscribes them (the ADR default — subscribers ⊇ collaborators), so they get feedback by default. Removing a collaborator leaves their subscription intact; callset_subscriber()withaction="remove"to also stop their notices. Returns the (post-mutation) task mapping.- Return type:
- scitex_cards.set_edge(store=None, action=None, kind=None, source=None, target=None)[source]
Add or remove a depends_on / blocks edge — and SUBSCRIBE THE WAITER.
actionin {“add”, “remove”}.kindin {“depends_on”, “blocks”}. Mutatestasks[source][kind](adding/removingtarget).* ADDING AN EDGE SUBSCRIBES THE WAITING CARD’S OWNER TO THE CARD THEY ARE WAITING ON. Until 2026-07-13 it did not, and that was a SILENT NO-OP. *
Measured by scitex-writer, with a controlled experiment:
depends_on edge + set_subscriber -> notification FIRES depends_on edge ALONE -> NOTHING. Total silence.
The entire reason to record “A depends_on B” is so that FINISHING B TELLS A. An agent who wants to hear when their blocker clears reaches for
depends_on— it is the semantically obvious call and it is literally named for the relationship — and got silence. And SILENCE IS INDISTINGUISHABLE FROM “the gate has not cleared yet”, so nobody ever finds out. A silent no-op wearing the costume of a working mechanism is strictly WORSE than no mechanism at all: with no mechanism, you go and check.Not hypothetical. FOUR cards on the live board sat blocked on gates that had ALREADY CLEARED — including a mutual deadlock between two agents, each recorded as waiting on the other, built out of two stale sentences, neither ever told.
THE RULE, stated once and applied to both kinds — THE OWNER OF THE WAITING CARD IS SUBSCRIBED TO THE CARD THEY WAIT ON:
A depends_on B — A waits on B => subscribe A’s owner to B A blocks B — B waits on A => subscribe B’s owner to A
blocksis the same relationship pointing the other way; leaving it silent would just move the landmine one call to the left.REMOVING an edge does NOT unsubscribe. The owner may have subscribed for their own reasons, and silently dropping that subscription would re-create this very bug from the other side. An extra notification is a nuisance; a missing one strands a card for weeks. Unsubscribe explicitly with
set_subscriber()when you mean it.Returns
subscribed: WHO will now be told when the awaited card completes, orNonewhen the edge was removed or the waiting card has no owner. The caller can SEE that delivery is wired instead of assuming it — which is the whole complaint this fixes.CAVEAT WORTH KNOWING WHEN YOU TEST THIS: a self-completion does not notify, because
actor == subscriberis suppressed. Anyone who exercises the mechanism on their OWN card sees nothing and concludes it is broken. That suppression is correct — it just needs saying.- Return type:
- scitex_cards.set_subscriber(store=None, *, task_id=None, who=None, action='add')[source]
Add or remove
whoon a card’ssubscribers— the notify list (ADR-0009).actionin {“add”, “remove”}. Anyone may unsubscribe — even a collaborator (the ADR’s “always unsubscribable” rule): aremovehere drops them from the notify list without touching collaborators. Returns the (post-mutation) task mapping.- Return type:
- scitex_cards.summarize_tasks(store=None, *, scope=None, assignee=None)[source]
Return numeric progress counts grouped by status, scope, assignee.
Output shape (always present keys):
{ "store": "/abs/path/to/cards.db", "total": int, "by_status": {<status>: int, ...}, # one key per VALID_STATUSES "by_scope": {<scope|"">: int, ...}, "by_assignee": {<assignee|"">: int, ...}, }
Tasks with no scope / assignee bucket under the empty string
"". Theby_statusmap is densified to allVALID_STATUSESso consumers (web UI, progress widgets) don’t have to special-case zero-count keys.AGGREGATED IN PYTHON, ON PURPOSE. It could be a handful of
GROUP BYqueries, and that is precisely the temptation to resist: a second aggregation written in SQL is a second implementation to keep in step with this one, and it is not covered by the equality proof that makeslist_tasks()safe to switch. One path at a time, each proven identical before the next.(This paragraph said “Still YAML-only, ON PURPOSE” until 2026-08-09. That was false and had become misleading: the counts come from
load_tasks, which reads whatever the canonical resolver resolves – PostgreSQL on this fleet. The stale word is what made the mislabelledstorekey below look intentional rather than wrong.)- Return type:
- scitex_cards.update_task(store=None, task_id=None, *, entry_points=None, expected_revision=None, **fields)[source]
Update fields of the task with id
task_id; return the merged dict.Any keyword argument becomes a field on the task. Passing
Nonefor a field DELETES it (matches the operator’s mental model: “clear the scope” = update_task(…, scope=None)). To leave a field untouched, just omit it.expected_revisionmakes the write a COMPARE-AND-SET: pass therevisionyou read and it lands only if nobody has written since. On a mismatch NOTHING is written andRevisionConflictErroris raised, so a caller re-reads and re-applies rather than clobbers.IT IS OPT-IN, and that is load-bearing.
_migrate_v6_to_v7records that REJECT-by-default was RULED UNUSABLE – “an UPDATE from a writer that knows nothing aboutrevisionwould ABORT, so fleet writes would fail until every container is current”, which this fleet cannot establish. WithNoneno guard is emitted and the write is byte-identical to before.It RAISES here while the bulk path REPORTS, and the predicate is the opt-in rather than the layer: passing a revision IS an assertion, and a violated explicit assertion that returns quietly is an invisible lost update.
The ONE exception is
_CONTROL_KWARGS– names that are control parameters elsewhere in this stack. Those are REFUSED with a message naming the real path, because silently storing a requested guard as a data field is worse than not offering it: the caller is then wrong about whether they are protected.ONE clear rule, closed enums included: an empty string
""on a CLOSED-ENUM field (blocker/kind) also DELETES the key — it is a delete instruction, consumed here, never written as a value. This is what the MCP/CLI surfaces have always promised (“pass ‘’ to CLEAR”); previously""was written literally and the validator rejected the save, so the documented way to clear a blocker was the one way that could not work. The validator is NOT weakened: a genuinely invalid value (blocker="banana") still raises.statusis the exception and CANNOT be cleared — every card must carry a decision.status=""raises with the reason and the valid set rather than silently dropping the request. See _store_enums.- Raises:
TaskNotFoundError – If no task matches
task_id.TaskValidationError – If the resulting mutation is structurally invalid, or if
statuswas passed the""clear-sentinel (status cannot be cleared).
- Return type: