@cohortapp/agent-sdk 2.5.1 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/bin/maestro.mjs +185 -88
  2. package/bin/maestro.test.mjs +175 -48
  3. package/docs/runbooks/backup-restore.md +65 -33
  4. package/framework-features.json +4 -4
  5. package/lib/backup/policy.mjs +710 -0
  6. package/lib/backup/policy.test.mjs +305 -0
  7. package/lib/budget-escalate.mjs +133 -0
  8. package/lib/budget-escalate.test.mjs +232 -0
  9. package/lib/budget-guard.envelope.test.mjs +476 -0
  10. package/lib/budget-guard.mjs +853 -75
  11. package/lib/budget-guard.test.mjs +91 -42
  12. package/lib/cadences.mjs +33 -0
  13. package/lib/channels/orgmail/adapter.mjs +88 -3
  14. package/lib/channels/orgmail/adapter.test.mjs +137 -0
  15. package/lib/channels/repeat-suppressor.mjs +198 -0
  16. package/lib/channels/repeat-suppressor.test.mjs +134 -0
  17. package/lib/comms/receipts.mjs +297 -0
  18. package/lib/cost/ledger-row.mjs +333 -0
  19. package/lib/cost/ledger-row.test.mjs +183 -0
  20. package/lib/execution/drive.mjs +28 -1
  21. package/lib/execution/effects.mjs +191 -12
  22. package/lib/execution/effects.test.mjs +50 -11
  23. package/lib/goals/admission.mjs +13 -1
  24. package/lib/goals/admission.test.mjs +26 -1
  25. package/lib/goals/loop.mjs +13 -0
  26. package/lib/kpi-sensors.test.mjs +3 -0
  27. package/lib/mandate/cache.mjs +13 -5
  28. package/lib/mandate/derive.mjs +146 -21
  29. package/lib/mandate/derive.test.mjs +50 -6
  30. package/lib/mandate/model.mjs +32 -4
  31. package/lib/mandate/refresh.test.mjs +16 -2
  32. package/lib/mcp/server.test.mjs +12 -3
  33. package/lib/model-router/economics.mjs +107 -76
  34. package/lib/model-router/economics.test.mjs +64 -46
  35. package/lib/model-router/integration-coverage.test.mjs +39 -37
  36. package/lib/model-router/ledger.mjs +75 -22
  37. package/lib/model-router/ledger.test.mjs +35 -2
  38. package/lib/org/client.mjs +14 -0
  39. package/lib/org/cost-sync.mjs +16 -2
  40. package/lib/org/doctor.mjs +62 -1
  41. package/lib/org/doctor.test.mjs +36 -3
  42. package/lib/org/email-remedy.mjs +49 -0
  43. package/lib/org/engagement-ledger.mjs +376 -0
  44. package/lib/org/engagement-ledger.test.mjs +112 -0
  45. package/lib/org/engagement.mjs +1056 -0
  46. package/lib/org/engagement.test.mjs +739 -0
  47. package/lib/org/messaging.mjs +230 -3
  48. package/lib/org/messaging.test.mjs +110 -1
  49. package/lib/org/param-contract.mjs +56 -2
  50. package/lib/org/param-contract.test.mjs +26 -0
  51. package/lib/org/protocol.checksum +1 -1
  52. package/lib/org/protocol.mjs +5 -0
  53. package/lib/org/protocol.test.mjs +7 -1
  54. package/lib/org/tool-surface.mjs +506 -10
  55. package/lib/org/tool-surface.test.mjs +191 -7
  56. package/lib/org/ui-parity.mjs +333 -6
  57. package/lib/org/ui-parity.test.mjs +96 -3
  58. package/lib/org/work-ledger.mjs +241 -0
  59. package/lib/org/work-ledger.test.mjs +237 -0
  60. package/lib/plan/adoption-e2e.test.mjs +366 -0
  61. package/lib/plan/budget-enforcement.test.mjs +400 -0
  62. package/lib/plan/budget-runtime.mjs +215 -0
  63. package/lib/plan/compile.mjs +201 -5
  64. package/lib/plan/compile.test.mjs +19 -5
  65. package/lib/plan/emit.mjs +8 -0
  66. package/lib/plan/emit.test.mjs +18 -0
  67. package/lib/resource-governor.mjs +58 -12
  68. package/lib/resource-governor.test.mjs +41 -1
  69. package/lib/security/audit-engine.mjs +45 -8
  70. package/lib/security/audit-engine.test.mjs +35 -0
  71. package/lib/setup/enroll-from-cohort.mjs +14 -1
  72. package/lib/setup/sections/mandate.mjs +48 -7
  73. package/lib/setup/sections/mandate.test.mjs +17 -2
  74. package/lib/setup/sections/orgmail.mjs +10 -2
  75. package/lib/setup/state.mjs +83 -2
  76. package/lib/telemetry/collect.mjs +360 -20
  77. package/lib/telemetry/collect.test.mjs +266 -0
  78. package/package.json +1 -1
  79. package/scripts/cost/track-claude-usage.mjs +207 -48
  80. package/scripts/cost/track-claude-usage.test.mjs +148 -0
  81. package/scripts/daemon/agent-daemon.mjs +315 -17
  82. package/scripts/daemon/assurance-e2e.test.mjs +421 -0
  83. package/scripts/daemon/assurance.mjs +944 -0
  84. package/scripts/daemon/assurance.test.mjs +668 -0
  85. package/scripts/daemon/cadence-consumer-governance.test.mjs +56 -0
  86. package/scripts/daemon/cadence-consumer.mjs +147 -9
  87. package/scripts/daemon/cadence-consumer.test.mjs +6 -0
  88. package/scripts/daemon/cadence-handlers.mjs +158 -0
  89. package/scripts/daemon/cadence-handlers.test.mjs +64 -0
  90. package/scripts/daemon/deliver.mjs +314 -0
  91. package/scripts/daemon/dispatcher-governance.test.mjs +10 -0
  92. package/scripts/daemon/dispatcher.mjs +64 -6
  93. package/scripts/daemon/responder-cost.test.mjs +68 -0
  94. package/scripts/daemon/responder.mjs +351 -298
  95. package/scripts/local-triggers/generate-plists.test.mjs +7 -4
  96. package/scripts/maintenance/backup-run.mjs +415 -0
  97. package/scripts/maintenance/backup-to-cloud.sh +16 -116
  98. package/scripts/org/send-orgmail.mjs +16 -0
  99. package/scripts/record-receipt.sh +63 -0
  100. package/scripts/restore-from-backup.sh +14 -3
  101. package/scripts/restore-from-backup.test.mjs +8 -5
  102. package/scripts/send-email-threaded.py +47 -0
  103. package/scripts/send-sms.sh +4 -0
  104. package/scripts/send-whatsapp.sh +4 -0
  105. package/scripts/setup/init-backup.mjs +93 -38
  106. package/scripts/slack-send.sh +12 -0
@@ -218,9 +218,19 @@ export function mapProfileToAgentConfig(profile) {
218
218
  }
219
219
 
220
220
  /**
221
- * Map a CharterDTO ({ approvedByLine, gatesText, sections:[{kind,order,title,body}] })
221
+ * Map a CharterDTO ({ approvedByLine, gatesText, sections:[{id,kind,order,title,body}] })
222
222
  * into the persisted charter subset. Returns null when there is no real charter
223
223
  * (no sections AND no approvedBy/gates text) so an empty patch stays empty.
224
+ *
225
+ * `id` IS CARRIED, and it is load-bearing. This projection is the ONLY writer of
226
+ * `config/agent.json` charter.sections, and it used to drop the field — so every
227
+ * section on every enrolled seat had keys `[body,kind,order,title]` only,
228
+ * `lib/mandate/derive.mjs#charterSectionId` read `s.id` as undefined, and every
229
+ * proposal reached hq with null charter provenance. hq's `propose` gate then
230
+ * refuses the whole slate for "traces to no CharterSection", which reads as a
231
+ * governance decision and is actually a dropped column three modules upstream.
232
+ * The id is hq's `CharterSection.id`; it is a join key, not content.
233
+ *
224
234
  * @param {object|null} charter
225
235
  * @returns {object|null}
226
236
  */
@@ -230,6 +240,9 @@ function mapCharter(charter) {
230
240
  const sections = sectionsIn
231
241
  .filter((s) => s && typeof s === "object")
232
242
  .map((s) => ({
243
+ // Omitted rather than nulled when hq did not send one, so a charter fetched
244
+ // from an older hq stays byte-identical to what it was before.
245
+ ...(str(s.id) ? { id: str(s.id) } : {}),
233
246
  kind: str(s.kind),
234
247
  order: Number.isFinite(s.order) ? s.order : 0,
235
248
  title: str(s.title) || null,
@@ -198,19 +198,54 @@ export async function resolveMandate(o = {}, deps = {}) {
198
198
  // call adopt, from anywhere, for any reason.
199
199
  const proposals = res.source === "local" ? allObjectives(res.record) : [];
200
200
  if (typeof call === "function" && proposals.length) {
201
+ // PILLARS FIRST, then their children. hq's `mandate.propose` takes
202
+ // `parentId` and `charterSectionId` — real row ids — and has never had any
203
+ // idea what a `parentKey` is. This loop used to post `parentKey`, which the
204
+ // dispatcher discarded as an unknown param, so EVERY proposal reached hq
205
+ // with `parentId: null` AND `charterSectionId: null`. That is the whole
206
+ // reason "0 of 35 adopted" looked like a missing human click: all 35 rows
207
+ // were unadoptable on arrival, and nothing on either side said so.
208
+ const order = { PILLAR: 0, OBJECTIVE: 1, GOAL: 2 };
209
+ const ordered = proposals
210
+ .slice()
211
+ .sort((a, b) => (order[String(a.kind).toUpperCase()] ?? 3) - (order[String(b.kind).toUpperCase()] ?? 3));
212
+ /** local key → the row id hq assigned it, so children can cite a real parent. */
213
+ const idByKey = new Map();
201
214
  let posted = 0;
202
- for (const obj of proposals) {
215
+ const refusals = [];
216
+ for (const obj of ordered) {
217
+ const parentId = obj.parentKey ? idByKey.get(obj.parentKey) || null : null;
218
+ if (obj.parentKey && !parentId) {
219
+ // The parent pillar itself failed to post. Proposing the child anyway
220
+ // would recreate the exact defect above.
221
+ refusals.push(`${obj.key}: its parent "${obj.parentKey}" is not on the server`);
222
+ continue;
223
+ }
203
224
  try {
204
225
  const r = await call("mandate.propose", {
205
226
  key: obj.key, kind: obj.kind, text: obj.text, metric: obj.metric, unit: obj.unit,
206
227
  direction: obj.direction, target: obj.target, tolerance: obj.tolerance,
207
- cadence: obj.cadence, sensor: obj.sensor, parentKey: obj.parentKey,
228
+ cadence: obj.cadence, sensor: obj.sensor,
229
+ parentId,
230
+ charterSectionId: obj.charterSectionId || null,
208
231
  });
209
- if (r && r.ok) posted++;
210
- } catch { /* a per-objective failure must not abort the rest */ }
232
+ if (r && r.ok) {
233
+ posted++;
234
+ const id = (r.result && r.result.id) || r.id || null;
235
+ if (id) idByKey.set(obj.key, id);
236
+ } else {
237
+ refusals.push(`${obj.key}: ${(r && r.error && r.error.message) || "hq refused it"}`);
238
+ }
239
+ } catch (e) {
240
+ // FAIL-OPEN IS FINE; SILENT IS NOT. This `catch {}` used to be empty,
241
+ // which is how a fleet-wide provenance bug stayed invisible: every
242
+ // proposal was refused and the only symptom was a count nobody read.
243
+ refusals.push(`${obj.key}: ${e && e.message ? e.message : e}`);
244
+ }
211
245
  }
212
- if (posted !== proposals.length) {
213
- log(`[mandate] proposed ${posted}/${proposals.length} objectives to hq — the rest stayed local (the plan compiles from the cache either way)`);
246
+ if (posted !== ordered.length) {
247
+ log(`[mandate] proposed ${posted}/${ordered.length} objectives to hq — the rest stayed local (the plan compiles from the cache either way)`);
248
+ for (const r of refusals) log(`[mandate] refused → ${r}`);
214
249
  }
215
250
  }
216
251
 
@@ -316,7 +351,13 @@ export default {
316
351
  record = res.record;
317
352
  if (res.derived) {
318
353
  const c = res.derived.counts;
319
- io.note(`[mandate] derived ${c.proposed} PROPOSED objectives (${c.pillars} pillar · ${c.objectives} objective · ${c.goals} goal; ${c.withMethodSensor} with a method sensor). Adopt them in Cohort to activate them.`);
354
+ // ADOPTABLE, not just proposed. "35 proposed" is the number that let a
355
+ // seat sit at zero adopted for months and read as a missing human
356
+ // click; "35 proposed / 4 adoptable / 31 refused" is the number that
357
+ // says whose problem it is. hq refuses the other 31 at `mandate.adopt`
358
+ // regardless — this is where an operator finds that out in one line
359
+ // instead of one 400 at a time.
360
+ io.note(`[mandate] derived ${c.proposed} PROPOSED objectives (${c.pillars} pillar · ${c.objectives} objective · ${c.goals} goal; ${c.withMethodSensor} with a method sensor). ${c.adoptable} are ADOPTABLE today${c.refused ? `; ${c.refused} carry unresolved provenance/duplicate debt` : ""}. Adopt them in Cohort to activate them.`);
320
361
  }
321
362
  }
322
363
 
@@ -131,9 +131,21 @@ test("apply derives a PROPOSED mandate and caches it (nothing adopted by the age
131
131
  assert.equal(cache.source, "local");
132
132
  assert.ok(cache.body.objectives.length > 0);
133
133
  assert.ok(cache.body.objectives.every((o) => o.state === "proposed"), "the agent NEVER adopts its own objectives");
134
- // charter pillars became PILLAR nodes, and objectives hang off them
134
+ // charter pillars became PILLAR nodes, and a PLACEABLE objective hangs off one
135
135
  assert.equal(cache.body.objectives.filter((o) => o.kind === "PILLAR").length, 2);
136
- assert.ok(cache.body.objectives.filter((o) => o.kind === "OBJECTIVE").every((o) => o.parentKey), "provenance to a charter pillar");
136
+ const placed = cache.body.objectives.filter((o) => o.kind === "OBJECTIVE" && o.parentKey);
137
+ assert.ok(placed.length > 0, "at least one objective traces to a charter pillar");
138
+ for (const o of placed) {
139
+ // The clause id rides on the ROW, not only on its parent — `mandate.propose`
140
+ // takes `charterSectionId`, and a node carrying provenance only through
141
+ // `parentKey` reached hq with both provenance columns null.
142
+ assert.ok(o.charterSectionId, `"${o.key}" must carry the charter clause id it inherited`);
143
+ }
144
+ // An objective the deriver could NOT place is carried unplaced and named —
145
+ // never auto-attached to whichever pillar happened to be first.
146
+ for (const o of cache.body.objectives.filter((x) => x.kind === "OBJECTIVE" && !x.parentKey)) {
147
+ assert.equal(o.charterSectionId, null);
148
+ }
137
149
  });
138
150
 
139
151
  test("apply with only PROPOSED objectives emits EXACTLY today's cadence set — no schedule growth", async () => {
@@ -162,6 +174,9 @@ test("an ADOPTED objective grows the schedule: a real measure cadence lands in t
162
174
  key: "pipeline-coverage", id: "obj_7Kx", kind: "OBJECTIVE", text: "Pipeline coverage", state: "active",
163
175
  metric: "pipeline_coverage_x", direction: "up", target: 3, tolerance: 0.2, weight: 1, cadence: "weekly",
164
176
  charterSectionId: "cs_1", sensor: { capability: "crm_list_deals", params: { stage: "open" }, source: "method" },
177
+ // hq stamps this on every adopt; the compiler REQUIRES it (an active row
178
+ // with no adopter on record compiles nothing — the law fails closed).
179
+ adoptedById: "mem_A03",
165
180
  collaborators: [{ member_id: "mem_A03", role: "REVIEWER" }],
166
181
  }],
167
182
  },
@@ -32,6 +32,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
32
32
  import * as io from "../io.mjs";
33
33
  import { isEnabled, configFromAgent, loadOrgConfig } from "../../org/client.mjs";
34
34
  import { emailInbox } from "../../org/ui-parity.mjs";
35
+ import { remedyFor } from "../../org/email-remedy.mjs";
35
36
 
36
37
  const GATE_REL = join("config", "orgmail.yaml");
37
38
 
@@ -152,13 +153,15 @@ export default {
152
153
  // Cohort → Settings → Email — warn, never fail the wizard on it.
153
154
  let detail = "";
154
155
  let reachable = false;
156
+ let probeCode = "";
155
157
  try {
156
158
  const r = await emailInbox({ limit: 1 }, { base: orgCfg.base, token: orgCfg.token, orgId: orgCfg.orgId, fetchImpl: ctx.fetchImpl });
157
159
  if (r && r.ok) {
158
160
  reachable = true;
159
161
  detail = r.result?.mailbox?.address ? `mailbox ${r.result.mailbox.address}` : "mailbox reachable";
160
162
  } else {
161
- detail = `${r?.error?.code || "?"}: ${r?.error?.message || "unreachable"}`;
163
+ probeCode = r?.error?.code || "?";
164
+ detail = `${probeCode}: ${r?.error?.message || "unreachable"}`;
162
165
  }
163
166
  } catch {
164
167
  detail = "probe failed";
@@ -166,7 +169,12 @@ export default {
166
169
  checks.push({
167
170
  name: reachable ? `Cohort mailbox reachable (${detail})` : "Cohort mailbox reachability (best-effort)",
168
171
  ok: true, // never hard-fail — the admin may not have verified a domain yet
169
- remedy: reachable ? undefined : `email.inbox probe: ${detail} — an admin must verify a domain + assign this agent a mailbox in Cohort → Settings → Email`,
172
+ // The remedy comes from the ONE classifier the doctor and the adapter also
173
+ // use, so an operator is told the same thing by whichever surface they hit
174
+ // first — and is told an ACTOR and an ACT, not just a state.
175
+ remedy: reachable
176
+ ? undefined
177
+ : `email.inbox probe: ${detail}. ${remedyFor(probeCode) || "Retry; if it persists, a workspace ADMIN checks Cohort → Settings → Email"}`,
170
178
  });
171
179
  return { ok: checks.every((c) => c.ok), checks };
172
180
  },
@@ -32,6 +32,60 @@ import { createHash } from "node:crypto";
32
32
  export const PROGRESS_REL = "state/setup/progress.json";
33
33
  export const SCHEMA_VERSION = 1;
34
34
 
35
+ /**
36
+ * Answer keys whose VALUES are live credentials and must never be persisted.
37
+ *
38
+ * This checkpoint is a 0644 JSON file under `state/`, which is inside the
39
+ * default backup include set — so `orgToken` (the seat's COHORT_API_KEY,
40
+ * collected by `lib/setup/sections/org.mjs` via `io.secret`) was written here in
41
+ * plaintext and shipped, byte-identical to `.env`, inside every nightly archive.
42
+ * The backup deny-list matched PATHS only, so `.env` was correctly excluded
43
+ * while the same secret rode along inside an included file and the runner logged
44
+ * "archive verified clean". Set `offsite.provider` and it uploads nightly.
45
+ *
46
+ * The checkpoint is a RESUME HINT, never an authority: the wizard re-`detect`s
47
+ * every section on resume and the real credential lives in `.env` / the
48
+ * keychain, so dropping the value costs nothing but a re-prompt.
49
+ *
50
+ * Matched on the key NAME (narrow, deliberate patterns — `orgAuthMethod` must
51
+ * NOT match, or resume breaks) rather than on value shape, so a rotated token
52
+ * with a new prefix is still caught.
53
+ */
54
+ export const SECRET_ANSWER_KEY_RE =
55
+ /(^|[._-])(token|secret|password|passphrase|credential|api[._-]?key|private[._-]?key|pairing[._-]?code)($|[._-])|token$|secret$|apikey$|pairingcode$/i;
56
+
57
+ /**
58
+ * Strip credential-valued answers. The key is DELETED, not placeholdered: every
59
+ * consumer resolves `answers.x != null ? answers.x : process.env.X` (see
60
+ * `lib/setup/sections/org.mjs#connFrom`), so a placeholder string would be
61
+ * handed to hq as a bearer token on resume. Absent is the only correct shape —
62
+ * it routes the read back to `.env` / the keychain, which is where the value
63
+ * actually lives.
64
+ *
65
+ * The dropped key names are recorded on `_redacted` so the file still SAYS what
66
+ * was removed. Names are not secrets; values are.
67
+ *
68
+ * @param {object|null|undefined} answers
69
+ * @returns {{answers:object, redacted:string[]}}
70
+ */
71
+ export function redactSecretAnswers(answers) {
72
+ const src = answers && typeof answers === "object" ? answers : {};
73
+ const out = {};
74
+ const redacted = [];
75
+ for (const [k, v] of Object.entries(src)) {
76
+ if (k === "_redacted") continue;
77
+ if (SECRET_ANSWER_KEY_RE.test(k) && typeof v === "string" && v) {
78
+ redacted.push(k);
79
+ continue;
80
+ }
81
+ out[k] = v;
82
+ }
83
+ const prior = Array.isArray(src._redacted) ? src._redacted : [];
84
+ const all = [...new Set([...prior, ...redacted])].sort();
85
+ if (all.length) out._redacted = all;
86
+ return { answers: out, redacted };
87
+ }
88
+
35
89
  /** Absolute path to the progress file for an agent root. */
36
90
  export function progressPath(agentRoot) {
37
91
  return join(agentRoot, PROGRESS_REL);
@@ -78,10 +132,36 @@ export function loadProgress(agentRoot) {
78
132
  try {
79
133
  const raw = JSON.parse(readFileSync(p, "utf-8"));
80
134
  if (!raw || typeof raw !== "object") return emptyProgress();
135
+ const { answers, redacted } = redactSecretAnswers(
136
+ raw.answers && typeof raw.answers === "object" ? raw.answers : {}
137
+ );
138
+ // SELF-HEAL, LOUDLY. Files written before this guard hold a live
139
+ // COHORT_API_KEY in plaintext, and they are inside the backup include set.
140
+ // Rewriting on the first read scrubs every enrolled agent the moment
141
+ // anything touches the checkpoint, instead of waiting for the next setup
142
+ // run. The credential is NOT rotated for you — say so.
143
+ if (redacted.length > 0) {
144
+ try {
145
+ const cleaned = { ...raw, answers };
146
+ const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
147
+ writeFileSync(tmp, JSON.stringify(cleaned, null, 2) + "\n", "utf-8");
148
+ renameSync(tmp, p);
149
+ console.warn(
150
+ `[setup/state] ${PROGRESS_REL} held credential value(s) in plaintext (${redacted.join(", ")}) — ` +
151
+ "scrubbed in place. This file is inside the backup include set, so treat those credentials as " +
152
+ "EXPOSED to every archive taken since enrolment and ROTATE them."
153
+ );
154
+ } catch (err) {
155
+ console.error(
156
+ `[setup/state] ${PROGRESS_REL} holds credential value(s) in plaintext (${redacted.join(", ")}) and could ` +
157
+ `not be scrubbed (${err && err.message ? err.message : err}) — delete the file and rotate the credential.`
158
+ );
159
+ }
160
+ }
81
161
  return {
82
162
  version: raw.version || SCHEMA_VERSION,
83
163
  updatedAt: raw.updatedAt || null,
84
- answers: raw.answers && typeof raw.answers === "object" ? raw.answers : {},
164
+ answers,
85
165
  sections: raw.sections && typeof raw.sections === "object" ? raw.sections : {},
86
166
  enrich: raw.enrich && typeof raw.enrich === "object" ? raw.enrich : {},
87
167
  };
@@ -98,7 +178,8 @@ export function loadProgress(agentRoot) {
98
178
  export function saveProgress(agentRoot, progress) {
99
179
  const p = progressPath(agentRoot);
100
180
  mkdirSync(dirname(p), { recursive: true });
101
- const body = { ...emptyProgress(), ...progress, version: SCHEMA_VERSION, updatedAt: new Date().toISOString() };
181
+ const merged = { ...emptyProgress(), ...progress, version: SCHEMA_VERSION, updatedAt: new Date().toISOString() };
182
+ const body = { ...merged, answers: redactSecretAnswers(merged.answers).answers };
102
183
  const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
103
184
  writeFileSync(tmp, JSON.stringify(body, null, 2) + "\n", "utf-8");
104
185
  renameSync(tmp, p);