chati-dev 4.4.0 → 4.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ EK1FsSHB+n5Dbo2dRSF1DPgRD35si2lygeQvPZZp91Vy069v6AlHbK/delS4LVkj/sAU7Tp5PoulKchOCF75AQ==
@@ -833,10 +833,13 @@ The orchestrator enforces `chati.dev/constitution.md`:
833
833
 
834
834
  | Level | Action | Articles |
835
835
  |-------|--------|----------|
836
- | **BLOCK** | Halt agent on violation | I, II, III, IV, VII, VIII, X, XI, XII, XIII, XV, XVIII, XX, XXI, XXII |
836
+ | **BLOCK** | Halt agent on violation | I, II, III, IV, V, VII, VIII, X, XI, XII, XIII, XV, XX, XXI, XXII, XXIII |
837
837
  | **STRICT** | Must not bypass quality gates | XVII |
838
- | **GUIDE** | Correct without halting | V, IX, XIV, XVI, XIX |
838
+ | **GUIDE** | Correct without halting | IX, XIV, XVI, XIX, XXIV |
839
839
  | **WARN** | Generate warning in QA | VI |
840
+ | **FLAG** | Feature-flagged (inert until enabled) | XXV |
841
+
842
+ Article XVIII was deprecated 2026-04-18 and folded into Article XVII; it carries no enforcement level.
840
843
 
841
844
  ---
842
845
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chati-dev",
3
- "version": "4.4.0",
3
+ "version": "4.4.1",
4
4
  "description": "AI-Powered Multi-Agent Orchestration System - Structured vibe coding for Full Stack Development",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  "sync": "node scripts/sync-framework.js",
29
29
  "scan": "node scripts/scan-stale.js",
30
30
  "install-to": "node scripts/install-to.js",
31
- "prepublishOnly": "node scripts/sync-framework.js && node scripts/scan-stale.js && node scripts/validate-package.js && node scripts/sign-manifest.js",
31
+ "prepublishOnly": "node scripts/sync-framework.js && node scripts/scan-stale.js && node scripts/sign-manifest.js && REQUIRE_SIGNED_MANIFEST=1 node scripts/validate-package.js",
32
32
  "test": "node --test test/**/*.test.js",
33
33
  "lint": "eslint src/ bin/",
34
34
  "lint:fix": "eslint src/ bin/ --fix",
@@ -30,6 +30,7 @@ const HOOK_REGISTRY = [
30
30
  // 2. PreToolUse catch-all below (backup — Claude sees deny reason, reports it)
31
31
  { name: 'prism-engine', event: 'UserPromptSubmit', matcher: '.*' },
32
32
  { name: 'model-governance', event: 'UserPromptSubmit', matcher: '.*' },
33
+ { name: 'reasoning-escalator', event: 'UserPromptSubmit', matcher: '.*' },
33
34
 
34
35
  // PreToolUse — fires before each tool call. Matcher targets specific tools.
35
36
  // license-guard is first on catch-all ".*" — blocks ALL tool calls when
@@ -38,11 +39,13 @@ const HOOK_REGISTRY = [
38
39
  // no tool call succeeds with an expired license.
39
40
  { name: 'license-guard', event: 'PreToolUse', matcher: '.*' },
40
41
  { name: 'read-protection', event: 'PreToolUse', matcher: 'Read' },
41
- { name: 'constitution-guard',event: 'PreToolUse', matcher: 'Bash|Write|Edit' },
42
- { name: 'mode-governance', event: 'PreToolUse', matcher: 'Write|Edit' },
43
- { name: 'style-guard', event: 'PreToolUse', matcher: 'Write|Edit|Bash' },
44
- { name: 'undercover-guard', event: 'PreToolUse', matcher: 'Write|Edit|Bash' },
45
- { name: 'team-quality-gate', event: 'PreToolUse', matcher: 'Write|Edit' },
42
+ { name: 'constitution-guard',event: 'PreToolUse', matcher: 'Bash|Write|Edit|NotebookEdit' },
43
+ { name: 'git-push-authority', event: 'PreToolUse', matcher: 'Bash' },
44
+ { name: 'mode-governance', event: 'PreToolUse', matcher: 'Write|Edit|NotebookEdit' },
45
+ { name: 'style-guard', event: 'PreToolUse', matcher: 'Write|Edit|NotebookEdit|Bash' },
46
+ { name: 'undercover-guard', event: 'PreToolUse', matcher: 'Write|Edit|NotebookEdit|Bash' },
47
+ { name: 'team-quality-gate', event: 'PreToolUse', matcher: 'Write|Edit|NotebookEdit' },
48
+ { name: 'reasoning-escalator', event: 'PreToolUse', matcher: 'Write|Edit|NotebookEdit' },
46
49
 
47
50
  // PostToolUse — fires after each tool call. Used to auto-advance the pipeline
48
51
  // when an agent finishes writing its handoff. Hook inspects the written path
@@ -55,7 +58,7 @@ const HOOK_REGISTRY = [
55
58
  { name: 'post-dev', event: 'PostToolUse', matcher: 'Write' },
56
59
 
57
60
  // PreCompact — fires before context compaction (observational).
58
- { name: 'session-digest', event: 'PreCompact', matcher: '' },
61
+ { name: 'session-digest', event: 'PreCompact', matcher: '.*' },
59
62
  ];
60
63
 
61
64
  /**
@@ -224,7 +224,7 @@ export async function installFramework(config) {
224
224
  *
225
225
  * New files added to the source framework are picked up automatically.
226
226
  */
227
- const FRAMEWORK_DIRS_TO_COPY = [
227
+ export const FRAMEWORK_DIRS_TO_COPY = [
228
228
  'orchestrator',
229
229
  'agents',
230
230
  'templates',
@@ -824,7 +824,7 @@ export function copyCliDependencies(pkgDir, destNodeModules) {
824
824
  * If the file exists but is malformed: leave it alone, log a warning. We will
825
825
  * not silently destroy user data.
826
826
  */
827
- function writeClaudeSettingsWithMerge(settingsPath) {
827
+ export function writeClaudeSettingsWithMerge(settingsPath) {
828
828
  createDir(dirname(settingsPath));
829
829
  const fresh = JSON.parse(generateClaudeSettings());
830
830
 
@@ -878,7 +878,37 @@ export function mergeClaudeSettings(existing, fresh) {
878
878
  merged.hooks = { ...existingHooks };
879
879
 
880
880
  for (const [eventName, freshGroups] of Object.entries(freshHooks)) {
881
- const existingGroups = existingHooks[eventName] || [];
881
+ let existingGroups = existingHooks[eventName] || [];
882
+
883
+ // Widened-matcher upgrade: when a chati hook's matcher grows (e.g.
884
+ // Write|Edit -> Write|Edit|NotebookEdit), the old registration must be
885
+ // REPLACED, not kept alongside the new one - otherwise the hook fires
886
+ // twice for the overlapping tools on every upgraded install and
887
+ // settings.json grows without bound. Drop an existing registration of a
888
+ // command when the fresh settings register the SAME command on a matcher
889
+ // that is a strict superset of the existing one. A user-widened matcher
890
+ // (existing wider than fresh) is left untouched.
891
+ const toolsOf = (matcher) =>
892
+ matcher === '.*' || matcher === '' || matcher == null ? null : String(matcher).split('|');
893
+ const covers = (superTools, subTools) =>
894
+ superTools === null || (subTools !== null && subTools.every((t) => superTools.includes(t)));
895
+ const freshByCommand = new Map();
896
+ for (const g of freshGroups) {
897
+ for (const h of g.hooks || []) {
898
+ freshByCommand.set(h.command, { matcher: g.matcher, tools: toolsOf(g.matcher) });
899
+ }
900
+ }
901
+ existingGroups = existingGroups
902
+ .map((group) => ({
903
+ ...group,
904
+ hooks: (group.hooks || []).filter((h) => {
905
+ const freshReg = freshByCommand.get(h.command);
906
+ if (!freshReg || freshReg.matcher === group.matcher) return true;
907
+ return !covers(freshReg.tools, toolsOf(group.matcher));
908
+ }),
909
+ }))
910
+ .filter((group) => (group.hooks || []).length > 0);
911
+
882
912
  // Append fresh groups; we don't dedup at the group level (different
883
913
  // matchers are different groups). Within a single fresh group, the
884
914
  // hook commands are unique to chati so duplication risk is minimal.
@@ -138,12 +138,38 @@ export function compareManifests(oldManifest, newManifest) {
138
138
  * @param {string} signatureBase64 - Base64-encoded Ed25519 signature
139
139
  * @returns {{ valid: boolean, reason: string }}
140
140
  */
141
- export function verifyManifest(manifest, signatureBase64) {
142
- if (!SIGNING_PUBLIC_KEY) return { valid: false, reason: 'no-public-key' };
141
+ export function verifyManifest(manifest, signatureBase64, publicKeyPem = null) {
142
+ // publicKeyPem is a test seam: production callers use the embedded key.
143
+ const key = publicKeyPem ? createPublicKey(publicKeyPem) : SIGNING_PUBLIC_KEY;
144
+ if (!key) return { valid: false, reason: 'no-public-key' };
143
145
 
144
- const manifestJson = JSON.stringify(manifest, Object.keys(manifest).sort(), 2);
146
+ const manifestJson = serializeManifest(manifest);
145
147
  const signature = Buffer.from(signatureBase64, 'base64');
146
148
 
147
- const valid = cryptoVerify(null, Buffer.from(manifestJson), SIGNING_PUBLIC_KEY, signature);
149
+ const valid = cryptoVerify(null, Buffer.from(manifestJson), key, signature);
148
150
  return { valid, reason: valid ? 'ok' : 'signature-mismatch' };
149
151
  }
152
+
153
+ /**
154
+ * Canonical manifest serialization used by BOTH signing and verification.
155
+ *
156
+ * MUST NOT use a JSON.stringify replacer ARRAY: a replacer array filters keys
157
+ * at EVERY depth, so the per-file path keys inside `files` were dropped and
158
+ * the signature attested to an empty file map (and the written manifest.json
159
+ * itself contained "files": {}). Determinism comes from sorting the top-level
160
+ * keys and the files map explicitly.
161
+ *
162
+ * @param {object} manifest
163
+ * @returns {string}
164
+ */
165
+ export function serializeManifest(manifest) {
166
+ const sorted = Object.fromEntries(
167
+ Object.entries(manifest).sort(([a], [b]) => a.localeCompare(b))
168
+ );
169
+ if (sorted.files && typeof sorted.files === 'object') {
170
+ sorted.files = Object.fromEntries(
171
+ Object.entries(sorted.files).sort(([a], [b]) => a.localeCompare(b))
172
+ );
173
+ }
174
+ return JSON.stringify(sorted, null, 2);
175
+ }
@@ -1,3 +1,3 @@
1
1
  -----BEGIN PUBLIC KEY-----
2
- MCowBQYDK2VwAyEA8thWGs1GHKpeJyUTejpBwp8TZ7iZiN3vDxGDoQf8dMs=
2
+ MCowBQYDK2VwAyEAgo4u1bUZttpuxRluFnZc+3AMQH+U5xWEURN41kv/s0A=
3
3
  -----END PUBLIC KEY-----
@@ -5,6 +5,7 @@ import { hashFile } from './file-hasher.js';
5
5
  import { loadManifest } from './manifest.js';
6
6
  import { ADAPTABLE_FILES } from '../config/framework-adapter.js';
7
7
  import { validateSchema, CONFIG_SCHEMA } from '../utils/schema-validator.js';
8
+ import { EXPECTED_ARTICLE_COUNT } from '../utils/constitution-meta.js';
8
9
 
9
10
  function resolveFrameworkDir(targetDir) {
10
11
  if (existsSync(join(targetDir, '.chati.dev'))) return '.chati.dev';
@@ -72,7 +73,7 @@ export async function validateInstallation(targetDir) {
72
73
  if (existsSync(constitutionPath)) {
73
74
  const content = readFileSync(constitutionPath, 'utf-8');
74
75
  const articleCount = (content.match(/^## Article/gm) || []).length;
75
- results.constitution.pass = articleCount >= 22;
76
+ results.constitution.pass = articleCount === EXPECTED_ARTICLE_COUNT;
76
77
  results.constitution.details.push({ articleCount });
77
78
  }
78
79
  results.total += 1;
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import yaml from 'js-yaml';
4
4
  import { resolveFrameworkDir } from '../utils/framework-dir.js';
5
+ import { EXPECTED_ARTICLE_COUNT } from '../utils/constitution-meta.js';
5
6
  import { flattenEntitiesMap } from '../utils/flatten-entities.js';
6
7
 
7
8
  /**
@@ -143,7 +144,7 @@ export function runHealthCheck(targetDir) {
143
144
  if (existsSync(constitutionPath)) {
144
145
  const content = readFileSync(constitutionPath, 'utf-8');
145
146
  const articleCount = (content.match(/^## Article/gm) || []).length;
146
- checks.constitution.pass = articleCount >= 22;
147
+ checks.constitution.pass = articleCount === EXPECTED_ARTICLE_COUNT;
147
148
  checks.constitution.details = `${articleCount} articles`;
148
149
  } else {
149
150
  checks.constitution.details = 'Not found';
@@ -12,7 +12,12 @@ import { existsSync, readFileSync, writeFileSync } from 'fs';
12
12
  import { join } from 'path';
13
13
 
14
14
  const SECTION_HEADER = '## Current State';
15
- const SECTION_REGEX = /^## Current State\n([\s\S]*?)(?=\n## |\n---|\n$|$)/m;
15
+ // The section ends at the next `## ` heading, a `---` rule, or the TRUE end of
16
+ // the file. `$(?![\s\S])` is the absolute end-of-string: with the `m` flag a
17
+ // bare `$` matches at every line end, which made the lazy capture stop after
18
+ // the FIRST content line, orphaning the rest of the section below the
19
+ // replacement on every write (the corruption grew with each update).
20
+ const SECTION_REGEX = /^## Current State\n([\s\S]*?)(?=\n## |\n---|$(?![\s\S]))/m;
16
21
 
17
22
  /**
18
23
  * Update CLAUDE.md with current project state after a handoff.
@@ -42,7 +42,8 @@ import { analyzeDeviationImpact, applyDeviation } from './index.js';
42
42
  import { detectQuickFlow, detectStandardFlow } from './index.js';
43
43
  import { getAgentFile } from '../terminal/prompt-builder.js';
44
44
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
45
- import { join, resolve } from 'path';
45
+ import { join, resolve, dirname } from 'path';
46
+ import { fileURLToPath } from 'url';
46
47
  import { execSync } from 'child_process';
47
48
  import yaml from 'js-yaml';
48
49
  import {
@@ -53,6 +54,15 @@ import {
53
54
  } from '../intelligence/timeline.js';
54
55
  import { updateClaudeMd } from '../memory/magic-docs.js';
55
56
 
57
+ // Spawn commands must reference the terminal runners with an absolute,
58
+ // layout-independent path: the old monorepo-relative
59
+ // 'packages/chati-dev/src/terminal/...' only resolves when the command runs
60
+ // from the monorepo root, never in an installed project (.chati.dev/_cli/ or
61
+ // node_modules). Anchoring to this file's own location works in every layout
62
+ // because the src/ tree shape is preserved verbatim by the CLI bundling.
63
+ const _THISDIR = dirname(fileURLToPath(import.meta.url));
64
+ const RUNNER = (name) => JSON.stringify(join(_THISDIR, '..', 'terminal', name));
65
+
56
66
  // ---------------------------------------------------------------------------
57
67
  // Constants
58
68
  // ---------------------------------------------------------------------------
@@ -533,13 +543,14 @@ function sessionToPipelineState(session, projectDir) {
533
543
 
534
544
  /**
535
545
  * Build the spawn command string for an autonomous agent.
546
+ * Exported for testing (path resolution must hold in every install layout).
536
547
  */
537
- function buildSpawnCommand(agent, projectDir, previousAgent, provider, timeout) {
548
+ export function buildSpawnCommand(agent, projectDir, previousAgent, provider, timeout) {
538
549
  const parts = [
539
- 'node', 'packages/chati-dev/src/terminal/run-agent.js',
550
+ 'node', RUNNER('run-agent.js'),
540
551
  '--agent', agent,
541
552
  '--task-id', `${agent}-task`,
542
- '--project-dir', resolve(projectDir),
553
+ '--project-dir', JSON.stringify(resolve(projectDir)),
543
554
  '--previous-agent', previousAgent || 'none',
544
555
  '--provider', provider || 'claude',
545
556
  '--timeout', String(timeout || 600000),
@@ -549,14 +560,15 @@ function buildSpawnCommand(agent, projectDir, previousAgent, provider, timeout)
549
560
 
550
561
  /**
551
562
  * Build the parallel spawn command string.
563
+ * Exported for testing.
552
564
  */
553
- function buildParallelSpawnCommand(agents, projectDir, previousAgent, provider, timeout) {
565
+ export function buildParallelSpawnCommand(agents, projectDir, previousAgent, provider, timeout) {
554
566
  const taskIds = agents.map(a => `${a}-task`).join(',');
555
567
  const parts = [
556
- 'node', 'packages/chati-dev/src/terminal/run-parallel.js',
568
+ 'node', RUNNER('run-parallel.js'),
557
569
  '--agents', agents.join(','),
558
570
  '--task-ids', taskIds,
559
- '--project-dir', resolve(projectDir),
571
+ '--project-dir', JSON.stringify(resolve(projectDir)),
560
572
  '--previous-agent', previousAgent || 'none',
561
573
  '--provider', provider || 'claude',
562
574
  '--timeout', String(timeout || 900000),
@@ -2700,10 +2712,10 @@ async function handleSpawnTeam(projectDir, args) {
2700
2712
 
2701
2713
  // Build spawn command
2702
2714
  const spawnCmd = [
2703
- 'node', join('packages', 'chati-dev', 'src', 'terminal', 'run-team.js'),
2715
+ 'node', RUNNER('run-team.js'),
2704
2716
  '--team-id', teamId,
2705
2717
  '--team-type', teamType,
2706
- '--project-dir', resolve(projectDir),
2718
+ '--project-dir', JSON.stringify(resolve(projectDir)),
2707
2719
  '--previous-agent', previousAgent,
2708
2720
  '--provider', provider,
2709
2721
  '--timeout', args.timeout || '1800000',
@@ -105,7 +105,9 @@ export function detectDeviation(userMessage, _pipelineState) {
105
105
  * @returns {{ impact: 'low'|'medium'|'high', affectedAgents: string[], recommendation: string, requiresConfirmation: boolean }}
106
106
  */
107
107
  export function analyzeDeviationImpact(deviationType, pipelineState, details = {}) {
108
- const currentAgent = pipelineState.current_agent;
108
+ // sessionToPipelineState produces camelCase (currentAgent); accept the legacy
109
+ // snake_case shape too so direct callers keep working.
110
+ const currentAgent = pipelineState.currentAgent ?? pipelineState.current_agent;
109
111
  const completedAgents = Object.entries(pipelineState.agents || {})
110
112
  .filter(([, data]) => data.status === 'completed')
111
113
  .map(([name]) => name);
@@ -268,7 +270,7 @@ export function applyDeviation(pipelineState, deviationType, details) {
268
270
  changes.push(`Reset agent: ${agentName}`);
269
271
  }
270
272
 
271
- newState.current_agent = targetAgent;
273
+ newState.currentAgent = targetAgent;
272
274
  changes.push(`Rolled back to: ${targetAgent}`);
273
275
 
274
276
  break;
@@ -302,7 +304,7 @@ export function applyDeviation(pipelineState, deviationType, details) {
302
304
  }
303
305
 
304
306
  // Reset to initial state
305
- newState.current_agent = '';
307
+ newState.currentAgent = '';
306
308
  newState.backlog = [];
307
309
  changes.push('Reset all agents to initial state');
308
310
 
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { fileURLToPath } from 'url';
18
18
  import { buildAgentPrompt } from './prompt-builder.js';
19
- import { spawnParallelGroup, spawnTerminal } from './spawner.js';
19
+ import { spawnParallelGroup, spawnTerminal, killTerminal } from './spawner.js';
20
20
  import { TerminalMonitor } from './monitor.js';
21
21
  import { collectResults, mergeHandoffs, buildConsolidatedHandoff } from './collector.js';
22
22
  import { parseAgentOutput } from './handoff-parser.js';
@@ -254,13 +254,13 @@ async function main() {
254
254
  * @param {number} timeout - Per-agent timeout in ms
255
255
  * @returns {Promise<{ groupId: string, terminals: object[] }>}
256
256
  */
257
- async function sequentialFallback(configs, timeout) {
257
+ async function sequentialFallback(configs, timeout, _spawnFn = spawnTerminal) {
258
258
  const groupId = `seq-fallback-${Date.now()}`;
259
259
  const terminals = [];
260
260
 
261
261
  for (let i = 0; i < configs.length; i++) {
262
262
  const cfg = configs[i];
263
- const terminal = spawnTerminal({
263
+ const handle = _spawnFn({
264
264
  agent: cfg.agent,
265
265
  taskId: cfg.taskId,
266
266
  model: cfg.model,
@@ -270,26 +270,26 @@ async function sequentialFallback(configs, timeout) {
270
270
  timeout: cfg.timeout || timeout,
271
271
  });
272
272
 
273
- // Wait for this terminal to finish before spawning the next
273
+ // Wait for the REAL process exit before spawning the next agent. The old
274
+ // implementation probed terminal.onExit?.(), a method TerminalHandle never
275
+ // had, so every agent "completed" instantly and the fallback ran the whole
276
+ // group in parallel - defeating its purpose (shedding rate-limit pressure).
274
277
  await new Promise((resolve) => {
275
- const timer = setTimeout(() => {
276
- terminal.kill?.();
278
+ if (!handle?.process || handle.status !== 'running') {
277
279
  resolve();
280
+ return;
281
+ }
282
+ const timer = setTimeout(() => {
283
+ killTerminal(handle).catch(() => {}).then(resolve, resolve);
278
284
  }, (cfg.timeout || timeout) + 5_000);
279
285
 
280
- terminal.onExit?.(() => {
286
+ handle.process.once('exit', () => {
281
287
  clearTimeout(timer);
282
288
  resolve();
283
289
  });
284
-
285
- // If terminal doesn't have onExit, resolve after a short poll
286
- if (!terminal.onExit) {
287
- clearTimeout(timer);
288
- resolve();
289
- }
290
290
  });
291
291
 
292
- terminals.push(terminal);
292
+ terminals.push(handle);
293
293
 
294
294
  // Small delay between spawns to avoid rate limit pressure
295
295
  if (i < configs.length - 1) {
@@ -1,6 +1,24 @@
1
1
  import { existsSync, mkdirSync, cpSync, rmSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { resolveFrameworkDir } from '../utils/framework-dir.js';
4
+ import { FRAMEWORK_DIRS_TO_COPY } from '../installer/core.js';
5
+
6
+ /**
7
+ * The backup must cover exactly what the upgrade overwrites, or a rollback
8
+ * leaves the install in a chimera state (some dirs at the new version, some
9
+ * restored). The upgrade replaces every dir in FRAMEWORK_DIRS_TO_COPY (via
10
+ * copyFrameworkFiles), so that same list is the backup/restore scope - one
11
+ * source of truth, no asymmetry. Previously the backup saved 12 dirs and the
12
+ * restore returned only 10 (intelligence and data were backed up but never
13
+ * restored; hooks, domains, tasks, scaffold and others were never backed up
14
+ * at all).
15
+ */
16
+ const BACKUP_DIRS = FRAMEWORK_DIRS_TO_COPY;
17
+
18
+ const BACKUP_FILES = [
19
+ 'constitution.md',
20
+ 'config.yaml',
21
+ ];
4
22
 
5
23
  /**
6
24
  * Create backup of chati.dev/ directory before upgrade
@@ -17,28 +35,7 @@ export function createBackup(targetDir, currentVersion) {
17
35
 
18
36
  mkdirSync(backupDir, { recursive: true });
19
37
 
20
- // Backup framework files (NOT artifacts or intelligence)
21
- const dirsToBackup = [
22
- 'orchestrator',
23
- 'agents',
24
- 'templates',
25
- 'workflows',
26
- 'quality-gates',
27
- 'schemas',
28
- 'frameworks',
29
- 'i18n',
30
- 'patterns',
31
- 'migrations',
32
- 'intelligence',
33
- 'data',
34
- ];
35
-
36
- const filesToBackup = [
37
- 'constitution.md',
38
- 'config.yaml',
39
- ];
40
-
41
- for (const dir of dirsToBackup) {
38
+ for (const dir of BACKUP_DIRS) {
42
39
  const src = join(frameworkDir, dir);
43
40
  const dest = join(backupDir, dir);
44
41
  if (existsSync(src)) {
@@ -46,7 +43,7 @@ export function createBackup(targetDir, currentVersion) {
46
43
  }
47
44
  }
48
45
 
49
- for (const file of filesToBackup) {
46
+ for (const file of BACKUP_FILES) {
50
47
  const src = join(frameworkDir, file);
51
48
  const dest = join(backupDir, file);
52
49
  if (existsSync(src)) {
@@ -68,26 +65,7 @@ export function restoreFromBackup(targetDir, version) {
68
65
  throw new Error(`Backup not found: ${backupDir}`);
69
66
  }
70
67
 
71
- // Restore backed-up directories
72
- const dirsToRestore = [
73
- 'orchestrator',
74
- 'agents',
75
- 'templates',
76
- 'workflows',
77
- 'quality-gates',
78
- 'schemas',
79
- 'frameworks',
80
- 'i18n',
81
- 'patterns',
82
- 'migrations',
83
- ];
84
-
85
- const filesToRestore = [
86
- 'constitution.md',
87
- 'config.yaml',
88
- ];
89
-
90
- for (const dir of dirsToRestore) {
68
+ for (const dir of BACKUP_DIRS) {
91
69
  const src = join(backupDir, dir);
92
70
  const dest = join(frameworkDir, dir);
93
71
  if (existsSync(src)) {
@@ -98,7 +76,7 @@ export function restoreFromBackup(targetDir, version) {
98
76
  }
99
77
  }
100
78
 
101
- for (const file of filesToRestore) {
79
+ for (const file of BACKUP_FILES) {
102
80
  const src = join(backupDir, file);
103
81
  const dest = join(frameworkDir, file);
104
82
  if (existsSync(src)) {
@@ -42,13 +42,25 @@ export function findMigrations(targetDir, fromVersion, toVersion) {
42
42
  for (const file of files) {
43
43
  try {
44
44
  const content = yaml.load(readFileSync(join(migrationsDir, file), 'utf-8'));
45
- const migration = content?.migration;
45
+ // Two header schemas exist: the nested `migration: { from, to }` form and
46
+ // the older flat `from_version`/`to_version` form (with `operations`
47
+ // instead of `steps`). The flat files were silently skipped here, which
48
+ // hid them from every upgrade.
49
+ const migration = content?.migration
50
+ ?? (content?.from_version && content?.to_version
51
+ ? { from: content.from_version, to: content.to_version, description: content.description, breaking: content.breaking }
52
+ : null);
46
53
 
47
54
  if (!migration?.from || !migration?.to) continue;
48
55
 
49
- // Check if this migration is in the upgrade path
56
+ // A migration applies when its TARGET version lies inside the upgrade
57
+ // interval (fromVersion, toVersion]. The old condition compared the
58
+ // migration's `from` against the user's version with gte, which skipped
59
+ // every pending migration for users sitting ABOVE a migration's starting
60
+ // point (a 4.2.3 user never received v4.2-to-v4.3) and is wrong for
61
+ // users below it too (a 3.x user needs every step on the way up).
50
62
  if (
51
- semver.gte(migration.from, fromVersion) &&
63
+ semver.gt(migration.to, fromVersion) &&
52
64
  semver.lte(migration.to, toVersion)
53
65
  ) {
54
66
  applicable.push({
@@ -57,7 +69,7 @@ export function findMigrations(targetDir, fromVersion, toVersion) {
57
69
  to: migration.to,
58
70
  description: migration.description || '',
59
71
  breaking: migration.breaking || false,
60
- steps: content.steps || [],
72
+ steps: content.steps || content.operations || [],
61
73
  rollback: content.rollback || [],
62
74
  });
63
75
  }
@@ -73,14 +85,20 @@ export function findMigrations(targetDir, fromVersion, toVersion) {
73
85
  }
74
86
 
75
87
  /**
76
- * Execute a single migration step
88
+ * Execute a single migration step. Exported for testing.
77
89
  */
78
- async function executeMigrationStep(targetDir, step) {
90
+ export async function executeMigrationStep(targetDir, step, sourceFile = '') {
79
91
  switch (step.type) {
80
92
  case 'create_directory': {
81
- const dir = join(targetDir, step.path);
82
- if (!existsSync(dir) || !step.idempotent) {
83
- mkdirSync(dir, { recursive: true });
93
+ // Both shapes exist in the migration corpus: `path` (string) in the
94
+ // nested-schema files and `paths` (array) in the older flat-schema ones.
95
+ const dirs = Array.isArray(step.paths) ? step.paths : [step.path];
96
+ for (const rel of dirs) {
97
+ if (!rel) continue;
98
+ const dir = join(targetDir, rel);
99
+ if (!existsSync(dir) || !step.idempotent) {
100
+ mkdirSync(dir, { recursive: true });
101
+ }
84
102
  }
85
103
  break;
86
104
  }
@@ -100,7 +118,19 @@ async function executeMigrationStep(targetDir, step) {
100
118
 
101
119
  const content = yaml.load(readFileSync(filePath, 'utf-8')) || {};
102
120
 
103
- if (step.operation === 'add_field') {
121
+ if (step.changes && typeof step.changes === 'object') {
122
+ // Older flat-schema shape: a `changes:` map of (possibly dotted)
123
+ // field -> value, applied as a plain set.
124
+ for (const [field, value] of Object.entries(step.changes)) {
125
+ const keys = field.split('.');
126
+ let obj = content;
127
+ for (let i = 0; i < keys.length - 1; i++) {
128
+ if (!obj[keys[i]]) obj[keys[i]] = {};
129
+ obj = obj[keys[i]];
130
+ }
131
+ obj[keys[keys.length - 1]] = value;
132
+ }
133
+ } else if (step.operation === 'add_field') {
104
134
  const keys = step.field.split('.');
105
135
  let obj = content;
106
136
  for (let i = 0; i < keys.length - 1; i++) {
@@ -176,7 +206,16 @@ async function executeMigrationStep(targetDir, step) {
176
206
  }
177
207
 
178
208
  default:
179
- // Unknown step type - skip
209
+ // Copy-style steps (copy_file, copy_directory) from the older flat-schema
210
+ // migrations are subsumed by the upgrade's blanket copyFrameworkFiles, so
211
+ // skipping them is correct. Anything else unknown is surfaced instead of
212
+ // silently dropped, so an upgrader from a very old version knows what to
213
+ // review manually.
214
+ if (step.type !== 'copy_file' && step.type !== 'copy_directory') {
215
+ process.stderr.write(
216
+ `[chati] warning: migration step type "${step.type}"${sourceFile ? ` in ${sourceFile}` : ''} is not auto-executable; review the migration file manually\n`
217
+ );
218
+ }
180
219
  break;
181
220
  }
182
221
  }
@@ -196,7 +235,7 @@ export async function runMigrations(targetDir, fromVersion, toVersion) {
196
235
  for (const migration of migrations) {
197
236
  try {
198
237
  for (const step of migration.steps) {
199
- await executeMigrationStep(targetDir, step);
238
+ await executeMigrationStep(targetDir, step, migration.file);
200
239
  }
201
240
  results.push({ file: migration.file, success: true });
202
241
  } catch (err) {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @fileoverview Single source of truth for constitution metadata used by
3
+ * integrity checks.
4
+ *
5
+ * Every check that counts `## Article` headings (doctor, registry health,
6
+ * installer validator, stale-scan check 13) compares against THIS constant,
7
+ * so adding a constitutional article is a one-line bump here. The stale-scan
8
+ * asserts the canonical constitution.md actually contains this many articles,
9
+ * which keeps the constant honest.
10
+ */
11
+
12
+ export const EXPECTED_ARTICLE_COUNT = 25;