@jwilger/pi-development-system 0.83.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.
@@ -0,0 +1,43 @@
1
+ import type { ClassifierBoolQuestion } from "@earendil-works/pi-ai";
2
+ import { redactSecrets } from "../../core/redact.ts";
3
+ import { err, ok, type Result } from "../../core/result.ts";
4
+ import type { Jev, JevError } from "../client.ts";
5
+
6
+ /** At or above this, a diff with no ADR in it is flagged (gate `adr.missing`). */
7
+ export const ARCHITECTURE_THRESHOLD = 0.7;
8
+
9
+ export type ArchitectureInput = { diffStat: string; diff: string };
10
+
11
+ const DIFF_CLIP = 8000;
12
+
13
+ export const ARCHITECTURE_QUESTION: ClassifierBoolQuestion = {
14
+ type: "bool",
15
+ instructions:
16
+ "Read `diffStat` and `diff`. Does this change make an architecture-shaping decision that is hard to reverse: a new module boundary or layer, a new runtime dependency, a persisted data format or schema, a public protocol or interface contract, or a change to how components communicate?",
17
+ criteria: {
18
+ true: "The diff introduces or changes a boundary, dependency, data format or protocol that later code will build on and that would be costly to undo",
19
+ false:
20
+ "The diff is a bug fix, local refactor, test, documentation, formatting or feature work inside existing boundaries that is easy to change later",
21
+ },
22
+ };
23
+
24
+ // Bound the text before redacting (redaction cost grows with run length); keep margin so a secret at the cut is still seen whole.
25
+ const clip = (text: string, max: number): string =>
26
+ redactSecrets(text.slice(0, max * 2)).slice(0, max);
27
+
28
+ /** The probability that the diff shapes the architecture. */
29
+ export async function judgeArchitectureShaping(
30
+ jev: Jev,
31
+ input: ArchitectureInput,
32
+ ): Promise<Result<number, JevError>> {
33
+ const asked = await jev.ask(
34
+ { diffStat: clip(input.diffStat, 2000), diff: clip(input.diff, DIFF_CLIP) },
35
+ { architecture: ARCHITECTURE_QUESTION },
36
+ );
37
+ if (!asked.ok) return asked;
38
+ const answer = asked.value.architecture;
39
+ if (answer?.type !== "bool") {
40
+ return err({ kind: "provider", message: "missing architecture answer" });
41
+ }
42
+ return ok(answer.probability);
43
+ }
@@ -0,0 +1,64 @@
1
+ import type { ClassifierBoolQuestion } from "@earendil-works/pi-ai";
2
+ import { redactSecrets } from "../../core/redact.ts";
3
+ import { err, ok, type Result } from "../../core/result.ts";
4
+ import { PRODUCT_LENSES, type ProductLens } from "../../review/lens-review.ts";
5
+ import type { Jev, JevError } from "../client.ts";
6
+
7
+ const lensQuestion = (instructions: string, yes: string, no: string): ClassifierBoolQuestion => ({
8
+ type: "bool",
9
+ instructions,
10
+ criteria: { true: yes, false: no },
11
+ });
12
+
13
+ /** One narrow question per product lens: does this lens have something to say about the brief? */
14
+ export const PRODUCT_LENS_QUESTIONS: Readonly<Record<ProductLens, ClassifierBoolQuestion>> = {
15
+ cagan: lensQuestion(
16
+ "Read the `brief`. Does it commit to building a product or feature while leaving a value, usability, feasibility or viability risk unnamed or untested? A trivial copy, rename or maintenance change carries no such risk.",
17
+ "It proposes something to build and names no test or evidence for at least one of those risks",
18
+ "The change is trivial, or the brief names its risks and a test or evidence for them",
19
+ ),
20
+ torres: lensQuestion(
21
+ "Read the `brief`. Does it claim what customers need or want without citing discovery evidence (interviews, observed behaviour, usage data)? A brief that makes no customer claim does not count.",
22
+ "It asserts customer needs, demand or behaviour with no discovery evidence cited",
23
+ "Customer claims are backed by cited evidence, or the brief makes no customer claim",
24
+ ),
25
+ pichler: lensQuestion(
26
+ "Read the `brief`. Does it lack a measurable goal or metric that ties the work to a product vision or strategy? A trivial copy, rename or maintenance change needs none.",
27
+ "It proposes substantial work with no measurable goal or metric tied to a vision",
28
+ "It names a measurable goal or metric, or the change is trivial",
29
+ ),
30
+ perri: lensQuestion(
31
+ "Read the `brief`. Does it define success as shipping features or deliverables rather than as a measurable change for customers or the business? A typo fix, rename, dependency upgrade or other maintenance change is not a product bet and always answers false.",
32
+ "A product bet whose success is framed as delivering a list of features or a launch date, with no outcome measure",
33
+ "Success is a measurable outcome, or the change is maintenance (typo, rename, upgrade, refactor)",
34
+ ),
35
+ rumelt: lensQuestion(
36
+ "Read the `brief`. Does it state goals or ambitions without a diagnosis of the real challenge, a guiding policy and coherent actions? A trivial copy, rename or maintenance change needs no strategy.",
37
+ "It lists goals, ambitions or features with no diagnosis of the challenge and no guiding policy",
38
+ "It diagnoses the challenge and sets a policy and actions, or the change is trivial",
39
+ ),
40
+ };
41
+
42
+ const BRIEF_CLIP = 8000;
43
+
44
+ // Bound the text before redacting (redaction cost grows with run length); keep margin so a secret at the cut is still seen whole.
45
+ const clip = (text: string, max: number): string =>
46
+ redactSecrets(text.slice(0, max * 2)).slice(0, max);
47
+
48
+ /** Probability per product lens that it applies to the brief. */
49
+ export async function judgeProductLenses(
50
+ jev: Jev,
51
+ input: { brief: string },
52
+ ): Promise<Result<Record<ProductLens, number>, JevError>> {
53
+ const asked = await jev.ask({ brief: clip(input.brief, BRIEF_CLIP) }, PRODUCT_LENS_QUESTIONS);
54
+ if (!asked.ok) return asked;
55
+ const out = {} as Record<ProductLens, number>;
56
+ for (const lens of PRODUCT_LENSES) {
57
+ const answer = asked.value[lens];
58
+ if (answer?.type !== "bool" || !Number.isFinite(answer.probability)) {
59
+ return err({ kind: "provider", message: `missing or malformed answer for lens ${lens}` });
60
+ }
61
+ out[lens] = answer.probability;
62
+ }
63
+ return ok(out);
64
+ }
@@ -0,0 +1,43 @@
1
+ import type { ClassifierBoolQuestion } from "@earendil-works/pi-ai";
2
+ import { redactSecrets } from "../../core/redact.ts";
3
+ import { err, ok, type Result } from "../../core/result.ts";
4
+ import type { Jev, JevError } from "../client.ts";
5
+
6
+ /** At or above this, the brief is reported as carrying solution-level detail (a warning only). */
7
+ export const SOLUTION_DETAIL_THRESHOLD = 0.7;
8
+
9
+ export type SolutionDetailInput = { brief: string };
10
+
11
+ const BRIEF_CLIP = 8000;
12
+
13
+ export const SOLUTION_DETAIL_QUESTION: ClassifierBoolQuestion = {
14
+ type: "bool",
15
+ instructions:
16
+ "Read `brief`, a product brief. Does it specify the solution rather than the problem: named database tables or columns, API endpoints, class or module names, framework or library choices, or step-by-step implementation design?",
17
+ criteria: {
18
+ true: "The brief prescribes how the system is built (tables, endpoints, classes, libraries, internal design) instead of, or in addition to, the outcome, users and risks",
19
+ false:
20
+ "The brief describes outcomes, users, journeys, constraints and risks in product terms; any technical mention is a stated constraint or a name the users themselves use",
21
+ },
22
+ };
23
+
24
+ // Bound the text before redacting (redaction cost grows with run length); keep margin so a secret at the cut is still seen whole.
25
+ const clip = (text: string, max: number): string =>
26
+ redactSecrets(text.slice(0, max * 2)).slice(0, max);
27
+
28
+ /** The probability that the brief contains solution-level detail. */
29
+ export async function judgeSolutionDetail(
30
+ jev: Jev,
31
+ input: SolutionDetailInput,
32
+ ): Promise<Result<number, JevError>> {
33
+ const asked = await jev.ask(
34
+ { brief: clip(input.brief, BRIEF_CLIP) },
35
+ { solution: SOLUTION_DETAIL_QUESTION },
36
+ );
37
+ if (!asked.ok) return asked;
38
+ const answer = asked.value.solution;
39
+ if (answer?.type !== "bool") {
40
+ return err({ kind: "provider", message: "missing solution-detail answer" });
41
+ }
42
+ return ok(answer.probability);
43
+ }
@@ -0,0 +1,70 @@
1
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
4
+ import { type Static, Type } from "typebox";
5
+ import { adrFileName, nextAdrNumber, parseAdrTitle, renderAdr } from "./adr.ts";
6
+
7
+ const Parameters = Type.Object({
8
+ title: Type.String({ description: "The decision, in a few words (one line)." }),
9
+ });
10
+
11
+ const ADR_DIR = "docs/adr";
12
+ const TEMPLATE = "0000-template.md";
13
+
14
+ const reply = (text: string, isError = false) => ({
15
+ content: [{ type: "text" as const, text }],
16
+ details: undefined,
17
+ isError,
18
+ });
19
+
20
+ const message = (e: unknown): string => (e instanceof Error ? e.message : String(e));
21
+
22
+ /**
23
+ * `devsys_adr_new`: the next numbered ADR, filled from `docs/adr/0000-template.md`. Numbering and the
24
+ * file name are mechanical, so the model writes the decision and not the paperwork.
25
+ */
26
+ export function createAdrNewTool(deps: { now: () => Date }): ToolDefinition<typeof Parameters> {
27
+ return {
28
+ name: "devsys_adr_new",
29
+ // pi runs a turn's tool calls in parallel; two calls would read the same directory and pick the same number.
30
+ executionMode: "sequential",
31
+ label: "New ADR",
32
+ description:
33
+ "Create the next numbered ADR in docs/adr from its template and return the path to fill in. Use for a hard-to-reverse technical decision (a boundary, dependency, data format or protocol); product decisions go in the decision register.",
34
+ promptSnippet: "Create the next ADR from the template",
35
+ parameters: Parameters,
36
+ async execute(_id, params: Static<typeof Parameters>, _signal, _onUpdate, ctx) {
37
+ const title = parseAdrTitle(params.title);
38
+ if (!title.ok) return reply(title.error.message, true);
39
+ const dir = join(ctx.cwd, ADR_DIR);
40
+ let template: string;
41
+ try {
42
+ template = await readFile(join(dir, TEMPLATE), "utf8");
43
+ } catch {
44
+ return reply(
45
+ `${ADR_DIR}/${TEMPLATE} is missing, so there is nothing to start from; add the template first.`,
46
+ true,
47
+ );
48
+ }
49
+ try {
50
+ const number = nextAdrNumber(await readdir(dir));
51
+ const name = adrFileName(number, title.value);
52
+ const date = deps.now().toISOString().slice(0, 10);
53
+ await mkdir(dir, { recursive: true });
54
+ // `wx`: fail rather than overwrite an ADR with the same file name (calls from other sessions).
55
+ await writeFile(
56
+ join(dir, name),
57
+ renderAdr(template, { number, title: title.value, date }),
58
+ {
59
+ flag: "wx",
60
+ },
61
+ );
62
+ return reply(
63
+ `Created ${ADR_DIR}/${name} (status proposed). Fill in Context, Decision, Consequences, Alternatives and Revisit when. Leave the status proposed until the user agrees, then set it to accepted.`,
64
+ );
65
+ } catch (e) {
66
+ return reply(`could not create the ADR: ${message(e)}`, true);
67
+ }
68
+ },
69
+ };
70
+ }
@@ -0,0 +1,46 @@
1
+ import { err, ok, type Result } from "../core/result.ts";
2
+ import { type ParseError, parseError } from "../core/types.ts";
3
+
4
+ /** `0004-prompt-cache-safe-channels.md` → 4. The template is `0000` and the README has no number. */
5
+ const NUMBERED = /^(\d{4})-.+\.md$/;
6
+
7
+ /** One more than the highest ADR number present; gaps are not filled, so a number is never reused. */
8
+ export function nextAdrNumber(fileNames: readonly string[]): number {
9
+ const numbers = fileNames.flatMap((name) => {
10
+ const digits = NUMBERED.exec(name)?.[1];
11
+ return digits === undefined ? [] : [Number.parseInt(digits, 10)];
12
+ });
13
+ return Math.max(0, ...numbers) + 1;
14
+ }
15
+
16
+ export const slugify = (title: string): string =>
17
+ title
18
+ .toLowerCase()
19
+ .replace(/[^a-z0-9]+/g, "-")
20
+ .replace(/^-+|-+$/g, "");
21
+
22
+ /** A one-line title with something to slug from; a line break could add lines to the document. */
23
+ export function parseAdrTitle(input: string): Result<string, ParseError> {
24
+ const title = input.trim();
25
+ if (/[\r\n]/.test(title)) return err(parseError("an ADR title is a single line"));
26
+ if (slugify(title) === "") {
27
+ return err(parseError("an ADR title needs at least one letter or digit"));
28
+ }
29
+ return ok(title);
30
+ }
31
+
32
+ const adrNumberText = (n: number): string => String(n).padStart(4, "0");
33
+
34
+ export const adrFileName = (n: number, title: string): string =>
35
+ `${adrNumberText(n)}-${slugify(title)}.md`;
36
+
37
+ export type AdrInput = { number: number; title: string; date: string };
38
+
39
+ /** The template with its heading, status and date filled in; the sections are left for the author. */
40
+ export function renderAdr(template: string, input: AdrInput): string {
41
+ // Function replacers: a title is data, so `$&` and `$1` in it must not act as replacement patterns.
42
+ return template
43
+ .replace(/^# ADR NNNN: .*$/m, () => `# ADR ${adrNumberText(input.number)}: ${input.title}`)
44
+ .replace(/^- \*\*Status:\*\* .*$/m, () => "- **Status:** proposed")
45
+ .replace(/^- \*\*Date:\*\* .*$/m, () => `- **Date:** ${input.date}`);
46
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Brief lint (plan I9.5): a brief states the outcome, the people and the risks; endpoints, tables and
3
+ * class names are solution detail that belongs in an ADR or the architecture. This is the
4
+ * deterministic half (regex markers); the Jev half lives in `src/jev/questions/solution-detail.ts`.
5
+ * Findings are warnings: a brief may legitimately quote a constraint that looks technical.
6
+ */
7
+
8
+ type BriefFindingKind = "endpoint" | "table" | "class" | "path";
9
+
10
+ export type BriefFinding = {
11
+ kind: BriefFindingKind;
12
+ /** 1-based line in the brief. */
13
+ line: number;
14
+ /** The text that matched. */
15
+ match: string;
16
+ message: string;
17
+ };
18
+
19
+ type Rule = { kind: BriefFindingKind; pattern: RegExp; what: string };
20
+
21
+ const RULES: readonly Rule[] = [
22
+ {
23
+ kind: "endpoint",
24
+ pattern: /\b(?:GET|POST|PUT|PATCH|DELETE)\s+\/[\w\-/{}:.]+/g,
25
+ what: "an HTTP endpoint",
26
+ },
27
+ {
28
+ kind: "table",
29
+ pattern: /\bCREATE\s+TABLE\b|`\w+`\s+table\b|\btable\s+`\w+`/gi,
30
+ what: "a database table",
31
+ },
32
+ {
33
+ kind: "class",
34
+ pattern:
35
+ /\b(?:(?:class|interface)\s+[A-Z]\w+|[A-Z][a-z0-9]+(?:[A-Z][a-z0-9]+)*(?:Service|Repository|Controller|Gateway|Manager|Handler|Factory|Client))\b/g,
36
+ what: "a class or service name",
37
+ },
38
+ {
39
+ kind: "path",
40
+ pattern: /\b(?:src|lib|test|app|packages)\/[\w\-./]+\.\w+/g,
41
+ what: "a source file path",
42
+ },
43
+ ];
44
+
45
+ const advice = (what: string): string =>
46
+ `${what} is solution detail; keep the brief to outcome, users and risks and record this in an ADR (devsys_adr_new) or the architecture notes`;
47
+
48
+ /** The brief with fenced code blocks blanked, keeping line numbers: an example in a fence is quoted, not specified. */
49
+ function withoutFences(text: string): string[] {
50
+ let inFence = false;
51
+ return text.split("\n").map((line) => {
52
+ if (/^\s*```/.test(line)) {
53
+ inFence = !inFence;
54
+ return "";
55
+ }
56
+ return inFence ? "" : line;
57
+ });
58
+ }
59
+
60
+ export function lintBrief(text: string): BriefFinding[] {
61
+ return withoutFences(text).flatMap((line, i) =>
62
+ RULES.flatMap((rule) =>
63
+ [...line.matchAll(rule.pattern)].map((m) => ({
64
+ kind: rule.kind,
65
+ line: i + 1,
66
+ match: m[0],
67
+ message: advice(rule.what),
68
+ })),
69
+ ),
70
+ );
71
+ }
@@ -60,12 +60,11 @@ async function declinedToReplace(
60
60
  before: DevsysState,
61
61
  ): Promise<string | undefined> {
62
62
  const { activeSlice, phase } = before;
63
- if (activeSlice === undefined || (phase !== "implementing" && phase !== "reviewing")) {
64
- return undefined;
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 be committed under the new slice's rules. Start new work anyway?`,
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
+ }