chati-dev 4.4.0 → 4.5.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.
Files changed (85) hide show
  1. package/README.md +29 -27
  2. package/bin/chati.js +251 -1
  3. package/framework/agents/plan/tasks.md +46 -1
  4. package/framework/config.yaml +3 -3
  5. package/framework/constitution.md +16 -17
  6. package/framework/context/governance.md +5 -5
  7. package/framework/context/root.md +5 -5
  8. package/framework/data/entity-registry.yaml +1 -1
  9. package/framework/manifest.json +1351 -0
  10. package/framework/manifest.sig +1 -0
  11. package/framework/orchestrator/chati.md +48 -14
  12. package/node_modules/@chati/browser-capability/README.md +10 -0
  13. package/node_modules/@chati/browser-capability/package.json +17 -0
  14. package/node_modules/@chati/browser-capability/src/index.js +165 -0
  15. package/node_modules/@chati/core/package.json +13 -0
  16. package/node_modules/@chati/core/src/index.js +111 -0
  17. package/node_modules/@chati/knowledge-context/package.json +17 -0
  18. package/node_modules/@chati/knowledge-context/src/index.js +202 -0
  19. package/node_modules/@chati/planning/package.json +17 -0
  20. package/node_modules/@chati/planning/src/index.js +367 -0
  21. package/node_modules/@chati/provider-registry/package.json +16 -0
  22. package/node_modules/@chati/provider-registry/src/index.js +132 -0
  23. package/node_modules/@chati/rail/README.md +24 -0
  24. package/node_modules/@chati/rail/package.json +19 -0
  25. package/node_modules/@chati/rail/src/index.js +437 -0
  26. package/node_modules/@chati/release-lane/README.md +24 -0
  27. package/node_modules/@chati/release-lane/package.json +17 -0
  28. package/node_modules/@chati/release-lane/src/index.js +172 -0
  29. package/node_modules/@chati/review-council/package.json +17 -0
  30. package/node_modules/@chati/review-council/src/index.js +264 -0
  31. package/node_modules/@chati/tracking-clickup/README.md +55 -0
  32. package/node_modules/@chati/tracking-clickup/package.json +17 -0
  33. package/node_modules/@chati/tracking-clickup/src/index.js +293 -0
  34. package/package.json +25 -3
  35. package/src/config/claude-settings-generator.js +9 -6
  36. package/src/config/ide-configs.js +11 -0
  37. package/src/context/domain-loader.js +1 -1
  38. package/src/dashboard/data-reader.js +1 -1
  39. package/src/executors/runner.js +1 -1
  40. package/src/installer/core.js +33 -3
  41. package/src/installer/manifest.js +30 -4
  42. package/src/installer/scaffold-applier.js +1 -1
  43. package/src/installer/signing-public-key.pem +1 -1
  44. package/src/installer/templates.js +3 -3
  45. package/src/installer/validator.js +3 -2
  46. package/src/installer-v2/catalog-client.js +141 -0
  47. package/src/installer-v2/index.js +301 -0
  48. package/src/installer-v2/model-catalog-envelope.json +59 -0
  49. package/src/installer-v2/model-catalog.json +33 -0
  50. package/src/installer-v2/model-catalog.sig +1 -0
  51. package/src/installer-v2/wizard-installation.js +115 -0
  52. package/src/intelligence/registry-manager.js +3 -2
  53. package/src/license/client.js +1 -1
  54. package/src/memory/magic-docs.js +6 -1
  55. package/src/memory/session-digest.js +1 -1
  56. package/src/merger/yaml-merger.js +1 -1
  57. package/src/orchestrator/browser-runtime.js +25 -0
  58. package/src/orchestrator/cli.js +112 -15
  59. package/src/orchestrator/clickup-projection.js +84 -0
  60. package/src/orchestrator/clickup-runtime.js +13 -0
  61. package/src/orchestrator/deviation-handler.js +5 -3
  62. package/src/orchestrator/knowledge-runtime.js +64 -0
  63. package/src/orchestrator/planning-runtime.js +127 -0
  64. package/src/orchestrator/rail-runtime.js +421 -0
  65. package/src/orchestrator/release-runtime.js +14 -0
  66. package/src/orchestrator/review-runtime.js +74 -0
  67. package/src/orchestrator/runtime-installation-v2.js +38 -0
  68. package/src/orchestrator/session-manager.js +1 -1
  69. package/src/telemetry/config.js +1 -1
  70. package/src/terminal/adapters/grok-adapter.js +16 -0
  71. package/src/terminal/adapters/index.js +1 -0
  72. package/src/terminal/cli-registry.js +14 -0
  73. package/src/terminal/prompt-builder.js +2 -0
  74. package/src/terminal/run-agent.js +3 -0
  75. package/src/terminal/run-parallel.js +35 -24
  76. package/src/terminal/spawner.js +7 -1
  77. package/src/terminal/team-task-list.js +1 -1
  78. package/src/upgrade/backup.js +22 -44
  79. package/src/upgrade/checker.js +1 -1
  80. package/src/upgrade/migrator.js +52 -13
  81. package/src/utils/constitution-meta.js +12 -0
  82. package/src/utils/feature-flags.js +25 -1
  83. package/src/wizard/i18n.js +8 -2
  84. package/src/wizard/index.js +39 -4
  85. package/src/wizard/questions.js +59 -2
@@ -0,0 +1,293 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { ContractError, canonicalize, sha256 } from '@chati/core';
4
+
5
+ const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
6
+ const DELIVERY_STATES = new Set(['pending', 'sent_unconfirmed', 'confirmed', 'retryable_failure', 'permanent_failure']);
7
+ const RECONCILIATION_STATES = new Set(['not_due', 'pending', 'reconciled', 'drift_detected', 'blocked']);
8
+ const OPERATIONS = new Set(['create', 'update', 'close']);
9
+ export const HOURLY_WATCHER_INTERVAL_MS = 60 * 60 * 1000;
10
+
11
+ function fail(code, message, details = {}) { throw new ContractError(code, message, details); }
12
+ function string(value, code, label) { if (typeof value !== 'string' || value.trim() === '') fail(code, `${label} must be a non-empty string`); }
13
+ function object(value, code, label) { if (!value || typeof value !== 'object' || Array.isArray(value)) fail(code, `${label} must be an object`); }
14
+ function timestamp(clock) {
15
+ if (typeof clock !== 'function') fail('MISSING_CLOCK', 'a deterministic clock function is required');
16
+ const result = clock();
17
+ const value = result instanceof Date ? result.toISOString() : result;
18
+ if (typeof value !== 'string' || !RFC3339.test(value) || Number.isNaN(Date.parse(value))) fail('INVALID_CLOCK', 'clock must return a valid RFC3339 timestamp or Date');
19
+ return value;
20
+ }
21
+ function writeAtomic(path, data) {
22
+ mkdirSync(dirname(path), { recursive: true });
23
+ const temporary = `${path}.${process.pid}.tmp`;
24
+ writeFileSync(temporary, `${JSON.stringify(canonicalize(data), null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
25
+ renameSync(temporary, path);
26
+ }
27
+ function readOutbox(path) {
28
+ if (!existsSync(path)) return { schema_version: 1, projections: [] };
29
+ let data;
30
+ try { data = JSON.parse(readFileSync(path, 'utf8')); } catch { fail('OUTBOX_CORRUPT', 'outbox JSON is malformed'); }
31
+ if (data?.schema_version !== 1 || !Array.isArray(data.projections)) fail('OUTBOX_CORRUPT', 'outbox schema is invalid');
32
+ return data;
33
+ }
34
+ function closureMutation(payload) {
35
+ const status = typeof payload.status === 'string' ? payload.status.trim().toLowerCase() : '';
36
+ return ['closed', 'close', 'done', 'complete', 'completed'].includes(status)
37
+ || Object.hasOwn(payload, 'end_date') || Object.hasOwn(payload, 'endDate');
38
+ }
39
+
40
+ /**
41
+ * C-07 local projection authority. It intentionally has no ClickUp SDK,
42
+ * credentials, network client, timer or knowledge-store integration. A caller
43
+ * injects a transport only after an independently authorized boundary.
44
+ */
45
+ export class TrackingClickUp {
46
+ #outboxPath;
47
+ #evolutionDir;
48
+ #clock;
49
+ #transport;
50
+ #verifyCompletion;
51
+ #verifyReference;
52
+ #resolveReference;
53
+ #preflight;
54
+ #watcherIntervalMs;
55
+ #state;
56
+
57
+ constructor({ outbox_path, project_evolution_dir, clock, transport = null, verify_completion, verify_reference, resolve_reference, preflight = null, watcher_interval_ms = HOURLY_WATCHER_INTERVAL_MS } = {}) {
58
+ string(outbox_path, 'INVALID_OUTBOX_PATH', 'outbox_path');
59
+ string(project_evolution_dir, 'INVALID_EVOLUTION_DIR', 'project_evolution_dir');
60
+ if (transport !== null && (!transport || typeof transport.send !== 'function')) fail('INVALID_TRANSPORT', 'transport.send must be a function');
61
+ if (typeof verify_completion !== 'function') fail('MISSING_COMPLETION_VERIFIER', 'verify_completion must be an injected C-06 verifier');
62
+ if (typeof verify_reference !== 'function') fail('MISSING_REFERENCE_VERIFIER', 'verify_reference must verify immutable handoff and journal references');
63
+ if (typeof resolve_reference !== 'function') fail('MISSING_REFERENCE_RESOLVER', 'resolve_reference must produce verified handoff and journal views');
64
+ if (preflight !== null && (typeof preflight.validate_schema !== 'function' || typeof preflight.authorize_transport !== 'function')) fail('INVALID_PREFLIGHT', 'preflight requires validate_schema and authorize_transport functions');
65
+ if (watcher_interval_ms !== HOURLY_WATCHER_INTERVAL_MS) fail('INVALID_WATCHER_INTERVAL', 'watcher interval must be exactly hourly');
66
+ this.#outboxPath = outbox_path;
67
+ this.#evolutionDir = project_evolution_dir;
68
+ this.#clock = clock;
69
+ this.#transport = transport;
70
+ this.#verifyCompletion = verify_completion;
71
+ this.#verifyReference = verify_reference;
72
+ this.#resolveReference = resolve_reference;
73
+ this.#preflight = preflight;
74
+ this.#watcherIntervalMs = watcher_interval_ms;
75
+ this.#state = readOutbox(outbox_path);
76
+ // Project Evolution is a derivative, never the only copy of evidence.
77
+ // Rebuild it after an interrupted outbox/evolution write sequence.
78
+ for (const projectId of new Set(this.#state.projections.map((item) => item.project_id))) this.#evolve(projectId);
79
+ }
80
+
81
+ get projections() { return Object.freeze(this.#state.projections.map((item) => Object.freeze(canonicalize(item)))); }
82
+ get outboxPath() { return this.#outboxPath; }
83
+ get watcherConfig() { return Object.freeze({ interval_ms: this.#watcherIntervalMs, mode: 'external-one-shot-scheduler' }); }
84
+
85
+ #persist() { writeAtomic(this.#outboxPath, this.#state); }
86
+ #find(projectionId) {
87
+ string(projectionId, 'INVALID_PROJECTION_ID', 'projection_id');
88
+ const projection = this.#state.projections.find((item) => item.projection_id === projectionId);
89
+ if (!projection) fail('PROJECTION_NOT_FOUND', 'projection does not exist', { projection_id: projectionId });
90
+ return projection;
91
+ }
92
+ #evolve(projectId) {
93
+ const relevant = this.#state.projections.filter((item) => item.project_id === projectId);
94
+ const latestByTask = new Map();
95
+ for (const item of relevant) latestByTask.set(item.task_id, item);
96
+ const sourceProjections = relevant.map(({ project_evolution_ref, ...source }) => source);
97
+ const snapshot = canonicalize({
98
+ schema_version: 1,
99
+ project_id: projectId,
100
+ current_phase: [...latestByTask.values()].at(-1)?.handoff_view.phase ?? 'unknown',
101
+ tasks: [...latestByTask.values()].map((item) => ({ task_id: item.task_id, attempt_id: item.attempt_id, phase: item.handoff_view.phase, canonical_state: item.journal_view.task_state, blockers: item.journal_view.blockers, delivery_state: item.delivery_state, reconciliation_state: item.reconciliation_state })),
102
+ decisions: [...new Map(relevant.flatMap((item) => item.decisions.map((decision) => [decision.decision_ref, decision]))).values()],
103
+ verified_handoff_refs: [...new Set(relevant.map((item) => item.handoff_ref))],
104
+ verified_journal_refs: [...new Set(relevant.map((item) => item.journal_ref))],
105
+ receipt_refs: [...new Set(relevant.map((item) => item.receipt_ref).filter(Boolean))],
106
+ blockers: relevant.filter((item) => item.delivery_state === 'permanent_failure' || item.reconciliation_state === 'blocked').map((item) => item.projection_id),
107
+ projection_backlog: relevant.filter((item) => !['confirmed', 'permanent_failure'].includes(item.delivery_state)).map((item) => item.projection_id),
108
+ source_outbox_hash: sha256({ schema_version: 1, projections: sourceProjections }),
109
+ });
110
+ const digest = sha256(snapshot);
111
+ const path = join(this.#evolutionDir, projectId, `${digest}.json`);
112
+ writeAtomic(path, snapshot);
113
+ return Object.freeze({ project_evolution_ref: `project-evolution://${projectId}/${digest}`, path, digest });
114
+ }
115
+ #setEvolution(projection) {
116
+ const evolution = this.#evolve(projection.project_id);
117
+ projection.project_evolution_ref = evolution.project_evolution_ref;
118
+ return evolution;
119
+ }
120
+ #preflightTransport(projection) {
121
+ validateProjection(projection);
122
+ if (!this.#preflight) fail('TRANSPORT_PRECONDITION_UNMET', 'schema and authorization preflight are required before any transport call');
123
+ if (this.#preflight.validate_schema(Object.freeze(canonicalize(projection))) !== true) fail('SCHEMA_NOT_AUTHORIZED', 'remote schema preflight rejected projection');
124
+ if (this.#preflight.authorize_transport(Object.freeze(canonicalize(projection))) !== true) fail('TRANSPORT_NOT_AUTHORIZED', 'transport authorization or token gate rejected projection');
125
+ }
126
+
127
+ enqueue(input = {}) {
128
+ object(input, 'INVALID_PROJECTION', 'projection');
129
+ const { projection_id, idempotency_key, canonical_event_id, task_id, clickup_ref, operation, payload, project_id, handoff_ref, journal_ref } = input;
130
+ const attempt_id = input.attempt_id ?? `project:${project_id}:no-attempt`;
131
+ for (const [name, value] of Object.entries({ projection_id, idempotency_key, canonical_event_id, task_id, clickup_ref, project_id })) string(value, 'INVALID_PROJECTION', name);
132
+ string(attempt_id, 'INVALID_PROJECTION', 'attempt_id');
133
+ if (!OPERATIONS.has(operation)) fail('INVALID_PROJECTION_OPERATION', 'operation must be create, update, or close');
134
+ object(payload, 'INVALID_PROJECTION', 'payload');
135
+ string(handoff_ref, 'MISSING_SOURCE_REFERENCE', 'handoff_ref');
136
+ string(journal_ref, 'MISSING_SOURCE_REFERENCE', 'journal_ref');
137
+ if (this.#verifyReference(handoff_ref, 'handoff') !== true || this.#verifyReference(journal_ref, 'journal') !== true) fail('UNVERIFIED_SOURCE_REFERENCE', 'handoff and journal references must be verified before projection');
138
+ const handoff_view = this.#resolveReference(handoff_ref, 'handoff');
139
+ const journal_view = this.#resolveReference(journal_ref, 'journal');
140
+ object(handoff_view, 'INVALID_HANDOFF_VIEW', 'resolved handoff view');
141
+ object(journal_view, 'INVALID_JOURNAL_VIEW', 'resolved journal view');
142
+ string(handoff_view.phase, 'INVALID_HANDOFF_VIEW', 'resolved handoff view.phase');
143
+ string(journal_view.task_state, 'INVALID_JOURNAL_VIEW', 'resolved journal view.task_state');
144
+ if (!Array.isArray(journal_view.blockers) || !journal_view.blockers.every((item) => typeof item === 'string' && item)) fail('INVALID_JOURNAL_VIEW', 'resolved journal view.blockers must be non-empty strings');
145
+ if (input.decisions !== undefined && (!Array.isArray(input.decisions) || !input.decisions.every((item) => item && typeof item === 'object' && typeof item.decision_ref === 'string' && item.decision_ref && this.#verifyReference(item.decision_ref, 'decision') === true))) fail('INVALID_DECISIONS', 'decisions must be verified immutable project decision references');
146
+ const duplicateByKey = this.#state.projections.find((item) => item.idempotency_key === idempotency_key);
147
+ const duplicateById = this.#state.projections.find((item) => item.projection_id === projection_id);
148
+ const duplicate = duplicateByKey || duplicateById;
149
+ if (duplicate) {
150
+ const sameIdentity = duplicateByKey === duplicateById
151
+ && duplicate.task_id === task_id
152
+ && duplicate.attempt_id === attempt_id
153
+ && duplicate.operation === operation
154
+ && duplicate.payload_hash === sha256(payload);
155
+ if (sameIdentity) return Object.freeze(canonicalize(duplicate));
156
+ if (duplicateByKey) fail('DUPLICATE_IDEMPOTENCY_KEY', 'idempotency_key already exists with different material');
157
+ fail('DUPLICATE_PROJECTION_ID', 'projection_id already exists with different material');
158
+ }
159
+ if (operation === 'close' || closureMutation(payload)) {
160
+ object(input.completion, 'MISSING_COMPLETION_EVIDENCE', 'completion');
161
+ const verdict = this.#verifyCompletion(input.completion);
162
+ if (verdict !== true) fail('INVALID_COMPLETION_EVIDENCE', 'close requires a valid C-06 completion');
163
+ }
164
+ const projection = canonicalize({
165
+ schema_version: 1, projection_id, idempotency_key, canonical_event_id, task_id, attempt_id, clickup_ref, project_id, handoff_ref, journal_ref, handoff_view: canonicalize(handoff_view), journal_view: canonicalize(journal_view), decisions: canonicalize(input.decisions ?? []), operation,
166
+ payload_hash: sha256(payload), payload, delivery_state: 'pending', reconciliation_state: 'not_due', created_at: timestamp(this.#clock), attempts: 0,
167
+ });
168
+ this.#state.projections.push(projection);
169
+ this.#persist(); // Durable before any injected transport is called.
170
+ this.#setEvolution(projection);
171
+ this.#persist();
172
+ return Object.freeze(canonicalize(projection));
173
+ }
174
+
175
+ deliver({ projection_id } = {}) {
176
+ const projection = this.#find(projection_id);
177
+ if (!this.#transport) fail('TRANSPORT_NOT_CONFIGURED', 'no authorized projection transport is configured');
178
+ if (projection.delivery_state === 'confirmed') return Object.freeze(canonicalize(projection));
179
+ if (projection.delivery_state === 'permanent_failure') fail('PERMANENT_FAILURE', 'permanently failed projection cannot be retried');
180
+ if (projection.delivery_state === 'sent_unconfirmed') fail('UNCONFIRMED_DELIVERY_REQUIRES_LOOKUP', 'unconfirmed delivery must be resolved by idempotency lookup before resend');
181
+ this.#preflightTransport(projection);
182
+ projection.attempts += 1;
183
+ projection.last_attempt_at = timestamp(this.#clock);
184
+ try {
185
+ const response = this.#transport.send(Object.freeze(canonicalize(projection)));
186
+ object(response, 'INVALID_TRANSPORT_RECEIPT', 'transport response');
187
+ if (response.receipt_ref) {
188
+ string(response.receipt_ref, 'INVALID_TRANSPORT_RECEIPT', 'receipt_ref');
189
+ if (this.#verifyReference(response.receipt_ref, 'receipt') !== true) fail('UNVERIFIED_RECEIPT', 'delivery receipt must be an immutable verified reference');
190
+ projection.delivery_state = 'confirmed';
191
+ projection.receipt_ref = response.receipt_ref;
192
+ projection.reconciliation_state = 'pending';
193
+ } else projection.delivery_state = 'sent_unconfirmed';
194
+ } catch (error) {
195
+ projection.delivery_state = error?.permanent === true ? 'permanent_failure' : 'retryable_failure';
196
+ projection.failure_code = typeof error?.code === 'string' ? error.code : 'TRANSPORT_FAILURE';
197
+ }
198
+ this.#setEvolution(projection);
199
+ this.#persist();
200
+ return Object.freeze(canonicalize(projection));
201
+ }
202
+
203
+ reconcile({ projection_id } = {}) {
204
+ const projection = this.#find(projection_id);
205
+ if (!this.#transport || typeof this.#transport.reconcile !== 'function') fail('RECONCILIATION_NOT_CONFIGURED', 'no authorized reconciliation transport is configured');
206
+ if (projection.delivery_state !== 'confirmed') fail('RECEIPT_REQUIRED', 'reconciliation requires confirmed delivery receipt');
207
+ this.#preflightTransport(projection);
208
+ const result = this.#transport.reconcile(Object.freeze(canonicalize(projection)));
209
+ object(result, 'INVALID_RECONCILIATION_RESULT', 'reconciliation result');
210
+ if (!['reconciled', 'drift_detected', 'blocked'].includes(result.state)) fail('INVALID_RECONCILIATION_RESULT', 'reconciliation state is invalid');
211
+ projection.reconciliation_state = result.state;
212
+ if (result.evidence_ref !== undefined) { string(result.evidence_ref, 'INVALID_RECONCILIATION_RESULT', 'evidence_ref'); projection.reconciliation_evidence_ref = result.evidence_ref; }
213
+ this.#setEvolution(projection);
214
+ this.#persist();
215
+ return Object.freeze(canonicalize(projection));
216
+ }
217
+
218
+ resolveSentUnconfirmed({ projection_id } = {}) {
219
+ const projection = this.#find(projection_id);
220
+ if (projection.delivery_state !== 'sent_unconfirmed') fail('NOT_SENT_UNCONFIRMED', 'idempotency lookup only applies to sent_unconfirmed delivery');
221
+ if (!this.#transport || typeof this.#transport.lookup !== 'function') fail('IDEMPOTENCY_LOOKUP_NOT_CONFIGURED', 'sent_unconfirmed requires an idempotency lookup transport');
222
+ this.#preflightTransport(projection);
223
+ const result = this.#transport.lookup(Object.freeze(canonicalize(projection)));
224
+ object(result, 'INVALID_LOOKUP_RESULT', 'idempotency lookup result');
225
+ if (result.receipt_ref !== undefined) {
226
+ string(result.receipt_ref, 'INVALID_LOOKUP_RESULT', 'receipt_ref');
227
+ if (this.#verifyReference(result.receipt_ref, 'receipt') !== true) fail('UNVERIFIED_RECEIPT', 'lookup receipt must be an immutable verified reference');
228
+ projection.delivery_state = 'confirmed';
229
+ projection.receipt_ref = result.receipt_ref;
230
+ projection.reconciliation_state = 'pending';
231
+ } else if (result.state === 'not_found') projection.delivery_state = 'pending';
232
+ else if (result.state !== 'unknown') fail('INVALID_LOOKUP_RESULT', 'lookup requires receipt_ref, not_found, or unknown');
233
+ this.#setEvolution(projection);
234
+ this.#persist();
235
+ return Object.freeze(canonicalize(projection));
236
+ }
237
+
238
+ watchOnce() {
239
+ // Intentionally one-shot: a host scheduler may invoke this hourly. No timer is created.
240
+ const replayed = [];
241
+ for (const projection of this.#state.projections) {
242
+ if (projection.delivery_state === 'sent_unconfirmed') {
243
+ const resolved = this.resolveSentUnconfirmed({ projection_id: projection.projection_id });
244
+ if (resolved.delivery_state === 'pending') replayed.push(this.deliver({ projection_id: projection.projection_id }));
245
+ } else if (['pending', 'retryable_failure'].includes(projection.delivery_state)) replayed.push(this.deliver({ projection_id: projection.projection_id }));
246
+ }
247
+ return Object.freeze(replayed);
248
+ }
249
+
250
+ rederiveProjectEvolution({ project_id } = {}) {
251
+ string(project_id, 'INVALID_PROJECT_ID', 'project_id');
252
+ if (!this.#state.projections.some((item) => item.project_id === project_id)) fail('PROJECT_NOT_FOUND', 'project has no durable projections', { project_id });
253
+ return this.#evolve(project_id);
254
+ }
255
+
256
+ recordMetrics({ projection_id, actual_human_effort, actual_ai_processing, rework_cycles, rework_review_refs, completion } = {}) {
257
+ const projection = this.#find(projection_id);
258
+ object(actual_human_effort, 'INVALID_HUMAN_EFFORT', 'actual_human_effort');
259
+ object(actual_ai_processing, 'INVALID_AI_PROCESSING', 'actual_ai_processing');
260
+ if (!Number.isInteger(rework_cycles) || rework_cycles < 0) fail('INVALID_REWORK_CYCLES', 'rework_cycles must be a non-negative integer');
261
+ object(completion, 'MISSING_COMPLETION_EVIDENCE', 'completion');
262
+ if (this.#verifyCompletion(completion) !== true) fail('INVALID_COMPLETION_EVIDENCE', 'metrics require a valid C-06 completion');
263
+ if (!Array.isArray(rework_review_refs) || rework_review_refs.length !== rework_cycles || !rework_review_refs.every((ref) => typeof ref === 'string' && ref && this.#verifyReference(ref, 'review') === true)) {
264
+ fail('INVALID_REWORK_PROVENANCE', 'each rework cycle requires one verified C-05 review reference');
265
+ }
266
+ string(actual_human_effort.evidence_ref, 'INVALID_HUMAN_EFFORT', 'actual_human_effort.evidence_ref');
267
+ string(actual_ai_processing.evidence_ref, 'INVALID_AI_PROCESSING', 'actual_ai_processing.evidence_ref');
268
+ if (this.#verifyReference(actual_human_effort.evidence_ref, 'human-effort') !== true || this.#verifyReference(actual_ai_processing.evidence_ref, 'attempt-instrumentation') !== true) {
269
+ fail('UNVERIFIED_METRIC_PROVENANCE', 'metrics require verified explicit human and attempt instrumentation evidence');
270
+ }
271
+ // No wall-clock value exists here. Provenance is durable with the metric.
272
+ const metricMaterial = canonicalize({ actual_human_effort, actual_ai_processing, rework_cycles, rework_review_refs, completion_id: completion.completion_id });
273
+ if (projection.metrics) {
274
+ const { recorded_at: _recordedAt, ...existingMaterial } = projection.metrics;
275
+ if (sha256(existingMaterial) !== sha256(metricMaterial)) fail('METRICS_ALREADY_RECORDED', 'projection metrics are immutable');
276
+ return Object.freeze(canonicalize(projection));
277
+ }
278
+ projection.metrics = canonicalize({ ...metricMaterial, recorded_at: timestamp(this.#clock) });
279
+ this.#setEvolution(projection);
280
+ this.#persist();
281
+ return Object.freeze(canonicalize(projection));
282
+ }
283
+ }
284
+
285
+ export function validateProjection(projection) {
286
+ object(projection, 'INVALID_PROJECTION', 'projection');
287
+ for (const field of ['projection_id', 'idempotency_key', 'canonical_event_id', 'task_id', 'clickup_ref', 'project_id', 'payload_hash']) string(projection[field], 'INVALID_PROJECTION', field);
288
+ if (!OPERATIONS.has(projection.operation)) fail('INVALID_PROJECTION_OPERATION', 'operation is invalid');
289
+ if (!DELIVERY_STATES.has(projection.delivery_state)) fail('INVALID_DELIVERY_STATE', 'delivery_state is invalid');
290
+ if (!RECONCILIATION_STATES.has(projection.reconciliation_state)) fail('INVALID_RECONCILIATION_STATE', 'reconciliation_state is invalid');
291
+ if (projection.delivery_state === 'confirmed' && typeof projection.receipt_ref !== 'string') fail('MISSING_RECEIPT', 'confirmed projection requires receipt_ref');
292
+ return Object.freeze(canonicalize(projection));
293
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chati-dev",
3
- "version": "4.4.0",
3
+ "version": "4.5.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,9 @@
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-model-catalog.js && node scripts/sign-manifest.js && REQUIRE_SIGNED_MANIFEST=1 node scripts/validate-package.js",
32
+ "prepack": "node scripts/prepare-bundled-dependencies.js",
33
+ "postpack": "node scripts/prepare-bundled-dependencies.js --clean",
32
34
  "test": "node --test test/**/*.test.js",
33
35
  "lint": "eslint src/ bin/",
34
36
  "lint:fix": "eslint src/ bin/ --fix",
@@ -62,12 +64,32 @@
62
64
  "url": "https://github.com/Chati-dev/Chati.dev/issues"
63
65
  },
64
66
  "dependencies": {
67
+ "@chati/browser-capability": "0.1.0",
68
+ "@chati/core": "0.1.0",
69
+ "@chati/knowledge-context": "0.1.0",
70
+ "@chati/planning": "0.1.0",
71
+ "@chati/provider-registry": "0.1.0",
72
+ "@chati/rail": "0.1.0",
73
+ "@chati/release-lane": "0.1.0",
74
+ "@chati/review-council": "0.1.0",
75
+ "@chati/tracking-clickup": "0.1.0",
65
76
  "@clack/prompts": "^0.7.0",
66
77
  "chalk": "^5.3.0",
67
- "js-yaml": "^4.1.0",
78
+ "js-yaml": "^5.4.0",
68
79
  "ora": "^8.0.1",
69
80
  "semver": "^7.6.0"
70
81
  },
82
+ "bundleDependencies": [
83
+ "@chati/browser-capability",
84
+ "@chati/core",
85
+ "@chati/knowledge-context",
86
+ "@chati/planning",
87
+ "@chati/provider-registry",
88
+ "@chati/rail",
89
+ "@chati/release-lane",
90
+ "@chati/review-council",
91
+ "@chati/tracking-clickup"
92
+ ],
71
93
  "engines": {
72
94
  "node": ">=20.0.0"
73
95
  },
@@ -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
  /**
@@ -37,6 +37,16 @@ export const IDE_CONFIGS = {
37
37
  rulesPath: '.codex/rules/',
38
38
  overrideFile: 'AGENTS.override.md',
39
39
  },
40
+ 'grok-cli': {
41
+ name: 'Grok CLI',
42
+ description: 'xAI terminal agent',
43
+ group: 'cli',
44
+ recommended: false,
45
+ configPath: '.grok/commands/',
46
+ rulesFile: 'AGENTS.md',
47
+ mcpConfigFile: '.grok/mcp.json',
48
+ formatNotes: 'Native markdown command format',
49
+ },
40
50
  'vscode': {
41
51
  name: 'VS Code',
42
52
  description: 'Extensions: Continue, AI Chat, etc',
@@ -87,6 +97,7 @@ export const IDE_TO_PROVIDER = {
87
97
  'claude-code': 'claude',
88
98
  'gemini-cli': 'gemini',
89
99
  'codex-cli': 'codex',
100
+ 'grok-cli': 'grok',
90
101
  'vscode': null,
91
102
  'cursor': null,
92
103
  'windsurf': null,
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { existsSync, readFileSync, readdirSync } from 'fs';
9
9
  import { join, basename } from 'path';
10
- import yaml from 'js-yaml';
10
+ import * as yaml from 'js-yaml';
11
11
 
12
12
  /**
13
13
  * Load a single domain YAML file.
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, existsSync, readdirSync } from 'fs';
2
2
  import { join } from 'path';
3
- import yaml from 'js-yaml';
3
+ import * as yaml from 'js-yaml';
4
4
  import { resolveFrameworkDir } from '../utils/framework-dir.js';
5
5
 
6
6
  /**
@@ -17,7 +17,7 @@
17
17
  import { spawn } from 'child_process';
18
18
  import { existsSync, readFileSync } from 'fs';
19
19
  import { join, resolve, sep } from 'path';
20
- import yaml from 'js-yaml';
20
+ import * as yaml from 'js-yaml';
21
21
  import { resolveFrameworkDir } from '../utils/framework-dir.js';
22
22
 
23
23
  /**
@@ -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
+ }
@@ -24,7 +24,7 @@ import {
24
24
  } from 'fs';
25
25
  import { join, dirname } from 'path';
26
26
  import { fileURLToPath } from 'url';
27
- import yaml from 'js-yaml';
27
+ import * as yaml from 'js-yaml';
28
28
  import { hashContent } from './file-hasher.js';
29
29
 
30
30
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -1,3 +1,3 @@
1
1
  -----BEGIN PUBLIC KEY-----
2
- MCowBQYDK2VwAyEA8thWGs1GHKpeJyUTejpBwp8TZ7iZiN3vDxGDoQf8dMs=
2
+ MCowBQYDK2VwAyEAgo4u1bUZttpuxRluFnZc+3AMQH+U5xWEURN41kv/s0A=
3
3
  -----END PUBLIC KEY-----
@@ -1,4 +1,4 @@
1
- import yaml from 'js-yaml';
1
+ import * as yaml from 'js-yaml';
2
2
 
3
3
  /**
4
4
  * Generate session.yaml content
@@ -60,7 +60,7 @@ export function generateSessionYaml(config) {
60
60
  };
61
61
  }
62
62
 
63
- return yaml.dump(session, { lineWidth: -1, quotingType: '"', forceQuotes: false });
63
+ return yaml.dump(session, { lineWidth: -1, quoteStyle: 'double', forceQuotes: false });
64
64
  }
65
65
 
66
66
  /**
@@ -176,7 +176,7 @@ export function generateConfigYaml(config) {
176
176
  }
177
177
  }
178
178
 
179
- return yaml.dump(configData, { lineWidth: -1, quotingType: '"', forceQuotes: false });
179
+ return yaml.dump(configData, { lineWidth: -1, quoteStyle: 'double', forceQuotes: false });
180
180
  }
181
181
 
182
182
  /**
@@ -1,10 +1,11 @@
1
1
  import { existsSync, readFileSync } from 'fs';
2
2
  import { join } from 'path';
3
- import yaml from 'js-yaml';
3
+ import * as yaml from 'js-yaml';
4
4
  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;