← Files APODICTIC Development EditorARCHIVED FILE

scripts/lifecycle_node.py

10.6 KB · Nov 3, 2026 · 18:57 UTC

↓ Download file

#!/usr/bin/env python3
"""lifecycle-node — derive a project's lifecycle node from its sidecar (Project Addressability, Increment 3).

`validate.sh lifecycle-node <sidecar> [run_folder]` shells out here. State-driven dispatch needs one
authoritative, *tested* answer to "where is this project?" so `/start`, `/projects`, and the Increment-4
loop dispatcher don't each re-derive it in prose. This computes the node by a single precedence
(FIRST MATCH WINS) over the existing Diagnostic_State.meta.json sidecar — no new stored state:

    cold -> blocked_gate -> execution -> pre_writing -> submission -> revising -> diagnosed -> diagnosing

The set is TOTAL: any readable sidecar resolves to exactly one node, with `diagnosing` the catch-all
default (intake done, passes/synthesis in progress, no editorial letter yet — the case that matched no
node in the first draft). Each node's signal:

  cold          no sidecar / unreadable (no bound project)
  blocked_gate  execution.pending_gate present (resolve the gate first)
  execution     mode == "execution"
  pre_writing   next_action.key == "pre_writing" (pre-writing minimal sidecar)
  submission    readiness[] non-empty (a submission-readiness assessment was recorded)
  revising      revision_progress.steps_complete > 0
  diagnosed     a synthesis/editorial letter exists: {project_root}/SYNTHESIS.md or
                {project_root}/runs/*/*_Synthesis_*.md (project_root = the sidecar's dir; an optional
                run_folder arg is an extra search location)
  diagnosing    default — mode == "diagnostic" and nothing above matched

`diagnosed` is the only node needing a filesystem check; when no synthesis is found anywhere,
derivation falls through to `diagnosing` (the safe pre-letter default). Reads the sidecar directly
(stdlib JSON). See docs/project-addressability.md.

  lifecycle_node.py lifecycle-node <sidecar> [run_folder]
  lifecycle_node.py --self-test

Exit: 0 (prints the node), 2 usage. (Pure derivation — there is no failure verdict; an unreadable
sidecar is reported as `cold`, exit 0.)
"""
import glob
import json
import os
import sys

NODES = ("cold", "blocked_gate", "execution", "pre_writing",
         "submission", "revising", "diagnosed", "diagnosing")


def _read_json(path):
    try:
        with open(path, encoding="utf-8") as fh:
            return json.load(fh)
    except (OSError, json.JSONDecodeError, UnicodeDecodeError):
        return None


def _letter_exists(project_root, run_folder=None):
    """True if a synthesis/editorial letter exists for the project."""
    candidates = []
    if project_root:
        candidates.append(os.path.join(project_root, "SYNTHESIS.md"))
        candidates += glob.glob(os.path.join(project_root, "runs", "*", "*_Synthesis_*.md"))
    if run_folder:
        candidates += glob.glob(os.path.join(run_folder, "*_Synthesis_*.md"))
    return any(os.path.isfile(c) for c in candidates)


def derive_node(sidecar, project_root=None, run_folder=None):
    """Return (node, reason) by first-match precedence. TOTAL: any input resolves to a known node.

    Defensive against malformed-but-readable sidecars (a non-dict top level, or fields with the
    wrong type — e.g. `next_action` as a bare string, which docs/project-addressability.md notes
    the denormalized copy can be). Anything that isn't a usable JSON object is `cold`."""
    if not isinstance(sidecar, dict):
        return "cold", "no sidecar / not a JSON object (no usable bound state)"
    execution = sidecar.get("execution")
    if isinstance(execution, dict) and execution.get("pending_gate"):
        return "blocked_gate", "execution.pending_gate=%r" % execution.get("pending_gate")
    if sidecar.get("mode") == "execution":
        return "execution", "mode == execution"
    na = sidecar.get("next_action")
    na_key = na.get("key") if isinstance(na, dict) else na  # tolerate the bare-string form
    if na_key == "pre_writing":
        return "pre_writing", "next_action key == pre_writing"
    readiness = sidecar.get("readiness")
    if isinstance(readiness, list) and readiness:
        return "submission", "readiness[] has %d entr(y/ies)" % len(readiness)
    rp = sidecar.get("revision_progress")
    steps = rp.get("steps_complete", 0) if isinstance(rp, dict) else 0
    if isinstance(steps, int) and steps > 0:
        return "revising", "revision_progress.steps_complete == %d" % steps
    if _letter_exists(project_root, run_folder):
        return "diagnosed", "synthesis/editorial letter present"
    return "diagnosing", "no higher-precedence signal; no letter yet (default)"


def run(paths):
    if not paths:
        return 2, ["lifecycle-node: usage: lifecycle-node <sidecar> [run_folder]"]
    sidecar_path = paths[0]
    run_folder = paths[1] if len(paths) > 1 else None
    sidecar = _read_json(sidecar_path)
    project_root = os.path.dirname(os.path.abspath(sidecar_path)) if sidecar is not None else None
    node, reason = derive_node(sidecar, project_root=project_root, run_folder=run_folder)
    return 0, ["lifecycle-node: %s" % node, "  reason: %s" % reason]


# ---------------------------------------------------------------- self-test

def run_self_test():
    import tempfile
    import shutil
    rc = {"v": 0}

    def chk(name, cond):
        print("  %s: %s" % (name, "OK" if cond else "FAIL"))
        if not cond:
            rc["v"] = 1

    # non-UTF8 sidecar: _read_json must degrade to the unreadable path (None), never
    # a traceback (the disposition_check adjacent-exception class, swept repo-wide)
    _fd, _nu = tempfile.mkstemp(suffix=".json")
    with os.fdopen(_fd, "wb") as _fh:
        _fh.write(b"\xff\xfenot utf-8\xff")
    chk("non_utf8_read_json_returns_none", _read_json(_nu) is None)
    os.unlink(_nu)

    def node(sidecar, project_root=None, run_folder=None):
        return derive_node(sidecar, project_root=project_root, run_folder=run_folder)[0]

    base = {"schema": "apodictic.diagnostic-state.v1", "mode": "diagnostic",
            "next_action": {"key": "run_passes", "description": ""},
            "revision_progress": {"steps_complete": 0}, "readiness": []}

    # cold — no sidecar
    chk("cold_no_sidecar", node(None) == "cold")

    # B1 — fresh / mid-pass Core-DE project must be diagnosing, NOT undefined
    chk("b1_fresh_project_diagnosing", node(dict(base)) == "diagnosing")

    # blocked_gate beats everything else
    sc = dict(base, mode="execution", execution={"pending_gate": "run_synthesis"})
    chk("blocked_gate_precedence", node(sc) == "blocked_gate")

    # execution
    chk("execution", node(dict(base, mode="execution", active_scene_scope="ch3"))== "execution")

    # pre_writing (minimal sidecar)
    chk("pre_writing", node({"mode": "diagnostic", "next_action": {"key": "pre_writing"}}) == "pre_writing")

    # submission — readiness[] non-empty
    sc = dict(base, readiness=[{"schema": "apodictic.readiness.v1", "dimension": "query",
                                "verdict": "weak", "rationale": "x"}])
    chk("submission_readiness", node(sc) == "submission")

    # revising — steps_complete > 0
    chk("revising", node(dict(base, revision_progress={"steps_complete": 2})) == "revising")

    # precedence: submission before revising (both true -> submission)
    sc = dict(base, readiness=[{"schema": "apodictic.readiness.v1", "dimension": "d",
                                "verdict": "v", "rationale": "r"}],
              revision_progress={"steps_complete": 3})
    chk("submission_beats_revising", node(sc) == "submission")

    # diagnosed — synthesis present at project root
    d = tempfile.mkdtemp()
    try:
        open(os.path.join(d, "SYNTHESIS.md"), "w", encoding="utf-8", newline="").close()
        chk("diagnosed_root_synthesis", node(dict(base), project_root=d) == "diagnosed")
        # via runs/*/*_Synthesis_*.md
        d2 = tempfile.mkdtemp()
        os.makedirs(os.path.join(d2, "runs", "2026-06-08_opus_core-de"))
        open(os.path.join(d2, "runs", "2026-06-08_opus_core-de", "Proj_Core_DE_Synthesis_run.md"), "w", encoding="utf-8", newline="").close()
        chk("diagnosed_runs_glob", node(dict(base), project_root=d2) == "diagnosed")
        shutil.rmtree(d2, ignore_errors=True)
        # S4 — no letter anywhere -> diagnosing (safe default), even though project_root given
        d3 = tempfile.mkdtemp()
        chk("s4_no_letter_diagnosing", node(dict(base), project_root=d3) == "diagnosing")
        shutil.rmtree(d3, ignore_errors=True)
    finally:
        shutil.rmtree(d, ignore_errors=True)

    # B1 (build review) — malformed-but-readable inputs must NOT crash and must resolve to a node
    chk("malformed_next_action_string", node({"next_action": "pre_writing"}) == "pre_writing")
    chk("malformed_toplevel_list", node([]) == "cold")
    chk("malformed_toplevel_scalar", node(42) == "cold")
    chk("malformed_empty_dict", node({}) == "diagnosing")
    chk("malformed_execution_nondict", node({"execution": "oops", "mode": "diagnostic"}) == "diagnosing")
    chk("malformed_revprog_nondict", node({"revision_progress": "oops"}) == "diagnosing")
    chk("malformed_steps_string", node({"revision_progress": {"steps_complete": "3"}}) == "diagnosing")

    # totality — a wide range of sidecars all resolve to a known node
    import itertools
    for mode, na, steps, rdy in itertools.product(
            ["diagnostic", "execution"], ["run_passes", "pre_writing", "run_synthesis", "coaching"],
            [0, 1], [[], [{"schema": "apodictic.readiness.v1", "dimension": "d", "verdict": "v", "rationale": "r"}]]):
        n = node({"mode": mode, "next_action": {"key": na},
                  "revision_progress": {"steps_complete": steps}, "readiness": rdy})
        if n not in NODES:
            chk("totality_%s_%s_%d" % (mode, na, steps), False)
    chk("totality_all_known", True)

    # CLI resolution (real sidecar file)
    df = tempfile.mkdtemp()
    try:
        p = os.path.join(df, "Diagnostic_State.meta.json")
        with open(p, "w", encoding="utf-8", newline="") as fh:
            json.dump(dict(base), fh)
        chk("cli_file_resolution", run([p])[0] == 0 and "diagnosing" in run([p])[1][0])
        chk("cli_usage", run([])[0] == 2)
        # unreadable / missing path -> cold, exit 0
        chk("cli_missing_cold", "cold" in run([os.path.join(df, "nope.json")])[1][0])
    finally:
        shutil.rmtree(df, ignore_errors=True)

    print("Self-test: PASS" if rc["v"] == 0 else "Self-test: FAIL")
    return rc["v"]


def main(argv):
    if "--self-test" in argv:
        return run_self_test()
    args = [a for a in argv[1:] if a != "lifecycle-node"]
    paths = [a for a in args if not a.startswith("--")]
    code, lines = run(paths)
    for ln in lines:
        print(ln)
    return code


if __name__ == "__main__":
    sys.exit(main(sys.argv))

SHA-256: 9bf6ee799c539cf2fef8b5762902f87e858ca4011eb9bda89145e20dc391fa5a