delimit-cli 4.5.13 → 4.6.1

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 (53) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +9 -8
  3. package/bin/delimit-cli.js +179 -4
  4. package/bin/delimit-setup.js +46 -6
  5. package/gateway/ai/_compile_status.py +154 -0
  6. package/gateway/ai/agent_dispatch.py +41 -0
  7. package/gateway/ai/backends/git_health.py +175 -0
  8. package/gateway/ai/backends/tools_infra.py +163 -10
  9. package/gateway/ai/cli_contract.py +185 -0
  10. package/gateway/ai/daemon.py +10 -0
  11. package/gateway/ai/daily_digest.py +1 -2
  12. package/gateway/ai/delimit_daemon.py +67 -0
  13. package/gateway/ai/dispatch_gate.py +399 -0
  14. package/gateway/ai/governance.py +181 -0
  15. package/gateway/ai/heartbeat.py +290 -0
  16. package/gateway/ai/hot_reload.py +1 -2
  17. package/gateway/ai/led193_daemon/executor.py +9 -0
  18. package/gateway/ai/ledger_manager.py +90 -4
  19. package/gateway/ai/ledger_proof.py +127 -0
  20. package/gateway/ai/license.py +132 -47
  21. package/gateway/ai/license_core.cpython-310-x86_64-linux-gnu.so +0 -0
  22. package/gateway/ai/license_core.pyi +1 -1
  23. package/gateway/ai/notify.py +39 -0
  24. package/gateway/ai/outreach_loop_daemon.py +349 -0
  25. package/gateway/ai/outreach_substantive.py +1437 -0
  26. package/gateway/ai/pro_tools.yaml +167 -0
  27. package/gateway/ai/reaper.py +70 -0
  28. package/gateway/ai/reddit_scanner.py +17 -6
  29. package/gateway/ai/sensing/schema.py +1 -1
  30. package/gateway/ai/sensing/signal_store.py +0 -1
  31. package/gateway/ai/server.py +5490 -1602
  32. package/gateway/ai/social_capability/fit_floor.py +114 -12
  33. package/gateway/ai/social_queue.py +166 -10
  34. package/gateway/ai/tdqs_lint.py +611 -0
  35. package/gateway/ai/tenant_auth.py +329 -0
  36. package/gateway/ai/tenant_data.py +339 -0
  37. package/gateway/ai/tenant_paths.py +150 -0
  38. package/gateway/ai/usage_allowlist.py +198 -0
  39. package/gateway/ai/workers/base.py +2 -2
  40. package/gateway/ai/workers/executor.py +32 -3
  41. package/gateway/ai/workers/outreach_drafter.py +0 -1
  42. package/gateway/ai/workers/pr_drafter.py +0 -1
  43. package/gateway/ai/x_ranker.py +12 -2
  44. package/gateway/core/json_schema_diff.py +25 -1
  45. package/lib/auth-signin.js +136 -0
  46. package/lib/auth-signout.js +169 -0
  47. package/lib/delimit-template.js +11 -0
  48. package/lib/migration-2092-banner.js +213 -0
  49. package/package.json +5 -2
  50. package/server.json +4 -4
  51. package/scripts/build-license-core.sh +0 -85
  52. package/scripts/security-check.sh +0 -66
  53. package/scripts/test-license-core-so.sh +0 -107
package/CHANGELOG.md CHANGED
@@ -1,6 +1,54 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## [4.6.0] - 2026-05-15
5
+
6
+ ### Added — Codex CLI + Gemini CLI auto-trigger directives (LED-1399)
7
+
8
+ `delimit setup` now installs governance directives at the **verified-effective**
9
+ locations for Codex CLI and Gemini CLI, closing the gap where users of those
10
+ CLIs got the delimit MCP server wired but **not** the auto-trigger behaviors
11
+ (`delimit_test_smoke` after edits, `delimit_repo_diagnose` before commit, etc.)
12
+ that Claude Code users already get.
13
+
14
+ **What changed:**
15
+ - **Codex CLI**: writes `~/AGENTS.md` (verified against the Codex binary spec —
16
+ Codex auto-loads `AGENTS.md` "from CWD up to the root"). The previous
17
+ `~/.codex/instructions.md` write was dead code; Codex never read that path.
18
+ - **Gemini CLI**: writes `~/.gemini/GEMINI.md` (verified against the gemini-cli
19
+ bundle: `return ["GEMINI.md"]` is the discovery list; the global tier is
20
+ `~/.gemini/GEMINI.md`).
21
+ - Both gated on the respective CLI being installed (directory exists OR
22
+ `which` finds the binary). Both use the existing managed-section markers
23
+ (`<!-- delimit:start -->` / `<!-- delimit:end -->`) so user content is
24
+ preserved on upgrade per the LED-1257 customer-protection rule.
25
+
26
+ **Backwards-compat:** existing `~/.codex/instructions.md` files from prior
27
+ installs are NOT removed (harmless dead config; deleting could clobber user
28
+ customizations around our managed section).
29
+
30
+ ### Other changes
31
+
32
+ - **server.json merge-gate framing** (LED-2178): formal merge-gate framing in
33
+ the public server.json descriptor.
34
+ - **Memory Rules in `delimit init` template** (STR-143): the init-generated
35
+ CLAUDE.md now includes the canonical Memory Rules section.
36
+ - Documentation refreshes: cross-agent-handoff worked example surfaced on README,
37
+ test-count badge bumped, misleading version stamps removed.
38
+
39
+ ### Known issue (pre-existing, fix tracked) — **RETRACTED 2026-05-15**
40
+
41
+ > ~~**`delimit attest mcp` exit codes** (LED-1403): on tool error (e.g. no
42
+ > lockfile → npm audit unavailable) and unknown attestation kind, the CLI
43
+ > currently returns exit 1 instead of the expected exit 2.~~
44
+ >
45
+ > **Retraction:** the original report was a phantom test failure caused by a
46
+ > corrupted local git worktree (LED-1401), not a real CLI bug. On a clean
47
+ > clone, all 6 `attest-mcp` test suites pass and the CLI returns the correct
48
+ > exit codes (0 pass+skip / 1 fail / 2 error per STR-656). LED-1403 closed
49
+ > `not_reproducible`. No customer action required.
50
+
51
+
4
52
  ## [4.5.2] - 2026-05-02
5
53
 
6
54
  ### Hardened — `postinstall.js` never-block-install guard (LED-1188)
package/README.md CHANGED
@@ -5,10 +5,10 @@
5
5
  Wrap any AI coding assistant (Claude Code, Codex, Cursor, Gemini CLI) with a governance chain that runs your gates, records what changed, and signs a replayable receipt for every merge.
6
6
 
7
7
  [![npm](https://img.shields.io/npm/v/delimit-cli)](https://www.npmjs.com/package/delimit-cli)
8
- [![Tests](https://img.shields.io/badge/tests-1640%2B%20passing-brightgreen)](https://github.com/delimit-ai/delimit-mcp-server)
8
+ [![Tests](https://img.shields.io/badge/tests-4800%2B%20passing-brightgreen)](https://github.com/delimit-ai/delimit-mcp-server)
9
9
  [![GitHub Action](https://img.shields.io/badge/GitHub%20Action-latest-blue)](https://github.com/marketplace/actions/delimit-api-governance)
10
10
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
11
- [![Glama](https://glama.ai/mcp/servers/delimit-ai/delimit/badge)](https://glama.ai/mcp/servers/delimit-ai/delimit)
11
+ [![Glama Score](https://glama.ai/mcp/servers/delimit-ai/delimit-mcp-server/badges/score.svg)](https://glama.ai/mcp/servers/delimit-ai/delimit-mcp-server/score)
12
12
 
13
13
  ```console
14
14
  $ delimit wrap -- claude "fix the flaky test in tests/api.spec.ts"
@@ -32,8 +32,9 @@ Every wrapped run emits a `delimit.attestation.v1` bundle: repo head before/afte
32
32
 
33
33
  ## See it in action
34
34
 
35
- Worked example, real OSS repo, every claim verifiable:
35
+ Worked examples, real artifacts, every claim verifiable:
36
36
 
37
+ - **[Cross-agent handoff: one artifact, four CLIs](https://delimit.ai/reports/cross-agent-handoff)**: structured session handoff between Claude Code, Codex, Cursor, and Gemini CLI through a single JSON record at `~/.delimit/sessions/`. Persistent context across sessions, same artifact across four CLIs.
37
38
  - **[cal.com v2 API attestation](https://delimit.ai/reports/cal-com-v2-attestation)**: full diff, signed verdict, replayable bundle. Runs the same chain you get on day one.
38
39
 
39
40
  For the schema and signing methodology behind every report, see **[delimit.ai/methodology/mcp-attestation](https://delimit.ai/methodology/mcp-attestation)**.
@@ -86,7 +87,7 @@ npx delimit-cli init # Sets up governance + drift baseline
86
87
 
87
88
  ---
88
89
 
89
- ## What's New in v4.3
90
+ ## What's New
90
91
 
91
92
  *Gate every AI-assisted invocation. Ship the receipts.*
92
93
 
@@ -114,9 +115,9 @@ delimit ai-sbom -o ./ai-sbom.json
114
115
  # → components: 4 models detected, 187 gates run
115
116
  ```
116
117
 
117
- ## What's New in v4.20
118
+ ## Earlier releases
118
119
 
119
- *The highest state of AI governance.*
120
+ *The highest state of AI governance — earlier features still active.*
120
121
 
121
122
  - **`delimit doctor`** -- 14 prescriptive diagnostics. Every failure prints the exact command to fix it. `--ci` for pipelines, `--fix` for auto-repair.
122
123
  - **`delimit simulate`** -- policy dry-run. See what would be blocked before you commit. The `terraform plan` for API governance.
@@ -257,7 +258,7 @@ npx delimit-cli models --status # Show current model config
257
258
  npx delimit-cli status # Compact dashboard of your Delimit setup
258
259
  npx delimit-cli doctor # Check setup health
259
260
  npx delimit-cli uninstall --dry-run # Preview removal
260
- npx delimit-cli wrap -- claude -p "..." # Gate any AI-assisted CLI + signed attestation (v4.3)
261
+ npx delimit-cli wrap -- claude -p "..." # Gate any AI-assisted CLI + signed attestation
261
262
  npx delimit-cli wrap --max-time 60 -- codex "..."# With kill switch + handoff on timeout
262
263
  npx delimit-cli trust-page -o ./trust # Render attestations into a static trust page
263
264
  npx delimit-cli ai-sbom -o ./ai-sbom.json # Build a CycloneDX-AI bill of materials
@@ -419,4 +420,4 @@ If you spot another code path that could phone home without disclosure, file an
419
420
  - [npm](https://www.npmjs.com/package/delimit-cli) -- CLI package
420
421
  - [Pricing](https://delimit.ai/pricing) -- free tier + Pro
421
422
 
422
- MIT License
423
+ MIT License
@@ -69,7 +69,7 @@ function normalizeNaturalLanguageArgs(argv) {
69
69
  const explicitCommands = new Set([
70
70
  'install', 'mode', 'status', 'session', 'build', 'ask', 'policy', 'auth', 'audit',
71
71
  'explain-decision', 'uninstall', 'proxy', 'hook', 'version', 'vault', 'deliberate',
72
- 'remember', 'recall', 'forget', 'report'
72
+ 'remember', 'recall', 'forget', 'report', 'signin', 'signout', 'activate'
73
73
  ]);
74
74
  if (explicitCommands.has((raw[0] || '').toLowerCase())) {
75
75
  return raw;
@@ -4629,9 +4629,19 @@ program
4629
4629
  const prePushPath = path.join(hooksDir, 'pre-push');
4630
4630
  const marker = '# delimit-governance-hook';
4631
4631
 
4632
- // Resolution order: local node_modules → global PATH → npx fallback.
4633
- // npx is last because it can fail with Arborist 'extraneous' errors
4634
- // when a project's node_modules / lockfile drift (LED-1248).
4632
+ // Resolution order: local node_modules → global PATH →
4633
+ // global node_modules direct npx fallback.
4634
+ //
4635
+ // npx is the LAST resort because on some npm-arborist environments
4636
+ // it crashes with "Cannot read properties of undefined (reading
4637
+ // 'extraneous')" before reaching the CLI (LED-1207, LED-1248). That
4638
+ // failure mode silently breaks the gate and forces --no-verify,
4639
+ // which violates the no-silent-no-verify rule.
4640
+ //
4641
+ // The third tier (`node $(npm root -g)/delimit-cli/bin/delimit-cli.js`)
4642
+ // catches the case where delimit-cli is globally installed but its bin
4643
+ // shim isn't on PATH (npm-installed-but-symlink-missing, fresh CI
4644
+ // containers, etc.) — bypassing npm/npx entirely.
4635
4645
  const preCommitHook = `#!/bin/sh
4636
4646
  ${marker}
4637
4647
  # Delimit API governance gate
@@ -4640,6 +4650,8 @@ if [ -x ./node_modules/.bin/delimit-cli ]; then
4640
4650
  ./node_modules/.bin/delimit-cli check --staged
4641
4651
  elif command -v delimit-cli >/dev/null 2>&1; then
4642
4652
  delimit-cli check --staged
4653
+ elif _delimit_global="$(npm root -g 2>/dev/null)/delimit-cli/bin/delimit-cli.js" && [ -f "$_delimit_global" ]; then
4654
+ node "$_delimit_global" check --staged
4643
4655
  else
4644
4656
  npx delimit-cli check --staged
4645
4657
  fi
@@ -4653,6 +4665,8 @@ if [ -x ./node_modules/.bin/delimit-cli ]; then
4653
4665
  ./node_modules/.bin/delimit-cli check --base origin/main
4654
4666
  elif command -v delimit-cli >/dev/null 2>&1; then
4655
4667
  delimit-cli check --base origin/main
4668
+ elif _delimit_global="$(npm root -g 2>/dev/null)/delimit-cli/bin/delimit-cli.js" && [ -f "$_delimit_global" ]; then
4669
+ node "$_delimit_global" check --base origin/main
4656
4670
  else
4657
4671
  npx delimit-cli check --base origin/main
4658
4672
  fi
@@ -5653,6 +5667,140 @@ program
5653
5667
  } catch {}
5654
5668
  });
5655
5669
 
5670
+ // ---------------------------------------------------------------------------
5671
+ // LED-2100: `delimit signin` — capture delimit.ai OAuth bearer token so the
5672
+ // hosted-deliberation tier (LED-2092) can authenticate from the CLI. The
5673
+ // token is written to ~/.delimit/auth.json (mode 0600) where the gateway
5674
+ // reads it via deliberation.py::_read_oauth_token. Existing keys in
5675
+ // auth.json (e.g. from `delimit auth`) are preserved.
5676
+ //
5677
+ // Surface contract (do not change without coordinating with the gateway):
5678
+ // - Writes `delimit_token` and `access_token` (same value, dual-key for
5679
+ // compatibility with the gateway resolver's preferred-then-fallback read)
5680
+ // - Writes `signed_in_at` (ISO8601) and `email` (when supplied)
5681
+ // - Distinct from `delimit activate <key>` which writes license.json for
5682
+ // the Pro license-key flow. Sign-in is OAuth-bearer-token only.
5683
+ //
5684
+ // Default flow is paste-token (works in headless / SSH sessions). The
5685
+ // browser-callback flow is a follow-up once delimit.ai/account/cli ships
5686
+ // the cli-aware redirect endpoint.
5687
+ // ---------------------------------------------------------------------------
5688
+ program
5689
+ .command('signin')
5690
+ .description('Sign in to delimit.ai to enable hosted multi-model deliberation')
5691
+ .option('--token <token>', 'Provide the bearer token directly (skip prompt)')
5692
+ .option('--email <email>', 'Associate the token with an email address')
5693
+ .option('--status', 'Print current sign-in status without changes')
5694
+ .action(async (options) => {
5695
+ const { writeAuthToken, readCurrentToken, authFilePath } = require('../lib/auth-signin');
5696
+
5697
+ if (options.status) {
5698
+ const token = readCurrentToken();
5699
+ if (!token) {
5700
+ console.log(chalk.yellow(' Not signed in.'));
5701
+ console.log(chalk.dim(` Run: delimit signin`));
5702
+ process.exit(1);
5703
+ }
5704
+ console.log(chalk.green(' Signed in.'));
5705
+ console.log(chalk.dim(` Auth file: ${authFilePath()}`));
5706
+ return;
5707
+ }
5708
+
5709
+ let token = (options.token || process.env.DELIMIT_AUTH_TOKEN || '').trim();
5710
+ let email = (options.email || '').trim();
5711
+
5712
+ if (!token) {
5713
+ console.log(chalk.blue.bold('\n Delimit sign-in\n'));
5714
+ console.log(' 1. Open: ' + chalk.cyan('https://delimit.ai/account/cli'));
5715
+ console.log(' 2. Sign in and copy your CLI token');
5716
+ console.log(' 3. Paste it below\n');
5717
+
5718
+ const answers = await inquirer.prompt([
5719
+ {
5720
+ type: 'password',
5721
+ name: 'token',
5722
+ message: 'Paste token:',
5723
+ mask: '*',
5724
+ validate: (input) => {
5725
+ const v = (input || '').trim();
5726
+ if (!v) return 'Token cannot be empty.';
5727
+ if (v.length < 16) return 'Token looks too short — copy the full string from delimit.ai/account/cli.';
5728
+ return true;
5729
+ },
5730
+ },
5731
+ {
5732
+ type: 'input',
5733
+ name: 'email',
5734
+ message: 'Email (optional, for display only):',
5735
+ default: '',
5736
+ },
5737
+ ]);
5738
+ token = (answers.token || '').trim();
5739
+ email = email || (answers.email || '').trim();
5740
+ }
5741
+
5742
+ try {
5743
+ const result = writeAuthToken({ token, email });
5744
+ console.log('');
5745
+ if (email) {
5746
+ console.log(chalk.green(` Signed in as ${email}. Hosted deliberation enabled.`));
5747
+ } else {
5748
+ console.log(chalk.green(' Signed in. Hosted deliberation enabled.'));
5749
+ }
5750
+ console.log(chalk.dim(` Auth file: ${result.path} (mode 0600)`));
5751
+ if (result.merged) {
5752
+ console.log(chalk.dim(' Existing auth.json keys preserved.'));
5753
+ }
5754
+ console.log(chalk.dim(' To sign out: delimit signout'));
5755
+ console.log('');
5756
+ } catch (err) {
5757
+ console.error(chalk.red(` Sign-in failed: ${err.message}`));
5758
+ process.exit(1);
5759
+ }
5760
+ });
5761
+
5762
+ // ---------------------------------------------------------------------------
5763
+ // LED-2106: `delimit signout` — remove the OAuth bearer token written by
5764
+ // `delimit signin` (LED-2100) without clobbering the legacy bookkeeping
5765
+ // keys (configured / timestamp / tools) written by `delimit auth`. Today
5766
+ // users sign out via `rm ~/.delimit/auth.json` which wipes everything;
5767
+ // this command scrubs only the OAuth-related keys.
5768
+ //
5769
+ // Surface contract:
5770
+ // - Removes ONLY: delimit_token, access_token, signed_in_at, email
5771
+ // - Preserves: every other key (e.g. configured, timestamp, tools)
5772
+ // - Deletes auth.json entirely if it has no remaining keys after scrub
5773
+ // - Idempotent: safe to run when not signed in (prints "Not signed in.")
5774
+ // ---------------------------------------------------------------------------
5775
+ program
5776
+ .command('signout')
5777
+ .description('Sign out of delimit.ai (removes the hosted-deliberation token)')
5778
+ .action(async () => {
5779
+ const { removeAuthToken } = require('../lib/auth-signout');
5780
+ try {
5781
+ const result = removeAuthToken();
5782
+ if (!result.changed) {
5783
+ console.log(chalk.yellow(' Not signed in.'));
5784
+ return;
5785
+ }
5786
+ console.log('');
5787
+ if (result.email) {
5788
+ console.log(chalk.green(` Signed out (${result.email}).`));
5789
+ } else {
5790
+ console.log(chalk.green(' Signed out.'));
5791
+ }
5792
+ if (result.deleted) {
5793
+ console.log(chalk.dim(` Removed: ${result.path}`));
5794
+ } else {
5795
+ console.log(chalk.dim(` Auth file: ${result.path} (other keys preserved)`));
5796
+ }
5797
+ console.log('');
5798
+ } catch (err) {
5799
+ console.error(chalk.red(` Sign-out failed: ${err.message}`));
5800
+ process.exit(1);
5801
+ }
5802
+ });
5803
+
5656
5804
  // ---------------------------------------------------------------------------
5657
5805
  // LED-187: Export governance config as shareable JSON
5658
5806
  // ---------------------------------------------------------------------------
@@ -5842,6 +5990,21 @@ program
5842
5990
  .option('--mode <mode>', 'Deliberation mode: quick | dialogue | debate', 'dialogue')
5843
5991
  .option('--question <q>', 'Question to deliberate (alternative to positional arg)')
5844
5992
  .action(async (questionParts, options) => {
5993
+ // LED-2095: one-time migration banner for Free + BYOK users to
5994
+ // surface the LED-2092 / LED-2093 ephemeral-Free-tier change.
5995
+ // This fires on the first deliberation-adjacent call after the
5996
+ // upgrade and never again on this machine.
5997
+ try {
5998
+ const { maybeShowMigrationBanner } = require('../lib/migration-2092-banner');
5999
+ const banner = maybeShowMigrationBanner();
6000
+ if (banner.shown) {
6001
+ console.log(chalk.yellow(banner.text));
6002
+ }
6003
+ } catch {
6004
+ // Banner is purely informational; never block deliberation
6005
+ // because of a flag-file write or models.json read error.
6006
+ }
6007
+
5845
6008
  const question = options.question || (questionParts.length > 0 ? questionParts.join(' ') : null);
5846
6009
 
5847
6010
  if (options.list) {
@@ -6058,6 +6221,18 @@ program
6058
6221
  .description('Configure deliberation model API keys (BYOK)')
6059
6222
  .option('--status', 'Show current model configuration (non-interactive)')
6060
6223
  .action(async (options) => {
6224
+ // LED-2095: deliberation-adjacent surface — also a valid trigger
6225
+ // point for the one-time migration banner. See lib/migration-2092-banner.js.
6226
+ try {
6227
+ const { maybeShowMigrationBanner } = require('../lib/migration-2092-banner');
6228
+ const banner = maybeShowMigrationBanner();
6229
+ if (banner.shown) {
6230
+ console.log(chalk.yellow(banner.text));
6231
+ }
6232
+ } catch {
6233
+ // Banner is informational; never block the command on banner errors.
6234
+ }
6235
+
6061
6236
  const config = loadModelsConfig();
6062
6237
 
6063
6238
  // --status: non-interactive output
@@ -633,12 +633,52 @@ Run full governance compliance checks. Verify security, policy compliance, evide
633
633
  log(` ${dim(' CLAUDE.md already up to date')}`);
634
634
  }
635
635
 
636
- // Codex instructions
637
- const codexInstructions = path.join(os.homedir(), '.codex', 'instructions.md');
638
- if (fs.existsSync(path.join(os.homedir(), '.codex'))) {
639
- const codexResult = upsertDelimitSection(codexInstructions);
640
- if (codexResult.action !== 'unchanged') {
641
- log(` ${green('✓')} ${codexResult.action === 'created' ? 'Created' : 'Updated'} ${codexInstructions}`);
636
+ // Codex instructions: AGENTS.md is the file Codex CLI auto-loads
637
+ // ("from CWD up to the root", per Codex binary spec). The previous
638
+ // ~/.codex/instructions.md write was dead code — Codex never read it.
639
+ // LED-1399: install ~/AGENTS.md (parallels ~/CLAUDE.md) when codex CLI
640
+ // is present so user-home-rooted sessions pick up governance triggers.
641
+ const codexHome = path.join(os.homedir(), '.codex');
642
+ let hasCodex = fs.existsSync(codexHome);
643
+ if (!hasCodex) {
644
+ try { execSync('which codex 2>/dev/null', { stdio: 'pipe' }); hasCodex = true; } catch {}
645
+ }
646
+ if (hasCodex) {
647
+ const agentsMd = path.join(os.homedir(), 'AGENTS.md');
648
+ const agentsResult = upsertDelimitSection(agentsMd);
649
+ if (agentsResult.action === 'created') {
650
+ await logp(` ${green('✓')} Created ${agentsMd} (Codex CLI auto-loads from CWD up to home)`);
651
+ } else if (agentsResult.action === 'updated') {
652
+ await logp(` ${green('✓')} Updated Delimit section in ${agentsMd}`);
653
+ } else if (agentsResult.action === 'appended') {
654
+ await logp(` ${green('✓')} Appended Delimit section to ${agentsMd} (user content preserved)`);
655
+ } else {
656
+ log(` ${dim(' AGENTS.md already up to date')}`);
657
+ }
658
+ }
659
+
660
+ // Gemini CLI: GEMINI.md is the auto-loaded instruction file
661
+ // (~/.gemini/GEMINI.md is the user-global tier per Gemini CLI bundle).
662
+ // LED-1399: install when Gemini CLI is present so governance triggers
663
+ // fire across-projects without per-repo setup.
664
+ const geminiHome = path.join(os.homedir(), '.gemini');
665
+ let hasGemini = fs.existsSync(geminiHome);
666
+ if (!hasGemini) {
667
+ try { execSync('which gemini 2>/dev/null', { stdio: 'pipe' }); hasGemini = true; } catch {}
668
+ }
669
+ if (hasGemini) {
670
+ // Ensure ~/.gemini exists before writing the user-global GEMINI.md.
671
+ try { fs.mkdirSync(geminiHome, { recursive: true, mode: 0o755 }); } catch {}
672
+ const geminiMd = path.join(geminiHome, 'GEMINI.md');
673
+ const geminiResult = upsertDelimitSection(geminiMd);
674
+ if (geminiResult.action === 'created') {
675
+ await logp(` ${green('✓')} Created ${geminiMd} (Gemini CLI user-global instructions)`);
676
+ } else if (geminiResult.action === 'updated') {
677
+ await logp(` ${green('✓')} Updated Delimit section in ${geminiMd}`);
678
+ } else if (geminiResult.action === 'appended') {
679
+ await logp(` ${green('✓')} Appended Delimit section to ${geminiMd} (user content preserved)`);
680
+ } else {
681
+ log(` ${dim(' GEMINI.md already up to date')}`);
642
682
  }
643
683
  }
644
684
 
@@ -0,0 +1,154 @@
1
+ """LED-2087 Phase 1a — proprietary-module compilation status helper.
2
+
3
+ Customers can run on the Python source fallback (slower; no compiled-
4
+ attestation parity) when the platform-appropriate ``.so`` /``.pyd``
5
+ isn't shipped in their bundle. The 3 proprietary modules
6
+ (``license_core``, ``deliberation``, ``governance``) each get this
7
+ treatment via the LED-1259 warn-and-fallback path for ``license_core``
8
+ and (post-LED-2087-phase-1a) the same introspection here for the
9
+ other two.
10
+
11
+ This module is intentionally minimal-surface:
12
+ - No modification of the proprietary source modules
13
+ - No new MCP tool added to the customer-facing surface
14
+ - One INFO-level log line at gateway startup (silent on the happy
15
+ Linux x86_64 / py3.10 path where all three are native-loaded)
16
+ - Importable status helper for tests + future replay tooling
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import importlib
21
+ import logging
22
+ from typing import Dict, Iterable, List, Optional, Tuple
23
+
24
+ logger = logging.getLogger("delimit.ai._compile_status")
25
+
26
+ # Modules audited by this helper. Each tuple is (import_name, friendly_label).
27
+ # ai.license_core already has the LED-1259 warn-and-fallback path in
28
+ # ai/license.py — we still introspect it here for the consolidated
29
+ # startup report.
30
+ PROPRIETARY_MODULES: Tuple[Tuple[str, str], ...] = (
31
+ ("ai.license_core", "license_core"),
32
+ ("ai.deliberation", "deliberation"),
33
+ ("ai.governance", "governance"),
34
+ )
35
+
36
+ # Extensions Python uses for native compiled modules across platforms.
37
+ # .so — Linux + macOS
38
+ # .pyd — Windows
39
+ # .dylib — macOS dynamic libs (Python typically uses .so on macOS too,
40
+ # but include for defensive coverage)
41
+ _NATIVE_EXTS: Tuple[str, ...] = (".so", ".pyd", ".dylib")
42
+
43
+
44
+ def is_native_compiled(import_name: str) -> Optional[bool]:
45
+ """Return True if the named module loaded from a native binary
46
+ (``.so`` / ``.pyd`` / ``.dylib``), False if it loaded from
47
+ ``.py`` source, or None if the module isn't importable at all.
48
+
49
+ Pure introspection — no side effects beyond the import call itself.
50
+ The import is harmless: if the module is already imported (almost
51
+ always true at gateway startup) ``importlib`` returns the cached
52
+ module without re-executing.
53
+ """
54
+ try:
55
+ mod = importlib.import_module(import_name)
56
+ except ImportError:
57
+ return None
58
+ path = getattr(mod, "__file__", "") or ""
59
+ if not path:
60
+ # Some build pipelines produce modules without ``__file__``
61
+ # (e.g. frozen modules). Conservatively treat as "unknown but
62
+ # importable" — surface as None so callers don't assume either
63
+ # native or source state.
64
+ return None
65
+ for ext in _NATIVE_EXTS:
66
+ if path.endswith(ext):
67
+ return True
68
+ if path.endswith(".py"):
69
+ return False
70
+ # Unknown extension (e.g. .pyc) — conservatively treat as not-native.
71
+ return False
72
+
73
+
74
+ def compilation_status_report(
75
+ modules: Iterable[Tuple[str, str]] = PROPRIETARY_MODULES,
76
+ ) -> Dict[str, str]:
77
+ """Return ``{friendly_label: status}`` for each module.
78
+
79
+ Status values:
80
+ - ``"native"`` — loaded from .so / .pyd / .dylib
81
+ - ``"source"`` — loaded from .py (fallback path)
82
+ - ``"missing"`` — module not importable at all
83
+ - ``"unknown"`` — importable but ``__file__`` unrecognized
84
+
85
+ Used by the startup logger + tests + future status-query tooling.
86
+ Deterministic given the same set of imported modules + the same
87
+ platform (no clock-dependent state).
88
+ """
89
+ report: Dict[str, str] = {}
90
+ for import_name, label in modules:
91
+ native = is_native_compiled(import_name)
92
+ if native is True:
93
+ report[label] = "native"
94
+ elif native is False:
95
+ # Distinguish source-known-extension from unknown.
96
+ try:
97
+ mod = importlib.import_module(import_name)
98
+ path = getattr(mod, "__file__", "") or ""
99
+ report[label] = "source" if path.endswith(".py") else "unknown"
100
+ except ImportError:
101
+ # Should not happen post-is_native_compiled returning False,
102
+ # but cover the race anyway.
103
+ report[label] = "missing"
104
+ else:
105
+ report[label] = "missing"
106
+ return report
107
+
108
+
109
+ def log_compilation_status_on_startup(
110
+ modules: Iterable[Tuple[str, str]] = PROPRIETARY_MODULES,
111
+ ) -> None:
112
+ """Emit one log line summarizing the proprietary-module load state.
113
+
114
+ Silent on the happy path (Linux x86_64 / Python 3.10 dev box where
115
+ all three modules are native-loaded — actually emits INFO, but
116
+ the message clearly says "all native" so ops can scan past).
117
+
118
+ Calls this exactly once at gateway server startup. Idempotent: if
119
+ a future caller invokes it twice, both emissions show the same
120
+ state because is_native_compiled is pure.
121
+ """
122
+ report = compilation_status_report(modules)
123
+ native = [label for label, status in report.items() if status == "native"]
124
+ source = [label for label, status in report.items() if status == "source"]
125
+ missing = [label for label, status in report.items() if status == "missing"]
126
+ unknown = [label for label, status in report.items() if status == "unknown"]
127
+
128
+ if not source and not missing and not unknown:
129
+ logger.info(
130
+ "[LED-2087] proprietary modules native-loaded: %s",
131
+ ", ".join(native) if native else "(none)",
132
+ )
133
+ return
134
+
135
+ # Customer-facing: ANY non-native module gets surfaced clearly so the
136
+ # operator knows performance / attestation parity is degraded for
137
+ # those specific modules. This is the LED-1259 warn-and-fallback
138
+ # pattern extended to deliberation + governance.
139
+ fragments: List[str] = []
140
+ if native:
141
+ fragments.append(f"native={','.join(native)}")
142
+ if source:
143
+ fragments.append(f"source-fallback={','.join(source)}")
144
+ if missing:
145
+ fragments.append(f"missing={','.join(missing)}")
146
+ if unknown:
147
+ fragments.append(f"unknown={','.join(unknown)}")
148
+ logger.warning(
149
+ "[LED-2087] proprietary-module load state — %s. Source-fallback "
150
+ "and missing modules run Python source path (slower; no compiled-"
151
+ "attestation parity). Cross-platform binaries land per the "
152
+ "LED-2087 Phase 1 build matrix.",
153
+ " ".join(fragments),
154
+ )
@@ -36,6 +36,11 @@ DLQ_AUTO_PAUSE_THRESHOLD = 20
36
36
  TASK_TYPE_ROUTER = {
37
37
  # Outreach and social work — Gemini Flash is fast and cheap
38
38
  "outreach": "gemini",
39
+ # LED-2214b: substantive github outreach gets the same default
40
+ # routing as generic outreach (cheap, fast drafter) but is named
41
+ # distinctly so a regression that resurrects the generic dispatch
42
+ # path does not silently land here.
43
+ "outreach_substantive": "gemini",
39
44
  "social": "gemini",
40
45
  "content": "gemini",
41
46
  "sensor": "gemini",
@@ -161,6 +166,42 @@ def dispatch_task(
161
166
  if priority not in VALID_PRIORITIES:
162
167
  return {"error": f"priority must be one of: {', '.join(sorted(VALID_PRIORITIES))}"}
163
168
 
169
+ # LED-1279: anti-duplicate gate. If the title/description/context tags an
170
+ # LED that's already been shipped (i.e. there's a commit on main mentioning
171
+ # the LED with date >= LED.created_at), refuse the dispatch and auto-close
172
+ # the LED. Yesterday's AGT-65A61AD5 wasted three subagent cycles on
173
+ # LED-1208/9/10, all of which had been shipped in commit 014fb5c on
174
+ # 2026-05-03. This gate prevents that class of duplicate.
175
+ try:
176
+ from ai.dispatch_gate import evaluate_dispatch, extract_led_id, lookup_led_created_at
177
+
178
+ led_id_for_gate = extract_led_id(title, description, context)
179
+ if led_id_for_gate:
180
+ led_created_at = lookup_led_created_at(led_id_for_gate)
181
+ refusal = evaluate_dispatch(
182
+ title=title,
183
+ description=description,
184
+ context=context,
185
+ led_created_at=led_created_at,
186
+ )
187
+ if refusal is not None:
188
+ _append_audit({
189
+ "action": "dispatch_refused_shipped",
190
+ "title": stripped,
191
+ "led_id": refusal.get("led_id"),
192
+ "shipped_in": refusal.get("shipped_in", {}).get("short_sha"),
193
+ "shipped_repo": refusal.get("shipped_in", {}).get("repo"),
194
+ })
195
+ return refusal
196
+ except Exception as e: # pragma: no cover — gate must never crash dispatch
197
+ # If the gate itself blows up, log it and proceed — losing a dispatch
198
+ # to a gate bug is a worse failure mode than the duplicate it would
199
+ # have caught.
200
+ _append_audit({
201
+ "action": "dispatch_gate_error",
202
+ "error": str(e)[:200],
203
+ })
204
+
164
205
  tasks = _load_tasks()
165
206
 
166
207
  normalized_external_key = external_key.strip()