patchwarden 1.1.0 → 1.5.0

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 (70) hide show
  1. package/README.en.md +41 -7
  2. package/README.md +36 -9
  3. package/dist/controlCenter.js +197 -1
  4. package/dist/direct/directSessionStore.d.ts +2 -0
  5. package/dist/direct/directVerification.js +7 -0
  6. package/dist/doctor.js +1 -1
  7. package/dist/policy/projectPolicy.d.ts +55 -0
  8. package/dist/policy/projectPolicy.js +286 -0
  9. package/dist/smoke-test.js +8 -8
  10. package/dist/test/unit/evidence-pack.test.d.ts +1 -0
  11. package/dist/test/unit/evidence-pack.test.js +130 -0
  12. package/dist/test/unit/project-policy-release-mode.test.d.ts +1 -0
  13. package/dist/test/unit/project-policy-release-mode.test.js +125 -0
  14. package/dist/test/unit/run-task-loop.test.d.ts +1 -0
  15. package/dist/test/unit/run-task-loop.test.js +380 -0
  16. package/dist/test/unit/schema-drift-check.test.js +10 -9
  17. package/dist/tools/evidencePack.d.ts +39 -0
  18. package/dist/tools/evidencePack.js +168 -0
  19. package/dist/tools/recommendAgentForTask.d.ts +19 -0
  20. package/dist/tools/recommendAgentForTask.js +56 -0
  21. package/dist/tools/registry.js +376 -2
  22. package/dist/tools/releaseMode.d.ts +50 -0
  23. package/dist/tools/releaseMode.js +370 -0
  24. package/dist/tools/runDirectVerificationBundle.d.ts +26 -0
  25. package/dist/tools/runDirectVerificationBundle.js +64 -0
  26. package/dist/tools/runTaskLoop.d.ts +57 -0
  27. package/dist/tools/runTaskLoop.js +417 -0
  28. package/dist/tools/runVerification.d.ts +4 -0
  29. package/dist/tools/runVerification.js +4 -0
  30. package/dist/tools/safeViews.d.ts +6 -0
  31. package/dist/tools/safeViews.js +2 -0
  32. package/dist/tools/taskLineage.d.ts +91 -0
  33. package/dist/tools/taskLineage.js +175 -0
  34. package/dist/tools/toolCatalog.d.ts +2 -2
  35. package/dist/tools/toolCatalog.js +6 -0
  36. package/dist/tools/toolRegistry.js +110 -0
  37. package/dist/version.d.ts +2 -2
  38. package/dist/version.js +2 -2
  39. package/docs/chatgpt-usage.md +31 -0
  40. package/docs/control-center/README.md +9 -0
  41. package/package.json +2 -2
  42. package/scripts/checks/control-center-smoke.js +87 -0
  43. package/scripts/checks/control-smoke.js +2 -2
  44. package/scripts/checks/mcp-manifest-check.js +12 -0
  45. package/scripts/checks/mcp-smoke.js +31 -7
  46. package/scripts/checks/watcher-supervisor-smoke.js +1 -1
  47. package/src/controlCenter.ts +198 -1
  48. package/src/direct/directSessionStore.ts +2 -0
  49. package/src/direct/directVerification.ts +7 -0
  50. package/src/doctor.ts +1 -1
  51. package/src/policy/projectPolicy.ts +344 -0
  52. package/src/smoke-test.ts +5 -5
  53. package/src/test/unit/evidence-pack.test.ts +142 -0
  54. package/src/test/unit/project-policy-release-mode.test.ts +156 -0
  55. package/src/test/unit/run-task-loop.test.ts +425 -0
  56. package/src/test/unit/schema-drift-check.test.ts +11 -9
  57. package/src/tools/evidencePack.ts +205 -0
  58. package/src/tools/listWorkspace.ts +71 -71
  59. package/src/tools/recommendAgentForTask.ts +79 -0
  60. package/src/tools/registry.ts +405 -2
  61. package/src/tools/releaseMode.ts +450 -0
  62. package/src/tools/runDirectVerificationBundle.ts +98 -0
  63. package/src/tools/runTaskLoop.ts +526 -0
  64. package/src/tools/runVerification.ts +8 -0
  65. package/src/tools/safeViews.ts +2 -0
  66. package/src/tools/taskLineage.ts +300 -0
  67. package/src/tools/toolCatalog.ts +6 -0
  68. package/src/tools/toolRegistry.ts +110 -0
  69. package/src/version.ts +2 -2
  70. package/ui/pages/dashboard.html +143 -2
@@ -118,11 +118,14 @@ try {
118
118
  "discover_tools",
119
119
  "explain_tool",
120
120
  "export_handoff",
121
+ "export_task_evidence_pack",
121
122
  "finalize_direct_session",
122
123
  "get_diff",
123
124
  "get_plan",
125
+ "get_project_policy",
124
126
  "get_result",
125
127
  "get_result_json",
128
+ "get_task_lineage",
126
129
  "get_task_log_tail",
127
130
  "get_task_progress",
128
131
  "get_task_status",
@@ -139,9 +142,16 @@ try {
139
142
  "merge_worktree",
140
143
  "read_goal",
141
144
  "read_workspace_file",
145
+ "recommend_agent_for_task",
142
146
  "reconcile_tasks",
143
147
  "reject_subgoal",
148
+ "release_check",
149
+ "release_cleanup",
150
+ "release_prepare",
151
+ "release_verify",
144
152
  "retry_task",
153
+ "run_direct_verification_bundle",
154
+ "run_task_loop",
145
155
  "run_verification",
146
156
  "safe_audit",
147
157
  "safe_audit_direct_session",
@@ -167,7 +177,7 @@ try {
167
177
  if (!tools._meta || typeof tools._meta.tool_manifest_sha256 !== "string" || tools._meta.tool_manifest_sha256.length !== 64) {
168
178
  throw new Error(`tools/list _meta missing manifest hash: ${JSON.stringify(tools._meta || null)}`);
169
179
  }
170
- if (tools._meta.tool_profile !== "full" || tools._meta.tool_count !== 54) {
180
+ if (tools._meta.tool_profile !== "full" || tools._meta.tool_count !== 64) {
171
181
  throw new Error(`tools/list _meta profile/count mismatch: ${JSON.stringify(tools._meta)}`);
172
182
  }
173
183
  if (typeof tools._meta.schema_epoch !== "string" || typeof tools._meta.server_version !== "string") {
@@ -417,7 +427,7 @@ try {
417
427
  await disabledClient.close();
418
428
  ok("chatgpt_direct disabled exposes only health_check with diagnostic");
419
429
 
420
- // 2. chatgpt_direct enabled: 13 tools + minimal create_direct_session
430
+ // 2. chatgpt_direct enabled: 14 tools + minimal create_direct_session
421
431
  const enabledConfigPath = join(tempRoot, "direct-enabled.json");
422
432
  const directRepo = join(workspaceRoot, "direct-fixture");
423
433
  mkdirSync(join(directRepo, "src"), { recursive: true });
@@ -473,6 +483,7 @@ try {
473
483
  "health_check",
474
484
  "list_workspace",
475
485
  "read_workspace_file",
486
+ "run_direct_verification_bundle",
476
487
  "run_verification",
477
488
  "safe_audit_direct_session",
478
489
  "safe_direct_summary",
@@ -483,8 +494,8 @@ try {
483
494
  if (JSON.stringify(enabledNames) !== JSON.stringify(expectedDirect)) {
484
495
  throw new Error(`chatgpt_direct enabled tools mismatch: ${enabledNames.join(", ")}`);
485
496
  }
486
- if (enabledTools._meta.tool_count !== 13) {
487
- throw new Error(`chatgpt_direct enabled tool_count should be 13, got ${enabledTools._meta.tool_count}`);
497
+ if (enabledTools._meta.tool_count !== 14) {
498
+ throw new Error(`chatgpt_direct enabled tool_count should be 14, got ${enabledTools._meta.tool_count}`);
488
499
  }
489
500
 
490
501
  // Minimal create_direct_session
@@ -497,6 +508,19 @@ try {
497
508
  if (!sessionResult.session_id || !sessionResult.session_id.startsWith("direct_")) {
498
509
  throw new Error(`create_direct_session failed: ${JSON.stringify(sessionResult)}`);
499
510
  }
511
+ const bundleResult = JSON.parse(
512
+ (await enabledClient.callTool({
513
+ name: "run_direct_verification_bundle",
514
+ arguments: { session_id: sessionResult.session_id, commands: ["npm test"], timeout_seconds: 30 },
515
+ })).content?.[0]?.text || "{}"
516
+ );
517
+ if (bundleResult.status !== "passed" || bundleResult.command_count !== 1 || bundleResult.passed_commands !== 1) {
518
+ throw new Error(`run_direct_verification_bundle failed: ${JSON.stringify(bundleResult)}`);
519
+ }
520
+ const bundleText = JSON.stringify(bundleResult).toLowerCase();
521
+ if (bundleText.includes("stdout_tail") || bundleText.includes("stderr_tail") || bundleText.includes("verification.log")) {
522
+ throw new Error("run_direct_verification_bundle leaked log tail fields");
523
+ }
500
524
 
501
525
  const enabledHealth = JSON.parse(
502
526
  (await enabledClient.callTool({ name: "health_check", arguments: {} })).content?.[0]?.text || "{}"
@@ -504,12 +528,12 @@ try {
504
528
  if (enabledHealth.direct_profile_enabled !== true) {
505
529
  throw new Error(`direct_profile_enabled should be true, got ${enabledHealth.direct_profile_enabled}`);
506
530
  }
507
- if (enabledHealth.direct_tool_count !== 13) {
508
- throw new Error(`direct_tool_count should be 13, got ${enabledHealth.direct_tool_count}`);
531
+ if (enabledHealth.direct_tool_count !== 14) {
532
+ throw new Error(`direct_tool_count should be 14, got ${enabledHealth.direct_tool_count}`);
509
533
  }
510
534
 
511
535
  await enabledClient.close();
512
- ok("chatgpt_direct enabled exposes 13 tools and create_direct_session works");
536
+ ok("chatgpt_direct enabled exposes 14 tools and create_direct_session works");
513
537
  } catch (error) {
514
538
  fail("MCP smoke test", error);
515
539
  } finally {
@@ -105,7 +105,7 @@ function createFixture(name) {
105
105
  server_version: "0.6.0",
106
106
  schema_epoch: "2026-06-22-v6",
107
107
  tool_profile: "chatgpt_core",
108
- tool_count: 21,
108
+ tool_count: 26,
109
109
  tool_names: [],
110
110
  tool_manifest_sha256: "a".repeat(64),
111
111
  });
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { createServer, get as httpGet, type IncomingMessage, type ServerResponse } from "node:http";
16
- import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
16
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
17
17
  import { delimiter, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { randomUUID } from "node:crypto";
@@ -33,6 +33,10 @@ import { readWatcherStatus, type WatcherStatusSnapshot } from "./watcherStatus.j
33
33
  import { redactSensitiveContent } from "./security/contentRedaction.js";
34
34
  import { guardWorkspacePath } from "./security/pathGuard.js";
35
35
  import { auditTask } from "./tools/auditTask.js";
36
+ import { getProjectPolicySummary } from "./policy/projectPolicy.js";
37
+ import { safeDirectSummary } from "./tools/safeViews.js";
38
+ import { toSafeTaskLineage, type TaskLineageRecord } from "./tools/taskLineage.js";
39
+ import { listEvidencePacks, readEvidencePack } from "./tools/evidencePack.js";
36
40
  import { PATCHWARDEN_VERSION, TOOL_SCHEMA_EPOCH } from "./version.js";
37
41
 
38
42
  // ── Paths ─────────────────────────────────────────────────────────
@@ -250,6 +254,25 @@ function readJsonFileSafe<T = unknown>(filePath: string): T | null {
250
254
  }
251
255
  }
252
256
 
257
+ function isPathInside(root: string, target: string): boolean {
258
+ const rel = relative(root, target);
259
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
260
+ }
261
+
262
+ function readJsonFileSafeUnder<T = unknown>(root: string, relPath: string): T | null {
263
+ const base = resolve(root);
264
+ const target = resolve(base, relPath);
265
+ if (!isPathInside(base, target) || !existsSync(target)) return null;
266
+ try {
267
+ const realBase = realpathSync(base);
268
+ const realTarget = realpathSync(target);
269
+ if (!isPathInside(realBase, realTarget)) return null;
270
+ return readJsonFileSafe<T>(realTarget);
271
+ } catch {
272
+ return null;
273
+ }
274
+ }
275
+
253
276
  function readTextFileSafe(filePath: string): string | null {
254
277
  if (!existsSync(filePath)) return null;
255
278
  try {
@@ -1020,6 +1043,108 @@ function handleStaleTasks(res: ServerResponse): void {
1020
1043
  }
1021
1044
  }
1022
1045
 
1046
+ function handleLineages(res: ServerResponse): void {
1047
+ try {
1048
+ const root = join(config.workspaceRoot, ".patchwarden", "lineages");
1049
+ if (!existsSync(root)) {
1050
+ sendJson(res, 200, { lineages: [], total: 0, reason: null });
1051
+ return;
1052
+ }
1053
+ const lineages = readdirSync(root, { withFileTypes: true })
1054
+ .filter((entry) => entry.isDirectory())
1055
+ .map((entry) => readJsonFileSafeUnder<TaskLineageRecord>(root, join(entry.name, "lineage.json")))
1056
+ .filter((entry): entry is TaskLineageRecord => entry !== null)
1057
+ .map((entry) => toSafeTaskLineage(entry, 6))
1058
+ .sort((a, b) => b.updated_at.localeCompare(a.updated_at))
1059
+ .slice(0, 50);
1060
+ sendJson(res, 200, { lineages, total: lineages.length, reason: null });
1061
+ } catch (err) {
1062
+ sendJson(res, 200, { lineages: [], total: 0, reason: errorMessage(err) });
1063
+ }
1064
+ }
1065
+
1066
+ function handleLineageDetail(res: ServerResponse, lineageId: string): void {
1067
+ try {
1068
+ if (!/^[A-Za-z0-9_-]+$/.test(lineageId)) {
1069
+ sendJson(res, 400, { error: "Invalid lineage id" });
1070
+ return;
1071
+ }
1072
+ const data = readJsonFileSafeUnder<TaskLineageRecord>(
1073
+ join(config.workspaceRoot, ".patchwarden", "lineages"),
1074
+ join(lineageId, "lineage.json")
1075
+ );
1076
+ if (!data) {
1077
+ sendJson(res, 404, { error: "Lineage not found" });
1078
+ return;
1079
+ }
1080
+ sendJson(res, 200, toSafeTaskLineage(data, 20));
1081
+ } catch (err) {
1082
+ sendJson(res, 200, { lineage_id: lineageId, error: errorMessage(err) });
1083
+ }
1084
+ }
1085
+
1086
+ function handleProjectPolicy(res: ServerResponse, repoPath: string): void {
1087
+ try {
1088
+ sendJson(res, 200, getProjectPolicySummary(repoPath || "."));
1089
+ } catch (err) {
1090
+ sendJson(res, 200, {
1091
+ repo_path: repoPath || ".",
1092
+ valid: false,
1093
+ issues: [{ code: "policy_unavailable", severity: "error", field: "repo_path", message: errorMessage(err) }],
1094
+ });
1095
+ }
1096
+ }
1097
+
1098
+ function handleReleaseStatus(res: ServerResponse, repoPath: string): void {
1099
+ try {
1100
+ const policy = getProjectPolicySummary(repoPath || ".");
1101
+ sendJson(res, 200, {
1102
+ repo_path: repoPath || ".",
1103
+ resolved_repo_path: policy.resolved_repo_path,
1104
+ policy_valid: policy.valid,
1105
+ policy_issue_count: policy.issues.length,
1106
+ policy_issues: policy.issues.slice(0, 10),
1107
+ release_readiness: policy.release_readiness,
1108
+ next_action: policy.valid && policy.release_readiness.version_consistent !== false
1109
+ ? "Run release_check or release_prepare from the full MCP profile when ready."
1110
+ : "Fix project-policy or version consistency issues before release preparation.",
1111
+ remote_write_performed: false,
1112
+ });
1113
+ } catch (err) {
1114
+ sendJson(res, 200, {
1115
+ repo_path: repoPath || ".",
1116
+ policy_valid: false,
1117
+ error: errorMessage(err),
1118
+ remote_write_performed: false,
1119
+ });
1120
+ }
1121
+ }
1122
+
1123
+ function handleEvidencePacks(res: ServerResponse): void {
1124
+ try {
1125
+ sendJson(res, 200, listEvidencePacks({ max_items: 50 }));
1126
+ } catch (err) {
1127
+ sendJson(res, 200, { evidence_packs: [], total: 0, reason: errorMessage(err) });
1128
+ }
1129
+ }
1130
+
1131
+ function handleEvidencePackDetail(res: ServerResponse, lineageId: string): void {
1132
+ try {
1133
+ if (!/^[A-Za-z0-9_-]+$/.test(lineageId)) {
1134
+ sendJson(res, 400, { error: "Invalid lineage id" });
1135
+ return;
1136
+ }
1137
+ const pack = readEvidencePack(lineageId);
1138
+ if (!pack) {
1139
+ sendJson(res, 404, { error: "Evidence pack not found" });
1140
+ return;
1141
+ }
1142
+ sendJson(res, 200, pack);
1143
+ } catch (err) {
1144
+ sendJson(res, 200, { lineage_id: lineageId, error: errorMessage(err), bounded: true });
1145
+ }
1146
+ }
1147
+
1023
1148
  /**
1024
1149
  * Reconcile a stale task. Does NOT delete the task. Reads the task files,
1025
1150
  * decides whether it is safe to mark the task as stale/archived, writes a
@@ -1655,6 +1780,29 @@ function handleDirectSessionDetail(res: ServerResponse, sessionId: string): void
1655
1780
  }
1656
1781
  }
1657
1782
 
1783
+ function handleDirectSessionSafeSummary(res: ServerResponse, sessionId: string): void {
1784
+ try {
1785
+ if (
1786
+ sessionId === "." ||
1787
+ sessionId === ".." ||
1788
+ sessionId.includes("/") ||
1789
+ sessionId.includes("\\") ||
1790
+ sessionId.includes("\0")
1791
+ ) {
1792
+ sendJson(res, 400, { error: "Invalid session id" });
1793
+ return;
1794
+ }
1795
+ sendJson(res, 200, safeDirectSummary(sessionId, { max_items: 12 }));
1796
+ } catch (err) {
1797
+ sendJson(res, 200, {
1798
+ session_id: sessionId,
1799
+ error: errorMessage(err),
1800
+ large_logs_omitted: true,
1801
+ diff_omitted: true,
1802
+ });
1803
+ }
1804
+ }
1805
+
1658
1806
  function parseReviewVerdict(content: string): string | null {
1659
1807
  // independent-review.md format: "**Verdict**: PASS" (case-insensitive)
1660
1808
  const m = content.match(/\*\*Verdict\*\*\s*:\s*([A-Za-z]+)/);
@@ -1952,6 +2100,44 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
1952
2100
  handleStaleTasks(res);
1953
2101
  return;
1954
2102
  }
2103
+ if (method === "GET" && pathname === "/api/lineages") {
2104
+ handleLineages(res);
2105
+ return;
2106
+ }
2107
+ const lineageMatch = pathname.match(/^\/api\/lineages\/([^/]+)$/);
2108
+ if (method === "GET" && lineageMatch) {
2109
+ let lineageId: string;
2110
+ try {
2111
+ lineageId = decodeURIComponent(lineageMatch[1]);
2112
+ } catch {
2113
+ lineageId = lineageMatch[1];
2114
+ }
2115
+ handleLineageDetail(res, lineageId);
2116
+ return;
2117
+ }
2118
+ if (method === "GET" && pathname === "/api/project-policy") {
2119
+ handleProjectPolicy(res, parsedUrl.searchParams.get("repo_path") || ".");
2120
+ return;
2121
+ }
2122
+ if (method === "GET" && pathname === "/api/release/status") {
2123
+ handleReleaseStatus(res, parsedUrl.searchParams.get("repo_path") || ".");
2124
+ return;
2125
+ }
2126
+ if (method === "GET" && pathname === "/api/evidence-packs") {
2127
+ handleEvidencePacks(res);
2128
+ return;
2129
+ }
2130
+ const evidencePackMatch = pathname.match(/^\/api\/evidence-packs\/([^/]+)$/);
2131
+ if (method === "GET" && evidencePackMatch) {
2132
+ let lineageId: string;
2133
+ try {
2134
+ lineageId = decodeURIComponent(evidencePackMatch[1]);
2135
+ } catch {
2136
+ lineageId = evidencePackMatch[1];
2137
+ }
2138
+ handleEvidencePackDetail(res, lineageId);
2139
+ return;
2140
+ }
1955
2141
  const taskMatch = pathname.match(/^\/api\/tasks\/([^/]+)$/);
1956
2142
  if (method === "GET" && taskMatch) {
1957
2143
  let taskId: string;
@@ -2010,6 +2196,17 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
2010
2196
  handleDirectSessions(res);
2011
2197
  return;
2012
2198
  }
2199
+ const directSessionSummaryMatch = pathname.match(/^\/api\/direct-sessions\/([^/]+)\/summary$/);
2200
+ if (method === "GET" && directSessionSummaryMatch) {
2201
+ let sessionId: string;
2202
+ try {
2203
+ sessionId = decodeURIComponent(directSessionSummaryMatch[1]);
2204
+ } catch {
2205
+ sessionId = directSessionSummaryMatch[1];
2206
+ }
2207
+ handleDirectSessionSafeSummary(res, sessionId);
2208
+ return;
2209
+ }
2013
2210
  const directSessionMatch = pathname.match(/^\/api\/direct-sessions\/([^/]+)$/);
2014
2211
  if (method === "GET" && directSessionMatch) {
2015
2212
  let sessionId: string;
@@ -40,6 +40,8 @@ export interface DirectSessionVerificationRun {
40
40
  exit_code: number | null;
41
41
  passed: boolean;
42
42
  timed_out: boolean;
43
+ redacted?: boolean;
44
+ redaction_categories?: string[];
43
45
  stdout_tail: string;
44
46
  stderr_tail: string;
45
47
  started_at: string;
@@ -69,6 +69,13 @@ export async function runDirectVerification(
69
69
  exit_code: result.exitCode,
70
70
  passed,
71
71
  timed_out: result.timedOut,
72
+ redacted: stdoutRedacted.redacted || stderrRedacted.redacted,
73
+ redaction_categories: [
74
+ ...new Set([
75
+ ...stdoutRedacted.redaction_categories,
76
+ ...stderrRedacted.redaction_categories,
77
+ ]),
78
+ ],
72
79
  stdout_tail: stdoutTail,
73
80
  stderr_tail: stderrTail,
74
81
  started_at: startedAt,
package/src/doctor.ts CHANGED
@@ -248,7 +248,7 @@ try {
248
248
  const coreTools = selectToolsForProfile(fullTools, "chatgpt_core", config?.enableDirectProfile);
249
249
  const createSchema = coreTools.find((tool) => tool.name === "create_task")?.inputSchema as any;
250
250
  const waitSchema = coreTools.find((tool) => tool.name === "wait_for_task")?.inputSchema as any;
251
- check("Full tool profile exposes 54 tools", fullTools.length === 54, `${fullTools.length} tools`);
251
+ check("Full tool profile exposes 64 tools", fullTools.length === 64, `${fullTools.length} tools`);
252
252
  check(
253
253
  `chatgpt_core profile exposes the exact ${CHATGPT_CORE_TOOL_NAMES.length}-tool manifest`,
254
254
  JSON.stringify(coreTools.map((tool) => tool.name)) === JSON.stringify(CHATGPT_CORE_TOOL_NAMES),