@pipefy/pipefy-process-coder 0.1.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 (92) hide show
  1. package/.agents/skills/ppc-pipefy-flow-authoring/SKILL.md +262 -0
  2. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-danfe-consulta.json +247 -0
  3. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-webhook-retorno-consulta.json +589 -0
  4. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-4-recebimento-barramento.json +1391 -0
  5. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-2-danfe-retorno.json +623 -0
  6. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-4-criacao-operacao.json +636 -0
  7. package/.agents/skills/ppc-pipefy-flow-authoring/examples/03-subflow-4-criacao-titulo.json +3642 -0
  8. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-cedente.json +863 -0
  9. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-sacado.json +799 -0
  10. package/.agents/skills/ppc-pipefy-flow-authoring/examples/README.md +42 -0
  11. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-acompanhamento-cobranca.json +581 -0
  12. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-nfe-monitoramento.json +503 -0
  13. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-retorno-bancario.json +562 -0
  14. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-cedente.json +557 -0
  15. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-sacado.json +609 -0
  16. package/.agents/skills/ppc-pipefy-pipe-authoring/SKILL.md +146 -0
  17. package/.agents/skills/ppc-pipefy-process-design/SKILL.md +127 -0
  18. package/.agents/skills/ppc-pipefy-workspace/SKILL.md +91 -0
  19. package/AGENTS.md +456 -0
  20. package/README.md +808 -0
  21. package/bin/pipe.js +13 -0
  22. package/package.json +35 -0
  23. package/src/apply/adopt.ts +150 -0
  24. package/src/apply/agentops.ts +71 -0
  25. package/src/apply/compile.ts +875 -0
  26. package/src/apply/execute.ts +336 -0
  27. package/src/apply/flowops.ts +399 -0
  28. package/src/apply/idmap.ts +241 -0
  29. package/src/apply/mutations.ts +955 -0
  30. package/src/apply/registry.ts +430 -0
  31. package/src/apply/types.ts +134 -0
  32. package/src/cli/args.ts +88 -0
  33. package/src/cli.ts +211 -0
  34. package/src/codec/flow.ts +199 -0
  35. package/src/codec/pack.ts +103 -0
  36. package/src/codec/roundtrip.ts +94 -0
  37. package/src/codec/unpack.ts +198 -0
  38. package/src/commands/agents.ts +184 -0
  39. package/src/commands/apply.ts +1144 -0
  40. package/src/commands/context.ts +119 -0
  41. package/src/commands/create.ts +79 -0
  42. package/src/commands/diff.ts +314 -0
  43. package/src/commands/flows.ts +414 -0
  44. package/src/commands/misc.ts +644 -0
  45. package/src/commands/plan.ts +331 -0
  46. package/src/commands/pull.ts +567 -0
  47. package/src/commands/runs.ts +83 -0
  48. package/src/commands/skills.ts +137 -0
  49. package/src/commands/verify.ts +253 -0
  50. package/src/config.ts +168 -0
  51. package/src/diff/agents.ts +122 -0
  52. package/src/diff/diff.ts +1130 -0
  53. package/src/diff/flow.ts +318 -0
  54. package/src/diff/html.ts +322 -0
  55. package/src/diff/render.ts +101 -0
  56. package/src/model/payload.ts +154 -0
  57. package/src/model/tree.ts +99 -0
  58. package/src/model/volatile.ts +55 -0
  59. package/src/pipefy/agents.ts +165 -0
  60. package/src/pipefy/automations.ts +219 -0
  61. package/src/pipefy/capability.ts +119 -0
  62. package/src/pipefy/client.ts +267 -0
  63. package/src/pipefy/discovery.ts +209 -0
  64. package/src/pipefy/internal.ts +380 -0
  65. package/src/pipefy/ipaas.ts +365 -0
  66. package/src/pipefy/reconstruct.ts +775 -0
  67. package/src/pipefy/reference.ts +251 -0
  68. package/src/pipefy/snapshot.ts +245 -0
  69. package/src/pipefy/toolkit.ts +200 -0
  70. package/src/pipefy/toolkit_bearer.py +137 -0
  71. package/src/report/integrations.ts +231 -0
  72. package/src/report/run.ts +475 -0
  73. package/src/util/fsx.ts +45 -0
  74. package/src/util/git.ts +32 -0
  75. package/src/util/json.ts +55 -0
  76. package/src/util/log.ts +76 -0
  77. package/src/util/pool.ts +48 -0
  78. package/src/util/slug.ts +26 -0
  79. package/src/util/tui.ts +335 -0
  80. package/src/validate/index.ts +123 -0
  81. package/src/validate/integrity.ts +387 -0
  82. package/src/validate/reference.ts +136 -0
  83. package/src/validate/schema.ts +328 -0
  84. package/src/workspace/agents.ts +290 -0
  85. package/src/workspace/docs.ts +407 -0
  86. package/src/workspace/flows.ts +191 -0
  87. package/src/workspace/layout.ts +165 -0
  88. package/src/workspace/lock.ts +148 -0
  89. package/src/workspace/read.ts +165 -0
  90. package/src/workspace/reference.ts +24 -0
  91. package/src/workspace/stamp.ts +301 -0
  92. package/src/workspace/write.ts +225 -0
@@ -0,0 +1,301 @@
1
+ import { dim } from '../util/log.ts';
2
+ import { IdMap } from '../apply/idmap.ts';
3
+ import type { Row } from '../model/payload.ts';
4
+ import type { Tree } from '../model/tree.ts';
5
+
6
+ /**
7
+ * Stamp server-assigned ids onto entities the workspace still marks as new.
8
+ *
9
+ * After a *partial* apply the post-apply sync does not run — the files must keep
10
+ * the author's edits so `--resume` and a re-plan have something to work with.
11
+ * But the entities that did get created still read `"id": null, "_new": true`,
12
+ * so recompiling would create them a second time. That is the same duplication
13
+ * hazard the success path avoids by adopting live state, and a partial failure is
14
+ * exactly when someone re-runs.
15
+ *
16
+ * So: for every still-new row, find the entity that now exists on the pipe and
17
+ * adopt its id. Matching is uuid first — exact, and available for phases,
18
+ * fields, automations and labels — then the natural key, which is what the rest
19
+ * have. Nothing else about the row is touched, so an edit that has not been
20
+ * applied yet survives.
21
+ */
22
+
23
+ export type StampResult = {
24
+ /** Whether anything at all changed and the files need writing. */
25
+ changed?: boolean;
26
+ stamped: Array<{ entity: string; label: string; id: number }>;
27
+ /** Still new after matching: these genuinely were not created. */
28
+ unmatched: Array<{ entity: string; label: string }>;
29
+ /**
30
+ * Server-owned groups adopted from live state because the author had expressed
31
+ * no intent about them. Reported so the adoption is never silent.
32
+ */
33
+ refreshed: string[];
34
+ /**
35
+ * References rewritten from `%{_new:<uuid>}` to the real id.
36
+ *
37
+ * Adopting an id without this leaves every reference to it dangling, and the
38
+ * validator — correctly — refuses to plan. The placeholder was a promise to
39
+ * substitute once the id existed; the id now exists.
40
+ */
41
+ rewrittenReferences: number;
42
+ };
43
+
44
+ const isNew = (row: Row) => row['id'] === null || row['id'] === undefined || row['_new'] === true;
45
+
46
+ const adopt = (row: Row, live: Row) => {
47
+ /**
48
+ * The authored uuid is kept as tool metadata before being replaced.
49
+ *
50
+ * It is the only link between a reference written as `%{_new:<authored uuid>}`
51
+ * and the entity that now exists under a server-minted one. Discarding it
52
+ * without rewriting in the same pass strands every such reference permanently
53
+ * — which is exactly what happened once, and cost a hand edit to undo.
54
+ */
55
+ if (row['uuid'] && live['uuid'] && row['uuid'] !== live['uuid']) {
56
+ row['_authored_uuid'] = row['uuid'];
57
+ }
58
+ row['id'] = live['id'];
59
+ if (live['uuid']) row['uuid'] = live['uuid'];
60
+ if (live['slug']) row['slug'] = live['slug'];
61
+ delete row['_new'];
62
+
63
+ /**
64
+ * Fill in what the server filled in, and only that.
65
+ *
66
+ * An authored row states a label and a type; the pipe also holds `unique`,
67
+ * `card_synced`, `color_uuid` and the connector flags. Adopting the id without
68
+ * them leaves a row that the ordinary diff then reads as "set all of these to
69
+ * null" — a page of phantom changes on the next `pipe diff`. Keys the author
70
+ * did write are never touched, so an edit that has not been applied yet
71
+ * survives adoption.
72
+ */
73
+ for (const [k, v] of Object.entries(live)) {
74
+ if (k in row) continue;
75
+ if (k.startsWith('_')) continue;
76
+ row[k] = v;
77
+ }
78
+ };
79
+
80
+ /** uuid, else a natural key that does not include anything a create can get wrong. */
81
+ const keysOf = (row: Row, natural: (r: Row) => string): string[] => {
82
+ const keys: string[] = [];
83
+ if (row['uuid']) keys.push(`uuid:${String(row['uuid'])}`);
84
+ keys.push(`natural:${natural(row)}`);
85
+ return keys;
86
+ };
87
+
88
+ const matchInto = (
89
+ entity: string,
90
+ authored: Row[],
91
+ live: Row[],
92
+ natural: (r: Row) => string,
93
+ result: StampResult,
94
+ /** Aliases → id, so references to what was adopted can be rewritten. */
95
+ resolved?: IdMap,
96
+ ) => {
97
+ const byKey = new Map<string, Row>();
98
+ for (const l of live) {
99
+ for (const k of keysOf(l, natural)) if (!byKey.has(k)) byKey.set(k, l);
100
+ }
101
+ const claimed = new Set<Row>();
102
+
103
+ for (const row of authored) {
104
+ if (!isNew(row)) continue;
105
+ const label = String(row['name'] ?? row['label'] ?? entity);
106
+
107
+ let hit: Row | undefined;
108
+ for (const k of keysOf(row, natural)) {
109
+ const candidate = byKey.get(k);
110
+ if (candidate && !claimed.has(candidate)) {
111
+ hit = candidate;
112
+ break;
113
+ }
114
+ }
115
+
116
+ if (!hit) {
117
+ result.unmatched.push({ entity, label });
118
+ continue;
119
+ }
120
+ claimed.add(hit);
121
+ const authoredUuid = row['uuid'] ? String(row['uuid']) : null;
122
+ const authoredLabel = String(row['label'] ?? row['name'] ?? '');
123
+ adopt(row, hit);
124
+ const id = Number(hit['id']);
125
+ result.stamped.push({ entity, label, id });
126
+ result.changed = true;
127
+
128
+ if (resolved && Number.isFinite(id)) {
129
+ // Both spellings an author may have used to name this entity.
130
+ const aliases = [authoredUuid ? `_new:${authoredUuid}` : '', authoredLabel ? `_new:${authoredLabel}` : ''].filter(Boolean);
131
+ for (const a of aliases) resolved.resolvePlaceholder(a, id);
132
+ }
133
+ }
134
+ };
135
+
136
+ /**
137
+ * Groups the server owns outright, refreshed from live state whenever the author
138
+ * did not touch them.
139
+ *
140
+ * Creating a pipe's start form mints a `public_forms` row and a `visibilities`
141
+ * row that nobody asked for. After a partial apply the files are deliberately
142
+ * left as written, so those rows exist in the new baseline and not in the
143
+ * workspace — and the next diff reads that as *delete the public form*, marked
144
+ * destructive. The author never expressed an opinion about either row, so the
145
+ * honest resolution is to adopt what the server did.
146
+ *
147
+ * "Did not touch" is decided against the previous baseline, so an author who
148
+ * genuinely edited the public form keeps their edit.
149
+ */
150
+ const refreshUntouched = (authored: Tree, live: Tree, previous: Tree | null, result: StampResult) => {
151
+ const same = (a: unknown, b: unknown) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
152
+
153
+ const singletons: Array<'public_form' | 'preferences'> = ['public_form', 'preferences'];
154
+ for (const key of singletons) {
155
+ if (same(authored[key], live[key])) continue;
156
+ // With no previous baseline there is no way to tell an edit from a drift, so
157
+ // nothing is touched: a false negative here costs a phantom diff, a false
158
+ // positive silently discards someone's edit.
159
+ if (!previous || !same(authored[key], previous[key])) continue;
160
+ authored[key] = live[key];
161
+ result.refreshed.push(key);
162
+ result.changed = true;
163
+ }
164
+
165
+ // Read-only groups are refreshed unconditionally: there is no edit to lose,
166
+ // because an edit to them is refused at plan time by path alone.
167
+ for (const key of ['email_templates', 'email_inboxes', 'visibilities'] as const) {
168
+ if (same(authored.readonly[key], live.readonly[key])) continue;
169
+ authored.readonly[key] = live.readonly[key];
170
+ result.refreshed.push(`_readonly/${key}`);
171
+ result.changed = true;
172
+ }
173
+ };
174
+
175
+ export const stampCreated = (authored: Tree, live: Tree, previous: Tree | null = null): StampResult => {
176
+ const result: StampResult = { stamped: [], unmatched: [], refreshed: [], rewrittenReferences: 0, changed: false };
177
+ const resolved = new IdMap();
178
+
179
+ matchInto('phases', authored.phases, live.phases, (r) => String(r['name'] ?? ''), result, resolved);
180
+
181
+ /**
182
+ * Fields are matched within their phase where the phase is known, and across
183
+ * the whole pipe otherwise: a field created inside a brand-new phase has a
184
+ * null phase_id in the file it was authored in.
185
+ */
186
+ const liveFields = live.phases.flatMap((p) => p.fields.map((f) => ({ ...f, _phase_name: p['name'] })));
187
+ for (const phase of authored.phases) {
188
+ matchInto(
189
+ 'fields',
190
+ phase.fields,
191
+ liveFields,
192
+ (r) => String(r['label'] ?? ''),
193
+ result,
194
+ resolved,
195
+ );
196
+ }
197
+
198
+ matchInto('labels', authored.labels, live.labels, (r) => String(r['name'] ?? ''), result, resolved);
199
+ matchInto('webhooks', authored.webhooks, live.webhooks, (r) => `${String(r['name'] ?? '')}::${String(r['url'] ?? '')}`, result, resolved);
200
+ matchInto(
201
+ 'pipe_relations',
202
+ authored.pipe_relations,
203
+ live.pipe_relations,
204
+ (r) => `${String(r['parent_id'] ?? '')}::${String(r['child_id'] ?? '')}::${String(r['name'] ?? '')}`,
205
+ result,
206
+ resolved,
207
+ );
208
+ matchInto('automations', authored.automations, live.automations, (r) => String(r['name'] ?? ''), result, resolved);
209
+ matchInto('field_conditions', authored.field_conditions, live.field_conditions, (r) => String(r['name'] ?? ''), result, resolved);
210
+
211
+ refreshUntouched(authored, live, previous, result);
212
+
213
+ /**
214
+ * The resolution map is built from everything that exists on the pipe, not
215
+ * only from what this pass stamped.
216
+ *
217
+ * A placeholder can outlive the adoption of the thing it names: adopt a field's
218
+ * id in one run and the automation referring to it by `%{_new:<uuid>}` is still
219
+ * dangling in the next, with nothing left to match it against. Resolving
220
+ * against live state fixes it whenever it is noticed.
221
+ */
222
+ for (const p of authored.phases) {
223
+ for (const f of p.fields) {
224
+ const fid = Number(f['id']);
225
+ if (Number.isFinite(fid) && f['_authored_uuid']) {
226
+ resolved.resolvePlaceholder(`_new:${String(f['_authored_uuid'])}`, fid);
227
+ }
228
+ }
229
+ const pid = Number(p['id']);
230
+ if (Number.isFinite(pid) && p['_authored_uuid']) {
231
+ resolved.resolvePlaceholder(`_new:${String(p['_authored_uuid'])}`, pid);
232
+ }
233
+ }
234
+
235
+ for (const p of live.phases) {
236
+ const id = Number(p['id']);
237
+ if (!Number.isFinite(id)) continue;
238
+ if (p['uuid']) resolved.resolvePlaceholder(`_new:${String(p['uuid'])}`, id);
239
+ if (p['name']) resolved.resolvePlaceholder(`_new:${String(p['name'])}`, id);
240
+ for (const f of p.fields) {
241
+ const fid = Number(f['id']);
242
+ if (!Number.isFinite(fid)) continue;
243
+ if (f['uuid']) resolved.resolvePlaceholder(`_new:${String(f['uuid'])}`, fid);
244
+ if (f['label']) resolved.resolvePlaceholder(`_new:${String(f['label'])}`, fid);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Rewrite every reference to something that now exists. The IdMap does the
250
+ * deep walk — the same one the executor uses at send time — so an automation's
251
+ * `field_maps`, a condition's `field_address` and an `action_params` blob are
252
+ * all covered by one pass.
253
+ */
254
+ const before = JSON.stringify({
255
+ phases: authored.phases,
256
+ automations: authored.automations,
257
+ field_conditions: authored.field_conditions,
258
+ });
259
+ const rewritten = resolved.rewrite({
260
+ phases: authored.phases,
261
+ automations: authored.automations,
262
+ field_conditions: authored.field_conditions,
263
+ });
264
+ if (JSON.stringify(rewritten) !== before) {
265
+ authored.phases = rewritten.phases;
266
+ authored.automations = rewritten.automations;
267
+ authored.field_conditions = rewritten.field_conditions;
268
+ result.rewrittenReferences = [...before.matchAll(/%{_new:/g)].length;
269
+ result.changed = true;
270
+ }
271
+
272
+ return result;
273
+ };
274
+
275
+ export const renderStamp = (r: StampResult): string[] => {
276
+ const lines: string[] = [];
277
+ if (r.stamped.length) {
278
+ lines.push(
279
+ `adopted ${r.stamped.length} server-assigned id${r.stamped.length === 1 ? '' : 's'} into the workspace, ` +
280
+ `so a re-plan will not create them again`,
281
+ );
282
+ for (const s of r.stamped.slice(0, 8)) lines.push(dim(` ${s.entity} ${s.label} → ${s.id}`));
283
+ if (r.stamped.length > 8) lines.push(dim(` …and ${r.stamped.length - 8} more`));
284
+ }
285
+ if (r.rewrittenReferences) {
286
+ lines.push(
287
+ `resolved ${r.rewrittenReferences} placeholder reference${r.rewrittenReferences === 1 ? '' : 's'} to those ids`,
288
+ );
289
+ }
290
+ if (r.refreshed.length) {
291
+ lines.push(
292
+ `adopted live state for ${r.refreshed.length} group${r.refreshed.length === 1 ? '' : 's'} ` +
293
+ `the workspace never edited: ${r.refreshed.join(', ')}`,
294
+ );
295
+ }
296
+ if (r.unmatched.length) {
297
+ lines.push(`${r.unmatched.length} entit${r.unmatched.length === 1 ? 'y was' : 'ies were'} not created and remain new:`);
298
+ for (const u of r.unmatched.slice(0, 8)) lines.push(dim(` ${u.entity} ${u.label}`));
299
+ }
300
+ return lines;
301
+ };
@@ -0,0 +1,225 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { mkdirp, exists, writeJson } from '../util/fsx.ts';
4
+ import { indexed, slug, uniquify } from '../util/slug.ts';
5
+ import type { Row } from '../model/payload.ts';
6
+ import type { Tree } from '../model/tree.ts';
7
+ import { repoPaths } from './layout.ts';
8
+ import { AGENT_DIR_TAG } from './agents.ts';
9
+
10
+ /**
11
+ * tree → files. PLAN.md §4:
12
+ * - fields live inside their phase file, because that is what Claude edits
13
+ * most and they only make sense in phase context
14
+ * - conditions and field_maps fold into their owner's file, matching how the
15
+ * mutations already take them nested
16
+ * - `_readonly/` is first-class so the planner can reject edits by path alone
17
+ */
18
+
19
+ const clearDir = async (dir: string) => {
20
+ await mkdirp(dir);
21
+ for (const name of await fs.readdir(dir)) {
22
+ if (name.endsWith('.json')) await fs.rm(join(dir, name));
23
+ }
24
+ };
25
+
26
+ export type WrittenFiles = { path: string; kind: string }[];
27
+
28
+ export const writeTree = async (dir: string, tree: Tree): Promise<WrittenFiles> => {
29
+ const p = repoPaths(dir, tree.meta.repoKind);
30
+ const written: WrittenFiles = [];
31
+ const put = async (path: string, value: unknown, kind: string) => {
32
+ await writeJson(path, value);
33
+ written.push({ path, kind });
34
+ };
35
+
36
+ await mkdirp(dir);
37
+
38
+ await put(p.meta, tree.meta, 'meta');
39
+ await put(p.repo, tree.repo, 'repo');
40
+
41
+ await clearDir(p.phasesDir);
42
+ const phaseName = uniquify();
43
+ for (const phase of tree.phases) {
44
+ const name = phaseName(indexed(Number(phase['index'] ?? 0), String(phase['name'] ?? '')));
45
+ await put(join(p.phasesDir, `${name}.json`), phase, 'phase');
46
+ }
47
+
48
+ /**
49
+ * A database gets no `automations/` or `field-conditions/` directory.
50
+ *
51
+ * Both are always empty for one — a database has no workflow to automate — and
52
+ * an empty directory is an invitation. Dropping a file into it would author a
53
+ * change that `pipe plan` can only refuse, so the honest thing is not to offer
54
+ * the slot. The arrays stay in the tree and round-trip as `[]`; it is only the
55
+ * directory that is absent.
56
+ */
57
+ const hasWorkflow = tree.meta.repoKind !== 'table';
58
+ if (hasWorkflow || tree.automations.length) {
59
+ await clearDir(p.automationsDir);
60
+
61
+ /**
62
+ * A behavior (an automation carrying `_agent_dir`, PLAN.md's agents/
63
+ * doc) is not written here — it goes under its agent's `behaviors/`
64
+ * instead, same file shape, different folder. Every agent directory that
65
+ * already exists gets its `behaviors/` cleared up front, same as
66
+ * `automations/` above, so a behavior deleted upstream (or reassigned to
67
+ * a different agent) does not leave a stale file behind.
68
+ */
69
+ const agentNames = (await exists(p.agentsDir)) ? await fs.readdir(p.agentsDir, { withFileTypes: true }) : [];
70
+ const agentDirs = agentNames.filter((e) => e.isDirectory()).map((e) => e.name);
71
+ for (const name of agentDirs) await clearDir(join(p.agentsDir, name, 'behaviors'));
72
+
73
+ const autoName = uniquify();
74
+ const behaviorNameByDir = new Map<string, ReturnType<typeof uniquify>>();
75
+ for (const a of tree.automations) {
76
+ const agentDir = a[AGENT_DIR_TAG] as string | undefined;
77
+ const { [AGENT_DIR_TAG]: _drop, ...row } = a as Row & { [AGENT_DIR_TAG]?: string };
78
+
79
+ if (agentDir) {
80
+ let behaviorName = behaviorNameByDir.get(agentDir);
81
+ if (!behaviorName) {
82
+ behaviorName = uniquify();
83
+ behaviorNameByDir.set(agentDir, behaviorName);
84
+ }
85
+ const dir = join(p.agentsDir, agentDir, 'behaviors');
86
+ await mkdirp(dir);
87
+ const name = behaviorName(slug(String(row['name'] ?? `behavior-${row['id']}`)));
88
+ await put(join(dir, `${name}.json`), row, 'behavior');
89
+ continue;
90
+ }
91
+
92
+ const name = autoName(slug(String(row['name'] ?? `automation-${row['id']}`)));
93
+ await put(join(p.automationsDir, `${name}.json`), row, 'automation');
94
+ }
95
+ }
96
+
97
+ if (hasWorkflow || tree.field_conditions.length) {
98
+ await clearDir(p.fieldConditionsDir);
99
+ const fcName = uniquify();
100
+ for (const fc of tree.field_conditions) {
101
+ const name = fcName(slug(String(fc['name'] ?? `condition-${fc['id']}`)));
102
+ await put(join(p.fieldConditionsDir, `${name}.json`), fc, 'field-condition');
103
+ }
104
+ }
105
+
106
+ await put(p.labels, tree.labels, 'labels');
107
+ await put(p.webhooks, tree.webhooks, 'webhooks');
108
+ await put(p.publicForm, tree.public_form, 'public-form');
109
+ await put(p.preferences, tree.preferences, 'preferences');
110
+
111
+ /**
112
+ * relations.json carries the writable `pipe_relations` plus a derived,
113
+ * read-only summary of where this pipe actually connects — which for the test
114
+ * pipe comes only from connector fields (PLAN.md §2.2). `_`-prefixed keys are
115
+ * tool metadata and are ignored on read.
116
+ */
117
+ const connectors = tree.phases
118
+ .flatMap((ph) => ph.fields.map((f) => ({ phase: ph['name'], field: f })))
119
+ .filter(({ field }) => field['type_id'] === 'connector')
120
+ .map(({ phase, field }) => ({
121
+ field_id: field['id'],
122
+ field_label: field['label'],
123
+ in_phase: phase,
124
+ connected_repo_id: field['connected_pipe_id'] ?? null,
125
+ can_create_connected_cards: field['can_create_connected_cards'] ?? null,
126
+ }));
127
+ await put(
128
+ p.relations,
129
+ {
130
+ pipe_relations: tree.pipe_relations,
131
+ _connector_fields: connectors,
132
+ _note:
133
+ 'Connector fields do not imply pipe_relations rows and pipeMapGraph does not ' +
134
+ 'derive edges from them (PLAN.md §2.2). _connector_fields is derived on pull ' +
135
+ 'and ignored on read — edit the connector field inside its phase file instead.',
136
+ },
137
+ 'relations',
138
+ );
139
+
140
+ await clearDir(p.readonlyDir);
141
+ const ro: Array<[string, Row[]]> = [
142
+ ['email-templates.json', tree.readonly.email_templates],
143
+ ['email-inboxes.json', tree.readonly.email_inboxes],
144
+ ['visibilities.json', tree.readonly.visibilities],
145
+ ['field-maps.json', tree.readonly.field_maps],
146
+ ];
147
+ for (const [name, rows] of ro) await put(join(p.readonlyDir, name), rows, 'readonly');
148
+
149
+ await writeReadmes(p, tree.meta.repoKind);
150
+ return written;
151
+ };
152
+
153
+ const writeReadmes = async (p: ReturnType<typeof repoPaths>, kind: 'pipe' | 'table' = 'pipe') => {
154
+ /**
155
+ * A database gets its own README, because its files are honest but not
156
+ * self-explanatory.
157
+ *
158
+ * Pipefy stores a database as a repo: its columns are fields on a start form,
159
+ * and the phases after it are record statuses. That is the real shape and the
160
+ * codec round-trips it exactly — but nothing about `phases/01--ativo.json`
161
+ * tells a reader it is a status with no write path rather than a workflow step.
162
+ * The old code refused to write these files at all for that reason; writing
163
+ * them with an explanation is strictly better than reporting a gap.
164
+ */
165
+ if (kind === 'table') {
166
+ await fs.writeFile(
167
+ join(p.dir, 'README.md'),
168
+ [
169
+ '# This is a database, not a pipe',
170
+ '',
171
+ 'Pipefy stores a database as a repo, so these files have the shape of a pipe.',
172
+ 'That shape is real — it is what the API returns and what it accepts back — but',
173
+ 'two parts of it mean something different here.',
174
+ '',
175
+ '| Path | What it actually is |',
176
+ '| --- | --- |',
177
+ '| `table.json` | the database row. `noun` is what one record is called |',
178
+ '| `phases/00--start-form.json` | **the columns of the database.** Its `fields[]` are what you edit |',
179
+ '| `phases/NN--*.json` (the rest) | **record statuses**, not workflow steps. No public write path |',
180
+ '',
181
+ '## What can be changed',
182
+ '',
183
+ 'The database row and its columns. A column is created with',
184
+ '`createTableField`, changed with `updateTableField` and removed with',
185
+ '`deleteTableField` — all of which address a field by its **slug**, unlike the',
186
+ 'pipe mutations. Order is a separate `setTableFieldOrder` call, because',
187
+ '`createTableField` has no `index` input.',
188
+ '',
189
+ '## What cannot',
190
+ '',
191
+ '- **Record statuses.** Readable, and absent from `UpdateTableInput`.',
192
+ '- **Records.** They are the data of this database, as cards are the data of a pipe.',
193
+ ' They are deliberately not pulled, so an apply can never rewrite them.',
194
+ '- **Automations, field conditions and phase jumps.** A database has no',
195
+ ' workflow. If you find yourself authoring one here, the change belongs on a',
196
+ ' pipe.',
197
+ '',
198
+ '`pipe plan` refuses each of these by name rather than sending it.',
199
+ '',
200
+ ].join('\n'),
201
+ 'utf8',
202
+ );
203
+ }
204
+ await fs.writeFile(
205
+ join(p.readonlyDir, 'README.md'),
206
+ [
207
+ '# Read-only entities',
208
+ '',
209
+ 'These have **no public write path** (PLAN.md §2). They are pulled so that',
210
+ 'Claude can read them and reason correctly, and any edit here is rejected by',
211
+ '`pipe validate` on the basis of the path alone.',
212
+ '',
213
+ '| File | Why read-only |',
214
+ '| --- | --- |',
215
+ '| `email-templates.json` | `EmailTemplate` is read-only; no Input types exist |',
216
+ '| `email-inboxes.json` | no mutation found |',
217
+ '| `visibilities.json` | only settable via `updatePipe.public_form`; `slug` is not settable |',
218
+ '| `field-maps.json` | a field’s own field_maps (`owner_type` other than `Automation`) — a connector ' +
219
+ 'field’s "copy this value from the connected card" configuration. No write path has been ' +
220
+ 'measured; edit the connector field in the product instead |',
221
+ '',
222
+ ].join('\n'),
223
+ 'utf8',
224
+ );
225
+ };