delimit-cli 4.1.53 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/bin/delimit-cli.js +1 -2
- package/bin/delimit-setup.js +22 -7
- package/gateway/ai/agent_dispatch.py +79 -0
- package/gateway/ai/daily_digest.py +386 -0
- package/gateway/ai/ledger_manager.py +32 -0
- package/gateway/ai/license_core.py +2 -0
- package/gateway/ai/notify.py +17 -11
- package/gateway/ai/reddit_proxy.py +28 -9
- package/gateway/ai/sensing/__init__.py +35 -0
- package/gateway/ai/sensing/schema.py +107 -0
- package/gateway/ai/sensing/signal_store.py +348 -0
- package/gateway/ai/server.py +419 -6
- package/gateway/ai/supabase_sync.py +308 -0
- package/gateway/ai/work_order.py +216 -0
- package/gateway/ai/workers/__init__.py +32 -0
- package/gateway/ai/workers/base.py +154 -0
- package/gateway/ai/workers/executor.py +861 -0
- package/gateway/ai/workers/outreach_drafter.py +161 -0
- package/gateway/ai/workers/pr_drafter.py +148 -0
- package/package.json +14 -1
- package/gateway/ai/continuity.py +0 -462
- package/gateway/ai/inbox_daemon_runner.py +0 -217
- package/gateway/ai/loop_engine.py +0 -1303
- package/gateway/ai/social_cache.py +0 -341
- package/gateway/ai/social_daemon.py +0 -483
- package/gateway/ai/tweet_corpus_schema.sql +0 -76
- package/scripts/crosspost_devto.py +0 -304
- package/scripts/demo-v420-clean.sh +0 -267
- package/scripts/demo-v420-deliberation.sh +0 -217
- package/scripts/demo-v420.sh +0 -55
- package/scripts/sync-gateway.sh +0 -112
package/gateway/ai/continuity.py
DELETED
|
@@ -1,462 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Continuity State Resolution (LED-240).
|
|
3
|
-
|
|
4
|
-
Resolves user identity, project, venture, and private state namespace
|
|
5
|
-
at startup so all tools bind to the correct local state under ~/.delimit/.
|
|
6
|
-
|
|
7
|
-
Design:
|
|
8
|
-
- Single-user (root) today: namespace collapses to ~/.delimit/ (backwards compat)
|
|
9
|
-
- Multi-user (future): each user gets ~/.delimit/users/{user_hash}/
|
|
10
|
-
- Private state never leaks into git-tracked dirs, npm payloads, or public repos
|
|
11
|
-
|
|
12
|
-
Resolution chain:
|
|
13
|
-
resolve_user() -> whoami + git config + gh auth
|
|
14
|
-
resolve_project() -> git remote + .delimit/ dir + package.json/pyproject.toml
|
|
15
|
-
resolve_venture() -> map project to venture via ventures.json registry
|
|
16
|
-
resolve_namespace() -> compute private state path
|
|
17
|
-
auto_bind() -> run all, set env vars for downstream tools
|
|
18
|
-
"""
|
|
19
|
-
|
|
20
|
-
import hashlib
|
|
21
|
-
import json
|
|
22
|
-
import logging
|
|
23
|
-
import os
|
|
24
|
-
import subprocess
|
|
25
|
-
from pathlib import Path
|
|
26
|
-
from typing import Any, Dict, Optional
|
|
27
|
-
|
|
28
|
-
logger = logging.getLogger("delimit.continuity")
|
|
29
|
-
|
|
30
|
-
DELIMIT_HOME = Path.home() / ".delimit"
|
|
31
|
-
VENTURES_FILE = DELIMIT_HOME / "ventures.json"
|
|
32
|
-
|
|
33
|
-
# Directories that hold private continuity state (must never be git-tracked or published)
|
|
34
|
-
PRIVATE_STATE_DIRS = [
|
|
35
|
-
"souls",
|
|
36
|
-
"handoff_receipts",
|
|
37
|
-
"ledger",
|
|
38
|
-
"evidence",
|
|
39
|
-
"agent_actions",
|
|
40
|
-
"events",
|
|
41
|
-
"traces",
|
|
42
|
-
"deliberations",
|
|
43
|
-
"audit",
|
|
44
|
-
"audits",
|
|
45
|
-
"vault",
|
|
46
|
-
"secrets",
|
|
47
|
-
"credentials",
|
|
48
|
-
"context",
|
|
49
|
-
"continuity",
|
|
50
|
-
]
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def _run_cmd(args: list, timeout: int = 5) -> str:
|
|
54
|
-
"""Run a command and return stdout, or empty string on failure."""
|
|
55
|
-
try:
|
|
56
|
-
result = subprocess.run(
|
|
57
|
-
args,
|
|
58
|
-
capture_output=True,
|
|
59
|
-
text=True,
|
|
60
|
-
timeout=timeout,
|
|
61
|
-
)
|
|
62
|
-
if result.returncode == 0:
|
|
63
|
-
return result.stdout.strip()
|
|
64
|
-
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
65
|
-
pass
|
|
66
|
-
return ""
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def _stable_hash(value: str) -> str:
|
|
70
|
-
"""Deterministic short hash for namespace isolation."""
|
|
71
|
-
return hashlib.sha256(value.encode()).hexdigest()[:16]
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
# ---- resolve_user -------------------------------------------------------
|
|
75
|
-
|
|
76
|
-
def resolve_user() -> Dict[str, str]:
|
|
77
|
-
"""Detect the current user from OS, git config, and GitHub auth.
|
|
78
|
-
|
|
79
|
-
Returns a dict with:
|
|
80
|
-
- os_user: system username (whoami)
|
|
81
|
-
- git_email: git config user.email (may be empty)
|
|
82
|
-
- git_name: git config user.name (may be empty)
|
|
83
|
-
- gh_user: GitHub username from gh auth status (may be empty)
|
|
84
|
-
- user_hash: stable hash derived from the best available identity
|
|
85
|
-
- identity_source: which signal produced the hash
|
|
86
|
-
"""
|
|
87
|
-
os_user = os.environ.get("USER", "") or _run_cmd(["whoami"])
|
|
88
|
-
git_email = _run_cmd(["git", "config", "--global", "user.email"])
|
|
89
|
-
git_name = _run_cmd(["git", "config", "--global", "user.name"])
|
|
90
|
-
|
|
91
|
-
# gh auth status --active outputs the logged-in user
|
|
92
|
-
gh_user = ""
|
|
93
|
-
gh_raw = _run_cmd(["gh", "auth", "status"])
|
|
94
|
-
if gh_raw:
|
|
95
|
-
# Parse "Logged in to github.com account <user> ..."
|
|
96
|
-
for line in gh_raw.splitlines():
|
|
97
|
-
stripped = line.strip()
|
|
98
|
-
if "account" in stripped.lower():
|
|
99
|
-
parts = stripped.split()
|
|
100
|
-
for i, tok in enumerate(parts):
|
|
101
|
-
if tok.lower() == "account" and i + 1 < len(parts):
|
|
102
|
-
candidate = parts[i + 1].strip("()")
|
|
103
|
-
if candidate and candidate not in ("as", "to"):
|
|
104
|
-
gh_user = candidate
|
|
105
|
-
break
|
|
106
|
-
if gh_user:
|
|
107
|
-
break
|
|
108
|
-
|
|
109
|
-
# Pick the strongest identity signal for hashing
|
|
110
|
-
if gh_user:
|
|
111
|
-
identity_key = f"gh:{gh_user}"
|
|
112
|
-
identity_source = "github"
|
|
113
|
-
elif git_email:
|
|
114
|
-
identity_key = f"email:{git_email}"
|
|
115
|
-
identity_source = "git_email"
|
|
116
|
-
elif os_user:
|
|
117
|
-
identity_key = f"os:{os_user}"
|
|
118
|
-
identity_source = "os_user"
|
|
119
|
-
else:
|
|
120
|
-
identity_key = "unknown"
|
|
121
|
-
identity_source = "none"
|
|
122
|
-
|
|
123
|
-
return {
|
|
124
|
-
"os_user": os_user,
|
|
125
|
-
"git_email": git_email,
|
|
126
|
-
"git_name": git_name,
|
|
127
|
-
"gh_user": gh_user,
|
|
128
|
-
"user_hash": _stable_hash(identity_key),
|
|
129
|
-
"identity_source": identity_source,
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
# ---- resolve_project -----------------------------------------------------
|
|
134
|
-
|
|
135
|
-
def resolve_project(project_path: str = ".") -> Dict[str, str]:
|
|
136
|
-
"""Detect the current project from the working directory.
|
|
137
|
-
|
|
138
|
-
Returns a dict with:
|
|
139
|
-
- path: resolved absolute path
|
|
140
|
-
- name: project name (from package.json, pyproject.toml, or dir name)
|
|
141
|
-
- repo_url: git remote origin URL (may be empty)
|
|
142
|
-
- project_hash: stable hash of the resolved path
|
|
143
|
-
- has_delimit_dir: whether .delimit/ exists in the project
|
|
144
|
-
- project_type: node | python | unknown
|
|
145
|
-
"""
|
|
146
|
-
p = Path(project_path).resolve()
|
|
147
|
-
info: Dict[str, str] = {
|
|
148
|
-
"path": str(p),
|
|
149
|
-
"name": p.name,
|
|
150
|
-
"repo_url": "",
|
|
151
|
-
"project_hash": _stable_hash(str(p)),
|
|
152
|
-
"has_delimit_dir": str((p / ".delimit").is_dir()),
|
|
153
|
-
"project_type": "unknown",
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
# package.json
|
|
157
|
-
pkg_file = p / "package.json"
|
|
158
|
-
if pkg_file.exists():
|
|
159
|
-
try:
|
|
160
|
-
pkg = json.loads(pkg_file.read_text())
|
|
161
|
-
info["name"] = pkg.get("name", p.name)
|
|
162
|
-
info["project_type"] = "node"
|
|
163
|
-
except Exception:
|
|
164
|
-
pass
|
|
165
|
-
|
|
166
|
-
# pyproject.toml
|
|
167
|
-
pyproj = p / "pyproject.toml"
|
|
168
|
-
if pyproj.exists() and info["project_type"] == "unknown":
|
|
169
|
-
try:
|
|
170
|
-
for line in pyproj.read_text().splitlines():
|
|
171
|
-
if line.strip().startswith("name"):
|
|
172
|
-
val = line.split("=", 1)[1].strip().strip('"').strip("'")
|
|
173
|
-
if val:
|
|
174
|
-
info["name"] = val
|
|
175
|
-
info["project_type"] = "python"
|
|
176
|
-
break
|
|
177
|
-
except Exception:
|
|
178
|
-
pass
|
|
179
|
-
|
|
180
|
-
# git remote
|
|
181
|
-
remote = _run_cmd(["git", "-C", str(p), "remote", "get-url", "origin"])
|
|
182
|
-
if remote:
|
|
183
|
-
info["repo_url"] = remote
|
|
184
|
-
|
|
185
|
-
return info
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
# ---- resolve_venture -----------------------------------------------------
|
|
189
|
-
|
|
190
|
-
def resolve_venture(project_path: str = ".") -> Dict[str, str]:
|
|
191
|
-
"""Map a project to its venture using the global ventures registry.
|
|
192
|
-
|
|
193
|
-
Returns a dict with:
|
|
194
|
-
- venture_name: matched venture name (or "unregistered")
|
|
195
|
-
- venture_match: how the match was made (exact | path | repo | none)
|
|
196
|
-
- registered_ventures: count of ventures in registry
|
|
197
|
-
"""
|
|
198
|
-
project = resolve_project(project_path)
|
|
199
|
-
resolved_path = project["path"]
|
|
200
|
-
repo_url = project["repo_url"]
|
|
201
|
-
|
|
202
|
-
ventures = _load_ventures()
|
|
203
|
-
result: Dict[str, str] = {
|
|
204
|
-
"venture_name": "unregistered",
|
|
205
|
-
"venture_match": "none",
|
|
206
|
-
"registered_ventures": str(len(ventures)),
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
# Exact path match
|
|
210
|
-
for name, vinfo in ventures.items():
|
|
211
|
-
v_path = vinfo.get("path", "")
|
|
212
|
-
if v_path and os.path.realpath(v_path) == os.path.realpath(resolved_path):
|
|
213
|
-
result["venture_name"] = name
|
|
214
|
-
result["venture_match"] = "exact"
|
|
215
|
-
return result
|
|
216
|
-
|
|
217
|
-
# Path-prefix match (project is a subdirectory of a venture)
|
|
218
|
-
for name, vinfo in ventures.items():
|
|
219
|
-
v_path = vinfo.get("path", "")
|
|
220
|
-
if v_path:
|
|
221
|
-
try:
|
|
222
|
-
if Path(resolved_path).is_relative_to(Path(v_path).resolve()):
|
|
223
|
-
result["venture_name"] = name
|
|
224
|
-
result["venture_match"] = "path"
|
|
225
|
-
return result
|
|
226
|
-
except (ValueError, TypeError):
|
|
227
|
-
pass
|
|
228
|
-
|
|
229
|
-
# Repo URL match
|
|
230
|
-
if repo_url:
|
|
231
|
-
normalized_repo = repo_url.rstrip("/").replace(".git", "").lower()
|
|
232
|
-
for name, vinfo in ventures.items():
|
|
233
|
-
v_repo = vinfo.get("repo", "").rstrip("/").replace(".git", "").lower()
|
|
234
|
-
if v_repo and v_repo == normalized_repo:
|
|
235
|
-
result["venture_name"] = name
|
|
236
|
-
result["venture_match"] = "repo"
|
|
237
|
-
return result
|
|
238
|
-
|
|
239
|
-
return result
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
def _load_ventures() -> Dict[str, Any]:
|
|
243
|
-
"""Load the global ventures registry."""
|
|
244
|
-
if not VENTURES_FILE.exists():
|
|
245
|
-
return {}
|
|
246
|
-
try:
|
|
247
|
-
return json.loads(VENTURES_FILE.read_text())
|
|
248
|
-
except (json.JSONDecodeError, OSError):
|
|
249
|
-
return {}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
# ---- resolve_namespace ---------------------------------------------------
|
|
253
|
-
|
|
254
|
-
def resolve_namespace(
|
|
255
|
-
user_hash: str = "",
|
|
256
|
-
venture_name: str = "",
|
|
257
|
-
project_hash: str = "",
|
|
258
|
-
) -> Dict[str, str]:
|
|
259
|
-
"""Compute the private state path for a user/venture/project combination.
|
|
260
|
-
|
|
261
|
-
Namespace layout:
|
|
262
|
-
Single-user (default): ~/.delimit/ (backwards compat)
|
|
263
|
-
Multi-user (future): ~/.delimit/users/{user_hash}/
|
|
264
|
-
|
|
265
|
-
Within a namespace, state is organized by type:
|
|
266
|
-
souls/{project_hash}/
|
|
267
|
-
handoff_receipts/{project_hash}/
|
|
268
|
-
ledger/
|
|
269
|
-
evidence/
|
|
270
|
-
...
|
|
271
|
-
|
|
272
|
-
Returns a dict with:
|
|
273
|
-
- namespace_root: the base path for this user's private state
|
|
274
|
-
- is_multi_user: whether multi-user scoping is active
|
|
275
|
-
- souls_dir: where soul captures go
|
|
276
|
-
- receipts_dir: where handoff receipts go
|
|
277
|
-
- ledger_dir: central ledger
|
|
278
|
-
- evidence_dir: audit evidence
|
|
279
|
-
- events_dir: event log
|
|
280
|
-
"""
|
|
281
|
-
# Determine if we are in multi-user mode.
|
|
282
|
-
# For now, multi-user activates only if DELIMIT_MULTI_USER=1 is set,
|
|
283
|
-
# keeping backwards compat for the single-user (root) case.
|
|
284
|
-
multi_user = os.environ.get("DELIMIT_MULTI_USER", "") == "1"
|
|
285
|
-
|
|
286
|
-
if multi_user and user_hash:
|
|
287
|
-
namespace_root = DELIMIT_HOME / "users" / user_hash
|
|
288
|
-
else:
|
|
289
|
-
namespace_root = DELIMIT_HOME
|
|
290
|
-
|
|
291
|
-
result = {
|
|
292
|
-
"namespace_root": str(namespace_root),
|
|
293
|
-
"is_multi_user": str(multi_user),
|
|
294
|
-
"souls_dir": str(namespace_root / "souls"),
|
|
295
|
-
"receipts_dir": str(namespace_root / "handoff_receipts"),
|
|
296
|
-
"ledger_dir": str(namespace_root / "ledger"),
|
|
297
|
-
"evidence_dir": str(namespace_root / "evidence"),
|
|
298
|
-
"events_dir": str(namespace_root / "events"),
|
|
299
|
-
"traces_dir": str(namespace_root / "traces"),
|
|
300
|
-
"agent_actions_dir": str(namespace_root / "agent_actions"),
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
# If project_hash is provided, the per-project subdirs get it
|
|
304
|
-
if project_hash:
|
|
305
|
-
result["souls_dir"] = str(namespace_root / "souls" / project_hash)
|
|
306
|
-
result["receipts_dir"] = str(
|
|
307
|
-
namespace_root / "handoff_receipts" / project_hash
|
|
308
|
-
)
|
|
309
|
-
|
|
310
|
-
return result
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
# ---- auto_bind -----------------------------------------------------------
|
|
314
|
-
|
|
315
|
-
def auto_bind(project_path: str = ".") -> Dict[str, Any]:
|
|
316
|
-
"""Run full resolution chain and set environment variables.
|
|
317
|
-
|
|
318
|
-
This is the single entry point for startup. It:
|
|
319
|
-
1. Resolves user identity
|
|
320
|
-
2. Resolves current project
|
|
321
|
-
3. Maps project to venture
|
|
322
|
-
4. Computes the namespace
|
|
323
|
-
5. Sets DELIMIT_* env vars so all downstream tools use the right paths
|
|
324
|
-
6. Ensures the namespace directories exist
|
|
325
|
-
|
|
326
|
-
Returns the full resolution context for logging/debugging.
|
|
327
|
-
"""
|
|
328
|
-
user = resolve_user()
|
|
329
|
-
project = resolve_project(project_path)
|
|
330
|
-
venture = resolve_venture(project_path)
|
|
331
|
-
namespace = resolve_namespace(
|
|
332
|
-
user_hash=user["user_hash"],
|
|
333
|
-
venture_name=venture["venture_name"],
|
|
334
|
-
project_hash=project["project_hash"],
|
|
335
|
-
)
|
|
336
|
-
|
|
337
|
-
# Set env vars for downstream consumption
|
|
338
|
-
_env_vars = {
|
|
339
|
-
"DELIMIT_USER_HASH": user["user_hash"],
|
|
340
|
-
"DELIMIT_USER_IDENTITY": user.get("gh_user") or user.get("git_email") or user.get("os_user", ""),
|
|
341
|
-
"DELIMIT_PROJECT_PATH": project["path"],
|
|
342
|
-
"DELIMIT_PROJECT_HASH": project["project_hash"],
|
|
343
|
-
"DELIMIT_PROJECT_NAME": project["name"],
|
|
344
|
-
"DELIMIT_VENTURE": venture["venture_name"],
|
|
345
|
-
"DELIMIT_NAMESPACE_ROOT": namespace["namespace_root"],
|
|
346
|
-
"DELIMIT_LEDGER_DIR": namespace["ledger_dir"],
|
|
347
|
-
"DELIMIT_SOULS_DIR": namespace["souls_dir"],
|
|
348
|
-
"DELIMIT_EVIDENCE_DIR": namespace["evidence_dir"],
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
for key, value in _env_vars.items():
|
|
352
|
-
os.environ[key] = value
|
|
353
|
-
|
|
354
|
-
# Ensure critical namespace directories exist
|
|
355
|
-
for dir_key in ("namespace_root", "souls_dir", "receipts_dir", "ledger_dir",
|
|
356
|
-
"evidence_dir", "events_dir", "traces_dir", "agent_actions_dir"):
|
|
357
|
-
Path(namespace[dir_key]).mkdir(parents=True, exist_ok=True)
|
|
358
|
-
|
|
359
|
-
# Verify private state is not inside a git worktree that would be committed
|
|
360
|
-
leak_warnings = _check_for_leaks(namespace["namespace_root"])
|
|
361
|
-
|
|
362
|
-
context = {
|
|
363
|
-
"user": user,
|
|
364
|
-
"project": project,
|
|
365
|
-
"venture": venture,
|
|
366
|
-
"namespace": namespace,
|
|
367
|
-
"env_vars_set": list(_env_vars.keys()),
|
|
368
|
-
"leak_warnings": leak_warnings,
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
logger.info(
|
|
372
|
-
"Continuity bound: user=%s project=%s venture=%s namespace=%s",
|
|
373
|
-
user.get("gh_user") or user.get("os_user", "?"),
|
|
374
|
-
project["name"],
|
|
375
|
-
venture["venture_name"],
|
|
376
|
-
namespace["namespace_root"],
|
|
377
|
-
)
|
|
378
|
-
|
|
379
|
-
return context
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
# ---- Safety checks -------------------------------------------------------
|
|
383
|
-
|
|
384
|
-
def _check_for_leaks(namespace_root: str) -> list:
|
|
385
|
-
"""Check that the namespace root is not inside a git worktree or npm package.
|
|
386
|
-
|
|
387
|
-
Returns a list of warning strings (empty if clean).
|
|
388
|
-
"""
|
|
389
|
-
warnings = []
|
|
390
|
-
ns_path = Path(namespace_root).resolve()
|
|
391
|
-
|
|
392
|
-
# Check 1: namespace should be under ~/.delimit/ (home directory)
|
|
393
|
-
home = Path.home().resolve()
|
|
394
|
-
expected_base = home / ".delimit"
|
|
395
|
-
if not str(ns_path).startswith(str(expected_base)):
|
|
396
|
-
warnings.append(
|
|
397
|
-
f"Namespace root {ns_path} is outside ~/.delimit/ -- state may leak"
|
|
398
|
-
)
|
|
399
|
-
|
|
400
|
-
# Check 2: namespace should not be inside a git worktree
|
|
401
|
-
git_root = _run_cmd(["git", "-C", str(ns_path), "rev-parse", "--show-toplevel"])
|
|
402
|
-
if git_root:
|
|
403
|
-
# If the git root is the home dir itself, that is fine (some users have ~/ as a repo)
|
|
404
|
-
# But if it is a project repo, that is a problem.
|
|
405
|
-
git_root_path = Path(git_root).resolve()
|
|
406
|
-
if git_root_path != home and str(ns_path).startswith(str(git_root_path)):
|
|
407
|
-
warnings.append(
|
|
408
|
-
f"Namespace root {ns_path} is inside git worktree {git_root_path} "
|
|
409
|
-
"-- private state could be committed. Add to .gitignore."
|
|
410
|
-
)
|
|
411
|
-
|
|
412
|
-
# Check 3: verify .gitignore coverage in the gateway repo
|
|
413
|
-
gateway_gitignore = Path("/home/delimit/delimit-gateway/.gitignore")
|
|
414
|
-
if gateway_gitignore.exists():
|
|
415
|
-
content = gateway_gitignore.read_text()
|
|
416
|
-
if ".delimit/" not in content and ".delimit/ledger/" not in content:
|
|
417
|
-
warnings.append(
|
|
418
|
-
"Gateway .gitignore does not exclude .delimit/ -- state may be committed"
|
|
419
|
-
)
|
|
420
|
-
|
|
421
|
-
return warnings
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
def verify_npm_exclusion() -> Dict[str, Any]:
|
|
425
|
-
"""Verify that private state directories are excluded from npm publish.
|
|
426
|
-
|
|
427
|
-
Checks .npmignore in the npm package for coverage of state dirs.
|
|
428
|
-
Returns a report.
|
|
429
|
-
"""
|
|
430
|
-
npmignore = Path("/home/delimit/npm-delimit/.npmignore")
|
|
431
|
-
result: Dict[str, Any] = {
|
|
432
|
-
"npmignore_exists": npmignore.exists(),
|
|
433
|
-
"covered": [],
|
|
434
|
-
"missing": [],
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
if not npmignore.exists():
|
|
438
|
-
result["missing"] = PRIVATE_STATE_DIRS[:]
|
|
439
|
-
return result
|
|
440
|
-
|
|
441
|
-
content = npmignore.read_text()
|
|
442
|
-
for dirname in PRIVATE_STATE_DIRS:
|
|
443
|
-
# Check if the dir or a pattern covering it appears in .npmignore
|
|
444
|
-
if dirname in content or ".delimit" in content or f"**/{dirname}" in content:
|
|
445
|
-
result["covered"].append(dirname)
|
|
446
|
-
else:
|
|
447
|
-
result["missing"].append(dirname)
|
|
448
|
-
|
|
449
|
-
return result
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
# ---- Convenience for other modules ---------------------------------------
|
|
453
|
-
|
|
454
|
-
def get_namespace_root() -> Path:
|
|
455
|
-
"""Return the current namespace root from env or default.
|
|
456
|
-
|
|
457
|
-
Other modules can call this instead of hardcoding Path.home() / ".delimit".
|
|
458
|
-
"""
|
|
459
|
-
env_root = os.environ.get("DELIMIT_NAMESPACE_ROOT", "")
|
|
460
|
-
if env_root:
|
|
461
|
-
return Path(env_root)
|
|
462
|
-
return DELIMIT_HOME
|
|
@@ -1,217 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Standalone runner for the Delimit inbox polling daemon.
|
|
4
|
-
|
|
5
|
-
Designed for use with systemd or manual invocation. Adds:
|
|
6
|
-
- Structured logging with timestamps
|
|
7
|
-
- Graceful SIGTERM handling for clean systemd stop
|
|
8
|
-
- PID file to prevent duplicate instances
|
|
9
|
-
- Startup validation of required configuration
|
|
10
|
-
|
|
11
|
-
Usage:
|
|
12
|
-
# Via systemd (see deploy/inbox-daemon.service)
|
|
13
|
-
systemctl start delimit-inbox-daemon
|
|
14
|
-
|
|
15
|
-
# Manual foreground run
|
|
16
|
-
python3 ai/inbox_daemon_runner.py
|
|
17
|
-
|
|
18
|
-
# Single poll cycle (for testing)
|
|
19
|
-
python3 ai/inbox_daemon_runner.py --once
|
|
20
|
-
|
|
21
|
-
Environment variables:
|
|
22
|
-
DELIMIT_SMTP_PASS Required. IMAP/SMTP password.
|
|
23
|
-
DELIMIT_INBOX_POLL_INTERVAL Poll interval in seconds (default: 300).
|
|
24
|
-
DELIMIT_HOME Delimit config directory (default: ~/.delimit).
|
|
25
|
-
PYTHONPATH Must include the gateway root for ai.* imports.
|
|
26
|
-
"""
|
|
27
|
-
|
|
28
|
-
import logging
|
|
29
|
-
import os
|
|
30
|
-
import signal
|
|
31
|
-
import sys
|
|
32
|
-
import time
|
|
33
|
-
from datetime import datetime, timezone
|
|
34
|
-
from pathlib import Path
|
|
35
|
-
|
|
36
|
-
# Ensure the gateway root is on sys.path so ai.* imports work
|
|
37
|
-
_gateway_root = Path(__file__).resolve().parent.parent
|
|
38
|
-
if str(_gateway_root) not in sys.path:
|
|
39
|
-
sys.path.insert(0, str(_gateway_root))
|
|
40
|
-
|
|
41
|
-
# PID file to prevent duplicate instances
|
|
42
|
-
PID_DIR = Path(os.environ.get("DELIMIT_HOME", Path.home() / ".delimit"))
|
|
43
|
-
PID_FILE = PID_DIR / "inbox-daemon.pid"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def _setup_logging() -> logging.Logger:
|
|
47
|
-
"""Configure structured logging for journald and console."""
|
|
48
|
-
log_format = "%(asctime)s [%(name)s] %(levelname)s: %(message)s"
|
|
49
|
-
logging.basicConfig(
|
|
50
|
-
level=logging.INFO,
|
|
51
|
-
format=log_format,
|
|
52
|
-
stream=sys.stdout,
|
|
53
|
-
)
|
|
54
|
-
# Suppress noisy libraries
|
|
55
|
-
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
|
56
|
-
logging.getLogger("imaplib").setLevel(logging.WARNING)
|
|
57
|
-
return logging.getLogger("delimit.inbox_daemon_runner")
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def _write_pid() -> None:
|
|
61
|
-
"""Write PID file. Check for stale processes first."""
|
|
62
|
-
PID_DIR.mkdir(parents=True, exist_ok=True)
|
|
63
|
-
|
|
64
|
-
if PID_FILE.exists():
|
|
65
|
-
try:
|
|
66
|
-
old_pid = int(PID_FILE.read_text().strip())
|
|
67
|
-
# Check if the old process is still running
|
|
68
|
-
os.kill(old_pid, 0)
|
|
69
|
-
# Process exists -- abort to prevent duplicates
|
|
70
|
-
print(
|
|
71
|
-
f"ERROR: Another inbox daemon is running (PID {old_pid}). "
|
|
72
|
-
f"Remove {PID_FILE} if stale.",
|
|
73
|
-
file=sys.stderr,
|
|
74
|
-
)
|
|
75
|
-
sys.exit(1)
|
|
76
|
-
except (ValueError, ProcessLookupError, PermissionError):
|
|
77
|
-
# Stale PID file -- safe to overwrite
|
|
78
|
-
pass
|
|
79
|
-
except OSError:
|
|
80
|
-
pass
|
|
81
|
-
|
|
82
|
-
PID_FILE.write_text(str(os.getpid()))
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def _remove_pid() -> None:
|
|
86
|
-
"""Remove PID file on clean shutdown."""
|
|
87
|
-
try:
|
|
88
|
-
if PID_FILE.exists():
|
|
89
|
-
current_pid = PID_FILE.read_text().strip()
|
|
90
|
-
if current_pid == str(os.getpid()):
|
|
91
|
-
PID_FILE.unlink()
|
|
92
|
-
except OSError:
|
|
93
|
-
pass
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def _validate_config(logger: logging.Logger) -> bool:
|
|
97
|
-
"""Validate required configuration before starting the daemon."""
|
|
98
|
-
ok = True
|
|
99
|
-
|
|
100
|
-
if not os.environ.get("DELIMIT_SMTP_PASS"):
|
|
101
|
-
# Check if the notify module can load credentials from config
|
|
102
|
-
try:
|
|
103
|
-
from ai.notify import _load_smtp_account, IMAP_USER
|
|
104
|
-
if IMAP_USER:
|
|
105
|
-
account = _load_smtp_account(IMAP_USER)
|
|
106
|
-
if account and (account.get("pass") or account.get("password")):
|
|
107
|
-
logger.info("SMTP credentials loaded from config for %s", IMAP_USER)
|
|
108
|
-
else:
|
|
109
|
-
logger.error(
|
|
110
|
-
"DELIMIT_SMTP_PASS not set and no credentials found in config for %s",
|
|
111
|
-
IMAP_USER,
|
|
112
|
-
)
|
|
113
|
-
ok = False
|
|
114
|
-
else:
|
|
115
|
-
logger.error("DELIMIT_SMTP_PASS not set and IMAP_USER not configured")
|
|
116
|
-
ok = False
|
|
117
|
-
except ImportError:
|
|
118
|
-
logger.error("DELIMIT_SMTP_PASS not set and ai.notify module not importable")
|
|
119
|
-
ok = False
|
|
120
|
-
else:
|
|
121
|
-
logger.info("SMTP credentials provided via environment")
|
|
122
|
-
|
|
123
|
-
return ok
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def main() -> None:
|
|
127
|
-
import argparse
|
|
128
|
-
|
|
129
|
-
parser = argparse.ArgumentParser(
|
|
130
|
-
description="Delimit inbox daemon runner -- persistent email governance polling",
|
|
131
|
-
)
|
|
132
|
-
parser.add_argument(
|
|
133
|
-
"--once",
|
|
134
|
-
action="store_true",
|
|
135
|
-
help="Run a single poll cycle and exit",
|
|
136
|
-
)
|
|
137
|
-
parser.add_argument(
|
|
138
|
-
"--interval",
|
|
139
|
-
type=int,
|
|
140
|
-
default=None,
|
|
141
|
-
help="Override poll interval in seconds",
|
|
142
|
-
)
|
|
143
|
-
args = parser.parse_args()
|
|
144
|
-
|
|
145
|
-
logger = _setup_logging()
|
|
146
|
-
logger.info(
|
|
147
|
-
"Delimit inbox daemon runner starting (PID %d, Python %s)",
|
|
148
|
-
os.getpid(),
|
|
149
|
-
sys.version.split()[0],
|
|
150
|
-
)
|
|
151
|
-
|
|
152
|
-
# Validate config before doing anything else
|
|
153
|
-
if not _validate_config(logger):
|
|
154
|
-
logger.error("Configuration validation failed. Exiting.")
|
|
155
|
-
sys.exit(1)
|
|
156
|
-
|
|
157
|
-
# Import the daemon module (after PYTHONPATH is set up)
|
|
158
|
-
from ai.inbox_daemon import (
|
|
159
|
-
_daemon_state,
|
|
160
|
-
_daemon_loop,
|
|
161
|
-
poll_once,
|
|
162
|
-
POLL_INTERVAL,
|
|
163
|
-
)
|
|
164
|
-
|
|
165
|
-
# Override poll interval if requested
|
|
166
|
-
if args.interval is not None:
|
|
167
|
-
import ai.inbox_daemon
|
|
168
|
-
ai.inbox_daemon.POLL_INTERVAL = args.interval
|
|
169
|
-
logger.info("Poll interval overridden to %d seconds", args.interval)
|
|
170
|
-
|
|
171
|
-
# Single-shot mode
|
|
172
|
-
if args.once:
|
|
173
|
-
logger.info("Running single poll cycle (--once mode)")
|
|
174
|
-
result = poll_once()
|
|
175
|
-
if "error" in result:
|
|
176
|
-
logger.error("Poll failed: %s", result["error"])
|
|
177
|
-
sys.exit(1)
|
|
178
|
-
logger.info(
|
|
179
|
-
"Poll complete: %d processed, %d forwarded",
|
|
180
|
-
result.get("processed", 0),
|
|
181
|
-
result.get("forwarded", 0),
|
|
182
|
-
)
|
|
183
|
-
return
|
|
184
|
-
|
|
185
|
-
# Write PID file (only for long-running mode)
|
|
186
|
-
_write_pid()
|
|
187
|
-
|
|
188
|
-
# Graceful shutdown handler
|
|
189
|
-
def _handle_signal(signum, frame):
|
|
190
|
-
sig_name = signal.Signals(signum).name
|
|
191
|
-
logger.info("Received %s -- initiating graceful shutdown", sig_name)
|
|
192
|
-
_daemon_state._stop_event.set()
|
|
193
|
-
|
|
194
|
-
signal.signal(signal.SIGTERM, _handle_signal)
|
|
195
|
-
signal.signal(signal.SIGINT, _handle_signal)
|
|
196
|
-
|
|
197
|
-
# Start the daemon loop (blocks until stop event)
|
|
198
|
-
logger.info(
|
|
199
|
-
"Inbox daemon entering main loop (poll interval: %ds)",
|
|
200
|
-
ai.inbox_daemon.POLL_INTERVAL,
|
|
201
|
-
)
|
|
202
|
-
_daemon_state.running = True
|
|
203
|
-
_daemon_state._stop_event.clear()
|
|
204
|
-
|
|
205
|
-
try:
|
|
206
|
-
_daemon_loop()
|
|
207
|
-
except Exception as e:
|
|
208
|
-
logger.critical("Daemon loop crashed: %s", e, exc_info=True)
|
|
209
|
-
sys.exit(1)
|
|
210
|
-
finally:
|
|
211
|
-
_daemon_state.running = False
|
|
212
|
-
_remove_pid()
|
|
213
|
-
logger.info("Inbox daemon runner exiting cleanly")
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
if __name__ == "__main__":
|
|
217
|
-
main()
|