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.
Files changed (128) hide show
  1. package/LICENSE +21 -0
  2. package/PRINCIPLES.md +70 -0
  3. package/README.md +213 -0
  4. package/VERSION +1 -0
  5. package/bin/boss +3 -0
  6. package/library/README.md +19 -0
  7. package/library/agents/.gitkeep +0 -0
  8. package/library/agents/mentor-venture.md +57 -0
  9. package/library/hooks/.gitkeep +0 -0
  10. package/library/hooks/auto-log.js +133 -0
  11. package/library/hooks/memory-cue.js +82 -0
  12. package/library/hooks/secrets-guard.js +87 -0
  13. package/library/memory-seed/README.md +29 -0
  14. package/library/memory-seed/durable-facts-example.md +16 -0
  15. package/library/practices/.gitkeep +0 -0
  16. package/library/practices/agent-security.md +111 -0
  17. package/library/practices/ai-adoption-culture.md +104 -0
  18. package/library/practices/ai-ux-patterns.md +246 -0
  19. package/library/practices/celebration-of-done.md +100 -0
  20. package/library/practices/conscience-voicing.md +121 -0
  21. package/library/practices/context-discipline.md +116 -0
  22. package/library/practices/design-system.md +152 -0
  23. package/library/practices/git-workflow.md +119 -0
  24. package/library/practices/harm-taxonomy.md +45 -0
  25. package/library/practices/quality-ratchet.md +48 -0
  26. package/library/practices/revalidation.md +57 -0
  27. package/library/practices/scalable-architecture.md +111 -0
  28. package/library/practices/ship-it-live.md +149 -0
  29. package/library/practices/skill-authoring.md +70 -0
  30. package/library/skills/.gitkeep +0 -0
  31. package/library/skills/boss-learn/SKILL.md +63 -0
  32. package/library/skills/boss-sync/SKILL.md +48 -0
  33. package/package.json +49 -0
  34. package/registry/CHANGELOG.md +2737 -0
  35. package/src/board.js +655 -0
  36. package/src/brain.js +288 -0
  37. package/src/cli.js +542 -0
  38. package/src/conscience.js +426 -0
  39. package/src/insights.js +147 -0
  40. package/src/learn.js +92 -0
  41. package/src/map.js +103 -0
  42. package/src/modes.js +82 -0
  43. package/src/paths.js +36 -0
  44. package/src/registry.js +34 -0
  45. package/src/scaffold.js +138 -0
  46. package/src/sync.js +292 -0
  47. package/src/team.js +103 -0
  48. package/stages/L0-quickstart/manifest.json +12 -0
  49. package/stages/L0-quickstart/template/.claude/agents/coder-generalist.md +31 -0
  50. package/stages/L0-quickstart/template/.claude/agents/mentor-venture.md +57 -0
  51. package/stages/L0-quickstart/template/.claude/agents/pm.md +28 -0
  52. package/stages/L0-quickstart/template/.claude/hooks/conscience.js +89 -0
  53. package/stages/L0-quickstart/template/.claude/hooks/lib/loop-runtime.js +507 -0
  54. package/stages/L0-quickstart/template/.claude/hooks/lib/yaml.js +163 -0
  55. package/stages/L0-quickstart/template/.claude/hooks/memory-cue.js +82 -0
  56. package/stages/L0-quickstart/template/.claude/hooks/secrets-guard.js +87 -0
  57. package/stages/L0-quickstart/template/.claude/rules/your-app-code.md +17 -0
  58. package/stages/L0-quickstart/template/.claude/settings.json +36 -0
  59. package/stages/L0-quickstart/template/.claude/skills/boss/SKILL.md +161 -0
  60. package/stages/L0-quickstart/template/.claude/skills/boss-learn/SKILL.md +63 -0
  61. package/stages/L0-quickstart/template/.claude/skills/boss-sync/SKILL.md +55 -0
  62. package/stages/L0-quickstart/template/.claude/skills/canvas/SKILL.md +112 -0
  63. package/stages/L0-quickstart/template/.claude/skills/comprehend/SKILL.md +72 -0
  64. package/stages/L0-quickstart/template/.claude/skills/decide/SKILL.md +122 -0
  65. package/stages/L0-quickstart/template/.claude/skills/feedback/SKILL.md +68 -0
  66. package/stages/L0-quickstart/template/.claude/skills/import/SKILL.md +73 -0
  67. package/stages/L0-quickstart/template/.claude/skills/persona/SKILL.md +92 -0
  68. package/stages/L0-quickstart/template/.claude/skills/prototype/SKILL.md +114 -0
  69. package/stages/L0-quickstart/template/.claude/skills/triage/SKILL.md +104 -0
  70. package/stages/L0-quickstart/template/.claude/skills/welcome/SKILL.md +262 -0
  71. package/stages/L0-quickstart/template/AGENTS.md +31 -0
  72. package/stages/L0-quickstart/template/CLAUDE.md +57 -0
  73. package/stages/L0-quickstart/template/docs/IDS.md +42 -0
  74. package/stages/L0-quickstart/template/docs/ideas/INDEX.md +24 -0
  75. package/stages/L0-quickstart/template/docs/loops/canvas-loop.md +90 -0
  76. package/stages/L0-quickstart/template/docs/loops/capture-loop.md +64 -0
  77. package/stages/L1-mvp/manifest.json +12 -0
  78. package/stages/L1-mvp/template/.claude/agents/mentor-architect.md +124 -0
  79. package/stages/L1-mvp/template/.claude/agents/mentor-cofounder.md +85 -0
  80. package/stages/L1-mvp/template/.claude/agents/mentor-gtm.md +49 -0
  81. package/stages/L1-mvp/template/.claude/agents/program-manager.md +46 -0
  82. package/stages/L1-mvp/template/.claude/agents/tester.md +42 -0
  83. package/stages/L1-mvp/template/.claude/hooks/auto-log.js +133 -0
  84. package/stages/L1-mvp/template/.claude/rules/feature-context.md +18 -0
  85. package/stages/L1-mvp/template/.claude/skills/ai-cost/SKILL.md +249 -0
  86. package/stages/L1-mvp/template/.claude/skills/ai-failure-states/SKILL.md +226 -0
  87. package/stages/L1-mvp/template/.claude/skills/ai-first-init/SKILL.md +227 -0
  88. package/stages/L1-mvp/template/.claude/skills/close/SKILL.md +170 -0
  89. package/stages/L1-mvp/template/.claude/skills/consult/SKILL.md +72 -0
  90. package/stages/L1-mvp/template/.claude/skills/cost-review/SKILL.md +204 -0
  91. package/stages/L1-mvp/template/.claude/skills/design-tokens-init/SKILL.md +192 -0
  92. package/stages/L1-mvp/template/.claude/skills/drift-deep/SKILL.md +170 -0
  93. package/stages/L1-mvp/template/.claude/skills/evals/SKILL.md +154 -0
  94. package/stages/L1-mvp/template/.claude/skills/extract/SKILL.md +209 -0
  95. package/stages/L1-mvp/template/.claude/skills/judge-traces/SKILL.md +68 -0
  96. package/stages/L1-mvp/template/.claude/skills/log/SKILL.md +64 -0
  97. package/stages/L1-mvp/template/.claude/skills/practice/SKILL.md +92 -0
  98. package/stages/L1-mvp/template/.claude/skills/pretotype/SKILL.md +95 -0
  99. package/stages/L1-mvp/template/.claude/skills/red-team/SKILL.md +137 -0
  100. package/stages/L1-mvp/template/.claude/skills/revalidate/SKILL.md +51 -0
  101. package/stages/L1-mvp/template/.claude/skills/ship/SKILL.md +105 -0
  102. package/stages/L1-mvp/template/.claude/skills/smoke/SKILL.md +43 -0
  103. package/stages/L1-mvp/template/.claude/skills/spec/SKILL.md +145 -0
  104. package/stages/L1-mvp/template/claude-append.md +122 -0
  105. package/stages/L1-mvp/template/docs/loops/ai-failure-state-loop.md +107 -0
  106. package/stages/L1-mvp/template/docs/loops/coordination-loop.md +116 -0
  107. package/stages/L1-mvp/template/docs/loops/cost-budget-loop.md +117 -0
  108. package/stages/L1-mvp/template/docs/loops/cost-review-loop.md +113 -0
  109. package/stages/L1-mvp/template/docs/loops/design-tokens-loop.md +98 -0
  110. package/stages/L1-mvp/template/docs/loops/drift-loop.md +149 -0
  111. package/stages/L1-mvp/template/docs/loops/extraction-loop.md +128 -0
  112. package/stages/L1-mvp/template/docs/loops/focus-loop.md +106 -0
  113. package/stages/L1-mvp/template/docs/loops/pretotype-loop.md +88 -0
  114. package/stages/L1-mvp/template/docs/loops/spec-loop.md +83 -0
  115. package/stages/L2-v1/manifest.json +12 -0
  116. package/stages/L2-v1/template/.claude/agents/db-architect.md +91 -0
  117. package/stages/L2-v1/template/.claude/agents/mentor-business.md +124 -0
  118. package/stages/L2-v1/template/.claude/agents/mentor-fundraising.md +72 -0
  119. package/stages/L2-v1/template/.claude/agents/mentor-pitch.md +84 -0
  120. package/stages/L2-v1/template/.claude/agents/mentor-talent.md +84 -0
  121. package/stages/L2-v1/template/.claude/agents/ui-designer.md +81 -0
  122. package/stages/L2-v1/template/.claude/agents/ux-designer.md +87 -0
  123. package/stages/L2-v1/template/.claude/skills/board/SKILL.md +98 -0
  124. package/stages/L2-v1/template/.claude/skills/design-review/SKILL.md +77 -0
  125. package/stages/L2-v1/template/.claude/skills/ux-check/SKILL.md +93 -0
  126. package/stages/L2-v1/template/claude-append.md +59 -0
  127. package/stages/L2-v1/template/docs/loops/design-drift-loop.md +108 -0
  128. 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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 ? ` &middot; ${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
+ }