chati-dev 4.3.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/README.md +18 -13
  2. package/bin/chati.js +32 -4
  3. package/framework/agents/build/dev.md +9 -5
  4. package/framework/agents/discover/brief.md +2 -0
  5. package/framework/agents/discover/brownfield-wu.md +2 -0
  6. package/framework/agents/discover/greenfield-wu.md +18 -1
  7. package/framework/agents/plan/detail.md +2 -0
  8. package/framework/agents/plan/tasks.md +31 -0
  9. package/framework/agents/plan/ux-brand-architect.md +21 -2
  10. package/framework/agents/plan/ux-component-engineer.md +10 -0
  11. package/framework/agents/quality/qa-implementation.md +1 -1
  12. package/framework/agents/quality/qa-planning.md +1 -1
  13. package/framework/agents/quality/qa-visual.md +33 -21
  14. package/framework/config.yaml +5 -4
  15. package/framework/constitution.md +58 -11
  16. package/framework/context/governance.md +12 -10
  17. package/framework/context/root.md +2 -2
  18. package/framework/data/entity-registry.yaml +12 -4
  19. package/framework/data/qa-rubrics.yaml +335 -0
  20. package/framework/domains/constitution.yaml +3 -3
  21. package/framework/executors/json-validate.js +80 -0
  22. package/framework/executors/npm-script.js +72 -0
  23. package/framework/executors/path-check.js +67 -0
  24. package/framework/executors/registry.yaml +36 -0
  25. package/framework/hooks/advance-trigger.js +47 -3
  26. package/framework/hooks/constitution-guard.js +9 -4
  27. package/framework/hooks/git-push-authority.js +113 -0
  28. package/framework/hooks/license-guard.js +59 -9
  29. package/framework/hooks/model-governance.js +40 -17
  30. package/framework/hooks/prism-engine.js +78 -11
  31. package/framework/hooks/session-digest.js +12 -3
  32. package/framework/hooks/settings.json +7 -3
  33. package/framework/hooks/style-guard.js +52 -3
  34. package/framework/hooks/team-quality-gate.js +43 -5
  35. package/framework/i18n/en.yaml +3 -3
  36. package/framework/i18n/es.yaml +3 -3
  37. package/framework/i18n/fr.yaml +3 -3
  38. package/framework/i18n/pt.yaml +3 -3
  39. package/framework/intelligence/confidence.yaml +85 -6
  40. package/framework/intelligence/context-engine.md +9 -5
  41. package/framework/intelligence/decision-engine.md +1 -1
  42. package/framework/orchestrator/chati-router.js +59 -5
  43. package/framework/orchestrator/chati-update.md +19 -3
  44. package/framework/orchestrator/chati.md +20 -1
  45. package/framework/quality-gates/planning-gate.md +2 -0
  46. package/framework/rules/human-writing-style.md +47 -0
  47. package/framework/scaffold/motion-premium/README.md +12 -2
  48. package/framework/scaffold/motion-premium/lib/animations/tokens.ts.template +1 -1
  49. package/framework/scaffold/motion-premium/scaffold.yaml +6 -1
  50. package/framework/scaffold/motion-premium-3d/README.md +10 -0
  51. package/framework/scaffold/motion-premium-3d/components/webgl/WebGLContext.tsx.template +1 -1
  52. package/framework/scaffold/motion-premium-3d/scaffold.yaml +3 -0
  53. package/framework/scaffold/saas-dashboard/README.md +58 -0
  54. package/framework/scaffold/saas-dashboard/app/dashboard/page.tsx.template +546 -0
  55. package/framework/scaffold/saas-dashboard/app/globals.css.template +746 -0
  56. package/framework/scaffold/saas-dashboard/app/layout.tsx.template +192 -0
  57. package/framework/scaffold/saas-dashboard/app/shell.css.template +1070 -0
  58. package/framework/scaffold/saas-dashboard/components/data/Chart.tsx.template +725 -0
  59. package/framework/scaffold/saas-dashboard/components/data/DataTable.tsx.template +623 -0
  60. package/framework/scaffold/saas-dashboard/components/data/DataTable.types.ts.template +104 -0
  61. package/framework/scaffold/saas-dashboard/components/data/DataTablePagination.tsx.template +234 -0
  62. package/framework/scaffold/saas-dashboard/components/data/SkeletonBlock.tsx.template +111 -0
  63. package/framework/scaffold/saas-dashboard/components/data/StatCard.tsx.template +393 -0
  64. package/framework/scaffold/saas-dashboard/components/data/StatCardGrid.tsx.template +121 -0
  65. package/framework/scaffold/saas-dashboard/components/feedback/EmptyState.tsx.template +281 -0
  66. package/framework/scaffold/saas-dashboard/components/interaction/CommandPalette.tsx.template +767 -0
  67. package/framework/scaffold/saas-dashboard/components/interaction/Field.tsx.template +351 -0
  68. package/framework/scaffold/saas-dashboard/components/interaction/Form.tsx.template +424 -0
  69. package/framework/scaffold/saas-dashboard/components/primitives/Badge.tsx.template +193 -0
  70. package/framework/scaffold/saas-dashboard/components/primitives/Button.tsx.template +261 -0
  71. package/framework/scaffold/saas-dashboard/components/primitives/Card.tsx.template +287 -0
  72. package/framework/scaffold/saas-dashboard/components/primitives/Input.tsx.template +310 -0
  73. package/framework/scaffold/saas-dashboard/components/primitives/Spinner.tsx.template +135 -0
  74. package/framework/scaffold/saas-dashboard/components/primitives/index.ts.template +60 -0
  75. package/framework/scaffold/saas-dashboard/components/shell/AppShell.tsx.template +174 -0
  76. package/framework/scaffold/saas-dashboard/components/shell/Breadcrumb.tsx.template +186 -0
  77. package/framework/scaffold/saas-dashboard/components/shell/Container.tsx.template +86 -0
  78. package/framework/scaffold/saas-dashboard/components/shell/NavItem.tsx.template +147 -0
  79. package/framework/scaffold/saas-dashboard/components/shell/Sidebar.tsx.template +378 -0
  80. package/framework/scaffold/saas-dashboard/components/shell/SidebarContext.tsx.template +169 -0
  81. package/framework/scaffold/saas-dashboard/components/shell/ThemeToggle.tsx.template +128 -0
  82. package/framework/scaffold/saas-dashboard/components/shell/Topbar.tsx.template +301 -0
  83. package/framework/scaffold/saas-dashboard/lib/brand.ts.template +324 -0
  84. package/framework/scaffold/saas-dashboard/scaffold.yaml +225 -0
  85. package/framework/schemas/session.schema.json +5 -0
  86. package/framework/schemas/task.schema.json +9 -0
  87. package/framework/scripts/visual-qa.js +101 -3
  88. package/framework/tasks/orchestrator-health.md +4 -4
  89. package/framework/tasks/qa-impl-verdict.md +11 -5
  90. package/framework/templates/brandbook-html-tmpl.md +1 -1
  91. package/framework/templates/qa-gate-tmpl.yaml +24 -11
  92. package/package.json +4 -3
  93. package/src/config/context-file-generator.js +0 -6
  94. package/src/dashboard/renderer.js +0 -36
  95. package/src/executors/runner.js +204 -0
  96. package/src/installer/core.js +13 -3
  97. package/src/installer/templates.js +5 -6
  98. package/src/intelligence/registry-manager.js +1 -1
  99. package/src/license/client.js +9 -3
  100. package/src/license/commands.js +12 -2
  101. package/src/license/machine-id.js +42 -1
  102. package/src/memory/gotchas.js +58 -40
  103. package/src/memory/magic-docs.js +1 -1
  104. package/src/memory/session-digest.js +9 -4
  105. package/src/orchestrator/cli.js +186 -15
  106. package/src/orchestrator/doctor.js +98 -16
  107. package/src/orchestrator/pipeline-manager.js +44 -22
  108. package/src/orchestrator/session-manager.js +64 -8
  109. package/src/telemetry/sender.js +7 -19
  110. package/src/terminal/run-team.js +3 -3
  111. package/src/upgrade/tracked-files-detector.js +34 -16
  112. package/src/utils/feature-flags.js +1 -1
  113. package/src/utils/flatten-entities.js +4 -40
  114. package/src/utils/schema-validator.js +0 -14
  115. package/src/wizard/i18n.js +3 -3
  116. package/src/api/index.js +0 -120
  117. package/src/autonomy/autonomous-gate.js +0 -294
  118. package/src/autonomy/build-loop.js +0 -281
  119. package/src/autonomy/build-state.js +0 -286
  120. package/src/autonomy/cause-analyzer.js +0 -177
  121. package/src/autonomy/escalation.js +0 -214
  122. package/src/autonomy/index.js +0 -51
  123. package/src/autonomy/mode-manager.js +0 -225
  124. package/src/autonomy/mode-suggester.js +0 -283
  125. package/src/autonomy/progress-reporter.js +0 -275
  126. package/src/autonomy/safety-net.js +0 -370
  127. package/src/config/agent-customizer.js +0 -231
  128. package/src/decision/analyzer.js +0 -291
  129. package/src/decision/engine.js +0 -250
  130. package/src/decision/index.js +0 -38
  131. package/src/decision/registry-healer.js +0 -468
  132. package/src/decision/registry-updater.js +0 -339
  133. package/src/extensions/loader.js +0 -145
  134. package/src/extensions/registry.js +0 -134
  135. package/src/gates/circuit-breaker.js +0 -151
  136. package/src/gates/g1-planning-complete.js +0 -154
  137. package/src/gates/g2-qa-planning.js +0 -156
  138. package/src/gates/g3-implementation.js +0 -215
  139. package/src/gates/g4-qa-implementation.js +0 -240
  140. package/src/gates/g5-deploy-ready.js +0 -181
  141. package/src/gates/gate-base.js +0 -185
  142. package/src/gates/index.js +0 -46
  143. package/src/health/auto-fix.js +0 -216
  144. package/src/health/engine.js +0 -246
  145. package/src/merger/semantic-merger.js +0 -292
  146. package/src/preview/detector.js +0 -238
  147. package/src/preview/index.js +0 -20
  148. package/src/preview/launcher.js +0 -235
  149. package/src/preview/log-buffer.js +0 -103
  150. package/src/quality/metrics-collector.js +0 -281
  151. package/src/quality/test-runner.js +0 -366
  152. package/src/tasks/executor.js +0 -195
  153. package/src/tasks/index.js +0 -4
  154. package/src/tasks/loader.js +0 -210
  155. package/src/tasks/router.js +0 -182
  156. package/src/utils/event-bus.js +0 -126
@@ -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,7 +41,7 @@ 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';
44
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
42
45
  import { join, resolve } from 'path';
43
46
  import { execSync } from 'child_process';
44
47
  import yaml from 'js-yaml';
@@ -634,6 +637,17 @@ function writeSessionLock(projectDir, currentAgent, stateInfo = {}) {
634
637
  const localMdPath = join(projectDir, 'CLAUDE.local.md');
635
638
  let content = existsSync(localMdPath) ? readFileSync(localMdPath, 'utf-8') : '';
636
639
 
640
+ // Phase display is authoritatively driven by session.mode on disk. Callers
641
+ // previously passed stateInfo.phase derived from transient intermediate
642
+ // values (the next agent's expected phase before user approval), causing
643
+ // the lock to drift ahead of session.yaml (e.g. show "build" while
644
+ // session.mode was still "plan" awaiting approval).
645
+ let authoritativePhase = null;
646
+ try {
647
+ const { loaded, session } = loadSession(projectDir);
648
+ if (loaded && session?.mode) authoritativePhase = session.mode;
649
+ } catch { /* non-fatal — fall back to stateInfo */ }
650
+
637
651
  const lockInner = `## Session Lock -- ACTIVE
638
652
 
639
653
  **Chati.dev session is ACTIVE.** Follow these rules for EVERY message:
@@ -645,7 +659,7 @@ function writeSessionLock(projectDir, currentAgent, stateInfo = {}) {
645
659
 
646
660
  const stateInner = `## Current State
647
661
  - **Agent**: ${currentAgent || 'None'}
648
- - **Phase**: ${stateInfo.phase || 'discover'}
662
+ - **Phase**: ${authoritativePhase || stateInfo.phase || 'discover'}
649
663
  - **Pipeline**: ${stateInfo.position ?? 0}/${stateInfo.total ?? '?'} (${stateInfo.progress ?? 0}%)
650
664
  - **Mode**: ${stateInfo.mode || 'interactive'}`;
651
665
 
@@ -920,6 +934,25 @@ async function _handleNextInner(projectDir) {
920
934
  spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider, 600000);
921
935
  }
922
936
 
937
+ // Surface criteria (Article XVII): even in autonomous mode, a high-stakes
938
+ // transition still needs a human nod. Deploy is external and effectively
939
+ // irreversible, so it trips a critical criterion and is never auto-confirmed
940
+ // by the gates alone. Hold the autonomous spawn and hand control back to the
941
+ // user for that step.
942
+ let surfaceHold = null;
943
+ if (action === 'spawn_autonomous' && (agentDef?.phase === 'deploy' || nextAgent === 'devops')) {
944
+ const verdict = evaluateSurfaceCriteria(
945
+ { type: 'deploy', description: `${nextAgent} (${agentDef?.phase || 'deploy'} phase)`, tags: ['external', 'destructive'] },
946
+ {},
947
+ );
948
+ const critical = verdict.triggers.filter((t) => t.severity === 'critical');
949
+ if (critical.length > 0) {
950
+ action = 'activate_interactive';
951
+ spawnCommand = null;
952
+ surfaceHold = { criteria: critical.map((t) => t.id), reason: critical.map((t) => t.reason).join('; ') };
953
+ }
954
+ }
955
+
923
956
  // Record agent activation event
924
957
  recordEvent(projectDir, EventType.AGENT_ACTIVATED, nextAgent, { action, phase: agentDef?.phase });
925
958
 
@@ -941,6 +974,7 @@ async function _handleNextInner(projectDir) {
941
974
  pipeline_progress: progress,
942
975
  session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode, user_level: session.user_level || 'auto' },
943
976
  status_summary: `${lastAgent} completed. Next: ${nextAgent} (${action.replace('_', ' ')}, ${agentDef?.phase || 'unknown'} phase).`,
977
+ ...(surfaceHold ? { surface: surfaceHold } : {}),
944
978
  };
945
979
 
946
980
  // Include team data when spawn_team action is selected (Article XXI)
@@ -978,6 +1012,7 @@ const SCAFFOLD_CONFIDENCE_THRESHOLD = 0.7;
978
1012
  const PRESET_CONFIDENCE_THRESHOLDS = {
979
1013
  'motion-premium': 0.7,
980
1014
  'motion-premium-3d': 0.8,
1015
+ 'saas-dashboard': 0.7,
981
1016
  };
982
1017
 
983
1018
  function thresholdFor(preset) {
@@ -1526,6 +1561,28 @@ async function handleAdvance(projectDir, args) {
1526
1561
  },
1527
1562
  };
1528
1563
  }
1564
+
1565
+ // Spring-motion veto: linear easing on a transition and transition-property:
1566
+ // all are amateur-motion tells measured by visual-qa.js. Block the
1567
+ // autonomous advance on them, the same way layout_errors block, so the code
1568
+ // gate enforces premium motion and not only the QA-Visual agent prose.
1569
+ const motionErrors = (report.summary?.motion_errors || []).filter(e => e.severity === 'error');
1570
+ if (motionErrors.length > 0) {
1571
+ return {
1572
+ advanced: false,
1573
+ blocked: true,
1574
+ reason: 'motion_errors_detected',
1575
+ 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).`,
1576
+ motion_error_count: motionErrors.length,
1577
+ motion_errors: motionErrors,
1578
+ fault_origin: 'CODE',
1579
+ action_required: {
1580
+ step1: 'Display the motion errors to the user with the offending selectors',
1581
+ step2: 'Route back to dev for rework (Fault Vector Protocol: CODE -> dev)',
1582
+ step3: 'Dev replaces linear/all easing with a cubic-bezier on transform/opacity, re-runs visual-qa.js, then re-runs advance qa-visual',
1583
+ },
1584
+ };
1585
+ }
1529
1586
  } catch {
1530
1587
  // report.json unparseable — fall through to other checks, they will catch it
1531
1588
  }
@@ -2930,7 +2987,7 @@ async function handleWaitForCapture(projectDir, args) {
2930
2987
  * qa-visual-score: calculate visual QA score deterministically from report.json.
2931
2988
  * Replaces QA-Visual's generative scoring formula.
2932
2989
  */
2933
- function handleQaVisualScore(args) {
2990
+ export function handleQaVisualScore(args) {
2934
2991
  const reportPath = args.report || '/tmp/visual-qa/report.json';
2935
2992
  if (!existsSync(reportPath)) {
2936
2993
  return errorResult(`report.json not found at ${reportPath}`, 'REPORT_NOT_FOUND');
@@ -2944,18 +3001,51 @@ function handleQaVisualScore(args) {
2944
3001
  const s = report.summary || {};
2945
3002
  const hasRefs = args['has-refs'] === 'true';
2946
3003
 
3004
+ // The animation stack the project actually uses. The motion-premium presets
3005
+ // ship GSAP + Lenis; the saas-dashboard app-shell preset ships framer-motion;
3006
+ // some projects use CSS only. The Lenis/GSAP presence checks apply ONLY to the
3007
+ // gsap-lenis stack, so a framer-motion project is not falsely blocked for
3008
+ // missing libraries it deliberately does not use. The agent resolves the stack
3009
+ // from architecture.md and passes --animation-library (default gsap-lenis for
3010
+ // backward compatibility with the motion presets).
3011
+ // Default to gsap-lenis for backward compatibility (the motion presets), but do
3012
+ // NOT assume it when the flag is absent AND the report shows no GSAP or Lenis
3013
+ // usage at all: a clean framer-motion or CSS project would otherwise be
3014
+ // phantom-blocked for libraries it never used. In that case fall back to a
3015
+ // non-GSAP stack (no GSAP/Lenis hard-block) and warn that --animation-library
3016
+ // should be passed explicitly. A project that passes the flag is unaffected.
3017
+ const animLibFlag = args['animation-library'];
3018
+ const animLibInferred = !animLibFlag;
3019
+ let animLib = String(animLibFlag || 'gsap-lenis').toLowerCase();
3020
+ if (animLibInferred && !s.gsap_loaded_all && !(s.total_gsap_animations > 0) && !s.lenis_active_all) {
3021
+ animLib = 'unknown';
3022
+ }
3023
+ const isGsapStack = animLib === 'gsap-lenis' || animLib === 'gsap' || animLib === 'lenis';
3024
+
3025
+ // Measured error signals from the report (objective, not judged).
3026
+ const motionErrorCount = (s.motion_errors || []).filter(e => e.severity === 'error').length;
3027
+ const layoutErrorCount = s.layout_error_count || 0;
3028
+ const reducedMotionViolations = (s.reduced_motion_violations || []).length;
3029
+
2947
3030
  // Weights (adjust when references exist)
2948
3031
  const w = hasRefs
2949
3032
  ? { animation: 0.30, scroll: 0.20, responsive: 0.15, hover: 0.10, brand: 0.10, fidelity: 0.15 }
2950
3033
  : { animation: 0.35, scroll: 0.25, responsive: 0.20, hover: 0.10, brand: 0.10, fidelity: 0 };
2951
3034
 
2952
- // Base scores per dimension (0-100)
2953
- 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));
2954
- const scrollScore = (s.total_scroll_triggers || 0) > 0 ? 80 + Math.min(20, s.total_scroll_triggers * 2) : 40;
2955
- const responsiveScore = 80; // baseline actual responsive check is visual (generative)
2956
- const hoverScore = 80; // baseline
3035
+ // Animation score is stack-aware. The gsap-lenis stack scores on Lenis + GSAP
3036
+ // presence and animation count; any other stack scores on the motion discipline
3037
+ // the report can measure: reduced-motion support and the absence of amateur
3038
+ // motion (the spring-motion veto), since premium motion can be built without
3039
+ // GSAP.
3040
+ const animScore = isGsapStack
3041
+ ? Math.min(100, (s.lenis_active_all ? 50 : 0) + (s.gsap_loaded_all ? 30 : 0) + Math.min(20, (s.total_gsap_animations || 0) * 2))
3042
+ : Math.max(0, Math.min(100, 70 + (s.reduced_motion_support ? 20 : 0) + (motionErrorCount === 0 ? 10 : -30)));
3043
+
3044
+ const scrollScore = (s.total_scroll_triggers || 0) > 0 ? 80 + Math.min(20, s.total_scroll_triggers * 2) : (isGsapStack ? 40 : 80);
3045
+ const responsiveScore = layoutErrorCount > 0 ? 40 : 80; // layout errors are measured; the agent judges the rest
3046
+ const hoverScore = 80; // baseline; the agent judges interaction states from screenshots
2957
3047
  const brandScore = (s.reduced_motion_support ? 10 : 0) + Math.min(90, (report.pages?.[0]?.js_checks?.custom_properties_count ?? 0));
2958
- const fidelityScore = 70; // placeholder Visualizer comparison is generative
3048
+ const fidelityScore = 70; // placeholder; the Visualizer comparison is the agent's judged signal
2959
3049
 
2960
3050
  let rawScore = Math.round(
2961
3051
  animScore * w.animation +
@@ -2970,18 +3060,96 @@ function handleQaVisualScore(args) {
2970
3060
  const errors = [];
2971
3061
  const warnings = [];
2972
3062
 
2973
- if (!s.lenis_active_all) errors.push('Lenis not active on all pages');
2974
- if (!s.gsap_loaded_all) errors.push('GSAP not loaded on all pages');
3063
+ if (isGsapStack && !s.lenis_active_all) errors.push('Lenis not active on all pages');
3064
+ if (isGsapStack && !s.gsap_loaded_all) errors.push('GSAP not loaded on all pages');
3065
+ if (motionErrorCount > 0) { errors.push(`${motionErrorCount} amateur-motion error(s): linear easing or transition:all`); rawScore -= 15; }
3066
+ if (layoutErrorCount > 0) { errors.push(`${layoutErrorCount} objective layout error(s)`); rawScore -= 15; }
3067
+ if (reducedMotionViolations > 0) { errors.push(`${reducedMotionViolations} reduced-motion violation(s)`); rawScore -= 10; }
2975
3068
  if (s.em_dashes_detected) { errors.push('Em-dashes found in visible text'); rawScore -= 10; }
2976
3069
  if (s.console_errors_total > 0) { warnings.push(`${s.console_errors_total} console error(s)`); rawScore -= 5; }
3070
+ 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.');
3071
+
3072
+ // Hard blocks: measured failures that cannot be APPROVED regardless of the
3073
+ // agent's judged 8-dimension rubric score. The agent applies visual_rubric on
3074
+ // top of this measured floor and SHALL NOT issue APPROVED when hardBlocked.
3075
+ const hardBlocked =
3076
+ (isGsapStack && (!s.lenis_active_all || !s.gsap_loaded_all)) ||
3077
+ motionErrorCount > 0 ||
3078
+ layoutErrorCount > 0 ||
3079
+ reducedMotionViolations > 0 ||
3080
+ !!s.em_dashes_detected;
2977
3081
 
2978
- // Hard blocks
2979
- const hardBlocked = !s.lenis_active_all || !s.gsap_loaded_all || s.em_dashes_detected;
2980
3082
  const score = Math.max(0, Math.min(100, rawScore));
2981
3083
  const threshold = 90;
2982
- const verdict = hardBlocked ? 'BLOCKED' : score >= threshold ? 'APPROVED' : 'NEEDS_CORRECTION';
3084
+ // FLOOR_COMPUTED (not NEEDS_CORRECTION) is the label for a clean-but-below-floor
3085
+ // measured score: it signals that this is the CLI's measured floor, NOT the gate
3086
+ // verdict. The gate verdict is the QA-Visual agent's 8-dimension rubric score. A
3087
+ // clean framer-motion build can sit below the measured floor yet still be APPROVED
3088
+ // by the agent, so the CLI must not emit a correction-sounding label here.
3089
+ const verdict = hardBlocked ? 'BLOCKED' : score >= threshold ? 'APPROVED' : 'FLOOR_COMPUTED';
2983
3090
 
2984
- return { score, threshold, verdict, hardBlocked, errors, warnings, weights: w, dimensions: { animScore, scrollScore, responsiveScore, hoverScore, brandScore, fidelityScore } };
3091
+ return {
3092
+ score, threshold, verdict, hardBlocked, animation_library: animLib, errors, warnings, weights: w,
3093
+ measured_signals: {
3094
+ motion_error_count: motionErrorCount,
3095
+ layout_error_count: layoutErrorCount,
3096
+ reduced_motion_violations: reducedMotionViolations,
3097
+ em_dashes: !!s.em_dashes_detected,
3098
+ },
3099
+ dimensions: { animScore, scrollScore, responsiveScore, hoverScore, brandScore, fidelityScore },
3100
+ 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.',
3101
+ };
3102
+ }
3103
+
3104
+ /**
3105
+ * run-script-task: execute a task whose execution_kind is `script` through its
3106
+ * deterministic executor (no LLM). Gated by the execution_kinds feature flag;
3107
+ * when the flag is off it returns ran:false so the orchestrator uses the agent
3108
+ * path and the pipeline is unchanged. The orchestrator resolves the task fields
3109
+ * from tasks.yaml and passes them as flags (--ref, --id, --title).
3110
+ */
3111
+ export async function handleRunScriptTask(projectDir, args) {
3112
+ if (!isFeatureEnabled(projectDir, 'execution_kinds')) {
3113
+ return { ran: false, reason: 'The execution_kinds feature is off. Run this task through the agent path.', fallback: 'agent' };
3114
+ }
3115
+
3116
+ const kind = String(args.kind || 'script');
3117
+ if (kind !== 'script') {
3118
+ return { ran: false, reason: `execution_kind "${kind}" is not auto-run by the script runner (only "script" is). Route through the normal path.`, kind };
3119
+ }
3120
+
3121
+ const ref = args.ref || '';
3122
+ if (!ref) {
3123
+ return { ran: false, reason: 'Missing --ref (the execution_ref, for example "npm-script:test").' };
3124
+ }
3125
+
3126
+ const id = args.id || args.task || '';
3127
+ const title = args.title || '';
3128
+ const result = await runScriptTask(projectDir, { id, title, execution_ref: ref });
3129
+
3130
+ // Write a synthetic handoff so the pipeline reads it like an agent handoff.
3131
+ const timestamp = new Date().toISOString();
3132
+ const handoff = formatScriptHandoff({ id, title }, result, timestamp);
3133
+ let handoffPath = null;
3134
+ try {
3135
+ const dir = join(projectDir, resolveFrameworkDir(projectDir), 'artifacts', 'handoffs');
3136
+ mkdirSync(dir, { recursive: true });
3137
+ handoffPath = join(dir, `script-${(id || 'task').replace(/[^A-Za-z0-9._-]/g, '_')}.md`);
3138
+ writeFileSync(handoffPath, handoff, 'utf-8');
3139
+ } catch {
3140
+ // best-effort; the structured result is still returned for the orchestrator
3141
+ }
3142
+
3143
+ return {
3144
+ ran: true,
3145
+ executor: result.executor,
3146
+ arg: result.arg,
3147
+ status: result.status,
3148
+ summary: result.summary,
3149
+ durationMs: result.durationMs,
3150
+ handoffPath,
3151
+ details: result.details,
3152
+ };
2985
3153
  }
2986
3154
 
2987
3155
  /**
@@ -3202,6 +3370,9 @@ export async function runOrchestrate(subCommand, argv, projectDir) {
3202
3370
  case 'assess-coverage':
3203
3371
  output(handleAssessCoverage(args));
3204
3372
  break;
3373
+ case 'run-script-task':
3374
+ output(await handleRunScriptTask(projectDir, args));
3375
+ break;
3205
3376
  default:
3206
3377
  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'));
3207
3378
  break;
@@ -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);
@@ -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
  }