@jwilger/pi-development-system 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,12 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { appendContextTail, renderContextTail } from "../src/context/context-tail.ts";
3
4
  import { renderStatus, renderStatusLine, STATUS_KEY } from "../src/context/status.ts";
4
5
  import { applyPromptSection } from "../src/context/system-prompt.ts";
6
+ import { createApprovalStore } from "../src/gates/approvals.ts";
7
+ import { registerGitGuard } from "../src/gates/git-guard.ts";
8
+ import { createRecordDepartureTool } from "../src/gates/record-departure-tool.ts";
9
+ import { createRequestApprovalTool } from "../src/gates/request-approval-tool.ts";
5
10
  import { createSessionState } from "../src/state/session-state.ts";
6
11
 
7
12
  const nonNegotiables = readFileSync(
@@ -12,6 +17,7 @@ const nonNegotiables = readFileSync(
12
17
  /** Composition root: wires modules, holds no logic. */
13
18
  export function createDevelopmentSystem(pi: ExtensionAPI) {
14
19
  const state = createSessionState(pi);
20
+ const approvals = createApprovalStore(pi);
15
21
 
16
22
  let lastCtx: ExtensionContext | undefined;
17
23
 
@@ -21,11 +27,11 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
21
27
 
22
28
  const rebuild = (ctx: ExtensionContext) => {
23
29
  lastCtx = ctx;
24
- state.rebuildFrom(
25
- ctx.sessionManager
26
- .getBranch()
27
- .flatMap((e) => (e.type === "custom" ? [{ customType: e.customType, data: e.data }] : [])),
28
- );
30
+ const entries = ctx.sessionManager
31
+ .getBranch()
32
+ .flatMap((e) => (e.type === "custom" ? [{ customType: e.customType, data: e.data }] : []));
33
+ state.rebuildFrom(entries);
34
+ approvals.rebuildFrom(entries);
29
35
  refreshStatus();
30
36
  };
31
37
 
@@ -37,6 +43,15 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
37
43
  applyPromptSection(event, state.get(), nonNegotiables);
38
44
  });
39
45
 
46
+ pi.on("context", (event) => {
47
+ const messages = appendContextTail(event.messages, renderContextTail(state.get()), Date.now());
48
+ return messages === undefined ? undefined : { messages };
49
+ });
50
+
51
+ registerGitGuard({ pi, approvals });
52
+ pi.registerTool(createRecordDepartureTool({ pi, state }));
53
+ pi.registerTool(createRequestApprovalTool({ pi, approvals }));
54
+
40
55
  pi.registerCommand("devsys-status", {
41
56
  description: "Show development-system phase, sizing, slice, departures and Jev status",
42
57
  handler: async (_args, ctx) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
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"
@@ -32,11 +32,12 @@
32
32
  ]
33
33
  },
34
34
  "dependencies": {
35
+ "shell-quote": "^1.12.0",
35
36
  "smol-toml": "^1.4.0"
36
37
  },
37
38
  "peerDependencies": {
38
- "@earendil-works/pi-ai": "*",
39
39
  "@earendil-works/pi-agent-core": "*",
40
+ "@earendil-works/pi-ai": "*",
40
41
  "@earendil-works/pi-coding-agent": "*",
41
42
  "@earendil-works/pi-tui": "*",
42
43
  "typebox": "*"
@@ -46,6 +47,7 @@
46
47
  },
47
48
  "devDependencies": {
48
49
  "@types/node": "^26.6.4",
50
+ "@types/shell-quote": "^1.7.5",
49
51
  "@typesafe-ai/sdk": "^0.6.0",
50
52
  "typescript": "^7.0.2"
51
53
  },
@@ -0,0 +1,37 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import type { Departure, DevsysState } from "../core/types.ts";
3
+
4
+ const MAX_LINES = 25;
5
+ const REMINDER =
6
+ "Departures: call devsys_record_departure before acting against a default; hard stops need the user.";
7
+
8
+ const scopeLabel = (d: Departure): string =>
9
+ d.scope.kind === "slice" ? `slice ${d.scope.slice}` : d.scope.kind;
10
+
11
+ /** Small, cache-friendly reminder appended at the end of the context; undefined when nothing to say. */
12
+ export function renderContextTail(state: DevsysState): string | undefined {
13
+ if (state.phase === "idle" && state.openDepartures.length === 0) return undefined;
14
+ const head = [
15
+ "[development-system]",
16
+ `phase: ${state.phase} · slice: ${state.activeSlice ?? "none"}`,
17
+ `Jev: ${state.jev}`,
18
+ ];
19
+ const tailLines = [REMINDER];
20
+ const room = MAX_LINES - head.length - tailLines.length - 1;
21
+ const lines = state.openDepartures.map((d) => `- ${d.gate} — ${d.chosen} (${scopeLabel(d)})`);
22
+ const shown =
23
+ lines.length <= room
24
+ ? lines
25
+ : [...lines.slice(0, room - 1), `- …and ${lines.length - room + 1} more`];
26
+ return [...head, "Open departures:", ...shown, ...tailLines].join("\n");
27
+ }
28
+
29
+ /** Appends the tail as a user-role message at the end of `messages`. */
30
+ export function appendContextTail(
31
+ messages: ReadonlyArray<AgentMessage>,
32
+ tail: string | undefined,
33
+ now: number,
34
+ ): AgentMessage[] | undefined {
35
+ if (tail === undefined) return undefined;
36
+ return [...messages, { role: "user", content: tail, timestamp: now }];
37
+ }
@@ -0,0 +1,96 @@
1
+ import {
2
+ type Departure,
3
+ type DepartureId,
4
+ type DepartureScope,
5
+ type GateId,
6
+ isParseError,
7
+ type ParseError,
8
+ parseError,
9
+ parseGateId,
10
+ type SliceRef,
11
+ } from "./types.ts";
12
+
13
+ export type { Departure } from "./types.ts";
14
+
15
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
16
+ typeof v === "object" && v !== null && !Array.isArray(v);
17
+
18
+ function parseScope(input: unknown): DepartureScope | ParseError {
19
+ if (!isRecord(input)) return parseError("scope must be an object");
20
+ const { kind, slice, toolCallId } = input;
21
+ if (kind === "session") return { kind: "session" };
22
+ if (kind === "slice" && typeof slice === "string") {
23
+ return { kind: "slice", slice: slice as SliceRef };
24
+ }
25
+ if (kind === "once" && typeof toolCallId === "string") return { kind: "once", toolCallId };
26
+ return parseError(`invalid scope: ${JSON.stringify(input)}`);
27
+ }
28
+
29
+ const TEXT_FIELDS = ["id", "default", "chosen", "why", "costIfWrong", "recordedAt"] as const;
30
+
31
+ /** Boundary parse for a Departure (persisted entries, tool input). */
32
+ export function parseDeparture(input: unknown): Departure | ParseError {
33
+ if (!isRecord(input)) return parseError("departure must be an object");
34
+ for (const field of TEXT_FIELDS) {
35
+ if (typeof input[field] !== "string") return parseError(`departure.${field} must be a string`);
36
+ }
37
+ const { id, gate: rawGate, tier, approver, revisitWhen } = input;
38
+ const gate = parseGateId(String(rawGate));
39
+ if (isParseError(gate)) return gate;
40
+ if (tier !== "soft" && tier !== "hard") return parseError(`departure.tier invalid: ${tier}`);
41
+ if (approver !== "agent" && approver !== "user") {
42
+ return parseError(`departure.approver invalid: ${approver}`);
43
+ }
44
+ if (revisitWhen !== undefined && typeof revisitWhen !== "string") {
45
+ return parseError("departure.revisitWhen must be a string");
46
+ }
47
+ const scope = parseScope(input.scope);
48
+ if (isParseError(scope)) return scope;
49
+ return {
50
+ id: id as DepartureId,
51
+ gate,
52
+ tier,
53
+ default: String(input.default),
54
+ chosen: String(input.chosen),
55
+ why: String(input.why),
56
+ costIfWrong: String(input.costIfWrong),
57
+ approver,
58
+ scope,
59
+ ...(revisitWhen !== undefined ? { revisitWhen } : {}),
60
+ recordedAt: String(input.recordedAt),
61
+ };
62
+ }
63
+
64
+ function renderScope(scope: DepartureScope): string {
65
+ switch (scope.kind) {
66
+ case "slice":
67
+ return `slice \`${scope.slice}\``;
68
+ case "session":
69
+ return "session";
70
+ case "once":
71
+ return `once (${scope.toolCallId})`;
72
+ }
73
+ }
74
+
75
+ /** Decision-log entry (plan Appendix A). Ends with a newline. */
76
+ export function renderDepartureMarkdown(d: Departure): string {
77
+ const revisit = d.revisitWhen !== undefined ? ` · **Revisit when:** ${d.revisitWhen}` : "";
78
+ return [
79
+ `### ${d.recordedAt} · ${d.gate} · ${d.tier} · ${d.approver}`,
80
+ `- **Default:** ${d.default}`,
81
+ `- **Chosen:** ${d.chosen}`,
82
+ `- **Why:** ${d.why}`,
83
+ `- **Cost if wrong:** ${d.costIfWrong}`,
84
+ `- **Scope:** ${renderScope(d.scope)}${revisit}`,
85
+ "",
86
+ ].join("\n");
87
+ }
88
+
89
+ /** The first departure recorded for `gate` at or before `now` (ISO-8601 UTC strings compare lexically). */
90
+ export function matchesPending(
91
+ departures: ReadonlyArray<Departure>,
92
+ gate: GateId,
93
+ now: string,
94
+ ): Departure | undefined {
95
+ return departures.find((d) => d.gate === gate && d.recordedAt <= now);
96
+ }
@@ -0,0 +1,42 @@
1
+ import type { Tier } from "./types.ts";
2
+
3
+ export type GateInfo = { readonly tier: Tier; readonly default: string };
4
+
5
+ /** Registered gate ids (plan Appendix A); grows per increment. Qualified ids use their base entry. */
6
+ export const GATES: Readonly<Record<string, GateInfo>> = {
7
+ "git.history-rewrite": { tier: "hard", default: "never rewrite published history" },
8
+ "git.force-push": { tier: "hard", default: "never force-push" },
9
+ "git.branch-delete-remote": { tier: "hard", default: "never delete remote branches" },
10
+ "git.destructive-reset": {
11
+ tier: "hard",
12
+ default: "never discard uncommitted work with reset --hard",
13
+ },
14
+ "git.no-verify": { tier: "hard", default: "never skip commit hooks" },
15
+ "tests.weaken": { tier: "soft", default: "never weaken, skip or delete tests to get green" },
16
+ "commit.rationale": { tier: "soft", default: "commit messages carry their rationale" },
17
+ "commit.mixed-change": {
18
+ tier: "soft",
19
+ default: "structural and behavioural changes go in separate commits",
20
+ },
21
+ "commit.forbidden-trailer": { tier: "soft", default: "no Co-Authored-By or AI trailers" },
22
+ "push.red-trunk": { tier: "soft", default: "do not push unrelated work onto a red trunk" },
23
+ "push.delivery-mode": { tier: "soft", default: "follow the repository's delivery mode" },
24
+ "tdd.red-first": { tier: "soft", default: "write a failing test before changing behaviour" },
25
+ "lints.suppression": { tier: "soft", default: "do not suppress lints without a reasoned note" },
26
+ "review.unsatisfied": { tier: "soft", default: "finish the fresh-context review before release" },
27
+ "scope.expansion": { tier: "soft", default: "stay within the agreed scope" },
28
+ "models.phase-mismatch": { tier: "soft", default: "use the model slot routed for this phase" },
29
+ "artifact.skipped": {
30
+ tier: "soft",
31
+ default: "produce the planning artifact for this size of work",
32
+ },
33
+ "adr.missing": { tier: "soft", default: "record an ADR for architecture-shaping decisions" },
34
+ };
35
+
36
+ /** Looks up a gate by id, ignoring any `:qualifier`. */
37
+ export function lookupGate(id: string): GateInfo | undefined {
38
+ const base = id.split(":")[0] ?? id;
39
+ return GATES[base];
40
+ }
41
+
42
+ export const gateIds = (): string[] => Object.keys(GATES);
@@ -0,0 +1,161 @@
1
+ import { parse } from "shell-quote";
2
+
3
+ export type GitIntent =
4
+ | "history-rewrite"
5
+ | "force-push"
6
+ | "branch-delete-remote"
7
+ | "destructive-reset"
8
+ | "no-verify"
9
+ | "ordinary"
10
+ | "unknown";
11
+
12
+ /** Highest severity first. */
13
+ const SEVERITY: ReadonlyArray<GitIntent> = [
14
+ "force-push",
15
+ "branch-delete-remote",
16
+ "history-rewrite",
17
+ "destructive-reset",
18
+ "no-verify",
19
+ "unknown",
20
+ "ordinary",
21
+ ];
22
+
23
+ const worst = (a: GitIntent, b: GitIntent): GitIntent =>
24
+ SEVERITY.indexOf(a) <= SEVERITY.indexOf(b) ? a : b;
25
+
26
+ /** Splits a command string on newlines that are outside quotes. */
27
+ function splitLines(command: string): string[] {
28
+ const lines: string[] = [];
29
+ let current = "";
30
+ let quote: "'" | '"' | undefined;
31
+ for (const ch of command) {
32
+ if (quote !== undefined) {
33
+ if (ch === quote) quote = undefined;
34
+ current += ch;
35
+ } else if (ch === "'" || ch === '"') {
36
+ quote = ch;
37
+ current += ch;
38
+ } else if (ch === "\n") {
39
+ lines.push(current);
40
+ current = "";
41
+ } else {
42
+ current += ch;
43
+ }
44
+ }
45
+ lines.push(current);
46
+ return lines;
47
+ }
48
+
49
+ /** Tokens of one line grouped into simple commands (operators separate segments). */
50
+ function segments(line: string): string[][] {
51
+ const result: string[][] = [[]];
52
+ for (const token of parse(line, (name) => `$${name}`)) {
53
+ if (typeof token === "string") result[result.length - 1]?.push(token);
54
+ else if ("pattern" in token) result[result.length - 1]?.push(token.pattern);
55
+ else if (!("comment" in token)) result.push([]);
56
+ }
57
+ return result.filter((s) => s.length > 0);
58
+ }
59
+
60
+ const WRAPPERS = new Set(["command", "sudo", "env", "time", "nohup", "exec", "builtin", "nice"]);
61
+ const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
62
+ const OPAQUE = new Set(["xargs", "ssh", "find", "parallel", "watch"]);
63
+ const GLOBAL_WITH_VALUE = new Set(["-c", "-C", "--git-dir", "--work-tree", "--namespace"]);
64
+ const HOOK_SKIP_ENV = new Set(["LEFTHOOK=0", "HUSKY=0"]);
65
+
66
+ const basename = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
67
+ const isAssignment = (t: string): boolean => /^[A-Za-z_][A-Za-z0-9_]*=/.test(t);
68
+
69
+ function classifyGitArgs(globals: string[], sub: string | undefined, args: string[]): GitIntent {
70
+ let intent: GitIntent = "ordinary";
71
+ if (globals.some((g) => /^core\.hooksPath=/.test(g))) intent = worst(intent, "no-verify");
72
+ if (args.includes("--no-verify")) intent = worst(intent, "no-verify");
73
+ switch (sub) {
74
+ case "push": {
75
+ const shortFlags = args.filter((a) => /^-[A-Za-z]+$/.test(a));
76
+ if (
77
+ args.some((a) => /^--(force|force-with-lease|force-if-includes|mirror)\b/.test(a)) ||
78
+ shortFlags.some((a) => a.includes("f")) ||
79
+ args.some((a) => /^\+\S/.test(a))
80
+ ) {
81
+ return worst(intent, "force-push");
82
+ }
83
+ if (
84
+ args.includes("--delete") ||
85
+ shortFlags.includes("-d") ||
86
+ args.some((a) => /^:\S/.test(a))
87
+ ) {
88
+ return worst(intent, "branch-delete-remote");
89
+ }
90
+ return intent;
91
+ }
92
+ case "commit":
93
+ if (args.includes("--amend")) return worst(intent, "history-rewrite");
94
+ if (args.some((a) => /^-[A-Za-z]*n[A-Za-z]*$/.test(a))) return worst(intent, "no-verify");
95
+ return intent;
96
+ case "rebase":
97
+ return args.some((a) => ["--abort", "--continue", "--skip", "--quit"].includes(a))
98
+ ? intent
99
+ : worst(intent, "history-rewrite");
100
+ case "filter-branch":
101
+ case "filter-repo":
102
+ return worst(intent, "history-rewrite");
103
+ case "reset":
104
+ return args.includes("--hard") ? worst(intent, "destructive-reset") : intent;
105
+ default:
106
+ return intent;
107
+ }
108
+ }
109
+
110
+ function classifySegment(tokens: string[]): GitIntent {
111
+ let hookSkip = false;
112
+ let i = 0;
113
+ for (;;) {
114
+ const t = tokens[i];
115
+ if (t === undefined) return "ordinary";
116
+ if (isAssignment(t)) {
117
+ if (HOOK_SKIP_ENV.has(t)) hookSkip = true;
118
+ i++;
119
+ } else if (WRAPPERS.has(t)) {
120
+ i++;
121
+ } else {
122
+ break;
123
+ }
124
+ }
125
+ const head = tokens[i] ?? "";
126
+ const rest = tokens.slice(i + 1);
127
+ const base = basename(head);
128
+ const withHookSkip = (intent: GitIntent): GitIntent =>
129
+ hookSkip ? worst(intent, "no-verify") : intent;
130
+ if (head.startsWith("$") || base === "$GIT") return "unknown";
131
+ if (SHELLS.has(base)) {
132
+ const c = rest.indexOf("-c");
133
+ const script = c >= 0 ? rest[c + 1] : undefined;
134
+ return script !== undefined ? classifyGitCommand(script) : "ordinary";
135
+ }
136
+ if (base === "eval") return classifyGitCommand(rest.join(" "));
137
+ if (OPAQUE.has(base)) return rest.some((t) => basename(t) === "git") ? "unknown" : "ordinary";
138
+ if (base !== "git") return "ordinary";
139
+ const globals: string[] = [];
140
+ let j = 0;
141
+ while (j < rest.length && (rest[j] ?? "").startsWith("-")) {
142
+ const opt = rest[j] ?? "";
143
+ if (GLOBAL_WITH_VALUE.has(opt)) {
144
+ globals.push(rest[j + 1] ?? "");
145
+ j += 2;
146
+ } else {
147
+ j++;
148
+ }
149
+ }
150
+ const sub = rest[j];
151
+ return withHookSkip(classifyGitArgs(globals, sub, rest.slice(j + 1)));
152
+ }
153
+
154
+ /** Classifies the most severe git intent in a shell command (deterministic fast path). */
155
+ export function classifyGitCommand(command: string): GitIntent {
156
+ let intent: GitIntent = "ordinary";
157
+ for (const line of splitLines(command)) {
158
+ for (const segment of segments(line)) intent = worst(intent, classifySegment(segment));
159
+ }
160
+ return intent;
161
+ }
@@ -0,0 +1,100 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { type Departure, parseDeparture } from "../core/departure.ts";
3
+ import { lookupGate } from "../core/gates.ts";
4
+ import { type GateId, isParseError } from "../core/types.ts";
5
+ import { appendDecision } from "../state/decision-log.ts";
6
+ import { DEPARTURE_ENTRY_TYPE } from "./record-departure-tool.ts";
7
+
8
+ export const APPROVAL_ENTRY_TYPE = "devsys-approval";
9
+
10
+ type Approval = { id: string; gate: string; command: string; used: boolean };
11
+
12
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
13
+ typeof v === "object" && v !== null && !Array.isArray(v);
14
+
15
+ /** Pre-granted, single-use approvals for an exact (gate, command) pair, persisted as session entries. */
16
+ export type ApprovalStore = {
17
+ grant(gate: string, command: string, id: string): void;
18
+ /** True (and marks the approval used) when an unused approval exists for this exact pair. */
19
+ consume(gate: string, command: string): boolean;
20
+ rebuildFrom(entries: ReadonlyArray<{ customType: string; data: unknown }>): void;
21
+ };
22
+
23
+ export function createApprovalStore(pi: ExtensionAPI): ApprovalStore {
24
+ let approvals: Approval[] = [];
25
+ const persist = (a: Approval) => pi.appendEntry(APPROVAL_ENTRY_TYPE, a);
26
+ return {
27
+ grant(gate, command, id) {
28
+ const approval = { id, gate, command: command.trim(), used: false };
29
+ approvals.push(approval);
30
+ persist(approval);
31
+ },
32
+ consume(gate, command) {
33
+ const found = approvals.find(
34
+ (a) => !a.used && a.gate === gate && a.command === command.trim(),
35
+ );
36
+ if (found === undefined) return false;
37
+ found.used = true;
38
+ persist(found);
39
+ return true;
40
+ },
41
+ rebuildFrom(entries) {
42
+ const byId = new Map<string, Approval>();
43
+ for (const e of entries) {
44
+ if (e.customType !== APPROVAL_ENTRY_TYPE || !isRecord(e.data)) continue;
45
+ const { id, gate, command, used } = e.data;
46
+ if (
47
+ typeof id === "string" &&
48
+ typeof gate === "string" &&
49
+ typeof command === "string" &&
50
+ typeof used === "boolean"
51
+ ) {
52
+ byId.set(id, { id, gate, command, used });
53
+ }
54
+ }
55
+ approvals = [...byId.values()];
56
+ },
57
+ };
58
+ }
59
+
60
+ export type HardStopRequest = {
61
+ pi: ExtensionAPI;
62
+ ctx: ExtensionContext;
63
+ gate: GateId;
64
+ command: string;
65
+ why: string;
66
+ toolCallId: string;
67
+ now?: () => Date;
68
+ };
69
+
70
+ export type HardStopOutcome =
71
+ | { kind: "approved"; departure: Departure }
72
+ | { kind: "declined" }
73
+ | { kind: "unavailable" };
74
+
75
+ /** Asks the user to approve one irreversible action; on approval records it (hard · user · once). */
76
+ export async function requestHardStop(req: HardStopRequest): Promise<HardStopOutcome> {
77
+ if (!req.ctx.hasUI) return { kind: "unavailable" };
78
+ const approved = await req.ctx.ui.confirm(
79
+ "Development system — hard stop",
80
+ `${req.command}\nGate: ${req.gate}\nThis is irreversible. Approve once?`,
81
+ );
82
+ if (!approved) return { kind: "declined" };
83
+ const at = (req.now ?? (() => new Date()))();
84
+ const departure = parseDeparture({
85
+ id: `dep-${at.getTime()}-${req.toolCallId}`,
86
+ gate: req.gate,
87
+ tier: "hard",
88
+ default: lookupGate(req.gate)?.default ?? "hard stop",
89
+ chosen: req.command,
90
+ why: req.why,
91
+ costIfWrong: "an irreversible git operation is performed",
92
+ approver: "user",
93
+ scope: { kind: "once", toolCallId: req.toolCallId },
94
+ recordedAt: at.toISOString().replace(/\.\d{3}Z$/, "Z"),
95
+ });
96
+ if (isParseError(departure)) throw new Error(departure.message);
97
+ await appendDecision(req.ctx.cwd, departure, at);
98
+ req.pi.appendEntry(DEPARTURE_ENTRY_TYPE, departure);
99
+ return { kind: "approved", departure };
100
+ }
@@ -0,0 +1,44 @@
1
+ import { type ExtensionAPI, isToolCallEventType } from "@earendil-works/pi-coding-agent";
2
+ import { classifyGitCommand, type GitIntent } from "../core/git-intent.ts";
3
+ import { isParseError, parseGateId } from "../core/types.ts";
4
+ import { type ApprovalStore, requestHardStop } from "./approvals.ts";
5
+
6
+ const HARD: ReadonlySet<GitIntent> = new Set([
7
+ "history-rewrite",
8
+ "force-push",
9
+ "branch-delete-remote",
10
+ "destructive-reset",
11
+ "no-verify",
12
+ ]);
13
+
14
+ export type GitGuardDeps = { pi: ExtensionAPI; approvals: ApprovalStore; now?: () => Date };
15
+
16
+ /** Registers the `tool_call` hard stop for irreversible git operations in bash. */
17
+ export function registerGitGuard(deps: GitGuardDeps): void {
18
+ deps.pi.on("tool_call", async (event, ctx) => {
19
+ if (!isToolCallEventType("bash", event)) return undefined;
20
+ const command = event.input.command;
21
+ const intent = classifyGitCommand(command);
22
+ if (!HARD.has(intent)) return undefined;
23
+ const gate = parseGateId(`git.${intent}`);
24
+ if (isParseError(gate)) return { block: true, reason: gate.message };
25
+ if (deps.approvals.consume(gate, command)) return undefined;
26
+ const outcome = await requestHardStop({
27
+ pi: deps.pi,
28
+ ctx,
29
+ gate,
30
+ command,
31
+ why: "approved interactively at the hard-stop dialog",
32
+ toolCallId: event.toolCallId,
33
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
34
+ });
35
+ if (outcome.kind === "approved") return undefined;
36
+ return {
37
+ block: true,
38
+ reason:
39
+ outcome.kind === "unavailable"
40
+ ? `hard stop ${gate}: requires user approval; run interactively`
41
+ : `hard stop ${gate}: the user declined this command. Do not retry it; propose a reversible alternative.`,
42
+ };
43
+ });
44
+ }
@@ -0,0 +1,99 @@
1
+ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { type Static, Type } from "typebox";
3
+ import { parseDeparture, renderDepartureMarkdown } from "../core/departure.ts";
4
+ import { gateIds, lookupGate } from "../core/gates.ts";
5
+ import { isParseError, parseGateId } from "../core/types.ts";
6
+ import { appendDecision } from "../state/decision-log.ts";
7
+ import type { SessionState } from "../state/session-state.ts";
8
+
9
+ export const DEPARTURE_ENTRY_TYPE = "devsys-departure";
10
+
11
+ const Parameters = Type.Object({
12
+ gate: Type.String({ description: `Gate id. One of: ${gateIds().join(", ")}` }),
13
+ chosen: Type.String({ description: "What you are doing instead of the default" }),
14
+ why: Type.String({ description: "Why the default does not fit here" }),
15
+ costIfWrong: Type.String({ description: "What goes wrong if this judgement is mistaken" }),
16
+ scope: Type.Union([Type.Literal("slice"), Type.Literal("session"), Type.Literal("once")], {
17
+ description: "How long the departure applies",
18
+ }),
19
+ revisitWhen: Type.Optional(Type.String({ description: "Condition that should reopen this" })),
20
+ });
21
+
22
+ export type RecordDepartureDeps = {
23
+ pi: ExtensionAPI;
24
+ state: SessionState;
25
+ now?: () => Date;
26
+ };
27
+
28
+ const fail = (text: string) => ({
29
+ content: [{ type: "text" as const, text }],
30
+ details: undefined,
31
+ isError: true,
32
+ });
33
+
34
+ function resolveScope(kind: "slice" | "session" | "once", toolCallId: string, slice?: string) {
35
+ if (kind === "once") return { kind, toolCallId };
36
+ if (kind === "session") return { kind };
37
+ return slice !== undefined ? { kind, slice } : undefined;
38
+ }
39
+
40
+ /** `devsys_record_departure`: the sanctioned escape hatch for soft gates. */
41
+ export function createRecordDepartureTool(
42
+ deps: RecordDepartureDeps,
43
+ ): ToolDefinition<typeof Parameters> {
44
+ const now = deps.now ?? (() => new Date());
45
+ return {
46
+ name: "devsys_record_departure",
47
+ label: "Record departure",
48
+ description:
49
+ "Record a deliberate departure from a soft default before acting against it. " +
50
+ "Hard stops (git history/force/no-verify) cannot be recorded here; use devsys_request_approval.",
51
+ promptSnippet: "Record a departure from a development-system default (soft gates only)",
52
+ parameters: Parameters,
53
+ exposure: "direct",
54
+ async execute(toolCallId, params: Static<typeof Parameters>, _signal, _onUpdate, ctx) {
55
+ const gate = parseGateId(params.gate);
56
+ const info = isParseError(gate) ? undefined : lookupGate(gate);
57
+ if (isParseError(gate) || info === undefined) {
58
+ return fail(`Unknown gate "${params.gate}". Registered gates: ${gateIds().join(", ")}`);
59
+ }
60
+ if (info.tier === "hard") {
61
+ return fail(
62
+ `Gate ${gate} is a hard stop: it needs the user. Call devsys_request_approval with the command and your reason.`,
63
+ );
64
+ }
65
+ const current = deps.state.get();
66
+ const scope = resolveScope(params.scope, toolCallId, current.activeSlice);
67
+ if (scope === undefined) {
68
+ return fail('scope "slice" needs an active slice; use "session" or "once" instead.');
69
+ }
70
+ const recordedAt = now();
71
+ const departure = parseDeparture({
72
+ id: `dep-${recordedAt.getTime()}-${toolCallId}`,
73
+ gate,
74
+ tier: info.tier,
75
+ default: info.default,
76
+ chosen: params.chosen,
77
+ why: params.why,
78
+ costIfWrong: params.costIfWrong,
79
+ approver: "agent",
80
+ scope,
81
+ ...(params.revisitWhen !== undefined ? { revisitWhen: params.revisitWhen } : {}),
82
+ recordedAt: recordedAt.toISOString().replace(/\.\d{3}Z$/, "Z"),
83
+ });
84
+ if (isParseError(departure)) return fail(departure.message);
85
+ const { path } = await appendDecision(ctx.cwd, departure, recordedAt);
86
+ deps.pi.appendEntry(DEPARTURE_ENTRY_TYPE, departure);
87
+ deps.state.update((s) => ({ ...s, openDepartures: [...s.openDepartures, departure] }));
88
+ return {
89
+ content: [
90
+ {
91
+ type: "text" as const,
92
+ text: `Recorded in ${path}:\n\n${renderDepartureMarkdown(departure)}`,
93
+ },
94
+ ],
95
+ details: undefined,
96
+ };
97
+ },
98
+ };
99
+ }
@@ -0,0 +1,62 @@
1
+ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { type Static, Type } from "typebox";
3
+ import { gateIds, lookupGate } from "../core/gates.ts";
4
+ import { isParseError, parseGateId } from "../core/types.ts";
5
+ import { type ApprovalStore, requestHardStop } from "./approvals.ts";
6
+
7
+ const Parameters = Type.Object({
8
+ gate: Type.String({ description: "A hard gate id, e.g. git.force-push" }),
9
+ command: Type.String({ description: "The exact command you intend to run afterwards" }),
10
+ why: Type.String({ description: "Why this irreversible action is needed" }),
11
+ });
12
+
13
+ const text = (t: string, isError = false) => ({
14
+ content: [{ type: "text" as const, text: t }],
15
+ details: undefined,
16
+ isError,
17
+ });
18
+
19
+ export type RequestApprovalDeps = { pi: ExtensionAPI; approvals: ApprovalStore; now?: () => Date };
20
+
21
+ /** `devsys_request_approval`: ask the user ahead of time to approve one hard-stop command. */
22
+ export function createRequestApprovalTool(
23
+ deps: RequestApprovalDeps,
24
+ ): ToolDefinition<typeof Parameters> {
25
+ return {
26
+ name: "devsys_request_approval",
27
+ label: "Request approval",
28
+ description:
29
+ "Ask the user to approve one irreversible command (hard stop). Approval is single-use and " +
30
+ "only valid for the exact command text. Unavailable when running headless.",
31
+ promptSnippet: "Ask the user to approve an irreversible git command (hard stop)",
32
+ parameters: Parameters,
33
+ exposure: "direct",
34
+ async execute(toolCallId, params: Static<typeof Parameters>, _signal, _onUpdate, ctx) {
35
+ const gate = parseGateId(params.gate);
36
+ if (isParseError(gate) || lookupGate(gate)?.tier !== "hard") {
37
+ const hard = gateIds().filter((id) => lookupGate(id)?.tier === "hard");
38
+ return text(`"${params.gate}" is not a hard gate. Hard gates: ${hard.join(", ")}`, true);
39
+ }
40
+ const outcome = await requestHardStop({
41
+ pi: deps.pi,
42
+ ctx,
43
+ gate,
44
+ command: params.command,
45
+ why: params.why,
46
+ toolCallId,
47
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
48
+ });
49
+ if (outcome.kind === "unavailable") {
50
+ return text("unavailable headless: hard stops need an interactive user.", true);
51
+ }
52
+ if (outcome.kind === "declined") {
53
+ return text(
54
+ "The user declined. Do not run this command; propose a reversible alternative.",
55
+ true,
56
+ );
57
+ }
58
+ deps.approvals.grant(gate, params.command, outcome.departure.id);
59
+ return text(`Approved once for gate ${gate}. Run exactly: ${params.command}`);
60
+ },
61
+ };
62
+ }
@@ -0,0 +1,28 @@
1
+ import { appendFile, mkdir, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { type Departure, renderDepartureMarkdown } from "../core/departure.ts";
4
+
5
+ const exists = async (path: string): Promise<boolean> =>
6
+ stat(path).then(
7
+ () => true,
8
+ () => false,
9
+ );
10
+
11
+ /** Appends a departure to `docs/decisions/YYYY-MM.md` (UTC month of `now`), creating it with a header. */
12
+ export async function appendDecision(
13
+ repoRoot: string,
14
+ d: Departure,
15
+ now: Date,
16
+ ): Promise<{ path: string }> {
17
+ const month = now.toISOString().slice(0, 7);
18
+ const dir = join(repoRoot, "docs", "decisions");
19
+ const path = join(dir, `${month}.md`);
20
+ await mkdir(dir, { recursive: true });
21
+ const entry = renderDepartureMarkdown(d);
22
+ if (await exists(path)) {
23
+ await appendFile(path, `\n${entry}`);
24
+ } else {
25
+ await appendFile(path, `# Decisions — ${month}\n\n${entry}`);
26
+ }
27
+ return { path };
28
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { parseDeparture } from "../core/departure.ts";
2
3
  import {
3
4
  type Departure,
4
5
  type DevsysState,
@@ -28,36 +29,18 @@ const JEV: ReadonlyArray<JevStatus> = ["online", "offline", "unknown"];
28
29
  const isRecord = (v: unknown): v is Record<string, unknown> =>
29
30
  typeof v === "object" && v !== null && !Array.isArray(v);
30
31
 
31
- const isString = (v: unknown): v is string => typeof v === "string";
32
-
33
- /** Structural check for a persisted Departure; I1.1's parseDeparture should supersede it. */
34
- function isDeparture(v: unknown): v is Departure {
35
- if (!isRecord(v)) return false;
36
- const scope = v["scope"];
37
- const scopeOk =
38
- isRecord(scope) &&
39
- (scope["kind"] === "session" ||
40
- (scope["kind"] === "slice" && isString(scope["slice"])) ||
41
- (scope["kind"] === "once" && isString(scope["toolCallId"])));
42
- return (
43
- scopeOk &&
44
- ["id", "gate", "default", "chosen", "why", "costIfWrong", "recordedAt"].every((k) =>
45
- isString(v[k]),
46
- ) &&
47
- (v["tier"] === "soft" || v["tier"] === "hard") &&
48
- (v["approver"] === "agent" || v["approver"] === "user") &&
49
- (v["revisitWhen"] === undefined || isString(v["revisitWhen"]))
50
- );
51
- }
52
-
53
32
  /** Boundary parse for persisted state: the only place state is cast from `unknown`. */
54
33
  export function parseDevsysState(input: unknown): DevsysState | ParseError {
55
34
  if (!isRecord(input)) return parseError("devsys state must be an object");
56
35
  const { phase, sizing, activeSlice, openDepartures, jev, lastPushAt } = input;
57
36
  if (!PHASES.includes(phase as Phase)) return parseError(`unknown phase: ${String(phase)}`);
58
37
  if (!JEV.includes(jev as JevStatus)) return parseError(`unknown jev status: ${String(jev)}`);
59
- if (!Array.isArray(openDepartures) || !openDepartures.every(isDeparture)) {
60
- return parseError("openDepartures must be an array of valid departures");
38
+ if (!Array.isArray(openDepartures)) return parseError("openDepartures must be an array");
39
+ const departures: Departure[] = [];
40
+ for (const raw of openDepartures) {
41
+ const departure = parseDeparture(raw);
42
+ if (isParseError(departure)) return departure;
43
+ departures.push(departure);
61
44
  }
62
45
  if (sizing !== undefined && !SIZINGS.includes(sizing as Sizing)) {
63
46
  return parseError(`unknown sizing: ${String(sizing)}`);
@@ -71,7 +54,7 @@ export function parseDevsysState(input: unknown): DevsysState | ParseError {
71
54
  return {
72
55
  phase: phase as Phase,
73
56
  jev: jev as JevStatus,
74
- openDepartures: openDepartures,
57
+ openDepartures: departures,
75
58
  ...(sizing !== undefined ? { sizing: sizing as Sizing } : {}),
76
59
  ...(activeSlice !== undefined ? { activeSlice: activeSlice as SliceRef } : {}),
77
60
  ...(lastPushAt !== undefined ? { lastPushAt } : {}),