delimit-cli 4.5.1 → 4.5.2

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 (47) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +2 -2
  3. package/bin/delimit-cli.js +109 -24
  4. package/gateway/ai/content_engine.py +3 -4
  5. package/gateway/ai/inbox_classifier.py +215 -0
  6. package/gateway/ai/integrations/opensage_wrapper.py +4 -1
  7. package/gateway/ai/ledger_manager.py +218 -38
  8. package/gateway/ai/license.py +26 -0
  9. package/gateway/ai/notify.py +68 -3
  10. package/gateway/ai/reddit_proxy.py +93 -15
  11. package/gateway/ai/reddit_scanner.py +36 -18
  12. package/gateway/ai/server.py +128 -6
  13. package/gateway/ai/social_capability/__init__.py +6 -0
  14. package/gateway/ai/social_capability/capability_validator.py +273 -0
  15. package/gateway/ai/social_capability/current_capabilities.yaml +95 -0
  16. package/gateway/ai/social_queue.py +307 -0
  17. package/gateway/ai/supabase_sync.py +14 -2
  18. package/gateway/ai/swarm.py +29 -11
  19. package/gateway/ai/tui.py +6 -2
  20. package/gateway/ai/x_ranker.py +276 -0
  21. package/lib/attest-mcp.js +487 -0
  22. package/lib/attest-telemetry.js +48 -0
  23. package/lib/delimit-home.js +35 -0
  24. package/lib/delimit-template.js +14 -0
  25. package/package.json +8 -2
  26. package/scripts/postinstall.js +89 -40
  27. package/gateway/ai/content_grounding/__init__.py +0 -98
  28. package/gateway/ai/content_grounding/build.py +0 -350
  29. package/gateway/ai/content_grounding/consume.py +0 -280
  30. package/gateway/ai/content_grounding/features.py +0 -218
  31. package/gateway/ai/content_grounding/fixtures/fail/01_missing_evidence.json +0 -9
  32. package/gateway/ai/content_grounding/fixtures/fail/02_unknown_evidence_prefix.json +0 -9
  33. package/gateway/ai/content_grounding/fixtures/fail/03_banned_comparative.json +0 -17
  34. package/gateway/ai/content_grounding/fixtures/fail/04_banned_adoption.json +0 -17
  35. package/gateway/ai/content_grounding/fixtures/fail/05_aggregate_no_numeric.json +0 -17
  36. package/gateway/ai/content_grounding/fixtures/fail/06_unversioned_inference_rule.json +0 -18
  37. package/gateway/ai/content_grounding/fixtures/pass/01_feature_shipped.json +0 -18
  38. package/gateway/ai/content_grounding/fixtures/pass/02_aggregate_claim.json +0 -23
  39. package/gateway/ai/content_grounding/fixtures/pass/03_attestation.json +0 -16
  40. package/gateway/ai/content_grounding/schemas/claim.schema.json +0 -40
  41. package/gateway/ai/content_grounding/schemas/event.schema.json +0 -23
  42. package/gateway/ai/content_grounding/schemas.py +0 -276
  43. package/gateway/ai/content_grounding/telemetry.py +0 -221
  44. package/gateway/ai/inbox_drafts/__init__.py +0 -61
  45. package/gateway/ai/inbox_drafts/registry.py +0 -412
  46. package/gateway/ai/inbox_drafts/schema.py +0 -374
  47. package/gateway/ai/inbox_executor.py +0 -565
package/CHANGELOG.md CHANGED
@@ -1,6 +1,93 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## [4.5.2] - 2026-05-02
5
+
6
+ ### Hardened — `postinstall.js` never-block-install guard (LED-1188)
7
+
8
+ `scripts/postinstall.js` is now wrapped in a top-level guard that ensures no
9
+ postinstall failure mode can ever block `npm install delimit-cli`. Per the
10
+ customer-protection rule, npm publish is a production deploy and a postinstall
11
+ crash on a paying Pro user's machine is a customer-facing incident regardless
12
+ of root cause.
13
+
14
+ Failure modes now hardened:
15
+ - **EROFS / EACCES / EPERM / EPIPE on stdout** — every banner write is wrapped
16
+ in a try/catch (some sandbox installers redirect stdout to a read-only pipe).
17
+ - **Network unreachable / DNS fail / TLS error / proxy reject** — the
18
+ telemetry HTTPS request silent-fails at every layer (`req.on('error')`,
19
+ `req.on('timeout')`, outer try/catch).
20
+ - **Missing or unreadable `package.json`** — graceful no-op; lets the install
21
+ complete so `delimit doctor` can diagnose the partial-install state.
22
+ - **`uncaughtException` propagation** — outermost guard swallows any
23
+ synchronous throw from the IIFE so the npm process always sees exit 0.
24
+ - **Idempotency** — re-running install is a no-op; the postinstall does not
25
+ write to `~/.delimit/` (that's `bin/delimit-setup.js`).
26
+ - **`DELIMIT_NO_TELEMETRY=1|true|yes`** kill switch honored (case-insensitive).
27
+
28
+ Regression coverage: `tests/postinstall-hardening.test.js` (6 tests) locks the
29
+ contract.
30
+
31
+ ### Added — `lib/delimit-home.js` env-var unification (LED-1188)
32
+
33
+ Single source of truth for resolving the Delimit private-state directory. Replaces
34
+ ~37 hardcoded `path.join(os.homedir(), '.delimit')` sites across the CLI surface.
35
+
36
+ Resolution order:
37
+ 1. `$DELIMIT_HOME` (preferred — explicit, easy to reason about)
38
+ 2. `$DELIMIT_NAMESPACE_ROOT` (gateway-compat fallback)
39
+ 3. `<homedir>/.delimit` (default)
40
+
41
+ Both helpers re-resolve on every call so tests can mutate `process.env` between
42
+ calls without module-cache invalidation. Public API:
43
+
44
+ ```js
45
+ const { delimitHome, homeSubpath } = require('delimit-cli/lib/delimit-home');
46
+ const ledger = homeSubpath('ledger'); // shorthand for path.join(delimitHome(), 'ledger')
47
+ ```
48
+
49
+ Regression coverage: `tests/delimit-home.test.js` (9 tests).
50
+
51
+ ### Carry — STR-656 `delimit attest mcp` v1 (PRs #73, #74, #76, #77)
52
+
53
+ The four mcp-server PRs already merged into main are carried to npm in this
54
+ release. No customer-facing change beyond what those PRs documented:
55
+
56
+ - **#73** — `delimit attest mcp` panel-verdict behavior locks (Q1–Q6):
57
+ live MCP-protocol-conformance probe, 3-tier exit codes, `--output` /
58
+ `--no-write` flags, EROFS soft-fail on the preview JSON write, telemetry
59
+ counter with `DELIMIT_NO_TELEMETRY` kill switch, top-level runtime guard.
60
+ `--write` is now deprecated alias for `--output` (will be removed in v4.7).
61
+ - **#74** — `delimit setup` template + `package.json` files-array gate
62
+ `gateway/ai/self_repair/` and `self_repair_daemon.py` as gateway-only.
63
+ - **#76** — Template reframe of the operating-model rule per swarm-executor
64
+ panel verdict.
65
+ - **#77** — `package.json` files-array gate `gateway/ai/corp_dashboard.py`
66
+ as gateway-only.
67
+
68
+ Regression coverage: `tests/attest-mcp.test.js` (10 tests, now in the npm
69
+ test script — was previously orphaned).
70
+
71
+ ### Bundle — proprietary gating extended
72
+
73
+ `package.json` files-array now excludes three additional gateway-internal
74
+ modules from the npm bundle (per the npm-bundle proprietary-gating rule —
75
+ these are not customer-shipping CLI surfaces):
76
+
77
+ - `!gateway/ai/content_grounding/` — LED-1084 grounding layer
78
+ - `!gateway/ai/inbox_drafts/` — LED-1129 SQLite draft registry
79
+ - `!gateway/ai/inbox_executor.py` — LED-1134 inbox executor
80
+
81
+ These remain gateway-only; they are imported only by gateway daemons and have
82
+ no public CLI surface.
83
+
84
+ ### Backward compatibility
85
+
86
+ - No MCP tool signature changes
87
+ - No CLI command renamed or removed
88
+ - No storage format change
89
+ - All previously-passing tests still pass; 25 new tests added (171 → 196)
90
+
4
91
  ## [4.5.1] - 2026-04-28
5
92
 
6
93
  ### Security — attestation `canonicalize()` strengthened (LED-1180)
package/README.md CHANGED
@@ -5,8 +5,8 @@
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-165%20passing-brightgreen)](https://github.com/delimit-ai/delimit-mcp-server)
9
- [![GitHub Action](https://img.shields.io/badge/GitHub%20Action-v1.6.0-blue)](https://github.com/marketplace/actions/delimit-api-governance)
8
+ [![Tests](https://img.shields.io/badge/tests-1640%2B%20passing-brightgreen)](https://github.com/delimit-ai/delimit-mcp-server)
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
11
  [![Glama](https://glama.ai/mcp/servers/delimit-ai/delimit/badge)](https://glama.ai/mcp/servers/delimit-ai/delimit)
12
12
 
@@ -11,6 +11,7 @@ const inquirer = require('inquirer');
11
11
  const DelimitAuthSetup = require('../lib/auth-setup');
12
12
  const DelimitHooksInstaller = require('../lib/hooks-installer');
13
13
  const crossModelHooks = require('../lib/cross-model-hooks');
14
+ const { delimitHome, homeSubpath } = require('../lib/delimit-home');
14
15
  const {
15
16
  resolveContinuityContext,
16
17
  formatContinuityReport,
@@ -58,8 +59,8 @@ function normalizeNaturalLanguageArgs(argv) {
58
59
  const raw = argv.slice(2);
59
60
  if (raw.length === 0) {
60
61
  // First-run detection: if no ~/.delimit exists, show welcome flow
61
- const delimitHome = path.join(os.homedir(), '.delimit');
62
- if (!fs.existsSync(delimitHome) || !fs.existsSync(path.join(delimitHome, 'server'))) {
62
+ const home = delimitHome();
63
+ if (!fs.existsSync(home) || !fs.existsSync(path.join(home, 'server'))) {
63
64
  return ['scan']; // lowest friction entry point for new users
64
65
  }
65
66
  return resolveRepoRoot(process.cwd()) ? ['session', '--inspect'] : ['session', '--all'];
@@ -1093,8 +1094,7 @@ program
1093
1094
  .option('--dry-run', 'Preview what would be removed without making changes')
1094
1095
  .action(async (options) => {
1095
1096
  const dryRun = options.dryRun;
1096
- const HOME = process.env.HOME;
1097
- const backupDir = path.join(HOME, '.delimit', 'backups', `uninstall-${Date.now()}`);
1097
+ const backupDir = homeSubpath('backups', `uninstall-${Date.now()}`);
1098
1098
  const changes = [];
1099
1099
 
1100
1100
  if (dryRun) {
@@ -1349,8 +1349,7 @@ program
1349
1349
 
1350
1350
  // Helper function for installation
1351
1351
  async function installDelimit(mode, scope, hooksType = 'all') {
1352
- const HOME = process.env.HOME;
1353
- const DELIMIT_HOME = path.join(HOME, '.delimit');
1352
+ const DELIMIT_HOME = delimitHome();
1354
1353
 
1355
1354
  // Create directories
1356
1355
  ['bin', 'hooks', 'shims', 'config', 'audit', 'credentials'].forEach(dir => {
@@ -2554,7 +2553,7 @@ program
2554
2553
  .action(async () => {
2555
2554
  console.log(chalk.bold('\n Delimit — Resume Work\n'));
2556
2555
 
2557
- const DELIMIT_HOME = path.join(os.homedir(), '.delimit');
2556
+ const DELIMIT_HOME = delimitHome();
2558
2557
 
2559
2558
  // 1. Last session handoff
2560
2559
  const sessionsDir = path.join(DELIMIT_HOME, 'sessions');
@@ -3285,10 +3284,10 @@ program
3285
3284
  .option('--format <fmt>', 'Output format: md, json, html', 'md')
3286
3285
  .option('--output <file>', 'Write report to file instead of stdout')
3287
3286
  .action(async (options) => {
3288
- const delimitHome = path.join(os.homedir(), '.delimit');
3289
- const evidenceDir = path.join(delimitHome, 'evidence');
3290
- const ledgerDir = path.join(delimitHome, 'ledger');
3291
- const memoryDir = path.join(delimitHome, 'memory');
3287
+ const home = delimitHome();
3288
+ const evidenceDir = path.join(home, 'evidence');
3289
+ const ledgerDir = path.join(home, 'ledger');
3290
+ const memoryDir = path.join(home, 'memory');
3292
3291
 
3293
3292
  // Parse duration into milliseconds
3294
3293
  function parseDuration(dur) {
@@ -4906,6 +4905,94 @@ program
4906
4905
  }
4907
4906
  });
4908
4907
 
4908
+ // STR-656 — `delimit attest mcp` local-preview command (no public attestation,
4909
+ // no badge, no publish). Per the methodology gate (STR-657) the public signed-
4910
+ // attestation surface is locked until: 30d methodology visibility +
4911
+ // 14d CLI shipped + 5+ merge-gate pilot reference accounts + incident-
4912
+ // response process documented. This command exists so maintainers can
4913
+ // run the methodology checks locally and see the preview report shape.
4914
+ program
4915
+ .command('attest <kind>')
4916
+ .description('Run a Delimit attestation methodology check locally (preview only). kind: mcp')
4917
+ .option('--path <dir>', 'Path to repo (default: cwd)')
4918
+ .option('--json', 'Emit machine-readable JSON instead of the text preview')
4919
+ .option('--output <file>', 'Write the preview JSON to a file (default: .delimit/attestation-preview.json)')
4920
+ .option('--write <file>', '(deprecated) alias for --output')
4921
+ .option('--no-write', 'Do not write the preview JSON to disk')
4922
+ .action(async (kind, opts) => {
4923
+ const { recordTelemetry } = require('../lib/attest-telemetry');
4924
+ // --write is the deprecated alias for --output. Emit a one-line
4925
+ // notice so users migrate before we remove it. (Panel verdict on
4926
+ // STR-656 pre-push gate, 2026-04-30: retire the alias in v4.7.)
4927
+ if (opts.write && typeof opts.write === 'string' && !opts.output) {
4928
+ console.error(chalk.yellow(
4929
+ ' [deprecation] --write is deprecated; use --output instead. (will be removed in v4.7)'
4930
+ ));
4931
+ }
4932
+ if (kind !== 'mcp') {
4933
+ console.log(chalk.yellow(` Unknown attestation kind: ${kind}`));
4934
+ console.log(chalk.gray(' Supported kinds (v1): mcp'));
4935
+ console.log(chalk.gray(' pr-review and release attestations land in a follow-up.'));
4936
+ recordTelemetry({ kind, outcome: 'unknown_kind' });
4937
+ process.exitCode = 2;
4938
+ return;
4939
+ }
4940
+ const { runAttestMcp, renderPreview } = require('../lib/attest-mcp');
4941
+ let report;
4942
+ try {
4943
+ report = await runAttestMcp({ path: opts.path });
4944
+ } catch (e) {
4945
+ console.log(chalk.red(` attest mcp crashed: ${e.message}`));
4946
+ recordTelemetry({ kind: 'mcp', outcome: 'runner_crash', error: e.message });
4947
+ process.exitCode = 2;
4948
+ return;
4949
+ }
4950
+ if (report.error) {
4951
+ console.log(chalk.red(` ${report.error}`));
4952
+ recordTelemetry({ kind: 'mcp', outcome: 'runner_error', error: report.error });
4953
+ process.exitCode = 2;
4954
+ return;
4955
+ }
4956
+ if (opts.json) {
4957
+ console.log(JSON.stringify(report, null, 2));
4958
+ } else {
4959
+ console.log(renderPreview(report));
4960
+ }
4961
+ // Write the JSON report unless --no-write was given. --output wins,
4962
+ // --write is the deprecated alias kept for ≥1 minor cycle.
4963
+ if (opts.write !== false) {
4964
+ const outPath = opts.output || opts.write ||
4965
+ path.join(report.repo.path, '.delimit', 'attestation-preview.json');
4966
+ try {
4967
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
4968
+ fs.writeFileSync(outPath, JSON.stringify(report, null, 2) + '\n');
4969
+ if (!opts.json) console.log(chalk.gray(` Preview JSON: ${outPath}\n`));
4970
+ } catch (e) {
4971
+ // Soft-fail: a read-only filesystem (EROFS) or permissions
4972
+ // issue must NOT elevate the exit code — the report itself
4973
+ // is on stdout already.
4974
+ if (!opts.json) {
4975
+ console.log(chalk.yellow(` could not write preview JSON (${e.code || 'IO'}): ${e.message}`));
4976
+ }
4977
+ }
4978
+ }
4979
+ // 3-tier exit codes (panel verdict on STR-656 scaffold review):
4980
+ // 0 — pass + skip (no policy failure, no tool error)
4981
+ // 1 — at least one check returned fail (policy violation)
4982
+ // 2 — at least one check returned error (tool unavailable / unreadable input)
4983
+ // Skips do NOT raise the exit code; they are evidence states, not failures.
4984
+ const anyFail = report.checks.some((c) => c.status === 'fail');
4985
+ const anyError = report.checks.some((c) => c.status === 'error');
4986
+ const exitCode = anyFail ? 1 : (anyError ? 2 : 0);
4987
+ recordTelemetry({
4988
+ kind: 'mcp',
4989
+ outcome: ['pass', 'fail', 'error'][exitCode],
4990
+ methodology_version: report.methodology_version,
4991
+ check_summary: report.checks.map((c) => `${c.id}:${c.status}`).join(','),
4992
+ });
4993
+ process.exitCode = exitCode;
4994
+ });
4995
+
4909
4996
  // CI command — generate GitHub Action workflow
4910
4997
  program
4911
4998
  .command('ci')
@@ -5477,8 +5564,7 @@ program
5477
5564
  .command('activate <key>')
5478
5565
  .description('Activate a Delimit Pro license key')
5479
5566
  .action(async (key) => {
5480
- const os = require('os');
5481
- const licenseDir = path.join(os.homedir(), '.delimit');
5567
+ const licenseDir = delimitHome();
5482
5568
  const licensePath = path.join(licenseDir, 'license.json');
5483
5569
 
5484
5570
  if (!key || key.length < 10) {
@@ -5766,8 +5852,7 @@ program
5766
5852
  console.log(`Question: ${chalk.bold(question)}\n`);
5767
5853
 
5768
5854
  // Try to run deliberation directly via the gateway
5769
- const HOME = process.env.HOME || require('os').homedir();
5770
- const gatewayScript = path.join(HOME, '.delimit', 'server', 'ai', 'deliberation.py');
5855
+ const gatewayScript = homeSubpath('server', 'ai', 'deliberation.py');
5771
5856
  const scriptPath = fs.existsSync(gatewayScript) ? gatewayScript : null;
5772
5857
 
5773
5858
  if (scriptPath) {
@@ -5809,7 +5894,7 @@ if result.get('summary'):
5809
5894
  }
5810
5895
 
5811
5896
  // Save pending deliberation to file for reference
5812
- const deliberationDir = path.join(HOME, '.delimit', 'deliberation');
5897
+ const deliberationDir = homeSubpath('deliberation');
5813
5898
  fs.mkdirSync(deliberationDir, { recursive: true });
5814
5899
  const pending = {
5815
5900
  question,
@@ -5848,8 +5933,8 @@ if result.get('summary'):
5848
5933
  // Models command: BYOK deliberation key management wizard
5849
5934
  // ---------------------------------------------------------------------------
5850
5935
 
5851
- const MODELS_CONFIG_PATH = path.join(os.homedir(), '.delimit', 'models.json');
5852
- const DELIBERATION_USAGE_PATH = path.join(os.homedir(), '.delimit', 'deliberation_usage.json');
5936
+ const MODELS_CONFIG_PATH = homeSubpath('models.json');
5937
+ const DELIBERATION_USAGE_PATH = homeSubpath('deliberation_usage.json');
5853
5938
 
5854
5939
  const DEFAULT_MODELS = {
5855
5940
  grok: { enabled: false, api_key: '', model: 'grok-4-0709', name: 'Grok 4' },
@@ -6199,7 +6284,7 @@ program
6199
6284
  program
6200
6285
  .command('trust-page')
6201
6286
  .description('Render attestations into a public trust page (static HTML + JSON feed)')
6202
- .option('-d, --dir <path>', 'Attestation directory', path.join(os.homedir(), '.delimit', 'attestations'))
6287
+ .option('-d, --dir <path>', 'Attestation directory', homeSubpath('attestations'))
6203
6288
  .option('-o, --out <path>', 'Output directory', './trust-page')
6204
6289
  .option('-t, --title <title>', 'Trust page title', 'Trust Page')
6205
6290
  .option('--json', 'Output result as JSON', false)
@@ -6229,7 +6314,7 @@ program
6229
6314
  program
6230
6315
  .command('ai-sbom')
6231
6316
  .description('Build a CycloneDX-AI bill of materials from attestations')
6232
- .option('-d, --dir <path>', 'Attestation directory', path.join(os.homedir(), '.delimit', 'attestations'))
6317
+ .option('-d, --dir <path>', 'Attestation directory', homeSubpath('attestations'))
6233
6318
  .option('-o, --out <path>', 'Output file', './ai-sbom.json')
6234
6319
  .option('-n, --name <name>', 'BOM subject name', 'ai-sbom')
6235
6320
  .option('-v, --package-version <v>', 'BOM subject version', '1.0.0')
@@ -6292,7 +6377,7 @@ program
6292
6377
  console.log("\nUse " + chalk.cyan("delimit vault list") + " to see configured secrets.");
6293
6378
  } else if (action === "list") {
6294
6379
  console.log(chalk.bold("Configured Secrets:"));
6295
- const secretsDir = path.join(os.homedir(), '.delimit', 'secrets');
6380
+ const secretsDir = homeSubpath('secrets');
6296
6381
  if (fs.existsSync(secretsDir)) {
6297
6382
  const files = fs.readdirSync(secretsDir).filter(f => f.endsWith('.json') && !f.startsWith('.'));
6298
6383
  if (files.length === 0) {
@@ -6314,7 +6399,7 @@ program
6314
6399
  console.log(chalk.dim(" Example: delimit vault set OPENAI_API_KEY"));
6315
6400
  process.exit(1);
6316
6401
  }
6317
- const secretsDir = path.join(os.homedir(), '.delimit', 'secrets');
6402
+ const secretsDir = homeSubpath('secrets');
6318
6403
  fs.mkdirSync(secretsDir, { recursive: true });
6319
6404
  const filePath = path.join(secretsDir, `${name}.json`);
6320
6405
  const existing = fs.existsSync(filePath);
@@ -6338,7 +6423,7 @@ program
6338
6423
  console.log(chalk.red("Usage: delimit vault reveal <NAME>"));
6339
6424
  process.exit(1);
6340
6425
  }
6341
- const secretsDir = path.join(os.homedir(), '.delimit', 'secrets');
6426
+ const secretsDir = homeSubpath('secrets');
6342
6427
  const filePath = path.join(secretsDir, `${name}.json`);
6343
6428
  if (!fs.existsSync(filePath)) {
6344
6429
  console.log(chalk.red(` Secret "${name}" not found.`));
@@ -6399,7 +6484,7 @@ program
6399
6484
  // Memory commands: remember, recall, forget
6400
6485
  // ---------------------------------------------------------------------------
6401
6486
 
6402
- const MEMORY_DIR = path.join(os.homedir(), '.delimit', 'memory');
6487
+ const MEMORY_DIR = homeSubpath('memory');
6403
6488
  const MEMORY_FILE = path.join(MEMORY_DIR, 'memories.jsonl');
6404
6489
 
6405
6490
  const KNOWN_TECH_TERMS = new Set([
@@ -988,11 +988,10 @@ def post_next_tweet() -> Dict[str, Any]:
988
988
  Checks the day-typed tweet schedule first. Falls back to the flat queue
989
989
  if no scheduled tweet is available for today.
990
990
  """
991
- from ai.social import post_tweet, should_post_today
991
+ from ai.social import post_tweet, should_post_now
992
992
 
993
- if not should_post_today():
994
- daily_limit = int(os.environ.get("DELIMIT_DAILY_TWEETS", "8"))
995
- return {"status": "skipped", "reason": f"Daily posting limit reached ({daily_limit}/day)"}
993
+ if not should_post_now():
994
+ return {"status": "skipped", "reason": "Rate cap hit (2/hr or 24/day)"}
996
995
 
997
996
  # --- Try day-typed schedule first ---
998
997
  scheduled = get_scheduled_tweet()
@@ -0,0 +1,215 @@
1
+ """Inbox-reply keyword classifier — extracted from inbox_daemon.py for
2
+ LED-2059 live-reload.
3
+
4
+ The inbox daemon is a long-running thread inside the gateway process.
5
+ Edits to keyword lists or detector regex landed on disk but didn't take
6
+ effect until the gateway restarted, which is why the founder's "ship the
7
+ symphony thread" reply (2026-04-28 incident) didn't auto-execute even
8
+ though the LED-820 fix was already on disk.
9
+
10
+ This module is reloaded by ``inbox_daemon.poll_once()`` at the start of
11
+ each poll via ``importlib.reload``. Code changes here pick up within one
12
+ poll interval (default 300s) without a gateway restart.
13
+
14
+ Three classifier signals layered by escalating intent:
15
+ - ``detect_approval_keywords`` — soft "approved" / "lgtm". Sets the draft
16
+ to ``approved`` status; the founder still posts manually.
17
+ - ``detect_explicit_post_keywords`` — strong "ship it" / "post 812" /
18
+ "autopost". The daemon is allowed to call ``auto_post_draft`` with a
19
+ per-call DELIMIT_ENABLE_X_AUTOPOST bypass.
20
+ - ``detect_cancel_keywords`` — "cancel" / "hold" / "drop it". Marks the
21
+ draft cancelled and skips any future processing for that id.
22
+
23
+ All three detectors strip quoted Gmail / Outlook history before scanning
24
+ so a quoted prior email containing one of the keywords doesn't trigger
25
+ the wrong branch (the LED-817 incident).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ from typing import Iterable
32
+
33
+ # ── Keyword lists ────────────────────────────────────────────────────
34
+
35
+ # LED-817 (P0): word-boundary regex matching to prevent substring false
36
+ # positives from quoted Gmail history (e.g. "Reply 'hold' → I hold" in
37
+ # a quoted prior email tripping the cancel branch on an "approved" reply).
38
+ #
39
+ # LED-820 (P1) tier split: APPROVAL_KEYWORDS is the SOFT signal (mark
40
+ # approved, email founder for manual post — same as before). EXPLICIT_POST
41
+ # is the STRONG signal — caller authorized auto-execution of the draft's
42
+ # action right now, no second click required.
43
+
44
+ APPROVAL_KEYWORDS: list[str] = [
45
+ "approved",
46
+ "approve",
47
+ "yes",
48
+ "go ahead",
49
+ "lgtm",
50
+ "looks good",
51
+ ]
52
+
53
+ # Explicit-post keywords — strong signal. Founder authorized auto-execution
54
+ # of the draft's action (post the tweet, comment the issue) WITHOUT a
55
+ # second click. Only triggers when both (a) one of these phrases is in the
56
+ # unquoted reply body AND (b) the draft has a registered draft_id match.
57
+ EXPLICIT_POST_KEYWORDS: list[str] = [
58
+ "post it",
59
+ "ship it",
60
+ "post 8", # "post 812", "post 800", etc. — LED-id-prefixed posts
61
+ "post led", # "post LED-812"
62
+ "publish it",
63
+ "send it",
64
+ "go post",
65
+ "post via api",
66
+ "autopost",
67
+ ]
68
+
69
+ CANCEL_KEYWORDS: list[str] = [
70
+ "cancel",
71
+ "stop",
72
+ "abort",
73
+ "don't post",
74
+ "do not post",
75
+ "hold",
76
+ "skip",
77
+ "drop it",
78
+ ]
79
+
80
+
81
+ # ── Regex compilation ────────────────────────────────────────────────
82
+
83
+ def _compile_keyword_regex(keywords: Iterable[str]) -> re.Pattern[str]:
84
+ """LED-817: build a strict word-boundary regex. Stricter than ``\\b``
85
+ because hyphens count as word boundaries in Python — ``\\bstop\\b``
86
+ matches the 'stop' in 'non-stop', re-introducing the substring bug
87
+ we're trying to fix. Use ``(?<![\\w-])`` / ``(?![\\w-])`` to treat
88
+ hyphens as internal so 'non-stop' doesn't trigger 'stop' but
89
+ 'please cancel.' still triggers 'cancel'.
90
+ """
91
+ parts: list[str] = []
92
+ for kw in keywords:
93
+ if " " in kw or "'" in kw:
94
+ # Multi-word phrase — exact escape, internal whitespace
95
+ # already provides separation.
96
+ parts.append(re.escape(kw))
97
+ else:
98
+ parts.append(rf"(?<![\w-]){re.escape(kw)}(?![\w-])")
99
+ return re.compile("(" + "|".join(parts) + ")", re.IGNORECASE)
100
+
101
+
102
+ _APPROVAL_RE = _compile_keyword_regex(APPROVAL_KEYWORDS)
103
+ _CANCEL_RE = _compile_keyword_regex(CANCEL_KEYWORDS)
104
+ _EXPLICIT_POST_RE = _compile_keyword_regex(EXPLICIT_POST_KEYWORDS)
105
+
106
+
107
+ # ── Quoted-email stripping ──────────────────────────────────────────
108
+
109
+ # LED-817 (P0): strip quoted email content before keyword scanning.
110
+ # Gmail (and most clients) preserve quoted history below the reply.
111
+ # Without stripping, a substring like "hold" from a previously-quoted
112
+ # email of mine triggered cancel on an "approved" reply. Detect quote
113
+ # markers and cut everything from the first marker onward.
114
+ _QUOTE_MARKERS: tuple[re.Pattern[str], ...] = (
115
+ re.compile(r"^On\s+.+?\s+wrote:\s*$", re.MULTILINE), # Gmail
116
+ re.compile(r"^-{2,}\s*Original Message\s*-{2,}", re.MULTILINE | re.IGNORECASE),
117
+ re.compile(r"^-{2,}\s*Forwarded message", re.MULTILINE | re.IGNORECASE),
118
+ re.compile(r"^From:\s*.+?\s*<", re.MULTILINE), # Outlook
119
+ re.compile(r"^Sent from my", re.MULTILINE), # Mobile sig
120
+ )
121
+
122
+ # Lines starting with ">" are quoted in plaintext email
123
+ _QUOTED_LINE_PREFIX_RE = re.compile(r"^[\s]*>", re.MULTILINE)
124
+
125
+
126
+ def _strip_quoted_content(text: str) -> str:
127
+ """Remove quoted email history so keyword scans only see the new reply.
128
+
129
+ Cuts at the first quote marker found anywhere in the body, then drops
130
+ any remaining lines that start with '>'. The intent is conservative:
131
+ if a marker is ambiguous, we keep the text. False negatives (failing
132
+ to strip) cause the same false-positive bug we're fixing, so the
133
+ detection has to favor cutting too aggressively rather than too little.
134
+ """
135
+ if not text:
136
+ return ""
137
+
138
+ # Find the earliest position of any quote marker
139
+ earliest = len(text)
140
+ for pattern in _QUOTE_MARKERS:
141
+ match = pattern.search(text)
142
+ if match and match.start() < earliest:
143
+ earliest = match.start()
144
+
145
+ head = text[:earliest]
146
+
147
+ # Drop ">"-prefixed lines from the head (in case Gmail used ">" without
148
+ # a "On X wrote:" header, or the user manually quoted).
149
+ cleaned_lines = [
150
+ line for line in head.splitlines()
151
+ if not _QUOTED_LINE_PREFIX_RE.match(line)
152
+ ]
153
+ return "\n".join(cleaned_lines).strip()
154
+
155
+
156
+ # ── Public detectors ────────────────────────────────────────────────
157
+
158
+ def detect_approval_keywords(text: str) -> bool:
159
+ """Soft-signal approval. Returns True if ``text`` (after stripping
160
+ quoted history) contains an approval keyword on a word boundary.
161
+
162
+ Guards against feedback loops:
163
+ - Ignores emails FROM the daemon itself (contain "post this manually")
164
+ - Ignores pathological one-word spam ("test" / "hello" / "approve me")
165
+ - Otherwise relies on the upstream draft_id match to filter
166
+ """
167
+ if not text:
168
+ return False
169
+ body = _strip_quoted_content(text).lower().strip()
170
+ if not body:
171
+ return False
172
+
173
+ # Block feedback loop: daemon's own confirmation emails
174
+ if "post this manually" in body or "has been approved" in body:
175
+ return False
176
+
177
+ # LED-817 (P0): the previous junk-block dropped bare "approved" /
178
+ # "approve" replies under the assumption they were spam. With the
179
+ # upstream `draft_id and detect_approval_keywords` gate at the
180
+ # callsite, a bare "approved" can only fire when the reply is in a
181
+ # signed-draft thread — i.e. founder-intent. Keep the spam guard
182
+ # only for the truly pathological cases.
183
+ if body in ("test", "hello", "approve me"):
184
+ return False
185
+
186
+ return bool(_APPROVAL_RE.search(body))
187
+
188
+
189
+ def detect_cancel_keywords(text: str) -> bool:
190
+ """LED-817: word-boundary regex against the unquoted reply only,
191
+ no longer trips on 'hold' inside quoted history."""
192
+ if not text:
193
+ return False
194
+ body = _strip_quoted_content(text).lower().strip()
195
+ if not body:
196
+ return False
197
+ return bool(_CANCEL_RE.search(body))
198
+
199
+
200
+ def detect_explicit_post_keywords(text: str) -> bool:
201
+ """LED-820 (P1): strong-signal trigger that authorizes the daemon to
202
+ actually execute the draft's action (post the tweet, comment the
203
+ issue) instead of merely marking it approved.
204
+
205
+ Returns True only when the unquoted reply body contains an unambiguous
206
+ posting directive ("post it" / "ship it" / "post 812" / etc).
207
+ Generic approvals like "approved" / "lgtm" do NOT auto-execute — the
208
+ founder must explicitly direct the post.
209
+ """
210
+ if not text:
211
+ return False
212
+ body = _strip_quoted_content(text).lower().strip()
213
+ if not body:
214
+ return False
215
+ return bool(_EXPLICIT_POST_RE.search(body))
@@ -32,7 +32,10 @@ from typing import Any, Callable, Dict, List, Optional
32
32
 
33
33
  logger = logging.getLogger("delimit.integrations.opensage")
34
34
 
35
- DELIMIT_HOME = Path.home() / ".delimit"
35
+ # LED-1188: env-var-aware home resolution (DELIMIT_HOME / DELIMIT_NAMESPACE_ROOT).
36
+ from ..continuity import get_namespace_root # noqa: E402
37
+
38
+ DELIMIT_HOME = get_namespace_root()
36
39
  AUDIT_DIR = DELIMIT_HOME / "audit"
37
40
  POLICY_FILE = DELIMIT_HOME / "enforcement_mode"
38
41