diff --git a/docs/agentic/PROJECT-MANAGEMENT-TEMPLATE-CATALOG.md b/docs/agentic/PROJECT-MANAGEMENT-TEMPLATE-CATALOG.md new file mode 100644 index 000000000..a5a2c5215 --- /dev/null +++ b/docs/agentic/PROJECT-MANAGEMENT-TEMPLATE-CATALOG.md @@ -0,0 +1,118 @@ +# Project-Management / Gantt Integration Source Catalog + +**Status:** Reconsolidated research and implementation intake +**Updated:** 2026-09-21 +**Authority:** Discovery catalog only. The repository-native dependency-phase plan remains authoritative. + +## Purpose + +This catalog consolidates the prior Gantt work in termux-monorepo, the user's forked Gantt repositories, and a fresh external search of project-management templates. The goal is pattern extraction + deterministic adapters, not replacing the repository's control plane with a third-party UI. + +The existing architecture already establishes the key boundary: phases, dependencies, approvals, PR/check evidence, and GitHub Project reconciliation are authoritative; Gantt/Markdown/Mermaid/dashboard views are derived. + +## Reconnaissance: user-owned Gantt forks + +| Source | Role discovered | Reusable capability | Disposition | +|---|---|---|---| +| timerloggedout-spec/GanTTY_fork | Python terminal planner | interactive dependency editing, task state, compact terminal UX | reference only | +| timerloggedout-spec/ganttless_fork-agentic | Rust ASCII renderer | deterministic compact timeline from YAML/CLI input | renderer adapter | +| timerloggedout-spec/gantt-cli_fork_agentic | Rust TUI planner | parent/child tasks, dependencies, topological scheduling, JSON persistence, undo/redo | exporter/pattern adapter | +| timerloggedout-spec/Gantt-Chart-Code_fork | Rust program ingester | flat-record to hierarchy transformation | ingestion pattern | +| timerloggedout-spec/montt_fork | Rust Monte Carlo Gantt forecasting | resource/estimate DSL and probabilistic schedule concepts | deferred research seed | + +These are forks of upstream projects rather than authoritative monorepo components. The current fork metadata identifies the upstreams as timeopochin/GanTTY, kyoheiu/ganttless, zhangjinshui-nerveee/gantt-cli, bytesandbalance/Gantt-Chart-Code, and simon-siggaard/montt. + +## Prior repository work recovered + +- docs/agentic/dependency-phases.json — canonical phase/dependency model and GitHub Project identifiers. +- scripts/agentic/dependency_phase_engine.py — validation, stable plan hashing, topological ordering, wave calculation, evidence evaluation, and derived Mermaid/Markdown rendering. +- docs/proposals/active/gantt-dependency-phases/ — proposal, work items, manifest, source, and acceptance contract. +- docs/agentic/TEMPLATE_CAPABILITY_ASSESSMENT.md — earlier source-reviewed candidate assessment. +- docs/agentic/template-candidates.yaml — machine-readable candidate registry. +- termux-multi-agent/templates/gantt_core.py — historical Gantt prototype; useful provenance, but it uses ambient wall-clock time and is not suitable as deterministic control-plane implementation. + +The proposal explicitly defines the Gantt representation as a derived view that cannot authorize dispatch or completion. That remains the governing rule. + +## Fresh external template/search corpus + +| Source | Useful pattern | Integration class | Key caveat | +|---|---|---|---| +| Project Planner | dependency-first planning, Gantt + graph + Kanban, critical path, suggested dates, Markdown/Dataview persistence | data-model/reference | Obsidian plugin domain | +| task-cli | AI decomposition -> tasks -> Gantt -> report/Q&A CLI lifecycle | agent workflow reference | LLM planning must not become authority | +| GanttReady | CPM, EVM, resources, calendars, AI scheduling | scheduling research | .NET/SQLite application boundary | +| Agentic Project Management | persistent agent context, manager/worker/handoff model | orchestration reference | broader agent framework | +| Agent Kanban | agents as first-class actors, assignment/provenance/dependencies/review | agent-control reference | board product, not repository SSOT | +| DuneBoard | Markdown SSOT, task graph, parent/child, dependencies, readiness rules | schema/reference | local task-board product | +| Plandeck | durable file plans, deterministic completion gate, dependency unlocks, critical path | agent skill reference | CLI-oriented | +| It's a Plan | project/issue/cycle/timeline model, REST/MCP/webhooks, agents as project members | integration reference | AGPL core; active development | +| Taskboard | local PM + CLI + MCP, tickets/dependencies/teams, single-binary model | MCP integration reference | SQLite application boundary | +| Pith | MCP-first, CLI-native agent/human task management, subtask/progress APIs | MCP integration reference | external service model | +| kanban-mcp | persistent items, relationships/epics, blocking edges, semantic search | MCP/relationship reference | database-backed board | +| pm-gantt-chart | dependency-aware schedule, critical path, slack, Mermaid/HTML/SVG/CSV/JSON exports | Gantt exporter reference | preserve canonical IDs | +| gantts-app | WBS, CPM, baselines, resources, calendars, file-based interchange | scheduling/UI reference | browser-first application | +| GanttProject | hierarchy, dependencies, milestones, baselines, resources, costs, interoperability | mature PM reference | desktop application | + +## Consolidated capability vocabulary + +1. Stable identity: string IDs that survive reorder and rendering. +2. Dependency DAG: explicit predecessor relationships; cycles fail closed. +3. WBS / hierarchy: parent-child decomposition without conflating hierarchy with dependency. +4. Readiness: deterministic predicate derived from prerequisites and evidence. +5. Schedule projection: dates derived from dependencies, durations, calendars, and explicit anchors. +6. Critical path / float: analytical output, never authorization. +7. Resources / ownership: planning and workload metadata. +8. Progress / evidence: status tied to observable artifacts, not a chart state. +9. Human gates: approval is explicit evidence. +10. Agent provenance: actor, claim, run, PR, SHA, and plan hash are first-class. +11. Multiple projections: Mermaid, ASCII, JSON, HTML, dashboards, and GitHub Projects can consume one normalized model. +12. MCP/API adapters: external PM systems can be integration surfaces without becoming repository authority. + +## Consolidation decision + +### Keep as canonical + +docs/agentic/dependency-phases.json + deterministic evaluator + repository proposal/check/PR evidence. + +### Add now + +A standard Gantt projection contract that converts the canonical phase/evaluation model into stable-ID schedule records. It supports dependency-derived relative waves, optional deterministic date anchoring, explicit duration input, critical-path calculation, JSON output for agents/tools, and Mermaid Gantt output for documentation. It never writes back to the canonical plan. + +### Defer + +- third-party Gantt submodules; +- database-backed PM systems as canonical state; +- autonomous AI scheduling that mutates phase dates; +- probabilistic forecasts until empirical duration history exists; +- visual boards in CI; +- automatic merge/closure. + +## Adapter contract + +Every future PM/Gantt adapter should consume and preserve: phase_id, title, depends_on, state, wave, duration_days, start, end, critical, plan_sha256. + +An adapter may add presentation metadata but may not rewrite identity, dependency edges, approvals, or completion evidence. + +## Licensing / provenance rule + +Third-party code is not copied merely because a feature is useful. Before adopting implementation code, record upstream repository, revision, license, provenance, and fixture parity. Prefer small compatibility adapters and generated outputs over vendoring entire PM applications. + +## Current implementation target + +scripts/agentic/gantt_projection.py implements the first normalized projection. It is dependency-only when no date anchor is supplied, and becomes a deterministic date schedule only when the operator supplies an explicit start date and duration mapping. This avoids inventing calendar commitments from a visualization. + +## Sources + +- https://github.com/hmil1151/project-planner +- https://github.com/sunjiawe/task-cli +- https://github.com/fvftuu/GanttReady +- https://github.com/sdi2200262/agentic-project-management +- https://github.com/saltbo/agent-kanban +- https://github.com/KaEvDm/DuneBoard +- https://github.com/OthmanAdi/plandeck +- https://github.com/croffasia/itsaplan +- https://github.com/tcarac/taskboard +- https://github.com/SiluPanda/pith +- https://github.com/multidimensionalcats/kanban-mcp +- https://github.com/unbraind/pm-gantt-chart +- https://github.com/Synth88Labs/gantts-app +- https://github.com/bardsoftware/ganttproject diff --git a/scripts/agentic/gantt_projection.py b/scripts/agentic/gantt_projection.py new file mode 100644 index 000000000..6940f42f2 --- /dev/null +++ b/scripts/agentic/gantt_projection.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Project the canonical dependency-phase plan into a stable Gantt interchange model.""" + +from __future__ import annotations + +import argparse +import json +from datetime import date, timedelta +from pathlib import Path +from typing import Any + +from dependency_phase_engine import compute_waves, plan_digest, topological_order, validate_plan + + +def _load_json(path: Path) -> dict[str, Any]: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError(f"{path}: expected a JSON object") + return value + + +def _parse_date(value: str) -> date: + try: + return date.fromisoformat(value) + except ValueError as exc: + raise ValueError(f"invalid --start-date: {value!r}; expected YYYY-MM-DD") from exc + + +def _durations(phases: list[dict[str, Any]], default_days: int, mapping: dict[str, Any] | None) -> dict[str, int]: + if default_days < 1: + raise ValueError("--duration-days must be >= 1") + mapping = mapping or {} + phase_ids = {str(p["phase_id"]) for p in phases} + result: dict[str, int] = {} + for phase in phases: + phase_id = str(phase["phase_id"]) + raw = mapping.get(phase_id, default_days) + if isinstance(raw, bool) or not isinstance(raw, int) or raw < 1: + raise ValueError(f"duration for {phase_id} must be a positive integer") + result[phase_id] = raw + unknown = sorted(set(mapping) - phase_ids) + if unknown: + raise ValueError("duration mapping contains unknown phase IDs: " + ", ".join(unknown)) + return result + + +def _critical_path(phases: list[dict[str, Any]], durations: dict[str, int]) -> set[str]: + by_id = {str(p["phase_id"]): p for p in phases} + best_end: dict[str, int] = {} + best_chain: dict[str, list[str]] = {} + for phase_id in topological_order(phases): + deps = [str(d) for d in by_id[phase_id].get("depends_on", [])] + if not deps: + best_end[phase_id] = durations[phase_id] + best_chain[phase_id] = [phase_id] + continue + predecessor = max(deps, key=lambda dep: (best_end[dep], dep)) + best_end[phase_id] = best_end[predecessor] + durations[phase_id] + best_chain[phase_id] = best_chain[predecessor] + [phase_id] + if not best_chain: + return set() + sink = max(best_chain, key=lambda phase_id: (best_end[phase_id], phase_id)) + return set(best_chain[sink]) + + +def _validate_report(report: dict[str, Any], phases: list[dict[str, Any]], plan: dict[str, Any]) -> None: + evaluations = report.get("evaluations") + if not isinstance(evaluations, list): + raise ValueError("report.evaluations must be a list") + + phase_ids = {str(phase["phase_id"]) for phase in phases} + seen: set[str] = set() + required = { + "idempotency_key", + "phase_id", + "project_item_id", + "project_status", + "pull_requests", + "reason", + "state", + } + allowed_states = {"complete", "ready", "waiting", "blocked", "unknown"} + + for index, item in enumerate(evaluations): + if not isinstance(item, dict): + raise ValueError(f"report.evaluations[{index}] must be an object") + missing = sorted(required - set(item)) + if missing: + raise ValueError( + f"report.evaluations[{index}] missing required fields: " + ", ".join(missing) + ) + phase_id = item["phase_id"] + if not isinstance(phase_id, str) or not phase_id: + raise ValueError(f"report.evaluations[{index}].phase_id must be a non-empty string") + if phase_id not in phase_ids: + raise ValueError(f"report.evaluations[{index}].phase_id is unknown: {phase_id}") + if phase_id in seen: + raise ValueError(f"report.evaluations contains duplicate phase_id: {phase_id}") + seen.add(phase_id) + if not isinstance(item["idempotency_key"], str) or not item["idempotency_key"]: + raise ValueError(f"report.evaluations[{index}].idempotency_key must be a non-empty string") + if not isinstance(item["pull_requests"], list) or any( + isinstance(pr, bool) or not isinstance(pr, int) for pr in item["pull_requests"] + ): + raise ValueError(f"report.evaluations[{index}].pull_requests must be a list of integers") + if not isinstance(item["reason"], str): + raise ValueError(f"report.evaluations[{index}].reason must be a string") + if item["state"] not in allowed_states: + raise ValueError(f"report.evaluations[{index}].state is invalid: {item['state']!r}") + + if report.get("plan_id") not in (None, plan["plan_id"]): + raise ValueError("report.plan_id does not match the supplied plan") + if report.get("plan_sha256") not in (None, plan_digest(plan)): + raise ValueError("report.plan_sha256 does not match the supplied plan") + missing_phase_ids = sorted(phase_ids - seen) + if missing_phase_ids: + raise ValueError("report.evaluations is missing phase IDs: " + ", ".join(missing_phase_ids)) + + +def project( + plan: dict[str, Any], + report: dict[str, Any] | None = None, + start_date: date | None = None, + default_duration_days: int = 1, + duration_mapping: dict[str, Any] | None = None, +) -> dict[str, Any]: + errors = validate_plan(plan) + if errors: + raise ValueError("plan validation failed:\n- " + "\n- ".join(errors)) + + phases = plan["phases"] + waves = compute_waves(phases) + durations = _durations(phases, default_duration_days, duration_mapping) + critical = _critical_path(phases, durations) + + evaluation_by_id: dict[str, dict[str, Any]] = {} + if report: + _validate_report(report, phases, plan) + evaluation_by_id = {str(item["phase_id"]): item for item in report["evaluations"]} + + by_id = {str(p["phase_id"]): p for p in phases} + tasks: list[dict[str, Any]] = [] + computed_end: dict[str, date] = {} + + for phase_id in topological_order(phases): + phase = by_id[phase_id] + deps = [str(d) for d in phase.get("depends_on", [])] + task: dict[str, Any] = { + "id": phase_id, + "title": str(phase["title"]), + "type": "phase", + "state": evaluation_by_id.get(phase_id, {}).get("state", "unknown"), + "dependencies": deps, + "wave": waves[phase_id], + "duration_days": durations[phase_id], + "critical": phase_id in critical, + } + if start_date: + start = max((computed_end[d] for d in deps), default=start_date) + end = start + timedelta(days=durations[phase_id] - 1) + computed_end[phase_id] = end + timedelta(days=1) + task["start"], task["end"] = start.isoformat(), end.isoformat() + tasks.append(task) + + return { + "schema_version": 1, + "projection": "gantt.interchange.v1", + "authority": "derived", + "schedule_mode": "dependency-derived" if start_date else "relative-wave", + "plan_id": plan["plan_id"], + "plan_sha256": plan_digest(plan), + "tasks": tasks, + } + + +def render_mermaid_gantt(projection: dict[str, Any]) -> str: + lines = [ + "gantt", + f" title {projection['plan_id']} (derived)", + " dateFormat YYYY-MM-DD", + " axisFormat %Y-%m-%d", + ] + if projection["schedule_mode"] != "dependency-derived": + lines.append(" %% No calendar anchor supplied; use JSON wave data for relative planning.") + return "\n".join(lines) + "\n" + + for task in projection["tasks"]: + task_id = task["id"].replace("-", "_") + title = " ".join(str(task["title"]).split()).replace(":", " - ") + marker = "crit, " if task["critical"] else "" + lines.append(f" {title} :{marker}{task_id}, {task['start']}, {task['duration_days']}d") + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--plan", default="docs/agentic/dependency-phases.json") + parser.add_argument("--report") + parser.add_argument("--start-date") + parser.add_argument("--duration-days", type=int, default=1) + parser.add_argument("--durations-json") + parser.add_argument("--format", choices=("json", "mermaid"), default="json") + parser.add_argument("--output") + args = parser.parse_args() + + plan = _load_json(Path(args.plan)) + report = _load_json(Path(args.report)) if args.report else None + start = _parse_date(args.start_date) if args.start_date else None + mapping = _load_json(Path(args.durations_json)) if args.durations_json else None + projection = project(plan, report, start, args.duration_days, mapping) + output = render_mermaid_gantt(projection) if args.format == "mermaid" else json.dumps(projection, indent=2, sort_keys=True) + "\n" + + if args.output: + Path(args.output).write_text(output, encoding="utf-8") + else: + print(output, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/agentic/test_gantt_projection.py b/tests/agentic/test_gantt_projection.py new file mode 100644 index 000000000..e63528ba3 --- /dev/null +++ b/tests/agentic/test_gantt_projection.py @@ -0,0 +1,75 @@ +import json +import unittest +from datetime import date +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts" / "agentic")) + +from gantt_projection import project, render_mermaid_gantt + +PLAN = json.loads((ROOT / "docs" / "agentic" / "dependency-phases.json").read_text(encoding="utf-8")) +REPORT = json.loads((ROOT / "docs" / "agentic" / "dependency-phase-report.json").read_text(encoding="utf-8")) + + +class GanttProjectionTests(unittest.TestCase): + def test_relative_projection_preserves_stable_ids_dependencies_and_critical_path(self): + result = project(PLAN) + self.assertEqual(result["projection"], "gantt.interchange.v1") + self.assertEqual(result["schedule_mode"], "relative-wave") + self.assertEqual([t["id"] for t in result["tasks"]], ["DPH-000", "DPH-100", "DPH-200", "DPH-300"]) + self.assertEqual(result["tasks"][1]["dependencies"], ["DPH-000"]) + self.assertEqual(result["tasks"][2]["wave"], 2) + self.assertTrue(all(task["critical"] for task in result["tasks"])) + + def test_date_projection_is_deterministic_and_marks_critical_path(self): + result = project(PLAN, start_date=date(2026, 9, 22), default_duration_days=1) + self.assertEqual(result["tasks"][0]["start"], "2026-09-22") + self.assertEqual(result["tasks"][3]["end"], "2026-09-25") + self.assertTrue(all(task["critical"] for task in result["tasks"])) + + def test_duration_mapping_changes_schedule_without_mutating_plan(self): + before = json.dumps(PLAN, sort_keys=True) + result = project(PLAN, start_date=date(2026, 9, 22), default_duration_days=1, duration_mapping={"DPH-100": 3}) + self.assertEqual(before, json.dumps(PLAN, sort_keys=True)) + self.assertEqual(result["tasks"][2]["start"], "2026-09-26") + + def test_mermaid_projection_is_derived(self): + result = project(PLAN, start_date=date(2026, 9, 22)) + text = render_mermaid_gantt(result) + self.assertIn("gantt", text) + self.assertIn("DPH_000", text) + self.assertIn("crit", text) + + def test_mermaid_collapses_title_whitespace(self): + result = project({**PLAN, "phases": [{**PLAN["phases"][0], "title": "line one\nline two"}]}) + self.assertIn("line one line two", render_mermaid_gantt({**result, "schedule_mode": "dependency-derived"})) + + def test_valid_report_projects_evaluation_state(self): + result = project(PLAN, report=REPORT) + self.assertEqual(result["tasks"][0]["state"], "complete") + self.assertEqual(result["tasks"][1]["state"], "ready") + + def test_malformed_report_fails_closed(self): + for malformed in ( + {"evaluations": {}}, + {"evaluations": [dict(REPORT["evaluations"][0], phase_id="NOT-A-PHASE")]}, + {"evaluations": [dict(REPORT["evaluations"][0], pull_requests="900")]}, + ): + with self.assertRaises(ValueError): + project(PLAN, report=malformed) + + def test_incomplete_report_fails_closed(self): + malformed = dict(REPORT) + malformed["evaluations"] = REPORT["evaluations"][:-1] + with self.assertRaises(ValueError): + project(PLAN, report=malformed) + + def test_unknown_duration_id_fails_closed(self): + with self.assertRaises(ValueError): + project(PLAN, duration_mapping={"NOT-A-PHASE": 2}) + + +if __name__ == "__main__": + unittest.main()