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
@@ -177,18 +177,23 @@ export function buildStructuredDigest(projectDir, sessionState, enrichment = {})
177
177
  },
178
178
  };
179
179
 
180
- // Final budget enforcement — iterative truncation until within budget
180
+ // Final budget enforcement — iterative truncation until within budget.
181
+ // Cache `JSON.stringify(digest).length` and refresh only after a real
182
+ // mutation; the prior implementation serialised the entire object twice
183
+ // per iteration, which became expensive for large sessions.
181
184
  const truncOrder = ['next_steps', 'gotchas_found', 'files_changed', 'blockers', 'decisions_made'];
185
+ const measure = () => JSON.stringify(digest).length;
186
+ let size = measure();
182
187
  let pass = 0;
183
- while (JSON.stringify(digest).length > MAX_STRUCTURED_CHARS && pass < truncOrder.length) {
188
+ while (size > MAX_STRUCTURED_CHARS && pass < truncOrder.length) {
184
189
  const field = truncOrder[pass];
185
190
  if (Array.isArray(digest[field]) && digest[field].length > 1) {
186
191
  digest[field] = digest[field].slice(0, Math.max(1, Math.ceil(digest[field].length / 2)));
192
+ size = measure();
193
+ if (size > MAX_STRUCTURED_CHARS) pass++; // still too big — move on
187
194
  } else {
188
195
  pass++;
189
196
  }
190
- // If we halved and still too big, move to next field
191
- if (JSON.stringify(digest).length > MAX_STRUCTURED_CHARS) pass++;
192
197
  }
193
198
 
194
199
  return digest;
@@ -12,6 +12,8 @@
12
12
  */
13
13
 
14
14
  import { resolveFrameworkDir } from '../utils/framework-dir.js';
15
+ import { isFeatureEnabled } from '../utils/feature-flags.js';
16
+ import { runScriptTask, formatScriptHandoff } from '../executors/runner.js';
15
17
 
16
18
  import {
17
19
  loadSession, initSession, updateSession, recordAgentCompletion,
@@ -24,6 +26,7 @@ import {
24
26
  import { resolveContextLimit } from '../utils/provider-limits.js';
25
27
  import { waitForLicense } from '../license/wait.js';
26
28
  import { runDoctor } from './doctor.js';
29
+ import { evaluateSurfaceCriteria } from '../autonomy/surface-criteria.js';
27
30
  import {
28
31
  getNextAgent, getAgentDefinition, AGENT_PIPELINE,
29
32
  } from './index.js';
@@ -38,8 +41,9 @@ import {
38
41
  import { analyzeDeviationImpact, applyDeviation } from './index.js';
39
42
  import { detectQuickFlow, detectStandardFlow } from './index.js';
40
43
  import { getAgentFile } from '../terminal/prompt-builder.js';
41
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
42
- import { join, resolve } from 'path';
44
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
45
+ import { join, resolve, dirname } from 'path';
46
+ import { fileURLToPath } from 'url';
43
47
  import { execSync } from 'child_process';
44
48
  import yaml from 'js-yaml';
45
49
  import {
@@ -50,6 +54,15 @@ import {
50
54
  } from '../intelligence/timeline.js';
51
55
  import { updateClaudeMd } from '../memory/magic-docs.js';
52
56
 
57
+ // Spawn commands must reference the terminal runners with an absolute,
58
+ // layout-independent path: the old monorepo-relative
59
+ // 'packages/chati-dev/src/terminal/...' only resolves when the command runs
60
+ // from the monorepo root, never in an installed project (.chati.dev/_cli/ or
61
+ // node_modules). Anchoring to this file's own location works in every layout
62
+ // because the src/ tree shape is preserved verbatim by the CLI bundling.
63
+ const _THISDIR = dirname(fileURLToPath(import.meta.url));
64
+ const RUNNER = (name) => JSON.stringify(join(_THISDIR, '..', 'terminal', name));
65
+
53
66
  // ---------------------------------------------------------------------------
54
67
  // Constants
55
68
  // ---------------------------------------------------------------------------
@@ -530,13 +543,14 @@ function sessionToPipelineState(session, projectDir) {
530
543
 
531
544
  /**
532
545
  * Build the spawn command string for an autonomous agent.
546
+ * Exported for testing (path resolution must hold in every install layout).
533
547
  */
534
- function buildSpawnCommand(agent, projectDir, previousAgent, provider, timeout) {
548
+ export function buildSpawnCommand(agent, projectDir, previousAgent, provider, timeout) {
535
549
  const parts = [
536
- 'node', 'packages/chati-dev/src/terminal/run-agent.js',
550
+ 'node', RUNNER('run-agent.js'),
537
551
  '--agent', agent,
538
552
  '--task-id', `${agent}-task`,
539
- '--project-dir', resolve(projectDir),
553
+ '--project-dir', JSON.stringify(resolve(projectDir)),
540
554
  '--previous-agent', previousAgent || 'none',
541
555
  '--provider', provider || 'claude',
542
556
  '--timeout', String(timeout || 600000),
@@ -546,14 +560,15 @@ function buildSpawnCommand(agent, projectDir, previousAgent, provider, timeout)
546
560
 
547
561
  /**
548
562
  * Build the parallel spawn command string.
563
+ * Exported for testing.
549
564
  */
550
- function buildParallelSpawnCommand(agents, projectDir, previousAgent, provider, timeout) {
565
+ export function buildParallelSpawnCommand(agents, projectDir, previousAgent, provider, timeout) {
551
566
  const taskIds = agents.map(a => `${a}-task`).join(',');
552
567
  const parts = [
553
- 'node', 'packages/chati-dev/src/terminal/run-parallel.js',
568
+ 'node', RUNNER('run-parallel.js'),
554
569
  '--agents', agents.join(','),
555
570
  '--task-ids', taskIds,
556
- '--project-dir', resolve(projectDir),
571
+ '--project-dir', JSON.stringify(resolve(projectDir)),
557
572
  '--previous-agent', previousAgent || 'none',
558
573
  '--provider', provider || 'claude',
559
574
  '--timeout', String(timeout || 900000),
@@ -931,6 +946,25 @@ async function _handleNextInner(projectDir) {
931
946
  spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider, 600000);
932
947
  }
933
948
 
949
+ // Surface criteria (Article XVII): even in autonomous mode, a high-stakes
950
+ // transition still needs a human nod. Deploy is external and effectively
951
+ // irreversible, so it trips a critical criterion and is never auto-confirmed
952
+ // by the gates alone. Hold the autonomous spawn and hand control back to the
953
+ // user for that step.
954
+ let surfaceHold = null;
955
+ if (action === 'spawn_autonomous' && (agentDef?.phase === 'deploy' || nextAgent === 'devops')) {
956
+ const verdict = evaluateSurfaceCriteria(
957
+ { type: 'deploy', description: `${nextAgent} (${agentDef?.phase || 'deploy'} phase)`, tags: ['external', 'destructive'] },
958
+ {},
959
+ );
960
+ const critical = verdict.triggers.filter((t) => t.severity === 'critical');
961
+ if (critical.length > 0) {
962
+ action = 'activate_interactive';
963
+ spawnCommand = null;
964
+ surfaceHold = { criteria: critical.map((t) => t.id), reason: critical.map((t) => t.reason).join('; ') };
965
+ }
966
+ }
967
+
934
968
  // Record agent activation event
935
969
  recordEvent(projectDir, EventType.AGENT_ACTIVATED, nextAgent, { action, phase: agentDef?.phase });
936
970
 
@@ -952,6 +986,7 @@ async function _handleNextInner(projectDir) {
952
986
  pipeline_progress: progress,
953
987
  session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode, user_level: session.user_level || 'auto' },
954
988
  status_summary: `${lastAgent} completed. Next: ${nextAgent} (${action.replace('_', ' ')}, ${agentDef?.phase || 'unknown'} phase).`,
989
+ ...(surfaceHold ? { surface: surfaceHold } : {}),
955
990
  };
956
991
 
957
992
  // Include team data when spawn_team action is selected (Article XXI)
@@ -989,6 +1024,7 @@ const SCAFFOLD_CONFIDENCE_THRESHOLD = 0.7;
989
1024
  const PRESET_CONFIDENCE_THRESHOLDS = {
990
1025
  'motion-premium': 0.7,
991
1026
  'motion-premium-3d': 0.8,
1027
+ 'saas-dashboard': 0.7,
992
1028
  };
993
1029
 
994
1030
  function thresholdFor(preset) {
@@ -1537,6 +1573,28 @@ async function handleAdvance(projectDir, args) {
1537
1573
  },
1538
1574
  };
1539
1575
  }
1576
+
1577
+ // Spring-motion veto: linear easing on a transition and transition-property:
1578
+ // all are amateur-motion tells measured by visual-qa.js. Block the
1579
+ // autonomous advance on them, the same way layout_errors block, so the code
1580
+ // gate enforces premium motion and not only the QA-Visual agent prose.
1581
+ const motionErrors = (report.summary?.motion_errors || []).filter(e => e.severity === 'error');
1582
+ if (motionErrors.length > 0) {
1583
+ return {
1584
+ advanced: false,
1585
+ blocked: true,
1586
+ reason: 'motion_errors_detected',
1587
+ message: `${motionErrors.length} amateur-motion error(s) measured: linear easing on a transition, or transition-property: all. Premium motion uses spring or cubic-bezier easing on specific compositor properties (transform, opacity).`,
1588
+ motion_error_count: motionErrors.length,
1589
+ motion_errors: motionErrors,
1590
+ fault_origin: 'CODE',
1591
+ action_required: {
1592
+ step1: 'Display the motion errors to the user with the offending selectors',
1593
+ step2: 'Route back to dev for rework (Fault Vector Protocol: CODE -> dev)',
1594
+ step3: 'Dev replaces linear/all easing with a cubic-bezier on transform/opacity, re-runs visual-qa.js, then re-runs advance qa-visual',
1595
+ },
1596
+ };
1597
+ }
1540
1598
  } catch {
1541
1599
  // report.json unparseable — fall through to other checks, they will catch it
1542
1600
  }
@@ -2654,10 +2712,10 @@ async function handleSpawnTeam(projectDir, args) {
2654
2712
 
2655
2713
  // Build spawn command
2656
2714
  const spawnCmd = [
2657
- 'node', join('packages', 'chati-dev', 'src', 'terminal', 'run-team.js'),
2715
+ 'node', RUNNER('run-team.js'),
2658
2716
  '--team-id', teamId,
2659
2717
  '--team-type', teamType,
2660
- '--project-dir', resolve(projectDir),
2718
+ '--project-dir', JSON.stringify(resolve(projectDir)),
2661
2719
  '--previous-agent', previousAgent,
2662
2720
  '--provider', provider,
2663
2721
  '--timeout', args.timeout || '1800000',
@@ -2941,7 +2999,7 @@ async function handleWaitForCapture(projectDir, args) {
2941
2999
  * qa-visual-score: calculate visual QA score deterministically from report.json.
2942
3000
  * Replaces QA-Visual's generative scoring formula.
2943
3001
  */
2944
- function handleQaVisualScore(args) {
3002
+ export function handleQaVisualScore(args) {
2945
3003
  const reportPath = args.report || '/tmp/visual-qa/report.json';
2946
3004
  if (!existsSync(reportPath)) {
2947
3005
  return errorResult(`report.json not found at ${reportPath}`, 'REPORT_NOT_FOUND');
@@ -2955,18 +3013,51 @@ function handleQaVisualScore(args) {
2955
3013
  const s = report.summary || {};
2956
3014
  const hasRefs = args['has-refs'] === 'true';
2957
3015
 
3016
+ // The animation stack the project actually uses. The motion-premium presets
3017
+ // ship GSAP + Lenis; the saas-dashboard app-shell preset ships framer-motion;
3018
+ // some projects use CSS only. The Lenis/GSAP presence checks apply ONLY to the
3019
+ // gsap-lenis stack, so a framer-motion project is not falsely blocked for
3020
+ // missing libraries it deliberately does not use. The agent resolves the stack
3021
+ // from architecture.md and passes --animation-library (default gsap-lenis for
3022
+ // backward compatibility with the motion presets).
3023
+ // Default to gsap-lenis for backward compatibility (the motion presets), but do
3024
+ // NOT assume it when the flag is absent AND the report shows no GSAP or Lenis
3025
+ // usage at all: a clean framer-motion or CSS project would otherwise be
3026
+ // phantom-blocked for libraries it never used. In that case fall back to a
3027
+ // non-GSAP stack (no GSAP/Lenis hard-block) and warn that --animation-library
3028
+ // should be passed explicitly. A project that passes the flag is unaffected.
3029
+ const animLibFlag = args['animation-library'];
3030
+ const animLibInferred = !animLibFlag;
3031
+ let animLib = String(animLibFlag || 'gsap-lenis').toLowerCase();
3032
+ if (animLibInferred && !s.gsap_loaded_all && !(s.total_gsap_animations > 0) && !s.lenis_active_all) {
3033
+ animLib = 'unknown';
3034
+ }
3035
+ const isGsapStack = animLib === 'gsap-lenis' || animLib === 'gsap' || animLib === 'lenis';
3036
+
3037
+ // Measured error signals from the report (objective, not judged).
3038
+ const motionErrorCount = (s.motion_errors || []).filter(e => e.severity === 'error').length;
3039
+ const layoutErrorCount = s.layout_error_count || 0;
3040
+ const reducedMotionViolations = (s.reduced_motion_violations || []).length;
3041
+
2958
3042
  // Weights (adjust when references exist)
2959
3043
  const w = hasRefs
2960
3044
  ? { animation: 0.30, scroll: 0.20, responsive: 0.15, hover: 0.10, brand: 0.10, fidelity: 0.15 }
2961
3045
  : { animation: 0.35, scroll: 0.25, responsive: 0.20, hover: 0.10, brand: 0.10, fidelity: 0 };
2962
3046
 
2963
- // Base scores per dimension (0-100)
2964
- const animScore = Math.min(100, (s.lenis_active_all ? 50 : 0) + (s.gsap_loaded_all ? 30 : 0) + Math.min(20, (s.total_gsap_animations || 0) * 2));
2965
- const scrollScore = (s.total_scroll_triggers || 0) > 0 ? 80 + Math.min(20, s.total_scroll_triggers * 2) : 40;
2966
- const responsiveScore = 80; // baseline actual responsive check is visual (generative)
2967
- const hoverScore = 80; // baseline
3047
+ // Animation score is stack-aware. The gsap-lenis stack scores on Lenis + GSAP
3048
+ // presence and animation count; any other stack scores on the motion discipline
3049
+ // the report can measure: reduced-motion support and the absence of amateur
3050
+ // motion (the spring-motion veto), since premium motion can be built without
3051
+ // GSAP.
3052
+ const animScore = isGsapStack
3053
+ ? Math.min(100, (s.lenis_active_all ? 50 : 0) + (s.gsap_loaded_all ? 30 : 0) + Math.min(20, (s.total_gsap_animations || 0) * 2))
3054
+ : Math.max(0, Math.min(100, 70 + (s.reduced_motion_support ? 20 : 0) + (motionErrorCount === 0 ? 10 : -30)));
3055
+
3056
+ const scrollScore = (s.total_scroll_triggers || 0) > 0 ? 80 + Math.min(20, s.total_scroll_triggers * 2) : (isGsapStack ? 40 : 80);
3057
+ const responsiveScore = layoutErrorCount > 0 ? 40 : 80; // layout errors are measured; the agent judges the rest
3058
+ const hoverScore = 80; // baseline; the agent judges interaction states from screenshots
2968
3059
  const brandScore = (s.reduced_motion_support ? 10 : 0) + Math.min(90, (report.pages?.[0]?.js_checks?.custom_properties_count ?? 0));
2969
- const fidelityScore = 70; // placeholder Visualizer comparison is generative
3060
+ const fidelityScore = 70; // placeholder; the Visualizer comparison is the agent's judged signal
2970
3061
 
2971
3062
  let rawScore = Math.round(
2972
3063
  animScore * w.animation +
@@ -2981,18 +3072,96 @@ function handleQaVisualScore(args) {
2981
3072
  const errors = [];
2982
3073
  const warnings = [];
2983
3074
 
2984
- if (!s.lenis_active_all) errors.push('Lenis not active on all pages');
2985
- if (!s.gsap_loaded_all) errors.push('GSAP not loaded on all pages');
3075
+ if (isGsapStack && !s.lenis_active_all) errors.push('Lenis not active on all pages');
3076
+ if (isGsapStack && !s.gsap_loaded_all) errors.push('GSAP not loaded on all pages');
3077
+ if (motionErrorCount > 0) { errors.push(`${motionErrorCount} amateur-motion error(s): linear easing or transition:all`); rawScore -= 15; }
3078
+ if (layoutErrorCount > 0) { errors.push(`${layoutErrorCount} objective layout error(s)`); rawScore -= 15; }
3079
+ if (reducedMotionViolations > 0) { errors.push(`${reducedMotionViolations} reduced-motion violation(s)`); rawScore -= 10; }
2986
3080
  if (s.em_dashes_detected) { errors.push('Em-dashes found in visible text'); rawScore -= 10; }
2987
3081
  if (s.console_errors_total > 0) { warnings.push(`${s.console_errors_total} console error(s)`); rawScore -= 5; }
3082
+ if (animLibInferred) warnings.push('No --animation-library was passed; resolve the motion stack from architecture.md and pass it (gsap-lenis | framer-motion | css | none) for an accurate verdict.');
3083
+
3084
+ // Hard blocks: measured failures that cannot be APPROVED regardless of the
3085
+ // agent's judged 8-dimension rubric score. The agent applies visual_rubric on
3086
+ // top of this measured floor and SHALL NOT issue APPROVED when hardBlocked.
3087
+ const hardBlocked =
3088
+ (isGsapStack && (!s.lenis_active_all || !s.gsap_loaded_all)) ||
3089
+ motionErrorCount > 0 ||
3090
+ layoutErrorCount > 0 ||
3091
+ reducedMotionViolations > 0 ||
3092
+ !!s.em_dashes_detected;
2988
3093
 
2989
- // Hard blocks
2990
- const hardBlocked = !s.lenis_active_all || !s.gsap_loaded_all || s.em_dashes_detected;
2991
3094
  const score = Math.max(0, Math.min(100, rawScore));
2992
3095
  const threshold = 90;
2993
- const verdict = hardBlocked ? 'BLOCKED' : score >= threshold ? 'APPROVED' : 'NEEDS_CORRECTION';
3096
+ // FLOOR_COMPUTED (not NEEDS_CORRECTION) is the label for a clean-but-below-floor
3097
+ // measured score: it signals that this is the CLI's measured floor, NOT the gate
3098
+ // verdict. The gate verdict is the QA-Visual agent's 8-dimension rubric score. A
3099
+ // clean framer-motion build can sit below the measured floor yet still be APPROVED
3100
+ // by the agent, so the CLI must not emit a correction-sounding label here.
3101
+ const verdict = hardBlocked ? 'BLOCKED' : score >= threshold ? 'APPROVED' : 'FLOOR_COMPUTED';
3102
+
3103
+ return {
3104
+ score, threshold, verdict, hardBlocked, animation_library: animLib, errors, warnings, weights: w,
3105
+ measured_signals: {
3106
+ motion_error_count: motionErrorCount,
3107
+ layout_error_count: layoutErrorCount,
3108
+ reduced_motion_violations: reducedMotionViolations,
3109
+ em_dashes: !!s.em_dashes_detected,
3110
+ },
3111
+ dimensions: { animScore, scrollScore, responsiveScore, hoverScore, brandScore, fidelityScore },
3112
+ note: 'Measured objective floor plus hard-blocks. BLOCKED is binding (a measured failure). APPROVED and FLOOR_COMPUTED are NOT the gate verdict: the QA-Visual agent applies the 8-dimension visual_rubric (chati.dev/data/qa-rubrics.yaml) for the judged dimensions and issues the verdict. FLOOR_COMPUTED means the measured floor is below the threshold but nothing is hard-blocked, so the agent rubric score decides. The agent cannot issue APPROVED when hardBlocked is true.',
3113
+ };
3114
+ }
3115
+
3116
+ /**
3117
+ * run-script-task: execute a task whose execution_kind is `script` through its
3118
+ * deterministic executor (no LLM). Gated by the execution_kinds feature flag;
3119
+ * when the flag is off it returns ran:false so the orchestrator uses the agent
3120
+ * path and the pipeline is unchanged. The orchestrator resolves the task fields
3121
+ * from tasks.yaml and passes them as flags (--ref, --id, --title).
3122
+ */
3123
+ export async function handleRunScriptTask(projectDir, args) {
3124
+ if (!isFeatureEnabled(projectDir, 'execution_kinds')) {
3125
+ return { ran: false, reason: 'The execution_kinds feature is off. Run this task through the agent path.', fallback: 'agent' };
3126
+ }
3127
+
3128
+ const kind = String(args.kind || 'script');
3129
+ if (kind !== 'script') {
3130
+ return { ran: false, reason: `execution_kind "${kind}" is not auto-run by the script runner (only "script" is). Route through the normal path.`, kind };
3131
+ }
2994
3132
 
2995
- return { score, threshold, verdict, hardBlocked, errors, warnings, weights: w, dimensions: { animScore, scrollScore, responsiveScore, hoverScore, brandScore, fidelityScore } };
3133
+ const ref = args.ref || '';
3134
+ if (!ref) {
3135
+ return { ran: false, reason: 'Missing --ref (the execution_ref, for example "npm-script:test").' };
3136
+ }
3137
+
3138
+ const id = args.id || args.task || '';
3139
+ const title = args.title || '';
3140
+ const result = await runScriptTask(projectDir, { id, title, execution_ref: ref });
3141
+
3142
+ // Write a synthetic handoff so the pipeline reads it like an agent handoff.
3143
+ const timestamp = new Date().toISOString();
3144
+ const handoff = formatScriptHandoff({ id, title }, result, timestamp);
3145
+ let handoffPath = null;
3146
+ try {
3147
+ const dir = join(projectDir, resolveFrameworkDir(projectDir), 'artifacts', 'handoffs');
3148
+ mkdirSync(dir, { recursive: true });
3149
+ handoffPath = join(dir, `script-${(id || 'task').replace(/[^A-Za-z0-9._-]/g, '_')}.md`);
3150
+ writeFileSync(handoffPath, handoff, 'utf-8');
3151
+ } catch {
3152
+ // best-effort; the structured result is still returned for the orchestrator
3153
+ }
3154
+
3155
+ return {
3156
+ ran: true,
3157
+ executor: result.executor,
3158
+ arg: result.arg,
3159
+ status: result.status,
3160
+ summary: result.summary,
3161
+ durationMs: result.durationMs,
3162
+ handoffPath,
3163
+ details: result.details,
3164
+ };
2996
3165
  }
2997
3166
 
2998
3167
  /**
@@ -3213,6 +3382,9 @@ export async function runOrchestrate(subCommand, argv, projectDir) {
3213
3382
  case 'assess-coverage':
3214
3383
  output(handleAssessCoverage(args));
3215
3384
  break;
3385
+ case 'run-script-task':
3386
+ output(await handleRunScriptTask(projectDir, args));
3387
+ break;
3216
3388
  default:
3217
3389
  output(errorResult(`Unknown sub-command: ${subCommand}. Valid: next, advance, init, validate-handoff, status, deviation, exit, providers, detect-flow, backlog, qa-plan-score, qa-impl-score, scan, spawn-team, team-status, team-dissolve`, 'UNKNOWN_COMMAND'));
3218
3390
  break;
@@ -105,7 +105,9 @@ export function detectDeviation(userMessage, _pipelineState) {
105
105
  * @returns {{ impact: 'low'|'medium'|'high', affectedAgents: string[], recommendation: string, requiresConfirmation: boolean }}
106
106
  */
107
107
  export function analyzeDeviationImpact(deviationType, pipelineState, details = {}) {
108
- const currentAgent = pipelineState.current_agent;
108
+ // sessionToPipelineState produces camelCase (currentAgent); accept the legacy
109
+ // snake_case shape too so direct callers keep working.
110
+ const currentAgent = pipelineState.currentAgent ?? pipelineState.current_agent;
109
111
  const completedAgents = Object.entries(pipelineState.agents || {})
110
112
  .filter(([, data]) => data.status === 'completed')
111
113
  .map(([name]) => name);
@@ -268,7 +270,7 @@ export function applyDeviation(pipelineState, deviationType, details) {
268
270
  changes.push(`Reset agent: ${agentName}`);
269
271
  }
270
272
 
271
- newState.current_agent = targetAgent;
273
+ newState.currentAgent = targetAgent;
272
274
  changes.push(`Rolled back to: ${targetAgent}`);
273
275
 
274
276
  break;
@@ -302,7 +304,7 @@ export function applyDeviation(pipelineState, deviationType, details) {
302
304
  }
303
305
 
304
306
  // Reset to initial state
305
- newState.current_agent = '';
307
+ newState.currentAgent = '';
306
308
  newState.backlog = [];
307
309
  changes.push('Reset all agents to initial state');
308
310
 
@@ -1,21 +1,22 @@
1
1
  /**
2
- * @fileoverview /chati doctor — silent boot-time self-diagnostic.
2
+ * @fileoverview /chati doctor — silent self-diagnostic.
3
3
  *
4
- * Runs a battery of fast (< 200ms) filesystem + version checks every time
5
- * /chati boots. Pass-silent, fail-loud: the intent is that the user never
6
- * sees doctor output when everything is fine; they see a specific,
7
- * actionable message ONLY when something is off.
4
+ * Runs a battery of fast (< 200ms) filesystem + version checks when invoked
5
+ * via the /chati doctor subcommand. Pass-silent, fail-loud: the intent is that
6
+ * the user never sees doctor output when everything is fine; they see a
7
+ * specific, actionable message ONLY when something is off.
8
8
  *
9
- * Six checks, two severities:
9
+ * Seven checks, two severities:
10
10
  *
11
- * id severity description
12
- * --------- -------- -----------
13
- * license BLOCK ~/.chati-dev/license.yaml status != VALID (fail-open if missing)
14
- * scaffold BLOCK projectDir/.chati.dev/ directory exists
15
- * session BLOCK .chati/session.yaml parses cleanly (or is absent)
16
- * hooks WARN .claude/settings.json has expected hook registrations
17
- * model WARN session.active_model is populated (hook fills on first fire)
18
- * node BLOCK process version >= 22
11
+ * id severity description
12
+ * ------------ -------- -----------
13
+ * license BLOCK ~/.chati-dev/license.yaml status != VALID (fail-open if missing)
14
+ * scaffold BLOCK projectDir/.chati.dev/ directory exists
15
+ * session BLOCK .chati/session.yaml parses cleanly (or is absent)
16
+ * hooks WARN .claude/settings.json has expected hook registrations
17
+ * model WARN session.active_model is populated (hook fills on first fire)
18
+ * node BLOCK process version >= 22
19
+ * constitution WARN articleCount + i18n match the real "## Article" count
19
20
  *
20
21
  * BLOCK failures short-circuit boot with a fix instruction. WARN failures
21
22
  * surface as non-blocking notices.
@@ -25,7 +26,7 @@
25
26
  * verdict.
26
27
  */
27
28
 
28
- import { existsSync, readFileSync } from 'fs';
29
+ import { existsSync, readFileSync, readdirSync } from 'fs';
29
30
  import { join } from 'path';
30
31
  import { readLicenseStatus } from '../license/wait.js';
31
32
 
@@ -242,8 +243,87 @@ export function checkNode({ versionString = process.version } = {}) {
242
243
  };
243
244
  }
244
245
 
246
+ /**
247
+ * Constitution metadata drift. The articleCount in domains/constitution.yaml
248
+ * and the per-locale i18n strings must match the real number of "## Article"
249
+ * headings in constitution.md. Drift here is metadata-only (runtime is
250
+ * unaffected), so it is a WARN. Catching it keeps the registry, the localized
251
+ * UI and the document in sync.
252
+ * @param {{ projectDir: string }} opts
253
+ * @returns {CheckResult}
254
+ */
255
+ export function checkConstitution({ projectDir }) {
256
+ const fwDir = ['.chati.dev', 'chati.dev']
257
+ .map(d => join(projectDir, d))
258
+ .find(d => existsSync(join(d, 'constitution.md')));
259
+ if (!fwDir) {
260
+ return { id: 'constitution', severity: SEVERITY.WARN, status: 'pass', message: 'constitution.md not found — skipping drift check.' };
261
+ }
262
+ try {
263
+ const md = readFileSync(join(fwDir, 'constitution.md'), 'utf-8');
264
+ const real = (md.match(/^#{2,3} Article /gm) || []).length;
265
+ const mismatches = [];
266
+
267
+ const domainPath = join(fwDir, 'domains', 'constitution.yaml');
268
+ if (existsSync(domainPath)) {
269
+ const m = readFileSync(domainPath, 'utf-8').match(/^articleCount:\s*(\d+)/m);
270
+ if (m && parseInt(m[1], 10) !== real) mismatches.push(`domains/constitution.yaml=${m[1]}`);
271
+ }
272
+
273
+ for (const locale of ['en', 'es', 'fr', 'pt']) {
274
+ const p = join(fwDir, 'i18n', `${locale}.yaml`);
275
+ if (!existsSync(p)) continue;
276
+ const m = readFileSync(p, 'utf-8').match(/constitution:\s*"[^"]*?(\d{1,3})\s*(?:Article|Articulo|Artigo)/i);
277
+ if (m && parseInt(m[1], 10) !== real) mismatches.push(`i18n/${locale}.yaml=${m[1]}`);
278
+ }
279
+
280
+ if (mismatches.length === 0) {
281
+ return { id: 'constitution', severity: SEVERITY.WARN, status: 'pass', message: `Constitution metadata in sync (${real} Articles).` };
282
+ }
283
+ return {
284
+ id: 'constitution',
285
+ severity: SEVERITY.WARN,
286
+ status: 'fail',
287
+ message: `Constitution article-count drift (real=${real}): ${mismatches.join('; ')}`,
288
+ fix: `Set those values to ${real} to match constitution.md.`,
289
+ };
290
+ } catch (err) {
291
+ return { id: 'constitution', severity: SEVERITY.WARN, status: 'pass', message: `Cannot verify constitution metadata: ${err.message}` };
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Surface durable pipeline-advance failure markers. advance-trigger.js drops a
297
+ * JSON marker in .chati/failures/ when it cannot spawn the router (a silent
298
+ * pipeline stall). Re-surface them here so the stall is visible on the next
299
+ * doctor run instead of only living as an unread file.
300
+ * @param {{ projectDir: string }} opts
301
+ * @returns {CheckResult}
302
+ */
303
+ export function checkFailures({ projectDir }) {
304
+ const failuresDir = join(projectDir, '.chati', 'failures');
305
+ if (!existsSync(failuresDir)) {
306
+ return { id: 'failures', severity: SEVERITY.WARN, status: 'pass', message: 'No pipeline-advance failures recorded.' };
307
+ }
308
+ try {
309
+ const markers = readdirSync(failuresDir).filter(f => f.endsWith('.json'));
310
+ if (markers.length === 0) {
311
+ return { id: 'failures', severity: SEVERITY.WARN, status: 'pass', message: 'No pipeline-advance failures recorded.' };
312
+ }
313
+ return {
314
+ id: 'failures',
315
+ severity: SEVERITY.WARN,
316
+ status: 'fail',
317
+ message: `${markers.length} pipeline-advance failure marker(s) in .chati/failures/ — the pipeline may have stalled silently.`,
318
+ fix: 'Inspect .chati/failures/*.json, resolve the cause (often node not on PATH for the hook process), then delete the markers.',
319
+ };
320
+ } catch (err) {
321
+ return { id: 'failures', severity: SEVERITY.WARN, status: 'pass', message: `Cannot read .chati/failures/: ${err.message}` };
322
+ }
323
+ }
324
+
245
325
  // ---------------------------------------------------------------------------
246
- // runDoctor — aggregate all six checks.
326
+ // runDoctor — aggregate all checks.
247
327
  // ---------------------------------------------------------------------------
248
328
 
249
329
  /**
@@ -263,6 +343,8 @@ export function runDoctor(opts) {
263
343
  checkHooks({ projectDir: opts.projectDir }),
264
344
  checkModel({ projectDir: opts.projectDir }),
265
345
  checkNode(),
346
+ checkConstitution({ projectDir: opts.projectDir }),
347
+ checkFailures({ projectDir: opts.projectDir }),
266
348
  ];
267
349
  const blockers = checks.filter(c => c.status === 'fail' && c.severity === SEVERITY.BLOCK);
268
350
  const warnings = checks.filter(c => c.status === 'fail' && c.severity === SEVERITY.WARN);