@the-open-engine/zeroshot 6.4.0 → 6.5.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 (144) hide show
  1. package/cli/commands/providers.js +11 -5
  2. package/cli/index.js +79 -16
  3. package/cli/lib/first-run.js +14 -3
  4. package/docker/zeroshot-cluster/Dockerfile +11 -1
  5. package/lib/agent-cli-provider/acp-stdio-runner.d.ts +4 -0
  6. package/lib/agent-cli-provider/acp-stdio-runner.d.ts.map +1 -0
  7. package/lib/agent-cli-provider/acp-stdio-runner.js +259 -0
  8. package/lib/agent-cli-provider/acp-stdio-runner.js.map +1 -0
  9. package/lib/agent-cli-provider/adapters/acp.d.ts +23 -0
  10. package/lib/agent-cli-provider/adapters/acp.d.ts.map +1 -0
  11. package/lib/agent-cli-provider/adapters/acp.js +373 -0
  12. package/lib/agent-cli-provider/adapters/acp.js.map +1 -0
  13. package/lib/agent-cli-provider/adapters/claude.d.ts.map +1 -1
  14. package/lib/agent-cli-provider/adapters/claude.js +2 -29
  15. package/lib/agent-cli-provider/adapters/claude.js.map +1 -1
  16. package/lib/agent-cli-provider/adapters/copilot-parser.d.ts +3 -0
  17. package/lib/agent-cli-provider/adapters/copilot-parser.d.ts.map +1 -0
  18. package/lib/agent-cli-provider/adapters/copilot-parser.js +154 -0
  19. package/lib/agent-cli-provider/adapters/copilot-parser.js.map +1 -0
  20. package/lib/agent-cli-provider/adapters/copilot.d.ts +3 -0
  21. package/lib/agent-cli-provider/adapters/copilot.d.ts.map +1 -0
  22. package/lib/agent-cli-provider/adapters/copilot.js +176 -0
  23. package/lib/agent-cli-provider/adapters/copilot.js.map +1 -0
  24. package/lib/agent-cli-provider/adapters/gateway.d.ts +6 -0
  25. package/lib/agent-cli-provider/adapters/gateway.d.ts.map +1 -0
  26. package/lib/agent-cli-provider/adapters/gateway.js +161 -0
  27. package/lib/agent-cli-provider/adapters/gateway.js.map +1 -0
  28. package/lib/agent-cli-provider/adapters/gemini.d.ts.map +1 -1
  29. package/lib/agent-cli-provider/adapters/gemini.js +9 -2
  30. package/lib/agent-cli-provider/adapters/gemini.js.map +1 -1
  31. package/lib/agent-cli-provider/adapters/index.d.ts.map +1 -1
  32. package/lib/agent-cli-provider/adapters/index.js +6 -34
  33. package/lib/agent-cli-provider/adapters/index.js.map +1 -1
  34. package/lib/agent-cli-provider/adapters/opencode.d.ts.map +1 -1
  35. package/lib/agent-cli-provider/adapters/opencode.js +5 -1
  36. package/lib/agent-cli-provider/adapters/opencode.js.map +1 -1
  37. package/lib/agent-cli-provider/adapters/pi.d.ts +3 -0
  38. package/lib/agent-cli-provider/adapters/pi.d.ts.map +1 -0
  39. package/lib/agent-cli-provider/adapters/pi.js +331 -0
  40. package/lib/agent-cli-provider/adapters/pi.js.map +1 -0
  41. package/lib/agent-cli-provider/claude-command.d.ts +6 -0
  42. package/lib/agent-cli-provider/claude-command.d.ts.map +1 -0
  43. package/lib/agent-cli-provider/claude-command.js +43 -0
  44. package/lib/agent-cli-provider/claude-command.js.map +1 -0
  45. package/lib/agent-cli-provider/contract-actions.d.ts.map +1 -1
  46. package/lib/agent-cli-provider/contract-actions.js +13 -17
  47. package/lib/agent-cli-provider/contract-actions.js.map +1 -1
  48. package/lib/agent-cli-provider/contract-invoke.d.ts.map +1 -1
  49. package/lib/agent-cli-provider/contract-invoke.js +10 -3
  50. package/lib/agent-cli-provider/contract-invoke.js.map +1 -1
  51. package/lib/agent-cli-provider/contract-options.d.ts.map +1 -1
  52. package/lib/agent-cli-provider/contract-options.js +57 -2
  53. package/lib/agent-cli-provider/contract-options.js.map +1 -1
  54. package/lib/agent-cli-provider/contract-support.d.ts +1 -0
  55. package/lib/agent-cli-provider/contract-support.d.ts.map +1 -1
  56. package/lib/agent-cli-provider/contract-support.js +25 -3
  57. package/lib/agent-cli-provider/contract-support.js.map +1 -1
  58. package/lib/agent-cli-provider/errors.d.ts +1 -0
  59. package/lib/agent-cli-provider/errors.d.ts.map +1 -1
  60. package/lib/agent-cli-provider/errors.js +14 -5
  61. package/lib/agent-cli-provider/errors.js.map +1 -1
  62. package/lib/agent-cli-provider/gateway-client.d.ts +32 -0
  63. package/lib/agent-cli-provider/gateway-client.d.ts.map +1 -0
  64. package/lib/agent-cli-provider/gateway-client.js +135 -0
  65. package/lib/agent-cli-provider/gateway-client.js.map +1 -0
  66. package/lib/agent-cli-provider/gateway-runner.d.ts +9 -0
  67. package/lib/agent-cli-provider/gateway-runner.d.ts.map +1 -0
  68. package/lib/agent-cli-provider/gateway-runner.js +294 -0
  69. package/lib/agent-cli-provider/gateway-runner.js.map +1 -0
  70. package/lib/agent-cli-provider/gateway-tools.d.ts +14 -0
  71. package/lib/agent-cli-provider/gateway-tools.d.ts.map +1 -0
  72. package/lib/agent-cli-provider/gateway-tools.js +495 -0
  73. package/lib/agent-cli-provider/gateway-tools.js.map +1 -0
  74. package/lib/agent-cli-provider/index.d.ts +3 -2
  75. package/lib/agent-cli-provider/index.d.ts.map +1 -1
  76. package/lib/agent-cli-provider/index.js +14 -1
  77. package/lib/agent-cli-provider/index.js.map +1 -1
  78. package/lib/agent-cli-provider/invoke-evidence.d.ts +2 -2
  79. package/lib/agent-cli-provider/invoke-evidence.d.ts.map +1 -1
  80. package/lib/agent-cli-provider/invoke-evidence.js +17 -3
  81. package/lib/agent-cli-provider/invoke-evidence.js.map +1 -1
  82. package/lib/agent-cli-provider/provider-registry.d.ts +437 -0
  83. package/lib/agent-cli-provider/provider-registry.d.ts.map +1 -0
  84. package/lib/agent-cli-provider/provider-registry.js +411 -0
  85. package/lib/agent-cli-provider/provider-registry.js.map +1 -0
  86. package/lib/agent-cli-provider/single-agent-runtime.d.ts +7 -0
  87. package/lib/agent-cli-provider/single-agent-runtime.d.ts.map +1 -1
  88. package/lib/agent-cli-provider/single-agent-runtime.js +139 -19
  89. package/lib/agent-cli-provider/single-agent-runtime.js.map +1 -1
  90. package/lib/agent-cli-provider/types.d.ts +106 -4
  91. package/lib/agent-cli-provider/types.d.ts.map +1 -1
  92. package/lib/agent-cli-provider/types.js.map +1 -1
  93. package/lib/docker-config.js +24 -13
  94. package/lib/provider-defaults.js +15 -4
  95. package/lib/provider-detection.js +2 -0
  96. package/lib/provider-names.js +32 -12
  97. package/lib/repo-settings.js +15 -1
  98. package/lib/settings/claude-auth.js +3 -6
  99. package/lib/settings.js +17 -2
  100. package/lib/setup-apply.js +300 -0
  101. package/lib/setup-journal.js +109 -0
  102. package/lib/setup-plan.js +406 -0
  103. package/lib/setup-undo.js +88 -0
  104. package/lib/start-cluster.js +12 -1
  105. package/lib/stream-json-parser.js +7 -5
  106. package/package.json +29 -1
  107. package/scripts/live-provider-smoke.js +221 -0
  108. package/src/agent/agent-lifecycle.js +5 -4
  109. package/src/agent/agent-quality-gate-schema.js +2 -2
  110. package/src/agent/agent-task-executor.js +41 -2
  111. package/src/agent-cli-provider/acp-stdio-runner.ts +314 -0
  112. package/src/agent-cli-provider/adapters/acp.ts +493 -0
  113. package/src/agent-cli-provider/adapters/claude.ts +2 -32
  114. package/src/agent-cli-provider/adapters/copilot-parser.ts +166 -0
  115. package/src/agent-cli-provider/adapters/copilot.ts +231 -0
  116. package/src/agent-cli-provider/adapters/gateway.ts +186 -0
  117. package/src/agent-cli-provider/adapters/gemini.ts +9 -2
  118. package/src/agent-cli-provider/adapters/index.ts +5 -36
  119. package/src/agent-cli-provider/adapters/opencode.ts +4 -1
  120. package/src/agent-cli-provider/adapters/pi.ts +410 -0
  121. package/src/agent-cli-provider/claude-command.ts +47 -0
  122. package/src/agent-cli-provider/contract-actions.ts +14 -17
  123. package/src/agent-cli-provider/contract-invoke.ts +12 -3
  124. package/src/agent-cli-provider/contract-options.ts +72 -3
  125. package/src/agent-cli-provider/contract-support.ts +30 -3
  126. package/src/agent-cli-provider/errors.ts +14 -4
  127. package/src/agent-cli-provider/gateway-client.ts +170 -0
  128. package/src/agent-cli-provider/gateway-runner.ts +353 -0
  129. package/src/agent-cli-provider/gateway-tools.ts +616 -0
  130. package/src/agent-cli-provider/index.ts +28 -0
  131. package/src/agent-cli-provider/invoke-evidence.ts +24 -3
  132. package/src/agent-cli-provider/provider-registry.ts +515 -0
  133. package/src/agent-cli-provider/single-agent-runtime.ts +192 -18
  134. package/src/agent-cli-provider/types.ts +145 -4
  135. package/src/config-validator.js +33 -15
  136. package/src/isolation-manager.js +124 -19
  137. package/src/orchestrator.js +23 -10
  138. package/src/preflight.js +83 -30
  139. package/src/providers/capabilities.js +15 -42
  140. package/src/providers/index.js +52 -72
  141. package/src/worktree-claude-config.js +17 -0
  142. package/task-lib/commands/run.js +1 -0
  143. package/task-lib/provider-helper-runtime.js +11 -0
  144. package/task-lib/runner.js +7 -0
@@ -0,0 +1,300 @@
1
+ /**
2
+ * `zeroshot setup apply` — writes decisions from #A's plan/decision contract
3
+ * (lib/setup-plan.js) to global settings, repo-local settings, and the undo
4
+ * journal (lib/setup-journal.js).
5
+ *
6
+ * Fail-closed: every input decisionId + value is resolved and validated
7
+ * against its domain before ANY write happens. Writes are then applied only
8
+ * to settings keys a run-mode resolver actually reads (setup-plan.js's
9
+ * CONSUMED_PATHS/isConsumedPath — the same set that filters proposedWrites)
10
+ * — a settings key nobody reads is dead config, the exact drift the
11
+ * canonical-path rule (issue #605) forbids.
12
+ */
13
+
14
+ const { resolveDecisionPath, domainFor, isConsumedPath, CONSUMED_PATHS } = require('./setup-plan');
15
+ const { validateMountConfig, validateEnvPassthrough } = require('./docker-config');
16
+ const { VALID_PROVIDERS } = require('./provider-names');
17
+ const { VALID_MODELS, mapLegacyModelToLevel } = require('./settings');
18
+ const {
19
+ loadJournal,
20
+ saveJournal,
21
+ upsertJournalEntry,
22
+ getNestedValue,
23
+ setNestedValue,
24
+ deepEqual,
25
+ } = require('./setup-journal');
26
+
27
+ const SECRET_PATTERN = /token|secret|password|api[_-]?key|credential/i;
28
+
29
+ function assertSecretSafePath(targetPath) {
30
+ if (SECRET_PATTERN.test(targetPath)) {
31
+ throw new Error(`Refusing to write secret-shaped settings path: ${targetPath}`);
32
+ }
33
+ }
34
+
35
+ function domainError(decisionId, value) {
36
+ return new Error(
37
+ `Invalid value for decision "${decisionId}": expected ${domainFor(decisionId)}, got ${JSON.stringify(value)}`
38
+ );
39
+ }
40
+
41
+ // Resolves a submitted decision value into the raw form actually stored at
42
+ // its target settings path, validating it against #A's domain first.
43
+ // (defaultIsolation's domain is worktree|docker|none, but its target path is
44
+ // the boolean settings.defaultDocker — this is where that translation lives.)
45
+ function convertDecisionValue({ decisionId, value, globalSettings, deps }) {
46
+ switch (decisionId) {
47
+ case 'defaultProvider':
48
+ if (typeof value !== 'string' || !deps.VALID_PROVIDERS.includes(value)) {
49
+ throw domainError(decisionId, value);
50
+ }
51
+ return value;
52
+
53
+ case 'defaultIsolation':
54
+ if (!['worktree', 'docker', 'none'].includes(value)) throw domainError(decisionId, value);
55
+ return value === 'docker';
56
+
57
+ case 'allowLocalNoIsolation':
58
+ if (typeof value !== 'boolean') throw domainError(decisionId, value);
59
+ return value;
60
+
61
+ case 'defaultDelivery':
62
+ if (!['none', 'pr', 'ship'].includes(value)) throw domainError(decisionId, value);
63
+ return value;
64
+
65
+ case 'defaultIssueSource':
66
+ if (typeof value !== 'string' || !deps.listIssueProviders().includes(value)) {
67
+ throw domainError(decisionId, value);
68
+ }
69
+ return value;
70
+
71
+ case 'prBase':
72
+ if (typeof value !== 'string' || value.trim() === '') throw domainError(decisionId, value);
73
+ return value;
74
+
75
+ case 'dockerMounts': {
76
+ const err = validateMountConfig(value);
77
+ if (err) throw new Error(`Invalid value for decision "${decisionId}": ${err}`);
78
+ return value;
79
+ }
80
+
81
+ case 'dockerEnvPassthrough': {
82
+ const err = validateEnvPassthrough(value);
83
+ if (err) throw new Error(`Invalid value for decision "${decisionId}": ${err}`);
84
+ return value;
85
+ }
86
+
87
+ case 'updatePolicy':
88
+ if (!['off', 'notify', 'auto'].includes(value)) throw domainError(decisionId, value);
89
+ return value;
90
+
91
+ default:
92
+ if (decisionId.startsWith('providerLevel.')) {
93
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
94
+ throw domainError(decisionId, value);
95
+ }
96
+ for (const key of ['min', 'default', 'max']) {
97
+ if (!VALID_MODELS.includes(value[key])) throw domainError(decisionId, value);
98
+ }
99
+ const providerName = decisionId.slice('providerLevel.'.length);
100
+ const existing = getNestedValue(globalSettings, `providerSettings.${providerName}`) || {};
101
+ return {
102
+ ...existing,
103
+ minLevel: mapLegacyModelToLevel(value.min),
104
+ defaultLevel: mapLegacyModelToLevel(value.default),
105
+ maxLevel: mapLegacyModelToLevel(value.max),
106
+ };
107
+ }
108
+ throw new Error(`Unknown decision ID: ${decisionId}`);
109
+ }
110
+ }
111
+
112
+ function defaultApplyDeps() {
113
+ const fs = require('fs');
114
+ const { loadSettings, saveSettings } = require('./settings');
115
+ const { readRepoSettings, writeRepoSettings } = require('./repo-settings');
116
+ const { checkGhAuth } = require('../src/preflight');
117
+ const { listProviders: listIssueProviders } = require('../src/issue-providers');
118
+
119
+ return {
120
+ readFile: (filePath) => fs.readFileSync(filePath, 'utf8'),
121
+ loadSettings,
122
+ saveSettings,
123
+ readRepoSettings,
124
+ writeRepoSettings,
125
+ checkGhAuth,
126
+ listIssueProviders,
127
+ VALID_PROVIDERS,
128
+ now: () => new Date().toISOString(),
129
+ };
130
+ }
131
+
132
+ // Phase 1: resolve + validate EVERY input decision before any write happens
133
+ // (fail-closed — a single bad decisionId or out-of-domain value rejects the
134
+ // whole request, never a partial apply).
135
+ function resolveAndValidateDecisions(input, globalSettings, deps) {
136
+ const resolved = [];
137
+ for (const [decisionId, value] of Object.entries(input)) {
138
+ const target = resolveDecisionPath(decisionId);
139
+ if (!target) {
140
+ throw new Error(`Unknown decision ID: ${decisionId}`);
141
+ }
142
+ assertSecretSafePath(target.path);
143
+ const writeValue = convertDecisionValue({ decisionId, value, globalSettings, deps });
144
+ resolved.push({ decisionId, target, inputValue: value, writeValue });
145
+ }
146
+ return resolved;
147
+ }
148
+
149
+ // Decides the outcome for a single resolved decision: a skip (with reason)
150
+ // or the write to perform. Returns the write descriptor rather than mutating
151
+ // anything, so the caller controls when settings objects/journal are touched.
152
+ function resolveDecisionOutcome(
153
+ { decisionId, target, inputValue, writeValue },
154
+ { globalSettings, repoSettings, allowRiskyDefaults }
155
+ ) {
156
+ const settingsObj = target.scope === 'repo' ? repoSettings : globalSettings;
157
+ const currentValue = getNestedValue(settingsObj, target.path) ?? null;
158
+ const base = { decisionId, from: currentValue, to: writeValue };
159
+
160
+ if (decisionId === 'defaultDelivery' && inputValue === 'ship' && !allowRiskyDefaults) {
161
+ return { result: { ...base, applied: false, skippedReason: 'requires-explicit-opt-in' } };
162
+ }
163
+ if (!isConsumedPath(target.scope, target.path)) {
164
+ return { result: { ...base, applied: false, skippedReason: 'no-consumer' } };
165
+ }
166
+ if (deepEqual(currentValue, writeValue)) {
167
+ return { result: { ...base, applied: false, skippedReason: 'unchanged' } };
168
+ }
169
+
170
+ return {
171
+ result: { ...base, applied: true },
172
+ write: {
173
+ settingsObj,
174
+ scope: target.scope,
175
+ path: target.path,
176
+ writeValue,
177
+ priorValue: currentValue,
178
+ },
179
+ };
180
+ }
181
+
182
+ // Applies one decision's write to its in-memory settings object and journals
183
+ // it (deferred persistence — the caller flushes to disk once at the end).
184
+ function applyWrite(write, { repoRoot, journal, deps }) {
185
+ setNestedValue(write.settingsObj, write.path, write.writeValue);
186
+ upsertJournalEntry(journal, {
187
+ scope: write.scope,
188
+ path: write.path,
189
+ repoRoot: write.scope === 'repo' ? repoRoot : null,
190
+ priorValue: write.priorValue,
191
+ appliedValue: write.writeValue,
192
+ appliedAt: deps.now(),
193
+ });
194
+ return write.scope;
195
+ }
196
+
197
+ function persistIfDirty({
198
+ globalDirty,
199
+ repoDirty,
200
+ globalSettings,
201
+ repoSettings,
202
+ repoRoot,
203
+ journal,
204
+ deps,
205
+ }) {
206
+ if (globalDirty) deps.saveSettings(globalSettings);
207
+ if (repoDirty) deps.writeRepoSettings(repoRoot, repoSettings);
208
+ if (globalDirty || repoDirty) saveJournal(journal);
209
+ }
210
+
211
+ // Phase 2: write every resolved decision, journaling each mutation and
212
+ // skipping (never throwing) decisions that are unchanged, risky-without-optin,
213
+ // or targeted at a settings key no resolver consumes.
214
+ function writeResolvedDecisions(
215
+ resolved,
216
+ { globalSettings, repoSettings, repoRoot, journal, allowRiskyDefaults, deps }
217
+ ) {
218
+ const results = [];
219
+ let globalDirty = false;
220
+ let repoDirty = false;
221
+
222
+ for (const decision of resolved) {
223
+ const { result, write } = resolveDecisionOutcome(decision, {
224
+ globalSettings,
225
+ repoSettings,
226
+ allowRiskyDefaults,
227
+ });
228
+ results.push(result);
229
+ if (!write) continue;
230
+
231
+ const scope = applyWrite(write, { repoRoot, journal, deps });
232
+ if (scope === 'repo') repoDirty = true;
233
+ else globalDirty = true;
234
+ }
235
+
236
+ persistIfDirty({ globalDirty, repoDirty, globalSettings, repoSettings, repoRoot, journal, deps });
237
+
238
+ return results;
239
+ }
240
+
241
+ /**
242
+ * Apply a decisions file `{ "<decisionId>": <value>, ... }` to settings.
243
+ *
244
+ * @param {Object} params
245
+ * @param {string} params.decisionsPath - Path to the decisions JSON file.
246
+ * @param {string} params.cwd - Working directory (for repo-scope settings lookup).
247
+ * @param {boolean} [params.allowRiskyDefaults] - Required to store defaultDelivery='ship'.
248
+ * @param {Object} [params.deps] - Injected dependencies (for testing).
249
+ * @returns {Array<{decisionId: string, applied: boolean, from: *, to: *, skippedReason?: string}>}
250
+ */
251
+ function applyDecisions({ decisionsPath, cwd, allowRiskyDefaults = false, deps = {} }) {
252
+ const resolvedDeps = { ...defaultApplyDeps(), ...deps };
253
+
254
+ let input;
255
+ try {
256
+ input = JSON.parse(resolvedDeps.readFile(decisionsPath));
257
+ } catch (err) {
258
+ throw new Error(`Failed to read decisions file "${decisionsPath}": ${err.message}`);
259
+ }
260
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
261
+ throw new Error('Decisions file must be a JSON object of { decisionId: value }');
262
+ }
263
+
264
+ const globalSettings = resolvedDeps.loadSettings();
265
+ const { repoRoot, settings: repoSettingsRaw } = resolvedDeps.readRepoSettings(cwd);
266
+ const repoSettings = repoSettingsRaw || {};
267
+
268
+ const resolved = resolveAndValidateDecisions(input, globalSettings, resolvedDeps);
269
+
270
+ const results = writeResolvedDecisions(resolved, {
271
+ globalSettings,
272
+ repoSettings,
273
+ repoRoot,
274
+ journal: loadJournal(),
275
+ allowRiskyDefaults,
276
+ deps: resolvedDeps,
277
+ });
278
+
279
+ // Never store provider-auth secrets: print the login command instead.
280
+ const issueSourceApplied = results.find(
281
+ (r) => r.decisionId === 'defaultIssueSource' && r.applied
282
+ );
283
+ if (issueSourceApplied && issueSourceApplied.to === 'github') {
284
+ const auth = resolvedDeps.checkGhAuth();
285
+ if (!auth || !auth.authenticated) {
286
+ console.log('Run: gh auth login');
287
+ }
288
+ }
289
+
290
+ return results;
291
+ }
292
+
293
+ module.exports = {
294
+ applyDecisions,
295
+ resolveAndValidateDecisions,
296
+ writeResolvedDecisions,
297
+ assertSecretSafePath,
298
+ isConsumedPath,
299
+ CONSUMED_PATHS,
300
+ };
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Undo journal for `zeroshot setup apply` / `zeroshot setup undo`.
3
+ *
4
+ * One journal entry per setting this setup owns: { scope, path, repoRoot,
5
+ * priorValue, appliedValue, appliedAt }. `priorValue: null` means the key
6
+ * did not exist before apply, so undo deletes it rather than restoring it.
7
+ * Shared by lib/setup-apply.js (writer) and lib/setup-undo.js (reader) so the
8
+ * nested-path mutation and equality semantics can't drift between the two.
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { getSettingsFile } = require('./settings');
14
+ const { getNestedValue } = require('./setup-plan');
15
+
16
+ function getJournalPath() {
17
+ return path.join(path.dirname(getSettingsFile()), 'setup-undo-journal.json');
18
+ }
19
+
20
+ function loadJournal() {
21
+ const journalPath = getJournalPath();
22
+ if (!fs.existsSync(journalPath)) {
23
+ return { version: 1, entries: [] };
24
+ }
25
+ try {
26
+ const parsed = JSON.parse(fs.readFileSync(journalPath, 'utf8'));
27
+ if (!parsed || !Array.isArray(parsed.entries)) {
28
+ return { version: 1, entries: [] };
29
+ }
30
+ return parsed;
31
+ } catch {
32
+ return { version: 1, entries: [] };
33
+ }
34
+ }
35
+
36
+ function saveJournal(journal) {
37
+ const journalPath = getJournalPath();
38
+ fs.mkdirSync(path.dirname(journalPath), { recursive: true });
39
+ fs.writeFileSync(journalPath, JSON.stringify(journal, null, 2), 'utf8');
40
+ }
41
+
42
+ function entryKey(entry) {
43
+ return `${entry.scope}:${entry.repoRoot || ''}:${entry.path}`;
44
+ }
45
+
46
+ // Re-applying an already-journaled write updates appliedValue/appliedAt but
47
+ // keeps the original priorValue — that's the true pre-apply state undo must
48
+ // restore to, and it must not drift across repeated `apply` runs.
49
+ function upsertJournalEntry(journal, entry) {
50
+ const key = entryKey(entry);
51
+ const existingIndex = journal.entries.findIndex((e) => entryKey(e) === key);
52
+ if (existingIndex === -1) {
53
+ journal.entries.push(entry);
54
+ return;
55
+ }
56
+ journal.entries[existingIndex] = {
57
+ ...entry,
58
+ priorValue: journal.entries[existingIndex].priorValue,
59
+ };
60
+ }
61
+
62
+ function setNestedValue(target, pathStr, value) {
63
+ const keys = pathStr.split('.');
64
+ let node = target;
65
+ for (let i = 0; i < keys.length - 1; i++) {
66
+ const key = keys[i];
67
+ if (typeof node[key] !== 'object' || node[key] === null) {
68
+ node[key] = {};
69
+ }
70
+ node = node[key];
71
+ }
72
+ node[keys[keys.length - 1]] = value;
73
+ }
74
+
75
+ function deleteNestedKey(target, pathStr) {
76
+ const keys = pathStr.split('.');
77
+ let node = target;
78
+ for (let i = 0; i < keys.length - 1; i++) {
79
+ const key = keys[i];
80
+ if (typeof node[key] !== 'object' || node[key] === null) return;
81
+ node = node[key];
82
+ }
83
+ delete node[keys[keys.length - 1]];
84
+ }
85
+
86
+ function deepEqual(a, b) {
87
+ if (a === b) return true;
88
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false;
89
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
90
+ if (Array.isArray(a)) {
91
+ if (a.length !== b.length) return false;
92
+ return a.every((v, i) => deepEqual(v, b[i]));
93
+ }
94
+ const aKeys = Object.keys(a);
95
+ const bKeys = Object.keys(b);
96
+ if (aKeys.length !== bKeys.length) return false;
97
+ return aKeys.every((k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]));
98
+ }
99
+
100
+ module.exports = {
101
+ getJournalPath,
102
+ loadJournal,
103
+ saveJournal,
104
+ upsertJournalEntry,
105
+ getNestedValue,
106
+ setNestedValue,
107
+ deleteNestedKey,
108
+ deepEqual,
109
+ };