diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b0428ea --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +GITEA_TOKEN= diff --git a/.gitignore b/.gitignore index 5065492..79f0335 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ out/ *.log .DS_Store .env +.env.* +!.env.example .yarn/* !.yarn/patches !.yarn/plugins diff --git a/docs/PLAN.md b/docs/PLAN.md index 63d92cb..6aec5e9 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -87,7 +87,11 @@ directives. Electron app, one window, "tell me what to do" experience. Deliverables per phase; replace TBD with actuals at phase close. - **P0 — Scaffold** — repo, Electron shell, gitea API client + token auth, - `pm-state` repo bootstrap. Actual: TBD + `pm-state` repo bootstrap. Actual (2026-07-08): yarn4 workspaces + electron + shell + `@commitea/core` label schema + design system mirrored. **Dogfood + backlog filed** on `christian/commitea`: 10 labels (est/p/deadline, exclusive + scopes), 5 phase milestones, 34 tracer-bullet issues with 51 native + dependencies. `pm-state` repo bootstrap still pending (P1-6 / P5-1). - **P1 — Sync + data model** — read mirror into SQLite cache, webhook listener + reconcile-on-launch, lifecycle inference from event stream, label schema applied. Actual: TBD @@ -113,12 +117,16 @@ vertical slices within phases where possible.) - Concurrent multi-PM directive writing - Cloud LLM as *requirement* (it's a config option, not a dependency) -## Open items for design session +## Open items for design session — RESOLVED 2026-07-08 -- Chat-as-only-write-path: strict or soft (UI edits allowed but agent - observes/objects)? -- `pm-state` file formats (JSONL event log settled; capacity/calibration - schemas TBD) -- Tool schema for the fat `query_project` tool -- Webhook endpoint mechanics per instance (port allocation, cleanup on quit) -- Cold-start estimate distributions (industry priors vs uniform pessimism) +Settled in [decisions.md](./decisions.md), [pm-state.md](./pm-state.md), +[agent-tools.md](./agent-tools.md): + +- Chat-as-only-write-path → **soft, split by semantics** (decisions.md D1) +- `pm-state` file formats → directive/capacity/calibration schemas (pm-state.md) +- Fat `query_project` tool schema → one read tool + three write tools + (agent-tools.md) +- Webhook mechanics → **poll + reconcile only in v1** (NAT), behind a + `ChangeSource` interface (decisions.md D2) +- Cold-start distributions → **lognormal per-bucket priors** in code + (decisions.md D3) diff --git a/docs/agent-tools.md b/docs/agent-tools.md new file mode 100644 index 0000000..08c01d7 --- /dev/null +++ b/docs/agent-tools.md @@ -0,0 +1,119 @@ +# Agent tools + +Reginald gets **few, fat tools** so a small local model (gemma-4b class) can +survive with one thing to reach for. One read tool, three write tools. All I/O +is compact JSON; ticket data is fetched through tools, never copied into hot +memory ([PLAN.md](./PLAN.md) memory layers). + +Model routing (per PLAN.md): the read tool + prose/standup run on the small +local model; `capture_work` decomposition and `record_directive` negotiation +route to the big model. + +## `query_project` (read) + +The single read tool. A `view` enum selects the shape; `filters` narrows it. The +scheduler's deterministic output backs every forecast field — the model never +computes, it reports. + +```jsonc +query_project({ + view: "focus" | "issue" | "milestone" | "board" | "runway" + | "calibration" | "directives" | "standup" | "search", + filters?: { + issueId?: number, + milestoneId?: number, + state?: "diagnosis" | "triage" | "steeping" | "in_review" | "done", + assignee?: string, // gitea username + label?: string, + query?: string, // free text, for view:"search" + limit?: number // default 20, max 100 + } +}) +``` + +View payloads (compact; forecasts always ranges, never point dates): + +- **focus** — `{ now, next[], later[] }`, each `{ issue, title, rationale }`. +- **issue** — intent (title, description, comments, assignee, labels) + derived + (lifecycle timeline, per-issue forecast range, dependency ids, provenance). +- **milestone** — `{ due, hard, stats, cone: {p10,p50,p80 dates}, issues[] }`. +- **board** — issues grouped by the five lifecycle columns. +- **runway** — per-milestone `{ due, band: {p50,p80}, slack }` + capacity list. +- **calibration** — `{ n, coldStart, globalMultiplier, byLabelBias[], byPersonBias[] }`. +- **directives** — pending consequence diff + recent ledger entries. +- **standup** — drift report, per-person plan, stale blockers. +- **search** — issues matching `query`. + +## `capture_work` (write — big model) + +Braindump → interview → proposed issue set. Returns a **proposal**, never files +directly; the Capture screen's review tray edits it before `apply_changes` files +it. Decomposition + estimate negotiation is the one place the big model earns its +keep. + +```jsonc +capture_work({ + braindump: string, + answers?: { question: string, answer: string }[] // interview turns so far +}) +// → { needsMoreInfo?: string[], // follow-up questions; present as chips +// proposal?: { issues: [{ title, body, estimate: EstimateLabel, +// priority?: PriorityLabel, deps?: number[], +// milestone?: number }], +// consequence: string } } // one-line schedule impact +``` + +## `apply_changes` (write — unified mutation) + +Every mutation funnels here: filing captured issues, label/estimate/priority/ +milestone/dependency edits. Additive ops apply directly; **destructive ops +require `approved: true`** (the caller obtains approval via the consequence +diff / Dialog first — see [decisions.md](./decisions.md) D1). Batched so one call += one coherent change with one consequence. + +```jsonc +apply_changes({ + ops: [ + { op: "create_issue", title, body, labels?, milestone?, deps? }, + { op: "set_estimate", issue, estimate: EstimateLabel }, + { op: "set_priority", issue, priority: PriorityLabel }, + { op: "set_milestone", issue, milestone: number | null }, + { op: "set_deadline_hard", milestone: number, hard: boolean }, + { op: "add_dep", issue, dependsOn: number }, + { op: "remove_dep", issue, dependsOn: number }, // destructive + { op: "close_issue", issue }, // destructive + { op: "remove_label", issue, label } // destructive + ], + approved?: boolean // required iff any op is destructive +}) +// → { applied: number, consequence: string, rejected?: {op, reason}[] } +``` + +Only touches labels in the CommiTea namespaces (`est/*`, `p/*`, `deadline/hard`) +plus native issue fields — never invents labels, comments, or synthetic issues +(zero-pollution goal). + +## `record_directive` (write — big model) + +Appends to the directive log ([pm-state.md](./pm-state.md)), triggers a scheduler +re-run, and returns the consequence diff for propose-approve. Does **not** mutate +gitea itself — a directive is intent; its effects land through `apply_changes` +after approval. + +```jsonc +record_directive({ + kind: "reprioritize" | "reestimate" | "set-deadline" | "scope" | "capacity" | "note", + quote: string, // verbatim PM words, stored in the ledger + target?: { issue?: number, milestone?: number, member?: string }, + params?: object, // structured effect, e.g. { priority: 1 } + rationale?: string +}) +// → { directiveId, consequence: { before, after }[], summary: string } +``` + +## Not tools + +Reads that are pure UI state (theme, current view, back-stack) never go through +tools. The scheduler, Monte Carlo, calibration fit, and lifecycle inference are +**code**, invoked by the runtime around these tools — the model requests a view +or proposes a change; deterministic code produces every number. diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..6a8b281 --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,73 @@ +# Settled design decisions + +Resolves the "Open items for design session" in [PLAN.md](./PLAN.md). Dated +2026-07-08. Companion docs: [pm-state.md](./pm-state.md) (sidecar formats), +[agent-tools.md](./agent-tools.md) (tool schemas). + +## D1 — Write path is soft, split by semantics + +Chat (Reginald) is the write path for **PM-semantic** mutations; gitea-native +content stays directly editable. + +- **Through the agent** — estimates (`est/*`), priority (`p/*`), + `deadline/hard`, milestone assignment, dependency edits, and directives. + Additive ops act directly; destructive ops go propose-approve (Dialog or the + consequence diff on the Directives screen). +- **Direct, reconciled** — issue title/description, comments, assignees. Edit + them in the CommiTea UI ("composer writes to gitea, as you") or in the gitea + web UI; reconcile absorbs out-of-band edits because **gitea is the source of + truth** for intent. The agent observes changes on the next reconcile and may + object in standup, but never blocks them. + +Rejected: strict (every write through chat). Hostile to quick edits and fights +the reconcile-from-gitea model — an edit made in gitea's own web UI would be +un-representable. + +## D2 — Sync is poll + reconcile, no live webhooks in v1 + +`gitea.stephenmann.io` is remote and the desktop app sits behind NAT, so the +server cannot POST to a localhost webhook. v1: + +- **Reconcile on launch** — full read of the work repo(s) + `pm-state` repo into + the local SQLite cache. +- **Light poll while running** — `since`/conditional-request poll of issues and + the issue timeline (target 30–60 s cadence; visible < 2 s is a webhook-era + goal, relaxed to the poll interval for v1). +- The change-source is an **interface** (`ChangeSource`) with a polling + implementation; a webhook implementation can plug in later for a LAN / + self-hosted / tunnelled instance without touching the reconcile core. + +Rejected now: reachability-detection hybrid (moving parts, cleanup-on-quit), +outbound tunnel (runtime dependency + public ingress). Both remain future +options behind the same interface. + +## D3 — Cold-start forecasts use lognormal per-bucket priors + +Before the team has n ≥ 20 closed issues with estimates, Monte Carlo samples a +**lognormal** actual-duration distribution per estimate bucket +(`1d/2d/3d/5d/8d`), with a pessimism-skewed median (actuals run long). The +priors live in code (`@commitea/core`), not in a data file. At n ≥ 20 the +scheduler switches to the team's own empirical fit (see calibration model in +[pm-state.md](./pm-state.md)); `byLabel` / `byPerson` bias terms layer on once +their own sample sizes clear a floor. + +Rejected: uniform pessimism multiplier (`actual = est × U[1.3, 2.0]`) — cruder, +dishonest tails, no path to per-bucket calibration. + +## D4 — Purity test applies to the SQLite cache, not the pm-state repo + +The plan's invariant — *delete the sidecar → resync → no truth lost* — is about +the **local SQLite cache**, which is a rebuildable index over two durable +sources: + +- **Work repo(s) in gitea** — human-authored intent (issues, milestones + due + dates, dependencies, assignees, labels, comments). +- **`pm-state` repo in gitea** — the sidecar's own durable truth that is *not* + regenerable from the work repo: directive log, capacity config, charter, and + the (cached-but-committed) calibration model. + +Delete SQLite → rebuild from both repos → nothing lost. The `pm-state` repo is +never the thing you delete; it is versioned and backed up in gitea like any +other repo. Regenerable state (issue mirror, inferred lifecycle timestamps, +Monte Carlo forecasts, focus snapshot) lives in SQLite only and is recomputed on +rebuild. See [pm-state.md](./pm-state.md) for the file/table split. diff --git a/docs/pm-state.md b/docs/pm-state.md new file mode 100644 index 0000000..547621c --- /dev/null +++ b/docs/pm-state.md @@ -0,0 +1,123 @@ +# pm-state: the sidecar store + +Machine-derived and PM-authored state that has no home in the work repo. Split +across two tiers per [decisions.md](./decisions.md) D4: + +- **`pm-state` gitea repo** — durable, versioned truth. Committed files below. +- **local SQLite** — rebuildable cache/index. Never the source of truth. + +## `pm-state` repo layout + +``` +charter.md durable project charter + hot-memory seed (human + agent authored) +directives/log.jsonl durable append-only directive ledger (conflict-free merge) +capacity/members.yaml durable per-person capacity model (human-set) +calibration/model.json cached fitted calibration; regenerable from actuals, committed for + reproducibility + offline forecasting +``` + +`forecasts/`, `focus/`, and inferred lifecycle timestamps are **not** committed +— they are SQLite-only and recomputed on rebuild (regenerable from the work repo ++ calibration model). + +## Directive log — `directives/log.jsonl` + +Append-only, one JSON object per line. **Merge is concatenation**: order is +derived from `ts` at read time, so two clients appending never produce a git +conflict. `id` is the durable key; `seq` is a display ordinal computed on read, +never stored (avoids the "who owns the next number" contention). v1 is +single-writer (one PM); this format is already safe for the deferred +multi-writer case. + +```jsonc +{ + "id": "d_01J8...", // crypto.randomUUID at write; durable identity + "ts": "2026-07-08T14:03:00Z", // ISO 8601 UTC; sole ordering key + "actor": "christian", // gitea username of the directive-giver + "kind": "reprioritize", // reprioritize | reestimate | set-deadline | scope | capacity | note + "target": { "issue": 87 }, // { issue } | { milestone } | { member } | null (project-wide) + "quote": "bump the auth bug above everything", // verbatim, shown in the ledger + "params": { "priority": 1 }, // structured effect the scheduler applies + "rationale": "pilot customer blocked", // why (optional but nagged for) + "status": "accepted" // proposed | accepted | amended | withdrawn +} +``` + +Lifecycle: a directive is recorded as `proposed`, the scheduler re-runs, the +agent presents the consequence diff, and the PM's response flips it to +`accepted` / `amended` / `withdrawn`. All four states stay in the ledger +(append a status-change line; never mutate a prior line). + +## Capacity — `capacity/members.yaml` + +Estimate unit is **ideal person-days**. Capacity is expressed in ideal +person-days available per calendar day. + +```yaml +members: + - gitea: christian + focusFactor: 0.8 # productive fraction of a working day (0..1) + projectAllocation: 0.6 # share of focused time on THIS project (0..1) + workdays: [mon, tue, wed, thu, fri] + daysOff: [] # ISO dates, e.g. ["2026-07-14"]; PTO calendars deferred + # other standing slices (compliance 0.2, pilots 0.2) are documentation only — + # only projectAllocation feeds the scheduler. +``` + +Derived: `capacityPerWorkday = focusFactor * projectAllocation` (ideal +person-days per working day). The scheduler spreads this across `workdays`, +zeroing `daysOff`. Missing member ⇒ excluded from capacity, flagged by the agent. + +## Calibration — `calibration/model.json` + +Fitted from closed-issue actuals (estimate label vs inferred elapsed working +time). Lognormal on `log(actual / estimate)`. + +```jsonc +{ + "version": 1, + "fittedAt": "2026-07-08T00:00:00Z", + "n": 42, // closed issues with an estimate feeding the fit + "coldStart": false, // true while n < 20 → scheduler uses code priors instead + "global": { "mu": 0.166, "sigma": 0.45 }, // lognormal params on log-ratio; median ratio = e^mu ≈ 1.18 + "byBucket": { // per estimate label; falls back to global when its n is thin + "1d": { "mu": 0.30, "sigma": 0.55, "n": 12 }, + "2d": { "mu": 0.18, "sigma": 0.40, "n": 9 } + // 3d / 5d / 8d ... + }, + "byLabel": { "backend": { "biasMu": 0.12, "n": 7 } }, // additive to mu; applied when n ≥ floor + "byPerson": { "christian": { "biasMu": -0.05, "n": 20 } } +} +``` + +Cold-start (`coldStart: true`, or a bucket with `n` below floor): the scheduler +ignores the file's fitted params for that axis and samples the **code-resident +lognormal priors** in `@commitea/core` (per D3). The file still records whatever +partial `n` exists so the UI's calibration teaser can show progress toward 20. + +## SQLite cache (rebuildable — not committed) + +Mirror + derived tables, rebuilt from both gitea repos on reconcile: + +- `issues`, `labels`, `milestones`, `comments`, `issue_events` — verbatim work-repo mirror +- `lifecycle` — inferred per-issue timestamps (see below), keyed by issue +- `forecasts` — last Monte Carlo run per milestone/issue (regenerable) +- `focus` — current Now/Next/Later snapshot (regenerable) +- `directives` — indexed view of `log.jsonl` for fast querying + +## Lifecycle inference + +Timestamps derived from the gitea issue timeline; no manual time tracking. Maps +onto the board's five columns: + +| Board column | Enter when | Source event | +|--------------|--------------------------------------------------------|-------------------------| +| Diagnosis | issue opened | `opened` | +| Triage | first label or milestone applied | `label` / `milestone` | +| Steeping | first branch or commit references the issue | `commit_ref` / branch | +| In review | a PR referencing the issue is opened | PR `opened` | +| Done | issue closed (PR merged is the deploy signal within) | `closed` / PR `merged` | + +Elapsed **working** time between Steeping→Done (minus non-workdays/daysOff) is +the "actual" that feeds calibration. Re-openings append new segments; the fit +uses summed working time.