@jwilger/pi-development-system 0.1.2 → 0.3.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,14 @@
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";
10
+ import { registerTestGuard } from "../src/gates/test-guard.ts";
11
+ import { createJevHolder } from "../src/jev/holder.ts";
5
12
  import { createSessionState } from "../src/state/session-state.ts";
6
13
 
7
14
  const nonNegotiables = readFileSync(
@@ -12,20 +19,26 @@ const nonNegotiables = readFileSync(
12
19
  /** Composition root: wires modules, holds no logic. */
13
20
  export function createDevelopmentSystem(pi: ExtensionAPI) {
14
21
  const state = createSessionState(pi);
22
+ const approvals = createApprovalStore(pi);
15
23
 
24
+ const jevHolder = createJevHolder();
25
+ let jevModel: string | undefined;
16
26
  let lastCtx: ExtensionContext | undefined;
17
27
 
18
28
  const refreshStatus = () => {
19
- lastCtx?.ui.setStatus(STATUS_KEY, renderStatusLine(state.get()));
29
+ lastCtx?.ui.setStatus(STATUS_KEY, renderStatusLine(state.get(), jevModel));
20
30
  };
21
31
 
22
32
  const rebuild = (ctx: ExtensionContext) => {
23
33
  lastCtx = ctx;
24
- state.rebuildFrom(
25
- ctx.sessionManager
26
- .getBranch()
27
- .flatMap((e) => (e.type === "custom" ? [{ customType: e.customType, data: e.data }] : [])),
28
- );
34
+ const probe = jevHolder.probe(ctx);
35
+ jevModel = probe.model;
36
+ state.update((s) => (s.jev === probe.availability ? s : { ...s, jev: probe.availability }));
37
+ const entries = ctx.sessionManager
38
+ .getBranch()
39
+ .flatMap((e) => (e.type === "custom" ? [{ customType: e.customType, data: e.data }] : []));
40
+ state.rebuildFrom(entries);
41
+ approvals.rebuildFrom(entries);
29
42
  refreshStatus();
30
43
  };
31
44
 
@@ -37,10 +50,20 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
37
50
  applyPromptSection(event, state.get(), nonNegotiables);
38
51
  });
39
52
 
53
+ pi.on("context", (event) => {
54
+ const messages = appendContextTail(event.messages, renderContextTail(state.get()), Date.now());
55
+ return messages === undefined ? undefined : { messages };
56
+ });
57
+
58
+ registerGitGuard({ pi, approvals, jev: (ctx) => jevHolder.forContext(ctx) });
59
+ registerTestGuard({ pi, state, approvals, jev: (ctx) => jevHolder.forContext(ctx) });
60
+ pi.registerTool(createRecordDepartureTool({ pi, state }));
61
+ pi.registerTool(createRequestApprovalTool({ pi, approvals }));
62
+
40
63
  pi.registerCommand("devsys-status", {
41
64
  description: "Show development-system phase, sizing, slice, departures and Jev status",
42
65
  handler: async (_args, ctx) => {
43
- ctx.ui.notify(renderStatus(state.get()), "info");
66
+ ctx.ui.notify(renderStatus(state.get(), jevModel), "info");
44
67
  },
45
68
  });
46
69
 
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.3.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,39 @@
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(
22
+ (d) => `- ${d.gate} — ${d.chosen.replace(/\s+/g, " ")} (${scopeLabel(d)})`,
23
+ );
24
+ const shown =
25
+ lines.length <= room
26
+ ? lines
27
+ : [...lines.slice(0, room - 1), `- …and ${lines.length - room + 1} more`];
28
+ return [...head, "Open departures:", ...shown, ...tailLines].join("\n");
29
+ }
30
+
31
+ /** Appends the tail as a user-role message at the end of `messages`. */
32
+ export function appendContextTail(
33
+ messages: ReadonlyArray<AgentMessage>,
34
+ tail: string | undefined,
35
+ now: number,
36
+ ): AgentMessage[] | undefined {
37
+ if (tail === undefined) return undefined;
38
+ return [...messages, { role: "user", content: tail, timestamp: now }];
39
+ }
@@ -2,15 +2,18 @@ import type { DevsysState } from "../core/types.ts";
2
2
 
3
3
  export const STATUS_KEY = "devsys";
4
4
 
5
- export const renderStatusLine = (state: DevsysState): string =>
6
- `devsys: ${state.phase} · jev ${state.jev}`;
5
+ const jevLabel = (state: DevsysState, model: string | undefined): string =>
6
+ state.jev === "online" && model !== undefined ? `online (${model})` : state.jev;
7
7
 
8
- export const renderStatus = (state: DevsysState): string =>
8
+ export const renderStatusLine = (state: DevsysState, jevModel?: string): string =>
9
+ `devsys: ${state.phase} · jev ${jevLabel(state, jevModel)}`;
10
+
11
+ export const renderStatus = (state: DevsysState, jevModel?: string): string =>
9
12
  [
10
13
  "Development system status",
11
14
  `- phase: ${state.phase}`,
12
15
  `- sizing: ${state.sizing ?? "unset"}`,
13
16
  `- active slice: ${state.activeSlice ?? "none"}`,
14
17
  `- open departures: ${state.openDepartures.length}`,
15
- `- jev: ${state.jev}`,
18
+ `- jev: ${jevLabel(state, jevModel)}`,
16
19
  ].join("\n");
@@ -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,275 @@
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; backslash-newline is a continuation. */
27
+ function splitLines(command: string): string[] {
28
+ const lines: string[] = [];
29
+ let current = "";
30
+ let quote: "'" | '"' | undefined;
31
+ for (let i = 0; i < command.length; i++) {
32
+ const ch = command.charAt(i);
33
+ if (quote !== undefined) {
34
+ if (ch === quote) quote = undefined;
35
+ current += ch;
36
+ } else if (ch === "\\" && command.charAt(i + 1) === "\n") {
37
+ i++;
38
+ } else if (ch === "#" && (current === "" || /\s$/.test(current))) {
39
+ // A comment runs to the end of the line; quotes inside it do not open a quote.
40
+ while (i < command.length && command.charAt(i) !== "\n") current += command.charAt(i++);
41
+ i--;
42
+ } else if (ch === "'" || ch === '"') {
43
+ quote = ch;
44
+ current += ch;
45
+ } else if (ch === "\n") {
46
+ lines.push(current);
47
+ current = "";
48
+ } else {
49
+ current += ch;
50
+ }
51
+ }
52
+ lines.push(current);
53
+ return lines;
54
+ }
55
+
56
+ const isRedirect = (op: string): boolean => /^[<>]/.test(op) && !op.endsWith("(");
57
+
58
+ /** Tokens of one line grouped into simple commands (operators separate segments). */
59
+ function segments(line: string): string[][] {
60
+ const result: string[][] = [[]];
61
+ for (const token of parse(line, (name) => `$${name}`)) {
62
+ if (typeof token === "string") result[result.length - 1]?.push(token);
63
+ else if ("pattern" in token) result[result.length - 1]?.push(token.pattern);
64
+ else if ("op" in token && isRedirect(token.op)) continue;
65
+ else if (!("comment" in token)) result.push([]);
66
+ }
67
+ return result.filter((s) => s.length > 0);
68
+ }
69
+
70
+ const WRAPPERS = new Set(["command", "sudo", "env", "time", "nohup", "exec", "builtin", "nice"]);
71
+ const KEYWORDS = new Set(["then", "do", "else", "elif", "if", "while", "until", "{", "!", "time"]);
72
+ const DATA_ONLY = new Set([
73
+ "echo",
74
+ "printf",
75
+ "cat",
76
+ "grep",
77
+ "rg",
78
+ "man",
79
+ "which",
80
+ "type",
81
+ "head",
82
+ "tail",
83
+ ]);
84
+ const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
85
+ const OPAQUE = new Set(["xargs", "ssh", "find", "parallel", "watch"]);
86
+ const GLOBAL_WITH_VALUE = new Set(["-c", "-C", "--git-dir", "--work-tree", "--namespace"]);
87
+ const HOOK_SKIP_ENV = new Set(["LEFTHOOK=0", "HUSKY=0"]);
88
+
89
+ const basename = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
90
+ const isAssignment = (t: string): boolean => /^[A-Za-z_][A-Za-z0-9_]*=/.test(t);
91
+
92
+ /** True when `arg` is `name` or an unambiguous-looking git abbreviation of it (`--amen`, `--del=x`). */
93
+ const isLong = (arg: string, ...names: string[]): boolean => {
94
+ const key = arg.split("=")[0] ?? "";
95
+ return key.length >= 4 && key.startsWith("--") && names.some((n) => n.startsWith(key));
96
+ };
97
+
98
+ function classifyGitArgs(globals: string[], sub: string | undefined, args: string[]): GitIntent {
99
+ let intent: GitIntent = "ordinary";
100
+ if (globals.some((g) => /^core\.hookspath=/i.test(g))) intent = worst(intent, "no-verify");
101
+ if (args.some((a) => isLong(a, "--no-verify"))) intent = worst(intent, "no-verify");
102
+ switch (sub) {
103
+ case "push": {
104
+ const shortFlags = args.filter((a) => /^-[A-Za-z]+$/.test(a));
105
+ if (
106
+ args.some((a) =>
107
+ isLong(a, "--force", "--force-with-lease", "--force-if-includes", "--mirror"),
108
+ ) ||
109
+ shortFlags.some((a) => a.includes("f")) ||
110
+ args.some((a) => /^\+\S/.test(a))
111
+ ) {
112
+ return worst(intent, "force-push");
113
+ }
114
+ if (
115
+ args.some((a) => isLong(a, "--delete")) ||
116
+ shortFlags.includes("-d") ||
117
+ args.some((a) => /^:\S/.test(a))
118
+ ) {
119
+ return worst(intent, "branch-delete-remote");
120
+ }
121
+ return intent;
122
+ }
123
+ case "commit":
124
+ if (args.some((a) => isLong(a, "--amend"))) return worst(intent, "history-rewrite");
125
+ if (args.some((a) => /^-[A-Za-z]*n[A-Za-z]*$/.test(a))) return worst(intent, "no-verify");
126
+ return intent;
127
+ case "rebase":
128
+ return args.some((a) => ["--abort", "--continue", "--skip", "--quit"].includes(a))
129
+ ? intent
130
+ : worst(intent, "history-rewrite");
131
+ case "filter-branch":
132
+ case "filter-repo":
133
+ return worst(intent, "history-rewrite");
134
+ case "reset":
135
+ return args.some((a) => isLong(a, "--hard")) ? worst(intent, "destructive-reset") : intent;
136
+ default:
137
+ return intent;
138
+ }
139
+ }
140
+
141
+ function classifySegment(tokens: string[]): GitIntent {
142
+ let hookSkip = false;
143
+ let i = 0;
144
+ for (;;) {
145
+ const t = tokens[i];
146
+ if (t === undefined) return "ordinary";
147
+ if (isAssignment(t)) {
148
+ if (HOOK_SKIP_ENV.has(t)) hookSkip = true;
149
+ i++;
150
+ } else if (WRAPPERS.has(t) || KEYWORDS.has(t)) {
151
+ i++;
152
+ } else {
153
+ break;
154
+ }
155
+ }
156
+ const head = tokens[i] ?? "";
157
+ const rest = tokens.slice(i + 1);
158
+ const base = basename(head);
159
+ const withHookSkip = (intent: GitIntent): GitIntent =>
160
+ hookSkip ? worst(intent, "no-verify") : intent;
161
+ if (head.startsWith("$") || base === "$GIT") return "unknown";
162
+ if (SHELLS.has(base)) {
163
+ const c = rest.findIndex((t) => /^-[A-Za-z]*c[A-Za-z]*$/.test(t));
164
+ const script = c >= 0 ? rest[c + 1] : undefined;
165
+ return script !== undefined ? classifyGitCommand(script) : "ordinary";
166
+ }
167
+ if (base === "eval") return classifyGitCommand(rest.join(" "));
168
+ if (OPAQUE.has(base)) return rest.some((t) => basename(t) === "git") ? "unknown" : "ordinary";
169
+ if (base !== "git") {
170
+ if (DATA_ONLY.has(base)) return "ordinary";
171
+ // Wrapper with options (`sudo -E git ...`, `timeout 5 git ...`): classify from the git token.
172
+ const at = rest.findIndex((t) => basename(t) === "git");
173
+ return at >= 0 ? withHookSkip(classifySegment(rest.slice(at))) : "ordinary";
174
+ }
175
+ const globals: string[] = [];
176
+ let j = 0;
177
+ while (j < rest.length && (rest[j] ?? "").startsWith("-")) {
178
+ const opt = rest[j] ?? "";
179
+ if (GLOBAL_WITH_VALUE.has(opt)) {
180
+ globals.push(rest[j + 1] ?? "");
181
+ j += 2;
182
+ } else {
183
+ j++;
184
+ }
185
+ }
186
+ const sub = rest[j];
187
+ const args = rest.slice(j + 1);
188
+ const dynamic =
189
+ (sub ?? "").startsWith("$") ||
190
+ (["push", "reset", "rebase"].includes(sub ?? "") &&
191
+ args.some((t) => t.startsWith("$") || t.includes("`")));
192
+ const intent = classifyGitArgs(globals, sub, args);
193
+ return withHookSkip(dynamic ? worst(intent, "unknown") : intent);
194
+ }
195
+
196
+ /**
197
+ * The parts of a line bash may still execute or treat as syntax: single-quoted text and
198
+ * backslash-escaped characters are dropped; double-quoted text is kept unless `dropDouble`.
199
+ */
200
+ function live(line: string, dropDouble: boolean): string {
201
+ let out = "";
202
+ let quote: "'" | '"' | undefined;
203
+ for (let i = 0; i < line.length; i++) {
204
+ const ch = line.charAt(i);
205
+ if (quote === "'") {
206
+ if (ch === "'") quote = undefined;
207
+ } else if (ch === "\\") {
208
+ i++;
209
+ } else if (quote === '"') {
210
+ if (ch === '"') quote = undefined;
211
+ else if (!dropDouble) out += ch;
212
+ } else if (ch === "'" || ch === '"') {
213
+ quote = ch;
214
+ } else {
215
+ out += ch;
216
+ }
217
+ }
218
+ return out;
219
+ }
220
+
221
+ /** Bodies of `$(...)` and backtick substitutions anywhere in the text (quoted or not). */
222
+ function substitutions(text: string): string[] {
223
+ const bodies: string[] = [];
224
+ for (let at = text.indexOf("$("); at >= 0; at = text.indexOf("$(", at + 2)) {
225
+ let depth = 1;
226
+ let end = at + 2;
227
+ for (; end < text.length && depth > 0; end++) {
228
+ const ch = text.charAt(end);
229
+ if (ch === "(") depth++;
230
+ else if (ch === ")") depth--;
231
+ }
232
+ bodies.push(text.slice(at + 2, depth === 0 ? end - 1 : end));
233
+ }
234
+ for (const m of text.matchAll(/`([^`]*)`/g)) bodies.push(m[1] ?? "");
235
+ return bodies;
236
+ }
237
+
238
+ const HEREDOC = /^<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/;
239
+
240
+ /** The delimiter of a heredoc started by an unquoted `<<` on this line, if any. */
241
+ function heredocDelimiter(line: string): string | undefined {
242
+ let quote: "'" | '"' | undefined;
243
+ for (let i = 0; i < line.length; i++) {
244
+ const ch = line.charAt(i);
245
+ if (quote !== undefined) {
246
+ if (ch === quote) quote = undefined;
247
+ } else if (ch === "\\") {
248
+ i++;
249
+ } else if (ch === "'" || ch === '"') {
250
+ quote = ch;
251
+ } else if (line.startsWith("<<<", i)) {
252
+ i += 2;
253
+ } else if (ch === "<" && line.charAt(i + 1) === "<") {
254
+ return HEREDOC.exec(line.slice(i))?.[2];
255
+ }
256
+ }
257
+ return undefined;
258
+ }
259
+
260
+ /** Classifies the most severe git intent in a shell command (deterministic fast path). */
261
+ export function classifyGitCommand(command: string): GitIntent {
262
+ let intent: GitIntent = "ordinary";
263
+ let heredocEnd: string | undefined;
264
+ for (const line of splitLines(command)) {
265
+ if (heredocEnd !== undefined) {
266
+ if (line.trim() === heredocEnd) heredocEnd = undefined;
267
+ continue;
268
+ }
269
+ for (const segment of segments(line)) intent = worst(intent, classifySegment(segment));
270
+ for (const body of substitutions(live(line, false)))
271
+ intent = worst(intent, classifyGitCommand(body));
272
+ heredocEnd = heredocDelimiter(line) ?? heredocEnd;
273
+ }
274
+ return intent;
275
+ }
@@ -0,0 +1,10 @@
1
+ /** Removes credentials from text before it is written to committed logs or session entries (R11). */
2
+ export function redactSecrets(text: string): string {
3
+ return text
4
+ .replace(/(\w+:\/\/)[^\s/@]+@/g, "$1[redacted]@")
5
+ .replace(/\b(?:ghp|gho|ghu|ghs|github_pat)_[A-Za-z0-9_]{16,}/g, "[redacted]")
6
+ .replace(/\b(\w*(?:TOKEN|KEY|SECRET|PASSWORD)\w*)=("[^"]*"|'[^']*'|\S+)/gi, "$1=[redacted]")
7
+ .replace(/\b(Authorization:\s*)\w+\s+[^\s'"]+/gi, "$1[redacted]")
8
+ .replace(/\bBearer\s+[^\s'"]+/g, "Bearer [redacted]")
9
+ .replace(/\bsk-[A-Za-z0-9_-]{16,}/g, "[redacted]");
10
+ }
@@ -0,0 +1,7 @@
1
+ /** A value or a typed error (errors as values, R2/R3). */
2
+ export type Result<T, E> =
3
+ | { readonly ok: true; readonly value: T }
4
+ | { readonly ok: false; readonly error: E };
5
+
6
+ export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
7
+ export const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
@@ -0,0 +1,27 @@
1
+ export type TestPathProfile = { readonly testGlobs?: readonly string[] };
2
+
3
+ const PATTERNS: readonly RegExp[] = [
4
+ /(^|\/)(test|tests|__tests__|spec|specs|e2e)\//,
5
+ /\.(test|spec|cy)\.[cm]?[jt]sx?$/,
6
+ /_test\.(go|rb|py|exs?|rs)$/,
7
+ /(^|\/)test_[^/]+\.py$/,
8
+ /_spec\.rb$/,
9
+ ];
10
+
11
+ const SPECIALS = /[.+^$(){}|[\]\\]/g;
12
+
13
+ function globToRegExp(glob: string): RegExp {
14
+ const escaped = glob
15
+ .replace(SPECIALS, "\\$&")
16
+ .replace(/\*\*/g, "@@GLOBSTAR@@")
17
+ .replace(/\*/g, "[^/]*")
18
+ .replace(/@@GLOBSTAR@@/g, ".*");
19
+ return new RegExp(`^${escaped}$`);
20
+ }
21
+
22
+ /** Whether a repo-relative path is a test file (conventions, plus profile-supplied globs). */
23
+ export function isTestPath(path: string, profile?: TestPathProfile): boolean {
24
+ const normalized = path.replace(/^\.\//, "");
25
+ if (PATTERNS.some((p) => p.test(normalized))) return true;
26
+ return (profile?.testGlobs ?? []).some((g) => globToRegExp(g).test(normalized));
27
+ }