Plaintext-first project scheduling, inspired by Beancount and hledger.
The .plan file is the authoritative project definition. The system parses it
into a project model, validates it, derives a CPM schedule, tracking state,
costs, and reports, and exposes the results through a CLI. GUI and plugin
interfaces remain future work.
The central design rule:
Derived information must not become a second source of truth.
Plan definition and tracking history are authoritative and live in the file.
Schedules, resolved budgets, variances, forecasts, and reports are derived and
never written back as authoritative data. (Serialization exists but is not yet
wired into the CLI; see planscript/serializer/.)
- Plaintext readability over structured-data convenience. Do not drift toward JSON/YAML.
- Avoid quoting and unnecessary punctuation; punctuation must communicate meaning.
- Keep task lines short. Dependencies, budgets, and metadata get their own indented lines.
- Descriptive metadata is separate from scheduler input.
- Hierarchy in a task number is organizational; it never implies a dependency.
- CPM stays pure: the scheduler operates on duration/offset values and dependency relationships.
- Dates are mapped to and from schedule offsets in a calendar layer.
- Do not call something a constraint if it does not change the schedule.
- Prefer a small number of powerful concepts (a zero-duration task is a milestone; there is no milestone class).
- Resolve the semantic model before inventing syntax.
- Invalid input fails explicitly with a line number; nothing is silently repaired.
.plan FILE (authoritative)
│
┌────────┴────────┐
│ │
plan definition tracking history
│ │
▼ ▼
Parser (events)
│ │
▼ │
Project model ◄──────────┘
│
┌────────────┼────────────┬───────────┬──────────┐
▼ ▼ ▼ ▼ ▼
Scheduler Tracker Analyzer Budgeter (Serializer)
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
Schedule actuals / variance Budget .plan text
status reports (derived)
└────────────┴────────────┴───────────┘
│
▼
CLI: display / gantt / reporter
| Module | Responsibility |
|---|---|
planscript/__main__.py |
Module entry point for python -m planscript. |
planscript/app.py |
Argument parsing, subcommands, exit codes, error reporting. |
planscript/exceptions.py |
ParseError, ValidationError, SchedulingError, BudgetingError. |
planscript/engine/parser.py |
Line-oriented parser: .plan text → Project. |
planscript/model/project.py |
Project aggregate root and project-level validation. |
planscript/model/task.py |
Task (number, name, duration, budget, metadata). |
planscript/model/dependency.py |
Dependency, DependencyType, DependencyGraph. |
planscript/model/hierarchy.py |
TaskHierarchy derived from task numbers. |
planscript/model/calendar.py |
Calendar (working days, holidays). Model only; not yet applied. |
planscript/model/budget.py |
Budget (resolved/derived budget). |
planscript/model/schedule.py |
Schedule (derived CPM results and dates). |
planscript/engine/scheduler.py |
Scheduler: CPM forward/backward pass, float, critical paths, dates. |
planscript/engine/tracker.py |
Tracker, TaskEvent, TaskState, Invoice, directives and statuses. |
planscript/engine/analyzer.py |
Analyzer, TaskVariance: planned vs. actual variance and progress. |
planscript/engine/budgeter.py |
Budgeter: resolves explicit and weighted budgets. |
planscript/engine/reporter.py |
ReportBuilder and report dataclasses for status reporting. |
planscript/cli/display.py |
Table rendering, schedule/budget views, legacy interactive menus. |
planscript/cli/gantt.py |
Textual Gantt rendering. |
planscript/serializer/plan_serializer.py |
Project → .plan text. Incomplete; not wired into the CLI. |
planscript/tests/ |
unittest suite (225 tests) plus shared project fixtures. |
_archive/ |
Superseded interactive CLI and the original standalone invoice model. |
All model types are dataclasses. Identity is by task number; Task objects are
shared by reference between the project and its dependencies, so renaming a
task number keeps its dependencies attached.
| Attribute | Type | Notes |
|---|---|---|
name |
str |
Project name from the project: line. |
start_date |
date | None |
Planned/target start. Soft; not a constraint. |
finish_date |
date | None |
Planned/target finish. Soft; not a constraint. |
calendar |
str | None |
Calendar name from the calendar: attribute; stored, not interpreted. |
tasks |
dict[str, Task] |
Keyed by task number, kept sorted by number. |
dependencies |
list[Dependency] |
All dependency relationships. |
calendars |
dict[str, Calendar] |
Declared calendars. Currently always empty. |
budget |
Budget |
Derived; populated by Budgeter. |
schedule |
Schedule | None |
Derived; populated by Scheduler. |
tracker |
Tracker |
Authoritative tracking history and derived state. |
metadata |
dict |
Project-level descriptive metadata. |
Operations: add_task, remove_task (also removes its dependencies),
renumber_task, sort_tasks, list_tasks, add_dependency,
remove_dependency, get_predecessors, get_successors,
get_incoming_dependencies, get_outgoing_dependencies, validate.
Project.validate() is the single entry point for model validation and runs
date, summary, dependency, budget, duration, and tracking checks.
| Attribute | Type | Notes |
|---|---|---|
number |
str |
Hierarchical identifier such as 4.2.a; the dictionary key. |
name |
str |
Display name only. |
duration |
timedelta | None |
None = summary task; 0 = milestone. |
budget |
Decimal | None |
Explicit budget. |
budget_wt |
Decimal | None |
Percentage weight of the budgeted ancestor. |
metadata |
dict |
Free-form task metadata. |
Also Task.is_milestone. A task-level calendar field is planned but not yet
implemented.
| Attribute | Type | Notes |
|---|---|---|
predecessor |
Task |
Task that establishes the constraint. |
successor |
Task |
Task whose schedule is constrained. |
dep_type |
DependencyType |
FS (default), SS, FF, SF. |
lag |
timedelta |
Signed offset; defaults to zero. |
lag_unit |
str |
Unit the lag was authored in (h/d/w). |
DependencyGraph is a derived index of the project's Dependency objects
(predecessors/successors keyed by task ID). topological_sort() returns
task IDs in dependency order and raises ValueError if the graph contains a
cycle.
Derived purely from task numbers: 1.2.3 belongs to 1.2 when that task
exists. When an implied parent does not exist, the task attaches to its
nearest existing ancestor — with 1 present but 1.2 absent, 1.2.3 is a
child of 1 — so projects need not declare every level; a task with no
existing ancestor is a root. This is separate from the dependency
graph. API: get_parent, get_children, get_roots, get_leaves,
get_leaf_ids, has_children, is_summary, get_descendants,
get_ancestors, get_tree.
id, name, working_days (0 = Monday), and holidays, with
is_working_day, next_working_day, previous_working_day,
add_working_days, and working_days_between.
Status: implemented as a model but not yet used by the scheduler, tracker, or analyzer. Dates are currently scheduled on plain calendar days.
hierarchy, ordered_task_ids, early_start, early_finish, late_start,
late_finish, total_float, critical_tasks, critical_paths, duration,
start_dates, finish_dates.
CPM values are stored as timedelta offsets from the project's planned start
day; start_dates/finish_dates are the calendar-day projections of those
offsets when the project has a start_date, and None when it does not (a
calculated-only schedule). Summary tasks have None float because they do
not participate in CPM.
TaskEvent(date, task_id, directive, info)— one dated event.EventDirective—START,PROGRESS,COMPLETE,NOTE.TaskStatus—NOT_STARTED,STARTED,IN_PROGRESS,COMPLETED.TaskState(task_id)withstatusandpercent_complete, derived by replaying the task's events in date order (_derive).Invoice(invoice_date, invoice_amount, allocations)withadd_allocationandvalidate(allocations must equal the invoice amount).Tracker(hierarchy, task_events, invoice_events)derivesactual_start,actual_finish,actual_dates,actual_duration,actual_cost, andget_task_state.
Actual dates for summary tasks are rolled up from descendants: earliest actual start, latest actual finish (and a summary is only finished when all children are).
Analyzer(project, as_of) computes start_variance, finish_variance,
duration_variance, task_variance, cost_variance (actual cost minus
resolved budget for one task), total_cost_variance (project actual minus
budget total), project_actual_start, and project_progress
(duration-weighted percent complete across leaf tasks). Schedule variance is
positive when later than planned; cost variance is positive when over budget.
Scheduler().calculate(project) -> Schedule runs these stages:
- Build a
TaskHierarchyand aDependencyGraph. - Topologically sort tasks by dependency (cycle ⇒
ValidationErrorat validation time). - Forward pass — for each leaf task, earliest start is the maximum imposed
start across its predecessors:
FS:ES(succ) = EF(pred) + lagSS:ES(succ) = ES(pred) + lagFF:EF(succ) = EF(pred) + lag, thenES = EF − durationSF:EF(succ) = ES(pred) + lag, thenES = EF − durationTasks with no predecessors start at offset 0.EF = ES + duration.
- Backward pass — project duration is the maximum early finish; latest finish starts there and propagates back through successors.
- Float —
total_float = LS − ES. Summary tasks getNone. - Critical tasks — total float exactly zero.
- Critical paths — walks the sub-graph of critical tasks connected by tight dependencies (those that actually impose the successor's early start). Branches produce multiple paths.
- Dates — offsets are projected onto calendar days from the project's
start_date. A milestone finishes on its start day; other tasks finish one day beforeEF. Summary dates roll up: earliest descendant start, latest descendant finish.
Rules and current limits:
- Durations and offsets are treated as plain calendar days. The project's
calendar:is not consulted yet. - Without a project
start_datethe schedule is calculated-only:start_dates/finish_datesareNone. The scheduled view and Gantt chart print a note,Analyzervariance andReportBuilderraiseSchedulingError, and thestatuscommand exits1. - Summary tasks are excluded from CPM and only receive rolled-up dates.
Records events and derives per-task actuals. Every derived value is measured to
a single data date (as_of), which is the report's reference date and
defaults to today. Events and invoices dated after the data date are ignored,
so historical state is deterministic and a future-dated entry never makes a
task look started or finished. future_dated_events(as_of) returns the entries
a data date excluded, so callers can report them instead of silently dropping
them.
actual_start— the task'sstartevent; for a summary, the earliest descendant actual start.actual_finish— the task'scompleteevent; for a summary, the latest descendant finish, and only when every descendant is finished.actual_duration— inclusive calendar days:finish − start + 1, oras_of − start + 1while the task is open. Because the data date limits the events considered, an elapsed duration is never negative, and a task whose start is recorded after the data date has no actual duration yet.actual_cost— sum of invoice allocations to the task up to the data date, plus all descendant costs for a summary.get_task_state— replays events up to the data date to deriveTaskStatusandpercent_complete, raisingValidationError/ParseErrorfor invalid sequences instead of repairing them.
Compares Schedule and task durations against Tracker actuals at the same
data date (Analyzer(project, as_of), defaulting to today):
start_variance/finish_variance— actual minus planned date,Noneuntil the corresponding actual exists; raisesSchedulingErrorwhen the schedule has no calendar dates (no projectstart_date).duration_variance— actual (or elapsed, measured toas_of) duration minus planned duration. Summary planned duration comes from scheduled dates (raisingSchedulingErrorwhen the schedule has none). A milestone that has happened has zero duration variance; one that has not reportsNone.planned_progress/actual_progress— 0..1 fractions for a task. Planned progress counts calendar days toas_of, inclusive of the planned start day and capped at the planned duration; a milestone counts as planned once its date is reached. Actual progress comes from the tracking events up toas_of; a milestone reports as complete or not complete, since it has no duration to measure.planned_project_progress/actual_project_progress— duration-weighted averages over leaf tasks with a positive duration, so milestones and summary tasks do not weight the project figure.project_actual_start— earliest actual start across tasks.cost_variance/total_cost_variance— actual minus budget, where a positive value is over budget.project_consumed_cost— actual cost divided by the planned budget, orNonewhen the project has no planned budget.
Budgeter().calculate(project) -> Budget performs two passes:
- Allocate top-down. Each task keeps its explicit amount (which becomes the base for its own children). Weighted siblings split their nearest budgeted ancestor's amount by percentage. Shares are allocated in whole cents, with leftover cents awarded to the largest fractional shares (ties broken by task number), so weighted children always sum exactly to the parent.
- Roll up bottom-up. A summary task without its own budget takes the sum
of its resolvable children (partially resolvable summaries sum what is
known). Leaf tasks with no determinable budget are reported in
Budget.unallocatedrather than guessed.
Budget.total sums only root tasks, so summaries are not double counted.
Legality of an authored budget (explicit vs. weighted, sibling consistency,
weight totals) is enforced by Project.validate(), not by the Budgeter.
ReportBuilder(project, as_of, look_ahead).build() -> ProjectReport composes
the analyzer and tracker into a status report:
- Project status:
Not Started(no leaf has begun),Started(work has begun but nothing is finished or progressing),In Progress,Completed(every leaf is complete). - Per-task
ScheduleCondition:Blocked(planned start passed, a predecessor is incomplete),Late(planned start passed, predecessors complete, not started),Overdue(started but planned finish passed), otherwiseOn Schedule. Unstarted conditions are evaluated before overdue so un-actioned work surfaces first, and completion is judged at the data date. - For blocked tasks,
blocked_bylists incomplete predecessors androot_causeswalks the dependency chain to the terminal blockers that must actually be actioned. (Both are computed but not yet rendered: seeROADMAP.mdP1-3.) ProjectBudgetReportandTaskBudgetReportcarry only the planned and actual amounts;remaining(budget − actual) andcost_variance(actual − budget) are derived from them, so the two can never disagree.- A value that cannot be derived at the data date is reported as
n/a, never as a zero: a zero row means a real zero. AData Noticessection lists tracking entries dated after the report date, so a truncated figure is explained rather than silently dropped. - Milestones report no duration (no duration variance, no inclusive one-day
span), and per-task
budget_consumedisn/afor a task with no budget.
Example (python -m planscript status Simple.plan -ao 2026-10-05):
Status Report as-of 2026-10-05
=======================================
Project Name: Variance Test Project
Status: In Progress
--------------------------------------
Project Schedule Report
--------------------------------------
Planned Start: 2026-08-01
Planned Finish: 2026-11-30
Planned Duration: 48d
Actual Start: 2026-08-01
Forecast Finish: n/a
Schedule Variance: n/a
--------------------------------------
Project Budget Report
--------------------------------------
Planned Budget: $304,650.50
Actual Cost: $55,862.24
Remaining Budget: $248,788.26
Cost Variance (actual - budget): ($248,788.26)
--------------------------------------
Project Progress Report
--------------------------------------
Planned Progress: 100.0%
Actual Progress: 74.1%
Budget Consumed: 18.3%
1.1 - Kickoff
--------------------------------------
Schedule Report Status: On Schedule
--------------------------------------
Planned Start / Finish: 2026-08-01 / 2026-08-01
Planned Duration: - (milestone)
Actual Start / Finish: 2026-08-01 / 2026-08-01
Duration Variance: 0d (milestone)
--------------------------------------
Budget Report
--------------------------------------
Planned Budget: $3,400.00
Actual Cost: $2,500.00
Remaining Budget: $900.00
Cost Variance (actual - budget): ($900.00)
--------------------------------------
Progress Report
--------------------------------------
Planned Progress: 100.0%
Actual Progress: 100.0%
Budget Consumed: 73.5%
Forecast fields (Forecast Finish, Schedule Variance, per-task forecast
duration) are reserved but not yet derived; they print n/a until P3-8
implements forecasting.
python -m planscript status Simple.plan -ao 2026-09-26 (the real data date at
the time of writing) additionally prints a Data Notices section, because
Simple.plan records a 3.4 start entry dated 2026-09-27 - after the report
date. That entry is excluded from every figure and announced instead.
Entry point: python -m planscript <command> <file.plan> (planscript/app.py).
| Command | Options | Behavior |
|---|---|---|
check |
— | Parses and validates; prints the project name and Valid. |
summary |
— | Project header (name, duration when scheduled, target dates) plus task, dependency, and tracking counts. Does not schedule. |
schedule |
-d/--dates, -c/--calculated, -g/--gantt |
Calculates the schedule. With no flags all three views plus critical paths are shown. Without a project start_date, the dates view and Gantt chart print a note instead of calendar output. |
status |
-ao/--as-of YYYY-MM-DD (default today), -la/--look-ahead DAYS (default 21) |
Builds and renders a status report. Requires a project start_date; without one it fails with SchedulingError (exit 1). |
budget |
-ao/--as-of YYYY-MM-DD (default today) |
Resolves the budget and prints the budget table. |
Exit codes: 0 success, 1 expected failure (missing file, decode error,
ParseError, ValidationError, SchedulingError, BudgetingError), 2
argparse usage errors. Expected failures print a one-line message to stderr;
unexpected exceptions are deliberately not caught and surface as tracebacks.
Views:
display.view_project_summary/view_project_header.display.view_schedule_calculated— ID, task, duration, ES, EF, LS, LF, float.display.view_schedule_scheduled— start/finish dates and float; prints a note when the schedule is calculated-only (nostart_date).display.view_budget— resolved amount and basis (explicit, weight,rollup,unallocated).display.view_critical_paths.cli/gantt.render_gantt— textual bar chart scaled by project duration; prints a note when the project has nostart_date.displayalso still contains the earlier interactive menu functions. They are only referenced by_archive/app.pyand are not part of the current CLI.
Known CLI defects (tracked in ROADMAP.md):
scheduleprints an arrow character (→) that raisesUnicodeEncodeErrorwhen stdout is redirected or piped on a Windows ANSI code page.
PlanSerializer.serialize(project) writes a .plan document. It is a skeleton:
project metadata, calendars, budgets, invoice/tracking events, and metadata are
not yet emitted, lag formatting is approximate, and it writes
dependency <predecessor> > <successor> … lines rather than the depends
syntax the parser accepts. It is not referenced by the CLI, so the CLI is
currently read-only.
Tracking history lives in the same authoritative .plan file as the plan
(see Purpose). Event syntax, directives, and lifecycle rules are enforced by
the parser and TaskState._derive and are documented in SYNTAX.md; this
section records the design decisions behind them.
- Tracking is optional. A project with no events is untracked; one with at least one valid event is tracked. A tracked project does not need every task covered: a task with no history derives as Not Started at 0%, and there is no project-level "tracking enabled" flag.
- State is derived, never stored.
TaskStatereplays a task's events in date order; events carry dates, not timestamps (day-level precision), and no historical snapshots are kept. The same history always derives the same state — "today" only enters when a report or analysis is given anas_ofdate. - Plan, actual, and forecast stay distinct. The plan is what was intended; actuals derive only from tracking, so a planned date passing never creates an actual start or finish; forecasts combine plan and actuals and are derived information that is never written back.
- Explicit versus calculated progress stays a real distinction:
progressmeans the user reported it, andcompleteimplies 100%. Any future calculated-progress mechanism must never be presented as though the user reported it (ROADMAP.mdP3-10). - Designed but not yet enforced — same-day lifecycle precedence, rejecting
future-dated events, duplicate same-day detection, and tracking on summary
tasks — are listed under Current Gaps and tracked as
ROADMAP.mdP1-2.
Validation is layered so that each failure is reported by the layer that owns the rule:
- Parser (
ParseError) — line-level syntax and structure: missing or duplicateproject:, content before the project, unindented attributes, duplicate attributes, invalid dates, invalid durations, unknown task references independs/tracking/allocation lines, self-dependencies, duplicate dependencies, malformed directive arguments, and unrecognized lines. - Model (
ValidationError, viaProject.validate()) — project start after finish, summary tasks with durations, leaf tasks without durations, dependencies touching summary tasks, cycles, negative durations, budget legality (explicit vs. weighted, sibling consistency, weight totals, percentage bounds, budgeted ancestor required), and tracking references and invoice allocation totals. - State derivation (
ValidationError/ParseError) — event lifecycle rules when aTaskStateis derived (start once, no progress before start, no progress after complete, complete once, 0–100%). - Engines (
SchedulingError,BudgetingError) — nothing to schedule or budget, and weighted budgets with no allocatable ancestor.
The suite uses Python's built-in unittest; there is no third-party test
dependency. Currently 225 tests; all pass except one assertion in
test_agreed_cli_regressions that awaits the section rendering of
ROADMAP.md P1-3.
python -m unittest discover -s planscript/tests -t .| Test module | Focus |
|---|---|
test_parser.py |
Project/task/metadata/budget/dependency syntax, error messages, line references. |
test_model.py |
TaskHierarchy behavior and budget validation rules. |
test_scheduler.py |
CPM examples across dependency types, branching, merging, float, critical paths. |
test_tracking.py |
Event parsing, lifecycle/derivation rules, actual dates and costs. |
test_budgeter.py |
Explicit, weighted, nested, rollup, remainder-cent, and unallocated cases. |
test_performance.py |
Variance calculations against tracked plans. |
test_cli.py |
Subcommand behavior and exit codes through subprocesses. |
test_projects.py |
Shared in-memory project fixtures for the scheduler tests. |
Test conventions: fixtures live in test_projects.py; CLI tests run the module
as a subprocess with PYTHONIOENCODING=utf-8; tests assert on messages rather
than tracebacks.
The design intent and the implementation are not yet aligned in these areas.
ROADMAP.md tracks them with priorities:
- Calendars exist as a model but are unused, so schedules and actual durations ignore working days, holidays, and working hours.
_archive/holds superseded code, anddisplay.pyretains unused menu functions.- The serializer is incomplete and not wired to the CLI, so plans can be read but not written back.
- The status report lists every task but not the summary sections (overdue,
blocked, late, upcoming deadlines/starts) that
ROADMAP.mdP1-3 still asks for, and forecast finish / schedule variance are not yet derived. - Tracking design decisions that are not yet implemented: same-day lifecycle precedence, rejection of future-dated events, duplicate same-day detection, and a decision on tracking summary tasks.