chati-dev 4.3.1 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +18 -13
  2. package/bin/chati.js +12 -4
  3. package/framework/agents/build/dev.md +5 -1
  4. package/framework/agents/discover/greenfield-wu.md +16 -1
  5. package/framework/agents/plan/tasks.md +31 -0
  6. package/framework/agents/plan/ux-brand-architect.md +21 -2
  7. package/framework/agents/plan/ux-component-engineer.md +10 -0
  8. package/framework/agents/quality/qa-implementation.md +1 -1
  9. package/framework/agents/quality/qa-planning.md +1 -1
  10. package/framework/agents/quality/qa-visual.md +24 -21
  11. package/framework/config.yaml +5 -4
  12. package/framework/constitution.md +58 -11
  13. package/framework/context/governance.md +12 -10
  14. package/framework/context/root.md +2 -2
  15. package/framework/data/entity-registry.yaml +12 -4
  16. package/framework/data/qa-rubrics.yaml +335 -0
  17. package/framework/domains/constitution.yaml +3 -3
  18. package/framework/executors/json-validate.js +80 -0
  19. package/framework/executors/npm-script.js +72 -0
  20. package/framework/executors/path-check.js +67 -0
  21. package/framework/executors/registry.yaml +36 -0
  22. package/framework/hooks/advance-trigger.js +47 -3
  23. package/framework/hooks/constitution-guard.js +9 -4
  24. package/framework/hooks/git-push-authority.js +113 -0
  25. package/framework/hooks/license-guard.js +59 -9
  26. package/framework/hooks/model-governance.js +40 -17
  27. package/framework/hooks/prism-engine.js +78 -11
  28. package/framework/hooks/session-digest.js +12 -3
  29. package/framework/hooks/settings.json +7 -3
  30. package/framework/hooks/style-guard.js +52 -3
  31. package/framework/hooks/team-quality-gate.js +43 -5
  32. package/framework/i18n/en.yaml +3 -3
  33. package/framework/i18n/es.yaml +3 -3
  34. package/framework/i18n/fr.yaml +3 -3
  35. package/framework/i18n/pt.yaml +3 -3
  36. package/framework/intelligence/confidence.yaml +85 -6
  37. package/framework/intelligence/context-engine.md +9 -5
  38. package/framework/intelligence/decision-engine.md +1 -1
  39. package/framework/orchestrator/chati-router.js +1 -1
  40. package/framework/orchestrator/chati-update.md +19 -3
  41. package/framework/orchestrator/chati.md +19 -1
  42. package/framework/quality-gates/planning-gate.md +2 -0
  43. package/framework/scaffold/motion-premium/scaffold.yaml +2 -1
  44. package/framework/scaffold/motion-premium-3d/components/webgl/WebGLContext.tsx.template +1 -1
  45. package/framework/scaffold/saas-dashboard/README.md +58 -0
  46. package/framework/scaffold/saas-dashboard/app/dashboard/page.tsx.template +546 -0
  47. package/framework/scaffold/saas-dashboard/app/globals.css.template +746 -0
  48. package/framework/scaffold/saas-dashboard/app/layout.tsx.template +192 -0
  49. package/framework/scaffold/saas-dashboard/app/shell.css.template +1070 -0
  50. package/framework/scaffold/saas-dashboard/components/data/Chart.tsx.template +725 -0
  51. package/framework/scaffold/saas-dashboard/components/data/DataTable.tsx.template +623 -0
  52. package/framework/scaffold/saas-dashboard/components/data/DataTable.types.ts.template +104 -0
  53. package/framework/scaffold/saas-dashboard/components/data/DataTablePagination.tsx.template +234 -0
  54. package/framework/scaffold/saas-dashboard/components/data/SkeletonBlock.tsx.template +111 -0
  55. package/framework/scaffold/saas-dashboard/components/data/StatCard.tsx.template +393 -0
  56. package/framework/scaffold/saas-dashboard/components/data/StatCardGrid.tsx.template +121 -0
  57. package/framework/scaffold/saas-dashboard/components/feedback/EmptyState.tsx.template +281 -0
  58. package/framework/scaffold/saas-dashboard/components/interaction/CommandPalette.tsx.template +767 -0
  59. package/framework/scaffold/saas-dashboard/components/interaction/Field.tsx.template +351 -0
  60. package/framework/scaffold/saas-dashboard/components/interaction/Form.tsx.template +424 -0
  61. package/framework/scaffold/saas-dashboard/components/primitives/Badge.tsx.template +193 -0
  62. package/framework/scaffold/saas-dashboard/components/primitives/Button.tsx.template +261 -0
  63. package/framework/scaffold/saas-dashboard/components/primitives/Card.tsx.template +287 -0
  64. package/framework/scaffold/saas-dashboard/components/primitives/Input.tsx.template +310 -0
  65. package/framework/scaffold/saas-dashboard/components/primitives/Spinner.tsx.template +135 -0
  66. package/framework/scaffold/saas-dashboard/components/primitives/index.ts.template +60 -0
  67. package/framework/scaffold/saas-dashboard/components/shell/AppShell.tsx.template +174 -0
  68. package/framework/scaffold/saas-dashboard/components/shell/Breadcrumb.tsx.template +186 -0
  69. package/framework/scaffold/saas-dashboard/components/shell/Container.tsx.template +86 -0
  70. package/framework/scaffold/saas-dashboard/components/shell/NavItem.tsx.template +147 -0
  71. package/framework/scaffold/saas-dashboard/components/shell/Sidebar.tsx.template +378 -0
  72. package/framework/scaffold/saas-dashboard/components/shell/SidebarContext.tsx.template +169 -0
  73. package/framework/scaffold/saas-dashboard/components/shell/ThemeToggle.tsx.template +128 -0
  74. package/framework/scaffold/saas-dashboard/components/shell/Topbar.tsx.template +301 -0
  75. package/framework/scaffold/saas-dashboard/lib/brand.ts.template +324 -0
  76. package/framework/scaffold/saas-dashboard/scaffold.yaml +225 -0
  77. package/framework/schemas/session.schema.json +5 -0
  78. package/framework/schemas/task.schema.json +9 -0
  79. package/framework/scripts/visual-qa.js +87 -1
  80. package/framework/tasks/orchestrator-health.md +4 -4
  81. package/framework/tasks/qa-impl-verdict.md +11 -5
  82. package/framework/templates/qa-gate-tmpl.yaml +24 -11
  83. package/package.json +3 -3
  84. package/src/config/context-file-generator.js +0 -6
  85. package/src/dashboard/renderer.js +0 -36
  86. package/src/executors/runner.js +204 -0
  87. package/src/installer/core.js +2 -1
  88. package/src/installer/templates.js +5 -6
  89. package/src/intelligence/registry-manager.js +1 -1
  90. package/src/license/client.js +9 -3
  91. package/src/license/commands.js +12 -2
  92. package/src/license/machine-id.js +42 -1
  93. package/src/memory/gotchas.js +58 -40
  94. package/src/memory/magic-docs.js +1 -1
  95. package/src/memory/session-digest.js +9 -4
  96. package/src/orchestrator/cli.js +174 -14
  97. package/src/orchestrator/doctor.js +98 -16
  98. package/src/orchestrator/pipeline-manager.js +44 -22
  99. package/src/orchestrator/session-manager.js +56 -8
  100. package/src/telemetry/sender.js +7 -19
  101. package/src/terminal/run-team.js +3 -3
  102. package/src/upgrade/tracked-files-detector.js +34 -16
  103. package/src/utils/feature-flags.js +1 -1
  104. package/src/utils/flatten-entities.js +4 -40
  105. package/src/utils/schema-validator.js +0 -14
  106. package/src/wizard/i18n.js +3 -3
  107. package/src/api/index.js +0 -120
  108. package/src/autonomy/autonomous-gate.js +0 -294
  109. package/src/autonomy/build-loop.js +0 -281
  110. package/src/autonomy/build-state.js +0 -286
  111. package/src/autonomy/cause-analyzer.js +0 -177
  112. package/src/autonomy/escalation.js +0 -214
  113. package/src/autonomy/index.js +0 -51
  114. package/src/autonomy/mode-manager.js +0 -225
  115. package/src/autonomy/mode-suggester.js +0 -283
  116. package/src/autonomy/progress-reporter.js +0 -275
  117. package/src/autonomy/safety-net.js +0 -370
  118. package/src/config/agent-customizer.js +0 -231
  119. package/src/decision/analyzer.js +0 -291
  120. package/src/decision/engine.js +0 -250
  121. package/src/decision/index.js +0 -38
  122. package/src/decision/registry-healer.js +0 -468
  123. package/src/decision/registry-updater.js +0 -339
  124. package/src/extensions/loader.js +0 -145
  125. package/src/extensions/registry.js +0 -134
  126. package/src/gates/circuit-breaker.js +0 -151
  127. package/src/gates/g1-planning-complete.js +0 -154
  128. package/src/gates/g2-qa-planning.js +0 -156
  129. package/src/gates/g3-implementation.js +0 -215
  130. package/src/gates/g4-qa-implementation.js +0 -240
  131. package/src/gates/g5-deploy-ready.js +0 -181
  132. package/src/gates/gate-base.js +0 -185
  133. package/src/gates/index.js +0 -46
  134. package/src/health/auto-fix.js +0 -216
  135. package/src/health/engine.js +0 -246
  136. package/src/merger/semantic-merger.js +0 -292
  137. package/src/preview/detector.js +0 -238
  138. package/src/preview/index.js +0 -20
  139. package/src/preview/launcher.js +0 -235
  140. package/src/preview/log-buffer.js +0 -103
  141. package/src/quality/metrics-collector.js +0 -281
  142. package/src/quality/test-runner.js +0 -366
  143. package/src/tasks/executor.js +0 -195
  144. package/src/tasks/index.js +0 -4
  145. package/src/tasks/loader.js +0 -210
  146. package/src/tasks/router.js +0 -182
  147. package/src/utils/event-bus.js +0 -126
@@ -0,0 +1,204 @@
1
+ /**
2
+ * @fileoverview Execution-kind runner for deterministic, zero-LLM script tasks.
3
+ *
4
+ * When a task declares `execution_kind: script` (and the execution_kinds feature
5
+ * flag is on), the orchestrator runs it here instead of spawning an agent. The
6
+ * runner resolves the executor from chati.dev/executors/registry.yaml, spawns it
7
+ * as a child process under the executor contract (JSON request on stdin, JSON
8
+ * result on stdout), enforces a timeout, and shapes a synthetic handoff so the
9
+ * pipeline continues exactly as it would after an agent.
10
+ *
11
+ * Contract result: { status: 'passed'|'failed'|'error', summary, details }.
12
+ * passed -> the task is done; advance.
13
+ * failed -> the deterministic check is negative; route to dev (CODE fault).
14
+ * error -> the executor could not run; surface to the human.
15
+ */
16
+
17
+ import { spawn } from 'child_process';
18
+ import { existsSync, readFileSync } from 'fs';
19
+ import { join, resolve, sep } from 'path';
20
+ import yaml from 'js-yaml';
21
+ import { resolveFrameworkDir } from '../utils/framework-dir.js';
22
+
23
+ /**
24
+ * Split an execution_ref into executor id and argument. The split is on the
25
+ * FIRST colon, so an argument may itself contain colons.
26
+ *
27
+ * @param {string} ref - e.g. "npm-script:test" or "path-check:dist,build"
28
+ * @returns {{ id: string, arg: string }}
29
+ */
30
+ export function parseExecutionRef(ref) {
31
+ const s = String(ref || '').trim();
32
+ if (!s) return { id: '', arg: '' };
33
+ const i = s.indexOf(':');
34
+ if (i === -1) return { id: s, arg: '' };
35
+ return { id: s.slice(0, i).trim(), arg: s.slice(i + 1).trim() };
36
+ }
37
+
38
+ /**
39
+ * Load and parse the executor registry for a project.
40
+ *
41
+ * @param {string} projectDir
42
+ * @returns {{ version?: string, executors: Record<string, {path:string, description?:string, timeout_ms?:number}> }}
43
+ */
44
+ export function loadExecutorRegistry(projectDir) {
45
+ const path = join(projectDir, resolveFrameworkDir(projectDir), 'executors', 'registry.yaml');
46
+ if (!existsSync(path)) return { executors: {} };
47
+ try {
48
+ const parsed = yaml.load(readFileSync(path, 'utf-8'));
49
+ return parsed && typeof parsed === 'object' ? { executors: {}, ...parsed } : { executors: {} };
50
+ } catch {
51
+ return { executors: {} };
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Spawn an executor script, feed it the JSON request, and resolve its JSON
57
+ * result. Never rejects: a spawn failure, a timeout, or non-JSON output all
58
+ * resolve to a structured `error` result.
59
+ *
60
+ * @param {string} execPath - absolute path to the executor script
61
+ * @param {string} request - JSON string written to the executor's stdin
62
+ * @param {number} timeoutMs
63
+ * @returns {Promise<{status:string, summary:string, details?:object}>}
64
+ */
65
+ function spawnExecutor(execPath, request, timeoutMs) {
66
+ return new Promise((resolve) => {
67
+ let settled = false;
68
+ const done = (r) => { if (!settled) { settled = true; resolve(r); } };
69
+
70
+ const child = spawn(process.execPath, [execPath], { shell: false });
71
+ let out = '';
72
+ let err = '';
73
+
74
+ const timer = setTimeout(() => {
75
+ child.kill('SIGKILL');
76
+ done({ status: 'error', summary: `Executor timed out after ${timeoutMs}ms.` });
77
+ }, timeoutMs);
78
+
79
+ child.stdout.on('data', (c) => { out += c; });
80
+ child.stderr.on('data', (c) => { err += c; });
81
+ child.on('error', (e) => {
82
+ clearTimeout(timer);
83
+ done({ status: 'error', summary: `Executor could not be spawned: ${e.message}.` });
84
+ });
85
+ child.on('close', () => {
86
+ clearTimeout(timer);
87
+ try {
88
+ const parsed = JSON.parse(out.trim());
89
+ if (!parsed || typeof parsed !== 'object' || !parsed.status) {
90
+ done({ status: 'error', summary: 'Executor returned JSON without a status field.', details: { stdout: out.slice(0, 500) } });
91
+ return;
92
+ }
93
+ done(parsed);
94
+ } catch {
95
+ done({ status: 'error', summary: 'Executor did not return valid JSON.', details: { stdout: out.slice(0, 500), stderr: err.slice(0, 500) } });
96
+ }
97
+ });
98
+
99
+ child.stdin.write(request);
100
+ child.stdin.end();
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Run a `script` task through its executor.
106
+ *
107
+ * @param {string} projectDir
108
+ * @param {{id?:string, title?:string, execution_ref?:string}} task
109
+ * @returns {Promise<{executor:string, arg:string, status:string, summary:string, details?:object, durationMs:number}>}
110
+ */
111
+ export async function runScriptTask(projectDir, task) {
112
+ const startedAt = Date.now();
113
+ const ref = task?.execution_ref || '';
114
+ const { id, arg } = parseExecutionRef(ref);
115
+
116
+ const wrap = (r) => ({ executor: id, arg, durationMs: Date.now() - startedAt, ...r });
117
+
118
+ if (!id) {
119
+ return wrap({ status: 'error', summary: 'Script task has no execution_ref (expected "executor-id:arg").' });
120
+ }
121
+
122
+ const registry = loadExecutorRegistry(projectDir);
123
+ const entry = registry.executors?.[id];
124
+ if (!entry) {
125
+ const known = Object.keys(registry.executors || {}).join(', ') || '(none)';
126
+ return wrap({ status: 'error', summary: `No executor "${id}" in the registry. Known: ${known}.` });
127
+ }
128
+
129
+ const execRoot = join(projectDir, resolveFrameworkDir(projectDir), 'executors');
130
+ const execPath = join(projectDir, resolveFrameworkDir(projectDir), entry.path);
131
+ // Containment: a tampered or supply-chain-compromised registry must not point
132
+ // the runner at a file outside the framework's executors directory.
133
+ const resolvedExec = resolve(execPath);
134
+ const resolvedRoot = resolve(execRoot);
135
+ if (resolvedExec !== resolvedRoot && !resolvedExec.startsWith(resolvedRoot + sep)) {
136
+ return wrap({ status: 'error', summary: `Executor path "${entry.path}" is outside the executors directory and was refused.` });
137
+ }
138
+ if (!existsSync(execPath)) {
139
+ return wrap({ status: 'error', summary: `Executor file is missing: ${entry.path}.` });
140
+ }
141
+
142
+ const request = JSON.stringify({
143
+ task: { id: task.id, title: task.title, execution_ref: ref },
144
+ projectDir,
145
+ arg,
146
+ });
147
+
148
+ const result = await spawnExecutor(execPath, request, entry.timeout_ms || 60000);
149
+ return wrap(result);
150
+ }
151
+
152
+ /**
153
+ * Shape a synthetic handoff for a completed script task so the pipeline reads it
154
+ * the same way it reads an agent handoff.
155
+ *
156
+ * @param {{id?:string, title?:string}} task
157
+ * @param {{executor:string, arg:string, status:string, summary:string, details?:object, durationMs:number}} result
158
+ * @param {string} [timestamp] - ISO timestamp (caller supplies; the runner does not read the clock for handoff text)
159
+ * @returns {string} handoff markdown
160
+ */
161
+ export function formatScriptHandoff(task, result, timestamp) {
162
+ const status = result.status === 'passed' ? 'completed'
163
+ : result.status === 'failed' ? 'blocked'
164
+ : 'error';
165
+ const score = result.status === 'passed' ? 100 : 0;
166
+ // failed is a real negative result routed to dev (CODE). An error means the
167
+ // executor could not run; that is surfaced to the human, not backlogged, so it
168
+ // carries no fault_origin (DEFER would wrongly signal "out of scope").
169
+ const faultOrigin = result.status === 'failed' ? 'CODE' : null;
170
+
171
+ const lines = [];
172
+ lines.push('---');
173
+ lines.push(`task: ${task?.id || '(unknown)'}`);
174
+ lines.push('execution_kind: script');
175
+ lines.push(`executor: ${result.executor}${result.arg ? `:${result.arg}` : ''}`);
176
+ lines.push(`status: ${status}`);
177
+ lines.push(`score: ${score}`);
178
+ if (faultOrigin) lines.push(`fault_origin: ${faultOrigin}`);
179
+ if (timestamp) lines.push(`completed_at: ${timestamp}`);
180
+ lines.push('---');
181
+ lines.push('');
182
+ lines.push(`# Script task ${task?.id || ''}: ${task?.title || ''}`.trim());
183
+ lines.push('');
184
+ lines.push(`Ran deterministically with no LLM call (executor \`${result.executor}\`, ${result.durationMs}ms).`);
185
+ lines.push('');
186
+ lines.push('## Summary');
187
+ lines.push('');
188
+ lines.push(result.summary || 'Script task completed.');
189
+ if (result.details) {
190
+ lines.push('');
191
+ lines.push('```json');
192
+ lines.push(JSON.stringify(result.details, null, 2));
193
+ lines.push('```');
194
+ }
195
+ if (result.status === 'failed') {
196
+ lines.push('');
197
+ lines.push('The deterministic check is negative. Route to dev for a fix (Fault Vector Protocol: CODE), then re-run the script task.');
198
+ } else if (result.status === 'error') {
199
+ lines.push('');
200
+ lines.push('The executor could not run. Surface to the human: check the execution_ref and the executor registry.');
201
+ }
202
+ lines.push('');
203
+ return lines.join('\n');
204
+ }
@@ -244,6 +244,7 @@ const FRAMEWORK_DIRS_TO_COPY = [
244
244
  'presets',
245
245
  'scripts',
246
246
  'scaffold',
247
+ 'executors',
247
248
  ];
248
249
 
249
250
  /**
@@ -769,7 +770,7 @@ export function copyCliDependencies(pkgDir, destNodeModules) {
769
770
  if (deps.length === 0) return;
770
771
 
771
772
  // Resolve each dep from the package.json location (works for hoisted + nested layouts).
772
- // eslint-disable-next-line no-undef
773
+
773
774
  const require = createRequire(join(pkgDir, 'package.json'));
774
775
  createDir(destNodeModules);
775
776
 
@@ -130,7 +130,6 @@ export function generateConfigYaml(config) {
130
130
  rate_limiter_integration: true,
131
131
  l5_keywords: true,
132
132
  prompt_size_guard: true,
133
- ids_decision_engine: true,
134
133
  surface_criteria: true,
135
134
  parallel_fallback: true,
136
135
  tool_mesh: true,
@@ -219,7 +218,7 @@ description: >
219
218
 
220
219
  # Chati.dev Orchestrator
221
220
 
222
- ## CRITICAL Language Override
221
+ ## CRITICAL: Language Override
223
222
 
224
223
  Read \`.chati/session.yaml\` field \`language\` BEFORE anything else.
225
224
  ALL responses MUST be in this language. This overrides any global setting.
@@ -236,14 +235,14 @@ If session.yaml does not exist or has no language field, default to English.
236
235
  ## Load
237
236
 
238
237
  The orchestrator and all agent files are **pre-configured for Codex CLI**.
239
- No translation needed follow all instructions as written.
238
+ No translation needed; follow all instructions as written.
240
239
 
241
240
  Read and execute the full orchestrator at \`${orchPath}\`.
242
241
 
243
242
  **NEVER create or reference CLAUDE.md, CLAUDE.local.md, or .claude/ directories.**
244
243
 
245
244
  **Context to pass:**
246
- - \`.chati/session.yaml\` (session state includes language)
245
+ - \`.chati/session.yaml\` (session state, includes language)
247
246
  - \`AGENTS.md\` (project context)
248
247
  - \`artifacts/handoffs/\` (latest handoff)
249
248
  - \`.chati.dev/config.yaml\` (version info)
@@ -265,13 +264,13 @@ ALL responses MUST be in this language (en, pt, es, fr).
265
264
  If session.yaml does not exist or has no language field, default to English.
266
265
 
267
266
  The orchestrator and all agent files are pre-configured for Gemini CLI.
268
- No translation needed follow all instructions as written.
267
+ No translation needed; follow all instructions as written.
269
268
 
270
269
  Read and execute the full orchestrator at ${orchPath}.
271
270
  NEVER create or reference CLAUDE.md, CLAUDE.local.md, or .claude/ directories.
272
271
 
273
272
  Context to load:
274
- - .chati/session.yaml (session state includes language)
273
+ - .chati/session.yaml (session state, includes language)
275
274
  - GEMINI.md (project context)
276
275
  - artifacts/handoffs/ (latest handoff)
277
276
  - .chati.dev/config.yaml (version info)
@@ -144,7 +144,7 @@ export function runHealthCheck(targetDir) {
144
144
  const content = readFileSync(constitutionPath, 'utf-8');
145
145
  const articleCount = (content.match(/^## Article/gm) || []).length;
146
146
  checks.constitution.pass = articleCount >= 22;
147
- checks.constitution.details = `${articleCount}/23 articles`;
147
+ checks.constitution.details = `${articleCount} articles`;
148
148
  } else {
149
149
  checks.constitution.details = 'Not found';
150
150
  }
@@ -1,5 +1,5 @@
1
1
  import { homedir } from 'os';
2
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import yaml from 'js-yaml';
5
5
  import { getMachineId, getMachineName } from './machine-id.js';
@@ -27,8 +27,14 @@ function readGlobal() {
27
27
  }
28
28
 
29
29
  function writeGlobal(data) {
30
- mkdirSync(getGlobalDir(), { recursive: true });
31
- writeFileSync(getGlobalLicensePath(), yaml.dump(data, { lineWidth: -1 }));
30
+ const dir = getGlobalDir();
31
+ const path = getGlobalLicensePath();
32
+ mkdirSync(dir, { recursive: true });
33
+ writeFileSync(path, yaml.dump(data, { lineWidth: -1 }));
34
+ // Restrict to owner read/write only (0o600). On multi-user machines this
35
+ // prevents other local users from reading the license key from the
36
+ // world-readable default. POSIX-only; chmodSync is a no-op on Windows.
37
+ try { chmodSync(path, 0o600); } catch { /* non-POSIX FS — skip silently */ }
32
38
  }
33
39
 
34
40
  // ---------------------------------------------------------------------------
@@ -10,6 +10,15 @@ import { activateLicense, deactivateLicense, getLicenseKey, getLicenseStatus, va
10
10
  // activate
11
11
  // ---------------------------------------------------------------------------
12
12
 
13
+ /**
14
+ * Run the interactive license activation flow.
15
+ *
16
+ * Returns `{ ok: boolean, cancelled?: boolean, plan?, days?, error? }`.
17
+ * On failure this function does NOT call `process.exit` — callers (typically
18
+ * `bin/chati.js`) decide whether to exit non-zero based on the returned `ok`
19
+ * flag. This keeps the function unit-testable and reusable from programmatic
20
+ * call sites.
21
+ */
13
22
  export async function runActivate(projectDir, keyArg) {
14
23
  let key = keyArg;
15
24
 
@@ -25,7 +34,7 @@ export async function runActivate(projectDir, keyArg) {
25
34
  });
26
35
  if (isCancel(input)) {
27
36
  console.log('Activation cancelled.');
28
- return;
37
+ return { ok: false, cancelled: true };
29
38
  }
30
39
  key = input.trim();
31
40
  }
@@ -39,10 +48,11 @@ export async function runActivate(projectDir, keyArg) {
39
48
  console.log(`\n✓ Activated! Plan: ${plan} — ${days} day(s) remaining.`);
40
49
  console.log(` Key saved to ~/.chati-dev/license.yaml`);
41
50
  console.log(` Run /chati to start.\n`);
51
+ return { ok: true, plan, days };
42
52
  } catch (err) {
43
53
  console.error(`\n✗ Activation failed: ${err.message}`);
44
54
  console.error(` Visit https://chati.dev/pricing to get a key.\n`);
45
- process.exit(1);
55
+ return { ok: false, error: err.message };
46
56
  }
47
57
  }
48
58
 
@@ -1,9 +1,49 @@
1
1
  import os from 'os';
2
2
  import crypto from 'crypto';
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
4
+ import { join } from 'path';
5
+
6
+ const PERSISTENT_ID_FILE = '.chati-dev/machine-id';
7
+
8
+ /**
9
+ * Read or mint a persistent random per-installation salt at
10
+ * `~/.chati-dev/machine-id`. Used to disambiguate machines that share
11
+ * everything OS-visible (hostname, platform, arch, CPU, username, MAC) —
12
+ * e.g. virtual machines or containers with no external NIC where the prior
13
+ * `'no-mac'` fallback collapsed many machines onto a single fingerprint.
14
+ *
15
+ * The file is created lazily on first call. Format is a single 32-hex-char
16
+ * string with mode 0600 so other local users cannot read it.
17
+ */
18
+ function getPersistentSalt() {
19
+ const path = join(os.homedir(), PERSISTENT_ID_FILE);
20
+ try {
21
+ if (existsSync(path)) {
22
+ const raw = readFileSync(path, 'utf-8').trim();
23
+ if (/^[0-9a-f]{16,}$/i.test(raw)) return raw;
24
+ }
25
+ } catch { /* fall through to mint */ }
26
+ // Mint a new salt.
27
+ try {
28
+ const salt = crypto.randomBytes(16).toString('hex');
29
+ mkdirSync(join(os.homedir(), '.chati-dev'), { recursive: true });
30
+ writeFileSync(path, salt);
31
+ try { chmodSync(path, 0o600); } catch { /* non-POSIX FS — skip */ }
32
+ return salt;
33
+ } catch {
34
+ // FS write failed (read-only homedir?). Return a deterministic-empty
35
+ // string — fingerprint then falls back to the OS-visible properties
36
+ // only, matching pre-fix behaviour rather than crashing.
37
+ return '';
38
+ }
39
+ }
3
40
 
4
41
  /**
5
42
  * Generates a stable 16-char machine fingerprint.
6
- * Based on: hostname, platform, arch, CPU model, username, MAC address.
43
+ * Inputs: hostname, platform, arch, CPU model, username, MAC address, and a
44
+ * persistent random per-installation salt (see getPersistentSalt). The salt
45
+ * eliminates fingerprint collisions on VMs/containers that share all
46
+ * OS-visible properties and report `'no-mac'`.
7
47
  */
8
48
  export function getMachineId() {
9
49
  const ifaces = Object.values(os.networkInterfaces()).flat();
@@ -15,6 +55,7 @@ export function getMachineId() {
15
55
  os.cpus()[0]?.model ?? '',
16
56
  os.userInfo().username,
17
57
  mac,
58
+ getPersistentSalt(),
18
59
  ].join('|');
19
60
  return crypto.createHash('sha256').update(raw).digest('hex').substring(0, 16);
20
61
  }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
2
2
  import { join, dirname } from 'path';
3
3
  import { createHash } from 'crypto';
4
+ import { acquireLock } from '../utils/file-lock.js';
4
5
 
5
6
  const GOTCHAS_FILE = '.chati/memories/shared/gotchas.json';
6
7
  const ERROR_LOG_FILE = '.chati/memories/shared/error-log.json';
@@ -203,53 +204,70 @@ export function recordError(projectDir, error) {
203
204
  });
204
205
 
205
206
  if (recentErrors.length >= ERROR_PATTERN_THRESHOLD) {
206
- // Promote to gotcha
207
- const gotchas = loadGotchas(projectDir);
208
-
209
- // Check if already exists
210
- const existingGotcha = gotchas.find(g => g.pattern === hash);
207
+ // Promote to gotcha — read-then-write must be atomic across concurrent
208
+ // agents. Without a lock two parallel terminals can both compute the
209
+ // same next ID (G001, G002, …) and overwrite each other's entry, since
210
+ // generateGotchaId() picks max+1 by scanning the file. Hold an advisory
211
+ // lock on the gotchas file for the full read-modify-write window.
212
+ const gotchasPath = join(projectDir, GOTCHAS_FILE);
213
+ const dir = dirname(gotchasPath);
214
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
215
+ const lock = acquireLock(gotchasPath, { timeout: 5000 });
216
+ if (!lock.acquired) {
217
+ // Degraded path: still record but skip promotion. Avoids hanging on a
218
+ // stale lock during pipeline execution.
219
+ return { recorded: true, promoted: false, gotcha: null };
220
+ }
221
+ try {
222
+ const gotchas = loadGotchas(projectDir);
223
+
224
+ // Check if already exists
225
+ const existingGotcha = gotchas.find(g => g.pattern === hash);
226
+
227
+ if (existingGotcha) {
228
+ // Update existing gotcha
229
+ existingGotcha.count = recentErrors.length;
230
+ existingGotcha.last_seen = timestamp;
231
+ saveGotchas(projectDir, gotchas);
232
+
233
+ return {
234
+ recorded: true,
235
+ promoted: false,
236
+ gotcha: existingGotcha,
237
+ };
238
+ }
239
+
240
+ // Create new gotcha
241
+ const gotchaId = generateGotchaId(gotchas);
242
+ const classification = classifyError(message);
243
+ const newGotcha = {
244
+ id: gotchaId,
245
+ pattern: hash,
246
+ message: normalizeErrorMessage(message),
247
+ original_message: message,
248
+ agent,
249
+ task,
250
+ count: recentErrors.length,
251
+ first_seen: recentErrors[0].timestamp,
252
+ last_seen: timestamp,
253
+ promoted_at: timestamp,
254
+ resolution: null,
255
+ category: classification.category,
256
+ severity: classification.severity,
257
+ context,
258
+ };
211
259
 
212
- if (existingGotcha) {
213
- // Update existing gotcha
214
- existingGotcha.count = recentErrors.length;
215
- existingGotcha.last_seen = timestamp;
260
+ gotchas.push(newGotcha);
216
261
  saveGotchas(projectDir, gotchas);
217
262
 
218
263
  return {
219
264
  recorded: true,
220
- promoted: false,
221
- gotcha: existingGotcha,
265
+ promoted: true,
266
+ gotcha: newGotcha,
222
267
  };
268
+ } finally {
269
+ lock.release();
223
270
  }
224
-
225
- // Create new gotcha
226
- const gotchaId = generateGotchaId(gotchas);
227
- const classification = classifyError(message);
228
- const newGotcha = {
229
- id: gotchaId,
230
- pattern: hash,
231
- message: normalizeErrorMessage(message),
232
- original_message: message,
233
- agent,
234
- task,
235
- count: recentErrors.length,
236
- first_seen: recentErrors[0].timestamp,
237
- last_seen: timestamp,
238
- promoted_at: timestamp,
239
- resolution: null,
240
- category: classification.category,
241
- severity: classification.severity,
242
- context,
243
- };
244
-
245
- gotchas.push(newGotcha);
246
- saveGotchas(projectDir, gotchas);
247
-
248
- return {
249
- recorded: true,
250
- promoted: true,
251
- gotcha: newGotcha,
252
- };
253
271
  }
254
272
 
255
273
  return {
@@ -44,7 +44,7 @@ export function updateClaudeMd(projectDir, context = {}) {
44
44
  lines.push('');
45
45
  lines.push('### Recent Decisions');
46
46
  for (const d of context.decisions.slice(0, 5)) {
47
- const text = typeof d === 'object' ? `${d.what} ${d.why}` : String(d);
47
+ const text = typeof d === 'object' ? `${d.what}: ${d.why}` : String(d);
48
48
  lines.push(`- ${text}`);
49
49
  }
50
50
  }
@@ -177,18 +177,23 @@ export function buildStructuredDigest(projectDir, sessionState, enrichment = {})
177
177
  },
178
178
  };
179
179
 
180
- // Final budget enforcement — iterative truncation until within budget
180
+ // Final budget enforcement — iterative truncation until within budget.
181
+ // Cache `JSON.stringify(digest).length` and refresh only after a real
182
+ // mutation; the prior implementation serialised the entire object twice
183
+ // per iteration, which became expensive for large sessions.
181
184
  const truncOrder = ['next_steps', 'gotchas_found', 'files_changed', 'blockers', 'decisions_made'];
185
+ const measure = () => JSON.stringify(digest).length;
186
+ let size = measure();
182
187
  let pass = 0;
183
- while (JSON.stringify(digest).length > MAX_STRUCTURED_CHARS && pass < truncOrder.length) {
188
+ while (size > MAX_STRUCTURED_CHARS && pass < truncOrder.length) {
184
189
  const field = truncOrder[pass];
185
190
  if (Array.isArray(digest[field]) && digest[field].length > 1) {
186
191
  digest[field] = digest[field].slice(0, Math.max(1, Math.ceil(digest[field].length / 2)));
192
+ size = measure();
193
+ if (size > MAX_STRUCTURED_CHARS) pass++; // still too big — move on
187
194
  } else {
188
195
  pass++;
189
196
  }
190
- // If we halved and still too big, move to next field
191
- if (JSON.stringify(digest).length > MAX_STRUCTURED_CHARS) pass++;
192
197
  }
193
198
 
194
199
  return digest;