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.
Files changed (122) hide show
  1. package/README.md +8 -3
  2. package/bin/chati.js +124 -66
  3. package/framework/agents/build/dev.md +5 -5
  4. package/framework/agents/deploy/devops.md +7 -7
  5. package/framework/agents/discover/brief.md +4 -4
  6. package/framework/agents/discover/brownfield-wu.md +3 -3
  7. package/framework/agents/discover/greenfield-wu.md +3 -3
  8. package/framework/agents/plan/architect.md +2 -2
  9. package/framework/agents/plan/detail.md +4 -4
  10. package/framework/agents/plan/phases.md +2 -2
  11. package/framework/agents/plan/tasks.md +2 -2
  12. package/framework/agents/plan/ux.md +2 -2
  13. package/framework/agents/quality/qa-implementation.md +11 -10
  14. package/framework/agents/quality/qa-planning.md +3 -3
  15. package/framework/agents/quality/qa-visual.md +1 -1
  16. package/framework/config.yaml +3 -3
  17. package/framework/constitution.md +18 -18
  18. package/framework/context/protocols.md +1 -1
  19. package/framework/context/quality.md +1 -1
  20. package/framework/context/root.md +4 -4
  21. package/framework/data/entity-registry.yaml +1 -1
  22. package/framework/domains/agents/orchestrator.yaml +2 -2
  23. package/framework/domains/constitution.yaml +1 -1
  24. package/framework/hooks/advance-trigger.js +4 -6
  25. package/framework/hooks/git-push-authority.js +45 -37
  26. package/framework/hooks/mode-governance.js +149 -40
  27. package/framework/hooks/model-governance.js +13 -20
  28. package/framework/hooks/prism-engine.js +74 -92
  29. package/framework/hooks/reasoning-escalator.js +23 -40
  30. package/framework/hooks/session-digest.js +12 -13
  31. package/framework/hooks/session-reader.js +224 -0
  32. package/framework/hooks/session-writer.js +195 -0
  33. package/framework/hooks/team-quality-gate.js +34 -24
  34. package/framework/i18n/en.yaml +2 -2
  35. package/framework/i18n/es.yaml +2 -2
  36. package/framework/i18n/fr.yaml +2 -2
  37. package/framework/i18n/pt.yaml +2 -2
  38. package/framework/intelligence/context-engine.md +4 -4
  39. package/framework/intelligence/memory-layer.md +1 -1
  40. package/framework/manifest.json +129 -119
  41. package/framework/manifest.sig +1 -1
  42. package/framework/orchestrator/chati-router.js +252 -25
  43. package/framework/orchestrator/chati.md +238 -70
  44. package/framework/schemas/session.schema.json +1 -1
  45. package/framework/tasks/orchestrator-deviation.md +1 -1
  46. package/framework/tasks/orchestrator-escalate.md +1 -1
  47. package/framework/tasks/orchestrator-handoff.md +6 -6
  48. package/framework/tasks/orchestrator-health.md +5 -9
  49. package/framework/tasks/orchestrator-mode-switch.md +3 -7
  50. package/framework/tasks/orchestrator-resume.md +10 -14
  51. package/framework/tasks/orchestrator-route.md +3 -3
  52. package/framework/tasks/orchestrator-spawn-terminal.md +1 -1
  53. package/framework/tasks/orchestrator-status.md +9 -9
  54. package/framework/tasks/orchestrator-suggest-mode.md +1 -1
  55. package/framework/tasks/qa-impl-consolidate.md +2 -2
  56. package/framework/tasks/qa-impl-performance-test.md +4 -4
  57. package/framework/tasks/qa-impl-regression-check.md +4 -4
  58. package/framework/tasks/qa-impl-sast-scan.md +1 -1
  59. package/framework/tasks/qa-impl-test-execute.md +1 -1
  60. package/framework/tasks/qa-impl-verdict.md +2 -2
  61. package/framework/tasks/qa-planning-consolidate.md +3 -3
  62. package/framework/tasks/qa-planning-coverage-plan.md +2 -2
  63. package/framework/tasks/qa-planning-gate-define.md +4 -4
  64. package/framework/tasks/qa-planning-risk-matrix.md +4 -4
  65. package/framework/tasks/qa-planning-test-strategy.md +2 -2
  66. package/node_modules/@chati/browser-capability/src/index.js +12 -2
  67. package/node_modules/@chati/planning/src/index.js +24 -4
  68. package/node_modules/@chati/provider-registry/src/index.js +11 -0
  69. package/node_modules/@chati/rail/src/index.js +1967 -83
  70. package/node_modules/@chati/release-lane/README.md +12 -8
  71. package/node_modules/@chati/release-lane/package.json +1 -1
  72. package/node_modules/@chati/release-lane/src/index.js +1426 -58
  73. package/node_modules/@chati/review-council/src/index.js +63 -0
  74. package/node_modules/@chati/tracking-clickup/README.md +13 -0
  75. package/node_modules/@chati/tracking-clickup/src/index.js +690 -30
  76. package/package-artifact-manifest.json +1 -0
  77. package/package-artifact-manifest.sig +1 -0
  78. package/package.json +16 -7
  79. package/scripts/verify-real-harness-e2e.js +1581 -0
  80. package/src/config/framework-adapter.js +4 -4
  81. package/src/installer/core.js +148 -53
  82. package/src/installer/manifest.js +140 -9
  83. package/src/installer/package-artifact.js +249 -0
  84. package/src/installer/templates.js +65 -20
  85. package/src/installer-v2/catalog-client.js +531 -23
  86. package/src/installer-v2/index.js +91 -34
  87. package/src/installer-v2/installation-authority.js +327 -0
  88. package/src/installer-v2/provider-executable.js +235 -0
  89. package/src/installer-v2/wizard-installation.js +1 -1
  90. package/src/orchestrator/browser-runtime.js +44 -13
  91. package/src/orchestrator/cli.js +1545 -174
  92. package/src/orchestrator/clickup-projection.js +43 -1
  93. package/src/orchestrator/clickup-runtime.js +355 -39
  94. package/src/orchestrator/doctor.js +16 -3
  95. package/src/orchestrator/index.js +16 -0
  96. package/src/orchestrator/planning-runtime.js +20 -2
  97. package/src/orchestrator/rail-adjudication-evidence.js +234 -0
  98. package/src/orchestrator/rail-evidence-authority.js +147 -0
  99. package/src/orchestrator/rail-execution-evidence.js +45 -0
  100. package/src/orchestrator/rail-runtime.js +836 -56
  101. package/src/orchestrator/release-runtime.js +65 -6
  102. package/src/orchestrator/review-runtime.js +186 -41
  103. package/src/orchestrator/runtime-installation-v2.js +236 -20
  104. package/src/orchestrator/session-manager.js +1167 -53
  105. package/src/terminal/adapters/claude-adapter.js +3 -1
  106. package/src/terminal/adapters/codex-adapter.js +2 -0
  107. package/src/terminal/adapters/grok-adapter.js +7 -4
  108. package/src/terminal/handoff-parser.js +19 -1
  109. package/src/terminal/prompt-builder.js +9 -1
  110. package/src/terminal/provider-preflight.js +36 -3
  111. package/src/terminal/rail-execution-worktree.js +213 -0
  112. package/src/terminal/rail-prompts.js +169 -0
  113. package/src/terminal/rail-readonly-workspace.js +324 -0
  114. package/src/terminal/run-agent.js +384 -24
  115. package/src/terminal/run-parallel.js +5 -0
  116. package/src/terminal/run-rail-adjudication.js +323 -0
  117. package/src/terminal/run-rail-review.js +291 -0
  118. package/src/terminal/run-rail-rework.js +325 -0
  119. package/src/terminal/run-rail-task.js +380 -0
  120. package/src/terminal/run-team.js +5 -0
  121. package/src/terminal/spawner.js +1225 -77
  122. package/src/wizard/index.js +3 -2
@@ -10,13 +10,18 @@
10
10
  * Constitution Article XI enforcement.
11
11
  */
12
12
 
13
- import { existsSync, readFileSync } from 'fs';
14
- import { join, relative, isAbsolute } from 'path';
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/', '.chati.dev/', '.chati/', 'artifacts/', 'CLAUDE.local.md', 'CLAUDE.md', '.claude/'],
19
- description: 'Planning mode: write only to framework dir, .chati/, artifacts/, and Claude config files',
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
- function getCurrentMode(projectDir) {
43
- const sessionPath = join(projectDir, '.chati', 'session.yaml');
44
- // No session = no enforcement yet (orchestrator will set mode on first run).
45
- // Defaulting to planning here would deadlock fresh installs (planning blocks
46
- // session.yaml writes, so the orchestrator could never bootstrap).
47
- if (!existsSync(sessionPath)) return 'build';
48
-
49
- const raw = readFileSync(sessionPath, 'utf-8');
50
- // Prefer explicit mode field; fall back to project.state mapping.
51
- const modeMatch = raw.match(/^\s*mode:\s*(.+)$/m);
52
- if (modeMatch) {
53
- const mode = modeMatch[1].trim().replace(/^["']|["']$/g, '');
54
- if (MODE_SCOPES[mode]) return mode;
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
- const stateMatch = raw.match(/^\s+state:\s*(.+)$/m);
57
- if (stateMatch) {
58
- const state = stateMatch[1].trim().replace(/^["']|["']$/g, '');
59
- return STATE_TO_GOVERNANCE_MODE[state] || 'build';
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
- // Session exists but no mode/state field — be permissive.
62
- return 'build';
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 rel = isAbsolute(filePath) ? relative(projectDir, filePath) : filePath;
70
- // Block paths that escape the project — regardless of mode
71
- if (rel.startsWith('..')) return false;
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 => rel.startsWith(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(allowOutput()));
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
- // Block direct edits to session.yaml it must be managed by CLI only.
95
- // CLI uses fs.writeFileSync (not intercepted by hooks), so this only blocks
96
- // Claude's Edit/Write tool calls, which is exactly what we want.
97
- const rel = isAbsolute(filePath) ? relative(projectDir, filePath) : filePath;
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
- '[Article XV] session.yaml is managed by the CLI. Use the router: node chati.dev/orchestrator/chati-router.js advance --agent {name} --score {score}'
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
- // Hook errors are fail-OPEN: a broken hook should not block the user.
118
- // Security-critical denials are handled by explicit logic above.
119
- process.stdout.write(JSON.stringify(allowOutput()));
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 { getCurrentMode, isPathAllowed, MODE_SCOPES, STATE_TO_GOVERNANCE_MODE };
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, readFileSync } from 'fs';
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 sessionPath = join(projectDir, '.chati', 'session.yaml');
48
- if (!existsSync(sessionPath)) return null;
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 agent = getCurrentAgent(projectDir);
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 = getSessionField(projectDir, 'active_model');
112
- const modelAtLastAudit = getSessionField(projectDir, 'model_at_last_audit');
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. process.env.CLAUDE_MODEL (Claude Code set)
20
- * 2. event.session.model (future-proof for event payload)
21
- * 3. session.active_model (written by /chati boot)
22
- * 4. HOOK_AGENT_MODELS[currentAgent] (last-resort default)
23
- * 5. null (provider fallback takes over)
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, writeFileSync, renameSync, statSync } from 'fs';
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 lightweight YAML extraction (avoids js-yaml dependency).
139
+ // Session read through the canonical nofollow, bounded and lock-aware reader.
136
140
  // ---------------------------------------------------------------------------
137
141
 
138
142
  function readSessionState(projectDir) {
139
- const sessionPath = join(projectDir, '.chati', 'session.yaml');
140
- if (!existsSync(sessionPath)) return null;
141
-
142
- const raw = readFileCached(sessionPath);
143
- if (raw == null) return null;
144
- const extract = (key) => {
145
- const match = raw.match(new RegExp(`^\\s*${key}:\\s*(.+)$`, 'm'));
146
- if (!match) return null;
147
- const v = match[1].trim().replace(/^["']|["']$/g, '');
148
- return v === 'null' || v === '' ? null : v;
149
- };
150
- const extractNum = (key) => {
151
- const v = extract(key);
152
- if (v === null) return null;
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
- raw,
170
- sessionPath,
171
- mode: extract('mode') || 'discover',
172
- currentAgent: extract('current_agent') || null,
173
- workflow: extract('workflow') || null,
174
- pipelinePosition: extract('pipeline_position') || null,
175
- provider: extract('active_provider') || extract('provider') || null,
176
- // v1.2 context detection fields.
177
- activeModel: extract('active_model'),
178
- modelAtLastAudit: extract('model_at_last_audit'),
179
- contextTokensUsed: extractNum('context_tokens_used') || 0,
180
- contextWindowTokens: extractNum('context_window_tokens'),
181
- activeTeamId,
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 write targeted line replacement on session.yaml.
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
- function writeBackSession(sessionPath, rawContent, updates) {
192
- let out = rawContent;
193
- for (const [key, value] of Object.entries(updates)) {
194
- const serialized =
195
- value === null || value === undefined ? 'null' :
196
- typeof value === 'number' ? String(value) :
197
- typeof value === 'string' ? value :
198
- JSON.stringify(value);
199
- const re = new RegExp(`^(\\s*${key}:\\s*)(.*)$`, 'm');
200
- if (re.test(out)) {
201
- out = out.replace(re, `$1${serialized}`);
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. Environment override Claude Code or user-set.
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
- // 2. Event payload (future-proof — Claude Code may populate this).
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
- // 3. Session-recorded active model — written at /chati boot.
220
+ // 4. Legacy session projection.
236
221
  if (session && session.activeModel) return session.activeModel;
237
- // 4. Agent default (last resort, matches historic behavior).
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
- const tokensUsed = Math.min(contextLimit, session.contextTokensUsed + delta);
316
- const remainingPercent = Math.max(0, Math.round((1 - tokensUsed / contextLimit) * 100));
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
- const bracket = computeBracket(remainingPercent);
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
- writeBackSession(session.sessionPath, session.raw, {
324
- context_tokens_used: tokensUsed,
325
- context_window_tokens: contextLimit,
326
- context_last_bracket: bracket,
327
- // If we detected a model and the session did not have one, cache it.
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
- writeBackSession,
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 {