delimit-cli 4.1.44 → 4.1.48

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.
@@ -723,6 +723,9 @@ if [ "$DELIMIT_WRAPPED" = "true" ] || [ ! -t 1 ]; then
723
723
  [ -x "$c" ] && exec "$c" "$@"
724
724
  done
725
725
  fi
726
+ # Record session start for exit screen
727
+ SESSION_START=\$(date +%s)
728
+ SESSION_CWD="\$(pwd)"
726
729
  # Auto-update in background (non-blocking)
727
730
  ( CURR=\$(delimit-cli --version 2>/dev/null); LATE=\$(npm view delimit-cli version 2>/dev/null); \\
728
731
  if [ -n "\$LATE" ] && [ "\$LATE" != "\$CURR" ]; then \\
@@ -754,18 +757,93 @@ printf " \${MAGENTA}\${BOLD}[Delimit]\${RESET} \${MAGENTA}═══════
754
757
  sleep 0.08
755
758
  printf " \${GREEN}\${BOLD}[Delimit]\${RESET} \${GREEN}✓ Allowed\${RESET}\\n"
756
759
  echo ""
757
- # Find real binary — check renamed binary first, then common paths
758
- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
759
- # 1. Check for renamed binary (tool-real) next to this shim
760
- [ -x "$SCRIPT_DIR/${toolName}-real" ] && exec "$SCRIPT_DIR/${toolName}-real" "$@"
761
- # 2. Check common paths (skip self)
762
- SELF="$(readlink -f "$0" 2>/dev/null || echo "$0")"
763
- 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
764
- [ -x "$c" ] && [ "$(readlink -f "$c" 2>/dev/null)" != "$SELF" ] && exec "$c" "$@"
765
- done
766
- # 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.
767
845
  REAL=$(PATH=$(echo "$PATH" | tr ':' '\\n' | grep -v '.delimit/shims' | tr '\\n' ':') command -v ${toolName} 2>/dev/null)
768
- [ -x "$REAL" ] && exec "$REAL" "$@"
846
+ [ -x "$REAL" ] && delimit_run_and_exit "$REAL" "$@"
769
847
  echo "[Delimit] ${toolName} not found in PATH" >&2
770
848
  case "${toolName}" in
771
849
  claude) echo " Install: npm install -g @anthropic-ai/claude-code" >&2 ;;
@@ -782,59 +860,17 @@ exit 127
782
860
  fs.chmodSync(shimPath, '755');
783
861
  }
784
862
 
785
- // Rename+wrap: place shim at the real binary's location
786
- // so it works immediately without PATH changes
787
- for (const tool of ['claude', 'codex', 'gemini']) {
788
- try {
789
- // Find real binary location
790
- let realPath = null;
791
- const searchPaths = [
792
- `/usr/local/bin/${tool}`,
793
- `/usr/bin/${tool}`,
794
- path.join(os.homedir(), '.local', 'bin', tool),
795
- ];
796
- // Also check npm global bin
797
- try {
798
- const npmBin = execSync('npm bin -g 2>/dev/null', { encoding: 'utf-8', timeout: 3000 }).trim();
799
- if (npmBin) searchPaths.push(path.join(npmBin, tool));
800
- } catch {}
801
-
802
- for (const p of searchPaths) {
803
- try {
804
- if (fs.existsSync(p) && fs.statSync(p).isFile()) {
805
- // Check it's the real binary, not already our shim
806
- const content = fs.readFileSync(p, 'utf-8').substring(0, 200);
807
- if (!content.includes('Delimit Governance Shim')) {
808
- realPath = p;
809
- break;
810
- }
811
- }
812
- } catch {}
813
- }
814
-
815
- if (realPath) {
816
- const dir = path.dirname(realPath);
817
- const realDest = path.join(dir, `${tool}-real`);
818
- // Only rename if not already renamed
819
- if (!fs.existsSync(realDest)) {
820
- fs.renameSync(realPath, realDest);
821
- }
822
- // Place our shim at the original location
823
- const shimContent = fs.readFileSync(path.join(shimsDir, tool), 'utf-8');
824
- // Update shim to also check for tool-real in same directory
825
- const patchedShim = shimContent.replace(
826
- `echo "[Delimit] ${tool} not found in PATH"`,
827
- `# Check for renamed binary next to shim\n` +
828
- `SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"\n` +
829
- `[ -x "$SCRIPT_DIR/${tool}-real" ] && exec "$SCRIPT_DIR/${tool}-real" "$@"\n` +
830
- `echo "[Delimit] ${tool} not found in PATH"`
831
- );
832
- fs.writeFileSync(realPath, patchedShim);
833
- fs.chmodSync(realPath, '755');
834
- log(` ${green('✓')} ${tool}: wrapped at ${dim(realPath)}`);
835
- }
836
- } catch {}
837
- }
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.
838
874
 
839
875
  // Add to PATH in shell rc files (create if missing)
840
876
  const pathLine = `export PATH="${shimsDir}:$PATH" # Delimit governance wrapping`;
@@ -1301,9 +1337,16 @@ function getClaudeMdContent() {
1301
1337
 
1302
1338
  /**
1303
1339
  * Upsert the Delimit section in a file using <!-- delimit:start --> / <!-- delimit:end --> markers.
1304
- * If markers exist, replaces only that region (preserving user content above/below).
1305
- * If no markers exist but old Delimit content is detected, replaces the whole file.
1306
- * If no Delimit content at all, appends the section.
1340
+ *
1341
+ * NEVER clobbers user-authored content outside the markers. The previous behavior
1342
+ * replaced the whole file whenever it detected "old Delimit content" heuristically,
1343
+ * which destroyed founder-customized CLAUDE.md files on every upgrade (v4.1.47 incident).
1344
+ *
1345
+ * Behavior:
1346
+ * - File missing → create with just the managed section.
1347
+ * - File has markers → replace only the region between them (user content above/below preserved).
1348
+ * - File has no markers → append the managed section at the bottom (user content at top preserved).
1349
+ *
1307
1350
  * Returns { action: 'created' | 'updated' | 'unchanged' | 'appended' }
1308
1351
  */
1309
1352
  function upsertDelimitSection(filePath) {
@@ -1318,7 +1361,7 @@ function upsertDelimitSection(filePath) {
1318
1361
 
1319
1362
  const existing = fs.readFileSync(filePath, 'utf-8');
1320
1363
 
1321
- // Check if markers already exist
1364
+ // Check if managed markers already exist
1322
1365
  const startMarkerRe = /<!-- delimit:start[^>]*-->/;
1323
1366
  const endMarker = '<!-- delimit:end -->';
1324
1367
  const hasStart = startMarkerRe.test(existing);
@@ -1331,25 +1374,16 @@ function upsertDelimitSection(filePath) {
1331
1374
  if (currentVersion === version) {
1332
1375
  return { action: 'unchanged' };
1333
1376
  }
1334
- // Replace only the delimit section
1377
+ // Replace only the managed region — preserve content above/below
1335
1378
  const before = existing.substring(0, existing.search(startMarkerRe));
1336
1379
  const after = existing.substring(existing.indexOf(endMarker) + endMarker.length);
1337
1380
  fs.writeFileSync(filePath, before + newSection + after);
1338
1381
  return { action: 'updated' };
1339
1382
  }
1340
1383
 
1341
- // No markers — check for old Delimit content that should be replaced
1342
- const isOldDelimit = existing.includes('# Delimit AI Guardrails') ||
1343
- existing.includes('delimit_init') ||
1344
- existing.includes('persistent memory, verified execution') ||
1345
- (existing.includes('# Delimit') && existing.includes('delimit_ledger_context'));
1346
-
1347
- if (isOldDelimit) {
1348
- fs.writeFileSync(filePath, newSection + '\n');
1349
- return { action: 'updated' };
1350
- }
1351
-
1352
- // File exists with user content but no Delimit section — append
1384
+ // No markers present append the managed section at the bottom.
1385
+ // User content at the top is preserved verbatim. Markers get added so future
1386
+ // upgrades can update just the managed region.
1353
1387
  const separator = existing.endsWith('\n') ? '\n' : '\n\n';
1354
1388
  fs.writeFileSync(filePath, existing + separator + newSection + '\n');
1355
1389
  return { action: 'appended' };
@@ -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