/nacl-tl-plan -- Development Planning from Neo4j Graph
SkillFiles & storageGraph-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.
No other account needed.
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:
| Aspect | nacl-tl-plan | nacl-tl-plan |
|---|---|---|
| Data source | ~70 markdown files in docs/ | Neo4j graph |
| Tokens per UC | ~150K (read all docs) | ~550 (~50 query + ~500 response) |
| Retrieval method | Read files sequentially | 1 Cypher query per UC |
| Task file format | Standard .tl/tasks/ | IDENTICAL (dev agents unchanged) |
Shared references: nacl-core/SKILL.md
Neo4j Tools
| Tool | Usage |
|---|---|
mcp__neo4j__read-cypher | Read SA graph (modules, UCs, entities, deps) |
mcp__neo4j__write-cypher | Create Wave and Task nodes in the TL layer |
mcp__neo4j__get-schema | Introspect current graph schema before planning |
Invocation
/nacl-tl-plan [options]
Parameters
| Parameter | Values | Description |
|---|---|---|
scope | full (default) | Plan all UCs from the graph |
module:<id> | Plan only UCs in a specific module | |
uc:<id1>,<id2> | Plan only specific UCs | |
--feature | FR-NNN | Plan 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-start | 0 (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:
- The
--intake <id>argument, if given. - Otherwise, the
intake_idfield 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). - 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:
- STOP -- planning is impossible without SA data in the graph.
- Suggest user runs
/nacl-sa-architector/nacl-sa-domainfirst.
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-completeis 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-featurestep 3g always bumpsspec_versionAND 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-NNNscoping only (below), never for auto drift detection.
Incremental algorithm (default when tasks exist):
stale_set= UCs from Signal 1 ∪ Signal 2 (both drift-confirmed).new_set= UCs fromINCLUDES_UC {kind:'new'}(or in-scope UCs) with noGENERATESedge yet.- 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 isdone/verified-pendingkeep their dev state too — only their files regenerate (see the Stale+done policy below). - Use
MERGE (t:Task {id: $taskId})(Step 2.4) so re-running is idempotent at the node level: the sameUC###-BE/UC###-FEids are updated in place, never duplicated. - On each successful regeneration, stamp
planned_from_versionand clear the staleness flag (Step 2.4). - If
stale_set ∪ new_setis 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 setsstatusand everyphase_*back to'pending'so the next dev run rebuilds from the fresh snapshot. This includesblocked/failed/regression: the spec change is usually what unblocks them, so they re-queue. - Shipped stale tasks (status
doneorverified-pending): the code already shipped — re-planning must NOT reopen it. Regenerate the task files from the current spec and prepend a delta section totask-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 PRESERVESstatus, everyphase_*,commit, andverification_evidence. The delta CODE is carried by a NEW task of the feature — normally the FR'skind:'new'UC tasks, whoseDEPENDS_ONedges 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.wavein the same query that reads its status and pass it as$waveNumberin Step 2.4, so theIN_WAVEre-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
assignmode withwaveStart = 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:
- File absent on disk → record
external-contract-missingfor(uc_id, contract_id). - 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 whenec.kind == 'provider'. Section 7 must be filled OR explicitly markedN/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.mdentry until the gate passes OR a signed exception covering every violation is on disk. - Emit
Status: BLOCKEDworkflow detailexternal-contract-missing(when the file is absent) orexternal-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.
| Rule | Description |
|---|---|
| Wave 0 | Always TECH tasks (infrastructure) |
| BE before FE | For the same UC, UC###-BE is in an earlier wave than UC###-FE |
| Dependency chain | If UC-B depends on UC-A, then UC-B-BE wave > UC-A-BE wave |
| Parallel independent | UCs with no mutual dependencies can be in the same wave |
| api-contract first | api-contract.md is created during planning, so available to FE by default |
| SYNC after pair | nacl-tl-sync runs when both BE and FE for a UC are approved (a task PHASE, not a wave) |
| QA in final waves | E2E 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 ID | Title | Category |
|---|---|---|
| TECH-001 | Docker Compose Setup | infra |
| TECH-002 | CI/CD Pipeline | cicd |
| TECH-003 | Database Migrations Setup | database |
| TECH-004 | Shared Types Package | types |
| TECH-005 | Authentication Setup | auth |
| TECH-006 | Error Handling Middleware | middleware |
| TECH-007 | Logging & Monitoring | monitoring |
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