In scope. Ship a Linear-shape scheduling module inside Workforce, sufficient to replace Linear Standard for an R&D team of 5–50 engineers. Ships as a first-class Workforce module — no separate app, no separate signup, no separate billing.
Non-goals. Not building: Insights custom-analytics builder, customer changelog, Docs (Notion-like pages), Initiatives (higher-order project grouping), SLA rules, more than one level of subtask nesting, customer requests, marketplace / third-party integration hub. Rationale per facet in §3 and §8.
Two-way Linear sync. One-shot import + 14-day read-only safety net only. Not permanent coexistence (defeats the "cancel Linear" outcome).
Postgres, new schema wf_scheduling. All tables carry subscriber_id uuid NOT NULL for tenant isolation.
-- Core tables (P0)
wf_scheduling.teams (id, subscriber_id, key, name, cycle_length_days, created_at)
wf_scheduling.cycles (id, team_id, number, starts_at, ends_at, state)
wf_scheduling.projects (id, subscriber_id, name, description, team_id NULL, target_date, state)
wf_scheduling.milestones (id, project_id, name, target_date, order_idx, state)
wf_scheduling.tasks (id, subscriber_id, team_id, cycle_id NULL, project_id NULL,
identifier text, -- 'KL-152'
title text, description text,
state text, -- backlog|todo|in_progress|in_review|done|canceled
priority smallint, -- 0..4 (no|urgent|high|med|low)
assignee_user_id uuid,
estimate_hours numeric, -- signed required (see wf-only extras)
parent_task_id uuid, -- exactly 1 level of nesting
created_at, updated_at, completed_at)
wf_scheduling.labels (id, team_id, name, color)
wf_scheduling.task_labels (task_id, label_id)
wf_scheduling.workflow_states (id, team_id, name, category, position)
wf_scheduling.task_comments (id, task_id, author_user_id, body_md, created_at)
wf_scheduling.task_history (id, task_id, actor_user_id, kind, from_val, to_val, at)
-- Views + templates (P1)
wf_scheduling.views (id, subscriber_id, owner_user_id, name, query_json, shared bool)
wf_scheduling.templates (id, subscriber_id, kind, name, payload_json)
-- Intake (P1)
wf_scheduling.triage_inbox (id, subscriber_id, source, raw_payload, task_id NULL, state, created_at)
wf_scheduling.notifications (id, user_id, kind, ref_table, ref_id, seen_at, created_at)
-- Automations (P2)
wf_scheduling.automations (id, team_id, trigger_json, action_json, enabled)
-- =========================================================
-- WORKFORCE-ONLY EXTRAS (§4)
-- =========================================================
wf_scheduling.task_estimates (id, task_id,
ai_proposed_hours numeric, ai_reasoning text, ai_proposed_at,
engineer_hours numeric, engineer_reason text, engineer_signed_at,
manager_hours numeric, manager_approved_by uuid, manager_approved_at,
round smallint, escalated_at NULL,
final_hours numeric, -- populated once all 3 sign
CHECK (final_hours IS NULL OR
(engineer_signed_at IS NOT NULL
AND manager_approved_at IS NOT NULL)))
wf_scheduling.task_actuals (id, task_id, source text, -- 'timer'|'commit_window'|'adjust'
started_at, ended_at, minutes numeric,
blocked bool DEFAULT false,
commit_sha text NULL, note text NULL)
wf_scheduling.task_blockers (id, task_id, blocker_task_id NULL, blocker_note, opened_at, cleared_at)
wf_scheduling.diary_rollups (id, subscriber_id, user_id, period text, -- 'day'|'week'|'month'
period_start date, score numeric,
delta_median numeric, active_hours numeric,
tasks_done int, breakdown_json, generated_at)
wf_scheduling.access_grants (id, user_id, kind text, -- 'ai_sub'|'repo'|'board'|'ci'|'chat'|'scope_signed'
scope_json, granted_at, revoked_at NULL,
manager_countersigned_at NULL)
Fires when a task is created and has non-empty title + description + at least one label. Debounced 15s (edits during creation don't re-fire).
{
task: { title, description, labels[], team, priority, parent_task_id },
similar_past_tasks: [top-5 by embedding, from wf_scheduling.tasks WHERE completed_at IS NOT NULL],
code_paths_touched: [from label inference + repo folder map],
engineer: { user_id, past_delta_median, familiarity_score_for_labels },
ai_subscription_history: [engineer's past sessions on similar code paths]
}
task_estimatesai_proposed_hours: numeric ai_reasoning: text -- 2-3 sentences, cites similar tasks by ID ai_proposed_at: timestamptz round: 1
If engineer has no past_delta_median (new hire, first task), AI proposal is flagged tentative and requires 2 rounds minimum before converging — protects new hires from an under-calibrated first estimate.
proposed → engineer_countered → ai_re-proposed → engineer_signed → manager_pending → manager_approved → final_locked ↓ any state escalated (after round 3) → manager_arbitrates → final_locked
in_progress until final_hours IS NOT NULL.engineer_signed_at. If breached, notification fires to manager's manager (or Steve if no chain configured).task_estimates row with round++; old row preserved. Requires the same 3-way sign to activate.POST /api/wf/tasks/{id}/estimate/propose { ai_hours, reasoning }
POST /api/wf/tasks/{id}/estimate/counter { hours, reason }
POST /api/wf/tasks/{id}/estimate/engineer-sign
POST /api/wf/tasks/{id}/estimate/manager-approve
POST /api/wf/tasks/{id}/estimate/escalate
Source A · Commit-window inference. Any commit whose branch name, PR link, or commit message references {team_key}-{n} (e.g. KL-152) contributes to that task's actuals. Consecutive commits on the same branch within 60 min = one continuous work window; gap >60 min = new window. Windows stored in task_actuals with source='commit_window'.
Source B · Task-timer. Engineer hits T to start / T to pause. Rows written with source='timer'.
Dedup. When both sources overlap, keep the union of the intervals (not the sum). Query view wf_scheduling.task_actuals_dedup handles this.
When task_blockers.cleared_at IS NULL, any actuals with started_at BETWEEN opened_at AND now() are marked blocked=true and excluded from delta calc.
POST /api/wf/tasks/{id}/actuals/adjust { minutes, note } — writes source='adjust'. All adjusts visible to manager on the task detail.
delta = SUM(actuals.minutes WHERE blocked=false) / 60
÷
task_estimates.final_hours
< 0.90 → beat_estimate (green — feeds calibration; no praise/blame) 0.90 – 1.20 → on_plan (green — target band) > 1.20 → overrun (yellow — schedule retro with manager)
Per user, per cycle: delta_median of tasks completed. Feeds §4.5 diary rollup. Never surfaces per-task delta as "bad" — the retro is a conversation, not a punishment.
daily — 23:59 local time per user's timezone weekly — Fri 17:00 local time per user's timezone monthly — last working day of month, 17:00 local time
for each active user:
fetch tasks completed in period
compute:
- delta_median
- active_hours (from task_actuals_dedup)
- tasks_done
- score (weighted per §4.4 + collaboration + code-quality + ...)
- breakdown_json (per-category contributions)
INSERT INTO diary_rollups
emit notification "Your {period} diary is ready"
Engineer sees their own diary before manager does — same URL, same time. No hidden manager preview. Enforced in the read handler.
A user cannot be assigned to any task in a team until they have access_grants rows with kind IN ('ai_sub','repo','board','ci','chat','scope_signed') AND manager_countersigned_at IS NOT NULL for at least the scoped-repo grant. Enforced at task-assign time.
Any grant can be revoked by the user at any time. UPDATE access_grants SET revoked_at=now() WHERE user_id=$1 AND kind=$2. Manager is notified; cannot block.
Grants marked temp=true (Charles's temp-assist case) auto-revoke at expires_at. Job runs hourly.
Auth reminder (per CLAUDE.md). Every owner-facing endpoint gates on requireApiKey. Never trust subscriber_id as auth. Diary endpoint additionally checks owner-or-manager via a manages(current_user_id, target_user_id, subscriber_id) helper.
1. Create task with title + description + label 2. Assert ai_proposed_hours populated within 15s 3. Counter with different hours + reason 4. Assert ai_re-proposed 5. Engineer sign 6. Assert task state cannot change to in_progress 7. Manager approve 8. Assert task state CAN now change to in_progress 9. Skip signature (bypass attempt) → assert 409 CONFLICT with clear error