Merge pull request 'S19: shared mini-pc runner and landscape workflows' (#21) from feature/S19_shared-runner into main
All checks were successful
Build, test and publish / verify (push) Successful in 9m3s

This commit was merged in pull request #21.
This commit is contained in:
2026-11-10 09:22:52 +01:00
43 changed files with 265 additions and 548 deletions

View File

@@ -5,7 +5,7 @@ on:
jobs: jobs:
verify: verify:
runs-on: agiledancers-mini-pc runs-on: mini-pc
env: env:
CI: 'true' CI: 'true'
TARGET_PLATFORM: ${{ vars.TARGET_PLATFORM }} TARGET_PLATFORM: ${{ vars.TARGET_PLATFORM }}

View File

@@ -28,7 +28,7 @@ on:
jobs: jobs:
deploy: deploy:
if: gitea.ref == 'refs/heads/main' if: gitea.ref == 'refs/heads/main'
runs-on: agiledancers-mini-pc runs-on: mini-pc
env: env:
CI: 'true' CI: 'true'
GITEA_SERVER_URL: ${{ gitea.server_url }} GITEA_SERVER_URL: ${{ gitea.server_url }}

View File

@@ -5,7 +5,7 @@ on:
jobs: jobs:
rollback: rollback:
if: gitea.ref == 'refs/heads/main' if: gitea.ref == 'refs/heads/main'
runs-on: agiledancers-mini-pc runs-on: mini-pc
env: env:
CI: 'true' CI: 'true'
GITEA_SERVER_URL: ${{ gitea.server_url }} GITEA_SERVER_URL: ${{ gitea.server_url }}

View File

@@ -1,22 +1,17 @@
# Project skills and specifications # Shared skills and specifications
- Project skills are stored in `generated/skills/`; specifications belong in [landscape](http://192.168.2.28:3000/wanjakrah/landscape/src/branch/main/specs). - Shared agent workflows live in [landscape skills](http://192.168.2.28:3000/wanjakrah/landscape/src/branch/main/skills/README.md).
- For code reviews, read and follow `generated/skills/code-review/SKILL.md`. If no Read the requested SKILL.md there before applying it. A sibling checkout is optional.
dedicated Skill tool is available, load the file directly. - For code reviews use landscape's skills/code-review/SKILL.md with docs/backend-review.md.
- Use `grill-me` only when the user explicitly requests it, for example - Use grill-me only when explicitly requested, with its skills/grilling/SKILL.md dependency.
`Use grill-me to review this plan` or `Gebruik grill-me voor dit ontwerp`. Installing or mentioning a skill starts no interview.
- Before using it, read `generated/skills/grill-me/SKILL.md` and its dependency - Keep upstream skills unchanged; licenses and revision records live in landscape/skills/.
`generated/skills/grilling/SKILL.md`. If no dedicated Skill tool is available, load these - Specifications and ADRs belong to landscape. Engineering owns pinned build resources.
files directly and follow their instructions within the available tools.
- Installing or mentioning a skill does not start an interview. Do not apply
`grilling` automatically to ordinary development requests.
- Keep the upstream skill files unchanged. Source revision and license are
recorded in `generated/skills/upstream.json` and `generated/skills/LICENSE.mattpocock`.
# Backend changes and reviews # Backend changes and reviews
- Before changing backend Java, read `CODING_STANDARDS.md`. - Before changing backend Java, read `CODING_STANDARDS.md`.
- For reviews, use `generated/skills/code-review/SKILL.md` together with - For reviews, use landscape's `skills/code-review/SKILL.md` together with
`docs/backend-review.md`. Keep upstream skills unchanged. `docs/backend-review.md`. Keep upstream skills unchanged.
- Obtain maintainer agreement before adding any standards waiver; record its - Obtain maintainer agreement before adding any standards waiver; record its
narrow scope and reason alongside the affected code or configuration. narrow scope and reason alongside the affected code or configuration.

View File

@@ -1,7 +1,7 @@
# Landscape and shared engineering # Landscape and shared engineering
Architecture decisions, specifications and project onboarding are maintained in Architecture decisions, specifications and project onboarding are maintained in
[landscape](http://192.168.2.28:3000/wanjakrah/landscape). Reusable tooling, skills, [landscape](http://192.168.2.28:3000/wanjakrah/landscape). Shared agent skills are maintained in landscape/skills/. Reusable tooling,
standards and the project generator are maintained in standards and the project generator are maintained in
[engineering](http://192.168.2.28:3000/wanjakrah/engineering). [engineering](http://192.168.2.28:3000/wanjakrah/engineering).

View File

@@ -6,6 +6,6 @@ runner:
timeout: 2h timeout: 2h
shutdown_timeout: 2m shutdown_timeout: 2m
labels: labels:
- agiledancers-mini-pc:host - mini-pc:host
cache: cache:
enabled: false enabled: false

View File

@@ -1,15 +1,15 @@
[Unit] [Unit]
Description=Agiledancers Gitea Actions runner Description=mini-pc Gitea Actions runner
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
WorkingDirectory=%h/.local/share/agiledancers-runner WorkingDirectory=%h/.local/share/mini-pc-runner
EnvironmentFile=%h/.config/agiledancers-runner/environment EnvironmentFile=%h/.config/mini-pc-runner/environment
ExecStartPre=/usr/bin/env java %h/.local/share/agiledancers-runner/WaitForDocker.java 120 ExecStartPre=/usr/bin/env java %h/.local/share/mini-pc-runner/WaitForDocker.java 120
TimeoutStartSec=150 TimeoutStartSec=150
ExecStart=%h/.local/bin/act_runner daemon --config %h/.local/share/agiledancers-runner/config.yaml ExecStart=%h/.local/bin/act_runner daemon --config %h/.local/share/mini-pc-runner/config.yaml
Restart=on-failure Restart=on-failure
RestartSec=10 RestartSec=10
KillSignal=SIGTERM KillSignal=SIGTERM

View File

@@ -0,0 +1,12 @@
# S19 review follow-up — 2026-11-10
Engineering v2.0.0 at `46df72f008113d0ed683a6c7344b1d7309564d84` is pinned and committed.
Regeneration and bootstrap --check passed against the published tag and full commit.
Shared engineering tests passed: 8 bootstrap/generator, 21 CI and 34 release tests;
formatter integration passed. Java launcher documentation, varargs and console API updated.
Workflows use the shared user-scoped mini-pc runner for wanjakrah. It serves current
and future repositories without per-project registration. Skills are maintained in
landscape/skills/ and project agent instructions reference those workflows.
Project deployment credentials and runtime configuration remain application-owned.
No production deployment was performed.

View File

@@ -1,7 +1,7 @@
# Agile Dancers backend review companion # Agile Dancers backend review companion
Use [the shared review guidance](../generated/docs/backend-review.md), Use [the shared review guidance](../generated/docs/backend-review.md),
[code-review skill](../generated/skills/code-review/SKILL.md), and [code-review skill](http://192.168.2.28:3000/wanjakrah/landscape/src/branch/main/skills/code-review/SKILL.md), and
[project standards](../CODING_STANDARDS.md). [project standards](../CODING_STANDARDS.md).
The backend module is agiledancers-be. The approved Group constructor waiver The backend module is agiledancers-be. The approved Group constructor waiver

View File

@@ -36,7 +36,7 @@ Gitea 1.25.4 en de registry staan op de bestaande Synology,
Linux x86_64 mini-pc. Er is geen extra hardware nodig. De mini-pc moet aanstaan en Linux x86_64 mini-pc. Er is geen extra hardware nodig. De mini-pc moet aanstaan en
wakker blijven; Docker en de runner moeten draaien. Anders blijven jobs wachten. wakker blijven; Docker en de runner moeten draaien. Anders blijven jobs wachten.
Een host-runner voert repositorycode uit als de ontwikkelgebruiker: gebruik deze Een host-runner voert repositorycode uit als de ontwikkelgebruiker: gebruik deze
runner alleen voor deze vertrouwde repository. runner alleen voor de vertrouwde repositories van deze eigenaar.
De runner heeft Git, Bash, JDK 25, Maven 3.9+, Node 22.22.3+/24.15.0+/26+, npm De runner heeft Git, Bash, JDK 25, Maven 3.9+, Node 22.22.3+/24.15.0+/26+, npm
en een werkende Docker-daemon en een werkende Docker-daemon
@@ -87,7 +87,7 @@ Docker-clients die images van deze interne HTTP-registry moeten ophalen.
Gebruik de Docker Desktop-context of zet `DOCKER_HOST` expliciet. De installer Gebruik de Docker Desktop-context of zet `DOCKER_HOST` expliciet. De installer
neemt de geselecteerde socket over in de gebruikersservice. Als de socket later neemt de geselecteerde socket over in de gebruikersservice. Als de socket later
wijzigt, pas dan `~/.config/agiledancers-runner/environment` aan en herstart de wijzigt, pas dan `~/.config/mini-pc-runner/environment` aan en herstart de
runner. Bij Docker Desktop blijft de Desktop-VM eveneens nodig voor builds. runner. Bij Docker Desktop blijft de Desktop-VM eveneens nodig voor builds.
Runner en containers moeten de Synology en internetdownloads kunnen bereiken. Runner en containers moeten de Synology en internetdownloads kunnen bereiken.
@@ -109,12 +109,13 @@ Stel bij repository **Settings → Actions** in:
| Secret | `REGISTRY_TOKEN` | PAT met `write:package` en `read:repository` voor imagepublicatie en controle van de huidige hoofdbranch | | Secret | `REGISTRY_TOKEN` | PAT met `write:package` en `read:repository` voor imagepublicatie en controle van de huidige hoofdbranch |
Het gewone jobtoken van Gitea 1.25 biedt geen packagepublicatie; gebruik een PAT. Het gewone jobtoken van Gitea 1.25 biedt geen packagepublicatie; gebruik een PAT.
Gebruik een repository-runnerregistratietoken, geen instancebrede registratie. Gebruik de gedeelde user-scoped runner `mini-pc` voor `wanjakrah`; een nieuwe
repository heeft geen eigen runnerregistratie nodig.
Sla tokenwaarden buiten Git op en plaats ze niet in opdrachten, screenshots of logs. Sla tokenwaarden buiten Git op en plaats ze niet in opdrachten, screenshots of logs.
Voor reproduceerbare configuratie bestaat `java generated/ci/Ci.java configure-gitea`. Maak twee Voor reproduceerbare configuratie bestaat `java generated/ci/Ci.java configure-gitea`. Maak twee
bestanden buiten de repository met mode `600`: één voor een PAT met voldoende bestanden buiten de repository met mode `600`: één voor een PAT met voldoende
repositorybeheerrechten (repository Actions, secrets, variabelen en runnerregistratie), repositorybeheerrechten (repository Actions, secrets en variabelen),
en één voor de publicatie-PAT. De beheer-PAT hoeft niet als workflowsecret te worden en één voor de publicatie-PAT. De beheer-PAT hoeft niet als workflowsecret te worden
bewaard. Vul die bestanden via een lokale editor of verborgen terminalinvoer. bewaard. Vul die bestanden via een lokale editor of verborgen terminalinvoer.
Voer daarna uit met de werkelijke platformwaarde: Voer daarna uit met de werkelijke platformwaarde:
@@ -127,11 +128,9 @@ export TARGET_PLATFORM=linux/amd64 # alleen als dit op de Synology is vastgeste
java generated/ci/Ci.java configure-gitea java generated/ci/Ci.java configure-gitea
``` ```
Het script stelt de repository-instellingen in via de API en schrijft het Het script stelt alleen de applicatie-instellingen, registrysecrets en platformwaarde
runnerregistratietoken naar in. De gedeelde runnerregistratie wordt eenmalig beheerd vanuit engineering.
`~/.config/agiledancers-runner/registration-token` met mode `600`. Het toont geen Applicatiespecifieke deploymentcredentials blijven onder ~/.config/agiledancers-runner/.
tokenwaarden. Voor handmatige inrichting kun je dat registratietoken uit de
repository-instellingen halen en in hetzelfde bestand plaatsen.
De bevestigde Gitea 1.25.4-configuratie heeft geen `[actions]`-sectie: de defaults De bevestigde Gitea 1.25.4-configuratie heeft geen `[actions]`-sectie: de defaults
zijn negentig dagen voor artifacts en 365 dagen voor logs. Een workflow vraagt veertien dagen zijn negentig dagen voor artifacts en 365 dagen voor logs. Een workflow vraagt veertien dagen
@@ -139,43 +138,30 @@ via `retention-days: 14`. Een kortere serverlimiet of het vroegtijdig verwijdere
van workflowruns mag die periode niet inkorten. Artifactretentie geldt voor van workflowruns mag die periode niet inkorten. Artifactretentie geldt voor
testdiagnostiek; gepubliceerde containerimages worden niet automatisch verwijderd. testdiagnostiek; gepubliceerde containerimages worden niet automatisch verwijderd.
## Runner installeren en starten ## Gedeelde runner gebruiken
De fysieke mini-pc draait `mini-pc-runner.service` met label `mini-pc:host`,
geregistreerd op gebruikersniveau voor `wanjakrah`. Daardoor kunnen bestaande en
nieuwe repositories van deze eigenaar dezelfde runner gebruiken. De gedeelde
capaciteit is één job tegelijk. De workflowbestanden gebruiken `runs-on: mini-pc`.
Voor eenmalige registratie, installatie en migratie volg je de
[engineering-instructies](http://192.168.2.28:3000/wanjakrah/engineering/src/tag/v2.0.0/README.md).
Controleer dit project met `java generated/ci/Ci.java check-runner`; gebruik daarbij
het in engineering beschreven owner-only tokenbestand voor de gedeelde setup.
```sh ```sh
export JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 systemctl --user status mini-pc-runner
java generated/ci/Ci.java install-runner --prepare-only journalctl --user -u mini-pc-runner -f
# Na Dockerconfiguratie en het opslaan van het registratietoken: systemctl --user restart mini-pc-runner
java generated/ci/Ci.java install-runner
systemctl --user status agiledancers-runner
journalctl --user -u agiledancers-runner -f
``` ```
De installer controleert tools, installeert checksum-gecontroleerde runner Runnerstate staat onder ~/.local/share/mini-pc-runner/ en serviceconfiguratie onder
`v0.2.13`, neemt label `agiledancers-mini-pc:host` over en registreert de runner. ~/.config/mini-pc-runner/. De runner start samen met Docker Desktop na grafisch
Configuratie en `.runner`-credentials staan in aanmelden, wacht maximaal 120 seconden op Docker en probeert een mislukte start
`~/.local/share/agiledancers-runner`, de service in opnieuw. User lingering staat uit: vóór aanmelden of bij een uitgeschakelde mini-pc
`~/.config/systemd/user/agiledancers-runner.service` en de expliciete Java-, PATH- is de runner niet beschikbaar. De oude agiledancers-runnerstate blijft voor herstel
en Docker-socketinstellingen in `~/.config/agiledancers-runner/environment`. bewaard; applicatiespecifieke deploymenttokens en SSH-sleutels blijven op hun eigen paden.
Een bestaande registratie wordt hergebruikt. Er worden geen secrets in de
repository geschreven. Bewaar het tokenbestand alleen zolang registratie/herstel
dat nodig heeft en behandel `.runner` als credential.
Op de mini-pc starten Docker Desktop en de runner automatisch na grafisch
aanmelden. Bij een Docker Desktop-socket installeert de installer hiervoor
`~/.config/systemd/user/agiledancers-runner.service.d/10-agiledancers-docker-desktop.conf`
en schakelt beide services in voor `graphical-session.target`. De runner wacht
via `~/.local/share/agiledancers-runner/WaitForDocker.java` maximaal 120 seconden
tot de ingestelde Docker-engine bereikbaar is. Bij een mislukte start probeert
systemd het na tien seconden opnieuw. De runner volgt stoppen en herstarten van
Docker Desktop en stopt bij afmelden.
User lingering staat op deze pc uit: vóór aanmelden starten deze services niet.
S4 belooft geen beschikbaarheid wanneer de mini-pc uitstaat. Herstarten/stoppen:
```sh
systemctl --user restart agiledancers-runner
systemctl --user stop agiledancers-runner
```
## Images en publicatie ## Images en publicatie

View File

@@ -1,5 +1,5 @@
{ {
"repository": "http://192.168.2.28:3000/wanjakrah/engineering.git", "repository": "http://192.168.2.28:3000/wanjakrah/engineering.git",
"tag": "v1.0.0", "tag": "v2.0.0",
"commit": "c25ce11a1bb8bf97f24d5e2b12d98680a9c573ca" "commit": "46df72f008113d0ed683a6c7344b1d7309564d84"
} }

View File

@@ -16,7 +16,9 @@ TARGET_PLATFORM=linux/amd64
GITEA_SERVER_URL=http://192.168.2.28:3000 GITEA_SERVER_URL=http://192.168.2.28:3000
CI_REPOSITORY=wanjakrah/agiledancers CI_REPOSITORY=wanjakrah/agiledancers
INSECURE_REGISTRY=192.168.2.28:3000 INSECURE_REGISTRY=192.168.2.28:3000
RUNNER_LABEL=agiledancers-mini-pc RUNNER_LABEL=mini-pc
RUNNER_NAME=mini-pc
RUNNER_SCOPE=user
DEPLOY_HOST=192.168.2.28 DEPLOY_HOST=192.168.2.28
DEPLOY_USER=agiledancers-deploy DEPLOY_USER=agiledancers-deploy
DEPLOY_SSH_PORT=22 DEPLOY_SSH_PORT=22

View File

@@ -153,7 +153,7 @@ exactly at a blocking limit passes.
repeating checks already enforced by tooling. repeating checks already enforced by tooling.
- Keep upstream skill files unchanged. Put project-specific code-review guidance - Keep upstream skill files unchanged. Put project-specific code-review guidance
in a companion file referenced by project instructions and used alongside in a companion file referenced by project instructions and used alongside
`skills/code-review/SKILL.md`. landscape's `skills/code-review/SKILL.md`.
- Waivers must be explicit, narrowly scoped and carry a recorded reason alongside - Waivers must be explicit, narrowly scoped and carry a recorded reason alongside
code or configuration. Agents need maintainer agreement before introducing one. code or configuration. Agents need maintainer agreement before introducing one.
No blanket suppressions, exclusions of difficult files or weakened thresholds No blanket suppressions, exclusions of difficult files or weakened thresholds

View File

@@ -1,5 +1,5 @@
{ {
"commit": "c25ce11a1bb8bf97f24d5e2b12d98680a9c573ca", "commit": "46df72f008113d0ed683a6c7344b1d7309564d84",
"repository": "http://192.168.2.28:3000/wanjakrah/engineering.git", "repository": "http://192.168.2.28:3000/wanjakrah/engineering.git",
"tag": "v1.0.0" "tag": "v2.0.0"
} }

View File

@@ -3,9 +3,15 @@ import java.time.*;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.*; import java.util.*;
/** Run from the repository root with JDK 25: java ci/Ci.java <command>. */ /** Run from the repository root with JDK 25: java generated/ci/Ci.java <command>. */
public class Ci { public class Ci {
public static void main(String[] args) { /**
* Runs a CI, release or workstation setup command from the consumer repository root.
*
* @param args command followed by its optional arguments; configuration comes from the project and environment
* @implNote Requires JDK 25. Failures exit nonzero with redacted diagnostics; setup commands can change local or Gitea state.
*/
public static void main(final String... args) {
try { try {
if (Runtime.version().feature() < 25) throw new IllegalArgumentException("JDK 25 or newer is required"); if (Runtime.version().feature() < 25) throw new IllegalArgumentException("JDK 25 or newer is required");
if (args.length == 0) throw new IllegalArgumentException(usage()); if (args.length == 0) throw new IllegalArgumentException(usage());
@@ -30,6 +36,8 @@ public class Ci {
file, "CI_RUN_ATTEMPT=" + id + "\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND); file, "CI_RUN_ATTEMPT=" + id + "\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} }
case "configure-gitea" -> Setup.configureGitea(env); case "configure-gitea" -> Setup.configureGitea(env);
case "configure-runner" -> Setup.configureRunner(env);
case "check-runner" -> Setup.checkRunner(env);
case "configure-docker" -> { case "configure-docker" -> {
if (args.length != 2) throw new IllegalArgumentException("Supply the daemon.json path"); if (args.length != 2) throw new IllegalArgumentException("Supply the daemon.json path");
Setup.configureDocker(Path.of(args[1]), Setup.required(env, "INSECURE_REGISTRY")); Setup.configureDocker(Path.of(args[1]), Setup.required(env, "INSECURE_REGISTRY"));
@@ -56,7 +64,8 @@ public class Ci {
} }
static String usage() { static String usage() {
return "Usage: java ci/Ci.java verify|publish|cleanup|attempt|test|configure-gitea|configure-docker" return "Usage: java generated/ci/Ci.java"
+ " verify|publish|cleanup|attempt|test|configure-gitea|configure-runner|check-runner|configure-docker"
+ " <path>|install-runner [--prepare-only]|deploy|rollback|release-test|configure-deployment"; + " <path>|install-runner [--prepare-only]|deploy|rollback|release-test|configure-deployment";
} }
} }

View File

@@ -14,7 +14,7 @@ class CiTests {
static void test(String name, Test test) throws Exception { static void test(String name, Test test) throws Exception {
test.run(); test.run();
count++; count++;
System.out.println("PASS " + name); IO.println("PASS " + name);
} }
static void equal(Object expected, Object actual) { static void equal(Object expected, Object actual) {
@@ -32,6 +32,55 @@ class CiTests {
static void run() throws Exception { static void run() throws Exception {
count = 0; count = 0;
test("shared runner scope and identity are independent of the application", () -> {
equal("mini-pc", Setup.runnerName(Map.of("PROJECT_NAME", "first")));
equal("mini-pc", Setup.runnerName(Map.of("PROJECT_NAME", "second")));
equal("/api/v1/user/actions/runners/registration-token", Setup.runnerRegistrationPath(Map.of()));
equal(
"/api/v1/admin/actions/runners/registration-token",
Setup.runnerRegistrationPath(Map.of("RUNNER_SCOPE", "instance")));
equal(
"/api/v1/orgs/team/actions/runners/registration-token",
Setup.runnerRegistrationPath(
Map.of("RUNNER_SCOPE", "organization", "RUNNER_ORGANIZATION", "team")));
equal(
"/api/v1/repos/owner/app/actions/runners/registration-token",
Setup.runnerRegistrationPath(Map.of("RUNNER_SCOPE", "repository", "CI_REPOSITORY", "owner/app")));
failure(() -> Setup.runnerRegistrationPath(Map.of("RUNNER_SCOPE", "unknown")));
failure(() -> Setup.runnerRegistrationPath(
Map.of("RUNNER_SCOPE", "organization", "RUNNER_ORGANIZATION", "../unsafe")));
failure(() -> Setup.runnerName(Map.of("RUNNER_NAME", "../unsafe")));
});
test("runner preflight rejects unavailable offline and differently labelled runners", () -> {
equal(false, Setup.hasRunner(Map.of("runners", List.of()), "mini-pc"));
equal(
false,
Setup.hasRunner(
Map.of(
"runners",
List.of(Map.of("status", "offline", "labels", List.of(Map.of("name", "mini-pc"))))),
"mini-pc"));
equal(
false,
Setup.hasRunner(
Map.of(
"runners",
List.of(Map.of("status", "online", "labels", List.of(Map.of("name", "other"))))),
"mini-pc"));
equal(
true,
Setup.hasRunner(
Map.of(
"runners",
List.of(Map.of(
"status",
"online",
"busy",
true,
"labels",
List.of(Map.of("name", "mini-pc"))))),
"mini-pc"));
});
test("recipe supports backend-only and frontend-only projects", () -> { test("recipe supports backend-only and frontend-only projects", () -> {
equal(List.of("backend"), Recipe.roles(Map.of("BACKEND_MODULE", "server"))); equal(List.of("backend"), Recipe.roles(Map.of("BACKEND_MODULE", "server")));
equal(List.of("frontend"), Recipe.roles(Map.of("FRONTEND_MODULE", "web"))); equal(List.of("frontend"), Recipe.roles(Map.of("FRONTEND_MODULE", "web")));
@@ -253,7 +302,7 @@ class CiTests {
Pipeline.deleteTree(dir); Pipeline.deleteTree(dir);
} }
}); });
System.out.println(count + " CI tests passed"); IO.println(count + " CI tests passed");
} }
static class Fixture implements AutoCloseable { static class Fixture implements AutoCloseable {

View File

@@ -377,7 +377,7 @@ class Pipeline {
void publish() throws Exception { void publish() throws Exception {
if (!"refs/heads/main".equals(env.get("CI_REF"))) { if (!"refs/heads/main".equals(env.get("CI_REF"))) {
System.out.println("Branch is not main; no publication"); IO.println("Branch is not main; no publication");
return; return;
} }
check( check(
@@ -425,7 +425,7 @@ class Pipeline {
} }
for (String role : Recipe.roles(env)) { for (String role : Recipe.roles(env)) {
if (!commit.equals(mainHead())) { if (!commit.equals(mainHead())) {
System.out.println("Newer main exists; moving tags unchanged"); IO.println("Newer main exists; moving tags unchanged");
return; return;
} }
String image = prefix + role; String image = prefix + role;
@@ -462,7 +462,7 @@ class Pipeline {
void ensureImage(String repository, String source) throws Exception { void ensureImage(String repository, String source) throws Exception {
String ref = repository + ":" + commit; String ref = repository + ":" + commit;
var manifest = result(false, "docker", "manifest", "inspect", "--insecure", ref); var manifest = result(false, "docker", "manifest", "inspect", "--insecure", ref);
if (manifest.code() == 0) System.out.println("Preserving existing " + ref); if (manifest.code() == 0) IO.println("Preserving existing " + ref);
else if (manifest.output().toLowerCase(Locale.ROOT).matches("(?s).*(manifest unknown|no such manifest).*")) { else if (manifest.output().toLowerCase(Locale.ROOT).matches("(?s).*(manifest unknown|no such manifest).*")) {
run("docker", "tag", source, ref); run("docker", "tag", source, ref);
run("docker", "push", ref); run("docker", "push", ref);

View File

@@ -75,7 +75,7 @@ class Release {
remote.call("preflight", null); remote.call("preflight", null);
ReleaseState before = ReleaseState.parse(remote.call("state", null)); ReleaseState before = ReleaseState.parse(remote.call("state", null));
selected = select(before); selected = select(before);
System.out.println("Selected " + selected.version() + " at " + selected.sha()); IO.println("Selected " + selected.version() + " at " + selected.sha());
// Validate the recovery pair before any replacement too. // Validate the recovery pair before any replacement too.
if (before.current() != null) ensure(before.current()); if (before.current() != null) ensure(before.current());
if (mode.equals("new")) { if (mode.equals("new")) {
@@ -108,7 +108,7 @@ class Release {
record(outcome); record(outcome);
remote.call("finish", null); remote.call("finish", null);
pending = false; pending = false;
System.out.println("Production " + selected.version() + " is healthy (" + selected.sha() + ")"); IO.println("Production " + selected.version() + " is healthy (" + selected.sha() + ")");
} finally { } finally {
try { try {
record(outcome); record(outcome);

View File

@@ -61,7 +61,7 @@ class ReleaseSetup {
env.getOrDefault("PULL_USER", env.get("CI_REPOSITORY").split("/")[0]); env.getOrDefault("PULL_USER", env.get("CI_REPOSITORY").split("/")[0]);
Pipeline.check(registryUser.matches("[a-zA-Z0-9_.-]+"), "Invalid registry user"); Pipeline.check(registryUser.matches("[a-zA-Z0-9_.-]+"), "Invalid registry user");
try (var remote = new ReleaseRemote(root, env, commands, "setup")) { try (var remote = new ReleaseRemote(root, env, commands, "setup")) {
System.out.println(remote.executeScript( IO.println(remote.executeScript(
Files.readString(root.resolve("generated/deploy/provision.sh")), Files.readString(root.resolve("generated/deploy/provision.sh")),
pull + "\n", pull + "\n",
300, 300,
@@ -86,7 +86,7 @@ class ReleaseSetup {
for (String variable : for (String variable :
List.of("DEPLOY_HOST", "DEPLOY_USER", "DEPLOY_SSH_PORT", "DEPLOY_DIRECTORY", "DEPLOY_REGISTRY")) List.of("DEPLOY_HOST", "DEPLOY_USER", "DEPLOY_SSH_PORT", "DEPLOY_DIRECTORY", "DEPLOY_REGISTRY"))
variable(api, admin, variable, env.get(variable)); variable(api, admin, variable, env.get(variable));
System.out.println( IO.println(
"Production configuration and Gitea deployment secrets/variables installed; no release was created."); "Production configuration and Gitea deployment secrets/variables installed; no release was created.");
} }

View File

@@ -14,7 +14,7 @@ class ReleaseTests {
static void test(String name, CiTests.Test test) throws Exception { static void test(String name, CiTests.Test test) throws Exception {
test.run(); test.run();
count++; count++;
System.out.println("PASS release: " + name); IO.println("PASS release: " + name);
} }
static Map<String, String> environment() { static Map<String, String> environment() {
@@ -252,7 +252,7 @@ class ReleaseTests {
apiTests(); apiTests();
shellTests(); shellTests();
ReleaseShellTests.run(); ReleaseShellTests.run();
System.out.println(count + " release behavior tests passed"); IO.println(count + " release behavior tests passed");
} }
static class FakeApi extends ReleaseApi { static class FakeApi extends ReleaseApi {

View File

@@ -79,12 +79,79 @@ class Setup {
exists = false; exists = false;
} }
api(server, admin, exists ? "PUT" : "POST", variable, Map.of("value", platform)); api(server, admin, exists ? "PUT" : "POST", variable, Map.of("value", platform));
Map<String, Object> response = IO.println("Actions, project registry secrets and platform configured; using the shared runner.");
Json.object(api(server, admin, "POST", base + "/actions/runners/registration-token", null)); }
Path directory = home().resolve(".config/" + Recipe.project(env) + "-runner");
static String runnerName(final Map<String, String> env) {
final String name = env.getOrDefault("RUNNER_NAME", env.getOrDefault("RUNNER_LABEL", "mini-pc"));
Pipeline.check(name.matches("[a-z][a-z0-9-]*"), "Invalid runner name");
return name;
}
static String runnerRegistrationPath(final Map<String, String> env) {
return switch (env.getOrDefault("RUNNER_SCOPE", "user")) {
case "user" -> "/api/v1/user/actions/runners/registration-token";
case "instance" -> "/api/v1/admin/actions/runners/registration-token";
case "organization" -> {
final String organization = required(env, "RUNNER_ORGANIZATION");
Pipeline.check(organization.matches("[a-zA-Z0-9_-]+"), "Invalid runner organization");
yield "/api/v1/orgs/" + organization + "/actions/runners/registration-token";
}
case "repository" ->
"/api/v1/repos/" + required(env, "CI_REPOSITORY") + "/actions/runners/registration-token";
default ->
throw new IllegalArgumentException("Runner scope must be user, organization, instance or repository");
};
}
static void configureRunner(final Map<String, String> env) throws Exception {
final String admin = secret(env, "GITEA_ADMIN_TOKEN_FILE");
final Map<String, Object> response =
Json.object(api(required(env, "GITEA_SERVER_URL"), admin, "POST", runnerRegistrationPath(env), null));
final Path directory = home().resolve(".config/" + runnerName(env) + "-runner");
privateDirectory(directory); privateDirectory(directory);
privateWrite(directory.resolve("registration-token"), Objects.toString(response.get("token"))); privateWrite(directory.resolve("registration-token"), Objects.toString(response.get("token")));
System.out.println("Actions, registry secrets and platform configured; runner token saved outside Git."); IO.println("Shared runner registration token saved outside Git; run install-runner once on the workstation.");
}
static void checkRunner(final Map<String, String> env) throws Exception {
final String token = secret(env, "GITEA_ADMIN_TOKEN_FILE");
final String repository = required(env, "CI_REPOSITORY");
final String server = required(env, "GITEA_SERVER_URL");
final Map<String, Object> project = Json.object(api(server, token, "GET", "/api/v1/repos/" + repository, null));
Pipeline.check(Boolean.TRUE.equals(project.get("has_actions")), "Enable Actions for the repository first");
if ("user".equals(env.getOrDefault("RUNNER_SCOPE", "user"))) {
final Map<String, Object> user = Json.object(api(server, token, "GET", "/api/v1/user", null));
Pipeline.check(
repository.split("/", 2)[0].equalsIgnoreCase(Objects.toString(user.get("login"))),
"User-scoped runner must belong to the repository owner; use organization scope for an"
+ " organization");
}
// Gitea's repository runner list excludes inherited user/organization runners.
final String base = runnerRegistrationPath(env).replace("/registration-token", "");
final Map<String, Object> response =
Json.object(api(required(env, "GITEA_SERVER_URL"), token, "GET", base, null));
Pipeline.check(
hasRunner(response, env.getOrDefault("RUNNER_LABEL", "mini-pc")),
"No online shared runner with the configured label is available to this repository");
IO.println("Shared runner is online and available to " + required(env, "CI_REPOSITORY"));
}
static boolean hasRunner(final Map<String, Object> response, final String label) {
for (final Object entry : (List<?>) response.getOrDefault("runners", List.of())) {
final Map<String, Object> runner = Json.object(entry);
if (!"online".equals(runner.get("status"))
&& !"idle".equals(runner.get("status"))
&& !"active".equals(runner.get("status"))) {
continue;
}
for (final Object item : (List<?>) runner.getOrDefault("labels", List.of())) {
if (label.equals(Json.object(item).get("name"))) {
return true;
}
}
}
return false;
} }
static void configureDocker(Path file, String registry) throws IOException { static void configureDocker(Path file, String registry) throws IOException {
@@ -95,7 +162,7 @@ class Setup {
throw new IllegalArgumentException("Existing insecure-registries must be an array; unchanged"); throw new IllegalArgumentException("Existing insecure-registries must be an array; unchanged");
List<Object> registries = new ArrayList<>((List<?>) entry); List<Object> registries = new ArrayList<>((List<?>) entry);
if (registries.contains(registry)) { if (registries.contains(registry)) {
System.out.println("Internal registry already configured; no change"); IO.println("Internal registry already configured; no change");
return; return;
} }
registries.add(registry); registries.add(registry);
@@ -114,7 +181,7 @@ class Setup {
} finally { } finally {
Files.deleteIfExists(temp); Files.deleteIfExists(temp);
} }
System.out.println("Internal registry added; restart/apply Docker Engine configuration to activate"); IO.println("Internal registry added; restart/apply Docker Engine configuration to activate");
} }
static void installRunner(Path root, Map<String, String> env, Commands commands, boolean prepareOnly) static void installRunner(Path root, Map<String, String> env, Commands commands, boolean prepareOnly)
@@ -123,8 +190,8 @@ class Setup {
System.getProperty("os.name").equals("Linux") System.getProperty("os.name").equals("Linux")
&& Set.of("amd64", "x86_64").contains(System.getProperty("os.arch")), && Set.of("amd64", "x86_64").contains(System.getProperty("os.arch")),
"Installer targets the existing Linux x86_64 mini-PC"); "Installer targets the existing Linux x86_64 mini-PC");
Path runnerDir = home().resolve(".local/share/" + Recipe.project(env) + "-runner"), Path runnerDir = home().resolve(".local/share/" + runnerName(env) + "-runner"),
configDir = home().resolve(".config/" + Recipe.project(env) + "-runner"); configDir = home().resolve(".config/" + runnerName(env) + "-runner");
Path bin = home().resolve(".local/bin"), units = home().resolve(".config/systemd/user"); Path bin = home().resolve(".local/bin"), units = home().resolve(".config/systemd/user");
for (Path path : List.of(runnerDir, configDir, bin, units)) privateDirectory(path); for (Path path : List.of(runnerDir, configDir, bin, units)) privateDirectory(path);
for (String tool : List.of("node", "java", "mvn", "docker", "git", "systemctl")) executable(env, tool); for (String tool : List.of("node", "java", "mvn", "docker", "git", "systemctl")) executable(env, tool);
@@ -164,11 +231,14 @@ class Setup {
String actual = commands.execute(List.of(runner.toString(), "--version"), root, env, null, false) String actual = commands.execute(List.of(runner.toString(), "--version"), root, env, null, false)
.checked(); .checked();
Pipeline.check(actual.contains("v0.2.13"), "Expected runner v0.2.13"); Pipeline.check(actual.contains("v0.2.13"), "Expected runner v0.2.13");
privateWrite(runnerDir.resolve("config.yaml"), Files.readString(root.resolve("ci/runner/config.yaml"))); privateWrite(
Files.copy( runnerDir.resolve("config.yaml"),
root.resolve("ci/runner/runner.service"), Files.readString(root.resolve("ci/runner/config.yaml"))
units.resolve(Recipe.project(env) + "-runner.service"), .replace("@RUNNER@", env.getOrDefault("RUNNER_LABEL", "mini-pc")));
StandardCopyOption.REPLACE_EXISTING); privateWrite(
units.resolve(runnerName(env) + "-runner.service"),
Files.readString(root.resolve("ci/runner/runner.service")).replace("@RUNNER_NAME@", runnerName(env)));
Files.copy( Files.copy(
root.resolve("generated/ci/runner/WaitForDocker.java"), root.resolve("generated/ci/runner/WaitForDocker.java"),
runnerDir.resolve("WaitForDocker.java"), runnerDir.resolve("WaitForDocker.java"),
@@ -189,7 +259,7 @@ class Setup {
"JAVA_HOME=" + quoted(javaHome) + "\nPATH=" + quoted(path) + "\nDOCKER_HOST=" + quoted(dockerHost) "JAVA_HOME=" + quoted(javaHome) + "\nPATH=" + quoted(path) + "\nDOCKER_HOST=" + quoted(dockerHost)
+ "\n"); + "\n");
boolean desktop = dockerHost.endsWith("/.docker/desktop/docker.sock"); boolean desktop = dockerHost.endsWith("/.docker/desktop/docker.sock");
Path overrides = units.resolve(Recipe.project(env) + "-runner.service.d"); Path overrides = units.resolve(runnerName(env) + "-runner.service.d");
Path desktopOverride = overrides.resolve("10-docker-desktop.conf"); Path desktopOverride = overrides.resolve("10-docker-desktop.conf");
if (desktop) { if (desktop) {
Files.createDirectories(overrides); Files.createDirectories(overrides);
@@ -203,7 +273,7 @@ class Setup {
commands.execute(List.of("systemctl", "--user", "daemon-reload"), root, env, null, true) commands.execute(List.of("systemctl", "--user", "daemon-reload"), root, env, null, true)
.checked(); .checked();
if (prepareOnly) { if (prepareOnly) {
System.out.println("Runner installed; registration and startup still required."); IO.println("Runner installed; registration and startup still required.");
return; return;
} }
commands.execute(List.of("docker", "info"), root, env, null, false).checked(); commands.execute(List.of("docker", "info"), root, env, null, false).checked();
@@ -221,9 +291,9 @@ class Setup {
"--instance", "--instance",
required(env, "GITEA_SERVER_URL"), required(env, "GITEA_SERVER_URL"),
"--name", "--name",
required(env, "RUNNER_LABEL"), runnerName(env),
"--labels", "--labels",
required(env, "RUNNER_LABEL") + ":host", env.getOrDefault("RUNNER_LABEL", "mini-pc") + ":host",
"--token", "--token",
token), token),
runnerDir, runnerDir,
@@ -243,7 +313,7 @@ class Setup {
.checked(); .checked();
// Replace any previous default.target link with the desktop-login target. // Replace any previous default.target link with the desktop-login target.
commands.execute( commands.execute(
List.of("systemctl", "--user", "disable", Recipe.project(env) + "-runner.service"), List.of("systemctl", "--user", "disable", runnerName(env) + "-runner.service"),
root, root,
env, env,
null, null,
@@ -251,13 +321,13 @@ class Setup {
.checked(); .checked();
} }
commands.execute( commands.execute(
List.of("systemctl", "--user", "enable", "--now", Recipe.project(env) + "-runner.service"), List.of("systemctl", "--user", "enable", "--now", runnerName(env) + "-runner.service"),
root, root,
env, env,
null, null,
true) true)
.checked(); .checked();
System.out.println("Runner registered and started; keep the mini-PC and Docker available."); IO.println("Runner registered and started; keep the mini-PC and Docker available.");
} }
static Path executable(Map<String, String> env, String name) { static Path executable(Map<String, String> env, String name) {

View File

@@ -3,11 +3,18 @@ import java.util.concurrent.TimeUnit;
/** Source-launcher startup probe; uses the service's configured DOCKER_HOST. */ /** Source-launcher startup probe; uses the service's configured DOCKER_HOST. */
class WaitForDocker { class WaitForDocker {
public static void main(String[] args) throws Exception { /**
* Waits for the configured Docker daemon before allowing the runner service to start.
*
* @param args optional timeout in seconds, from 1 through 300; defaults to 120
* @throws Exception if the timeout is invalid or the Docker probe cannot run
* @implNote Exits nonzero if Docker does not become ready before the deadline.
*/
public static void main(final String... args) throws Exception {
long seconds = args.length == 0 ? 120 : Long.parseLong(args[0]); long seconds = args.length == 0 ? 120 : Long.parseLong(args[0]);
if (seconds < 1 || seconds > 300) throw new IllegalArgumentException("Timeout must be 1–300 seconds"); if (seconds < 1 || seconds > 300) throw new IllegalArgumentException("Timeout must be 1–300 seconds");
long deadline = System.nanoTime() + Duration.ofSeconds(seconds).toNanos(); long deadline = System.nanoTime() + Duration.ofSeconds(seconds).toNanos();
System.out.println("Waiting for the configured Docker engine..."); IO.println("Waiting for the configured Docker engine...");
while (System.nanoTime() < deadline) { while (System.nanoTime() < deadline) {
Process process = new ProcessBuilder("docker", "info", "--format", "{{.ServerVersion}}") Process process = new ProcessBuilder("docker", "info", "--format", "{{.ServerVersion}}")
.redirectOutput(ProcessBuilder.Redirect.DISCARD) .redirectOutput(ProcessBuilder.Redirect.DISCARD)
@@ -17,7 +24,7 @@ class WaitForDocker {
long remaining = Math.max(1, deadline - System.nanoTime()); long remaining = Math.max(1, deadline - System.nanoTime());
if (process.waitFor(Math.min(remaining, TimeUnit.SECONDS.toNanos(5)), TimeUnit.NANOSECONDS) if (process.waitFor(Math.min(remaining, TimeUnit.SECONDS.toNanos(5)), TimeUnit.NANOSECONDS)
&& process.exitValue() == 0) { && process.exitValue() == 0) {
System.out.println("Docker is available; starting the Gitea runner."); IO.println("Docker is available; starting the Gitea runner.");
return; return;
} }
} finally { } finally {

View File

@@ -1,6 +1,6 @@
# Backend review companion # Backend review companion
Use alongside `generated/skills/code-review/SKILL.md` and `generated/CODING_STANDARDS.md`. Use alongside landscape's `skills/code-review/SKILL.md` and `generated/CODING_STANDARDS.md`.
Review changed code for intent-revealing comments, missing public-contract Javadoc, Review changed code for intent-revealing comments, missing public-contract Javadoc,
appropriate record use, defensive copying of mutable record components, and appropriate record use, defensive copying of mutable record components, and

View File

@@ -1,23 +0,0 @@
# Engineering skills
Reusable agent instructions for consuming repositories. Invoke them explicitly in the
conversation, for example:
> Gebruik grill-me om de JSON-import en -export met mij uit te werken.
## Installed skills
| Skill | Purpose |
| --- | --- |
| [code-review](code-review/SKILL.md) | Review changes against documented coding standards and the originating spec. |
| [grill-me](grill-me/SKILL.md) | Entry point for reviewing a plan or design through questions. |
| [grilling](grilling/SKILL.md) | Interview instructions required by `grill-me`. |
The skills are copied from [Matt Pocock's skills.sh listing](https://skills.sh/mattpocock/skills/grill-me)
and its [upstream repository](https://github.com/mattpocock/skills).
The exact upstream revisions and paths are recorded in [upstream.json](upstream.json).
The upstream [MIT license](LICENSE.mattpocock) is included.
These project-local files are available to the agent from the next conversation
turn through the instructions in the consuming project's root `AGENTS.md`. They are not installed
globally. Update upstream sources in engineering, retaining licenses and revision records, then release a new engineering version. Consumers regenerate their pinned copies.

View File

@@ -1,24 +0,0 @@
# License and attribution
ADR brainstorm is maintained by Wanja Krah and is distributed under the MIT
License. See [LICENSE.wanjakrah](LICENSE.wanjakrah) for the license covering
Wanja Krah's original contributions.
The design-tree approach and related interviewing guidance in `SKILL.md` were
adapted from Matt Pocock's `grilling` skill, invoked by his `grill-me` skill.
This includes tracking dependent decisions, settling prerequisites before
asking dependent questions, investigating accessible facts, and leaving
decisions with the user. The wording and workflow have been adapted for ADR
brainstorming; this is not an unmodified copy of the upstream skill.
Upstream source:
- Repository: https://github.com/mattpocock/skills
- Revision: `59db609eb1e18aaf59b63284d0e52255aff0c63c`
- Paths: `skills/productivity/grilling` and `skills/productivity/grill-me`
- License: MIT, copyright (c) 2026 Matt Pocock
Matt Pocock's full copyright and license notice is preserved in
[LICENSE.mattpocock](LICENSE.mattpocock) for the adapted material. Keep both
license files with redistributed copies or substantial portions of this skill.
The two notices preserve attribution under the same MIT terms; they do not
introduce an additional license restriction or imply upstream endorsement.

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Matt Pocock
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Wanja Krah
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,103 +0,0 @@
---
name: adr-brainstorm
description: "Brainstorm with the user about a new ADR. Use when the user wants to stress-test their thinking, or uses any combination of 'adr' and 'brainstorm' in their trigger phrase."
---
An ADR is an Architecture Decision Record. Such a record contains a decision on the architecture or design for the application landscape the user is maintaining.
If an ADR-folder exists, read the README.md of that folder to get a full understanding of any specific rules and properties of how the user works with ADR's that might not be listed in this Skill.
The goal of the ADR is to provide an architectural solution or a design solution for a given context. Therefor, two to four different approaches must be worked out with their pro's and con's, from which the user should select one as their preferred solution.
Below, the brainstorm session is mapped out in multiple steps. Each step must be completed succesfully, before continuing to the next step. Success means reaching a shared understanding and whatever the step entitles as its success criteria.
If the user, or the relevant ADR, indicates that an ADR is to supersede another ADR, read that ADR first.
## Explore decisions with the user
### Follow a design tree
Map the architectural question as a design tree: each decision may
introduce dependent decisions. Track which decisions are agreed,
open, awaiting factual investigation, or explicitly deferred.
Ask dependent questions only after their prerequisites are settled.
Revisit earlier decisions when new evidence changes their assumptions,
and explain which dependent decisions need reconsideration.
Keep the tree within the ADR's agreed scope. Suggest follow-up work
for independent topics rather than continually expanding the ADR.
A deferred topic must have a clear boundary and must not leave the
main decision ambiguous.
Finish when the user has agreed on the architectural decision and its material tradeoffs. Identify remaining implementation questions as follow-up work. Do not leave questions open that could materially change the chosen approach.
### Establish facts before asking for decisions
Finding accessible facts is the agent's responsibility. Inspect relevant
code, configuration, specifications, ADRs, and authoritative documentation
before asking the user questions those sources can answer.
Distinguish observed facts, assumptions, recommendations, and decisions.
Cite sources for material factual claims and explain uncertainty where
evidence is unavailable or conflicting. Ask the user for requirements,
priorities, constraints, and knowledge unavailable from accessible sources.
Evaluate alternatives against the agreed requirements. Present credible
alternatives fairly, including drawbacks of the recommended approach.
Do not infer that an option is unsuitable merely because the current
implementation uses something else.
### Keep the conversation collaborative
Use short rounds, usually one main question or a few closely related
questions. Number questions when asking more than one. Do not ask every
available question at once if that would hinder discussion.
For decision questions, normally provide a recommended answer and its
reason. Where natural, phrase the question so "yes" clearly accepts that
recommendation. Make its scope explicit; avoid combining independent
decisions into one yes/no question.
Use open questions when discovering requirements or when a recommendation
would prematurely narrow the discussion. Allow disagreement, alternatives,
and back-and-forth exploration before seeking a decision.
### Leave architectural decisions with the user
The agent investigates, explains tradeoffs, challenges assumptions, and
recommends. The user selects the architectural approach and accepts its
material tradeoffs.
Do not treat silence, a factual answer, or agreement on one decision as
approval of other decisions. Preserve earlier agreements without repeatedly
requesting confirmation unless their meaning or supporting facts change.
At meaningful milestones, summarize the agreed decisions and remaining
questions. Before finalizing the ADR, confirm shared understanding of the
complete proposal. Keep recommendations and unresolved assumptions out of
the Decisions section until the user accepts them.
### Guide the process through steps
Coöperate with the user via the following steps. Communicate each step in which step you are, and what the step entails. Use the template adr-template.md as a template for the final ADR.
The _first step_ is to get to a shared understanding of the probived context and the problem area the user is in need for a decision. Success for step one means you can describe the context in one to fifty sentences.
The _second step_ is to get to two to four different architectural solutions or design solutions for the described context. Every solution needs a short description of one to fifty sentences and a list of one to ten pro's and one to ten con's. allow discussion of consequences when they affect the choice. Record them formally in _step four_, but don’t postpone discovering migration costs or operational burdens until after selecting an option.
Success for step two means there are more than one credible solutions with a set of relevant pro's and con's by which a user is able to choose, or not choose that solution for the next step.
The _third step_ is to pick the best solution. Ths user must decide what solution he prefers. The choosen solution will be presented as the ADR decision. A decision may branch into a small tree of related decisions, small meaning one to five levels deep. Make the architectural decision, its scope, and its material tradeoffs unambiguous; record remaining implementation questions as follow-up work. Success of step three means there is a shared understanding of the choosen solution and what it entails.
The _fourth step_ is to make clear what the consequences will be for the application landscape. Success of step four means there is a clear understanding of the changes that should be made to software landscape and the way futurue work should be implemented.
The _fifth step_ is fill out the other properties of the ADR that is required by the template. Succes of step five means the ID of the ADR is unique, the title is short and comprehandible, and gives the user an overall idea of what the ADR is about. If the ADR supersedes another ADR, that is listed as well.
Set the Expiration Date of when the ADR should be reviewd. This should be a relative date, to be made absolute by the user as soon as the user moves the ADR to APPROVED.
Finally, set the status to REFINED. It's up to the user to set the Status to either APPROVED - meaning the ADR is accepted and to be applied for all future work - or REJECTED - meaning the ADR is not accepted and should not be taken in consideration.
When updating the ADR with the result of the brainstorm, make it both easy to glanse through, as to read in full, meaning:
- stick to the template
- keep to the formatting as is custom for markdown-files
- high use of headers and sub-headers
- high use of bullit points, lists, and tables where applicable
- keep sentences short and precise where possible
- create images with ASCII-art where possible

View File

@@ -1,20 +0,0 @@
**ID:** <insert ADR followed by a unique number> \
**Title:** <insert short and descriptive title> \
**Supersedes:** - \
**Status:** NEW \
**Expiration Date:** to be determined
## Context
To be determined
## Decisions
To be determined
## Options considered
To be determined
## Consequences
To be determined
## References
<insert a list of references consulted, along with a timestamp of when it was consulted>

View File

@@ -1,3 +0,0 @@
interface:
display_name: "ADR Brainstorm"
short_description: "Brainstorm with the user on an ADR to get it from NEW to REFINED"

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Matt Pocock
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,89 +0,0 @@
---
name: code-review
description: "Review the changes since a fixed point (commit, branch, tag, or merge-base) along two axes: Standards (does the code follow this repo's documented coding standards?) and Spec (does the code match what the originating issue/spec asked for?). Runs both reviews in parallel sub-agents and reports them side by side. Use when the user wants to review a branch, a PR, work-in-progress changes, or asks to \"review since X\"."
---
Two-axis review of the diff between `HEAD` and a fixed point the user supplies:
- **Standards**: does the code conform to this repo's documented coding standards?
- **Spec**: does the code faithfully implement the originating issue / spec?
Both axes run as **parallel sub-agents** so they don't pollute each other's context, then this skill aggregates their findings. Start every sub-agent with a fresh context window.
The issue tracker should have been provided to you. If not, tell the user to run `/setup-matt-pocock-skills`.
## Process
### 1. Pin the fixed point
Whatever the user said is the fixed point (a commit SHA, branch name, tag, `main`, `HEAD~5`, etc.). If they didn't specify one, ask for it.
Capture the diff command once: `git diff <fixed-point>...HEAD` (three-dot, so the comparison is against the merge-base). Also note the list of commits via `git log <fixed-point>..HEAD --oneline`.
Before going further, confirm the fixed point resolves (`git rev-parse <fixed-point>`) and the diff is non-empty. A bad ref or empty diff should fail here, not inside two parallel sub-agents.
### 2. Identify the spec source
Look for the originating spec, in this order:
1. Issue references in the commit messages (`#123`, `Closes #45`, GitLab `!67`, etc.), fetched via the workflow in the tracker doc.
2. A path the user passed as an argument.
3. A spec file under `docs/`, `specs/`, or `.scratch/` matching the branch name or feature.
4. If nothing is found, ask the user where the spec is. If they say there isn't one, the **Spec** sub-agent will skip and report "no spec available".
### 3. Identify the standards sources
Search the repo for every file that documents how code should be written. When `CODING_STANDARDS.md` or `CONTRIBUTING.md` exists, it must be on the list.
On top of whatever the repo documents, the Standards axis always carries the **smell baseline** below: a fixed set of Fowler code smells (_Refactoring_, ch.3) that applies even when a repo documents nothing. Two rules bind it:
- **The repo overrides.** A documented repo standard always wins; where it endorses something the baseline would flag, suppress the smell.
- **Always a judgement call.** Each smell is a labelled heuristic ("possible Feature Envy"), never a hard violation. Like any standard here, skip anything tooling already enforces.
Each smell reads *what it is* → *how to fix*; match it against the diff:
- **Mysterious Name**: a function, variable, or type whose name doesn't reveal what it does or holds. → rename it; if no honest name comes, the design's murky.
- **Duplicated Code**: the same logic shape appears in more than one hunk or file in the change. → extract the shared shape, call it from both.
- **Feature Envy**: a method that reaches into another object's data more than its own. → move the method onto the data it envies.
- **Data Clumps**: the same few fields or params keep travelling together (a type wanting to be born). → bundle them into one type, pass that.
- **Primitive Obsession**: a primitive or string standing in for a domain concept that deserves its own type. → give the concept its own small type.
- **Repeated Switches**: the same `switch`/`if`-cascade on the same type recurs across the change. → replace with polymorphism, or one map both sites share.
- **Shotgun Surgery**: one logical change forces scattered edits across many files in the diff. → gather what changes together into one module.
- **Divergent Change**: one file or module is edited for several unrelated reasons. → split so each module changes for one reason.
- **Speculative Generality**: abstraction, parameters, or hooks added for needs the spec doesn't have. → delete it; inline back until a real need shows.
- **Message Chains**: long `a.b().c().d()` navigation the caller shouldn't depend on. → hide the walk behind one method on the first object.
- **Middle Man**: a class or function that mostly just delegates onward. → cut it, call the real target direct.
- **Refused Bequest**: a subclass or implementer that ignores or overrides most of what it inherits. → drop the inheritance, use composition.
### 4. Spawn both sub-agents in parallel
Issue both sub-agent calls together, in the foreground, and aggregate the reports they return.
**Standards sub-agent prompt** should include:
- The full diff command and commit list.
- The list of standards-source files you found in step 3, **plus the smell baseline from step 3** pasted in full (the sub-agent has no other access to it).
- The brief: "Report, per file/hunk where relevant, (a) every place the diff violates a documented standard: cite the standard (file + the rule); and (b) any baseline smell you spot: name it and quote the hunk. Distinguish hard violations from judgement calls: documented-standard breaches can be hard, but baseline smells are always judgement calls, and a documented repo standard overrides the baseline. Skip anything tooling enforces. Under 400 words."
**Spec sub-agent prompt** should include:
- The diff command and commit list.
- The path or fetched contents of the spec.
- The brief: "Report: (a) requirements the spec asked for that are missing or partial; (b) behaviour in the diff that wasn't asked for (scope creep); (c) requirements that look implemented but where the implementation looks wrong. Quote the spec line for each finding. Under 400 words."
If the spec is missing, skip the Spec sub-agent and note this in the final report.
### 5. Aggregate
Present the two reports under `## Standards` and `## Spec` headings, verbatim or lightly cleaned. Do **not** merge or rerank findings, because the two axes are deliberately separate (see _Why two axes_).
End with a one-line summary: total findings per axis, and the worst issue _within each axis_ (if any). Don't pick a single winner across axes: that's the reranking the separation exists to prevent.
## Why two axes
A change can pass one axis and fail the other:
- Code that follows every standard but implements the wrong thing → **Standards pass, Spec fail.**
- Code that does exactly what the issue asked but breaks the project's conventions → **Spec pass, Standards fail.**
Reporting them separately stops one axis from masking the other.

View File

@@ -1,3 +0,0 @@
interface:
display_name: "Code Review"
short_description: "Review a diff on standards and spec"

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Matt Pocock
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,7 +0,0 @@
---
name: grill-me
description: A relentless interview to sharpen a plan or design.
disable-model-invocation: true
---
Call the Skill tool with "grilling".

View File

@@ -1,5 +0,0 @@
interface:
display_name: "Grill Me"
short_description: "Sharpen a plan through interview"
policy:
allow_implicit_invocation: false

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Matt Pocock
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,30 +0,0 @@
---
name: grilling
description: Grill the user relentlessly about a plan, decision, or idea. Use when the user wants to stress-test their thinking, or uses any 'grill' trigger phrases.
---
Interview the user relentlessly until you reach a shared understanding. Map this as a **design tree**: every decision branches into the decisions that hang off it.
Work the tree in **rounds**. The **frontier** is every decision whose prerequisites are already settled: the questions you can ask _now_ without guessing at answers you haven't heard yet. Ask the whole frontier in one round: number each question and give your recommended answer. Then wait for the user's answers before the next round.
Format a round like so:
```
❓ **Q1** - **<question title>**: <question body, might be multiple paragraphs, including multiple choices>
➡️ <your recommended answer>
---
❓ **Q2** - **<question title>**: <question body, might be multiple paragraphs, including multiple choices>
➡️ <your recommended answer>
```
Word each question so "yes" accepts your recommended answer.
Each round the user answers reshapes the tree: settled decisions push the frontier outward and unblock questions that depended on them. Recompute the frontier and ask the next round. A question whose answer depends on another question still open in this round belongs to a _later_ round, not this one.
Finding _facts_ is your job, never the user's. When a frontier question needs a fact from the environment (filesystem, tools, etc.), dispatch a sub-agent to find it; don't ask the user for anything you could look up yourself. Don't block on it: a running exploration is an unsettled prerequisite, so only the questions downstream of it wait for the sub-agent to report; ask the rest of the frontier now. The _decisions_ are the user's: put each to them and wait.
The session is done when the frontier is empty: every branch of the design tree visited, nothing left silently assumed. Do not act on it until the user confirms you have reached a shared understanding.

View File

@@ -1,3 +0,0 @@
interface:
display_name: "Grilling"
short_description: "Stress-test thinking a round of questions at a time"

View File

@@ -1,14 +0,0 @@
{
"repository": "https://github.com/mattpocock/skills",
"commit": "59db609eb1e18aaf59b63284d0e52255aff0c63c",
"source": "https://skills.sh/mattpocock/skills/grill-me",
"skills": {
"grill-me": "skills/productivity/grill-me",
"grilling": "skills/productivity/grilling",
"code-review": {
"path": "skills/engineering/code-review",
"commit": "a496f5513d4a4cf889bc48e189e579f4b0bdf6aa",
"source": "https://www.skills.sh/mattpocock/skills/code-review"
}
}
}

View File

@@ -4,7 +4,13 @@ import org.w3c.dom.Element;
/** Exact module-wide line gate; consumes JaCoCo XML without rounding percentages. */ /** Exact module-wide line gate; consumes JaCoCo XML without rounding percentages. */
public class Coverage { public class Coverage {
public static void main(String[] args) throws Exception { /**
* Gates a JaCoCo XML report at 80 percent module line coverage without rounding.
*
* @param args the report path as the sole argument
* @throws Exception if the report cannot be read, has no line counter, or fails the coverage gate
*/
public static void main(final String... args) throws Exception {
final var factory = DocumentBuilderFactory.newInstance(); final var factory = DocumentBuilderFactory.newInstance();
factory.setFeature("http://xml.org/sax/features/external-general-entities", false); factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
@@ -18,8 +24,7 @@ public class Coverage {
final long covered = Long.parseLong(counter.getAttribute("covered")); final long covered = Long.parseLong(counter.getAttribute("covered"));
final long missed = Long.parseLong(counter.getAttribute("missed")); final long missed = Long.parseLong(counter.getAttribute("missed"));
final long total = covered + missed; final long total = covered + missed;
System.out.println( IO.println("Backend line coverage: " + covered + "/" + total + " (" + 100.0 * covered / total + "%)");
"Backend line coverage: " + covered + "/" + total + " (" + 100.0 * covered / total + "%)");
if (total == 0 || 5 * covered < 4 * total) if (total == 0 || 5 * covered < 4 * total)
throw new IllegalStateException("Backend line coverage must reach 80%: " + args[0]); throw new IllegalStateException("Backend line coverage must reach 80%: " + args[0]);
return; return;

View File

@@ -9,7 +9,13 @@ import org.openrewrite.text.*;
/** Source-launcher bootstrap: no project-specific artifact is resolved or installed. */ /** Source-launcher bootstrap: no project-specific artifact is resolved or installed. */
public class Format { public class Format {
public static void main(String[] args) throws Exception { /**
* Checks or atomically formats configured handwritten Java sources with the pinned formatter.
*
* @param args consumer repository root followed by either {@code check} or {@code apply}
* @throws Exception if configuration, parsing or formatting fails, or check mode finds differences
*/
public static void main(final String... args) throws Exception {
Path root = Path.of(args[0]).toAbsolutePath().normalize(); Path root = Path.of(args[0]).toAbsolutePath().normalize();
boolean check = switch (args[1]) { boolean check = switch (args[1]) {
case "check" -> true; case "check" -> true;
@@ -64,7 +70,7 @@ public class Format {
.getChangeset() .getChangeset()
.getAllResults(); .getAllResults();
for (Result result : results) { for (Result result : results) {
System.out.println((check ? "Needs formatting: " : "Formatted: ") IO.println((check ? "Needs formatting: " : "Formatted: ")
+ result.getAfter().getSourcePath()); + result.getAfter().getSourcePath());
} }
if (check && !results.isEmpty()) { if (check && !results.isEmpty()) {
@@ -96,8 +102,7 @@ public class Format {
try (var output = Files.newOutputStream(cacheFile)) { try (var output = Files.newOutputStream(cacheFile)) {
cache.store(output, "Successfully formatted content only"); cache.store(output, "Successfully formatted content only");
} }
System.out.println( IO.println("Palantir " + args[1] + ": " + sources.size() + " Java files, " + results.size() + " differences");
"Palantir " + args[1] + ": " + sources.size() + " Java files, " + results.size() + " differences");
} }
static String digest(String value) throws Exception { static String digest(String value) throws Exception {

View File

@@ -28,7 +28,13 @@ public class Standards {
static final List<Waiver> waivers = new ArrayList<>(); static final List<Waiver> waivers = new ArrayList<>();
public static void main(String[] args) throws Exception { /**
* Checks configured backend sources and writes standards reports without changing maintained code.
*
* @param args the consumer repository root as the sole argument
* @throws Exception if configuration, resolution or parsing fails, or a blocking violation is found
*/
public static void main(final String... args) throws Exception {
findings.clear(); findings.clear();
codeLines.clear(); codeLines.clear();
types.clear(); types.clear();