docs: settle open design items + dogfood backlog

Resolve the five design-session open items from PLAN.md:
- decisions.md: soft write-path, poll-only sync (NAT), lognormal
  cold-start priors, purity test binds the SQLite cache
- pm-state.md: sidecar layout + directive/capacity/calibration
  schemas + lifecycle inference table
- agent-tools.md: query_project read tool + three write tools

Also gitignore .env.* (protect the gitea PAT) and record the P0
actual: 10 labels, 5 milestones, 34 tracer-bullet issues + 51
dependencies filed on christian/commitea as the first managed project.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Croissant Le Doux
2026-07-08 11:30:23 -04:00
parent 7a5cacc54c
commit 9920634e74
6 changed files with 335 additions and 9 deletions

View File

@@ -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)

119
docs/agent-tools.md Normal file
View File

@@ -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.

73
docs/decisions.md Normal file
View File

@@ -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 3060 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.

123
docs/pm-state.md Normal file
View File

@@ -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.