@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
@@ -0,0 +1,149 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { mkdirp, exists, writeJson, readJson } from "../util/fsx.js";
4
+ import { flowBaselineFile } from "./layout.js";
5
+ import { slug } from "../util/slug.js";
6
+ import { canonicalString } from "../util/json.js";
7
+ import { unlinkFlow, linkFlow } from "../codec/flow.js";
8
+ /**
9
+ * The `flows/` half of a workspace.
10
+ *
11
+ * A pipe's iPaaS flows are entities of that pipe — the pipe's own webhook URLs
12
+ * carry their ids — so they live beside `pipes/`, read by the same pull and
13
+ * written by the same apply.
14
+ *
15
+ * pipes/<id>--<name>/flows/
16
+ * _meta.json coverage, projectId, counts, read time
17
+ * connections.json externalId, pieceName, displayName, status
18
+ * pieces.json pieceName -> version the instance offers
19
+ * _readonly/flow-runs.json recent outcomes; never written back
20
+ * <flowId>--<slug>/
21
+ * flow.json the flow row
22
+ * version.json the version row, without the trigger
23
+ * nodes/NN--<name>.json one node per file, in execution order
24
+ *
25
+ * Coverage matters here as much as anywhere: a pipe whose iPaaS could not be
26
+ * reached gets `unknown`, which is never read as "this pipe has no flows".
27
+ */
28
+ export const FLOWS_DIR = 'flows';
29
+ /**
30
+ * The flows directory of ONE repo. Under the repo, not at the workspace root:
31
+ * a workspace can hold several pipes, each with its own iPaaS workspace, and a
32
+ * shared directory meant the second pipe's pull deleted the first pipe's flows.
33
+ */
34
+ export const flowsRoot = (repoDir) => join(repoDir, FLOWS_DIR);
35
+ export const flowDirName = (tree) => {
36
+ const id = tree.flow['id'] ? String(tree.flow['id']) : '_new';
37
+ return `${id}--${slug(String(tree.version['displayName'] ?? 'flow'))}`;
38
+ };
39
+ /** The empty section, for a pipe with no iPaaS or one that could not be read. */
40
+ export const unknownFlows = (reason) => ({
41
+ meta: { coverage: 'unknown', readAt: new Date().toISOString(), reason, notes: [reason] },
42
+ flows: [],
43
+ connections: [],
44
+ pieces: {},
45
+ runs: [],
46
+ });
47
+ // ── write ───────────────────────────────────────────────────────────────────
48
+ export const writeFlows = async (workspace, section) => {
49
+ const root = flowsRoot(workspace);
50
+ /**
51
+ * Rewritten wholesale rather than merged: a flow deleted upstream has to
52
+ * disappear from the workspace too, and a leftover directory would be read as a
53
+ * flow the author wants created.
54
+ */
55
+ if (await exists(root))
56
+ await fs.rm(root, { recursive: true, force: true });
57
+ await mkdirp(join(root, '_readonly'));
58
+ const written = [];
59
+ const put = async (path, value) => {
60
+ await writeJson(path, value);
61
+ written.push(path);
62
+ };
63
+ await put(join(root, '_meta.json'), section.meta);
64
+ await put(join(root, 'connections.json'), section.connections);
65
+ await put(join(root, 'pieces.json'), section.pieces);
66
+ await put(join(root, '_readonly', 'flow-runs.json'), section.runs);
67
+ for (const tree of section.flows) {
68
+ const dir = join(root, flowDirName(tree));
69
+ await mkdirp(join(dir, 'nodes'));
70
+ await put(join(dir, 'flow.json'), tree.flow);
71
+ await put(join(dir, 'version.json'), tree.version);
72
+ for (const [i, node] of tree.nodes.entries()) {
73
+ await put(join(dir, 'nodes', `${String(i).padStart(2, '0')}--${node.name}.json`), node);
74
+ }
75
+ }
76
+ return written;
77
+ };
78
+ // ── read ────────────────────────────────────────────────────────────────────
79
+ export const readFlows = async (workspace) => {
80
+ const root = flowsRoot(workspace);
81
+ if (!(await exists(root)))
82
+ return null;
83
+ const meta = (await readJson(join(root, '_meta.json')).catch(() => null)) ?? {
84
+ coverage: 'unknown',
85
+ readAt: new Date().toISOString(),
86
+ notes: ['flows/_meta.json is missing, so coverage is unknown'],
87
+ };
88
+ const connections = (await readJson(join(root, 'connections.json')).catch(() => [])) ?? [];
89
+ const pieces = (await readJson(join(root, 'pieces.json')).catch(() => ({}))) ?? {};
90
+ const runs = (await readJson(join(root, '_readonly', 'flow-runs.json')).catch(() => [])) ?? [];
91
+ const flows = [];
92
+ for (const entry of await fs.readdir(root, { withFileTypes: true })) {
93
+ if (!entry.isDirectory())
94
+ continue;
95
+ /**
96
+ * Only the tool's own metadata directories are skipped, not everything with a
97
+ * leading underscore.
98
+ *
99
+ * `flowDirName` names a flow that does not exist yet `_new--<slug>`, so a
100
+ * blanket `startsWith('_')` filter made an authored flow invisible to the
101
+ * reader that produced it — the diff reported "no changes" over a whole flow
102
+ * waiting to be created.
103
+ */
104
+ if (entry.name === '_readonly')
105
+ continue;
106
+ const dir = join(root, entry.name);
107
+ const flow = await readJson(join(dir, 'flow.json'));
108
+ const version = await readJson(join(dir, 'version.json'));
109
+ const nodeDir = join(dir, 'nodes');
110
+ const files = (await fs.readdir(nodeDir)).filter((f) => f.endsWith('.json')).sort();
111
+ const nodes = [];
112
+ for (const f of files) {
113
+ const node = await readJson(join(nodeDir, f));
114
+ nodes.push({ ...node, name: String(node['name'] ?? ''), type: String(node['type'] ?? '') });
115
+ }
116
+ /**
117
+ * Order comes from the filename prefix, which is how the codec wrote it — but
118
+ * the trigger must be first for `linkFlow` to find the head of the chain, and
119
+ * a rename could put something else there.
120
+ */
121
+ const triggerAt = nodes.findIndex((n) => n.name === 'trigger');
122
+ if (triggerAt > 0)
123
+ nodes.unshift(...nodes.splice(triggerAt, 1));
124
+ flows.push({ flow, version, nodes });
125
+ }
126
+ return { meta, flows, connections, pieces, runs };
127
+ };
128
+ /**
129
+ * Record what was just read as the baseline, so the next diff has a past to
130
+ * compare against without touching the network.
131
+ */
132
+ export const writeFlowBaseline = (workspaceRoot, repoId, section) => writeJson(flowBaselineFile(workspaceRoot, repoId), section);
133
+ /** The recorded flow baseline, or null when this repo has never been pulled. */
134
+ export const readFlowBaseline = async (workspaceRoot, repoId) => {
135
+ try {
136
+ return await readJson(flowBaselineFile(workspaceRoot, repoId));
137
+ }
138
+ catch {
139
+ return null;
140
+ }
141
+ };
142
+ /** The flow row as the API would return it, for comparison and for operations. */
143
+ export const asApiFlow = (tree) => linkFlow(tree);
144
+ /** Whether two flow trees are the same, ignoring `_` metadata differences in order. */
145
+ export const sameFlow = (a, b) => canonicalString(linkFlow(a)) === canonicalString(linkFlow(b));
146
+ export const flowIdOf = (tree) => tree.flow['id'] ? String(tree.flow['id']) : null;
147
+ export const isNewFlow = (tree) => !flowIdOf(tree) || tree.flow['_new'] === true;
148
+ /** Rebuild a tree from an API flow row, for the read path and for verification. */
149
+ export const treeFromApi = (row) => unlinkFlow(row);
@@ -0,0 +1,146 @@
1
+ import { join } from 'node:path';
2
+ import { slug } from "../util/slug.js";
3
+ /**
4
+ * Workspace layout — PLAN.md §4.
5
+ *
6
+ * Two deliberate deviations from the plan's sketch, both to make the edit loop
7
+ * shorter, and both noted in the generated AGENTS.md:
8
+ *
9
+ * - `phase-jumps.json` is gone. A phase's jump set lives in the phase file as
10
+ * `jump_target_ids`, because the internal endpoint is per-phase and
11
+ * set-replacing (PLAN.md §2.1), so the set is the honest shape and there is
12
+ * no second file to keep consistent.
13
+ * - `webhooks.json` is one array file rather than a directory, matching
14
+ * `labels.json`. Webhooks are few and have no sub-entities.
15
+ */
16
+ export const PPC_DIR = '.ppc';
17
+ export const paths = (root) => ({
18
+ root,
19
+ workspaceFile: join(root, 'workspace.json'),
20
+ /**
21
+ * `AGENTS.md`, not `CLAUDE.md`: the emerging cross-tool convention (this
22
+ * repo's own top-level docs already use it) — the file is read by whatever
23
+ * agent is editing the workspace, not only Claude Code.
24
+ */
25
+ agentsMd: join(root, 'AGENTS.md'),
26
+ mappingMd: join(root, 'MAPPING.md'),
27
+ capabilitiesMd: join(root, 'CAPABILITIES.md'),
28
+ referenceMd: join(root, 'REFERENCE.md'),
29
+ pipe: join(root, PPC_DIR),
30
+ lock: join(root, PPC_DIR, 'lock.json'),
31
+ capabilities: join(root, PPC_DIR, 'capabilities.json'),
32
+ /**
33
+ * The closed sets the API publishes, read on the last pull. Cached so
34
+ * validate, plan and apply can refuse a wrong value offline and name it.
35
+ */
36
+ reference: join(root, PPC_DIR, 'reference.json'),
37
+ baselineDir: join(root, PPC_DIR, 'baseline'),
38
+ plansDir: join(root, PPC_DIR, 'plans'),
39
+ pipesDir: join(root, 'pipes'),
40
+ databasesDir: join(root, 'databases'),
41
+ gitignore: join(root, '.gitignore'),
42
+ });
43
+ /**
44
+ * `pipes/<numericId>--<slug>` / `databases/<internalId>--<apiId>--<slug>`.
45
+ *
46
+ * A database carries two identities — a numeric internal id and an api id — and
47
+ * `table(id:)` answers to both. Putting both in the directory name is what makes
48
+ * the duality visible rather than a footnote: whichever spelling you pull with,
49
+ * you land in the same directory, and the one you did not use is right there when
50
+ * you need to cross-reference a connector field (which carries the numeric form)
51
+ * against an API response (which carries the slug).
52
+ */
53
+ export const repoDirName = (ref) => ref.kind === 'table' && ref.apiId && ref.apiId !== String(ref.id)
54
+ ? `${ref.id}--${ref.apiId}--${slug(ref.name)}`
55
+ : `${ref.id}--${slug(ref.name)}`;
56
+ export const repoDir = (root, ref) => join(ref.kind === 'table' ? paths(root).databasesDir : paths(root).pipesDir, repoDirName(ref));
57
+ /**
58
+ * The files of one repo.
59
+ *
60
+ * `kind` decides one name: a database's root row is `table.json`, not
61
+ * `pipe.json`. It is worth the parameter because a file called `pipe.json`
62
+ * inside `databases/` teaches a reader something false about the platform, and
63
+ * this workspace is read by agents that have no other source of truth.
64
+ *
65
+ * Required rather than defaulted: a caller who forgets would silently get pipe
66
+ * paths for a database and a mysteriously absent root file.
67
+ */
68
+ export const repoPaths = (dir, kind = 'pipe') => ({
69
+ dir,
70
+ meta: join(dir, '_meta.json'),
71
+ repo: join(dir, kind === 'table' ? 'table.json' : 'pipe.json'),
72
+ /** Both spellings, so a directory can be recognised before its kind is known. */
73
+ repoCandidates: [join(dir, 'pipe.json'), join(dir, 'table.json')],
74
+ phasesDir: join(dir, 'phases'),
75
+ automationsDir: join(dir, 'automations'),
76
+ fieldConditionsDir: join(dir, 'field-conditions'),
77
+ labels: join(dir, 'labels.json'),
78
+ webhooks: join(dir, 'webhooks.json'),
79
+ publicForm: join(dir, 'public-form.json'),
80
+ preferences: join(dir, 'preferences.json'),
81
+ relations: join(dir, 'relations.json'),
82
+ readonlyDir: join(dir, '_readonly'),
83
+ agentsDir: join(dir, 'agents'),
84
+ });
85
+ /** Baseline is stored as the payload envelope: one file, and diff-ready. */
86
+ export const baselineFile = (root, repoId) => join(paths(root).baselineDir, `${repoId}.json`);
87
+ /**
88
+ * The agent baseline: what a repo's AI agents looked like at the last pull.
89
+ *
90
+ * Stored separately from the payload envelope for the same reason flows are:
91
+ * agents are not in it, they come from a different query, keyed by the pipe's
92
+ * uuid rather than its id (src/pipefy/agents.ts).
93
+ */
94
+ export const agentBaselineFile = (root, repoId) => join(paths(root).baselineDir, `${repoId}.agents.json`);
95
+ /**
96
+ * The flow baseline: what a repo's iPaaS flows looked like at the last pull.
97
+ *
98
+ * Stored separately from the payload envelope because flows are not in it —
99
+ * they come from a different endpoint. Without this the flow diff had no
100
+ * recorded past to compare against and had to re-read the pipe live every time,
101
+ * which meant no offline diff and no record of what was pulled.
102
+ */
103
+ export const flowBaselineFile = (root, repoId) => join(paths(root).baselineDir, `${repoId}.flows.json`);
104
+ export const planFile = (root, planId) => join(paths(root).plansDir, `${planId}.json`);
105
+ /**
106
+ * How a finished plan is named.
107
+ *
108
+ * A plan file is the record `--resume` replays, and it is written before the
109
+ * first request and rewritten after every step, so a crash mid-apply is
110
+ * resumable. That made `.ppc/plans/` a list of filenames that all look alike,
111
+ * with no way to tell an applied plan from one still waiting — the outcome was
112
+ * only inside the JSON, or via `pipe status`.
113
+ *
114
+ * So the outcome goes in the name once the apply is over. **No suffix means it
115
+ * was never applied**, which is the state a bare `pipe plan` leaves behind.
116
+ * The plan id does not change, so every `--resume <id>` instruction still works.
117
+ */
118
+ export const PLAN_SUFFIX = {
119
+ applied: '--applied',
120
+ 'partially-applied': '--partial',
121
+ failed: '--failed',
122
+ };
123
+ export const planFileFor = (root, planId, status) => join(paths(root).plansDir, `${planId}${PLAN_SUFFIX[status] ?? ''}.json`);
124
+ /**
125
+ * The file holding a plan, whatever it was renamed to. Returns null if there is
126
+ * none — the caller decides whether that is an error.
127
+ */
128
+ export const findPlanFile = async (root, planId) => {
129
+ const { access } = await import('node:fs/promises');
130
+ const candidates = [planFile(root, planId), ...Object.values(PLAN_SUFFIX).map((sfx) => join(paths(root).plansDir, `${planId}${sfx}.json`))];
131
+ for (const c of candidates) {
132
+ try {
133
+ await access(c);
134
+ return c;
135
+ }
136
+ catch {
137
+ // try the next spelling
138
+ }
139
+ }
140
+ return null;
141
+ };
142
+ /** Paths under a repo dir that must never be edited — checked by path alone. */
143
+ export const isReadonlyPath = (relPath) => {
144
+ const p = relPath.replace(/\\/g, '/');
145
+ return p.includes('/_readonly/') || p.startsWith('_readonly/');
146
+ };
@@ -0,0 +1,83 @@
1
+ import { relative } from 'node:path';
2
+ import { exists, readJson, writeJson } from "../util/fsx.js";
3
+ import { baselineFile, paths, repoDir } from "./layout.js";
4
+ export const emptyLock = (rootRepoId, organizationId, depth) => ({
5
+ version: 1,
6
+ updatedAt: new Date().toISOString(),
7
+ rootRepoId,
8
+ organizationId,
9
+ depth,
10
+ repos: [],
11
+ edges: [],
12
+ });
13
+ export const readLock = async (root) => {
14
+ const p = paths(root).lock;
15
+ if (!(await exists(p)))
16
+ return null;
17
+ return readJson(p);
18
+ };
19
+ export const writeLock = async (root, lock) => {
20
+ lock.updatedAt = new Date().toISOString();
21
+ await writeJson(paths(root).lock, lock);
22
+ };
23
+ const identityFor = (tree) => {
24
+ const grab = (rows) => {
25
+ const out = {};
26
+ for (const r of rows) {
27
+ const uuid = r['uuid'];
28
+ const id = Number(r['id']);
29
+ if (typeof uuid === 'string' && uuid && Number.isFinite(id))
30
+ out[uuid] = id;
31
+ }
32
+ return out;
33
+ };
34
+ return {
35
+ phases: grab(tree.phases),
36
+ fields: grab(tree.phases.flatMap((p) => p.fields)),
37
+ automations: grab(tree.automations),
38
+ labels: grab(tree.labels),
39
+ };
40
+ };
41
+ export const upsertRepo = (lock, ref, root, tree) => {
42
+ const entry = {
43
+ kind: ref.kind,
44
+ id: ref.id,
45
+ apiId: ref.apiId,
46
+ uuid: tree.repo['uuid'] ?? ref.uuid ?? null,
47
+ name: tree.repo['name'] ?? ref.name ?? null,
48
+ dir: relative(root, repoDir(root, ref)).replace(/\\/g, '/'),
49
+ via: ref.via,
50
+ depth: ref.depth,
51
+ source: tree.meta.source,
52
+ versionId: tree.meta.versionId ?? null,
53
+ readAt: tree.meta.readAt,
54
+ coverage: tree.meta.coverage,
55
+ identity: identityFor(tree),
56
+ fieldSlugs: Object.fromEntries(tree.phases
57
+ .flatMap((p) => p.fields)
58
+ .filter((f) => f['slug'])
59
+ .map((f) => [String(f['id']), String(f['slug'])])),
60
+ };
61
+ const i = lock.repos.findIndex((r) => r.id === ref.id);
62
+ if (i >= 0)
63
+ lock.repos[i] = entry;
64
+ else
65
+ lock.repos.push(entry);
66
+ return entry;
67
+ };
68
+ export const lookupRepo = (lock, idOrName) => {
69
+ const byId = lock.repos.find((r) => String(r.id) === idOrName || r.apiId === idOrName);
70
+ if (byId)
71
+ return byId;
72
+ const needle = idOrName.toLowerCase();
73
+ return lock.repos.find((r) => (r.name ?? '').toLowerCase() === needle || r.dir.toLowerCase().includes(needle));
74
+ };
75
+ export const rootRepo = (lock) => lock.repos.find((r) => r.id === lock.rootRepoId);
76
+ export const baselinePathFor = (root, repoId) => baselineFile(root, repoId);
77
+ export const readWorkspace = async (root) => {
78
+ const p = paths(root).workspaceFile;
79
+ if (!(await exists(p)))
80
+ return null;
81
+ return readJson(p);
82
+ };
83
+ export const writeWorkspace = (root, w) => writeJson(paths(root).workspaceFile, w);
@@ -0,0 +1,168 @@
1
+ import { basename, dirname } from 'node:path';
2
+ import { exists, listJson, readJson } from "../util/fsx.js";
3
+ import { UserError } from "../util/log.js";
4
+ import { fullCoverage } from "../model/payload.js";
5
+ import { repoPaths } from "./layout.js";
6
+ import { AGENT_DIR_TAG } from "./agents.js";
7
+ /**
8
+ * files → tree. The exact inverse of write.ts, so that
9
+ * `readTree(writeTree(t)) ≡ t` and the edited workspace can be diffed against
10
+ * the baseline with no special cases.
11
+ *
12
+ * Ordering never comes from filenames: the index-prefixed names are for humans,
13
+ * and `index` inside the file is authoritative. A phase Claude renumbered by
14
+ * editing `index` therefore works without renaming any file.
15
+ */
16
+ const num = (v) => {
17
+ const n = Number(v);
18
+ return Number.isFinite(n) ? n : 0;
19
+ };
20
+ /**
21
+ * An id, or the placeholder that stands for one, or `null` — a real jump
22
+ * target state (a jump slot enabled with no destination phase chosen), not
23
+ * an absence. Must be checked before `Number(v)`: `Number(null)` is `0`, a
24
+ * finite number, which would otherwise turn a null jump into a jump to
25
+ * phase 0 — the exact failure mode this function exists to avoid for typos
26
+ * and placeholders.
27
+ *
28
+ * `num` collapses anything non-numeric to 0, which is right for an index and
29
+ * catastrophic for an id list: a phase jumping to a phase created in the same
30
+ * edit is written `"%{_new:<uuid>}"`, exactly as the generated AGENTS.md
31
+ * instructs, and `num` turned that into a jump to phase 0 — accepted by the
32
+ * validator, sent to the API, and impossible to explain. A typo met the same
33
+ * fate. Non-numeric values are kept verbatim so the validator can judge them.
34
+ */
35
+ const idOrPlaceholder = (v) => {
36
+ if (v === null)
37
+ return null;
38
+ const n = Number(v);
39
+ if (Number.isFinite(n) && v !== '')
40
+ return n;
41
+ return String(v);
42
+ };
43
+ const readArrayFile = async (path) => {
44
+ if (!(await exists(path)))
45
+ return [];
46
+ const v = await readJson(path);
47
+ if (v === null)
48
+ return [];
49
+ if (!Array.isArray(v))
50
+ throw new UserError(`${path}: expected a JSON array`);
51
+ return v;
52
+ };
53
+ const readObjectFile = async (path) => {
54
+ if (!(await exists(path)))
55
+ return null;
56
+ const v = await readJson(path);
57
+ if (v === null)
58
+ return null;
59
+ if (typeof v !== 'object' || Array.isArray(v))
60
+ throw new UserError(`${path}: expected a JSON object or null`);
61
+ return v;
62
+ };
63
+ const readDirOf = async (dir) => {
64
+ const files = (await listJson(dir)).filter((f) => basename(f) !== '_meta.json');
65
+ const out = [];
66
+ for (const f of files)
67
+ out.push({ path: f, value: await readJson(f) });
68
+ return out;
69
+ };
70
+ export const readTree = async (dir) => {
71
+ /**
72
+ * The kind comes from `_meta.json`, which is written first and whose path does
73
+ * not depend on the kind. Falling back to probing both spellings keeps a
74
+ * hand-made directory readable.
75
+ */
76
+ const meta = ((await readObjectFile(repoPaths(dir).meta)) ?? {});
77
+ const p = repoPaths(dir, meta.repoKind === 'table' ? 'table' : 'pipe');
78
+ let repoFile = p.repo;
79
+ if (!(await exists(repoFile))) {
80
+ const found = [];
81
+ for (const c of p.repoCandidates)
82
+ if (await exists(c))
83
+ found.push(c);
84
+ if (!found.length) {
85
+ throw new UserError(`${dir} is not a pulled repo directory (no pipe.json or table.json)`);
86
+ }
87
+ repoFile = found[0];
88
+ }
89
+ const repo = (await readObjectFile(repoFile)) ?? {};
90
+ const phaseFiles = await readDirOf(p.phasesDir);
91
+ const phases = phaseFiles
92
+ .map(({ path, value }) => {
93
+ if (!value || typeof value !== 'object')
94
+ throw new UserError(`${path}: expected a phase object`);
95
+ return {
96
+ ...value,
97
+ fields: Array.isArray(value.fields) ? value.fields : [],
98
+ jump_target_ids: Array.isArray(value.jump_target_ids) ? value.jump_target_ids.map(idOrPlaceholder) : [],
99
+ team_member_ids: Array.isArray(value.team_member_ids) ? value.team_member_ids.map(idOrPlaceholder) : [],
100
+ };
101
+ })
102
+ .sort((a, b) => num(a['index']) - num(b['index']) || num(a['id']) - num(b['id']));
103
+ const normaliseAutomation = (path, value) => {
104
+ if (!value || typeof value !== 'object')
105
+ throw new UserError(`${path}: expected an automation object`);
106
+ const v = value;
107
+ return {
108
+ ...v,
109
+ condition: v.condition ?? null,
110
+ field_maps: Array.isArray(v.field_maps) ? v.field_maps : [],
111
+ distribute_assignments: Array.isArray(v.distribute_assignments) ? v.distribute_assignments : [],
112
+ response_schemas: Array.isArray(v.response_schemas) ? v.response_schemas : [],
113
+ };
114
+ };
115
+ const automations = (await readDirOf(p.automationsDir)).map(({ path, value }) => normaliseAutomation(path, value));
116
+ /**
117
+ * A behavior — an automation folded under its agent's `behaviors/` instead
118
+ * of `automations/`, per workspace/agents.ts. `_agent_dir` is not read from
119
+ * the file (it is never written there); it is derived from where the file
120
+ * physically is, which is the only record of the link there is.
121
+ */
122
+ for (const path of await listJson(p.agentsDir)) {
123
+ if (basename(dirname(path)) !== 'behaviors')
124
+ continue;
125
+ const agentDir = basename(dirname(dirname(path)));
126
+ const value = await readJson(path);
127
+ automations.push({ ...normaliseAutomation(path, value), [AGENT_DIR_TAG]: agentDir });
128
+ }
129
+ const field_conditions = (await readDirOf(p.fieldConditionsDir))
130
+ .map(({ path, value }) => {
131
+ if (!value || typeof value !== 'object')
132
+ throw new UserError(`${path}: expected a field condition object`);
133
+ return {
134
+ ...value,
135
+ actions: Array.isArray(value.actions) ? value.actions : [],
136
+ condition: value.condition ?? null,
137
+ };
138
+ })
139
+ .sort((a, b) => num(a['index']) - num(b['index']) || num(a['id']) - num(b['id']));
140
+ const relationsFile = (await readObjectFile(p.relations)) ?? {};
141
+ const pipe_relations = Array.isArray(relationsFile['pipe_relations']) ? relationsFile['pipe_relations'] : [];
142
+ return {
143
+ meta: {
144
+ source: meta.source ?? 'reconstructed',
145
+ versionId: meta.versionId ?? null,
146
+ readAt: meta.readAt ?? new Date().toISOString(),
147
+ repoKind: meta.repoKind ?? 'pipe',
148
+ coverage: meta.coverage ?? fullCoverage('unknown'),
149
+ repo_columns: meta.repo_columns ?? 'complete',
150
+ notes: meta.notes ?? [],
151
+ },
152
+ repo,
153
+ phases,
154
+ automations,
155
+ field_conditions,
156
+ labels: await readArrayFile(p.labels),
157
+ webhooks: await readArrayFile(p.webhooks),
158
+ pipe_relations,
159
+ public_form: await readObjectFile(p.publicForm),
160
+ preferences: await readObjectFile(p.preferences),
161
+ readonly: {
162
+ email_templates: await readArrayFile(`${p.readonlyDir}/email-templates.json`),
163
+ email_inboxes: await readArrayFile(`${p.readonlyDir}/email-inboxes.json`),
164
+ visibilities: await readArrayFile(`${p.readonlyDir}/visibilities.json`),
165
+ field_maps: await readArrayFile(`${p.readonlyDir}/field-maps.json`),
166
+ },
167
+ };
168
+ };
@@ -0,0 +1,24 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { paths } from "./layout.js";
3
+ import { debug } from "../util/log.js";
4
+ /**
5
+ * Load the cached closed sets, or null.
6
+ *
7
+ * Null on anything: no file, unreadable, malformed. Every consumer treats null
8
+ * as "these checks are skipped" and never as "this value is fine", so a
9
+ * workspace pulled by an older build loses the check rather than gaining a
10
+ * wrong answer.
11
+ */
12
+ export const loadReference = async (root) => {
13
+ try {
14
+ const raw = await readFile(paths(root).reference, 'utf8');
15
+ const ref = JSON.parse(raw);
16
+ if (!ref || typeof ref !== 'object' || !Array.isArray(ref.events))
17
+ return null;
18
+ return ref;
19
+ }
20
+ catch (e) {
21
+ debug(`no cached reference: ${e.message}`);
22
+ return null;
23
+ }
24
+ };