@jwilger/pi-development-system 0.7.0 → 0.8.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 +6 -0
- package/package.json +1 -1
- package/skills/delivery-discipline/SKILL.md +77 -0
- package/skills/delivery-discipline/references/lefthook.yml +18 -0
- package/src/context/status.ts +11 -1
- package/src/core/types.ts +7 -0
- package/src/state/ci-command.ts +66 -0
- package/src/state/ci-watch.ts +33 -0
- package/src/state/session-state.ts +17 -1
|
@@ -11,6 +11,7 @@ import { createRecordDepartureTool } from "../src/gates/record-departure-tool.ts
|
|
|
11
11
|
import { createRequestApprovalTool } from "../src/gates/request-approval-tool.ts";
|
|
12
12
|
import { registerTestGuard } from "../src/gates/test-guard.ts";
|
|
13
13
|
import { createJevHolder } from "../src/jev/holder.ts";
|
|
14
|
+
import { registerCiCommand } from "../src/state/ci-command.ts";
|
|
14
15
|
import { createModelsTool, registerModelsCommand } from "../src/state/models-command.ts";
|
|
15
16
|
import { createSessionState } from "../src/state/session-state.ts";
|
|
16
17
|
|
|
@@ -66,6 +67,11 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
|
66
67
|
jev: (ctx) => jevHolder.forContext(ctx),
|
|
67
68
|
exec: (command, args, options) => pi.exec(command, args, options),
|
|
68
69
|
});
|
|
70
|
+
registerCiCommand({
|
|
71
|
+
pi,
|
|
72
|
+
state,
|
|
73
|
+
exec: (command, args, options) => pi.exec(command, args, options),
|
|
74
|
+
});
|
|
69
75
|
registerPushGuard({
|
|
70
76
|
pi,
|
|
71
77
|
state,
|
package/package.json
CHANGED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: delivery-discipline
|
|
3
|
+
description: How to commit, push and release in this development system - trunk-based delivery, Conventional Commit messages that carry their rationale, handling a red build, and when worktrees are warranted. Use before committing, pushing, opening a pull request, or when CI is red.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Delivery discipline
|
|
7
|
+
|
|
8
|
+
Delivery mode comes from `.development-system.toml` (`[delivery] mode`):
|
|
9
|
+
`trunk` (default), `pull-request`, or `local-only`. The repository's own policy
|
|
10
|
+
wins over any habit. The extension enforces the hard parts; this skill is how
|
|
11
|
+
to work with them.
|
|
12
|
+
|
|
13
|
+
## Commit messages
|
|
14
|
+
|
|
15
|
+
Shape: `type(scope): subject` then a body that says **why**.
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
fix(auth): reject expired refresh tokens
|
|
19
|
+
|
|
20
|
+
Expired tokens were accepted for up to a minute because the clock check used
|
|
21
|
+
the issue time. Sessions outliving their grant is a security defect, so the
|
|
22
|
+
check now uses expiry; clock-skew tolerance is deliberately not added.
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- Do not write a body that only restates the diff. The diff already says what;
|
|
26
|
+
the body is the only place the reason survives.
|
|
27
|
+
- Do not add `Co-Authored-By`, "Generated with" or any AI trailer. This is
|
|
28
|
+
never a departure; the guard blocks it outright.
|
|
29
|
+
- Do not mix structural changes (rename, move, reformat) with behavioural ones
|
|
30
|
+
in one commit. Reviewers cannot see the behaviour change inside the noise.
|
|
31
|
+
Tidy first, commit, then change behaviour.
|
|
32
|
+
- A body-less commit is allowed only through a recorded departure
|
|
33
|
+
(`devsys_record_departure`, gate `commit.rationale`); say why in it.
|
|
34
|
+
|
|
35
|
+
## Trunk flow
|
|
36
|
+
|
|
37
|
+
1. Small increment, tests green locally, commit with rationale.
|
|
38
|
+
2. Push to trunk, then watch CI (`/devsys-ci`). A push is not done until CI is green.
|
|
39
|
+
3. If an hour passes without a pushed commit, stop and ask whether the
|
|
40
|
+
increment is too big; split it.
|
|
41
|
+
|
|
42
|
+
Do not start unrelated work while trunk CI is red. Fix it first; a red build
|
|
43
|
+
hides every later failure. A push to a red trunk is a hard stop unless the
|
|
44
|
+
commit is `fix(ci): ...` and its diff only repairs the failing build.
|
|
45
|
+
|
|
46
|
+
## Pull-request mode
|
|
47
|
+
|
|
48
|
+
Never push to the trunk directly (hard stop, gate `push.delivery-mode`). Push a
|
|
49
|
+
branch, open the PR, and wait for its checks.
|
|
50
|
+
|
|
51
|
+
## Local-only
|
|
52
|
+
|
|
53
|
+
Pushes are blocked. Commit locally, tell the user what is unpublished.
|
|
54
|
+
|
|
55
|
+
## Worktrees
|
|
56
|
+
|
|
57
|
+
Use `.worktrees/<name>` only when two pieces of work genuinely run in
|
|
58
|
+
parallel. One increment at a time needs none; they add cleanup and confusion.
|
|
59
|
+
|
|
60
|
+
## Never
|
|
61
|
+
|
|
62
|
+
- `--no-verify`, `git push --force`, rewriting published history, deleting
|
|
63
|
+
remote branches: hard stops needing the user's explicit approval each time.
|
|
64
|
+
- Weakening or deleting tests to get green (gate `tests.weaken`).
|
|
65
|
+
- Claiming CI passed without having looked at it.
|
|
66
|
+
|
|
67
|
+
## Checklist
|
|
68
|
+
|
|
69
|
+
Copy and tick:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
- [ ] Commit is one kind of change (structural or behavioural)
|
|
73
|
+
- [ ] Subject is Conventional; body explains why
|
|
74
|
+
- [ ] No AI trailer
|
|
75
|
+
- [ ] Local gates green (see references/lefthook.yml for the template)
|
|
76
|
+
- [ ] Pushed per delivery mode; CI watched to green
|
|
77
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
# Template: local gates that mirror CI so a red build is found before the push.
|
|
3
|
+
# Adapt the commands to the repository's toolchain.
|
|
4
|
+
# Keep CI re-running every gate.
|
|
5
|
+
commit-msg:
|
|
6
|
+
commands:
|
|
7
|
+
conventional:
|
|
8
|
+
run: your-commit-message-check {1}
|
|
9
|
+
|
|
10
|
+
pre-commit:
|
|
11
|
+
parallel: true
|
|
12
|
+
commands:
|
|
13
|
+
typecheck:
|
|
14
|
+
run: npx tsc --noEmit
|
|
15
|
+
lint:
|
|
16
|
+
run: biome check .
|
|
17
|
+
test:
|
|
18
|
+
run: npm test
|
package/src/context/status.ts
CHANGED
|
@@ -5,8 +5,17 @@ export const STATUS_KEY = "devsys";
|
|
|
5
5
|
const jevLabel = (state: DevsysState, model: string | undefined): string =>
|
|
6
6
|
state.jev === "online" && model !== undefined ? `online (${model})` : state.jev;
|
|
7
7
|
|
|
8
|
+
const ciLabel = (state: DevsysState): string | undefined => {
|
|
9
|
+
const { ci } = state;
|
|
10
|
+
if (ci === undefined || ci.status === "unknown") return undefined;
|
|
11
|
+
const sha = ci.sha === undefined ? "" : ` (${ci.sha.slice(0, 7)})`;
|
|
12
|
+
return `ci ${ci.status}${sha}`;
|
|
13
|
+
};
|
|
14
|
+
|
|
8
15
|
export const renderStatusLine = (state: DevsysState, jevModel?: string): string =>
|
|
9
|
-
`devsys: ${state.phase}
|
|
16
|
+
[`devsys: ${state.phase}`, `jev ${jevLabel(state, jevModel)}`, ciLabel(state)]
|
|
17
|
+
.filter((part) => part !== undefined)
|
|
18
|
+
.join(" · ");
|
|
10
19
|
|
|
11
20
|
export const renderStatus = (state: DevsysState, jevModel?: string): string =>
|
|
12
21
|
[
|
|
@@ -16,4 +25,5 @@ export const renderStatus = (state: DevsysState, jevModel?: string): string =>
|
|
|
16
25
|
`- active slice: ${state.activeSlice ?? "none"}`,
|
|
17
26
|
`- open departures: ${state.openDepartures.length}`,
|
|
18
27
|
`- jev: ${jevLabel(state, jevModel)}`,
|
|
28
|
+
`- ci: ${ciLabel(state)?.slice(3) ?? "unknown"}`,
|
|
19
29
|
].join("\n");
|
package/src/core/types.ts
CHANGED
|
@@ -56,6 +56,12 @@ export type Departure = {
|
|
|
56
56
|
|
|
57
57
|
export type JevStatus = "online" | "offline" | "unknown";
|
|
58
58
|
|
|
59
|
+
export type CiStatusName = "green" | "red" | "pending" | "unknown";
|
|
60
|
+
export const CI_STATUSES: readonly CiStatusName[] = ["green", "red", "pending", "unknown"];
|
|
61
|
+
|
|
62
|
+
/** Last observed CI state of the trunk (see `/devsys-ci`). */
|
|
63
|
+
export type CiState = { readonly status: CiStatusName; readonly sha?: string };
|
|
64
|
+
|
|
59
65
|
export type DevsysState = {
|
|
60
66
|
readonly phase: Phase;
|
|
61
67
|
readonly sizing?: Sizing;
|
|
@@ -63,6 +69,7 @@ export type DevsysState = {
|
|
|
63
69
|
readonly openDepartures: ReadonlyArray<Departure>;
|
|
64
70
|
readonly jev: JevStatus;
|
|
65
71
|
readonly lastPushAt?: string;
|
|
72
|
+
readonly ci?: CiState;
|
|
66
73
|
};
|
|
67
74
|
|
|
68
75
|
export const initialState = (): DevsysState => ({
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Exec } from "../core/exec.ts";
|
|
3
|
+
import type { CiState } from "../core/types.ts";
|
|
4
|
+
import { watchCi } from "./ci-watch.ts";
|
|
5
|
+
import { loadConfig } from "./config.ts";
|
|
6
|
+
import type { SessionState } from "./session-state.ts";
|
|
7
|
+
|
|
8
|
+
export const CI_ENTRY_TYPE = "devsys-ci";
|
|
9
|
+
|
|
10
|
+
export type CiCommandDeps = {
|
|
11
|
+
pi: ExtensionAPI;
|
|
12
|
+
state: SessionState;
|
|
13
|
+
exec: Exec;
|
|
14
|
+
sleep?: (ms: number) => Promise<void>;
|
|
15
|
+
intervalMs?: number;
|
|
16
|
+
maxPolls?: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const sameCi = (a: CiState | undefined, b: CiState): boolean =>
|
|
20
|
+
a?.status === b.status && a?.sha === b.sha;
|
|
21
|
+
|
|
22
|
+
/** Observes trunk CI until it settles, mirroring each change into state (status line) and a `devsys-ci` entry. */
|
|
23
|
+
export async function runCiWatch(
|
|
24
|
+
deps: CiCommandDeps,
|
|
25
|
+
ctx: Pick<ExtensionCommandContext, "cwd" | "ui">,
|
|
26
|
+
): Promise<CiState | undefined> {
|
|
27
|
+
const config = await loadConfig(ctx.cwd);
|
|
28
|
+
if (!config.ok) {
|
|
29
|
+
ctx.ui.notify(`cannot read the delivery policy: ${config.error.message}`, "error");
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
const final = await watchCi({
|
|
33
|
+
exec: deps.exec,
|
|
34
|
+
branch: config.value.delivery.trunk,
|
|
35
|
+
cwd: ctx.cwd,
|
|
36
|
+
sleep: deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
37
|
+
intervalMs: deps.intervalMs ?? 15_000,
|
|
38
|
+
maxPolls: deps.maxPolls ?? 120,
|
|
39
|
+
onObserved: (observed) => {
|
|
40
|
+
if (sameCi(deps.state.get().ci, observed)) return;
|
|
41
|
+
deps.state.update((s) => ({ ...s, ci: observed }));
|
|
42
|
+
deps.pi.appendEntry(CI_ENTRY_TYPE, observed);
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
ctx.ui.notify(
|
|
46
|
+
`CI on ${config.value.delivery.trunk}: ${final.status}${final.sha === undefined ? "" : ` (${final.sha.slice(0, 7)})`}`,
|
|
47
|
+
final.status === "red" ? "error" : "info",
|
|
48
|
+
);
|
|
49
|
+
return final;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `/devsys-ci`: watch the trunk's CI without blocking the session. */
|
|
53
|
+
export function registerCiCommand(deps: CiCommandDeps): void {
|
|
54
|
+
deps.pi.registerCommand("devsys-ci", {
|
|
55
|
+
description: "Watch CI on the trunk and show its state in the status line",
|
|
56
|
+
handler: async (_args, ctx) => {
|
|
57
|
+
ctx.ui.notify("watching CI on the trunk…", "info");
|
|
58
|
+
runCiWatch(deps, ctx).catch((cause: unknown) => {
|
|
59
|
+
ctx.ui.notify(
|
|
60
|
+
`CI watch failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
61
|
+
"error",
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Exec } from "../core/exec.ts";
|
|
2
|
+
import type { CiState } from "../core/types.ts";
|
|
3
|
+
import { getTrunkStatus } from "./ci.ts";
|
|
4
|
+
|
|
5
|
+
export type WatchOptions = {
|
|
6
|
+
exec: Exec;
|
|
7
|
+
branch: string;
|
|
8
|
+
cwd: string;
|
|
9
|
+
/** Called with every observation, including the first. */
|
|
10
|
+
onObserved: (observed: CiState) => void;
|
|
11
|
+
sleep: (ms: number) => Promise<void>;
|
|
12
|
+
intervalMs: number;
|
|
13
|
+
maxPolls: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Observes the trunk's newest run until it settles (not pending) or the poll budget is spent. */
|
|
17
|
+
export async function watchCi(options: WatchOptions): Promise<CiState> {
|
|
18
|
+
let last: CiState = { status: "unknown" };
|
|
19
|
+
for (let poll = 0; poll < options.maxPolls; poll++) {
|
|
20
|
+
const trunk = await getTrunkStatus(options.exec, {
|
|
21
|
+
branch: options.branch,
|
|
22
|
+
cwd: options.cwd,
|
|
23
|
+
});
|
|
24
|
+
last =
|
|
25
|
+
trunk.headSha === undefined
|
|
26
|
+
? { status: trunk.status }
|
|
27
|
+
: { status: trunk.status, sha: trunk.headSha };
|
|
28
|
+
options.onObserved(last);
|
|
29
|
+
if (last.status !== "pending") return last;
|
|
30
|
+
await options.sleep(options.intervalMs);
|
|
31
|
+
}
|
|
32
|
+
return last;
|
|
33
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { parseDeparture } from "../core/departure.ts";
|
|
3
3
|
import {
|
|
4
|
+
CI_STATUSES,
|
|
5
|
+
type CiState,
|
|
6
|
+
type CiStatusName,
|
|
4
7
|
type Departure,
|
|
5
8
|
type DevsysState,
|
|
6
9
|
initialState,
|
|
@@ -29,10 +32,20 @@ const JEV: ReadonlyArray<JevStatus> = ["online", "offline", "unknown"];
|
|
|
29
32
|
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
30
33
|
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
31
34
|
|
|
35
|
+
function parseCi(input: unknown): CiState | ParseError {
|
|
36
|
+
if (!isRecord(input)) return parseError("ci must be an object");
|
|
37
|
+
const { status, sha } = input;
|
|
38
|
+
if (!CI_STATUSES.includes(status as CiStatusName)) {
|
|
39
|
+
return parseError(`unknown ci status: ${String(status)}`);
|
|
40
|
+
}
|
|
41
|
+
if (sha !== undefined && typeof sha !== "string") return parseError("ci.sha must be a string");
|
|
42
|
+
return { status: status as CiStatusName, ...(sha !== undefined ? { sha } : {}) };
|
|
43
|
+
}
|
|
44
|
+
|
|
32
45
|
/** Boundary parse for persisted state: the only place state is cast from `unknown`. */
|
|
33
46
|
export function parseDevsysState(input: unknown): DevsysState | ParseError {
|
|
34
47
|
if (!isRecord(input)) return parseError("devsys state must be an object");
|
|
35
|
-
const { phase, sizing, activeSlice, openDepartures, jev, lastPushAt } = input;
|
|
48
|
+
const { phase, sizing, activeSlice, openDepartures, jev, lastPushAt, ci } = input;
|
|
36
49
|
if (!PHASES.includes(phase as Phase)) return parseError(`unknown phase: ${String(phase)}`);
|
|
37
50
|
if (!JEV.includes(jev as JevStatus)) return parseError(`unknown jev status: ${String(jev)}`);
|
|
38
51
|
if (!Array.isArray(openDepartures)) return parseError("openDepartures must be an array");
|
|
@@ -51,6 +64,8 @@ export function parseDevsysState(input: unknown): DevsysState | ParseError {
|
|
|
51
64
|
if (lastPushAt !== undefined && typeof lastPushAt !== "string") {
|
|
52
65
|
return parseError("lastPushAt must be a string");
|
|
53
66
|
}
|
|
67
|
+
const parsedCi = ci === undefined ? undefined : parseCi(ci);
|
|
68
|
+
if (isParseError(parsedCi)) return parsedCi;
|
|
54
69
|
return {
|
|
55
70
|
phase: phase as Phase,
|
|
56
71
|
jev: jev as JevStatus,
|
|
@@ -58,6 +73,7 @@ export function parseDevsysState(input: unknown): DevsysState | ParseError {
|
|
|
58
73
|
...(sizing !== undefined ? { sizing: sizing as Sizing } : {}),
|
|
59
74
|
...(activeSlice !== undefined ? { activeSlice: activeSlice as SliceRef } : {}),
|
|
60
75
|
...(lastPushAt !== undefined ? { lastPushAt } : {}),
|
|
76
|
+
...(parsedCi !== undefined ? { ci: parsedCi } : {}),
|
|
61
77
|
};
|
|
62
78
|
}
|
|
63
79
|
|