@jwilger/pi-development-system 0.0.0 → 0.1.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/extensions/development-system.ts +48 -0
- package/package.json +36 -2
- package/principles/DEFAULTS.md +20 -0
- package/principles/NON-NEGOTIABLES.md +35 -0
- package/src/context/status.ts +16 -0
- package/src/context/system-prompt.ts +30 -0
- package/src/core/types.ts +74 -0
- package/src/state/session-state.ts +85 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { renderStatus, renderStatusLine, STATUS_KEY } from "../src/context/status.ts";
|
|
4
|
+
import { applyPromptSection } from "../src/context/system-prompt.ts";
|
|
5
|
+
import { createSessionState } from "../src/state/session-state.ts";
|
|
6
|
+
|
|
7
|
+
const nonNegotiables = readFileSync(
|
|
8
|
+
new URL("../principles/NON-NEGOTIABLES.md", import.meta.url),
|
|
9
|
+
"utf8",
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
/** Composition root: wires modules, holds no logic. */
|
|
13
|
+
export default function developmentSystem(pi: ExtensionAPI): void {
|
|
14
|
+
const state = createSessionState(pi);
|
|
15
|
+
|
|
16
|
+
const refreshStatus = (ctx: ExtensionContext) => {
|
|
17
|
+
ctx.ui.setStatus(STATUS_KEY, renderStatusLine(state.get()));
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
pi.on("session_start", (_event, ctx) => {
|
|
21
|
+
state.rebuildFrom(ctx.sessionManager.getBranch().flatMap(toCustomEntry));
|
|
22
|
+
refreshStatus(ctx);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
26
|
+
state.rebuildFrom(ctx.sessionManager.getBranch().flatMap(toCustomEntry));
|
|
27
|
+
refreshStatus(ctx);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
pi.on("before_agent_start", (event) => {
|
|
31
|
+
applyPromptSection(event, state.get(), nonNegotiables);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
pi.registerCommand("devsys-status", {
|
|
35
|
+
description: "Show development-system phase, sizing, slice, departures and Jev status",
|
|
36
|
+
handler: async (_args, ctx) => {
|
|
37
|
+
ctx.ui.notify(renderStatus(state.get()), "info");
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function toCustomEntry(entry: unknown): Array<{ customType: string; data: unknown }> {
|
|
43
|
+
if (typeof entry !== "object" || entry === null) return [];
|
|
44
|
+
const e = entry as { type?: unknown; customType?: unknown; data?: unknown };
|
|
45
|
+
return e.type === "custom" && typeof e.customType === "string"
|
|
46
|
+
? [{ customType: e.customType, data: e.data }]
|
|
47
|
+
: [];
|
|
48
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jwilger/pi-development-system",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.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"
|
|
@@ -13,14 +13,48 @@
|
|
|
13
13
|
"type": "module",
|
|
14
14
|
"files": [
|
|
15
15
|
"extensions",
|
|
16
|
+
"src",
|
|
16
17
|
"skills",
|
|
17
18
|
"prompts",
|
|
19
|
+
"principles",
|
|
20
|
+
"agents",
|
|
18
21
|
"themes"
|
|
19
22
|
],
|
|
23
|
+
"pi": {
|
|
24
|
+
"extensions": [
|
|
25
|
+
"./extensions/development-system.ts"
|
|
26
|
+
],
|
|
27
|
+
"skills": [
|
|
28
|
+
"./skills"
|
|
29
|
+
],
|
|
30
|
+
"prompts": [
|
|
31
|
+
"./prompts"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"smol-toml": "^1.4.0"
|
|
36
|
+
},
|
|
20
37
|
"peerDependencies": {
|
|
21
|
-
"@earendil-works/pi-
|
|
38
|
+
"@earendil-works/pi-ai": "*",
|
|
39
|
+
"@earendil-works/pi-agent-core": "*",
|
|
40
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
41
|
+
"@earendil-works/pi-tui": "*",
|
|
42
|
+
"typebox": "*"
|
|
22
43
|
},
|
|
23
44
|
"publishConfig": {
|
|
24
45
|
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^26.6.4",
|
|
49
|
+
"@typesafe-ai/sdk": "^0.6.0",
|
|
50
|
+
"typescript": "^7.0.2"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"test": "node --test \"test/**/*.test.ts\"",
|
|
54
|
+
"typecheck": "tsc --noEmit",
|
|
55
|
+
"lint": "biome check .",
|
|
56
|
+
"lint:fix": "biome check --write .",
|
|
57
|
+
"check:build-gate": "node scripts/build-gate.ts",
|
|
58
|
+
"check:version": "node scripts/check-version.ts"
|
|
25
59
|
}
|
|
26
60
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Defaults (recommended; judgement allowed, departure recorded)
|
|
2
|
+
|
|
3
|
+
Follow these unless you have a grounded reason not to. If you depart, record
|
|
4
|
+
why (what / why / cost if wrong) with the departure tool before continuing.
|
|
5
|
+
|
|
6
|
+
- **RED first.** Write a failing test before behaviour changes. Why: it proves the test can fail.
|
|
7
|
+
- **Functional core, imperative shell.** Pure decisions, thin effects. Why: logic stays testable without the world.
|
|
8
|
+
- **Semantic types.** Parse at the boundary; illegal states unrepresentable. Why: the type is the proof.
|
|
9
|
+
- **Typed errors as values.** No stringly errors for expected failures. Why: callers can handle what they can name.
|
|
10
|
+
- **Structural and behavioural changes never share a commit.** Why: reviewable, revertable.
|
|
11
|
+
- **Artifact set sized to the work.** Brief, decisions, journeys, event model, lens review, ADR — only what the work needs. Why: planning is not the product.
|
|
12
|
+
- **Fresh-context review before commit.** Why: the author is the worst reviewer of their own diff.
|
|
13
|
+
- **Clean-streak review depth.** Three consecutive clean rounds before final delivery. Why: later rounds catch what earlier ones missed.
|
|
14
|
+
- **Scope stays what was asked.** Expansion from a finding is recorded. Why: scope is the steering wheel.
|
|
15
|
+
- **Lint suppression needs a rationale comment.** Why: a bare suppression is a hidden quality shortcut.
|
|
16
|
+
- **Model matches phase.** Stronger model to plan, review and advise; faster to implement. Why: cost where judgement is.
|
|
17
|
+
- **Threat model proportional to exposure.** Why: trust single owners, harden shared surfaces.
|
|
18
|
+
- **Keep context small.** Load skills lazily; one slice per clean context. Why: instruction following decays with context size.
|
|
19
|
+
- **Push at least hourly.** If an hour passes without a push, challenge whether the increment is too big. Why: small steps expose bad plans early.
|
|
20
|
+
- **Language-profile idioms** (Rust, TypeScript). Why: concrete idioms beat abstract principles for weaker models.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Non-negotiables
|
|
2
|
+
|
|
3
|
+
Departing from any item below requires an explicit decision from the author
|
|
4
|
+
(John). Do not decide it yourself, do not route around it. If you believe a
|
|
5
|
+
departure is warranted: stop, say which item and why, and ask.
|
|
6
|
+
|
|
7
|
+
## 1. Never rewrite pushed history or force-push
|
|
8
|
+
Amend/rebase/reset of pushed refs, `--force*`, deleting shared branches. Because it destroys other people's basis for trust.
|
|
9
|
+
|
|
10
|
+
## 2. Never weaken verification to make a gate pass
|
|
11
|
+
Deleting, skipping or loosening tests; suppressing a lint; editing expected values to match output; `--no-verify`. Because a green gate must mean the same thing afterwards. (Changing a test because the *requirement* changed is a recorded departure, not this.)
|
|
12
|
+
|
|
13
|
+
## 3. Never claim something was verified, run or green when it was not
|
|
14
|
+
Because the record of evidence is the product. Evidence comes before claims.
|
|
15
|
+
|
|
16
|
+
## 4. Never push on a red trunk
|
|
17
|
+
Except a change whose purpose is fixing that failure. Because unrelated work on red hides the signal.
|
|
18
|
+
|
|
19
|
+
## 5. Never work around an unresolved gate
|
|
20
|
+
Stop, record, ask. Because a gate you can route around is not a gate.
|
|
21
|
+
|
|
22
|
+
## 6. Never violate repo-local delivery policy
|
|
23
|
+
Delivery mode, protected branches, required reviews. Because the repo, not this tool, owns policy.
|
|
24
|
+
|
|
25
|
+
## 7. Never let secrets leave the machine unsanitised
|
|
26
|
+
Commits, logs, eval cases, subagent prompts. Because exfiltration is irreversible.
|
|
27
|
+
|
|
28
|
+
## 8. Never commit without a rationale-bearing Conventional Commit, or add AI attribution trailers
|
|
29
|
+
No `Co-Authored-By`, no `Generated-by`. Because the message is the durable why; attribution noise is not.
|
|
30
|
+
|
|
31
|
+
## 9. Never make an architecture-shaping decision without an ADR
|
|
32
|
+
New dependency class, boundary change, persistence model, public contract. Because "Revisit when" is how decisions stay honest.
|
|
33
|
+
|
|
34
|
+
## 10. Never ship model-visible instructions without an evidence check
|
|
35
|
+
Skills, prompts, Jev questions need a fixture or eval. Because effectiveness is eval-driven, not vibes.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { DevsysState } from "../core/types.ts";
|
|
2
|
+
|
|
3
|
+
export const STATUS_KEY = "devsys";
|
|
4
|
+
|
|
5
|
+
export const renderStatusLine = (state: DevsysState): string =>
|
|
6
|
+
`devsys: ${state.phase} · jev ${state.jev}`;
|
|
7
|
+
|
|
8
|
+
export const renderStatus = (state: DevsysState): string =>
|
|
9
|
+
[
|
|
10
|
+
"Development system status",
|
|
11
|
+
`- phase: ${state.phase}`,
|
|
12
|
+
`- sizing: ${state.sizing ?? "unset"}`,
|
|
13
|
+
`- active slice: ${state.activeSlice ?? "none"}`,
|
|
14
|
+
`- open departures: ${state.openDepartures.length}`,
|
|
15
|
+
`- jev: ${state.jev}`,
|
|
16
|
+
].join("\n");
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { BeforeAgentStartEvent } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { DevsysState } from "../core/types.ts";
|
|
3
|
+
|
|
4
|
+
export const PROMPT_SECTION_NAME = "development-system";
|
|
5
|
+
|
|
6
|
+
/** Pure: renders the system-prompt section from current state. */
|
|
7
|
+
export function buildPromptSection(state: DevsysState, nonNegotiables: string): string {
|
|
8
|
+
const slice = state.activeSlice ?? "none";
|
|
9
|
+
return [
|
|
10
|
+
nonNegotiables.trim(),
|
|
11
|
+
"",
|
|
12
|
+
"## Current state",
|
|
13
|
+
`- phase: ${state.phase}`,
|
|
14
|
+
`- active slice: ${slice}`,
|
|
15
|
+
`- open departures: ${state.openDepartures.length}`,
|
|
16
|
+
`- jev: ${state.jev}`,
|
|
17
|
+
].join("\n");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Adapter: mutates the section on a before_agent_start event (never returns systemPrompt). */
|
|
21
|
+
export function applyPromptSection(
|
|
22
|
+
event: BeforeAgentStartEvent,
|
|
23
|
+
state: DevsysState,
|
|
24
|
+
nonNegotiables: string,
|
|
25
|
+
): void {
|
|
26
|
+
event.systemPromptOptions.sections[PROMPT_SECTION_NAME] = buildPromptSection(
|
|
27
|
+
state,
|
|
28
|
+
nonNegotiables,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** Pure domain types for the development system. No I/O lives here. */
|
|
2
|
+
|
|
3
|
+
export type ParseError = { readonly kind: "parse-error"; readonly message: string };
|
|
4
|
+
|
|
5
|
+
export const parseError = (message: string): ParseError => ({ kind: "parse-error", message });
|
|
6
|
+
|
|
7
|
+
export const isParseError = (value: unknown): value is ParseError =>
|
|
8
|
+
typeof value === "object" &&
|
|
9
|
+
value !== null &&
|
|
10
|
+
(value as { kind?: unknown }).kind === "parse-error";
|
|
11
|
+
|
|
12
|
+
export type Tier = "hard" | "soft" | "advisory";
|
|
13
|
+
|
|
14
|
+
export type GateId = string & { readonly __brand: "GateId" };
|
|
15
|
+
|
|
16
|
+
const GATE_ID = /^[a-z][a-z0-9-]*(\.[a-z0-9-]+)+(:[a-z0-9-]+)?$/;
|
|
17
|
+
|
|
18
|
+
/** Gate ids are dotted kebab-case, optionally qualified: `artifact.skipped:brief`. */
|
|
19
|
+
export function parseGateId(input: string): GateId | ParseError {
|
|
20
|
+
return GATE_ID.test(input)
|
|
21
|
+
? (input as GateId)
|
|
22
|
+
: parseError(`invalid gate id "${input}": expected dotted kebab-case like "git.force-push"`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type GateDecision =
|
|
26
|
+
| { readonly kind: "allow" }
|
|
27
|
+
| { readonly kind: "block"; readonly reason: string }
|
|
28
|
+
| { readonly kind: "require-departure"; readonly gate: GateId; readonly reason: string }
|
|
29
|
+
| { readonly kind: "require-user"; readonly gate: GateId; readonly reason: string };
|
|
30
|
+
|
|
31
|
+
export type Phase = "intake" | "planning" | "implementing" | "reviewing" | "delivering" | "idle";
|
|
32
|
+
|
|
33
|
+
export type Sizing = "fix" | "change" | "capability" | "product";
|
|
34
|
+
|
|
35
|
+
export type SliceRef = string & { readonly __brand: "SliceRef" };
|
|
36
|
+
|
|
37
|
+
export type DepartureId = string & { readonly __brand: "DepartureId" };
|
|
38
|
+
|
|
39
|
+
export type DepartureScope =
|
|
40
|
+
| { readonly kind: "slice"; readonly slice: SliceRef }
|
|
41
|
+
| { readonly kind: "session" }
|
|
42
|
+
| { readonly kind: "once"; readonly toolCallId: string };
|
|
43
|
+
|
|
44
|
+
/** A recorded departure from a default or non-negotiable (plan Appendix A). */
|
|
45
|
+
export type Departure = {
|
|
46
|
+
readonly id: DepartureId;
|
|
47
|
+
readonly gate: GateId;
|
|
48
|
+
readonly tier: "soft" | "hard";
|
|
49
|
+
readonly default: string;
|
|
50
|
+
readonly chosen: string;
|
|
51
|
+
readonly why: string;
|
|
52
|
+
readonly costIfWrong: string;
|
|
53
|
+
readonly approver: "agent" | "user";
|
|
54
|
+
readonly scope: DepartureScope;
|
|
55
|
+
readonly revisitWhen?: string;
|
|
56
|
+
readonly recordedAt: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type JevStatus = "online" | "offline" | "unknown";
|
|
60
|
+
|
|
61
|
+
export type DevsysState = {
|
|
62
|
+
readonly phase: Phase;
|
|
63
|
+
readonly sizing?: Sizing;
|
|
64
|
+
readonly activeSlice?: SliceRef;
|
|
65
|
+
readonly openDepartures: ReadonlyArray<Departure>;
|
|
66
|
+
readonly jev: JevStatus;
|
|
67
|
+
readonly lastPushAt?: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const initialState = (): DevsysState => ({
|
|
71
|
+
phase: "idle",
|
|
72
|
+
openDepartures: [],
|
|
73
|
+
jev: "unknown",
|
|
74
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
type Departure,
|
|
4
|
+
type DevsysState,
|
|
5
|
+
initialState,
|
|
6
|
+
isParseError,
|
|
7
|
+
type JevStatus,
|
|
8
|
+
type ParseError,
|
|
9
|
+
type Phase,
|
|
10
|
+
parseError,
|
|
11
|
+
type Sizing,
|
|
12
|
+
type SliceRef,
|
|
13
|
+
} from "../core/types.ts";
|
|
14
|
+
|
|
15
|
+
export const STATE_ENTRY_TYPE = "devsys-state";
|
|
16
|
+
|
|
17
|
+
const PHASES: ReadonlyArray<Phase> = [
|
|
18
|
+
"intake",
|
|
19
|
+
"planning",
|
|
20
|
+
"implementing",
|
|
21
|
+
"reviewing",
|
|
22
|
+
"delivering",
|
|
23
|
+
"idle",
|
|
24
|
+
];
|
|
25
|
+
const SIZINGS: ReadonlyArray<Sizing> = ["fix", "change", "capability", "product"];
|
|
26
|
+
const JEV: ReadonlyArray<JevStatus> = ["online", "offline", "unknown"];
|
|
27
|
+
|
|
28
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
29
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
30
|
+
|
|
31
|
+
/** Boundary parse for persisted state: the only place state is cast from `unknown`. */
|
|
32
|
+
export function parseDevsysState(input: unknown): DevsysState | ParseError {
|
|
33
|
+
if (!isRecord(input)) return parseError("devsys state must be an object");
|
|
34
|
+
const { phase, sizing, activeSlice, openDepartures, jev, lastPushAt } = input;
|
|
35
|
+
if (!PHASES.includes(phase as Phase)) return parseError(`unknown phase: ${String(phase)}`);
|
|
36
|
+
if (!JEV.includes(jev as JevStatus)) return parseError(`unknown jev status: ${String(jev)}`);
|
|
37
|
+
if (!Array.isArray(openDepartures)) return parseError("openDepartures must be an array");
|
|
38
|
+
if (sizing !== undefined && !SIZINGS.includes(sizing as Sizing)) {
|
|
39
|
+
return parseError(`unknown sizing: ${String(sizing)}`);
|
|
40
|
+
}
|
|
41
|
+
if (activeSlice !== undefined && typeof activeSlice !== "string") {
|
|
42
|
+
return parseError("activeSlice must be a string");
|
|
43
|
+
}
|
|
44
|
+
if (lastPushAt !== undefined && typeof lastPushAt !== "string") {
|
|
45
|
+
return parseError("lastPushAt must be a string");
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
phase: phase as Phase,
|
|
49
|
+
jev: jev as JevStatus,
|
|
50
|
+
openDepartures: openDepartures as Departure[],
|
|
51
|
+
...(sizing !== undefined ? { sizing: sizing as Sizing } : {}),
|
|
52
|
+
...(activeSlice !== undefined ? { activeSlice: activeSlice as SliceRef } : {}),
|
|
53
|
+
...(lastPushAt !== undefined ? { lastPushAt } : {}),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type SessionState = {
|
|
58
|
+
get(): DevsysState;
|
|
59
|
+
update(fn: (s: DevsysState) => DevsysState): void;
|
|
60
|
+
rebuildFrom(entries: ReadonlyArray<{ customType: string; data: unknown }>): void;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* In-session authority for development-system state. Every update appends the
|
|
65
|
+
* full (small) state as a custom entry; on rebuild the last valid entry wins.
|
|
66
|
+
*/
|
|
67
|
+
export function createSessionState(api: ExtensionAPI): SessionState {
|
|
68
|
+
let current: DevsysState = initialState();
|
|
69
|
+
return {
|
|
70
|
+
get: () => current,
|
|
71
|
+
update(fn) {
|
|
72
|
+
current = fn(current);
|
|
73
|
+
api.appendEntry(STATE_ENTRY_TYPE, current);
|
|
74
|
+
},
|
|
75
|
+
rebuildFrom(entries) {
|
|
76
|
+
let rebuilt: DevsysState = initialState();
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
if (entry.customType !== STATE_ENTRY_TYPE) continue;
|
|
79
|
+
const parsed = parseDevsysState(entry.data);
|
|
80
|
+
if (!isParseError(parsed)) rebuilt = parsed;
|
|
81
|
+
}
|
|
82
|
+
current = rebuilt;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|