@jwilger/pi-development-system 0.1.0 → 0.1.2

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.
@@ -10,22 +10,28 @@ const nonNegotiables = readFileSync(
10
10
  );
11
11
 
12
12
  /** Composition root: wires modules, holds no logic. */
13
- export default function developmentSystem(pi: ExtensionAPI): void {
13
+ export function createDevelopmentSystem(pi: ExtensionAPI) {
14
14
  const state = createSessionState(pi);
15
15
 
16
- const refreshStatus = (ctx: ExtensionContext) => {
17
- ctx.ui.setStatus(STATUS_KEY, renderStatusLine(state.get()));
16
+ let lastCtx: ExtensionContext | undefined;
17
+
18
+ const refreshStatus = () => {
19
+ lastCtx?.ui.setStatus(STATUS_KEY, renderStatusLine(state.get()));
18
20
  };
19
21
 
20
- pi.on("session_start", (_event, ctx) => {
21
- state.rebuildFrom(ctx.sessionManager.getBranch().flatMap(toCustomEntry));
22
- refreshStatus(ctx);
23
- });
22
+ const rebuild = (ctx: ExtensionContext) => {
23
+ lastCtx = ctx;
24
+ state.rebuildFrom(
25
+ ctx.sessionManager
26
+ .getBranch()
27
+ .flatMap((e) => (e.type === "custom" ? [{ customType: e.customType, data: e.data }] : [])),
28
+ );
29
+ refreshStatus();
30
+ };
24
31
 
25
- pi.on("session_tree", (_event, ctx) => {
26
- state.rebuildFrom(ctx.sessionManager.getBranch().flatMap(toCustomEntry));
27
- refreshStatus(ctx);
28
- });
32
+ state.onChange(refreshStatus);
33
+ pi.on("session_start", (_event, ctx) => rebuild(ctx));
34
+ pi.on("session_tree", (_event, ctx) => rebuild(ctx));
29
35
 
30
36
  pi.on("before_agent_start", (event) => {
31
37
  applyPromptSection(event, state.get(), nonNegotiables);
@@ -37,12 +43,10 @@ export default function developmentSystem(pi: ExtensionAPI): void {
37
43
  ctx.ui.notify(renderStatus(state.get()), "info");
38
44
  },
39
45
  });
46
+
47
+ return { state };
40
48
  }
41
49
 
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
- : [];
50
+ export default function developmentSystem(pi: ExtensionAPI): void {
51
+ createDevelopmentSystem(pi);
48
52
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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"
@@ -10,7 +10,7 @@ Amend/rebase/reset of pushed refs, `--force*`, deleting shared branches. Because
10
10
  ## 2. Never weaken verification to make a gate pass
11
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
12
 
13
- ## 3. Never claim something was verified, run or green when it was not
13
+ ## 3. Never claim something was verified, run, or green that was not
14
14
  Because the record of evidence is the product. Evidence comes before claims.
15
15
 
16
16
  ## 4. Never push on a red trunk
@@ -22,8 +22,8 @@ Stop, record, ask. Because a gate you can route around is not a gate.
22
22
  ## 6. Never violate repo-local delivery policy
23
23
  Delivery mode, protected branches, required reviews. Because the repo, not this tool, owns policy.
24
24
 
25
- ## 7. Never let secrets leave the machine unsanitised
26
- Commits, logs, eval cases, subagent prompts. Because exfiltration is irreversible.
25
+ ## 7. Never let secrets leave the machine without sanitisation
26
+ In commits, logs, eval cases, or subagent prompts. Because exfiltration is irreversible.
27
27
 
28
28
  ## 8. Never commit without a rationale-bearing Conventional Commit, or add AI attribution trailers
29
29
  No `Co-Authored-By`, no `Generated-by`. Because the message is the durable why; attribution noise is not.
package/src/core/types.ts CHANGED
@@ -5,9 +5,7 @@ export type ParseError = { readonly kind: "parse-error"; readonly message: strin
5
5
  export const parseError = (message: string): ParseError => ({ kind: "parse-error", message });
6
6
 
7
7
  export const isParseError = (value: unknown): value is ParseError =>
8
- typeof value === "object" &&
9
- value !== null &&
10
- (value as { kind?: unknown }).kind === "parse-error";
8
+ typeof value === "object" && value !== null && "kind" in value && value.kind === "parse-error";
11
9
 
12
10
  export type Tier = "hard" | "soft" | "advisory";
13
11
 
@@ -28,13 +28,37 @@ const JEV: ReadonlyArray<JevStatus> = ["online", "offline", "unknown"];
28
28
  const isRecord = (v: unknown): v is Record<string, unknown> =>
29
29
  typeof v === "object" && v !== null && !Array.isArray(v);
30
30
 
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
+
31
53
  /** Boundary parse for persisted state: the only place state is cast from `unknown`. */
32
54
  export function parseDevsysState(input: unknown): DevsysState | ParseError {
33
55
  if (!isRecord(input)) return parseError("devsys state must be an object");
34
56
  const { phase, sizing, activeSlice, openDepartures, jev, lastPushAt } = input;
35
57
  if (!PHASES.includes(phase as Phase)) return parseError(`unknown phase: ${String(phase)}`);
36
58
  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");
59
+ if (!Array.isArray(openDepartures) || !openDepartures.every(isDeparture)) {
60
+ return parseError("openDepartures must be an array of valid departures");
61
+ }
38
62
  if (sizing !== undefined && !SIZINGS.includes(sizing as Sizing)) {
39
63
  return parseError(`unknown sizing: ${String(sizing)}`);
40
64
  }
@@ -47,7 +71,7 @@ export function parseDevsysState(input: unknown): DevsysState | ParseError {
47
71
  return {
48
72
  phase: phase as Phase,
49
73
  jev: jev as JevStatus,
50
- openDepartures: openDepartures as Departure[],
74
+ openDepartures: openDepartures,
51
75
  ...(sizing !== undefined ? { sizing: sizing as Sizing } : {}),
52
76
  ...(activeSlice !== undefined ? { activeSlice: activeSlice as SliceRef } : {}),
53
77
  ...(lastPushAt !== undefined ? { lastPushAt } : {}),
@@ -58,6 +82,8 @@ export type SessionState = {
58
82
  get(): DevsysState;
59
83
  update(fn: (s: DevsysState) => DevsysState): void;
60
84
  rebuildFrom(entries: ReadonlyArray<{ customType: string; data: unknown }>): void;
85
+ /** Registers a listener run after every update(); returns an unsubscribe function. */
86
+ onChange(listener: (state: DevsysState) => void): () => void;
61
87
  };
62
88
 
63
89
  /**
@@ -66,11 +92,19 @@ export type SessionState = {
66
92
  */
67
93
  export function createSessionState(api: ExtensionAPI): SessionState {
68
94
  let current: DevsysState = initialState();
95
+ const listeners = new Set<(state: DevsysState) => void>();
69
96
  return {
70
97
  get: () => current,
71
98
  update(fn) {
72
99
  current = fn(current);
73
100
  api.appendEntry(STATE_ENTRY_TYPE, current);
101
+ for (const listener of listeners) listener(current);
102
+ },
103
+ onChange(listener) {
104
+ listeners.add(listener);
105
+ return () => {
106
+ listeners.delete(listener);
107
+ };
74
108
  },
75
109
  rebuildFrom(entries) {
76
110
  let rebuilt: DevsysState = initialState();