chati-dev 4.5.15 → 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 +2 -2
- 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 +278 -24
- package/framework/orchestrator/chati.md +250 -68
- package/framework/schemas/session.schema.json +21 -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 +157 -56
- package/src/installer/manifest.js +140 -9
- package/src/installer/package-artifact.js +249 -0
- package/src/installer/templates.js +91 -23
- 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/intelligence/timeline.js +3 -1
- package/src/orchestrator/browser-runtime.js +44 -13
- package/src/orchestrator/cli.js +1616 -151
- package/src/orchestrator/clickup-projection.js +43 -1
- package/src/orchestrator/clickup-runtime.js +355 -39
- package/src/orchestrator/doctor.js +87 -4
- 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 +1249 -52
- 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 +10 -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 +434 -16
- 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/utils/schema-validator.js +5 -0
- 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,
|
|
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
|
-
*
|
|
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
|
|
225
|
-
if (!
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
*
|
|
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
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
254
|
-
*
|
|
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
|
-
|
|
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
|
|
19
|
-
if (!
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
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:
|
|
31
|
-
currentAgent:
|
|
32
|
-
pipelinePosition:
|
|
33
|
-
workflow:
|
|
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:
|
|
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 {
|
|
17
|
-
import {
|
|
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 ||
|
|
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 ||
|
|
105
|
+
const currentAgent = process.env.CHATI_TEAM_MEMBER || sessionRead.session?.current_agent || null;
|
|
99
106
|
if (!currentAgent) {
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
113
|
-
|
|
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
|
|
195
|
-
if (!
|
|
196
|
-
|
|
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).
|