newo 1.7.2 → 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,47 @@ 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
+
33
+ ## [1.7.3] - 2025-09-15
34
+
35
+ ### Added
36
+ - **Complete Attributes Change Tracking**: Full hash-based change detection for customer attributes
37
+ - Attributes.yaml files now included in hash tracking during pull operations
38
+ - Status command detects and reports modifications to attributes.yaml files
39
+ - Push command detects and handles attributes changes with proper synchronization
40
+ - Comprehensive workflow: modify → status shows change → push applies change → status shows clean
41
+
42
+ ### Enhanced
43
+ - **File Synchronization Scope**: Extended to cover all file types in NEWO workspace
44
+ - Skills: .guidance and .jinja files with full hash tracking ✓
45
+ - Attributes: customer attributes.yaml with change detection ✓
46
+ - Metadata: flows.yaml and metadata.yaml files tracked ✓
47
+ - Multi-customer: all file types synchronized across multiple customers ✓
48
+
8
49
  ## [1.7.2] - 2025-09-15
9
50
 
10
51
  ### Fixed
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,21 +217,43 @@ 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);
221
229
  }
222
230
  }
223
231
  }
224
- await saveHashes(hashes, customer.idn);
225
- // Save customer attributes
232
+ // Save customer attributes before hash tracking
226
233
  try {
227
234
  await saveCustomerAttributes(client, customer, verbose);
235
+ // Add attributes.yaml to hash tracking
236
+ const attributesFile = customerAttributesPath(customer.idn);
237
+ if (await fs.pathExists(attributesFile)) {
238
+ const attributesContent = await fs.readFile(attributesFile, 'utf8');
239
+ hashes[attributesFile] = sha256(attributesContent);
240
+ if (verbose)
241
+ console.log(`✓ Added attributes.yaml to hash tracking`);
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
+ }
228
251
  }
229
252
  catch (error) {
230
253
  console.error(`❌ Failed to save customer attributes for ${customer.idn}:`, error);
231
254
  // Don't throw - continue with the rest of the process
232
255
  }
256
+ await saveHashes(hashes, customer.idn);
233
257
  return;
234
258
  }
235
259
  // Multi-project mode
@@ -253,6 +277,12 @@ export async function pullAll(client, customer, projectId = null, verbose = fals
253
277
  const newPath = skillScriptPath(customer.idn, project.idn, agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
254
278
  const content = await fs.readFile(newPath, 'utf8');
255
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
+ }
256
286
  // Also track legacy path for backwards compatibility during transition
257
287
  const legacyPath = skillPath(customer.idn, project.idn, agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
258
288
  allHashes[legacyPath] = sha256(content);
@@ -261,15 +291,31 @@ export async function pullAll(client, customer, projectId = null, verbose = fals
261
291
  }
262
292
  }
263
293
  await fs.writeJson(mapPath(customer.idn), idMap, { spaces: 2 });
264
- await saveHashes(allHashes, customer.idn);
265
- // Save customer attributes
294
+ // Save customer attributes before hash tracking
266
295
  try {
267
296
  await saveCustomerAttributes(client, customer, verbose);
297
+ // Add attributes.yaml to hash tracking
298
+ const attributesFile = customerAttributesPath(customer.idn);
299
+ if (await fs.pathExists(attributesFile)) {
300
+ const attributesContent = await fs.readFile(attributesFile, 'utf8');
301
+ allHashes[attributesFile] = sha256(attributesContent);
302
+ if (verbose)
303
+ console.log(`✓ Added attributes.yaml to hash tracking`);
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
+ }
268
313
  }
269
314
  catch (error) {
270
315
  console.error(`❌ Failed to save customer attributes for ${customer.idn}:`, error);
271
316
  // Don't throw - continue with the rest of the process
272
317
  }
318
+ await saveHashes(allHashes, customer.idn);
273
319
  }
274
320
  export async function pushChanged(client, customer, verbose = false) {
275
321
  await ensureState(customer.idn);
@@ -287,6 +333,7 @@ export async function pushChanged(client, customer, verbose = false) {
287
333
  console.log('🔄 Scanning for changes...');
288
334
  let pushed = 0;
289
335
  let scanned = 0;
336
+ let metadataChanged = false;
290
337
  // Handle both old single-project format and new multi-project format with type guards
291
338
  const projects = isProjectMap(idMapData) && idMapData.projects
292
339
  ? idMapData.projects
@@ -387,24 +434,178 @@ export async function pushChanged(client, customer, verbose = false) {
387
434
  }
388
435
  }
389
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
+ }
390
495
  if (verbose)
391
496
  console.log(`🔄 Scanned ${scanned} files, found ${pushed} changes`);
392
- // Check for attributes changes and push if needed
497
+ // Check for attributes changes and push specific changed attributes only
393
498
  try {
394
499
  const attributesFile = customerAttributesPath(customer.idn);
395
500
  const attributesMapFile = customerAttributesMapPath(customer.idn);
501
+ const attributesBackupFile = customerAttributesBackupPath(customer.idn);
396
502
  if (await fs.pathExists(attributesFile) && await fs.pathExists(attributesMapFile)) {
397
503
  if (verbose)
398
504
  console.log('🔍 Checking customer attributes for changes...');
399
- // Check file modification time for change detection instead of YAML parsing
400
- const attributesStats = await fs.stat(attributesFile);
401
- const idMapping = await fs.readJson(attributesMapFile);
402
- // Count attributes by ID mapping instead of parsing YAML (avoids enum parsing issues)
403
- const attributeCount = Object.keys(idMapping).length;
404
- if (verbose) {
405
- console.log(`📊 Found ${attributeCount} attributes ready for push operations`);
406
- console.log(`📅 Attributes file last modified: ${attributesStats.mtime.toISOString()}`);
407
- // TODO: Implement change detection by comparing with last push timestamp
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
+ }
603
+ }
604
+ else {
605
+ // No backup exists, create initial backup
606
+ await fs.writeFile(attributesBackupFile, currentContent, 'utf8');
607
+ if (verbose)
608
+ console.log(`✓ Created initial attributes backup for diff tracking`);
408
609
  }
409
610
  }
410
611
  else if (verbose) {
@@ -415,6 +616,47 @@ export async function pushChanged(client, customer, verbose = false) {
415
616
  if (verbose)
416
617
  console.log(`⚠️ Attributes push check failed: ${error instanceof Error ? error.message : String(error)}`);
417
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
+ }
418
660
  await saveHashes(newHashes, customer.idn);
419
661
  console.log(pushed ? `✅ Push complete. ${pushed} file(s) updated.` : '✅ Nothing to push.');
420
662
  }
@@ -485,6 +727,48 @@ export async function status(customer, verbose = false) {
485
727
  console.log(` ✓ Unchanged: ${currentPath}`);
486
728
  }
487
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
+ }
488
772
  }
489
773
  }
490
774
  }
@@ -492,16 +776,71 @@ export async function status(customer, verbose = false) {
492
776
  try {
493
777
  const attributesFile = customerAttributesPath(customer.idn);
494
778
  if (await fs.pathExists(attributesFile)) {
495
- const attributesStats = await fs.stat(attributesFile);
496
- const attributesPath = `${customer.idn}/attributes.yaml`;
779
+ const content = await fs.readFile(attributesFile, 'utf8');
780
+ const h = sha256(content);
781
+ const oldHash = hashes[attributesFile];
497
782
  if (verbose) {
498
- console.log(`📄 ${attributesPath}`);
499
- console.log(` 📅 Last modified: ${attributesStats.mtime.toISOString()}`);
500
- console.log(` 📊 Size: ${(attributesStats.size / 1024).toFixed(1)}KB`);
783
+ console.log(`📄 ${attributesFile}`);
784
+ console.log(` Old hash: ${oldHash || 'none'}`);
785
+ console.log(` New hash: ${h}`);
786
+ }
787
+ if (oldHash !== h) {
788
+ console.log(`M ${attributesFile}`);
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
+ }
838
+ if (verbose)
839
+ console.log(` 🔄 Modified: attributes.yaml`);
840
+ }
841
+ else if (verbose) {
842
+ console.log(` ✓ Unchanged: attributes.yaml`);
501
843
  }
502
- // For now, just report the file exists (change detection would require timestamp tracking)
503
- if (verbose)
504
- console.log(` ✓ Attributes file tracked`);
505
844
  }
506
845
  }
507
846
  catch (error) {
@@ -512,12 +851,29 @@ export async function status(customer, verbose = false) {
512
851
  const flowsFile = flowsYamlPath(customer.idn);
513
852
  if (await fs.pathExists(flowsFile)) {
514
853
  try {
515
- const flowsStats = await fs.stat(flowsFile);
854
+ const flowsContent = await fs.readFile(flowsFile, 'utf8');
855
+ const h = sha256(flowsContent);
856
+ const oldHash = hashes[flowsFile];
516
857
  if (verbose) {
517
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`);
518
875
  console.log(` 📅 Last modified: ${flowsStats.mtime.toISOString()}`);
519
876
  console.log(` 📊 Size: ${(flowsStats.size / 1024).toFixed(1)}KB`);
520
- console.log(` ✓ Flows file tracked`);
521
877
  }
522
878
  }
523
879
  catch (error) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "newo",
3
- "version": "1.7.2",
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,21 +301,45 @@ 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);
302
314
  }
303
315
  }
304
316
  }
305
- await saveHashes(hashes, customer.idn);
306
317
 
307
- // Save customer attributes
318
+ // Save customer attributes before hash tracking
308
319
  try {
309
320
  await saveCustomerAttributes(client, customer, verbose);
321
+
322
+ // Add attributes.yaml to hash tracking
323
+ const attributesFile = customerAttributesPath(customer.idn);
324
+ if (await fs.pathExists(attributesFile)) {
325
+ const attributesContent = await fs.readFile(attributesFile, 'utf8');
326
+ hashes[attributesFile] = sha256(attributesContent);
327
+ if (verbose) console.log(`✓ Added attributes.yaml to hash tracking`);
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
+ }
310
337
  } catch (error) {
311
338
  console.error(`❌ Failed to save customer attributes for ${customer.idn}:`, error);
312
339
  // Don't throw - continue with the rest of the process
313
340
  }
341
+
342
+ await saveHashes(hashes, customer.idn);
314
343
  return;
315
344
  }
316
345
 
@@ -336,6 +365,13 @@ export async function pullAll(
336
365
  const content = await fs.readFile(newPath, 'utf8');
337
366
  allHashes[newPath] = sha256(content);
338
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
+
339
375
  // Also track legacy path for backwards compatibility during transition
340
376
  const legacyPath = skillPath(customer.idn, project.idn, agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
341
377
  allHashes[legacyPath] = sha256(content);
@@ -345,15 +381,32 @@ export async function pullAll(
345
381
  }
346
382
 
347
383
  await fs.writeJson(mapPath(customer.idn), idMap, { spaces: 2 });
348
- await saveHashes(allHashes, customer.idn);
349
384
 
350
- // Save customer attributes
385
+ // Save customer attributes before hash tracking
351
386
  try {
352
387
  await saveCustomerAttributes(client, customer, verbose);
388
+
389
+ // Add attributes.yaml to hash tracking
390
+ const attributesFile = customerAttributesPath(customer.idn);
391
+ if (await fs.pathExists(attributesFile)) {
392
+ const attributesContent = await fs.readFile(attributesFile, 'utf8');
393
+ allHashes[attributesFile] = sha256(attributesContent);
394
+ if (verbose) console.log(`✓ Added attributes.yaml to hash tracking`);
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
+ }
353
404
  } catch (error) {
354
405
  console.error(`❌ Failed to save customer attributes for ${customer.idn}:`, error);
355
406
  // Don't throw - continue with the rest of the process
356
407
  }
408
+
409
+ await saveHashes(allHashes, customer.idn);
357
410
  }
358
411
 
359
412
  export async function pushChanged(client: AxiosInstance, customer: CustomerConfig, verbose: boolean = false): Promise<void> {
@@ -371,6 +424,7 @@ export async function pushChanged(client: AxiosInstance, customer: CustomerConfi
371
424
  if (verbose) console.log('🔄 Scanning for changes...');
372
425
  let pushed = 0;
373
426
  let scanned = 0;
427
+ let metadataChanged = false;
374
428
 
375
429
  // Handle both old single-project format and new multi-project format with type guards
376
430
  const projects = isProjectMap(idMapData) && idMapData.projects
@@ -477,27 +531,197 @@ export async function pushChanged(client: AxiosInstance, customer: CustomerConfi
477
531
  }
478
532
  }
479
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
+
480
598
  if (verbose) console.log(`🔄 Scanned ${scanned} files, found ${pushed} changes`);
481
599
 
482
- // Check for attributes changes and push if needed
600
+ // Check for attributes changes and push specific changed attributes only
483
601
  try {
484
602
  const attributesFile = customerAttributesPath(customer.idn);
485
603
  const attributesMapFile = customerAttributesMapPath(customer.idn);
604
+ const attributesBackupFile = customerAttributesBackupPath(customer.idn);
486
605
 
487
606
  if (await fs.pathExists(attributesFile) && await fs.pathExists(attributesMapFile)) {
488
607
  if (verbose) console.log('🔍 Checking customer attributes for changes...');
489
608
 
490
- // Check file modification time for change detection instead of YAML parsing
491
- const attributesStats = await fs.stat(attributesFile);
492
- const idMapping = await fs.readJson(attributesMapFile) as Record<string, string>;
609
+ const currentContent = await fs.readFile(attributesFile, 'utf8');
493
610
 
494
- // Count attributes by ID mapping instead of parsing YAML (avoids enum parsing issues)
495
- const attributeCount = Object.keys(idMapping).length;
611
+ // Check if backup exists for diff comparison
612
+ if (await fs.pathExists(attributesBackupFile)) {
613
+ const backupContent = await fs.readFile(attributesBackupFile, 'utf8');
496
614
 
497
- if (verbose) {
498
- console.log(`📊 Found ${attributeCount} attributes ready for push operations`);
499
- console.log(`📅 Attributes file last modified: ${attributesStats.mtime.toISOString()}`);
500
- // TODO: Implement change detection by comparing with last push timestamp
615
+ if (currentContent !== backupContent) {
616
+ if (verbose) console.log(`🔄 Attributes file changed, analyzing differences...`);
617
+
618
+ try {
619
+ // Load ID mapping for push operations
620
+ const idMapping = await fs.readJson(attributesMapFile) as Record<string, string>;
621
+
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`);
501
725
  }
502
726
  } else if (verbose) {
503
727
  console.log('ℹ️ No attributes file or ID mapping found for push checking');
@@ -506,6 +730,52 @@ export async function pushChanged(client: AxiosInstance, customer: CustomerConfi
506
730
  if (verbose) console.log(`⚠️ Attributes push check failed: ${error instanceof Error ? error.message : String(error)}`);
507
731
  }
508
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
+
509
779
  await saveHashes(newHashes, customer.idn);
510
780
  console.log(pushed ? `✅ Push complete. ${pushed} file(s) updated.` : '✅ Nothing to push.');
511
781
  }
@@ -581,6 +851,51 @@ export async function status(customer: CustomerConfig, verbose: boolean = false)
581
851
  console.log(` ✓ Unchanged: ${currentPath}`);
582
852
  }
583
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
+ }
584
899
  }
585
900
  }
586
901
  }
@@ -589,17 +904,79 @@ export async function status(customer: CustomerConfig, verbose: boolean = false)
589
904
  try {
590
905
  const attributesFile = customerAttributesPath(customer.idn);
591
906
  if (await fs.pathExists(attributesFile)) {
592
- const attributesStats = await fs.stat(attributesFile);
593
- const attributesPath = `${customer.idn}/attributes.yaml`;
907
+ const content = await fs.readFile(attributesFile, 'utf8');
908
+ const h = sha256(content);
909
+ const oldHash = hashes[attributesFile];
594
910
 
595
911
  if (verbose) {
596
- console.log(`📄 ${attributesPath}`);
597
- console.log(` 📅 Last modified: ${attributesStats.mtime.toISOString()}`);
598
- console.log(` 📊 Size: ${(attributesStats.size / 1024).toFixed(1)}KB`);
912
+ console.log(`📄 ${attributesFile}`);
913
+ console.log(` Old hash: ${oldHash || 'none'}`);
914
+ console.log(` New hash: ${h}`);
599
915
  }
600
916
 
601
- // For now, just report the file exists (change detection would require timestamp tracking)
602
- if (verbose) console.log(` ✓ Attributes file tracked`);
917
+ if (oldHash !== h) {
918
+ console.log(`M ${attributesFile}`);
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
+
976
+ if (verbose) console.log(` 🔄 Modified: attributes.yaml`);
977
+ } else if (verbose) {
978
+ console.log(` ✓ Unchanged: attributes.yaml`);
979
+ }
603
980
  }
604
981
  } catch (error) {
605
982
  if (verbose) console.log(`⚠️ Error checking attributes: ${error instanceof Error ? error.message : String(error)}`);
@@ -609,12 +986,30 @@ export async function status(customer: CustomerConfig, verbose: boolean = false)
609
986
  const flowsFile = flowsYamlPath(customer.idn);
610
987
  if (await fs.pathExists(flowsFile)) {
611
988
  try {
612
- 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
+
613
993
  if (verbose) {
614
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`);
615
1011
  console.log(` 📅 Last modified: ${flowsStats.mtime.toISOString()}`);
616
1012
  console.log(` 📊 Size: ${(flowsStats.size / 1024).toFixed(1)}KB`);
617
- console.log(` ✓ Flows file tracked`);
618
1013
  }
619
1014
  } catch (error) {
620
1015
  if (verbose) console.log(`⚠️ Error checking flows.yaml: ${error instanceof Error ? error.message : String(error)}`);