Skip to content

issues

Provenance

  • Source: .spec/spexcode/spec-cli/issues/spec.md
  • Source SHA-256: 6c0d19824e61e269570a4c85e71231186347d9d0f216abc99aec5a4dcc5e8cb5

raw source

A thread in the local issue store and an issue on a forge are literally the same object on the proper abstract level — a recorded concern bound to spec node(s), carrying its own lifecycle, living beside the graph and never as node state. Local and remote issues are the SAME data model under the SAME name — an issue; a store where either needs a different model or a different word is a bug. Where one is stored is a property of the individual issue, not a mode of the project: a project has both at once, mixed — an agent's taste concern living local next to a human-visible GitHub issue, on the same nodes. Don't build two parallel systems and promise a bridge; build the one object and let the stores be adapters.

expanded spec

The core type. An Issue is { id, store, concern, by, status, nodes[], created, body, replies[], evidence[], labels[], url? }. store names the adapter that holds it (local, or a forge host like github) — data, not a mode. There is deliberately no content-kind taxonomy: a field that does no mechanical work (nothing branches on it) is a label, not structure — what a thread is (a change suggestion, an annotation, a question) is what its prose says. nodes[] is the binding to the graph; status is the issue's OWN lifecycle, authored in its store, never git-derived (a node defines, an issue does — [[spec-forge]]'s two-plane contract holds here unchanged). evidence[] is a list of content-addressed evidence hashes — the typed target [[video-evidence]] points at when a video finding routes to the responsible node's concern. A reply may itself be a remark ([[remark-substrate]]) — the same {by, at, body} shape plus a mutable resolved bit and the reading it was authored against — but that is one reply carrying extra state, not a second thread type; a plain reply is unchanged.

Two stores, one translation rule. The local store is the local issue store ([[local-issues]] owns its whole mechanism — venue, file format, lock, trunk commit); a local issue thread is a local Issue, its store implied by where it lives, never written into the file. The forge store rides [[spec-forge]]'s tracer read: a ForgeIssue becomes an Issue at this boundary — id <host>#<number>, title → concern, state → status, its comments → replies[] (the SAME Reply shape a local issue thread carries — both stores' discussions are one thread type, so every surface renders one kind of thread), and its platform labels → labels[] (name plus the optional display colors the host supplied), and the host's node-naming conventions (Spec: body marker, transitive PR links — [[links]]) translated into nodes[] here, so product semantics see only nodes[] and never know a marker existed. A local Issue has labels: []; label metadata is display-only, never a second lifecycle, concern taxonomy, or SpexCode-owned label scheme. Platform differences live at the adapter boundary; nothing downstream branches on store.

One read, differently freshened — and ONE time line. mergedIssues(forgeState, nodeIds) is a pure merge that interleaves every store by creation time, newest first — the stores are the same abstraction, so a github issue, a gitlab issue, and a local thread sort as one list, never store-grouped blocks (that grouping is exactly the two-surfaces smell this node exists to kill). It excludes eval-remark threads (isEvalConcern, [[eval-issue-split]]): a scenario-scoped concern is a remark, not an issue (I1), so it is filtered here ONCE and every issue surface this feeds — the drain, the board badge, the [[issues-view]] Issues page list — is free of it by construction; the complementary read loadEvalRemarkTracks keeps only those, feeding the eval scoreboard instead. Each caller supplies the forge slice at the freshness its surface warrants — the server ([[dashboard-issues]]'s resident cache: instant view, background reconcile) for GET /api/issues and the board fold, the CLI (spex issue ls [--node] [--store] [--all] [--json]) via a live driver pull that degrades loudly to local-only (one stderr note) when the forge is unreachable — local reading never hostages on a network. The single-thread detail is the same read, narrowed (findIssue): spex issue show <id> and GET /api/issues/:id both find the id inside the merged, eval-remark-free set — never a second lookup path, so an eval-remark thread is invisible to show exactly as it is to the list — with the same per-surface freshness (live pull on the CLI, resident slice on the server; a local id skips the forge slice entirely). The board fold attaches each node's merged issues (issues / open subset openIssues), so every per-node surface — tile badge, node-info Issues tab, and the [[issues-view]] page — reads the same mixed set with no second path; the board also carries ONE top-level freshness stamp over the whole merged set (open/thread/reply counts + latest activity), so any thread write — reply, remark, resolve, retract, close, on a noded or nodeless thread — moves board bytes and reaches a delta-subscribed viewer ([[remark-substrate]] write-visibility) while the per-node fold stays [[graph-lean]]-slim.

Writes stay where they're owned — and store-routed verbs stay one port, on BOTH surfaces. Creation is ONE verb over every store (createIssuespex issue open [--store <store>] and POST /api/issues are the same routing): it defaults to local (the [[local-issues]] committed write), and a forge store choice — the dashboard New form's compact store picker, or the CLI's --store <host> — creates the real forge issue through that store's driver, no local→forge promote round-trip when a concern is born forge-visible. The created forge issue body carries the same Spec: <nodes> marker used by promotion, derived from the author's [[node]] prose links (unioned with explicit --node ids), so the tracer links it back on the next read; no surface-only node field appears. Replying is ONE verb over both stores (replyIssuespex issue reply <id> and POST /api/issues/:id/reply are the same routing): a local id goes through the local issue store's committed write, a forge id (<host>#<n>) posts a real comment through the driver's createComment — the [[port]]'s second write verb, the same seam discipline as promotion (the driver stays the only network toucher; the tracer stays read-only; a failed forge write fails loud, never queues). A local reply may carry optional evidence hashes (an anchored annotation's frame blob) that accrue onto the thread's typed evidence[], deduped — a forge reply has no such field, so its frame rides the comment body's image link instead. A reply's @session text stays as a passive [[mentions]] reference; assigning or contacting an agent uses explicit session actions. The same reply may loop in the thread's originator as a courtesy if their session is online (the implicit loop-in — [[mentions]] owns the mechanism, silent when offline, never a spawn); the originator is a local thread's author, or an eval-comment thread's reading-filer, and a forge issue's github-login author resolves to nobody, so a forge reply loops in no one. Freshness after a forge write stays caller-owned: the server forces its resident slice's read-back before answering (the comment shown is the read-back, never a local echo); the CLI's next read is a live pull anyway. The explicit local→forge migration verb is promotionspex issue promote <id>: a local concern that outgrows the repo (needs CI or external visibility) moves to the forge as one recorded action instead of a lossy hand-copy. It composes the forge issue from the thread itself — concern → title; body + the Spec: <nodes> marker + the evidence hashes + a provenance footer — and creates it through the [[port]]'s driver (the driver stays the only thing that touches the network; no second gh call-site). The marker is the round-trip: the promoted issue links back to the same nodes through the EXISTING tracer read, so promotion adds no linking code. Order makes failure safe: the forge issue is created FIRST, and only then is the local thread closed out — marked landed with a reply carrying the permalink (its file remains as the recorded trail); an unreachable forge fails loud with the local thread untouched, and only an open thread promotes. The two-plane contract is untouched throughout: a forge issue is execution, never node state. Promotion is human-reachable too: the dashboard's Promote affordance is a thin POST /api/issues/:id/promote over this same verb (the provenance footer and permalink reply carry the caller's surface-derived identity — a session id from the CLI, 'human' from the dashboard). Closing is ONE verb over both stores, on both surfaces too (closeIssuespex issue close <id> and POST /api/issues/:id/close behind the dashboard's Close button are the same routing). A local id marks the thread landed through the local store; a forge id (<host>#<n>) calls the driver's closeIssue — so an agent can close a github issue with the same verb the human clicks. The server forces a forge refresh before answering, so the follow-up read shows the store-authored closed state; the CLI's next read is a live pull. There is no parallel sign/accept/reject lifecycle; an issue is open until it is closed or promoted. Closing is lifecycle on the issue object, not graph state; it never writes a spec node's status.