draftgo-cli 3.0.55 → 4.0.1

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 (50) hide show
  1. package/README.md +112 -316
  2. package/package.json +5 -5
  3. package/resources/skill/SKILL.md +25 -24
  4. package/resources/skill/init/SKILL.md +5 -10
  5. package/resources/skill/manifest.json +2 -2
  6. package/resources/skill/references/aihub.md +10 -5
  7. package/resources/skill/references/chat-sdk.md +10 -0
  8. package/resources/skill/references/checkout.md +4 -4
  9. package/resources/skill/references/custom-services.md +65 -226
  10. package/resources/skill/references/data.md +3 -2
  11. package/resources/skill/references/frontend.md +96 -490
  12. package/resources/skill/references/mcp.md +39 -103
  13. package/resources/skill/references/runtime.md +3 -2
  14. package/resources/skill/story/SKILL.md +1 -2
  15. package/src/apiContractCache.js +112 -0
  16. package/src/cli.js +1 -21
  17. package/src/commandRegistry.js +6 -11
  18. package/src/commands/api.js +28 -8
  19. package/src/commands/check.js +1 -10
  20. package/src/commands/customService.js +2 -4
  21. package/src/commands/delete.js +23 -46
  22. package/src/commands/deploy.js +1 -1
  23. package/src/commands/help.js +16 -31
  24. package/src/commands/init.js +4 -10
  25. package/src/commands/listTargets.js +1 -1
  26. package/src/commands/local.js +2 -6
  27. package/src/commands/map.js +0 -11
  28. package/src/commands/status.js +1 -1
  29. package/src/commands/uninstall.js +3 -3
  30. package/src/commands/update.js +1 -1
  31. package/src/commands/verify.js +43 -21
  32. package/src/commands/{verifyUi.js → visualVerify.js} +28 -116
  33. package/src/commands/worklog.js +86 -0
  34. package/src/customServices.js +150 -33
  35. package/src/{localdev → localRuntime}/detect.js +1 -1
  36. package/src/{localdev → localRuntime}/mysqlClient.js +1 -1
  37. package/src/{localdev → localRuntime}/services.js +1 -1
  38. package/src/projectConfig.js +2 -0
  39. package/src/{installers/index.js → targets.js} +3 -5
  40. package/src/worklog.js +274 -0
  41. package/src/workspaceHealth.js +1 -1
  42. package/src/worktree/index.js +81 -51
  43. package/src/changelog.js +0 -276
  44. package/src/commands/changelog.js +0 -24
  45. package/src/commands/localDev.js +0 -9
  46. package/src/commands/sync.js +0 -46
  47. package/src/commands/task.js +0 -408
  48. package/src/commands/verifyUiCompat.js +0 -16
  49. /package/src/{localdev → localRuntime}/compose.js +0 -0
  50. /package/src/{localdev → localRuntime}/index.js +0 -0
package/src/worklog.js ADDED
@@ -0,0 +1,274 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+ const { dgDir } = require('./paths');
7
+
8
+ const STATUS = Object.freeze({ pending: '', active: '●', completed: '√' });
9
+ const LOCK_WAIT_ARRAY = new Int32Array(new SharedArrayBuffer(4));
10
+
11
+ function formatLocalDate(value = new Date()) {
12
+ if (!(value instanceof Date) || Number.isNaN(value.getTime())) throw new TypeError('A valid Date is required.');
13
+ const year = String(value.getFullYear()).padStart(4, '0');
14
+ const month = String(value.getMonth() + 1).padStart(2, '0');
15
+ const day = String(value.getDate()).padStart(2, '0');
16
+ return `${year}-${month}-${day}`;
17
+ }
18
+
19
+ function worklogPath(projectDir) {
20
+ return path.join(dgDir(projectDir), 'worklog.md');
21
+ }
22
+
23
+ function invalid(detail) {
24
+ const error = new Error(`Invalid .draftgo/worklog.md: ${detail}`);
25
+ error.code = 'INVALID_WORKLOG';
26
+ return error;
27
+ }
28
+
29
+ function normalizeText(value, name = 'Worklog text') {
30
+ const text = value == null ? '' : String(value).trim();
31
+ if (!text) throw new TypeError(`${name} must not be empty.`);
32
+ if (/[\r\n\u2028\u2029]/.test(text)) throw new TypeError(`${name} must be a single line.`);
33
+ return text;
34
+ }
35
+
36
+ function normalizeDate(value, now = new Date()) {
37
+ const date = value == null || value === '' ? formatLocalDate(now) : String(value).trim();
38
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new TypeError('Worklog date must use YYYY-MM-DD.');
39
+ const [year, month, day] = date.split('-').map(Number);
40
+ const parsed = new Date(year, month - 1, day);
41
+ if (parsed.getFullYear() !== year || parsed.getMonth() !== month - 1 || parsed.getDate() !== day) {
42
+ throw new TypeError(`Invalid worklog date: ${date}.`);
43
+ }
44
+ return date;
45
+ }
46
+
47
+ function parseStatus(marker) {
48
+ if (marker === '●') return 'active';
49
+ if (marker === '√') return 'completed';
50
+ if (marker === '') return 'pending';
51
+ throw invalid(`unsupported status marker ${JSON.stringify(marker)}.`);
52
+ }
53
+
54
+ function parseWorklog(source) {
55
+ if (typeof source !== 'string') throw new TypeError('Worklog source must be a string.');
56
+ if (!source) return [];
57
+ if (/\r(?!\n)/.test(source)) throw invalid('unsupported line ending.');
58
+ const normalized = source.replace(/\r\n/g, '\n');
59
+ const body = normalized.endsWith('\n') ? normalized.slice(0, -1) : normalized;
60
+ if (!body) return [];
61
+ const lines = body.split('\n');
62
+ const blocks = [];
63
+ let cursor = 0;
64
+
65
+ while (cursor < lines.length) {
66
+ const header = /^\[\s*(\d{4}-\d{2}-\d{2})\s*\]$/.exec(lines[cursor]);
67
+ if (!header) throw invalid(`expected a [ YYYY-MM-DD ] header at line ${cursor + 1}.`);
68
+ const date = normalizeDate(header[1]);
69
+ if (blocks.length && date <= blocks[blocks.length - 1].date) {
70
+ throw invalid(`date blocks must be strictly chronological (line ${cursor + 1}).`);
71
+ }
72
+ cursor += 1;
73
+ const entries = [];
74
+ while (cursor < lines.length && lines[cursor] !== '') {
75
+ const itemMatch = /^([1-9]\d*)\.\s+\[\s*(●|√)?\s*\]\s+(.+)$/.exec(lines[cursor]);
76
+ if (!itemMatch) throw invalid(`invalid item at line ${cursor + 1}.`);
77
+ const number = Number(itemMatch[1]);
78
+ if (!Number.isSafeInteger(number) || number !== entries.length + 1) {
79
+ throw invalid(`items must be consecutively numbered from 1 (line ${cursor + 1}).`);
80
+ }
81
+ const title = normalizeText(itemMatch[3], 'Worklog item');
82
+ const status = parseStatus(itemMatch[2] || '');
83
+ cursor += 1;
84
+ const notes = [];
85
+ while (cursor < lines.length && lines[cursor].startsWith('//')) {
86
+ const note = lines[cursor].slice(2).trim();
87
+ if (note) notes.push(note);
88
+ cursor += 1;
89
+ }
90
+ entries.push({ number, title, status, notes });
91
+ }
92
+ if (!entries.length) throw invalid(`date block ${date} has no items.`);
93
+ blocks.push({ date, entries });
94
+ if (cursor >= lines.length) break;
95
+ cursor += 1;
96
+ if (cursor >= lines.length || lines[cursor] === '') {
97
+ throw invalid(`date blocks must be separated by one blank line (line ${cursor + 1}).`);
98
+ }
99
+ }
100
+ return blocks;
101
+ }
102
+
103
+ function renderStatus(status) {
104
+ const marker = STATUS[status];
105
+ if (marker === undefined) throw new TypeError(`Unknown worklog status: ${status}.`);
106
+ return marker ? `[ ${marker} ]` : '[ ]';
107
+ }
108
+
109
+ function renderWorklog(blocks) {
110
+ if (!Array.isArray(blocks)) throw new TypeError('Worklog blocks must be an array.');
111
+ if (!blocks.length) return '';
112
+ return `${blocks.map((block) => [
113
+ `[ ${block.date} ]`,
114
+ ...block.entries.flatMap((entry) => [
115
+ `${entry.number}. ${renderStatus(entry.status)} ${entry.title}`,
116
+ ...(entry.notes || []).map((note) => `// ${note}`),
117
+ ]),
118
+ ].join('\n')).join('\n\n')}\n`;
119
+ }
120
+
121
+ function cloneBlocks(blocks) {
122
+ return blocks.map((block) => ({
123
+ ...block,
124
+ entries: block.entries.map((entry) => ({ ...entry, notes: [...entry.notes] })),
125
+ }));
126
+ }
127
+
128
+ function appendItem(blocks, title, status = 'pending', notes = [], date = normalizeDate()) {
129
+ const next = cloneBlocks(blocks);
130
+ const last = next[next.length - 1];
131
+ if (last && date < last.date) throw new Error(`Cannot append ${date}; the latest worklog date is ${last.date}.`);
132
+ if (last && last.date === date) {
133
+ last.entries.push({ number: last.entries.length + 1, title, status, notes: [...notes] });
134
+ } else {
135
+ next.push({ date, entries: [{ number: 1, title, status, notes: [...notes] }] });
136
+ }
137
+ return next;
138
+ }
139
+
140
+ function resolveReference(blocks, reference) {
141
+ const value = String(reference || '').trim();
142
+ const match = /^(?:([^#]+)#)?([1-9]\d*)$/.exec(value);
143
+ if (!match) throw new Error('Worklog item reference must be a number or YYYY-MM-DD#number.');
144
+ const date = match[1];
145
+ const number = Number(match[2]);
146
+ const block = date ? blocks.find((entry) => entry.date === normalizeDate(date)) : blocks[blocks.length - 1];
147
+ if (!block) throw new Error(`No worklog entries exist for ${date || 'the latest date'}.`);
148
+ const entry = block.entries.find((item) => item.number === number);
149
+ if (!entry) throw new Error(`Worklog item ${value} was not found.`);
150
+ return { block, entry };
151
+ }
152
+
153
+ function updateItem(blocks, reference, status, note) {
154
+ const next = cloneBlocks(blocks);
155
+ const resolved = resolveReference(next, reference);
156
+ resolved.entry.status = status;
157
+ if (note) resolved.entry.notes.push(note);
158
+ return next;
159
+ }
160
+
161
+ function temporaryPath(destination) {
162
+ return path.join(path.dirname(destination), `.${path.basename(destination)}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`);
163
+ }
164
+
165
+ function writeTextAtomic(destination, content) {
166
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
167
+ const temporary = temporaryPath(destination);
168
+ let descriptor;
169
+ try {
170
+ descriptor = fs.openSync(temporary, 'wx', 0o666);
171
+ fs.writeFileSync(descriptor, content, 'utf8');
172
+ fs.fsyncSync(descriptor);
173
+ fs.closeSync(descriptor);
174
+ descriptor = undefined;
175
+ fs.renameSync(temporary, destination);
176
+ } finally {
177
+ if (descriptor !== undefined) {
178
+ try { fs.closeSync(descriptor); } catch { /* Preserve the original failure. */ }
179
+ }
180
+ try { fs.rmSync(temporary, { force: true }); } catch { /* Best-effort cleanup. */ }
181
+ }
182
+ }
183
+
184
+ function removeStaleLock(lockPath, staleMs) {
185
+ try {
186
+ if (Date.now() - fs.lstatSync(lockPath).mtimeMs < staleMs) return false;
187
+ fs.rmSync(lockPath, { force: true });
188
+ return true;
189
+ } catch (error) {
190
+ if (error && error.code === 'ENOENT') return true;
191
+ throw error;
192
+ }
193
+ }
194
+
195
+ function acquireLock(destination, options = {}) {
196
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
197
+ const lockPath = `${destination}.lock`;
198
+ const timeoutMs = Number(options.lockTimeoutMs ?? 10_000);
199
+ const staleMs = Number(options.lockStaleMs ?? 60_000);
200
+ const retryMs = Number(options.lockRetryMs ?? 10);
201
+ if (![timeoutMs, staleMs, retryMs].every(Number.isSafeInteger) || timeoutMs < 0 || staleMs < 1 || retryMs < 1) {
202
+ throw new TypeError('Invalid worklog lock timing options.');
203
+ }
204
+ const token = `${process.pid}:${crypto.randomBytes(16).toString('hex')}`;
205
+ const started = Date.now();
206
+ while (true) {
207
+ let descriptor;
208
+ try {
209
+ descriptor = fs.openSync(lockPath, 'wx', 0o600);
210
+ fs.writeFileSync(descriptor, `${token}\n`, 'utf8');
211
+ fs.fsyncSync(descriptor);
212
+ fs.closeSync(descriptor);
213
+ return { path: lockPath, token };
214
+ } catch (error) {
215
+ if (descriptor !== undefined) {
216
+ try { fs.closeSync(descriptor); } catch { /* Preserve the original failure. */ }
217
+ }
218
+ if (!error || error.code !== 'EEXIST') throw error;
219
+ }
220
+ if (removeStaleLock(lockPath, staleMs)) continue;
221
+ const elapsed = Date.now() - started;
222
+ if (elapsed >= timeoutMs) {
223
+ const error = new Error('Timed out waiting for .draftgo/worklog.md.lock.');
224
+ error.code = 'WORKLOG_LOCK_TIMEOUT';
225
+ throw error;
226
+ }
227
+ Atomics.wait(LOCK_WAIT_ARRAY, 0, 0, Math.min(retryMs, timeoutMs - elapsed));
228
+ }
229
+ }
230
+
231
+ function releaseLock(lock) {
232
+ try {
233
+ if (fs.readFileSync(lock.path, 'utf8').trim() === lock.token) fs.rmSync(lock.path, { force: true });
234
+ } catch (error) {
235
+ if (!error || error.code !== 'ENOENT') throw error;
236
+ }
237
+ }
238
+
239
+ function mutateWorklog(projectDir, mutate, options = {}) {
240
+ const file = worklogPath(projectDir);
241
+ const lock = acquireLock(file, options);
242
+ try {
243
+ const source = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
244
+ const blocks = parseWorklog(source);
245
+ const result = mutate(blocks);
246
+ const next = result && result.blocks ? result.blocks : result;
247
+ writeTextAtomic(file, renderWorklog(next));
248
+ return { ...(result && result.blocks ? result : { blocks: next }), path: file };
249
+ } finally {
250
+ releaseLock(lock);
251
+ }
252
+ }
253
+
254
+ function readWorklog(projectDir) {
255
+ const file = worklogPath(projectDir);
256
+ return parseWorklog(fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '');
257
+ }
258
+
259
+ module.exports = {
260
+ STATUS,
261
+ formatLocalDate,
262
+ worklogPath,
263
+ normalizeDate,
264
+ parseWorklog,
265
+ renderWorklog,
266
+ appendItem,
267
+ resolveReference,
268
+ updateItem,
269
+ mutateWorklog,
270
+ readWorklog,
271
+ acquireLock,
272
+ releaseLock,
273
+ writeTextAtomic,
274
+ };
@@ -4,7 +4,7 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const runtime = require('./runtimeFiles');
6
6
 
7
- const PROTECTED_PREFIXES = ['worktree/', 'conflicts/', 'Task/', 'lessons/', 'tmp/', 'config.json', 'changelog.md', 'story.yaml', '.version', 'runtime-manifest.json'];
7
+ const PROTECTED_PREFIXES = ['worktree/', 'conflicts/', 'lessons/', 'tmp/', 'config.json', 'api-contract-cache.json', 'api-contract-cache.json.lock', 'worklog.md', 'story.yaml', '.version', 'runtime-manifest.json'];
8
8
  function workspaceHealth(projectDir) {
9
9
  const root = runtime.draftgoRoot(projectDir); const registered = new Set(runtime.load(projectDir).entries.map((entry) => String(entry.path).replace(/\\/g, '/')));
10
10
  const summary = { managed_files: 0, unknown_files: 0, temporary_files: 0, artifact_files: 0, total_bytes: 0, reclaimable_bytes: 0, warnings: [] };
@@ -72,7 +72,12 @@ async function createSession(config, options) {
72
72
  }
73
73
 
74
74
  function ensureIds(resourceIds) {
75
- const ids = resourceIds.map((value) => String(value).trim()).filter(Boolean);
75
+ const seen = new Set();
76
+ const ids = resourceIds.map((value) => String(value).trim()).filter((value) => {
77
+ if (!value || seen.has(value)) return false;
78
+ seen.add(value);
79
+ return true;
80
+ });
76
81
  if (!ids.length) throw new WorktreeError('INVALID_RESOURCE_ID', 'At least one resource id is required.');
77
82
  return ids;
78
83
  }
@@ -95,9 +100,7 @@ async function checkoutResources(projectDir, resourceType, resourceIds, options
95
100
  const backend = backendFor(options);
96
101
  const session = await createSession(config, options);
97
102
  const manifest = loadManifest(projectDir);
98
- const results = [];
99
-
100
- for (const resourceId of ids) {
103
+ const settled = await Promise.allSettled(ids.map(async (resourceId) => {
101
104
  const metadata = await backend.resolveMetadata(config, canonical, resourceId, {
102
105
  ...options,
103
106
  ...session,
@@ -154,9 +157,28 @@ async function checkoutResources(projectDir, resourceType, resourceIds, options
154
157
  updated_at: metadata.updated_at,
155
158
  updated_by: metadata.updated_by,
156
159
  };
157
- manifest.entries[entryKey(canonical, resourceId)] = entry;
158
- await saveManifest(projectDir, manifest);
159
- results.push(entry);
160
+ return entry;
161
+ }));
162
+ const results = settled.filter((item) => item.status === 'fulfilled').map((item) => item.value);
163
+ for (const entry of results) manifest.entries[entryKey(canonical, entry.resource_id)] = entry;
164
+ if (results.length) await saveManifest(projectDir, manifest);
165
+ const failures = settled.filter((item) => item.status === 'rejected');
166
+ if (failures.length) {
167
+ const first = failures[0].reason;
168
+ first.details = {
169
+ ...(first.details || {}),
170
+ batch: {
171
+ completed: results.map((entry) => ({
172
+ resource_type: entry.resource_type, resource_id: entry.resource_id,
173
+ status: 'checked_out', local_path: entry.local_path,
174
+ })),
175
+ failed: failures.map((item) => ({
176
+ status: 'failed', code: item.reason.code || 'CHECKOUT_FAILED', message: item.reason.message,
177
+ })),
178
+ not_started: [],
179
+ },
180
+ };
181
+ throw first;
160
182
  }
161
183
  return results;
162
184
  }
@@ -264,9 +286,9 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
264
286
  if (typeof options.onStatus === 'function') options.onStatus(result);
265
287
  };
266
288
 
267
- // Resolve and validate every target before the first remote write. This prevents
268
- // a late local or stale-version error from causing an avoidable partial batch.
269
- for (const resourceId of ids) {
289
+ // Resolve and validate every target before the first remote write. Different
290
+ // resources are independent, so preflight can run without a client-side cap.
291
+ const preflight = await Promise.all(ids.map(async (resourceId) => {
270
292
  try {
271
293
  const entry = getEntry(manifest, canonical, resourceId);
272
294
  if (!entry) {
@@ -312,7 +334,7 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
312
334
  state,
313
335
  );
314
336
  }
315
- plans.push({ resourceId, entry, localPath, current, fresh });
337
+ return { plan: { resourceId, entry, localPath, current, fresh } };
316
338
  } catch (error) {
317
339
  if (error.code === 'REMOTE_VERSION_CHANGED') {
318
340
  const entry = getEntry(manifest, canonical, resourceId);
@@ -327,7 +349,7 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
327
349
  error = conflictError;
328
350
  }
329
351
  }
330
- preflightFailures.push({
352
+ return { failure: {
331
353
  resource_type: canonical,
332
354
  resource_id: resourceId,
333
355
  status: 'failed',
@@ -335,8 +357,12 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
335
357
  code: error.code || 'PREFLIGHT_FAILED',
336
358
  message: error.message,
337
359
  details: error.details || {},
338
- });
360
+ } };
339
361
  }
362
+ }));
363
+ for (const item of preflight) {
364
+ if (item.plan) plans.push(item.plan);
365
+ else preflightFailures.push(item.failure);
340
366
  }
341
367
 
342
368
  if (preflightFailures.length) {
@@ -363,40 +389,10 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
363
389
  );
364
390
  }
365
391
 
366
- function throwBatchFailure(error, index, resourceId, remoteChangePossible = false) {
367
- const failed = {
368
- resource_type: canonical,
369
- resource_id: resourceId,
370
- status: 'failed',
371
- phase: 'upload',
372
- code: error.code || 'COMMIT_FAILED',
373
- message: error.message,
374
- remote_change_possible: remoteChangePossible,
375
- };
376
- const notStarted = plans.slice(index + 1).map((plan) => ({
377
- resource_type: canonical,
378
- resource_id: plan.resourceId,
379
- status: 'not_started',
380
- phase: 'upload',
381
- }));
382
- report(failed);
383
- for (const item of notStarted) report(item);
384
- error.details = {
385
- ...(error.details || {}),
386
- batch: {
387
- completed: results.filter((item) => ['committed', 'unchanged'].includes(item.status)),
388
- failed: [failed],
389
- not_started: notStarted,
390
- },
391
- };
392
- throw error;
393
- }
394
-
395
- for (let index = 0; index < plans.length; index += 1) {
396
- const { resourceId, entry, localPath, current, fresh } = plans[index];
392
+ async function commitPlan(plan) {
393
+ const { resourceId, entry, localPath, current, fresh } = plan;
397
394
  if (current.hash === entry.base_hash) {
398
- report({ resource_type: canonical, resource_id: resourceId, status: 'unchanged', hash: current.hash });
399
- continue;
395
+ return { resource_type: canonical, resource_id: resourceId, status: 'unchanged', hash: current.hash };
400
396
  }
401
397
  const commitMetadata = {
402
398
  ...fresh,
@@ -420,7 +416,12 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
420
416
  conflict,
421
417
  );
422
418
  }
423
- throwBatchFailure(failure, index, resourceId, !versionConflict);
419
+ failure.batchResult = {
420
+ resource_type: canonical, resource_id: resourceId, status: 'failed', phase: 'upload',
421
+ code: failure.code || 'COMMIT_FAILED', message: failure.message,
422
+ remote_change_possible: !versionConflict,
423
+ };
424
+ throw failure;
424
425
  }
425
426
 
426
427
  try {
@@ -458,18 +459,47 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
458
459
  entry.updated_at = committed.updated_at;
459
460
  entry.updated_by = committed.updated_by;
460
461
  entry.committed_at = new Date().toISOString();
461
- await saveManifest(projectDir, manifest);
462
- report({
462
+ return {
463
463
  resource_type: canonical,
464
464
  resource_id: resourceId,
465
465
  status: 'committed',
466
466
  hash: current.hash,
467
467
  base_version: entry.base_version,
468
468
  base_revision: entry.base_revision,
469
- });
469
+ };
470
+ } catch (error) {
471
+ error.batchResult = {
472
+ resource_type: canonical, resource_id: resourceId, status: 'failed', phase: 'upload',
473
+ code: error.code || 'COMMIT_FAILED', message: error.message, remote_change_possible: true,
474
+ };
475
+ throw error;
476
+ }
477
+ }
478
+
479
+ const settled = await Promise.allSettled(plans.map(async (plan) => {
480
+ try {
481
+ const result = await commitPlan(plan);
482
+ report(result);
483
+ return result;
470
484
  } catch (error) {
471
- throwBatchFailure(error, index, resourceId, true);
485
+ report(error.batchResult);
486
+ throw error;
472
487
  }
488
+ }));
489
+ const committed = results.filter((item) => item.status === 'committed');
490
+ if (committed.length) await saveManifest(projectDir, manifest);
491
+ const failures = settled.filter((item) => item.status === 'rejected');
492
+ if (failures.length) {
493
+ const first = failures[0].reason;
494
+ first.details = {
495
+ ...(first.details || {}),
496
+ batch: {
497
+ completed: results.filter((item) => ['committed', 'unchanged'].includes(item.status)),
498
+ failed: results.filter((item) => item.status === 'failed'),
499
+ not_started: [],
500
+ },
501
+ };
502
+ throw first;
473
503
  }
474
504
  return results;
475
505
  }