iterate-plugin 3.5.7 → 3.7.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 (79) hide show
  1. package/README.md +97 -22
  2. package/README.zh-CN.md +79 -19
  3. package/dist/approval-gate.js +143 -23
  4. package/dist/atomic-fs.js +148 -17
  5. package/dist/config-loader.js +516 -35
  6. package/dist/config-write.js +83 -22
  7. package/dist/evidence.js +141 -87
  8. package/dist/file-lock.js +193 -0
  9. package/dist/index.js +61 -28
  10. package/dist/jobs.js +56 -11
  11. package/dist/live.js +54 -14
  12. package/dist/meta-review.js +132 -28
  13. package/dist/method-scope.js +31 -8
  14. package/dist/review-scope.js +32 -9
  15. package/dist/review.js +96 -17
  16. package/dist/session-hooks.js +61 -14
  17. package/dist/skill-prompt.js +396 -66
  18. package/dist/tools/checkpoint.js +85 -19
  19. package/dist/tools/config.js +44 -2
  20. package/dist/tools/context.js +22 -5
  21. package/dist/tools/decision-log.js +106 -28
  22. package/dist/tools/defense-events.js +90 -31
  23. package/dist/tools/defense-store.js +28 -3
  24. package/dist/tools/experience-bank.js +108 -43
  25. package/dist/tools/experience-store.js +54 -4
  26. package/dist/tools/fix.js +347 -119
  27. package/dist/tools/history.js +68 -14
  28. package/dist/tools/present.js +80 -0
  29. package/dist/tools/prune.js +150 -53
  30. package/dist/tools/quality-gate.js +52 -2
  31. package/dist/tools/quality-store.js +17 -1
  32. package/dist/tools/review.js +158 -7
  33. package/dist/tools/transcript.js +173 -36
  34. package/dist/tools/triage.js +67 -12
  35. package/dist/tools/validate.js +115 -24
  36. package/dist/transcript.js +267 -4
  37. package/dist/working-directory.js +114 -0
  38. package/lib/client.js +1912 -512
  39. package/lib/parse.js +1001 -103
  40. package/package.json +16 -9
  41. package/src/approval-gate.ts +160 -23
  42. package/src/atomic-fs.ts +152 -17
  43. package/src/client/i18n.ts +517 -0
  44. package/src/client/index.ts +1031 -369
  45. package/src/client/session-cache.ts +54 -0
  46. package/src/config-loader.ts +549 -36
  47. package/src/config-write.ts +108 -26
  48. package/src/evidence.ts +194 -92
  49. package/src/file-lock.ts +211 -0
  50. package/src/index.ts +60 -29
  51. package/src/jobs.ts +63 -12
  52. package/src/live.ts +50 -12
  53. package/src/meta-review.ts +151 -34
  54. package/src/method-scope.ts +31 -8
  55. package/src/review-scope.ts +33 -10
  56. package/src/review.ts +96 -17
  57. package/src/session-hooks.ts +60 -13
  58. package/src/skill-prompt.ts +396 -66
  59. package/src/tools/checkpoint.ts +81 -19
  60. package/src/tools/config.ts +44 -2
  61. package/src/tools/context.ts +25 -5
  62. package/src/tools/decision-log.ts +114 -23
  63. package/src/tools/defense-events.ts +87 -31
  64. package/src/tools/defense-store.ts +30 -3
  65. package/src/tools/experience-bank.ts +112 -44
  66. package/src/tools/experience-store.ts +54 -5
  67. package/src/tools/fix.ts +258 -20
  68. package/src/tools/history.ts +74 -15
  69. package/src/tools/present.ts +89 -0
  70. package/src/tools/prune.ts +154 -51
  71. package/src/tools/quality-gate.ts +57 -2
  72. package/src/tools/quality-store.ts +18 -1
  73. package/src/tools/review.ts +155 -6
  74. package/src/tools/transcript.ts +178 -36
  75. package/src/tools/triage.ts +70 -13
  76. package/src/tools/validate.ts +138 -32
  77. package/src/transcript.ts +279 -4
  78. package/src/types.ts +29 -1
  79. package/src/working-directory.ts +126 -0
@@ -6,9 +6,19 @@
6
6
  * any I/O, so it is fully unit-testable:
7
7
  *
8
8
  * - `{ kind: 'allow' }` → run the call.
9
- * - `{ kind: 'ask', reason }` → prompt the human via the dsh approval service.
9
+ * - `{ kind: 'ask', reason, displayReason }` → prompt the human via the dsh
10
+ * approval service (`displayReason` carries the localized prompt text;
11
+ * dsh 0.2.x renders it when present).
10
12
  * - `{ kind: 'deny', reason }` → refuse; the caller surfaces the reason.
11
13
  *
14
+ * LANGUAGE RULE (project `config.language`, zh|en):
15
+ * - Human-facing text (the `ask` prompt / `displayReason`) FOLLOWS the
16
+ * configured language.
17
+ * - Structured/durable text (the `deny` reason, `deny.info.reason`,
18
+ * decision-log and console output) STAYS ENGLISH so logs, audits, and
19
+ * machine matchers never depend on a locale. `ask.reason` is the audited
20
+ * English summary; `displayReason` is the localized prompt.
21
+ *
12
22
  * Policy (config `observatory.approval`, default 'ask'):
13
23
  * - `allow` → destructive iterate calls always run (debug/trusted).
14
24
  * - `deny` → destructive iterate calls are always refused (fail-closed).
@@ -16,36 +26,135 @@
16
26
  *
17
27
  * Destructive calls are exactly the ones that mutate the workspace:
18
28
  * `iterate_fix` (writes files), `iterate_rollback` (restores from backups),
19
- * and `iterate_prune` with `dryRun !== true` (deletes `.iterate/` artifacts).
29
+ * `iterate_prune` with `dryRun === false` (deletes `.iterate/` artifacts —
30
+ * prune is read-only by default, so only an explicit `dryRun: false` gates),
31
+ * and `iterate_config` with `operation === 'write'` (rewrites
32
+ * `iterate.config.yaml`). `iterate_config` writes matter because the config
33
+ * is policy: a silent `reviewer: {evidence_validation: false}` or a
34
+ * `validation.commands` addition changes what later rounds are allowed to do,
35
+ * so a write is consented to exactly like a file write — the read operation
36
+ * stays free.
20
37
  * Read-only calls are always allowed. Non-iterate calls are untouched — the
21
38
  * gate only ever inspects iterate tools so it cannot alter unrelated behavior.
22
39
  */
23
- /** Destructive iterate tools subject to the gate. */
24
- const DESTRUCTIVE_TOOLS = new Set(['iterate_fix', 'iterate_rollback', 'iterate_prune']);
25
- /** Human-readable reason rendered in the approval prompt. */
26
- function describe(toolName, arguments0) {
40
+ /** Iterate tools subject to the gate (some only in specific operations). */
41
+ const DESTRUCTIVE_TOOLS = new Set([
42
+ 'iterate_fix',
43
+ 'iterate_rollback',
44
+ 'iterate_prune',
45
+ 'iterate_config',
46
+ ]);
47
+ /**
48
+ * True when this specific call MUTATES the workspace and therefore needs
49
+ * consent. Two of the four gated tools are operation-conditional:
50
+ * - `iterate_prune` is read-only unless `dryRun === false`;
51
+ * - `iterate_config` reads freely, but `operation === 'write'` rewrites
52
+ * `iterate.config.yaml` — the file that carries the approval policy,
53
+ * the reviewer gates, and the command allow-list.
54
+ */
55
+ function mutatesWorkspace(toolName, args) {
56
+ if (toolName === 'iterate_prune')
57
+ return args.dryRun === false;
58
+ if (toolName === 'iterate_config')
59
+ return args.operation === 'write';
60
+ return DESTRUCTIVE_TOOLS.has(toolName);
61
+ }
62
+ /** Config updates the model supplied (empty when absent/malformed). */
63
+ function readUpdates(args) {
64
+ const u = args.updates;
65
+ return u && typeof u === 'object' && !Array.isArray(u) ? u : {};
66
+ }
67
+ /**
68
+ * Config switches that DISABLE a safety gate, for the consent text. Writing
69
+ * `reviewer.evidence_validation: false` is legal config — but the human being
70
+ * asked must see that the call is about to turn a review gate off, not just
71
+ * "update a file".
72
+ */
73
+ function disabledReviewGates(updates) {
74
+ const rv = updates.reviewer;
75
+ if (!rv || typeof rv !== 'object' || Array.isArray(rv))
76
+ return [];
77
+ const off = [];
78
+ for (const key of ['output_schema_validation', 'evidence_validation', 'coverage_validation']) {
79
+ if (rv[key] === false)
80
+ off.push(`reviewer.${key}`);
81
+ }
82
+ return off;
83
+ }
84
+ /**
85
+ * Human-readable reason rendered in the approval prompt.
86
+ *
87
+ * `language` selects the wording of the HUMAN-FACING text; call it with
88
+ * `'en'` for the structured, machine-readable string (deny reasons, audited
89
+ * ask summaries) and with the project's configured language for the prompt
90
+ * text (`displayReason`). The structured deny reason therefore never
91
+ * localizes, while the consent prompt follows `config.language`.
92
+ */
93
+ export function describe(toolName, arguments0, language = 'en') {
94
+ const zh = language === 'zh';
27
95
  const file = arguments0 && typeof arguments0.file === 'string'
28
96
  ? `\`${arguments0.file}\``
29
- : 'the workspace';
97
+ : zh
98
+ ? '工作区'
99
+ : 'the workspace';
30
100
  switch (toolName) {
31
101
  case 'iterate_fix':
32
- return `Apply an atomic fix to ${file}`;
102
+ return zh
103
+ ? `对 ${file} 应用一次原子修复`
104
+ : `Apply an atomic fix to ${file}`;
33
105
  case 'iterate_rollback': {
34
- const id = arguments0 && typeof arguments0.id === 'string' ? ` \`${arguments0.id}\`` : '';
35
- return `Revert fix${id} (restore ${file} from backup)`;
106
+ // Rollback's parameters are `id` (required) + `path` (project root) —
107
+ // it has NO `file` param, so naming `arguments.file` could never
108
+ // identify the target ("restore the workspace from backup" every time).
109
+ // Name the fix id and, when present, the project scope instead.
110
+ const id = arguments0 && typeof arguments0.id === 'string' && arguments0.id
111
+ ? `\`${arguments0.id}\``
112
+ : null;
113
+ const scope = arguments0 && typeof arguments0.path === 'string' && arguments0.path
114
+ ? `\`${arguments0.path}\``
115
+ : null;
116
+ const target = id ?? scope ?? (zh ? '一次修复' : 'a fix');
117
+ const inScope = id && scope ? (zh ? `(位于 ${scope})` : ` in ${scope}`) : '';
118
+ return zh
119
+ ? `回滚修复 ${target}${inScope}(从备份恢复其文件)`
120
+ : `Revert fix ${target}${inScope} (restore its file from backup)`;
36
121
  }
37
122
  case 'iterate_prune':
38
- return 'Delete stale `.iterate/` runtime artifacts';
123
+ return zh
124
+ ? '删除过期的 `.iterate/` 运行时产物'
125
+ : 'Delete stale `.iterate/` runtime artifacts';
126
+ case 'iterate_config': {
127
+ // Name WHAT is being written and shout when the write disables a review
128
+ // gate — the consent prompt is the last human look before the model
129
+ // rewrites the policy file. The WARNING marker stays literal in both
130
+ // languages so the UI's highlight rule matches on it.
131
+ const updates = readUpdates(arguments0 ?? {});
132
+ const keys = Object.keys(updates);
133
+ const scope = keys.length > 0 ? keys.map((k) => `\`${k}\``).join(', ') : (zh ? '配置' : 'the configuration');
134
+ const base = zh
135
+ ? `更新 \`iterate.config.yaml\`(${scope})`
136
+ : `Update \`iterate.config.yaml\` (${scope})`;
137
+ const off = disabledReviewGates(updates);
138
+ if (off.length === 0)
139
+ return base;
140
+ return zh
141
+ ? `${base} — WARNING:将关闭 ${off.join(', ')}`
142
+ : `${base} — WARNING: turns OFF ${off.join(', ')}`;
143
+ }
39
144
  default:
40
- return `Run ${toolName}`;
145
+ return zh ? `运行 ${toolName}` : `Run ${toolName}`;
41
146
  }
42
147
  }
43
148
  /**
44
149
  * Decide whether a tool execution may proceed under the given policy.
45
- * Returns `allow` for read-only prune (`dryRun: true`), for `allow`-policy
150
+ * Returns `allow` for read-only prune (`dryRun: true`), for config READS
151
+ * (`iterate_config` without `operation: 'write'`), for `allow`-policy
46
152
  * deployments, and for any non-iterate tool.
153
+ *
154
+ * `language` only affects the HUMAN-FACING `ask.displayReason` text; the
155
+ * audited `ask.reason` and the `deny.reason` stay English (see header).
47
156
  */
48
- export function decideApproval(execution, policy) {
157
+ export function decideApproval(execution, policy, language = 'en') {
49
158
  // Defensive reads: a hostile/proxied execution object must degrade to "not
50
159
  // our tool" (allow) rather than throw inside the gate.
51
160
  let name = '';
@@ -69,21 +178,32 @@ export function decideApproval(execution, policy) {
69
178
  const args = rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)
70
179
  ? rawArgs
71
180
  : {};
72
- // `iterate_prune` is read-only in its default dry-run mode — no gate needed.
73
- if (name === 'iterate_prune' && args.dryRun === false) {
74
- // falls through to the destructive path below
75
- }
76
- else if (name === 'iterate_prune') {
181
+ // Operation-conditional tools are read-only in their default operation —
182
+ // `iterate_prune` dry-runs and `iterate_config` reads need no consent.
183
+ if (!mutatesWorkspace(name, args))
77
184
  return { kind: 'allow' };
78
- }
79
185
  if (policy === 'allow')
80
186
  return { kind: 'allow' };
81
- const reason = describe(name, args);
187
+ // Structured text (deny reason / audited ask summary) is always English;
188
+ // only the human prompt (`displayReason`) follows the project language.
189
+ const reason = describe(name, args, 'en');
82
190
  if (policy === 'deny')
83
191
  return { kind: 'deny', reason };
84
- return { kind: 'ask', reason };
192
+ const localized = describe(name, args, language);
193
+ return {
194
+ kind: 'ask',
195
+ reason,
196
+ displayReason: language === 'zh' ? { en: reason, zh: localized } : { en: reason },
197
+ };
85
198
  }
86
- /** True when any destructive iterate tool is listed in a name set. */
199
+ /**
200
+ * True when a tool name is ROUTED through the approval gate.
201
+ *
202
+ * Name-level only (the caller has not parsed arguments yet), so the two
203
+ * operation-conditional tools are included for their whole name and
204
+ * {@link decideApproval} narrows per call: prune with a dry run and
205
+ * `iterate_config` reads both come back `allow`.
206
+ */
87
207
  export function isDestructiveIterateTool(name) {
88
208
  return typeof name === 'string' && DESTRUCTIVE_TOOLS.has(name);
89
209
  }
package/dist/atomic-fs.js CHANGED
@@ -4,15 +4,24 @@
4
4
  * Every JSON/YAML artifact the plugin persists (.iterate/*.json,
5
5
  * iterate.config.yaml) is written through here: content goes to a
6
6
  * uniquely-named temp file in the SAME directory as the target (rename is
7
- * only atomic within one filesystem), then is `renameSync`ed over the
8
- * destination. A crash mid-write can therefore leave a stale temp file
9
- * behind, but never a truncated / half-written state file — readers
10
- * (which all tolerate missing/corrupt files) see either the previous or
11
- * the new content, never a mixture.
7
+ * only atomic within one filesystem), is fsynced to plating, then is
8
+ * `renameSync`ed over the destination, followed by a best-effort directory
9
+ * fsync so the rename itself is durable. A crash mid-write can therefore
10
+ * leave a stale temp file behind, but never a truncated / half-written state
11
+ * file — readers (which all tolerate missing/corrupt files) see either the
12
+ * previous or the new content, never a mixture.
13
+ *
14
+ * Two properties travel with the rename (which REPLACES the target inode):
15
+ * - permissions: the temp is given the existing target's mode before the
16
+ * swap, so rewriting a 0755 script stays 0755 and a 0600 secret never
17
+ * widens to the umask default (0644).
18
+ * - symlinks: when the target is a symlink, the atomic replace runs on the
19
+ * link's REAL target instead — a fix written through a linked source file
20
+ * must reach the real file, and the link must survive as a link.
12
21
  */
13
- import { dirname, join } from 'node:path';
14
- import { renameSync, rmSync, writeFileSync } from 'node:fs';
15
- import { rename, rm, writeFile } from 'node:fs/promises';
22
+ import { basename, dirname, join } from 'node:path';
23
+ import { closeSync, fchmodSync, fsyncSync, lstatSync, openSync, realpathSync, renameSync, rmSync, statSync, writeSync, } from 'node:fs';
24
+ import { lstat, open, realpath, rename, rm, stat } from 'node:fs/promises';
16
25
  /** Best-effort unlink of a leftover temp file (never throws). */
17
26
  function cleanupTemp(tmp) {
18
27
  try {
@@ -35,21 +44,128 @@ async function cleanupTempAsync(tmp) {
35
44
  * Unique temp file path for `filePath`, in the same directory, following the
36
45
  * prunable `.<basename>.tmp-<pid>-<random>` convention (see prune.ts
37
46
  * `isPrunableTemp`). The pid+random suffix makes concurrent writers safe.
47
+ * `basename` from node:path handles the platform separator (a hand-rolled
48
+ * `split('/')` breaks on Windows paths).
38
49
  */
39
50
  function tempPathFor(filePath) {
40
- return join(dirname(filePath), `.${filePath.split('/').pop() ?? 'file'}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`);
51
+ return join(dirname(filePath), `.${basename(filePath)}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`);
52
+ }
53
+ /**
54
+ * Resolve the path the atomic replace must actually run on. A symlinked
55
+ * target is resolved to its real path: `renameSync(tmp, link)` would swap the
56
+ * LINK for a regular file and the linked-to file would silently keep its old
57
+ * content (a fix that never reaches the real source). When lstat/realpath
58
+ * cannot resolve (missing target, dangling or unreadable link), fall back to
59
+ * writing `filePath` itself — the pre-symlink-aware behavior.
60
+ */
61
+ function resolveAtomicTarget(filePath) {
62
+ try {
63
+ if (lstatSync(filePath).isSymbolicLink())
64
+ return realpathSync(filePath);
65
+ }
66
+ catch {
67
+ // Missing target or unresolvable link — write `filePath` itself.
68
+ }
69
+ return filePath;
70
+ }
71
+ /** Async variant of {@link resolveAtomicTarget}. */
72
+ async function resolveAtomicTargetAsync(filePath) {
73
+ try {
74
+ if ((await lstat(filePath)).isSymbolicLink())
75
+ return await realpath(filePath);
76
+ }
77
+ catch {
78
+ // Missing target or unresolvable link — write `filePath` itself.
79
+ }
80
+ return filePath;
81
+ }
82
+ /**
83
+ * The permission bits to give the temp file: the existing target's mode (so
84
+ * the rename-over swap preserves it), or `null` for a brand-new file — then
85
+ * the default open mode (0o666 & ~umask) applies, exactly like a plain
86
+ * `writeFileSync` of a new file.
87
+ */
88
+ function existingMode(target) {
89
+ try {
90
+ return statSync(target).mode & 0o777;
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
96
+ /** Async variant of {@link existingMode}. */
97
+ async function existingModeAsync(target) {
98
+ try {
99
+ return (await stat(target)).mode & 0o777;
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ }
105
+ /**
106
+ * Best-effort fsync of the directory holding the final file, so the rename
107
+ * itself survives a crash. Unsupported on some platforms/filesystems — the
108
+ * data is already durable (fsynced on the temp), this only covers the
109
+ * directory entry, so failure is ignored by design.
110
+ */
111
+ function fsyncDirBestEffort(dir) {
112
+ try {
113
+ const fd = openSync(dir, 'r');
114
+ try {
115
+ fsyncSync(fd);
116
+ }
117
+ finally {
118
+ closeSync(fd);
119
+ }
120
+ }
121
+ catch {
122
+ // Directory fsync is unavailable (Windows, some FUSE mounts) — durability
123
+ // of the content does not depend on it.
124
+ }
125
+ }
126
+ /** Async variant of {@link fsyncDirBestEffort}. */
127
+ async function fsyncDirBestEffortAsync(dir) {
128
+ let dh;
129
+ try {
130
+ dh = await open(dir, 'r');
131
+ await dh.sync();
132
+ }
133
+ catch {
134
+ // Directory fsync is unavailable — see fsyncDirBestEffort.
135
+ }
136
+ finally {
137
+ if (dh) {
138
+ await dh.close().catch(() => { });
139
+ }
140
+ }
41
141
  }
42
142
  /**
43
- * Write `data` to `filePath` atomically (temp file + rename, same dir).
143
+ * Write `data` to `filePath` atomically (temp file + fsync + rename, same dir).
44
144
  * Temp files follow the `<name>.tmp-<pid>-<random>` convention also used by
45
- * prune.ts / checkpoint.ts so they are recognizable and prunable.
145
+ * prune.ts / checkpoint.ts so they are recognizable and prunable. The existing
146
+ * target's mode is preserved across the swap and symlinked targets are
147
+ * resolved first (see the header).
46
148
  * Throws on failure (callers own error reporting — they map failures to
47
149
  * structured tool results).
48
150
  */
49
151
  export function writeTextAtomic(filePath, data) {
50
- const tmp = tempPathFor(filePath);
152
+ const target = resolveAtomicTarget(filePath);
153
+ const tmp = tempPathFor(target);
154
+ const mode = existingMode(target);
51
155
  try {
52
- writeFileSync(tmp, data, 'utf-8');
156
+ // fd-based write so the mode can be pinned and the CONTENT fsynced before
157
+ // the rename — a crash after rename but before the data hit the disk would
158
+ // otherwise leave a zero-length / torn state file.
159
+ const fd = openSync(tmp, 'w');
160
+ try {
161
+ writeSync(fd, data, null, 'utf-8');
162
+ if (mode !== null)
163
+ fchmodSync(fd, mode);
164
+ fsyncSync(fd);
165
+ }
166
+ finally {
167
+ closeSync(fd);
168
+ }
53
169
  }
54
170
  catch (err) {
55
171
  // The write itself failed (disk full, permission, …): the temp file may
@@ -59,19 +175,33 @@ export function writeTextAtomic(filePath, data) {
59
175
  throw err;
60
176
  }
61
177
  try {
62
- renameSync(tmp, filePath);
178
+ renameSync(tmp, target);
63
179
  }
64
180
  catch (err) {
65
181
  // Best-effort temp cleanup so a failed rename never litters the directory.
66
182
  cleanupTemp(tmp);
67
183
  throw err;
68
184
  }
185
+ fsyncDirBestEffort(dirname(target));
69
186
  }
70
187
  /** Async variant of {@link writeTextAtomic} (same temp naming convention). */
71
188
  export async function writeTextAtomicAsync(filePath, data) {
72
- const tmp = tempPathFor(filePath);
189
+ const target = await resolveAtomicTargetAsync(filePath);
190
+ const tmp = tempPathFor(target);
191
+ const mode = await existingModeAsync(target);
73
192
  try {
74
- await writeFile(tmp, data, 'utf-8');
193
+ // fd-based write: pin the target's mode and fsync the content before the
194
+ // rename (mirrors the sync variant — see writeTextAtomic).
195
+ const fh = await open(tmp, 'w');
196
+ try {
197
+ await fh.writeFile(data, 'utf-8');
198
+ if (mode !== null)
199
+ await fh.chmod(mode);
200
+ await fh.sync();
201
+ }
202
+ finally {
203
+ await fh.close();
204
+ }
75
205
  }
76
206
  catch (err) {
77
207
  // The write itself failed (disk full, permission, …): the temp file may
@@ -81,13 +211,14 @@ export async function writeTextAtomicAsync(filePath, data) {
81
211
  throw err;
82
212
  }
83
213
  try {
84
- await rename(tmp, filePath);
214
+ await rename(tmp, target);
85
215
  }
86
216
  catch (err) {
87
217
  // Best-effort temp cleanup so a failed rename never litters the directory.
88
218
  await cleanupTempAsync(tmp);
89
219
  throw err;
90
220
  }
221
+ await fsyncDirBestEffortAsync(dirname(target));
91
222
  }
92
223
  /** JSON.stringify (2-space indent) + atomic write in one call. */
93
224
  export function writeJsonAtomic(filePath, value) {