@jwilger/pi-development-system 0.44.0 → 0.45.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/implementer.md +1 -1
- package/extensions/development-system.ts +2 -1
- package/package.json +6 -2
- package/principles/NON-NEGOTIABLES.md +10 -0
- package/src/context/model-advice.ts +1 -1
- package/src/context/status.ts +1 -1
- package/src/context/system-prompt.ts +1 -1
- package/src/context/turn-verifier.ts +3 -3
- package/src/core/departure.ts +5 -1
- package/src/core/exhaustive.ts +4 -0
- package/src/core/gates.ts +1 -1
- package/src/core/git-intent.ts +37 -24
- package/src/core/git-invocations.ts +1 -1
- package/src/core/model-advice.ts +1 -1
- package/src/core/profile.ts +2 -2
- package/src/core/review-flow.ts +6 -2
- package/src/core/review.ts +2 -2
- package/src/core/test-weakening.ts +1 -0
- package/src/core/types.ts +0 -6
- package/src/gates/approvals.ts +1 -1
- package/src/gates/git-guard.ts +32 -20
- package/src/gates/lint-suppression-guard.ts +3 -2
- package/src/gates/red-first-guard.ts +1 -1
- package/src/gates/test-guard.ts +13 -9
- package/src/jev/questions/shell-intent.ts +1 -1
- package/src/review/digest.ts +50 -42
- package/src/review/review-tools.ts +55 -21
- package/src/state/ci-command.ts +2 -1
- package/src/state/config.ts +9 -8
- package/src/state/models-command.ts +4 -7
- package/src/state/session-state.ts +38 -32
- package/src/subagents/orch/mailbox.ts +2 -2
- package/src/subagents/prefs/agent-import.ts +3 -3
- package/src/subagents/prefs/settings.ts +4 -4
- package/src/subagents/types.ts +2 -2
- package/src/subagents/ui/dialog.ts +3 -3
- package/src/subagents/ui/import-picker.ts +1 -1
- package/src/subagents/ui/model-picker.ts +1 -1
- package/src/subagents/ui/status-ui.ts +2 -3
- package/src/subagents/ui/ui.ts +5 -5
package/agents/implementer.md
CHANGED
|
@@ -37,7 +37,7 @@ You are an implementer. You receive exactly one task record and nothing else: do
|
|
|
37
37
|
|
|
38
38
|
End with evidence the coordinator can check, one block per command you ran:
|
|
39
39
|
|
|
40
|
-
```
|
|
40
|
+
```text
|
|
41
41
|
Run: <exact command>
|
|
42
42
|
Expected: <what the task said>
|
|
43
43
|
Actual: <what you saw, quoted>
|
|
@@ -148,8 +148,9 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
|
148
148
|
|
|
149
149
|
pi.registerCommand("devsys-status", {
|
|
150
150
|
description: "Show development-system phase, sizing, slice, departures and Jev status",
|
|
151
|
-
handler:
|
|
151
|
+
handler: (_args, ctx) => {
|
|
152
152
|
ctx.ui.notify(renderStatus(state.get(), jevModel), "info");
|
|
153
|
+
return Promise.resolve();
|
|
153
154
|
},
|
|
154
155
|
});
|
|
155
156
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jwilger/pi-development-system",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.1",
|
|
4
4
|
"description": "A pi extension package representing a seasoned approach to software development using a full AI SDLC.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -51,6 +51,8 @@
|
|
|
51
51
|
"@types/node": "^26.6.4",
|
|
52
52
|
"@types/shell-quote": "^1.7.5",
|
|
53
53
|
"@typesafe-ai/sdk": "^0.6.0",
|
|
54
|
+
"knip": "^6.40.0",
|
|
55
|
+
"markdownlint-cli2": "^0.23.3",
|
|
54
56
|
"typescript": "^7.0.2"
|
|
55
57
|
},
|
|
56
58
|
"scripts": {
|
|
@@ -61,6 +63,8 @@
|
|
|
61
63
|
"lint": "biome check .",
|
|
62
64
|
"lint:fix": "biome check --write .",
|
|
63
65
|
"check:build-gate": "node scripts/build-gate.ts",
|
|
64
|
-
"check:version": "node scripts/check-version.ts"
|
|
66
|
+
"check:version": "node scripts/check-version.ts",
|
|
67
|
+
"lint:knip": "knip",
|
|
68
|
+
"lint:md": "markdownlint-cli2"
|
|
65
69
|
}
|
|
66
70
|
}
|
|
@@ -5,31 +5,41 @@ Departing from any item below requires an explicit decision from the author
|
|
|
5
5
|
departure is warranted: stop, say which item and why, and ask.
|
|
6
6
|
|
|
7
7
|
## 1. Never rewrite pushed history or force-push
|
|
8
|
+
|
|
8
9
|
Amend/rebase/reset of pushed refs, `--force*`, deleting shared branches. Because it destroys other people's basis for trust.
|
|
9
10
|
|
|
10
11
|
## 2. Never weaken verification to make a gate pass
|
|
12
|
+
|
|
11
13
|
Deleting, skipping or loosening tests; suppressing a lint; editing expected values to match output; `--no-verify`. Because a green gate must mean the same thing afterwards. (Changing a test because the *requirement* changed is a recorded departure, not this.)
|
|
12
14
|
|
|
13
15
|
## 3. Never claim something was verified, run, or green that was not
|
|
16
|
+
|
|
14
17
|
Because the record of evidence is the product. Evidence comes before claims.
|
|
15
18
|
|
|
16
19
|
## 4. Never push on a red trunk
|
|
20
|
+
|
|
17
21
|
Except a change whose purpose is fixing that failure. Because unrelated work on red hides the signal.
|
|
18
22
|
|
|
19
23
|
## 5. Never work around an unresolved gate
|
|
24
|
+
|
|
20
25
|
Stop, record, ask. Because a gate you can route around is not a gate.
|
|
21
26
|
|
|
22
27
|
## 6. Never violate repo-local delivery policy
|
|
28
|
+
|
|
23
29
|
Delivery mode, protected branches, required reviews. Because the repo, not this tool, owns policy.
|
|
24
30
|
|
|
25
31
|
## 7. Never let secrets leave the machine without sanitisation
|
|
32
|
+
|
|
26
33
|
In commits, logs, eval cases, or subagent prompts. Because exfiltration is irreversible.
|
|
27
34
|
|
|
28
35
|
## 8. Never commit without a rationale-bearing Conventional Commit, or add AI attribution trailers
|
|
36
|
+
|
|
29
37
|
No `Co-Authored-By`, no `Generated-by`. Because the message is the durable why; attribution noise is not.
|
|
30
38
|
|
|
31
39
|
## 9. Never make an architecture-shaping decision without an ADR
|
|
40
|
+
|
|
32
41
|
New dependency class, boundary change, persistence model, public contract. Because "Revisit when" is how decisions stay honest.
|
|
33
42
|
|
|
34
43
|
## 10. Never ship model-visible instructions without an evidence check
|
|
44
|
+
|
|
35
45
|
Skills, prompts, Jev questions need a fixture or eval. Because effectiveness is eval-driven, not vibes.
|
|
@@ -56,6 +56,6 @@ export function registerModelAdvice(deps: ModelAdviceDeps): void {
|
|
|
56
56
|
deps.state.onChange((state) => {
|
|
57
57
|
if (state.phase === phase) return;
|
|
58
58
|
phase = state.phase;
|
|
59
|
-
if (last !== undefined)
|
|
59
|
+
if (last !== undefined) check(last).catch(() => undefined);
|
|
60
60
|
});
|
|
61
61
|
}
|
package/src/context/status.ts
CHANGED
|
@@ -13,7 +13,7 @@ const ciLabel = (state: DevsysState): string | undefined => {
|
|
|
13
13
|
return `ci ${ci.status}${sha}`;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
const activeReview = (state: DevsysState): string | undefined => {
|
|
17
17
|
// Without an active slice (before /devsys-start exists) show the most recent review instead of nothing.
|
|
18
18
|
const review =
|
|
19
19
|
state.activeSlice === undefined ? state.reviews?.at(-1) : reviewOf(state, state.activeSlice);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { BeforeAgentStartEvent } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { DevsysState } from "../core/types.ts";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
const PROMPT_SECTION_NAME = "development-system";
|
|
5
5
|
|
|
6
6
|
/** Pure: renders the system-prompt section from current state. */
|
|
7
7
|
export function buildPromptSection(state: DevsysState, nonNegotiables: string): string {
|
|
@@ -7,9 +7,9 @@ import type { Jev } from "../jev/client.ts";
|
|
|
7
7
|
import { judgeTurn, type ToolEvidence } from "../jev/questions/turn.ts";
|
|
8
8
|
import type { SessionState } from "../state/session-state.ts";
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
const VERIFIER_THRESHOLD = 0.75;
|
|
11
11
|
export const DEFAULT_VERIFIER_MAX = 6;
|
|
12
|
-
|
|
12
|
+
const VERIFIER_ENTRY_TYPE = "devsys-verifier";
|
|
13
13
|
const VERIFIED_PHASES: ReadonlySet<Phase> = new Set(["implementing", "reviewing", "delivering"]);
|
|
14
14
|
const MAX_EVIDENCE = 60;
|
|
15
15
|
|
|
@@ -23,7 +23,7 @@ export type TurnVerifierDeps = {
|
|
|
23
23
|
type Parts = { text: string; callsTools: boolean };
|
|
24
24
|
|
|
25
25
|
/** Text and tool-call presence of an assistant message; undefined for any other role. */
|
|
26
|
-
|
|
26
|
+
function assistantParts(message: AgentMessage): Parts | undefined {
|
|
27
27
|
if (message.role !== "assistant") return undefined;
|
|
28
28
|
const text = message.content.flatMap((c) => (c.type === "text" ? [c.text] : [])).join("\n");
|
|
29
29
|
return { text, callsTools: message.content.some((c) => c.type === "toolCall") };
|
package/src/core/departure.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertNever } from "./exhaustive.ts";
|
|
1
2
|
import {
|
|
2
3
|
type Departure,
|
|
3
4
|
type DepartureId,
|
|
@@ -69,6 +70,8 @@ function renderScope(scope: DepartureScope): string {
|
|
|
69
70
|
return "session";
|
|
70
71
|
case "once":
|
|
71
72
|
return `once (${scope.toolCallId})`;
|
|
73
|
+
default:
|
|
74
|
+
return assertNever(scope);
|
|
72
75
|
}
|
|
73
76
|
}
|
|
74
77
|
|
|
@@ -76,7 +79,8 @@ function renderScope(scope: DepartureScope): string {
|
|
|
76
79
|
export function renderDepartureMarkdown(d: Departure): string {
|
|
77
80
|
const revisit = d.revisitWhen !== undefined ? ` · **Revisit when:** ${d.revisitWhen}` : "";
|
|
78
81
|
return [
|
|
79
|
-
|
|
82
|
+
`## ${d.recordedAt} · ${d.gate} · ${d.tier} · ${d.approver}`,
|
|
83
|
+
"",
|
|
80
84
|
`- **Default:** ${d.default}`,
|
|
81
85
|
`- **Chosen:** ${d.chosen}`,
|
|
82
86
|
`- **Why:** ${d.why}`,
|
package/src/core/gates.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { Tier } from "./types.ts";
|
|
|
3
3
|
export type GateInfo = { readonly tier: Tier; readonly default: string };
|
|
4
4
|
|
|
5
5
|
/** Registered gate ids (plan Appendix A); grows per increment. Qualified ids use their base entry. */
|
|
6
|
-
|
|
6
|
+
const GATES: Readonly<Record<string, GateInfo>> = {
|
|
7
7
|
"git.history-rewrite": { tier: "hard", default: "never rewrite published history" },
|
|
8
8
|
"git.force-push": { tier: "hard", default: "never force-push" },
|
|
9
9
|
"git.branch-delete-remote": { tier: "hard", default: "never delete remote branches" },
|
package/src/core/git-intent.ts
CHANGED
|
@@ -24,6 +24,7 @@ const worst = (a: GitIntent, b: GitIntent): GitIntent =>
|
|
|
24
24
|
SEVERITY.indexOf(a) <= SEVERITY.indexOf(b) ? a : b;
|
|
25
25
|
|
|
26
26
|
/** Splits a command string on newlines that are outside quotes; backslash-newline is a continuation. */
|
|
27
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: a shell lexer is one flat branch per character class; splitting it would scatter a single state machine across helpers
|
|
27
28
|
export function splitLines(command: string): string[] {
|
|
28
29
|
const lines: string[] = [];
|
|
29
30
|
let current = "";
|
|
@@ -63,7 +64,7 @@ export function segments(line: string): string[][] {
|
|
|
63
64
|
const result: string[][] = [[]];
|
|
64
65
|
let skipOperand = false;
|
|
65
66
|
for (const token of parseShell(line, (name) => `$${name}`)) {
|
|
66
|
-
const current = result
|
|
67
|
+
const current = result.at(-1);
|
|
67
68
|
if (typeof token === "string" || "pattern" in token) {
|
|
68
69
|
const text = typeof token === "string" ? token : token.pattern;
|
|
69
70
|
if (!skipOperand) current?.push(text);
|
|
@@ -179,26 +180,50 @@ function classifyGitArgs(globals: string[], sub: string | undefined, args: strin
|
|
|
179
180
|
}
|
|
180
181
|
}
|
|
181
182
|
|
|
182
|
-
|
|
183
|
+
/** Skips leading `NAME=value` words, wrappers and shell keywords; `index` is the command word (-1: none). */
|
|
184
|
+
function skipPrefix(tokens: string[]): { index: number; hookSkip: boolean } {
|
|
183
185
|
let hookSkip = false;
|
|
184
|
-
let
|
|
186
|
+
let index = 0;
|
|
185
187
|
for (;;) {
|
|
186
|
-
const t = tokens[
|
|
187
|
-
if (t === undefined) return
|
|
188
|
+
const t = tokens[index];
|
|
189
|
+
if (t === undefined) return { index: -1, hookSkip };
|
|
188
190
|
if (isAssignment(t)) {
|
|
189
191
|
if (HOOK_SKIP_ENV.has(t)) hookSkip = true;
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
192
|
+
} else if (!(WRAPPERS.has(t) || KEYWORDS.has(t))) {
|
|
193
|
+
return { index, hookSkip };
|
|
194
|
+
}
|
|
195
|
+
index++;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Git's global options (`-c k=v`, `-C dir`, flags) before the subcommand. */
|
|
200
|
+
function splitGlobals(rest: string[]): {
|
|
201
|
+
globals: string[];
|
|
202
|
+
sub: string | undefined;
|
|
203
|
+
args: string[];
|
|
204
|
+
} {
|
|
205
|
+
const globals: string[] = [];
|
|
206
|
+
let j = 0;
|
|
207
|
+
while (j < rest.length && (rest[j] ?? "").startsWith("-")) {
|
|
208
|
+
const opt = rest[j] ?? "";
|
|
209
|
+
if (GLOBAL_WITH_VALUE.has(opt)) {
|
|
210
|
+
globals.push(rest[j + 1] ?? "");
|
|
211
|
+
j += 2;
|
|
193
212
|
} else {
|
|
194
|
-
|
|
213
|
+
j++;
|
|
195
214
|
}
|
|
196
215
|
}
|
|
216
|
+
return { globals, sub: rest[j], args: rest.slice(j + 1) };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function classifySegment(tokens: string[]): GitIntent {
|
|
220
|
+
const { index: i, hookSkip } = skipPrefix(tokens);
|
|
221
|
+
if (i < 0) return "ordinary";
|
|
197
222
|
const head = tokens[i] ?? "";
|
|
198
223
|
const rest = tokens.slice(i + 1);
|
|
199
224
|
const base = basename(head);
|
|
200
|
-
const withHookSkip = (
|
|
201
|
-
hookSkip ? worst(
|
|
225
|
+
const withHookSkip = (found: GitIntent): GitIntent =>
|
|
226
|
+
hookSkip ? worst(found, "no-verify") : found;
|
|
202
227
|
if (head.startsWith("$") || base === "$GIT") return "unknown";
|
|
203
228
|
if (SHELLS.has(base)) {
|
|
204
229
|
const c = rest.findIndex((t) => /^-[A-Za-z]*c[A-Za-z]*$/.test(t));
|
|
@@ -213,19 +238,7 @@ function classifySegment(tokens: string[]): GitIntent {
|
|
|
213
238
|
const at = rest.findIndex((t) => basename(t) === "git");
|
|
214
239
|
return at >= 0 ? withHookSkip(classifySegment(rest.slice(at))) : "ordinary";
|
|
215
240
|
}
|
|
216
|
-
const globals
|
|
217
|
-
let j = 0;
|
|
218
|
-
while (j < rest.length && (rest[j] ?? "").startsWith("-")) {
|
|
219
|
-
const opt = rest[j] ?? "";
|
|
220
|
-
if (GLOBAL_WITH_VALUE.has(opt)) {
|
|
221
|
-
globals.push(rest[j + 1] ?? "");
|
|
222
|
-
j += 2;
|
|
223
|
-
} else {
|
|
224
|
-
j++;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
const sub = rest[j];
|
|
228
|
-
const args = rest.slice(j + 1);
|
|
241
|
+
const { globals, sub, args } = splitGlobals(rest);
|
|
229
242
|
const dynamic =
|
|
230
243
|
(sub ?? "").startsWith("$") ||
|
|
231
244
|
(["push", "reset", "rebase"].includes(sub ?? "") &&
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
} from "./git-intent.ts";
|
|
16
16
|
import { stripHeredocs } from "./heredoc.ts";
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
type GitInvocation = {
|
|
19
19
|
readonly sub: string;
|
|
20
20
|
readonly args: readonly string[];
|
|
21
21
|
/** Directory the command runs in, relative to the starting one (from `cd` and `git -C`), when it moved. */
|
package/src/core/model-advice.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
import type { Phase } from "./types.ts";
|
|
10
10
|
|
|
11
11
|
/** Highest first. */
|
|
12
|
-
|
|
12
|
+
const TIERS = ["frontier", "strong", "fast"] as const;
|
|
13
13
|
export type Tier = (typeof TIERS)[number];
|
|
14
14
|
|
|
15
15
|
const PHASE_SLOT: Partial<Record<Phase, Slot>> = {
|
package/src/core/profile.ts
CHANGED
|
@@ -4,14 +4,14 @@ import { isParseError, type ParseError, type Profile, parseError } from "./types
|
|
|
4
4
|
|
|
5
5
|
export type { Profile };
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
const PROFILES: readonly Profile[] = ["rust", "typescript"];
|
|
8
8
|
|
|
9
9
|
const MARKERS: ReadonlyArray<{ profile: Profile; files: readonly string[] }> = [
|
|
10
10
|
{ profile: "rust", files: ["Cargo.toml"] },
|
|
11
11
|
{ profile: "typescript", files: ["package.json", "tsconfig.json"] },
|
|
12
12
|
];
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
const isProfile = (value: string): value is Profile => PROFILES.some((p) => p === value);
|
|
15
15
|
|
|
16
16
|
/** Boundary parse for configured profile names. */
|
|
17
17
|
export function parseProfiles(names: readonly string[]): Profile[] | ParseError {
|
package/src/core/review-flow.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertNever } from "./exhaustive.ts";
|
|
1
2
|
import {
|
|
2
3
|
cleanStreak,
|
|
3
4
|
type Finding,
|
|
@@ -47,7 +48,8 @@ export function reviewGap(state: DevsysState, slice: SliceRef, now: DiffNow): st
|
|
|
47
48
|
if (review === undefined || review.rounds.length === 0) {
|
|
48
49
|
return `no review has been recorded for slice ${slice}`;
|
|
49
50
|
}
|
|
50
|
-
|
|
51
|
+
const action = nextAction(review, now.digest);
|
|
52
|
+
switch (action) {
|
|
51
53
|
case "done":
|
|
52
54
|
return undefined;
|
|
53
55
|
case "fix-findings":
|
|
@@ -58,6 +60,8 @@ export function reviewGap(state: DevsysState, slice: SliceRef, now: DiffNow): st
|
|
|
58
60
|
return `the diff has changed since slice ${slice} was last reviewed; review it again`;
|
|
59
61
|
case "review":
|
|
60
62
|
return `slice ${slice} has ${cleanStreak(review)}/${review.required} clean review rounds`;
|
|
63
|
+
default:
|
|
64
|
+
return assertNever(action);
|
|
61
65
|
}
|
|
62
66
|
}
|
|
63
67
|
|
|
@@ -106,7 +110,7 @@ export function combinePackets(packets: readonly ReviewPacket[]): {
|
|
|
106
110
|
}
|
|
107
111
|
|
|
108
112
|
/** Jev confidence needed before its severity replaces the reviewer's. */
|
|
109
|
-
|
|
113
|
+
const SEVERITY_ADOPT_CONFIDENCE = 0.8;
|
|
110
114
|
|
|
111
115
|
const counts = (severity: Severity): boolean =>
|
|
112
116
|
severity === "blocking" || severity === "should-fix";
|
package/src/core/review.ts
CHANGED
|
@@ -90,7 +90,7 @@ export const LENSES = [
|
|
|
90
90
|
"performance",
|
|
91
91
|
] as const;
|
|
92
92
|
export type Lens = (typeof LENSES)[number];
|
|
93
|
-
|
|
93
|
+
const LENS_THRESHOLD = 0.5;
|
|
94
94
|
|
|
95
95
|
/** Lenses that apply when Jev is offline: the ones every change deserves. */
|
|
96
96
|
export const DEFAULT_LENSES: ReadonlyArray<Lens> = ["types", "tests"];
|
|
@@ -141,7 +141,7 @@ function parseRound(input: unknown): ReviewRound | ParseError {
|
|
|
141
141
|
if (typeof n !== "number" || typeof reviewedAt !== "string" || typeof diffDigest !== "string") {
|
|
142
142
|
return parseError("round needs number n and string reviewedAt and diffDigest");
|
|
143
143
|
}
|
|
144
|
-
if (!Array.isArray(lenses)
|
|
144
|
+
if (!(Array.isArray(lenses) && lenses.every((l) => typeof l === "string"))) {
|
|
145
145
|
return parseError("round lenses must be an array of strings");
|
|
146
146
|
}
|
|
147
147
|
if (!Array.isArray(findings)) return parseError("round findings must be an array");
|
|
@@ -125,6 +125,7 @@ type Segment = { words: string[]; truncates: string[] };
|
|
|
125
125
|
type Token = { kind: "word"; value: string } | { kind: "op"; op: string };
|
|
126
126
|
|
|
127
127
|
/** Turns unquoted newlines into `;` (and joins backslash continuations) so shell-quote sees every command. */
|
|
128
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: a shell lexer is one flat branch per character class; splitting it would scatter a single state machine across helpers
|
|
128
129
|
function splitLines(command: string): string {
|
|
129
130
|
let out = "";
|
|
130
131
|
let quote: "'" | '"' | undefined;
|
package/src/core/types.ts
CHANGED
|
@@ -22,12 +22,6 @@ export function parseGateId(input: string): GateId | ParseError {
|
|
|
22
22
|
: parseError(`invalid gate id "${input}": expected dotted kebab-case like "git.force-push"`);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export type GateDecision =
|
|
26
|
-
| { readonly kind: "allow" }
|
|
27
|
-
| { readonly kind: "block"; readonly reason: string }
|
|
28
|
-
| { readonly kind: "require-departure"; readonly gate: GateId; readonly reason: string }
|
|
29
|
-
| { readonly kind: "require-user"; readonly gate: GateId; readonly reason: string };
|
|
30
|
-
|
|
31
25
|
export type Phase = "intake" | "planning" | "implementing" | "reviewing" | "delivering" | "idle";
|
|
32
26
|
|
|
33
27
|
export type Sizing = "fix" | "change" | "capability" | "product";
|
package/src/gates/approvals.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { type GateId, isParseError } from "../core/types.ts";
|
|
|
6
6
|
import { appendDecision } from "../state/decision-log.ts";
|
|
7
7
|
import { DEPARTURE_ENTRY_TYPE } from "./record-departure-tool.ts";
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
const APPROVAL_ENTRY_TYPE = "devsys-approval";
|
|
10
10
|
|
|
11
11
|
type Approval = { id: string; gate: string; command: string; used: boolean };
|
|
12
12
|
|
package/src/gates/git-guard.ts
CHANGED
|
@@ -26,6 +26,35 @@ export type GitGuardDeps = {
|
|
|
26
26
|
now?: () => Date;
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
+
type Resolved = { intent: GitIntent } | { block: true; reason: string };
|
|
30
|
+
|
|
31
|
+
/** An unclassifiable command: Jev may classify it; otherwise the user decides (headless: blocked). */
|
|
32
|
+
async function resolveUnknown(
|
|
33
|
+
deps: GitGuardDeps,
|
|
34
|
+
ctx: ExtensionContext,
|
|
35
|
+
command: string,
|
|
36
|
+
): Promise<Resolved> {
|
|
37
|
+
const judged =
|
|
38
|
+
deps.jev === undefined ? undefined : await judgeShellIntent(deps.jev(ctx), command);
|
|
39
|
+
if (judged?.ok === true && judged.value.intent !== "unknown") {
|
|
40
|
+
return { intent: judged.value.intent };
|
|
41
|
+
}
|
|
42
|
+
const why = judged?.ok === false ? `unavailable: ${judged.error.kind}` : "not confident";
|
|
43
|
+
const approved = ctx.hasUI
|
|
44
|
+
? await ctx.ui.confirm(
|
|
45
|
+
"Development system — unclassified command",
|
|
46
|
+
`${redactSecrets(command)}\nCould not classify this command (Jev ${why}). It may run an irreversible git operation. Approve?`,
|
|
47
|
+
)
|
|
48
|
+
: false;
|
|
49
|
+
if (approved) return { intent: "ordinary" };
|
|
50
|
+
return {
|
|
51
|
+
block: true,
|
|
52
|
+
reason: ctx.hasUI
|
|
53
|
+
? "the user declined an unclassifiable command; propose a simpler, explicit git command"
|
|
54
|
+
: "could not classify this command and Jev is unavailable or unsure; run interactively or use an explicit git command",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
29
58
|
/** Registers the `tool_call` hard stop for irreversible git operations in bash. */
|
|
30
59
|
export function registerGitGuard(deps: GitGuardDeps): void {
|
|
31
60
|
deps.pi.on("tool_call", async (event, ctx) => {
|
|
@@ -33,26 +62,9 @@ export function registerGitGuard(deps: GitGuardDeps): void {
|
|
|
33
62
|
const command = event.input.command;
|
|
34
63
|
let intent = classifyGitCommand(command);
|
|
35
64
|
if (intent === "unknown") {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
intent = judged.value.intent;
|
|
40
|
-
} else {
|
|
41
|
-
const approved = ctx.hasUI
|
|
42
|
-
? await ctx.ui.confirm(
|
|
43
|
-
"Development system — unclassified command",
|
|
44
|
-
`${redactSecrets(command)}\nCould not classify this command (Jev ${judged?.ok === false ? `unavailable: ${judged.error.kind}` : "not confident"}). It may run an irreversible git operation. Approve?`,
|
|
45
|
-
)
|
|
46
|
-
: false;
|
|
47
|
-
return approved
|
|
48
|
-
? undefined
|
|
49
|
-
: {
|
|
50
|
-
block: true,
|
|
51
|
-
reason: ctx.hasUI
|
|
52
|
-
? "the user declined an unclassifiable command; propose a simpler, explicit git command"
|
|
53
|
-
: "could not classify this command and Jev is unavailable or unsure; run interactively or use an explicit git command",
|
|
54
|
-
};
|
|
55
|
-
}
|
|
65
|
+
const resolved = await resolveUnknown(deps, ctx, command);
|
|
66
|
+
if ("block" in resolved) return resolved;
|
|
67
|
+
intent = resolved.intent;
|
|
56
68
|
}
|
|
57
69
|
if (!HARD.has(intent)) return undefined;
|
|
58
70
|
const gate = parseGateId(`git.${intent}`);
|
|
@@ -31,7 +31,8 @@ function resultingText(
|
|
|
31
31
|
event: ToolCallEvent,
|
|
32
32
|
cwd: string,
|
|
33
33
|
): { path: string; before: string; after: string } | undefined {
|
|
34
|
-
if (!isToolCallEventType("write", event)
|
|
34
|
+
if (!(isToolCallEventType("write", event) || isToolCallEventType("edit", event)))
|
|
35
|
+
return undefined;
|
|
35
36
|
const path = normalizeRepoPath(cwd, event.input.path, homedir());
|
|
36
37
|
const class_ = classifyPath(path);
|
|
37
38
|
if (class_ !== "source" && class_ !== "test") return undefined;
|
|
@@ -74,7 +75,7 @@ export function registerLintSuppressionGuard(deps: LintSuppressionGuardDeps): vo
|
|
|
74
75
|
`${GATE_ID}: ${change.path} adds a suppression without a reason: ${list}. A suppression hides a ` +
|
|
75
76
|
`finding from every later reader, so it must say why (at least ${MIN_RATIONALE} characters, on the ` +
|
|
76
77
|
`same line or the next, e.g. "// biome-ignore lint/x: boundary parse of foreign JSON"). Add the ` +
|
|
77
|
-
|
|
78
|
+
"reason, or fix the finding instead. If a bare suppression is truly right, call " +
|
|
78
79
|
`devsys_record_departure with gate "${GATE_ID}", what you are doing, why, and the cost if wrong; then retry.`,
|
|
79
80
|
};
|
|
80
81
|
});
|
|
@@ -69,7 +69,7 @@ export function registerRedFirstGuard(deps: RedFirstGuardDeps): void {
|
|
|
69
69
|
const departure = departureUse(deps.state, gate);
|
|
70
70
|
|
|
71
71
|
deps.pi.on("tool_call", (event, ctx) => {
|
|
72
|
-
if (!isToolCallEventType("edit", event)
|
|
72
|
+
if (!(isToolCallEventType("edit", event) || isToolCallEventType("write", event)))
|
|
73
73
|
return undefined;
|
|
74
74
|
const { phase, lastTestRun, activeSlice } = deps.state.get();
|
|
75
75
|
if (phase !== "implementing") return undefined;
|
package/src/gates/test-guard.ts
CHANGED
|
@@ -75,8 +75,7 @@ const NO_SIGNALS: WeakeningSignals = { addsSkip: false, emptied: false, commente
|
|
|
75
75
|
|
|
76
76
|
/** Adding lines never weakens a test unless an added line itself disables something. */
|
|
77
77
|
const isHarmlessAddition = (before: string, after: string, signals: WeakeningSignals): boolean =>
|
|
78
|
-
!signals.addsSkip &&
|
|
79
|
-
!signals.commentedOut &&
|
|
78
|
+
!(signals.addsSkip || signals.commentedOut) &&
|
|
80
79
|
isPureAddition(before, after) &&
|
|
81
80
|
!addedLines(before, after).some((line) => RISKY_ADDITION.test(line));
|
|
82
81
|
|
|
@@ -134,19 +133,24 @@ export function registerTestGuard(deps: TestGuardDeps): void {
|
|
|
134
133
|
const collectChanges = (event: ToolCallEvent, ctx: ExtensionContext): Change[] => {
|
|
135
134
|
if (isToolCallEventType("bash", event)) {
|
|
136
135
|
const { command } = event.input;
|
|
137
|
-
return bashMutations(command).flatMap(({ path:
|
|
138
|
-
const
|
|
136
|
+
return bashMutations(command).flatMap(({ path: written, kind }): Change[] => {
|
|
137
|
+
const target = repoPath(ctx.cwd, written);
|
|
139
138
|
// Missing plain paths have nothing to weaken; globs and directories may still hide tests.
|
|
140
|
-
const touched = GLOB.test(
|
|
141
|
-
if (!isTestPath(
|
|
139
|
+
const touched = GLOB.test(target) || existsSync(join(ctx.cwd, target));
|
|
140
|
+
if (!(isTestPath(target) && touched)) return [];
|
|
142
141
|
const rewritten =
|
|
143
142
|
kind === "overwrite" ? `(rewritten by shell command) ${command}` : undefined;
|
|
144
143
|
return [
|
|
145
|
-
{
|
|
144
|
+
{
|
|
145
|
+
path: target,
|
|
146
|
+
before: readIfExists(join(ctx.cwd, target)) ?? "",
|
|
147
|
+
after: undefined,
|
|
148
|
+
rewritten,
|
|
149
|
+
},
|
|
146
150
|
];
|
|
147
151
|
});
|
|
148
152
|
}
|
|
149
|
-
if (!isToolCallEventType("write", event)
|
|
153
|
+
if (!(isToolCallEventType("write", event) || isToolCallEventType("edit", event))) return [];
|
|
150
154
|
const path = repoPath(ctx.cwd, event.input.path);
|
|
151
155
|
const raw = isTestPath(path) ? readIfExists(join(ctx.cwd, path)) : undefined;
|
|
152
156
|
if (raw === undefined) return [];
|
|
@@ -215,7 +219,7 @@ export function registerTestGuard(deps: TestGuardDeps): void {
|
|
|
215
219
|
block: true,
|
|
216
220
|
reason:
|
|
217
221
|
`${GATE_ID}: ${first.why} (${needing.map((n) => n.change.path).join(", ")}). Tests are the oracle; ` +
|
|
218
|
-
|
|
222
|
+
"weakening them needs a recorded decision. If this is deliberate, call devsys_record_departure " +
|
|
219
223
|
`with gate "${GATE_ID}", what you are doing instead, why, and the cost if wrong; then retry. ` +
|
|
220
224
|
"Otherwise fix the code, not the test.",
|
|
221
225
|
};
|
|
@@ -4,7 +4,7 @@ import { redactSecrets } from "../../core/redact.ts";
|
|
|
4
4
|
import { err, ok, type Result } from "../../core/result.ts";
|
|
5
5
|
import type { Jev, JevError } from "../client.ts";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
const SHELL_INTENT_THRESHOLD = 0.6;
|
|
8
8
|
|
|
9
9
|
export const SHELL_INTENT_QUESTION: ClassifierChoiceQuestion = {
|
|
10
10
|
type: "choice",
|
package/src/review/digest.ts
CHANGED
|
@@ -20,6 +20,23 @@ export type DiffSnapshot = {
|
|
|
20
20
|
|
|
21
21
|
const MAX_UNTRACKED = 200;
|
|
22
22
|
|
|
23
|
+
const PLAIN_DIFF = [
|
|
24
|
+
"-c",
|
|
25
|
+
"core.quotePath=false",
|
|
26
|
+
"diff",
|
|
27
|
+
"--no-ext-diff",
|
|
28
|
+
"--no-color",
|
|
29
|
+
// diff.mnemonicPrefix / diff.noprefix change the `a/` `b/` header prefixes the file keys are cut from.
|
|
30
|
+
"--src-prefix=a/",
|
|
31
|
+
"--dst-prefix=b/",
|
|
32
|
+
// Every changed path gets a `diff --git` section: no submodule log summaries, no textconv that
|
|
33
|
+
// can hide an edit whose converted text is unchanged.
|
|
34
|
+
"--submodule=short",
|
|
35
|
+
"--no-textconv",
|
|
36
|
+
// diff.ignoreSubmodules=all would hide a bump that `git add` still stages.
|
|
37
|
+
"--ignore-submodules=dirty",
|
|
38
|
+
];
|
|
39
|
+
|
|
23
40
|
/**
|
|
24
41
|
* The change in `range` (default `HEAD`: everything not yet committed) with its digest. `git diff`
|
|
25
42
|
* leaves out untracked files, so for a range that includes the work tree they are listed and
|
|
@@ -33,25 +50,9 @@ export async function snapshotDiff(
|
|
|
33
50
|
try {
|
|
34
51
|
// Plain, parseable output whatever the user's git config says: an external diff driver or
|
|
35
52
|
// forced colour removes the `diff --git` headers the per-file digests are cut from.
|
|
36
|
-
const plain = [
|
|
37
|
-
"-c",
|
|
38
|
-
"core.quotePath=false",
|
|
39
|
-
"diff",
|
|
40
|
-
"--no-ext-diff",
|
|
41
|
-
"--no-color",
|
|
42
|
-
// diff.mnemonicPrefix / diff.noprefix change the `a/` `b/` header prefixes the file keys are cut from.
|
|
43
|
-
"--src-prefix=a/",
|
|
44
|
-
"--dst-prefix=b/",
|
|
45
|
-
// Every changed path gets a `diff --git` section: no submodule log summaries, no textconv that
|
|
46
|
-
// can hide an edit whose converted text is unchanged.
|
|
47
|
-
"--submodule=short",
|
|
48
|
-
"--no-textconv",
|
|
49
|
-
// diff.ignoreSubmodules=all would hide a bump that `git add` still stages.
|
|
50
|
-
"--ignore-submodules=dirty",
|
|
51
|
-
];
|
|
52
53
|
const [diff, stat, others] = await Promise.all([
|
|
53
|
-
exec("git", [...
|
|
54
|
-
exec("git", [...
|
|
54
|
+
exec("git", [...PLAIN_DIFF, "--full-index", "--no-renames", range], { cwd, timeout: 15_000 }),
|
|
55
|
+
exec("git", [...PLAIN_DIFF, "--stat", range], { cwd, timeout: 15_000 }),
|
|
55
56
|
// Any range that ends in the work tree (HEAD, HEAD~1, a branch) leaves untracked files out of git diff.
|
|
56
57
|
!range.includes("..")
|
|
57
58
|
? exec(
|
|
@@ -79,30 +80,10 @@ export async function snapshotDiff(
|
|
|
79
80
|
if (diff.stdout.trim() !== "" && Object.keys(files).length === 0) {
|
|
80
81
|
return err("could not read the diff: no per-file sections were found");
|
|
81
82
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (target !== undefined) {
|
|
87
|
-
files[path] = `new:${blobOf(target)}`;
|
|
88
|
-
links.add(path);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
// `git hash-object <path>` follows symlinks (and fails on dangling ones), so links are hashed above.
|
|
92
|
-
const regular = untracked.filter((p) => !links.has(p));
|
|
93
|
-
if (regular.length > 0) {
|
|
94
|
-
const hashes = await exec("git", ["hash-object", "--", ...regular], {
|
|
95
|
-
cwd,
|
|
96
|
-
timeout: 15_000,
|
|
97
|
-
});
|
|
98
|
-
const lines = hashes.stdout.split("\n").filter((l) => l !== "");
|
|
99
|
-
if (hashes.code !== 0 || lines.length !== regular.length) {
|
|
100
|
-
return err(hashes.stderr.trim() || "git hash-object could not hash every untracked file");
|
|
101
|
-
}
|
|
102
|
-
for (const [i, path] of regular.entries()) files[path] = `new:${lines[i]}`;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
const names = Object.keys(files).sort();
|
|
83
|
+
const hashed = await hashUntracked(exec, cwd, untracked);
|
|
84
|
+
if (!hashed.ok) return hashed;
|
|
85
|
+
Object.assign(files, hashed.value);
|
|
86
|
+
const names = Object.keys(files).sort((a, b) => a.localeCompare(b));
|
|
106
87
|
const untrackedStat = [
|
|
107
88
|
...untracked.map((p) => ` ${p} (untracked)`),
|
|
108
89
|
...nested.map((p) => ` ${p} (nested repository, not reviewable)`),
|
|
@@ -118,6 +99,33 @@ export async function snapshotDiff(
|
|
|
118
99
|
}
|
|
119
100
|
}
|
|
120
101
|
|
|
102
|
+
/** Content digests (`new:<blob>`) of untracked files; symlinks are hashed by target, not followed. */
|
|
103
|
+
async function hashUntracked(
|
|
104
|
+
exec: Exec,
|
|
105
|
+
cwd: string,
|
|
106
|
+
untracked: string[],
|
|
107
|
+
): Promise<Result<Record<string, string>, string>> {
|
|
108
|
+
const files: Record<string, string> = {};
|
|
109
|
+
const links = new Set<string>();
|
|
110
|
+
for (const path of untracked) {
|
|
111
|
+
const target = await linkTarget(cwd, path);
|
|
112
|
+
if (target !== undefined) {
|
|
113
|
+
files[path] = `new:${blobOf(target)}`;
|
|
114
|
+
links.add(path);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// `git hash-object <path>` follows symlinks (and fails on dangling ones), so links are hashed above.
|
|
118
|
+
const regular = untracked.filter((p) => !links.has(p));
|
|
119
|
+
if (regular.length === 0) return ok(files);
|
|
120
|
+
const hashes = await exec("git", ["hash-object", "--", ...regular], { cwd, timeout: 15_000 });
|
|
121
|
+
const lines = hashes.stdout.split("\n").filter((l) => l !== "");
|
|
122
|
+
if (hashes.code !== 0 || lines.length !== regular.length) {
|
|
123
|
+
return err(hashes.stderr.trim() || "git hash-object could not hash every untracked file");
|
|
124
|
+
}
|
|
125
|
+
for (const [i, path] of regular.entries()) files[path] = `new:${lines[i]}`;
|
|
126
|
+
return ok(files);
|
|
127
|
+
}
|
|
128
|
+
|
|
121
129
|
const NEW_FILE = /^new file mode .*\nindex 0+\.\.([0-9a-f]+)/m;
|
|
122
130
|
|
|
123
131
|
/** A new file digests as its blob, the same form an untracked file gets, so `git add` changes nothing. */
|
|
@@ -2,6 +2,7 @@ import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding
|
|
|
2
2
|
import { type Static, Type } from "typebox";
|
|
3
3
|
import type { Exec } from "../core/exec.ts";
|
|
4
4
|
import { resolveSlot } from "../core/models.ts";
|
|
5
|
+
import type { Lens } from "../core/review.ts";
|
|
5
6
|
import {
|
|
6
7
|
addRound,
|
|
7
8
|
DEFAULT_LENSES,
|
|
@@ -72,6 +73,51 @@ const pathSafe = (slice: string): string =>
|
|
|
72
73
|
.replace(/^-+|-+$/g, "")
|
|
73
74
|
.slice(0, 32);
|
|
74
75
|
|
|
76
|
+
type LensJudgement = Awaited<ReturnType<typeof judgeLenses>>;
|
|
77
|
+
|
|
78
|
+
/** Jev's lenses for the diff, or the defaults (with the reason) when Jev is offline or names none. */
|
|
79
|
+
function chooseLenses(judged: LensJudgement): { lenses: Lens[]; basis: string } {
|
|
80
|
+
if (!judged.ok) {
|
|
81
|
+
return {
|
|
82
|
+
lenses: [...DEFAULT_LENSES],
|
|
83
|
+
basis: `Jev unavailable (${judged.error.kind}); using the default lenses.`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const selected = selectLenses(judged.value);
|
|
87
|
+
if (selected.length === 0) {
|
|
88
|
+
return {
|
|
89
|
+
lenses: [...DEFAULT_LENSES],
|
|
90
|
+
basis: "Jev found no specific lens; using the defaults.",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return { lenses: selected, basis: "Jev chose the lenses." };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The agent_spawn arguments for one reviewer round. */
|
|
97
|
+
function spawnPayload(input: {
|
|
98
|
+
slice: SliceRef;
|
|
99
|
+
round: number;
|
|
100
|
+
lenses: Lens[];
|
|
101
|
+
range: string;
|
|
102
|
+
model: ReturnType<typeof resolveSlot>;
|
|
103
|
+
now: Date;
|
|
104
|
+
}): Record<string, unknown> {
|
|
105
|
+
return {
|
|
106
|
+
// A fresh path per attempt: a failed spawn must not leave a thread that blocks the retry.
|
|
107
|
+
path: `/review-${pathSafe(input.slice)}-r${input.round}-${input.now.getTime().toString(36)}`,
|
|
108
|
+
type: "reviewer",
|
|
109
|
+
task: reviewerTask({
|
|
110
|
+
slice: input.slice,
|
|
111
|
+
round: input.round,
|
|
112
|
+
lenses: input.lenses,
|
|
113
|
+
diffRange: input.range,
|
|
114
|
+
}),
|
|
115
|
+
...(input.model.ok ? { model: input.model.value.model } : {}),
|
|
116
|
+
thinkingLevel: "high",
|
|
117
|
+
wait: true,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
75
121
|
/** `devsys_review_start`: Jev picks lenses for the diff; the reply carries the agent_spawn payload for a fresh reviewer. */
|
|
76
122
|
export function createReviewStartTool(
|
|
77
123
|
deps: ReviewToolDeps,
|
|
@@ -106,13 +152,7 @@ export function createReviewStartTool(
|
|
|
106
152
|
diffSample: snap.value.sample,
|
|
107
153
|
profiles: deps.state.get().profiles ?? [],
|
|
108
154
|
});
|
|
109
|
-
const
|
|
110
|
-
const lenses = selected.length > 0 ? selected : [...DEFAULT_LENSES];
|
|
111
|
-
const basis = judged.ok
|
|
112
|
-
? selected.length > 0
|
|
113
|
-
? "Jev chose the lenses."
|
|
114
|
-
: "Jev found no specific lens; using the defaults."
|
|
115
|
-
: `Jev unavailable (${judged.error.kind}); using the default lenses.`;
|
|
155
|
+
const { lenses, basis } = chooseLenses(judged);
|
|
116
156
|
|
|
117
157
|
const required = Math.max(
|
|
118
158
|
config.value.review.requiredCleanRounds,
|
|
@@ -137,20 +177,14 @@ export function createReviewStartTool(
|
|
|
137
177
|
}
|
|
138
178
|
|
|
139
179
|
const round = review.rounds.length + 1;
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
type: "reviewer",
|
|
149
|
-
task: reviewerTask({ slice, round, lenses, diffRange: range }),
|
|
150
|
-
...(resolved.ok ? { model: resolved.value.model } : {}),
|
|
151
|
-
thinkingLevel: "high",
|
|
152
|
-
wait: true,
|
|
153
|
-
};
|
|
180
|
+
const spawn = spawnPayload({
|
|
181
|
+
slice,
|
|
182
|
+
round,
|
|
183
|
+
lenses,
|
|
184
|
+
range,
|
|
185
|
+
model: resolveSlot(config.value.models, "reviewer", availableModels(ctx.modelRegistry)),
|
|
186
|
+
now: deps.now?.() ?? new Date(),
|
|
187
|
+
});
|
|
154
188
|
return reply(
|
|
155
189
|
[
|
|
156
190
|
`${reviewLabel(review)}; round ${round}; diff ${snap.value.digest}. ${basis}`,
|
package/src/state/ci-command.ts
CHANGED
|
@@ -68,7 +68,7 @@ export async function runCiWatch(
|
|
|
68
68
|
export function registerCiCommand(deps: CiCommandDeps): void {
|
|
69
69
|
deps.pi.registerCommand("devsys-ci", {
|
|
70
70
|
description: "Watch CI on the trunk and show its state in the status line",
|
|
71
|
-
handler:
|
|
71
|
+
handler: (_args, ctx) => {
|
|
72
72
|
ctx.ui.notify("watching CI on the trunk…", "info");
|
|
73
73
|
runCiWatch(deps, ctx).catch((cause: unknown) => {
|
|
74
74
|
// ctx goes stale if the session is replaced or reloaded mid-watch; there is nobody left to tell.
|
|
@@ -81,6 +81,7 @@ export function registerCiCommand(deps: CiCommandDeps): void {
|
|
|
81
81
|
// stale context
|
|
82
82
|
}
|
|
83
83
|
});
|
|
84
|
+
return Promise.resolve();
|
|
84
85
|
},
|
|
85
86
|
});
|
|
86
87
|
}
|
package/src/state/config.ts
CHANGED
|
@@ -11,8 +11,8 @@ export const CONFIG_FILE = ".development-system.toml";
|
|
|
11
11
|
const DELIVERY_MODES = ["trunk", "pull-request", "local-only"] as const;
|
|
12
12
|
const TRACKER_KINDS = ["repo-files", "github", "jira", "linear"] as const;
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
type DeliveryMode = (typeof DELIVERY_MODES)[number];
|
|
15
|
+
type TrackerKind = (typeof TRACKER_KINDS)[number];
|
|
16
16
|
|
|
17
17
|
export type ConfigError = {
|
|
18
18
|
readonly kind: "config-error";
|
|
@@ -280,31 +280,32 @@ export function parseConfig(source: string): Result<DevsysConfig, ConfigError> {
|
|
|
280
280
|
const delivery = deliveryAndReview(root);
|
|
281
281
|
const lim = limits(root);
|
|
282
282
|
const matrix = models(root);
|
|
283
|
-
const
|
|
283
|
+
const routes = routing(root);
|
|
284
284
|
const prof = profiles(root);
|
|
285
285
|
if (!delivery.ok) return delivery;
|
|
286
286
|
if (!lim.ok) return lim;
|
|
287
287
|
if (!matrix.ok) return matrix;
|
|
288
|
-
if (!
|
|
288
|
+
if (!routes.ok) return routes;
|
|
289
289
|
if (!prof.ok) return prof;
|
|
290
290
|
return ok({
|
|
291
291
|
...delivery.value,
|
|
292
292
|
...lim.value,
|
|
293
293
|
profiles: { override: prof.value },
|
|
294
294
|
models: matrix.value,
|
|
295
|
-
routing:
|
|
295
|
+
routing: routes.value,
|
|
296
296
|
});
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
+
const isMissingFile = (cause: unknown): boolean =>
|
|
300
|
+
cause instanceof Error && "code" in cause && String(cause.code) === "ENOENT";
|
|
301
|
+
|
|
299
302
|
/** Reads `<repoRoot>/.development-system.toml`; a missing file means all defaults. */
|
|
300
303
|
export async function loadConfig(repoRoot: string): Promise<Result<DevsysConfig, ConfigError>> {
|
|
301
304
|
let source: string;
|
|
302
305
|
try {
|
|
303
306
|
source = await readFile(join(repoRoot, CONFIG_FILE), "utf8");
|
|
304
307
|
} catch (cause) {
|
|
305
|
-
if (
|
|
306
|
-
return parseConfig("");
|
|
307
|
-
}
|
|
308
|
+
if (isMissingFile(cause)) return parseConfig("");
|
|
308
309
|
return fail(
|
|
309
310
|
`cannot read ${CONFIG_FILE}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
310
311
|
);
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
} from "../core/models.ts";
|
|
20
20
|
import { CONFIG_FILE, loadConfig } from "./config.ts";
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
const CONFIG_ENTRY_TYPE = "devsys-config";
|
|
23
23
|
|
|
24
24
|
export type CatalogModel = Available & {
|
|
25
25
|
readonly cost?: { readonly input: number; readonly output: number };
|
|
@@ -32,7 +32,7 @@ export interface ModelCatalog {
|
|
|
32
32
|
hasConfiguredAuth(model: CatalogModel): boolean;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
type SlotRow = {
|
|
36
36
|
readonly slot: Slot;
|
|
37
37
|
readonly resolution: Resolution | undefined;
|
|
38
38
|
};
|
|
@@ -45,7 +45,7 @@ export function availableModels(catalog: ModelCatalog): CatalogModel[] {
|
|
|
45
45
|
return [...catalog.getAvailable(), ...classifiers];
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
function resolveRows(matrix: ModelMatrix, available: readonly Available[]): SlotRow[] {
|
|
49
49
|
return SLOTS.map((slot) => {
|
|
50
50
|
const r = resolveSlot(matrix, slot, available);
|
|
51
51
|
return { slot, resolution: r.ok ? r.value : undefined };
|
|
@@ -62,10 +62,7 @@ function priceOf(model: string, available: readonly CatalogModel[]): string {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/** One line per slot: `slot → provider/id (via candidate) · $in/$out per M`, or `UNRESOLVED`. */
|
|
65
|
-
|
|
66
|
-
rows: readonly SlotRow[],
|
|
67
|
-
available: readonly CatalogModel[],
|
|
68
|
-
): string {
|
|
65
|
+
function renderModelsTable(rows: readonly SlotRow[], available: readonly CatalogModel[]): string {
|
|
69
66
|
const width = Math.max(...SLOTS.map((s) => s.length));
|
|
70
67
|
return rows
|
|
71
68
|
.map(({ slot, resolution }) =>
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
type TestRun,
|
|
20
20
|
} from "../core/types.ts";
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
const STATE_ENTRY_TYPE = "devsys-state";
|
|
23
23
|
|
|
24
24
|
const PHASES: ReadonlyArray<Phase> = [
|
|
25
25
|
"intake",
|
|
@@ -77,30 +77,45 @@ function parseReviews(input: unknown): ReviewState[] | ParseError {
|
|
|
77
77
|
return out;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
type Nested = Pick<DevsysState, "ci" | "profiles" | "lastTestRun" | "reviews">;
|
|
81
|
+
|
|
82
|
+
/** Parses each optional nested field that is present; the first malformed one is the error. */
|
|
83
|
+
function parseNested(input: Record<string, unknown>): Nested | ParseError {
|
|
84
|
+
const out: Record<string, unknown> = {};
|
|
85
|
+
const fields = [
|
|
86
|
+
["ci", parseCi],
|
|
87
|
+
["profiles", parseProfileList],
|
|
88
|
+
["lastTestRun", parseTestRun],
|
|
89
|
+
["reviews", parseReviews],
|
|
90
|
+
] as const;
|
|
91
|
+
for (const [key, parse] of fields) {
|
|
92
|
+
if (input[key] === undefined) continue;
|
|
93
|
+
const parsed = parse(input[key]);
|
|
94
|
+
if (isParseError(parsed)) return parsed;
|
|
95
|
+
out[key] = parsed;
|
|
96
|
+
}
|
|
97
|
+
return out as Nested;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseDepartures(raw: unknown): Departure[] | ParseError {
|
|
101
|
+
if (!Array.isArray(raw)) return parseError("openDepartures must be an array");
|
|
102
|
+
const departures: Departure[] = [];
|
|
103
|
+
for (const item of raw) {
|
|
104
|
+
const departure = parseDeparture(item);
|
|
105
|
+
if (isParseError(departure)) return departure;
|
|
106
|
+
departures.push(departure);
|
|
107
|
+
}
|
|
108
|
+
return departures;
|
|
109
|
+
}
|
|
110
|
+
|
|
80
111
|
/** Boundary parse for persisted state: the only place state is cast from `unknown`. */
|
|
81
112
|
export function parseDevsysState(input: unknown): DevsysState | ParseError {
|
|
82
113
|
if (!isRecord(input)) return parseError("devsys state must be an object");
|
|
83
|
-
const {
|
|
84
|
-
phase,
|
|
85
|
-
sizing,
|
|
86
|
-
activeSlice,
|
|
87
|
-
openDepartures,
|
|
88
|
-
jev,
|
|
89
|
-
lastPushAt,
|
|
90
|
-
ci,
|
|
91
|
-
profiles,
|
|
92
|
-
lastTestRun,
|
|
93
|
-
reviews,
|
|
94
|
-
} = input;
|
|
114
|
+
const { phase, sizing, activeSlice, openDepartures, jev, lastPushAt } = input;
|
|
95
115
|
if (!PHASES.includes(phase as Phase)) return parseError(`unknown phase: ${String(phase)}`);
|
|
96
116
|
if (!JEV.includes(jev as JevStatus)) return parseError(`unknown jev status: ${String(jev)}`);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
for (const raw of openDepartures) {
|
|
100
|
-
const departure = parseDeparture(raw);
|
|
101
|
-
if (isParseError(departure)) return departure;
|
|
102
|
-
departures.push(departure);
|
|
103
|
-
}
|
|
117
|
+
const departures = parseDepartures(openDepartures);
|
|
118
|
+
if (isParseError(departures)) return departures;
|
|
104
119
|
if (sizing !== undefined && !SIZINGS.includes(sizing as Sizing)) {
|
|
105
120
|
return parseError(`unknown sizing: ${String(sizing)}`);
|
|
106
121
|
}
|
|
@@ -110,14 +125,8 @@ export function parseDevsysState(input: unknown): DevsysState | ParseError {
|
|
|
110
125
|
if (lastPushAt !== undefined && typeof lastPushAt !== "string") {
|
|
111
126
|
return parseError("lastPushAt must be a string");
|
|
112
127
|
}
|
|
113
|
-
const
|
|
114
|
-
if (isParseError(
|
|
115
|
-
const parsedProfiles = profiles === undefined ? undefined : parseProfileList(profiles);
|
|
116
|
-
if (isParseError(parsedProfiles)) return parsedProfiles;
|
|
117
|
-
const parsedRun = lastTestRun === undefined ? undefined : parseTestRun(lastTestRun);
|
|
118
|
-
if (isParseError(parsedRun)) return parsedRun;
|
|
119
|
-
const parsedReviews = reviews === undefined ? undefined : parseReviews(reviews);
|
|
120
|
-
if (isParseError(parsedReviews)) return parsedReviews;
|
|
128
|
+
const nested = parseNested(input);
|
|
129
|
+
if (isParseError(nested)) return nested;
|
|
121
130
|
return {
|
|
122
131
|
phase: phase as Phase,
|
|
123
132
|
jev: jev as JevStatus,
|
|
@@ -125,10 +134,7 @@ export function parseDevsysState(input: unknown): DevsysState | ParseError {
|
|
|
125
134
|
...(sizing !== undefined ? { sizing: sizing as Sizing } : {}),
|
|
126
135
|
...(activeSlice !== undefined ? { activeSlice: activeSlice as SliceRef } : {}),
|
|
127
136
|
...(lastPushAt !== undefined ? { lastPushAt } : {}),
|
|
128
|
-
...
|
|
129
|
-
...(parsedProfiles !== undefined ? { profiles: parsedProfiles } : {}),
|
|
130
|
-
...(parsedRun !== undefined ? { lastTestRun: parsedRun } : {}),
|
|
131
|
-
...(parsedReviews !== undefined ? { reviews: parsedReviews } : {}),
|
|
137
|
+
...nested,
|
|
132
138
|
};
|
|
133
139
|
}
|
|
134
140
|
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import type { SessionEntry, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
const LEGACY_QUEUE_TYPE = "pi-subagent:queue:v1";
|
|
6
6
|
const ACCEPTED_TYPE = "pi-subagent:mailbox:accepted:v2";
|
|
7
7
|
const CONSUMED_TYPE = "pi-subagent:mailbox:consumed:v2";
|
|
8
8
|
const MIGRATION_TYPE = "pi-subagent:mailbox:migration:v2";
|
|
9
|
-
|
|
9
|
+
const MAILBOX_FIELD = "piSubagentMailbox";
|
|
10
10
|
export const BOOTSTRAP_MESSAGE = "Subagent session initialized; awaiting parent input.";
|
|
11
11
|
|
|
12
12
|
export type AcceptedKind = "steer" | "followUp" | "update";
|
|
@@ -12,7 +12,7 @@ import { dialogText } from "../ui/dialog.ts";
|
|
|
12
12
|
export const IMPORT_REQUEST_PREFIX =
|
|
13
13
|
"Import ONLY the agents I selected in the pi-subagent-manager migration picker.";
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
const IMPORT_CHILD_TOOLS = [
|
|
16
16
|
"read",
|
|
17
17
|
"bash",
|
|
18
18
|
"powershell",
|
|
@@ -61,7 +61,7 @@ function stateDirectory(agentDir: string, create: boolean): string | undefined {
|
|
|
61
61
|
return directory;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
function importWasOffered(agentDir: string): boolean {
|
|
65
65
|
const directory = stateDirectory(agentDir, false);
|
|
66
66
|
if (!directory) return false;
|
|
67
67
|
const stat = statIfPresent(join(directory, OFFER_MARKER));
|
|
@@ -89,7 +89,7 @@ export function markImportOffered(agentDir: string): void {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
|
|
92
|
+
function buildImportPrompt(options: {
|
|
93
93
|
candidates: readonly ImportCandidate[];
|
|
94
94
|
agentDir: string;
|
|
95
95
|
cwd: string;
|
|
@@ -14,16 +14,16 @@ import {
|
|
|
14
14
|
} from "node:fs";
|
|
15
15
|
import { dirname, join, resolve } from "node:path";
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
const SUBAGENT_MODES = ["off", "opportunistic", "orchestration"] as const;
|
|
18
18
|
export type SubagentMode = (typeof SUBAGENT_MODES)[number];
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
const WIDGET_MODES = ["full", "minimal"] as const;
|
|
21
21
|
export type WidgetMode = (typeof WIDGET_MODES)[number];
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
const TOOL_FILTERING_MODES = ["allowed", "all-except-blocked", "all"] as const;
|
|
24
24
|
export type ToolFilteringMode = (typeof TOOL_FILTERING_MODES)[number];
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
const MODEL_SELECTION_MODES = [
|
|
27
27
|
"pick-first-available",
|
|
28
28
|
"pick-first-scoped",
|
|
29
29
|
"use-current",
|
package/src/subagents/types.ts
CHANGED
|
@@ -44,7 +44,7 @@ export interface AgentType {
|
|
|
44
44
|
/** File path of the base definition under the customization, if any. */
|
|
45
45
|
baseFilePath?: string;
|
|
46
46
|
}
|
|
47
|
-
|
|
47
|
+
type ThreadState = "starting" | "running" | "paused" | "completed" | "failed" | "stopped";
|
|
48
48
|
export interface ThreadView {
|
|
49
49
|
path: string;
|
|
50
50
|
parent: string | null;
|
|
@@ -71,7 +71,7 @@ export interface ThreadView {
|
|
|
71
71
|
/** devsys: per-spawn model/thinkingLevel pin requested through agent_spawn, when any. */
|
|
72
72
|
pinned?: { model?: string; thinkingLevel?: ThinkingLevel };
|
|
73
73
|
}
|
|
74
|
-
|
|
74
|
+
type DriverEvent =
|
|
75
75
|
| { kind: "activity" | "error"; text: string }
|
|
76
76
|
| {
|
|
77
77
|
kind: "checkpoint";
|
|
@@ -24,7 +24,7 @@ export interface DialogHost {
|
|
|
24
24
|
requestRender(force?: boolean): void;
|
|
25
25
|
terminal?: { rows: number };
|
|
26
26
|
}
|
|
27
|
-
|
|
27
|
+
interface DialogEditorHost extends DialogHost {
|
|
28
28
|
terminal: { rows: number };
|
|
29
29
|
}
|
|
30
30
|
export interface DialogRow {
|
|
@@ -97,7 +97,7 @@ export function frameDialog(
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
/** A multiline editor inside the same bounded shell as the surrounding form. */
|
|
100
|
-
|
|
100
|
+
class DialogEditor {
|
|
101
101
|
private editor: Editor;
|
|
102
102
|
private initialText: string;
|
|
103
103
|
constructor(
|
|
@@ -180,7 +180,7 @@ export function dialogEditor(
|
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
/** Bounded, two-column menu shared by settings and agent definitions. */
|
|
183
|
-
|
|
183
|
+
class DialogMenu {
|
|
184
184
|
private selected = 0;
|
|
185
185
|
private viewport = 1;
|
|
186
186
|
constructor(
|
|
@@ -71,7 +71,7 @@ function wrapText(text: string, columns: number): string[] {
|
|
|
71
71
|
* Checkbox dialog for choosing external agents to migrate.
|
|
72
72
|
* Selection only — the caller imports; this does not read or write agent files.
|
|
73
73
|
*/
|
|
74
|
-
|
|
74
|
+
class ImportPicker {
|
|
75
75
|
private cursor = 0;
|
|
76
76
|
private viewport = 1;
|
|
77
77
|
private readonly checked = new Set<string>();
|
|
@@ -84,7 +84,7 @@ function describeModel(model: AvailableModel, scopedIdentities: ReadonlySet<stri
|
|
|
84
84
|
return [name, annotation].filter(Boolean).join(" · ");
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
class OrderedModelEditorComponent extends Container {
|
|
88
88
|
private readonly tui: {
|
|
89
89
|
requestRender(force?: boolean): void;
|
|
90
90
|
terminal?: { rows: number };
|
|
@@ -31,7 +31,6 @@ import {
|
|
|
31
31
|
} from "./ui.ts";
|
|
32
32
|
import { LiveAgentView, fillViewport, type AgentViewportState } from "./live-agent-view.ts";
|
|
33
33
|
|
|
34
|
-
export { buildStatusTree, type StatusRow };
|
|
35
34
|
|
|
36
35
|
const ROOT = "/root";
|
|
37
36
|
const INACTIVE_STATES = new Set(["paused", "stopped", "failed", "completed"]);
|
|
@@ -81,7 +80,7 @@ export interface AgentTreeState {
|
|
|
81
80
|
collapsed: Set<string>;
|
|
82
81
|
}
|
|
83
82
|
|
|
84
|
-
|
|
83
|
+
class StatusDialog {
|
|
85
84
|
private state: AgentTreeState;
|
|
86
85
|
private get selectedPath(): string {
|
|
87
86
|
return this.state.selectedPath;
|
|
@@ -530,7 +529,7 @@ export class AgentNavigationController {
|
|
|
530
529
|
|
|
531
530
|
const navigationByService = new WeakMap<ThreadService, AgentNavigationController>();
|
|
532
531
|
|
|
533
|
-
|
|
532
|
+
async function showAgentTree(
|
|
534
533
|
ctx: ExtensionContext,
|
|
535
534
|
service: ThreadService,
|
|
536
535
|
selectedPath?: string,
|
package/src/subagents/ui/ui.ts
CHANGED
|
@@ -55,7 +55,7 @@ export type ThreadController = Pick<
|
|
|
55
55
|
>;
|
|
56
56
|
|
|
57
57
|
/** Plain terminal-safe text: never pass agent-supplied terminal commands through. */
|
|
58
|
-
|
|
58
|
+
function sanitizeText(text: string): string {
|
|
59
59
|
return stripTerminalSequences(text).replace(/[\x00-\x1f\x7f-\x9f]/g, " ");
|
|
60
60
|
}
|
|
61
61
|
|
|
@@ -200,7 +200,7 @@ function isLive(thread: ThreadView): boolean {
|
|
|
200
200
|
);
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
|
|
203
|
+
function renderThreads(
|
|
204
204
|
threads: ThreadView[],
|
|
205
205
|
width: number,
|
|
206
206
|
theme: AgentBadgeTheme,
|
|
@@ -453,7 +453,7 @@ function takeWidgetRows(rows: StatusRow[], budget: number): StatusRow[] {
|
|
|
453
453
|
}
|
|
454
454
|
|
|
455
455
|
/** Themed agent tree above the editor. Synthetic /root is the main conversation and is omitted. */
|
|
456
|
-
|
|
456
|
+
function renderAgentTree(
|
|
457
457
|
threads: ThreadView[],
|
|
458
458
|
width: number,
|
|
459
459
|
theme: AgentBadgeTheme,
|
|
@@ -493,7 +493,7 @@ export function renderAgentTree(
|
|
|
493
493
|
}
|
|
494
494
|
|
|
495
495
|
/** One-line counts for real agents; starting agents count as running, including tokens. */
|
|
496
|
-
|
|
496
|
+
function renderAgentSummary(
|
|
497
497
|
threads: ThreadView[],
|
|
498
498
|
width: number,
|
|
499
499
|
theme: Pick<Theme, "fg">,
|
|
@@ -682,7 +682,7 @@ export async function showThreads(
|
|
|
682
682
|
}
|
|
683
683
|
|
|
684
684
|
/** Parse editor argv, rejecting shell operators instead of evaluating a shell command. */
|
|
685
|
-
|
|
685
|
+
function editorArguments(command: string): string[] {
|
|
686
686
|
const args = parseShell(command, process.env);
|
|
687
687
|
if (!args.length || args.some((arg) => typeof arg !== "string") || !args[0]) {
|
|
688
688
|
throw new Error(
|