@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/CHANGELOG.md +11 -0
- package/README.md +7 -2
- package/agents/commit-hygiene-classifier.agent.yaml +2 -1
- package/agents/fragility-classifier.agent.yaml +2 -1
- package/agents/hedge-classifier.agent.yaml +2 -1
- package/agents/unauthorized-action-classifier.agent.yaml +2 -1
- package/agents/work-quality-classifier.agent.yaml +2 -1
- package/dist/index.d.ts +122 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +403 -188
- package/dist/index.js.map +1 -1
- package/examples/fragility.monitor.json +3 -5
- package/examples/hedge.monitor.json +1 -1
- package/examples/work-quality.monitor.json +2 -4
- package/package.json +10 -7
- package/schemas/monitor-instruction-list.schema.json +15 -0
- package/schemas/monitor-pattern-list.schema.json +20 -0
- package/schemas/monitor.schema.json +3 -7
- package/skills/pi-behavior-monitors/SKILL.md +23 -23
- package/skills/pi-behavior-monitors/references/bundled-resources.md +3 -1
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-
|
|
16
|
-
import { validateFromFile } from "@davidorex/pi-
|
|
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 {
|
|
20
|
-
import { getAgentDir } from "@
|
|
21
|
-
import { Box, Text } from "@
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
+
return null;
|
|
103
136
|
const parent = path.dirname(cwd);
|
|
104
137
|
if (parent === cwd)
|
|
105
|
-
|
|
138
|
+
return null;
|
|
106
139
|
cwd = parent;
|
|
107
140
|
}
|
|
108
|
-
|
|
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 (
|
|
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
|
-
|
|
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)
|
|
326
|
-
|
|
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
|
-
|
|
703
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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:
|
|
1194
|
+
error: errMsg,
|
|
1036
1195
|
};
|
|
1037
1196
|
}
|
|
1038
|
-
|
|
1039
|
-
//
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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
|
|
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
|
-
|
|
1052
|
-
|
|
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
|
-
|
|
1230
|
+
const existing = loadPatterns(monitor);
|
|
1231
|
+
if (existing.some((p) => p.id === id))
|
|
1060
1232
|
return;
|
|
1061
|
-
|
|
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
|
-
|
|
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.
|
|
1071
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1094
|
-
//
|
|
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
|
-
//
|
|
1106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
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
|
-
|
|
1361
|
-
|
|
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
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
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
|