@pipefy/pipefy-process-coder 0.1.0 → 0.1.2

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 (162) hide show
  1. package/.agents/skills/ppc-pipefy-flow-authoring/SKILL.md +262 -262
  2. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-danfe-consulta.json +247 -247
  3. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-webhook-retorno-consulta.json +589 -589
  4. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-4-recebimento-barramento.json +1391 -1391
  5. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-2-danfe-retorno.json +623 -623
  6. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-4-criacao-operacao.json +636 -636
  7. package/.agents/skills/ppc-pipefy-flow-authoring/examples/03-subflow-4-criacao-titulo.json +3642 -3642
  8. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-cedente.json +863 -863
  9. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-sacado.json +799 -799
  10. package/.agents/skills/ppc-pipefy-flow-authoring/examples/README.md +42 -42
  11. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-acompanhamento-cobranca.json +581 -581
  12. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-nfe-monitoramento.json +503 -503
  13. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-retorno-bancario.json +562 -562
  14. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-cedente.json +557 -557
  15. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-sacado.json +609 -609
  16. package/.agents/skills/ppc-pipefy-pipe-authoring/SKILL.md +146 -146
  17. package/.agents/skills/ppc-pipefy-process-design/SKILL.md +127 -127
  18. package/.agents/skills/ppc-pipefy-workspace/SKILL.md +91 -91
  19. package/AGENTS.md +476 -456
  20. package/LICENSE +202 -0
  21. package/NOTICE +11 -0
  22. package/README.md +830 -808
  23. package/bin/pipe.js +25 -4
  24. package/dist/apply/adopt.js +128 -0
  25. package/dist/apply/agentops.js +42 -0
  26. package/dist/apply/compile.js +722 -0
  27. package/dist/apply/execute.js +277 -0
  28. package/dist/apply/flowops.js +293 -0
  29. package/dist/apply/idmap.js +233 -0
  30. package/dist/apply/mutations.js +704 -0
  31. package/dist/apply/registry.js +348 -0
  32. package/dist/apply/types.js +18 -0
  33. package/dist/cli/args.js +79 -0
  34. package/dist/cli.js +196 -0
  35. package/dist/codec/flow.js +164 -0
  36. package/dist/codec/pack.js +88 -0
  37. package/dist/codec/roundtrip.js +86 -0
  38. package/dist/codec/unpack.js +181 -0
  39. package/dist/commands/agents.js +145 -0
  40. package/dist/commands/apply.js +982 -0
  41. package/dist/commands/context.js +79 -0
  42. package/dist/commands/create.js +63 -0
  43. package/dist/commands/diff.js +294 -0
  44. package/dist/commands/flows.js +348 -0
  45. package/dist/commands/misc.js +562 -0
  46. package/dist/commands/plan.js +301 -0
  47. package/dist/commands/pull.js +549 -0
  48. package/dist/commands/runs.js +74 -0
  49. package/dist/commands/skills.js +131 -0
  50. package/dist/commands/verify.js +205 -0
  51. package/dist/config.js +125 -0
  52. package/dist/diff/agents.js +80 -0
  53. package/dist/diff/diff.js +983 -0
  54. package/dist/diff/flow.js +258 -0
  55. package/{src/diff/html.ts → dist/diff/html.js} +274 -322
  56. package/dist/diff/render.js +90 -0
  57. package/dist/model/payload.js +84 -0
  58. package/dist/model/tree.js +7 -0
  59. package/dist/model/volatile.js +54 -0
  60. package/dist/pipefy/agents.js +131 -0
  61. package/dist/pipefy/automations.js +155 -0
  62. package/dist/pipefy/capability.js +84 -0
  63. package/dist/pipefy/client.js +204 -0
  64. package/dist/pipefy/discovery.js +148 -0
  65. package/dist/pipefy/internal.js +406 -0
  66. package/dist/pipefy/ipaas.js +269 -0
  67. package/dist/pipefy/reconstruct.js +676 -0
  68. package/dist/pipefy/reference.js +172 -0
  69. package/dist/pipefy/snapshot.js +185 -0
  70. package/dist/pipefy/toolkit.js +174 -0
  71. package/{src → dist}/pipefy/toolkit_bearer.py +60 -14
  72. package/dist/report/integrations.js +165 -0
  73. package/dist/report/run.js +356 -0
  74. package/dist/util/fsx.js +50 -0
  75. package/dist/util/git.js +25 -0
  76. package/dist/util/json.js +56 -0
  77. package/dist/util/log.js +69 -0
  78. package/dist/util/pool.js +42 -0
  79. package/dist/util/slug.js +23 -0
  80. package/dist/util/tui.js +287 -0
  81. package/dist/validate/index.js +86 -0
  82. package/dist/validate/integrity.js +370 -0
  83. package/dist/validate/reference.js +120 -0
  84. package/dist/validate/schema.js +278 -0
  85. package/dist/version.js +18 -0
  86. package/dist/workspace/agents.js +245 -0
  87. package/{src/workspace/docs.ts → dist/workspace/docs.js} +367 -407
  88. package/dist/workspace/flows.js +149 -0
  89. package/dist/workspace/layout.js +146 -0
  90. package/dist/workspace/lock.js +83 -0
  91. package/dist/workspace/read.js +168 -0
  92. package/dist/workspace/reference.js +24 -0
  93. package/dist/workspace/stamp.js +234 -0
  94. package/dist/workspace/write.js +194 -0
  95. package/package.json +38 -35
  96. package/src/apply/adopt.ts +0 -150
  97. package/src/apply/agentops.ts +0 -71
  98. package/src/apply/compile.ts +0 -875
  99. package/src/apply/execute.ts +0 -336
  100. package/src/apply/flowops.ts +0 -399
  101. package/src/apply/idmap.ts +0 -241
  102. package/src/apply/mutations.ts +0 -955
  103. package/src/apply/registry.ts +0 -430
  104. package/src/apply/types.ts +0 -134
  105. package/src/cli/args.ts +0 -88
  106. package/src/cli.ts +0 -211
  107. package/src/codec/flow.ts +0 -199
  108. package/src/codec/pack.ts +0 -103
  109. package/src/codec/roundtrip.ts +0 -94
  110. package/src/codec/unpack.ts +0 -198
  111. package/src/commands/agents.ts +0 -184
  112. package/src/commands/apply.ts +0 -1144
  113. package/src/commands/context.ts +0 -119
  114. package/src/commands/create.ts +0 -79
  115. package/src/commands/diff.ts +0 -314
  116. package/src/commands/flows.ts +0 -414
  117. package/src/commands/misc.ts +0 -644
  118. package/src/commands/plan.ts +0 -331
  119. package/src/commands/pull.ts +0 -567
  120. package/src/commands/runs.ts +0 -83
  121. package/src/commands/skills.ts +0 -137
  122. package/src/commands/verify.ts +0 -253
  123. package/src/config.ts +0 -168
  124. package/src/diff/agents.ts +0 -122
  125. package/src/diff/diff.ts +0 -1130
  126. package/src/diff/flow.ts +0 -318
  127. package/src/diff/render.ts +0 -101
  128. package/src/model/payload.ts +0 -154
  129. package/src/model/tree.ts +0 -99
  130. package/src/model/volatile.ts +0 -55
  131. package/src/pipefy/agents.ts +0 -165
  132. package/src/pipefy/automations.ts +0 -219
  133. package/src/pipefy/capability.ts +0 -119
  134. package/src/pipefy/client.ts +0 -267
  135. package/src/pipefy/discovery.ts +0 -209
  136. package/src/pipefy/internal.ts +0 -380
  137. package/src/pipefy/ipaas.ts +0 -365
  138. package/src/pipefy/reconstruct.ts +0 -775
  139. package/src/pipefy/reference.ts +0 -251
  140. package/src/pipefy/snapshot.ts +0 -245
  141. package/src/pipefy/toolkit.ts +0 -200
  142. package/src/report/integrations.ts +0 -231
  143. package/src/report/run.ts +0 -475
  144. package/src/util/fsx.ts +0 -45
  145. package/src/util/git.ts +0 -32
  146. package/src/util/json.ts +0 -55
  147. package/src/util/log.ts +0 -76
  148. package/src/util/pool.ts +0 -48
  149. package/src/util/slug.ts +0 -26
  150. package/src/util/tui.ts +0 -335
  151. package/src/validate/index.ts +0 -123
  152. package/src/validate/integrity.ts +0 -387
  153. package/src/validate/reference.ts +0 -136
  154. package/src/validate/schema.ts +0 -328
  155. package/src/workspace/agents.ts +0 -290
  156. package/src/workspace/flows.ts +0 -191
  157. package/src/workspace/layout.ts +0 -165
  158. package/src/workspace/lock.ts +0 -148
  159. package/src/workspace/read.ts +0 -165
  160. package/src/workspace/reference.ts +0 -24
  161. package/src/workspace/stamp.ts +0 -301
  162. package/src/workspace/write.ts +0 -225
@@ -1,407 +1,367 @@
1
- import { join } from 'node:path';
2
- import { rm } from 'node:fs/promises';
3
- import { writeFile, exists } from '../util/fsx.ts';
4
- import type { Tree } from '../model/tree.ts';
5
- import type { Capabilities } from '../pipefy/capability.ts';
6
- import { registryRows } from '../apply/registry.ts';
7
- import type { Lock } from './lock.ts';
8
- import { paths } from './layout.ts';
9
- import { renderReferenceMd, type Reference } from '../pipefy/reference.ts';
10
-
11
- /**
12
- * The generated docs that are the whole interface for whoever edits this
13
- * workspace — PLAN.md §6. No agent baked into the app: the FDE pulls, edits
14
- * with an agent in a terminal, and publishes. These files are what make that
15
- * work unattended, regardless of which agent is reading them.
16
- */
17
-
18
- const AGENTS_MD = (lock: Lock, trees: Tree[]) => {
19
- const reconstructed = lock.repos.filter((r) => r.source === 'reconstructed');
20
- const unknownGroups = new Set<string>();
21
- for (const r of lock.repos) {
22
- for (const [name, cov] of Object.entries(r.coverage)) if (cov === 'unknown') unknownGroups.add(name);
23
- }
24
-
25
- return `# Editing this Pipefy workspace
26
-
27
- This directory is a Pipefy pipe (and everything it connects to) pulled to disk as
28
- JSON. Edit the JSON, then let \`pipe\` validate, plan and apply it. The rules below
29
- are not style preferences — breaking one either corrupts a live pipe or makes the
30
- apply refuse.
31
-
32
- ## The loop
33
-
34
- \`\`\`bash
35
- pipe validate # JSON shape + referential integrity. Instant, offline.
36
- pipe diff # what you changed, against the last pull
37
- pipe plan --dry-run # is every change actually applicable? refuses if not
38
- pipe apply # snapshot, apply, verify
39
- \`\`\`
40
-
41
- **\`pipe validate && pipe plan --dry-run\` must both be clean before you propose an
42
- apply.** Iterate against them yourself; they need no network for validate and no
43
- writes for plan.
44
-
45
- ## Identity rules
46
-
47
- - \`id\` is **server-owned**. Never invent one, never edit one, never reuse one.
48
- - A **new** entity is \`"id": null\` plus \`"_new": true\`. For \`phases\`, \`fields\`,
49
- \`automations\` and \`labels\` — the only four arrays that have one — also add a
50
- fresh v4 \`uuid\`. Everything else is identified by its owner.
51
- - To **delete**, remove the object (or the whole file). Do not set a tombstone.
52
- - \`_\`-prefixed keys are tool metadata. Leave them alone; they are ignored by
53
- every comparator and never sent to the API.
54
-
55
- ## What cannot be changed at all
56
-
57
- | Edit | Why it is refused |
58
- | --- | --- |
59
- | a field's \`type_id\` | \`updatePhaseField\` has no \`type_id\`. Doing it as delete+create destroys every existing value. |
60
- | a field's \`phase_id\` | \`updatePhaseField\` has no \`phase_id\`. Same data loss. |
61
- | a phase's \`index\` (reordering) | \`createPhase\` takes an index, \`updatePhase\` does not. |
62
- | anything under \`_readonly/\` | no public write path exists. Read it, reason with it, don't edit it. |
63
- | anything under \`_non-snapshot/\` | not part of the structure payload; never written back. |
64
- | the start-form phase's existence, \`index\`, or \`name\` | it is a real phase at index 0; deleting or reindexing it is catastrophic, and its name is referenced by other things (the public form, automations, a client's own docs) this tool cannot check for breakage. |
65
-
66
- If a request needs one of these, say so plainly and stop — do not work around it
67
- with a delete and a create.
68
-
69
- ## Field references
70
-
71
- Automations and conditions refer to fields by numeric id inside opaque JSON:
72
- \`"recipients": "%{<fieldId>}"\`, \`"field_address": "433255493"\`,
73
- \`"trigger_field_ids": ["433255544"]\`. \`MAPPING.md\` translates every one of those
74
- numbers into a label and a phase.
75
-
76
- - **Deleting a field obliges you to fix every reference to it.** \`pipe validate\`
77
- fails on a dangling reference, by design.
78
- - Referencing a field you are creating in the same edit is fine: leave the
79
- placeholder \`%{_new:<uuid>}\` and the applier substitutes the real id after the
80
- field exists.
81
-
82
- ## Flows reference fields too, and \`pipe validate\` does not check them
83
-
84
- If this workspace has any \`flows/\` directories, they are iPaaS flows (Advanced
85
- Automations) — the same entities as the pipe, pulled, diffed, planned and
86
- applied by the same \`pipe pull/diff/plan/apply\` commands (\`--no-flows\` opts
87
- out). A flow's datapills address a field by **slug**, inside opaque strings:
88
- \`{{step_1['output'].data.card.fields.<slug>.value}}\`.
89
-
90
- - **\`pipe validate\` never looks inside a flow.** Renaming, retyping or deleting
91
- a field is checked against automations and conditions, never against flow
92
- datapills — a broken reference there is silent until the flow runs. Before
93
- editing a field a flow might use, grep \`flows/\` for its slug yourself.
94
- - A field's slug does **not** change when its label is renamed (measured;
95
- \`VALIDATION.md\` §3g), so a label-only rename is safe for existing datapills.
96
- Anything that changes or removes the slug is not.
97
- - **Adding a node to an existing flow works if the node is reachable from the
98
- trigger** — some other node's \`_nextAction\`, \`_firstLoopAction\` or a
99
- branch's \`_children\` must point at it, which is how the diff finds the
100
- parent to send \`ADD_ACTION\` after. An orphan node nothing points to is
101
- still refused, by name, rather than guessed at.
102
- - Enabling a flow arms it against live traffic and is refused unless you pass
103
- \`--allow-flow-enable\`, the same shape as \`--allow-card-moves\`.
104
- - Load the \`ppc-pipefy-flow-authoring\` skill before writing or debugging a flow by
105
- hand — it covers node shapes, datapill syntax and the traps that validate
106
- clean and resolve nothing at runtime.
107
-
108
- ## Values that come from a closed set
109
-
110
- \`REFERENCE.md\` in this workspace lists what the API accepts in every position
111
- where a value must come from a fixed list: each automation event's
112
- \`event_params\`, each action's \`action_params\`, which events and actions cannot be
113
- paired, and the field \`type_id\`, \`color\` and \`filter\` sets. It was read from the
114
- API on the last pull.
115
-
116
- **Read it before writing an automation.** The schema publishes these lists and
117
- then types the inputs that consume them as \`ID\` and \`String\`, so a wrong value is
118
- not caught by GraphQL. It reaches a runtime check and comes back as \`is invalid\`
119
- or \`All fields must be filled properly.\` — naming no parameter, no event, and no
120
- accepted value, partway through an apply.
121
-
122
- The traps are near-misses, not nonsense. \`in_phase_id\` is a real parameter that
123
- belongs to \`card_inbox_received_email\`; \`card_moved\` takes only \`to_phase_id\`.
124
- \`card_id\` is real on \`update_card_field\` and refused on \`move_single_card\`.
125
-
126
- \`pipe validate\` checks all of this offline and names the parameter, so a wrong
127
- value costs a second rather than a half-applied change. It is a backstop, not a
128
- substitute for reading the file.
129
-
130
- ## Where things live
131
-
132
- \`\`\`
133
- pipes/<id>--<name>/
134
- pipe.json the repo row
135
- phases/NN--name.json phase + its fields[] + jump_target_ids[] + team_member_ids[]
136
- automations/<name>.json automation + condition + field_maps + response_schemas
137
- field-conditions/<name>.json condition + actions
138
- labels.json webhooks.json public-form.json preferences.json relations.json
139
- _readonly/ _non-snapshot/ _meta.json
140
- databases/<id>--<apiId>--<name>/
141
- table.json the database row
142
- phases/00--start-form.json the database's COLUMNS live here, as fields[]
143
- phases/NN--*.json record statuses — readable, no write path
144
- README.md what each of those files actually is
145
- \`\`\`
146
-
147
- Fields live **inside their phase file** — that is where they make sense and where
148
- you will edit them most. Ordering comes from the \`index\` value inside the file,
149
- never from the filename, so renumbering works without renaming anything.
150
-
151
- ${
152
- reconstructed.length
153
- ? `## This workspace was read without snapshots
154
-
155
- ${reconstructed.length} of ${lock.repos.length} repo(s) were reconstructed from the
156
- GraphQL and internal APIs rather than from a snapshot payload. Consequences you
157
- must respect:
158
-
159
- ${[...unknownGroups].sort().map((g) => `- \`${g}\` is **unknown**, not empty. Do not add to it and do not conclude the pipe has none.`).join('\n')}
160
-
161
- An \`unknown\` entity group is never diffed and never applied. \`_meta.json\` in each
162
- repo directory records exactly what was and was not read.
163
- `
164
- : `## This workspace was read from snapshots
165
-
166
- Every entity group came from the snapshot payload, so absence means absence.
167
- `
168
- }
169
- ## Secrets
170
-
171
- Webhook URLs are bearer-equivalent — possession is authorization — and
172
- HTTP-request automations can carry live credentials in \`action_params\`. This
173
- workspace has a \`.gitignore\`, but do not paste these files into anything, and do
174
- not commit the workspace.
175
-
176
- ## Runs
177
-
178
- Pass \`--report\` to any of pull, apply, dry run or verify and it writes a folder
179
- under \`runs/\` recording what happened:
180
-
181
- \`\`\`
182
- runs/index.md one line per run, oldest first
183
- runs/<when>--<kind>/report.md what happened, readable, no credentials
184
- runs/<when>--<kind>/run.json the same thing structured
185
- runs/<when>--<kind>/pipe.json the payload as of the end of that run
186
- runs/<when>--<kind>/integrations.json webhooks, relations, cross-repo automations
187
- \`\`\`
188
-
189
- An apply also writes \`pipe.before.json\`, so both sides of the change sit in one
190
- folder. Read \`report.md\` first — it is the only file in there written for a
191
- human.
192
-
193
- It is off by default: a run folder holds a full copy of the pipe, credentials
194
- included, and one per command would grow without bound. The record an apply
195
- needs in order to resume is the plan file under \`.ppc/plans/\`, which is always
196
- written.
197
-
198
- ${trees.length ? `## This workspace\n\n${trees.map((t) => `- ${t.repo['name']} (${t.meta.repoKind} ${t.repo['id']}) — ${t.phases.length} phases, ${t.phases.reduce((n, p) => n + p.fields.length, 0)} fields, ${t.automations.length} automations, read from ${t.meta.source}`).join('\n')}` : ''}
199
- `;
200
- };
201
-
202
- const MAPPING_MD = (trees: Tree[], lock: Lock) => {
203
- const sections = trees.map((t) => {
204
- const fields = t.phases.flatMap((p) => p.fields.map((f) => ({ f, phase: String(p['name'] ?? '') })));
205
- const fieldRows = fields
206
- .map(({ f, phase }) => `| \`${f['id']}\` | ${f['label']} | \`${f['slug'] ?? ''}\` | ${f['type_id']} | ${phase} |`)
207
- .join('\n');
208
-
209
- const phaseRows = t.phases
210
- .map(
211
- (p) =>
212
- `| ${p['index']} | \`${p['id']}\` | ${p['name']}${Number(p['index']) === 0 ? ' **(start form)**' : ''} | ${
213
- p.fields.length
214
- } | ${p.jump_target_ids.length ? p.jump_target_ids.map((j) => `\`${j}\``).join(', ') : '—'} |`,
215
- )
216
- .join('\n');
217
-
218
- const autoRows = t.automations
219
- .map(
220
- (a) =>
221
- `| \`${a['id']}\` | ${a['name']} | ${a['event_id']} → ${a['action_id']} | ${
222
- a['action_repo_id'] !== a['event_repo_id'] ? `cross-repo → \`${a['action_repo_id']}\`` : 'same repo'
223
- } | ${a.condition ? 'yes' : '—'} |`,
224
- )
225
- .join('\n');
226
-
227
- const connectors = fields
228
- .filter(({ f }) => f['type_id'] === 'connector')
229
- .map(({ f, phase }) => `| \`${f['id']}\` | ${f['label']} | ${phase} | \`${f['connected_pipe_id']}\` |`)
230
- .join('\n');
231
-
232
- return `## ${t.repo['name']} — ${t.meta.repoKind} \`${t.repo['id']}\`
233
-
234
- ### Phases
235
-
236
- | index | id | name | fields | jumps to |
237
- | --- | --- | --- | --- | --- |
238
- ${phaseRows}
239
-
240
- ### Fields
241
-
242
- | id | label | slug | type | phase |
243
- | --- | --- | --- | --- | --- |
244
- ${fieldRows}
245
-
246
- ### Automations
247
-
248
- | id | name | event → action | scope | condition |
249
- | --- | --- | --- | --- | --- |
250
- ${autoRows || '| — | none | | | |'}
251
-
252
- ${connectors ? `### Connector fields\n\n| field id | label | phase | connected repo |\n| --- | --- | --- | --- |\n${connectors}\n` : ''}`;
253
- });
254
-
255
- /**
256
- * Edges name what they point at, not just its id.
257
- *
258
- * `301908697` on its own tells a reader nothing — and a connector field aimed
259
- * at a database means something different from one aimed at a pipe: the target
260
- * has columns and records rather than phases and cards. The repo is looked up
261
- * in the lock, so a target outside the workspace still shows as unknown rather
262
- * than being guessed at.
263
- */
264
- const byId = new Map(lock.repos.map((r) => [String(r.id), r]));
265
- const edgeRows = lock.edges
266
- .map((e) => {
267
- const to = byId.get(String(e.to));
268
- const target = to ? `${to.kind} · ${to.name}` : 'not in this workspace';
269
- return `| \`${e.from}\` | \`${e.to}\` | ${target} | ${e.kind} | ${e.label ?? ''} |`;
270
- })
271
- .join('\n');
272
-
273
- return `# Mapping
274
-
275
- Generated on pull. This is what makes \`%{<fieldId>}\` mean "Requester email".
276
-
277
- ${sections.join('\n\n')}
278
-
279
- ## Connections
280
-
281
- Discovered as the union of \`pipe_relations\`, connector fields, and cross-repo
282
- automations — connector fields alone find edges that neither \`pipe_relations\` nor
283
- \`pipeMapGraph\` reports (PLAN.md §2.2).
284
-
285
- | from | to | what it is | via | label |
286
- | --- | --- | --- | --- | --- |
287
- ${edgeRows || '| — | — | — | — | — |'}
288
- `;
289
- };
290
-
291
- const CAPABILITIES_MD = (caps: Capabilities | null) => {
292
- const rows = registryRows()
293
- .sort((a, b) => a.key.localeCompare(b.key))
294
- .map(
295
- (r) =>
296
- `| \`${r.entity}\` | ${r.op} | ${
297
- r.status === 'SUPPORTED' ? 'yes' : r.status === 'PARTIAL' ? 'partly' : '**no**'
298
- } | ${r.via ? `\`${r.via}\`` : '—'} | ${r.reason ?? ''}${r.ask ? ` _(ask ${r.ask})_` : ''} |`,
299
- )
300
- .join('\n');
301
-
302
- const detected = caps
303
- ? `## Detected on this endpoint
304
-
305
- | Capability | Present | Consequence if absent |
306
- | --- | --- | --- |
307
- | snapshots (\`createRepoSnapshot\`) | ${caps.snapshots ? 'yes' : '**no**'} | pulls reconstruct the structure over GraphQL instead |
308
- | \`restoreRepoSnapshot\` | ${caps.restoreRepoSnapshot ? 'yes' : '**no**'} | no atomic rollback; a failed apply is repaired forward |
309
- | \`createRepoDraft\` | ${caps.createRepoDraft ? 'yes' : '**no**'} | no free rehearsal environment; plans are checked, not proven |
310
- | \`importRepoSnapshot\` | ${caps.importRepoSnapshot ? 'yes' : 'no'} | Route B is unavailable; writes go through the mutation compiler |
311
- | internal API | ${caps.internalApi} | automation detail and phase-jump editing depend on it |
312
-
313
- ${caps.internalApi === 'unavailable' ? '> The internal endpoint did not answer. Automations and phase jumps are reported as **unknown** rather than empty, and any change to them is refused rather than silently skipped.\n' : ''}`
314
- : '';
315
-
316
- return `# Capabilities
317
-
318
- Generated from the live registry in \`src/apply/registry.ts\`, so what you read
319
- here is what the applier will actually attempt. If something says **no**, propose
320
- a different change rather than a workaround.
321
-
322
- ${detected}
323
- ## Write coverage
324
-
325
- | Entity | Operation | Writable | Via | Notes |
326
- | --- | --- | --- | --- | --- |
327
- ${rows}
328
-
329
- ## Flows (iPaaS), separately from the table above
330
-
331
- Flows compile through \`src/apply/flowops.ts\`, not the registry above — they are
332
- still applied by the same \`pipe apply\` (skip with \`--no-flows\`), just tracked
333
- here rather than in the write-coverage table.
334
-
335
- | Change | Writable | Via | Notes |
336
- | --- | --- | --- | --- |
337
- | create a flow | yes | \`createFlow\` (whole tree) | built trigger-first, then each node in execution order |
338
- | delete a flow | yes | \`deleteFlow\` | destructive |
339
- | rename a flow | yes | \`CHANGE_NAME\` | |
340
- | update the trigger | yes | \`UPDATE_TRIGGER\` | whole node, not a sparse patch |
341
- | update an action | yes | \`UPDATE_ACTION\` | whole node; a router's branches live in \`settings.branches\`, so editing them is an \`UPDATE_ACTION\` on the router — there is no \`UPDATE_BRANCH\` |
342
- | delete an action | yes | \`DELETE_ACTION\` | destructive |
343
- | add a node **to an existing flow** | yes, if reachable from the trigger | \`ADD_ACTION\` | the parent is found by scanning the authored tree for whoever's \`_nextAction\` / \`_firstLoopAction\` / \`_children\` points at the new node. An orphan node nothing points to is refused by name |
344
- | enable a flow | partly | \`CHANGE_STATUS\` | arms it against live traffic — refused unless \`--allow-flow-enable\`, and refused outright while any connection it uses is still a placeholder |
345
- | disable a flow | yes | \`CHANGE_STATUS\` | |
346
- | a connection the flow needs but the project lacks | yes, as a placeholder | \`createConnection\` | created disabled with a placeholder credential so the structure can still be built; reconnect it for real before enabling |
347
- | whole-tree replace | **refused on purpose** | — | \`IMPORT_FLOW\` exists and is never used — it re-runs an old schema migration on an already-live tree and doubles every datapill (\`{{step_1['output'].x}}\` → \`{{step_1['output']['output'].x}}\`), resolving nothing at runtime while every validator still calls it valid |
348
-
349
- After any flow write, the applier reads the flow back and diffs its datapills
350
- against what was sent — the only check that catches the \`IMPORT_FLOW\`-shaped
351
- migration bug, and the reason a flow apply is not reported successful on the
352
- mutation's response alone.
353
-
354
- ## The three that hurt most
355
-
356
- 1. **Field type changes** — routine to ask for, impossible to do without
357
- destroying data. Refused.
358
- 2. **Moving a field between phases** — same.
359
- 3. **Reordering phases** — \`createPhase\` takes an index, \`updatePhase\` does not.
360
-
361
- These are asks 2, 3 and 4 in \`ROUTE-B-V2.md\`. Every refusal pipe emits is a data
362
- point for them.
363
- `;
364
- };
365
-
366
- const GITIGNORE = `# A pulled workspace contains webhook URLs (bearer-equivalent: possession is
367
- # authorization) and HTTP-request automation credentials. Never commit it.
368
- .ppc/
369
- *.gz
370
-
371
- # Run folders hold a full copy of the pipe (runs/*/pipe.json) and its integration
372
- # surface, credentials included. The report beside them is redacted and can be
373
- # shared by hand; the payloads cannot.
374
- runs/*/pipe*.json
375
- runs/*/integrations*.json
376
-
377
- # Uncomment to keep the structure out of git entirely.
378
- # pipes/
379
- # databases/
380
- # runs/
381
- `;
382
-
383
- export const writeDocs = async (
384
- root: string,
385
- lock: Lock,
386
- trees: Tree[],
387
- caps: Capabilities | null,
388
- ref: Reference | null = null,
389
- ) => {
390
- const p = paths(root);
391
- await writeFile(p.agentsMd, AGENTS_MD(lock, trees));
392
- await writeFile(p.mappingMd, MAPPING_MD(trees, lock));
393
- await writeFile(p.capabilitiesMd, CAPABILITIES_MD(caps));
394
- await writeFile(p.referenceMd, renderReferenceMd(ref));
395
- await writeFile(p.gitignore, GITIGNORE);
396
-
397
- /**
398
- * Migration cleanup: a workspace pulled before this rename has a stale
399
- * `CLAUDE.md` sitting beside the new `AGENTS.md`, still telling whoever
400
- * reads it to look here — remove it rather than leave a duplicate that
401
- * silently goes out of date the next time this file is regenerated.
402
- */
403
- const staleClaudeMd = join(root, 'CLAUDE.md');
404
- if (await exists(staleClaudeMd)) await rm(staleClaudeMd, { force: true });
405
-
406
- return [p.agentsMd, p.mappingMd, p.capabilitiesMd, p.referenceMd, p.gitignore];
407
- };
1
+ import { join } from 'node:path';
2
+ import { rm } from 'node:fs/promises';
3
+ import { writeFile, exists } from "../util/fsx.js";
4
+ import { registryRows } from "../apply/registry.js";
5
+ import { paths } from "./layout.js";
6
+ import { renderReferenceMd } from "../pipefy/reference.js";
7
+ /**
8
+ * The generated docs that are the whole interface for whoever edits this
9
+ * workspace — PLAN.md §6. No agent baked into the app: the FDE pulls, edits
10
+ * with an agent in a terminal, and publishes. These files are what make that
11
+ * work unattended, regardless of which agent is reading them.
12
+ */
13
+ const AGENTS_MD = (lock, trees) => {
14
+ const reconstructed = lock.repos.filter((r) => r.source === 'reconstructed');
15
+ const unknownGroups = new Set();
16
+ for (const r of lock.repos) {
17
+ for (const [name, cov] of Object.entries(r.coverage))
18
+ if (cov === 'unknown')
19
+ unknownGroups.add(name);
20
+ }
21
+ return `# Editing this Pipefy workspace
22
+
23
+ This directory is a Pipefy pipe (and everything it connects to) pulled to disk as
24
+ JSON. Edit the JSON, then let \`pipe\` validate, plan and apply it. The rules below
25
+ are not style preferences — breaking one either corrupts a live pipe or makes the
26
+ apply refuse.
27
+
28
+ ## The loop
29
+
30
+ \`\`\`bash
31
+ pipe validate # JSON shape + referential integrity. Instant, offline.
32
+ pipe diff # what you changed, against the last pull
33
+ pipe plan --dry-run # is every change actually applicable? refuses if not
34
+ pipe apply # snapshot, apply, verify
35
+ \`\`\`
36
+
37
+ **\`pipe validate && pipe plan --dry-run\` must both be clean before you propose an
38
+ apply.** Iterate against them yourself; they need no network for validate and no
39
+ writes for plan.
40
+
41
+ ## Identity rules
42
+
43
+ - \`id\` is **server-owned**. Never invent one, never edit one, never reuse one.
44
+ - A **new** entity is \`"id": null\` plus \`"_new": true\`. For \`phases\`, \`fields\`,
45
+ \`automations\` and \`labels\` — the only four arrays that have one — also add a
46
+ fresh v4 \`uuid\`. Everything else is identified by its owner.
47
+ - To **delete**, remove the object (or the whole file). Do not set a tombstone.
48
+ - \`_\`-prefixed keys are tool metadata. Leave them alone; they are ignored by
49
+ every comparator and never sent to the API.
50
+
51
+ ## What cannot be changed at all
52
+
53
+ | Edit | Why it is refused |
54
+ | --- | --- |
55
+ | a field's \`type_id\` | \`updatePhaseField\` has no \`type_id\`. Doing it as delete+create destroys every existing value. |
56
+ | a field's \`phase_id\` | \`updatePhaseField\` has no \`phase_id\`. Same data loss. |
57
+ | a phase's \`index\` (reordering) | \`createPhase\` takes an index, \`updatePhase\` does not. |
58
+ | anything under \`_readonly/\` | no public write path exists. Read it, reason with it, don't edit it. |
59
+ | anything under \`_non-snapshot/\` | not part of the structure payload; never written back. |
60
+ | the start-form phase's existence, \`index\`, or \`name\` | it is a real phase at index 0; deleting or reindexing it is catastrophic, and its name is referenced by other things (the public form, automations, a client's own docs) this tool cannot check for breakage. |
61
+
62
+ If a request needs one of these, say so plainly and stop — do not work around it
63
+ with a delete and a create.
64
+
65
+ ## Field references
66
+
67
+ Automations and conditions refer to fields by numeric id inside opaque JSON:
68
+ \`"recipients": "%{<fieldId>}"\`, \`"field_address": "433255493"\`,
69
+ \`"trigger_field_ids": ["433255544"]\`. \`MAPPING.md\` translates every one of those
70
+ numbers into a label and a phase.
71
+
72
+ - **Deleting a field obliges you to fix every reference to it.** \`pipe validate\`
73
+ fails on a dangling reference, by design.
74
+ - Referencing a field you are creating in the same edit is fine: leave the
75
+ placeholder \`%{_new:<uuid>}\` and the applier substitutes the real id after the
76
+ field exists.
77
+
78
+ ## Flows reference fields too, and \`pipe validate\` does not check them
79
+
80
+ If this workspace has any \`flows/\` directories, they are iPaaS flows (Advanced
81
+ Automations) — the same entities as the pipe, pulled, diffed, planned and
82
+ applied by the same \`pipe pull/diff/plan/apply\` commands (\`--no-flows\` opts
83
+ out). A flow's datapills address a field by **slug**, inside opaque strings:
84
+ \`{{step_1['output'].data.card.fields.<slug>.value}}\`.
85
+
86
+ - **\`pipe validate\` never looks inside a flow.** Renaming, retyping or deleting
87
+ a field is checked against automations and conditions, never against flow
88
+ datapills — a broken reference there is silent until the flow runs. Before
89
+ editing a field a flow might use, grep \`flows/\` for its slug yourself.
90
+ - A field's slug does **not** change when its label is renamed (measured;
91
+ \`VALIDATION.md\` §3g), so a label-only rename is safe for existing datapills.
92
+ Anything that changes or removes the slug is not.
93
+ - **Adding a node to an existing flow works if the node is reachable from the
94
+ trigger** — some other node's \`_nextAction\`, \`_firstLoopAction\` or a
95
+ branch's \`_children\` must point at it, which is how the diff finds the
96
+ parent to send \`ADD_ACTION\` after. An orphan node nothing points to is
97
+ still refused, by name, rather than guessed at.
98
+ - Enabling a flow arms it against live traffic and is refused unless you pass
99
+ \`--allow-flow-enable\`, the same shape as \`--allow-card-moves\`.
100
+ - Load the \`ppc-pipefy-flow-authoring\` skill before writing or debugging a flow by
101
+ hand — it covers node shapes, datapill syntax and the traps that validate
102
+ clean and resolve nothing at runtime.
103
+
104
+ ## Values that come from a closed set
105
+
106
+ \`REFERENCE.md\` in this workspace lists what the API accepts in every position
107
+ where a value must come from a fixed list: each automation event's
108
+ \`event_params\`, each action's \`action_params\`, which events and actions cannot be
109
+ paired, and the field \`type_id\`, \`color\` and \`filter\` sets. It was read from the
110
+ API on the last pull.
111
+
112
+ **Read it before writing an automation.** The schema publishes these lists and
113
+ then types the inputs that consume them as \`ID\` and \`String\`, so a wrong value is
114
+ not caught by GraphQL. It reaches a runtime check and comes back as \`is invalid\`
115
+ or \`All fields must be filled properly.\` — naming no parameter, no event, and no
116
+ accepted value, partway through an apply.
117
+
118
+ The traps are near-misses, not nonsense. \`in_phase_id\` is a real parameter that
119
+ belongs to \`card_inbox_received_email\`; \`card_moved\` takes only \`to_phase_id\`.
120
+ \`card_id\` is real on \`update_card_field\` and refused on \`move_single_card\`.
121
+
122
+ \`pipe validate\` checks all of this offline and names the parameter, so a wrong
123
+ value costs a second rather than a half-applied change. It is a backstop, not a
124
+ substitute for reading the file.
125
+
126
+ ## Where things live
127
+
128
+ \`\`\`
129
+ pipes/<id>--<name>/
130
+ pipe.json the repo row
131
+ phases/NN--name.json phase + its fields[] + jump_target_ids[] + team_member_ids[]
132
+ automations/<name>.json automation + condition + field_maps + response_schemas
133
+ field-conditions/<name>.json condition + actions
134
+ labels.json webhooks.json public-form.json preferences.json relations.json
135
+ _readonly/ _non-snapshot/ _meta.json
136
+ databases/<id>--<apiId>--<name>/
137
+ table.json the database row
138
+ phases/00--start-form.json the database's COLUMNS live here, as fields[]
139
+ phases/NN--*.json record statuses — readable, no write path
140
+ README.md what each of those files actually is
141
+ \`\`\`
142
+
143
+ Fields live **inside their phase file** — that is where they make sense and where
144
+ you will edit them most. Ordering comes from the \`index\` value inside the file,
145
+ never from the filename, so renumbering works without renaming anything.
146
+
147
+ ${reconstructed.length
148
+ ? `## This workspace was read without snapshots
149
+
150
+ ${reconstructed.length} of ${lock.repos.length} repo(s) were reconstructed from the
151
+ GraphQL and internal APIs rather than from a snapshot payload. Consequences you
152
+ must respect:
153
+
154
+ ${[...unknownGroups].sort().map((g) => `- \`${g}\` is **unknown**, not empty. Do not add to it and do not conclude the pipe has none.`).join('\n')}
155
+
156
+ An \`unknown\` entity group is never diffed and never applied. \`_meta.json\` in each
157
+ repo directory records exactly what was and was not read.
158
+ `
159
+ : `## This workspace was read from snapshots
160
+
161
+ Every entity group came from the snapshot payload, so absence means absence.
162
+ `}
163
+ ## Secrets
164
+
165
+ Webhook URLs are bearer-equivalent — possession is authorization — and
166
+ HTTP-request automations can carry live credentials in \`action_params\`. This
167
+ workspace has a \`.gitignore\`, but do not paste these files into anything, and do
168
+ not commit the workspace.
169
+
170
+ ## Runs
171
+
172
+ Pass \`--report\` to any of pull, apply, dry run or verify and it writes a folder
173
+ under \`runs/\` recording what happened:
174
+
175
+ \`\`\`
176
+ runs/index.md one line per run, oldest first
177
+ runs/<when>--<kind>/report.md what happened, readable, no credentials
178
+ runs/<when>--<kind>/run.json the same thing structured
179
+ runs/<when>--<kind>/pipe.json the payload as of the end of that run
180
+ runs/<when>--<kind>/integrations.json webhooks, relations, cross-repo automations
181
+ \`\`\`
182
+
183
+ An apply also writes \`pipe.before.json\`, so both sides of the change sit in one
184
+ folder. Read \`report.md\` first — it is the only file in there written for a
185
+ human.
186
+
187
+ It is off by default: a run folder holds a full copy of the pipe, credentials
188
+ included, and one per command would grow without bound. The record an apply
189
+ needs in order to resume is the plan file under \`.ppc/plans/\`, which is always
190
+ written.
191
+
192
+ ${trees.length ? `## This workspace\n\n${trees.map((t) => `- ${t.repo['name']} (${t.meta.repoKind} ${t.repo['id']}) — ${t.phases.length} phases, ${t.phases.reduce((n, p) => n + p.fields.length, 0)} fields, ${t.automations.length} automations, read from ${t.meta.source}`).join('\n')}` : ''}
193
+ `;
194
+ };
195
+ const MAPPING_MD = (trees, lock) => {
196
+ const sections = trees.map((t) => {
197
+ const fields = t.phases.flatMap((p) => p.fields.map((f) => ({ f, phase: String(p['name'] ?? '') })));
198
+ const fieldRows = fields
199
+ .map(({ f, phase }) => `| \`${f['id']}\` | ${f['label']} | \`${f['slug'] ?? ''}\` | ${f['type_id']} | ${phase} |`)
200
+ .join('\n');
201
+ const phaseRows = t.phases
202
+ .map((p) => `| ${p['index']} | \`${p['id']}\` | ${p['name']}${Number(p['index']) === 0 ? ' **(start form)**' : ''} | ${p.fields.length} | ${p.jump_target_ids.length ? p.jump_target_ids.map((j) => `\`${j}\``).join(', ') : '—'} |`)
203
+ .join('\n');
204
+ const autoRows = t.automations
205
+ .map((a) => `| \`${a['id']}\` | ${a['name']} | ${a['event_id']} → ${a['action_id']} | ${a['action_repo_id'] !== a['event_repo_id'] ? `cross-repo → \`${a['action_repo_id']}\`` : 'same repo'} | ${a.condition ? 'yes' : '—'} |`)
206
+ .join('\n');
207
+ const connectors = fields
208
+ .filter(({ f }) => f['type_id'] === 'connector')
209
+ .map(({ f, phase }) => `| \`${f['id']}\` | ${f['label']} | ${phase} | \`${f['connected_pipe_id']}\` |`)
210
+ .join('\n');
211
+ return `## ${t.repo['name']} — ${t.meta.repoKind} \`${t.repo['id']}\`
212
+
213
+ ### Phases
214
+
215
+ | index | id | name | fields | jumps to |
216
+ | --- | --- | --- | --- | --- |
217
+ ${phaseRows}
218
+
219
+ ### Fields
220
+
221
+ | id | label | slug | type | phase |
222
+ | --- | --- | --- | --- | --- |
223
+ ${fieldRows}
224
+
225
+ ### Automations
226
+
227
+ | id | name | event → action | scope | condition |
228
+ | --- | --- | --- | --- | --- |
229
+ ${autoRows || '| — | none | | | |'}
230
+
231
+ ${connectors ? `### Connector fields\n\n| field id | label | phase | connected repo |\n| --- | --- | --- | --- |\n${connectors}\n` : ''}`;
232
+ });
233
+ /**
234
+ * Edges name what they point at, not just its id.
235
+ *
236
+ * `301908697` on its own tells a reader nothing — and a connector field aimed
237
+ * at a database means something different from one aimed at a pipe: the target
238
+ * has columns and records rather than phases and cards. The repo is looked up
239
+ * in the lock, so a target outside the workspace still shows as unknown rather
240
+ * than being guessed at.
241
+ */
242
+ const byId = new Map(lock.repos.map((r) => [String(r.id), r]));
243
+ const edgeRows = lock.edges
244
+ .map((e) => {
245
+ const to = byId.get(String(e.to));
246
+ const target = to ? `${to.kind} · ${to.name}` : 'not in this workspace';
247
+ return `| \`${e.from}\` | \`${e.to}\` | ${target} | ${e.kind} | ${e.label ?? ''} |`;
248
+ })
249
+ .join('\n');
250
+ return `# Mapping
251
+
252
+ Generated on pull. This is what makes \`%{<fieldId>}\` mean "Requester email".
253
+
254
+ ${sections.join('\n\n')}
255
+
256
+ ## Connections
257
+
258
+ Discovered as the union of \`pipe_relations\`, connector fields, and cross-repo
259
+ automations — connector fields alone find edges that neither \`pipe_relations\` nor
260
+ \`pipeMapGraph\` reports (PLAN.md §2.2).
261
+
262
+ | from | to | what it is | via | label |
263
+ | --- | --- | --- | --- | --- |
264
+ ${edgeRows || '| — | — | — | — | — |'}
265
+ `;
266
+ };
267
+ const CAPABILITIES_MD = (caps) => {
268
+ const rows = registryRows()
269
+ .sort((a, b) => a.key.localeCompare(b.key))
270
+ .map((r) => `| \`${r.entity}\` | ${r.op} | ${r.status === 'SUPPORTED' ? 'yes' : r.status === 'PARTIAL' ? 'partly' : '**no**'} | ${r.via ? `\`${r.via}\`` : '—'} | ${r.reason ?? ''}${r.ask ? ` _(ask ${r.ask})_` : ''} |`)
271
+ .join('\n');
272
+ const detected = caps
273
+ ? `## Detected on this endpoint
274
+
275
+ | Capability | Present | Consequence if absent |
276
+ | --- | --- | --- |
277
+ | snapshots (\`createRepoSnapshot\`) | ${caps.snapshots ? 'yes' : '**no**'} | pulls reconstruct the structure over GraphQL instead |
278
+ | \`restoreRepoSnapshot\` | ${caps.restoreRepoSnapshot ? 'yes' : '**no**'} | no atomic rollback; a failed apply is repaired forward |
279
+ | \`createRepoDraft\` | ${caps.createRepoDraft ? 'yes' : '**no**'} | no free rehearsal environment; plans are checked, not proven |
280
+ | \`importRepoSnapshot\` | ${caps.importRepoSnapshot ? 'yes' : 'no'} | Route B is unavailable; writes go through the mutation compiler |
281
+ | internal API | ${caps.internalApi} | automation detail and phase-jump editing depend on it |
282
+
283
+ ${caps.internalApi === 'unavailable' ? '> The internal endpoint did not answer. Automations and phase jumps are reported as **unknown** rather than empty, and any change to them is refused rather than silently skipped.\n' : ''}${caps.internalApi === 'incompatible' ? '> The internal endpoint refused one of this tool\'s own queries as invalid — a bug in `pipe`, not an outage. Automations are reported as **unknown** or **partial** rather than empty, and any change to them is refused. `pipe doctor` names the query.\n' : ''}`
284
+ : '';
285
+ return `# Capabilities
286
+
287
+ Generated from the live registry in \`src/apply/registry.ts\`, so what you read
288
+ here is what the applier will actually attempt. If something says **no**, propose
289
+ a different change rather than a workaround.
290
+
291
+ ${detected}
292
+ ## Write coverage
293
+
294
+ | Entity | Operation | Writable | Via | Notes |
295
+ | --- | --- | --- | --- | --- |
296
+ ${rows}
297
+
298
+ ## Flows (iPaaS), separately from the table above
299
+
300
+ Flows compile through \`src/apply/flowops.ts\`, not the registry above — they are
301
+ still applied by the same \`pipe apply\` (skip with \`--no-flows\`), just tracked
302
+ here rather than in the write-coverage table.
303
+
304
+ | Change | Writable | Via | Notes |
305
+ | --- | --- | --- | --- |
306
+ | create a flow | yes | \`createFlow\` (whole tree) | built trigger-first, then each node in execution order |
307
+ | delete a flow | yes | \`deleteFlow\` | destructive |
308
+ | rename a flow | yes | \`CHANGE_NAME\` | |
309
+ | update the trigger | yes | \`UPDATE_TRIGGER\` | whole node, not a sparse patch |
310
+ | update an action | yes | \`UPDATE_ACTION\` | whole node; a router's branches live in \`settings.branches\`, so editing them is an \`UPDATE_ACTION\` on the router — there is no \`UPDATE_BRANCH\` |
311
+ | delete an action | yes | \`DELETE_ACTION\` | destructive |
312
+ | add a node **to an existing flow** | yes, if reachable from the trigger | \`ADD_ACTION\` | the parent is found by scanning the authored tree for whoever's \`_nextAction\` / \`_firstLoopAction\` / \`_children\` points at the new node. An orphan node nothing points to is refused by name |
313
+ | enable a flow | partly | \`CHANGE_STATUS\` | arms it against live traffic — refused unless \`--allow-flow-enable\`, and refused outright while any connection it uses is still a placeholder |
314
+ | disable a flow | yes | \`CHANGE_STATUS\` | |
315
+ | a connection the flow needs but the project lacks | yes, as a placeholder | \`createConnection\` | created disabled with a placeholder credential so the structure can still be built; reconnect it for real before enabling |
316
+ | whole-tree replace | **refused on purpose** | — | \`IMPORT_FLOW\` exists and is never used — it re-runs an old schema migration on an already-live tree and doubles every datapill (\`{{step_1['output'].x}}\` → \`{{step_1['output']['output'].x}}\`), resolving nothing at runtime while every validator still calls it valid |
317
+
318
+ After any flow write, the applier reads the flow back and diffs its datapills
319
+ against what was sent — the only check that catches the \`IMPORT_FLOW\`-shaped
320
+ migration bug, and the reason a flow apply is not reported successful on the
321
+ mutation's response alone.
322
+
323
+ ## The three that hurt most
324
+
325
+ 1. **Field type changes** — routine to ask for, impossible to do without
326
+ destroying data. Refused.
327
+ 2. **Moving a field between phases** — same.
328
+ 3. **Reordering phases** — \`createPhase\` takes an index, \`updatePhase\` does not.
329
+
330
+ These are asks 2, 3 and 4 in \`ROUTE-B-V2.md\`. Every refusal pipe emits is a data
331
+ point for them.
332
+ `;
333
+ };
334
+ const GITIGNORE = `# A pulled workspace contains webhook URLs (bearer-equivalent: possession is
335
+ # authorization) and HTTP-request automation credentials. Never commit it.
336
+ .ppc/
337
+ *.gz
338
+
339
+ # Run folders hold a full copy of the pipe (runs/*/pipe.json) and its integration
340
+ # surface, credentials included. The report beside them is redacted and can be
341
+ # shared by hand; the payloads cannot.
342
+ runs/*/pipe*.json
343
+ runs/*/integrations*.json
344
+
345
+ # Uncomment to keep the structure out of git entirely.
346
+ # pipes/
347
+ # databases/
348
+ # runs/
349
+ `;
350
+ export const writeDocs = async (root, lock, trees, caps, ref = null) => {
351
+ const p = paths(root);
352
+ await writeFile(p.agentsMd, AGENTS_MD(lock, trees));
353
+ await writeFile(p.mappingMd, MAPPING_MD(trees, lock));
354
+ await writeFile(p.capabilitiesMd, CAPABILITIES_MD(caps));
355
+ await writeFile(p.referenceMd, renderReferenceMd(ref));
356
+ await writeFile(p.gitignore, GITIGNORE);
357
+ /**
358
+ * Migration cleanup: a workspace pulled before this rename has a stale
359
+ * `CLAUDE.md` sitting beside the new `AGENTS.md`, still telling whoever
360
+ * reads it to look here — remove it rather than leave a duplicate that
361
+ * silently goes out of date the next time this file is regenerated.
362
+ */
363
+ const staleClaudeMd = join(root, 'CLAUDE.md');
364
+ if (await exists(staleClaudeMd))
365
+ await rm(staleClaudeMd, { force: true });
366
+ return [p.agentsMd, p.mappingMd, p.capabilitiesMd, p.referenceMd, p.gitignore];
367
+ };