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.
Files changed (124) 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 +2 -2
  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 +278 -24
  43. package/framework/orchestrator/chati.md +250 -68
  44. package/framework/schemas/session.schema.json +21 -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 +157 -56
  82. package/src/installer/manifest.js +140 -9
  83. package/src/installer/package-artifact.js +249 -0
  84. package/src/installer/templates.js +91 -23
  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/intelligence/timeline.js +3 -1
  91. package/src/orchestrator/browser-runtime.js +44 -13
  92. package/src/orchestrator/cli.js +1616 -151
  93. package/src/orchestrator/clickup-projection.js +43 -1
  94. package/src/orchestrator/clickup-runtime.js +355 -39
  95. package/src/orchestrator/doctor.js +87 -4
  96. package/src/orchestrator/index.js +16 -0
  97. package/src/orchestrator/planning-runtime.js +20 -2
  98. package/src/orchestrator/rail-adjudication-evidence.js +234 -0
  99. package/src/orchestrator/rail-evidence-authority.js +147 -0
  100. package/src/orchestrator/rail-execution-evidence.js +45 -0
  101. package/src/orchestrator/rail-runtime.js +836 -56
  102. package/src/orchestrator/release-runtime.js +65 -6
  103. package/src/orchestrator/review-runtime.js +186 -41
  104. package/src/orchestrator/runtime-installation-v2.js +236 -20
  105. package/src/orchestrator/session-manager.js +1249 -52
  106. package/src/terminal/adapters/claude-adapter.js +3 -1
  107. package/src/terminal/adapters/codex-adapter.js +2 -0
  108. package/src/terminal/adapters/grok-adapter.js +7 -4
  109. package/src/terminal/handoff-parser.js +19 -1
  110. package/src/terminal/prompt-builder.js +10 -1
  111. package/src/terminal/provider-preflight.js +36 -3
  112. package/src/terminal/rail-execution-worktree.js +213 -0
  113. package/src/terminal/rail-prompts.js +169 -0
  114. package/src/terminal/rail-readonly-workspace.js +324 -0
  115. package/src/terminal/run-agent.js +434 -16
  116. package/src/terminal/run-parallel.js +5 -0
  117. package/src/terminal/run-rail-adjudication.js +323 -0
  118. package/src/terminal/run-rail-review.js +291 -0
  119. package/src/terminal/run-rail-rework.js +325 -0
  120. package/src/terminal/run-rail-task.js +380 -0
  121. package/src/terminal/run-team.js +5 -0
  122. package/src/terminal/spawner.js +1225 -77
  123. package/src/utils/schema-validator.js +5 -0
  124. package/src/wizard/index.js +3 -2
@@ -49,10 +49,10 @@ export function stripLegacyBuildSurface(content) {
49
49
  const CODEX_CREDENTIAL_BOUNDARY = `
50
50
  ## Codex external provider credential boundary
51
51
 
52
- Commands returned in any \`spawn_command\`, \`parallel_spawn_command\` or
53
- \`parallel_spawn_commands\` start another provider CLI. Codex MUST execute each
54
- command through its native local execution path so the child CLI can access its
55
- existing local credentials:
52
+ Commands returned in any \`spawn_command\`, \`deferred_spawn_command\`,
53
+ \`parallel_spawn_command\` or \`parallel_spawn_commands\` start another provider
54
+ CLI. Codex MUST execute each command through its native local execution path so
55
+ the child CLI can access its existing local credentials:
56
56
 
57
57
  - If the active Codex host requires approval to leave a restricted workspace
58
58
  sandbox, request the narrow native approval it exposes for the exact command.
@@ -1,17 +1,18 @@
1
- import { mkdirSync, writeFileSync, copyFileSync, existsSync, readFileSync, readdirSync, statSync, rmSync } from 'fs';
1
+ import { mkdirSync, writeFileSync, copyFileSync, existsSync, readFileSync, readdirSync, statSync, lstatSync, rmSync } from 'fs';
2
2
  import { execFileSync } from 'child_process';
3
3
  import { join, dirname } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { createRequire } from 'module';
6
6
  import { IDE_CONFIGS, IDE_TO_PROVIDER } from '../config/ide-configs.js';
7
7
  import { mergeClaudeMCPConfig, upsertManagedMcpToml } from '../config/mcp-configs.js';
8
- import { generateSessionYaml, generateConfigYaml, generateProjectContext, generateClaudeMd, generateClaudeLocalMd, generateCodexSkill, generateGeminiRouter, generateGrokRouter, generateGeminiSessionLock, generateAgentsOverrideMd, generateCodexConstitutionGuardRules, generateCodexReadProtectionRules } from './templates.js';
8
+ import { generateSessionYaml, generateConfigYaml, generateProjectContext, generateClaudeMd, generateClaudeLocalMd, generateCodexSkill, generateGeminiRouter, generateGrokRouter, generateGrokGovernanceRules, generateGeminiSessionLock, generateAgentsOverrideMd, generateCodexConstitutionGuardRules, generateCodexReadProtectionRules } from './templates.js';
9
9
  import { generateContextFiles } from '../config/context-file-generator.js';
10
10
  import { adaptFrameworkFile, ADAPTABLE_FILES, stripLegacyBuildSurface } from '../config/framework-adapter.js';
11
11
  import { generateClaudeSettings } from '../config/claude-settings-generator.js';
12
12
  import { generateProviderOverlays } from './provider-overlay.js';
13
13
  import { applyInstalledPathReplacement } from './path-replacement.js';
14
- import { verifyManifest } from './manifest.js';
14
+ import { verifyFrameworkSource } from './manifest.js';
15
+ import { initializeSessionDocument } from '../orchestrator/session-manager.js';
15
16
  import * as yaml from 'js-yaml';
16
17
 
17
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -30,47 +31,35 @@ export async function installFramework(config) {
30
31
  const enabledProviders = allProviders?.length
31
32
  ? [...new Set(allProviders)]
32
33
  : [...new Set((selectedIDEs || []).map(ide => IDE_TO_PROVIDER[ide]).filter(Boolean))];
33
- // Always derive version from package.json (single source of truth)
34
+ // The signed source is always anchored to the package version. `config.version`
35
+ // remains an installer/test presentation override and cannot redefine trust.
36
+ let packageVersion = null;
37
+ try {
38
+ const pkgPath = join(__dirname, '..', '..', 'package.json');
39
+ packageVersion = JSON.parse(readFileSync(pkgPath, 'utf-8')).version;
40
+ } catch { /* the integrity check below will still fail closed on source drift */ }
41
+
34
42
  let { version } = config;
35
- if (!version) {
36
- try {
37
- const pkgPath = join(__dirname, '..', '..', 'package.json');
38
- version = JSON.parse(readFileSync(pkgPath, 'utf-8')).version;
39
- } catch { version = 'unknown'; }
40
- }
43
+ if (!version) version = packageVersion || 'unknown';
41
44
 
42
45
  // 0. Verify framework signature (supply chain protection)
43
- const manifestPath = join(FRAMEWORK_SOURCE, 'manifest.json');
44
- const sigPath = join(FRAMEWORK_SOURCE, 'manifest.sig');
45
-
46
- if (existsSync(manifestPath) && existsSync(sigPath)) {
47
- const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
48
- const signature = readFileSync(sigPath, 'utf-8').trim();
49
- const result = verifyManifest(manifest, signature);
50
-
51
- if (!result.valid && result.reason === 'signature-mismatch') {
52
- throw new Error('Framework signature verification failed. Package may have been tampered with.');
53
- }
54
- }
55
-
56
- // 1. Create .chati/ session directory
57
- createDir(join(targetDir, '.chati'));
58
- const sessionPath = join(targetDir, '.chati', 'session.yaml');
59
- // Preserve existing session.yaml on reinstall/recovery (don't destroy pipeline state).
60
- // Only write fresh session if one does not exist.
61
- if (!existsSync(sessionPath)) {
62
- writeFileSync(
63
- sessionPath,
64
- generateSessionYaml({ projectName, projectType, language, selectedIDEs, selectedMCPs, allProviders: enabledProviders }),
65
- 'utf-8'
66
- );
46
+ const integrity = verifyFrameworkSource(FRAMEWORK_SOURCE, { expectedVersion: packageVersion });
47
+ if (!integrity.valid) {
48
+ throw new Error(`Framework integrity verification failed (${integrity.reason}). Package may have been tampered with.`);
67
49
  }
50
+ // 1. Create the canonical session through the lock-aware session manager.
51
+ // Reinstall/recovery always preserves an existing document.
52
+ const initialSession = initializeSessionDocument(
53
+ targetDir,
54
+ generateSessionYaml({ projectName, projectType, language, selectedIDEs, selectedMCPs, allProviders: enabledProviders }),
55
+ );
56
+ if (initialSession.error) throw new Error(initialSession.error);
68
57
 
69
58
  // 2. Create .chati.dev/ framework directory (hidden — dot prefix)
70
59
  // The source is chati.dev/ in the monorepo; installed as .chati.dev/ in user projects.
71
60
  // Text replacement during copy handles all internal path references.
72
61
  const frameworkDir = join(targetDir, '.chati.dev');
73
- createDir(frameworkDir);
62
+ createSafeManagedDirectory(frameworkDir);
74
63
 
75
64
  // 2b. Create artifacts/ at PROJECT ROOT (visible to user — this is their output)
76
65
  const artifactsDir = join(targetDir, 'artifacts');
@@ -101,7 +90,7 @@ export async function installFramework(config) {
101
90
  ];
102
91
 
103
92
  for (const dir of frameworkDirs) {
104
- createDir(join(frameworkDir, dir));
93
+ createSafeManagedDirectory(join(frameworkDir, dir));
105
94
  }
106
95
 
107
96
  // Create .chati/memories/ directory tree for Memory Layer
@@ -162,6 +151,9 @@ export async function installFramework(config) {
162
151
  join(__dirname, '..', '..', 'framework', 'hooks'),
163
152
  join(frameworkDir, 'framework', 'hooks')
164
153
  );
154
+ if (isRuntimeV2) {
155
+ rmSync(join(frameworkDir, 'framework', 'hooks', 'post-dev.js'), { force: true });
156
+ }
165
157
 
166
158
  // Also bundle production deps so the CLI can import js-yaml/chalk/etc. at runtime
167
159
  // without relying on a node_modules tree in the user's project.
@@ -182,7 +174,13 @@ export async function installFramework(config) {
182
174
  // Write config.yaml
183
175
  writeFileSync(
184
176
  join(frameworkDir, 'config.yaml'),
185
- generateConfigYaml({ version, projectType, language, selectedIDEs, allProviders: enabledProviders, runtimeVersion: config.runtimeVersion }),
177
+ generateConfigYaml({
178
+ version, projectType, language, selectedIDEs,
179
+ allProviders: enabledProviders,
180
+ runtimeVersion: config.runtimeVersion,
181
+ installationMode: config.installationMode,
182
+ clickupPreflight: config.clickupPreflight,
183
+ }),
186
184
  'utf-8'
187
185
  );
188
186
 
@@ -324,11 +322,18 @@ function pruneLegacyExecutionSurface(frameworkDir, providers) {
324
322
  }
325
323
 
326
324
  export function ensureGitRepository(targetDir) {
325
+ const environment = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')));
326
+ const gitEnvironment = {
327
+ ...environment,
328
+ GIT_CONFIG_NOSYSTEM: '1',
329
+ GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null',
330
+ GIT_TERMINAL_PROMPT: '0',
331
+ };
327
332
  try {
328
- execFileSync('git', ['-C', targetDir, 'rev-parse', '--show-toplevel'], { stdio: 'ignore' });
333
+ execFileSync('git', ['-C', targetDir, 'rev-parse', '--show-toplevel'], { stdio: 'ignore', env: gitEnvironment });
329
334
  return { initialized: false };
330
335
  } catch {
331
- execFileSync('git', ['-C', targetDir, 'init', '-q'], { stdio: 'ignore' });
336
+ execFileSync('git', ['-C', targetDir, 'init', '-q'], { stdio: 'ignore', env: gitEnvironment });
332
337
  return { initialized: true };
333
338
  }
334
339
  }
@@ -395,7 +400,7 @@ const TEXT_EXTENSIONS = new Set(['.md', '.js', '.yaml', '.yml', '.json', '.html'
395
400
  */
396
401
  function copyDirRecursive(srcDir, destDir, provider, relBase = '') {
397
402
  if (!existsSync(srcDir)) return;
398
- createDir(destDir);
403
+ createSafeManagedDirectory(destDir);
399
404
 
400
405
  const entries = readdirSync(srcDir, { withFileTypes: true });
401
406
  for (const entry of entries) {
@@ -419,10 +424,10 @@ function copyDirRecursive(srcDir, destDir, provider, relBase = '') {
419
424
  if (provider && ADAPTABLE_FILES.has(relPath)) {
420
425
  content = adaptFrameworkFile(content, relPath, provider);
421
426
  }
422
- writeFileSync(destPath, content, 'utf-8');
427
+ writeSafeManagedFile(destPath, content, 'utf-8');
423
428
  } else {
424
429
  // Binary file: direct copy
425
- copyFileSync(srcPath, destPath);
430
+ copySafeManagedFile(srcPath, destPath);
426
431
  }
427
432
  }
428
433
  }
@@ -445,13 +450,13 @@ export function copyFrameworkFiles(destDir, provider = null) {
445
450
  const src = join(FRAMEWORK_SOURCE, file);
446
451
  if (!existsSync(src)) continue;
447
452
  const dest = join(destDir, file);
448
- createDir(dirname(dest));
453
+ createSafeManagedDirectory(dirname(dest));
449
454
  let content = readFileSync(src, 'utf-8');
450
455
  content = applyInstalledPathReplacement(content);
451
456
  if (provider && ADAPTABLE_FILES.has(file)) {
452
457
  content = adaptFrameworkFile(content, file, provider);
453
458
  }
454
- writeFileSync(dest, content, 'utf-8');
459
+ writeSafeManagedFile(dest, content, 'utf-8');
455
460
  }
456
461
 
457
462
  // Copy each framework directory recursively
@@ -481,7 +486,7 @@ async function configureIDE(targetDir, ideKey, selectedMCPs, options = {}) {
481
486
 
482
487
  Run via Bash:
483
488
  \`\`\`
484
- node .chati.dev/orchestrator/chati-router.js
489
+ node .chati.dev/orchestrator/chati-router.js --harness claude
485
490
  \`\`\`
486
491
 
487
492
  Parse the JSON output. This single command already handled: license validation, integrity check, session state, pipeline routing, and team registration (if needed). You do NOT need to run any of these separately.
@@ -497,7 +502,7 @@ Parse the JSON output. This single command already handled: license validation,
497
502
 
498
503
  ### If \`ok\` is true:
499
504
 
500
- 1. Use \`session.language\` for ALL responses (pt = Português, en = English, es = Español, fr = Français).
505
+ 1. Use \`session.language\` for every visible response, preface, progress message, question and error, starting with the first response (pt = Português, en = English, es = Español, fr = Français).
501
506
  2. Read the full orchestrator at \`${orchestratorPath || '.chati.dev/orchestrator/chati.md'}\`.
502
507
  3. The \`action\` field tells you what to do. Go to the matching "Action:" section in the orchestrator.
503
508
  4. **If \`action\` is \`spawn_team\`**: the team is ALREADY registered (Step 1 already ran as code). The \`team\` object contains \`team_id\`, \`task_list_path\`, \`mailbox_path\`, \`members\`. Go directly to "Action: Spawn Team" Step 2 and use these values in the agent prompts. DO NOT run \`orchestrate spawn-team\` again — it already ran.
@@ -506,6 +511,14 @@ Parse the JSON output. This single command already handled: license validation,
506
511
 
507
512
  **The \`pipeline\` object contains the full JSON from \`orchestrate next\`.** Use its fields (agent_file, spawn_command, handoff_status, etc.) as documented in the orchestrator.
508
513
 
514
+ **Pending interaction invariant:** If \`action\` is \`resume_pending_interaction\`,
515
+ do not execute a provider command. Display exactly \`pipeline.question\`, with
516
+ no preface, summary or paraphrase. After the user answers, write
517
+ \`{"response":"<exact user response>"}\` to \`pipeline.response_file\` using the
518
+ native file-write tool, never a shell command. Then execute
519
+ \`pipeline.deferred_spawn_command\` exactly once and unchanged. Never execute a
520
+ command merely because a command-shaped field is present.
521
+
509
522
  **User input:** $ARGUMENTS
510
523
  `;
511
524
  writeFileSync(join(targetDir, '.claude', 'commands', 'chati.md'), routerContent, 'utf-8');
@@ -538,14 +551,14 @@ Parse the JSON output. This single command already handled: license validation,
538
551
  writeFileSync(join(targetDir, '.codex', 'rules', 'constitution-guard.rules'), generateCodexConstitutionGuardRules(), 'utf-8');
539
552
  writeFileSync(join(targetDir, '.codex', 'rules', 'read-protection.rules'), generateCodexReadProtectionRules(), 'utf-8');
540
553
 
541
- // .codex/hooks.json — license-guard wired to UserPromptSubmit (per-turn enforcement).
542
- // Codex hooks require [features] hooks = true in the active project config.
543
- // Without it, license enforcement only happens at skill entry and the router,
544
- // entry a long-running terminal session would not be re-validated.
554
+ // .codex/hooks.json — use Codex's native lifecycle surface for the same
555
+ // controller boundaries enforced in the other enabled harnesses. In
556
+ // particular, `PreToolUse` observes Bash and local mutation tools, not
557
+ // merely the prompt entrypoint. Codex hooks require [features] hooks = true
558
+ // in the active project config.
545
559
  writeFileSync(
546
560
  join(targetDir, '.codex', 'hooks.json'),
547
561
  JSON.stringify({
548
- $comment: 'chati.dev Codex lifecycle hooks. Requires [features] hooks = true.',
549
562
  hooks: {
550
563
  UserPromptSubmit: [
551
564
  {
@@ -558,6 +571,34 @@ Parse the JSON output. This single command already handled: license validation,
558
571
  ],
559
572
  },
560
573
  ],
574
+ PreToolUse: [
575
+ {
576
+ matcher: '^Bash$',
577
+ hooks: [
578
+ {
579
+ type: 'command',
580
+ command: 'node .chati.dev/hooks/git-push-authority.js',
581
+ },
582
+ ],
583
+ },
584
+ {
585
+ matcher: '^(?:apply_patch|Edit|Write|NotebookEdit)$',
586
+ hooks: [
587
+ {
588
+ type: 'command',
589
+ command: 'node .chati.dev/hooks/mode-governance.js',
590
+ },
591
+ {
592
+ type: 'command',
593
+ command: 'node .chati.dev/hooks/team-quality-gate.js',
594
+ },
595
+ {
596
+ type: 'command',
597
+ command: 'node .chati.dev/hooks/reasoning-escalator.js',
598
+ },
599
+ ],
600
+ },
601
+ ],
561
602
  },
562
603
  }, null, 2) + '\n',
563
604
  'utf-8'
@@ -593,6 +634,8 @@ Parse the JSON output. This single command already handled: license validation,
593
634
  } else if (ideKey === 'grok-cli') {
594
635
  writeFileSync(join(targetDir, '.grok', 'commands', 'chati.md'), generateGrokRouter({ orchestratorPath }), 'utf-8');
595
636
  writeFileSync(join(targetDir, '.grok', 'session-lock.md'), generateClaudeLocalMd(), 'utf-8');
637
+ createDir(join(targetDir, '.grok', 'rules'));
638
+ writeFileSync(join(targetDir, '.grok', 'rules', 'chati-governance.md'), generateGrokGovernanceRules(), 'utf-8');
596
639
  const grokConfigPath = join(targetDir, '.grok', 'config.toml');
597
640
  if (selectedMCPs.length > 0 || existsSync(grokConfigPath)) {
598
641
  const existingConfig = existsSync(grokConfigPath) ? readFileSync(grokConfigPath, 'utf-8') : '';
@@ -693,6 +736,7 @@ export function updateGitignore(targetDir, selectedIDEs) {
693
736
  '',
694
737
  '# Runtime state (session, teams, timeline, transient memories)',
695
738
  '.chati/session.yaml',
739
+ '.chati/runtime/',
696
740
  '.chati/teams/',
697
741
  '.chati/timeline.json',
698
742
  '.chati/memories/*/session/',
@@ -739,6 +783,7 @@ export function updateGitignore(targetDir, selectedIDEs) {
739
783
  '',
740
784
  '# Grok provider framework files',
741
785
  '.grok/commands/',
786
+ '.grok/rules/',
742
787
  '.grok/session-lock.md',
743
788
  );
744
789
  }
@@ -955,6 +1000,58 @@ function createDir(dir) {
955
1000
  }
956
1001
  }
957
1002
 
1003
+ function unsafeManagedPath(path, message) {
1004
+ const error = new Error(`${message}: ${path}`);
1005
+ error.code = 'UNSAFE_INSTALLATION_TARGET';
1006
+ throw error;
1007
+ }
1008
+
1009
+ /** Managed framework paths must never resolve through a user-provided link. */
1010
+ function createSafeManagedDirectory(dir) {
1011
+ const missing = [];
1012
+ let current = join(dir);
1013
+ for (;;) {
1014
+ try {
1015
+ const stat = lstatSync(current);
1016
+ if (stat.isSymbolicLink() || !stat.isDirectory()) unsafeManagedPath(current, 'managed directory must be a real directory');
1017
+ break;
1018
+ } catch (error) {
1019
+ if (error?.code !== 'ENOENT') throw error;
1020
+ missing.push(current);
1021
+ const parent = dirname(current);
1022
+ if (parent === current) unsafeManagedPath(current, 'could not resolve a managed directory ancestor');
1023
+ current = parent;
1024
+ }
1025
+ }
1026
+ for (const path of missing.reverse()) {
1027
+ try { mkdirSync(path); }
1028
+ catch (error) { if (error?.code !== 'EEXIST') throw error; }
1029
+ const stat = lstatSync(path);
1030
+ if (stat.isSymbolicLink() || !stat.isDirectory()) unsafeManagedPath(path, 'managed directory changed while being created');
1031
+ }
1032
+ }
1033
+
1034
+ function assertSafeManagedFileDestination(path) {
1035
+ try {
1036
+ const stat = lstatSync(path);
1037
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1) {
1038
+ unsafeManagedPath(path, 'managed file destination must be a single-link regular file');
1039
+ }
1040
+ } catch (error) {
1041
+ if (error?.code !== 'ENOENT') throw error;
1042
+ }
1043
+ }
1044
+
1045
+ function writeSafeManagedFile(path, content, encoding) {
1046
+ assertSafeManagedFileDestination(path);
1047
+ writeFileSync(path, content, encoding);
1048
+ }
1049
+
1050
+ function copySafeManagedFile(source, destination) {
1051
+ assertSafeManagedFileDestination(destination);
1052
+ copyFileSync(source, destination);
1053
+ }
1054
+
958
1055
  /**
959
1056
  * Recursively copy CLI source (excluding tests, node_modules, hidden files).
960
1057
  * Used to bundle the CLI source into the user's .chati.dev/_cli/ folder so
@@ -962,7 +1059,7 @@ function createDir(dir) {
962
1059
  */
963
1060
  export function copyCliSource(srcDir, destDir) {
964
1061
  if (!existsSync(srcDir)) return;
965
- createDir(destDir);
1062
+ createSafeManagedDirectory(destDir);
966
1063
  const entries = readdirSync(srcDir);
967
1064
  for (const entry of entries) {
968
1065
  if (entry.startsWith('.')) continue;
@@ -974,7 +1071,7 @@ export function copyCliSource(srcDir, destDir) {
974
1071
  if (stat.isDirectory()) {
975
1072
  copyCliSource(srcPath, destPath);
976
1073
  } else {
977
- copyFileSync(srcPath, destPath);
1074
+ copySafeManagedFile(srcPath, destPath);
978
1075
  }
979
1076
  }
980
1077
  }
@@ -984,7 +1081,7 @@ export function copyCliSource(srcDir, destDir) {
984
1081
  */
985
1082
  function copyDirVerbatim(srcDir, destDir) {
986
1083
  if (!existsSync(srcDir)) return;
987
- createDir(destDir);
1084
+ createSafeManagedDirectory(destDir);
988
1085
  for (const entry of readdirSync(srcDir)) {
989
1086
  const srcPath = join(srcDir, entry);
990
1087
  const destPath = join(destDir, entry);
@@ -993,7 +1090,11 @@ function copyDirVerbatim(srcDir, destDir) {
993
1090
  if (stat.isDirectory()) {
994
1091
  copyDirVerbatim(srcPath, destPath);
995
1092
  } else {
996
- try { copyFileSync(srcPath, destPath); } catch { /* skip unreadable */ }
1093
+ try { copySafeManagedFile(srcPath, destPath); }
1094
+ catch (error) {
1095
+ if (error?.code === 'UNSAFE_INSTALLATION_TARGET') throw error;
1096
+ /* skip unreadable source material */
1097
+ }
997
1098
  }
998
1099
  }
999
1100
  }
@@ -1021,7 +1122,7 @@ export function copyCliDependencies(pkgDir, destNodeModules) {
1021
1122
  // Resolve each dep from the package.json location (works for hoisted + nested layouts).
1022
1123
 
1023
1124
  const require = createRequire(join(pkgDir, 'package.json'));
1024
- createDir(destNodeModules);
1125
+ createSafeManagedDirectory(destNodeModules);
1025
1126
 
1026
1127
  const visited = new Set();
1027
1128
  const queue = [...deps];
@@ -1,5 +1,7 @@
1
- import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'fs';
2
- import { join, dirname } from 'path';
1
+ import {
2
+ readFileSync, writeFileSync, existsSync, statSync, lstatSync, mkdirSync, readdirSync,
3
+ } from 'fs';
4
+ import { join, dirname, posix } from 'path';
3
5
  import { fileURLToPath } from 'url';
4
6
  import { verify as cryptoVerify, createPublicKey } from 'crypto';
5
7
  import { hashFile } from './file-hasher.js';
@@ -139,15 +141,144 @@ export function compareManifests(oldManifest, newManifest) {
139
141
  * @returns {{ valid: boolean, reason: string }}
140
142
  */
141
143
  export function verifyManifest(manifest, signatureBase64, publicKeyPem = null) {
142
- // publicKeyPem is a test seam: production callers use the embedded key.
143
- const key = publicKeyPem ? createPublicKey(publicKeyPem) : SIGNING_PUBLIC_KEY;
144
- if (!key) return { valid: false, reason: 'no-public-key' };
144
+ try {
145
+ // publicKeyPem is a test seam: production callers use the embedded key.
146
+ const key = publicKeyPem ? createPublicKey(publicKeyPem) : SIGNING_PUBLIC_KEY;
147
+ if (!key) return { valid: false, reason: 'no-public-key' };
148
+ if (typeof signatureBase64 !== 'string'
149
+ || !/^[A-Za-z0-9+/]+={0,2}$/.test(signatureBase64)
150
+ || Buffer.from(signatureBase64, 'base64').length !== 64) {
151
+ return { valid: false, reason: 'invalid-signature-encoding' };
152
+ }
153
+
154
+ const manifestJson = serializeManifest(manifest);
155
+ const signature = Buffer.from(signatureBase64, 'base64');
156
+ const valid = cryptoVerify(null, Buffer.from(manifestJson), key, signature);
157
+ return { valid, reason: valid ? 'ok' : 'signature-mismatch' };
158
+ } catch {
159
+ return { valid: false, reason: 'manifest-verification-error' };
160
+ }
161
+ }
162
+
163
+ function isCanonicalManifestPath(filePath) {
164
+ return typeof filePath === 'string'
165
+ && filePath.length > 0
166
+ && !filePath.includes('\\')
167
+ && !filePath.includes('\0')
168
+ && !posix.isAbsolute(filePath)
169
+ && posix.normalize(filePath) === filePath
170
+ && filePath !== '.'
171
+ && !filePath.startsWith('../')
172
+ && !['manifest.json', 'manifest.sig'].includes(filePath);
173
+ }
174
+
175
+ function collectFrameworkFiles(rootDir) {
176
+ const files = new Map();
177
+
178
+ function walk(directory, segments = []) {
179
+ const entries = readdirSync(directory, { withFileTypes: true })
180
+ .sort((left, right) => left.name.localeCompare(right.name));
181
+ for (const entry of entries) {
182
+ if (segments.length === 0 && ['manifest.json', 'manifest.sig'].includes(entry.name)) continue;
183
+ const entrySegments = [...segments, entry.name];
184
+ const relativePath = entrySegments.join('/');
185
+ const absolutePath = join(rootDir, ...entrySegments);
186
+ const stat = lstatSync(absolutePath);
187
+ if (stat.isSymbolicLink()) return { valid: false, reason: 'framework-symlink', file: relativePath };
188
+ if (stat.isDirectory()) {
189
+ const nested = walk(absolutePath, entrySegments);
190
+ if (!nested.valid) return nested;
191
+ } else if (stat.isFile()) {
192
+ if (stat.nlink !== 1) return { valid: false, reason: 'framework-hardlink', file: relativePath };
193
+ files.set(relativePath, { absolutePath, stat });
194
+ } else {
195
+ return { valid: false, reason: 'framework-special-file', file: relativePath };
196
+ }
197
+ }
198
+ return { valid: true };
199
+ }
145
200
 
146
- const manifestJson = serializeManifest(manifest);
147
- const signature = Buffer.from(signatureBase64, 'base64');
201
+ const rootStat = lstatSync(rootDir);
202
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
203
+ return { valid: false, reason: 'invalid-framework-root', files };
204
+ }
205
+ const result = walk(rootDir);
206
+ return { ...result, files };
207
+ }
208
+
209
+ /**
210
+ * Verify that the signed manifest covers the exact framework file set and
211
+ * that every signed size/hash still matches the bytes about to be installed.
212
+ */
213
+ export function verifyManifestFiles(rootDir, manifest) {
214
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)
215
+ || !manifest.files || typeof manifest.files !== 'object' || Array.isArray(manifest.files)) {
216
+ return { valid: false, reason: 'invalid-manifest-schema' };
217
+ }
148
218
 
149
- const valid = cryptoVerify(null, Buffer.from(manifestJson), key, signature);
150
- return { valid, reason: valid ? 'ok' : 'signature-mismatch' };
219
+ const expected = new Map();
220
+ for (const [filePath, record] of Object.entries(manifest.files)) {
221
+ if (!isCanonicalManifestPath(filePath)) {
222
+ return { valid: false, reason: 'unsafe-manifest-path', file: filePath };
223
+ }
224
+ if (!record || typeof record !== 'object' || Array.isArray(record)
225
+ || !/^[a-f0-9]{64}$/.test(record.hash)
226
+ || !Number.isSafeInteger(record.size) || record.size < 0
227
+ || typeof record.type !== 'string') {
228
+ return { valid: false, reason: 'invalid-manifest-record', file: filePath };
229
+ }
230
+ expected.set(filePath, record);
231
+ }
232
+
233
+ let collected;
234
+ try { collected = collectFrameworkFiles(rootDir); }
235
+ catch (error) {
236
+ return { valid: false, reason: 'framework-read-error', detail: error.message };
237
+ }
238
+ if (!collected.valid) return collected;
239
+
240
+ for (const filePath of expected.keys()) {
241
+ if (!collected.files.has(filePath)) return { valid: false, reason: 'manifest-file-missing', file: filePath };
242
+ }
243
+ for (const filePath of collected.files.keys()) {
244
+ if (!expected.has(filePath)) return { valid: false, reason: 'unsigned-framework-file', file: filePath };
245
+ }
246
+
247
+ for (const [filePath, record] of expected) {
248
+ const actual = collected.files.get(filePath);
249
+ if (actual.stat.size !== record.size) return { valid: false, reason: 'framework-size-mismatch', file: filePath };
250
+ try {
251
+ if (hashFile(actual.absolutePath) !== record.hash) {
252
+ return { valid: false, reason: 'framework-hash-mismatch', file: filePath };
253
+ }
254
+ } catch (error) {
255
+ return { valid: false, reason: 'framework-read-error', file: filePath, detail: error.message };
256
+ }
257
+ }
258
+ return { valid: true, reason: 'ok', fileCount: expected.size };
259
+ }
260
+
261
+ /** Verify the complete signed framework source before the installer writes. */
262
+ export function verifyFrameworkSource(rootDir, { publicKeyPem = null, expectedVersion = null } = {}) {
263
+ const manifestPath = join(rootDir, 'manifest.json');
264
+ const signaturePath = join(rootDir, 'manifest.sig');
265
+ if (!existsSync(manifestPath)) return { valid: false, reason: 'manifest-missing' };
266
+ if (!existsSync(signaturePath)) return { valid: false, reason: 'signature-missing' };
267
+
268
+ let manifest;
269
+ let signature;
270
+ try {
271
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
272
+ signature = readFileSync(signaturePath, 'utf8').trim();
273
+ } catch (error) {
274
+ return { valid: false, reason: 'manifest-read-error', detail: error.message };
275
+ }
276
+ const signatureResult = verifyManifest(manifest, signature, publicKeyPem);
277
+ if (!signatureResult.valid) return signatureResult;
278
+ if (expectedVersion !== null && manifest.version !== expectedVersion) {
279
+ return { valid: false, reason: 'manifest-version-mismatch' };
280
+ }
281
+ return verifyManifestFiles(rootDir, manifest);
151
282
  }
152
283
 
153
284
  /**