@pipefy/pipefy-process-coder 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) 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 +465 -456
  20. package/README.md +825 -807
  21. package/bin/pipe.js +25 -4
  22. package/dist/apply/adopt.js +128 -0
  23. package/dist/apply/agentops.js +42 -0
  24. package/dist/apply/compile.js +722 -0
  25. package/dist/apply/execute.js +277 -0
  26. package/dist/apply/flowops.js +293 -0
  27. package/dist/apply/idmap.js +233 -0
  28. package/dist/apply/mutations.js +704 -0
  29. package/dist/apply/registry.js +348 -0
  30. package/dist/apply/types.js +18 -0
  31. package/dist/cli/args.js +79 -0
  32. package/dist/cli.js +187 -0
  33. package/dist/codec/flow.js +164 -0
  34. package/dist/codec/pack.js +88 -0
  35. package/dist/codec/roundtrip.js +86 -0
  36. package/dist/codec/unpack.js +181 -0
  37. package/dist/commands/agents.js +145 -0
  38. package/dist/commands/apply.js +982 -0
  39. package/dist/commands/context.js +79 -0
  40. package/dist/commands/create.js +63 -0
  41. package/dist/commands/diff.js +294 -0
  42. package/dist/commands/flows.js +348 -0
  43. package/dist/commands/misc.js +528 -0
  44. package/dist/commands/plan.js +301 -0
  45. package/dist/commands/pull.js +511 -0
  46. package/dist/commands/runs.js +74 -0
  47. package/dist/commands/skills.js +131 -0
  48. package/dist/commands/verify.js +205 -0
  49. package/dist/config.js +125 -0
  50. package/dist/diff/agents.js +80 -0
  51. package/dist/diff/diff.js +983 -0
  52. package/dist/diff/flow.js +258 -0
  53. package/{src/diff/html.ts → dist/diff/html.js} +274 -322
  54. package/dist/diff/render.js +90 -0
  55. package/dist/model/payload.js +84 -0
  56. package/dist/model/tree.js +7 -0
  57. package/dist/model/volatile.js +54 -0
  58. package/dist/pipefy/agents.js +122 -0
  59. package/dist/pipefy/automations.js +155 -0
  60. package/dist/pipefy/capability.js +84 -0
  61. package/dist/pipefy/client.js +204 -0
  62. package/dist/pipefy/discovery.js +148 -0
  63. package/dist/pipefy/internal.js +315 -0
  64. package/dist/pipefy/ipaas.js +269 -0
  65. package/dist/pipefy/reconstruct.js +674 -0
  66. package/dist/pipefy/reference.js +172 -0
  67. package/dist/pipefy/snapshot.js +185 -0
  68. package/dist/pipefy/toolkit.js +158 -0
  69. package/dist/report/integrations.js +165 -0
  70. package/dist/report/run.js +355 -0
  71. package/dist/util/fsx.js +50 -0
  72. package/dist/util/git.js +25 -0
  73. package/dist/util/json.js +56 -0
  74. package/dist/util/log.js +69 -0
  75. package/dist/util/pool.js +42 -0
  76. package/dist/util/slug.js +23 -0
  77. package/dist/util/tui.js +287 -0
  78. package/dist/validate/index.js +86 -0
  79. package/dist/validate/integrity.js +370 -0
  80. package/dist/validate/reference.js +120 -0
  81. package/dist/validate/schema.js +278 -0
  82. package/dist/workspace/agents.js +245 -0
  83. package/{src/workspace/docs.ts → dist/workspace/docs.js} +367 -407
  84. package/dist/workspace/flows.js +149 -0
  85. package/dist/workspace/layout.js +146 -0
  86. package/dist/workspace/lock.js +83 -0
  87. package/dist/workspace/read.js +168 -0
  88. package/dist/workspace/reference.js +24 -0
  89. package/dist/workspace/stamp.js +234 -0
  90. package/dist/workspace/write.js +194 -0
  91. package/package.json +37 -35
  92. package/src/apply/adopt.ts +0 -150
  93. package/src/apply/agentops.ts +0 -71
  94. package/src/apply/compile.ts +0 -875
  95. package/src/apply/execute.ts +0 -336
  96. package/src/apply/flowops.ts +0 -399
  97. package/src/apply/idmap.ts +0 -241
  98. package/src/apply/mutations.ts +0 -955
  99. package/src/apply/registry.ts +0 -430
  100. package/src/apply/types.ts +0 -134
  101. package/src/cli/args.ts +0 -88
  102. package/src/cli.ts +0 -211
  103. package/src/codec/flow.ts +0 -199
  104. package/src/codec/pack.ts +0 -103
  105. package/src/codec/roundtrip.ts +0 -94
  106. package/src/codec/unpack.ts +0 -198
  107. package/src/commands/agents.ts +0 -184
  108. package/src/commands/apply.ts +0 -1144
  109. package/src/commands/context.ts +0 -119
  110. package/src/commands/create.ts +0 -79
  111. package/src/commands/diff.ts +0 -314
  112. package/src/commands/flows.ts +0 -414
  113. package/src/commands/misc.ts +0 -644
  114. package/src/commands/plan.ts +0 -331
  115. package/src/commands/pull.ts +0 -567
  116. package/src/commands/runs.ts +0 -83
  117. package/src/commands/skills.ts +0 -137
  118. package/src/commands/verify.ts +0 -253
  119. package/src/config.ts +0 -168
  120. package/src/diff/agents.ts +0 -122
  121. package/src/diff/diff.ts +0 -1130
  122. package/src/diff/flow.ts +0 -318
  123. package/src/diff/render.ts +0 -101
  124. package/src/model/payload.ts +0 -154
  125. package/src/model/tree.ts +0 -99
  126. package/src/model/volatile.ts +0 -55
  127. package/src/pipefy/agents.ts +0 -165
  128. package/src/pipefy/automations.ts +0 -219
  129. package/src/pipefy/capability.ts +0 -119
  130. package/src/pipefy/client.ts +0 -267
  131. package/src/pipefy/discovery.ts +0 -209
  132. package/src/pipefy/internal.ts +0 -380
  133. package/src/pipefy/ipaas.ts +0 -365
  134. package/src/pipefy/reconstruct.ts +0 -775
  135. package/src/pipefy/reference.ts +0 -251
  136. package/src/pipefy/snapshot.ts +0 -245
  137. package/src/pipefy/toolkit.ts +0 -200
  138. package/src/report/integrations.ts +0 -231
  139. package/src/report/run.ts +0 -475
  140. package/src/util/fsx.ts +0 -45
  141. package/src/util/git.ts +0 -32
  142. package/src/util/json.ts +0 -55
  143. package/src/util/log.ts +0 -76
  144. package/src/util/pool.ts +0 -48
  145. package/src/util/slug.ts +0 -26
  146. package/src/util/tui.ts +0 -335
  147. package/src/validate/index.ts +0 -123
  148. package/src/validate/integrity.ts +0 -387
  149. package/src/validate/reference.ts +0 -136
  150. package/src/validate/schema.ts +0 -328
  151. package/src/workspace/agents.ts +0 -290
  152. package/src/workspace/flows.ts +0 -191
  153. package/src/workspace/layout.ts +0 -165
  154. package/src/workspace/lock.ts +0 -148
  155. package/src/workspace/read.ts +0 -165
  156. package/src/workspace/reference.ts +0 -24
  157. package/src/workspace/stamp.ts +0 -301
  158. package/src/workspace/write.ts +0 -225
  159. /package/{src → dist}/pipefy/toolkit_bearer.py +0 -0
@@ -0,0 +1,355 @@
1
+ import { join } from 'node:path';
2
+ import { promises as fs } from 'node:fs';
3
+ import { mkdirp, writeFile, writeJson, exists } from "../util/fsx.js";
4
+ import { paths } from "../workspace/layout.js";
5
+ import { integrationsFrom, renderIntegrations } from "./integrations.js";
6
+ const stamp = (d = new Date()) => d.toISOString().replace(/[:.]/g, '-').replace(/-\d{3}Z$/, 'Z');
7
+ /**
8
+ * The command as typed, for reproduction — with any token elided. `--token` is
9
+ * the one flag whose value must never reach a file, and `pipe login --token` is
10
+ * a real invocation someone might run inside a workspace.
11
+ */
12
+ export const invocation = (argv = process.argv.slice(2)) => {
13
+ const parts = [];
14
+ for (let i = 0; i < argv.length; i++) {
15
+ const a = argv[i];
16
+ if (/^--token(=|$)/.test(a)) {
17
+ parts.push('--token <elided>');
18
+ if (!a.includes('=') && argv[i + 1] && !argv[i + 1].startsWith('-'))
19
+ i++;
20
+ continue;
21
+ }
22
+ parts.push(a);
23
+ }
24
+ return `pipe ${parts.join(' ')}`.trim();
25
+ };
26
+ /** `runs/` lives at the workspace root, not under `.ppc/` — these are for reading. */
27
+ export const runsDir = (root) => join(paths(root).root, 'runs');
28
+ export const runDir = (root, id) => join(runsDir(root), id);
29
+ /**
30
+ * Collects what happened while a command runs.
31
+ *
32
+ * Deliberately tolerant: a recorder that throws would turn a successful apply
33
+ * into a failed command, and the record is worth less than the work it
34
+ * describes. Everything here is additive and total.
35
+ */
36
+ export class RunRecorder {
37
+ id;
38
+ startedAt;
39
+ notes = [];
40
+ warnings = [];
41
+ plan = null;
42
+ verification;
43
+ beforeEnv = null;
44
+ afterEnv = null;
45
+ kind;
46
+ ctx;
47
+ constructor(kind, ctx) {
48
+ this.kind = kind;
49
+ this.ctx = ctx;
50
+ /**
51
+ * `startedAt` is passed in by commands that only learn where the workspace
52
+ * is at the end — a pull cannot name its own output directory until
53
+ * discovery has run, and a run that reports 0.0s for a 60-second snapshot is
54
+ * worse than no timing at all.
55
+ */
56
+ this.startedAt = ctx.startedAt ?? new Date();
57
+ this.id = `${stamp(this.startedAt)}--${kind}`;
58
+ }
59
+ note(text) {
60
+ this.notes.push(text);
61
+ }
62
+ warn(text) {
63
+ this.warnings.push(text);
64
+ }
65
+ /** The plan is the step list; recording it is how the report gets its detail. */
66
+ fromPlan(plan) {
67
+ this.plan = plan;
68
+ }
69
+ verified(ok, detail) {
70
+ this.verification = { ran: true, ok, ...(detail ? { detail } : {}) };
71
+ }
72
+ /** The payload the run started from, when the command knows it. */
73
+ before(env) {
74
+ this.beforeEnv = env;
75
+ }
76
+ after(env) {
77
+ this.afterEnv = env;
78
+ }
79
+ build(outcome, summary) {
80
+ const finished = new Date();
81
+ const steps = (this.plan?.steps ?? []).map((s) => {
82
+ const started = s.startedAt ? Date.parse(s.startedAt) : NaN;
83
+ const ended = s.finishedAt ? Date.parse(s.finishedAt) : NaN;
84
+ return {
85
+ index: s.index,
86
+ id: s.id,
87
+ description: s.description,
88
+ entity: String(s.entity),
89
+ op: s.op,
90
+ kind: s.kind,
91
+ status: s.status,
92
+ tags: [
93
+ ...(s.destructive ? ['destructive'] : []),
94
+ ...(s.unverified ? ['unverified'] : []),
95
+ ...(s.kind.startsWith('internal') ? ['internal-api'] : []),
96
+ ],
97
+ ...(s.error ? { error: s.error } : {}),
98
+ ...(producedId(s.result) !== undefined ? { produced: producedId(s.result) } : {}),
99
+ ...(Number.isFinite(started) && Number.isFinite(ended) ? { durationMs: ended - started } : {}),
100
+ };
101
+ });
102
+ const count = (status) => steps.filter((s) => s.status === status).length;
103
+ const env = this.afterEnv;
104
+ return {
105
+ id: this.id,
106
+ kind: this.kind,
107
+ command: this.ctx.command,
108
+ tool: { name: 'pipefy-process-coder', version: '0.1.0' },
109
+ startedAt: this.startedAt.toISOString(),
110
+ finishedAt: finished.toISOString(),
111
+ durationMs: finished.getTime() - this.startedAt.getTime(),
112
+ workspace: this.ctx.workspace,
113
+ repo: this.ctx.repo,
114
+ outcome,
115
+ summary,
116
+ ...(this.plan ? { planId: this.plan.id, preApplyVersionId: this.plan.preApplyVersionId ?? null } : {}),
117
+ counts: {
118
+ steps: steps.length,
119
+ done: count('done'),
120
+ failed: count('failed'),
121
+ skipped: count('skipped'),
122
+ pending: count('pending'),
123
+ blocked: this.plan?.blocked.length ?? 0,
124
+ },
125
+ steps,
126
+ blocked: (this.plan?.blocked ?? []).map((b) => ({
127
+ label: b.label,
128
+ entity: String(b.entity),
129
+ op: b.op,
130
+ ...(b.reason ? { reason: b.reason } : {}),
131
+ ...(b.ask ? { ask: b.ask } : {}),
132
+ })),
133
+ // Deduplicated: a multi-repo pull collects the same coverage note from
134
+ // every tree, and three copies of it read as three problems.
135
+ warnings: [...new Set([...(this.plan?.warnings ?? []), ...this.warnings])],
136
+ notes: [...new Set(this.notes)],
137
+ ...(this.verification ? { verification: this.verification } : {}),
138
+ ...(env ? { coverage: env.coverage } : {}),
139
+ integrations: {
140
+ ...(this.beforeEnv ? { before: integrationsFrom(this.beforeEnv).counts } : {}),
141
+ ...(this.afterEnv ? { after: integrationsFrom(this.afterEnv).counts } : {}),
142
+ },
143
+ artifacts: [],
144
+ };
145
+ }
146
+ /**
147
+ * Write the run folder. Never throws: a failure to record is reported as a
148
+ * note and nothing more, because the alternative is a command that did its
149
+ * work and then reported failure.
150
+ */
151
+ async write(outcome, summary) {
152
+ try {
153
+ const record = this.build(outcome, summary);
154
+ const dir = runDir(this.ctx.workspace, record.id);
155
+ await mkdirp(dir);
156
+ const written = [];
157
+ const put = async (name, value) => {
158
+ await writeJson(join(dir, name), value);
159
+ written.push(name);
160
+ };
161
+ if (this.afterEnv) {
162
+ await put('pipe.json', this.afterEnv);
163
+ await put('integrations.json', integrationsFrom(this.afterEnv));
164
+ }
165
+ if (this.beforeEnv && this.kind !== 'pull') {
166
+ await put('pipe.before.json', this.beforeEnv);
167
+ await put('integrations.before.json', integrationsFrom(this.beforeEnv));
168
+ }
169
+ record.artifacts = [...written, 'report.md', 'run.json'];
170
+ await writeJson(join(dir, 'run.json'), record);
171
+ await writeFile(join(dir, 'report.md'), renderRunReport(record, this.afterEnv, this.beforeEnv));
172
+ await appendIndex(this.ctx.workspace, record);
173
+ return { dir };
174
+ }
175
+ catch (e) {
176
+ return { dir: runDir(this.ctx.workspace, this.id), error: e.message };
177
+ }
178
+ }
179
+ }
180
+ /** The created id, from whatever shape the mutation answered with. */
181
+ const producedId = (result) => {
182
+ if (!result || typeof result !== 'object')
183
+ return undefined;
184
+ const seen = new Set();
185
+ const walk = (v, depth) => {
186
+ if (depth > 4 || !v || typeof v !== 'object' || seen.has(v))
187
+ return undefined;
188
+ seen.add(v);
189
+ const o = v;
190
+ if (typeof o['id'] === 'string' || typeof o['id'] === 'number')
191
+ return o['id'];
192
+ for (const val of Object.values(o)) {
193
+ const hit = walk(val, depth + 1);
194
+ if (hit !== undefined)
195
+ return hit;
196
+ }
197
+ return undefined;
198
+ };
199
+ return walk(result, 0);
200
+ };
201
+ const OUTCOME_WORD = {
202
+ ok: 'succeeded',
203
+ partial: 'partially applied',
204
+ failed: 'failed',
205
+ refused: 'refused before sending anything',
206
+ 'no-op': 'had nothing to do',
207
+ };
208
+ export const renderRunReport = (r, after, before) => {
209
+ const L = [];
210
+ const secs = (r.durationMs / 1000).toFixed(1);
211
+ L.push(`# ${r.kind} — ${r.repo.name} (${r.repo.id})`, '');
212
+ L.push(`**${OUTCOME_WORD[r.outcome]}** · ${r.summary}`, '');
213
+ L.push('| | |', '| --- | --- |');
214
+ L.push(`| Run | \`${r.id}\` |`);
215
+ L.push(`| Command | \`${r.command}\` |`);
216
+ L.push(`| Started | ${r.startedAt} |`);
217
+ L.push(`| Duration | ${secs}s |`);
218
+ L.push(`| Workspace | \`${r.workspace}\` |`);
219
+ if (r.planId)
220
+ L.push(`| Plan | \`${r.planId}\` |`);
221
+ if (r.preApplyVersionId)
222
+ L.push(`| Pre-apply snapshot | \`${r.preApplyVersionId}\` |`);
223
+ if (after)
224
+ L.push(`| Read path | ${after.source}${after.versionId ? ` \`${after.versionId.slice(0, 8)}\`` : ''} |`);
225
+ if (r.counts.steps) {
226
+ L.push(`| Steps | ${r.counts.steps} — ${r.counts.done} done, ${r.counts.failed} failed, ` +
227
+ `${r.counts.skipped} skipped${r.counts.pending ? `, ${r.counts.pending} never attempted` : ''} |`);
228
+ }
229
+ if (r.verification)
230
+ L.push(`| Verification | ${r.verification.ok ? 'clean' : 'found differences'} |`);
231
+ L.push('');
232
+ if (r.steps.length) {
233
+ L.push('## Steps', '');
234
+ L.push('| # | Step | Entity | Op | Status | Result |', '| --- | --- | --- | --- | --- | --- |');
235
+ for (const s of r.steps) {
236
+ const result = s.error
237
+ ? `\`${s.error.replace(/\|/g, '\\|').slice(0, 160)}\``
238
+ : s.produced !== undefined
239
+ ? `id ${s.produced}`
240
+ : s.status === 'done'
241
+ ? 'applied'
242
+ : '—';
243
+ const tags = s.tags.length ? ` <sub>${s.tags.join(' ')}</sub>` : '';
244
+ L.push(`| ${s.index} | ${s.description.replace(/\|/g, '\\|')}${tags} | ${s.entity} | ${s.op} | ` +
245
+ `${statusMark(s.status)} | ${result} |`);
246
+ }
247
+ L.push('');
248
+ const failures = r.steps.filter((s) => s.status === 'failed' || s.status === 'skipped');
249
+ if (failures.length) {
250
+ L.push('### What did not land', '');
251
+ for (const s of failures) {
252
+ L.push(`- **${s.description}** (${s.status}) — ${s.error ?? 'no reason recorded'}`);
253
+ }
254
+ L.push('');
255
+ }
256
+ }
257
+ if (r.blocked.length) {
258
+ L.push('## Refused before sending', '');
259
+ L.push('These changes are in the workspace and have no write path. Nothing was attempted.', '');
260
+ for (const b of r.blocked) {
261
+ L.push(`- **${b.label}** — ${b.reason ?? 'no write path'}${b.ask ? ` (ask ${b.ask})` : ''}`);
262
+ }
263
+ L.push('');
264
+ }
265
+ if (r.warnings.length) {
266
+ L.push('## Warnings', '');
267
+ for (const w of r.warnings)
268
+ L.push(`- ${w}`);
269
+ L.push('');
270
+ }
271
+ if (after) {
272
+ const t = after.payload.relations;
273
+ L.push('## The pipe, as of the end of this run', '');
274
+ L.push('| Group | Count | Coverage |', '| --- | --- | --- |');
275
+ for (const [name, rows] of Object.entries(t)) {
276
+ const cov = (r.coverage ?? {})[name] ?? 'unknown';
277
+ if (!rows.length && cov === 'complete')
278
+ continue;
279
+ L.push(`| ${name} | ${rows.length} | ${cov}${cov === 'complete' ? '' : ' ⚠'} |`);
280
+ }
281
+ L.push('');
282
+ const gaps = Object.entries(r.coverage ?? {}).filter(([, c]) => c !== 'complete');
283
+ if (gaps.length) {
284
+ L.push(`⚠ ${gaps.length} group${gaps.length === 1 ? '' : 's'} not fully read. ` +
285
+ 'A count of 0 there means "not read", not "empty".', '');
286
+ }
287
+ L.push('## Integrations', '');
288
+ if (before) {
289
+ const b = integrationsFrom(before).counts;
290
+ const a = integrationsFrom(after).counts;
291
+ const moved = Object.keys(a).filter((k) => a[k] !== b[k]);
292
+ if (moved.length) {
293
+ L.push(`Changed by this run: ${moved.map((k) => `${k} ${b[k]} → ${a[k]}`).join(', ')}.`, '');
294
+ }
295
+ }
296
+ L.push(...renderIntegrations(integrationsFrom(after)));
297
+ L.push('');
298
+ }
299
+ if (r.notes.length) {
300
+ L.push('## Notes', '');
301
+ for (const n of r.notes)
302
+ L.push(`- ${n}`);
303
+ L.push('');
304
+ }
305
+ L.push('---', '', `Artifacts in this folder: ${r.artifacts.map((a) => `\`${a}\``).join(', ')}.`);
306
+ L.push('', '`pipe.json` and `integrations.json` are full copies of the pipe and include ' +
307
+ 'webhook URLs and HTTP-request credentials. This report does not.');
308
+ return L.join('\n') + '\n';
309
+ };
310
+ const statusMark = (status) => status === 'done' ? 'done' : status === 'failed' ? '**failed**' : status === 'skipped' ? 'skipped' : status;
311
+ /**
312
+ * One line per run in `runs/index.md`, newest last.
313
+ *
314
+ * Appended rather than regenerated so that a run folder someone has deleted or
315
+ * moved does not silently vanish from the history.
316
+ */
317
+ const appendIndex = async (root, r) => {
318
+ const path = join(runsDir(root), 'index.md');
319
+ const header = [
320
+ '# Runs',
321
+ '',
322
+ 'Every invocation that read or wrote this pipe, oldest first. Each row links to',
323
+ 'the run folder, which holds the report, the payload, and the integration surface',
324
+ 'as of the end of that run.',
325
+ '',
326
+ '| When | Run | Kind | Outcome | Steps | Summary |',
327
+ '| --- | --- | --- | --- | --- | --- |',
328
+ ].join('\n');
329
+ if (!(await exists(path)))
330
+ await writeFile(path, header + '\n');
331
+ const row = `| ${r.startedAt.replace('T', ' ').slice(0, 19)} | [${r.id}](${r.id}/report.md) | ${r.kind} | ` +
332
+ `${r.outcome} | ${r.counts.steps ? `${r.counts.done}/${r.counts.steps}` : '—'} | ` +
333
+ `${r.summary.replace(/\|/g, '\\|')} |\n`;
334
+ await fs.appendFile(path, row, 'utf8');
335
+ };
336
+ /** Read every run record in a workspace, oldest first. */
337
+ export const listRuns = async (root) => {
338
+ const dir = runsDir(root);
339
+ if (!(await exists(dir)))
340
+ return [];
341
+ const entries = await fs.readdir(dir, { withFileTypes: true });
342
+ const out = [];
343
+ for (const e of entries) {
344
+ if (!e.isDirectory())
345
+ continue;
346
+ try {
347
+ out.push(JSON.parse(await fs.readFile(join(dir, e.name, 'run.json'), 'utf8')));
348
+ }
349
+ catch {
350
+ // A run folder without a readable run.json is skipped, not fatal: the
351
+ // report beside it may still be the thing someone wants to read.
352
+ }
353
+ }
354
+ return out.sort((a, b) => a.startedAt.localeCompare(b.startedAt));
355
+ };
@@ -0,0 +1,50 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { pretty } from "./json.js";
4
+ export const mkdirp = (p) => fs.mkdir(p, { recursive: true });
5
+ export const writeFile = async (p, content) => {
6
+ await mkdirp(dirname(p));
7
+ await fs.writeFile(p, content, 'utf8');
8
+ };
9
+ export const writeJson = (p, v) => writeFile(p, pretty(v));
10
+ export const readJson = async (p) => {
11
+ const raw = await fs.readFile(p, 'utf8');
12
+ try {
13
+ return JSON.parse(raw);
14
+ }
15
+ catch (e) {
16
+ throw new Error(`${p}: invalid JSON - ${e.message}`);
17
+ }
18
+ };
19
+ export const exists = async (p) => {
20
+ try {
21
+ await fs.stat(p);
22
+ return true;
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ };
28
+ /** Every .json file under a directory, recursively, sorted. */
29
+ export const listJson = async (dir) => {
30
+ const acc = [];
31
+ const walk = async (d) => {
32
+ let entries;
33
+ try {
34
+ entries = (await fs.readdir(d, { withFileTypes: true }));
35
+ }
36
+ catch {
37
+ return;
38
+ }
39
+ for (const e of entries) {
40
+ const full = join(d, e.name);
41
+ if (e.isDirectory())
42
+ await walk(full);
43
+ else if (e.name.endsWith('.json'))
44
+ acc.push(full);
45
+ }
46
+ };
47
+ await walk(dir);
48
+ return acc.sort();
49
+ };
50
+ export const rmrf = (p) => fs.rm(p, { recursive: true, force: true });
@@ -0,0 +1,25 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ /**
5
+ * Best-effort, and never throws: a workspace with no local version control is
6
+ * an advisory, not a blocker. `pipe rollback` only restores the *latest*
7
+ * snapshot (commands/misc.ts), so it stops covering a rollback the moment
8
+ * anything re-snapshots the repo — which a normal `pipe apply` does on its own
9
+ * read-back. A local git history is what covers the gap.
10
+ */
11
+ export const checkGit = async (root) => {
12
+ try {
13
+ await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: root });
14
+ return { installed: true, tracked: true };
15
+ }
16
+ catch {
17
+ try {
18
+ await execFileAsync('git', ['--version']);
19
+ return { installed: true, tracked: false };
20
+ }
21
+ catch {
22
+ return { installed: false, tracked: false };
23
+ }
24
+ }
25
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Canonical JSON. PLAN.md §3: the round-trip exit criterion is *semantic*
3
+ * equality under a canonical form, never byte equality — `JSON.parse` turns
4
+ * `index: 6.0` into `6`, and fractional indices invite float noise.
5
+ */
6
+ /** Normalise a number to a stable representation. -0 -> 0, 6.0 -> 6. */
7
+ const num = (n) => {
8
+ if (!Number.isFinite(n))
9
+ return n;
10
+ if (Object.is(n, -0))
11
+ return 0;
12
+ // Collapse float noise past 10 significant decimals; index and lateness are
13
+ // the only fractional numbers in the payload and never need more.
14
+ const r = Number(n.toFixed(10));
15
+ return Object.is(r, -0) ? 0 : r;
16
+ };
17
+ export const canonical = (v) => {
18
+ if (v === null || v === undefined)
19
+ return null;
20
+ if (typeof v === 'number')
21
+ return num(v);
22
+ if (typeof v === 'boolean' || typeof v === 'string')
23
+ return v;
24
+ if (Array.isArray(v))
25
+ return v.map(canonical);
26
+ if (typeof v === 'object') {
27
+ const o = v;
28
+ const res = {};
29
+ for (const k of Object.keys(o).sort()) {
30
+ if (o[k] === undefined)
31
+ continue;
32
+ res[k] = canonical(o[k]);
33
+ }
34
+ return res;
35
+ }
36
+ return String(v);
37
+ };
38
+ export const canonicalString = (v) => JSON.stringify(canonical(v));
39
+ export const deepEqual = (a, b) => canonicalString(a) === canonicalString(b);
40
+ /** Stable pretty JSON for files a human and Claude will read and edit. */
41
+ export const pretty = (v) => JSON.stringify(v, null, 2) + '\n';
42
+ export const clone = (v) => (v === undefined ? v : JSON.parse(JSON.stringify(v)));
43
+ /** Walk every string in a structure, replacing it. Used by the %{id} rewriter. */
44
+ export const mapStrings = (v, fn) => {
45
+ if (typeof v === 'string')
46
+ return fn(v);
47
+ if (Array.isArray(v))
48
+ return v.map((x) => mapStrings(x, fn));
49
+ if (v && typeof v === 'object') {
50
+ const res = {};
51
+ for (const [k, val] of Object.entries(v))
52
+ res[k] = mapStrings(val, fn);
53
+ return res;
54
+ }
55
+ return v;
56
+ };
@@ -0,0 +1,69 @@
1
+ const ESC = '\x1b[';
2
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
3
+ const c = (code) => (s) => (useColor ? `${ESC}${code}m${s}${ESC}0m` : s);
4
+ export const dim = c('2');
5
+ export const bold = c('1');
6
+ export const red = c('31');
7
+ export const green = c('32');
8
+ export const yellow = c('33');
9
+ export const blue = c('36');
10
+ export const magenta = c('35');
11
+ let verbose = false;
12
+ export const setVerbose = (v) => { verbose = v; };
13
+ export const isVerbose = () => verbose;
14
+ let sink = null;
15
+ export const claimLogSink = (s) => {
16
+ sink = s;
17
+ };
18
+ export const hasLogSink = () => sink !== null;
19
+ /** Colour is for a terminal. A report is a file, so it gets none. */
20
+ export const stripAnsi = (s) => s.replace(/\u001b\[[0-9;]*m/g, '');
21
+ export const out = (s = '') => process.stdout.write(s + '\n');
22
+ export const info = (s) => {
23
+ if (sink)
24
+ sink.note(s);
25
+ else
26
+ process.stderr.write(s + '\n');
27
+ };
28
+ export const debug = (s) => { if (verbose)
29
+ process.stderr.write(dim('· ' + s) + '\n'); };
30
+ export const warn = (s) => {
31
+ if (sink)
32
+ sink.note(yellow('! ') + s);
33
+ else
34
+ process.stderr.write(yellow('! ') + s + '\n');
35
+ };
36
+ export const error = (s) => {
37
+ if (sink)
38
+ sink.note(red('x ') + s);
39
+ else
40
+ process.stderr.write(red('x ') + s + '\n');
41
+ };
42
+ export const ok = (s) => {
43
+ if (sink)
44
+ sink.note(green('+ ') + s);
45
+ else
46
+ process.stderr.write(green('+ ') + s + '\n');
47
+ };
48
+ export const step = (s) => {
49
+ if (sink)
50
+ return;
51
+ process.stderr.write(blue('> ') + s + '\n');
52
+ };
53
+ const CLEAR_LINE = `\r${ESC}2K`;
54
+ /** Single-line progress that collapses to nothing on a non-tty. */
55
+ export const progress = (s) => {
56
+ if (!process.stderr.isTTY)
57
+ return;
58
+ process.stderr.write(CLEAR_LINE + dim(' ' + s));
59
+ };
60
+ export const progressDone = () => { if (process.stderr.isTTY)
61
+ process.stderr.write(CLEAR_LINE); };
62
+ export class UserError extends Error {
63
+ hint;
64
+ constructor(message, hint) {
65
+ super(message);
66
+ this.name = 'UserError';
67
+ this.hint = hint;
68
+ }
69
+ }
@@ -0,0 +1,42 @@
1
+ import { debug } from "./log.js";
2
+ export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
3
+ /** Bounded-concurrency map that preserves input order. */
4
+ export const mapPool = async (items, limit, fn) => {
5
+ const results = new Array(items.length);
6
+ let next = 0;
7
+ const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
8
+ for (;;) {
9
+ const i = next++;
10
+ if (i >= items.length)
11
+ return;
12
+ results[i] = await fn(items[i], i);
13
+ }
14
+ });
15
+ await Promise.all(workers);
16
+ return results;
17
+ };
18
+ /**
19
+ * Exponential backoff with jitter. PLAN.md §9: rate limits are unmeasured, so
20
+ * every network call goes through this and every retry is logged - which is how
21
+ * we measure the ceiling instead of discovering it during a client apply.
22
+ */
23
+ export const retry = async (fn, opts = {}) => {
24
+ const tries = opts.tries ?? 5;
25
+ const base = opts.baseMs ?? 600;
26
+ let lastErr;
27
+ for (let attempt = 1; attempt <= tries; attempt++) {
28
+ try {
29
+ return await fn();
30
+ }
31
+ catch (e) {
32
+ lastErr = e;
33
+ const retryable = e.retryable !== false;
34
+ if (!retryable || attempt === tries)
35
+ break;
36
+ const wait = Math.round(base * 2 ** (attempt - 1) * (0.7 + Math.random() * 0.6));
37
+ debug(`retry ${attempt}/${tries - 1} in ${wait}ms${opts.label ? ` (${opts.label})` : ''}: ${e.message}`);
38
+ await sleep(wait);
39
+ }
40
+ }
41
+ throw lastErr;
42
+ };
@@ -0,0 +1,23 @@
1
+ /** Filesystem-safe, stable, human-readable name segment. */
2
+ export const slug = (s, fallback = 'untitled') => {
3
+ const base = (s ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '');
4
+ const out = base
5
+ .toLowerCase()
6
+ .replace(/['"`]/g, '')
7
+ .replace(/[^a-z0-9]+/g, '-')
8
+ .replace(/^-+|-+$/g, '')
9
+ .slice(0, 60)
10
+ .replace(/-+$/g, '');
11
+ return out || fallback;
12
+ };
13
+ /** `01--new-request` - index-prefixed so directory order matches pipe order. */
14
+ export const indexed = (i, name) => `${String(Math.trunc(i)).padStart(2, '0')}--${slug(name)}`;
15
+ /** Disambiguate repeated slugs deterministically: name, name-2, name-3. */
16
+ export const uniquify = () => {
17
+ const seen = new Map();
18
+ return (s) => {
19
+ const n = (seen.get(s) ?? 0) + 1;
20
+ seen.set(s, n);
21
+ return n === 1 ? s : `${s}-${n}`;
22
+ };
23
+ };