@hobocode/thought-layer 0.6.1 → 0.8.5
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/README.md +7 -3
- package/core/artifacts-io.ts +146 -0
- package/core/artifacts.ts +690 -0
- package/core/index.ts +7 -0
- package/core/merge.ts +157 -0
- package/core/notion-io.ts +292 -0
- package/core/notion.ts +312 -0
- package/core/progress.ts +8 -5
- package/core/state-ops.ts +1 -1
- package/core/sync-io.ts +432 -0
- package/core/sync.ts +150 -0
- package/dist/tl.js +1866 -45
- package/extensions/thought-layer.ts +93 -2
- package/package.json +8 -1
- package/prompts/tl-artifacts.md +7 -0
- package/prompts/tl-compliance.md +7 -0
- package/prompts/tl-wiki.md +9 -0
- package/skills/thought-layer-compliance/SKILL.md +139 -0
- package/skills/thought-layer-framework/SKILL.md +9 -0
- package/skills/thought-layer-wiki/SKILL.md +43 -0
package/core/notion.ts
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// Pure mapping from a ProgressState (+ the delivered-artifacts manifest) to a
|
|
2
|
+
// Notion "wiki" plan: a root page, one child page per workflow area, and an
|
|
3
|
+
// Artifacts database. No fetch, no fs - the notion-io layer turns this plan into
|
|
4
|
+
// API calls. Kept pure so the block construction and the API-limit chunking are
|
|
5
|
+
// unit-testable without a token.
|
|
6
|
+
//
|
|
7
|
+
// Notion API limits this encodes: rich_text content max 2000 chars (chunkRichText),
|
|
8
|
+
// at most 100 children per append (chunkChildren), and at most 2 levels of block
|
|
9
|
+
// nesting per create/append (withinDepth guards it; we keep tables/toggles to one
|
|
10
|
+
// nested level). Notion has no upsert, so the io layer find-or-creates by stored id.
|
|
11
|
+
|
|
12
|
+
import { computeProjection, fmtMoney, type Assumptions } from "./model.ts";
|
|
13
|
+
import {
|
|
14
|
+
brandGuideMarkdown, requirementsMarkdown, glossaryMarkdown, swotMarkdown,
|
|
15
|
+
type Brand, type Grill, type Swot, type ArtifactManifest,
|
|
16
|
+
} from "./artifacts.ts";
|
|
17
|
+
import type { ProgressState } from "./progress.ts";
|
|
18
|
+
|
|
19
|
+
// Notion's free-tier single-file upload ceiling. Larger files link to GitHub.
|
|
20
|
+
export const NOTION_FREE_FILE_LIMIT = 5 * 1024 * 1024;
|
|
21
|
+
const RICH_TEXT_MAX = 2000;
|
|
22
|
+
const CHILDREN_MAX = 100;
|
|
23
|
+
|
|
24
|
+
export type Annotations = Partial<Record<"bold" | "italic" | "strikethrough" | "underline" | "code", boolean>> & { color?: string };
|
|
25
|
+
export interface RichText { type: "text"; text: { content: string; link?: { url: string } | null }; annotations?: Annotations; }
|
|
26
|
+
export type Block = Record<string, unknown>;
|
|
27
|
+
|
|
28
|
+
const obj = (v: unknown): Record<string, unknown> => (v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {});
|
|
29
|
+
const str = (v: unknown): string => (typeof v === "string" ? v : "");
|
|
30
|
+
|
|
31
|
+
// ---- rich text (with the 2000-char split) ------------------------------------
|
|
32
|
+
|
|
33
|
+
const rtSeg = (content: string, ann?: Annotations, link?: string): RichText => ({
|
|
34
|
+
type: "text",
|
|
35
|
+
text: { content, ...(link ? { link: { url: link } } : {}) },
|
|
36
|
+
...(ann ? { annotations: ann } : {}),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Split a string into rich_text segments no longer than 2000 chars each.
|
|
40
|
+
export function chunkRichText(text: string, ann?: Annotations, link?: string): RichText[] {
|
|
41
|
+
const s = String(text ?? "");
|
|
42
|
+
if (s.length <= RICH_TEXT_MAX) return [rtSeg(s, ann, link)];
|
|
43
|
+
const out: RichText[] = [];
|
|
44
|
+
for (let i = 0; i < s.length; i += RICH_TEXT_MAX) out.push(rtSeg(s.slice(i, i + RICH_TEXT_MAX), ann, link));
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Minimal inline markdown: **bold** and `code`. Everything else is plain text.
|
|
49
|
+
// Every segment is itself chunked to the 2000-char limit.
|
|
50
|
+
function inline(text: string): RichText[] {
|
|
51
|
+
const out: RichText[] = [];
|
|
52
|
+
const re = /\*\*([^*]+)\*\*|`([^`]+)`/g;
|
|
53
|
+
let last = 0;
|
|
54
|
+
let m: RegExpExecArray | null;
|
|
55
|
+
while ((m = re.exec(text))) {
|
|
56
|
+
if (m.index > last) out.push(...chunkRichText(text.slice(last, m.index)));
|
|
57
|
+
if (m[1] != null) out.push(...chunkRichText(m[1], { bold: true }));
|
|
58
|
+
else if (m[2] != null) out.push(...chunkRichText(m[2], { code: true }));
|
|
59
|
+
last = re.lastIndex;
|
|
60
|
+
}
|
|
61
|
+
if (last < text.length) out.push(...chunkRichText(text.slice(last)));
|
|
62
|
+
return out.length ? out : [rtSeg("")];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---- block builders ----------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
const para = (text: string): Block => ({ object: "block", type: "paragraph", paragraph: { rich_text: inline(text) } });
|
|
68
|
+
const heading = (level: 1 | 2 | 3, text: string): Block => {
|
|
69
|
+
const t = `heading_${level}`;
|
|
70
|
+
return { object: "block", type: t, [t]: { rich_text: inline(text) } };
|
|
71
|
+
};
|
|
72
|
+
const bullet = (text: string): Block => ({ object: "block", type: "bulleted_list_item", bulleted_list_item: { rich_text: inline(text) } });
|
|
73
|
+
const numbered = (text: string): Block => ({ object: "block", type: "numbered_list_item", numbered_list_item: { rich_text: inline(text) } });
|
|
74
|
+
const quote = (text: string): Block => ({ object: "block", type: "quote", quote: { rich_text: inline(text) } });
|
|
75
|
+
const callout = (text: string, emoji = "💡"): Block => ({ object: "block", type: "callout", callout: { rich_text: inline(text), icon: { type: "emoji", emoji } } });
|
|
76
|
+
const divider = (): Block => ({ object: "block", type: "divider", divider: {} });
|
|
77
|
+
const codeBlock = (text: string, language = "plain text"): Block => ({ object: "block", type: "code", code: { rich_text: chunkRichText(text), language } });
|
|
78
|
+
export const bookmark = (url: string): Block => ({ object: "block", type: "bookmark", bookmark: { url } });
|
|
79
|
+
export const externalImage = (url: string): Block => ({ object: "block", type: "image", image: { type: "external", external: { url } } });
|
|
80
|
+
|
|
81
|
+
function table(headers: string[], rows: string[][]): Block {
|
|
82
|
+
const toRow = (cells: string[]): Block => ({ object: "block", type: "table_row", table_row: { cells: cells.map((c) => chunkRichText(c)) } });
|
|
83
|
+
return {
|
|
84
|
+
object: "block",
|
|
85
|
+
type: "table",
|
|
86
|
+
table: { table_width: headers.length, has_column_header: true, has_row_header: false, children: [toRow(headers), ...rows.map(toRow)] },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---- markdown -> blocks (the workhorse; all artifacts are markdown) ----------
|
|
91
|
+
|
|
92
|
+
export function markdownToBlocks(md: string): Block[] {
|
|
93
|
+
const lines = String(md ?? "").split("\n");
|
|
94
|
+
const out: Block[] = [];
|
|
95
|
+
let i = 0;
|
|
96
|
+
while (i < lines.length) {
|
|
97
|
+
const raw = lines[i] ?? "";
|
|
98
|
+
const t = raw.trim();
|
|
99
|
+
if (t.startsWith("```")) {
|
|
100
|
+
const buf: string[] = [];
|
|
101
|
+
i++;
|
|
102
|
+
while (i < lines.length && !(lines[i] ?? "").trim().startsWith("```")) { buf.push(lines[i] ?? ""); i++; }
|
|
103
|
+
i++; // skip closing fence
|
|
104
|
+
out.push(codeBlock(buf.join("\n")));
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (!t) { i++; continue; }
|
|
108
|
+
if (t === "---" || t === "***" || t === "___") { out.push(divider()); i++; continue; }
|
|
109
|
+
const mh = t.match(/^(#{1,6})\s+(.*)$/);
|
|
110
|
+
if (mh) { out.push(heading(Math.min(3, mh[1]!.length) as 1 | 2 | 3, mh[2]!)); i++; continue; }
|
|
111
|
+
if (t.startsWith("> ")) { out.push(quote(t.slice(2))); i++; continue; }
|
|
112
|
+
const mb = t.match(/^[-*]\s+(.*)$/);
|
|
113
|
+
if (mb) { out.push(bullet(mb[1]!)); i++; continue; }
|
|
114
|
+
const mn = t.match(/^\d+\.\s+(.*)$/);
|
|
115
|
+
if (mn) { out.push(numbered(mn[1]!)); i++; continue; }
|
|
116
|
+
out.push(para(t));
|
|
117
|
+
i++;
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---- API-limit helpers (unit-tested) -----------------------------------------
|
|
123
|
+
|
|
124
|
+
// Split a children array into <=100-block append batches.
|
|
125
|
+
export function chunkChildren(blocks: Block[], size = CHILDREN_MAX): Block[][] {
|
|
126
|
+
const out: Block[][] = [];
|
|
127
|
+
for (let i = 0; i < blocks.length; i += size) out.push(blocks.slice(i, i + size));
|
|
128
|
+
return out.length ? out : [[]];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// True when no block nests deeper than maxDepth levels (Notion allows 2 per
|
|
132
|
+
// create/append). We keep tables/toggles to a single nested level, so this holds.
|
|
133
|
+
export function withinDepth(blocks: Block[], maxDepth = 2): boolean {
|
|
134
|
+
const depth = (bs: Block[], d: number): number => {
|
|
135
|
+
let max = d;
|
|
136
|
+
for (const b of bs) {
|
|
137
|
+
const t = b["type"] as string;
|
|
138
|
+
const inner = (obj(b[t])["children"]) as Block[] | undefined;
|
|
139
|
+
if (Array.isArray(inner) && inner.length) max = Math.max(max, depth(inner, d + 1));
|
|
140
|
+
}
|
|
141
|
+
return max;
|
|
142
|
+
};
|
|
143
|
+
return depth(blocks, 1) <= maxDepth;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---- artifact references (link vs upload decision) ---------------------------
|
|
147
|
+
|
|
148
|
+
export function artifactCategory(path: string): string {
|
|
149
|
+
if (path.startsWith("Brand/")) return "Brand";
|
|
150
|
+
if (path.startsWith("Deploy/")) return "Deploy";
|
|
151
|
+
if (path.startsWith("LandingPage/")) return "Landing";
|
|
152
|
+
if (path.endsWith(".svg")) return "Infographic";
|
|
153
|
+
if (path === "BuildPrompt.md") return "Build prompt";
|
|
154
|
+
return "Doc";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Decide how an artifact appears in the wiki: upload to Notion when small enough
|
|
158
|
+
// and an uploadable type, else link to its GitHub copy.
|
|
159
|
+
export function artifactRef(bytes: number, hasGithubUrl: boolean): "upload" | "link" {
|
|
160
|
+
return bytes <= NOTION_FREE_FILE_LIMIT && !hasGithubUrl ? "upload" : hasGithubUrl ? "link" : "upload";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---- the wiki plan -----------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
export interface WikiArtifact { name: string; path: string; category: string; bytes: number; url?: string; }
|
|
166
|
+
export interface WikiArea { key: string; title: string; emoji: string; blocks: Block[]; }
|
|
167
|
+
export interface WikiPlan { title: string; icon: string; overview: Block[]; areas: WikiArea[]; artifacts: WikiArtifact[]; }
|
|
168
|
+
|
|
169
|
+
export interface WikiBuildOptions {
|
|
170
|
+
manifest?: ArtifactManifest | null;
|
|
171
|
+
urls?: Record<string, string>; // artifact relpath -> GitHub blob URL
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// The 8 workflow areas, in order, with their emoji.
|
|
175
|
+
export const WIKI_AREAS: Array<{ key: string; title: string; emoji: string }> = [
|
|
176
|
+
{ key: "big-idea", title: "The Big Idea", emoji: "💡" },
|
|
177
|
+
{ key: "business-model", title: "Business Model", emoji: "💰" },
|
|
178
|
+
{ key: "brand", title: "Brand", emoji: "🎨" },
|
|
179
|
+
{ key: "market-research", title: "Market Research", emoji: "📊" },
|
|
180
|
+
{ key: "strategy", title: "Strategy", emoji: "📈" },
|
|
181
|
+
{ key: "product", title: "Product (PRD)", emoji: "📋" },
|
|
182
|
+
{ key: "compliance", title: "Compliance & Tax", emoji: "⚖️" },
|
|
183
|
+
{ key: "decision-science", title: "Decision Science", emoji: "🧭" },
|
|
184
|
+
{ key: "library", title: "Library", emoji: "📚" },
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
export function buildWikiPlan(state: ProgressState, opts: WikiBuildOptions = {}): WikiPlan {
|
|
188
|
+
const answers = obj(state.answers);
|
|
189
|
+
const brand = (state.brand && typeof state.brand === "object" ? state.brand : null) as Brand | null;
|
|
190
|
+
const guide = brand?.guide || null;
|
|
191
|
+
const grill = (state.grill && typeof state.grill === "object" ? state.grill : null) as Grill | null;
|
|
192
|
+
const swot = (state.swot && typeof state.swot === "object" ? state.swot : null) as Swot | null;
|
|
193
|
+
const prd = obj(state.prd);
|
|
194
|
+
const bizModel = obj(state.bizModel);
|
|
195
|
+
const research = obj(state.research);
|
|
196
|
+
const assumptions = (bizModel["assumptions"] || null) as Assumptions | null;
|
|
197
|
+
const brandName = str(guide?.brandName) || "Your Product";
|
|
198
|
+
const oneLiner = str(answers["what-statement"]) || str(answers["pitch"]) || str(guide?.positioning);
|
|
199
|
+
|
|
200
|
+
const overview: Block[] = [];
|
|
201
|
+
if (oneLiner) overview.push(callout(oneLiner, "💡"));
|
|
202
|
+
overview.push(para("This private workspace was generated by The Thought Layer. Each section below is a page; the Artifacts database links the files delivered to your repo."));
|
|
203
|
+
|
|
204
|
+
const areaBlocks: Record<string, Block[]> = {};
|
|
205
|
+
|
|
206
|
+
// Big idea: the one-liner, positioning, and the press release if present.
|
|
207
|
+
{
|
|
208
|
+
const b: Block[] = [];
|
|
209
|
+
if (oneLiner) b.push(heading(2, "What it is"), callout(oneLiner, "🎯"));
|
|
210
|
+
if (guide?.positioning) b.push(heading(2, "Who it is for"), para(guide.positioning));
|
|
211
|
+
const press = str(answers["press-release"]);
|
|
212
|
+
if (press) b.push(heading(2, "The press release"), ...markdownToBlocks(press));
|
|
213
|
+
areaBlocks["big-idea"] = b;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Business model: a parties table + the projection summary.
|
|
217
|
+
{
|
|
218
|
+
const b: Block[] = [];
|
|
219
|
+
const proj = computeProjection(assumptions);
|
|
220
|
+
if (proj && assumptions) {
|
|
221
|
+
const s = proj.summary;
|
|
222
|
+
const cur = assumptions.currency || "USD";
|
|
223
|
+
b.push(heading(2, "The numbers"));
|
|
224
|
+
b.push(callout(
|
|
225
|
+
`Year 1 revenue ${fmtMoney(s.year1Revenue, cur)}. Monthly break-even ${s.breakEvenMonth ? `month ${s.breakEvenMonth}` : "beyond horizon"}. Max cash drawdown ${fmtMoney(s.maxDrawdown, cur)}. MRR at month ${s.horizon} is ${fmtMoney(s.endingMRR, cur)}.`,
|
|
226
|
+
"💰",
|
|
227
|
+
));
|
|
228
|
+
const rows = (assumptions.parties || []).slice(0, 12).map((p) => [
|
|
229
|
+
str(p.name) || p.id, str(p.role), String(p.startingCount ?? ""), String(p.monthlyNewBase ?? ""),
|
|
230
|
+
`${p.monthlyChurnPct ?? 0}%`, fmtMoney(Number(p.revenuePerUnitPerMonth) || 0, cur), fmtMoney(Number(p.cacPerUnit) || 0, cur),
|
|
231
|
+
]);
|
|
232
|
+
b.push(heading(2, "Parties"), table(["Party", "Role", "Start", "New/mo", "Churn", "Rev/unit/mo", "CAC"], rows));
|
|
233
|
+
if (assumptions.narrative) b.push(heading(2, "Notes"), para(assumptions.narrative));
|
|
234
|
+
}
|
|
235
|
+
areaBlocks["business-model"] = b;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Brand: the style guide rendered natively, plus a palette table.
|
|
239
|
+
{
|
|
240
|
+
const b: Block[] = [];
|
|
241
|
+
if (guide) {
|
|
242
|
+
b.push(...markdownToBlocks(brandGuideMarkdown(guide)));
|
|
243
|
+
const pal = (guide.palette || []).filter((p) => p?.hex);
|
|
244
|
+
if (pal.length) b.push(heading(2, "Palette"), table(["Color", "Hex", "Role"], pal.map((p) => [str(p.name), str(p.hex), str(p.role)])));
|
|
245
|
+
}
|
|
246
|
+
areaBlocks["brand"] = b;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Market research.
|
|
250
|
+
{
|
|
251
|
+
const b: Block[] = [];
|
|
252
|
+
const brief = str(research["brief"]);
|
|
253
|
+
if (brief) {
|
|
254
|
+
const desc = str(research["description"]);
|
|
255
|
+
if (desc) b.push(callout(desc, "📊"));
|
|
256
|
+
b.push(...markdownToBlocks(brief));
|
|
257
|
+
}
|
|
258
|
+
areaBlocks["market-research"] = b;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Strategy: the SWOT.
|
|
262
|
+
{
|
|
263
|
+
const b: Block[] = [];
|
|
264
|
+
const hasSwot = !!swot && Object.values(swot).some((v) => Array.isArray(v) && v.some((x) => x && String(x).trim()));
|
|
265
|
+
if (hasSwot) b.push(...markdownToBlocks(swotMarkdown(swot)));
|
|
266
|
+
areaBlocks["strategy"] = b;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Product: the PRD, requirements, and glossary.
|
|
270
|
+
{
|
|
271
|
+
const b: Block[] = [];
|
|
272
|
+
const prdMd = str(prd["markdown"]);
|
|
273
|
+
if (prdMd) b.push(...markdownToBlocks(prdMd));
|
|
274
|
+
if (grill?.requirements?.length) { b.push(divider()); b.push(...markdownToBlocks(requirementsMarkdown(grill))); }
|
|
275
|
+
if (grill?.glossary?.length) { b.push(divider()); b.push(...markdownToBlocks(glossaryMarkdown(grill))); }
|
|
276
|
+
areaBlocks["product"] = b;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Compliance: the researched GRC, licensing, and tax report (from the
|
|
280
|
+
// thought-layer-compliance skill). The report carries its own disclaimer.
|
|
281
|
+
{
|
|
282
|
+
const b: Block[] = [];
|
|
283
|
+
const report = str(obj(state.governance)["report"]);
|
|
284
|
+
if (report.trim()) b.push(...markdownToBlocks(report));
|
|
285
|
+
areaBlocks["compliance"] = b;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Decision science: any dq-* / decision-* answers.
|
|
289
|
+
{
|
|
290
|
+
const b: Block[] = [];
|
|
291
|
+
const dq = Object.keys(answers).filter((k) => /^(dq|decision)/i.test(k) && str(answers[k]).trim());
|
|
292
|
+
if (dq.length) { b.push(heading(2, "Decision records")); for (const k of dq) b.push(bullet(`**${k}**: ${str(answers[k])}`)); }
|
|
293
|
+
areaBlocks["decision-science"] = b;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Library: no structured data in state; left empty (the io layer skips empties).
|
|
297
|
+
areaBlocks["library"] = [];
|
|
298
|
+
|
|
299
|
+
const areas: WikiArea[] = WIKI_AREAS
|
|
300
|
+
.map((a) => ({ ...a, blocks: areaBlocks[a.key] || [] }))
|
|
301
|
+
.filter((a) => a.blocks.length > 0);
|
|
302
|
+
|
|
303
|
+
// Artifacts for the database: skip the landing-page SEO sidecars (keep only
|
|
304
|
+
// index.html), keep everything else.
|
|
305
|
+
const urls = opts.urls || {};
|
|
306
|
+
const files = opts.manifest?.files || [];
|
|
307
|
+
const artifacts: WikiArtifact[] = files
|
|
308
|
+
.filter((f) => !(f.path.startsWith("LandingPage/") && f.path !== "LandingPage/index.html"))
|
|
309
|
+
.map((f) => ({ name: f.path, path: f.path, category: artifactCategory(f.path), bytes: f.bytes, ...(urls[f.path] ? { url: urls[f.path] } : {}) }));
|
|
310
|
+
|
|
311
|
+
return { title: `${brandName} workspace`, icon: "🚀", overview, areas, artifacts };
|
|
312
|
+
}
|
package/core/progress.ts
CHANGED
|
@@ -21,10 +21,10 @@ export const PROGRESS_FORMAT = 2;
|
|
|
21
21
|
|
|
22
22
|
export const KNOWN_STATE_KEYS = [
|
|
23
23
|
"version", "answers", "feedback", "bizModel", "grill",
|
|
24
|
-
"assets", "research", "swot", "prd", "naming", "brand", "kit",
|
|
24
|
+
"assets", "research", "swot", "prd", "naming", "brand", "governance", "kit",
|
|
25
25
|
] as const;
|
|
26
26
|
|
|
27
|
-
export type ArtifactKey = "bizModel" | "grill" | "assets" | "research" | "swot" | "prd" | "naming" | "brand";
|
|
27
|
+
export type ArtifactKey = "bizModel" | "grill" | "assets" | "research" | "swot" | "prd" | "naming" | "brand" | "governance";
|
|
28
28
|
|
|
29
29
|
export interface Writer { kind: "web" | "kit"; version?: string; ts: number; }
|
|
30
30
|
|
|
@@ -81,6 +81,7 @@ export interface ProgressState {
|
|
|
81
81
|
prd: unknown;
|
|
82
82
|
naming: unknown;
|
|
83
83
|
brand: unknown;
|
|
84
|
+
governance: unknown;
|
|
84
85
|
kit: KitNamespace | null;
|
|
85
86
|
[extra: string]: unknown;
|
|
86
87
|
}
|
|
@@ -99,7 +100,7 @@ export interface ProgressPayload {
|
|
|
99
100
|
export function emptyState(): ProgressState {
|
|
100
101
|
return {
|
|
101
102
|
version: 2, answers: {}, feedback: {}, bizModel: null, grill: null,
|
|
102
|
-
assets: null, research: null, swot: null, prd: null, naming: null, brand: null, kit: null,
|
|
103
|
+
assets: null, research: null, swot: null, prd: null, naming: null, brand: null, governance: null, kit: null,
|
|
103
104
|
};
|
|
104
105
|
}
|
|
105
106
|
|
|
@@ -132,6 +133,7 @@ export function parseProgress(text: string): ProgressPayload {
|
|
|
132
133
|
prd: s["prd"] ?? null,
|
|
133
134
|
naming: s["naming"] ?? null,
|
|
134
135
|
brand: s["brand"] ?? null,
|
|
136
|
+
governance: s["governance"] ?? null,
|
|
135
137
|
kit: (s["kit"] as KitNamespace | undefined) ?? null,
|
|
136
138
|
};
|
|
137
139
|
return {
|
|
@@ -149,7 +151,7 @@ export function parseProgress(text: string): ProgressPayload {
|
|
|
149
151
|
export function buildProgress(state: Partial<ProgressState>, writer: Writer, exportedAt: string): ProgressPayload {
|
|
150
152
|
const s = (state || {}) as Record<string, unknown>;
|
|
151
153
|
const {
|
|
152
|
-
answers, feedback, bizModel, grill, assets, research, swot, prd, naming, brand, kit,
|
|
154
|
+
answers, feedback, bizModel, grill, assets, research, swot, prd, naming, brand, governance, kit,
|
|
153
155
|
version: _v, exportedAt: _ea, formatNewer: _fn, ...rest
|
|
154
156
|
} = s;
|
|
155
157
|
return {
|
|
@@ -170,6 +172,7 @@ export function buildProgress(state: Partial<ProgressState>, writer: Writer, exp
|
|
|
170
172
|
prd: prd ?? null,
|
|
171
173
|
naming: naming ?? null,
|
|
172
174
|
brand: brand ?? null,
|
|
175
|
+
governance: governance ?? null,
|
|
173
176
|
kit: (kit as KitNamespace | undefined) ?? null,
|
|
174
177
|
...rest,
|
|
175
178
|
},
|
|
@@ -299,7 +302,7 @@ export function summarizeState(state: ProgressState): StateSummary {
|
|
|
299
302
|
if (s === "green" || s === "yellow" || s === "red") byStatus[s]++;
|
|
300
303
|
else byStatus.ungraded++;
|
|
301
304
|
}
|
|
302
|
-
const artifacts = (["bizModel", "grill", "assets", "research", "swot", "prd", "naming", "brand"] as const)
|
|
305
|
+
const artifacts = (["bizModel", "grill", "assets", "research", "swot", "prd", "naming", "brand", "governance"] as const)
|
|
303
306
|
.filter((k) => state[k] != null);
|
|
304
307
|
const kit = (state.kit && typeof state.kit === "object") ? state.kit : null;
|
|
305
308
|
return {
|
package/core/state-ops.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
type ArtifactKey, type PersonaInput, type EndState, type KitCursor,
|
|
13
13
|
} from "./progress.ts";
|
|
14
14
|
|
|
15
|
-
export const ARTIFACT_KEYS: ArtifactKey[] = ["bizModel", "grill", "assets", "research", "swot", "prd", "naming", "brand"];
|
|
15
|
+
export const ARTIFACT_KEYS: ArtifactKey[] = ["bizModel", "grill", "assets", "research", "swot", "prd", "naming", "brand", "governance"];
|
|
16
16
|
const END_STATES: EndState[] = ["open", "pass", "setAside"];
|
|
17
17
|
|
|
18
18
|
export interface StateOp {
|