@davidorex/pi-behavior-monitors 0.14.4 → 0.26.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.
package/dist/index.js CHANGED
@@ -12,20 +12,51 @@ import * as fs from "node:fs";
12
12
  import * as os from "node:os";
13
13
  import * as path from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
- import { readBlock } from "@davidorex/pi-project/block-api";
16
- import { validateFromFile } from "@davidorex/pi-project/schema-validator";
15
+ import { appendToBlock, appendToTypedFile, readBlock, upsertItemInBlock, writeTypedFile, } from "@davidorex/pi-context/block-api";
16
+ import { validateFromFile } from "@davidorex/pi-context/schema-validator";
17
+ import { dateRotatedPath, executeAgent } from "@davidorex/pi-jit-agents";
17
18
  import { createAgentLoader } from "@davidorex/pi-workflows/agent-spec";
18
19
  import { compileAgentSpec } from "@davidorex/pi-workflows/step-shared";
19
- import { complete, StringEnum } from "@mariozechner/pi-ai";
20
- import { getAgentDir } from "@mariozechner/pi-coding-agent";
21
- import { Box, Text } from "@mariozechner/pi-tui";
22
- import { Type } from "@sinclair/typebox";
20
+ import { StringEnum, Type } from "@earendil-works/pi-ai";
21
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
22
+ import { Box, Text } from "@earendil-works/pi-tui";
23
23
  import nunjucks from "nunjucks";
24
24
  const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
25
- const EXAMPLES_DIR = path.join(EXTENSION_DIR, "..", "examples");
26
- const AGENTS_DIR = path.join(EXTENSION_DIR, "..", "agents");
27
- /** Tool definition for forcing structured verdict output from the classify LLM call. */
28
- const VERDICT_TOOL = {
25
+ /**
26
+ * Package root is one level up from `dist/` at runtime (compiled `dist/index.js`),
27
+ * or equal to `EXTENSION_DIR` when running from source (e.g. vitest). Detecting
28
+ * which case we're in by inspecting the basename keeps the bundled-content paths
29
+ * (`examples/`, `agents/`) reachable from both contexts without separate test
30
+ * fixtures.
31
+ */
32
+ const PACKAGE_ROOT = path.basename(EXTENSION_DIR) === "dist" ? path.dirname(EXTENSION_DIR) : EXTENSION_DIR;
33
+ const EXAMPLES_DIR = path.join(PACKAGE_ROOT, "examples");
34
+ const AGENTS_DIR = path.join(PACKAGE_ROOT, "agents");
35
+ const SCHEMAS_DIR = path.join(PACKAGE_ROOT, "schemas");
36
+ /**
37
+ * Framework-contract schemas for monitor side-car state files. The
38
+ * `*.patterns.json` and `*.instructions.json` files written by `learnPattern`
39
+ * and `saveInstructions` route through `appendToTypedFile` /
40
+ * `writeTypedFile` from `@davidorex/pi-context/block-api`, threading these
41
+ * schema paths so AJV validation, atomic write, and proper-lockfile gating
42
+ * apply uniformly with `.project/<block>.json` writes (FGAP-019 closure;
43
+ * issue-065's three remaining bypass categories — no AJV, no
44
+ * proper-lockfile, no centralized choke point — closed for monitor
45
+ * framework state).
46
+ */
47
+ const PATTERN_LIST_SCHEMA_PATH = path.join(SCHEMAS_DIR, "monitor-pattern-list.schema.json");
48
+ const INSTRUCTIONS_LIST_SCHEMA_PATH = path.join(SCHEMAS_DIR, "monitor-instruction-list.schema.json");
49
+ /**
50
+ * Tool definition for forcing structured verdict output from the classify LLM call.
51
+ *
52
+ * Retained as a documentation reference; the live phantom-tool used at dispatch
53
+ * time is now built by `buildPhantomTool(outputSchema)` inside pi-jit-agents'
54
+ * `executeAgent`, fed by the agent spec's `outputSchema` (canonical:
55
+ * `verdict.schema.json`). The shape below mirrors that schema. Underscore
56
+ * prefix suppresses the unused-symbol lint while preserving the on-file
57
+ * trace of the contract that the phantom-tool path now enforces.
58
+ */
59
+ const _VERDICT_TOOL = {
29
60
  name: "classify_verdict",
30
61
  description: "Output the monitor classification verdict",
31
62
  parameters: Type.Object({
@@ -84,41 +115,71 @@ export const VALID_EVENTS = new Set(["message_end", "turn_end", "agent_end", "co
84
115
  function isValidEvent(event) {
85
116
  return VALID_EVENTS.has(event);
86
117
  }
87
- // =============================================================================
88
- // Discovery
89
- // =============================================================================
90
- export function discoverMonitors() {
91
- const dirs = [];
92
- // project-local
118
+ /**
119
+ * Walk up from `process.cwd()` looking for a `.pi/monitors/` directory, stopping
120
+ * at the first `.git` boundary so we never escape into the user's home config.
121
+ * Returns the resolved path or `null` when no project-tier monitors directory
122
+ * exists. Single source for the project-tier walk used by both `discoverMonitors`
123
+ * and `createMonitorAgentTemplateEnv` (the previous `resolveProjectMonitorsDir`
124
+ * also returned a synthesized cwd-rooted fallback path even when no directory
125
+ * existed, which conflated "exists" with "would be created on seed"; that
126
+ * conflation is no longer needed and is removed here).
127
+ */
128
+ function findProjectMonitorsDir() {
93
129
  let cwd = process.cwd();
94
130
  while (true) {
95
131
  const candidate = path.join(cwd, ".pi", "monitors");
96
- if (isDir(candidate)) {
97
- dirs.push(candidate);
98
- break;
99
- }
100
- // Stop at project root (.git boundary) — don't traverse into user home config
132
+ if (isDir(candidate))
133
+ return candidate;
101
134
  if (isDir(path.join(cwd, ".git")))
102
- break;
135
+ return null;
103
136
  const parent = path.dirname(cwd);
104
137
  if (parent === cwd)
105
- break;
138
+ return null;
106
139
  cwd = parent;
107
140
  }
108
- // global
141
+ }
142
+ /**
143
+ * Discover monitors across three tiers; first match by `name` wins.
144
+ *
145
+ * Precedence (highest first):
146
+ * 1. project `.pi/monitors/` (walk-up to .git boundary)
147
+ * 2. global `~/.pi/agent/monitors/` (via `getAgentDir()`)
148
+ * 3. bundled `<package>/examples/` (built-in defaults shipped with this extension)
149
+ *
150
+ * Any monitor file in a lower tier whose `name` matches an already-seen
151
+ * higher-tier monitor is recorded in `overrides` (not loaded). This replaces
152
+ * the prior `seedExamples()` copy-on-first-run pattern: bundled fixes (e.g.
153
+ * commit affe992 routing fragility/hedge classifiers through `agent_end`) now
154
+ * propagate to users automatically because tier 3 is read live, not copied.
155
+ */
156
+ export function discoverMonitors() {
157
+ const dirs = [];
158
+ const projectDir = findProjectMonitorsDir();
159
+ if (projectDir)
160
+ dirs.push(projectDir);
109
161
  const globalDir = path.join(getAgentDir(), "monitors");
110
162
  if (isDir(globalDir))
111
163
  dirs.push(globalDir);
164
+ if (isDir(EXAMPLES_DIR))
165
+ dirs.push(EXAMPLES_DIR);
112
166
  const seen = new Map();
167
+ const overrides = [];
113
168
  for (const dir of dirs) {
114
169
  for (const file of listMonitorFiles(dir)) {
115
170
  const monitor = parseMonitorJson(path.join(dir, file), dir);
116
- if (monitor && !seen.has(monitor.name)) {
171
+ if (!monitor)
172
+ continue;
173
+ const winner = seen.get(monitor.name);
174
+ if (!winner) {
117
175
  seen.set(monitor.name, monitor);
118
176
  }
177
+ else {
178
+ overrides.push({ name: monitor.name, winnerDir: winner.dir, losingDir: dir });
179
+ }
119
180
  }
120
181
  }
121
- return Array.from(seen.values());
182
+ return { monitors: Array.from(seen.values()), overrides };
122
183
  }
123
184
  function isDir(p) {
124
185
  try {
@@ -209,73 +270,6 @@ function parseMonitorJson(filePath, dir) {
209
270
  };
210
271
  }
211
272
  // =============================================================================
212
- // Example seeding
213
- // =============================================================================
214
- export function resolveProjectMonitorsDir() {
215
- let cwd = process.cwd();
216
- while (true) {
217
- const piDir = path.join(cwd, ".pi");
218
- if (isDir(piDir))
219
- return path.join(piDir, "monitors");
220
- // Stop at project root (.git boundary) — don't traverse into user home config
221
- if (isDir(path.join(cwd, ".git")))
222
- break;
223
- const parent = path.dirname(cwd);
224
- if (parent === cwd)
225
- break;
226
- cwd = parent;
227
- }
228
- return path.join(process.cwd(), ".pi", "monitors");
229
- }
230
- function seedExamples() {
231
- if (discoverMonitors().length > 0)
232
- return 0;
233
- if (!isDir(EXAMPLES_DIR))
234
- return 0;
235
- const targetDir = resolveProjectMonitorsDir();
236
- fs.mkdirSync(targetDir, { recursive: true });
237
- if (listMonitorFiles(targetDir).length > 0)
238
- return 0;
239
- const entries = fs.readdirSync(EXAMPLES_DIR, { withFileTypes: true });
240
- const files = entries.filter((e) => e.isFile() && e.name.endsWith(".json"));
241
- let copied = 0;
242
- for (const file of files) {
243
- const dest = path.join(targetDir, file.name);
244
- if (!fs.existsSync(dest)) {
245
- fs.copyFileSync(path.join(EXAMPLES_DIR, file.name), dest);
246
- copied++;
247
- }
248
- }
249
- // Also copy template subdirectories (e.g., commit-hygiene/classify.md)
250
- // These contain Nunjucks .md prompt templates referenced by promptTemplate
251
- // fields in the monitor JSON specs.
252
- const dirs = entries.filter((e) => e.isDirectory());
253
- for (const dir of dirs) {
254
- const srcDir = path.join(EXAMPLES_DIR, dir.name);
255
- const destDir = path.join(targetDir, dir.name);
256
- if (!fs.existsSync(destDir)) {
257
- copyDirRecursive(srcDir, destDir);
258
- }
259
- }
260
- return copied;
261
- }
262
- // =============================================================================
263
- // Skill syncing
264
- // =============================================================================
265
- function copyDirRecursive(src, dest) {
266
- fs.mkdirSync(dest, { recursive: true });
267
- for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
268
- const srcPath = path.join(src, entry.name);
269
- const destPath = path.join(dest, entry.name);
270
- if (entry.isDirectory()) {
271
- copyDirRecursive(srcPath, destPath);
272
- }
273
- else if (entry.isFile()) {
274
- fs.copyFileSync(srcPath, destPath);
275
- }
276
- }
277
- }
278
- // =============================================================================
279
273
  // Context collection
280
274
  // =============================================================================
281
275
  const TRUNCATE = 2000;
@@ -319,14 +313,59 @@ function collectUserText(branch) {
319
313
  }
320
314
  return "";
321
315
  }
322
- function collectAssistantText(branch) {
316
+ /**
317
+ * Collect the assistant's response text for the current turn.
318
+ *
319
+ * Walks the branch backward from the tail and aggregates text from every
320
+ * assistant `MessageEntry` until it reaches the most recent user message
321
+ * (turn boundary), at which point the walk stops. Collected segments are
322
+ * reversed back to chronological order and joined with `\n\n`. Empty
323
+ * segments (from tool-call-only assistant messages with no text content)
324
+ * are dropped before joining.
325
+ *
326
+ * Encoded design decisions:
327
+ *
328
+ * - F-014 root-cause fix: previously the function returned from the FIRST
329
+ * assistant message it hit walking backward; when that latest assistant
330
+ * message was tool-call-only, the result was empty even though earlier
331
+ * assistant messages in the same turn carried the user-visible text.
332
+ * The walk now aggregates across all assistant messages in the turn so
333
+ * tool-call-only tail messages no longer mask prior text.
334
+ *
335
+ * - F-018 multi-message-turn semantics: "the assistant's response" is
336
+ * defined as all assistant text emitted from the most recent user
337
+ * message up to the present, joined with double-newline separator.
338
+ * Non-message entries (model_change, label, custom_message,
339
+ * branch_summary, etc.) are skipped — they are not part of the
340
+ * assistant's response.
341
+ *
342
+ * - `extractText` (text-only filter) is intentionally not modified here:
343
+ * tool-call blocks have no user-visible text, and thinking blocks are
344
+ * model-internal reasoning that should not leak into classifier prompts.
345
+ *
346
+ * Edge cases:
347
+ * - Empty branch: returns "".
348
+ * - No user message in branch (e.g., system-init turn): walks to start
349
+ * and returns all collected assistant text.
350
+ * - All assistant messages tool-call-only: returns "" (true empty
351
+ * response, distinct from the F-014 false-positive case).
352
+ */
353
+ export function collectAssistantText(branch) {
354
+ const segments = [];
323
355
  for (let i = branch.length - 1; i >= 0; i--) {
324
356
  const entry = branch[i];
325
- if (isMessageEntry(entry) && entry.message.role === "assistant") {
326
- return extractText(entry.message.content);
357
+ if (!isMessageEntry(entry))
358
+ continue;
359
+ if (entry.message.role === "user")
360
+ break;
361
+ if (entry.message.role === "assistant") {
362
+ segments.push(extractText(entry.message.content));
327
363
  }
328
364
  }
329
- return "";
365
+ return segments
366
+ .reverse()
367
+ .filter((s) => s.length > 0)
368
+ .join("\n\n");
330
369
  }
331
370
  function collectToolResults(branch, limit = 5) {
332
371
  const results = [];
@@ -699,20 +738,20 @@ function loadInstructions(monitor) {
699
738
  return [];
700
739
  }
701
740
  }
702
- function saveInstructions(monitor, instructions) {
703
- const tmpPath = `${monitor.resolvedInstructionsPath}.${process.pid}.tmp`;
741
+ // Exported (over the package barrel) so the side-car-routing test suite in
742
+ // `index.test.ts` can drive it directly without reaching through the
743
+ // `/monitors rules ...` command surface; behavior is otherwise identical to
744
+ // when this was a module-private helper.
745
+ export function saveInstructions(monitor, instructions) {
746
+ // Routes through `writeTypedFile` so AJV validates the array against
747
+ // `monitor-instruction-list.schema.json`, the write goes through
748
+ // proper-lockfile, and the same atomic tmp+rename + cleanup-on-failure
749
+ // applies as for `.project/<block>.json` writes — FGAP-019 closure.
704
750
  try {
705
- fs.writeFileSync(tmpPath, `${JSON.stringify(instructions, null, 2)}\n`);
706
- fs.renameSync(tmpPath, monitor.resolvedInstructionsPath);
751
+ writeTypedFile(monitor.resolvedInstructionsPath, INSTRUCTIONS_LIST_SCHEMA_PATH, instructions, undefined, `monitor '${monitor.name}' instructions`);
707
752
  return null;
708
753
  }
709
754
  catch (err) {
710
- try {
711
- fs.unlinkSync(tmpPath);
712
- }
713
- catch {
714
- /* cleanup */
715
- }
716
755
  return err instanceof Error ? err.message : String(err);
717
756
  }
718
757
  }
@@ -937,13 +976,13 @@ export function mapVerdictToClassifyResult(parsed) {
937
976
  * Monitor paths take precedence.
938
977
  */
939
978
  function createMonitorAgentTemplateEnv(cwd) {
940
- const projectMonitorsDir = resolveProjectMonitorsDir();
979
+ const projectMonitorsDir = findProjectMonitorsDir();
941
980
  const userMonitorsDir = path.join(os.homedir(), ".pi", "agent", "monitors");
942
981
  const projectTemplatesDir = path.join(cwd, ".pi", "templates");
943
982
  const userTemplatesDir = path.join(os.homedir(), ".pi", "agent", "templates");
944
983
  const searchPaths = [];
945
984
  // Monitor paths first — monitor templates take precedence
946
- if (isDir(projectMonitorsDir))
985
+ if (projectMonitorsDir)
947
986
  searchPaths.push(projectMonitorsDir);
948
987
  if (isDir(userMonitorsDir))
949
988
  searchPaths.push(userMonitorsDir);
@@ -964,6 +1003,86 @@ function createMonitorAgentTemplateEnv(cwd) {
964
1003
  let cachedAgentLoader = null;
965
1004
  /** Module-level cached template environment for classify agent specs, populated at session_start. */
966
1005
  let cachedMonitorAgentEnv = null;
1006
+ /**
1007
+ * Module-level reference to the ExtensionAPI for CLI flag lookup at classify time.
1008
+ * Set by the default export (activate function) so classifyViaAgent can read
1009
+ * --trace, --no-trace, --trace-dir, and --trace-filter via pi.getFlag().
1010
+ *
1011
+ * Held as an opaque `getFlag` callable rather than the full ExtensionAPI to keep
1012
+ * the surface narrow and to allow future-replacement by direct argv parsing
1013
+ * without a wider refactor.
1014
+ */
1015
+ let getCliFlag = null;
1016
+ /**
1017
+ * Resolve effective trace settings for a monitor classify call.
1018
+ *
1019
+ * Trace-path resolution precedence (highest to lowest):
1020
+ * 1. `--no-trace` CLI flag → null (explicit disable)
1021
+ * 2. `--trace-dir <dir>` CLI flag → dateRotatedPath(<dir>) — single shared dir,
1022
+ * no per-monitor subdir (caller chose the path explicitly)
1023
+ * 3. `PI_AGENT_TRACE_DIR` env var → dateRotatedPath(<env>/<monitor.name>) —
1024
+ * env var is the global base; per-monitor subdir appends
1025
+ * 4. `--trace` CLI flag → default per-monitor path (explicit enable)
1026
+ * 5. Default ON when no flags / env unset → default per-monitor path
1027
+ *
1028
+ * Default per-monitor path:
1029
+ * .workflows/monitors/<monitor.name>/<YYYY-MM-DD>.jsonl
1030
+ *
1031
+ * Redaction-config path resolution (highest to lowest):
1032
+ * 1. `--trace-filter <file>` CLI flag
1033
+ * 2. `PI_AGENT_TRACE_FILTER` env var
1034
+ * 3. .workflows/monitors/<monitor.name>/trace-config.json (if exists)
1035
+ * 4. null (builtin patterns only)
1036
+ *
1037
+ * Per DEC-0005 the trace stream is push-write inside executeAgent and is
1038
+ * intentionally divergent from pi-mono's pull/replay session model — this
1039
+ * resolver feeds DispatchContext fields the runtime consumes; trace-write
1040
+ * failures are non-fatal and never abort classify.
1041
+ */
1042
+ function resolveTraceSettings(monitorName) {
1043
+ const flagNoTrace = getCliFlag?.("no-trace") === true;
1044
+ const flagTrace = getCliFlag?.("trace") === true;
1045
+ const flagTraceDirRaw = getCliFlag?.("trace-dir");
1046
+ const flagTraceDir = typeof flagTraceDirRaw === "string" && flagTraceDirRaw.length > 0 ? flagTraceDirRaw : null;
1047
+ const flagTraceFilterRaw = getCliFlag?.("trace-filter");
1048
+ const flagTraceFilter = typeof flagTraceFilterRaw === "string" && flagTraceFilterRaw.length > 0 ? flagTraceFilterRaw : null;
1049
+ const envTraceDir = process.env.PI_AGENT_TRACE_DIR;
1050
+ const envTraceFilter = process.env.PI_AGENT_TRACE_FILTER;
1051
+ const defaultTraceBaseDir = path.join(process.cwd(), ".workflows", "monitors", monitorName);
1052
+ const defaultTracePath = dateRotatedPath(defaultTraceBaseDir);
1053
+ let tracePath;
1054
+ if (flagNoTrace) {
1055
+ tracePath = null;
1056
+ }
1057
+ else if (flagTraceDir) {
1058
+ tracePath = dateRotatedPath(flagTraceDir);
1059
+ }
1060
+ else if (envTraceDir && envTraceDir.length > 0) {
1061
+ tracePath = dateRotatedPath(path.join(envTraceDir, monitorName));
1062
+ }
1063
+ else if (flagTrace) {
1064
+ tracePath = defaultTracePath;
1065
+ }
1066
+ else {
1067
+ // Default: trace ON to per-monitor path. Disable explicitly via --no-trace.
1068
+ tracePath = defaultTracePath;
1069
+ }
1070
+ const monitorTraceConfigPath = path.join(process.cwd(), ".workflows", "monitors", monitorName, "trace-config.json");
1071
+ let redactionConfigPath;
1072
+ if (flagTraceFilter) {
1073
+ redactionConfigPath = flagTraceFilter;
1074
+ }
1075
+ else if (envTraceFilter && envTraceFilter.length > 0) {
1076
+ redactionConfigPath = envTraceFilter;
1077
+ }
1078
+ else if (fs.existsSync(monitorTraceConfigPath)) {
1079
+ redactionConfigPath = monitorTraceConfigPath;
1080
+ }
1081
+ else {
1082
+ redactionConfigPath = null;
1083
+ }
1084
+ return { tracePath, redactionConfigPath };
1085
+ }
967
1086
  /**
968
1087
  * Classify via agent spec — the sole classify path.
969
1088
  * Loads the agent YAML, builds context from collectors, compiles via
@@ -1012,71 +1131,127 @@ async function classifyViaAgent(ctx, monitor, branch, extraContext, signal) {
1012
1131
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
1013
1132
  if (!auth.ok)
1014
1133
  throw new Error(auth.error);
1134
+ // --- Trace-capture wiring (issue-023 T6) -------------------------------
1135
+ // Resolve effective tracePath / redactionConfigPath / monitorName for this
1136
+ // classify call. See resolveTraceSettings() for precedence rules. The
1137
+ // dispatch surface is pi-jit-agents' executeAgent (DEC-0005 push-write
1138
+ // trace pipeline); failures inside the trace path are non-fatal and never
1139
+ // abort the classify call.
1140
+ const { tracePath, redactionConfigPath } = resolveTraceSettings(monitor.name);
1141
+ // Resolve absolute outputSchema path. The pi-workflows compileAgentSpec
1142
+ // preserves the relative path that the agent YAML declared; executeAgent
1143
+ // requires an absolute filesystem path for validateFromFile / phantom-tool
1144
+ // construction. AGENTS_DIR is the canonical fallback resolution base.
1145
+ let resolvedOutputSchema;
1146
+ if (compiled.outputSchema) {
1147
+ resolvedOutputSchema = path.isAbsolute(compiled.outputSchema)
1148
+ ? compiled.outputSchema
1149
+ : path.resolve(AGENTS_DIR, compiled.outputSchema);
1150
+ }
1151
+ // Build a synthetic CompiledAgent from the pi-workflows compileAgentSpec
1152
+ // output. The two AgentSpec shapes are not identical — pi-jit-agents only
1153
+ // reads `name` off `spec` for trace stamping; we stub `loadedFrom` so the
1154
+ // type-check passes. `contextValues` carries the resolved collector output
1155
+ // keyed by collector name so executeAgent can emit one context_collection
1156
+ // entry per resolved key.
1157
+ const synthCompiled = {
1158
+ spec: {
1159
+ name: agentSpec.name,
1160
+ loadedFrom: "<monitor-classify>",
1161
+ },
1162
+ systemPrompt: compiled.systemPrompt,
1163
+ taskPrompt: prompt,
1164
+ model: modelSpec,
1165
+ outputSchema: resolvedOutputSchema,
1166
+ contextValues: { ...collected, ...(extraContext ?? {}) },
1167
+ };
1168
+ const dispatch = {
1169
+ model: model,
1170
+ // JitAgentAuth requires non-optional apiKey/headers; the
1171
+ // ResolvedRequestAuth ok-branch leaves both optional. Default empty
1172
+ // values match the legacy behavior where pi-ai's `complete` accepted
1173
+ // undefined values transparently.
1174
+ auth: { apiKey: auth.apiKey ?? "", headers: auth.headers ?? {} },
1175
+ maxTokens: 1024,
1176
+ signal,
1177
+ tracePath,
1178
+ redactionConfigPath,
1179
+ monitorName: monitor.name,
1180
+ };
1015
1181
  // Thinking is disabled for classify calls — Anthropic API rejects
1016
1182
  // thinking + forced toolChoice. The phantom tool enforces output shape;
1017
1183
  // thinking support requires Structured Outputs (output_config.format)
1018
- // which pi-ai does not yet expose.
1019
- const response = await complete(model, {
1020
- messages: [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }],
1021
- tools: [VERDICT_TOOL],
1022
- }, {
1023
- apiKey: auth.apiKey,
1024
- headers: auth.headers,
1025
- maxTokens: 1024,
1026
- signal,
1027
- toolChoice: { type: "tool", name: "classify_verdict" },
1028
- });
1029
- const toolCall = response.content.find((c) => c.type === "toolCall");
1030
- if (!toolCall) {
1031
- const contentTypes = response.content.map((c) => c.type).join(", ");
1032
- const errMsg = response.errorMessage ? ` error: ${response.errorMessage}` : "";
1184
+ // which pi-ai does not yet expose. executeAgent enforces the same
1185
+ // constraint internally.
1186
+ let result;
1187
+ try {
1188
+ result = await executeAgent(synthCompiled, dispatch);
1189
+ }
1190
+ catch (err) {
1191
+ const errMsg = err instanceof Error ? err.message : String(err);
1033
1192
  return {
1034
1193
  verdict: "error",
1035
- error: `No tool call in response (stopReason: ${response.stopReason}, content: [${contentTypes}]${errMsg})`,
1194
+ error: errMsg,
1036
1195
  };
1037
1196
  }
1038
- const parsed = toolCall.arguments;
1039
- // Validate against verdict schema if the agent spec declares one
1040
- if (compiled.outputSchema) {
1041
- const schemaPath = path.isAbsolute(compiled.outputSchema)
1042
- ? compiled.outputSchema
1043
- : path.resolve(AGENTS_DIR, compiled.outputSchema);
1044
- validateFromFile(schemaPath, parsed, `verdict for monitor '${monitor.name}'`);
1197
+ // executeAgent returns the validated tool-call arguments as `output` when
1198
+ // outputSchema is set (verdict schema). Otherwise `output` is the extracted
1199
+ // text — fall back to parseVerdict for unstructured responses to preserve
1200
+ // the legacy free-text classify path used by some agent specs.
1201
+ if (resolvedOutputSchema && result.output && typeof result.output === "object") {
1202
+ const parsed = result.output;
1203
+ // Re-validate at the call site for parity with the prior implementation
1204
+ // (executeAgent already validated; the duplicate call is a no-op on the
1205
+ // happy path and preserves the existing error-surfacing label).
1206
+ validateFromFile(resolvedOutputSchema, parsed, `verdict for monitor '${monitor.name}'`);
1207
+ return mapVerdictToClassifyResult(parsed);
1208
+ }
1209
+ if (!resolvedOutputSchema && typeof result.output === "string") {
1210
+ return parseVerdict(result.output);
1045
1211
  }
1046
- return mapVerdictToClassifyResult(parsed);
1212
+ return {
1213
+ verdict: "error",
1214
+ error: `Unexpected executeAgent output shape (type=${typeof result.output})`,
1215
+ };
1047
1216
  }
1048
1217
  // =============================================================================
1049
1218
  // Pattern learning (JSON)
1050
1219
  // =============================================================================
1051
- function learnPattern(monitor, description, severity = "warning") {
1052
- const patterns = loadPatterns(monitor);
1220
+ // Exported (over the package barrel) so the side-car-routing test suite can
1221
+ // drive it directly without reaching through the dispatch path; behavior is
1222
+ // otherwise identical to when this was a module-private helper.
1223
+ export function learnPattern(monitor, description, severity = "warning") {
1053
1224
  const id = description
1054
1225
  .toLowerCase()
1055
1226
  .replace(/[^a-z0-9]+/g, "-")
1056
1227
  .slice(0, 60);
1057
1228
  // dedup by ID — two different descriptions can slugify to the same ID,
1058
1229
  // so check ID rather than raw description to avoid collisions
1059
- if (patterns.some((p) => p.id === id))
1230
+ const existing = loadPatterns(monitor);
1231
+ if (existing.some((p) => p.id === id))
1060
1232
  return;
1061
- patterns.push({
1233
+ const newPattern = {
1062
1234
  id,
1063
1235
  description,
1064
1236
  severity,
1065
1237
  source: "learned",
1066
1238
  learned_at: new Date().toISOString(),
1067
- });
1068
- const tmpPath = `${monitor.resolvedPatternsPath}.${process.pid}.tmp`;
1239
+ };
1240
+ // Routes through `appendToTypedFile` (arrayPath = null = top-level array
1241
+ // shape) so AJV validates the appended item + the whole array against
1242
+ // `monitor-pattern-list.schema.json`, the read-modify-write critical
1243
+ // section is wrapped in proper-lockfile, and the same atomic tmp+rename
1244
+ // applies as for `.project/<block>.json` writes — FGAP-019 closure.
1245
+ // `appendToTypedFile` requires the file to already exist, so bootstrap
1246
+ // an empty list here when the patterns file is absent (matching the
1247
+ // prior "loadPatterns returns [] on read failure" behavior).
1069
1248
  try {
1070
- fs.writeFileSync(tmpPath, `${JSON.stringify(patterns, null, 2)}\n`);
1071
- fs.renameSync(tmpPath, monitor.resolvedPatternsPath);
1249
+ if (!fs.existsSync(monitor.resolvedPatternsPath)) {
1250
+ writeTypedFile(monitor.resolvedPatternsPath, PATTERN_LIST_SCHEMA_PATH, [], undefined, `monitor '${monitor.name}' patterns`);
1251
+ }
1252
+ appendToTypedFile(monitor.resolvedPatternsPath, PATTERN_LIST_SCHEMA_PATH, null, newPattern, undefined, `monitor '${monitor.name}' pattern`);
1072
1253
  }
1073
1254
  catch (err) {
1074
- try {
1075
- fs.unlinkSync(tmpPath);
1076
- }
1077
- catch {
1078
- /* cleanup */
1079
- }
1080
1255
  console.error(`[${monitor.name}] Failed to write pattern: ${err instanceof Error ? err.message : err}`);
1081
1256
  }
1082
1257
  }
@@ -1086,12 +1261,43 @@ function learnPattern(monitor, description, severity = "warning") {
1086
1261
  export function generateFindingId(monitorName, _description) {
1087
1262
  return `${monitorName}-${Date.now().toString(36)}`;
1088
1263
  }
1089
- function executeWriteAction(monitor, action, result) {
1264
+ /**
1265
+ * Apply a monitor's write-action by routing through `@davidorex/pi-context/block-api`.
1266
+ *
1267
+ * Closes issue-065: the prior body wrote findings via `fs.writeFileSync`
1268
+ * after manual JSON read+merge+write, bypassing AJV schema validation,
1269
+ * proper-lockfile contention guards, DispatchContext author stamping, and
1270
+ * any centralized "monitor wrote here" choke point. This refactor routes
1271
+ * every monitor write through `appendToBlock` / `upsertItemInBlock` with
1272
+ * a `DispatchContext.writer` of `{ kind: "monitor", monitor_name }` so:
1273
+ *
1274
+ * 1. AJV validates the entry against `.project/schemas/<block>.schema.json`
1275
+ * before write — drift surfaces immediately, not on later reader load.
1276
+ * 2. proper-lockfile guards concurrent monitor + LLM writes against the
1277
+ * same block file.
1278
+ * 3. created_by / created_at / modified_by / modified_at fields, when
1279
+ * declared on the destination schema, are stamped with the monitor's
1280
+ * writer string ("monitor/<name>") so monitor-authored entries are
1281
+ * distinguishable from human / agent / workflow entries downstream.
1282
+ * 4. All monitor writes funnel through one execute boundary — the future
1283
+ * "detect monitor bypass" check has a single call site to inspect.
1284
+ *
1285
+ * Failure model: AJV ValidationError or any block-api error is logged via
1286
+ * console.error with the monitor name and propagated NO FURTHER. This
1287
+ * mirrors the previous fs-write failure handling — a monitor that cannot
1288
+ * persist a finding does not crash the host turn; the operator sees the
1289
+ * stderr line and can investigate. Re-raising would change the host
1290
+ * extension's failure model and is out of scope for this refactor.
1291
+ *
1292
+ * Exported for unit-test access (Phase F of issue-065 closure plan).
1293
+ */
1294
+ export function executeWriteAction(monitor, action, result) {
1090
1295
  if (!action.write)
1091
1296
  return;
1092
1297
  const writeCfg = action.write;
1093
- const filePath = path.isAbsolute(writeCfg.path) ? writeCfg.path : path.resolve(process.cwd(), writeCfg.path);
1094
- // Build the entry from template, substituting placeholders
1298
+ // Build the entry from template, substituting placeholders.
1299
+ // Template substitution semantics preserved verbatim from the prior body —
1300
+ // the change is the persistence path, not the entry shape.
1095
1301
  const findingId = generateFindingId(monitor.name, result.description ?? "unknown");
1096
1302
  const entry = {};
1097
1303
  for (const [key, tmpl] of Object.entries(writeCfg.template)) {
@@ -1102,45 +1308,25 @@ function executeWriteAction(monitor, action, result) {
1102
1308
  .replace(/\{monitor_name\}/g, monitor.name)
1103
1309
  .replace(/\{timestamp\}/g, new Date().toISOString());
1104
1310
  }
1105
- // Read existing file or create structure
1106
- let data = {};
1311
+ // DispatchContext writer identifying the monitor — block-api stamps
1312
+ // created_by / modified_by with `writerToString({ kind: "monitor", monitor_name })`
1313
+ // → "monitor/<name>" when the destination schema declares those fields.
1314
+ const ctx = {
1315
+ writer: { kind: "monitor", monitor_name: monitor.name },
1316
+ };
1107
1317
  try {
1108
- data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
1109
- }
1110
- catch {
1111
- // file doesn't exist or is invalid — create fresh
1112
- }
1113
- const arrayField = writeCfg.array_field;
1114
- if (!Array.isArray(data[arrayField])) {
1115
- data[arrayField] = [];
1116
- }
1117
- const arr = data[arrayField];
1118
- if (writeCfg.merge === "upsert") {
1119
- const idx = arr.findIndex((item) => item.id === entry.id);
1120
- if (idx !== -1) {
1121
- arr[idx] = entry;
1318
+ if (writeCfg.merge === "upsert") {
1319
+ upsertItemInBlock(process.cwd(), writeCfg.block, writeCfg.array_field, entry, "id", ctx);
1122
1320
  }
1123
1321
  else {
1124
- arr.push(entry);
1322
+ appendToBlock(process.cwd(), writeCfg.block, writeCfg.array_field, entry, ctx);
1125
1323
  }
1126
1324
  }
1127
- else {
1128
- arr.push(entry);
1129
- }
1130
- const tmpPath = `${filePath}.${process.pid}.tmp`;
1131
- try {
1132
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
1133
- fs.writeFileSync(tmpPath, `${JSON.stringify(data, null, 2)}\n`);
1134
- fs.renameSync(tmpPath, filePath);
1135
- }
1136
1325
  catch (err) {
1137
- try {
1138
- fs.unlinkSync(tmpPath);
1139
- }
1140
- catch {
1141
- /* cleanup */
1142
- }
1143
- console.error(`[${monitor.name}] Failed to write to ${filePath}: ${err instanceof Error ? err.message : err}`);
1326
+ // Mirror the previous failure model: log + continue. Includes AJV
1327
+ // ValidationError (schema mismatch), missing block file, missing
1328
+ // arrayKey, lock-contention timeout — all surface here as Error.
1329
+ console.error(`[${monitor.name}] block-api write failed: ${err instanceof Error ? err.message : String(err)}`);
1144
1330
  }
1145
1331
  }
1146
1332
  // =============================================================================
@@ -1357,8 +1543,32 @@ async function escalate(monitor, _pi, ctx) {
1357
1543
  // Extension entry point
1358
1544
  // =============================================================================
1359
1545
  export default function (pi) {
1360
- const seeded = seedExamples();
1361
- const monitors = discoverMonitors();
1546
+ // Cache pi for classify-time CLI flag lookup. classifyViaAgent reads
1547
+ // --trace / --no-trace / --trace-dir / --trace-filter via getCliFlag()
1548
+ // to resolve the trace destination passed through DispatchContext.
1549
+ getCliFlag = (name) => pi.getFlag(name);
1550
+ // CLI flag registration for the agent-trace pipeline (issue-023 T6).
1551
+ // pi-coding-agent's convention is `--no-<name>` for boolean disable, plain
1552
+ // `--<name>` for boolean enable, and `--<name> <value>` for value flags.
1553
+ pi.registerFlag("trace", {
1554
+ type: "boolean",
1555
+ default: false,
1556
+ description: "Enable agent-trace JSONL emission for monitor classify calls (default location: .workflows/monitors/<name>/<YYYY-MM-DD>.jsonl).",
1557
+ });
1558
+ pi.registerFlag("no-trace", {
1559
+ type: "boolean",
1560
+ default: false,
1561
+ description: "Disable agent-trace JSONL emission for monitor classify calls.",
1562
+ });
1563
+ pi.registerFlag("trace-dir", {
1564
+ type: "string",
1565
+ description: "Override the base directory for agent-trace JSONL files. Date-rotated filename appended.",
1566
+ });
1567
+ pi.registerFlag("trace-filter", {
1568
+ type: "string",
1569
+ description: "Path to a trace-config.json with custom redaction patterns layered atop the builtin set.",
1570
+ });
1571
+ const { monitors, overrides } = discoverMonitors();
1362
1572
  loadedMonitors = monitors;
1363
1573
  if (monitors.length === 0)
1364
1574
  return;
@@ -1391,9 +1601,14 @@ export default function (pi) {
1391
1601
  try {
1392
1602
  statusCtx = ctx;
1393
1603
  invokeCtx = ctx;
1394
- if (seeded > 0 && ctx.hasUI) {
1395
- const dir = resolveProjectMonitorsDir();
1396
- ctx.ui.notify(`Seeded ${seeded} example monitor files into ${dir}\nEdit or delete them to customize.`, "info");
1604
+ // Surface drift between project/user overrides and bundled defaults.
1605
+ // One notification per shadowed lower-tier monitor; users decide
1606
+ // whether to delete the override (and pick up bundled updates) or
1607
+ // keep it (preserving local customisations).
1608
+ if (ctx.hasUI && overrides.length > 0) {
1609
+ for (const o of overrides) {
1610
+ ctx.ui.notify(`[monitors] override active for '${o.name}' — using ${o.winnerDir}/${o.name}.monitor.json; bundled version at ${o.losingDir} may be newer (delete the override to receive updates)`, "warning");
1611
+ }
1397
1612
  }
1398
1613
  // Reset per-monitor state for the new session. This covers both
1399
1614
  // fresh sessions and what was previously handled by session_switch