@hobocode/thought-layer 0.1.0 → 0.2.1
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/core/index.ts +5 -0
- package/core/progress.ts +348 -0
- package/core/stage-map.ts +16 -0
- package/core/stages.ts +68 -0
- package/core/state-file.ts +56 -0
- package/core/state-ops.ts +104 -0
- package/dist/tl.js +453 -0
- package/extensions/thought-layer.ts +55 -1
- package/package.json +15 -2
- package/skills/thought-layer-brand/SKILL.md +4 -0
- package/skills/thought-layer-business-model/SKILL.md +5 -1
- package/skills/thought-layer-framework/SKILL.md +37 -0
- package/skills/thought-layer-grill/SKILL.md +4 -0
- package/skills/thought-layer-market-research/SKILL.md +5 -1
- package/skills/thought-layer-prd/SKILL.md +4 -0
- package/skills/thought-layer-strategy/SKILL.md +5 -1
package/core/index.ts
CHANGED
|
@@ -10,3 +10,8 @@
|
|
|
10
10
|
export * from "./scoring.ts";
|
|
11
11
|
export * from "./domains.ts";
|
|
12
12
|
export * from "./model.ts";
|
|
13
|
+
export * from "./progress.ts";
|
|
14
|
+
export * from "./stages.ts";
|
|
15
|
+
export * from "./stage-map.ts";
|
|
16
|
+
export * from "./state-file.ts";
|
|
17
|
+
export * from "./state-ops.ts";
|
package/core/progress.ts
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// The portable Thought Layer progress file: the interop format shared with the
|
|
2
|
+
// web app (src/lib/progressFile.js). An agent reads it to resume a long
|
|
3
|
+
// validation run and writes it to hand back to a co-founder using the web app.
|
|
4
|
+
//
|
|
5
|
+
// This module is the single deterministic source of envelope + feedback-entry
|
|
6
|
+
// assembly, so the model never hand-writes the JSON. Hand-written feedback is
|
|
7
|
+
// the main way a file gets corrupted (wrong thresholds, missing personas,
|
|
8
|
+
// statement-vs-text drift), so the tl_state tool and the CLI both build the
|
|
9
|
+
// objects here from prose + numbers the model supplies.
|
|
10
|
+
//
|
|
11
|
+
// Mirrors the web app's buildProgressPayload / parseProgressFile EXACTLY. Keep
|
|
12
|
+
// PROGRESS_FORMAT and KNOWN_STATE_KEYS in sync with src/lib/progressFile.js.
|
|
13
|
+
|
|
14
|
+
import { aggregateConfidence, statusFromConfidence } from "./scoring.ts";
|
|
15
|
+
import type { Status } from "./scoring.ts";
|
|
16
|
+
import { isAnswerableQid } from "./stages.ts";
|
|
17
|
+
import { ANSWERABLE_QIDS } from "./stage-map.ts";
|
|
18
|
+
|
|
19
|
+
export const APP = "thought-layer";
|
|
20
|
+
export const PROGRESS_FORMAT = 2;
|
|
21
|
+
|
|
22
|
+
export const KNOWN_STATE_KEYS = [
|
|
23
|
+
"version", "answers", "feedback", "bizModel", "grill",
|
|
24
|
+
"assets", "research", "swot", "prd", "naming", "brand", "kit",
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
export type ArtifactKey = "bizModel" | "grill" | "assets" | "research" | "swot" | "prd" | "naming" | "brand";
|
|
28
|
+
|
|
29
|
+
export interface Writer { kind: "web" | "kit"; version?: string; ts: number; }
|
|
30
|
+
|
|
31
|
+
export interface Suggestion { id: string; persona: string; summary: string; patch: string; }
|
|
32
|
+
|
|
33
|
+
export interface PersonaResult {
|
|
34
|
+
assessment: string;
|
|
35
|
+
confidence: number;
|
|
36
|
+
confidenceRationale: string;
|
|
37
|
+
suggestions: Suggestion[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface FeedbackEntry {
|
|
41
|
+
mode: string;
|
|
42
|
+
personas: Record<string, PersonaResult>;
|
|
43
|
+
confidence: number | null;
|
|
44
|
+
status: Status | null;
|
|
45
|
+
assessment: string;
|
|
46
|
+
suggestions: Suggestion[];
|
|
47
|
+
appliedIds: string[];
|
|
48
|
+
todos: Suggestion[];
|
|
49
|
+
round: number;
|
|
50
|
+
overridden: boolean;
|
|
51
|
+
exited: boolean;
|
|
52
|
+
stale: boolean;
|
|
53
|
+
ts: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface KitCursor {
|
|
57
|
+
stage?: "validation" | "model" | "design";
|
|
58
|
+
backboneStage?: number;
|
|
59
|
+
lastQuestionId?: string;
|
|
60
|
+
phase?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface KitNamespace {
|
|
64
|
+
schema: number;
|
|
65
|
+
cursor?: KitCursor;
|
|
66
|
+
modulesRun?: string[];
|
|
67
|
+
parked?: Record<string, string[]>;
|
|
68
|
+
panelMeta?: Record<string, unknown>;
|
|
69
|
+
updatedAt: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ProgressState {
|
|
73
|
+
version: number;
|
|
74
|
+
answers: Record<string, unknown>;
|
|
75
|
+
feedback: Record<string, unknown>;
|
|
76
|
+
bizModel: unknown;
|
|
77
|
+
grill: unknown;
|
|
78
|
+
assets: unknown;
|
|
79
|
+
research: unknown;
|
|
80
|
+
swot: unknown;
|
|
81
|
+
prd: unknown;
|
|
82
|
+
naming: unknown;
|
|
83
|
+
brand: unknown;
|
|
84
|
+
kit: KitNamespace | null;
|
|
85
|
+
[extra: string]: unknown;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ProgressPayload {
|
|
89
|
+
app: string;
|
|
90
|
+
format: number;
|
|
91
|
+
exportedAt: string;
|
|
92
|
+
writer?: Writer;
|
|
93
|
+
state: ProgressState;
|
|
94
|
+
formatNewer: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---- envelope ----------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
export function emptyState(): ProgressState {
|
|
100
|
+
return {
|
|
101
|
+
version: 2, answers: {}, feedback: {}, bizModel: null, grill: null,
|
|
102
|
+
assets: null, research: null, swot: null, prd: null, naming: null, brand: null, kit: null,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Lenient parse: validate the gate string, accept ANY format (a newer file is
|
|
107
|
+
// not rejected), default missing artifacts to null, and preserve unknown keys
|
|
108
|
+
// so a file from a newer build round-trips losslessly.
|
|
109
|
+
export function parseProgress(text: string): ProgressPayload {
|
|
110
|
+
let payload: Record<string, unknown>;
|
|
111
|
+
try { payload = JSON.parse(text) as Record<string, unknown>; }
|
|
112
|
+
catch { throw new Error("That file isn't valid JSON."); }
|
|
113
|
+
if (payload?.["app"] !== APP || !payload?.["state"]) {
|
|
114
|
+
throw new Error("That file isn't a Thought Layer progress file.");
|
|
115
|
+
}
|
|
116
|
+
const rawFormat = payload["format"];
|
|
117
|
+
const formatNewer = typeof rawFormat === "number" && rawFormat > PROGRESS_FORMAT;
|
|
118
|
+
const s = (payload["state"] && typeof payload["state"] === "object")
|
|
119
|
+
? payload["state"] as Record<string, unknown> : {};
|
|
120
|
+
const obj = (v: unknown): Record<string, unknown> =>
|
|
121
|
+
(v && typeof v === "object" && !Array.isArray(v)) ? v as Record<string, unknown> : {};
|
|
122
|
+
const state: ProgressState = {
|
|
123
|
+
...s,
|
|
124
|
+
version: 2,
|
|
125
|
+
answers: obj(s["answers"]),
|
|
126
|
+
feedback: obj(s["feedback"]),
|
|
127
|
+
bizModel: s["bizModel"] ?? null,
|
|
128
|
+
grill: s["grill"] ?? null,
|
|
129
|
+
assets: s["assets"] ?? null,
|
|
130
|
+
research: s["research"] ?? null,
|
|
131
|
+
swot: s["swot"] ?? null,
|
|
132
|
+
prd: s["prd"] ?? null,
|
|
133
|
+
naming: s["naming"] ?? null,
|
|
134
|
+
brand: s["brand"] ?? null,
|
|
135
|
+
kit: (s["kit"] as KitNamespace | undefined) ?? null,
|
|
136
|
+
};
|
|
137
|
+
return {
|
|
138
|
+
app: APP,
|
|
139
|
+
format: typeof rawFormat === "number" ? rawFormat : PROGRESS_FORMAT,
|
|
140
|
+
exportedAt: typeof payload["exportedAt"] === "string" ? payload["exportedAt"] as string : "",
|
|
141
|
+
writer: payload["writer"] as Writer | undefined,
|
|
142
|
+
state,
|
|
143
|
+
formatNewer,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Assemble the on-disk payload. Pulls known keys explicitly and preserves any
|
|
148
|
+
// unknown future key (rest), exactly like the web app's buildProgressPayload.
|
|
149
|
+
export function buildProgress(state: Partial<ProgressState>, writer: Writer, exportedAt: string): ProgressPayload {
|
|
150
|
+
const s = (state || {}) as Record<string, unknown>;
|
|
151
|
+
const {
|
|
152
|
+
answers, feedback, bizModel, grill, assets, research, swot, prd, naming, brand, kit,
|
|
153
|
+
version: _v, exportedAt: _ea, formatNewer: _fn, ...rest
|
|
154
|
+
} = s;
|
|
155
|
+
return {
|
|
156
|
+
app: APP,
|
|
157
|
+
format: PROGRESS_FORMAT,
|
|
158
|
+
exportedAt,
|
|
159
|
+
writer,
|
|
160
|
+
formatNewer: false,
|
|
161
|
+
state: {
|
|
162
|
+
version: 2,
|
|
163
|
+
answers: (answers as Record<string, unknown>) || {},
|
|
164
|
+
feedback: (feedback as Record<string, unknown>) || {},
|
|
165
|
+
bizModel: bizModel ?? null,
|
|
166
|
+
grill: grill ?? null,
|
|
167
|
+
assets: assets ?? null,
|
|
168
|
+
research: research ?? null,
|
|
169
|
+
swot: swot ?? null,
|
|
170
|
+
prd: prd ?? null,
|
|
171
|
+
naming: naming ?? null,
|
|
172
|
+
brand: brand ?? null,
|
|
173
|
+
kit: (kit as KitNamespace | undefined) ?? null,
|
|
174
|
+
...rest,
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function serializeProgress(payload: ProgressPayload): string {
|
|
180
|
+
const { formatNewer: _fn, ...wire } = payload;
|
|
181
|
+
return JSON.stringify(wire, null, 2) + "\n";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---- feedback assembly (the corruption guard) --------------------------------
|
|
185
|
+
|
|
186
|
+
const clampConfidence = (n: unknown): number =>
|
|
187
|
+
(typeof n === "number" && !Number.isNaN(n)) ? Math.min(1, Math.max(0, n)) : 0.6;
|
|
188
|
+
|
|
189
|
+
export interface PersonaInput {
|
|
190
|
+
persona: string;
|
|
191
|
+
assessment?: string;
|
|
192
|
+
confidence: number;
|
|
193
|
+
confidenceRationale?: string;
|
|
194
|
+
suggestions?: Array<{ id?: string; summary?: string; patch?: string }>;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// "pass" and "open" are byte-identical on disk: the web app derives done from
|
|
198
|
+
// confidence >= 0.85 && !stale, so a GENUINE pass must not set overridden (that
|
|
199
|
+
// is reserved for an explicit set-aside, which renders "set aside by you").
|
|
200
|
+
export type EndState = "open" | "pass" | "setAside";
|
|
201
|
+
|
|
202
|
+
export function buildFeedbackEntry(opts: {
|
|
203
|
+
mode: string;
|
|
204
|
+
personas: PersonaInput[];
|
|
205
|
+
endState?: EndState;
|
|
206
|
+
round?: number;
|
|
207
|
+
ts: number;
|
|
208
|
+
}): FeedbackEntry {
|
|
209
|
+
const { mode, personas: inputs, endState = "open", round = 1, ts } = opts;
|
|
210
|
+
const personas: Record<string, PersonaResult> = {};
|
|
211
|
+
for (const p of inputs) {
|
|
212
|
+
const k = p.persona;
|
|
213
|
+
personas[k] = {
|
|
214
|
+
assessment: p.assessment || "",
|
|
215
|
+
confidence: clampConfidence(p.confidence),
|
|
216
|
+
confidenceRationale: p.confidenceRationale || "",
|
|
217
|
+
suggestions: (p.suggestions || []).slice(0, 3).map((sg, i) => ({
|
|
218
|
+
id: `${k}-${sg.id || `s${i + 1}`}`,
|
|
219
|
+
persona: k,
|
|
220
|
+
summary: sg.summary || "Suggestion",
|
|
221
|
+
patch: sg.patch || "",
|
|
222
|
+
})),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const keys = Object.keys(personas);
|
|
226
|
+
const confidence = aggregateConfidence(keys.map((k) => personas[k]!.confidence));
|
|
227
|
+
const status = statusFromConfidence(confidence);
|
|
228
|
+
const suggestions = keys.flatMap((k) => personas[k]!.suggestions);
|
|
229
|
+
const assessment = mode !== "panel" && personas[mode] ? personas[mode]!.assessment : "";
|
|
230
|
+
|
|
231
|
+
const entry: FeedbackEntry = {
|
|
232
|
+
mode, personas, confidence, status, assessment,
|
|
233
|
+
suggestions, appliedIds: [], todos: [],
|
|
234
|
+
round, overridden: false, exited: false, stale: false, ts,
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
if (endState === "setAside") {
|
|
238
|
+
// Mirror QuestionPage.exit(): freeze unresolved suggestions as to-dos and
|
|
239
|
+
// force the dot green (the founder explicitly set this stage aside).
|
|
240
|
+
const seen = new Set<string>();
|
|
241
|
+
const todos = suggestions.filter((sg) => (seen.has(sg.id) ? false : (seen.add(sg.id), true)));
|
|
242
|
+
return { ...entry, overridden: true, exited: true, status: "green", todos };
|
|
243
|
+
}
|
|
244
|
+
return entry;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---- requirement normalization ----------------------------------------------
|
|
248
|
+
|
|
249
|
+
// The kit's grill describes a requirement as { id, category, statement }; the
|
|
250
|
+
// web app's prd/grill store it as { id, category, text }. Remap on write.
|
|
251
|
+
export function normalizeRequirements(
|
|
252
|
+
reqs: Array<{ id?: string; category?: string; statement?: string; text?: string }> | undefined,
|
|
253
|
+
): Array<{ id: string; category: string; text: string }> {
|
|
254
|
+
return (reqs || []).map((r, i) => ({
|
|
255
|
+
id: r.id || `r${i + 1}`,
|
|
256
|
+
category: r.category || "functional",
|
|
257
|
+
text: r.text ?? r.statement ?? "",
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Normalize an artifact value on write. For prd/grill, remap the kit's
|
|
262
|
+
// requirement `statement` field to the web app's `text` field so the structured
|
|
263
|
+
// views render. Everything else passes through untouched.
|
|
264
|
+
export function normalizeArtifactValue(key: ArtifactKey, value: unknown): unknown {
|
|
265
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
266
|
+
if (key === "prd" || key === "grill") {
|
|
267
|
+
const v = value as Record<string, unknown>;
|
|
268
|
+
if (Array.isArray(v["requirements"])) {
|
|
269
|
+
return { ...v, requirements: normalizeRequirements(v["requirements"] as Array<{ id?: string; category?: string; statement?: string; text?: string }>) };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return value;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---- resume summary ----------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
export interface StateSummary {
|
|
278
|
+
answered: number;
|
|
279
|
+
totalAnswerable: number;
|
|
280
|
+
byStatus: { green: number; yellow: number; red: number; ungraded: number };
|
|
281
|
+
artifacts: string[];
|
|
282
|
+
cursor: KitCursor | null;
|
|
283
|
+
modulesRun: string[];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const isFilled = (v: unknown): boolean =>
|
|
287
|
+
typeof v === "string" ? v.trim().length > 0
|
|
288
|
+
: Array.isArray(v) ? v.length > 0
|
|
289
|
+
: v != null && typeof v === "object" ? Object.keys(v).length > 0
|
|
290
|
+
: v != null;
|
|
291
|
+
|
|
292
|
+
export function summarizeState(state: ProgressState): StateSummary {
|
|
293
|
+
const answers = state.answers || {};
|
|
294
|
+
const answered = Object.keys(answers).filter((k) => isFilled(answers[k])).length;
|
|
295
|
+
const byStatus = { green: 0, yellow: 0, red: 0, ungraded: 0 };
|
|
296
|
+
for (const v of Object.values(state.feedback || {})) {
|
|
297
|
+
const fb = v as { status?: string; overridden?: boolean } | null;
|
|
298
|
+
const s = fb?.overridden ? "green" : fb?.status;
|
|
299
|
+
if (s === "green" || s === "yellow" || s === "red") byStatus[s]++;
|
|
300
|
+
else byStatus.ungraded++;
|
|
301
|
+
}
|
|
302
|
+
const artifacts = (["bizModel", "grill", "assets", "research", "swot", "prd", "naming", "brand"] as const)
|
|
303
|
+
.filter((k) => state[k] != null);
|
|
304
|
+
const kit = (state.kit && typeof state.kit === "object") ? state.kit : null;
|
|
305
|
+
return {
|
|
306
|
+
answered,
|
|
307
|
+
totalAnswerable: ANSWERABLE_QIDS.length,
|
|
308
|
+
byStatus,
|
|
309
|
+
artifacts,
|
|
310
|
+
cursor: kit?.cursor ?? null,
|
|
311
|
+
modulesRun: kit?.modulesRun ?? [],
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---- state mutators (keep the tool + CLI thin and consistent) ----------------
|
|
316
|
+
|
|
317
|
+
function withKit(state: ProgressState, patch: Partial<KitNamespace>, ts: number): KitNamespace {
|
|
318
|
+
const prev = (state.kit && typeof state.kit === "object") ? state.kit : { schema: 1, updatedAt: ts };
|
|
319
|
+
return { ...prev, ...patch, schema: 1, updatedAt: ts };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function setAnswer(state: ProgressState, qId: string, value: unknown, ts: number): ProgressState {
|
|
323
|
+
if (!isAnswerableQid(qId)) {
|
|
324
|
+
throw new Error(`"${qId}" is not an answerable Thought Layer question id. The agent leaves web-app-only fields to the founder; module sub-stage notes belong in kit.panelMeta.`);
|
|
325
|
+
}
|
|
326
|
+
return { ...state, answers: { ...state.answers, [qId]: value }, kit: withKit(state, {}, ts) };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function setFeedbackEntry(state: ProgressState, qId: string, entry: FeedbackEntry, ts: number): ProgressState {
|
|
330
|
+
return { ...state, feedback: { ...state.feedback, [qId]: entry }, kit: withKit(state, {}, ts) };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function setArtifact(state: ProgressState, key: ArtifactKey, value: unknown, ts: number): ProgressState {
|
|
334
|
+
return { ...state, [key]: value, kit: withKit(state, {}, ts) };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function setCursor(state: ProgressState, cursor: KitCursor, ts: number): ProgressState {
|
|
338
|
+
return { ...state, kit: withKit(state, { cursor }, ts) };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Park a panel note that has no web-app question id (module sub-stage verdicts,
|
|
342
|
+
// later-stage concerns surfaced early) under the agent-owned kit namespace.
|
|
343
|
+
export function parkNote(state: ProgressState, key: string, note: string, ts: number): ProgressState {
|
|
344
|
+
const prev = (state.kit && typeof state.kit === "object") ? state.kit : { schema: 1, updatedAt: ts };
|
|
345
|
+
const parked = { ...(prev.parked || {}) };
|
|
346
|
+
parked[key] = [...(parked[key] || []), note];
|
|
347
|
+
return { ...state, kit: withKit(state, { parked }, ts) };
|
|
348
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// GENERATED from the web app's stage-map.json by scripts/sync-stage-map.mjs.
|
|
2
|
+
// Do not edit by hand. This is the vendored copy of the web app's question
|
|
3
|
+
// registry; it is the single source of truth for which question ids exist, so
|
|
4
|
+
// an agent never writes an answer the web app cannot represent. Regenerate with
|
|
5
|
+
// `node scripts/sync-stage-map.mjs` after the web app's sections.js changes.
|
|
6
|
+
|
|
7
|
+
export const STATE_FORMAT = 2;
|
|
8
|
+
|
|
9
|
+
export const AREAS: readonly string[] = ["big-idea","business-model","brand","market-research","strategy","product","decision-science","library"];
|
|
10
|
+
|
|
11
|
+
// Every question id, in canonical order.
|
|
12
|
+
export const ALL_QIDS: readonly string[] = ["what-statement","domain-experience","domain-gaps","paid-today","evidence","target-market","incumbent-gap","pitch","headline","customer-quote","press-why-now","deck-audience","landing-goal","asset-builder","commitment","cost-architecture","cost-risk","realistic-goal","pricing-model","first-ten","retention","crm-approach","crm-community","support-model","support-scaling","bm-who-buys","bm-who-supplies","bm-parties","bm-builder","naming-studio","brand-feel","brand-unlike","brand-studio","market-research","swot","prd-problem","prd-not-building","prd-draft","prd-grill","review","dq-frame","dq-alternatives","dq-information","dq-values","dq-reasoning","dq-commitment","library"];
|
|
13
|
+
|
|
14
|
+
// Question ids that hold a value under answers[] (text / repeatable / parties /
|
|
15
|
+
// research-flagged text). Artifact and page steps are excluded.
|
|
16
|
+
export const ANSWERABLE_QIDS: readonly string[] = ["what-statement","domain-experience","domain-gaps","paid-today","evidence","target-market","incumbent-gap","pitch","headline","customer-quote","press-why-now","deck-audience","landing-goal","commitment","cost-architecture","cost-risk","realistic-goal","pricing-model","first-ten","retention","crm-approach","crm-community","support-model","support-scaling","bm-who-buys","bm-who-supplies","bm-parties","brand-feel","brand-unlike","market-research","prd-problem","prd-not-building","dq-frame","dq-alternatives","dq-information","dq-values","dq-reasoning","dq-commitment"];
|
package/core/stages.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The kit's mapping from framework backbone stages (and the optional modules)
|
|
2
|
+
// to the web app question ids they write into answers[]. Hand-maintained, but
|
|
3
|
+
// guarded: core/stages.test.ts fails if any mapped qId is missing from the
|
|
4
|
+
// vendored stage-map (regenerated from the web app). That is the drift
|
|
5
|
+
// protection for the two-repo interop contract - rename a qId in the web app,
|
|
6
|
+
// re-run sync-stage-map, and the test goes red until this map is updated.
|
|
7
|
+
|
|
8
|
+
import { ANSWERABLE_QIDS, ALL_QIDS } from "./stage-map.ts";
|
|
9
|
+
|
|
10
|
+
const ANSWERABLE_SET = new Set(ANSWERABLE_QIDS);
|
|
11
|
+
const ALL_SET = new Set(ALL_QIDS);
|
|
12
|
+
|
|
13
|
+
// A question id the agent may write a value into answers[] for.
|
|
14
|
+
export function isAnswerableQid(qId: string): boolean {
|
|
15
|
+
return ANSWERABLE_SET.has(qId);
|
|
16
|
+
}
|
|
17
|
+
// A question id (answerable or artifact/page step) the web app knows about.
|
|
18
|
+
export function isKnownQid(qId: string): boolean {
|
|
19
|
+
return ALL_SET.has(qId);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Backbone stage (in framework order) -> the answer qIds it writes. Stages that
|
|
23
|
+
// only produce an artifact (PRD, grill, business-model numbers) are noted but
|
|
24
|
+
// have no answer ids beyond the prose questions listed here.
|
|
25
|
+
export const BACKBONE_QID_MAP: Record<string, readonly string[]> = {
|
|
26
|
+
"concise-what": ["what-statement"],
|
|
27
|
+
"domain-knowledge": ["domain-experience", "domain-gaps"],
|
|
28
|
+
"validation": ["paid-today", "evidence"],
|
|
29
|
+
"market-selection": ["target-market", "incumbent-gap"],
|
|
30
|
+
"thirty-second-test": ["pitch"],
|
|
31
|
+
"time": ["commitment"],
|
|
32
|
+
"costs": ["cost-architecture", "cost-risk"],
|
|
33
|
+
"scale": ["realistic-goal"],
|
|
34
|
+
"pricing": ["pricing-model"],
|
|
35
|
+
"business-model": ["bm-who-buys", "bm-who-supplies", "bm-parties"],
|
|
36
|
+
"customer-acquisition": ["first-ten", "retention"],
|
|
37
|
+
"customer-relationships": ["crm-approach", "crm-community"],
|
|
38
|
+
"support": ["support-model", "support-scaling"],
|
|
39
|
+
"prd": ["prd-problem", "prd-not-building"],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Optional deep-dive modules -> the answer qIds they may write. The modules
|
|
43
|
+
// mostly converge into backbone qIds; these are the ids unique to a module.
|
|
44
|
+
export const MODULE_QID_MAP: Record<string, readonly string[]> = {
|
|
45
|
+
"market-research": ["market-research"],
|
|
46
|
+
"brand": ["brand-feel", "brand-unlike"],
|
|
47
|
+
// strategy folds into market-selection (target-market / incumbent-gap) and
|
|
48
|
+
// produces the swot artifact; business-model folds into costs/pricing and
|
|
49
|
+
// produces the bizModel artifact - neither has a unique answer qId.
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Every answer qId the kit may write, across backbone + modules. The drift test
|
|
53
|
+
// asserts this is a subset of the web app's answerable registry.
|
|
54
|
+
export const KIT_WRITTEN_QIDS: readonly string[] = [
|
|
55
|
+
...new Set([
|
|
56
|
+
...Object.values(BACKBONE_QID_MAP).flat(),
|
|
57
|
+
...Object.values(MODULE_QID_MAP).flat(),
|
|
58
|
+
]),
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
// qIds the agent deliberately leaves for the founder in the web app (no kit
|
|
62
|
+
// stage). Surfaced so the framework skill can skip them explicitly rather than
|
|
63
|
+
// writing empty strings (which read as "attempted and blank").
|
|
64
|
+
export const WEB_APP_ONLY_QIDS: readonly string[] = [
|
|
65
|
+
"headline", "customer-quote", "press-why-now", // press release
|
|
66
|
+
"deck-audience", "landing-goal", // launch assets
|
|
67
|
+
"dq-frame", "dq-alternatives", "dq-information", "dq-values", "dq-reasoning", "dq-commitment", // decision science
|
|
68
|
+
];
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Node filesystem layer for the portable progress file. The pure transforms
|
|
2
|
+
// live in progress.ts; this is the thin IO both frontends share - the Pi
|
|
3
|
+
// tl_state tool and the CLI bin. Kept out of progress.ts so the transforms stay
|
|
4
|
+
// testable without touching disk.
|
|
5
|
+
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
8
|
+
import {
|
|
9
|
+
parseProgress, buildProgress, serializeProgress, emptyState,
|
|
10
|
+
type ProgressState, type ProgressPayload, type Writer,
|
|
11
|
+
} from "./progress.ts";
|
|
12
|
+
|
|
13
|
+
export const STATE_DIR = ".thought-layer";
|
|
14
|
+
export const STATE_FILE = "state.json";
|
|
15
|
+
|
|
16
|
+
// Resolve the canonical state file path. `target` may be a project directory or
|
|
17
|
+
// a direct path to a .json file; defaults to <cwd>/.thought-layer/state.json.
|
|
18
|
+
export function resolveStatePath(target?: string, cwd: string = process.cwd()): string {
|
|
19
|
+
if (!target) return join(cwd, STATE_DIR, STATE_FILE);
|
|
20
|
+
const abs = isAbsolute(target) ? target : resolve(cwd, target);
|
|
21
|
+
return abs.endsWith(".json") ? abs : join(abs, STATE_DIR, STATE_FILE);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface LoadResult {
|
|
25
|
+
path: string;
|
|
26
|
+
exists: boolean;
|
|
27
|
+
payload: ProgressPayload;
|
|
28
|
+
state: ProgressState;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Read the state file. A missing file is not an error: it returns an empty
|
|
32
|
+
// state so the caller can start a fresh run and write it on first save.
|
|
33
|
+
export function loadStateFile(target?: string, cwd?: string): LoadResult {
|
|
34
|
+
const path = resolveStatePath(target, cwd);
|
|
35
|
+
if (!existsSync(path)) {
|
|
36
|
+
const state = emptyState();
|
|
37
|
+
return { path, exists: false, payload: buildProgress(state, { kind: "kit", ts: 0 }, ""), state };
|
|
38
|
+
}
|
|
39
|
+
const payload = parseProgress(readFileSync(path, "utf8"));
|
|
40
|
+
return { path, exists: true, payload, state: payload.state };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Write the state file, stamping the kit writer + an ISO exportedAt. Creates the
|
|
44
|
+
// .thought-layer directory on first write.
|
|
45
|
+
export function saveStateFile(
|
|
46
|
+
state: ProgressState,
|
|
47
|
+
opts: { target?: string; cwd?: string; version?: string; ts: number; exportedAt: string },
|
|
48
|
+
): { path: string; bytes: number } {
|
|
49
|
+
const path = resolveStatePath(opts.target, opts.cwd);
|
|
50
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
51
|
+
const writer: Writer = { kind: "kit", ts: opts.ts };
|
|
52
|
+
if (opts.version) writer.version = opts.version;
|
|
53
|
+
const text = serializeProgress(buildProgress(state, writer, opts.exportedAt));
|
|
54
|
+
writeFileSync(path, text);
|
|
55
|
+
return { path, bytes: Buffer.byteLength(text) };
|
|
56
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The single dispatch for every progress-file operation, shared by both
|
|
2
|
+
// frontends - the Pi tl_state tool and the CLI bin - so they can never drift.
|
|
3
|
+
// The model (or the shell) supplies a payload of prose + numbers; this builds
|
|
4
|
+
// the exact on-disk objects via core/progress.ts and writes through
|
|
5
|
+
// core/state-file.ts. Pure of any host concern (no Pi types, no argv).
|
|
6
|
+
|
|
7
|
+
import { gradeFromConfidence } from "./scoring.ts";
|
|
8
|
+
import { loadStateFile, saveStateFile } from "./state-file.ts";
|
|
9
|
+
import {
|
|
10
|
+
setAnswer, setArtifact, setCursor, parkNote, buildFeedbackEntry,
|
|
11
|
+
normalizeArtifactValue, summarizeState,
|
|
12
|
+
type ArtifactKey, type PersonaInput, type EndState, type KitCursor,
|
|
13
|
+
} from "./progress.ts";
|
|
14
|
+
|
|
15
|
+
export const ARTIFACT_KEYS: ArtifactKey[] = ["bizModel", "grill", "assets", "research", "swot", "prd", "naming", "brand"];
|
|
16
|
+
const END_STATES: EndState[] = ["open", "pass", "setAside"];
|
|
17
|
+
|
|
18
|
+
export interface StateOp {
|
|
19
|
+
op: string;
|
|
20
|
+
path?: string;
|
|
21
|
+
qId?: string;
|
|
22
|
+
value?: unknown;
|
|
23
|
+
artifact?: string;
|
|
24
|
+
mode?: string;
|
|
25
|
+
personas?: PersonaInput[];
|
|
26
|
+
endState?: string;
|
|
27
|
+
round?: number;
|
|
28
|
+
cursor?: KitCursor;
|
|
29
|
+
key?: string;
|
|
30
|
+
note?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface StateOpResult {
|
|
34
|
+
ok: boolean;
|
|
35
|
+
message: string;
|
|
36
|
+
details: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function applyStateOp(p: StateOp, ctx: { ts: number; exportedAt: string }): StateOpResult {
|
|
40
|
+
const { ts, exportedAt } = ctx;
|
|
41
|
+
const fail = (message: string): StateOpResult => ({ ok: false, message, details: {} });
|
|
42
|
+
try {
|
|
43
|
+
const loaded = loadStateFile(p.path);
|
|
44
|
+
const save = (next: typeof loaded.state) => saveStateFile(next, { target: p.path, ts, exportedAt }).path;
|
|
45
|
+
|
|
46
|
+
if (p.op === "read" || p.op === "export") {
|
|
47
|
+
const sum = summarizeState(loaded.state);
|
|
48
|
+
const message = loaded.exists
|
|
49
|
+
? `Loaded ${loaded.path}: ${sum.answered}/${sum.totalAnswerable} answered ` +
|
|
50
|
+
`(${sum.byStatus.green} green, ${sum.byStatus.yellow} yellow, ${sum.byStatus.red} red), ` +
|
|
51
|
+
`artifacts: ${sum.artifacts.join(", ") || "none"}. ` +
|
|
52
|
+
`Resume at ${sum.cursor ? `stage ${sum.cursor.backboneStage ?? "?"} (${sum.cursor.phase ?? "?"})` : "the beginning"}.`
|
|
53
|
+
: `No state file yet at ${loaded.path}. Start a fresh run; it will be created on first write.`;
|
|
54
|
+
return { ok: true, message, details: { path: loaded.path, exists: loaded.exists, summary: sum, state: loaded.state } };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (p.op === "answer") {
|
|
58
|
+
if (!p.qId || typeof p.value !== "string") return fail("answer needs a qId and a string value.");
|
|
59
|
+
const path = save(setAnswer(loaded.state, p.qId, p.value, ts));
|
|
60
|
+
return { ok: true, message: `Recorded answer for "${p.qId}" and saved ${path}.`, details: { path, qId: p.qId } };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (p.op === "feedback") {
|
|
64
|
+
if (!p.qId || !p.personas?.length) return fail("feedback needs a qId and at least one persona.");
|
|
65
|
+
const endState = (END_STATES as string[]).includes(p.endState || "") ? (p.endState as EndState) : "open";
|
|
66
|
+
const mode = p.mode || (p.personas.length > 1 ? "panel" : p.personas[0]!.persona);
|
|
67
|
+
const entry = buildFeedbackEntry({ mode, personas: p.personas, endState, round: p.round, ts });
|
|
68
|
+
const next = { ...loaded.state, feedback: { ...loaded.state.feedback, [p.qId]: entry } };
|
|
69
|
+
const path = save(next);
|
|
70
|
+
const grade = gradeFromConfidence(entry.confidence);
|
|
71
|
+
const pct = entry.confidence != null ? `${(entry.confidence * 100).toFixed(0)}%` : "n/a";
|
|
72
|
+
const tail = endState === "setAside" ? `, set aside with ${entry.todos.length} to-do(s)` : endState === "pass" ? ", cleared the bar" : "";
|
|
73
|
+
return {
|
|
74
|
+
ok: true,
|
|
75
|
+
message: `Recorded panel verdict for "${p.qId}": confidence ${pct} -> ${entry.status}, grade ${grade}${tail}. Saved ${path}.`,
|
|
76
|
+
details: { path, qId: p.qId, confidence: entry.confidence, status: entry.status, grade },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (p.op === "artifact") {
|
|
81
|
+
const key = p.artifact as ArtifactKey;
|
|
82
|
+
if (!ARTIFACT_KEYS.includes(key)) return fail(`artifact needs a key in: ${ARTIFACT_KEYS.join(", ")}.`);
|
|
83
|
+
if (p.value == null) return fail("artifact needs a value object.");
|
|
84
|
+
const path = save(setArtifact(loaded.state, key, normalizeArtifactValue(key, p.value), ts));
|
|
85
|
+
return { ok: true, message: `Stored ${key} artifact and saved ${path}.`, details: { path, artifact: key } };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (p.op === "cursor") {
|
|
89
|
+
if (!p.cursor) return fail("cursor needs a cursor object.");
|
|
90
|
+
const path = save(setCursor(loaded.state, p.cursor, ts));
|
|
91
|
+
return { ok: true, message: `Saved resume cursor (stage ${p.cursor.backboneStage ?? "?"}, ${p.cursor.phase ?? "?"}) to ${path}.`, details: { path } };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (p.op === "park") {
|
|
95
|
+
if (!p.key || !p.note) return fail("park needs a key and a note.");
|
|
96
|
+
const path = save(parkNote(loaded.state, p.key, p.note, ts));
|
|
97
|
+
return { ok: true, message: `Parked a note under "${p.key}" and saved ${path}.`, details: { path, key: p.key } };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return fail(`Unknown op "${p.op}". Use read, answer, feedback, artifact, cursor, park, or export.`);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
return fail(`tl_state error: ${(e as Error).message}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
package/dist/tl.js
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// bin/tl.ts
|
|
4
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
5
|
+
|
|
6
|
+
// core/scoring.ts
|
|
7
|
+
var CONFIDENCE_GOAL = 0.85;
|
|
8
|
+
function statusFromConfidence(c) {
|
|
9
|
+
if (typeof c !== "number" || Number.isNaN(c)) return null;
|
|
10
|
+
if (c >= CONFIDENCE_GOAL) return "green";
|
|
11
|
+
if (c >= 0.6) return "yellow";
|
|
12
|
+
return "red";
|
|
13
|
+
}
|
|
14
|
+
function gradeFromConfidence(c) {
|
|
15
|
+
if (typeof c !== "number" || Number.isNaN(c)) return null;
|
|
16
|
+
if (c >= 0.9) return "A";
|
|
17
|
+
if (c >= 0.8) return "B";
|
|
18
|
+
if (c >= 0.7) return "C";
|
|
19
|
+
if (c >= 0.6) return "D";
|
|
20
|
+
return "F";
|
|
21
|
+
}
|
|
22
|
+
function aggregateConfidence(values) {
|
|
23
|
+
const nums = (values || []).filter((v) => typeof v === "number" && !Number.isNaN(v));
|
|
24
|
+
if (nums.length === 0) return null;
|
|
25
|
+
return nums.reduce((a, b) => a + b, 0) / nums.length;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// core/state-file.ts
|
|
29
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
30
|
+
import { dirname, isAbsolute, join, resolve } from "path";
|
|
31
|
+
|
|
32
|
+
// core/stage-map.ts
|
|
33
|
+
var ALL_QIDS = ["what-statement", "domain-experience", "domain-gaps", "paid-today", "evidence", "target-market", "incumbent-gap", "pitch", "headline", "customer-quote", "press-why-now", "deck-audience", "landing-goal", "asset-builder", "commitment", "cost-architecture", "cost-risk", "realistic-goal", "pricing-model", "first-ten", "retention", "crm-approach", "crm-community", "support-model", "support-scaling", "bm-who-buys", "bm-who-supplies", "bm-parties", "bm-builder", "naming-studio", "brand-feel", "brand-unlike", "brand-studio", "market-research", "swot", "prd-problem", "prd-not-building", "prd-draft", "prd-grill", "review", "dq-frame", "dq-alternatives", "dq-information", "dq-values", "dq-reasoning", "dq-commitment", "library"];
|
|
34
|
+
var ANSWERABLE_QIDS = ["what-statement", "domain-experience", "domain-gaps", "paid-today", "evidence", "target-market", "incumbent-gap", "pitch", "headline", "customer-quote", "press-why-now", "deck-audience", "landing-goal", "commitment", "cost-architecture", "cost-risk", "realistic-goal", "pricing-model", "first-ten", "retention", "crm-approach", "crm-community", "support-model", "support-scaling", "bm-who-buys", "bm-who-supplies", "bm-parties", "brand-feel", "brand-unlike", "market-research", "prd-problem", "prd-not-building", "dq-frame", "dq-alternatives", "dq-information", "dq-values", "dq-reasoning", "dq-commitment"];
|
|
35
|
+
|
|
36
|
+
// core/stages.ts
|
|
37
|
+
var ANSWERABLE_SET = new Set(ANSWERABLE_QIDS);
|
|
38
|
+
var ALL_SET = new Set(ALL_QIDS);
|
|
39
|
+
function isAnswerableQid(qId) {
|
|
40
|
+
return ANSWERABLE_SET.has(qId);
|
|
41
|
+
}
|
|
42
|
+
var BACKBONE_QID_MAP = {
|
|
43
|
+
"concise-what": ["what-statement"],
|
|
44
|
+
"domain-knowledge": ["domain-experience", "domain-gaps"],
|
|
45
|
+
"validation": ["paid-today", "evidence"],
|
|
46
|
+
"market-selection": ["target-market", "incumbent-gap"],
|
|
47
|
+
"thirty-second-test": ["pitch"],
|
|
48
|
+
"time": ["commitment"],
|
|
49
|
+
"costs": ["cost-architecture", "cost-risk"],
|
|
50
|
+
"scale": ["realistic-goal"],
|
|
51
|
+
"pricing": ["pricing-model"],
|
|
52
|
+
"business-model": ["bm-who-buys", "bm-who-supplies", "bm-parties"],
|
|
53
|
+
"customer-acquisition": ["first-ten", "retention"],
|
|
54
|
+
"customer-relationships": ["crm-approach", "crm-community"],
|
|
55
|
+
"support": ["support-model", "support-scaling"],
|
|
56
|
+
"prd": ["prd-problem", "prd-not-building"]
|
|
57
|
+
};
|
|
58
|
+
var MODULE_QID_MAP = {
|
|
59
|
+
"market-research": ["market-research"],
|
|
60
|
+
"brand": ["brand-feel", "brand-unlike"]
|
|
61
|
+
// strategy folds into market-selection (target-market / incumbent-gap) and
|
|
62
|
+
// produces the swot artifact; business-model folds into costs/pricing and
|
|
63
|
+
// produces the bizModel artifact - neither has a unique answer qId.
|
|
64
|
+
};
|
|
65
|
+
var KIT_WRITTEN_QIDS = [
|
|
66
|
+
.../* @__PURE__ */ new Set([
|
|
67
|
+
...Object.values(BACKBONE_QID_MAP).flat(),
|
|
68
|
+
...Object.values(MODULE_QID_MAP).flat()
|
|
69
|
+
])
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
// core/progress.ts
|
|
73
|
+
var APP = "thought-layer";
|
|
74
|
+
var PROGRESS_FORMAT = 2;
|
|
75
|
+
function emptyState() {
|
|
76
|
+
return {
|
|
77
|
+
version: 2,
|
|
78
|
+
answers: {},
|
|
79
|
+
feedback: {},
|
|
80
|
+
bizModel: null,
|
|
81
|
+
grill: null,
|
|
82
|
+
assets: null,
|
|
83
|
+
research: null,
|
|
84
|
+
swot: null,
|
|
85
|
+
prd: null,
|
|
86
|
+
naming: null,
|
|
87
|
+
brand: null,
|
|
88
|
+
kit: null
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function parseProgress(text) {
|
|
92
|
+
let payload;
|
|
93
|
+
try {
|
|
94
|
+
payload = JSON.parse(text);
|
|
95
|
+
} catch {
|
|
96
|
+
throw new Error("That file isn't valid JSON.");
|
|
97
|
+
}
|
|
98
|
+
if (payload?.["app"] !== APP || !payload?.["state"]) {
|
|
99
|
+
throw new Error("That file isn't a Thought Layer progress file.");
|
|
100
|
+
}
|
|
101
|
+
const rawFormat = payload["format"];
|
|
102
|
+
const formatNewer = typeof rawFormat === "number" && rawFormat > PROGRESS_FORMAT;
|
|
103
|
+
const s = payload["state"] && typeof payload["state"] === "object" ? payload["state"] : {};
|
|
104
|
+
const obj = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
105
|
+
const state = {
|
|
106
|
+
...s,
|
|
107
|
+
version: 2,
|
|
108
|
+
answers: obj(s["answers"]),
|
|
109
|
+
feedback: obj(s["feedback"]),
|
|
110
|
+
bizModel: s["bizModel"] ?? null,
|
|
111
|
+
grill: s["grill"] ?? null,
|
|
112
|
+
assets: s["assets"] ?? null,
|
|
113
|
+
research: s["research"] ?? null,
|
|
114
|
+
swot: s["swot"] ?? null,
|
|
115
|
+
prd: s["prd"] ?? null,
|
|
116
|
+
naming: s["naming"] ?? null,
|
|
117
|
+
brand: s["brand"] ?? null,
|
|
118
|
+
kit: s["kit"] ?? null
|
|
119
|
+
};
|
|
120
|
+
return {
|
|
121
|
+
app: APP,
|
|
122
|
+
format: typeof rawFormat === "number" ? rawFormat : PROGRESS_FORMAT,
|
|
123
|
+
exportedAt: typeof payload["exportedAt"] === "string" ? payload["exportedAt"] : "",
|
|
124
|
+
writer: payload["writer"],
|
|
125
|
+
state,
|
|
126
|
+
formatNewer
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function buildProgress(state, writer, exportedAt) {
|
|
130
|
+
const s = state || {};
|
|
131
|
+
const {
|
|
132
|
+
answers,
|
|
133
|
+
feedback,
|
|
134
|
+
bizModel,
|
|
135
|
+
grill,
|
|
136
|
+
assets,
|
|
137
|
+
research,
|
|
138
|
+
swot,
|
|
139
|
+
prd,
|
|
140
|
+
naming,
|
|
141
|
+
brand,
|
|
142
|
+
kit,
|
|
143
|
+
version: _v,
|
|
144
|
+
exportedAt: _ea,
|
|
145
|
+
formatNewer: _fn,
|
|
146
|
+
...rest
|
|
147
|
+
} = s;
|
|
148
|
+
return {
|
|
149
|
+
app: APP,
|
|
150
|
+
format: PROGRESS_FORMAT,
|
|
151
|
+
exportedAt,
|
|
152
|
+
writer,
|
|
153
|
+
formatNewer: false,
|
|
154
|
+
state: {
|
|
155
|
+
version: 2,
|
|
156
|
+
answers: answers || {},
|
|
157
|
+
feedback: feedback || {},
|
|
158
|
+
bizModel: bizModel ?? null,
|
|
159
|
+
grill: grill ?? null,
|
|
160
|
+
assets: assets ?? null,
|
|
161
|
+
research: research ?? null,
|
|
162
|
+
swot: swot ?? null,
|
|
163
|
+
prd: prd ?? null,
|
|
164
|
+
naming: naming ?? null,
|
|
165
|
+
brand: brand ?? null,
|
|
166
|
+
kit: kit ?? null,
|
|
167
|
+
...rest
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function serializeProgress(payload) {
|
|
172
|
+
const { formatNewer: _fn, ...wire } = payload;
|
|
173
|
+
return JSON.stringify(wire, null, 2) + "\n";
|
|
174
|
+
}
|
|
175
|
+
var clampConfidence = (n) => typeof n === "number" && !Number.isNaN(n) ? Math.min(1, Math.max(0, n)) : 0.6;
|
|
176
|
+
function buildFeedbackEntry(opts) {
|
|
177
|
+
const { mode, personas: inputs, endState = "open", round = 1, ts } = opts;
|
|
178
|
+
const personas = {};
|
|
179
|
+
for (const p of inputs) {
|
|
180
|
+
const k = p.persona;
|
|
181
|
+
personas[k] = {
|
|
182
|
+
assessment: p.assessment || "",
|
|
183
|
+
confidence: clampConfidence(p.confidence),
|
|
184
|
+
confidenceRationale: p.confidenceRationale || "",
|
|
185
|
+
suggestions: (p.suggestions || []).slice(0, 3).map((sg, i) => ({
|
|
186
|
+
id: `${k}-${sg.id || `s${i + 1}`}`,
|
|
187
|
+
persona: k,
|
|
188
|
+
summary: sg.summary || "Suggestion",
|
|
189
|
+
patch: sg.patch || ""
|
|
190
|
+
}))
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const keys = Object.keys(personas);
|
|
194
|
+
const confidence = aggregateConfidence(keys.map((k) => personas[k].confidence));
|
|
195
|
+
const status = statusFromConfidence(confidence);
|
|
196
|
+
const suggestions = keys.flatMap((k) => personas[k].suggestions);
|
|
197
|
+
const assessment = mode !== "panel" && personas[mode] ? personas[mode].assessment : "";
|
|
198
|
+
const entry = {
|
|
199
|
+
mode,
|
|
200
|
+
personas,
|
|
201
|
+
confidence,
|
|
202
|
+
status,
|
|
203
|
+
assessment,
|
|
204
|
+
suggestions,
|
|
205
|
+
appliedIds: [],
|
|
206
|
+
todos: [],
|
|
207
|
+
round,
|
|
208
|
+
overridden: false,
|
|
209
|
+
exited: false,
|
|
210
|
+
stale: false,
|
|
211
|
+
ts
|
|
212
|
+
};
|
|
213
|
+
if (endState === "setAside") {
|
|
214
|
+
const seen = /* @__PURE__ */ new Set();
|
|
215
|
+
const todos = suggestions.filter((sg) => seen.has(sg.id) ? false : (seen.add(sg.id), true));
|
|
216
|
+
return { ...entry, overridden: true, exited: true, status: "green", todos };
|
|
217
|
+
}
|
|
218
|
+
return entry;
|
|
219
|
+
}
|
|
220
|
+
function normalizeRequirements(reqs) {
|
|
221
|
+
return (reqs || []).map((r, i) => ({
|
|
222
|
+
id: r.id || `r${i + 1}`,
|
|
223
|
+
category: r.category || "functional",
|
|
224
|
+
text: r.text ?? r.statement ?? ""
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
227
|
+
function normalizeArtifactValue(key, value) {
|
|
228
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
229
|
+
if (key === "prd" || key === "grill") {
|
|
230
|
+
const v = value;
|
|
231
|
+
if (Array.isArray(v["requirements"])) {
|
|
232
|
+
return { ...v, requirements: normalizeRequirements(v["requirements"]) };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return value;
|
|
236
|
+
}
|
|
237
|
+
var isFilled = (v) => typeof v === "string" ? v.trim().length > 0 : Array.isArray(v) ? v.length > 0 : v != null && typeof v === "object" ? Object.keys(v).length > 0 : v != null;
|
|
238
|
+
function summarizeState(state) {
|
|
239
|
+
const answers = state.answers || {};
|
|
240
|
+
const answered = Object.keys(answers).filter((k) => isFilled(answers[k])).length;
|
|
241
|
+
const byStatus = { green: 0, yellow: 0, red: 0, ungraded: 0 };
|
|
242
|
+
for (const v of Object.values(state.feedback || {})) {
|
|
243
|
+
const fb = v;
|
|
244
|
+
const s = fb?.overridden ? "green" : fb?.status;
|
|
245
|
+
if (s === "green" || s === "yellow" || s === "red") byStatus[s]++;
|
|
246
|
+
else byStatus.ungraded++;
|
|
247
|
+
}
|
|
248
|
+
const artifacts = ["bizModel", "grill", "assets", "research", "swot", "prd", "naming", "brand"].filter((k) => state[k] != null);
|
|
249
|
+
const kit = state.kit && typeof state.kit === "object" ? state.kit : null;
|
|
250
|
+
return {
|
|
251
|
+
answered,
|
|
252
|
+
totalAnswerable: ANSWERABLE_QIDS.length,
|
|
253
|
+
byStatus,
|
|
254
|
+
artifacts,
|
|
255
|
+
cursor: kit?.cursor ?? null,
|
|
256
|
+
modulesRun: kit?.modulesRun ?? []
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
function withKit(state, patch, ts) {
|
|
260
|
+
const prev = state.kit && typeof state.kit === "object" ? state.kit : { schema: 1, updatedAt: ts };
|
|
261
|
+
return { ...prev, ...patch, schema: 1, updatedAt: ts };
|
|
262
|
+
}
|
|
263
|
+
function setAnswer(state, qId, value, ts) {
|
|
264
|
+
if (!isAnswerableQid(qId)) {
|
|
265
|
+
throw new Error(`"${qId}" is not an answerable Thought Layer question id. The agent leaves web-app-only fields to the founder; module sub-stage notes belong in kit.panelMeta.`);
|
|
266
|
+
}
|
|
267
|
+
return { ...state, answers: { ...state.answers, [qId]: value }, kit: withKit(state, {}, ts) };
|
|
268
|
+
}
|
|
269
|
+
function setArtifact(state, key, value, ts) {
|
|
270
|
+
return { ...state, [key]: value, kit: withKit(state, {}, ts) };
|
|
271
|
+
}
|
|
272
|
+
function setCursor(state, cursor, ts) {
|
|
273
|
+
return { ...state, kit: withKit(state, { cursor }, ts) };
|
|
274
|
+
}
|
|
275
|
+
function parkNote(state, key, note, ts) {
|
|
276
|
+
const prev = state.kit && typeof state.kit === "object" ? state.kit : { schema: 1, updatedAt: ts };
|
|
277
|
+
const parked = { ...prev.parked || {} };
|
|
278
|
+
parked[key] = [...parked[key] || [], note];
|
|
279
|
+
return { ...state, kit: withKit(state, { parked }, ts) };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// core/state-file.ts
|
|
283
|
+
var STATE_DIR = ".thought-layer";
|
|
284
|
+
var STATE_FILE = "state.json";
|
|
285
|
+
function resolveStatePath(target, cwd = process.cwd()) {
|
|
286
|
+
if (!target) return join(cwd, STATE_DIR, STATE_FILE);
|
|
287
|
+
const abs = isAbsolute(target) ? target : resolve(cwd, target);
|
|
288
|
+
return abs.endsWith(".json") ? abs : join(abs, STATE_DIR, STATE_FILE);
|
|
289
|
+
}
|
|
290
|
+
function loadStateFile(target, cwd) {
|
|
291
|
+
const path = resolveStatePath(target, cwd);
|
|
292
|
+
if (!existsSync(path)) {
|
|
293
|
+
const state = emptyState();
|
|
294
|
+
return { path, exists: false, payload: buildProgress(state, { kind: "kit", ts: 0 }, ""), state };
|
|
295
|
+
}
|
|
296
|
+
const payload = parseProgress(readFileSync(path, "utf8"));
|
|
297
|
+
return { path, exists: true, payload, state: payload.state };
|
|
298
|
+
}
|
|
299
|
+
function saveStateFile(state, opts) {
|
|
300
|
+
const path = resolveStatePath(opts.target, opts.cwd);
|
|
301
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
302
|
+
const writer = { kind: "kit", ts: opts.ts };
|
|
303
|
+
if (opts.version) writer.version = opts.version;
|
|
304
|
+
const text = serializeProgress(buildProgress(state, writer, opts.exportedAt));
|
|
305
|
+
writeFileSync(path, text);
|
|
306
|
+
return { path, bytes: Buffer.byteLength(text) };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// core/state-ops.ts
|
|
310
|
+
var ARTIFACT_KEYS = ["bizModel", "grill", "assets", "research", "swot", "prd", "naming", "brand"];
|
|
311
|
+
var END_STATES = ["open", "pass", "setAside"];
|
|
312
|
+
function applyStateOp(p, ctx) {
|
|
313
|
+
const { ts, exportedAt } = ctx;
|
|
314
|
+
const fail = (message) => ({ ok: false, message, details: {} });
|
|
315
|
+
try {
|
|
316
|
+
const loaded = loadStateFile(p.path);
|
|
317
|
+
const save = (next) => saveStateFile(next, { target: p.path, ts, exportedAt }).path;
|
|
318
|
+
if (p.op === "read" || p.op === "export") {
|
|
319
|
+
const sum = summarizeState(loaded.state);
|
|
320
|
+
const message = loaded.exists ? `Loaded ${loaded.path}: ${sum.answered}/${sum.totalAnswerable} answered (${sum.byStatus.green} green, ${sum.byStatus.yellow} yellow, ${sum.byStatus.red} red), artifacts: ${sum.artifacts.join(", ") || "none"}. Resume at ${sum.cursor ? `stage ${sum.cursor.backboneStage ?? "?"} (${sum.cursor.phase ?? "?"})` : "the beginning"}.` : `No state file yet at ${loaded.path}. Start a fresh run; it will be created on first write.`;
|
|
321
|
+
return { ok: true, message, details: { path: loaded.path, exists: loaded.exists, summary: sum, state: loaded.state } };
|
|
322
|
+
}
|
|
323
|
+
if (p.op === "answer") {
|
|
324
|
+
if (!p.qId || typeof p.value !== "string") return fail("answer needs a qId and a string value.");
|
|
325
|
+
const path = save(setAnswer(loaded.state, p.qId, p.value, ts));
|
|
326
|
+
return { ok: true, message: `Recorded answer for "${p.qId}" and saved ${path}.`, details: { path, qId: p.qId } };
|
|
327
|
+
}
|
|
328
|
+
if (p.op === "feedback") {
|
|
329
|
+
if (!p.qId || !p.personas?.length) return fail("feedback needs a qId and at least one persona.");
|
|
330
|
+
const endState = END_STATES.includes(p.endState || "") ? p.endState : "open";
|
|
331
|
+
const mode = p.mode || (p.personas.length > 1 ? "panel" : p.personas[0].persona);
|
|
332
|
+
const entry = buildFeedbackEntry({ mode, personas: p.personas, endState, round: p.round, ts });
|
|
333
|
+
const next = { ...loaded.state, feedback: { ...loaded.state.feedback, [p.qId]: entry } };
|
|
334
|
+
const path = save(next);
|
|
335
|
+
const grade = gradeFromConfidence(entry.confidence);
|
|
336
|
+
const pct = entry.confidence != null ? `${(entry.confidence * 100).toFixed(0)}%` : "n/a";
|
|
337
|
+
const tail = endState === "setAside" ? `, set aside with ${entry.todos.length} to-do(s)` : endState === "pass" ? ", cleared the bar" : "";
|
|
338
|
+
return {
|
|
339
|
+
ok: true,
|
|
340
|
+
message: `Recorded panel verdict for "${p.qId}": confidence ${pct} -> ${entry.status}, grade ${grade}${tail}. Saved ${path}.`,
|
|
341
|
+
details: { path, qId: p.qId, confidence: entry.confidence, status: entry.status, grade }
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
if (p.op === "artifact") {
|
|
345
|
+
const key = p.artifact;
|
|
346
|
+
if (!ARTIFACT_KEYS.includes(key)) return fail(`artifact needs a key in: ${ARTIFACT_KEYS.join(", ")}.`);
|
|
347
|
+
if (p.value == null) return fail("artifact needs a value object.");
|
|
348
|
+
const path = save(setArtifact(loaded.state, key, normalizeArtifactValue(key, p.value), ts));
|
|
349
|
+
return { ok: true, message: `Stored ${key} artifact and saved ${path}.`, details: { path, artifact: key } };
|
|
350
|
+
}
|
|
351
|
+
if (p.op === "cursor") {
|
|
352
|
+
if (!p.cursor) return fail("cursor needs a cursor object.");
|
|
353
|
+
const path = save(setCursor(loaded.state, p.cursor, ts));
|
|
354
|
+
return { ok: true, message: `Saved resume cursor (stage ${p.cursor.backboneStage ?? "?"}, ${p.cursor.phase ?? "?"}) to ${path}.`, details: { path } };
|
|
355
|
+
}
|
|
356
|
+
if (p.op === "park") {
|
|
357
|
+
if (!p.key || !p.note) return fail("park needs a key and a note.");
|
|
358
|
+
const path = save(parkNote(loaded.state, p.key, p.note, ts));
|
|
359
|
+
return { ok: true, message: `Parked a note under "${p.key}" and saved ${path}.`, details: { path, key: p.key } };
|
|
360
|
+
}
|
|
361
|
+
return fail(`Unknown op "${p.op}". Use read, answer, feedback, artifact, cursor, park, or export.`);
|
|
362
|
+
} catch (e) {
|
|
363
|
+
return fail(`tl_state error: ${e.message}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// bin/tl.ts
|
|
368
|
+
var HELP = `tl - read/write the portable Thought Layer state file (.thought-layer/state.json)
|
|
369
|
+
|
|
370
|
+
tl read [path] [--json] where the run stands
|
|
371
|
+
tl export [path] handoff check
|
|
372
|
+
tl answer <qId> <value> [path] record an answer
|
|
373
|
+
tl feedback --data '<json>' record a panel verdict ({qId,mode,personas,endState,round})
|
|
374
|
+
tl artifact <key> --data '<json>' store an artifact value object
|
|
375
|
+
tl cursor --data '<json>' save the resume cursor object
|
|
376
|
+
tl park <key> <note> [path] stash a panel note
|
|
377
|
+
tl exec --data '<json>' run a full {op,...} payload
|
|
378
|
+
|
|
379
|
+
--path <p> project dir or .json path --data <j> JSON payload ("-" = stdin)
|
|
380
|
+
--json print details JSON -h, --help`;
|
|
381
|
+
function parseArgs(argv) {
|
|
382
|
+
const args = [];
|
|
383
|
+
const flags = {};
|
|
384
|
+
for (let i = 0; i < argv.length; i++) {
|
|
385
|
+
const a = argv[i];
|
|
386
|
+
if (a === "-h" || a === "--help") flags["help"] = true;
|
|
387
|
+
else if (a === "--json") flags["json"] = true;
|
|
388
|
+
else if (a.startsWith("--")) {
|
|
389
|
+
const key = a.slice(2);
|
|
390
|
+
const next = argv[i + 1];
|
|
391
|
+
if (next !== void 0 && !next.startsWith("--")) {
|
|
392
|
+
flags[key] = next;
|
|
393
|
+
i++;
|
|
394
|
+
} else flags[key] = true;
|
|
395
|
+
} else args.push(a);
|
|
396
|
+
}
|
|
397
|
+
return { args, flags };
|
|
398
|
+
}
|
|
399
|
+
function readData(flags) {
|
|
400
|
+
const d = flags["data"];
|
|
401
|
+
if (d === void 0) return void 0;
|
|
402
|
+
const raw = d === "-" || d === true ? readFileSync2(0, "utf8") : String(d);
|
|
403
|
+
try {
|
|
404
|
+
return JSON.parse(raw);
|
|
405
|
+
} catch {
|
|
406
|
+
throw new Error("--data is not valid JSON.");
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
function buildOp(args, flags) {
|
|
410
|
+
const op = args[0];
|
|
411
|
+
const path = typeof flags["path"] === "string" ? flags["path"] : void 0;
|
|
412
|
+
const data = readData(flags);
|
|
413
|
+
switch (op) {
|
|
414
|
+
case "read":
|
|
415
|
+
case "export":
|
|
416
|
+
return { op, path: path ?? args[1] };
|
|
417
|
+
case "answer":
|
|
418
|
+
return { op, qId: args[1], value: args[2], path: path ?? args[3] };
|
|
419
|
+
case "park":
|
|
420
|
+
return { op, key: args[1], note: args[2], path: path ?? args[3] };
|
|
421
|
+
case "feedback":
|
|
422
|
+
return { op, path, ...data || {} };
|
|
423
|
+
case "artifact":
|
|
424
|
+
return { op, artifact: args[1], value: data, path };
|
|
425
|
+
case "cursor":
|
|
426
|
+
return { op, cursor: data || {}, path };
|
|
427
|
+
case "exec":
|
|
428
|
+
return { path, ...data || {} };
|
|
429
|
+
default:
|
|
430
|
+
throw new Error(`Unknown command "${op ?? ""}". Run \`tl --help\`.`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
function main() {
|
|
434
|
+
const { args, flags } = parseArgs(process.argv.slice(2));
|
|
435
|
+
if (args[0] === "tl" || args[0] === "thought-layer") args.shift();
|
|
436
|
+
if (flags["help"] || args.length === 0) {
|
|
437
|
+
console.log(HELP);
|
|
438
|
+
process.exit(0);
|
|
439
|
+
}
|
|
440
|
+
let payload;
|
|
441
|
+
try {
|
|
442
|
+
payload = buildOp(args, flags);
|
|
443
|
+
} catch (e) {
|
|
444
|
+
console.error(e.message);
|
|
445
|
+
process.exit(2);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const r = applyStateOp(payload, { ts: Date.now(), exportedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
449
|
+
if (flags["json"]) console.log(JSON.stringify(r.details, null, 2));
|
|
450
|
+
else console.log(r.message);
|
|
451
|
+
process.exit(r.ok ? 0 : 1);
|
|
452
|
+
}
|
|
453
|
+
main();
|
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
aggregateConfidence, statusFromConfidence, gradeFromConfidence,
|
|
10
10
|
checkDomains, registrarSearchUrl,
|
|
11
11
|
computeProjection, fmtMoney,
|
|
12
|
-
|
|
12
|
+
applyStateOp,
|
|
13
|
+
type Assumptions, type StateOp,
|
|
13
14
|
} from "../core/index.ts";
|
|
14
15
|
|
|
15
16
|
const text = (t: string, details?: Record<string, unknown>): ToolResult => ({
|
|
@@ -125,4 +126,57 @@ export default function (pi: ExtensionAPI) {
|
|
|
125
126
|
return text(`Projection summary:\n${body}`, { summary: s });
|
|
126
127
|
},
|
|
127
128
|
});
|
|
129
|
+
|
|
130
|
+
// tl_state: read, update, and write the portable Thought Layer state file
|
|
131
|
+
// (.thought-layer/state.json) so a co-founder using the web app and an agent
|
|
132
|
+
// share ONE lossless file. This owns the feedback-envelope assembly and
|
|
133
|
+
// artifact normalization, so the model supplies prose + numbers and never
|
|
134
|
+
// hand-writes the JSON (the main way the file gets corrupted).
|
|
135
|
+
const SuggestionSchema = Type.Object({
|
|
136
|
+
id: Type.Optional(Type.String()),
|
|
137
|
+
summary: Type.Optional(Type.String()),
|
|
138
|
+
patch: Type.Optional(Type.String()),
|
|
139
|
+
});
|
|
140
|
+
const PersonaSchema = Type.Object({
|
|
141
|
+
persona: Type.String({ description: "redteam | expert | investor, or the single chosen persona key." }),
|
|
142
|
+
assessment: Type.Optional(Type.String()),
|
|
143
|
+
confidence: Type.Number({ description: "0 to 1, this persona's confidence." }),
|
|
144
|
+
confidenceRationale: Type.Optional(Type.String()),
|
|
145
|
+
suggestions: Type.Optional(Type.Array(SuggestionSchema)),
|
|
146
|
+
});
|
|
147
|
+
pi.registerTool({
|
|
148
|
+
name: "tl_state",
|
|
149
|
+
label: "Thought Layer: state",
|
|
150
|
+
description:
|
|
151
|
+
"Read, update, and write the portable Thought Layer progress file (.thought-layer/state.json) shared with the web app so work passes losslessly between a founder in the browser and an agent. " +
|
|
152
|
+
"ops: 'read' (resume: where the run stands), 'answer' (record a question answer), 'feedback' (record a panel verdict - pass it the per-persona prose + confidences and it builds the exact entry), " +
|
|
153
|
+
"'artifact' (store prd/grill/bizModel/naming/brand/etc., requirements auto-normalized), 'cursor' (save resume position), 'park' (stash a panel note with no web-app question), 'export' (report the current file for handoff). " +
|
|
154
|
+
"Always use this instead of writing the JSON by hand.",
|
|
155
|
+
parameters: Type.Object({
|
|
156
|
+
op: Type.Union([
|
|
157
|
+
Type.Literal("read"), Type.Literal("answer"), Type.Literal("feedback"),
|
|
158
|
+
Type.Literal("artifact"), Type.Literal("cursor"), Type.Literal("park"), Type.Literal("export"),
|
|
159
|
+
], { description: "The operation to perform." }),
|
|
160
|
+
path: Type.Optional(Type.String({ description: "Project dir or .json path. Defaults to ./.thought-layer/state.json in the cwd." })),
|
|
161
|
+
qId: Type.Optional(Type.String({ description: "Question id (for 'answer'/'feedback'). Must be a real Thought Layer question id." })),
|
|
162
|
+
value: Type.Optional(Type.Unknown({ description: "For 'answer': the answer string. For 'artifact': the artifact object." })),
|
|
163
|
+
artifact: Type.Optional(Type.String({ description: "For 'artifact': one of bizModel, grill, assets, research, swot, prd, naming, brand." })),
|
|
164
|
+
mode: Type.Optional(Type.String({ description: "For 'feedback': 'panel' (3 personas) or a single persona key." })),
|
|
165
|
+
personas: Type.Optional(Type.Array(PersonaSchema, { description: "For 'feedback': one entry per persona with its assessment + confidence." })),
|
|
166
|
+
endState: Type.Optional(Type.String({ description: "For 'feedback': 'open' (still iterating), 'pass' (cleared 0.85), or 'setAside' (frozen with to-dos)." })),
|
|
167
|
+
round: Type.Optional(Type.Number({ description: "For 'feedback': which feedback round this is (default 1)." })),
|
|
168
|
+
cursor: Type.Optional(Type.Object({
|
|
169
|
+
stage: Type.Optional(Type.String()),
|
|
170
|
+
backboneStage: Type.Optional(Type.Number()),
|
|
171
|
+
lastQuestionId: Type.Optional(Type.String()),
|
|
172
|
+
phase: Type.Optional(Type.String()),
|
|
173
|
+
}, { description: "For 'cursor': the resume position." })),
|
|
174
|
+
key: Type.Optional(Type.String({ description: "For 'park': a synthetic note key, e.g. 'brand.voice' or 'mr.willingness-to-pay'." })),
|
|
175
|
+
note: Type.Optional(Type.String({ description: "For 'park': the note to stash under the kit namespace." })),
|
|
176
|
+
}),
|
|
177
|
+
async execute(_id, params): Promise<ToolResult> {
|
|
178
|
+
const r = applyStateOp(params as StateOp, { ts: Date.now(), exportedAt: new Date().toISOString() });
|
|
179
|
+
return text(r.message, r.details);
|
|
180
|
+
},
|
|
181
|
+
});
|
|
128
182
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hobocode/thought-layer",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "The Thought Layer: rigor for building. Validate an idea, grill it into a buildable spec, then build and deploy it, inside the agent you already use. BYOK, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Hobocode LLC <jerm@hobocode.net>",
|
|
@@ -25,10 +25,16 @@
|
|
|
25
25
|
"exports": {
|
|
26
26
|
"./core": "./core/index.ts"
|
|
27
27
|
},
|
|
28
|
+
"bin": {
|
|
29
|
+
"thought-layer": "./dist/tl.js",
|
|
30
|
+
"tl": "./dist/tl.js"
|
|
31
|
+
},
|
|
28
32
|
"scripts": {
|
|
29
33
|
"typecheck": "tsc --noEmit",
|
|
30
34
|
"test": "vitest run",
|
|
31
|
-
"test:watch": "vitest"
|
|
35
|
+
"test:watch": "vitest",
|
|
36
|
+
"build": "tsup",
|
|
37
|
+
"prepublishOnly": "npm run typecheck && npm run test && npm run build"
|
|
32
38
|
},
|
|
33
39
|
"dependencies": {
|
|
34
40
|
"@sinclair/typebox": "^0.34.0"
|
|
@@ -41,6 +47,7 @@
|
|
|
41
47
|
},
|
|
42
48
|
"devDependencies": {
|
|
43
49
|
"@types/node": "^22.0.0",
|
|
50
|
+
"tsup": "^8.3.0",
|
|
44
51
|
"typescript": "^5.6.0",
|
|
45
52
|
"vitest": "^2.1.0"
|
|
46
53
|
},
|
|
@@ -52,6 +59,12 @@
|
|
|
52
59
|
"core/scoring.ts",
|
|
53
60
|
"core/domains.ts",
|
|
54
61
|
"core/model.ts",
|
|
62
|
+
"core/progress.ts",
|
|
63
|
+
"core/stages.ts",
|
|
64
|
+
"core/stage-map.ts",
|
|
65
|
+
"core/state-file.ts",
|
|
66
|
+
"core/state-ops.ts",
|
|
67
|
+
"dist",
|
|
55
68
|
"README.md",
|
|
56
69
|
"LICENSE"
|
|
57
70
|
]
|
|
@@ -99,4 +99,8 @@ Its finished output feeds back, mapped block by block — one canonical mapping,
|
|
|
99
99
|
- **The whole kit** receives the **Name**, produced here via `thought-layer-naming` and checked with `tl_domains`.
|
|
100
100
|
- **The design phase (framework Part 3)** receives the **Personality**, the **Voice**, and the **Visual & Identity Direction** as notes for the PRD's UX and identity sections — direction only. This module never drafts the PRD and never grills; the design phase remains the framework's Part 3, **PRD first and the grill second**, and the final logos, palettes, and screens are produced there, not here.
|
|
101
101
|
|
|
102
|
+
## Persisting (multi-session)
|
|
103
|
+
|
|
104
|
+
Keep the shared state file current as the brand takes shape (see the framework skill's "Saving and resuming"). Record the brand-feel and what-it-is-not answers against `brand-feel` and `brand-unlike` via the state tool, store the chosen name as the `naming` artifact and the guide as the `brand` artifact (op `artifact`), and leave the final logos, palettes, and screens to the design phase / web app. Sub-stage verdicts with no web-app question (promise, emotional job, personality, voice, visual direction) go to op `park` (a key like `brand.voice`), never into answers. If neither `tl_state` nor `tl` is available, carry the brief in chat.
|
|
105
|
+
|
|
102
106
|
It takes positioning as input and expresses it; it never sets or relitigates competitive strategy (that is `thought-layer-strategy` and backbone Market Selection), and it never starts the design phase.
|
|
@@ -104,4 +104,8 @@ Its finished output feeds back into the backbone, mapped block by block — one
|
|
|
104
104
|
- **Business Model (stage 10)** receives the **Revenue Model & Money Flow**, the **Unit Economics**, and the **Scenario Modeling**.
|
|
105
105
|
- **Scale Expectations (stage 8)** receives the **scenario projections** as an evidence anchor, alongside market-research's SOM; the two reconcile (scenario year-one revenue should sit inside the winnable SOM) and the 12-month and 3-year calls stay the founder's.
|
|
106
106
|
|
|
107
|
-
It never drafts the PRD and never grills; the design phase remains the framework's Part 3, PRD first and the grill second.
|
|
107
|
+
It never drafts the PRD and never grills; the design phase remains the framework's Part 3, PRD first and the grill second.
|
|
108
|
+
|
|
109
|
+
## Persisting (multi-session)
|
|
110
|
+
|
|
111
|
+
Keep the shared state file current as the model firms up (see the framework skill's "Saving and resuming"). Record the deepened answers against their web-app question ids via the state tool — Costs to `cost-architecture`/`cost-risk`, Pricing to `pricing-model`, the parties to `bm-who-buys`/`bm-who-supplies`/`bm-parties` — and store the numeric model (the `tl_project` output and its assumptions) as the `bizModel` artifact (op `artifact`). Sub-stage verdicts with no web-app question (money flow, unit economics, cost stress, scenarios) go to op `park` (a key like `bm.unit-economics`), never into answers. If neither `tl_state` nor `tl` is available, carry the model in chat.
|
|
@@ -20,6 +20,43 @@ Then walk the stages below **in order, one stage per turn**. For each stage:
|
|
|
20
20
|
|
|
21
21
|
Reaching the Grill or the PRD before all of Part 1 (validate the idea) and Part 2 (the business model) are worked through is the signature failure of this framework. Do not do it. The Grill and the PRD are the design phase and they come last.
|
|
22
22
|
|
|
23
|
+
## Saving and resuming (this runs across many sessions)
|
|
24
|
+
|
|
25
|
+
No one finishes this in one sitting. The work lives in a portable file, `.thought-layer/state.json`, in the project directory. It is your memory across turns and sessions, and it is the SAME interop file the web app reads, so a founder can answer some stages here and hand the file to a co-founder who continues in the web app (weareallproductmanagersnow.com, "Load progress from file"), back and forth, losslessly. Never hand-write this JSON: use the tool below, which builds the exact shapes the web app expects.
|
|
26
|
+
|
|
27
|
+
**The tool.** If the `tl_state` tool is available (Pi), use it. Otherwise run the CLI from any shell: `npx -y @hobocode/thought-layer tl <op> ...` (or just `tl ...` if the package is installed). Ops: `read`, `answer`, `feedback`, `artifact`, `cursor`, `park`, `export`.
|
|
28
|
+
|
|
29
|
+
**On start, ALWAYS read first.** `tl_state read` (or `tl read`). If a file exists, summarize where the run stands and **resume from the saved cursor** - do not restart at stage 1. If not, start fresh; the file is created on first write.
|
|
30
|
+
|
|
31
|
+
**After each stage:**
|
|
32
|
+
1. Record the answer against its question id: `tl_state` op `answer` (or `tl answer <qId> "<value>"`).
|
|
33
|
+
2. Record the panel verdict: `tl_state` op `feedback` - pass it the per-persona assessments + confidences and the end state (`pass` when confidence clears 0.85, `setAside` when the user sets it aside with to-dos, else `open`). The tool computes the status, grade, and to-dos; you supply only prose + numbers.
|
|
34
|
+
3. Save the cursor: `tl_state` op `cursor` with the backbone stage number and phase, so the next session resumes exactly here.
|
|
35
|
+
|
|
36
|
+
**Stage to question id** (use these exact ids; the tool rejects unknown ones):
|
|
37
|
+
|
|
38
|
+
| Stage | id(s) |
|
|
39
|
+
|---|---|
|
|
40
|
+
| 1 Concise What | `what-statement` |
|
|
41
|
+
| 2 Domain Knowledge | `domain-experience`, `domain-gaps` |
|
|
42
|
+
| 3 Validation | `paid-today`, `evidence` |
|
|
43
|
+
| 4 Market Selection | `target-market`, `incumbent-gap` |
|
|
44
|
+
| 5 30-Second Test | `pitch` |
|
|
45
|
+
| 6 Time | `commitment` |
|
|
46
|
+
| 7 Costs | `cost-architecture`, `cost-risk` |
|
|
47
|
+
| 8 Scale | `realistic-goal` |
|
|
48
|
+
| 9 Pricing | `pricing-model` |
|
|
49
|
+
| 10 Business Model | `bm-who-buys`, `bm-who-supplies`, `bm-parties` (+ `bizModel` artifact) |
|
|
50
|
+
| 11 Customer Acquisition | `first-ten`, `retention` |
|
|
51
|
+
| 12 Customer Relationships | `crm-approach`, `crm-community` |
|
|
52
|
+
| 13 Support | `support-model`, `support-scaling` |
|
|
53
|
+
| 14 PRD | `prd-problem`, `prd-not-building` (+ `prd` artifact) |
|
|
54
|
+
| 15 Grill | `grill` artifact (re-compose `prd` markdown on done) |
|
|
55
|
+
|
|
56
|
+
**Artifacts** (PRD, grill, bizModel, naming, brand, swot, research) go through op `artifact`, which normalizes them to the web app's shapes. **Web-app-only fields** - the Decision Support questions (`dq-*`), the press-release fields, and the launch-asset fields - have no stage here; leave them for the founder in the web app, do not write them. **Module sub-stage verdicts** that have no web-app question (the deep-dive modules' internal stages) go to op `park`, never into answers.
|
|
57
|
+
|
|
58
|
+
**At session end or handoff,** run `export` to confirm the file is current, and tell the founder where it is and that they (or a collaborator) can load it into the web app or keep going with the agent.
|
|
59
|
+
|
|
23
60
|
## Part 1: Validate the idea
|
|
24
61
|
|
|
25
62
|
Altitude: is the idea clear, honest, real, and worth pursuing? Not how it will be built.
|
|
@@ -56,6 +56,10 @@ Update the draft PRD in place, keeping two living artifacts inside it current:
|
|
|
56
56
|
|
|
57
57
|
When the grilling is complete, the hardened PRD — with its sharpened glossary and completed requirements — is the build brief the build step consumes.
|
|
58
58
|
|
|
59
|
+
## Persisting (multi-session)
|
|
60
|
+
|
|
61
|
+
Keep the shared state file current as you harden the PRD, so the work survives the session and round-trips to the web app and a co-founder. Store the grill artifact via the state tool: `tl_state` op `artifact` with `artifact: "grill"` and `{ transcript, requirements, glossary, done, doneSummary }` (or `tl artifact grill --data '<json>'`); each requirement carries `statement`, which the tool remaps to the web app's `text`. When the grill is done, re-compose the hardened glossary + requirements into the PRD prose and store that too (`artifact: "prd"`), so an imported file shows the hardened spec, not the stale draft. If neither `tl_state` nor `tl` is available, just keep the PRD updated in chat. See the framework skill's "Saving and resuming."
|
|
62
|
+
|
|
59
63
|
## Credit
|
|
60
64
|
|
|
61
65
|
The "grill" — a relentless, one-question-at-a-time interview that grills an existing plan, sharpens the domain glossary, and surfaces contradictions, updating the doc inline as decisions crystallize — is inspired by Matt Pocock's [`grill-with-docs`](https://github.com/mattpocock/skills/blob/main/skills/engineering/grill-with-docs/SKILL.md) skill (MIT, © Matt Pocock). His grills an architecture plan against the existing domain model and docs; this skill adapts the same technique to grill a draft PRD against the domain and harden it inline. Thank you, Matt.
|
|
@@ -102,4 +102,8 @@ Its finished output feeds back into the backbone, mapped block by block — one
|
|
|
102
102
|
- **Market Selection (stage 4)** receives the **ICP**, the **Market Sizing / SOM**, the **Competitive Landscape**, the **Wedge**, and **Why Now**.
|
|
103
103
|
- **Pricing (stage 9)** receives the **Willingness to Pay** evidence — the anchors, not the final number.
|
|
104
104
|
|
|
105
|
-
The **SOM** additionally anchors backbone stage 8 (Scale Expectations), and **Channels** additionally feeds backbone stage 11 (Customer Acquisition) as reachability evidence. It never drafts the PRD and never grills; the design phase remains the framework's Part 3, PRD first and the grill second.
|
|
105
|
+
The **SOM** additionally anchors backbone stage 8 (Scale Expectations), and **Channels** additionally feeds backbone stage 11 (Customer Acquisition) as reachability evidence. It never drafts the PRD and never grills; the design phase remains the framework's Part 3, PRD first and the grill second.
|
|
106
|
+
|
|
107
|
+
## Persisting (multi-session)
|
|
108
|
+
|
|
109
|
+
This deep-dive runs across sessions, so keep the shared state file current (see the framework skill's "Saving and resuming"). When the evidence feeds back to a backbone stage, record that answer against its web-app question id via the state tool — Validation to `evidence`/`paid-today`, Market Selection to `target-market`/`incumbent-gap`, Pricing to `pricing-model` — and store the write-up as the `research` artifact (op `artifact`). The module's own sub-stage verdicts (ICP, sizing, WTP, and the rest) have no web-app question: stash them with op `park` (a key like `mr.willingness-to-pay`), never in answers. If neither `tl_state` nor `tl` is available, just carry the evidence ledger in chat.
|
|
@@ -36,3 +36,7 @@ Write in clear prose with markdown headings:
|
|
|
36
36
|
- Make the success metrics honest outcomes, not vanity: draft a north-star metric tied to delivered customer value (one that moves only when customers get the promised outcome), at least one counter-metric it could break, and a leading plus lagging pair. The grill holds these to its metric-honesty rule.
|
|
37
37
|
|
|
38
38
|
Output only the markdown document, no preamble.
|
|
39
|
+
|
|
40
|
+
## Persisting (multi-session)
|
|
41
|
+
|
|
42
|
+
After composing the draft, save it to the shared state file so it survives the session and round-trips to the web app and any co-founder, rather than living only in chat. Store it via the state tool: `tl_state` op `artifact` with `artifact: "prd"` and `{ markdown, glossary, requirements, weakestAssumptions }` (or the CLI `tl artifact prd --data '<json>'`). Each requirement may carry `statement` for its wording; the tool remaps that to the web app's `text` field for you. If neither `tl_state` nor `tl` is available, just output the markdown. See the framework skill's "Saving and resuming."
|
|
@@ -103,4 +103,8 @@ Its finished output feeds back into the backbone, mapped block by block — one
|
|
|
103
103
|
- **Market Selection (stage 4)** receives the **Strategic Positioning** (where to play and what you refuse), the **Moat mechanism and its clock**, the **Beachhead → Expansion sequence**, the **Incumbent-response counter**, and the **Strategic Bets** — the full case that the chosen market is not just real but winnable and defensible over multiple years.
|
|
104
104
|
- **The 30-Second Test (stage 5)** is *informed* by the positioning statement (it sharpens what the pitch must convey) but receives no pitch from this module; writing the one-sentence pitch stays the founder's call in the backbone.
|
|
105
105
|
|
|
106
|
-
Boundaries it holds to the end: it takes the buyer, the market size, the demand evidence, the competitive map, the wedge's existence, why-now, and market-research's first-pass defensibility window and structural-incumbent sketch as input and does not re-derive them (it deepens the window and structural sketch in stages 2 and 4 rather than rebuilding them); it sets positioning but hands brand promise, personality, voice, name, and visual direction to **thought-layer-brand**, which expresses that positioning; and it sets the strategic spine but hands deep unit economics, pricing-and-packaging, and scenario modeling to **thought-layer-business-model**. It never drafts the PRD and never grills; the design phase remains the framework's Part 3, PRD first and the grill second.
|
|
106
|
+
Boundaries it holds to the end: it takes the buyer, the market size, the demand evidence, the competitive map, the wedge's existence, why-now, and market-research's first-pass defensibility window and structural-incumbent sketch as input and does not re-derive them (it deepens the window and structural sketch in stages 2 and 4 rather than rebuilding them); it sets positioning but hands brand promise, personality, voice, name, and visual direction to **thought-layer-brand**, which expresses that positioning; and it sets the strategic spine but hands deep unit economics, pricing-and-packaging, and scenario modeling to **thought-layer-business-model**. It never drafts the PRD and never grills; the design phase remains the framework's Part 3, PRD first and the grill second.
|
|
107
|
+
|
|
108
|
+
## Persisting (multi-session)
|
|
109
|
+
|
|
110
|
+
Keep the shared state file current as the strategy lands (see the framework skill's "Saving and resuming"). The deepened positioning, moat, sequence, incumbent-response, and bets feed **Market Selection** — record that answer against `target-market` and `incumbent-gap` via the state tool, and store the SWOT as the `swot` artifact (op `artifact`). The module's own sub-stage verdicts (positioning, moat, beachhead, incumbent-response, bets) have no web-app question: stash them with op `park` (a key like `strategy.moat`), never in answers. If neither `tl_state` nor `tl` is available, carry the strategy ledger in chat.
|