octwin-cli 0.8.16 → 0.9.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.
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `.octwin-base.json` — what this pack folder was made from: the pack, version and content hash of its
3
+ * last `octwin pull` or successful `octwin deploy`, plus one hash per file.
4
+ *
5
+ * Two writers publish the same pack: this CLI and Tavi (Build with AI) in the console. Until 0.8.17 a
6
+ * deploy said nothing about the copy it was made from, so an old copy on disk silently replaced what Tavi
7
+ * had just published. Now `deploy` sends the base and the platform refuses (`live_moved`) when the live
8
+ * content is not the base's — the platform's half is `deployVerdict` (`provisioning/deploy-base.ts`).
9
+ *
10
+ * The per-file hashes are for `pull`: a file that still matches the base is the old version and safe to
11
+ * replace; a file that does not is the author's own edit, and `pull` stops rather than lose it
12
+ * (`planPull`). Tracked in git like the rest of the pack — a fresh clone keeps its base. Never deployed:
13
+ * a dot-file is not pack content (`classifyPackPath`).
14
+ *
15
+ * Pure apart from `readBase` / `writeBase`, so every rule is tested without a network.
16
+ */
17
+ import { createHash } from 'node:crypto';
18
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ export const BASE_FILE = '.octwin-base.json';
21
+ /**
22
+ * The hash of one file. Text is hashed with CRLF read as LF: git on Windows rewrites line endings on
23
+ * checkout, and a base that called every file "edited" after a clone would make `pull` useless there.
24
+ */
25
+ export function fileHash(kind, body) {
26
+ const bytes = kind === 'text' ? Buffer.from(body.replace(/\r\n/g, '\n'), 'utf8') : Buffer.from(body, 'base64');
27
+ return createHash('sha256').update(bytes).digest('hex');
28
+ }
29
+ /** One hash per file of a bundle — text files as UTF-8, blobs as base64 (the shape deploy and pull carry). */
30
+ export function hashBundle(files, blobs) {
31
+ const out = {};
32
+ for (const [rel, body] of Object.entries(files))
33
+ out[rel] = fileHash('text', body);
34
+ for (const [rel, b64] of Object.entries(blobs))
35
+ out[rel] = fileHash('blob', b64);
36
+ return out;
37
+ }
38
+ /** The folder's base, or null when it has none — or one this CLI cannot read, which says nothing either. */
39
+ export function readBase(dir) {
40
+ const p = join(dir, BASE_FILE);
41
+ if (!existsSync(p))
42
+ return null;
43
+ try {
44
+ const b = JSON.parse(readFileSync(p, 'utf8'));
45
+ if (typeof b.pack_id !== 'string' || typeof b.version !== 'string' || typeof b.content_sha !== 'string')
46
+ return null;
47
+ const files = b.files && typeof b.files === 'object' && !Array.isArray(b.files) ? b.files : {};
48
+ return { pack_id: b.pack_id, version: b.version, content_sha: b.content_sha, files };
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ export function writeBase(dir, base) {
55
+ const files = Object.fromEntries(Object.entries(base.files).sort(([a], [b]) => a.localeCompare(b)));
56
+ writeFileSync(join(dir, BASE_FILE), JSON.stringify({ ...base, files }, null, 2) + '\n', 'utf8');
57
+ }
58
+ /** What a deploy sends as `base` — the three facts the platform compares, never the file list. */
59
+ export function deployBaseOf(base) {
60
+ return base ? { pack_id: base.pack_id, version: base.version, content_sha: base.content_sha } : null;
61
+ }
62
+ /**
63
+ * What a pull into a folder would do. `local` and `incoming` are path → `fileHash`.
64
+ *
65
+ * • `atRisk` — files the pull would overwrite or delete that hold work the base does not: changed or
66
+ * added since the last pull/deploy. With no base nothing can be told apart, so every local file that
67
+ * differs from the incoming one is at risk. A file already equal to the incoming one never is.
68
+ * • `remove` — local files the incoming version no longer has (the live version deleted them).
69
+ *
70
+ * The caller refuses when `atRisk` is not empty, unless `--force`.
71
+ */
72
+ export function planPull(args) {
73
+ const { local, incoming, base } = args;
74
+ const atRisk = [];
75
+ const remove = [];
76
+ for (const [rel, hash] of Object.entries(local)) {
77
+ if (incoming[rel] === hash)
78
+ continue;
79
+ if (!(base && base.files[rel] === hash))
80
+ atRisk.push(rel);
81
+ if (!(rel in incoming))
82
+ remove.push(rel);
83
+ }
84
+ return { atRisk: atRisk.sort(), remove: remove.sort() };
85
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The pack id `octwin init <dir>` derives when no `--id` is given.
3
+ *
4
+ * It is the target directory's OWN name — its last path segment — lowercased, every run of
5
+ * characters outside `[a-z0-9-]` one hyphen, edges trimmed. Until 2026-10-11 it stripped the slashes
6
+ * out of the WHOLE path instead, so `./clinic` became `-clinic` (refused) and `packs/clinic` became
7
+ * `packsclinic`. The result still passes `describePackNameProblem` in `cmdInit`, which names the fix
8
+ * (`--id`) when a directory's name cannot be a pack id.
9
+ */
10
+ import { basename, resolve } from 'node:path';
11
+ export function packIdFromDir(target) {
12
+ return basename(resolve(target)).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
13
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Offline journey-id check — every LITERAL id a flow names in `track:` or `do: track_event` must be
3
+ * declared by one of the pack's own `journeys/*.journey.yaml`.
4
+ *
5
+ * WHY it matters: a journey id resolves at RUNTIME, against the pack's journey indexes. A `track:`
6
+ * naming one nothing declares throws mid-conversation (the customer gets the flow's generic failure
7
+ * copy, the real reason is only in `octwin logs`), and `do: track_event` takes its `failed` port on
8
+ * every run, so the funnel silently records nothing. A September field report shipped four dead
9
+ * `track:` call sites across three flows in a pack with no `journeys/` at all, and the offline check
10
+ * passed it — the platform's `undeclared-journey-reference` rule caught it only on `--remote`.
11
+ *
12
+ * The ids are the pack's own, so this check needs NO pulled KB and always runs: the declared set is
13
+ * read from the same bundle, the way the platform reads it (`journeys/<id>.journey.yaml`, top level
14
+ * of the folder only). Stage and goal ids are `id:`, event ids `name:`; `key:` is the accepted
15
+ * synonym for either. A pack with no `journeys/` declares an EMPTY set, so every literal id it names
16
+ * is reported — that is the case this check exists for.
17
+ *
18
+ * Each key resolves against its OWN index, as on the platform: `event` against events,
19
+ * `stage_enter` against stages, `goal_reached` against goals. A stage id does not satisfy `event:`.
20
+ *
21
+ * WHAT IT DOES NOT CHECK — stated because a check that hides its edges gets trusted past them:
22
+ * • An EXPRESSION id (`'$state.x'`, `'{$…}'`) — it names nothing this check could look up. On a
23
+ * `track:` node that is a defect of its own (the node reads the id as written), which the
24
+ * platform's `track-id-not-literal` reports; on `track_event` it is legal.
25
+ * • Anything inside a `use:` template body — the platform expands those; `--remote` covers it.
26
+ * • Whether a journey file is itself valid — the declaration check and `--remote` own that.
27
+ */
28
+ /** `journeys/<id>.journey.yaml`, top level of the folder only — the platform loader's own rule. */
29
+ const JOURNEY_FILE = /^journeys\/[^/]+\.journey\.yaml$/;
30
+ /** Flow files — everything the platform loads as a flow lives under `flows/`. */
31
+ const FLOW_FILE = /^flows\/.+\.ya?ml$/i;
32
+ const norm = (p) => p.replace(/\\/g, '/');
33
+ export const isJourneyFile = (p) => JOURNEY_FILE.test(norm(p));
34
+ export const isFlowFile = (p) => FLOW_FILE.test(norm(p));
35
+ const isBag = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
36
+ /** A runtime-resolved value; nothing static to check. Same predicate as the platform rule. */
37
+ const isExpr = (v) => v.trimStart().startsWith('$') || v.includes('{$');
38
+ /** Collect every declared id from the pack's parsed files (non-journey files are ignored). */
39
+ export function collectJourneyIds(docs) {
40
+ const ids = { events: new Set(), stages: new Set(), goals: new Set(), any: false };
41
+ const idOf = (row, field) => {
42
+ if (!isBag(row))
43
+ return null;
44
+ const v = row[field] ?? row['key'];
45
+ return typeof v === 'string' && v !== '' ? v : null;
46
+ };
47
+ for (const [file, doc] of docs) {
48
+ if (!isJourneyFile(file))
49
+ continue;
50
+ ids.any = true;
51
+ if (!isBag(doc))
52
+ continue;
53
+ const list = (k) => (Array.isArray(doc[k]) ? doc[k] : []);
54
+ for (const s of list('stages')) {
55
+ const id = idOf(s, 'id');
56
+ if (id)
57
+ ids.stages.add(id);
58
+ }
59
+ for (const g of list('goals')) {
60
+ const id = idOf(g, 'id');
61
+ if (id)
62
+ ids.goals.add(id);
63
+ }
64
+ for (const e of list('events')) {
65
+ const id = idOf(e, 'name');
66
+ if (id)
67
+ ids.events.add(id);
68
+ }
69
+ }
70
+ return ids;
71
+ }
72
+ /** Walk one parsed flow file and report every literal journey id the pack does not declare. */
73
+ export function findJourneyViolations(doc, file, ids) {
74
+ const findings = [];
75
+ const KEYS = [
76
+ ['event', ids.events],
77
+ ['stage_enter', ids.stages],
78
+ ['goal_reached', ids.goals],
79
+ ];
80
+ const check = (bag, at, via) => {
81
+ for (const [key, declared] of KEYS) {
82
+ const id = bag[key];
83
+ if (typeof id !== 'string' || id === '' || isExpr(id) || declared.has(id))
84
+ continue;
85
+ findings.push({
86
+ file, path: [...at, key], via, key, id,
87
+ declared: [...declared].sort(), noJourneys: !ids.any,
88
+ });
89
+ }
90
+ };
91
+ const walk = (node, path) => {
92
+ if (Array.isArray(node)) {
93
+ node.forEach((v, i) => walk(v, [...path, i]));
94
+ return;
95
+ }
96
+ if (!isBag(node))
97
+ return;
98
+ if (isBag(node['track']))
99
+ check(node['track'], [...path, 'track'], 'track');
100
+ if (node['do'] === 'track_event' && isBag(node['args']))
101
+ check(node['args'], [...path, 'args'], 'track_event');
102
+ for (const [k, v] of Object.entries(node))
103
+ walk(v, [...path, k]);
104
+ };
105
+ walk(doc, []);
106
+ return findings;
107
+ }
108
+ const WHAT = { event: 'event', stage_enter: 'stage', goal_reached: 'goal' };
109
+ /** One-line human message per finding — the platform rule's wording, with the file and line. */
110
+ export function describeJourneyFinding(f) {
111
+ const at = f.line != null ? `:${f.line}` : (f.path.length ? ` (${f.path.join('.')})` : '');
112
+ const label = f.via === 'track' ? `track.${f.key}` : `track_event args.${f.key}`;
113
+ const tail = f.noJourneys
114
+ ? ` — this pack ships no journeys/, so ${f.via === 'track' ? 'every track: in it fails at runtime' : 'this call takes its `failed` port on every run'}`
115
+ : f.declared.length
116
+ ? ` (declared: ${f.declared.join(', ')})`
117
+ : ` (no journey declares any ${WHAT[f.key]})`;
118
+ return `${f.file}${at}: ${label}: ${WHAT[f.key]} '${f.id}' is declared in no journey${tail}`;
119
+ }
@@ -5,7 +5,8 @@
5
5
  * the pack being authored, so the pull ships navigation instead:
6
6
  *
7
7
  * INDEX.md — the corpus: which docs exist, which FAMILY each belongs to,
8
- * how big it is, and the order to read the first six.
8
+ * how big it is, the order to read the first few, which guide
9
+ * to add per capability, the recipes, and every symptom row.
9
10
  * SYMBOLS.md — a NAME → the file that defines it (see `kb-symbols.ts`).
10
11
  * OUTLINE.md — every doc's headings WITH line numbers, so a heavy doc is read
11
12
  * from the right offset instead of end to end.
@@ -55,27 +56,129 @@ export function kbOneLiner(text, max = 160) {
55
56
  const line = end >= 40 ? flat.slice(0, end) : flat;
56
57
  return line.length > max ? line.slice(0, max - 1).trimEnd() + '…' : line;
57
58
  }
59
+ /**
60
+ * The size at which a doc is "heavy — search it" in INDEX.md. Also the line a CRAFT guide may not
61
+ * cross without a named reason (`content/kb/validate.ts` §2c) — one number, so the label an author
62
+ * reads and the gate the KB is held to cannot disagree.
63
+ */
64
+ export const HEAVY_BYTES = 30 * 1024;
58
65
  /**
59
66
  * What one doc costs to open.
60
67
  *
61
68
  * DERIVED from the published byte count, never declared. `craft-capabilities`
62
69
  * used to carry this as a hand-typed table of four doc names, which is a fact
63
- * stated twice — and the corpus is lopsided enough (3.7 KB to 71 KB) that
64
- * getting it wrong costs an agent a five-figure token read on a hunch.
70
+ * stated twice — and the corpus is lopsided enough that getting it wrong costs
71
+ * an agent a five-figure token read on a hunch.
65
72
  */
66
73
  export function kbWeight(bytes) {
67
74
  if (!bytes)
68
75
  return { label: '', kb: '' };
69
76
  const kb = `${Math.round(bytes / 1024)} KB`;
70
- if (bytes >= 30 * 1024)
77
+ if (bytes >= HEAVY_BYTES)
71
78
  return { label: 'heavy — search it', kb };
72
79
  if (bytes >= 12 * 1024)
73
80
  return { label: 'a sitting', kb };
74
81
  return { label: 'one read', kb };
75
82
  }
83
+ /** A table cell holds no raw `|`, and no line break. */
84
+ export const kbEscapePipes = (s) => s.replace(/(?<!\\)\|/g, '\\|');
85
+ const cellText = (s) => kbEscapePipes(s.replace(/\s+/g, ' ').trim());
86
+ /**
87
+ * The words that name one section of a doc: `§3` for a numbered heading (`## 3. …`, `## §3 …`,
88
+ * `## 3b. …`, `## 2.5 …`), the heading itself otherwise. An anchor the outline does not carry prints
89
+ * as itself — `content:check` refuses one, so that only happens against a hand-built index.
90
+ */
91
+ export function kbSectionLabel(doc, anchor) {
92
+ const s = doc.sections?.find(x => x.anchor === anchor);
93
+ if (!s)
94
+ return `#${anchor}`;
95
+ const n = /^§?\s?(\d+(?:\.\d+)?[a-z]?)[.:]?\s/.exec(s.title);
96
+ return n ? `§${n[1]}` : s.title;
97
+ }
98
+ /**
99
+ * A section's heading WITHOUT its number — `4. Many — what the customer sees` → `Many — what the customer
100
+ * sees`. For a committed page that check:kb-examples scans: a `§4` printed right after a symptom that
101
+ * says "button label" reads, to its cap rule, as a 4-character button cap.
102
+ */
103
+ export function kbSectionTitle(doc, anchor) {
104
+ const s = doc.sections?.find(x => x.anchor === anchor);
105
+ return s ? s.title.replace(/^§?\s?\d+(?:\.\d+)?[a-z]?[.:]?\s+/, '') : `#${anchor}`;
106
+ }
107
+ /** `§3` follows the guide's name directly; a heading title is set off with `›`. */
108
+ export const kbSectionSuffix = (doc, anchor) => {
109
+ if (!anchor)
110
+ return '';
111
+ const label = kbSectionLabel(doc, anchor);
112
+ return label.startsWith('§') ? ` ${label}` : ` › ${label}`;
113
+ };
114
+ /** The reading route every author takes first, in `read_order`. */
115
+ export function renderReadingRoute(docs, cell, opts = {}) {
116
+ return docs
117
+ .filter(d => d.read_order != null)
118
+ .sort((a, b) => a.read_order - b.read_order)
119
+ .map(d => {
120
+ const w = kbWeight(d.bytes);
121
+ return `${d.read_order}. ${cell(d)}${opts.sizes && w.kb ? ` *(${w.kb})*` : ''} — ${kbOneLiner(d.summary, 150)}`;
122
+ });
123
+ }
124
+ /** "The business… → also read", one row per declared route, in index order. Empty when no doc declares one. */
125
+ export function renderCapabilityRoutes(docs, cell) {
126
+ const rows = docs.flatMap(d => (d.routes ?? []).map(r => ({ d, r })));
127
+ if (!rows.length)
128
+ return [];
129
+ return [
130
+ '| The business… | Also read |',
131
+ '|---|---|',
132
+ ...rows.map(({ d, r }) => `| ${cellText(r.when)} | ${cell(d, r.anchor, r.note ? cellText(r.note) : undefined)} |`),
133
+ ];
134
+ }
135
+ /** Every module recipe with its id. Empty when no doc carries one. */
136
+ export function renderRecipes(docs, cell) {
137
+ const rows = docs.flatMap(d => (d.recipes ?? []).map(r => ({ d, r })));
138
+ if (!rows.length)
139
+ return [];
140
+ return [
141
+ '| Capability | Recipe | Id |',
142
+ '|---|---|---|',
143
+ ...rows.map(({ d, r }) => `| ${cellText(r.module)} | ${cell(d, r.anchor)} | \`guide:${d.key}#${r.anchor}\` |`),
144
+ ];
145
+ }
146
+ /** Every guide's symptom rows, gathered: what you see → the guide, the section, and why. */
147
+ export function renderSymptoms(docs, cell) {
148
+ const rows = docs.flatMap(d => (d.symptoms ?? []).map(s => ({ d, s })));
149
+ if (!rows.length)
150
+ return [];
151
+ return [
152
+ '| What you\'re seeing | Read |',
153
+ '|---|---|',
154
+ ...rows.map(({ d, s }) => `| ${cellText(s.text)} | ${cell(d, s.anchor, s.why ? cellText(s.why) : undefined)} |`),
155
+ ];
156
+ }
157
+ /** INDEX.md's cell: the pulled file, for a section the line to read it from, then the note. */
158
+ const indexCell = (d, anchor, note) => {
159
+ const line = anchor ? d.sections?.find(s => s.anchor === anchor)?.line : undefined;
160
+ return `\`${d.key}.md\`${kbEscapePipes(kbSectionSuffix(d, anchor))}${line ? ` (line ${line})` : ''}${note ? ` — ${note}` : ''}`;
161
+ };
162
+ /**
163
+ * One row per doc: its title, its one-line summary, and the view's cell for the file — plus its size
164
+ * and class when `sizes` is set. Sizes are INDEX.md's alone: a committed page that printed them would
165
+ * be rewritten by every edit to any other doc.
166
+ */
167
+ export function renderKbDocTable(rows, cell, opts = {}) {
168
+ return [
169
+ opts.sizes ? '| Doc | Read it for | File | Size |' : '| Doc | Read it for | File |',
170
+ opts.sizes ? '|---|---|---|---|' : '|---|---|---|',
171
+ ...rows.map(d => {
172
+ const w = kbWeight(d.bytes);
173
+ const size = opts.sizes ? ` ${w.kb ? `${w.kb} · ${w.label}` : ''} |` : '';
174
+ return `| ${d.title ?? d.key} | ${kbOneLiner(d.summary).replace(/\|/g, '\\|')} | ${cell(d)} |${size}`;
175
+ }),
176
+ ];
177
+ }
76
178
  /** Display order of the doc families: the authoring on-ramp first, grammar last. */
77
179
  const GROUP_ORDER = ['craft', 'module-guide', 'reference'];
78
- const GROUP_HEADING = {
180
+ /** Each family's heading and one-paragraph blurb — INDEX.md's, and `craft-capabilities`' generated doc list. */
181
+ export const KB_GROUP_HEADING = {
79
182
  craft: {
80
183
  title: 'Craft guides — how to build WELL',
81
184
  blurb: 'Author altitude, opinionated, and where the traps are. Each is small enough to read whole. '
@@ -118,28 +221,49 @@ export function buildKbIndexMarkdown(bundle, catalogEntryCounts) {
118
221
  L.push('| a heavy doc and one subject inside it | **`OUTLINE.md`** — every heading with its line number; read from there |');
119
222
  L.push('');
120
223
  // The reading route, straight from the index — no second hand-maintained copy.
121
- const route = docs.filter(d => d.read_order != null).sort((a, b) => a.read_order - b.read_order);
224
+ const route = renderReadingRoute(docs, indexCell, { sizes: true });
122
225
  if (route.length) {
123
226
  L.push('## Read in this order');
124
227
  L.push('');
125
228
  L.push('Before authoring anything. Everything else is reached on demand.');
126
229
  L.push('');
127
- for (const d of route) {
128
- const w = kbWeight(d.bytes);
129
- L.push(`${d.read_order}. \`${d.key}.md\`${w.kb ? ` *(${w.kb})*` : ''} — ${kbOneLiner(d.summary, 150)}`);
130
- }
230
+ L.push(...route);
231
+ L.push('');
232
+ }
233
+ // The three routing tables — each printed only when the platform publishes its field, so an older
234
+ // platform's index prints none of them rather than an empty table.
235
+ const perCapability = renderCapabilityRoutes(docs, indexCell);
236
+ if (perCapability.length) {
237
+ L.push('## Add per capability');
238
+ L.push('');
239
+ L.push('Then add only the guides for what the business actually does — a section, where one is named:');
240
+ L.push('');
241
+ L.push(...perCapability);
242
+ L.push('');
243
+ }
244
+ const recipes = renderRecipes(docs, indexCell);
245
+ if (recipes.length) {
246
+ L.push('## Start from a recipe');
247
+ L.push('');
248
+ L.push('A recipe is a section of its guide that builds one capability end to end: its ingredients, a');
249
+ L.push('starter to copy, the rules, and the scenarios to test it with. Where one exists, start there.');
250
+ L.push('');
251
+ L.push(...recipes);
252
+ L.push('');
253
+ }
254
+ const symptoms = renderSymptoms(docs, indexCell);
255
+ if (symptoms.length) {
256
+ L.push('## When something is already wrong');
257
+ L.push('');
258
+ L.push('Every guide\'s `## Symptoms` rows, gathered. Find what you are seeing, then read that section.');
259
+ L.push('');
260
+ L.push(...symptoms);
131
261
  L.push('');
132
262
  }
133
263
  L.push('## Guides & reference docs');
134
264
  L.push('');
135
265
  const renderDocTable = (rows) => {
136
- L.push('| Doc | Read it for | File | Size |');
137
- L.push('|---|---|---|---|');
138
- for (const d of rows) {
139
- const w = kbWeight(d.bytes);
140
- const size = w.kb ? `${w.kb} · ${w.label}` : '';
141
- L.push(`| ${d.title ?? d.key} | ${kbOneLiner(d.summary).replace(/\|/g, '\\|')} | \`${d.key}.md\` | ${size} |`);
142
- }
266
+ L.push(...renderKbDocTable(rows, indexCell, { sizes: true }));
143
267
  L.push('');
144
268
  };
145
269
  if (grouped) {
@@ -147,7 +271,7 @@ export function buildKbIndexMarkdown(bundle, catalogEntryCounts) {
147
271
  const rows = docs.filter(d => d.group === g);
148
272
  if (!rows.length)
149
273
  continue;
150
- const h = GROUP_HEADING[g];
274
+ const h = KB_GROUP_HEADING[g];
151
275
  L.push(`### ${h.title}`);
152
276
  L.push('');
153
277
  L.push(h.blurb);
@@ -0,0 +1,190 @@
1
+ /**
2
+ * `octwin recipe <module>` — write a KB recipe's starter files into a pack, from the pulled KB.
3
+ *
4
+ * A recipe is a `## <Module> recipe` section of a craft guide whose `### <Module> starter` carries
5
+ * whole files, each a ```yaml fence whose first line is `# starter: <kind> — <path>` (the same
6
+ * contract the platform's `content/kb/recipes.test.ts` holds every starter to, as one pack on the
7
+ * `octwin init` starter). Reading them from the PULLED guide is the point: there is no second copy of
8
+ * a recipe to drift — hand-kept `--template` folders would have been one per recipe.
9
+ *
10
+ * Nothing is overwritten. A flow, a flow's locale file or a declaration the pack does not have yet is
11
+ * written; one it already has is left alone — a declaration's part is printed for the author to merge
12
+ * (an `xrm.yaml` already holds their entities), and a file the recipe would replace is named. A
13
+ * `manifest` starter is printed as the lines to add, and so are the new flows for `flows:` and the
14
+ * agent's `tools:`.
15
+ *
16
+ * Platform-free like every file here: the parse is a fence regex, not the platform's outline reader.
17
+ *
18
+ * A recipe can NEED another: the booking starter's "no free slot" button opens `reception`, which the
19
+ * handoff recipe ships, whose own buttons open casework's `ticket`. `recipeNeeds` derives that from the
20
+ * recipe's files — never a hand-kept list — so `octwin recipe` can say it before the author meets the
21
+ * chain one validate error at a time (2026-10-12: an author adding only `booking` hit three in a row).
22
+ */
23
+ import { parse as parseYaml } from 'yaml';
24
+ import { collectJourneyIds, findJourneyViolations, isJourneyFile } from './journey-check.js';
25
+ /** Every recipe the pulled index names, module → the guide that carries it. */
26
+ export function recipeModules(index) {
27
+ const out = new Map();
28
+ for (const e of index)
29
+ for (const r of e.recipes ?? [])
30
+ if (!out.has(r.module))
31
+ out.set(r.module, e.key);
32
+ return out;
33
+ }
34
+ const STARTER_FENCE = /```yaml\n# starter: (\S+)(?: — ([^\n]+))?\n([\s\S]*?)```/g;
35
+ /**
36
+ * The starters under `## <Module> recipe` in a guide (up to the next H2), or `null` when the guide
37
+ * has no such section. `module` is the recipe's module word (`booking`, `reschedule`), matched without
38
+ * regard to case.
39
+ */
40
+ export function recipeStarters(markdown, module) {
41
+ const text = markdown.replace(/\r\n/g, '\n');
42
+ const heading = new RegExp(`^## ${module.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} recipe\\s*$`, 'im');
43
+ const m = heading.exec(text);
44
+ if (!m)
45
+ return null;
46
+ const from = m.index + m[0].length;
47
+ const next = text.slice(from).search(/^## /m);
48
+ const section = next < 0 ? text.slice(from) : text.slice(from, from + next);
49
+ const out = [];
50
+ for (const f of section.matchAll(STARTER_FENCE)) {
51
+ const rest = (f[2] ?? '').trim();
52
+ const paren = rest.indexOf(' (');
53
+ out.push({
54
+ kind: f[1],
55
+ path: (paren < 0 ? rest : rest.slice(0, paren)).trim(),
56
+ note: paren < 0 ? null : rest.slice(paren + 2).replace(/\)\s*$/, '').trim() || null,
57
+ body: f[3],
58
+ });
59
+ }
60
+ return out;
61
+ }
62
+ /** A flow's id from its path — `flows/tools/book.flow.yaml` → `book`. */
63
+ const flowIdOf = (path) => path.split('/').pop().replace(/\.flow\.yaml$/, '');
64
+ /**
65
+ * Decide what each starter does to the pack at `exists`'s answers. A pack-level locale file carries
66
+ * the PACK's id as its `pack_id` — the recipe's own (`homefix`) would namespace its labels under a
67
+ * pack that is not this one, and `$enum_label` would find none of them.
68
+ */
69
+ export function planRecipe(starters, exists, packId) {
70
+ const actions = [];
71
+ const flows = [];
72
+ for (const s of starters) {
73
+ if (s.kind === 'manifest') {
74
+ actions.push({ act: 'manifest', body: s.body });
75
+ continue;
76
+ }
77
+ const body = s.kind === 'pack-locale' ? s.body.replace(/^pack_id:.*$/m, `pack_id: ${packId}`) : s.body;
78
+ if (s.kind === 'flow')
79
+ flows.push(flowIdOf(s.path));
80
+ if (!exists(s.path))
81
+ actions.push({ act: 'write', path: s.path, body });
82
+ else if (s.kind === 'flow' || s.kind === 'locale')
83
+ actions.push({ act: 'exists', path: s.path });
84
+ else
85
+ actions.push({ act: 'merge', path: s.path, note: s.note, body });
86
+ }
87
+ return { actions, flows };
88
+ }
89
+ const isExprId = (v) => v.startsWith('$') || v.includes('{$');
90
+ /**
91
+ * A tap target the PLATFORM answers, not a pack flow — `_platform.handoff_cancel` today. The prefix is
92
+ * reserved (no pack flow id can carry it), so it opens nothing a recipe could ship. The platform's
93
+ * constant is `HANDOFF_CANCEL_TAP_TARGET`; `content/kb/recipes.test.ts` holds it to this prefix.
94
+ */
95
+ export const PLATFORM_TARGET_PREFIX = '_platform.';
96
+ function parseOrNull(body) {
97
+ try {
98
+ return parseYaml(body);
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ }
104
+ /** Every literal flow id an `invoke:` / `resume:` names in `node`, into `out` — `$self` opens nothing new. */
105
+ function collectOpens(node, out) {
106
+ if (Array.isArray(node)) {
107
+ for (const v of node)
108
+ collectOpens(v, out);
109
+ return;
110
+ }
111
+ if (node === null || typeof node !== 'object')
112
+ return;
113
+ for (const [k, v] of Object.entries(node)) {
114
+ if ((k === 'invoke' || k === 'resume') && typeof v === 'string') {
115
+ if (v !== '$self' && !isExprId(v) && !v.startsWith(PLATFORM_TARGET_PREFIX))
116
+ out.add(v);
117
+ }
118
+ else
119
+ collectOpens(v, out);
120
+ }
121
+ }
122
+ function factsOf(starters) {
123
+ const facts = { ships: new Set(), opens: new Set(), journeys: [], tracking: [] };
124
+ for (const s of starters) {
125
+ if (s.kind === 'manifest' || s.kind === 'locale' || s.kind === 'pack-locale')
126
+ continue;
127
+ const doc = parseOrNull(s.body);
128
+ if (isJourneyFile(s.path)) {
129
+ facts.journeys.push([s.path, doc]);
130
+ continue;
131
+ }
132
+ if (s.kind === 'flow') {
133
+ facts.ships.add(flowIdOf(s.path));
134
+ collectOpens(doc, facts.opens);
135
+ }
136
+ facts.tracking.push([s.path, doc]); // a declaration's `on:` hook carries `track:` too
137
+ }
138
+ return facts;
139
+ }
140
+ const INDEX_OF = { event: 'events', stage_enter: 'stages', goal_reached: 'goals' };
141
+ /**
142
+ * The other recipes `module` needs on top of what the pack `has`: a recipe that ships each flow its
143
+ * taps open, and one whose journey declares each literal id its `track:` names — then theirs, until
144
+ * nothing is missing. Read from the recipes' own files, the same parse the platform's cookbook test
145
+ * holds every recipe to, so a starter that gains a button gains the need with it.
146
+ */
147
+ export function recipeNeeds(module, recipes, has) {
148
+ const memo = new Map();
149
+ const facts = (m) => {
150
+ let f = memo.get(m);
151
+ if (!f)
152
+ memo.set(m, f = factsOf(recipes.get(m) ?? []));
153
+ return f;
154
+ };
155
+ const chosen = [module];
156
+ const direct = [];
157
+ const unresolved = [];
158
+ const take = (provider, from) => {
159
+ if (chosen.includes(provider))
160
+ return;
161
+ chosen.push(provider);
162
+ if (from === module)
163
+ direct.push(provider);
164
+ };
165
+ for (let i = 0; i < chosen.length; i++) {
166
+ const m = chosen[i];
167
+ const shipped = new Set([...has.flows, ...chosen.flatMap(c => [...facts(c).ships])]);
168
+ for (const flow of facts(m).opens) {
169
+ if (shipped.has(flow))
170
+ continue;
171
+ const provider = [...recipes.keys()].find(r => facts(r).ships.has(flow));
172
+ if (provider)
173
+ take(provider, m);
174
+ else
175
+ unresolved.push(`${m}: a button opens '${flow}', which neither the pack nor any recipe ships`);
176
+ }
177
+ const declared = collectJourneyIds([...has.journeys, ...chosen.flatMap(c => facts(c).journeys)]);
178
+ for (const [path, doc] of facts(m).tracking) {
179
+ for (const v of findJourneyViolations(doc, path, declared)) {
180
+ const index = INDEX_OF[v.key];
181
+ const provider = [...recipes.keys()].find(r => collectJourneyIds(facts(r).journeys)[index].has(v.id));
182
+ if (provider)
183
+ take(provider, m);
184
+ else
185
+ unresolved.push(`${m}: ${path} tracks ${v.key} '${v.id}', which no journey declares`);
186
+ }
187
+ }
188
+ }
189
+ return { needs: chosen.slice(1), direct, unresolved: [...new Set(unresolved)] };
190
+ }