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.
- package/bin/chati.js +16 -0
- package/framework/config.yaml +3 -3
- package/framework/constitution.md +3 -1
- package/framework/data/entity-registry.yaml +1 -1
- package/framework/manifest.json +1351 -0
- package/framework/manifest.sig +1 -0
- package/framework/orchestrator/chati.md +5 -2
- package/package.json +2 -2
- package/src/config/claude-settings-generator.js +9 -6
- package/src/installer/core.js +33 -3
- package/src/installer/manifest.js +30 -4
- package/src/installer/signing-public-key.pem +1 -1
- package/src/installer/validator.js +2 -1
- package/src/intelligence/registry-manager.js +2 -1
- package/src/memory/magic-docs.js +6 -1
- package/src/orchestrator/cli.js +21 -9
- package/src/orchestrator/deviation-handler.js +5 -3
- package/src/terminal/run-parallel.js +14 -14
- package/src/upgrade/backup.js +22 -44
- package/src/upgrade/migrator.js +51 -12
- package/src/utils/constitution-meta.js +12 -0
- package/src/utils/feature-flags.js +25 -1
|
@@ -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,
|
|
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 |
|
|
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.
|
|
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/
|
|
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: '
|
|
43
|
-
{ name: '
|
|
44
|
-
{ name: '
|
|
45
|
-
{ name: '
|
|
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
|
/**
|
package/src/installer/core.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
146
|
+
const manifestJson = serializeManifest(manifest);
|
|
145
147
|
const signature = Buffer.from(signatureBase64, 'base64');
|
|
146
148
|
|
|
147
|
-
const valid = cryptoVerify(null, Buffer.from(manifestJson),
|
|
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
|
+
}
|
|
@@ -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
|
|
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
|
|
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';
|
package/src/memory/magic-docs.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/src/orchestrator/cli.js
CHANGED
|
@@ -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', '
|
|
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', '
|
|
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',
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
292
|
+
terminals.push(handle);
|
|
293
293
|
|
|
294
294
|
// Small delay between spawns to avoid rate limit pressure
|
|
295
295
|
if (i < configs.length - 1) {
|
package/src/upgrade/backup.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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)) {
|
package/src/upgrade/migrator.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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.
|
|
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
|
-
//
|
|
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;
|