chati-dev 4.4.1 → 4.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -27
- package/bin/chati.js +235 -1
- package/framework/agents/plan/tasks.md +46 -1
- package/framework/config.yaml +2 -2
- package/framework/constitution.md +13 -16
- package/framework/context/governance.md +5 -5
- package/framework/context/root.md +5 -5
- package/framework/i18n/en.yaml +8 -2
- package/framework/i18n/es.yaml +8 -2
- package/framework/i18n/fr.yaml +8 -2
- package/framework/i18n/pt.yaml +8 -2
- package/framework/manifest.json +21 -21
- package/framework/manifest.sig +1 -1
- package/framework/orchestrator/chati.md +43 -12
- package/node_modules/@chati/browser-capability/README.md +10 -0
- package/node_modules/@chati/browser-capability/package.json +17 -0
- package/node_modules/@chati/browser-capability/src/index.js +165 -0
- package/node_modules/@chati/core/package.json +13 -0
- package/node_modules/@chati/core/src/index.js +111 -0
- package/node_modules/@chati/knowledge-context/package.json +17 -0
- package/node_modules/@chati/knowledge-context/src/index.js +202 -0
- package/node_modules/@chati/planning/package.json +17 -0
- package/node_modules/@chati/planning/src/index.js +367 -0
- package/node_modules/@chati/provider-registry/package.json +16 -0
- package/node_modules/@chati/provider-registry/src/index.js +148 -0
- package/node_modules/@chati/rail/README.md +24 -0
- package/node_modules/@chati/rail/package.json +19 -0
- package/node_modules/@chati/rail/src/index.js +437 -0
- package/node_modules/@chati/release-lane/README.md +24 -0
- package/node_modules/@chati/release-lane/package.json +17 -0
- package/node_modules/@chati/release-lane/src/index.js +172 -0
- package/node_modules/@chati/review-council/package.json +17 -0
- package/node_modules/@chati/review-council/src/index.js +264 -0
- package/node_modules/@chati/tracking-clickup/README.md +55 -0
- package/node_modules/@chati/tracking-clickup/package.json +17 -0
- package/node_modules/@chati/tracking-clickup/src/index.js +293 -0
- package/package.json +25 -3
- package/src/config/ide-configs.js +11 -0
- package/src/context/domain-loader.js +1 -1
- package/src/dashboard/data-reader.js +1 -1
- package/src/executors/runner.js +1 -1
- package/src/installer/core.js +14 -13
- package/src/installer/provider-overlay.js +8 -15
- package/src/installer/scaffold-applier.js +1 -1
- package/src/installer/templates.js +12 -25
- package/src/installer/validator.js +5 -10
- package/src/installer-v2/catalog-client.js +165 -0
- package/src/installer-v2/index.js +304 -0
- package/src/installer-v2/model-catalog-envelope.json +278 -0
- package/src/installer-v2/model-catalog.json +130 -0
- package/src/installer-v2/model-catalog.sig +1 -0
- package/src/installer-v2/wizard-installation.js +116 -0
- package/src/intelligence/registry-manager.js +1 -1
- package/src/license/client.js +27 -1
- package/src/memory/session-digest.js +1 -1
- package/src/merger/yaml-merger.js +1 -1
- package/src/orchestrator/browser-runtime.js +25 -0
- package/src/orchestrator/cli.js +93 -8
- package/src/orchestrator/clickup-projection.js +84 -0
- package/src/orchestrator/clickup-runtime.js +13 -0
- package/src/orchestrator/knowledge-runtime.js +64 -0
- package/src/orchestrator/planning-runtime.js +127 -0
- package/src/orchestrator/rail-runtime.js +421 -0
- package/src/orchestrator/release-runtime.js +14 -0
- package/src/orchestrator/review-runtime.js +74 -0
- package/src/orchestrator/runtime-installation-v2.js +57 -0
- package/src/orchestrator/session-manager.js +1 -1
- package/src/telemetry/config.js +1 -1
- package/src/terminal/adapters/grok-adapter.js +16 -0
- package/src/terminal/adapters/index.js +1 -0
- package/src/terminal/cli-registry.js +20 -5
- package/src/terminal/prompt-builder.js +4 -2
- package/src/terminal/run-agent.js +3 -0
- package/src/terminal/run-parallel.js +21 -10
- package/src/terminal/spawner.js +7 -1
- package/src/terminal/team-task-list.js +1 -1
- package/src/upgrade/checker.js +1 -1
- package/src/upgrade/migrator.js +1 -1
- package/src/utils/config-parser.js +3 -8
- package/src/wizard/i18n.js +10 -4
- package/src/wizard/index.js +49 -17
- package/src/wizard/questions.js +50 -28
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# CHATI Release Lane
|
|
2
|
+
|
|
3
|
+
Fixture-only C-11 contract. This package accepts only `FakeReleaseTransport` and has no network client, credential loader, Git command, deployment command or timer.
|
|
4
|
+
|
|
5
|
+
`ReleaseLane` creates immutable hash-chained records. A release needs non-empty source completion references verified by an injected C-06 completion verifier as `merged` and bound to the same immutable reference, an immutable plan reference, passed fake smoke evidence and a human authorization whose `scope_hash` matches the exact release scope. Rollback is a separate attempt with a separate human authorization and scope hash. The package rejects accessors and non-plain nested inputs before it canonicalizes or records them.
|
|
6
|
+
|
|
7
|
+
## Legacy removal readiness
|
|
8
|
+
|
|
9
|
+
| Legacy surface | Replacement in G3 | Parity and migration evidence | Rollback | Responsibility | Status |
|
|
10
|
+
| --- | --- | --- | --- | --- | --- |
|
|
11
|
+
| BUILD agent | `chati-rail` execution attempt | G3-4 fixtures, G3-9 pending | legacy remains intact | Gabriel, technical policy | not-ready |
|
|
12
|
+
| QA Implementation | `chati-review-council` plus RAIL completion | G3-5 fixtures, G3-9 pending | legacy remains intact | Gabriel, technical policy | ready-for-shadow |
|
|
13
|
+
| QA Visual | browser capability contract | G3-7B fixtures, real visual parity pending | legacy remains intact | Gabriel, browser policy | not-ready |
|
|
14
|
+
| DevOps / release | this fake-only release lane | C-11 tests below, real release deliberately absent | legacy remains intact | Gabriel, release policy | ready-for-shadow |
|
|
15
|
+
| Legacy hooks | provider-neutral policy and adapters | G3-9 migration fixture pending | legacy remains intact | Gabriel, CHATI core | not-ready |
|
|
16
|
+
| Provider maps | provider registry and harness adapters | G3-1 to G3-3 tests, parity pending | legacy remains intact | Gabriel, CHATI core | ready-for-shadow |
|
|
17
|
+
|
|
18
|
+
No row is `ready-for-removal`. This matrix is not authorization to delete or deploy.
|
|
19
|
+
|
|
20
|
+
## Verification
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npm -w @chati/release-lane test
|
|
24
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chati/release-lane",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Local, fake-only release and rollback authorization contract for CHATI",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": "./src/index.js",
|
|
8
|
+
"dependencies": {
|
|
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,172 @@
|
|
|
1
|
+
import { ContractError, canonicalize, sha256 } from '@chati/core';
|
|
2
|
+
|
|
3
|
+
const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
4
|
+
const FAKE_TRANSPORT_CAPABILITIES = new WeakMap();
|
|
5
|
+
|
|
6
|
+
function fail(code, message, details = {}) { throw new ContractError(code, message, details); }
|
|
7
|
+
|
|
8
|
+
function plainData(value, code, label, ancestors = new Set()) {
|
|
9
|
+
if (value === null || ['string', 'boolean', 'number'].includes(typeof value)) return;
|
|
10
|
+
if (!value || typeof value !== 'object') fail(code, `${label} must be JSON-compatible data`);
|
|
11
|
+
if (ancestors.has(value)) fail(code, `${label} must not contain cycles`);
|
|
12
|
+
ancestors.add(value);
|
|
13
|
+
if (Array.isArray(value)) {
|
|
14
|
+
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
|
|
15
|
+
if (key === 'length') continue;
|
|
16
|
+
if (descriptor.get || descriptor.set) fail(code, `${label}[${key}] must not be an accessor`);
|
|
17
|
+
plainData(descriptor.value, code, `${label}[${key}]`, ancestors);
|
|
18
|
+
}
|
|
19
|
+
} else {
|
|
20
|
+
const prototype = Object.getPrototypeOf(value);
|
|
21
|
+
if (prototype !== Object.prototype && prototype !== null) fail(code, `${label} must be a plain object`);
|
|
22
|
+
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
|
|
23
|
+
if (descriptor.get || descriptor.set) fail(code, `${label}.${key} must not be an accessor`);
|
|
24
|
+
plainData(descriptor.value, code, `${label}.${key}`, ancestors);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
ancestors.delete(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function deepFreeze(value) {
|
|
31
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
32
|
+
for (const item of Object.values(value)) deepFreeze(item);
|
|
33
|
+
Object.freeze(value);
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
function freezeData(value, code = 'UNSAFE_DATA', label = 'value') { plainData(value, code, label); return deepFreeze(canonicalize(value)); }
|
|
38
|
+
function object(value, code, label) { plainData(value, code, label); if (!value || typeof value !== 'object' || Array.isArray(value)) fail(code, `${label} must be an object`); }
|
|
39
|
+
function string(value, code, label) { if (typeof value !== 'string' || value.trim() === '') fail(code, `${label} must be a non-empty string`); }
|
|
40
|
+
function strings(value, code, label, { empty = false } = {}) { plainData(value, code, label); if (!Array.isArray(value) || (!empty && value.length === 0) || !value.every((item) => typeof item === 'string' && item.trim())) fail(code, `${label} must be an array of non-empty strings`); }
|
|
41
|
+
function at(clock) {
|
|
42
|
+
if (typeof clock !== 'function') fail('MISSING_CLOCK', 'a deterministic clock is required');
|
|
43
|
+
const result = clock();
|
|
44
|
+
const iso = result instanceof Date ? result.toISOString() : result;
|
|
45
|
+
if (typeof iso !== 'string' || !RFC3339.test(iso) || Number.isNaN(Date.parse(iso))) fail('INVALID_CLOCK', 'clock must return a valid RFC3339 timestamp or Date');
|
|
46
|
+
return iso;
|
|
47
|
+
}
|
|
48
|
+
function releaseScope(request) { return freezeData({ action: 'release', environment: request.environment, release_attempt_id: request.release_attempt_id, release_plan_ref: request.release_plan_ref, rollback_plan_ref: request.rollback_plan_ref, source_completion_refs: [...request.source_completion_refs] }); }
|
|
49
|
+
function rollbackScope(request) { return freezeData({ action: 'rollback', release_attempt_id: request.release_attempt_id, rollback_attempt_id: request.rollback_attempt_id, rollback_plan_ref: request.rollback_plan_ref }); }
|
|
50
|
+
function authorization(input, scopeHash) {
|
|
51
|
+
object(input, 'MISSING_HUMAN_AUTHORIZATION', 'authorization');
|
|
52
|
+
if (input.actor_type === undefined) fail('MISSING_HUMAN_AUTHORIZATION', 'authorization.actor_type is required');
|
|
53
|
+
if (input.actor_type !== 'human') fail('AUTHORIZATION_ACTOR_NOT_HUMAN', 'release authorization must be issued by a human actor');
|
|
54
|
+
string(input.authorized_by, 'MISSING_HUMAN_AUTHORIZATION', 'authorization.authorized_by');
|
|
55
|
+
string(input.authorized_at, 'INVALID_HUMAN_AUTHORIZATION', 'authorization.authorized_at');
|
|
56
|
+
if (!RFC3339.test(input.authorized_at) || Number.isNaN(Date.parse(input.authorized_at))) fail('INVALID_HUMAN_AUTHORIZATION', 'authorization.authorized_at must be RFC3339');
|
|
57
|
+
string(input.scope_hash, 'MISSING_HUMAN_AUTHORIZATION', 'authorization.scope_hash');
|
|
58
|
+
if (input.scope_hash !== scopeHash) fail('AUTHORIZATION_SCOPE_MISMATCH', 'authorization scope_hash does not match the immutable operation scope');
|
|
59
|
+
return freezeData(input, 'INVALID_HUMAN_AUTHORIZATION', 'authorization');
|
|
60
|
+
}
|
|
61
|
+
function smoke(input) { object(input, 'MISSING_SMOKE_EVIDENCE', 'smoke'); if (input.verdict !== 'passed') fail('SMOKE_EVIDENCE_FAILED', 'release or rollback requires passed smoke evidence'); strings(input.evidence_refs, 'MISSING_SMOKE_EVIDENCE', 'smoke.evidence_refs'); return freezeData(input, 'MISSING_SMOKE_EVIDENCE', 'smoke'); }
|
|
62
|
+
function fakeAdapter(adapter) { const capability = FAKE_TRANSPORT_CAPABILITIES.get(adapter); if (!capability) fail('EXTERNAL_TRANSPORT_FORBIDDEN', 'G3 release lane accepts only a capability minted by FakeReleaseTransport'); return capability; }
|
|
63
|
+
|
|
64
|
+
/** C-11 fixture-only release authority. No network, credentials, Git command or deployment command exists here. */
|
|
65
|
+
export class ReleaseLane {
|
|
66
|
+
#clock;
|
|
67
|
+
#verifyCompletion;
|
|
68
|
+
#records = [];
|
|
69
|
+
#releases = new Map();
|
|
70
|
+
#rollbacks = new Map();
|
|
71
|
+
|
|
72
|
+
constructor({ clock, verify_completion } = {}) {
|
|
73
|
+
if (typeof verify_completion !== 'function') fail('MISSING_COMPLETION_VERIFIER', 'verify_completion must validate C-06 completion evidence');
|
|
74
|
+
this.#clock = clock;
|
|
75
|
+
this.#verifyCompletion = verify_completion;
|
|
76
|
+
}
|
|
77
|
+
get records() { return freezeData([...this.#records]); }
|
|
78
|
+
#record(type, payload) {
|
|
79
|
+
const previous_hash = this.#records.at(-1)?.chain_hash ?? 'GENESIS';
|
|
80
|
+
const material = freezeData({ schema_version: 1, sequence: this.#records.length + 1, type, occurred_at: at(this.#clock), payload });
|
|
81
|
+
const recordHash = sha256(material);
|
|
82
|
+
const record = freezeData({ ...material, record_hash: recordHash, previous_hash, chain_hash: sha256({ previous_hash, record_hash: recordHash }) });
|
|
83
|
+
this.#records.push(record);
|
|
84
|
+
return record;
|
|
85
|
+
}
|
|
86
|
+
#verifyCompletions(refs) {
|
|
87
|
+
return freezeData(refs.map((ref) => {
|
|
88
|
+
const completion = this.#verifyCompletion(ref);
|
|
89
|
+
object(completion, 'UNVERIFIABLE_SOURCE_COMPLETION', `completion ${ref}`);
|
|
90
|
+
if (completion.state !== 'merged') fail('SOURCE_COMPLETION_NOT_MERGED', 'source completion must be C-06 merged evidence', { ref });
|
|
91
|
+
string(completion.completion_id, 'UNVERIFIABLE_SOURCE_COMPLETION', `completion ${ref}.completion_id`);
|
|
92
|
+
string(completion.immutable_ref, 'UNVERIFIABLE_SOURCE_COMPLETION', `completion ${ref}.immutable_ref`);
|
|
93
|
+
if (completion.immutable_ref !== ref) fail('SOURCE_COMPLETION_REF_MISMATCH', 'completion verifier did not bind the requested immutable reference', { ref });
|
|
94
|
+
return { completion_id: completion.completion_id, immutable_ref: completion.immutable_ref, state: completion.state };
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
createReleaseAttempt(request = {}) {
|
|
98
|
+
object(request, 'INVALID_RELEASE_ATTEMPT', 'release request');
|
|
99
|
+
for (const field of ['release_attempt_id', 'environment', 'release_plan_ref', 'rollback_plan_ref']) string(request[field], 'INVALID_RELEASE_ATTEMPT', field);
|
|
100
|
+
strings(request.source_completion_refs, 'INVALID_RELEASE_ATTEMPT', 'source_completion_refs');
|
|
101
|
+
if (this.#releases.has(request.release_attempt_id)) fail('RELEASE_ATTEMPT_EXISTS', 'release_attempt_id already exists');
|
|
102
|
+
const verifiedCompletions = this.#verifyCompletions(request.source_completion_refs);
|
|
103
|
+
const scope = releaseScope(request);
|
|
104
|
+
const attempt = freezeData({ ...request, verified_completions: verifiedCompletions, scope, scope_hash: sha256(scope), state: 'created' });
|
|
105
|
+
this.#releases.set(attempt.release_attempt_id, attempt);
|
|
106
|
+
this.#record('release_attempt_created', { attempt });
|
|
107
|
+
return attempt;
|
|
108
|
+
}
|
|
109
|
+
executeRelease({ release_attempt_id, authorization: approval, smoke: smokeInput, adapter } = {}) {
|
|
110
|
+
string(release_attempt_id, 'INVALID_RELEASE_ATTEMPT', 'release_attempt_id');
|
|
111
|
+
const attempt = this.#releases.get(release_attempt_id);
|
|
112
|
+
if (!attempt) fail('RELEASE_ATTEMPT_NOT_FOUND', 'release attempt does not exist');
|
|
113
|
+
if (attempt.state !== 'created') fail('RELEASE_ATTEMPT_TERMINAL', 'release attempt is no longer executable');
|
|
114
|
+
try {
|
|
115
|
+
const humanAuthorization = authorization(approval, attempt.scope_hash);
|
|
116
|
+
const smokeEvidence = smoke(smokeInput);
|
|
117
|
+
const capability = fakeAdapter(adapter);
|
|
118
|
+
this.#record('release_authorized', { release_attempt_id, authorization: humanAuthorization });
|
|
119
|
+
this.#record('release_smoke_verified', { release_attempt_id, smoke: smokeEvidence });
|
|
120
|
+
const receipt = capability.release(freezeData({ attempt, authorization: humanAuthorization, smoke: smokeEvidence }));
|
|
121
|
+
string(receipt?.receipt_ref, 'INVALID_FAKE_RECEIPT', 'fake release receipt_ref');
|
|
122
|
+
const released = freezeData({ ...attempt, state: 'released', authorization: humanAuthorization, smoke: smokeEvidence, receipt_ref: receipt.receipt_ref });
|
|
123
|
+
this.#releases.set(release_attempt_id, released);
|
|
124
|
+
this.#record('release_completed', { release_attempt_id, receipt_ref: receipt.receipt_ref });
|
|
125
|
+
return released;
|
|
126
|
+
} catch (error) {
|
|
127
|
+
this.#releases.set(release_attempt_id, freezeData({ ...attempt, state: 'rejected', rejection_code: error.code ?? 'RELEASE_EXECUTION_FAILED' }));
|
|
128
|
+
this.#record('release_rejected', { release_attempt_id, code: error.code ?? 'RELEASE_EXECUTION_FAILED' });
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
executeRollback({ rollback_attempt_id, release_attempt_id, rollback_plan_ref, authorization: approval, smoke: smokeInput, adapter } = {}) {
|
|
133
|
+
string(rollback_attempt_id, 'INVALID_ROLLBACK_ATTEMPT', 'rollback_attempt_id');
|
|
134
|
+
string(release_attempt_id, 'INVALID_ROLLBACK_ATTEMPT', 'release_attempt_id');
|
|
135
|
+
string(rollback_plan_ref, 'INVALID_ROLLBACK_ATTEMPT', 'rollback_plan_ref');
|
|
136
|
+
if (this.#rollbacks.has(rollback_attempt_id)) fail('ROLLBACK_ATTEMPT_EXISTS', 'rollback_attempt_id already exists');
|
|
137
|
+
const release = this.#releases.get(release_attempt_id);
|
|
138
|
+
if (!release || release.state !== 'released') fail('ROLLBACK_SOURCE_NOT_RELEASED', 'rollback requires a separately recorded released attempt');
|
|
139
|
+
const scope = rollbackScope({ rollback_attempt_id, release_attempt_id, rollback_plan_ref });
|
|
140
|
+
const attempt = freezeData({ rollback_attempt_id, release_attempt_id, rollback_plan_ref, scope, scope_hash: sha256(scope), state: 'created' });
|
|
141
|
+
this.#rollbacks.set(rollback_attempt_id, attempt);
|
|
142
|
+
this.#record('rollback_attempt_created', { attempt });
|
|
143
|
+
try {
|
|
144
|
+
const humanAuthorization = authorization(approval, attempt.scope_hash);
|
|
145
|
+
const smokeEvidence = smoke(smokeInput);
|
|
146
|
+
const capability = fakeAdapter(adapter);
|
|
147
|
+
this.#record('rollback_authorized', { rollback_attempt_id, authorization: humanAuthorization });
|
|
148
|
+
this.#record('rollback_smoke_verified', { rollback_attempt_id, smoke: smokeEvidence });
|
|
149
|
+
const receipt = capability.rollback(freezeData({ attempt, release, authorization: humanAuthorization, smoke: smokeEvidence }));
|
|
150
|
+
string(receipt?.receipt_ref, 'INVALID_FAKE_RECEIPT', 'fake rollback receipt_ref');
|
|
151
|
+
const rolledBack = freezeData({ ...attempt, state: 'rolled_back', authorization: humanAuthorization, smoke: smokeEvidence, receipt_ref: receipt.receipt_ref });
|
|
152
|
+
this.#rollbacks.set(rollback_attempt_id, rolledBack);
|
|
153
|
+
this.#record('rollback_completed', { rollback_attempt_id, receipt_ref: receipt.receipt_ref });
|
|
154
|
+
return rolledBack;
|
|
155
|
+
} catch (error) {
|
|
156
|
+
this.#rollbacks.set(rollback_attempt_id, freezeData({ ...attempt, state: 'rejected', rejection_code: error.code ?? 'ROLLBACK_EXECUTION_FAILED' }));
|
|
157
|
+
this.#record('rollback_rejected', { rollback_attempt_id, code: error.code ?? 'ROLLBACK_EXECUTION_FAILED' });
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export class FakeReleaseTransport {
|
|
164
|
+
#attempts = [];
|
|
165
|
+
constructor() {
|
|
166
|
+
FAKE_TRANSPORT_CAPABILITIES.set(this, Object.freeze({
|
|
167
|
+
release: (input) => { this.#attempts.push(freezeData({ operation: 'release', release_attempt_id: input.attempt.release_attempt_id })); return freezeData({ receipt_ref: `fake://release/${input.attempt.release_attempt_id}` }); },
|
|
168
|
+
rollback: (input) => { this.#attempts.push(freezeData({ operation: 'rollback', rollback_attempt_id: input.attempt.rollback_attempt_id })); return freezeData({ receipt_ref: `fake://rollback/${input.attempt.rollback_attempt_id}` }); },
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
get attempts() { return freezeData([...this.#attempts]); }
|
|
172
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chati/review-council",
|
|
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
|
+
"@chati/provider-registry": "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,264 @@
|
|
|
1
|
+
import { ContractError, canonicalize } from '@chati/core';
|
|
2
|
+
import { assertEligibleInvocation } from '@chati/provider-registry';
|
|
3
|
+
|
|
4
|
+
const LEVELS = new Set(['A', 'B', 'C']);
|
|
5
|
+
const SEVERITIES = new Set(['critical', 'high', 'medium', 'low']);
|
|
6
|
+
const DISPOSITIONS = new Set(['accepted', 'rejected', 'unresolved']);
|
|
7
|
+
const VERDICTS = new Set(['accept', 'rework', 'required-human-decision', 'blocked']);
|
|
8
|
+
const AUTHORITY_REASONS = new Set(['scope', 'security', 'privacy', 'cost', 'external-action']);
|
|
9
|
+
const IMPASSE_KINDS = new Set(['none', 'technical']);
|
|
10
|
+
|
|
11
|
+
function fail(code, message, details = {}) {
|
|
12
|
+
throw new ContractError(code, message, details);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function assertObject(value, code, label) {
|
|
16
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) fail(code, `${label} must be an object`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function assertString(value, code, label) {
|
|
20
|
+
if (typeof value !== 'string' || value.trim() === '') fail(code, `${label} must be a non-empty string`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function assertRef(value, code, label) {
|
|
24
|
+
assertString(value, code, label);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function freeze(value) {
|
|
28
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
29
|
+
Object.freeze(value);
|
|
30
|
+
for (const nested of Object.values(value)) freeze(nested);
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function validateIdentity(identity, label) {
|
|
36
|
+
assertObject(identity, 'INVALID_REVIEW_IDENTITY', label);
|
|
37
|
+
for (const field of ['provider_id', 'model_id']) assertString(identity[field], 'INVALID_REVIEW_IDENTITY', `${label}.${field}`);
|
|
38
|
+
return identity;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function validateFinding(finding) {
|
|
42
|
+
assertObject(finding, 'INVALID_REVIEW_FINDING', 'finding');
|
|
43
|
+
assertString(finding.finding_id, 'INVALID_REVIEW_FINDING', 'finding.finding_id');
|
|
44
|
+
if (!SEVERITIES.has(finding.severity)) fail('INVALID_REVIEW_FINDING', 'finding.severity is invalid');
|
|
45
|
+
assertRef(finding.location_ref, 'INVALID_REVIEW_FINDING', 'finding.location_ref');
|
|
46
|
+
if (!Array.isArray(finding.evidence_refs) || !finding.evidence_refs.every((ref) => typeof ref === 'string' && ref.trim() !== '')) {
|
|
47
|
+
fail('INVALID_REVIEW_FINDING', 'finding.evidence_refs must be an array of non-empty strings');
|
|
48
|
+
}
|
|
49
|
+
if (!DISPOSITIONS.has(finding.disposition)) fail('INVALID_REVIEW_FINDING', 'finding.disposition is invalid');
|
|
50
|
+
assertRef(finding.disposition_reason_ref, 'INVALID_REVIEW_FINDING', 'finding.disposition_reason_ref');
|
|
51
|
+
if (finding.root_cause_taxonomy !== undefined) assertString(finding.root_cause_taxonomy, 'INVALID_REVIEW_FINDING', 'finding.root_cause_taxonomy');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Validates the normalized C-05 review record without invoking a model. */
|
|
55
|
+
export function validateReviewRecord(review) {
|
|
56
|
+
assertObject(review, 'INVALID_REVIEW', 'review');
|
|
57
|
+
for (const field of ['review_id', 'attempt_id', 'reviewer_invocation_ref', 'rubric_ref']) assertString(review[field], 'INVALID_REVIEW', field);
|
|
58
|
+
if (!Number.isInteger(review.review_round) || review.review_round < 1) fail('INVALID_REVIEW', 'review_round must be a positive integer');
|
|
59
|
+
for (const field of ['required_independence_level', 'attained_independence_level']) {
|
|
60
|
+
if (!LEVELS.has(review[field])) fail('INVALID_REVIEW', `${field} must be A, B or C`);
|
|
61
|
+
}
|
|
62
|
+
validateIdentity(review.builder_identity, 'builder_identity');
|
|
63
|
+
validateIdentity(review.reviewer_identity, 'reviewer_identity');
|
|
64
|
+
if (review.clean_context !== true) fail('REVIEW_CONTEXT_NOT_CLEAN', 'review requires a declared clean context');
|
|
65
|
+
assertObject(review.degradation, 'INVALID_REVIEW_DEGRADATION', 'degradation');
|
|
66
|
+
if (!['none', 'approved'].includes(review.degradation.status)) fail('INVALID_REVIEW_DEGRADATION', 'degradation.status is invalid');
|
|
67
|
+
if (review.degradation.status === 'approved') {
|
|
68
|
+
assertString(review.degradation.approved_by, 'INVALID_REVIEW_DEGRADATION', 'degradation.approved_by');
|
|
69
|
+
assertRef(review.degradation.reason_ref, 'INVALID_REVIEW_DEGRADATION', 'degradation.reason_ref');
|
|
70
|
+
}
|
|
71
|
+
if (!Array.isArray(review.findings)) fail('INVALID_REVIEW', 'findings must be an array');
|
|
72
|
+
const findingIds = new Set();
|
|
73
|
+
for (const finding of review.findings) {
|
|
74
|
+
validateFinding(finding);
|
|
75
|
+
if (findingIds.has(finding.finding_id)) fail('DUPLICATE_REVIEW_FINDING', 'finding_id is duplicated');
|
|
76
|
+
findingIds.add(finding.finding_id);
|
|
77
|
+
}
|
|
78
|
+
if (!VERDICTS.has(review.verdict)) fail('INVALID_REVIEW', 'verdict is invalid');
|
|
79
|
+
if (review.findings.some((finding) => finding.disposition === 'accepted') && review.verdict !== 'rework') {
|
|
80
|
+
fail('ACCEPTED_FINDINGS_REQUIRE_REWORK', 'any accepted finding requires verdict rework');
|
|
81
|
+
}
|
|
82
|
+
assertObject(review.impasse, 'INVALID_REVIEW_IMPASSE', 'impasse');
|
|
83
|
+
if (!IMPASSE_KINDS.has(review.impasse.kind)) fail('INVALID_REVIEW_IMPASSE', 'impasse.kind is invalid');
|
|
84
|
+
if (review.impasse.kind === 'technical') assertRef(review.impasse.evidence_ref, 'INVALID_REVIEW_IMPASSE', 'impasse.evidence_ref');
|
|
85
|
+
if (review.impasse.kind === 'none' && review.impasse.evidence_ref !== undefined) fail('INVALID_REVIEW_IMPASSE', 'impasse.evidence_ref is only valid for technical impasse');
|
|
86
|
+
if (review.verdict === 'required-human-decision') {
|
|
87
|
+
if (!AUTHORITY_REASONS.has(review.authority_reason)) {
|
|
88
|
+
fail('HUMAN_DECISION_NOT_AUTHORITY', 'required-human-decision requires an enumerated authority reason');
|
|
89
|
+
}
|
|
90
|
+
assertRef(review.authority_reason_ref, 'HUMAN_DECISION_NOT_AUTHORITY', 'authority_reason_ref');
|
|
91
|
+
} else if (review.authority_reason !== undefined || review.authority_reason_ref !== undefined) {
|
|
92
|
+
fail('UNEXPECTED_AUTHORITY_REASON', 'authority reason is only valid for required-human-decision');
|
|
93
|
+
}
|
|
94
|
+
return freeze(canonicalize(review));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Computes the declared independence level from the two model identities. */
|
|
98
|
+
export function computeIndependenceLevel({ builder_identity, reviewer_identity }) {
|
|
99
|
+
validateIdentity(builder_identity, 'builder_identity');
|
|
100
|
+
validateIdentity(reviewer_identity, 'reviewer_identity');
|
|
101
|
+
if (builder_identity.provider_id !== reviewer_identity.provider_id) return 'A';
|
|
102
|
+
if (builder_identity.model_id !== reviewer_identity.model_id) return 'B';
|
|
103
|
+
return 'C';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** C-05: A/B may never silently degrade to C. */
|
|
107
|
+
export function assertReviewIndependence(review) {
|
|
108
|
+
const validated = validateReviewRecord(review);
|
|
109
|
+
const computed = computeIndependenceLevel(validated);
|
|
110
|
+
if (computed !== validated.attained_independence_level) {
|
|
111
|
+
fail('INDEPENDENCE_LEVEL_MISMATCH', 'attained_independence_level does not match the declared identities', { computed, attained: validated.attained_independence_level });
|
|
112
|
+
}
|
|
113
|
+
const order = { A: 3, B: 2, C: 1 };
|
|
114
|
+
if (order[validated.attained_independence_level] < order[validated.required_independence_level] && validated.degradation.status !== 'approved') {
|
|
115
|
+
fail('INDEPENDENCE_UNAVAILABLE', 'required independent review is unavailable without an explicit approved degradation');
|
|
116
|
+
}
|
|
117
|
+
return validated;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Derives cycles from evidence, not caller-supplied review objects. Every
|
|
122
|
+
* history record is bound to the same attempt and proves C-01/C-02 reviewer
|
|
123
|
+
* eligibility before it may affect a rework metric.
|
|
124
|
+
*/
|
|
125
|
+
function inspectReviewHistory({ review_evidence, attempt_id, installation, snapshot, clock }) {
|
|
126
|
+
if (!Array.isArray(review_evidence)) fail('INVALID_REVIEW_HISTORY', 'review_evidence must be an array');
|
|
127
|
+
assertString(attempt_id, 'INVALID_REVIEW_HISTORY', 'attempt_id');
|
|
128
|
+
const reviewIds = new Set();
|
|
129
|
+
const rounds = new Set();
|
|
130
|
+
const reworkCycles = review_evidence.reduce((count, entry) => {
|
|
131
|
+
assertObject(entry, 'INVALID_REVIEW_HISTORY', 'review evidence entry');
|
|
132
|
+
const validated = acceptReview({ review: entry.review, invocation: entry.invocation, installation, snapshot, clock });
|
|
133
|
+
if (validated.attempt_id !== attempt_id) fail('REVIEW_ATTEMPT_MISMATCH', 'review history may only contain the current attempt', { expected_attempt_id: attempt_id, actual_attempt_id: validated.attempt_id });
|
|
134
|
+
if (reviewIds.has(validated.review_id)) fail('DUPLICATE_REVIEW_ID', 'review history cannot repeat review_id within an attempt', { attempt_id, review_id: validated.review_id });
|
|
135
|
+
if (rounds.has(validated.review_round)) fail('DUPLICATE_REVIEW_ROUND', 'review history cannot repeat review_round within an attempt', { attempt_id, review_round: validated.review_round });
|
|
136
|
+
reviewIds.add(validated.review_id);
|
|
137
|
+
rounds.add(validated.review_round);
|
|
138
|
+
return count + (validated.findings.some((finding) => finding.disposition === 'accepted') ? 1 : 0);
|
|
139
|
+
}, 0);
|
|
140
|
+
return { reworkCycles, reviewIds, rounds };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function countReworkCycles(input) {
|
|
144
|
+
return inspectReviewHistory(input).reworkCycles;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function assertReviewerEligible({ review, invocation, installation, snapshot, clock }) {
|
|
148
|
+
if (!invocation) fail('MISSING_REVIEWER_INVOCATION', 'reviewer invocation is required to prove eligibility');
|
|
149
|
+
const eligible = assertEligibleInvocation({ installation, snapshot, invocation, clock });
|
|
150
|
+
if (invocation.action !== 'review') fail('INVALID_REVIEWER_ACTION', 'reviewer invocation action must be review');
|
|
151
|
+
if (invocation.invocation_id !== review.reviewer_invocation_ref) {
|
|
152
|
+
fail('REVIEWER_INVOCATION_REFERENCE_MISMATCH', 'reviewer invocation must match review.reviewer_invocation_ref');
|
|
153
|
+
}
|
|
154
|
+
if (invocation.provider_id !== review.reviewer_identity.provider_id || invocation.model_pin.model_id !== review.reviewer_identity.model_id) {
|
|
155
|
+
fail('REVIEWER_IDENTITY_MISMATCH', 'reviewer identity must match the eligible invocation');
|
|
156
|
+
}
|
|
157
|
+
return eligible;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Validates review independence and selected-only reviewer eligibility. */
|
|
161
|
+
export function acceptReview({ review, invocation, installation, snapshot, clock }) {
|
|
162
|
+
const validated = assertReviewIndependence(review);
|
|
163
|
+
assertReviewerEligible({ review: validated, invocation, installation, snapshot, clock });
|
|
164
|
+
return validated;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Selects one top-tier adjudicator per installation-selected harness. A
|
|
169
|
+
* project may use one, two or three providers, but never gains an unselected
|
|
170
|
+
* vendor merely because a rework loop escalated.
|
|
171
|
+
*/
|
|
172
|
+
export function selectAdjudicationInvocations({ installation, snapshot }) {
|
|
173
|
+
const invocations = [];
|
|
174
|
+
const missing = [];
|
|
175
|
+
for (const binding of installation.enabled_providers) {
|
|
176
|
+
const eligibleModels = snapshot.models
|
|
177
|
+
.filter((model) => model.provider_id === binding.provider_id
|
|
178
|
+
&& binding.allowed_models.includes(model.model_id)
|
|
179
|
+
&& model.actions.includes('adjudication')
|
|
180
|
+
&& typeof model.highest_reasoning_configuration === 'string'
|
|
181
|
+
&& model.highest_reasoning_configuration.trim() !== ''
|
|
182
|
+
&& binding.allowed_reasoning_configurations?.includes(model.highest_reasoning_configuration))
|
|
183
|
+
.sort((left, right) => (right.adjudication_priority ?? 0) - (left.adjudication_priority ?? 0)
|
|
184
|
+
|| left.model_id.localeCompare(right.model_id));
|
|
185
|
+
const selected = eligibleModels[0];
|
|
186
|
+
if (!selected) {
|
|
187
|
+
missing.push({ provider_id: binding.provider_id, harness_id: binding.harness_id, reason_code: 'NO_SELECTED_ADJUDICATION_MODEL' });
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
invocations.push({
|
|
191
|
+
provider_id: binding.provider_id,
|
|
192
|
+
harness_id: binding.harness_id,
|
|
193
|
+
action: 'adjudication',
|
|
194
|
+
model_pin: {
|
|
195
|
+
model_id: selected.model_id,
|
|
196
|
+
catalog_snapshot_ref: snapshot.snapshot_id,
|
|
197
|
+
reasoning_configuration: selected.highest_reasoning_configuration,
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return { invocations, missing };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Creates the local-only AI adjudication plan after two completed normal
|
|
206
|
+
* rework cycles and a third technical impasse. It never executes a CLI.
|
|
207
|
+
*/
|
|
208
|
+
export function decideReviewProgress({ prior_review_evidence = [], current_review, reviewer_invocation, installation, snapshot, clock }) {
|
|
209
|
+
const current = acceptReview({ review: current_review, invocation: reviewer_invocation, installation, snapshot, clock });
|
|
210
|
+
const history = inspectReviewHistory({ review_evidence: prior_review_evidence, attempt_id: current.attempt_id, installation, snapshot, clock });
|
|
211
|
+
if (history.reviewIds.has(current.review_id)) fail('DUPLICATE_REVIEW_ID', 'current review_id already exists in this attempt history', { attempt_id: current.attempt_id, review_id: current.review_id });
|
|
212
|
+
if (history.rounds.has(current.review_round)) fail('DUPLICATE_REVIEW_ROUND', 'current review_round already exists in this attempt history', { attempt_id: current.attempt_id, review_round: current.review_round });
|
|
213
|
+
const priorCycles = history.reworkCycles;
|
|
214
|
+
if (current.verdict === 'required-human-decision') return freeze(canonicalize({ decision: 'required-human-decision', authority_reason: current.authority_reason }));
|
|
215
|
+
if (current.verdict !== 'rework') return freeze(canonicalize({ decision: current.verdict, rework_cycles: priorCycles }));
|
|
216
|
+
if (priorCycles < 2) {
|
|
217
|
+
return freeze(canonicalize({
|
|
218
|
+
decision: 'normal-rework',
|
|
219
|
+
rework_cycles: priorCycles + (current.findings.some((finding) => finding.disposition === 'accepted') ? 1 : 0),
|
|
220
|
+
}));
|
|
221
|
+
}
|
|
222
|
+
if (current.impasse.kind !== 'technical') {
|
|
223
|
+
return freeze(canonicalize({
|
|
224
|
+
decision: 'blocked',
|
|
225
|
+
reason: 'TECHNICAL_IMPASSE_EVIDENCE_REQUIRED',
|
|
226
|
+
requires: 'policy-or-handoff-revision',
|
|
227
|
+
rework_cycles: priorCycles + (current.findings.some((finding) => finding.disposition === 'accepted') ? 1 : 0),
|
|
228
|
+
}));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const { invocations, missing } = selectAdjudicationInvocations({ installation, snapshot });
|
|
232
|
+
for (const invocation of invocations) {
|
|
233
|
+
try {
|
|
234
|
+
assertEligibleInvocation({ installation, snapshot, invocation, clock });
|
|
235
|
+
} catch (error) {
|
|
236
|
+
missing.push({ provider_id: invocation.provider_id, model_id: invocation.model_pin.model_id, reason_code: error.code ?? 'NOT_ELIGIBLE' });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (missing.length > 0 || invocations.length === 0) return freeze(canonicalize({ decision: 'blocked', reason: 'AI_ADJUDICATION_UNAVAILABLE', missing }));
|
|
240
|
+
return freeze(canonicalize({
|
|
241
|
+
decision: 'AI_ADJUDICATION',
|
|
242
|
+
rework_cycles: priorCycles + (current.findings.some((finding) => finding.disposition === 'accepted') ? 1 : 0),
|
|
243
|
+
adjudicators: invocations,
|
|
244
|
+
allowed_outcomes: ['bounded-correction', 'handoff-revision', 'blocked-authority'],
|
|
245
|
+
}));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Validates the bounded output of the three-model local adjudication step. */
|
|
249
|
+
export function validateAdjudicationDecision(decision) {
|
|
250
|
+
assertObject(decision, 'INVALID_ADJUDICATION_DECISION', 'decision');
|
|
251
|
+
if (!['bounded-correction', 'handoff-revision', 'blocked-authority'].includes(decision.outcome)) {
|
|
252
|
+
fail('INVALID_ADJUDICATION_DECISION', 'outcome must be bounded-correction, handoff-revision or blocked-authority');
|
|
253
|
+
}
|
|
254
|
+
assertRef(decision.decision_ref, 'INVALID_ADJUDICATION_DECISION', 'decision_ref');
|
|
255
|
+
if (decision.outcome === 'bounded-correction') assertRef(decision.correction_ref, 'INVALID_ADJUDICATION_DECISION', 'correction_ref');
|
|
256
|
+
if (decision.outcome === 'handoff-revision') assertRef(decision.handoff_revision_ref, 'INVALID_ADJUDICATION_DECISION', 'handoff_revision_ref');
|
|
257
|
+
if (decision.outcome === 'blocked-authority') {
|
|
258
|
+
if (!AUTHORITY_REASONS.has(decision.authority_reason)) fail('HUMAN_DECISION_NOT_AUTHORITY', 'blocked authority requires an enumerated authority reason');
|
|
259
|
+
assertRef(decision.authority_reason_ref, 'HUMAN_DECISION_NOT_AUTHORITY', 'authority_reason_ref');
|
|
260
|
+
}
|
|
261
|
+
return freeze(canonicalize(decision));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export const authorityReasons = AUTHORITY_REASONS;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# @chati/tracking-clickup
|
|
2
|
+
|
|
3
|
+
Local, deterministic implementation of G3 contract C-07.
|
|
4
|
+
|
|
5
|
+
It owns a durable JSON outbox and versioned, derivable `Project Evolution`
|
|
6
|
+
snapshots. Delivery and reconciliation are distinct state dimensions. A caller
|
|
7
|
+
may inject a fake transport in tests or an independently authorized ClickUp
|
|
8
|
+
adapter at an outer boundary.
|
|
9
|
+
|
|
10
|
+
## Guarantees
|
|
11
|
+
|
|
12
|
+
- A projection is persisted before `transport.send()` can run.
|
|
13
|
+
- Every remote effect has a unique durable idempotency key.
|
|
14
|
+
- Enqueue requires verified immutable handoff and journal references, resolved
|
|
15
|
+
through an injected reference resolver into a phase and canonical task view.
|
|
16
|
+
Decisions and receipts are likewise verified references, and are retained in
|
|
17
|
+
the derived `Project Evolution` snapshot.
|
|
18
|
+
- `confirmed` requires a persisted immutable receipt reference.
|
|
19
|
+
- Reconciliation cannot run before delivery confirmation and does not erase
|
|
20
|
+
delivery receipts.
|
|
21
|
+
- `watchOnce()` is intentionally a one-shot replay operation. Its exported
|
|
22
|
+
interval contract is exactly hourly (`HOURLY_WATCHER_INTERVAL_MS`); hosting
|
|
23
|
+
that scheduler is outside this package, so no local timer is created.
|
|
24
|
+
- ClickUp outages produce a replayable local backlog. They never pause local
|
|
25
|
+
dispatch or overwrite canonical RAIL completion.
|
|
26
|
+
- A `close` projection requires an injected C-06 completion verifier. This
|
|
27
|
+
package cannot invent task completion. The same rule blocks `update`
|
|
28
|
+
payloads that try to set terminal status or an end date.
|
|
29
|
+
- Schema and authorization/token preflight gates must approve before any
|
|
30
|
+
injected transport call. `sent_unconfirmed` is never resent until an
|
|
31
|
+
idempotency lookup returns `not_found`.
|
|
32
|
+
- Human effort, AI processing and rework cycles must be supplied as their
|
|
33
|
+
own facts. The package never derives them from elapsed pipeline time.
|
|
34
|
+
|
|
35
|
+
## Explicit limits
|
|
36
|
+
|
|
37
|
+
This package has no ClickUp SDK, network client, token handling, timer,
|
|
38
|
+
provider invocation, RAIL journal mutation, Brain write path, release path or
|
|
39
|
+
real external action. It neither reads nor writes a real ClickUp task.
|
|
40
|
+
|
|
41
|
+
`Project Evolution` is intentionally derivative. Construction and
|
|
42
|
+
`rederiveProjectEvolution()` regenerate it from the durable outbox after an
|
|
43
|
+
interrupted local artifact write, retaining handoff, journal, decision and
|
|
44
|
+
receipt references held by the outbox. Its digest excludes its own derived
|
|
45
|
+
reference and has no generated-at timestamp, so recovery recreates the same
|
|
46
|
+
immutable reference until canonical source evidence changes.
|
|
47
|
+
|
|
48
|
+
When a project-level projection has no execution attempt, its persisted
|
|
49
|
+
canonical attempt id is `project:<project_id>:no-attempt`; it is never absent.
|
|
50
|
+
|
|
51
|
+
Run its deterministic test suite with:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
npm -w packages/chati-tracking-clickup test
|
|
55
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chati/tracking-clickup",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Durable, local ClickUp projection outbox for CHATI",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": "./src/index.js",
|
|
8
|
+
"dependencies": {
|
|
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
|
+
}
|