@dougharness/cli 1.1.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/board-serve.js +40 -6
- package/dist/board-serve.js.map +1 -1
- package/dist/init.js +1 -1
- package/dist/init.js.map +1 -1
- package/dist/memory-setup.d.ts +7 -0
- package/dist/memory-setup.js +70 -46
- package/dist/memory-setup.js.map +1 -1
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +6 -2
- package/node_modules/@dougharness/flow/agents/coder.md +23 -0
- package/node_modules/@dougharness/flow/agents/implementer.md +1 -1
- package/node_modules/@dougharness/flow/agents/planner.md +1 -1
- package/node_modules/@dougharness/flow/agents/researcher.md +2 -1
- package/node_modules/@dougharness/flow/agents/reviewer.md +3 -1
- package/node_modules/@dougharness/flow/agents/spec-writer.md +23 -0
- package/node_modules/@dougharness/flow/agents/tester.md +21 -0
- package/node_modules/@dougharness/flow/hooks/hooks.json +4 -1
- package/node_modules/@dougharness/flow/hooks/register.tsx +94 -0
- package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +117 -0
- package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +17 -0
- package/node_modules/@dougharness/flow/lib/board.mjs +91 -10
- package/node_modules/@dougharness/flow/lib/card-land.mjs +115 -0
- package/node_modules/@dougharness/flow/lib/card-loop.mjs +778 -0
- package/node_modules/@dougharness/flow/lib/coordinator.mjs +393 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +15 -5
- package/node_modules/@dougharness/flow/lib/decisions.mjs +4 -2
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +144 -3
- package/node_modules/@dougharness/flow/lib/land.mjs +27 -8
- package/node_modules/@dougharness/flow/lib/memory.mjs +406 -76
- package/node_modules/@dougharness/flow/lib/plan.mjs +33 -5
- package/node_modules/@dougharness/flow/lib/worktrees.mjs +3 -3
- package/node_modules/@dougharness/flow/package.json +1 -1
- package/node_modules/@dougharness/flow/scripts/autopilot.mjs +440 -0
- package/node_modules/@dougharness/flow/scripts/board.mjs +107 -32
- package/node_modules/@dougharness/flow/scripts/cost.mjs +16 -3
- package/node_modules/@dougharness/flow/scripts/memory.mjs +33 -10
- package/node_modules/@dougharness/flow/scripts/plan.mjs +104 -30
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +8 -2
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +30 -10
- package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +80 -0
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +6 -6
- package/node_modules/@dougharness/flow/workflows/card-queen.js +846 -0
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +63 -23
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/lib/anchor.mjs +29 -4
- package/node_modules/@dougharness/gates/lib/bash-rules.mjs +196 -6
- package/node_modules/@dougharness/gates/lib/gate-lock.mjs +40 -8
- package/node_modules/@dougharness/gates/lib/proposals.mjs +41 -5
- package/node_modules/@dougharness/gates/lib/scope.mjs +43 -4
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/reanchor.mjs +2 -1
- package/node_modules/@dougharness/gates/scripts/research-cap.mjs +119 -15
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +156 -14
- package/node_modules/@dougharness/gates/scripts/verify.mjs +8 -4
- package/package.json +3 -3
- package/templates/board-app.js +46 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The live autopilot pane (card autopilot-live-queue). It opens itself when a run starts (card autopilot-pane-auto-open); /doug-autopilot reopens it. It polls
|
|
2
|
+
// .doug/.state/autopilot.json (written by scripts/autopilot.mjs) and shows the same lines the board's panel shows.
|
|
3
|
+
// It also refreshes on prompt submit, turn end, and after a Bash call, because a reload drops the session.start timer (card autopilot-pane-current).
|
|
4
|
+
import { atom, read, update } from 'claude-code'
|
|
5
|
+
import type { Register } from 'claude-code'
|
|
6
|
+
|
|
7
|
+
import { AUTOPILOT_FILE, autopilotLines, autopilotView, parseAutopilot, shouldOpenPane } from '../lib/autopilot-core.mjs'
|
|
8
|
+
|
|
9
|
+
const PANE = 'doug-autopilot'
|
|
10
|
+
const POLL_MS = 5000
|
|
11
|
+
const lines = atom({ plugin: 'doug-flow', key: 'autopilot' } as const, [] as string[])
|
|
12
|
+
// The startedAt of the run the pane was last opened for, so a pane the user closed stays closed for that run.
|
|
13
|
+
const opened = atom({ plugin: 'doug-flow', key: 'autopilot-opened' } as const, null as string | null)
|
|
14
|
+
|
|
15
|
+
const refresh = async ($: any) => {
|
|
16
|
+
try {
|
|
17
|
+
const root = await $.session.root()
|
|
18
|
+
let text = ''
|
|
19
|
+
try {
|
|
20
|
+
text = await $.fs.read(`${root}/${AUTOPILOT_FILE}`)
|
|
21
|
+
} catch {
|
|
22
|
+
// no file: no run
|
|
23
|
+
}
|
|
24
|
+
const state = parseAutopilot(text)
|
|
25
|
+
const fresh = autopilotLines(autopilotView(state, new Date()))
|
|
26
|
+
await update($, lines, old => (JSON.stringify(old) === JSON.stringify(fresh) ? old : fresh))
|
|
27
|
+
// Only `autopilot.autoOpenPane: false` in .doug/config.json turns the auto-open off.
|
|
28
|
+
let autoOpen = true
|
|
29
|
+
try {
|
|
30
|
+
autoOpen = JSON.parse(await $.fs.read(`${root}/.doug/config.json`))?.autopilot?.autoOpenPane !== false
|
|
31
|
+
} catch {
|
|
32
|
+
// missing or unparsable config: flag on
|
|
33
|
+
}
|
|
34
|
+
const last = await read($, opened)
|
|
35
|
+
if (state && shouldOpenPane(state, last, autoOpen, new Date())) {
|
|
36
|
+
await update($, opened, () => state.startedAt as string)
|
|
37
|
+
await $.ui.open({ id: PANE, title: 'Autopilot' })
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// a failed poll keeps the last drawing
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const register: Register = on => {
|
|
45
|
+
on('session.start', async ($, e, next) => {
|
|
46
|
+
await $.command.register({
|
|
47
|
+
name: 'doug-autopilot',
|
|
48
|
+
description: 'Show the autopilot queue live in a pane',
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// The timer starts only in an interactive session; the event hooks run in any session (two small reads per prompt, turn end, and Bash call).
|
|
52
|
+
if (e.isInteractive) {
|
|
53
|
+
await refresh($)
|
|
54
|
+
$.clock.every(POLL_MS, () => refresh($))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return next(e)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// A reload drops the session.start timer, so these hooks also refresh the pane (card autopilot-pane-current).
|
|
61
|
+
on('prompt.submit', async ($, e, next) => {
|
|
62
|
+
await refresh($)
|
|
63
|
+
return next(e)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
on('turn.complete', async ($, e, next) => {
|
|
67
|
+
await refresh($)
|
|
68
|
+
return next(e)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
|
|
72
|
+
const ran = await next(e)
|
|
73
|
+
await refresh($)
|
|
74
|
+
return ran
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
on('command.run', { command: 'doug-autopilot' }, async $ => {
|
|
78
|
+
await $.ui.open({ id: PANE, title: 'Autopilot' })
|
|
79
|
+
return { text: 'Autopilot pane opened.' }
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
|
|
83
|
+
const { Box, Text } = $.ui.resolve(e)
|
|
84
|
+
const shown = await read($, lines)
|
|
85
|
+
return (
|
|
86
|
+
<Box flexDirection="column">
|
|
87
|
+
{shown.length === 0 && <Text dimColor>No autopilot run.</Text>}
|
|
88
|
+
{shown.map(line => (
|
|
89
|
+
<Text>{line}</Text>
|
|
90
|
+
))}
|
|
91
|
+
</Box>
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// The pure half of the autopilot view (card autopilot-live-queue): no Node imports, so the live pane's hooks module
|
|
2
|
+
// (which may import only its own files) can use it. lib/autopilot-view.mjs re-exports it and adds the file read.
|
|
3
|
+
export const AUTOPILOT_FILE = ".doug/.state/autopilot.json";
|
|
4
|
+
export const STEPS = ["research", "tester", "coder", "reviewer", "gate", "commit", "record"];
|
|
5
|
+
// A running file untouched for this long is no longer a live run (the lead died or the session closed). The lead's
|
|
6
|
+
// conservative choice where the card is silent.
|
|
7
|
+
export const INACTIVE_MS = 2 * 60 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
// The state parsed from file text, or null for invalid JSON, a wrong version, or a non-object. Never throws.
|
|
10
|
+
export function parseAutopilot(text) {
|
|
11
|
+
try {
|
|
12
|
+
const s = JSON.parse(text);
|
|
13
|
+
if (!s || typeof s !== "object" || Array.isArray(s) || s.version !== 1) return null;
|
|
14
|
+
if (s.cards !== undefined && !Array.isArray(s.cards)) return null;
|
|
15
|
+
return s;
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const hhmm = (iso) => {
|
|
22
|
+
const t = Date.parse(iso);
|
|
23
|
+
if (Number.isNaN(t)) return "";
|
|
24
|
+
const d = new Date(t);
|
|
25
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const usd = (n) => (typeof n === "number" ? `$${n.toFixed(2)}` : "-");
|
|
29
|
+
const num = (n, suffix = "") => (typeof n === "number" ? `${n}${suffix}` : "-");
|
|
30
|
+
|
|
31
|
+
// The view of a state at `now` (a Date or a millisecond count), or null for a null state.
|
|
32
|
+
export function autopilotView(state, now = new Date()) {
|
|
33
|
+
if (!state || typeof state !== "object") return null;
|
|
34
|
+
const nowMs = new Date(now).getTime();
|
|
35
|
+
const ended = state.status === "ended";
|
|
36
|
+
const updated = Date.parse(state.updatedAt);
|
|
37
|
+
const stale = !ended && !(nowMs - updated <= INACTIVE_MS);
|
|
38
|
+
const mode = ended ? "ended" : stale ? "inactive" : "running";
|
|
39
|
+
const live = mode === "running";
|
|
40
|
+
|
|
41
|
+
const cards = (Array.isArray(state.cards) ? state.cards : [])
|
|
42
|
+
.filter((c) => c && typeof c === "object")
|
|
43
|
+
.map((c) => {
|
|
44
|
+
let cardState = c.state;
|
|
45
|
+
let detail = "";
|
|
46
|
+
if (cardState === "running" && !live) {
|
|
47
|
+
cardState = "interrupted";
|
|
48
|
+
detail = "interrupted";
|
|
49
|
+
} else if (cardState === "running") {
|
|
50
|
+
detail = c.step ? `${c.step} since ${hhmm(c.stepStartedAt)}`.trim() : "started";
|
|
51
|
+
} else if (cardState === "landed") {
|
|
52
|
+
detail = c.commit ? String(c.commit) : "";
|
|
53
|
+
} else if (cardState === "stopped") {
|
|
54
|
+
detail = c.reason ? String(c.reason) : "";
|
|
55
|
+
}
|
|
56
|
+
return { id: c.id, title: c.title ?? "", size: c.size ?? "", class: c.class ?? "", state: cardState, detail };
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const runningCard = live ? (Array.isArray(state.cards) ? state.cards : []).find((c) => c && c.state === "running") : null;
|
|
60
|
+
const running = live
|
|
61
|
+
? (Array.isArray(state.cards) ? state.cards : []).filter((c) => c && c.state === "running").map((c) => ({ id: c.id, runId: c.runId ?? null }))
|
|
62
|
+
: [];
|
|
63
|
+
const current = runningCard ? { id: runningCard.id, step: runningCard.step ?? null, since: runningCard.step ? hhmm(runningCard.stepStartedAt) : null } : null;
|
|
64
|
+
|
|
65
|
+
const landed = cards.filter((c) => c.state === "landed").length;
|
|
66
|
+
const stopped = cards.filter((c) => c.state === "stopped").length;
|
|
67
|
+
const header =
|
|
68
|
+
mode === "ended"
|
|
69
|
+
? `Autopilot ended ${hhmm(state.endedAt ?? state.updatedAt)}: ${landed} landed, ${stopped} stopped`.replace(/\s+:/, ":")
|
|
70
|
+
: mode === "inactive"
|
|
71
|
+
? `Autopilot inactive since ${hhmm(state.updatedAt)}`
|
|
72
|
+
: `Autopilot running since ${hhmm(state.startedAt)}`;
|
|
73
|
+
|
|
74
|
+
const caps = state.caps && typeof state.caps === "object" ? state.caps : {};
|
|
75
|
+
const used = state.used && typeof state.used === "object" ? state.used : {};
|
|
76
|
+
return {
|
|
77
|
+
mode,
|
|
78
|
+
header,
|
|
79
|
+
cards,
|
|
80
|
+
current,
|
|
81
|
+
running,
|
|
82
|
+
caps: {
|
|
83
|
+
usd: `${usd(used.usd)} / ${usd(caps.usd)}`,
|
|
84
|
+
wall: `${num(used.wallMin)} / ${num(caps.wallMin)} min`,
|
|
85
|
+
context: `${num(used.contextPct, "%")} / ${num(caps.contextPct, "%")}`,
|
|
86
|
+
},
|
|
87
|
+
filed: (Array.isArray(state.filed) ? state.filed : []).filter((f) => f && typeof f === "object").map((f) => ({ ...f })),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Whether the live pane should open on its own: a running, still-active run whose startedAt is not the one already
|
|
92
|
+
// opened for (a closed pane is not reopened for the same run). `autoOpen` false (autopilot.autoOpenPane) turns it off.
|
|
93
|
+
export function shouldOpenPane(state, lastOpenedStartedAt, autoOpen = true, now = new Date()) {
|
|
94
|
+
if (autoOpen === false) return false;
|
|
95
|
+
if (!state || typeof state !== "object") return false;
|
|
96
|
+
if (state.status !== "running") return false;
|
|
97
|
+
if (typeof state.startedAt !== "string" || state.startedAt === "") return false;
|
|
98
|
+
if (state.startedAt === lastOpenedStartedAt) return false;
|
|
99
|
+
return autopilotView(state, now)?.mode === "running";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const MARKS = { landed: "✓", running: "▶", stopped: "✗", queued: "·", interrupted: "!" };
|
|
103
|
+
|
|
104
|
+
// The view as text lines for a text surface: the header, one line per card, a caps line, one line per filed card.
|
|
105
|
+
export function autopilotLines(view) {
|
|
106
|
+
if (!view) return [];
|
|
107
|
+
const lines = [view.header];
|
|
108
|
+
for (const c of view.cards) {
|
|
109
|
+
const shape = [c.size, c.class].filter(Boolean).join("/");
|
|
110
|
+
lines.push(`${MARKS[c.state] ?? "?"} ${c.id}${c.title ? ` ${c.title}` : ""}${shape ? ` (${shape})` : ""}${c.detail ? ` ${c.detail}` : ""}`);
|
|
111
|
+
}
|
|
112
|
+
lines.push(`caps: usd ${view.caps.usd}, wall ${view.caps.wall}, context ${view.caps.context}`);
|
|
113
|
+
for (const f of view.filed) {
|
|
114
|
+
lines.push(`+ ${f.id} (${f.kind}${f.from ? ` from ${f.from}` : ""}) ${f.position ?? ""}`.trimEnd());
|
|
115
|
+
}
|
|
116
|
+
return lines;
|
|
117
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The autopilot state file (.doug/.state/autopilot.json, card autopilot-live-queue) as the pane and the served board
|
|
2
|
+
// page show it. The pure view lives in autopilot-core.mjs (no Node imports, so the pane can use it); this module adds
|
|
3
|
+
// the file read. Neither throws on a missing, unreadable, or malformed file; a view of "no run" is null.
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { AUTOPILOT_FILE, parseAutopilot } from "./autopilot-core.mjs";
|
|
7
|
+
|
|
8
|
+
export { AUTOPILOT_FILE, STEPS, INACTIVE_MS, parseAutopilot, autopilotView, autopilotLines, shouldOpenPane } from "./autopilot-core.mjs";
|
|
9
|
+
|
|
10
|
+
// The parsed state, or null for a missing or unreadable file, invalid JSON, a wrong version, or a non-object.
|
|
11
|
+
export function readAutopilot(dir) {
|
|
12
|
+
try {
|
|
13
|
+
return parseAutopilot(readFileSync(join(dir, AUTOPILOT_FILE), "utf8"));
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -62,7 +62,9 @@ export function readyCapWarning(board) {
|
|
|
62
62
|
// autopilot-wall-cap-by-size, wall-budget-size-class). Each size has `all`; a class key is present only when its
|
|
63
63
|
// cell had WALL_BUDGET_MIN_N or more landings. `board.mjs budgets --from docs/live-runs.md` recomputes it.
|
|
64
64
|
export const WALL_BUDGET_MIN_N = 5;
|
|
65
|
-
|
|
65
|
+
// Source: `board.mjs budgets --from docs/live-runs.md` on 2026-10-12 over 373 cards with a Wall clock row (S 274, M 83, L 16;
|
|
66
|
+
// rehearsal entries skipped, repeated ids summed).
|
|
67
|
+
export const WALL_BUDGET_MIN = {"S":{"all":52,"code":46,"docs":29,"gate-script":112,"prose":41,"tests-only":66},"M":{"all":106,"code":90,"gate-script":157},"L":{"all":170}};
|
|
66
68
|
|
|
67
69
|
// A card's budget: its size's class cell when it has one, else the size's `all`. No or an unknown size counts as M.
|
|
68
70
|
export function cardBudgetMinutes(card, table = WALL_BUDGET_MIN) {
|
|
@@ -78,8 +80,9 @@ export function wallCapMinutes(cards, table = WALL_BUDGET_MIN) {
|
|
|
78
80
|
|
|
79
81
|
// The landings in a docs/live-runs.md text: one { id, minutes } per `## <date>, <id>: <title>` section that has a
|
|
80
82
|
// `| Wall clock | <n> min |` row (text after min is allowed; a decimal n is skipped) (the first such row). Older multi-column tables have no such row and are skipped.
|
|
83
|
+
// Rehearsal entries are skipped, and a card with several entries is one sample, their sum.
|
|
81
84
|
export function parseLiveRunWalls(text) {
|
|
82
|
-
const
|
|
85
|
+
const byId = new Map();
|
|
83
86
|
let id = null;
|
|
84
87
|
let seen = false;
|
|
85
88
|
for (const line of String(text).split("\n")) {
|
|
@@ -90,13 +93,18 @@ export function parseLiveRunWalls(text) {
|
|
|
90
93
|
continue;
|
|
91
94
|
}
|
|
92
95
|
if (id === null || seen) continue;
|
|
96
|
+
// A rehearsal entry (the fixture's run, not this repository's) is no landing of the card.
|
|
97
|
+
if (/^Rehearsal /.test(line)) {
|
|
98
|
+
id = null;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
93
101
|
const w = /^\| Wall clock \| (\d+) min(?:\s[^|]*)? \|\s*$/.exec(line);
|
|
94
102
|
if (w) {
|
|
95
|
-
|
|
103
|
+
byId.set(id, (byId.get(id) ?? 0) + Number(w[1]));
|
|
96
104
|
seen = true;
|
|
97
105
|
}
|
|
98
106
|
}
|
|
99
|
-
return
|
|
107
|
+
return [...byId].map(([k, minutes]) => ({ id: k, minutes }));
|
|
100
108
|
}
|
|
101
109
|
|
|
102
110
|
// The table recomputed from samples joined to their cards: p80 (sorted ascending, index min(floor(0.8 n), n - 1)) per size and per size/class cell. A
|
|
@@ -126,6 +134,71 @@ export function wallBudgetTable(samples, board, { minN = WALL_BUDGET_MIN_N, fall
|
|
|
126
134
|
return { table, cells };
|
|
127
135
|
}
|
|
128
136
|
|
|
137
|
+
// USD budget per card in dollars, a size x class table at p80 of measured cost over flow and hand landings (card
|
|
138
|
+
// usd-budget-size-class), shaped like WALL_BUDGET_MIN. Each size has `all`; a class key is present only when its cell
|
|
139
|
+
// had USD_BUDGET_MIN_N or more landings with a Cost row. `board.mjs budgets --usd --from docs/live-runs.md`
|
|
140
|
+
// recomputes it. There is no floor: the autopilot USD cap is the sum of these over the queue.
|
|
141
|
+
export const USD_BUDGET_MIN_N = 5;
|
|
142
|
+
// Source: `board.mjs budgets --usd --from docs/live-runs.md` on 2026-10-11 over 19 cards with a measured Cost (rehearsal entries
|
|
143
|
+
// skipped, repeated ids summed): S 5, M 10, L 4, so no class cell reached USD_BUDGET_MIN_N and each size is its all-class p80.
|
|
144
|
+
// Every row so far is a flow entry, since hand entries only gain a Cost row with this card. L rests on 4 cards.
|
|
145
|
+
export const USD_BUDGET = {"S":{"all":3.32},"M":{"all":15.79},"L":{"all":14.51}};
|
|
146
|
+
|
|
147
|
+
// A card's USD budget: its size's class cell when it has one, else the size's `all`. No or an unknown size counts as M.
|
|
148
|
+
export function cardBudgetDollars(card, table = USD_BUDGET) {
|
|
149
|
+
const size = card && (card.size === "S" || card.size === "M" || card.size === "L") ? card.size : "M";
|
|
150
|
+
const row = table[size];
|
|
151
|
+
const cls = card && card.class;
|
|
152
|
+
return typeof cls === "string" && Object.prototype.hasOwnProperty.call(row, cls) && cls !== "all" ? row[cls] : row.all;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function usdCapDollars(cards, table = USD_BUDGET) {
|
|
156
|
+
return cards.reduce((sum, c) => sum + cardBudgetDollars(c, table), 0);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The measured costs in a docs/live-runs.md text: one { id, usd } per `## <date>, <id>: <title>` section that has a
|
|
160
|
+
// `| Cost | $<n> |` row (text after the amount is allowed; the first such row; `not measured` is skipped). Flow and hand
|
|
161
|
+
// entries share the row. Rehearsal entries are skipped, and a card with several entries is one sample, their sum.
|
|
162
|
+
export function parseLiveRunCosts(text) {
|
|
163
|
+
const byId = new Map();
|
|
164
|
+
let id = null;
|
|
165
|
+
let seen = false;
|
|
166
|
+
let tableSeen = false;
|
|
167
|
+
for (const line of String(text).split("\n")) {
|
|
168
|
+
if (line.startsWith("## ")) {
|
|
169
|
+
const m = /^## \d{4}-\d{2}-\d{2}, ([^:\s]+): /.exec(line);
|
|
170
|
+
id = m ? m[1] : null;
|
|
171
|
+
seen = false;
|
|
172
|
+
tableSeen = false;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (id === null || seen) continue;
|
|
176
|
+
// A rehearsal entry (the fixture's run, not this repository's) is no landing of the card.
|
|
177
|
+
if (/^Rehearsal /.test(line)) {
|
|
178
|
+
id = null;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (line.trim() !== "" && !line.startsWith("|") && tableSeen) {
|
|
182
|
+
// prose after the section's table ends the landing: a later Cost row belongs to no landing
|
|
183
|
+
id = null;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (line.startsWith("|")) tableSeen = true;
|
|
187
|
+
const c = /^\| Cost \| \$(\d+(?:\.\d+)?)(?:\s[^|]*)? \|\s*$/.exec(line);
|
|
188
|
+
if (c) {
|
|
189
|
+
byId.set(id, Math.round(((byId.get(id) ?? 0) + Number(c[1])) * 100) / 100);
|
|
190
|
+
seen = true;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return [...byId].map(([k, usd]) => ({ id: k, usd }));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// usdBudgetTable is wallBudgetTable's twin over { id, usd } samples: p80 per size and per size/class cell, a class
|
|
197
|
+
// cell under minN left out, an empty size keeping the fallback's `all`.
|
|
198
|
+
export function usdBudgetTable(samples, board, { minN = USD_BUDGET_MIN_N, fallback = USD_BUDGET } = {}) {
|
|
199
|
+
return wallBudgetTable(samples.map((s) => ({ id: s.id, minutes: s.usd })), board, { minN, fallback });
|
|
200
|
+
}
|
|
201
|
+
|
|
129
202
|
function today() {
|
|
130
203
|
return new Date().toISOString().slice(0, 10);
|
|
131
204
|
}
|
|
@@ -707,7 +780,14 @@ function wallRow(wall, flags) {
|
|
|
707
780
|
return `within budget (${wall.wallMinutes ?? wallMin(longest)} min a task)${unverified}`;
|
|
708
781
|
}
|
|
709
782
|
|
|
710
|
-
|
|
783
|
+
// The first prose line of a rehearsal entry. The start scenario runs in an empty git repo; every other scenario
|
|
784
|
+
// (start-feature included) on the ts-basic fixture, the rule rehearse-lib's rehearsalNote uses.
|
|
785
|
+
function rehearsalLine(rehearsal, cardId) {
|
|
786
|
+
const where = rehearsal === "start" ? "an empty git repo" : "the ts-basic fixture";
|
|
787
|
+
return `Rehearsal ${rehearsal} for card \`${cardId}\` on ${where} (scripts/rehearse.mjs); the commit and gate below are the fixture's, not this repository's.`;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
export function runEntry({ card, report, cost = null, codexCost = null, wallClock = null, wall = null, mergeCommit = null, record = null, adversary = [], date = new Date().toISOString().slice(0, 10), batch = null, sharedCost = null, taskIds = [], note = null, rehearsal = null, rehearsalOutcome = null }) {
|
|
711
791
|
const inBatch = Array.isArray(batch) && batch.length > 1;
|
|
712
792
|
if (inBatch) report = filterReportForCard(report, card.id, taskIds);
|
|
713
793
|
const lines = [];
|
|
@@ -716,13 +796,13 @@ export function runEntry({ card, report, cost = null, codexCost = null, wallCloc
|
|
|
716
796
|
const batchNote = inBatch ? `, in one plan with ${listOthers(batch, card.id)} (one approval, one run, one landing)` : "";
|
|
717
797
|
lines.push(
|
|
718
798
|
rehearsal
|
|
719
|
-
?
|
|
799
|
+
? rehearsalLine(rehearsal, card.id)
|
|
720
800
|
: `Ran through /doug-next on this repo from card \`${card.id}\`${record ? ` in \`${record}\`` : ""}${batchNote}. Plan "${report.plan}", integration branch \`${report.integrationBranch}\`${report.modelsSource ? `, models from ${report.modelsSource}` : ""}.`,
|
|
721
801
|
);
|
|
722
802
|
lines.push("");
|
|
723
803
|
lines.push("| Measure | Value |");
|
|
724
804
|
lines.push("|---|---|");
|
|
725
|
-
lines.push(`| Outcome | ${outcomeOf(report)}${pausedAt(report) ? `; next: ${listNext(report)}` : ""} |`);
|
|
805
|
+
lines.push(`| Outcome | ${rehearsal ? `rehearsal ${rehearsal} ${rehearsalOutcome}` : outcomeOf(report)}${!rehearsal && pausedAt(report) ? `; next: ${listNext(report)}` : ""} |`);
|
|
726
806
|
lines.push(`| Cost | ${cost === null ? "not measured" : `$${Number(cost).toFixed(2)}${inBatch ? " for this card's tasks" : ""}`} |`);
|
|
727
807
|
if (!inBatch || batch[0] === card.id) {
|
|
728
808
|
lines.push(
|
|
@@ -794,22 +874,23 @@ export function handWallVerdict(card, wallClock, table = WALL_BUDGET_MIN) {
|
|
|
794
874
|
|
|
795
875
|
// The live-runs entry for a hand-track card (decision 0005): no workflow report and no levels, so it records what
|
|
796
876
|
// doug-hand can measure: the landing commit, the wall clock, and the gate result, plus an optional note.
|
|
797
|
-
export function handEntry({ card, commit = null, wallClock = null, gate = null, note = null, record = null, date = new Date().toISOString().slice(0, 10), rehearsal = null, table = WALL_BUDGET_MIN }) {
|
|
877
|
+
export function handEntry({ card, commit = null, wallClock = null, gate = null, note = null, record = null, date = new Date().toISOString().slice(0, 10), rehearsal = null, rehearsalOutcome = null, table = WALL_BUDGET_MIN, cost = null }) {
|
|
798
878
|
const lines = [];
|
|
799
879
|
lines.push(`## ${date}, ${card.id}: ${card.title}`);
|
|
800
880
|
lines.push("");
|
|
801
881
|
lines.push(
|
|
802
882
|
rehearsal
|
|
803
|
-
?
|
|
883
|
+
? rehearsalLine(rehearsal, card.id)
|
|
804
884
|
: `Built by hand through doug-hand from card \`${card.id}\`${record ? ` in \`${record}\`` : ""} (hand track: a gated by-hand change); no workflow run.`,
|
|
805
885
|
);
|
|
806
886
|
lines.push("");
|
|
807
887
|
lines.push("| Measure | Value |");
|
|
808
888
|
lines.push("|---|---|");
|
|
809
|
-
lines.push(`| Outcome | ${commit ? "landed" : "not landed"} |`);
|
|
889
|
+
lines.push(`| Outcome | ${rehearsal ? `rehearsal ${rehearsal} ${rehearsalOutcome}` : commit ? "landed" : "not landed"} |`);
|
|
810
890
|
const shownCommit = rehearsal && commit ? String(commit).slice(0, 7) : commit;
|
|
811
891
|
lines.push(`| Commit | ${shownCommit ? `\`${shownCommit}\`` : "none"} |`);
|
|
812
892
|
lines.push(`| Wall clock | ${wallClock === null ? "not measured" : wallClock} |`);
|
|
893
|
+
if (cost !== null && cost !== undefined) lines.push(`| Cost | $${Number(cost).toFixed(2)} |`);
|
|
813
894
|
if (!rehearsal) lines.push(`| Wall budget | ${handWallVerdict(card, wallClock, table).text} |`);
|
|
814
895
|
lines.push(`| Gate | ${gate === null ? "not recorded" : gate} |`);
|
|
815
896
|
lines.push("");
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// landCard: land a hand card's branch (card parallel-card-queens B6, ADR 0015). The card's worktree is rebased on the
|
|
2
|
+
// base branch, the rebased tree is gated through the vendored verify.mjs (--json; its gate.lock is shared by every
|
|
3
|
+
// card), main fast-forwards to the branch, and only the card's own worktree is removed. A refusal returns
|
|
4
|
+
// { ok: false, reason } with main untouched; it never throws (landPlan's contract, lib/land.mjs).
|
|
5
|
+
//
|
|
6
|
+
// 1. the base branch is the one checked out in dir, and no tracked file outside .doug/ is modified
|
|
7
|
+
// 2. no commit on the branch since the base carries a Co-Authored-By or Claude-Session line
|
|
8
|
+
// 3. git rebase <base> in the worktree; a conflict is aborted and refused as "rebase-conflict: <files>", any
|
|
9
|
+
// other rebase failure is refused under its own reason
|
|
10
|
+
// 4. gate: .doug/hooks/scripts/verify.mjs --json with its cwd at the worktree; red refuses, nothing merges
|
|
11
|
+
// 5. git merge --ff-only <branch> in dir
|
|
12
|
+
// 6. removeFinishedWorktrees(dir, { branches: [branch] }) only, never mergedInto
|
|
13
|
+
|
|
14
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { commandEnv, trailerCommits, vendoredVerifySpawnTimeoutMs } from "./land.mjs";
|
|
18
|
+
import { removeFinishedWorktrees } from "./worktrees.mjs";
|
|
19
|
+
|
|
20
|
+
function git(dir, args) {
|
|
21
|
+
return execFileSync("git", args, { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 60000 }).trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function tryGit(dir, args) {
|
|
25
|
+
try {
|
|
26
|
+
return git(dir, args);
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function tail(text, lines = 30) {
|
|
33
|
+
return String(text || "").trimEnd().split("\n").slice(-lines).join("\n");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const GATE_TIMEOUT_MS = 600000;
|
|
37
|
+
|
|
38
|
+
// Returns { ok: true, commit, gateLine, lockWaitMs, removed, kept } or { ok: false, reason }.
|
|
39
|
+
export function landCard(dir, { id, branch, worktree, base = "main" } = {}) {
|
|
40
|
+
try {
|
|
41
|
+
return land(dir, { id, branch, worktree, base });
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return { ok: false, reason: `land-card failed: ${tail((err && (err.stderr || err.message)) || err, 5)}` };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function land(dir, { id, branch, worktree, base }) {
|
|
48
|
+
if (!branch || !worktree) return { ok: false, reason: "land-card needs a branch and a worktree" };
|
|
49
|
+
if (tryGit(dir, ["rev-parse", "--verify", "-q", `refs/heads/${branch}`]) === null) return { ok: false, reason: `branch ${branch} does not exist; nothing to land` };
|
|
50
|
+
if (!existsSync(worktree)) return { ok: false, reason: `worktree ${worktree} does not exist` };
|
|
51
|
+
if (tryGit(worktree, ["symbolic-ref", "--short", "-q", "HEAD"]) !== branch) return { ok: false, reason: `worktree ${worktree} does not have ${branch} checked out` };
|
|
52
|
+
|
|
53
|
+
const current = tryGit(dir, ["symbolic-ref", "--short", "-q", "HEAD"]);
|
|
54
|
+
if (!current) return { ok: false, reason: "detached HEAD; check out the base branch first" };
|
|
55
|
+
if (current !== base) return { ok: false, reason: `base branch is ${base} but ${current} is checked out; check out ${base} first (land never switches your branch)` };
|
|
56
|
+
|
|
57
|
+
// Not through git(): its trim() would eat the leading status column of the first line.
|
|
58
|
+
const dirty = execFileSync("git", ["status", "--porcelain", "--untracked-files=no"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
|
|
59
|
+
.split("\n")
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.map((l) => l.slice(3).replace(/^"|"$/g, ""))
|
|
62
|
+
.filter((p) => !p.startsWith(".doug/"));
|
|
63
|
+
if (dirty.length) return { ok: false, reason: `tracked files are modified: ${dirty.join(", ")}; commit or stash them first` };
|
|
64
|
+
|
|
65
|
+
const trailered = trailerCommits(dir, base, branch);
|
|
66
|
+
if (trailered.length) return { ok: false, reason: `commits on ${branch} carry attribution trailers (Co-Authored-By or Claude-Session), which this repository does not record; rewrite them first:\n${trailered.map((c) => ` ${c.sha} ${c.subject} (${c.trailers.join(", ")})`).join("\n")}` };
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
git(worktree, ["rebase", base]);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
const files = (tryGit(worktree, ["diff", "--name-only", "--diff-filter=U"]) || "").split("\n").filter(Boolean);
|
|
72
|
+
if (files.length) {
|
|
73
|
+
tryGit(worktree, ["rebase", "--abort"]);
|
|
74
|
+
return { ok: false, reason: `rebase-conflict: ${files.join(", ")}` };
|
|
75
|
+
}
|
|
76
|
+
tryGit(worktree, ["rebase", "--abort"]);
|
|
77
|
+
return { ok: false, reason: `rebase-failed: ${tail(err.stderr || err.message, 3)}` };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const vendored = join(dir, ".doug/hooks/scripts/verify.mjs");
|
|
81
|
+
if (!existsSync(vendored)) return { ok: false, reason: `gate not run: ${vendored} does not exist` };
|
|
82
|
+
const r = spawnSync(process.execPath, [vendored, "--json", "--by", "land-card", "--cwd", worktree, "--record-dir", dir, "--timeout-ms", String(GATE_TIMEOUT_MS)], {
|
|
83
|
+
cwd: worktree,
|
|
84
|
+
encoding: "utf8",
|
|
85
|
+
timeout: vendoredVerifySpawnTimeoutMs(GATE_TIMEOUT_MS, 2),
|
|
86
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
87
|
+
env: commandEnv(),
|
|
88
|
+
});
|
|
89
|
+
let out = null;
|
|
90
|
+
try {
|
|
91
|
+
out = JSON.parse(String(r.stdout || "").trim().split("\n").pop());
|
|
92
|
+
} catch {
|
|
93
|
+
out = null;
|
|
94
|
+
}
|
|
95
|
+
if (!out || !Array.isArray(out.results) || out.results.length === 0) {
|
|
96
|
+
const why = r.error && r.error.code === "ETIMEDOUT" ? "timed out" : "gave no readable result";
|
|
97
|
+
return { ok: false, reason: `gate failed on ${branch}: the vendored verify.mjs ${why}` };
|
|
98
|
+
}
|
|
99
|
+
const gateLine = "gate: " + out.results.map((x) => `${x.name || x.command} ${x.ok === true ? "ok" : `exit ${x.status}`}`).join("; ");
|
|
100
|
+
const lockWaitMs = typeof out.lockWaitMs === "number" ? out.lockWaitMs : 0;
|
|
101
|
+
if (out.ok !== true) {
|
|
102
|
+
const bad = out.results.find((x) => x.ok !== true) || out.results[0];
|
|
103
|
+
return { ok: false, reason: `gate red on ${branch}: \`${bad.command}\` (${bad.timedOut ? "timed out" : `exit ${bad.status}`})\n${bad.outputTail || ""}`, gateLine, lockWaitMs };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
git(dir, ["merge", "--ff-only", branch]);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
tryGit(dir, ["merge", "--abort"]);
|
|
110
|
+
return { ok: false, reason: `fast-forward of ${base} to ${branch} failed:\n${tail((err.stdout || "") + "\n" + (err.stderr || ""), 10)}`, gateLine, lockWaitMs };
|
|
111
|
+
}
|
|
112
|
+
const commit = git(dir, ["rev-parse", "--short", "HEAD"]);
|
|
113
|
+
const wt = removeFinishedWorktrees(dir, { branches: [branch] });
|
|
114
|
+
return { ok: true, id, commit, gateLine, lockWaitMs, removed: wt.removed, kept: wt.kept };
|
|
115
|
+
}
|