@jwilger/pi-development-system 0.84.0 → 0.85.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 +7 -0
- package/extensions/development-system.ts +3 -0
- package/package.json +1 -1
- package/prompts/devsys-plan.md +1 -1
- package/src/context/phase-guide.ts +3 -3
- package/src/context/turn-verifier.ts +7 -1
- package/src/core/lifecycle.ts +51 -0
- package/src/gates/commit-guard.ts +2 -1
- package/src/gates/red-first-guard.ts +20 -4
- package/src/planning/intake-tool.ts +3 -4
- package/src/planning/slice-close.ts +195 -0
- package/src/review/review-tools.ts +3 -0
package/README.md
CHANGED
|
@@ -27,6 +27,13 @@ terminology, journeys). `devsys_lens_review` plans a review of the brief by five
|
|
|
27
27
|
writes the packets to `docs/product/reviews/`; `devsys_adr_new` creates the next numbered ADR, and
|
|
28
28
|
a commit that shapes the architecture without one is stopped by the soft gate `adr.missing`.
|
|
29
29
|
|
|
30
|
+
A slice has a life cycle: `implementing` → `reviewing` (when a review round starts) → `delivering`
|
|
31
|
+
(review satisfied) → `idle`. Editing production source while delivering reopens `implementing`,
|
|
32
|
+
and red-first stays on throughout. A push of a clean tree closes a delivered slice by itself (a commit
|
|
33
|
+
in `local-only` mode; CI is not awaited), and `devsys_finish_slice` closes it explicitly or, with a
|
|
34
|
+
reason, abandons it. A plan's increments each end in a push, so each increment is its own slice: the
|
|
35
|
+
next one starts with `devsys_intake`.
|
|
36
|
+
|
|
30
37
|
With `codemode` enabled (`"defaultTools": ["+codemode"]` in pi settings), rarely used tools are
|
|
31
38
|
reached through scripts and the `judge_*` Jev wrappers exist for scripts only; without codemode
|
|
32
39
|
the rarely used tools are declared directly and the wrappers are absent.
|
|
@@ -30,6 +30,7 @@ import { createJudgeTools } from "../src/jev/judge-tools.ts";
|
|
|
30
30
|
import { createAdrNewTool } from "../src/planning/adr-tool.ts";
|
|
31
31
|
import { createBeginWorkTool } from "../src/planning/begin-tool.ts";
|
|
32
32
|
import { createIntakeTool } from "../src/planning/intake-tool.ts";
|
|
33
|
+
import { createFinishSliceTool, registerSliceClose } from "../src/planning/slice-close.ts";
|
|
33
34
|
import { createTaskCheckTool } from "../src/planning/task-check-tool.ts";
|
|
34
35
|
import { createLensReviewTool } from "../src/review/lens-review-tool.ts";
|
|
35
36
|
import { createReviewRecordTool, createReviewStartTool } from "../src/review/review-tools.ts";
|
|
@@ -158,6 +159,7 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
|
158
159
|
},
|
|
159
160
|
});
|
|
160
161
|
registerRedFirstGuard({ pi, state });
|
|
162
|
+
registerSliceClose({ pi, state, exec });
|
|
161
163
|
registerLintSuppressionGuard({ pi, state });
|
|
162
164
|
pi.registerTool(createRecordDepartureTool({ pi, state }));
|
|
163
165
|
pi.registerTool(createRequestApprovalTool({ pi, approvals }));
|
|
@@ -177,6 +179,7 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
|
177
179
|
pi.registerTool(createPhaseTool({ state }));
|
|
178
180
|
pi.registerTool(createBeginWorkTool({ state }));
|
|
179
181
|
pi.registerTool(createAdrNewTool({ now: () => new Date() }));
|
|
182
|
+
pi.registerTool(createFinishSliceTool({ state, exec }));
|
|
180
183
|
pi.registerTool(
|
|
181
184
|
createLensReviewTool({ state, jev: (ctx) => jevHolder.forContext(ctx), now: () => new Date() }),
|
|
182
185
|
);
|
package/package.json
CHANGED
package/prompts/devsys-plan.md
CHANGED
|
@@ -9,4 +9,4 @@ Write the plan for the current work with the work-intake-and-slicing skill.
|
|
|
9
9
|
3. Write each task so that someone who sees only that task could finish it. Never write `TBD`.
|
|
10
10
|
4. Run `devsys_task_check` on every task record you wrote and fix each one until it says ready. Split any that come back too-big.
|
|
11
11
|
5. Stop and ask the user to review the plan before any implementation starts. Do not start a goal yet: a goal tool may continue on its own and begin implementing before the review.
|
|
12
|
-
6. Only after the user approves the plan: call `devsys_begin_work` (so the review and red-first gates are on while the
|
|
12
|
+
6. Only after the user approves the plan: call `devsys_begin_work` (so the review and red-first gates are on while the increment is built; it does nothing if the work is already implementing). A push of a clean, reviewed tree closes the slice, so each later increment starts with `devsys_intake`. Then, if a `create_goal` tool exists in this session, call it with the objective "complete docs/plan/<slug>.md honouring its Constraints and Progress checklist, one increment at a time, stopping after each increment's Release step". Otherwise tell the user the plan path so they can start a goal on it. Do not depend on any goal tool's file format.
|
|
@@ -10,11 +10,11 @@ export function phaseGuide(phase: Phase): string {
|
|
|
10
10
|
case "planning":
|
|
11
11
|
return "Planning. Produce the artifacts the sizing asked for, write task records (check each with devsys_task_check), and when the user approves the plan call devsys_begin_work.";
|
|
12
12
|
case "implementing":
|
|
13
|
-
return "Implementing one slice. Write a failing test first, make it pass with the least code, run the tests and read the result before claiming anything. Push
|
|
13
|
+
return "Implementing one slice. Write a failing test first, make it pass with the least code, run the tests and read the result before claiming anything. Push when the slice is complete and reviewed: a push of a clean tree closes it (devsys_finish_slice closes it explicitly).";
|
|
14
14
|
case "reviewing":
|
|
15
|
-
return "Reviewing. Call devsys_review_start for a fresh-context review, pass its packet and diffDigest to devsys_review_record, fix every blocking and should-fix finding, and repeat until the
|
|
15
|
+
return "Reviewing. Call devsys_review_start for a fresh-context review, pass its packet and diffDigest to devsys_review_record, fix every blocking and should-fix finding (red-first still applies), and repeat until the review is satisfied; then the slice moves to delivering.";
|
|
16
16
|
case "delivering":
|
|
17
|
-
return "Delivering. Commit with a rationale, push
|
|
17
|
+
return "Delivering. The review is satisfied. Commit with a rationale, push, and release; a push of a clean tree closes the slice (devsys_finish_slice closes it explicitly, or abandons it with a reason). Editing source reopens implementing. A red trunk (CI) is repaired first.";
|
|
18
18
|
default:
|
|
19
19
|
return assertNever(phase);
|
|
20
20
|
}
|
|
@@ -149,17 +149,23 @@ export function registerTurnVerifier(deps: TurnVerifierDeps): void {
|
|
|
149
149
|
let evidence: ToolEvidence[] = [];
|
|
150
150
|
let corrected = 0;
|
|
151
151
|
let justCorrected = false;
|
|
152
|
+
let inFlightThisRun = false;
|
|
152
153
|
|
|
153
154
|
deps.pi.on("session_start", () => {
|
|
154
155
|
corrected = 0;
|
|
155
156
|
justCorrected = false;
|
|
157
|
+
inFlightThisRun = false;
|
|
156
158
|
evidence = [];
|
|
157
159
|
});
|
|
158
160
|
deps.pi.on("agent_start", () => {
|
|
161
|
+
// A push in this run may close the slice; its delivery report is still the claim to check.
|
|
162
|
+
inFlightThisRun = VERIFIED_PHASES.has(deps.state.get().phase);
|
|
159
163
|
evidence = [];
|
|
160
164
|
justCorrected = false;
|
|
161
165
|
});
|
|
162
166
|
deps.pi.on("tool_result", (event) => {
|
|
167
|
+
// The slice can start inside this run (intake, begin) and be closed by its own push; note it was open.
|
|
168
|
+
if (VERIFIED_PHASES.has(deps.state.get().phase)) inFlightThisRun = true;
|
|
163
169
|
evidence = [...evidence, evidenceOf(event)].slice(-MAX_EVIDENCE);
|
|
164
170
|
});
|
|
165
171
|
|
|
@@ -167,7 +173,7 @@ export function registerTurnVerifier(deps: TurnVerifierDeps): void {
|
|
|
167
173
|
// An aborted or errored turn is dropped by pi; judging it would delay the abort and spend a correction.
|
|
168
174
|
if (event.outcome !== "completed") return undefined;
|
|
169
175
|
const state = deps.state.get();
|
|
170
|
-
if (!VERIFIED_PHASES.has(state.phase)) return undefined;
|
|
176
|
+
if (!(VERIFIED_PHASES.has(state.phase) || inFlightThisRun)) return undefined;
|
|
171
177
|
const parts = assistantParts(event.message);
|
|
172
178
|
if (parts === undefined || parts.callsTools || parts.text.trim() === "") return undefined;
|
|
173
179
|
if (justCorrected) {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ReviewAction } from "./review.ts";
|
|
2
|
+
import type { DevsysState, SliceRef } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How a slice moves through the phases once it is implementing. Pure: the tools and guards that observe the
|
|
6
|
+
* events (a review round, an edit, a push) call these and write the result to state.
|
|
7
|
+
*
|
|
8
|
+
* implementing → reviewing a review round starts or is recorded without being satisfied
|
|
9
|
+
* reviewing → delivering the review is satisfied on the current diff
|
|
10
|
+
* delivering → reviewing a later round finds something again
|
|
11
|
+
* delivering → implementing production source is edited (the review no longer covers it)
|
|
12
|
+
* any open → idle the slice is delivered or abandoned (`closeSlice`)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const IN_FLIGHT = new Set<DevsysState["phase"]>(["implementing", "reviewing", "delivering"]);
|
|
16
|
+
|
|
17
|
+
/** The state after a review round was started or recorded; `action` is `nextAction` for the diff as it is now. */
|
|
18
|
+
export function afterReviewRound(
|
|
19
|
+
state: DevsysState,
|
|
20
|
+
action: ReviewAction,
|
|
21
|
+
slice: SliceRef | undefined = state.activeSlice,
|
|
22
|
+
): DevsysState {
|
|
23
|
+
if (state.activeSlice === undefined || slice !== state.activeSlice) return state;
|
|
24
|
+
if (!IN_FLIGHT.has(state.phase)) return state;
|
|
25
|
+
return { ...state, phase: action === "done" ? "delivering" : "reviewing" };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Editing production source after review reopens the slice, so red-first applies to the edit again. */
|
|
29
|
+
export const afterSourceEdit = (state: DevsysState): DevsysState =>
|
|
30
|
+
state.phase === "delivering" ? { ...state, phase: "implementing" } : state;
|
|
31
|
+
|
|
32
|
+
/** Back to idle: the slice, its sizing and its slice-scoped departures are done. Review history is kept. */
|
|
33
|
+
export function closeSlice(state: DevsysState): DevsysState {
|
|
34
|
+
const { activeSlice, sizing: _sizing, ...rest } = state;
|
|
35
|
+
return {
|
|
36
|
+
...rest,
|
|
37
|
+
phase: "idle",
|
|
38
|
+
openDepartures: state.openDepartures.filter(
|
|
39
|
+
(d) => d.scope.kind !== "slice" || d.scope.slice !== activeSlice,
|
|
40
|
+
),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A recorded `review.unsatisfied` departure covers this slice, so no review is owed before it ships. */
|
|
45
|
+
export const reviewWaived = (state: DevsysState): boolean =>
|
|
46
|
+
state.openDepartures.some(
|
|
47
|
+
(d) =>
|
|
48
|
+
d.gate === "review.unsatisfied" &&
|
|
49
|
+
(d.scope.kind === "session" ||
|
|
50
|
+
(d.scope.kind === "slice" && d.scope.slice === state.activeSlice)),
|
|
51
|
+
);
|
|
@@ -194,7 +194,8 @@ async function reviewNeeds(
|
|
|
194
194
|
command: string,
|
|
195
195
|
): Promise<Need[]> {
|
|
196
196
|
const { phase, activeSlice } = deps.state.get();
|
|
197
|
-
|
|
197
|
+
const inFlight = phase === "implementing" || phase === "reviewing" || phase === "delivering";
|
|
198
|
+
if (!inFlight || activeSlice === undefined) return [];
|
|
198
199
|
const snap = await snapshotDiff(deps.exec, ctx.cwd, "HEAD");
|
|
199
200
|
// An unreadable diff cannot be compared; the gate asks for a departure rather than guessing.
|
|
200
201
|
const gap = reviewGap(
|
|
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { type ExtensionAPI, isToolCallEventType } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { afterSourceEdit } from "../core/lifecycle.ts";
|
|
5
6
|
import { classifyPath } from "../core/path-class.ts";
|
|
6
7
|
import { normalizeRepoPath } from "../core/test-paths.ts";
|
|
7
8
|
import type { SessionState } from "../state/session-state.ts";
|
|
@@ -57,7 +58,7 @@ const JUDGED_EXEMPTIONS =
|
|
|
57
58
|
"straightforward CI scripting, a simple dev-environment utility, or a behaviour-preserving refactor with green coverage";
|
|
58
59
|
|
|
59
60
|
/**
|
|
60
|
-
* Soft gate `tdd.red-first`: while implementing, production source is edited only after a failing
|
|
61
|
+
* Soft gate `tdd.red-first`: while a slice is in flight (implementing, reviewing, delivering), production source is edited only after a failing
|
|
61
62
|
* test run has been observed. Test, docs, config and generated files are exempt by path; the
|
|
62
63
|
* judged exemptions go through one recorded departure, which covers its whole slice.
|
|
63
64
|
*/
|
|
@@ -69,11 +70,13 @@ export function registerRedFirstGuard(deps: RedFirstGuardDeps): void {
|
|
|
69
70
|
deps.pi.on("tool_call", (event, ctx) => {
|
|
70
71
|
if (!(isToolCallEventType("edit", event) || isToolCallEventType("write", event)))
|
|
71
72
|
return undefined;
|
|
72
|
-
const { phase,
|
|
73
|
-
if (phase !== "implementing"
|
|
74
|
-
|
|
73
|
+
const { phase, activeSlice } = deps.state.get();
|
|
74
|
+
if (phase !== "implementing" && phase !== "reviewing" && phase !== "delivering")
|
|
75
|
+
return undefined;
|
|
75
76
|
const path = normalizeRepoPath(ctx.cwd, event.input.path, homedir());
|
|
76
77
|
if (classifyPath(path) !== "source") return undefined;
|
|
78
|
+
const { lastTestRun } = deps.state.get();
|
|
79
|
+
if (lastTestRun !== undefined && lastTestRun.exitCode !== 0) return undefined;
|
|
77
80
|
if (touchesInlineTest(event.input, () => existing(ctx.cwd, path))) return undefined;
|
|
78
81
|
if (departure.covers()) return undefined;
|
|
79
82
|
const seen =
|
|
@@ -90,4 +93,17 @@ export function registerRedFirstGuard(deps: RedFirstGuardDeps): void {
|
|
|
90
93
|
`departure covers the rest of the ${scope}.`,
|
|
91
94
|
};
|
|
92
95
|
});
|
|
96
|
+
|
|
97
|
+
// The review covered the code as it was: source that was really written puts the slice back to implementing.
|
|
98
|
+
deps.pi.on("tool_result", (event, ctx) => {
|
|
99
|
+
if (event.isError || (event.toolName !== "edit" && event.toolName !== "write"))
|
|
100
|
+
return undefined;
|
|
101
|
+
if (deps.state.get().phase !== "delivering") return undefined;
|
|
102
|
+
const given = (event.input as { path?: unknown }).path;
|
|
103
|
+
if (typeof given !== "string") return undefined;
|
|
104
|
+
if (classifyPath(normalizeRepoPath(ctx.cwd, given, homedir())) === "source") {
|
|
105
|
+
deps.state.update(afterSourceEdit);
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
});
|
|
93
109
|
}
|
|
@@ -60,12 +60,11 @@ async function declinedToReplace(
|
|
|
60
60
|
before: DevsysState,
|
|
61
61
|
): Promise<string | undefined> {
|
|
62
62
|
const { activeSlice, phase } = before;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
63
|
+
const inFlight = phase === "implementing" || phase === "reviewing" || phase === "delivering";
|
|
64
|
+
if (activeSlice === undefined || !inFlight) return undefined;
|
|
66
65
|
const go = await ctx.ui.confirm(
|
|
67
66
|
"Slice already in flight",
|
|
68
|
-
`Slice "${activeSlice}" is still ${phase}. Its uncommitted changes would
|
|
67
|
+
`Slice "${activeSlice}" is still ${phase}. Its uncommitted or unpushed changes would ship under the new slice's rules. Start new work anyway?`,
|
|
69
68
|
);
|
|
70
69
|
return go ? undefined : `Intake cancelled; slice "${activeSlice}" is unchanged.`;
|
|
71
70
|
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ExtensionAPI,
|
|
3
|
+
type ExtensionContext,
|
|
4
|
+
isBashToolResult,
|
|
5
|
+
type ToolDefinition,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { type Static, Type } from "typebox";
|
|
8
|
+
import { extractCommits } from "../core/commit-command.ts";
|
|
9
|
+
import type { Exec } from "../core/exec.ts";
|
|
10
|
+
import { closeSlice, reviewWaived } from "../core/lifecycle.ts";
|
|
11
|
+
import { pushTargets } from "../core/push-command.ts";
|
|
12
|
+
import { isSatisfied } from "../core/review.ts";
|
|
13
|
+
import { reviewOf } from "../core/review-flow.ts";
|
|
14
|
+
import type { DevsysState } from "../core/types.ts";
|
|
15
|
+
import { loadConfig } from "../state/config.ts";
|
|
16
|
+
import type { SessionState } from "../state/session-state.ts";
|
|
17
|
+
|
|
18
|
+
const SLICE_ENTRY_TYPE = "devsys-slice";
|
|
19
|
+
|
|
20
|
+
export type SliceCloseDeps = { pi: ExtensionAPI; state: SessionState; exec: Exec };
|
|
21
|
+
|
|
22
|
+
const IN_FLIGHT = new Set<DevsysState["phase"]>(["implementing", "reviewing", "delivering"]);
|
|
23
|
+
|
|
24
|
+
/** True when `git status` shows nothing to commit; `undefined` when git could not say. */
|
|
25
|
+
async function treeIsClean(exec: Exec, cwd: string): Promise<boolean | undefined> {
|
|
26
|
+
const r = await exec("git", ["status", "--porcelain"], { cwd, timeout: 15_000 });
|
|
27
|
+
return r.code === 0 ? r.stdout.trim() === "" : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Commits on HEAD that no branch of `remote` has; works without an upstream, `undefined` when git could not say. */
|
|
31
|
+
async function unpushedCommits(
|
|
32
|
+
exec: Exec,
|
|
33
|
+
cwd: string,
|
|
34
|
+
remote: string,
|
|
35
|
+
): Promise<number | undefined> {
|
|
36
|
+
const r = await exec("git", ["rev-list", "--count", "HEAD", "--not", `--remotes=${remote}`], {
|
|
37
|
+
cwd,
|
|
38
|
+
timeout: 15_000,
|
|
39
|
+
});
|
|
40
|
+
const n = Number(r.stdout.trim());
|
|
41
|
+
return r.code === 0 && Number.isInteger(n) ? n : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** No review is owed before this slice ships: its review is satisfied, or a departure waives it. */
|
|
45
|
+
function reviewCleared(state: DevsysState): boolean {
|
|
46
|
+
if (state.activeSlice === undefined) return false;
|
|
47
|
+
const review = reviewOf(state, state.activeSlice);
|
|
48
|
+
return reviewWaived(state) || (review !== undefined && isSatisfied(review));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What delivered the slice: a commit when nothing leaves the machine, otherwise a push. */
|
|
52
|
+
const shipped = (mode: string, command: string): boolean =>
|
|
53
|
+
mode === "local-only" ? extractCommits(command).length > 0 : pushTargets(command).length > 0;
|
|
54
|
+
|
|
55
|
+
const closedMessage = (slice: string, how: string): string =>
|
|
56
|
+
`Slice ${slice} ${how}. Phase: idle; the next request starts new work with devsys_intake.`;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Closes the slice when it ships: in any in-flight phase whose review is satisfied (`delivering`, or a session
|
|
60
|
+
* saved before the life cycle existed) or waived by a recorded departure, a successful push (trunk, pull-request) or commit (local-only) that
|
|
61
|
+
* leaves a clean working tree. CI is not awaited; the push guard already refuses to build on a red trunk.
|
|
62
|
+
*/
|
|
63
|
+
export function registerSliceClose(deps: SliceCloseDeps): void {
|
|
64
|
+
deps.pi.on("tool_result", async (event, ctx) => {
|
|
65
|
+
if (!isBashToolResult(event) || event.isError) return undefined;
|
|
66
|
+
const state = deps.state.get();
|
|
67
|
+
const { activeSlice, phase } = state;
|
|
68
|
+
if (activeSlice === undefined || !IN_FLIGHT.has(phase)) return undefined;
|
|
69
|
+
if (!reviewCleared(state)) return undefined;
|
|
70
|
+
const config = await loadConfig(ctx.cwd);
|
|
71
|
+
if (!(config.ok && shipped(config.value.delivery.mode, String(event.input.command)))) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
if ((await treeIsClean(deps.exec, ctx.cwd)) !== true) return undefined;
|
|
75
|
+
// A tags-only push, or a push of another branch, leaves this slice's commits where they were.
|
|
76
|
+
if (config.value.delivery.mode !== "local-only") {
|
|
77
|
+
if ((await unpushedCommits(deps.exec, ctx.cwd, config.value.delivery.remote)) !== 0)
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
// The checks above awaited git; close only the slice they were about.
|
|
81
|
+
if (deps.state.get().activeSlice !== activeSlice) return undefined;
|
|
82
|
+
deps.state.update(closeSlice);
|
|
83
|
+
deps.pi.sendMessage(
|
|
84
|
+
{
|
|
85
|
+
customType: SLICE_ENTRY_TYPE,
|
|
86
|
+
content: closedMessage(activeSlice, "was delivered"),
|
|
87
|
+
display: true,
|
|
88
|
+
},
|
|
89
|
+
{ triggerTurn: false },
|
|
90
|
+
);
|
|
91
|
+
return undefined;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const Parameters = Type.Object({
|
|
96
|
+
abandon: Type.Optional(
|
|
97
|
+
Type.Boolean({
|
|
98
|
+
description:
|
|
99
|
+
"Close the slice without delivering it. Needs a reason; the working tree is left as it is.",
|
|
100
|
+
}),
|
|
101
|
+
),
|
|
102
|
+
reason: Type.Optional(Type.String({ description: "Why the slice is abandoned (with abandon)." })),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const reply = (text: string, isError = false) => ({
|
|
106
|
+
content: [{ type: "text" as const, text }],
|
|
107
|
+
details: undefined,
|
|
108
|
+
isError,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/** Another slice became active while this one waited on the user or on git: leave it alone. */
|
|
112
|
+
const changedMeanwhile = (slice: string) =>
|
|
113
|
+
reply(`Slice ${slice} is no longer the active slice; nothing was closed.`, true);
|
|
114
|
+
|
|
115
|
+
/** Why a slice cannot be finished yet, or `undefined` when it is reviewed, clean and pushed. */
|
|
116
|
+
async function finishBlocker(deps: Pick<SliceCloseDeps, "state" | "exec">, cwd: string) {
|
|
117
|
+
const state = deps.state.get();
|
|
118
|
+
const { activeSlice } = state;
|
|
119
|
+
if (activeSlice === undefined) return undefined;
|
|
120
|
+
if (!reviewCleared(state)) {
|
|
121
|
+
return `slice ${activeSlice} has no satisfied review: finish the review rounds (devsys_review_start), or record a review.unsatisfied departure`;
|
|
122
|
+
}
|
|
123
|
+
const clean = await treeIsClean(deps.exec, cwd);
|
|
124
|
+
if (clean !== true) {
|
|
125
|
+
return clean === false
|
|
126
|
+
? "there are uncommitted changes: commit them first"
|
|
127
|
+
: "git could not report the working tree status";
|
|
128
|
+
}
|
|
129
|
+
const config = await loadConfig(cwd);
|
|
130
|
+
if (!config.ok) return `cannot read the delivery mode: ${config.error.message}`;
|
|
131
|
+
if (config.value.delivery.mode === "local-only") return undefined;
|
|
132
|
+
const ahead = await unpushedCommits(deps.exec, cwd, config.value.delivery.remote);
|
|
133
|
+
if (ahead === undefined) {
|
|
134
|
+
return "git could not say whether the commits are pushed";
|
|
135
|
+
}
|
|
136
|
+
return ahead > 0 ? `${ahead} commit(s) are not pushed yet: push first` : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* `devsys_finish_slice`: the explicit way to close a slice. The push guard closes a delivered slice by itself;
|
|
141
|
+
* this covers what it cannot see (a push made outside pi, a slice that is dropped) and checks the same facts.
|
|
142
|
+
*/
|
|
143
|
+
export function createFinishSliceTool(
|
|
144
|
+
deps: Pick<SliceCloseDeps, "state" | "exec">,
|
|
145
|
+
): ToolDefinition<typeof Parameters> {
|
|
146
|
+
return {
|
|
147
|
+
name: "devsys_finish_slice",
|
|
148
|
+
label: "Finish slice",
|
|
149
|
+
description:
|
|
150
|
+
"Close the active slice and return to idle. Checks that its review is satisfied (or waived by a recorded departure), the tree is clean and the commits are pushed. With abandon and a reason it drops the slice without delivering it, after the user confirms.",
|
|
151
|
+
promptSnippet: "Close the active slice once it is delivered, or abandon it",
|
|
152
|
+
parameters: Parameters,
|
|
153
|
+
exposure: "model-only",
|
|
154
|
+
async execute(
|
|
155
|
+
_id,
|
|
156
|
+
params: Static<typeof Parameters>,
|
|
157
|
+
_signal,
|
|
158
|
+
_onUpdate,
|
|
159
|
+
ctx: ExtensionContext,
|
|
160
|
+
) {
|
|
161
|
+
const { activeSlice } = deps.state.get();
|
|
162
|
+
if (activeSlice === undefined) {
|
|
163
|
+
return reply("There is no active slice to finish. Start work with devsys_intake.", true);
|
|
164
|
+
}
|
|
165
|
+
if (params.abandon === true) {
|
|
166
|
+
const reason = params.reason?.trim() ?? "";
|
|
167
|
+
if (reason === "") return reply("abandoning a slice needs a reason: pass `reason`.", true);
|
|
168
|
+
// Idle switches the review and red-first gates off, so only the user may drop an unfinished slice.
|
|
169
|
+
if (!ctx.hasUI) {
|
|
170
|
+
return reply(
|
|
171
|
+
`Abandoning slice ${activeSlice} needs the user's confirmation and there is no interactive session; ask the user to run it.`,
|
|
172
|
+
true,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
const go = await ctx.ui.confirm(
|
|
176
|
+
"Abandon slice?",
|
|
177
|
+
`Slice "${activeSlice}" would be closed without being delivered (${reason}). Its review and red-first gates stop applying; the working tree is left as it is.`,
|
|
178
|
+
);
|
|
179
|
+
if (!go) return reply(`Abandon declined; slice ${activeSlice} is unchanged.`, true);
|
|
180
|
+
if (deps.state.get().activeSlice !== activeSlice) return changedMeanwhile(activeSlice);
|
|
181
|
+
deps.state.update(closeSlice);
|
|
182
|
+
return reply(
|
|
183
|
+
`${closedMessage(activeSlice, `was abandoned (${reason})`)} The working tree was not touched.`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const blocker = await finishBlocker(deps, ctx.cwd);
|
|
187
|
+
if (blocker !== undefined) {
|
|
188
|
+
return reply(`Slice ${activeSlice} is not finished: ${blocker}.`, true);
|
|
189
|
+
}
|
|
190
|
+
if (deps.state.get().activeSlice !== activeSlice) return changedMeanwhile(activeSlice);
|
|
191
|
+
deps.state.update(closeSlice);
|
|
192
|
+
return reply(closedMessage(activeSlice, "is finished"));
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { type Static, Type } from "typebox";
|
|
3
3
|
import type { Exec } from "../core/exec.ts";
|
|
4
|
+
import { afterReviewRound } from "../core/lifecycle.ts";
|
|
4
5
|
import { resolveSlot } from "../core/models.ts";
|
|
5
6
|
import type { Lens } from "../core/review.ts";
|
|
6
7
|
import {
|
|
@@ -181,6 +182,7 @@ async function startRound(
|
|
|
181
182
|
const existing = reviewOf(deps.state.get(), p.slice) ?? startReview(p.slice, required);
|
|
182
183
|
const review: ReviewState = { ...existing, required };
|
|
183
184
|
deps.state.update((s) => upsertReview(s, review));
|
|
185
|
+
deps.state.update((s) => afterReviewRound(s, nextAction(review, p.snap.digest), p.slice));
|
|
184
186
|
const refusal = startRefusal(review, p);
|
|
185
187
|
if (refusal !== undefined) return refusal;
|
|
186
188
|
|
|
@@ -345,6 +347,7 @@ async function recordRound(
|
|
|
345
347
|
},
|
|
346
348
|
);
|
|
347
349
|
deps.state.update((s) => upsertReview(s, review));
|
|
350
|
+
deps.state.update((s) => afterReviewRound(s, nextAction(review, p.snap.digest), p.slice));
|
|
348
351
|
return reply(recordSummary(review, findings, notes, p));
|
|
349
352
|
}
|
|
350
353
|
|