From 42a7bf8625643aa41a396d577addf7d554e97e31 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 23:09:07 -0500 Subject: [PATCH 1/4] WIP: goal holds (run, drain, park, let go), Upkeep, fleet ops on holds, commit handoffs Work in progress; tests still being fixed. Not for review yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T5z3hZtpGaQUGsTaAMQrAg --- README.md | 25 +- docs/devteam/dashboard/index.html | 93 ++- docs/devteam/governance/constitution.md | 2 +- docs/devteam/process/planning.md | 18 +- docs/devteam/process/supervisor.md | 38 +- docs/devteam/process/worker.md | 17 +- docs/devteam/schema/log.schema.json | 77 ++- docs/devteam/scripts/devteam.mjs | 731 ++++++++++++++++++++---- test/kit.test.mjs | 276 ++++++--- 9 files changed, 1014 insertions(+), 263 deletions(-) diff --git a/README.md b/README.md index e8fca70..df1346d 100644 --- a/README.md +++ b/README.md @@ -47,31 +47,30 @@ docs/devteam/ 4. Fill `project/settings.md` (`init` sets `main_branch` from the remote's default branch): components and stacks, `agent_command`, the `gates` map (name → command) the supervisor runs before every merge, `worktree_files`, the gitignored files a fresh worktree needs to boot (a Rails `config/master.key`, a `.env`), and `worktree_setup`, the command that makes it runnable (`npm ci`), both applied to every worker, planner and gate checkout, and `forge: "git"` if the remote has no `gh`. Record anything the repo already does differently from `governance/` (a release branch, its own CI) in the settings; the settings win. Infer what you can from the repo; ask the owner the rest in one batch. 5. Run `devteam.mjs gate --all` on `main`. Every gate that fails there is a toolchain to fix, a gate to set to `"mode": "ratchet"`, or a chore ticket. 6. Existing codebase: write `project/designs/architecture.md` from the code (components, contracts, persistence, dependencies, distance from the code rules, hotspots, tests, work in flight). New repo: leave the headings. -7. **Raise the floor first.** For every gate in `quality.md` §2 and every Law in `code-style.md` the repo doesn't yet enforce (lint, format, types, tests in CI, coverage, dead code, duplication, secrets, lockfile, and an encrypted secret store per `code-style.md` §10 if the repo has none), add a chore ticket: `devteam.mjs ticket add -NN- --json '{…, "kind": "chore", "feature": "chores", "serves": [], "notes": "turns on "}'`. Gates that fail on existing code go on in **ratchet** mode (`quality.md` §2). The fleet's first work is its own quality floor. -8. Commit the kit, settings and architecture doc as one `chore-devteam-bootstrap` PR, then `devteam.mjs log-sync` for the tickets. +7. **Propose the Baseline goal.** Draft the repo's first goal, **Baseline**: every gate in `quality.md` §2 and every Law in `code-style.md` enforced on main (lint, format, types, tests in CI, coverage, dead code, duplication, secrets, lockfile, and an encrypted secret store per `code-style.md` §10 if the repo has none), measured as the floor gaps left, target 0 (`process/planning.md` §7). The owner opts in through the regular flow; once approved, its features are the floor tickets, and gates that fail on existing code go on in **ratchet** mode (`quality.md` §2). +8. Commit the kit, settings and architecture doc as one `chore-devteam-bootstrap` PR, then `devteam.mjs log-sync` for the Baseline goal draft. 9. Start the supervisor. It is the owner's foreground session in the main checkout, never a background, headless or tmux-detached one. **If an agent session did this setup, that session becomes the supervisor:** it reads the prompt from `node docs/devteam/scripts/devteam.mjs prompt supervisor` and follows it in place; it does not launch a second agent. If the owner did the setup by hand, they start the agent in their terminal with `DEVTEAM_ROLE=supervisor` in its environment (`DEVTEAM_ROLE=supervisor claude "$(node docs/devteam/scripts/devteam.mjs prompt supervisor)"`), inside tmux if they want workers' messages typed into its pane as they happen. Agent CLIs such as Claude Code don't keep shell state between commands, so an `export` inside the session doesn't stick: a session already running prefixes each devteam command with `DEVTEAM_ROLE=supervisor`. Run the dashboard where it outlives the session (a tmux window, or another terminal): `node docs/devteam/scripts/devteam.mjs dashboard`, and open the address. 10. The supervisor asks the owner for goals and the loop begins. To tell a session something from your own terminal as yourself, `node docs/devteam/scripts/devteam.mjs send "…" --owner`; answers to questions go through the inbox. Requirements: git, tmux (for the workers), Node 18+ (the project's own pinned Node is fine: asdf, nvm and the dashboard all run the script with it), the GitHub CLI, one agent CLI (Claude Code, Codex, OpenCode or similar) with superpowers installed. -### Moving the fleet to another machine +### Holding goals, and moving the fleet -The fleet stops cleanly and picks up elsewhere: every session pushes its work and leaves a handoff note on its branch, the log lands on main, and the new machine starts from a clone (`process/supervisor.md` §12). +Every goal, and the built-in Upkeep goal that owns chores, has a toggle on the dashboard: **Run**, **Drain** (nothing new starts; what's running finishes and merges), **Park** (sessions commit their work, push, leave a handoff commit and stop) and **Let go** (reversible). The strip at the top of the dashboard holds or resumes every goal at once and says whether the fleet can move (`process/supervisor.md` §12). -**On the old machine**, tell the supervisor to drain (for example: "Drain the fleet; I'm moving it to my new laptop"). It runs: +**Moving to another machine.** On the old one, park the fleet from the dashboard, or tell the supervisor ("Park the fleet; I'm moving it to my new laptop"). Fleet drain works too, more slowly: every ticket in flight finishes first. It runs: ``` -DEVTEAM_ROLE=supervisor node docs/devteam/scripts/devteam.mjs drain --reason "moving to a new laptop" -# once every session is parked: its own handoff, project/handoffs/supervisor.md, landed as chore drain-handoff -DEVTEAM_ROLE=supervisor node docs/devteam/scripts/devteam.mjs drain --status # what's left; repeat until "ready to move: yes" -DEVTEAM_ROLE=supervisor node docs/devteam/scripts/devteam.mjs observe-spend # then land the log: +DEVTEAM_ROLE=supervisor node docs/devteam/scripts/devteam.mjs fleet park --reason "moving to a new laptop" +# once every session is parked, its own handoff, pushed as a tag: +DEVTEAM_ROLE=supervisor node docs/devteam/scripts/devteam.mjs handoff --supervisor < note.md +DEVTEAM_ROLE=supervisor node docs/devteam/scripts/devteam.mjs fleet status # what's left; repeat until "ready to move: yes" +DEVTEAM_ROLE=supervisor node docs/devteam/scripts/devteam.mjs observe-spend # then land the log: DEVTEAM_ROLE=supervisor node docs/devteam/scripts/devteam.mjs log-sync DEVTEAM_ROLE=supervisor node docs/devteam/scripts/devteam.mjs merge --chore log ``` -Anything needed for 1:1 parity is checked in and portable; only secrets travel by hand. So `drain --status` also blocks on files left in `run/`, worktrees with unpushed work, the owner's unfinished review drafts, and agent memory outside the repo that isn't folded into knowledge or the supervisor's handoff. When it's ready it prints a parity fingerprint of the log; `undrain` on the new machine prints its own, and they match. - -`drain --status` ends with the new machine's checklist, generated from the settings: which `worktree_files` and secret keys to copy, which logins, which tools. +Anything needed for 1:1 parity is in git; only secrets travel by hand. So `fleet status` also blocks on files left in `run/`, worktrees with unpushed work, the owner's unfinished review drafts, and agent memory outside the repo that isn't folded into knowledge or the supervisor's handoff. When it's ready it prints a parity fingerprint of the log, and the new machine's checklist (which `worktree_files` and secret keys to copy, which logins, which tools). **On the new machine**, in this order: @@ -84,7 +83,7 @@ tmux new -s supervisor DEVTEAM_ROLE=supervisor claude "$(node docs/devteam/scripts/devteam.mjs prompt supervisor)" ``` -That is step 9's start, unchanged: the supervisor's prompt sees the drain and opens with what to do, so it runs `undrain` (which rebuilds `run/log` from the landed log, records the undrain and prints each drained session's restart command), restarts the drained workers and planners (each gets its handoff note in its prompt), and then the dashboard is restarted where it outlives the session: +That is step 9's start, unchanged: the supervisor's prompt sees the parked fleet and opens with what to do, so it runs `fleet unpark` (which rebuilds `run/log` from the landed log, prints the same parity fingerprint before writing anything, puts every goal back as it was and prints each parked session's restart command), restarts the parked workers and planners (each gets its handoff in its prompt), and then the dashboard is restarted where it outlives the session: ``` tmux new-window -d -n dashboard 'node docs/devteam/scripts/devteam.mjs dashboard' diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index 3b318bf..9d2f2c5 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -300,6 +300,15 @@ .seg2 { display: inline-flex; border: 1px solid var(--line-2); border-radius: 7px; overflow: hidden; } .seg2 button { margin: 0; height: 24px; border: 0; border-radius: 0; background: var(--surface); color: var(--fg-2); font-weight: 500; padding: 0 10px; } .seg2 button[aria-pressed="true"] { background: var(--well); color: var(--fg); } + .holdbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 8px 0 2px; } + .holdbar .seg2 button[data-mode="drain"][aria-pressed="true"] { background: color-mix(in srgb, var(--amber, #c98a00) 18%, var(--surface)); } + .holdbar .seg2 button[data-mode="park"][aria-pressed="true"], .holdbar .seg2 button[data-mode="let-go"][aria-pressed="true"] { background: color-mix(in srgb, var(--red) 14%, var(--surface)); } + .holdbar .seg2 button.confirm { color: var(--red); } + .fleet-strip { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px; margin-bottom: 14px; font-size: 12px; background: var(--surface); } + .fleet-strip .grow { flex: 1 1 auto; } + .fleet-strip details { width: 100%; } + .fleet-strip ul { margin: 6px 0 0 18px; padding: 0; } + .card.upkeep { padding-top: 8px; padding-bottom: 8px; } .md { max-width: var(--measure); font-size: 13.5px; line-height: 1.65; } .md h1 { font-size: 19px; margin: 6px 0 12px; } .md h2 { font-size: 16px; margin: 22px 0 8px; display: block; } .md h3 { font-size: 14px; margin: 18px 0 6px; color: var(--fg); } .md p, .md ul, .md ol { margin: 8px 0; } .md li { margin: 3px 0; } @@ -403,6 +412,7 @@

Loading…

+
@@ -924,7 +934,7 @@

Checks before merge the automated const notePh = goalAsk ? 'Notes for the planner: taken into account whatever you choose (needed to refine)' : list.length ? 'Add a note (optional), or your own answer' : 'Your answer'; return `
- ${cardHead(KIND_ICON[q.kind] || 'circle-help', `${esc(KIND_WORDS[q.kind] || words(q.kind))}`, { extra: (q.blocks || []).length ? `holds up ${q.blocks.map(tlink).join(', ')}` : q.ticket ? `about ${tlink(q.ticket)}` : '', id: q.id, rows: [['Asked', esc(when(q.asked_at))], ...(q.class ? [['Kind', esc(q.class)]] : [])] })} + ${cardHead(KIND_ICON[q.kind] || 'circle-help', `${esc(KIND_WORDS[q.kind] || words(q.kind))}${q.goal_hold === 'park' ? 'its goal is parked' : ''}`, { extra: (q.blocks || []).length ? `holds up ${q.blocks.map(tlink).join(', ')}` : q.ticket ? `about ${tlink(q.ticket)}` : '', id: q.id, rows: [['Asked', esc(when(q.asked_at))], ...(q.class ? [['Kind', esc(q.class)]] : [])] })}

${linkify(q.question)}

${q.context ? `

${linkify(q.context)}

` : ''} ${(q.links || []).length ? `` : ''} @@ -1465,7 +1475,17 @@

${linkify(q.question)}

} const GOAL_STEPS = ['Defined', 'Designed', 'Approved', 'Built', 'Met']; +// A held goal keeps its step; its label says the hold first. function goalState(g, R, openQs) { + const s = goalStateOf(g, R, openQs); + const h = g.hold_view; + if (!h || h.mode === 'run' || h.mode === 'let-go') return s; + const words = { draining: 'Draining', drained: 'Drained', parking: 'Parking', parked: 'Parked' }[h.state] || h.state; + const running = h.running.length ? ` (${h.running.length} still running)` : ''; + const why = `${h.fleet ? 'the whole fleet' : h.by === 'supervisor' ? 'the supervisor' : 'you'}${h.reason ? `: ${esc(h.reason)}` : ''}`; + return { ...s, label: `${words}${running} · ${s.label}`, tone: h.mode === 'park' ? 'high' : 'medium', next: `${h.mode === 'drain' ? 'Draining: what is running finishes and merges; nothing new starts' : 'Parked: its sessions stopped with their work pushed and a handoff; nothing starts and nothing merges'} (set by ${why}). ${s.next || ''}` }; +} +function goalStateOf(g, R, openQs) { const ask = openQs.find((q) => q.kind === 'goal-approval' && q.goal === g.id); const defQ = openQs.find((q) => q.kind === 'goal-definition'); if (g.status === 'met') return { key: 'met', label: 'Met', tone: 'good', step: 5, next: 'The measure reached its target.' }; @@ -1529,6 +1549,47 @@

${linkify(q.question)}

return `
  • ${x}${sub ? `${esc(sub)}` : ''}
  • `; }).join('')}`; } +// The toggle: Run, Drain, Park, and Let go (Restore once let go). Let go asks to confirm in place. +function holdControl(id, h, { letGo = true, status = null } = {}) { + if (status === 'met') return ''; + if (status === 'abandoned') return `
    `; + const cur = h?.mode || 'run'; + const fleet = DATA?.fleet?.mode ? 'set for the whole fleet: use the strip at the top to resume it' : ''; + const btn = (m, label) => ``; + return `
    ${btn('run', 'Run')}${btn('drain', 'Drain')}${btn('park', 'Park')}${letGo ? btn('let-go', 'Let go') : ''}${fleet}
    `; +} +function upkeepCard(u) { + const words = { running: 'Running', draining: 'Draining', drained: 'Drained', parking: 'Parking', parked: 'Parked' }[u.state] || u.state; + const chores = u.chores || []; + return `
    + ${cardHead('wrench', `${esc(words)}`, { extra: 'always on: observing and keeping the record' })} +

    Upkeep

    +

    Chores, CI fixes, research, retros, kit syncs and the improvement loop's trials and proposals.${chores.length ? ` In flight: ${chores.slice(0, 6).map((c) => `${esc(c.id)}`).join(', ')}${chores.length > 6 ? '…' : ''}.` : ''}${u.reason ? ` (${esc(u.reason)})` : ''}

    + ${holdControl('upkeep', u, { letGo: false })} +
    `; +} +// Can the fleet move? The mode is in the data; readiness comes from fleet status, asked for apart. +let FLEET = { at: 0, data: null, busy: false }; +function fleetStrip() { + const f = DATA?.fleet || {}; + const mode = f.mode; + const st = FLEET.data; + const ready = st ? (st.ready ? 'ready to move' : `not ready to move`) : 'checking…'; + const left = st && !st.ready ? `
    ${esc(st.blockers.length)} thing${st.blockers.length === 1 ? '' : 's'} left before a move
      ${st.blockers.map((b) => `
    • ${linkify(b)}
    • `).join('')}
    ` : ''; + const parity = st?.parity ? `parity ${esc(st.parity.hash)}` : ''; + const label = mode === 'park' ? 'Fleet parked' : mode === 'drain' ? `Fleet draining${st && !st.sessions.some((x) => !x.parked) ? ' (drained)' : ''}` : 'Fleet running'; + const btns = mode + ? '' + : ''; + return `${label}${f.reason ? ` · ${esc(f.reason)}` : ''}${ready}${parity}${btns}${left}`; +} +async function loadFleet(fetchToo = false) { + if (FLEET.busy) return; + FLEET.busy = true; + try { const r = await fetch(`/api/fleet${fetchToo ? '?fetch=1' : ''}`); if (r.ok) FLEET = { ...FLEET, data: await r.json(), at: Date.now() }; } catch {} + FLEET.busy = false; + if ($('fleet-strip')) $('fleet-strip').innerHTML = fleetStrip(); +} function goalCard(g, R, openQs, week) { const s = goalState(g, R, openQs); const design = s.ask?.links?.[0] ?? (['ready', 'met'].includes(g.status) && g.check.design ? { title: 'The design', path: `docs/devteam/${g.check.design}` } : null); @@ -1539,6 +1600,7 @@

    ${esc(g.title ${g.outcome ? `

    ${esc(g.outcome)}

    ` : ''} ${steps} ${s.next ? `

    ${s.next}

    ` : ''} + ${holdControl(g.id, g.hold_view, { status: g.status })}
    Open${design ? ` · Read the design` : ''}${planningFor(g).length ? ` · ${planningFor(g).map((p) => `planner on ${esc(p.title)}${p.waiting ? ' stuck on a prompt' : ''}`).join(', ')}` : ''}

    `; } @@ -1573,10 +1635,10 @@

    ${esc(g.title const openQs = DATA.questions || []; const live = o.goals.filter((g) => !['met', 'abandoned'].includes(g.status)); const doneGoals = o.goals.filter((g) => ['met', 'abandoned'].includes(g.status)); - $('goals-list').innerHTML = o.goals.length ? live.map((g) => goalCard(g, R, openQs, week)).join('') + (doneGoals.length ? `
    Met and let go (${doneGoals.length})${doneGoals.map((g) => goalCard(g, R, openQs, week)).join('')}
    ` : '') : (() => { + $('goals-list').innerHTML = (DATA.upkeep ? upkeepCard(DATA.upkeep) : '') + (o.goals.length ? live.map((g) => goalCard(g, R, openQs, week)).join('') + (doneGoals.length ? `
    Met and let go (${doneGoals.length})${doneGoals.map((g) => goalCard(g, R, openQs, week)).join('')}
    ` : '') : (() => { const ask = (DATA.questions || []).find((q) => q.kind === 'goal-definition'); return empty(ask ? `No goals yet. The supervisor has asked for them:
    answer it in the Inbox.` : 'No goals yet. The supervisor asks the owner for them first (process/planning.md §1).'); - })(); + })()); // Observe $('observations').innerHTML = o.observations.length ? o.observations.slice(0, 40).map((x) => ` @@ -1705,6 +1767,7 @@

    History

    ${g.outcome ? `

    ${esc(g.outcome)}

    ` : ''} ${goalSteps(g, s, R)} ${s.next ? `

    ${s.next}

    ` : ''} + ${holdControl(g.id, g.hold_view, { status: g.status })} ${feats.length ? `

    ${boardLink(`goal:${g.id}`)}

    ` : ''}
    ${design ? `
    Design
    ${esc(design.title || 'The design')}
    ` : ''} @@ -2102,6 +2165,8 @@

    ${linkify(k.statement)}

    $('answered').innerHTML = o.answered.length ? o.answered.map(answeredCard).join('') : empty('Nothing answered yet.'); renderOoda(o); + $('fleet-strip').innerHTML = fleetStrip(); + if (Date.now() - FLEET.at > 60000) loadFleet(); for (const id of openForms) { const f = document.querySelector(`[data-correct-form="${id}"]`); if (f) f.hidden = false; } renderProgress(); @@ -2246,6 +2311,28 @@

    ${linkify(k.statement)}

    }); document.addEventListener('click', async (ev) => { + const hb = ev.target.closest('[data-hold-id]'); + if (hb) { + const id = hb.dataset.holdId, mode = hb.dataset.mode; + const errId = `hold-${id}`; + if (mode === 'let-go' && !hb.classList.contains('confirm')) { hb.classList.add('confirm'); hb.textContent = 'Confirm let go'; setTimeout(() => { hb.classList.remove('confirm'); hb.textContent = 'Let go'; }, 6000); return; } + hb.disabled = true; + const ok = mode === 'let-go' ? await post('goal-decide', { id, verdict: 'let-go' }, errId) : mode === 'restore' ? await post('goal-decide', { id, verdict: 'restore' }, errId) : await post('goal-hold', { id, mode }, errId); + if (!ok) hb.disabled = false; + else loadFleet(); + return; + } + const fb = ev.target.closest('[data-fleet]'); + if (fb) { + const op = fb.dataset.fleet; + if (op === 'check') { fb.disabled = true; await loadFleet(true); return; } + if (op === 'park' && !fb.classList.contains('confirm')) { fb.classList.add('confirm'); fb.textContent = 'Confirm: park every session'; setTimeout(() => { fb.classList.remove('confirm'); fb.textContent = 'Park all'; }, 6000); return; } + fb.disabled = true; + const ok = await post('fleet', { op }, 'fleet'); + if (!ok) fb.disabled = false; + else loadFleet(); + return; + } const mv = ev.target.closest('[data-mdview]'); if (mv) { const v = mv.dataset.mdview; diff --git a/docs/devteam/governance/constitution.md b/docs/devteam/governance/constitution.md index 02fdd83..7a24860 100644 --- a/docs/devteam/governance/constitution.md +++ b/docs/devteam/governance/constitution.md @@ -41,7 +41,7 @@ Some owner decisions can be handed to the supervisor a step at a time, as eviden "release-production": { "ceiling": 3, "start": 1, "about": "ship a tagged release to production, with every gate passing at the tagged commit" }, "production-migration": { "ceiling": 3, "start": 1, "about": "run a rollback-safe migration against production data" } }, - "constitutional": ["merge-word", "approve-feature-plan", "merge-chore", "worker-slots", "replan-within-feature", "add-ticket", "approve-spec", "approve-plan", "approve-design-change", "contract-ruling", "improvement-trial", "release-staging", "goal-readiness", "apply-default", "withdraw-question", "recovery", "carry-answer", "drain", "undrain"], + "constitutional": ["merge-word", "approve-feature-plan", "merge-chore", "worker-slots", "replan-within-feature", "add-ticket", "approve-spec", "approve-plan", "approve-design-change", "contract-ruling", "improvement-trial", "release-staging", "goal-readiness", "apply-default", "withdraw-question", "recovery", "carry-answer", "drain", "undrain", "goal-hold", "fleet-drain", "fleet-park", "fleet-undrain", "fleet-unpark"], "owner_only": ["one-way-door", "check-override", "governance-edit", "process-edit", "gate-change", "goal-change", "new-dependency"], "reasons": [ { "id": "below-rung", "means": "the class is on the ladder and the supervisor isn't trusted to act alone yet" }, diff --git a/docs/devteam/process/planning.md b/docs/devteam/process/planning.md index e8c33a9..8bace46 100644 --- a/docs/devteam/process/planning.md +++ b/docs/devteam/process/planning.md @@ -10,7 +10,7 @@ A feature is what other teams call an epic: stories, many PRs, one reviewed plan The supervisor plans itself or makes a planner (`devteam.mjs planner `), a session in its own worktree on branch `plan/`. Everything a planner produces lands through one reviewed PR: the design, the architecture update, the review records, and `project/log/plan-.jsonl` holding the feature and its tickets. Nothing is schedulable until that PR merges. -When the supervisor drains the fleet (`supervisor.md` §12), a planner does what `worker.md` §9 says, with its handoff note at `project/plans//handoff.md` on `plan/`; `phase parked` is the one phase a planner logs. +When its goal is parked, let go, or the fleet is parked (`supervisor.md` §12), a planner does what `worker.md` §9 says, recording its handoff on `plan/` with `devteam.mjs handoff`; `phase parked` is the one phase a planner logs. ## 1. Goals @@ -18,7 +18,7 @@ The owner states the goals, in whatever words. The supervisor drafts them (`devt **Goals are meant to be ambitious.** A goal is the unit the fleet stays aligned on across many loops and PRs: weeks or months of work, many features, many workers. Never split a goal to make it fit one loop; a goal one loop could meet is a ticket or a feature. What keeps a large goal workable is its spine (§2), its milestones, and planning one milestone at a time. -A goal moves through: **defining** (gaps, `goal-definition` questions) → **designing** (fully defined; the spine is written and reviewed and its milestones are on the goal) → **awaiting approval** (a `goal-approval` question in the inbox, the design linked) → **ready** (approved; the current milestone's features and tickets are planned from the design) → **met**, or **abandoned** if the owner lets it go. +A goal moves through (its toggle, below, is separate): **defining** (gaps, `goal-definition` questions) → **designing** (fully defined; the spine is written and reviewed and its milestones are on the goal) → **awaiting approval** (a `goal-approval` question in the inbox, the design linked) → **ready** (approved; the current milestone's features and tickets are planned from the design) → **met**, or **abandoned** if the owner lets it go. A goal is **fully defined** when it has: an outcome (what is true for users or the business when it's met), a measure with a numeric target, unit and direction, at least one non-goal, the preconditions that must hold for the outcome, a priority distinct from the other goals, and a baseline reading (`goal reading --value n`). `devteam.mjs goal check` lists what's missing. @@ -43,6 +43,14 @@ A goal is **fully defined** when it has: an outcome (what is true for users or t `goal approve` without a `goal-approval` question refuses: a goal is approved on its approach, not before one exists. +**Holds: run, drain, park, let go.** Every goal has a toggle on the dashboard (or `goal hold run|drain|park [--reason "…"]`; the owner adds `--owner` in a terminal). A hold is separate from the goal's status, so a draft and a ready goal can both be held, and the goal keeps its step. +- **Run**: normal. +- **Drain**: nothing new starts for it (no planner, no worker that hasn't run yet, no idle-planning or idle-capacity finding); work already running carries on, merges, and a parked session may resume to finish. It shows **draining**, then **drained** once nothing of it is running. +- **Park**: its sessions get a park ruling (commit WIP, push, record a handoff, `phase parked`, stop; `worker.md` §9); nothing starts; its ticket merges wait (`merge` refuses without `--force`, logged). Its track and forecast stand still (no ETA, not "behind"), and it leaves the queue other goals' dates are computed from. Its questions stay open, labelled, and don't count as the owner falling behind. +- **Let go** (`goal decide let-go`): the goal is abandoned; its sessions park and its open questions leave the inbox. It is reversible: `goal decide restore` brings it back with the status it had, parked, and its questions return. + +Work belongs to goals: a ticket to its feature's goals (when it serves several, it runs under the least restrictive of them, leaving out goals already met), a planner to the goal it plans (`planner --goal ` until its feature line or a question names one), a chore to **Upkeep** (§7). The owner sets and releases any hold. The supervisor may drain a running goal, with a reason (an act of class `goal-hold`, which the owner can correct), and release only a drain it set; it parks a single goal only by asking the owner. A whole-fleet hold is a shorthand for every goal at once (`supervisor.md` §12). + **Readings.** At each retro the supervisor records a reading from CI, metrics or releases where the measure allows. On track or behind is computed from the readings, the target and the horizon. ## 2. The design @@ -147,8 +155,10 @@ A ticket may spec and plan while its dependencies are unmerged (plan-ahead); onl When a design change, a failed spike or a correction changes the graph: the supervisor sets the affected tickets (`ticket set --json '{"superseded_by": …}'`, new `depends_on`, `touches`) and adds replacements; each such command logs its act (`replan-within-feature`, `add-ticket`); a ticket already in flight gets a ruling recorded in its spec. Ids are never reused. A re-plan big enough to need review goes through a new `plan/-` branch like a first plan. Once the supervisor has set a field on a ticket, planners can no longer change that field; later re-plans of it are the supervisor's. -## 7. Bootstrap tickets and existing codebases +## 7. The Baseline goal, Upkeep, and existing codebases + +**Baseline.** Bootstrap proposes the first goal, and the owner opts in through the regular flow (definition, design, approval): **Baseline**, the repo meeting the kit's floor. Its outcome: every gate in `quality.md` §2 and every Law in `code-style.md` enforced on main. Its measure: the floor gaps, the gates and Laws not yet enforced, counted from `gate --all` and the settings, direction down, target 0. Its preconditions group the gaps (format and lint, types, tests in CI, coverage, dead code and duplication, secrets and lockfile, an encrypted secret store); its milestones order them; its features are the floor tickets, each saying in `notes` which gate it turns on. Gates that fail on existing code start in ratchet mode. The owner may decline it, or hold it, like any goal. -Bootstrap writes the first tickets in the reserved `chores` feature: one per gate in `quality.md` §2 and Law in `code-style.md` the repo doesn't yet enforce, each saying in `notes` which gate it turns on. Gates that fail on existing code start in ratchet mode. These are the fleet's first work, before any feature. +**Upkeep.** Work that serves no goal of the owner's belongs to the built-in **Upkeep** goal: the reserved `chores` feature (chores, spikes, CI fixes), and the supervisor's upkeep (retros, research, kit syncs, the improvement loop's trials and proposals, trust and lesson suggestions). It has no measure, design, milestones or place in the forecast, can't be let go, and has the same toggle as any goal (`goal hold upkeep …`); its line is made the first time it is held. While it is drained or parked, no new chore ticket, upkeep chore (`chore start`) or improvement act starts. Two things never stop: observing (metrics, findings, readings), so a held fleet still notices what it should; and keeping the record (`log-sync`, `merge --chore log`, posted secrets). Adopting an existing codebase: write `architecture.md` from the code; list work already in flight in its table and keep new footprints off those files; start `max_active_tickets` at 2 or 3, since old hotspots make footprints overlap, and raise it when `waves` shows real width; plan the first feature with contract tickets that carve stable interfaces around the hotspots; where the repo has its own process docs, map each rule to the devteam doc that covers it, link where they agree, ask the owner where they conflict. diff --git a/docs/devteam/process/supervisor.md b/docs/devteam/process/supervisor.md index bf16033..1b922ae 100644 --- a/docs/devteam/process/supervisor.md +++ b/docs/devteam/process/supervisor.md @@ -7,7 +7,7 @@ Two loops run through you. **Goal → design → roadmap → graph → tickets:* ## 1. Starting up 1. You are the owner's foreground agent session in the main checkout, with `DEVTEAM_ROLE=supervisor` in your environment (owner commands refuse to run in it). If you are the session that adopted devteam, you are already the supervisor: carry on here, and never start another supervisor session, headless or in tmux. If your agent CLI doesn't keep shell state between commands, prefix every devteam command with `DEVTEAM_ROLE=supervisor`. Running inside tmux lets workers' messages and the owner's answers be typed into your pane as they happen; outside tmux they wait in the log for your next tick (`messages --open`, `questions --all`). -2. If your prompt opens by saying the fleet is drained, do what it says first (§12: on the machine it moved to, `undrain` and restart the drained sessions). Read `project/settings.md`, then: `devteam.mjs goal list`, `knowledge --status accepted`, `problem`, `trust`, `questions --all`, `messages --open`, `history --tail 200`, `status`. +2. If your prompt opens by saying the fleet is parked or draining, do what it says first (§12: on the machine it moved to, `fleet unpark` and restart the parked sessions). Read `project/settings.md`, then: `devteam.mjs goal list`, `knowledge --status accepted`, `problem`, `trust`, `questions --all`, `messages --open`, `history --tail 200`, `status`. 3. No ready goals: start at `planning.md` §1 by asking the owner what the project is for; a fully defined draft goal needs its design (the spine), its milestones on the goal, and a `goal-approval` question; a goal the owner asked to refine (`goal show`: `decision.verdict` refine) needs its design reworked with their notes and a new `goal-approval` question. Ready goals with no design or no features for the current milestone: plan (`planning.md` §2), yourself or with `devteam.mjs planner `. A goal whose reading has reached a milestone's target: re-read the spine against what was built, then plan the next milestone (`planning.md` §1, planning rolls a milestone at a time). 4. Match tmux windows to tickets: `tmux list-windows -t ` against `status`. A ticket whose window is gone but whose phase is not done or parked goes to recovery (§11). Workers' messages reach your pane only when you run inside tmux; either way `messages --open` has them. 5. Run the tick (§3). @@ -181,32 +181,36 @@ Due `retro_every_days` after the last one, or when a feature has fully merged si - A provider limit across the fleet: set sessions to continue at reset where the agent supports it. - Kill only a known PID, never by pattern on a shared machine. - After your own restart: §1 again, then message every worker to re-read its files. -- Stopping the fleet to move it to another machine, or for any planned stop: §12 (`drain`, `drain --status`, `undrain`). +- Stopping work on a goal, or the whole fleet (to move it, or for any planned stop): §12 (`goal hold`, `fleet drain|park`, `fleet status`, `fleet unpark|undrain|resume`). - After a merge, and only once `merge` has succeeded (chain cleanup with `&&`, never `;`: a refused merge, such as a forge outage, must leave the worker's window and worktree alone): once the worker has logged `phase done` (its final spend; on a later tick if it hasn't yet), `git worktree remove .worktrees/` and close the window. Closing it first loses the ticket's cost. The log keeps the history. -## 12. Moving the fleet +## 12. Holding the fleet, and moving it -To stop the whole fleet cleanly and pick it up on another computer. Git carries the log once it lands (`project/log`), every ticket and plan branch, and the PRs. Everything else is this machine's: `run/` (the live log, the owner's review results and drafts in `run/questions`, `run/pr` with `forge: "git"`, caches, `dashboard.log`), every session's worktree under `.worktrees/` and whatever is uncommitted or unpushed there, each agent's harness session (its context doesn't move), the `worktree_files` and secret keys, and the tools (agent CLI, `gh`, asdf, tmux, the dashboard). A drain moves what can move into git and lists the rest. +Goal holds (`planning.md` §1) work one goal at a time; the fleet ops set every goal at once, Upkeep included, and record what each goal was, so undoing one puts back exactly what it changed: -**The rule:** anything the new machine needs for 1:1 parity is checked in and portable. Only secrets (`worktree_files`, the sinks' keys) travel outside git, by hand over a secure channel. Nothing of value may live only in `run/`, a worktree, a stash or an agent's own memory when you stop. +- `fleet drain [--reason "…"]`: every running goal to drain. Nothing new starts; in-flight work finishes and merges. +- `fleet park [--reason "…"]`: every goal not parked to park, and the park ruling to every running session. +- `fleet undrain`, `fleet unpark`: put back each goal the op changed and nobody changed since. `fleet resume` does whichever is in force (unpark first). +- `fleet status`: whether the fleet can move (below). The dashboard's strip at the top shows it. -**On the old machine** -1. `devteam.mjs drain --reason ""`. It logs an act of class `drain` (operational: on no ladder, it grants nothing) naming every worker and planner it tells, and sends each the same `ruling`: commit work in progress (a WIP commit is fine) and push its branch; write a handoff note (`project/tickets//handoff.md`, or `project/plans//handoff.md` for a planner) with what's done, the exact next step, open questions and anything it couldn't commit; commit and push it; `phase parked --note "drained"`; stop (`worker.md` §9). Running `drain` again re-sends it to the sessions not yet parked. -2. While draining, `worker` and `planner` refuse to start a session (`--force` starts one anyway, and its act says so), the idle-capacity and idle-planning findings are off, and parked sessions are not stale. Answer what's open as usual; ask the owner to finish any review page they saved a draft of. -3. `devteam.mjs drain --status` until it says **ready to move: yes**. Per session it checks parked, the branch pushed (local equals origin), the worktree clean, and the handoff note on the pushed branch; fleet-wide, that every live log file is on main (else `log-sync`, then `merge --chore log`), spend observed up to now (else `observe-spend`, then land the log again), and every answered review result committed to its PR (`review result --commit` in that PR's worktree). It warns about drafts that won't move and, with forge git, `run/pr`; it lists open PRs and open owner questions, which move with the forge and the log. The one line that can't land first is the act of merging the log itself; git keeps that merge. The supervisor's own spend after its last `observe-spend` stays behind (a warning). -4. It ends with the new machine's checklist, generated from the settings and the log: the clone, `.tool-versions` (asdf), the agent CLI and `gh` logins, the `worktree_files` and the secret sinks' keys to copy over a secure channel (never git, chat or the log), tmux, the supervisor start, `undrain`, and the dashboard. Give it to the owner, then stop: close the workers' tmux session and your own. +You may run any of them (each is a logged act the owner can correct); the owner can from the dashboard or with `--owner`. On a single goal you may only drain (`goal hold drain --reason "…"`) and release your own drain; to park or let go of one, ask. When a hold or let-go lands, the owner's command nudges you: send its sessions the park ruling (`goal park-sessions `, once per cause; `--again` re-sends to those not yet parked), and on a release restart what it parked with the commands it prints. The findings say what's left: `park-unsent`, `parked-under-run` (a session parked under a goal that runs again: resume it), `blocked-by-goal-hold` (a ticket waiting on a held goal's work: ask the owner), `goal-drained` (a drain has finished), `held-long` (a goal parked for `hold_review_days`: ask whether to run it or let it go). When the owner asks why work stopped, or you think a goal should stop, the hold is the answer: drain what you may; ask for the rest. -**Your own handoff.** Once every session is parked, write `project/handoffs/supervisor.md`: what you were in the middle of, what you are waiting on (PRs, CI, the owner), what you told the owner you would do, trials and promises in flight, and anything from your agent's own memory that the next supervisor needs (owner rules belong in knowledge; add them there). Land it: `chore start drain-handoff`, `chore pr drain-handoff --title "Supervisor handoff"`, `merge --chore drain-handoff`. A later drain overwrites it. +**Moving the fleet to another machine.** A fleet fully parked or fully drained can move with 1:1 parity. Git carries the log once it lands (`project/log`), every ticket and plan branch with its handoff commit, the supervisor's handoff tag, and the PRs. Everything else is this machine's: `run/` (the live log, the owner's review results and drafts in `run/questions`, `run/pr` with `forge: "git"`, caches, `dashboard.log`), every session's worktree under `.worktrees/` and whatever is uncommitted or unpushed there, each agent's harness session (its context doesn't move), the `worktree_files` and secret keys, and the tools (agent CLI, `gh`, asdf, tmux, the dashboard). -**What `drain --status` also blocks on**, so nothing stays behind: no supervisor handoff on main since the drain; agent memory outside the repo changed after that handoff landed; files in `run/` the kit didn't make (move what matters into `project/` with a chore, delete the rest); any worktree under `.worktrees/` with uncommitted work or commits on no remote; an owner's unfinished review draft. Local branches with commits on no remote and stash entries are warned about. +**The rule:** anything the new machine needs for 1:1 parity is in git. Only secrets (`worktree_files`, the sinks' keys) travel outside it, by hand over a secure channel. Nothing of value may live only in `run/`, a worktree, a stash or an agent's own memory when you stop. -**When it says ready to move: yes** it prints a **parity** fingerprint of the whole log. Give it to the owner with the checklist, then stop: close the workers' tmux session and your own. Anything you log after that stays on this machine. +**On the old machine** +1. `fleet park --reason "moving to "` (quick: sessions stop where they are, with a handoff) or `fleet drain` (slower: every ticket in flight finishes and merges first, so nothing needs a handoff). +2. `fleet status` until it says **ready to move: yes**. Per session: parked (with a park; under a drain, finished), the branch pushed (local equals origin), the worktree clean, and, for a parked session, its handoff commit on the pushed branch, newer than the park that stopped it. Fleet-wide: every live log file on main (else `log-sync`, then `merge --chore log`), spend observed (else `observe-spend`, then land the log again), every answered review result committed to its PR, and your own handoff. It warns about drafts that won't move and, with forge git, `run/pr`; it lists open PRs and owner questions, which move with the forge and the log. The one line that can't land first is the act of merging the log itself; git keeps that merge. +3. **Your own handoff.** Once every session is parked or finished, write what you were in the middle of, what you are waiting on (PRs, CI, the owner), what you told the owner you would do, trials and promises in flight, and anything from your agent's own memory the next supervisor needs (owner rules belong in knowledge; add them there), and push it: `devteam.mjs handoff --supervisor`, the note on stdin. It becomes an annotated tag on origin, `devteam-handoff/`; no file, no PR. +4. `fleet status` also blocks on: agent memory outside the repo changed after your handoff; files in `run/` the kit didn't make; any worktree with uncommitted work or commits on no remote; an owner's unfinished review draft. Local branches with unpushed commits and stash entries are warned about. +5. When it says ready to move it prints a **parity** fingerprint of the whole log and the new machine's checklist. Give both to the owner, then stop: close the workers' tmux session and your own. Anything you log after that stays on this machine. **On the new machine** (the owner, then you) -1. The owner clones, installs the pinned tools, logs in to the agent CLI and `gh`, copies the files and keys, and starts you exactly as the README's step 9 does. Your prompt (`prompt supervisor`) sees a drain with no later undrain and opens with what to do. -2. `devteam.mjs undrain`. It prints the drained supervisor's handoff (read it first) and the log's parity fingerprint, which matches the one the old machine printed when the move is 1:1. If `run/log` is empty or missing it rebuilds it from `project/log` on origin's main, then logs an `undrain` act (with this host) and lifts the drain. It prints each drained session's restart command: `worker ` or `planner `, with `--resume` only where the window and worktree are still here. Run them; a worktree is made from the pushed branch, never from main, and `--resume` where no harness session can exist (its worktree was just made) starts fresh instead and says so. +1. The owner clones, installs the pinned tools, logs in to the agent CLI and `gh`, copies the files and keys, and starts you exactly as the README's step 9 does. Your prompt sees the parked (or drained) fleet and opens with what to do. +2. `fleet unpark` (or `fleet undrain`, or `fleet resume`). If `run/log` is empty it rebuilds it from `project/log` on origin's main, prints the parity fingerprint before writing anything (it matches the old machine's when the move is 1:1) and your predecessor's handoff (read it first), then puts every goal back as it was before the fleet op and prints each parked session's restart command: `worker ` or `planner `, with `--resume` only where the window and worktree are still here. Run them; a worktree is made from the pushed branch, never from main, and `--resume` where no harness session can exist starts fresh and says so. Sessions whose own goal is still parked stay parked, and it says which. 3. Make sure the dashboard is running (§11) and tell the owner its address. Then §1 as usual. -**The handoff note.** A restarted session gets its handoff note in its prompt, marked "Handoff from a drained session", only while the note is current: the session was parked by the latest drain and has logged no phase of its own since (the launch's `starting` line doesn't count; a planner, which logs no phases while it works, ends it with any line of its own, such as a message). Once it logs its first phase after the restart, later prompts leave the note out, so a stale note can't mislead a later session. The file is never deleted: it stays in the ticket's (or plan's) folder and lands with the PR, and a later drain overwrites it with a fresh one. Adding or rewriting a handoff note does not void a review verdict. +**The handoff in a prompt.** A restarted session gets its handoff in its prompt, marked "Handoff from a parked session", only while it is current: its latest `parked` line had a park cause (a park or let-go of its goal, or a fleet op), and it has logged no phase of its own since (the launch's `starting` line doesn't count; a planner ends it with any line of its own). The handoff commit stays on its branch and lands with its PR. Notes written by the old drain (`handoff.md` files, `project/handoffs/supervisor.md`) are still read. -Drain and undrain change who may start sessions, nothing else: neither answers, approves or merges anything, and nothing the owner decides moves to you. +**The old names.** `drain` now asks which you mean (it used to park); `drain --status` is `fleet status`; `undrain` lifts an old drain still in force. Holds change what may start and merge, nothing else: they answer, approve or merge nothing, and nothing the owner decides moves to you. diff --git a/docs/devteam/process/worker.md b/docs/devteam/process/worker.md index 5f1da1a..64125b9 100644 --- a/docs/devteam/process/worker.md +++ b/docs/devteam/process/worker.md @@ -8,7 +8,7 @@ You are a worker: one session bound to one ticket, from spec to merge, run with ## 2. Catch up before you act -0. If your prompt has a **Handoff from a drained session**, read it first: it is the previous session's own account (what's done, the exact next step, open questions, anything it couldn't commit). Check it against your branch and the log, then carry on from its next step. Keep `handoff.md`; it is part of the record and lands with your PR. Once you log your first phase, later prompts leave it out. +0. If your prompt has a **Handoff from a parked session**, read it first: it is the previous session's own account (what's done, the exact next step, open questions, anything it couldn't commit). Check it against your branch and the log, then carry on from its next step. The handoff is the commit on your branch; it lands with your PR. Once you log your first phase, later prompts leave it out. 1. `devteam.mjs ticket show `: scope, dependencies, `touches`, done-whens, one-way doors. 2. `project/tickets//` if it exists: you may be resuming; its files are what has been done. 3. `devteam.mjs history `: phases, messages, rulings and owner answers about your ticket and its dependencies. Then `devteam.mjs messages`. Make sure every `ruling` and `answer` is in your spec or plan before researching: someone may already have decided what you are about to investigate. @@ -86,14 +86,13 @@ The supervisor turns recurring observations into lessons, and accepted lessons r 5. `devteam.mjs send supervisor " ready to merge" --kind merge-request` (it sends your exact head). Pushing after that voids the request; ask again with the new head. 6. When the supervisor says it merged: `devteam.mjs phase done`, then exit. The supervisor removes the worktree and closes the window. -## 9. When you are told to drain +## 9. When you are told to park -A `Drain:` ruling means the fleet is stopping, usually to move to another machine where a new session, without your context, will carry on from your branch. Stop what you are doing at the next safe point, then: +A `Park:` ruling means your goal is parked, or let go, or the whole fleet is stopping to move to another machine; a new session, without your context, may carry on from your branch. Stop what you are doing at the next safe point, then: -1. Commit all work in progress, even half-done (a `WIP:` commit is fine; never leave changes only in the worktree), and push your branch. -2. Write `project/tickets//handoff.md` (a planner: `project/plans//handoff.md`), overwriting any earlier one: what's done, the exact next step (the task, the file, the command), open questions and requests you're waiting on (with message and question ids), and anything you could not commit and why. Write it for a reader who has only your files and the log. -3. Commit and push it. -4. `devteam.mjs phase parked --note "drained"`. -5. Stop: end your turn and exit. Don't start anything new, and don't wait for a reply. +1. Commit all work in progress, even half-done (a `WIP:` commit is fine; never leave changes only in the worktree). +2. Record your handoff: `devteam.mjs handoff`, with the note on stdin (or `--file`). Write it for a reader who has only your files and the log: what's done, the exact next step (the task, the file, the command), open questions and requests you're waiting on (with message and question ids), and anything you could not commit and why. It becomes an empty commit on your branch titled `devteam handoff: `, and the branch is pushed. +3. `devteam.mjs phase parked --note "parked"`. +4. Stop: end your turn and exit. Don't start anything new, and don't wait for a reply. -A handoff note doesn't void a review verdict; nothing else about committing after an approval changes. +A merge-request you sent before parking is void (you pushed); send it again once resumed. The handoff commit changes no file, so it doesn't void a review verdict. When your goal is drained rather than parked, nothing changes for you: finish your ticket. diff --git a/docs/devteam/schema/log.schema.json b/docs/devteam/schema/log.schema.json index ac743c9..8a8def8 100644 --- a/docs/devteam/schema/log.schema.json +++ b/docs/devteam/schema/log.schema.json @@ -162,15 +162,79 @@ "abandoned" ] }, + "kind": { + "description": "upkeep: the built-in goal that owns the reserved chores feature and the supervisor's upkeep (planning.md §7). It has no measure, design, milestones or status lifecycle; the earliest upkeep line is the one, later ones are ignored.", + "enum": [ + "upkeep" + ] + }, + "hold": { + "description": "The goal's toggle (planning.md §1, holds): run, drain (nothing new starts; in-flight work finishes and merges) or park (sessions commit WIP, push, write a handoff and stop; ticket merges wait). fleet names the fleet op that set it and prior the hold it replaced, so fleet undrain/unpark can put it back. The owner sets any hold; a supervisor line counts only as a drain of a running goal, the release of its own drain, or a fleet op (the fold enforces it).", + "type": "object", + "properties": { + "mode": { + "enum": [ + "run", + "drain", + "park" + ] + }, + "reason": { + "type": "string", + "maxLength": 300 + }, + "fleet": { + "type": "string", + "pattern": "^f-\\d{8}-\\d{9}$" + }, + "undoes": { + "type": "string", + "pattern": "^f-\\d{8}-\\d{9}$" + }, + "prior": { + "type": "object", + "properties": { + "mode": { + "enum": [ + "run", + "drain", + "park" + ] + }, + "reason": { + "type": "string", + "maxLength": 300 + }, + "fleet": { + "type": "string" + } + }, + "required": [ + "mode" + ], + "additionalProperties": false + }, + "host": { + "type": "string", + "maxLength": 200, + "description": "The machine a fleet op ran on, so the moved fleet's supervisor knows whether it is on the old machine or the new one." + } + }, + "required": [ + "mode" + ], + "additionalProperties": false + }, "decision": { "type": "object", - "description": "The owner's latest decision on the goal's design: approve (ready), refine (notes the planner takes into account before asking again), let-go (abandoned).", + "description": "The owner's latest decision on the goal's design: approve (ready), refine (notes the planner takes into account before asking again), let-go (abandoned), restore (a let-go goal back to the status it had, parked).", "properties": { "verdict": { "enum": [ "approve", "refine", - "let-go" + "let-go", + "restore" ] }, "notes": { @@ -222,7 +286,8 @@ ], "x-set-only": [ "approved", - "carried" + "carried", + "hold" ] }, "feature": { @@ -1415,7 +1480,8 @@ "milestones", "plan_after", "approved", - "carried" + "carried", + "hold" ], "owner": [ "title", @@ -1428,7 +1494,8 @@ "milestones", "status", "decision", - "plan_after" + "plan_after", + "hold" ] }, "feature": { diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 97f7588..7b030db 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -166,6 +166,8 @@ const SETTINGS_DEFAULTS = { // count against max_active_tickets. max_active_planners: null, stale_after_minutes: 30, + // A goal parked this long (not by a fleet op) is worth asking the owner about again. + hold_review_days: 14, // How long `ack` sets a finding aside; it comes back sooner if its subject changes. finding_ack_hours: 24, tick_minutes: 15, @@ -426,6 +428,10 @@ function fold(all) { continue; } const { v, op, __file, __line, at, by, ...body } = l; + if (body.type === 'goal' && body.kind === 'upkeep' && [...objects.values()].some((o) => o.type === 'goal' && o.kind === 'upkeep')) { + ignored.push({ line: l, why: 'a second upkeep goal; the earliest is the one' }); + continue; + } const rank = RANK[writerClass(by)]; objects.set(l.id, { ...body, created_at: at, created_by: by, updates: [], __rank: Object.fromEntries(Object.keys(body).map((k) => [k, rank])) }); } @@ -446,7 +452,28 @@ function fold(all) { ignored.push({ line: l, why: `${l.by} may only change the feature "${f}" and its tickets` }); continue; } - if (obj.type === 'goal' && cls === 'supervisor' && (obj.status ?? 'draft') !== 'draft') { + // A goal's hold has its own rule (planning.md §1, holds): the owner's line always counts; the + // supervisor's counts as a fleet op, the undo of a fleet op still in force on this goal, a drain + // of a running goal, or the release of a drain it set itself. Anything else is ignored, with why. + let holdLine = null; + if (obj.type === 'goal' && l.hold) { + const cur = obj.hold ?? { mode: 'run' }; + const why = holdRefusal(l.hold, cur, obj.__holdBy ?? null, cls); + if (why) { + ignored.push({ line: l, why }); + if (Object.keys(l).every((k) => ENVELOPE.includes(k) || k.startsWith('__') || k === 'hold')) continue; + } else holdLine = l.hold; + } + const holdOnly = obj.type === 'goal' && Object.keys(l).every((k) => ENVELOPE.includes(k) || k.startsWith('__') || k === 'hold'); + if (holdOnly) { + if (holdLine) { + obj.hold = holdLine; + obj.__holdBy = cls; + obj.updates.push({ at: l.at, by: l.by, ...(l.channel ? { channel: l.channel } : {}), hold: holdLine }); + } + continue; + } + if (obj.type === 'goal' && cls === 'supervisor' && (obj.status ?? 'draft') !== 'draft' && obj.kind !== 'upkeep') { const problem = !l.approved ? null : goalApprovals.has(l.approved) ? `${l.approved} already authorised a goal set` : goalChangeProblem(objects.get(l.approved), obj.id, l.approved, l); if (!l.approved || problem) { ignored.push({ line: l, why: l.approved ? `the supervisor edits a ${obj.status} goal only with an answered goal-change question: ${problem}` : 'the supervisor edits a goal only while it is a draft' }); @@ -459,8 +486,13 @@ function fold(all) { } const rank = RANK[cls]; const changed = {}; + if (holdLine) { + obj.hold = holdLine; + obj.__holdBy = cls; + changed.hold = holdLine; + } for (const [k, val] of Object.entries(l)) { - if (ENVELOPE.includes(k) || k.startsWith('__')) continue; + if (ENVELOPE.includes(k) || k.startsWith('__') || k === 'hold') continue; if ((obj.__rank[k] ?? -1) > rank) continue; obj[k] = val; obj.__rank[k] = rank; @@ -470,9 +502,23 @@ function fold(all) { else ignored.push({ line: l, why: 'overridden by a higher-ranked writer' }); if (obj.type === 'goal' && changed.approved) goalApprovals.add(changed.approved); } - for (const o of objects.values()) delete o.__rank; + for (const o of objects.values()) { + delete o.__rank; + if (o.__holdBy !== undefined) { o.hold_by = o.__holdBy; delete o.__holdBy; } + } return { objects, ignored }; } +// Why the fold ignores a hold line, or null when it counts. cur is the hold in force (run when +// none), curBy who set it. A supervisor never parks a single goal or releases the owner's hold. +function holdRefusal(h, cur, curBy, cls) { + if (cls === 'owner') return null; + if (cls !== 'supervisor') return `${cls} may not change a goal's hold`; + if (h.undoes) return cur.fleet === h.undoes ? null : `this goal's hold is no longer the one ${h.undoes} set, so that fleet op doesn't undo it`; + if (h.fleet) return ['drain', 'park'].includes(h.mode) ? null : 'a fleet op drains or parks'; + if (h.mode === 'drain') return cur.mode === 'run' ? null : `the supervisor drains only a running goal (this one is on ${cur.mode})`; + if (h.mode === 'run') return cur.mode === 'drain' && curBy === 'supervisor' && !cur.fleet ? null : 'the supervisor releases only a drain it set'; + return 'only the owner parks a single goal (the supervisor may fleet park)'; +} // --- where the log lives --- // @@ -674,7 +720,9 @@ function readLog() { const { objects, ignored } = fold(lines); return (logCache = { lines, objects, errors, ignored }); } -const logObjects = (type) => [...readLog().objects.values()].filter((o) => o.type === type); +const logObjects = (type) => [...readLog().objects.values()].filter((o) => o.type === type && !(type === 'goal' && o.kind === 'upkeep')); +// The built-in Upkeep goal (planning.md §7), or null while it has no line yet (then it runs). +const upkeepGoal = () => [...readLog().objects.values()].find((o) => o.type === 'goal' && o.kind === 'upkeep') ?? null; const logGet = (id) => readLog().objects.get(id) ?? null; const byTime = (a, b) => a.created_at.localeCompare(b.created_at); @@ -714,7 +762,8 @@ function questionView(q) { review: review?.review ?? null, result: review?.result ?? null, declined: review ? review.declined : null, allow_partial: review ? review.allow_partial : null, result_status: review?.result_status ?? null, }; } -const questions = () => logObjects('question').map(questionView); +// The inbox's view: each question with its goal's hold (never inside questionView, which the fold uses). +const questions = () => logObjects('question').map((q) => ({ ...questionView(q), goal_hold: questionHold(q) })); // Why question q does not authorise the supervisor to change goal goalId (null when it does): it // is a goal-change question about that goal, answered by the owner with its recommendation (an @@ -1257,7 +1306,7 @@ function planners() { // Before its feature line exists (while designing), a planner's goal is the one its questions // name: a question whose links point at this plan branch. const refQs = questions().filter((q) => (q.links ?? []).some((l) => l.ref === b || l.ref === `refs/heads/${b}`)); - const goals = [...new Set([...(feat.goals ?? []), ...refQs.map((q) => q.goal).filter(Boolean)])]; + const goals = [...new Set([...(feat.goals ?? []), ...refQs.map((q) => q.goal).filter(Boolean), ...plannerLaunchGoals(feature)])]; return { feature, title: feat.title ?? feature, goals, tickets: graph.split('\n').filter((t) => t.includes('"type":"ticket"') && t.includes('"op":"new"')).length, branch: b, window: !!target, problem: target ? null : problem, waiting: target ? paneWaitingIn(by) : null, @@ -1391,6 +1440,8 @@ function cmdValidate() { for (const g of f.goals ?? []) { const o = logGet(g); if (o?.type !== 'goal') errors.push(`feature ${fid} names goal ${g}, which is not in the log`); + else if (o.kind === 'upkeep') errors.push(`feature ${fid} names the Upkeep goal; upkeep is the chores feature's, and a feature serves the goals it plans for`); + else if (o.status === 'abandoned') warnings.push(`feature ${fid} names goal ${g}, which the owner let go: its work stays parked until goal decide ${g} restore`); else if (!['ready', 'met'].includes(o.status)) errors.push(`feature ${fid} names goal ${g}, which is ${o.status ?? 'draft'}; the owner approves a goal before features are planned from it`); else { // A feature's own design builds on its goal's design, the spine, and says so (planning.md §2). @@ -1447,6 +1498,176 @@ function cmdValidate() { // Why a ticket with no worker isn't waiting for one, or null if it is: the supervisor parked its // last worker (a ruling stopped it), or its branch already carries a final review (it waits for // the merge, not for a new worker). +// ===== holds: run, drain, park, let go (planning.md §1, holds) ===== +// +// A goal's hold says what the fleet may do for it: run; drain (nothing new starts, in-flight work +// finishes and merges); park (sessions commit WIP, push, write a handoff and stop; ticket merges +// wait). Let go is the goal abandoned: parked, and reversible (goal decide restore). Every +// piece of work belongs to a goal: a ticket to its feature's goals, a chore to Upkeep, a planner to +// the goal it plans. A fleet op (fleet drain, fleet park) sets every goal's hold at once and records +// what each was, so fleet undrain or unpark puts back what it changed. +const HOLD_RANK = { run: 0, drain: 1, park: 2, 'let-go': 3 }; +const UPKEEP_VIRTUAL = Object.freeze({ id: 'upkeep', type: 'goal', kind: 'upkeep', title: 'Upkeep', hold: null, updates: [] }); +const upkeepOrVirtual = () => upkeepGoal() ?? UPKEEP_VIRTUAL; +const isUpkeep = (g) => g?.kind === 'upkeep'; +// A goal named on the command line: its id, or "upkeep". +function goalArg(id) { + if (id === 'upkeep') return upkeepOrVirtual(); + const g = logGet(id ?? ''); + return g?.type === 'goal' ? g : null; +} +function goalMode(g) { + if (!g) return 'run'; + if (g.status === 'abandoned') return 'let-go'; + return g.hold?.mode ?? 'run'; +} +// The mode work serving several goals runs under: the least restrictive among them, leaving out +// goals already met (a feature that served a met goal and a parked one is parked). +function modeOf(goals) { + const known = goals.filter(Boolean); + const live = known.filter((g) => g.status !== 'met'); + const set = live.length ? live : known; + if (!set.length) return 'run'; + return set.map(goalMode).sort((a, b) => HOLD_RANK[a] - HOLD_RANK[b])[0]; +} +function ticketGoals(t) { + if (!t) return []; + if (t.feature === CHORES) return [upkeepOrVirtual()]; + return (features().get(t.feature)?.goals ?? []).map((id) => logGet(id)).filter((g) => g?.type === 'goal'); +} +const ticketMode = (t) => modeOf(ticketGoals(t)); +// The goals that can hold anything: live goals and Upkeep. +const holdableGoals = () => [upkeepOrVirtual(), ...logObjects('goal').filter((g) => !['met', 'abandoned'].includes(g.status))]; +// When every holdable goal is parked, or every one is draining or parked, the fleet is too. +function fleetMode() { + const modes = holdableGoals().map(goalMode); + if (modes.every((m) => m === 'park')) return 'park'; + if (modes.every((m) => m === 'park' || m === 'drain') && modes.includes('drain')) return 'drain'; + return null; +} +// The fleet op in force: the newest one whose hold is still on some goal. +function fleetOpInForce() { + const hs = holdableGoals().map((g) => g.hold).filter((h) => h?.fleet); + return hs.sort((a, b) => b.fleet.localeCompare(a.fleet))[0] ?? null; +} +const newFleetId = () => { const at = nowIso(); return `f-${at.slice(0, 10).replace(/-/g, '')}-${at.slice(11, 23).replace(/[:.]/g, '')}`; }; +// A session's goals: a worker's ticket's; a planner's feature line's, its questions', or its launch's. +function sessionGoals(name) { + const s = sessionOf(name); + if (s.role === 'worker') return ticketGoals(tickets().get(s.id)); + const p = planners().find((x) => x.feature === s.id); + const ids = p ? p.goals : plannerLaunchGoals(s.id); + return ids.map((id) => logGet(id)).filter((g) => g?.type === 'goal'); +} +// The goal a planner was started for (planner --goal), from its launch acts. +function plannerLaunchGoals(feature) { + const re = new RegExp(` planner plan-${feature} `); + return [...new Set(logObjects('act').filter((a) => a.class === 'worker-slots' && re.test(` ${a.what} `)).flatMap((a) => (a.refs ?? []).filter((r) => r.startsWith('g-'))))]; +} +// A session with no goal (an old planner) follows the fleet. +function sessionMode(name) { + const gs = sessionGoals(name); + return gs.length ? modeOf(gs) : fleetMode() ?? 'run'; +} +// The hold (or let-go) that holds a session now, as a cause: which goal, since when, and a marker a +// park ruling carries so it is sent once per cause. +function currentCause(name) { + const gs = sessionGoals(name); + const set = gs.length ? gs.filter((g) => g.status !== 'met') : holdableGoals(); + const causes = set.map((g) => causeOf(g, Date.now())).filter(Boolean); + if (!causes.length || causes.length < set.length) return null; + return causes.sort((a, b) => b.at.localeCompare(a.at))[0]; +} +// What parked or let go a goal at a time (ms), or null if it was running or draining then. +function causeOf(g, when) { + const ups = (g.updates ?? []).filter((u) => Date.parse(u.at) <= when); + const st = ups.filter((u) => u.status !== undefined).pop(); + if (st?.status === 'abandoned') return { kind: 'let-go', goal: g.id, title: g.title, at: st.at, marker: `let-go:${g.id}@${st.at}`, reason: g.decision?.notes ?? null }; + const h = ups.filter((u) => u.hold !== undefined).pop(); + if (h?.hold?.mode !== 'park') return null; + return h.hold.fleet + ? { kind: 'fleet', goal: g.id, title: g.title, at: h.at, fleet: h.hold.fleet, host: h.hold.host ?? null, marker: `fleet:${h.hold.fleet}`, reason: h.hold.reason ?? null } + : { kind: 'park', goal: g.id, title: g.title, at: h.at, marker: `park:${g.id}@${h.at}`, reason: h.hold.reason ?? null }; +} +// What a session told to park must do; the marker in brackets lets a ruling be sent once per cause. +function parkMessage(s, cause) { + const why = cause.kind === 'fleet' ? `the fleet is parked${cause.reason ? ` (${cause.reason.slice(0, 120)})` : ''}` : cause.kind === 'let-go' ? `the owner let go of "${cause.title}"` : `"${cause.title}" is parked${cause.reason ? ` (${cause.reason.slice(0, 120)})` : ''}`; + return `Park: ${why}. Now: commit all work in progress (a WIP commit is fine); then devteam.mjs handoff, with your note on stdin (what is done, the exact next step, open questions, anything you could not commit): it adds the note as an empty commit on ${s.branch} and pushes the branch; then devteam.mjs phase parked --note "parked", and stop (worker.md §9). A merge-request you sent is void once you push; send it again when you are resumed. [${cause.marker}]`; +} +const parkRulingSent = (name, marker) => logObjects('message').some((m) => m.to === name && m.kind === 'ruling' && (m.text ?? '').includes(`[${marker}]`)); +// Every live session: workers with a phase that isn't done and no merge, planners in flight. +function liveSessions() { + const names = new Set(); + for (const t of tickets().values()) if (activeWorker(t.id) || (workerState(t.id)?.phase === 'parked' && !mergeShaFor(t.id) && isActive(t))) names.add(t.id); + for (const p of planners()) names.add(`plan-${p.feature}`); + return [...names].sort().map(sessionOf); +} +// Send the park ruling to every running session a park or let-go holds and hasn't been told about +// for its current cause (once; --again re-sends). goal: only that goal's sessions. +function sendParkRulings({ goal = null, again = false } = {}) { + const sent = []; + for (const s of liveSessions()) { + if (lastPhaseOf(s.name)?.to === 'parked') continue; + if (goal && !sessionGoals(s.name).some((g) => g.id === goal.id) && !(isUpkeep(goal) && !sessionGoals(s.name).length)) continue; + if (!['park', 'let-go'].includes(sessionMode(s.name))) continue; + const cause = currentCause(s.name); + if (!cause || (!again && parkRulingSent(s.name, cause.marker))) continue; + sendLine({ to: s.name, kind: 'ruling', text: parkMessage(s, cause), from: 'supervisor', ticket: s.role === 'worker' ? s.id : null }); + sent.push(s.name); + } + return sent; +} +// The restart command for each parked session a release lets run again. +function restartCommands(filter = () => true) { + const windows = new Set((sh('tmux', ['list-windows', '-t', loadSettings().tmux_session, '-F', '#W']) ?? '').split('\n').filter(Boolean)); + return liveSessions().filter((s) => lastPhaseOf(s.name)?.to === 'parked' && ['run', 'drain'].includes(sessionMode(s.name)) && filter(s)).map((s) => ({ name: s.name, cmd: `${s.role} ${s.id}${windows.has(s.name) && fs.existsSync(s.wt) ? ' --resume' : ''}`, handoff: !!handoffFor(s.name) })); +} +// Write one goal's hold, creating Upkeep's line the first time it is held. +function writeHold(g, hold, by) { + let target = g; + if (isUpkeep(g) && !upkeepGoal()) target = appendLog({ type: 'goal', kind: 'upkeep', title: 'Upkeep' }, by); + return appendLog({ type: 'goal', op: 'set', id: target.id, hold }, by); +} +const holdWords = (m) => ({ run: 'running', drain: 'draining', park: 'parked', 'let-go': 'let go' }[m] ?? m); +// The mode label a goal shows: drain splits into draining (work still running) and drained. +function holdView(g) { + const mode = goalMode(g); + const running = mode === 'drain' || mode === 'park' ? liveSessions().filter((s) => sessionGoals(s.name).some((x) => x.id === g.id) && lastPhaseOf(s.name)?.to !== 'parked').map((s) => s.name) : []; + const state = mode === 'drain' ? (running.length ? 'draining' : 'drained') : mode === 'park' ? (running.length ? 'parking' : 'parked') : mode === 'let-go' ? 'let go' : 'running'; + return { mode, state, running, reason: g.hold?.reason ?? null, by: g.hold_by ?? null, fleet: g.hold?.fleet ?? null, since: (g.updates ?? []).filter((u) => u.hold !== undefined).pop()?.at ?? null }; +} + +// What holds need from the supervisor, as findings with a next step (supervisor.md §3): rulings a +// park hasn't sent yet, sessions parked under a goal that runs again, tickets waiting on a held +// goal's work, a drain that has finished, and a goal parked long enough to ask about. +function holdFindings(add, settings) { + const now = Date.now(); + const sessions = liveSessions(); + for (const s of sessions) { + const parkedNow = lastPhaseOf(s.name)?.to === 'parked'; + const mode = sessionMode(s.name); + if (!parkedNow && ['park', 'let-go'].includes(mode)) { + const c = currentCause(s.name); + if (c && !parkRulingSent(s.name, c.marker)) add('high', 'park-unsent', s.name, `its goal is ${holdWords(mode)} (${causeWords(c)}), and it hasn't been told`, `goal park-sessions ${isUpkeep(logGet(c.goal) ?? UPKEEP_VIRTUAL) ? 'upkeep' : c.goal}`); + } + if (parkedNow && ['run', 'drain'].includes(mode) && !draining()) add('medium', 'parked-under-run', s.name, `parked, and its goal is ${holdWords(mode)} again`, `${s.role} ${s.id} --resume${mode === 'drain' ? ' (a draining goal finishes what it started)' : ''}`); + } + const ts = tickets(); + const providers = new Map(); + for (const t of ts.values()) for (const c of t.contracts?.provides ?? []) providers.set(c, t.id); + for (const t of ts.values()) { + if (!isActive(t) || mergeShaFor(t.id) || ticketMode(t) !== 'run') continue; + const blockers = [...(t.depends_on ?? []), ...(t.contracts?.consumes ?? []).map((c) => providers.get(c)).filter(Boolean)].filter((d) => ts.has(d) && !mergeShaFor(d) && ticketMode(ts.get(d)) !== 'run'); + if (blockers.length) add('medium', 'blocked-by-goal-hold', t.id, `waits on ${blockers.map((d) => `${d} (its goal is ${holdWords(ticketMode(ts.get(d)))})`).join(', ')}`, 'ask the owner whether to run the goal it waits on, or re-plan the dependency'); + } + for (const g of holdableGoals()) { + const v = holdView(g); + const name = isUpkeep(g) ? 'upkeep' : g.id; + if (v.state === 'drained' && !v.fleet) add('low', 'goal-drained', name, `${isUpkeep(g) ? 'Upkeep' : `"${g.title}"`} has finished its in-flight work`, `leave it drained, or run it again (goal hold ${name} run${v.by === 'owner' ? ' --owner: the owner set it' : ''})`); + const days = v.since ? (now - Date.parse(v.since)) / 86400000 : 0; + if (v.mode === 'park' && !v.fleet && days >= (settings.hold_review_days ?? 14)) add('low', 'held-long', name, `parked for ${Math.floor(days)} days${v.reason ? ` (${v.reason})` : ''}`, 'ask the owner whether to run it again or let it go (an owner-decision question)'); + } +} function notSchedulable(t) { if (workerState(t.id)?.phase === 'parked') return 'parked'; const b = ticketBranchRef(t.id); @@ -1463,6 +1684,8 @@ function cmdReady() { const why = notSchedulable(t); if (why) { waiting.push({ id: t.id, why }); continue; } const depsMerged = (t.depends_on ?? []).every((d) => mergeShaFor(d)); + const gm = ticketMode(t); + if (gm !== 'run') { waiting.push({ id: t.id, why: `goal ${holdWords(gm)}` }); continue; } if (isHeld(t)) { if (depsMerged || flags['plan-ahead']) held.push({ id: t.id, held: t.held }); continue; } if (depsMerged || flags['plan-ahead']) rows.push({ id: t.id, component: t.component, kind: t.kind, size: t.size, risk: t.risk, mode: depsMerged ? 'build' : 'plan-ahead' }); } @@ -1473,7 +1696,7 @@ function cmdReady() { if (flags.json) return json({ ready: rows, held, waiting, active, active_per_component: perComponent, caps: { max_active_tickets: s.max_active_tickets, max_active_per_component: s.max_active_per_component } }); out(`active workers: ${active.length}/${s.max_active_tickets}`); for (const h of held) out(`held ${h.id} (${h.held})`); - for (const w of waiting) out(`${w.why.padEnd(10)} ${w.id} (no new worker: ${w.why === 'parked' ? 'resume it with `worker --resume` when its ruling allows' : 'merge or answer its review'})`); + for (const w of waiting) out(`${w.why.padEnd(10)} ${w.id} (no new worker: ${w.why === 'parked' ? 'resume it with `worker --resume` when its ruling and its goal allow' : w.why.startsWith('goal ') ? 'its goal is on hold (goal hold)' : 'merge or answer its review'})`); if (!rows.length) return out('nothing ready'); for (const r of rows) out(`${r.mode.padEnd(10)} ${r.id} (${r.kind}, ${r.size}, ${r.risk})${(perComponent[r.component] ?? 0) >= s.max_active_per_component ? ' (component at cap)' : ''}`); } @@ -1971,6 +2194,10 @@ function cmdMerge() { const sha = pr.headRefOid; if (!resolveCommit(sha)) die(`cannot find ${sha} locally after fetching ${branch}`); const fails = checkPr({ headRef: branch, sha, title: pr.title, body: pr.body }); + // A parked or let-go goal's ticket waits to merge (a draining one merges: that's the point), and + // so does an upkeep chore while Upkeep is parked; record-keeping chores never wait. --force does. + const heldBy = id && tickets().has(id) ? ticketMode(tickets().get(id)) : chore && !isRecordKeeping(chore) ? goalMode(upkeepOrVirtual()) : 'run'; + if (['park', 'let-go'].includes(heldBy) && !flags.force) fails.push(`its goal is ${holdWords(heldBy)}: it merges when the goal runs or drains again (planning.md §1, holds); --force merges it anyway, logged`); // A plan's ticket ids are new: one already on main (another plan took the number) is refused // outright; renumber on the branch. if (plan) { @@ -2108,7 +2335,7 @@ function cmdMerge() { mainLogCache = undefined; mergeShaCache.clear(); const cls = id ? 'merge-word' : plan ? 'approve-feature-plan' : 'merge-chore'; - const act = appendLog({ type: 'act', class: cls, what: `merged ${branch} at ${sha}${standing?.tag ? ` (upstream ${standing.tag}, verbatim)` : standing ? ` (${standing.what}, under the owner's standing rule)` : ''}`, refs: id ? [id] : plan ? [plan] : [], reversible: false, approved_by: approved ?? standing?.rule.id ?? undefined }, 'supervisor'); + const act = appendLog({ type: 'act', class: cls, what: `merged ${branch} at ${sha}${['park', 'let-go'].includes(heldBy) ? ` while its goal is ${holdWords(heldBy)}, with --force` : ''}${standing?.tag ? ` (upstream ${standing.tag}, verbatim)` : standing ? ` (${standing.what}, under the owner's standing rule)` : ''}`, refs: id ? [id] : plan ? [plan] : [], reversible: false, approved_by: approved ?? standing?.rule.id ?? undefined }, 'supervisor'); out(`merged ${branch} at ${sha.slice(0, 12)} (${act.id})`); } // ===== 7. trust ===== @@ -2187,6 +2414,7 @@ function cmdAsk() { supervisorOnly('ask'); const q = flagStr('question') ?? die('ask needs --question'); const kind = flagStr('kind') ?? 'owner-decision'; + if (['proposal', 'trust'].includes(kind)) upkeepAllows(`a ${kind} question`); const cls = flagStr('class') ?? null; const L = ladder(); if (kind === 'goal-approval') { @@ -3130,6 +3358,16 @@ function cmdSecret() { } if (failures.length) die(`not posted:\n - ${failures.join('\n - ')}`); } +// The hold on the goal a question is about (its goal, or the goals of the tickets it is about or +// blocks): null while that runs or drains. A let-go goal's open questions leave the inbox until it +// is restored; a parked goal's stay, labelled. +function questionHold(q) { + const ts = tickets(); + const goals = q.goal ? [logGet(q.goal)].filter((g) => g?.type === 'goal') : [...new Set([q.ticket, ...(q.blocks ?? [])].filter((id) => ts.has(id)))].flatMap((id) => ticketGoals(ts.get(id))); + if (!goals.length) return null; + const m = modeOf(goals); + return ['park', 'let-go'].includes(m) ? m : null; +} function rankQuestions(qs) { const sub = activeTickets(); const crit = findCycle(sub) ? new Set() : new Set(criticalPath(sub).path); @@ -3139,12 +3377,12 @@ function rankQuestions(qs) { function cmdQuestions() { let qs = questions(); if (flagStr('id')) qs = qs.filter((q) => q.id === flagStr('id')); - else if (!flags.all) qs = qs.filter((q) => q.status === 'open'); + else if (!flags.all) qs = qs.filter((q) => q.status === 'open' && q.goal_hold !== 'let-go'); qs = rankQuestions(qs); if (flags.json) return json(qs); if (!qs.length) return out('no questions'); for (const q of qs) { - out(`${q.id} [${q.kind}, ${q.status}]${q.blocks.length ? ` blocks ${q.blocks.join(',')}` : ''}\n ${q.question}`); + out(`${q.id} [${q.kind}, ${q.status}${q.goal_hold ? `, its goal ${holdWords(q.goal_hold)}` : ''}]${q.blocks.length ? ` blocks ${q.blocks.join(',')}` : ''}\n ${q.question}`); for (const c of q.choices) out(` - ${c.label}${c.label === q.recommendation ? ' (recommended)' : ''}${c.detail ? `: ${c.detail}` : ''}${c.effect ? ` For users: ${c.effect}` : ''}`); for (const l of q.links) out(` see: ${l.title} (${l.url ?? `${l.ref ? `${l.ref}:` : ''}${l.path}`})`); if (q.review) out(` review: ${q.review.page?.path} at ${String(q.review.commit).slice(0, 12)} (${q.review.ref})${q.review.data ? `, ${q.review.data.rows} rows` : ''}; ${q.result_status} (devteam.mjs review show ${q.id})`); @@ -3163,6 +3401,7 @@ function cmdAct() { const cls = flagStr('class'); if (!cls || !what) die('usage: act --class [--scope s] [--approved ] [--refs ids] [--irreversible] -- ""'); supervisorOnly('act'); + if (cls === 'improvement-trial') upkeepAllows('a new trial'); const L = ladder(); const scope = flagStr('scope') ?? null; const approved = flagStr('approved') ?? null; @@ -3319,6 +3558,7 @@ function cmdKnowledge() { const by = () => (flags.owner ? ownerWriter().by : (supervisorOnly('knowledge'), 'supervisor')); if (sub === 'add') { const w = by(); + if (w !== 'owner') upkeepAllows('proposing a lesson'); // An agent's lesson is proposed from what it saw: the log entries it rests on (observations, // acts, questions, messages, problems) go with it, so the owner can check them before keeping it. const evidence = asList(flags.evidence).flatMap((x) => String(x).split(',')).map((x) => x.trim()).filter(Boolean); @@ -3540,7 +3780,11 @@ function goalView(g) { } track = reached(g, expected, last.measure.value) ? 'on track' : 'behind'; } - return { ...g, status: g.status ?? 'draft', readings: readings.map((r) => ({ at: r.created_at, value: r.measure.value, source: r.source })), track, check: goalCheck(g), forecast: goalForecast(g) }; + // A parked or let-go goal stands still on purpose: it is neither on track nor behind. + const mode = goalMode(g); + const still = ['park', 'let-go'].includes(mode); + const forecast = goalForecast(g); + return { ...g, status: g.status ?? 'draft', readings: readings.map((r) => ({ at: r.created_at, value: r.measure.value, source: r.source })), track: still ? null : track, check: goalCheck(g), forecast: forecast && still ? { ...forecast, eta: null, past_horizon: false } : forecast, hold_view: holdView(g) }; } function cmdGoal() { const [sub, id] = positional; @@ -3601,7 +3845,17 @@ function cmdGoal() { const g = logGet(id); if (g?.type !== 'goal') die(`no goal ${id}`); const verdict = sub === 'approve' ? 'approve' : positional[2]; - if (!['approve', 'refine', 'let-go'].includes(verdict)) die('usage: goal decide approve|refine|let-go [--notes "…"] [--question q-…]'); + if (!['approve', 'refine', 'let-go', 'restore'].includes(verdict)) die('usage: goal decide approve|refine|let-go|restore [--notes "…"] [--question q-…]'); + // Restore undoes a let go: the goal gets back the status it had, parked, and its questions return. + if (verdict === 'restore') { + if (g.status !== 'abandoned') die(`${id} is ${g.status ?? 'draft'}, not let go; nothing to restore`); + const statuses = [{ at: g.created_at, status: 'draft' }, ...g.updates.filter((u) => u.status !== undefined)]; + const lastGo = statuses.map((u) => u.status).lastIndexOf('abandoned'); + const prior = statuses.slice(0, lastGo).map((u) => u.status).filter((x) => x !== 'abandoned').pop() ?? 'draft'; + appendLog({ type: 'goal', op: 'set', id, status: prior, decision: { verdict: 'restore', notes: flagStr('notes')?.trim() || undefined }, hold: { mode: 'park', reason: 'restored from let go' } }, by); + nudgeSupervisor(`[devteam goal restore ${id} from owner] it is back as ${prior}, parked; its questions are in the inbox again`); + return out(`${id}: restored as ${prior}, parked (goal hold ${id} run --owner to run it)`); + } const notes = flagStr('notes')?.trim() || undefined; if (verdict === 'refine' && !notes) die('refine needs --notes: what the planner should change'); const ask = flagStr('question') ? questions().find((q) => q.id === flagStr('question')) : questions().find((q) => q.kind === 'goal-approval' && q.goal === id && q.status === 'open'); @@ -3614,8 +3868,45 @@ function cmdGoal() { const status = { approve: 'ready', refine: undefined, 'let-go': 'abandoned' }[verdict]; appendLog({ type: 'goal', op: 'set', id, status, decision: { verdict, notes, question: ask?.id } }, by); if (ask?.status === 'open') appendLog({ type: 'question', op: 'set', id: ask.id, answer: `${{ approve: 'Approve', refine: 'Refine', 'let-go': 'Let go' }[verdict]}${notes ? ` — ${notes}` : ''}` }, by); - nudgeSupervisor(`[devteam goal ${verdict} ${id} from owner] read it with: devteam.mjs goal show ${id}`); - return out(`${id}: ${verdict}${status ? ` (${status})` : ''}`); + nudgeSupervisor(`[devteam goal ${verdict} ${id} from owner] read it with: devteam.mjs goal show ${id}${verdict === 'let-go' ? `; its sessions park: goal park-sessions ${id}` : ''}`); + return out(`${id}: ${verdict}${status ? ` (${status})` : ''}${verdict === 'let-go' ? '; its sessions park and its questions leave the inbox (goal decide restore undoes it)' : ''}`); + } + // The goal's toggle (planning.md §1, holds): run, drain or park. The owner sets any; the + // supervisor drains a running goal (with a reason) and releases only its own drain. + if (sub === 'hold') { + const g = goalArg(id) ?? die('usage: goal hold run|drain|park [--reason "…"] [--owner]'); + const mode = positional[2]; + if (!['run', 'drain', 'park'].includes(mode)) die('usage: goal hold run|drain|park [--reason "…"] [--owner]'); + const by = flags.owner ? ownerWriter().by : (supervisorOnly('goal hold'), 'supervisor'); + if (g.status === 'met') die(`${g.id} is met; there is no work to hold`); + if (g.status === 'abandoned') die(`${g.id} is let go: goal decide ${g.id} restore brings it back, parked`); + const why = holdRefusal({ mode }, g.hold ?? { mode: 'run' }, g.hold_by ?? null, by); + if (why) die(`goal hold: ${why}`); + const reason = flagStr('reason')?.trim(); + if (by === 'supervisor' && !reason) die('the supervisor says why: --reason "…"'); + const before = goalMode(g); + const line = writeHold(g, { mode, ...(reason ? { reason: reason.slice(0, 300) } : {}) }, by); + logCache = null; + const name = isUpkeep(g) ? 'upkeep' : g.id; + const now = goalArg(isUpkeep(g) ? 'upkeep' : g.id); + if (by === 'supervisor') appendLog({ type: 'act', class: 'goal-hold', what: `${mode === 'drain' ? 'drained' : 'released my drain on'} ${isUpkeep(g) ? 'Upkeep' : `"${g.title}"`}: ${reason}`, refs: [now.id], reversible: true }, 'supervisor'); + out(`${isUpkeep(g) ? 'Upkeep' : g.id} is ${holdWords(mode)} (${line.id})`); + if (mode === 'park') { + if (by === 'supervisor') { const sent = sendParkRulings({ goal: now }); out(sent.length ? `told ${sent.join(', ')} to commit, push, write a handoff and park` : 'no running session of it to tell'); } + else nudgeSupervisor(`[devteam goal park ${name} from owner] send its sessions the park ruling: goal park-sessions ${name}`); + } + if (['park', 'let-go'].includes(before) && mode !== 'park') { + const rows = restartCommands((x) => sessionGoals(x.name).some((y) => y.id === now.id) || (isUpkeep(now) && !sessionGoals(x.name).length)); + if (rows.length) out(`restart its parked sessions, as the supervisor:\n${rows.map((x) => ` devteam.mjs ${x.cmd}`).join('\n')}`); + if (by !== 'supervisor') nudgeSupervisor(`[devteam goal ${mode} ${name} from owner] ${rows.length ? `restart: ${rows.map((x) => x.cmd).join('; ')}` : 'nothing parked to restart'}`); + } else if (by !== 'supervisor' && mode !== 'park') nudgeSupervisor(`[devteam goal ${mode} ${name} from owner] ${mode === 'drain' ? 'start nothing new for it; let what runs finish' : 'it runs again'}`); + return; + } + if (sub === 'park-sessions') { + supervisorOnly('goal park-sessions'); + const g = flags.all ? null : goalArg(id) ?? die('usage: goal park-sessions | --all [--again]'); + const sent = sendParkRulings({ goal: g, again: !!flags.again }); + return out(sent.length ? `told ${sent.join(', ')} to commit, push, write a handoff and park` : 'every session it holds has been told (--again re-sends to those not yet parked)'); } if (sub === 'status') { const { by } = ownerWriter(); @@ -3633,7 +3924,7 @@ function cmdGoal() { if (flags.json) return json(f); return out(f ? forecastText(g, f) : `${id} has no milestones yet`); } - if (sub === 'show') return json(goalView(logGet(id)?.type === 'goal' ? logGet(id) : die(`no goal ${id}`))); + if (sub === 'show') { const g = goalArg(id) ?? die(`no goal ${id}`); return json(isUpkeep(g) ? { ...g, hold_view: holdView(g) } : goalView(g)); } if (sub === 'reading') { if (!id || logGet(id)?.type !== 'goal') die('usage: goal reading --value n [--source s] [--detail d]'); const by = flags.owner ? ownerWriter().by : (supervisorOnly('goal reading'), 'supervisor'); @@ -3663,9 +3954,11 @@ function cmdGoal() { const gs = logObjects('goal').map(goalView).sort((a, b) => (a.priority ?? 99) - (b.priority ?? 99)); if (flags.json) return json(gs); if (!gs.length) return out('no goals (the owner states them; planning.md §1)'); + const u = holdView(upkeepOrVirtual()); + out(`upkeep [${u.state}${u.reason ? `: ${u.reason}` : ''}] Upkeep: chores, CI fixes, retros, kit syncs`); for (const g of gs) { const last = g.readings[g.readings.length - 1]; - out(`${g.id} [${g.status}${g.priority ? `, p${g.priority}` : ''}] ${g.title}\n ${g.measure?.description ?? '-'}: ${last ? last.value : '?'} -> ${g.measure?.target ?? '?'} ${g.measure?.unit ?? ''} by ${g.horizon ?? '?'}${g.track ? ` (${g.track})` : ''}; features: ${g.check.features.join(', ') || 'none'}`); + out(`${g.id} [${g.status}${g.priority ? `, p${g.priority}` : ''}${g.hold_view.mode !== 'run' ? `, ${g.hold_view.state}` : ''}] ${g.title}\n ${g.measure?.description ?? '-'}: ${last ? last.value : '?'} -> ${g.measure?.target ?? '?'} ${g.measure?.unit ?? ''} by ${g.horizon ?? '?'}${g.track ? ` (${g.track})` : ''}; features: ${g.check.features.join(', ') || 'none'}`); } } @@ -3683,7 +3976,8 @@ function collectRoadmap() { const cycleDays = m.cycle_hours.median ? m.cycle_hours.median / 24 : null; const openQs = questions().filter((q) => q.status === 'open'); const goals = logObjects('goal').filter((g) => !['met', 'abandoned'].includes(g.status)).sort((a, b) => (a.priority ?? 99) - (b.priority ?? 99)); - const rankOf = new Map(goals.map((g, i) => [g.id, i])); + // A parked goal's work stands still, so it stays out of the queue the others' dates come from. + const rankOf = new Map(goals.filter((g) => goalMode(g) !== 'park').map((g, i) => [g.id, i])); const fs_ = features(); const views = [...new Set([...items.map((t) => t.feature), ...fs_.keys()])].map((fid) => { const f = fs_.get(fid); @@ -3716,8 +4010,9 @@ function collectRoadmap() { const etas = feats.map((v) => v.eta).filter(Boolean).sort(); // With milestones, the goal's date is the forecast's: later milestones count by their estimate. const fc = goalForecast(g, base); - const eta = fc ? (fc.milestones.every((m) => m.status === 'met') ? 'done' : fc.eta) : c.unplanned.length || !feats.length ? null : feats.every((v) => v.done) ? 'done' : etas.pop() ?? null; - return { id: g.id, title: g.title, priority: g.priority ?? null, status: g.status ?? 'draft', unplanned: c.unplanned.filter((u) => !c.later.includes(u.n)), later: c.later, current_milestone: c.current_milestone, milestones: fc?.milestones ?? [], past_horizon: fc?.past_horizon ?? false, features: feats.map((v) => v.feature), eta, has_design: !c.coverage.some((x) => x.startsWith('no design')) }; + const parked = goalMode(g) === 'park'; + const eta = parked ? null : fc ? (fc.milestones.every((m) => m.status === 'met') ? 'done' : fc.eta) : c.unplanned.length || !feats.length ? null : feats.every((v) => v.done) ? 'done' : etas.pop() ?? null; + return { id: g.id, title: g.title, priority: g.priority ?? null, status: g.status ?? 'draft', hold: goalMode(g), unplanned: c.unplanned.filter((u) => !c.later.includes(u.n)), later: c.later, current_milestone: c.current_milestone, milestones: fc?.milestones ?? [], past_horizon: fc?.past_horizon ?? false, features: feats.map((v) => v.feature), eta, has_design: !c.coverage.some((x) => x.startsWith('no design')) }; }); return { generated_at: nowIso(), rate_points_per_day: +rate.toFixed(2), median_cycle_days: cycleDays === null ? null : +cycleDays.toFixed(1), @@ -3729,7 +4024,7 @@ const weekOf = (d) => { if (!d) return 'no date'; const x = new Date(`${d}T00:00 function roadmapText(r) { const L = [`rate ${r.rate_points_per_day} points/day; median cycle ${r.median_cycle_days ?? '-'} days`]; for (const g of r.goals) { - L.push('', `${g.priority ? `p${g.priority} ` : ''}${g.title} [${g.status}] -> ${g.eta === 'done' ? 'done' : weekOf(g.eta)}${g.past_horizon ? ' (after its horizon)' : ''}${g.has_design ? '' : ' (no design)'}`); + L.push('', `${g.priority ? `p${g.priority} ` : ''}${g.title} [${g.status}${g.hold !== 'run' ? `, ${holdWords(g.hold)}` : ''}] -> ${g.hold === 'park' ? 'no date while parked' : g.eta === 'done' ? 'done' : weekOf(g.eta)}${g.past_horizon ? ' (after its horizon)' : ''}${g.has_design ? '' : ' (no design)'}`); for (const m of g.milestones) L.push(` milestone ${m.n} [${m.status}] ${m.title} -> ${m.eta === 'met' ? 'met' : weekOf(m.eta)}${m.features.length ? '' : ` (about ${m.estimate_tickets} tickets, unplanned)`}${m.late ? ' (after its date)' : ''}`); for (const u of g.unplanned) L.push(` unplanned: precondition ${u.n}: ${u.text}`); for (const f of g.features) { @@ -3784,9 +4079,27 @@ function openPr(wt, branch, title, bodyText) { if (!pr) pr = sh('gh', ['pr', 'create', '--head', branch, '--base', main, '--title', title, '--body', `${bodyText}\n\n## One-way doors\n\nNone.\n`], { cwd: wt }) ?? ''; return pr; } +// The improvement loop is Upkeep's work: while Upkeep is drained or parked it starts nothing new +// (a trial, a proposal, a trust question, a proposed lesson, a retro); --force does, logged by +// the act or line itself. Observing (metrics, findings, readings) and the record never stop. +function upkeepAllows(what) { + const m = goalMode(upkeepOrVirtual()); + if (m === 'run' || flags.force) return; + die(`Upkeep is ${holdWords(m)}: ${what} waits until it runs (goal hold upkeep run --owner), or --force`); +} +// Chores that keep the record rather than do upkeep: never held. +const isRecordKeeping = (slug) => ['log', 'fleet-handoff', 'drain-handoff'].includes(slug) || /^secret-/.test(slug ?? ''); function cmdChore() { const [sub, slugName] = positional; - if (sub === 'start') { if (!slugName) die('usage: chore start '); supervisorOnly('chore'); return out(choreWorktree(slugName, { setup: true })); } + if (sub === 'start') { + if (!slugName) die('usage: chore start '); + supervisorOnly('chore'); + // Upkeep work starts only while Upkeep runs; keeping the record (the log, the fleet handoff, + // posted secrets) never waits. + const um = goalMode(upkeepOrVirtual()); + if (um !== 'run' && !isRecordKeeping(choreSlug(slugName)) && !flags.force) die(`Upkeep is ${holdWords(um)}: no new upkeep chore starts (goal hold upkeep run --owner, or --force, logged)`); + return out(choreWorktree(slugName, { setup: true })); + } if (sub !== 'pr' || !slugName || !flagStr('title')) die('usage: chore start | chore pr --title ""'); supervisorOnly('chore'); choreSlug(slugName); @@ -3890,6 +4203,7 @@ function cmdRetro() { process.exit(d.due ? 0 : 1); } supervisorOnly('retro'); + if (sub !== 'close' && !flags.print) upkeepAllows('a retro'); if (sub === 'close') { const date = arg ?? die('usage: retro close --summary "" [--refs ids]'); const rel = `retros/${date}.md`; @@ -4271,9 +4585,10 @@ function collectMetrics(since) { const msgWaits = expecting.filter((m) => replies.has(m.id)).map((m) => replies.get(m.id) - Date.parse(m.created_at)); const qs = questions().filter((q) => Date.parse(q.asked_at) >= since); const qWaits = qs.filter((q) => q.answered_at && q.status === 'answered').map((q) => Date.parse(q.answered_at) - Date.parse(q.asked_at)); - const openQs = qs.filter((q) => q.status === 'open'); + // a held goal's open questions wait with it: not counted as the owner falling behind + const openQs = qs.filter((q) => q.status === 'open' && !q.goal_hold); const grace = settings.question_default_after_hours * 3600000; - const recentQs = questions().filter((q) => now - Date.parse(q.asked_at) <= 14 * DAY && q.status !== 'withdrawn' && (q.status !== 'open' || now - Date.parse(q.asked_at) > grace)); + const recentQs = questions().filter((q) => now - Date.parse(q.asked_at) <= 14 * DAY && q.status !== 'withdrawn' && !(q.status === 'open' && q.goal_hold) && (q.status !== 'open' || now - Date.parse(q.asked_at) > grace)); const answeredRecent = recentQs.filter((q) => q.status === 'answered').length; const reviews = reviewStats(); @@ -4369,8 +4684,10 @@ function collectMetrics(since) { if (recentQs.length >= 4 && answeredRecent / recentQs.length < 0.5) add('high', 'inbox-stalled', 'owner questions', `${answeredRecent} of ${recentQs.length} answered in 14 days`, 'remind the owner what is blocked; give defaults where the class allows; withdraw what no longer matters'); // churn is a signal to re-plan a ticket still in flight; a merged ticket's passes are retro material for (const [id, n] of Object.entries(reviewPasses()).filter(([id2, n]) => n >= 3 && !mergeShaFor(id2))) add('medium', 'review-churn', id, `${n} final review passes`, 'the ticket may be too big or under-specified; re-plan it'); + // An old fleet drain in force starts nothing; goal holds start nothing for their goals. const drained = !!draining(); - const ready = [...ts.values()].filter((t) => isActive(t) && !isHeld(t) && !mergeShaFor(t.id) && !activeWorker(t.id) && (t.depends_on ?? []).every((d) => mergeShaFor(d)) && !notSchedulable(t)); + const ready = [...ts.values()].filter((t) => isActive(t) && !isHeld(t) && !mergeShaFor(t.id) && !activeWorker(t.id) && (t.depends_on ?? []).every((d) => mergeShaFor(d)) && !notSchedulable(t) && ticketMode(t) === 'run'); + holdFindings(add, settings); const active = [...ts.keys()].filter(activeWorker).length; // Goals that need planning with nobody planning them: a fully defined draft goal with no design up // for approval, or a ready goal whose current milestone has unplanned preconditions, and no @@ -4382,7 +4699,7 @@ function collectMetrics(since) { // (a goal let go, or one that doesn't exist, holds nothing back) const blockedBy = (g) => (g.plan_after ?? []).filter((d) => logGet(d.goal)?.type === 'goal' && (logGet(d.goal).status ?? 'draft') === 'draft'); const waiting = logObjects('goal').filter((g) => { - if (busy.has(g.id) || ['met', 'abandoned'].includes(g.status) || blockedBy(g).length) return false; + if (busy.has(g.id) || ['met', 'abandoned'].includes(g.status) || blockedBy(g).length || goalMode(g) !== 'run') return false; const c = goalCheck(g); if ((g.status ?? 'draft') === 'draft') return !c.definition.length && !questions().some((q) => q.goal === g.id && q.status === 'open'); return g.status === 'ready' && c.coverage.some((x) => x.includes('has no feature') || x.startsWith('no feature names')); @@ -4528,12 +4845,15 @@ function dashboardData() { gates_configured: Object.keys(settings.gates ?? {}), tickets: deriveStatus(), critical_path: findCycle(sub) ? [] : criticalPath(sub).path, - questions: rankQuestions(questions().filter((q) => q.status === 'open')), + questions: rankQuestions(questions().filter((q) => q.status === 'open' && q.goal_hold !== 'let-go')), open_messages: msgs.filter((m) => !['info', 'ruling', 'answer'].includes(m.kind) && !replied.has(m.id) && !settledByLanding(m)).map((m) => ({ id: m.id, kind: m.kind, actor: m.created_by, to: m.to, summary: m.text })), metrics: collectMetrics(0), progress: collectProgress(), planners: planners(), ooda: collectOoda(), + // holds: Upkeep's, and the fleet's mode when every goal is held (fleet status gives readiness) + upkeep: (() => { const u = upkeepOrVirtual(); const chores = deriveStatus().filter((r) => r.feature === CHORES && !r.merge_sha && r.stage !== 'superseded'); return { id: upkeepGoal()?.id ?? null, ...holdView(u), chores: chores.map((r) => ({ id: r.id, title: r.title, stage: r.stage, phase: r.phase })) }; })(), + fleet: { mode: draining() ? 'park' : fleetMode(), op: fleetOpInForce()?.fleet ?? draining()?.id ?? null, reason: fleetOpInForce()?.reason ?? (draining() ? drainReason(draining()) : null) }, }; } const cmdDashboardData = () => { gitReadCache = new Map(); out(JSON.stringify(dashboardData())); }; @@ -4599,7 +4919,9 @@ function cmdDashboard() { return args; }, correct: (b) => (ID.test(b.id ?? '') && text(b.reason, 600) ? ['correct', b.id, '--reason', b.reason.trim()] : null), - 'goal-decide': (b) => (ID.test(b.id ?? '') && ['approve', 'refine', 'let-go'].includes(b.verdict) && (b.notes === undefined || text(b.notes, 4000)) ? ['goal', 'decide', b.id, b.verdict, ...(b.notes ? ['--notes', b.notes.trim()] : [])] : null), + 'goal-hold': (b) => ((ID.test(b.id ?? '') || b.id === 'upkeep') && ['run', 'drain', 'park'].includes(b.mode) && (b.reason === undefined || b.reason === '' || text(b.reason, 300)) ? ['goal', 'hold', b.id, b.mode, '--owner', ...(b.reason ? ['--reason', b.reason.trim()] : [])] : null), + fleet: (b) => (['drain', 'park', 'undrain', 'unpark', 'resume'].includes(b.op) && (b.reason === undefined || b.reason === '' || text(b.reason, 300)) ? ['fleet', b.op, '--owner', ...(b.reason && ['drain', 'park'].includes(b.op) ? ['--reason', b.reason.trim()] : [])] : null), + 'goal-decide': (b) => (ID.test(b.id ?? '') && ['approve', 'refine', 'let-go', 'restore'].includes(b.verdict) && (b.notes === undefined || text(b.notes, 4000)) ? ['goal', 'decide', b.id, b.verdict, ...(b.notes ? ['--notes', b.notes.trim()] : [])] : null), knowledge: (b) => (ID.test(b.id ?? '') && ['accepted', 'retired', 'stale'].includes(b.status) ? ['knowledge', 'set', b.id, '--status', b.status, '--owner'] : null), ack: (b) => (/^[a-z][a-z-]*$/.test(b.kind ?? '') && text(b.subject, 200) && text(b.reason, 600) ? ['ack', b.kind, b.subject, '--reason', b.reason.trim(), '--owner'] : null), }; @@ -4622,6 +4944,11 @@ function cmdDashboard() { return res.end(r.stdout); } const u = new URL(req.url, 'http://x'); + // Whether the fleet can move now: fleet status, without fetching (the strip at the top asks). + if (req.method === 'GET' && u.pathname === '/api/fleet') { + const r = await run(['fleet', 'status', '--json', ...(u.searchParams.get('fetch') === '1' ? [] : ['--no-fetch'])]); + return r.ok ? send(res, 200, 'application/json', r.stdout) : send(res, 500, 'application/json', JSON.stringify({ error: r.stderr.trim() })); + } if (req.method === 'GET' && u.pathname === '/api/timeline') { const id = u.searchParams.get('id') ?? ''; if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) return send(res, 400, 'application/json', JSON.stringify({ error: 'bad ticket id' })); @@ -5021,13 +5348,27 @@ function makeSession(role) { supervisorOnly(role); const id = positional[0] ?? die(`usage: ${role} <${role === 'worker' ? 'ticket-id' : 'feature'}> [--resume] [--force]`); if (repoRoot !== mainRoot) die(`run this from the main checkout (${mainRoot})`); - // While the fleet drains for a move, no session starts here; --force does, and its act says so. - const drain = draining(); - if (drain && !flags.force) die(`the fleet is draining (${drain.id}, ${drain.created_at}): no new sessions start. On the machine it moves to, run undrain first (supervisor.md, Moving the fleet); to start this one anyway, add --force (it is logged)`); + if (role === 'worker' && !tickets().has(id)) die(`no ticket ${id} (devteam.mjs ticket show ${id})`); + // Every session serves a goal, and the goal's hold says whether it may start (planning.md §1): + // drain lets a session that already ran carry on but starts nothing new; park and let-go start + // nothing. --force does anyway, and its act says so. A planner names its goal until its feature + // line or a question does. + const goalOpt = flagStr('goal'); + if (goalOpt && (role !== 'planner' || logGet(goalOpt)?.type !== 'goal')) die(role === 'planner' ? `--goal ${goalOpt}: no such goal` : '--goal is for a planner'); + let goalIds = []; + if (role === 'planner') { + goalIds = planners().find((p) => p.feature === id)?.goals ?? plannerLaunchGoals(id); + if (!goalIds.length && goalOpt) goalIds = [goalOpt]; + if (!goalIds.length && !resolveCommit(`refs/heads/plan/${id}`)) die(`planner ${id} --goal : which goal it plans (needed until its feature line or a question names one)`); + } + const legacyDrain = draining(); + const mode = legacyDrain ? 'park' : role === 'worker' ? ticketMode(tickets().get(id)) : goalIds.length ? modeOf(goalIds.map((g) => logGet(g))) : fleetMode() ?? 'run'; + const started = role === 'worker' ? !!lastPhaseOf(id) : !!resolveCommit(`refs/heads/plan/${id}`); + const refused = ['park', 'let-go'].includes(mode) || (mode === 'drain' && !started); + if (refused && !flags.force) die(legacyDrain ? `the fleet is drained by an old drain (${legacyDrain.id}): fleet unpark first (supervisor.md §12); to start this one anyway, add --force (it is logged)` : `its goal is ${holdWords(mode)}: ${mode === 'drain' ? 'a draining goal finishes what is running and starts nothing new' : 'nothing starts for it'} (planning.md §1, holds). To start it anyway, add --force (it is logged)`); if (!sh('tmux', ['-V'])) die('tmux not found'); const s = loadSettings(); let resume = !!flags.resume; - if (role === 'worker' && !tickets().has(id)) die(`no ticket ${id} (devteam.mjs ticket show ${id})`); const blockSize = flagNum('id-block') ?? ID_BLOCK_DEFAULT; if (role === 'planner' && !(Number.isInteger(blockSize) && blockSize >= 1)) die('--id-block takes a whole number of ticket numbers to reserve (at least 1)'); const name = role === 'worker' ? id : `plan-${id}`; @@ -5082,13 +5423,13 @@ function makeSession(role) { } typeInto(target, `${cmd} "$(cat '${promptFile}')"`); if (role === 'worker' && !resume) appendLog({ type: 'phase', from: null, to: 'starting', note: `started in ${target}` }, id); - // a planner parked by a drain is running again + // a planner parked by a park is running again if (role === 'planner' && lastPhaseOf(name)?.to === 'parked') appendLog({ type: 'phase', from: 'parked', to: 'starting', note: `started in ${target}` }, name); - appendLog({ type: 'act', class: 'worker-slots', what: `${resume ? 'resumed' : 'made'} ${role} ${name} in ${target}${block ? `, ticket numbers ${padNN(block.from)}-${padNN(block.to)}` : ''}${drain ? ` while the fleet is draining (${drain.id}), with --force` : ''}`, refs: role === 'worker' ? [id] : [], reversible: true, id_block: block ?? undefined }, 'supervisor'); + appendLog({ type: 'act', class: 'worker-slots', what: `${resume ? 'resumed' : 'made'} ${role} ${name} in ${target}${block ? `, ticket numbers ${padNN(block.from)}-${padNN(block.to)}` : ''}${refused ? ` while its goal is ${holdWords(mode)}, with --force` : ''}`, refs: role === 'worker' ? [id] : goalIds, reversible: true, id_block: block ?? undefined }, 'supervisor'); out(`${resume ? 'resumed' : 'made'} ${role} ${name} in ${target} at ${wt}${block ? `; ticket numbers ${padNN(block.from)}-${padNN(block.to)} reserved` : ''}`); } -// --- moving the fleet: drain, drain --status, undrain (supervisor.md, Moving the fleet) --- +// --- moving the fleet: fleet park | drain, fleet status, fleet unpark | undrain (supervisor.md §12) --- // // What git doesn't carry stays on this machine: run/ (the live log, review results and drafts, // forge-git PR text, caches), each session's worktree and its harness session, worktree_files and @@ -5111,48 +5452,112 @@ function sessionOf(name) { return { name, role: planner ? 'planner' : 'worker', id, branch: planner ? `plan/${id}` : `ticket/${id}`, wt: path.join(mainRoot, '.worktrees', name), handoff: `${projectRel}/${planner ? `plans/${id}` : `tickets/${id}`}/handoff.md` }; } const isHandoffFile = (file) => !!file && new RegExp(`^${reReserved(projectRel)}/(tickets|plans)/[^/]+/handoff\\.md$`).test(file); -// The handoff note a session gets in its prompt, only while it is current: the session was parked -// by the latest drain and has logged no phase of its own since (a "starting" line is the launch's). -// After its first real phase line the note is history: it stays in its folder, never prompted again, -// until a later drain overwrites it. +// What parked a session at a time: an old fleet drain in force then, or the park or let-go of one +// of its goals (a fleet park is one on every goal) in force then. A session parked for another +// reason (a ruling about its ticket) has no cause, and so no handoff block. +function parkCauseAt(name, at) { + const t = Date.parse(at); + const legacy = drainActs().filter((a) => Date.parse(a.created_at) <= t).pop(); + if (legacy?.class === 'drain') return { kind: 'fleet', at: legacy.created_at, marker: `drain:${legacy.id}`, reason: drainReason(legacy), host: drainHost(legacy), legacy }; + const gs = sessionGoals(name); + const set = gs.length ? gs : holdableGoals(); + return set.map((g) => causeOf(g, t)).filter(Boolean).sort((a, b) => b.at.localeCompare(a.at))[0] ?? null; +} +// The handoff note a session gets in its prompt, only while it is current: the session's latest +// parked line had a park cause, and it has logged no phase of its own since (a "starting" line is +// the launch's). After its first real phase line the note is history: it stays in its folder, +// never prompted again, until a later park overwrites it. function handoffFor(name) { - const d = latestDrain(); - if (!d) return null; - // A planner logs no phases while it works, so any line of its own (a message, an observation) ends it. const planner = writerClass(name) === 'planner'; - const since = readLog().lines.filter((l) => l.by === name && l.at > d.created_at && (planner || l.type === 'phase')); + const mine = readLog().lines.filter((l) => l.by === name).sort((x, y) => x.at.localeCompare(y.at)); + const parked = mine.filter((l) => l.type === 'phase' && l.to === 'parked').pop(); + if (!parked) return null; + // A planner logs no phases while it works, so any line of its own (a message, an observation) ends it. const launch = (l) => l.type === 'phase' && ['parked', 'starting'].includes(l.to); - if (!since.some((l) => l.type === 'phase' && l.to === 'parked') || since.some((l) => !launch(l))) return null; + if (mine.some((l) => l.at > parked.at && (planner || l.type === 'phase') && !launch(l))) return null; + const cause = parkCauseAt(name, parked.at); + if (!cause) return null; const s = sessionOf(name); + for (const r of [`refs/heads/${s.branch}`, `refs/remotes/origin/${s.branch}`]) { + const c = handoffCommit(r, name); + if (c) return { ...s, text: c.body, where: `the handoff commit ${c.sha.slice(0, 12)} on ${s.branch}, ${c.at}`, cause, parked_at: parked.at }; + } + // the note used to be a file: read one a session parked by an old drain left const local = readText(path.join(s.wt, s.handoff)); - if (local !== null) return { ...s, text: local, where: `${s.handoff} in its worktree`, drain: d }; + if (local !== null) return { ...s, text: local, where: `${s.handoff} in its worktree`, cause, parked_at: parked.at }; for (const r of [`refs/heads/${s.branch}`, `refs/remotes/origin/${s.branch}`]) { if (!resolveCommit(r)) continue; const text = git(['show', `${r}:${s.handoff}`]); - if (text !== null) return { ...s, text, where: `${s.handoff} on ${s.branch}, committed ${git(['log', '-1', '--format=%cI', r, '--', s.handoff]) ?? '?'}`, drain: d }; + if (text !== null) return { ...s, text, where: `${s.handoff} on ${s.branch}, committed ${git(['log', '-1', '--format=%cI', r, '--', s.handoff]) ?? '?'}`, cause, parked_at: parked.at }; } return null; } +// A parked session's handoff is an empty commit on its own branch, titled "devteam handoff: ", +// with the note as its body: nothing extra lands in the repo, and it travels with the branch. +const HANDOFF_SUBJECT = (name) => `devteam handoff: ${name}`; +function handoffCommit(ref, name) { + if (!resolveCommit(ref)) return null; + const raw = git(['log', '-1', '--format=%H%x00%cI%x00%B', `--grep=^${HANDOFF_SUBJECT(name).replace(/[.[\]*^$]/g, '\\$&')}$`, ref]); + if (!raw) return null; + const [sha, at, msg] = raw.split('\0'); + return { sha, at, body: msg.split('\n').slice(1).join('\n').trim() }; +} +// The supervisor's handoff is an annotated tag on origin, devteam-handoff/, with the note +// as its message: no file, no PR. An older move's file on main is still read. +const SUPERVISOR_TAGS = 'refs/tags/devteam-handoff/'; +function supervisorHandoff(since) { + if (!flags['no-fetch'] && commonDir) git(['fetch', '--quiet', 'origin', `+${SUPERVISOR_TAGS}*:${SUPERVISOR_TAGS}*`]); + const rows = (git(['for-each-ref', '--sort=-taggerdate', '--format=%(refname:short)%00%(taggerdate:iso-strict)%00%(contents)%00%01', SUPERVISOR_TAGS]) ?? '').split('\x01').map((x) => x.replace(/^\n/, '')).filter(Boolean); + for (const r of rows) { + const [tag, at, text] = r.split('\0'); + if (!since || Date.parse(at) >= Date.parse(since) - 60000) return { text: (text ?? '').trim(), at, rel: tag }; + } + const legacy = supervisorHandoffOnMain(); + return legacy && (!since || Date.parse(legacy.at) >= Date.parse(since) - 60000) ? legacy : null; +} +// handoff [--file f] [--supervisor]: a worker or planner records its handoff note (stdin or a file) +// as an empty commit on its branch and pushes the branch; the supervisor records its own as an +// annotated tag on origin. +function cmdHandoff() { + const note = (flagStr('file') ? fs.readFileSync(flagStr('file'), 'utf8') : fs.readFileSync(0, 'utf8')).trim(); + if (!note) die('handoff: the note is empty (stdin or --file): what is done, the exact next step, open questions, anything not committed'); + if (flags.supervisor) { + supervisorOnly('handoff --supervisor'); + const op = fleetOpInForce()?.fleet ?? (draining() ? draining().id : null) ?? die('handoff --supervisor is for a fleet park or drain in force'); + git(['fetch', '--quiet', 'origin', loadSettings().main_branch]); + const tag = `devteam-handoff/${op}-${newFleetId().slice(2)}`; + const base = mainRef() ?? 'HEAD'; + withScratch((dir) => { + const f = path.join(dir, 'note'); + fs.writeFileSync(f, `Supervisor handoff (${op})\n\n${note}\n`); + gitOrDie(['tag', '-a', tag, '-F', f, base], mainRoot); + }); + gitOrDie(['push', '--quiet', 'origin', `refs/tags/${tag}`], mainRoot); + return out(`pushed ${tag} with your handoff; fleet status checks it, and fleet unpark prints it on the new machine`); + } + const role = process.env.DEVTEAM_ROLE; + if (!['worker', 'planner'].includes(role)) die('handoff: a worker or planner records its own (the supervisor adds --supervisor)'); + const name = role === 'worker' ? (process.env.DEVTEAM_TICKET ?? currentTicketId() ?? die('handoff: no ticket')) : `plan-${process.env.DEVTEAM_FEATURE ?? die('handoff: no feature')}`; + const sess = sessionOf(name); + if ((git(['status', '--porcelain']) ?? '').trim()) die('handoff: commit your work in progress first (a WIP commit is fine); the handoff commit holds only the note'); + const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']); + if (branch !== sess.branch) die(`handoff: you are on ${branch}, not ${sess.branch}`); + withScratch((dir) => { + const f = path.join(dir, 'msg'); + fs.writeFileSync(f, `${HANDOFF_SUBJECT(name)}\n\n${note}\n`); + gitOrDie(['commit', '--quiet', '--allow-empty', '-F', f]); + }); + gitOrDie(['push', '--quiet', 'origin', `HEAD:refs/heads/${sess.branch}`]); + out(`handoff committed on ${sess.branch} and pushed; now: devteam.mjs phase parked --note "parked", and stop`); +} +const causeWords = (c) => (c.kind === 'fleet' ? `the fleet was parked${c.host ? ` on ${c.host}` : ''}${c.reason ? ` (${c.reason})` : ''}` : c.kind === 'let-go' ? `the owner let go of "${c.title}"` : `"${c.title}" was parked${c.reason ? ` (${c.reason})` : ''}`); const HANDOFF_MAX = 12000; function handoffBlock(name) { const h = handoffFor(name); if (!h) return null; const body = h.text.length > HANDOFF_MAX ? `${h.text.slice(0, HANDOFF_MAX)}\n\n(cut at ${HANDOFF_MAX} characters; read the whole file)` : h.text; - return `## Handoff from a drained session\n\nThe fleet was drained (${h.drain.id}, ${h.drain.created_at}${drainHost(h.drain) ? ` on ${drainHost(h.drain)}` : ''}) and this session starts without the earlier one's context. Before anything else in ${h.role === 'worker' ? 'worker.md §2' : 'planning.md'}, read this note, left by the session that stopped (${h.where}); then check it against your branch, your files and the log. Keep the file: it is part of the record and lands with your PR. Between the lines below is the note itself, as that session wrote it.\n\n-----\n${body.trimEnd()}\n-----`; + return `## Handoff from a parked session\n\nThis session was parked (${causeWords(h.cause)}, ${h.cause.at}) and starts without the earlier one's context. Before anything else in ${h.role === 'worker' ? 'worker.md §2' : 'planning.md'}, read this note, left by the session that stopped (${h.where}); then check it against your branch, your files and the log. Keep the file: it is part of the record and lands with your PR. Between the lines below is the note itself, as that session wrote it.\n\n-----\n${body.trimEnd()}\n-----`; } -// What a stopping session must do: the same message for every worker and planner. -function drainMessage(s, reason) { - return `Drain: the fleet is stopping${reason ? ` (${reason.slice(0, 120)})` : ''} to move to another machine. Now: commit all work in progress (a WIP commit is fine) and push ${s.branch}; write ${s.handoff} with what is done, the exact next step, open questions and anything you could not commit; commit and push it; then devteam.mjs phase parked --note "drained", and stop (worker.md §9).`; -} -// The sessions a drain is about: everyone it messaged, and anyone running now, less what landed. -function fleetSessions(d = draining()) { - const names = new Set(d?.refs ?? []); - for (const t of tickets().keys()) if (activeWorker(t)) names.add(t); - const inFlight = new Set(planners().map((p) => `plan-${p.feature}`)); - for (const n of inFlight) names.add(n); - return [...names].sort().map(sessionOf).filter((s) => (s.role === 'worker' ? tickets().has(s.id) && !mergeShaFor(s.id) : inFlight.has(s.name))); -} - // The supervisor's own handoff: its context doesn't move either, so it writes one too, and it lands // on main like any project file (chore drain-handoff), so the next supervisor reads it from git. const SUPERVISOR_HANDOFF = 'handoffs/supervisor.md'; @@ -5187,55 +5592,109 @@ function harnessMemoryFiles() { // the undrain itself are the two lines only one side has, so neither counts. function parityPrint() { logCache = null; - const lines = readLog().lines.filter((l) => !(l.type === 'act' && (l.class === 'undrain' || (l.class === 'merge-chore' && /^merged chore-log at /.test(l.what ?? ''))))); + const lines = readLog().lines.filter((l) => !(l.type === 'act' && (['undrain', 'fleet-unpark', 'fleet-undrain'].includes(l.class) || (l.class === 'merge-chore' && /^merged chore-log at /.test(l.what ?? '')))) && !(l.type === 'goal' && l.hold?.undoes)); const ids = lines.map((l) => `${l.id}\t${JSON.stringify(l)}`).sort(); return { hash: sha256(Buffer.from(ids.join('\n'))).slice(0, 16), lines: ids.length }; } +// drain and undrain are the old names. drain used to park the fleet; now fleet park does that and +// fleet drain lets in-flight work finish. undrain still lifts an old drain still in force. function cmdDrain() { - if (flags.status) return drainStatus(); - supervisorOnly('drain'); - const reason = flagStr('reason') ?? 'moving the fleet to another machine'; - const cur = draining(); - const sessions = fleetSessions(cur).filter((s) => !cur || lastPhaseOf(s.name)?.to !== 'parked'); - let act = cur; - if (!cur) act = appendLog({ type: 'act', class: 'drain', what: `drained the fleet on ${os.hostname()}: ${reason}`, refs: sessions.map((s) => s.name), reversible: true }, 'supervisor'); - else out(`already draining since ${cur.created_at} (${cur.id}); messaging the sessions not yet parked again`); - for (const s of sessions) sendLine({ to: s.name, kind: 'ruling', text: drainMessage(s, drainReason(act)), from: 'supervisor', ticket: s.role === 'worker' ? s.id : null }); - out(`${cur ? 'still draining' : `drained (${act.id})`}: ${sessions.length ? `told ${sessions.map((s) => s.name).join(', ')} to push, write a handoff and park` : 'no worker or planner to tell'}; no worker or planner starts until undrain`); - out(`next: once every session is parked, write your own handoff, ${projectRel}/${SUPERVISOR_HANDOFF} (what you were in the middle of, what you are waiting on, what you told the owner you would do, and anything from your agent's own memory the next supervisor needs: owner rules belong in knowledge), and land it: chore start drain-handoff, then chore pr drain-handoff and merge --chore drain-handoff`); - out('then: drain --status until it says "ready to move" (it lists what is left, and the new machine\'s checklist)'); + if (flags.status) return fleetStatus(); + die('drain has split in two (supervisor.md §12): fleet park stops every session with a WIP commit, a push and a handoff note (what drain used to do; use it to move machines), and fleet drain lets in-flight work finish while nothing new starts. Run the one you mean'); +} +function cmdUndrain() { + if (!draining()) die('undrain lifts an old fleet drain; for a fleet op use fleet unpark or fleet undrain'); + return fleetUndo('park'); +} +const fleetWriter = () => (flags.owner ? ownerWriter().by : (supervisorOnly('fleet'), 'supervisor')); +const fleetAt = (op) => (op ? `${op.slice(2, 6)}-${op.slice(6, 8)}-${op.slice(8, 10)}T${op.slice(11, 13)}:${op.slice(13, 15)}:${op.slice(15, 17)}.${op.slice(17, 20)}Z` : null); +// fleet drain | park | undrain | unpark | status +function cmdFleet() { + const sub = positional[0]; + if (sub === 'status') return fleetStatus(); + if (sub === 'drain' || sub === 'park') return fleetOp(sub); + if (sub === 'undrain') return fleetUndo('drain'); + if (sub === 'unpark') return fleetUndo('park'); + // resume: undo whatever fleet ops are in force, park first (a move), then drain. + if (sub === 'resume') { + const inForce = (m) => holdableGoals().some((g) => g.hold?.fleet && g.hold.mode === m); + if (!inForce('park') && !inForce('drain') && !draining()) return out('no fleet park or drain in force; nothing to resume'); + if (inForce('park') || draining()) { fleetUndo('park'); logCache = null; } + if (inForce('drain')) fleetUndo('drain'); + return; + } + die('usage: fleet drain | park [--reason "…"] | fleet undrain | unpark | resume | fleet status (add --owner when the owner runs it)'); +} +// Set every goal's hold at once (Upkeep included), recording what each was. A fleet drain touches +// only running goals and a fleet park only goals not already parked, so undoing it puts back +// exactly what it changed. +function fleetOp(mode) { + const by = fleetWriter(); + const reason = flagStr('reason') ?? (mode === 'park' ? 'moving the fleet to another machine' : 'finishing in-flight work, starting none'); + const op = newFleetId(); + const host = os.hostname(); + const changed = []; + for (const g of holdableGoals()) { + const m = goalMode(g); + if (mode === 'drain' ? m !== 'run' : m === 'park') continue; + const cur = g.hold ?? { mode: 'run' }; + const prior = { mode: cur.mode, ...(cur.reason ? { reason: cur.reason } : {}), ...(cur.fleet ? { fleet: cur.fleet } : {}) }; + writeHold(g, { mode, reason, fleet: op, host, prior }, by); + changed.push(isUpkeep(g) ? 'upkeep' : g.id); + } + if (!changed.length) return out(`every goal is already ${mode === 'park' ? 'parked' : 'draining or parked'}; nothing to do`); + if (by === 'supervisor') appendLog({ type: 'act', class: `fleet-${mode}`, what: `${mode === 'park' ? 'parked' : 'drained'} the fleet on ${host} (${op}): ${reason}`, refs: changed.filter((x) => x !== 'upkeep'), reversible: true }, 'supervisor'); + out(`${mode === 'park' ? 'parked' : 'draining'} the fleet (${op}): ${changed.length} goal(s) set to ${mode}, Upkeep included; nothing new starts until fleet un${mode}`); + if (mode === 'park') { + if (by === 'supervisor') { + const sent = sendParkRulings(); + out(sent.length ? `told ${sent.join(', ')} to commit, push, write a handoff and park` : 'no running worker or planner to tell'); + out("next: once every session is parked, write your own handoff (what you were in the middle of, what you are waiting on, what you told the owner you would do, and anything from your agent's own memory the next supervisor needs: owner rules belong in knowledge) and push it: devteam.mjs handoff --supervisor, the note on stdin"); + } else nudgeSupervisor(`[devteam fleet park ${op} from owner] send the park rulings (goal park-sessions --all), write your handoff and land it, then fleet status`); + } else { + out('in-flight work carries on and merges; fleet status says "ready to move" once nothing is running'); + if (by !== 'supervisor') nudgeSupervisor(`[devteam fleet drain ${op} from owner] start nothing new; let in-flight work finish and merge`); + } + out('then: fleet status until it says "ready to move" if you are moving machines (it lists what is left, and the new machine\'s checklist)'); } - // Everything the move needs, checked, then the new machine's checklist. Writes nothing. -function drainStatus() { +// A whole fleet parked or drained can move: parked sessions carry their work in git with a handoff +// note, and a drained fleet has nothing in flight at all. +function fleetStatus() { const s = loadSettings(); - const d = draining(); + const legacy = draining(); + const fm = legacy ? 'park' : fleetMode(); + const op = fleetOpInForce(); + const since = legacy ? legacy.created_at : op ? fleetAt(op.fleet) : null; + const d = since ? { id: legacy?.id ?? op?.fleet ?? 'holds', created_at: since } : null; if (!flags['no-fetch'] && commonDir) git(['fetch', '--quiet', 'origin']); mainRefCache = undefined; const main = mainRef(); const blockers = []; const warnings = []; - if (!d) blockers.push('the fleet is not drained: run drain first'); + if (!fm) blockers.push(`the fleet is neither parked nor drained (${holdableGoals().filter((g) => goalMode(g) === 'run').map((g) => (isUpkeep(g) ? 'Upkeep' : `"${g.title}"`)).join(', ')} still running): run fleet park or fleet drain first`); - const sessions = fleetSessions(d).map((x) => { + const sessions = liveSessions().map((x) => { const phase = lastPhaseOf(x.name); + const cause = phase?.to === 'parked' ? parkCauseAt(x.name, phase.created_at) : null; const local = resolveCommit(`refs/heads/${x.branch}`); const remote = resolveCommit(`refs/remotes/origin/${x.branch}`); const dirty = fs.existsSync(x.wt) ? (git(['status', '--porcelain'], x.wt) ?? '').split('\n').filter(Boolean) : []; - const handoffAt = remote && git(['cat-file', '-e', `${remote}:${x.handoff}`]) !== null ? git(['log', '-1', '--format=%cI', remote, '--', x.handoff]) : null; + const hc = remote ? handoffCommit(`refs/remotes/origin/${x.branch}`, x.name) : null; + const handoffAt = hc ? hc.at : remote && git(['cat-file', '-e', `${remote}:${x.handoff}`]) !== null ? git(['log', '-1', '--format=%cI', remote, '--', x.handoff]) : null; const r = { name: x.name, role: x.role, branch: x.branch, parked: phase?.to === 'parked', phase: phase?.to ?? null, pushed: !!remote && (!local || local === remote), local, remote, clean: !dirty.length, uncommitted: dirty.slice(0, 20), - handoff: !!handoffAt && (!d || Date.parse(handoffAt) >= Date.parse(d.created_at) - 60000), handoff_path: x.handoff, handoff_at: handoffAt, + handoff: !!handoffAt && (!cause || Date.parse(handoffAt) >= Date.parse(cause.at) - 60000), handoff_path: x.handoff, handoff_at: handoffAt, }; r.problems = [ - !r.parked && `not parked (phase ${r.phase ?? 'none'})`, + !r.parked && (fm === 'drain' ? `still running (phase ${r.phase ?? 'none'}): a drain finishes in-flight work; wait for it to merge, or fleet park to stop it now` : `not parked (phase ${r.phase ?? 'none'})`), !remote ? `${x.branch} is not on origin: push it` : !r.pushed && `${x.branch} here (${local.slice(0, 12)}) is not what origin has (${remote.slice(0, 12)}): push it`, !r.clean && `${dirty.length} uncommitted change(s) in its worktree`, - !r.handoff && (handoffAt ? 'its handoff note on origin is older than the drain' : `no ${x.handoff} on origin/${x.branch}`), + r.parked && !r.handoff && (handoffAt ? 'its handoff on origin is older than the park that stopped it' : `no handoff commit on origin/${x.branch} (devteam.mjs handoff in its worktree)`), ].filter(Boolean); for (const p of r.problems) blockers.push(`${x.name}: ${p}`); return r; @@ -5288,9 +5747,9 @@ function drainStatus() { // The supervisor's handoff, on main, written since the drain; and nothing the agents keep outside // the repo changed after it (what the next supervisor needs from it is folded into the handoff). - const sh0 = supervisorHandoffOnMain(); - const handoffOk = !!sh0 && !!d && Date.parse(sh0.at) >= Date.parse(d.created_at) - 60000; - if (d && !handoffOk) blockers.push(`${sh0 ? `${projectRel}/${SUPERVISOR_HANDOFF} on ${s.main_branch} is older than the drain` : `no ${projectRel}/${SUPERVISOR_HANDOFF} on ${s.main_branch}`}: write the supervisor's handoff and land it (chore start drain-handoff, chore pr drain-handoff, merge --chore drain-handoff)`); + const sh0 = d ? supervisorHandoff(d.created_at) : null; + const handoffOk = !!sh0; + if (d && !handoffOk) blockers.push(`no supervisor handoff since the fleet ${fm}: write it and push it (devteam.mjs handoff --supervisor, the note on stdin)`); const memory = harnessMemoryFiles(); const newer = handoffOk ? memory.filter((m) => m.mtime > Date.parse(sh0.at)) : []; if (newer.length) blockers.push(`agent memory outside the repo changed after the supervisor's handoff landed (${newer.map((m) => path.basename(m.path)).join(', ')}): fold what the next session needs into knowledge or the handoff, and land it again`); @@ -5333,11 +5792,11 @@ function drainStatus() { const checklist = moveChecklist(s, memory); const ready = !blockers.length; const parity = ready ? parityPrint() : null; - const r = { parity, supervisor_handoff: sh0 ? { at: sh0.at, current: handoffOk } : null, draining: d ? { id: d.id, at: d.created_at, host: drainHost(d), reason: drainReason(d) } : null, sessions, log: { landed: !unlanded.length, unlanded, left_behind: leftBehind }, spend: { unobserved: spendOthers, supervisor_usd: supUsd }, reviews: { uncommitted, drafts }, open_prs: prs ?? [], open_questions: openQs, blockers, warnings, ready, checklist }; + const r = { parity, supervisor_handoff: sh0 ? { at: sh0.at, current: handoffOk } : null, fleet: fm ? { mode: fm, op: op?.fleet ?? legacy?.id ?? null, at: since, host: legacy ? drainHost(legacy) : op?.host ?? null, reason: legacy ? drainReason(legacy) : op?.reason ?? null } : null, sessions, log: { landed: !unlanded.length, unlanded, left_behind: leftBehind }, spend: { unobserved: spendOthers, supervisor_usd: supUsd }, reviews: { uncommitted, drafts }, open_prs: prs ?? [], open_questions: openQs, blockers, warnings, ready, checklist }; if (flags.json) return json(r); - out(d ? `draining since ${d.created_at} (${d.id}${drainHost(d) ? `, on ${drainHost(d)}` : ''}): ${drainReason(d)}` : 'not draining'); + out(fm ? `${fm === 'park' ? 'parked' : 'draining'}${since ? ` since ${since}` : ''} (${r.fleet.op ?? 'every goal held one by one'}${r.fleet.host ? `, on ${r.fleet.host}` : ''})${r.fleet.reason ? `: ${r.fleet.reason}` : ''}` : 'neither parked nor drained'); out(`\nsessions:${sessions.length ? '' : ' none'}`); - for (const x of sessions) out(` ${x.name.padEnd(28)} ${x.parked ? 'parked' : `NOT PARKED (${x.phase ?? '-'})`} ${x.pushed ? 'pushed' : 'NOT PUSHED'} ${x.clean ? 'clean' : 'UNCOMMITTED'} ${x.handoff ? 'handoff' : 'NO HANDOFF'}`); + for (const x of sessions) out(` ${x.name.padEnd(28)} ${x.parked ? 'parked' : fm === 'drain' ? `STILL RUNNING (${x.phase ?? '-'})` : `NOT PARKED (${x.phase ?? '-'})`} ${x.pushed ? 'pushed' : 'NOT PUSHED'} ${x.clean ? 'clean' : 'UNCOMMITTED'} ${x.handoff ? 'handoff' : 'NO HANDOFF'}`); out(`\nlog: ${unlanded.length ? `not landed (${unlanded.map((u) => u.file).join(', ')}): run log-sync, then merge --chore log` : `on ${s.main_branch}`}${leftBehind ? " (the log's own merge act stays behind; git keeps the merge)" : ''}`); out(`spend: ${spendOthers.length ? `not yet observed for ${spendOthers.map((x) => `${x.who} $${x.usd}`).join(', ')}` : 'observed up to now'}${supUsd ? `; the supervisor's own $${supUsd} since its last observe-spend` : ''}`); out(`review results: ${uncommitted.length ? uncommitted.map((u) => `${u.id} not committed to ${u.ref}`).join('; ') : 'all in their PRs'}${drafts.length ? `; drafts that won't move: ${drafts.join(', ')}` : ''}`); @@ -5346,7 +5805,7 @@ function drainStatus() { if (blockers.length) { out('\nleft to do:'); for (const b of blockers) out(` - ${b}`); } if (warnings.length) { out('\nwarnings:'); for (const w of warnings) out(` - ${w}`); } out(`\nready to move: ${ready ? 'yes' : 'no'}`); - if (parity) out(`parity: ${parity.hash} (${parity.lines} log entries). undrain on the new machine prints its own; they match when the move is 1:1. Stop now: anything you log from here on stays on this machine.`); + if (parity) out(`parity: ${parity.hash} (${parity.lines} log entries). fleet un${fm} on the new machine prints its own before it writes anything; they match when the move is 1:1. Stop now: anything you log from here on stays on this machine.`); out('\nnew-machine checklist:'); checklist.forEach((c, i) => out(` ${i + 1}. ${c}`)); } @@ -5376,19 +5835,21 @@ function moveChecklist(s, memory = []) { ...(commands.size ? [`log in to the commands that take posted secrets: ${[...commands].join(', ')}`] : []), ...(s.forge === 'git' ? ['copy run/pr/ from the old checkout (forge git keeps the PR text there)'] : []), ...(memory.length ? [`your agent's own memory for this project (${memory.length} file(s) under ${path.dirname(path.dirname(memory[0].path))}) isn't in the repo; the supervisor folded what the next session needs into knowledge and its handoff, so nothing to copy`] : []), - `start the supervisor inside tmux exactly as the README's step 9 does: DEVTEAM_ROLE=supervisor ${agent} "$(node docs/devteam/scripts/devteam.mjs prompt supervisor)"; its prompt sees the drain`, - 'the supervisor runs undrain (it rebuilds run/log from the landed log) and restarts each drained session with the command it prints', + `start the supervisor inside tmux exactly as the README's step 9 does: DEVTEAM_ROLE=supervisor ${agent} "$(node docs/devteam/scripts/devteam.mjs prompt supervisor)"; its prompt sees the parked fleet`, + 'the supervisor runs fleet unpark, or fleet undrain for a drained fleet (it rebuilds run/log from the landed log, prints the parity fingerprint, puts every goal back as it was) and restarts each parked session with the command it prints', 'in the browser you use there: turn on "Notify me of new Inbox items" again (a browser setting)', `restart the dashboard where it outlives the session: tmux new-window -d -t ${s.tmux_session} -n dashboard 'node docs/devteam/scripts/devteam.mjs dashboard' (or another terminal), then open http://127.0.0.1:${s.dashboard_port}`, ]; } -// undrain: on any machine, after cloning. Rebuild run/log from the landed log if it is empty, -// record the undrain, and say how to restart each drained session. -function cmdUndrain() { - supervisorOnly('undrain'); - if (!commonDir || !projectInRepo) die('undrain needs a git checkout with the project folder inside it'); - if (repoRoot !== mainRoot) die(`run undrain from the main checkout (${mainRoot})`); +// fleet undrain | unpark: on any machine (after cloning, for a move). Rebuild run/log from the +// landed log if it is empty, take the parity fingerprint before writing anything, put back each +// goal the fleet op changed (and the owner hasn't changed since), and say how to restart the +// sessions that may run again. An old drain act still in force is lifted by unpark. +function fleetUndo(mode) { + const by = fleetWriter(); + if (!commonDir || !projectInRepo) die(`fleet un${mode} needs a git checkout with the project folder inside it`); + if (repoRoot !== mainRoot) die(`run it from the main checkout (${mainRoot})`); git(['fetch', '--quiet', 'origin']); mainRefCache = undefined; const live = liveLogDir(); @@ -5400,36 +5861,51 @@ function cmdUndrain() { logCache = null; out(`run/log was empty: rebuilt ${files.length} file(s) from ${projectRel}/log on ${main}`); } - const d = draining(); - if (!d) die('the fleet is not drained (no drain act without a later undrain in the log). If it was drained on another machine, land its log there first: log-sync, then merge --chore log'); + const legacy = mode === 'park' ? draining() : null; + const held = holdableGoals().filter((g) => g.hold?.fleet && g.hold.mode === mode); + const op = held.map((g) => g.hold.fleet).sort().pop() ?? null; + if (!op && !legacy) die(`no fleet ${mode} is in force (no goal holds a fleet op's ${mode}). If the fleet was ${mode === 'park' ? 'parked' : 'drained'} on another machine, land its log there first: log-sync, then merge --chore log`); + if (legacy && by !== 'supervisor') die('an old drain is in force; the supervisor lifts it (fleet unpark, as the supervisor)'); const parity = parityPrint(); - const act = appendLog({ type: 'act', class: 'undrain', what: `undrained the fleet on ${os.hostname()} (drained by ${d.id}${drainHost(d) ? ` on ${drainHost(d)}` : ''})`, refs: [d.id], reversible: true }, 'supervisor'); - out(`undrained (${act.id}); workers and planners can start again`); - const windows = new Set((sh('tmux', ['list-windows', '-t', loadSettings().tmux_session, '-F', '#W']) ?? '').split('\n').filter(Boolean)); - const rows = fleetSessions(d).filter((x) => (d.refs ?? []).includes(x.name)).map((x) => { - const cmd = `${x.role} ${x.id}${windows.has(x.name) && fs.existsSync(x.wt) ? ' --resume' : ''}`; - const ph = lastPhaseOf(x.name)?.to; - return ` devteam.mjs ${cmd.padEnd(36)} ${ph === 'parked' ? 'parked' : `phase ${ph ?? '-'}`}; ${handoffFor(x.name) ? 'its handoff note goes into its prompt' : 'no current handoff note'}`; - }); - out(rows.length ? `restart the drained sessions, as the supervisor:\n${rows.join('\n')}` : 'no drained session to restart'); + const host = os.hostname(); + const restored = []; + for (const g of held.filter((x) => x.hold.fleet === op)) { + const p = g.hold.prior ?? { mode: 'run' }; + writeHold(g, { mode: p.mode, ...(p.reason ? { reason: p.reason } : {}), ...(p.fleet ? { fleet: p.fleet } : {}), undoes: op, host }, by); + restored.push(`${isUpkeep(g) ? 'Upkeep' : g.title} → ${p.mode}`); + } + if (legacy) appendLog({ type: 'act', class: 'undrain', what: `undrained the fleet on ${host} (drained by ${legacy.id}${drainHost(legacy) ? ` on ${drainHost(legacy)}` : ''})`, refs: [legacy.id], reversible: true }, 'supervisor'); + if (op && by === 'supervisor') appendLog({ type: 'act', class: `fleet-un${mode}`, what: `un${mode === 'park' ? 'parked' : 'drained'} the fleet on ${host} (${op})`, refs: held.filter((x) => x.hold.fleet === op && !isUpkeep(x)).map((x) => x.id), reversible: true }, 'supervisor'); + logCache = null; + out(`un${mode === 'park' ? 'parked' : 'drained'} the fleet${op ? ` (${op})` : ''}${legacy ? `, and lifted the old drain ${legacy.id}` : ''}: ${restored.length ? restored.join('; ') : 'no goal to put back'}`); + const rows = restartCommands().map((x) => ` devteam.mjs ${x.cmd.padEnd(36)} ${x.handoff ? 'its handoff note goes into its prompt' : 'no current handoff note'}`); + out(rows.length ? `restart the parked sessions that may run again, as the supervisor:\n${rows.join('\n')}` : 'no parked session to restart'); + const stillParked = liveSessions().filter((x) => lastPhaseOf(x.name)?.to === 'parked' && ['park', 'let-go'].includes(sessionMode(x.name))).map((x) => x.name); + if (stillParked.length) out(`still parked, because their goal is: ${stillParked.join(', ')}`); out('then make sure the dashboard is running (supervisor.md §11)'); - out(`parity: ${parity.hash} (${parity.lines} log entries): the drained machine's drain --status printed the same when the move is 1:1`); - const sh0 = supervisorHandoffOnMain(); - if (sh0 && Date.parse(sh0.at) >= Date.parse(d.created_at) - 60000) out(`\n## Handoff from the drained supervisor (${sh0.rel}, ${sh0.at})\n\n${sh0.text.trimEnd()}\n\nRead it before anything else; it stays in the repo as the record.`); - else out(`\nno supervisor handoff from this drain on main (${projectRel}/${SUPERVISOR_HANDOFF})`); + out(`parity: ${parity.hash} (${parity.lines} log entries), taken before this wrote anything: fleet status printed the same on the old machine when the move is 1:1`); + const at = op ? fleetAt(op) : legacy?.created_at; + const sh0 = at ? supervisorHandoff(at) : null; + if (sh0) out(`\n## Handoff from the previous supervisor (${sh0.rel}, ${sh0.at})\n\n${sh0.text.trimEnd()}\n\nRead it before anything else; it stays in the repo as the record.`); + else if (mode === 'park') out('\nno supervisor handoff from this park (no devteam-handoff tag on origin since it)'); } -// The supervisor's prompt while the fleet is drained: what to do first. +// The supervisor's prompt while the fleet is parked or draining: what to do first. function drainedPreface() { - const d = draining(); - if (!d) return null; + const legacy = draining(); + const fm = fleetMode(); + if (!legacy && !fm) return null; + const op = fleetOpInForce(); + const host = legacy ? drainHost(legacy) : op?.host ?? null; + const what = legacy ? `drained by an old drain (${legacy.id}, ${legacy.created_at}: ${drainReason(legacy)})` : `${fm === 'park' ? 'parked' : 'draining'}${op ? ` (${op.fleet}${op.reason ? `: ${op.reason}` : ''})` : ' (every goal is held)'}`; + if (fm === 'drain' && !legacy) return `The fleet is ${what}${host ? `, set on ${host}` : ''}: every goal is draining, so in-flight work finishes and merges and nothing new starts. Carry on with what is running; fleet status says "ready to move" once nothing is. fleet undrain lifts it.`; // A checkout with no live log is a fresh clone; hostnames repeat (a default laptop name), so a // matching one counts only where run/log is still there. const liveHere = fs.existsSync(liveLogDir()) && fs.readdirSync(liveLogDir()).some((f) => f.endsWith('.jsonl')); - const here = liveHere && (!drainHost(d) || drainHost(d) === os.hostname()); - return `The fleet is drained (${d.id}, ${d.created_at}${drainHost(d) ? `, on ${drainHost(d)}` : ''}: ${drainReason(d)}). ${here - ? 'This is the machine it was drained on. If the move is under way, carry on with it (supervisor.md, Moving the fleet): node docs/devteam/scripts/devteam.mjs drain --status until it says "ready to move", landing the log with log-sync and merge --chore log; start nothing. Run undrain here only if the owner says to resume on this machine.' - : 'This looks like the machine it moved to. Before anything else: (1) DEVTEAM_ROLE=supervisor node docs/devteam/scripts/devteam.mjs undrain (it rebuilds run/log from the landed log, records the undrain, and prints the restart command for each drained session); (2) run each of those commands; (3) make sure the dashboard is running (supervisor.md §11) and tell the owner its address; then carry on from supervisor.md §1.'}`; + const here = liveHere && (!host || host === os.hostname()); + return `The fleet is ${what}${host ? `, on ${host}` : ''}. ${here + ? 'This is the machine it was parked on. If a move is under way, carry on with it (supervisor.md §12): node docs/devteam/scripts/devteam.mjs fleet status until it says "ready to move", landing the log with log-sync and merge --chore log; start nothing. Run fleet unpark here only if the owner says to resume on this machine.' + : 'This looks like the machine it moved to. Before anything else: (1) DEVTEAM_ROLE=supervisor node docs/devteam/scripts/devteam.mjs fleet unpark (it rebuilds run/log from the landed log, prints the parity fingerprint, puts every goal back as it was and prints the restart command for each parked session); (2) run each of those commands; (3) make sure the dashboard is running (supervisor.md §11) and tell the owner its address; then carry on from supervisor.md §1.'}`; } // wait [--minutes n]: block between ticks until something needs the supervisor, then say what. @@ -5647,13 +6123,18 @@ const HELP = `devteam (positionals before flags; flags take the word Setup and roles init [--name n] [--upstream-base vX] [--github] project folders, settings, .gitignore, the devteam section of AGENTS.md prompt supervisor | worker | planner the prompt a session starts with - worker [--resume] [--force] | planner [--resume] [--force] [--id-block ] supervisor: a worktree, a tmux window and an agent for a ticket or a feature plan (a new planner reserves n ticket numbers, default 20); refused while the fleet drains unless --force (logged); --resume where no session exists starts fresh with the handoff note + worker [--resume] [--force] | planner [--goal ] [--resume] [--force] [--id-block ] supervisor: a worktree, a tmux window and an agent for a ticket or a feature plan (a new planner names its goal and reserves n ticket numbers, default 20); refused while its goal is parked or let go, or draining for a session that hasn't run, unless --force (logged); --resume where no session exists starts fresh with the handoff note whoami | settings [--get key] -Moving the fleet (supervisor.md, Moving the fleet) - drain [--reason r] supervisor: log a drain act, tell every worker and planner to push, write a handoff and park; no session starts until undrain - drain --status [--json] [--no-fetch] per session parked, pushed, clean, handoff on origin; the log landed, spend observed, review results committed; ready to move yes/no, and the new machine's checklist - undrain supervisor, on any machine after cloning: rebuild an empty run/log from the landed log, log an undrain act, print each drained session's restart command +Holds and the fleet (planning.md §1, holds; supervisor.md §12) + goal hold run|drain|park [--reason r] [--owner] a goal's toggle: drain starts nothing new and lets in-flight work finish; park stops its sessions (WIP commit, push, handoff). The supervisor drains a running goal (with --reason) and releases only its own drain + goal park-sessions | --all [--again] supervisor: send the park ruling to the sessions a park or let go holds, once per cause + goal decide let-go | restore [--notes n] owner: let a goal go (its sessions park, its questions leave the inbox), or bring it back, parked + fleet drain | park [--reason r] [--owner] every goal at once (Upkeep included): drain sets running goals to drain, park sets every goal not parked to park; each records what it replaced + fleet undrain | unpark | resume [--owner] put back every goal the fleet op changed (and nobody changed since); resume does whichever are in force; on a new machine it first rebuilds an empty run/log from the landed log and prints the parity fingerprint before writing + handoff [--file f] [--supervisor] a parked worker or planner: the note (stdin) as an empty "devteam handoff: " commit on its branch, pushed; the supervisor: an annotated devteam-handoff/ tag on origin + fleet status [--json] [--no-fetch] ready to move? per session parked (or, drained, nothing running), pushed, clean, handoff on origin; the log landed, spend observed, review results committed; the new machine's checklist + drain --status | undrain the old names: drain --status is fleet status; undrain lifts an old drain still in force (drain itself now asks for fleet park or fleet drain) The graph (planning.md) feature add|set|show --json '{…}' | --file f a feature: goals, preconditions, stories with releases, design @@ -5700,7 +6181,7 @@ Observe and orient problem [--all] | problem add --title t --severity n [--risk] [--threatens g…] | problem set … Goals and retros - goal [list|check|show|forecast|add|set|decide|approve|status|reading] … + goal [list|check|show|forecast|add|set|decide|approve|status|reading|hold|park-sessions] … goal set --approved … supervisor: apply an answered goal-change question to a goal past draft (one set per answer) retro [--due | --print | close --summary s --refs ids] chore start | chore pr --title t a supervisor-written file (design, retro, architecture) and its PR @@ -5721,7 +6202,7 @@ const COMMANDS = { observe: cmdObserve, 'observe-metrics': cmdObserveMetrics, 'observe-spend': cmdObserveSpend, orient: cmdOrient, metrics: cmdMetrics, ack: cmdAck, timeline: cmdTimeline, knowledge: cmdKnowledge, problem: cmdProblem, goal: cmdGoal, retro: cmdRetro, chore: cmdChore, log: cmdLog, 'log-check': cmdLogCheck, 'log-sync': cmdLogSync, history: cmdHistory, - drain: cmdDrain, undrain: cmdUndrain, wait: cmdWait, + drain: cmdDrain, undrain: cmdUndrain, fleet: cmdFleet, handoff: cmdHandoff, wait: cmdWait, dashboard: cmdDashboard, 'dashboard-data': cmdDashboardData, notes: cmdNotes, }; if (!command || command === 'help' || command === '--help') out(HELP); diff --git a/test/kit.test.mjs b/test/kit.test.mjs index f8ebe52..aa775a1 100644 --- a/test/kit.test.mjs +++ b/test/kit.test.mjs @@ -959,9 +959,9 @@ test('devteam kit', async (t) => { // the planner's agent is a no-op here landSettings((txt) => txt.replace('"gates": {', '"agent_command": "true", "resume_command": "true", "gates": {')); // two planners started together: blocks above main's highest number (bill-01) and each other's - assert.match(must(['planner', 'alpha'], { role: 'sup' }), /ticket numbers 02-21 reserved/); - assert.match(must(['planner', 'beta', '--id-block', '5'], { role: 'sup' }), /ticket numbers 22-26 reserved/); - assert.match(dt(['planner', 'gamma', '--id-block', '0'], { role: 'sup' }).err, /--id-block takes a whole number/); + assert.match(must(['planner', 'alpha', '--goal', G], { role: 'sup' }), /ticket numbers 02-21 reserved/); + assert.match(must(['planner', 'beta', '--goal', G, '--id-block', '5'], { role: 'sup' }), /ticket numbers 22-26 reserved/); + assert.match(dt(['planner', 'gamma', '--goal', G, '--id-block', '0'], { role: 'sup' }).err, /--id-block takes a whole number/); const blocks = () => json(must(['log', '--type', 'act', '--json'])).filter((a) => a.id_block).map((a) => a.id_block); assert.deepEqual(blocks(), [{ feature: 'alpha', from: 2, to: 21 }, { feature: 'beta', from: 22, to: 26 }]); // the block is in the planner's prompt, and a relaunch keeps it rather than reserving another @@ -1276,6 +1276,53 @@ test('devteam kit', async (t) => { for (const f of ['config/apns.key', 'config/uni.key', 'config/credentials/test.key']) fs.rmSync(path.join(repo, f), { force: true }); }); + await t.test('goal holds: who may set what, merges wait on a park, let go and restore', () => { + const hold = () => json(must(['goal', 'show', G, '--json'])).hold_view; + // the supervisor drains a running goal, with a reason; it can't park one, or release the owner's hold + assert.match(dt(['goal', 'hold', G, 'drain'], { role: 'sup' }).err, /--reason/); + must(['goal', 'hold', G, 'drain', '--reason', 'billing waits on pricing'], { role: 'sup' }); + assert.deepEqual([hold().mode, hold().by], ['drain', 'supervisor']); + assert.ok(json(must(['log', '--type', 'act', '--json'])).some((a) => a.class === 'goal-hold' && /drained "Self-serve billing"/.test(a.what))); + assert.match(dt(['goal', 'hold', G, 'park', '--reason', 'x'], { role: 'sup' }).err, /only the owner parks a single goal/); + must(['goal', 'hold', G, 'park', '--owner', '--reason', 'pausing billing for a week']); + assert.deepEqual([hold().mode, hold().by], ['park', 'owner']); + assert.match(dt(['goal', 'hold', G, 'run', '--reason', 'x'], { role: 'sup' }).err, /releases only a drain it set/); + // a forged supervisor line is ignored by the fold, with why + const supFile = fs.readdirSync(path.join(repo, 'docs/devteam/run/log')).find((f) => /^supervisor-/.test(f)); + fs.appendFileSync(path.join(repo, 'docs/devteam/run/log', supFile), line({ id: G, type: 'goal', op: 'set', at: '2099-01-01T00:00:00.000Z', by: 'supervisor', hold: { mode: 'run' } })); + assert.equal(hold().mode, 'park'); + assert.match(must(['log-check']), /the supervisor releases only a drain it set/); + // the roadmap and the track stand still while parked + assert.match(must(['roadmap']), /Self-serve billing \[ready, parked\] -> no date while parked/); + assert.equal(json(must(['goal', 'show', G, '--json'])).track, null); + // its questions stay open and labelled; they don't count as the owner falling behind + const Qh = must(['ask', '--question', 'Monthly or yearly plans?', '--goal', G, '--recommendation', 'monthly'], { role: 'sup' }); + assert.match(must(['questions']), new RegExp(`${Qh} \\[owner-decision, open, its goal parked\\]`)); + // let go: abandoned, its questions leave the inbox; restore brings both back, parked + must(['goal', 'decide', G, 'let-go', '--notes', 'not this quarter']); + assert.equal(hold().mode, 'let-go'); + assert.doesNotMatch(must(['questions']), new RegExp(Qh)); + assert.ok(!json(must(['dashboard-data'])).questions.some((q) => q.id === Qh)); + assert.match(must(['validate']), /names goal g-[^,]+, which the owner let go/, 'a let-go goal\'s landed feature is a warning, not an error'); + assert.match(dt(['goal', 'hold', G, 'run', '--owner']).err, /goal decide .* restore/); + assert.match(must(['goal', 'decide', G, 'restore']), /restored as ready, parked/); + const gv = json(must(['goal', 'show', G, '--json'])); + assert.deepEqual([gv.status, gv.hold_view.mode], ['ready', 'park']); + assert.match(must(['questions']), new RegExp(Qh)); + // run again; the owner's release lets the supervisor drain once more + must(['goal', 'hold', G, 'run', '--owner']); + must(['goal', 'hold', G, 'drain', '--reason', 'again'], { role: 'sup' }); + must(['goal', 'hold', G, 'run', '--reason', 'pricing landed'], { role: 'sup' }); + assert.equal(hold().mode, 'run'); + must(['answer', Qh, 'monthly']); + // Upkeep: virtual until its first hold, held like any goal, never let go + assert.match(must(['goal', 'list']), /^upkeep \[running\] Upkeep/m); + must(['goal', 'hold', 'upkeep', 'drain', '--reason', 'quiet'], { role: 'sup' }); + assert.match(must(['goal', 'list']), /^upkeep \[(draining|drained): quiet\]/m); + must(['goal', 'hold', 'upkeep', 'run', '--owner']); + assert.equal(json(must(['dashboard-data'])).upkeep.mode, 'run'); + }); + await t.test('computed views: metrics, roadmap, retro, dashboard data', () => { must(['metrics']); assert.match(must(['roadmap']), /Self-serve billing/); @@ -2092,51 +2139,67 @@ test('devteam kit', async (t) => { }); // Moving the fleet: its own throwaway repo, an old machine and a fresh clone standing in for the new one. -test('drain: the fleet stops cleanly, and a clone elsewhere picks it up', () => { - const base = fs.mkdtempSync(path.join(os.tmpdir(), 'devteam-drain-')); +// A throwaway origin, an old machine and a new one, for the fleet tests. +function fleetFixture(name) { + const base = fs.mkdtempSync(path.join(os.tmpdir(), `devteam-${name}-`)); const org = path.join(base, 'origin.git'); const old = path.join(base, 'old'); const neu = path.join(base, 'new'); const home = path.join(base, 'home'); const tmuxDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dt-tmux-')); - const ID = 'ch-01-tidy'; const env = (role) => { const e = { ...process.env, CLAUDE_CONFIG_DIR: path.join(home, 'claude'), CODEX_HOME: path.join(home, 'codex'), TMUX_TMPDIR: tmuxDir }; for (const k of ['DEVTEAM_ROLE', 'DEVTEAM_TICKET', 'DEVTEAM_FEATURE', 'DEVTEAM_RUN', 'TMUX', 'TMUX_PANE']) delete e[k]; return { ...e, ...role }; }; - const run = (cwd, args, role = {}) => { const r = spawnSync(process.execPath, [path.join(cwd, 'docs/devteam/scripts/devteam.mjs'), ...args], { cwd, env: env(role), encoding: 'utf8' }); return { status: r.status, out: r.stdout.trim(), err: r.stderr.trim() }; }; - const ok = (cwd, args, role) => { const r = run(cwd, args, role); assert.equal(r.status, 0, `${args.join(' ')}: ${r.err || r.out}`); return r.out; }; + const run = (cwd, args, role = {}, input) => { const r = spawnSync(process.execPath, [path.join(cwd, 'docs/devteam/scripts/devteam.mjs'), ...args], { cwd, env: env(role), encoding: 'utf8', input }); return { status: r.status, out: r.stdout.trim(), err: r.stderr.trim() }; }; + const ok = (cwd, args, role, input) => { const r = run(cwd, args, role, input); assert.equal(r.status, 0, `${args.join(' ')}: ${r.err || r.out}`); return r.out; }; const g = (args, cwd) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + g(['init', '-q', '--bare', '-b', 'main', org], base); // the clone checks out origin's HEAD; CI's git defaults to master + fs.mkdirSync(old); + g(['init', '-q', '-b', 'main'], old); + for (const [k, v] of [['user.email', 't@t'], ['user.name', 't']]) g(['config', k, v], old); + g(['remote', 'add', 'origin', org], old); + fs.cpSync(kit, path.join(old, 'docs', 'devteam'), { recursive: true, filter: (p) => !/[\\/](project|run)([\\/]|$)/.test(p.slice(kit.length)) }); + ok(old, ['init', '--name', name]); + const sf = path.join(old, 'docs/devteam/project/settings.md'); + fs.writeFileSync(sf, fs.readFileSync(sf, 'utf8').replace('"gates": {}', '"gates": {}, "forge": "git", "worktree_files": ["config/dev.key"], "agent_command": "true", "resume_command": "true"')); + write(path.join(old, 'config/dev.key'), 'not a real key\n'); + write(path.join(old, '.gitignore'), `${fs.readFileSync(path.join(old, '.gitignore'), 'utf8')}config/dev.key\n`); + g(['add', '-A'], old); + g(['commit', '-qm', 'init'], old); + g(['push', '-q', 'origin', 'main'], old); + // the log lands (as merge --chore log would put it on main) + const land = () => { + const live = path.join(old, 'docs/devteam/run/log'); + for (const f of fs.readdirSync(live).filter((x) => x.endsWith('.jsonl'))) fs.copyFileSync(path.join(live, f), path.join(old, 'docs/devteam/project/log', f)); + g(['add', '-A', 'docs/devteam/project/log'], old); + g(['commit', '-qm', 'chore-log: log through now'], old); + g(['push', '-q', 'origin', 'main'], old); + }; + const done = () => { fs.rmSync(tmuxDir, { recursive: true, force: true }); if (!process.env.DEVTEAM_TEST_KEEP) fs.rmSync(base, { recursive: true, force: true }); else console.log(`${name} fixture kept at ${base}`); }; + return { base, org, old, neu, home, env, run, ok, g, land, done }; +} + +test('fleet park: every session stops with a handoff, and a clone elsewhere picks it up with parity', () => { + const F = fleetFixture('park'); + const { org, old, neu, home, env, run, ok, g, base } = F; + const ID = 'ch-01-tidy'; const SUP = { DEVTEAM_ROLE: 'supervisor' }; const WRK = { DEVTEAM_ROLE: 'worker', DEVTEAM_TICKET: ID }; try { - g(['init', '-q', '--bare', '-b', 'main', org], base); // the clone checks out origin's HEAD; CI's git defaults to master - fs.mkdirSync(old); - g(['init', '-q', '-b', 'main'], old); - for (const [k, v] of [['user.email', 't@t'], ['user.name', 't']]) g(['config', k, v], old); - g(['remote', 'add', 'origin', org], old); - fs.cpSync(kit, path.join(old, 'docs', 'devteam'), { recursive: true, filter: (p) => !/[\\/](project|run)([\\/]|$)/.test(p.slice(kit.length)) }); - ok(old, ['init', '--name', 'moving']); - const sf = path.join(old, 'docs/devteam/project/settings.md'); - fs.writeFileSync(sf, fs.readFileSync(sf, 'utf8').replace('"gates": {}', '"gates": {}, "forge": "git", "worktree_files": ["config/dev.key"], "agent_command": "true", "resume_command": "true"')); - write(path.join(old, 'config/dev.key'), 'not a real key\n'); - write(path.join(old, '.gitignore'), `${fs.readFileSync(path.join(old, '.gitignore'), 'utf8')}config/dev.key\n`); - g(['add', '-A'], old); - g(['commit', '-qm', 'init'], old); - g(['push', '-q', 'origin', 'main'], old); ok(old, ['ticket', 'add', ID, '--json', JSON.stringify({ title: 'tidy up', feature: 'chores', component: 'ch', kind: 'chore', size: 'S', risk: 'normal', serves: [], depends_on: [], touches: ['tidy/**'], done_when: ['the tidy check passes'], notes: 'turns on tidy' })], SUP); - // a planner logs one phase of its own: parked, when drained + // a planner logs one phase of its own: parked const pw = path.join(old, '.worktrees', 'plan-beta'); g(['worktree', 'add', '-q', '-b', 'plan/beta', pw, 'origin/main'], old); assert.match(run(pw, ['phase', 'build'], { DEVTEAM_ROLE: 'planner', DEVTEAM_FEATURE: 'beta' }).err, /a planner logs only phase parked/); - ok(pw, ['phase', 'parked', '--note', 'drained'], { DEVTEAM_ROLE: 'planner', DEVTEAM_FEATURE: 'beta' }); + ok(pw, ['phase', 'parked', '--note', 'parked'], { DEVTEAM_ROLE: 'planner', DEVTEAM_FEATURE: 'beta' }); assert.equal(json(ok(old, ['dashboard-data'])).planners.find((p) => p.feature === 'beta').parked, true); g(['worktree', 'remove', '--force', pw], old); g(['branch', '-q', '-D', 'plan/beta'], old); - // a worker mid-build, with work committed but not pushed, and an edit not committed + // a worker on a chore (Upkeep's) mid-build, with work committed but not pushed, and an edit not committed const ww = path.join(old, '.worktrees', ID); g(['worktree', 'add', '-q', '-b', `ticket/${ID}`, ww, 'origin/main'], old); ok(ww, ['phase', 'build', '--task', 'T1'], WRK); @@ -2145,84 +2208,81 @@ test('drain: the fleet stops cleanly, and a clone elsewhere picks it up', () => g(['commit', '-qm', 'T1'], ww); write(path.join(ww, 'tidy/b.txt'), 'b, not yet committed\n'); - // drain is the supervisor's; it logs its act, messages the worker, and blocks new sessions - assert.match(run(ww, ['drain', '--reason', 'new laptop'], WRK).err, /drain: the supervisor does this/); - const drained = ok(old, ['drain', '--reason', 'new laptop'], SUP); - assert.match(drained, new RegExp(`drained \\(a-\\d{8}-\\d{9}-supervisor\\): told ${ID} to push`)); - const act = json(ok(old, ['log', '--type', 'act', '--json'])).find((a) => a.class === 'drain'); - assert.match(act.what, /^drained the fleet on \S+: new laptop$/); - assert.deepEqual(act.refs, [ID]); + // the old drain now asks which you mean; a worker can't park the fleet + assert.match(run(old, ['drain', '--reason', 'x'], SUP).err, /fleet park stops every session .*fleet drain lets in-flight work finish/); + assert.match(run(ww, ['fleet', 'park'], WRK).err, /fleet: the supervisor does this/); + const parked = ok(old, ['fleet', 'park', '--reason', 'new laptop'], SUP); + assert.match(parked, /parked the fleet \(f-\d{8}-\d{9}\): 1 goal\(s\) set to park, Upkeep included/); + assert.match(parked, new RegExp(`told ${ID} to commit, push, write a handoff and park`)); + const act = json(ok(old, ['log', '--type', 'act', '--json'])).find((a) => a.class === 'fleet-park'); + assert.match(act.what, /^parked the fleet on \S+ \(f-\d{8}-\d{9}\): new laptop$/); const msg = json(ok(old, ['messages', '--to', ID, '--json'])).pop(); - assert.match(msg.text, new RegExp(`push ticket/${ID}; write docs/devteam/project/tickets/${ID}/handoff\\.md .*phase parked --note "drained"`)); - assert.match(run(old, ['worker', ID], SUP).err, /the fleet is draining .*run undrain first.*--force/); - assert.match(run(old, ['planner', 'gamma'], SUP).err, /the fleet is draining/); + assert.match(msg.text, /^Park: the fleet is parked \(new laptop\)\. .*devteam\.mjs handoff.*phase parked --note "parked"/); + assert.equal(ok(old, ['fleet', 'park'], SUP), 'every goal is already parked; nothing to do'); + assert.match(ok(old, ['goal', 'park-sessions', '--all'], SUP), /every session it holds has been told/, 'a park ruling goes once per cause'); + assert.match(run(old, ['worker', ID, '--resume'], SUP).err, /its goal is parked: nothing starts for it.*--force/); + assert.match(run(old, ['planner', 'gamma'], SUP).err, /--goal /); const findings = json(ok(old, ['metrics', '--json'])).findings; - assert.ok(!findings.some((f) => ['idle-capacity', 'idle-planning'].includes(f.kind)), 'nothing new is suggested while draining'); - assert.match(ok(old, ['prompt', 'supervisor']), /^The fleet is drained .*This is the machine it was drained on/); + assert.ok(!findings.some((f) => ['idle-capacity', 'idle-planning', 'park-unsent'].includes(f.kind)), 'nothing new is suggested while parked, and the ruling was sent'); + assert.match(ok(old, ['prompt', 'supervisor']), /^The fleet is parked .*This is the machine it was parked on/); + assert.match(run(old, ['chore', 'start', 'retro-x'], SUP).err, /Upkeep is parked: no new upkeep chore starts/); // not ready: unpushed, uncommitted, not parked, no handoff, log not landed - let st = json(ok(old, ['drain', '--status', '--json'], SUP)); + let st = json(ok(old, ['fleet', 'status', '--json'], SUP)); assert.equal(st.ready, false); + assert.equal(st.fleet.mode, 'park'); const s0 = st.sessions.find((x) => x.name === ID); - assert.deepEqual([s0.parked, s0.pushed, s0.clean, s0.handoff], [false, false, false, false]); + assert.deepEqual([s0.parked, s0.pushed, s0.clean], [false, false, false]); assert.ok(st.blockers.some((b) => /live log is not on main/.test(b))); assert.ok(st.checklist.some((c) => /config\/dev\.key/.test(c) && /secure channel/.test(c)), 'the checklist lists worktree_files'); - assert.ok(st.checklist.some((c) => /undrain/.test(c)) && st.checklist.some((c) => /dashboard/.test(c))); - assert.match(ok(old, ['drain', '--status'], SUP), /ready to move: no\n\nnew-machine checklist:/); + assert.ok(st.checklist.some((c) => /fleet unpark/.test(c)) && st.checklist.some((c) => /dashboard/.test(c))); + assert.match(ok(old, ['drain', '--status'], SUP), /ready to move: no\n\nnew-machine checklist:/, 'drain --status is fleet status'); - // the worker does what the drain message says; a handoff without the push is still not ready - g(['add', '-A'], ww); - g(['commit', '-qm', 'WIP: drain'], ww); - write(path.join(ww, `docs/devteam/project/tickets/${ID}/handoff.md`), '# Handoff\n\nDone: T1. Next: write tidy/c.txt (T2). Open: none. Uncommitted: none.\n'); + // the worker does what the ruling says: commit, park without a handoff first (not ready), then the handoff g(['add', '-A'], ww); - g(['commit', '-qm', 'handoff'], ww); - ok(ww, ['phase', 'parked', '--note', 'drained'], WRK); - st = json(ok(old, ['drain', '--status', '--json'], SUP)); + g(['commit', '-qm', 'WIP: park'], ww); + assert.match(run(ww, ['handoff'], WRK, '').err, /the note is empty/); + ok(ww, ['phase', 'parked', '--note', 'parked'], WRK); + st = json(ok(old, ['fleet', 'status', '--json'], SUP)); assert.deepEqual(st.sessions.map((x) => [x.parked, x.pushed, x.clean, x.handoff]), [[true, false, true, false]]); - g(['push', '-q', '-u', 'origin', `ticket/${ID}`], ww); - st = json(ok(old, ['drain', '--status', '--json'], SUP)); + assert.match(ok(ww, ['handoff'], WRK, 'Done: T1. Next: write tidy/c.txt (T2). Open: none. Uncommitted: none.\n'), /handoff committed on ticket\/ch-01-tidy and pushed/); + assert.equal(g(['log', '-1', '--format=%s', `origin/ticket/${ID}`], ww), `devteam handoff: ${ID}`, 'the handoff is a commit, not a file'); + assert.equal(g(['diff', '--name-only', 'HEAD~1', 'HEAD'], ww), '', 'it changes no file'); + st = json(ok(old, ['fleet', 'status', '--json'], SUP)); assert.deepEqual(st.sessions.map((x) => [x.parked, x.pushed, x.clean, x.handoff]), [[true, true, true, true]]); assert.equal(st.ready, false, 'the log has not landed yet'); - // the log lands (as merge --chore log would put it on main) - const live = path.join(old, 'docs/devteam/run/log'); - for (const f of fs.readdirSync(live).filter((x) => x.endsWith('.jsonl'))) fs.copyFileSync(path.join(live, f), path.join(old, 'docs/devteam/project/log', f)); - g(['add', '-A', 'docs/devteam/project/log'], old); - g(['commit', '-qm', 'chore-log: log through now'], old); - g(['push', '-q', 'origin', 'main'], old); + F.land(); // everything else that is only here blocks: no supervisor handoff, a loose run/ file, a worktree with unpushed work - st = json(ok(old, ['drain', '--status', '--json'], SUP)); - assert.ok(st.blockers.some((b) => /no docs\/devteam\/project\/handoffs\/supervisor\.md on main/.test(b)), 'the supervisor writes a handoff too'); + st = json(ok(old, ['fleet', 'status', '--json'], SUP)); + assert.ok(st.blockers.some((b) => /no supervisor handoff since the fleet park/.test(b)), 'the supervisor writes a handoff too'); write(path.join(old, 'docs/devteam/run/research-notes.md'), 'notes\n'); const cw = path.join(old, '.worktrees', 'chore-notes'); g(['worktree', 'add', '-q', '-b', 'chore-notes', cw, 'origin/main'], old); write(path.join(cw, 'notes.txt'), 'x\n'); g(['add', '-A'], cw); g(['commit', '-qm', 'notes'], cw); - st = json(ok(old, ['drain', '--status', '--json'], SUP)); + st = json(ok(old, ['fleet', 'status', '--json'], SUP)); assert.ok(st.blockers.some((b) => /files in run\/ the kit didn't make, which don't move: research-notes\.md/.test(b))); assert.ok(st.blockers.some((b) => /worktrees holding work that isn't on the remote: chore-notes \(1 commit\(s\) on no remote\)/.test(b))); fs.rmSync(path.join(old, 'docs/devteam/run/research-notes.md')); g(['worktree', 'remove', '--force', cw], old); g(['branch', '-q', '-D', 'chore-notes'], old); - // the supervisor's handoff lands on main (as chore drain-handoff would put it there) - write(path.join(old, 'docs/devteam/project/handoffs/supervisor.md'), '# Supervisor handoff\n\nWaiting on: nothing. Next: restart ch-01-tidy.\n'); - g(['add', '-A', 'docs/devteam/project/handoffs'], old); - g(['commit', '-qm', 'chore-drain-handoff'], old); - g(['push', '-q', 'origin', 'main'], old); + // the supervisor's handoff: an annotated tag on origin, no file + assert.match(ok(old, ['handoff', '--supervisor'], SUP, '# Supervisor handoff\n\nWaiting on: nothing. Next: restart ch-01-tidy.\n'), /pushed devteam-handoff\/f-/); + assert.match(g(['ls-remote', '--tags', 'origin'], old), /refs\/tags\/devteam-handoff\/f-/); // agent memory outside the repo that changed after the handoff blocks; folded in before it, it doesn't const mem = path.join(home, 'claude', 'projects', fs.realpathSync(old).replace(/[^a-zA-Z0-9]/g, '-'), 'memory'); write(path.join(mem, 'rule.md'), 'the owner said X\n'); const later = new Date(Date.now() + 120000); fs.utimesSync(path.join(mem, 'rule.md'), later, later); - st = json(ok(old, ['drain', '--status', '--json'], SUP)); + st = json(ok(old, ['fleet', 'status', '--json'], SUP)); assert.ok(st.blockers.some((b) => /agent memory outside the repo changed after the supervisor's handoff landed \(rule\.md\)/.test(b))); const earlier = new Date(Date.now() - 3600000); fs.utimesSync(path.join(mem, 'rule.md'), earlier, earlier); - st = json(ok(old, ['drain', '--status', '--json'], SUP)); + st = json(ok(old, ['fleet', 'status', '--json'], SUP)); assert.deepEqual(st.blockers, []); assert.equal(st.ready, true); - assert.ok(st.checklist.some((c) => /Notify me of new Inbox items/.test(c))); - const oldStatus = ok(old, ['drain', '--status'], SUP); + const oldStatus = ok(old, ['fleet', 'status'], SUP); assert.match(oldStatus, /ready to move: yes\nparity: [0-9a-f]{16} \(\d+ log entries\)/); const oldParity = /parity: ([0-9a-f]{16})/.exec(oldStatus)[1]; @@ -2230,38 +2290,82 @@ test('drain: the fleet stops cleanly, and a clone elsewhere picks it up', () => g(['clone', '-q', org, neu], base); for (const [k, v] of [['user.email', 't@t'], ['user.name', 't']]) g(['config', k, v], neu); assert.ok(!fs.existsSync(path.join(neu, 'docs/devteam/run/log'))); - assert.match(ok(neu, ['prompt', 'supervisor']), /^The fleet is drained .*This looks like the machine it moved to\. Before anything else: \(1\) DEVTEAM_ROLE=supervisor node docs\/devteam\/scripts\/devteam\.mjs undrain/, 'the standard prompt sees the drain, and that this is a fresh clone'); - const up = ok(neu, ['undrain'], SUP); + assert.match(ok(neu, ['prompt', 'supervisor']), /^The fleet is parked .*This looks like the machine it moved to\. Before anything else: \(1\) DEVTEAM_ROLE=supervisor node docs\/devteam\/scripts\/devteam\.mjs fleet unpark/, 'the standard prompt sees the parked fleet, and that this is a fresh clone'); + const up = ok(neu, ['fleet', 'resume'], SUP); assert.match(up, /run\/log was empty: rebuilt \d+ file\(s\)/); assert.equal(/parity: ([0-9a-f]{16})/.exec(up)?.[1], oldParity, 'the new machine holds exactly the log the old one did'); - assert.match(up, /## Handoff from the drained supervisor[\s\S]*Next: restart ch-01-tidy\./); - assert.match(up, new RegExp(`undrained \\(a-[^)]+\\)[\\s\\S]*devteam\\.mjs worker ${ID} +parked; its handoff note goes into its prompt`)); - assert.ok(fs.readdirSync(path.join(neu, 'docs/devteam/run/log')).some((f) => /^supervisor-/.test(f))); - assert.equal(json(ok(neu, ['drain', '--status', '--json', '--no-fetch'], SUP)).draining, null, 'the drain is lifted'); - assert.doesNotMatch(ok(neu, ['prompt', 'supervisor']), /The fleet is drained/); - assert.ok(json(ok(neu, ['log', '--type', 'act', '--json'])).some((a) => a.class === 'undrain' && a.refs[0] === act.id)); + assert.match(up, /## Handoff from the previous supervisor[\s\S]*Next: restart ch-01-tidy\./); + assert.match(up, /unparked the fleet \(f-\d{8}-\d{9}\): Upkeep → run/); + assert.match(up, new RegExp(`devteam\\.mjs worker ${ID} +its handoff note goes into its prompt`)); + assert.equal(json(ok(neu, ['fleet', 'status', '--json', '--no-fetch'], SUP)).fleet, null, 'the park is lifted'); + assert.doesNotMatch(ok(neu, ['prompt', 'supervisor']), /The fleet is parked/); + assert.ok(json(ok(neu, ['log', '--type', 'act', '--json'])).some((a) => a.class === 'fleet-unpark')); // the restarted worker's prompt carries the handoff until it logs its first phase const wp = ok(neu, ['prompt', 'worker', ID]); - assert.match(wp, /## Handoff from a drained session[\s\S]*Done: T1\. Next: write tidy\/c\.txt \(T2\)/); - assert.match(wp, new RegExp(`on ticket/${ID}, committed`)); + assert.match(wp, /## Handoff from a parked session[\s\S]*the fleet was parked[\s\S]*Done: T1\. Next: write tidy\/c\.txt \(T2\)/); + assert.match(wp, new RegExp(`the handoff commit [0-9a-f]{12} on ticket/${ID}`)); const nw = path.join(neu, '.worktrees', ID); if (hasTmux) { - // --resume where the harness has no session (no worktree here yet): a fresh start, said plainly, - // on the pushed branch, with the handoff in the prompt const rs = spawnSync(process.execPath, [path.join(neu, 'docs/devteam/scripts/devteam.mjs'), 'worker', ID, '--resume'], { cwd: neu, env: env(SUP), encoding: 'utf8' }); spawnSync('tmux', ['kill-server'], { env: env({}) }); assert.equal(rs.status, 0, rs.stderr); assert.match(rs.stderr, /no worker session to resume for ch-01-tidy: its worktree was just made here.*starting it fresh with agent_command and its handoff note/); assert.equal(g(['rev-parse', 'HEAD'], nw), g(['rev-parse', `origin/ticket/${ID}`], neu), 'the worktree continues the pushed branch, not main'); - assert.match(fs.readFileSync(path.join(neu, `docs/devteam/run/prompts/${ID}.txt`), 'utf8'), /## Handoff from a drained session/); - assert.match(ok(neu, ['prompt', 'worker', ID]), /## Handoff from a drained session/, 'the launch\'s own "starting" line keeps the note current'); + assert.match(fs.readFileSync(path.join(neu, `docs/devteam/run/prompts/${ID}.txt`), 'utf8'), /## Handoff from a parked session/); } else g(['worktree', 'add', '-q', '--track', '-b', `ticket/${ID}`, nw, `origin/ticket/${ID}`], neu); ok(nw, ['phase', 'build', '--task', 'T2'], WRK); - assert.doesNotMatch(ok(neu, ['prompt', 'worker', ID]), /Handoff from a drained session/, 'after its first phase line the note is history'); - assert.ok(fs.existsSync(path.join(nw, `docs/devteam/project/tickets/${ID}/handoff.md`)), 'the note stays in the ticket folder'); + assert.doesNotMatch(ok(neu, ['prompt', 'worker', ID]), /Handoff from a parked session/, 'after its first phase line the note is history'); + } finally { + F.done(); + } +}); + +test('fleet drain: in-flight work finishes, nothing new starts, and a drained fleet moves with parity', () => { + const F = fleetFixture('drain'); + const { org, old, neu, run, ok, g, base } = F; + const SUP = { DEVTEAM_ROLE: 'supervisor' }; + const ID = 'ch-01-small'; + const WRK = { DEVTEAM_ROLE: 'worker', DEVTEAM_TICKET: ID }; + try { + ok(old, ['ticket', 'add', ID, '--json', JSON.stringify({ title: 'small', feature: 'chores', component: 'ch', kind: 'chore', size: 'S', risk: 'normal', serves: [], depends_on: [], touches: ['small/**'], done_when: ['the small check passes'], notes: 'turns on small' })], SUP); + ok(old, ['ticket', 'add', 'ch-02-later', '--json', JSON.stringify({ title: 'later', feature: 'chores', component: 'ch', kind: 'chore', size: 'S', risk: 'normal', serves: [], depends_on: [], touches: ['later/**'], done_when: ['the later check passes'], notes: 'turns on later' })], SUP); + const ww = path.join(old, '.worktrees', ID); + g(['worktree', 'add', '-q', '-b', `ticket/${ID}`, ww, 'origin/main'], old); + ok(ww, ['phase', 'build', '--task', 'T1'], WRK); + assert.match(ok(old, ['fleet', 'drain', '--reason', 'quiet week'], SUP), /draining the fleet \(f-\d{8}-\d{9}\): 1 goal\(s\) set to drain/); + assert.equal(json(ok(old, ['messages', '--to', ID, '--json'])).length, 0, 'a drain tells running sessions nothing: they finish'); + const ready = json(ok(old, ['ready', '--json'])); + assert.deepEqual(ready.ready, [], 'nothing new is ready'); + assert.ok(ready.waiting.some((w) => w.id === 'ch-02-later' && w.why === 'goal draining')); + assert.match(run(old, ['worker', 'ch-02-later'], SUP).err, /its goal is draining: a draining goal finishes what is running and starts nothing new/); + assert.match(ok(old, ['goal', 'list']), /^upkeep \[draining: quiet week\] Upkeep/m); + let st = json(ok(old, ['fleet', 'status', '--json'], SUP)); + assert.equal(st.fleet.mode, 'drain'); + assert.ok(st.blockers.some((b) => new RegExp(`${ID}: still running \\(phase build\\): a drain finishes in-flight work`).test(b))); + // the ticket finishes (merged, as merge would put it on main) + write(path.join(ww, 'small/a.txt'), 'a\n'); + g(['add', '-A'], ww); + g(['commit', '-qm', `${ID}: small`], ww); + g(['push', '-q', 'origin', `ticket/${ID}`], ww); + g(['merge', '-q', '--no-ff', '-m', `Merge pull request #1 from x/ticket/${ID}`, `ticket/${ID}`], old); + g(['push', '-q', 'origin', 'main'], old); + ok(ww, ['phase', 'done'], WRK); + g(['worktree', 'remove', '--force', ww], old); + assert.match(ok(old, ['goal', 'list']), /^upkeep \[drained: quiet week\]/m, 'drained once nothing of it runs'); + F.land(); + ok(old, ['handoff', '--supervisor'], SUP, 'Nothing in flight. Next: run Upkeep again.\n'); + st = json(ok(old, ['fleet', 'status', '--json'], SUP)); + assert.deepEqual(st.blockers, []); + assert.equal(st.ready, true, 'a drained fleet, nothing running, can move'); + const oldParity = st.parity.hash; + g(['clone', '-q', org, neu], base); + for (const [k, v] of [['user.email', 't@t'], ['user.name', 't']]) g(['config', k, v], neu); + const up = ok(neu, ['fleet', 'undrain'], SUP); + assert.equal(/parity: ([0-9a-f]{16})/.exec(up)?.[1], oldParity); + assert.match(up, /undrained the fleet \(f-\d{8}-\d{9}\): Upkeep → run/); + assert.deepEqual(json(ok(neu, ['ready', '--json'])).ready.map((r) => r.id), ['ch-02-later'], 'work starts again'); } finally { - fs.rmSync(tmuxDir, { recursive: true, force: true }); - if (!process.env.DEVTEAM_TEST_KEEP) fs.rmSync(base, { recursive: true, force: true }); else console.log(`drain fixture kept at ${base}`); + F.done(); } }); From 52861fd687dcc01802542cd3f6d2d6a32d3e0c8a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 23:15:47 -0500 Subject: [PATCH 2/4] WIP: holds fixes (fold recursion, upkeep in goal list, improvement loop quiets with Upkeep, inbox labels), tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T5z3hZtpGaQUGsTaAMQrAg --- docs/devteam/scripts/devteam.mjs | 2 +- test/kit.test.mjs | 94 ++++++++++++++++---------------- 2 files changed, 48 insertions(+), 48 deletions(-) diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 7b030db..acdf947 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -3953,9 +3953,9 @@ function cmdGoal() { } const gs = logObjects('goal').map(goalView).sort((a, b) => (a.priority ?? 99) - (b.priority ?? 99)); if (flags.json) return json(gs); - if (!gs.length) return out('no goals (the owner states them; planning.md §1)'); const u = holdView(upkeepOrVirtual()); out(`upkeep [${u.state}${u.reason ? `: ${u.reason}` : ''}] Upkeep: chores, CI fixes, retros, kit syncs`); + if (!gs.length) return out('no goals (the owner states them; planning.md §1)'); for (const g of gs) { const last = g.readings[g.readings.length - 1]; out(`${g.id} [${g.status}${g.priority ? `, p${g.priority}` : ''}${g.hold_view.mode !== 'run' ? `, ${g.hold_view.state}` : ''}] ${g.title}\n ${g.measure?.description ?? '-'}: ${last ? last.value : '?'} -> ${g.measure?.target ?? '?'} ${g.measure?.unit ?? ''} by ${g.horizon ?? '?'}${g.track ? ` (${g.track})` : ''}; features: ${g.check.features.join(', ') || 'none'}`); diff --git a/test/kit.test.mjs b/test/kit.test.mjs index aa775a1..4828564 100644 --- a/test/kit.test.mjs +++ b/test/kit.test.mjs @@ -1139,6 +1139,53 @@ test('devteam kit', async (t) => { fs.writeFileSync(file, before); }); + await t.test('goal holds: who may set what, merges wait on a park, let go and restore', () => { + const hold = () => json(must(['goal', 'show', G, '--json'])).hold_view; + // the supervisor drains a running goal, with a reason; it can't park one, or release the owner's hold + assert.match(dt(['goal', 'hold', G, 'drain'], { role: 'sup' }).err, /--reason/); + must(['goal', 'hold', G, 'drain', '--reason', 'billing waits on pricing'], { role: 'sup' }); + assert.deepEqual([hold().mode, hold().by], ['drain', 'supervisor']); + assert.ok(json(must(['log', '--type', 'act', '--json'])).some((a) => a.class === 'goal-hold' && /drained "Self-serve billing"/.test(a.what))); + assert.match(dt(['goal', 'hold', G, 'park', '--reason', 'x'], { role: 'sup' }).err, /only the owner parks a single goal/); + must(['goal', 'hold', G, 'park', '--owner', '--reason', 'pausing billing for a week']); + assert.deepEqual([hold().mode, hold().by], ['park', 'owner']); + assert.match(dt(['goal', 'hold', G, 'run', '--reason', 'x'], { role: 'sup' }).err, /releases only a drain it set/); + // a forged supervisor line is ignored by the fold, with why + const supFile = fs.readdirSync(path.join(repo, 'docs/devteam/run/log')).find((f) => /^supervisor-/.test(f)); + fs.appendFileSync(path.join(repo, 'docs/devteam/run/log', supFile), line({ id: G, type: 'goal', op: 'set', at: new Date(Date.now() + 500).toISOString(), by: 'supervisor', hold: { mode: 'run' } })); + assert.equal(hold().mode, 'park'); + assert.match(must(['log-check']), /the supervisor releases only a drain it set/); + // the roadmap and the track stand still while parked + assert.match(must(['roadmap']), /Self-serve billing \[ready, parked\] -> no date while parked/); + assert.equal(json(must(['goal', 'show', G, '--json'])).track, null); + // its questions stay open and labelled; they don't count as the owner falling behind + const Qh = must(['ask', '--question', 'Monthly or yearly plans?', '--goal', G, '--recommendation', 'monthly'], { role: 'sup' }); + assert.match(must(['questions']), new RegExp(`${Qh} \\[owner-decision, open, its goal parked\\]`)); + // let go: abandoned, its questions leave the inbox; restore brings both back, parked + must(['goal', 'decide', G, 'let-go', '--notes', 'not this quarter']); + assert.equal(hold().mode, 'let-go'); + assert.doesNotMatch(must(['questions']), new RegExp(Qh)); + assert.ok(!json(must(['dashboard-data'])).questions.some((q) => q.id === Qh)); + assert.match(must(['validate']), /names goal g-[^,]+, which the owner let go/, 'a let-go goal\'s landed feature is a warning, not an error'); + assert.match(dt(['goal', 'hold', G, 'run', '--owner']).err, /goal decide .* restore/); + assert.match(must(['goal', 'decide', G, 'restore']), /restored as ready, parked/); + const gv = json(must(['goal', 'show', G, '--json'])); + assert.deepEqual([gv.status, gv.hold_view.mode], ['ready', 'park']); + assert.match(must(['questions']), new RegExp(Qh)); + // run again; the owner's release lets the supervisor drain once more + must(['goal', 'hold', G, 'run', '--owner']); + must(['goal', 'hold', G, 'drain', '--reason', 'again'], { role: 'sup' }); + must(['goal', 'hold', G, 'run', '--reason', 'pricing landed'], { role: 'sup' }); + assert.equal(hold().mode, 'run'); + must(['answer', Qh, 'monthly']); + // Upkeep: virtual until its first hold, held like any goal, never let go + assert.match(must(['goal', 'list']), /^upkeep \[running\] Upkeep/m); + must(['goal', 'hold', 'upkeep', 'drain', '--reason', 'quiet'], { role: 'sup' }); + assert.match(must(['goal', 'list']), /^upkeep \[(draining|drained): quiet\]/m); + must(['goal', 'hold', 'upkeep', 'run', '--owner']); + assert.equal(json(must(['dashboard-data'])).upkeep.mode, 'run'); + }); + await t.test('secrets: the owner posts straight to a sink; the log never holds a value', async (t) => { const VALUE = 'sk_test_51Hq"zz\\9-not-in-the-log'; const CERT = '-----BEGIN CERTIFICATE-----\nMIIBszCCAVmgAwIBAgIUQ\n-----END CERTIFICATE-----\n'; @@ -1276,53 +1323,6 @@ test('devteam kit', async (t) => { for (const f of ['config/apns.key', 'config/uni.key', 'config/credentials/test.key']) fs.rmSync(path.join(repo, f), { force: true }); }); - await t.test('goal holds: who may set what, merges wait on a park, let go and restore', () => { - const hold = () => json(must(['goal', 'show', G, '--json'])).hold_view; - // the supervisor drains a running goal, with a reason; it can't park one, or release the owner's hold - assert.match(dt(['goal', 'hold', G, 'drain'], { role: 'sup' }).err, /--reason/); - must(['goal', 'hold', G, 'drain', '--reason', 'billing waits on pricing'], { role: 'sup' }); - assert.deepEqual([hold().mode, hold().by], ['drain', 'supervisor']); - assert.ok(json(must(['log', '--type', 'act', '--json'])).some((a) => a.class === 'goal-hold' && /drained "Self-serve billing"/.test(a.what))); - assert.match(dt(['goal', 'hold', G, 'park', '--reason', 'x'], { role: 'sup' }).err, /only the owner parks a single goal/); - must(['goal', 'hold', G, 'park', '--owner', '--reason', 'pausing billing for a week']); - assert.deepEqual([hold().mode, hold().by], ['park', 'owner']); - assert.match(dt(['goal', 'hold', G, 'run', '--reason', 'x'], { role: 'sup' }).err, /releases only a drain it set/); - // a forged supervisor line is ignored by the fold, with why - const supFile = fs.readdirSync(path.join(repo, 'docs/devteam/run/log')).find((f) => /^supervisor-/.test(f)); - fs.appendFileSync(path.join(repo, 'docs/devteam/run/log', supFile), line({ id: G, type: 'goal', op: 'set', at: '2099-01-01T00:00:00.000Z', by: 'supervisor', hold: { mode: 'run' } })); - assert.equal(hold().mode, 'park'); - assert.match(must(['log-check']), /the supervisor releases only a drain it set/); - // the roadmap and the track stand still while parked - assert.match(must(['roadmap']), /Self-serve billing \[ready, parked\] -> no date while parked/); - assert.equal(json(must(['goal', 'show', G, '--json'])).track, null); - // its questions stay open and labelled; they don't count as the owner falling behind - const Qh = must(['ask', '--question', 'Monthly or yearly plans?', '--goal', G, '--recommendation', 'monthly'], { role: 'sup' }); - assert.match(must(['questions']), new RegExp(`${Qh} \\[owner-decision, open, its goal parked\\]`)); - // let go: abandoned, its questions leave the inbox; restore brings both back, parked - must(['goal', 'decide', G, 'let-go', '--notes', 'not this quarter']); - assert.equal(hold().mode, 'let-go'); - assert.doesNotMatch(must(['questions']), new RegExp(Qh)); - assert.ok(!json(must(['dashboard-data'])).questions.some((q) => q.id === Qh)); - assert.match(must(['validate']), /names goal g-[^,]+, which the owner let go/, 'a let-go goal\'s landed feature is a warning, not an error'); - assert.match(dt(['goal', 'hold', G, 'run', '--owner']).err, /goal decide .* restore/); - assert.match(must(['goal', 'decide', G, 'restore']), /restored as ready, parked/); - const gv = json(must(['goal', 'show', G, '--json'])); - assert.deepEqual([gv.status, gv.hold_view.mode], ['ready', 'park']); - assert.match(must(['questions']), new RegExp(Qh)); - // run again; the owner's release lets the supervisor drain once more - must(['goal', 'hold', G, 'run', '--owner']); - must(['goal', 'hold', G, 'drain', '--reason', 'again'], { role: 'sup' }); - must(['goal', 'hold', G, 'run', '--reason', 'pricing landed'], { role: 'sup' }); - assert.equal(hold().mode, 'run'); - must(['answer', Qh, 'monthly']); - // Upkeep: virtual until its first hold, held like any goal, never let go - assert.match(must(['goal', 'list']), /^upkeep \[running\] Upkeep/m); - must(['goal', 'hold', 'upkeep', 'drain', '--reason', 'quiet'], { role: 'sup' }); - assert.match(must(['goal', 'list']), /^upkeep \[(draining|drained): quiet\]/m); - must(['goal', 'hold', 'upkeep', 'run', '--owner']); - assert.equal(json(must(['dashboard-data'])).upkeep.mode, 'run'); - }); - await t.test('computed views: metrics, roadmap, retro, dashboard data', () => { must(['metrics']); assert.match(must(['roadmap']), /Self-serve billing/); From fc79b940ccf1c1d0c7142e66d708d00a620688d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 23:28:45 -0500 Subject: [PATCH 3/4] Holds: fixes from the Fable code review - park rulings fit the 600-character message limit (marker kept whole) - tickets, features, planners and live sessions computed once per log read - the fleet strip asks for move readiness only while the fleet is held, or on Check - a ticket whose feature names no goal follows the fleet, like its session - a fleet held goal by goal can move and resume; the strip offers Resume for a partly held op - restore reads the goal's status from its creation line - a session parked as its hold was released keeps its handoff - a second Upkeep line folds into the first, with its holds - docs: plan PRs land while held; Upkeep wording; let-go warning; handoff prompt text Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T5z3hZtpGaQUGsTaAMQrAg --- docs/devteam/dashboard/index.html | 11 +++--- docs/devteam/process/planning.md | 4 +- docs/devteam/scripts/devteam.mjs | 63 ++++++++++++++++++++----------- test/kit.test.mjs | 10 +++++ 4 files changed, 60 insertions(+), 28 deletions(-) diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index 9d2f2c5..87f9b8b 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -1574,11 +1574,11 @@

    Upkeep

    const f = DATA?.fleet || {}; const mode = f.mode; const st = FLEET.data; - const ready = st ? (st.ready ? 'ready to move' : `not ready to move`) : 'checking…'; + const ready = st ? (st.ready ? 'ready to move' : `not ready to move`) : mode || f.op ? 'checking…' : 'park or drain the fleet to move it; Check says what a move needs'; const left = st && !st.ready ? `
    ${esc(st.blockers.length)} thing${st.blockers.length === 1 ? '' : 's'} left before a move
      ${st.blockers.map((b) => `
    • ${linkify(b)}
    • `).join('')}
    ` : ''; const parity = st?.parity ? `parity ${esc(st.parity.hash)}` : ''; - const label = mode === 'park' ? 'Fleet parked' : mode === 'drain' ? `Fleet draining${st && !st.sessions.some((x) => !x.parked) ? ' (drained)' : ''}` : 'Fleet running'; - const btns = mode + const label = mode === 'park' ? 'Fleet parked' : mode === 'drain' ? `Fleet draining${st && !st.sessions.some((x) => !x.parked) ? ' (drained)' : ''}` : f.op ? 'Fleet partly held (a goal was set apart after the fleet op)' : 'Fleet running'; + const btns = mode || f.op ? '' : ''; return `${label}${f.reason ? ` · ${esc(f.reason)}` : ''}${ready}${parity}${btns}${left}`; @@ -2165,8 +2165,9 @@

    ${linkify(k.statement)}

    $('answered').innerHTML = o.answered.length ? o.answered.map(answeredCard).join('') : empty('Nothing answered yet.'); renderOoda(o); - $('fleet-strip').innerHTML = fleetStrip(); - if (Date.now() - FLEET.at > 60000) loadFleet(); + // readiness is costly (git, gh, harness logs): asked for only while the fleet is held, or on Check + if (!$('fleet-strip').querySelector('.confirm')) $('fleet-strip').innerHTML = fleetStrip(); + if ((d.fleet?.mode || d.fleet?.op) && Date.now() - FLEET.at > 60000) loadFleet(); for (const id of openForms) { const f = document.querySelector(`[data-correct-form="${id}"]`); if (f) f.hidden = false; } renderProgress(); diff --git a/docs/devteam/process/planning.md b/docs/devteam/process/planning.md index 8bace46..854217a 100644 --- a/docs/devteam/process/planning.md +++ b/docs/devteam/process/planning.md @@ -49,7 +49,7 @@ A goal is **fully defined** when it has: an outcome (what is true for users or t - **Park**: its sessions get a park ruling (commit WIP, push, record a handoff, `phase parked`, stop; `worker.md` §9); nothing starts; its ticket merges wait (`merge` refuses without `--force`, logged). Its track and forecast stand still (no ETA, not "behind"), and it leaves the queue other goals' dates are computed from. Its questions stay open, labelled, and don't count as the owner falling behind. - **Let go** (`goal decide let-go`): the goal is abandoned; its sessions park and its open questions leave the inbox. It is reversible: `goal decide restore` brings it back with the status it had, parked, and its questions return. -Work belongs to goals: a ticket to its feature's goals (when it serves several, it runs under the least restrictive of them, leaving out goals already met), a planner to the goal it plans (`planner --goal ` until its feature line or a question names one), a chore to **Upkeep** (§7). The owner sets and releases any hold. The supervisor may drain a running goal, with a reason (an act of class `goal-hold`, which the owner can correct), and release only a drain it set; it parks a single goal only by asking the owner. A whole-fleet hold is a shorthand for every goal at once (`supervisor.md` §12). +A plan PR still lands while its goal is held: landing a plan starts nothing. Work belongs to goals: a ticket to its feature's goals (when it serves several, it runs under the least restrictive of them, leaving out goals already met), a planner to the goal it plans (`planner --goal ` until its feature line or a question names one), a chore to **Upkeep** (§7). The owner sets and releases any hold. The supervisor may drain a running goal, with a reason (an act of class `goal-hold`, which the owner can correct), and release only a drain it set; it parks a single goal only by asking the owner. A whole-fleet hold is a shorthand for every goal at once (`supervisor.md` §12). **Readings.** At each retro the supervisor records a reading from CI, metrics or releases where the measure allows. On track or behind is computed from the readings, the target and the horizon. @@ -159,6 +159,6 @@ When a design change, a failed spike or a correction changes the graph: the supe **Baseline.** Bootstrap proposes the first goal, and the owner opts in through the regular flow (definition, design, approval): **Baseline**, the repo meeting the kit's floor. Its outcome: every gate in `quality.md` §2 and every Law in `code-style.md` enforced on main. Its measure: the floor gaps, the gates and Laws not yet enforced, counted from `gate --all` and the settings, direction down, target 0. Its preconditions group the gaps (format and lint, types, tests in CI, coverage, dead code and duplication, secrets and lockfile, an encrypted secret store); its milestones order them; its features are the floor tickets, each saying in `notes` which gate it turns on. Gates that fail on existing code start in ratchet mode. The owner may decline it, or hold it, like any goal. -**Upkeep.** Work that serves no goal of the owner's belongs to the built-in **Upkeep** goal: the reserved `chores` feature (chores, spikes, CI fixes), and the supervisor's upkeep (retros, research, kit syncs, the improvement loop's trials and proposals, trust and lesson suggestions). It has no measure, design, milestones or place in the forecast, can't be let go, and has the same toggle as any goal (`goal hold upkeep …`); its line is made the first time it is held. While it is drained or parked, no new chore ticket, upkeep chore (`chore start`) or improvement act starts. Two things never stop: observing (metrics, findings, readings), so a held fleet still notices what it should; and keeping the record (`log-sync`, `merge --chore log`, posted secrets). +**Upkeep.** Work that serves no goal of the owner's belongs to the built-in **Upkeep** goal: the reserved `chores` feature (chores, spikes, CI fixes), and the supervisor's upkeep (retros, research, kit syncs, the improvement loop's trials and proposals, trust and lesson suggestions). It has no measure, design, milestones or place in the forecast, can't be let go, and has the same toggle as any goal (`goal hold upkeep …`); its line is made the first time it is held. While it is drained or parked, no chore ticket's worker, upkeep chore (`chore start`) or improvement act starts. Two things never stop: observing (metrics, findings, readings), so a held fleet still notices what it should; and keeping the record (`log-sync`, `merge --chore log`, posted secrets). Adopting an existing codebase: write `architecture.md` from the code; list work already in flight in its table and keep new footprints off those files; start `max_active_tickets` at 2 or 3, since old hotspots make footprints overlap, and raise it when `waves` shows real width; plan the first feature with contract tickets that carve stable interfaces around the hotspots; where the repo has its own process docs, map each rule to the devteam doc that covers it, link where they agree, ask the owner where they conflict. diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index acdf947..93975f4 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -421,6 +421,7 @@ function fold(all) { const objects = new Map(); const ignored = []; const goalApprovals = new Set(); + const aliases = new Map(); for (const l of lines) { if (l.op !== 'new') continue; if (objects.has(l.id)) { @@ -428,8 +429,11 @@ function fold(all) { continue; } const { v, op, __file, __line, at, by, ...body } = l; - if (body.type === 'goal' && body.kind === 'upkeep' && [...objects.values()].some((o) => o.type === 'goal' && o.kind === 'upkeep')) { - ignored.push({ line: l, why: 'a second upkeep goal; the earliest is the one' }); + const kept = body.type === 'goal' && body.kind === 'upkeep' ? [...objects.values()].find((o) => o.type === 'goal' && o.kind === 'upkeep') : null; + if (kept) { + // two writers made Upkeep at once: the earliest is the one, and the other's holds apply to it + aliases.set(l.id, kept.id); + ignored.push({ line: l, why: `a second upkeep goal; its changes apply to ${kept.id}` }); continue; } const rank = RANK[writerClass(by)]; @@ -437,7 +441,7 @@ function fold(all) { } for (const l of lines) { if (l.op !== 'set') continue; - const obj = objects.get(l.id); + const obj = objects.get(aliases.get(l.id) ?? l.id); if (!obj) { ignored.push({ line: l, why: 'set on an unknown id' }); continue; @@ -1074,8 +1078,10 @@ const projectFileText = (rel) => readText(path.join(projectDir, rel)) ?? (projec // ===== 5. the graph (tickets and features, folded from the log) ===== -const tickets = () => new Map(logObjects('ticket').map((t) => [t.id, t])); -const features = () => new Map(logObjects('feature').map((f) => [f.id, f])); +// Computed once per read of the log (a write starts a new read): callers don't change what they get. +const memo = (key, fn) => { const l = readLog(); l.memo ??= new Map(); if (!l.memo.has(key)) l.memo.set(key, fn()); return l.memo.get(key); }; +const tickets = () => memo('tickets', () => new Map(logObjects('ticket').map((t) => [t.id, t]))); +const features = () => memo('features', () => new Map(logObjects('feature').map((f) => [f.id, f]))); const isActive = (t) => !t.superseded_by; // A held ticket is one the supervisor chose not to schedule yet (`ticket set --json '{"held": "why"}'`; null releases it). const isHeld = (t) => typeof t.held === 'string' && t.held.trim() !== ''; @@ -1279,7 +1285,8 @@ const paneWaiting = (id) => (activeWorker(id) ? paneWaitingIn(id) : null); // Planners in flight: a plan/ branch whose plan file hasn't landed on main. They write no phase lines, so their // last sign of life is the newest of: a live log line (messages, observations), a commit on the // branch, their plan file, an uncommitted edit in their worktree. Their pane says if they're stuck. -function planners() { +function planners() { return memo('planners', plannersNow); } +function plannersNow() { const s = loadSettings(); const main = mainRef(); const lines = readLog().lines; @@ -1441,7 +1448,7 @@ function cmdValidate() { const o = logGet(g); if (o?.type !== 'goal') errors.push(`feature ${fid} names goal ${g}, which is not in the log`); else if (o.kind === 'upkeep') errors.push(`feature ${fid} names the Upkeep goal; upkeep is the chores feature's, and a feature serves the goals it plans for`); - else if (o.status === 'abandoned') warnings.push(`feature ${fid} names goal ${g}, which the owner let go: its work stays parked until goal decide ${g} restore`); + else if (o.status === 'abandoned') warnings.push(`feature ${fid} names goal ${g}, which the owner let go: its work waits unless another goal it serves runs (goal decide ${g} restore brings it back)`); else if (!['ready', 'met'].includes(o.status)) errors.push(`feature ${fid} names goal ${g}, which is ${o.status ?? 'draft'}; the owner approves a goal before features are planned from it`); else { // A feature's own design builds on its goal's design, the spine, and says so (planning.md §2). @@ -1535,7 +1542,8 @@ function ticketGoals(t) { if (t.feature === CHORES) return [upkeepOrVirtual()]; return (features().get(t.feature)?.goals ?? []).map((id) => logGet(id)).filter((g) => g?.type === 'goal'); } -const ticketMode = (t) => modeOf(ticketGoals(t)); +// A ticket whose feature names no goal (an old project) follows the fleet, like its session. +const ticketMode = (t) => { const gs = ticketGoals(t); return gs.length ? modeOf(gs) : fleetMode() ?? 'run'; }; // The goals that can hold anything: live goals and Upkeep. const holdableGoals = () => [upkeepOrVirtual(), ...logObjects('goal').filter((g) => !['met', 'abandoned'].includes(g.status))]; // When every holdable goal is parked, or every one is draining or parked, the fleet is too. @@ -1591,12 +1599,18 @@ function causeOf(g, when) { } // What a session told to park must do; the marker in brackets lets a ruling be sent once per cause. function parkMessage(s, cause) { - const why = cause.kind === 'fleet' ? `the fleet is parked${cause.reason ? ` (${cause.reason.slice(0, 120)})` : ''}` : cause.kind === 'let-go' ? `the owner let go of "${cause.title}"` : `"${cause.title}" is parked${cause.reason ? ` (${cause.reason.slice(0, 120)})` : ''}`; - return `Park: ${why}. Now: commit all work in progress (a WIP commit is fine); then devteam.mjs handoff, with your note on stdin (what is done, the exact next step, open questions, anything you could not commit): it adds the note as an empty commit on ${s.branch} and pushes the branch; then devteam.mjs phase parked --note "parked", and stop (worker.md §9). A merge-request you sent is void once you push; send it again when you are resumed. [${cause.marker}]`; + const title = String(cause.title ?? '').slice(0, 60); + const reason = cause.reason ? ` (${String(cause.reason).slice(0, 80)})` : ''; + const why = cause.kind === 'fleet' ? `the fleet is parked${reason}` : cause.kind === 'let-go' ? `the owner let go of "${title}"` : `"${title}" is parked${reason}`; + const tail = ` [${cause.marker}]`; + const body = `Park: ${why}. Commit all work (WIP is fine), then devteam.mjs handoff with your note on stdin (done, exact next step, open questions, anything uncommitted; it commits the note and pushes ${s.branch}), then devteam.mjs phase parked --note "parked", and stop (worker.md §9). Any merge-request you sent is void; resend it when resumed.`; + // a message line holds 600 characters; the marker that sends it once per cause is kept whole + return `${body.slice(0, 600 - tail.length)}${tail}`; } const parkRulingSent = (name, marker) => logObjects('message').some((m) => m.to === name && m.kind === 'ruling' && (m.text ?? '').includes(`[${marker}]`)); // Every live session: workers with a phase that isn't done and no merge, planners in flight. -function liveSessions() { +function liveSessions() { return memo('liveSessions', liveSessionsNow); } +function liveSessionsNow() { const names = new Set(); for (const t of tickets().values()) if (activeWorker(t.id) || (workerState(t.id)?.phase === 'parked' && !mergeShaFor(t.id) && isActive(t))) names.add(t.id); for (const p of planners()) names.add(`plan-${p.feature}`); @@ -3849,7 +3863,8 @@ function cmdGoal() { // Restore undoes a let go: the goal gets back the status it had, parked, and its questions return. if (verdict === 'restore') { if (g.status !== 'abandoned') die(`${id} is ${g.status ?? 'draft'}, not let go; nothing to restore`); - const statuses = [{ at: g.created_at, status: 'draft' }, ...g.updates.filter((u) => u.status !== undefined)]; + const born = readLog().lines.find((l) => l.id === id && l.op === 'new')?.status ?? 'draft'; + const statuses = [{ at: g.created_at, status: born }, ...g.updates.filter((u) => u.status !== undefined)]; const lastGo = statuses.map((u) => u.status).lastIndexOf('abandoned'); const prior = statuses.slice(0, lastGo).map((u) => u.status).filter((x) => x !== 'abandoned').pop() ?? 'draft'; appendLog({ type: 'goal', op: 'set', id, status: prior, decision: { verdict: 'restore', notes: flagStr('notes')?.trim() || undefined }, hold: { mode: 'park', reason: 'restored from let go' } }, by); @@ -5475,9 +5490,13 @@ function handoffFor(name) { // A planner logs no phases while it works, so any line of its own (a message, an observation) ends it. const launch = (l) => l.type === 'phase' && ['parked', 'starting'].includes(l.to); if (mine.some((l) => l.at > parked.at && (planner || l.type === 'phase') && !launch(l))) return null; - const cause = parkCauseAt(name, parked.at); - if (!cause) return null; const s = sessionOf(name); + // A hold released while the session was parking leaves no cause in force at its parked line; + // a handoff commit it made for that park still counts. + const prev = mine.filter((l) => l.type === 'phase' && l.at < parked.at && !launch(l)).pop(); + const own = [`refs/heads/${s.branch}`, `refs/remotes/origin/${s.branch}`].map((r) => handoffCommit(r, name)).find((c) => c && (!prev || c.at >= prev.at)); + const cause = parkCauseAt(name, parked.at) ?? (own ? { kind: 'self', at: parked.at, marker: `self:${name}@${parked.at}`, reason: null } : null); + if (!cause) return null; for (const r of [`refs/heads/${s.branch}`, `refs/remotes/origin/${s.branch}`]) { const c = handoffCommit(r, name); if (c) return { ...s, text: c.body, where: `the handoff commit ${c.sha.slice(0, 12)} on ${s.branch}, ${c.at}`, cause, parked_at: parked.at }; @@ -5523,7 +5542,7 @@ function cmdHandoff() { if (!note) die('handoff: the note is empty (stdin or --file): what is done, the exact next step, open questions, anything not committed'); if (flags.supervisor) { supervisorOnly('handoff --supervisor'); - const op = fleetOpInForce()?.fleet ?? (draining() ? draining().id : null) ?? die('handoff --supervisor is for a fleet park or drain in force'); + const op = fleetOpInForce()?.fleet ?? (draining() ? draining().id : null) ?? (fleetMode() ? `held-${newFleetId().slice(2)}` : null) ?? die('handoff --supervisor is for a fleet park or drain in force'); git(['fetch', '--quiet', 'origin', loadSettings().main_branch]); const tag = `devteam-handoff/${op}-${newFleetId().slice(2)}`; const base = mainRef() ?? 'HEAD'; @@ -5550,13 +5569,13 @@ function cmdHandoff() { gitOrDie(['push', '--quiet', 'origin', `HEAD:refs/heads/${sess.branch}`]); out(`handoff committed on ${sess.branch} and pushed; now: devteam.mjs phase parked --note "parked", and stop`); } -const causeWords = (c) => (c.kind === 'fleet' ? `the fleet was parked${c.host ? ` on ${c.host}` : ''}${c.reason ? ` (${c.reason})` : ''}` : c.kind === 'let-go' ? `the owner let go of "${c.title}"` : `"${c.title}" was parked${c.reason ? ` (${c.reason})` : ''}`); +const causeWords = (c) => (c.kind === 'self' ? 'it was told to park' : c.kind === 'fleet' ? `the fleet was parked${c.host ? ` on ${c.host}` : ''}${c.reason ? ` (${c.reason})` : ''}` : c.kind === 'let-go' ? `the owner let go of "${c.title}"` : `"${c.title}" was parked${c.reason ? ` (${c.reason})` : ''}`); const HANDOFF_MAX = 12000; function handoffBlock(name) { const h = handoffFor(name); if (!h) return null; const body = h.text.length > HANDOFF_MAX ? `${h.text.slice(0, HANDOFF_MAX)}\n\n(cut at ${HANDOFF_MAX} characters; read the whole file)` : h.text; - return `## Handoff from a parked session\n\nThis session was parked (${causeWords(h.cause)}, ${h.cause.at}) and starts without the earlier one's context. Before anything else in ${h.role === 'worker' ? 'worker.md §2' : 'planning.md'}, read this note, left by the session that stopped (${h.where}); then check it against your branch, your files and the log. Keep the file: it is part of the record and lands with your PR. Between the lines below is the note itself, as that session wrote it.\n\n-----\n${body.trimEnd()}\n-----`; + return `## Handoff from a parked session\n\nThis session was parked (${causeWords(h.cause)}, ${h.cause.at}) and starts without the earlier one's context. Before anything else in ${h.role === 'worker' ? 'worker.md §2' : 'planning.md'}, read this note, left by the session that stopped (${h.where}); then check it against your branch, your files and the log. It stays on your branch as a commit and lands with your PR. Between the lines below is the note itself, as that session wrote it.\n\n-----\n${body.trimEnd()}\n-----`; } // The supervisor's own handoff: its context doesn't move either, so it writes one too, and it lands // on main like any project file (chore drain-handoff), so the next supervisor reads it from git. @@ -5667,7 +5686,7 @@ function fleetStatus() { const fm = legacy ? 'park' : fleetMode(); const op = fleetOpInForce(); const since = legacy ? legacy.created_at : op ? fleetAt(op.fleet) : null; - const d = since ? { id: legacy?.id ?? op?.fleet ?? 'holds', created_at: since } : null; + const d = fm ? { id: legacy?.id ?? op?.fleet ?? 'holds', created_at: since } : null; if (!flags['no-fetch'] && commonDir) git(['fetch', '--quiet', 'origin']); mainRefCache = undefined; const main = mainRef(); @@ -5747,7 +5766,7 @@ function fleetStatus() { // The supervisor's handoff, on main, written since the drain; and nothing the agents keep outside // the repo changed after it (what the next supervisor needs from it is folded into the handoff). - const sh0 = d ? supervisorHandoff(d.created_at) : null; + const sh0 = d ? supervisorHandoff(d.created_at ?? null) : null; const handoffOk = !!sh0; if (d && !handoffOk) blockers.push(`no supervisor handoff since the fleet ${fm}: write it and push it (devteam.mjs handoff --supervisor, the note on stdin)`); const memory = harnessMemoryFiles(); @@ -5864,7 +5883,9 @@ function fleetUndo(mode) { const legacy = mode === 'park' ? draining() : null; const held = holdableGoals().filter((g) => g.hold?.fleet && g.hold.mode === mode); const op = held.map((g) => g.hold.fleet).sort().pop() ?? null; - if (!op && !legacy) die(`no fleet ${mode} is in force (no goal holds a fleet op's ${mode}). If the fleet was ${mode === 'park' ? 'parked' : 'drained'} on another machine, land its log there first: log-sync, then merge --chore log`); + const oneByOne = !op && !legacy && fleetMode() === mode; + if (!op && !legacy && !oneByOne) die(`no fleet ${mode} is in force (no goal holds a fleet op's ${mode}). If the fleet was ${mode === 'park' ? 'parked' : 'drained'} on another machine, land its log there first: log-sync, then merge --chore log`); + if (oneByOne) out(`every goal was ${mode === 'park' ? 'parked' : 'drained'} one by one, not by a fleet op: nothing to put back here; set each goal with its toggle (goal hold run)`); if (legacy && by !== 'supervisor') die('an old drain is in force; the supervisor lifts it (fleet unpark, as the supervisor)'); const parity = parityPrint(); const host = os.hostname(); @@ -5885,7 +5906,7 @@ function fleetUndo(mode) { out('then make sure the dashboard is running (supervisor.md §11)'); out(`parity: ${parity.hash} (${parity.lines} log entries), taken before this wrote anything: fleet status printed the same on the old machine when the move is 1:1`); const at = op ? fleetAt(op) : legacy?.created_at; - const sh0 = at ? supervisorHandoff(at) : null; + const sh0 = at || oneByOne ? supervisorHandoff(at ?? null) : null; if (sh0) out(`\n## Handoff from the previous supervisor (${sh0.rel}, ${sh0.at})\n\n${sh0.text.trimEnd()}\n\nRead it before anything else; it stays in the repo as the record.`); else if (mode === 'park') out('\nno supervisor handoff from this park (no devteam-handoff tag on origin since it)'); } diff --git a/test/kit.test.mjs b/test/kit.test.mjs index 4828564..8b6dc28 100644 --- a/test/kit.test.mjs +++ b/test/kit.test.mjs @@ -1184,6 +1184,16 @@ test('devteam kit', async (t) => { assert.match(must(['goal', 'list']), /^upkeep \[(draining|drained): quiet\]/m); must(['goal', 'hold', 'upkeep', 'run', '--owner']); assert.equal(json(must(['dashboard-data'])).upkeep.mode, 'run'); + // two Upkeep lines made at once fold to one, and a hold on the second applies to the first + const U = json(must(['dashboard-data'])).upkeep.id; + const ownerFile = fs.readdirSync(path.join(repo, 'docs/devteam/run/log')).find((f) => /^owner-/.test(f)); + const at = new Date(Date.now() + 200).toISOString(); + const U2 = `g-${at.slice(0, 10).replace(/-/g, '')}-${at.slice(11, 23).replace(/[:.]/g, '')}-owner`; + fs.appendFileSync(path.join(repo, 'docs/devteam/run/log', ownerFile), line({ id: U2, type: 'goal', op: 'new', at, by: 'owner', channel: 'cli', kind: 'upkeep', title: 'Upkeep' })); + fs.appendFileSync(path.join(repo, 'docs/devteam/run/log', ownerFile), line({ id: U2, type: 'goal', op: 'set', at: new Date(Date.now() + 300).toISOString(), by: 'owner', channel: 'cli', hold: { mode: 'drain' } })); + const up = json(must(['dashboard-data'])).upkeep; + assert.deepEqual([up.id, up.mode], [U, 'drain']); + must(['goal', 'hold', 'upkeep', 'run', '--owner']); }); await t.test('secrets: the owner posts straight to a sink; the log never holds a value', async (t) => { From f7b0f94ea4c5dcac8ccf5b48eb1501f6f87ec1dc Mon Sep 17 00:00:00 2001 From: hhff Date: Wed, 4 Nov 2026 00:14:28 -0500 Subject: [PATCH 4/4] Holds: the fold holds fleet lines to what a fleet op does A supervisor fleet line counted whatever it said: a forged fleet drain could loosen the owner's park on a goal to a drain (letting its started sessions resume), and a forged undo of the fleet op in force could put back any mode, not the one the op recorded. The fold now takes a fleet line only when it tightens (drain a running goal, park one not parked) and records the hold it replaces as prior, and an undo only when it puts back exactly that prior. Co-Authored-By: Claude Opus 5.5 --- docs/devteam/scripts/devteam.mjs | 14 ++++++++++++-- test/kit.test.mjs | 10 ++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 93975f4..826a4b9 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -517,8 +517,18 @@ function fold(all) { function holdRefusal(h, cur, curBy, cls) { if (cls === 'owner') return null; if (cls !== 'supervisor') return `${cls} may not change a goal's hold`; - if (h.undoes) return cur.fleet === h.undoes ? null : `this goal's hold is no longer the one ${h.undoes} set, so that fleet op doesn't undo it`; - if (h.fleet) return ['drain', 'park'].includes(h.mode) ? null : 'a fleet op drains or parks'; + // An undo puts back exactly the hold the fleet op recorded as prior, nothing else. + if (h.undoes) { + if (cur.fleet !== h.undoes) return `this goal's hold is no longer the one ${h.undoes} set, so that fleet op doesn't undo it`; + const p = cur.prior ?? { mode: 'run' }; + return h.mode === p.mode && (h.fleet ?? null) === (p.fleet ?? null) ? null : `undoing ${h.undoes} puts back ${p.mode}${p.fleet ? ` (${p.fleet})` : ''}, not ${h.mode}`; + } + // A fleet op only tightens (drain a running goal, park one not parked) and records what it replaced. + if (h.fleet) { + if (!['drain', 'park'].includes(h.mode)) return 'a fleet op drains or parks'; + if (h.mode === 'drain' ? cur.mode !== 'run' : cur.mode === 'park') return `a fleet ${h.mode} doesn't change a goal on ${cur.mode}`; + return h.prior?.mode === cur.mode && (h.prior?.fleet ?? null) === (cur.fleet ?? null) ? null : `a fleet op records the hold it replaces (${cur.mode}) as prior`; + } if (h.mode === 'drain') return cur.mode === 'run' ? null : `the supervisor drains only a running goal (this one is on ${cur.mode})`; if (h.mode === 'run') return cur.mode === 'drain' && curBy === 'supervisor' && !cur.fleet ? null : 'the supervisor releases only a drain it set'; return 'only the owner parks a single goal (the supervisor may fleet park)'; diff --git a/test/kit.test.mjs b/test/kit.test.mjs index 8b6dc28..16b2c23 100644 --- a/test/kit.test.mjs +++ b/test/kit.test.mjs @@ -1155,6 +1155,16 @@ test('devteam kit', async (t) => { fs.appendFileSync(path.join(repo, 'docs/devteam/run/log', supFile), line({ id: G, type: 'goal', op: 'set', at: new Date(Date.now() + 500).toISOString(), by: 'supervisor', hold: { mode: 'run' } })); assert.equal(hold().mode, 'park'); assert.match(must(['log-check']), /the supervisor releases only a drain it set/); + // a forged fleet line can't loosen the owner's park to a drain, and a forged undo puts back only what the op recorded + const supPath = path.join(repo, 'docs/devteam/run/log', supFile); + const before = fs.readFileSync(supPath, 'utf8'); + const forge = (hold0, dt0 = 600) => fs.appendFileSync(path.join(repo, 'docs/devteam/run/log', supFile), line({ id: G, type: 'goal', op: 'set', at: new Date(Date.now() + dt0).toISOString(), by: 'supervisor', hold: hold0 })); + forge({ mode: 'drain', fleet: 'f-20261101-000000000', prior: { mode: 'park' } }, 700); + assert.equal(hold().mode, 'park', 'a fleet drain never loosens a park'); + assert.match(must(['log-check']), /a fleet drain doesn't change a goal on park/); + forge({ mode: 'run', undoes: 'f-20261101-000000000' }, 800); + assert.equal(hold().mode, 'park', 'an undo of a fleet op not in force counts for nothing'); + fs.writeFileSync(supPath, before); // the roadmap and the track stand still while parked assert.match(must(['roadmap']), /Self-serve billing \[ready, parked\] -> no date while parked/); assert.equal(json(must(['goal', 'show', G, '--json'])).track, null);