/nacl-tl-plan -- Development Planning from Neo4j Graph

SkillFiles & storage

Graph-based development planning from SA specifications in Neo4j. One Cypher query per UC instead of reading ~70 markdown files. Creates paired BE+FE tasks, TECH tasks, api-contracts, and execution waves. Task file format is IDENTICAL to nacl-tl-plan (dev agents don't change). Use when: create dev plan from graph, plan implementation, generate tasks, create development schedule, generate execution waves, or the user says "/nacl-tl-plan".

Available today. Use it from your connected AI after setup.

Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Then ask your AI: use the /nacl-tl-plan -- Development Planning from Neo4j Graph skill

What this skill tells your AI

The instructions your AI receives, as published by itsalt/nacl in nacl-tl-plan/SKILL.md and read by ahel’s review.

Purpose

Graph-powered replacement for /nacl-tl-plan. Reads the SA specification from Neo4j (modules, use cases, entities, dependencies) via Cypher queries and generates self-sufficient task files for dev agents (nacl-tl-dev-be, nacl-tl-dev-fe).

Critical difference from nacl-tl-plan:

Aspectnacl-tl-plannacl-tl-plan
Data source~70 markdown files in docs/Neo4j graph
Tokens per UC~150K (read all docs)~550 (~50 query + ~500 response)
Retrieval methodRead files sequentially1 Cypher query per UC
Task file formatStandard .tl/tasks/IDENTICAL (dev agents unchanged)

Shared references: nacl-core/SKILL.md


Neo4j Tools

ToolUsage
mcp__neo4j__read-cypherRead SA graph (modules, UCs, entities, deps)
mcp__neo4j__write-cypherCreate Wave and Task nodes in the TL layer
mcp__neo4j__get-schemaIntrospect current graph schema before planning

Invocation

/nacl-tl-plan [options]

Parameters

ParameterValuesDescription
scopefull (default)Plan all UCs from the graph
module:<id>Plan only UCs in a specific module
uc:<id1>,<id2>Plan only specific UCs
--featureFR-NNNPlan only UCs from a feature request. Resolves the UC list and new/modified split from the graph ((:FeatureRequest)-[:INCLUDES_UC]->), falling back to .tl/feature-requests/FR-NNN.md only if the node is absent (Step 1.5b).
wave-start0 (default)Starting wave number (for incremental planning)
--overwrite(flag)Destroy ALL existing Task/Wave nodes and re-plan from scratch. Default is incremental (Step 1.5b); use this only for an intentional clean rebuild.
--intake<intake-id>Batch provenance. Stamps Task.intake_id on every task this run creates/reopens and UseCase.intake_id on every UC it plans, so both the conductor's Phase-4/4.5 task gates (WHERE t.intake_id = $intakeId) and its Phase-4.5 P-S6 UC-closure staleness gate ((:UseCase {intake_id:$intake})) see the whole batch. Optional; omit for a standalone plan.

Configuration Resolution — $intakeId

Resolve the $intakeId parameter for the Step 2.4 Task MERGE once, at the start of the run, in this order:

  1. The --intake <id> argument, if given.
  2. Otherwise, the intake_id field of .tl/conductor-state.json, if that file exists (the conductor writes it there — this is how a conductor-driven plan inherits the batch id without the operator retyping it).
  3. Otherwise null.

Bind the resolved value as the $intakeId parameter on every Step 2.4 write (the template references it twice in one statement — the Task stamp and the source-UseCase stamp — so it must always be bound: pass literal null when absent; coalesce($intakeId, t.intake_id) / coalesce($intakeId, uc.intake_id) then leave any prior value untouched). Do NOT infer the id from the branch name or the prompt text — resolve it deterministically from the two sources above only.


Workflow Overview

Phase 1: READ SA GRAPH
  |
  +-- Modules, UseCases, DomainEntities, Dependencies
  +-- Priorities, SystemRoles
  +-- External Contracts Gate (W6, Step 1.6) — BLOCKED if missing/stub
  |
Phase 2: WAVE PLANNING
  |
  +-- Topological sort by DEPENDS_ON + priority
  +-- Wave 0: TECH tasks (infra)
  +-- Wave 1+: UC-BE before UC-FE, independent UCs in parallel
  +-- Create Wave / Task nodes in Neo4j
  |
Phase 3: TASK GENERATION
  |
  +-- For each UC: run sa_uc_full_context($ucId)
  +-- Map query result to 8 task files
  +-- Write files to .tl/tasks/UC###/
  |
Phase 4: MASTER PLAN
  |
  +-- Update master-plan.md
  +-- Update status.json
  +-- Update changelog.md

Phase 1: Read SA Graph

Step 1.1: Pre-flight -- verify graph has SA data

// Pre-flight: count SA-layer nodes
MATCH (n)
WHERE n:Module OR n:UseCase OR n:DomainEntity OR n:Form OR n:Requirement OR n:SystemRole
RETURN labels(n)[0] AS label, count(n) AS count
ORDER BY label

If result is empty or all counts are 0:

  1. STOP -- planning is impossible without SA data in the graph.
  2. Suggest user runs /nacl-sa-architect or /nacl-sa-domain first.

Step 1.2: Get all modules with their UCs and entities

// All modules with UC and entity counts
MATCH (m:Module)
OPTIONAL MATCH (m)-[:CONTAINS_UC]->(uc:UseCase)
OPTIONAL MATCH (m)-[:CONTAINS_ENTITY]->(de:DomainEntity)
RETURN m.id AS module_id, m.name AS module_name,
       count(DISTINCT uc) AS uc_count,
       count(DISTINCT de) AS entity_count
ORDER BY m.id

Step 1.3: Get all UCs with priorities and dependencies

// sa_uc_dependencies -- all UCs with their DEPENDS_ON edges
MATCH (uc:UseCase)
OPTIONAL MATCH (uc)-[:DEPENDS_ON]->(dep:UseCase)
OPTIONAL MATCH (m:Module)-[:CONTAINS_UC]->(uc)
RETURN uc.id AS uc_id, uc.name AS uc_name,
       uc.priority AS priority,
       m.id AS module_id, m.name AS module_name,
       collect(dep.id) AS depends_on
ORDER BY uc.priority DESC, uc.id

Step 1.4: Get complete domain model overview

// Domain entities with attribute count and relationships
MATCH (de:DomainEntity)
OPTIONAL MATCH (de)-[:HAS_ATTRIBUTE]->(da:DomainAttribute)
OPTIONAL MATCH (de)-[rel:RELATES_TO]->(de2:DomainEntity)
RETURN de.id AS entity_id, de.name AS entity_name,
       count(DISTINCT da) AS attr_count,
       collect(DISTINCT {target: de2.name, type: rel.rel_type, card: rel.cardinality}) AS relationships
ORDER BY de.id

Step 1.5: Check for existing TL-layer data (incremental planning)

// Count existing Task and Wave nodes
MATCH (n)
WHERE n:Task OR n:Wave
RETURN labels(n)[0] AS label, count(n) AS count

If Tasks/Waves already exist, the default is INCREMENTAL re-planning, not overwrite. A full overwrite destroys in-progress dev state and re-bakes every snapshot; it happens only when the user explicitly passes --overwrite. Otherwise run Step 1.5b to find the narrow set of UCs that actually changed since the last plan, and regenerate only those.

Step 1.5b: Detect which UCs need re-planning (idempotency)

A Task's .tl/tasks/UC###/*.md files embed a point-in-time snapshot of the SA graph (field types, role permissions, enum values — see "Self-Sufficiency"). That snapshot goes stale when its source UC changes. Two drift-confirmed signals identify the stale set precisely — no markdown diffing, no whole-layer nuke. Both require evidence of actual drift; neither fires on a UC that is already current.

Signal 1 — version drift (a Task baked from an older spec than its UC now carries):

// mcp__neo4j__read-cypher
// GUARD planned_from_version IS NOT NULL: a Task with no baseline (project upgraded to
// Фаза 0 but gap-closure baseline not yet run) is NOT treated as drifted here — that
// would over-flag every task on day one and reset in-progress work. Such a real change
// is still caught by Signal 2 (set by sa-feature step 3g / tl-fix L2-L3).
MATCH (uc:UseCase)-[:GENERATES]->(t:Task)
WHERE t.planned_from_version IS NOT NULL
  AND coalesce(uc.spec_version, 0) > t.planned_from_version
RETURN DISTINCT uc.id AS uc_id, uc.spec_version AS current_version,
       t.planned_from_version AS planned_version, 'spec-drift' AS reason

Signal 2 — explicit stale stamp (set by the write-skills at change time; catches changes even without a version bump, e.g. some tl-fix paths):

// mcp__neo4j__read-cypher
MATCH (uc:UseCase)
WHERE coalesce(uc.review_status,'current') = 'stale'
   OR EXISTS { (uc)-[:GENERATES]->(t:Task) WHERE coalesce(t.review_status,'current')='stale' }
RETURN DISTINCT uc.id AS uc_id, uc.stale_origin AS origin

Do NOT add a third "FR flagged modified" auto-detection arm keyed only on INCLUDES_UC {kind:'modified'} + status='spec-complete'. spec-complete is a sticky state — an FR that was never advanced keeps that edge forever, so such an arm re-flags its UCs on every run even after they're current, regenerating already-current tasks and resetting in-progress work (the exact churn the Signal-1 guard prevents). sa-feature step 3g always bumps spec_version AND stamps stale when it processes an FR, so Signals 1+2 already catch every real FR-driven change. INCLUDES_UC {kind} is consumed for explicit --feature FR-NNN scoping only (below), never for auto drift detection.

Incremental algorithm (default when tasks exist):

  1. stale_set = UCs from Signal 1 ∪ Signal 2 (both drift-confirmed).
  2. new_set = UCs from INCLUDES_UC {kind:'new'} (or in-scope UCs) with no GENERATES edge yet.
  3. Regenerate task files only for stale_set ∪ new_set. UCs not in either set are left untouched — their tasks and dev state survive. Within the stale set, tasks whose status is done/verified-pending keep their dev state too — only their files regenerate (see the Stale+done policy below).
  4. Use MERGE (t:Task {id: $taskId}) (Step 2.4) so re-running is idempotent at the node level: the same UC###-BE/UC###-FE ids are updated in place, never duplicated.
  5. On each successful regeneration, stamp planned_from_version and clear the staleness flag (Step 2.4).
  6. If stale_set ∪ new_set is empty, report "plan is current — nothing to regenerate" and stop.

Stale+done policy (dev state survives re-planning). Split the stale TASKS (per task, not per UC — one UC may have one preserved and one reset task) by current status before regenerating:

  • Active stale tasks (status is anything but done/verified-pending): regenerate files AND reset dev state — Step 2.4 sets status and every phase_* back to 'pending' so the next dev run rebuilds from the fresh snapshot. This includes blocked/failed/regression: the spec change is usually what unblocks them, so they re-queue.
  • Shipped stale tasks (status done or verified-pending): the code already shipped — re-planning must NOT reopen it. Regenerate the task files from the current spec and prepend a delta section to task-be.md/task-fe.md: ## Delta since v<planned_from_version>, listing what changed between the baked snapshot and the current spec. Step 2.4 PRESERVES status, every phase_*, commit, and verification_evidence. The delta CODE is carried by a NEW task of the feature — normally the FR's kind:'new' UC tasks, whose DEPENDS_ON edges point at the shipped tasks; name the carrier task next to each shipped stale task in the plan report ("Delta carried by: ..."). If no new task can carry the delta, HALT and ask the operator: either they explicitly reopen the shipped task (the ONLY sanctioned reset — statement below, run only on in-session operator confirmation), or the delta becomes a follow-up feature. Reopening is never a MERGE side effect.
  • Do NOT hand shipped stale tasks to the deterministic wave planner — they are no longer execution units. Read each one's existing t.wave in the same query that reads its status and pass it as $waveNumber in Step 2.4, so the IN_WAVE re-link is a no-op. When rewriting .tl/status.json, keep their real status — do not list them as pending.
// mcp__neo4j__write-cypher — operator-approved reopen ONLY (explicit
// confirmation in-session; never automatic)
MATCH (t:Task {id: $taskId})
SET t.status = 'pending', t.updated = datetime(),
    t.phase_be = 'pending', t.phase_fe = 'pending', t.phase_sync = 'pending',
    t.phase_review_be = 'pending', t.phase_review_fe = 'pending',
    t.phase_qa = 'pending'

First Фаза-0 plan on an existing project — baseline, don't regenerate. If Tasks exist but none has planned_from_version (project just upgraded), do NOT treat them as drifted. Baseline them once so future drift is detectable, without resetting any in-progress work:

// mcp__neo4j__write-cypher — one-time baseline (idempotent; only touches null ones)
MATCH (uc:UseCase)-[:GENERATES]->(t:Task)
WHERE t.planned_from_version IS NULL
SET t.planned_from_version = coalesce(uc.spec_version, 0)

After baselining, only a real spec_version bump (or a review_status='stale' stamp from nacl-sa-feature/nacl-tl-fix) marks a task for regeneration.

--feature FR-NNN: resolve the UC list from the graph, not the markdown file — read (fr:FeatureRequest {id:$frId})-[r:INCLUDES_UC]->(uc:UseCase) and use r.kind to split new vs modified. This finally consumes the INCLUDES_UC{kind} edges that nacl-sa-feature writes (previously written but never read). Fall back to the markdown UC list only if the FR node is absent.

// mcp__neo4j__read-cypher — resolve --feature scope from the graph
MATCH (fr:FeatureRequest {id:$frId})-[r:INCLUDES_UC]->(uc:UseCase)
RETURN uc.id AS uc_id, r.kind AS kind
ORDER BY uc.id

Wave numbers for the regenerated/new tasks are NOT assigned by hand. Once you have decided the feature's task list and dependency edges, hand them to the deterministic planner in assign mode with waveStart = max(existing Wave.number) + 1 (Step 2.1). The tool returns the global wave per task; feed those into Step 2.4. This is the path a mature project almost always takes — keep it on the tool, not on reasoning.

Step 1.6: External Contracts Gate (W6)

Purpose. For every UC in planning scope, refuse to generate a task when the UC references an external provider/protocol whose .tl/external-contracts/<slug>.md is absent. This is a consumer-side read of the artifact written by nacl-sa-architect during its External Contracts phase (W6 plan brief, declared primary-owner exception). Strict-only — there is no inline --skip-external-contract flag.

Why this check exists. 13 of ~60 postmortem signals across two NaCl projects were external-API / wire-protocol gaps (kie.ai in both projects, TUS upload, base_url divergence, reverse-proxy URL scheme, ffmpeg/ffprobe runtime — see docs/retrospectives/project-beta-runtime-baseline.md §§ A1–A9, B1–B7). Local tests passed; the product did not work. The nacl-tl-sync Wire-Evidence Gate (W2) already downgrades sync to UNVERIFIED when wire-evidence is absent. W6 makes the artifact concrete upstream so the gate has something to point at.

1.6.1: Discover external dependencies
// mcp__neo4j__read-cypher
// Per-UC external-contract requirements via graph
MATCH (uc:UseCase)
WHERE uc.id IN $uc_ids
OPTIONAL MATCH (uc)-[:REQUIRES_EXTERNAL]->(ec:ExternalContract)
OPTIONAL MATCH (m:Module)-[:CONTAINS_UC]->(uc)
OPTIONAL MATCH (m)-[:DEPENDS_ON_EXTERNAL]->(mec:ExternalContract)
RETURN uc.id AS uc_id,
       collect(DISTINCT {id: ec.id, name: ec.name, kind: ec.kind,
                         file_path: ec.file_path}) AS uc_direct,
       collect(DISTINCT {id: mec.id, name: mec.name, kind: mec.kind,
                         file_path: mec.file_path}) AS via_module

The union of uc_direct and via_module is the set of external contracts the UC's tasks must reference at generation time.

1.6.2: File-system existence and stub check

For each ExternalContract row returned above, verify the file referenced by ec.file_path:

  1. File absent on disk → record external-contract-missing for (uc_id, contract_id).
  2. File present but required sections empty / "TBD" / stub → record external-contract-stub. The required sections are 1–8 and 10–11 of the template (.tl/external-contracts/_template.md). Section 9 is required when ec.kind == 'provider'. Section 7 must be filled OR explicitly marked N/A — no file URLs.

Both conditions are blockers; the only override is a signed exception under the W4 schema (no inline flag).

1.6.3: Example check logic (pseudocode)
violations = []
for uc in scope:
  required_contracts = graph_query(uc.id)  # Step 1.6.1
  for contract in required_contracts:
    if not file_exists(contract.file_path):
      violations.append({
        uc_id: uc.id, contract_id: contract.id,
        contract_name: contract.name, contract_kind: contract.kind,
        reason: "external-contract-missing",
        expected_path: contract.file_path,
        remedy: "Run /nacl-sa-architect External Contracts phase OR file " +
                "signed exception under W4 schema."
      })
      continue
    sections = parse_required_sections(contract.file_path)
    missing_required = []
    for section in [1,2,3,4,5,6,7,8,10,11]:
      if not sections[section].filled_non_stub:
        missing_required.append(section)
    if contract.kind == 'provider' and not sections[9].filled_non_stub:
      missing_required.append(9)
    if missing_required:
      violations.append({
        uc_id: uc.id, contract_id: contract.id,
        contract_name: contract.name,
        reason: "external-contract-stub",
        missing_sections: missing_required,
        expected_path: contract.file_path,
        remedy: "Complete sections " + missing_required + " of " +
                contract.file_path + " OR file signed exception (W4)."
      })

if violations:
  # Cluster violations per UC. Surface the full list.
  for v in violations:
    log(v.uc_id, v.contract_name, v.reason, v.expected_path)
  emit_headline("PLAN HALTED — EXTERNAL_CONTRACT_MISSING")
  emit_status("BLOCKED")
  exit_without_writing_anything()

The example above is a sketch. The skill's implementation MUST:

  • Surface every violation (do NOT short-circuit after the first one — the operator needs the complete list to either author the missing contracts or to scope a signed exception).
  • Refuse to write any TL node, any Wave node, any task file, or any .tl/status.json / .tl/master-plan.md / .tl/changelog.md entry until the gate passes OR a signed exception covering every violation is on disk.
  • Emit Status: BLOCKED workflow detail external-contract-missing (when the file is absent) or external-contract-stub (when sections are unfilled). See Output Summary below for the full headline / status contract.
1.6.4: Worked example — UC-300 referencing kie.ai
graph: UC-300 -[:REQUIRES_EXTERNAL]-> (ExternalContract {id: 'ext-kie',
        name: 'kie.ai', kind: 'provider',
        file_path: '.tl/external-contracts/kie.md'})

case A: .tl/external-contracts/kie.md exists, all required sections filled
  → gate PASSES; UC-300 task generation proceeds.

case B: .tl/external-contracts/kie.md absent
  → gate FAILS with external-contract-missing.
  → headline: PLAN HALTED — EXTERNAL_CONTRACT_MISSING
  → status:   BLOCKED
  → remedy:   /nacl-sa-architect External Contracts phase, or signed
              exception under W4 schema.

case C: file exists but Section 9 (Model namespace) is "TBD"
  → gate FAILS with external-contract-stub; missing_sections: [9].
  → headline: PLAN HALTED — EXTERNAL_CONTRACT_STUB
  → status:   BLOCKED

The same flow applies for any protocol (TUS, SSE, etc.) — the kind property on ExternalContract toggles the Section-9 requirement only.

1.6.5: Strict-only language

There is no inline --skip-external-contract flag. There is no gate_mode: legacy carve-out. The project_kind: prototype config does NOT relax this gate; prototypes are the PR/CI carve-out, not the contract carve-out. The only override is a signed exception under the W4 schema covering every violation by (uc_id, contract_id) tuple.


Phase 2: Wave Planning

Step 2.1: Compute the wave plan (deterministic — both planning paths)

Do not assign waves by hand — a missed dependency edge silently produces an FE-before-its-dependency wave (verified: on a real --feature run an agent re-derived waves by reasoning and never called the tool). The planner has two modes; both return { mode, waves, tasks:[{task_id,type,uc,wave}], task_deps } and exit non-zero on a dependency cycle or an undefined dependency. Use the returned wave per task in Step 2.4 — never renumber by hand. Pinned by scripts/wave-plan.test.mjs.

From-scratch (plan) — full Phase-0/--overwrite plan. Feed UCs from Step 1.3 + the Step 2.3 TECH ids; tasks are the UC###-BE/UC###-FE pairs, waves start at 0 (TECH):

node nacl-tl-plan/scripts/wave-plan.mjs '{"tech":["TECH-001","TECH-002"],"ucs":[{"id":"UC001","priority":"high","depends_on":[]},{"id":"UC002","depends_on":["UC001"]}]}'

ucs[].tasks defaults to ["BE","FE"]; pass ["BE"] for a backend-only UC.

Incremental / --feature (assign) — the common path on a mature project. YOU decide the task list and its dependency edges (the creative part); the tool owns only the wave NUMBERS, offset onto the existing sequence. First read the current top wave from the graph, then pass the explicit task DAG with waveStart:

# waveStart via MCP:  MATCH (w:Wave) RETURN coalesce(max(w.number),-1)+1 AS wave_start
node nacl-tl-plan/scripts/wave-plan.mjs assign '{"waveStart":30,"tasks":[
  {"id":"W1-T1","kind":"BE","uc":"UC-044","depends_on":[]},
  {"id":"W2-T1","kind":"BE","uc":"UC-044","depends_on":["W1-T1"]},
  {"id":"W4-T1","kind":"FE","uc":"UC-044","depends_on":["W2-T1"]}]}'

wave(task) = waveStart + topological-level; a same-uc BE→FE edge is added implicitly as a safety net. (Auto-routes: a payload with tasks → assign, with ucs → plan.)

Step 2.2: Wave assignment rules (what the planner implements)

The table documents the planner's contract; the script is the authority (scheme: depth(uc) = the DEPENDS_ON topological level; TECH → wave 0; UC###-BE → wave 1 + 2·depth; UC###-FE → BE wave + 1). Equivalence and these invariants are pinned by scripts/wave-plan.test.mjs.

RuleDescription
Wave 0Always TECH tasks (infrastructure)
BE before FEFor the same UC, UC###-BE is in an earlier wave than UC###-FE
Dependency chainIf UC-B depends on UC-A, then UC-B-BE wave > UC-A-BE wave
Parallel independentUCs with no mutual dependencies can be in the same wave
api-contract firstapi-contract.md is created during planning, so available to FE by default
SYNC after pairnacl-tl-sync runs when both BE and FE for a UC are approved (a task PHASE, not a wave)
QA in final wavesE2E tests run after sync is complete (a task PHASE, not a wave)

Step 2.3: Standard TECH tasks

Create TECH tasks for Wave 0. Common TECH tasks:

Task IDTitleCategory
TECH-001Docker Compose Setupinfra
TECH-002CI/CD Pipelinecicd
TECH-003Database Migrations Setupdatabase
TECH-004Shared Types Packagetypes
TECH-005Authentication Setupauth
TECH-006Error Handling Middlewaremiddleware
TECH-007Logging & Monitoringmonitoring

Adjust list based on what the project actually needs (infer from graph content).

Step 2.4: Create Wave and Task nodes in Neo4j

// Create Wave node
MERGE (w:Wave {id: $waveId})
SET w.number = $waveNumber,
    w.name = $waveName,
    w.status = 'pending'

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
27
Forks
4
Last commit
Sep 2026
Advanced
Item type
skill
Key
nacl-tl-plan
Source
github.com/itsalt/nacl