diff --git a/docs/routines/automation-candidate-pipeline-prd.md b/docs/routines/automation-candidate-pipeline-prd.md
index ac82c3b140..5c24cff33a 100644
--- a/docs/routines/automation-candidate-pipeline-prd.md
+++ b/docs/routines/automation-candidate-pipeline-prd.md
@@ -10,6 +10,8 @@ created_at: "2026-05-02"
updated_at: "2026-05-02"
---
+> **2026-09-05 status note:** the QuickJS routine scripts this document describes (`routines/monitoring/*.js`) were removed from the repository — they were never attached as production routines. The Rust automation-candidate API/DB surface remains; treat the routine-side sections below as historical.
+
# PRD: AgentDesk Automation Candidate Pipeline
> status: `implemented`
diff --git a/docs/routines/automation-candidate-pipeline-spec.md b/docs/routines/automation-candidate-pipeline-spec.md
index a338e4c33c..e68df7576c 100644
--- a/docs/routines/automation-candidate-pipeline-spec.md
+++ b/docs/routines/automation-candidate-pipeline-spec.md
@@ -11,6 +11,8 @@ created_at: "2026-05-02"
updated_at: "2026-05-02"
---
+> **2026-09-05 status note:** the QuickJS routine scripts this document describes (`routines/monitoring/*.js`) were removed from the repository — they were never attached as production routines. The Rust automation-candidate API/DB surface remains; treat the routine-side sections below as historical.
+
# SPEC SOURCE: AgentDesk Automation Candidate Pipeline
## Linked Documents
diff --git a/docs/routines/daily-log-digest.md b/docs/routines/daily-log-digest.md
deleted file mode 100644
index 40dfe44e23..0000000000
--- a/docs/routines/daily-log-digest.md
+++ /dev/null
@@ -1,92 +0,0 @@
-# Daily dcserver log digest routine
-
-Issue #4263 adds `monitoring/daily-log-digest.js` to the existing PostgreSQL-backed routine
-worker. It is an agent-backed monitoring routine because the QuickJS routine sandbox intentionally
-has no filesystem or network bridge. The routine dispatches one fresh agent turn per day; that turn
-runs the deterministic sibling helper, and the existing routine Discord logger posts the final
-summary to the configured routine channel/thread.
-
-## Attach once
-
-Routines use `routines.default_timezone` (Asia/Seoul by default). Attach one row on the cluster
-leader and target the operations channel with `discord_thread_id`:
-
-```bash
-REL_PORT="${AGENTDESK_REL_PORT:-8791}"
-API="http://127.0.0.1:${REL_PORT}"
-
-curl -sf "$API/api/routines" -X POST -H 'Content-Type: application/json' -d '{
- "script_ref": "monitoring/daily-log-digest.js",
- "name": "daily-dcserver-log-digest",
- "agent_id": "project-agentdesk",
- "execution_strategy": "fresh",
- "schedule": "10 9 * * *",
- "discord_thread_id": "YOUR_OPS_CHANNEL_OR_THREAD_ID",
- "timeout_secs": 900
-}'
-```
-
-The cron schedule is persisted in the normal routines table and claimed through the existing
-routine lease. A checkpoint day key is a second guard against a manual or duplicate same-day run,
-so the routine dispatches at most one digest agent turn per KST day.
-
-## Inputs and configuration
-
-`routines/monitoring/daily_log_digest.py` resolves the runtime root in the same order used by
-release tooling: `AGENTDESK_ROOT_DIR`, then `ADK_REL`, then `$HOME/.adk/release`. It reads:
-
-- `logs/dcserver.stdout.log` and its numbered rotations (the internal tracing writer);
-- `logs/dcserver.launchd.stderr.log` (the path emitted by AgentDesk's launchd/systemd setup).
-
-Timestamped lines are limited to the preceding 24 hours. Undated launchd stderr uses a persistent
-device/inode/byte-offset checkpoint under `runtime/daily-log-digest/`, so a range is counted at
-most once; rotation or truncation starts a new range. The first observation establishes the
-watermark, and a stale file outside the window is baselined without counting its old contents.
-
-Optional environment settings, normally placed in the deployment's preserved
-`config/launchd.env`, are:
-
-- `AGENTDESK_LOG_DIGEST_THRESHOLD`: positive daily count threshold, default `50`; a pattern must
- be strictly greater than the threshold. Invalid values warn and fall back to `50`.
-- `AGENTDESK_LOG_DIGEST_REPO`: GitHub repository for open-issue dedup, default
- `itismyfield/AgentDesk`.
-- `AGENTDESK_LOG_DIGEST_CREATE_ISSUE`: default `off`. Only the literal `confirmed`, set by a human
- after reviewing pending drafts, allows the approval path to inspect per-draft markers.
-
-## Normalization, dedup, and drafts
-
-`log_digest_issue_drafts.py` is the reusable API for this routine and #4265. Its public pipeline is:
-
-```python
-patterns = aggregate_normalized_signatures(lines)
-decisions = decide_issue_drafts(patterns, open_issues, threshold=50)
-drafts = write_pending_drafts(
- [decision.draft for decision in decisions if decision.draft],
- pending_dir,
-)
-post = maybe_post_approved_drafts(drafts, approval_mode, create_issue)
-```
-
-Normalization removes ANSI decoration and timestamps, canonicalizes ERROR/WARN, and replaces UUIDs,
-hashes, known embedded/dynamic IDs, request tokens, and most bare numbers with placeholders. HTTP
-status codes and explicitly labelled ports remain distinct; unlabeled semantic numbers may still
-collapse. Counts are grouped by severity plus normalized signature. Threshold crossings are
-compared against the bounded issue title and first non-empty body line using at least three shared
-tokens and symmetric Jaccard similarity; direct containment is accepted only for signature-like
-candidates. This can miss a duplicate described only deep in a long body, but avoids suppressing a
-new short signature merely mentioned in an unrelated epic. If the open-issue query is unavailable,
-invalid, or reaches the 1,000-result cap, draft generation fails closed to avoid duplicate pending
-work from an incomplete dedup set.
-
-Pending Markdown files use a stable signature hash and live at:
-
-```text
-${AGENTDESK_ROOT_DIR:-$HOME/.adk/release}/runtime/pending-issue-drafts/daily-log-digest/
-```
-
-The normal/default path never creates an issue. Approval is deliberately two-step: after reviewing
-one pending file, create its adjacent marker (for example
-`error-0123456789abcdef.md.approved`) and set
-`AGENTDESK_LOG_DIGEST_CREATE_ISSUE=confirmed`. Both the literal environment gate and that specific
-draft's `.approved` marker must exist before the injected issue-creation callback can run. A future
-caller using the shared helper inherits both checks.
diff --git a/docs/routines/local-worktree-inventory.md b/docs/routines/local-worktree-inventory.md
deleted file mode 100644
index adc20b570b..0000000000
--- a/docs/routines/local-worktree-inventory.md
+++ /dev/null
@@ -1,80 +0,0 @@
-# Local agent worktree inventory (#4684)
-
-Isolation `worktree` agent spawns leave `.claude/worktrees/agent-*` git worktrees
-behind whenever the sub-agent commits (auto-clean only removes *unchanged*
-worktrees). With no reclamation these accumulate — the issue observed 70GB / 134
-worktrees, 52 orphaned. This routine provides the missing **visibility**: a
-scheduled, report-only inventory that surfaces sizes, ages, and orphan
-classifications so the leak is measurable and a later prune step has trustworthy
-input.
-
-## Two pieces
-
-- `routines/monitoring/local_worktree_inventory.js` — the deterministic,
- **read-only** helper that does the real work: enumerates `agent-*` worktree
- directories (no symlink follow), cross-references `git worktree list
- --porcelain`, and per candidate reads mtime/age, apparent disk size (`du
- -sk`), dirty state (`git status --porcelain`), lock state, git registration,
- and merge state (`git merge-base --is-ancestor
origin/main`). It emits
- one schema-validated JSON report.
-- `routines/local-worktree-gc.js` — the QuickJS routine. QuickJS routines have
- no filesystem bridge, so (matching `daily-log-digest`) it only dispatches one
- fresh agent turn per KST day that runs the helper and returns its JSON stdout
- verbatim. A checkpoint day-key prevents duplicate same-day dispatch.
-
-## Safety: report-only, safe by construction
-
-The helper performs **zero destructive operations** — its entire source contains
-no worktree remove/prune, branch delete, ref delete, `rm -rf`, `find -delete`, or
-`fs` unlink/rm call (asserted by test). Every child process is a read-only git or
-`du` subcommand. It never deletes anything and never claims deletion authority:
-every reported entry sets `positive_ownership_proof: false` and the report sets
-`destructive_actions: 0`.
-
-Dispositions are advisory labels for a human or a future prune step, never an
-instruction the helper acts on:
-
-- **PRESERVE** — dirty (uncommitted work), locked, unknown/uninspectable,
- registered-but-missing directory, or clean-but-unmerged-and-recent. This is the
- #4595 lesson: a naive GC could have destroyed the exact uncommitted work this
- work was recovered from, so anything dirty or locked is preserved
- unconditionally (schema validation rejects any dirty/locked entry not marked
- PRESERVE).
-- **AGED_ORPHAN_REVIEW** — clean but unmerged and older than the age threshold
- (default 7 days). Flagged for human review; any future removal must first back
- up the branch tip to `refs/archive/worktree-gc/` (issue proposal #2). The
- helper does not remove it.
-- **SAFE_MERGED_CANDIDATE** — clean AND merged into `origin/main` AND registered
- (issue proposal #1, the session-verified safe-reclaim condition). Surfaced as a
- candidate only; still report-only.
-
-## Scheduling (attach once)
-
-Like `daily-log-digest`, scheduling is a persisted routine row, not JS metadata.
-Attach one row on the cluster leader targeting the operations channel:
-
-```bash
-REL_PORT="${AGENTDESK_REL_PORT:-8791}"
-API="http://127.0.0.1:${REL_PORT}"
-
-curl -sf "$API/api/routines" -X POST -H 'Content-Type: application/json' -d '{
- "script_ref": "local-worktree-gc.js",
- "name": "local-agent-worktree-inventory",
- "agent_id": "project-agentdesk",
- "execution_strategy": "fresh",
- "schedule": "0 9 * * *",
- "discord_thread_id": "YOUR_OPS_CHANNEL_OR_THREAD_ID",
- "timeout_secs": 900
-}'
-```
-
-The cron schedule (09:00 `routines.default_timezone`, Asia/Seoul by default) is
-persisted in the routines table and claimed through the existing routine lease.
-
-## Configuration
-
-The helper resolves the repository from `AGENTDESK_REPO_DIR` (the routine prompt
-sets it to `$ROOT/workspaces/agentdesk`). The age threshold defaults to 7 days
-and the integration ref to `origin/main`; both are parameters of `runInventory`.
-Running `node routines/monitoring/local_worktree_inventory.js` directly prints the
-current inventory report for ad-hoc inspection.
diff --git a/docs/routines/observation-provider-enrichment-prd.md b/docs/routines/observation-provider-enrichment-prd.md
index 3f4b002019..ad85f4f73f 100644
--- a/docs/routines/observation-provider-enrichment-prd.md
+++ b/docs/routines/observation-provider-enrichment-prd.md
@@ -11,6 +11,8 @@ updated_at: "2026-05-02"
p0_pr: "https://github.com/itismyfield/AgentDesk/pull/1497"
---
+> **2026-09-05 status note:** the QuickJS routine scripts this document describes (`routines/monitoring/*.js`) were removed from the repository — they were never attached as production routines. The Rust automation-candidate API/DB surface remains; treat the routine-side sections below as historical.
+
# PRD: AgentDesk Observation Provider Enrichment
> status: `p0-implemented` — PR #1497 merged. P1/P2 미착수.
diff --git a/docs/routines/observation-provider-enrichment-spec.md b/docs/routines/observation-provider-enrichment-spec.md
index 98bda9ca31..67c7cd9ca1 100644
--- a/docs/routines/observation-provider-enrichment-spec.md
+++ b/docs/routines/observation-provider-enrichment-spec.md
@@ -12,6 +12,8 @@ updated_at: "2026-05-02"
p0_pr: "https://github.com/itismyfield/AgentDesk/pull/1497"
---
+> **2026-09-05 status note:** the QuickJS routine scripts this document describes (`routines/monitoring/*.js`) were removed from the repository — they were never attached as production routines. The Rust automation-candidate API/DB surface remains; treat the routine-side sections below as historical.
+
# SPEC SOURCE: AgentDesk Observation Provider Enrichment
## Linked Documents
diff --git a/docs/routines/weekly-churn-audit.md b/docs/routines/weekly-churn-audit.md
deleted file mode 100644
index 0530ce7981..0000000000
--- a/docs/routines/weekly-churn-audit.md
+++ /dev/null
@@ -1,64 +0,0 @@
-# Weekly regression-churn audit
-
-Issue #4265 adds the offline `routines/monitoring/weekly_churn_audit.py` cron entry point. It reads
-the local repository's `git log --since='7 days'`, recognizes conventional `fix:` and
-`fix(scope):` subjects (including the optional breaking-change `!` marker), and counts each fix
-commit once per changed file and once per containing module directory. A file with at least the
-configured threshold (default `3`) is reported as a `재설계 후보`.
-
-Genuine issue references such as a leading `#4262` and references in the commit body are parsed in
-text order. Terminal squash-merge PR suffixes such as `(#4511) (#4523)` are removed from the
-subject before parsing, so they never become regression generations. Adjacent remaining references
-form `#A→#B` edges; edges that meet across commits are joined, and the longest chain in each
-connected lineage is reported with its generation count. Lineage path exploration is bounded and
-logs a warning if a dense component must be truncated. The audit uses only commit text from every
-commit in the window and the local git object database for this analysis; the file/module churn
-tallies remain restricted to conventional fix commits.
-
-## Weekly cron entry point
-
-The default invocation is a dry run and prints the report to stdout:
-
-```bash
-ROOT="${AGENTDESK_ROOT_DIR:-$HOME/.adk/release}"
-python3 "$ROOT/routines/monitoring/weekly_churn_audit.py" \
- --repo-root "$ROOT" \
- --runtime-root "$ROOT"
-```
-
-For example, an operator-managed KST cron can run it at 09:20 every Monday:
-
-```cron
-20 9 * * 1 python3 "$HOME/.adk/release/routines/monitoring/weekly_churn_audit.py" --repo-root "$HOME/.adk/release" --runtime-root "$HOME/.adk/release"
-```
-
-Optional configuration:
-
-- `AGENTDESK_CHURN_AUDIT_THRESHOLD`: positive candidate threshold, default `3`. Invalid values log
- a warning and fall back to the default.
-- `AGENTDESK_CHURN_AUDIT_SINCE`: local git window, default `7 days`.
-- `AGENTDESK_CHURN_AUDIT_REPO`: repository used only by confirmed GitHub dedup/creation, default
- `itismyfield/AgentDesk`.
-- `AGENTDESK_CHURN_AUDIT_API`: local AgentDesk send endpoint, default
- `http://127.0.0.1:8791/api/discord/send`.
-- `AGENTDESK_CHURN_AUDIT_CHANNEL_ID`: weekly operations channel or thread ID.
-
-## Default-off side effects
-
-Both side-effect paths require a literal, human-set confirmation:
-
-- `AGENTDESK_CHURN_AUDIT_POST_CHANNEL=confirmed` posts the stdout report to
- `AGENTDESK_CHURN_AUDIT_CHANNEL_ID`. The default is `off`. A successful report fingerprint is
- stored under `runtime/weekly-churn-audit/post-state.json`, so rerunning an identical weekly report
- does not post it twice.
-- `AGENTDESK_CHURN_AUDIT_CREATE_ISSUE=confirmed` enables the GitHub open-issue scan and pending
- draft emission. The default is `off`, so an ordinary run makes no `gh` or network call and writes
- no draft. If dedup is unavailable or truncated, draft emission fails closed.
-
-Pending drafts use the stable writer shared with the daily log digest and live under
-`runtime/pending-issue-drafts/weekly-churn-audit/`. Confirmation alone does not create an issue:
-the operator must review a draft and add its adjacent `.approved` marker. Only a subsequent run
-with both that marker and `AGENTDESK_CHURN_AUDIT_CREATE_ISSUE=confirmed` can invoke issue creation.
-Created drafts include a stable per-file `churn-audit:candidate` marker; later runs use that exact
-marker to suppress duplicates while the issue remains open. This is the same two-step human
-approval boundary used by the sibling daily digest.
diff --git a/policies/__tests__/automation-candidate-executor.test.js b/policies/__tests__/automation-candidate-executor.test.js
deleted file mode 100644
index 754db1fe4f..0000000000
--- a/policies/__tests__/automation-candidate-executor.test.js
+++ /dev/null
@@ -1,473 +0,0 @@
-const test = require("node:test");
-const assert = require("node:assert/strict");
-const { loadRoutine } = require("./support/routine-harness");
-
-const ROUTINE_PATH = "routines/monitoring/automation-candidate-executor.js";
-
-const BASE_NOW = new Date("2026-05-12T10:00:00Z");
-
-const MAX_ITERATIONS = 10;
-const DISPATCH_RETRY_MS = 30 * 60 * 1000;
-const MAX_DISPATCH_RETRIES = 3;
-
-function makeReadyObs(cardId, overrides = {}) {
- return {
- source: "kanban_ready",
- pipeline_stage_id: overrides.pipeline_stage_id ?? "automation-candidate",
- card_id: cardId,
- summary: overrides.summary || `Test card ${cardId}`,
- metadata: {
- automation_candidate: {
- source: overrides.source || "test",
- dedupe_key: overrides.dedupe_key || `test:${cardId}`,
- ...(overrides.automation_candidate || {}),
- },
- program: {
- repo_dir: overrides.repo_dir || "/tmp/repo",
- description: overrides.description || "Fix something",
- allowed_write_paths: overrides.allowed_write_paths || ["src/"],
- metric_name: overrides.metric_name || "score",
- metric_target: overrides.metric_target ?? 0.9,
- current_iteration: overrides.current_iteration ?? 0,
- ...(overrides.program || {}),
- },
- },
- };
-}
-
-function makeDispatchedObs(cardId) {
- return {
- source: "kanban_dispatched",
- card_id: cardId,
- };
-}
-
-// --- No candidates ---
-
-test("no ready observations → complete with no-candidates summary", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: [], automationInventory: [] });
-
- assert.equal(r.action, "complete");
- assert.ok(r.result.summary.includes("없음"), `summary should mention no candidates: ${r.result.summary}`);
- assert.equal(r.checkpoint.stats.ticks, 1);
- assert.equal(r.checkpoint.stats.dispatched, 0);
-});
-
-test("general kanban ready card without automation discriminator is skipped", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const obs = [makeReadyObs("card-general", {
- pipeline_stage_id: null,
- automation_candidate: null,
- })];
- delete obs[0].pipeline_stage_id;
- delete obs[0].metadata.automation_candidate;
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] });
-
- assert.equal(r.action, "complete");
- assert.equal(r.checkpoint.stats.dispatched, 0);
- assert.equal(r.checkpoint.stats.skipped, 1);
-});
-
-test("automation candidate marker without complete program is skipped", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const obs = [makeReadyObs("card-incomplete-program")];
- delete obs[0].metadata.program.repo_dir;
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] });
-
- assert.equal(r.action, "complete");
- assert.equal(r.checkpoint.stats.dispatched, 0);
- assert.equal(r.checkpoint.stats.skipped, 1);
-});
-
-// --- Single ready card dispatch ---
-
-test("single ready card → agent action with correct prompt content", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const cardId = "card-abc-123";
- const obs = [makeReadyObs(cardId, {
- summary: "Improve login security",
- allowed_write_paths: ["src/auth/"],
- metric_name: "security_score",
- metric_target: 0.95,
- current_iteration: 0,
- })];
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] });
-
- assert.equal(r.action, "agent", "should emit agent action");
- // Card ID in prompt
- assert.ok(r.prompt.includes(cardId), "prompt must include card_id");
- // Branch name
- const expectedBranch = `automation/${cardId}/iter-1`;
- assert.ok(r.prompt.includes(expectedBranch), `prompt must include branch name: ${expectedBranch}`);
- // API endpoint
- assert.ok(r.prompt.includes(`/api/automation-candidates/${cardId}/iteration-result`),
- "prompt must include the iteration-result API endpoint");
- // allowed_write_paths
- assert.ok(r.prompt.includes("src/auth/"), "prompt must mention allowed_write_paths");
- // metric name
- assert.ok(r.prompt.includes("security_score"), "prompt must include metric_name");
-
- // Checkpoint: pending entry created
- assert.ok(r.checkpoint.pending[cardId], "pending entry should be created");
- assert.equal(r.checkpoint.pending[cardId].attempt_count, 1);
- assert.equal(r.checkpoint.stats.dispatched, 1);
-});
-
-test("prompt iteration number equals current_iteration + 1", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const cardId = "card-iter-test";
- // current_iteration = 2 → should dispatch iter 3
- const obs = [makeReadyObs(cardId, { current_iteration: 2 })];
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] });
-
- assert.equal(r.action, "agent");
- assert.ok(r.prompt.includes("iter-3"), "prompt branch should be iter-3");
- assert.ok(r.prompt.includes("3 /"), "prompt should show iteration 3");
-});
-
-test("previous iterations are included in prompt when automationInventory provided", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const cardId = "card-with-history";
- const obs = [makeReadyObs(cardId, { current_iteration: 1 })];
- const prevIterations = [
- { iteration: 1, status: "keep", metric_before: 0.7, metric_after: 0.8, description: "First attempt" },
- ];
-
- const r = tick({
- now: BASE_NOW,
- checkpoint: null,
- observations: obs,
- automationInventory: { [cardId]: prevIterations },
- });
-
- assert.equal(r.action, "agent");
- assert.ok(r.prompt.includes("First attempt"), "prompt should include previous iteration description");
- assert.ok(r.prompt.includes("0.7"), "prompt should include metric_before from previous iter");
-});
-
-test("previous iterations are read from API inventory response shape", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const cardId = "card-api-inventory";
- const obs = [makeReadyObs(cardId, { current_iteration: 1 })];
- const prevIterations = [
- { iteration: 1, status: "keep", metric_before: 5, metric_after: 3, description: "API shaped history" },
- ];
-
- const r = tick({
- now: BASE_NOW,
- checkpoint: null,
- observations: obs,
- automationInventory: { card_id: cardId, iterations: prevIterations },
- });
-
- assert.equal(r.action, "agent");
- assert.ok(r.prompt.includes("API shaped history"), "prompt should include API-shaped history");
- assert.ok(r.prompt.includes("5"), "prompt should include metric_before from API-shaped history");
-});
-
-test("array automation inventory is filtered by card before prompt injection", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const cardId = "card-array-inventory";
- const obs = [makeReadyObs(cardId, { current_iteration: 1 })];
-
- const r = tick({
- now: BASE_NOW,
- checkpoint: null,
- observations: obs,
- automationInventory: [
- { pattern_id: "routine-only", status: "active", description: "Unrelated routine inventory" },
- { card_id: "other-card", iteration: 1, status: "discard", description: "Other card history" },
- { card_id: cardId, iterations: [
- { iteration: 1, status: "keep", metric_before: 4, metric_after: 2, description: "Matching card history" },
- ] },
- ],
- });
-
- assert.equal(r.action, "agent");
- assert.ok(r.prompt.includes("Matching card history"), "prompt should include matching card history");
- assert.ok(!r.prompt.includes("Unrelated routine inventory"), "prompt must not include routine inventory rows");
- assert.ok(!r.prompt.includes("Other card history"), "prompt must not include another card history");
-});
-
-test("previous iterations are read from keyed API inventory response shape", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const cardId = "card-keyed-api-inventory";
- const obs = [makeReadyObs(cardId, { current_iteration: 1 })];
- const prevIterations = [
- { iteration: 1, status: "discard", metric_before: 5, metric_after: 5, description: "Keyed API history" },
- ];
-
- const r = tick({
- now: BASE_NOW,
- checkpoint: null,
- observations: obs,
- automationInventory: { [cardId]: { iterations: prevIterations } },
- });
-
- assert.equal(r.action, "agent");
- assert.ok(r.prompt.includes("Keyed API history"), "prompt should include keyed API-shaped history");
-});
-
-// --- kanban_dispatched suppression ---
-
-test("card in kanban_dispatched observation → suppressed (complete)", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const cardId = "card-dispatched";
- const obs = [
- makeReadyObs(cardId),
- makeDispatchedObs(cardId),
- ];
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] });
-
- assert.equal(r.action, "complete", "card already dispatched should not produce agent action");
- assert.equal(r.checkpoint.stats.dispatched, 0);
- assert.equal(r.checkpoint.stats.skipped, 1);
-});
-
-test("kanban_dispatched by evidence_ref format is also suppressed", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const cardId = "card-evidence-ref";
- const obs = [
- makeReadyObs(cardId),
- // evidence_ref format instead of card_id field
- { source: "kanban_dispatched", evidence_ref: `kanban_cards:${cardId}` },
- ];
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] });
-
- assert.equal(r.action, "complete");
- assert.equal(r.checkpoint.stats.skipped, 1);
-});
-
-// --- Checkpoint.dispatched suppression ---
-
-test("card already in checkpoint.dispatched → skipped", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const cardId = "card-in-checkpoint";
- const obs = [makeReadyObs(cardId)];
- const checkpoint = {
- version: 2,
- dispatched: {
- [cardId]: { dispatched_at: BASE_NOW.toISOString(), status: "ok", iteration: 1 },
- },
- pending: {},
- stats: { ticks: 0, dispatched: 0, skipped: 0, max_iterations_reached: 0 },
- };
-
- const r = tick({ now: BASE_NOW, checkpoint, observations: obs, automationInventory: [] });
-
- assert.equal(r.action, "complete", "checkpointed card should not be re-dispatched");
- assert.equal(r.checkpoint.stats.skipped, 1);
-});
-
-// --- MAX_ITERATIONS boundary ---
-
-test("card at iteration > MAX_ITERATIONS → max_iterations_reached, skipped", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const cardId = "card-max-iter";
- // current_iteration = 10 → next would be iter 11 > MAX_ITERATIONS(10)
- const obs = [makeReadyObs(cardId, { current_iteration: MAX_ITERATIONS })];
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] });
-
- assert.equal(r.action, "complete");
- assert.equal(r.checkpoint.stats.max_iterations_reached, 1);
- assert.equal(r.checkpoint.dispatched[cardId].status, "max_iterations_reached");
- assert.equal(r.checkpoint.stats.dispatched, 0);
-});
-
-test("card at iteration == MAX_ITERATIONS (iter 10) is still dispatched", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const cardId = "card-at-limit";
- // current_iteration = 9 → next is iter 10 == MAX_ITERATIONS → still dispatch
- const obs = [makeReadyObs(cardId, { current_iteration: 9 })];
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] });
-
- assert.equal(r.action, "agent", "iter 10 should still be dispatched");
- assert.ok(r.prompt.includes("iter-10"), "prompt should show iter-10");
-});
-
-// --- Retry window ---
-
-test("card retried within DISPATCH_RETRY_MS → skipped", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const cardId = "card-retry-window";
- const obs = [makeReadyObs(cardId)];
- // last_attempted_at is 10 min ago (< 30 min window)
- const recentlyAttempted = new Date(BASE_NOW.getTime() - 10 * 60 * 1000).toISOString();
- const checkpoint = {
- version: 2,
- dispatched: {},
- pending: {
- [cardId]: {
- first_attempted_at: recentlyAttempted,
- last_attempted_at: recentlyAttempted,
- attempt_count: 1,
- iteration: 1,
- },
- },
- stats: { ticks: 0, dispatched: 0, skipped: 0, max_iterations_reached: 0 },
- };
-
- const r = tick({ now: BASE_NOW, checkpoint, observations: obs, automationInventory: [] });
-
- assert.equal(r.action, "complete", "card within retry window should be skipped");
- assert.equal(r.checkpoint.stats.skipped, 1);
-});
-
-test("card attempted after DISPATCH_RETRY_MS → re-dispatched", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const cardId = "card-retry-expired";
- const obs = [makeReadyObs(cardId)];
- // last_attempted_at is 40 min ago (> 30 min window)
- const expiredAttempt = new Date(BASE_NOW.getTime() - 40 * 60 * 1000).toISOString();
- const checkpoint = {
- version: 2,
- dispatched: {},
- pending: {
- [cardId]: {
- first_attempted_at: expiredAttempt,
- last_attempted_at: expiredAttempt,
- attempt_count: 1,
- iteration: 1,
- },
- },
- stats: { ticks: 0, dispatched: 0, skipped: 0, max_iterations_reached: 0 },
- };
-
- const r = tick({ now: BASE_NOW, checkpoint, observations: obs, automationInventory: [] });
-
- assert.equal(r.action, "agent", "card past retry window should be re-dispatched");
- assert.equal(r.checkpoint.pending[cardId].attempt_count, 2);
-});
-
-// --- MAX_DISPATCH_RETRIES ---
-
-test("card at MAX_DISPATCH_RETRIES limit waits for retry window before stalled", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const cardId = "card-max-retries-in-flight";
- const obs = [makeReadyObs(cardId)];
- const recentAttempt = new Date(BASE_NOW.getTime() - 60 * 1000).toISOString();
- const checkpoint = {
- version: 2,
- dispatched: {},
- pending: {
- [cardId]: {
- first_attempted_at: recentAttempt,
- last_attempted_at: recentAttempt,
- attempt_count: MAX_DISPATCH_RETRIES,
- iteration: 1,
- },
- },
- stats: { ticks: 0, dispatched: 0, skipped: 0, max_iterations_reached: 0 },
- };
-
- const withinWindow = tick({
- now: BASE_NOW,
- checkpoint,
- observations: obs,
- automationInventory: [],
- });
-
- assert.equal(withinWindow.action, "complete");
- assert.equal(withinWindow.checkpoint.stats.stalled_candidates, 0);
- assert.equal(withinWindow.checkpoint.pending[cardId].status, undefined);
- assert.equal(withinWindow.checkpoint.pending[cardId].stalled_at, undefined);
-
- const afterWindow = new Date(BASE_NOW.getTime() + 30 * 60 * 1000);
- const stalled = tick({
- now: afterWindow,
- checkpoint: withinWindow.checkpoint,
- observations: obs,
- automationInventory: [],
- });
-
- assert.equal(stalled.checkpoint.stats.stalled_candidates, 1);
- assert.equal(stalled.checkpoint.pending[cardId].status, "stalled");
- assert.equal(stalled.checkpoint.pending[cardId].stalled_at, afterWindow.toISOString());
-});
-
-test("card at MAX_DISPATCH_RETRIES limit → stalled once across repeated ticks", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const cardId = "card-max-retries";
- const obs = [makeReadyObs(cardId)];
- // attempt_count == MAX_DISPATCH_RETRIES(3)
- const expiredAttempt = new Date(BASE_NOW.getTime() - 40 * 60 * 1000).toISOString();
- const checkpoint = {
- version: 2,
- dispatched: {},
- pending: {
- [cardId]: {
- first_attempted_at: expiredAttempt,
- last_attempted_at: expiredAttempt,
- attempt_count: MAX_DISPATCH_RETRIES,
- iteration: 1,
- },
- },
- stats: { ticks: 0, dispatched: 0, skipped: 0, max_iterations_reached: 0 },
- };
-
- const r = tick({ now: BASE_NOW, checkpoint, observations: obs, automationInventory: [] });
-
- assert.equal(r.action, "complete", "card at max retries should be permanently skipped");
- assert.equal(r.checkpoint.stats.dispatched, 0, "should not count as dispatched");
- assert.equal(r.checkpoint.stats.stalled_candidates, 1, "should count as stalled");
- assert.equal(r.checkpoint.pending[cardId].status, "stalled");
- assert.equal(r.checkpoint.pending[cardId].stalled_at, BASE_NOW.toISOString());
-
- const nextNow = new Date(BASE_NOW.getTime() + 60 * 1000);
- const repeated = tick({
- now: nextNow,
- checkpoint: r.checkpoint,
- observations: obs,
- automationInventory: [],
- });
-
- assert.equal(repeated.action, "complete");
- assert.equal(repeated.checkpoint.stats.stalled_candidates, 1,
- "the same stalled candidate must not be recounted on later ticks");
- assert.equal(repeated.checkpoint.pending[cardId].stalled_at, BASE_NOW.toISOString(),
- "the first stalled transition timestamp must remain stable");
-});
-
-// --- Checkpoint version mismatch ---
-
-test("stale checkpoint version is reset to empty", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const obs = [];
- const staleCheckpoint = {
- version: 1, // old version
- dispatched_signatures: { "some-sig": true },
- stats: { ticks: 999 },
- };
-
- const r = tick({ now: BASE_NOW, checkpoint: staleCheckpoint, observations: obs, automationInventory: [] });
-
- assert.equal(r.checkpoint.version, 2, "checkpoint version should be reset to 2");
- assert.equal(r.checkpoint.stats.ticks, 1, "stats should reset (stale checkpoint discarded)");
-});
-
-// --- Multiple ready cards: first wins ---
-
-test("two ready cards → only first is dispatched per tick", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const obs = [
- makeReadyObs("card-first"),
- makeReadyObs("card-second"),
- ];
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] });
-
- assert.equal(r.action, "agent", "should dispatch first card");
- assert.ok(r.prompt.includes("card-first"), "first card should be dispatched");
- assert.ok(!r.prompt.includes("card-second"), "second card should not appear in this tick's prompt");
- assert.equal(r.checkpoint.stats.dispatched, 1);
-});
diff --git a/policies/__tests__/daily-log-digest.test.js b/policies/__tests__/daily-log-digest.test.js
deleted file mode 100644
index f63ff7fa5f..0000000000
--- a/policies/__tests__/daily-log-digest.test.js
+++ /dev/null
@@ -1,64 +0,0 @@
-const test = require("node:test");
-const assert = require("node:assert/strict");
-const { loadRoutine } = require("./support/routine-harness");
-
-const ROUTINE_PATH = "routines/monitoring/daily-log-digest.js";
-
-test("daily log digest uses the monitoring agent-action frame", () => {
- const { routine, tick } = loadRoutine(ROUTINE_PATH);
- const now = new Date("2026-07-14T00:10:00Z");
-
- const result = tick({ now, checkpoint: null, observations: [], automationInventory: [] });
-
- assert.equal(routine.name, "Daily dcserver Log Digest");
- assert.equal(result.action, "agent");
- assert.match(result.prompt, /python3 "\$ROOT\/routines\/monitoring\/daily_log_digest\.py"/);
- assert.match(result.prompt, /Do not call `gh issue create` directly/);
- assert.match(result.prompt, /AGENTDESK_LOG_DIGEST_CREATE_ISSUE=confirmed/);
- assert.equal(result.checkpoint.last_dispatched_day, "2026-07-14");
-});
-
-test("daily checkpoint suppresses a second digest on the same day", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const first = tick({
- now: new Date("2026-07-14T00:10:00Z"),
- checkpoint: null,
- observations: [],
- automationInventory: [],
- });
- const duplicate = tick({
- now: new Date("2026-07-14T12:10:00Z"),
- checkpoint: first.checkpoint,
- observations: [],
- automationInventory: [],
- });
-
- assert.equal(duplicate.action, "complete");
- assert.equal(duplicate.result.status, "already_dispatched");
-});
-
-test("daily checkpoint dispatches again on the next day", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const result = tick({
- now: new Date("2026-07-15T00:10:00Z"),
- checkpoint: { version: 1, last_dispatched_day: "2026-07-14" },
- observations: [],
- automationInventory: [],
- });
-
- assert.equal(result.action, "agent");
- assert.equal(result.checkpoint.last_dispatched_day, "2026-07-15");
-});
-
-test("daily checkpoint day key follows the routine's default KST timezone", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const result = tick({
- now: new Date("2026-07-14T16:10:00Z"),
- checkpoint: { version: 1, last_dispatched_day: "2026-07-14" },
- observations: [],
- automationInventory: [],
- });
-
- assert.equal(result.action, "agent");
- assert.equal(result.checkpoint.last_dispatched_day, "2026-07-15");
-});
diff --git a/policies/__tests__/detector.test.js b/policies/__tests__/detector.test.js
deleted file mode 100644
index 3a2b63f414..0000000000
--- a/policies/__tests__/detector.test.js
+++ /dev/null
@@ -1,153 +0,0 @@
-const test = require("node:test");
-const assert = require("node:assert/strict");
-const { loadRoutine } = require("./support/routine-harness");
-
-const ROUTINE_PATH = "routines/monitoring/automation-candidate-detector.js";
-
-const BASE_NOW = new Date("2026-05-02T10:00:00Z");
-
-function makeReviewObs(signature, overrides = {}) {
- return {
- key: `routine_observation:candidate_review:${signature}`,
- value: {
- signature,
- score: overrides.score !== undefined ? overrides.score : 85,
- evidence_count: overrides.evidence_count !== undefined ? overrides.evidence_count : 8,
- category: overrides.category || "routine-candidate",
- suggested_automation: overrides.suggested_automation || "자동화 제안",
- outcome_summary: overrides.outcome_summary || "결과 요약",
- last_seen_at: overrides.last_seen_at || BASE_NOW.toISOString(),
- },
- summary: `candidate_review for ${signature}`,
- };
-}
-
-function makeNormalizedReviewObs(signature, overrides = {}) {
- const obs = makeReviewObs(signature, overrides);
- return {
- evidence_ref: `kv_meta:${obs.key}`,
- value: obs.value,
- summary: obs.summary,
- };
-}
-
-function makeApprovedObs(signature) {
- return {
- key: `routine_observation:candidate_approved:${signature}`,
- summary: `candidate_approved for ${signature}`,
- };
-}
-
-function makeDispatchedObs(signature) {
- return {
- key: `routine_observation:candidate_dispatched:${signature}`,
- summary: `candidate_dispatched for ${signature}`,
- };
-}
-
-test("quality gate pass: emits agent action for valid candidate", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const obs = [makeReviewObs("valid-sig")];
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] });
- assert.equal(r.action, "agent", "should emit agent action for valid candidate");
- assert.ok(r.prompt.includes("valid-sig"), "prompt should include candidate signature");
- assert.ok(r.prompt.includes("routine_observation:candidate_approved:valid-sig"),
- "prompt should instruct to write candidate_approved kv_meta");
-});
-
-test("normalized provider candidate_review emits agent action", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const obs = [makeNormalizedReviewObs("normalized-sig")];
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] });
- assert.equal(r.action, "agent", "normalized kv_meta observation should emit agent action");
- assert.ok(r.prompt.includes("routine_observation:candidate_approved:normalized-sig"));
-});
-
-test("already approved candidate is skipped without re-emitting", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const obs = [
- makeReviewObs("approved-sig"),
- makeApprovedObs("approved-sig"),
- ];
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] });
- assert.equal(r.action, "complete", "should return complete when candidate already approved");
- assert.equal(r.result.review_count, 1, "should have processed 1 review obs");
-});
-
-test("evidence_age reject: candidate older than 48h is rejected at quality gate", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const oldTimestamp = new Date(BASE_NOW.getTime() - 49 * 3600_000).toISOString();
- const obs = [makeReviewObs("stale-sig", { last_seen_at: oldTimestamp })];
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] });
- assert.equal(r.action, "complete", "stale candidate should not produce agent action");
- assert.equal(r.checkpoint.stats.skipped_quality_gate, 1, "skipped_quality_gate should be 1");
-});
-
-test("invalid last_seen_at is rejected at quality gate", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const obs = [makeReviewObs("invalid-ts-sig", { last_seen_at: "not-a-date" })];
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] });
- assert.equal(r.action, "complete", "invalid timestamp candidate should not produce agent action");
- assert.equal(r.checkpoint.stats.skipped_quality_gate, 1, "skipped_quality_gate should be 1");
-});
-
-test("previously emitted candidate is not re-emitted on second tick", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const obs = [makeReviewObs("dup-emit-sig")];
-
- // First tick: emits agent action
- const r1 = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] });
- assert.equal(r1.action, "agent", "first tick should emit");
-
- // Second tick: same obs, same checkpoint — should not re-emit
- const nowT2 = new Date(BASE_NOW.getTime() + 60_000);
- const r2 = tick({ now: nowT2, checkpoint: r1.checkpoint, observations: obs, automationInventory: [] });
- assert.equal(r2.action, "complete", "second tick should not re-emit");
-});
-
-test("emitted candidate is retried when durable approval is still missing", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const obs = [makeReviewObs("retry-sig")];
-
- const r1 = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] });
- assert.equal(r1.action, "agent", "first tick should emit");
-
- const nowT2 = new Date(BASE_NOW.getTime() + 61 * 60_000);
- const r2 = tick({ now: nowT2, checkpoint: r1.checkpoint, observations: obs, automationInventory: [] });
- assert.equal(r2.action, "agent", "stale emit should retry when no approval marker exists");
- assert.equal(r2.checkpoint.seen_candidates["retry-sig"].first_seen_at, BASE_NOW.toISOString());
- assert.equal(r2.checkpoint.seen_candidates["retry-sig"].last_emitted_at, nowT2.toISOString());
-});
-
-test("multiple review observations only mark the emitted candidate", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const obs = [
- makeReviewObs("first-sig"),
- makeReviewObs("second-sig"),
- ];
-
- const r1 = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] });
- assert.equal(r1.action, "agent", "first tick should emit one agent action");
- assert.ok(r1.prompt.includes("first-sig"), "first candidate should be emitted first");
- assert.equal(r1.checkpoint.seen_candidates["first-sig"].status, "emitted");
- assert.equal(
- r1.checkpoint.seen_candidates["second-sig"],
- undefined,
- "non-emitted candidates must remain available for later ticks"
- );
-
- const r2 = tick({
- now: new Date(BASE_NOW.getTime() + 60_000),
- checkpoint: r1.checkpoint,
- observations: obs,
- automationInventory: [],
- });
- assert.equal(r2.action, "agent", "second tick should emit the remaining candidate");
- assert.ok(r2.prompt.includes("second-sig"), "second candidate should be emitted on the next tick");
- assert.equal(r2.checkpoint.seen_candidates["second-sig"].status, "emitted");
-});
diff --git a/policies/__tests__/executor.test.js b/policies/__tests__/executor.test.js
deleted file mode 100644
index e1914cb217..0000000000
--- a/policies/__tests__/executor.test.js
+++ /dev/null
@@ -1,106 +0,0 @@
-const test = require("node:test");
-const assert = require("node:assert/strict");
-const { loadRoutine } = require("./support/routine-harness");
-
-const ROUTINE_PATH = "routines/monitoring/automation-executor.js";
-
-const BASE_NOW = new Date("2026-05-02T10:00:00Z");
-
-function makeApprovedObs(signature, overrides = {}) {
- return {
- key: `routine_observation:candidate_approved:${signature}`,
- value: {
- signature,
- score: overrides.score !== undefined ? overrides.score : 85,
- category: overrides.category || "routine-candidate",
- approved_at: overrides.approved_at || BASE_NOW.toISOString(),
- suggested_automation: overrides.suggested_automation || "자동화 제안",
- outcome_summary: overrides.outcome_summary || "결과 요약",
- },
- summary: `candidate_approved for ${signature}`,
- };
-}
-
-function makeNormalizedApprovedObs(signature, overrides = {}) {
- const obs = makeApprovedObs(signature, overrides);
- return {
- evidence_ref: `kv_meta:${obs.key}`,
- value: obs.value,
- summary: obs.summary,
- };
-}
-
-function makeDispatchedObs(signature, overrides = {}) {
- return {
- key: `routine_observation:candidate_dispatched:${signature}`,
- value: {
- signature,
- dispatched_at: overrides.dispatched_at,
- timestamp: overrides.timestamp,
- category: overrides.category || "routine-candidate",
- },
- summary: `candidate_dispatched for ${signature}`,
- };
-}
-
-test("approved candidate triggers agent dispatch with GitHub Issue prompt", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const obs = [makeApprovedObs("dispatch-sig")];
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] });
- assert.equal(r.action, "agent", "should emit agent action for approved candidate");
- assert.ok(r.prompt.includes("dispatch-sig"), "prompt should include signature");
- assert.ok(r.prompt.includes("GitHub Issue"), "prompt should mention GitHub Issue");
- assert.ok(r.prompt.includes("routine_observation:candidate_dispatched:dispatch-sig"),
- "prompt should instruct to write candidate_dispatched kv_meta");
- assert.ok(!r.checkpoint.dispatched_signatures["dispatch-sig"],
- "signature should be checkpointed only after durable candidate_dispatched kv_meta is observed");
-});
-
-test("normalized approved observation triggers agent dispatch", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const obs = [makeNormalizedApprovedObs("normalized-dispatch-sig")];
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] });
- assert.equal(r.action, "agent", "normalized kv_meta observation should emit agent action");
- assert.ok(r.prompt.includes("normalized-dispatch-sig"));
-});
-
-test("already dispatched candidate is skipped", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const obs = [
- makeApprovedObs("skip-sig"),
- makeDispatchedObs("skip-sig"),
- ];
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] });
- assert.equal(r.action, "complete", "already dispatched candidate should not produce agent action");
- assert.equal(r.checkpoint.stats.skipped_already_dispatched, 1, "skipped counter should be 1");
- assert.ok(r.checkpoint.dispatched_signatures["skip-sig"],
- "durable dispatched observation should be mirrored into checkpoint");
-});
-
-test("durable dispatched observation preserves marker timestamp", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const dispatchedAt = new Date(BASE_NOW.getTime() - 6 * 24 * 3600_000).toISOString();
- const obs = [
- makeApprovedObs("old-dispatch-sig"),
- makeDispatchedObs("old-dispatch-sig", { dispatched_at: dispatchedAt }),
- ];
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] });
- assert.equal(r.action, "complete", "already dispatched candidate should not produce agent action");
- assert.equal(
- r.checkpoint.dispatched_signatures["old-dispatch-sig"],
- dispatchedAt,
- "checkpoint should mirror durable marker time instead of now"
- );
-});
-
-test("no approved candidates returns complete with empty summary", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: [], automationInventory: [] });
- assert.equal(r.action, "complete", "no candidates → complete");
- assert.equal(r.result.approved_count, 0, "approved_count should be 0");
-});
diff --git a/policies/__tests__/local-worktree-gc.test.js b/policies/__tests__/local-worktree-gc.test.js
deleted file mode 100644
index 61e470c616..0000000000
--- a/policies/__tests__/local-worktree-gc.test.js
+++ /dev/null
@@ -1,249 +0,0 @@
-const test = require("node:test");
-const assert = require("node:assert/strict");
-const fs = require("node:fs");
-const os = require("node:os");
-const path = require("node:path");
-const { loadRoutine } = require("./support/routine-harness");
-
-const REPO_ROOT = path.resolve(__dirname, "..", "..");
-const helper = require(path.join(REPO_ROOT, "routines/monitoring/local_worktree_inventory.js"));
-
-const DAY = 24 * 60 * 60;
-
-// --- Pure classification: the #4595 data-loss invariant ---
-
-test("dirty worktrees are always PRESERVE (uncommitted work protected)", () => {
- const d = helper.classifyWorktree({
- registered: true,
- locked: false,
- dirty: true,
- merged: true, // even if merged, dirty wins
- age_seconds: 30 * DAY,
- worktree_state: "dirty",
- });
- assert.equal(d.disposition, "PRESERVE");
- assert.equal(d.positive_ownership_proof, false);
-});
-
-test("locked worktrees are always PRESERVE", () => {
- const d = helper.classifyWorktree({
- registered: true,
- locked: true,
- dirty: false,
- merged: true,
- age_seconds: 90 * DAY,
- worktree_state: "clean",
- });
- assert.equal(d.disposition, "PRESERVE");
-});
-
-test("unknown inspection state never authorizes cleanup", () => {
- const d = helper.classifyWorktree({
- registered: true,
- locked: false,
- dirty: false,
- merged: null,
- age_seconds: 90 * DAY,
- worktree_state: "unknown",
- });
- assert.equal(d.disposition, "PRESERVE");
-});
-
-test("clean + unmerged + recent stays PRESERVE (possible live work)", () => {
- const d = helper.classifyWorktree({
- registered: true,
- locked: false,
- dirty: false,
- merged: false,
- age_seconds: 2 * DAY,
- worktree_state: "clean",
- });
- assert.equal(d.disposition, "PRESERVE");
-});
-
-test("clean + unmerged + aged is AGED_ORPHAN_REVIEW, not deletion", () => {
- const d = helper.classifyWorktree({
- registered: true,
- locked: false,
- dirty: false,
- merged: false,
- age_seconds: 30 * DAY,
- worktree_state: "clean",
- });
- assert.equal(d.disposition, "AGED_ORPHAN_REVIEW");
- assert.equal(d.positive_ownership_proof, false);
-});
-
-test("clean + merged + registered is SAFE_MERGED_CANDIDATE (report-only)", () => {
- const d = helper.classifyWorktree({
- registered: true,
- locked: false,
- dirty: false,
- merged: true,
- age_seconds: 30 * DAY,
- worktree_state: "clean",
- });
- assert.equal(d.disposition, "SAFE_MERGED_CANDIDATE");
- assert.equal(d.positive_ownership_proof, false);
-});
-
-// --- Full inventory over a real temp fixture with injected read-only deps ---
-
-function makeFixture() {
- const root = fs.mkdtempSync(path.join(os.tmpdir(), "wt-inv-"));
- const worktreesRoot = path.join(root, ".claude", "worktrees");
- fs.mkdirSync(worktreesRoot, { recursive: true });
-
- const now = Date.parse("2026-07-21T00:00:00Z");
- const old = now - 30 * DAY * 1000;
- const recent = now - 1 * DAY * 1000;
-
- const mk = (name, mtimeMs) => {
- const p = path.join(worktreesRoot, name);
- fs.mkdirSync(p);
- fs.utimesSync(p, new Date(mtimeMs), new Date(mtimeMs));
- return p;
- };
-
- const dirtyP = mk("agent-dirty", old);
- const lockedP = mk("agent-locked", old);
- const mergedP = mk("agent-merged", old);
- const unmergedAgedP = mk("agent-unmerged-aged", old);
- const unmergedRecentP = mk("agent-live", recent);
- // A non-agent dir that must be ignored entirely.
- mk("release-main", old);
-
- const heads = {
- [dirtyP]: "aaa",
- [lockedP]: "bbb",
- [mergedP]: "ccc",
- [unmergedAgedP]: "ddd",
- [unmergedRecentP]: "eee",
- };
- const registered = {};
- for (const [p, head] of Object.entries(heads)) {
- registered[p] = { path: p, head, branch: `refs/heads/${path.basename(p)}`, locked: p === lockedP };
- }
- // Registered-but-missing agent worktree (git knows it, directory gone).
- const missingP = path.join(worktreesRoot, "agent-missing");
- registered[missingP] = { path: missingP, head: "fff", branch: "refs/heads/gone", locked: false };
-
- const deps = {
- worktreeList: () => registered,
- statusPorcelain: (p) => (p === dirtyP ? " M file.txt\n" : ""),
- isMerged: (head) => head === "ccc", // only agent-merged is merged
- sizeKb: (p) =>
- ({ [dirtyP]: 100, [lockedP]: 200, [mergedP]: 300, [unmergedAgedP]: 400, [unmergedRecentP]: 500 }[p] || 10),
- };
-
- return { root, worktreesRoot, now, deps, paths: { dirtyP, lockedP, mergedP, unmergedAgedP, unmergedRecentP, missingP } };
-}
-
-test("runInventory classifies a mixed fixture correctly and validates schema", () => {
- const fx = makeFixture();
- try {
- const report = helper.runInventory({
- repoDir: fx.root,
- worktreesRoot: fx.worktreesRoot,
- nowMs: fx.now,
- deps: fx.deps,
- agedOrphanSeconds: 7 * DAY,
- });
-
- // Report-only, safe-by-construction invariants.
- assert.equal(report.mode, "report_only");
- assert.equal(report.destructive_actions, 0);
- assert.equal(report.schema_version, helper.SCHEMA_VERSION);
-
- const by = Object.fromEntries(report.worktrees.map((w) => [w.name, w]));
-
- // Non-agent directory excluded.
- assert.ok(!by["release-main"], "non-agent dirs must be excluded");
-
- assert.equal(by["agent-dirty"].disposition, "PRESERVE");
- assert.equal(by["agent-dirty"].dirty, true);
- assert.equal(by["agent-locked"].disposition, "PRESERVE");
- assert.equal(by["agent-locked"].locked, true);
- assert.equal(by["agent-live"].disposition, "PRESERVE"); // unmerged + recent
- assert.equal(by["agent-unmerged-aged"].disposition, "AGED_ORPHAN_REVIEW");
- assert.equal(by["agent-merged"].disposition, "SAFE_MERGED_CANDIDATE");
-
- // Registered-but-missing directory surfaces and is preserved.
- assert.equal(by["agent-missing"].worktree_state, "missing");
- assert.equal(by["agent-missing"].disposition, "PRESERVE");
-
- // Sizes are captured so the 70GB problem is visible; totals aggregate them.
- assert.equal(by["agent-merged"].size_kb, 300);
- assert.equal(report.totals.total_size_kb, 100 + 200 + 300 + 400 + 500);
- assert.equal(report.totals.count, 6); // 5 present agent-* + 1 missing
-
- // Every entry carries no ownership proof.
- for (const w of report.worktrees) assert.equal(w.positive_ownership_proof, false);
- } finally {
- fs.rmSync(fx.root, { recursive: true, force: true });
- }
-});
-
-test("validateReport rejects a dirty worktree marked for anything but PRESERVE", () => {
- const bad = {
- schema_version: helper.SCHEMA_VERSION,
- mode: "report_only",
- destructive_actions: 0,
- totals: {},
- inspection_errors: [],
- worktrees: [
- {
- path: "/x/agent-bad",
- disposition: "SAFE_MERGED_CANDIDATE",
- worktree_state: "dirty",
- dirty: true,
- locked: false,
- positive_ownership_proof: false,
- age_seconds: 1,
- size_kb: 1,
- },
- ],
- };
- assert.throws(() => helper.validateReport(bad), /must be PRESERVE/);
-});
-
-// --- Safety by construction: the helper module has no destructive code path ---
-
-test("inventory helper source contains zero destructive operations", () => {
- const raw = fs.readFileSync(path.join(REPO_ROOT, "routines/monitoring/local_worktree_inventory.js"), "utf8");
- // Scan executable code only: strip block and line comments so prose that
- // merely names a destructive command (e.g. explaining what is NOT done) does
- // not trip the guard. The guarantee is that no destructive call is reachable.
- const src = raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
- const forbidden = [
- /\brm\b[^"']*-r?f/i,
- /\bfind\b[^"']*-delete/i,
- /worktree\s+remove/i,
- /worktree\s+prune/i,
- /branch\s+-D/i,
- /update-ref\s+-d/i,
- /\.rmSync\b/,
- /\.rmdirSync\b/,
- /\.unlinkSync\b/,
- ];
- for (const pat of forbidden) {
- assert.doesNotMatch(src, pat, `helper must not contain ${pat}`);
- }
-});
-
-// --- The QuickJS routine is a schedule/dispatch shim only ---
-
-test("routine dispatches the deterministic helper and self-guards per day", () => {
- const { routine, tick } = loadRoutine("routines/local-worktree-gc.js");
- assert.equal(routine.name, "Local agent worktree inventory");
-
- const first = tick({ now: new Date("2026-07-21T00:00:00Z"), checkpoint: null });
- assert.equal(first.action, "agent");
- assert.match(first.prompt, /local_worktree_inventory\.js/);
- assert.match(first.prompt, /report-only inventory/i);
- assert.match(first.prompt, /Do NOT remove, prune, or modify/);
-
- // Same KST day => no duplicate dispatch.
- const second = tick({ now: new Date("2026-07-21T05:00:00Z"), checkpoint: first.checkpoint });
- assert.equal(second.action, "complete");
-});
diff --git a/policies/__tests__/pipeline.test.js b/policies/__tests__/pipeline.test.js
deleted file mode 100644
index c564e70e44..0000000000
--- a/policies/__tests__/pipeline.test.js
+++ /dev/null
@@ -1,307 +0,0 @@
-// End-to-end pipeline simulation: Recommender → Detector → Executor
-// Verifies the full automation candidate lifecycle across all three routines.
-
-const test = require("node:test");
-const assert = require("node:assert/strict");
-const { loadRoutine } = require("./support/routine-harness");
-
-const RECOMMENDER_PATH = "routines/monitoring/automation-candidate-recommender.js";
-const DETECTOR_PATH = "routines/monitoring/automation-candidate-detector.js";
-const EXECUTOR_PATH = "routines/monitoring/automation-executor.js";
-
-const BASE_NOW = new Date("2026-05-02T10:00:00Z");
-const SIGNATURE = "routine-candidate:my-repeated-script.js";
-
-// --- Observation factories ---
-
-function makeRunObs(signature, evidenceRef, opts = {}) {
- return {
- signature,
- evidence_ref: evidenceRef,
- category: opts.category || "routine-candidate",
- summary: `Repeated failure: ${signature}`,
- occurrences: opts.occurrences || 1,
- weight: opts.weight || 1,
- timestamp: opts.timestamp || BASE_NOW.toISOString(),
- };
-}
-
-// Simulates precomputed_observation_from_kv output for candidate_review marker
-function makeCandidateReviewKvObs(signature, candidate, overrides = {}) {
- return {
- key: `routine_observation:candidate_review:${signature}`,
- evidence_ref: `kv_meta:routine_observation:candidate_review:${signature}`,
- value: {
- signature,
- score: candidate.score || 85,
- evidence_count: candidate.evidence_count || 8,
- category: candidate.category || "routine-candidate",
- suggested_automation: candidate.suggested_automation || "자동화 제안",
- outcome_summary: candidate.outcome_summary || "결과 요약",
- last_seen_at: overrides.last_seen_at || BASE_NOW.toISOString(),
- },
- source: "candidate_review",
- category: "routine-candidate",
- signature: `routine-candidate:${signature}`,
- summary: `candidate_review: ${signature}`,
- timestamp: BASE_NOW.toISOString(),
- occurrences: 1,
- weight: 1,
- };
-}
-
-// Simulates precomputed_observation_from_kv output for candidate_approved marker
-function makeCandidateApprovedKvObs(signature, opts = {}) {
- const approvedAt = opts.approved_at || BASE_NOW.toISOString();
- return {
- key: `routine_observation:candidate_approved:${signature}`,
- evidence_ref: `kv_meta:routine_observation:candidate_approved:${signature}`,
- value: {
- signature,
- score: opts.score || 85,
- category: opts.category || "routine-candidate",
- approved_at: approvedAt,
- suggested_automation: opts.suggested_automation || "자동화 제안",
- outcome_summary: opts.outcome_summary || "결과 요약",
- },
- source: "candidate_approved",
- category: "routine-candidate",
- signature: `routine-candidate:${signature}`,
- summary: `candidate_approved: ${signature}`,
- timestamp: approvedAt,
- occurrences: 1,
- weight: 1,
- };
-}
-
-// Simulates precomputed_observation_from_kv output for candidate_dispatched marker
-function makeCandidateDispatchedKvObs(signature, opts = {}) {
- const dispatchedAt = opts.dispatched_at || BASE_NOW.toISOString();
- return {
- key: `routine_observation:candidate_dispatched:${signature}`,
- evidence_ref: `kv_meta:routine_observation:candidate_dispatched:${signature}`,
- value: {
- signature,
- dispatched_at: dispatchedAt,
- category: opts.category || "routine-candidate",
- },
- source: "candidate_dispatched",
- category: "routine-candidate",
- signature: `routine-candidate:${signature}`,
- summary: `candidate_dispatched: ${signature}`,
- timestamp: dispatchedAt,
- occurrences: 1,
- weight: 1,
- };
-}
-
-// Build 8 distinct run observations for the same pattern to exceed SCORE_THRESHOLD=80
-function makeRunObsSet(signature, count = 8) {
- return Array.from({ length: count }, (_, i) =>
- makeRunObs(signature, `routine_runs:${signature}:run:failed:sample-${i}`)
- );
-}
-
-// --- Phase 1: Recommender reaches escalation threshold ---
-
-test("pipeline phase 1: recommender accumulates evidence and escalates candidate", () => {
- const { tick } = loadRoutine(RECOMMENDER_PATH);
-
- const obs = makeRunObsSet(SIGNATURE);
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] });
-
- assert.equal(r.action, "agent", "recommender should escalate when score >= 80 and evidence >= 5");
- assert.ok(r.prompt.includes(SIGNATURE), "prompt should reference the candidate signature");
- assert.ok(
- r.prompt.includes(""),
- "materialize draft should use the detector-recognized repo_dir placeholder"
- );
- assert.ok(
- !r.prompt.includes(""),
- "materialize draft must not include stale repo_dir placeholder"
- );
- assert.ok(r.checkpoint.candidates[SIGNATURE], "candidate should be in checkpoint");
- assert.ok(
- r.checkpoint.candidates[SIGNATURE].score >= 80,
- `candidate score should be >= 80, got ${r.checkpoint.candidates[SIGNATURE].score}`
- );
-});
-
-test("pipeline phase 1b: ROI-aware high-impact category escalates with three evidence points", () => {
- const { tick } = loadRoutine(RECOMMENDER_PATH);
- const signature = "session-pattern:maker";
- const obs = [
- makeRunObs(signature, "session_transcripts:maker", {
- category: "session-pattern",
- occurrences: 3,
- weight: 2,
- }),
- ];
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] });
-
- assert.equal(r.action, "agent", "session-pattern should use ROI-aware gate below the global evidence=5 threshold");
- const candidate = r.checkpoint.candidates[signature];
- assert.equal(candidate.evidence_count, 3);
- assert.equal(candidate.category, "session-pattern");
- assert.ok(candidate.score >= 60, `candidate score should satisfy ROI gate, got ${candidate.score}`);
- assert.ok(r.prompt.includes("gate=60/3"), "prompt should explain the category-specific gate");
-});
-
-// --- Phase 2: Detector passes quality gate for a candidate_review observation ---
-
-test("pipeline phase 2: detector quality-gates candidate_review and emits agent", () => {
- const { tick } = loadRoutine(DETECTOR_PATH);
-
- const candidate = { score: 87, evidence_count: 8, category: "routine-candidate" };
- const reviewObs = [makeCandidateReviewKvObs(SIGNATURE, candidate)];
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: reviewObs, automationInventory: [] });
-
- assert.equal(r.action, "agent", "detector should emit agent action for valid candidate_review");
- assert.ok(r.prompt.includes(SIGNATURE), "approval prompt should include the signature");
- assert.ok(
- r.prompt.includes(`routine_observation:candidate_approved:${SIGNATURE}`),
- "prompt should instruct agent to write candidate_approved kv_meta"
- );
- assert.equal(r.checkpoint.seen_candidates[SIGNATURE].status, "emitted");
- assert.ok(
- r.checkpoint.seen_candidates[SIGNATURE].status !== "approved" &&
- r.checkpoint.seen_candidates[SIGNATURE].status !== "dispatched",
- "candidate should not be prematurely approved/dispatched in checkpoint"
- );
-});
-
-// --- Phase 3: Executor dispatches approved candidate ---
-
-test("pipeline phase 3: executor dispatches approved candidate and does not pre-mark dispatched", () => {
- const { tick } = loadRoutine(EXECUTOR_PATH);
-
- const approvedObs = [makeCandidateApprovedKvObs(SIGNATURE)];
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: approvedObs, automationInventory: [] });
-
- assert.equal(r.action, "agent", "executor should emit agent dispatch action for approved candidate");
- assert.ok(r.prompt.includes(SIGNATURE), "dispatch prompt should include the signature");
- assert.ok(
- r.prompt.includes(`routine_observation:candidate_dispatched:${SIGNATURE}`),
- "prompt should instruct agent to write candidate_dispatched kv_meta"
- );
- assert.ok(
- !r.checkpoint.dispatched_signatures[SIGNATURE],
- "dispatched_signatures must NOT be pre-set before durable kv_meta is observed"
- );
-});
-
-// --- Phase 4: Executor skips re-dispatch after durable dispatched marker appears ---
-
-test("pipeline phase 4: executor skips candidate once durable dispatched kv_meta is observed", () => {
- const { tick } = loadRoutine(EXECUTOR_PATH);
-
- const dispatchedAt = new Date(BASE_NOW.getTime() - 2 * 3600_000).toISOString();
- const obs = [
- makeCandidateApprovedKvObs(SIGNATURE),
- makeCandidateDispatchedKvObs(SIGNATURE, { dispatched_at: dispatchedAt }),
- ];
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] });
-
- assert.equal(r.action, "complete", "executor should complete when durable dispatched marker exists");
- assert.equal(
- r.checkpoint.dispatched_signatures[SIGNATURE],
- dispatchedAt,
- "checkpoint should preserve the actual dispatch time, not now"
- );
-});
-
-// --- Phase 5: Recommender suppresses candidate after dispatched marker appears ---
-
-test("pipeline phase 5: recommender suppresses re-recommendation after dispatched kv_meta observed", () => {
- const { tick } = loadRoutine(RECOMMENDER_PATH);
-
- // Regular run observations plus a dispatched marker
- const runObs = makeRunObsSet(SIGNATURE);
- const dispatchedObs = makeCandidateDispatchedKvObs(SIGNATURE);
- const allObs = [...runObs, dispatchedObs];
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: allObs, automationInventory: [] });
-
- // Should not escalate because SIGNATURE is now suppressed via dispatched marker
- assert.equal(r.action, "complete", "recommender should not escalate a dispatched candidate");
- assert.ok(
- !r.checkpoint.candidates[SIGNATURE] ||
- r.checkpoint.candidates[SIGNATURE].state !== "recommended",
- "dispatched candidate should be dropped or not in recommended state"
- );
- assert.ok(
- (r.result.suppression_summary || "").includes("dispatched") ||
- !r.checkpoint.candidates[SIGNATURE],
- "suppression summary should mention dispatched suppression or candidate removed"
- );
-});
-
-// --- Phase 6: Full sequential pipeline simulation ---
-
-test("pipeline phase 6: full sequential recommender→detector→executor flow", () => {
- const recommender = loadRoutine(RECOMMENDER_PATH);
- const detector = loadRoutine(DETECTOR_PATH);
- const executor = loadRoutine(EXECUTOR_PATH);
-
- const t0 = BASE_NOW;
-
- // Step 1: Recommender sees enough evidence and escalates
- const runObs = makeRunObsSet(SIGNATURE);
- const r1 = recommender.tick({ now: t0, checkpoint: null, observations: runObs, automationInventory: [] });
- assert.equal(r1.action, "agent", "step 1: recommender escalates");
- const recommenderCp = r1.checkpoint;
-
- // Step 2: (Agent writes candidate_review kv_meta) → Detector sees it
- const t1 = new Date(t0.getTime() + 5 * 60_000);
- const candidate = recommenderCp.candidates[SIGNATURE] || {};
- const reviewObs = [makeCandidateReviewKvObs(SIGNATURE, { score: candidate.score || 85, evidence_count: candidate.evidence_count || 8 })];
- const r2 = detector.tick({ now: t1, checkpoint: null, observations: reviewObs, automationInventory: [] });
- assert.equal(r2.action, "agent", "step 2: detector emits approval request");
- const detectorCp = r2.checkpoint;
-
- // Step 3: (Agent writes candidate_approved kv_meta) → Executor sees it
- const t2 = new Date(t1.getTime() + 5 * 60_000);
- const approvedObs = [makeCandidateApprovedKvObs(SIGNATURE, { score: candidate.score || 85 })];
- const r3 = executor.tick({ now: t2, checkpoint: null, observations: approvedObs, automationInventory: [] });
- assert.equal(r3.action, "agent", "step 3: executor dispatches");
- assert.ok(!r3.checkpoint.dispatched_signatures[SIGNATURE], "dispatched not pre-set");
-
- // Step 4: (Agent writes candidate_dispatched kv_meta) → Executor now skips on next tick
- const t3 = new Date(t2.getTime() + 5 * 60_000);
- const dispatchedAt = t2.toISOString();
- const dispatchedObs = [makeCandidateDispatchedKvObs(SIGNATURE, { dispatched_at: dispatchedAt })];
- const obsWithDispatched = [...approvedObs, ...dispatchedObs];
- const r4 = executor.tick({ now: t3, checkpoint: r3.checkpoint, observations: obsWithDispatched, automationInventory: [] });
- assert.equal(r4.action, "complete", "step 4: executor skips already dispatched candidate");
- assert.equal(r4.checkpoint.dispatched_signatures[SIGNATURE], dispatchedAt);
-
- // Step 5: Recommender on next cycle sees dispatched marker → suppresses candidate
- const t4 = new Date(t3.getTime() + 5 * 60_000);
- const allObs = [...runObs, ...dispatchedObs];
- const r5 = recommender.tick({ now: t4, checkpoint: recommenderCp, observations: allObs, automationInventory: [] });
- assert.equal(r5.action, "complete", "step 5: recommender suppresses dispatched candidate");
-
- // Step 6: Detector on next cycle — if candidate_review marker expires or is missing, no action
- const t5 = new Date(t4.getTime() + 5 * 60_000);
- const r6 = detector.tick({ now: t5, checkpoint: detectorCp, observations: dispatchedObs, automationInventory: [] });
- assert.equal(r6.action, "complete", "step 6: detector has no pending reviews");
-});
-
-// --- Normalized observation variants (simulating Rust provider output with both key+evidence_ref) ---
-
-test("pipeline: normalized kv_meta obs (key+evidence_ref) works across all three routines", () => {
- const detector = loadRoutine(DETECTOR_PATH);
- const executor = loadRoutine(EXECUTOR_PATH);
-
- const reviewObs = [makeCandidateReviewKvObs(SIGNATURE, { score: 90, evidence_count: 10 })];
- const r1 = detector.tick({ now: BASE_NOW, checkpoint: null, observations: reviewObs, automationInventory: [] });
- assert.equal(r1.action, "agent", "detector handles kv obs with both key and evidence_ref");
-
- const approvedObs = [makeCandidateApprovedKvObs(SIGNATURE)];
- const r2 = executor.tick({ now: BASE_NOW, checkpoint: null, observations: approvedObs, automationInventory: [] });
- assert.equal(r2.action, "agent", "executor handles kv obs with both key and evidence_ref");
-});
diff --git a/policies/__tests__/recommender-dedup.test.js b/policies/__tests__/recommender-dedup.test.js
deleted file mode 100644
index a6d4d8c280..0000000000
--- a/policies/__tests__/recommender-dedup.test.js
+++ /dev/null
@@ -1,198 +0,0 @@
-const test = require("node:test");
-const assert = require("node:assert/strict");
-const { loadRoutine } = require("./support/routine-harness");
-
-const ROUTINE_PATH = "routines/monitoring/automation-candidate-recommender.js";
-
-function makeObs(signature, evidenceRef, opts = {}) {
- return {
- signature,
- evidence_ref: evidenceRef || `routine_runs:${signature}.js:run:failed`,
- category: "failure-pattern",
- summary: `Test observation for ${signature}`,
- occurrences: opts.occurrences || 1,
- weight: opts.weight || 1,
- };
-}
-
-function parseScoringLine(result) {
- const summary = result.result && result.result.scoring_summary;
- if (!summary) return { scored: 0, deduped: 0 };
- const m = summary.match(/scored=(\d+).*deduped=(\d+)/);
- return m ? { scored: parseInt(m[1], 10), deduped: parseInt(m[2], 10) } : { scored: 0, deduped: 0 };
-}
-
-function buildCheckpointWithEvidence(entries, nowBase) {
- const seen_evidence = {};
- for (let i = 0; i < entries; i++) {
- seen_evidence[`key-${i}`] = new Date(nowBase + i * 1000).toISOString();
- }
- return {
- version: 1,
- cursors: {},
- candidates: {},
- suppressions: {},
- seen_evidence,
- recommendations: [],
- last_tick_at: null,
- stats: {
- ticks: 0,
- observations_seen: 0,
- agent_escalations: 0,
- recommendations_today: 0,
- recommendation_day: null,
- },
- };
-}
-
-test("same evidence_ref is deduped on consecutive ticks", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const obs = [makeObs("repeated-pattern", "routine_runs:heavy.js:run:failed")];
-
- const result1 = tick({ now: new Date("2026-05-02T10:00:00Z"), checkpoint: null, observations: obs, automationInventory: [] });
- const s1 = parseScoringLine(result1);
- assert.equal(s1.scored, 1, "first tick should score 1");
- assert.equal(s1.deduped, 0, "first tick should have 0 deduped");
-
- const result2 = tick({ now: new Date("2026-05-02T10:01:00Z"), checkpoint: result1.checkpoint, observations: obs, automationInventory: [] });
- const s2 = parseScoringLine(result2);
- assert.equal(s2.deduped, 1, "second tick with same evidence_ref should dedup 1");
- assert.equal(s2.scored, 0, "second tick should score 0 new");
-});
-
-test("dedup hit refreshes seen_evidence timestamp for LRU", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const evidenceRef = "routine_runs:lru.js:run:failed";
- const obs = [makeObs("lru-pattern", evidenceRef)];
-
- const result1 = tick({ now: new Date("2026-05-02T10:00:00Z"), checkpoint: null, observations: obs, automationInventory: [] });
- const result2 = tick({ now: new Date("2026-05-02T10:10:00Z"), checkpoint: result1.checkpoint, observations: obs, automationInventory: [] });
-
- assert.equal(parseScoringLine(result2).deduped, 1, "second tick should dedup");
- assert.equal(result2.checkpoint.seen_evidence[evidenceRef].seen_at, "2026-05-02T10:10:00.000Z");
-});
-
-test("rolling grouped evidence scores only newly increased occurrences", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const evidenceRef = "routine_runs:rolling.js:run:failed";
-
- const result1 = tick({
- now: new Date("2026-05-02T10:00:00Z"),
- checkpoint: null,
- observations: [makeObs("rolling-pattern", evidenceRef, { occurrences: 2 })],
- automationInventory: [],
- });
- assert.equal(parseScoringLine(result1).scored, 1, "first aggregate should score");
-
- const result2 = tick({
- now: new Date("2026-05-02T10:05:00Z"),
- checkpoint: result1.checkpoint,
- observations: [makeObs("rolling-pattern", evidenceRef, { occurrences: 5 })],
- automationInventory: [],
- });
- const s2 = parseScoringLine(result2);
- assert.equal(s2.scored, 1, "aggregate with new occurrences should score once");
- assert.equal(s2.deduped, 0, "aggregate with higher count should not be fully deduped");
- assert.equal(result2.checkpoint.candidates["rolling-pattern"].evidence_count, 5);
-});
-
-test("rolling grouped evidence tracks the latest observed count after a window drop", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const evidenceRef = "routine_runs:sliding.js:run:failed";
-
- const result1 = tick({
- now: new Date("2026-05-02T10:00:00Z"),
- checkpoint: null,
- observations: [makeObs("sliding-pattern", evidenceRef, { occurrences: 10 })],
- automationInventory: [],
- });
- assert.equal(parseScoringLine(result1).scored, 1, "first aggregate should score");
-
- const result2 = tick({
- now: new Date("2026-05-02T10:05:00Z"),
- checkpoint: result1.checkpoint,
- observations: [makeObs("sliding-pattern", evidenceRef, { occurrences: 4 })],
- automationInventory: [],
- });
- const s2 = parseScoringLine(result2);
- assert.equal(s2.scored, 0, "smaller rolling window count should not rescore");
- assert.equal(s2.deduped, 1, "smaller rolling window count should be treated as already seen");
- assert.equal(result2.checkpoint.seen_evidence[evidenceRef].occurrences, 4);
-
- const result3 = tick({
- now: new Date("2026-05-02T10:10:00Z"),
- checkpoint: result2.checkpoint,
- observations: [makeObs("sliding-pattern", evidenceRef, { occurrences: 6 })],
- automationInventory: [],
- });
- const s3 = parseScoringLine(result3);
- assert.equal(s3.scored, 1, "increase after the window drop should score the fresh delta");
- assert.equal(s3.deduped, 0, "increase after the window drop should not be pinned to the old peak");
- assert.equal(result3.checkpoint.candidates["sliding-pattern"].evidence_count, 12);
- assert.equal(result3.checkpoint.seen_evidence[evidenceRef].occurrences, 6);
-});
-
-test("evidence is re-scored after 25h TTL expires", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const obs = [makeObs("stale-pattern", "routine_runs:stale.js:run:failed")];
-
- const result1 = tick({ now: new Date("2026-05-01T10:00:00Z"), checkpoint: null, observations: obs, automationInventory: [] });
- assert.equal(parseScoringLine(result1).scored, 1, "first tick scores 1");
-
- // 26h later — TTL of 25h has fully expired
- const result2 = tick({ now: new Date("2026-05-02T12:00:00Z"), checkpoint: result1.checkpoint, observations: obs, automationInventory: [] });
- const s2 = parseScoringLine(result2);
- assert.equal(s2.deduped, 0, "after TTL expiry evidence should not be deduped");
- assert.equal(s2.scored, 1, "evidence is re-scored after TTL expiry");
-});
-
-test("seen_evidence LRU cap trims entries above 500", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
-
- // 501 entries, all timestamped within the 25h TTL window
- const nowMs = new Date("2026-05-02T10:00:00Z").getTime();
- const cp = buildCheckpointWithEvidence(501, nowMs - 10 * 3600 * 1000); // entries 10h ago
-
- const result = tick({ now: new Date("2026-05-02T10:00:00Z"), checkpoint: cp, observations: [], automationInventory: [] });
-
- const seenCount = Object.keys(result.checkpoint.seen_evidence || {}).length;
- assert.ok(seenCount <= 500, `seen_evidence should be capped at 500, got ${seenCount}`);
-});
-
-test("composite key deduplication works when evidence_ref is absent", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const obs = [{
- signature: "no-ref-pattern",
- source: "kv_meta",
- category: "failure-pattern",
- summary: "Some kv meta observation without evidence_ref",
- occurrences: 1,
- weight: 1,
- }];
-
- const result1 = tick({ now: new Date("2026-05-02T10:00:00Z"), checkpoint: null, observations: obs, automationInventory: [] });
- assert.equal(parseScoringLine(result1).scored, 1, "first tick scores 1");
-
- const result2 = tick({ now: new Date("2026-05-02T10:01:00Z"), checkpoint: result1.checkpoint, observations: obs, automationInventory: [] });
- assert.equal(parseScoringLine(result2).deduped, 1, "composite key dedup works without evidence_ref");
-});
-
-test("multiple distinct evidence_refs are each scored independently", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const obs = [
- makeObs("pattern-a", "routine_runs:script-a.js:run:failed"),
- makeObs("pattern-b", "routine_runs:script-b.js:run:failed"),
- makeObs("pattern-c", "routine_runs:script-c.js:run:succeeded"),
- ];
-
- const result1 = tick({ now: new Date("2026-05-02T10:00:00Z"), checkpoint: null, observations: obs, automationInventory: [] });
- const s1 = parseScoringLine(result1);
- assert.equal(s1.scored, 3, "all 3 distinct observations scored on first tick");
- assert.equal(s1.deduped, 0);
-
- // Same observations next tick — all 3 deduped
- const result2 = tick({ now: new Date("2026-05-02T10:01:00Z"), checkpoint: result1.checkpoint, observations: obs, automationInventory: [] });
- const s2 = parseScoringLine(result2);
- assert.equal(s2.deduped, 3, "all 3 deduped on second tick");
- assert.equal(s2.scored, 0);
-});
diff --git a/policies/__tests__/recommender-saturation.test.js b/policies/__tests__/recommender-saturation.test.js
deleted file mode 100644
index c226b2de18..0000000000
--- a/policies/__tests__/recommender-saturation.test.js
+++ /dev/null
@@ -1,258 +0,0 @@
-const test = require("node:test");
-const assert = require("node:assert/strict");
-const { loadRoutine } = require("./support/routine-harness");
-
-const ROUTINE_PATH = "routines/monitoring/automation-candidate-recommender.js";
-
-const BASE_NOW = new Date("2026-05-02T10:00:00Z");
-
-function makeObs(signature, evidenceRef, opts = {}) {
- return {
- signature,
- evidence_ref: evidenceRef || `ref:${signature}`,
- category: opts.category || "routine-candidate",
- summary: `obs for ${signature}`,
- occurrences: opts.occurrences || 1,
- weight: opts.weight || 1,
- timestamp: opts.timestamp || BASE_NOW.toISOString(),
- };
-}
-
-function nextMs(base, deltaMs) {
- return new Date(base.getTime() + deltaMs);
-}
-
-function parseSummary(result) {
- const s = result.result && result.result.scoring_summary;
- if (!s) return {};
- const out = {};
- for (const part of s.split(",")) {
- const [k, v] = part.trim().split("=");
- out[k] = isNaN(Number(v)) ? v : Number(v);
- }
- return out;
-}
-
-// Returns a checkpoint pre-filled so that all observations are already in seen_evidence
-function buildFullyDedupedCheckpoint(obs, nowStr) {
- const seen_evidence = {};
- for (const o of obs) {
- const key = o.evidence_ref || `${o.source || ""}|${o.category || ""}|${o.signature || ""}`;
- seen_evidence[key] = nowStr;
- }
- return {
- version: 1,
- cursors: {},
- candidates: {},
- suppressions: {},
- seen_evidence,
- recommendations: [],
- last_tick_at: null,
- ema_scored: 0,
- saturation_ticks: 0,
- fast_fail_ticks: 0,
- reopt_count: 0,
- diversity_mode_ticks_remaining: 0,
- last_reopt_at: null,
- stats: {
- ticks: 0,
- observations_seen: 0,
- agent_escalations: 0,
- recommendations_today: 0,
- recommendation_day: null,
- category_scored: {},
- },
- };
-}
-
-test("fast-fail: 2 consecutive all-dedup ticks trigger reopt", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const obs = [makeObs("dup-sig", "ref:dup-sig")];
-
- // Tick 1: score once (establishes seen_evidence)
- const t1Now = BASE_NOW;
- const r1 = tick({ now: t1Now, checkpoint: null, observations: obs, automationInventory: [] });
- assert.equal(parseSummary(r1).scored, 1, "tick1 should score 1");
-
- // Tick 2: all dedup (fast_fail_ticks becomes 1)
- const t2Now = nextMs(t1Now, 60_000);
- const r2 = tick({ now: t2Now, checkpoint: r1.checkpoint, observations: obs, automationInventory: [] });
- assert.equal(parseSummary(r2).deduped, 1, "tick2 all deduped");
- assert.equal(parseSummary(r2).fast_fail_ticks, 1, "fast_fail_ticks=1 after tick2");
- assert.equal(parseSummary(r2).reopt_count, 0, "no reopt yet");
-
- // Tick 3: all dedup (fast_fail_ticks becomes 2 → reopt triggered)
- const t3Now = nextMs(t2Now, 60_000);
- const r3 = tick({ now: t3Now, checkpoint: r2.checkpoint, observations: obs, automationInventory: [] });
- const s3 = parseSummary(r3);
- assert.equal(s3.reopt_count, 1, "reopt_count=1 after fast-fail trigger");
- assert.ok(s3.reopt_triggered === "fast_fail" || r3.result.scoring_summary.includes("reopt_triggered=fast_fail"),
- "reopt reason should be fast_fail");
-});
-
-test("EMA tier: 5 consecutive low-ema ticks trigger reopt", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- // Use unique evidence_refs per tick to avoid dedup, but scored=1 each time.
- // ema_scored = 0.9^5 * 0 + 0.1 each tick, quickly < 0.3 so saturation_ticks should accumulate.
- // After 5 ticks of scored=1 (ema stays low since 0.1 base), saturation may or may not trigger.
- // Instead: use zero obs ticks to guarantee scored=0, ema_scored stays at 0.
-
- const t1Now = BASE_NOW;
- let cp = null;
- for (let i = 1; i <= 5; i++) {
- const now = nextMs(t1Now, i * 60_000);
- // No observations → scored=0, ema_scored stays near 0
- const r = tick({ now, checkpoint: cp, observations: [], automationInventory: [] });
- cp = r.checkpoint;
- const s = parseSummary(r);
- if (i < 5) {
- assert.equal(s.reopt_count, 0, `no reopt before tick 5, got at tick ${i}`);
- } else {
- assert.equal(s.reopt_count, 1, "reopt_count=1 after 5 EMA-saturation ticks");
- assert.ok(r.result.scoring_summary.includes("reopt_triggered=ema_saturation"),
- "reopt reason should be ema_saturation");
- }
- }
-});
-
-test("EMA calculation: scored=1 produces ema≈0.1 from zero", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const obs = [makeObs("ema-sig", "ref:ema-sig-unique1")];
-
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] });
- const s = parseSummary(r);
- assert.equal(s.scored, 1, "should score 1");
- // ema_scored = 0.9 * 0 + 0.1 * 1 = 0.1
- assert.ok(Math.abs(s.ema_scored - 0.1) < 0.001, `ema_scored should be ~0.1, got ${s.ema_scored}`);
-});
-
-test("partial seen_evidence reset removes old entries that were not just refreshed", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
-
- const recentNow = BASE_NOW;
- // Both entries are within 25h TTL; the matching one is refreshed by the dedup hit.
- const staleTs = nextMs(recentNow, -13 * 3600_000).toISOString(); // 13h ago
- const recentTs = nextMs(recentNow, -1 * 3600_000).toISOString(); // 1h ago
-
- const baseCheckpoint = {
- version: 1,
- cursors: {},
- candidates: {},
- suppressions: {},
- seen_evidence: {
- "old-unmatched-key": staleTs,
- "recent-key": recentTs,
- },
- recommendations: [],
- last_tick_at: null,
- ema_scored: 0,
- saturation_ticks: 0,
- fast_fail_ticks: 1, // one more all-dedup tick will reach FAST_FAIL_TICKS=2
- reopt_count: 0,
- diversity_mode_ticks_remaining: 0,
- last_reopt_at: null,
- stats: { ticks: 0, observations_seen: 0, agent_escalations: 0, recommendations_today: 0, recommendation_day: null, category_scored: {} },
- };
-
- // Provide one obs whose evidence_ref matches the recent key → all obs are deduped,
- // while old-unmatched-key stays old enough for partial reset to remove.
- const obs = [
- { signature: "sig-recent", evidence_ref: "recent-key", category: "routine-candidate", summary: "r", occurrences: 1, weight: 1 },
- ];
-
- const r = tick({ now: recentNow, checkpoint: baseCheckpoint, observations: obs, automationInventory: [] });
- const cpAfter = r.checkpoint;
- assert.equal(parseSummary(r).reopt_count, 1, "reopt should have triggered (fast_fail_ticks reached 2)");
- assert.ok(!cpAfter.seen_evidence["old-unmatched-key"], "old unmatched key (13h old) should be removed after partial reset");
- assert.ok(cpAfter.seen_evidence["recent-key"], "recent key refreshed by dedup should be kept");
-});
-
-test("diversity mode activates after reopt and decrements each tick", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
-
- // Trigger reopt by feeding 5 empty ticks (EMA tier)
- let cp = null;
- let reoptTick = null;
- for (let i = 1; i <= 5; i++) {
- const now = nextMs(BASE_NOW, i * 60_000);
- const r = tick({ now, checkpoint: cp, observations: [], automationInventory: [] });
- cp = r.checkpoint;
- if (i === 5) reoptTick = r;
- }
- assert.equal(parseSummary(reoptTick).reopt_count, 1, "reopt should have triggered");
- // After reopt diversity_mode_ticks_remaining should be 10
- assert.equal(cp.diversity_mode_ticks_remaining, 10, "diversity mode should be 10 after reopt");
-
- // One more tick should decrement it
- const r2 = tick({ now: nextMs(BASE_NOW, 6 * 60_000), checkpoint: cp, observations: [], automationInventory: [] });
- assert.equal(r2.checkpoint.diversity_mode_ticks_remaining, 9, "diversity_mode_ticks_remaining should decrement");
-});
-
-test("saturation counters reset after reopt", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
-
- // 5 empty ticks → EMA saturation → reopt
- let cp = null;
- for (let i = 1; i <= 5; i++) {
- const r = tick({ now: nextMs(BASE_NOW, i * 60_000), checkpoint: cp, observations: [], automationInventory: [] });
- cp = r.checkpoint;
- }
- assert.equal(cp.saturation_ticks, 0, "saturation_ticks reset after reopt");
- assert.equal(cp.fast_fail_ticks, 0, "fast_fail_ticks reset after reopt");
-});
-
-test("scoring_summary includes ema_scored, saturation_ticks, reopt_count fields", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
- const r = tick({ now: BASE_NOW, checkpoint: null, observations: [], automationInventory: [] });
- const s = r.result.scoring_summary;
- assert.ok(s.includes("ema_scored="), "scoring_summary should include ema_scored");
- assert.ok(s.includes("saturation_ticks="), "scoring_summary should include saturation_ticks");
- assert.ok(s.includes("reopt_count="), "scoring_summary should include reopt_count");
-});
-
-test("candidate_dispatched obs suppresses re-recommendation (REQ-P1-004)", () => {
- const { tick } = loadRoutine(ROUTINE_PATH);
-
- // First tick: score a candidate until it is eligible for escalation
- // Build a checkpoint with a high-score candidate
- const sig = "dispatched-sig";
- const baseCheckpoint = {
- version: 1,
- cursors: {},
- candidates: {
- [sig]: {
- category: "routine-candidate",
- state: "observing",
- score: 95,
- evidence_count: 10,
- first_seen_at: BASE_NOW.toISOString(),
- last_seen_at: BASE_NOW.toISOString(),
- examples: [],
- last_recommended_at: null,
- last_recommendation_hash: null,
- cooldown_until: null,
- automation_ref: null,
- has_error_evidence: false,
- },
- },
- suppressions: {},
- seen_evidence: {},
- recommendations: [],
- last_tick_at: null,
- ema_scored: 0, saturation_ticks: 0, fast_fail_ticks: 0, reopt_count: 0,
- diversity_mode_ticks_remaining: 0, last_reopt_at: null,
- stats: { ticks: 0, observations_seen: 0, agent_escalations: 0, recommendations_today: 0, recommendation_day: null, category_scored: {}, category_scored_history: [] },
- };
-
- // Without dispatched obs: should escalate
- const r1 = tick({ now: BASE_NOW, checkpoint: baseCheckpoint, observations: [], automationInventory: [] });
- assert.equal(r1.action, "agent", "candidate above threshold should escalate");
-
- // With candidate_dispatched obs: same candidate should be suppressed
- const dispatchedObs = [{
- evidence_ref: `kv_meta:routine_observation:candidate_dispatched:${sig}`,
- summary: "dispatched",
- }];
- const r2 = tick({ now: new Date(BASE_NOW.getTime() + 60_000), checkpoint: baseCheckpoint, observations: dispatchedObs, automationInventory: [] });
- assert.equal(r2.action, "complete", "dispatched obs should suppress candidate and prevent escalation");
-});
diff --git a/routines/local-worktree-gc.js b/routines/local-worktree-gc.js
deleted file mode 100644
index 7eec18c3bc..0000000000
--- a/routines/local-worktree-gc.js
+++ /dev/null
@@ -1,71 +0,0 @@
-// Local agent worktree inventory routine (#4684)
-//
-// QuickJS routines intentionally have no filesystem/network bridge, so this
-// routine cannot enumerate `.claude/worktrees` itself. Matching the reviewed
-// daily-log-digest frame, it dispatches one fresh agent turn per KST day whose
-// only job is to run the deterministic, READ-ONLY sibling helper
-// (`routines/monitoring/local_worktree_inventory.js`) and return its JSON stdout
-// verbatim. The helper performs zero destructive actions by construction; this
-// routine likewise issues no cleanup — it only schedules the inventory.
-
-const CHECKPOINT_VERSION = 1;
-
-function dayKey(now) {
- const value = typeof now === "string" ? now : now.toISOString();
- const kst = new Date(new Date(value).getTime() + 9 * 60 * 60 * 1000);
- return kst.toISOString().slice(0, 10);
-}
-
-function loadCheckpoint(raw) {
- if (!raw || raw.version !== CHECKPOINT_VERSION) {
- return { version: CHECKPOINT_VERSION, last_dispatched_day: null };
- }
- return { version: CHECKPOINT_VERSION, last_dispatched_day: raw.last_dispatched_day || null };
-}
-
-function buildPrompt(day) {
- return [
- "# Local agent worktree inventory",
- "",
- `Inventory day: ${day}`,
- "",
- "Run the repository-bundled deterministic, read-only helper:",
- "```bash",
- 'ROOT="${AGENTDESK_ROOT_DIR:-${ADK_REL:-$HOME/.adk/release}}"',
- 'REPO="${AGENTDESK_REPO_DIR:-$ROOT/workspaces/agentdesk}"',
- 'AGENTDESK_REPO_DIR="$REPO" node "$REPO/routines/monitoring/local_worktree_inventory.js"',
- "```",
- "",
- "Return the helper stdout (a single JSON report) verbatim as your final response, with no preface.",
- "This is a report-only inventory. Do NOT remove, prune, or modify any worktree, ref, branch, or file.",
- "The helper never deletes anything; you must not either. Uncommitted, locked, and unmerged worktrees",
- "are reported with disposition PRESERVE and must be left untouched.",
- ].join("\n");
-}
-
-agentdesk.routines.register({
- name: "Local agent worktree inventory",
-
- tick(ctx) {
- const day = dayKey(ctx.now);
- const checkpoint = loadCheckpoint(ctx.checkpoint);
- if (checkpoint.last_dispatched_day === day) {
- return {
- action: "complete",
- result: {
- status: "already_dispatched",
- summary: `local worktree inventory already dispatched for ${day}`,
- },
- checkpoint,
- };
- }
-
- checkpoint.last_dispatched_day = day;
- return {
- action: "agent",
- prompt: buildPrompt(day),
- lastResult: `local worktree inventory dispatched for ${day}`,
- checkpoint,
- };
- },
-});
diff --git a/routines/monitoring/automation-candidate-detector.js b/routines/monitoring/automation-candidate-detector.js
deleted file mode 100644
index 43d14c1712..0000000000
--- a/routines/monitoring/automation-candidate-detector.js
+++ /dev/null
@@ -1,309 +0,0 @@
-// Automation Candidate Detector
-// Reads candidate_review:* observations (written by recommender escalation handler)
-// and applies a quality gate. Passing candidates are forwarded to the agent for
-// approval kv_meta write. Already approved/dispatched candidates are skipped.
-
-const EVIDENCE_AGE_MAX_MS = 48 * 3600 * 1000; // 48h — matches candidate_review TTL
-const MIN_SCORE_THRESHOLD = 80;
-const CHECKPOINT_VERSION = 1;
-const SEEN_CANDIDATE_TTL_MS = 72 * 3600 * 1000; // matches candidate_approved TTL
-const EMITTED_RETRY_MS = 60 * 60 * 1000; // retry if no durable approval marker appears
-const MAX_EMIT_RETRIES = 5; // give up and mark stalled after this many no-shows
-
-// --- Checkpoint helpers ---
-
-function emptyCheckpoint() {
- return {
- version: CHECKPOINT_VERSION,
- seen_candidates: {}, // signature -> { first_seen_at, last_emitted_at, status }
- stats: {
- ticks: 0,
- materialized: 0,
- skipped_already_approved: 0,
- skipped_quality_gate: 0,
- stalled_candidates: 0,
- },
- };
-}
-
-function loadCheckpoint(raw) {
- if (!raw || typeof raw !== "object" || raw.version !== CHECKPOINT_VERSION) {
- return emptyCheckpoint();
- }
- const cp = Object.assign(emptyCheckpoint(), raw);
- cp.seen_candidates = raw.seen_candidates || {};
- cp.stats = Object.assign(emptyCheckpoint().stats, raw.stats || {});
- return cp;
-}
-
-function nowIso(now) {
- return typeof now === "string" ? now : now.toISOString ? now.toISOString() : String(now);
-}
-
-function isRecentIso(value, nowStr, maxAgeMs) {
- const timestamp = new Date(value || "").getTime();
- if (!Number.isFinite(timestamp)) return false;
- return new Date(nowStr).getTime() - timestamp < maxAgeMs;
-}
-
-function observationKey(obs) {
- if (typeof obs.key === "string") return obs.key;
- if (typeof obs.evidence_ref === "string") {
- if (obs.evidence_ref.startsWith("kv_meta:routine_observation:")) {
- return obs.evidence_ref.slice("kv_meta:".length);
- }
- if (obs.evidence_ref.startsWith("routine_observation:")) {
- return obs.evidence_ref;
- }
- }
- return "";
-}
-
-function observationSignature(obs, prefix) {
- const key = observationKey(obs);
- return key.startsWith(prefix) ? key.slice(prefix.length) : null;
-}
-
-function observationPayload(obs) {
- return obs.value && typeof obs.value === "object" ? obs.value : obs;
-}
-
-// --- Prune expired seen_candidates ---
-
-function pruneSeen(cp, nowStr) {
- const cutoff = new Date(nowStr).getTime() - SEEN_CANDIDATE_TTL_MS;
- for (const [sig, entry] of Object.entries(cp.seen_candidates)) {
- if (new Date(entry.first_seen_at).getTime() < cutoff) {
- delete cp.seen_candidates[sig];
- }
- }
-}
-
-// --- Quality gate ---
-
-function passesQualityGate(candidate, nowStr) {
- const score = typeof candidate.score === "number" ? candidate.score : 0;
- if (score < MIN_SCORE_THRESHOLD) return { pass: false, reason: `score=${score} < ${MIN_SCORE_THRESHOLD}` };
-
- if (candidate.evidence_age_ms != null) {
- if (candidate.evidence_age_ms > EVIDENCE_AGE_MAX_MS) {
- return { pass: false, reason: `evidence_age=${candidate.evidence_age_ms}ms > ${EVIDENCE_AGE_MAX_MS}ms` };
- }
- } else if (candidate.last_seen_at) {
- const seenAtMs = new Date(candidate.last_seen_at).getTime();
- if (!Number.isFinite(seenAtMs)) {
- return { pass: false, reason: `invalid_last_seen_at=${candidate.last_seen_at}` };
- }
- const ageMs = new Date(nowStr).getTime() - seenAtMs;
- if (ageMs > EVIDENCE_AGE_MAX_MS) {
- return { pass: false, reason: `evidence_age=${ageMs}ms > ${EVIDENCE_AGE_MAX_MS}ms` };
- }
- }
-
- return { pass: true, reason: null };
-}
-
-// --- Build materialization prompt ---
-// After quality gate passes, instruct the LLM to:
-// 1. Create the Kanban card via POST /api/automation-candidates
-// 2. Write candidate_approved:* kv_meta with the resulting card_id
-
-function buildMaterializationPrompt(signature, candidate) {
- const mr = candidate.materialize_request || {};
- const mrBody = mr.body || {};
- const program = mrBody.program || {};
-
- const materializeBody = {
- title: mrBody.title || `[automation-candidate] ${signature}`,
- source: mrBody.source || "routine_recommender",
- dedupe_key: mrBody.dedupe_key || signature,
- start_ready: true,
- program: {
- repo_dir: program.repo_dir || "",
- allowed_write_paths: program.allowed_write_paths || ["src"],
- metric_name: program.metric_name || "automation_friction_count",
- metric_target: program.metric_target ?? 0,
- metric_direction: program.metric_direction || "lower_is_better",
- final_gate: program.final_gate || "manual_review",
- iteration_budget: program.iteration_budget || 3,
- },
- };
-
- const lines = [
- "# 자동화 후보 카드 생성 요청",
- "",
- `**후보 ID**: \`${signature}\``,
- `**카테고리**: ${candidate.category || "routine-candidate"}`,
- `**점수**: ${candidate.score} / 100`,
- `**증거 수**: ${candidate.evidence_count || 0}`,
- `**제안된 자동화**: ${candidate.suggested_automation || "(없음)"}`,
- `**결과 요약**: ${candidate.outcome_summary || "(없음)"}`,
- "",
- "---",
- "## 실행 지침",
- "",
- "이 후보는 품질 게이트를 통과했습니다. 아래 두 단계를 순서대로 실행하세요.",
- "",
- "### 1단계: Kanban 카드 생성",
- "",
- "아래 API를 호출하여 자동화 후보 카드를 생성합니다:",
- "",
- "```",
- "POST /api/automation-candidates",
- "Content-Type: application/json",
- "",
- JSON.stringify(materializeBody, null, 2),
- "```",
- "",
- "**중요**: `program.repo_dir`가 `` 상태라면,",
- "현재 워크스페이스의 agentdesk 리포지터리 절대 경로로 대체하세요.",
- "예: `/Users/example/workspaces/agentdesk`",
- "",
- "API 응답에서 `card_id`를 저장해두세요.",
- "",
- "### 2단계: 승인 마커 기록",
- "",
- "카드 생성에 성공한 경우, 아래 kv_meta를 기록합니다 (TTL: 72h):",
- "```",
- `routine_observation:candidate_approved:${signature}`,
- "```",
- `값(JSON): {\"signature\":\"${signature}\",\"score\":${candidate.score},\"card_id\":\"<생성된 card_id>\",\"approved_at\":\"<현재시각ISO>\",\"category\":\"${candidate.category || "routine-candidate"}\"}`,
- "",
- "카드 생성에 실패하거나 후보가 자동화 가치가 없다고 판단되면 kv_meta를 기록하지 마세요.",
- "",
- "**주의**: 카드를 생성하지 않고 kv_meta만 기록하면 자동화 루프가 동작하지 않습니다.",
- ];
- return lines.join("\n");
-}
-
-// --- Main tick ---
-
-agentdesk.routines.register({
- name: "Automation Candidate Detector",
-
- tick(ctx) {
- const nowStr = nowIso(ctx.now);
- const cp = loadCheckpoint(ctx.checkpoint);
- const observations = ctx.observations || [];
-
- pruneSeen(cp, nowStr);
-
- // Find candidate_review observations
- const reviewPrefix = "routine_observation:candidate_review:";
- const approvedPrefix = "routine_observation:candidate_approved:";
- const dispatchedPrefix = "routine_observation:candidate_dispatched:";
- const reviewObs = observations.filter((obs) => observationSignature(obs, reviewPrefix));
-
- // Find already-approved and dispatched signatures from observations
- const approvedSigs = new Set(
- observations
- .map((obs) => observationSignature(obs, approvedPrefix))
- .filter(Boolean)
- );
- const dispatchedSigs = new Set(
- observations
- .map((obs) => observationSignature(obs, dispatchedPrefix))
- .filter(Boolean)
- );
-
- cp.stats.ticks++;
-
- if (reviewObs.length === 0) {
- return {
- action: "complete",
- result: {
- status: "ok",
- summary: "검토할 후보 없음",
- review_count: 0,
- },
- checkpoint: cp,
- };
- }
-
- // Process each candidate_review observation
- const emitPrompts = [];
- const queuedSignatures = new Set();
- for (const obs of reviewObs) {
- const signature = observationSignature(obs, reviewPrefix);
- const candidate = observationPayload(obs);
-
- // Skip already approved/dispatched
- if (approvedSigs.has(signature) || dispatchedSigs.has(signature)) {
- cp.stats.skipped_already_approved++;
- cp.seen_candidates[signature] = cp.seen_candidates[signature] || {
- first_seen_at: nowStr,
- status: "skipped_already_handled",
- };
- continue;
- }
-
- // Skip only recent emits. If no durable approved/dispatched marker appears,
- // retry before the candidate_review marker can age out.
- const seen = cp.seen_candidates[signature];
- if (queuedSignatures.has(signature)) {
- cp.stats.skipped_already_approved++;
- continue;
- }
- // Give up if LLM has repeatedly failed to write the approved marker.
- if (seen && (seen.emit_count || 0) >= MAX_EMIT_RETRIES) {
- if (seen.status !== "stalled") {
- cp.seen_candidates[signature] = Object.assign({}, seen, { status: "stalled" });
- }
- cp.stats.stalled_candidates++;
- continue;
- }
- if (
- seen &&
- seen.status === "emitted" &&
- isRecentIso(seen.last_emitted_at || seen.first_seen_at, nowStr, EMITTED_RETRY_MS)
- ) {
- cp.stats.skipped_already_approved++;
- continue;
- }
-
- // Quality gate
- const gate = passesQualityGate(candidate, nowStr);
- if (!gate.pass) {
- cp.stats.skipped_quality_gate++;
- cp.seen_candidates[signature] = { first_seen_at: nowStr, status: "rejected", reason: gate.reason };
- continue;
- }
-
- emitPrompts.push({ signature, candidate });
- queuedSignatures.add(signature);
- }
-
- if (emitPrompts.length === 0) {
- return {
- action: "complete",
- result: {
- status: "ok",
- summary: `검토 ${reviewObs.length}건 처리됨 (카드 생성 요청 없음)`,
- review_count: reviewObs.length,
- skipped_approved: cp.stats.skipped_already_approved,
- skipped_quality_gate: cp.stats.skipped_quality_gate,
- },
- checkpoint: cp,
- };
- }
-
- // Emit one materialization prompt (first candidate, others handled on next ticks)
- const { signature, candidate } = emitPrompts[0];
- const prompt = buildMaterializationPrompt(signature, candidate);
- const previousSeen = cp.seen_candidates[signature];
- const emitCount = (previousSeen?.emit_count || 0) + 1;
- cp.seen_candidates[signature] = {
- first_seen_at: previousSeen?.first_seen_at || nowStr,
- last_emitted_at: nowStr,
- emit_count: emitCount,
- status: "emitted",
- };
- cp.stats.materialized++;
-
- return {
- action: "agent",
- prompt,
- checkpoint: cp,
- };
- },
-});
diff --git a/routines/monitoring/automation-candidate-executor.js b/routines/monitoring/automation-candidate-executor.js
deleted file mode 100644
index 74c73962d0..0000000000
--- a/routines/monitoring/automation-candidate-executor.js
+++ /dev/null
@@ -1,320 +0,0 @@
-// Automation Candidate Executor
-//
-// Consumes kanban_ready observations (pipeline_stage_id='automation-candidate', status='ready')
-// and drives an autoresearch-style iteration loop per card:
-//
-// ready → requested → in_progress → [metric-regression re-queue OR review → done]
-//
-// Re-queue on metric regression: current card → "review", new child card → "ready"
-// (intermediate cards go to "review", NOT "done", to avoid premature kanban_dispatched suppression)
-//
-// Loop ends when MAX_ITERATIONS is reached or final gate triggers.
-// LLM submits iteration results via POST /api/automation-candidates/{card_id}/iteration-result.
-// Rust computes keep/discard verdict deterministically.
-
-const MAX_ITERATIONS = 10;
-const DISPATCH_RETRY_MS = 30 * 60 * 1000;
-const MAX_DISPATCH_RETRIES = 3;
-const DISPATCHED_WINDOW_DAYS = 7;
-const CHECKPOINT_VERSION = 2;
-const AUTOMATION_CANDIDATE_CONTRACT = Object.freeze({
- pipelineStageId: "automation-candidate",
- programKey: "program",
- requiredProgramFields: Object.freeze([
- "repo_dir",
- "allowed_write_paths",
- "metric_name",
- "metric_target",
- ]),
-});
-
-// --- Checkpoint helpers ---
-
-function emptyCheckpoint() {
- return {
- version: CHECKPOINT_VERSION,
- // card_id -> { dispatched_at, iteration, status }
- dispatched: {},
- // card_id -> { attempt_count, last_attempted_at, first_attempted_at, status?, stalled_at? }
- pending: {},
- stats: { ticks: 0, dispatched: 0, skipped: 0, max_iterations_reached: 0, stalled_candidates: 0 },
- };
-}
-
-function loadCheckpoint(raw) {
- if (!raw || raw.version !== CHECKPOINT_VERSION) return emptyCheckpoint();
- const cp = Object.assign(emptyCheckpoint(), raw);
- cp.dispatched = raw.dispatched || {};
- cp.pending = raw.pending || {};
- cp.stats = Object.assign(emptyCheckpoint().stats, raw.stats || {});
- return cp;
-}
-
-function nowIso(now) {
- return typeof now === "string" ? now : now.toISOString ? now.toISOString() : String(now);
-}
-
-function isRecent(iso, nowStr, maxMs) {
- const t = new Date(iso || "").getTime();
- return Number.isFinite(t) && new Date(nowStr).getTime() - t < maxMs;
-}
-
-function pruneDispatched(cp, nowStr) {
- const cutoff = new Date(nowStr).getTime() - DISPATCHED_WINDOW_DAYS * 86400 * 1000;
- for (const [id, entry] of Object.entries(cp.dispatched)) {
- if (new Date(entry.dispatched_at || 0).getTime() < cutoff) delete cp.dispatched[id];
- }
- for (const [id, entry] of Object.entries(cp.pending)) {
- if (new Date(entry.first_attempted_at || 0).getTime() < cutoff) delete cp.pending[id];
- }
-}
-
-function hasCompleteProgramContract(program) {
- const [repoDirField, allowedPathsField, metricNameField, metricTargetField] =
- AUTOMATION_CANDIDATE_CONTRACT.requiredProgramFields;
- return typeof program[repoDirField] === "string"
- && program[repoDirField].trim().length > 0
- && Array.isArray(program[allowedPathsField])
- && program[allowedPathsField].length > 0
- && program[allowedPathsField].every((path) => typeof path === "string" && path.trim().length > 0)
- && typeof program[metricNameField] === "string"
- && program[metricNameField].trim().length > 0
- && program[metricTargetField] != null;
-}
-
-function isAutomationCandidateCard(obs) {
- const program = (obs.metadata || {})[AUTOMATION_CANDIDATE_CONTRACT.programKey] || {};
- return obs.pipeline_stage_id === AUTOMATION_CANDIDATE_CONTRACT.pipelineStageId
- && hasCompleteProgramContract(program);
-}
-
-function candidateIterationBudget(program) {
- const value = Number(program.iteration_budget || MAX_ITERATIONS);
- if (!Number.isFinite(value)) return MAX_ITERATIONS;
- return Math.max(1, Math.min(MAX_ITERATIONS, Math.floor(value)));
-}
-
-function previousIterationsFor(inventory, cardId) {
- if (!inventory) return [];
- if (Array.isArray(inventory)) {
- return inventory.flatMap((item) => {
- if (!item || typeof item !== "object") return [];
- const itemCardId = item.card_id || item.kanban_card_id;
- if (itemCardId !== cardId) return [];
- if (Array.isArray(item.iterations)) return item.iterations;
- return [item];
- });
- }
- if (Array.isArray(inventory[cardId])) return inventory[cardId];
- if (inventory[cardId] && Array.isArray(inventory[cardId].iterations)) {
- return inventory[cardId].iterations;
- }
- if (inventory.card_id === cardId && Array.isArray(inventory.iterations)) {
- return inventory.iterations;
- }
- return [];
-}
-
-// --- Build executor prompt (autoresearch-style: program contract + previous findings) ---
-
-function buildIterationPrompt(cardId, card, iteration, previousIterations) {
- const program = (card.metadata && card.metadata.program) || {};
- const allowedPaths = (program.allowed_write_paths || []).join(", ") || "(not specified)";
- const metricName = program.metric_name || "improvement_score";
- const metricDirection = program.metric_direction || program.direction || "lower_is_better";
- const metricTarget = program.metric_target != null ? String(program.metric_target) : "(not specified)";
- const iterBudget = candidateIterationBudget(program);
- const description = program.description || card.title || "(no description)";
-
- const prevSummary = previousIterations.length === 0
- ? "(이전 반복 없음 — 첫 번째 시도입니다)"
- : previousIterations.map((r, i) =>
- ` 반복 ${r.iteration}: ${r.status} | ${metricName}: ${r.metric_before} → ${r.metric_after} | ${r.description || ""}`
- ).join("\n");
-
- return [
- "## 자동화 후보 반복 실행 요청 (Automation Candidate Executor)",
- "",
- `**카드 ID**: \`${cardId}\``,
- `**제목**: ${card.title || "(no title)"}`,
- `**반복 번호**: ${iteration} / ${iterBudget}`,
- "",
- "### Program Contract",
- `- **목표**: ${description}`,
- `- **수정 허용 경로**: \`${allowedPaths}\``,
- `- **지표명**: ${metricName}`,
- `- **지표 방향**: ${metricDirection}`,
- `- **목표값**: ${metricTarget}`,
- "",
- "### 이전 반복 결과",
- prevSummary,
- "",
- "### 실행 지침",
- "",
- "1. **먼저 아래 API로 격리된 git worktree를 준비**하고, 응답의 `path`에서만 작업:",
- "",
- "```",
- `POST /api/automation-candidates/${cardId}/prepare-worktree`,
- "Content-Type: application/json",
- "",
- JSON.stringify({ iteration }, null, 2),
- "```",
- "",
- "2. **allowed_write_paths 내에서만** 코드 수정",
- "3. 지표 측정 (변경 전/후)",
- "4. **반드시 아래 API 호출로 결과 제출** (다른 방법으로 상태 변경 금지):",
- "",
- "```",
- `POST /api/automation-candidates/${cardId}/iteration-result`,
- "Content-Type: application/json",
- "",
- JSON.stringify({
- iteration,
- branch: `automation/${cardId}/iter-${iteration}`,
- commit_hash: "<커밋 해시>",
- metric_before: "<변경 전 수치>",
- metric_after: "<변경 후 수치>",
- is_simplification: false,
- status: "keep",
- description: "<변경 요약>",
- allowed_write_paths_used: program.allowed_write_paths || [],
- run_seconds: "<소요 초>",
- crash_trace: null,
- }, null, 2),
- "```",
- "",
- "**주의**: `lower_is_better`에서는 `metric_after < metric_before`, `higher_is_better`에서는 `metric_after > metric_before`인 경우에만 Rust가 `keep` 판정합니다.",
- "**주의**: allowed_write_paths 외 경로 수정 시 API가 403을 반환합니다.",
- "**주의**: 이 API 호출 없이 카드 상태를 직접 변경하지 마세요.",
- ].join("\n");
-}
-
-// --- Main tick ---
-
-agentdesk.routines.register({
- name: "Automation Candidate Executor",
-
- tick(ctx) {
- const nowStr = nowIso(ctx.now);
- const cp = loadCheckpoint(ctx.checkpoint);
- const observations = ctx.observations || [];
-
- pruneDispatched(cp, nowStr);
-
- // Collect kanban_ready candidates (source 8 in store.rs)
- const readyCards = observations
- .filter((o) => o.source === "kanban_ready")
- .filter((o) => {
- const valid = isAutomationCandidateCard(o);
- if (!valid) cp.stats.skipped++;
- return valid;
- })
- .map((o) => ({ cardId: o.card_id || o.evidence_ref?.replace("kanban_cards:", ""), obs: o }))
- .filter((c) => c.cardId);
-
- // Collect kanban_dispatched to suppress already-completed cards (source 9)
- const dispatchedCardIds = new Set(
- observations
- .filter((o) => o.source === "kanban_dispatched")
- .map((o) => o.card_id || o.evidence_ref?.replace("kanban_cards:", ""))
- .filter(Boolean)
- );
-
- cp.stats.ticks++;
-
- if (readyCards.length === 0) {
- return {
- action: "complete",
- result: { status: "ok", summary: "실행 대기 중인 자동화 후보 없음" },
- checkpoint: cp,
- };
- }
-
- // Filter candidates
- const toProcess = [];
- for (const { cardId, obs } of readyCards) {
- if (cp.dispatched[cardId] || dispatchedCardIds.has(cardId)) {
- cp.stats.skipped++;
- continue;
- }
- const meta = obs.metadata || {};
- const program = meta.program || {};
- const maxIterations = candidateIterationBudget(program);
- const iteration = (program.current_iteration || 0) + 1;
- const pending = cp.pending[cardId];
- const pendingIteration = Number.isFinite(Number(pending?.iteration))
- ? Number(pending.iteration)
- : iteration;
- if (pending && pendingIteration !== iteration) {
- delete cp.pending[cardId];
- }
- const activePending = pending && pendingIteration === iteration ? pending : null;
- const retryWindowOpen = activePending
- && isRecent(activePending.last_attempted_at, nowStr, DISPATCH_RETRY_MS);
-
- if (activePending && (activePending.attempt_count || 0) >= MAX_DISPATCH_RETRIES) {
- if (activePending.status !== "stalled" && retryWindowOpen) {
- cp.stats.skipped++;
- continue;
- }
- if (activePending.status !== "stalled") {
- cp.pending[cardId] = Object.assign({}, activePending, {
- status: "stalled",
- stalled_at: nowStr,
- });
- cp.stats.stalled_candidates = (cp.stats.stalled_candidates || 0) + 1;
- }
- continue;
- }
- if (retryWindowOpen) {
- cp.stats.skipped++;
- continue;
- }
-
- if (iteration > maxIterations) {
- cp.stats.max_iterations_reached++;
- cp.dispatched[cardId] = { dispatched_at: nowStr, status: "max_iterations_reached", iteration };
- continue;
- }
-
- toProcess.push({ cardId, obs, iteration, program });
- }
-
- if (toProcess.length === 0) {
- return {
- action: "complete",
- result: {
- status: "ok",
- summary: `ready 후보 ${readyCards.length}건 모두 처리됨 또는 대기 중`,
- skipped: cp.stats.skipped,
- },
- checkpoint: cp,
- };
- }
-
- // Process first candidate
- const { cardId, obs, iteration } = toProcess[0];
- const card = {
- title: obs.summary || "",
- metadata: obs.metadata || {},
- };
-
- // Read previous iteration results from ctx.automationInventory if available
- const previousIterations = previousIterationsFor(ctx.automationInventory, cardId);
-
- const prevPending = cp.pending[cardId];
- cp.pending[cardId] = {
- first_attempted_at: prevPending?.first_attempted_at || nowStr,
- last_attempted_at: nowStr,
- attempt_count: (prevPending?.attempt_count || 0) + 1,
- iteration,
- };
- cp.stats.dispatched++;
-
- return {
- action: "agent",
- prompt: buildIterationPrompt(cardId, card, iteration, previousIterations),
- checkpoint: cp,
- };
- },
-});
diff --git a/routines/monitoring/automation-candidate-recommender.js b/routines/monitoring/automation-candidate-recommender.js
deleted file mode 100644
index 292e0fe0ef..0000000000
--- a/routines/monitoring/automation-candidate-recommender.js
+++ /dev/null
@@ -1,1336 +0,0 @@
-// Automation Candidate Recommender
-// 매 tick마다 bounded observations를 checkpoint에 누적하고,
-// 강한 근거(score >= 80)에서만 agent proposal을 생성한다.
-// P0: read-only, no auto-implementation, proposal-only.
-
-const SCORE_THRESHOLD = 80;
-const DAILY_CAP = 3;
-const COOLDOWN_HOURS = 6;
-const MAX_EXAMPLES_PER_CANDIDATE = 3;
-const PROMPT_CAP_BYTES = 12288;
-const CHECKPOINT_CAP_BYTES = 65536;
-const CHECKPOINT_VERSION = 1;
-const CANDIDATE_TTL_DAYS = 30;
-const REPO_DIR_PLACEHOLDER = "";
-
-// seen_evidence dedup: prevents the same evidence from incrementing score/evidence_count
-// across multiple ticks. TTL covers the 24h observation query window with margin.
-const SEEN_EVIDENCE_TTL_MS = 25 * 3600 * 1000; // 25 h
-const SEEN_EVIDENCE_MAX_ENTRIES = 500; // LRU cap
-
-// Saturation detection & re-optimization (P0-E)
-// EMA formula mirrors autoresearch train.py ema_beta=0.9 (karpathy/autoresearch).
-// Fast-fail tier: analogous to autoresearch "if isnan(loss): exit(1)".
-// Re-optimization (partial reset + diversity mode) is original design.
-const EMA_BETA = 0.9;
-const EMA_SATURATION_THRESHOLD = 0.3; // ema_scored below this → saturation_ticks++
-const SATURATION_TICKS = 5; // consecutive EMA-saturated ticks → reopt
-const FAST_FAIL_TICKS = 2; // all-dedup consecutive ticks → immediate reopt
-const REOPT_WINDOW_MS = 12 * 3600 * 1000; // partial reset: remove seen_evidence older than 12h
-const DIVERSITY_BOOST_TICKS = 10; // ticks of diversity mode after each reopt
-const DIVERSITY_LOOKBACK_TICKS = 5; // history window for underrepresented category detection
-
-const KNOWN_CATEGORIES = new Set([
- "routine-candidate",
- "release-freshness",
- "outbox-delivery",
- "memento-hygiene",
- "api-friction",
- "kanban-flow",
- "dispatch-retry",
- "session-pattern",
- "log-signal",
- "automation-candidate",
-]);
-
-const CATEGORY_GATES = Object.freeze({
- "routine-candidate": { minScore: SCORE_THRESHOLD, minEvidence: 5 },
- "release-freshness": { minScore: SCORE_THRESHOLD, minEvidence: 5 },
- "outbox-delivery": { minScore: 60, minEvidence: 3 },
- "memento-hygiene": { minScore: 60, minEvidence: 3 },
- "api-friction": { minScore: 60, minEvidence: 3 },
- "kanban-flow": { minScore: 60, minEvidence: 3 },
- "dispatch-retry": { minScore: 60, minEvidence: 3 },
- "session-pattern": { minScore: 60, minEvidence: 3 },
- "log-signal": { minScore: 60, minEvidence: 3 },
- "automation-candidate": { minScore: SCORE_THRESHOLD, minEvidence: 5 },
-});
-
-const CATEGORY_SCORE_MULTIPLIERS = Object.freeze({
- "outbox-delivery": 1.25,
- "api-friction": 1.2,
- "memento-hygiene": 1.1,
- "kanban-flow": 1.15,
- "dispatch-retry": 1.2,
- "session-pattern": 1.15,
- "log-signal": 1.15,
-});
-
-// --- Scoring weights ---
-const WEIGHT_BASE = 10;
-const WEIGHT_RECENCY_BONUS = 10; // occurred in last 30 min
-const WEIGHT_FIRST_SEEN = 5; // new candidate bonus
-
-// --- Checkpoint helpers ---
-
-function emptyCheckpoint() {
- return {
- version: CHECKPOINT_VERSION,
- cursors: {},
- candidates: {},
- suppressions: {},
- seen_evidence: {}, // evidence_key -> { seen_at, occurrences } (legacy ISO string accepted)
- recommendations: [],
- last_tick_at: null,
- // P0-E saturation tracking
- ema_scored: 0,
- saturation_ticks: 0,
- fast_fail_ticks: 0,
- reopt_count: 0,
- diversity_mode_ticks_remaining: 0,
- last_reopt_at: null,
- stats: {
- ticks: 0,
- observations_seen: 0,
- agent_escalations: 0,
- recommendations_today: 0,
- recommendation_day: null,
- category_scored: {},
- category_scored_history: [], // [{cat: count, ...}, ...] last DIVERSITY_LOOKBACK_TICKS entries
- },
- };
-}
-
-function loadCheckpoint(raw) {
- if (!raw || typeof raw !== "object" || raw.version !== CHECKPOINT_VERSION) {
- return emptyCheckpoint();
- }
- const cp = Object.assign(emptyCheckpoint(), raw);
- cp.candidates = raw.candidates || {};
- cp.suppressions = raw.suppressions || {};
- cp.seen_evidence = raw.seen_evidence || {};
- cp.stats = Object.assign(emptyCheckpoint().stats, raw.stats || {});
- cp.stats.category_scored = (raw.stats && raw.stats.category_scored) ? raw.stats.category_scored : {};
- cp.stats.category_scored_history = (raw.stats && Array.isArray(raw.stats.category_scored_history))
- ? raw.stats.category_scored_history : [];
- // P0-E backward-compat defaults
- if (typeof cp.ema_scored !== "number") cp.ema_scored = 0;
- if (typeof cp.saturation_ticks !== "number") cp.saturation_ticks = 0;
- if (typeof cp.fast_fail_ticks !== "number") cp.fast_fail_ticks = 0;
- if (typeof cp.reopt_count !== "number") cp.reopt_count = 0;
- if (typeof cp.diversity_mode_ticks_remaining !== "number") cp.diversity_mode_ticks_remaining = 0;
- if (cp.last_reopt_at === undefined) cp.last_reopt_at = null;
- return cp;
-}
-
-function nowIso(now) {
- return typeof now === "string" ? now : now.toISOString ? now.toISOString() : String(now);
-}
-
-function dateOf(iso) {
- return iso ? iso.slice(0, 10) : null;
-}
-
-function addHours(isoStr, hours) {
- const ms = new Date(isoStr).getTime() + hours * 3600 * 1000;
- return new Date(ms).toISOString();
-}
-
-function simpleHash(str) {
- let h = 0;
- for (let i = 0; i < str.length; i++) {
- h = ((h << 5) - h + str.charCodeAt(i)) | 0;
- }
- return (h >>> 0).toString(16);
-}
-
-// --- seen_evidence dedup helpers ---
-
-function seenEvidenceTimestamp(entry) {
- if (typeof entry === "string") return entry;
- if (entry && typeof entry === "object") {
- return entry.seen_at || entry.first_seen_at || null;
- }
- return null;
-}
-
-function seenEvidenceOccurrences(entry) {
- if (!entry || typeof entry !== "object") return 0;
- const value = Number(entry.occurrences || 0);
- if (!Number.isFinite(value) || value < 0) return 0;
- return Math.floor(value);
-}
-
-function isSeenEvidenceFresh(entry, nowStr) {
- const ts = seenEvidenceTimestamp(entry);
- if (!ts) return false;
- return new Date(nowStr).getTime() - new Date(ts).getTime() < SEEN_EVIDENCE_TTL_MS;
-}
-
-function evidenceKey(obs) {
- if (obs.evidence_ref) return obs.evidence_ref;
- return `${obs.source || ""}|${obs.category || ""}|${obs.signature || ""}|${simpleHash(String(obs.summary || ""))}`;
-}
-
-function seenOccurrences(cp, key, nowStr) {
- const entry = cp.seen_evidence[key];
- if (!isSeenEvidenceFresh(entry, nowStr)) return 0;
- return seenEvidenceOccurrences(entry);
-}
-
-function isEvidenceFullySeen(cp, key, nowStr, occurrences) {
- const entry = cp.seen_evidence[key];
- if (!isSeenEvidenceFresh(entry, nowStr)) return false;
- if (typeof entry === "string") return true;
- return seenEvidenceOccurrences(entry) >= occurrences;
-}
-
-function markEvidenceSeen(cp, key, nowStr, occurrences) {
- const value = Number(occurrences || 0);
- cp.seen_evidence[key] = {
- seen_at: nowStr,
- occurrences: Number.isFinite(value) && value > 0 ? Math.floor(value) : 0,
- };
-}
-
-function pruneSeenEvidence(cp, nowStr) {
- const cutoffMs = new Date(nowStr).getTime() - SEEN_EVIDENCE_TTL_MS;
- // Expire TTL-exceeded entries
- for (const key of Object.keys(cp.seen_evidence)) {
- const seenAt = seenEvidenceTimestamp(cp.seen_evidence[key]);
- if (!seenAt || new Date(seenAt).getTime() < cutoffMs) {
- delete cp.seen_evidence[key];
- }
- }
- // LRU eviction: drop oldest entries if still over cap
- const keys = Object.keys(cp.seen_evidence);
- if (keys.length > SEEN_EVIDENCE_MAX_ENTRIES) {
- keys.sort((a, b) =>
- new Date(seenEvidenceTimestamp(cp.seen_evidence[a])).getTime() -
- new Date(seenEvidenceTimestamp(cp.seen_evidence[b])).getTime()
- );
- for (const key of keys.slice(0, keys.length - SEEN_EVIDENCE_MAX_ENTRIES)) {
- delete cp.seen_evidence[key];
- }
- }
-}
-
-// --- P0-E: Saturation detection & re-optimization helpers ---
-
-function updateEmaScored(cp, scoredThisTick) {
- cp.ema_scored = EMA_BETA * cp.ema_scored + (1 - EMA_BETA) * scoredThisTick;
-}
-
-function updateFastFailTicks(cp, scoringReport, obsCount) {
- // Fast-fail: all observations were deduped (no new scoring signal at all)
- const allDeduped =
- scoringReport.scored === 0 &&
- scoringReport.deduped > 0 &&
- scoringReport.deduped === obsCount;
- if (allDeduped) {
- cp.fast_fail_ticks++;
- } else {
- cp.fast_fail_ticks = 0;
- }
-}
-
-function updateSaturationTicks(cp) {
- if (cp.ema_scored < EMA_SATURATION_THRESHOLD) {
- cp.saturation_ticks++;
- } else {
- cp.saturation_ticks = 0;
- }
-}
-
-function shouldTriggerReopt(cp) {
- if (cp.fast_fail_ticks >= FAST_FAIL_TICKS) return { trigger: true, reason: "fast_fail" };
- if (cp.saturation_ticks >= SATURATION_TICKS) return { trigger: true, reason: "ema_saturation" };
- return { trigger: false, reason: null };
-}
-
-function partialResetSeenEvidence(cp, nowStr) {
- const cutoff = new Date(new Date(nowStr).getTime() - REOPT_WINDOW_MS).getTime();
- for (const [key, entry] of Object.entries(cp.seen_evidence)) {
- const seenAt = seenEvidenceTimestamp(entry);
- if (!seenAt || new Date(seenAt).getTime() < cutoff) {
- delete cp.seen_evidence[key];
- }
- }
-}
-
-function triggerReopt(cp, nowStr) {
- partialResetSeenEvidence(cp, nowStr);
- cp.diversity_mode_ticks_remaining = DIVERSITY_BOOST_TICKS;
- cp.reopt_count++;
- cp.saturation_ticks = 0;
- cp.fast_fail_ticks = 0;
- cp.last_reopt_at = nowStr;
-}
-
-// Returns Set of underrepresented categories using last DIVERSITY_LOOKBACK_TICKS tick history.
-// A category is underrepresented when its summed scored count is below the per-category average.
-function underrepresentedCatsFromHistory(cp) {
- const totals = {};
- for (const cat of KNOWN_CATEGORIES) totals[cat] = 0;
- for (const snapshot of cp.stats.category_scored_history || []) {
- for (const [cat, n] of Object.entries(snapshot)) {
- totals[cat] = (totals[cat] || 0) + n;
- }
- }
- const vals = Object.values(totals);
- const avg = vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0;
- return new Set(Object.entries(totals).filter(([, n]) => n < avg).map(([c]) => c));
-}
-
-function normalizeCategory(value) {
- if (typeof value === "string" && KNOWN_CATEGORIES.has(value)) {
- return value;
- }
- return "routine-candidate";
-}
-
-function categoryGate(category) {
- return CATEGORY_GATES[normalizeCategory(category)] || CATEGORY_GATES["routine-candidate"];
-}
-
-function categoryScoreMultiplier(category) {
- const value = CATEGORY_SCORE_MULTIPLIERS[normalizeCategory(category)] || 1;
- return Math.min(1.5, Math.max(1, value));
-}
-
-function candidateGate(candidate) {
- return categoryGate(candidate && candidate.category);
-}
-
-function candidateMeetsGate(candidate) {
- const gate = candidateGate(candidate);
- return (candidate.score || 0) >= gate.minScore
- && (candidate.evidence_count || 0) >= gate.minEvidence;
-}
-
-function observationOccurrences(obs) {
- const value = Number(obs.occurrences || obs.count || 1);
- if (!Number.isFinite(value) || value < 1) {
- return 1;
- }
- return Math.min(50, Math.floor(value));
-}
-
-function observationKey(obs) {
- if (typeof obs.key === "string") return obs.key;
- if (typeof obs.evidence_ref === "string") {
- if (obs.evidence_ref.startsWith("kv_meta:routine_observation:")) {
- return obs.evidence_ref.slice("kv_meta:".length);
- }
- if (obs.evidence_ref.startsWith("routine_observation:")) {
- return obs.evidence_ref;
- }
- }
- return "";
-}
-
-function compactText(value, maxChars) {
- return String(value || "")
- .replace(/\s+/g, " ")
- .trim()
- .slice(0, maxChars);
-}
-
-function topEvidenceForCandidate(candidate, limit) {
- return (candidate.examples || [])
- .slice(-limit)
- .reverse()
- .map((example) => ({
- summary: compactText(example.summary, 140),
- timestamp: example.timestamp || null,
- evidence_ref: example.evidence_ref || null,
- weight: example.weight || 1,
- occurrences: example.occurrences || 1,
- }));
-}
-
-function topEvidenceSummaryForCandidate(candidate) {
- const evidence = topEvidenceForCandidate(candidate, 2);
- if (evidence.length === 0) return "핵심 근거 없음";
- return evidence
- .map(
- (item, index) =>
- `${index + 1}) ${item.summary || "요약 없음"} (occurrences=${item.occurrences}, weight=${item.weight})`
- )
- .join(" / ");
-}
-
-function topContributionSummary(map, limit) {
- const entries = Object.entries(map || {})
- .sort(([, a], [, b]) => Number(b || 0) - Number(a || 0))
- .slice(0, limit)
- .map(([key, count]) => `${key}=${count}`);
- return entries.length ? entries.join(", ") : "none";
-}
-
-function candidateSourceSummary(candidate) {
- return [
- `sources(${topContributionSummary(candidate.sources, 3)})`,
- `categories(${topContributionSummary(candidate.source_categories, 3)})`,
- ].join(" ");
-}
-
-function topCandidateEvidence(cp, limit) {
- return Object.entries(cp.candidates || {})
- .filter(([, candidate]) => candidate.state === "observing" || candidate.state === "recommended")
- .sort(([, a], [, b]) => (b.score || 0) - (a.score || 0))
- .slice(0, limit)
- .map(([patternId, candidate]) => ({
- pattern_id: patternId,
- score: candidate.score || 0,
- score_delta_last_tick: candidate.score_delta_last_tick || 0,
- evidence_count: candidate.evidence_count || 0,
- gate: candidateGate(candidate),
- latest_evidence: topEvidenceSummaryForCandidate(candidate),
- }));
-}
-
-function topCandidateEvidenceSummary(cp) {
- const top = topCandidateEvidence(cp, 3);
- if (top.length === 0) return "관찰 중인 후보 없음";
- return top
- .map(
- (item, index) =>
- `${index + 1}) ${item.pattern_id}: score=${item.score}, delta=${item.score_delta_last_tick}, evidence=${item.evidence_count}, 근거=${item.latest_evidence}`
- )
- .join(" / ");
-}
-
-function suppressionSummary(suppressedObservations, droppedCandidates) {
- const parts = [];
- for (const item of (suppressedObservations || []).slice(0, 3)) {
- parts.push(`${item.pattern_id}: ${item.reason}`);
- }
- for (const item of (droppedCandidates || []).slice(0, 3)) {
- parts.push(`${item.pattern_id}: ${item.reason}`);
- }
- return parts.length ? parts.join(" / ") : "중복/억제 후보 없음";
-}
-
-function noEscalationReason(cp, nowStr) {
- if (cp.stats.recommendations_today >= DAILY_CAP) {
- return `보류 이유: 일일 추천 한도 ${DAILY_CAP}개에 도달했습니다.`;
- }
- const top = topCandidateEvidence(cp, 1)[0];
- if (!top) {
- return "보류 이유: 관찰된 후보가 없습니다.";
- }
- const candidate = cp.candidates[top.pattern_id] || {};
- const gate = candidateGate(candidate);
- if ((candidate.evidence_count || 0) < gate.minEvidence) {
- return `보류 이유: 최상위 후보 ${top.pattern_id}의 근거가 ${candidate.evidence_count || 0}회로 category=${normalizeCategory(candidate.category)} 최소 ${gate.minEvidence}회 미만입니다.`;
- }
- if ((candidate.score || 0) < gate.minScore) {
- return `보류 이유: 최상위 후보 ${top.pattern_id}의 점수 ${candidate.score || 0}가 category=${normalizeCategory(candidate.category)} 기준 ${gate.minScore} 미만입니다.`;
- }
- if (candidate.cooldown_until && candidate.cooldown_until > nowStr) {
- return `보류 이유: 최상위 후보 ${top.pattern_id}가 ${candidate.cooldown_until}까지 쿨다운 중입니다.`;
- }
- return `보류 이유: 최상위 후보 ${top.pattern_id}는 중복 추천 해시 또는 게이트 조건으로 보류됐습니다.`;
-}
-
-function utf8CharBytes(ch) {
- const codePoint = ch.codePointAt(0);
- if (codePoint <= 0x7f) return 1;
- if (codePoint <= 0x7ff) return 2;
- if (codePoint <= 0xffff) return 3;
- return 4;
-}
-
-function utf8ByteLength(value) {
- let bytes = 0;
- for (const ch of String(value || "")) {
- bytes += utf8CharBytes(ch);
- }
- return bytes;
-}
-
-function truncateUtf8(value, maxBytes) {
- if (maxBytes <= 0) return "";
- let bytes = 0;
- let out = "";
- for (const ch of String(value || "")) {
- const nextBytes = utf8CharBytes(ch);
- if (bytes + nextBytes > maxBytes) break;
- out += ch;
- bytes += nextBytes;
- }
- return out;
-}
-
-// --- Daily cap reset ---
-
-function resetDailyCapIfNeeded(cp, nowStr) {
- const today = dateOf(nowStr);
- if (cp.stats.recommendation_day !== today) {
- cp.stats.recommendations_today = 0;
- cp.stats.recommendation_day = today;
- }
-}
-
-// --- Suppression / inventory filter ---
-
-function hasDurableAcceptance(entry) {
- return Boolean(entry && (entry.automation_ref || entry.source_ref));
-}
-
-function buildSuppressedSet(cp, inventory, observations) {
- const exact = new Map();
- const prefixes = [];
-
- function addPattern(patternId, reason) {
- if (!patternId) return;
- if (patternId.endsWith(":*")) {
- prefixes.push({ prefix: patternId.slice(0, -1), reason });
- } else {
- exact.set(patternId, reason);
- }
- }
-
- // From checkpoint suppressions
- for (const [patternId, entry] of Object.entries(cp.suppressions || {})) {
- const state = entry.state;
- if (
- (state === "accepted" && hasDurableAcceptance(entry)) ||
- state === "implemented" ||
- state === "suppressed" ||
- state === "rejected"
- ) {
- addPattern(patternId, `체크포인트 억제 상태=${state}`);
- }
- }
-
- // From automation inventory (exact pattern_id match only)
- for (const item of inventory || []) {
- if (
- item.pattern_id &&
- ((item.status === "accepted" && hasDurableAcceptance(item)) ||
- item.status === "implemented" ||
- item.status === "suppressed" ||
- item.status === "rejected")
- ) {
- addPattern(
- item.pattern_id,
- `자동화 인벤토리 상태=${item.status}${item.source_ref ? ` ref=${item.source_ref}` : ""}`
- );
- }
- }
-
- // Candidate-level accepted/implemented/suppressed/rejected states
- for (const [patternId, candidate] of Object.entries(cp.candidates || {})) {
- const s = candidate.state;
- if (
- (s === "accepted" && hasDurableAcceptance(candidate)) ||
- s === "implemented" ||
- s === "suppressed" ||
- s === "rejected"
- ) {
- addPattern(patternId, `후보 상태=${s}`);
- }
- }
-
- // Contract: executor/dispatched markers must suffix the same signature used by obs.signature.
- // candidate_dispatched:* kv_meta observations → suppress re-recommendation (REQ-P1-004)
- for (const obs of observations || []) {
- const key = observationKey(obs);
- if (key.startsWith("routine_observation:candidate_dispatched:")) {
- const signature = key.replace("routine_observation:candidate_dispatched:", "");
- if (signature) addPattern(signature, "dispatched kv_meta 존재 (재추천 차단)");
- }
- }
-
- return {
- has(patternId) {
- return exact.has(patternId) || prefixes.some((item) => patternId.startsWith(item.prefix));
- },
- reason(patternId) {
- if (exact.has(patternId)) return exact.get(patternId);
- const matched = prefixes.find((item) => patternId.startsWith(item.prefix));
- return matched ? matched.reason : null;
- },
- };
-}
-
-function dropSuppressedCandidates(cp, suppressedSet) {
- const dropped = [];
- for (const [patternId, candidate] of Object.entries(cp.candidates || {})) {
- if (suppressedSet.has(patternId)) {
- const s = candidate.state;
- if (
- (s === "accepted" && hasDurableAcceptance(candidate)) ||
- s === "implemented" ||
- s === "suppressed" ||
- s === "rejected"
- ) {
- continue;
- }
- dropped.push({
- pattern_id: patternId,
- state: s,
- reason: suppressedSet.reason(patternId) || "인벤토리/체크포인트 기준으로 억제",
- });
- delete cp.candidates[patternId];
- }
- }
-
- if (Array.isArray(cp.recommendations)) {
- cp.recommendations = cp.recommendations.filter((item) => !suppressedSet.has(item.pattern_id));
- }
-
- return dropped.slice(0, 5);
-}
-
-// --- Expired suppression cleanup ---
-
-function pruneExpiredSuppressions(cp, nowStr) {
- for (const [patternId, entry] of Object.entries(cp.suppressions || {})) {
- if (entry.expires_at && entry.expires_at < nowStr) {
- delete cp.suppressions[patternId];
- if (cp.candidates[patternId]) {
- cp.candidates[patternId].state = "observing";
- }
- }
- }
-}
-
-function expireStaleCandidates(cp, nowStr) {
- const cutoff = new Date(new Date(nowStr).getTime() - CANDIDATE_TTL_DAYS * 24 * 3600 * 1000).toISOString();
- for (const candidate of Object.values(cp.candidates || {})) {
- if (
- candidate.last_seen_at &&
- candidate.last_seen_at < cutoff &&
- (candidate.state === "observing" || candidate.state === "recommended")
- ) {
- candidate.state = "expired";
- }
- }
-}
-
-// --- Score observations into candidates ---
-
-function scoreObservations(cp, observations, suppressedSet, nowStr, diversityMode) {
- const thirtyMinAgo = new Date(new Date(nowStr).getTime() - 30 * 60 * 1000).toISOString();
- const report = {
- scored: 0,
- deduped: 0,
- suppressed: [],
- category_scored_this_tick: {},
- };
-
- // Diversity mode: underrepresented categories from last DIVERSITY_LOOKBACK_TICKS tick history
- const underrepresentedCats = diversityMode ? underrepresentedCatsFromHistory(cp) : null;
-
- for (const candidate of Object.values(cp.candidates || {})) {
- candidate.score_delta_last_tick = 0;
- candidate.scored_observations_last_tick = 0;
- candidate.last_score_reason = null;
- }
-
- for (const obs of observations) {
- cp.stats.observations_seen++;
-
- const patternId = obs.signature;
- if (!patternId) continue;
- if (suppressedSet.has(patternId)) {
- report.suppressed.push({
- pattern_id: patternId,
- reason: suppressedSet.reason(patternId) || "인벤토리/체크포인트 기준으로 억제",
- });
- continue;
- }
-
- // Cross-tick dedup: skip evidence already scored in a previous tick, but
- // score only the newly increased occurrence count for rolling grouped sources.
- const eKey = evidenceKey(obs);
- const totalOccurrences = observationOccurrences(obs);
- if (isEvidenceFullySeen(cp, eKey, nowStr, totalOccurrences)) {
- markEvidenceSeen(cp, eKey, nowStr, totalOccurrences);
- report.deduped++;
- continue;
- }
- const priorOccurrences = seenOccurrences(cp, eKey, nowStr);
- const occurrences = Math.max(1, totalOccurrences - priorOccurrences);
- markEvidenceSeen(cp, eKey, nowStr, totalOccurrences);
-
- const category = normalizeCategory(obs.category);
- let candidate = cp.candidates[patternId];
- if (!candidate) {
- candidate = {
- category,
- state: "observing",
- score: WEIGHT_FIRST_SEEN,
- evidence_count: 0,
- first_seen_at: obs.timestamp || nowStr,
- last_seen_at: null,
- examples: [],
- sources: {},
- source_categories: {},
- last_recommended_at: null,
- last_recommendation_hash: null,
- cooldown_until: null,
- automation_ref: null,
- has_error_evidence: false,
- };
- cp.candidates[patternId] = candidate;
- } else {
- candidate.category = normalizeCategory(candidate.category || category);
- }
-
- // Skip if already resolved
- if (candidate.state === "accepted" && !hasDurableAcceptance(candidate)) {
- candidate.state = "recommended";
- }
- if (
- (candidate.state === "accepted" && hasDurableAcceptance(candidate)) ||
- candidate.state === "implemented" ||
- candidate.state === "suppressed" ||
- candidate.state === "rejected"
- ) {
- continue;
- }
-
- candidate.evidence_count += occurrences;
- candidate.last_seen_at = obs.timestamp || nowStr;
- candidate.sources = candidate.sources || {};
- candidate.source_categories = candidate.source_categories || {};
- const source = String(obs.source || "unknown");
- candidate.sources[source] = (candidate.sources[source] || 0) + occurrences;
- candidate.source_categories[category] = (candidate.source_categories[category] || 0) + occurrences;
-
- // Score delta
- const weight = typeof obs.weight === "number" ? obs.weight : 1;
- const scoredOccurrences = Math.min(occurrences, 5);
- let delta = WEIGHT_BASE * weight * scoredOccurrences;
- if (weight === 2) {
- candidate.has_error_evidence = true;
- }
-
- // Recency bonus
- const recencyBonus = obs.timestamp && obs.timestamp >= thirtyMinAgo ? WEIGHT_RECENCY_BONUS : 0;
- if (obs.timestamp && obs.timestamp >= thirtyMinAgo) {
- delta += recencyBonus;
- }
-
- // Diversity mode: 1.5× boost for underrepresented categories
- const diversityBoost = (underrepresentedCats && underrepresentedCats.has(category)) ? 1.5 : 1;
- const categoryMultiplier = categoryScoreMultiplier(category);
- delta = delta * diversityBoost * categoryMultiplier;
-
- candidate.score = Math.min(100, candidate.score + delta);
- candidate.score_delta_last_tick = (candidate.score_delta_last_tick || 0) + delta;
- candidate.scored_observations_last_tick =
- (candidate.scored_observations_last_tick || 0) + 1;
- candidate.last_score_reason =
- `weight=${weight}, occurrences=${occurrences}, recency_bonus=${recencyBonus}, category_multiplier=${categoryMultiplier}${diversityBoost > 1 ? ", diversity_boost=1.5" : ""}`;
- candidate.last_scored_at = nowStr;
- report.scored++;
-
- // Track per-category scoring stats (cumulative + per-tick for diversity history)
- cp.stats.category_scored[category] = (cp.stats.category_scored[category] || 0) + 1;
- report.category_scored_this_tick[category] = (report.category_scored_this_tick[category] || 0) + 1;
-
- // Keep up to 3 examples
- if (candidate.examples.length < MAX_EXAMPLES_PER_CANDIDATE) {
- candidate.examples.push({
- summary: obs.summary,
- timestamp: obs.timestamp,
- evidence_ref: obs.evidence_ref,
- source: obs.source,
- category,
- weight,
- occurrences,
- });
- }
- }
-
- return report;
-}
-
-// --- Find best escalation candidate ---
-
-function findEscalationCandidate(cp, nowStr) {
- if (cp.stats.recommendations_today >= DAILY_CAP) {
- return null;
- }
-
- let best = null;
- let bestScore = -1;
-
- for (const [patternId, candidate] of Object.entries(cp.candidates || {})) {
- if (candidate.state !== "observing" && candidate.state !== "recommended") {
- continue;
- }
- if (!candidateMeetsGate(candidate)) {
- continue;
- }
- if (candidate.score <= bestScore) {
- continue;
- }
- if (candidate.cooldown_until && candidate.cooldown_until > nowStr) {
- continue;
- }
-
- // Dedupe: same hash within cooldown
- const hash = simpleHash(patternId + ":" + candidate.evidence_count);
- if (candidate.last_recommendation_hash === hash) {
- continue;
- }
-
- bestScore = candidate.score;
- best = { patternId, candidate, hash };
- }
-
- return best;
-}
-
-// --- Mark candidate as recommended ---
-
-function markRecommended(cp, escalation, nowStr) {
- const { patternId, candidate, hash } = escalation;
- const assessment = candidateAssessment(patternId, candidate);
- const decisionSummary = candidateDecisionSummary(patternId, candidate, assessment);
- const topEvidenceSummary = topEvidenceSummaryForCandidate(candidate);
- candidate.state = "recommended";
- candidate.last_recommended_at = nowStr;
- candidate.last_recommendation_hash = hash;
- candidate.cooldown_until = addHours(nowStr, COOLDOWN_HOURS);
- candidate.suggested_automation = assessment.suggestedAutomation;
- candidate.recommended_execution = assessment.recommendedExecution;
- candidate.outcome_summary = assessment.outcomeSummary;
- candidate.before_after = assessment.beforeAfter;
- candidate.expected_files = assessment.expectedFiles;
- candidate.expected_side_effects = assessment.expectedSideEffects;
- candidate.verification_method = assessment.verificationMethod;
- candidate.gated_handoff = assessment.gatedHandoff;
- candidate.materialize_request = assessment.materializeRequest;
- candidate.decision_summary = decisionSummary;
- candidate.top_evidence = topEvidenceForCandidate(candidate, 3);
- candidate.top_evidence_summary = topEvidenceSummary;
- cp.stats.recommendations_today++;
- cp.stats.agent_escalations++;
- cp.recommendations.push({
- pattern_id: patternId,
- recommended_at: nowStr,
- hash,
- score: candidate.score,
- score_delta_last_tick: candidate.score_delta_last_tick || 0,
- evidence_count: candidate.evidence_count,
- sources: candidate.sources || {},
- source_categories: candidate.source_categories || {},
- outcome_summary: assessment.outcomeSummary,
- decision_summary: decisionSummary,
- top_evidence_summary: topEvidenceSummary,
- materialize_request: assessment.materializeRequest,
- });
- // Keep recommendations list bounded
- if (cp.recommendations.length > 50) {
- cp.recommendations = cp.recommendations.slice(-50);
- }
-}
-
-// --- Agent prompt builder ---
-
-function buildOutcomeSummary(patternId, candidate, isErrorPattern, category) {
- const latestExample = (candidate.examples || []).slice(-1)[0] || {};
- const latestSummary = String(latestExample.summary || patternId)
- .replace(/\s+/g, " ")
- .slice(0, 120);
- const count = candidate.evidence_count || observationOccurrences(latestExample) || 0;
- const categoryLabel = {
- "routine-candidate": "루틴 반복",
- "release-freshness": "릴리스 신선도",
- "outbox-delivery": "메시지 발송",
- "memento-hygiene": "메모리 위생",
- "api-friction": "API 마찰",
- "kanban-flow": "칸반 흐름",
- "dispatch-retry": "디스패치 재시도",
- "session-pattern": "세션 오류",
- "log-signal": "운영 로그",
- "automation-candidate": "자동화 후보",
- }[category] || "루틴 후보";
- const prefix = isErrorPattern || category === "outbox-delivery" || category === "api-friction"
- ? "실패 요약"
- : "성공 요약";
- const action = prefix === "실패 요약"
- ? "자동 복구나 알림 후보입니다"
- : "수동 확인 없이 루틴화할 후보입니다";
- return `${prefix}: ${categoryLabel} 패턴이 ${count}회 반복되어 ${action}. 최근 근거: ${latestSummary}`;
-}
-
-function candidateAssessment(patternId, candidate) {
- const isErrorPattern = Boolean(candidate.has_error_evidence) ||
- (candidate.examples || []).some((example) => example.weight === 2);
- const category = normalizeCategory(candidate.category);
- const categoryProfiles = {
- "routine-candidate": {
- suggestedAutomation: isErrorPattern
- ? "반복 실패 루틴에 대한 자동 재시도 또는 알림"
- : "반복 패턴을 자동 처리하는 예약 루틴",
- before: "반복 루틴 근거는 수동 로그 확인 후에만 보입니다.",
- after: "제한된 루틴/규칙이 반복 패턴을 처리하거나 쿨다운을 두고 에스컬레이션합니다.",
- files: ["routines/monitoring/*.js", "src/services/routines/*"],
- sideEffects: "루틴 또는 규칙 경로가 추가될 수 있으므로 쿨다운, 중복 제거, Discord 노이즈를 검증해야 합니다.",
- verification: "대상 루틴 로더 테스트를 실행하고 체크포인트 후보 필드를 확인합니다.",
- },
- "release-freshness": {
- suggestedAutomation: "오래된 배포, 버전, 생성 인벤토리 신호를 감지하는 릴리스 신선도 모니터",
- before: "버전이나 생성 문서가 오래된 뒤에야 사람이 릴리스 드리프트를 발견합니다.",
- after: "신선도 점검이 오래된 릴리스 상태가 누적되기 전에 업데이트 경로를 제안합니다.",
- files: ["scripts/*release*", "src/cli/*", "docs/generated/worker-inventory.md"],
- sideEffects: "읽기 전용 신선도 점검이 추가될 수 있으며 자동 게시, 태깅, 배포는 피해야 합니다.",
- verification: "스크립트 검사와 릴리스 부작용이 없음을 증명하는 신선도 픽스처를 실행합니다.",
- },
- "outbox-delivery": {
- suggestedAutomation: "반복 전송 또는 큐 적재 실패를 감지하는 메시지 아웃박스 전달 모니터",
- before: "전달 실패 반복 패턴을 찾으려면 DB/로그를 사람이 직접 확인해야 합니다.",
- after: "반복 아웃박스 실패가 명확한 전달 수정 경로를 가진 제한된 제안으로 묶입니다.",
- files: ["src/services/message_outbox.rs", "src/services/routines/discord_log.rs", "src/services/discord/*"],
- sideEffects: "알림 재시도 또는 폴백 동작이 바뀔 수 있으므로 중복 제거와 전달 대상을 검증해야 합니다.",
- verification: "아웃박스/루틴 대상 테스트를 실행하고 전달 실패 픽스처를 확인합니다.",
- },
- "memento-hygiene": {
- suggestedAutomation: "반복되는 메모리 품질 또는 라우팅 문제를 요약하는 Memento 위생 다이제스트 모니터",
- before: "메모리 위생 문제는 원문 노트에 흩어져 있어 안전하게 조치하기 어렵습니다.",
- after: "토픽/횟수/최신 예시 다이제스트만 제한된 제안으로 변환합니다.",
- files: ["src/services/memory/*", "src/services/routines/store.rs", "routines/monitoring/*.js"],
- sideEffects: "이 루틴은 원문 메모리 본문을 읽거나 쓰면 안 되며 다이제스트 절단을 검증해야 합니다.",
- verification: "추천기 다이제스트 픽스처를 실행하고 프롬프트에 원문 메모리 본문이 없는지 확인합니다.",
- },
- "api-friction": {
- suggestedAutomation: "반복되는 문서 또는 엔드포인트 워크플로 붕괴를 감지하는 API 마찰 모니터",
- before: "API 마찰이 에이전트 응답에서 반복되지만 통합 개선 제안으로 이어지지 않습니다.",
- after: "반복 마찰 마커가 지문별로 묶이고 문서 및 검증 가이드가 함께 제안됩니다.",
- files: ["src/services/api_friction.rs", "src/server/routes/*", "docs/*"],
- sideEffects: "문서 또는 API 라우팅이 바뀔 수 있으며 DB 직접 우회가 도입되지 않았는지 검증해야 합니다.",
- verification: "API 마찰 파싱 테스트와 대상 루틴 추천기 픽스처를 실행합니다.",
- },
- "kanban-flow": {
- suggestedAutomation: "정체되거나 차단된 칸반 흐름을 감지하고 다음 조치 후보를 생성하는 칸반 흐름 모니터",
- before: "오래 멈춘 카드와 차단 사유를 사람이 수동으로 훑어야 합니다.",
- after: "정체/차단 패턴이 에이전트별·상태별로 묶여 자동화 후보 카드로 올라옵니다.",
- files: ["src/server/routes/kanban.rs", "src/db/kanban_cards/*", "routines/monitoring/*.js"],
- sideEffects: "카드 상태 전이는 직접 수행하지 않고, 후보 카드와 검증 지시만 생성해야 합니다.",
- verification: "칸반 observation fixture와 executor ready-card 회귀 테스트를 실행합니다.",
- },
- "dispatch-retry": {
- suggestedAutomation: "반복 재시도되는 디스패치 흐름을 감지하는 dispatch retry monitor",
- before: "반복 재시도/실패 디스패치는 로그나 DB를 직접 확인해야 합니다.",
- after: "from/to agent와 status별 반복 재시도 패턴이 후보로 묶입니다.",
- files: ["src/server/routes/dispatches/*", "src/db/auto_queue/*", "policies/timeouts/*"],
- sideEffects: "재시도 정책은 중복 디스패치와 알림 폭증을 만들 수 있으므로 dry-run 검증이 필요합니다.",
- verification: "dispatch/outbox 관련 테스트와 retry fixture를 실행합니다.",
- },
- "session-pattern": {
- suggestedAutomation: "반복 에러가 발생하는 agent/session 패턴을 감지하고 개선 후보를 만드는 세션 패턴 모니터",
- before: "같은 agent 오류가 대화 로그에 흩어져 후속 개선으로 이어지기 어렵습니다.",
- after: "agent별 반복 오류가 bounded observation으로 점수화되어 개선 후보가 됩니다.",
- files: ["src/db/session_transcripts.rs", "src/services/discord/*", "routines/monitoring/*.js"],
- sideEffects: "세션 본문 원문을 후보에 과도하게 싣지 않고 집계와 최신 예시만 사용해야 합니다.",
- verification: "session_transcripts observation과 recommender ROI gate 테스트를 실행합니다.",
- },
- "log-signal": {
- suggestedAutomation: "audit_logs와 kanban_audit_logs 반복 패턴을 감지하는 운영 로그 모니터",
- before: "운영 로그의 반복 action/source 패턴은 analytics 화면에서만 보이고 자동화 후보로 이어지지 않습니다.",
- after: "반복 로그 패턴이 category별 후보로 묶이고 ROI gate에 포함됩니다.",
- files: ["src/services/analytics/*", "src/services/routines/store.rs", "src/kanban/audit.rs"],
- sideEffects: "로그는 읽기 전용 근거로만 사용하고 audit log write path는 변경하지 않아야 합니다.",
- verification: "store.rs observation 테스트와 recommender ROI gate 테스트를 실행합니다.",
- },
- "automation-candidate": {
- suggestedAutomation: "이미 준비된 automation-candidate 카드의 반복 실행 상태를 관리하는 후보 루프 모니터",
- before: "준비된 자동화 후보 카드가 executor까지 이어졌는지 사람이 확인해야 합니다.",
- after: "ready/done 후보 카드가 observation으로 들어와 executor/suppression 경로를 안정화합니다.",
- files: ["src/services/automation_candidate_materializer.rs", "src/services/routines/store.rs", "routines/monitoring/automation-candidate-executor.js"],
- sideEffects: "후보 실행은 allowed_write_paths와 iteration result API 계약 안에서만 진행해야 합니다.",
- verification: "automation-candidate executor tests와 materializer tests를 실행합니다.",
- },
- };
- const profile = categoryProfiles[category] || categoryProfiles["routine-candidate"];
- const recommendedExecution = category === "routine-candidate" && candidate.score < 90
- ? "rule"
- : "agent";
- const title = patternId.replace(/\s+/g, " ").slice(0, 96);
- const allowedWritePaths = candidateProgramAllowedPaths(profile.files);
- return {
- suggestedAutomation: profile.suggestedAutomation,
- recommendedExecution,
- outcomeSummary: buildOutcomeSummary(patternId, candidate, isErrorPattern, category),
- beforeAfter: {
- before: profile.before,
- after: profile.after,
- },
- expectedFiles: profile.files,
- expectedSideEffects: profile.sideEffects,
- verificationMethod: profile.verification,
- gatedHandoff: {
- status: "requires_human_approval",
- kanban_card_draft: {
- title: `[automation-candidate] ${title}`,
- category,
- acceptance: [
- "브랜치/카드 변경 전에 제안 승인이 완료되어야 합니다",
- "루틴은 제한적이고 멱등적으로 유지되어야 합니다",
- "검증 명령 또는 픽스처가 PR/카드에 기록되어야 합니다",
- ],
- },
- pr_draft: {
- title: `자동화 후보 구현: ${title}`,
- body_hint: "Before/After, 예상 파일, 부작용, 검증 근거를 포함합니다.",
- },
- side_effects: "사람이 게이트된 핸드오프를 명시적으로 승인하기 전까지는 없음",
- },
- materializeRequest: {
- endpoint: "POST /api/automation-candidates",
- body: {
- title: `[automation-candidate] ${title}`,
- source: "routine_recommender",
- dedupe_key: patternId,
- start_ready: false,
- program: {
- repo_dir: REPO_DIR_PLACEHOLDER,
- allowed_write_paths: allowedWritePaths,
- metric_name: candidateMetricName(category),
- metric_target: 0,
- metric_direction: "lower_is_better",
- final_gate: "manual_review",
- iteration_budget: 3,
- },
- },
- needs_human_fields: ["program.repo_dir"],
- },
- };
-}
-
-function candidateProgramAllowedPaths(files) {
- const out = [];
- for (const file of files || []) {
- let path = String(file || "").trim();
- if (!path || path.startsWith("/") || path.includes("..")) continue;
- const star = path.indexOf("*");
- if (star >= 0) path = path.slice(0, star);
- path = path.replace(/\/+$/, "");
- if (!path) continue;
- if (!out.includes(path)) out.push(path);
- }
- return out.length ? out : ["src"];
-}
-
-function candidateMetricName(category) {
- return {
- "routine-candidate": "manual_routine_touch_count",
- "release-freshness": "release_drift_count",
- "outbox-delivery": "outbox_delivery_failure_count",
- "memento-hygiene": "memento_hygiene_issue_count",
- "api-friction": "api_friction_count",
- "kanban-flow": "kanban_stuck_or_blocked_count",
- "dispatch-retry": "dispatch_retry_count",
- "session-pattern": "session_error_pattern_count",
- "log-signal": "audit_log_pattern_count",
- "automation-candidate": "automation_candidate_backlog_count",
- }[category] || "automation_friction_count";
-}
-
-function candidateDecisionSummary(patternId, candidate, assessment) {
- const score = candidate.score || 0;
- const delta = candidate.score_delta_last_tick || 0;
- const evidenceCount = candidate.evidence_count || 0;
- const gate = candidateGate(candidate);
- const evidenceSummary = topEvidenceSummaryForCandidate(candidate);
- return `선택 이유: ${patternId} 후보가 category=${normalizeCategory(candidate.category)}, score=${score}, delta=${delta}, evidence=${evidenceCount}, gate=${gate.minScore}/${gate.minEvidence}로 ROI 기준을 충족했습니다. ${candidateSourceSummary(candidate)}. ${assessment.outcomeSummary} 핵심 근거: ${evidenceSummary}`;
-}
-
-function convergenceSummary(candidate) {
- if (!candidate.last_recommended_at && !candidate.last_recommendation_hash) {
- return "이 후보는 이전 추천 이력이 없습니다. 현재 tick의 새 근거를 중심으로 평가합니다.";
- }
- const parts = [];
- if (candidate.last_recommended_at) {
- parts.push(`이전 추천 시각=${candidate.last_recommended_at}`);
- }
- if (candidate.last_recommendation_hash) {
- parts.push(`이전 추천 해시=${candidate.last_recommendation_hash}`);
- }
- if (candidate.cooldown_until) {
- parts.push(`쿨다운 종료=${candidate.cooldown_until}`);
- }
- return `이 후보는 이전 추천/체크포인트 이력이 있습니다. ${parts.join(", ")}`;
-}
-
-function buildPrompt(escalation) {
- const { patternId, candidate } = escalation;
- const evidenceLines = (candidate.examples || [])
- .map((ex, i) => `${i + 1}. [${ex.timestamp || "?"}] ${ex.summary || ""} (occurrences=${ex.occurrences || 1})`)
- .join("\n");
-
- const {
- suggestedAutomation,
- recommendedExecution,
- beforeAfter,
- expectedFiles,
- expectedSideEffects,
- verificationMethod,
- outcomeSummary,
- gatedHandoff,
- materializeRequest,
- } = candidateAssessment(patternId, candidate);
- const decisionSummary = candidateDecisionSummary(patternId, candidate, {
- outcomeSummary,
- });
- const handoffAcceptance = (gatedHandoff.kanban_card_draft.acceptance || [])
- .map((item) => `- ${item}`)
- .join("\n");
-
- const raw = `# 자동화 후보 추천
-
-패턴: ${patternId}
-카테고리: ${normalizeCategory(candidate.category)}
-점수: ${candidate.score}/100
-근거: ${candidate.evidence_count}회 발생 (최초: ${candidate.first_seen_at || "?"}, 최신: ${candidate.last_seen_at || "?"})
-
-## 근거 예시
-${evidenceLines || "(기록 없음)"}
-
-## 성공/실패 한 줄 요약
-${outcomeSummary}
-
-## 선택 판단 근거
-${decisionSummary}
-
-## 루트 기반 JS 자동화 패턴 탐지 가이드
-- 이 제안은 runtime이 제공한 bounded observation, checkpoint, automation inventory만 근거로 판단합니다.
-- 같은 증상이 아니라 같은 루트 원인 또는 같은 수동 작업이 반복되는지 pattern/category/count/first-last/example을 연결해 설명합니다.
-- 단순 빈도만 보지 말고 최근성, 실패 weight, 운영 ROI, 부작용, 이미 구현/억제된 자동화와의 중복 가능성을 함께 평가합니다.
-- 규칙으로 충분한 deterministic retry/check/threshold인지, 문맥 판단과 코드 변경 설계가 필요한 agent 주도 자동화인지 구분합니다.
-- 근거가 부족하거나 오탐 가능성이 높으면 자동화 보류로 결론을 내립니다.
-
-## 이전 작업/체크포인트 수렴 대응
-${convergenceSummary(candidate)}
-- 이전 추천, checkpoint, inventory를 보고 같은 결론에 수렴하더라도 "추가 개선 없음"으로만 끝내지 않습니다.
-- 반복 제안이 되지 않게 새 근거, 새 실패 양상, 새 검증 경로, 기존 자동화의 누락 지점 중 하나를 반드시 확인합니다.
-- 목표 달성을 위해 대체 탐색 경로를 최소 1개 제안합니다: observation 스키마 보강, 로그 수집 위치 변경, suppression/inventory 오탐 점검, rule 기반 작은 자동화, agent 주도 핸드오프 분리, 검증 metric 추가.
-- 여전히 보류라면 다음 tick에서 어떤 근거를 더 수집해야 하는지 한 줄로 남깁니다.
-
-## 이미 자동화됨 판단 기준
-- automation inventory, checkpoint suppression, candidate state 중 하나가 같은 pattern_id 또는 wildcard prefix와 매칭되어야 합니다.
-- status가 implemented/suppressed/rejected이면 이미 처리된 것으로 봅니다.
-- status가 accepted인 경우에는 automation_ref 또는 source_ref 같은 지속 증거(durable proof)가 있을 때만 이미 자동화된 것으로 봅니다.
-- 지속 증거가 없는 accepted 또는 단순 이전 추천 이력은 자동화 완료가 아니라 "이전 제안"으로만 봅니다.
-- 이름이 비슷한 정도로 중복 처리하지 말고 pattern/category/evidence_ref가 같은 루트 원인이나 같은 반복 수동 작업을 가리키는지 확인합니다.
-
-## 자료 범위 및 검색 정책
-- 기본 판단 근거는 PostgreSQL-backed routine observation/checkpoint/inventory, 로그 기반 observation, memento-mcp digest observation입니다.
-- 유저 프롬프트와 메모리는 runtime이 구조화 observation 또는 승인된 digest로 제공한 범위만 사용합니다.
-- 외부 웹자료 검색은 기본 동작이 아닙니다. 최신 외부 문서/버전 확인이 꼭 필요하면 opt-in evidence provider 필요성을 제안만 합니다.
-- 이 프롬프트 안에서 임의 웹 검색, 지속 크롤링, memento 쓰기, DB 직접 우회는 수행하지 않습니다.
-
-## Before / After
-- Before: ${beforeAfter.before}
-- After: ${beforeAfter.after}
-
-## 예상 구현 파일
-${expectedFiles.map((file) => `- ${file}`).join("\n")}
-
-## 판단
-- 제안 자동화: ${suggestedAutomation}
-- 권장 실행 방식: ${recommendedExecution} (규칙 기반 vs 에이전트 주도)
-- 예상 부작용: ${expectedSideEffects}
-
-## 검증 방법
-${verificationMethod}
-
-## 게이트된 핸드오프 초안
-- 상태: ${gatedHandoff.status}
-- Kanban 제목: ${gatedHandoff.kanban_card_draft.title}
-- PR 제목: ${gatedHandoff.pr_draft.title}
-- 핸드오프 부작용: ${gatedHandoff.side_effects}
-${handoffAcceptance}
-
-## 자동화 후보 카드 생성 초안
-아래 JSON은 탐지기(detector)가 자동으로 카드를 생성할 때 사용합니다.
-\`program.repo_dir\`에 현재 워크스페이스의 실제 절대 경로를 채워 kv_meta를 기록해주세요.
-${JSON.stringify(materializeRequest, null, 2)}
-
-## 지시사항
-에이전트가 도출한 내용은 반드시 한국어로 작성합니다. 이 자동화를 구현할 가치가 있는지 평가하고 다음을 제공합니다:
-1. 자동화 여부(예 / 아니오 / 보류), 신뢰도, 그리고 이유
-2. 루트 원인 또는 반복 수동 작업 가설과 그 근거
-3. 구현한다면 제안 구현 방식, rule-vs-agent 선택 이유, 영향 파일/루틴
-4. 성공/실패에 대한 한 줄 요약
-5. 예상 부작용, 오탐/중복 억제 방법, 자동화 동작 검증 방법
-6. 이전 추천과 같은 결론이면 다른 탐색/진행 방식 또는 추가로 수집할 근거
-
-## kv_meta 기록 지침
-
-평가 완료 후 **자동화 가치가 있다(예 / 부분 예)** 고 판단한 경우에만 아래를 실행합니다.
-
-**key** (TTL: 48h):
-\`routine_observation:candidate_review:${patternId}\`
-
-**value** (JSON):
-\`\`\`json
-{
- "score": ${candidate.score},
- "evidence_count": ${candidate.evidence_count || 0},
- "last_seen_at": "${candidate.last_seen_at || ""}",
- "category": "${normalizeCategory(candidate.category)}",
- "suggested_automation": "<평가에서 도출한 제안 자동화 내용>",
- "outcome_summary": "<평가에서 도출한 한 줄 결과 요약>",
- "materialize_request": <위 '자동화 후보 카드 생성 초안' JSON에서 program.repo_dir을 실제 절대 경로로 채운 버전>
-}
-\`\`\`
-
-\`program.repo_dir\`는 현재 워크스페이스에서 확인한 agentdesk 리포지터리 절대 경로로 채웁니다.
-자동화 가치가 없거나 보류라면 kv_meta를 기록하지 않습니다.
-구현, 파일 수정, 서비스 재시작, memento 쓰기, PR/카드/이슈 생성은 금지합니다.`;
-
- if (utf8ByteLength(raw) <= PROMPT_CAP_BYTES) {
- return raw;
- }
-
- // Trim examples first while preserving policy and decision sections.
- const evidenceHeader = "## 근거 예시\n";
- const [header, restWithEvidence] = raw.split(evidenceHeader);
- const evidenceBlock = evidenceLines || "(기록 없음)";
- const rest = restWithEvidence.slice(evidenceBlock.length);
- const budget =
- PROMPT_CAP_BYTES -
- utf8ByteLength(header) -
- utf8ByteLength(evidenceHeader) -
- utf8ByteLength(rest) -
- 20;
- const trimmedEvidence =
- budget > 32
- ? truncateUtf8(evidenceBlock, budget)
- : "(근거 예시는 크기 제한으로 생략됨)";
- return header + evidenceHeader + trimmedEvidence + rest;
-}
-
-// --- Checkpoint size guard ---
-
-function guardCheckpointSize(cp) {
- const json = JSON.stringify(cp);
- if (json.length <= CHECKPOINT_CAP_BYTES) return cp;
-
- // Prune least-recently-observed candidates first.
- const entries = Object.entries(cp.candidates).sort(
- ([, a], [, b]) => String(a.last_seen_at || "").localeCompare(String(b.last_seen_at || ""))
- );
- let pruned = 0;
- for (const [patternId] of entries) {
- if (JSON.stringify(cp).length <= CHECKPOINT_CAP_BYTES) break;
- if (
- cp.candidates[patternId].state === "observing"
- ) {
- delete cp.candidates[patternId];
- pruned++;
- }
- }
-
- // Trim examples on remaining candidates
- for (const candidate of Object.values(cp.candidates)) {
- if (candidate.examples && candidate.examples.length > 1) {
- candidate.examples = candidate.examples.slice(-1);
- }
- }
-
- return cp;
-}
-
-// --- Main tick ---
-
-agentdesk.routines.register({
- name: "Automation Candidate Recommender",
-
- tick(ctx) {
- const nowStr = nowIso(ctx.now);
- const cp = loadCheckpoint(ctx.checkpoint);
- const observations = ctx.observations || [];
- const inventory = ctx.automationInventory || [];
-
- resetDailyCapIfNeeded(cp, nowStr);
- pruneExpiredSuppressions(cp, nowStr);
- pruneSeenEvidence(cp, nowStr);
- expireStaleCandidates(cp, nowStr);
-
- const suppressedSet = buildSuppressedSet(cp, inventory, observations);
- const droppedCandidates = dropSuppressedCandidates(cp, suppressedSet);
- const diversityMode = cp.diversity_mode_ticks_remaining > 0;
- const scoringReport = scoreObservations(cp, observations, suppressedSet, nowStr, diversityMode);
-
- // Update category scored history for diversity mode (keep last DIVERSITY_LOOKBACK_TICKS entries)
- cp.stats.category_scored_history.push(scoringReport.category_scored_this_tick);
- if (cp.stats.category_scored_history.length > DIVERSITY_LOOKBACK_TICKS) {
- cp.stats.category_scored_history.shift();
- }
-
- // P0-E: saturation detection & re-optimization
- updateEmaScored(cp, scoringReport.scored);
- updateFastFailTicks(cp, scoringReport, observations.length);
- updateSaturationTicks(cp);
- const reoptCheck = shouldTriggerReopt(cp);
- if (reoptCheck.trigger) {
- triggerReopt(cp, nowStr);
- } else if (cp.diversity_mode_ticks_remaining > 0) {
- cp.diversity_mode_ticks_remaining--;
- }
-
- cp.stats.ticks++;
- cp.last_tick_at = nowStr;
-
- const escalation = findEscalationCandidate(cp, nowStr);
-
- if (!escalation) {
- const activeCandidates = Object.values(cp.candidates).filter(
- (c) => c.state === "observing" || c.state === "recommended"
- ).length;
- const summary = `관찰=${observations.length}, 후보=${activeCandidates}, 오늘 추천=${cp.stats.recommendations_today}`;
- const outcomeSummary = `성공 요약: 새 자동화 추천 후보 없음 (${summary})`;
- const decisionSummary = noEscalationReason(cp, nowStr);
- const topEvidenceSummary = topCandidateEvidenceSummary(cp);
- const suppressedSummary = suppressionSummary(scoringReport.suppressed, droppedCandidates);
- const scoringSummary = [
- `scored=${scoringReport.scored}`,
- `deduped=${scoringReport.deduped}`,
- `suppressed=${scoringReport.suppressed.length + droppedCandidates.length}`,
- `ema_scored=${cp.ema_scored.toFixed(3)}`,
- `saturation_ticks=${cp.saturation_ticks}`,
- `fast_fail_ticks=${cp.fast_fail_ticks}`,
- `reopt_count=${cp.reopt_count}`,
- diversityMode ? `diversity_mode_remaining=${cp.diversity_mode_ticks_remaining}` : null,
- reoptCheck.trigger ? `reopt_triggered=${reoptCheck.reason}` : null,
- ].filter(Boolean).join(", ");
- return {
- action: "complete",
- result: {
- status: "ok",
- summary,
- outcome_summary: outcomeSummary,
- decision_summary: decisionSummary,
- top_evidence_summary: topEvidenceSummary,
- suppression_summary: suppressedSummary,
- scoring_summary: scoringSummary,
- observation_count: observations.length,
- active_candidate_count: activeCandidates,
- recommendations_today: cp.stats.recommendations_today,
- reopt_count: cp.reopt_count,
- },
- checkpoint: guardCheckpointSize(cp),
- lastResult: outcomeSummary,
- };
- }
-
- const prompt = buildPrompt(escalation);
- markRecommended(cp, escalation, nowStr);
-
- return {
- action: "agent",
- prompt,
- checkpoint: guardCheckpointSize(cp),
- };
- },
-});
diff --git a/routines/monitoring/automation-executor.js b/routines/monitoring/automation-executor.js
deleted file mode 100644
index a5edd991d0..0000000000
--- a/routines/monitoring/automation-executor.js
+++ /dev/null
@@ -1,242 +0,0 @@
-// Automation Executor
-// Reads candidate_approved:* observations and dispatches GitHub Issue + Kanban card
-// creation prompts to the agent. Dedup via candidate_dispatched:* observations and
-// checkpoint dispatched_signatures. A signature is marked dispatched only after
-// durable candidate_dispatched kv_meta is observed.
-
-const DISPATCHED_TTL_DAYS = 7;
-const DISPATCH_RETRY_MS = 60 * 60 * 1000; // re-emit at most once per hour per candidate
-const MAX_DISPATCH_RETRIES = 5; // give up and mark stalled after this many no-shows
-const CHECKPOINT_VERSION = 1;
-
-// --- Checkpoint helpers ---
-
-function emptyCheckpoint() {
- return {
- version: CHECKPOINT_VERSION,
- dispatched_signatures: {}, // signature -> dispatched_at ISO string (TTL 7d)
- pending_dispatches: {}, // signature -> { attempt_count, last_attempted_at, first_attempted_at }
- stats: {
- ticks: 0,
- dispatched: 0,
- skipped_already_dispatched: 0,
- stalled_candidates: 0,
- },
- };
-}
-
-function loadCheckpoint(raw) {
- if (!raw || typeof raw !== "object" || raw.version !== CHECKPOINT_VERSION) {
- return emptyCheckpoint();
- }
- const cp = Object.assign(emptyCheckpoint(), raw);
- cp.dispatched_signatures = raw.dispatched_signatures || {};
- cp.pending_dispatches = raw.pending_dispatches || {};
- cp.stats = Object.assign(emptyCheckpoint().stats, raw.stats || {});
- return cp;
-}
-
-function nowIso(now) {
- return typeof now === "string" ? now : now.toISOString ? now.toISOString() : String(now);
-}
-
-function validIso(value) {
- const timestamp = new Date(value || "").getTime();
- return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
-}
-
-function observationKey(obs) {
- if (typeof obs.key === "string") return obs.key;
- if (typeof obs.evidence_ref === "string") {
- if (obs.evidence_ref.startsWith("kv_meta:routine_observation:")) {
- return obs.evidence_ref.slice("kv_meta:".length);
- }
- if (obs.evidence_ref.startsWith("routine_observation:")) {
- return obs.evidence_ref;
- }
- }
- return "";
-}
-
-function observationSignature(obs, prefix) {
- const key = observationKey(obs);
- return key.startsWith(prefix) ? key.slice(prefix.length) : null;
-}
-
-function observationPayload(obs) {
- return obs.value && typeof obs.value === "object" ? obs.value : obs;
-}
-
-function isRecentIso(value, nowStr, maxAgeMs) {
- const timestamp = new Date(value || "").getTime();
- if (!Number.isFinite(timestamp)) return false;
- return new Date(nowStr).getTime() - timestamp < maxAgeMs;
-}
-
-function observationDispatchedAt(obs, fallback) {
- const payload = observationPayload(obs);
- return (
- validIso(payload.dispatched_at) ||
- validIso(payload.timestamp) ||
- validIso(obs.timestamp) ||
- fallback
- );
-}
-
-// --- Prune expired dispatched_signatures ---
-
-function pruneDispatched(cp, nowStr) {
- const cutoffMs = DISPATCHED_TTL_DAYS * 24 * 3600 * 1000;
- const cutoff = new Date(nowStr).getTime() - cutoffMs;
- for (const [sig, dispatchedAt] of Object.entries(cp.dispatched_signatures)) {
- if (new Date(dispatchedAt).getTime() < cutoff) {
- delete cp.dispatched_signatures[sig];
- }
- }
- // Prune stale pending entries (same TTL as dispatched)
- for (const [sig, entry] of Object.entries(cp.pending_dispatches)) {
- if (new Date(entry.first_attempted_at || 0).getTime() < cutoff) {
- delete cp.pending_dispatches[sig];
- }
- }
-}
-
-// --- Build dispatch prompt ---
-
-function buildDispatchPrompt(signature, candidate) {
- const lines = [
- "승인된 자동화 후보에 대한 GitHub Issue 및 Kanban 카드 생성을 요청합니다.",
- "",
- `**후보 ID (signature)**: \`${signature}\``,
- `**카테고리**: ${candidate.category || "routine-candidate"}`,
- `**점수**: ${candidate.score || 0}`,
- `**승인 시각**: ${candidate.approved_at || "(알 수 없음)"}`,
- `**제안된 자동화**: ${candidate.suggested_automation || "(없음)"}`,
- `**결과 요약**: ${candidate.outcome_summary || "(없음)"}`,
- "",
- "---",
- "## 작업 지침",
- "",
- "1. **GitHub Issue 생성**: `kunkunGames/agentdesk` 저장소에 이슈를 생성해주세요.",
- " - 제목: `[자동화 후보] " + (candidate.category || "routine-candidate") + ": " + signature + "`",
- " - 레이블: `automation-candidate`",
- " - 본문: 위 후보 정보 포함",
- "",
- "2. **Kanban 카드 생성**: kanban-writer 스킬을 사용해 카드를 생성해주세요.",
- "",
- "3. **완료 후 kv_meta 기록**:",
- "```",
- `routine_observation:candidate_dispatched:${signature}`,
- "```",
- "값(JSON): `{\"signature\":\"" + signature + "\",\"dispatched_at\":\"<현재시각ISO>\",\"category\":\"" + (candidate.category || "routine-candidate") + "\"}`",
- "TTL: 7d (604800초)",
- "",
- "이 kv_meta 기록은 executor 중복 방지 및 recommender 재추천 억제에 사용됩니다.",
- ];
- return lines.join("\n");
-}
-
-// --- Main tick ---
-
-agentdesk.routines.register({
- name: "Automation Executor",
-
- tick(ctx) {
- const nowStr = nowIso(ctx.now);
- const cp = loadCheckpoint(ctx.checkpoint);
- const observations = ctx.observations || [];
-
- pruneDispatched(cp, nowStr);
-
- // Find candidate_approved observations
- const approvedPrefix = "routine_observation:candidate_approved:";
- const dispatchedPrefix = "routine_observation:candidate_dispatched:";
- const approvedObs = observations.filter((obs) => observationSignature(obs, approvedPrefix));
-
- // Find already-dispatched signatures (from kv_meta observations + checkpoint)
- const dispatchedFromObs = new Map();
- for (const obs of observations) {
- const signature = observationSignature(obs, dispatchedPrefix);
- if (signature) {
- dispatchedFromObs.set(signature, observationDispatchedAt(obs, nowStr));
- }
- }
- for (const [signature, dispatchedAt] of dispatchedFromObs.entries()) {
- const current = cp.dispatched_signatures[signature];
- if (!current || new Date(dispatchedAt).getTime() < new Date(current).getTime()) {
- cp.dispatched_signatures[signature] = dispatchedAt;
- }
- }
-
- cp.stats.ticks++;
-
- if (approvedObs.length === 0) {
- return {
- action: "complete",
- result: {
- status: "ok",
- summary: "승인된 후보 없음",
- approved_count: 0,
- },
- checkpoint: cp,
- };
- }
-
- // Filter to candidates not yet dispatched
- const toDispatch = [];
- for (const obs of approvedObs) {
- const signature = observationSignature(obs, approvedPrefix);
- const candidate = observationPayload(obs);
-
- if (dispatchedFromObs.has(signature) || cp.dispatched_signatures[signature]) {
- cp.stats.skipped_already_dispatched++;
- continue;
- }
-
- // Give up if LLM has repeatedly failed to write the dispatched marker.
- const pending = cp.pending_dispatches[signature];
- if (pending && (pending.attempt_count || 0) >= MAX_DISPATCH_RETRIES) {
- cp.stats.stalled_candidates = (cp.stats.stalled_candidates || 0) + 1;
- continue;
- }
- // Throttle: don't re-emit within cooldown window.
- if (pending && isRecentIso(pending.last_attempted_at, nowStr, DISPATCH_RETRY_MS)) {
- cp.stats.skipped_already_dispatched++;
- continue;
- }
-
- toDispatch.push({ signature, candidate });
- }
-
- if (toDispatch.length === 0) {
- return {
- action: "complete",
- result: {
- status: "ok",
- summary: `승인 후보 ${approvedObs.length}건 모두 이미 처리됨`,
- approved_count: approvedObs.length,
- skipped: cp.stats.skipped_already_dispatched,
- },
- checkpoint: cp,
- };
- }
-
- // Dispatch first pending candidate; remaining handled on next ticks
- const { signature, candidate } = toDispatch[0];
- const prevPending = cp.pending_dispatches[signature];
- cp.pending_dispatches[signature] = {
- first_attempted_at: prevPending?.first_attempted_at || nowStr,
- last_attempted_at: nowStr,
- attempt_count: (prevPending?.attempt_count || 0) + 1,
- };
- cp.stats.dispatched++;
-
- const prompt = buildDispatchPrompt(signature, candidate);
-
- return {
- action: "agent",
- prompt,
- checkpoint: cp,
- };
- },
-});
diff --git a/routines/monitoring/daily-log-digest.js b/routines/monitoring/daily-log-digest.js
deleted file mode 100644
index b7f9b9c191..0000000000
--- a/routines/monitoring/daily-log-digest.js
+++ /dev/null
@@ -1,71 +0,0 @@
-// Daily dcserver Log Digest (#4263)
-//
-// QuickJS routines intentionally have no filesystem/network bridge. Match the
-// existing monitoring frame by dispatching one fresh agent turn; the agent runs
-// the deterministic sibling helper and its final response is posted through the
-// routine Discord logger.
-
-const CHECKPOINT_VERSION = 1;
-
-function dayKey(now) {
- const value = typeof now === "string" ? now : now.toISOString();
- const kst = new Date(new Date(value).getTime() + 9 * 60 * 60 * 1000);
- return kst.toISOString().slice(0, 10);
-}
-
-function loadCheckpoint(raw) {
- if (!raw || raw.version !== CHECKPOINT_VERSION) {
- return { version: CHECKPOINT_VERSION, last_dispatched_day: null };
- }
- return {
- version: CHECKPOINT_VERSION,
- last_dispatched_day: raw.last_dispatched_day || null,
- };
-}
-
-function buildPrompt(day) {
- return [
- "# Daily dcserver log digest",
- "",
- `Digest day: ${day}`,
- "",
- "Run the repository-bundled deterministic helper:",
- "```bash",
- 'ROOT="${AGENTDESK_ROOT_DIR:-${ADK_REL:-$HOME/.adk/release}}"',
- 'python3 "$ROOT/routines/monitoring/daily_log_digest.py"',
- "```",
- "",
- "Return the helper stdout verbatim as your final response, with no preface or follow-up.",
- "Do not call `gh issue create` directly. The helper writes pending drafts and its shared",
- "gate permits posting only when a human has explicitly set",
- "`AGENTDESK_LOG_DIGEST_CREATE_ISSUE=confirmed` and marked that specific draft",
- "with an adjacent `.approved` file; the default is `off`.",
- ].join("\n");
-}
-
-agentdesk.routines.register({
- name: "Daily dcserver Log Digest",
-
- tick(ctx) {
- const day = dayKey(ctx.now);
- const checkpoint = loadCheckpoint(ctx.checkpoint);
- if (checkpoint.last_dispatched_day === day) {
- return {
- action: "complete",
- result: {
- status: "already_dispatched",
- summary: `daily log digest already dispatched for ${day}`,
- },
- checkpoint,
- };
- }
-
- checkpoint.last_dispatched_day = day;
- return {
- action: "agent",
- prompt: buildPrompt(day),
- lastResult: `daily log digest dispatched for ${day}`,
- checkpoint,
- };
- },
-});
diff --git a/routines/monitoring/daily_log_digest.py b/routines/monitoring/daily_log_digest.py
deleted file mode 100644
index f3def1fbf5..0000000000
--- a/routines/monitoring/daily_log_digest.py
+++ /dev/null
@@ -1,393 +0,0 @@
-#!/usr/bin/env python3
-"""Aggregate the last day of dcserver logs and emit one human-review digest."""
-
-from __future__ import annotations
-
-import argparse
-import hashlib
-import json
-import os
-import re
-import subprocess
-from datetime import datetime, timedelta, timezone
-from pathlib import Path
-from typing import BinaryIO, Iterable
-
-from log_digest_issue_drafts import (
- CONFIRMED_APPROVAL,
- DEFAULT_DAILY_THRESHOLD,
- IssueDraft,
- OpenIssue,
- aggregate_normalized_signatures,
- decide_issue_drafts,
- extract_severity,
- format_daily_summary,
- maybe_post_approved_drafts,
- write_pending_drafts,
-)
-
-
-REPOSITORY = "itismyfield/AgentDesk"
-OPEN_ISSUE_LIMIT = 1000
-UNDATED_CHECKPOINT_VERSION = 3
-UNDATED_HEAD_FINGERPRINT_CAP = 65_536
-_LINE_TIMESTAMP_RE = re.compile(
- r"(? Path:
- """Resolve the runtime root used by the release launcher."""
-
- # src/config.rs owns AGENTDESK_ROOT_DIR as the canonical runtime override.
- # ADK_REL remains a compatibility fallback because deploy-release.sh derives
- # it from that override and the routine launcher may pass only ADK_REL.
- configured = os.environ.get("AGENTDESK_ROOT_DIR") or os.environ.get("ADK_REL")
- if configured:
- return Path(configured).expanduser()
- return Path.home() / ".adk" / "release"
-
-
-def dcserver_log_paths(root: Path) -> list[Path]:
- """Return internal stdout rotations plus the actual launchd stderr path."""
-
- logs = root / "logs"
- stdout = logs / "dcserver.stdout.log"
- paths = [stdout]
- paths.extend(logs / f"dcserver.stdout.log.{index}" for index in range(1, 11))
- paths.append(logs / "dcserver.launchd.stderr.log")
- return paths
-
-
-def _parse_line_timestamp(line: str) -> datetime | None:
- match = _LINE_TIMESTAMP_RE.search(line)
- if not match:
- return None
- value = match.group(1).replace(",", ".")
- if value.endswith("Z"):
- value = value[:-1] + "+00:00"
- try:
- parsed = datetime.fromisoformat(value)
- except ValueError:
- return None
- if parsed.tzinfo is None:
- parsed = parsed.replace(tzinfo=timezone.utc)
- return parsed.astimezone(timezone.utc)
-
-
-def _load_undated_offsets(
- checkpoint_path: Path | None,
-) -> tuple[dict[str, dict[str, int | str]], list[str]]:
- if checkpoint_path is None or not checkpoint_path.is_file():
- return {}, []
- try:
- payload = json.loads(checkpoint_path.read_text(encoding="utf-8"))
- if not isinstance(payload, dict):
- raise ValueError("unsupported checkpoint shape")
- files = payload.get("files")
- if payload.get("version") != UNDATED_CHECKPOINT_VERSION or not isinstance(files, dict):
- raise ValueError("unsupported checkpoint shape")
- offsets: dict[str, dict[str, int | str]] = {}
- for path, entry in files.items():
- if not isinstance(path, str) or not isinstance(entry, dict):
- raise ValueError("invalid checkpoint entry")
- offsets[path] = {
- "device": int(entry["device"]),
- "inode": int(entry["inode"]),
- "offset": int(entry["offset"]),
- "head_hash": str(entry["head_hash"]),
- "head_length": int(entry["head_length"]),
- }
- return offsets, []
- except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
- return {}, [f"could not load undated-line checkpoint {checkpoint_path}: {error}"]
-
-
-def _save_undated_offsets(
- checkpoint_path: Path | None, offsets: dict[str, dict[str, int | str]]
-) -> list[str]:
- if checkpoint_path is None:
- return []
- try:
- checkpoint_path.parent.mkdir(parents=True, exist_ok=True)
- temporary = checkpoint_path.with_suffix(checkpoint_path.suffix + ".tmp")
- temporary.write_text(
- json.dumps(
- {"version": UNDATED_CHECKPOINT_VERSION, "files": offsets},
- sort_keys=True,
- )
- + "\n",
- encoding="utf-8",
- )
- temporary.replace(checkpoint_path)
- except OSError as error:
- return [f"could not save undated-line checkpoint {checkpoint_path}: {error}"]
- return []
-
-
-def _head_fingerprint(stream: BinaryIO, offset: int) -> tuple[int, str]:
- head_length = min(offset, UNDATED_HEAD_FINGERPRINT_CAP)
- stream.seek(0)
- digest = hashlib.sha256(stream.read(head_length)).hexdigest()
- return head_length, digest
-
-
-def _watermark_matches(stream: BinaryIO, previous: dict[str, int | str]) -> bool:
- offset = int(previous["offset"])
- head_length = int(previous["head_length"])
- if head_length != min(offset, UNDATED_HEAD_FINGERPRINT_CAP):
- return False
- stream.seek(0)
- current = hashlib.sha256(stream.read(head_length)).hexdigest()
- return current == previous["head_hash"]
-
-
-def recent_log_lines(
- paths: Iterable[Path],
- since: datetime,
- now: datetime,
- *,
- undated_checkpoint: Path | None = None,
-) -> tuple[list[str], list[str]]:
- """Read the 24h window, baselining unseen undated launchd files at EOF."""
-
- lines: list[str] = []
- undated_offsets, warnings = _load_undated_offsets(undated_checkpoint)
- found_any = False
- for path in paths:
- if not path.is_file():
- continue
- found_any = True
- try:
- stat = path.stat()
- include_undated = (
- path.name == "dcserver.launchd.stderr.log"
- and datetime.fromtimestamp(stat.st_mtime, timezone.utc) >= since
- )
- checkpoint_key = str(path.resolve())
- previous = undated_offsets.get(checkpoint_key)
- with path.open("rb") as stream:
- previous_offset = 0
- if previous is None and undated_checkpoint is not None:
- # The first persisted observation establishes a watermark;
- # pre-existing undated history has no reliable 24h timestamp.
- previous_offset = stat.st_size
- elif (
- previous is not None
- and previous["device"] == stat.st_dev
- and previous["inode"] == stat.st_ino
- and 0 <= int(previous["offset"]) <= stat.st_size
- and _watermark_matches(stream, previous)
- ):
- previous_offset = int(previous["offset"])
- stream.seek(0)
- while raw_bytes := stream.readline():
- line_end = stream.tell()
- raw_line = raw_bytes.decode("utf-8", errors="replace")
- if extract_severity(raw_line) is None:
- continue
- line = raw_line.rstrip("\n")
- timestamp = _parse_line_timestamp(line)
- if timestamp is not None:
- if since <= timestamp <= now + timedelta(minutes=5):
- lines.append(line)
- elif include_undated and line_end > previous_offset:
- # Identity, offset, and head fingerprint make appended
- # ranges eligible once. Rotation or detected rewrite
- # restarts at byte zero; first observation baselines EOF.
- lines.append(line)
- if path.name == "dcserver.launchd.stderr.log":
- final_offset = stream.tell()
- # A rewrite beyond the cap that reproduces the first 64 KiB
- # cannot be distinguished from an append. For append-only
- # launchd logs, reproducing that prefix (or a SHA collision)
- # after truncate/regrow is considered operationally negligible.
- head_length, head_hash = _head_fingerprint(stream, final_offset)
- undated_offsets[checkpoint_key] = {
- "device": stat.st_dev,
- "inode": stat.st_ino,
- "offset": final_offset,
- "head_length": head_length,
- "head_hash": head_hash,
- }
- except OSError as error:
- warnings.append(f"could not read {path}: {error}")
- if not found_any:
- warnings.append("no dcserver stdout or launchd stderr log files were found")
- warnings.extend(_save_undated_offsets(undated_checkpoint, undated_offsets))
- return lines, warnings
-
-
-def load_open_issues(repo: str) -> tuple[list[OpenIssue], str | None]:
- command = [
- "gh",
- "issue",
- "list",
- "--repo",
- repo,
- "--state",
- "open",
- "--limit",
- str(OPEN_ISSUE_LIMIT),
- "--json",
- "number,title,body,url",
- ]
- try:
- completed = subprocess.run(command, check=False, capture_output=True, text=True, timeout=30)
- except (OSError, subprocess.TimeoutExpired) as error:
- return [], f"open-issue dedup unavailable ({error}); drafts suppressed"
- if completed.returncode != 0:
- detail = completed.stderr.strip() or f"gh exited {completed.returncode}"
- return [], f"open-issue dedup unavailable ({detail}); drafts suppressed"
- try:
- payload = json.loads(completed.stdout)
- issues = [
- OpenIssue(
- number=int(item["number"]),
- title=str(item.get("title") or ""),
- body=str(item.get("body") or ""),
- url=str(item.get("url") or ""),
- )
- for item in payload
- ]
- except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
- return [], f"open-issue dedup response invalid ({error}); drafts suppressed"
- if len(issues) == OPEN_ISSUE_LIMIT:
- return (
- issues,
- f"open-issue dedup may be truncated at {OPEN_ISSUE_LIMIT} results; drafts suppressed",
- )
- return issues, None
-
-
-def create_github_issue(repo: str, draft: IssueDraft) -> str:
- if draft.path is None:
- raise ValueError("approved issue draft must be written before posting")
- completed = subprocess.run(
- [
- "gh",
- "issue",
- "create",
- "--repo",
- repo,
- "--title",
- draft.title,
- "--body-file",
- str(draft.path),
- ],
- check=True,
- capture_output=True,
- text=True,
- timeout=30,
- )
- return completed.stdout.strip()
-
-
-def positive_int(value: str) -> int:
- parsed = int(value)
- if parsed <= 0:
- raise argparse.ArgumentTypeError("must be greater than zero")
- return parsed
-
-
-def threshold_from_env(value: str | None) -> tuple[int, str | None]:
- configured = value if value is not None else str(DEFAULT_DAILY_THRESHOLD)
- try:
- return positive_int(configured), None
- except (ValueError, argparse.ArgumentTypeError):
- return (
- DEFAULT_DAILY_THRESHOLD,
- "invalid AGENTDESK_LOG_DIGEST_THRESHOLD "
- f"value {configured!r}; using default {DEFAULT_DAILY_THRESHOLD}",
- )
-
-
-def parse_args() -> argparse.Namespace:
- env_threshold, threshold_warning = threshold_from_env(
- os.environ.get("AGENTDESK_LOG_DIGEST_THRESHOLD")
- )
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("--root", type=Path, default=runtime_root())
- parser.add_argument("--repo", default=os.environ.get("AGENTDESK_LOG_DIGEST_REPO", REPOSITORY))
- parser.add_argument(
- "--threshold",
- type=positive_int,
- default=None,
- )
- parser.add_argument("--now", help="RFC3339 test/diagnostic override")
- args = parser.parse_args()
- if args.threshold is None:
- args.threshold = env_threshold
- args.threshold_warning = threshold_warning
- else:
- args.threshold_warning = None
- return args
-
-
-def main() -> int:
- args = parse_args()
- now = datetime.fromisoformat(args.now.replace("Z", "+00:00")) if args.now else datetime.now(timezone.utc)
- if now.tzinfo is None:
- now = now.replace(tzinfo=timezone.utc)
- now = now.astimezone(timezone.utc)
- since = now - timedelta(days=1)
- window_label = f"{since:%Y-%m-%d %H:%M}–{now:%Y-%m-%d %H:%M} UTC"
-
- lines, warnings = recent_log_lines(
- dcserver_log_paths(args.root),
- since,
- now,
- undated_checkpoint=args.root
- / "runtime"
- / "daily-log-digest"
- / "undated-line-offsets.json",
- )
- if args.threshold_warning:
- warnings.append(args.threshold_warning)
- patterns = aggregate_normalized_signatures(lines)
- open_issues, dedup_warning = load_open_issues(args.repo)
- if dedup_warning:
- warnings.append(dedup_warning)
- decisions = decide_issue_drafts(
- patterns,
- open_issues,
- threshold=args.threshold,
- window_label=window_label,
- dedup_available=dedup_warning is None,
- )
- pending_dir = args.root / "runtime" / "pending-issue-drafts" / "daily-log-digest"
- drafts = write_pending_drafts(
- [decision.draft for decision in decisions if decision.draft is not None],
- pending_dir,
- )
-
- approval_mode = os.environ.get("AGENTDESK_LOG_DIGEST_CREATE_ISSUE", "off")
- post = maybe_post_approved_drafts(
- drafts,
- approval_mode,
- lambda draft: create_github_issue(args.repo, draft),
- )
- if approval_mode not in {"off", CONFIRMED_APPROVAL}:
- warnings.append(
- "invalid AGENTDESK_LOG_DIGEST_CREATE_ISSUE value ignored; use literal 'confirmed' or 'off'"
- )
- elif approval_mode == CONFIRMED_APPROVAL and not post.attempted:
- warnings.append(post.reason)
- if post.created_urls:
- warnings.append("human-confirmed issues created: " + ", ".join(post.created_urls))
-
- print(
- format_daily_summary(
- patterns,
- decisions,
- drafts,
- threshold=args.threshold,
- window_label=window_label,
- warnings=warnings,
- )
- )
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/routines/monitoring/local_worktree_inventory.js b/routines/monitoring/local_worktree_inventory.js
deleted file mode 100644
index f414967f7e..0000000000
--- a/routines/monitoring/local_worktree_inventory.js
+++ /dev/null
@@ -1,341 +0,0 @@
-// Local agent worktree inventory helper (#4684)
-//
-// Deterministic, READ-ONLY inventory of `.claude/worktrees/agent-*` git
-// worktrees. QuickJS routines have no filesystem bridge, so the scheduled
-// routine (`routines/local-worktree-gc.js`) dispatches one agent turn that runs
-// this Node helper and returns its JSON stdout verbatim.
-//
-// SAFETY BY CONSTRUCTION: this module performs zero destructive operations. It
-// never removes worktrees, prunes refs, deletes branches, or unlinks files.
-// Every child-process call is a read-only git/du subcommand. There is no code
-// path that deletes anything — "never deletes" is provable by inspection, not by
-// trusting an LLM to obey natural-language instructions. Disposition labels are
-// advisory classifications for a human/future prune step; this helper only reads
-// and emits a schema-validated report. The #4595 lesson is enforced here:
-// dirty, locked, unmerged, and unknown worktrees are always marked PRESERVE, so
-// the exact uncommitted work a naive GC could destroy is protected.
-
-const fs = require("node:fs");
-const path = require("node:path");
-const { execFileSync } = require("node:child_process");
-
-const SCHEMA_VERSION = 1;
-const DEFAULT_AGED_ORPHAN_SECONDS = 7 * 24 * 60 * 60; // 7 days (issue proposal #2)
-const AGENT_WORKTREE_PREFIX = "agent-";
-
-// --- Read-only signal collectors (dependency-injected for tests) ---
-
-// Parse `git worktree list --porcelain` into { : {head, branch, locked, bare} }.
-function parseWorktreeList(porcelain) {
- const registered = {};
- let current = null;
- for (const rawLine of String(porcelain).split("\n")) {
- const line = rawLine.trimEnd();
- if (line.startsWith("worktree ")) {
- current = { path: line.slice("worktree ".length), head: null, branch: null, locked: false };
- registered[current.path] = current;
- } else if (!current) {
- continue;
- } else if (line.startsWith("HEAD ")) {
- current.head = line.slice("HEAD ".length);
- } else if (line.startsWith("branch ")) {
- current.branch = line.slice("branch ".length);
- } else if (line === "locked" || line.startsWith("locked ")) {
- current.locked = true;
- } else if (line === "") {
- current = null;
- }
- }
- return registered;
-}
-
-function defaultDeps(repoDir) {
- const gitRO = (args) =>
- execFileSync("git", ["-C", repoDir, ...args], {
- encoding: "utf8",
- stdio: ["ignore", "pipe", "pipe"],
- maxBuffer: 32 * 1024 * 1024,
- });
- return {
- // Read-only: list registered worktrees.
- worktreeList: () => parseWorktreeList(gitRO(["worktree", "list", "--porcelain"])),
- // Read-only: porcelain status of a specific worktree; non-empty => dirty.
- statusPorcelain: (wtPath) =>
- execFileSync("git", ["-C", wtPath, "status", "--porcelain"], {
- encoding: "utf8",
- stdio: ["ignore", "pipe", "pipe"],
- maxBuffer: 32 * 1024 * 1024,
- }),
- // Read-only: is an ancestor of the integration ref? merge-base exits 0/1.
- isMerged: (head, baseRef) => {
- try {
- execFileSync("git", ["-C", repoDir, "merge-base", "--is-ancestor", head, baseRef], {
- stdio: "ignore",
- });
- return true;
- } catch {
- return false;
- }
- },
- // Read-only: apparent-size disk usage in KiB. `du` mutates nothing.
- sizeKb: (wtPath) => {
- const out = execFileSync("du", ["-sk", wtPath], {
- encoding: "utf8",
- stdio: ["ignore", "pipe", "ignore"],
- });
- const kb = parseInt(String(out).trim().split(/\s+/)[0], 10);
- return Number.isFinite(kb) ? kb : null;
- },
- };
-}
-
-// --- Pure classification (no I/O) ---
-
-// Given objective signals, decide an advisory disposition. NEVER a deletion
-// authority: positive_ownership_proof is always false and destructive_actions
-// stays 0. Dirty/locked/unmerged-recent/unknown => PRESERVE (the #4595 lesson).
-function classifyWorktree(signals, opts = {}) {
- const agedSeconds =
- typeof opts.agedOrphanSeconds === "number" ? opts.agedOrphanSeconds : DEFAULT_AGED_ORPHAN_SECONDS;
- const base = { positive_ownership_proof: false };
-
- if (signals.worktree_state === "unknown" || signals.locked) {
- return { ...base, disposition: "PRESERVE", reason: signals.locked ? "locked worktree" : "inspection unknown" };
- }
- if (signals.worktree_state === "missing") {
- // Registered but directory absent: a `git worktree prune` concern, never ours to delete.
- return { ...base, disposition: "PRESERVE", reason: "registered worktree with missing directory" };
- }
- if (signals.dirty) {
- return { ...base, disposition: "PRESERVE", reason: "uncommitted changes present" };
- }
- if (signals.merged === false) {
- const aged = typeof signals.age_seconds === "number" && signals.age_seconds > agedSeconds;
- if (aged) {
- // Proposal #2: aged unmerged orphan. Flag for human review + archive-ref
- // backup BEFORE any future removal. This helper never removes it.
- return {
- ...base,
- disposition: "AGED_ORPHAN_REVIEW",
- reason: `clean but unmerged, mtime older than ${agedSeconds}s; archive branch tip before any prune`,
- };
- }
- return { ...base, disposition: "PRESERVE", reason: "clean but unmerged and recent (possibly live work)" };
- }
- if (signals.merged === true && signals.registered && !signals.dirty) {
- // Proposal #1: clean AND merged AND registered => the session-verified safe
- // reclaim condition. Still report-only; surfaced as a candidate, not deleted.
- return { ...base, disposition: "SAFE_MERGED_CANDIDATE", reason: "clean and merged into integration ref" };
- }
- return { ...base, disposition: "PRESERVE", reason: "no positive ownership proof" };
-}
-
-// --- Enumeration + report assembly ---
-
-function listAgentWorktreeDirs(worktreesRoot) {
- let entries;
- try {
- entries = fs.readdirSync(worktreesRoot, { withFileTypes: true });
- } catch (err) {
- if (err && err.code === "ENOENT") return [];
- throw err;
- }
- const dirs = [];
- for (const entry of entries) {
- if (!entry.name.startsWith(AGENT_WORKTREE_PREFIX)) continue;
- const abs = path.join(worktreesRoot, entry.name);
- // Do not follow symlinks: lstat, and only accept real directories.
- let st;
- try {
- st = fs.lstatSync(abs);
- } catch {
- continue;
- }
- if (st.isSymbolicLink() || !st.isDirectory()) continue;
- dirs.push({ name: entry.name, path: abs, mtimeMs: st.mtimeMs });
- }
- return dirs;
-}
-
-function runInventory(options = {}) {
- const repoDir = options.repoDir || process.env.AGENTDESK_REPO_DIR || process.cwd();
- const worktreesRoot = options.worktreesRoot || path.join(repoDir, ".claude", "worktrees");
- const baseRef = options.baseRef || "origin/main";
- const nowMs = typeof options.nowMs === "number" ? options.nowMs : Date.now();
- const agedOrphanSeconds =
- typeof options.agedOrphanSeconds === "number" ? options.agedOrphanSeconds : DEFAULT_AGED_ORPHAN_SECONDS;
- const deps = options.deps || defaultDeps(repoDir);
-
- const inspectionErrors = [];
- let registered = {};
- try {
- registered = deps.worktreeList() || {};
- } catch (err) {
- inspectionErrors.push({ path: repoDir, error: `worktree list failed: ${err.message || err}` });
- }
-
- const dirs = listAgentWorktreeDirs(worktreesRoot);
- const seen = new Set();
- const worktrees = [];
-
- const collect = (name, absPath, mtimeMs) => {
- seen.add(absPath);
- const reg = registered[absPath];
- const isRegistered = Boolean(reg);
- const locked = Boolean(reg && reg.locked);
- const head = reg ? reg.head : null;
- const branch = reg ? reg.branch : null;
-
- let worktreeState = "unknown";
- let dirty = null;
- let merged = null;
- const dirExists = mtimeMs !== null;
-
- if (!dirExists) {
- worktreeState = "missing";
- } else {
- try {
- const status = deps.statusPorcelain(absPath);
- dirty = String(status).trim().length > 0;
- worktreeState = dirty ? "dirty" : "clean";
- } catch (err) {
- worktreeState = "unknown";
- inspectionErrors.push({ path: absPath, error: `status failed: ${err.message || err}` });
- }
- if (head && worktreeState !== "unknown") {
- try {
- merged = deps.isMerged(head, baseRef);
- } catch (err) {
- merged = null;
- inspectionErrors.push({ path: absPath, error: `merge-base failed: ${err.message || err}` });
- }
- }
- }
-
- let sizeKb = null;
- if (dirExists) {
- try {
- sizeKb = deps.sizeKb(absPath);
- } catch (err) {
- inspectionErrors.push({ path: absPath, error: `size failed: ${err.message || err}` });
- }
- }
-
- const ageSeconds = dirExists ? Math.max(0, Math.floor((nowMs - mtimeMs) / 1000)) : null;
- const signals = {
- registered: isRegistered,
- locked,
- dirty: dirty === true,
- merged,
- age_seconds: ageSeconds,
- worktree_state: worktreeState,
- };
- const decision = classifyWorktree(signals, { agedOrphanSeconds });
-
- worktrees.push({
- path: absPath,
- name,
- age_seconds: ageSeconds,
- size_kb: sizeKb,
- registered: isRegistered,
- locked,
- dirty,
- merged,
- head,
- branch,
- worktree_state: worktreeState,
- disposition: decision.disposition,
- positive_ownership_proof: false,
- reason: decision.reason,
- });
- };
-
- for (const dir of dirs) {
- collect(dir.name, dir.path, dir.mtimeMs);
- }
- // Registered agent-* worktrees whose directory is missing must still surface.
- for (const [absPath, reg] of Object.entries(registered)) {
- if (seen.has(absPath)) continue;
- if (!path.basename(absPath).startsWith(AGENT_WORKTREE_PREFIX)) continue;
- collect(path.basename(absPath), absPath, null);
- void reg;
- }
-
- worktrees.sort((a, b) => (b.size_kb || 0) - (a.size_kb || 0));
-
- const totalSizeKb = worktrees.reduce((sum, w) => sum + (w.size_kb || 0), 0);
- const orphanCount = worktrees.filter(
- (w) => w.disposition === "AGED_ORPHAN_REVIEW" || w.disposition === "SAFE_MERGED_CANDIDATE",
- ).length;
- const preserveCount = worktrees.filter((w) => w.disposition === "PRESERVE").length;
-
- const report = {
- schema_version: SCHEMA_VERSION,
- generated_at: new Date(nowMs).toISOString(),
- root: worktreesRoot,
- base_ref: baseRef,
- mode: "report_only",
- destructive_actions: 0,
- aged_orphan_seconds: agedOrphanSeconds,
- totals: {
- count: worktrees.length,
- total_size_kb: totalSizeKb,
- orphan_count: orphanCount,
- preserve_count: preserveCount,
- },
- worktrees,
- inspection_errors: inspectionErrors,
- };
- validateReport(report);
- return report;
-}
-
-// --- Schema validation (throws on violation) ---
-
-const VALID_DISPOSITIONS = new Set(["PRESERVE", "SAFE_MERGED_CANDIDATE", "AGED_ORPHAN_REVIEW"]);
-const VALID_STATES = new Set(["clean", "dirty", "missing", "unknown"]);
-
-function validateReport(report) {
- const fail = (msg) => {
- throw new Error(`local-worktree-inventory schema violation: ${msg}`);
- };
- if (!report || typeof report !== "object") fail("report is not an object");
- if (report.schema_version !== SCHEMA_VERSION) fail("schema_version mismatch");
- if (report.mode !== "report_only") fail("mode must be report_only");
- if (report.destructive_actions !== 0) fail("destructive_actions must be 0");
- if (!Array.isArray(report.worktrees)) fail("worktrees must be an array");
- if (!Array.isArray(report.inspection_errors)) fail("inspection_errors must be an array");
- if (!report.totals || typeof report.totals !== "object") fail("totals must be an object");
- for (const w of report.worktrees) {
- if (typeof w.path !== "string" || !w.path) fail("worktree.path must be a non-empty string");
- if (!VALID_DISPOSITIONS.has(w.disposition)) fail(`invalid disposition '${w.disposition}' for ${w.path}`);
- if (!VALID_STATES.has(w.worktree_state)) fail(`invalid worktree_state '${w.worktree_state}' for ${w.path}`);
- if (w.positive_ownership_proof !== false) fail(`positive_ownership_proof must be false for ${w.path}`);
- if (!(w.age_seconds === null || typeof w.age_seconds === "number")) fail("age_seconds must be number|null");
- if (!(w.size_kb === null || typeof w.size_kb === "number")) fail("size_kb must be number|null");
- // Data-loss invariant: anything dirty or locked MUST be PRESERVE.
- if ((w.dirty === true || w.locked === true) && w.disposition !== "PRESERVE") {
- fail(`dirty/locked worktree ${w.path} must be PRESERVE, got ${w.disposition}`);
- }
- }
- return report;
-}
-
-function main() {
- const report = runInventory();
- process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
-}
-
-if (require.main === module) {
- main();
-}
-
-module.exports = {
- SCHEMA_VERSION,
- DEFAULT_AGED_ORPHAN_SECONDS,
- parseWorktreeList,
- classifyWorktree,
- listAgentWorktreeDirs,
- runInventory,
- validateReport,
-};
diff --git a/routines/monitoring/log_digest_issue_drafts.py b/routines/monitoring/log_digest_issue_drafts.py
deleted file mode 100644
index aecff35ca7..0000000000
--- a/routines/monitoring/log_digest_issue_drafts.py
+++ /dev/null
@@ -1,432 +0,0 @@
-#!/usr/bin/env python3
-"""Reusable log-signature aggregation and human-gated issue-draft helpers.
-
-The daily log digest and future audits (notably #4265) share this module so
-signature normalization, open-issue deduplication, and the default-off issue
-creation boundary do not drift between routines.
-"""
-
-from __future__ import annotations
-
-import hashlib
-import re
-from collections import Counter
-from dataclasses import dataclass
-from pathlib import Path
-from typing import Callable, Iterable, Sequence
-
-
-DEFAULT_DAILY_THRESHOLD = 50
-CONFIRMED_APPROVAL = "confirmed"
-
-_ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
-_SEVERITY_RE = re.compile(r"\b(ERROR|WARN(?:ING)?)\b", re.IGNORECASE)
-_TIMESTAMP_RE = re.compile(
- r"(? str | None:
- """Return canonical ERROR/WARN severity when the line contains one."""
-
- match = _SEVERITY_RE.search(_ANSI_RE.sub("", line))
- if not match:
- return None
- return "ERROR" if match.group(1).upper() == "ERROR" else "WARN"
-
-
-def normalize_signature(line: str) -> str:
- """Collapse volatile log fields while retaining the semantic message.
-
- Timestamps, UUIDs, hashes, and known dynamic identifiers become stable
- placeholders. Bare numbers are treated as volatile except HTTP/status codes
- and values explicitly labelled as ports. This deliberately narrow heuristic
- preserves the common semantic cases without pretending every number's role
- can be inferred from free-form text; unlabeled semantic numbers may collapse.
- """
-
- normalized = _ANSI_RE.sub("", line).strip()
- normalized = _TIMESTAMP_RE.sub("", normalized)
- normalized = _SEVERITY_RE.sub("", normalized)
- normalized = _UUID_RE.sub("", normalized)
- normalized = _HASH_RE.sub("", normalized)
- normalized = _EMBEDDED_ID_RE.sub(lambda match: f"{match.group(1)}", normalized)
- normalized = _DYNAMIC_KEY_VALUE_RE.sub(lambda match: f"{match.group(1).lower()}=", normalized)
- normalized = _MEASURED_NUMBER_RE.sub(_normalize_measured_number, normalized)
- normalized = _NUMBER_RE.sub(_normalize_bare_number, normalized)
- normalized = _WHITESPACE_RE.sub(" ", normalized).strip(" -:|\t")
- return normalized.lower()[:500]
-
-
-def _normalize_measured_number(match: re.Match[str]) -> str:
- unit = match.group(1).lower()
- if unit in {"ms", "us", "ns", "s", "m", "h"}:
- return ""
- if unit in {"b", "kb", "mb", "gb", "kib", "mib", "gib"}:
- return ""
- return ""
-
-
-def _normalize_bare_number(match: re.Match[str]) -> str:
- prefix = match.string[max(0, match.start() - 24) : match.start()]
- if re.search(r"\b(?:http(?:\s+status)?|status|port)\s*$", prefix, re.IGNORECASE):
- return match.group(0)
- return ""
-
-
-def aggregate_normalized_signatures(lines: Iterable[str]) -> list[SignatureCount]:
- """Count ERROR/WARN lines by ``(severity, normalized signature)``."""
-
- counts: Counter[tuple[str, str]] = Counter()
- samples: dict[tuple[str, str], str] = {}
- for raw_line in lines:
- severity = extract_severity(raw_line)
- if severity is None:
- continue
- signature = normalize_signature(raw_line)
- if not signature:
- continue
- key = (severity, signature)
- counts[key] += 1
- samples.setdefault(key, _WHITESPACE_RE.sub(" ", _ANSI_RE.sub("", raw_line)).strip()[:500])
-
- return sorted(
- (
- SignatureCount(severity=severity, signature=signature, count=count, sample=samples[key])
- for key, count in counts.items()
- for severity, signature in [key]
- ),
- key=lambda pattern: (-pattern.count, pattern.severity, pattern.signature),
- )
-
-
-def exceeds_threshold(count: int, threshold: int = DEFAULT_DAILY_THRESHOLD) -> bool:
- """The issue contract says *exceeds*, so equality does not cross the gate."""
-
- if threshold < 0:
- raise ValueError("threshold must be non-negative")
- return count > threshold
-
-
-def _similarity_tokens_from_normalized(normalized: str) -> set[str]:
- return {
- token
- for token in _TOKEN_RE.findall(normalized)
- if token not in _STOP_WORDS and not token.startswith("timestamp")
- }
-
-
-def _normalized_issue_candidates(issue: OpenIssue) -> list[str]:
- """Return bounded, issue-defining text rather than an unbounded epic body."""
-
- first_body_line = next((line.strip() for line in issue.body.splitlines() if line.strip()), "")
- return [normalize_signature(part) for part in (issue.title, first_body_line) if part]
-
-
-def issue_matches_signature(signature: str, issue: OpenIssue) -> bool:
- """Match against the issue title or first non-empty body line.
-
- Each bounded candidate must share at least three meaningful tokens and reach
- 50% symmetric Jaccard similarity. Direct containment is accepted only when
- the candidate is still signature-like (at most 3x the signature token count,
- with a floor of 12 tokens). This avoids a three-token signature matching an
- unrelated long epic that merely mentions those words. A duplicate described
- only deep in a long issue body may therefore require human reconciliation.
- """
-
- normalized_signature = normalize_signature(signature)
- signature_tokens = _similarity_tokens_from_normalized(normalized_signature)
- if len(signature_tokens) < 3:
- return False
- for candidate in _normalized_issue_candidates(issue):
- issue_tokens = _similarity_tokens_from_normalized(candidate)
- overlap = signature_tokens & issue_tokens
- if len(overlap) < 3:
- continue
- signature_like_limit = max(12, len(signature_tokens) * 3)
- if normalized_signature in candidate and len(issue_tokens) <= signature_like_limit:
- return True
- union = signature_tokens | issue_tokens
- if union and len(overlap) / len(union) >= 0.50:
- return True
- return False
-
-
-def build_issue_draft(pattern: SignatureCount, window_label: str, threshold: int) -> IssueDraft:
- signature_preview = pattern.signature[:120]
- title = f"ops(log-digest): {pattern.severity} {signature_preview}"
- safe_sample = normalize_signature(pattern.sample) or pattern.signature
- body = "\n".join(
- [
- "# Daily log-digest draft",
- "",
- "This is a pending draft generated for human review. It has not been posted to GitHub.",
- "",
- f"- Window: `{window_label}`",
- f"- Severity: `{pattern.severity}`",
- f"- Count: `{pattern.count}`",
- f"- Draft threshold: `>{threshold}`",
- f"- Normalized signature: `{pattern.signature}`",
- "",
- "## Representative sample",
- "",
- "```text",
- f"{pattern.severity} {safe_sample}",
- "```",
- "",
- "## Human review",
- "",
- "Confirm impact, reproduction, ownership, and labels before approving issue creation.",
- ]
- )
- return IssueDraft(
- severity=pattern.severity,
- signature=pattern.signature,
- count=pattern.count,
- title=title,
- body=body,
- )
-
-
-def decide_issue_drafts(
- patterns: Sequence[SignatureCount],
- open_issues: Sequence[OpenIssue],
- *,
- threshold: int = DEFAULT_DAILY_THRESHOLD,
- window_label: str = "last 24 hours",
- dedup_available: bool = True,
-) -> list[DraftDecision]:
- """Apply threshold and fail-closed open-issue deduplication.
-
- If the GitHub open-issue scan is unavailable, threshold crossings are
- reported but no draft is emitted; this prevents duplicate drafts when the
- dedup authority cannot be consulted.
- """
-
- decisions: list[DraftDecision] = []
- for pattern in patterns:
- if not exceeds_threshold(pattern.count, threshold):
- continue
- matching_issue = next(
- (issue for issue in open_issues if issue_matches_signature(pattern.signature, issue)),
- None,
- )
- draft = None
- if dedup_available and matching_issue is None:
- draft = build_issue_draft(pattern, window_label, threshold)
- decisions.append(DraftDecision(pattern=pattern, draft=draft, matching_issue=matching_issue))
- return decisions
-
-
-def stable_draft_filename(draft: IssueDraft) -> str:
- digest = hashlib.sha256(f"{draft.severity}\0{draft.signature}".encode()).hexdigest()[:16]
- return f"{draft.severity.lower()}-{digest}.md"
-
-
-def write_pending_drafts(drafts: Iterable[IssueDraft], pending_dir: Path) -> list[IssueDraft]:
- pending_dir.mkdir(parents=True, exist_ok=True)
- written: list[IssueDraft] = []
- for draft in drafts:
- path = pending_dir / stable_draft_filename(draft)
- path.write_text(draft.body + "\n", encoding="utf-8")
- written.append(
- IssueDraft(
- severity=draft.severity,
- signature=draft.signature,
- count=draft.count,
- title=draft.title,
- body=draft.body,
- path=path,
- )
- )
- return written
-
-
-def maybe_post_approved_drafts(
- drafts: Sequence[IssueDraft],
- approval_mode: str,
- create_issue: Callable[[IssueDraft], str],
-) -> PostDecision:
- """Post only after the operator supplies the literal ``confirmed`` gate.
-
- This check is intentionally in the shared helper (not only its CLI caller),
- so every future consumer inherits the same default-off safety boundary.
- """
-
- if approval_mode != CONFIRMED_APPROVAL:
- return PostDecision(
- attempted=False,
- created_urls=(),
- reason="issue creation disabled; set approval mode to literal 'confirmed' after human review",
- )
-
- approved = [
- draft
- for draft in drafts
- if draft.path is not None and Path(f"{draft.path}.approved").is_file()
- ]
- if not approved:
- return PostDecision(
- attempted=False,
- created_urls=(),
- reason="confirmation enabled, but no human-reviewed .approved draft markers exist",
- )
-
- created_urls = tuple(create_issue(draft) for draft in approved)
- return PostDecision(attempted=True, created_urls=created_urls, reason="human-confirmed issue creation")
-
-
-def _compact(text: str, limit: int = 100) -> str:
- return text if len(text) <= limit else text[: limit - 1] + "…"
-
-
-def format_daily_summary(
- patterns: Sequence[SignatureCount],
- decisions: Sequence[DraftDecision],
- drafts: Sequence[IssueDraft],
- *,
- threshold: int,
- window_label: str,
- warnings: Sequence[str] = (),
- top_per_severity: int = 3,
-) -> str:
- """Format the single routine-channel digest for one daily run."""
-
- lines = [f"📊 dcserver daily log digest — {window_label}"]
- for severity in ("ERROR", "WARN"):
- top = [pattern for pattern in patterns if pattern.severity == severity][:top_per_severity]
- if top:
- lines.append(f"{severity} top: " + " | ".join(
- f"{pattern.count}× {_compact(pattern.signature, 90)}" for pattern in top
- ))
- else:
- lines.append(f"{severity} top: none")
- lines.append("ℹ best-effort signatures; verify top patterns manually")
-
- crossed = [decision for decision in decisions]
- lines.append(f"Threshold >{threshold}: {len(crossed)} crossed")
- if crossed:
- lines.append(
- "Crossed: "
- + " | ".join(
- f"{decision.pattern.count}× {decision.pattern.severity} "
- f"{_compact(decision.pattern.signature, 75)}"
- for decision in crossed[:10]
- )
- )
- if len(crossed) > 10:
- lines.append(f"Crossed: +{len(crossed) - 10} more")
- matched = [decision for decision in crossed if decision.matching_issue is not None]
- if matched:
- references = ", ".join(f"#{decision.matching_issue.number}" for decision in matched)
- lines.append(f"Open-issue dedup: {len(matched)} matched ({references})")
- if drafts:
- lines.append("Pending drafts: " + ", ".join(str(draft.path or draft.title) for draft in drafts))
- else:
- lines.append("Pending drafts: none")
- lines.extend(f"⚠ {warning}" for warning in warnings)
- return "\n".join(lines)
diff --git a/routines/monitoring/weekly_churn_audit.py b/routines/monitoring/weekly_churn_audit.py
deleted file mode 100644
index 73f9adf633..0000000000
--- a/routines/monitoring/weekly_churn_audit.py
+++ /dev/null
@@ -1,556 +0,0 @@
-#!/usr/bin/env python3
-"""Audit one week of repeated fix churn and flag redesign candidates."""
-
-from __future__ import annotations
-
-import argparse
-import hashlib
-import json
-import os
-import re
-import subprocess
-import sys
-import urllib.error
-import urllib.request
-from collections import Counter
-from dataclasses import dataclass
-from pathlib import Path, PurePosixPath
-from typing import Callable, Mapping, Sequence
-
-from daily_log_digest import (
- REPOSITORY,
- create_github_issue,
- load_open_issues,
- runtime_root,
-)
-from log_digest_issue_drafts import (
- CONFIRMED_APPROVAL,
- IssueDraft,
- OpenIssue,
- PostDecision,
- maybe_post_approved_drafts,
- write_pending_drafts,
-)
-
-
-DEFAULT_THRESHOLD = 3
-DEFAULT_SINCE = "7 days"
-DEFAULT_API_URL = "http://127.0.0.1:8791/api/discord/send"
-LINEAGE_PATH_STATE_LIMIT = 10_000
-_FIX_SUBJECT_RE = re.compile(r"^fix(?:\([^)\r\n]+\))?!?:")
-_ISSUE_REFERENCE_RE = re.compile(r"(? int:
- return len(self.issues)
-
-
-@dataclass(frozen=True)
-class ChurnAudit:
- since: str
- threshold: int
- fix_commits: tuple[GitCommit, ...]
- file_counts: Mapping[str, int]
- module_counts: Mapping[str, int]
- candidates: tuple[ChurnCandidate, ...]
- lineages: tuple[IssueLineage, ...]
-
-
-def is_fix_commit_subject(subject: str) -> bool:
- """Recognize conventional fix subjects, including breaking-change markers."""
-
- return _FIX_SUBJECT_RE.match(subject) is not None
-
-
-def issue_references(subject: str, body: str = "") -> tuple[int, ...]:
- """Return genuine issue references in text order, excluding squash PR suffixes."""
-
- ordered: list[int] = []
- seen: set[int] = set()
- issue_text = f"{_SQUASH_PR_SUFFIX_RE.sub('', subject)}\n{body}"
- for match in _ISSUE_REFERENCE_RE.finditer(issue_text):
- number = int(match.group(1))
- if number not in seen:
- seen.add(number)
- ordered.append(number)
- return tuple(ordered)
-
-
-def module_for_file(file: str) -> str:
- """Map a file to its containing repo-relative module directory."""
-
- path = PurePosixPath(file)
- if path.name == "mod.rs":
- return str(path.parent)
- if str(path.parent) != ".":
- return str(path.parent)
- return path.stem
-
-
-def log(message: str) -> None:
- print(f"weekly-churn-audit: {message}", file=sys.stderr)
-
-
-def _git(repo_root: Path, args: Sequence[str]) -> subprocess.CompletedProcess[str]:
- return subprocess.run(
- ["git", "-C", str(repo_root), *args],
- check=False,
- capture_output=True,
- text=True,
- encoding="utf-8",
- errors="replace",
- )
-
-
-def _require_git(result: subprocess.CompletedProcess[str], operation: str) -> str:
- if result.returncode != 0:
- detail = result.stderr.strip() or f"git exited {result.returncode}"
- raise RuntimeError(f"could not {operation}: {detail}")
- return result.stdout
-
-
-def collect_git_commits(repo_root: Path, since: str = DEFAULT_SINCE) -> list[GitCommit]:
- """Collect commit text and changed paths from the requested local git window."""
-
- log_output = _require_git(
- _git(repo_root, ["log", f"--since={since}", "--format=%H"]),
- "read weekly git log",
- )
- commits: list[GitCommit] = []
- for sha in (line.strip() for line in log_output.splitlines() if line.strip()):
- text = _require_git(
- _git(repo_root, ["show", "-s", "--format=%s%x00%b", sha]),
- f"read commit text for {sha}",
- )
- subject, separator, body = text.rstrip("\n").partition("\x00")
- if not separator:
- raise RuntimeError(f"could not parse commit text for {sha}")
- if not is_fix_commit_subject(subject):
- commits.append(GitCommit(sha=sha, subject=subject, body=body.strip(), files=()))
- continue
- changed = _require_git(
- _git(
- repo_root,
- ["diff-tree", "--root", "--no-commit-id", "--name-only", "-r", sha],
- ),
- f"read changed files for {sha}",
- )
- files = tuple(sorted({line for line in changed.splitlines() if line}))
- commits.append(GitCommit(sha=sha, subject=subject, body=body.strip(), files=files))
- return commits
-
-
-def _longest_lineage(
- component: set[int], edges: Mapping[int, set[int]]
-) -> tuple[int, ...]:
- best: tuple[int, ...] = ()
- starts = sorted(component)
- pending = [(node, ()) for node in reversed(starts[:LINEAGE_PATH_STATE_LIMIT])]
- scheduled = len(pending)
- truncated = len(starts) > LINEAGE_PATH_STATE_LIMIT
- while pending:
- node, path = pending.pop()
- extended = (*path, node)
- if len(extended) > len(best) or (len(extended) == len(best) and extended < best):
- best = extended
- for child in reversed(sorted(edges.get(node, set()))):
- if child in extended:
- continue
- if scheduled >= LINEAGE_PATH_STATE_LIMIT:
- truncated = True
- break
- pending.append((child, extended))
- scheduled += 1
- if truncated:
- log(
- "issue-lineage search truncated at "
- f"{LINEAGE_PATH_STATE_LIMIT} path states for a {len(component)}-issue component"
- )
- return best
-
-
-def compute_issue_lineages(commits: Sequence[GitCommit]) -> tuple[IssueLineage, ...]:
- """Merge commit-text #A→#B edges and report the longest chain per component."""
-
- nodes: set[int] = set()
- edges: dict[int, set[int]] = {}
- neighbours: dict[int, set[int]] = {}
- for commit in commits:
- references = issue_references(commit.subject, commit.body)
- nodes.update(references)
- for parent, child in zip(references, references[1:]):
- edges.setdefault(parent, set()).add(child)
- neighbours.setdefault(parent, set()).add(child)
- neighbours.setdefault(child, set()).add(parent)
-
- lineages: list[IssueLineage] = []
- unseen = set(nodes)
- while unseen:
- first = min(unseen)
- component: set[int] = set()
- pending = [first]
- while pending:
- node = pending.pop()
- if node in component:
- continue
- component.add(node)
- pending.extend(neighbours.get(node, set()) - component)
- unseen -= component
- lineages.append(IssueLineage(_longest_lineage(component, edges)))
- return tuple(sorted(lineages, key=lambda item: (-item.generations, item.issues)))
-
-
-def analyze_churn(
- commits: Sequence[GitCommit],
- *,
- since: str = DEFAULT_SINCE,
- threshold: int = DEFAULT_THRESHOLD,
-) -> ChurnAudit:
- if threshold <= 0:
- raise ValueError("threshold must be positive")
-
- fix_commits = tuple(commit for commit in commits if is_fix_commit_subject(commit.subject))
- file_counts: Counter[str] = Counter()
- module_counts: Counter[str] = Counter()
- commits_by_file: dict[str, list[GitCommit]] = {}
- for commit in fix_commits:
- unique_files = set(commit.files)
- file_counts.update(unique_files)
- module_counts.update({module_for_file(file) for file in unique_files})
- for file in unique_files:
- commits_by_file.setdefault(file, []).append(commit)
-
- candidates = tuple(
- ChurnCandidate(file=file, count=count, commits=tuple(commits_by_file[file]))
- for file, count in sorted(file_counts.items(), key=lambda item: (-item[1], item[0]))
- if count >= threshold
- )
- return ChurnAudit(
- since=since,
- threshold=threshold,
- fix_commits=fix_commits,
- file_counts=dict(file_counts),
- module_counts=dict(module_counts),
- candidates=candidates,
- lineages=compute_issue_lineages(commits),
- )
-
-
-def _candidate_marker(candidate: ChurnCandidate) -> str:
- return f""
-
-
-def build_candidate_draft(candidate: ChurnCandidate, since: str, threshold: int) -> IssueDraft:
- evidence = [
- f"- `{commit.sha[:12]}` {commit.subject}" for commit in candidate.commits
- ]
- body = "\n".join(
- [
- _candidate_marker(candidate),
- "# 주간 회귀 churn 재설계 후보",
- "",
- "This is a pending draft generated for human review. It has not been posted to GitHub.",
- "",
- f"- File: `{candidate.file}`",
- f"- Window: `git log --since={since!r}`",
- f"- Fix commits: `{candidate.count}`",
- f"- Candidate threshold: `>={threshold}`",
- "",
- "## Fix lineage evidence",
- "",
- *evidence,
- "",
- "## Human review",
- "",
- "Confirm the recurring failure model, intended ownership, and redesign boundary before approval.",
- ]
- )
- return IssueDraft(
- severity="CHURN",
- signature=_candidate_marker(candidate),
- count=candidate.count,
- title=f"ops(process): redesign candidate for {candidate.file}",
- body=body,
- )
-
-
-def candidate_drafts(
- candidates: Sequence[ChurnCandidate],
- open_issues: Sequence[OpenIssue],
- *,
- since: str,
- threshold: int,
- dedup_available: bool = True,
-) -> tuple[list[IssueDraft], list[tuple[ChurnCandidate, OpenIssue]]]:
- """Build drafts only when the complete open-issue dedup authority is available."""
-
- drafts: list[IssueDraft] = []
- matches: list[tuple[ChurnCandidate, OpenIssue]] = []
- for candidate in candidates:
- matching = next(
- (
- issue
- for issue in open_issues
- if _candidate_marker(candidate) in issue.body
- ),
- None,
- )
- if matching is not None:
- matches.append((candidate, matching))
- elif dedup_available:
- drafts.append(build_candidate_draft(candidate, since, threshold))
- return drafts, matches
-
-
-def format_report(audit: ChurnAudit, notes: Sequence[str] = ()) -> str:
- lines = [
- f"📈 AgentDesk 주간 회귀 churn 감사 — git log --since={audit.since!r}",
- f"Fix commits: {len(audit.fix_commits)} | 재설계 후보 threshold: >={audit.threshold}",
- "",
- f"재설계 후보 ({len(audit.candidates)}):",
- ]
- if audit.candidates:
- lines.extend(f"- {item.count}× `{item.file}`" for item in audit.candidates)
- else:
- lines.append("- none")
-
- lines.extend(["", f"파일별 fix-commit tally ({len(audit.file_counts)}):"])
- if audit.file_counts:
- lines.extend(
- f"- {count}× `{file}`"
- for file, count in sorted(
- audit.file_counts.items(), key=lambda item: (-item[1], item[0])
- )
- )
- else:
- lines.append("- none")
-
- lines.extend(["", f"모듈별 fix-commit tally ({len(audit.module_counts)}):"])
- if audit.module_counts:
- lines.extend(
- f"- {count}× `{module}`"
- for module, count in sorted(
- audit.module_counts.items(), key=lambda item: (-item[1], item[0])
- )
- )
- else:
- lines.append("- none")
-
- lines.extend(["", f"Issue-reference lineages ({len(audit.lineages)}):"])
- if audit.lineages:
- lines.extend(
- f"- generations={lineage.generations}: "
- + "→".join(f"#{issue}" for issue in lineage.issues)
- for lineage in audit.lineages
- )
- else:
- lines.append("- none")
- lines.extend(["", *(f"⚠ {note}" for note in notes)])
- return "\n".join(lines).rstrip()
-
-
-def _post_report(api_url: str, channel_id: str, report: str) -> None:
- payload = json.dumps(
- {
- "target": f"channel:{channel_id}",
- "content": report,
- "source": "weekly-churn-audit",
- "bot": "notify",
- }
- ).encode()
- request = urllib.request.Request(
- api_url,
- data=payload,
- headers={"Content-Type": "application/json"},
- method="POST",
- )
- with urllib.request.urlopen(request, timeout=30) as response:
- response_payload = json.loads(response.read().decode())
- if not isinstance(response_payload, dict) or response_payload.get("ok") is not True:
- raise RuntimeError(f"AgentDesk channel post rejected: {response_payload!r}")
-
-
-def maybe_post_weekly_channel(
- report: str,
- approval_mode: str,
- channel_id: str | None,
- state_path: Path,
- post_report: Callable[[str], None],
-) -> tuple[bool, str]:
- """Post once per report fingerprint, and only behind literal confirmation."""
-
- if approval_mode != CONFIRMED_APPROVAL:
- return False, "weekly ops channel post disabled"
- if not channel_id:
- return False, "channel post confirmed but AGENTDESK_CHURN_AUDIT_CHANNEL_ID is unset"
-
- fingerprint = hashlib.sha256(report.encode()).hexdigest()
- try:
- prior = json.loads(state_path.read_text(encoding="utf-8")) if state_path.is_file() else {}
- except (OSError, json.JSONDecodeError):
- prior = {}
- if isinstance(prior, dict) and prior.get("fingerprint") == fingerprint:
- return False, "identical weekly report already posted"
-
- post_report(report)
- state_path.parent.mkdir(parents=True, exist_ok=True)
- temporary = state_path.with_suffix(".tmp")
- temporary.write_text(json.dumps({"fingerprint": fingerprint}) + "\n", encoding="utf-8")
- temporary.replace(state_path)
- return True, "weekly ops channel report posted"
-
-
-def positive_int(value: str) -> int:
- parsed = int(value)
- if parsed <= 0:
- raise argparse.ArgumentTypeError("must be greater than zero")
- return parsed
-
-
-def threshold_from_env(value: str | None) -> tuple[int, str | None]:
- configured = value if value is not None else str(DEFAULT_THRESHOLD)
- try:
- return positive_int(configured), None
- except (ValueError, argparse.ArgumentTypeError):
- return (
- DEFAULT_THRESHOLD,
- "invalid AGENTDESK_CHURN_AUDIT_THRESHOLD "
- f"value {configured!r}; using default {DEFAULT_THRESHOLD}",
- )
-
-
-def parse_args() -> argparse.Namespace:
- script_repo_root = Path(__file__).resolve().parents[2]
- env_threshold, threshold_warning = threshold_from_env(
- os.environ.get("AGENTDESK_CHURN_AUDIT_THRESHOLD")
- )
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("--repo-root", type=Path, default=script_repo_root)
- parser.add_argument("--runtime-root", type=Path, default=runtime_root())
- parser.add_argument(
- "--repo", default=os.environ.get("AGENTDESK_CHURN_AUDIT_REPO", REPOSITORY)
- )
- parser.add_argument(
- "--since", default=os.environ.get("AGENTDESK_CHURN_AUDIT_SINCE", DEFAULT_SINCE)
- )
- parser.add_argument(
- "--threshold",
- type=positive_int,
- default=None,
- )
- args = parser.parse_args()
- if args.threshold is None:
- args.threshold = env_threshold
- if threshold_warning:
- log(threshold_warning)
- return args
-
-
-def main() -> int:
- args = parse_args()
- audit = analyze_churn(
- collect_git_commits(args.repo_root, args.since),
- since=args.since,
- threshold=args.threshold,
- )
- notes: list[str] = []
-
- issue_mode = os.environ.get("AGENTDESK_CHURN_AUDIT_CREATE_ISSUE", "off")
- if issue_mode == CONFIRMED_APPROVAL:
- open_issues, dedup_warning = load_open_issues(args.repo)
- if dedup_warning:
- notes.append(dedup_warning)
- proposed, matches = candidate_drafts(
- audit.candidates,
- open_issues,
- since=args.since,
- threshold=args.threshold,
- dedup_available=dedup_warning is None,
- )
- pending_dir = (
- args.runtime_root / "runtime" / "pending-issue-drafts" / "weekly-churn-audit"
- )
- drafts = write_pending_drafts(proposed, pending_dir)
- if matches:
- notes.append(
- "open-issue dedup matched: "
- + ", ".join(f"{item.file}→#{issue.number}" for item, issue in matches)
- )
- post: PostDecision = maybe_post_approved_drafts(
- drafts,
- issue_mode,
- lambda draft: create_github_issue(args.repo, draft),
- )
- notes.append(
- "pending drafts: "
- + (", ".join(str(draft.path) for draft in drafts) if drafts else "none")
- )
- if not post.attempted:
- notes.append(post.reason)
- elif post.created_urls:
- notes.append("human-confirmed issues created: " + ", ".join(post.created_urls))
- elif issue_mode == "off":
- notes.append(
- "issue drafts dry-run only; set AGENTDESK_CHURN_AUDIT_CREATE_ISSUE=confirmed "
- "to dedup and emit human-review drafts"
- )
- else:
- notes.append(
- "invalid AGENTDESK_CHURN_AUDIT_CREATE_ISSUE value ignored; "
- "use literal 'confirmed' or 'off'"
- )
-
- report = format_report(audit, notes)
- channel_mode = os.environ.get("AGENTDESK_CHURN_AUDIT_POST_CHANNEL", "off")
- try:
- posted, channel_note = maybe_post_weekly_channel(
- report,
- channel_mode,
- os.environ.get("AGENTDESK_CHURN_AUDIT_CHANNEL_ID"),
- args.runtime_root / "runtime" / "weekly-churn-audit" / "post-state.json",
- lambda content: _post_report(
- os.environ.get("AGENTDESK_CHURN_AUDIT_API", DEFAULT_API_URL),
- os.environ.get("AGENTDESK_CHURN_AUDIT_CHANNEL_ID", ""),
- content,
- ),
- )
- except (OSError, RuntimeError, ValueError, urllib.error.URLError) as error:
- posted, channel_note = False, f"weekly ops channel post failed: {error}"
- if channel_mode not in {"off", CONFIRMED_APPROVAL}:
- channel_note = (
- "invalid AGENTDESK_CHURN_AUDIT_POST_CHANNEL value ignored; "
- "use literal 'confirmed' or 'off'"
- )
- if posted or channel_mode != "off":
- report = f"{report}\n⚠ {channel_note}"
-
- print(report)
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/scripts/ci-script-checks.sh b/scripts/ci-script-checks.sh
index 98e547a98a..38e52a7081 100755
--- a/scripts/ci-script-checks.sh
+++ b/scripts/ci-script-checks.sh
@@ -185,12 +185,12 @@ node --test policies/__tests__/merge-automation.test.js
echo "=== Timeout shadow aggregation gate tests (#3950) ==="
node --test scripts/__tests__/timeout-shadow-gate.test.mjs
-echo "=== Daily log-digest routine tests (#4263) ==="
-node --test policies/__tests__/daily-log-digest.test.js
-"$PYTHON" -m unittest tests.test_daily_log_digest
-
-echo "=== Weekly regression-churn audit tests (#4265) ==="
-"$PYTHON" -m unittest tests.test_weekly_churn_audit
+echo "=== Operator routine scripts must stay out of git (docs/source-of-truth.md) ==="
+if tracked_routines="$(git ls-files routines)" && [ -n "$tracked_routines" ]; then
+ echo "✗ routines/ is operator-private and must not be tracked; found:" >&2
+ printf ' %s\n' $tracked_routines >&2
+ exit 1
+fi
echo "=== External toolchain draft/approval/smoke tests (#4555) ==="
"$PYTHON" -m unittest tests.test_toolchain_update
diff --git a/tests/test_daily_log_digest.py b/tests/test_daily_log_digest.py
deleted file mode 100644
index 48cd2057f9..0000000000
--- a/tests/test_daily_log_digest.py
+++ /dev/null
@@ -1,612 +0,0 @@
-#!/usr/bin/env python3
-"""Focused tests for the reusable daily log-digest draft pipeline (#4263)."""
-
-from __future__ import annotations
-
-import json
-import os
-import subprocess
-import sys
-import tempfile
-import unittest
-from contextlib import redirect_stdout
-from datetime import datetime, timedelta, timezone
-from io import StringIO
-from pathlib import Path
-from types import SimpleNamespace
-from unittest.mock import patch
-
-
-ROOT = Path(__file__).resolve().parents[1]
-ROUTINE_DIR = ROOT / "routines" / "monitoring"
-sys.path.insert(0, str(ROUTINE_DIR))
-
-from log_digest_issue_drafts import ( # noqa: E402
- _MEASURED_NUMBER_RE,
- IssueDraft,
- OpenIssue,
- SignatureCount,
- aggregate_normalized_signatures,
- decide_issue_drafts,
- exceeds_threshold,
- format_daily_summary,
- issue_matches_signature,
- maybe_post_approved_drafts,
- normalize_signature,
- write_pending_drafts,
-)
-import daily_log_digest # noqa: E402
-from daily_log_digest import ( # noqa: E402
- OPEN_ISSUE_LIMIT,
- dcserver_log_paths,
- load_open_issues,
- recent_log_lines,
- runtime_root,
-)
-
-
-class SignatureNormalizationTests(unittest.TestCase):
- def test_runtime_root_keeps_canonical_override_before_deploy_fallback(self) -> None:
- with patch.dict(
- os.environ,
- {"AGENTDESK_ROOT_DIR": "/canonical", "ADK_REL": "/deploy-fallback"},
- clear=False,
- ):
- self.assertEqual(runtime_root(), Path("/canonical"))
- with patch.dict(os.environ, {"ADK_REL": "/deploy-fallback"}, clear=True):
- self.assertEqual(runtime_root(), Path("/deploy-fallback"))
-
- def test_runtime_log_paths_include_internal_stdout_and_launchd_stderr(self) -> None:
- paths = dcserver_log_paths(Path("/srv/agentdesk"))
- self.assertIn(Path("/srv/agentdesk/logs/dcserver.stdout.log"), paths)
- self.assertIn(Path("/srv/agentdesk/logs/dcserver.stdout.log.1"), paths)
- self.assertIn(Path("/srv/agentdesk/logs/dcserver.launchd.stderr.log"), paths)
-
- def test_recent_window_filters_old_and_undated_stdout_lines(self) -> None:
- now = datetime(2026, 7, 14, 0, 0, tzinfo=timezone.utc)
- since = now - timedelta(days=1)
- with tempfile.TemporaryDirectory() as temp:
- logs = Path(temp)
- stdout = logs / "dcserver.stdout.log"
- launchd_stderr = logs / "dcserver.launchd.stderr.log"
- stdout.write_text(
- "2026-07-12T23:59:00Z ERROR stale failure id=1\n"
- "2026-07-13T12:00:00Z WARN recent timeout id=2\n"
- "ERROR undated stale stdout line\n",
- encoding="utf-8",
- )
- launchd_stderr.write_text("WARN undated recent launchd bootstrap\n", encoding="utf-8")
- timestamp = now.timestamp()
- stdout.touch()
- launchd_stderr.touch()
- # touch() uses wall clock; explicitly pin both mtimes to the test window.
- os.utime(stdout, (timestamp, timestamp))
- os.utime(launchd_stderr, (timestamp, timestamp))
-
- lines, warnings = recent_log_lines([stdout, launchd_stderr], since, now)
-
- self.assertEqual(warnings, [])
- self.assertEqual(
- lines,
- [
- "2026-07-13T12:00:00Z WARN recent timeout id=2",
- "WARN undated recent launchd bootstrap",
- ],
- )
-
- def test_first_undated_observation_baselines_then_counts_only_append(self) -> None:
- now = datetime(2026, 7, 14, 0, 0, tzinfo=timezone.utc)
- since = now - timedelta(days=1)
- with tempfile.TemporaryDirectory() as temp:
- root = Path(temp)
- launchd_stderr = root / "dcserver.launchd.stderr.log"
- checkpoint = root / "runtime" / "undated-offsets.json"
- launchd_stderr.write_text(
- "".join(f"ERROR panic stale-{index}\n" for index in range(60)),
- encoding="utf-8",
- )
- old_run = now - timedelta(days=3)
- os.utime(launchd_stderr, (old_run.timestamp(), old_run.timestamp()))
-
- baseline, baseline_warnings = recent_log_lines(
- [launchd_stderr],
- old_run - timedelta(days=1),
- old_run,
- undated_checkpoint=checkpoint,
- )
- with launchd_stderr.open("a", encoding="utf-8") as stream:
- stream.write("WARN fresh append today\n")
- os.utime(launchd_stderr, (now.timestamp(), now.timestamp()))
- fresh, fresh_warnings = recent_log_lines(
- [launchd_stderr], since, now, undated_checkpoint=checkpoint
- )
- repeated, repeated_warnings = recent_log_lines(
- [launchd_stderr],
- now,
- now + timedelta(days=1),
- undated_checkpoint=checkpoint,
- )
-
- self.assertEqual(baseline, [])
- self.assertEqual(fresh, ["WARN fresh append today"])
- self.assertEqual(repeated, [])
- self.assertEqual(baseline_warnings + fresh_warnings + repeated_warnings, [])
-
- def test_corrupt_checkpoint_also_baselines_undated_history(self) -> None:
- now = datetime(2026, 7, 14, 0, 0, tzinfo=timezone.utc)
- with tempfile.TemporaryDirectory() as temp:
- root = Path(temp)
- launchd_stderr = root / "dcserver.launchd.stderr.log"
- checkpoint = root / "runtime" / "undated-offsets.json"
- launchd_stderr.write_text("ERROR old undated history\n" * 60, encoding="utf-8")
- os.utime(launchd_stderr, (now.timestamp(), now.timestamp()))
- checkpoint.parent.mkdir(parents=True)
- checkpoint.write_text("{broken", encoding="utf-8")
-
- baseline, warnings = recent_log_lines(
- [launchd_stderr],
- now - timedelta(days=1),
- now,
- undated_checkpoint=checkpoint,
- )
-
- self.assertEqual(baseline, [])
- self.assertEqual(len(warnings), 1)
- self.assertIn("could not load undated-line checkpoint", warnings[0])
-
- def test_truncate_then_regrow_past_offset_resets_same_inode_watermark(self) -> None:
- now = datetime(2026, 7, 14, 0, 0, tzinfo=timezone.utc)
- since = now - timedelta(days=1)
- with tempfile.TemporaryDirectory() as temp:
- root = Path(temp)
- launchd_stderr = root / "dcserver.launchd.stderr.log"
- checkpoint = root / "runtime" / "undated-offsets.json"
- launchd_stderr.write_text("ERROR old history\n" * 8, encoding="utf-8")
- os.utime(launchd_stderr, (now.timestamp(), now.timestamp()))
- baseline, _ = recent_log_lines(
- [launchd_stderr], since, now, undated_checkpoint=checkpoint
- )
- old_offset = launchd_stderr.stat().st_size
- old_inode = launchd_stderr.stat().st_ino
-
- with launchd_stderr.open("w", encoding="utf-8") as stream:
- stream.write("ERROR new content after truncation\n" * 10)
- os.utime(launchd_stderr, (now.timestamp(), now.timestamp()))
- fresh, warnings = recent_log_lines(
- [launchd_stderr], since, now, undated_checkpoint=checkpoint
- )
- new_offset = launchd_stderr.stat().st_size
- new_inode = launchd_stderr.stat().st_ino
-
- self.assertEqual(baseline, [])
- self.assertEqual(new_inode, old_inode)
- self.assertGreater(new_offset, old_offset)
- self.assertEqual(fresh, ["ERROR new content after truncation"] * 10)
- self.assertEqual(warnings, [])
-
- def test_truncate_regrow_with_same_tail_resets_then_plain_append_resumes(self) -> None:
- now = datetime(2026, 7, 14, 0, 0, tzinfo=timezone.utc)
- since = now - timedelta(days=1)
- tail = "ERROR tail unchanged marker\n" * 8
- with tempfile.TemporaryDirectory() as temp:
- root = Path(temp)
- launchd_stderr = root / "dcserver.launchd.stderr.log"
- checkpoint = root / "runtime" / "undated-offsets.json"
- launchd_stderr.write_text("ERROR old prelude\n" + tail, encoding="utf-8")
- os.utime(launchd_stderr, (now.timestamp(), now.timestamp()))
- baseline, baseline_warnings = recent_log_lines(
- [launchd_stderr], since, now, undated_checkpoint=checkpoint
- )
- old_inode = launchd_stderr.stat().st_ino
- old_offset = launchd_stderr.stat().st_size
-
- rewritten = (
- "ERROR new prelude\n"
- + tail
- + "ERROR appended beyond old offset\n"
- )
- with launchd_stderr.open("w", encoding="utf-8") as stream:
- stream.write(rewritten)
- os.utime(launchd_stderr, (now.timestamp(), now.timestamp()))
- new_inode = launchd_stderr.stat().st_ino
- fresh, fresh_warnings = recent_log_lines(
- [launchd_stderr], since, now, undated_checkpoint=checkpoint
- )
-
- with launchd_stderr.open("a", encoding="utf-8") as stream:
- stream.write("ERROR plain append after rewrite\n")
- appended, append_warnings = recent_log_lines(
- [launchd_stderr], since, now, undated_checkpoint=checkpoint
- )
-
- self.assertEqual(baseline, [])
- self.assertEqual(new_inode, old_inode)
- self.assertGreater(len(rewritten.encode()), old_offset)
- self.assertEqual(
- fresh,
- ["ERROR new prelude"]
- + ["ERROR tail unchanged marker"] * 8
- + ["ERROR appended beyond old offset"],
- )
- self.assertEqual(appended, ["ERROR plain append after rewrite"])
- self.assertEqual(baseline_warnings + fresh_warnings + append_warnings, [])
-
- def test_different_ids_hashes_and_timestamps_collapse(self) -> None:
- first = (
- "2026-07-13T01:02:03.123Z WARN sqlx pool timed out while acquiring "
- "id=123 request_id=req-a9f3 token=secret-one commit=deadbeef"
- )
- second = (
- "2026-07-14T04:05:06.987Z WARN sqlx pool timed out while acquiring "
- "id=456 request_id=req-b7d1 token=secret-two commit=cafebabe"
- )
-
- self.assertEqual(normalize_signature(first), normalize_signature(second))
- patterns = aggregate_normalized_signatures([first, second])
- self.assertEqual(len(patterns), 1)
- self.assertEqual(patterns[0].severity, "WARN")
- self.assertEqual(patterns[0].count, 2)
-
- def test_semantically_distinct_patterns_stay_distinct(self) -> None:
- patterns = aggregate_normalized_signatures(
- [
- "2026-07-14T01:00:00Z ERROR postgres pool timed out id=123",
- "2026-07-14T01:00:01Z ERROR discord gateway connection refused id=456",
- ]
- )
-
- self.assertEqual(len(patterns), 2)
- self.assertNotEqual(patterns[0].signature, patterns[1].signature)
-
- def test_short_embedded_request_ids_collapse_and_cross_threshold(self) -> None:
- ids = ("4f2a", "8c1d", "0b7e", "9d3c")
- lines = [
- f"ERROR failed to open /tmp/req-{ids[index % len(ids)]}/data"
- for index in range(100)
- ]
- patterns = aggregate_normalized_signatures(lines)
-
- self.assertEqual(len(patterns), 1)
- self.assertEqual(patterns[0].count, 100)
- self.assertIn("req-", patterns[0].signature)
- self.assertTrue(exceeds_threshold(patterns[0].count, 50))
-
- def test_unit_suffixed_numbers_collapse_without_touching_identifiers(self) -> None:
- duration_patterns = aggregate_normalized_signatures(
- ["ERROR request took 123ms", "ERROR request took 456ms"]
- )
- size_patterns = aggregate_normalized_signatures(
- ["WARN payload reached 512kb", "WARN payload reached 1mb"]
- )
-
- self.assertEqual(len(duration_patterns), 1)
- self.assertEqual(duration_patterns[0].count, 2)
- self.assertIn("", duration_patterns[0].signature)
- self.assertEqual(len(size_patterns), 1)
- self.assertEqual(size_patterns[0].count, 2)
- self.assertIn("", size_patterns[0].signature)
- for value, placeholder in (
- ("1.5s", ""),
- ("20us", ""),
- ("8gib", ""),
- ("99%", ""),
- ("20/s", ""),
- ("30req/s", ""),
- ):
- with self.subTest(value=value):
- self.assertIn(placeholder, normalize_signature(f"ERROR measured {value}"))
- self.assertEqual(
- normalize_signature("ERROR error500handler failed"),
- "error500handler failed",
- )
-
- def test_measured_numbers_require_standalone_token_boundaries(self) -> None:
- for first, second in (
- ("cache_1mb_loader", "cache_2mb_loader"),
- ("cache-1mb-loader", "cache-2mb-loader"),
- ("cache.1mb.loader", "cache.2mb.loader"),
- ):
- with self.subTest(first=first, second=second):
- patterns = aggregate_normalized_signatures(
- [f"ERROR {first} failed", f"ERROR {second} failed"]
- )
- self.assertEqual(len(patterns), 2)
- self.assertNotEqual(patterns[0].signature, patterns[1].signature)
-
- for first, second, expected_signature in (
- ("request took 123ms.", "request took 456ms.", "request took ."),
- ("size 512kb.", "size 1mb.", "size ."),
- ("request took 123ms,", "request took 456ms,", "request took ,"),
- ("request took (123ms)", "request took (456ms)", "request took ()"),
- ("request took 123ms;", "request took 456ms;", "request took ;"),
- ("request took 123ms:", "request took 456ms:", "request took "),
- ):
- with self.subTest(first=first, second=second):
- patterns = aggregate_normalized_signatures(
- [f"ERROR {first}", f"ERROR {second}"]
- )
- self.assertEqual(len(patterns), 1)
- self.assertEqual(patterns[0].count, 2)
- self.assertEqual(patterns[0].signature, expected_signature)
-
- for first, second, placeholder in (
- ("took 123ms", "took 456ms", ""),
- ("512kb", "1mb", ""),
- ("took 1.5s", "took 2.5s", ""),
- ):
- with self.subTest(first=first, second=second):
- patterns = aggregate_normalized_signatures(
- [f"ERROR {first}", f"ERROR {second}"]
- )
- self.assertEqual(len(patterns), 1)
- self.assertEqual(patterns[0].count, 2)
- self.assertIn(placeholder, patterns[0].signature)
-
- self.assertIsNone(_MEASURED_NUMBER_RE.search("0.144.1"))
-
- def test_http_status_codes_remain_distinct(self) -> None:
- self.assertNotEqual(
- normalize_signature("ERROR HTTP 500 upstream error"),
- normalize_signature("ERROR HTTP 404 upstream error"),
- )
-
- def test_port_numbers_remain_distinct(self) -> None:
- self.assertNotEqual(
- normalize_signature("ERROR connection to port 5432 refused"),
- normalize_signature("ERROR connection to port 6379 refused"),
- )
-
-
-class DraftDecisionTests(unittest.TestCase):
- def setUp(self) -> None:
- self.pattern = SignatureCount(
- severity="ERROR",
- signature="postgres pool timed out while acquiring connection id=",
- count=51,
- sample="ERROR postgres pool timed out while acquiring connection id=9234",
- )
-
- def test_threshold_crosses_only_above_named_limit(self) -> None:
- self.assertFalse(exceeds_threshold(49, 50))
- self.assertFalse(exceeds_threshold(50, 50))
- self.assertTrue(exceeds_threshold(51, 50))
-
- below = SignatureCount(**{**self.pattern.__dict__, "count": 50})
- self.assertEqual(decide_issue_drafts([below], [], threshold=50), [])
- crossed = decide_issue_drafts([self.pattern], [], threshold=50)
- self.assertEqual(len(crossed), 1)
- self.assertIsNotNone(crossed[0].draft)
- self.assertNotIn("9234", crossed[0].draft.body)
- self.assertIn("id=", crossed[0].draft.body)
-
- def test_matching_open_issue_suppresses_draft(self) -> None:
- issue = OpenIssue(
- number=4249,
- title="fix(db): postgres pool timed out while acquiring connection",
- body="Repeated pool acquisition timeouts are visible in dcserver.",
- url="https://github.com/itismyfield/AgentDesk/issues/4249",
- )
-
- self.assertTrue(issue_matches_signature(self.pattern.signature, issue))
- decisions = decide_issue_drafts([self.pattern], [issue], threshold=50)
- self.assertEqual(len(decisions), 1)
- self.assertEqual(decisions[0].matching_issue, issue)
- self.assertIsNone(decisions[0].draft)
-
- def test_unrelated_long_issue_body_does_not_suppress_short_signature(self) -> None:
- pattern = SignatureCount(
- severity="WARN",
- signature="worker lease expired",
- count=60,
- sample="WARN worker lease expired",
- )
- issue = OpenIssue(
- number=4250,
- title="epic: routine runtime hardening",
- body=(
- "This broad epic discusses workers, scheduling, and resilience.\n"
- + "unrelated planning context " * 80
- + "worker ownership and lease cleanup after expired sessions"
- ),
- )
-
- self.assertFalse(issue_matches_signature(pattern.signature, issue))
- decisions = decide_issue_drafts([pattern], [issue], threshold=50)
- self.assertIsNotNone(decisions[0].draft)
-
- def test_genuine_short_signature_duplicate_is_suppressed(self) -> None:
- pattern = SignatureCount(
- severity="WARN",
- signature="worker lease expired",
- count=60,
- sample="WARN worker lease expired",
- )
- issue = OpenIssue(number=4251, title="fix(runtime): worker lease expired")
-
- self.assertTrue(issue_matches_signature(pattern.signature, issue))
- decisions = decide_issue_drafts([pattern], [issue], threshold=50)
- self.assertIsNone(decisions[0].draft)
-
- def test_unavailable_dedup_fails_closed_without_draft(self) -> None:
- decisions = decide_issue_drafts(
- [self.pattern],
- [],
- threshold=50,
- dedup_available=False,
- )
- self.assertEqual(len(decisions), 1)
- self.assertIsNone(decisions[0].draft)
-
- def test_issue_creation_is_default_off_mutation_guard(self) -> None:
- """Removing the shared approval check makes this mutation-style test fail."""
-
- calls: list[str] = []
- draft = IssueDraft(
- severity=self.pattern.severity,
- signature=self.pattern.signature,
- count=self.pattern.count,
- title="draft title",
- body="draft body",
- )
-
- with tempfile.TemporaryDirectory() as temp:
- written = write_pending_drafts([draft], Path(temp))[0]
- Path(f"{written.path}.approved").touch()
-
- with patch.dict(os.environ, {}, clear=True):
- unset_mode = os.environ.get("AGENTDESK_LOG_DIGEST_CREATE_ISSUE", "off")
- for mode in (unset_mode, "off", "invalid"):
- disabled = maybe_post_approved_drafts(
- [written],
- mode,
- lambda item: calls.append(item.title) or "https://example.test/1",
- )
- self.assertFalse(disabled.attempted)
- self.assertEqual(calls, [], f"mode {mode!r} must suppress an approved draft")
-
- Path(f"{written.path}.approved").unlink()
- unreviewed = maybe_post_approved_drafts(
- [written],
- "confirmed",
- lambda item: calls.append(item.title) or "https://example.test/1",
- )
- self.assertFalse(unreviewed.attempted)
- self.assertEqual(calls, [], "confirmation alone cannot bypass per-draft review")
-
- Path(f"{written.path}.approved").touch()
- confirmed = maybe_post_approved_drafts(
- [written],
- "confirmed",
- lambda item: calls.append(item.title) or "https://example.test/1",
- )
- self.assertTrue(confirmed.attempted)
- self.assertEqual(calls, ["draft title"])
-
- def test_daily_summary_lists_top_patterns_crossings_and_drafts(self) -> None:
- decisions = decide_issue_drafts([self.pattern], [], threshold=50)
- with tempfile.TemporaryDirectory() as temp:
- drafts = write_pending_drafts(
- [decision.draft for decision in decisions if decision.draft],
- Path(temp),
- )
- summary = format_daily_summary(
- [self.pattern],
- decisions,
- drafts,
- threshold=50,
- window_label="2026-07-13 00:00–2026-07-14 00:00 UTC",
- )
-
- self.assertIn("ERROR top: 51× postgres pool timed out", summary)
- self.assertIn("WARN top: none", summary)
- self.assertIn("best-effort signatures; verify top patterns manually", summary)
- self.assertIn("Threshold >50: 1 crossed", summary)
- self.assertIn("Crossed: 51× ERROR postgres pool timed out", summary)
- self.assertIn("Pending drafts:", summary)
- self.assertNotIn("Pending drafts: none", summary)
-
-
-class DailyDigestIntegrationTests(unittest.TestCase):
- def _write_threshold_crossing(self, root: Path) -> None:
- logs = root / "logs"
- logs.mkdir(parents=True)
- (logs / "dcserver.stdout.log").write_text(
- "".join(
- f"2026-07-13T12:00:{index % 60:02d}Z ERROR worker lease expired id={index}\n"
- for index in range(51)
- ),
- encoding="utf-8",
- )
-
- def test_main_gh_failure_wires_dedup_unavailable_fail_closed(self) -> None:
- with tempfile.TemporaryDirectory() as temp:
- root = Path(temp)
- self._write_threshold_crossing(root)
- completed = subprocess.CompletedProcess(
- args=["gh"], returncode=1, stdout="", stderr="simulated gh failure"
- )
- output = StringIO()
- with (
- patch.object(
- sys,
- "argv",
- [
- "daily_log_digest.py",
- "--root",
- str(root),
- "--now",
- "2026-07-14T00:00:00Z",
- ],
- ),
- patch.object(daily_log_digest.subprocess, "run", return_value=completed),
- patch.dict(os.environ, {"AGENTDESK_LOG_DIGEST_CREATE_ISSUE": "off"}, clear=False),
- redirect_stdout(output),
- ):
- rc = daily_log_digest.main()
-
- drafts = list(
- (root / "runtime" / "pending-issue-drafts" / "daily-log-digest").glob("*.md")
- )
-
- self.assertEqual(rc, 0)
- self.assertEqual(drafts, [])
- self.assertIn("dedup unavailable", output.getvalue())
- self.assertIn("drafts suppressed", output.getvalue())
-
- def test_invalid_threshold_env_warns_and_falls_back_to_50(self) -> None:
- for invalid in ("0", "-4", "not-a-number"):
- with self.subTest(invalid=invalid), tempfile.TemporaryDirectory() as temp:
- root = Path(temp)
- self._write_threshold_crossing(root)
- output = StringIO()
- with (
- patch.object(
- sys,
- "argv",
- [
- "daily_log_digest.py",
- "--root",
- str(root),
- "--now",
- "2026-07-14T00:00:00Z",
- ],
- ),
- patch.object(daily_log_digest, "load_open_issues", return_value=([], None)),
- patch.dict(
- os.environ,
- {
- "AGENTDESK_LOG_DIGEST_THRESHOLD": invalid,
- "AGENTDESK_LOG_DIGEST_CREATE_ISSUE": "off",
- },
- clear=False,
- ),
- redirect_stdout(output),
- ):
- rc = daily_log_digest.main()
-
- self.assertEqual(rc, 0)
- self.assertIn("invalid AGENTDESK_LOG_DIGEST_THRESHOLD", output.getvalue())
- self.assertIn("using default 50", output.getvalue())
- self.assertIn("Threshold >50: 1 crossed", output.getvalue())
-
- def test_open_issue_cap_is_fail_closed_with_warning(self) -> None:
- payload = [
- {"number": index, "title": f"issue {index}", "body": "", "url": ""}
- for index in range(1, OPEN_ISSUE_LIMIT + 1)
- ]
- completed = SimpleNamespace(returncode=0, stdout=json.dumps(payload), stderr="")
- with patch.object(daily_log_digest.subprocess, "run", return_value=completed):
- issues, warning = load_open_issues("owner/repo")
-
- self.assertEqual(len(issues), OPEN_ISSUE_LIMIT)
- self.assertIsNotNone(warning)
- self.assertIn("may be truncated", warning)
- pattern = SignatureCount("ERROR", "brand new failure pattern", 51, "ERROR sample")
- decisions = decide_issue_drafts(
- [pattern], issues, threshold=50, dedup_available=warning is None
- )
- self.assertIsNone(decisions[0].draft)
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_weekly_churn_audit.py b/tests/test_weekly_churn_audit.py
deleted file mode 100644
index 186f84a651..0000000000
--- a/tests/test_weekly_churn_audit.py
+++ /dev/null
@@ -1,391 +0,0 @@
-#!/usr/bin/env python3
-"""Focused tests for the weekly regression-churn audit (#4265)."""
-
-from __future__ import annotations
-
-import os
-import subprocess
-import sys
-import tempfile
-import unittest
-from contextlib import redirect_stdout
-from io import StringIO
-from pathlib import Path
-from unittest.mock import patch
-
-
-ROOT = Path(__file__).resolve().parents[1]
-ROUTINE_DIR = ROOT / "routines" / "monitoring"
-sys.path.insert(0, str(ROUTINE_DIR))
-
-import weekly_churn_audit # noqa: E402
-from log_digest_issue_drafts import OpenIssue, stable_draft_filename # noqa: E402
-from weekly_churn_audit import ( # noqa: E402
- GitCommit,
- analyze_churn,
- candidate_drafts,
- compute_issue_lineages,
- is_fix_commit_subject,
- issue_references,
- maybe_post_weekly_channel,
-)
-
-
-def commit(
- subject: str,
- files: tuple[str, ...] = ("src/services/discord/example.rs",),
- *,
- sha: str = "a" * 40,
- body: str = "",
-) -> GitCommit:
- return GitCommit(sha=sha, subject=subject, body=body, files=files)
-
-
-class WeeklyChurnAuditTests(unittest.TestCase):
- def test_fix_commit_classifier_is_precise(self) -> None:
- for subject in (
- "fix: stop duplicate relay",
- "fix(discord): stop duplicate relay",
- "fix!: stop breaking duplicate relay",
- "fix(discord)!: stop breaking duplicate relay",
- ):
- with self.subTest(subject=subject):
- self.assertTrue(is_fix_commit_subject(subject))
-
- for subject in (
- "chore: stop duplicate relay",
- "feat(discord): stop duplicate relay",
- "refactor: stop duplicate relay",
- "docs: explain duplicate relay",
- "test: reproduce duplicate relay",
- "prefix: fix: embedded text is not a fix subject",
- ):
- with self.subTest(subject=subject):
- self.assertFalse(is_fix_commit_subject(subject))
-
- def test_threshold_includes_n_but_not_n_minus_one(self) -> None:
- repeated = "src/services/discord/repeated.rs"
- below = "src/services/discord/below.rs"
- commits = [
- commit("fix: first", (repeated, below), sha="1" * 40),
- commit("fix(scope): second", (repeated, below), sha="2" * 40),
- commit("fix: third", (repeated,), sha="3" * 40),
- commit("feat: not counted", (below,), sha="4" * 40),
- ]
-
- audit = analyze_churn(commits, threshold=3)
-
- self.assertEqual(audit.file_counts[repeated], 3)
- self.assertEqual(audit.file_counts[below], 2)
- self.assertEqual([candidate.file for candidate in audit.candidates], [repeated])
- self.assertEqual(audit.module_counts["src/services/discord"], 3)
-
- def test_squash_pr_suffixes_do_not_create_issue_generations(self) -> None:
- subject = "fix(deploy): #4262 post-deploy scope (#4511) (#4523)"
-
- self.assertTrue(is_fix_commit_subject(subject))
- self.assertEqual(issue_references(subject), (4262,))
- self.assertEqual(compute_issue_lineages([commit(subject)])[0].issues, (4262,))
- self.assertEqual(issue_references("fix(deploy): #100 (#101) (#102)"), (100,))
- self.assertEqual(
- issue_references("fix: #100 see #99", "Regression-of: #98"),
- (100, 99, 98),
- )
-
- def test_issue_lineage_generation_count_spans_commit_text_edges(self) -> None:
- commits = [
- commit("fix: #100 first regression", body="Regression-of cross-reference: #200"),
- commit("fix: #200 follow-up", body="Regression-of cross-reference: #300"),
- commit("fix: independent #900"),
- ]
-
- lineages = compute_issue_lineages(commits)
-
- self.assertEqual(lineages[0].issues, (100, 200, 300))
- self.assertEqual(lineages[0].generations, 3)
- self.assertIn((900,), [lineage.issues for lineage in lineages])
-
- def test_open_issue_dedup_suppresses_matching_candidate_draft(self) -> None:
- candidate = analyze_churn(
- [
- commit(f"fix: regression {index}", sha=str(index) * 40)
- for index in range(1, 4)
- ],
- threshold=3,
- ).candidates[0]
- created = weekly_churn_audit.build_candidate_draft(candidate, "7 days", 3)
- matching = OpenIssue(number=4265, title=created.title, body=created.body)
-
- drafts, matches = candidate_drafts(
- [candidate], [matching], since="7 days", threshold=3
- )
-
- self.assertEqual(drafts, [])
- self.assertEqual(matches, [(candidate, matching)])
-
- def test_created_issue_marker_prevents_duplicate_on_second_run(self) -> None:
- audit_commits = [
- commit(f"fix: repeat {index}", ("justfile",), sha=str(index) * 40)
- for index in range(1, 4)
- ]
- candidate = analyze_churn(audit_commits, threshold=3).candidates[0]
- draft = weekly_churn_audit.build_candidate_draft(candidate, "7 days", 3)
- open_issues: list[OpenIssue] = []
-
- def load_open(_repo: str) -> tuple[list[OpenIssue], None]:
- return list(open_issues), None
-
- def create_issue(_repo: str, approved) -> str:
- open_issues.append(
- OpenIssue(number=4265, title=approved.title, body=approved.body)
- )
- return "https://example.test/issues/4265"
-
- with tempfile.TemporaryDirectory() as temp:
- pending = (
- Path(temp)
- / "runtime"
- / "pending-issue-drafts"
- / "weekly-churn-audit"
- )
- pending.mkdir(parents=True)
- draft_name = stable_draft_filename(draft)
- Path(f"{pending / draft_name}.approved").touch()
- argv = [
- "weekly_churn_audit.py",
- "--repo-root",
- str(ROOT),
- "--runtime-root",
- temp,
- ]
- with (
- patch.object(sys, "argv", argv),
- patch.dict(
- os.environ,
- {"AGENTDESK_CHURN_AUDIT_CREATE_ISSUE": "confirmed"},
- clear=True,
- ),
- patch.object(
- weekly_churn_audit,
- "collect_git_commits",
- return_value=audit_commits,
- ),
- patch.object(
- weekly_churn_audit, "load_open_issues", side_effect=load_open
- ),
- patch.object(
- weekly_churn_audit, "create_github_issue", side_effect=create_issue
- ) as create,
- redirect_stdout(StringIO()),
- ):
- self.assertEqual(weekly_churn_audit.main(), 0)
- self.assertEqual(weekly_churn_audit.main(), 0)
-
- create.assert_called_once()
-
- def test_git_replaces_non_utf8_commit_text(self) -> None:
- with tempfile.TemporaryDirectory() as temp:
- repo = Path(temp)
- subprocess.run(["git", "init", "-q", str(repo)], check=True)
- subprocess.run(
- ["git", "-C", str(repo), "config", "user.name", "Audit Test"],
- check=True,
- )
- subprocess.run(
- ["git", "-C", str(repo), "config", "user.email", "audit@example.test"],
- check=True,
- )
- subprocess.run(
- [
- "git",
- "-C",
- str(repo),
- "config",
- "i18n.commitEncoding",
- "ISO-8859-1",
- ],
- check=True,
- )
- subprocess.run(
- [
- "git",
- "-C",
- str(repo),
- "config",
- "i18n.logOutputEncoding",
- "ISO-8859-1",
- ],
- check=True,
- )
- (repo / "sample.txt").write_text("sample\n", encoding="utf-8")
- subprocess.run(["git", "-C", str(repo), "add", "sample.txt"], check=True)
- subprocess.run(
- ["git", "-C", str(repo), "commit", "-q", "-F", "-"],
- input=b"fix: latin-1 \xff message\n",
- check=True,
- )
-
- commits = weekly_churn_audit.collect_git_commits(repo, "7 days")
-
- self.assertEqual(len(commits), 1)
- self.assertIn("\ufffd", commits[0].subject)
- self.assertEqual(commits[0].files, ("sample.txt",))
-
- def test_invalid_env_thresholds_fall_back_and_log(self) -> None:
- for value in ("abc", "0", "-1"):
- with (
- self.subTest(value=value),
- patch.object(sys, "argv", ["weekly_churn_audit.py"]),
- patch.dict(
- os.environ,
- {"AGENTDESK_CHURN_AUDIT_THRESHOLD": value},
- clear=True,
- ),
- patch.object(weekly_churn_audit, "log") as audit_log,
- ):
- args = weekly_churn_audit.parse_args()
-
- self.assertEqual(args.threshold, weekly_churn_audit.DEFAULT_THRESHOLD)
- audit_log.assert_called_once()
- self.assertIn(value, audit_log.call_args.args[0])
-
- def test_dense_cyclic_lineage_search_is_bounded_and_logged(self) -> None:
- component = set(range(1, 8))
- edges = {node: component - {node} for node in component}
- with (
- patch.object(weekly_churn_audit, "LINEAGE_PATH_STATE_LIMIT", 25),
- patch.object(weekly_churn_audit, "log") as audit_log,
- ):
- lineage = weekly_churn_audit._longest_lineage(component, edges)
-
- self.assertTrue(lineage)
- self.assertLessEqual(len(lineage), len(component))
- audit_log.assert_called_once()
- self.assertIn("truncated at 25 path states", audit_log.call_args.args[0])
-
- def test_channel_post_gate_is_default_off_and_confirmed_is_idempotent(self) -> None:
- calls: list[str] = []
- with tempfile.TemporaryDirectory() as temp:
- state = Path(temp) / "post-state.json"
- disabled = maybe_post_weekly_channel(
- "report",
- "off",
- "123",
- state,
- calls.append,
- )
- first = maybe_post_weekly_channel(
- "report",
- "confirmed",
- "123",
- state,
- calls.append,
- )
- repeated = maybe_post_weekly_channel(
- "report",
- "confirmed",
- "123",
- state,
- calls.append,
- )
-
- self.assertEqual(disabled, (False, "weekly ops channel post disabled"))
- self.assertEqual(first, (True, "weekly ops channel report posted"))
- self.assertEqual(repeated, (False, "identical weekly report already posted"))
- self.assertEqual(calls, ["report"])
-
- def test_main_default_off_has_no_issue_or_channel_side_effect(self) -> None:
- audit_commits = [
- commit(f"fix: repeat {index}", sha=str(index) * 40)
- for index in range(1, 4)
- ]
- with tempfile.TemporaryDirectory() as temp:
- output = StringIO()
- with (
- patch.object(
- sys,
- "argv",
- [
- "weekly_churn_audit.py",
- "--repo-root",
- str(ROOT),
- "--runtime-root",
- temp,
- ],
- ),
- patch.dict(os.environ, {}, clear=True),
- patch.object(
- weekly_churn_audit,
- "collect_git_commits",
- return_value=audit_commits,
- ),
- patch.object(weekly_churn_audit, "load_open_issues") as load_open,
- patch.object(weekly_churn_audit, "write_pending_drafts") as write_drafts,
- patch.object(
- weekly_churn_audit, "maybe_post_approved_drafts"
- ) as create_issues,
- patch.object(weekly_churn_audit, "_post_report") as post_channel,
- redirect_stdout(output),
- ):
- rc = weekly_churn_audit.main()
-
- runtime_files = list(Path(temp).rglob("*"))
-
- self.assertEqual(rc, 0)
- load_open.assert_not_called()
- write_drafts.assert_not_called()
- create_issues.assert_not_called()
- post_channel.assert_not_called()
- self.assertEqual(runtime_files, [])
- self.assertIn("재설계 후보 (1)", output.getvalue())
- self.assertIn("issue drafts dry-run only", output.getvalue())
-
- def test_confirmed_issue_gate_still_requires_per_draft_approval(self) -> None:
- audit_commits = [
- commit(f"fix: repeat {index}", sha=str(index) * 40)
- for index in range(1, 4)
- ]
- with tempfile.TemporaryDirectory() as temp:
- argv = [
- "weekly_churn_audit.py",
- "--repo-root",
- str(ROOT),
- "--runtime-root",
- temp,
- ]
- with (
- patch.object(sys, "argv", argv),
- patch.dict(
- os.environ,
- {"AGENTDESK_CHURN_AUDIT_CREATE_ISSUE": "confirmed"},
- clear=True,
- ),
- patch.object(
- weekly_churn_audit,
- "collect_git_commits",
- return_value=audit_commits,
- ),
- patch.object(
- weekly_churn_audit, "load_open_issues", return_value=([], None)
- ) as load_open,
- patch.object(
- weekly_churn_audit,
- "create_github_issue",
- return_value="https://example.test/issues/1",
- ) as create_issue,
- redirect_stdout(StringIO()),
- ):
- self.assertEqual(weekly_churn_audit.main(), 0)
- draft = next(
- (Path(temp) / "runtime" / "pending-issue-drafts").rglob("*.md")
- )
- create_issue.assert_not_called()
- Path(f"{draft}.approved").touch()
- self.assertEqual(weekly_churn_audit.main(), 0)
-
- self.assertEqual(load_open.call_count, 2)
- create_issue.assert_called_once()
-
-
-if __name__ == "__main__":
- unittest.main()