@tryinget/pi-agent-vent 0.1.2 → 0.2.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/README.md CHANGED
@@ -45,11 +45,12 @@ Then in Pi:
45
45
 
46
46
  ## Tool behavior
47
47
 
48
- `agent_vent` supports fourteen actions:
48
+ `agent_vent` supports fifteen actions:
49
49
 
50
50
  | Action | Purpose |
51
51
  |---|---|
52
- | `record` | Append a minimized vent record. Requires `summary`. |
52
+ | `record` | Append a minimized vent record. Requires `summary` and must pass the local anti-junk quality check. |
53
+ | `preview` | Build the sanitized would-be record and anti-junk quality result without writing the local store. |
53
54
  | `summary` | Show recurrence groups and advisory candidate incidents. |
54
55
  | `list` | Show recent local records. |
55
56
  | `path` | Show the store path and boundary contract. |
@@ -99,11 +100,14 @@ The runtime-facing name is intentionally singular: use `agent_vent` for the LLM
99
100
 
100
101
  `pi-agent-vent` is a companion to `pi-autonomous-session-control`, not part of it. ASC/`self` remains the execution and operational-mirror owner; this package owns only local vent diagnostics.
101
102
 
103
+ For the cross-package handoff between ASC `self` diagnostic candidates, toolbox activation, and `agent_vent` preview/record writes, see [Self, toolbox, and agent_vent diagnostic boundary](docs/project/2026-06-05-self-toolbox-agent-vent-diagnostic-boundary.md).
104
+
102
105
  When `pi-toolbox-discovery` is installed, it exposes the `agent_vent` bundle so agents can discover or activate the same-named `agent_vent` tool on demand:
103
106
 
104
107
  ```ts
105
108
  toolbox({ action: "search", query: "vent" })
106
109
  toolbox({ action: "activate", bundle: "agent_vent" })
110
+ agent_vent({ action: "preview", summary: "...", category: "workflow", tool: "..." })
107
111
  ```
108
112
 
109
113
  The owner extension must still be installed/reloaded so the `agent_vent` tool is registered before toolbox can activate it.
@@ -157,6 +161,7 @@ Product docs:
157
161
  - [Vision](docs/project/vision.md)
158
162
  - [Product posture](docs/project/product-posture.md)
159
163
  - [Agent vent design](docs/project/2026-05-21-agent-vent-design.md)
164
+ - [Self/toolbox/agent_vent diagnostic boundary](docs/project/2026-06-05-self-toolbox-agent-vent-diagnostic-boundary.md)
160
165
  - [Implementation plan](docs/project/2026-05-21-agent-vent-implementation-plan.md)
161
166
 
162
167
  ## Package checks
@@ -168,7 +173,9 @@ npm install
168
173
  npm run check
169
174
  ```
170
175
 
171
- For release confidence, `npm run release:check` packs the package, verifies packaged docs/scripts, runs the packaged fallback gate, installs the tarball into an isolated npm prefix for a no-auth shadow registered-tool `agent_vent path` smoke, then installs the tarball with isolated Pi settings and npm prefix/cache, validates that local `npm:<tarball>` is being used only as the install source, and smokes the installed artifact through local-path Pi package discovery with `/agent_vent path`. Use `npm run release:check:quick` for artifact-only checks when live Pi smoke is not available; quick checks still include the no-auth installed shadow registered-tool smoke. These smokes prove artifact/package-loading behavior only, not npm/GitHub publication or provenance.
176
+ For release confidence, `npm run release:check` packs the package, verifies packaged docs/scripts, runs the packaged fallback gate, installs the tarball into an isolated npm prefix for a no-auth shadow registered-tool `agent_vent path` smoke, then installs the tarball with isolated Pi settings and npm prefix/cache, validates that local `npm:<tarball>` is being used only as the install source, and smokes the installed artifact through local-path Pi package discovery with `/agent_vent path`. Use `npm run release:check:quick` for artifact-only checks when live Pi smoke is not available; quick checks still include the no-auth installed shadow registered-tool smoke.
177
+
178
+ Release probes pin `pi-ai` and `pi-coding-agent` to one exact version and fail before installation if that contract diverges from the selected/installed Pi host. Only the isolated artifact/probe installs set `min-release-age=0`, allowing a newly selected exact host contract to be tested while ordinary installs continue to obey the workstation supply-chain cutoff. These smokes prove artifact/package-loading behavior only, not npm/GitHub publication or provenance.
172
179
 
173
180
  Run from monorepo root through the canonical package gate:
174
181
 
@@ -55,3 +55,16 @@ Not selected by default:
55
55
  - Authority: candidate incidents are recommendations only; do not create or imply AK/GitHub/incident mutations.
56
56
  - Validation: `npm run check` from this package, or root `bash ./scripts/package-quality-gate.sh ci packages/pi-agent-vent`.
57
57
  - Optional companions are intentionally not adopted for v0.1: no `fast-check`, Cucumber, Nunjucks, or ts-quality rollout.
58
+
59
+ ## Repo loop validation
60
+
61
+ `@tryinget/pi-agent-vent` adopts `repo-loop-validation-v1` for package-local loop prompt dogfooding. The policy declaration is in `policy/engineering-lane.json`.
62
+
63
+ - `loop-doctor`: `npm run loop-doctor` (non-failing Node/npm/package/git diagnostics)
64
+ - `loop-verify-fast`: `npm run loop-verify-fast` (maps to `quality:pre-commit`)
65
+ - `loop-impact-plan`: `npm run loop-impact-plan` (coarse package impact note plus changed-file listing)
66
+ - `loop-impact-run`: `npm run loop-impact-run` (maps to `npm run check`)
67
+ - `loop-impact-wide`: `npm run loop-impact-wide` (explicit full package gate, also `npm run check`)
68
+ - `loop-landing-check`: `npm run loop-landing-check` (maps to `npm run check`)
69
+
70
+ These commands produce package-local evidence for orchestration prompts. They do not replace Pi runtime install/reload proof, release approval, or monorepo owner authority.
@@ -95,7 +95,8 @@ The tool prompt and runtime validation both bias toward minimal summaries:
95
95
 
96
96
  Actions:
97
97
 
98
- - `record` — append a vent record.
98
+ - `record` — append a vent record after the local anti-junk quality check accepts it.
99
+ - `preview` — build the sanitized would-be record and anti-junk quality result without writing the local store.
99
100
  - `summary` — summarize recurrence groups and candidate incidents.
100
101
  - `list` — show recent records.
101
102
  - `path` — show local store path and data contract.
@@ -112,7 +113,8 @@ Actions:
112
113
 
113
114
  Important behavior:
114
115
 
115
- - `record` requires `summary`.
116
+ - `record` requires `summary` and rejects low-signal generic payloads such as `done`.
117
+ - `preview` returns `recordPreview`, `quality`, and `wouldRecord` without appending JSONL; use it before `record` when the candidate came from `self`, another tool, or a generic/friction-heavy moment.
116
118
  - `severity` defaults to `medium`.
117
119
  - `category` defaults to `other`.
118
120
  - `recurrenceKey` may be supplied by the agent; otherwise it is derived from category + summary.
@@ -164,11 +166,11 @@ This heuristic intentionally errs toward surfacing review candidates, not assert
164
166
 
165
167
  ## Cross-package integration
166
168
 
167
- `pi-agent-vent` remains separate from `pi-autonomous-session-control` by design:
169
+ `pi-agent-vent` remains separate from `pi-autonomous-session-control` by design. The detailed handoff is documented in [Self, toolbox, and agent_vent diagnostic boundary](2026-06-05-self-toolbox-agent-vent-diagnostic-boundary.md):
168
170
 
169
- - ASC/`self` owns operational introspection, subagent/runtime control, and mirror-only handoff/progress summaries.
170
- - `pi-agent-vent` owns local diagnostic vent records, redaction, recurrence grouping, and advisory candidate-incident heuristics.
171
+ - ASC/`self` owns operational introspection, subagent/runtime control, mirror-only handoff/progress summaries, and typed `self.diagnostic_candidate.v1` suggestions.
171
172
  - `pi-toolbox-discovery` owns discovery/activation of the already-registered `agent_vent` tool through the same-named `agent_vent` bundle; `agent-vent` is not a runtime alias.
173
+ - `pi-agent-vent` owns local diagnostic vent records, preview quality checks, redaction, recurrence grouping, and advisory candidate-incident heuristics.
172
174
 
173
175
  This keeps vent persistence from becoming hidden ASC state while still making the capability discoverable during autonomous work.
174
176
 
@@ -0,0 +1,66 @@
1
+ ---
2
+ summary: "Boundary contract for self diagnostic candidates, toolbox activation, and agent_vent local diagnostic records."
3
+ read_when:
4
+ - "Changing ASC self diagnostic-review output."
5
+ - "Changing toolbox agent_vent bundle discovery or activation guidance."
6
+ - "Changing agent_vent preview/record behavior or diagnostic authority wording."
7
+ system4d:
8
+ container: "Cross-package diagnostic handoff boundary."
9
+ compass: "Make recurring friction visible without turning diagnostics into authority."
10
+ engine: "self mirrors candidate -> toolbox activates bundle -> agent_vent previews/records local diagnostics -> human decides owner escalation."
11
+ fog: "Diagnostic candidates and local records can be mistaken for tasks, evidence, incidents, telemetry, or ASC state."
12
+ ---
13
+
14
+ # Self, toolbox, and agent_vent diagnostic boundary
15
+
16
+ ## Purpose
17
+
18
+ This note documents the intended handoff between three Pi extension surfaces:
19
+
20
+ 1. `self` from `pi-autonomous-session-control` notices current-session friction and can return a typed `self.diagnostic_candidate.v1` payload.
21
+ 2. `toolbox` from `pi-toolbox-discovery` can discover or activate the already-registered `agent_vent` tool on demand.
22
+ 3. `agent_vent` from `pi-agent-vent` can preview or record minimized local diagnostic records in its append-only local store.
23
+
24
+ The chain is deliberately not an escalation pipeline. It is a low-cost local diagnostic path for repeated bugs, tool failures, workflow friction, context loss, missing affordances, and similar agent-experience problems.
25
+
26
+ ## Ownership contract
27
+
28
+ | Surface | Owns | Does not own |
29
+ |---|---|---|
30
+ | `self` | Moment-level mirror of the current session; candidate diagnostic payloads; suggested next local actions. | Durable vent records, recurrence truth, AK evidence/tasks, GitHub issues, incidents, telemetry, or owner routing. |
31
+ | `toolbox` | Bundle discovery, risk posture, and active-tool-set changes for already-registered tools. | Registering missing owner tools, validating vent quality, storing diagnostics, or deciding escalation. |
32
+ | `agent_vent` | Local JSONL diagnostic records, preview quality checks, redaction, recurrence grouping, review state, curation, draft-only text, export, and retention lifecycle. | AK evidence/tasks, GitHub issues, real incidents, external telemetry, ASC/self state, publication, or owner-system lifecycle. |
33
+
34
+ ## Safe handoff sequence
35
+
36
+ Use this sequence when a session notices recurring or high-friction behavior worth possible local memory:
37
+
38
+ ```ts
39
+ self({ query: "What friction just happened?" })
40
+ toolbox({ action: "activate", bundle: "agent_vent" })
41
+ agent_vent({ action: "preview", summary: "...", category: "...", tool: "...", packageName: "..." })
42
+ agent_vent({ action: "record", summary: "...", category: "...", tool: "...", packageName: "..." })
43
+ ```
44
+
45
+ Rules:
46
+
47
+ - `self` may prefill or suggest an `agent_vent` payload, but it must not write the record internally.
48
+ - `toolbox` activation only makes the `agent_vent` tool callable; it does not create or preview a diagnostic record.
49
+ - `agent_vent action=preview` sanitizes the would-be record and runs the anti-junk quality check without writing the store.
50
+ - `agent_vent action=record` writes only if the local quality check accepts the payload.
51
+ - Generic low-signal summaries such as `done` are rejected for `record`; use `preview` to inspect issues and warnings before writing.
52
+ - Human/operator judgment still decides whether any recurrence should become an AK task, GitHub issue, incident review, evidence record, publication, or owner-surface handoff.
53
+
54
+ ## Copy wording for boundaries
55
+
56
+ Recommended short wording:
57
+
58
+ > Diagnostic review is mirror/local only: `self` can propose a candidate, `toolbox` can activate the `agent_vent` capability, and `agent_vent` can preview or write local diagnostic memory. None of these actions create AK evidence/tasks, GitHub issues, incidents, external telemetry, publication, owner routing, or ASC/self state.
59
+
60
+ Use stronger wording when a durable local write happened:
61
+
62
+ > A local `agent_vent` record was written to the operator's Pi diagnostic store. This is recurrence memory for review, not canonical evidence, task truth, issue state, incident declaration, telemetry, publication, or owner-system mutation.
63
+
64
+ ## Review and escalation posture
65
+
66
+ `agent_vent` recurrence groups and candidate incidents mean “worth human review,” not “incident declared.” Draft outputs are paste-ready text only. Exports are diagnostic projections only. If a human decides a local recurrence deserves owner action, use the owning surface directly and record evidence there according to that owner’s rules.
@@ -114,6 +114,8 @@ The highest-leverage product line is:
114
114
  local vent capture -> operator review queue -> draft-only owner routing -> human-approved escalation
115
115
  ```
116
116
 
117
+ For visible self-evolution work, keep `agent_vent` as recurrence memory and review queue only. The DRY routing map lives in the root `docs/project/visible-self-evolution-spine.md` spine; orchestrator may project verified evidence, while AK/society owner surfaces retain durable authority.
118
+
117
119
  Do not add automatic GitHub/AK/incident writers. The local facet summary, fail-closed facet-filtered local review queue, per-state review outcome follow-up, read-only cross-state review comparison, explicit local decision posture, filter-preserving supported follow-up commands, record/state-scoped facet export, export-local-follow-up guidance, read-only retention-candidate planning, read-only retention-history receipt projection, advisory human-review hints, quoted state-aware next-action guidance, bounded review-detail samples, curation-aware recurrence resolution, facet-aware draft-only routing, retention archive/restore, and privacy membrane now have package validation; remaining product depth should refine operator comprehension only where it does not broaden authority. Hard-delete beyond backup-backed archive is decided out of v0.1: permanent removal remains operator-owned filesystem/data-lifecycle control unless a future decision accepts a narrower purge design.
118
120
 
119
121
  Current proof: `npm run check` passes with 80 package tests plus release dry-run, packaged Markdown link checks, an unpacked-tarball `npm install && npm run check` contract smoke that runs the packaged fallback gate from inside the artifact, an artifact-only no-auth installed shadow registered-tool `agent_vent path` no-store-read smoke, and a full installed-tarball local-path package-discovery `/agent_vent path` smoke using isolated `PI_CODING_AGENT_DIR`, isolated npm prefix/cache, and `PI_AGENT_VENT_DIR`; docs strict check passes, `git diff --check` passes, and historical dogfood covered curation resolution, export posture, and live `pi install` reload behavior. Validation now covers quoted rollback commands, complete retention token inputs, stale-token/stale-restore failures, path-escape/symlink backup failures, receipt-failure rollback, retention-history restore-candidate reconstruction without active-store reads, export follow-up command quoting without archive/restore tokens or owner-routing claims, review decision-posture projection without resolution/assignment/evidence/incident claims, curation-aware recurrence resolution for review state and record feedback, stale lock cleanup, backup restore, duplicate-id-safe retention archive selection, retention-candidate planning without archive tokens, retention-candidate and compare tool-schema/command-contract parity, filter-preserving supported compare follow-ups, record/state-scoped facet export without owner-routing claims, mixed-group export non-broadening, empty export filters failing closed before store reads, tag/facet privacy metadata recomputation, hostile legacy JSONL privacy recomputation, fail-closed review/outcome/compare/export/retention-candidate/history syntax before store reads including empty filters, invalid review category/state handling, explicit per-state outcome/compare limits, quoted legacy recurrence-key command round trips, tool-only maintainer-note hints, reference-style packaged Markdown links, fail-closed `files[]` wildcard handling, public artifact script viability, release metadata alignment with `.copier-answers.yml`, local `npm:<tarball>` install-source validation, isolated installed-command smoke output, local-path package-discovery loading from the installed artifact, shadow registered-tool execution from the installed artifact, and registered-tool `path` no-store-read/no-store-write behavior with symlinked active stores.
@@ -44,6 +44,10 @@ agent notices recurring friction
44
44
  -> owner system receives a human-approved draft only when appropriate
45
45
  ```
46
46
 
47
+ For self-evolution loops, `agent_vent` is the recurrence-memory surface after ASC/self has produced a diagnostic candidate.
48
+ It is not the loop executor, evaluator, or escalation authority.
49
+ Use the root `docs/project/visible-self-evolution-spine.md` spine and the cross-package [self/toolbox/agent_vent diagnostic boundary](./2026-06-05-self-toolbox-agent-vent-diagnostic-boundary.md) for the DRY owner map.
50
+
47
51
  Near-term product work should make the review step better rather than broadening authority. Useful next surfaces include:
48
52
 
49
53
  - `/agent_vent review` for an operator-facing review queue;
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { Type } from "typebox";
4
4
  import {
5
5
  appendCurationEvent,
@@ -7,6 +7,7 @@ import {
7
7
  appendVentRecord,
8
8
  archiveRecurrenceGroup,
9
9
  assertCanCurateRecurrence,
10
+ assessVentRecordQuality,
10
11
  buildEscalationDraft,
11
12
  buildFacetSummary,
12
13
  buildLifecycleSnapshot,
@@ -49,6 +50,7 @@ import {
49
50
  normalizeReviewState,
50
51
  RETENTION_ACTIONS,
51
52
  readRetentionEvents,
53
+ resolveCategoryFilter,
52
54
  resolveRecurrenceGroup,
53
55
  restoreRetentionBackup,
54
56
  SEVERITIES,
@@ -59,6 +61,7 @@ import {
59
61
 
60
62
  const ACTIONS = [
61
63
  "record",
64
+ "preview",
62
65
  "summary",
63
66
  "list",
64
67
  "path",
@@ -79,6 +82,19 @@ const REVIEW_STATES = Array.isArray(STORE_REVIEW_STATES)
79
82
  ? STORE_REVIEW_STATES
80
83
  : FALLBACK_REVIEW_STATES;
81
84
  const RETENTION_CANDIDATE_STATES = ["reviewed", "all", ...REVIEW_STATES] as const;
85
+ const CATEGORY_ALIAS_INPUTS = [
86
+ "workflow_friction",
87
+ "operator_friction",
88
+ "process_friction",
89
+ "missing_affordance",
90
+ "missing_feature",
91
+ "documentation_gap",
92
+ "docs_gap",
93
+ "context_window",
94
+ "context_friction",
95
+ "tooling_friction",
96
+ ] as const;
97
+ const CATEGORY_INPUTS = [...CATEGORIES, ...CATEGORY_ALIAS_INPUTS] as const;
82
98
 
83
99
  const AgentVentParams = Type.Object({
84
100
  action: Type.Optional(
@@ -92,9 +108,10 @@ const AgentVentParams = Type.Object({
92
108
  ),
93
109
  category: Type.Optional(
94
110
  Type.Union(
95
- CATEGORIES.map((category) => Type.Literal(category)),
111
+ CATEGORY_INPUTS.map((category) => Type.Literal(category)),
96
112
  {
97
- description: "Local category for the frustration pattern.",
113
+ description:
114
+ "Local category for the frustration pattern. Common aliases are accepted and normalized to canonical categories.",
98
115
  },
99
116
  ),
100
117
  ),
@@ -235,11 +252,12 @@ export default function agentVentExtension(pi: ExtensionAPI) {
235
252
  name: "agent_vent",
236
253
  label: "Agent Vent",
237
254
  description:
238
- "Record, review, and inspect local agent frustration events so recurring bugs, workflow friction, and missing affordances become visible.",
255
+ "Record, preview, review, and inspect local agent frustration events so recurring bugs, workflow friction, and missing affordances become visible.",
239
256
  promptSnippet:
240
- "Record minimized local frustration events and review recurring patterns without creating incidents, tasks, issues, evidence records, or telemetry.",
257
+ "Preview or record minimized local frustration events and review recurring patterns without creating incidents, tasks, issues, evidence records, or telemetry.",
241
258
  promptGuidelines: [
242
259
  "Use agent_vent when you encounter recurring agent frustration, long-lived bugs, repeated tool/runtime failures, context-loss patterns, or missing affordances worth later human review.",
260
+ "Use action=preview before action=record when the diagnostic may be generic, low-signal, or copied from another tool; previews run the anti-junk quality check without writing the local store.",
243
261
  "Use action=review to inspect the local recurrence review queue; include recurrenceKey to inspect bounded representative samples for one local group; optionally filter review by local category, tag, tool, or package facets; use action=set_review to mark a recurrence group as new, acknowledged, dismissed, or escalation_drafted.",
244
262
  "Use action=outcomes for read-only post-review follow-up across local review-state buckets; outcome guidance is local diagnostic UX only, not owner routing or external completion.",
245
263
  "Use action=compare for a read-only cross-state review comparison before export, retention planning, or draft-only handoff; comparison output emits no archive/restore tokens and mutates nothing.",
@@ -253,7 +271,8 @@ export default function agentVentExtension(pi: ExtensionAPI) {
253
271
  "When calling agent_vent, summarize minimally and never include secrets, credentials, private user payloads, or long raw logs.",
254
272
  ],
255
273
  parameters: AgentVentParams,
256
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
274
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
275
+ throwIfCancelled(signal);
257
276
  const storePath = defaultStorePath();
258
277
  const reviewPath = defaultReviewPath();
259
278
  const curationPath = defaultCurationPath();
@@ -275,13 +294,29 @@ export default function agentVentExtension(pi: ExtensionAPI) {
275
294
  );
276
295
  }
277
296
 
278
- if (action === "record") {
297
+ if (action === "preview" || action === "record") {
279
298
  const sessionFile = ctx.sessionManager.getSessionFile();
280
299
  const record = createVentRecord(params, {
281
300
  cwd: ctx.cwd,
282
301
  sessionFile: sessionFile ? path.basename(sessionFile) : undefined,
283
- source: "agent_vent_tool",
302
+ source: action === "preview" ? "agent_vent_preview" : "agent_vent_tool",
284
303
  });
304
+ const quality = assessVentRecordQuality(params, record);
305
+ if (action === "preview") {
306
+ return textResult(formatRecordPreview(record, quality), {
307
+ action,
308
+ storePath,
309
+ recordPreview: record,
310
+ quality,
311
+ wouldRecord: quality.recordable,
312
+ });
313
+ }
314
+ if (!quality.recordable) {
315
+ throw new Error(
316
+ `agent_vent record rejected by anti-junk quality check: ${quality.issues.join("; ")}`,
317
+ );
318
+ }
319
+ throwIfCancelled(signal);
285
320
  appendVentRecord(storePath, record);
286
321
  const state = loadDiagnosticState({
287
322
  storePath,
@@ -331,6 +366,7 @@ export default function agentVentExtension(pi: ExtensionAPI) {
331
366
  });
332
367
  }
333
368
 
369
+ throwIfCancelled(signal);
334
370
  const state = loadDiagnosticState({
335
371
  storePath,
336
372
  reviewPath,
@@ -367,6 +403,7 @@ export default function agentVentExtension(pi: ExtensionAPI) {
367
403
  };
368
404
  assertCanCurateRecurrence(records, curationEvents, input);
369
405
  const event = createCurationEvent(input, { source: "agent_vent_tool" });
406
+ throwIfCancelled(signal);
370
407
  appendCurationEvent(curationPath, event);
371
408
  const targetText = event.targetRecurrenceKey ? ` -> ${event.targetRecurrenceKey}` : "";
372
409
  const text = [
@@ -400,6 +437,7 @@ export default function agentVentExtension(pi: ExtensionAPI) {
400
437
  },
401
438
  { source: "agent_vent_tool" },
402
439
  );
440
+ throwIfCancelled(signal);
403
441
  appendReviewEvent(reviewPath, event);
404
442
  const text = [
405
443
  `Set local review state for ${event.recurrenceKey} to ${event.state}.`,
@@ -610,6 +648,7 @@ export default function agentVentExtension(pi: ExtensionAPI) {
610
648
  });
611
649
  }
612
650
  if (retentionAction === "archive") {
651
+ throwIfCancelled(signal);
613
652
  const result = archiveRecurrenceGroup({
614
653
  storePath,
615
654
  reviewPath,
@@ -632,6 +671,7 @@ export default function agentVentExtension(pi: ExtensionAPI) {
632
671
  retention: result,
633
672
  });
634
673
  }
674
+ throwIfCancelled(signal);
635
675
  const result = restoreRetentionBackup({
636
676
  storePath,
637
677
  retentionPath,
@@ -737,15 +777,31 @@ function registerAgentVentCommand(pi: ExtensionAPI, name: string, description: s
737
777
  description,
738
778
  handler: async (args, ctx) => {
739
779
  const output = handleCommand(args);
740
- if (ctx.hasUI) {
741
- ctx.ui.notify(output, "info");
742
- } else {
780
+ if (!ctx.hasUI) {
743
781
  console.log(output);
782
+ } else if (isLongCommandOutput(output)) {
783
+ pi.sendMessage({
784
+ customType: "agent-vent-command",
785
+ content: output,
786
+ display: true,
787
+ details: { command: name },
788
+ });
789
+ ctx.ui.notify("agent_vent output added to the session transcript", "info");
790
+ } else {
791
+ ctx.ui.notify(output, "info");
744
792
  }
745
793
  },
746
794
  });
747
795
  }
748
796
 
797
+ function isLongCommandOutput(output: string): boolean {
798
+ return output.length > 500 || output.split("\n").length > 8;
799
+ }
800
+
801
+ function throwIfCancelled(signal: AbortSignal | undefined): void {
802
+ if (signal?.aborted) throw new Error("agent_vent cancelled");
803
+ }
804
+
749
805
  function handleCommand(args: string) {
750
806
  const tokens = splitCommandArgs(args);
751
807
  const action = tokens[0] || "summary";
@@ -759,6 +815,7 @@ function handleCommand(args: string) {
759
815
  return [
760
816
  "agent_vent commands:",
761
817
  " /agent_vent summary Show recurrence groups and candidate incidents.",
818
+ " LLM tool action=preview Preview a local diagnostic record and anti-junk quality check without writing the store.",
762
819
  " /agent_vent list [limit] Show recent local vent records.",
763
820
  " /agent_vent facets [limit] Show read-only local category/tag/tool/package facets.",
764
821
  " /agent_vent review [state|all] [limit] [category=bug] [tag=reload] [tool=pi-reload] [package=tryinget-pi-agent-vent]",
@@ -1084,8 +1141,8 @@ function parseReviewListTokens(tokens: string[], options: { allowReviewedState?:
1084
1141
  invalidFilters.push("category=");
1085
1142
  continue;
1086
1143
  }
1087
- const normalizedCategory = value.toLowerCase().replaceAll("-", "_");
1088
- if (CATEGORIES.includes(normalizedCategory)) filters.category = normalizedCategory;
1144
+ const category = resolveCategoryFilter(value);
1145
+ if (category) filters.category = category;
1089
1146
  else invalidFilters.push(`category=${value}`);
1090
1147
  } else if (key === "tool") {
1091
1148
  if (!value) {
@@ -1374,6 +1431,24 @@ function formatDiagnosticWarnings(state: Record<string, unknown>) {
1374
1431
  : "";
1375
1432
  }
1376
1433
 
1434
+ function formatRecordPreview(
1435
+ record: Record<string, unknown>,
1436
+ quality: { recordable?: boolean; issues?: string[]; warnings?: string[]; boundary?: string },
1437
+ ) {
1438
+ const issues = quality.issues || [];
1439
+ const warnings = quality.warnings || [];
1440
+ return [
1441
+ `Agent vent preview: ${quality.recordable ? "recordable" : "not recordable"} (${record.severity || "medium"}/${record.category || "other"}) under ${record.recurrenceKey || "unknown"}.`,
1442
+ `Summary: ${record.summary || "(no summary)"}`,
1443
+ issues.length ? `Issues: ${issues.join("; ")}` : "Issues: none",
1444
+ warnings.length ? `Warnings: ${warnings.join("; ")}` : "Warnings: none",
1445
+ "No local diagnostic record was written. Use action=record only if this is a recurring or review-worthy diagnostic, not an ordinary progress update.",
1446
+ `Boundary: ${quality.boundary || "local diagnostic preview only"}`,
1447
+ ].join("\n");
1448
+ }
1449
+
1450
+ export const _test = { isLongCommandOutput, throwIfCancelled };
1451
+
1377
1452
  function textResult(text: string, details: Record<string, unknown>) {
1378
1453
  return {
1379
1454
  content: [{ type: "text" as const, text }],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tryinget/pi-agent-vent",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Local pi tool for agents to record recurring frustrations, bugs, and workflow friction without creating incidents or external telemetry",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -28,7 +28,7 @@
28
28
  "access": "public"
29
29
  },
30
30
  "engines": {
31
- "node": ">=22"
31
+ "node": ">=22.19.0"
32
32
  },
33
33
  "scripts": {
34
34
  "fix": "bash ./scripts/quality-gate.sh fix",
@@ -43,7 +43,13 @@
43
43
  "docs:list:workspace": "bash ./scripts/docs-list.sh --workspace --discover",
44
44
  "docs:list:json": "bash ./scripts/docs-list.sh --json",
45
45
  "release:check": "bash ./scripts/release-check.sh",
46
- "release:check:quick": "SKIP_PI_SMOKE=1 bash ./scripts/release-check.sh"
46
+ "release:check:quick": "SKIP_PI_SMOKE=1 bash ./scripts/release-check.sh",
47
+ "loop-doctor": "bash -lc 'node --version; npm --version; npm pkg get name version >/dev/null; git status --short -- . || true; exit 0'",
48
+ "loop-verify-fast": "npm run quality:pre-commit",
49
+ "loop-impact-plan": "bash -lc 'echo \"loop-impact-plan: package-local impact planner is coarse; run npm run loop-impact-run for the full package gate.\"; git status --short -- . || true'",
50
+ "loop-impact-run": "npm run check",
51
+ "loop-impact-wide": "npm run check",
52
+ "loop-landing-check": "npm run check"
47
53
  },
48
54
  "files": [
49
55
  "extensions/agent-vent.ts",
@@ -60,6 +66,7 @@
60
66
  "docs/project/product-posture.md",
61
67
  "docs/project/2026-05-21-agent-vent-design.md",
62
68
  "docs/project/2026-05-21-agent-vent-implementation-plan.md",
69
+ "docs/project/2026-06-05-self-toolbox-agent-vent-diagnostic-boundary.md",
63
70
  "docs/project/extension-sop.md",
64
71
  "docs/project/trusted-publishing.md",
65
72
  "docs/adr/2026-05-22-agent-vent-retention-delete-policy.md"
@@ -79,17 +86,19 @@
79
86
  "releaseConfigMode": "component"
80
87
  },
81
88
  "devDependencies": {
82
- "@biomejs/biome": "2.3.14"
89
+ "@biomejs/biome": "2.3.14",
90
+ "@earendil-works/pi-ai": "0.80.6",
91
+ "@earendil-works/pi-coding-agent": "0.80.6"
83
92
  },
84
93
  "overrides": {
85
94
  "fast-xml-parser": "5.3.6"
86
95
  },
87
96
  "peerDependencies": {
88
- "@mariozechner/pi-ai": "*",
89
- "@mariozechner/pi-coding-agent": "*",
97
+ "@earendil-works/pi-ai": "*",
98
+ "@earendil-works/pi-coding-agent": "*",
90
99
  "typebox": "*"
91
100
  },
92
101
  "dependencies": {
93
- "typebox": "^1.0.0"
102
+ "typebox": "*"
94
103
  }
95
104
  }
@@ -17,6 +17,18 @@
17
17
  "dependency-governance",
18
18
  "specification-and-dsls",
19
19
  "engineering-reasoning"
20
- ]
20
+ ],
21
+ "loop_validation": {
22
+ "version": "repo-loop-validation-v1",
23
+ "contract_doc": "docs/engineering.local.md#repo-loop-validation",
24
+ "commands": {
25
+ "loop-doctor": "npm run loop-doctor",
26
+ "loop-verify-fast": "npm run loop-verify-fast",
27
+ "loop-impact-plan": "npm run loop-impact-plan",
28
+ "loop-impact-run": "npm run loop-impact-run",
29
+ "loop-impact-wide": "npm run loop-impact-wide",
30
+ "loop-landing-check": "npm run loop-landing-check"
31
+ }
32
+ }
21
33
  }
22
34
  }
@@ -1,8 +1,8 @@
1
1
  ---
2
- summary: "Monorepo package implementation planning prompt template."
2
+ summary: "pi-agent-vent implementation planning prompt template."
3
3
  read_when:
4
4
  - "Using or updating the monorepo package implementation-planning prompt template."
5
- description: Draft an implementation plan for a requested change
5
+ description: Draft an implementation plan for a requested pi-agent-vent change
6
6
  system4d:
7
7
  container: "Prompt template for implementation planning."
8
8
  compass: "Turn requests into actionable, risk-aware plans."
@@ -10,7 +10,7 @@ system4d:
10
10
  fog: "Hidden constraints unless assumptions are surfaced."
11
11
  ---
12
12
 
13
- Create an implementation plan for this request: $@
13
+ Create a pi-agent-vent implementation plan for this request: $@
14
14
 
15
15
  Include:
16
16
  - Scope and non-goals
@@ -1,8 +1,8 @@
1
1
  ---
2
- summary: "Monorepo package security review prompt template."
2
+ summary: "pi-agent-vent security review prompt template."
3
3
  read_when:
4
4
  - "Using or updating the monorepo package security-review prompt template."
5
- description: Review a change for security risks and mitigations
5
+ description: Review a pi-agent-vent change for security risks and mitigations
6
6
  system4d:
7
7
  container: "Prompt template for security-focused review."
8
8
  compass: "Identify practical vulnerabilities before release."
@@ -10,7 +10,7 @@ system4d:
10
10
  fog: "Partial context can hide exploit paths."
11
11
  ---
12
12
 
13
- Review this change for security concerns: $@
13
+ Review this pi-agent-vent change for security concerns: $@
14
14
 
15
15
  Focus on:
16
16
  - Input validation and injection risk
@@ -8,7 +8,81 @@ NAME="$(node -p "JSON.parse(require('node:fs').readFileSync('package.json', 'utf
8
8
  VERSION="$(node -p "JSON.parse(require('node:fs').readFileSync('package.json', 'utf8')).version")"
9
9
  REPOSITORY_URL="$(node -p "(() => { const pkg = JSON.parse(require('node:fs').readFileSync('package.json', 'utf8')); const repo = pkg.repository; if (typeof repo === 'string') return repo.trim(); if (repo && typeof repo === 'object' && typeof repo.url === 'string') return repo.url.trim(); return ''; })()")"
10
10
 
11
+ HOST_VERSION="$(node -p "JSON.parse(require('node:fs').readFileSync('package.json', 'utf8')).devDependencies['@earendil-works/pi-coding-agent'] || ''")"
12
+
13
+ # One release-only policy membrane. Every dependency-resolving command, including
14
+ # Pi's nested npm invocation, must enter through here with disposable cache/prefix.
15
+ RELEASE_MIN_AGE=0
16
+ with_release_npm_policy() {
17
+ local cache="$1"
18
+ local prefix="$2"
19
+ shift 2
20
+ case "$cache" in
21
+ /tmp/pi-agent-vent-*-npm-cache-*) ;;
22
+ *)
23
+ echo "Release command refused non-isolated npm cache: $cache" >&2
24
+ return 1
25
+ ;;
26
+ esac
27
+ if [[ "$prefix" != "-" ]]; then
28
+ case "$prefix" in
29
+ /tmp/pi-agent-vent-*-npm-prefix-*) ;;
30
+ *)
31
+ echo "Release command refused non-isolated npm prefix: $prefix" >&2
32
+ return 1
33
+ ;;
34
+ esac
35
+ (
36
+ export NPM_CONFIG_PREFIX="$prefix" NPM_CONFIG_CACHE="$cache"
37
+ export NPM_CONFIG_MIN_RELEASE_AGE="$RELEASE_MIN_AGE"
38
+ "$@"
39
+ )
40
+ else
41
+ (
42
+ export NPM_CONFIG_CACHE="$cache" NPM_CONFIG_MIN_RELEASE_AGE="$RELEASE_MIN_AGE"
43
+ "$@"
44
+ )
45
+ fi
46
+ }
47
+
48
+ release_npm_install() {
49
+ local cache="$1"
50
+ local prefix="$2"
51
+ shift 2
52
+ with_release_npm_policy "$cache" "$prefix" npm install \
53
+ --ignore-scripts --no-audit --fund=false "$@"
54
+ }
55
+
56
+ CONTROL_NPM_CACHE="$(mktemp -d /tmp/pi-agent-vent-control-npm-cache-XXXXXX)"
57
+ TEST_AGENT_DIR=""
58
+ TEST_NPM_PREFIX=""
59
+ TEST_NPM_CACHE=""
60
+ ARTIFACT_NPM_PREFIX=""
61
+ ARTIFACT_NPM_CACHE=""
62
+ ARTIFACT_TOOL_VENT_DIR=""
63
+ TARBALL_CHECK_DIR=""
64
+ TARBALL_NPM_CACHE=""
65
+ TARBALL_PATH=""
66
+ cleanup() {
67
+ if [[ "${KEEP_RELEASE_ARTIFACTS:-0}" != "1" ]]; then
68
+ for path_to_remove in "$CONTROL_NPM_CACHE" "$TEST_AGENT_DIR" "$TEST_NPM_PREFIX" \
69
+ "$TEST_NPM_CACHE" "$ARTIFACT_NPM_PREFIX" "$ARTIFACT_NPM_CACHE" \
70
+ "$ARTIFACT_TOOL_VENT_DIR" "$TARBALL_CHECK_DIR" "$TARBALL_NPM_CACHE"; do
71
+ if [[ -n "$path_to_remove" && -d "$path_to_remove" ]]; then
72
+ rm -rf "$path_to_remove"
73
+ fi
74
+ done
75
+ if [[ -n "$TARBALL_PATH" && -f "$TARBALL_PATH" ]]; then
76
+ rm -f "$TARBALL_PATH"
77
+ fi
78
+ fi
79
+ }
80
+ trap cleanup EXIT
81
+
11
82
  echo "== release-check: ${NAME}@${VERSION}"
83
+ node ./scripts/release-smoke-check.mjs assert-exact-host-contract \
84
+ --package-json package.json \
85
+ --host-version "$HOST_VERSION"
12
86
 
13
87
  if [[ -z "$REPOSITORY_URL" ]]; then
14
88
  echo "package.json repository.url is required for provenance release publishing." >&2
@@ -21,14 +95,14 @@ if [[ "$NAME" != "${NAME,,}" ]]; then
21
95
  fi
22
96
 
23
97
  echo "== npm pack --dry-run --json"
24
- PACK_JSON="$(npm pack --dry-run --json)"
98
+ PACK_JSON="$(npm --cache "$CONTROL_NPM_CACHE" pack --dry-run --json)"
25
99
  echo "$PACK_JSON"
26
100
 
27
101
  PACK_JSON="$PACK_JSON" node ./scripts/release-artifact-check.mjs
28
102
 
29
103
  echo "== npm publish --dry-run"
30
104
  set +e
31
- PUBLISH_DRY_RUN_OUTPUT="$(npm publish --dry-run 2>&1)"
105
+ PUBLISH_DRY_RUN_OUTPUT="$(npm --cache "$CONTROL_NPM_CACHE" publish --dry-run 2>&1)"
32
106
  PUBLISH_DRY_RUN_EXIT=$?
33
107
  set -e
34
108
  echo "$PUBLISH_DRY_RUN_OUTPUT"
@@ -41,55 +115,20 @@ if [[ "$PUBLISH_DRY_RUN_EXIT" -ne 0 ]]; then
41
115
  fi
42
116
  fi
43
117
 
44
- TEST_AGENT_DIR=""
45
- TEST_NPM_PREFIX=""
46
- TEST_NPM_CACHE=""
47
- ARTIFACT_NPM_PREFIX=""
48
- ARTIFACT_NPM_CACHE=""
49
- ARTIFACT_TOOL_VENT_DIR=""
50
- TARBALL_CHECK_DIR=""
51
- TARBALL_PATH=""
52
- cleanup() {
53
- if [[ "${KEEP_RELEASE_ARTIFACTS:-0}" != "1" ]]; then
54
- if [[ -n "$TEST_AGENT_DIR" && -d "$TEST_AGENT_DIR" ]]; then
55
- rm -rf "$TEST_AGENT_DIR"
56
- fi
57
- if [[ -n "$TEST_NPM_PREFIX" && -d "$TEST_NPM_PREFIX" ]]; then
58
- rm -rf "$TEST_NPM_PREFIX"
59
- fi
60
- if [[ -n "$TEST_NPM_CACHE" && -d "$TEST_NPM_CACHE" ]]; then
61
- rm -rf "$TEST_NPM_CACHE"
62
- fi
63
- if [[ -n "$ARTIFACT_NPM_PREFIX" && -d "$ARTIFACT_NPM_PREFIX" ]]; then
64
- rm -rf "$ARTIFACT_NPM_PREFIX"
65
- fi
66
- if [[ -n "$ARTIFACT_NPM_CACHE" && -d "$ARTIFACT_NPM_CACHE" ]]; then
67
- rm -rf "$ARTIFACT_NPM_CACHE"
68
- fi
69
- if [[ -n "$ARTIFACT_TOOL_VENT_DIR" && -d "$ARTIFACT_TOOL_VENT_DIR" ]]; then
70
- rm -rf "$ARTIFACT_TOOL_VENT_DIR"
71
- fi
72
- if [[ -n "$TARBALL_CHECK_DIR" && -d "$TARBALL_CHECK_DIR" ]]; then
73
- rm -rf "$TARBALL_CHECK_DIR"
74
- fi
75
- if [[ -n "$TARBALL_PATH" && -f "$TARBALL_PATH" ]]; then
76
- rm -f "$TARBALL_PATH"
77
- fi
78
- fi
79
- }
80
- trap cleanup EXIT
81
-
82
118
  echo "== npm pack"
83
- TARBALL="$(npm pack --silent | tail -n 1)"
119
+ TARBALL="$(npm --cache "$CONTROL_NPM_CACHE" pack --silent | tail -n 1)"
84
120
  TARBALL_PATH="$ROOT_DIR/$TARBALL"
85
121
  echo "Tarball: $TARBALL_PATH"
86
122
 
87
123
  TARBALL_CHECK_DIR="$(mktemp -d /tmp/pi-agent-vent-tarball-check-XXXXXX)"
124
+ TARBALL_NPM_CACHE="$(mktemp -d /tmp/pi-agent-vent-tarball-npm-cache-XXXXXX)"
88
125
  echo "== unpacked tarball package contract"
89
126
  tar -xzf "$TARBALL_PATH" -C "$TARBALL_CHECK_DIR"
90
127
  (
91
128
  cd "$TARBALL_CHECK_DIR/package"
92
- npm install --ignore-scripts --no-audit --fund=false
129
+ # This isolated artifact probe intentionally selects the exact host contract above.
130
+ # Ordinary installs retain the workstation's npm release-age policy.
131
+ release_npm_install "$TARBALL_NPM_CACHE" -
93
132
  npm run check
94
133
  )
95
134
 
@@ -107,8 +146,8 @@ case "$ARTIFACT_PACKAGE_ROOT" in
107
146
  esac
108
147
 
109
148
  echo "== npm installed artifact shadow registered-tool smoke (no Pi auth)"
110
- npm --prefix "$ARTIFACT_NPM_PREFIX" --cache "$ARTIFACT_NPM_CACHE" \
111
- install --global --ignore-scripts --no-audit --fund=false "$TARBALL_PATH"
149
+ release_npm_install "$ARTIFACT_NPM_CACHE" "$ARTIFACT_NPM_PREFIX" \
150
+ --prefix "$ARTIFACT_NPM_PREFIX" --global "$TARBALL_PATH"
112
151
  node ./scripts/release-smoke-check.mjs assert-installed-artifact \
113
152
  --package-root "$ARTIFACT_PACKAGE_ROOT" \
114
153
  --package-name "$NAME" \
@@ -124,15 +163,19 @@ else
124
163
  echo "pi CLI not found in PATH." >&2
125
164
  exit 1
126
165
  fi
166
+ INSTALLED_PI_VERSION="$(pi --version)"
167
+ node ./scripts/release-smoke-check.mjs assert-exact-host-contract \
168
+ --package-json package.json \
169
+ --host-version "$INSTALLED_PI_VERSION"
127
170
  if [[ ! -f "$HOME/.pi/agent/auth.json" ]]; then
128
171
  echo "Missing $HOME/.pi/agent/auth.json (needed for isolated pi smoke tests)." >&2
129
172
  echo "Tip: set SKIP_PI_SMOKE=1 for artifact-only checks." >&2
130
173
  exit 1
131
174
  fi
132
175
 
133
- TEST_AGENT_DIR="$(mktemp -d /tmp/pi-extension-release-check-XXXXXX)"
134
- TEST_NPM_PREFIX="$(mktemp -d /tmp/pi-extension-release-npm-prefix-XXXXXX)"
135
- TEST_NPM_CACHE="$(mktemp -d /tmp/pi-extension-release-npm-cache-XXXXXX)"
176
+ TEST_AGENT_DIR="$(mktemp -d /tmp/pi-agent-vent-pi-agent-dir-XXXXXX)"
177
+ TEST_NPM_PREFIX="$(mktemp -d /tmp/pi-agent-vent-pi-npm-prefix-XXXXXX)"
178
+ TEST_NPM_CACHE="$(mktemp -d /tmp/pi-agent-vent-pi-npm-cache-XXXXXX)"
136
179
 
137
180
  cp "$HOME/.pi/agent/auth.json" "$TEST_AGENT_DIR/auth.json"
138
181
 
@@ -153,8 +196,8 @@ JSON
153
196
 
154
197
  echo "== pi install tarball (isolated PI_CODING_AGENT_DIR and npm prefix)"
155
198
  PACKAGE_SPEC="npm:$TARBALL_PATH"
156
- NPM_CONFIG_PREFIX="$TEST_NPM_PREFIX" NPM_CONFIG_CACHE="$TEST_NPM_CACHE" \
157
- PI_CODING_AGENT_DIR="$TEST_AGENT_DIR" pi install "$PACKAGE_SPEC"
199
+ PI_CODING_AGENT_DIR="$TEST_AGENT_DIR" \
200
+ with_release_npm_policy "$TEST_NPM_CACHE" "$TEST_NPM_PREFIX" pi install "$PACKAGE_SPEC"
158
201
 
159
202
  echo "== verify tarball package recorded in settings"
160
203
  TEST_AGENT_DIR="$TEST_AGENT_DIR" PACKAGE_SPEC="$PACKAGE_SPEC" node <<'NODE'
@@ -178,14 +221,16 @@ NODE
178
221
 
179
222
  if [[ -x "./scripts/release-smoke.sh" ]]; then
180
223
  echo "== extension-specific smoke checks (scripts/release-smoke.sh)"
224
+ PI_INSTALLED_PACKAGE_ROOT="$TEST_AGENT_DIR/npm/node_modules/$NAME"
181
225
  NPM_CONFIG_PREFIX="$TEST_NPM_PREFIX" NPM_CONFIG_CACHE="$TEST_NPM_CACHE" \
182
- PI_CODING_AGENT_DIR="$TEST_AGENT_DIR" PACKAGE_SPEC="$PACKAGE_SPEC" bash ./scripts/release-smoke.sh
226
+ PI_CODING_AGENT_DIR="$TEST_AGENT_DIR" PACKAGE_SPEC="$PACKAGE_SPEC" \
227
+ INSTALLED_PACKAGE_ROOT="$PI_INSTALLED_PACKAGE_ROOT" bash ./scripts/release-smoke.sh
183
228
  fi
184
229
  fi
185
230
 
186
231
  echo "== npm view ${NAME} version (pre-publish may be 404)"
187
232
  set +e
188
- npm view "$NAME" version --json --registry https://registry.npmjs.org/
233
+ npm --cache "$CONTROL_NPM_CACHE" view "$NAME" version --json --registry https://registry.npmjs.org/
189
234
  VIEW_EXIT=$?
190
235
  set -e
191
236
  echo "npm view exit: $VIEW_EXIT"
@@ -5,6 +5,22 @@ import { pathToFileURL } from "node:url";
5
5
 
6
6
  export const readJsonFile = (filePath) => JSON.parse(fs.readFileSync(filePath, "utf8"));
7
7
 
8
+ export const assertExactHostContract = ({ packageJson, hostVersion }) => {
9
+ const piAi = packageJson?.devDependencies?.["@earendil-works/pi-ai"];
10
+ const codingAgent = packageJson?.devDependencies?.["@earendil-works/pi-coding-agent"];
11
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(hostVersion || "")) {
12
+ throw new Error(
13
+ `Release smoke requires an exact Pi host version, got: ${hostVersion || "(empty)"}`,
14
+ );
15
+ }
16
+ if (piAi !== hostVersion || codingAgent !== hostVersion) {
17
+ throw new Error(
18
+ `Release smoke host contract mismatch: pi=${hostVersion}, pi-ai=${piAi || "(missing)"}, pi-coding-agent=${codingAgent || "(missing)"}`,
19
+ );
20
+ }
21
+ return hostVersion;
22
+ };
23
+
8
24
  export const packageSourcesFromSettings = (settings) => {
9
25
  const packages = Array.isArray(settings?.packages) ? settings.packages : [];
10
26
  return packages
@@ -292,6 +308,15 @@ const readArgValue = (args, name) => {
292
308
  const runCli = async () => {
293
309
  const [command, ...args] = process.argv.slice(2);
294
310
 
311
+ if (command === "assert-exact-host-contract") {
312
+ const packageJsonPath = readArgValue(args, "--package-json");
313
+ const hostVersion = readArgValue(args, "--host-version");
314
+ if (!packageJsonPath) throw new Error("--package-json is required");
315
+ assertExactHostContract({ packageJson: readJsonFile(packageJsonPath), hostVersion });
316
+ console.log(`Exact Pi host contract selected: ${hostVersion}.`);
317
+ return;
318
+ }
319
+
295
320
  if (command === "assert-settings") {
296
321
  const settingsPath = readArgValue(args, "--settings");
297
322
  const packageSpec = readArgValue(args, "--package-spec");
@@ -353,7 +378,7 @@ const runCli = async () => {
353
378
  }
354
379
 
355
380
  throw new Error(
356
- "Usage: node ./scripts/release-smoke-check.mjs <assert-settings|assert-local-tarball-install-source|assert-installed-artifact|prepare-local-path-artifact-settings|assert-command-output|assert-installed-tool-path> ...",
381
+ "Usage: node ./scripts/release-smoke-check.mjs <assert-exact-host-contract|assert-settings|assert-local-tarball-install-source|assert-installed-artifact|prepare-local-path-artifact-settings|assert-command-output|assert-installed-tool-path> ...",
357
382
  );
358
383
  };
359
384
 
@@ -14,6 +14,11 @@ if [[ -z "${PI_CODING_AGENT_DIR:-}" ]]; then
14
14
  exit 1
15
15
  fi
16
16
 
17
+ if [[ -z "${INSTALLED_PACKAGE_ROOT:-}" ]]; then
18
+ echo "INSTALLED_PACKAGE_ROOT is required; the caller must provide Pi's isolated installed-artifact path." >&2
19
+ exit 1
20
+ fi
21
+
17
22
  if ! command -v pi >/dev/null 2>&1; then
18
23
  echo "pi CLI not found in PATH." >&2
19
24
  exit 1
@@ -38,23 +43,15 @@ SMOKE_TOOL_VENT_DIR="$SMOKE_DIR/agent-vent-tool-store"
38
43
  SMOKE_LOCAL_PATH_OUTPUT="$SMOKE_DIR/agent-vent-local-path.out"
39
44
  PACKAGE_NAME="$(node -p "JSON.parse(require('node:fs').readFileSync('package.json', 'utf8')).name")"
40
45
  PACKAGE_VERSION="$(node -p "JSON.parse(require('node:fs').readFileSync('package.json', 'utf8')).version")"
41
- if [[ -n "${NPM_CONFIG_PREFIX:-}" ]]; then
42
- GLOBAL_NODE_MODULES="$(npm --prefix "$NPM_CONFIG_PREFIX" root -g)"
43
- else
44
- GLOBAL_NODE_MODULES="$(npm root -g)"
45
- fi
46
- INSTALLED_PACKAGE_ROOT="$GLOBAL_NODE_MODULES/$PACKAGE_NAME"
47
46
  INSTALLED_EXTENSION_PATH="$INSTALLED_PACKAGE_ROOT/extensions/agent-vent.ts"
48
47
 
49
- if [[ -n "${NPM_CONFIG_PREFIX:-}" ]]; then
50
- case "$INSTALLED_PACKAGE_ROOT" in
51
- "$NPM_CONFIG_PREFIX"/*) ;;
52
- *)
53
- echo "Installed package root escaped isolated npm prefix: $INSTALLED_PACKAGE_ROOT" >&2
54
- exit 1
55
- ;;
56
- esac
57
- fi
48
+ case "$(realpath -m "$INSTALLED_PACKAGE_ROOT")" in
49
+ "$(realpath "$PI_CODING_AGENT_DIR")"/*) ;;
50
+ *)
51
+ echo "Installed package root escaped isolated Pi agent directory: $INSTALLED_PACKAGE_ROOT" >&2
52
+ exit 1
53
+ ;;
54
+ esac
58
55
 
59
56
  node ./scripts/release-smoke-check.mjs assert-settings \
60
57
  --settings "$PI_CODING_AGENT_DIR/settings.json" \
@@ -92,7 +92,7 @@ function validatePackageJson() {
92
92
  }
93
93
  }
94
94
 
95
- const requiredPeers = ["@mariozechner/pi-coding-agent", "@mariozechner/pi-ai"];
95
+ const requiredPeers = ["@earendil-works/pi-coding-agent", "@earendil-works/pi-ai"];
96
96
  for (const peer of requiredPeers) {
97
97
  if (typeof p.peerDependencies?.[peer] !== "string") {
98
98
  fail(`package.json peerDependencies must include ${peer}`);
@@ -129,8 +129,8 @@ function validatePackageJson() {
129
129
  fail("package.json publishConfig.access must be 'public'");
130
130
  }
131
131
 
132
- if (p.engines?.node !== ">=22") {
133
- fail("package.json engines.node must be '>=22'");
132
+ if (p.engines?.node !== ">=22.19.0") {
133
+ fail("package.json engines.node must be '>=22.19.0'");
134
134
  }
135
135
 
136
136
  const expectedWorkspacePath = readCopierAnswer("workspace_relative_path");
@@ -33,8 +33,8 @@ required_files=(
33
33
  "scripts/validate-structure.sh"
34
34
  "scripts/validate-structure.mjs"
35
35
  "scripts/quality-gate.sh"
36
- "prompts/implementation-planning.md"
37
- "prompts/security-review.md"
36
+ "prompts/pi-agent-vent-implementation-planning.md"
37
+ "prompts/pi-agent-vent-security-review.md"
38
38
  )
39
39
 
40
40
  required_dirs=(
package/src/vent-store.js CHANGED
@@ -22,6 +22,18 @@ export const CATEGORIES = [
22
22
  "workflow",
23
23
  "other",
24
24
  ];
25
+ export const CATEGORY_ALIASES = Object.freeze({
26
+ workflow_friction: "workflow",
27
+ operator_friction: "workflow",
28
+ process_friction: "workflow",
29
+ missing_affordance: "missing_capability",
30
+ missing_feature: "missing_capability",
31
+ documentation_gap: "documentation",
32
+ docs_gap: "documentation",
33
+ context_window: "context_loss",
34
+ context_friction: "context_loss",
35
+ tooling_friction: "friction",
36
+ });
25
37
  export const SEVERITIES = ["low", "medium", "high", "critical"];
26
38
  export const REVIEW_STATES = ["new", "acknowledged", "dismissed", "escalation_drafted"];
27
39
  export const CURATION_ACTIONS = ["merge", "rename", "remove"];
@@ -71,12 +83,22 @@ export function defaultBackupDir(env = process.env) {
71
83
  return path.join(defaultStoreDir(env), BACKUP_DIR_NAME);
72
84
  }
73
85
 
74
- export function normalizeCategory(value) {
75
- const normalized = String(value || "other")
86
+ export function normalizeCategoryToken(value) {
87
+ return String(value || "")
76
88
  .trim()
77
89
  .toLowerCase()
78
90
  .replaceAll("-", "_");
79
- return CATEGORIES.includes(normalized) ? normalized : "other";
91
+ }
92
+
93
+ export function resolveCategoryFilter(value) {
94
+ const normalized = normalizeCategoryToken(value);
95
+ if (!normalized) return undefined;
96
+ const candidate = CATEGORY_ALIASES[normalized] || normalized;
97
+ return CATEGORIES.includes(candidate) ? candidate : undefined;
98
+ }
99
+
100
+ export function normalizeCategory(value) {
101
+ return resolveCategoryFilter(value || "other") || "other";
80
102
  }
81
103
 
82
104
  export function normalizeSeverity(value) {
@@ -227,6 +249,56 @@ function collectRedactionMetadata(fields) {
227
249
  return { redacted, redactionPatterns: [...redactionPatterns].sort() };
228
250
  }
229
251
 
252
+ function hasDiagnosticAnchor(input = {}) {
253
+ return Boolean(
254
+ compactText(input?.frustration, 1200) ||
255
+ compactText(input?.evidence, 1600) ||
256
+ compactText(input?.expected, 800) ||
257
+ compactText(input?.actual, 800) ||
258
+ compactText(input?.reproduction, 1200) ||
259
+ compactText(input?.tool || input?.toolName, 160) ||
260
+ compactText(input?.packageName, 200) ||
261
+ compactText(input?.recurrenceKey, 200) ||
262
+ (Array.isArray(input?.tags) && input.tags.some((tag) => compactText(tag, 120))),
263
+ );
264
+ }
265
+
266
+ export function assessVentRecordQuality(input = {}, record = undefined) {
267
+ const summary = compactText(input?.summary ?? record?.summary, 600) || "";
268
+ const normalizedSummary = summary.toLowerCase();
269
+ const wordCount = normalizedSummary.split(/\s+/).filter(Boolean).length;
270
+ const anchored = hasDiagnosticAnchor(input);
271
+ const issues = [];
272
+ const warnings = [];
273
+
274
+ if (
275
+ /^(done|ok|okay|fixed|test|testing|progress|update|note|misc|bad|ugh)$/.test(normalizedSummary)
276
+ ) {
277
+ issues.push("summary is too generic for a diagnostic record");
278
+ }
279
+ if (summary.length < 12 && !anchored) {
280
+ issues.push("summary is too short without a diagnostic anchor");
281
+ }
282
+ if (wordCount < 4 && !anchored) {
283
+ warnings.push(
284
+ "add evidence, expected/actual, tool, package, tag, or recurrenceKey before recording",
285
+ );
286
+ }
287
+ if (normalizeCategory(input?.category ?? record?.category) === "other" && !anchored) {
288
+ warnings.push(
289
+ "category defaults to other; add a specific category or local diagnostic facet if known",
290
+ );
291
+ }
292
+
293
+ return {
294
+ recordable: issues.length === 0,
295
+ issues,
296
+ warnings,
297
+ boundary:
298
+ "Quality check is local anti-junk guidance only; it does not create AK evidence, tasks, issues, incidents, telemetry, owner routing, or ASC/self state.",
299
+ };
300
+ }
301
+
230
302
  export function createVentRecord(input, context = {}) {
231
303
  const summary = sanitizeOptionalText(input?.summary, 600);
232
304
  if (!summary.value) {
@@ -2588,13 +2660,13 @@ function assertNoEmptyReviewFilterValues(input = {}) {
2588
2660
 
2589
2661
  function normalizeReviewFilterCategory(value) {
2590
2662
  if (value === undefined) return undefined;
2591
- const normalized = String(value).trim().toLowerCase().replaceAll("-", "_");
2592
- if (!CATEGORIES.includes(normalized)) {
2663
+ const category = resolveCategoryFilter(value);
2664
+ if (!category) {
2593
2665
  throw new Error(
2594
2666
  `invalid agent_vent review filter category: ${value}; expected one of ${CATEGORIES.join(", ")}`,
2595
2667
  );
2596
2668
  }
2597
- return normalized;
2669
+ return category;
2598
2670
  }
2599
2671
 
2600
2672
  function sanitizeReviewFilterTags(value) {
@@ -3,15 +3,17 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import test from "node:test";
6
- import agentVentExtension from "../extensions/agent-vent.ts";
6
+ import agentVentExtension, { _test } from "../extensions/agent-vent.ts";
7
7
  import { createCurationEvent, createVentRecord, readReviewEvents } from "../src/vent-store.js";
8
8
 
9
9
  function createMockPi() {
10
10
  const tools = new Map();
11
11
  const commands = new Map();
12
+ const messages = [];
12
13
  return {
13
14
  tools,
14
15
  commands,
16
+ messages,
15
17
  api: {
16
18
  registerTool(tool) {
17
19
  tools.set(tool.name, tool);
@@ -19,6 +21,9 @@ function createMockPi() {
19
21
  registerCommand(name, command) {
20
22
  commands.set(name, command);
21
23
  },
24
+ sendMessage(message) {
25
+ messages.push(message);
26
+ },
22
27
  },
23
28
  };
24
29
  }
@@ -39,6 +44,8 @@ test("agent_vent tool schema stays aligned with retention candidate and compare
39
44
  const schemaText = JSON.stringify(pi.tools.get("agent_vent").parameters);
40
45
 
41
46
  assert.match(schemaText, /"compare"/);
47
+ assert.match(schemaText, /"preview"/);
48
+ assert.match(schemaText, /"workflow_friction"/);
42
49
  assert.match(schemaText, /"candidates"/);
43
50
  assert.match(schemaText, /"history"/);
44
51
  assert.match(schemaText, /retentionCandidateState/);
@@ -48,6 +55,41 @@ test("agent_vent tool schema stays aligned with retention candidate and compare
48
55
  assert.match(schemaText, /outcomes, compare, export, or retention planning/);
49
56
  });
50
57
 
58
+ test("agent_vent keeps long command output persistent while short statuses stay notifications", async () => {
59
+ const pi = createMockPi();
60
+ agentVentExtension(pi.api);
61
+ const notifications = [];
62
+ const ctx = { hasUI: true, ui: { notify: (message) => notifications.push(message) } };
63
+
64
+ await pi.commands.get("agent_vent").handler("help", ctx);
65
+ assert.equal(pi.messages.length, 1);
66
+ assert.equal(pi.messages[0].customType, "agent-vent-command");
67
+ assert.match(pi.messages[0].content, /agent_vent commands/);
68
+ assert.match(notifications[0], /added to the session transcript/);
69
+
70
+ await pi.commands.get("agent_vent").handler("unknown", ctx);
71
+ assert.equal(pi.messages.length, 1);
72
+ assert.match(notifications[1], /Unknown \/agent_vent action/);
73
+ assert.equal(_test.isLongCommandOutput("short"), false);
74
+ });
75
+
76
+ test("agent_vent cancellation fails before expensive store work", async () => {
77
+ const pi = createMockPi();
78
+ agentVentExtension(pi.api);
79
+ const controller = new AbortController();
80
+ controller.abort();
81
+ await assert.rejects(
82
+ () =>
83
+ pi.tools
84
+ .get("agent_vent")
85
+ .execute("cancelled", { action: "summary" }, controller.signal, undefined, {
86
+ cwd: "/repo",
87
+ sessionManager: { getSessionFile: () => undefined },
88
+ }),
89
+ /cancelled/,
90
+ );
91
+ });
92
+
51
93
  test("agent_vent records minimized local diagnostics without external authority claims", async () => {
52
94
  const pi = createMockPi();
53
95
  agentVentExtension(pi.api);
@@ -474,6 +516,129 @@ test("agent_vent records minimized local diagnostics without external authority
474
516
  }
475
517
  });
476
518
 
519
+ test("agent_vent accepts category aliases for tool records and command filters", async () => {
520
+ const pi = createMockPi();
521
+ agentVentExtension(pi.api);
522
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-vent-alias-"));
523
+ const oldDir = process.env.PI_AGENT_VENT_DIR;
524
+ const oldLog = console.log;
525
+ const messages = [];
526
+ process.env.PI_AGENT_VENT_DIR = dir;
527
+ console.log = (message) => messages.push(String(message));
528
+ try {
529
+ const tool = pi.tools.get("agent_vent");
530
+ const result = await tool.execute(
531
+ "tool-call-category-alias",
532
+ {
533
+ action: "record",
534
+ summary: "Workflow friction alias should normalize",
535
+ category: "workflow_friction",
536
+ severity: "medium",
537
+ },
538
+ undefined,
539
+ undefined,
540
+ {
541
+ cwd: "/repo",
542
+ sessionManager: { getSessionFile: () => undefined },
543
+ },
544
+ );
545
+
546
+ assert.equal(result.details.record.category, "workflow");
547
+ assert.equal(
548
+ result.details.record.recurrenceKey,
549
+ "workflow:workflow-friction-alias-should-normalize",
550
+ );
551
+
552
+ const reviewResult = await tool.execute(
553
+ "tool-call-category-alias-review",
554
+ { action: "review", category: "workflow_friction" },
555
+ undefined,
556
+ undefined,
557
+ {
558
+ cwd: "/repo",
559
+ sessionManager: { getSessionFile: () => undefined },
560
+ },
561
+ );
562
+ assert.equal(reviewResult.details.reviewQueue.filters.category, "workflow");
563
+ assert.equal(reviewResult.details.reviewQueue.matchingGroupCount, 1);
564
+
565
+ await pi.commands.get("agent_vent").handler("review category=workflow_friction", {
566
+ hasUI: false,
567
+ });
568
+ assert.match(messages[0], /Filters: category=workflow/);
569
+ assert.doesNotMatch(messages[0], /category=workflow_friction/);
570
+ } finally {
571
+ console.log = oldLog;
572
+ if (oldDir === undefined) delete process.env.PI_AGENT_VENT_DIR;
573
+ else process.env.PI_AGENT_VENT_DIR = oldDir;
574
+ fs.rmSync(dir, { recursive: true, force: true });
575
+ }
576
+ });
577
+
578
+ test("agent_vent preview runs anti-junk checks without writing records", async () => {
579
+ const pi = createMockPi();
580
+ agentVentExtension(pi.api);
581
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-vent-preview-"));
582
+ const oldDir = process.env.PI_AGENT_VENT_DIR;
583
+ process.env.PI_AGENT_VENT_DIR = dir;
584
+ try {
585
+ const tool = pi.tools.get("agent_vent");
586
+ const preview = await tool.execute(
587
+ "tool-call-preview",
588
+ {
589
+ action: "preview",
590
+ summary: "Workflow friction alias should normalize before recording",
591
+ category: "workflow_friction",
592
+ tool: "self",
593
+ },
594
+ undefined,
595
+ undefined,
596
+ {
597
+ cwd: "/repo",
598
+ sessionManager: { getSessionFile: () => undefined },
599
+ },
600
+ );
601
+
602
+ assert.equal(preview.details.wouldRecord, true);
603
+ assert.equal(preview.details.recordPreview.category, "workflow");
604
+ assert.match(preview.content[0].text, /No local diagnostic record was written/);
605
+ assert.equal(fs.existsSync(path.join(dir, "vents.jsonl")), false);
606
+
607
+ const junkPreview = await tool.execute(
608
+ "tool-call-preview-junk",
609
+ { action: "preview", summary: "done" },
610
+ undefined,
611
+ undefined,
612
+ {
613
+ cwd: "/repo",
614
+ sessionManager: { getSessionFile: () => undefined },
615
+ },
616
+ );
617
+ assert.equal(junkPreview.details.wouldRecord, false);
618
+ assert.match(junkPreview.content[0].text, /not recordable/);
619
+
620
+ await assert.rejects(
621
+ () =>
622
+ tool.execute(
623
+ "tool-call-record-junk",
624
+ { action: "record", summary: "done" },
625
+ undefined,
626
+ undefined,
627
+ {
628
+ cwd: "/repo",
629
+ sessionManager: { getSessionFile: () => undefined },
630
+ },
631
+ ),
632
+ /anti-junk quality check/,
633
+ );
634
+ assert.equal(fs.existsSync(path.join(dir, "vents.jsonl")), false);
635
+ } finally {
636
+ if (oldDir === undefined) delete process.env.PI_AGENT_VENT_DIR;
637
+ else process.env.PI_AGENT_VENT_DIR = oldDir;
638
+ fs.rmSync(dir, { recursive: true, force: true });
639
+ }
640
+ });
641
+
477
642
  test("agent_vent command rejects unknown review filter keys without creating stores", async () => {
478
643
  const pi = createMockPi();
479
644
  agentVentExtension(pi.api);
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import test from "node:test";
6
6
  import {
7
7
  assertAgentVentPathSmokeOutput,
8
+ assertExactHostContract,
8
9
  assertInstalledArtifactPackage,
9
10
  assertLocalTarballInstallSource,
10
11
  assertPackageSpecInstalled,
@@ -13,6 +14,24 @@ import {
13
14
  packageSourcesFromSettings,
14
15
  } from "../scripts/release-smoke-check.mjs";
15
16
 
17
+ test("release smoke release-age bypass is gated by one exact host contract", () => {
18
+ const packageJson = {
19
+ devDependencies: {
20
+ "@earendil-works/pi-ai": "0.80.6",
21
+ "@earendil-works/pi-coding-agent": "0.80.6",
22
+ },
23
+ };
24
+ assert.equal(assertExactHostContract({ packageJson, hostVersion: "0.80.6" }), "0.80.6");
25
+ assert.throws(
26
+ () => assertExactHostContract({ packageJson, hostVersion: "^0.80.6" }),
27
+ /requires an exact Pi host version/,
28
+ );
29
+ assert.throws(
30
+ () => assertExactHostContract({ packageJson, hostVersion: "0.80.7" }),
31
+ /host contract mismatch/,
32
+ );
33
+ });
34
+
16
35
  test("release smoke settings check accepts only unfiltered string package entries", () => {
17
36
  const settings = {
18
37
  packages: [
@@ -111,6 +111,30 @@ test("createVentRecord minimizes, redacts, and derives recurrence key", () => {
111
111
  assert.deepEqual(tagOnlySecret.privacy.redactionPatterns, ["assigned_secret"]);
112
112
  });
113
113
 
114
+ test("category aliases normalize at record and review-filter boundaries", () => {
115
+ const workflow = createVentRecord({
116
+ summary: "Workflow alias should not degrade",
117
+ category: "workflow_friction",
118
+ recurrenceKey: "workflow alias",
119
+ });
120
+ const missing = createVentRecord({
121
+ summary: "Missing affordance alias should preserve meaning",
122
+ category: "missing_affordance",
123
+ });
124
+
125
+ assert.equal(workflow.category, "workflow");
126
+ assert.equal(workflow.recurrenceKey, "workflow:workflow-alias");
127
+ assert.equal(missing.category, "missing_capability");
128
+ assert.match(missing.recurrenceKey, /^missing_capability:/);
129
+
130
+ const queue = summarizeReviewQueue([workflow, missing], [], {
131
+ filters: { category: "workflow_friction" },
132
+ });
133
+ assert.equal(queue.filters.category, "workflow");
134
+ assert.equal(queue.matchingGroupCount, 1);
135
+ assert.equal(queue.items[0].recurrenceKey, workflow.recurrenceKey);
136
+ });
137
+
114
138
  test("explicit recurrence keys are redacted before slugging", () => {
115
139
  const record = createVentRecord({
116
140
  summary: "Explicit key should not leak",