bossbuild 0.97.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/LICENSE +21 -0
- package/PRINCIPLES.md +70 -0
- package/README.md +213 -0
- package/VERSION +1 -0
- package/bin/boss +3 -0
- package/library/README.md +19 -0
- package/library/agents/.gitkeep +0 -0
- package/library/agents/mentor-venture.md +57 -0
- package/library/hooks/.gitkeep +0 -0
- package/library/hooks/auto-log.js +133 -0
- package/library/hooks/memory-cue.js +82 -0
- package/library/hooks/secrets-guard.js +87 -0
- package/library/memory-seed/README.md +29 -0
- package/library/memory-seed/durable-facts-example.md +16 -0
- package/library/practices/.gitkeep +0 -0
- package/library/practices/agent-security.md +111 -0
- package/library/practices/ai-adoption-culture.md +104 -0
- package/library/practices/ai-ux-patterns.md +246 -0
- package/library/practices/celebration-of-done.md +100 -0
- package/library/practices/conscience-voicing.md +121 -0
- package/library/practices/context-discipline.md +116 -0
- package/library/practices/design-system.md +152 -0
- package/library/practices/git-workflow.md +119 -0
- package/library/practices/harm-taxonomy.md +45 -0
- package/library/practices/quality-ratchet.md +48 -0
- package/library/practices/revalidation.md +57 -0
- package/library/practices/scalable-architecture.md +111 -0
- package/library/practices/ship-it-live.md +149 -0
- package/library/practices/skill-authoring.md +70 -0
- package/library/skills/.gitkeep +0 -0
- package/library/skills/boss-learn/SKILL.md +63 -0
- package/library/skills/boss-sync/SKILL.md +48 -0
- package/package.json +49 -0
- package/registry/CHANGELOG.md +2737 -0
- package/src/board.js +655 -0
- package/src/brain.js +288 -0
- package/src/cli.js +542 -0
- package/src/conscience.js +426 -0
- package/src/insights.js +147 -0
- package/src/learn.js +92 -0
- package/src/map.js +103 -0
- package/src/modes.js +82 -0
- package/src/paths.js +36 -0
- package/src/registry.js +34 -0
- package/src/scaffold.js +138 -0
- package/src/sync.js +292 -0
- package/src/team.js +103 -0
- package/stages/L0-quickstart/manifest.json +12 -0
- package/stages/L0-quickstart/template/.claude/agents/coder-generalist.md +31 -0
- package/stages/L0-quickstart/template/.claude/agents/mentor-venture.md +57 -0
- package/stages/L0-quickstart/template/.claude/agents/pm.md +28 -0
- package/stages/L0-quickstart/template/.claude/hooks/conscience.js +89 -0
- package/stages/L0-quickstart/template/.claude/hooks/lib/loop-runtime.js +507 -0
- package/stages/L0-quickstart/template/.claude/hooks/lib/yaml.js +163 -0
- package/stages/L0-quickstart/template/.claude/hooks/memory-cue.js +82 -0
- package/stages/L0-quickstart/template/.claude/hooks/secrets-guard.js +87 -0
- package/stages/L0-quickstart/template/.claude/rules/your-app-code.md +17 -0
- package/stages/L0-quickstart/template/.claude/settings.json +36 -0
- package/stages/L0-quickstart/template/.claude/skills/boss/SKILL.md +161 -0
- package/stages/L0-quickstart/template/.claude/skills/boss-learn/SKILL.md +63 -0
- package/stages/L0-quickstart/template/.claude/skills/boss-sync/SKILL.md +55 -0
- package/stages/L0-quickstart/template/.claude/skills/canvas/SKILL.md +112 -0
- package/stages/L0-quickstart/template/.claude/skills/comprehend/SKILL.md +72 -0
- package/stages/L0-quickstart/template/.claude/skills/decide/SKILL.md +122 -0
- package/stages/L0-quickstart/template/.claude/skills/feedback/SKILL.md +68 -0
- package/stages/L0-quickstart/template/.claude/skills/import/SKILL.md +73 -0
- package/stages/L0-quickstart/template/.claude/skills/persona/SKILL.md +92 -0
- package/stages/L0-quickstart/template/.claude/skills/prototype/SKILL.md +114 -0
- package/stages/L0-quickstart/template/.claude/skills/triage/SKILL.md +104 -0
- package/stages/L0-quickstart/template/.claude/skills/welcome/SKILL.md +262 -0
- package/stages/L0-quickstart/template/AGENTS.md +31 -0
- package/stages/L0-quickstart/template/CLAUDE.md +57 -0
- package/stages/L0-quickstart/template/docs/IDS.md +42 -0
- package/stages/L0-quickstart/template/docs/ideas/INDEX.md +24 -0
- package/stages/L0-quickstart/template/docs/loops/canvas-loop.md +90 -0
- package/stages/L0-quickstart/template/docs/loops/capture-loop.md +64 -0
- package/stages/L1-mvp/manifest.json +12 -0
- package/stages/L1-mvp/template/.claude/agents/mentor-architect.md +124 -0
- package/stages/L1-mvp/template/.claude/agents/mentor-cofounder.md +85 -0
- package/stages/L1-mvp/template/.claude/agents/mentor-gtm.md +49 -0
- package/stages/L1-mvp/template/.claude/agents/program-manager.md +46 -0
- package/stages/L1-mvp/template/.claude/agents/tester.md +42 -0
- package/stages/L1-mvp/template/.claude/hooks/auto-log.js +133 -0
- package/stages/L1-mvp/template/.claude/rules/feature-context.md +18 -0
- package/stages/L1-mvp/template/.claude/skills/ai-cost/SKILL.md +249 -0
- package/stages/L1-mvp/template/.claude/skills/ai-failure-states/SKILL.md +226 -0
- package/stages/L1-mvp/template/.claude/skills/ai-first-init/SKILL.md +227 -0
- package/stages/L1-mvp/template/.claude/skills/close/SKILL.md +170 -0
- package/stages/L1-mvp/template/.claude/skills/consult/SKILL.md +72 -0
- package/stages/L1-mvp/template/.claude/skills/cost-review/SKILL.md +204 -0
- package/stages/L1-mvp/template/.claude/skills/design-tokens-init/SKILL.md +192 -0
- package/stages/L1-mvp/template/.claude/skills/drift-deep/SKILL.md +170 -0
- package/stages/L1-mvp/template/.claude/skills/evals/SKILL.md +154 -0
- package/stages/L1-mvp/template/.claude/skills/extract/SKILL.md +209 -0
- package/stages/L1-mvp/template/.claude/skills/judge-traces/SKILL.md +68 -0
- package/stages/L1-mvp/template/.claude/skills/log/SKILL.md +64 -0
- package/stages/L1-mvp/template/.claude/skills/practice/SKILL.md +92 -0
- package/stages/L1-mvp/template/.claude/skills/pretotype/SKILL.md +95 -0
- package/stages/L1-mvp/template/.claude/skills/red-team/SKILL.md +137 -0
- package/stages/L1-mvp/template/.claude/skills/revalidate/SKILL.md +51 -0
- package/stages/L1-mvp/template/.claude/skills/ship/SKILL.md +105 -0
- package/stages/L1-mvp/template/.claude/skills/smoke/SKILL.md +43 -0
- package/stages/L1-mvp/template/.claude/skills/spec/SKILL.md +145 -0
- package/stages/L1-mvp/template/claude-append.md +122 -0
- package/stages/L1-mvp/template/docs/loops/ai-failure-state-loop.md +107 -0
- package/stages/L1-mvp/template/docs/loops/coordination-loop.md +116 -0
- package/stages/L1-mvp/template/docs/loops/cost-budget-loop.md +117 -0
- package/stages/L1-mvp/template/docs/loops/cost-review-loop.md +113 -0
- package/stages/L1-mvp/template/docs/loops/design-tokens-loop.md +98 -0
- package/stages/L1-mvp/template/docs/loops/drift-loop.md +149 -0
- package/stages/L1-mvp/template/docs/loops/extraction-loop.md +128 -0
- package/stages/L1-mvp/template/docs/loops/focus-loop.md +106 -0
- package/stages/L1-mvp/template/docs/loops/pretotype-loop.md +88 -0
- package/stages/L1-mvp/template/docs/loops/spec-loop.md +83 -0
- package/stages/L2-v1/manifest.json +12 -0
- package/stages/L2-v1/template/.claude/agents/db-architect.md +91 -0
- package/stages/L2-v1/template/.claude/agents/mentor-business.md +124 -0
- package/stages/L2-v1/template/.claude/agents/mentor-fundraising.md +72 -0
- package/stages/L2-v1/template/.claude/agents/mentor-pitch.md +84 -0
- package/stages/L2-v1/template/.claude/agents/mentor-talent.md +84 -0
- package/stages/L2-v1/template/.claude/agents/ui-designer.md +81 -0
- package/stages/L2-v1/template/.claude/agents/ux-designer.md +87 -0
- package/stages/L2-v1/template/.claude/skills/board/SKILL.md +98 -0
- package/stages/L2-v1/template/.claude/skills/design-review/SKILL.md +77 -0
- package/stages/L2-v1/template/.claude/skills/ux-check/SKILL.md +93 -0
- package/stages/L2-v1/template/claude-append.md +59 -0
- package/stages/L2-v1/template/docs/loops/design-drift-loop.md +108 -0
- package/stages/L3-scale/README.md +13 -0
package/src/board.js
ADDED
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
// boss board — a live read of what's in flight, derived entirely from files
|
|
2
|
+
// that already exist. NOT a maintained document: the founder never edits the
|
|
3
|
+
// board, they change the work (IDEA/FEAT/canvas) and the board re-renders.
|
|
4
|
+
//
|
|
5
|
+
// Design decisions (IDEA-015), held deliberately:
|
|
6
|
+
// - Frontmatter is truth. We read each IDEA-*/FEAT-* file's `status`, never
|
|
7
|
+
// docs/ideas/INDEX.md — INDEX is itself a hand-maintained table that can
|
|
8
|
+
// drift from the files. A board that trusts a drifting source lies.
|
|
9
|
+
// - Pure projection. No `.boss/board.json`, no second source of truth, nothing
|
|
10
|
+
// to keep in sync. Concurrent / out-of-order edits can't corrupt a render.
|
|
11
|
+
// - The riskiest assumption sits ABOVE the columns (humane-lens override):
|
|
12
|
+
// "motion but no evidence" must be more visible here than in a normal kanban,
|
|
13
|
+
// not less. Empty columns are shown, not hidden — the empty cell is the
|
|
14
|
+
// diagnostic.
|
|
15
|
+
|
|
16
|
+
import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
// The flow, left to right. BOSS's own vocabulary, surfaced as plain words.
|
|
20
|
+
const COLUMNS = ['Captured', 'Taking shape', 'Building', 'Shipped'];
|
|
21
|
+
|
|
22
|
+
// A FEAT in Building past this many days is "aging" — the zombie-feature smell
|
|
23
|
+
// (IDEA-034 Track B). Frontmatter-true (reads `building_since:`), never guessed.
|
|
24
|
+
const AGING_DAYS = 21;
|
|
25
|
+
|
|
26
|
+
// The Shipped column is otherwise unbounded — every shipped item shows forever,
|
|
27
|
+
// which buries the live work under history. Two honest archive rules combine:
|
|
28
|
+
// - DATE: a FEAT with a `shipped_on:` older than SHIPPED_WINDOW_DAYS is archived
|
|
29
|
+
// (folded), regardless of count — frontmatter-true, the real "older than a
|
|
30
|
+
// month" filter (stamped by /spec when status → shipped).
|
|
31
|
+
// - COUNT: among the still-recent (and any undated legacy) shipped items, show at
|
|
32
|
+
// most SHIPPED_RECENT so the column stays bounded even before dates exist.
|
|
33
|
+
// Everything folded collapses into a "+N shipped earlier" line; `--all` expands it.
|
|
34
|
+
const SHIPPED_RECENT = 6;
|
|
35
|
+
const SHIPPED_WINDOW_DAYS = 30;
|
|
36
|
+
|
|
37
|
+
// Parse the first `--- ... ---` frontmatter block. Zero-dep, tolerant of the
|
|
38
|
+
// flat `key: value` frontmatter BOSS's templates use.
|
|
39
|
+
function frontmatter(text) {
|
|
40
|
+
const m = text.match(/^---\n([\s\S]*?)\n---/);
|
|
41
|
+
if (!m) return {};
|
|
42
|
+
const out = {};
|
|
43
|
+
for (const line of m[1].split('\n')) {
|
|
44
|
+
const i = line.indexOf(':');
|
|
45
|
+
if (i === -1) continue;
|
|
46
|
+
const k = line.slice(0, i).trim();
|
|
47
|
+
if (k) out[k] = line.slice(i + 1).trim();
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function firstHeading(text) {
|
|
53
|
+
const m = text.match(/^#\s+(.+)$/m);
|
|
54
|
+
return m ? m[1].trim() : '';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Owner-as-person (founder layer slice 2b, IDEA-037/FEAT-021): only a `@handle`
|
|
58
|
+
// counts as a founder owner for the team lens — role owners (`pm`) and blanks are
|
|
59
|
+
// ignored here. This is provenance (who's the DRI), surfaced ONLY when it's a team;
|
|
60
|
+
// deliberately NOT aggregated into a per-person count (that's the credit-score line
|
|
61
|
+
// mentor-humane drew — provenance, never a leaderboard).
|
|
62
|
+
const personOwner = (o) => {
|
|
63
|
+
// Strip surrounding quotes first — a leading `@` is reserved in YAML, so the
|
|
64
|
+
// convention writes `owner: "@handle"` (quoted). Both forms resolve the same.
|
|
65
|
+
const v = (o || '').trim().replace(/^["']|["']$/g, '');
|
|
66
|
+
return v.startsWith('@') ? v : null;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// A title we'd actually want on a card. Drops template placeholders
|
|
70
|
+
// ("<Title — one plain line>") and a leading "FEAT-NNN —" if the heading
|
|
71
|
+
// repeats the id. Falls back to the id.
|
|
72
|
+
function cardTitle(heading, id) {
|
|
73
|
+
let t = (heading || '').trim();
|
|
74
|
+
if (!t || t.startsWith('<')) return id;
|
|
75
|
+
t = t.replace(/^(IDEA|FEAT|EXTR)-\d+\s*[—:-]\s*/i, '').trim();
|
|
76
|
+
if (t.length > 52) t = t.slice(0, 51).trimEnd() + '…';
|
|
77
|
+
return t || id;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Is the canvas's riskiest assumption actually named, or still the placeholder?
|
|
81
|
+
// Mirrors the conscience hook's read: the line is
|
|
82
|
+
// `- **Riskiest assumption:** <text or _(placeholder)_>`.
|
|
83
|
+
function riskiestNamed(canvasText) {
|
|
84
|
+
const m = canvasText.match(/Riskiest assumption:\*\*\s*(.*)/);
|
|
85
|
+
if (!m) return false;
|
|
86
|
+
const v = m[1].trim();
|
|
87
|
+
if (!v) return false;
|
|
88
|
+
if (v.startsWith('_')) return false; // italic placeholder _(…)_
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function ideaColumn(status, hasRisk) {
|
|
93
|
+
const s = (status || '').toLowerCase();
|
|
94
|
+
if (s === 'shipped') return 'Shipped';
|
|
95
|
+
if (s === 'building') return 'Building'; // promoted but no FEAT file yet
|
|
96
|
+
if (hasRisk) return 'Taking shape';
|
|
97
|
+
return 'Captured';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function featColumn(status) {
|
|
101
|
+
const s = (status || '').toLowerCase();
|
|
102
|
+
if (s === 'shipped' || s === 'done') return 'Shipped';
|
|
103
|
+
return 'Building'; // building / drafting / blocked — all in-flight
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Build the card list from the project's docs/ideas directory. Returns
|
|
107
|
+
// { cards: [{id, title, column, blocked}], hasIdeasDir }.
|
|
108
|
+
export function collectBoard(projectDir) {
|
|
109
|
+
const ideasDir = join(projectDir, 'docs', 'ideas');
|
|
110
|
+
if (!existsSync(ideasDir)) return { cards: [], hasIdeasDir: false };
|
|
111
|
+
|
|
112
|
+
const files = readdirSync(ideasDir).filter((f) => f.endsWith('.md'));
|
|
113
|
+
const feats = [];
|
|
114
|
+
const ideas = [];
|
|
115
|
+
const featSources = new Set(); // IDEA ids a FEAT was promoted from
|
|
116
|
+
|
|
117
|
+
for (const f of files) {
|
|
118
|
+
if (f === 'INDEX.md' || f === 'CANVAS.md') continue;
|
|
119
|
+
if (f.includes('-canvas')) continue; // canvas files are state, not cards
|
|
120
|
+
const text = readFileSync(join(ideasDir, f), 'utf8');
|
|
121
|
+
const fm = frontmatter(text);
|
|
122
|
+
const id = fm.id || f.replace(/\.md$/, '');
|
|
123
|
+
const title = cardTitle(firstHeading(text), id);
|
|
124
|
+
const priority = (fm.priority || '').trim().toLowerCase() === 'high' ? 'high' : null;
|
|
125
|
+
if (/^FEAT/i.test(id)) {
|
|
126
|
+
if (fm.source) featSources.add(fm.source);
|
|
127
|
+
feats.push({ id, title, status: fm.status, nextReview: fm.next_review, buildingSince: fm.building_since, shippedOn: fm.shipped_on, priority, owner: fm.owner });
|
|
128
|
+
} else {
|
|
129
|
+
ideas.push({ id, title, status: fm.status, nextReview: fm.next_review, priority, owner: fm.owner });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// "Review due" is frontmatter-true, never guessed: an item carries an explicit
|
|
134
|
+
// `next_review:` date (set when it was paused / by /revalidate) that has passed.
|
|
135
|
+
// We deliberately do NOT infer staleness from age — a guessed signal would add
|
|
136
|
+
// noise the founder learns to ignore. No date → not due. (IDEA-027.)
|
|
137
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
138
|
+
const reviewDue = (nextReview, status) => {
|
|
139
|
+
const s = (status || '').toLowerCase();
|
|
140
|
+
if (s === 'shipped' || s === 'done' || s === 'killed') return false;
|
|
141
|
+
const d = (nextReview || '').trim();
|
|
142
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(d) && d <= today; // YYYY-MM-DD lexical compare
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Time-in-build aging (IDEA-034 Track B) — frontmatter-true, NEVER guessed from
|
|
146
|
+
// mtime. A FEAT that's been in Building past AGING_DAYS is the zombie-feature
|
|
147
|
+
// smell /revalidate targets. Reads `building_since:` (stamped by /spec when it
|
|
148
|
+
// sets status: building); no date → no age signal, exactly like reviewDue.
|
|
149
|
+
const todayMs = Date.parse(today);
|
|
150
|
+
const daysSince = (date) => {
|
|
151
|
+
const d = (date || '').trim();
|
|
152
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) return null;
|
|
153
|
+
const ms = Date.parse(d);
|
|
154
|
+
if (Number.isNaN(ms)) return null;
|
|
155
|
+
return Math.max(0, Math.floor((todayMs - ms) / 86400000));
|
|
156
|
+
};
|
|
157
|
+
const ageInBuild = (buildingSince, column) => (column === 'Building' ? daysSince(buildingSince) : null);
|
|
158
|
+
// How long ago a FEAT shipped (frontmatter-true, IDEA-034 follow-on). Only
|
|
159
|
+
// meaningful in the Shipped column; null when no `shipped_on:` is stamped.
|
|
160
|
+
const shippedAge = (shippedOn, column) => (column === 'Shipped' ? daysSince(shippedOn) : null);
|
|
161
|
+
|
|
162
|
+
const cards = [];
|
|
163
|
+
for (const ft of feats) {
|
|
164
|
+
const column = featColumn(ft.status);
|
|
165
|
+
const ageDays = ageInBuild(ft.buildingSince, column);
|
|
166
|
+
const shippedAgeDays = shippedAge(ft.shippedOn, column);
|
|
167
|
+
cards.push({
|
|
168
|
+
id: ft.id,
|
|
169
|
+
title: ft.title,
|
|
170
|
+
column,
|
|
171
|
+
blocked: (ft.status || '').toLowerCase() === 'blocked',
|
|
172
|
+
reviewDue: reviewDue(ft.nextReview, ft.status),
|
|
173
|
+
ageDays,
|
|
174
|
+
aging: ageDays != null && ageDays >= AGING_DAYS,
|
|
175
|
+
shippedAgeDays,
|
|
176
|
+
archived: shippedAgeDays != null && shippedAgeDays > SHIPPED_WINDOW_DAYS,
|
|
177
|
+
priority: ft.priority,
|
|
178
|
+
owner: personOwner(ft.owner),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
for (const id of ideas) {
|
|
182
|
+
// A promoted idea is represented by its FEAT card — don't double-count it.
|
|
183
|
+
if (featSources.has(id.id)) continue;
|
|
184
|
+
const hasRisk = files.includes(`${id.id}-canvas.md`)
|
|
185
|
+
&& riskiestNamed(readFileSync(join(ideasDir, `${id.id}-canvas.md`), 'utf8'));
|
|
186
|
+
cards.push({
|
|
187
|
+
id: id.id,
|
|
188
|
+
title: id.title,
|
|
189
|
+
column: ideaColumn(id.status, hasRisk),
|
|
190
|
+
blocked: false,
|
|
191
|
+
reviewDue: reviewDue(id.nextReview, id.status),
|
|
192
|
+
priority: id.priority,
|
|
193
|
+
owner: personOwner(id.owner),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Stable, readable order: by id within each column (handled at render time).
|
|
198
|
+
return { cards, hasIdeasDir: true };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The line that sits above the columns. Plain and factual — never gamified
|
|
202
|
+
// (voice-keeper). When there's motion but nothing pressure-tested, it says so:
|
|
203
|
+
// that's the humane point of the surface.
|
|
204
|
+
function evidenceLine(counts, total) {
|
|
205
|
+
if (total === 0) return 'Nothing captured yet — `/triage <thought>` starts the board.';
|
|
206
|
+
if (counts.Captured > 0 && counts['Taking shape'] === 0 && counts.Building === 0) {
|
|
207
|
+
const n = counts.Captured;
|
|
208
|
+
return `${n} captured, nothing pressure-tested yet — what would you learn first? (\`/canvas\`)`;
|
|
209
|
+
}
|
|
210
|
+
return COLUMNS
|
|
211
|
+
.map((c) => `${counts[c]} ${c.toLowerCase()}`)
|
|
212
|
+
.join(' · ');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Within-column ordering (IDEA-034 Track B + E). `priority: high` floats to the top
|
|
216
|
+
// of its column (the one explicit ordering signal — a property of the work, never a
|
|
217
|
+
// drag-to-reorder). Then: Building surfaces the longest-running first (finish what's
|
|
218
|
+
// been open longest), Shipped shows newest first, the rest keep stable id order.
|
|
219
|
+
function sortColumn(list, col) {
|
|
220
|
+
const byPriority = (a, b) => (b.priority === 'high' ? 1 : 0) - (a.priority === 'high' ? 1 : 0);
|
|
221
|
+
let tiebreak;
|
|
222
|
+
if (col === 'Building') {
|
|
223
|
+
tiebreak = (a, b) => {
|
|
224
|
+
const av = a.ageDays == null ? -1 : a.ageDays;
|
|
225
|
+
const bv = b.ageDays == null ? -1 : b.ageDays;
|
|
226
|
+
if (av !== bv) return bv - av;
|
|
227
|
+
return a.id.localeCompare(b.id, undefined, { numeric: true });
|
|
228
|
+
};
|
|
229
|
+
} else if (col === 'Shipped') {
|
|
230
|
+
// Newest-shipped first: dated items (by shipped_on age, younger first) ahead of
|
|
231
|
+
// undated legacy items, then id-desc as the proxy when no date exists.
|
|
232
|
+
tiebreak = (a, b) => {
|
|
233
|
+
const aa = a.shippedAgeDays, bb = b.shippedAgeDays;
|
|
234
|
+
if (aa != null && bb != null) return aa - bb;
|
|
235
|
+
if (aa != null) return -1;
|
|
236
|
+
if (bb != null) return 1;
|
|
237
|
+
return b.id.localeCompare(a.id, undefined, { numeric: true });
|
|
238
|
+
};
|
|
239
|
+
} else {
|
|
240
|
+
tiebreak = (a, b) => a.id.localeCompare(b.id, undefined, { numeric: true });
|
|
241
|
+
}
|
|
242
|
+
return [...list].sort((a, b) => byPriority(a, b) || tiebreak(a, b));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Decide which Shipped cards to show vs. fold. Archives anything dated older than
|
|
246
|
+
// the window (frontmatter-true), then caps the still-recent (+ undated legacy) to
|
|
247
|
+
// SHIPPED_RECENT so the column stays bounded. Returns { shown, hidden }. `--all`
|
|
248
|
+
// (showAll) reveals everything. Expects `sorted` already in newest-first order.
|
|
249
|
+
function shippedView(sorted, showAll) {
|
|
250
|
+
if (showAll) return { shown: sorted, hidden: 0 };
|
|
251
|
+
const live = sorted.filter((c) => !c.archived); // not date-archived
|
|
252
|
+
const shown = live.slice(0, SHIPPED_RECENT);
|
|
253
|
+
return { shown, hidden: sorted.length - shown.length };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Days → a compact "3w" / "5d" age string for the in-build flag.
|
|
257
|
+
function ageLabel(days) {
|
|
258
|
+
const w = Math.floor(days / 7);
|
|
259
|
+
return w >= 1 ? `${w}w` : `${days}d`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// One card's flag (text). Priority: blocked > review-due > aging-in-build.
|
|
263
|
+
function cardFlagText(c) {
|
|
264
|
+
if (c.blocked) return ' · blocked';
|
|
265
|
+
if (c.reviewDue) return ' · ↻ review due';
|
|
266
|
+
if (c.aging) return ` · ⌛ ${ageLabel(c.ageDays)} in build`;
|
|
267
|
+
return '';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function renderBoardText(projectName, data, opts = {}) {
|
|
271
|
+
const showAll = opts.all === true;
|
|
272
|
+
const { hasIdeasDir } = data;
|
|
273
|
+
// `--mine` narrows the board to the cards I own (founder layer slice 2b) — "what am
|
|
274
|
+
// I on the hook for." A team lens; harmless solo (matches nothing until @owners exist).
|
|
275
|
+
let cards = data.cards;
|
|
276
|
+
if (opts.mine) cards = cards.filter((c) => c.owner && c.owner.toLowerCase() === opts.mine.toLowerCase());
|
|
277
|
+
const lines = [];
|
|
278
|
+
lines.push('');
|
|
279
|
+
lines.push(` ${projectName} · board${opts.mine ? ' · ' + opts.mine : ''}`);
|
|
280
|
+
|
|
281
|
+
const counts = Object.fromEntries(COLUMNS.map((c) => [c, 0]));
|
|
282
|
+
for (const c of cards) counts[c.column] = (counts[c.column] || 0) + 1;
|
|
283
|
+
|
|
284
|
+
lines.push(` ▸ ${evidenceLine(counts, cards.length)}`);
|
|
285
|
+
lines.push('');
|
|
286
|
+
|
|
287
|
+
if (!hasIdeasDir) {
|
|
288
|
+
lines.push(' (no docs/ideas/ here — is this a BOSS project?)');
|
|
289
|
+
lines.push('');
|
|
290
|
+
return lines.join('\n');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
for (const col of COLUMNS) {
|
|
294
|
+
const inCol = sortColumn(cards.filter((c) => c.column === col), col);
|
|
295
|
+
// Cap/age-archive the otherwise-unbounded Shipped column.
|
|
296
|
+
const { shown, hidden } = col === 'Shipped'
|
|
297
|
+
? shippedView(inCol, showAll)
|
|
298
|
+
: { shown: inCol, hidden: 0 };
|
|
299
|
+
lines.push(` ${col} (${inCol.length})`);
|
|
300
|
+
if (!inCol.length) {
|
|
301
|
+
lines.push(' —');
|
|
302
|
+
} else {
|
|
303
|
+
for (const c of shown) {
|
|
304
|
+
// `⬆` gutter marks priority: high; the status flag (blocked/aging/review) is
|
|
305
|
+
// orthogonal and still shown as a suffix, so a high+aging card carries both.
|
|
306
|
+
const prio = c.priority === 'high' ? '⬆ ' : ' ';
|
|
307
|
+
const owner = (opts.owners && c.owner) ? ` ${c.owner}` : '';
|
|
308
|
+
lines.push(` ${prio}${c.id.padEnd(10)} ${c.title}${cardFlagText(c)}${owner}`);
|
|
309
|
+
}
|
|
310
|
+
if (hidden > 0) lines.push(` … +${hidden} shipped earlier (\`boss board --all\`)`);
|
|
311
|
+
}
|
|
312
|
+
lines.push('');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// The trigger half of /revalidate: a paused item whose next_review date has
|
|
316
|
+
// passed is surfaced here so the gate has something to fire on (IDEA-027).
|
|
317
|
+
const due = cards.filter((c) => c.reviewDue).sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }));
|
|
318
|
+
if (due.length) {
|
|
319
|
+
lines.push(` ↻ ${due.length} past review — run \`/revalidate ${due[0].id}\` (still relevant? still aligned? anything changed?)`);
|
|
320
|
+
lines.push('');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Aging-in-build banner (IDEA-034 Track B): the longest-running open FEAT. A
|
|
324
|
+
// build that's sat for weeks is the zombie-feature smell — finish it or /revalidate.
|
|
325
|
+
const aging = cards.filter((c) => c.aging).sort((a, b) => b.ageDays - a.ageDays);
|
|
326
|
+
if (aging.length) {
|
|
327
|
+
const top = aging[0];
|
|
328
|
+
lines.push(` ⌛ ${aging.length} aging in build — ${top.id} has been open ${ageLabel(top.ageDays)}. Finish it, or \`/revalidate ${top.id}\`.`);
|
|
329
|
+
lines.push('');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
lines.push(' The board is a read of the files. To change it, change the work:');
|
|
333
|
+
lines.push(' `/triage` to capture · `/canvas` to pressure-test · `/spec` to build.');
|
|
334
|
+
lines.push('');
|
|
335
|
+
return lines.join('\n');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// --- Visual kanban (HTML) -------------------------------------------------
|
|
339
|
+
// Same projection as the terminal board, rendered as a self-contained HTML
|
|
340
|
+
// page (no server, no deps, no JS framework). "Updated when the board is" =
|
|
341
|
+
// re-run the command; the file is a pure projection of the files, exactly like
|
|
342
|
+
// the text board. Calm palette, not a startup-bro dashboard (voice-keeper).
|
|
343
|
+
|
|
344
|
+
const esc = (s) =>
|
|
345
|
+
String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
346
|
+
|
|
347
|
+
const COLUMN_HUE = {
|
|
348
|
+
Captured: '#8a8f98',
|
|
349
|
+
'Taking shape': '#b8862b',
|
|
350
|
+
Building: '#2f6f4f',
|
|
351
|
+
Shipped: '#3a5a9b',
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
export function renderBoardHtml(projectName, { cards, hasIdeasDir }, stampedAt) {
|
|
355
|
+
const counts = Object.fromEntries(COLUMNS.map((c) => [c, 0]));
|
|
356
|
+
for (const c of cards) counts[c.column] = (counts[c.column] || 0) + 1;
|
|
357
|
+
const evidence = hasIdeasDir ? evidenceLine(counts, cards.length) : 'no docs/ideas/ here — is this a BOSS project?';
|
|
358
|
+
const due = cards.filter((c) => c.reviewDue).sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }));
|
|
359
|
+
|
|
360
|
+
const cardHtml = (c) => {
|
|
361
|
+
const cls = (c.priority === 'high' ? ' is-priority' : '')
|
|
362
|
+
+ (c.blocked ? ' is-blocked' : c.reviewDue ? ' is-review' : c.aging ? ' is-aging' : '');
|
|
363
|
+
const flag = c.blocked
|
|
364
|
+
? '<span class="flag blocked">blocked</span>'
|
|
365
|
+
: c.reviewDue
|
|
366
|
+
? '<span class="flag review">↻ review due</span>'
|
|
367
|
+
: c.aging
|
|
368
|
+
? `<span class="flag aging">⌛ ${esc(ageLabel(c.ageDays))} in build</span>`
|
|
369
|
+
: '';
|
|
370
|
+
const prio = c.priority === 'high' ? '<span class="prio" title="priority: high">⬆ high</span>' : '';
|
|
371
|
+
return `<div class="card${cls}">
|
|
372
|
+
<div class="id">${esc(c.id)}${prio}</div>
|
|
373
|
+
<div class="title">${esc(c.title)}</div>${flag}
|
|
374
|
+
</div>`;
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const columnHtml = COLUMNS.map((col) => {
|
|
378
|
+
const inCol = sortColumn(cards.filter((c) => c.column === col), col);
|
|
379
|
+
let cardsHtml;
|
|
380
|
+
if (!inCol.length) {
|
|
381
|
+
cardsHtml = '<div class="empty">—</div>';
|
|
382
|
+
} else if (col === 'Shipped') {
|
|
383
|
+
// Date-archive old ships + cap the rest; the folded ones go in a <details>.
|
|
384
|
+
const { shown, hidden } = shippedView(inCol, false);
|
|
385
|
+
if (!hidden) {
|
|
386
|
+
cardsHtml = inCol.map(cardHtml).join('\n');
|
|
387
|
+
} else {
|
|
388
|
+
const shownIds = new Set(shown.map((c) => c.id));
|
|
389
|
+
const rest = inCol.filter((c) => !shownIds.has(c.id));
|
|
390
|
+
cardsHtml = `${shown.map(cardHtml).join('\n')}\n<details class="more"><summary>+${hidden} shipped earlier</summary>\n<div class="cards rest">${rest.map(cardHtml).join('\n')}</div></details>`;
|
|
391
|
+
}
|
|
392
|
+
} else {
|
|
393
|
+
cardsHtml = inCol.map(cardHtml).join('\n');
|
|
394
|
+
}
|
|
395
|
+
return `<section class="col" style="--hue:${COLUMN_HUE[col]}">
|
|
396
|
+
<h2><span class="label">${esc(col)}</span> <span class="n">${inCol.length}</span></h2>
|
|
397
|
+
<div class="cards">${cardsHtml}</div>
|
|
398
|
+
</section>`;
|
|
399
|
+
}).join('\n');
|
|
400
|
+
|
|
401
|
+
const dueBanner = due.length
|
|
402
|
+
? `<div class="banner review-banner">↻ ${due.length} past review — run <code>/revalidate ${esc(due[0].id)}</code> <span class="muted">still relevant? still aligned? anything changed?</span></div>`
|
|
403
|
+
: '';
|
|
404
|
+
|
|
405
|
+
const agingCards = cards.filter((c) => c.aging).sort((a, b) => b.ageDays - a.ageDays);
|
|
406
|
+
const agingBanner = agingCards.length
|
|
407
|
+
? `<div class="banner aging-banner">⌛ ${agingCards.length} aging in build — <code>${esc(agingCards[0].id)}</code> open ${esc(ageLabel(agingCards[0].ageDays))} <span class="muted">finish it, or</span> <code>/revalidate ${esc(agingCards[0].id)}</code></div>`
|
|
408
|
+
: '';
|
|
409
|
+
|
|
410
|
+
const pills = COLUMNS.map((col) =>
|
|
411
|
+
`<span class="pill" style="--hue:${COLUMN_HUE[col]}"><i></i>${esc(col)} <b>${counts[col] || 0}</b></span>`
|
|
412
|
+
).join('');
|
|
413
|
+
|
|
414
|
+
return `<!doctype html>
|
|
415
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
416
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
417
|
+
<title>${esc(projectName)} · board</title>
|
|
418
|
+
<style>
|
|
419
|
+
:root {
|
|
420
|
+
color-scheme: light dark;
|
|
421
|
+
--bg: #f6f7f9; --panel: #ffffff; --ink: #1b1c20; --muted: #767b85;
|
|
422
|
+
--line: #e7e9ee; --accent: #4b54c6; --shadow: 0 1px 2px rgba(20,22,40,.06), 0 4px 14px rgba(20,22,40,.05);
|
|
423
|
+
}
|
|
424
|
+
@media (prefers-color-scheme: dark) {
|
|
425
|
+
:root { --bg: #0e0f12; --panel: #17191e; --ink: #e7e8ec; --muted: #8a909b;
|
|
426
|
+
--line: #25282f; --accent: #8b93ff; --shadow: 0 1px 2px rgba(0,0,0,.3), 0 6px 20px rgba(0,0,0,.35); }
|
|
427
|
+
}
|
|
428
|
+
* { box-sizing: border-box; }
|
|
429
|
+
body { margin: 0; font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
430
|
+
background: var(--bg); color: var(--ink); padding: 40px 24px 64px;
|
|
431
|
+
-webkit-font-smoothing: antialiased; }
|
|
432
|
+
.wrap { max-width: 1160px; margin: 0 auto; }
|
|
433
|
+
header { margin: 0 0 6px; }
|
|
434
|
+
.kicker { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .16em;
|
|
435
|
+
color: var(--accent); margin: 0 0 5px; }
|
|
436
|
+
h1 { font-size: 24px; font-weight: 680; letter-spacing: -.018em; margin: 0; display: flex; align-items: center; gap: 10px; }
|
|
437
|
+
h1::before { content: ""; width: 9px; height: 9px; border-radius: 50%; background: var(--accent); flex: none;
|
|
438
|
+
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 16%, transparent); }
|
|
439
|
+
.evidence { color: var(--muted); font-size: 13.5px; margin: 7px 0 0; }
|
|
440
|
+
.pills { display: flex; gap: 7px; flex-wrap: wrap; margin: 18px 0 22px; }
|
|
441
|
+
.pill { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted);
|
|
442
|
+
background: var(--panel); border: 1px solid var(--line); border-radius: 999px; padding: 4px 11px; }
|
|
443
|
+
.pill i { width: 7px; height: 7px; border-radius: 50%; background: var(--hue); }
|
|
444
|
+
.pill b { color: var(--ink); font-weight: 600; }
|
|
445
|
+
.banner { margin: 0 0 14px; padding: 11px 15px; border-radius: 10px; font-size: 13px;
|
|
446
|
+
border: 1px solid color-mix(in srgb, var(--bar) 38%, var(--line)); background: color-mix(in srgb, var(--bar) 11%, var(--panel)); }
|
|
447
|
+
.review-banner { --bar: #b8862b; } .aging-banner { --bar: #c2792f; }
|
|
448
|
+
.banner code { font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
449
|
+
background: color-mix(in srgb, var(--bar) 16%, transparent); padding: 1.5px 6px; border-radius: 5px; }
|
|
450
|
+
.banner .muted { color: var(--muted); }
|
|
451
|
+
.board { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; align-items: start; }
|
|
452
|
+
@media (max-width: 820px) { .board { grid-template-columns: 1fr 1fr; } }
|
|
453
|
+
@media (max-width: 480px) { .board { grid-template-columns: 1fr; } }
|
|
454
|
+
.col { min-width: 0; }
|
|
455
|
+
.col h2 { display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
|
456
|
+
font-size: 11.5px; font-weight: 650; text-transform: uppercase; letter-spacing: .07em;
|
|
457
|
+
color: var(--hue); margin: 0 0 12px; padding: 0 2px 9px; border-bottom: 1.5px solid color-mix(in srgb, var(--hue) 55%, var(--line)); }
|
|
458
|
+
.col h2 .n { color: var(--muted); font-weight: 600; font-size: 11px;
|
|
459
|
+
background: color-mix(in srgb, var(--hue) 12%, var(--panel)); border-radius: 999px; padding: 1px 8px; }
|
|
460
|
+
.cards { display: flex; flex-direction: column; gap: 9px; }
|
|
461
|
+
.card { background: var(--panel); border: 1px solid var(--line); border-left: 3px solid var(--hue);
|
|
462
|
+
border-radius: 9px; padding: 10px 13px 11px; box-shadow: var(--shadow);
|
|
463
|
+
transition: transform .12s ease, box-shadow .12s ease; }
|
|
464
|
+
.card:hover { transform: translateY(-1px); box-shadow: 0 2px 4px rgba(20,22,40,.08), 0 10px 26px rgba(20,22,40,.10); }
|
|
465
|
+
.card .id { display: flex; align-items: center; justify-content: space-between; gap: 6px;
|
|
466
|
+
font: 600 10px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
467
|
+
color: color-mix(in srgb, var(--muted) 85%, transparent); letter-spacing: .04em; text-transform: uppercase; }
|
|
468
|
+
/* Title carries the weight now — bold and a touch larger; the id is the quiet label. */
|
|
469
|
+
.card .title { font-size: 14px; font-weight: 600; line-height: 1.38; letter-spacing: -.005em; margin-top: 4px; }
|
|
470
|
+
.card .prio { font-size: 9.5px; font-weight: 700; letter-spacing: .03em; color: var(--accent);
|
|
471
|
+
background: color-mix(in srgb, var(--accent) 13%, transparent); border-radius: 999px; padding: 1px 7px; }
|
|
472
|
+
/* Stuck cards pull the eye: tinted panel + a heavier left bar, not just a hairline. */
|
|
473
|
+
.card.is-review { border-left-color: #b8862b; background: color-mix(in srgb, #b8862b 6%, var(--panel)); }
|
|
474
|
+
.card.is-blocked { border-left-color: #b3434a; background: color-mix(in srgb, #b3434a 7%, var(--panel)); }
|
|
475
|
+
.card.is-aging { border-left-color: #c2792f; background: color-mix(in srgb, #c2792f 6%, var(--panel)); }
|
|
476
|
+
.card.is-priority { border-left-color: var(--accent); }
|
|
477
|
+
.flag { display: inline-flex; align-items: center; margin-top: 9px; font-size: 11px; font-weight: 600;
|
|
478
|
+
padding: 2px 9px; border-radius: 999px; }
|
|
479
|
+
.flag.review { background: rgba(184,134,43,.16); color: #9a6a14; }
|
|
480
|
+
.flag.blocked { background: rgba(179,67,74,.16); color: #b3434a; }
|
|
481
|
+
.flag.aging { background: rgba(194,121,47,.16); color: #a5641f; }
|
|
482
|
+
@media (prefers-color-scheme: dark) {
|
|
483
|
+
.flag.review { color: #e0b35a; } .flag.blocked { color: #e88a90; } .flag.aging { color: #e0a566; }
|
|
484
|
+
}
|
|
485
|
+
.empty { color: color-mix(in srgb, var(--muted) 55%, transparent); font-size: 18px; padding: 6px 2px; }
|
|
486
|
+
details.more { margin-top: 2px; }
|
|
487
|
+
details.more > summary { cursor: pointer; list-style: none; font-size: 12px; color: var(--muted);
|
|
488
|
+
padding: 7px 2px; user-select: none; }
|
|
489
|
+
details.more > summary::-webkit-details-marker { display: none; }
|
|
490
|
+
details.more > summary::before { content: "▸ "; }
|
|
491
|
+
details.more[open] > summary::before { content: "▾ "; }
|
|
492
|
+
details.more .rest { margin-top: 9px; opacity: .82; }
|
|
493
|
+
footer { color: var(--muted); font-size: 12px; margin: 34px 0 0; padding-top: 18px; border-top: 1px solid var(--line); }
|
|
494
|
+
footer code { font: 11.5px ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
495
|
+
background: color-mix(in srgb, var(--muted) 14%, transparent); padding: 1.5px 6px; border-radius: 5px; }
|
|
496
|
+
</style></head>
|
|
497
|
+
<body>
|
|
498
|
+
<div class="wrap">
|
|
499
|
+
<header>
|
|
500
|
+
<div class="kicker">Board</div>
|
|
501
|
+
<h1>${esc(projectName)}</h1>
|
|
502
|
+
<p class="evidence">${esc(evidence)}</p>
|
|
503
|
+
</header>
|
|
504
|
+
<div class="pills">${pills}</div>
|
|
505
|
+
${dueBanner}
|
|
506
|
+
${agingBanner}
|
|
507
|
+
<div class="board">
|
|
508
|
+
${columnHtml}
|
|
509
|
+
</div>
|
|
510
|
+
<footer>
|
|
511
|
+
A read of the files — to change the board, change the work (<code>/triage</code> · <code>/canvas</code> · <code>/spec</code>).
|
|
512
|
+
Re-run <code>boss board --html</code> to refresh.${stampedAt ? ` · ${esc(stampedAt)}` : ''}
|
|
513
|
+
</footer>
|
|
514
|
+
</div>
|
|
515
|
+
</body></html>
|
|
516
|
+
`;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// --- Agent-readable / focused views (IDEA-034 Track A) --------------------
|
|
520
|
+
// The board stops being only a picture for the founder and becomes state the
|
|
521
|
+
// agent can read and steer by: what to pick up next, what's stuck, and the whole
|
|
522
|
+
// projection as JSON. Lighter cousin of the V1 `/board` skill (which also reads
|
|
523
|
+
// smoke / evals / declared deps the CLI projection doesn't have).
|
|
524
|
+
|
|
525
|
+
// "What should I pick up?" — ordered by the flow's own logic: finish what's open
|
|
526
|
+
// before starting new (the focus discipline), then build what's pressure-tested,
|
|
527
|
+
// then pressure-test what's only captured. Blocked work is called out separately —
|
|
528
|
+
// it can't move without clearing the blocker first.
|
|
529
|
+
export function computeNext(cards) {
|
|
530
|
+
const building = cards.filter((c) => c.column === 'Building');
|
|
531
|
+
const finish = sortColumn(building.filter((c) => !c.blocked), 'Building')
|
|
532
|
+
.map((c) => ({ id: c.id, title: c.title, group: 'finish', action: 'finish it', age: c.ageDays, priority: c.priority || null }));
|
|
533
|
+
const start = sortColumn(cards.filter((c) => c.column === 'Taking shape'), 'Taking shape')
|
|
534
|
+
.map((c) => ({ id: c.id, title: c.title, group: 'start', action: '/spec to build', priority: c.priority || null }));
|
|
535
|
+
const unblock = sortColumn(building.filter((c) => c.blocked), 'Building')
|
|
536
|
+
.map((c) => ({ id: c.id, title: c.title, group: 'unblock', action: 'clear the blocker', priority: c.priority || null }));
|
|
537
|
+
// Only suggest pressure-testing when there's nothing further along to move.
|
|
538
|
+
const pressure = (finish.length || start.length)
|
|
539
|
+
? []
|
|
540
|
+
: sortColumn(cards.filter((c) => c.column === 'Captured'), 'Captured')
|
|
541
|
+
.slice(0, 3)
|
|
542
|
+
.map((c) => ({ id: c.id, title: c.title, group: 'pressure-test', action: '/canvas', priority: c.priority || null }));
|
|
543
|
+
return { finish, start, unblock, pressure };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// "What's not moving?" — blocked, aging-in-build, and past-review, in one place.
|
|
547
|
+
export function computeStuck(cards) {
|
|
548
|
+
return {
|
|
549
|
+
blocked: cards.filter((c) => c.blocked),
|
|
550
|
+
aging: cards.filter((c) => c.aging).sort((a, b) => b.ageDays - a.ageDays),
|
|
551
|
+
reviewDue: cards.filter((c) => c.reviewDue && !c.blocked),
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export function renderBoardNext(projectName, { cards, hasIdeasDir }) {
|
|
556
|
+
const lines = ['', ` ${projectName} · next`];
|
|
557
|
+
if (!hasIdeasDir) { lines.push(' (no docs/ideas/ here — is this a BOSS project?)', ''); return lines.join('\n'); }
|
|
558
|
+
const { finish, start, unblock, pressure } = computeNext(cards);
|
|
559
|
+
if (!finish.length && !start.length && !unblock.length && !pressure.length) {
|
|
560
|
+
lines.push(' ▸ nothing in flight — `/triage` to capture or `/canvas` to pressure-test.', '');
|
|
561
|
+
return lines.join('\n');
|
|
562
|
+
}
|
|
563
|
+
lines.push(' ▸ finish before you start', '');
|
|
564
|
+
const block = (label, items, withAge) => {
|
|
565
|
+
if (!items.length) return;
|
|
566
|
+
lines.push(` ${label} (${items.length})`);
|
|
567
|
+
for (const it of items) {
|
|
568
|
+
const prio = it.priority === 'high' ? '⬆ ' : ' ';
|
|
569
|
+
const age = withAge && it.age != null && it.age >= AGING_DAYS ? ` ⌛ ${ageLabel(it.age)}` : '';
|
|
570
|
+
lines.push(` ${prio}${it.id.padEnd(10)} ${it.title.padEnd(40)} → ${it.action}${age}`);
|
|
571
|
+
}
|
|
572
|
+
lines.push('');
|
|
573
|
+
};
|
|
574
|
+
block('Finish — in build', finish, true);
|
|
575
|
+
block('Start — pressure-tested, ready to build', start, false);
|
|
576
|
+
block('Pressure-test — only captured so far', pressure, false);
|
|
577
|
+
block('Blocked — clear to move', unblock, false);
|
|
578
|
+
return lines.join('\n');
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export function renderBoardBlocked(projectName, { cards, hasIdeasDir }) {
|
|
582
|
+
const lines = ['', ` ${projectName} · not moving`];
|
|
583
|
+
if (!hasIdeasDir) { lines.push(' (no docs/ideas/ here — is this a BOSS project?)', ''); return lines.join('\n'); }
|
|
584
|
+
const { blocked, aging, reviewDue } = computeStuck(cards);
|
|
585
|
+
if (!blocked.length && !aging.length && !reviewDue.length) {
|
|
586
|
+
lines.push(' ▸ nothing blocked, nothing stale — the board is moving.', '');
|
|
587
|
+
return lines.join('\n');
|
|
588
|
+
}
|
|
589
|
+
lines.push('');
|
|
590
|
+
const block = (label, items, flag) => {
|
|
591
|
+
if (!items.length) return;
|
|
592
|
+
lines.push(` ${label} (${items.length})`);
|
|
593
|
+
for (const c of items) lines.push(` ${c.id.padEnd(10)} ${c.title.padEnd(40)} ${flag(c)}`);
|
|
594
|
+
lines.push('');
|
|
595
|
+
};
|
|
596
|
+
block('Blocked', blocked, () => '— status: blocked');
|
|
597
|
+
block('Aging in build', aging, (c) => `⌛ open ${ageLabel(c.ageDays)} — finish or /revalidate`);
|
|
598
|
+
block('Review due', reviewDue, (c) => `↻ run /revalidate ${c.id}`);
|
|
599
|
+
return lines.join('\n');
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// The full projection as JSON — the actual agent-readability contract. Stable,
|
|
603
|
+
// machine-parseable; an agent (or the `/board` skill) reads this instead of
|
|
604
|
+
// re-deriving state from the files.
|
|
605
|
+
export function boardJson(projectDir, projectName) {
|
|
606
|
+
const { cards, hasIdeasDir } = collectBoard(projectDir);
|
|
607
|
+
const counts = Object.fromEntries(COLUMNS.map((c) => [c, 0]));
|
|
608
|
+
for (const c of cards) counts[c.column] = (counts[c.column] || 0) + 1;
|
|
609
|
+
const { finish, start, unblock, pressure } = computeNext(cards);
|
|
610
|
+
const { blocked, aging, reviewDue } = computeStuck(cards);
|
|
611
|
+
// Present cards in display order (by column, then priority/age within) so a JSON
|
|
612
|
+
// consumer reads them the same way the board renders.
|
|
613
|
+
const ordered = COLUMNS.flatMap((col) => sortColumn(cards.filter((c) => c.column === col), col));
|
|
614
|
+
return {
|
|
615
|
+
project: projectName,
|
|
616
|
+
hasIdeasDir,
|
|
617
|
+
columns: COLUMNS,
|
|
618
|
+
counts,
|
|
619
|
+
total: cards.length,
|
|
620
|
+
cards: ordered.map((c) => ({
|
|
621
|
+
id: c.id, title: c.title, column: c.column,
|
|
622
|
+
priority: c.priority || null,
|
|
623
|
+
owner: c.owner || null,
|
|
624
|
+
blocked: c.blocked, reviewDue: c.reviewDue,
|
|
625
|
+
aging: c.aging || false, ageDays: c.ageDays ?? null,
|
|
626
|
+
archived: c.archived || false, shippedAgeDays: c.shippedAgeDays ?? null,
|
|
627
|
+
})),
|
|
628
|
+
next: { finish, start, pressureTest: pressure, unblock },
|
|
629
|
+
stuck: {
|
|
630
|
+
blocked: blocked.map((c) => c.id),
|
|
631
|
+
aging: aging.map((c) => ({ id: c.id, ageDays: c.ageDays })),
|
|
632
|
+
reviewDue: reviewDue.map((c) => c.id),
|
|
633
|
+
},
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export function board(projectDir, projectName, opts = {}) {
|
|
638
|
+
const data = collectBoard(projectDir);
|
|
639
|
+
if (opts.next) return console.log(renderBoardNext(projectName, data));
|
|
640
|
+
if (opts.blocked) return console.log(renderBoardBlocked(projectName, data));
|
|
641
|
+
if (opts.json) return console.log(JSON.stringify(boardJson(projectDir, projectName), null, 2));
|
|
642
|
+
console.log(renderBoardText(projectName, data, { all: opts.all, owners: opts.owners, mine: opts.mine }));
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Write the visual kanban to .boss/board.html and return its path.
|
|
646
|
+
export function boardHtml(projectDir, projectName) {
|
|
647
|
+
const data = collectBoard(projectDir);
|
|
648
|
+
const stampedAt = new Date().toISOString().slice(0, 16).replace('T', ' ');
|
|
649
|
+
const html = renderBoardHtml(projectName, data, stampedAt);
|
|
650
|
+
const dir = join(projectDir, '.boss');
|
|
651
|
+
const out = join(dir, 'board.html');
|
|
652
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
653
|
+
writeFileSync(out, html);
|
|
654
|
+
return out;
|
|
655
|
+
}
|