chati-dev 4.3.1 → 4.4.0

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 (147) hide show
  1. package/README.md +18 -13
  2. package/bin/chati.js +12 -4
  3. package/framework/agents/build/dev.md +5 -1
  4. package/framework/agents/discover/greenfield-wu.md +16 -1
  5. package/framework/agents/plan/tasks.md +31 -0
  6. package/framework/agents/plan/ux-brand-architect.md +21 -2
  7. package/framework/agents/plan/ux-component-engineer.md +10 -0
  8. package/framework/agents/quality/qa-implementation.md +1 -1
  9. package/framework/agents/quality/qa-planning.md +1 -1
  10. package/framework/agents/quality/qa-visual.md +24 -21
  11. package/framework/config.yaml +5 -4
  12. package/framework/constitution.md +58 -11
  13. package/framework/context/governance.md +12 -10
  14. package/framework/context/root.md +2 -2
  15. package/framework/data/entity-registry.yaml +12 -4
  16. package/framework/data/qa-rubrics.yaml +335 -0
  17. package/framework/domains/constitution.yaml +3 -3
  18. package/framework/executors/json-validate.js +80 -0
  19. package/framework/executors/npm-script.js +72 -0
  20. package/framework/executors/path-check.js +67 -0
  21. package/framework/executors/registry.yaml +36 -0
  22. package/framework/hooks/advance-trigger.js +47 -3
  23. package/framework/hooks/constitution-guard.js +9 -4
  24. package/framework/hooks/git-push-authority.js +113 -0
  25. package/framework/hooks/license-guard.js +59 -9
  26. package/framework/hooks/model-governance.js +40 -17
  27. package/framework/hooks/prism-engine.js +78 -11
  28. package/framework/hooks/session-digest.js +12 -3
  29. package/framework/hooks/settings.json +7 -3
  30. package/framework/hooks/style-guard.js +52 -3
  31. package/framework/hooks/team-quality-gate.js +43 -5
  32. package/framework/i18n/en.yaml +3 -3
  33. package/framework/i18n/es.yaml +3 -3
  34. package/framework/i18n/fr.yaml +3 -3
  35. package/framework/i18n/pt.yaml +3 -3
  36. package/framework/intelligence/confidence.yaml +85 -6
  37. package/framework/intelligence/context-engine.md +9 -5
  38. package/framework/intelligence/decision-engine.md +1 -1
  39. package/framework/orchestrator/chati-router.js +1 -1
  40. package/framework/orchestrator/chati-update.md +19 -3
  41. package/framework/orchestrator/chati.md +19 -1
  42. package/framework/quality-gates/planning-gate.md +2 -0
  43. package/framework/scaffold/motion-premium/scaffold.yaml +2 -1
  44. package/framework/scaffold/motion-premium-3d/components/webgl/WebGLContext.tsx.template +1 -1
  45. package/framework/scaffold/saas-dashboard/README.md +58 -0
  46. package/framework/scaffold/saas-dashboard/app/dashboard/page.tsx.template +546 -0
  47. package/framework/scaffold/saas-dashboard/app/globals.css.template +746 -0
  48. package/framework/scaffold/saas-dashboard/app/layout.tsx.template +192 -0
  49. package/framework/scaffold/saas-dashboard/app/shell.css.template +1070 -0
  50. package/framework/scaffold/saas-dashboard/components/data/Chart.tsx.template +725 -0
  51. package/framework/scaffold/saas-dashboard/components/data/DataTable.tsx.template +623 -0
  52. package/framework/scaffold/saas-dashboard/components/data/DataTable.types.ts.template +104 -0
  53. package/framework/scaffold/saas-dashboard/components/data/DataTablePagination.tsx.template +234 -0
  54. package/framework/scaffold/saas-dashboard/components/data/SkeletonBlock.tsx.template +111 -0
  55. package/framework/scaffold/saas-dashboard/components/data/StatCard.tsx.template +393 -0
  56. package/framework/scaffold/saas-dashboard/components/data/StatCardGrid.tsx.template +121 -0
  57. package/framework/scaffold/saas-dashboard/components/feedback/EmptyState.tsx.template +281 -0
  58. package/framework/scaffold/saas-dashboard/components/interaction/CommandPalette.tsx.template +767 -0
  59. package/framework/scaffold/saas-dashboard/components/interaction/Field.tsx.template +351 -0
  60. package/framework/scaffold/saas-dashboard/components/interaction/Form.tsx.template +424 -0
  61. package/framework/scaffold/saas-dashboard/components/primitives/Badge.tsx.template +193 -0
  62. package/framework/scaffold/saas-dashboard/components/primitives/Button.tsx.template +261 -0
  63. package/framework/scaffold/saas-dashboard/components/primitives/Card.tsx.template +287 -0
  64. package/framework/scaffold/saas-dashboard/components/primitives/Input.tsx.template +310 -0
  65. package/framework/scaffold/saas-dashboard/components/primitives/Spinner.tsx.template +135 -0
  66. package/framework/scaffold/saas-dashboard/components/primitives/index.ts.template +60 -0
  67. package/framework/scaffold/saas-dashboard/components/shell/AppShell.tsx.template +174 -0
  68. package/framework/scaffold/saas-dashboard/components/shell/Breadcrumb.tsx.template +186 -0
  69. package/framework/scaffold/saas-dashboard/components/shell/Container.tsx.template +86 -0
  70. package/framework/scaffold/saas-dashboard/components/shell/NavItem.tsx.template +147 -0
  71. package/framework/scaffold/saas-dashboard/components/shell/Sidebar.tsx.template +378 -0
  72. package/framework/scaffold/saas-dashboard/components/shell/SidebarContext.tsx.template +169 -0
  73. package/framework/scaffold/saas-dashboard/components/shell/ThemeToggle.tsx.template +128 -0
  74. package/framework/scaffold/saas-dashboard/components/shell/Topbar.tsx.template +301 -0
  75. package/framework/scaffold/saas-dashboard/lib/brand.ts.template +324 -0
  76. package/framework/scaffold/saas-dashboard/scaffold.yaml +225 -0
  77. package/framework/schemas/session.schema.json +5 -0
  78. package/framework/schemas/task.schema.json +9 -0
  79. package/framework/scripts/visual-qa.js +87 -1
  80. package/framework/tasks/orchestrator-health.md +4 -4
  81. package/framework/tasks/qa-impl-verdict.md +11 -5
  82. package/framework/templates/qa-gate-tmpl.yaml +24 -11
  83. package/package.json +3 -3
  84. package/src/config/context-file-generator.js +0 -6
  85. package/src/dashboard/renderer.js +0 -36
  86. package/src/executors/runner.js +204 -0
  87. package/src/installer/core.js +2 -1
  88. package/src/installer/templates.js +5 -6
  89. package/src/intelligence/registry-manager.js +1 -1
  90. package/src/license/client.js +9 -3
  91. package/src/license/commands.js +12 -2
  92. package/src/license/machine-id.js +42 -1
  93. package/src/memory/gotchas.js +58 -40
  94. package/src/memory/magic-docs.js +1 -1
  95. package/src/memory/session-digest.js +9 -4
  96. package/src/orchestrator/cli.js +174 -14
  97. package/src/orchestrator/doctor.js +98 -16
  98. package/src/orchestrator/pipeline-manager.js +44 -22
  99. package/src/orchestrator/session-manager.js +56 -8
  100. package/src/telemetry/sender.js +7 -19
  101. package/src/terminal/run-team.js +3 -3
  102. package/src/upgrade/tracked-files-detector.js +34 -16
  103. package/src/utils/feature-flags.js +1 -1
  104. package/src/utils/flatten-entities.js +4 -40
  105. package/src/utils/schema-validator.js +0 -14
  106. package/src/wizard/i18n.js +3 -3
  107. package/src/api/index.js +0 -120
  108. package/src/autonomy/autonomous-gate.js +0 -294
  109. package/src/autonomy/build-loop.js +0 -281
  110. package/src/autonomy/build-state.js +0 -286
  111. package/src/autonomy/cause-analyzer.js +0 -177
  112. package/src/autonomy/escalation.js +0 -214
  113. package/src/autonomy/index.js +0 -51
  114. package/src/autonomy/mode-manager.js +0 -225
  115. package/src/autonomy/mode-suggester.js +0 -283
  116. package/src/autonomy/progress-reporter.js +0 -275
  117. package/src/autonomy/safety-net.js +0 -370
  118. package/src/config/agent-customizer.js +0 -231
  119. package/src/decision/analyzer.js +0 -291
  120. package/src/decision/engine.js +0 -250
  121. package/src/decision/index.js +0 -38
  122. package/src/decision/registry-healer.js +0 -468
  123. package/src/decision/registry-updater.js +0 -339
  124. package/src/extensions/loader.js +0 -145
  125. package/src/extensions/registry.js +0 -134
  126. package/src/gates/circuit-breaker.js +0 -151
  127. package/src/gates/g1-planning-complete.js +0 -154
  128. package/src/gates/g2-qa-planning.js +0 -156
  129. package/src/gates/g3-implementation.js +0 -215
  130. package/src/gates/g4-qa-implementation.js +0 -240
  131. package/src/gates/g5-deploy-ready.js +0 -181
  132. package/src/gates/gate-base.js +0 -185
  133. package/src/gates/index.js +0 -46
  134. package/src/health/auto-fix.js +0 -216
  135. package/src/health/engine.js +0 -246
  136. package/src/merger/semantic-merger.js +0 -292
  137. package/src/preview/detector.js +0 -238
  138. package/src/preview/index.js +0 -20
  139. package/src/preview/launcher.js +0 -235
  140. package/src/preview/log-buffer.js +0 -103
  141. package/src/quality/metrics-collector.js +0 -281
  142. package/src/quality/test-runner.js +0 -366
  143. package/src/tasks/executor.js +0 -195
  144. package/src/tasks/index.js +0 -4
  145. package/src/tasks/loader.js +0 -210
  146. package/src/tasks/router.js +0 -182
  147. package/src/utils/event-bus.js +0 -126
@@ -0,0 +1,36 @@
1
+ # Executor Registry - deterministic, zero-LLM task executors.
2
+ #
3
+ # Each executor is a standalone Node script that reads a JSON request on stdin
4
+ # and writes a JSON result on stdout. The runner (packages/chati-dev/src/
5
+ # executors/runner.js) resolves an executor by id, spawns it, enforces a
6
+ # timeout, and writes a synthetic handoff. Executors run ONLY when a task
7
+ # declares execution_kind: script AND the execution_kinds feature flag is on.
8
+ #
9
+ # Contract:
10
+ # stdin {"task": {id, title, execution_ref}, "projectDir": "...", "arg": "..."}
11
+ # stdout {"status": "passed" | "failed" | "error", "summary": "...", "details": {...}}
12
+ # arg the part after the first colon in execution_ref
13
+ # (e.g. "npm-script:test" resolves executor "npm-script", arg "test").
14
+ # status passed = the deterministic check succeeded;
15
+ # failed = it ran and the result is negative (route to dev);
16
+ # error = the executor could not run (bad arg, spawn failure).
17
+ #
18
+ # Adding an executor: write a standalone script under executors/, register it
19
+ # here with a path and a timeout, and cover it with a test. Executors must be
20
+ # deterministic, side-effect-light, and never call an LLM.
21
+
22
+ version: "1.0.0"
23
+
24
+ executors:
25
+ npm-script:
26
+ path: executors/npm-script.js
27
+ description: "Run a project npm script and report deterministic pass/fail. arg is the script name; only lint, test, typecheck, build, and format are allowed (no arbitrary or long-running commands)."
28
+ timeout_ms: 600000
29
+ path-check:
30
+ path: executors/path-check.js
31
+ description: "Assert that one or more paths exist (a deterministic verification gate). arg is a comma-separated list of project-relative paths."
32
+ timeout_ms: 30000
33
+ json-validate:
34
+ path: executors/json-validate.js
35
+ description: "Parse JSON files and report any that are malformed (a deterministic well-formedness check). arg is a comma-separated list of project-relative .json paths."
36
+ timeout_ms: 60000
@@ -17,10 +17,33 @@
17
17
  * ignored so it outlives the hook process.
18
18
  */
19
19
 
20
- import { existsSync, readFileSync } from 'fs';
20
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
21
21
  import { join } from 'path';
22
22
  import { spawn } from 'child_process';
23
23
 
24
+ // Canonical gate thresholds live in data/qa-rubrics.yaml. These literals are
25
+ // only a last-resort fallback for when that file is briefly unavailable (for
26
+ // example mid-upgrade); they must match the YAML.
27
+ const FALLBACK_QA_THRESHOLDS = { 'qa-planning': 95, 'qa-implementation': 95, 'qa-visual': 90 };
28
+
29
+ function loadQaThresholds(cwd) {
30
+ const candidates = [
31
+ join(cwd, '.chati.dev', 'data', 'qa-rubrics.yaml'),
32
+ join(cwd, 'chati.dev', 'data', 'qa-rubrics.yaml'),
33
+ ];
34
+ let raw = null;
35
+ for (const p of candidates) {
36
+ if (existsSync(p)) { try { raw = readFileSync(p, 'utf-8'); break; } catch { /* fall through */ } }
37
+ }
38
+ if (!raw) return { ...FALLBACK_QA_THRESHOLDS };
39
+ const out = {};
40
+ for (const key of Object.keys(FALLBACK_QA_THRESHOLDS)) {
41
+ const m = raw.match(new RegExp(`^\\s*${key}:\\s*(\\d+)`, 'm'));
42
+ out[key] = m ? parseInt(m[1], 10) : FALLBACK_QA_THRESHOLDS[key];
43
+ }
44
+ return out;
45
+ }
46
+
24
47
  async function readStdin() {
25
48
  let data = '';
26
49
  for await (const chunk of process.stdin) data += chunk;
@@ -95,7 +118,7 @@ async function main() {
95
118
 
96
119
  // Decide whether to fire under interactive mode.
97
120
  if (profile !== 'autonomous') {
98
- const QA_THRESHOLDS = { 'qa-planning': 95, 'qa-implementation': 95, 'qa-visual': 90 };
121
+ const QA_THRESHOLDS = loadQaThresholds(cwd);
99
122
  const threshold = QA_THRESHOLDS[agent];
100
123
  const isQA = threshold !== undefined;
101
124
  const verdict = (fm.verdict || '').toUpperCase();
@@ -118,14 +141,35 @@ async function main() {
118
141
  if (!routerPath) return;
119
142
 
120
143
  // Fire and forget: detach so the router outlives this hook process.
144
+ // A silent spawn failure would stall the pipeline with no signal. Warn on
145
+ // stderr (Claude Code surfaces it immediately as a non-blocking hook warning)
146
+ // and drop a durable post-mortem marker in .chati/failures/ for inspection.
147
+ // The stderr warning is the surfaced signal; nothing scans the marker dir
148
+ // automatically yet.
149
+ const recordFailure = (err) => {
150
+ const msg = (err && err.message) ? err.message : String(err);
151
+ try {
152
+ const failuresDir = join(cwd, '.chati', 'failures');
153
+ mkdirSync(failuresDir, { recursive: true });
154
+ writeFileSync(
155
+ join(failuresDir, `advance-${agent}-${Date.now()}.json`),
156
+ JSON.stringify({ type: 'advance_spawn_failure', agent, score, error: msg, timestamp: new Date().toISOString() }),
157
+ 'utf-8'
158
+ );
159
+ } catch { /* truly non-fatal: filesystem unavailable */ }
160
+ try { process.stderr.write(`[chati] advance-trigger: pipeline advance for "${agent}" failed to spawn: ${msg}\n`); } catch { /* ignore */ }
161
+ };
121
162
  try {
122
163
  const child = spawn('node', [routerPath, 'advance', '--agent', agent, '--score', String(score)], {
123
164
  cwd,
124
165
  detached: true,
125
166
  stdio: 'ignore',
126
167
  });
168
+ child.on('error', recordFailure);
127
169
  child.unref();
128
- } catch { /* non-fatal */ }
170
+ } catch (err) {
171
+ recordFailure(err);
172
+ }
129
173
  }
130
174
 
131
175
  main().catch(() => process.stdout.write('{}\n'));
@@ -137,10 +137,15 @@ async function main() {
137
137
 
138
138
  process.stdout.write(JSON.stringify(allowOutput()));
139
139
  } catch (err) {
140
- process.stderr.write(`[chati-hook-error] constitution-guard: ${err?.message || 'unknown'}\n`);
141
- // Fail-open on hook errors: a broken hook should not block legitimate work.
142
- // Real security checks above are explicit and run before this catch.
143
- process.stdout.write(JSON.stringify(allowOutput()));
140
+ const msg = err?.message || 'unknown';
141
+ process.stderr.write(`[chati-hook-error] constitution-guard: ${msg}\n`);
142
+ // Fail CLOSED. A security/integrity guard that crashes must not
143
+ // silently permit the operation. The cost of a false block (user re-runs)
144
+ // is far lower than a false pass (a secret leak or governance violation
145
+ // slipping through). Deny with a clear, actionable reason.
146
+ process.stdout.write(JSON.stringify(denyOutput(
147
+ `constitution-guard could not complete its security checks (internal error: ${msg}). The operation was blocked as a precaution. Re-run once resolved, or report this if it keeps happening.`
148
+ )));
144
149
  }
145
150
  }
146
151
 
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @fileoverview Git-push authority hook — PreToolUse (Bash).
4
+ *
5
+ * During an active pipeline, only the devops agent may push to a remote or open
6
+ * a pull request. This stops a non-devops agent (for example dev, mid
7
+ * implementation) from pushing unreviewed work past the quality gates.
8
+ *
9
+ * Scope is deliberately narrow so normal git use is never blocked:
10
+ * - it only ever denies push / PR-creation / release-creation commands;
11
+ * - it only enforces while a pipeline is active (session.mode is a pipeline
12
+ * phase) AND the current agent is set AND is not devops;
13
+ * - outside an active session (no session, completed session) it does
14
+ * nothing.
15
+ *
16
+ * Fails open (with an stderr log) on any internal error: a guard crash must
17
+ * never block every Bash command.
18
+ */
19
+ import { existsSync, readFileSync } from 'fs';
20
+ import { join } from 'path';
21
+
22
+ const PUSH_PATTERNS = [
23
+ // git push, including global options that carry a value (git -C <path> push,
24
+ // git -c k=v push) so an agent cannot side-step the guard with -C.
25
+ /\bgit\s+(?:-{1,2}\S+(?:\s+\S+)?\s+)*push\b/i,
26
+ /\bgh\s+pr\s+(?:create|merge)\b/i,
27
+ /\bgh\s+release\s+create\b/i,
28
+ ];
29
+
30
+ // Pipeline phases during which a non-devops agent is actively driving work.
31
+ const ACTIVE_MODES = ['discover', 'plan', 'build', 'validate', 'deploy'];
32
+
33
+ function isPushCommand(command) {
34
+ return PUSH_PATTERNS.some((p) => p.test(command || ''));
35
+ }
36
+
37
+ /**
38
+ * Block only when a push/PR command runs while a pipeline is active (mode is a
39
+ * pipeline phase) and the current driver is a non-devops agent.
40
+ */
41
+ function shouldBlockPush(command, mode, agent) {
42
+ if (!isPushCommand(command)) return false;
43
+ return Boolean(mode && ACTIVE_MODES.includes(mode) && agent && agent !== 'devops');
44
+ }
45
+
46
+ function readSessionField(projectDir, key) {
47
+ const p = join(projectDir, '.chati', 'session.yaml');
48
+ if (!existsSync(p)) return null;
49
+ try {
50
+ const raw = readFileSync(p, 'utf-8');
51
+ const m = raw.match(new RegExp(`^\\s*${key}:\\s*(.+)$`, 'm'));
52
+ if (!m) return null;
53
+ const v = m[1].trim().replace(/^["']|["']$/g, '');
54
+ return v === 'null' || v === '' ? null : v;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ async function main() {
61
+ let input = '';
62
+ for await (const chunk of process.stdin) input += chunk;
63
+
64
+ try {
65
+ const event = JSON.parse(input);
66
+ if ((event.tool_name || '') !== 'Bash') {
67
+ process.stdout.write('{}');
68
+ return;
69
+ }
70
+ const command = (event.tool_input && event.tool_input.command) || '';
71
+ if (!isPushCommand(command)) {
72
+ process.stdout.write('{}');
73
+ return;
74
+ }
75
+
76
+ const projectDir = event.cwd || process.cwd();
77
+ const mode = readSessionField(projectDir, 'mode');
78
+ const agent = readSessionField(projectDir, 'current_agent');
79
+
80
+ // Only enforce while a pipeline is active with a non-devops driver.
81
+ if (shouldBlockPush(command, mode, agent)) {
82
+ process.stdout.write(JSON.stringify({
83
+ hookSpecificOutput: {
84
+ hookEventName: 'PreToolUse',
85
+ permissionDecision: 'deny',
86
+ permissionDecisionReason: `[Article IV] Only the devops agent may push to a remote or open a pull request during an active pipeline. The current agent is "${agent}". Let the pipeline reach the deploy phase (devops), or run /chati exit to take manual control.`,
87
+ },
88
+ }));
89
+ return;
90
+ }
91
+
92
+ process.stdout.write('{}');
93
+ } catch (err) {
94
+ // Fail open: a guard crash must not block every Bash command. Log so the
95
+ // failure is visible instead of silently disabling enforcement.
96
+ process.stderr.write(`[chati] git-push-authority error (allowing operation): ${err && err.message ? err.message : err}\n`);
97
+ process.stdout.write('{}');
98
+ }
99
+ }
100
+
101
+ export { PUSH_PATTERNS, ACTIVE_MODES, isPushCommand, shouldBlockPush, readSessionField };
102
+
103
+ // Only run main when executed directly (not when imported by tests).
104
+ // realpathSync resolves symlinks on both sides (macOS /tmp -> /private/tmp).
105
+ import { fileURLToPath } from 'url';
106
+ import { realpathSync } from 'fs';
107
+ if (process.argv[1]) {
108
+ try {
109
+ if (realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
110
+ main();
111
+ }
112
+ } catch { /* path resolution failed — not invoked as a script */ }
113
+ }
@@ -19,7 +19,7 @@
19
19
  * Telemetry pings are owned by session-digest.js (PreCompact event), not here.
20
20
  */
21
21
 
22
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
22
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
23
23
  import { join } from 'path';
24
24
  import { homedir } from 'os';
25
25
 
@@ -70,9 +70,21 @@ export async function checkLicense() {
70
70
  // cache or detects revocation. Network failures NEVER block the user.
71
71
  try {
72
72
  const machineId = await computeMachineId();
73
+ // The license key travels in a request header, never in the URL query
74
+ // string (which lands in server access, CDN and proxy logs in plaintext).
75
+ // Server contract: POST /license/validate, key in X-License-Key header,
76
+ // machine_id in the JSON body.
73
77
  const res = await fetch(
74
- `${API_BASE}/license/validate?key=${encodeURIComponent(licenseKey)}&machine_id=${encodeURIComponent(machineId)}`,
75
- { signal: AbortSignal.timeout(5000) }
78
+ `${API_BASE}/license/validate`,
79
+ {
80
+ method: 'POST',
81
+ headers: {
82
+ 'Content-Type': 'application/json',
83
+ 'X-License-Key': licenseKey,
84
+ },
85
+ body: JSON.stringify({ machine_id: machineId }),
86
+ signal: AbortSignal.timeout(5000),
87
+ }
76
88
  );
77
89
  const data = await res.json();
78
90
 
@@ -89,6 +101,8 @@ export async function checkLicense() {
89
101
  };
90
102
  mkdirSync(GLOBAL_DIR, { recursive: true });
91
103
  writeFileSync(LICENSE_PATH, dumpYaml(updated));
104
+ // The license file holds the activation key; restrict to owner-only.
105
+ try { chmodSync(LICENSE_PATH, 0o600); } catch { /* no-op on Windows / read-only FS */ }
92
106
 
93
107
  if (data.status === 'VALID') return { valid: true, status: 'VALID' };
94
108
  return { valid: false, reason: buildMessage(data.status, data.reason), status: data.status };
@@ -138,7 +152,12 @@ async function main() {
138
152
  process.exit(2);
139
153
  }
140
154
  }
141
- } catch { /* fail open */
155
+ } catch (err) {
156
+ // Fail open by design: a license-guard crash must never brick a paying
157
+ // user (an unreadable or corrupt license file, a transient FS error). But
158
+ // it must not be silent: log to stderr so the failure is visible instead
159
+ // of silently bypassing enforcement.
160
+ process.stderr.write(`[chati] license-guard error (allowing operation): ${err && err.message ? err.message : err}\n`);
142
161
  process.stdout.write(isPreToolUse
143
162
  ? JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' } })
144
163
  : '{}');
@@ -169,9 +188,26 @@ function buildMessage(status, reason) {
169
188
  // YAML helpers (no external deps)
170
189
  // ---------------------------------------------------------------------------
171
190
 
191
+ /**
192
+ * Unquote a scalar. Single-quoted values un-double the '' escape; double-quoted
193
+ * values are taken verbatim; bare values are returned trimmed.
194
+ */
195
+ function unquoteYaml(s) {
196
+ s = s.trim();
197
+ if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) {
198
+ return s.slice(1, -1).replace(/''/g, "'");
199
+ }
200
+ if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
201
+ return s.slice(1, -1);
202
+ }
203
+ return s;
204
+ }
205
+
172
206
  function readYamlField(raw, field) {
173
- const match = raw.match(new RegExp(`^${field}:\\s*(.+)$`, 'm'));
174
- return match ? match[1].trim().replace(/^["']|["']$/g, '') : null;
207
+ const match = raw.match(new RegExp(`^${field}:\\s*(.*)$`, 'm'));
208
+ if (!match) return null;
209
+ const value = unquoteYaml(match[1]);
210
+ return value === '' ? null : value;
175
211
  }
176
212
 
177
213
  /** Minimal YAML object parser for simple key:value files */
@@ -179,15 +215,29 @@ function parseYaml(raw) {
179
215
  const obj = {};
180
216
  for (const line of raw.split('\n')) {
181
217
  const m = line.match(/^(\w+):\s*(.*)$/);
182
- if (m) obj[m[1]] = m[2].trim().replace(/^["']|["']$/g, '');
218
+ if (m) obj[m[1]] = unquoteYaml(m[2]);
183
219
  }
184
220
  return obj;
185
221
  }
186
222
 
187
- /** Minimal YAML serializer for simple flat objects */
223
+ /**
224
+ * Minimal YAML serializer for simple flat objects.
225
+ * Single-quotes any value that contains ':', a quote, '#' or surrounding
226
+ * whitespace, and doubles inner single-quotes ('' is the YAML single-quote
227
+ * escape). Newlines are collapsed to spaces so a multi-line reason can never
228
+ * split the record across lines and corrupt later fields. A naive serializer
229
+ * that wraps colon-bearing values in single quotes without escaping inner
230
+ * quotes can write a file the parser then cannot read back, which would brick
231
+ * the license cache until manually deleted.
232
+ */
188
233
  function dumpYaml(obj) {
189
234
  return Object.entries(obj)
190
- .map(([k, v]) => `${k}: ${v == null ? '' : String(v).includes(':') ? `'${v}'` : v}`)
235
+ .map(([k, v]) => {
236
+ if (v == null) return `${k}: `;
237
+ const s = String(v).replace(/\r?\n/g, ' ').trim();
238
+ const needsQuote = s === '' || /[:'#]/.test(s) || /^\s|\s$/.test(s);
239
+ return needsQuote ? `${k}: '${s.replace(/'/g, "''")}'` : `${k}: ${s}`;
240
+ })
191
241
  .join('\n') + '\n';
192
242
  }
193
243
 
@@ -43,20 +43,6 @@ const AGENT_MODELS = {
43
43
  devops: { provider: 'claude', model: 'sonnet', tier: 'sonnet' },
44
44
  };
45
45
 
46
- /**
47
- * Upgrade conditions per agent. When context matches, the model
48
- * should be upgraded to the specified target.
49
- */
50
- const UPGRADE_CONDITIONS = {
51
- orchestrator: { to: 'opus', condition: 'complex routing or deviation handling' },
52
- 'greenfield-wu': { to: 'sonnet', condition: 'multi-stack or enterprise' },
53
- brief: { to: 'opus', condition: '10+ integrations' },
54
- ux: { to: 'opus', condition: 'design system from scratch' },
55
- phases: { to: 'opus', condition: '20+ requirements' },
56
- tasks: { to: 'opus', condition: '50+ tasks' },
57
- devops: { to: 'opus', condition: 'multi-environment or IaC' },
58
- };
59
-
60
46
  function getCurrentAgent(projectDir) {
61
47
  const sessionPath = join(projectDir, '.chati', 'session.yaml');
62
48
  if (!existsSync(sessionPath)) return null;
@@ -66,6 +52,27 @@ function getCurrentAgent(projectDir) {
66
52
  return match ? match[1].trim().replace(/^["']|["']$/g, '') : null;
67
53
  }
68
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;
63
+ }
64
+
65
+ // Collapse a model id to its family so opus and opus[1m] count as the same
66
+ // model for parity purposes (Article XXIV).
67
+ function modelFamily(m) {
68
+ if (!m) return null;
69
+ const s = String(m).toLowerCase();
70
+ for (const fam of ['opus', 'sonnet', 'haiku', 'pro', 'flash', 'codex', 'gemini', 'gpt']) {
71
+ if (s.includes(fam)) return fam;
72
+ }
73
+ return s;
74
+ }
75
+
69
76
  async function main() {
70
77
  let input = '';
71
78
  for await (const chunk of process.stdin) {
@@ -77,15 +84,31 @@ async function main() {
77
84
  const projectDir = event.cwd || process.cwd();
78
85
  const agent = getCurrentAgent(projectDir);
79
86
 
87
+ // Advisory context injection via the canonical UserPromptSubmit schema.
88
+ const lines = [];
89
+
80
90
  if (agent && AGENT_MODELS[agent]) {
81
91
  const assignment = AGENT_MODELS[agent];
82
92
  const expected = assignment.model || assignment;
83
93
  const provider = assignment.provider || 'claude';
84
- // Advisory context injection via canonical UserPromptSubmit schema.
94
+ lines.push(`[Article XVI] Agent "${agent}" assigned model: ${expected} (provider: ${provider})`);
95
+ }
96
+
97
+ // Article XXIV: Model-Harness Parity. When the active model family differs
98
+ // from the model at the last harness audit, advise a re-audit. Advisory
99
+ // only, never blocks; the family comparison ignores variant changes such
100
+ // as opus vs opus[1m].
101
+ const activeModel = getSessionField(projectDir, 'active_model');
102
+ const modelAtLastAudit = getSessionField(projectDir, 'model_at_last_audit');
103
+ if (activeModel && modelAtLastAudit && modelFamily(activeModel) !== modelFamily(modelAtLastAudit)) {
104
+ 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.`);
105
+ }
106
+
107
+ if (lines.length > 0) {
85
108
  process.stdout.write(JSON.stringify({
86
109
  hookSpecificOutput: {
87
110
  hookEventName: 'UserPromptSubmit',
88
- additionalContext: `[Article XVI] Agent "${agent}" assigned model: ${expected} (provider: ${provider})`,
111
+ additionalContext: lines.join('\n'),
89
112
  },
90
113
  }));
91
114
  } else {
@@ -97,7 +120,7 @@ async function main() {
97
120
  }
98
121
  }
99
122
 
100
- export { AGENT_MODELS, UPGRADE_CONDITIONS, getCurrentAgent };
123
+ export { AGENT_MODELS, getCurrentAgent };
101
124
 
102
125
  // Only run main when executed directly (not imported by tests).
103
126
  // realpathSync resolves symlinks on both sides — important on macOS where
@@ -32,9 +32,26 @@
32
32
  * Claude Code Hook: triggers on every user message submission.
33
33
  */
34
34
 
35
- import { existsSync, readFileSync, writeFileSync, renameSync } from 'fs';
35
+ import { existsSync, readFileSync, writeFileSync, renameSync, statSync } from 'fs';
36
36
  import { join } from 'path';
37
37
 
38
+ // mtime-keyed read cache. This is a no-op for the per-prompt hook process
39
+ // (it starts fresh each invocation and must read at least once), but it
40
+ // spares repeated disk reads for long-lived importers of these helpers
41
+ // (status --watch, context-status, tests) that call them many times in one
42
+ // process. The mtime key guarantees a changed file is always re-read, so the
43
+ // cache can never serve stale state.
44
+ const _fileReadCache = new Map(); // absPath -> { mtimeMs, content }
45
+ function readFileCached(absPath) {
46
+ let mtimeMs;
47
+ try { mtimeMs = statSync(absPath).mtimeMs; } catch { return null; }
48
+ const hit = _fileReadCache.get(absPath);
49
+ if (hit && hit.mtimeMs === mtimeMs) return hit.content;
50
+ const content = readFileSync(absPath, 'utf-8');
51
+ _fileReadCache.set(absPath, { mtimeMs, content });
52
+ return content;
53
+ }
54
+
38
55
  // ---------------------------------------------------------------------------
39
56
  // Context window limits — load from shared JSON, fall back to embedded.
40
57
  // ---------------------------------------------------------------------------
@@ -47,18 +64,23 @@ function loadLimits(projectDir) {
47
64
  for (const candidate of candidates) {
48
65
  if (existsSync(candidate)) {
49
66
  try {
50
- return JSON.parse(readFileSync(candidate, 'utf-8'));
67
+ const content = readFileCached(candidate);
68
+ if (content != null) return JSON.parse(content);
51
69
  } catch { /* malformed — fall through */ }
52
70
  }
53
71
  }
54
72
  // Last-resort fallback. Matches chati.dev/data/model-limits.json verbatim.
55
- // Scan-stale check #25 guards parity between this block and the JSON.
73
+ // Scan-stale check #25 guards parity between this block AND the JSON.
74
+ // 2026-04-18 (context-window-auto-detect-v2): opus and sonnet families
75
+ // default to 1M; haiku stays at 200K. The prior `opus: 200_000` here was a
76
+ // stale regression-lock from PR #3 that silently degraded users to a 5x
77
+ // smaller context budget whenever model-limits.json was unreadable.
56
78
  return {
57
- providers: { claude: 200_000, gemini: 1_000_000, codex: 128_000 },
79
+ providers: { claude: 1_000_000, gemini: 1_000_000, codex: 128_000 },
58
80
  models: {
59
- opus: 200_000,
81
+ opus: 1_000_000,
60
82
  'opus[1m]': 1_000_000,
61
- sonnet: 200_000,
83
+ sonnet: 1_000_000,
62
84
  'sonnet[1m]': 1_000_000,
63
85
  haiku: 200_000,
64
86
  pro: 1_000_000,
@@ -117,7 +139,8 @@ function readSessionState(projectDir) {
117
139
  const sessionPath = join(projectDir, '.chati', 'session.yaml');
118
140
  if (!existsSync(sessionPath)) return null;
119
141
 
120
- const raw = readFileSync(sessionPath, 'utf-8');
142
+ const raw = readFileCached(sessionPath);
143
+ if (raw == null) return null;
121
144
  const extract = (key) => {
122
145
  const match = raw.match(new RegExp(`^\\s*${key}:\\s*(.+)$`, 'm'));
123
146
  if (!match) return null;
@@ -152,6 +175,7 @@ function readSessionState(projectDir) {
152
175
  provider: extract('active_provider') || extract('provider') || 'claude',
153
176
  // v1.2 context detection fields.
154
177
  activeModel: extract('active_model'),
178
+ modelAtLastAudit: extract('model_at_last_audit'),
155
179
  contextTokensUsed: extractNum('context_tokens_used') || 0,
156
180
  contextWindowTokens: extractNum('context_window_tokens'),
157
181
  activeTeamId,
@@ -302,6 +326,12 @@ async function main() {
302
326
  context_last_bracket: bracket,
303
327
  // If we detected a model and the session did not have one, cache it.
304
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
+ : {}),
305
335
  });
306
336
  } catch (err) {
307
337
  process.stderr.write(`[chati] prism-engine write-back failed (non-fatal): ${err.message}\n`);
@@ -319,6 +349,35 @@ async function main() {
319
349
  }
320
350
  }
321
351
 
352
+ // 5b. Gotchas block. Surface the most relevant known gotchas to every agent
353
+ // per turn. This is the reliable per-turn, all-agent delivery path; gotchas
354
+ // attached to a handoff alone do not reach interactive agents and do not
355
+ // survive the first agent memory write. Read straight from the shared store;
356
+ // no src/ import (hooks are standalone).
357
+ let gotchasBlock = '';
358
+ if (session.currentAgent && bracket !== 'CRITICAL') {
359
+ const gotchasPath = join(projectDir, '.chati', 'memories', 'shared', 'gotchas.json');
360
+ const rawGotchas = readFileCached(gotchasPath);
361
+ if (rawGotchas) {
362
+ try {
363
+ const all = JSON.parse(rawGotchas);
364
+ const SEV = { CRITICAL: 3, WARNING: 2, INFO: 1 };
365
+ const ranked = (Array.isArray(all) ? all : [])
366
+ .filter((g) => g && !g.resolution)
367
+ .map((g) => ({ g, score: (g.agent === session.currentAgent ? 10 : 0) + (SEV[g.severity] || 1) + Math.min(5, g.count || 1) }))
368
+ .sort((a, b) => b.score - a.score)
369
+ .slice(0, 3);
370
+ if (ranked.length) {
371
+ const items = ranked.map(({ g }) => {
372
+ const msg = String(g.original_message || g.message || '').replace(/[<>]/g, '').replace(/\s+/g, ' ').slice(0, 160);
373
+ return ` <gotcha id="${g.id}" severity="${g.severity || 'INFO'}">${msg}</gotcha>`;
374
+ });
375
+ gotchasBlock = [' <known-gotchas>', ...items, ' </known-gotchas>'].join('\n');
376
+ }
377
+ } catch { /* malformed gotchas store — skip */ }
378
+ }
379
+ }
380
+
322
381
  // 6. Team context block (unchanged).
323
382
  let teamContextBlock = '';
324
383
  if (session.activeTeamId && bracket !== 'CRITICAL') {
@@ -335,10 +394,16 @@ async function main() {
335
394
 
336
395
  // 7. Advisories.
337
396
  const frustrationDetected = detectFrustration(promptText);
338
- const microcompactAdvisory =
339
- (bracket === 'DEPLETED' || bracket === 'CRITICAL')
340
- ? ' <advisory priority="medium">Context constrained. Avoid re-reading files already in context. Summarize stale outputs before proceeding.</advisory>'
341
- : '';
397
+ const lowContext = bracket === 'DEPLETED' || bracket === 'CRITICAL';
398
+ const microcompactAdvisory = lowContext
399
+ ? ' <advisory priority="medium">Context constrained. Avoid re-reading files already in context. Summarize stale outputs before proceeding.</advisory>'
400
+ : '';
401
+ // During BUILD at low context, prefer a clean session reset (handoff + fresh
402
+ // session) over in-place compaction: compaction keeps the model wrapping up
403
+ // prematurely while code is being written (Article XII reset preference).
404
+ const buildResetAdvisory = (lowContext && session.mode === 'build')
405
+ ? ' <advisory priority="high">BUILD phase at low context. Prefer a clean reset: write a handoff and continue in a fresh session, rather than compacting in place.</advisory>'
406
+ : '';
342
407
 
343
408
  // 8. Compose the context block.
344
409
  const contextBlock = [
@@ -347,9 +412,11 @@ async function main() {
347
412
  session.currentAgent ? ` <agent>${session.currentAgent}</agent>` : '',
348
413
  session.pipelinePosition ? ` <pipeline-position>${session.pipelinePosition}</pipeline-position>` : '',
349
414
  memoryBlock,
415
+ gotchasBlock,
350
416
  teamContextBlock,
351
417
  bracket === 'CRITICAL' ? ' <advisory>Context running low. Consider handoff or summary.</advisory>' : '',
352
418
  microcompactAdvisory,
419
+ buildResetAdvisory,
353
420
  frustrationDetected ? ' <advisory priority="high">User shows signs of frustration. Be more direct, acknowledge the issue explicitly, focus on the solution, avoid repeating previous suggestions.</advisory>' : '',
354
421
  '</chati-context>',
355
422
  ].filter(Boolean).join('\n');
@@ -6,7 +6,8 @@
6
6
  * recovered after context compaction. Writes to .chati/memories/shared/session/.
7
7
  */
8
8
 
9
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
9
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, chmodSync } from 'fs';
10
+ import { createHash } from 'crypto';
10
11
  import { join } from 'path';
11
12
 
12
13
  /**
@@ -250,11 +251,19 @@ async function main() {
250
251
  content += newEntry;
251
252
  }
252
253
  writeFileSync(pingsPath, content, 'utf-8');
254
+ // pings.yaml records the installation and private project paths;
255
+ // restrict to owner-only.
256
+ try { chmodSync(pingsPath, 0o600); } catch { /* no-op on Windows / read-only FS */ }
253
257
 
254
- // Fire-and-forget telemetry (3s timeout, never blocks)
258
+ // Fire-and-forget telemetry (3s timeout, never blocks).
259
+ // The raw license key is never transmitted. The body carries only a
260
+ // non-reversible installation_id (sha256 of the key, 16 hex) so the
261
+ // server can group sessions per install without ever seeing, logging
262
+ // or storing the key.
255
263
  try {
264
+ const installationId = createHash('sha256').update(licenseKey).digest('hex').slice(0, 16);
256
265
  const body = JSON.stringify({
257
- license_key: licenseKey,
266
+ installation_id: installationId,
258
267
  project_name: digest.currentAgent || 'unknown',
259
268
  events: [{ type: 'session_active', timestamp: nowIso, properties: { pipeline_phase: digest.mode, current_agent: digest.currentAgent } }],
260
269
  });