chati-dev 4.2.2 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -53
- package/bin/chati.js +150 -5
- package/framework/agents/build/dev.md +509 -76
- package/framework/agents/deploy/devops.md +40 -25
- package/framework/agents/discover/brief.md +156 -22
- package/framework/agents/discover/brownfield-wu.md +24 -14
- package/framework/agents/discover/greenfield-wu.md +100 -14
- package/framework/agents/plan/architect-data-engineer.md +6 -6
- package/framework/agents/plan/architect-system.md +46 -12
- package/framework/agents/plan/architect.md +40 -20
- package/framework/agents/plan/detail.md +36 -24
- package/framework/agents/plan/phases.md +36 -26
- package/framework/agents/plan/tasks.md +114 -33
- package/framework/agents/plan/ux-brand-architect.md +240 -8
- package/framework/agents/plan/ux-component-engineer.md +28 -13
- package/framework/agents/plan/ux-researcher.md +7 -6
- package/framework/agents/plan/ux.md +55 -22
- package/framework/agents/quality/qa-implementation.md +143 -74
- package/framework/agents/quality/qa-planning.md +115 -42
- package/framework/agents/quality/qa-visual.md +439 -0
- package/framework/agents/shared/visualizer.md +128 -0
- package/framework/config.yaml +7 -6
- package/framework/constitution.md +127 -44
- package/framework/context/governance.md +12 -7
- package/framework/context/quality.md +6 -5
- package/framework/context/root.md +6 -6
- package/framework/data/entity-registry.yaml +377 -4
- package/framework/data/model-limits.json +19 -0
- package/framework/domains/agents/qa-visual.yaml +74 -0
- package/framework/domains/constitution.yaml +46 -2
- package/framework/domains/workflows/greenfield-fullstack.yaml +2 -2
- package/framework/hooks/advance-trigger.js +131 -0
- package/framework/hooks/brief-validator.js +83 -0
- package/framework/hooks/constitution-guard.js +24 -5
- package/framework/hooks/license-guard.js +62 -27
- package/framework/hooks/mode-governance.js +13 -2
- package/framework/hooks/model-governance.js +1 -0
- package/framework/hooks/post-dev.js +207 -0
- package/framework/hooks/prism-engine.js +274 -105
- package/framework/hooks/reasoning-escalator.js +371 -0
- package/framework/hooks/reference-trigger.js +117 -0
- package/framework/hooks/session-digest.js +50 -1
- package/framework/hooks/settings.json +32 -1
- package/framework/hooks/style-guard.js +25 -6
- package/framework/hooks/team-quality-gate.js +19 -12
- package/framework/hooks/undercover-guard.js +4 -2
- package/framework/i18n/en.yaml +3 -3
- package/framework/i18n/es.yaml +3 -3
- package/framework/i18n/fr.yaml +3 -3
- package/framework/i18n/pt.yaml +3 -3
- package/framework/intelligence/context-engine.md +4 -5
- package/framework/intelligence/decision-engine.md +1 -1
- package/framework/intelligence/hooks-performance.md +3 -3
- package/framework/migrations/v1.0-to-v1.1.yaml +1 -1
- package/framework/migrations/v1.4-to-v2.0.yaml +11 -11
- package/framework/migrations/v4.0-to-v4.1.yaml +2 -2
- package/framework/migrations/v4.2-to-v4.3.yaml +29 -0
- package/framework/orchestrator/chati-router.js +387 -0
- package/framework/orchestrator/chati-update.md +40 -40
- package/framework/orchestrator/chati.md +294 -94
- package/framework/scaffold/motion-premium/README.md +89 -0
- package/framework/scaffold/motion-premium/app/globals.css.template +400 -0
- package/framework/scaffold/motion-premium/app/layout.tsx.template +110 -0
- package/framework/scaffold/motion-premium/components/animation/BackgroundCrossfadeProvider.tsx.template +170 -0
- package/framework/scaffold/motion-premium/components/animation/LenisProvider.tsx.template +49 -0
- package/framework/scaffold/motion-premium/components/animation/PageTransitionWrapper.tsx.template +83 -0
- package/framework/scaffold/motion-premium/components/animation/Preloader.tsx.template +171 -0
- package/framework/scaffold/motion-premium/components/ui/Container.tsx.template +69 -0
- package/framework/scaffold/motion-premium/components/ui/PageSection.tsx.template +74 -0
- package/framework/scaffold/motion-premium/lib/animations/gsap.ts.template +112 -0
- package/framework/scaffold/motion-premium/lib/animations/refreshCoordinator.ts.template +75 -0
- package/framework/scaffold/motion-premium/lib/animations/tokens.ts.template +119 -0
- package/framework/scaffold/motion-premium/lib/animations/useGsapContext.ts.template +92 -0
- package/framework/scaffold/motion-premium/lib/animations/useScrollSnapStepper.ts.template +265 -0
- package/framework/scaffold/motion-premium/lib/animations/useSmoothScroll.ts.template +67 -0
- package/framework/scaffold/motion-premium/lib/brand.ts.template +43 -0
- package/framework/scaffold/motion-premium/scaffold.yaml +174 -0
- package/framework/scaffold/motion-premium-3d/README.md +80 -0
- package/framework/scaffold/motion-premium-3d/app/(3d)/scroll-demo/ScrollDemoCanvas.tsx.template +81 -0
- package/framework/scaffold/motion-premium-3d/app/(3d)/scroll-demo/ScrollDemoClient.tsx.template +75 -0
- package/framework/scaffold/motion-premium-3d/app/(3d)/scroll-demo/page.tsx.template +26 -0
- package/framework/scaffold/motion-premium-3d/components/3d/CameraRig.tsx.template +100 -0
- package/framework/scaffold/motion-premium-3d/components/3d/CanvasProvider.tsx.template +85 -0
- package/framework/scaffold/motion-premium-3d/components/3d/InvalidateOnScroll.tsx.template +51 -0
- package/framework/scaffold/motion-premium-3d/components/3d/MeshCrossfade.tsx.template +79 -0
- package/framework/scaffold/motion-premium-3d/components/3d/ScrollCrossfade.tsx.template +88 -0
- package/framework/scaffold/motion-premium-3d/components/3d/ScrollScene.tsx.template +121 -0
- package/framework/scaffold/motion-premium-3d/components/webgl/SceneFallback.tsx.template +65 -0
- package/framework/scaffold/motion-premium-3d/components/webgl/WebGLContext.tsx.template +68 -0
- package/framework/scaffold/motion-premium-3d/lib/webgl/detect.ts.template +69 -0
- package/framework/scaffold/motion-premium-3d/scaffold.yaml +133 -0
- package/framework/schemas/session.schema.json +109 -21
- package/framework/scripts/reference-capture.js +430 -0
- package/framework/scripts/visual-qa.js +674 -0
- package/framework/tasks/orchestrator-handoff.md +1 -1
- package/framework/tasks/orchestrator-resume.md +1 -1
- package/framework/tasks/orchestrator-route.md +1 -1
- package/framework/tasks/orchestrator-status.md +3 -3
- package/framework/tasks/qa-planning-gate-define.md +1 -1
- package/framework/templates/brandbook-html-tmpl.md +1 -1
- package/framework/templates/brandbook-tmpl.yaml +1 -1
- package/framework/templates/component-spec-tmpl.yaml +1 -1
- package/framework/templates/design-token-tmpl.yaml +1 -1
- package/framework/templates/icon-system-tmpl.yaml +1 -1
- package/framework/templates/team-planning-tasks.yaml +6 -5
- package/framework/workflows/brownfield-discovery.yaml +2 -2
- package/framework/workflows/brownfield-fullstack.yaml +15 -11
- package/framework/workflows/brownfield-service.yaml +14 -10
- package/framework/workflows/brownfield-ui.yaml +15 -11
- package/framework/workflows/greenfield-fullstack.yaml +16 -13
- package/framework/workflows/quick-flow.yaml +3 -3
- package/framework/workflows/standard-flow.yaml +12 -9
- package/package.json +10 -5
- package/src/autonomy/autonomous-gate.js +1 -0
- package/src/autonomy/build-state.js +1 -2
- package/src/autonomy/progress-reporter.js +1 -1
- package/src/config/agent-customizer.js +11 -3
- package/src/config/claude-settings-generator.js +27 -7
- package/src/config/context-file-generator.js +41 -21
- package/src/config/framework-adapter.js +1 -0
- package/src/config/gemini-hooks-generator.js +19 -7
- package/src/config/mcp-configs.js +1 -0
- package/src/context/layers/l1-global.js +2 -1
- package/src/dashboard/data-reader.js +4 -3
- package/src/dashboard/layout.js +2 -1
- package/src/decision/analyzer.js +6 -30
- package/src/decision/engine.js +4 -28
- package/src/decision/registry-healer.js +3 -2
- package/src/decision/registry-updater.js +23 -14
- package/src/extensions/loader.js +2 -8
- package/src/gates/g1-planning-complete.js +2 -1
- package/src/gates/g2-qa-planning.js +2 -1
- package/src/gates/g3-implementation.js +2 -1
- package/src/gates/g4-qa-implementation.js +3 -2
- package/src/gates/g5-deploy-ready.js +2 -1
- package/src/health/engine.js +4 -3
- package/src/installer/core.js +422 -81
- package/src/installer/preflight.js +131 -0
- package/src/installer/provider-overlay.js +3 -3
- package/src/installer/scaffold-applier.js +358 -0
- package/src/installer/templates.js +46 -29
- package/src/installer/validator.js +17 -12
- package/src/intelligence/registry-manager.js +22 -29
- package/src/intelligence/timeline.js +11 -6
- package/src/license/commands.js +1 -1
- package/src/license/wait.js +102 -0
- package/src/memory/agent-memory.js +81 -0
- package/src/memory/dream.js +32 -1
- package/src/merger/replace-merger.js +28 -15
- package/src/orchestrator/agent-selector.js +2 -1
- package/src/orchestrator/cli.js +1869 -71
- package/src/orchestrator/doctor.js +270 -0
- package/src/orchestrator/handoff-engine.js +4 -3
- package/src/orchestrator/index.js +2 -0
- package/src/orchestrator/pipeline-manager.js +306 -15
- package/src/orchestrator/session-manager.js +331 -6
- package/src/tasks/handoff.js +3 -2
- package/src/telemetry/config.js +4 -3
- package/src/telemetry/schema.js +1 -0
- package/src/terminal/collector.js +3 -2
- package/src/terminal/index.js +1 -2
- package/src/terminal/isolation.js +52 -18
- package/src/terminal/prompt-builder.js +42 -25
- package/src/terminal/run-parallel.js +1 -1
- package/src/terminal/run-team.js +3 -3
- package/src/terminal/team-task-list.js +43 -4
- package/src/upgrade/backup.js +3 -2
- package/src/upgrade/checker.js +3 -2
- package/src/upgrade/migrator.js +65 -7
- package/src/upgrade/tracked-files-detector.js +86 -0
- package/src/upgrade/user-messages.js +94 -0
- package/src/utils/config-parser.js +2 -1
- package/src/utils/feature-flags.js +2 -1
- package/src/utils/flatten-entities.js +69 -0
- package/src/utils/framework-dir.js +16 -0
- package/src/utils/model-id.js +85 -0
- package/src/utils/provider-limits.js +84 -23
- package/src/utils/schema-validator.js +1 -1
- package/src/wizard/i18n.js +5 -4
- package/src/wizard/index.js +14 -0
- package/assets/logo - c/303/263pia.png +0 -0
- package/assets/logo.svg +0 -42
- package/assets/logo2.png +0 -0
- package/assets/social-preview.png +0 -0
- package/scripts/bundle-framework.js +0 -69
- package/scripts/changelog-generator.js +0 -222
- package/scripts/codebase-mapper.js +0 -728
- package/scripts/commit-message-generator.js +0 -167
- package/scripts/coverage-analyzer.js +0 -260
- package/scripts/dependency-analyzer.js +0 -280
- package/scripts/doctor/checks/agents.js +0 -77
- package/scripts/doctor/checks/constitution.js +0 -41
- package/scripts/doctor/checks/domain-alignment.js +0 -58
- package/scripts/doctor/checks/prism-layers.js +0 -84
- package/scripts/doctor/checks/registry.js +0 -55
- package/scripts/doctor/checks/schemas.js +0 -61
- package/scripts/doctor/fixes/reference-fix.js +0 -100
- package/scripts/doctor/fixes/registry-fix.js +0 -56
- package/scripts/doctor/index.js +0 -212
- package/scripts/framework-analyzer.js +0 -308
- package/scripts/generate-constitution-domain.js +0 -253
- package/scripts/generate-signing-key.js +0 -33
- package/scripts/health-check.js +0 -481
- package/scripts/ide-sync.js +0 -326
- package/scripts/performance-analyzer.js +0 -325
- package/scripts/plan-tracker.js +0 -278
- package/scripts/populate-entity-registry.js +0 -481
- package/scripts/pr-review.js +0 -317
- package/scripts/rollback-manager.js +0 -310
- package/scripts/semantic-lint.js +0 -328
- package/scripts/sign-manifest.js +0 -53
- package/scripts/stuck-detector.js +0 -343
- package/scripts/test-quality-assessment.js +0 -257
- package/scripts/validate-agents.js +0 -368
- package/scripts/validate-package.js +0 -505
- package/scripts/validate-tasks.js +0 -465
- package/src/autonomy/worktree-manager.js +0 -250
- package/src/intelligence/decision-engine.js +0 -256
- package/src/intelligence/document-sharder.js +0 -221
- package/src/intelligence/elicitation.js +0 -265
package/src/orchestrator/cli.js
CHANGED
|
@@ -11,11 +11,19 @@
|
|
|
11
11
|
* Sub-commands: next, advance, init, validate-handoff, status, deviation, exit
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { resolveFrameworkDir } from '../utils/framework-dir.js';
|
|
15
|
+
|
|
14
16
|
import {
|
|
15
17
|
loadSession, initSession, updateSession, recordAgentCompletion,
|
|
16
18
|
recordModeTransition, getSessionSummary, validateSession,
|
|
17
|
-
releaseSession,
|
|
19
|
+
releaseSession, parseHandoffDecisionTrail, parseHandoffScaffoldSignals,
|
|
18
20
|
} from './index.js';
|
|
21
|
+
import {
|
|
22
|
+
resolveScaffoldSource, loadScaffoldManifest, applyScaffold,
|
|
23
|
+
} from '../installer/scaffold-applier.js';
|
|
24
|
+
import { resolveContextLimit } from '../utils/provider-limits.js';
|
|
25
|
+
import { waitForLicense } from '../license/wait.js';
|
|
26
|
+
import { runDoctor } from './doctor.js';
|
|
19
27
|
import {
|
|
20
28
|
getNextAgent, getAgentDefinition, AGENT_PIPELINE,
|
|
21
29
|
} from './index.js';
|
|
@@ -29,12 +37,18 @@ import {
|
|
|
29
37
|
} from './index.js';
|
|
30
38
|
import { analyzeDeviationImpact, applyDeviation } from './index.js';
|
|
31
39
|
import { detectQuickFlow, detectStandardFlow } from './index.js';
|
|
32
|
-
import {
|
|
33
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
40
|
+
import { getAgentFile } from '../terminal/prompt-builder.js';
|
|
41
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
|
|
34
42
|
import { join, resolve } from 'path';
|
|
43
|
+
import { execSync } from 'child_process';
|
|
44
|
+
import yaml from 'js-yaml';
|
|
35
45
|
import {
|
|
36
46
|
initTaskList, readTaskList, getTeamProgress,
|
|
37
47
|
} from '../terminal/team-task-list.js';
|
|
48
|
+
import {
|
|
49
|
+
recordEvent, EventType, clearTimeline,
|
|
50
|
+
} from '../intelligence/timeline.js';
|
|
51
|
+
import { updateClaudeMd } from '../memory/magic-docs.js';
|
|
38
52
|
|
|
39
53
|
// ---------------------------------------------------------------------------
|
|
40
54
|
// Constants
|
|
@@ -44,14 +58,411 @@ const INTERACTIVE_AGENTS = ['greenfield-wu', 'brownfield-wu', 'brief'];
|
|
|
44
58
|
|
|
45
59
|
const LOCK_START = '<!-- chati-lock:start -->';
|
|
46
60
|
const LOCK_END = '<!-- chati-lock:end -->';
|
|
61
|
+
const STATE_START = '<!-- chati-state:start -->';
|
|
62
|
+
const STATE_END = '<!-- chati-state:end -->';
|
|
47
63
|
|
|
48
64
|
const RESUME_MESSAGES = {
|
|
49
65
|
en: 'Session saved. Type /chati anytime to resume.',
|
|
50
|
-
pt: '
|
|
51
|
-
es: '
|
|
66
|
+
pt: 'Sessão salva. Digite /chati para retomar.',
|
|
67
|
+
es: 'Sesión guardada. Escribe /chati para reanudar.',
|
|
52
68
|
fr: 'Session sauvee. Tapez /chati pour reprendre.',
|
|
53
69
|
};
|
|
54
70
|
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// QA-Visual scope helpers (Fase 6 — scope isolation)
|
|
73
|
+
//
|
|
74
|
+
// When shared CSS or token files change (globals.css, brand.ts, tailwind
|
|
75
|
+
// config), a regression in the affected utility can silently break routes
|
|
76
|
+
// that were NOT touched by the current task. The qa-visual gate uses
|
|
77
|
+
// these helpers to detect the situation and require full app/ coverage
|
|
78
|
+
// before allowing advance.
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
const SHARED_CSS_PATTERNS = [
|
|
82
|
+
/(^|\/)app\/globals\.css$/,
|
|
83
|
+
/(^|\/)src\/app\/globals\.css$/,
|
|
84
|
+
/(^|\/)lib\/brand\.ts$/,
|
|
85
|
+
/(^|\/)src\/lib\/brand\.ts$/,
|
|
86
|
+
/(^|\/)tailwind\.config\.(?:js|ts|mjs|cjs)$/,
|
|
87
|
+
/(^|\/)postcss\.config\.(?:js|ts|mjs|cjs)$/,
|
|
88
|
+
/(^|\/)lib\/animations\/tokens\.(?:ts|js)$/,
|
|
89
|
+
/(^|\/)src\/lib\/animations\/tokens\.(?:ts|js)$/,
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* True if any entry in `filesModified` matches a shared CSS / token file.
|
|
94
|
+
* These files touch every route — a change here requires full coverage in QA-Visual.
|
|
95
|
+
* Exported for testing.
|
|
96
|
+
*
|
|
97
|
+
* @param {string[]} filesModified - Repo-relative file paths.
|
|
98
|
+
* @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
export function detectSharedCSSChanges(filesModified) {
|
|
101
|
+
if (!Array.isArray(filesModified) || filesModified.length === 0) return false;
|
|
102
|
+
return filesModified.some(f => typeof f === 'string' && SHARED_CSS_PATTERNS.some(p => p.test(f)));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Enumerate Next.js App Router routes by walking a project's `app/` directory
|
|
107
|
+
* and collecting `page.{tsx,jsx,ts,js,mdx}` files. Excludes:
|
|
108
|
+
* - dynamic segments (`[slug]/`, `[[...slug]]/`) — can't be captured statically
|
|
109
|
+
* - private folders (`_components/`)
|
|
110
|
+
* - route groups (`(marketing)/`) stripped from the URL path
|
|
111
|
+
*
|
|
112
|
+
* Returns null if no `app/` directory exists (non-Next.js project).
|
|
113
|
+
* Exported for testing.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} projectDir
|
|
116
|
+
* @returns {string[] | null}
|
|
117
|
+
*/
|
|
118
|
+
export function discoverAppRoutes(projectDir) {
|
|
119
|
+
if (!projectDir) return null;
|
|
120
|
+
// Common locations: app/ at root, or src/app/ (create-next-app option).
|
|
121
|
+
const candidates = [join(projectDir, 'app'), join(projectDir, 'src', 'app')];
|
|
122
|
+
const appDir = candidates.find(d => existsSync(d));
|
|
123
|
+
if (!appDir) return null;
|
|
124
|
+
|
|
125
|
+
const routes = [];
|
|
126
|
+
const PAGE_RE = /^page\.(tsx|jsx|ts|js|mdx)$/;
|
|
127
|
+
|
|
128
|
+
function walk(dir, segments) {
|
|
129
|
+
let entries;
|
|
130
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
131
|
+
for (const e of entries) {
|
|
132
|
+
if (e.isDirectory()) {
|
|
133
|
+
const name = e.name;
|
|
134
|
+
// Skip private (underscore) and dynamic segments
|
|
135
|
+
if (name.startsWith('_')) continue;
|
|
136
|
+
if (name.startsWith('[')) continue; // dynamic: [slug], [...catchAll], [[...slug]]
|
|
137
|
+
// Route groups (parens) wrap but don't appear in URL
|
|
138
|
+
const isGroup = name.startsWith('(') && name.endsWith(')');
|
|
139
|
+
const nextSegments = isGroup ? segments : [...segments, name];
|
|
140
|
+
walk(join(dir, e.name), nextSegments);
|
|
141
|
+
} else if (e.isFile() && PAGE_RE.test(e.name)) {
|
|
142
|
+
routes.push(segments.length === 0 ? '/' : '/' + segments.join('/'));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
walk(appDir, []);
|
|
147
|
+
// Deduplicate (route groups can produce the same URL twice) and sort.
|
|
148
|
+
return [...new Set(routes)].sort();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Run `git diff --name-only` to list files modified in the current working
|
|
153
|
+
* copy since HEAD, plus files changed relative to main. Silently returns []
|
|
154
|
+
* if git is unavailable or projectDir is not a git repo.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} projectDir
|
|
157
|
+
* @returns {string[]}
|
|
158
|
+
*/
|
|
159
|
+
export function getModifiedFiles(projectDir) {
|
|
160
|
+
if (!projectDir) return [];
|
|
161
|
+
const cmds = [
|
|
162
|
+
'git diff --name-only HEAD 2>/dev/null',
|
|
163
|
+
'git diff --name-only --cached 2>/dev/null',
|
|
164
|
+
'git diff --name-only main...HEAD 2>/dev/null',
|
|
165
|
+
];
|
|
166
|
+
const out = new Set();
|
|
167
|
+
for (const cmd of cmds) {
|
|
168
|
+
try {
|
|
169
|
+
const raw = execSync(cmd, { cwd: projectDir, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
170
|
+
for (const line of raw.split('\n')) {
|
|
171
|
+
const f = line.trim();
|
|
172
|
+
if (f) out.add(f);
|
|
173
|
+
}
|
|
174
|
+
} catch { /* silently skip failed command (not a repo, no main branch, etc.) */ }
|
|
175
|
+
}
|
|
176
|
+
return [...out];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Reasoning Tier (Fase 9 — Article XXIII)
|
|
181
|
+
//
|
|
182
|
+
// Per-agent DEFAULT tiers. The escalator hook promotes from default upward
|
|
183
|
+
// based on friction signals; de-escalation only happens on an explicit
|
|
184
|
+
// /quick slash command.
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
export const REASONING_TIERS = ['standard', 'deep', 'deliberate'];
|
|
188
|
+
|
|
189
|
+
export const AGENT_DEFAULT_TIER = {
|
|
190
|
+
'greenfield-wu': 'standard',
|
|
191
|
+
'brownfield-wu': 'deep',
|
|
192
|
+
'brief': 'standard',
|
|
193
|
+
'detail': 'deep',
|
|
194
|
+
'architect': 'deep',
|
|
195
|
+
'ux': 'standard',
|
|
196
|
+
'phases': 'standard',
|
|
197
|
+
'tasks': 'standard',
|
|
198
|
+
'qa-planning': 'deep',
|
|
199
|
+
'dev': 'standard',
|
|
200
|
+
'qa-implementation': 'deep',
|
|
201
|
+
'qa-visual': 'standard',
|
|
202
|
+
'devops': 'standard',
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Read the current reasoning_tier from a session object. Falls back to the
|
|
207
|
+
* per-agent default when the session field is missing or invalid.
|
|
208
|
+
* Exported for testing.
|
|
209
|
+
*/
|
|
210
|
+
export function getReasoningTier(session) {
|
|
211
|
+
if (!session) return 'standard';
|
|
212
|
+
const recorded = session.reasoning_tier;
|
|
213
|
+
if (REASONING_TIERS.includes(recorded)) return recorded;
|
|
214
|
+
const agent = session.current_agent;
|
|
215
|
+
if (agent && AGENT_DEFAULT_TIER[agent]) return AGENT_DEFAULT_TIER[agent];
|
|
216
|
+
return 'standard';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Premium-reference detection (Fase 7 — task-to-UX fidelity)
|
|
221
|
+
//
|
|
222
|
+
// When the brief or reference-analysis cites a well-known premium-animation
|
|
223
|
+
// site (Oryzo, Norris, Relats, Wero, igloo inc, Awwwards entries) OR uses
|
|
224
|
+
// specific phrase constructions that signal premium motion intent
|
|
225
|
+
// (cinematic scroll, horizontal pin, scroll-driven, award-winning), the
|
|
226
|
+
// brand-architect agent MUST produce an animation-inventory.md artifact
|
|
227
|
+
// that names every observed pattern and maps it to a scaffold template.
|
|
228
|
+
// The inventory is consumed 1:1 by the tasks agent and used as the
|
|
229
|
+
// checklist by qa-visual Mode 2. Without it, the downstream pipeline
|
|
230
|
+
// produces generic "add scroll reveals" tasks that fail to reproduce the
|
|
231
|
+
// specific patterns the user asked for. The gate below enforces the
|
|
232
|
+
// requirement at advance-ux time.
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
const PREMIUM_REF_PATTERNS = [
|
|
236
|
+
// Named premium-animation reference sites.
|
|
237
|
+
{ label: 'Oryzo', pattern: /\boryzo\b/i },
|
|
238
|
+
{ label: 'Norris', pattern: /\bnorris\b/i },
|
|
239
|
+
{ label: 'Relats', pattern: /\brelats\b/i },
|
|
240
|
+
{ label: 'Wero', pattern: /\bwero\b/i },
|
|
241
|
+
{ label: 'Igloo Inc', pattern: /\bigloo\s*inc\b/i },
|
|
242
|
+
{ label: 'Awwwards', pattern: /\bawwwards\b/i },
|
|
243
|
+
// Phrase constructions that signal premium animation intent.
|
|
244
|
+
{ label: 'premium-animation', pattern: /\bpremium\s+(?:animation|motion|feel|experience)/i },
|
|
245
|
+
{ label: 'cinematic-scroll', pattern: /\bcinematic\s+(?:scroll|experience|transitions?)/i },
|
|
246
|
+
{ label: 'horizontal-pin', pattern: /\bhorizontal[- ]?pin(?:ned)?\s+(?:scroll|sequence|section)/i },
|
|
247
|
+
{ label: 'scroll-driven', pattern: /\bscroll[- ]?driven\s+(?:animation|design|experience|crossfade|background)/i },
|
|
248
|
+
{ label: 'award-winning', pattern: /\baward[- ]?winning\s+(?:design|animation|website|experience)/i },
|
|
249
|
+
{ label: 'pixel-perfect-motion', pattern: /\bpixel[- ]?perfect\s+(?:animation|motion)/i },
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Detect premium-animation references in text (typically brief-report.md
|
|
254
|
+
* and/or reference-analysis.md concatenated). Exported for testing.
|
|
255
|
+
*
|
|
256
|
+
* @param {string} text
|
|
257
|
+
* @returns {{ detected: boolean, matches: string[] }}
|
|
258
|
+
*/
|
|
259
|
+
export function detectPremiumRefs(text) {
|
|
260
|
+
if (!text || typeof text !== 'string') return { detected: false, matches: [] };
|
|
261
|
+
const matches = [];
|
|
262
|
+
for (const { label, pattern } of PREMIUM_REF_PATTERNS) {
|
|
263
|
+
if (pattern.test(text)) matches.push(label);
|
|
264
|
+
}
|
|
265
|
+
return { detected: matches.length > 0, matches };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Read brief-report.md + reference-analysis.md from a project as a single
|
|
270
|
+
* corpus for premium-ref detection. Missing files are silently skipped.
|
|
271
|
+
* Exported for testing.
|
|
272
|
+
*
|
|
273
|
+
* @param {string} projectDir
|
|
274
|
+
* @returns {string}
|
|
275
|
+
*/
|
|
276
|
+
export function readBriefAndRefCorpus(projectDir) {
|
|
277
|
+
if (!projectDir) return '';
|
|
278
|
+
const paths = [
|
|
279
|
+
join(projectDir, 'artifacts', '1-Brief', 'brief-report.md'),
|
|
280
|
+
join(projectDir, 'artifacts', '4-UX', 'reference-analysis.md'),
|
|
281
|
+
];
|
|
282
|
+
let corpus = '';
|
|
283
|
+
for (const p of paths) {
|
|
284
|
+
if (!existsSync(p)) continue;
|
|
285
|
+
try { corpus += readFileSync(p, 'utf-8') + '\n'; } catch { /* ignore */ }
|
|
286
|
+
}
|
|
287
|
+
return corpus;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// State-at-a-glance card (Fase 8)
|
|
292
|
+
//
|
|
293
|
+
// Every handleNext response and every correction-loop trigger carries a
|
|
294
|
+
// rendered multi-line "state card" that gives the human operator a one-
|
|
295
|
+
// glance summary without grepping session.yaml. Box-drawn, unicode,
|
|
296
|
+
// fixed width for predictability.
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
export const STATE_CARD_TOTAL_WIDTH = 43;
|
|
300
|
+
export const STATE_CARD_CONTENT_WIDTH = 39;
|
|
301
|
+
const STATE_CARD_MAX_NAME = 30;
|
|
302
|
+
|
|
303
|
+
function _stateTruncate(s, maxLen) {
|
|
304
|
+
if (s === null || s === undefined) return '';
|
|
305
|
+
const str = String(s);
|
|
306
|
+
if (str.length <= maxLen) return str;
|
|
307
|
+
return str.slice(0, Math.max(0, maxLen - 1)) + '…';
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function _statePadRight(s, width) {
|
|
311
|
+
const str = _stateTruncate(s, width);
|
|
312
|
+
return str + ' '.repeat(Math.max(0, width - str.length));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Pure formatter for the state-at-a-glance card. All input fields are
|
|
317
|
+
* taken as-is (no I/O). `buildStateCard` is the I/O wrapper that
|
|
318
|
+
* collects the fields from session + git + disk. Exported for testing.
|
|
319
|
+
*
|
|
320
|
+
* Card shape (fixed widths, STATE_CARD_TOTAL_WIDTH = 43 chars):
|
|
321
|
+
* ┌─ {project} ─────────────┐
|
|
322
|
+
* │ {Cycle N •} {agent status} • {mode} │
|
|
323
|
+
* │ Last commit: {sha7} • QA: {score|—} │
|
|
324
|
+
* │ Open: {N} item(s) from audit │
|
|
325
|
+
* [│ ↺ Reset: {agent-list} │] (only if correctionReset)
|
|
326
|
+
* └───────────────────────────────────────┘
|
|
327
|
+
*
|
|
328
|
+
* @param {object} opts
|
|
329
|
+
* @param {string} opts.projectName
|
|
330
|
+
* @param {number|null} [opts.cycle] - if number, "Cycle N" prefix is added
|
|
331
|
+
* @param {string} opts.agent
|
|
332
|
+
* @param {string} opts.agentStatus - pending | in_progress | completed | …
|
|
333
|
+
* @param {string} opts.mode - execution_mode (interactive|autonomous) per Article XVII
|
|
334
|
+
* @param {string} opts.commitShort - 7-char SHA or 'unknown'
|
|
335
|
+
* @param {number|null} opts.qaScore - numeric 0..100 or null (shows —)
|
|
336
|
+
* @param {number} opts.backlogCount
|
|
337
|
+
* @param {{agents?: string[], files?: string[]}|null} [opts.correctionReset]
|
|
338
|
+
* @returns {string} Multi-line card (use console.log or JSON field).
|
|
339
|
+
*/
|
|
340
|
+
export function formatStateCard(opts = {}) {
|
|
341
|
+
const projectName = _stateTruncate(opts.projectName || 'unnamed project', STATE_CARD_MAX_NAME);
|
|
342
|
+
const dashesAfterName = STATE_CARD_TOTAL_WIDTH - 4 - projectName.length - 1;
|
|
343
|
+
const topLine = `┌─ ${projectName} ${'─'.repeat(Math.max(0, dashesAfterName))}┐`;
|
|
344
|
+
|
|
345
|
+
const parts1 = [];
|
|
346
|
+
if (typeof opts.cycle === 'number' && Number.isFinite(opts.cycle)) {
|
|
347
|
+
parts1.push(`Cycle ${opts.cycle}`);
|
|
348
|
+
}
|
|
349
|
+
parts1.push(`${opts.agent || 'none'} ${opts.agentStatus || 'pending'}`);
|
|
350
|
+
parts1.push(opts.mode || 'interactive');
|
|
351
|
+
const line1 = _statePadRight(parts1.join(' • '), STATE_CARD_CONTENT_WIDTH);
|
|
352
|
+
|
|
353
|
+
const qaDisplay = opts.qaScore !== null && opts.qaScore !== undefined ? String(opts.qaScore) : '—';
|
|
354
|
+
const line2 = _statePadRight(
|
|
355
|
+
`Last commit: ${opts.commitShort || 'unknown'} • QA: ${qaDisplay}`,
|
|
356
|
+
STATE_CARD_CONTENT_WIDTH,
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const n = Number.isFinite(opts.backlogCount) ? opts.backlogCount : 0;
|
|
360
|
+
const line3 = _statePadRight(`Open: ${n} item${n === 1 ? '' : 's'} from audit`, STATE_CARD_CONTENT_WIDTH);
|
|
361
|
+
|
|
362
|
+
const lines = [topLine, `│ ${line1} │`, `│ ${line2} │`, `│ ${line3} │`];
|
|
363
|
+
|
|
364
|
+
if (opts.correctionReset && (
|
|
365
|
+
(opts.correctionReset.agents && opts.correctionReset.agents.length > 0) ||
|
|
366
|
+
(opts.correctionReset.files && opts.correctionReset.files.length > 0)
|
|
367
|
+
)) {
|
|
368
|
+
const agents = (opts.correctionReset.agents || []).join(', ');
|
|
369
|
+
const filesCount = (opts.correctionReset.files || []).length;
|
|
370
|
+
const resetDetail = [agents, filesCount > 0 ? `(+${filesCount} file${filesCount === 1 ? '' : 's'})` : '']
|
|
371
|
+
.filter(Boolean).join(' ') || 'n/a';
|
|
372
|
+
const resetLine = _statePadRight(`↺ Reset: ${resetDetail}`, STATE_CARD_CONTENT_WIDTH);
|
|
373
|
+
lines.push(`│ ${resetLine} │`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
lines.push(`└${'─'.repeat(STATE_CARD_TOTAL_WIDTH - 2)}┘`);
|
|
377
|
+
return lines.join('\n');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Collect state from session + git + qa report, then render the card via
|
|
382
|
+
* formatStateCard. Silently degrades when optional sources are missing
|
|
383
|
+
* (no git, no qa-visual report, etc.) — the card always renders.
|
|
384
|
+
* Exported for testing.
|
|
385
|
+
*
|
|
386
|
+
* @param {object} args
|
|
387
|
+
* @param {object} args.session - loaded session.yaml
|
|
388
|
+
* @param {string} args.projectDir
|
|
389
|
+
* @param {{agents?: string[], files?: string[]}|null} [args.correctionReset]
|
|
390
|
+
* @returns {string}
|
|
391
|
+
*/
|
|
392
|
+
export function buildStateCard({ session, projectDir, correctionReset = null } = {}) {
|
|
393
|
+
if (!session) return '';
|
|
394
|
+
|
|
395
|
+
const projectName = session.project?.name || 'unnamed project';
|
|
396
|
+
const agent = session.current_agent || 'none';
|
|
397
|
+
const agentStatus = session.agents?.[agent]?.status || 'pending';
|
|
398
|
+
const mode = session.execution_mode || 'interactive';
|
|
399
|
+
const cycle = typeof session.cycle === 'number' ? session.cycle : null;
|
|
400
|
+
|
|
401
|
+
// Last commit SHA (7 chars) — silent on non-git dirs
|
|
402
|
+
let commitShort = 'unknown';
|
|
403
|
+
if (projectDir) {
|
|
404
|
+
try {
|
|
405
|
+
const out = execSync('git log --pretty=%h -1 HEAD', {
|
|
406
|
+
cwd: projectDir, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
407
|
+
}).trim();
|
|
408
|
+
if (/^[a-f0-9]{7,40}$/.test(out)) commitShort = out.slice(0, 7);
|
|
409
|
+
} catch { /* not a repo / no commits */ }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// QA-Visual score — session.agents['qa-visual'].score takes priority; fallback to report.md.
|
|
413
|
+
let qaScore = null;
|
|
414
|
+
const qaAgentScore = session.agents?.['qa-visual']?.score;
|
|
415
|
+
if (typeof qaAgentScore === 'number' && qaAgentScore > 0) {
|
|
416
|
+
qaScore = qaAgentScore;
|
|
417
|
+
} else if (projectDir) {
|
|
418
|
+
const candidates = [
|
|
419
|
+
join(projectDir, 'artifacts', '9-QA-Implementation', 'qa-visual-report.md'),
|
|
420
|
+
join(projectDir, 'artifacts', '8-QA-Visual', 'visual-qa-analysis.md'),
|
|
421
|
+
];
|
|
422
|
+
for (const p of candidates) {
|
|
423
|
+
if (!existsSync(p)) continue;
|
|
424
|
+
try {
|
|
425
|
+
const txt = readFileSync(p, 'utf-8');
|
|
426
|
+
const m = txt.match(/(?:^|\n)\s*(?:##\s+)?Score:\s*(\d+(?:\.\d+)?)\s*%?/);
|
|
427
|
+
if (m) { qaScore = parseFloat(m[1]); break; }
|
|
428
|
+
} catch { /* ignore */ }
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const backlogCount = Array.isArray(session.backlog) ? session.backlog.length : 0;
|
|
433
|
+
|
|
434
|
+
return formatStateCard({
|
|
435
|
+
projectName, cycle, agent, agentStatus, mode, commitShort, qaScore, backlogCount, correctionReset,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Evaluate whether a shared CSS change requires blocking the qa-visual advance
|
|
441
|
+
* due to incomplete route coverage. Pure function (no git, no file-reading for
|
|
442
|
+
* modifiedFiles) — caller supplies `modifiedFiles`. Exported for testing.
|
|
443
|
+
*
|
|
444
|
+
* @param {object} opts
|
|
445
|
+
* @param {Array<{ path: string }>} opts.capturedPages - Pages present in report.json
|
|
446
|
+
* @param {string} opts.projectDir - For discoverAppRoutes
|
|
447
|
+
* @param {string[]} opts.modifiedFiles
|
|
448
|
+
* @returns {{ blocked: false } | { blocked: true, reason: string, triggeringFiles: string[], appRoutes: string[], uncovered: string[] }}
|
|
449
|
+
*/
|
|
450
|
+
export function checkSharedCSSCoverage({ capturedPages, projectDir, modifiedFiles }) {
|
|
451
|
+
if (!detectSharedCSSChanges(modifiedFiles)) return { blocked: false };
|
|
452
|
+
const appRoutes = discoverAppRoutes(projectDir);
|
|
453
|
+
if (!appRoutes || appRoutes.length === 0) return { blocked: false };
|
|
454
|
+
const capturedPaths = new Set((capturedPages || []).map(p => p.path));
|
|
455
|
+
const uncovered = appRoutes.filter(r => !capturedPaths.has(r));
|
|
456
|
+
if (uncovered.length === 0) return { blocked: false };
|
|
457
|
+
return {
|
|
458
|
+
blocked: true,
|
|
459
|
+
reason: 'shared_css_change_requires_full_coverage',
|
|
460
|
+
triggeringFiles: modifiedFiles.filter(f => SHARED_CSS_PATTERNS.some(p => p.test(f))),
|
|
461
|
+
appRoutes,
|
|
462
|
+
uncovered,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
55
466
|
// ---------------------------------------------------------------------------
|
|
56
467
|
// Helpers
|
|
57
468
|
// ---------------------------------------------------------------------------
|
|
@@ -113,6 +524,7 @@ function sessionToPipelineState(session, projectDir) {
|
|
|
113
524
|
currentAgent: session.current_agent || null,
|
|
114
525
|
modeTransitions: session.mode_transitions || [],
|
|
115
526
|
history: [],
|
|
527
|
+
correctionCycles: { ...(session.correction_cycles || {}) },
|
|
116
528
|
};
|
|
117
529
|
}
|
|
118
530
|
|
|
@@ -165,6 +577,7 @@ const AGENT_MODEL_MAP = {
|
|
|
165
577
|
'qa-planning': { provider: 'claude', model: 'opus', upgrade: 'no downgrade' },
|
|
166
578
|
dev: { provider: 'claude', model: 'opus', upgrade: 'no downgrade' },
|
|
167
579
|
'qa-implementation': { provider: 'claude', model: 'opus', upgrade: 'no downgrade' },
|
|
580
|
+
'qa-visual': { provider: 'claude', model: 'opus', upgrade: 'no downgrade' },
|
|
168
581
|
devops: { provider: 'claude', model: 'sonnet', upgrade: 'opus if multi-environment or IaC' },
|
|
169
582
|
};
|
|
170
583
|
|
|
@@ -177,7 +590,7 @@ function resolveAgentModel(agent, projectDir) {
|
|
|
177
590
|
|
|
178
591
|
// Check config.yaml for agent_overrides
|
|
179
592
|
try {
|
|
180
|
-
const configPath = join(projectDir,
|
|
593
|
+
const configPath = join(projectDir, resolveFrameworkDir(projectDir), 'config.yaml');
|
|
181
594
|
if (existsSync(configPath)) {
|
|
182
595
|
const configRaw = readFileSync(configPath, 'utf-8');
|
|
183
596
|
// Simple YAML parsing for agent_overrides section
|
|
@@ -207,33 +620,37 @@ function estimateContextBracket(completedCount, totalCount) {
|
|
|
207
620
|
/**
|
|
208
621
|
* Write session lock block to CLAUDE.local.md.
|
|
209
622
|
*/
|
|
210
|
-
function
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
content = readFileSync(localMdPath, 'utf-8');
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// Remove existing lock if present
|
|
218
|
-
const startIdx = content.indexOf(LOCK_START);
|
|
219
|
-
const endIdx = content.indexOf(LOCK_END);
|
|
623
|
+
function replaceBlock(content, startMarker, endMarker, newInner) {
|
|
624
|
+
const block = `${startMarker}\n${newInner}\n${endMarker}`;
|
|
625
|
+
const startIdx = content.indexOf(startMarker);
|
|
626
|
+
const endIdx = content.indexOf(endMarker);
|
|
220
627
|
if (startIdx !== -1 && endIdx !== -1) {
|
|
221
|
-
|
|
628
|
+
return content.slice(0, startIdx) + block + content.slice(endIdx + endMarker.length);
|
|
222
629
|
}
|
|
630
|
+
return content.trimEnd() + '\n\n' + block + '\n';
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function writeSessionLock(projectDir, currentAgent, stateInfo = {}) {
|
|
634
|
+
const localMdPath = join(projectDir, 'CLAUDE.local.md');
|
|
635
|
+
let content = existsSync(localMdPath) ? readFileSync(localMdPath, 'utf-8') : '';
|
|
223
636
|
|
|
224
|
-
const
|
|
225
|
-
## Session Lock -- ACTIVE
|
|
637
|
+
const lockInner = `## Session Lock -- ACTIVE
|
|
226
638
|
|
|
227
639
|
**Chati.dev session is ACTIVE.** Follow these rules for EVERY message:
|
|
228
640
|
|
|
229
|
-
1. Read
|
|
641
|
+
1. Read \`${resolveFrameworkDir(projectDir)}/orchestrator/chati.md\` and follow its routing logic
|
|
230
642
|
2. Route ALL user messages through the current agent: \`${currentAgent}\`
|
|
231
643
|
3. NEVER respond outside of the Chati.dev system
|
|
232
|
-
4. The ONLY way to exit is via \`/chati exit\`, \`/chati stop\`, or \`/chati quit
|
|
233
|
-
|
|
234
|
-
|
|
644
|
+
4. The ONLY way to exit is via \`/chati exit\`, \`/chati stop\`, or \`/chati quit\``;
|
|
645
|
+
|
|
646
|
+
const stateInner = `## Current State
|
|
647
|
+
- **Agent**: ${currentAgent || 'None'}
|
|
648
|
+
- **Phase**: ${stateInfo.phase || 'discover'}
|
|
649
|
+
- **Pipeline**: ${stateInfo.position ?? 0}/${stateInfo.total ?? '?'} (${stateInfo.progress ?? 0}%)
|
|
650
|
+
- **Mode**: ${stateInfo.mode || 'interactive'}`;
|
|
235
651
|
|
|
236
|
-
content = content
|
|
652
|
+
content = replaceBlock(content, LOCK_START, LOCK_END, lockInner);
|
|
653
|
+
content = replaceBlock(content, STATE_START, STATE_END, stateInner);
|
|
237
654
|
writeFileSync(localMdPath, content, 'utf-8');
|
|
238
655
|
}
|
|
239
656
|
|
|
@@ -246,11 +663,15 @@ function removeSessionLock(projectDir, resumeMsg) {
|
|
|
246
663
|
|
|
247
664
|
let content = readFileSync(localMdPath, 'utf-8');
|
|
248
665
|
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
666
|
+
const lockInner = `## Session Lock
|
|
667
|
+
**Status: INACTIVE** — Type \`/chati\` to activate.`;
|
|
668
|
+
const stateInner = `## Current State
|
|
669
|
+
- **Agent**: None (ready to start)
|
|
670
|
+
- **Pipeline**: Pre-start
|
|
671
|
+
- **Mode**: interactive`;
|
|
672
|
+
|
|
673
|
+
content = replaceBlock(content, LOCK_START, LOCK_END, lockInner);
|
|
674
|
+
content = replaceBlock(content, STATE_START, STATE_END, stateInner);
|
|
254
675
|
|
|
255
676
|
if (resumeMsg) {
|
|
256
677
|
content = content.trimEnd() + `\n\n## Session Paused\n\n${resumeMsg}\n`;
|
|
@@ -264,6 +685,48 @@ function removeSessionLock(projectDir, resumeMsg) {
|
|
|
264
685
|
// ---------------------------------------------------------------------------
|
|
265
686
|
|
|
266
687
|
async function handleNext(projectDir) {
|
|
688
|
+
const result = await _handleNextInner(projectDir);
|
|
689
|
+
|
|
690
|
+
// Fase 8: attach state-at-a-glance card to every non-error response.
|
|
691
|
+
// The card renders as a JSON string field; the terminal / orchestrator
|
|
692
|
+
// prompt can console.log it verbatim. Silent on setup/error responses
|
|
693
|
+
// so a missing session does not produce a misleading card.
|
|
694
|
+
if (result && result.action !== 'setup' && result.action !== 'error') {
|
|
695
|
+
try {
|
|
696
|
+
const { session } = loadSession(projectDir);
|
|
697
|
+
if (session) {
|
|
698
|
+
result.state_card = buildStateCard({ session, projectDir });
|
|
699
|
+
}
|
|
700
|
+
} catch { /* non-fatal — card is UX sugar */ }
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Centralized session lock update: applies to ALL return paths.
|
|
704
|
+
// Activates lock when an agent is returned, deactivates on completion.
|
|
705
|
+
if (result && result.agent && result.action !== 'error' && result.action !== 'setup') {
|
|
706
|
+
const activating = ['activate_interactive', 'spawn_autonomous', 'spawn_team', 'spawn_parallel'];
|
|
707
|
+
if (activating.includes(result.action)) {
|
|
708
|
+
try {
|
|
709
|
+
writeSessionLock(projectDir, result.agent, {
|
|
710
|
+
phase: result.phase || 'discover',
|
|
711
|
+
mode: result.session?.execution_mode || 'interactive',
|
|
712
|
+
position: result.pipeline_progress?.completedAgents?.length ?? 0,
|
|
713
|
+
total: AGENT_PIPELINE.length,
|
|
714
|
+
progress: result.pipeline_progress?.progress ?? 0,
|
|
715
|
+
});
|
|
716
|
+
} catch { /* non-fatal */ }
|
|
717
|
+
}
|
|
718
|
+
} else if (result && result.action === 'complete') {
|
|
719
|
+
try {
|
|
720
|
+
const { session } = loadSession(projectDir);
|
|
721
|
+
const lang = session?.language || 'en';
|
|
722
|
+
removeSessionLock(projectDir, RESUME_MESSAGES[lang] || RESUME_MESSAGES.en);
|
|
723
|
+
} catch { /* non-fatal */ }
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
return result;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
async function _handleNextInner(projectDir) {
|
|
267
730
|
const { loaded, session } = loadSession(projectDir);
|
|
268
731
|
if (!loaded || !session) {
|
|
269
732
|
return { action: 'setup', status_summary: 'No session found. Project needs initialization.' };
|
|
@@ -291,11 +754,37 @@ async function handleNext(projectDir) {
|
|
|
291
754
|
if (currentAgent && !completedAgents.includes(currentAgent)) {
|
|
292
755
|
const agent = currentAgent;
|
|
293
756
|
const agentStatus = session.agents?.[agent]?.status;
|
|
294
|
-
if (agentStatus === 'in_progress'
|
|
295
|
-
const agentFile =
|
|
757
|
+
if (agentStatus === 'in_progress') {
|
|
758
|
+
const agentFile = getAgentFile(agent, projectDir) || null;
|
|
296
759
|
const agentDef = getAgentDefinition(agent);
|
|
297
760
|
const isInteractive = INTERACTIVE_AGENTS.includes(agent);
|
|
298
761
|
|
|
762
|
+
// Build Team override: even when resuming, if agent is 'dev' and teams
|
|
763
|
+
// enabled, return spawn_team so Dev + QA-Implementation run together.
|
|
764
|
+
if (agent === 'dev' && isAgentTeamsEnabled(projectDir)) {
|
|
765
|
+
const teamConfig = TEAM_CONFIGS.build;
|
|
766
|
+
const teamId = generateTeamId(teamConfig.slug);
|
|
767
|
+
return {
|
|
768
|
+
action: 'spawn_team',
|
|
769
|
+
agent,
|
|
770
|
+
agent_file: agentFile,
|
|
771
|
+
phase: agentDef?.phase || session.mode,
|
|
772
|
+
spawn_command: null,
|
|
773
|
+
parallel_spawn_command: null,
|
|
774
|
+
parallel_agents: teamConfig.members,
|
|
775
|
+
handoff_status: { valid: true, missing: [], warnings: ['Resuming dev as Build Team'] },
|
|
776
|
+
gate_status: { canAdvance: true, reason: 'Build Team formation (Article XXI)' },
|
|
777
|
+
model_info: resolveAgentModel(agent, projectDir),
|
|
778
|
+
context_bracket: estimateContextBracket(completedAgents.length, AGENT_PIPELINE.length),
|
|
779
|
+
pipeline_progress: getPipelineProgress(pipelineState),
|
|
780
|
+
session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode, user_level: session.user_level || 'auto' },
|
|
781
|
+
status_summary: `Forming Build Team: dev + qa-implementation.`,
|
|
782
|
+
team_id: teamId,
|
|
783
|
+
team_type: 'build',
|
|
784
|
+
members: teamConfig.members,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
|
|
299
788
|
return {
|
|
300
789
|
action: isInteractive ? 'activate_interactive' : 'spawn_autonomous',
|
|
301
790
|
agent,
|
|
@@ -322,7 +811,7 @@ async function handleNext(projectDir) {
|
|
|
322
811
|
if (!lastAgent || completedAgents.length === 0) {
|
|
323
812
|
const projectType = session.project_type || session.project?.type || 'greenfield';
|
|
324
813
|
const firstAgent = projectType === 'brownfield' ? 'brownfield-wu' : 'greenfield-wu';
|
|
325
|
-
const agentFile =
|
|
814
|
+
const agentFile = getAgentFile(firstAgent, projectDir) || null;
|
|
326
815
|
|
|
327
816
|
return {
|
|
328
817
|
action: 'activate_interactive',
|
|
@@ -342,8 +831,24 @@ async function handleNext(projectDir) {
|
|
|
342
831
|
};
|
|
343
832
|
}
|
|
344
833
|
|
|
345
|
-
// Get next agent
|
|
346
|
-
|
|
834
|
+
// Get next agent — if lastAgent is not in AGENT_PIPELINE (e.g. qa-planning
|
|
835
|
+
// which lives inside Planning Team), fall back to the last completed agent
|
|
836
|
+
// that IS in the pipeline so we can find the correct successor.
|
|
837
|
+
let nextInfo = getNextAgent(lastAgent, completedAgents);
|
|
838
|
+
|
|
839
|
+
if ((!nextInfo || !nextInfo.next) && lastAgent) {
|
|
840
|
+
const agentInPipeline = getAgentDefinition(lastAgent);
|
|
841
|
+
if (!agentInPipeline) {
|
|
842
|
+
// lastAgent is not in AGENT_PIPELINE — find the last completed agent that IS
|
|
843
|
+
const pipelineNames = AGENT_PIPELINE.map((a) => a.name);
|
|
844
|
+
for (let i = completedAgents.length - 1; i >= 0; i--) {
|
|
845
|
+
if (pipelineNames.includes(completedAgents[i])) {
|
|
846
|
+
nextInfo = getNextAgent(completedAgents[i], completedAgents);
|
|
847
|
+
if (nextInfo && nextInfo.next) break;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
347
852
|
|
|
348
853
|
if (!nextInfo || !nextInfo.next) {
|
|
349
854
|
return {
|
|
@@ -355,7 +860,7 @@ async function handleNext(projectDir) {
|
|
|
355
860
|
}
|
|
356
861
|
|
|
357
862
|
const nextAgent = nextInfo.next;
|
|
358
|
-
const agentFile =
|
|
863
|
+
const agentFile = getAgentFile(nextAgent, projectDir) || null;
|
|
359
864
|
const agentDef = getAgentDefinition(nextAgent);
|
|
360
865
|
const isInteractive = INTERACTIVE_AGENTS.includes(nextAgent);
|
|
361
866
|
|
|
@@ -415,6 +920,9 @@ async function handleNext(projectDir) {
|
|
|
415
920
|
spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider, 600000);
|
|
416
921
|
}
|
|
417
922
|
|
|
923
|
+
// Record agent activation event
|
|
924
|
+
recordEvent(projectDir, EventType.AGENT_ACTIVATED, nextAgent, { action, phase: agentDef?.phase });
|
|
925
|
+
|
|
418
926
|
const progress = getPipelineProgress(pipelineState);
|
|
419
927
|
const bracket = estimateContextBracket(completedAgents.length, AGENT_PIPELINE.length);
|
|
420
928
|
|
|
@@ -445,6 +953,262 @@ async function handleNext(projectDir) {
|
|
|
445
953
|
return result;
|
|
446
954
|
}
|
|
447
955
|
|
|
956
|
+
// ---------------------------------------------------------------------------
|
|
957
|
+
// Fase 10 — Scaffold auto-gate helpers
|
|
958
|
+
//
|
|
959
|
+
// Problem: after qa-planning approves the plan, the framework knows (from
|
|
960
|
+
// greenfield-wu's scaffold_candidates + ux-brand-architect's scaffold_signals)
|
|
961
|
+
// whether a scaffold preset like motion-premium would save the Build phase
|
|
962
|
+
// from reinventing animation wiring by hand. Without a deterministic gate,
|
|
963
|
+
// the orchestrator must rely on Claude remembering to offer the scaffold —
|
|
964
|
+
// and Claude forgets. This gate turns remembering into code.
|
|
965
|
+
//
|
|
966
|
+
// A preset "fires" when:
|
|
967
|
+
// 1. It is in session.scaffold_candidates (stack match, Signal 1).
|
|
968
|
+
// 2. It has a signal with confidence >= 0.7 (premium intent, Signal 2).
|
|
969
|
+
// 3. It is NOT yet in session.scaffold_applied (idempotency).
|
|
970
|
+
// ---------------------------------------------------------------------------
|
|
971
|
+
|
|
972
|
+
const SCAFFOLD_CONFIDENCE_THRESHOLD = 0.7;
|
|
973
|
+
|
|
974
|
+
// Per-preset threshold overrides. ADR-3D-07 sets motion-premium-3d to 0.8
|
|
975
|
+
// (higher than motion-premium's 0.7) — false positives cost ~600KB of
|
|
976
|
+
// bundle pressed onto a project that did not need 3D. New presets added
|
|
977
|
+
// here; any preset not in the map inherits SCAFFOLD_CONFIDENCE_THRESHOLD.
|
|
978
|
+
const PRESET_CONFIDENCE_THRESHOLDS = {
|
|
979
|
+
'motion-premium': 0.7,
|
|
980
|
+
'motion-premium-3d': 0.8,
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
function thresholdFor(preset) {
|
|
984
|
+
return PRESET_CONFIDENCE_THRESHOLDS[preset] ?? SCAFFOLD_CONFIDENCE_THRESHOLD;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Evaluate which (if any) scaffold preset's auto-gate should fire.
|
|
989
|
+
* Enumerates candidates in session order; first preset whose signal
|
|
990
|
+
* confidence meets its per-preset threshold AND is not already applied
|
|
991
|
+
* wins. When a user applies or skips one preset, the next advance call
|
|
992
|
+
* re-evaluates — letting a project with BOTH motion-premium and
|
|
993
|
+
* motion-premium-3d signals receive both prompts in sequence.
|
|
994
|
+
*
|
|
995
|
+
* @param {object} session - Post-completion session
|
|
996
|
+
* @returns {{ fires: boolean, preset: string|null, signalKey: string|null, confidence: number, threshold: number, evidence: string[], reason: string|null }}
|
|
997
|
+
*/
|
|
998
|
+
function checkScaffoldGate(session) {
|
|
999
|
+
const candidates = Array.isArray(session?.scaffold_candidates) ? session.scaffold_candidates : [];
|
|
1000
|
+
const signals = (session?.scaffold_signals && typeof session.scaffold_signals === 'object') ? session.scaffold_signals : {};
|
|
1001
|
+
const applied = new Set(Array.isArray(session?.scaffold_applied) ? session.scaffold_applied : []);
|
|
1002
|
+
|
|
1003
|
+
for (const preset of candidates) {
|
|
1004
|
+
if (applied.has(preset)) continue;
|
|
1005
|
+
const key = preset.replace(/-/g, '_');
|
|
1006
|
+
const signal = signals[key];
|
|
1007
|
+
if (!signal) continue;
|
|
1008
|
+
const confidence = typeof signal.confidence === 'number' ? signal.confidence : 0;
|
|
1009
|
+
const threshold = thresholdFor(preset);
|
|
1010
|
+
if (confidence < threshold) continue;
|
|
1011
|
+
return {
|
|
1012
|
+
fires: true,
|
|
1013
|
+
preset,
|
|
1014
|
+
signalKey: key,
|
|
1015
|
+
confidence,
|
|
1016
|
+
threshold,
|
|
1017
|
+
evidence: Array.isArray(signal.evidence) ? signal.evidence : [],
|
|
1018
|
+
reason: 'scaffold_decision_required',
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
return { fires: false, preset: null, signalKey: null, confidence: 0, threshold: SCAFFOLD_CONFIDENCE_THRESHOLD, evidence: [], reason: null };
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Build the action_required payload the orchestrator presents to the user
|
|
1027
|
+
* when a scaffold gate fires.
|
|
1028
|
+
*/
|
|
1029
|
+
function buildScaffoldGateResponse(gate, { agent, score, phaseTransition }) {
|
|
1030
|
+
return {
|
|
1031
|
+
advanced: true,
|
|
1032
|
+
agent_completed: agent,
|
|
1033
|
+
score,
|
|
1034
|
+
phase_transition: phaseTransition || { triggered: false },
|
|
1035
|
+
scaffold_decision_required: true,
|
|
1036
|
+
scaffold_gate: {
|
|
1037
|
+
preset: gate.preset,
|
|
1038
|
+
confidence: gate.confidence,
|
|
1039
|
+
evidence: gate.evidence,
|
|
1040
|
+
},
|
|
1041
|
+
next: {
|
|
1042
|
+
action: 'scaffold_decision',
|
|
1043
|
+
agent: null,
|
|
1044
|
+
pending_preset: gate.preset,
|
|
1045
|
+
confidence: gate.confidence,
|
|
1046
|
+
status_summary: `Planning approved (qa-planning score ${score}). Scaffold "${gate.preset}" is eligible (confidence ${gate.confidence.toFixed(2)}). Present three options to the user before advancing to dev.`,
|
|
1047
|
+
options: [
|
|
1048
|
+
{
|
|
1049
|
+
id: 'apply',
|
|
1050
|
+
label: `Apply ${gate.preset} scaffold (15 files, Premium-tier animation wiring)`,
|
|
1051
|
+
command: `advance --agent qa-planning --score ${score} --decision apply`,
|
|
1052
|
+
description: 'Writes the scaffold templates to the project. Recommended — the Animation Inventory maps 1:1 to these files.',
|
|
1053
|
+
},
|
|
1054
|
+
{
|
|
1055
|
+
id: 'dryrun',
|
|
1056
|
+
label: `Preview ${gate.preset} scaffold (list files, write nothing)`,
|
|
1057
|
+
command: `advance --agent qa-planning --score ${score} --decision dryrun`,
|
|
1058
|
+
description: 'Shows what would be written. Run this first if you want to see the diff before committing.',
|
|
1059
|
+
},
|
|
1060
|
+
{
|
|
1061
|
+
id: 'skip',
|
|
1062
|
+
label: `Skip ${gate.preset} (proceed to dev without scaffold)`,
|
|
1063
|
+
command: `advance --agent qa-planning --score ${score} --decision skip`,
|
|
1064
|
+
description: 'Dev will have to implement each Animation Inventory row from scratch. Choose this only if the project cannot use the scaffold (e.g. alternative animation stack).',
|
|
1065
|
+
},
|
|
1066
|
+
],
|
|
1067
|
+
},
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Execute a scaffold decision (apply|skip|dryrun), update session, and
|
|
1073
|
+
* advance the pipeline from qa-planning to dev. Reuses applyScaffold from
|
|
1074
|
+
* the installer module (Fase 4) so the programmatic and CLI-wrapped paths
|
|
1075
|
+
* share one implementation.
|
|
1076
|
+
*
|
|
1077
|
+
* @param {string} projectDir
|
|
1078
|
+
* @param {object} session - Pre-decision session (qa-planning already completed)
|
|
1079
|
+
* @param {'apply'|'skip'|'dryrun'} decision
|
|
1080
|
+
* @param {string} agent - Should be 'qa-planning' when gate fired there.
|
|
1081
|
+
* @param {number} score
|
|
1082
|
+
*/
|
|
1083
|
+
async function handleScaffoldDecision(projectDir, session, decision, agent, score) {
|
|
1084
|
+
const gate = checkScaffoldGate(session);
|
|
1085
|
+
if (!gate.fires) {
|
|
1086
|
+
return errorResult(
|
|
1087
|
+
'No scaffold decision pending (no eligible candidate with confidence >= 0.7 and not already applied).',
|
|
1088
|
+
'NO_SCAFFOLD_PENDING',
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const preset = gate.preset;
|
|
1093
|
+
let scaffoldResult = null;
|
|
1094
|
+
|
|
1095
|
+
if (decision === 'apply' || decision === 'dryrun') {
|
|
1096
|
+
let scaffoldSourceDir;
|
|
1097
|
+
try {
|
|
1098
|
+
scaffoldSourceDir = resolveScaffoldSource(projectDir);
|
|
1099
|
+
} catch (err) {
|
|
1100
|
+
return errorResult(
|
|
1101
|
+
`Cannot locate scaffold source directory. ${err.message}`,
|
|
1102
|
+
'SCAFFOLD_SOURCE_MISSING',
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
try {
|
|
1106
|
+
// Sanity-check the preset exists before invoking applyScaffold.
|
|
1107
|
+
loadScaffoldManifest(scaffoldSourceDir, preset);
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
return errorResult(
|
|
1110
|
+
`Scaffold preset "${preset}" not found at ${scaffoldSourceDir}: ${err.message}`,
|
|
1111
|
+
'SCAFFOLD_PRESET_MISSING',
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
scaffoldResult = applyScaffold({
|
|
1115
|
+
projectDir,
|
|
1116
|
+
scaffoldSourceDir,
|
|
1117
|
+
preset,
|
|
1118
|
+
dryRun: decision === 'dryrun',
|
|
1119
|
+
force: false,
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// On 'apply' and 'skip', record the preset as resolved so the gate does
|
|
1124
|
+
// not re-fire on a future advance. On 'dryrun', leave it pending.
|
|
1125
|
+
if (decision === 'apply' || decision === 'skip') {
|
|
1126
|
+
const appliedList = Array.isArray(session.scaffold_applied) ? [...session.scaffold_applied] : [];
|
|
1127
|
+
if (!appliedList.includes(preset)) appliedList.push(preset);
|
|
1128
|
+
await updateSession(projectDir, { scaffold_applied: appliedList });
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// Dry-run: return info, leave pipeline at qa-planning — user must re-run
|
|
1132
|
+
// advance with --decision apply|skip to actually proceed.
|
|
1133
|
+
if (decision === 'dryrun') {
|
|
1134
|
+
return {
|
|
1135
|
+
advanced: false,
|
|
1136
|
+
agent_completed: agent,
|
|
1137
|
+
score,
|
|
1138
|
+
scaffold_decision: decision,
|
|
1139
|
+
scaffold: {
|
|
1140
|
+
preset: scaffoldResult.preset,
|
|
1141
|
+
version: scaffoldResult.version,
|
|
1142
|
+
would_apply: scaffoldResult.applied,
|
|
1143
|
+
would_skip: scaffoldResult.skipped,
|
|
1144
|
+
placeholders: scaffoldResult.placeholders,
|
|
1145
|
+
brandSource: scaffoldResult.brandSource,
|
|
1146
|
+
},
|
|
1147
|
+
next: {
|
|
1148
|
+
action: 'scaffold_decision_pending',
|
|
1149
|
+
agent: null,
|
|
1150
|
+
status_summary: `Dry-run complete for "${preset}". Re-run advance with --decision apply to write the files, or --decision skip to proceed without the scaffold.`,
|
|
1151
|
+
},
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// apply / skip: advance qa-planning → dev.
|
|
1156
|
+
const { session: postDecision } = loadSession(projectDir);
|
|
1157
|
+
const pipelineState = sessionToPipelineState(postDecision || session, projectDir);
|
|
1158
|
+
pipelineState.completedAgents = [...(postDecision?.completed_agents || session.completed_agents || [])];
|
|
1159
|
+
const advanceResult = advancePipeline(pipelineState, agent, { score, handoffData: {}, findings: [] });
|
|
1160
|
+
|
|
1161
|
+
const updates = {
|
|
1162
|
+
current_agent: advanceResult.nextAgent || '',
|
|
1163
|
+
last_handoff: agent,
|
|
1164
|
+
pipeline_position: pipelineState.completedAgents.length,
|
|
1165
|
+
};
|
|
1166
|
+
if (advanceResult.needsModeSwitch) {
|
|
1167
|
+
const newPhase = advanceResult.state?.phase || session.mode;
|
|
1168
|
+
updates.mode = newPhase;
|
|
1169
|
+
if (session.project) updates.project = { ...session.project, state: newPhase };
|
|
1170
|
+
await recordModeTransition(projectDir, {
|
|
1171
|
+
from: session.mode, to: newPhase,
|
|
1172
|
+
trigger: `scaffold_decision=${decision} on ${preset}`,
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
await updateSession(projectDir, updates);
|
|
1176
|
+
|
|
1177
|
+
const nextAgent = advanceResult.nextAgent || '';
|
|
1178
|
+
const progress = getPipelineProgress(advanceResult.state || pipelineState);
|
|
1179
|
+
try { updateClaudeMd(projectDir, { currentAgent: nextAgent, progress }); } catch { /* non-fatal */ }
|
|
1180
|
+
try {
|
|
1181
|
+
if (nextAgent) {
|
|
1182
|
+
writeSessionLock(projectDir, nextAgent, {
|
|
1183
|
+
phase: updates.mode || session.mode,
|
|
1184
|
+
mode: session.execution_mode || 'interactive',
|
|
1185
|
+
position: updates.pipeline_position,
|
|
1186
|
+
total: progress.total,
|
|
1187
|
+
progress: progress.percent,
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
} catch { /* non-fatal */ }
|
|
1191
|
+
|
|
1192
|
+
const next = await handleNext(projectDir);
|
|
1193
|
+
return {
|
|
1194
|
+
advanced: true,
|
|
1195
|
+
agent_completed: agent,
|
|
1196
|
+
score,
|
|
1197
|
+
scaffold_decision: decision,
|
|
1198
|
+
scaffold: scaffoldResult
|
|
1199
|
+
? {
|
|
1200
|
+
preset: scaffoldResult.preset,
|
|
1201
|
+
version: scaffoldResult.version,
|
|
1202
|
+
applied: scaffoldResult.applied,
|
|
1203
|
+
skipped: scaffoldResult.skipped,
|
|
1204
|
+
manifestPath: scaffoldResult.manifestPath,
|
|
1205
|
+
}
|
|
1206
|
+
: { preset, skipped_by_user: true },
|
|
1207
|
+
phase_transition: advanceResult.needsModeSwitch ? { triggered: true, from: session.mode, to: updates.mode } : { triggered: false },
|
|
1208
|
+
next,
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
|
|
448
1212
|
async function handleAdvance(projectDir, args) {
|
|
449
1213
|
const agent = args.agent;
|
|
450
1214
|
const score = parseInt(args.score, 10);
|
|
@@ -457,6 +1221,33 @@ async function handleAdvance(projectDir, args) {
|
|
|
457
1221
|
const { loaded, session } = loadSession(projectDir);
|
|
458
1222
|
if (!loaded || !session) return errorResult('No session found', 'NO_SESSION');
|
|
459
1223
|
|
|
1224
|
+
// Fase 10 scaffold decision — route apply|skip|dryrun to the scaffold
|
|
1225
|
+
// dispatcher BEFORE the alreadyCompleted guard so the user can resolve a
|
|
1226
|
+
// pending scaffold decision after qa-planning is marked complete.
|
|
1227
|
+
const SCAFFOLD_DECISIONS = new Set(['apply', 'skip', 'dryrun']);
|
|
1228
|
+
if (decision && SCAFFOLD_DECISIONS.has(decision)) {
|
|
1229
|
+
return handleScaffoldDecision(projectDir, session, decision, agent, score);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// Idempotency guard: if this agent is already marked completed, return no-op.
|
|
1233
|
+
// Prevents duplicate advance calls (hook + user-approval path) from double-
|
|
1234
|
+
// incrementing pipeline_position or corrupting completed_agents.
|
|
1235
|
+
const agentState = session.agents && session.agents[agent];
|
|
1236
|
+
const alreadyCompleted =
|
|
1237
|
+
agentState && agentState.status === 'completed' &&
|
|
1238
|
+
Array.isArray(session.completed_agents) && session.completed_agents.includes(agent);
|
|
1239
|
+
if (alreadyCompleted && !decision) {
|
|
1240
|
+
const next = await handleNext(projectDir);
|
|
1241
|
+
return {
|
|
1242
|
+
advanced: false,
|
|
1243
|
+
already_advanced: true,
|
|
1244
|
+
agent_completed: agent,
|
|
1245
|
+
score: agentState.score,
|
|
1246
|
+
next,
|
|
1247
|
+
status_summary: `${agent} already completed (score ${agentState.score}). Next: ${next.agent || 'none'}.`,
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
|
|
460
1251
|
// Handle user_preview decisions
|
|
461
1252
|
if (decision) {
|
|
462
1253
|
const pipelineState = sessionToPipelineState(session, projectDir);
|
|
@@ -479,22 +1270,508 @@ async function handleAdvance(projectDir, args) {
|
|
|
479
1270
|
return { advanced: true, agent_completed: agent, score, decision, phase_transition: { triggered: previewResult.needsModeSwitch }, next };
|
|
480
1271
|
}
|
|
481
1272
|
|
|
482
|
-
//
|
|
483
|
-
|
|
1273
|
+
// Read agent handoff document to extract Decision Trail entries.
|
|
1274
|
+
// Agents (e.g. qa-implementation) write Decision Trail entries to their handoff
|
|
1275
|
+
// instead of directly editing session.yaml (blocked by mode-governance hook).
|
|
1276
|
+
let handoffData = {};
|
|
1277
|
+
// Handoff files live at <frameworkDir>/artifacts/handoffs/<agent>-handoff.md
|
|
1278
|
+
// EXCEPT when artifacts/ is user-facing (non-framework). Currently install
|
|
1279
|
+
// writes artifacts to project root <projectDir>/artifacts/. Check both locations
|
|
1280
|
+
// to cover legacy + current layouts.
|
|
1281
|
+
const fwDir = resolveFrameworkDir(projectDir);
|
|
1282
|
+
const handoffCandidates = [
|
|
1283
|
+
join(projectDir, 'artifacts', 'handoffs', `${agent}-handoff.md`),
|
|
1284
|
+
join(projectDir, fwDir, 'artifacts', 'handoffs', `${agent}-handoff.md`),
|
|
1285
|
+
];
|
|
1286
|
+
const handoffPath = handoffCandidates.find(p => existsSync(p));
|
|
1287
|
+
if (handoffPath) {
|
|
1288
|
+
try {
|
|
1289
|
+
const handoffMarkdown = readFileSync(handoffPath, 'utf-8');
|
|
1290
|
+
handoffData = parseHandoffDecisionTrail(handoffMarkdown);
|
|
1291
|
+
// Fase 10 auto-gate — parse scaffold candidates/signals alongside Decision Trail.
|
|
1292
|
+
const scaffold = parseHandoffScaffoldSignals(handoffMarkdown);
|
|
1293
|
+
handoffData.scaffold_candidates = scaffold.scaffold_candidates;
|
|
1294
|
+
handoffData.scaffold_signals = scaffold.scaffold_signals;
|
|
1295
|
+
} catch {
|
|
1296
|
+
// Non-fatal: if handoff can't be read, skip Decision Trail / scaffold merge
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
484
1299
|
|
|
485
|
-
//
|
|
486
|
-
|
|
487
|
-
|
|
1300
|
+
// Deterministic gates: block advance if required artifacts are missing.
|
|
1301
|
+
|
|
1302
|
+
// BRIEF gate: reference frames captured → Visualizer MUST run for ALL sites before Brief advances.
|
|
1303
|
+
if (agent === 'brief') {
|
|
1304
|
+
const refsDir = join(projectDir, 'artifacts', '4-UX', 'references');
|
|
1305
|
+
const completePath = join(refsDir, 'capture-complete.json');
|
|
1306
|
+
const analysisPath = join(projectDir, 'artifacts', '4-UX', 'reference-analysis.md');
|
|
1307
|
+
|
|
1308
|
+
if (existsSync(completePath)) {
|
|
1309
|
+
let captureData = {};
|
|
1310
|
+
try { captureData = JSON.parse(readFileSync(completePath, 'utf-8')); } catch { /* ignore */ }
|
|
1311
|
+
const okSites = Object.entries(captureData.sites || {})
|
|
1312
|
+
.filter(([, s]) => s.status === 'ok')
|
|
1313
|
+
.map(([slug, s]) => ({ slug, url: s.url, desktopFrames: `artifacts/4-UX/references/${slug}/desktop/frames/`, mobileFrames: `artifacts/4-UX/references/${slug}/mobile/frames/` }));
|
|
1314
|
+
|
|
1315
|
+
if (okSites.length > 0) {
|
|
1316
|
+
// Check: consolidated file must mention ALL sites, OR every site has its own analysis.md
|
|
1317
|
+
let allAnalyzed = false;
|
|
1318
|
+
if (existsSync(analysisPath)) {
|
|
1319
|
+
try {
|
|
1320
|
+
const content = readFileSync(analysisPath, 'utf-8');
|
|
1321
|
+
allAnalyzed = okSites.every(s => content.includes(s.slug) || content.includes(s.url));
|
|
1322
|
+
} catch { /* ignore */ }
|
|
1323
|
+
}
|
|
1324
|
+
if (!allAnalyzed) {
|
|
1325
|
+
const missingSites = okSites.filter(s => !existsSync(join(refsDir, s.slug, 'analysis.md')));
|
|
1326
|
+
allAnalyzed = missingSites.length === 0;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
if (!allAnalyzed) {
|
|
1330
|
+
// Find which sites still need analysis
|
|
1331
|
+
const needsAnalysis = okSites.filter(s => {
|
|
1332
|
+
if (existsSync(join(refsDir, s.slug, 'analysis.md'))) return false;
|
|
1333
|
+
if (existsSync(analysisPath)) {
|
|
1334
|
+
try {
|
|
1335
|
+
const content = readFileSync(analysisPath, 'utf-8');
|
|
1336
|
+
return !(content.includes(s.slug) || content.includes(s.url));
|
|
1337
|
+
} catch { return true; }
|
|
1338
|
+
}
|
|
1339
|
+
return true;
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
return {
|
|
1343
|
+
advanced: false,
|
|
1344
|
+
blocked: true,
|
|
1345
|
+
reason: 'visual_analysis_required',
|
|
1346
|
+
message: `${needsAnalysis.length} of ${okSites.length} site(s) still need visual analysis. Spawn ONE Visualizer per missing site (parallel).`,
|
|
1347
|
+
already_analyzed: okSites.length - needsAnalysis.length,
|
|
1348
|
+
action_required: {
|
|
1349
|
+
step1: 'Display to user (translate to session.language): "Analyzing the reference sites you provided — scroll experience, animations, colors, and typography in depth..."',
|
|
1350
|
+
step2: `Spawn ${needsAnalysis.length} Agent(s) IN PARALLEL (one per site, each with model: sonnet):`,
|
|
1351
|
+
agents: needsAnalysis.map(s => ({
|
|
1352
|
+
description: `Visualizer — ${s.slug} scroll analysis`,
|
|
1353
|
+
model: 'sonnet',
|
|
1354
|
+
prompt: `Read and follow .chati.dev/agents/shared/visualizer.md (Mode 1: Reference Analysis). Analyze ONE site only: ${s.slug} (${s.url}). Read ALL frames in ${s.desktopFrames} and ${s.mobileFrames}. Read artifacts/4-UX/references/${s.slug}/extracted-tokens.json. Save output to artifacts/4-UX/references/${s.slug}/analysis.md`,
|
|
1355
|
+
})),
|
|
1356
|
+
step3: 'Wait for ALL to return. Merge ALL per-site analysis.md files into artifacts/4-UX/reference-analysis.md',
|
|
1357
|
+
step4: 'Append Visual Experience Analysis summary to brief-report.md',
|
|
1358
|
+
step5: 'Present the UPDATED brief to the user with Completion Options. Do NOT advance — wait for user approval.',
|
|
1359
|
+
},
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
// okSites.length === 0 means all captures failed → allow advance, UX falls back to text
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// If capture not yet complete, return immediately — don't block advance for 10 min.
|
|
1367
|
+
// Tell Claude to call wait-for-capture separately, then re-try advance.
|
|
1368
|
+
if (!existsSync(join(refsDir, 'capture-complete.json'))) {
|
|
1369
|
+
try {
|
|
1370
|
+
const dirs = readdirSync(refsDir).filter(d => {
|
|
1371
|
+
try { return existsSync(join(refsDir, d, 'desktop')); } catch { return false; }
|
|
1372
|
+
});
|
|
1373
|
+
if (dirs.length > 0) {
|
|
1374
|
+
return {
|
|
1375
|
+
advanced: false,
|
|
1376
|
+
blocked: true,
|
|
1377
|
+
reason: 'capture_in_progress',
|
|
1378
|
+
message: 'Reference capture still running in background. Wait for it, then spawn Visualizer, then present options to user.',
|
|
1379
|
+
action_required: {
|
|
1380
|
+
step1: 'Run: node .chati.dev/orchestrator/chati-router.js wait-for-capture',
|
|
1381
|
+
step2: 'Then spawn Visualizer to analyze frames (save to artifacts/4-UX/reference-analysis.md)',
|
|
1382
|
+
step3: 'Append Visual Experience Analysis summary to brief-report.md',
|
|
1383
|
+
step4: 'Present the UPDATED brief to the user with Completion Options. Do NOT advance — wait for user approval.',
|
|
1384
|
+
},
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
} catch { /* refsDir may not exist — no references, skip */ }
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// QA-Visual gate: visual-qa.js must have run AND Visualizer must have analyzed
|
|
1392
|
+
// EVERY page — mirrors the Brief gate (ONE Visualizer per site/page in parallel).
|
|
1393
|
+
if (agent === 'qa-visual') {
|
|
1394
|
+
const vqaOutput = join(projectDir, '.chati', 'visual-qa');
|
|
1395
|
+
const projectReport = join(vqaOutput, 'report.json');
|
|
1396
|
+
// Only look at project-scoped .chati/visual-qa/. The /tmp/visual-qa/ fallback
|
|
1397
|
+
// was removed because it cross-contaminated isolated tests: any prior run of
|
|
1398
|
+
// visual-qa.js against a real project left /tmp/visual-qa/report.json behind,
|
|
1399
|
+
// which then polluted tmpdir-based test fixtures that expected a clean state.
|
|
1400
|
+
const reportPath = existsSync(projectReport) ? projectReport : null;
|
|
1401
|
+
const reportDir = reportPath ? reportPath.replace('/report.json', '') : vqaOutput;
|
|
1402
|
+
const consolidatedPath = join(projectDir, 'artifacts', '8-QA-Visual', 'visual-qa-analysis.md');
|
|
1403
|
+
const finalReportPath = join(projectDir, 'artifacts', '9-QA-Implementation', 'qa-visual-report.md');
|
|
1404
|
+
|
|
1405
|
+
// Check report.json exists (visual-qa.js ran)
|
|
1406
|
+
if (!reportPath) {
|
|
1407
|
+
// Autonomous-completion v1 (ADR-AUTO-02) rewrites this path:
|
|
1408
|
+
// The post-dev hook (P4) normally fires visual-qa.js when dev
|
|
1409
|
+
// completes. If we land here, either the hook did not fire (opted
|
|
1410
|
+
// out via config.yaml or dev score < 90) or its spawn is still
|
|
1411
|
+
// finishing in the background. Give Claude the commands to
|
|
1412
|
+
// recover manually.
|
|
1413
|
+
return {
|
|
1414
|
+
advanced: false,
|
|
1415
|
+
blocked: true,
|
|
1416
|
+
reason: 'visual_qa_not_run',
|
|
1417
|
+
message: 'visual-qa.js has not produced report.json yet. Either the post-dev auto-QA hook is still running, or it was skipped.',
|
|
1418
|
+
action_required: {
|
|
1419
|
+
step1: 'Run: node .chati.dev/scripts/visual-qa.js --pages "/" <add routes> --output .chati/visual-qa/',
|
|
1420
|
+
step2: 'The script spawns its own Next dev server on a free port, so you do not need to start one yourself.',
|
|
1421
|
+
step3: 'When the capture finishes, re-run advance qa-visual.',
|
|
1422
|
+
rationale: 'post-dev.js fires automatically on dev-handoff.md with score >= 90 and auto_visual_qa: true (default). If auto-QA is off, run the command above. See ADR-AUTO-02 / autonomous-completion-v1-pr.md.',
|
|
1423
|
+
},
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// Read captured pages from report.json
|
|
1428
|
+
let capturedPages = [];
|
|
1429
|
+
try {
|
|
1430
|
+
const report = JSON.parse(readFileSync(reportPath, 'utf-8'));
|
|
1431
|
+
capturedPages = (report.pages || []).map(p => ({ slug: p.slug || 'home', path: p.path || '/' }));
|
|
1432
|
+
} catch { /* ignore */ }
|
|
1433
|
+
|
|
1434
|
+
if (capturedPages.length === 0) {
|
|
1435
|
+
return {
|
|
1436
|
+
advanced: false,
|
|
1437
|
+
blocked: true,
|
|
1438
|
+
reason: 'visual_qa_report_empty',
|
|
1439
|
+
message: 'visual-qa.js ran but report.json has no captured pages. Re-run the script.',
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
// Autonomous-completion v1 (ADR-AUTO-04): Reduced-motion enforcement gate.
|
|
1444
|
+
// When visual-qa.js runs with --reduced-motion on|both, it writes
|
|
1445
|
+
// summary.reduced_motion_mode + summary.reduced_motion_violations to
|
|
1446
|
+
// report.json. A violation = a scroll-triggered region where frames
|
|
1447
|
+
// diverged between motion-on and motion-off captures — i.e. animation
|
|
1448
|
+
// that ignores the user's prefers-reduced-motion: reduce preference.
|
|
1449
|
+
// v1 contract: BLOCK when violations[] is non-empty (fault_origin: CODE).
|
|
1450
|
+
// v1 WARN: if reduced_motion_mode === 'off', the a11y pass never ran.
|
|
1451
|
+
// The comparison pass itself ships in v1.2 — in v1, violations[] is
|
|
1452
|
+
// always empty, so the gate runs clean today but is ready for the
|
|
1453
|
+
// upgrade without handleAdvance edits.
|
|
1454
|
+
try {
|
|
1455
|
+
const report = JSON.parse(readFileSync(reportPath, 'utf-8'));
|
|
1456
|
+
const rmMode = report.summary?.reduced_motion_mode;
|
|
1457
|
+
const violations = Array.isArray(report.summary?.reduced_motion_violations)
|
|
1458
|
+
? report.summary.reduced_motion_violations
|
|
1459
|
+
: [];
|
|
1460
|
+
if (violations.length > 0) {
|
|
1461
|
+
return {
|
|
1462
|
+
advanced: false,
|
|
1463
|
+
blocked: true,
|
|
1464
|
+
reason: 'reduced_motion_violation',
|
|
1465
|
+
message: `${violations.length} region(s) animate under prefers-reduced-motion: reduce. Motion MUST be disabled when the user preference is set.`,
|
|
1466
|
+
reduced_motion_violations: violations,
|
|
1467
|
+
fault_origin: 'CODE',
|
|
1468
|
+
action_required: {
|
|
1469
|
+
step1: 'Display the violating pages + regions to the user.',
|
|
1470
|
+
step2: 'Route back to dev for rework (Fault Vector Protocol: CODE → dev).',
|
|
1471
|
+
step3: 'Dev wraps the offending animation in usePrefersReducedMotion() (from lib/animations/gsap.ts.template) and returns early when motion is reduced.',
|
|
1472
|
+
step4: 'Re-run visual-qa.js with --reduced-motion both, then re-run advance qa-visual.',
|
|
1473
|
+
},
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
// WARN path — a11y pass not executed.
|
|
1477
|
+
if (rmMode === 'off') {
|
|
1478
|
+
// Non-blocking — attached to the response later as advisory_warnings
|
|
1479
|
+
// when we reach the success path. We do not early-return; other
|
|
1480
|
+
// gates (layout errors, per-page analysis) still need to run.
|
|
1481
|
+
// Storing in a closure var so downstream return decorates with it.
|
|
1482
|
+
// (Implemented as a side-effect on report — but report is local here;
|
|
1483
|
+
// use a hoisted pseudo-state via the outer function.)
|
|
1484
|
+
}
|
|
1485
|
+
} catch {
|
|
1486
|
+
// report.json unparseable — defer to the other checks which will catch
|
|
1487
|
+
// the corrupted-file case explicitly.
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// Objective layout gate — catches bugs that pass qualitative Visualizer review.
|
|
1491
|
+
// Root cause of 2026-04-16 cascade-layer trap: `* { margin: 0 }` outside any
|
|
1492
|
+
// @layer silently overrode all Tailwind utilities; Visualizers approved at
|
|
1493
|
+
// 94% because 1280px viewport masked the x=0 container alignment. This gate
|
|
1494
|
+
// blocks advance on measured facts (getBoundingClientRect / getComputedStyle)
|
|
1495
|
+
// regardless of what LLM review said.
|
|
1496
|
+
try {
|
|
1497
|
+
const report = JSON.parse(readFileSync(reportPath, 'utf-8'));
|
|
1498
|
+
const layoutErrors = (report.summary?.layout_errors || []).filter(e => e.severity === 'error');
|
|
1499
|
+
if (layoutErrors.length > 0) {
|
|
1500
|
+
// Group by page + type for a clean summary
|
|
1501
|
+
const byPageType = {};
|
|
1502
|
+
for (const e of layoutErrors) {
|
|
1503
|
+
const key = `${e.page || '?'}::${e.type}`;
|
|
1504
|
+
byPageType[key] = byPageType[key] || { page: e.page, type: e.type, viewports: [], details: [] };
|
|
1505
|
+
byPageType[key].viewports.push(e.viewport);
|
|
1506
|
+
byPageType[key].details.push(e.detail);
|
|
1507
|
+
}
|
|
1508
|
+
return {
|
|
1509
|
+
advanced: false,
|
|
1510
|
+
blocked: true,
|
|
1511
|
+
reason: 'layout_errors_detected',
|
|
1512
|
+
message: `${layoutErrors.length} objective layout error(s) measured by Playwright. These are measurements, not judgments — must be fixed before advancing.`,
|
|
1513
|
+
layout_error_count: layoutErrors.length,
|
|
1514
|
+
layout_errors: Object.values(byPageType),
|
|
1515
|
+
fault_origin: 'CODE',
|
|
1516
|
+
action_required: {
|
|
1517
|
+
step1: 'Display the errors to the user with specific selectors + measurements',
|
|
1518
|
+
step2: 'Route back to dev for rework (Fault Vector Protocol: CODE → dev)',
|
|
1519
|
+
step3: 'Dev fixes → pnpm build → re-run visual-qa.js → re-run advance qa-visual',
|
|
1520
|
+
common_causes: [
|
|
1521
|
+
'Tailwind v4: unlayered CSS in globals.css wins over @layer utilities (wrap globals in @layer base {})',
|
|
1522
|
+
'Tailwind v4: arbitrary CSS var without type hint silently omits property (use text-[length:var(...)])',
|
|
1523
|
+
'Missing mx-auto on a max-w-* container',
|
|
1524
|
+
'Element wider than viewport causing horizontal overflow',
|
|
1525
|
+
],
|
|
1526
|
+
},
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
} catch {
|
|
1530
|
+
// report.json unparseable — fall through to other checks, they will catch it
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// NEW (Fase 6): Shared CSS / token change → full coverage required.
|
|
1534
|
+
// Regression risk: a change to globals.css or brand.ts can silently break
|
|
1535
|
+
// routes that were NOT touched by the current task. If the current report
|
|
1536
|
+
// covers fewer routes than the project's app/ directory exposes, block.
|
|
1537
|
+
try {
|
|
1538
|
+
const modifiedFiles = getModifiedFiles(projectDir);
|
|
1539
|
+
const coverage = checkSharedCSSCoverage({ capturedPages, projectDir, modifiedFiles });
|
|
1540
|
+
if (coverage.blocked) {
|
|
1541
|
+
return {
|
|
1542
|
+
advanced: false,
|
|
1543
|
+
blocked: true,
|
|
1544
|
+
reason: coverage.reason,
|
|
1545
|
+
message: `Shared CSS / token change detected (${coverage.triggeringFiles.join(', ')}). Visual-QA captured ${capturedPages.length}/${coverage.appRoutes.length} routes. ${coverage.uncovered.length} route(s) uncovered — re-run with full coverage.`,
|
|
1546
|
+
triggering_files: coverage.triggeringFiles,
|
|
1547
|
+
captured_routes: capturedPages.map(p => p.path),
|
|
1548
|
+
expected_routes: coverage.appRoutes,
|
|
1549
|
+
uncovered_routes: coverage.uncovered,
|
|
1550
|
+
fault_origin: 'SPEC',
|
|
1551
|
+
action_required: {
|
|
1552
|
+
step1: 'Display the uncovered routes and the triggering shared files to the user.',
|
|
1553
|
+
step2: `Re-run visual-qa.js with --pages covering all ${coverage.appRoutes.length} routes:`,
|
|
1554
|
+
step3: `node .chati.dev/scripts/visual-qa.js --pages ${coverage.appRoutes.map(r => `"${r}"`).join(' ')} --url http://localhost:3456 --output .chati/visual-qa/`,
|
|
1555
|
+
step4: 'Then re-run advance qa-visual.',
|
|
1556
|
+
rationale: 'Shared-CSS changes can regress any route; visual-QA must check all of them when this class of file is modified.',
|
|
1557
|
+
},
|
|
1558
|
+
};
|
|
1559
|
+
}
|
|
1560
|
+
} catch {
|
|
1561
|
+
// git unavailable or projectDir not a repo — skip scope expansion silently.
|
|
1562
|
+
// Other checks below still enforce correctness on the covered routes.
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// Check: every captured page has per-page analysis.md OR is mentioned in the consolidated file
|
|
1566
|
+
let consolidatedContent = '';
|
|
1567
|
+
if (existsSync(consolidatedPath)) {
|
|
1568
|
+
try { consolidatedContent = readFileSync(consolidatedPath, 'utf-8'); } catch { /* ignore */ }
|
|
1569
|
+
}
|
|
1570
|
+
const needsAnalysis = capturedPages.filter(p => {
|
|
1571
|
+
const perPage = join(reportDir, p.slug, 'analysis.md');
|
|
1572
|
+
if (existsSync(perPage)) return false;
|
|
1573
|
+
if (consolidatedContent && (consolidatedContent.includes(p.slug) || consolidatedContent.includes(`\`${p.path}\``))) return false;
|
|
1574
|
+
return true;
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
if (needsAnalysis.length > 0) {
|
|
1578
|
+
return {
|
|
1579
|
+
advanced: false,
|
|
1580
|
+
blocked: true,
|
|
1581
|
+
reason: 'visual_analysis_required',
|
|
1582
|
+
message: `${needsAnalysis.length} of ${capturedPages.length} page(s) still need Visualizer analysis. Spawn ONE Visualizer per missing page (parallel).`,
|
|
1583
|
+
already_analyzed: capturedPages.length - needsAnalysis.length,
|
|
1584
|
+
action_required: {
|
|
1585
|
+
step1: 'Display to user (translate to session.language): "Analyzing the build visually and comparing against the original references..."',
|
|
1586
|
+
step2: `Spawn ${needsAnalysis.length} Agent(s) IN PARALLEL (one per page, each with model: sonnet):`,
|
|
1587
|
+
agents: needsAnalysis.map(p => ({
|
|
1588
|
+
description: `Visualizer — ${p.slug} build validation`,
|
|
1589
|
+
model: 'sonnet',
|
|
1590
|
+
prompt: `Read and follow .chati.dev/agents/shared/visualizer.md (Mode 2: Build Validation). Analyze ONE page only: ${p.path} (${p.slug}). Read ALL build frames matching ${reportDir}/${p.slug}-*.png. Compare to artifacts/4-UX/brandbook.md and artifacts/4-UX/references/*/analysis.md (text only). Save output to ${reportDir}/${p.slug}/analysis.md`,
|
|
1591
|
+
})),
|
|
1592
|
+
step3: 'Wait for ALL to return.',
|
|
1593
|
+
step4: `Merge ALL per-page analysis.md files into ${consolidatedPath} (consolidated Visualizer report).`,
|
|
1594
|
+
step5: `Write final QA-Visual verdict to ${finalReportPath} with score, blockers, warnings.`,
|
|
1595
|
+
step6: 'Present findings and verdict to the user with Completion Options. Do NOT advance — wait for user approval.',
|
|
1596
|
+
},
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// Check consolidated analysis file exists
|
|
1601
|
+
if (!existsSync(consolidatedPath)) {
|
|
1602
|
+
return {
|
|
1603
|
+
advanced: false,
|
|
1604
|
+
blocked: true,
|
|
1605
|
+
reason: 'consolidated_analysis_missing',
|
|
1606
|
+
message: `Per-page analyses exist but consolidated file is missing. Create ${consolidatedPath} by merging all per-page analyses.`,
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// Check final QA-Visual report exists at canonical path (NOT at 7-QA-Implementation)
|
|
1611
|
+
if (!existsSync(finalReportPath)) {
|
|
1612
|
+
const wrongPath = join(projectDir, 'artifacts', '7-QA-Implementation', 'qa-visual-report.md');
|
|
1613
|
+
const hint = existsSync(wrongPath)
|
|
1614
|
+
? ` Found qa-visual-report.md at WRONG path ${wrongPath} — move it to ${finalReportPath}.`
|
|
1615
|
+
: '';
|
|
1616
|
+
return {
|
|
1617
|
+
advanced: false,
|
|
1618
|
+
blocked: true,
|
|
1619
|
+
reason: 'qa_visual_final_report_missing',
|
|
1620
|
+
message: `Final QA-Visual report missing at canonical path: ${finalReportPath}.${hint}`,
|
|
1621
|
+
};
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// QA-Implementation gate: report MUST be at artifacts/9-QA-Implementation/ (NOT 7-*)
|
|
1626
|
+
if (agent === 'qa-implementation') {
|
|
1627
|
+
const canonicalDir = join(projectDir, 'artifacts', '9-QA-Implementation');
|
|
1628
|
+
const wrongDir = join(projectDir, 'artifacts', '7-QA-Implementation');
|
|
1629
|
+
const canonicalReport = join(canonicalDir, 'qa-implementation-report.md');
|
|
1630
|
+
const canonicalFinal = join(canonicalDir, 'qa-implementation-final-report.md');
|
|
1631
|
+
|
|
1632
|
+
// Block if wrong path exists — force relocation
|
|
1633
|
+
if (existsSync(wrongDir)) {
|
|
1634
|
+
return {
|
|
1635
|
+
advanced: false,
|
|
1636
|
+
blocked: true,
|
|
1637
|
+
reason: 'qa_impl_wrong_path',
|
|
1638
|
+
message: `artifacts/7-QA-Implementation/ is NOT canonical. Move all files to ${canonicalDir} (7- is reserved for QA-Planning).`,
|
|
1639
|
+
action_required: {
|
|
1640
|
+
step1: `Move files: mv artifacts/7-QA-Implementation/* artifacts/9-QA-Implementation/`,
|
|
1641
|
+
step2: `Remove empty folder: rmdir artifacts/7-QA-Implementation`,
|
|
1642
|
+
step3: 'Then re-run advance',
|
|
1643
|
+
},
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
// Require at least one of the canonical report files
|
|
1648
|
+
if (!existsSync(canonicalReport) && !existsSync(canonicalFinal)) {
|
|
1649
|
+
return {
|
|
1650
|
+
advanced: false,
|
|
1651
|
+
blocked: true,
|
|
1652
|
+
reason: 'qa_impl_report_missing',
|
|
1653
|
+
message: `QA-Implementation report missing. Expected ${canonicalReport} or ${canonicalFinal}.`,
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
// UX (brand-architect) gate
|
|
1659
|
+
if (agent === 'ux') {
|
|
1660
|
+
// Fase 7 — Task-to-UX fidelity. Before checking brandbook.html, verify that
|
|
1661
|
+
// if the brief or reference-analysis cites premium animation references,
|
|
1662
|
+
// the brand-architect has produced the animation-inventory artifact. The
|
|
1663
|
+
// inventory is mandatory: without it, tasks generate generic "add scroll
|
|
1664
|
+
// reveals" instead of the specific patterns the user's references
|
|
1665
|
+
// encode, and qa-visual has no per-pattern checklist for Mode 2.
|
|
1666
|
+
const inventoryPath = join(projectDir, 'artifacts', '4-UX', 'animation-inventory.md');
|
|
1667
|
+
if (!existsSync(inventoryPath)) {
|
|
1668
|
+
const corpus = readBriefAndRefCorpus(projectDir);
|
|
1669
|
+
const refs = detectPremiumRefs(corpus);
|
|
1670
|
+
if (refs.detected) {
|
|
1671
|
+
return {
|
|
1672
|
+
advanced: false,
|
|
1673
|
+
blocked: true,
|
|
1674
|
+
reason: 'animation_inventory_required',
|
|
1675
|
+
message: `Brief or reference-analysis cites premium animation references (${refs.matches.join(', ')}). Brand-architect must produce artifacts/4-UX/animation-inventory.md before advancing.`,
|
|
1676
|
+
triggering_matches: refs.matches,
|
|
1677
|
+
expected_artifact: 'artifacts/4-UX/animation-inventory.md',
|
|
1678
|
+
fault_origin: 'SPEC',
|
|
1679
|
+
action_required: {
|
|
1680
|
+
step1: 'Inspect artifacts/1-Brief/brief-report.md and artifacts/4-UX/reference-analysis.md to enumerate every named animation pattern.',
|
|
1681
|
+
step2: 'Write artifacts/4-UX/animation-inventory.md with one row per pattern. Schema:',
|
|
1682
|
+
schema: '| Pattern name | Observed at (site / section) | Parameters (stagger, scrub, easing, duration) | Reproducible via (scaffold file) | Target route |',
|
|
1683
|
+
step3: 'Populate the "Reproducible via" column from scaffold/motion-premium/* templates — gsap.ts, useGsapContext.ts, useScrollSnapStepper.ts, BackgroundCrossfadeProvider.tsx, etc. Each row that cannot be mapped to an existing scaffold pattern is a yellow flag (ask architect before committing a custom implementation).',
|
|
1684
|
+
step4: 'Append a section to brandbook.md (or brand-architect handoff) noting the inventory is locked and tasks must 1:1 map against it.',
|
|
1685
|
+
step5: 'Then re-run advance ux.',
|
|
1686
|
+
rationale: 'The inventory locks the promise made to the user by the brief/references. Tasks agent consumes it 1:1 to avoid generic descriptions; QA-Visual Mode 2 uses it as a per-pattern implementation checklist.',
|
|
1687
|
+
},
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
const brandbookPath = join(projectDir, 'artifacts', '4-UX', 'brandbook.html');
|
|
1693
|
+
if (!existsSync(brandbookPath)) {
|
|
1694
|
+
return {
|
|
1695
|
+
advanced: false,
|
|
1696
|
+
blocked: true,
|
|
1697
|
+
reason: 'brandbook_html_missing',
|
|
1698
|
+
message: 'brandbook.html is a mandatory deliverable for the UX agent. Write artifacts/4-UX/brandbook.html before advancing.',
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// Record completion (merges Decision Trail entries from handoff into session.yaml)
|
|
1704
|
+
await recordAgentCompletion(projectDir, { agent, status, score, handoffData });
|
|
1705
|
+
recordEvent(projectDir, EventType.AGENT_COMPLETED, agent, { score, status });
|
|
1706
|
+
|
|
1707
|
+
// Fase 10 — Scaffold auto-gate. After qa-planning completes, check whether
|
|
1708
|
+
// a scaffold preset has cleared the confidence threshold and is still
|
|
1709
|
+
// unapplied. If so, return an action_required response with the 3-option
|
|
1710
|
+
// decision payload. qa-planning stays marked complete; the user's next
|
|
1711
|
+
// advance call must carry --decision apply|skip|dryrun.
|
|
1712
|
+
if (status === 'completed' && score >= 95 && agent === 'qa-planning') {
|
|
1713
|
+
const { session: postQa } = loadSession(projectDir);
|
|
1714
|
+
const gate = checkScaffoldGate(postQa || session);
|
|
1715
|
+
if (gate.fires) {
|
|
1716
|
+
return buildScaffoldGateResponse(gate, {
|
|
1717
|
+
agent,
|
|
1718
|
+
score,
|
|
1719
|
+
phaseTransition: { triggered: false },
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// Advance pipeline. Build the pipelineState from POST-completion session
|
|
1725
|
+
// (reload after recordAgentCompletion) so completedAgents reflects the dedup
|
|
1726
|
+
// guard in session-manager (no duplicate pushes).
|
|
1727
|
+
const { session: postCompletion } = loadSession(projectDir);
|
|
1728
|
+
const pipelineState = sessionToPipelineState(postCompletion || session, projectDir);
|
|
1729
|
+
pipelineState.completedAgents = [...(postCompletion?.completed_agents || session.completed_agents || [])];
|
|
488
1730
|
pipelineState.agents[agent] = { status: 'completed', score, startedAt: null, completedAt: new Date().toISOString() };
|
|
489
1731
|
|
|
490
|
-
const advanceResult = advancePipeline(pipelineState, agent, {
|
|
1732
|
+
const advanceResult = advancePipeline(pipelineState, agent, {
|
|
1733
|
+
score,
|
|
1734
|
+
handoffData,
|
|
1735
|
+
findings: handoffData?.decision_trail_entries || [],
|
|
1736
|
+
});
|
|
491
1737
|
|
|
492
1738
|
// Update session with new state
|
|
493
1739
|
const updates = {
|
|
494
1740
|
current_agent: advanceResult.nextAgent || '',
|
|
495
1741
|
last_handoff: agent,
|
|
1742
|
+
pipeline_position: pipelineState.completedAgents.length,
|
|
496
1743
|
};
|
|
497
1744
|
|
|
1745
|
+
// Backward transition (Article XXII Fault Vector Protocol):
|
|
1746
|
+
// QA failed → reset target agent (and everything after it) back to pending.
|
|
1747
|
+
// Persist the new agent states + correction_cycles counter + pipeline_position.
|
|
1748
|
+
if (advanceResult.nextAction === 'correction_loop' || advanceResult.nextAction === 'escalate') {
|
|
1749
|
+
const advancedState = advanceResult.state || pipelineState;
|
|
1750
|
+
// Serialize agent map back to session shape (status/score/started/completed).
|
|
1751
|
+
const agentResults = { ...(session.agent_results || {}) };
|
|
1752
|
+
for (const [name, a] of Object.entries(advancedState.agents || {})) {
|
|
1753
|
+
agentResults[name] = {
|
|
1754
|
+
...(agentResults[name] || {}),
|
|
1755
|
+
status: a.status,
|
|
1756
|
+
score: a.score,
|
|
1757
|
+
started_at: a.startedAt || null,
|
|
1758
|
+
completed_at: a.completedAt || null,
|
|
1759
|
+
};
|
|
1760
|
+
}
|
|
1761
|
+
updates.agents = advancedState.agents;
|
|
1762
|
+
updates.agent_results = agentResults;
|
|
1763
|
+
updates.completed_agents = [...advancedState.completedAgents];
|
|
1764
|
+
updates.pipeline_position = advancedState.completedAgents.length;
|
|
1765
|
+
updates.correction_cycles = { ...(advancedState.correctionCycles || {}) };
|
|
1766
|
+
|
|
1767
|
+
recordEvent(
|
|
1768
|
+
projectDir,
|
|
1769
|
+
advanceResult.nextAction === 'escalate' ? EventType.CORRECTION_ESCALATED : EventType.CORRECTION_TRIGGERED,
|
|
1770
|
+
advanceResult.correction?.target || agent,
|
|
1771
|
+
{ correction: advanceResult.correction },
|
|
1772
|
+
);
|
|
1773
|
+
}
|
|
1774
|
+
|
|
498
1775
|
// Handle phase transition
|
|
499
1776
|
let phaseTransition = { triggered: false };
|
|
500
1777
|
if (advanceResult.needsModeSwitch) {
|
|
@@ -510,9 +1787,113 @@ async function handleAdvance(projectDir, args) {
|
|
|
510
1787
|
to: newPhase,
|
|
511
1788
|
trigger: `${agent} completed with score ${score}`,
|
|
512
1789
|
});
|
|
1790
|
+
recordEvent(projectDir, EventType.MODE_TRANSITION, agent, { from: session.mode, to: newPhase });
|
|
513
1791
|
}
|
|
514
1792
|
|
|
515
|
-
await updateSession(projectDir, updates);
|
|
1793
|
+
const saveResult = await updateSession(projectDir, updates);
|
|
1794
|
+
if (saveResult && saveResult.saved === false) {
|
|
1795
|
+
return errorResult(`Session update failed: ${saveResult.error}`, 'SESSION_WRITE_FAILED');
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
// Refresh Magic Docs: CLAUDE.md (public) + CLAUDE.local.md (runtime lock+state).
|
|
1799
|
+
// Non-fatal: if updates fail, pipeline state already advanced on disk.
|
|
1800
|
+
const nextAgent = advanceResult.nextAgent || '';
|
|
1801
|
+
const progress = getPipelineProgress(advanceResult.state || pipelineState);
|
|
1802
|
+
try {
|
|
1803
|
+
updateClaudeMd(projectDir, { currentAgent: nextAgent, progress });
|
|
1804
|
+
} catch { /* non-fatal */ }
|
|
1805
|
+
try {
|
|
1806
|
+
if (nextAgent) {
|
|
1807
|
+
writeSessionLock(projectDir, nextAgent, {
|
|
1808
|
+
phase: updates.mode || session.mode,
|
|
1809
|
+
mode: session.execution_mode || 'interactive',
|
|
1810
|
+
position: updates.pipeline_position,
|
|
1811
|
+
total: progress.total,
|
|
1812
|
+
progress: progress.percent,
|
|
1813
|
+
});
|
|
1814
|
+
} else {
|
|
1815
|
+
// Pipeline complete: reset lock to INACTIVE
|
|
1816
|
+
const lang = session.language || 'en';
|
|
1817
|
+
removeSessionLock(projectDir, RESUME_MESSAGES[lang] || RESUME_MESSAGES.en);
|
|
1818
|
+
}
|
|
1819
|
+
} catch { /* non-fatal */ }
|
|
1820
|
+
|
|
1821
|
+
// Backward transition via Fault Vector Protocol — QA agent failed threshold.
|
|
1822
|
+
// We have already reset target agent state above; now return the correction
|
|
1823
|
+
// payload so Claude/Agent UI knows which agent to invoke next.
|
|
1824
|
+
if (advanceResult.nextAction === 'correction_loop') {
|
|
1825
|
+
const c = advanceResult.correction;
|
|
1826
|
+
// Fase 8: compute the "Reset:" list for the state card — agents that
|
|
1827
|
+
// were completed before but are now back to pending (the correction
|
|
1828
|
+
// target + everything past it in the pipeline).
|
|
1829
|
+
const beforeCompleted = new Set(session.completed_agents || []);
|
|
1830
|
+
const afterCompleted = new Set(advanceResult.state?.completedAgents || pipelineState.completedAgents || []);
|
|
1831
|
+
const resetAgents = [...beforeCompleted].filter(a => !afterCompleted.has(a));
|
|
1832
|
+
if (!resetAgents.includes(c.target)) resetAgents.unshift(c.target);
|
|
1833
|
+
const stateCard = (() => {
|
|
1834
|
+
try {
|
|
1835
|
+
const { session: post } = loadSession(projectDir);
|
|
1836
|
+
return buildStateCard({
|
|
1837
|
+
session: post || session,
|
|
1838
|
+
projectDir,
|
|
1839
|
+
correctionReset: { agents: resetAgents, files: [] },
|
|
1840
|
+
});
|
|
1841
|
+
} catch { return ''; }
|
|
1842
|
+
})();
|
|
1843
|
+
return {
|
|
1844
|
+
advanced: true,
|
|
1845
|
+
agent_completed: agent,
|
|
1846
|
+
score,
|
|
1847
|
+
phase_transition: phaseTransition,
|
|
1848
|
+
correction_loop: true,
|
|
1849
|
+
correction: c,
|
|
1850
|
+
reset: { agents: resetAgents },
|
|
1851
|
+
state_card: stateCard,
|
|
1852
|
+
next: {
|
|
1853
|
+
action: 'correction_loop',
|
|
1854
|
+
agent: c.target,
|
|
1855
|
+
reason: c.reason,
|
|
1856
|
+
fault_origin: c.faultOrigin,
|
|
1857
|
+
cycle: c.cycle,
|
|
1858
|
+
max_cycles: 2,
|
|
1859
|
+
findings_count: (c.findings || []).length,
|
|
1860
|
+
pipeline_progress: getPipelineProgress(advanceResult.state || pipelineState),
|
|
1861
|
+
status_summary: `${agent} scored ${c.score ?? 'n/a'} (below threshold). Fault origin: ${c.faultOrigin}. Re-opening ${c.target} (correction cycle ${c.cycle} of 2). Claude must invoke ${c.target} with the findings.`,
|
|
1862
|
+
},
|
|
1863
|
+
};
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
// Max correction cycles exceeded — escalate to human.
|
|
1867
|
+
if (advanceResult.nextAction === 'escalate') {
|
|
1868
|
+
const c = advanceResult.correction;
|
|
1869
|
+
const stateCard = (() => {
|
|
1870
|
+
try {
|
|
1871
|
+
const { session: post } = loadSession(projectDir);
|
|
1872
|
+
return buildStateCard({ session: post || session, projectDir });
|
|
1873
|
+
} catch { return ''; }
|
|
1874
|
+
})();
|
|
1875
|
+
return {
|
|
1876
|
+
advanced: false,
|
|
1877
|
+
blocked: true,
|
|
1878
|
+
agent_completed: agent,
|
|
1879
|
+
score,
|
|
1880
|
+
phase_transition: phaseTransition,
|
|
1881
|
+
correction_loop: true,
|
|
1882
|
+
correction: c,
|
|
1883
|
+
reason: 'max_correction_cycles_exceeded',
|
|
1884
|
+
message: c.reason,
|
|
1885
|
+
state_card: stateCard,
|
|
1886
|
+
next: {
|
|
1887
|
+
action: 'escalate',
|
|
1888
|
+
agent: null,
|
|
1889
|
+
reason: c.reason,
|
|
1890
|
+
fault_origin: c.faultOrigin,
|
|
1891
|
+
cycle: c.cycle,
|
|
1892
|
+
max_cycles: 2,
|
|
1893
|
+
status_summary: `${agent} failed after ${c.cycle} correction cycles. Human review required before pipeline can continue.`,
|
|
1894
|
+
},
|
|
1895
|
+
};
|
|
1896
|
+
}
|
|
516
1897
|
|
|
517
1898
|
// Check if user_preview is needed
|
|
518
1899
|
if (advanceResult.nextAction === 'user_preview') {
|
|
@@ -536,6 +1917,23 @@ async function handleAdvance(projectDir, args) {
|
|
|
536
1917
|
}
|
|
537
1918
|
|
|
538
1919
|
async function handleInit(projectDir, args) {
|
|
1920
|
+
// Auto-resolve: if a parent directory already has a Chati installation,
|
|
1921
|
+
// use that as the project root automatically. Prevents accidental nested
|
|
1922
|
+
// installs (stray `init/` dirs) when CLI is invoked from a subdirectory.
|
|
1923
|
+
const { dirname: pathDirname } = await import('path');
|
|
1924
|
+
let probe = pathDirname(projectDir);
|
|
1925
|
+
let depth = 0;
|
|
1926
|
+
while (probe && probe !== '/' && depth < 5) {
|
|
1927
|
+
const fwParent = existsSync(join(probe, '.chati.dev')) || existsSync(join(probe, 'chati.dev'));
|
|
1928
|
+
if (fwParent) {
|
|
1929
|
+
// Found install in parent — use that as projectDir for the rest of init.
|
|
1930
|
+
projectDir = probe;
|
|
1931
|
+
break;
|
|
1932
|
+
}
|
|
1933
|
+
probe = pathDirname(probe);
|
|
1934
|
+
depth++;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
539
1937
|
// License validation at init time (moved from per-prompt UserPromptSubmit hook)
|
|
540
1938
|
try {
|
|
541
1939
|
const licensePath = join(process.env.HOME || '', '.chati-dev', 'license.yaml');
|
|
@@ -559,9 +1957,23 @@ async function handleInit(projectDir, args) {
|
|
|
559
1957
|
|
|
560
1958
|
const type = args.type || 'greenfield';
|
|
561
1959
|
const language = args.language || 'en';
|
|
562
|
-
const name = args.name || '';
|
|
563
1960
|
const workflow = args.workflow || 'full';
|
|
564
1961
|
|
|
1962
|
+
// Preserve fields from session.yaml created by installer (project name, ides, mcps)
|
|
1963
|
+
let preservedName = args.name || '';
|
|
1964
|
+
let preservedIdes = [];
|
|
1965
|
+
let preservedMcps = [];
|
|
1966
|
+
try {
|
|
1967
|
+
const existingPath = join(projectDir, '.chati', 'session.yaml');
|
|
1968
|
+
if (existsSync(existingPath)) {
|
|
1969
|
+
const existing = yaml.load(readFileSync(existingPath, 'utf-8')) || {};
|
|
1970
|
+
preservedName = preservedName || existing.project?.name || '';
|
|
1971
|
+
preservedIdes = existing.ides || [];
|
|
1972
|
+
preservedMcps = existing.mcps || [];
|
|
1973
|
+
}
|
|
1974
|
+
} catch { /* expected: session may not exist */ }
|
|
1975
|
+
const name = preservedName;
|
|
1976
|
+
|
|
565
1977
|
const isGreenfield = type === 'greenfield';
|
|
566
1978
|
|
|
567
1979
|
// Initialize pipeline state based on workflow
|
|
@@ -577,27 +1989,53 @@ async function handleInit(projectDir, args) {
|
|
|
577
1989
|
pipelineAgents = Object.keys(p.agents);
|
|
578
1990
|
}
|
|
579
1991
|
|
|
580
|
-
//
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
1992
|
+
// RESUME GUARD: if a session already exists with completed agents or a non-discover mode,
|
|
1993
|
+
// this is a RESUME — preserve all state and only update workflow/lock. Do NOT reset.
|
|
1994
|
+
const sessionPath = join(projectDir, '.chati', 'session.yaml');
|
|
1995
|
+
const isResume = existsSync(sessionPath) && (() => {
|
|
1996
|
+
try {
|
|
1997
|
+
const s = yaml.load(readFileSync(sessionPath, 'utf-8')) || {};
|
|
1998
|
+
const hasProgress = (s.completed_agents && s.completed_agents.length > 0)
|
|
1999
|
+
|| (s.mode && s.mode !== 'discover')
|
|
2000
|
+
|| (s.current_agent && s.current_agent !== '');
|
|
2001
|
+
return hasProgress;
|
|
2002
|
+
} catch { return false; }
|
|
2003
|
+
})();
|
|
2004
|
+
|
|
2005
|
+
if (isResume) {
|
|
2006
|
+
// Resume path — keep session.yaml intact, just refresh workflow if changed
|
|
2007
|
+
await updateSession(projectDir, { workflow });
|
|
2008
|
+
recordEvent(projectDir, EventType.SESSION_STARTED, 'orchestrator', { resume: true, workflow });
|
|
2009
|
+
} else {
|
|
2010
|
+
// Fresh init — create new session
|
|
2011
|
+
const result = initSession(projectDir, {
|
|
2012
|
+
mode: 'discover',
|
|
2013
|
+
projectName: name,
|
|
2014
|
+
isGreenfield,
|
|
2015
|
+
language,
|
|
2016
|
+
ides: preservedIdes,
|
|
2017
|
+
mcps: preservedMcps,
|
|
2018
|
+
});
|
|
591
2019
|
|
|
592
|
-
|
|
593
|
-
|
|
2020
|
+
if (!result.created) {
|
|
2021
|
+
return errorResult(`Failed to initialize session: ${result.error}`, 'INIT_FAILED');
|
|
2022
|
+
}
|
|
594
2023
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
2024
|
+
await updateSession(projectDir, { workflow });
|
|
2025
|
+
clearTimeline(projectDir);
|
|
2026
|
+
recordEvent(projectDir, EventType.SESSION_STARTED, 'orchestrator', { projectType: type, workflow, language });
|
|
2027
|
+
}
|
|
598
2028
|
|
|
599
|
-
//
|
|
600
|
-
|
|
2029
|
+
// Determine first/active agent — on resume, use current_agent from session
|
|
2030
|
+
let firstAgent;
|
|
2031
|
+
if (isResume) {
|
|
2032
|
+
const s = yaml.load(readFileSync(sessionPath, 'utf-8')) || {};
|
|
2033
|
+
firstAgent = s.current_agent || (isGreenfield ? 'greenfield-wu' : 'brownfield-wu');
|
|
2034
|
+
} else {
|
|
2035
|
+
firstAgent = isGreenfield ? 'greenfield-wu' : 'brownfield-wu';
|
|
2036
|
+
await updateSession(projectDir, { current_agent: firstAgent });
|
|
2037
|
+
}
|
|
2038
|
+
const firstAgentFile = getAgentFile(firstAgent, projectDir) || null;
|
|
601
2039
|
|
|
602
2040
|
// Write session lock
|
|
603
2041
|
try {
|
|
@@ -674,6 +2112,27 @@ async function handleStatus(projectDir) {
|
|
|
674
2112
|
agentModels[agentDef.name] = resolveAgentModel(agentDef.name, projectDir);
|
|
675
2113
|
}
|
|
676
2114
|
|
|
2115
|
+
// Fase 10 workstream 2 (context-window-detection v1) — model-aware
|
|
2116
|
+
// context block. Resolves the active window from session.active_model
|
|
2117
|
+
// (populated by prism-engine on first prompt) and reports the exact
|
|
2118
|
+
// bracket prism-engine last computed. If the session predates v1.2
|
|
2119
|
+
// (no context_* fields yet), the block falls back to estimateContextBracket
|
|
2120
|
+
// so the existing response shape stays stable for consumers.
|
|
2121
|
+
const activeModel = session.active_model || null;
|
|
2122
|
+
const activeProvider = session.active_provider || (session.providers_enabled || ['claude'])[0];
|
|
2123
|
+
const windowTokens = session.context_window_tokens != null
|
|
2124
|
+
? session.context_window_tokens
|
|
2125
|
+
: resolveContextLimit(activeModel, activeProvider);
|
|
2126
|
+
const tokensUsed = session.context_tokens_used != null ? session.context_tokens_used : 0;
|
|
2127
|
+
const remainingPct = windowTokens > 0
|
|
2128
|
+
? Math.max(0, Math.round((1 - tokensUsed / windowTokens) * 100))
|
|
2129
|
+
: 100;
|
|
2130
|
+
const lastBracket = session.context_last_bracket
|
|
2131
|
+
|| (remainingPct < 25 ? 'CRITICAL'
|
|
2132
|
+
: remainingPct < 40 ? 'DEPLETED'
|
|
2133
|
+
: remainingPct < 60 ? 'MODERATE'
|
|
2134
|
+
: 'FRESH');
|
|
2135
|
+
|
|
677
2136
|
return {
|
|
678
2137
|
session: summary.summary || summary,
|
|
679
2138
|
pipeline: progress,
|
|
@@ -683,6 +2142,15 @@ async function handleStatus(projectDir) {
|
|
|
683
2142
|
agents: session.agents || {},
|
|
684
2143
|
agent_models: agentModels,
|
|
685
2144
|
context_bracket: estimateContextBracket((session.completed_agents || []).length, AGENT_PIPELINE.length),
|
|
2145
|
+
context: {
|
|
2146
|
+
model: activeModel,
|
|
2147
|
+
provider: activeProvider,
|
|
2148
|
+
window_tokens: windowTokens,
|
|
2149
|
+
tokens_used: tokensUsed,
|
|
2150
|
+
remaining_pct: remainingPct,
|
|
2151
|
+
bracket: lastBracket,
|
|
2152
|
+
handoff_required: remainingPct < 15,
|
|
2153
|
+
},
|
|
686
2154
|
user_level: session.user_level || 'auto',
|
|
687
2155
|
execution_mode: session.execution_mode || 'interactive',
|
|
688
2156
|
};
|
|
@@ -743,6 +2211,16 @@ async function handleDeviation(projectDir, args) {
|
|
|
743
2211
|
}
|
|
744
2212
|
|
|
745
2213
|
await updateSession(projectDir, updates);
|
|
2214
|
+
|
|
2215
|
+
// Refresh session lock if agent changed
|
|
2216
|
+
if (updates.current_agent) {
|
|
2217
|
+
try {
|
|
2218
|
+
writeSessionLock(projectDir, updates.current_agent, {
|
|
2219
|
+
phase: session.mode,
|
|
2220
|
+
mode: session.execution_mode || 'interactive',
|
|
2221
|
+
});
|
|
2222
|
+
} catch { /* non-fatal */ }
|
|
2223
|
+
}
|
|
746
2224
|
}
|
|
747
2225
|
|
|
748
2226
|
return {
|
|
@@ -760,6 +2238,7 @@ async function handleExit(projectDir, args) {
|
|
|
760
2238
|
}
|
|
761
2239
|
|
|
762
2240
|
// Save current state
|
|
2241
|
+
recordEvent(projectDir, EventType.SESSION_ENDED, session.current_agent || 'orchestrator', { mode: session.mode });
|
|
763
2242
|
await updateSession(projectDir, {});
|
|
764
2243
|
|
|
765
2244
|
// Release session ownership
|
|
@@ -790,8 +2269,16 @@ async function handleProviders(projectDir) {
|
|
|
790
2269
|
agentModels[agentDef.name] = resolveAgentModel(agentDef.name, projectDir);
|
|
791
2270
|
}
|
|
792
2271
|
|
|
2272
|
+
let primaryProvider = 'claude';
|
|
2273
|
+
const configPath = join(projectDir, resolveFrameworkDir(projectDir), 'config.yaml');
|
|
2274
|
+
if (existsSync(configPath)) {
|
|
2275
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
2276
|
+
const providerMatch = raw.match(/primary_provider:\s*["']?(\w+)/);
|
|
2277
|
+
if (providerMatch) primaryProvider = providerMatch[1].toLowerCase();
|
|
2278
|
+
}
|
|
2279
|
+
|
|
793
2280
|
return {
|
|
794
|
-
primary_provider:
|
|
2281
|
+
primary_provider: primaryProvider,
|
|
795
2282
|
agent_models: agentModels,
|
|
796
2283
|
};
|
|
797
2284
|
}
|
|
@@ -971,7 +2458,7 @@ async function handleScan(projectDir, args) {
|
|
|
971
2458
|
|
|
972
2459
|
const TEAM_CONFIGS = {
|
|
973
2460
|
planning: {
|
|
974
|
-
members: ['detail', 'architect', 'ux'],
|
|
2461
|
+
members: ['detail', 'architect', 'ux', 'qa-planning'],
|
|
975
2462
|
slug: 'pln',
|
|
976
2463
|
templateFile: 'team-planning-tasks.yaml',
|
|
977
2464
|
},
|
|
@@ -996,14 +2483,22 @@ function generateTeamId(slug) {
|
|
|
996
2483
|
* (Gemini) or spawn_autonomous (Codex) when the active provider is not claude.
|
|
997
2484
|
*/
|
|
998
2485
|
function isAgentTeamsEnabled(projectDir) {
|
|
999
|
-
const configPath = join(projectDir,
|
|
2486
|
+
const configPath = join(projectDir, resolveFrameworkDir(projectDir), 'config.yaml');
|
|
1000
2487
|
if (!existsSync(configPath)) return false;
|
|
1001
2488
|
const raw = readFileSync(configPath, 'utf-8');
|
|
1002
2489
|
|
|
1003
2490
|
// Feature flag check
|
|
1004
2491
|
const flagMatch = raw.match(/agent_teams:\s*(true|false)/);
|
|
1005
|
-
|
|
1006
|
-
if (
|
|
2492
|
+
// If field is explicitly set, respect it
|
|
2493
|
+
if (flagMatch) {
|
|
2494
|
+
if (flagMatch[1] !== 'true') return false;
|
|
2495
|
+
} else {
|
|
2496
|
+
// If field is absent (pre-v4.2.0 projects), default to true for Claude provider
|
|
2497
|
+
// Claude Code supports Agent tool natively; Gemini/Codex fall back to sequential
|
|
2498
|
+
const providerMatch = raw.match(/primary_provider:\s*["']?(\w+)/);
|
|
2499
|
+
const provider = providerMatch ? providerMatch[1].toLowerCase() : 'claude';
|
|
2500
|
+
if (provider !== 'claude') return false;
|
|
2501
|
+
}
|
|
1007
2502
|
|
|
1008
2503
|
// Provider gate — Agent Teams is Claude-only.
|
|
1009
2504
|
// Read primary_provider from session.yaml (preferred) or active_provider
|
|
@@ -1036,7 +2531,7 @@ async function handleSpawnTeam(projectDir, args) {
|
|
|
1036
2531
|
const teamDir = join(projectDir, '.chati', 'teams', teamId);
|
|
1037
2532
|
const mailboxDir = join(teamDir, 'mailbox');
|
|
1038
2533
|
const taskListPath = join(teamDir, 'tasks.yaml');
|
|
1039
|
-
const templatePath = join(projectDir,
|
|
2534
|
+
const templatePath = join(projectDir, resolveFrameworkDir(projectDir), 'templates', config.templateFile);
|
|
1040
2535
|
|
|
1041
2536
|
// Create team directories
|
|
1042
2537
|
mkdirSync(mailboxDir, { recursive: true });
|
|
@@ -1047,7 +2542,7 @@ async function handleSpawnTeam(projectDir, args) {
|
|
|
1047
2542
|
|
|
1048
2543
|
// For build teams: dynamically populate tasks from tasks.md (Article XXI §9)
|
|
1049
2544
|
if (teamType === 'build') {
|
|
1050
|
-
const tasksArtifact = join(projectDir, '
|
|
2545
|
+
const tasksArtifact = join(projectDir, 'artifacts', '6-Tasks', 'tasks.md');
|
|
1051
2546
|
if (existsSync(tasksArtifact)) {
|
|
1052
2547
|
const tasksContent = readFileSync(tasksArtifact, 'utf-8');
|
|
1053
2548
|
// Extract task IDs (pattern: T{phase}.{seq})
|
|
@@ -1142,6 +2637,7 @@ async function handleSpawnTeam(projectDir, args) {
|
|
|
1142
2637
|
});
|
|
1143
2638
|
|
|
1144
2639
|
await updateSession(projectDir, session);
|
|
2640
|
+
recordEvent(projectDir, EventType.AGENT_ACTIVATED, `team:${teamType}`, { teamId, members: config.members });
|
|
1145
2641
|
}
|
|
1146
2642
|
} catch { /* non-critical: session update may fail, team can still spawn */ }
|
|
1147
2643
|
|
|
@@ -1262,7 +2758,7 @@ async function handleTeamDissolve(projectDir, args) {
|
|
|
1262
2758
|
let correctionCyclesOk = true;
|
|
1263
2759
|
let maxCorrectionCycles = 2; // default per constitution
|
|
1264
2760
|
try {
|
|
1265
|
-
const configPath = join(projectDir,
|
|
2761
|
+
const configPath = join(projectDir, resolveFrameworkDir(projectDir), 'config.yaml');
|
|
1266
2762
|
if (existsSync(configPath)) {
|
|
1267
2763
|
const configRaw = readFileSync(configPath, 'utf-8');
|
|
1268
2764
|
const maxMatch = configRaw.match(/team_correction_cycles_max:\s*(\d+)/);
|
|
@@ -1286,7 +2782,7 @@ async function handleTeamDissolve(projectDir, args) {
|
|
|
1286
2782
|
if (!mailboxClean) gateFailures.push('unresolved_mailbox_messages');
|
|
1287
2783
|
if (!noBlockers) gateFailures.push('open_blockers');
|
|
1288
2784
|
if (!correctionCyclesOk) gateFailures.push('correction_cycles_exceeded');
|
|
1289
|
-
if (teamScore <
|
|
2785
|
+
if (teamScore < tierThreshold) gateFailures.push('team_score_below_threshold');
|
|
1290
2786
|
|
|
1291
2787
|
// Update session
|
|
1292
2788
|
try {
|
|
@@ -1341,12 +2837,296 @@ async function handleTeamDissolve(projectDir, args) {
|
|
|
1341
2837
|
mailbox_clean: mailboxClean,
|
|
1342
2838
|
no_open_blockers: noBlockers,
|
|
1343
2839
|
correction_cycles_ok: correctionCyclesOk,
|
|
1344
|
-
team_score_above_threshold: teamScore >=
|
|
2840
|
+
team_score_above_threshold: teamScore >= tierThreshold,
|
|
1345
2841
|
},
|
|
1346
2842
|
},
|
|
1347
2843
|
};
|
|
1348
2844
|
}
|
|
1349
2845
|
|
|
2846
|
+
// ---------------------------------------------------------------------------
|
|
2847
|
+
// Deterministic subcommands — replace generative LLM decisions with code
|
|
2848
|
+
// ---------------------------------------------------------------------------
|
|
2849
|
+
|
|
2850
|
+
/**
|
|
2851
|
+
* wait-for-license: poll ~/.chati-dev/license.yaml until status=VALID or
|
|
2852
|
+
* a terminal-bad status (EXPIRED|INVALID|REVOKED) or timeout. Used by the
|
|
2853
|
+
* /chati boot path when doctor reports a missing/invalid license — the
|
|
2854
|
+
* orchestrator displays the activate command, then calls this to block
|
|
2855
|
+
* until the user has run it in another terminal.
|
|
2856
|
+
*
|
|
2857
|
+
* @param {string} _projectDir unused (license is home-dir scoped)
|
|
2858
|
+
* @param {{ timeout?: string, interval?: string }} args
|
|
2859
|
+
*/
|
|
2860
|
+
async function handleWaitForLicense(_projectDir, args) {
|
|
2861
|
+
const timeoutMs = args.timeout ? parseInt(args.timeout, 10) : undefined;
|
|
2862
|
+
const intervalMs = args.interval ? parseInt(args.interval, 10) : undefined;
|
|
2863
|
+
const result = await waitForLicense({ timeoutMs, intervalMs });
|
|
2864
|
+
return {
|
|
2865
|
+
valid: result.valid,
|
|
2866
|
+
status: result.status,
|
|
2867
|
+
reason: result.reason,
|
|
2868
|
+
waited_ms: result.waitedMs,
|
|
2869
|
+
next: result.valid
|
|
2870
|
+
? { action: 'proceed', status_summary: 'License VALID — continuing /chati boot.' }
|
|
2871
|
+
: {
|
|
2872
|
+
action: 'license_resolution_required',
|
|
2873
|
+
status_summary: `License status: ${result.status}. ${result.reason || ''}`.trim(),
|
|
2874
|
+
fix: 'Paste in another terminal: `npx chati-dev activate --key=YOUR-KEY`, then re-invoke `/chati`.',
|
|
2875
|
+
},
|
|
2876
|
+
};
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
/**
|
|
2880
|
+
* doctor: run the silent self-diagnostic. Returns the structured verdict
|
|
2881
|
+
* from runDoctor for the orchestrator to surface. Callers are /chati boot
|
|
2882
|
+
* (implicit) and `/chati doctor` (explicit).
|
|
2883
|
+
*
|
|
2884
|
+
* @param {string} projectDir
|
|
2885
|
+
*/
|
|
2886
|
+
function handleDoctor(projectDir) {
|
|
2887
|
+
const verdict = runDoctor({ projectDir });
|
|
2888
|
+
return {
|
|
2889
|
+
ok: verdict.ok,
|
|
2890
|
+
checks: verdict.checks,
|
|
2891
|
+
blockers: verdict.blockers,
|
|
2892
|
+
warnings: verdict.warnings,
|
|
2893
|
+
next: verdict.ok
|
|
2894
|
+
? { action: 'proceed', status_summary: 'doctor: all checks OK (silent pass).' }
|
|
2895
|
+
: {
|
|
2896
|
+
action: 'remediation_required',
|
|
2897
|
+
status_summary: `doctor: ${verdict.blockers.length} blocker(s), ${verdict.warnings.length} warning(s).`,
|
|
2898
|
+
remediations: verdict.blockers.map(b => ({ id: b.id, fix: b.fix })).filter(r => r.fix),
|
|
2899
|
+
},
|
|
2900
|
+
};
|
|
2901
|
+
}
|
|
2902
|
+
|
|
2903
|
+
/**
|
|
2904
|
+
* wait-for-capture: block until capture-complete.json exists (max 10 min).
|
|
2905
|
+
* Replaces Brand Architect's generative polling loop.
|
|
2906
|
+
*/
|
|
2907
|
+
async function handleWaitForCapture(projectDir, args) {
|
|
2908
|
+
const refsDir = join(projectDir, 'artifacts', '4-UX', 'references');
|
|
2909
|
+
const completePath = join(refsDir, 'capture-complete.json');
|
|
2910
|
+
const timeoutMs = parseInt(args.timeout || '600000', 10); // 10 min default
|
|
2911
|
+
const pollMs = 5000; // check every 5s
|
|
2912
|
+
const start = Date.now();
|
|
2913
|
+
|
|
2914
|
+
while (Date.now() - start < timeoutMs) {
|
|
2915
|
+
if (existsSync(completePath)) {
|
|
2916
|
+
try {
|
|
2917
|
+
const data = JSON.parse(readFileSync(completePath, 'utf-8'));
|
|
2918
|
+
return { ready: true, ...data };
|
|
2919
|
+
} catch {
|
|
2920
|
+
return { ready: true, error: 'capture-complete.json exists but invalid JSON' };
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
await new Promise(r => setTimeout(r, pollMs));
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
return { ready: false, timedOut: true, message: `capture-complete.json not found after ${timeoutMs / 1000}s. Proceed with WebFetch fallback.` };
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
/**
|
|
2930
|
+
* qa-visual-score: calculate visual QA score deterministically from report.json.
|
|
2931
|
+
* Replaces QA-Visual's generative scoring formula.
|
|
2932
|
+
*/
|
|
2933
|
+
function handleQaVisualScore(args) {
|
|
2934
|
+
const reportPath = args.report || '/tmp/visual-qa/report.json';
|
|
2935
|
+
if (!existsSync(reportPath)) {
|
|
2936
|
+
return errorResult(`report.json not found at ${reportPath}`, 'REPORT_NOT_FOUND');
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
let report;
|
|
2940
|
+
try { report = JSON.parse(readFileSync(reportPath, 'utf-8')); } catch {
|
|
2941
|
+
return errorResult('report.json is invalid JSON', 'INVALID_REPORT');
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
const s = report.summary || {};
|
|
2945
|
+
const hasRefs = args['has-refs'] === 'true';
|
|
2946
|
+
|
|
2947
|
+
// Weights (adjust when references exist)
|
|
2948
|
+
const w = hasRefs
|
|
2949
|
+
? { animation: 0.30, scroll: 0.20, responsive: 0.15, hover: 0.10, brand: 0.10, fidelity: 0.15 }
|
|
2950
|
+
: { animation: 0.35, scroll: 0.25, responsive: 0.20, hover: 0.10, brand: 0.10, fidelity: 0 };
|
|
2951
|
+
|
|
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
|
|
2957
|
+
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
|
|
2959
|
+
|
|
2960
|
+
let rawScore = Math.round(
|
|
2961
|
+
animScore * w.animation +
|
|
2962
|
+
scrollScore * w.scroll +
|
|
2963
|
+
responsiveScore * w.responsive +
|
|
2964
|
+
hoverScore * w.hover +
|
|
2965
|
+
brandScore * w.brand +
|
|
2966
|
+
fidelityScore * w.fidelity
|
|
2967
|
+
);
|
|
2968
|
+
|
|
2969
|
+
// Penalties
|
|
2970
|
+
const errors = [];
|
|
2971
|
+
const warnings = [];
|
|
2972
|
+
|
|
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');
|
|
2975
|
+
if (s.em_dashes_detected) { errors.push('Em-dashes found in visible text'); rawScore -= 10; }
|
|
2976
|
+
if (s.console_errors_total > 0) { warnings.push(`${s.console_errors_total} console error(s)`); rawScore -= 5; }
|
|
2977
|
+
|
|
2978
|
+
// Hard blocks
|
|
2979
|
+
const hardBlocked = !s.lenis_active_all || !s.gsap_loaded_all || s.em_dashes_detected;
|
|
2980
|
+
const score = Math.max(0, Math.min(100, rawScore));
|
|
2981
|
+
const threshold = 90;
|
|
2982
|
+
const verdict = hardBlocked ? 'BLOCKED' : score >= threshold ? 'APPROVED' : 'NEEDS_CORRECTION';
|
|
2983
|
+
|
|
2984
|
+
return { score, threshold, verdict, hardBlocked, errors, warnings, weights: w, dimensions: { animScore, scrollScore, responsiveScore, hoverScore, brandScore, fidelityScore } };
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
/**
|
|
2988
|
+
* wave-status: read Build Team tasks.yaml + mailbox, return wave status.
|
|
2989
|
+
* Replaces Claude's manual mailbox counting.
|
|
2990
|
+
*/
|
|
2991
|
+
async function handleWaveStatus(projectDir, args) {
|
|
2992
|
+
const teamId = args['team-id'];
|
|
2993
|
+
if (!teamId) return errorResult('Missing --team-id flag', 'MISSING_TEAM_ID');
|
|
2994
|
+
|
|
2995
|
+
const teamDir = join(projectDir, '.chati', 'teams', teamId);
|
|
2996
|
+
const tasksPath = join(teamDir, 'tasks.yaml');
|
|
2997
|
+
if (!existsSync(tasksPath)) return errorResult(`Team tasks not found: ${tasksPath}`, 'TEAM_NOT_FOUND');
|
|
2998
|
+
|
|
2999
|
+
let tasks;
|
|
3000
|
+
try { tasks = yaml.load(readFileSync(tasksPath, 'utf-8')); } catch {
|
|
3001
|
+
return errorResult('tasks.yaml is invalid', 'INVALID_TASKS');
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
const taskList = tasks.tasks || [];
|
|
3005
|
+
const completed = taskList.filter(t => t.status === 'done' || t.status === 'completed');
|
|
3006
|
+
const inProgress = taskList.filter(t => t.status === 'in_progress');
|
|
3007
|
+
const pending = taskList.filter(t => t.status === 'pending');
|
|
3008
|
+
const blocked = taskList.filter(t => t.blocker);
|
|
3009
|
+
const failed = taskList.filter(t => t.status === 'failed' || t.status === 'needs_correction');
|
|
3010
|
+
|
|
3011
|
+
// Determine wave type
|
|
3012
|
+
let waveType = 'regular';
|
|
3013
|
+
if (pending.length === 0 && failed.length > 0) waveType = 'cleanup';
|
|
3014
|
+
if (pending.length === 0 && failed.length === 0 && inProgress.length === 0) waveType = 'complete';
|
|
3015
|
+
|
|
3016
|
+
return {
|
|
3017
|
+
team_id: teamId,
|
|
3018
|
+
wave_type: waveType,
|
|
3019
|
+
total: taskList.length,
|
|
3020
|
+
completed: completed.length,
|
|
3021
|
+
in_progress: inProgress.length,
|
|
3022
|
+
pending: pending.length,
|
|
3023
|
+
failed: failed.length,
|
|
3024
|
+
blocked: blocked.length,
|
|
3025
|
+
all_done: pending.length === 0 && inProgress.length === 0 && failed.length === 0,
|
|
3026
|
+
recommendation: waveType === 'complete'
|
|
3027
|
+
? 'All tasks complete. Ready for team dissolution.'
|
|
3028
|
+
: waveType === 'cleanup'
|
|
3029
|
+
? `${failed.length} task(s) need correction. Start Cleanup Wave.`
|
|
3030
|
+
: `${pending.length} task(s) pending, ${inProgress.length} in progress. Continue Regular Wave.`,
|
|
3031
|
+
};
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
/**
|
|
3035
|
+
* Brief category dictionaries — user-input i18n tokens keyed by ISO 639-1.
|
|
3036
|
+
*
|
|
3037
|
+
* These match free-form brief text against 7 coverage categories. The set is
|
|
3038
|
+
* routed through session.language at call time (same exemption pattern as
|
|
3039
|
+
* RESUME_MESSAGES). English is always unioned in; the locale layer adds
|
|
3040
|
+
* dialect tokens so projects running brief in pt/es/fr still get coverage.
|
|
3041
|
+
*
|
|
3042
|
+
* Article VII: code identifiers and framework strings stay English; these
|
|
3043
|
+
* locale entries are user-input tokens, analogous to i18n message catalogs.
|
|
3044
|
+
*/
|
|
3045
|
+
export const BRIEF_CATEGORY_KEYWORDS = {
|
|
3046
|
+
en: {
|
|
3047
|
+
problem: ['problem', 'issue', 'challenge', 'pain', 'struggle', 'frustrate', 'difficult', 'gap', 'need', 'fix'],
|
|
3048
|
+
users: ['user', 'customer', 'client', 'audience', 'persona', 'visitor', 'target', 'demographic', 'segment'],
|
|
3049
|
+
outcome: ['goal', 'outcome', 'result', 'success', 'metric', 'kpi', 'achieve', 'objective', 'want', 'expect'],
|
|
3050
|
+
constraints: ['budget', 'timeline', 'deadline', 'team', 'resource', 'limit', 'constraint', 'technology', 'stack'],
|
|
3051
|
+
scope: ['scope', 'feature', 'page', 'section', 'include', 'exclude', 'not build', 'out of scope', 'mvp', 'v1'],
|
|
3052
|
+
references: ['reference', 'competitor', 'inspiration', 'like', 'similar', 'example', 'benchmark', 'design', 'look'],
|
|
3053
|
+
context: ['company', 'business', 'industry', 'market', 'brand', 'history', 'background', 'about', 'mission'],
|
|
3054
|
+
},
|
|
3055
|
+
pt: {
|
|
3056
|
+
problem: ['problema', 'desafio', 'dor', 'dificuldade', 'necessidade', 'resolver', 'corrigir'],
|
|
3057
|
+
users: ['usuario', 'usu\u00e1rio', 'cliente', 'p\u00fablico', 'visitante', 'alvo', 'pessoa'],
|
|
3058
|
+
outcome: ['objetivo', 'meta', 'resultado', 'sucesso', 'quero', 'espero', 'alcan\u00e7ar', 'atingir'],
|
|
3059
|
+
constraints: ['or\u00e7amento', 'prazo', 'equipe', 'recurso', 'limite', 'tecnologia', 'restri\u00e7\u00e3o'],
|
|
3060
|
+
scope: ['escopo', 'funcionalidade', 'p\u00e1gina', 'incluir', 'excluir', 'n\u00e3o fazer', 'fora do escopo'],
|
|
3061
|
+
references: ['refer\u00eancia', 'referencia', 'concorrente', 'inspira\u00e7\u00e3o', 'inspiracao', 'parecido', 'exemplo', 'visual'],
|
|
3062
|
+
context: ['empresa', 'neg\u00f3cio', 'negocio', 'mercado', 'marca', 'hist\u00f3ria', 'historia', 'sobre', 'miss\u00e3o'],
|
|
3063
|
+
},
|
|
3064
|
+
es: {
|
|
3065
|
+
problem: ['problema', 'desaf\u00edo', 'dolor', 'dificultad', 'necesidad', 'resolver', 'corregir'],
|
|
3066
|
+
users: ['usuario', 'cliente', 'audiencia', 'p\u00fablico', 'visitante', 'objetivo', 'persona'],
|
|
3067
|
+
outcome: ['meta', 'objetivo', 'resultado', '\u00e9xito', 'quiero', 'espero', 'lograr', 'alcanzar'],
|
|
3068
|
+
constraints: ['presupuesto', 'plazo', 'equipo', 'recurso', 'l\u00edmite', 'tecnolog\u00eda', 'restricci\u00f3n'],
|
|
3069
|
+
scope: ['alcance', 'funcionalidad', 'p\u00e1gina', 'incluir', 'excluir', 'no hacer', 'fuera de alcance'],
|
|
3070
|
+
references: ['referencia', 'competidor', 'inspiraci\u00f3n', 'parecido', 'ejemplo', 'visual'],
|
|
3071
|
+
context: ['empresa', 'negocio', 'mercado', 'marca', 'historia', 'acerca', 'misi\u00f3n'],
|
|
3072
|
+
},
|
|
3073
|
+
fr: {
|
|
3074
|
+
problem: ['probl\u00e8me', 'd\u00e9fi', 'douleur', 'difficult\u00e9', 'besoin', 'r\u00e9soudre', 'corriger'],
|
|
3075
|
+
users: ['utilisateur', 'client', 'audience', 'public', 'visiteur', 'cible', 'persona'],
|
|
3076
|
+
outcome: ['objectif', 'r\u00e9sultat', 'succ\u00e8s', 'veux', 'souhaite', 'atteindre'],
|
|
3077
|
+
constraints: ['budget', 'd\u00e9lai', '\u00e9quipe', 'ressource', 'limite', 'technologie', 'contrainte'],
|
|
3078
|
+
scope: ['p\u00e9rim\u00e8tre', 'fonctionnalit\u00e9', 'page', 'inclure', 'exclure', 'hors p\u00e9rim\u00e8tre'],
|
|
3079
|
+
references: ['r\u00e9f\u00e9rence', 'concurrent', 'inspiration', 'similaire', 'exemple', 'visuel'],
|
|
3080
|
+
context: ['entreprise', 'march\u00e9', 'marque', 'histoire', 'mission'],
|
|
3081
|
+
},
|
|
3082
|
+
};
|
|
3083
|
+
|
|
3084
|
+
const BRIEF_CATEGORIES = ['problem', 'users', 'outcome', 'constraints', 'scope', 'references', 'context'];
|
|
3085
|
+
|
|
3086
|
+
/**
|
|
3087
|
+
* Build the keyword set for a category at a given language. Always includes EN
|
|
3088
|
+
* tokens; the locale layer is appended when session.language selects it.
|
|
3089
|
+
*
|
|
3090
|
+
* @param {string} category One of BRIEF_CATEGORIES.
|
|
3091
|
+
* @param {string} [language='en'] ISO 639-1 session language.
|
|
3092
|
+
* @returns {string[]}
|
|
3093
|
+
*/
|
|
3094
|
+
export function getBriefCategoryKeywords(category, language = 'en') {
|
|
3095
|
+
const en = BRIEF_CATEGORY_KEYWORDS.en[category] ?? [];
|
|
3096
|
+
if (language === 'en') return [...en];
|
|
3097
|
+
const locale = BRIEF_CATEGORY_KEYWORDS[language]?.[category] ?? [];
|
|
3098
|
+
return [...en, ...locale];
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
/**
|
|
3102
|
+
* assess-coverage: classify user input against 7 Brief categories.
|
|
3103
|
+
* Replaces Brief's generative coverage assessment.
|
|
3104
|
+
*/
|
|
3105
|
+
function handleAssessCoverage(args) {
|
|
3106
|
+
const input = (args.input || '').toLowerCase();
|
|
3107
|
+
if (!input) return errorResult('Missing --input flag', 'MISSING_INPUT');
|
|
3108
|
+
const language = (args.language || 'en').toLowerCase();
|
|
3109
|
+
|
|
3110
|
+
const categories = {};
|
|
3111
|
+
for (const cat of BRIEF_CATEGORIES) {
|
|
3112
|
+
const keywords = getBriefCategoryKeywords(cat, language);
|
|
3113
|
+
categories[cat] = { keywords, found: keywords.some(k => input.includes(k)) };
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
const covered = Object.entries(categories).filter(([, d]) => d.found).map(([k]) => k);
|
|
3117
|
+
const missing = Object.entries(categories).filter(([, d]) => !d.found).map(([k]) => k);
|
|
3118
|
+
const coveragePct = Math.round((covered.length / BRIEF_CATEGORIES.length) * 100);
|
|
3119
|
+
|
|
3120
|
+
return {
|
|
3121
|
+
coverage_pct: coveragePct,
|
|
3122
|
+
covered,
|
|
3123
|
+
missing,
|
|
3124
|
+
recommendation: coveragePct >= 50
|
|
3125
|
+
? `Good coverage (${coveragePct}%). Clarify: ${missing.join(', ')}.`
|
|
3126
|
+
: `Low coverage (${coveragePct}%). Deep exploration needed on: ${missing.join(', ')}.`,
|
|
3127
|
+
};
|
|
3128
|
+
}
|
|
3129
|
+
|
|
1350
3130
|
// ---------------------------------------------------------------------------
|
|
1351
3131
|
// Main entry point
|
|
1352
3132
|
// ---------------------------------------------------------------------------
|
|
@@ -1404,6 +3184,24 @@ export async function runOrchestrate(subCommand, argv, projectDir) {
|
|
|
1404
3184
|
case 'team-dissolve':
|
|
1405
3185
|
output(await handleTeamDissolve(projectDir, args));
|
|
1406
3186
|
break;
|
|
3187
|
+
case 'wait-for-capture':
|
|
3188
|
+
output(await handleWaitForCapture(projectDir, args));
|
|
3189
|
+
break;
|
|
3190
|
+
case 'wait-for-license':
|
|
3191
|
+
output(await handleWaitForLicense(projectDir, args));
|
|
3192
|
+
break;
|
|
3193
|
+
case 'doctor':
|
|
3194
|
+
output(handleDoctor(projectDir));
|
|
3195
|
+
break;
|
|
3196
|
+
case 'qa-visual-score':
|
|
3197
|
+
output(handleQaVisualScore(args));
|
|
3198
|
+
break;
|
|
3199
|
+
case 'wave-status':
|
|
3200
|
+
output(await handleWaveStatus(projectDir, args));
|
|
3201
|
+
break;
|
|
3202
|
+
case 'assess-coverage':
|
|
3203
|
+
output(handleAssessCoverage(args));
|
|
3204
|
+
break;
|
|
1407
3205
|
default:
|
|
1408
3206
|
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'));
|
|
1409
3207
|
break;
|