pi-warden 0.28.4 → 0.34.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +154 -0
- package/README.md +5 -1
- package/dist/audit.d.ts +22 -0
- package/dist/audit.js +110 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.js +9 -0
- package/dist/conscience-config.d.ts +21 -0
- package/dist/conscience-config.js +82 -0
- package/dist/conscience-loader.d.ts +26 -0
- package/dist/conscience-loader.js +82 -0
- package/dist/conscience-policy.d.ts +24 -0
- package/dist/conscience-policy.js +28 -0
- package/dist/conscience-privacy.d.ts +4 -0
- package/dist/conscience-privacy.js +11 -0
- package/dist/discover.d.ts +14 -0
- package/dist/discover.js +85 -0
- package/dist/extension.d.ts +1 -1
- package/dist/extension.js +213 -35
- package/dist/extension.js.bak +213 -35
- package/dist/guard.d.ts +127 -1
- package/dist/guard.js +447 -31
- package/dist/holds.d.ts +2 -0
- package/dist/holds.js +7 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.js +5 -2
- package/dist/init.d.ts +36 -0
- package/dist/init.js +199 -0
- package/dist/laya-download.d.ts +40 -0
- package/dist/laya-download.js +288 -0
- package/dist/laya-judge.d.ts +62 -0
- package/dist/laya-judge.js +495 -0
- package/dist/learning.d.ts +30 -0
- package/dist/learning.js +53 -10
- package/dist/redact.js +9 -4
- package/dist/rules-file.d.ts +18 -0
- package/dist/rules-file.js +72 -0
- package/dist/rules.d.ts +8 -0
- package/dist/rules.js +54 -3
- package/dist/shape.js +9 -1
- package/dist/tool-recommendation.d.ts +15 -0
- package/dist/tool-recommendation.js +31 -0
- package/dist/widget.d.ts +3 -1
- package/dist/widget.js +16 -13
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,160 @@ How to keep this current: add the entry in the same pull request as the change,
|
|
|
8
8
|
|
|
9
9
|
<!-- Empty. Next release starts here. -->
|
|
10
10
|
|
|
11
|
+
## 0.34.1
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- `scripts/calibrate-action.mjs`: `--yes` now spends what the corpus needs instead of stopping at the 2000-request default; `--max-requests N` is an explicit cap that `--yes` does not lift, and a run it stops early says how many replays it skipped and writes `report-latest-partial.md`.
|
|
15
|
+
|
|
16
|
+
### Docs
|
|
17
|
+
- Action guard calibration on 315 recorded sessions at 0.33.3 under `eval/reports/2026-09-21-calibration-0.33.3/`, with the headline table in `docs/guards.md`.
|
|
18
|
+
- README measurement paragraph reworded for readability, same numbers.
|
|
19
|
+
|
|
20
|
+
## 0.34.0
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- `scripts/hold-stats.mjs`: read-only script reporting per-project and total hold statistics from the SQLite database, with `--json` output and `PI_WARDEN_DB` support.
|
|
24
|
+
- `/warden status` now shows a lifetime hold sentence for the current project root (e.g. "Lifetime here: 55 holds, 11 labeled, 0 stood (0/11), 114 allowed accepted, 0 regretted.").
|
|
25
|
+
|
|
26
|
+
### Docs
|
|
27
|
+
- README "Does it actually help?" section now includes a measurement-on-real-use paragraph with current hold numbers, date, and the link to the floor-evidence decision in 0.33.0.
|
|
28
|
+
|
|
29
|
+
## 0.33.4
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- Rules guard no longer judges prose-only fallback documents (README.md, CLAUDE.md, AGENTS.md) as one rule; a fallback with no rule-shaped sections is skipped.
|
|
33
|
+
|
|
34
|
+
## 0.33.3
|
|
35
|
+
|
|
36
|
+
### Tests
|
|
37
|
+
- Live smoke stuck suite now exercises the Jev-judged path: two new cases reach `source: "typesafe"` and print real scores; the existing offline-repeat case prints `offline repeat` instead of three `undefined` values.
|
|
38
|
+
|
|
39
|
+
## 0.33.2
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
- Tests and eval runs now write to an isolated database via `PI_WARDEN_DB` instead of polluting the user's `holds.db`.
|
|
43
|
+
|
|
44
|
+
## 0.33.1
|
|
45
|
+
|
|
46
|
+
### Tests
|
|
47
|
+
- Live smoke suite cap raised from 60 to 100 requests; summary line now reports budget-error misses.
|
|
48
|
+
|
|
49
|
+
## 0.33.0
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
- Built-in pattern floor is now evidence when a judge answers (`action.floor: "evidence"`, default). Built-in hits (shell rules, rm classifier, sensitive-path, outside-project) are fed to the judge as `floor_hits` in the request state and traced as `(evidence)`, but they no longer override the judge's `irreversible` score. User-declared rules keep their declared action. `action.floor: "level"` restores the legacy behaviour. Replay on 52 real holds: evidence mode drops held count from 52 to 28; level mode preserves 44 confirm + 8 warn.
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
- Deferred sensitive-path hit now warns (instead of silently allowing) when the judge fails in evidence mode with `failOpen: true`.
|
|
56
|
+
|
|
57
|
+
## 0.32.0
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
- `replanned` outcomes now persist to SQLite, closing the gap where re-plan labels were set in memory but never written to the database.
|
|
61
|
+
- `command_preview` stores the redacted command or path (capped at 200 chars) instead of the bare tool name.
|
|
62
|
+
- Outcome known at record time (dialog-approved/declined) is no longer lost to a race between `noteOutcomes` and `learningIds.set`.
|
|
63
|
+
- Set `PRAGMA busy_timeout` on the SQLite connection and skip VACUUM when no rows are pruned, preventing SQLITE_BUSY on startup with concurrent sessions. Fixes #36.
|
|
64
|
+
|
|
65
|
+
### Changed
|
|
66
|
+
- Judged allowed calls (`held = 0`) are now stored in SQLite alongside held calls, enabling precision and false-negative rate computation.
|
|
67
|
+
- Disclosure and `docs/data-handling.md` updated to reflect that judged allowed calls are stored with `held = 0`.
|
|
68
|
+
|
|
69
|
+
## 0.31.0
|
|
70
|
+
|
|
71
|
+
### Added
|
|
72
|
+
- `/warden audit` command: agent-driven workspace audit that sends a prompt to the session model. The model reads source, finds concrete Jev opportunities, produces measurable evidence, and writes an HTML report to `.pi-warden/audit-report.html`.
|
|
73
|
+
|
|
74
|
+
### Changed
|
|
75
|
+
- Bumped pi-typesafe dependency to ^0.6.1.
|
|
76
|
+
|
|
77
|
+
## 0.30.4
|
|
78
|
+
|
|
79
|
+
### Changed
|
|
80
|
+
- Made `should_proceed` trace-only by default until calibrated, so low scores no longer ask the agent to pause. Set `action.shouldProceed.steer: true` to restore the existing steer; the `hold` threshold is unchanged.
|
|
81
|
+
|
|
82
|
+
## 0.30.3
|
|
83
|
+
|
|
84
|
+
### Fixed
|
|
85
|
+
- Live status follows the newest verdict, including guards without sentence tokens; stack entries retain guard update order within their severity groups.
|
|
86
|
+
- Action warning and hold sentences use verdict reasons instead of inferring irreversibility from the level or unrelated scores.
|
|
87
|
+
|
|
88
|
+
## 0.30.2
|
|
89
|
+
|
|
90
|
+
### Fixed
|
|
91
|
+
- The live widget bar wraps its sentence to the pane width; one over-wide line tripped pi's render-width guard and aborted the session. Fixes #29.
|
|
92
|
+
|
|
93
|
+
## 0.30.1
|
|
94
|
+
|
|
95
|
+
### Fixed
|
|
96
|
+
- Approval question now accepts generic task-level approval ("proceed", "yes", "go ahead", "sure") instead of requiring explicit approval of each specific action. Fixes the pattern where the model keeps holding after the user approved the whole task.
|
|
97
|
+
|
|
98
|
+
### Changed
|
|
99
|
+
- Approval question uses intent-based wording that lets Jev reason about approval directly rather than matching specific words.
|
|
100
|
+
- Bumped pi-typesafe dependency to ^0.6.1.
|
|
101
|
+
|
|
102
|
+
## 0.30.0
|
|
103
|
+
|
|
104
|
+
### Added
|
|
105
|
+
- Violation pipeline: deterministic per-violation authorization, escalation, and aggregation for pattern-detected and rules-guard violations.
|
|
106
|
+
- `Violation`, `ViolationScope`, `Authorization`, `EscalatedViolation` types for the violation pipeline.
|
|
107
|
+
- `authorize()`, `isNegated()`, `scopeMatches()`, `isAuthEligible()` (severity-based) for deterministic authorization.
|
|
108
|
+
- `escalateBlastRadius()` and `escalateRulesViolation()` for Jev-backed severity escalation.
|
|
109
|
+
- `aggregateLevel()` for final tool-call level from remaining violations.
|
|
110
|
+
- `resolveRulesFile()` and `extractRules()` for token-aware rules file resolution.
|
|
111
|
+
- `checkPiWardenMissing()` for first-run warning support.
|
|
112
|
+
- `escalationThreshold` config key in `ActionGuardConfig` (default 0.85).
|
|
113
|
+
- `/warden init` command: scaffolds a starter pi-warden.md with safety rules and project-type-specific rules.
|
|
114
|
+
- `writeStarterRules()`, `generateStarterRules()`, `detectProjectType()`, `buildProjectContext()` in `src/init.ts`.
|
|
115
|
+
- First-run warning when pi-warden.md is missing and a fallback is active.
|
|
116
|
+
- Per-violation noul questions sent to Jev on the same request, returning P(yes) as a confidence value. Answers drive escalation (`escalateBlastRadius`, `escalateRulesViolation`) and aggregation (`aggregateLevel`) in the action guard pipeline. Calibrated: AUC 0.73 against regret, 0.42 against rejected turns (600 sessions, 2026-09-20).
|
|
117
|
+
- `parseViolationJudgments()` for safe parsing of Jev noul responses with defaults for missing/malformed data; supports legacy choice fallback.
|
|
118
|
+
- Resolved rules file (`rules`, `rulesSource`) passed to Jev in the request state.
|
|
119
|
+
- `should_proceed` noul question on every action request: unified gate covering rule violations, unrequested scope, explicit constraint breaches, and material user decisions. Calibrated: AUC 0.26 against regret, 0.58 against rejected turns (600 sessions, 2026-09-20).
|
|
120
|
+
- `action.shouldProceed` config key `{ hold: number }` (default 0.6): steer threshold for `should_proceed`.
|
|
121
|
+
- `shouldProceedQuestion` exported from `src/guard.ts`.
|
|
122
|
+
- `Verdict.shouldProceedSteer` flag for the unified gate steer.
|
|
123
|
+
- `Judgment.shouldProceed` field for traceability.
|
|
124
|
+
- `matchPathRules` is now exported for use by conscience-loader.
|
|
125
|
+
|
|
126
|
+
### Changed
|
|
127
|
+
- `ViolationScope` no longer has a `labels` field; scope matching uses paths only.
|
|
128
|
+
- `Violation` no longer has an `authEligible` field; eligibility is derived from severity in `authorize()`.
|
|
129
|
+
- `isAuthEligible()` now takes a `Severity` parameter instead of a `PatternHit`.
|
|
130
|
+
- `escalateRulesViolation()` threshold check now matches `escalateBlastRadius()` style (guard clause for no-escalation).
|
|
131
|
+
|
|
132
|
+
### Tests
|
|
133
|
+
- 38 tests for authorization, escalation, aggregation, violation judgment parsing, rules-file resolution, and first-run warning.
|
|
134
|
+
|
|
135
|
+
## 0.29.3
|
|
136
|
+
|
|
137
|
+
### Fixed
|
|
138
|
+
|
|
139
|
+
- Trace-only off-task findings no longer reach the agent through dedicated steers, generic headless warnings, or the headless `/warden test` report. Independent warning and confirmation reasons on the same call still deliver normally.
|
|
140
|
+
|
|
141
|
+
## 0.29.2
|
|
142
|
+
|
|
143
|
+
### Added
|
|
144
|
+
|
|
145
|
+
- GitHub Actions CI for pull requests, main pushes, version tags, and manual runs: workflow lint plus typecheck, offline tests, and build on Node 22.19.0, 24, and 26.
|
|
146
|
+
- Package installation smoke checks and downloadable npm tarballs with SHA-256 checksums; validated version tags prepare draft GitHub releases without publishing to npm.
|
|
147
|
+
|
|
148
|
+
### Docs
|
|
149
|
+
|
|
150
|
+
- Documented CI checks and manual release steps, and corrected the contributor version-bump instructions.
|
|
151
|
+
|
|
152
|
+
## 0.29.1
|
|
153
|
+
|
|
154
|
+
### Changed
|
|
155
|
+
|
|
156
|
+
- Extracted duplicated credential-key regex alternation in `redact.ts` into a shared `CREDENTIAL_KEYS` constant.
|
|
157
|
+
|
|
158
|
+
## 0.29.0
|
|
159
|
+
|
|
160
|
+
### Changed
|
|
161
|
+
|
|
162
|
+
- Off-task is now gated on the categorical scope answer instead of the score alone (AUC 0.51). `expected_step` vetoes off-task entirely; `unrelated` always warns; `plausible_side_step` warns trace-only. All off-task steers are trace-only until AUC clears 0.51.
|
|
163
|
+
- Ledger records now include a `callExcerpt` (tool + path) for auditing off-task and intent-mismatch warns.
|
|
164
|
+
|
|
11
165
|
## 0.28.4
|
|
12
166
|
|
|
13
167
|
### Fixed
|
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ Works without any key (offline guards: pattern list, runaway stop, sensitive-pat
|
|
|
24
24
|
|
|
25
25
|
| Guard | Watches | Does |
|
|
26
26
|
| --- | --- | --- |
|
|
27
|
-
| **Action** | `bash`, `write`, `edit` before run | Holds irreversible calls,
|
|
27
|
+
| **Action** | `bash`, `write`, `edit` before run | Holds irreversible calls, records trace-only off-task findings |
|
|
28
28
|
| **Rules** | every `write` and `edit` | Judges against project Markdown rules, quotes the broken rule |
|
|
29
29
|
| **Slop** | code and replies | Names stubs, restating comments, dead code, hedging |
|
|
30
30
|
| **Stuck** | tool results | Repeated failures using the same strategy |
|
|
@@ -74,6 +74,10 @@ An overnight stability run covered **13,952 guard cases across 109 cycles** with
|
|
|
74
74
|
|
|
75
75
|
These are project-maintained benchmarks, not universal claims. Raw reports and reproducible eval tooling are in the repo.
|
|
76
76
|
|
|
77
|
+
### Does it get in the way? Measured, not promised.
|
|
78
|
+
|
|
79
|
+
**3 holds per 1,000 calls. The other 997 run.** Across 315 recorded sessions and 18,075 guarded calls, the action hold fired 48 times, each for an action that is hard to undo. On the maintainer's machine, 452 allowed calls have run since outcome tracking began and not one was regretted afterward. When a hold was wrong, one retry cleared it: 2 of 13 labeled holds stood, the other 11 cleared on retry. The 27 calls the maintainer later regretted in the replay were ordinary edits and commits, not the kind a pre-call hold can see coming; those belong to the rules guard, the done-check, and the regret label, which are measured too. Snapshot numbers: `scripts/hold-stats.mjs` refreshes them, and the full replay is in [`eval/reports/2026-09-21-calibration-0.33.3/`](eval/reports/2026-09-21-calibration-0.33.3/).
|
|
80
|
+
|
|
77
81
|
[See the evals ->](eval/reports/)
|
|
78
82
|
|
|
79
83
|
## Privacy
|
package/dist/audit.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { discoverSourceFiles, findProjects } from "./discover.js";
|
|
2
|
+
/**
|
|
3
|
+
* Build the user message that sends the workspace to the session model for an
|
|
4
|
+
* agent-driven audit. The model reads source, finds Jev opportunities, produces
|
|
5
|
+
* measurable evidence, and writes an HTML report.
|
|
6
|
+
*
|
|
7
|
+
* `projects` comes from {@link findProjects}. `cwd` is the workspace root.
|
|
8
|
+
*/
|
|
9
|
+
export declare function buildAuditPrompt(cwd: string, projects: string[]): string;
|
|
10
|
+
export interface ReportSnapshot {
|
|
11
|
+
exists: boolean;
|
|
12
|
+
mtimeMs: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Take a point-in-time snapshot of the audit report file. Call before
|
|
16
|
+
* sending the prompt and again after the agent finishes.
|
|
17
|
+
*/
|
|
18
|
+
export declare function snapshotReport(reportPath: string): ReportSnapshot;
|
|
19
|
+
/**
|
|
20
|
+
* Determine whether the audit produced a new or updated report.
|
|
21
|
+
*/
|
|
22
|
+
export declare function reportOutcome(before: ReportSnapshot, after: ReportSnapshot): "written" | "stale" | "missing";
|
package/dist/audit.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { redact } from "./redact.js";
|
|
4
|
+
import { discoverSourceFiles, findProjects } from "./discover.js";
|
|
5
|
+
export { discoverSourceFiles, findProjects } from "./discover.js";
|
|
6
|
+
// ─── Prompt builder ─────────────────────────────────────────────────────────
|
|
7
|
+
/**
|
|
8
|
+
* Build the user message that sends the workspace to the session model for an
|
|
9
|
+
* agent-driven audit. The model reads source, finds Jev opportunities, produces
|
|
10
|
+
* measurable evidence, and writes an HTML report.
|
|
11
|
+
*
|
|
12
|
+
* `projects` comes from {@link findProjects}. `cwd` is the workspace root.
|
|
13
|
+
*/
|
|
14
|
+
export function buildAuditPrompt(cwd, projects) {
|
|
15
|
+
const projectName = cwd.split("/").pop() ?? "workspace";
|
|
16
|
+
// Collect project summaries so the model knows the workspace shape.
|
|
17
|
+
const summaries = [];
|
|
18
|
+
for (const projectPath of projects) {
|
|
19
|
+
const name = projectPath.split("/").pop() ?? projectPath;
|
|
20
|
+
let files;
|
|
21
|
+
try {
|
|
22
|
+
files = discoverSourceFiles(projectPath);
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
console.warn(`audit: could not discover files in ${redact(projectPath)}: ${err instanceof Error ? err.message : err}`);
|
|
26
|
+
files = [];
|
|
27
|
+
}
|
|
28
|
+
const manifests = ["package.json", "Cargo.toml", "pyproject.toml", "go.mod"];
|
|
29
|
+
let manifest = "";
|
|
30
|
+
for (const m of manifests) {
|
|
31
|
+
const p = join(projectPath, m);
|
|
32
|
+
if (existsSync(p)) {
|
|
33
|
+
manifest = m;
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
summaries.push(`- ${name} (${files.length} source files${manifest ? `, manifest: ${manifest}` : ""})`);
|
|
38
|
+
}
|
|
39
|
+
const reportPath = join(cwd, ".pi-warden", "audit-report.html");
|
|
40
|
+
return [
|
|
41
|
+
`# Workspace Audit — ${projectName}`,
|
|
42
|
+
"",
|
|
43
|
+
"You are auditing this workspace to find concrete places where hand-written heuristics can be replaced by typed Jev (TypeSafe) questions.",
|
|
44
|
+
"",
|
|
45
|
+
"## Workspace inventory",
|
|
46
|
+
summaries.join("\n"),
|
|
47
|
+
"",
|
|
48
|
+
"## What to do",
|
|
49
|
+
"",
|
|
50
|
+
"1. **Read the actual source** of each project. Do not rely on README or manifest alone. Open the files listed above (and any others you discover) and read the code.",
|
|
51
|
+
"",
|
|
52
|
+
"2. **Find concrete replacement candidates.** Look for:",
|
|
53
|
+
" - if/else or switch chains that classify, route, or decide",
|
|
54
|
+
" - regex classifiers or keyword lists that label text",
|
|
55
|
+
" - threshold tables or scoring heuristics",
|
|
56
|
+
" - ranking or sorting by hand-built criteria",
|
|
57
|
+
" - validation logic that checks correctness of free-form output",
|
|
58
|
+
" - routing decisions based on intent or content",
|
|
59
|
+
"",
|
|
60
|
+
" For each candidate, cite the exact **file:line** location.",
|
|
61
|
+
"",
|
|
62
|
+
"3. **Produce measurable data for every finding.** For each candidate, gather evidence such as:",
|
|
63
|
+
" - Count the branches, keyword entries, or cases the current code handles",
|
|
64
|
+
" - Count cases it misses (e.g. grep for keywords it does not cover, list known false positives from comments or issues)",
|
|
65
|
+
" - Run the existing test suite and count pass/fail relevant to this code path",
|
|
66
|
+
" - Write a throwaway script under the OS temp directory that feeds sample inputs through both the current code and a Jev question (using `typesafe_evaluate` when available in the session; this tool requires the operator to run `/typesafe enable` once per session — if a call fails with \"TypeSafe is disabled\", ask the operator once, and if it remains disabled, continue with the other evidence and mark those findings unmeasured)",
|
|
67
|
+
"",
|
|
68
|
+
" A finding without measurable comparison data must be marked **\"unmeasured\"** — do not dress it up.",
|
|
69
|
+
"",
|
|
70
|
+
"4. **Rank findings** by expected improvement (high/medium/low) and setup effort (low/medium/high).",
|
|
71
|
+
"",
|
|
72
|
+
`5. **Write a self-contained HTML report** to \`${reportPath}\` (create the \`.pi-warden\` directory if it does not exist).`,
|
|
73
|
+
" - Plain HTML + inline CSS, no external assets, no scripts that fetch anything.",
|
|
74
|
+
" - Sections: summary, findings table, one section per finding with evidence, methodology, and what was not measured.",
|
|
75
|
+
" - One short chat line at the end naming the file path and the finding count.",
|
|
76
|
+
"",
|
|
77
|
+
"## Constraints",
|
|
78
|
+
"",
|
|
79
|
+
"- Read-only: do not modify any project source while auditing. The only files you may write are the report and throwaway scripts under the OS temp directory.",
|
|
80
|
+
"- Be specific. Every finding must name a file:line in this workspace.",
|
|
81
|
+
"- Prefer fewer, well-evidenced findings over many thin ones.",
|
|
82
|
+
"- If no replacement opportunities exist in a project, say so briefly and move on.",
|
|
83
|
+
].join("\n");
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Take a point-in-time snapshot of the audit report file. Call before
|
|
87
|
+
* sending the prompt and again after the agent finishes.
|
|
88
|
+
*/
|
|
89
|
+
export function snapshotReport(reportPath) {
|
|
90
|
+
if (!existsSync(reportPath))
|
|
91
|
+
return { exists: false, mtimeMs: 0 };
|
|
92
|
+
try {
|
|
93
|
+
const s = statSync(reportPath);
|
|
94
|
+
return { exists: true, mtimeMs: s.mtimeMs };
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
console.warn(`audit: could not stat report: ${err instanceof Error ? err.message : err}`);
|
|
98
|
+
return { exists: false, mtimeMs: 0 };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Determine whether the audit produced a new or updated report.
|
|
103
|
+
*/
|
|
104
|
+
export function reportOutcome(before, after) {
|
|
105
|
+
if (!after.exists)
|
|
106
|
+
return "missing";
|
|
107
|
+
if (!before.exists || after.mtimeMs > before.mtimeMs)
|
|
108
|
+
return "written";
|
|
109
|
+
return "stale";
|
|
110
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -41,6 +41,11 @@ export interface ActionGuardConfig {
|
|
|
41
41
|
intentMismatch: number;
|
|
42
42
|
/** The same, for a command whose effect is visible outside the working tree (commit, push, merge, publish, launch): less mismatch is enough. */
|
|
43
43
|
visibleMismatch: number;
|
|
44
|
+
/** Low P(should_proceed) is trace-only unless steer is enabled; hold is the inclusive threshold, not a blocking decision. Calibration: AUC 0.26 against regret, 44% flagged at 0.6 (100 targeted sessions, 2026-09-20). */
|
|
45
|
+
shouldProceed: {
|
|
46
|
+
hold: number;
|
|
47
|
+
steer: boolean;
|
|
48
|
+
};
|
|
44
49
|
/** Write each judged call and what the user did next (approved, declined, re-planned, regretted) to an owner-only per-session file under the agent directory; redacted, never the command. */
|
|
45
50
|
feedbackLog: boolean;
|
|
46
51
|
/** User-defined command rules (user file only; project files cannot set severity above warn). */
|
|
@@ -53,6 +58,11 @@ export interface ActionGuardConfig {
|
|
|
53
58
|
pathRules: PathRule[];
|
|
54
59
|
/** User-defined arming rules: editing files matching globs arms a command pattern for a window (user file only). */
|
|
55
60
|
armingRules: ArmingRule[];
|
|
61
|
+
/** Jev confidence at or above this escalates a violation's severity in the blast-radius and rules-guard escalation paths. */
|
|
62
|
+
escalationThreshold: number;
|
|
63
|
+
/** When a judge answers: "evidence" (default) lets the judge decide the level from irreversible score alone, using built-in pattern hits as context; "level" restores the legacy behaviour where the floor sets the level before the judge.
|
|
64
|
+
* User-declared rules keep their declared action in both modes. */
|
|
65
|
+
floor: "evidence" | "level";
|
|
56
66
|
}
|
|
57
67
|
/** A user-defined path rule: which paths, which side of the access is held, which tools, what happens on a hit. */
|
|
58
68
|
export interface PathRule {
|
package/dist/config.js
CHANGED
|
@@ -29,12 +29,15 @@ export function defaultConfig() {
|
|
|
29
29
|
offTask: { warn: 0.6, steer: 0.85 },
|
|
30
30
|
intentMismatch: 0.9,
|
|
31
31
|
visibleMismatch: 0.8,
|
|
32
|
+
shouldProceed: { hold: 0.6, steer: false },
|
|
32
33
|
feedbackLog: true,
|
|
33
34
|
commandRules: [],
|
|
34
35
|
commandDenyRules: [],
|
|
35
36
|
exemptRules: [],
|
|
36
37
|
pathRules: [],
|
|
37
38
|
armingRules: [],
|
|
39
|
+
escalationThreshold: 0.85,
|
|
40
|
+
floor: "evidence",
|
|
38
41
|
},
|
|
39
42
|
stuck: { enabled: true, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 0.7, churnThreshold: 5, nudge: true },
|
|
40
43
|
done: { enabled: true, claimsDone: 0.7, nudge: true },
|
|
@@ -251,6 +254,10 @@ function applyAction(base, raw, timeoutMs, source) {
|
|
|
251
254
|
offTask: offTaskThreshold(raw.offTask, base.offTask),
|
|
252
255
|
intentMismatch: probability(raw.intentMismatch, base.intentMismatch),
|
|
253
256
|
visibleMismatch: Math.min(probability(raw.visibleMismatch, base.visibleMismatch), probability(raw.intentMismatch, base.intentMismatch)),
|
|
257
|
+
shouldProceed: {
|
|
258
|
+
hold: probability(raw.shouldProceed?.hold, base.shouldProceed.hold),
|
|
259
|
+
steer: boolean(raw.shouldProceed?.steer, base.shouldProceed.steer),
|
|
260
|
+
},
|
|
254
261
|
feedbackLog: boolean(raw.feedbackLog, base.feedbackLog),
|
|
255
262
|
// Only the user file declares these; a project file cannot add, edit, or remove them. The base (already the
|
|
256
263
|
// user's rules when a project file layers on top) is carried through, so a project "action" block cannot wipe them.
|
|
@@ -261,6 +268,8 @@ function applyAction(base, raw, timeoutMs, source) {
|
|
|
261
268
|
pathRules: source === "user" ? parsePathRules(raw.pathRules) : base.pathRules,
|
|
262
269
|
// Arming rules are user-declared security policy: same gate.
|
|
263
270
|
armingRules: source === "user" ? parseArmingRules(raw.armingRules) : base.armingRules,
|
|
271
|
+
escalationThreshold: probability(raw.escalationThreshold, base.escalationThreshold),
|
|
272
|
+
floor: source === "user" && (raw.floor === "level" || raw.floor === "evidence") ? raw.floor : base.floor,
|
|
264
273
|
};
|
|
265
274
|
}
|
|
266
275
|
function applyStuck(base, raw) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface ConscienceConfig {
|
|
2
|
+
enabled: boolean;
|
|
3
|
+
skills: {
|
|
4
|
+
mode: "off" | "recommend" | "load";
|
|
5
|
+
exclude: string[];
|
|
6
|
+
};
|
|
7
|
+
tools: {
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
exclude: string[];
|
|
10
|
+
};
|
|
11
|
+
timeoutMs: number;
|
|
12
|
+
maxAssessments: number;
|
|
13
|
+
maxNudges: number;
|
|
14
|
+
maxSkillBytes: number;
|
|
15
|
+
maxLoadedBytes: number;
|
|
16
|
+
}
|
|
17
|
+
export declare const conscienceWarnings: (config: ConscienceConfig) => readonly string[];
|
|
18
|
+
export declare function disabledConscience(): ConscienceConfig;
|
|
19
|
+
export declare function validConscience(value: unknown): value is ConscienceConfig;
|
|
20
|
+
/** Normalize each raw layer before defaults lose the distinction between absent and explicit opt-outs. */
|
|
21
|
+
export declare function mergeConscience(base: ConscienceConfig, layer: Record<string, unknown>, project: boolean): ConscienceConfig;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const ranges = { timeoutMs: [100, 10000], maxAssessments: [1, 10], maxNudges: [1, 5], maxSkillBytes: [1024, 131072], maxLoadedBytes: [1024, 262144] };
|
|
2
|
+
const modes = ["off", "recommend", "load"];
|
|
3
|
+
const warnings = new WeakMap();
|
|
4
|
+
const object = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
const exclusions = (value) => Array.isArray(value) && value.every(item => typeof item === "string" && /^[A-Za-z0-9_.:*\-]+$/.test(item));
|
|
6
|
+
export const conscienceWarnings = (config) => warnings.get(config) ?? [];
|
|
7
|
+
export function disabledConscience() {
|
|
8
|
+
return { enabled: false, skills: { mode: "off", exclude: [] }, tools: { enabled: false, exclude: [] }, timeoutMs: 1500, maxAssessments: 3, maxNudges: 2, maxSkillBytes: 32768, maxLoadedBytes: 65536 };
|
|
9
|
+
}
|
|
10
|
+
export function validConscience(value) {
|
|
11
|
+
if (!object(value) || typeof value.enabled !== "boolean" || !object(value.skills) || !object(value.tools))
|
|
12
|
+
return false;
|
|
13
|
+
if (!modes.includes(value.skills.mode) || !exclusions(value.skills.exclude) || typeof value.tools.enabled !== "boolean" || !exclusions(value.tools.exclude))
|
|
14
|
+
return false;
|
|
15
|
+
return Object.entries(ranges).every(([key, [min, max]]) => typeof value[key] === "number" && Number.isInteger(value[key]) && value[key] >= min && value[key] <= max);
|
|
16
|
+
}
|
|
17
|
+
/** Normalize each raw layer before defaults lose the distinction between absent and explicit opt-outs. */
|
|
18
|
+
export function mergeConscience(base, layer, project) {
|
|
19
|
+
const notes = [...conscienceWarnings(base)];
|
|
20
|
+
const warn = (key) => notes.push(`Invalid conscience configuration: ${key}; prior policy retained or capability disabled.`);
|
|
21
|
+
const raw = object(layer.conscience) ? layer.conscience : {};
|
|
22
|
+
if (layer.conscience !== undefined && !object(layer.conscience))
|
|
23
|
+
warn("conscience");
|
|
24
|
+
const next = { ...base, skills: { ...base.skills, exclude: [...base.skills.exclude] }, tools: { ...base.tools, exclude: [...base.tools.exclude] } };
|
|
25
|
+
const action = object(layer.action) ? layer.action : {};
|
|
26
|
+
const legacyOff = action.enabled === false || action.toolRecommendation === false;
|
|
27
|
+
if (raw.enabled !== undefined && typeof raw.enabled !== "boolean")
|
|
28
|
+
warn("conscience.enabled");
|
|
29
|
+
const explicit = typeof raw.enabled === "boolean";
|
|
30
|
+
const enabled = explicit ? raw.enabled : legacyOff ? false : base.enabled;
|
|
31
|
+
next.enabled = project ? base.enabled && enabled : enabled;
|
|
32
|
+
if (layer.enabled === false)
|
|
33
|
+
next.enabled = false;
|
|
34
|
+
if (explicit && (legacyOff || layer.enabled === false))
|
|
35
|
+
notes.push("Conscience migration: conscience.enabled supersedes action.enabled/action.toolRecommendation; enabled remains the master opt-out.");
|
|
36
|
+
for (const category of ["skills", "tools"]) {
|
|
37
|
+
const value = raw[category];
|
|
38
|
+
if (value === undefined)
|
|
39
|
+
continue;
|
|
40
|
+
if (!object(value)) {
|
|
41
|
+
warn(`conscience.${category}`);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (category === "skills" && value.mode !== undefined) {
|
|
45
|
+
if (modes.includes(value.mode)) {
|
|
46
|
+
const index = modes.indexOf(value.mode);
|
|
47
|
+
next.skills.mode = modes[project ? Math.min(index, modes.indexOf(base.skills.mode)) : index];
|
|
48
|
+
}
|
|
49
|
+
else
|
|
50
|
+
warn("conscience.skills.mode");
|
|
51
|
+
}
|
|
52
|
+
if (category === "tools" && value.enabled !== undefined) {
|
|
53
|
+
if (typeof value.enabled === "boolean")
|
|
54
|
+
next.tools.enabled = project ? base.tools.enabled && value.enabled : value.enabled;
|
|
55
|
+
else
|
|
56
|
+
warn("conscience.tools.enabled");
|
|
57
|
+
}
|
|
58
|
+
if (value.exclude !== undefined) {
|
|
59
|
+
if (exclusions(value.exclude))
|
|
60
|
+
next[category].exclude = [...new Set([...(project ? base[category].exclude : []), ...value.exclude])];
|
|
61
|
+
else {
|
|
62
|
+
warn(`conscience.${category}.exclude`);
|
|
63
|
+
if (category === "skills")
|
|
64
|
+
next.skills.mode = "off";
|
|
65
|
+
else
|
|
66
|
+
next.tools.enabled = false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const key of Object.keys(ranges)) {
|
|
71
|
+
const value = raw[key];
|
|
72
|
+
if (value === undefined)
|
|
73
|
+
continue;
|
|
74
|
+
const [min, max] = ranges[key];
|
|
75
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= min && value <= max)
|
|
76
|
+
next[key] = project ? Math.min(base[key], value) : value;
|
|
77
|
+
else
|
|
78
|
+
warn(`conscience.${key}`);
|
|
79
|
+
}
|
|
80
|
+
warnings.set(next, [...new Set(notes)]);
|
|
81
|
+
return next;
|
|
82
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Skill } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ActionGuardConfig } from "./config.js";
|
|
3
|
+
export interface SkillReadRequest {
|
|
4
|
+
skill: Skill;
|
|
5
|
+
cwd: string;
|
|
6
|
+
action: ActionGuardConfig;
|
|
7
|
+
maxBytes: number;
|
|
8
|
+
signal: AbortSignal;
|
|
9
|
+
disclosed: string;
|
|
10
|
+
}
|
|
11
|
+
export type SkillReadResult = {
|
|
12
|
+
ok: true;
|
|
13
|
+
body: string;
|
|
14
|
+
bytes: number;
|
|
15
|
+
digest: string;
|
|
16
|
+
notice: boolean;
|
|
17
|
+
} | {
|
|
18
|
+
ok: false;
|
|
19
|
+
reason: "load_denied" | "load_too_large" | "load_changed" | "load_failed" | "metadata_unsafe" | "timeout";
|
|
20
|
+
};
|
|
21
|
+
export declare function skillPathPolicy(skill: Skill, cwd: string, action: ActionGuardConfig, resolved?: string): {
|
|
22
|
+
allowed: boolean;
|
|
23
|
+
notice: boolean;
|
|
24
|
+
};
|
|
25
|
+
/** Narrow catalog read, not an agent tool call and not a filesystem sandbox. */
|
|
26
|
+
export declare function readTrustedSkill(request: SkillReadRequest): Promise<SkillReadResult>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { basename, resolve } from "node:path";
|
|
4
|
+
import { parseFrontmatter, stripFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { matchPathRules } from "./guard.js";
|
|
6
|
+
import { digest } from "./conscience-policy.js";
|
|
7
|
+
import { safeConscienceText } from "./conscience-privacy.js";
|
|
8
|
+
export function skillPathPolicy(skill, cwd, action, resolved) {
|
|
9
|
+
const hits = [skill.filePath, ...(resolved ? [resolved] : [])].flatMap(path => matchPathRules("read", { path }, cwd, action.pathRules, new Set(action.exemptRules)));
|
|
10
|
+
return { allowed: !hits.some(hit => hit.severity === "deny" || hit.severity === "destructive"), notice: hits.length > 0 };
|
|
11
|
+
}
|
|
12
|
+
/** Narrow catalog read, not an agent tool call and not a filesystem sandbox. */
|
|
13
|
+
export async function readTrustedSkill(request) {
|
|
14
|
+
const { skill, cwd, action, signal, maxBytes } = request;
|
|
15
|
+
const check = () => signal.throwIfAborted();
|
|
16
|
+
const fingerprint = (value) => [value.dev, value.ino, value.size, value.mtimeMs, value.ctimeMs].join(":");
|
|
17
|
+
try {
|
|
18
|
+
check();
|
|
19
|
+
if (!skillPathPolicy(skill, cwd, action).allowed)
|
|
20
|
+
return { ok: false, reason: "load_denied" };
|
|
21
|
+
const advertised = resolve(cwd, skill.filePath);
|
|
22
|
+
const target = await realpath(advertised);
|
|
23
|
+
check();
|
|
24
|
+
const base = await realpath(resolve(cwd, skill.baseDir));
|
|
25
|
+
check();
|
|
26
|
+
const policy = skillPathPolicy(skill, cwd, action, target);
|
|
27
|
+
if (!policy.allowed)
|
|
28
|
+
return { ok: false, reason: "load_denied" };
|
|
29
|
+
const before = await stat(target);
|
|
30
|
+
check();
|
|
31
|
+
if (!before.isFile())
|
|
32
|
+
return { ok: false, reason: "load_failed" };
|
|
33
|
+
if (before.size > maxBytes)
|
|
34
|
+
return { ok: false, reason: "load_too_large" };
|
|
35
|
+
const handle = await open(target, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
36
|
+
try {
|
|
37
|
+
check();
|
|
38
|
+
const opened = await handle.stat();
|
|
39
|
+
check();
|
|
40
|
+
if (!opened.isFile() || fingerprint(before) !== fingerprint(opened))
|
|
41
|
+
return { ok: false, reason: "load_changed" };
|
|
42
|
+
const buffer = Buffer.alloc(maxBytes + 1);
|
|
43
|
+
let count = 0;
|
|
44
|
+
while (count < buffer.length) {
|
|
45
|
+
const result = await handle.read(buffer, count, buffer.length - count, count);
|
|
46
|
+
check();
|
|
47
|
+
if (!result.bytesRead)
|
|
48
|
+
break;
|
|
49
|
+
count += result.bytesRead;
|
|
50
|
+
}
|
|
51
|
+
if (count > maxBytes)
|
|
52
|
+
return { ok: false, reason: "load_too_large" };
|
|
53
|
+
const after = await handle.stat();
|
|
54
|
+
check();
|
|
55
|
+
const currentTarget = await realpath(advertised);
|
|
56
|
+
check();
|
|
57
|
+
const currentBase = await realpath(resolve(cwd, skill.baseDir));
|
|
58
|
+
check();
|
|
59
|
+
const current = await stat(target);
|
|
60
|
+
check();
|
|
61
|
+
if (currentTarget !== target || currentBase !== base || fingerprint(opened) !== fingerprint(after) || fingerprint(after) !== fingerprint(current) || count !== after.size)
|
|
62
|
+
return { ok: false, reason: "load_changed" };
|
|
63
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, count));
|
|
64
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(text))
|
|
65
|
+
return { ok: false, reason: "load_failed" };
|
|
66
|
+
const { frontmatter } = parseFrontmatter(text);
|
|
67
|
+
if ((frontmatter.name ?? basename(skill.baseDir)) !== skill.name || frontmatter.description !== skill.description || frontmatter["disable-model-invocation"] === true)
|
|
68
|
+
return { ok: false, reason: "load_changed" };
|
|
69
|
+
const body = stripFrontmatter(text);
|
|
70
|
+
if (safeConscienceText(text, request.disclosed) !== text)
|
|
71
|
+
return { ok: false, reason: "metadata_unsafe" };
|
|
72
|
+
check();
|
|
73
|
+
return { ok: true, body, bytes: count, digest: digest(text), notice: policy.notice };
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
await handle.close();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return { ok: false, reason: signal.aborted ? "timeout" : "load_failed" };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const SCORE_LEVELS: string[];
|
|
2
|
+
export declare const SCORE_INSTRUCTIONS = "Assess next-step usefulness of the named candidate against task, recent context, observations, supplied capabilities and constraints. A supplied workflow must not be replaced; another skill must support a new unmet need. Conversation methods may be useful before asking the user a question. An appropriate ordinary tool does not exclude a complementary skill.";
|
|
3
|
+
export declare const DISPOSITION: {
|
|
4
|
+
type: "choice";
|
|
5
|
+
instructions: string;
|
|
6
|
+
criteria: {
|
|
7
|
+
advance: string;
|
|
8
|
+
awaiting_user: string;
|
|
9
|
+
no_gap: string;
|
|
10
|
+
unclear: string;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
export declare const CONSCIENCE_MODEL = "jev-1.13";
|
|
14
|
+
export declare const digest: (value: unknown) => string;
|
|
15
|
+
export declare const QUESTION_POLICY_HASH: string;
|
|
16
|
+
export interface MeasuredPolicy {
|
|
17
|
+
questionHash: string;
|
|
18
|
+
model: string;
|
|
19
|
+
recommendation: number;
|
|
20
|
+
loading: number;
|
|
21
|
+
evidence: string;
|
|
22
|
+
}
|
|
23
|
+
export declare const measuredPolicy: MeasuredPolicy | undefined;
|
|
24
|
+
export declare function validPolicy(policy: MeasuredPolicy | undefined, model: string): policy is MeasuredPolicy;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export const SCORE_LEVELS = [
|
|
3
|
+
"No useful contribution to the current request, or conflicts with the supplied constraints.",
|
|
4
|
+
"Related to the subject, but already covered, premature, or unable to resolve the current need.",
|
|
5
|
+
"Addresses a concrete unmet need at the current step without displacing the active workflow.",
|
|
6
|
+
"Supplies a missing prerequisite or directly applicable documented method needed for the current step.",
|
|
7
|
+
];
|
|
8
|
+
export const SCORE_INSTRUCTIONS = "Assess next-step usefulness of the named candidate against task, recent context, observations, supplied capabilities and constraints. A supplied workflow must not be replaced; another skill must support a new unmet need. Conversation methods may be useful before asking the user a question. An appropriate ordinary tool does not exclude a complementary skill.";
|
|
9
|
+
export const DISPOSITION = {
|
|
10
|
+
type: "choice",
|
|
11
|
+
instructions: "What is the current task disposition? An initial thinking request is not awaiting_user merely because clarification will be needed: its conversation method is an available next step. Awaiting_user requires an actual unanswered question from the assistant.",
|
|
12
|
+
criteria: {
|
|
13
|
+
advance: "A useful next step can be taken now.",
|
|
14
|
+
awaiting_user: "The assistant has already asked for information and must wait for the user.",
|
|
15
|
+
no_gap: "The request is satisfied with no useful missing step.",
|
|
16
|
+
unclear: "There is insufficient evidence to identify a useful next step.",
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
export const CONSCIENCE_MODEL = "jev-1.13";
|
|
20
|
+
export const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
21
|
+
export const QUESTION_POLICY_HASH = digest({ levels: SCORE_LEVELS, instructions: SCORE_INSTRUCTIONS, disposition: DISPOSITION, stateVersion: 1 });
|
|
22
|
+
// No shipping threshold exists until the frozen holdout and follow-through gates pass.
|
|
23
|
+
export const measuredPolicy = undefined;
|
|
24
|
+
export function validPolicy(policy, model) {
|
|
25
|
+
return !!policy && policy.questionHash === QUESTION_POLICY_HASH && policy.model === model && !!policy.evidence
|
|
26
|
+
&& Number.isFinite(policy.recommendation) && policy.recommendation >= 0.5 && policy.recommendation <= 0.95
|
|
27
|
+
&& Number.isFinite(policy.loading) && policy.loading >= policy.recommendation && policy.loading <= 0.95;
|
|
28
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** URLs are not local paths; protect them before scanning free text for filesystem spans. */
|
|
2
|
+
export declare function safeConscienceText(text: string, disclosed?: string): string;
|
|
3
|
+
export declare const safeCapabilityName: (name: string) => boolean;
|
|
4
|
+
export declare const excludedCapability: (name: string, patterns: readonly string[]) => boolean;
|