CtrlK
BlogDocsLog inGet started
Tessl Logo

spec-driven-development/spec-as-source

Spec-driven development on OpenSpec, with mechanical spec-as-source enforcement: a custom 'spec-as-source' OpenSpec schema adds file-ownership (targets) and test-verification ([@test]) metadata to every capability spec, three scripts (link check, ownership check, manifest build) keep code and specs from drifting apart, plus requirement-gathering, spec-writer, work-review, and a session-handoff skill with a proactive context-warning hook and a packaged handoff memory: the skill ships the exporter, importer, graph model, facts pipeline, Neo4j Compose runtime and operating guide to load handoffs into a local, authenticated Neo4j graph and query them.

68

Quality

85%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide
SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

local-handoff-memory.mdskills/handoff/references/

Local handoff memory (Neo4j)

The Markdown handoffs in .handoff/HANDOFF-NNN.md are the canonical record. Every saved handoff also has an E21 sidecar HANDOFF-NNN.graph.json (scripts/graph_export.py of the handoff skill, formats v1 and v2). This page explains how to load those sidecars into a local, authenticated Neo4j Community graph, browse them from project, session or handoff, and rebuild the graph from scratch.

The graph is a derived index: you can delete it and rebuild it at any time. Nothing in it is needed to read or resume a handoff; the Markdown files remain directly usable without Neo4j.

The local runtime provides no MCP access, wiki or session-YAML import, or cloud service. Sidecar imports assert explicit records. Additional record relationships come only from declared graph-model rules: deterministic pattern rules or the AI facts pipeline described in graph-ai-executor.md.

Prerequisites

  • Docker with the Compose plugin (docker compose version).
  • Python 3.10 or newer (python3 --version).
  • Free loopback ports 7474 (Browser/HTTP) and 7687 (Bolt). If they are taken, set NEO4J_HTTP_PORT / NEO4J_BOLT_PORT before every docker compose command; the ports stay bound to 127.0.0.1.

Pinned versions: image neo4j:5.26.31-community (5.26 LTS) in runtime/compose.yaml, driver neo4j==6.3.0 in runtime/requirements.txt, both inside the handoff skill folder.

Where the commands run

Everything the memory needs ships inside the handoff skill folder — the folder that holds the skill's SKILL.md: scripts/ (exporter, importer, model engine, facts pipeline), runtime/ (Compose file and driver requirement) and references/ (graph model, schemas, this guide). In the spec-as-source repository it is skills/handoff; in an installed plugin it is wherever your plugin manager placed the handoff skill. Set it once per shell:

HANDOFF_SKILL=/path/to/the/handoff/skill    # the folder with SKILL.md, scripts/, runtime/, references/

The scripts find their own model, schemas and helper scripts from their location inside that folder, so they work from any copy of it. Run the commands from your project folder (the one with .handoff/): handoff paths such as .handoff are resolved from the current directory, skill files never are. <skill> in SKILL.md is the same folder as HANDOFF_SKILL.

Install the importer dependency

Create a virtual environment outside the repository (never commit it):

python3 -m venv ~/.venvs/handoff-memory
~/.venvs/handoff-memory/bin/pip install -r "$HANDOFF_SKILL"/runtime/requirements.txt

validate needs no driver; import and rebuild do. The commands below use PY=~/.venvs/handoff-memory/bin/python.

Automatic saved imports and private retry queue

Workflow B calls the importer after export and quality checks, using the memory virtualenv above when available (python3 otherwise):

PY=~/.venvs/handoff-memory/bin/python
[ -x "$PY" ] || PY=python3
"$PY" "$HANDOFF_SKILL"/scripts/import_handoff_graph.py import-saved .handoff/HANDOFF-NNN.md
"$PY" "$HANDOFF_SKILL"/scripts/import_handoff_graph.py import-pending

import-saved reports imported or da importare, plus the pending count. The existing sibling sidecar is validated and durably queued before connecting. Offline service, transaction failure, missing driver or credential all retain pending work and return 0 for a durable save. Invalid current input returns 1; unsafe or failed queue storage returns 3 and must not be described as a completed save. Source Markdown, sidecar, metadata and technical identity files remain unchanged, even on failure. A successful database commit with a failed queue acknowledgement is reported accurately and leaves an idempotent retry.

These two commands use a nonempty NEO4J_PASSWORD first, otherwise the local ~/.config/handoff-memory/neo4j-password file (line terminator removed). Missing, unreadable or empty credentials leave pending work. Ordinary import and rebuild remain environment-only. Never put credentials in commands, URIs, tracked files, queue state or logs; endpoints remain loopback-only.

The private path-only queue is ${XDG_STATE_HOME:-~/.local/state}/handoff-memory/pending-imports.json. HANDOFF_MEMORY_QUEUE_FILE can override it to a dedicated private directory. Queue files contain normalized absolute source paths, so never version them. Inside a Git tree, the queue, sibling lock and all replacement files must be ignored: use an ignored private directory. Parent symlinks are resolved before this protection check. New queue directories and state files use 0700 and 0600; an existing queue directory must already be private (0700), otherwise it is rejected without changing its permissions. An interprocess lock protects enqueue, selection, transaction and acknowledgement. Malformed or inaccessible state is never replaced with an empty list: repair or restore that local file before retrying. Queue writes fsync the file and parent directory around atomic replacement. Unsupported directory synchronization fails before replacement; a post-replace failure restores prior queue bytes. If storage also prevents restoration, a private .tmp-prior-* copy remains for recovery rather than losing prior work. Restore that local recovery copy before retrying; no source file is changed.

import-pending needs no path arguments. It retries every independent entry, continues after failures and returns 3 if entries remain (0 when empty). The next saved handoff also retries the backlog. A deleted or moved source stays pending; restore its existing sidecar or deliberately repair the local queue. Successful explicit import acknowledges only supplied sidecars already queued; absent queues are never created by it. Corrupt, unsafe or inaccessible queues produce a safe warning without preventing the legacy import. validate, rebuild and facts imports do not acknowledge pending sidecars.

Only explicit v2 metadata.continues references select existing sibling predecessor sidecars recursively. Their local snapshots govern even if already stored, reconciling deterministic content while retaining derived edges under the existing importer rules. An absent predecessor remains an external endpoint: its matching stored identity must exist; no placeholder is generated. Retry never regenerates sources or scans unrelated folders.

Set the password (runtime only)

There is no default password and no unauthenticated mode. Type it without echo and export it for this shell only (Neo4j requires at least 8 characters):

read -rs -p 'Neo4j password: ' NEO4J_PASSWORD; echo; export NEO4J_PASSWORD

Every docker compose command for this project reads NEO4J_PASSWORD; without it Compose stops with required variable NEO4J_PASSWORD is missing and nothing starts. Do not run docker compose config without --quiet: it prints the resolved password. When you are done: unset NEO4J_PASSWORD.

The password initializes a new data volume only. Changing the variable later does not rotate the password of an existing database; change it from Neo4j Browser (:server change-password) and use the new value afterwards.

Start and readiness

docker compose -f "$HANDOFF_SKILL"/runtime/compose.yaml up -d
until curl -fsS http://127.0.0.1:${NEO4J_HTTP_PORT:-7474} >/dev/null; do sleep 2; done
docker compose -f "$HANDOFF_SKILL"/runtime/compose.yaml ps

Data lives in the named volume handoff-memory_handoff-memory-data, mounted at /data.

Validate (offline)

python3 "$HANDOFF_SKILL"/scripts/import_handoff_graph.py validate .handoff

Arguments are sidecar files or directories; a directory contributes only its direct HANDOFF-*.graph.json children (no recursive scan). Every input is checked against the selected unchanged v1 or v2 sidecar schema and for whole-input consistency (duplicate keys, versions, repeated or conflicting IDs, filename/provenance mismatches). Identical duplicates collapse to one. An empty selection fails. The output ends with a payload digest: the same inputs in any order give the same digest.

Import

$PY "$HANDOFF_SKILL"/scripts/import_handoff_graph.py import .handoff

Options: --uri (default bolt://127.0.0.1:7687; only loopback bolt:// or neo4j:// URIs are accepted), --user (default neo4j), --database (default neo4j). The password is read from NEO4J_PASSWORD only, never from the command line.

Behaviour:

  • the whole input is validated before connecting; one bad file rejects the whole batch and the graph is not touched;
  • everything is written in one transaction in the namespace spec-as-source/handoff/v1; E21 IDs are copied unchanged;
  • each supplied handoff is reconciled to its current sidecar (records removed from the sidecar are removed from the graph); handoffs you do not pass stay;
  • re-importing the same sidecars leaves the graph identical;
  • a conflicting stored identity, or an obsolete record that has a relationship from outside the namespace, aborts the import and rolls everything back;
  • concurrent imports/rebuilds are serialized by a namespace lock node (HandoffMemoryControl).

Exit codes: 0 success, 1 invalid input (nothing connected), 3 configuration or database failure (nothing committed).

Graph model

What the graph contains is declared in references/graph-model.yaml of the handoff skill (E24), not in the importer code. The tables below describe the starting model, which reproduces the E23 graph exactly; see Extending the model to add node and relationship types.

Node labelsProperties
HandoffMemory:HandoffProjectnamespace, id
HandoffMemory:HandoffSessionnamespace, id
HandoffMemory:HandoffDocumentnamespace, id, filename, schema_version
HandoffMemory:HandoffRecord:Decision / :DurableFact / :NextAction / :Failurenamespace, id, type, text, source_file, source_section
HandoffMemory:Clientnamespace, id, name
HandoffMemory:WorkContextnamespace, id, name
HandoffMemory:AgentSessionnamespace, id, registry_id (exact agent-registry id)
HandoffMemory:Tracknamespace, id, name, project_id
HandoffMemory:PlanEntrynamespace, id, entry, project_id

Schema v2 sidecars (E23) enrich the same nodes: HandoffProject gains the canonical project_id; HandoffDocument gains project_id, source_directory, source_collection, metadata_profile, metadata_origin, date, continues, agent_session, parent_session, client, work_context, track, plan_entry, change, deadline and operator_person / operator_provider / operator_models. A property that is absent means the metadata value is null: unknown for historical handoffs (no invented dates, predecessors, sessions or contexts), "none" for new ones. An empty work_context list is a known empty list, not an unknown.

V2 relationships (each with namespace), created only from explicit metadata: handoff FOR_CLIENT client, IN_CONTEXT work context, AUTHORED_BY agent session, ON_TRACK track, FOR_PLAN_ENTRY plan entry, CONTINUES its declared predecessor in the same folder (skips and branches allowed), and agent session CHILD_OF parent agent session. The synthetic HandoffSession stays distinct from the real AgentSession. Contextual node IDs are typed (a client and a work context with the same text never collide) and track/plan entry are scoped to the canonical project.

Identity and copied folders: node IDs come from the technical project UUID (graph-project-id, or the identity envelope of a historical .meta.yaml) and the filename, so the same filename in two folders gives two documents. Copies of the same Markdown in distinct folders stay independent HandoffDocuments through their technical identity (and collection), even when they share the same source_directory; group them by canonical project_id. source_directory is relative to the repository root of its project: the handoff folder relative to the nearest ancestor holding .git (.handoff, sub/.handoff, . for the root), or the folder's own name outside any git work tree. It is never an absolute path. Mixed v1/v2 input is accepted, but two different definitions of the same handoff in one batch are rejected.

Relationships (each with namespace): project HAS_SESSION session, session HAS_HANDOFF handoff, handoff HAS_RECORD record. source_file and source_section are the exact Markdown pointer from the sidecar provenance. If the Markdown file is not next to the sidecar, the pointer is kept as is: look for source_file in the .handoff/ directory of the project whose .handoff/graph-project-id equals the project id.

Extending the model

The model file has three mappings, keyed by id, in write order:

  • nodes: each type has labels, identity (source = E21 id taken from the handoff; key = stable id computed from the listed key properties, equal on every machine) and optionally write: enrich (merge properties instead of replacing them);
  • relationships: each type has from and to node types and optionally containment: true, max_out: N, acyclic: true;
  • rules: how each node and relationship is produced from a handoff. Kinds: document (project, session, handoff and their containment), record (one node per record of a record_type), metadata (one keyed node per value of a metadata field, edge from the handoff or from another rule's node with from_rule), handoff_ref (edge to another handoff named by a field, e.g. continues), pattern (one keyed node per regex match in record text, edge from the record).

Example — which files do handoffs talk about? Add to the model:

nodes:
  file:
    labels: [File]
    identity: key
    key: [path]
relationships:
  MENTIONS:
    from: [decision, durable_fact, next_action, failure]
    to: [file]
rules:
  file_mentions:
    kind: pattern
    sources: [decision, durable_fact, next_action, failure]
    regex: '`([A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)+)`'
    group: 1
    node: file
    props:
      path: value
    edge: MENTIONS

Then validate and re-import — no code changes:

$PY "$HANDOFF_SKILL"/scripts/handoff_graph_facts.py validate-model
$PY "$HANDOFF_SKILL"/scripts/import_handoff_graph.py import .handoff

The validator rejects, naming the offending key, anything it cannot guarantee: undeclared endpoints, reserved or malformed labels, duplicate rules, unused types, regexes that do not compile. Only declared labels and relationship types are ever written. In the spec-as-source repository a model change is a spec change: edit openspec/specs/handoff-graph-model/spec.md in the same change. Removing or renaming a type is not reconciled incrementally yet: use rebuild.

From Markdown to facts (any machine)

The pipeline reads handoffs where they are and writes one HANDOFF-NNN.facts.json beside each, with the nodes and edges the model produces and, per record, source_line_start, source_line_end and text_sha256 pointing into the Markdown:

$PY "$HANDOFF_SKILL"/scripts/handoff_graph_facts.py collect ~/project-a ~/project-b    # list + SHA-256
$PY "$HANDOFF_SKILL"/scripts/handoff_graph_facts.py extract ~/project-a ~/project-b    # write facts
$PY "$HANDOFF_SKILL"/scripts/import_handoff_graph.py import --facts ~/project-a/.handoff ~/project-b/.handoff

A root is a project directory with .handoff/, or the .handoff/ directory itself. Markdown, .meta.yaml and sidecars are never modified; facts are written only after every handoff of every root was extracted and validated. graph-project-id is never created unless you pass --create-identity. Facts carry the model version: after changing the model, run extract again (the importer refuses facts from another model version). Directory inputs read *.graph.json by default and *.facts.json with --facts; supplying both forms of one handoff in a batch is rejected. --model <file> selects another model file on both commands.

Relationships derived by AI

Some relationships are written in prose and need a model to recognise. The model declares them as ai rules (kind: ai): the repository model has supersedes (decision SUPERSEDES an earlier decision of the same project) and resolved_by (failure RESOLVED_BY a decision of the same or a later handoff). No script calls a model; the flow goes through files:

$PY "$HANDOFF_SKILL"/scripts/handoff_graph_facts.py ai-requests ~/project-a --out requests.jsonl
#   an executor answers: "$HANDOFF_SKILL"/references/graph-ai-executor.md
$PY "$HANDOFF_SKILL"/scripts/handoff_graph_facts.py ai-apply ~/project-a --responses responses.jsonl
$PY "$HANDOFF_SKILL"/scripts/handoff_graph_facts.py extract ~/project-a
$PY "$HANDOFF_SKILL"/scripts/import_handoff_graph.py import --facts ~/project-a/.handoff
  • Each request carries the rule's instruction and, separately, data with the source record and its candidates; record texts are untrusted data, never instructions.
  • ai-apply rejects the whole file on any invalid answer (unknown request, target outside the candidates, confidence outside 0–1) and otherwise stores ids, model and confidences in .handoff/graph-ai-cache.json — no text.
  • ai-requests lists only unanswered requests. The request id covers the rule, its version and instruction and the texts of source and candidates: editing any of them asks again; anything else is reused from the cache, so extract and rebuild repeat without new calls.
  • Derived edges carry derived_by_rule, derived_by_version, derived_by_model, confidence and derived_request; deterministic edges carry none. Below the rule's min_confidence no edge is created.
  • Derived edges are imported only from facts files. Importing a handoff from its .graph.json sidecar leaves them in place; importing its facts again reconciles them to the current cache.

Browse in Neo4j Browser

Open http://127.0.0.1:7474, connect to neo4j://127.0.0.1:7687 with user neo4j and your password.

Find real IDs first

Either list them in Browser:

MATCH (p:HandoffProject {namespace: 'spec-as-source/handoff/v1'})-[:HAS_SESSION]->(s:HandoffSession)-[:HAS_HANDOFF]->(h:HandoffDocument)
RETURN p.id AS projectId, s.id AS sessionId, h.id AS handoffId, h.filename AS filename
ORDER BY filename

or read them from a sidecar:

python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d["project"]["id"], d["session"]["id"], d["handoff"]["id"])' .handoff/HANDOFF-029.graph.json

Then set the parameter in Browser, replacing the value with an ID from the list, for example:

:param projectId => '00000000-0000-4000-8000-000000000000'
:param sessionId => '00000000-0000-4000-8000-000000000000'
:param handoffId => '00000000-0000-4000-8000-000000000000'

(the zero UUID is a placeholder; it matches nothing).

From a project

MATCH (p:HandoffProject {namespace: 'spec-as-source/handoff/v1', id: $projectId})-[:HAS_SESSION]->(:HandoffSession)-[:HAS_HANDOFF]->(h:HandoffDocument)-[:HAS_RECORD]->(r:HandoffRecord)
RETURN h.filename AS handoff, r.type AS type, r.text AS text, r.source_file AS source_file, r.source_section AS source_section
ORDER BY handoff, type, source_section, text

From a session

MATCH (s:HandoffSession {namespace: 'spec-as-source/handoff/v1', id: $sessionId})-[:HAS_HANDOFF]->(h:HandoffDocument)-[:HAS_RECORD]->(r:HandoffRecord)
RETURN h.filename AS handoff, r.type AS type, r.text AS text, r.source_file AS source_file, r.source_section AS source_section
ORDER BY handoff, type, source_section, text

From a handoff

MATCH (h:HandoffDocument {namespace: 'spec-as-source/handoff/v1', id: $handoffId})-[:HAS_RECORD]->(r:HandoffRecord)
RETURN h.filename AS handoff, r.type AS type, r.text AS text, r.source_file AS source_file, r.source_section AS source_section
ORDER BY handoff, type, source_section, text

To see only one type, add a label to r: (r:Decision), (r:DurableFact), (r:NextAction) or (r:Failure). For a picture of the graph, return the path instead: MATCH path = (h:HandoffDocument {id: $handoffId})-[:HAS_RECORD]->() RETURN path.

Continuation chain of a project

Set the canonical project id, e.g. :param projectId => 'spec-as-source'.

MATCH (h:HandoffDocument {namespace: 'spec-as-source/handoff/v1', project_id: $projectId})
OPTIONAL MATCH (h)-[:CONTINUES]->(prev:HandoffDocument)
RETURN h.source_directory AS folder, h.filename AS handoff, prev.filename AS continues, h.date AS date
ORDER BY folder, handoff

Handoffs of a client

:param client => 'Lizia''s Cake'

MATCH (c:Client {namespace: 'spec-as-source/handoff/v1', name: $client})<-[:FOR_CLIENT]-(h:HandoffDocument)
RETURN h.project_id AS project, h.source_directory AS folder, h.filename AS handoff, h.date AS date
ORDER BY project, folder, handoff

Failures by work context

:param context => 'sviluppo'

MATCH (w:WorkContext {namespace: 'spec-as-source/handoff/v1', name: $context})<-[:IN_CONTEXT]-(h:HandoffDocument)-[:HAS_RECORD]->(f:Failure)
RETURN h.filename AS handoff, f.text AS failure, f.source_file AS source_file, f.source_section AS source_section
ORDER BY handoff, failure

Derived relationships and confidence

// derived relationships at or above $minConfidence
MATCH (a:HandoffMemory)-[r]->(b:HandoffMemory)
WHERE r.derived_by_rule IS NOT NULL AND r.confidence >= $minConfidence
RETURN type(r) AS rel, a.text AS source, b.text AS target, r.confidence AS confidence,
       r.derived_by_model AS model, a.source_file AS sourceFile, b.source_file AS targetFile
ORDER BY confidence DESC

Set the threshold first, e.g. :param minConfidence => 0.7. Decisions still in force (not superseded with that confidence):

MATCH (d:HandoffMemory:Decision)
WHERE NOT EXISTS { MATCH (:Decision)-[r:SUPERSEDES]->(d) WHERE r.confidence >= $minConfidence }
RETURN d.text, d.source_file ORDER BY d.source_file DESC LIMIT 50

Historical recovery (v1 → v2, create-only)

The E23 recovery turns old handoffs into v2 without touching their Markdown:

  1. Freeze the inventory (paths, filenames, SHA-256) and the explicit mapping collection → canonical project_id → technical UUID. An existing graph-project-id is reused unchanged; a folder without one gets a deterministic UUID persisted in the manifest and in each .meta.yaml identity envelope, never as a new external identity file. Audit files and candidates name other projects, so they live in the git-ignored output/private/handoff-v2/recovery/, never under a tracked path.

  2. Stage candidates in this repository (sources are read-only):

    python3 "$HANDOFF_SKILL"/scripts/graph_export.py recover \
      --manifest output/private/handoff-v2/recovery/manifest.json \
      --inventory output/private/handoff-v2/recovery/inventory.json \
      --classification output/private/handoff-v2/recovery/classification.json \
      --out output/private/handoff-v2/recovery/candidates

    Without --out, recover writes to output/private/ of the current git work tree (and refuses to run outside one). Any destination inside a git work tree that git does not ignore is refused before anything is written.

    Mechanical fields come from the header first, then from an explicit body statement; unknowns stay null with a diagnostic, date-only dates keep their precision, and an explicit reference to a missing handoff stops the run. work_context and track come from the Sonnet classification (a header Filone wins over it). recovery-report.json lists null counts, continuation edges, drift and held destinations. Re-running with the same inputs gives the same bytes.

  3. Publish create-only next to each Markdown (take the agent-registry lock on the files first):

    python3 "$HANDOFF_SKILL"/scripts/graph_export.py publish \
      --candidates output/private/handoff-v2/recovery/candidates \
      --manifest output/private/handoff-v2/recovery/manifest.json

    Only absent .meta.yaml / .graph.json files are created (an atomic link, so a concurrent writer wins without truncation). An occupied destination is reported under held and left byte-identical. After someone reviews the candidate and its diff and explicitly approves the replacement, pass --replace <destination>=<sha256 of the reviewed current file>: the file is replaced only if it still has that hash.

  4. Validate and import the full corpus twice, then compare sorted snapshots.

Precedence for any handoff: Markdown frontmatter, then the sibling HANDOFF-NNN.meta.yaml, then null. The Markdown stays canonical; .meta.yaml holds recovered metadata; sidecar and graph are derived and can be regenerated.

Stop, start, persistence

docker compose -f "$HANDOFF_SKILL"/runtime/compose.yaml stop    # keeps data
docker compose -f "$HANDOFF_SKILL"/runtime/compose.yaml start
docker compose -f "$HANDOFF_SKILL"/runtime/compose.yaml down    # removes the container, keeps the volume
docker compose -f "$HANDOFF_SKILL"/runtime/compose.yaml up -d   # same data again

Never add -v to down unless you want to discard the derived graph: down -v deletes the volume. Nothing canonical is lost that way — the graph can be rebuilt below — but the Browser data is gone until you do.

Rebuild the namespace (full replacement)

rebuild replaces everything in the namespace spec-as-source/handoff/v1, across all projects stored there, with exactly the sidecars you pass. Always pass the complete desired set: whatever you leave out is removed.

python3 "$HANDOFF_SKILL"/scripts/import_handoff_graph.py validate .handoff      # check the full set first
$PY "$HANDOFF_SKILL"/scripts/import_handoff_graph.py rebuild .handoff

It validates everything before connecting, then in one transaction takes the namespace lock, checks the boundary, deletes the namespace content and inserts the new set. It never clears the database or deletes the volume, and it leaves nodes and relationships outside the namespace untouched. If any relationship connects a namespace node to something outside the namespace (or an unowned relationship touches a namespace node), it aborts before deleting anything: inspect it with

MATCH (n:HandoffMemory {namespace: 'spec-as-source/handoff/v1'})-[x]-(o)
WHERE NOT (type(x) IN ['HAS_SESSION', 'HAS_HANDOFF', 'HAS_RECORD'] AND coalesce(x.namespace, '') = 'spec-as-source/handoff/v1' AND o:HandoffMemory AND coalesce(o.namespace, '') = 'spec-as-source/handoff/v1')
RETURN n.id, type(x), labels(o)

and remove or move that relationship yourself. Any failure rolls back to the previous content.

Regenerate sidecars from Markdown and restore the graph

The exporter picks the version itself: v2 when the handoff has frontmatter or a sibling .meta.yaml, v1 otherwise. Regeneration keeps graph-project-id, the .meta.yaml identity envelopes and every existing ID; historical Markdown and metadata files are never rewritten by it.

Use this when sidecars are missing or you want to be sure the graph matches the Markdown:

  1. Keep the Markdown handoffs and .handoff/graph-project-id untouched. That file holds the project identity: if it is lost, E21 generates a new one and every ID in the graph changes.

  2. Re-export each handoff with the unchanged E21 exporter (it only rewrites its own HANDOFF-NNN.graph.json):

    for md in .handoff/HANDOFF-*.md; do python3 "$HANDOFF_SKILL"/scripts/graph_export.py "$md"; done
  3. Validate the full set, then rebuild:

    python3 "$HANDOFF_SKILL"/scripts/import_handoff_graph.py validate .handoff
    $PY "$HANDOFF_SKILL"/scripts/import_handoff_graph.py rebuild .handoff

The importer never writes Markdown, sidecars or graph-project-id. Recovery never depends on copying a Neo4j data volume.

When the service is unavailable

  • Read and resume from the Markdown handoffs as usual; nothing depends on Neo4j.
  • validate still works offline.
  • import/rebuild fail with exit code 3 and a short error class (for example ServiceUnavailable); sources are not modified.
  • Start the service again (up -d) and re-run the command, or rebuild from scratch with the procedure above.

Verification (spec-as-source repository only)

These checks live in the spec-as-source repository, not in the skill; the paths below are relative to that repository's root.

  • Offline contract: bash tests/handoff-memory/test_contract.sh (part of bash scripts/verify.sh; no Docker, no network).
  • Real service smoke: SMOKE_PYTHON=$PY bash tests/handoff-memory/smoke.sh. It starts a separate Compose project with its own volume, random loopback ports and a temporary password, checks import, idempotence, rejection, provenance, the queries on this page, stop/start persistence, rebuild, boundary and rollback, concurrency, then removes only its own project and volume. It is never run by verify.sh.
  • Packaged skill: bash tests/handoff-memory/test_packaging.sh (part of verify.sh) runs the offline commands from a copy of the skill outside the repository; SMOKE_PYTHON=$PY bash tests/handoff-memory/smoke_skill_copy.sh runs the documented export, import and queries from such a copy against its own isolated Compose project. Neither touches the project handoff-memory.

README.md

tile.json