delimit-cli 4.1.43 → 4.1.47

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 (57) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +46 -5
  3. package/bin/delimit-cli.js +1987 -337
  4. package/bin/delimit-setup.js +108 -66
  5. package/gateway/ai/activate_helpers.py +253 -7
  6. package/gateway/ai/agent_dispatch.py +34 -2
  7. package/gateway/ai/backends/deploy_bridge.py +167 -12
  8. package/gateway/ai/backends/gateway_core.py +236 -13
  9. package/gateway/ai/backends/repo_bridge.py +80 -16
  10. package/gateway/ai/backends/tools_infra.py +49 -32
  11. package/gateway/ai/checksums.sha256 +6 -0
  12. package/gateway/ai/content_engine.py +1276 -2
  13. package/gateway/ai/continuity.py +462 -0
  14. package/gateway/ai/deliberation.pyi +53 -0
  15. package/gateway/ai/github_scanner.py +1 -1
  16. package/gateway/ai/governance.py +58 -0
  17. package/gateway/ai/governance.pyi +32 -0
  18. package/gateway/ai/governance_hardening.py +569 -0
  19. package/gateway/ai/inbox_daemon_runner.py +217 -0
  20. package/gateway/ai/key_resolver.py +95 -2
  21. package/gateway/ai/ledger_manager.py +53 -3
  22. package/gateway/ai/license.py +104 -3
  23. package/gateway/ai/license_core.py +177 -36
  24. package/gateway/ai/license_core.pyi +50 -0
  25. package/gateway/ai/loop_engine.py +929 -294
  26. package/gateway/ai/notify.py +1786 -2
  27. package/gateway/ai/reddit_scanner.py +190 -1
  28. package/gateway/ai/screen_record.py +1 -1
  29. package/gateway/ai/secrets_broker.py +5 -1
  30. package/gateway/ai/server.py +254 -19
  31. package/gateway/ai/social_cache.py +341 -0
  32. package/gateway/ai/social_daemon.py +41 -10
  33. package/gateway/ai/supabase_sync.py +190 -2
  34. package/gateway/ai/swarm.py +86 -0
  35. package/gateway/ai/swarm_infra.py +656 -0
  36. package/gateway/ai/tui.py +594 -36
  37. package/gateway/ai/tweet_corpus_schema.sql +76 -0
  38. package/gateway/core/diff_engine_v2.py +6 -2
  39. package/gateway/core/generator_drift.py +242 -0
  40. package/gateway/core/json_schema_diff.py +375 -0
  41. package/gateway/core/openapi_version.py +124 -0
  42. package/gateway/core/spec_detector.py +47 -7
  43. package/gateway/core/spec_health.py +5 -2
  44. package/gateway/core/zero_spec/express_extractor.py +2 -2
  45. package/gateway/core/zero_spec/nestjs_extractor.py +40 -9
  46. package/gateway/requirements.txt +3 -6
  47. package/lib/cross-model-hooks.js +4 -12
  48. package/package.json +11 -3
  49. package/scripts/demo-v420-clean.sh +267 -0
  50. package/scripts/demo-v420-deliberation.sh +217 -0
  51. package/scripts/demo-v420.sh +55 -0
  52. package/scripts/postinstall.js +4 -3
  53. package/scripts/publish-ci-guard.sh +30 -0
  54. package/scripts/record-and-upload.sh +132 -0
  55. package/scripts/release.sh +126 -0
  56. package/scripts/sync-gateway.sh +112 -0
  57. package/scripts/youtube-upload.py +141 -0
@@ -174,8 +174,13 @@ async function main() {
174
174
  fs.mkdirSync(path.join(DELIMIT_HOME, 'evidence'), { recursive: true });
175
175
 
176
176
  // Copy the gateway core from our bundled copy
177
+ // Skip if server dirs are symlinks (dev machine using gateway source directly)
178
+ const serverAiDir = path.join(DELIMIT_HOME, 'server', 'ai');
179
+ const isDevSymlink = fs.existsSync(serverAiDir) && fs.lstatSync(serverAiDir).isSymbolicLink();
177
180
  const gatewaySource = path.join(__dirname, '..', 'gateway');
178
- if (fs.existsSync(gatewaySource)) {
181
+ if (isDevSymlink) {
182
+ await logp(` ${green('✓')} Server linked to gateway source (dev mode)`);
183
+ } else if (fs.existsSync(gatewaySource)) {
179
184
  copyDir(gatewaySource, path.join(DELIMIT_HOME, 'server'));
180
185
  await logp(` ${green('✓')} Core engine installed`);
181
186
  } else {
@@ -216,7 +221,8 @@ async function main() {
216
221
  }
217
222
 
218
223
  // Re-copy gateway source AFTER Pro modules to ensure full files aren't overwritten by stubs
219
- if (fs.existsSync(gatewaySource)) {
224
+ // Skip if dev symlinks are in place
225
+ if (fs.existsSync(gatewaySource) && !isDevSymlink) {
220
226
  copyDir(gatewaySource, path.join(DELIMIT_HOME, 'server'));
221
227
  }
222
228
 
@@ -717,6 +723,9 @@ if [ "$DELIMIT_WRAPPED" = "true" ] || [ ! -t 1 ]; then
717
723
  [ -x "$c" ] && exec "$c" "$@"
718
724
  done
719
725
  fi
726
+ # Record session start for exit screen
727
+ SESSION_START=\$(date +%s)
728
+ SESSION_CWD="\$(pwd)"
720
729
  # Auto-update in background (non-blocking)
721
730
  ( CURR=\$(delimit-cli --version 2>/dev/null); LATE=\$(npm view delimit-cli version 2>/dev/null); \\
722
731
  if [ -n "\$LATE" ] && [ "\$LATE" != "\$CURR" ]; then \\
@@ -748,18 +757,93 @@ printf " \${MAGENTA}\${BOLD}[Delimit]\${RESET} \${MAGENTA}═══════
748
757
  sleep 0.08
749
758
  printf " \${GREEN}\${BOLD}[Delimit]\${RESET} \${GREEN}✓ Allowed\${RESET}\\n"
750
759
  echo ""
751
- # Find real binary — check renamed binary first, then common paths
752
- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
753
- # 1. Check for renamed binary (tool-real) next to this shim
754
- [ -x "$SCRIPT_DIR/${toolName}-real" ] && exec "$SCRIPT_DIR/${toolName}-real" "$@"
755
- # 2. Check common paths (skip self)
756
- SELF="$(readlink -f "$0" 2>/dev/null || echo "$0")"
757
- for c in /usr/local/bin/${toolName}-real /usr/bin/${toolName}-real "$HOME/.local/bin/${toolName}-real" /usr/bin/${toolName} /usr/local/bin/${toolName} "$HOME/.local/bin/${toolName}" "$(npm bin -g 2>/dev/null)/${toolName}"; do
758
- [ -x "$c" ] && [ "$(readlink -f "$c" 2>/dev/null)" != "$SELF" ] && exec "$c" "$@"
759
- done
760
- # 3. Last resort: search PATH excluding shim directory
760
+
761
+ # --- Exit screen: session summary after AI exits ---
762
+ delimit_exit_screen() {
763
+ _EXIT_CODE=\$1
764
+ # Only show exit screen on interactive terminals
765
+ [ ! -t 1 ] && return
766
+ SESSION_END=\$(date +%s)
767
+ ELAPSED=\$((SESSION_END - SESSION_START))
768
+ # Format duration
769
+ if [ \$ELAPSED -ge 3600 ]; then
770
+ HOURS=\$((ELAPSED / 3600))
771
+ MINS=\$(( (ELAPSED % 3600) / 60 ))
772
+ DURATION="\${HOURS}h \${MINS}m"
773
+ elif [ \$ELAPSED -ge 60 ]; then
774
+ MINS=\$((ELAPSED / 60))
775
+ SECS=\$((ELAPSED % 60))
776
+ DURATION="\${MINS}m \${SECS}s"
777
+ else
778
+ DURATION="\${ELAPSED}s"
779
+ fi
780
+ # Count git commits made during session
781
+ COMMITS=0
782
+ if [ -d "\$SESSION_CWD/.git" ] || git -C "\$SESSION_CWD" rev-parse --git-dir >/dev/null 2>&1; then
783
+ COMMITS=\$(git -C "\$SESSION_CWD" log --oneline --after="\$SESSION_START" --format="%H" 2>/dev/null | wc -l | tr -d ' ')
784
+ fi
785
+ # Count ledger items created during session (by timestamp)
786
+ LEDGER_DIR="\$DELIMIT_HOME/ledger"
787
+ LEDGER_ITEMS=0
788
+ if [ -d "\$LEDGER_DIR" ]; then
789
+ for lf in "\$LEDGER_DIR"/*.jsonl; do
790
+ [ -f "\$lf" ] || continue
791
+ COUNT=\$(awk -v start="\$SESSION_START" '
792
+ BEGIN { n=0 }
793
+ {
794
+ if (match(\$0, /"(created_at|ts)":"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}/)) {
795
+ n++
796
+ } else if (match(\$0, /"(created_at|ts)":([0-9]+)/, arr)) {
797
+ if (arr[2]+0 >= start+0) n++
798
+ }
799
+ }
800
+ END { print n }
801
+ ' "\$lf" 2>/dev/null || echo "0")
802
+ LEDGER_ITEMS=\$((LEDGER_ITEMS + COUNT))
803
+ done
804
+ fi
805
+ # Count deliberations (governance decisions)
806
+ DELIBERATIONS=0
807
+ if [ -f "\$DELIMIT_HOME/deliberations.jsonl" ]; then
808
+ DELIBERATIONS=\$(awk -v start="\$SESSION_START" '
809
+ BEGIN { n=0 }
810
+ { if (match(\$0, /"ts":([0-9]+)/, arr)) { if (arr[1]+0 >= start+0) n++ } }
811
+ END { print n }
812
+ ' "\$DELIMIT_HOME/deliberations.jsonl" 2>/dev/null || echo "0")
813
+ fi
814
+ # Determine exit status label
815
+ if [ "\$_EXIT_CODE" -eq 0 ]; then
816
+ STATUS_LABEL="\${GREEN}clean exit\${RESET}"
817
+ else
818
+ STATUS_LABEL="\${ORANGE}exit code \${_EXIT_CODE}\${RESET}"
819
+ fi
820
+ echo ""
821
+ printf " \${MAGENTA}\${BOLD}[Delimit]\${RESET} \${MAGENTA}═══════════════════════════════════════════\${RESET}\\n"
822
+ printf " \${MAGENTA}\${BOLD}[Delimit]\${RESET} \${PURPLE}<\${MAGENTA}/\${ORANGE}>\${RESET} \${BOLD}SESSION COMPLETE: ${displayName.toUpperCase()}\${RESET}\\n"
823
+ printf " \${MAGENTA}\${BOLD}[Delimit]\${RESET} \${MAGENTA}═══════════════════════════════════════════\${RESET}\\n"
824
+ printf " \${PURPLE}\${BOLD}[Delimit]\${RESET} \${DIM}Duration:\${RESET} \${WHITE}\${DURATION}\${RESET}\\n"
825
+ printf " \${PURPLE}\${BOLD}[Delimit]\${RESET} \${DIM}Status:\${RESET} \${STATUS_LABEL}\\n"
826
+ printf " \${PURPLE}\${BOLD}[Delimit]\${RESET} \${DIM}Git commits:\${RESET} \${WHITE}\${COMMITS}\${RESET}\\n"
827
+ printf " \${PURPLE}\${BOLD}[Delimit]\${RESET} \${DIM}Ledger items:\${RESET} \${WHITE}\${LEDGER_ITEMS}\${RESET}\\n"
828
+ printf " \${PURPLE}\${BOLD}[Delimit]\${RESET} \${DIM}Deliberations:\${RESET} \${WHITE}\${DELIBERATIONS}\${RESET}\\n"
829
+ printf " \${MAGENTA}\${BOLD}[Delimit]\${RESET} \${MAGENTA}═══════════════════════════════════════════\${RESET}\\n"
830
+ echo ""
831
+ }
832
+
833
+ # Run real binary and show exit screen (replaces exec to allow post-exit code)
834
+ delimit_run_and_exit() {
835
+ "\$@"
836
+ _RC=\$?
837
+ delimit_exit_screen \$_RC
838
+ exit \$_RC
839
+ }
840
+
841
+ # Find real binary by stripping shim dir from PATH.
842
+ # We rely on PATH ordering ($HOME/.delimit/shims first) — no rename hack,
843
+ # no race with npm reinstalls. Previously we used mv tool→tool-real + cp shim,
844
+ # which broke whenever npm/brew clobbered the binary mid-operation.
761
845
  REAL=$(PATH=$(echo "$PATH" | tr ':' '\\n' | grep -v '.delimit/shims' | tr '\\n' ':') command -v ${toolName} 2>/dev/null)
762
- [ -x "$REAL" ] && exec "$REAL" "$@"
846
+ [ -x "$REAL" ] && delimit_run_and_exit "$REAL" "$@"
763
847
  echo "[Delimit] ${toolName} not found in PATH" >&2
764
848
  case "${toolName}" in
765
849
  claude) echo " Install: npm install -g @anthropic-ai/claude-code" >&2 ;;
@@ -776,59 +860,17 @@ exit 127
776
860
  fs.chmodSync(shimPath, '755');
777
861
  }
778
862
 
779
- // Rename+wrap: place shim at the real binary's location
780
- // so it works immediately without PATH changes
781
- for (const tool of ['claude', 'codex', 'gemini']) {
782
- try {
783
- // Find real binary location
784
- let realPath = null;
785
- const searchPaths = [
786
- `/usr/local/bin/${tool}`,
787
- `/usr/bin/${tool}`,
788
- path.join(os.homedir(), '.local', 'bin', tool),
789
- ];
790
- // Also check npm global bin
791
- try {
792
- const npmBin = execSync('npm bin -g 2>/dev/null', { encoding: 'utf-8', timeout: 3000 }).trim();
793
- if (npmBin) searchPaths.push(path.join(npmBin, tool));
794
- } catch {}
795
-
796
- for (const p of searchPaths) {
797
- try {
798
- if (fs.existsSync(p) && fs.statSync(p).isFile()) {
799
- // Check it's the real binary, not already our shim
800
- const content = fs.readFileSync(p, 'utf-8').substring(0, 200);
801
- if (!content.includes('Delimit Governance Shim')) {
802
- realPath = p;
803
- break;
804
- }
805
- }
806
- } catch {}
807
- }
808
-
809
- if (realPath) {
810
- const dir = path.dirname(realPath);
811
- const realDest = path.join(dir, `${tool}-real`);
812
- // Only rename if not already renamed
813
- if (!fs.existsSync(realDest)) {
814
- fs.renameSync(realPath, realDest);
815
- }
816
- // Place our shim at the original location
817
- const shimContent = fs.readFileSync(path.join(shimsDir, tool), 'utf-8');
818
- // Update shim to also check for tool-real in same directory
819
- const patchedShim = shimContent.replace(
820
- `echo "[Delimit] ${tool} not found in PATH"`,
821
- `# Check for renamed binary next to shim\n` +
822
- `SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"\n` +
823
- `[ -x "$SCRIPT_DIR/${tool}-real" ] && exec "$SCRIPT_DIR/${tool}-real" "$@"\n` +
824
- `echo "[Delimit] ${tool} not found in PATH"`
825
- );
826
- fs.writeFileSync(realPath, patchedShim);
827
- fs.chmodSync(realPath, '755');
828
- log(` ${green('✓')} ${tool}: wrapped at ${dim(realPath)}`);
829
- }
830
- } catch {}
831
- }
863
+ // Governance is enforced via PATH ordering $HOME/.delimit/shims
864
+ // is prepended to PATH (see below), so `claude`/`codex`/`gemini`
865
+ // resolve to our shim first, and the shim then PATH-strips itself
866
+ // and execs the real binary.
867
+ //
868
+ // We deliberately do NOT mv /usr/bin/claude → claude-real and copy
869
+ // the shim into /usr/bin/claude. That rename+wrap approach raced
870
+ // with npm reinstalls (which clobber /usr/bin/claude back to a
871
+ // symlink), leaving users with "[Delimit] claude not found in PATH"
872
+ // when the rename ran but the shim copy failed or the symlink got
873
+ // re-created mid-operation. PATH ordering is the durable contract.
832
874
 
833
875
  // Add to PATH in shell rc files (create if missing)
834
876
  const pathLine = `export PATH="${shimsDir}:$PATH" # Delimit governance wrapping`;
@@ -6,8 +6,9 @@ heavy MCP decorator dependencies).
6
6
 
7
7
  import json
8
8
  import os
9
+ import stat
9
10
  from pathlib import Path
10
- from typing import Dict, Any
11
+ from typing import Dict, Any, List
11
12
 
12
13
 
13
14
  def activate_auto_permissions(auto_permissions: bool) -> dict:
@@ -96,6 +97,228 @@ def configure_codex_permissions(config_path: Path) -> dict:
96
97
  return {"item": "Permissions", "status": "Pass", "detail": "Codex: delimit section exists"}
97
98
 
98
99
 
100
+ # ─── LED-269: Filesystem permission auto-config for delimit_init ──────
101
+ #
102
+ # Safety contract:
103
+ # - Never chmod 777
104
+ # - Never touch anything outside <project>/.delimit/ or
105
+ # <project>/.claude/settings.json
106
+ # - Never modify existing permissions on files we did not create
107
+ # (we only chmod files/dirs we just created or that match our
108
+ # known-safe set: .delimit/ itself, .delimit/secrets/* files)
109
+ # - Idempotent: running twice is a no-op for already-correct state
110
+ # - Backwards compatible: existing installs work without re-running init
111
+
112
+
113
+ # Reasonable default permission allowlist for AI assistants working
114
+ # inside a Delimit-governed project. Mirrors what most projects already
115
+ # grant manually. Conservative — no `Bash(rm:*)`, no wildcards on dangerous
116
+ # commands, no network egress beyond what tools need.
117
+ _DEFAULT_CLAUDE_PROJECT_ALLOW: List[str] = [
118
+ "Edit",
119
+ "Write",
120
+ "Read",
121
+ "Glob",
122
+ "Grep",
123
+ "Bash(git status)",
124
+ "Bash(git diff:*)",
125
+ "Bash(git log:*)",
126
+ "Bash(git add:*)",
127
+ "Bash(ls:*)",
128
+ "Bash(cat:*)",
129
+ "Bash(pwd)",
130
+ "Bash(delimit:*)",
131
+ "Bash(npm test:*)",
132
+ "Bash(npm run:*)",
133
+ "Bash(pytest:*)",
134
+ "Bash(python:*)",
135
+ "Bash(python3:*)",
136
+ "mcp__delimit__*",
137
+ ]
138
+
139
+
140
+ def _safe_chmod(path: Path, mode: int) -> bool:
141
+ """chmod a path defensively. Returns True if applied, False on any error.
142
+
143
+ Refuses world-writable bits (0o002) outright. Never raises.
144
+ """
145
+ if mode & 0o002:
146
+ return False
147
+ try:
148
+ path.chmod(mode)
149
+ return True
150
+ except (OSError, PermissionError):
151
+ return False
152
+
153
+
154
+ def _detect_target_owner(project_root: Path) -> tuple:
155
+ """If running as root, detect the (uid, gid) of the project owner so
156
+ we can chown anything we create back to them. Returns (None, None)
157
+ if not running as root or detection fails.
158
+ """
159
+ try:
160
+ if os.geteuid() != 0:
161
+ return (None, None)
162
+ except AttributeError:
163
+ # Windows or platform without geteuid
164
+ return (None, None)
165
+
166
+ try:
167
+ st = project_root.stat()
168
+ # Don't chown to root-owned projects (no-op)
169
+ if st.st_uid == 0 and st.st_gid == 0:
170
+ return (None, None)
171
+ return (st.st_uid, st.st_gid)
172
+ except OSError:
173
+ return (None, None)
174
+
175
+
176
+ def _safe_chown(path: Path, uid, gid) -> None:
177
+ """chown a path defensively. Never raises."""
178
+ if uid is None or gid is None:
179
+ return
180
+ try:
181
+ os.chown(str(path), uid, gid)
182
+ except (OSError, PermissionError, AttributeError):
183
+ pass
184
+
185
+
186
+ def setup_init_permissions(project_root: Path, no_permissions: bool = False) -> Dict[str, Any]:
187
+ """LED-269: Configure filesystem permissions for a freshly-initialized
188
+ Delimit project.
189
+
190
+ Performs:
191
+ 1. chmod 755 on <project>/.delimit/ (idempotent)
192
+ 2. chmod 600 on every file under <project>/.delimit/secrets/ (if dir exists)
193
+ 3. Creates <project>/.claude/settings.json with a reasonable Edit/Write/
194
+ Bash allowlist if it does not already exist (never overwrites)
195
+ 4. If running as root, chowns anything we created back to the project
196
+ owner so the user can still access their own files
197
+
198
+ Args:
199
+ project_root: Resolved absolute path to the project root.
200
+ no_permissions: If True, skip everything and return a 'skipped' result.
201
+
202
+ Returns:
203
+ Dict with keys: status, applied (list of changes), skipped (list of
204
+ items skipped with reason), warnings (list).
205
+ """
206
+ result: Dict[str, Any] = {
207
+ "status": "skipped" if no_permissions else "ok",
208
+ "applied": [],
209
+ "skipped": [],
210
+ "warnings": [],
211
+ }
212
+
213
+ if no_permissions:
214
+ result["skipped"].append("--no-permissions flag set")
215
+ return result
216
+
217
+ project_root = Path(project_root).resolve()
218
+ delimit_dir = project_root / ".delimit"
219
+
220
+ # Hard safety guard: refuse to operate if .delimit/ doesn't exist.
221
+ # delimit_init creates it before calling us — if it's missing something
222
+ # is wrong and we should not silently chmod random paths.
223
+ if not delimit_dir.is_dir():
224
+ result["status"] = "error"
225
+ result["warnings"].append(f".delimit/ not found at {delimit_dir} — refusing to set permissions")
226
+ return result
227
+
228
+ target_uid, target_gid = _detect_target_owner(project_root)
229
+ running_as_root = target_uid is not None
230
+
231
+ # 1. chmod 755 on .delimit/
232
+ try:
233
+ current_mode = stat.S_IMODE(delimit_dir.stat().st_mode)
234
+ if current_mode != 0o755:
235
+ if _safe_chmod(delimit_dir, 0o755):
236
+ result["applied"].append(f"chmod 755 {delimit_dir}")
237
+ else:
238
+ result["warnings"].append(f"Could not chmod {delimit_dir}")
239
+ else:
240
+ result["skipped"].append(f"{delimit_dir} already 755")
241
+ except OSError as e:
242
+ result["warnings"].append(f"stat failed on {delimit_dir}: {e}")
243
+
244
+ # 2. chmod 600 on secrets files (only if secrets dir exists)
245
+ secrets_dir = delimit_dir / "secrets"
246
+ if secrets_dir.is_dir():
247
+ # Lock the secrets dir itself to 700
248
+ try:
249
+ current_mode = stat.S_IMODE(secrets_dir.stat().st_mode)
250
+ if current_mode != 0o700:
251
+ if _safe_chmod(secrets_dir, 0o700):
252
+ result["applied"].append(f"chmod 700 {secrets_dir}")
253
+ except OSError:
254
+ pass
255
+
256
+ for entry in secrets_dir.iterdir():
257
+ if not entry.is_file():
258
+ continue
259
+ try:
260
+ current_mode = stat.S_IMODE(entry.stat().st_mode)
261
+ if current_mode != 0o600:
262
+ if _safe_chmod(entry, 0o600):
263
+ result["applied"].append(f"chmod 600 {entry}")
264
+ else:
265
+ result["warnings"].append(f"Could not chmod {entry}")
266
+ except OSError:
267
+ continue
268
+ else:
269
+ result["skipped"].append("no secrets/ directory present")
270
+
271
+ # 3. Create project-local .claude/settings.json with reasonable allowlist
272
+ claude_dir = project_root / ".claude"
273
+ claude_settings = claude_dir / "settings.json"
274
+ if claude_settings.exists():
275
+ result["skipped"].append(f"{claude_settings} already exists (not modified)")
276
+ else:
277
+ try:
278
+ claude_dir.mkdir(parents=True, exist_ok=True)
279
+ settings_payload = {
280
+ "permissions": {
281
+ "allow": list(_DEFAULT_CLAUDE_PROJECT_ALLOW),
282
+ "deny": [
283
+ "Bash(rm -rf:*)",
284
+ "Bash(sudo:*)",
285
+ ],
286
+ },
287
+ "_generated_by": "delimit_init",
288
+ "_note": "Edit freely. Delimit will never overwrite this file.",
289
+ }
290
+ claude_settings.write_text(json.dumps(settings_payload, indent=2) + "\n")
291
+ # Lock to 644 (or 600 if it lives in a secrets dir, which it doesn't)
292
+ _safe_chmod(claude_settings, 0o644)
293
+ result["applied"].append(f"created {claude_settings}")
294
+
295
+ if running_as_root:
296
+ _safe_chown(claude_dir, target_uid, target_gid)
297
+ _safe_chown(claude_settings, target_uid, target_gid)
298
+ except OSError as e:
299
+ result["warnings"].append(f"Could not create {claude_settings}: {e}")
300
+
301
+ # 4. Re-chown .delimit/ tree if we're root and there's a non-root owner
302
+ if running_as_root:
303
+ try:
304
+ for path in [delimit_dir, *delimit_dir.rglob("*")]:
305
+ # Only chown if currently owned by root — never override
306
+ # an existing non-root owner
307
+ try:
308
+ if path.stat().st_uid == 0:
309
+ _safe_chown(path, target_uid, target_gid)
310
+ except OSError:
311
+ continue
312
+ result["applied"].append(f"chown -R {target_uid}:{target_gid} {delimit_dir} (root-owned entries)")
313
+ except OSError:
314
+ pass
315
+
316
+ if not result["applied"] and not result["warnings"]:
317
+ result["status"] = "noop"
318
+
319
+ return result
320
+
321
+
99
322
  def build_checklist(
100
323
  license_key: str,
101
324
  project_path: str,
@@ -185,26 +408,49 @@ def build_checklist(
185
408
  checklist.append({"item": label, "status": "Skip (Pro)", "detail": "Requires Delimit Pro"})
186
409
 
187
410
  # --- Score: only count applicable checks (exclude skips) ---
411
+ # LED-270: explicitly distinguish passed / failed / skipped so callers
412
+ # (CI, dashboards, CLI summaries) can render them separately. Skipped
413
+ # checks (premium on free tier, no test framework, no AI assistant)
414
+ # never count as failures and are excluded from the score denominator.
188
415
  applicable = [c for c in checklist if not c["status"].startswith("Skip")]
189
416
  passed_total = sum(1 for c in applicable if c["status"] == "Pass")
417
+ failed_total = sum(1 for c in applicable if c["status"] == "Fail")
418
+ skipped_total = len(checklist) - len(applicable)
419
+ # Break out skip reasons so the UI can show "X Pro features locked"
420
+ # without conflating them with "no test framework"-style skips.
421
+ skipped_premium = sum(1 for c in checklist if c["status"] == "Skip (Pro)")
422
+ skipped_other = skipped_total - skipped_premium
190
423
  total = len(applicable)
191
424
  score = f"{passed_total}/{total}"
192
425
 
426
+ tier = get_license().get("tier", "free")
427
+
193
428
  result: Dict[str, Any] = {
194
429
  "tool": "activate",
195
430
  "status": "complete",
196
431
  "score": score,
197
432
  "passed": passed_total,
433
+ "failed": failed_total,
198
434
  "total": total,
199
- "skipped": len(checklist) - total,
435
+ "skipped": skipped_total,
436
+ "skipped_premium": skipped_premium,
437
+ "skipped_other": skipped_other,
200
438
  "checklist": checklist,
201
- "tier": get_license().get("tier", "free"),
439
+ "tier": tier,
202
440
  "project": str(p),
203
441
  }
204
- if passed_total == total and total > 0:
205
- result["message"] = f"All {total} checks passed. Delimit is fully operational."
206
- elif passed_total < total:
442
+ if failed_total == 0 and total > 0:
443
+ msg = f"All {total} checks passed. Delimit is fully operational."
444
+ if skipped_premium > 0 and tier == "free":
445
+ msg += f" ({skipped_premium} Pro features available with upgrade.)"
446
+ result["message"] = msg
447
+ elif failed_total > 0:
207
448
  failed_items = [c["item"] for c in applicable if c["status"] == "Fail"]
208
- result["message"] = f"{passed_total}/{total} checks passed. Fix: {', '.join(failed_items)}"
449
+ result["message"] = (
450
+ f"{passed_total}/{total} checks passed, {failed_total} failed. "
451
+ f"Fix: {', '.join(failed_items)}"
452
+ )
453
+ else:
454
+ result["message"] = f"{passed_total}/{total} checks passed."
209
455
 
210
456
  return result
@@ -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
  }