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
@@ -19,9 +19,12 @@
19
19
  * mutation — no js-yaml dependency).
20
20
  */
21
21
 
22
- import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
22
+ import { existsSync, readdirSync, statSync } from 'fs';
23
23
  import { join, isAbsolute, relative } from 'path';
24
24
  import { execSync } from 'child_process';
25
+ import { fileURLToPath } from 'url';
26
+ import { readHookSession } from './session-reader.js';
27
+ import { updateHookSession } from './session-writer.js';
25
28
 
26
29
  // ---------------------------------------------------------------------------
27
30
  // Pure helpers (exported for testing)
@@ -217,56 +220,36 @@ export function checkDeliberatePlanRequirement(input) {
217
220
  // ---------------------------------------------------------------------------
218
221
 
219
222
  /**
220
- * Parse the reasoning_tier and started_at fields out of session.yaml using
221
- * regex. Stays dependency-free (hooks do not import js-yaml).
223
+ * Read reasoning state through the canonical safe session boundary.
222
224
  */
223
225
  export function readSessionTier(projectDir) {
224
- const sessionPath = join(projectDir, '.chati', 'session.yaml');
225
- if (!existsSync(sessionPath)) return { tier: 'standard', startedAt: 0, raw: null };
226
- let raw = '';
227
- try { raw = readFileSync(sessionPath, 'utf-8'); } catch { return { tier: 'standard', startedAt: 0, raw: null }; }
228
- const tierMatch = raw.match(/^\s*reasoning_tier:\s*(["']?)(\w+)\1\s*$/m);
229
- const startedMatch = raw.match(/^\s*started_at:\s*(["']?)([^"'\n]+)\1\s*$/m);
230
- let startedAt = 0;
231
- if (startedMatch) {
232
- const t = Date.parse(startedMatch[2]);
233
- if (!Number.isNaN(t)) startedAt = t;
234
- }
235
- const tier = tierMatch ? tierMatch[2] : 'standard';
236
- return { tier: TIERS.includes(tier) ? tier : 'standard', startedAt, raw };
226
+ const sessionRead = readHookSession(projectDir);
227
+ if (!sessionRead.loaded) return { tier: 'standard', startedAt: 0, raw: null };
228
+ const tier = TIERS.includes(sessionRead.session.reasoning_tier)
229
+ ? sessionRead.session.reasoning_tier
230
+ : 'standard';
231
+ const started = sessionRead.session.started_at;
232
+ const parsed = started instanceof Date ? started.getTime() : Date.parse(started);
233
+ return { tier, startedAt: Number.isNaN(parsed) ? 0 : parsed, raw: null };
237
234
  }
238
235
 
239
236
  /**
240
- * Parse session.language out of session.yaml. Defaults to 'en' if the key is
241
- * missing or unreadable. Kept regex-based to avoid a js-yaml dep in hooks.
237
+ * Read session language through the canonical safe session boundary.
242
238
  */
243
239
  export function readSessionLanguage(projectDir) {
244
- const sessionPath = join(projectDir, '.chati', 'session.yaml');
245
- if (!existsSync(sessionPath)) return 'en';
246
- let raw = '';
247
- try { raw = readFileSync(sessionPath, 'utf-8'); } catch { return 'en'; }
248
- const m = raw.match(/^\s*language:\s*(["']?)([a-z]{2})\1\s*$/m);
249
- return m ? m[2] : 'en';
240
+ const sessionRead = readHookSession(projectDir);
241
+ const language = sessionRead.loaded ? sessionRead.session.language : null;
242
+ return typeof language === 'string' && /^[a-z]{2}$/.test(language) ? language : 'en';
250
243
  }
251
244
 
252
245
  /**
253
- * Update session.yaml with a new reasoning_tier value. Append `reasoning_tier:`
254
- * if missing; replace in place if present. Silent on failure.
246
+ * Update session.yaml through the hook-local safe writer. An installed hook
247
+ * must not dynamically import the mutable project runtime just to persist its
248
+ * own scalar state.
255
249
  */
256
- export function writeSessionTier(projectDir, tier) {
250
+ export async function writeSessionTier(projectDir, tier) {
257
251
  if (!TIERS.includes(tier)) return false;
258
- const sessionPath = join(projectDir, '.chati', 'session.yaml');
259
- if (!existsSync(sessionPath)) return false;
260
- let raw = '';
261
- try { raw = readFileSync(sessionPath, 'utf-8'); } catch { return false; }
262
- const line = `reasoning_tier: ${tier}`;
263
- let next;
264
- if (/^\s*reasoning_tier:\s*\w+\s*$/m.test(raw)) {
265
- next = raw.replace(/^\s*reasoning_tier:\s*\w+\s*$/m, line);
266
- } else {
267
- next = raw.trimEnd() + '\n' + line + '\n';
268
- }
269
- try { writeFileSync(sessionPath, next, 'utf-8'); return true; } catch { return false; }
252
+ return updateHookSession(projectDir, { reasoning_tier: tier }).saved === true;
270
253
  }
271
254
 
272
255
  /**
@@ -335,7 +318,7 @@ async function main() {
335
318
  language,
336
319
  });
337
320
  if (result.tier !== currentTier) {
338
- writeSessionTier(projectDir, result.tier);
321
+ await writeSessionTier(projectDir, result.tier);
339
322
  }
340
323
  process.stdout.write(JSON.stringify(userPromptPassthrough()));
341
324
  return;
@@ -9,28 +9,27 @@
9
9
  import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, chmodSync } from 'fs';
10
10
  import { createHash } from 'crypto';
11
11
  import { join } from 'path';
12
+ import { readHookSession } from './session-reader.js';
12
13
 
13
14
  /**
14
15
  * Extract structured 8-section digest from session state.
15
16
  * Enriched version captures decisions, blockers, gotchas for better recovery.
16
17
  */
17
18
  function extractDigest(projectDir) {
18
- const sessionPath = join(projectDir, '.chati', 'session.yaml');
19
- if (!existsSync(sessionPath)) return null;
20
-
21
- const raw = readFileSync(sessionPath, 'utf-8');
22
- const extract = (key) => {
23
- const match = raw.match(new RegExp(`^\\s*${key}:\\s*(.+)$`, 'm'));
24
- return match ? match[1].trim().replace(/^["']|["']$/g, '') : null;
25
- };
19
+ const sessionRead = readHookSession(projectDir);
20
+ if (!sessionRead.loaded) return null;
21
+ const session = sessionRead.session;
22
+ const scalar = (key) => ['string', 'number', 'boolean'].includes(typeof session[key])
23
+ ? String(session[key])
24
+ : null;
26
25
 
27
26
  // Base digest (backward-compatible)
28
27
  const base = {
29
28
  timestamp: new Date().toISOString(),
30
- mode: extract('mode') || 'discover',
31
- currentAgent: extract('current_agent') || 'none',
32
- pipelinePosition: extract('pipeline_position') || 'unknown',
33
- workflow: extract('workflow') || 'unknown',
29
+ mode: scalar('mode') || 'discover',
30
+ currentAgent: scalar('current_agent') || 'none',
31
+ pipelinePosition: scalar('pipeline_position') || 'unknown',
32
+ workflow: scalar('workflow') || 'unknown',
34
33
  };
35
34
 
36
35
  // Enrich with gotchas (lightweight read, capped at 10)
@@ -52,7 +51,7 @@ function extractDigest(projectDir) {
52
51
  ...base,
53
52
  version: '2.0',
54
53
  // Structured sections (enriched)
55
- active_task: extract('active_task') || null,
54
+ active_task: scalar('active_task') || null,
56
55
  blockers: [], // Populated by agents at runtime
57
56
  decisions_made: [], // Populated by agents at runtime
58
57
  gotchas_found: gotchas,
@@ -0,0 +1,224 @@
1
+ import {
2
+ closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync,
3
+ } from 'node:fs';
4
+ import { dirname, join, resolve } from 'node:path';
5
+
6
+ // Hooks are trusted by the host lifecycle, but the project runtime is not a
7
+ // trust root. In particular, a hook must never import .chati.dev/_cli just to
8
+ // read session state: a provider that can replace that runtime could execute
9
+ // arbitrary code before a governance decision is made.
10
+ const SESSION_MAX_BYTES = 4 * 1024 * 1024;
11
+ const SESSION_STABILITY_ATTEMPTS = 3;
12
+ const SESSION_STABILITY_RETRY_MS = 5;
13
+
14
+ function sameSnapshot(left, right) {
15
+ return Boolean(left && right
16
+ && left.dev === right.dev
17
+ && left.ino === right.ino
18
+ && left.mode === right.mode
19
+ && left.uid === right.uid
20
+ && left.gid === right.gid
21
+ && left.nlink === right.nlink
22
+ && left.size === right.size
23
+ && left.mtimeMs === right.mtimeMs
24
+ && left.ctimeMs === right.ctimeMs);
25
+ }
26
+
27
+ function readBounded(descriptor) {
28
+ const buffer = Buffer.allocUnsafe(SESSION_MAX_BYTES + 1);
29
+ let offset = 0;
30
+ while (offset < buffer.length) {
31
+ const bytes = readSync(descriptor, buffer, offset, buffer.length - offset, null);
32
+ if (bytes === 0) break;
33
+ offset += bytes;
34
+ }
35
+ if (offset > SESSION_MAX_BYTES) throw new Error('session.yaml exceeds the 4 MiB safety limit');
36
+ return buffer.subarray(0, offset).toString('utf8');
37
+ }
38
+
39
+ function withoutYamlComment(value) {
40
+ let quote = null;
41
+ for (let index = 0; index < value.length; index += 1) {
42
+ const char = value[index];
43
+ if (quote) {
44
+ if (char === '\\' && quote === '"') { index += 1; continue; }
45
+ if (char === quote) quote = null;
46
+ continue;
47
+ }
48
+ if (char === '"' || char === "'") { quote = char; continue; }
49
+ if (char === '#' && (index === 0 || /\s/.test(value[index - 1]))) return value.slice(0, index).trimEnd();
50
+ }
51
+ if (quote) throw new Error('session.yaml has an unterminated scalar quote');
52
+ return value.trimEnd();
53
+ }
54
+
55
+ function scalar(value) {
56
+ const source = withoutYamlComment(String(value || '').trim());
57
+ if (source === '') return '';
58
+ if (source === 'null' || source === '~') return null;
59
+ if (source === 'true') return true;
60
+ if (source === 'false') return false;
61
+ if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(source)) return Number(source);
62
+ if (source === '[]') return [];
63
+ if (source === '{}') return {};
64
+ if (source.startsWith('"')) {
65
+ try { return JSON.parse(source); }
66
+ catch { throw new Error('session.yaml has an invalid quoted scalar'); }
67
+ }
68
+ if (source.startsWith("'")) {
69
+ if (!source.endsWith("'")) throw new Error('session.yaml has an invalid quoted scalar');
70
+ return source.slice(1, -1).replaceAll("''", "'");
71
+ }
72
+ if (/^[\[{]/.test(source) || /[\]}]$/.test(source)) {
73
+ throw new Error('session.yaml uses an unsupported flow collection');
74
+ }
75
+ return source;
76
+ }
77
+
78
+ function mapping(line, label) {
79
+ const match = line.match(/^([A-Za-z_][\w-]*):(?:\s+(.*))?\s*$/);
80
+ if (!match) throw new Error(`session.yaml has an invalid ${label} mapping`);
81
+ if (['__proto__', 'constructor', 'prototype'].includes(match[1])) {
82
+ throw new Error(`session.yaml has an unsafe ${label} mapping key`);
83
+ }
84
+ return { key: match[1], value: match[2] === undefined ? undefined : scalar(match[2]) };
85
+ }
86
+
87
+ function assignOnce(target, key, value, label) {
88
+ if (Object.hasOwn(target, key)) throw new Error(`session.yaml has a duplicate ${label} key: ${key}`);
89
+ Object.defineProperty(target, key, {
90
+ configurable: false,
91
+ enumerable: true,
92
+ value,
93
+ writable: true,
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Read only the session projection used by lifecycle hooks. The generated
99
+ * document is YAML, but hooks do not need a general YAML implementation. A
100
+ * deliberately small data-only decoder eliminates dynamic imports from the
101
+ * project while supporting the canonical scalar, nested-map and team-list
102
+ * shapes. Unsupported syntax fails closed rather than being interpreted.
103
+ */
104
+ export function parseHookSessionProjection(raw) {
105
+ if (typeof raw !== 'string' || raw.includes('\t')) throw new Error('session.yaml uses unsupported indentation');
106
+ const session = {};
107
+ const rootKeys = new Set();
108
+ let section = null;
109
+ let team = null;
110
+ const lines = raw.replace(/^\uFEFF/, '').split(/\r?\n/);
111
+
112
+ for (const original of lines) {
113
+ if (!original.trim() || /^\s*#/.test(original) || /^\s*(---|\.\.\.)\s*$/.test(original)) continue;
114
+ const indent = original.match(/^ */)[0].length;
115
+ const text = original.slice(indent);
116
+ if (indent === 0) {
117
+ const entry = mapping(text, 'root');
118
+ if (rootKeys.has(entry.key)) throw new Error(`session.yaml has a duplicate root key: ${entry.key}`);
119
+ rootKeys.add(entry.key);
120
+ section = entry.value === undefined ? entry.key : null;
121
+ team = null;
122
+ if (entry.value !== undefined) assignOnce(session, entry.key, entry.value, 'root');
123
+ continue;
124
+ }
125
+
126
+ if (section === 'project' || section === 'current_execution_binding') {
127
+ if (indent !== 2) throw new Error(`session.yaml has invalid ${section} indentation`);
128
+ const entry = mapping(text, section);
129
+ if (entry.value === undefined) throw new Error(`session.yaml has an unsupported nested ${section} value`);
130
+ if (!Object.hasOwn(session, section)) assignOnce(session, section, {}, section);
131
+ assignOnce(session[section], entry.key, entry.value, section);
132
+ continue;
133
+ }
134
+
135
+ if (section === 'teams') {
136
+ if (indent === 2 && text.startsWith('- ')) {
137
+ const entry = mapping(text.slice(2), 'team');
138
+ if (entry.value === undefined) throw new Error('session.yaml has an unsupported nested team value');
139
+ if (!Array.isArray(session.teams)) session.teams = [];
140
+ team = {};
141
+ assignOnce(team, entry.key, entry.value, 'team');
142
+ session.teams.push(team);
143
+ continue;
144
+ }
145
+ if (indent === 4 && team) {
146
+ const entry = mapping(text, 'team');
147
+ if (entry.value === undefined) throw new Error('session.yaml has an unsupported nested team value');
148
+ assignOnce(team, entry.key, entry.value, 'team');
149
+ continue;
150
+ }
151
+ throw new Error('session.yaml has invalid teams indentation');
152
+ }
153
+ // Other session sections are not authority inputs for a hook. Ignore their
154
+ // data-only YAML subtree while the root-level state remains pinned below.
155
+ }
156
+ return Object.freeze(session);
157
+ }
158
+
159
+ function readPinnedSession(projectDir) {
160
+ const root = realpathSync(resolve(projectDir));
161
+ const sessionDir = join(root, '.chati');
162
+ const sessionDirStat = lstatSync(sessionDir);
163
+ if (!sessionDirStat.isDirectory() || sessionDirStat.isSymbolicLink() || realpathSync(sessionDir) !== sessionDir || dirname(sessionDir) !== root) {
164
+ throw new Error('The .chati directory must be a real directory inside the project');
165
+ }
166
+ let directory;
167
+ let session;
168
+ try {
169
+ directory = openSync(sessionDir, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
170
+ const openedDirectory = fstatSync(directory);
171
+ if (!openedDirectory.isDirectory() || openedDirectory.dev !== sessionDirStat.dev || openedDirectory.ino !== sessionDirStat.ino) {
172
+ throw new Error('The .chati directory changed during the session read');
173
+ }
174
+ // On Linux, continue through the pinned directory descriptor. An attacker
175
+ // cannot redirect this open by replacing the .chati ancestor after it was
176
+ // validated. Other platforms retain the same fail-closed snapshots below.
177
+ const pinnedDirectoryPath = process.platform === 'linux' ? `/proc/self/fd/${directory}` : sessionDir;
178
+ const pinnedSessionPath = join(pinnedDirectoryPath, 'session.yaml');
179
+ const path = join(sessionDir, 'session.yaml');
180
+ session = openSync(pinnedSessionPath, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
181
+ const before = fstatSync(session);
182
+ if (!before.isFile() || before.nlink !== 1 || before.size > SESSION_MAX_BYTES) {
183
+ throw new Error('session.yaml must be a bounded single-link regular file');
184
+ }
185
+ const raw = readBounded(session);
186
+ const after = fstatSync(session);
187
+ const pathAfter = lstatSync(pinnedSessionPath);
188
+ const directoryAfter = fstatSync(directory);
189
+ if (!sameSnapshot(before, after) || !sameSnapshot(before, pathAfter)
190
+ || !sameSnapshot(openedDirectory, directoryAfter)) {
191
+ throw new Error('session.yaml changed during the session read');
192
+ }
193
+ return { session: parseHookSessionProjection(raw), path };
194
+ } finally {
195
+ if (session !== undefined) closeSync(session);
196
+ if (directory !== undefined) closeSync(directory);
197
+ }
198
+ }
199
+
200
+ export function readHookSession(projectDir) {
201
+ let path;
202
+ let error;
203
+ try {
204
+ for (let attempt = 0; attempt < SESSION_STABILITY_ATTEMPTS; attempt += 1) {
205
+ try {
206
+ const result = readPinnedSession(projectDir);
207
+ return { loaded: true, missing: false, session: result.session, path: result.path, error: null };
208
+ } catch (caught) {
209
+ error = caught;
210
+ // A descriptor and path identity mismatch is expected only while an
211
+ // already-authorized writer atomically replaces session.yaml. Retry a
212
+ // few times from scratch, never through the prior descriptor. Every
213
+ // other malformed or unsafe state fails closed immediately.
214
+ if (caught?.message !== 'session.yaml changed during the session read'
215
+ || attempt + 1 === SESSION_STABILITY_ATTEMPTS) break;
216
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, SESSION_STABILITY_RETRY_MS);
217
+ }
218
+ }
219
+ } finally {
220
+ try { path = join(resolve(projectDir), '.chati', 'session.yaml'); } catch { path = null; }
221
+ }
222
+ const message = error?.message || 'Safe session reader is unavailable';
223
+ return { loaded: false, missing: error?.code === 'ENOENT', session: null, path, error: message };
224
+ }
@@ -0,0 +1,195 @@
1
+ import {
2
+ closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync,
3
+ readSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync,
4
+ } from 'node:fs';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { hostname } from 'node:os';
7
+ import { dirname, join, resolve } from 'node:path';
8
+ import { parseHookSessionProjection } from './session-reader.js';
9
+
10
+ const SESSION_MAX_BYTES = 4 * 1024 * 1024;
11
+ const LOCK_WAIT_MS = 2_000;
12
+ const LOCK_RETRY_MS = 20;
13
+
14
+ function sameIdentity(left, right) {
15
+ return Boolean(left && right && left.dev === right.dev && left.ino === right.ino);
16
+ }
17
+
18
+ function readBounded(descriptor) {
19
+ const buffer = Buffer.allocUnsafe(SESSION_MAX_BYTES + 1);
20
+ let offset = 0;
21
+ while (offset < buffer.length) {
22
+ const bytes = readSync(descriptor, buffer, offset, buffer.length - offset, null);
23
+ if (bytes === 0) break;
24
+ offset += bytes;
25
+ }
26
+ if (offset > SESSION_MAX_BYTES) throw new Error('session.yaml exceeds the 4 MiB safety limit');
27
+ return buffer.subarray(0, offset).toString('utf8');
28
+ }
29
+
30
+ function encodeScalar(value) {
31
+ if (value === null) return 'null';
32
+ if (typeof value === 'boolean' || typeof value === 'number') return String(value);
33
+ if (typeof value === 'string') {
34
+ if (/^[A-Za-z0-9_.\-\[\]]+$/.test(value) && !['true', 'false', 'null', '~'].includes(value)) return value;
35
+ return JSON.stringify(value);
36
+ }
37
+ throw new Error('hook session updates must be scalar values');
38
+ }
39
+
40
+ function escapeRegex(value) {
41
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
42
+ }
43
+
44
+ function upsertRootScalar(raw, key, value) {
45
+ if (!/^[A-Za-z_][\w-]*$/.test(key)) throw new Error('hook session key is invalid');
46
+ const expression = new RegExp(`^${escapeRegex(key)}:[^\\r\\n]*$`, 'gm');
47
+ const matches = raw.match(expression) || [];
48
+ if (matches.length > 1) throw new Error(`session.yaml has duplicate root key: ${key}`);
49
+ const entry = `${key}: ${encodeScalar(value)}`;
50
+ if (matches.length === 1) return raw.replace(expression, entry);
51
+ return `${raw.replace(/\s*$/, '')}\n${entry}\n`;
52
+ }
53
+
54
+ function directoryReference(directoryPath, descriptor) {
55
+ return process.platform === 'linux' ? `/proc/self/fd/${descriptor}` : directoryPath;
56
+ }
57
+
58
+ function waitForHookSessionLock(directoryReferencePath) {
59
+ const lockPath = join(directoryReferencePath, 'session.yaml.lock');
60
+ const ownerPath = join(lockPath, 'owner.json');
61
+ const token = randomUUID();
62
+ const startedAt = Date.now();
63
+ while (true) {
64
+ try {
65
+ mkdirSync(lockPath, { mode: 0o700 });
66
+ let owner;
67
+ try {
68
+ owner = openSync(ownerPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
69
+ writeFileSync(owner, JSON.stringify({
70
+ schema_version: 2, pid: process.pid, hostname: hostname(), token,
71
+ acquired_at: new Date().toISOString(),
72
+ }));
73
+ fsyncSync(owner);
74
+ } finally {
75
+ if (owner !== undefined) closeSync(owner);
76
+ }
77
+ return () => {
78
+ try { unlinkSync(ownerPath); } catch { /* lock ownership is verified by the bounded wait above */ }
79
+ try { rmdirSync(lockPath); } catch { /* a supervisor lock replacement must never be removed recursively */ }
80
+ };
81
+ } catch (error) {
82
+ if (error?.code !== 'EEXIST' || Date.now() - startedAt >= LOCK_WAIT_MS) {
83
+ throw error;
84
+ }
85
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_RETRY_MS);
86
+ }
87
+ }
88
+ }
89
+
90
+ function updateSessionWithLock(projectDir, mutator) {
91
+ let directory;
92
+ let session;
93
+ let temporary;
94
+ let releaseLock;
95
+ try {
96
+ const root = realpathSync(resolve(projectDir));
97
+ const directoryPath = join(root, '.chati');
98
+ const initialDirectory = lstatSync(directoryPath);
99
+ if (!initialDirectory.isDirectory() || initialDirectory.isSymbolicLink() || realpathSync(directoryPath) !== directoryPath || dirname(directoryPath) !== root) {
100
+ throw new Error('The .chati directory must be a real directory inside the project');
101
+ }
102
+ directory = openSync(directoryPath, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
103
+ const pinnedDirectory = fstatSync(directory);
104
+ if (!pinnedDirectory.isDirectory() || !sameIdentity(initialDirectory, pinnedDirectory)) {
105
+ throw new Error('The .chati directory changed during the session update');
106
+ }
107
+ const directoryPathRef = directoryReference(directoryPath, directory);
108
+ releaseLock = waitForHookSessionLock(directoryPathRef);
109
+ const sessionPath = join(directoryPathRef, 'session.yaml');
110
+ session = openSync(sessionPath, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
111
+ const before = fstatSync(session);
112
+ if (!before.isFile() || before.nlink !== 1 || before.size > SESSION_MAX_BYTES) {
113
+ throw new Error('session.yaml must be a bounded single-link regular file');
114
+ }
115
+ const raw = readBounded(session);
116
+ const afterRead = fstatSync(session);
117
+ const currentPath = lstatSync(sessionPath);
118
+ const currentDirectory = fstatSync(directory);
119
+ if (!sameIdentity(before, afterRead) || !sameIdentity(before, currentPath) || !sameIdentity(pinnedDirectory, currentDirectory)) {
120
+ throw new Error('session.yaml changed during the session update');
121
+ }
122
+ const updates = mutator(parseHookSessionProjection(raw));
123
+ if (!updates || typeof updates !== 'object' || Array.isArray(updates)) throw new Error('hook session mutation is invalid');
124
+ let next = raw;
125
+ for (const [key, value] of Object.entries(updates)) next = upsertRootScalar(next, key, value);
126
+ if (Buffer.byteLength(next, 'utf8') > SESSION_MAX_BYTES) throw new Error('session.yaml exceeds the 4 MiB safety limit');
127
+ const temporaryPath = join(directoryPathRef, `session.yaml.${process.pid}.${randomUUID()}.tmp`);
128
+ temporary = openSync(temporaryPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
129
+ writeFileSync(temporary, next, 'utf8');
130
+ fsyncSync(temporary);
131
+ const beforeRenameDirectory = fstatSync(directory);
132
+ const beforeRenameSession = lstatSync(sessionPath);
133
+ if (!sameIdentity(pinnedDirectory, beforeRenameDirectory) || !sameIdentity(before, beforeRenameSession)) {
134
+ throw new Error('session.yaml changed before the atomic update');
135
+ }
136
+ closeSync(temporary);
137
+ temporary = undefined;
138
+ renameSync(temporaryPath, sessionPath);
139
+ fsyncSync(directory);
140
+ return { saved: true, session: parseHookSessionProjection(next) };
141
+ } catch (error) {
142
+ return { saved: false, error: error?.message || 'safe hook session update failed' };
143
+ } finally {
144
+ if (temporary !== undefined) closeSync(temporary);
145
+ if (releaseLock) releaseLock();
146
+ if (session !== undefined) closeSync(session);
147
+ if (directory !== undefined) closeSync(directory);
148
+ }
149
+ }
150
+
151
+ /** Update root-level scalar state without importing any project runtime. */
152
+ export function updateHookSession(projectDir, updates) {
153
+ return updateSessionWithLock(projectDir, () => updates);
154
+ }
155
+
156
+ /** Apply PRISM's additive context accounting under the same session lock. */
157
+ export function updateHookContextUsage(projectDir, {
158
+ delta = 0,
159
+ contextWindowTokens,
160
+ detectedModel = null,
161
+ routedProvider = null,
162
+ } = {}) {
163
+ const normalizedDelta = Math.max(0, Number.isFinite(Number(delta)) ? Math.trunc(Number(delta)) : 0);
164
+ const normalizedWindow = Math.max(1, Number.isFinite(Number(contextWindowTokens))
165
+ ? Math.trunc(Number(contextWindowTokens))
166
+ : 200_000);
167
+ const persisted = updateSessionWithLock(projectDir, (session) => {
168
+ const current = Math.max(0, Number.isFinite(Number(session.context_tokens_used))
169
+ ? Math.trunc(Number(session.context_tokens_used))
170
+ : 0);
171
+ const tokensUsed = Math.min(normalizedWindow, current + normalizedDelta);
172
+ const remainingPercent = Math.max(0, Math.round((1 - tokensUsed / normalizedWindow) * 100));
173
+ const bracket = remainingPercent < 25 ? 'CRITICAL'
174
+ : remainingPercent < 40 ? 'DEPLETED'
175
+ : remainingPercent < 60 ? 'MODERATE' : 'FRESH';
176
+ const updates = {
177
+ context_tokens_used: tokensUsed,
178
+ context_window_tokens: normalizedWindow,
179
+ context_last_bracket: bracket,
180
+ };
181
+ if (detectedModel && (session.current_execution_binding?.model || !session.active_model)) updates.active_model = detectedModel;
182
+ if (routedProvider) updates.active_provider = routedProvider;
183
+ if ((detectedModel || session.active_model) && !session.model_at_last_audit) {
184
+ updates.model_at_last_audit = detectedModel || session.active_model;
185
+ }
186
+ return updates;
187
+ });
188
+ if (!persisted.saved) return persisted;
189
+ return {
190
+ ...persisted,
191
+ tokensUsed: persisted.session.context_tokens_used,
192
+ contextWindowTokens: persisted.session.context_window_tokens,
193
+ bracket: persisted.session.context_last_bracket,
194
+ };
195
+ }
@@ -13,8 +13,8 @@
13
13
  * Enforcement: BLOCK — writes to another member's artifact scope are rejected.
14
14
  */
15
15
 
16
- import { existsSync, readFileSync } from 'fs';
17
- import { join } from 'path';
16
+ import { readHookSession } from './session-reader.js';
17
+ import { isAbsolute, relative, resolve, sep } from 'path';
18
18
 
19
19
  // Artifact scope per agent — defines which output paths each agent owns
20
20
  const AGENT_ARTIFACT_SCOPES = {
@@ -82,23 +82,31 @@ async function main() {
82
82
  try {
83
83
  const event = JSON.parse(input);
84
84
  const projectDir = event.cwd || process.cwd();
85
+ const sessionRead = readHookSession(projectDir);
86
+ if (!sessionRead.loaded && !sessionRead.missing) {
87
+ process.stdout.write(JSON.stringify(denyOutput(
88
+ `[Session Security] Team-scoped writes are blocked because session.yaml could not be validated: ${sessionRead.error}`
89
+ )));
90
+ return;
91
+ }
85
92
 
86
93
  // The env var is the fast path, but it does not always propagate to a
87
94
  // parallel hook process. Fall back to the session file: if the env var is
88
95
  // absent AND no team is active in session.yaml, this is genuinely solo mode
89
96
  // and we allow. If a team IS active, we enforce isolation even without the
90
97
  // env var, otherwise cross-member overwrites slip through unblocked.
91
- const teamActive = !!process.env.CHATI_TEAM_ID || hasActiveTeamInSession(projectDir);
98
+ const teamActive = !!process.env.CHATI_TEAM_ID || hasActiveTeam(sessionRead.session);
92
99
  if (!teamActive) {
93
100
  process.stdout.write(JSON.stringify(allowOutput()));
94
101
  return;
95
102
  }
96
103
 
97
104
  // Detect current agent from env or session
98
- const currentAgent = process.env.CHATI_TEAM_MEMBER || detectAgentFromSession(projectDir);
105
+ const currentAgent = process.env.CHATI_TEAM_MEMBER || sessionRead.session?.current_agent || null;
99
106
  if (!currentAgent) {
100
- // Cannot determine agent → allow (fail-open for safety)
101
- process.stdout.write(JSON.stringify(allowOutput()));
107
+ process.stdout.write(JSON.stringify(denyOutput(
108
+ '[Team Quality Gate] Team execution is active but the current member identity is unavailable. Write blocked until CHATI_TEAM_MEMBER is propagated.'
109
+ )));
102
110
  return;
103
111
  }
104
112
 
@@ -109,8 +117,17 @@ async function main() {
109
117
  return;
110
118
  }
111
119
 
112
- // Normalize path relative to project
113
- const relativePath = filePath.replace(projectDir + '/', '').replace(projectDir, '');
120
+ // Normalize against the project root before matching ownership. Textual
121
+ // prefix replacement lets ../ traversal impersonate another member scope.
122
+ const absolutePath = isAbsolute(filePath) ? resolve(filePath) : resolve(projectDir, filePath);
123
+ const candidate = relative(resolve(projectDir), absolutePath);
124
+ if (candidate === '..' || candidate.startsWith(`..${sep}`) || isAbsolute(candidate)) {
125
+ process.stdout.write(JSON.stringify(denyOutput(
126
+ `[Team Quality Gate] ${currentAgent} cannot write outside the project root.`
127
+ )));
128
+ return;
129
+ }
130
+ const relativePath = candidate.split(sep).join('/');
114
131
 
115
132
  // Check shared paths first — always allowed
116
133
  if (SHARED_PATHS.some(sp => relativePath.startsWith(sp))) {
@@ -175,14 +192,6 @@ function denyOutput(reason) {
175
192
  };
176
193
  }
177
194
 
178
- function detectAgentFromSession(projectDir) {
179
- const sessionPath = join(projectDir, '.chati', 'session.yaml');
180
- if (!existsSync(sessionPath)) return null;
181
- const raw = readFileSync(sessionPath, 'utf-8');
182
- const match = raw.match(/current_agent:\s*(.+)/m);
183
- return match ? match[1].trim().replace(/^["']|["']$/g, '') : null;
184
- }
185
-
186
195
  /**
187
196
  * Detect whether a team is active from session.yaml alone, for the case where
188
197
  * CHATI_TEAM_ID did not propagate to this hook process. An active team is an
@@ -190,15 +199,16 @@ function detectAgentFromSession(projectDir) {
190
199
  * completing. preview.status uses a different vocabulary (inactive/running/
191
200
  * approved/adjusting), so matching these specific values is team-specific.
192
201
  */
202
+ function hasActiveTeam(session) {
203
+ return Array.isArray(session?.teams) && session.teams.some((team) =>
204
+ team?.team_id && ['active', 'forming', 'completing'].includes(team.status)
205
+ );
206
+ }
207
+
193
208
  function hasActiveTeamInSession(projectDir) {
194
- const sessionPath = join(projectDir, '.chati', 'session.yaml');
195
- if (!existsSync(sessionPath)) return false;
196
- try {
197
- const raw = readFileSync(sessionPath, 'utf-8');
198
- return /team_id:\s*\S/.test(raw) && /^\s*status:\s*(active|forming|completing)\b/m.test(raw);
199
- } catch {
200
- return false;
201
- }
209
+ const result = readHookSession(projectDir);
210
+ if (!result.loaded) return !result.missing;
211
+ return hasActiveTeam(result.session);
202
212
  }
203
213
 
204
214
  // Only run main when executed directly (not imported by tests).