pantheon-opencode 1.3.5 → 1.3.7

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 (71) hide show
  1. package/README.md +33 -1
  2. package/package.json +3 -3
  3. package/scripts/_pantheon_paths.py +0 -1
  4. package/scripts/build-agents-md.mjs +4 -2
  5. package/scripts/code_mode_server.py +307 -21
  6. package/scripts/doctor.mjs +26 -13
  7. package/scripts/hooks/format-multi-language.sh +6 -3
  8. package/scripts/hooks/log-session-start.sh +6 -0
  9. package/scripts/hooks/on-subagent-delegation-start.sh +22 -6
  10. package/scripts/hooks/validate-post-conditions.sh +1 -1
  11. package/scripts/hooks/validate-tool-safety.sh +5 -2
  12. package/scripts/install/agents-md.mjs +3 -1
  13. package/scripts/install/cli-ui.mjs +24 -4
  14. package/scripts/install/interactive.mjs +18 -7
  15. package/scripts/install/migrate-config.mjs +13 -19
  16. package/scripts/install/migrate.mjs +8 -5
  17. package/scripts/install/model-picker.mjs +4 -1
  18. package/scripts/install/opencode.mjs +2 -0
  19. package/scripts/install/shared.mjs +6 -4
  20. package/scripts/install/state.mjs +9 -6
  21. package/scripts/mcp_persistence_server.py +23 -22
  22. package/scripts/mcp_resources_server.py +60 -1
  23. package/scripts/memory_mcp_server.py +80 -4
  24. package/scripts/postinstall.mjs +27 -25
  25. package/scripts/prune.mjs +1 -1
  26. package/scripts/redaction-gate.mjs +3 -1
  27. package/scripts/sync-tui.mjs +148 -0
  28. package/scripts/uninstall.mjs +0 -2
  29. package/scripts/validation-policy.mjs +6 -1
  30. package/scripts/version-check.mjs +9 -4
  31. package/src/agents/aphrodite.md +1 -1
  32. package/src/agents/demeter.md +1 -1
  33. package/src/agents/hephaestus.md +1 -1
  34. package/src/agents/hermes.md +1 -1
  35. package/src/agents/themis.md +4 -0
  36. package/src/mcp/code_mode_server.py +304 -18
  37. package/src/mcp/eval_store.py +233 -0
  38. package/src/mcp/mcp_persistence_server.py +23 -22
  39. package/src/mcp/mcp_resources_server.py +58 -0
  40. package/src/mcp/memory_mcp_server.py +80 -3
  41. package/src/pantheon/auto-wake.ts +2 -4
  42. package/src/pantheon/background-job-board.ts +17 -4
  43. package/src/pantheon/comment-checker.ts +48 -0
  44. package/src/pantheon/delegation-classifier.ts +307 -0
  45. package/src/pantheon/delegation-enforce.ts +21 -21
  46. package/src/pantheon/delegation-finalize.ts +44 -4
  47. package/src/pantheon/delegation-notify.ts +97 -0
  48. package/src/pantheon/delegation.ts +413 -23
  49. package/src/pantheon/dispatch-guard.ts +2 -0
  50. package/src/pantheon/file-persistence.ts +7 -3
  51. package/src/pantheon/permission-eval.ts +284 -0
  52. package/src/pantheon/permission-globs.ts +86 -0
  53. package/src/pantheon/presets.d.mts +41 -0
  54. package/src/pantheon/presets.mjs +134 -0
  55. package/src/pantheon/retry-policy.ts +212 -0
  56. package/src/pantheon/session-guard.ts +1 -3
  57. package/src/pantheon/step-cap.ts +110 -0
  58. package/src/pantheon/tui-version-check.ts +72 -0
  59. package/src/pantheon/vision.ts +7 -5
  60. package/src/pantheon/zeus-delegate-with-retry.ts +81 -12
  61. package/src/plugin.ts +57 -3
  62. package/src/plugins/hook-runner.ts +1 -1
  63. package/src/plugins/pantheon-hooks.ts +1 -1
  64. package/src/plugins/tui/dist/tui.d.ts +1 -1
  65. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  66. package/src/plugins/tui/dist/tui.js +19 -14
  67. package/src/plugins/tui/dist/tui.tsx +52 -18
  68. package/src/plugins/tui/package.json +1 -1
  69. package/src/plugins/tui/src/index.tsx +106 -24
  70. package/src/plugins/tui/tsdown.config.ts +1 -1
  71. package/src/routing.yml +42 -0
@@ -178,14 +178,15 @@ _global_root = (
178
178
  _global_db = _init_db(_global_root / "global.db")
179
179
 
180
180
  _project_db_instance = None
181
+ _project_db_path: Path | None = None
181
182
  if args.project_db:
182
- _project_db_instance = _init_db(Path(args.project_db))
183
+ _project_db_path = Path(args.project_db)
184
+ _project_db_instance = _init_db(_project_db_path)
183
185
  else:
184
186
  _proj = pantheon_project()
185
187
  if _proj:
186
- _project_db_instance = _init_db(
187
- _proj / ".pantheon" / "persistence" / "project.db"
188
- )
188
+ _project_db_path = _proj / ".pantheon" / "persistence" / "project.db"
189
+ _project_db_instance = _init_db(_project_db_path)
189
190
  _project_db = _project_db_instance
190
191
 
191
192
 
@@ -262,7 +263,7 @@ async def kv_get(
262
263
  row = conn.execute(
263
264
  "SELECT value FROM kv_store "
264
265
  "WHERE namespace = ? AND key = ? "
265
- "AND (expires_at IS NULL OR expires_at > datetime('now')) "
266
+ "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
266
267
  "AND deleted_at IS NULL",
267
268
  (namespace, key),
268
269
  ).fetchone()
@@ -289,13 +290,13 @@ async def kv_stats(
289
290
  total = conn.execute("SELECT COUNT(*) FROM kv_store WHERE deleted_at IS NULL").fetchone()[0]
290
291
  expired = conn.execute(
291
292
  "SELECT COUNT(*) FROM kv_store "
292
- "WHERE expires_at IS NOT NULL AND expires_at < datetime('now') "
293
+ "WHERE expires_at IS NOT NULL AND datetime(expires_at) < datetime('now') "
293
294
  "AND deleted_at IS NULL"
294
295
  ).fetchone()[0]
295
296
 
296
297
  ns_rows = conn.execute(
297
298
  "SELECT namespace, COUNT(*) as cnt, "
298
- "SUM(CASE WHEN expires_at IS NOT NULL AND expires_at < datetime('now') THEN 1 ELSE 0 END) as expired_count "
299
+ "SUM(CASE WHEN expires_at IS NOT NULL AND datetime(expires_at) < datetime('now') THEN 1 ELSE 0 END) as expired_count "
299
300
  "FROM kv_store WHERE deleted_at IS NULL GROUP BY namespace ORDER BY cnt DESC"
300
301
  ).fetchall()
301
302
 
@@ -372,7 +373,7 @@ async def kv_list(
372
373
  rows = conn.execute(
373
374
  "SELECT key, value, created_at, expires_at FROM kv_store "
374
375
  "WHERE namespace = ? AND key LIKE ? "
375
- "AND (expires_at IS NULL OR expires_at > datetime('now')) "
376
+ "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
376
377
  "AND deleted_at IS NULL "
377
378
  "ORDER BY key LIMIT ?",
378
379
  (namespace, f"{prefix}%", limit),
@@ -426,7 +427,7 @@ async def kv_search(
426
427
  "JOIN kv_store ON kv_store_fts.rowid = kv_store.id "
427
428
  "WHERE kv_store_fts MATCH ? "
428
429
  "AND kv_store.deleted_at IS NULL "
429
- "AND (kv_store.expires_at IS NULL OR kv_store.expires_at > datetime('now'))"
430
+ "AND (kv_store.expires_at IS NULL OR datetime(kv_store.expires_at) > datetime('now'))"
430
431
  )
431
432
  params: list[str | int] = [fts_query]
432
433
 
@@ -474,7 +475,7 @@ async def purge_expired(
474
475
  expired = conn.execute(
475
476
  "SELECT key FROM kv_store "
476
477
  "WHERE expires_at IS NOT NULL "
477
- "AND expires_at < datetime('now') "
478
+ "AND datetime(expires_at) < datetime('now') "
478
479
  "AND deleted_at IS NULL",
479
480
  ).fetchall()
480
481
  expired_keys = [r[0] for r in expired]
@@ -490,7 +491,7 @@ async def purge_expired(
490
491
  conn.execute(
491
492
  "UPDATE kv_store SET deleted_at = datetime('now') "
492
493
  "WHERE expires_at IS NOT NULL "
493
- "AND expires_at < datetime('now') "
494
+ "AND datetime(expires_at) < datetime('now') "
494
495
  "AND deleted_at IS NULL",
495
496
  )
496
497
  conn.commit()
@@ -516,7 +517,7 @@ def _opportunistic_auto_purge(conn: sqlite3.Connection, namespace: str, threshol
516
517
  conn.execute(
517
518
  "UPDATE kv_store SET deleted_at = datetime('now') "
518
519
  "WHERE namespace = ? AND expires_at IS NOT NULL "
519
- "AND expires_at < datetime('now') AND deleted_at IS NULL",
520
+ "AND datetime(expires_at) < datetime('now') AND deleted_at IS NULL",
520
521
  (namespace,),
521
522
  )
522
523
  conn.commit()
@@ -659,7 +660,7 @@ async def context_get(
659
660
  row = conn.execute(
660
661
  "SELECT value FROM kv_store "
661
662
  "WHERE namespace = ? AND key = ? "
662
- "AND (expires_at IS NULL OR expires_at > datetime('now')) "
663
+ "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
663
664
  "AND deleted_at IS NULL",
664
665
  (ns, key),
665
666
  ).fetchone()
@@ -691,7 +692,7 @@ async def context_list(
691
692
  rows = conn.execute(
692
693
  "SELECT key, created_at, expires_at FROM kv_store "
693
694
  "WHERE namespace = ? "
694
- "AND (expires_at IS NULL OR expires_at > datetime('now')) "
695
+ "AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) "
695
696
  "AND deleted_at IS NULL "
696
697
  "ORDER BY created_at DESC LIMIT 50",
697
698
  (ns,),
@@ -737,7 +738,7 @@ async def context_stats(
737
738
 
738
739
  expired = conn.execute(
739
740
  "SELECT COUNT(*) FROM kv_store "
740
- "WHERE namespace = ? AND expires_at < datetime('now') "
741
+ "WHERE namespace = ? AND datetime(expires_at) < datetime('now') "
741
742
  "AND deleted_at IS NULL",
742
743
  (ns,),
743
744
  ).fetchone()[0]
@@ -773,15 +774,15 @@ async def context_stats(
773
774
  }
774
775
 
775
776
  def _resolve_db_path(scope: str) -> Path | None:
776
- """Resolve the database file path for a given scope."""
777
+ """Resolve the database file path for a given scope.
778
+
779
+ Returns the actual path used at init (honoring an explicit
780
+ ``--project-db``), so deletelog writes and db_size stats land next to
781
+ the real database file.
782
+ """
777
783
  if scope == "global":
778
784
  return _global_root / "global.db"
779
- if _project_db_instance:
780
- # Find the project root from the project DB path
781
- proj_root = pantheon_project()
782
- if proj_root:
783
- return proj_root / ".pantheon" / "persistence" / "project.db"
784
- return None
785
+ return _project_db_path
785
786
 
786
787
 
787
788
  # ── Main Entrypoint ─────────────────────────────────────────────────────────────
@@ -18,6 +18,7 @@ from typing import Any
18
18
 
19
19
  import yaml
20
20
  from _pantheon_paths import pantheon_home, pantheon_project
21
+ from eval_store import get_latest_eval, list_evals
21
22
  from mcp.server.fastmcp import FastMCP
22
23
 
23
24
  # ── Path Resolution ──────────────────────────────────────────────────────────
@@ -201,6 +202,63 @@ async def get_memory_bank(path: str) -> str:
201
202
  return resolved.read_text(encoding="utf-8")
202
203
 
203
204
 
205
+ # ── Plugin Eval Resources ─────────────────────────────────────────────────────
206
+
207
+
208
+ def _valid_plugin_name(name: str) -> bool:
209
+ """Validate a plugin name for the eval resource (no traversal)."""
210
+ return bool(name) and "/" not in name and "\\" not in name and ".." not in name
211
+
212
+
213
+ @mcp.resource(
214
+ "pantheon://eval",
215
+ description="List of evaluated plugins/skills with their latest "
216
+ "certification score (from the plugin_eval memory namespace)",
217
+ )
218
+ async def list_plugin_evals() -> str:
219
+ """Return a markdown list of evaluated plugins with latest scores."""
220
+ entries = list_evals()
221
+ if not entries:
222
+ return "No plugin evals recorded yet."
223
+
224
+ lines = ["# Plugin Eval Certification", ""]
225
+ for entry in entries:
226
+ name = entry.get("name", "?")
227
+ score = entry.get("metadata", {}).get("score", "?")
228
+ created = entry.get("created_at", "")
229
+ lines.append(f"- **{name}** — score {score} (evaluated {created})")
230
+ return "\n".join(lines)
231
+
232
+
233
+ @mcp.resource(
234
+ "pantheon://eval/{plugin}",
235
+ description="Latest certification report for a plugin/skill "
236
+ "(from the plugin_eval memory namespace)",
237
+ )
238
+ async def get_plugin_eval(plugin: str) -> str:
239
+ """Return the latest eval certification report for a plugin.
240
+
241
+ Security: plugin names containing path separators or traversal
242
+ sequences are rejected before any lookup.
243
+ """
244
+ if not _valid_plugin_name(plugin):
245
+ return "Invalid plugin name: blocked."
246
+
247
+ entry = get_latest_eval(plugin)
248
+ if entry is None:
249
+ return f"No eval found for plugin '{plugin}'."
250
+
251
+ score = entry.get("metadata", {}).get("score", "?")
252
+ created = entry.get("created_at", "")
253
+ value = entry.get("value", "{}")
254
+ return (
255
+ f"# Eval Report: {plugin}\n\n"
256
+ f"**Score:** {score}\n"
257
+ f"**Evaluated:** {created}\n\n"
258
+ f"```json\n{value}\n```"
259
+ )
260
+
261
+
204
262
  # ── Main Entrypoint ───────────────────────────────────────────────────────────
205
263
 
206
264
  if __name__ == "__main__":
@@ -25,6 +25,7 @@ import json
25
25
  import os
26
26
  import sqlite3
27
27
  import time
28
+ from datetime import datetime, timezone
28
29
  from pathlib import Path
29
30
  from typing import Any
30
31
 
@@ -186,10 +187,55 @@ def _parse_metadata(row: dict[str, Any]) -> dict[str, Any]:
186
187
  _RRF_CONST = 60
187
188
 
188
189
 
190
+ def _parse_iso_ts(value: str) -> float:
191
+ """Parse an ISO 8601 UTC timestamp to epoch seconds.
192
+
193
+ Handles both aware ISO strings (``2026-08-21T10:00:00Z`` /
194
+ ``...+00:00``) and legacy naive rows from ``DEFAULT (datetime('now'))``
195
+ (``YYYY-MM-DD HH:MM:SS``, space separator, no TZ). Naive timestamps are
196
+ assumed UTC — SQLite's ``datetime('now')`` is UTC, so interpreting them
197
+ in the local timezone would skew freshness decay.
198
+ """
199
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
200
+ if dt.tzinfo is None:
201
+ dt = dt.replace(tzinfo=timezone.utc)
202
+ return dt.timestamp()
203
+
204
+
205
+ def _fetch_created_at_map(
206
+ db: sqlite3.Connection, ids: list[int]
207
+ ) -> dict[int, str]:
208
+ """Fetch created_at timestamps for the given memory IDs.
209
+
210
+ Args:
211
+ db: Active SQLite connection.
212
+ ids: Memory row IDs to look up.
213
+
214
+ Returns:
215
+ Mapping of id -> created_at ISO string. Empty on failure.
216
+ """
217
+ created_at_map: dict[int, str] = {}
218
+ if not ids:
219
+ return created_at_map
220
+ try:
221
+ placeholders = ",".join("?" * len(ids))
222
+ rows = db.execute(
223
+ f"SELECT id, created_at FROM memories "
224
+ f"WHERE id IN ({placeholders})",
225
+ ids,
226
+ ).fetchall()
227
+ created_at_map = {r["id"]: r["created_at"] for r in rows}
228
+ except Exception:
229
+ created_at_map = {}
230
+ return created_at_map
231
+
232
+
189
233
  def _rrf_fuse(
190
234
  vec_ids: list[int],
191
235
  fts_ids: list[int],
192
236
  top_k: int,
237
+ created_at_map: dict[int, str] | None = None,
238
+ decay_days: float | None = None,
193
239
  ) -> list[tuple[int, float]]:
194
240
  """Reciprocal Rank Fusion of vector and FTS5 result ID lists.
195
241
 
@@ -197,6 +243,12 @@ def _rrf_fuse(
197
243
  vec_ids: Ordered list of IDs from vector search (most relevant first).
198
244
  fts_ids: Ordered list of IDs from FTS5 search (most relevant first).
199
245
  top_k: Maximum number of fused results to return.
246
+ created_at_map: Optional mapping of doc_id -> created_at ISO string,
247
+ used to compute freshness when ``decay_days`` is set.
248
+ decay_days: Optional freshness half-life in days. When set, each fused
249
+ score is multiplied by ``freshness = 2^(-days/decay_days)`` so
250
+ older entries rank lower. Default None = no decay (backward
251
+ compatible).
200
252
 
201
253
  Returns:
202
254
  List of (id, rrf_score) tuples sorted by descending score.
@@ -206,6 +258,19 @@ def _rrf_fuse(
206
258
  scores[doc_id] = 1.0 / (_RRF_CONST + rank)
207
259
  for rank, doc_id in enumerate(fts_ids, start=1):
208
260
  scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (_RRF_CONST + rank)
261
+
262
+ if decay_days is not None and decay_days > 0 and created_at_map:
263
+ now = time.time()
264
+ for doc_id in list(scores):
265
+ created = created_at_map.get(doc_id)
266
+ if not created:
267
+ continue
268
+ try:
269
+ days = max(0.0, (now - _parse_iso_ts(created)) / 86400.0)
270
+ except (ValueError, TypeError):
271
+ continue
272
+ scores[doc_id] *= 2.0 ** (-days / decay_days)
273
+
209
274
  ranked = sorted(scores.items(), key=lambda x: (-x[1], x[0]))
210
275
  return ranked[:top_k]
211
276
 
@@ -282,12 +347,15 @@ def memory_store(
282
347
  @mcp.tool(
283
348
  description="Hybrid semantic search across memories. "
284
349
  "Combines vector cosine similarity and FTS5 BM25 keyword search "
285
- "via Reciprocal Rank Fusion (RRF).",
350
+ "via Reciprocal Rank Fusion (RRF). Optional decay_days applies a "
351
+ "freshness half-life (2^(-days/decay_days)) so recent entries rank "
352
+ "higher; default None keeps the original rank-only behavior.",
286
353
  )
287
354
  def memory_search( # noqa: PLR0912
288
355
  query: str,
289
356
  namespace: str | None = None,
290
357
  top_k: int = 5,
358
+ decay_days: float | None = None,
291
359
  ) -> list[dict[str, Any]]:
292
360
  """Hybrid search: vector + keyword fused via RRF.
293
361
 
@@ -299,6 +367,9 @@ def memory_search( # noqa: PLR0912
299
367
  query: Search query text.
300
368
  namespace: Optional namespace filter.
301
369
  top_k: Maximum results (default 5, max 50).
370
+ decay_days: Optional freshness half-life in days. When set, fused
371
+ scores are multiplied by ``2^(-days_since_created/decay_days)``
372
+ so older entries rank lower. Default None = no decay.
302
373
 
303
374
  Returns:
304
375
  List of memory entries with score and metadata.
@@ -364,8 +435,14 @@ def memory_search( # noqa: PLR0912
364
435
  except Exception:
365
436
  fts_ids = []
366
437
 
367
- # 4. RRF fusion
368
- fused = _rrf_fuse(vec_ids, fts_ids, top_k)
438
+ # 4. RRF fusion (optionally freshness-decayed)
439
+ candidate_ids = list(dict.fromkeys([*vec_ids, *fts_ids]))
440
+ created_at_map = (
441
+ _fetch_created_at_map(db, candidate_ids)
442
+ if decay_days is not None and decay_days > 0
443
+ else {}
444
+ )
445
+ fused = _rrf_fuse(vec_ids, fts_ids, top_k, created_at_map, decay_days)
369
446
 
370
447
  if not fused:
371
448
  return []
@@ -15,9 +15,9 @@
15
15
  * @module auto-wake
16
16
  */
17
17
 
18
+ import { existsSync } from 'node:fs'
18
19
  import { readdir, readFile, rename } from 'node:fs/promises'
19
20
  import { join } from 'node:path'
20
- import { existsSync } from 'node:fs'
21
21
 
22
22
  /** Signal structure written by BackgroundJobBoard.writeSignal(). */
23
23
  export interface WakeSignal {
@@ -48,9 +48,7 @@ const DEFAULT_SIGNAL_DIR = '.pantheon/deepwork/board-signals'
48
48
  * @param signalDir Directory to scan (default: `.pantheon/deepwork/board-signals`)
49
49
  * @returns List of wake signals from newly consumed files
50
50
  */
51
- export async function consumeWakeSignals(
52
- signalDir?: string,
53
- ): Promise<WakeSignal[]> {
51
+ export async function consumeWakeSignals(signalDir?: string): Promise<WakeSignal[]> {
54
52
  const dir = signalDir ?? DEFAULT_SIGNAL_DIR
55
53
 
56
54
  // If the signal directory doesn't exist, there are no signals
@@ -23,7 +23,14 @@ const log = createPantheonLogger({ module: 'BackgroundJobBoard' })
23
23
  // ─── Types ─────────────────────────────────────────────────────────────
24
24
 
25
25
  /** Valid states for a background job. */
26
- export type BackgroundJobState = 'running' | 'completed' | 'error' | 'cancelled' | 'reconciled'
26
+ export type BackgroundJobState =
27
+ | 'running'
28
+ | 'completed'
29
+ | 'error'
30
+ | 'startup_failed'
31
+ | 'startup_unknown'
32
+ | 'cancelled'
33
+ | 'reconciled'
27
34
 
28
35
  /** Terminal states that can transition to reconciled. */
29
36
  const TERMINAL_STATES: ReadonlySet<BackgroundJobState> = new Set([
@@ -38,9 +45,11 @@ const TERMINAL_STATES: ReadonlySet<BackgroundJobState> = new Set([
38
45
  * Only running → terminal, or same-terminal → same-terminal (idempotent re-notify).
39
46
  */
40
47
  const UPDATE_TRANSITIONS: Record<BackgroundJobState, ReadonlySet<BackgroundJobState>> = {
41
- running: new Set(['completed', 'error', 'cancelled']),
48
+ running: new Set(['completed', 'error', 'startup_failed', 'startup_unknown', 'cancelled']),
42
49
  completed: new Set(['completed']),
43
50
  error: new Set(['error']),
51
+ startup_failed: new Set(['startup_failed']),
52
+ startup_unknown: new Set(['startup_unknown']),
44
53
  cancelled: new Set(['cancelled']),
45
54
  reconciled: new Set([]),
46
55
  }
@@ -101,7 +110,7 @@ export interface LaunchInput {
101
110
  /** Input for updating a job's status to a terminal state. */
102
111
  export interface StatusInput {
103
112
  taskID: string
104
- state: 'completed' | 'error' | 'cancelled'
113
+ state: 'completed' | 'error' | 'startup_failed' | 'startup_unknown' | 'cancelled'
105
114
  resultSummary?: string
106
115
  error?: string
107
116
  timedOut?: boolean
@@ -289,7 +298,11 @@ export class BackgroundJobBoard {
289
298
  if (input.timedOut !== undefined) {
290
299
  job.timedOut = input.timedOut
291
300
  }
292
- if (input.state === 'error') {
301
+ if (
302
+ input.state === 'error' ||
303
+ input.state === 'startup_failed' ||
304
+ input.state === 'startup_unknown'
305
+ ) {
293
306
  job.totalErrors++
294
307
  }
295
308
  if (input.timedOut) {
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Comment Checker — detects trivial/auto-generated comments that make code
3
+ * look machine-produced rather than human-written.
4
+ *
5
+ * Used by @themis (Layer 1: Surface review) to flag comments that add no
6
+ * information value and signal AI-generated code.
7
+ */
8
+
9
+ /** Regex patterns matching trivial, uninformative comments. */
10
+ export const TRIVIAL_PATTERNS: readonly RegExp[] = [
11
+ /#\s*increment\s+\w+/i,
12
+ /#\s*decrement\s+\w+/i,
13
+ /#\s*set\s+\w+/i,
14
+ /#\s*loop\s+over/i,
15
+ /#\s*(initialize|init)\s+\w+/i,
16
+ /#\s*(return|yield)\s+\w+/i,
17
+ ]
18
+
19
+ export interface CommentCheckResult {
20
+ /** Number of trivial comments found (0 = clean). */
21
+ score: number
22
+ /** Human-readable descriptions of each flagged comment. */
23
+ flags: string[]
24
+ }
25
+
26
+ /**
27
+ * Scan source code for trivial comments that add no information value.
28
+ *
29
+ * Returns a score (count of trivial comments) and a list of flags describing
30
+ * each match. A score of 0 means no trivial comments were detected.
31
+ */
32
+ export function checkCommentDensity(source: string): CommentCheckResult {
33
+ const flags: string[] = []
34
+ const lines = source.split('\n')
35
+
36
+ for (let i = 0; i < lines.length; i++) {
37
+ const line = lines[i] as string
38
+ for (const pattern of TRIVIAL_PATTERNS) {
39
+ if (pattern.test(line)) {
40
+ const trimmed = line.trim()
41
+ flags.push(`Line ${i + 1}: trivial comment — "${trimmed}"`)
42
+ break // one flag per line, even if multiple patterns match
43
+ }
44
+ }
45
+ }
46
+
47
+ return { score: flags.length, flags }
48
+ }