newo 3.7.2 → 3.7.4

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.
@@ -8,7 +8,7 @@
8
8
  * `value_type: json`. The API may return the `value` field as either a
9
9
  * STRING containing JSON or as an already-parsed OBJECT.
10
10
  *
11
- * Without normalization, two bugs leak through:
11
+ * Without normalization, several bugs leak through:
12
12
  *
13
13
  * 1. When the API returns the value as an OBJECT, `yaml.dump` serializes
14
14
  * it as a YAML structure (mappings/sequences). Pushing back then sends
@@ -21,10 +21,25 @@
21
21
  * string vs object representations it triggers spurious pushes that
22
22
  * overwrite the canvas with the wrong shape (Builder shows blank).
23
23
  *
24
- * The fix is conservative: for `value_type: json` only, always coerce the
25
- * value to a STRING when persisting and when pushing, and use canonical
26
- * JSON for comparisons. String-typed values in the wild are left
27
- * untouched, so no churn for the majority of attributes.
24
+ * 3. (Bug 3.7.2-a) Canvas JSON strings with structural newlines (real
25
+ * U+000A between tokens) can be emitted by yaml.dump as double-quoted
26
+ * scalars with `\n` escape sequences. patchYamlToPyyaml then converts
27
+ * those to single-quoted YAML scalars, where `\n` is treated as two
28
+ * literal chars (backslash + n). On push the platform stores those
29
+ * literal chars and the Builder calls JSON.parse, which fails on
30
+ * backslash-n as structural whitespace.
31
+ *
32
+ * 4. (Bug 3.7.2-b) Canvas body text contains Markdown with `\_`
33
+ * (backslash + underscore). `\_` is not a valid JSON escape sequence
34
+ * per RFC 8259 (valid ones: " \ / b f n r t uXXXX). Chrome V8's
35
+ * JSON.parse is strict: it throws SyntaxError on `\_`, silently
36
+ * blanking the Builder.
37
+ *
38
+ * The fix for (3) and (4): for `value_type: json` string values, strip
39
+ * invalid escape sequences then compact via JSON.parse + JSON.stringify.
40
+ * Compaction removes structural newlines and re-serializes all string
41
+ * values with only valid JSON escapes, producing a single-line string
42
+ * that round-trips through YAML without corruption.
28
43
  */
29
44
  /**
30
45
  * True if the attribute is a JSON-typed attribute (case- and
@@ -37,24 +52,84 @@ export function isJsonValueType(valueType) {
37
52
  const lower = valueType.toLowerCase();
38
53
  return lower === 'json' || lower.endsWith('.json');
39
54
  }
55
+ /**
56
+ * Fix invalid JSON escape sequences inside JSON string values.
57
+ *
58
+ * Per RFC 8259, valid escape sequences inside a JSON string are:
59
+ * \" \\ \/ \b \f \n \r \t \uXXXX
60
+ * Anything else (e.g. `\_` `\.` from Markdown) is invalid and causes
61
+ * JSON.parse to throw. Fix: drop the backslash (e.g. `\_` → `_`).
62
+ *
63
+ * Only modifies characters inside JSON string values (tracks quote
64
+ * context). Structural characters outside strings are untouched.
65
+ */
66
+ export function fixInvalidJsonEscapes(s) {
67
+ const VALID_ESCAPES = new Set(['"', '\\', '/', 'b', 'f', 'n', 'r', 't', 'u']);
68
+ const result = [];
69
+ let inString = false;
70
+ let i = 0;
71
+ while (i < s.length) {
72
+ const c = s[i];
73
+ if (inString) {
74
+ if (c === '\\' && i + 1 < s.length) {
75
+ const next = s[i + 1];
76
+ if (VALID_ESCAPES.has(next)) {
77
+ result.push(c, next);
78
+ }
79
+ else {
80
+ result.push(next); // drop the backslash — \_ → _, etc.
81
+ }
82
+ i += 2;
83
+ continue;
84
+ }
85
+ else if (c === '"') {
86
+ inString = false;
87
+ result.push(c);
88
+ }
89
+ else {
90
+ result.push(c);
91
+ }
92
+ }
93
+ else {
94
+ if (c === '"') {
95
+ inString = true;
96
+ result.push(c);
97
+ }
98
+ else {
99
+ result.push(c);
100
+ }
101
+ }
102
+ i++;
103
+ }
104
+ return result.join('');
105
+ }
40
106
  /**
41
107
  * Coerce a JSON-typed attribute's value to a STRING suitable for storage
42
108
  * in attributes.yaml and for sending to the platform.
43
109
  *
44
110
  * - `null` / `undefined` → `''`
45
111
  * - object → compact JSON string (`JSON.stringify(value)`)
46
- * - string → returned as-is (we trust the platform's existing format)
112
+ * - string → fix invalid escapes (e.g. `\_` `_`), then compact via
113
+ * JSON.parse + JSON.stringify. If parsing still fails after
114
+ * fixing escapes, return the fixed string as-is.
47
115
  * - other → `String(value)`
48
116
  *
49
- * We deliberately do NOT re-format string values, even when they look
50
- * like JSON. Many existing canvases are stored pretty-printed and
51
- * reformatting would create huge spurious diffs in users' repos.
117
+ * Compacting removes structural newlines and guarantees a single-line
118
+ * string that yaml.dump serializes without escape-sequence corruption in
119
+ * the patchYamlToPyyaml pass. See module-level comment for full context.
52
120
  */
53
121
  export function normalizeJsonValueForStorage(value) {
54
122
  if (value == null)
55
123
  return '';
56
- if (typeof value === 'string')
57
- return value;
124
+ if (typeof value === 'string') {
125
+ const fixed = fixInvalidJsonEscapes(value);
126
+ try {
127
+ return JSON.stringify(JSON.parse(fixed));
128
+ }
129
+ catch {
130
+ return fixed;
131
+ }
132
+ }
58
133
  if (typeof value === 'object') {
59
134
  try {
60
135
  return JSON.stringify(value);
@@ -69,20 +144,12 @@ export function normalizeJsonValueForStorage(value) {
69
144
  * Canonical comparison for JSON-typed attribute values.
70
145
  *
71
146
  * Returns the canonical form (compact JSON if parseable, otherwise the
72
- * raw string). Use this on both sides of a comparison so that pretty- vs
73
- * compact-printed JSON does not register as a change, and so that an
147
+ * fixed string). Use this on both sides of a comparison so that pretty-
148
+ * vs compact-printed JSON does not register as a change, and so that an
74
149
  * object on one side equals its stringified form on the other side.
75
150
  */
76
151
  export function canonicalJsonValue(value) {
77
- const stringified = normalizeJsonValueForStorage(value);
78
- if (stringified === '')
79
- return '';
80
- try {
81
- return JSON.stringify(JSON.parse(stringified));
82
- }
83
- catch {
84
- return stringified;
85
- }
152
+ return normalizeJsonValueForStorage(value);
86
153
  }
87
154
  /**
88
155
  * True if two JSON-typed attribute values are semantically equal.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newo",
3
- "version": "3.7.2",
3
+ "version": "3.7.4",
4
4
  "description": "NEWO CLI: Professional command-line tool with modular architecture for NEWO AI Agent development. Features account migration, integration management, webhook automation, AKB knowledge base, project attributes, sandbox testing, IDN-based file management, real-time progress tracking, intelligent sync operations, and comprehensive multi-customer support.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,6 +46,8 @@ import {
46
46
  listFlowSkills,
47
47
  listFlowEvents,
48
48
  listFlowStates,
49
+ createSkill,
50
+ createSkillParameter,
49
51
  updateSkill,
50
52
  publishFlow,
51
53
  getProjectAttributes,
@@ -70,6 +72,7 @@ import { sha256, saveHashes, loadHashes } from '../../../hash.js';
70
72
  import {
71
73
  v2ImportVersionPath,
72
74
  v2ProjectYamlPath,
75
+ v2AgentDir,
73
76
  v2AgentYamlPath,
74
77
  v2FlowYamlPath,
75
78
  v2SkillScriptPath,
@@ -89,10 +92,10 @@ import {
89
92
  generateV2FlowYaml,
90
93
  generateV2ProjectYaml,
91
94
  generateV2AgentYaml,
95
+ parseV2FlowYaml,
92
96
  buildV2InlineSkill,
93
97
  buildV2FlowEvent,
94
98
  buildV2StateField,
95
- parseV2FlowYaml,
96
99
  type V2InlineSkill,
97
100
  type V2FlowEvent,
98
101
  type V2StateField,
@@ -100,6 +103,7 @@ import {
100
103
  import { isContentDifferent } from '../../../sync/skill-files.js';
101
104
  import yaml from 'js-yaml';
102
105
  import { patchYamlToPyyaml } from '../../../format/yaml-patch.js';
106
+ import type { RunnerType, SkillParameter } from '../../../types.js';
103
107
 
104
108
  /**
105
109
  * V2ProjectSyncStrategy - same API, newo_v2 file layout
@@ -655,9 +659,17 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
655
659
  }
656
660
 
657
661
  const mapData = await fs.readJson(mapFile) as ProjectMap;
662
+ const metadataSync = await this.syncV2FlowYamlDefinitions(client, customer, mapData, newHashes);
663
+ result.created += metadataSync.created;
664
+ result.updated += metadataSync.updated;
665
+ result.errors.push(...metadataSync.errors);
658
666
 
659
667
  for (const change of changes) {
660
668
  try {
669
+ if (metadataSync.syncedPaths.has(change.path)) {
670
+ continue;
671
+ }
672
+
661
673
  if (change.operation === 'modified') {
662
674
  // V2 flow YAML: newo_customers/{cust}/{proj}/agents/{agent}/flows/{flow}/{flow}.yaml
663
675
  // The flow YAML carries title, events, and state_fields inline, so
@@ -682,6 +694,10 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
682
694
  }
683
695
  }
684
696
 
697
+ if (metadataSync.created > 0 || metadataSync.updated > 0) {
698
+ await writeFileSafe(mapFile, JSON.stringify(mapData, null, 2));
699
+ }
700
+
685
701
  await saveHashes(newHashes, customer.idn);
686
702
 
687
703
  if (result.created > 0 || result.updated > 0) {
@@ -800,6 +816,321 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
800
816
  return total;
801
817
  }
802
818
 
819
+ /**
820
+ * Reconcile inline skill definitions from V2 flow YAML before pushing scripts.
821
+ *
822
+ * V2 keeps skill metadata (model, runner_type, parameters) in the flow YAML,
823
+ * not in a separate skill metadata file. The map only contains the remote IDs
824
+ * from a previous pull, so new local skills must be created before their
825
+ * callers can be published.
826
+ */
827
+ private async syncV2FlowYamlDefinitions(
828
+ client: AxiosInstance,
829
+ customer: CustomerConfig,
830
+ mapData: ProjectMap,
831
+ newHashes: HashStore
832
+ ): Promise<{ created: number; updated: number; syncedPaths: Set<string>; errors: string[] }> {
833
+ let created = 0;
834
+ let updated = 0;
835
+ const syncedPaths = new Set<string>();
836
+ const errors: string[] = [];
837
+
838
+ for (const [projectIdn, projectData] of Object.entries(mapData.projects)) {
839
+ for (const [agentIdn, agentData] of Object.entries(projectData.agents)) {
840
+ for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
841
+ const flowYamlPath = v2FlowYamlPath(customer.idn, projectIdn, agentIdn, flowIdn);
842
+ if (!(await fs.pathExists(flowYamlPath))) {
843
+ continue;
844
+ }
845
+
846
+ let flowDef;
847
+ try {
848
+ flowDef = await parseV2FlowYaml(flowYamlPath);
849
+ } catch (error) {
850
+ this.logger.warn(
851
+ `[newo_v2] Failed to parse flow YAML ${flowYamlPath}: ${error instanceof Error ? error.message : String(error)}`
852
+ );
853
+ continue;
854
+ }
855
+
856
+ for (const skill of flowDef.skills || []) {
857
+ const skillLocator = `${projectIdn}/${agentIdn}/${flowIdn}/${skill.idn}`;
858
+ // Per-skill failure isolation: one broken skill must not abort the
859
+ // push of every other project/flow in the workspace.
860
+ try {
861
+ const runnerType = this.normalizeRunnerType(skill.runner_type);
862
+ const scriptPath = await this.resolveV2FlowSkillScriptPath(
863
+ customer.idn,
864
+ projectIdn,
865
+ agentIdn,
866
+ flowIdn,
867
+ skill.idn,
868
+ runnerType,
869
+ skill.prompt_script
870
+ );
871
+
872
+ if (!(await fs.pathExists(scriptPath))) {
873
+ errors.push(
874
+ `[newo_v2] Missing script for skill ${skillLocator}: ${scriptPath}`
875
+ );
876
+ continue;
877
+ }
878
+
879
+ const content = await fs.readFile(scriptPath, 'utf8');
880
+ const localMetadata = this.buildV2SkillMetadataFromYaml(skill, flowDef, runnerType, flowData.skills[skill.idn]);
881
+ const existingSkill = flowData.skills[skill.idn];
882
+
883
+ if (!existingSkill) {
884
+ this.assertSkillModelResolved(localMetadata, skillLocator);
885
+ try {
886
+ const createdSkill = await createSkill(client, flowData.id, {
887
+ idn: localMetadata.idn,
888
+ title: localMetadata.title,
889
+ prompt_script: content,
890
+ runner_type: localMetadata.runner_type,
891
+ model: localMetadata.model,
892
+ parameters: localMetadata.parameters,
893
+ path: localMetadata.path || ''
894
+ });
895
+
896
+ // The create endpoint ignores inline `parameters` (verified
897
+ // against the live platform) — create them explicitly.
898
+ await this.createMissingSkillParameters(
899
+ client,
900
+ { ...localMetadata, id: createdSkill.id, parameters: [] },
901
+ localMetadata
902
+ );
903
+
904
+ flowData.skills[skill.idn] = {
905
+ ...localMetadata,
906
+ id: createdSkill.id
907
+ };
908
+ newHashes[scriptPath] = sha256(content);
909
+ syncedPaths.add(scriptPath);
910
+ created++;
911
+ this.logger.info(`[newo_v2] Created skill: ${flowIdn}/${skill.idn}`);
912
+ } catch (error) {
913
+ if (!this.isAlreadyExistsApiError(error)) {
914
+ throw error;
915
+ }
916
+
917
+ const remoteSkills = await listFlowSkills(client, flowData.id);
918
+ const remoteSkill = remoteSkills.find(s => s.idn === skill.idn);
919
+ if (!remoteSkill) {
920
+ throw error;
921
+ }
922
+
923
+ const remoteMetadata: SkillMetadata = {
924
+ id: remoteSkill.id,
925
+ idn: remoteSkill.idn,
926
+ title: remoteSkill.title,
927
+ runner_type: remoteSkill.runner_type,
928
+ model: remoteSkill.model,
929
+ parameters: this.normalizeParameters(remoteSkill.parameters),
930
+ path: remoteSkill.path
931
+ };
932
+ await this.createMissingSkillParameters(client, remoteMetadata, localMetadata);
933
+ await updateSkill(client, {
934
+ id: remoteSkill.id,
935
+ title: localMetadata.title,
936
+ idn: localMetadata.idn,
937
+ prompt_script: content,
938
+ runner_type: localMetadata.runner_type,
939
+ model: localMetadata.model,
940
+ parameters: localMetadata.parameters,
941
+ path: remoteSkill.path || localMetadata.path
942
+ });
943
+
944
+ flowData.skills[skill.idn] = {
945
+ ...localMetadata,
946
+ id: remoteSkill.id,
947
+ path: remoteSkill.path || localMetadata.path
948
+ };
949
+ newHashes[scriptPath] = sha256(content);
950
+ syncedPaths.add(scriptPath);
951
+ updated++;
952
+ this.logger.info(`[newo_v2] Reused existing skill: ${flowIdn}/${skill.idn}`);
953
+ }
954
+ continue;
955
+ }
956
+
957
+ const createdParameters = await this.createMissingSkillParameters(client, existingSkill, localMetadata);
958
+
959
+ if (createdParameters > 0 || this.skillMetadataDiffers(existingSkill, localMetadata)) {
960
+ this.assertSkillModelResolved(localMetadata, skillLocator);
961
+ await updateSkill(client, {
962
+ id: existingSkill.id,
963
+ title: localMetadata.title,
964
+ idn: localMetadata.idn,
965
+ prompt_script: content,
966
+ runner_type: localMetadata.runner_type,
967
+ model: localMetadata.model,
968
+ parameters: localMetadata.parameters,
969
+ path: localMetadata.path
970
+ });
971
+
972
+ flowData.skills[skill.idn] = {
973
+ ...localMetadata,
974
+ id: existingSkill.id
975
+ };
976
+ newHashes[scriptPath] = sha256(content);
977
+ syncedPaths.add(scriptPath);
978
+ updated++;
979
+ this.logger.info(`[newo_v2] Updated skill metadata: ${flowIdn}/${skill.idn}`);
980
+ }
981
+ } catch (error) {
982
+ errors.push(
983
+ `Failed to sync skill ${skillLocator}: ${error instanceof Error ? error.message : String(error)}`
984
+ );
985
+ }
986
+ }
987
+ }
988
+ }
989
+ }
990
+
991
+ return { created, updated, syncedPaths, errors };
992
+ }
993
+
994
+ private async createMissingSkillParameters(
995
+ client: AxiosInstance,
996
+ existing: SkillMetadata,
997
+ local: SkillMetadata
998
+ ): Promise<number> {
999
+ const existingNames = new Set(this.normalizeParameters(existing.parameters).map(p => p.name));
1000
+ let created = 0;
1001
+
1002
+ for (const parameter of local.parameters) {
1003
+ if (existingNames.has(parameter.name)) {
1004
+ continue;
1005
+ }
1006
+
1007
+ try {
1008
+ await createSkillParameter(client, existing.id, {
1009
+ name: parameter.name,
1010
+ default_value: parameter.default_value ?? ''
1011
+ });
1012
+ created++;
1013
+ this.logger.info(`[newo_v2] Created skill parameter: ${local.idn}/${parameter.name}`);
1014
+ } catch (error) {
1015
+ if (!this.isAlreadyExistsApiError(error)) {
1016
+ throw error;
1017
+ }
1018
+ }
1019
+ existingNames.add(parameter.name);
1020
+ }
1021
+
1022
+ return created;
1023
+ }
1024
+
1025
+ /**
1026
+ * Detect "resource already exists" API errors.
1027
+ *
1028
+ * Matches only on the precise phrases the platform actually returns
1029
+ * ("already exists", "duplicate key"). Loose substrings like "exist"
1030
+ * would otherwise sweep up unrelated "does not exist" / "doesn't exist"
1031
+ * errors and trigger an incorrect reuse fallback.
1032
+ */
1033
+ private isAlreadyExistsApiError(error: unknown): boolean {
1034
+ const response = (error as { response?: { status?: number; data?: unknown } } | null | undefined)?.response;
1035
+ const status = response?.status;
1036
+ if (status !== 400 && status !== 409 && status !== 422) {
1037
+ return false;
1038
+ }
1039
+
1040
+ const haystack = JSON.stringify(
1041
+ response?.data ?? (error instanceof Error ? error.message : String(error))
1042
+ ).toLowerCase();
1043
+
1044
+ return haystack.includes('already exists') || haystack.includes('duplicate key');
1045
+ }
1046
+
1047
+ private normalizeRunnerType(runnerType: string | undefined): RunnerType {
1048
+ return runnerType === 'nsl' ? 'nsl' : 'guidance';
1049
+ }
1050
+
1051
+ private normalizeParameters(parameters: readonly SkillParameter[] | undefined): SkillParameter[] {
1052
+ return (parameters || []).map(p => ({
1053
+ name: p.name,
1054
+ default_value: p.default_value ?? ''
1055
+ }));
1056
+ }
1057
+
1058
+ /**
1059
+ * Fail fast if no model could be resolved for a V2 skill.
1060
+ *
1061
+ * `buildV2SkillMetadataFromYaml` falls back to empty strings when neither
1062
+ * the skill nor the flow declare a model. The platform rejects empty
1063
+ * model_idn/provider_idn at creation/update time, but the error it returns
1064
+ * is generic — we surface a clearer message before issuing the request.
1065
+ */
1066
+ private assertSkillModelResolved(metadata: SkillMetadata, locator: string): void {
1067
+ if (!metadata.model.model_idn || !metadata.model.provider_idn) {
1068
+ throw new Error(
1069
+ `[newo_v2] Cannot resolve model for skill ${locator}: ` +
1070
+ `model_idn="${metadata.model.model_idn}", provider_idn="${metadata.model.provider_idn}". ` +
1071
+ `Set either skill.model.* or flow default_model_idn/default_provider_idn in the flow YAML.`
1072
+ );
1073
+ }
1074
+ }
1075
+
1076
+ private buildV2SkillMetadataFromYaml(
1077
+ skill: V2InlineSkill,
1078
+ flowDef: Awaited<ReturnType<typeof parseV2FlowYaml>>,
1079
+ runnerType: RunnerType,
1080
+ existing?: SkillMetadata
1081
+ ): SkillMetadata {
1082
+ return {
1083
+ id: existing?.id || '',
1084
+ idn: skill.idn,
1085
+ title: skill.title || '',
1086
+ runner_type: runnerType,
1087
+ model: {
1088
+ model_idn: skill.model?.model_idn || flowDef.default_model_idn || '',
1089
+ provider_idn: skill.model?.provider_idn || flowDef.default_provider_idn || ''
1090
+ },
1091
+ parameters: this.normalizeParameters(skill.parameters),
1092
+ path: existing?.path || ''
1093
+ };
1094
+ }
1095
+
1096
+ private skillMetadataDiffers(existing: SkillMetadata, local: SkillMetadata): boolean {
1097
+ // Compare model/parameters field-by-field, never via JSON.stringify of the
1098
+ // raw objects: the map stores model keys in platform API order
1099
+ // (provider_idn first) while YAML-built metadata uses model_idn first, and
1100
+ // a key-order-sensitive comparison flags every skill as changed.
1101
+ const paramsKey = (params: readonly SkillParameter[] | undefined): string =>
1102
+ JSON.stringify(
1103
+ this.normalizeParameters(params).sort((a, b) => a.name.localeCompare(b.name))
1104
+ );
1105
+
1106
+ return (
1107
+ existing.title !== local.title ||
1108
+ existing.runner_type !== local.runner_type ||
1109
+ existing.model.model_idn !== local.model.model_idn ||
1110
+ existing.model.provider_idn !== local.model.provider_idn ||
1111
+ paramsKey(existing.parameters) !== paramsKey(local.parameters)
1112
+ );
1113
+ }
1114
+
1115
+ private async resolveV2FlowSkillScriptPath(
1116
+ customerIdn: string,
1117
+ projectIdn: string,
1118
+ agentIdn: string,
1119
+ flowIdn: string,
1120
+ skillIdn: string,
1121
+ runnerType: RunnerType,
1122
+ promptScript?: string
1123
+ ): Promise<string> {
1124
+ if (promptScript) {
1125
+ const fromPromptScript = `${v2AgentDir(customerIdn, projectIdn, agentIdn)}/${promptScript}`;
1126
+ if (await fs.pathExists(fromPromptScript)) {
1127
+ return fromPromptScript;
1128
+ }
1129
+ }
1130
+
1131
+ return v2SkillScriptPath(customerIdn, projectIdn, agentIdn, flowIdn, skillIdn, runnerType);
1132
+ }
1133
+
803
1134
  /**
804
1135
  * Push a V2 skill update
805
1136
  *
@@ -949,11 +1280,30 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
949
1280
  }
950
1281
 
951
1282
  for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
952
- for (const [skillIdn, skillMeta] of Object.entries(flowData.skills)) {
953
- const scriptPath = v2SkillScriptPath(
954
- customer.idn, projectIdn, agentIdn, flowIdn, skillIdn,
955
- skillMeta.runner_type
956
- );
1283
+ const flowYamlSkills = await this.loadLocalV2FlowSkills(customer.idn, projectIdn, agentIdn, flowIdn);
1284
+ const skillIdns = new Set([
1285
+ ...Object.keys(flowData.skills),
1286
+ ...flowYamlSkills.keys()
1287
+ ]);
1288
+
1289
+ for (const skillIdn of skillIdns) {
1290
+ const yamlSkill = flowYamlSkills.get(skillIdn);
1291
+ const skillMeta = flowData.skills[skillIdn];
1292
+ const runnerType = this.normalizeRunnerType(yamlSkill?.runner_type || skillMeta?.runner_type);
1293
+ const scriptPath = yamlSkill
1294
+ ? await this.resolveV2FlowSkillScriptPath(
1295
+ customer.idn,
1296
+ projectIdn,
1297
+ agentIdn,
1298
+ flowIdn,
1299
+ skillIdn,
1300
+ runnerType,
1301
+ yamlSkill.prompt_script
1302
+ )
1303
+ : v2SkillScriptPath(
1304
+ customer.idn, projectIdn, agentIdn, flowIdn, skillIdn,
1305
+ runnerType
1306
+ );
957
1307
 
958
1308
  if (await fs.pathExists(scriptPath)) {
959
1309
  const content = await fs.readFile(scriptPath, 'utf8');
@@ -1002,6 +1352,25 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
1002
1352
  return changes;
1003
1353
  }
1004
1354
 
1355
+ private async loadLocalV2FlowSkills(
1356
+ customerIdn: string,
1357
+ projectIdn: string,
1358
+ agentIdn: string,
1359
+ flowIdn: string
1360
+ ): Promise<Map<string, V2InlineSkill>> {
1361
+ const flowYamlPath = v2FlowYamlPath(customerIdn, projectIdn, agentIdn, flowIdn);
1362
+ if (!(await fs.pathExists(flowYamlPath))) {
1363
+ return new Map();
1364
+ }
1365
+
1366
+ try {
1367
+ const flowDef = await parseV2FlowYaml(flowYamlPath);
1368
+ return new Map((flowDef.skills || []).map(skill => [skill.idn, skill]));
1369
+ } catch {
1370
+ return new Map();
1371
+ }
1372
+ }
1373
+
1005
1374
  async validate(customer: CustomerConfig, _items: LocalProjectData[]): Promise<ValidationResult> {
1006
1375
  const errors: ValidationError[] = [];
1007
1376
 
@@ -1020,7 +1389,61 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
1020
1389
  for (const [projectIdn, projectData] of Object.entries(mapData.projects)) {
1021
1390
  for (const [agentIdn, agentData] of Object.entries(projectData.agents)) {
1022
1391
  for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
1392
+ const flowYamlPath = v2FlowYamlPath(customer.idn, projectIdn, agentIdn, flowIdn);
1393
+ let localYamlSkills: Map<string, V2InlineSkill> | undefined;
1394
+ if (await fs.pathExists(flowYamlPath)) {
1395
+ try {
1396
+ const flowDef = await parseV2FlowYaml(flowYamlPath);
1397
+ localYamlSkills = new Map((flowDef.skills || []).map(s => [s.idn, s]));
1398
+ const skillIdns = new Set([
1399
+ ...Object.keys(flowData.skills),
1400
+ ...localYamlSkills.keys()
1401
+ ]);
1402
+
1403
+ for (const skillIdn of skillIdns) {
1404
+ const localYamlSkill = localYamlSkills.get(skillIdn);
1405
+ const skillMeta = flowData.skills[skillIdn];
1406
+
1407
+ if (!localYamlSkill) {
1408
+ errors.push({
1409
+ field: `skill.${skillIdn}`,
1410
+ message: `Skill exists in project map but is missing from flow YAML: ${flowYamlPath}`,
1411
+ path: flowYamlPath
1412
+ });
1413
+ continue;
1414
+ }
1415
+
1416
+ const runnerType = this.normalizeRunnerType(
1417
+ localYamlSkill.runner_type || skillMeta?.runner_type
1418
+ );
1419
+ const scriptPath = await this.resolveV2FlowSkillScriptPath(
1420
+ customer.idn,
1421
+ projectIdn,
1422
+ agentIdn,
1423
+ flowIdn,
1424
+ skillIdn,
1425
+ runnerType,
1426
+ localYamlSkill.prompt_script
1427
+ );
1428
+
1429
+ if (!(await fs.pathExists(scriptPath))) {
1430
+ errors.push({
1431
+ field: `skill.${localYamlSkill.idn}`,
1432
+ message: `Script file not found: ${scriptPath}`,
1433
+ path: scriptPath
1434
+ });
1435
+ }
1436
+ }
1437
+ } catch {
1438
+ localYamlSkills = undefined;
1439
+ }
1440
+ }
1441
+
1023
1442
  for (const [skillIdn, skillMeta] of Object.entries(flowData.skills)) {
1443
+ if (localYamlSkills) {
1444
+ continue;
1445
+ }
1446
+
1024
1447
  const scriptPath = v2SkillScriptPath(
1025
1448
  customer.idn, projectIdn, agentIdn, flowIdn, skillIdn,
1026
1449
  skillMeta.runner_type