fullstackgtm 0.43.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +378 -1
  2. package/README.md +37 -14
  3. package/dist/audit.d.ts +3 -1
  4. package/dist/audit.js +29 -2
  5. package/dist/cli/audit.d.ts +15 -0
  6. package/dist/cli/audit.js +392 -0
  7. package/dist/cli/auth.d.ts +80 -0
  8. package/dist/cli/auth.js +500 -0
  9. package/dist/cli/call.d.ts +1 -0
  10. package/dist/cli/call.js +373 -0
  11. package/dist/cli/capabilities.d.ts +30 -0
  12. package/dist/cli/capabilities.js +197 -0
  13. package/dist/cli/draft.d.ts +7 -0
  14. package/dist/cli/draft.js +87 -0
  15. package/dist/cli/enrich.d.ts +8 -0
  16. package/dist/cli/enrich.js +788 -0
  17. package/dist/cli/fix.d.ts +33 -0
  18. package/dist/cli/fix.js +344 -0
  19. package/dist/cli/help.d.ts +25 -0
  20. package/dist/cli/help.js +609 -0
  21. package/dist/cli/icp.d.ts +7 -0
  22. package/dist/cli/icp.js +229 -0
  23. package/dist/cli/init.d.ts +7 -0
  24. package/dist/cli/init.js +58 -0
  25. package/dist/cli/market.d.ts +1 -0
  26. package/dist/cli/market.js +391 -0
  27. package/dist/cli/plans.d.ts +5 -0
  28. package/dist/cli/plans.js +454 -0
  29. package/dist/cli/schedule.d.ts +8 -0
  30. package/dist/cli/schedule.js +479 -0
  31. package/dist/cli/shared.d.ts +70 -0
  32. package/dist/cli/shared.js +331 -0
  33. package/dist/cli/signals.d.ts +7 -0
  34. package/dist/cli/signals.js +412 -0
  35. package/dist/cli/suggest.d.ts +49 -0
  36. package/dist/cli/suggest.js +135 -0
  37. package/dist/cli/tam.d.ts +9 -0
  38. package/dist/cli/tam.js +387 -0
  39. package/dist/cli/ui.d.ts +121 -0
  40. package/dist/cli/ui.js +375 -0
  41. package/dist/cli.d.ts +1 -57
  42. package/dist/cli.js +104 -4556
  43. package/dist/connector.d.ts +15 -0
  44. package/dist/connector.js +35 -0
  45. package/dist/connectors/hubspot.d.ts +3 -1
  46. package/dist/connectors/hubspot.js +10 -3
  47. package/dist/connectors/outboxChannel.d.ts +64 -0
  48. package/dist/connectors/outboxChannel.js +170 -0
  49. package/dist/connectors/prospectSources.d.ts +22 -0
  50. package/dist/connectors/prospectSources.js +43 -0
  51. package/dist/connectors/salesforce.d.ts +3 -1
  52. package/dist/connectors/salesforce.js +12 -5
  53. package/dist/connectors/signalSources.d.ts +105 -0
  54. package/dist/connectors/signalSources.js +264 -0
  55. package/dist/connectors/theirstack.d.ts +84 -0
  56. package/dist/connectors/theirstack.js +125 -0
  57. package/dist/icp.d.ts +52 -4
  58. package/dist/icp.js +112 -35
  59. package/dist/index.d.ts +1 -0
  60. package/dist/index.js +1 -0
  61. package/dist/init.js +3 -0
  62. package/dist/judge.d.ts +2 -0
  63. package/dist/judge.js +6 -0
  64. package/dist/judgeEval.d.ts +7 -0
  65. package/dist/judgeEval.js +8 -1
  66. package/dist/marketClassify.d.ts +2 -0
  67. package/dist/marketClassify.js +7 -1
  68. package/dist/mcp-bin.js +2 -2
  69. package/dist/mcp.js +259 -166
  70. package/dist/runReport.d.ts +26 -0
  71. package/dist/runReport.js +15 -0
  72. package/dist/schedule.d.ts +80 -2
  73. package/dist/schedule.js +272 -5
  74. package/dist/signals.d.ts +54 -0
  75. package/dist/signals.js +64 -0
  76. package/dist/spoolFiles.d.ts +44 -0
  77. package/dist/spoolFiles.js +114 -0
  78. package/dist/tam.d.ts +225 -0
  79. package/dist/tam.js +470 -0
  80. package/dist/types.d.ts +11 -0
  81. package/docs/api.md +91 -11
  82. package/docs/outbox-format.md +92 -0
  83. package/docs/recipes.md +37 -0
  84. package/docs/roadmap-to-1.0.md +31 -1
  85. package/docs/signal-spool-format.md +175 -0
  86. package/docs/tam.md +195 -0
  87. package/llms.txt +83 -11
  88. package/package.json +1 -1
  89. package/skills/fullstackgtm/SKILL.md +4 -3
  90. package/src/audit.ts +27 -1
  91. package/src/cli/audit.ts +447 -0
  92. package/src/cli/auth.ts +549 -0
  93. package/src/cli/call.ts +398 -0
  94. package/src/cli/capabilities.ts +215 -0
  95. package/src/cli/draft.ts +101 -0
  96. package/src/cli/enrich.ts +885 -0
  97. package/src/cli/fix.ts +372 -0
  98. package/src/cli/help.ts +664 -0
  99. package/src/cli/icp.ts +265 -0
  100. package/src/cli/init.ts +65 -0
  101. package/src/cli/market.ts +423 -0
  102. package/src/cli/plans.ts +523 -0
  103. package/src/cli/schedule.ts +526 -0
  104. package/src/cli/shared.ts +375 -0
  105. package/src/cli/signals.ts +434 -0
  106. package/src/cli/suggest.ts +151 -0
  107. package/src/cli/tam.ts +435 -0
  108. package/src/cli/ui.ts +426 -0
  109. package/src/cli.ts +103 -5170
  110. package/src/connector.ts +46 -0
  111. package/src/connectors/hubspot.ts +14 -2
  112. package/src/connectors/outboxChannel.ts +202 -0
  113. package/src/connectors/prospectSources.ts +57 -0
  114. package/src/connectors/salesforce.ts +18 -2
  115. package/src/connectors/signalSources.ts +314 -0
  116. package/src/connectors/theirstack.ts +170 -0
  117. package/src/icp.ts +120 -34
  118. package/src/index.ts +32 -0
  119. package/src/init.ts +3 -0
  120. package/src/judge.ts +7 -0
  121. package/src/judgeEval.ts +8 -1
  122. package/src/marketClassify.ts +8 -1
  123. package/src/mcp-bin.ts +2 -2
  124. package/src/mcp.ts +130 -57
  125. package/src/runReport.ts +39 -0
  126. package/src/schedule.ts +330 -7
  127. package/src/signals.ts +90 -0
  128. package/src/spoolFiles.ts +116 -0
  129. package/src/tam.ts +654 -0
  130. package/src/types.ts +12 -0
package/src/cli/icp.ts ADDED
@@ -0,0 +1,265 @@
1
+ // Extracted verbatim from src/cli.ts (mechanical split, no behavior change).
2
+
3
+ import { readFileSync, writeFileSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { resolveLlmCredential } from "../llm.ts";
6
+ import { fitThreshold, icpFromAnswers, icpToCrustdataFilters, icpToExploriumFilters, INTERVIEW_SPEC } from "../icp.ts";
7
+ import { createFileSignalStore, DEFAULT_SIGNALS_CONFIG, type SignalsConfig } from "../signals.ts";
8
+ import { createFileJudgeStore, DEFAULT_JUDGE_PROMPT, judgeRunId, judgeSignals } from "../judge.ts";
9
+ import { DEFAULT_GOLDEN_NOW_ISO, DEFAULT_GOLDEN_SET, DEFAULT_MIN_ACCURACY, defaultJudgeFn, gradeAgainstOutcomes, gradeJudge, parseGoldenSet } from "../judgeEval.ts";
10
+ import type { LlmCallOptions } from "../llm.ts";
11
+ import { loadIcp, numericOption, option, readSnapshot, resolveLlmBaseUrls, saveRequested } from "./shared.ts";
12
+ import { createStatusLine } from "./ui.ts";
13
+ import { unknownSubcommandError } from "./suggest.ts";
14
+
15
+
16
+ /**
17
+ * `icp` — develop and inspect the Ideal Customer Profile that targets acquire.
18
+ * The CLI can't run AskUserQuestion itself; `icp interview` emits the question
19
+ * spec an agent (Claude Code / Codex) drives with its AskUserQuestion tool, then
20
+ * `icp set` writes icp.json from the collected answers.
21
+ */
22
+ export async function icpCommand(args: string[]) {
23
+ const [sub, ...rest] = args;
24
+ // Help-before-network: catch --help/-h anywhere in argv (so `icp --help`,
25
+ // `icp judge --help`, `icp eval --help` all print usage before any work).
26
+ if (!sub || args.includes("--help") || args.includes("-h")) {
27
+ console.log(`Usage:
28
+ fullstackgtm icp interview emit the interview spec (an agent drives it with AskUserQuestion)
29
+ fullstackgtm icp set <answers.json> [--name <n>] [--out <path>] write icp.json from interview answers
30
+ fullstackgtm icp show [--icp <path>] render the ICP + the discovery filters it produces
31
+ fullstackgtm icp judge [--signals-from latest|<label>] [--with-history] [--prompt <path>] [--min-score 0] [--save] rank unjudged signals into send/nurture/skip decisions (timing x fit x memory)
32
+ fullstackgtm icp eval [--golden <path>|default] [--against-outcomes] [--min-accuracy 0.8] [--json] grade the judge: golden-set accuracy and/or hot>cold calibration; exits 2 below the bar
33
+
34
+ The ICP makes \`enrich acquire\` targeted, not random: it generates each
35
+ provider's discovery filters (Explorium, pipe0/Crustdata) AND scores every
36
+ discovered prospect for fit — only above-threshold leads become create_record
37
+ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.
38
+ \`icp judge\` ranks fresh signals (from \`signals fetch --save\`) into send/nurture/skip;
39
+ \`icp eval\` is the probabilistic-judgment gate before any apply.`);
40
+ return;
41
+ }
42
+ if (sub === "interview") {
43
+ console.log(
44
+ JSON.stringify(
45
+ {
46
+ questions: INTERVIEW_SPEC,
47
+ instructions:
48
+ "Ask each question with AskUserQuestion (multiSelect per .multiSelect). For each answer, collect the chosen options' .value arrays and concat them under the question's .id. Then run `fullstackgtm icp set <answers.json>` with that flattened object.",
49
+ },
50
+ null,
51
+ 2,
52
+ ),
53
+ );
54
+ return;
55
+ }
56
+ if (sub === "set") {
57
+ const file = rest.find((a) => !a.startsWith("--"));
58
+ if (!file) throw new Error("Usage: fullstackgtm icp set <answers.json> [--name <n>] [--out <path>]");
59
+ const answers = JSON.parse(readFileSync(resolve(process.cwd(), file), "utf8"));
60
+ const icp = icpFromAnswers(option(rest, "--name") ?? "ICP", answers);
61
+ const out = resolve(process.cwd(), option(rest, "--out") ?? "icp.json");
62
+ writeFileSync(out, `${JSON.stringify(icp, null, 2)}\n`);
63
+ console.log(
64
+ `Wrote ${out} — ${icp.persona.titleKeywords?.length ?? 0} title keyword(s), ` +
65
+ `${icp.firmographics.employeeBands?.length ?? 0} size band(s), fit threshold ${fitThreshold(icp)}.`,
66
+ );
67
+ return;
68
+ }
69
+ if (sub === "show") {
70
+ const icp = loadIcp(rest);
71
+ if (!icp) throw new Error("No ICP found (icp.json in cwd, or pass --icp <path>). Build one: fullstackgtm icp interview.");
72
+ console.log(
73
+ JSON.stringify(
74
+ {
75
+ icp,
76
+ exploriumFilters: icpToExploriumFilters(icp),
77
+ crustdataFilters: icpToCrustdataFilters(icp),
78
+ fitThreshold: fitThreshold(icp),
79
+ },
80
+ null,
81
+ 2,
82
+ ),
83
+ );
84
+ return;
85
+ }
86
+ if (sub === "judge") {
87
+ // The parent icpCommand already caught --help/-h before any work, so we can
88
+ // flag-parse then act (no network until judgeSignals, and only with a key).
89
+ const signalsFrom = option(rest, "--signals-from") ?? "latest";
90
+ const withHistory = rest.includes("--with-history");
91
+ const promptPath = option(rest, "--prompt");
92
+ const minScore = numericOption(rest, "--min-score") ?? 0;
93
+ const save = saveRequested(rest);
94
+
95
+ // 1) Load signals from the signal store.
96
+ const signalStore = createFileSignalStore();
97
+ const signalRun =
98
+ signalsFrom === "latest" ? await signalStore.latestRun() : await signalStore.getRun(signalsFrom);
99
+ if (!signalRun) {
100
+ throw new Error(`No signal run "${signalsFrom}" — run \`fullstackgtm signals fetch --save\` first.`);
101
+ }
102
+ const unjudged = signalRun.signals.filter((s) => !s.judgedBy);
103
+ if (unjudged.length === 0) throw new Error(`Signal run "${signalRun.runLabel}" has no unjudged signals.`);
104
+
105
+ // 2) Optional inputs: ICP (may be undefined), snapshot, outcomes + config.
106
+ // The snapshot is loaded whenever a source is given (--provider/--input/
107
+ // --demo/--sample) — it resolves each decision's CRM target (accountId +
108
+ // best contact) so `draft` can write against a real record. --with-history
109
+ // additionally enables the memory + fit scoring inputs.
110
+ const icp = loadIcp(rest);
111
+ const config: SignalsConfig = DEFAULT_SIGNALS_CONFIG;
112
+ const outcomes = await signalStore.listOutcomes();
113
+ const hasSnapshotSource =
114
+ Boolean(option(rest, "--provider")) ||
115
+ Boolean(option(rest, "--input")) ||
116
+ rest.includes("--demo") ||
117
+ rest.includes("--sample");
118
+ const snapshot = withHistory || hasSnapshotSource ? await readSnapshot(rest) : undefined;
119
+
120
+ // 3) Resolve the LLM seam ONLY if a key already exists (deterministic baseline
121
+ // otherwise — never PROMPT here; judge must run key-free).
122
+ const cred = resolveLlmCredential();
123
+ const baseUrls = resolveLlmBaseUrls();
124
+ const model = option(rest, "--model") ?? undefined;
125
+ const llm: LlmCallOptions | undefined = cred
126
+ ? { ...cred, ...baseUrls, ...(model ? { model } : {}) }
127
+ : undefined;
128
+ const promptTemplate = llm
129
+ ? promptPath
130
+ ? readFileSync(resolve(process.cwd(), promptPath), "utf8")
131
+ : DEFAULT_JUDGE_PROMPT
132
+ : undefined;
133
+
134
+ // 4) Judge. Interactive terminals get a per-account stderr status line
135
+ // (LLM judging is the slow path); inert otherwise.
136
+ const judgeStatus = createStatusLine();
137
+ let decisions: Awaited<ReturnType<typeof judgeSignals>>;
138
+ try {
139
+ decisions = await judgeSignals({
140
+ signals: unjudged,
141
+ outcomes,
142
+ config,
143
+ icp,
144
+ snapshot,
145
+ withHistory,
146
+ promptTemplate,
147
+ llm,
148
+ onAccount: judgeStatus.active
149
+ ? (done, total, domain) =>
150
+ judgeStatus.set(
151
+ `Judging account ${done + 1}/${total} · ${domain}${llm ? ` · ${llm.provider}` : " · deterministic"}`,
152
+ )
153
+ : undefined,
154
+ });
155
+ } finally {
156
+ judgeStatus.done();
157
+ }
158
+ decisions = decisions.filter((d) => d.score >= minScore);
159
+
160
+ // 5) Ranked decisions to stdout (JSON), guidance to stderr.
161
+ console.log(JSON.stringify(decisions, null, 2));
162
+ console.error(
163
+ `Judged ${unjudged.length} signal(s) across ${new Set(decisions.map((d) => d.accountDomain)).size} account(s): ` +
164
+ `${decisions.filter((d) => d.decision === "send").length} send, ` +
165
+ `${decisions.filter((d) => d.decision === "nurture").length} nurture, ` +
166
+ `${decisions.filter((d) => d.decision === "skip").length} skip` +
167
+ `${llm ? "" : " (deterministic — no LLM key; run `login anthropic|openai` for why-now + play)"}.`,
168
+ );
169
+
170
+ // 6) --save: persist a JudgeRun + stamp consumed signals judgedBy.
171
+ if (save) {
172
+ const runLabel = option(rest, "--label") ?? `judge-${new Date().toISOString().slice(0, 10)}`;
173
+ const store = createFileJudgeStore();
174
+ await store.appendRun({
175
+ id: judgeRunId(runLabel),
176
+ runLabel,
177
+ signalRunLabel: signalRun.runLabel,
178
+ createdAt: new Date().toISOString(),
179
+ decisions,
180
+ });
181
+ const judgedIds = new Set(decisions.flatMap((d) => d.evidence));
182
+ for (const s of signalRun.signals) if (judgedIds.has(s.id)) s.judgedBy = runLabel;
183
+ await signalStore.updateRun(signalRun);
184
+ console.error(
185
+ `Saved judge run "${runLabel}" (${decisions.length} decisions); stamped ${judgedIds.size} signals judgedBy. ` +
186
+ `Next: \`fullstackgtm draft --from-judge ${runLabel} --save\`.`,
187
+ );
188
+ } else {
189
+ console.error("(not saved — re-run with --save to persist a judge run and stamp the consumed signals)");
190
+ }
191
+ return;
192
+ }
193
+ if (sub === "eval") {
194
+ // The parent icpCommand already caught --help/-h before any work. The whole
195
+ // command is read-only: it grades, never emits a plan, never touches a provider.
196
+ const goldenArg = option(rest, "--golden") ?? "default";
197
+ const againstOutcomes = rest.includes("--against-outcomes");
198
+ const minAccuracy = numericOption(rest, "--min-accuracy") ?? DEFAULT_MIN_ACCURACY;
199
+ const asJson = rest.includes("--json");
200
+
201
+ if (againstOutcomes) {
202
+ // The empirical gate: hot decisions must book strictly more than cold.
203
+ const judgeStore = createFileJudgeStore();
204
+ const labelArg = option(rest, "--from-judge");
205
+ const judgeRun = labelArg ? await judgeStore.getRun(labelArg) : await judgeStore.latestRun();
206
+ if (!judgeRun) {
207
+ throw new Error(
208
+ `No judge run ${labelArg ? `"${labelArg}"` : "to grade"} — run \`fullstackgtm icp judge --save\` first.`,
209
+ );
210
+ }
211
+ const outcomes = await createFileSignalStore().listOutcomes();
212
+ const cal = gradeAgainstOutcomes(judgeRun.decisions, outcomes);
213
+ if (asJson) {
214
+ console.log(JSON.stringify(cal, null, 2));
215
+ } else {
216
+ console.log(
217
+ `Outcome calibration (judge run "${judgeRun.runLabel}"):\n` +
218
+ ` hot book rate: ${(cal.hotBookRate * 100).toFixed(1)}% over ${cal.hotCount} hot account(s)\n` +
219
+ ` cold book rate: ${(cal.coldBookRate * 100).toFixed(1)}% over ${cal.coldCount} cold account(s)\n` +
220
+ ` calibrated: ${cal.calibrated ? "yes (hot > cold)" : "NO (hot did not strictly beat cold)"}`,
221
+ );
222
+ }
223
+ console.error(
224
+ cal.calibrated
225
+ ? "Calibrated: hot scores book strictly more than cold — the judge earns its interrupt."
226
+ : "NOT calibrated: hot did not strictly beat cold. Tune the rubric before scheduling apply behind this gate.",
227
+ );
228
+ if (!cal.calibrated) process.exitCode = 2;
229
+ return;
230
+ }
231
+
232
+ // The golden-set gate (default). Resolve "default" BEFORE any file read.
233
+ // The default set is graded on its own pinned clock (its firstSeen stamps
234
+ // are relative to DEFAULT_GOLDEN_NOW_ISO) — wall time would let freshness
235
+ // decay flip the "fresh → send" rows as the calendar advances.
236
+ const rows =
237
+ goldenArg === "default"
238
+ ? DEFAULT_GOLDEN_SET
239
+ : parseGoldenSet(readFileSync(resolve(process.cwd(), goldenArg), "utf8"));
240
+ const gradeNow = goldenArg === "default" ? new Date(DEFAULT_GOLDEN_NOW_ISO) : new Date();
241
+ const result = await gradeJudge(rows, defaultJudgeFn({ icp: loadIcp(rest), now: gradeNow }));
242
+ if (asJson) {
243
+ console.log(JSON.stringify(result, null, 2));
244
+ } else {
245
+ const c = result.confusion;
246
+ console.log(
247
+ `Golden-set grade (${rows.length} row(s)):\n` +
248
+ ` accuracy: ${(result.accuracy * 100).toFixed(1)}% (bar ${(minAccuracy * 100).toFixed(0)}%)\n` +
249
+ ` precision: ${(result.precision * 100).toFixed(1)}%\n` +
250
+ ` recall: ${(result.recall * 100).toFixed(1)}%\n` +
251
+ ` confusion: tp=${c.truePositive} fp=${c.falsePositive} tn=${c.trueNegative} fn=${c.falseNegative}`,
252
+ );
253
+ }
254
+ if (result.accuracy < minAccuracy) {
255
+ console.error(
256
+ `Judge accuracy ${(result.accuracy * 100).toFixed(1)}% is below the ${(minAccuracy * 100).toFixed(0)}% bar — exit 2.`,
257
+ );
258
+ process.exitCode = 2;
259
+ } else {
260
+ console.error(`Judge passes the golden-set gate (${(result.accuracy * 100).toFixed(1)}% >= ${(minAccuracy * 100).toFixed(0)}%).`);
261
+ }
262
+ return;
263
+ }
264
+ throw unknownSubcommandError("icp", sub, ["interview", "set", "show", "judge", "eval"]);
265
+ }
@@ -0,0 +1,65 @@
1
+ // Extracted verbatim from src/cli.ts (mechanical split, no behavior change).
2
+
3
+ import { existsSync, writeFileSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { activeProfile, DEFAULT_PROFILE } from "../credentials.ts";
6
+ import { scaffoldWorkspace, type InitProvider, type InitSource } from "../init.ts";
7
+ import { option } from "./shared.ts";
8
+
9
+
10
+ /**
11
+ * `init` — scaffold a GTM workspace from cold scratch: a starter icp.json, an
12
+ * enrich.config.json (acquire preset + a visible assign seam), and a PLAYBOOK
13
+ * wired with the chosen source/provider that points at the recipes. Pure
14
+ * file-writer: no network, never overwrites without --force.
15
+ */
16
+ export function initCommand(args: string[]) {
17
+ if (args.includes("--help") || args.includes("-h")) {
18
+ console.log(`Usage:
19
+ fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
20
+
21
+ Scaffolds a workspace so the first acquire/signals/judge/draft commands work:
22
+ icp.json starter ICP (edit, or rebuild with \`icp interview\`)
23
+ enrich.config.json acquire preset for --source + a placeholder assign policy
24
+ PLAYBOOK.md the cold-start + outbound-loop recipes wired for this workspace
25
+
26
+ The CLI ships governed primitives; your coding agent is the orchestrator. See
27
+ docs/recipes.md for the full play set. Existing files are kept unless --force.`);
28
+ return;
29
+ }
30
+ const source = (option(args, "--source") ?? "pipe0") as InitSource;
31
+ if (!["pipe0", "explorium", "linkedin"].includes(source)) {
32
+ throw new Error(`init: --source must be pipe0|explorium|linkedin (got "${source}")`);
33
+ }
34
+ const provider = (option(args, "--provider") ?? "hubspot") as InitProvider;
35
+ if (!["hubspot", "salesforce"].includes(provider)) {
36
+ throw new Error(`init: --provider must be hubspot|salesforce (got "${provider}")`);
37
+ }
38
+ const outDir = resolve(process.cwd(), option(args, "--out") ?? ".");
39
+ const force = args.includes("--force");
40
+ const current = activeProfile();
41
+ const profile = current === DEFAULT_PROFILE ? undefined : current;
42
+
43
+ const files = scaffoldWorkspace({ source, provider, profile });
44
+ const wrote: string[] = [];
45
+ const kept: string[] = [];
46
+ for (const file of files) {
47
+ const path = resolve(outDir, file.path);
48
+ if (existsSync(path) && !force) {
49
+ kept.push(file.path);
50
+ continue;
51
+ }
52
+ writeFileSync(path, file.content);
53
+ wrote.push(file.path);
54
+ }
55
+
56
+ console.log(
57
+ `Scaffolded workspace (${provider}, discovery via ${source}${profile ? `, profile ${profile}` : ""}).`,
58
+ );
59
+ if (wrote.length) console.log(` wrote: ${wrote.join(", ")}`);
60
+ if (kept.length) console.log(` kept (use --force to overwrite): ${kept.join(", ")}`);
61
+ console.log(
62
+ "\nNext: edit icp.json + set acquire.assign.ownerId in enrich.config.json, then follow PLAYBOOK.md\n" +
63
+ "(full recipe set: docs/recipes.md).",
64
+ );
65
+ }