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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "3.5.7",
3
+ "version": "3.7.0",
4
4
  "description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness with quality command center and experience bank (v3.3). Features: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus dry-run pure-review mode, quality gate compute/persist, writable experience bank, defense events stream (record + bilingual labels), and native command buttons — with a live quality command center (F8/F9/F10 render real session data), §8 assign-fix instruction, and task_mode indicator wired end to end.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,7 +43,11 @@
43
43
  "0.1.7-alpha.1": "compatible",
44
44
  "0.1.7-alpha.2": "compatible",
45
45
  "0.1.7-rc.1": "compatible",
46
- "0.1.7-rc.2": "compatible"
46
+ "0.1.7-rc.2": "compatible",
47
+ "0.2.0-rc.1": "compatible",
48
+ "0.2.0-rc.2": "compatible",
49
+ "0.2.1-alpha.1": "compatible",
50
+ "0.2.1-alpha.2": "compatible"
47
51
  }
48
52
  },
49
53
  "client": {
@@ -74,25 +78,28 @@
74
78
  "build": "tsc -p tsconfig.build.json",
75
79
  "build:client": "node scripts/build-client.mjs",
76
80
  "prepublishOnly": "npm run build && npm run build:client",
77
- "typecheck": "tsc --noEmit",
81
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json && tsc --noEmit -p tsconfig.client.json",
78
82
  "typecheck:client": "tsc --noEmit -p tsconfig.client.json",
79
83
  "test": "tsx --test test/*.test.ts",
80
84
  "test:validate": "tsx --test test/validate.test.ts"
81
85
  },
82
86
  "dependencies": {
83
- "@deepseek-ai/cordis": "4.0.4",
84
- "@deepseek-ai/dsh-tools": "0.1.7-rc.2",
85
- "@deepseek-ai/dsh-util-values": "0.1.7-rc.2",
87
+ "@deepseek-ai/cordis": "4.0.5-alpha.1",
88
+ "@deepseek-ai/dsh-tools": "0.2.1-alpha.2",
89
+ "@deepseek-ai/dsh-util-values": "0.2.1-alpha.2",
86
90
  "js-yaml": "4.3.2"
87
91
  },
88
92
  "devDependencies": {
89
- "@deepseek-ai/dsh-agent": "0.1.7-rc.2",
90
- "@deepseek-ai/dsh-jobs": "0.1.7-rc.2",
91
- "@deepseek-ai/dsh-session": "0.1.7-rc.2",
93
+ "@deepseek-ai/dsh-agent": "0.2.1-alpha.2",
94
+ "@deepseek-ai/dsh-jobs": "0.2.1-alpha.2",
95
+ "@deepseek-ai/dsh-session": "0.2.1-alpha.2",
92
96
  "@types/js-yaml": "4.0.9",
93
97
  "@types/node": "22.15.0",
94
98
  "@types/react": "19.2.2",
95
99
  "esbuild": "0.25.12",
100
+ "jsdom": "26.1.0",
101
+ "react": "19.2.2",
102
+ "react-dom": "19.2.2",
96
103
  "tsx": "4.20.3",
97
104
  "typescript": "5.9.3"
98
105
  },
@@ -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,14 +26,33 @@
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
40
 
41
+ /** The languages the consent prompt can render (mirrors config.language). */
42
+ export type ApprovalLanguage = 'zh' | 'en'
43
+
44
+ /** Locale map accepted by dsh's `PreToolDecision.displayReason` (en is required). */
45
+ export type ApprovalDisplayReason = { readonly en: string; readonly [locale: string]: string }
46
+
24
47
  export type ApprovalDecision =
25
48
  | { kind: 'allow' }
26
- | { kind: 'ask'; reason: string }
49
+ | {
50
+ kind: 'ask'
51
+ /** Audited, English, machine-readable summary of what is being asked. */
52
+ reason: string
53
+ /** Localized prompt text (en, plus zh when configured). */
54
+ displayReason: ApprovalDisplayReason
55
+ }
27
56
  | { kind: 'deny'; reason: string }
28
57
 
29
58
  /** The subset of a tool execution the gate inspects. */
@@ -32,38 +61,134 @@ export interface ToolExecutionLike {
32
61
  arguments?: unknown
33
62
  }
34
63
 
35
- /** Destructive iterate tools subject to the gate. */
36
- const DESTRUCTIVE_TOOLS = new Set(['iterate_fix', 'iterate_rollback', 'iterate_prune'])
64
+ /** Iterate tools subject to the gate (some only in specific operations). */
65
+ const DESTRUCTIVE_TOOLS = new Set([
66
+ 'iterate_fix',
67
+ 'iterate_rollback',
68
+ 'iterate_prune',
69
+ 'iterate_config',
70
+ ])
37
71
 
38
- /** Human-readable reason rendered in the approval prompt. */
39
- function describe(toolName: string, arguments0?: Record<string, unknown>): string {
72
+ /**
73
+ * True when this specific call MUTATES the workspace and therefore needs
74
+ * consent. Two of the four gated tools are operation-conditional:
75
+ * - `iterate_prune` is read-only unless `dryRun === false`;
76
+ * - `iterate_config` reads freely, but `operation === 'write'` rewrites
77
+ * `iterate.config.yaml` — the file that carries the approval policy,
78
+ * the reviewer gates, and the command allow-list.
79
+ */
80
+ function mutatesWorkspace(toolName: string, args: Record<string, unknown>): boolean {
81
+ if (toolName === 'iterate_prune') return args.dryRun === false
82
+ if (toolName === 'iterate_config') return args.operation === 'write'
83
+ return DESTRUCTIVE_TOOLS.has(toolName)
84
+ }
85
+
86
+ /** Config updates the model supplied (empty when absent/malformed). */
87
+ function readUpdates(args: Record<string, unknown>): Record<string, unknown> {
88
+ const u = args.updates
89
+ return u && typeof u === 'object' && !Array.isArray(u) ? (u as Record<string, unknown>) : {}
90
+ }
91
+
92
+ /**
93
+ * Config switches that DISABLE a safety gate, for the consent text. Writing
94
+ * `reviewer.evidence_validation: false` is legal config — but the human being
95
+ * asked must see that the call is about to turn a review gate off, not just
96
+ * "update a file".
97
+ */
98
+ function disabledReviewGates(updates: Record<string, unknown>): string[] {
99
+ const rv = updates.reviewer
100
+ if (!rv || typeof rv !== 'object' || Array.isArray(rv)) return []
101
+ const off: string[] = []
102
+ for (const key of ['output_schema_validation', 'evidence_validation', 'coverage_validation'] as const) {
103
+ if ((rv as Record<string, unknown>)[key] === false) off.push(`reviewer.${key}`)
104
+ }
105
+ return off
106
+ }
107
+
108
+ /**
109
+ * Human-readable reason rendered in the approval prompt.
110
+ *
111
+ * `language` selects the wording of the HUMAN-FACING text; call it with
112
+ * `'en'` for the structured, machine-readable string (deny reasons, audited
113
+ * ask summaries) and with the project's configured language for the prompt
114
+ * text (`displayReason`). The structured deny reason therefore never
115
+ * localizes, while the consent prompt follows `config.language`.
116
+ */
117
+ export function describe(
118
+ toolName: string,
119
+ arguments0?: Record<string, unknown>,
120
+ language: ApprovalLanguage = 'en',
121
+ ): string {
122
+ const zh = language === 'zh'
40
123
  const file =
41
124
  arguments0 && typeof arguments0.file === 'string'
42
125
  ? `\`${arguments0.file}\``
43
- : 'the workspace'
126
+ : zh
127
+ ? '工作区'
128
+ : 'the workspace'
44
129
  switch (toolName) {
45
130
  case 'iterate_fix':
46
- return `Apply an atomic fix to ${file}`
131
+ return zh
132
+ ? `对 ${file} 应用一次原子修复`
133
+ : `Apply an atomic fix to ${file}`
47
134
  case 'iterate_rollback': {
135
+ // Rollback's parameters are `id` (required) + `path` (project root) —
136
+ // it has NO `file` param, so naming `arguments.file` could never
137
+ // identify the target ("restore the workspace from backup" every time).
138
+ // Name the fix id and, when present, the project scope instead.
48
139
  const id =
49
- arguments0 && typeof arguments0.id === 'string' ? ` \`${arguments0.id}\`` : ''
50
- return `Revert fix${id} (restore ${file} from backup)`
140
+ arguments0 && typeof arguments0.id === 'string' && arguments0.id
141
+ ? `\`${arguments0.id}\``
142
+ : null
143
+ const scope =
144
+ arguments0 && typeof arguments0.path === 'string' && arguments0.path
145
+ ? `\`${arguments0.path}\``
146
+ : null
147
+ const target = id ?? scope ?? (zh ? '一次修复' : 'a fix')
148
+ const inScope = id && scope ? (zh ? `(位于 ${scope})` : ` in ${scope}`) : ''
149
+ return zh
150
+ ? `回滚修复 ${target}${inScope}(从备份恢复其文件)`
151
+ : `Revert fix ${target}${inScope} (restore its file from backup)`
51
152
  }
52
153
  case 'iterate_prune':
53
- return 'Delete stale `.iterate/` runtime artifacts'
154
+ return zh
155
+ ? '删除过期的 `.iterate/` 运行时产物'
156
+ : 'Delete stale `.iterate/` runtime artifacts'
157
+ case 'iterate_config': {
158
+ // Name WHAT is being written and shout when the write disables a review
159
+ // gate — the consent prompt is the last human look before the model
160
+ // rewrites the policy file. The WARNING marker stays literal in both
161
+ // languages so the UI's highlight rule matches on it.
162
+ const updates = readUpdates(arguments0 ?? {})
163
+ const keys = Object.keys(updates)
164
+ const scope = keys.length > 0 ? keys.map((k) => `\`${k}\``).join(', ') : (zh ? '配置' : 'the configuration')
165
+ const base = zh
166
+ ? `更新 \`iterate.config.yaml\`(${scope})`
167
+ : `Update \`iterate.config.yaml\` (${scope})`
168
+ const off = disabledReviewGates(updates)
169
+ if (off.length === 0) return base
170
+ return zh
171
+ ? `${base} — WARNING:将关闭 ${off.join(', ')}`
172
+ : `${base} — WARNING: turns OFF ${off.join(', ')}`
173
+ }
54
174
  default:
55
- return `Run ${toolName}`
175
+ return zh ? `运行 ${toolName}` : `Run ${toolName}`
56
176
  }
57
177
  }
58
178
 
59
179
  /**
60
180
  * Decide whether a tool execution may proceed under the given policy.
61
- * Returns `allow` for read-only prune (`dryRun: true`), for `allow`-policy
181
+ * Returns `allow` for read-only prune (`dryRun: true`), for config READS
182
+ * (`iterate_config` without `operation: 'write'`), for `allow`-policy
62
183
  * deployments, and for any non-iterate tool.
184
+ *
185
+ * `language` only affects the HUMAN-FACING `ask.displayReason` text; the
186
+ * audited `ask.reason` and the `deny.reason` stay English (see header).
63
187
  */
64
188
  export function decideApproval(
65
189
  execution: ToolExecutionLike,
66
190
  policy: 'ask' | 'deny' | 'allow',
191
+ language: ApprovalLanguage = 'en',
67
192
  ): ApprovalDecision {
68
193
  // Defensive reads: a hostile/proxied execution object must degrade to "not
69
194
  // our tool" (allow) rather than throw inside the gate.
@@ -86,20 +211,32 @@ export function decideApproval(
86
211
  rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)
87
212
  ? (rawArgs as Record<string, unknown>)
88
213
  : {}
89
- // `iterate_prune` is read-only in its default dry-run mode — no gate needed.
90
- if (name === 'iterate_prune' && args.dryRun === false) {
91
- // falls through to the destructive path below
92
- } else if (name === 'iterate_prune') {
93
- return { kind: 'allow' }
94
- }
214
+ // Operation-conditional tools are read-only in their default operation —
215
+ // `iterate_prune` dry-runs and `iterate_config` reads need no consent.
216
+ if (!mutatesWorkspace(name, args)) return { kind: 'allow' }
95
217
 
96
218
  if (policy === 'allow') return { kind: 'allow' }
97
- const reason = describe(name, args)
219
+ // Structured text (deny reason / audited ask summary) is always English;
220
+ // only the human prompt (`displayReason`) follows the project language.
221
+ const reason = describe(name, args, 'en')
98
222
  if (policy === 'deny') return { kind: 'deny', reason }
99
- return { kind: 'ask', reason }
223
+ const localized = describe(name, args, language)
224
+ return {
225
+ kind: 'ask',
226
+ reason,
227
+ displayReason:
228
+ language === 'zh' ? { en: reason, zh: localized } : { en: reason },
229
+ }
100
230
  }
101
231
 
102
- /** True when any destructive iterate tool is listed in a name set. */
232
+ /**
233
+ * True when a tool name is ROUTED through the approval gate.
234
+ *
235
+ * Name-level only (the caller has not parsed arguments yet), so the two
236
+ * operation-conditional tools are included for their whole name and
237
+ * {@link decideApproval} narrows per call: prune with a dry run and
238
+ * `iterate_config` reads both come back `allow`.
239
+ */
103
240
  export function isDestructiveIterateTool(name: unknown): boolean {
104
241
  return typeof name === 'string' && DESTRUCTIVE_TOOLS.has(name)
105
242
  }
package/src/atomic-fs.ts CHANGED
@@ -4,16 +4,37 @@
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
22
 
14
- import { dirname, join } from 'node:path'
15
- import { renameSync, rmSync, writeFileSync } from 'node:fs'
16
- import { rename, rm, writeFile } from 'node:fs/promises'
23
+ import { basename, dirname, join } from 'node:path'
24
+ import {
25
+ closeSync,
26
+ fchmodSync,
27
+ fsyncSync,
28
+ lstatSync,
29
+ openSync,
30
+ realpathSync,
31
+ renameSync,
32
+ rmSync,
33
+ statSync,
34
+ writeSync,
35
+ } from 'node:fs'
36
+ import { lstat, open, realpath, rename, rm, stat } from 'node:fs/promises'
37
+ import type { FileHandle } from 'node:fs/promises'
17
38
 
18
39
  /** Best-effort unlink of a leftover temp file (never throws). */
19
40
  function cleanupTemp(tmp: string): void {
@@ -37,25 +58,126 @@ async function cleanupTempAsync(tmp: string): Promise<void> {
37
58
  * Unique temp file path for `filePath`, in the same directory, following the
38
59
  * prunable `.<basename>.tmp-<pid>-<random>` convention (see prune.ts
39
60
  * `isPrunableTemp`). The pid+random suffix makes concurrent writers safe.
61
+ * `basename` from node:path handles the platform separator (a hand-rolled
62
+ * `split('/')` breaks on Windows paths).
40
63
  */
41
64
  function tempPathFor(filePath: string): string {
42
65
  return join(
43
66
  dirname(filePath),
44
- `.${filePath.split('/').pop() ?? 'file'}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`,
67
+ `.${basename(filePath)}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`,
45
68
  )
46
69
  }
47
70
 
48
71
  /**
49
- * Write `data` to `filePath` atomically (temp file + rename, same dir).
72
+ * Resolve the path the atomic replace must actually run on. A symlinked
73
+ * target is resolved to its real path: `renameSync(tmp, link)` would swap the
74
+ * LINK for a regular file and the linked-to file would silently keep its old
75
+ * content (a fix that never reaches the real source). When lstat/realpath
76
+ * cannot resolve (missing target, dangling or unreadable link), fall back to
77
+ * writing `filePath` itself — the pre-symlink-aware behavior.
78
+ */
79
+ function resolveAtomicTarget(filePath: string): string {
80
+ try {
81
+ if (lstatSync(filePath).isSymbolicLink()) return realpathSync(filePath)
82
+ } catch {
83
+ // Missing target or unresolvable link — write `filePath` itself.
84
+ }
85
+ return filePath
86
+ }
87
+
88
+ /** Async variant of {@link resolveAtomicTarget}. */
89
+ async function resolveAtomicTargetAsync(filePath: string): Promise<string> {
90
+ try {
91
+ if ((await lstat(filePath)).isSymbolicLink()) return await realpath(filePath)
92
+ } catch {
93
+ // Missing target or unresolvable link — write `filePath` itself.
94
+ }
95
+ return filePath
96
+ }
97
+
98
+ /**
99
+ * The permission bits to give the temp file: the existing target's mode (so
100
+ * the rename-over swap preserves it), or `null` for a brand-new file — then
101
+ * the default open mode (0o666 & ~umask) applies, exactly like a plain
102
+ * `writeFileSync` of a new file.
103
+ */
104
+ function existingMode(target: string): number | null {
105
+ try {
106
+ return statSync(target).mode & 0o777
107
+ } catch {
108
+ return null
109
+ }
110
+ }
111
+
112
+ /** Async variant of {@link existingMode}. */
113
+ async function existingModeAsync(target: string): Promise<number | null> {
114
+ try {
115
+ return (await stat(target)).mode & 0o777
116
+ } catch {
117
+ return null
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Best-effort fsync of the directory holding the final file, so the rename
123
+ * itself survives a crash. Unsupported on some platforms/filesystems — the
124
+ * data is already durable (fsynced on the temp), this only covers the
125
+ * directory entry, so failure is ignored by design.
126
+ */
127
+ function fsyncDirBestEffort(dir: string): void {
128
+ try {
129
+ const fd = openSync(dir, 'r')
130
+ try {
131
+ fsyncSync(fd)
132
+ } finally {
133
+ closeSync(fd)
134
+ }
135
+ } catch {
136
+ // Directory fsync is unavailable (Windows, some FUSE mounts) — durability
137
+ // of the content does not depend on it.
138
+ }
139
+ }
140
+
141
+ /** Async variant of {@link fsyncDirBestEffort}. */
142
+ async function fsyncDirBestEffortAsync(dir: string): Promise<void> {
143
+ let dh: FileHandle | undefined
144
+ try {
145
+ dh = await open(dir, 'r')
146
+ await dh.sync()
147
+ } catch {
148
+ // Directory fsync is unavailable — see fsyncDirBestEffort.
149
+ } finally {
150
+ if (dh) {
151
+ await dh.close().catch(() => {})
152
+ }
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Write `data` to `filePath` atomically (temp file + fsync + rename, same dir).
50
158
  * Temp files follow the `<name>.tmp-<pid>-<random>` convention also used by
51
- * prune.ts / checkpoint.ts so they are recognizable and prunable.
159
+ * prune.ts / checkpoint.ts so they are recognizable and prunable. The existing
160
+ * target's mode is preserved across the swap and symlinked targets are
161
+ * resolved first (see the header).
52
162
  * Throws on failure (callers own error reporting — they map failures to
53
163
  * structured tool results).
54
164
  */
55
165
  export function writeTextAtomic(filePath: string, data: string): void {
56
- const tmp = tempPathFor(filePath)
166
+ const target = resolveAtomicTarget(filePath)
167
+ const tmp = tempPathFor(target)
168
+ const mode = existingMode(target)
57
169
  try {
58
- writeFileSync(tmp, data, 'utf-8')
170
+ // fd-based write so the mode can be pinned and the CONTENT fsynced before
171
+ // the rename — a crash after rename but before the data hit the disk would
172
+ // otherwise leave a zero-length / torn state file.
173
+ const fd = openSync(tmp, 'w')
174
+ try {
175
+ writeSync(fd, data, null, 'utf-8')
176
+ if (mode !== null) fchmodSync(fd, mode)
177
+ fsyncSync(fd)
178
+ } finally {
179
+ closeSync(fd)
180
+ }
59
181
  } catch (err) {
60
182
  // The write itself failed (disk full, permission, …): the temp file may
61
183
  // already be partially written, so remove it before propagating — a
@@ -64,19 +186,31 @@ export function writeTextAtomic(filePath: string, data: string): void {
64
186
  throw err
65
187
  }
66
188
  try {
67
- renameSync(tmp, filePath)
189
+ renameSync(tmp, target)
68
190
  } catch (err) {
69
191
  // Best-effort temp cleanup so a failed rename never litters the directory.
70
192
  cleanupTemp(tmp)
71
193
  throw err
72
194
  }
195
+ fsyncDirBestEffort(dirname(target))
73
196
  }
74
197
 
75
198
  /** Async variant of {@link writeTextAtomic} (same temp naming convention). */
76
199
  export async function writeTextAtomicAsync(filePath: string, data: string): Promise<void> {
77
- const tmp = tempPathFor(filePath)
200
+ const target = await resolveAtomicTargetAsync(filePath)
201
+ const tmp = tempPathFor(target)
202
+ const mode = await existingModeAsync(target)
78
203
  try {
79
- await writeFile(tmp, data, 'utf-8')
204
+ // fd-based write: pin the target's mode and fsync the content before the
205
+ // rename (mirrors the sync variant — see writeTextAtomic).
206
+ const fh = await open(tmp, 'w')
207
+ try {
208
+ await fh.writeFile(data, 'utf-8')
209
+ if (mode !== null) await fh.chmod(mode)
210
+ await fh.sync()
211
+ } finally {
212
+ await fh.close()
213
+ }
80
214
  } catch (err) {
81
215
  // The write itself failed (disk full, permission, …): the temp file may
82
216
  // already be partially written, so remove it before propagating — a
@@ -85,12 +219,13 @@ export async function writeTextAtomicAsync(filePath: string, data: string): Prom
85
219
  throw err
86
220
  }
87
221
  try {
88
- await rename(tmp, filePath)
222
+ await rename(tmp, target)
89
223
  } catch (err) {
90
224
  // Best-effort temp cleanup so a failed rename never litters the directory.
91
225
  await cleanupTempAsync(tmp)
92
226
  throw err
93
227
  }
228
+ await fsyncDirBestEffortAsync(dirname(target))
94
229
  }
95
230
 
96
231
  /** JSON.stringify (2-space indent) + atomic write in one call. */