What it does
Each commit in a GitHub repo becomes an activity. Each parent→child relationship becomes a dependency. The engine runs a topological sort, forward pass, and backward pass over that graph — the same three passes any CPM textbook teaches for a construction schedule — and reports which commits sat on the critical chain and which branches had slack before they needed to merge.
Merge commits create fan-in edges, so parallel branches surface as parallel paths in the network diagram, not a flattened line.
Build log
Six sessions, three months elapsed, in the order they actually happened — not a plan written in advance.
- 630d7a6 Symfony webapp scaffold with SQLite configuration
- 32aa06c Add Project and Activity entities with Doctrine migration
- 915fb35 / ae967d2 Documentation and README pass
- ee2b02d Overhauling documentation according to optimal template for better readability
- 670dec2 Add ActivityDependency, with incoming / outgoing collections on Activity
- 7c0e004 Activity dependency table for Critical Path Service
- d67141e Implement Critical Path Service — forward/backward pass, slack, tests
- 582edfa Clean up comments
- e979bd0 Updating agent progress
The test above (testEightActivityExample) is the one that shipped here. The known-issues log from this session records a docs typo caught against it: activity D's slack was written as 6, the test says 9 — LS(12) − ES(3). The test was the tiebreaker, not the doc.
- 1db309f Update docs and dependencies
- d61d39b Add project API endpoints with demo fixtures
- 0189be0 / 3af297f Allow demo projects without repo URL (same message, twice — a redo, not a duplicate)
- 51c1453 Static demo page for visualized project flow
- 11ca1e1 Add GitHub commit import — URL parser, commit fetcher, activity mapper
- 8d8e2f4 Closing out with polished demo and reframed for retrospective instead of prospective analysis
The case study: what broke, on the last day
The GitHub importer was the last feature, and it's also what surfaced the project's actual limit. CPM is a prospective planning method — it needs someone to estimate durations before work starts, so a schedule can be optimized against those estimates. Commit history is unavoidably retrospective: it only records what already happened. No amount of API polish closes that gap without a person supplying estimates by hand, which isn't what a GitHub importer is for.
Rather than keep the "can't slip," "breathing room," "minimum time to finish" planning language and let it misdescribe what the tool actually does, the last commit renamed the vocabulary: critical chain, had buffer, total elapsed time. The Mermaid diagram was also cut down from all ~100 commit edges to just the critical-path edges, and the transcript-parser UI for "describe any project" was removed — it called an endpoint, /api/projects/parse, that had never been implemented.
The alternative was extending the data model — issues, PRs, and manual duration input as a second, prospective input path alongside the commit importer. That would have made the tool actually do PM planning again. It was scoped out on purpose: the entity model (Project → Activity → ActivityDependency) and the mapper were built around one shape of input, commit graphs, and bending that into two incompatible planning modes was a rewrite, not a feature.
"This mismatch was identified during development. The project was closed out as a working portfolio artifact rather than extended into something it couldn't be." — README, Status
Closing a project on purpose is a decision, not a stall. The engine and the importer both work end-to-end against real repos; the thing that doesn't exist is a feature the tool was never actually positioned to have.
How it's put together
ES / EF / LS / LF / slack are computed on request, never persisted — CriticalPathService is a pure function over activities and dependencies, so re-running it against edited data can't drift from stored numbers because there are none to drift.
// GitHubActivityMapper — duration is elapsed time, not effort
$latestParentDate = max(array_map(fn($sha) => $dates[$sha], $inWindowParents));
$seconds = $dates[$commit['sha']]->getTimestamp() - $latestParentDate->getTimestamp();
$duration = (float) max(0, (int) round($seconds / 3600));
A commit's "duration" is the rounded hours since its latest in-window parent — the same elapsed-vs-effort gap the build log above shows by hand: a 43-day gap between sessions isn't 43 days of work, and neither is a commit dated 43 days after its parent.
| Method & path | Returns |
|---|---|
GET /api/projects | All imported projects |
GET /api/projects/{id} | Project name and id |
GET /api/projects/{id}/critical-path | ES, EF, LS, LF, slack, is_critical per activity |
GET /api/projects/{id}/network-diagram | Nodes, edges, Mermaid string, project duration |
POST /api/github/import | Parses a repo URL, fetches commits, persists the graph — 400/404/502 on bad input, missing repo, or GitHub API failure |
Known limitations, stated on purpose
Documented in the README rather than discovered by a user — these are the edges of v0, not bugs:
- No pagination — only the most recent 100 commits per import (GitHub's default page size)
- No URL normalization —
.gitsuffix and trailing-slash variants of the same repo count as separate projects - Re-import replaces in place — same URL overwrites that project's activities under the same
project_id, rather than versioning - No route-level test for the import endpoint — the mapper has unit tests; controller persistence was verified by hand against three real repos
Retrospective
Held up
- Textbook CPM (Kahn's-algorithm topo sort, forward/backward pass) implemented once and never touched again after 2026-04-29
- The eight-activity test fixture caught a real docs error before it shipped
- Import verified end-to-end against three real repos, not just fixtures
Would do differently
- Naming the prospective/retrospective mismatch on day one — it was checkable before any code, not just after the importer existed
- A route-level test for
/api/github/import, not just the mapper - URL normalization before re-import semantics, since one depends on the other