delimit-cli 4.0.3 → 4.0.5
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/README.md +9 -242
- package/bin/delimit-cli.js +580 -15
- package/bin/delimit-setup.js +30 -2
- package/gateway/ai/agent_dispatch.py +34 -2
- package/gateway/ai/github_scanner.py +1 -1
- package/gateway/ai/ledger_manager.py +13 -3
- package/gateway/ai/ledger_propose.py +240 -0
- package/gateway/ai/loop_engine.py +175 -372
- package/gateway/ai/notify.py +700 -13
- package/gateway/ai/reddit_proxy.py +106 -0
- package/gateway/ai/reddit_scanner.py +34 -0
- package/gateway/ai/server.py +343 -81
- package/gateway/ai/siem_streaming.py +290 -0
- package/gateway/ai/social_daemon.py +189 -0
- package/gateway/ai/swarm.py +434 -0
- package/lib/continuity-resolver.js +325 -0
- package/lib/cross-model-hooks.js +212 -0
- package/lib/delimit-template.js +5 -0
- package/lib/session-shell.js +655 -0
- package/lib/session-worker.js +479 -0
- package/package.json +1 -1
- package/scripts/security-check.sh +12 -0
package/bin/delimit-setup.js
CHANGED
|
@@ -138,6 +138,11 @@ async function main() {
|
|
|
138
138
|
log(` • Install governance agents + hooks`);
|
|
139
139
|
log(` • Set up CLAUDE.md instruction file`);
|
|
140
140
|
log('');
|
|
141
|
+
log(` ${purple('🔒 Security First:')}`);
|
|
142
|
+
log(` • Your secrets are ${bold('stored locally')} and ${bold('encrypted')}.`);
|
|
143
|
+
log(` • No API keys ever leave your machine.`);
|
|
144
|
+
log(` • You own your data and your governance policies.`);
|
|
145
|
+
log('');
|
|
141
146
|
log(` ${dim('Undo anytime:')} rm -rf ~/.delimit && delimit uninstall`);
|
|
142
147
|
log('');
|
|
143
148
|
|
|
@@ -300,6 +305,25 @@ async function main() {
|
|
|
300
305
|
configuredTools.push('Claude Code');
|
|
301
306
|
}
|
|
302
307
|
|
|
308
|
+
// Auto-approve all Delimit tools in Claude Code settings.json
|
|
309
|
+
const CLAUDE_SETTINGS = path.join(CLAUDE_DIR, 'settings.json');
|
|
310
|
+
try {
|
|
311
|
+
let claudeSettings = {};
|
|
312
|
+
if (fs.existsSync(CLAUDE_SETTINGS)) {
|
|
313
|
+
claudeSettings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS, 'utf-8'));
|
|
314
|
+
}
|
|
315
|
+
if (!claudeSettings.permissions) claudeSettings.permissions = {};
|
|
316
|
+
if (!claudeSettings.permissions.allow) claudeSettings.permissions.allow = [];
|
|
317
|
+
const allowList = claudeSettings.permissions.allow;
|
|
318
|
+
if (!allowList.includes('mcp__delimit__*') && !allowList.includes('mcp__delimit')) {
|
|
319
|
+
allowList.push('mcp__delimit__*');
|
|
320
|
+
fs.writeFileSync(CLAUDE_SETTINGS, JSON.stringify(claudeSettings, null, 2));
|
|
321
|
+
await logp(` ${green('✓')} Auto-approve Delimit tools in Claude Code`);
|
|
322
|
+
}
|
|
323
|
+
} catch (e) {
|
|
324
|
+
log(` ${yellow('!')} Could not set Claude Code permissions: ${e.message}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
303
327
|
// Step 3b: Configure Codex MCP (if installed)
|
|
304
328
|
const CODEX_CONFIG = path.join(os.homedir(), '.codex', 'config.toml');
|
|
305
329
|
// Create config.toml if .codex dir exists or codex is in PATH
|
|
@@ -320,7 +344,8 @@ async function main() {
|
|
|
320
344
|
fs.chmodSync(CODEX_CONFIG, 0o644);
|
|
321
345
|
let toml = fs.readFileSync(CODEX_CONFIG, 'utf-8');
|
|
322
346
|
const serverDir = path.join(DELIMIT_HOME, 'server');
|
|
323
|
-
|
|
347
|
+
// approval_policy = "never" means auto-approve all tools from this server (no per-prompt confirmations)
|
|
348
|
+
const correctEntry = `\n[mcp_servers.delimit]\ncommand = "${python}"\nargs = ["${actualServer}"]\ncwd = "${serverDir}"\napproval_policy = "never"\n\n[mcp_servers.delimit.env]\nPYTHONPATH = "${serverDir}:${path.join(serverDir, 'ai')}"\n`;
|
|
324
349
|
|
|
325
350
|
// Remove ALL existing delimit MCP entries (prevents duplicates)
|
|
326
351
|
const existed = toml.includes('mcp_servers.delimit');
|
|
@@ -399,6 +424,9 @@ async function main() {
|
|
|
399
424
|
cwd: path.join(DELIMIT_HOME, 'server'),
|
|
400
425
|
env: { PYTHONPATH: path.join(DELIMIT_HOME, 'server') }
|
|
401
426
|
};
|
|
427
|
+
// Auto-approve all tools — users should not be prompted for every Delimit call
|
|
428
|
+
if (!geminiConfig.general) geminiConfig.general = {};
|
|
429
|
+
geminiConfig.general.defaultApprovalMode = 'auto_edit';
|
|
402
430
|
fs.writeFileSync(GEMINI_CONFIG, JSON.stringify(geminiConfig, null, 2));
|
|
403
431
|
if (geminiExisted) {
|
|
404
432
|
await logp(` ${green('✓')} Updated Delimit paths in Gemini CLI config`);
|
|
@@ -1203,4 +1231,4 @@ function copyDir(src, dest) {
|
|
|
1203
1231
|
main().catch(err => {
|
|
1204
1232
|
console.error('Setup failed:', err.message);
|
|
1205
1233
|
process.exit(1);
|
|
1206
|
-
});
|
|
1234
|
+
});
|
|
@@ -61,6 +61,10 @@ def dispatch_task(
|
|
|
61
61
|
tools_needed: Optional[List[str]] = None,
|
|
62
62
|
constraints: Optional[List[str]] = None,
|
|
63
63
|
context: str = "",
|
|
64
|
+
task_type: str = "",
|
|
65
|
+
venture: str = "",
|
|
66
|
+
variables: Optional[Dict[str, Any]] = None,
|
|
67
|
+
external_key: str = "",
|
|
64
68
|
) -> Dict[str, Any]:
|
|
65
69
|
"""Create a tracked agent task.
|
|
66
70
|
|
|
@@ -78,6 +82,23 @@ def dispatch_task(
|
|
|
78
82
|
if priority not in VALID_PRIORITIES:
|
|
79
83
|
return {"error": f"priority must be one of: {', '.join(sorted(VALID_PRIORITIES))}"}
|
|
80
84
|
|
|
85
|
+
tasks = _load_tasks()
|
|
86
|
+
|
|
87
|
+
normalized_external_key = external_key.strip()
|
|
88
|
+
if normalized_external_key:
|
|
89
|
+
for existing in tasks.values():
|
|
90
|
+
if existing.get("external_key") != normalized_external_key:
|
|
91
|
+
continue
|
|
92
|
+
if existing.get("status") in ("dispatched", "in_progress", "handed_off", "done"):
|
|
93
|
+
prompt = _build_agent_prompt(existing)
|
|
94
|
+
return {
|
|
95
|
+
"status": "deduped",
|
|
96
|
+
"task_id": existing["id"],
|
|
97
|
+
"task": existing,
|
|
98
|
+
"agent_prompt": prompt,
|
|
99
|
+
"message": f"Task {existing['id']} already exists for {normalized_external_key}",
|
|
100
|
+
}
|
|
101
|
+
|
|
81
102
|
task_id = f"AGT-{uuid.uuid4().hex[:8].upper()}"
|
|
82
103
|
|
|
83
104
|
task = {
|
|
@@ -89,6 +110,10 @@ def dispatch_task(
|
|
|
89
110
|
"tools_needed": tools_needed or [],
|
|
90
111
|
"constraints": constraints or [],
|
|
91
112
|
"context": context.strip(),
|
|
113
|
+
"task_type": task_type.strip(),
|
|
114
|
+
"venture": venture.strip(),
|
|
115
|
+
"variables": variables or {},
|
|
116
|
+
"external_key": normalized_external_key,
|
|
92
117
|
"status": "dispatched",
|
|
93
118
|
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
94
119
|
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
@@ -97,7 +122,6 @@ def dispatch_task(
|
|
|
97
122
|
"handoffs": [],
|
|
98
123
|
}
|
|
99
124
|
|
|
100
|
-
tasks = _load_tasks()
|
|
101
125
|
tasks[task_id] = task
|
|
102
126
|
_save_tasks(tasks)
|
|
103
127
|
|
|
@@ -135,6 +159,11 @@ def _build_agent_prompt(task: Dict[str, Any]) -> str:
|
|
|
135
159
|
if task.get("context"):
|
|
136
160
|
lines.append(f"\n**Context:**\n{task['context']}")
|
|
137
161
|
|
|
162
|
+
if task.get("variables"):
|
|
163
|
+
lines.append("\n**Variables:**")
|
|
164
|
+
for key, value in task["variables"].items():
|
|
165
|
+
lines.append(f"- {key}: {value}")
|
|
166
|
+
|
|
138
167
|
if task.get("tools_needed"):
|
|
139
168
|
lines.append(f"\n**Tools needed:** {', '.join(task['tools_needed'])}")
|
|
140
169
|
|
|
@@ -447,7 +476,10 @@ def get_agent_dashboard() -> Dict[str, Any]:
|
|
|
447
476
|
"tasks": [
|
|
448
477
|
{"id": t["id"], "title": t["title"], "status": t["status"],
|
|
449
478
|
"priority": t.get("priority", "P1"),
|
|
450
|
-
"linked_ledger": t.get("linked_ledger_items", [])
|
|
479
|
+
"linked_ledger": t.get("linked_ledger_items", []),
|
|
480
|
+
"task_type": t.get("task_type", ""),
|
|
481
|
+
"venture": t.get("venture", ""),
|
|
482
|
+
"variables": t.get("variables", {})}
|
|
451
483
|
for t in model_tasks
|
|
452
484
|
],
|
|
453
485
|
}
|
|
@@ -91,10 +91,20 @@ def _register_venture(info: Dict[str, str]):
|
|
|
91
91
|
VENTURES_FILE.write_text(json.dumps(ventures, indent=2))
|
|
92
92
|
|
|
93
93
|
|
|
94
|
+
CENTRAL_LEDGER_DIR = Path.home() / ".delimit" / "ledger"
|
|
95
|
+
|
|
96
|
+
|
|
94
97
|
def _project_ledger_dir(project_path: str = ".") -> Path:
|
|
95
|
-
"""Get the ledger directory
|
|
96
|
-
|
|
97
|
-
|
|
98
|
+
"""Get the ledger directory — ALWAYS uses central ~/.delimit/ledger/.
|
|
99
|
+
|
|
100
|
+
Cross-model handoff fix: Codex and Gemini were writing to $PWD/.delimit/ledger/
|
|
101
|
+
which caused ledger fragmentation. All models must use the same central location
|
|
102
|
+
so Claude, Codex, and Gemini see the same items.
|
|
103
|
+
|
|
104
|
+
The central ledger at ~/.delimit/ledger/ is the source of truth.
|
|
105
|
+
Per-project .delimit/ dirs are for policies and config only, not ledger state.
|
|
106
|
+
"""
|
|
107
|
+
return CENTRAL_LEDGER_DIR
|
|
98
108
|
|
|
99
109
|
|
|
100
110
|
def _ensure(project_path: str = "."):
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Ledger Propose — AI-driven item generation from signals.
|
|
2
|
+
|
|
3
|
+
Analyzes repo state, sensing signals, completed work, and venture priorities
|
|
4
|
+
to propose 3-5 new ledger items with rationale. Runs at end of build loops
|
|
5
|
+
when the queue is empty, or on-demand.
|
|
6
|
+
|
|
7
|
+
Works across all AI models via MCP — no model-specific code.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
SIGNALS_DIR = Path.home() / ".delimit" / "signals"
|
|
17
|
+
PROPOSALS_DIR = Path.home() / ".delimit" / "proposals"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _gather_context(venture: str = "") -> Dict[str, Any]:
|
|
21
|
+
"""Collect context from multiple sources for proposal generation."""
|
|
22
|
+
context = {
|
|
23
|
+
"timestamp": time.time(),
|
|
24
|
+
"venture": venture or "all",
|
|
25
|
+
"sources": {},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# 1. Recent ledger completions (what just shipped)
|
|
29
|
+
ledger_path = Path.home() / ".delimit" / "ledger.jsonl"
|
|
30
|
+
if ledger_path.exists():
|
|
31
|
+
recent_done = []
|
|
32
|
+
for line in ledger_path.read_text().strip().split("\n")[-100:]:
|
|
33
|
+
try:
|
|
34
|
+
item = json.loads(line)
|
|
35
|
+
if item.get("status") == "done":
|
|
36
|
+
recent_done.append({
|
|
37
|
+
"id": item.get("id", ""),
|
|
38
|
+
"title": item.get("title", ""),
|
|
39
|
+
"venture": item.get("venture", ""),
|
|
40
|
+
})
|
|
41
|
+
except json.JSONDecodeError:
|
|
42
|
+
continue
|
|
43
|
+
context["sources"]["completed_work"] = recent_done[-10:]
|
|
44
|
+
|
|
45
|
+
# 2. Open items (what's already tracked)
|
|
46
|
+
open_items = []
|
|
47
|
+
if ledger_path.exists():
|
|
48
|
+
for line in ledger_path.read_text().strip().split("\n"):
|
|
49
|
+
try:
|
|
50
|
+
item = json.loads(line)
|
|
51
|
+
if item.get("status") == "open":
|
|
52
|
+
open_items.append(item.get("title", ""))
|
|
53
|
+
except json.JSONDecodeError:
|
|
54
|
+
continue
|
|
55
|
+
context["sources"]["open_items_count"] = len(open_items)
|
|
56
|
+
context["sources"]["open_item_titles"] = open_items[:20]
|
|
57
|
+
|
|
58
|
+
# 3. Sensing signals (GitHub issues, Reddit, migrations)
|
|
59
|
+
signals_file = Path.home() / ".delimit" / "signals" / "recent.jsonl"
|
|
60
|
+
if signals_file.exists():
|
|
61
|
+
signals = []
|
|
62
|
+
for line in signals_file.read_text().strip().split("\n")[-20:]:
|
|
63
|
+
try:
|
|
64
|
+
sig = json.loads(line)
|
|
65
|
+
signals.append({
|
|
66
|
+
"type": sig.get("type", ""),
|
|
67
|
+
"title": sig.get("title", ""),
|
|
68
|
+
"source": sig.get("source", ""),
|
|
69
|
+
"relevance": sig.get("relevance", 0),
|
|
70
|
+
})
|
|
71
|
+
except json.JSONDecodeError:
|
|
72
|
+
continue
|
|
73
|
+
context["sources"]["signals"] = signals
|
|
74
|
+
|
|
75
|
+
# 4. Git recent activity
|
|
76
|
+
try:
|
|
77
|
+
import subprocess
|
|
78
|
+
result = subprocess.run(
|
|
79
|
+
["git", "log", "--oneline", "-10"],
|
|
80
|
+
capture_output=True, text=True, timeout=5,
|
|
81
|
+
)
|
|
82
|
+
if result.returncode == 0:
|
|
83
|
+
context["sources"]["recent_commits"] = result.stdout.strip().split("\n")
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
# 5. Swarm state
|
|
88
|
+
swarm_registry = Path.home() / ".delimit" / "swarm" / "agent_registry.json"
|
|
89
|
+
if swarm_registry.exists():
|
|
90
|
+
try:
|
|
91
|
+
reg = json.loads(swarm_registry.read_text())
|
|
92
|
+
context["sources"]["swarm_agents"] = len(reg.get("agents", {}))
|
|
93
|
+
except json.JSONDecodeError:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
return context
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def propose_items(
|
|
100
|
+
venture: str = "",
|
|
101
|
+
focus: str = "",
|
|
102
|
+
max_items: int = 5,
|
|
103
|
+
) -> Dict[str, Any]:
|
|
104
|
+
"""Generate proposed ledger items based on current context.
|
|
105
|
+
|
|
106
|
+
Analyzes completed work, open items, sensing signals, and repo state
|
|
107
|
+
to suggest what to work on next. Returns structured proposals that
|
|
108
|
+
can be added to the ledger with delimit_ledger_add.
|
|
109
|
+
|
|
110
|
+
This is the AI's "what should I do next?" engine. Works with any model.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
venture: Focus proposals on a specific venture.
|
|
114
|
+
focus: Optional focus area (e.g., "outreach", "engineering", "security").
|
|
115
|
+
max_items: Maximum number of proposals to generate.
|
|
116
|
+
"""
|
|
117
|
+
context = _gather_context(venture)
|
|
118
|
+
|
|
119
|
+
# Build proposal prompt from context
|
|
120
|
+
proposals = []
|
|
121
|
+
open_titles = set(context["sources"].get("open_item_titles", []))
|
|
122
|
+
|
|
123
|
+
# Strategy 1: Follow-through on completed work
|
|
124
|
+
for done in context["sources"].get("completed_work", []):
|
|
125
|
+
title = done.get("title", "")
|
|
126
|
+
if venture and done.get("venture", "") != venture:
|
|
127
|
+
continue
|
|
128
|
+
# Suggest follow-up actions
|
|
129
|
+
if "deploy" in title.lower() or "publish" in title.lower():
|
|
130
|
+
follow_up = f"Verify deployment: {title}"
|
|
131
|
+
if follow_up not in open_titles:
|
|
132
|
+
proposals.append({
|
|
133
|
+
"title": follow_up,
|
|
134
|
+
"rationale": f"Follow-through: '{title}' was shipped but may need verification",
|
|
135
|
+
"priority": "P1",
|
|
136
|
+
"type": "task",
|
|
137
|
+
"source": "ledger_propose:follow_through",
|
|
138
|
+
})
|
|
139
|
+
if "outreach" in title.lower() or "issue" in title.lower():
|
|
140
|
+
follow_up = f"Monitor engagement: {title}"
|
|
141
|
+
if follow_up not in open_titles:
|
|
142
|
+
proposals.append({
|
|
143
|
+
"title": follow_up,
|
|
144
|
+
"rationale": f"Outreach needs monitoring for responses and engagement",
|
|
145
|
+
"priority": "P1",
|
|
146
|
+
"type": "task",
|
|
147
|
+
"source": "ledger_propose:follow_through",
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
# Strategy 2: Act on unprocessed signals
|
|
151
|
+
for sig in context["sources"].get("signals", []):
|
|
152
|
+
sig_title = sig.get("title", "")
|
|
153
|
+
if sig_title and sig_title not in open_titles:
|
|
154
|
+
proposals.append({
|
|
155
|
+
"title": f"Evaluate signal: {sig_title[:80]}",
|
|
156
|
+
"rationale": f"Unprocessed {sig.get('type', 'signal')} from {sig.get('source', 'unknown')}",
|
|
157
|
+
"priority": "P1",
|
|
158
|
+
"type": "strategy",
|
|
159
|
+
"source": "ledger_propose:signal",
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
# Strategy 3: Detect gaps
|
|
163
|
+
has_tests = any("test" in t.lower() for t in open_titles)
|
|
164
|
+
has_docs = any("doc" in t.lower() or "readme" in t.lower() for t in open_titles)
|
|
165
|
+
has_security = any("security" in t.lower() or "audit" in t.lower() for t in open_titles)
|
|
166
|
+
|
|
167
|
+
if not has_tests and focus != "outreach":
|
|
168
|
+
proposals.append({
|
|
169
|
+
"title": f"Run test coverage analysis{f' for {venture}' if venture else ''}",
|
|
170
|
+
"rationale": "No test-related items in queue — ensure coverage hasn't regressed",
|
|
171
|
+
"priority": "P1",
|
|
172
|
+
"type": "task",
|
|
173
|
+
"source": "ledger_propose:gap_detection",
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
if not has_security and focus != "outreach":
|
|
177
|
+
proposals.append({
|
|
178
|
+
"title": f"Security audit{f' for {venture}' if venture else ''}",
|
|
179
|
+
"rationale": "No security items in queue — periodic audits prevent surprises",
|
|
180
|
+
"priority": "P2",
|
|
181
|
+
"type": "task",
|
|
182
|
+
"source": "ledger_propose:gap_detection",
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
if not has_docs:
|
|
186
|
+
proposals.append({
|
|
187
|
+
"title": f"Documentation freshness check{f' for {venture}' if venture else ''}",
|
|
188
|
+
"rationale": "No doc items in queue — ensure README/CHANGELOG reflect current state",
|
|
189
|
+
"priority": "P2",
|
|
190
|
+
"type": "task",
|
|
191
|
+
"source": "ledger_propose:gap_detection",
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
# Apply focus filter
|
|
195
|
+
if focus:
|
|
196
|
+
focus_lower = focus.lower()
|
|
197
|
+
proposals = [p for p in proposals if focus_lower in p.get("title", "").lower()
|
|
198
|
+
or focus_lower in p.get("rationale", "").lower()
|
|
199
|
+
or focus_lower in p.get("type", "").lower()]
|
|
200
|
+
|
|
201
|
+
# Deduplicate and limit
|
|
202
|
+
seen = set()
|
|
203
|
+
unique = []
|
|
204
|
+
for p in proposals:
|
|
205
|
+
key = p["title"][:50]
|
|
206
|
+
if key not in seen:
|
|
207
|
+
seen.add(key)
|
|
208
|
+
unique.append(p)
|
|
209
|
+
proposals = unique[:max_items]
|
|
210
|
+
|
|
211
|
+
# Save proposals for audit trail
|
|
212
|
+
PROPOSALS_DIR.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
proposal_file = PROPOSALS_DIR / f"proposal_{int(time.time())}.json"
|
|
214
|
+
proposal_file.write_text(json.dumps({
|
|
215
|
+
"proposals": proposals,
|
|
216
|
+
"context_summary": {
|
|
217
|
+
"completed_work": len(context["sources"].get("completed_work", [])),
|
|
218
|
+
"open_items": context["sources"].get("open_items_count", 0),
|
|
219
|
+
"signals": len(context["sources"].get("signals", [])),
|
|
220
|
+
"swarm_agents": context["sources"].get("swarm_agents", 0),
|
|
221
|
+
},
|
|
222
|
+
"venture": venture,
|
|
223
|
+
"focus": focus,
|
|
224
|
+
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
225
|
+
}, indent=2))
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
"status": "ok",
|
|
229
|
+
"proposals": proposals,
|
|
230
|
+
"total": len(proposals),
|
|
231
|
+
"venture": venture or "all",
|
|
232
|
+
"focus": focus or "none",
|
|
233
|
+
"context": {
|
|
234
|
+
"completed_work_analyzed": len(context["sources"].get("completed_work", [])),
|
|
235
|
+
"open_items": context["sources"].get("open_items_count", 0),
|
|
236
|
+
"signals_analyzed": len(context["sources"].get("signals", [])),
|
|
237
|
+
},
|
|
238
|
+
"message": f"Generated {len(proposals)} proposal(s). "
|
|
239
|
+
"Use delimit_ledger_add to add approved items.",
|
|
240
|
+
}
|