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,202 @@
1
+ import { closeSync, existsSync, mkdirSync, openSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, resolve, relative } from 'node:path';
3
+ import { ContractError, canonicalize, sha256 } from '@chati/core';
4
+
5
+ const RETRIEVAL_SCOPES = new Set(['framework', 'organization', 'client', 'project', 'stack', 'task-category']);
6
+ const RETRIEVAL_PURPOSES = new Set(['discovery', 'planning', 'routing', 'execution', 'review', 'release', 'incident']);
7
+ const CANDIDATE_SCOPES = new Set([...RETRIEVAL_SCOPES, 'individual']);
8
+ const PRIVACY_CLASSIFICATIONS = new Set(['internal', 'confidential', 'client-restricted', 'personal-restricted']);
9
+ const SANITIZATION_STATUSES = new Set(['not-required', 'pending', 'passed', 'rejected']);
10
+ const PROPOSED_ACTIONS = new Set(['context', 'skill', 'template', 'quality-gate', 'regression-test', 'framework-change']);
11
+ const CANDIDATE_STATUSES = new Set(['observed', 'candidate']);
12
+ const CAPABILITY_STATES = new Set(['available', 'not-configured', 'unavailable', 'index-error']);
13
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/;
14
+
15
+ function fail(code, message, details = {}) {
16
+ throw new ContractError(code, message, details);
17
+ }
18
+
19
+ function assertObject(value, code, label) {
20
+ if (!value || typeof value !== 'object' || Array.isArray(value)) fail(code, `${label} must be an object`);
21
+ }
22
+
23
+ function assertString(value, code, label) {
24
+ if (typeof value !== 'string' || value.trim() === '') fail(code, `${label} must be a non-empty string`);
25
+ }
26
+
27
+ function assertIdentifier(value, code, label) {
28
+ assertString(value, code, label);
29
+ if (!IDENTIFIER.test(value)) fail(code, `${label} must be an opaque identifier`);
30
+ }
31
+
32
+ function assertStringArray(value, code, label, { allowEmpty = true } = {}) {
33
+ if (!Array.isArray(value) || (!allowEmpty && value.length === 0) || !value.every((entry) => typeof entry === 'string' && entry.trim() !== '')) {
34
+ fail(code, `${label} must be an array of non-empty strings`);
35
+ }
36
+ }
37
+
38
+ function timestamp(clock) {
39
+ if (typeof clock !== 'function') fail('MISSING_CLOCK', 'a deterministic clock function is required');
40
+ const value = clock();
41
+ const millis = value instanceof Date ? value.getTime() : Date.parse(value);
42
+ if (Number.isNaN(millis)) fail('INVALID_CLOCK', 'clock must return a valid Date or RFC3339 timestamp');
43
+ return new Date(millis).toISOString();
44
+ }
45
+
46
+ function assertTenant({ scope, classification, tenant_ref }, code) {
47
+ if (scope === 'client' || classification === 'client-restricted') assertIdentifier(tenant_ref, code, 'tenant_ref');
48
+ }
49
+
50
+ /** Validates C-09 without selecting a source or performing any I/O. */
51
+ export function validateKnowledgeRetrievalRequest(request) {
52
+ assertObject(request, 'INVALID_KNOWLEDGE_RETRIEVAL', 'request');
53
+ for (const field of ['retrieval_id', 'purpose', 'query_ref', 'policy_ref']) assertIdentifier(request[field], 'INVALID_KNOWLEDGE_RETRIEVAL', field);
54
+ if (!RETRIEVAL_SCOPES.has(request.request_scope)) fail('INVALID_KNOWLEDGE_RETRIEVAL', 'request_scope is invalid');
55
+ if (!RETRIEVAL_PURPOSES.has(request.purpose)) fail('INVALID_KNOWLEDGE_RETRIEVAL', 'purpose is invalid');
56
+ assertTenant({ scope: request.request_scope, tenant_ref: request.tenant_ref }, 'MISSING_TENANT_REF');
57
+ return Object.freeze(canonicalize(request));
58
+ }
59
+
60
+ /** Validates the CHATI-owned subset of C-08. It intentionally has no lifecycle beyond candidate. */
61
+ export function validateLearningCandidate(candidate) {
62
+ assertObject(candidate, 'INVALID_LEARNING_CANDIDATE', 'candidate');
63
+ for (const field of ['candidate_id', 'origin', 'taxonomy', 'proposed_action', 'status']) assertIdentifier(candidate[field], 'INVALID_LEARNING_CANDIDATE', field);
64
+ if (!['rail-evidence', 'explicit-personal-contribution'].includes(candidate.origin)) fail('INVALID_LEARNING_CANDIDATE', 'origin is invalid');
65
+ if (!CANDIDATE_SCOPES.has(candidate.scope)) fail('INVALID_LEARNING_CANDIDATE', 'scope is invalid');
66
+ if (!PRIVACY_CLASSIFICATIONS.has(candidate.privacy_classification)) fail('INVALID_LEARNING_CANDIDATE', 'privacy_classification is invalid');
67
+ if (!SANITIZATION_STATUSES.has(candidate.sanitization_status)) fail('INVALID_LEARNING_CANDIDATE', 'sanitization_status is invalid');
68
+ if (!PROPOSED_ACTIONS.has(candidate.proposed_action)) fail('INVALID_LEARNING_CANDIDATE', 'proposed_action is invalid');
69
+ if (!CANDIDATE_STATUSES.has(candidate.status)) fail('UNAUTHORIZED_KNOWLEDGE_LIFECYCLE', 'CHATI may only emit observed or candidate artifacts');
70
+ assertStringArray(candidate.origin_event_refs, 'INVALID_LEARNING_CANDIDATE', 'origin_event_refs', { allowEmpty: false });
71
+ assertStringArray(candidate.evidence_refs, 'INVALID_LEARNING_CANDIDATE', 'evidence_refs', { allowEmpty: false });
72
+ if (candidate.origin === 'rail-evidence') assertStringArray(candidate.lineage_refs, 'INVALID_LEARNING_CANDIDATE', 'lineage_refs', { allowEmpty: false });
73
+ else if (candidate.lineage_refs !== undefined) assertStringArray(candidate.lineage_refs, 'INVALID_LEARNING_CANDIDATE', 'lineage_refs', { allowEmpty: false });
74
+ assertTenant({ scope: candidate.scope, classification: candidate.privacy_classification, tenant_ref: candidate.tenant_ref }, 'MISSING_TENANT_REF');
75
+ if (candidate.privacy_classification === 'personal-restricted') fail('PERSONAL_RESTRICTED_CANDIDATE_FORBIDDEN', 'personal-restricted material cannot be emitted as a Company Brain candidate');
76
+ if (candidate.origin === 'explicit-personal-contribution') {
77
+ assertIdentifier(candidate.consent_ref, 'PERSONAL_CONSENT_REQUIRED', 'consent_ref');
78
+ if (candidate.sanitization_status !== 'passed') fail('PERSONAL_SANITIZATION_REQUIRED', 'explicit personal contribution requires sanitization_status passed');
79
+ }
80
+ return Object.freeze(canonicalize(candidate));
81
+ }
82
+
83
+ function receiptFor(request, state, returned_learning_refs, clock) {
84
+ return Object.freeze(canonicalize({
85
+ schema_version: 1,
86
+ retrieval_id: request.retrieval_id,
87
+ request_scope: request.request_scope,
88
+ ...(request.tenant_ref ? { tenant_ref: request.tenant_ref } : {}),
89
+ purpose: request.purpose,
90
+ query_ref: request.query_ref,
91
+ policy_ref: request.policy_ref,
92
+ state,
93
+ returned_learning_refs,
94
+ occurred_at: timestamp(clock),
95
+ source: 'company-brain-capability',
96
+ }));
97
+ }
98
+
99
+ function validateFakeRecord(record) {
100
+ assertObject(record, 'INVALID_KNOWLEDGE_RECORD', 'record');
101
+ assertIdentifier(record.learning_ref, 'INVALID_KNOWLEDGE_RECORD', 'learning_ref');
102
+ if (!RETRIEVAL_SCOPES.has(record.scope)) fail('INVALID_KNOWLEDGE_RECORD', 'record.scope is invalid');
103
+ if (record.source !== 'company-brain') fail('PERSONAL_BRAIN_SOURCE_FORBIDDEN', 'fake adapter only accepts company-brain records');
104
+ if (record.scope === 'client') assertIdentifier(record.tenant_ref, 'INVALID_KNOWLEDGE_RECORD', 'record.tenant_ref');
105
+ if (record.approved !== true) fail('UNAPPROVED_KNOWLEDGE_FORBIDDEN', 'fake adapter only accepts approved records');
106
+ return canonicalize(record);
107
+ }
108
+
109
+ /**
110
+ * Hermetic C-09 adapter. Its records are synthetic approved Company Brain
111
+ * references, never vault paths or content. It has no write, promotion,
112
+ * validation or deduplication capability by design.
113
+ */
114
+ export function createFakeKnowledgeReadAdapter({ capability_state = 'not-configured', records = [], authorized_tenant_refs, clock } = {}) {
115
+ if (!CAPABILITY_STATES.has(capability_state)) fail('INVALID_KNOWLEDGE_CAPABILITY_STATE', 'capability_state is invalid');
116
+ if (typeof clock !== 'function') fail('MISSING_CLOCK', 'a deterministic clock function is required');
117
+ if (!Array.isArray(records)) fail('INVALID_KNOWLEDGE_RECORD', 'records must be an array');
118
+ const approvedRecords = records.map(validateFakeRecord);
119
+ if (authorized_tenant_refs !== undefined) assertStringArray(authorized_tenant_refs, 'INVALID_TENANT_AUTHORIZATION', 'authorized_tenant_refs');
120
+ const authorizedTenants = authorized_tenant_refs === undefined ? undefined : new Set(authorized_tenant_refs);
121
+ const reads = [];
122
+
123
+ function retrieve(input) {
124
+ const request = validateKnowledgeRetrievalRequest(input);
125
+ if (capability_state !== 'available') {
126
+ const receipt = receiptFor(request, capability_state, [], clock);
127
+ reads.push(Object.freeze({ request, receipt }));
128
+ return receipt;
129
+ }
130
+ if (request.request_scope === 'client') {
131
+ if (authorizedTenants === undefined) fail('TENANT_AUTHORIZATION_UNAVAILABLE', 'client retrieval requires an explicit tenant authorization context');
132
+ if (!authorizedTenants.has(request.tenant_ref)) fail('TENANT_SCOPE_MISMATCH', 'client tenant is not authorized for this read adapter', { tenant_ref: request.tenant_ref });
133
+ }
134
+ const matches = approvedRecords.filter((record) => {
135
+ if (record.scope !== request.request_scope) return false;
136
+ if (request.request_scope === 'client') return record.tenant_ref === request.tenant_ref;
137
+ return true;
138
+ }).map((record) => record.learning_ref);
139
+ const receipt = receiptFor(request, matches.length === 0 ? 'empty' : 'available', matches, clock);
140
+ reads.push(Object.freeze({ request, receipt }));
141
+ return receipt;
142
+ }
143
+
144
+ return Object.freeze({
145
+ mode: 'read-only',
146
+ capability_state,
147
+ retrieve,
148
+ get reads() { return Object.freeze([...reads]); },
149
+ });
150
+ }
151
+
152
+ function assertProjectLocalPath(project_dir, candidate_id) {
153
+ assertString(project_dir, 'INVALID_PROJECT_DIRECTORY', 'project_dir');
154
+ assertIdentifier(candidate_id, 'INVALID_LEARNING_CANDIDATE', 'candidate_id');
155
+ const base = resolve(project_dir, '.chati', 'knowledge-candidates');
156
+ const target = resolve(base, `${candidate_id}.json`);
157
+ if (relative(base, target).startsWith('..')) fail('CANDIDATE_PATH_ESCAPE', 'candidate artifact must remain in the project-local store');
158
+ return { base, target };
159
+ }
160
+
161
+ /**
162
+ * Writes one immutable C-08 artifact below <project>/.chati/knowledge-candidates.
163
+ * This is intentionally the only side effect in this package.
164
+ */
165
+ export function emitLocalLearningCandidate({ project_dir, candidate } = {}) {
166
+ const validated = validateLearningCandidate(candidate);
167
+ const { base, target } = assertProjectLocalPath(project_dir, validated.candidate_id);
168
+ mkdirSync(base, { recursive: true, mode: 0o700 });
169
+ // POSIX rename replaces an existing destination. A durable exclusive lock
170
+ // serializes every emitter before that operation and preserves immutability.
171
+ const lock = `${target}.lock`;
172
+ let lockDescriptor;
173
+ try {
174
+ lockDescriptor = openSync(lock, 'wx', 0o600);
175
+ } catch (error) {
176
+ if (error?.code === 'EEXIST') fail('CANDIDATE_PUBLICATION_BUSY', 'another local emitter is publishing this candidate', { candidate_id: validated.candidate_id });
177
+ throw error;
178
+ }
179
+ const artifact = Object.freeze(canonicalize({
180
+ schema_version: 1,
181
+ artifact_type: 'learning-candidate',
182
+ artifact_id: validated.candidate_id,
183
+ artifact_digest: sha256(validated),
184
+ candidate: validated,
185
+ }));
186
+ const temporary = join(dirname(target), `.${validated.candidate_id}.${process.pid}.tmp`);
187
+ try {
188
+ if (existsSync(target)) fail('CANDIDATE_ALREADY_EXISTS', 'candidate artifact already exists', { candidate_id: validated.candidate_id });
189
+ writeFileSync(temporary, `${JSON.stringify(artifact)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
190
+ renameSync(temporary, target);
191
+ } catch (error) {
192
+ // The lock makes a competing emitter wait/fail before POSIX rename, which
193
+ // otherwise has replacement semantics.
194
+ if (error?.code === 'EEXIST') fail('CANDIDATE_PUBLICATION_CONFLICT', 'candidate publication encountered a conflicting local artifact', { candidate_id: validated.candidate_id });
195
+ throw error;
196
+ } finally {
197
+ if (existsSync(temporary)) unlinkSync(temporary);
198
+ closeSync(lockDescriptor);
199
+ unlinkSync(lock);
200
+ }
201
+ return Object.freeze(canonicalize({ candidate_id: validated.candidate_id, artifact_ref: `project-local://.chati/knowledge-candidates/${validated.candidate_id}.json`, artifact_digest: artifact.artifact_digest }));
202
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@chati/planning",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": "./src/index.js",
7
+ "dependencies": {
8
+ "@chati/browser-capability": "0.1.0",
9
+ "@chati/core": "0.1.0"
10
+ },
11
+ "scripts": {
12
+ "test": "node --test test/**/*.test.js"
13
+ },
14
+ "engines": {
15
+ "node": ">=20.0.0"
16
+ }
17
+ }
@@ -0,0 +1,367 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { ContractError, canonicalize, sha256 } from '@chati/core';
3
+ import { assertBrowserCapabilityForTask } from '@chati/browser-capability';
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 GIT_OBJECT_ID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i;
7
+
8
+ function fail(code, message, details = {}) {
9
+ throw new ContractError(code, message, details);
10
+ }
11
+
12
+ function assertObject(value, code, label) {
13
+ if (!value || typeof value !== 'object' || Array.isArray(value)) fail(code, `${label} must be an object`);
14
+ }
15
+
16
+ function assertString(value, code, label) {
17
+ if (typeof value !== 'string' || value.trim() === '') fail(code, `${label} must be a non-empty string`);
18
+ }
19
+
20
+ function assertTimestamp(value, code, label) {
21
+ assertString(value, code, label);
22
+ if (!RFC3339.test(value)) fail(code, `${label} must be RFC3339`);
23
+ }
24
+
25
+ function deepFreeze(value) {
26
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
27
+ Object.freeze(value);
28
+ for (const nested of Object.values(value)) deepFreeze(nested);
29
+ }
30
+ return value;
31
+ }
32
+
33
+ function assertStringArray(value, code, label, { allowEmpty = true } = {}) {
34
+ if (!Array.isArray(value) || (!allowEmpty && value.length === 0) || !value.every((item) => typeof item === 'string' && item.trim() !== '')) {
35
+ fail(code, `${label} must be an array of non-empty strings`);
36
+ }
37
+ }
38
+
39
+ function assertImmutableRef(value, code, label) {
40
+ assertString(value, code, label);
41
+ }
42
+
43
+ function validateTask(task, knownTaskIds) {
44
+ assertObject(task, 'INVALID_HANDOFF_TASK', 'task');
45
+ assertString(task.task_id, 'INVALID_HANDOFF_TASK', 'task.task_id');
46
+ assertStringArray(task.dependency_ids, 'INVALID_HANDOFF_TASK', 'task.dependency_ids');
47
+ assertStringArray(task.acceptance_criteria_refs, 'MISSING_ACCEPTANCE_CRITERIA', 'task.acceptance_criteria_refs', { allowEmpty: false });
48
+ assertImmutableRef(task.execution_scope_ref, 'INVALID_HANDOFF_TASK', 'task.execution_scope_ref');
49
+ assertImmutableRef(task.routing_constraints_ref, 'INVALID_HANDOFF_TASK', 'task.routing_constraints_ref');
50
+ assertObject(task.execution_binding, 'TASK_EXECUTION_BINDING_REQUIRED', 'task.execution_binding');
51
+ for (const field of ['provider_id', 'harness_id', 'model_id', 'reasoning_configuration']) {
52
+ assertString(task.execution_binding[field], 'TASK_EXECUTION_BINDING_REQUIRED', `task.execution_binding.${field}`);
53
+ }
54
+ if (task.clickup_ref !== undefined) assertImmutableRef(task.clickup_ref, 'INVALID_HANDOFF_TASK', 'task.clickup_ref');
55
+ if (task.requires_browser !== undefined && typeof task.requires_browser !== 'boolean') fail('INVALID_HANDOFF_TASK', 'task.requires_browser must be boolean');
56
+ if (task.requires_browser === true && (!task.browser_policy || typeof task.browser_policy !== 'object' || Array.isArray(task.browser_policy))) {
57
+ fail('BROWSER_POLICY_REQUIRED', 'browser tasks require a browser_policy in the sealed handoff');
58
+ }
59
+ if (task.requires_browser === true) assertBrowserCapabilityForTask(task);
60
+ assertObject(task.estimate, 'INVALID_HANDOFF_TASK', 'task.estimate');
61
+ for (const field of ['ai_processing', 'human_effort', 'uncertainty']) {
62
+ if (task.estimate[field] === undefined || task.estimate[field] === null) {
63
+ fail('INVALID_HANDOFF_TASK', `task.estimate.${field} is required`);
64
+ }
65
+ }
66
+ for (const dependencyId of task.dependency_ids) {
67
+ if (dependencyId === task.task_id || !knownTaskIds.has(dependencyId)) {
68
+ fail('UNRESOLVED_DEPENDENCY', `task ${task.task_id} has unresolved dependency ${dependencyId}`, { task_id: task.task_id, dependency_id: dependencyId });
69
+ }
70
+ }
71
+ }
72
+
73
+ function validateLineage(lineage, handoff) {
74
+ if (lineage === undefined) return;
75
+ assertObject(lineage, 'INVALID_HANDOFF_LINEAGE', 'lineage');
76
+ if (!['rebase', 'revision'].includes(lineage.kind)) fail('INVALID_HANDOFF_LINEAGE', 'lineage.kind must be rebase or revision');
77
+ for (const field of ['supersedes_handoff_id', 'supersedes_version', 'supersedes_manifest_digest']) {
78
+ assertString(lineage[field], 'INVALID_HANDOFF_LINEAGE', `lineage.${field}`);
79
+ }
80
+ if (lineage.supersedes_handoff_id === handoff.handoff_id) fail('INVALID_HANDOFF_LINEAGE', 'handoff cannot supersede itself');
81
+ if (!/^[a-f0-9]{64}$/i.test(lineage.supersedes_manifest_digest)) {
82
+ fail('INVALID_HANDOFF_LINEAGE', 'lineage.supersedes_manifest_digest must be SHA-256');
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Returns the exact material used to derive a handoff digest. The digest
88
+ * itself is intentionally excluded to make verification possible.
89
+ */
90
+ export function buildCanonicalManifest(handoff) {
91
+ assertObject(handoff, 'INVALID_HANDOFF', 'handoff');
92
+ const { seal, ...rest } = handoff;
93
+ assertObject(seal, 'INVALID_HANDOFF', 'seal');
94
+ const { manifest_digest, ...sealWithoutDigest } = seal;
95
+ return canonicalize({ ...rest, seal: sealWithoutDigest });
96
+ }
97
+
98
+ export function computeManifestDigest(handoff) {
99
+ return sha256(buildCanonicalManifest(handoff));
100
+ }
101
+
102
+ /** Validates a complete C-03 artifact and fails before any dispatch can occur. */
103
+ export function validateSealedHandoff(handoff) {
104
+ assertObject(handoff, 'INVALID_HANDOFF', 'handoff');
105
+ for (const field of ['handoff_id', 'version', 'project_id']) assertString(handoff[field], 'INVALID_HANDOFF', field);
106
+ assertObject(handoff.source_artifacts, 'INVALID_HANDOFF', 'source_artifacts');
107
+ for (const field of ['requirements_ref', 'architecture_ref', 'qa_plan_ref']) {
108
+ assertImmutableRef(handoff.source_artifacts[field], 'INVALID_HANDOFF', `source_artifacts.${field}`);
109
+ }
110
+ if (handoff.source_artifacts.ux_ref !== undefined) assertImmutableRef(handoff.source_artifacts.ux_ref, 'INVALID_HANDOFF', 'source_artifacts.ux_ref');
111
+ assertImmutableRef(handoff.task_graph_ref, 'INVALID_HANDOFF', 'task_graph_ref');
112
+ if (!Array.isArray(handoff.tasks) || handoff.tasks.length === 0) fail('INVALID_HANDOFF', 'tasks must be a non-empty array');
113
+ const taskIds = new Set();
114
+ for (const task of handoff.tasks) {
115
+ assertObject(task, 'INVALID_HANDOFF_TASK', 'task');
116
+ assertString(task.task_id, 'INVALID_HANDOFF_TASK', 'task.task_id');
117
+ if (taskIds.has(task.task_id)) fail('DUPLICATE_TASK_ID', `task_id ${task.task_id} is duplicated`);
118
+ taskIds.add(task.task_id);
119
+ }
120
+ for (const task of handoff.tasks) validateTask(task, taskIds);
121
+ validateLineage(handoff.lineage, handoff);
122
+ assertObject(handoff.seal, 'INVALID_HANDOFF_SEAL', 'seal');
123
+ assertString(handoff.seal.sealed_by, 'INVALID_HANDOFF_SEAL', 'seal.sealed_by');
124
+ assertTimestamp(handoff.seal.sealed_at, 'INVALID_HANDOFF_SEAL', 'seal.sealed_at');
125
+ if (handoff.seal.digest_algorithm !== 'sha256') fail('UNSUPPORTED_DIGEST_ALGORITHM', 'seal.digest_algorithm must be sha256');
126
+ assertString(handoff.seal.manifest_digest, 'INVALID_HANDOFF_SEAL', 'seal.manifest_digest');
127
+ if (!/^[a-f0-9]{64}$/i.test(handoff.seal.manifest_digest)) fail('INVALID_HANDOFF_SEAL', 'seal.manifest_digest must be SHA-256');
128
+ assertString(handoff.seal.git_commit_ref, 'INVALID_GIT_COMMIT_REF', 'seal.git_commit_ref');
129
+ if (!GIT_OBJECT_ID.test(handoff.seal.git_commit_ref)) fail('INVALID_GIT_COMMIT_REF', 'seal.git_commit_ref must be an immutable Git object id');
130
+ const actualDigest = computeManifestDigest(handoff);
131
+ if (actualDigest !== handoff.seal.manifest_digest) {
132
+ fail('HANDOFF_DIGEST_MISMATCH', 'handoff manifest digest does not match its material', { expected: handoff.seal.manifest_digest, actual: actualDigest });
133
+ }
134
+ return deepFreeze(canonicalize(handoff));
135
+ }
136
+
137
+ /** Seals a validated planning artifact. It cannot mutate the supplied draft. */
138
+ export function sealHandoff(draft) {
139
+ assertObject(draft, 'INVALID_HANDOFF', 'draft');
140
+ const copied = canonicalize(draft);
141
+ assertObject(copied.seal, 'INVALID_HANDOFF_SEAL', 'seal');
142
+ const seal = { ...copied.seal, digest_algorithm: copied.seal.digest_algorithm ?? 'sha256' };
143
+ delete seal.manifest_digest;
144
+ const unsealed = { ...copied, seal };
145
+ const manifestDigest = computeManifestDigest(unsealed);
146
+ return validateSealedHandoff({ ...unsealed, seal: { ...seal, manifest_digest: manifestDigest } });
147
+ }
148
+
149
+ /** Checks that an object id exists as a local commit. No remote is consulted. */
150
+ export function verifyLocalGitCommit({ git_dir, git_commit_ref }) {
151
+ assertString(git_dir, 'INVALID_GIT_DIRECTORY', 'git_dir');
152
+ assertString(git_commit_ref, 'INVALID_GIT_COMMIT_REF', 'git_commit_ref');
153
+ if (!GIT_OBJECT_ID.test(git_commit_ref)) fail('INVALID_GIT_COMMIT_REF', 'git_commit_ref must be an immutable Git object id');
154
+ try {
155
+ execFileSync('git', ['-C', git_dir, 'cat-file', '-e', `${git_commit_ref}^{commit}`], { stdio: 'ignore' });
156
+ } catch {
157
+ fail('GIT_COMMIT_NOT_FOUND', 'sealed git_commit_ref is unavailable in the supplied local repository', { git_commit_ref });
158
+ }
159
+ return true;
160
+ }
161
+
162
+ export function assertDispatchable({ handoff, task_id, completed_task_ids = [], git_dir, status = 'active' }) {
163
+ const validated = validateSealedHandoff(handoff);
164
+ if (status === 'cancelled') fail('HANDOFF_CANCELLED', 'cancelled handoff cannot be dispatched', { handoff_id: validated.handoff_id });
165
+ if (status === 'superseded') fail('HANDOFF_SUPERSEDED', 'superseded handoff cannot be dispatched', { handoff_id: validated.handoff_id });
166
+ if (status !== 'active') fail('HANDOFF_NOT_ACTIVE', 'handoff is not active', { status });
167
+ // A sealed digest without a reachable local commit is insufficient evidence
168
+ // for a new dispatch. Recovery is separately bound to its original attempt.
169
+ assertString(git_dir, 'MISSING_GIT_VERIFICATION_CONTEXT', 'git_dir');
170
+ verifyLocalGitCommit({ git_dir, git_commit_ref: validated.seal.git_commit_ref });
171
+ assertString(task_id, 'INVALID_TASK_ID', 'task_id');
172
+ assertStringArray(completed_task_ids, 'INVALID_COMPLETED_TASK_IDS', 'completed_task_ids');
173
+ const task = validated.tasks.find((candidate) => candidate.task_id === task_id);
174
+ if (!task) fail('TASK_NOT_FOUND', 'task is not part of the sealed handoff', { task_id });
175
+ const completed = new Set(completed_task_ids);
176
+ const pending = task.dependency_ids.filter((dependencyId) => !completed.has(dependencyId));
177
+ if (pending.length > 0) fail('DEPENDENCY_PENDING', 'task has incomplete dependencies', { task_id, pending_dependency_ids: pending });
178
+ return deepFreeze(canonicalize({
179
+ handoff_id: validated.handoff_id,
180
+ handoff_version: validated.version,
181
+ manifest_digest: validated.seal.manifest_digest,
182
+ git_commit_ref: validated.seal.git_commit_ref,
183
+ task_id: task.task_id,
184
+ }));
185
+ }
186
+
187
+ export function createAttemptBinding({ handoff, task_id, attempt_id, completed_task_ids = [], git_dir, status = 'active' }) {
188
+ assertString(attempt_id, 'INVALID_ATTEMPT_ID', 'attempt_id');
189
+ const dispatch = assertDispatchable({ handoff, task_id, completed_task_ids, git_dir, status });
190
+ return deepFreeze(canonicalize({ attempt_id, ...dispatch }));
191
+ }
192
+
193
+ export function validateAttemptBinding({ attempt, handoff }) {
194
+ assertObject(attempt, 'INVALID_ATTEMPT_BINDING', 'attempt');
195
+ const validated = validateSealedHandoff(handoff);
196
+ for (const field of ['attempt_id', 'handoff_id', 'handoff_version', 'manifest_digest', 'git_commit_ref', 'task_id']) {
197
+ assertString(attempt[field], 'INVALID_ATTEMPT_BINDING', field);
198
+ }
199
+ if (attempt.handoff_id !== validated.handoff_id || attempt.handoff_version !== validated.version || attempt.manifest_digest !== validated.seal.manifest_digest || attempt.git_commit_ref !== validated.seal.git_commit_ref) {
200
+ fail('ATTEMPT_HANDOFF_DRIFT', 'attempt is not bound to this exact sealed handoff', { attempt_id: attempt.attempt_id });
201
+ }
202
+ if (!validated.tasks.some((task) => task.task_id === attempt.task_id)) fail('ATTEMPT_TASK_DRIFT', 'attempt task is not part of bound handoff');
203
+ return deepFreeze(canonicalize(attempt));
204
+ }
205
+
206
+ function legacyValue(value, label) {
207
+ if (typeof value === 'string' && value.trim() !== '') return value;
208
+ fail('INVALID_LEGACY_PLANNING_ARTIFACT', `${label} must provide an immutable reference`);
209
+ }
210
+
211
+ function legacyCriteriaRefs(task, taskId) {
212
+ if (Array.isArray(task.acceptance_criteria_refs)) return task.acceptance_criteria_refs;
213
+ if (!Array.isArray(task.criteria) || task.criteria.length === 0 || !task.criteria.every((criterion) => typeof criterion === 'string' && criterion.trim() !== '')) {
214
+ fail('MISSING_ACCEPTANCE_CRITERIA', `legacy task ${taskId} must include criteria or acceptance_criteria_refs`);
215
+ }
216
+ // Legacy criteria are inline strings rather than immutable artifacts. Their
217
+ // content-addressed refs preserve their exact material in the imported graph.
218
+ return task.criteria.map((criterion) => `legacy-inline://sha256/${sha256({ task_id: taskId, criterion })}`);
219
+ }
220
+
221
+ /**
222
+ * Maps the old CHATI task-plan shape into an unsealed C-03 draft. It is a
223
+ * pure importer: it reads no legacy files and never edits the legacy package.
224
+ */
225
+ export function importLegacyPlanningArtifact({ legacy, handoff_id, version, project_id, seal, lineage, default_execution_binding, clickup_required = true } = {}) {
226
+ assertObject(legacy, 'INVALID_LEGACY_PLANNING_ARTIFACT', 'legacy');
227
+ assertString(handoff_id, 'INVALID_LEGACY_PLANNING_ARTIFACT', 'handoff_id');
228
+ assertString(version, 'INVALID_LEGACY_PLANNING_ARTIFACT', 'version');
229
+ assertObject(seal, 'INVALID_LEGACY_PLANNING_ARTIFACT', 'seal');
230
+ const artifacts = legacy.artifacts ?? legacy.source_artifacts;
231
+ assertObject(artifacts, 'INVALID_LEGACY_PLANNING_ARTIFACT', 'legacy.artifacts');
232
+ const resolvedProjectId = project_id ?? legacy.project_id ?? legacy.project?.id;
233
+ assertString(resolvedProjectId, 'INVALID_LEGACY_PLANNING_ARTIFACT', 'project_id');
234
+ const legacyTasks = legacy.tasks ?? legacy.task_graph?.tasks;
235
+ if (!Array.isArray(legacyTasks) || legacyTasks.length === 0) fail('INVALID_LEGACY_PLANNING_ARTIFACT', 'legacy.tasks must be a non-empty array');
236
+ const sharedRoutingRef = legacy.routing_constraints_ref ?? legacy.routing_policy_ref;
237
+ const sharedClickUpRefs = legacy.clickup_refs ?? {};
238
+ const tasks = legacyTasks.map((legacyTask) => {
239
+ assertObject(legacyTask, 'INVALID_LEGACY_PLANNING_ARTIFACT', 'legacy task');
240
+ const taskId = legacyTask.task_id ?? legacyTask.id;
241
+ assertString(taskId, 'INVALID_LEGACY_PLANNING_ARTIFACT', 'legacy task id');
242
+ const scope = legacyTask.execution_scope_ref ?? legacyTask.scope_ref ?? legacyTask.scope;
243
+ const routing = legacyTask.routing_constraints_ref ?? legacyTask.routing_policy_ref ?? sharedRoutingRef;
244
+ const clickup = legacyTask.clickup_ref ?? legacyTask.clickup_task_ref ?? sharedClickUpRefs[taskId];
245
+ const estimate = legacyTask.estimate ?? legacyTask.estimates;
246
+ const executionBinding = legacyTask.execution_binding ?? default_execution_binding;
247
+ assertObject(executionBinding, 'TASK_EXECUTION_BINDING_REQUIRED', `legacy task ${taskId}.execution_binding`);
248
+ for (const field of ['provider_id', 'harness_id', 'model_id', 'reasoning_configuration']) {
249
+ assertString(executionBinding[field], 'TASK_EXECUTION_BINDING_REQUIRED', `legacy task ${taskId}.execution_binding.${field}`);
250
+ }
251
+ const task = {
252
+ task_id: taskId,
253
+ dependency_ids: legacyTask.dependency_ids ?? legacyTask.dependencies ?? [],
254
+ acceptance_criteria_refs: legacyCriteriaRefs(legacyTask, taskId),
255
+ execution_scope_ref: legacyValue(scope, `legacy task ${taskId}.scope`),
256
+ routing_constraints_ref: legacyValue(routing, `legacy task ${taskId}.routing_constraints`),
257
+ execution_binding: canonicalize(executionBinding),
258
+ estimate,
259
+ };
260
+ if (clickup_required || clickup !== undefined) task.clickup_ref = legacyValue(clickup, `legacy task ${taskId}.clickup_ref`);
261
+ return task;
262
+ });
263
+ const source_artifacts = {
264
+ requirements_ref: legacyValue(artifacts.requirements_ref ?? artifacts.requirements, 'legacy artifacts.requirements'),
265
+ architecture_ref: legacyValue(artifacts.architecture_ref ?? artifacts.architecture, 'legacy artifacts.architecture'),
266
+ qa_plan_ref: legacyValue(artifacts.qa_plan_ref ?? artifacts.qa_plan, 'legacy artifacts.qa_plan'),
267
+ };
268
+ const ux = artifacts.ux_ref ?? artifacts.ux;
269
+ if (ux !== undefined) source_artifacts.ux_ref = legacyValue(ux, 'legacy artifacts.ux');
270
+ return canonicalize({
271
+ handoff_id,
272
+ version,
273
+ project_id: resolvedProjectId,
274
+ source_artifacts,
275
+ task_graph_ref: legacyValue(legacy.task_graph_ref ?? legacy.task_graph?.ref ?? artifacts.task_graph_ref ?? artifacts.task_graph, 'legacy task graph'),
276
+ tasks,
277
+ ...(lineage === undefined ? {} : { lineage }),
278
+ seal,
279
+ });
280
+ }
281
+
282
+ /**
283
+ * In-memory lifecycle gate used by the future RAIL dispatcher. It deliberately
284
+ * has no side effects: callers must pass its dispatch proof before claiming work.
285
+ */
286
+ export class SealedHandoffRegistry {
287
+ #records = new Map();
288
+ #attempts = new Map();
289
+
290
+ register(handoff) {
291
+ const validated = validateSealedHandoff(handoff);
292
+ if (this.#records.has(validated.handoff_id)) fail('HANDOFF_ALREADY_REGISTERED', 'handoff_id is already registered');
293
+ this.#records.set(validated.handoff_id, { handoff: validated, status: 'active', cancellation: null });
294
+ return validated;
295
+ }
296
+
297
+ rebase({ predecessor_handoff_id, successor }) {
298
+ assertString(predecessor_handoff_id, 'INVALID_HANDOFF_ID', 'predecessor_handoff_id');
299
+ const predecessor = this.#records.get(predecessor_handoff_id);
300
+ if (!predecessor) fail('HANDOFF_NOT_FOUND', 'predecessor handoff is not registered');
301
+ if (predecessor.status !== 'active') fail('HANDOFF_NOT_ACTIVE', 'only an active handoff can be rebased');
302
+ const validated = validateSealedHandoff(successor);
303
+ const lineage = validated.lineage;
304
+ if (!lineage || lineage.kind !== 'rebase' || lineage.supersedes_handoff_id !== predecessor.handoff.handoff_id || lineage.supersedes_version !== predecessor.handoff.version || lineage.supersedes_manifest_digest !== predecessor.handoff.seal.manifest_digest) {
305
+ fail('INVALID_REBASE_LINEAGE', 'successor must explicitly reference the exact predecessor');
306
+ }
307
+ if (validated.project_id !== predecessor.handoff.project_id) fail('PROJECT_LINEAGE_MISMATCH', 'successor project_id must match predecessor');
308
+ this.register(validated);
309
+ predecessor.status = 'superseded';
310
+ predecessor.successor_handoff_id = validated.handoff_id;
311
+ return validated;
312
+ }
313
+
314
+ cancel({ handoff_id, cancellation_id, event_version = 1, cancelled_by, cancelled_at, reason_ref }) {
315
+ assertString(handoff_id, 'INVALID_HANDOFF_ID', 'handoff_id');
316
+ const record = this.#records.get(handoff_id);
317
+ if (!record) fail('HANDOFF_NOT_FOUND', 'handoff is not registered');
318
+ if (record.status === 'superseded') fail('HANDOFF_SUPERSEDED', 'superseded handoff cannot be cancelled');
319
+ if (record.status === 'cancelled') return record.cancellation;
320
+ assertString(cancellation_id, 'INVALID_CANCELLATION', 'cancellation_id');
321
+ if (event_version !== 1) fail('UNSUPPORTED_CANCELLATION_EVENT_VERSION', 'event_version must be 1');
322
+ assertString(cancelled_by, 'INVALID_CANCELLATION', 'cancelled_by');
323
+ assertTimestamp(cancelled_at, 'INVALID_CANCELLATION', 'cancelled_at');
324
+ assertImmutableRef(reason_ref, 'INVALID_CANCELLATION', 'reason_ref');
325
+ record.status = 'cancelled';
326
+ record.cancellation = deepFreeze(canonicalize({
327
+ event_id: cancellation_id,
328
+ event_type: 'handoff_cancelled',
329
+ event_version,
330
+ handoff_id,
331
+ manifest_digest: record.handoff.seal.manifest_digest,
332
+ lineage: {
333
+ kind: 'cancellation',
334
+ supersedes_handoff_id: record.handoff.handoff_id,
335
+ supersedes_version: record.handoff.version,
336
+ supersedes_manifest_digest: record.handoff.seal.manifest_digest,
337
+ },
338
+ cancelled_by,
339
+ cancelled_at,
340
+ reason_ref,
341
+ }));
342
+ return record.cancellation;
343
+ }
344
+
345
+ createAttempt({ handoff_id, task_id, attempt_id, completed_task_ids = [], git_dir }) {
346
+ const record = this.#records.get(handoff_id);
347
+ if (!record) fail('HANDOFF_NOT_FOUND', 'handoff is not registered');
348
+ if (this.#attempts.has(attempt_id)) fail('ATTEMPT_ALREADY_EXISTS', 'attempt_id is already registered');
349
+ const attempt = createAttemptBinding({ handoff: record.handoff, task_id, attempt_id, completed_task_ids, git_dir, status: record.status });
350
+ this.#attempts.set(attempt_id, attempt);
351
+ return attempt;
352
+ }
353
+
354
+ getAttemptForRecovery(attempt_id) {
355
+ const attempt = this.#attempts.get(attempt_id);
356
+ if (!attempt) fail('ATTEMPT_NOT_FOUND', 'attempt is not registered');
357
+ const record = this.#records.get(attempt.handoff_id);
358
+ validateAttemptBinding({ attempt, handoff: record.handoff });
359
+ return attempt;
360
+ }
361
+
362
+ getStatus(handoff_id) {
363
+ const record = this.#records.get(handoff_id);
364
+ if (!record) fail('HANDOFF_NOT_FOUND', 'handoff is not registered');
365
+ return record.status;
366
+ }
367
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@chati/provider-registry",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": "./src/index.js",
7
+ "dependencies": {
8
+ "@chati/core": "0.1.0"
9
+ },
10
+ "scripts": {
11
+ "test": "node --test test/**/*.test.js"
12
+ },
13
+ "engines": {
14
+ "node": ">=20.0.0"
15
+ }
16
+ }