chati-dev 4.5.16 → 4.5.27
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 +8 -3
- package/bin/chati.js +124 -66
- package/framework/agents/build/dev.md +5 -5
- package/framework/agents/deploy/devops.md +7 -7
- package/framework/agents/discover/brief.md +4 -4
- package/framework/agents/discover/brownfield-wu.md +3 -3
- package/framework/agents/discover/greenfield-wu.md +3 -3
- package/framework/agents/plan/architect.md +2 -2
- package/framework/agents/plan/detail.md +4 -4
- package/framework/agents/plan/phases.md +2 -2
- package/framework/agents/plan/tasks.md +2 -2
- package/framework/agents/plan/ux.md +2 -2
- package/framework/agents/quality/qa-implementation.md +11 -10
- package/framework/agents/quality/qa-planning.md +3 -3
- package/framework/agents/quality/qa-visual.md +1 -1
- package/framework/config.yaml +3 -3
- package/framework/constitution.md +18 -18
- package/framework/context/protocols.md +1 -1
- package/framework/context/quality.md +1 -1
- package/framework/context/root.md +4 -4
- package/framework/data/entity-registry.yaml +1 -1
- package/framework/domains/agents/orchestrator.yaml +2 -2
- package/framework/domains/constitution.yaml +1 -1
- package/framework/hooks/advance-trigger.js +4 -6
- package/framework/hooks/git-push-authority.js +45 -37
- package/framework/hooks/mode-governance.js +149 -40
- package/framework/hooks/model-governance.js +13 -20
- package/framework/hooks/prism-engine.js +74 -92
- package/framework/hooks/reasoning-escalator.js +23 -40
- package/framework/hooks/session-digest.js +12 -13
- package/framework/hooks/session-reader.js +224 -0
- package/framework/hooks/session-writer.js +195 -0
- package/framework/hooks/team-quality-gate.js +34 -24
- package/framework/i18n/en.yaml +2 -2
- package/framework/i18n/es.yaml +2 -2
- package/framework/i18n/fr.yaml +2 -2
- package/framework/i18n/pt.yaml +2 -2
- package/framework/intelligence/context-engine.md +4 -4
- package/framework/intelligence/memory-layer.md +1 -1
- package/framework/manifest.json +129 -119
- package/framework/manifest.sig +1 -1
- package/framework/orchestrator/chati-router.js +252 -25
- package/framework/orchestrator/chati.md +238 -70
- package/framework/schemas/session.schema.json +1 -1
- package/framework/tasks/orchestrator-deviation.md +1 -1
- package/framework/tasks/orchestrator-escalate.md +1 -1
- package/framework/tasks/orchestrator-handoff.md +6 -6
- package/framework/tasks/orchestrator-health.md +5 -9
- package/framework/tasks/orchestrator-mode-switch.md +3 -7
- package/framework/tasks/orchestrator-resume.md +10 -14
- package/framework/tasks/orchestrator-route.md +3 -3
- package/framework/tasks/orchestrator-spawn-terminal.md +1 -1
- package/framework/tasks/orchestrator-status.md +9 -9
- package/framework/tasks/orchestrator-suggest-mode.md +1 -1
- package/framework/tasks/qa-impl-consolidate.md +2 -2
- package/framework/tasks/qa-impl-performance-test.md +4 -4
- package/framework/tasks/qa-impl-regression-check.md +4 -4
- package/framework/tasks/qa-impl-sast-scan.md +1 -1
- package/framework/tasks/qa-impl-test-execute.md +1 -1
- package/framework/tasks/qa-impl-verdict.md +2 -2
- package/framework/tasks/qa-planning-consolidate.md +3 -3
- package/framework/tasks/qa-planning-coverage-plan.md +2 -2
- package/framework/tasks/qa-planning-gate-define.md +4 -4
- package/framework/tasks/qa-planning-risk-matrix.md +4 -4
- package/framework/tasks/qa-planning-test-strategy.md +2 -2
- package/node_modules/@chati/browser-capability/src/index.js +12 -2
- package/node_modules/@chati/planning/src/index.js +24 -4
- package/node_modules/@chati/provider-registry/src/index.js +11 -0
- package/node_modules/@chati/rail/src/index.js +1967 -83
- package/node_modules/@chati/release-lane/README.md +12 -8
- package/node_modules/@chati/release-lane/package.json +1 -1
- package/node_modules/@chati/release-lane/src/index.js +1426 -58
- package/node_modules/@chati/review-council/src/index.js +63 -0
- package/node_modules/@chati/tracking-clickup/README.md +13 -0
- package/node_modules/@chati/tracking-clickup/src/index.js +690 -30
- package/package-artifact-manifest.json +1 -0
- package/package-artifact-manifest.sig +1 -0
- package/package.json +16 -7
- package/scripts/verify-real-harness-e2e.js +1581 -0
- package/src/config/framework-adapter.js +4 -4
- package/src/installer/core.js +148 -53
- package/src/installer/manifest.js +140 -9
- package/src/installer/package-artifact.js +249 -0
- package/src/installer/templates.js +65 -20
- package/src/installer-v2/catalog-client.js +531 -23
- package/src/installer-v2/index.js +91 -34
- package/src/installer-v2/installation-authority.js +327 -0
- package/src/installer-v2/provider-executable.js +235 -0
- package/src/installer-v2/wizard-installation.js +1 -1
- package/src/orchestrator/browser-runtime.js +44 -13
- package/src/orchestrator/cli.js +1545 -174
- package/src/orchestrator/clickup-projection.js +43 -1
- package/src/orchestrator/clickup-runtime.js +355 -39
- package/src/orchestrator/doctor.js +16 -3
- package/src/orchestrator/index.js +16 -0
- package/src/orchestrator/planning-runtime.js +20 -2
- package/src/orchestrator/rail-adjudication-evidence.js +234 -0
- package/src/orchestrator/rail-evidence-authority.js +147 -0
- package/src/orchestrator/rail-execution-evidence.js +45 -0
- package/src/orchestrator/rail-runtime.js +836 -56
- package/src/orchestrator/release-runtime.js +65 -6
- package/src/orchestrator/review-runtime.js +186 -41
- package/src/orchestrator/runtime-installation-v2.js +236 -20
- package/src/orchestrator/session-manager.js +1167 -53
- package/src/terminal/adapters/claude-adapter.js +3 -1
- package/src/terminal/adapters/codex-adapter.js +2 -0
- package/src/terminal/adapters/grok-adapter.js +7 -4
- package/src/terminal/handoff-parser.js +19 -1
- package/src/terminal/prompt-builder.js +9 -1
- package/src/terminal/provider-preflight.js +36 -3
- package/src/terminal/rail-execution-worktree.js +213 -0
- package/src/terminal/rail-prompts.js +169 -0
- package/src/terminal/rail-readonly-workspace.js +324 -0
- package/src/terminal/run-agent.js +384 -24
- package/src/terminal/run-parallel.js +5 -0
- package/src/terminal/run-rail-adjudication.js +323 -0
- package/src/terminal/run-rail-review.js +291 -0
- package/src/terminal/run-rail-rework.js +325 -0
- package/src/terminal/run-rail-task.js +380 -0
- package/src/terminal/run-team.js +5 -0
- package/src/terminal/spawner.js +1225 -77
- package/src/wizard/index.js +3 -2
|
@@ -10,13 +10,18 @@
|
|
|
10
10
|
* Constitution Article XI enforcement.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
13
|
+
import { lstatSync, realpathSync, statSync } from 'fs';
|
|
14
|
+
import { dirname, relative, isAbsolute, resolve, sep } from 'path';
|
|
15
|
+
import { readHookSession } from './session-reader.js';
|
|
15
16
|
|
|
16
17
|
const MODE_SCOPES = {
|
|
18
|
+
manual: {
|
|
19
|
+
allowed: ['*'],
|
|
20
|
+
description: 'Paused session: manual control has full project write access',
|
|
21
|
+
},
|
|
17
22
|
planning: {
|
|
18
|
-
allowed: ['chati.dev/', '
|
|
19
|
-
description: 'Planning mode: write only to framework
|
|
23
|
+
allowed: ['chati.dev/', 'artifacts/', 'CLAUDE.md'],
|
|
24
|
+
description: 'Planning mode: write only to framework source, artifacts, and the user-owned CLAUDE.md',
|
|
20
25
|
},
|
|
21
26
|
build: {
|
|
22
27
|
allowed: ['*'],
|
|
@@ -39,39 +44,134 @@ const STATE_TO_GOVERNANCE_MODE = {
|
|
|
39
44
|
completed: 'deploy',
|
|
40
45
|
};
|
|
41
46
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
47
|
+
const PROTECTED_CONTROL_PLANE_PATHS = Object.freeze([
|
|
48
|
+
// All runtime state and installed framework material is controller
|
|
49
|
+
// authority. Keeping this list at the directory boundary avoids a provider
|
|
50
|
+
// changing an adjacent lock, journal or runtime module to bypass a narrower
|
|
51
|
+
// file-level guard.
|
|
52
|
+
'.chati/',
|
|
53
|
+
'.chati.dev/',
|
|
54
|
+
'.chati/session.yaml',
|
|
55
|
+
'.chati/v2/installation.json',
|
|
56
|
+
'chati.dev/hooks/',
|
|
57
|
+
'chati.dev/orchestrator/',
|
|
58
|
+
'CLAUDE.local.md',
|
|
59
|
+
'AGENTS.override.md',
|
|
60
|
+
'.claude/settings.json',
|
|
61
|
+
'.claude/commands/chati.md',
|
|
62
|
+
'.claude/rules/chati/',
|
|
63
|
+
'.codex/config.toml',
|
|
64
|
+
'.codex/hooks.json',
|
|
65
|
+
'.codex/rules/',
|
|
66
|
+
'.agents/skills/chati/',
|
|
67
|
+
'.grok/CHATI.md',
|
|
68
|
+
'.grok/config.toml',
|
|
69
|
+
'.grok/commands/chati.md',
|
|
70
|
+
'.grok/rules/chati-governance.md',
|
|
71
|
+
'.grok/session-lock.md',
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
function pathEscapesProject(rel) {
|
|
75
|
+
return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function portablePath(value) {
|
|
79
|
+
return value.split(sep).join('/');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function scopedPathMatches(rel, scope) {
|
|
83
|
+
return scope.endsWith('/') ? rel.startsWith(scope) : rel === scope;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function physicalCandidate(filePath, projectDir) {
|
|
87
|
+
const root = realpathSync(resolve(projectDir));
|
|
88
|
+
const logical = isAbsolute(filePath) ? resolve(filePath) : resolve(root, filePath);
|
|
89
|
+
const logicalRel = relative(root, logical);
|
|
90
|
+
if (pathEscapesProject(logicalRel)) return null;
|
|
91
|
+
|
|
92
|
+
const missing = [];
|
|
93
|
+
let ancestor = logical;
|
|
94
|
+
while (true) {
|
|
95
|
+
try {
|
|
96
|
+
const physicalAncestor = realpathSync(ancestor);
|
|
97
|
+
const physical = resolve(physicalAncestor, ...missing.reverse());
|
|
98
|
+
const physicalRel = relative(root, physical);
|
|
99
|
+
if (pathEscapesProject(physicalRel)) return null;
|
|
100
|
+
return Object.freeze({
|
|
101
|
+
root,
|
|
102
|
+
logical,
|
|
103
|
+
logicalRel: portablePath(logicalRel),
|
|
104
|
+
physical,
|
|
105
|
+
physicalRel: portablePath(physicalRel),
|
|
106
|
+
});
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error?.code !== 'ENOENT') return null;
|
|
109
|
+
const parent = dirname(ancestor);
|
|
110
|
+
if (parent === ancestor) return null;
|
|
111
|
+
missing.push(ancestor.slice(parent.length + (parent.endsWith(sep) ? 0 : 1)));
|
|
112
|
+
ancestor = parent;
|
|
113
|
+
}
|
|
55
114
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getCurrentMode(projectDir) {
|
|
118
|
+
const result = readHookSession(projectDir);
|
|
119
|
+
if (!result.loaded) return null;
|
|
120
|
+
if (result.session?.session_status === 'paused') return 'manual';
|
|
121
|
+
const mode = result.session?.mode;
|
|
122
|
+
if (MODE_SCOPES[mode]) return mode;
|
|
123
|
+
const state = result.session?.project?.state;
|
|
124
|
+
if (state) return STATE_TO_GOVERNANCE_MODE[state] || null;
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isCanonicalSessionTarget(filePath, projectDir) {
|
|
129
|
+
const canonical = resolve(projectDir, '.chati', 'session.yaml');
|
|
130
|
+
const candidate = isAbsolute(filePath) ? resolve(filePath) : resolve(projectDir, filePath);
|
|
131
|
+
if (candidate === canonical) return true;
|
|
132
|
+
try {
|
|
133
|
+
const canonicalStat = statSync(canonical);
|
|
134
|
+
const candidateStat = statSync(candidate);
|
|
135
|
+
return canonicalStat.dev === candidateStat.dev && canonicalStat.ino === candidateStat.ino;
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
60
138
|
}
|
|
61
|
-
|
|
62
|
-
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isProtectedControlPlaneTarget(filePath, projectDir) {
|
|
142
|
+
const candidate = physicalCandidate(filePath, projectDir);
|
|
143
|
+
if (!candidate) return true;
|
|
144
|
+
if (PROTECTED_CONTROL_PLANE_PATHS.some((scope) =>
|
|
145
|
+
scopedPathMatches(candidate.logicalRel, scope) || scopedPathMatches(candidate.physicalRel, scope))) return true;
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const candidateStat = statSync(candidate.logical);
|
|
149
|
+
for (const protectedPath of PROTECTED_CONTROL_PLANE_PATHS.filter((value) => !value.endsWith('/'))) {
|
|
150
|
+
try {
|
|
151
|
+
const protectedStat = statSync(resolve(candidate.root, protectedPath));
|
|
152
|
+
if (candidateStat.dev === protectedStat.dev && candidateStat.ino === protectedStat.ino) return true;
|
|
153
|
+
} catch { /* a missing protected target has no physical alias yet */ }
|
|
154
|
+
}
|
|
155
|
+
} catch { /* a missing target is decided by its normalized path */ }
|
|
156
|
+
return false;
|
|
63
157
|
}
|
|
64
158
|
|
|
65
159
|
function isPathAllowed(filePath, projectDir, mode) {
|
|
66
160
|
const scope = MODE_SCOPES[mode];
|
|
67
161
|
if (!scope) return false;
|
|
68
162
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
if (
|
|
163
|
+
const candidate = physicalCandidate(filePath, projectDir);
|
|
164
|
+
if (!candidate) return false;
|
|
165
|
+
if (mode === 'planning') {
|
|
166
|
+
try {
|
|
167
|
+
const stat = lstatSync(candidate.logical);
|
|
168
|
+
if (stat.isSymbolicLink() || stat.nlink !== 1) return false;
|
|
169
|
+
} catch { /* a new file is checked through its pinned existing ancestor */ }
|
|
170
|
+
}
|
|
72
171
|
|
|
73
172
|
if (scope.allowed.includes('*')) return true;
|
|
74
|
-
return scope.allowed.some(prefix =>
|
|
173
|
+
return scope.allowed.some((prefix) =>
|
|
174
|
+
scopedPathMatches(candidate.logicalRel, prefix) && scopedPathMatches(candidate.physicalRel, prefix));
|
|
75
175
|
}
|
|
76
176
|
|
|
77
177
|
async function main() {
|
|
@@ -84,25 +184,32 @@ async function main() {
|
|
|
84
184
|
const event = JSON.parse(input);
|
|
85
185
|
const projectDir = event.cwd || process.cwd();
|
|
86
186
|
const toolInput = event.tool_input || {};
|
|
87
|
-
const filePath = toolInput.file_path || toolInput.path || '';
|
|
187
|
+
const filePath = toolInput.file_path || toolInput.path || toolInput.notebook_path || '';
|
|
88
188
|
|
|
89
189
|
if (!filePath) {
|
|
90
|
-
process.stdout.write(JSON.stringify(
|
|
190
|
+
process.stdout.write(JSON.stringify(denyOutput(
|
|
191
|
+
'[Session Security] Writes are blocked because the mutation target is missing.'
|
|
192
|
+
)));
|
|
91
193
|
return;
|
|
92
194
|
}
|
|
93
195
|
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
if (rel === join('.chati', 'session.yaml') || rel === '.chati/session.yaml') {
|
|
196
|
+
// The lifecycle, installation artifact and installed runtime are controller
|
|
197
|
+
// authority. Provider tool calls may never mutate them directly or through
|
|
198
|
+
// symlink/hard-link aliases. The CLI writes these files outside hook calls.
|
|
199
|
+
if (isProtectedControlPlaneTarget(filePath, projectDir)) {
|
|
99
200
|
process.stdout.write(JSON.stringify(denyOutput(
|
|
100
|
-
'[
|
|
201
|
+
'[Control Plane] This path is managed by the CHATI controller and cannot be edited by a provider tool call.'
|
|
101
202
|
)));
|
|
102
203
|
return;
|
|
103
204
|
}
|
|
104
205
|
|
|
105
206
|
const mode = getCurrentMode(projectDir);
|
|
207
|
+
if (!mode) {
|
|
208
|
+
process.stdout.write(JSON.stringify(denyOutput(
|
|
209
|
+
'[Session Security] Writes are blocked because session.yaml could not be validated.'
|
|
210
|
+
)));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
106
213
|
|
|
107
214
|
if (isPathAllowed(filePath, projectDir, mode)) {
|
|
108
215
|
process.stdout.write(JSON.stringify(allowOutput()));
|
|
@@ -114,9 +221,9 @@ async function main() {
|
|
|
114
221
|
}
|
|
115
222
|
} catch (err) {
|
|
116
223
|
process.stderr.write(`[chati-hook-error] mode-governance: ${err?.message || 'unknown'}\n`);
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
224
|
+
process.stdout.write(JSON.stringify(denyOutput(
|
|
225
|
+
'[Session Security] Writes are blocked because the mode governance hook could not validate its input.'
|
|
226
|
+
)));
|
|
120
227
|
}
|
|
121
228
|
}
|
|
122
229
|
|
|
@@ -139,14 +246,16 @@ function denyOutput(reason) {
|
|
|
139
246
|
};
|
|
140
247
|
}
|
|
141
248
|
|
|
142
|
-
export {
|
|
249
|
+
export {
|
|
250
|
+
getCurrentMode, isPathAllowed, isCanonicalSessionTarget, isProtectedControlPlaneTarget,
|
|
251
|
+
MODE_SCOPES, STATE_TO_GOVERNANCE_MODE, PROTECTED_CONTROL_PLANE_PATHS,
|
|
252
|
+
};
|
|
143
253
|
|
|
144
254
|
// Only run main when executed directly (not imported by tests).
|
|
145
255
|
// realpathSync resolves symlinks on both sides — important on macOS where
|
|
146
256
|
// /tmp is a symlink to /private/tmp. Without realpath the comparison fails
|
|
147
257
|
// silently and main() never runs.
|
|
148
258
|
import { fileURLToPath } from 'url';
|
|
149
|
-
import { realpathSync } from 'fs';
|
|
150
259
|
if (process.argv[1]) {
|
|
151
260
|
try {
|
|
152
261
|
if (realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
* This hook is advisory in IDE mode — it warns but does not block.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import { existsSync
|
|
26
|
+
import { existsSync } from 'fs';
|
|
27
27
|
import { join } from 'path';
|
|
28
|
+
import { readHookSession } from './session-reader.js';
|
|
28
29
|
|
|
29
30
|
const AGENT_MODELS = {
|
|
30
31
|
orchestrator: { provider: 'claude', model: 'sonnet', tier: 'sonnet' },
|
|
@@ -44,22 +45,8 @@ const AGENT_MODELS = {
|
|
|
44
45
|
};
|
|
45
46
|
|
|
46
47
|
function getCurrentAgent(projectDir) {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const raw = readFileSync(sessionPath, 'utf-8');
|
|
51
|
-
const match = raw.match(/^\s*current_agent:\s*(.+)$/m);
|
|
52
|
-
return match ? match[1].trim().replace(/^["']|["']$/g, '') : null;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function getSessionField(projectDir, key) {
|
|
56
|
-
const sessionPath = join(projectDir, '.chati', 'session.yaml');
|
|
57
|
-
if (!existsSync(sessionPath)) return null;
|
|
58
|
-
const raw = readFileSync(sessionPath, 'utf-8');
|
|
59
|
-
const match = raw.match(new RegExp(`^\\s*${key}:\\s*(.+)$`, 'm'));
|
|
60
|
-
if (!match) return null;
|
|
61
|
-
const v = match[1].trim().replace(/^["']|["']$/g, '');
|
|
62
|
-
return v === 'null' || v === '' ? null : v;
|
|
48
|
+
const result = readHookSession(projectDir);
|
|
49
|
+
return result.loaded ? result.session?.current_agent ?? null : null;
|
|
63
50
|
}
|
|
64
51
|
|
|
65
52
|
// Collapse a model id to its family so opus and opus[1m] count as the same
|
|
@@ -91,7 +78,13 @@ async function main() {
|
|
|
91
78
|
return;
|
|
92
79
|
}
|
|
93
80
|
|
|
94
|
-
const
|
|
81
|
+
const sessionRead = readHookSession(projectDir);
|
|
82
|
+
if (!sessionRead.loaded && !sessionRead.missing) {
|
|
83
|
+
process.stderr.write(`[chati] model-governance: unsafe session state: ${sessionRead.error}\n`);
|
|
84
|
+
process.stdout.write('{}');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const agent = sessionRead.session?.current_agent ?? null;
|
|
95
88
|
|
|
96
89
|
// Advisory context injection via the canonical UserPromptSubmit schema.
|
|
97
90
|
const lines = [];
|
|
@@ -108,8 +101,8 @@ async function main() {
|
|
|
108
101
|
// from the model at the last harness audit, advise a re-audit. Advisory
|
|
109
102
|
// only, never blocks; the family comparison ignores variant changes such
|
|
110
103
|
// as opus vs opus[1m].
|
|
111
|
-
const activeModel =
|
|
112
|
-
const modelAtLastAudit =
|
|
104
|
+
const activeModel = sessionRead.session?.active_model ?? null;
|
|
105
|
+
const modelAtLastAudit = sessionRead.session?.model_at_last_audit ?? null;
|
|
113
106
|
if (activeModel && modelAtLastAudit && modelFamily(activeModel) !== modelFamily(modelAtLastAudit)) {
|
|
114
107
|
lines.push(`[Article XXIV] Active model "${activeModel}" differs from the model at the last harness audit ("${modelAtLastAudit}"). Consider running /chati-update to re-audit the harness for this model. This is advisory and does not block your work.`);
|
|
115
108
|
}
|
|
@@ -16,11 +16,12 @@
|
|
|
16
16
|
* cumulative estimation and resolves the window from the active model.
|
|
17
17
|
*
|
|
18
18
|
* Detection chain for the active model (first hit wins):
|
|
19
|
-
* 1.
|
|
20
|
-
* 2.
|
|
21
|
-
* 3. session.
|
|
22
|
-
* 4.
|
|
23
|
-
* 5.
|
|
19
|
+
* 1. session.current_execution_binding.model (v2 canonical route)
|
|
20
|
+
* 2. process.env.CLAUDE_MODEL (legacy Claude Code session)
|
|
21
|
+
* 3. event.session.model (legacy event payload)
|
|
22
|
+
* 4. session.active_model (legacy projection)
|
|
23
|
+
* 5. HOOK_AGENT_MODELS[currentAgent] (legacy last-resort default)
|
|
24
|
+
* 6. null (provider fallback takes over)
|
|
24
25
|
*
|
|
25
26
|
* Context limits come from the shared JSON table at either
|
|
26
27
|
* `.chati.dev/data/model-limits.json` (installed) or
|
|
@@ -32,8 +33,11 @@
|
|
|
32
33
|
* Claude Code Hook: triggers on every user message submission.
|
|
33
34
|
*/
|
|
34
35
|
|
|
35
|
-
import { existsSync, readFileSync,
|
|
36
|
+
import { existsSync, readFileSync, statSync } from 'fs';
|
|
36
37
|
import { join } from 'path';
|
|
38
|
+
import { fileURLToPath } from 'url';
|
|
39
|
+
import { readHookSession } from './session-reader.js';
|
|
40
|
+
import { updateHookContextUsage, updateHookSession } from './session-writer.js';
|
|
37
41
|
|
|
38
42
|
// mtime-keyed read cache. This is a no-op for the per-prompt hook process
|
|
39
43
|
// (it starts fresh each invocation and must read at least once), but it
|
|
@@ -132,81 +136,57 @@ function resolveLimit(limits, modelKey, provider) {
|
|
|
132
136
|
}
|
|
133
137
|
|
|
134
138
|
// ---------------------------------------------------------------------------
|
|
135
|
-
// Session read
|
|
139
|
+
// Session read through the canonical nofollow, bounded and lock-aware reader.
|
|
136
140
|
// ---------------------------------------------------------------------------
|
|
137
141
|
|
|
138
142
|
function readSessionState(projectDir) {
|
|
139
|
-
const
|
|
140
|
-
if (!
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const n = parseInt(v, 10);
|
|
154
|
-
return Number.isFinite(n) ? n : null;
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
// Detect active team (Article XXI)
|
|
158
|
-
let activeTeamId = null;
|
|
159
|
-
let activeTeamPhase = null;
|
|
160
|
-
const teamStatusMatch = raw.match(/teams:[\s\S]*?status:\s*(active|forming)/m);
|
|
161
|
-
if (teamStatusMatch) {
|
|
162
|
-
const teamIdMatch = raw.match(/team_id:\s*(TM-\d{8}-[a-z]{3})/m);
|
|
163
|
-
activeTeamId = teamIdMatch ? teamIdMatch[1] : null;
|
|
164
|
-
const teamPhaseMatch = raw.match(/phase:\s*(planning|build)/m);
|
|
165
|
-
activeTeamPhase = teamPhaseMatch ? teamPhaseMatch[1] : null;
|
|
166
|
-
}
|
|
143
|
+
const sessionRead = readHookSession(projectDir);
|
|
144
|
+
if (!sessionRead.loaded) return null;
|
|
145
|
+
const document = sessionRead.session;
|
|
146
|
+
const binding = document.current_execution_binding && typeof document.current_execution_binding === 'object'
|
|
147
|
+
? document.current_execution_binding
|
|
148
|
+
: {};
|
|
149
|
+
const routedProvider = binding.provider || null;
|
|
150
|
+
const routedModel = binding.model || null;
|
|
151
|
+
const activeTeam = Array.isArray(document.teams)
|
|
152
|
+
? document.teams.find((team) => ['active', 'forming'].includes(team?.status))
|
|
153
|
+
: null;
|
|
154
|
+
const numeric = (value, fallback) => value !== null && value !== undefined && Number.isFinite(Number(value))
|
|
155
|
+
? Number(value)
|
|
156
|
+
: fallback;
|
|
167
157
|
|
|
168
158
|
return {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
activeTeamPhase,
|
|
159
|
+
mode: document.mode || 'discover',
|
|
160
|
+
currentAgent: document.current_agent || null,
|
|
161
|
+
workflow: document.workflow || null,
|
|
162
|
+
pipelinePosition: document.pipeline_position || null,
|
|
163
|
+
provider: routedProvider || document.active_provider || document.provider || null,
|
|
164
|
+
routedProvider,
|
|
165
|
+
routedModel,
|
|
166
|
+
activeModel: document.active_model || null,
|
|
167
|
+
modelAtLastAudit: document.model_at_last_audit || null,
|
|
168
|
+
contextTokensUsed: numeric(document.context_tokens_used, 0),
|
|
169
|
+
contextWindowTokens: numeric(document.context_window_tokens, null),
|
|
170
|
+
activeTeamId: activeTeam?.team_id || null,
|
|
171
|
+
activeTeamPhase: activeTeam?.phase || null,
|
|
183
172
|
};
|
|
184
173
|
}
|
|
185
174
|
|
|
186
175
|
// ---------------------------------------------------------------------------
|
|
187
|
-
// Session
|
|
188
|
-
// Atomic via tmp+rename. Only touches the fields we own.
|
|
176
|
+
// Session writes use only the canonical lock-aware manager below.
|
|
189
177
|
// ---------------------------------------------------------------------------
|
|
190
178
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
} else {
|
|
203
|
-
// Key not present — append at end (migration path).
|
|
204
|
-
out += (out.endsWith('\n') ? '' : '\n') + `${key}: ${serialized}\n`;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
const tmpPath = sessionPath + '.tmp';
|
|
208
|
-
writeFileSync(tmpPath, out, 'utf-8');
|
|
209
|
-
renameSync(tmpPath, sessionPath);
|
|
179
|
+
/**
|
|
180
|
+
* Persist PRISM projections through the hook-local safe writer. Hooks never
|
|
181
|
+
* import the project runtime: a provider that can alter .chati.dev must not
|
|
182
|
+
* execute code before the hook's governance decision completes.
|
|
183
|
+
*/
|
|
184
|
+
async function persistPrismSession(projectDir, updates) {
|
|
185
|
+
return updateHookSession(projectDir, updates);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function persistPrismUsage(projectDir, usage) {
|
|
189
|
+
return updateHookContextUsage(projectDir, usage);
|
|
210
190
|
}
|
|
211
191
|
|
|
212
192
|
// ---------------------------------------------------------------------------
|
|
@@ -221,20 +201,25 @@ const HOOK_AGENT_MODELS = {
|
|
|
221
201
|
};
|
|
222
202
|
|
|
223
203
|
function detectModel(event, session) {
|
|
224
|
-
// 1.
|
|
204
|
+
// 1. V2 canonical route. The opened harness is not the execution provider.
|
|
205
|
+
if (session && session.routedModel) {
|
|
206
|
+
const parsed = parseClaudeModelId(session.routedModel);
|
|
207
|
+
return parsed ? parsed.normalized : session.routedModel;
|
|
208
|
+
}
|
|
209
|
+
// 2. Legacy environment override from Claude Code or the user.
|
|
225
210
|
if (process.env.CLAUDE_MODEL) {
|
|
226
211
|
const parsed = parseClaudeModelId(process.env.CLAUDE_MODEL);
|
|
227
212
|
if (parsed) return parsed.normalized;
|
|
228
213
|
return process.env.CLAUDE_MODEL; // trust the raw string
|
|
229
214
|
}
|
|
230
|
-
//
|
|
215
|
+
// 3. Legacy event payload.
|
|
231
216
|
if (event && event.session && event.session.model) {
|
|
232
217
|
const parsed = parseClaudeModelId(event.session.model);
|
|
233
218
|
if (parsed) return parsed.normalized;
|
|
234
219
|
}
|
|
235
|
-
//
|
|
220
|
+
// 4. Legacy session projection.
|
|
236
221
|
if (session && session.activeModel) return session.activeModel;
|
|
237
|
-
//
|
|
222
|
+
// 5. Legacy agent default.
|
|
238
223
|
if (session && session.currentAgent && HOOK_AGENT_MODELS[session.currentAgent]) {
|
|
239
224
|
return HOOK_AGENT_MODELS[session.currentAgent];
|
|
240
225
|
}
|
|
@@ -307,32 +292,29 @@ async function main() {
|
|
|
307
292
|
|
|
308
293
|
// 1. Model + window detection.
|
|
309
294
|
const detectedModel = detectModel(event, session);
|
|
310
|
-
const contextLimit = resolveLimit(limits, detectedModel, session.provider);
|
|
295
|
+
const contextLimit = resolveLimit(limits, detectedModel, session.routedProvider || session.provider);
|
|
311
296
|
|
|
312
297
|
// 2. Token accounting — cumulative, replaces turn-count.
|
|
313
298
|
const promptText = event.prompt || '';
|
|
314
299
|
const delta = estimatePromptDelta(promptText);
|
|
315
|
-
|
|
316
|
-
|
|
300
|
+
let tokensUsed = Math.min(contextLimit, session.contextTokensUsed + delta);
|
|
301
|
+
let remainingPercent = Math.max(0, Math.round((1 - tokensUsed / contextLimit) * 100));
|
|
317
302
|
|
|
318
303
|
// 3. Bracket computation (percentage-based, model-scaled).
|
|
319
|
-
|
|
304
|
+
let bracket = computeBracket(remainingPercent);
|
|
320
305
|
|
|
321
306
|
// 4. Persist back to session.yaml (best-effort, non-fatal on write error).
|
|
322
307
|
try {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
...(detectedModel && !session.activeModel ? { active_model: detectedModel } : {}),
|
|
329
|
-
// Seed the harness-audit baseline once (Article XXIV). Only when unset,
|
|
330
|
-
// so a later model change leaves the old baseline in place for the
|
|
331
|
-
// model-governance advisory to detect.
|
|
332
|
-
...((detectedModel || session.activeModel) && !session.modelAtLastAudit
|
|
333
|
-
? { model_at_last_audit: detectedModel || session.activeModel }
|
|
334
|
-
: {}),
|
|
308
|
+
const persisted = await persistPrismUsage(projectDir, {
|
|
309
|
+
delta,
|
|
310
|
+
contextWindowTokens: contextLimit,
|
|
311
|
+
detectedModel,
|
|
312
|
+
routedProvider: session.routedProvider,
|
|
335
313
|
});
|
|
314
|
+
if (!persisted.saved) throw new Error(persisted.error || 'session update failed');
|
|
315
|
+
tokensUsed = persisted.tokensUsed;
|
|
316
|
+
remainingPercent = Math.max(0, Math.round((1 - tokensUsed / contextLimit) * 100));
|
|
317
|
+
bracket = persisted.bracket || computeBracket(remainingPercent);
|
|
336
318
|
} catch (err) {
|
|
337
319
|
process.stderr.write(`[chati] prism-engine write-back failed (non-fatal): ${err.message}\n`);
|
|
338
320
|
}
|
|
@@ -442,12 +424,12 @@ export {
|
|
|
442
424
|
loadLimits,
|
|
443
425
|
estimatePromptDelta,
|
|
444
426
|
computeBracket,
|
|
445
|
-
|
|
427
|
+
persistPrismSession,
|
|
428
|
+
persistPrismUsage,
|
|
446
429
|
TOOL_OUTPUT_MULTIPLIER,
|
|
447
430
|
};
|
|
448
431
|
|
|
449
432
|
// Only run main when executed directly (not imported by tests).
|
|
450
|
-
import { fileURLToPath } from 'url';
|
|
451
433
|
import { realpathSync } from 'fs';
|
|
452
434
|
if (process.argv[1]) {
|
|
453
435
|
try {
|