chati-dev 4.5.28 → 4.5.29

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 (172) hide show
  1. package/bin/chati.js +151 -123
  2. package/framework/agents/build/dev.md +4 -4
  3. package/framework/agents/deploy/devops.md +3 -3
  4. package/framework/agents/discover/brief.md +18 -6
  5. package/framework/agents/discover/brownfield-wu.md +3 -3
  6. package/framework/agents/discover/greenfield-wu.md +4 -4
  7. package/framework/agents/plan/architect.md +2 -2
  8. package/framework/agents/plan/detail.md +4 -4
  9. package/framework/agents/plan/phases.md +3 -3
  10. package/framework/agents/plan/tasks.md +47 -19
  11. package/framework/agents/plan/ux-brand-architect.md +1 -1
  12. package/framework/agents/plan/ux.md +26 -2
  13. package/framework/agents/quality/qa-implementation.md +8 -8
  14. package/framework/agents/quality/qa-planning.md +58 -4
  15. package/framework/agents/quality/qa-visual.md +4 -4
  16. package/framework/config.yaml +2 -2
  17. package/framework/constitution.md +12 -1
  18. package/framework/context/governance.md +2 -1
  19. package/framework/context/root.md +3 -1
  20. package/framework/domains/constitution.yaml +1 -1
  21. package/framework/domains/global.yaml +1 -1
  22. package/framework/domains/workflows/standard-flow.yaml +7 -8
  23. package/framework/hooks/advance-trigger.js +3 -2
  24. package/framework/hooks/brief-validator.js +1 -1
  25. package/framework/hooks/post-dev.js +1 -1
  26. package/framework/hooks/team-quality-gate.js +9 -2
  27. package/framework/i18n/en.yaml +1 -0
  28. package/framework/i18n/es.yaml +1 -0
  29. package/framework/i18n/fr.yaml +1 -0
  30. package/framework/i18n/pt.yaml +1 -0
  31. package/framework/manifest.json +83 -83
  32. package/framework/manifest.sig +1 -1
  33. package/framework/orchestrator/chati-router.js +14 -6
  34. package/framework/orchestrator/chati.md +61 -23
  35. package/framework/rules/human-writing-style.md +7 -0
  36. package/framework/schemas/session.schema.json +25 -2
  37. package/framework/schemas/task.schema.json +26 -0
  38. package/framework/tasks/orchestrator-handoff.md +10 -10
  39. package/framework/tasks/orchestrator-resume.md +3 -3
  40. package/framework/workflows/brownfield-fullstack.yaml +6 -3
  41. package/framework/workflows/brownfield-service.yaml +5 -3
  42. package/framework/workflows/brownfield-ui.yaml +5 -3
  43. package/framework/workflows/greenfield-fullstack.yaml +6 -3
  44. package/framework/workflows/standard-flow.yaml +8 -3
  45. package/node_modules/@chati/core/native/browser-proof-wire.h +29 -0
  46. package/node_modules/@chati/core/native/darwin-browser-listener.c +36 -0
  47. package/node_modules/@chati/core/native/darwin-browser-listener.h +32 -0
  48. package/node_modules/@chati/core/native/darwin-browser-proof.h +86 -0
  49. package/node_modules/@chati/core/native/darwin-code-identity.h +35 -0
  50. package/node_modules/@chati/core/native/darwin-launch-messages.h +37 -0
  51. package/node_modules/@chati/core/native/darwin-launcher.c +312 -0
  52. package/node_modules/@chati/core/native/descriptor-fs.c +407 -0
  53. package/node_modules/@chati/core/native/execution-admission.h +59 -0
  54. package/node_modules/@chati/core/native/execution-gate.c +41 -0
  55. package/node_modules/@chati/core/native/linux-process.c +41 -0
  56. package/node_modules/@chati/core/native/prebuilds/darwin-arm64/darwin-launcher +0 -0
  57. package/node_modules/@chati/core/native/prebuilds/darwin-arm64/descriptor-fs.node +0 -0
  58. package/node_modules/@chati/core/native/prebuilds/darwin-arm64/execution-gate +0 -0
  59. package/node_modules/@chati/core/native/prebuilds/linux-x64/descriptor-fs.node +0 -0
  60. package/node_modules/@chati/core/native/prebuilds/linux-x64/execution-gate +0 -0
  61. package/node_modules/@chati/core/native/process-info-pipe.c +91 -0
  62. package/node_modules/@chati/core/package.json +2 -1
  63. package/node_modules/@chati/core/scripts/build-native.js +36 -0
  64. package/node_modules/@chati/core/src/darwin-browser-proof.js +87 -0
  65. package/node_modules/@chati/core/src/darwin-host-boot.js +58 -0
  66. package/node_modules/@chati/core/src/darwin-process.js +79 -0
  67. package/node_modules/@chati/core/src/darwin-supervisor-channels.js +82 -0
  68. package/node_modules/@chati/core/src/darwin-terminal-receipt.js +111 -0
  69. package/node_modules/@chati/core/src/descriptor-fs.js +222 -0
  70. package/node_modules/@chati/core/src/execution-gate.js +8 -0
  71. package/node_modules/@chati/core/src/host-boot-marker.js +45 -0
  72. package/node_modules/@chati/core/src/index.js +18 -0
  73. package/node_modules/@chati/core/src/linux-host-boot.js +218 -0
  74. package/node_modules/@chati/core/src/linux-process.js +133 -0
  75. package/node_modules/@chati/planning/src/browser-verification-plan.js +71 -0
  76. package/node_modules/@chati/planning/src/index.js +33 -0
  77. package/node_modules/@chati/planning/src/planning-material.js +64 -0
  78. package/node_modules/@chati/planning/src/verification-plan.js +57 -0
  79. package/node_modules/@chati/rail/package.json +1 -0
  80. package/node_modules/@chati/rail/src/execution-restoration.js +92 -0
  81. package/node_modules/@chati/rail/src/index.js +812 -167
  82. package/node_modules/@chati/rail/src/provider-processing.js +118 -0
  83. package/node_modules/@chati/rail/src/terminal-containment.js +23 -0
  84. package/node_modules/@chati/release-lane/src/index.js +40 -43
  85. package/node_modules/@chati/review-council/src/index.js +223 -0
  86. package/node_modules/@chati/tracking-clickup/src/index.js +67 -58
  87. package/package-artifact-manifest.json +1 -1
  88. package/package-artifact-manifest.sig +1 -1
  89. package/package.json +3 -2
  90. package/scripts/verify-real-harness-e2e.js +20 -8
  91. package/src/installer/core.js +147 -107
  92. package/src/installer/package-artifact.js +7 -0
  93. package/src/installer/preflight.js +13 -0
  94. package/src/installer/provider-overlay.js +1 -2
  95. package/src/installer/templates.js +58 -0
  96. package/src/installer-v2/catalog-client.js +31 -1
  97. package/src/installer-v2/clickup-connection-storage.js +92 -0
  98. package/src/installer-v2/clickup-mcp-client.js +155 -0
  99. package/src/installer-v2/clickup-oauth-loopback.js +96 -0
  100. package/src/installer-v2/clickup-oauth.js +233 -0
  101. package/src/installer-v2/index.js +141 -15
  102. package/src/installer-v2/installation-authority.js +98 -4
  103. package/src/installer-v2/provider-executable.js +117 -12
  104. package/src/installer-v2/wizard-installation.js +11 -0
  105. package/src/orchestrator/agent-selector.js +39 -1
  106. package/src/orchestrator/browser-artifact-store.js +131 -0
  107. package/src/orchestrator/cli.js +762 -205
  108. package/src/orchestrator/clickup-mcp-observer.js +226 -0
  109. package/src/orchestrator/clickup-runtime.js +45 -24
  110. package/src/orchestrator/council-gate-runtime.js +903 -0
  111. package/src/orchestrator/evidence-authority-enrollment.js +75 -0
  112. package/src/orchestrator/evidence-authority-request.js +46 -0
  113. package/src/orchestrator/handoff-engine.js +4 -11
  114. package/src/orchestrator/pipeline-manager.js +42 -4
  115. package/src/orchestrator/planning-contract-revision.js +711 -0
  116. package/src/orchestrator/planning-reconciliation.js +302 -0
  117. package/src/orchestrator/planning-runtime.js +476 -29
  118. package/src/orchestrator/rail-adjudication-evidence.js +15 -24
  119. package/src/orchestrator/rail-human-effort-authority.js +46 -0
  120. package/src/orchestrator/rail-human-effort.js +149 -0
  121. package/src/orchestrator/rail-runtime.js +291 -47
  122. package/src/orchestrator/rail-verification.js +278 -0
  123. package/src/orchestrator/review-runtime.js +37 -2
  124. package/src/orchestrator/runtime-installation-v2.js +143 -24
  125. package/src/orchestrator/session-manager.js +1457 -29
  126. package/src/tasks/handoff.js +57 -33
  127. package/src/terminal/adapters/claude-adapter.js +3 -0
  128. package/src/terminal/adapters/codex-adapter.js +13 -0
  129. package/src/terminal/adapters/grok-adapter.js +1 -0
  130. package/src/terminal/agent-attempt-budget.js +35 -0
  131. package/src/terminal/browser-endpoint-proxy.js +174 -0
  132. package/src/terminal/browser-observer-driver.js +94 -0
  133. package/src/terminal/browser-observer-supervisor.js +73 -0
  134. package/src/terminal/browser-observer.js +95 -0
  135. package/src/terminal/browser-process-facts.js +75 -0
  136. package/src/terminal/browser-runtime.js +131 -0
  137. package/src/terminal/darwin-authority-policy.js +213 -0
  138. package/src/terminal/darwin-browser-application.js +116 -0
  139. package/src/terminal/darwin-browser-observer.js +57 -0
  140. package/src/terminal/darwin-execution.js +185 -0
  141. package/src/terminal/executable-snapshot.js +202 -0
  142. package/src/terminal/handoff-parser.js +48 -2
  143. package/src/terminal/isolation.js +14 -13
  144. package/src/terminal/prompt-builder.js +176 -15
  145. package/src/terminal/provider-preflight.js +47 -27
  146. package/src/terminal/rail-execution-worktree.js +22 -6
  147. package/src/terminal/rail-process-observer.js +10 -0
  148. package/src/terminal/rail-prompts.js +21 -3
  149. package/src/terminal/rail-readonly-workspace.js +54 -5
  150. package/src/terminal/rail-worktree-checkpoint.js +315 -0
  151. package/src/terminal/rail-worktree-restoration.js +485 -0
  152. package/src/terminal/run-agent.js +164 -42
  153. package/src/terminal/run-council-gate.js +338 -0
  154. package/src/terminal/run-planning-reconciliation.js +49 -0
  155. package/src/terminal/run-rail-adjudication.js +29 -7
  156. package/src/terminal/run-rail-recovery.js +97 -0
  157. package/src/terminal/run-rail-review.js +36 -15
  158. package/src/terminal/run-rail-rework.js +26 -15
  159. package/src/terminal/run-rail-task.js +22 -9
  160. package/src/terminal/runtime-native-helper.js +85 -0
  161. package/src/terminal/spawner.js +899 -142
  162. package/src/terminal/verification-process-facts.js +49 -0
  163. package/src/upgrade/entrypoints.js +68 -0
  164. package/src/upgrade/generation.js +194 -0
  165. package/src/upgrade/maintenance.js +79 -0
  166. package/src/upgrade/paths.js +81 -0
  167. package/src/upgrade/upgrade.js +267 -0
  168. package/src/utils/execution-role.js +15 -0
  169. package/src/utils/handoff-paths.js +59 -0
  170. package/src/wizard/i18n.js +1 -0
  171. package/src/wizard/index.js +17 -5
  172. package/src/wizard/questions.js +24 -12
@@ -0,0 +1,267 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join, relative } from 'node:path';
4
+ import { lstatAtSync, openAtSync, renameAtSync, renameNoReplaceAtSync, unlinkAtSync } from '@chati/core';
5
+ import { withUpgradeMaintenance, UPGRADE_PENDING_FILE } from './maintenance.js';
6
+ import { managedEntrypoints } from './entrypoints.js';
7
+ import { assertUpgradeParentsUnchanged, createUpgradeParents, openUpgradeDirectory, snapshotUpgradeParents } from './paths.js';
8
+ import {
9
+ UPGRADE_TARGETS, assertUpgradeBindings, assertUpgradeStatePreserved, authorizeUpgradeProfile, prepareUpgradeGeneration,
10
+ readUpgradeInstallation, upgradeFailure, upgradeSnapshot, upgradeTestHook, verifyUpgradePackage,
11
+ } from './generation.js';
12
+
13
+ const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
14
+ const identity = (stat) => `${stat.dev}:${stat.ino}`;
15
+ const openDirectory = (path) => openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
16
+
17
+ function readJsonAt(fd, name) {
18
+ const file = openAtSync(fd, name, constants.O_RDONLY | constants.O_NONBLOCK);
19
+ try {
20
+ const stat = fstatSync(file);
21
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > 16 * 1024 * 1024) upgradeFailure('UPGRADE_INTENT_INVALID', 'Upgrade metadata must be a bounded single-link regular file');
22
+ const content = readFileSync(file, 'utf8');
23
+ if (content.length > 16 * 1024 * 1024 || identity(lstatAtSync(fd, name)) !== identity(stat)) upgradeFailure('UPGRADE_INTENT_INVALID', 'Upgrade metadata changed during reading');
24
+ return JSON.parse(content);
25
+ } finally { closeSync(file); }
26
+ }
27
+
28
+ function writeJsonAt(fd, name, value, { replace = false } = {}) {
29
+ const temporary = `.pending-${randomUUID()}`;
30
+ const file = openAtSync(fd, temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
31
+ try { writeFileSync(file, JSON.stringify(value)); fsyncSync(file); } finally { closeSync(file); }
32
+ try {
33
+ if (replace) {
34
+ const stat = lstatAtSync(fd, name);
35
+ if (!stat.isFile() || stat.nlink !== 1) upgradeFailure('UPGRADE_INTENT_INVALID', 'Intent destination is not a regular file');
36
+ renameAtSync(fd, temporary, fd, name);
37
+ } else renameNoReplaceAtSync(fd, temporary, fd, name);
38
+ fsyncSync(fd);
39
+ } catch (error) { unlinkAtSync(fd, temporary, { force: true }); throw error; }
40
+ }
41
+
42
+ function syncTree(path) {
43
+ const stat = lstatSync(path);
44
+ if (stat.isDirectory()) for (const name of readdirSync(path)) syncTree(join(path, name));
45
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | (stat.isDirectory() ? constants.O_DIRECTORY : constants.O_NONBLOCK));
46
+ try { fsyncSync(fd); } finally { closeSync(fd); }
47
+ }
48
+
49
+ function pathsFor(root, directory, target, index) {
50
+ return { active: join(root, target.path), staged: join(directory, 'stage', target.path), previous: join(directory, `previous-${index}`) };
51
+ }
52
+
53
+ function operationsFor(root, directory, targets) {
54
+ return targets.flatMap((target, index) => {
55
+ const paths = pathsFor(root, directory, target, index);
56
+ return [
57
+ ...(target.before ? [{ from: paths.active, to: paths.previous, phase: `after-retire-${index}` }] : []),
58
+ { from: paths.staged, to: paths.active, phase: `after-publish-${index}` },
59
+ ];
60
+ });
61
+ }
62
+
63
+ function expectedPrefix(root, directory, intent, count) {
64
+ const expected = new Map();
65
+ intent.targets.forEach((target, index) => {
66
+ const paths = pathsFor(root, directory, target, index);
67
+ expected.set(paths.active, target.before); expected.set(paths.staged, target.after); expected.set(paths.previous, null);
68
+ });
69
+ for (const operation of operationsFor(root, directory, intent.targets).slice(0, count)) {
70
+ expected.set(operation.to, expected.get(operation.from)); expected.set(operation.from, null);
71
+ }
72
+ return expected;
73
+ }
74
+
75
+ function parentPaths(root, directory, targets) {
76
+ const paths = new Set([root]);
77
+ for (const operation of operationsFor(root, directory, targets)) {
78
+ for (const location of [operation.from, operation.to]) {
79
+ let path = dirname(location);
80
+ while (path !== root) { paths.add(path); path = dirname(path); }
81
+ }
82
+ }
83
+ return [...paths].sort();
84
+ }
85
+
86
+ function assertParents(root, directory, intent) {
87
+ for (const path of parentPaths(root, directory, intent.targets)) {
88
+ const stat = lstatSync(path);
89
+ if (!stat.isDirectory() || stat.isSymbolicLink() || identity(stat) !== intent.parents[relative(root, path)]) {
90
+ upgradeFailure('UPGRADE_STORAGE_CHANGED', 'An upgrade parent directory was replaced');
91
+ }
92
+ }
93
+ }
94
+
95
+ function matchesPrefix(root, directory, intent, count) {
96
+ return [...expectedPrefix(root, directory, intent, count)].every(([path, snapshot]) => same(upgradeSnapshot(path), snapshot));
97
+ }
98
+
99
+ function observedProgress(root, directory, intent) {
100
+ assertParents(root, directory, intent);
101
+ const limit = operationsFor(root, directory, intent.targets).length;
102
+ const next = intent.phase === 'rolling_back' ? intent.progress - 1 : intent.progress + 1;
103
+ for (const count of [intent.progress, next]) {
104
+ if (count >= 0 && count <= limit && matchesPrefix(root, directory, intent, count)) return count;
105
+ }
106
+ return upgradeFailure('UPGRADE_STORAGE_CHANGED', 'Installation does not match the recorded upgrade prefix or its immediately following rename. Backups were retained.');
107
+ }
108
+
109
+ function move(operation, context, directory, intent) {
110
+ context.assertCurrent(); assertParents(context.root, directory, intent);
111
+ const from = openUpgradeDirectory(context.root, relative(context.root, dirname(operation.from)), { expected: intent.parents });
112
+ let to;
113
+ try {
114
+ to = openUpgradeDirectory(context.root, relative(context.root, dirname(operation.to)), { expected: intent.parents });
115
+ if (from === undefined || to === undefined) upgradeFailure('UPGRADE_STORAGE_CHANGED', 'Upgrade parent disappeared');
116
+ context.assertCurrent(); assertParents(context.root, directory, intent);
117
+ if (fstatSync(from).dev !== fstatSync(to).dev) upgradeFailure('UPGRADE_FILESYSTEM_MISMATCH', 'Upgrade requires one filesystem for staged and active data');
118
+ renameNoReplaceAtSync(from, relative(dirname(operation.from), operation.from), to, relative(dirname(operation.to), operation.to));
119
+ fsyncSync(from); fsyncSync(to);
120
+ } finally { if (from !== undefined) closeSync(from); if (to !== undefined) closeSync(to); }
121
+ }
122
+
123
+ function validateIntent(intent, context, directory, packageIdentity, id) {
124
+ if (intent?.schema_version !== 1 || intent.id !== id || intent.root !== context.root
125
+ || !same(intent.package, packageIdentity) || !['promoting', 'rolling_back', 'complete', 'rolled_back'].includes(intent.phase)
126
+ || !Array.isArray(intent.targets) || intent.targets.length < 2 || intent.targets.length > UPGRADE_TARGETS.length
127
+ || !same(intent.targets.map(target => target.path), UPGRADE_TARGETS.filter(path => intent.targets.some(target => target.path === path)))
128
+ || intent.targets[0].path !== '.chati.dev' || intent.targets.at(-1).path !== '.chati/manifest.json'
129
+ || !intent.targets[0].before || !Number.isInteger(intent.progress)
130
+ || intent.progress < 0 || intent.progress > operationsFor(context.root, directory, intent.targets).length
131
+ || (intent.phase === 'complete' && intent.progress !== operationsFor(context.root, directory, intent.targets).length)
132
+ || (intent.phase === 'rolled_back' && intent.progress !== 0)
133
+ || !intent.parents || !intent.protected_state || typeof intent.from_version !== 'string') {
134
+ upgradeFailure('UPGRADE_INTENT_INVALID', 'Interrupted upgrade metadata does not match this project and verified package');
135
+ }
136
+ for (const target of intent.targets) {
137
+ for (const snapshot of [target.before, target.after]) {
138
+ if (snapshot === null && snapshot === target.before) continue;
139
+ if (!snapshot || !/^\d+:\d+$/.test(snapshot.identity) || !/^[a-f0-9]{64}$/.test(snapshot.digest)
140
+ || snapshot.type !== (target.path === '.chati.dev' ? 'directory' : 'file')) upgradeFailure('UPGRADE_INTENT_INVALID', 'Invalid upgrade inventory');
141
+ }
142
+ }
143
+ if (!same(Object.keys(intent.protected_state).sort(), ['.chati/session.yaml', '.chati/v2/installation.json', 'CLAUDE.md'].sort())) upgradeFailure('UPGRADE_INTENT_INVALID', 'Invalid protected-state write boundary');
144
+ }
145
+
146
+ function clearPending(context, id) {
147
+ context.assertCurrent();
148
+ if (!same(readJsonAt(context.stateFd, UPGRADE_PENDING_FILE), { schema_version: 1, id })) upgradeFailure('UPGRADE_INTENT_INVALID', 'Pending upgrade marker was replaced');
149
+ unlinkAtSync(context.stateFd, UPGRADE_PENDING_FILE); fsyncSync(context.stateFd);
150
+ }
151
+
152
+ async function finishUpgrade(context, directory, intent, transactionFd) {
153
+ const save = () => writeJsonAt(transactionFd, 'intent.json', intent, { replace: true });
154
+ const operations = operationsFor(context.root, directory, intent.targets);
155
+ intent.progress = observedProgress(context.root, directory, intent);
156
+ const result = () => ({ version: intent.package.version, previousVersion: intent.from_version, backupDir: directory, validation: intent.validation });
157
+ if (intent.phase === 'complete') {
158
+ // Promotion and authorization already committed durably. Requiring the old
159
+ // executable binding here could deadlock the reconfiguration needed after
160
+ // CLI drift. Still require the exact committed generation and user state.
161
+ assertUpgradeStatePreserved(context.root, intent.protected_state);
162
+ clearPending(context, intent.id);
163
+ return result();
164
+ }
165
+ const rollback = () => {
166
+ intent.phase = 'rolling_back'; save();
167
+ while (intent.progress > 0) {
168
+ const operation = operations[intent.progress - 1];
169
+ move({ from: operation.to, to: operation.from }, context, directory, intent);
170
+ upgradeTestHook(`rollback-${operation.phase}`, { directory });
171
+ intent.progress -= 1; save();
172
+ }
173
+ if (!matchesPrefix(context.root, directory, intent, 0)) upgradeFailure('UPGRADE_ROLLBACK_INCOMPLETE', 'Rollback inventory did not match the previous installation');
174
+ intent.phase = 'rolled_back'; save(); clearPending(context, intent.id);
175
+ };
176
+ if (['rolling_back', 'rolled_back'].includes(intent.phase)) {
177
+ rollback();
178
+ upgradeFailure('UPGRADE_ROLLBACK_RECOVERED', `Previous installation restored at v${intent.from_version}. Repeat upgrade to start a fresh transaction. Backup: ${directory}`);
179
+ }
180
+ let committed = false;
181
+ try {
182
+ assertUpgradeStatePreserved(context.root, intent.protected_state);
183
+ await authorizeUpgradeProfile(assertUpgradeBindings(context.root));
184
+ while (intent.progress < operations.length) {
185
+ const operation = operations[intent.progress];
186
+ move(operation, context, directory, intent);
187
+ upgradeTestHook(operation.phase, { directory });
188
+ intent.progress += 1; save();
189
+ }
190
+ if (!matchesPrefix(context.root, directory, intent, operations.length)) upgradeFailure('UPGRADE_STORAGE_CHANGED', 'Published generation did not match the validated inventory');
191
+ assertUpgradeStatePreserved(context.root, intent.protected_state);
192
+ assertUpgradeBindings(context.root);
193
+ intent.phase = 'complete'; save(); committed = true;
194
+ upgradeTestHook('after-commit', { directory });
195
+ clearPending(context, intent.id);
196
+ return result();
197
+ } catch (error) {
198
+ if (committed) upgradeFailure('UPGRADE_COMMITTED_RECOVERY_REQUIRED', `${error.message}. Upgrade committed; repeat upgrade to finish marker cleanup. Backup: ${directory}`);
199
+ try {
200
+ intent.progress = observedProgress(context.root, directory, intent);
201
+ rollback();
202
+ } catch (rollbackError) {
203
+ upgradeFailure('UPGRADE_RECOVERY_REQUIRED', `${error.message}; rollback incomplete: ${rollbackError.message}. Repeat upgrade after resolving the cause. Backup: ${directory}`);
204
+ }
205
+ return upgradeFailure('UPGRADE_FAILED_ROLLED_BACK', `${error.code || 'UPGRADE_FAILED'}: ${error.message}. Previous installation restored; backup: ${directory}`);
206
+ }
207
+ }
208
+
209
+ /** Managed runtime and exact entrypoint upgrade, not a general project installer. */
210
+ export async function upgradeProject(projectDir, { targetVersion, offline = false } = {}) {
211
+ return withUpgradeMaintenance(projectDir, async (context) => {
212
+ const packageIdentity = verifyUpgradePackage(targetVersion);
213
+ let pending;
214
+ try { pending = readJsonAt(context.stateFd, UPGRADE_PENDING_FILE); }
215
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
216
+ if (pending) {
217
+ if (pending.schema_version !== 1 || !/^[a-f0-9-]{36}$/.test(pending.id) || Object.keys(pending).length !== 2) upgradeFailure('UPGRADE_INTENT_INVALID', 'Invalid pending upgrade pointer');
218
+ const directory = join(context.storageDir, `txn-${pending.id}`);
219
+ const fd = openDirectory(directory);
220
+ try {
221
+ const intent = readJsonAt(fd, 'intent.json');
222
+ validateIntent(intent, context, directory, packageIdentity, pending.id);
223
+ return await finishUpgrade(context, directory, intent, fd);
224
+ } finally { closeSync(fd); }
225
+ }
226
+
227
+ // Capture the exact active generation and optional-file absence before any
228
+ // staging. No CLAUDE/session/installation/authority path is in the write set.
229
+ const runtimeTargets = ['.chati.dev', '.claude/settings.json', '.chati/manifest.json'];
230
+ const runtimeParents = snapshotUpgradeParents(context.root, runtimeTargets);
231
+ const before = Object.fromEntries(runtimeTargets.map(path => [path, upgradeSnapshot(join(context.root, path))]));
232
+ const installation = await readUpgradeInstallation(context.root, { ...packageIdentity, offline });
233
+ assertUpgradeParentsUnchanged(context.root, runtimeParents);
234
+ const entrypointTargets = managedEntrypoints(installation.providers).map(entry => entry.path);
235
+ const initialParents = snapshotUpgradeParents(context.root, [...runtimeTargets, ...entrypointTargets]);
236
+ for (const path of entrypointTargets) before[path] = upgradeSnapshot(join(context.root, path));
237
+ const id = randomUUID();
238
+ const directory = join(context.storageDir, `txn-${id}`);
239
+ mkdirSync(directory, { mode: 0o700 }); fsyncSync(context.stateFd);
240
+ const stagingDir = join(directory, 'stage');
241
+ mkdirSync(stagingDir, { mode: 0o700 });
242
+ const prepared = await prepareUpgradeGeneration(context.root, stagingDir, installation, packageIdentity);
243
+ const targets = prepared.targets.map(path => ({ path, before: before[path], after: upgradeSnapshot(join(stagingDir, path)) }));
244
+ assertUpgradeParentsUnchanged(context.root, initialParents);
245
+ for (const target of targets) {
246
+ if (!same(upgradeSnapshot(join(context.root, target.path)), target.before)) upgradeFailure('UPGRADE_STATE_CHANGED', 'Active installation changed during preparation');
247
+ }
248
+ const activeParents = createUpgradeParents(context.root, prepared.targets, initialParents);
249
+ const parents = Object.fromEntries(parentPaths(context.root, directory, targets).map(path => {
250
+ const stat = lstatSync(path);
251
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.dev !== lstatSync(directory).dev) upgradeFailure('UNSAFE_UPGRADE_PATH', 'Upgrade parents must be real directories on the same filesystem');
252
+ return [relative(context.root, path), identity(stat)];
253
+ }));
254
+ syncTree(stagingDir);
255
+ assertUpgradeParentsUnchanged(context.root, activeParents);
256
+ const intent = { schema_version: 1, id, root: context.root, package: packageIdentity, from_version: installation.config.version,
257
+ phase: 'promoting', progress: 0, targets, parents, protected_state: installation.protectedState, validation: prepared.validation };
258
+ const fd = openDirectory(directory);
259
+ try {
260
+ writeJsonAt(fd, 'intent.json', intent);
261
+ context.assertCurrent();
262
+ writeJsonAt(context.stateFd, UPGRADE_PENDING_FILE, { schema_version: 1, id });
263
+ upgradeTestHook('after-intent', { directory });
264
+ return await finishUpgrade(context, directory, intent, fd);
265
+ } finally { closeSync(fd); }
266
+ }, { exclusive: true });
267
+ }
@@ -0,0 +1,15 @@
1
+ // Instruction scope only. Never use this text as runtime authorization.
2
+ export const DELEGATED_EXECUTION_BOUNDARY = `## Delegated execution boundary
3
+
4
+ If the parent runtime already launched this process with a concrete agent/task assignment,
5
+ you are its delegated worker, not the user-facing coordinator. Execute only that assignment.
6
+ Do not invoke the Chati entry skill, router, advance, recovery, or dispatch another pipeline agent.
7
+ Use the supplied canonical context, write only the assigned outputs, and return the required
8
+ result envelope to the parent. Return missing information or errors in that envelope;
9
+ do not create interactions, change session state, or authorize transitions yourself.
10
+ An absent or ambiguous delegated assignment is a structured error, not a reason to reactivate.
11
+ Ordinary task commands or specialist delegation remain subject to the assigned scope and binding.
12
+ This role description is not permission to skip any runtime check or expand tool permissions.
13
+
14
+ For a normal user entry or continuation, the user-facing coordinator must still run the
15
+ deterministic router first and preserve its license, integrity, claim and gate checks.`;
@@ -0,0 +1,59 @@
1
+ import { lstatSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { resolveFrameworkDir } from './framework-dir.js';
4
+
5
+ const AGENT_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
6
+
7
+ function validateAgent(agent) {
8
+ if (typeof agent !== 'string' || !AGENT_NAME.test(agent)) {
9
+ throw new Error('Invalid handoff agent name');
10
+ }
11
+ }
12
+
13
+ // Each producer owns a directory, not a mounted file inode. Native CLI editors
14
+ // can therefore create a temporary file and atomically rename it into place.
15
+ export function agentHandoffDirectory(agent) {
16
+ validateAgent(agent);
17
+ return `artifacts/handoffs/${agent}/`;
18
+ }
19
+
20
+ export function agentHandoffPath(projectDir, agent) {
21
+ return join(projectDir, agentHandoffDirectory(agent), `${agent}-handoff.md`);
22
+ }
23
+
24
+ function handoffRoots(projectDir) {
25
+ return [
26
+ join(projectDir, 'artifacts', 'handoffs'),
27
+ join(projectDir, resolveFrameworkDir(projectDir), 'artifacts', 'handoffs'),
28
+ ];
29
+ }
30
+
31
+ // Select by presence, NEVER by successful parsing. An empty, corrupt or dangling
32
+ // new output must not silently resurrect a stale legacy handoff.
33
+ export function findAgentHandoffPath(projectDir, agent) {
34
+ validateAgent(agent);
35
+ const candidates = [agentHandoffPath(projectDir, agent),
36
+ ...handoffRoots(projectDir).map(root => join(root, `${agent}-handoff.md`))];
37
+ for (const candidate of candidates) {
38
+ const stat = lstatSync(candidate, { throwIfNoEntry: false });
39
+ if (!stat) continue;
40
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Handoff must be a regular file');
41
+ return candidate;
42
+ }
43
+ return null;
44
+ }
45
+
46
+ export function listHandoffAgents(projectDir) {
47
+ const agents = new Set();
48
+ for (const [index, root] of handoffRoots(projectDir).entries()) {
49
+ let entries;
50
+ try { entries = readdirSync(root, { withFileTypes: true }); }
51
+ catch (error) { if (error.code === 'ENOENT') continue; throw error; }
52
+ for (const entry of entries) {
53
+ if (index === 0 && entry.isDirectory() && AGENT_NAME.test(entry.name)) agents.add(entry.name);
54
+ const match = entry.name.match(/^([a-z][a-z0-9]*(?:-[a-z0-9]+)*)-handoff\.md$/);
55
+ if (match) agents.add(match[1]);
56
+ }
57
+ }
58
+ return [...agents].sort();
59
+ }
@@ -29,6 +29,7 @@ const FALLBACK_EN = {
29
29
  llm_provider_label: 'AI Provider',
30
30
  ide_selection_title: 'Select your IDEs / CLIs (multiple allowed):',
31
31
  provider_selection_title: 'Which AI providers will you use?',
32
+ council_providers_required: 'Select at least two distinct providers capable of review for the required quality council.',
32
33
  editor_selection_title: 'Which editor IDEs should get rules files? (optional)',
33
34
  provider_claude_hint: 'Anthropic official CLI',
34
35
  provider_gemini_hint: 'Google AI terminal agent',
@@ -3,10 +3,11 @@ import { existsSync, readFileSync } from 'fs';
3
3
  import { join, dirname, basename } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { logBanner } from '../utils/logger.js';
6
- import { WIZARD_BACK, stepLanguage, stepProjectType, stepProviderSelection, stepInstallationMode, stepConfirmation, stepTermsOfUse } from './questions.js';
6
+ import { WIZARD_BACK, stepLanguage, stepProjectType, stepProviderSelection, validateCouncilProviderSelection, stepInstallationMode, stepConfirmation, stepTermsOfUse } from './questions.js';
7
7
  import { createSpinner, withSpinner, showStep, showValidation, showQuickStart } from './feedback.js';
8
8
  import { installFramework } from '../installer/core.js';
9
- import { INSTALLATION_ARTIFACT_PATH, dryRunV2, installV2, reconfigureV2 } from '../installer-v2/index.js';
9
+ import { assertInstallerPlatformSupported } from '../installer/preflight.js';
10
+ import { INSTALLATION_ARTIFACT_PATH, assertExecutableV2Installation, dryRunV2, installV2, reconfigureV2 } from '../installer-v2/index.js';
10
11
  import { bindProviderExecutables } from '../installer-v2/provider-executable.js';
11
12
  import { buildWizardV2InstallationInput } from '../installer-v2/wizard-installation.js';
12
13
  import { resolveCapabilityCatalog, verifySignedCapabilityCatalog } from '../installer-v2/catalog-client.js';
@@ -27,6 +28,7 @@ const VERSION = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.jso
27
28
  * Run the 4-step installer wizard
28
29
  */
29
30
  export async function runWizard(targetDir, options = {}) {
31
+ assertInstallerPlatformSupported();
30
32
  // Load ASCII logo
31
33
  let logoText;
32
34
  try {
@@ -128,6 +130,8 @@ export async function runWizard(targetDir, options = {}) {
128
130
  stage = previousInteractiveStage('providers');
129
131
  continue;
130
132
  }
133
+ const providerError = validateCouncilProviderSelection(value);
134
+ if (providerError) throw Object.assign(new Error(providerError), { code: 'COUNCIL_PROVIDERS_REQUIRED' });
131
135
  selectedProviders = value;
132
136
  if (!catalogResolution) {
133
137
  catalogResolution = await withSpinner(t('installer.catalog_resolving'), async () => (
@@ -202,7 +206,15 @@ export async function runWizard(targetDir, options = {}) {
202
206
  capabilityCatalog: catalogResolution.catalog,
203
207
  installationMode,
204
208
  }));
205
- dryRunV2(v2InstallationInput, { projectDir: targetDir });
209
+ const preview = dryRunV2(v2InstallationInput, { projectDir: targetDir });
210
+ try {
211
+ assertExecutableV2Installation(preview.artifact);
212
+ } catch (error) {
213
+ if (options.providers !== undefined || options.modelSelections !== undefined) throw error;
214
+ p.log.error(`${t('installer.council_providers_required')} ${error.message}`);
215
+ stage = 'providers';
216
+ continue;
217
+ }
206
218
  if (existsSync(join(targetDir, INSTALLATION_ARTIFACT_PATH)) && options.reconfigure !== true) {
207
219
  throw Object.assign(new Error('a v2 installation already exists; use --reconfigure to replace it safely'), { code: 'INSTALLATION_ALREADY_EXISTS' });
208
220
  }
@@ -230,8 +242,8 @@ export async function runWizard(targetDir, options = {}) {
230
242
 
231
243
  try {
232
244
  await installFramework(config);
233
- if (options.reconfigure === true) reconfigureV2(v2InstallationInput, { projectDir: targetDir });
234
- else installV2(v2InstallationInput, { projectDir: targetDir });
245
+ if (options.reconfigure === true) await reconfigureV2(v2InstallationInput, { projectDir: targetDir, licenseValidator: options.licenseValidator });
246
+ else await installV2(v2InstallationInput, { projectDir: targetDir, licenseValidator: options.licenseValidator });
235
247
  installSpinner.stop();
236
248
 
237
249
  showStep(t('installer.created_chati'));
@@ -83,9 +83,15 @@ const PROVIDER_DISPLAY_NAMES = {
83
83
  /**
84
84
  * Step 3a: Provider Selection (multi-select)
85
85
  *
86
- * Asks the user which AI providers they want to use (Claude, Gemini, Codex).
87
- * Returns an array of provider names (e.g. ['claude', 'gemini']).
86
+ * Asks for at least two providers for the required cross-provider council.
87
+ * Never adds an unselected provider or defines a primary provider.
88
88
  */
89
+ export function validateCouncilProviderSelection(selected) {
90
+ if (!Array.isArray(selected) || selected.some((provider) => !supportedV2WizardProviders.includes(provider))
91
+ || new Set(selected).size < 2) return t('installer.council_providers_required');
92
+ return undefined;
93
+ }
94
+
89
95
  export async function stepProviderSelection({ allowBack = false } = {}) {
90
96
  const cliIDEs = Object.entries(IDE_CONFIGS).filter(([key, c]) => c.group === 'cli' && supportedV2WizardProviders.includes(IDE_TO_PROVIDER[key]));
91
97
  const options = [];
@@ -108,17 +114,23 @@ export async function stepProviderSelection({ allowBack = false } = {}) {
108
114
  });
109
115
  }
110
116
 
111
- const selected = await p.multiselect({
112
- message: `${t('installer.provider_selection_title')} ${dim(t('installer.multiselect_hint'))}`,
113
- options: allowBack ? [...options, backOption()] : options,
114
- required: true,
115
- });
116
-
117
- if (p.isCancel(selected)) {
118
- cancelInstallation();
117
+ let initialValues = [];
118
+ // The bundled Clack multiselect does not forward a custom validate callback.
119
+ // Validate the returned values ourselves, preserving selection on retry.
120
+ while (true) {
121
+ const selected = await p.multiselect({
122
+ message: `${t('installer.provider_selection_title')} ${t('installer.council_providers_required')} ${dim(t('installer.multiselect_hint'))}`,
123
+ options: allowBack ? [...options, backOption()] : options,
124
+ required: false,
125
+ initialValues,
126
+ });
127
+ if (p.isCancel(selected)) cancelInstallation();
128
+ if (allowBack && selected.includes(WIZARD_BACK)) return WIZARD_BACK;
129
+ const error = validateCouncilProviderSelection(selected);
130
+ if (!error) return selected;
131
+ p.log.error(error);
132
+ initialValues = selected.filter((provider) => supportedV2WizardProviders.includes(provider));
119
133
  }
120
-
121
- return selected.includes(WIZARD_BACK) ? WIZARD_BACK : selected;
122
134
  }
123
135
 
124
136
  /**