draftgo-cli 3.0.44 → 3.0.49

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 (39) hide show
  1. package/README.md +12 -6
  2. package/package.json +1 -1
  3. package/resources/skill/SKILL.md +63 -136
  4. package/resources/skill/manifest.json +1 -3
  5. package/resources/skill/references/aihub.md +3 -4
  6. package/resources/skill/references/app-api.md +6 -51
  7. package/resources/skill/references/architecture.md +3 -28
  8. package/resources/skill/references/checkout.md +2 -1
  9. package/resources/skill/references/custom-services.md +4 -15
  10. package/resources/skill/references/data.md +1 -118
  11. package/resources/skill/references/frontend.md +26 -68
  12. package/resources/skill/references/mcp.md +47 -0
  13. package/resources/skill/references/modules.md +7 -7
  14. package/resources/skill/references/runtime.md +3 -24
  15. package/src/cli.js +2 -0
  16. package/src/commandRegistry.js +1 -0
  17. package/src/commands/api.js +102 -0
  18. package/src/commands/autoPush.js +1 -1
  19. package/src/commands/check.js +24 -2
  20. package/src/commands/commit.js +31 -5
  21. package/src/commands/deploy.js +1 -1
  22. package/src/commands/help.js +11 -3
  23. package/src/commands/init.js +1 -1
  24. package/src/commands/map.js +20 -22
  25. package/src/commands/mcp.js +26 -3
  26. package/src/commands/reconcile.js +20 -0
  27. package/src/commands/verifyUi.js +92 -10
  28. package/src/context/index.js +117 -47
  29. package/src/mcp/client.js +52 -19
  30. package/src/projectMap.js +7 -2
  31. package/src/worktree/index.js +272 -59
  32. package/src/worktree/status.js +122 -0
  33. package/resources/skill/push/SKILL.md +0 -62
  34. package/resources/skill/references/api-endpoints.md +0 -180
  35. package/resources/skill/references/debugging-syntax.md +0 -308
  36. package/resources/skill/references/parallel.md +0 -56
  37. package/resources/skill/references/security.md +0 -74
  38. package/resources/skill/references/ui-protocol.md +0 -99
  39. package/resources/skill/scripts/README.md +0 -10
package/src/mcp/client.js CHANGED
@@ -225,16 +225,30 @@ class DraftGoMcpClient {
225
225
  }
226
226
 
227
227
  async initialize(options = {}) {
228
- const result = await this.request('initialize', {
229
- protocolVersion: options.protocolVersion || this.protocolVersion,
230
- capabilities: options.capabilities || {},
231
- clientInfo: options.clientInfo || {
232
- name: 'draftgo-cli',
233
- version: pkg.version,
234
- },
235
- }, options);
228
+ if (typeof options.onStage === 'function') options.onStage('initialize', 'started');
229
+ let result;
230
+ try {
231
+ result = await this.request('initialize', {
232
+ protocolVersion: options.protocolVersion || this.protocolVersion,
233
+ capabilities: options.capabilities || {},
234
+ clientInfo: options.clientInfo || {
235
+ name: 'draftgo-cli',
236
+ version: pkg.version,
237
+ },
238
+ }, options);
239
+ } catch (error) {
240
+ if (typeof options.onStage === 'function') options.onStage('initialize', 'failed');
241
+ throw error;
242
+ }
243
+ if (typeof options.onStage === 'function') options.onStage('initialize', 'succeeded', result);
236
244
  if (result && result.protocolVersion) this.protocolVersion = result.protocolVersion;
237
- await this.notify('notifications/initialized', undefined, options);
245
+ try {
246
+ await this.notify('notifications/initialized', undefined, options);
247
+ } catch (error) {
248
+ if (typeof options.onStage === 'function') options.onStage('initialized', 'failed');
249
+ throw error;
250
+ }
251
+ if (typeof options.onStage === 'function') options.onStage('initialized', 'succeeded');
238
252
  return result;
239
253
  }
240
254
 
@@ -279,7 +293,15 @@ class DraftGoMcpClient {
279
293
 
280
294
  async testConnection(options = {}) {
281
295
  const initialized = await this.initialize(options);
282
- const tools = await this.listAllTools(options);
296
+ if (typeof options.onStage === 'function') options.onStage('tools/list', 'started');
297
+ let tools;
298
+ try {
299
+ tools = await this.listAllTools(options);
300
+ } catch (error) {
301
+ if (typeof options.onStage === 'function') options.onStage('tools/list', 'failed');
302
+ throw error;
303
+ }
304
+ if (typeof options.onStage === 'function') options.onStage('tools/list', 'succeeded', { count: tools.length });
283
305
  const requiredTools = options.requiredTools || REQUIRED_DRAFTGO_TOOLS;
284
306
  const missing = requiredTools.filter((expected) => !tools.some((tool) =>
285
307
  tool && typeof tool.name === 'string' && toolMatches(tool.name, expected)));
@@ -325,21 +347,32 @@ class DraftGoMcpClient {
325
347
  },
326
348
  ];
327
349
  const runPlan = (plan, startIndex = 0) => allWithAbort(plan.map((entry, offset) =>
328
- async (queryOptions) => ({
350
+ async (queryOptions) => {
351
+ if (typeof options.onStage === 'function') options.onStage(`tools/call:${entry.label}`, 'started');
352
+ let result;
353
+ try {
354
+ result = await this.toolsCall(
355
+ entry.tool.name,
356
+ options.toolArguments && startIndex + offset === 0
357
+ ? options.toolArguments
358
+ : entry.args,
359
+ queryOptions,
360
+ );
361
+ } catch (error) {
362
+ if (typeof options.onStage === 'function') options.onStage(`tools/call:${entry.label}`, 'failed');
363
+ throw error;
364
+ }
365
+ if (typeof options.onStage === 'function') options.onStage(`tools/call:${entry.label}`, 'succeeded');
366
+ return ({
329
367
  label: entry.label,
330
368
  canonical: entry.canonical,
331
369
  name: entry.tool.name,
332
370
  arguments: options.toolArguments && startIndex + offset === 0
333
371
  ? options.toolArguments
334
372
  : entry.args,
335
- result: await this.toolsCall(
336
- entry.tool.name,
337
- options.toolArguments && startIndex + offset === 0
338
- ? options.toolArguments
339
- : entry.args,
340
- queryOptions,
341
- ),
342
- })), options);
373
+ result,
374
+ });
375
+ }), options);
343
376
  const tested = await runPlan(defaultPlan);
344
377
  if (!hasDiagnosticOverrides) {
345
378
  let discovery = tested.find((entry) => entry.label === 'api:db_meta');
package/src/projectMap.js CHANGED
@@ -148,8 +148,13 @@ function analyzeProject(projectDir) {
148
148
  }
149
149
  if (!fs.existsSync(base)) {
150
150
  errors.push(`${entry.resource_type} ${entry.resource_id}: checkout base is missing (${entry.base_path}).`);
151
- } else if (sha256File(base) !== entry.base_hash) {
152
- errors.push(`${entry.resource_type} ${entry.resource_id}: checkout base hash does not match the manifest.`);
151
+ } else {
152
+ const actualBaseHash = sha256File(base);
153
+ if (actualBaseHash !== entry.base_hash) {
154
+ errors.push(`${entry.resource_type} ${entry.resource_id}: checkout base hash does not match the manifest `
155
+ + `(manifest=${entry.base_hash}, base=${actualBaseHash}). `
156
+ + `Run \`draftgo check --remote\` to classify the mismatch before replacing files.`);
157
+ }
153
158
  }
154
159
 
155
160
  const type = mediaType(entry.content_type);
@@ -15,7 +15,14 @@ const {
15
15
  resourceFileName,
16
16
  safeIdSegment,
17
17
  } = require('./types');
18
- const { streamToFiles, hashFile, copyFileAtomic, writeJsonAtomic, normalizeSha256 } = require('./streams');
18
+ const {
19
+ streamToFiles,
20
+ hashFile,
21
+ copyFileAtomic,
22
+ writeJsonAtomic,
23
+ normalizeSha256,
24
+ tempPathFor,
25
+ } = require('./streams');
19
26
  const {
20
27
  loadManifest,
21
28
  getEntry,
@@ -24,6 +31,7 @@ const {
24
31
  saveManifest,
25
32
  } = require('./manifest');
26
33
  const { validateContentFile } = require('./validate');
34
+ const { inspectEntry, openMetadataSession, sameVersion } = require('./status');
27
35
 
28
36
  const CONFLICT_SCHEMA_VERSION = 1;
29
37
 
@@ -248,36 +256,148 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
248
256
  const session = await createSession(config, options);
249
257
  const manifest = loadManifest(projectDir);
250
258
  const results = [];
259
+ const plans = [];
260
+ const preflightFailures = [];
261
+
262
+ const report = (result) => {
263
+ results.push(result);
264
+ if (typeof options.onStatus === 'function') options.onStatus(result);
265
+ };
251
266
 
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.
252
269
  for (const resourceId of ids) {
253
- const entry = getEntry(manifest, canonical, resourceId);
254
- if (!entry) {
255
- throw new WorktreeError('RESOURCE_NOT_CHECKED_OUT', `${canonical} ${resourceId} is not checked out.`);
256
- }
257
- if (entry.server !== config.server) {
258
- throw new WorktreeError('CHECKOUT_SERVER_MISMATCH', 'Checkout belongs to a different DraftGo server.');
270
+ try {
271
+ const entry = getEntry(manifest, canonical, resourceId);
272
+ if (!entry) {
273
+ throw new WorktreeError('RESOURCE_NOT_CHECKED_OUT', `${canonical} ${resourceId} is not checked out.`);
274
+ }
275
+ if (entry.server !== config.server) {
276
+ throw new WorktreeError('CHECKOUT_SERVER_MISMATCH', 'Checkout belongs to a different DraftGo server.');
277
+ }
278
+ if (unresolvedConflict(projectDir, canonical, resourceId)) {
279
+ throw new WorktreeError(
280
+ 'UNRESOLVED_CONFLICT',
281
+ `${canonical} ${resourceId} has an unresolved conflict; resolve it before committing.`,
282
+ );
283
+ }
284
+
285
+ const localPath = absolutePath(projectDir, entry.local_path);
286
+ const current = await hashFile(localPath);
287
+ const basePath = absolutePath(projectDir, entry.base_path);
288
+ const base = await hashFile(basePath);
289
+ if (base.hash !== entry.base_hash) {
290
+ throw new WorktreeError(
291
+ 'BASE_HASH_MISMATCH',
292
+ `${canonical} ${resourceId} base hash differs from its manifest; run draftgo check --remote.`,
293
+ { manifest_hash: entry.base_hash, base_hash: base.hash },
294
+ );
295
+ }
296
+ if (current.hash !== entry.base_hash) validateContentFile(localPath, entry.content_type);
297
+
298
+ const fresh = await backend.resolveMetadata(config, canonical, resourceId, {
299
+ ...options,
300
+ ...session,
301
+ clientInitialized: true,
302
+ });
303
+ assertMetadataIdentity(fresh, canonical, resourceId);
304
+ const remoteMatchesBase = fresh.content_hash === entry.base_hash && sameVersion(entry, fresh);
305
+ if (!remoteMatchesBase) {
306
+ const state = await inspectEntry(projectDir, entry, fresh);
307
+ throw new WorktreeError(
308
+ state.local_matches_remote ? 'COMMITTED_UNRECORDED' : 'REMOTE_VERSION_CHANGED',
309
+ state.local_matches_remote
310
+ ? `${canonical} ${resourceId} already equals remote; run ${state.recommendation}.`
311
+ : `${canonical} ${resourceId} changed remotely; inspect with draftgo check --remote before committing.`,
312
+ state,
313
+ );
314
+ }
315
+ plans.push({ resourceId, entry, localPath, current, fresh });
316
+ } catch (error) {
317
+ if (error.code === 'REMOTE_VERSION_CHANGED') {
318
+ const entry = getEntry(manifest, canonical, resourceId);
319
+ try {
320
+ const conflict = await writeConflict(projectDir, entry, error, config, backend, options, session);
321
+ error = new WorktreeError(
322
+ 'RESOURCE_VERSION_CONFLICT',
323
+ `${canonical} ${resourceId} changed remotely; conflict materials were preserved during preflight.`,
324
+ conflict,
325
+ );
326
+ } catch (conflictError) {
327
+ error = conflictError;
328
+ }
329
+ }
330
+ preflightFailures.push({
331
+ resource_type: canonical,
332
+ resource_id: resourceId,
333
+ status: 'failed',
334
+ phase: 'preflight',
335
+ code: error.code || 'PREFLIGHT_FAILED',
336
+ message: error.message,
337
+ details: error.details || {},
338
+ });
259
339
  }
260
- if (unresolvedConflict(projectDir, canonical, resourceId)) {
261
- throw new WorktreeError(
262
- 'UNRESOLVED_CONFLICT',
263
- `${canonical} ${resourceId} has an unresolved conflict; resolve it before committing.`,
340
+ }
341
+
342
+ if (preflightFailures.length) {
343
+ const failedIds = new Set(preflightFailures.map((item) => String(item.resource_id)));
344
+ const notStarted = ids.filter((id) => !failedIds.has(String(id))).map((resourceId) => ({
345
+ resource_type: canonical,
346
+ resource_id: resourceId,
347
+ status: 'not_started',
348
+ phase: 'preflight',
349
+ }));
350
+ for (const result of [...preflightFailures, ...notStarted]) report(result);
351
+ if (ids.length === 1 && preflightFailures.length === 1) {
352
+ const original = new WorktreeError(
353
+ preflightFailures[0].code,
354
+ preflightFailures[0].message,
355
+ preflightFailures[0].details,
264
356
  );
357
+ throw original;
265
358
  }
359
+ throw new WorktreeError(
360
+ 'BATCH_PREFLIGHT_FAILED',
361
+ `Commit preflight failed for ${preflightFailures.length} resource(s); no remote content was changed.`,
362
+ { completed: [], failed: preflightFailures, not_started: notStarted },
363
+ );
364
+ }
266
365
 
267
- const localPath = absolutePath(projectDir, entry.local_path);
268
- const current = await hashFile(localPath);
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];
269
397
  if (current.hash === entry.base_hash) {
270
- results.push({ resource_type: canonical, resource_id: resourceId, status: 'unchanged', hash: current.hash });
398
+ report({ resource_type: canonical, resource_id: resourceId, status: 'unchanged', hash: current.hash });
271
399
  continue;
272
400
  }
273
- validateContentFile(localPath, entry.content_type);
274
-
275
- const fresh = await backend.resolveMetadata(config, canonical, resourceId, {
276
- ...options,
277
- ...session,
278
- clientInitialized: true,
279
- });
280
- assertMetadataIdentity(fresh, canonical, resourceId);
281
401
  const commitMetadata = {
282
402
  ...fresh,
283
403
  content_type: entry.content_type,
@@ -290,55 +410,147 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
290
410
  try {
291
411
  responsePayload = await backend.commit(config, commitMetadata, localPath, current, options);
292
412
  } catch (error) {
293
- if (!isVersionConflict(error)) throw error;
294
- const conflict = await writeConflict(projectDir, entry, error, config, backend, options, session);
295
- throw new WorktreeError(
296
- 'RESOURCE_VERSION_CONFLICT',
297
- `${canonical} ${resourceId} changed remotely; conflict materials were preserved.`,
298
- conflict,
299
- );
413
+ let failure = error;
414
+ const versionConflict = isVersionConflict(error);
415
+ if (versionConflict) {
416
+ const conflict = await writeConflict(projectDir, entry, error, config, backend, options, session);
417
+ failure = new WorktreeError(
418
+ 'RESOURCE_VERSION_CONFLICT',
419
+ `${canonical} ${resourceId} changed remotely; conflict materials were preserved.`,
420
+ conflict,
421
+ );
422
+ }
423
+ throwBatchFailure(failure, index, resourceId, !versionConflict);
300
424
  }
301
425
 
302
- let committed = commitVersion(normalizeCommitPayload(responsePayload));
303
- if (!committed.hash || (committed.version == null && committed.revision == null)) {
304
- const confirmed = await backend.resolveMetadata(config, canonical, resourceId, {
305
- ...options,
306
- ...session,
307
- clientInitialized: true,
426
+ try {
427
+ let committed = commitVersion(normalizeCommitPayload(responsePayload));
428
+ if (!committed.hash || (committed.version == null && committed.revision == null)) {
429
+ const confirmed = await backend.resolveMetadata(config, canonical, resourceId, {
430
+ ...options,
431
+ ...session,
432
+ clientInitialized: true,
433
+ });
434
+ assertMetadataIdentity(confirmed, canonical, resourceId);
435
+ committed = {
436
+ version: confirmed.base_version,
437
+ revision: confirmed.base_revision,
438
+ etag: confirmed.etag,
439
+ hash: confirmed.content_hash,
440
+ updated_at: confirmed.updated_at,
441
+ updated_by: confirmed.updated_by,
442
+ };
443
+ }
444
+ if (committed.hash && committed.hash !== current.hash) {
445
+ throw new WorktreeError(
446
+ 'HASH_MISMATCH',
447
+ 'DraftGo commit response hash differs from the uploaded raw bytes; manifest was not advanced.',
448
+ { local_hash: current.hash, remote_hash: committed.hash },
449
+ );
450
+ }
451
+
452
+ await copyFileAtomic(localPath, absolutePath(projectDir, entry.base_path), { expectedHash: current.hash });
453
+ entry.base_hash = current.hash;
454
+ entry.content_size = current.size;
455
+ entry.base_version = committed.version;
456
+ entry.base_revision = committed.revision;
457
+ entry.base_etag = committed.etag;
458
+ entry.updated_at = committed.updated_at;
459
+ entry.updated_by = committed.updated_by;
460
+ entry.committed_at = new Date().toISOString();
461
+ await saveManifest(projectDir, manifest);
462
+ report({
463
+ resource_type: canonical,
464
+ resource_id: resourceId,
465
+ status: 'committed',
466
+ hash: current.hash,
467
+ base_version: entry.base_version,
468
+ base_revision: entry.base_revision,
308
469
  });
309
- assertMetadataIdentity(confirmed, canonical, resourceId);
310
- committed = {
311
- version: confirmed.base_version,
312
- revision: confirmed.base_revision,
313
- etag: confirmed.etag,
314
- hash: confirmed.content_hash,
315
- updated_at: confirmed.updated_at,
316
- updated_by: confirmed.updated_by,
317
- };
470
+ } catch (error) {
471
+ throwBatchFailure(error, index, resourceId, true);
472
+ }
473
+ }
474
+ return results;
475
+ }
476
+
477
+ async function reconcileResources(projectDir, resourceType, resourceIds, options = {}) {
478
+ const canonical = canonicalResourceType(resourceType);
479
+ const ids = ensureIds(resourceIds);
480
+ const config = options.config || loadProjectConfig(projectDir);
481
+ const backend = backendFor(options);
482
+ const manifest = loadManifest(projectDir);
483
+ const session = options.backend && typeof options.backend.resolveMetadata === 'function'
484
+ ? { client: options.client || {}, tools: options.tools || [] }
485
+ : await openMetadataSession(config, options);
486
+ const results = [];
487
+
488
+ for (const resourceId of ids) {
489
+ const entry = getEntry(manifest, canonical, resourceId);
490
+ if (!entry) throw new WorktreeError('RESOURCE_NOT_CHECKED_OUT', `${canonical} ${resourceId} is not checked out.`);
491
+ if (entry.server !== config.server) {
492
+ throw new WorktreeError('CHECKOUT_SERVER_MISMATCH', 'Checkout belongs to a different DraftGo server.');
318
493
  }
319
- if (committed.hash && committed.hash !== current.hash) {
494
+ if (unresolvedConflict(projectDir, canonical, resourceId)) {
320
495
  throw new WorktreeError(
321
- 'HASH_MISMATCH',
322
- 'DraftGo commit response hash differs from the uploaded raw bytes; manifest was not advanced.',
323
- { local_hash: current.hash, remote_hash: committed.hash },
496
+ 'UNRESOLVED_CONFLICT',
497
+ `${canonical} ${resourceId} has an unresolved conflict; use conflict resolve instead of reconcile.`,
498
+ );
499
+ }
500
+ const remote = await backend.resolveMetadata(config, canonical, resourceId, {
501
+ ...options, ...session, clientInitialized: true,
502
+ });
503
+ assertMetadataIdentity(remote, canonical, resourceId);
504
+ const status = await inspectEntry(projectDir, entry, remote);
505
+ if (!status.local_matches_remote) {
506
+ throw new WorktreeError(
507
+ 'RECONCILE_UNSAFE',
508
+ `${canonical} ${resourceId} local content does not equal remote; refusing to advance metadata.`,
509
+ status,
510
+ );
511
+ }
512
+ if (entry.content_type !== remote.content_type || entry.file_extension !== remote.file_extension) {
513
+ throw new WorktreeError(
514
+ 'RECONCILE_FORMAT_CHANGED',
515
+ `${canonical} ${resourceId} remote content format changed; use checkout after preserving local work.`,
324
516
  );
325
517
  }
326
518
 
327
- await copyFileAtomic(localPath, absolutePath(projectDir, entry.base_path), { expectedHash: current.hash });
328
- entry.base_hash = current.hash;
329
- entry.content_size = current.size;
330
- entry.base_version = committed.version;
331
- entry.base_revision = committed.revision;
332
- entry.base_etag = committed.etag;
333
- entry.updated_at = committed.updated_at;
334
- entry.updated_by = committed.updated_by;
335
- entry.committed_at = new Date().toISOString();
519
+ const response = await backend.download(config, remote, options);
520
+ const basePath = absolutePath(projectDir, entry.base_path);
521
+ const verifiedRemote = tempPathFor(basePath);
522
+ let verified;
523
+ try {
524
+ verified = await streamToFiles(response.body, [verifiedRemote], {
525
+ expectedHash: remote.content_hash,
526
+ expectedSize: remote.content_size,
527
+ });
528
+ const localAfterDownload = await hashFile(absolutePath(projectDir, entry.local_path));
529
+ if (localAfterDownload.hash !== verified.hash) {
530
+ throw new WorktreeError(
531
+ 'RECONCILE_LOCAL_CHANGED',
532
+ `${canonical} ${resourceId} local content changed during reconcile; manifest was not advanced.`,
533
+ { local_hash: localAfterDownload.hash, remote_hash: verified.hash },
534
+ );
535
+ }
536
+ await copyFileAtomic(verifiedRemote, basePath, { expectedHash: verified.hash });
537
+ } finally {
538
+ await fs.promises.rm(verifiedRemote, { force: true }).catch(() => {});
539
+ }
540
+ entry.base_hash = verified.hash;
541
+ entry.content_size = verified.size;
542
+ entry.base_version = remote.base_version;
543
+ entry.base_revision = remote.base_revision;
544
+ entry.base_etag = remote.etag;
545
+ entry.updated_at = remote.updated_at;
546
+ entry.updated_by = remote.updated_by;
547
+ entry.reconciled_at = new Date().toISOString();
336
548
  await saveManifest(projectDir, manifest);
337
549
  results.push({
338
550
  resource_type: canonical,
339
551
  resource_id: resourceId,
340
- status: 'committed',
341
- hash: current.hash,
552
+ status: 'reconciled',
553
+ hash: verified.hash,
342
554
  base_version: entry.base_version,
343
555
  base_revision: entry.base_revision,
344
556
  });
@@ -454,6 +666,7 @@ module.exports = {
454
666
  conflictPaths,
455
667
  checkoutResources,
456
668
  commitResources,
669
+ reconcileResources,
457
670
  diffResource,
458
671
  listConflicts,
459
672
  showConflict,
@@ -0,0 +1,122 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const { loadProjectConfig } = require('../projectConfig');
5
+ const { DraftGoMcpClient } = require('../mcp/client');
6
+ const backendDefaults = require('./backend');
7
+ const { loadManifest, absolutePath } = require('./manifest');
8
+ const { hashFile } = require('./streams');
9
+
10
+ function versionValue(source) {
11
+ if (!source) return null;
12
+ if (source.base_version != null) return source.base_version;
13
+ if (source.base_revision != null) return source.base_revision;
14
+ return null;
15
+ }
16
+
17
+ function sameVersion(entry, remote) {
18
+ const local = versionValue(entry);
19
+ const current = versionValue(remote);
20
+ if (local == null || current == null) return true;
21
+ return String(local) === String(current);
22
+ }
23
+
24
+ async function optionalHash(projectDir, relative) {
25
+ if (!relative) return null;
26
+ const file = absolutePath(projectDir, relative);
27
+ return fs.existsSync(file) ? (await hashFile(file)).hash : null;
28
+ }
29
+
30
+ function classify(entry, localHash, baseHash, remote) {
31
+ const manifestHash = entry.base_hash;
32
+ const remoteHash = remote && remote.content_hash || null;
33
+ const baseMatchesManifest = baseHash === manifestHash;
34
+ const localMatchesManifest = localHash === manifestHash;
35
+ const localMatchesRemote = Boolean(localHash && remoteHash && localHash === remoteHash);
36
+ const remoteMatchesManifest = Boolean(remoteHash && remoteHash === manifestHash);
37
+ const versionMatches = remote ? sameVersion(entry, remote) : null;
38
+
39
+ let state;
40
+ let recommendation = null;
41
+ if (!localHash) {
42
+ state = 'local_missing';
43
+ recommendation = `draftgo checkout ${entry.resource_type} ${entry.resource_id} --force`;
44
+ } else if (!baseHash) {
45
+ state = 'base_missing';
46
+ recommendation = `draftgo checkout ${entry.resource_type} ${entry.resource_id} --force`;
47
+ } else if (!remote) {
48
+ state = baseMatchesManifest
49
+ ? (localMatchesManifest ? 'clean_local' : 'local_modified')
50
+ : 'local_metadata_corrupt';
51
+ } else if (localMatchesRemote && (!remoteMatchesManifest || !versionMatches || !baseMatchesManifest)) {
52
+ state = remoteMatchesManifest ? 'metadata_stale' : 'committed_unrecorded';
53
+ recommendation = `draftgo reconcile ${entry.resource_type} ${entry.resource_id}`;
54
+ } else if (!baseMatchesManifest) {
55
+ state = 'local_metadata_corrupt';
56
+ recommendation = `draftgo checkout ${entry.resource_type} ${entry.resource_id} --force`;
57
+ } else if (remoteMatchesManifest && versionMatches) {
58
+ state = localMatchesManifest ? 'clean' : 'local_modified';
59
+ } else if (localMatchesManifest) {
60
+ state = 'remote_changed';
61
+ recommendation = `draftgo checkout ${entry.resource_type} ${entry.resource_id}`;
62
+ } else {
63
+ state = 'diverged';
64
+ recommendation = `draftgo commit ${entry.resource_type} ${entry.resource_id}`;
65
+ }
66
+
67
+ return {
68
+ state,
69
+ local_hash: localHash,
70
+ base_file_hash: baseHash,
71
+ manifest_hash: manifestHash,
72
+ remote_hash: remoteHash,
73
+ manifest_version: versionValue(entry),
74
+ remote_version: versionValue(remote),
75
+ version_matches: versionMatches,
76
+ local_matches_remote: localMatchesRemote,
77
+ recommendation,
78
+ };
79
+ }
80
+
81
+ async function inspectEntry(projectDir, entry, remote = null) {
82
+ const [localHash, baseHash] = await Promise.all([
83
+ optionalHash(projectDir, entry.local_path),
84
+ optionalHash(projectDir, entry.base_path),
85
+ ]);
86
+ return { ...entry, ...classify(entry, localHash, baseHash, remote) };
87
+ }
88
+
89
+ async function openMetadataSession(config, options = {}) {
90
+ if (options.client && options.tools) return { client: options.client, tools: options.tools };
91
+ const client = options.client || new DraftGoMcpClient(config);
92
+ await client.initialize(options);
93
+ const tools = options.tools || await client.listAllTools(options);
94
+ return { client, tools };
95
+ }
96
+
97
+ async function inspectRemoteCheckouts(projectDir, options = {}) {
98
+ const config = options.config || loadProjectConfig(projectDir);
99
+ const manifest = options.manifest || loadManifest(projectDir);
100
+ const backend = { ...backendDefaults, ...(options.backend || {}) };
101
+ const session = options.backend && typeof options.backend.resolveMetadata === 'function'
102
+ ? { client: options.client || {}, tools: options.tools || [] }
103
+ : await openMetadataSession(config, options);
104
+ const entries = Object.values(manifest.entries);
105
+ return Promise.all(entries.map(async (entry) => {
106
+ const remote = await backend.resolveMetadata(config, entry.resource_type, entry.resource_id, {
107
+ ...options,
108
+ ...session,
109
+ clientInitialized: true,
110
+ });
111
+ return inspectEntry(projectDir, entry, remote);
112
+ }));
113
+ }
114
+
115
+ module.exports = {
116
+ versionValue,
117
+ sameVersion,
118
+ classify,
119
+ inspectEntry,
120
+ openMetadataSession,
121
+ inspectRemoteCheckouts,
122
+ };