#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""``add_task`` -- the INSERT half of the store's write surface.
Extracted from :mod:`scitex_cards._store_mutate` (PURE MOVE -- no behaviour
change), which re-exports ``add_task`` so ``from ._store_mutate import add_task``
and ``from ._store import add_task`` both keep working.
Split because `_store_mutate` had reached the repo's 512-line ceiling and
``update_task`` could not gain the ``expected_revision`` parameter scitex-dev's
reconciler needs -- five lines of headroom does not fit a signature, a docstring
and a call site. The two verbs were the obvious seam: they share only helpers
that already live in their own modules.
The owner/creator FAIL-LOUD rules and the ambient-store guard travel with the
function they protect, including the measured 2026-07-20 chain that produced
them.
"""
from __future__ import annotations
from pathlib import Path
from ._model import (
TaskValidationError,
_save_doc_unlocked,
_store_lock,
)
from ._paths import refuse_ambient_store_creation as _refuse_ambient_store_creation
from ._store_clocks import _clear_completion_stamp_on_leaving_done
from ._store_enums import resolve_enum_clears as _resolve_enum_clears
from ._store_events import _emit_card_event
from ._store_list import _resolved_store
from ._store_target import resolve_store_target
[docs]
def add_task(
store: str | Path | None = None,
*,
id: str,
title: str,
status: str = "deferred",
scope: str | None = None,
assignee: str | None = None,
priority: int | None = None,
parent: str | None = None,
note: str | None = None,
depends_on: list[str] | None = None,
blocks: list[str] | None = None,
repo: str | None = None,
created_by: str | None = None, # hook-bypass: line-limit
entry_points=None,
**extras,
) -> dict:
"""Append a new task to ``store`` and persist via :func:`save_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 ``**extras`` keyword catches operator-co-designed Task dataclass
fields (``task`` / ``project`` / ``host`` / ``agent`` / ``goal`` /
``last_activity`` / ``blocker`` / ``pr_url`` / ``issue_url`` / ``kind``
+ compute metadata ``job_id`` / ``command`` / ``started_at`` /
``finished_at``) without an explosion of named parameters. ``None``
values are dropped; non-``None`` values flow into the new task dict
and the writer's validator gates closed enums (``status`` / ``kind``
/ ``blocker``) — typos raise ``TaskValidationError`` with 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.
"""
from ._store import _read_write_doc, _resolve_creator_or_raise, _utc_now_iso
# Same ONE rule as `update_task` (the sibling write path): a `""` on a
# closed-enum field is a clear, so the key is simply NOT written on
# insert — rather than written as `""` for the validator to reject. A
# `status=""` is refused loudly (a card cannot be born status-less).
_enum_in = _resolve_enum_clears({"status": status, **extras}, source="add_task")
status = _enum_in.pop("status")
extras = _enum_in
resolved = _resolved_store(store)
# A write against a store that does not exist must not INVENT one when
# nothing named the path — that is how a decoy board accumulates and then
# gets imported over the real one. See the guard's docstring for the
# measured 2026-07-20 chain. An explicit `store` is the opt-in.
#
# The guard asks ONE question — "would this write MANUFACTURE a board?" —
# and answers it with `path.exists()`. So it must be handed the store's real
# LOCATION: the canonical SQLite database, which is what `save_tasks` writes
# and what `init-store` creates. `_resolved_store` returns a DISPLAY LABEL
# (`<db_dir>/tasks.yaml`) that the SQLite backend maps to that database —
# good enough to name a store in a message, never a thing on disk. The YAML
# tier was deleted (#512), so that label can NEVER exist, and passing it here
# made the guard refuse unconditionally: every `add` failed for any agent
# without $SCITEX_CARDS_DB while its own reads and updates succeeded, and the
# error told you to run `init-store` — which did not help, because the file
# it created was not the file being tested. Reported and reproduced by
# scitex-ui on 0.17.7. Guard the database, not the label.
# resolve_store_target, NOT resolve_db_path: the latter RAISES on a server
# target, and it raises while evaluating this ARGUMENT — so every write
# against PostgreSQL died here, before the guard it feeds ever ran. The
# guard itself is fine with a DSN (it returns early; a server store cannot
# be manufactured by a write). Handing it the target as written keeps the
# SQLite behaviour byte-identical and stops the coercion happening on the
# way in.
_refuse_ambient_store_creation(resolve_store_target(store), store)
resolved.parent.mkdir(parents=True, exist_ok=True)
# FAIL-LOUD on a missing/blank OWNER (operator mandate 2026-06-26,
# constitution rule 2 "no silent fallbacks"). The OWNER is `assignee`
# OR `agent` (lock-step below). A card with neither reached a blank
# creator/assignee on the board + a fallback lane + an owner-less
# comment relay that silently no-op'd — so an owner is REQUIRED.
# `agent` arrives via **extras (operator-co-designed field). # noqa: E501 # hook-bypass: line-limit
_agent_in = extras.get("agent")
_owner_in = assignee or _agent_in or ""
_owner_in = _owner_in.strip() if isinstance(_owner_in, str) else _owner_in
if not _owner_in:
raise TaskValidationError(
"assignee is required — pass assignee=<user> (or agent=<user>); "
"creator+assignee are mandatory and an owner-less card is "
"rejected (no silent fallback; see constitution)."
)
# RESOLVE the creator STRICTLY — raises a clear, actionable error when
# it can't be resolved (blank / "unknown"). Done BEFORE any write so a
# creatorless card never touches disk. (hook-bypass: line-limit)
_creator = _resolve_creator_or_raise(created_by)
new: dict = {"id": id, "title": title, "status": status}
# D11 partial-fix (ADR-0008): auto-stamp ``created_at`` +
# ``last_activity`` at insert time. ``created_at`` is the immutable
# insert stamp; ``last_activity`` starts equal and ticks on every
# subsequent successful update_task. Callers can override by passing
# the field explicitly (e.g. importers replaying historical state).
_stamp = _utc_now_iso()
new["created_at"] = _stamp
new["last_activity"] = _stamp
# A card BORN blocked starts its blocked-check clock now, stated rather than
# inferred. Without this the row carries no `blocked_at` and
# `_blocked_age_hours` falls back to `created_at` — which returns the RIGHT
# answer here, since for a card born blocked those two instants are the same.
# That is correct-by-luck, and the luck is spent the moment the fallback
# changes. Surfaced by grant 2026-07-30 while measuring why their blocker
# change produced no stamp; the fallback is load-bearing enough that a path
# relying on it silently should not exist.
if status == "blocked":
from ._stale.active_clocks import FIELD_BLOCKED_AT
new[FIELD_BLOCKED_AT] = _stamp
# `created_by` — the creating USER, STRICTLY resolved above (never a
# blank/"unknown" placeholder). Drives the board detail ROLES section +
# ADR-0009's creator auto-subscribe. (hook-bypass: line-limit)
new["created_by"] = _creator
if scope is not None:
new["scope"] = scope
# Keep `agent` + `assignee` in LOCK-STEP: whichever the caller supplied,
# BOTH are stamped to the resolved owner so the board/relay/notify never
# see an owner-less or half-owned card (mirrors `reassign_task`). The
# `agent` half is set from **extras after this block; force it here so
# an assignee-only OR agent-only call yields a fully-owned card. The
# explicit `agent` extra (if any) is overwritten with the same owner.
new["assignee"] = _owner_in
extras["agent"] = _owner_in
if priority is not None:
new["priority"] = priority
if parent is not None:
new["parent"] = parent
if note is not None:
new["note"] = note
if depends_on is not None:
new["depends_on"] = list(depends_on)
if blocks is not None:
new["blocks"] = list(blocks)
if repo is not None:
new["repo"] = repo
# Operator-co-designed surface (TG 9667) + compute metadata
# (ADR-0002). Forwarded through **extras so callers don't have to
# match a long explicit parameter list and the writer's validator
# gates the closed enums.
for key, value in extras.items():
if value is None:
continue
new[key] = value
# Lock for the FULL read-modify-write — without this, two concurrent
# writers each load a stale snapshot and the second `save_tasks` call
# silently clobbers the first writer's insert. See
# tests/scitex_cards/test__store.py::test_two_concurrent_writers...
# COLLECT the tolerated-value warnings this insert raises -- see the note at
# the result assembly below, and `_tolerated` for what went unseen.
from ._tolerated import collect as _collect_tolerated
with _collect_tolerated(id) as _tolerated, _store_lock(resolved):
# `missing_ok=True` is gone deliberately. It meant "an absent store
# yields an empty doc", which against a database feeds an empty doc
# into this read-modify-write and lets the subsequent save delete every
# card absent from it. A missing database is a configuration error, not
# an empty board — see `_read_write_doc`.
doc, tasks = _read_write_doc(resolved)
# WIP-validation gate (operator standing direction via lead a2a
# `d99b8de6839d46e586e4ee692f43c1d9` + ``5acfbb5d0db44db8a7fa4f70c399d539``,
# 2026-06-12). WARN to stderr at the limit, HARD REFUSE at 2x — EXCEPT
# for the emergency band (``priority <= 1``), which is never gated and
# is stamped with an audit comment when it lands over the cap. The whole
# policy — thresholds, exemption, refusal text, audit stamp — lives in
# ``_store_wip`` so it is readable in one screen; this is the same
# focused-sibling pattern as ``_store_enums`` / ``_store_verify``.
# See that module's header for the 2026-07-12 P0 the exemption closes.
# (hook-bypass: line-limit)
from ._store_wip import enforce_wip_gate
enforce_wip_gate(new, tasks, now_iso=_stamp)
tasks.append(new)
# DECLARE THE ROW — same reasoning as `update_task` below, and the
# enumeration is simpler: the only dict mutated here is `new`.
# `enforce_wip_gate` READS `tasks` to count the agent's open cards but
# its docstring is explicit that it "Mutates `new` in place (appends
# the audit comment)", so no other card is written. Without this, a
# single `add_task` re-asserts the caller's entire snapshot and reverts
# anything committed between its read and its write.
_save_doc_unlocked(doc, resolved, tasks=tasks, touched_ids=[new["id"]])
# C5: emit a canonical `created` card-event AFTER the card is durably
# persisted + the lock released. Fail-soft (the mutation already
# succeeded). Actor = the resolved creating user (same chain that
# `created_by` resolves through). (hook-bypass: line-limit)
_emit_card_event(
"card_created",
id,
actor=new.get("created_by"),
store=resolved,
entry_points=entry_points,
)
# Liveness (assignee-liveness feature): the creator just touched the
# store → stamp its heartbeat; and surface the ASSIGNEE's liveness in
# the result so the caller learns immediately if it just assigned to a
# non-running agent. Both fail-soft (never break the durable write).
from ._liveness import _assignee_liveness, _heartbeat
_heartbeat(new.get("created_by"), resolved)
result = dict(new)
_liveness = _assignee_liveness(new.get("assignee"), resolved)
if _liveness is not None:
result["assignee_liveness"] = _liveness
# Same shape as `assignee_liveness` above, same reason: a fact the caller
# needs, attached rather than logged past them. THIS verb is where it
# matters most -- all three `pending` cards created after that status was
# abolished came through `add_task`, each firing a warning into the server's
# stderr. Only when non-empty, so an ordinary insert is unchanged.
if _tolerated:
result["warnings"] = list(_tolerated)
return result
# The three lifecycle clocks now live in `_store_clocks`, imported at the top of
# this module and re-exported below. They moved out when a THIRD one was added
# (`_clear_completion_stamp_on_leaving_done`) and this file passed the 512-line
# limit: they are pure, they share one shape, and they are the only pieces of
# this module another module reaches for by name. Existing imports from here
# still resolve — see `__all__`.
__all__ = ["add_task"]
# EOF