patchwarden 1.5.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/README.en.md +43 -2
  2. package/README.md +43 -2
  3. package/dist/assessments/agentAssessor.d.ts +1 -1
  4. package/dist/assessments/agentAssessor.js +3 -3
  5. package/dist/assessments/assessmentStore.d.ts +1 -1
  6. package/dist/assessments/assessmentStore.js +2 -2
  7. package/dist/assessments/confirmCli.js +5 -4
  8. package/dist/config.d.ts +7 -0
  9. package/dist/config.js +34 -0
  10. package/dist/control/middleware/auth.d.ts +10 -0
  11. package/dist/control/middleware/auth.js +8 -0
  12. package/dist/control/middleware/static.d.ts +3 -0
  13. package/dist/control/middleware/static.js +65 -0
  14. package/dist/control/routes/audit.d.ts +15 -0
  15. package/dist/control/routes/audit.js +277 -0
  16. package/dist/control/routes/evidence.d.ts +4 -0
  17. package/dist/control/routes/evidence.js +96 -0
  18. package/dist/control/routes/lineage.d.ts +3 -0
  19. package/dist/control/routes/lineage.js +71 -0
  20. package/dist/control/routes/policy.d.ts +3 -0
  21. package/dist/control/routes/policy.js +81 -0
  22. package/dist/control/routes/process.d.ts +5 -0
  23. package/dist/control/routes/process.js +200 -0
  24. package/dist/control/routes/sessions.d.ts +22 -0
  25. package/dist/control/routes/sessions.js +224 -0
  26. package/dist/control/routes/status.d.ts +6 -0
  27. package/dist/control/routes/status.js +250 -0
  28. package/dist/control/routes/taskActions.d.ts +21 -0
  29. package/dist/control/routes/taskActions.js +233 -0
  30. package/dist/control/routes/tasks.d.ts +20 -0
  31. package/dist/control/routes/tasks.js +310 -0
  32. package/dist/control/routes/workspace.d.ts +15 -0
  33. package/dist/control/routes/workspace.js +193 -0
  34. package/dist/control/runtime.d.ts +91 -0
  35. package/dist/control/runtime.js +392 -0
  36. package/dist/control/server.d.ts +13 -0
  37. package/dist/control/server.js +479 -0
  38. package/dist/control/shared.d.ts +35 -0
  39. package/dist/control/shared.js +288 -0
  40. package/dist/controlCenter.d.ts +6 -0
  41. package/dist/controlCenter.js +7 -2197
  42. package/dist/direct/directGuards.js +30 -8
  43. package/dist/doctor.d.ts +18 -1
  44. package/dist/doctor.js +579 -348
  45. package/dist/goal/goalReport.d.ts +54 -0
  46. package/dist/goal/goalReport.js +204 -0
  47. package/dist/goal/goalStatus.d.ts +6 -0
  48. package/dist/goal/specKitImport.d.ts +63 -0
  49. package/dist/goal/specKitImport.js +220 -0
  50. package/dist/goal/subgoalSync.js +2 -1
  51. package/dist/httpServer.js +15 -12
  52. package/dist/index.js +7 -4
  53. package/dist/logging.d.ts +7 -1
  54. package/dist/logging.js +8 -0
  55. package/dist/runner/changeCapture.d.ts +3 -3
  56. package/dist/runner/changeCapture.js +63 -39
  57. package/dist/runner/cli.js +7 -6
  58. package/dist/runner/postTaskCleanup.js +26 -2
  59. package/dist/runner/runTask.js +245 -221
  60. package/dist/runner/simpleProcess.js +4 -4
  61. package/dist/runner/watch.js +17 -14
  62. package/dist/security/contentRedaction.d.ts +6 -0
  63. package/dist/security/contentRedaction.js +22 -0
  64. package/dist/smoke-test.js +257 -251
  65. package/dist/test/unit/apply-patch.test.d.ts +1 -0
  66. package/dist/test/unit/apply-patch.test.js +225 -0
  67. package/dist/test/unit/create-task.test.d.ts +1 -0
  68. package/dist/test/unit/create-task.test.js +197 -0
  69. package/dist/test/unit/direct-guards.test.js +124 -9
  70. package/dist/test/unit/evidence-pack.test.js +95 -1
  71. package/dist/test/unit/get-task-status.test.d.ts +1 -0
  72. package/dist/test/unit/get-task-status.test.js +174 -0
  73. package/dist/test/unit/get-task-summary.test.d.ts +1 -0
  74. package/dist/test/unit/get-task-summary.test.js +146 -0
  75. package/dist/test/unit/goal-report.test.d.ts +1 -0
  76. package/dist/test/unit/goal-report.test.js +159 -0
  77. package/dist/test/unit/goal-subgoal-task.test.js +6 -6
  78. package/dist/test/unit/goal-tools-registry.test.js +6 -4
  79. package/dist/test/unit/path-guard.test.js +24 -0
  80. package/dist/test/unit/spec-kit-import.test.d.ts +1 -0
  81. package/dist/test/unit/spec-kit-import.test.js +341 -0
  82. package/dist/test/unit/wait-for-task.test.d.ts +1 -0
  83. package/dist/test/unit/wait-for-task.test.js +144 -0
  84. package/dist/tools/auditTask.d.ts +8 -63
  85. package/dist/tools/auditTask.js +12 -8
  86. package/dist/tools/createDirectSession.d.ts +1 -1
  87. package/dist/tools/createDirectSession.js +2 -2
  88. package/dist/tools/createTask.d.ts +2 -2
  89. package/dist/tools/createTask.js +4 -4
  90. package/dist/tools/dispatch/coreDispatch.d.ts +9 -0
  91. package/dist/tools/dispatch/coreDispatch.js +282 -0
  92. package/dist/tools/dispatch/diagnosticDispatch.d.ts +14 -0
  93. package/dist/tools/dispatch/diagnosticDispatch.js +78 -0
  94. package/dist/tools/dispatch/directDispatch.d.ts +8 -0
  95. package/dist/tools/dispatch/directDispatch.js +115 -0
  96. package/dist/tools/dispatch/goalDispatch.d.ts +8 -0
  97. package/dist/tools/dispatch/goalDispatch.js +91 -0
  98. package/dist/tools/dispatch/releaseDispatch.d.ts +8 -0
  99. package/dist/tools/dispatch/releaseDispatch.js +45 -0
  100. package/dist/tools/dispatch/types.d.ts +23 -0
  101. package/dist/tools/dispatch/types.js +15 -0
  102. package/dist/tools/evidencePack.d.ts +6 -0
  103. package/dist/tools/evidencePack.js +213 -6
  104. package/dist/tools/finalizeDirectSession.d.ts +1 -1
  105. package/dist/tools/finalizeDirectSession.js +3 -3
  106. package/dist/tools/goalSubgoalTask.d.ts +1 -1
  107. package/dist/tools/goalSubgoalTask.js +2 -2
  108. package/dist/tools/healthCheck.js +3 -3
  109. package/dist/tools/registry.d.ts +3 -3
  110. package/dist/tools/registry.js +60 -503
  111. package/dist/tools/retryTask.d.ts +2 -2
  112. package/dist/tools/retryTask.js +2 -2
  113. package/dist/tools/runTaskLoop.js +4 -4
  114. package/dist/tools/safeViews.d.ts +2 -2
  115. package/dist/tools/safeViews.js +2 -2
  116. package/dist/tools/toolRegistry.js +22 -0
  117. package/dist/version.d.ts +1 -1
  118. package/dist/version.js +1 -1
  119. package/docs/CODE_WIKI.md +983 -0
  120. package/docs/agentseal-integration.md +150 -0
  121. package/docs/architecture.md +63 -0
  122. package/docs/assets/patchwarden-oss-demo.gif +0 -0
  123. package/docs/dashboard-overview.md +86 -0
  124. package/docs/demo.md +8 -0
  125. package/docs/direct-session-workflow.md +98 -0
  126. package/docs/evidence-pack-schema.md +215 -0
  127. package/docs/execution-plan-2026-07-09.md +315 -0
  128. package/docs/lineage-evidence-pack-workflow.md +127 -0
  129. package/docs/mcp-inspector-testing.md +200 -0
  130. package/docs/open-source-application.md +162 -0
  131. package/docs/opencode-worker.md +151 -0
  132. package/docs/openhands-worker.md +181 -0
  133. package/docs/release-evidence.md +72 -0
  134. package/docs/roadmap-execution-and-acceptance.md +365 -0
  135. package/docs/spec-kit-integration.md +131 -0
  136. package/docs/task-safe-review-workflow.md +98 -0
  137. package/docs/threat-model.md +79 -0
  138. package/docs/user-feedback.md +40 -0
  139. package/docs/why-patchwarden.md +110 -0
  140. package/package.json +2 -2
  141. package/scripts/checks/control-center-smoke.js +356 -0
  142. package/scripts/checks/lifecycle-smoke.js +29 -23
  143. package/scripts/checks/mcp-smoke.js +3 -1
  144. package/scripts/generate-demo-gif.py +320 -0
  145. package/src/assessments/agentAssessor.ts +3 -3
  146. package/src/assessments/assessmentStore.ts +2 -2
  147. package/src/assessments/confirmCli.ts +5 -4
  148. package/src/config.ts +37 -0
  149. package/src/control/middleware/auth.ts +17 -0
  150. package/src/control/middleware/static.ts +71 -0
  151. package/src/control/routes/audit.ts +321 -0
  152. package/src/control/routes/evidence.ts +107 -0
  153. package/src/control/routes/lineage.ts +92 -0
  154. package/src/control/routes/policy.ts +81 -0
  155. package/src/control/routes/process.ts +204 -0
  156. package/src/control/routes/sessions.ts +251 -0
  157. package/src/control/routes/status.ts +325 -0
  158. package/src/control/routes/taskActions.ts +248 -0
  159. package/src/control/routes/tasks.ts +323 -0
  160. package/src/control/routes/workspace.ts +203 -0
  161. package/src/control/runtime.ts +472 -0
  162. package/src/control/server.ts +471 -0
  163. package/src/control/shared.ts +294 -0
  164. package/src/controlCenter.ts +7 -2347
  165. package/src/direct/directGuards.ts +28 -7
  166. package/src/doctor.ts +741 -481
  167. package/src/goal/goalReport.ts +271 -0
  168. package/src/goal/goalStatus.ts +6 -0
  169. package/src/goal/specKitImport.ts +355 -0
  170. package/src/goal/subgoalSync.ts +4 -2
  171. package/src/httpServer.ts +17 -14
  172. package/src/index.ts +7 -4
  173. package/src/logging.ts +10 -1
  174. package/src/runner/changeCapture.ts +70 -42
  175. package/src/runner/cli.ts +7 -6
  176. package/src/runner/postTaskCleanup.ts +26 -2
  177. package/src/runner/runTask.ts +325 -223
  178. package/src/runner/simpleProcess.ts +4 -4
  179. package/src/runner/watch.ts +17 -14
  180. package/src/security/contentRedaction.ts +29 -0
  181. package/src/smoke-test.ts +252 -250
  182. package/src/test/unit/apply-patch.test.ts +293 -0
  183. package/src/test/unit/create-task.test.ts +255 -0
  184. package/src/test/unit/direct-guards.test.ts +178 -8
  185. package/src/test/unit/evidence-pack.test.ts +110 -1
  186. package/src/test/unit/get-task-status.test.ts +203 -0
  187. package/src/test/unit/get-task-summary.test.ts +173 -0
  188. package/src/test/unit/goal-report.test.ts +189 -0
  189. package/src/test/unit/goal-subgoal-task.test.ts +6 -6
  190. package/src/test/unit/goal-tools-registry.test.ts +7 -5
  191. package/src/test/unit/path-guard.test.ts +30 -0
  192. package/src/test/unit/spec-kit-import.test.ts +429 -0
  193. package/src/test/unit/wait-for-task.test.ts +176 -0
  194. package/src/tools/auditTask.ts +99 -59
  195. package/src/tools/createDirectSession.ts +3 -3
  196. package/src/tools/createTask.ts +7 -7
  197. package/src/tools/dispatch/coreDispatch.ts +374 -0
  198. package/src/tools/dispatch/diagnosticDispatch.ts +101 -0
  199. package/src/tools/dispatch/directDispatch.ts +167 -0
  200. package/src/tools/dispatch/goalDispatch.ts +127 -0
  201. package/src/tools/dispatch/releaseDispatch.ts +65 -0
  202. package/src/tools/dispatch/types.ts +24 -0
  203. package/src/tools/evidencePack.ts +291 -6
  204. package/src/tools/finalizeDirectSession.ts +4 -4
  205. package/src/tools/goalSubgoalTask.ts +2 -2
  206. package/src/tools/healthCheck.ts +3 -3
  207. package/src/tools/registry.ts +68 -628
  208. package/src/tools/retryTask.ts +2 -2
  209. package/src/tools/runTaskLoop.ts +4 -4
  210. package/src/tools/safeViews.ts +2 -2
  211. package/src/tools/toolRegistry.ts +22 -0
  212. package/src/version.ts +1 -1
  213. package/ui/pages/audit.html +192 -3
  214. package/ui/pages/dashboard.html +959 -44
  215. package/ui/pages/direct-sessions.html +505 -53
  216. package/ui/pages/task-detail.html +456 -438
  217. package/ui/pages/tasks.html +598 -61
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Control Center auth middleware — control token validation.
3
+ *
4
+ * POST routes are gated behind an in-memory control token generated at boot.
5
+ * The token is exposed read-only via GET /control-token.json (handled by the
6
+ * server router) so the local dashboard can read it, but every mutating
7
+ * action must pass through `checkControlToken` first.
8
+ */
9
+ import { type IncomingMessage } from "node:http";
10
+ import { controlToken } from "../shared.js";
11
+
12
+ export function checkControlToken(req: IncomingMessage): boolean {
13
+ const header = req.headers["x-patchwarden-control-token"];
14
+ const provided = Array.isArray(header) ? header[0] : header;
15
+ if (typeof provided !== "string" || provided.length === 0) return false;
16
+ return provided === controlToken;
17
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Control Center static file middleware.
3
+ *
4
+ * Serves the bundled `ui/` directory (dashboard pages, partials, vendor
5
+ * assets, css) with path-traversal protection, plus the inline SVG favicon.
6
+ * Content types are mapped from `CONTENT_TYPES` in shared.ts.
7
+ */
8
+ import { existsSync, readFileSync, statSync } from "node:fs";
9
+ import { extname, isAbsolute, relative, resolve } from "node:path";
10
+ import { type ServerResponse } from "node:http";
11
+ import {
12
+ CONTENT_TYPES,
13
+ CONTROL_CENTER_FAVICON,
14
+ errorMessage,
15
+ sendJson,
16
+ uiRoot,
17
+ } from "../shared.js";
18
+
19
+ export function serveStatic(res: ServerResponse, urlPath: string): void {
20
+ let candidate = "";
21
+ try {
22
+ const normalized = urlPath.startsWith("/") ? urlPath.slice(1) : urlPath;
23
+ let decoded: string;
24
+ try {
25
+ decoded = decodeURIComponent(normalized);
26
+ } catch {
27
+ sendJson(res, 400, { error: "Invalid path encoding" });
28
+ return;
29
+ }
30
+ if (decoded.includes("\0")) {
31
+ sendJson(res, 400, { error: "Invalid path" });
32
+ return;
33
+ }
34
+ const segments = decoded.split("/").filter(Boolean);
35
+ if (segments.some((s) => s === "..")) {
36
+ sendJson(res, 403, { error: "Forbidden" });
37
+ return;
38
+ }
39
+ candidate = resolve(uiRoot, ...segments);
40
+ const rel = relative(uiRoot, candidate);
41
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
42
+ sendJson(res, 403, { error: "Forbidden" });
43
+ return;
44
+ }
45
+ } catch (err) {
46
+ sendJson(res, 500, { error: errorMessage(err) });
47
+ return;
48
+ }
49
+ try {
50
+ if (!existsSync(candidate) || !statSync(candidate).isFile()) {
51
+ sendJson(res, 404, { error: "Not found" });
52
+ return;
53
+ }
54
+ const ext = extname(candidate).toLowerCase();
55
+ const contentType = CONTENT_TYPES[ext] || "application/octet-stream";
56
+ const content = readFileSync(candidate);
57
+ res.writeHead(200, { "Content-Type": contentType });
58
+ res.end(content);
59
+ } catch (err) {
60
+ sendJson(res, 500, { error: errorMessage(err) });
61
+ }
62
+ }
63
+
64
+ export function serveFavicon(res: ServerResponse): void {
65
+ res.writeHead(200, {
66
+ "Content-Type": "image/svg+xml; charset=utf-8",
67
+ "Cache-Control": "public, max-age=86400",
68
+ "X-Content-Type-Options": "nosniff",
69
+ });
70
+ res.end(CONTROL_CENTER_FAVICON);
71
+ }
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Control Center routes — audit aggregation, warnings, and log tails.
3
+ *
4
+ * `handleAudit` joins independent-review.md + audit.json across tasks and
5
+ * direct sessions. `handleWarnings` buckets audit/stale/verification warnings
6
+ * by type. `handleLogs` returns redacted stdout/stderr tails for core, direct,
7
+ * watcher, and control-center log categories.
8
+ */
9
+ import { existsSync, readdirSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { type ServerResponse } from "node:http";
12
+ import { type TaskEntry, listTasks } from "../../tools/listTasks.js";
13
+ import { type WatcherStatusSnapshot } from "../../watcherStatus.js";
14
+ import { redactSensitiveContent } from "../../security/contentRedaction.js";
15
+ import { getDirectSessionsDir, getTasksDir } from "../../config.js";
16
+ import {
17
+ classifyStaleTask,
18
+ fileMtimeIso,
19
+ parseReviewVerdict,
20
+ readWatcherStatusSafe,
21
+ } from "../runtime.js";
22
+ import {
23
+ config,
24
+ errorMessage,
25
+ findLatestLog,
26
+ getControlCenterLogDir,
27
+ getRuntimeRoot,
28
+ readFileTail,
29
+ readJsonFileSafe,
30
+ readTextFileSafe,
31
+ sendJson,
32
+ } from "../shared.js";
33
+
34
+ export type LogCategory = "core" | "direct" | "watcher" | "control-center";
35
+
36
+ export function handleLogs(res: ServerResponse, category: LogCategory, tailLines: number): void {
37
+ try {
38
+ let dir: string;
39
+ let stdoutPath: string;
40
+ let stderrPath: string;
41
+ let stdoutExists: boolean;
42
+ let stderrExists: boolean;
43
+
44
+ if (category === "control-center") {
45
+ dir = getControlCenterLogDir();
46
+ stdoutPath = join(dir, "control-center.stdout.log");
47
+ stderrPath = join(dir, "control-center.stderr.log");
48
+ stdoutExists = existsSync(stdoutPath);
49
+ stderrExists = existsSync(stderrPath);
50
+ } else if (category === "watcher") {
51
+ dir = getRuntimeRoot(false);
52
+ const sp = findLatestLog(dir, /^watcher-.*\.stdout\.log$/);
53
+ const ep = findLatestLog(dir, /^watcher-.*\.stderr\.log$/);
54
+ stdoutPath = sp ?? "";
55
+ stderrPath = ep ?? "";
56
+ stdoutExists = sp !== null;
57
+ stderrExists = ep !== null;
58
+ } else {
59
+ // core | direct -> tunnel client logs in the matching runtime dir
60
+ dir = getRuntimeRoot(category === "direct");
61
+ stdoutPath = join(dir, "tunnel-client.stdout.log");
62
+ stderrPath = join(dir, "tunnel-client.stderr.log");
63
+ stdoutExists = existsSync(stdoutPath);
64
+ stderrExists = existsSync(stderrPath);
65
+ }
66
+
67
+ if (!stdoutExists && !stderrExists) {
68
+ sendJson(res, 200, {
69
+ stdout: "",
70
+ stderr: "",
71
+ category,
72
+ tail: tailLines,
73
+ reason: "log file not found",
74
+ });
75
+ return;
76
+ }
77
+
78
+ const stdoutRaw = stdoutExists ? readFileTail(stdoutPath, tailLines) : "";
79
+ const stderrRaw = stderrExists ? readFileTail(stderrPath, tailLines) : "";
80
+ const stdout = redactSensitiveContent(stdoutRaw).content;
81
+ const stderr = redactSensitiveContent(stderrRaw).content;
82
+ sendJson(res, 200, { stdout, stderr, category, tail: tailLines, reason: null });
83
+ } catch (err) {
84
+ sendJson(res, 200, { stdout: "", stderr: "", category, tail: tailLines, reason: errorMessage(err) });
85
+ }
86
+ }
87
+
88
+ export function handleAudit(res: ServerResponse): void {
89
+ try {
90
+ const audits: Array<Record<string, unknown>> = [];
91
+
92
+ // 1. tasks/*/independent-review.md (written by audit_task — the primary audit artifact)
93
+ // 2. tasks/*/audit.json (legacy/explicit JSON audit, if present)
94
+ const tasksDir = getTasksDir(config);
95
+ if (existsSync(tasksDir)) {
96
+ let taskEntries: import("node:fs").Dirent[] = [];
97
+ try {
98
+ taskEntries = readdirSync(tasksDir, { withFileTypes: true }).filter((e) => e.isDirectory());
99
+ } catch {
100
+ taskEntries = [];
101
+ }
102
+ for (const entry of taskEntries) {
103
+ const taskDir = join(tasksDir, entry.name);
104
+
105
+ // independent-review.md
106
+ const reviewFile = join(taskDir, "independent-review.md");
107
+ if (existsSync(reviewFile)) {
108
+ const content = readTextFileSafe(reviewFile) ?? "";
109
+ audits.push({
110
+ task_id: entry.name,
111
+ source: "independent-review.md",
112
+ verdict: parseReviewVerdict(content),
113
+ checked_at: fileMtimeIso(reviewFile),
114
+ content_excerpt: content.slice(0, 500),
115
+ });
116
+ }
117
+
118
+ // audit.json (explicit JSON audit if present)
119
+ const auditFile = join(taskDir, "audit.json");
120
+ if (existsSync(auditFile)) {
121
+ const data = readJsonFileSafe<Record<string, unknown>>(auditFile);
122
+ if (data) {
123
+ audits.push({
124
+ task_id: entry.name,
125
+ source: "audit.json",
126
+ checked_at: data.checked_at ?? fileMtimeIso(auditFile),
127
+ ...data,
128
+ });
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ // 3. direct-sessions/*/audit.json (written by Direct audit_session)
135
+ const sessionsDir = getDirectSessionsDir(config);
136
+ if (existsSync(sessionsDir)) {
137
+ let sessionEntries: import("node:fs").Dirent[] = [];
138
+ try {
139
+ sessionEntries = readdirSync(sessionsDir, { withFileTypes: true }).filter((e) => e.isDirectory());
140
+ } catch {
141
+ sessionEntries = [];
142
+ }
143
+ for (const entry of sessionEntries) {
144
+ const auditFile = join(sessionsDir, entry.name, "audit.json");
145
+ if (!existsSync(auditFile)) continue;
146
+ const data = readJsonFileSafe<Record<string, unknown>>(auditFile);
147
+ if (data) {
148
+ audits.push({
149
+ source: "direct-session",
150
+ session_id: data.session_id ?? entry.name,
151
+ checked_at: fileMtimeIso(auditFile),
152
+ ...data,
153
+ });
154
+ }
155
+ }
156
+ }
157
+
158
+ // Sort by checked_at descending (missing timestamps sort last).
159
+ audits.sort((a, b) => {
160
+ const ac = String(a.checked_at ?? "");
161
+ const bc = String(b.checked_at ?? "");
162
+ return bc.localeCompare(ac);
163
+ });
164
+ const limited = audits.slice(0, 50);
165
+ sendJson(res, 200, { audits: limited, total: limited.length });
166
+ } catch (err) {
167
+ sendJson(res, 200, { audits: [], reason: errorMessage(err) });
168
+ }
169
+ }
170
+
171
+ // ── Warnings aggregation ─────────────────────────────────────────
172
+
173
+ interface WarningEntry {
174
+ type: string;
175
+ severity: "error" | "warning" | "info";
176
+ affected_tasks_count: number;
177
+ affected_tasks: string[];
178
+ likely_false_positive: boolean;
179
+ needs_fix: boolean;
180
+ blocked: boolean;
181
+ recommended_action: string;
182
+ }
183
+
184
+ /**
185
+ * Aggregate warnings from multiple sources:
186
+ * - audit.json warnings/fail_checks/possible_false_positives/manual_verification_required
187
+ * - stale task classification (heartbeat stale, collecting_artifacts stale, etc.)
188
+ * - task status=failed_verification
189
+ *
190
+ * Each warning type is grouped into a bucket with affected task IDs. The
191
+ * handler is fault-tolerant: any per-task read failure is skipped, and a
192
+ * top-level failure returns an empty warnings array (never 500).
193
+ */
194
+ export function handleWarnings(res: ServerResponse): void {
195
+ try {
196
+ const buckets: Record<string, Set<string>> = {
197
+ unrecorded_command_execution: new Set(),
198
+ artifact_hygiene: new Set(),
199
+ scope_changes: new Set(),
200
+ release_publish_claim: new Set(),
201
+ manual_verification_required: new Set(),
202
+ stale_task: new Set(),
203
+ failed_verification: new Set(),
204
+ };
205
+
206
+ const tasksDir = getTasksDir(config);
207
+ let taskList: TaskEntry[] = [];
208
+ let watcher: WatcherStatusSnapshot;
209
+ try {
210
+ const result = listTasks({ limit: 100 });
211
+ taskList = result.tasks;
212
+ watcher = result.watcher;
213
+ } catch (err) {
214
+ watcher = readWatcherStatusSafe();
215
+ }
216
+
217
+ const now = Date.now();
218
+
219
+ for (const task of taskList) {
220
+ const taskId = task.task_id;
221
+
222
+ // 1. Read audit.json for warning strings
223
+ const auditFile = join(tasksDir, taskId, "audit.json");
224
+ const audit = readJsonFileSafe<Record<string, unknown>>(auditFile);
225
+ if (audit) {
226
+ const warningTexts: string[] = [];
227
+ const collectStrings = (field: unknown) => {
228
+ if (Array.isArray(field)) {
229
+ for (const w of field) {
230
+ if (typeof w === "string") {
231
+ warningTexts.push(w);
232
+ } else if (w && typeof w === "object") {
233
+ const obj = w as Record<string, unknown>;
234
+ if (typeof obj.message === "string") warningTexts.push(obj.message);
235
+ else if (typeof obj.description === "string") warningTexts.push(obj.description);
236
+ else if (typeof obj.warning === "string") warningTexts.push(obj.warning);
237
+ else warningTexts.push(JSON.stringify(obj));
238
+ }
239
+ }
240
+ } else if (typeof field === "string") {
241
+ warningTexts.push(field);
242
+ }
243
+ };
244
+ collectStrings(audit.warnings);
245
+ collectStrings(audit.fail_checks);
246
+ collectStrings(audit.possible_false_positives);
247
+
248
+ for (const text of warningTexts) {
249
+ const lower = text.toLowerCase();
250
+ if (lower.includes("unrecorded") || lower.includes("command execution")) {
251
+ buckets.unrecorded_command_execution.add(taskId);
252
+ }
253
+ if (lower.includes("artifact") || lower.includes("hygiene")) {
254
+ buckets.artifact_hygiene.add(taskId);
255
+ }
256
+ if (lower.includes("scope") || lower.includes("out_of_scope")) {
257
+ buckets.scope_changes.add(taskId);
258
+ }
259
+ if (lower.includes("release") || lower.includes("publish")) {
260
+ buckets.release_publish_claim.add(taskId);
261
+ }
262
+ }
263
+
264
+ // manual_verification_required flag
265
+ if (audit.manual_verification_required === true) {
266
+ buckets.manual_verification_required.add(taskId);
267
+ }
268
+ }
269
+
270
+ // 2. Stale tasks
271
+ try {
272
+ const cls = classifyStaleTask(task, watcher, now);
273
+ if (cls.is_stale) {
274
+ buckets.stale_task.add(taskId);
275
+ }
276
+ } catch {
277
+ // skip stale classification failure
278
+ }
279
+
280
+ // 3. Failed verification
281
+ if (task.status === "failed_verification") {
282
+ buckets.failed_verification.add(taskId);
283
+ }
284
+ }
285
+
286
+ const warnings: WarningEntry[] = [];
287
+ const addWarning = (
288
+ type: string,
289
+ severity: "error" | "warning" | "info",
290
+ likelyFalsePositive: boolean,
291
+ needsFix: boolean,
292
+ blocked: boolean,
293
+ recommendedAction: string
294
+ ) => {
295
+ const tasks = buckets[type];
296
+ if (tasks.size === 0) return;
297
+ warnings.push({
298
+ type,
299
+ severity,
300
+ affected_tasks_count: tasks.size,
301
+ affected_tasks: Array.from(tasks),
302
+ likely_false_positive: likelyFalsePositive,
303
+ needs_fix: needsFix,
304
+ blocked,
305
+ recommended_action: recommendedAction,
306
+ });
307
+ };
308
+
309
+ addWarning("failed_verification", "error", false, true, false, "Re-run verification or fix the failing checks before retrying.");
310
+ addWarning("stale_task", "warning", false, false, false, "Reconcile or recreate stale tasks.");
311
+ addWarning("manual_verification_required", "warning", false, false, false, "Review the audit findings and manually verify the changes.");
312
+ addWarning("unrecorded_command_execution", "info", false, true, false, "Investigate unrecorded command executions; ensure watcher captures all commands.");
313
+ addWarning("release_publish_claim", "info", false, false, true, "Verify the release/publish claim against actual release artifacts before proceeding.");
314
+ addWarning("artifact_hygiene", "info", true, false, false, "Review artifact hygiene warnings; many are likely false positives.");
315
+ addWarning("scope_changes", "info", true, false, false, "Review scope changes; verify they are intentional before accepting.");
316
+
317
+ sendJson(res, 200, { warnings, total: warnings.length });
318
+ } catch (err) {
319
+ sendJson(res, 200, { warnings: [], total: 0, error: errorMessage(err) });
320
+ }
321
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Control Center routes — evidence packs (/api/evidence-packs/*).
3
+ *
4
+ * Lists exported evidence packs (bounded to 50), reads a single pack detail,
5
+ * and exports a pack for a lineage on demand. The export endpoint is a POST
6
+ * route gated by the control token in the server router.
7
+ */
8
+ import { existsSync, readdirSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { type ServerResponse } from "node:http";
11
+ import {
12
+ exportTaskEvidencePack,
13
+ listEvidencePacks,
14
+ readEvidencePack,
15
+ type SafeEvidencePack,
16
+ } from "../../tools/evidencePack.js";
17
+ import { errorMessage, sendJson, config } from "../shared.js";
18
+
19
+ function augmentEvidencePackSummary(pack: SafeEvidencePack): SafeEvidencePack & {
20
+ export_status: "exported" | "pending";
21
+ evidence_json_exists: boolean;
22
+ evidence_md_exists: boolean;
23
+ exported_at: string;
24
+ } {
25
+ const jsonExists = existsSync(pack.files.json);
26
+ const mdExists = existsSync(pack.files.markdown);
27
+ return {
28
+ ...pack,
29
+ export_status: jsonExists ? "exported" : "pending",
30
+ evidence_json_exists: jsonExists,
31
+ evidence_md_exists: mdExists,
32
+ exported_at: pack.generated_at,
33
+ };
34
+ }
35
+
36
+ export function handleEvidencePacks(res: ServerResponse): void {
37
+ try {
38
+ const list = listEvidencePacks({ max_items: 50 });
39
+ const packs = list.evidence_packs.map((pack) => augmentEvidencePackSummary(pack));
40
+ // Detect lineages that exist but have no exported evidence pack yet, so the
41
+ // dashboard can show an "Export evidence pack" action for the most recent one.
42
+ const lineagesRoot = join(config.workspaceRoot, ".patchwarden", "lineages");
43
+ let lineageCount = 0;
44
+ const exportedIds = new Set(packs.map((p) => p.lineage_id));
45
+ const pendingLineageIds: string[] = [];
46
+ if (existsSync(lineagesRoot)) {
47
+ const dirs = readdirSync(lineagesRoot, { withFileTypes: true }).filter((e) => e.isDirectory());
48
+ lineageCount = dirs.length;
49
+ for (const dir of dirs) {
50
+ if (!exportedIds.has(dir.name)) pendingLineageIds.push(dir.name);
51
+ }
52
+ }
53
+ sendJson(res, 200, {
54
+ evidence_packs: packs,
55
+ total: packs.length,
56
+ truncated: list.truncated,
57
+ has_lineages: lineageCount > 0,
58
+ lineage_count: lineageCount,
59
+ pending_lineage_ids: pendingLineageIds.slice(0, 20),
60
+ reason: null,
61
+ });
62
+ } catch (err) {
63
+ sendJson(res, 200, { evidence_packs: [], total: 0, reason: errorMessage(err) });
64
+ }
65
+ }
66
+
67
+ export function handleEvidencePackDetail(res: ServerResponse, lineageId: string): void {
68
+ try {
69
+ if (!/^[A-Za-z0-9_-]+$/.test(lineageId)) {
70
+ sendJson(res, 400, { error: "Invalid lineage id" });
71
+ return;
72
+ }
73
+ const pack = readEvidencePack(lineageId);
74
+ if (!pack) {
75
+ sendJson(res, 404, { error: "Evidence pack not found" });
76
+ return;
77
+ }
78
+ sendJson(res, 200, augmentEvidencePackSummary(pack));
79
+ } catch (err) {
80
+ sendJson(res, 200, { lineage_id: lineageId, error: errorMessage(err), bounded: true });
81
+ }
82
+ }
83
+
84
+ export function handleEvidencePackExport(res: ServerResponse, lineageId: string): void {
85
+ try {
86
+ if (!/^[A-Za-z0-9_-]+$/.test(lineageId)) {
87
+ sendJson(res, 400, { error: "Invalid lineage id" });
88
+ return;
89
+ }
90
+ // Check lineage exists.
91
+ const lineageFile = join(config.workspaceRoot, ".patchwarden", "lineages", lineageId, "lineage.json");
92
+ if (!existsSync(lineageFile)) {
93
+ sendJson(res, 404, { error: "Lineage not found" });
94
+ return;
95
+ }
96
+ // Check if already exported.
97
+ const evidenceJsonPath = join(config.workspaceRoot, ".patchwarden", "evidence-packs", lineageId, "evidence.json");
98
+ if (existsSync(evidenceJsonPath)) {
99
+ sendJson(res, 200, { ok: true, already_exported: true, lineage_id: lineageId });
100
+ return;
101
+ }
102
+ const pack = exportTaskEvidencePack({ lineage_id: lineageId });
103
+ sendJson(res, 200, { ok: true, exported: true, lineage_id: lineageId, evidence_pack_id: pack.evidence_pack_id });
104
+ } catch (err) {
105
+ sendJson(res, 200, { ok: false, lineage_id: lineageId, error: errorMessage(err) });
106
+ }
107
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Control Center routes — task lineage (/api/lineages/*).
3
+ *
4
+ * Lists all lineages under .patchwarden/lineages (bounded to 50, most recently
5
+ * updated first) and serves a single lineage detail. Each record is projected
6
+ * through `toSafeTaskLineage` so full artifact content is bounded.
7
+ */
8
+ import { existsSync, readdirSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { type ServerResponse } from "node:http";
11
+ import { toSafeTaskLineage, type SafeTaskLineage, type TaskLineageRecord } from "../../tools/taskLineage.js";
12
+ import { config, errorMessage, readJsonFileSafeUnder, sendJson } from "../shared.js";
13
+
14
+ interface LineageSummary {
15
+ iterations: number;
16
+ main_task_count: number;
17
+ fix_task_count: number;
18
+ cleanup_task_count: number;
19
+ direct_verification: {
20
+ session_id: string;
21
+ status: string;
22
+ audit_decision: string;
23
+ command_count: number;
24
+ passed_commands: number;
25
+ failed_commands: number;
26
+ } | null;
27
+ warnings_count: number;
28
+ }
29
+
30
+ function augmentLineageSummary(safe: SafeTaskLineage, record: TaskLineageRecord): SafeTaskLineage & LineageSummary {
31
+ const directSessions = safe.tasks.direct_sessions;
32
+ const firstDirect = directSessions.length > 0 ? directSessions[0] : null;
33
+ const directVerification = firstDirect
34
+ ? {
35
+ session_id: firstDirect.session_id,
36
+ status: firstDirect.status || "unknown",
37
+ audit_decision: firstDirect.audit_decision || "not_run",
38
+ command_count: firstDirect.command_count ?? 0,
39
+ passed_commands: firstDirect.passed_commands ?? 0,
40
+ failed_commands: firstDirect.failed_commands ?? 0,
41
+ }
42
+ : null;
43
+ return {
44
+ ...safe,
45
+ iterations: record.rounds.length,
46
+ main_task_count: record.main_task ? 1 : 0,
47
+ fix_task_count: record.fix_tasks.length,
48
+ cleanup_task_count: record.cleanup_tasks.length,
49
+ direct_verification: directVerification,
50
+ warnings_count: record.warnings.length,
51
+ };
52
+ }
53
+
54
+ export function handleLineages(res: ServerResponse): void {
55
+ try {
56
+ const root = join(config.workspaceRoot, ".patchwarden", "lineages");
57
+ if (!existsSync(root)) {
58
+ sendJson(res, 200, { lineages: [], total: 0, reason: null });
59
+ return;
60
+ }
61
+ const lineages = readdirSync(root, { withFileTypes: true })
62
+ .filter((entry) => entry.isDirectory())
63
+ .map((entry) => readJsonFileSafeUnder<TaskLineageRecord>(root, join(entry.name, "lineage.json")))
64
+ .filter((entry): entry is TaskLineageRecord => entry !== null)
65
+ .map((entry) => augmentLineageSummary(toSafeTaskLineage(entry, 6), entry))
66
+ .sort((a, b) => b.updated_at.localeCompare(a.updated_at))
67
+ .slice(0, 50);
68
+ sendJson(res, 200, { lineages, total: lineages.length, reason: null });
69
+ } catch (err) {
70
+ sendJson(res, 200, { lineages: [], total: 0, reason: errorMessage(err) });
71
+ }
72
+ }
73
+
74
+ export function handleLineageDetail(res: ServerResponse, lineageId: string): void {
75
+ try {
76
+ if (!/^[A-Za-z0-9_-]+$/.test(lineageId)) {
77
+ sendJson(res, 400, { error: "Invalid lineage id" });
78
+ return;
79
+ }
80
+ const data = readJsonFileSafeUnder<TaskLineageRecord>(
81
+ join(config.workspaceRoot, ".patchwarden", "lineages"),
82
+ join(lineageId, "lineage.json")
83
+ );
84
+ if (!data) {
85
+ sendJson(res, 404, { error: "Lineage not found" });
86
+ return;
87
+ }
88
+ sendJson(res, 200, augmentLineageSummary(toSafeTaskLineage(data, 20), data));
89
+ } catch (err) {
90
+ sendJson(res, 200, { lineage_id: lineageId, error: errorMessage(err) });
91
+ }
92
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Control Center routes — project policy + release status.
3
+ *
4
+ * Read-only wrappers around `getProjectPolicySummary` that surface release
5
+ * readiness, blocked commands, and version consistency for the dashboard's
6
+ * release panel. Never performs remote writes (`remote_write_performed` is
7
+ * always false).
8
+ */
9
+ import { existsSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { type ServerResponse } from "node:http";
12
+ import { getProjectPolicySummary } from "../../policy/projectPolicy.js";
13
+ import { errorMessage, sendJson } from "../shared.js";
14
+
15
+ export function handleProjectPolicy(res: ServerResponse, repoPath: string): void {
16
+ try {
17
+ sendJson(res, 200, getProjectPolicySummary(repoPath || "."));
18
+ } catch (err) {
19
+ sendJson(res, 200, {
20
+ repo_path: repoPath || ".",
21
+ valid: false,
22
+ issues: [{ code: "policy_unavailable", severity: "error", field: "repo_path", message: errorMessage(err) }],
23
+ });
24
+ }
25
+ }
26
+
27
+ export function handleReleaseStatus(res: ServerResponse, repoPath: string): void {
28
+ try {
29
+ const policy = getProjectPolicySummary(repoPath || ".");
30
+ const readiness = policy.release_readiness;
31
+ const requiredCommands = readiness.required_commands.map((c) => ({
32
+ command: c.command,
33
+ allowed: c.allowed,
34
+ blocked_reason: c.allowed ? null : (c.reason || "not_allowed"),
35
+ }));
36
+ const commandsBlockedCount = requiredCommands.filter((c) => !c.allowed).length;
37
+ const hasPackageJson = existsSync(join(policy.resolved_repo_path, "package.json"));
38
+ const versionSource = hasPackageJson
39
+ ? "package.json"
40
+ : (repoPath || ".") === "."
41
+ ? "workspace_root"
42
+ : "unknown";
43
+
44
+ let readyState: string;
45
+ if (!policy.valid || commandsBlockedCount > 0) {
46
+ readyState = "blocked";
47
+ } else if (readiness.version === null) {
48
+ readyState = "unknown";
49
+ } else if (readiness.version_consistent === true) {
50
+ readyState = "ready";
51
+ } else {
52
+ readyState = "blocked";
53
+ }
54
+
55
+ sendJson(res, 200, {
56
+ repo_path: repoPath || ".",
57
+ resolved_repo_path: policy.resolved_repo_path,
58
+ policy_valid: policy.valid,
59
+ policy_issue_count: policy.issues.length,
60
+ policy_issues: policy.issues.slice(0, 10),
61
+ release_readiness: readiness,
62
+ package_name: readiness.package_name,
63
+ version_source: versionSource,
64
+ version_consistent: readiness.version_consistent,
65
+ required_commands: requiredCommands,
66
+ commands_blocked_count: commandsBlockedCount,
67
+ ready_state: readyState,
68
+ next_action: policy.valid && readiness.version_consistent !== false
69
+ ? "Run release_check via create_task template, or run_task_loop with template=release_check."
70
+ : "Fix project-policy or version consistency issues before release preparation.",
71
+ remote_write_performed: false,
72
+ });
73
+ } catch (err) {
74
+ sendJson(res, 200, {
75
+ repo_path: repoPath || ".",
76
+ policy_valid: false,
77
+ error: errorMessage(err),
78
+ remote_write_performed: false,
79
+ });
80
+ }
81
+ }