newo 1.7.3 → 1.8.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.8.0] - 2025-09-15
9
+
10
+ ### Added
11
+ - **Complete Metadata Change Tracking**: Comprehensive metadata.yaml file synchronization
12
+ - All metadata.yaml files now tracked with hash-based change detection
13
+ - Status command shows detailed metadata changes (title, runner_type, model)
14
+ - Push command automatically updates skills when metadata changes
15
+ - flows.yaml automatically regenerated when metadata changes detected
16
+ - Preserves flows.yaml format consistency with backup/comparison system
17
+
18
+ ### Enhanced
19
+ - **Comprehensive File Synchronization**: All NEWO workspace files fully tracked
20
+ - Skills: .guidance and .jinja script files with hash tracking ✓
21
+ - Metadata: metadata.yaml files with skill updates + flows.yaml regeneration ✓
22
+ - Attributes: attributes.yaml with diff-based sync for 233 customer attributes ✓
23
+ - Flows: flows.yaml with automatic regeneration and format preservation ✓
24
+ - Multi-customer: All file types synchronized across multiple customer workspaces ✓
25
+
26
+ ### Technical
27
+ - **flows.yaml Regeneration**: Automatic regeneration pipeline when metadata changes
28
+ - Creates backup before regeneration for format comparison
29
+ - Re-fetches project data to ensure accuracy
30
+ - Updates hash tracking for regenerated flows.yaml
31
+ - Maintains consistent YAML format structure
32
+
8
33
  ## [1.7.3] - 2025-09-15
9
34
 
10
35
  ### Added
package/dist/fsutil.d.ts CHANGED
@@ -11,6 +11,7 @@ export declare function projectDir(customerIdn: string, projectIdn: string): str
11
11
  export declare function flowsYamlPath(customerIdn: string): string;
12
12
  export declare function customerAttributesPath(customerIdn: string): string;
13
13
  export declare function customerAttributesMapPath(customerIdn: string): string;
14
+ export declare function customerAttributesBackupPath(customerIdn: string): string;
14
15
  export declare function skillPath(customerIdn: string, projectIdn: string, agentIdn: string, flowIdn: string, skillIdn: string, runnerType?: RunnerType): string;
15
16
  export declare function skillFolderPath(customerIdn: string, projectIdn: string, agentIdn: string, flowIdn: string, skillIdn: string): string;
16
17
  export declare function skillScriptPath(customerIdn: string, projectIdn: string, agentIdn: string, flowIdn: string, skillIdn: string, runnerType?: RunnerType): string;
package/dist/fsutil.js CHANGED
@@ -34,6 +34,9 @@ export function customerAttributesPath(customerIdn) {
34
34
  export function customerAttributesMapPath(customerIdn) {
35
35
  return path.join(customerStateDir(customerIdn), 'attributes-map.json');
36
36
  }
37
+ export function customerAttributesBackupPath(customerIdn) {
38
+ return path.join(customerStateDir(customerIdn), 'attributes-backup.yaml');
39
+ }
37
40
  // Legacy skill path - direct file
38
41
  export function skillPath(customerIdn, projectIdn, agentIdn, flowIdn, skillIdn, runnerType = 'guidance') {
39
42
  const extension = runnerType === 'nsl' ? '.jinja' : '.guidance';
package/dist/sync.js CHANGED
@@ -1,5 +1,5 @@
1
- import { listProjects, listAgents, listFlowSkills, updateSkill, listFlowEvents, listFlowStates, getProjectMeta, getCustomerAttributes } from './api.js';
2
- import { ensureState, skillPath, skillScriptPath, writeFileSafe, readIfExists, mapPath, projectMetadataPath, agentMetadataPath, flowMetadataPath, skillMetadataPath, flowsYamlPath, customerAttributesPath, customerAttributesMapPath } from './fsutil.js';
1
+ import { listProjects, listAgents, listFlowSkills, updateSkill, listFlowEvents, listFlowStates, getProjectMeta, getCustomerAttributes, updateCustomerAttribute } from './api.js';
2
+ import { ensureState, skillPath, skillScriptPath, writeFileSafe, readIfExists, mapPath, projectMetadataPath, agentMetadataPath, flowMetadataPath, skillMetadataPath, flowsYamlPath, customerAttributesPath, customerAttributesMapPath, customerAttributesBackupPath } from './fsutil.js';
3
3
  import fs from 'fs-extra';
4
4
  import { sha256, loadHashes, saveHashes } from './hash.js';
5
5
  import yaml from 'js-yaml';
@@ -76,12 +76,14 @@ export async function saveCustomerAttributes(client, customer, verbose = false)
76
76
  yamlContent = yamlContent.replace(/__ENUM_PLACEHOLDER_(\w+)__/g, '!enum "AttributeValueTypes.$1"');
77
77
  // Fix JSON string formatting to match reference (remove escape characters)
78
78
  yamlContent = yamlContent.replace(/\\"/g, '"');
79
- // Save both files
79
+ // Save all files: attributes.yaml, ID mapping, and backup for diff tracking
80
80
  await writeFileSafe(customerAttributesPath(customer.idn), yamlContent);
81
81
  await writeFileSafe(customerAttributesMapPath(customer.idn), JSON.stringify(idMapping, null, 2));
82
+ await writeFileSafe(customerAttributesBackupPath(customer.idn), yamlContent);
82
83
  if (verbose) {
83
84
  console.log(`✓ Saved customer attributes to ${customerAttributesPath(customer.idn)}`);
84
85
  console.log(`✓ Saved attribute ID mapping to ${customerAttributesMapPath(customer.idn)}`);
86
+ console.log(`✓ Created attributes backup for diff tracking`);
85
87
  }
86
88
  }
87
89
  catch (error) {
@@ -215,6 +217,12 @@ export async function pullAll(client, customer, projectId = null, verbose = fals
215
217
  const newPath = skillScriptPath(customer.idn, projectMeta.idn, agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
216
218
  const content = await fs.readFile(newPath, 'utf8');
217
219
  hashes[newPath] = sha256(content);
220
+ // Track skill metadata.yaml file
221
+ const metadataPath = skillMetadataPath(customer.idn, projectMeta.idn, agentIdn, flowIdn, skillIdn);
222
+ if (await fs.pathExists(metadataPath)) {
223
+ const metadataContent = await fs.readFile(metadataPath, 'utf8');
224
+ hashes[metadataPath] = sha256(metadataContent);
225
+ }
218
226
  // Also track legacy path for backwards compatibility during transition
219
227
  const legacyPath = skillPath(customer.idn, projectMeta.idn, agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
220
228
  hashes[legacyPath] = sha256(content);
@@ -232,6 +240,14 @@ export async function pullAll(client, customer, projectId = null, verbose = fals
232
240
  if (verbose)
233
241
  console.log(`✓ Added attributes.yaml to hash tracking`);
234
242
  }
243
+ // Add flows.yaml to hash tracking
244
+ const flowsFile = flowsYamlPath(customer.idn);
245
+ if (await fs.pathExists(flowsFile)) {
246
+ const flowsContent = await fs.readFile(flowsFile, 'utf8');
247
+ hashes[flowsFile] = sha256(flowsContent);
248
+ if (verbose)
249
+ console.log(`✓ Added flows.yaml to hash tracking`);
250
+ }
235
251
  }
236
252
  catch (error) {
237
253
  console.error(`❌ Failed to save customer attributes for ${customer.idn}:`, error);
@@ -261,6 +277,12 @@ export async function pullAll(client, customer, projectId = null, verbose = fals
261
277
  const newPath = skillScriptPath(customer.idn, project.idn, agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
262
278
  const content = await fs.readFile(newPath, 'utf8');
263
279
  allHashes[newPath] = sha256(content);
280
+ // Track skill metadata.yaml file
281
+ const metadataPath = skillMetadataPath(customer.idn, project.idn, agentIdn, flowIdn, skillIdn);
282
+ if (await fs.pathExists(metadataPath)) {
283
+ const metadataContent = await fs.readFile(metadataPath, 'utf8');
284
+ allHashes[metadataPath] = sha256(metadataContent);
285
+ }
264
286
  // Also track legacy path for backwards compatibility during transition
265
287
  const legacyPath = skillPath(customer.idn, project.idn, agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
266
288
  allHashes[legacyPath] = sha256(content);
@@ -280,6 +302,14 @@ export async function pullAll(client, customer, projectId = null, verbose = fals
280
302
  if (verbose)
281
303
  console.log(`✓ Added attributes.yaml to hash tracking`);
282
304
  }
305
+ // Add flows.yaml to hash tracking
306
+ const flowsFile = flowsYamlPath(customer.idn);
307
+ if (await fs.pathExists(flowsFile)) {
308
+ const flowsContent = await fs.readFile(flowsFile, 'utf8');
309
+ allHashes[flowsFile] = sha256(flowsContent);
310
+ if (verbose)
311
+ console.log(`✓ Added flows.yaml to hash tracking`);
312
+ }
283
313
  }
284
314
  catch (error) {
285
315
  console.error(`❌ Failed to save customer attributes for ${customer.idn}:`, error);
@@ -303,6 +333,7 @@ export async function pushChanged(client, customer, verbose = false) {
303
333
  console.log('🔄 Scanning for changes...');
304
334
  let pushed = 0;
305
335
  let scanned = 0;
336
+ let metadataChanged = false;
306
337
  // Handle both old single-project format and new multi-project format with type guards
307
338
  const projects = isProjectMap(idMapData) && idMapData.projects
308
339
  ? idMapData.projects
@@ -403,37 +434,178 @@ export async function pushChanged(client, customer, verbose = false) {
403
434
  }
404
435
  }
405
436
  }
437
+ // Check for metadata-only changes (when metadata changed but script didn't)
438
+ try {
439
+ for (const [projectIdn, projectData] of Object.entries(projects)) {
440
+ for (const [agentIdn, agentObj] of Object.entries(projectData.agents)) {
441
+ for (const [flowIdn, flowObj] of Object.entries(agentObj.flows)) {
442
+ for (const [skillIdn, skillMeta] of Object.entries(flowObj.skills)) {
443
+ const metadataPath = projectIdn ?
444
+ skillMetadataPath(customer.idn, projectIdn, agentIdn, flowIdn, skillIdn) :
445
+ skillMetadataPath(customer.idn, '', agentIdn, flowIdn, skillIdn);
446
+ if (await fs.pathExists(metadataPath)) {
447
+ const metadataContent = await fs.readFile(metadataPath, 'utf8');
448
+ const h = sha256(metadataContent);
449
+ const oldHash = oldHashes[metadataPath];
450
+ if (oldHash !== h) {
451
+ if (verbose)
452
+ console.log(`🔄 Metadata-only change detected for ${skillIdn}, updating skill...`);
453
+ try {
454
+ // Load updated metadata
455
+ const updatedMetadata = yaml.load(metadataContent);
456
+ // Get current script content
457
+ const scriptPath = projectIdn ?
458
+ skillScriptPath(customer.idn, projectIdn, agentIdn, flowIdn, skillIdn, skillMeta.runner_type) :
459
+ skillScriptPath(customer.idn, '', agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
460
+ let scriptContent = '';
461
+ if (await fs.pathExists(scriptPath)) {
462
+ scriptContent = await fs.readFile(scriptPath, 'utf8');
463
+ }
464
+ // Create skill object with updated metadata
465
+ const skillObject = {
466
+ id: updatedMetadata.id,
467
+ title: updatedMetadata.title,
468
+ idn: updatedMetadata.idn,
469
+ prompt_script: scriptContent,
470
+ runner_type: updatedMetadata.runner_type,
471
+ model: updatedMetadata.model,
472
+ parameters: updatedMetadata.parameters,
473
+ path: updatedMetadata.path || undefined
474
+ };
475
+ await updateSkill(client, skillObject);
476
+ console.log(`↑ Pushed metadata update for skill: ${skillIdn} (${updatedMetadata.title})`);
477
+ newHashes[metadataPath] = h;
478
+ pushed++;
479
+ metadataChanged = true;
480
+ }
481
+ catch (error) {
482
+ console.error(`❌ Failed to push metadata for ${skillIdn}: ${error instanceof Error ? error.message : String(error)}`);
483
+ }
484
+ }
485
+ }
486
+ }
487
+ }
488
+ }
489
+ }
490
+ }
491
+ catch (error) {
492
+ if (verbose)
493
+ console.log(`⚠️ Metadata push check failed: ${error instanceof Error ? error.message : String(error)}`);
494
+ }
406
495
  if (verbose)
407
496
  console.log(`🔄 Scanned ${scanned} files, found ${pushed} changes`);
408
- // Check for attributes changes and push if needed
497
+ // Check for attributes changes and push specific changed attributes only
409
498
  try {
410
499
  const attributesFile = customerAttributesPath(customer.idn);
411
500
  const attributesMapFile = customerAttributesMapPath(customer.idn);
501
+ const attributesBackupFile = customerAttributesBackupPath(customer.idn);
412
502
  if (await fs.pathExists(attributesFile) && await fs.pathExists(attributesMapFile)) {
413
503
  if (verbose)
414
504
  console.log('🔍 Checking customer attributes for changes...');
415
- // Use hash comparison for change detection
416
- const content = await fs.readFile(attributesFile, 'utf8');
417
- const h = sha256(content);
418
- const oldHash = oldHashes[attributesFile];
419
- if (verbose) {
420
- console.log(`📄 Checking: ${attributesFile}`);
421
- console.log(` Old hash: ${oldHash || 'none'}`);
422
- console.log(` New hash: ${h}`);
505
+ const currentContent = await fs.readFile(attributesFile, 'utf8');
506
+ // Check if backup exists for diff comparison
507
+ if (await fs.pathExists(attributesBackupFile)) {
508
+ const backupContent = await fs.readFile(attributesBackupFile, 'utf8');
509
+ if (currentContent !== backupContent) {
510
+ if (verbose)
511
+ console.log(`🔄 Attributes file changed, analyzing differences...`);
512
+ try {
513
+ // Load ID mapping for push operations
514
+ const idMapping = await fs.readJson(attributesMapFile);
515
+ // Parse both versions to find changed attributes
516
+ const parseYaml = (content) => {
517
+ let yamlContent = content.replace(/!enum "([^"]+)"/g, '"$1"');
518
+ return yaml.load(yamlContent);
519
+ };
520
+ const currentData = parseYaml(currentContent);
521
+ const backupData = parseYaml(backupContent);
522
+ if (currentData?.attributes && backupData?.attributes) {
523
+ // Create maps for comparison
524
+ const currentAttrs = new Map(currentData.attributes.map(attr => [attr.idn, attr]));
525
+ const backupAttrs = new Map(backupData.attributes.map(attr => [attr.idn, attr]));
526
+ let attributesPushed = 0;
527
+ // Find changed attributes
528
+ for (const [idn, currentAttr] of currentAttrs) {
529
+ const backupAttr = backupAttrs.get(idn);
530
+ // Check if attribute changed (deep comparison of key fields)
531
+ const hasChanged = !backupAttr ||
532
+ currentAttr.value !== backupAttr.value ||
533
+ currentAttr.title !== backupAttr.title ||
534
+ currentAttr.description !== backupAttr.description ||
535
+ currentAttr.group !== backupAttr.group ||
536
+ currentAttr.is_hidden !== backupAttr.is_hidden;
537
+ if (hasChanged) {
538
+ const attributeId = idMapping[idn];
539
+ if (!attributeId) {
540
+ if (verbose)
541
+ console.log(`⚠️ Skipping ${idn} - no ID mapping`);
542
+ continue;
543
+ }
544
+ // Create attribute object for push
545
+ const attributeToUpdate = {
546
+ id: attributeId,
547
+ idn: currentAttr.idn,
548
+ value: currentAttr.value,
549
+ title: currentAttr.title || "",
550
+ description: currentAttr.description || "",
551
+ group: currentAttr.group || "",
552
+ is_hidden: currentAttr.is_hidden,
553
+ possible_values: currentAttr.possible_values || [],
554
+ value_type: currentAttr.value_type?.replace(/^"?AttributeValueTypes\.(.+)"?$/, '$1') || "string"
555
+ };
556
+ await updateCustomerAttribute(client, attributeToUpdate);
557
+ attributesPushed++;
558
+ if (verbose) {
559
+ console.log(` ✓ Pushed changed attribute: ${idn}`);
560
+ console.log(` Old value: ${backupAttr?.value || 'N/A'}`);
561
+ console.log(` New value: ${currentAttr.value}`);
562
+ }
563
+ }
564
+ }
565
+ if (attributesPushed > 0) {
566
+ console.log(`↑ Pushed ${attributesPushed} changed customer attributes to NEWO API`);
567
+ // Show summary of what was pushed
568
+ console.log(` 📊 Pushed attributes:`);
569
+ for (const [idn, currentAttr] of currentAttrs) {
570
+ const backupAttr = backupAttrs.get(idn);
571
+ const hasChanged = !backupAttr ||
572
+ currentAttr.value !== backupAttr.value ||
573
+ currentAttr.title !== backupAttr.title ||
574
+ currentAttr.description !== backupAttr.description ||
575
+ currentAttr.group !== backupAttr.group ||
576
+ currentAttr.is_hidden !== backupAttr.is_hidden;
577
+ if (hasChanged) {
578
+ console.log(` • ${idn}: ${currentAttr.title || 'No title'}`);
579
+ console.log(` Value: ${currentAttr.value}`);
580
+ }
581
+ }
582
+ // Update backup file after successful push
583
+ await fs.writeFile(attributesBackupFile, currentContent, 'utf8');
584
+ newHashes[attributesFile] = sha256(currentContent);
585
+ pushed++;
586
+ }
587
+ else if (verbose) {
588
+ console.log(` ✓ No attribute value changes detected`);
589
+ }
590
+ }
591
+ else {
592
+ console.log(`⚠️ Failed to parse attributes for comparison`);
593
+ }
594
+ }
595
+ catch (error) {
596
+ console.error(`❌ Failed to push changed attributes: ${error instanceof Error ? error.message : String(error)}`);
597
+ // Don't update hash/backup on failure so it will retry next time
598
+ }
599
+ }
600
+ else if (verbose) {
601
+ console.log(` ✓ No attributes file changes`);
602
+ }
423
603
  }
424
- if (oldHash !== h) {
604
+ else {
605
+ // No backup exists, create initial backup
606
+ await fs.writeFile(attributesBackupFile, currentContent, 'utf8');
425
607
  if (verbose)
426
- console.log(`🔄 Attributes file changed, preparing to push...`);
427
- // TODO: Implement actual attributes push here
428
- // For now, just update the hash to mark as "pushed"
429
- console.log(`↑ Attributes changed: ${attributesFile}`);
430
- newHashes[attributesFile] = h;
431
- pushed++;
432
- // Note: Individual attribute push would require parsing the YAML and comparing specific attributes
433
- // This is a placeholder for future implementation
434
- }
435
- else if (verbose) {
436
- console.log(` ✓ No attributes changes`);
608
+ console.log(`✓ Created initial attributes backup for diff tracking`);
437
609
  }
438
610
  }
439
611
  else if (verbose) {
@@ -444,6 +616,47 @@ export async function pushChanged(client, customer, verbose = false) {
444
616
  if (verbose)
445
617
  console.log(`⚠️ Attributes push check failed: ${error instanceof Error ? error.message : String(error)}`);
446
618
  }
619
+ // Regenerate flows.yaml if metadata changed
620
+ if (metadataChanged) {
621
+ try {
622
+ if (verbose)
623
+ console.log('🔄 Metadata changed, regenerating flows.yaml...');
624
+ // Create backup of current flows.yaml for format comparison
625
+ const flowsFile = flowsYamlPath(customer.idn);
626
+ let flowsBackup = '';
627
+ if (await fs.pathExists(flowsFile)) {
628
+ flowsBackup = await fs.readFile(flowsFile, 'utf8');
629
+ const backupPath = `${flowsFile}.backup`;
630
+ await fs.writeFile(backupPath, flowsBackup, 'utf8');
631
+ if (verbose)
632
+ console.log(`✓ Created flows.yaml backup at ${backupPath}`);
633
+ }
634
+ // Re-fetch agents for flows.yaml regeneration
635
+ const agentsForFlows = [];
636
+ for (const projectData of Object.values(projects)) {
637
+ const projectAgents = await listAgents(client, projectData.projectId);
638
+ agentsForFlows.push(...projectAgents);
639
+ }
640
+ // Regenerate flows.yaml
641
+ await generateFlowsYaml(client, customer, agentsForFlows, verbose);
642
+ // Update flows.yaml hash
643
+ if (await fs.pathExists(flowsFile)) {
644
+ const newFlowsContent = await fs.readFile(flowsFile, 'utf8');
645
+ newHashes[flowsFile] = sha256(newFlowsContent);
646
+ // Compare format with backup
647
+ if (flowsBackup) {
648
+ const sizeDiff = newFlowsContent.length - flowsBackup.length;
649
+ if (verbose) {
650
+ console.log(`✓ Regenerated flows.yaml (size change: ${sizeDiff > 0 ? '+' : ''}${sizeDiff} chars)`);
651
+ }
652
+ }
653
+ }
654
+ console.log('↑ Regenerated flows.yaml due to metadata changes');
655
+ }
656
+ catch (error) {
657
+ console.error(`❌ Failed to regenerate flows.yaml: ${error instanceof Error ? error.message : String(error)}`);
658
+ }
659
+ }
447
660
  await saveHashes(newHashes, customer.idn);
448
661
  console.log(pushed ? `✅ Push complete. ${pushed} file(s) updated.` : '✅ Nothing to push.');
449
662
  }
@@ -514,6 +727,48 @@ export async function status(customer, verbose = false) {
514
727
  console.log(` ✓ Unchanged: ${currentPath}`);
515
728
  }
516
729
  }
730
+ // Check metadata.yaml files for changes (after skill files)
731
+ for (const [skillIdn] of Object.entries(flowObj.skills)) {
732
+ const metadataPath = projectIdn ?
733
+ skillMetadataPath(customer.idn, projectIdn, agentIdn, flowIdn, skillIdn) :
734
+ skillMetadataPath(customer.idn, '', agentIdn, flowIdn, skillIdn);
735
+ if (await fs.pathExists(metadataPath)) {
736
+ const metadataContent = await fs.readFile(metadataPath, 'utf8');
737
+ const h = sha256(metadataContent);
738
+ const oldHash = hashes[metadataPath];
739
+ if (verbose) {
740
+ console.log(` 📄 ${metadataPath}`);
741
+ console.log(` Old hash: ${oldHash || 'none'}`);
742
+ console.log(` New hash: ${h}`);
743
+ }
744
+ if (oldHash !== h) {
745
+ console.log(`M ${metadataPath}`);
746
+ dirty++;
747
+ // Show which metadata fields changed
748
+ try {
749
+ const newMetadata = yaml.load(metadataContent);
750
+ console.log(` 📊 Metadata changed for skill: ${skillIdn}`);
751
+ if (newMetadata?.title) {
752
+ console.log(` • Title: ${newMetadata.title}`);
753
+ }
754
+ if (newMetadata?.runner_type) {
755
+ console.log(` • Runner: ${newMetadata.runner_type}`);
756
+ }
757
+ if (newMetadata?.model) {
758
+ console.log(` • Model: ${newMetadata.model.provider_idn}/${newMetadata.model.model_idn}`);
759
+ }
760
+ }
761
+ catch (e) {
762
+ // Fallback to simple message
763
+ if (verbose)
764
+ console.log(` 🔄 Modified: metadata.yaml`);
765
+ }
766
+ }
767
+ else if (verbose) {
768
+ console.log(` ✓ Unchanged: ${metadataPath}`);
769
+ }
770
+ }
771
+ }
517
772
  }
518
773
  }
519
774
  }
@@ -532,6 +787,54 @@ export async function status(customer, verbose = false) {
532
787
  if (oldHash !== h) {
533
788
  console.log(`M ${attributesFile}`);
534
789
  dirty++;
790
+ // Show which attributes changed by comparing with backup
791
+ try {
792
+ const attributesBackupFile = customerAttributesBackupPath(customer.idn);
793
+ if (await fs.pathExists(attributesBackupFile)) {
794
+ const backupContent = await fs.readFile(attributesBackupFile, 'utf8');
795
+ const parseYaml = (content) => {
796
+ let yamlContent = content.replace(/!enum "([^"]+)"/g, '"$1"');
797
+ return yaml.load(yamlContent);
798
+ };
799
+ const currentData = parseYaml(content);
800
+ const backupData = parseYaml(backupContent);
801
+ if (currentData?.attributes && backupData?.attributes) {
802
+ const currentAttrs = new Map(currentData.attributes.map(attr => [attr.idn, attr]));
803
+ const backupAttrs = new Map(backupData.attributes.map(attr => [attr.idn, attr]));
804
+ const changedAttributes = [];
805
+ for (const [idn, currentAttr] of currentAttrs) {
806
+ const backupAttr = backupAttrs.get(idn);
807
+ const hasChanged = !backupAttr ||
808
+ currentAttr.value !== backupAttr.value ||
809
+ currentAttr.title !== backupAttr.title ||
810
+ currentAttr.description !== backupAttr.description ||
811
+ currentAttr.group !== backupAttr.group ||
812
+ currentAttr.is_hidden !== backupAttr.is_hidden;
813
+ if (hasChanged) {
814
+ changedAttributes.push(idn);
815
+ }
816
+ }
817
+ if (changedAttributes.length > 0) {
818
+ console.log(` 📊 Changed attributes (${changedAttributes.length}):`);
819
+ changedAttributes.slice(0, 5).forEach(idn => {
820
+ const current = currentAttrs.get(idn);
821
+ const backup = backupAttrs.get(idn);
822
+ console.log(` • ${idn}: ${current?.title || 'No title'}`);
823
+ if (verbose) {
824
+ console.log(` Old: ${backup?.value || 'N/A'}`);
825
+ console.log(` New: ${current?.value || 'N/A'}`);
826
+ }
827
+ });
828
+ if (changedAttributes.length > 5) {
829
+ console.log(` ... and ${changedAttributes.length - 5} more`);
830
+ }
831
+ }
832
+ }
833
+ }
834
+ }
835
+ catch (e) {
836
+ // Fallback to simple message if diff analysis fails
837
+ }
535
838
  if (verbose)
536
839
  console.log(` 🔄 Modified: attributes.yaml`);
537
840
  }
@@ -548,12 +851,29 @@ export async function status(customer, verbose = false) {
548
851
  const flowsFile = flowsYamlPath(customer.idn);
549
852
  if (await fs.pathExists(flowsFile)) {
550
853
  try {
551
- const flowsStats = await fs.stat(flowsFile);
854
+ const flowsContent = await fs.readFile(flowsFile, 'utf8');
855
+ const h = sha256(flowsContent);
856
+ const oldHash = hashes[flowsFile];
552
857
  if (verbose) {
553
858
  console.log(`📄 flows.yaml`);
859
+ console.log(` Old hash: ${oldHash || 'none'}`);
860
+ console.log(` New hash: ${h}`);
861
+ }
862
+ if (oldHash !== h) {
863
+ console.log(`M ${flowsFile}`);
864
+ dirty++;
865
+ if (verbose) {
866
+ const flowsStats = await fs.stat(flowsFile);
867
+ console.log(` 🔄 Modified: flows.yaml`);
868
+ console.log(` 📊 Size: ${(flowsStats.size / 1024).toFixed(1)}KB`);
869
+ console.log(` 📅 Last modified: ${flowsStats.mtime.toISOString()}`);
870
+ }
871
+ }
872
+ else if (verbose) {
873
+ const flowsStats = await fs.stat(flowsFile);
874
+ console.log(` ✓ Unchanged: flows.yaml`);
554
875
  console.log(` 📅 Last modified: ${flowsStats.mtime.toISOString()}`);
555
876
  console.log(` 📊 Size: ${(flowsStats.size / 1024).toFixed(1)}KB`);
556
- console.log(` ✓ Flows file tracked`);
557
877
  }
558
878
  }
559
879
  catch (error) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "newo",
3
- "version": "1.7.3",
4
- "description": "NEWO CLI: sync AI Agent skills and customer attributes between NEWO platform and local files. Multi-customer workspaces, Git-first workflows, comprehensive project management.",
3
+ "version": "1.8.0",
4
+ "description": "NEWO CLI: comprehensive sync for AI Agent skills, customer attributes, and metadata between NEWO platform and local files. Multi-customer workspaces, complete change tracking, Git-first workflows.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "newo": "dist/cli.js"
package/src/fsutil.ts CHANGED
@@ -47,6 +47,10 @@ export function customerAttributesMapPath(customerIdn: string): string {
47
47
  return path.join(customerStateDir(customerIdn), 'attributes-map.json');
48
48
  }
49
49
 
50
+ export function customerAttributesBackupPath(customerIdn: string): string {
51
+ return path.join(customerStateDir(customerIdn), 'attributes-backup.yaml');
52
+ }
53
+
50
54
  // Legacy skill path - direct file
51
55
  export function skillPath(
52
56
  customerIdn: string,
package/src/sync.ts CHANGED
@@ -6,7 +6,8 @@ import {
6
6
  listFlowEvents,
7
7
  listFlowStates,
8
8
  getProjectMeta,
9
- getCustomerAttributes
9
+ getCustomerAttributes,
10
+ updateCustomerAttribute
10
11
  } from './api.js';
11
12
  import {
12
13
  ensureState,
@@ -21,7 +22,8 @@ import {
21
22
  skillMetadataPath,
22
23
  flowsYamlPath,
23
24
  customerAttributesPath,
24
- customerAttributesMapPath
25
+ customerAttributesMapPath,
26
+ customerAttributesBackupPath
25
27
  } from './fsutil.js';
26
28
  import fs from 'fs-extra';
27
29
  import { sha256, loadHashes, saveHashes } from './hash.js';
@@ -46,7 +48,8 @@ import type {
46
48
  FlowMetadata,
47
49
  SkillMetadata,
48
50
  FlowEvent,
49
- FlowState
51
+ FlowState,
52
+ CustomerAttribute
50
53
  } from './types.js';
51
54
 
52
55
  // Concurrency limits for API operations
@@ -136,13 +139,15 @@ export async function saveCustomerAttributes(
136
139
  // Fix JSON string formatting to match reference (remove escape characters)
137
140
  yamlContent = yamlContent.replace(/\\"/g, '"');
138
141
 
139
- // Save both files
142
+ // Save all files: attributes.yaml, ID mapping, and backup for diff tracking
140
143
  await writeFileSafe(customerAttributesPath(customer.idn), yamlContent);
141
144
  await writeFileSafe(customerAttributesMapPath(customer.idn), JSON.stringify(idMapping, null, 2));
145
+ await writeFileSafe(customerAttributesBackupPath(customer.idn), yamlContent);
142
146
 
143
147
  if (verbose) {
144
148
  console.log(`✓ Saved customer attributes to ${customerAttributesPath(customer.idn)}`);
145
149
  console.log(`✓ Saved attribute ID mapping to ${customerAttributesMapPath(customer.idn)}`);
150
+ console.log(`✓ Created attributes backup for diff tracking`);
146
151
  }
147
152
  } catch (error) {
148
153
  console.error(`❌ Failed to save customer attributes for ${customer.idn}:`, error);
@@ -296,6 +301,13 @@ export async function pullAll(
296
301
  const content = await fs.readFile(newPath, 'utf8');
297
302
  hashes[newPath] = sha256(content);
298
303
 
304
+ // Track skill metadata.yaml file
305
+ const metadataPath = skillMetadataPath(customer.idn, projectMeta.idn, agentIdn, flowIdn, skillIdn);
306
+ if (await fs.pathExists(metadataPath)) {
307
+ const metadataContent = await fs.readFile(metadataPath, 'utf8');
308
+ hashes[metadataPath] = sha256(metadataContent);
309
+ }
310
+
299
311
  // Also track legacy path for backwards compatibility during transition
300
312
  const legacyPath = skillPath(customer.idn, projectMeta.idn, agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
301
313
  hashes[legacyPath] = sha256(content);
@@ -314,6 +326,14 @@ export async function pullAll(
314
326
  hashes[attributesFile] = sha256(attributesContent);
315
327
  if (verbose) console.log(`✓ Added attributes.yaml to hash tracking`);
316
328
  }
329
+
330
+ // Add flows.yaml to hash tracking
331
+ const flowsFile = flowsYamlPath(customer.idn);
332
+ if (await fs.pathExists(flowsFile)) {
333
+ const flowsContent = await fs.readFile(flowsFile, 'utf8');
334
+ hashes[flowsFile] = sha256(flowsContent);
335
+ if (verbose) console.log(`✓ Added flows.yaml to hash tracking`);
336
+ }
317
337
  } catch (error) {
318
338
  console.error(`❌ Failed to save customer attributes for ${customer.idn}:`, error);
319
339
  // Don't throw - continue with the rest of the process
@@ -345,6 +365,13 @@ export async function pullAll(
345
365
  const content = await fs.readFile(newPath, 'utf8');
346
366
  allHashes[newPath] = sha256(content);
347
367
 
368
+ // Track skill metadata.yaml file
369
+ const metadataPath = skillMetadataPath(customer.idn, project.idn, agentIdn, flowIdn, skillIdn);
370
+ if (await fs.pathExists(metadataPath)) {
371
+ const metadataContent = await fs.readFile(metadataPath, 'utf8');
372
+ allHashes[metadataPath] = sha256(metadataContent);
373
+ }
374
+
348
375
  // Also track legacy path for backwards compatibility during transition
349
376
  const legacyPath = skillPath(customer.idn, project.idn, agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
350
377
  allHashes[legacyPath] = sha256(content);
@@ -366,6 +393,14 @@ export async function pullAll(
366
393
  allHashes[attributesFile] = sha256(attributesContent);
367
394
  if (verbose) console.log(`✓ Added attributes.yaml to hash tracking`);
368
395
  }
396
+
397
+ // Add flows.yaml to hash tracking
398
+ const flowsFile = flowsYamlPath(customer.idn);
399
+ if (await fs.pathExists(flowsFile)) {
400
+ const flowsContent = await fs.readFile(flowsFile, 'utf8');
401
+ allHashes[flowsFile] = sha256(flowsContent);
402
+ if (verbose) console.log(`✓ Added flows.yaml to hash tracking`);
403
+ }
369
404
  } catch (error) {
370
405
  console.error(`❌ Failed to save customer attributes for ${customer.idn}:`, error);
371
406
  // Don't throw - continue with the rest of the process
@@ -389,6 +424,7 @@ export async function pushChanged(client: AxiosInstance, customer: CustomerConfi
389
424
  if (verbose) console.log('🔄 Scanning for changes...');
390
425
  let pushed = 0;
391
426
  let scanned = 0;
427
+ let metadataChanged = false;
392
428
 
393
429
  // Handle both old single-project format and new multi-project format with type guards
394
430
  const projects = isProjectMap(idMapData) && idMapData.projects
@@ -495,40 +531,197 @@ export async function pushChanged(client: AxiosInstance, customer: CustomerConfi
495
531
  }
496
532
  }
497
533
 
534
+ // Check for metadata-only changes (when metadata changed but script didn't)
535
+ try {
536
+ for (const [projectIdn, projectData] of Object.entries(projects)) {
537
+ for (const [agentIdn, agentObj] of Object.entries(projectData.agents)) {
538
+ for (const [flowIdn, flowObj] of Object.entries(agentObj.flows)) {
539
+ for (const [skillIdn, skillMeta] of Object.entries(flowObj.skills)) {
540
+ const metadataPath = projectIdn ?
541
+ skillMetadataPath(customer.idn, projectIdn, agentIdn, flowIdn, skillIdn) :
542
+ skillMetadataPath(customer.idn, '', agentIdn, flowIdn, skillIdn);
543
+
544
+ if (await fs.pathExists(metadataPath)) {
545
+ const metadataContent = await fs.readFile(metadataPath, 'utf8');
546
+ const h = sha256(metadataContent);
547
+ const oldHash = oldHashes[metadataPath];
548
+
549
+ if (oldHash !== h) {
550
+ if (verbose) console.log(`🔄 Metadata-only change detected for ${skillIdn}, updating skill...`);
551
+
552
+ try {
553
+ // Load updated metadata
554
+ const updatedMetadata = yaml.load(metadataContent) as SkillMetadata;
555
+
556
+ // Get current script content
557
+ const scriptPath = projectIdn ?
558
+ skillScriptPath(customer.idn, projectIdn, agentIdn, flowIdn, skillIdn, skillMeta.runner_type) :
559
+ skillScriptPath(customer.idn, '', agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
560
+
561
+ let scriptContent = '';
562
+ if (await fs.pathExists(scriptPath)) {
563
+ scriptContent = await fs.readFile(scriptPath, 'utf8');
564
+ }
565
+
566
+ // Create skill object with updated metadata
567
+ const skillObject = {
568
+ id: updatedMetadata.id,
569
+ title: updatedMetadata.title,
570
+ idn: updatedMetadata.idn,
571
+ prompt_script: scriptContent,
572
+ runner_type: updatedMetadata.runner_type,
573
+ model: updatedMetadata.model,
574
+ parameters: updatedMetadata.parameters,
575
+ path: updatedMetadata.path || undefined
576
+ };
577
+
578
+ await updateSkill(client, skillObject);
579
+ console.log(`↑ Pushed metadata update for skill: ${skillIdn} (${updatedMetadata.title})`);
580
+
581
+ newHashes[metadataPath] = h;
582
+ pushed++;
583
+ metadataChanged = true;
584
+
585
+ } catch (error) {
586
+ console.error(`❌ Failed to push metadata for ${skillIdn}: ${error instanceof Error ? error.message : String(error)}`);
587
+ }
588
+ }
589
+ }
590
+ }
591
+ }
592
+ }
593
+ }
594
+ } catch (error) {
595
+ if (verbose) console.log(`⚠️ Metadata push check failed: ${error instanceof Error ? error.message : String(error)}`);
596
+ }
597
+
498
598
  if (verbose) console.log(`🔄 Scanned ${scanned} files, found ${pushed} changes`);
499
599
 
500
- // Check for attributes changes and push if needed
600
+ // Check for attributes changes and push specific changed attributes only
501
601
  try {
502
602
  const attributesFile = customerAttributesPath(customer.idn);
503
603
  const attributesMapFile = customerAttributesMapPath(customer.idn);
604
+ const attributesBackupFile = customerAttributesBackupPath(customer.idn);
504
605
 
505
606
  if (await fs.pathExists(attributesFile) && await fs.pathExists(attributesMapFile)) {
506
607
  if (verbose) console.log('🔍 Checking customer attributes for changes...');
507
608
 
508
- // Use hash comparison for change detection
509
- const content = await fs.readFile(attributesFile, 'utf8');
510
- const h = sha256(content);
511
- const oldHash = oldHashes[attributesFile];
609
+ const currentContent = await fs.readFile(attributesFile, 'utf8');
512
610
 
513
- if (verbose) {
514
- console.log(`📄 Checking: ${attributesFile}`);
515
- console.log(` Old hash: ${oldHash || 'none'}`);
516
- console.log(` New hash: ${h}`);
517
- }
611
+ // Check if backup exists for diff comparison
612
+ if (await fs.pathExists(attributesBackupFile)) {
613
+ const backupContent = await fs.readFile(attributesBackupFile, 'utf8');
518
614
 
519
- if (oldHash !== h) {
520
- if (verbose) console.log(`🔄 Attributes file changed, preparing to push...`);
615
+ if (currentContent !== backupContent) {
616
+ if (verbose) console.log(`🔄 Attributes file changed, analyzing differences...`);
521
617
 
522
- // TODO: Implement actual attributes push here
523
- // For now, just update the hash to mark as "pushed"
524
- console.log(`↑ Attributes changed: ${attributesFile}`);
525
- newHashes[attributesFile] = h;
526
- pushed++;
618
+ try {
619
+ // Load ID mapping for push operations
620
+ const idMapping = await fs.readJson(attributesMapFile) as Record<string, string>;
527
621
 
528
- // Note: Individual attribute push would require parsing the YAML and comparing specific attributes
529
- // This is a placeholder for future implementation
530
- } else if (verbose) {
531
- console.log(` ✓ No attributes changes`);
622
+ // Parse both versions to find changed attributes
623
+ const parseYaml = (content: string) => {
624
+ let yamlContent = content.replace(/!enum "([^"]+)"/g, '"$1"');
625
+ return yaml.load(yamlContent) as { attributes: any[] };
626
+ };
627
+
628
+ const currentData = parseYaml(currentContent);
629
+ const backupData = parseYaml(backupContent);
630
+
631
+ if (currentData?.attributes && backupData?.attributes) {
632
+ // Create maps for comparison
633
+ const currentAttrs = new Map(currentData.attributes.map(attr => [attr.idn, attr]));
634
+ const backupAttrs = new Map(backupData.attributes.map(attr => [attr.idn, attr]));
635
+
636
+ let attributesPushed = 0;
637
+
638
+ // Find changed attributes
639
+ for (const [idn, currentAttr] of currentAttrs) {
640
+ const backupAttr = backupAttrs.get(idn);
641
+
642
+ // Check if attribute changed (deep comparison of key fields)
643
+ const hasChanged = !backupAttr ||
644
+ currentAttr.value !== backupAttr.value ||
645
+ currentAttr.title !== backupAttr.title ||
646
+ currentAttr.description !== backupAttr.description ||
647
+ currentAttr.group !== backupAttr.group ||
648
+ currentAttr.is_hidden !== backupAttr.is_hidden;
649
+
650
+ if (hasChanged) {
651
+ const attributeId = idMapping[idn];
652
+ if (!attributeId) {
653
+ if (verbose) console.log(`⚠️ Skipping ${idn} - no ID mapping`);
654
+ continue;
655
+ }
656
+
657
+ // Create attribute object for push
658
+ const attributeToUpdate: CustomerAttribute = {
659
+ id: attributeId,
660
+ idn: currentAttr.idn,
661
+ value: currentAttr.value,
662
+ title: currentAttr.title || "",
663
+ description: currentAttr.description || "",
664
+ group: currentAttr.group || "",
665
+ is_hidden: currentAttr.is_hidden,
666
+ possible_values: currentAttr.possible_values || [],
667
+ value_type: currentAttr.value_type?.replace(/^"?AttributeValueTypes\.(.+)"?$/, '$1') || "string"
668
+ };
669
+
670
+ await updateCustomerAttribute(client, attributeToUpdate);
671
+ attributesPushed++;
672
+
673
+ if (verbose) {
674
+ console.log(` ✓ Pushed changed attribute: ${idn}`);
675
+ console.log(` Old value: ${backupAttr?.value || 'N/A'}`);
676
+ console.log(` New value: ${currentAttr.value}`);
677
+ }
678
+ }
679
+ }
680
+
681
+ if (attributesPushed > 0) {
682
+ console.log(`↑ Pushed ${attributesPushed} changed customer attributes to NEWO API`);
683
+
684
+ // Show summary of what was pushed
685
+ console.log(` 📊 Pushed attributes:`);
686
+ for (const [idn, currentAttr] of currentAttrs) {
687
+ const backupAttr = backupAttrs.get(idn);
688
+ const hasChanged = !backupAttr ||
689
+ currentAttr.value !== backupAttr.value ||
690
+ currentAttr.title !== backupAttr.title ||
691
+ currentAttr.description !== backupAttr.description ||
692
+ currentAttr.group !== backupAttr.group ||
693
+ currentAttr.is_hidden !== backupAttr.is_hidden;
694
+
695
+ if (hasChanged) {
696
+ console.log(` • ${idn}: ${currentAttr.title || 'No title'}`);
697
+ console.log(` Value: ${currentAttr.value}`);
698
+ }
699
+ }
700
+
701
+ // Update backup file after successful push
702
+ await fs.writeFile(attributesBackupFile, currentContent, 'utf8');
703
+
704
+ newHashes[attributesFile] = sha256(currentContent);
705
+ pushed++;
706
+ } else if (verbose) {
707
+ console.log(` ✓ No attribute value changes detected`);
708
+ }
709
+
710
+ } else {
711
+ console.log(`⚠️ Failed to parse attributes for comparison`);
712
+ }
713
+
714
+ } catch (error) {
715
+ console.error(`❌ Failed to push changed attributes: ${error instanceof Error ? error.message : String(error)}`);
716
+ // Don't update hash/backup on failure so it will retry next time
717
+ }
718
+ } else if (verbose) {
719
+ console.log(` ✓ No attributes file changes`);
720
+ }
721
+ } else {
722
+ // No backup exists, create initial backup
723
+ await fs.writeFile(attributesBackupFile, currentContent, 'utf8');
724
+ if (verbose) console.log(`✓ Created initial attributes backup for diff tracking`);
532
725
  }
533
726
  } else if (verbose) {
534
727
  console.log('ℹ️ No attributes file or ID mapping found for push checking');
@@ -537,6 +730,52 @@ export async function pushChanged(client: AxiosInstance, customer: CustomerConfi
537
730
  if (verbose) console.log(`⚠️ Attributes push check failed: ${error instanceof Error ? error.message : String(error)}`);
538
731
  }
539
732
 
733
+ // Regenerate flows.yaml if metadata changed
734
+ if (metadataChanged) {
735
+ try {
736
+ if (verbose) console.log('🔄 Metadata changed, regenerating flows.yaml...');
737
+
738
+ // Create backup of current flows.yaml for format comparison
739
+ const flowsFile = flowsYamlPath(customer.idn);
740
+ let flowsBackup = '';
741
+ if (await fs.pathExists(flowsFile)) {
742
+ flowsBackup = await fs.readFile(flowsFile, 'utf8');
743
+ const backupPath = `${flowsFile}.backup`;
744
+ await fs.writeFile(backupPath, flowsBackup, 'utf8');
745
+ if (verbose) console.log(`✓ Created flows.yaml backup at ${backupPath}`);
746
+ }
747
+
748
+ // Re-fetch agents for flows.yaml regeneration
749
+ const agentsForFlows: Agent[] = [];
750
+ for (const projectData of Object.values(projects)) {
751
+ const projectAgents = await listAgents(client, projectData.projectId);
752
+ agentsForFlows.push(...projectAgents);
753
+ }
754
+
755
+ // Regenerate flows.yaml
756
+ await generateFlowsYaml(client, customer, agentsForFlows, verbose);
757
+
758
+ // Update flows.yaml hash
759
+ if (await fs.pathExists(flowsFile)) {
760
+ const newFlowsContent = await fs.readFile(flowsFile, 'utf8');
761
+ newHashes[flowsFile] = sha256(newFlowsContent);
762
+
763
+ // Compare format with backup
764
+ if (flowsBackup) {
765
+ const sizeDiff = newFlowsContent.length - flowsBackup.length;
766
+ if (verbose) {
767
+ console.log(`✓ Regenerated flows.yaml (size change: ${sizeDiff > 0 ? '+' : ''}${sizeDiff} chars)`);
768
+ }
769
+ }
770
+ }
771
+
772
+ console.log('↑ Regenerated flows.yaml due to metadata changes');
773
+
774
+ } catch (error) {
775
+ console.error(`❌ Failed to regenerate flows.yaml: ${error instanceof Error ? error.message : String(error)}`);
776
+ }
777
+ }
778
+
540
779
  await saveHashes(newHashes, customer.idn);
541
780
  console.log(pushed ? `✅ Push complete. ${pushed} file(s) updated.` : '✅ Nothing to push.');
542
781
  }
@@ -612,6 +851,51 @@ export async function status(customer: CustomerConfig, verbose: boolean = false)
612
851
  console.log(` ✓ Unchanged: ${currentPath}`);
613
852
  }
614
853
  }
854
+
855
+ // Check metadata.yaml files for changes (after skill files)
856
+ for (const [skillIdn] of Object.entries(flowObj.skills)) {
857
+ const metadataPath = projectIdn ?
858
+ skillMetadataPath(customer.idn, projectIdn, agentIdn, flowIdn, skillIdn) :
859
+ skillMetadataPath(customer.idn, '', agentIdn, flowIdn, skillIdn);
860
+
861
+ if (await fs.pathExists(metadataPath)) {
862
+ const metadataContent = await fs.readFile(metadataPath, 'utf8');
863
+ const h = sha256(metadataContent);
864
+ const oldHash = hashes[metadataPath];
865
+
866
+ if (verbose) {
867
+ console.log(` 📄 ${metadataPath}`);
868
+ console.log(` Old hash: ${oldHash || 'none'}`);
869
+ console.log(` New hash: ${h}`);
870
+ }
871
+
872
+ if (oldHash !== h) {
873
+ console.log(`M ${metadataPath}`);
874
+ dirty++;
875
+
876
+ // Show which metadata fields changed
877
+ try {
878
+ const newMetadata = yaml.load(metadataContent) as any;
879
+
880
+ console.log(` 📊 Metadata changed for skill: ${skillIdn}`);
881
+ if (newMetadata?.title) {
882
+ console.log(` • Title: ${newMetadata.title}`);
883
+ }
884
+ if (newMetadata?.runner_type) {
885
+ console.log(` • Runner: ${newMetadata.runner_type}`);
886
+ }
887
+ if (newMetadata?.model) {
888
+ console.log(` • Model: ${newMetadata.model.provider_idn}/${newMetadata.model.model_idn}`);
889
+ }
890
+ } catch (e) {
891
+ // Fallback to simple message
892
+ if (verbose) console.log(` 🔄 Modified: metadata.yaml`);
893
+ }
894
+ } else if (verbose) {
895
+ console.log(` ✓ Unchanged: ${metadataPath}`);
896
+ }
897
+ }
898
+ }
615
899
  }
616
900
  }
617
901
  }
@@ -633,6 +917,62 @@ export async function status(customer: CustomerConfig, verbose: boolean = false)
633
917
  if (oldHash !== h) {
634
918
  console.log(`M ${attributesFile}`);
635
919
  dirty++;
920
+
921
+ // Show which attributes changed by comparing with backup
922
+ try {
923
+ const attributesBackupFile = customerAttributesBackupPath(customer.idn);
924
+ if (await fs.pathExists(attributesBackupFile)) {
925
+ const backupContent = await fs.readFile(attributesBackupFile, 'utf8');
926
+
927
+ const parseYaml = (content: string) => {
928
+ let yamlContent = content.replace(/!enum "([^"]+)"/g, '"$1"');
929
+ return yaml.load(yamlContent) as { attributes: any[] };
930
+ };
931
+
932
+ const currentData = parseYaml(content);
933
+ const backupData = parseYaml(backupContent);
934
+
935
+ if (currentData?.attributes && backupData?.attributes) {
936
+ const currentAttrs = new Map(currentData.attributes.map(attr => [attr.idn, attr]));
937
+ const backupAttrs = new Map(backupData.attributes.map(attr => [attr.idn, attr]));
938
+
939
+ const changedAttributes: string[] = [];
940
+
941
+ for (const [idn, currentAttr] of currentAttrs) {
942
+ const backupAttr = backupAttrs.get(idn);
943
+ const hasChanged = !backupAttr ||
944
+ currentAttr.value !== backupAttr.value ||
945
+ currentAttr.title !== backupAttr.title ||
946
+ currentAttr.description !== backupAttr.description ||
947
+ currentAttr.group !== backupAttr.group ||
948
+ currentAttr.is_hidden !== backupAttr.is_hidden;
949
+
950
+ if (hasChanged) {
951
+ changedAttributes.push(idn);
952
+ }
953
+ }
954
+
955
+ if (changedAttributes.length > 0) {
956
+ console.log(` 📊 Changed attributes (${changedAttributes.length}):`);
957
+ changedAttributes.slice(0, 5).forEach(idn => {
958
+ const current = currentAttrs.get(idn);
959
+ const backup = backupAttrs.get(idn);
960
+ console.log(` • ${idn}: ${current?.title || 'No title'}`);
961
+ if (verbose) {
962
+ console.log(` Old: ${backup?.value || 'N/A'}`);
963
+ console.log(` New: ${current?.value || 'N/A'}`);
964
+ }
965
+ });
966
+ if (changedAttributes.length > 5) {
967
+ console.log(` ... and ${changedAttributes.length - 5} more`);
968
+ }
969
+ }
970
+ }
971
+ }
972
+ } catch (e) {
973
+ // Fallback to simple message if diff analysis fails
974
+ }
975
+
636
976
  if (verbose) console.log(` 🔄 Modified: attributes.yaml`);
637
977
  } else if (verbose) {
638
978
  console.log(` ✓ Unchanged: attributes.yaml`);
@@ -646,12 +986,30 @@ export async function status(customer: CustomerConfig, verbose: boolean = false)
646
986
  const flowsFile = flowsYamlPath(customer.idn);
647
987
  if (await fs.pathExists(flowsFile)) {
648
988
  try {
649
- const flowsStats = await fs.stat(flowsFile);
989
+ const flowsContent = await fs.readFile(flowsFile, 'utf8');
990
+ const h = sha256(flowsContent);
991
+ const oldHash = hashes[flowsFile];
992
+
650
993
  if (verbose) {
651
994
  console.log(`📄 flows.yaml`);
995
+ console.log(` Old hash: ${oldHash || 'none'}`);
996
+ console.log(` New hash: ${h}`);
997
+ }
998
+
999
+ if (oldHash !== h) {
1000
+ console.log(`M ${flowsFile}`);
1001
+ dirty++;
1002
+ if (verbose) {
1003
+ const flowsStats = await fs.stat(flowsFile);
1004
+ console.log(` 🔄 Modified: flows.yaml`);
1005
+ console.log(` 📊 Size: ${(flowsStats.size / 1024).toFixed(1)}KB`);
1006
+ console.log(` 📅 Last modified: ${flowsStats.mtime.toISOString()}`);
1007
+ }
1008
+ } else if (verbose) {
1009
+ const flowsStats = await fs.stat(flowsFile);
1010
+ console.log(` ✓ Unchanged: flows.yaml`);
652
1011
  console.log(` 📅 Last modified: ${flowsStats.mtime.toISOString()}`);
653
1012
  console.log(` 📊 Size: ${(flowsStats.size / 1024).toFixed(1)}KB`);
654
- console.log(` ✓ Flows file tracked`);
655
1013
  }
656
1014
  } catch (error) {
657
1015
  if (verbose) console.log(`⚠️ Error checking flows.yaml: ${error instanceof Error ? error.message : String(error)}`);