chati-dev 4.3.1 → 4.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +18 -13
  2. package/bin/chati.js +28 -4
  3. package/framework/agents/build/dev.md +5 -1
  4. package/framework/agents/discover/greenfield-wu.md +16 -1
  5. package/framework/agents/plan/tasks.md +31 -0
  6. package/framework/agents/plan/ux-brand-architect.md +21 -2
  7. package/framework/agents/plan/ux-component-engineer.md +10 -0
  8. package/framework/agents/quality/qa-implementation.md +1 -1
  9. package/framework/agents/quality/qa-planning.md +1 -1
  10. package/framework/agents/quality/qa-visual.md +24 -21
  11. package/framework/config.yaml +5 -4
  12. package/framework/constitution.md +60 -11
  13. package/framework/context/governance.md +12 -10
  14. package/framework/context/root.md +2 -2
  15. package/framework/data/entity-registry.yaml +12 -4
  16. package/framework/data/qa-rubrics.yaml +335 -0
  17. package/framework/domains/constitution.yaml +3 -3
  18. package/framework/executors/json-validate.js +80 -0
  19. package/framework/executors/npm-script.js +72 -0
  20. package/framework/executors/path-check.js +67 -0
  21. package/framework/executors/registry.yaml +36 -0
  22. package/framework/hooks/advance-trigger.js +47 -3
  23. package/framework/hooks/constitution-guard.js +9 -4
  24. package/framework/hooks/git-push-authority.js +113 -0
  25. package/framework/hooks/license-guard.js +59 -9
  26. package/framework/hooks/model-governance.js +40 -17
  27. package/framework/hooks/prism-engine.js +78 -11
  28. package/framework/hooks/session-digest.js +12 -3
  29. package/framework/hooks/settings.json +7 -3
  30. package/framework/hooks/style-guard.js +52 -3
  31. package/framework/hooks/team-quality-gate.js +43 -5
  32. package/framework/i18n/en.yaml +3 -3
  33. package/framework/i18n/es.yaml +3 -3
  34. package/framework/i18n/fr.yaml +3 -3
  35. package/framework/i18n/pt.yaml +3 -3
  36. package/framework/intelligence/confidence.yaml +85 -6
  37. package/framework/intelligence/context-engine.md +9 -5
  38. package/framework/intelligence/decision-engine.md +1 -1
  39. package/framework/manifest.json +1351 -0
  40. package/framework/manifest.sig +1 -0
  41. package/framework/orchestrator/chati-router.js +1 -1
  42. package/framework/orchestrator/chati-update.md +19 -3
  43. package/framework/orchestrator/chati.md +24 -3
  44. package/framework/quality-gates/planning-gate.md +2 -0
  45. package/framework/scaffold/motion-premium/scaffold.yaml +2 -1
  46. package/framework/scaffold/motion-premium-3d/components/webgl/WebGLContext.tsx.template +1 -1
  47. package/framework/scaffold/saas-dashboard/README.md +58 -0
  48. package/framework/scaffold/saas-dashboard/app/dashboard/page.tsx.template +546 -0
  49. package/framework/scaffold/saas-dashboard/app/globals.css.template +746 -0
  50. package/framework/scaffold/saas-dashboard/app/layout.tsx.template +192 -0
  51. package/framework/scaffold/saas-dashboard/app/shell.css.template +1070 -0
  52. package/framework/scaffold/saas-dashboard/components/data/Chart.tsx.template +725 -0
  53. package/framework/scaffold/saas-dashboard/components/data/DataTable.tsx.template +623 -0
  54. package/framework/scaffold/saas-dashboard/components/data/DataTable.types.ts.template +104 -0
  55. package/framework/scaffold/saas-dashboard/components/data/DataTablePagination.tsx.template +234 -0
  56. package/framework/scaffold/saas-dashboard/components/data/SkeletonBlock.tsx.template +111 -0
  57. package/framework/scaffold/saas-dashboard/components/data/StatCard.tsx.template +393 -0
  58. package/framework/scaffold/saas-dashboard/components/data/StatCardGrid.tsx.template +121 -0
  59. package/framework/scaffold/saas-dashboard/components/feedback/EmptyState.tsx.template +281 -0
  60. package/framework/scaffold/saas-dashboard/components/interaction/CommandPalette.tsx.template +767 -0
  61. package/framework/scaffold/saas-dashboard/components/interaction/Field.tsx.template +351 -0
  62. package/framework/scaffold/saas-dashboard/components/interaction/Form.tsx.template +424 -0
  63. package/framework/scaffold/saas-dashboard/components/primitives/Badge.tsx.template +193 -0
  64. package/framework/scaffold/saas-dashboard/components/primitives/Button.tsx.template +261 -0
  65. package/framework/scaffold/saas-dashboard/components/primitives/Card.tsx.template +287 -0
  66. package/framework/scaffold/saas-dashboard/components/primitives/Input.tsx.template +310 -0
  67. package/framework/scaffold/saas-dashboard/components/primitives/Spinner.tsx.template +135 -0
  68. package/framework/scaffold/saas-dashboard/components/primitives/index.ts.template +60 -0
  69. package/framework/scaffold/saas-dashboard/components/shell/AppShell.tsx.template +174 -0
  70. package/framework/scaffold/saas-dashboard/components/shell/Breadcrumb.tsx.template +186 -0
  71. package/framework/scaffold/saas-dashboard/components/shell/Container.tsx.template +86 -0
  72. package/framework/scaffold/saas-dashboard/components/shell/NavItem.tsx.template +147 -0
  73. package/framework/scaffold/saas-dashboard/components/shell/Sidebar.tsx.template +378 -0
  74. package/framework/scaffold/saas-dashboard/components/shell/SidebarContext.tsx.template +169 -0
  75. package/framework/scaffold/saas-dashboard/components/shell/ThemeToggle.tsx.template +128 -0
  76. package/framework/scaffold/saas-dashboard/components/shell/Topbar.tsx.template +301 -0
  77. package/framework/scaffold/saas-dashboard/lib/brand.ts.template +324 -0
  78. package/framework/scaffold/saas-dashboard/scaffold.yaml +225 -0
  79. package/framework/schemas/session.schema.json +5 -0
  80. package/framework/schemas/task.schema.json +9 -0
  81. package/framework/scripts/visual-qa.js +87 -1
  82. package/framework/tasks/orchestrator-health.md +4 -4
  83. package/framework/tasks/qa-impl-verdict.md +11 -5
  84. package/framework/templates/qa-gate-tmpl.yaml +24 -11
  85. package/package.json +4 -4
  86. package/src/config/claude-settings-generator.js +9 -6
  87. package/src/config/context-file-generator.js +0 -6
  88. package/src/dashboard/renderer.js +0 -36
  89. package/src/executors/runner.js +204 -0
  90. package/src/installer/core.js +35 -4
  91. package/src/installer/manifest.js +30 -4
  92. package/src/installer/signing-public-key.pem +1 -1
  93. package/src/installer/templates.js +5 -6
  94. package/src/installer/validator.js +2 -1
  95. package/src/intelligence/registry-manager.js +3 -2
  96. package/src/license/client.js +9 -3
  97. package/src/license/commands.js +12 -2
  98. package/src/license/machine-id.js +42 -1
  99. package/src/memory/gotchas.js +58 -40
  100. package/src/memory/magic-docs.js +7 -2
  101. package/src/memory/session-digest.js +9 -4
  102. package/src/orchestrator/cli.js +195 -23
  103. package/src/orchestrator/deviation-handler.js +5 -3
  104. package/src/orchestrator/doctor.js +98 -16
  105. package/src/orchestrator/pipeline-manager.js +44 -22
  106. package/src/orchestrator/session-manager.js +56 -8
  107. package/src/telemetry/sender.js +7 -19
  108. package/src/terminal/run-parallel.js +14 -14
  109. package/src/terminal/run-team.js +3 -3
  110. package/src/upgrade/backup.js +22 -44
  111. package/src/upgrade/migrator.js +51 -12
  112. package/src/upgrade/tracked-files-detector.js +34 -16
  113. package/src/utils/constitution-meta.js +12 -0
  114. package/src/utils/feature-flags.js +26 -2
  115. package/src/utils/flatten-entities.js +4 -40
  116. package/src/utils/schema-validator.js +0 -14
  117. package/src/wizard/i18n.js +3 -3
  118. package/src/api/index.js +0 -120
  119. package/src/autonomy/autonomous-gate.js +0 -294
  120. package/src/autonomy/build-loop.js +0 -281
  121. package/src/autonomy/build-state.js +0 -286
  122. package/src/autonomy/cause-analyzer.js +0 -177
  123. package/src/autonomy/escalation.js +0 -214
  124. package/src/autonomy/index.js +0 -51
  125. package/src/autonomy/mode-manager.js +0 -225
  126. package/src/autonomy/mode-suggester.js +0 -283
  127. package/src/autonomy/progress-reporter.js +0 -275
  128. package/src/autonomy/safety-net.js +0 -370
  129. package/src/config/agent-customizer.js +0 -231
  130. package/src/decision/analyzer.js +0 -291
  131. package/src/decision/engine.js +0 -250
  132. package/src/decision/index.js +0 -38
  133. package/src/decision/registry-healer.js +0 -468
  134. package/src/decision/registry-updater.js +0 -339
  135. package/src/extensions/loader.js +0 -145
  136. package/src/extensions/registry.js +0 -134
  137. package/src/gates/circuit-breaker.js +0 -151
  138. package/src/gates/g1-planning-complete.js +0 -154
  139. package/src/gates/g2-qa-planning.js +0 -156
  140. package/src/gates/g3-implementation.js +0 -215
  141. package/src/gates/g4-qa-implementation.js +0 -240
  142. package/src/gates/g5-deploy-ready.js +0 -181
  143. package/src/gates/gate-base.js +0 -185
  144. package/src/gates/index.js +0 -46
  145. package/src/health/auto-fix.js +0 -216
  146. package/src/health/engine.js +0 -246
  147. package/src/merger/semantic-merger.js +0 -292
  148. package/src/preview/detector.js +0 -238
  149. package/src/preview/index.js +0 -20
  150. package/src/preview/launcher.js +0 -235
  151. package/src/preview/log-buffer.js +0 -103
  152. package/src/quality/metrics-collector.js +0 -281
  153. package/src/quality/test-runner.js +0 -366
  154. package/src/tasks/executor.js +0 -195
  155. package/src/tasks/index.js +0 -4
  156. package/src/tasks/loader.js +0 -210
  157. package/src/tasks/router.js +0 -182
  158. package/src/utils/event-bus.js +0 -126
@@ -9,6 +9,8 @@ import { initCollector, track as telemetryTrack, flush as telemetryFlush } from
9
9
  import { sendEvents } from '../telemetry/sender.js';
10
10
  import { getTelemetryConfig, isEnabled as isTelemetryEnabled } from '../telemetry/config.js';
11
11
  import { getCurrentVersion } from '../upgrade/checker.js';
12
+ import { readFileSync, existsSync } from 'fs';
13
+ import { join } from 'path';
12
14
 
13
15
  /**
14
16
  * Pipeline phases in order.
@@ -107,6 +109,35 @@ const QA_THRESHOLDS = {
107
109
  'qa-visual': QA_VISUAL_THRESHOLD,
108
110
  };
109
111
 
112
+ /**
113
+ * Resolve the gate thresholds from the canonical rubric file
114
+ * (chati.dev/data/qa-rubrics.yaml), falling back to the constants above when
115
+ * the file is unavailable (for example in a unit test with no project on disk).
116
+ * The constants stay as the documented fallback, not as a second source of
117
+ * truth: when the rubric file is present its values win. Cached per directory.
118
+ */
119
+ const _qaThresholdCache = new Map();
120
+ function loadQaThresholds(projectDir) {
121
+ const dir = projectDir || process.cwd();
122
+ if (_qaThresholdCache.has(dir)) return _qaThresholdCache.get(dir);
123
+ const candidates = ['.chati.dev', 'chati.dev'].map(
124
+ (d) => join(dir, d, 'data', 'qa-rubrics.yaml')
125
+ );
126
+ let raw = null;
127
+ for (const p of candidates) {
128
+ if (existsSync(p)) { try { raw = readFileSync(p, 'utf-8'); break; } catch { /* fall through */ } }
129
+ }
130
+ const out = { ...QA_THRESHOLDS };
131
+ if (raw) {
132
+ for (const key of Object.keys(QA_THRESHOLDS)) {
133
+ const m = raw.match(new RegExp(`^\\s*${key}:\\s*(\\d+)`, 'm'));
134
+ if (m) out[key] = parseInt(m[1], 10);
135
+ }
136
+ }
137
+ _qaThresholdCache.set(dir, out);
138
+ return out;
139
+ }
140
+
110
141
  /**
111
142
  * Route a failing QA to the correct rework target via Fault Vector Protocol.
112
143
  *
@@ -180,16 +211,6 @@ function correctionKey(qaAgent, target) {
180
211
  return `${qaAgent}->${target}`;
181
212
  }
182
213
 
183
- /**
184
- * Check if a QA agent's score is below its threshold.
185
- */
186
- function qaScoreBelowThreshold(qaAgent, score) {
187
- const threshold = QA_THRESHOLDS[qaAgent];
188
- if (threshold === undefined) return false;
189
- if (score === null || score === undefined) return true;
190
- return score < threshold;
191
- }
192
-
193
214
  /**
194
215
  * Initialize a new pipeline for a project.
195
216
  *
@@ -690,6 +711,7 @@ function getFirstAgentInPhase(state, phase) {
690
711
  */
691
712
  export function checkPhaseTransition(pipelineState) {
692
713
  const { phase, agents } = pipelineState;
714
+ const T = loadQaThresholds(pipelineState.targetDir);
693
715
 
694
716
  // Check based on current phase
695
717
  if (phase === 'discover') {
@@ -731,15 +753,15 @@ export function checkPhaseTransition(pipelineState) {
731
753
  return {
732
754
  canAdvance: false,
733
755
  reason: 'QA-Planning not yet completed',
734
- requiredScore: QA_PLANNING_THRESHOLD,
756
+ requiredScore: T['qa-planning'],
735
757
  };
736
758
  }
737
759
 
738
- if (qaPlanning.score === null || qaPlanning.score < QA_PLANNING_THRESHOLD) {
760
+ if (qaPlanning.score === null || qaPlanning.score < T['qa-planning']) {
739
761
  return {
740
762
  canAdvance: false,
741
- reason: `QA-Planning score ${qaPlanning.score || 0} below threshold ${QA_PLANNING_THRESHOLD}`,
742
- requiredScore: QA_PLANNING_THRESHOLD,
763
+ reason: `QA-Planning score ${qaPlanning.score || 0} below threshold ${T['qa-planning']}`,
764
+ requiredScore: T['qa-planning'],
743
765
  };
744
766
  }
745
767
 
@@ -795,15 +817,15 @@ export function checkPhaseTransition(pipelineState) {
795
817
  return {
796
818
  canAdvance: false,
797
819
  reason: 'QA-Implementation not yet completed',
798
- requiredScore: QA_IMPLEMENTATION_THRESHOLD,
820
+ requiredScore: T['qa-implementation'],
799
821
  };
800
822
  }
801
823
 
802
- if (qaImpl.score === null || qaImpl.score < QA_IMPLEMENTATION_THRESHOLD) {
824
+ if (qaImpl.score === null || qaImpl.score < T['qa-implementation']) {
803
825
  return {
804
826
  canAdvance: false,
805
- reason: `QA-Implementation score ${qaImpl.score || 0} below threshold ${QA_IMPLEMENTATION_THRESHOLD}`,
806
- requiredScore: QA_IMPLEMENTATION_THRESHOLD,
827
+ reason: `QA-Implementation score ${qaImpl.score || 0} below threshold ${T['qa-implementation']}`,
828
+ requiredScore: T['qa-implementation'],
807
829
  };
808
830
  }
809
831
 
@@ -813,14 +835,14 @@ export function checkPhaseTransition(pipelineState) {
813
835
  return {
814
836
  canAdvance: false,
815
837
  reason: 'QA-Visual not yet completed',
816
- requiredScore: 90,
838
+ requiredScore: T['qa-visual'],
817
839
  };
818
840
  }
819
- if (qaVisual.score === null || qaVisual.score < 90) {
841
+ if (qaVisual.score === null || qaVisual.score < T['qa-visual']) {
820
842
  return {
821
843
  canAdvance: false,
822
- reason: `QA-Visual score ${qaVisual.score || 0} below threshold 90`,
823
- requiredScore: 90,
844
+ reason: `QA-Visual score ${qaVisual.score || 0} below threshold ${T['qa-visual']}`,
845
+ requiredScore: T['qa-visual'],
824
846
  };
825
847
  }
826
848
  }
@@ -6,11 +6,24 @@
6
6
  */
7
7
 
8
8
  import yaml from 'js-yaml';
9
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
9
+ import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs';
10
10
  import { join, dirname } from 'path';
11
11
 
12
12
  const SESSION_FILE = '.chati/session.yaml';
13
13
 
14
+ /**
15
+ * Atomic write helper. Writes `content` to a sibling `.tmp` file and then
16
+ * renames it into place. If the process is killed mid-write the original
17
+ * remains intact. Same-FS rename on POSIX is atomic; on Windows it's
18
+ * best-effort. Mirrors the pattern used by `chati.dev/hooks/prism-engine.js`
19
+ * `writeBackSession` to keep both hook and CLI write paths consistent.
20
+ */
21
+ function writeFileAtomic(path, content, encoding = 'utf-8') {
22
+ const tmp = `${path}.tmp`;
23
+ writeFileSync(tmp, content, encoding);
24
+ renameSync(tmp, path);
25
+ }
26
+
14
27
  /**
15
28
  * Default session template.
16
29
  */
@@ -26,6 +39,12 @@ const DEFAULT_SESSION = {
26
39
  started_at: null,
27
40
  current_agent: '',
28
41
  pipeline_position: 0,
42
+ // execution_mode is a string enum ('interactive' | 'autonomous') per
43
+ // session.schema.json. Article XVIII deprecation (2026-04-18) collapsed
44
+ // the old 4-value execution_profile enum into this binary form. Per-
45
+ // transition metadata (setAt, reason) lives in mode_transitions[] below,
46
+ // not on this field. mode-manager.js was historically reading it as an
47
+ // object — that path was normalized to read the string in 2026-05.
29
48
  execution_mode: 'interactive',
30
49
  user_level: 'auto',
31
50
  user_level_confidence: 0.0,
@@ -135,7 +154,7 @@ export function initSession(projectDir, options = {}) {
135
154
  noRefs: true,
136
155
  });
137
156
 
138
- writeFileSync(sessionPath, yamlContent, 'utf-8');
157
+ writeFileAtomic(sessionPath, yamlContent);
139
158
 
140
159
  return {
141
160
  created: true,
@@ -216,6 +235,14 @@ export function migrateSession(session) {
216
235
  };
217
236
  }
218
237
 
238
+ // Defensive seed: ensure execution_mode is always present after migration.
239
+ // An unrecognized execution_profile (e.g. a hand-edited 'custom') or no
240
+ // profile at all used to leave the field absent, so validateSession could
241
+ // not catch it and every subsequent load re-migrated the same session. The
242
+ // profile mapping below can still promote this to 'autonomous'. Guarded so
243
+ // an existing 'autonomous' value is preserved.
244
+ if (!session.execution_mode) session.execution_mode = 'interactive';
245
+
219
246
  // Article XVIII deprecation (2026-04-18): collapse execution_profile into
220
247
  // execution_mode per the migration table. Runs LAST so it overrides any
221
248
  // default seeded above. See constitution Article XVIII for rationale.
@@ -232,6 +259,20 @@ export function migrateSession(session) {
232
259
  delete session.profile_transitions;
233
260
  }
234
261
 
262
+ // execution_mode normalization (2026-05): the schema declares this field
263
+ // as a string enum ('interactive' | 'autonomous'), and all CLI/dashboard/
264
+ // hook consumers treat it as a string. Earlier `setExecutionMode` writes
265
+ // accidentally produced an object shape `{ mode, setAt, reason }` that
266
+ // only `mode-manager.js#getCurrentMode` understood. Sessions that ran
267
+ // through that path stored the object form; flatten them back to a string
268
+ // here so subsequent reads from cli.js / dashboard / templates work.
269
+ // Per-transition metadata lives in mode_transitions[] (already present).
270
+ if (session.execution_mode && typeof session.execution_mode === 'object' && !Array.isArray(session.execution_mode)) {
271
+ const inner = session.execution_mode.mode;
272
+ const map = { 'human-in-the-loop': 'interactive', interactive: 'interactive', autonomous: 'autonomous' };
273
+ session.execution_mode = map[inner] || 'interactive';
274
+ }
275
+
235
276
  return { migrated: true, fromVersion, toVersion: CURRENT_SCHEMA_VERSION };
236
277
  }
237
278
 
@@ -260,9 +301,16 @@ export function loadSession(projectDir) {
260
301
  if (migration.migrated) {
261
302
  try {
262
303
  const yamlContent = yaml.dump(session, { lineWidth: -1, noRefs: true });
263
- writeFileSync(sessionPath, yamlContent, 'utf-8');
264
- } catch { /* expected: operation may fail gracefully */
265
- // Migration write failed continue with migrated in-memory session
304
+ writeFileAtomic(sessionPath, yamlContent);
305
+ } catch (writeErr) {
306
+ // Migration write-back failed (read-only filesystem, EACCES). The
307
+ // session continues with the migrated in-memory copy, but disk stays
308
+ // on the old schema and re-migrates on every load. Surface it so the
309
+ // user can fix the cause (make .chati/ writable) instead of it failing
310
+ // silently. The production path (writable FS) never reaches here.
311
+ process.stderr.write(
312
+ `[chati] warning: session migrated in memory but could not be written back to ${sessionPath} (${writeErr.code || writeErr.message}); it will re-migrate on every load until the directory is writable\n`
313
+ );
266
314
  }
267
315
  }
268
316
 
@@ -329,7 +377,7 @@ export function updateSession(projectDir, updates) {
329
377
  noRefs: true,
330
378
  });
331
379
 
332
- writeFileSync(sessionPath, yamlContent, 'utf-8');
380
+ writeFileAtomic(sessionPath, yamlContent);
333
381
 
334
382
  return {
335
383
  saved: true,
@@ -392,7 +440,7 @@ function writeSessionToDisk(projectDir, session) {
392
440
  const sessionPath = join(projectDir, SESSION_FILE);
393
441
  try {
394
442
  const yamlContent = yaml.dump(session, { lineWidth: -1, noRefs: true });
395
- writeFileSync(sessionPath, yamlContent, 'utf-8');
443
+ writeFileAtomic(sessionPath, yamlContent);
396
444
  return { saved: true, session };
397
445
  } catch (err) {
398
446
  return { saved: false, error: `Failed to write session: ${err.message}` };
@@ -875,7 +923,7 @@ export function validateSession(projectDir) {
875
923
  const session = loadResult.session;
876
924
 
877
925
  // Validate required fields
878
- const requiredFields = ['schema_version', 'version', 'mode', 'language', 'project_type', 'agents'];
926
+ const requiredFields = ['schema_version', 'version', 'mode', 'execution_mode', 'language', 'project_type', 'agents'];
879
927
  for (const field of requiredFields) {
880
928
  if (!session[field]) {
881
929
  return {
@@ -5,30 +5,19 @@
5
5
  * Fire-and-forget: never blocks the user workflow, fails silently.
6
6
  */
7
7
 
8
- import { existsSync, readFileSync } from 'fs';
9
- import { join } from 'path';
10
- import { homedir } from 'os';
11
-
12
8
  // ---------------------------------------------------------------------------
13
9
  // Default Endpoint
14
10
  // ---------------------------------------------------------------------------
15
11
 
16
12
  export const DEFAULT_ENDPOINT = 'https://chati.dev/api/telemetry';
17
13
 
18
- // ---------------------------------------------------------------------------
19
- // Helpers
20
- // ---------------------------------------------------------------------------
21
-
22
- function readGlobalLicenseKey() {
23
- try {
24
- const p = join(homedir(), '.chati-dev', 'license.yaml');
25
- if (!existsSync(p)) return null;
26
- const content = readFileSync(p, 'utf-8');
27
- const match = content.match(/^key:\s*(.+)$/m);
28
- const key = match?.[1]?.trim();
29
- return key && key !== 'null' ? key : null;
30
- } catch { return null; }
31
- }
14
+ // Note: the prior implementation read the local license key from
15
+ // `~/.chati-dev/license.yaml` and shipped it inside every telemetry POST as
16
+ // `license_key`. That sent a paying user's credential to the analytics
17
+ // endpoint on every install/pipeline event — outside the per-event PII
18
+ // scrubber. Removed (2026-05). Server-side attribution should be derived
19
+ // from the validated license session correlated to `anonymousId`, never from
20
+ // the raw key over the wire.
32
21
 
33
22
  // ---------------------------------------------------------------------------
34
23
  // Sender
@@ -50,7 +39,6 @@ export async function sendEvents(events, config) {
50
39
 
51
40
  const payload = {
52
41
  anonymousId: config.anonymousId,
53
- license_key: readGlobalLicenseKey(),
54
42
  project_name: config.projectName ?? null,
55
43
  chatiVersion: config.version || 'unknown',
56
44
  nodeVersion: process.version,
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { fileURLToPath } from 'url';
18
18
  import { buildAgentPrompt } from './prompt-builder.js';
19
- import { spawnParallelGroup, spawnTerminal } from './spawner.js';
19
+ import { spawnParallelGroup, spawnTerminal, killTerminal } from './spawner.js';
20
20
  import { TerminalMonitor } from './monitor.js';
21
21
  import { collectResults, mergeHandoffs, buildConsolidatedHandoff } from './collector.js';
22
22
  import { parseAgentOutput } from './handoff-parser.js';
@@ -254,13 +254,13 @@ async function main() {
254
254
  * @param {number} timeout - Per-agent timeout in ms
255
255
  * @returns {Promise<{ groupId: string, terminals: object[] }>}
256
256
  */
257
- async function sequentialFallback(configs, timeout) {
257
+ async function sequentialFallback(configs, timeout, _spawnFn = spawnTerminal) {
258
258
  const groupId = `seq-fallback-${Date.now()}`;
259
259
  const terminals = [];
260
260
 
261
261
  for (let i = 0; i < configs.length; i++) {
262
262
  const cfg = configs[i];
263
- const terminal = spawnTerminal({
263
+ const handle = _spawnFn({
264
264
  agent: cfg.agent,
265
265
  taskId: cfg.taskId,
266
266
  model: cfg.model,
@@ -270,26 +270,26 @@ async function sequentialFallback(configs, timeout) {
270
270
  timeout: cfg.timeout || timeout,
271
271
  });
272
272
 
273
- // Wait for this terminal to finish before spawning the next
273
+ // Wait for the REAL process exit before spawning the next agent. The old
274
+ // implementation probed terminal.onExit?.(), a method TerminalHandle never
275
+ // had, so every agent "completed" instantly and the fallback ran the whole
276
+ // group in parallel - defeating its purpose (shedding rate-limit pressure).
274
277
  await new Promise((resolve) => {
275
- const timer = setTimeout(() => {
276
- terminal.kill?.();
278
+ if (!handle?.process || handle.status !== 'running') {
277
279
  resolve();
280
+ return;
281
+ }
282
+ const timer = setTimeout(() => {
283
+ killTerminal(handle).catch(() => {}).then(resolve, resolve);
278
284
  }, (cfg.timeout || timeout) + 5_000);
279
285
 
280
- terminal.onExit?.(() => {
286
+ handle.process.once('exit', () => {
281
287
  clearTimeout(timer);
282
288
  resolve();
283
289
  });
284
-
285
- // If terminal doesn't have onExit, resolve after a short poll
286
- if (!terminal.onExit) {
287
- clearTimeout(timer);
288
- resolve();
289
- }
290
290
  });
291
291
 
292
- terminals.push(terminal);
292
+ terminals.push(handle);
293
293
 
294
294
  // Small delay between spawns to avoid rate limit pressure
295
295
  if (i < configs.length - 1) {
@@ -21,7 +21,7 @@ import { fileURLToPath } from 'url';
21
21
  import { existsSync, readFileSync } from 'fs';
22
22
  import { join } from 'path';
23
23
  import { buildAgentPrompt } from './prompt-builder.js';
24
- import { spawnParallelGroup, spawnTerminal } from './spawner.js';
24
+ import { spawnParallelGroup } from './spawner.js';
25
25
  import { TerminalMonitor } from './monitor.js';
26
26
  import { collectResults, mergeHandoffs, buildConsolidatedHandoff } from './collector.js';
27
27
  import { parseAgentOutput } from './handoff-parser.js';
@@ -107,7 +107,7 @@ async function main() {
107
107
  const taskListPath = join(teamDir, 'tasks.yaml');
108
108
 
109
109
  // Load session state (minimal parse)
110
- let sessionState = {};
110
+ const sessionState = {};
111
111
  try {
112
112
  const sessionPath = join(projectDir, '.chati', 'session.yaml');
113
113
  if (existsSync(sessionPath)) {
@@ -166,7 +166,7 @@ async function main() {
166
166
 
167
167
  // Spawn team members
168
168
  let group;
169
- let fallbackUsed = false;
169
+ const fallbackUsed = false;
170
170
  const echoEvents = [];
171
171
 
172
172
  try {
@@ -1,6 +1,24 @@
1
1
  import { existsSync, mkdirSync, cpSync, rmSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { resolveFrameworkDir } from '../utils/framework-dir.js';
4
+ import { FRAMEWORK_DIRS_TO_COPY } from '../installer/core.js';
5
+
6
+ /**
7
+ * The backup must cover exactly what the upgrade overwrites, or a rollback
8
+ * leaves the install in a chimera state (some dirs at the new version, some
9
+ * restored). The upgrade replaces every dir in FRAMEWORK_DIRS_TO_COPY (via
10
+ * copyFrameworkFiles), so that same list is the backup/restore scope - one
11
+ * source of truth, no asymmetry. Previously the backup saved 12 dirs and the
12
+ * restore returned only 10 (intelligence and data were backed up but never
13
+ * restored; hooks, domains, tasks, scaffold and others were never backed up
14
+ * at all).
15
+ */
16
+ const BACKUP_DIRS = FRAMEWORK_DIRS_TO_COPY;
17
+
18
+ const BACKUP_FILES = [
19
+ 'constitution.md',
20
+ 'config.yaml',
21
+ ];
4
22
 
5
23
  /**
6
24
  * Create backup of chati.dev/ directory before upgrade
@@ -17,28 +35,7 @@ export function createBackup(targetDir, currentVersion) {
17
35
 
18
36
  mkdirSync(backupDir, { recursive: true });
19
37
 
20
- // Backup framework files (NOT artifacts or intelligence)
21
- const dirsToBackup = [
22
- 'orchestrator',
23
- 'agents',
24
- 'templates',
25
- 'workflows',
26
- 'quality-gates',
27
- 'schemas',
28
- 'frameworks',
29
- 'i18n',
30
- 'patterns',
31
- 'migrations',
32
- 'intelligence',
33
- 'data',
34
- ];
35
-
36
- const filesToBackup = [
37
- 'constitution.md',
38
- 'config.yaml',
39
- ];
40
-
41
- for (const dir of dirsToBackup) {
38
+ for (const dir of BACKUP_DIRS) {
42
39
  const src = join(frameworkDir, dir);
43
40
  const dest = join(backupDir, dir);
44
41
  if (existsSync(src)) {
@@ -46,7 +43,7 @@ export function createBackup(targetDir, currentVersion) {
46
43
  }
47
44
  }
48
45
 
49
- for (const file of filesToBackup) {
46
+ for (const file of BACKUP_FILES) {
50
47
  const src = join(frameworkDir, file);
51
48
  const dest = join(backupDir, file);
52
49
  if (existsSync(src)) {
@@ -68,26 +65,7 @@ export function restoreFromBackup(targetDir, version) {
68
65
  throw new Error(`Backup not found: ${backupDir}`);
69
66
  }
70
67
 
71
- // Restore backed-up directories
72
- const dirsToRestore = [
73
- 'orchestrator',
74
- 'agents',
75
- 'templates',
76
- 'workflows',
77
- 'quality-gates',
78
- 'schemas',
79
- 'frameworks',
80
- 'i18n',
81
- 'patterns',
82
- 'migrations',
83
- ];
84
-
85
- const filesToRestore = [
86
- 'constitution.md',
87
- 'config.yaml',
88
- ];
89
-
90
- for (const dir of dirsToRestore) {
68
+ for (const dir of BACKUP_DIRS) {
91
69
  const src = join(backupDir, dir);
92
70
  const dest = join(frameworkDir, dir);
93
71
  if (existsSync(src)) {
@@ -98,7 +76,7 @@ export function restoreFromBackup(targetDir, version) {
98
76
  }
99
77
  }
100
78
 
101
- for (const file of filesToRestore) {
79
+ for (const file of BACKUP_FILES) {
102
80
  const src = join(backupDir, file);
103
81
  const dest = join(frameworkDir, file);
104
82
  if (existsSync(src)) {
@@ -42,13 +42,25 @@ export function findMigrations(targetDir, fromVersion, toVersion) {
42
42
  for (const file of files) {
43
43
  try {
44
44
  const content = yaml.load(readFileSync(join(migrationsDir, file), 'utf-8'));
45
- const migration = content?.migration;
45
+ // Two header schemas exist: the nested `migration: { from, to }` form and
46
+ // the older flat `from_version`/`to_version` form (with `operations`
47
+ // instead of `steps`). The flat files were silently skipped here, which
48
+ // hid them from every upgrade.
49
+ const migration = content?.migration
50
+ ?? (content?.from_version && content?.to_version
51
+ ? { from: content.from_version, to: content.to_version, description: content.description, breaking: content.breaking }
52
+ : null);
46
53
 
47
54
  if (!migration?.from || !migration?.to) continue;
48
55
 
49
- // Check if this migration is in the upgrade path
56
+ // A migration applies when its TARGET version lies inside the upgrade
57
+ // interval (fromVersion, toVersion]. The old condition compared the
58
+ // migration's `from` against the user's version with gte, which skipped
59
+ // every pending migration for users sitting ABOVE a migration's starting
60
+ // point (a 4.2.3 user never received v4.2-to-v4.3) and is wrong for
61
+ // users below it too (a 3.x user needs every step on the way up).
50
62
  if (
51
- semver.gte(migration.from, fromVersion) &&
63
+ semver.gt(migration.to, fromVersion) &&
52
64
  semver.lte(migration.to, toVersion)
53
65
  ) {
54
66
  applicable.push({
@@ -57,7 +69,7 @@ export function findMigrations(targetDir, fromVersion, toVersion) {
57
69
  to: migration.to,
58
70
  description: migration.description || '',
59
71
  breaking: migration.breaking || false,
60
- steps: content.steps || [],
72
+ steps: content.steps || content.operations || [],
61
73
  rollback: content.rollback || [],
62
74
  });
63
75
  }
@@ -73,14 +85,20 @@ export function findMigrations(targetDir, fromVersion, toVersion) {
73
85
  }
74
86
 
75
87
  /**
76
- * Execute a single migration step
88
+ * Execute a single migration step. Exported for testing.
77
89
  */
78
- async function executeMigrationStep(targetDir, step) {
90
+ export async function executeMigrationStep(targetDir, step, sourceFile = '') {
79
91
  switch (step.type) {
80
92
  case 'create_directory': {
81
- const dir = join(targetDir, step.path);
82
- if (!existsSync(dir) || !step.idempotent) {
83
- mkdirSync(dir, { recursive: true });
93
+ // Both shapes exist in the migration corpus: `path` (string) in the
94
+ // nested-schema files and `paths` (array) in the older flat-schema ones.
95
+ const dirs = Array.isArray(step.paths) ? step.paths : [step.path];
96
+ for (const rel of dirs) {
97
+ if (!rel) continue;
98
+ const dir = join(targetDir, rel);
99
+ if (!existsSync(dir) || !step.idempotent) {
100
+ mkdirSync(dir, { recursive: true });
101
+ }
84
102
  }
85
103
  break;
86
104
  }
@@ -100,7 +118,19 @@ async function executeMigrationStep(targetDir, step) {
100
118
 
101
119
  const content = yaml.load(readFileSync(filePath, 'utf-8')) || {};
102
120
 
103
- if (step.operation === 'add_field') {
121
+ if (step.changes && typeof step.changes === 'object') {
122
+ // Older flat-schema shape: a `changes:` map of (possibly dotted)
123
+ // field -> value, applied as a plain set.
124
+ for (const [field, value] of Object.entries(step.changes)) {
125
+ const keys = field.split('.');
126
+ let obj = content;
127
+ for (let i = 0; i < keys.length - 1; i++) {
128
+ if (!obj[keys[i]]) obj[keys[i]] = {};
129
+ obj = obj[keys[i]];
130
+ }
131
+ obj[keys[keys.length - 1]] = value;
132
+ }
133
+ } else if (step.operation === 'add_field') {
104
134
  const keys = step.field.split('.');
105
135
  let obj = content;
106
136
  for (let i = 0; i < keys.length - 1; i++) {
@@ -176,7 +206,16 @@ async function executeMigrationStep(targetDir, step) {
176
206
  }
177
207
 
178
208
  default:
179
- // Unknown step type - skip
209
+ // Copy-style steps (copy_file, copy_directory) from the older flat-schema
210
+ // migrations are subsumed by the upgrade's blanket copyFrameworkFiles, so
211
+ // skipping them is correct. Anything else unknown is surfaced instead of
212
+ // silently dropped, so an upgrader from a very old version knows what to
213
+ // review manually.
214
+ if (step.type !== 'copy_file' && step.type !== 'copy_directory') {
215
+ process.stderr.write(
216
+ `[chati] warning: migration step type "${step.type}"${sourceFile ? ` in ${sourceFile}` : ''} is not auto-executable; review the migration file manually\n`
217
+ );
218
+ }
180
219
  break;
181
220
  }
182
221
  }
@@ -196,7 +235,7 @@ export async function runMigrations(targetDir, fromVersion, toVersion) {
196
235
  for (const migration of migrations) {
197
236
  try {
198
237
  for (const step of migration.steps) {
199
- await executeMigrationStep(targetDir, step);
238
+ await executeMigrationStep(targetDir, step, migration.file);
200
239
  }
201
240
  results.push({ file: migration.file, success: true });
202
241
  } catch (err) {
@@ -41,6 +41,11 @@ function isGitRepo(targetDir) {
41
41
  * Detect which framework-owned paths are currently tracked by git.
42
42
  * Does NOT modify git state — read-only operation.
43
43
  *
44
+ * Runs a single `git ls-files -z -- <pattern1> <pattern2> …` invocation and
45
+ * partitions the result locally. The prior implementation spawned one
46
+ * `git ls-files` per pattern (19 processes per call) which added several
47
+ * hundred ms of overhead on a slow disk during every upgrade check.
48
+ *
44
49
  * @param {string} targetDir - absolute path to the project root
45
50
  * @returns {string[]} array of tracked framework file paths (relative to targetDir)
46
51
  */
@@ -49,28 +54,41 @@ export function detectTrackedFrameworkFiles(targetDir) {
49
54
  return [];
50
55
  }
51
56
 
52
- const tracked = [];
57
+ let stdout;
58
+ try {
59
+ // -z: NUL-separated output (safe for paths with spaces/newlines).
60
+ // Pass all patterns as positional args after `--` so they're treated as
61
+ // pathspecs, not options. Quoting is handled by execFile-style argv if
62
+ // available; we use execSync with a builtin pattern array escape.
63
+ const args = FRAMEWORK_PATTERNS.map(p => JSON.stringify(p)).join(' ');
64
+ stdout = execSync(`git ls-files -z -- ${args}`, {
65
+ cwd: targetDir,
66
+ stdio: ['ignore', 'pipe', 'ignore'],
67
+ timeout: 5000,
68
+ }).toString();
69
+ } catch {
70
+ // git ls-files failed entirely (not a repo, repo corrupt, etc.) → none tracked
71
+ return [];
72
+ }
53
73
 
54
- for (const pattern of FRAMEWORK_PATTERNS) {
55
- try {
56
- const result = execSync(`git ls-files -- "${pattern}"`, {
57
- cwd: targetDir,
58
- stdio: ['ignore', 'pipe', 'ignore'],
59
- timeout: 5000,
60
- })
61
- .toString()
62
- .trim();
74
+ if (!stdout) return [];
63
75
 
64
- if (result.length > 0) {
65
- tracked.push(pattern);
76
+ // Split on NUL. For each tracked path returned by git, find which of our
77
+ // FRAMEWORK_PATTERNS it belongs under (directory prefixes use trailing
78
+ // slash; exact-file patterns match equality).
79
+ const trackedPaths = stdout.split('\0').filter(Boolean);
80
+ const tracked = new Set();
81
+ for (const path of trackedPaths) {
82
+ for (const pattern of FRAMEWORK_PATTERNS) {
83
+ if (pattern.endsWith('/')) {
84
+ if (path.startsWith(pattern)) { tracked.add(pattern); break; }
85
+ } else if (path === pattern) {
86
+ tracked.add(pattern); break;
66
87
  }
67
- } catch {
68
- // git ls-files failure means path is not tracked — skip
69
88
  }
70
89
  }
71
90
 
72
- // Deduplicate in case patterns overlap
73
- return [...new Set(tracked)];
91
+ return [...tracked];
74
92
  }
75
93
 
76
94
  /**