specpi 0.28.0 → 0.30.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.
@@ -18,8 +18,9 @@ import { SYSTEM_NAMES } from "./config.mjs";
18
18
  * Decide what a layer switch means, and report it honestly.
19
19
  *
20
20
  * `state` is `{ settings }` and is never mutated -- the next state comes back in the result. `deps`
21
- * supplies the outside world, which is now only `keySources`: the command guard used to need a
22
- * package's global configuration file arbitrated here, and as the eighth system it needs nothing.
21
+ * supplies the outside world, which is only `keySources`. The command guard is not arbitrated here
22
+ * and is not arbitrated anywhere in this extension: it is a separate package with its own switch,
23
+ * and nothing SpecPi does at session time touches it.
23
24
  *
24
25
  * Scope belongs to `layerScopeLine`, not here, so this takes `{ on }` and nothing else. It used to
25
26
  * be handed `sessionOnly` and `interactive` as well and read neither, which reads as a decision
@@ -39,34 +40,16 @@ export function applyLayer({ on }, state, deps) {
39
40
  return { settings, lines: onLines(state.settings, settings, active) };
40
41
  }
41
42
 
42
- /**
43
- * What arming the command guard means, in the words every path that arms it has to use.
44
- *
45
- * Seven of the eight systems only ever add advice; this one can refuse a tool call. Saying so is a
46
- * rule rather than a nicety, and it lives here because it was a rule `/jev on` honoured alone while
47
- * `/jev enable guard` and `/jev startup on` armed the same system in silence.
48
- */
49
- export function guardWarning() {
50
- return (
51
- "guard is the only system that can refuse a tool call: it blocks a call it reads as " +
52
- "destructive and not what was asked for, asks you about the uncertain ones, and hands " +
53
- "everything else to the permission system unchanged. Turn it off with /jev disable guard."
54
- );
55
- }
56
-
57
43
  /**
58
44
  * Enabling the layer enables its systems, because a layer with none on runs and does nothing -- the
59
- * state people kept arriving at, with the notification cheerfully reporting "0 of 8".
45
+ * state people kept arriving at, with the notification cheerfully reporting "0 of 7".
60
46
  *
61
47
  * Only when none are on. Someone deliberately running retention alone has expressed a preference,
62
- * and `/jev off` then `/jev on` must not hand back the seven they turned off.
48
+ * and `/jev off` then `/jev on` must not hand back the six they turned off.
63
49
  *
64
- * The command guard is one of the eight, and it is the only system that can refuse a tool call. That
65
- * is a deliberate answer to a question this file and `config.mjs` resolve differently on purpose:
66
- * `/jev on` is a person acting now, so it arms everything and `onLines` says in as many words that
67
- * one of them can refuse a command; `migrateToThree` runs without anyone present, so it arms
68
- * nothing new. Silence is the difference -- an unattended migration must not change what a session
69
- * is allowed to run, and an explicit command that reports what it did may.
50
+ * Every system it arms only ever adds advice, which is what makes arming all of them a reasonable
51
+ * default and why this switch needs no warning attached. Nothing the layer can turn on is able to
52
+ * refuse a tool call; the one component that can is a separate package with a separate switch.
70
53
  */
71
54
  export function enableSystems(settings) {
72
55
  const chosen = SYSTEM_NAMES.filter((name) => settings.systems[name]);
@@ -86,12 +69,6 @@ function onLines(before, after, activeSource) {
86
69
  lines.push("No system was enabled, so all of them were. Turn any back off with /jev disable <system>.");
87
70
  }
88
71
 
89
- // Said out loud every time, because seven of the eight only ever add advice and this one can
90
- // take a command away. Nobody should discover that from a blocked call.
91
- if (after.systems.guard) {
92
- lines.push(guardWarning());
93
- }
94
-
95
72
  lines.push(keyLine(activeSource));
96
73
 
97
74
  return lines;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specpi",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "Scope control and a human-selected harness improvement loop for Pi",
5
5
  "author": "Tanner Middleton",
6
6
  "repository": {
@@ -28,6 +28,7 @@
28
28
  "scripts/lib.mjs",
29
29
  "scripts/lock.mjs",
30
30
  "scripts/packages.mjs",
31
+ "scripts/jev-guard.mjs",
31
32
  "templates",
32
33
  "extensions",
33
34
  "skills",
@@ -0,0 +1,154 @@
1
+ // Install-time seam for specpi-jev-guard, the Jev-scored command gate.
2
+ //
3
+ // The guard is pinned in the base set but ships **inert**. Its own `DEFAULT_SETTINGS.enabled` is
4
+ // `true`, so leaving it alone would mean a fresh SpecPi install started gating shell and file calls
5
+ // through a third-party service on day one. SpecPi writes `enabled: false` instead.
6
+ //
7
+ // That is the whole of SpecPi's involvement, and the file lives under `scripts/` to say so. The
8
+ // guard is its own thing: it ships its own `/jev-guard setup | on | off [--global] | check | model
9
+ // | backend`, keeps its own configuration, resolves its own key, and is not part of the Jev layer.
10
+ // The advisor imports nothing from here, `/jev` does not mention it, and no switch in SpecPi turns
11
+ // it on. One command does -- the package's own.
12
+ //
13
+ // Be precise about what this cannot do. The guard is fail-closed by design: with no key, an
14
+ // unreachable endpoint, or an answer it cannot parse, the call does not go through, and in a
15
+ // session with no UI its `uncertain` default blocks the middle band too. There is no setting that
16
+ // hands the decision back to @gotgenes/pi-permission-system instead. So the honest posture is:
17
+ //
18
+ // - off (what SpecPi installs) -> the guard is not in the tool path at all, and the permission
19
+ // system decides every call exactly as it did before this package existed;
20
+ // - on -> the guard decides first, asks a human in the middle band when there is a UI, and
21
+ // blocks when it cannot reach Jev. An outage stops gated work until it is switched off.
22
+ //
23
+ // That trade is the user's to make, which is why SpecPi only ever writes the off side of it.
24
+ //
25
+ // ONE KEY, and not one SpecPi supplies. Since 0.3.0 the package reads Pi's saved login first and
26
+ // the environment second -- the same order the Jev advisor uses for the same provider entry -- so
27
+ // `/login openrouter` serves both without either knowing about the other.
28
+ //
29
+ // `auditDisplay` is deliberately not asserted below, although SpecPi Chat renders the counter that
30
+ // setting controls. It defaults to `status`, which is what publishes the line, and it is the user's
31
+ // to change: pinning it here would take a display preference away from them to guarantee a readout
32
+ // in one frontend. A session with it set to `off` simply shows no counter, which is what they
33
+ // asked for.
34
+
35
+ import fs from "node:fs";
36
+ import os from "node:os";
37
+ import path from "node:path";
38
+ import { agentDirectory, regularFile, writeFileAtomic } from "../extensions/jev-advisor/config.mjs";
39
+
40
+ export const GUARD_PACKAGE = "specpi-jev-guard";
41
+
42
+ /** Must match the pin in templates/settings.json. */
43
+ export const GUARD_PIN = "npm:specpi-jev-guard@0.4.0";
44
+
45
+ // The guard reads `<homedir>/.pi/jev-guard.json` globally, and a project copy under `<cwd>/.pi/`
46
+ // when the project is trusted. SpecPi writes only the global file: a project-local override is the
47
+ // user's to make, and writing one would put a security setting inside whatever repository happened
48
+ // to be open at the time.
49
+ const CONFIG_DIR_NAME = ".pi";
50
+ const SETTINGS_FILE = "jev-guard.json";
51
+
52
+ function guardRoot() {
53
+ return path.join(agentDirectory(), "npm", "node_modules", GUARD_PACKAGE);
54
+ }
55
+
56
+ function guardConfigFile() {
57
+ return path.join(os.homedir(), CONFIG_DIR_NAME, SETTINGS_FILE);
58
+ }
59
+
60
+ export function installed() {
61
+ try {
62
+ const manifest = path.join(guardRoot(), "package.json");
63
+ if (!fs.existsSync(manifest)) {
64
+ return { installed: false };
65
+ }
66
+
67
+ const parsed = JSON.parse(fs.readFileSync(manifest, "utf8"));
68
+ if (parsed?.name !== GUARD_PACKAGE) {
69
+ return { installed: false };
70
+ }
71
+
72
+ return { installed: true, version: typeof parsed.version === "string" ? parsed.version : "unknown" };
73
+ } catch {
74
+ return { installed: false };
75
+ }
76
+ }
77
+
78
+ /**
79
+ * The fields SpecPi owns, in the guard's own schema. Anything absent here keeps the package's
80
+ * default, so its thresholds, command lists, protected paths and model settings stay its business.
81
+ *
82
+ * There is no `enabled: true` form of this, deliberately. SpecPi has no command that arms the
83
+ * guard, so a function that could produce an armed configuration would have no caller and one
84
+ * obvious wrong use.
85
+ */
86
+ export function desiredConfig() {
87
+ return {
88
+ // Off means the guard never enters the tool path, so no key is needed and nothing is sent.
89
+ enabled: false,
90
+ // Left at the guard's own default, and asserted so the two halves of the Jev story resolve
91
+ // the same credential: one `/login openrouter` serves the advisor and the guard.
92
+ backend: "openrouter",
93
+ // With a UI, a middle-band verdict asks rather than deciding on its own. Without one the
94
+ // guard fails closed; that is the package's design, disclosed rather than configured away.
95
+ uncertain: "ask",
96
+ };
97
+ }
98
+
99
+ export function readConfig() {
100
+ try {
101
+ const file = guardConfigFile();
102
+ if (!regularFile(file, "Jev guard settings")) {
103
+ return undefined;
104
+ }
105
+
106
+ return JSON.parse(fs.readFileSync(file, "utf8"));
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Write the inert posture, merged into whatever is already there.
114
+ *
115
+ * Merged, not replaced: a user's own thresholds, safe-command globs and protected paths survive,
116
+ * and only the three fields above are asserted. Idempotent, and it reports what it changed so the
117
+ * installer can say so.
118
+ *
119
+ * It runs on install and on update, and it always asserts `enabled: false`. Not only when the file
120
+ * is absent, which is the version of this that looks safer and is not: an install carrying a stale
121
+ * `enabled: true` from before this package was last unpinned would arm a fail-closed gate the
122
+ * moment the package came back, with nobody present to be told. So the rule is the blunt one --
123
+ * SpecPi never leaves an armed gate behind an installer run -- and `disarmed` is returned so the
124
+ * run can report the one case where that took something away from someone.
125
+ *
126
+ * This is an occasional, human-initiated act, which is what makes the blunt rule affordable. It
127
+ * deliberately does not run at session start: the package owns its switch between installs, and a
128
+ * per-session rewrite would mean `/jev-guard on --global` never survived a restart.
129
+ */
130
+ export function applyInertConfig() {
131
+ if (!installed().installed) {
132
+ return { applied: false, reason: "not-installed" };
133
+ }
134
+
135
+ const desired = desiredConfig();
136
+ const current = readConfig();
137
+ if (current && Object.entries(desired).every(([key, value]) => current[key] === value)) {
138
+ return { applied: false, reason: "already-current" };
139
+ }
140
+
141
+ writeFileAtomic(guardConfigFile(), `${JSON.stringify({ ...(current ?? {}), ...desired }, null, 4)}\n`);
142
+
143
+ return {
144
+ applied: true,
145
+ reason: current ? "updated" : "created",
146
+ // The one change worth announcing: everything else here is establishing a default, and this
147
+ // is switching off a gate a human had switched on.
148
+ disarmed: current?.enabled === true,
149
+ };
150
+ }
151
+
152
+ export function configPath() {
153
+ return guardConfigFile();
154
+ }
@@ -48,54 +48,6 @@ export function runBrowserQA(agentDir, command) {
48
48
  }
49
49
  }
50
50
 
51
- /**
52
- * Packages a past SpecPi version pinned and this one no longer does.
53
- *
54
- * Dropping an entry from `templates/settings.json` stops new installs getting it and does nothing at
55
- * all to a machine that already has it: the entry stays in `settings.json` and Pi keeps loading it.
56
- * That is tolerable for a package that merely stopped being useful, and not tolerable for
57
- * `specpi-jev-guard`, which fails closed -- an install left holding it after SpecPi deleted both the
58
- * code that kept it inert and the `/jev guard off` command that could disarm it would block every
59
- * shell call the moment its key or its endpoint went away, with nothing left to turn it off.
60
- *
61
- * So retirement is explicit, and it removes the entry rather than waiting for the restore path to.
62
- */
63
- export const retiredPackages = Object.freeze(["npm:specpi-jev-guard"]);
64
-
65
- /**
66
- * Drop retired entries from a settings object, in place, returning what was removed.
67
- *
68
- * Every entry goes, whatever shape it has. That is a deliberate exception to the ownership rule the
69
- * restore path applies elsewhere -- "a user-modified entry is theirs" -- and the exception is the
70
- * reason the list is not open to additions. Preserving a modified `specpi-jev-guard` entry preserved
71
- * a fail-closed gate that this release removed the controls for: the code that rewrote its global
72
- * configuration to `enabled: false` at every session start is gone, and so is `/jev guard off`, so a
73
- * preserved entry is a gate that refuses every shell call the moment its key or endpoint goes away,
74
- * with nothing left to turn it off. Filters are worth less than that.
75
- *
76
- * The removed entry is returned verbatim so the caller can print it back, and the write it belongs
77
- * to is inside the installer's transaction and backed up with everything else. Version is not
78
- * consulted: the reason for retirement is the package.
79
- */
80
- export function removeRetiredPackages(settings) {
81
- if (!Array.isArray(settings?.packages)) {
82
- return [];
83
- }
84
-
85
- const removed = [];
86
- settings.packages = settings.packages.filter((entry) => {
87
- if (!retiredPackages.includes(packageIdentity(entry))) {
88
- return true;
89
- }
90
-
91
- removed.push(packageSource(entry));
92
-
93
- return false;
94
- });
95
-
96
- return removed;
97
- }
98
-
99
51
  export function packageChanges(before, after) {
100
52
  return basePackages.map((source) => {
101
53
  const identity = packageIdentity(source);
@@ -182,14 +134,6 @@ export function installBasePackages(agentDir) {
182
134
 
183
135
  export function checkBasePackages(agentDir, settings) {
184
136
  const errors = [];
185
- for (const entry of Array.isArray(settings.packages) ? settings.packages : []) {
186
- if (retiredPackages.includes(packageIdentity(entry))) {
187
- errors.push(
188
- `Retired base package still configured: ${packageSource(entry)}. Run specpi update to unpin it.`,
189
- );
190
- }
191
- }
192
-
193
137
  for (const source of basePackages) {
194
138
  const identity = packageIdentity(source);
195
139
  const entry =
@@ -22,14 +22,8 @@ import {
22
22
  import { validateCapabilityRegistry } from "../extensions/tool-wishlist/registry.mjs";
23
23
  import { runValidator } from "../extensions/tool-wishlist/validators.mjs";
24
24
  import { acquireSpecPiLock } from "./lock.mjs";
25
- import {
26
- basePackages,
27
- checkBasePackages,
28
- installBasePackages,
29
- packageChanges,
30
- removeRetiredPackages,
31
- runBrowserQA,
32
- } from "./packages.mjs";
25
+ import { basePackages, checkBasePackages, installBasePackages, packageChanges, runBrowserQA } from "./packages.mjs";
26
+ import { applyInertConfig as applyGuardConfig, configPath as guardConfigPath } from "./jev-guard.mjs";
33
27
 
34
28
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
35
29
  const VERSION = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")).version;
@@ -63,12 +57,9 @@ const resourcePaths = [
63
57
  "extensions/jev-advisor/client.mjs",
64
58
  "extensions/jev-advisor/key-source.mjs",
65
59
  "extensions/jev-advisor/layer.mjs",
66
- "extensions/jev-advisor/risk.mjs",
67
- "extensions/jev-advisor/questions/guard.mjs",
68
60
  "extensions/jev-advisor/broker.mjs",
69
61
  "extensions/jev-advisor/gate.mjs",
70
62
  "extensions/jev-advisor/questions/retention.mjs",
71
- "extensions/jev-advisor/questions/compaction.mjs",
72
63
  "extensions/jev-advisor/questions/gap.mjs",
73
64
  "extensions/jev-advisor/questions/sources.mjs",
74
65
  "extensions/jev-advisor/questions/progress.mjs",
@@ -87,7 +78,7 @@ Usage:
87
78
  specpi doctor
88
79
  specpi uninstall [--yes]
89
80
 
90
- Installs /scope, the harness improvement loop, and seven pinned packages.
81
+ Installs /scope, the harness improvement loop, and eight pinned packages.
91
82
  The base is tested with Pi 0.84.4. Run specpi plan to see package versions.
92
83
  --skip-package-install installs only the core, or preserves an existing base on update.
93
84
  --skip-browser-install skips Chromium setup, not package acquisition or doctor checks.
@@ -260,32 +251,6 @@ function restoreLegacySettings(manifest, warnings) {
260
251
  writeJson(settingsPath, settings, existingMode(settingsPath, 0o600));
261
252
  }
262
253
 
263
- /**
264
- * Unpin a package a past version installed and this one has retired.
265
- *
266
- * Deliberately outside the `--skip-packages` guard. Skipping package acquisition means not
267
- * downloading or re-pinning anything, and it has never meant leaving an entry SpecPi itself wrote
268
- * pointing at code SpecPi has since removed the controls for -- `specpi-jev-guard` is fail-closed,
269
- * so the machine that skipped packages is exactly the machine that would keep it armed forever.
270
- *
271
- * `settingsPath` is in the transaction's watched set for every non-uninstall operation, so this
272
- * write is snapshotted, backed up and rolled back with everything else.
273
- */
274
- function retireBasePackages(warnings) {
275
- const settings = readJson(settingsPath, {});
276
- const removed = removeRetiredPackages(settings);
277
- if (removed.length === 0) {
278
- return;
279
- }
280
-
281
- writeJson(settingsPath, settings, existingMode(settingsPath, 0o600));
282
- for (const source of removed) {
283
- warnings.push(
284
- `Unpinned retired package: ${source}. Its downloaded files stay in the agent npm directory, Pi no longer loads it, and any settings of your own on that entry are in this run's backup.`,
285
- );
286
- }
287
- }
288
-
289
254
  function removeLegacyShell(manifest) {
290
255
  if (manifest?.shellRc && fs.existsSync(manifest.shellRc)) {
291
256
  const result = removeManagedBlock(fs.readFileSync(manifest.shellRc, "utf8"), SHELL_START, SHELL_END);
@@ -340,16 +305,7 @@ async function mutate(options, operation) {
340
305
  ...files.map(([, target]) => target),
341
306
  ...Object.keys(previous?.files || {}),
342
307
  ];
343
- // Watched whenever anything in this run can write it. `retireBasePackages` runs on every
344
- // non-uninstall operation, including under `--skip-package-install`, so the narrower
345
- // condition that used to guard this left that write outside the snapshot -- unbacked up, and
346
- // not rolled back by a later failure in the same transaction.
347
- if (
348
- operation !== "uninstall" ||
349
- !options.skipPackages ||
350
- previous?.settingsChanges?.length ||
351
- previous?.packageChanges?.length
352
- ) {
308
+ if (!options.skipPackages || previous?.settingsChanges?.length || previous?.packageChanges?.length) {
353
309
  watched.push(settingsPath);
354
310
  }
355
311
 
@@ -370,10 +326,6 @@ async function mutate(options, operation) {
370
326
  const warnings = [];
371
327
  const preserveBase = operation !== "uninstall" && options.skipPackages && previous?.basePackages?.length;
372
328
  restoreLegacySettings(preserveBase ? { ...previous, packageChanges: [] } : previous, warnings);
373
- if (operation !== "uninstall") {
374
- retireBasePackages(warnings);
375
- }
376
-
377
329
  removeLegacyShell(previous);
378
330
  let packageState = preserveBase
379
331
  ? {
@@ -402,6 +354,26 @@ async function mutate(options, operation) {
402
354
  runBrowserQA(agentDir, "setup");
403
355
  }
404
356
 
357
+ // specpi-jev-guard's own default is enabled:true, so a freshly installed base would
358
+ // start gating shell and file calls through a third-party service before anyone asked
359
+ // for it — and with no key it fails closed, which means a first install that refuses to
360
+ // run commands. This is the only place SpecPi touches that package's configuration:
361
+ // between installer runs the guard's own /jev-guard commands own it, so the write does
362
+ // not repeat at session start and a saved `--global` choice survives every restart.
363
+ try {
364
+ const guard = applyGuardConfig();
365
+ if (guard.disarmed) {
366
+ // The one case where establishing the default takes something away. An
367
+ // installer run that silently switched off a gate the user had switched on is
368
+ // exactly the kind of quiet security change this file refuses to make.
369
+ warnings.push(
370
+ `Switched the Jev command guard off in ${guardConfigPath()}. SpecPi installs it inert and asserts that on every install and update; run /jev-guard on --global in Pi to turn it back on.`,
371
+ );
372
+ }
373
+ } catch (error) {
374
+ console.log(`SpecPi: could not write the Jev guard's inert settings: ${error.message}`);
375
+ }
376
+
405
377
  packageState = {
406
378
  basePackages,
407
379
  packagesKeyBeforeExists: Object.hasOwn(before, "packages"),
@@ -6,6 +6,7 @@
6
6
  "npm:specpi-experiments@0.1.0",
7
7
  "npm:pi-goal-x@0.31.2",
8
8
  "npm:@sreetej510/pi-usage@0.10.0",
9
- "npm:@gotgenes/pi-permission-system@32.0.2"
9
+ "npm:@gotgenes/pi-permission-system@32.0.2",
10
+ "npm:specpi-jev-guard@0.4.0"
10
11
  ]
11
12
  }
@@ -1,153 +0,0 @@
1
- // System 1b: guide compaction, which is the one moment the prompt cache is discarded anyway.
2
- //
3
- // Pi's `findCutPoint` is documented as "walk backwards from newest, accumulating estimated message
4
- // sizes, stop when we've accumulated >= keepRecentTokens". It is a token ruler: it cannot tell the
5
- // load-bearing finding from six dead-end greps, and it discards whichever falls on the wrong side
6
- // of the line. Since the prefix is being rebuilt regardless, improving that choice costs nothing.
7
- //
8
- // This system never sets the cut itself. It supplies `customInstructions` — an existing documented
9
- // parameter on the compaction path — so the summariser is told what this session was actually
10
- // about. The token budget still bounds the result, so bad advice can shape a summary, never blow
11
- // the budget or drop an entry the preparation meant to keep.
12
-
13
- import { choice, noul } from "../client.mjs";
14
- import { choiceValue, nounTrue } from "../gate.mjs";
15
- import { compact } from "../sanitize.mjs";
16
-
17
- export const WORK_KINDS = Object.freeze({
18
- debugging: "Tracking down why something fails",
19
- building: "Adding or changing a feature",
20
- refactoring: "Restructuring code without changing behaviour",
21
- research: "Reading and answering questions about a codebase",
22
- testing: "Writing or repairing tests",
23
- ops: "Builds, releases, configuration or tooling",
24
- review: "Reading a diff and judging it",
25
- });
26
-
27
- /**
28
- * A digest of what compaction is about to discard: entry kinds and scale, never their text. The
29
- * summariser still sees the real conversation; this only steers what it keeps.
30
- */
31
- export function buildInput({ preparation, objective }) {
32
- const messages = preparation?.messagesToSummarize ?? [];
33
- const kinds = {};
34
- for (const message of messages) {
35
- const role = typeof message?.role === "string" ? message.role : "unknown";
36
- kinds[role] = (kinds[role] ?? 0) + 1;
37
- }
38
-
39
- const files = preparation?.fileOps ?? {};
40
-
41
- return {
42
- objective: compact(objective ?? "", 180),
43
- discarding: messages.length,
44
- roles: kinds,
45
- tokensBefore: preparation?.tokensBefore ?? 0,
46
- splitTurn: preparation?.isSplitTurn === true,
47
- filesRead: [...(files.read ?? [])].slice(0, 12).map((item) => compact(item, 60)),
48
- filesWritten: [...(files.written ?? []), ...(files.edited ?? [])].slice(0, 12).map((item) => compact(item, 60)),
49
- hadPreviousSummary: typeof preparation?.previousSummary === "string",
50
- };
51
- }
52
-
53
- /**
54
- * The same digest for a branch being left behind. `/tree` hands a different preparation shape --
55
- * session entries rather than messages, and no token count, because nothing is being cut to fit a
56
- * budget -- so it gets its own builder rather than a compaction input with three fields quietly
57
- * reading undefined.
58
- */
59
- export function buildBranchInput({ preparation, objective }) {
60
- const entries = preparation?.entriesToSummarize ?? [];
61
- const kinds = {};
62
- for (const entry of entries) {
63
- const kind = typeof entry?.type === "string" ? entry.type : "unknown";
64
- kinds[kind] = (kinds[kind] ?? 0) + 1;
65
- }
66
-
67
- return {
68
- objective: compact(objective ?? "", 180),
69
- abandoning: entries.length,
70
- kinds,
71
- wantsSummary: preparation?.userWantsSummary === true,
72
- // Navigating to an ancestor is backing out of a line of work; navigating elsewhere is
73
- // moving between siblings. The distinction is most of what a label has to capture.
74
- toAncestor: preparation?.targetId === preparation?.commonAncestorId,
75
- };
76
- }
77
-
78
- /**
79
- * Short, navigational, and a fixed enum so no model-written text reaches the session file. `/tree`
80
- * can filter to labelled entries, so a branch that says what it was is the difference between a
81
- * navigable tree and a list of timestamps.
82
- */
83
- export const BRANCH_LABELS = Object.freeze({
84
- "dead end": "The branch was abandoned because the approach did not work",
85
- "alternative tried": "A different approach to the same goal, set aside for another",
86
- "work completed": "The branch finished what it set out to do",
87
- research: "The branch was reading and answering questions, not changing anything",
88
- reverted: "The branch's changes were undone",
89
- interrupted: "The branch stopped part-way for an unrelated reason",
90
- });
91
-
92
- export function questions({ branch = false } = {}) {
93
- return {
94
- ...(branch ? { branch_label: choice("What was this abandoned branch?", BRANCH_LABELS) } : {}),
95
- work_kind: choice("What kind of work has this session mostly been doing?", WORK_KINDS),
96
- unresolved_thread: noul("There is an unfinished investigation whose findings must survive compaction"),
97
- discarded_span_was_dead_ends: noul(
98
- "The work being discarded was mostly abandoned attempts that led nowhere useful",
99
- ),
100
- };
101
- }
102
-
103
- const FOCUS = Object.freeze({
104
- debugging: "the symptom, what has been ruled out, and the current hypothesis",
105
- building: "what has been implemented so far and what remains",
106
- refactoring: "the invariants being preserved and which call sites have been updated",
107
- research: "the questions answered so far, with the files each answer came from",
108
- testing: "which tests exist, which fail, and why",
109
- ops: "the commands run, their outcomes, and the current configuration state",
110
- review: "the findings raised so far and their severity",
111
- });
112
-
113
- /**
114
- * Build `customInstructions` from gated answers only. With nothing gated this returns undefined and
115
- * Pi's own default prompt is used unchanged.
116
- */
117
- /**
118
- * The label for a branch summary entry, or undefined when the answer is ungated. Separate from
119
- * `decide` because the compaction hook has no label to set and would carry a dead field.
120
- */
121
- export function label(answers) {
122
- const value = choiceValue(answers?.branch_label, "compaction");
123
-
124
- return value && Object.hasOwn(BRANCH_LABELS, value) ? value : undefined;
125
- }
126
-
127
- export function decide(answers) {
128
- const kind = choiceValue(answers?.work_kind, "compaction");
129
- const unresolved = nounTrue(answers?.unresolved_thread, "compaction");
130
- const deadEnds = nounTrue(answers?.discarded_span_was_dead_ends, "compaction");
131
- const parts = [];
132
- if (kind && FOCUS[kind]) {
133
- parts.push(`This session has mainly been ${kind}. Prioritise ${FOCUS[kind]}.`);
134
- }
135
-
136
- if (unresolved) {
137
- parts.push(
138
- "An investigation is still open. Preserve its findings and the current hypothesis in full, even at the cost of earlier detail.",
139
- );
140
- }
141
-
142
- if (deadEnds) {
143
- parts.push(
144
- "Most of the discarded work was abandoned attempts. Record what was ruled out in one line each rather than recounting them, so the same paths are not retried.",
145
- );
146
- }
147
-
148
- if (parts.length === 0) {
149
- return { customInstructions: undefined, deadEnds, unresolved };
150
- }
151
-
152
- return { customInstructions: parts.join(" "), deadEnds, unresolved };
153
- }