/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".
Use /nacl-tl-plan -- Development Planning from Neo4j Graph in Claude, ChatGPT or Ahel Desktop
Free. Sign in, add /nacl-tl-plan -- Development Planning from Neo4j Graph and connect your AI. About a minute.
Also: Claude Code · Cursor · Codex
Then ask your AI: use the /nacl-tl-plan -- Development Planning from Neo4j Graph skill
Details
Instructions available. Your AI can read the instructions. Execution depends on the setup they require.
Account requirements not reviewed. Check the skill instructions before use; Ahel provides instructions and does not run this skill.
No other account needed.
Add Ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
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