chati-dev 4.2.2 → 4.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -53
- package/bin/chati.js +170 -5
- package/framework/agents/build/dev.md +509 -76
- package/framework/agents/deploy/devops.md +40 -25
- package/framework/agents/discover/brief.md +158 -22
- package/framework/agents/discover/brownfield-wu.md +26 -14
- package/framework/agents/discover/greenfield-wu.md +102 -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 +38 -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 +448 -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 +441 -0
- package/framework/orchestrator/chati-update.md +40 -40
- package/framework/orchestrator/chati.md +295 -94
- package/framework/rules/human-writing-style.md +47 -0
- package/framework/scaffold/motion-premium/README.md +99 -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 +178 -0
- package/framework/scaffold/motion-premium-3d/README.md +90 -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 +136 -0
- package/framework/schemas/session.schema.json +109 -21
- package/framework/scripts/reference-capture.js +430 -0
- package/framework/scripts/visual-qa.js +686 -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 +2 -2
- 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 +12 -6
- 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 +431 -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 +1880 -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 +339 -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,48 @@ 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') : '';
|
|
636
|
+
|
|
637
|
+
// Phase display is authoritatively driven by session.mode on disk. Callers
|
|
638
|
+
// previously passed stateInfo.phase derived from transient intermediate
|
|
639
|
+
// values (the next agent's expected phase before user approval), causing
|
|
640
|
+
// the lock to drift ahead of session.yaml (e.g. show "build" while
|
|
641
|
+
// session.mode was still "plan" awaiting approval).
|
|
642
|
+
let authoritativePhase = null;
|
|
643
|
+
try {
|
|
644
|
+
const { loaded, session } = loadSession(projectDir);
|
|
645
|
+
if (loaded && session?.mode) authoritativePhase = session.mode;
|
|
646
|
+
} catch { /* non-fatal — fall back to stateInfo */ }
|
|
223
647
|
|
|
224
|
-
const
|
|
225
|
-
## Session Lock -- ACTIVE
|
|
648
|
+
const lockInner = `## Session Lock -- ACTIVE
|
|
226
649
|
|
|
227
650
|
**Chati.dev session is ACTIVE.** Follow these rules for EVERY message:
|
|
228
651
|
|
|
229
|
-
1. Read
|
|
652
|
+
1. Read \`${resolveFrameworkDir(projectDir)}/orchestrator/chati.md\` and follow its routing logic
|
|
230
653
|
2. Route ALL user messages through the current agent: \`${currentAgent}\`
|
|
231
654
|
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
|
-
|
|
655
|
+
4. The ONLY way to exit is via \`/chati exit\`, \`/chati stop\`, or \`/chati quit\``;
|
|
656
|
+
|
|
657
|
+
const stateInner = `## Current State
|
|
658
|
+
- **Agent**: ${currentAgent || 'None'}
|
|
659
|
+
- **Phase**: ${authoritativePhase || stateInfo.phase || 'discover'}
|
|
660
|
+
- **Pipeline**: ${stateInfo.position ?? 0}/${stateInfo.total ?? '?'} (${stateInfo.progress ?? 0}%)
|
|
661
|
+
- **Mode**: ${stateInfo.mode || 'interactive'}`;
|
|
235
662
|
|
|
236
|
-
content = content
|
|
663
|
+
content = replaceBlock(content, LOCK_START, LOCK_END, lockInner);
|
|
664
|
+
content = replaceBlock(content, STATE_START, STATE_END, stateInner);
|
|
237
665
|
writeFileSync(localMdPath, content, 'utf-8');
|
|
238
666
|
}
|
|
239
667
|
|
|
@@ -246,11 +674,15 @@ function removeSessionLock(projectDir, resumeMsg) {
|
|
|
246
674
|
|
|
247
675
|
let content = readFileSync(localMdPath, 'utf-8');
|
|
248
676
|
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
677
|
+
const lockInner = `## Session Lock
|
|
678
|
+
**Status: INACTIVE** — Type \`/chati\` to activate.`;
|
|
679
|
+
const stateInner = `## Current State
|
|
680
|
+
- **Agent**: None (ready to start)
|
|
681
|
+
- **Pipeline**: Pre-start
|
|
682
|
+
- **Mode**: interactive`;
|
|
683
|
+
|
|
684
|
+
content = replaceBlock(content, LOCK_START, LOCK_END, lockInner);
|
|
685
|
+
content = replaceBlock(content, STATE_START, STATE_END, stateInner);
|
|
254
686
|
|
|
255
687
|
if (resumeMsg) {
|
|
256
688
|
content = content.trimEnd() + `\n\n## Session Paused\n\n${resumeMsg}\n`;
|
|
@@ -264,6 +696,48 @@ function removeSessionLock(projectDir, resumeMsg) {
|
|
|
264
696
|
// ---------------------------------------------------------------------------
|
|
265
697
|
|
|
266
698
|
async function handleNext(projectDir) {
|
|
699
|
+
const result = await _handleNextInner(projectDir);
|
|
700
|
+
|
|
701
|
+
// Fase 8: attach state-at-a-glance card to every non-error response.
|
|
702
|
+
// The card renders as a JSON string field; the terminal / orchestrator
|
|
703
|
+
// prompt can console.log it verbatim. Silent on setup/error responses
|
|
704
|
+
// so a missing session does not produce a misleading card.
|
|
705
|
+
if (result && result.action !== 'setup' && result.action !== 'error') {
|
|
706
|
+
try {
|
|
707
|
+
const { session } = loadSession(projectDir);
|
|
708
|
+
if (session) {
|
|
709
|
+
result.state_card = buildStateCard({ session, projectDir });
|
|
710
|
+
}
|
|
711
|
+
} catch { /* non-fatal — card is UX sugar */ }
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Centralized session lock update: applies to ALL return paths.
|
|
715
|
+
// Activates lock when an agent is returned, deactivates on completion.
|
|
716
|
+
if (result && result.agent && result.action !== 'error' && result.action !== 'setup') {
|
|
717
|
+
const activating = ['activate_interactive', 'spawn_autonomous', 'spawn_team', 'spawn_parallel'];
|
|
718
|
+
if (activating.includes(result.action)) {
|
|
719
|
+
try {
|
|
720
|
+
writeSessionLock(projectDir, result.agent, {
|
|
721
|
+
phase: result.phase || 'discover',
|
|
722
|
+
mode: result.session?.execution_mode || 'interactive',
|
|
723
|
+
position: result.pipeline_progress?.completedAgents?.length ?? 0,
|
|
724
|
+
total: AGENT_PIPELINE.length,
|
|
725
|
+
progress: result.pipeline_progress?.progress ?? 0,
|
|
726
|
+
});
|
|
727
|
+
} catch { /* non-fatal */ }
|
|
728
|
+
}
|
|
729
|
+
} else if (result && result.action === 'complete') {
|
|
730
|
+
try {
|
|
731
|
+
const { session } = loadSession(projectDir);
|
|
732
|
+
const lang = session?.language || 'en';
|
|
733
|
+
removeSessionLock(projectDir, RESUME_MESSAGES[lang] || RESUME_MESSAGES.en);
|
|
734
|
+
} catch { /* non-fatal */ }
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
return result;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function _handleNextInner(projectDir) {
|
|
267
741
|
const { loaded, session } = loadSession(projectDir);
|
|
268
742
|
if (!loaded || !session) {
|
|
269
743
|
return { action: 'setup', status_summary: 'No session found. Project needs initialization.' };
|
|
@@ -291,11 +765,37 @@ async function handleNext(projectDir) {
|
|
|
291
765
|
if (currentAgent && !completedAgents.includes(currentAgent)) {
|
|
292
766
|
const agent = currentAgent;
|
|
293
767
|
const agentStatus = session.agents?.[agent]?.status;
|
|
294
|
-
if (agentStatus === 'in_progress'
|
|
295
|
-
const agentFile =
|
|
768
|
+
if (agentStatus === 'in_progress') {
|
|
769
|
+
const agentFile = getAgentFile(agent, projectDir) || null;
|
|
296
770
|
const agentDef = getAgentDefinition(agent);
|
|
297
771
|
const isInteractive = INTERACTIVE_AGENTS.includes(agent);
|
|
298
772
|
|
|
773
|
+
// Build Team override: even when resuming, if agent is 'dev' and teams
|
|
774
|
+
// enabled, return spawn_team so Dev + QA-Implementation run together.
|
|
775
|
+
if (agent === 'dev' && isAgentTeamsEnabled(projectDir)) {
|
|
776
|
+
const teamConfig = TEAM_CONFIGS.build;
|
|
777
|
+
const teamId = generateTeamId(teamConfig.slug);
|
|
778
|
+
return {
|
|
779
|
+
action: 'spawn_team',
|
|
780
|
+
agent,
|
|
781
|
+
agent_file: agentFile,
|
|
782
|
+
phase: agentDef?.phase || session.mode,
|
|
783
|
+
spawn_command: null,
|
|
784
|
+
parallel_spawn_command: null,
|
|
785
|
+
parallel_agents: teamConfig.members,
|
|
786
|
+
handoff_status: { valid: true, missing: [], warnings: ['Resuming dev as Build Team'] },
|
|
787
|
+
gate_status: { canAdvance: true, reason: 'Build Team formation (Article XXI)' },
|
|
788
|
+
model_info: resolveAgentModel(agent, projectDir),
|
|
789
|
+
context_bracket: estimateContextBracket(completedAgents.length, AGENT_PIPELINE.length),
|
|
790
|
+
pipeline_progress: getPipelineProgress(pipelineState),
|
|
791
|
+
session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode, user_level: session.user_level || 'auto' },
|
|
792
|
+
status_summary: `Forming Build Team: dev + qa-implementation.`,
|
|
793
|
+
team_id: teamId,
|
|
794
|
+
team_type: 'build',
|
|
795
|
+
members: teamConfig.members,
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
299
799
|
return {
|
|
300
800
|
action: isInteractive ? 'activate_interactive' : 'spawn_autonomous',
|
|
301
801
|
agent,
|
|
@@ -322,7 +822,7 @@ async function handleNext(projectDir) {
|
|
|
322
822
|
if (!lastAgent || completedAgents.length === 0) {
|
|
323
823
|
const projectType = session.project_type || session.project?.type || 'greenfield';
|
|
324
824
|
const firstAgent = projectType === 'brownfield' ? 'brownfield-wu' : 'greenfield-wu';
|
|
325
|
-
const agentFile =
|
|
825
|
+
const agentFile = getAgentFile(firstAgent, projectDir) || null;
|
|
326
826
|
|
|
327
827
|
return {
|
|
328
828
|
action: 'activate_interactive',
|
|
@@ -342,8 +842,24 @@ async function handleNext(projectDir) {
|
|
|
342
842
|
};
|
|
343
843
|
}
|
|
344
844
|
|
|
345
|
-
// Get next agent
|
|
346
|
-
|
|
845
|
+
// Get next agent — if lastAgent is not in AGENT_PIPELINE (e.g. qa-planning
|
|
846
|
+
// which lives inside Planning Team), fall back to the last completed agent
|
|
847
|
+
// that IS in the pipeline so we can find the correct successor.
|
|
848
|
+
let nextInfo = getNextAgent(lastAgent, completedAgents);
|
|
849
|
+
|
|
850
|
+
if ((!nextInfo || !nextInfo.next) && lastAgent) {
|
|
851
|
+
const agentInPipeline = getAgentDefinition(lastAgent);
|
|
852
|
+
if (!agentInPipeline) {
|
|
853
|
+
// lastAgent is not in AGENT_PIPELINE — find the last completed agent that IS
|
|
854
|
+
const pipelineNames = AGENT_PIPELINE.map((a) => a.name);
|
|
855
|
+
for (let i = completedAgents.length - 1; i >= 0; i--) {
|
|
856
|
+
if (pipelineNames.includes(completedAgents[i])) {
|
|
857
|
+
nextInfo = getNextAgent(completedAgents[i], completedAgents);
|
|
858
|
+
if (nextInfo && nextInfo.next) break;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
347
863
|
|
|
348
864
|
if (!nextInfo || !nextInfo.next) {
|
|
349
865
|
return {
|
|
@@ -355,7 +871,7 @@ async function handleNext(projectDir) {
|
|
|
355
871
|
}
|
|
356
872
|
|
|
357
873
|
const nextAgent = nextInfo.next;
|
|
358
|
-
const agentFile =
|
|
874
|
+
const agentFile = getAgentFile(nextAgent, projectDir) || null;
|
|
359
875
|
const agentDef = getAgentDefinition(nextAgent);
|
|
360
876
|
const isInteractive = INTERACTIVE_AGENTS.includes(nextAgent);
|
|
361
877
|
|
|
@@ -415,6 +931,9 @@ async function handleNext(projectDir) {
|
|
|
415
931
|
spawnCommand = buildSpawnCommand(nextAgent, projectDir, lastAgent, modelInfo.provider, 600000);
|
|
416
932
|
}
|
|
417
933
|
|
|
934
|
+
// Record agent activation event
|
|
935
|
+
recordEvent(projectDir, EventType.AGENT_ACTIVATED, nextAgent, { action, phase: agentDef?.phase });
|
|
936
|
+
|
|
418
937
|
const progress = getPipelineProgress(pipelineState);
|
|
419
938
|
const bracket = estimateContextBracket(completedAgents.length, AGENT_PIPELINE.length);
|
|
420
939
|
|
|
@@ -445,6 +964,262 @@ async function handleNext(projectDir) {
|
|
|
445
964
|
return result;
|
|
446
965
|
}
|
|
447
966
|
|
|
967
|
+
// ---------------------------------------------------------------------------
|
|
968
|
+
// Fase 10 — Scaffold auto-gate helpers
|
|
969
|
+
//
|
|
970
|
+
// Problem: after qa-planning approves the plan, the framework knows (from
|
|
971
|
+
// greenfield-wu's scaffold_candidates + ux-brand-architect's scaffold_signals)
|
|
972
|
+
// whether a scaffold preset like motion-premium would save the Build phase
|
|
973
|
+
// from reinventing animation wiring by hand. Without a deterministic gate,
|
|
974
|
+
// the orchestrator must rely on Claude remembering to offer the scaffold —
|
|
975
|
+
// and Claude forgets. This gate turns remembering into code.
|
|
976
|
+
//
|
|
977
|
+
// A preset "fires" when:
|
|
978
|
+
// 1. It is in session.scaffold_candidates (stack match, Signal 1).
|
|
979
|
+
// 2. It has a signal with confidence >= 0.7 (premium intent, Signal 2).
|
|
980
|
+
// 3. It is NOT yet in session.scaffold_applied (idempotency).
|
|
981
|
+
// ---------------------------------------------------------------------------
|
|
982
|
+
|
|
983
|
+
const SCAFFOLD_CONFIDENCE_THRESHOLD = 0.7;
|
|
984
|
+
|
|
985
|
+
// Per-preset threshold overrides. ADR-3D-07 sets motion-premium-3d to 0.8
|
|
986
|
+
// (higher than motion-premium's 0.7) — false positives cost ~600KB of
|
|
987
|
+
// bundle pressed onto a project that did not need 3D. New presets added
|
|
988
|
+
// here; any preset not in the map inherits SCAFFOLD_CONFIDENCE_THRESHOLD.
|
|
989
|
+
const PRESET_CONFIDENCE_THRESHOLDS = {
|
|
990
|
+
'motion-premium': 0.7,
|
|
991
|
+
'motion-premium-3d': 0.8,
|
|
992
|
+
};
|
|
993
|
+
|
|
994
|
+
function thresholdFor(preset) {
|
|
995
|
+
return PRESET_CONFIDENCE_THRESHOLDS[preset] ?? SCAFFOLD_CONFIDENCE_THRESHOLD;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Evaluate which (if any) scaffold preset's auto-gate should fire.
|
|
1000
|
+
* Enumerates candidates in session order; first preset whose signal
|
|
1001
|
+
* confidence meets its per-preset threshold AND is not already applied
|
|
1002
|
+
* wins. When a user applies or skips one preset, the next advance call
|
|
1003
|
+
* re-evaluates — letting a project with BOTH motion-premium and
|
|
1004
|
+
* motion-premium-3d signals receive both prompts in sequence.
|
|
1005
|
+
*
|
|
1006
|
+
* @param {object} session - Post-completion session
|
|
1007
|
+
* @returns {{ fires: boolean, preset: string|null, signalKey: string|null, confidence: number, threshold: number, evidence: string[], reason: string|null }}
|
|
1008
|
+
*/
|
|
1009
|
+
function checkScaffoldGate(session) {
|
|
1010
|
+
const candidates = Array.isArray(session?.scaffold_candidates) ? session.scaffold_candidates : [];
|
|
1011
|
+
const signals = (session?.scaffold_signals && typeof session.scaffold_signals === 'object') ? session.scaffold_signals : {};
|
|
1012
|
+
const applied = new Set(Array.isArray(session?.scaffold_applied) ? session.scaffold_applied : []);
|
|
1013
|
+
|
|
1014
|
+
for (const preset of candidates) {
|
|
1015
|
+
if (applied.has(preset)) continue;
|
|
1016
|
+
const key = preset.replace(/-/g, '_');
|
|
1017
|
+
const signal = signals[key];
|
|
1018
|
+
if (!signal) continue;
|
|
1019
|
+
const confidence = typeof signal.confidence === 'number' ? signal.confidence : 0;
|
|
1020
|
+
const threshold = thresholdFor(preset);
|
|
1021
|
+
if (confidence < threshold) continue;
|
|
1022
|
+
return {
|
|
1023
|
+
fires: true,
|
|
1024
|
+
preset,
|
|
1025
|
+
signalKey: key,
|
|
1026
|
+
confidence,
|
|
1027
|
+
threshold,
|
|
1028
|
+
evidence: Array.isArray(signal.evidence) ? signal.evidence : [],
|
|
1029
|
+
reason: 'scaffold_decision_required',
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
return { fires: false, preset: null, signalKey: null, confidence: 0, threshold: SCAFFOLD_CONFIDENCE_THRESHOLD, evidence: [], reason: null };
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Build the action_required payload the orchestrator presents to the user
|
|
1038
|
+
* when a scaffold gate fires.
|
|
1039
|
+
*/
|
|
1040
|
+
function buildScaffoldGateResponse(gate, { agent, score, phaseTransition }) {
|
|
1041
|
+
return {
|
|
1042
|
+
advanced: true,
|
|
1043
|
+
agent_completed: agent,
|
|
1044
|
+
score,
|
|
1045
|
+
phase_transition: phaseTransition || { triggered: false },
|
|
1046
|
+
scaffold_decision_required: true,
|
|
1047
|
+
scaffold_gate: {
|
|
1048
|
+
preset: gate.preset,
|
|
1049
|
+
confidence: gate.confidence,
|
|
1050
|
+
evidence: gate.evidence,
|
|
1051
|
+
},
|
|
1052
|
+
next: {
|
|
1053
|
+
action: 'scaffold_decision',
|
|
1054
|
+
agent: null,
|
|
1055
|
+
pending_preset: gate.preset,
|
|
1056
|
+
confidence: gate.confidence,
|
|
1057
|
+
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.`,
|
|
1058
|
+
options: [
|
|
1059
|
+
{
|
|
1060
|
+
id: 'apply',
|
|
1061
|
+
label: `Apply ${gate.preset} scaffold (15 files, Premium-tier animation wiring)`,
|
|
1062
|
+
command: `advance --agent qa-planning --score ${score} --decision apply`,
|
|
1063
|
+
description: 'Writes the scaffold templates to the project. Recommended — the Animation Inventory maps 1:1 to these files.',
|
|
1064
|
+
},
|
|
1065
|
+
{
|
|
1066
|
+
id: 'dryrun',
|
|
1067
|
+
label: `Preview ${gate.preset} scaffold (list files, write nothing)`,
|
|
1068
|
+
command: `advance --agent qa-planning --score ${score} --decision dryrun`,
|
|
1069
|
+
description: 'Shows what would be written. Run this first if you want to see the diff before committing.',
|
|
1070
|
+
},
|
|
1071
|
+
{
|
|
1072
|
+
id: 'skip',
|
|
1073
|
+
label: `Skip ${gate.preset} (proceed to dev without scaffold)`,
|
|
1074
|
+
command: `advance --agent qa-planning --score ${score} --decision skip`,
|
|
1075
|
+
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).',
|
|
1076
|
+
},
|
|
1077
|
+
],
|
|
1078
|
+
},
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Execute a scaffold decision (apply|skip|dryrun), update session, and
|
|
1084
|
+
* advance the pipeline from qa-planning to dev. Reuses applyScaffold from
|
|
1085
|
+
* the installer module (Fase 4) so the programmatic and CLI-wrapped paths
|
|
1086
|
+
* share one implementation.
|
|
1087
|
+
*
|
|
1088
|
+
* @param {string} projectDir
|
|
1089
|
+
* @param {object} session - Pre-decision session (qa-planning already completed)
|
|
1090
|
+
* @param {'apply'|'skip'|'dryrun'} decision
|
|
1091
|
+
* @param {string} agent - Should be 'qa-planning' when gate fired there.
|
|
1092
|
+
* @param {number} score
|
|
1093
|
+
*/
|
|
1094
|
+
async function handleScaffoldDecision(projectDir, session, decision, agent, score) {
|
|
1095
|
+
const gate = checkScaffoldGate(session);
|
|
1096
|
+
if (!gate.fires) {
|
|
1097
|
+
return errorResult(
|
|
1098
|
+
'No scaffold decision pending (no eligible candidate with confidence >= 0.7 and not already applied).',
|
|
1099
|
+
'NO_SCAFFOLD_PENDING',
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
const preset = gate.preset;
|
|
1104
|
+
let scaffoldResult = null;
|
|
1105
|
+
|
|
1106
|
+
if (decision === 'apply' || decision === 'dryrun') {
|
|
1107
|
+
let scaffoldSourceDir;
|
|
1108
|
+
try {
|
|
1109
|
+
scaffoldSourceDir = resolveScaffoldSource(projectDir);
|
|
1110
|
+
} catch (err) {
|
|
1111
|
+
return errorResult(
|
|
1112
|
+
`Cannot locate scaffold source directory. ${err.message}`,
|
|
1113
|
+
'SCAFFOLD_SOURCE_MISSING',
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
try {
|
|
1117
|
+
// Sanity-check the preset exists before invoking applyScaffold.
|
|
1118
|
+
loadScaffoldManifest(scaffoldSourceDir, preset);
|
|
1119
|
+
} catch (err) {
|
|
1120
|
+
return errorResult(
|
|
1121
|
+
`Scaffold preset "${preset}" not found at ${scaffoldSourceDir}: ${err.message}`,
|
|
1122
|
+
'SCAFFOLD_PRESET_MISSING',
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
scaffoldResult = applyScaffold({
|
|
1126
|
+
projectDir,
|
|
1127
|
+
scaffoldSourceDir,
|
|
1128
|
+
preset,
|
|
1129
|
+
dryRun: decision === 'dryrun',
|
|
1130
|
+
force: false,
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// On 'apply' and 'skip', record the preset as resolved so the gate does
|
|
1135
|
+
// not re-fire on a future advance. On 'dryrun', leave it pending.
|
|
1136
|
+
if (decision === 'apply' || decision === 'skip') {
|
|
1137
|
+
const appliedList = Array.isArray(session.scaffold_applied) ? [...session.scaffold_applied] : [];
|
|
1138
|
+
if (!appliedList.includes(preset)) appliedList.push(preset);
|
|
1139
|
+
await updateSession(projectDir, { scaffold_applied: appliedList });
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// Dry-run: return info, leave pipeline at qa-planning — user must re-run
|
|
1143
|
+
// advance with --decision apply|skip to actually proceed.
|
|
1144
|
+
if (decision === 'dryrun') {
|
|
1145
|
+
return {
|
|
1146
|
+
advanced: false,
|
|
1147
|
+
agent_completed: agent,
|
|
1148
|
+
score,
|
|
1149
|
+
scaffold_decision: decision,
|
|
1150
|
+
scaffold: {
|
|
1151
|
+
preset: scaffoldResult.preset,
|
|
1152
|
+
version: scaffoldResult.version,
|
|
1153
|
+
would_apply: scaffoldResult.applied,
|
|
1154
|
+
would_skip: scaffoldResult.skipped,
|
|
1155
|
+
placeholders: scaffoldResult.placeholders,
|
|
1156
|
+
brandSource: scaffoldResult.brandSource,
|
|
1157
|
+
},
|
|
1158
|
+
next: {
|
|
1159
|
+
action: 'scaffold_decision_pending',
|
|
1160
|
+
agent: null,
|
|
1161
|
+
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.`,
|
|
1162
|
+
},
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// apply / skip: advance qa-planning → dev.
|
|
1167
|
+
const { session: postDecision } = loadSession(projectDir);
|
|
1168
|
+
const pipelineState = sessionToPipelineState(postDecision || session, projectDir);
|
|
1169
|
+
pipelineState.completedAgents = [...(postDecision?.completed_agents || session.completed_agents || [])];
|
|
1170
|
+
const advanceResult = advancePipeline(pipelineState, agent, { score, handoffData: {}, findings: [] });
|
|
1171
|
+
|
|
1172
|
+
const updates = {
|
|
1173
|
+
current_agent: advanceResult.nextAgent || '',
|
|
1174
|
+
last_handoff: agent,
|
|
1175
|
+
pipeline_position: pipelineState.completedAgents.length,
|
|
1176
|
+
};
|
|
1177
|
+
if (advanceResult.needsModeSwitch) {
|
|
1178
|
+
const newPhase = advanceResult.state?.phase || session.mode;
|
|
1179
|
+
updates.mode = newPhase;
|
|
1180
|
+
if (session.project) updates.project = { ...session.project, state: newPhase };
|
|
1181
|
+
await recordModeTransition(projectDir, {
|
|
1182
|
+
from: session.mode, to: newPhase,
|
|
1183
|
+
trigger: `scaffold_decision=${decision} on ${preset}`,
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
await updateSession(projectDir, updates);
|
|
1187
|
+
|
|
1188
|
+
const nextAgent = advanceResult.nextAgent || '';
|
|
1189
|
+
const progress = getPipelineProgress(advanceResult.state || pipelineState);
|
|
1190
|
+
try { updateClaudeMd(projectDir, { currentAgent: nextAgent, progress }); } catch { /* non-fatal */ }
|
|
1191
|
+
try {
|
|
1192
|
+
if (nextAgent) {
|
|
1193
|
+
writeSessionLock(projectDir, nextAgent, {
|
|
1194
|
+
phase: updates.mode || session.mode,
|
|
1195
|
+
mode: session.execution_mode || 'interactive',
|
|
1196
|
+
position: updates.pipeline_position,
|
|
1197
|
+
total: progress.total,
|
|
1198
|
+
progress: progress.percent,
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
} catch { /* non-fatal */ }
|
|
1202
|
+
|
|
1203
|
+
const next = await handleNext(projectDir);
|
|
1204
|
+
return {
|
|
1205
|
+
advanced: true,
|
|
1206
|
+
agent_completed: agent,
|
|
1207
|
+
score,
|
|
1208
|
+
scaffold_decision: decision,
|
|
1209
|
+
scaffold: scaffoldResult
|
|
1210
|
+
? {
|
|
1211
|
+
preset: scaffoldResult.preset,
|
|
1212
|
+
version: scaffoldResult.version,
|
|
1213
|
+
applied: scaffoldResult.applied,
|
|
1214
|
+
skipped: scaffoldResult.skipped,
|
|
1215
|
+
manifestPath: scaffoldResult.manifestPath,
|
|
1216
|
+
}
|
|
1217
|
+
: { preset, skipped_by_user: true },
|
|
1218
|
+
phase_transition: advanceResult.needsModeSwitch ? { triggered: true, from: session.mode, to: updates.mode } : { triggered: false },
|
|
1219
|
+
next,
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
|
|
448
1223
|
async function handleAdvance(projectDir, args) {
|
|
449
1224
|
const agent = args.agent;
|
|
450
1225
|
const score = parseInt(args.score, 10);
|
|
@@ -457,6 +1232,33 @@ async function handleAdvance(projectDir, args) {
|
|
|
457
1232
|
const { loaded, session } = loadSession(projectDir);
|
|
458
1233
|
if (!loaded || !session) return errorResult('No session found', 'NO_SESSION');
|
|
459
1234
|
|
|
1235
|
+
// Fase 10 scaffold decision — route apply|skip|dryrun to the scaffold
|
|
1236
|
+
// dispatcher BEFORE the alreadyCompleted guard so the user can resolve a
|
|
1237
|
+
// pending scaffold decision after qa-planning is marked complete.
|
|
1238
|
+
const SCAFFOLD_DECISIONS = new Set(['apply', 'skip', 'dryrun']);
|
|
1239
|
+
if (decision && SCAFFOLD_DECISIONS.has(decision)) {
|
|
1240
|
+
return handleScaffoldDecision(projectDir, session, decision, agent, score);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// Idempotency guard: if this agent is already marked completed, return no-op.
|
|
1244
|
+
// Prevents duplicate advance calls (hook + user-approval path) from double-
|
|
1245
|
+
// incrementing pipeline_position or corrupting completed_agents.
|
|
1246
|
+
const agentState = session.agents && session.agents[agent];
|
|
1247
|
+
const alreadyCompleted =
|
|
1248
|
+
agentState && agentState.status === 'completed' &&
|
|
1249
|
+
Array.isArray(session.completed_agents) && session.completed_agents.includes(agent);
|
|
1250
|
+
if (alreadyCompleted && !decision) {
|
|
1251
|
+
const next = await handleNext(projectDir);
|
|
1252
|
+
return {
|
|
1253
|
+
advanced: false,
|
|
1254
|
+
already_advanced: true,
|
|
1255
|
+
agent_completed: agent,
|
|
1256
|
+
score: agentState.score,
|
|
1257
|
+
next,
|
|
1258
|
+
status_summary: `${agent} already completed (score ${agentState.score}). Next: ${next.agent || 'none'}.`,
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
|
|
460
1262
|
// Handle user_preview decisions
|
|
461
1263
|
if (decision) {
|
|
462
1264
|
const pipelineState = sessionToPipelineState(session, projectDir);
|
|
@@ -479,22 +1281,508 @@ async function handleAdvance(projectDir, args) {
|
|
|
479
1281
|
return { advanced: true, agent_completed: agent, score, decision, phase_transition: { triggered: previewResult.needsModeSwitch }, next };
|
|
480
1282
|
}
|
|
481
1283
|
|
|
482
|
-
//
|
|
483
|
-
|
|
1284
|
+
// Read agent handoff document to extract Decision Trail entries.
|
|
1285
|
+
// Agents (e.g. qa-implementation) write Decision Trail entries to their handoff
|
|
1286
|
+
// instead of directly editing session.yaml (blocked by mode-governance hook).
|
|
1287
|
+
let handoffData = {};
|
|
1288
|
+
// Handoff files live at <frameworkDir>/artifacts/handoffs/<agent>-handoff.md
|
|
1289
|
+
// EXCEPT when artifacts/ is user-facing (non-framework). Currently install
|
|
1290
|
+
// writes artifacts to project root <projectDir>/artifacts/. Check both locations
|
|
1291
|
+
// to cover legacy + current layouts.
|
|
1292
|
+
const fwDir = resolveFrameworkDir(projectDir);
|
|
1293
|
+
const handoffCandidates = [
|
|
1294
|
+
join(projectDir, 'artifacts', 'handoffs', `${agent}-handoff.md`),
|
|
1295
|
+
join(projectDir, fwDir, 'artifacts', 'handoffs', `${agent}-handoff.md`),
|
|
1296
|
+
];
|
|
1297
|
+
const handoffPath = handoffCandidates.find(p => existsSync(p));
|
|
1298
|
+
if (handoffPath) {
|
|
1299
|
+
try {
|
|
1300
|
+
const handoffMarkdown = readFileSync(handoffPath, 'utf-8');
|
|
1301
|
+
handoffData = parseHandoffDecisionTrail(handoffMarkdown);
|
|
1302
|
+
// Fase 10 auto-gate — parse scaffold candidates/signals alongside Decision Trail.
|
|
1303
|
+
const scaffold = parseHandoffScaffoldSignals(handoffMarkdown);
|
|
1304
|
+
handoffData.scaffold_candidates = scaffold.scaffold_candidates;
|
|
1305
|
+
handoffData.scaffold_signals = scaffold.scaffold_signals;
|
|
1306
|
+
} catch {
|
|
1307
|
+
// Non-fatal: if handoff can't be read, skip Decision Trail / scaffold merge
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
484
1310
|
|
|
485
|
-
//
|
|
486
|
-
|
|
487
|
-
|
|
1311
|
+
// Deterministic gates: block advance if required artifacts are missing.
|
|
1312
|
+
|
|
1313
|
+
// BRIEF gate: reference frames captured → Visualizer MUST run for ALL sites before Brief advances.
|
|
1314
|
+
if (agent === 'brief') {
|
|
1315
|
+
const refsDir = join(projectDir, 'artifacts', '4-UX', 'references');
|
|
1316
|
+
const completePath = join(refsDir, 'capture-complete.json');
|
|
1317
|
+
const analysisPath = join(projectDir, 'artifacts', '4-UX', 'reference-analysis.md');
|
|
1318
|
+
|
|
1319
|
+
if (existsSync(completePath)) {
|
|
1320
|
+
let captureData = {};
|
|
1321
|
+
try { captureData = JSON.parse(readFileSync(completePath, 'utf-8')); } catch { /* ignore */ }
|
|
1322
|
+
const okSites = Object.entries(captureData.sites || {})
|
|
1323
|
+
.filter(([, s]) => s.status === 'ok')
|
|
1324
|
+
.map(([slug, s]) => ({ slug, url: s.url, desktopFrames: `artifacts/4-UX/references/${slug}/desktop/frames/`, mobileFrames: `artifacts/4-UX/references/${slug}/mobile/frames/` }));
|
|
1325
|
+
|
|
1326
|
+
if (okSites.length > 0) {
|
|
1327
|
+
// Check: consolidated file must mention ALL sites, OR every site has its own analysis.md
|
|
1328
|
+
let allAnalyzed = false;
|
|
1329
|
+
if (existsSync(analysisPath)) {
|
|
1330
|
+
try {
|
|
1331
|
+
const content = readFileSync(analysisPath, 'utf-8');
|
|
1332
|
+
allAnalyzed = okSites.every(s => content.includes(s.slug) || content.includes(s.url));
|
|
1333
|
+
} catch { /* ignore */ }
|
|
1334
|
+
}
|
|
1335
|
+
if (!allAnalyzed) {
|
|
1336
|
+
const missingSites = okSites.filter(s => !existsSync(join(refsDir, s.slug, 'analysis.md')));
|
|
1337
|
+
allAnalyzed = missingSites.length === 0;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
if (!allAnalyzed) {
|
|
1341
|
+
// Find which sites still need analysis
|
|
1342
|
+
const needsAnalysis = okSites.filter(s => {
|
|
1343
|
+
if (existsSync(join(refsDir, s.slug, 'analysis.md'))) return false;
|
|
1344
|
+
if (existsSync(analysisPath)) {
|
|
1345
|
+
try {
|
|
1346
|
+
const content = readFileSync(analysisPath, 'utf-8');
|
|
1347
|
+
return !(content.includes(s.slug) || content.includes(s.url));
|
|
1348
|
+
} catch { return true; }
|
|
1349
|
+
}
|
|
1350
|
+
return true;
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
return {
|
|
1354
|
+
advanced: false,
|
|
1355
|
+
blocked: true,
|
|
1356
|
+
reason: 'visual_analysis_required',
|
|
1357
|
+
message: `${needsAnalysis.length} of ${okSites.length} site(s) still need visual analysis. Spawn ONE Visualizer per missing site (parallel).`,
|
|
1358
|
+
already_analyzed: okSites.length - needsAnalysis.length,
|
|
1359
|
+
action_required: {
|
|
1360
|
+
step1: 'Display to user (translate to session.language): "Analyzing the reference sites you provided — scroll experience, animations, colors, and typography in depth..."',
|
|
1361
|
+
step2: `Spawn ${needsAnalysis.length} Agent(s) IN PARALLEL (one per site, each with model: sonnet):`,
|
|
1362
|
+
agents: needsAnalysis.map(s => ({
|
|
1363
|
+
description: `Visualizer — ${s.slug} scroll analysis`,
|
|
1364
|
+
model: 'sonnet',
|
|
1365
|
+
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`,
|
|
1366
|
+
})),
|
|
1367
|
+
step3: 'Wait for ALL to return. Merge ALL per-site analysis.md files into artifacts/4-UX/reference-analysis.md',
|
|
1368
|
+
step4: 'Append Visual Experience Analysis summary to brief-report.md',
|
|
1369
|
+
step5: 'Present the UPDATED brief to the user with Completion Options. Do NOT advance — wait for user approval.',
|
|
1370
|
+
},
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
// okSites.length === 0 means all captures failed → allow advance, UX falls back to text
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// If capture not yet complete, return immediately — don't block advance for 10 min.
|
|
1378
|
+
// Tell Claude to call wait-for-capture separately, then re-try advance.
|
|
1379
|
+
if (!existsSync(join(refsDir, 'capture-complete.json'))) {
|
|
1380
|
+
try {
|
|
1381
|
+
const dirs = readdirSync(refsDir).filter(d => {
|
|
1382
|
+
try { return existsSync(join(refsDir, d, 'desktop')); } catch { return false; }
|
|
1383
|
+
});
|
|
1384
|
+
if (dirs.length > 0) {
|
|
1385
|
+
return {
|
|
1386
|
+
advanced: false,
|
|
1387
|
+
blocked: true,
|
|
1388
|
+
reason: 'capture_in_progress',
|
|
1389
|
+
message: 'Reference capture still running in background. Wait for it, then spawn Visualizer, then present options to user.',
|
|
1390
|
+
action_required: {
|
|
1391
|
+
step1: 'Run: node .chati.dev/orchestrator/chati-router.js wait-for-capture',
|
|
1392
|
+
step2: 'Then spawn Visualizer to analyze frames (save to artifacts/4-UX/reference-analysis.md)',
|
|
1393
|
+
step3: 'Append Visual Experience Analysis summary to brief-report.md',
|
|
1394
|
+
step4: 'Present the UPDATED brief to the user with Completion Options. Do NOT advance — wait for user approval.',
|
|
1395
|
+
},
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
} catch { /* refsDir may not exist — no references, skip */ }
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// QA-Visual gate: visual-qa.js must have run AND Visualizer must have analyzed
|
|
1403
|
+
// EVERY page — mirrors the Brief gate (ONE Visualizer per site/page in parallel).
|
|
1404
|
+
if (agent === 'qa-visual') {
|
|
1405
|
+
const vqaOutput = join(projectDir, '.chati', 'visual-qa');
|
|
1406
|
+
const projectReport = join(vqaOutput, 'report.json');
|
|
1407
|
+
// Only look at project-scoped .chati/visual-qa/. The /tmp/visual-qa/ fallback
|
|
1408
|
+
// was removed because it cross-contaminated isolated tests: any prior run of
|
|
1409
|
+
// visual-qa.js against a real project left /tmp/visual-qa/report.json behind,
|
|
1410
|
+
// which then polluted tmpdir-based test fixtures that expected a clean state.
|
|
1411
|
+
const reportPath = existsSync(projectReport) ? projectReport : null;
|
|
1412
|
+
const reportDir = reportPath ? reportPath.replace('/report.json', '') : vqaOutput;
|
|
1413
|
+
const consolidatedPath = join(projectDir, 'artifacts', '8-QA-Visual', 'visual-qa-analysis.md');
|
|
1414
|
+
const finalReportPath = join(projectDir, 'artifacts', '9-QA-Implementation', 'qa-visual-report.md');
|
|
1415
|
+
|
|
1416
|
+
// Check report.json exists (visual-qa.js ran)
|
|
1417
|
+
if (!reportPath) {
|
|
1418
|
+
// Autonomous-completion v1 (ADR-AUTO-02) rewrites this path:
|
|
1419
|
+
// The post-dev hook (P4) normally fires visual-qa.js when dev
|
|
1420
|
+
// completes. If we land here, either the hook did not fire (opted
|
|
1421
|
+
// out via config.yaml or dev score < 90) or its spawn is still
|
|
1422
|
+
// finishing in the background. Give Claude the commands to
|
|
1423
|
+
// recover manually.
|
|
1424
|
+
return {
|
|
1425
|
+
advanced: false,
|
|
1426
|
+
blocked: true,
|
|
1427
|
+
reason: 'visual_qa_not_run',
|
|
1428
|
+
message: 'visual-qa.js has not produced report.json yet. Either the post-dev auto-QA hook is still running, or it was skipped.',
|
|
1429
|
+
action_required: {
|
|
1430
|
+
step1: 'Run: node .chati.dev/scripts/visual-qa.js --pages "/" <add routes> --output .chati/visual-qa/',
|
|
1431
|
+
step2: 'The script spawns its own Next dev server on a free port, so you do not need to start one yourself.',
|
|
1432
|
+
step3: 'When the capture finishes, re-run advance qa-visual.',
|
|
1433
|
+
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.',
|
|
1434
|
+
},
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
// Read captured pages from report.json
|
|
1439
|
+
let capturedPages = [];
|
|
1440
|
+
try {
|
|
1441
|
+
const report = JSON.parse(readFileSync(reportPath, 'utf-8'));
|
|
1442
|
+
capturedPages = (report.pages || []).map(p => ({ slug: p.slug || 'home', path: p.path || '/' }));
|
|
1443
|
+
} catch { /* ignore */ }
|
|
1444
|
+
|
|
1445
|
+
if (capturedPages.length === 0) {
|
|
1446
|
+
return {
|
|
1447
|
+
advanced: false,
|
|
1448
|
+
blocked: true,
|
|
1449
|
+
reason: 'visual_qa_report_empty',
|
|
1450
|
+
message: 'visual-qa.js ran but report.json has no captured pages. Re-run the script.',
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// Autonomous-completion v1 (ADR-AUTO-04): Reduced-motion enforcement gate.
|
|
1455
|
+
// When visual-qa.js runs with --reduced-motion on|both, it writes
|
|
1456
|
+
// summary.reduced_motion_mode + summary.reduced_motion_violations to
|
|
1457
|
+
// report.json. A violation = a scroll-triggered region where frames
|
|
1458
|
+
// diverged between motion-on and motion-off captures — i.e. animation
|
|
1459
|
+
// that ignores the user's prefers-reduced-motion: reduce preference.
|
|
1460
|
+
// v1 contract: BLOCK when violations[] is non-empty (fault_origin: CODE).
|
|
1461
|
+
// v1 WARN: if reduced_motion_mode === 'off', the a11y pass never ran.
|
|
1462
|
+
// The comparison pass itself ships in v1.2 — in v1, violations[] is
|
|
1463
|
+
// always empty, so the gate runs clean today but is ready for the
|
|
1464
|
+
// upgrade without handleAdvance edits.
|
|
1465
|
+
try {
|
|
1466
|
+
const report = JSON.parse(readFileSync(reportPath, 'utf-8'));
|
|
1467
|
+
const rmMode = report.summary?.reduced_motion_mode;
|
|
1468
|
+
const violations = Array.isArray(report.summary?.reduced_motion_violations)
|
|
1469
|
+
? report.summary.reduced_motion_violations
|
|
1470
|
+
: [];
|
|
1471
|
+
if (violations.length > 0) {
|
|
1472
|
+
return {
|
|
1473
|
+
advanced: false,
|
|
1474
|
+
blocked: true,
|
|
1475
|
+
reason: 'reduced_motion_violation',
|
|
1476
|
+
message: `${violations.length} region(s) animate under prefers-reduced-motion: reduce. Motion MUST be disabled when the user preference is set.`,
|
|
1477
|
+
reduced_motion_violations: violations,
|
|
1478
|
+
fault_origin: 'CODE',
|
|
1479
|
+
action_required: {
|
|
1480
|
+
step1: 'Display the violating pages + regions to the user.',
|
|
1481
|
+
step2: 'Route back to dev for rework (Fault Vector Protocol: CODE → dev).',
|
|
1482
|
+
step3: 'Dev wraps the offending animation in usePrefersReducedMotion() (from lib/animations/gsap.ts.template) and returns early when motion is reduced.',
|
|
1483
|
+
step4: 'Re-run visual-qa.js with --reduced-motion both, then re-run advance qa-visual.',
|
|
1484
|
+
},
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
// WARN path — a11y pass not executed.
|
|
1488
|
+
if (rmMode === 'off') {
|
|
1489
|
+
// Non-blocking — attached to the response later as advisory_warnings
|
|
1490
|
+
// when we reach the success path. We do not early-return; other
|
|
1491
|
+
// gates (layout errors, per-page analysis) still need to run.
|
|
1492
|
+
// Storing in a closure var so downstream return decorates with it.
|
|
1493
|
+
// (Implemented as a side-effect on report — but report is local here;
|
|
1494
|
+
// use a hoisted pseudo-state via the outer function.)
|
|
1495
|
+
}
|
|
1496
|
+
} catch {
|
|
1497
|
+
// report.json unparseable — defer to the other checks which will catch
|
|
1498
|
+
// the corrupted-file case explicitly.
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// Objective layout gate — catches bugs that pass qualitative Visualizer review.
|
|
1502
|
+
// Root cause of 2026-04-16 cascade-layer trap: `* { margin: 0 }` outside any
|
|
1503
|
+
// @layer silently overrode all Tailwind utilities; Visualizers approved at
|
|
1504
|
+
// 94% because 1280px viewport masked the x=0 container alignment. This gate
|
|
1505
|
+
// blocks advance on measured facts (getBoundingClientRect / getComputedStyle)
|
|
1506
|
+
// regardless of what LLM review said.
|
|
1507
|
+
try {
|
|
1508
|
+
const report = JSON.parse(readFileSync(reportPath, 'utf-8'));
|
|
1509
|
+
const layoutErrors = (report.summary?.layout_errors || []).filter(e => e.severity === 'error');
|
|
1510
|
+
if (layoutErrors.length > 0) {
|
|
1511
|
+
// Group by page + type for a clean summary
|
|
1512
|
+
const byPageType = {};
|
|
1513
|
+
for (const e of layoutErrors) {
|
|
1514
|
+
const key = `${e.page || '?'}::${e.type}`;
|
|
1515
|
+
byPageType[key] = byPageType[key] || { page: e.page, type: e.type, viewports: [], details: [] };
|
|
1516
|
+
byPageType[key].viewports.push(e.viewport);
|
|
1517
|
+
byPageType[key].details.push(e.detail);
|
|
1518
|
+
}
|
|
1519
|
+
return {
|
|
1520
|
+
advanced: false,
|
|
1521
|
+
blocked: true,
|
|
1522
|
+
reason: 'layout_errors_detected',
|
|
1523
|
+
message: `${layoutErrors.length} objective layout error(s) measured by Playwright. These are measurements, not judgments — must be fixed before advancing.`,
|
|
1524
|
+
layout_error_count: layoutErrors.length,
|
|
1525
|
+
layout_errors: Object.values(byPageType),
|
|
1526
|
+
fault_origin: 'CODE',
|
|
1527
|
+
action_required: {
|
|
1528
|
+
step1: 'Display the errors to the user with specific selectors + measurements',
|
|
1529
|
+
step2: 'Route back to dev for rework (Fault Vector Protocol: CODE → dev)',
|
|
1530
|
+
step3: 'Dev fixes → pnpm build → re-run visual-qa.js → re-run advance qa-visual',
|
|
1531
|
+
common_causes: [
|
|
1532
|
+
'Tailwind v4: unlayered CSS in globals.css wins over @layer utilities (wrap globals in @layer base {})',
|
|
1533
|
+
'Tailwind v4: arbitrary CSS var without type hint silently omits property (use text-[length:var(...)])',
|
|
1534
|
+
'Missing mx-auto on a max-w-* container',
|
|
1535
|
+
'Element wider than viewport causing horizontal overflow',
|
|
1536
|
+
],
|
|
1537
|
+
},
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
} catch {
|
|
1541
|
+
// report.json unparseable — fall through to other checks, they will catch it
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// NEW (Fase 6): Shared CSS / token change → full coverage required.
|
|
1545
|
+
// Regression risk: a change to globals.css or brand.ts can silently break
|
|
1546
|
+
// routes that were NOT touched by the current task. If the current report
|
|
1547
|
+
// covers fewer routes than the project's app/ directory exposes, block.
|
|
1548
|
+
try {
|
|
1549
|
+
const modifiedFiles = getModifiedFiles(projectDir);
|
|
1550
|
+
const coverage = checkSharedCSSCoverage({ capturedPages, projectDir, modifiedFiles });
|
|
1551
|
+
if (coverage.blocked) {
|
|
1552
|
+
return {
|
|
1553
|
+
advanced: false,
|
|
1554
|
+
blocked: true,
|
|
1555
|
+
reason: coverage.reason,
|
|
1556
|
+
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.`,
|
|
1557
|
+
triggering_files: coverage.triggeringFiles,
|
|
1558
|
+
captured_routes: capturedPages.map(p => p.path),
|
|
1559
|
+
expected_routes: coverage.appRoutes,
|
|
1560
|
+
uncovered_routes: coverage.uncovered,
|
|
1561
|
+
fault_origin: 'SPEC',
|
|
1562
|
+
action_required: {
|
|
1563
|
+
step1: 'Display the uncovered routes and the triggering shared files to the user.',
|
|
1564
|
+
step2: `Re-run visual-qa.js with --pages covering all ${coverage.appRoutes.length} routes:`,
|
|
1565
|
+
step3: `node .chati.dev/scripts/visual-qa.js --pages ${coverage.appRoutes.map(r => `"${r}"`).join(' ')} --url http://localhost:3456 --output .chati/visual-qa/`,
|
|
1566
|
+
step4: 'Then re-run advance qa-visual.',
|
|
1567
|
+
rationale: 'Shared-CSS changes can regress any route; visual-QA must check all of them when this class of file is modified.',
|
|
1568
|
+
},
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
} catch {
|
|
1572
|
+
// git unavailable or projectDir not a repo — skip scope expansion silently.
|
|
1573
|
+
// Other checks below still enforce correctness on the covered routes.
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// Check: every captured page has per-page analysis.md OR is mentioned in the consolidated file
|
|
1577
|
+
let consolidatedContent = '';
|
|
1578
|
+
if (existsSync(consolidatedPath)) {
|
|
1579
|
+
try { consolidatedContent = readFileSync(consolidatedPath, 'utf-8'); } catch { /* ignore */ }
|
|
1580
|
+
}
|
|
1581
|
+
const needsAnalysis = capturedPages.filter(p => {
|
|
1582
|
+
const perPage = join(reportDir, p.slug, 'analysis.md');
|
|
1583
|
+
if (existsSync(perPage)) return false;
|
|
1584
|
+
if (consolidatedContent && (consolidatedContent.includes(p.slug) || consolidatedContent.includes(`\`${p.path}\``))) return false;
|
|
1585
|
+
return true;
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
if (needsAnalysis.length > 0) {
|
|
1589
|
+
return {
|
|
1590
|
+
advanced: false,
|
|
1591
|
+
blocked: true,
|
|
1592
|
+
reason: 'visual_analysis_required',
|
|
1593
|
+
message: `${needsAnalysis.length} of ${capturedPages.length} page(s) still need Visualizer analysis. Spawn ONE Visualizer per missing page (parallel).`,
|
|
1594
|
+
already_analyzed: capturedPages.length - needsAnalysis.length,
|
|
1595
|
+
action_required: {
|
|
1596
|
+
step1: 'Display to user (translate to session.language): "Analyzing the build visually and comparing against the original references..."',
|
|
1597
|
+
step2: `Spawn ${needsAnalysis.length} Agent(s) IN PARALLEL (one per page, each with model: sonnet):`,
|
|
1598
|
+
agents: needsAnalysis.map(p => ({
|
|
1599
|
+
description: `Visualizer — ${p.slug} build validation`,
|
|
1600
|
+
model: 'sonnet',
|
|
1601
|
+
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`,
|
|
1602
|
+
})),
|
|
1603
|
+
step3: 'Wait for ALL to return.',
|
|
1604
|
+
step4: `Merge ALL per-page analysis.md files into ${consolidatedPath} (consolidated Visualizer report).`,
|
|
1605
|
+
step5: `Write final QA-Visual verdict to ${finalReportPath} with score, blockers, warnings.`,
|
|
1606
|
+
step6: 'Present findings and verdict to the user with Completion Options. Do NOT advance — wait for user approval.',
|
|
1607
|
+
},
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
// Check consolidated analysis file exists
|
|
1612
|
+
if (!existsSync(consolidatedPath)) {
|
|
1613
|
+
return {
|
|
1614
|
+
advanced: false,
|
|
1615
|
+
blocked: true,
|
|
1616
|
+
reason: 'consolidated_analysis_missing',
|
|
1617
|
+
message: `Per-page analyses exist but consolidated file is missing. Create ${consolidatedPath} by merging all per-page analyses.`,
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// Check final QA-Visual report exists at canonical path (NOT at 7-QA-Implementation)
|
|
1622
|
+
if (!existsSync(finalReportPath)) {
|
|
1623
|
+
const wrongPath = join(projectDir, 'artifacts', '7-QA-Implementation', 'qa-visual-report.md');
|
|
1624
|
+
const hint = existsSync(wrongPath)
|
|
1625
|
+
? ` Found qa-visual-report.md at WRONG path ${wrongPath} — move it to ${finalReportPath}.`
|
|
1626
|
+
: '';
|
|
1627
|
+
return {
|
|
1628
|
+
advanced: false,
|
|
1629
|
+
blocked: true,
|
|
1630
|
+
reason: 'qa_visual_final_report_missing',
|
|
1631
|
+
message: `Final QA-Visual report missing at canonical path: ${finalReportPath}.${hint}`,
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// QA-Implementation gate: report MUST be at artifacts/9-QA-Implementation/ (NOT 7-*)
|
|
1637
|
+
if (agent === 'qa-implementation') {
|
|
1638
|
+
const canonicalDir = join(projectDir, 'artifacts', '9-QA-Implementation');
|
|
1639
|
+
const wrongDir = join(projectDir, 'artifacts', '7-QA-Implementation');
|
|
1640
|
+
const canonicalReport = join(canonicalDir, 'qa-implementation-report.md');
|
|
1641
|
+
const canonicalFinal = join(canonicalDir, 'qa-implementation-final-report.md');
|
|
1642
|
+
|
|
1643
|
+
// Block if wrong path exists — force relocation
|
|
1644
|
+
if (existsSync(wrongDir)) {
|
|
1645
|
+
return {
|
|
1646
|
+
advanced: false,
|
|
1647
|
+
blocked: true,
|
|
1648
|
+
reason: 'qa_impl_wrong_path',
|
|
1649
|
+
message: `artifacts/7-QA-Implementation/ is NOT canonical. Move all files to ${canonicalDir} (7- is reserved for QA-Planning).`,
|
|
1650
|
+
action_required: {
|
|
1651
|
+
step1: `Move files: mv artifacts/7-QA-Implementation/* artifacts/9-QA-Implementation/`,
|
|
1652
|
+
step2: `Remove empty folder: rmdir artifacts/7-QA-Implementation`,
|
|
1653
|
+
step3: 'Then re-run advance',
|
|
1654
|
+
},
|
|
1655
|
+
};
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
// Require at least one of the canonical report files
|
|
1659
|
+
if (!existsSync(canonicalReport) && !existsSync(canonicalFinal)) {
|
|
1660
|
+
return {
|
|
1661
|
+
advanced: false,
|
|
1662
|
+
blocked: true,
|
|
1663
|
+
reason: 'qa_impl_report_missing',
|
|
1664
|
+
message: `QA-Implementation report missing. Expected ${canonicalReport} or ${canonicalFinal}.`,
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
// UX (brand-architect) gate
|
|
1670
|
+
if (agent === 'ux') {
|
|
1671
|
+
// Fase 7 — Task-to-UX fidelity. Before checking brandbook.html, verify that
|
|
1672
|
+
// if the brief or reference-analysis cites premium animation references,
|
|
1673
|
+
// the brand-architect has produced the animation-inventory artifact. The
|
|
1674
|
+
// inventory is mandatory: without it, tasks generate generic "add scroll
|
|
1675
|
+
// reveals" instead of the specific patterns the user's references
|
|
1676
|
+
// encode, and qa-visual has no per-pattern checklist for Mode 2.
|
|
1677
|
+
const inventoryPath = join(projectDir, 'artifacts', '4-UX', 'animation-inventory.md');
|
|
1678
|
+
if (!existsSync(inventoryPath)) {
|
|
1679
|
+
const corpus = readBriefAndRefCorpus(projectDir);
|
|
1680
|
+
const refs = detectPremiumRefs(corpus);
|
|
1681
|
+
if (refs.detected) {
|
|
1682
|
+
return {
|
|
1683
|
+
advanced: false,
|
|
1684
|
+
blocked: true,
|
|
1685
|
+
reason: 'animation_inventory_required',
|
|
1686
|
+
message: `Brief or reference-analysis cites premium animation references (${refs.matches.join(', ')}). Brand-architect must produce artifacts/4-UX/animation-inventory.md before advancing.`,
|
|
1687
|
+
triggering_matches: refs.matches,
|
|
1688
|
+
expected_artifact: 'artifacts/4-UX/animation-inventory.md',
|
|
1689
|
+
fault_origin: 'SPEC',
|
|
1690
|
+
action_required: {
|
|
1691
|
+
step1: 'Inspect artifacts/1-Brief/brief-report.md and artifacts/4-UX/reference-analysis.md to enumerate every named animation pattern.',
|
|
1692
|
+
step2: 'Write artifacts/4-UX/animation-inventory.md with one row per pattern. Schema:',
|
|
1693
|
+
schema: '| Pattern name | Observed at (site / section) | Parameters (stagger, scrub, easing, duration) | Reproducible via (scaffold file) | Target route |',
|
|
1694
|
+
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).',
|
|
1695
|
+
step4: 'Append a section to brandbook.md (or brand-architect handoff) noting the inventory is locked and tasks must 1:1 map against it.',
|
|
1696
|
+
step5: 'Then re-run advance ux.',
|
|
1697
|
+
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.',
|
|
1698
|
+
},
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
const brandbookPath = join(projectDir, 'artifacts', '4-UX', 'brandbook.html');
|
|
1704
|
+
if (!existsSync(brandbookPath)) {
|
|
1705
|
+
return {
|
|
1706
|
+
advanced: false,
|
|
1707
|
+
blocked: true,
|
|
1708
|
+
reason: 'brandbook_html_missing',
|
|
1709
|
+
message: 'brandbook.html is a mandatory deliverable for the UX agent. Write artifacts/4-UX/brandbook.html before advancing.',
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// Record completion (merges Decision Trail entries from handoff into session.yaml)
|
|
1715
|
+
await recordAgentCompletion(projectDir, { agent, status, score, handoffData });
|
|
1716
|
+
recordEvent(projectDir, EventType.AGENT_COMPLETED, agent, { score, status });
|
|
1717
|
+
|
|
1718
|
+
// Fase 10 — Scaffold auto-gate. After qa-planning completes, check whether
|
|
1719
|
+
// a scaffold preset has cleared the confidence threshold and is still
|
|
1720
|
+
// unapplied. If so, return an action_required response with the 3-option
|
|
1721
|
+
// decision payload. qa-planning stays marked complete; the user's next
|
|
1722
|
+
// advance call must carry --decision apply|skip|dryrun.
|
|
1723
|
+
if (status === 'completed' && score >= 95 && agent === 'qa-planning') {
|
|
1724
|
+
const { session: postQa } = loadSession(projectDir);
|
|
1725
|
+
const gate = checkScaffoldGate(postQa || session);
|
|
1726
|
+
if (gate.fires) {
|
|
1727
|
+
return buildScaffoldGateResponse(gate, {
|
|
1728
|
+
agent,
|
|
1729
|
+
score,
|
|
1730
|
+
phaseTransition: { triggered: false },
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
// Advance pipeline. Build the pipelineState from POST-completion session
|
|
1736
|
+
// (reload after recordAgentCompletion) so completedAgents reflects the dedup
|
|
1737
|
+
// guard in session-manager (no duplicate pushes).
|
|
1738
|
+
const { session: postCompletion } = loadSession(projectDir);
|
|
1739
|
+
const pipelineState = sessionToPipelineState(postCompletion || session, projectDir);
|
|
1740
|
+
pipelineState.completedAgents = [...(postCompletion?.completed_agents || session.completed_agents || [])];
|
|
488
1741
|
pipelineState.agents[agent] = { status: 'completed', score, startedAt: null, completedAt: new Date().toISOString() };
|
|
489
1742
|
|
|
490
|
-
const advanceResult = advancePipeline(pipelineState, agent, {
|
|
1743
|
+
const advanceResult = advancePipeline(pipelineState, agent, {
|
|
1744
|
+
score,
|
|
1745
|
+
handoffData,
|
|
1746
|
+
findings: handoffData?.decision_trail_entries || [],
|
|
1747
|
+
});
|
|
491
1748
|
|
|
492
1749
|
// Update session with new state
|
|
493
1750
|
const updates = {
|
|
494
1751
|
current_agent: advanceResult.nextAgent || '',
|
|
495
1752
|
last_handoff: agent,
|
|
1753
|
+
pipeline_position: pipelineState.completedAgents.length,
|
|
496
1754
|
};
|
|
497
1755
|
|
|
1756
|
+
// Backward transition (Article XXII Fault Vector Protocol):
|
|
1757
|
+
// QA failed → reset target agent (and everything after it) back to pending.
|
|
1758
|
+
// Persist the new agent states + correction_cycles counter + pipeline_position.
|
|
1759
|
+
if (advanceResult.nextAction === 'correction_loop' || advanceResult.nextAction === 'escalate') {
|
|
1760
|
+
const advancedState = advanceResult.state || pipelineState;
|
|
1761
|
+
// Serialize agent map back to session shape (status/score/started/completed).
|
|
1762
|
+
const agentResults = { ...(session.agent_results || {}) };
|
|
1763
|
+
for (const [name, a] of Object.entries(advancedState.agents || {})) {
|
|
1764
|
+
agentResults[name] = {
|
|
1765
|
+
...(agentResults[name] || {}),
|
|
1766
|
+
status: a.status,
|
|
1767
|
+
score: a.score,
|
|
1768
|
+
started_at: a.startedAt || null,
|
|
1769
|
+
completed_at: a.completedAt || null,
|
|
1770
|
+
};
|
|
1771
|
+
}
|
|
1772
|
+
updates.agents = advancedState.agents;
|
|
1773
|
+
updates.agent_results = agentResults;
|
|
1774
|
+
updates.completed_agents = [...advancedState.completedAgents];
|
|
1775
|
+
updates.pipeline_position = advancedState.completedAgents.length;
|
|
1776
|
+
updates.correction_cycles = { ...(advancedState.correctionCycles || {}) };
|
|
1777
|
+
|
|
1778
|
+
recordEvent(
|
|
1779
|
+
projectDir,
|
|
1780
|
+
advanceResult.nextAction === 'escalate' ? EventType.CORRECTION_ESCALATED : EventType.CORRECTION_TRIGGERED,
|
|
1781
|
+
advanceResult.correction?.target || agent,
|
|
1782
|
+
{ correction: advanceResult.correction },
|
|
1783
|
+
);
|
|
1784
|
+
}
|
|
1785
|
+
|
|
498
1786
|
// Handle phase transition
|
|
499
1787
|
let phaseTransition = { triggered: false };
|
|
500
1788
|
if (advanceResult.needsModeSwitch) {
|
|
@@ -510,9 +1798,113 @@ async function handleAdvance(projectDir, args) {
|
|
|
510
1798
|
to: newPhase,
|
|
511
1799
|
trigger: `${agent} completed with score ${score}`,
|
|
512
1800
|
});
|
|
1801
|
+
recordEvent(projectDir, EventType.MODE_TRANSITION, agent, { from: session.mode, to: newPhase });
|
|
513
1802
|
}
|
|
514
1803
|
|
|
515
|
-
await updateSession(projectDir, updates);
|
|
1804
|
+
const saveResult = await updateSession(projectDir, updates);
|
|
1805
|
+
if (saveResult && saveResult.saved === false) {
|
|
1806
|
+
return errorResult(`Session update failed: ${saveResult.error}`, 'SESSION_WRITE_FAILED');
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
// Refresh Magic Docs: CLAUDE.md (public) + CLAUDE.local.md (runtime lock+state).
|
|
1810
|
+
// Non-fatal: if updates fail, pipeline state already advanced on disk.
|
|
1811
|
+
const nextAgent = advanceResult.nextAgent || '';
|
|
1812
|
+
const progress = getPipelineProgress(advanceResult.state || pipelineState);
|
|
1813
|
+
try {
|
|
1814
|
+
updateClaudeMd(projectDir, { currentAgent: nextAgent, progress });
|
|
1815
|
+
} catch { /* non-fatal */ }
|
|
1816
|
+
try {
|
|
1817
|
+
if (nextAgent) {
|
|
1818
|
+
writeSessionLock(projectDir, nextAgent, {
|
|
1819
|
+
phase: updates.mode || session.mode,
|
|
1820
|
+
mode: session.execution_mode || 'interactive',
|
|
1821
|
+
position: updates.pipeline_position,
|
|
1822
|
+
total: progress.total,
|
|
1823
|
+
progress: progress.percent,
|
|
1824
|
+
});
|
|
1825
|
+
} else {
|
|
1826
|
+
// Pipeline complete: reset lock to INACTIVE
|
|
1827
|
+
const lang = session.language || 'en';
|
|
1828
|
+
removeSessionLock(projectDir, RESUME_MESSAGES[lang] || RESUME_MESSAGES.en);
|
|
1829
|
+
}
|
|
1830
|
+
} catch { /* non-fatal */ }
|
|
1831
|
+
|
|
1832
|
+
// Backward transition via Fault Vector Protocol — QA agent failed threshold.
|
|
1833
|
+
// We have already reset target agent state above; now return the correction
|
|
1834
|
+
// payload so Claude/Agent UI knows which agent to invoke next.
|
|
1835
|
+
if (advanceResult.nextAction === 'correction_loop') {
|
|
1836
|
+
const c = advanceResult.correction;
|
|
1837
|
+
// Fase 8: compute the "Reset:" list for the state card — agents that
|
|
1838
|
+
// were completed before but are now back to pending (the correction
|
|
1839
|
+
// target + everything past it in the pipeline).
|
|
1840
|
+
const beforeCompleted = new Set(session.completed_agents || []);
|
|
1841
|
+
const afterCompleted = new Set(advanceResult.state?.completedAgents || pipelineState.completedAgents || []);
|
|
1842
|
+
const resetAgents = [...beforeCompleted].filter(a => !afterCompleted.has(a));
|
|
1843
|
+
if (!resetAgents.includes(c.target)) resetAgents.unshift(c.target);
|
|
1844
|
+
const stateCard = (() => {
|
|
1845
|
+
try {
|
|
1846
|
+
const { session: post } = loadSession(projectDir);
|
|
1847
|
+
return buildStateCard({
|
|
1848
|
+
session: post || session,
|
|
1849
|
+
projectDir,
|
|
1850
|
+
correctionReset: { agents: resetAgents, files: [] },
|
|
1851
|
+
});
|
|
1852
|
+
} catch { return ''; }
|
|
1853
|
+
})();
|
|
1854
|
+
return {
|
|
1855
|
+
advanced: true,
|
|
1856
|
+
agent_completed: agent,
|
|
1857
|
+
score,
|
|
1858
|
+
phase_transition: phaseTransition,
|
|
1859
|
+
correction_loop: true,
|
|
1860
|
+
correction: c,
|
|
1861
|
+
reset: { agents: resetAgents },
|
|
1862
|
+
state_card: stateCard,
|
|
1863
|
+
next: {
|
|
1864
|
+
action: 'correction_loop',
|
|
1865
|
+
agent: c.target,
|
|
1866
|
+
reason: c.reason,
|
|
1867
|
+
fault_origin: c.faultOrigin,
|
|
1868
|
+
cycle: c.cycle,
|
|
1869
|
+
max_cycles: 2,
|
|
1870
|
+
findings_count: (c.findings || []).length,
|
|
1871
|
+
pipeline_progress: getPipelineProgress(advanceResult.state || pipelineState),
|
|
1872
|
+
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.`,
|
|
1873
|
+
},
|
|
1874
|
+
};
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
// Max correction cycles exceeded — escalate to human.
|
|
1878
|
+
if (advanceResult.nextAction === 'escalate') {
|
|
1879
|
+
const c = advanceResult.correction;
|
|
1880
|
+
const stateCard = (() => {
|
|
1881
|
+
try {
|
|
1882
|
+
const { session: post } = loadSession(projectDir);
|
|
1883
|
+
return buildStateCard({ session: post || session, projectDir });
|
|
1884
|
+
} catch { return ''; }
|
|
1885
|
+
})();
|
|
1886
|
+
return {
|
|
1887
|
+
advanced: false,
|
|
1888
|
+
blocked: true,
|
|
1889
|
+
agent_completed: agent,
|
|
1890
|
+
score,
|
|
1891
|
+
phase_transition: phaseTransition,
|
|
1892
|
+
correction_loop: true,
|
|
1893
|
+
correction: c,
|
|
1894
|
+
reason: 'max_correction_cycles_exceeded',
|
|
1895
|
+
message: c.reason,
|
|
1896
|
+
state_card: stateCard,
|
|
1897
|
+
next: {
|
|
1898
|
+
action: 'escalate',
|
|
1899
|
+
agent: null,
|
|
1900
|
+
reason: c.reason,
|
|
1901
|
+
fault_origin: c.faultOrigin,
|
|
1902
|
+
cycle: c.cycle,
|
|
1903
|
+
max_cycles: 2,
|
|
1904
|
+
status_summary: `${agent} failed after ${c.cycle} correction cycles. Human review required before pipeline can continue.`,
|
|
1905
|
+
},
|
|
1906
|
+
};
|
|
1907
|
+
}
|
|
516
1908
|
|
|
517
1909
|
// Check if user_preview is needed
|
|
518
1910
|
if (advanceResult.nextAction === 'user_preview') {
|
|
@@ -536,6 +1928,23 @@ async function handleAdvance(projectDir, args) {
|
|
|
536
1928
|
}
|
|
537
1929
|
|
|
538
1930
|
async function handleInit(projectDir, args) {
|
|
1931
|
+
// Auto-resolve: if a parent directory already has a Chati installation,
|
|
1932
|
+
// use that as the project root automatically. Prevents accidental nested
|
|
1933
|
+
// installs (stray `init/` dirs) when CLI is invoked from a subdirectory.
|
|
1934
|
+
const { dirname: pathDirname } = await import('path');
|
|
1935
|
+
let probe = pathDirname(projectDir);
|
|
1936
|
+
let depth = 0;
|
|
1937
|
+
while (probe && probe !== '/' && depth < 5) {
|
|
1938
|
+
const fwParent = existsSync(join(probe, '.chati.dev')) || existsSync(join(probe, 'chati.dev'));
|
|
1939
|
+
if (fwParent) {
|
|
1940
|
+
// Found install in parent — use that as projectDir for the rest of init.
|
|
1941
|
+
projectDir = probe;
|
|
1942
|
+
break;
|
|
1943
|
+
}
|
|
1944
|
+
probe = pathDirname(probe);
|
|
1945
|
+
depth++;
|
|
1946
|
+
}
|
|
1947
|
+
|
|
539
1948
|
// License validation at init time (moved from per-prompt UserPromptSubmit hook)
|
|
540
1949
|
try {
|
|
541
1950
|
const licensePath = join(process.env.HOME || '', '.chati-dev', 'license.yaml');
|
|
@@ -559,9 +1968,23 @@ async function handleInit(projectDir, args) {
|
|
|
559
1968
|
|
|
560
1969
|
const type = args.type || 'greenfield';
|
|
561
1970
|
const language = args.language || 'en';
|
|
562
|
-
const name = args.name || '';
|
|
563
1971
|
const workflow = args.workflow || 'full';
|
|
564
1972
|
|
|
1973
|
+
// Preserve fields from session.yaml created by installer (project name, ides, mcps)
|
|
1974
|
+
let preservedName = args.name || '';
|
|
1975
|
+
let preservedIdes = [];
|
|
1976
|
+
let preservedMcps = [];
|
|
1977
|
+
try {
|
|
1978
|
+
const existingPath = join(projectDir, '.chati', 'session.yaml');
|
|
1979
|
+
if (existsSync(existingPath)) {
|
|
1980
|
+
const existing = yaml.load(readFileSync(existingPath, 'utf-8')) || {};
|
|
1981
|
+
preservedName = preservedName || existing.project?.name || '';
|
|
1982
|
+
preservedIdes = existing.ides || [];
|
|
1983
|
+
preservedMcps = existing.mcps || [];
|
|
1984
|
+
}
|
|
1985
|
+
} catch { /* expected: session may not exist */ }
|
|
1986
|
+
const name = preservedName;
|
|
1987
|
+
|
|
565
1988
|
const isGreenfield = type === 'greenfield';
|
|
566
1989
|
|
|
567
1990
|
// Initialize pipeline state based on workflow
|
|
@@ -577,27 +2000,53 @@ async function handleInit(projectDir, args) {
|
|
|
577
2000
|
pipelineAgents = Object.keys(p.agents);
|
|
578
2001
|
}
|
|
579
2002
|
|
|
580
|
-
//
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
2003
|
+
// RESUME GUARD: if a session already exists with completed agents or a non-discover mode,
|
|
2004
|
+
// this is a RESUME — preserve all state and only update workflow/lock. Do NOT reset.
|
|
2005
|
+
const sessionPath = join(projectDir, '.chati', 'session.yaml');
|
|
2006
|
+
const isResume = existsSync(sessionPath) && (() => {
|
|
2007
|
+
try {
|
|
2008
|
+
const s = yaml.load(readFileSync(sessionPath, 'utf-8')) || {};
|
|
2009
|
+
const hasProgress = (s.completed_agents && s.completed_agents.length > 0)
|
|
2010
|
+
|| (s.mode && s.mode !== 'discover')
|
|
2011
|
+
|| (s.current_agent && s.current_agent !== '');
|
|
2012
|
+
return hasProgress;
|
|
2013
|
+
} catch { return false; }
|
|
2014
|
+
})();
|
|
2015
|
+
|
|
2016
|
+
if (isResume) {
|
|
2017
|
+
// Resume path — keep session.yaml intact, just refresh workflow if changed
|
|
2018
|
+
await updateSession(projectDir, { workflow });
|
|
2019
|
+
recordEvent(projectDir, EventType.SESSION_STARTED, 'orchestrator', { resume: true, workflow });
|
|
2020
|
+
} else {
|
|
2021
|
+
// Fresh init — create new session
|
|
2022
|
+
const result = initSession(projectDir, {
|
|
2023
|
+
mode: 'discover',
|
|
2024
|
+
projectName: name,
|
|
2025
|
+
isGreenfield,
|
|
2026
|
+
language,
|
|
2027
|
+
ides: preservedIdes,
|
|
2028
|
+
mcps: preservedMcps,
|
|
2029
|
+
});
|
|
591
2030
|
|
|
592
|
-
|
|
593
|
-
|
|
2031
|
+
if (!result.created) {
|
|
2032
|
+
return errorResult(`Failed to initialize session: ${result.error}`, 'INIT_FAILED');
|
|
2033
|
+
}
|
|
594
2034
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
2035
|
+
await updateSession(projectDir, { workflow });
|
|
2036
|
+
clearTimeline(projectDir);
|
|
2037
|
+
recordEvent(projectDir, EventType.SESSION_STARTED, 'orchestrator', { projectType: type, workflow, language });
|
|
2038
|
+
}
|
|
598
2039
|
|
|
599
|
-
//
|
|
600
|
-
|
|
2040
|
+
// Determine first/active agent — on resume, use current_agent from session
|
|
2041
|
+
let firstAgent;
|
|
2042
|
+
if (isResume) {
|
|
2043
|
+
const s = yaml.load(readFileSync(sessionPath, 'utf-8')) || {};
|
|
2044
|
+
firstAgent = s.current_agent || (isGreenfield ? 'greenfield-wu' : 'brownfield-wu');
|
|
2045
|
+
} else {
|
|
2046
|
+
firstAgent = isGreenfield ? 'greenfield-wu' : 'brownfield-wu';
|
|
2047
|
+
await updateSession(projectDir, { current_agent: firstAgent });
|
|
2048
|
+
}
|
|
2049
|
+
const firstAgentFile = getAgentFile(firstAgent, projectDir) || null;
|
|
601
2050
|
|
|
602
2051
|
// Write session lock
|
|
603
2052
|
try {
|
|
@@ -674,6 +2123,27 @@ async function handleStatus(projectDir) {
|
|
|
674
2123
|
agentModels[agentDef.name] = resolveAgentModel(agentDef.name, projectDir);
|
|
675
2124
|
}
|
|
676
2125
|
|
|
2126
|
+
// Fase 10 workstream 2 (context-window-detection v1) — model-aware
|
|
2127
|
+
// context block. Resolves the active window from session.active_model
|
|
2128
|
+
// (populated by prism-engine on first prompt) and reports the exact
|
|
2129
|
+
// bracket prism-engine last computed. If the session predates v1.2
|
|
2130
|
+
// (no context_* fields yet), the block falls back to estimateContextBracket
|
|
2131
|
+
// so the existing response shape stays stable for consumers.
|
|
2132
|
+
const activeModel = session.active_model || null;
|
|
2133
|
+
const activeProvider = session.active_provider || (session.providers_enabled || ['claude'])[0];
|
|
2134
|
+
const windowTokens = session.context_window_tokens != null
|
|
2135
|
+
? session.context_window_tokens
|
|
2136
|
+
: resolveContextLimit(activeModel, activeProvider);
|
|
2137
|
+
const tokensUsed = session.context_tokens_used != null ? session.context_tokens_used : 0;
|
|
2138
|
+
const remainingPct = windowTokens > 0
|
|
2139
|
+
? Math.max(0, Math.round((1 - tokensUsed / windowTokens) * 100))
|
|
2140
|
+
: 100;
|
|
2141
|
+
const lastBracket = session.context_last_bracket
|
|
2142
|
+
|| (remainingPct < 25 ? 'CRITICAL'
|
|
2143
|
+
: remainingPct < 40 ? 'DEPLETED'
|
|
2144
|
+
: remainingPct < 60 ? 'MODERATE'
|
|
2145
|
+
: 'FRESH');
|
|
2146
|
+
|
|
677
2147
|
return {
|
|
678
2148
|
session: summary.summary || summary,
|
|
679
2149
|
pipeline: progress,
|
|
@@ -683,6 +2153,15 @@ async function handleStatus(projectDir) {
|
|
|
683
2153
|
agents: session.agents || {},
|
|
684
2154
|
agent_models: agentModels,
|
|
685
2155
|
context_bracket: estimateContextBracket((session.completed_agents || []).length, AGENT_PIPELINE.length),
|
|
2156
|
+
context: {
|
|
2157
|
+
model: activeModel,
|
|
2158
|
+
provider: activeProvider,
|
|
2159
|
+
window_tokens: windowTokens,
|
|
2160
|
+
tokens_used: tokensUsed,
|
|
2161
|
+
remaining_pct: remainingPct,
|
|
2162
|
+
bracket: lastBracket,
|
|
2163
|
+
handoff_required: remainingPct < 15,
|
|
2164
|
+
},
|
|
686
2165
|
user_level: session.user_level || 'auto',
|
|
687
2166
|
execution_mode: session.execution_mode || 'interactive',
|
|
688
2167
|
};
|
|
@@ -743,6 +2222,16 @@ async function handleDeviation(projectDir, args) {
|
|
|
743
2222
|
}
|
|
744
2223
|
|
|
745
2224
|
await updateSession(projectDir, updates);
|
|
2225
|
+
|
|
2226
|
+
// Refresh session lock if agent changed
|
|
2227
|
+
if (updates.current_agent) {
|
|
2228
|
+
try {
|
|
2229
|
+
writeSessionLock(projectDir, updates.current_agent, {
|
|
2230
|
+
phase: session.mode,
|
|
2231
|
+
mode: session.execution_mode || 'interactive',
|
|
2232
|
+
});
|
|
2233
|
+
} catch { /* non-fatal */ }
|
|
2234
|
+
}
|
|
746
2235
|
}
|
|
747
2236
|
|
|
748
2237
|
return {
|
|
@@ -760,6 +2249,7 @@ async function handleExit(projectDir, args) {
|
|
|
760
2249
|
}
|
|
761
2250
|
|
|
762
2251
|
// Save current state
|
|
2252
|
+
recordEvent(projectDir, EventType.SESSION_ENDED, session.current_agent || 'orchestrator', { mode: session.mode });
|
|
763
2253
|
await updateSession(projectDir, {});
|
|
764
2254
|
|
|
765
2255
|
// Release session ownership
|
|
@@ -790,8 +2280,16 @@ async function handleProviders(projectDir) {
|
|
|
790
2280
|
agentModels[agentDef.name] = resolveAgentModel(agentDef.name, projectDir);
|
|
791
2281
|
}
|
|
792
2282
|
|
|
2283
|
+
let primaryProvider = 'claude';
|
|
2284
|
+
const configPath = join(projectDir, resolveFrameworkDir(projectDir), 'config.yaml');
|
|
2285
|
+
if (existsSync(configPath)) {
|
|
2286
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
2287
|
+
const providerMatch = raw.match(/primary_provider:\s*["']?(\w+)/);
|
|
2288
|
+
if (providerMatch) primaryProvider = providerMatch[1].toLowerCase();
|
|
2289
|
+
}
|
|
2290
|
+
|
|
793
2291
|
return {
|
|
794
|
-
primary_provider:
|
|
2292
|
+
primary_provider: primaryProvider,
|
|
795
2293
|
agent_models: agentModels,
|
|
796
2294
|
};
|
|
797
2295
|
}
|
|
@@ -971,7 +2469,7 @@ async function handleScan(projectDir, args) {
|
|
|
971
2469
|
|
|
972
2470
|
const TEAM_CONFIGS = {
|
|
973
2471
|
planning: {
|
|
974
|
-
members: ['detail', 'architect', 'ux'],
|
|
2472
|
+
members: ['detail', 'architect', 'ux', 'qa-planning'],
|
|
975
2473
|
slug: 'pln',
|
|
976
2474
|
templateFile: 'team-planning-tasks.yaml',
|
|
977
2475
|
},
|
|
@@ -996,14 +2494,22 @@ function generateTeamId(slug) {
|
|
|
996
2494
|
* (Gemini) or spawn_autonomous (Codex) when the active provider is not claude.
|
|
997
2495
|
*/
|
|
998
2496
|
function isAgentTeamsEnabled(projectDir) {
|
|
999
|
-
const configPath = join(projectDir,
|
|
2497
|
+
const configPath = join(projectDir, resolveFrameworkDir(projectDir), 'config.yaml');
|
|
1000
2498
|
if (!existsSync(configPath)) return false;
|
|
1001
2499
|
const raw = readFileSync(configPath, 'utf-8');
|
|
1002
2500
|
|
|
1003
2501
|
// Feature flag check
|
|
1004
2502
|
const flagMatch = raw.match(/agent_teams:\s*(true|false)/);
|
|
1005
|
-
|
|
1006
|
-
if (
|
|
2503
|
+
// If field is explicitly set, respect it
|
|
2504
|
+
if (flagMatch) {
|
|
2505
|
+
if (flagMatch[1] !== 'true') return false;
|
|
2506
|
+
} else {
|
|
2507
|
+
// If field is absent (pre-v4.2.0 projects), default to true for Claude provider
|
|
2508
|
+
// Claude Code supports Agent tool natively; Gemini/Codex fall back to sequential
|
|
2509
|
+
const providerMatch = raw.match(/primary_provider:\s*["']?(\w+)/);
|
|
2510
|
+
const provider = providerMatch ? providerMatch[1].toLowerCase() : 'claude';
|
|
2511
|
+
if (provider !== 'claude') return false;
|
|
2512
|
+
}
|
|
1007
2513
|
|
|
1008
2514
|
// Provider gate — Agent Teams is Claude-only.
|
|
1009
2515
|
// Read primary_provider from session.yaml (preferred) or active_provider
|
|
@@ -1036,7 +2542,7 @@ async function handleSpawnTeam(projectDir, args) {
|
|
|
1036
2542
|
const teamDir = join(projectDir, '.chati', 'teams', teamId);
|
|
1037
2543
|
const mailboxDir = join(teamDir, 'mailbox');
|
|
1038
2544
|
const taskListPath = join(teamDir, 'tasks.yaml');
|
|
1039
|
-
const templatePath = join(projectDir,
|
|
2545
|
+
const templatePath = join(projectDir, resolveFrameworkDir(projectDir), 'templates', config.templateFile);
|
|
1040
2546
|
|
|
1041
2547
|
// Create team directories
|
|
1042
2548
|
mkdirSync(mailboxDir, { recursive: true });
|
|
@@ -1047,7 +2553,7 @@ async function handleSpawnTeam(projectDir, args) {
|
|
|
1047
2553
|
|
|
1048
2554
|
// For build teams: dynamically populate tasks from tasks.md (Article XXI §9)
|
|
1049
2555
|
if (teamType === 'build') {
|
|
1050
|
-
const tasksArtifact = join(projectDir, '
|
|
2556
|
+
const tasksArtifact = join(projectDir, 'artifacts', '6-Tasks', 'tasks.md');
|
|
1051
2557
|
if (existsSync(tasksArtifact)) {
|
|
1052
2558
|
const tasksContent = readFileSync(tasksArtifact, 'utf-8');
|
|
1053
2559
|
// Extract task IDs (pattern: T{phase}.{seq})
|
|
@@ -1142,6 +2648,7 @@ async function handleSpawnTeam(projectDir, args) {
|
|
|
1142
2648
|
});
|
|
1143
2649
|
|
|
1144
2650
|
await updateSession(projectDir, session);
|
|
2651
|
+
recordEvent(projectDir, EventType.AGENT_ACTIVATED, `team:${teamType}`, { teamId, members: config.members });
|
|
1145
2652
|
}
|
|
1146
2653
|
} catch { /* non-critical: session update may fail, team can still spawn */ }
|
|
1147
2654
|
|
|
@@ -1262,7 +2769,7 @@ async function handleTeamDissolve(projectDir, args) {
|
|
|
1262
2769
|
let correctionCyclesOk = true;
|
|
1263
2770
|
let maxCorrectionCycles = 2; // default per constitution
|
|
1264
2771
|
try {
|
|
1265
|
-
const configPath = join(projectDir,
|
|
2772
|
+
const configPath = join(projectDir, resolveFrameworkDir(projectDir), 'config.yaml');
|
|
1266
2773
|
if (existsSync(configPath)) {
|
|
1267
2774
|
const configRaw = readFileSync(configPath, 'utf-8');
|
|
1268
2775
|
const maxMatch = configRaw.match(/team_correction_cycles_max:\s*(\d+)/);
|
|
@@ -1286,7 +2793,7 @@ async function handleTeamDissolve(projectDir, args) {
|
|
|
1286
2793
|
if (!mailboxClean) gateFailures.push('unresolved_mailbox_messages');
|
|
1287
2794
|
if (!noBlockers) gateFailures.push('open_blockers');
|
|
1288
2795
|
if (!correctionCyclesOk) gateFailures.push('correction_cycles_exceeded');
|
|
1289
|
-
if (teamScore <
|
|
2796
|
+
if (teamScore < tierThreshold) gateFailures.push('team_score_below_threshold');
|
|
1290
2797
|
|
|
1291
2798
|
// Update session
|
|
1292
2799
|
try {
|
|
@@ -1341,12 +2848,296 @@ async function handleTeamDissolve(projectDir, args) {
|
|
|
1341
2848
|
mailbox_clean: mailboxClean,
|
|
1342
2849
|
no_open_blockers: noBlockers,
|
|
1343
2850
|
correction_cycles_ok: correctionCyclesOk,
|
|
1344
|
-
team_score_above_threshold: teamScore >=
|
|
2851
|
+
team_score_above_threshold: teamScore >= tierThreshold,
|
|
1345
2852
|
},
|
|
1346
2853
|
},
|
|
1347
2854
|
};
|
|
1348
2855
|
}
|
|
1349
2856
|
|
|
2857
|
+
// ---------------------------------------------------------------------------
|
|
2858
|
+
// Deterministic subcommands — replace generative LLM decisions with code
|
|
2859
|
+
// ---------------------------------------------------------------------------
|
|
2860
|
+
|
|
2861
|
+
/**
|
|
2862
|
+
* wait-for-license: poll ~/.chati-dev/license.yaml until status=VALID or
|
|
2863
|
+
* a terminal-bad status (EXPIRED|INVALID|REVOKED) or timeout. Used by the
|
|
2864
|
+
* /chati boot path when doctor reports a missing/invalid license — the
|
|
2865
|
+
* orchestrator displays the activate command, then calls this to block
|
|
2866
|
+
* until the user has run it in another terminal.
|
|
2867
|
+
*
|
|
2868
|
+
* @param {string} _projectDir unused (license is home-dir scoped)
|
|
2869
|
+
* @param {{ timeout?: string, interval?: string }} args
|
|
2870
|
+
*/
|
|
2871
|
+
async function handleWaitForLicense(_projectDir, args) {
|
|
2872
|
+
const timeoutMs = args.timeout ? parseInt(args.timeout, 10) : undefined;
|
|
2873
|
+
const intervalMs = args.interval ? parseInt(args.interval, 10) : undefined;
|
|
2874
|
+
const result = await waitForLicense({ timeoutMs, intervalMs });
|
|
2875
|
+
return {
|
|
2876
|
+
valid: result.valid,
|
|
2877
|
+
status: result.status,
|
|
2878
|
+
reason: result.reason,
|
|
2879
|
+
waited_ms: result.waitedMs,
|
|
2880
|
+
next: result.valid
|
|
2881
|
+
? { action: 'proceed', status_summary: 'License VALID — continuing /chati boot.' }
|
|
2882
|
+
: {
|
|
2883
|
+
action: 'license_resolution_required',
|
|
2884
|
+
status_summary: `License status: ${result.status}. ${result.reason || ''}`.trim(),
|
|
2885
|
+
fix: 'Paste in another terminal: `npx chati-dev activate --key=YOUR-KEY`, then re-invoke `/chati`.',
|
|
2886
|
+
},
|
|
2887
|
+
};
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
/**
|
|
2891
|
+
* doctor: run the silent self-diagnostic. Returns the structured verdict
|
|
2892
|
+
* from runDoctor for the orchestrator to surface. Callers are /chati boot
|
|
2893
|
+
* (implicit) and `/chati doctor` (explicit).
|
|
2894
|
+
*
|
|
2895
|
+
* @param {string} projectDir
|
|
2896
|
+
*/
|
|
2897
|
+
function handleDoctor(projectDir) {
|
|
2898
|
+
const verdict = runDoctor({ projectDir });
|
|
2899
|
+
return {
|
|
2900
|
+
ok: verdict.ok,
|
|
2901
|
+
checks: verdict.checks,
|
|
2902
|
+
blockers: verdict.blockers,
|
|
2903
|
+
warnings: verdict.warnings,
|
|
2904
|
+
next: verdict.ok
|
|
2905
|
+
? { action: 'proceed', status_summary: 'doctor: all checks OK (silent pass).' }
|
|
2906
|
+
: {
|
|
2907
|
+
action: 'remediation_required',
|
|
2908
|
+
status_summary: `doctor: ${verdict.blockers.length} blocker(s), ${verdict.warnings.length} warning(s).`,
|
|
2909
|
+
remediations: verdict.blockers.map(b => ({ id: b.id, fix: b.fix })).filter(r => r.fix),
|
|
2910
|
+
},
|
|
2911
|
+
};
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
/**
|
|
2915
|
+
* wait-for-capture: block until capture-complete.json exists (max 10 min).
|
|
2916
|
+
* Replaces Brand Architect's generative polling loop.
|
|
2917
|
+
*/
|
|
2918
|
+
async function handleWaitForCapture(projectDir, args) {
|
|
2919
|
+
const refsDir = join(projectDir, 'artifacts', '4-UX', 'references');
|
|
2920
|
+
const completePath = join(refsDir, 'capture-complete.json');
|
|
2921
|
+
const timeoutMs = parseInt(args.timeout || '600000', 10); // 10 min default
|
|
2922
|
+
const pollMs = 5000; // check every 5s
|
|
2923
|
+
const start = Date.now();
|
|
2924
|
+
|
|
2925
|
+
while (Date.now() - start < timeoutMs) {
|
|
2926
|
+
if (existsSync(completePath)) {
|
|
2927
|
+
try {
|
|
2928
|
+
const data = JSON.parse(readFileSync(completePath, 'utf-8'));
|
|
2929
|
+
return { ready: true, ...data };
|
|
2930
|
+
} catch {
|
|
2931
|
+
return { ready: true, error: 'capture-complete.json exists but invalid JSON' };
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
await new Promise(r => setTimeout(r, pollMs));
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2937
|
+
return { ready: false, timedOut: true, message: `capture-complete.json not found after ${timeoutMs / 1000}s. Proceed with WebFetch fallback.` };
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
/**
|
|
2941
|
+
* qa-visual-score: calculate visual QA score deterministically from report.json.
|
|
2942
|
+
* Replaces QA-Visual's generative scoring formula.
|
|
2943
|
+
*/
|
|
2944
|
+
function handleQaVisualScore(args) {
|
|
2945
|
+
const reportPath = args.report || '/tmp/visual-qa/report.json';
|
|
2946
|
+
if (!existsSync(reportPath)) {
|
|
2947
|
+
return errorResult(`report.json not found at ${reportPath}`, 'REPORT_NOT_FOUND');
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
let report;
|
|
2951
|
+
try { report = JSON.parse(readFileSync(reportPath, 'utf-8')); } catch {
|
|
2952
|
+
return errorResult('report.json is invalid JSON', 'INVALID_REPORT');
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
const s = report.summary || {};
|
|
2956
|
+
const hasRefs = args['has-refs'] === 'true';
|
|
2957
|
+
|
|
2958
|
+
// Weights (adjust when references exist)
|
|
2959
|
+
const w = hasRefs
|
|
2960
|
+
? { animation: 0.30, scroll: 0.20, responsive: 0.15, hover: 0.10, brand: 0.10, fidelity: 0.15 }
|
|
2961
|
+
: { animation: 0.35, scroll: 0.25, responsive: 0.20, hover: 0.10, brand: 0.10, fidelity: 0 };
|
|
2962
|
+
|
|
2963
|
+
// Base scores per dimension (0-100)
|
|
2964
|
+
const animScore = Math.min(100, (s.lenis_active_all ? 50 : 0) + (s.gsap_loaded_all ? 30 : 0) + Math.min(20, (s.total_gsap_animations || 0) * 2));
|
|
2965
|
+
const scrollScore = (s.total_scroll_triggers || 0) > 0 ? 80 + Math.min(20, s.total_scroll_triggers * 2) : 40;
|
|
2966
|
+
const responsiveScore = 80; // baseline — actual responsive check is visual (generative)
|
|
2967
|
+
const hoverScore = 80; // baseline
|
|
2968
|
+
const brandScore = (s.reduced_motion_support ? 10 : 0) + Math.min(90, (report.pages?.[0]?.js_checks?.custom_properties_count ?? 0));
|
|
2969
|
+
const fidelityScore = 70; // placeholder — Visualizer comparison is generative
|
|
2970
|
+
|
|
2971
|
+
let rawScore = Math.round(
|
|
2972
|
+
animScore * w.animation +
|
|
2973
|
+
scrollScore * w.scroll +
|
|
2974
|
+
responsiveScore * w.responsive +
|
|
2975
|
+
hoverScore * w.hover +
|
|
2976
|
+
brandScore * w.brand +
|
|
2977
|
+
fidelityScore * w.fidelity
|
|
2978
|
+
);
|
|
2979
|
+
|
|
2980
|
+
// Penalties
|
|
2981
|
+
const errors = [];
|
|
2982
|
+
const warnings = [];
|
|
2983
|
+
|
|
2984
|
+
if (!s.lenis_active_all) errors.push('Lenis not active on all pages');
|
|
2985
|
+
if (!s.gsap_loaded_all) errors.push('GSAP not loaded on all pages');
|
|
2986
|
+
if (s.em_dashes_detected) { errors.push('Em-dashes found in visible text'); rawScore -= 10; }
|
|
2987
|
+
if (s.console_errors_total > 0) { warnings.push(`${s.console_errors_total} console error(s)`); rawScore -= 5; }
|
|
2988
|
+
|
|
2989
|
+
// Hard blocks
|
|
2990
|
+
const hardBlocked = !s.lenis_active_all || !s.gsap_loaded_all || s.em_dashes_detected;
|
|
2991
|
+
const score = Math.max(0, Math.min(100, rawScore));
|
|
2992
|
+
const threshold = 90;
|
|
2993
|
+
const verdict = hardBlocked ? 'BLOCKED' : score >= threshold ? 'APPROVED' : 'NEEDS_CORRECTION';
|
|
2994
|
+
|
|
2995
|
+
return { score, threshold, verdict, hardBlocked, errors, warnings, weights: w, dimensions: { animScore, scrollScore, responsiveScore, hoverScore, brandScore, fidelityScore } };
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
/**
|
|
2999
|
+
* wave-status: read Build Team tasks.yaml + mailbox, return wave status.
|
|
3000
|
+
* Replaces Claude's manual mailbox counting.
|
|
3001
|
+
*/
|
|
3002
|
+
async function handleWaveStatus(projectDir, args) {
|
|
3003
|
+
const teamId = args['team-id'];
|
|
3004
|
+
if (!teamId) return errorResult('Missing --team-id flag', 'MISSING_TEAM_ID');
|
|
3005
|
+
|
|
3006
|
+
const teamDir = join(projectDir, '.chati', 'teams', teamId);
|
|
3007
|
+
const tasksPath = join(teamDir, 'tasks.yaml');
|
|
3008
|
+
if (!existsSync(tasksPath)) return errorResult(`Team tasks not found: ${tasksPath}`, 'TEAM_NOT_FOUND');
|
|
3009
|
+
|
|
3010
|
+
let tasks;
|
|
3011
|
+
try { tasks = yaml.load(readFileSync(tasksPath, 'utf-8')); } catch {
|
|
3012
|
+
return errorResult('tasks.yaml is invalid', 'INVALID_TASKS');
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
const taskList = tasks.tasks || [];
|
|
3016
|
+
const completed = taskList.filter(t => t.status === 'done' || t.status === 'completed');
|
|
3017
|
+
const inProgress = taskList.filter(t => t.status === 'in_progress');
|
|
3018
|
+
const pending = taskList.filter(t => t.status === 'pending');
|
|
3019
|
+
const blocked = taskList.filter(t => t.blocker);
|
|
3020
|
+
const failed = taskList.filter(t => t.status === 'failed' || t.status === 'needs_correction');
|
|
3021
|
+
|
|
3022
|
+
// Determine wave type
|
|
3023
|
+
let waveType = 'regular';
|
|
3024
|
+
if (pending.length === 0 && failed.length > 0) waveType = 'cleanup';
|
|
3025
|
+
if (pending.length === 0 && failed.length === 0 && inProgress.length === 0) waveType = 'complete';
|
|
3026
|
+
|
|
3027
|
+
return {
|
|
3028
|
+
team_id: teamId,
|
|
3029
|
+
wave_type: waveType,
|
|
3030
|
+
total: taskList.length,
|
|
3031
|
+
completed: completed.length,
|
|
3032
|
+
in_progress: inProgress.length,
|
|
3033
|
+
pending: pending.length,
|
|
3034
|
+
failed: failed.length,
|
|
3035
|
+
blocked: blocked.length,
|
|
3036
|
+
all_done: pending.length === 0 && inProgress.length === 0 && failed.length === 0,
|
|
3037
|
+
recommendation: waveType === 'complete'
|
|
3038
|
+
? 'All tasks complete. Ready for team dissolution.'
|
|
3039
|
+
: waveType === 'cleanup'
|
|
3040
|
+
? `${failed.length} task(s) need correction. Start Cleanup Wave.`
|
|
3041
|
+
: `${pending.length} task(s) pending, ${inProgress.length} in progress. Continue Regular Wave.`,
|
|
3042
|
+
};
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
/**
|
|
3046
|
+
* Brief category dictionaries — user-input i18n tokens keyed by ISO 639-1.
|
|
3047
|
+
*
|
|
3048
|
+
* These match free-form brief text against 7 coverage categories. The set is
|
|
3049
|
+
* routed through session.language at call time (same exemption pattern as
|
|
3050
|
+
* RESUME_MESSAGES). English is always unioned in; the locale layer adds
|
|
3051
|
+
* dialect tokens so projects running brief in pt/es/fr still get coverage.
|
|
3052
|
+
*
|
|
3053
|
+
* Article VII: code identifiers and framework strings stay English; these
|
|
3054
|
+
* locale entries are user-input tokens, analogous to i18n message catalogs.
|
|
3055
|
+
*/
|
|
3056
|
+
export const BRIEF_CATEGORY_KEYWORDS = {
|
|
3057
|
+
en: {
|
|
3058
|
+
problem: ['problem', 'issue', 'challenge', 'pain', 'struggle', 'frustrate', 'difficult', 'gap', 'need', 'fix'],
|
|
3059
|
+
users: ['user', 'customer', 'client', 'audience', 'persona', 'visitor', 'target', 'demographic', 'segment'],
|
|
3060
|
+
outcome: ['goal', 'outcome', 'result', 'success', 'metric', 'kpi', 'achieve', 'objective', 'want', 'expect'],
|
|
3061
|
+
constraints: ['budget', 'timeline', 'deadline', 'team', 'resource', 'limit', 'constraint', 'technology', 'stack'],
|
|
3062
|
+
scope: ['scope', 'feature', 'page', 'section', 'include', 'exclude', 'not build', 'out of scope', 'mvp', 'v1'],
|
|
3063
|
+
references: ['reference', 'competitor', 'inspiration', 'like', 'similar', 'example', 'benchmark', 'design', 'look'],
|
|
3064
|
+
context: ['company', 'business', 'industry', 'market', 'brand', 'history', 'background', 'about', 'mission'],
|
|
3065
|
+
},
|
|
3066
|
+
pt: {
|
|
3067
|
+
problem: ['problema', 'desafio', 'dor', 'dificuldade', 'necessidade', 'resolver', 'corrigir'],
|
|
3068
|
+
users: ['usuario', 'usu\u00e1rio', 'cliente', 'p\u00fablico', 'visitante', 'alvo', 'pessoa'],
|
|
3069
|
+
outcome: ['objetivo', 'meta', 'resultado', 'sucesso', 'quero', 'espero', 'alcan\u00e7ar', 'atingir'],
|
|
3070
|
+
constraints: ['or\u00e7amento', 'prazo', 'equipe', 'recurso', 'limite', 'tecnologia', 'restri\u00e7\u00e3o'],
|
|
3071
|
+
scope: ['escopo', 'funcionalidade', 'p\u00e1gina', 'incluir', 'excluir', 'n\u00e3o fazer', 'fora do escopo'],
|
|
3072
|
+
references: ['refer\u00eancia', 'referencia', 'concorrente', 'inspira\u00e7\u00e3o', 'inspiracao', 'parecido', 'exemplo', 'visual'],
|
|
3073
|
+
context: ['empresa', 'neg\u00f3cio', 'negocio', 'mercado', 'marca', 'hist\u00f3ria', 'historia', 'sobre', 'miss\u00e3o'],
|
|
3074
|
+
},
|
|
3075
|
+
es: {
|
|
3076
|
+
problem: ['problema', 'desaf\u00edo', 'dolor', 'dificultad', 'necesidad', 'resolver', 'corregir'],
|
|
3077
|
+
users: ['usuario', 'cliente', 'audiencia', 'p\u00fablico', 'visitante', 'objetivo', 'persona'],
|
|
3078
|
+
outcome: ['meta', 'objetivo', 'resultado', '\u00e9xito', 'quiero', 'espero', 'lograr', 'alcanzar'],
|
|
3079
|
+
constraints: ['presupuesto', 'plazo', 'equipo', 'recurso', 'l\u00edmite', 'tecnolog\u00eda', 'restricci\u00f3n'],
|
|
3080
|
+
scope: ['alcance', 'funcionalidad', 'p\u00e1gina', 'incluir', 'excluir', 'no hacer', 'fuera de alcance'],
|
|
3081
|
+
references: ['referencia', 'competidor', 'inspiraci\u00f3n', 'parecido', 'ejemplo', 'visual'],
|
|
3082
|
+
context: ['empresa', 'negocio', 'mercado', 'marca', 'historia', 'acerca', 'misi\u00f3n'],
|
|
3083
|
+
},
|
|
3084
|
+
fr: {
|
|
3085
|
+
problem: ['probl\u00e8me', 'd\u00e9fi', 'douleur', 'difficult\u00e9', 'besoin', 'r\u00e9soudre', 'corriger'],
|
|
3086
|
+
users: ['utilisateur', 'client', 'audience', 'public', 'visiteur', 'cible', 'persona'],
|
|
3087
|
+
outcome: ['objectif', 'r\u00e9sultat', 'succ\u00e8s', 'veux', 'souhaite', 'atteindre'],
|
|
3088
|
+
constraints: ['budget', 'd\u00e9lai', '\u00e9quipe', 'ressource', 'limite', 'technologie', 'contrainte'],
|
|
3089
|
+
scope: ['p\u00e9rim\u00e8tre', 'fonctionnalit\u00e9', 'page', 'inclure', 'exclure', 'hors p\u00e9rim\u00e8tre'],
|
|
3090
|
+
references: ['r\u00e9f\u00e9rence', 'concurrent', 'inspiration', 'similaire', 'exemple', 'visuel'],
|
|
3091
|
+
context: ['entreprise', 'march\u00e9', 'marque', 'histoire', 'mission'],
|
|
3092
|
+
},
|
|
3093
|
+
};
|
|
3094
|
+
|
|
3095
|
+
const BRIEF_CATEGORIES = ['problem', 'users', 'outcome', 'constraints', 'scope', 'references', 'context'];
|
|
3096
|
+
|
|
3097
|
+
/**
|
|
3098
|
+
* Build the keyword set for a category at a given language. Always includes EN
|
|
3099
|
+
* tokens; the locale layer is appended when session.language selects it.
|
|
3100
|
+
*
|
|
3101
|
+
* @param {string} category One of BRIEF_CATEGORIES.
|
|
3102
|
+
* @param {string} [language='en'] ISO 639-1 session language.
|
|
3103
|
+
* @returns {string[]}
|
|
3104
|
+
*/
|
|
3105
|
+
export function getBriefCategoryKeywords(category, language = 'en') {
|
|
3106
|
+
const en = BRIEF_CATEGORY_KEYWORDS.en[category] ?? [];
|
|
3107
|
+
if (language === 'en') return [...en];
|
|
3108
|
+
const locale = BRIEF_CATEGORY_KEYWORDS[language]?.[category] ?? [];
|
|
3109
|
+
return [...en, ...locale];
|
|
3110
|
+
}
|
|
3111
|
+
|
|
3112
|
+
/**
|
|
3113
|
+
* assess-coverage: classify user input against 7 Brief categories.
|
|
3114
|
+
* Replaces Brief's generative coverage assessment.
|
|
3115
|
+
*/
|
|
3116
|
+
function handleAssessCoverage(args) {
|
|
3117
|
+
const input = (args.input || '').toLowerCase();
|
|
3118
|
+
if (!input) return errorResult('Missing --input flag', 'MISSING_INPUT');
|
|
3119
|
+
const language = (args.language || 'en').toLowerCase();
|
|
3120
|
+
|
|
3121
|
+
const categories = {};
|
|
3122
|
+
for (const cat of BRIEF_CATEGORIES) {
|
|
3123
|
+
const keywords = getBriefCategoryKeywords(cat, language);
|
|
3124
|
+
categories[cat] = { keywords, found: keywords.some(k => input.includes(k)) };
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
const covered = Object.entries(categories).filter(([, d]) => d.found).map(([k]) => k);
|
|
3128
|
+
const missing = Object.entries(categories).filter(([, d]) => !d.found).map(([k]) => k);
|
|
3129
|
+
const coveragePct = Math.round((covered.length / BRIEF_CATEGORIES.length) * 100);
|
|
3130
|
+
|
|
3131
|
+
return {
|
|
3132
|
+
coverage_pct: coveragePct,
|
|
3133
|
+
covered,
|
|
3134
|
+
missing,
|
|
3135
|
+
recommendation: coveragePct >= 50
|
|
3136
|
+
? `Good coverage (${coveragePct}%). Clarify: ${missing.join(', ')}.`
|
|
3137
|
+
: `Low coverage (${coveragePct}%). Deep exploration needed on: ${missing.join(', ')}.`,
|
|
3138
|
+
};
|
|
3139
|
+
}
|
|
3140
|
+
|
|
1350
3141
|
// ---------------------------------------------------------------------------
|
|
1351
3142
|
// Main entry point
|
|
1352
3143
|
// ---------------------------------------------------------------------------
|
|
@@ -1404,6 +3195,24 @@ export async function runOrchestrate(subCommand, argv, projectDir) {
|
|
|
1404
3195
|
case 'team-dissolve':
|
|
1405
3196
|
output(await handleTeamDissolve(projectDir, args));
|
|
1406
3197
|
break;
|
|
3198
|
+
case 'wait-for-capture':
|
|
3199
|
+
output(await handleWaitForCapture(projectDir, args));
|
|
3200
|
+
break;
|
|
3201
|
+
case 'wait-for-license':
|
|
3202
|
+
output(await handleWaitForLicense(projectDir, args));
|
|
3203
|
+
break;
|
|
3204
|
+
case 'doctor':
|
|
3205
|
+
output(handleDoctor(projectDir));
|
|
3206
|
+
break;
|
|
3207
|
+
case 'qa-visual-score':
|
|
3208
|
+
output(handleQaVisualScore(args));
|
|
3209
|
+
break;
|
|
3210
|
+
case 'wave-status':
|
|
3211
|
+
output(await handleWaveStatus(projectDir, args));
|
|
3212
|
+
break;
|
|
3213
|
+
case 'assess-coverage':
|
|
3214
|
+
output(handleAssessCoverage(args));
|
|
3215
|
+
break;
|
|
1407
3216
|
default:
|
|
1408
3217
|
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
3218
|
break;
|